···1414- Docs: TipTap v3 StarterKit config no longer emits duplicate-extension and Collaboration/UndoRedo-conflict warnings (v0.62.5, #710). PR #409 (the v2→v3 migration for #692) passed `history: false` to StarterKit.configure, but v3 renamed the History extension to UndoRedo — `history: false` is a silent no-op, so StarterKit's UndoRedo stayed active and fought Yjs for undo state. StarterKit v3 also vendors Link and Underline, so our explicit `@tiptap/extension-link` + `@tiptap/extension-underline` imports registered duplicates of those extensions. Updated `src/docs/main.ts` to use `StarterKit.configure({ undoRedo: false, codeBlock: false, link: { openOnClick: false } })` and removed the explicit Link/Underline imports. Dropped the two packages from package.json. Added `tests/tiptap-v3-no-warnings.test.ts` (2 tests: jsdom editor-construction with zero TipTap warnings, plus static scan of src/docs/main.ts for deprecated v2 patterns). Caught live by inspecting the browser console on a deployed doc. (#710)
1515- Calendar: mini-calendar date cells now expose a full spoken date via `aria-label` (v0.62.3, #696). Previously the `.cal-mini-day` buttons contained only the day number as text, so screen readers announced "button, 29" with no month/year/weekday context — unusable for non-sighted navigation. `renderMiniCalendar` now builds an `aria-label` like `"Tuesday, March 29, 2026"` (plus `, today`, `, selected`, and `, N events` suffixes when applicable) using a new shared `formatLongDate(d)` helper in `src/calendar/helpers.ts` that wraps `toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })`. The today cell also carries `aria-current="date"` and the selected cell carries `aria-pressed="true"` so state is conveyed through standard AT semantics rather than class names alone. 4 regression tests in `tests/calendar-mini-day-aria.test.ts` pin the helper output and verify every emitted `.cal-mini-day` button tag carries an `aria-label=` attribute. (#696)
1616- CSP: externalized 3 inline scripts from every page template so the app's `script-src 'self'` CSP stops silently blocking them (v0.62.2, #694). Theme init (FOUC prevention, reads localStorage before paint), theme-toggle click handler, and service-worker update→reload handler now live in `public/theme-init.js`, `public/theme-toggle.js`, and `public/sw-reload.js` respectively, loaded via `<script src="...">` so they satisfy the strict CSP without needing nonces or `unsafe-inline`. Before: the theme toggle button did nothing, dark-mode users saw a flash of light theme on every page load, and users never auto-reloaded when a new version deployed. All 7 HTML templates (landing + 6 editors) had the inline blocks; all 7 are now externalized. Added `tests/csp-no-inline-scripts.test.ts` which scans every template for inline `<script>` blocks and fails if any are reintroduced. Caught live by driving the deployed v0.62.1 app via Playwright MCP. (#694)
1717+- Sheets: the grid is now keyboard-reachable via Tab (v0.62.6, #698). Previously `<table class="sheet-grid">` had no tabindex, so keyboard users could tab through the toolbar and formula bar but never reach the cell grid itself. Added `wireGridFocus(grid)` in `src/sheets/keyboard-handler.ts` which sets `tabindex=0`; added a `.sheet-grid:focus` visible ring in `src/css/app.css`; and updated the Tab handler so Shift+Tab while the grid is focused lets the browser escape focus back to the toolbar instead of being hijacked as selection-navigation. 7 regression tests in `tests/sheets-grid-focus.test.ts`. (#698)
1718- Sheets: first printable keystroke against an empty cell no longer duplicates the character (v0.62.1, #693). The grid's keydown handler for printable chars in `src/sheets/keyboard-handler.ts` entered edit mode and set `editor.value = key` but never called `e.preventDefault()`, so the browser's native keypress/input pipeline on the now-focused cell-editor input also inserted the same character — pressing `5` produced `55`, pressing `1`→`0`→Enter produced `110`. Added the missing `preventDefault()` plus a targeted regression test in `tests/sheets-keyboard-handler.test.ts` (3 new tests: preventDefault invariant, single-char-not-doubled invariant, and the pre-existing Cmd+key-skip invariant). Caught live by driving the deployed app in a real browser via Playwright MCP during TipTap v3 post-ship smoke testing. (#693)
18191920### Changed
···25962596 width: max-content;
25972597}
2598259825992599+/*
26002600+ * #698 — keyboard focus indicator for the grid itself.
26012601+ * The grid table carries tabindex=0 so Tab from the toolbar / formula bar
26022602+ * lands here. The native outline would draw around the entire table body
26032603+ * (visually noisy); the selected cell already has its own teal outline
26042604+ * (`.sheet-grid td.selected`), so the grid-level focus ring is scoped to
26052605+ * keyboard-only activation (data-a11y-focus is set on Tab-press by the
26062606+ * inline script in index.html).
26072607+ */
26082608+.sheet-grid:focus { outline: none; }
26092609+[data-a11y-focus] .sheet-grid:focus-visible {
26102610+ outline: 2px solid var(--color-teal);
26112611+ outline-offset: -2px;
26122612+}
26132613+25992614.sheet-grid th {
26002615 background: var(--color-surface);
26012616 border: none;
+35-1
src/sheets/keyboard-handler.ts
···5353 DEFAULT_ROWS: number;
5454}
55555656+// ── Grid focus wiring ───────────────────────────────────────
5757+5858+/**
5959+ * Make the grid itself a keyboard tab stop so users can Tab into it from
6060+ * the toolbar / formula bar and get a visible focus indicator on the
6161+ * currently-selected cell.
6262+ *
6363+ * Regression: #698 — without `tabindex=0`, `<table class="sheet-grid">` is
6464+ * not focusable, so keyboard users Tab through the toolbar and formula bar
6565+ * but never reach the grid body and get stuck.
6666+ *
6767+ * The existing global keydown handler (see `wireKeyboardHandler`) already
6868+ * routes arrow keys to `moveSelection()` when `document.activeElement` is
6969+ * the grid (it falls through the formula-input / doc-title / find-bar /
7070+ * ai-chat-sidebar guards), so no additional event wiring is needed — only
7171+ * the `tabindex` attribute plus the matching `:focus` ring in app.css.
7272+ *
7373+ * Call once during sheets initialization, before `wireKeyboardHandler`.
7474+ */
7575+export function wireGridFocus(grid: HTMLElement): void {
7676+ grid.setAttribute('tabindex', '0');
7777+}
7878+5679// ── Keyboard listener ───────────────────────────────────────
57805881/**
···133156 deps.setSelectionRange({ startCol: deps.getSelectedCell().col, startRow: 1, endCol: deps.getSelectedCell().col, endRow: maxRow });
134157 deps.updateSelectionVisuals();
135158 }
136136- else if (key === 'Tab') { e.preventDefault(); deps.moveSelection(e.shiftKey ? -1 : 1, 0); }
159159+ else if (key === 'Tab') {
160160+ // #698 — when the grid itself is focused (user Tabbed in from the
161161+ // toolbar / formula bar), Shift+Tab should let the browser move focus
162162+ // BACK out to the previous focusable (toolbar, formula bar) instead
163163+ // of being hijacked as selection-navigation. Without this escape
164164+ // hatch, keyboard users who Tab into the grid can never Tab back out.
165165+ if (e.shiftKey && document.activeElement?.classList?.contains('sheet-grid')) {
166166+ return;
167167+ }
168168+ e.preventDefault();
169169+ deps.moveSelection(e.shiftKey ? -1 : 1, 0);
170170+ }
137171 else if (key === 'Enter') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); }
138172 else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deps.deleteSelectedCells(); }
139173 else if (key === 'F2') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); }
+2-1
src/sheets/main.ts
···2222import { updateFormulaHighlight as _updateFormulaHighlight, updateFormulaRangeHighlights as _updateFormulaRangeHighlights, updateFormulaTooltip, onFormulaInputUpdate as _onFormulaInputUpdate, commitFormulaBar as _commitFormulaBar, wireFormulaBarKeys as _wireFormulaBarKeys, refreshVisibleCells as _refreshVisibleCells } from './formula-bar-ui.js';
2323import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js';
2424import { wireShortcutButton } from './shortcuts-modal.js';
2525-import { wireKeyboardHandler as _wireKeyboardHandler } from './keyboard-handler.js';
2525+import { wireKeyboardHandler as _wireKeyboardHandler, wireGridFocus as _wireGridFocus } from './keyboard-handler.js';
2626import { moveSelection as _moveSelection, extendSelection as _extendSelection, moveSelectionTo as _moveSelectionTo, getDataExtent as _getDataExtent, scrollCellIntoView as _scrollCellIntoView, updateSelectionVisuals as _updateSelectionVisuals, clearPrevSelection as _clearPrevSelection, getCellEl as _getCellEl } from './selection-navigation.js';
2727import { deleteSelectedCells as _deleteSelectedCells, copySelection as _copySelection, pasteRowsAtSelection as _pasteRowsAtSelection, pasteAtSelection as _pasteAtSelection, showPasteSpecialDialog as _showPasteSpecialDialogCO, wirePasteListener as _wirePasteListener } from './clipboard-operations.js';
2828import { wireSaveStatus } from '../lib/save-status-ui.js';
···465465// ── Wire everything ────────────────────────────────────────
466466_wireTouchDoubleTap(_touchEventsDeps());
467467468468+_wireGridFocus(grid);
468469_wireKeyboardHandler({
469470 grid, formulaInput, sheetContainer, provider,
470471 getSelectedCell, setSelectedCell,
+199
tests/sheets-grid-focus.test.ts
···11+/**
22+ * @vitest-environment jsdom
33+ *
44+ * Regression coverage for #698 — Sheets grid must be keyboard-reachable.
55+ *
66+ * Before this fix, `.sheet-grid` had no `tabindex`, so it was unreachable via
77+ * Tab from the toolbar/formula bar. Keyboard users could Tab through the
88+ * toolbar and formula input but never land on the grid body; they got stuck.
99+ *
1010+ * The fix:
1111+ * 1. Set `tabindex="0"` on `.sheet-grid` (see `wireGridFocus` in
1212+ * `src/sheets/keyboard-handler.ts`).
1313+ * 2. When the grid itself is the active element, arrow keys still route to
1414+ * `moveSelection()` (existing handler already treats grid-focus the same
1515+ * as body-focus).
1616+ * 3. Shift+Tab on the focused grid lets the browser move focus back to the
1717+ * previous focusable (toolbar / formula bar) instead of being swallowed
1818+ * as a selection-navigation shortcut.
1919+ */
2020+import { describe, it, expect, beforeEach, vi } from 'vitest';
2121+import {
2222+ wireKeyboardHandler,
2323+ wireGridFocus,
2424+ type KeyboardHandlerDeps,
2525+} from '../src/sheets/keyboard-handler.js';
2626+2727+function makeDeps(overrides: Partial<KeyboardHandlerDeps> = {}): KeyboardHandlerDeps {
2828+ const grid = document.createElement('table');
2929+ grid.className = 'sheet-grid';
3030+ grid.id = 'sheet-grid';
3131+ document.body.appendChild(grid);
3232+ const formulaInput = document.createElement('input');
3333+ formulaInput.className = 'formula-input';
3434+ formulaInput.id = 'formula-input';
3535+ document.body.appendChild(formulaInput);
3636+ return {
3737+ grid,
3838+ formulaInput,
3939+ sheetContainer: null,
4040+ provider: { _saveSnapshot: () => {} },
4141+ getSelectedCell: () => ({ col: 1, row: 1 }),
4242+ setSelectedCell: () => {},
4343+ getSelectionRange: () => null,
4444+ setSelectionRange: () => {},
4545+ getActiveSheet: () => ({ get: () => 100 }),
4646+ getCellData: () => null,
4747+ setCellData: () => {},
4848+ getEditingCell: () => null,
4949+ startEditing: () => {},
5050+ moveSelection: () => {},
5151+ extendSelection: () => {},
5252+ moveSelectionTo: () => {},
5353+ getDataExtent: () => ({ col: 1, row: 1 }),
5454+ updateSelectionVisuals: () => {},
5555+ updateStatusBar: () => {},
5656+ copySelection: () => {},
5757+ deleteSelectedCells: () => {},
5858+ showPasteSpecialDialog: () => {},
5959+ applyStyleToSelection: () => {},
6060+ clearFormattingSelection: () => {},
6161+ updateUnderlineButtonState: () => {},
6262+ updateStrikethroughButtonState: () => {},
6363+ undoManager: null,
6464+ evalCache: { clear: () => {} },
6565+ clearSpillMaps: () => {},
6666+ invalidateRecalcEngine: () => {},
6767+ refreshVisibleCells: () => {},
6868+ renderGrid: () => {},
6969+ hideSelectedRows: () => {},
7070+ hideSelectedCols: () => {},
7171+ unhideAdjacentRows: () => {},
7272+ unhideAdjacentCols: () => {},
7373+ toggleFilterMode: () => {},
7474+ printSheet: () => {},
7575+ showFindReplaceBar: () => {},
7676+ DEFAULT_COLS: 26,
7777+ DEFAULT_ROWS: 100,
7878+ ...overrides,
7979+ };
8080+}
8181+8282+describe('sheets grid — keyboard focusability (#698)', () => {
8383+ beforeEach(() => {
8484+ document.body.innerHTML = '';
8585+ });
8686+8787+ it('wireGridFocus sets tabindex=0 on the grid so it is reachable via Tab', () => {
8888+ const deps = makeDeps();
8989+ expect(deps.grid.getAttribute('tabindex')).toBeNull();
9090+9191+ wireGridFocus(deps.grid);
9292+9393+ expect(deps.grid.getAttribute('tabindex')).toBe('0');
9494+ });
9595+9696+ it('focusing the grid directly makes document.activeElement the grid', () => {
9797+ const deps = makeDeps();
9898+ wireGridFocus(deps.grid);
9999+100100+ deps.grid.focus();
101101+102102+ // jsdom honors tabindex=0 for programmatic focus — this mirrors what
103103+ // happens when a keyboard user Tabs from the formula bar into the grid.
104104+ expect(document.activeElement).toBe(deps.grid);
105105+ });
106106+107107+ it('arrow keys route to moveSelection when the grid is the focused element', () => {
108108+ const moveSelection = vi.fn();
109109+ const deps = makeDeps({ moveSelection });
110110+ wireGridFocus(deps.grid);
111111+ wireKeyboardHandler(deps);
112112+113113+ deps.grid.focus();
114114+ expect(document.activeElement).toBe(deps.grid);
115115+116116+ document.dispatchEvent(
117117+ new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }),
118118+ );
119119+120120+ // Moves one column right: dCol=+1, dRow=0 → A1 → B1
121121+ expect(moveSelection).toHaveBeenCalledWith(1, 0);
122122+ });
123123+124124+ it('ArrowDown on focused grid moves selection down one row (A1 → A2)', () => {
125125+ const moveSelection = vi.fn();
126126+ const deps = makeDeps({ moveSelection });
127127+ wireGridFocus(deps.grid);
128128+ wireKeyboardHandler(deps);
129129+130130+ deps.grid.focus();
131131+ document.dispatchEvent(
132132+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
133133+ );
134134+135135+ expect(moveSelection).toHaveBeenCalledWith(0, 1);
136136+ });
137137+138138+ it('Shift+Tab on focused grid does NOT preventDefault, so focus can escape to the toolbar/formula bar', () => {
139139+ const moveSelection = vi.fn();
140140+ const deps = makeDeps({ moveSelection });
141141+ wireGridFocus(deps.grid);
142142+ wireKeyboardHandler(deps);
143143+144144+ deps.grid.focus();
145145+ const ev = new KeyboardEvent('keydown', {
146146+ key: 'Tab',
147147+ shiftKey: true,
148148+ bubbles: true,
149149+ cancelable: true,
150150+ });
151151+ const pd = vi.spyOn(ev, 'preventDefault');
152152+ document.dispatchEvent(ev);
153153+154154+ expect(pd).not.toHaveBeenCalled();
155155+ // Shift+Tab should not be hijacked to move selection backward when the
156156+ // grid itself is focused — it should let the browser restore focus to the
157157+ // toolbar / formula bar.
158158+ expect(moveSelection).not.toHaveBeenCalled();
159159+ });
160160+161161+ it('Tab (without shift) on focused grid still moves the selection right (spec-preserved behavior)', () => {
162162+ const moveSelection = vi.fn();
163163+ const deps = makeDeps({ moveSelection });
164164+ wireGridFocus(deps.grid);
165165+ wireKeyboardHandler(deps);
166166+167167+ deps.grid.focus();
168168+ const ev = new KeyboardEvent('keydown', {
169169+ key: 'Tab',
170170+ bubbles: true,
171171+ cancelable: true,
172172+ });
173173+ const pd = vi.spyOn(ev, 'preventDefault');
174174+ document.dispatchEvent(ev);
175175+176176+ expect(pd).toHaveBeenCalled();
177177+ expect(moveSelection).toHaveBeenCalledWith(1, 0);
178178+ });
179179+180180+ it('focusing from the formula input then pressing ArrowRight on the grid drives selection', () => {
181181+ // Simulates: user Tabs from formula bar → grid, then presses ArrowRight.
182182+ const moveSelection = vi.fn();
183183+ const deps = makeDeps({ moveSelection });
184184+ wireGridFocus(deps.grid);
185185+ wireKeyboardHandler(deps);
186186+187187+ deps.formulaInput.focus();
188188+ expect(document.activeElement).toBe(deps.formulaInput);
189189+190190+ deps.grid.focus();
191191+ expect(document.activeElement).toBe(deps.grid);
192192+193193+ document.dispatchEvent(
194194+ new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }),
195195+ );
196196+197197+ expect(moveSelection).toHaveBeenCalledWith(1, 0);
198198+ });
199199+});