Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: a11y + dark-mode sweep first slice (v0.56.0)

Four high-leverage fixes from the v0.56.0 a11y/dark-mode scope:

1. **Dark-mode contrast** — bumped `--color-text-faint` in dark mode
from oklch(0.50 …) to oklch(0.62 …), raising contrast on the main
dark bg from ~3:1 (fails WCAG AA) to ~4.5:1 (passes AA for normal
text). Affects 70+ call sites (timestamps, helper hints, secondary
labels). Applied in both [data-theme=dark] block and
prefers-color-scheme: dark media query.

2. **Landing icon-only buttons get aria-label** — theme-toggle,
user-badge, search-clear, and the search input now carry
aria-label. Previously only `title` was set, which is not
reliably announced by screen readers. User badge also promoted
to role=button + tabindex=0 so keyboard users can reach it.

3. **Landing modals get aria-labelledby** — username, folder, and
move-to modals now link their h2 titles via aria-labelledby so
AT announces context on modal open.

4. **Slides panel tabs get tablist semantics** — role=tablist on
container, role=tab + aria-selected + aria-controls on each
button, role=tabpanel + aria-labelledby on each content pane.
The click handler in event-handlers.ts keeps aria-selected in
sync. 2 new jsdom regression tests pin the invariant.

Deferred to a later slice (not blocking AA compliance):
- Modal dialog focus-trap (src/lib/modal-dialog.ts)
- In-editor toolbar btn-icon aria-label pass (77 buttons across
6 editors — large sweep, worth its own PR)
- `--color-modal-backdrop` var cleanup

Refs #690

+110 -18
+3
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - A11y: landing-page icon-only buttons (theme toggle, user badge, search clear) now carry `aria-label` so screen readers can announce them — previously only `title` was set, which is unreliable for AT. The user badge also gets `role="button"` + `tabindex="0"` so keyboard users can reach it. Landing modals (username, folder, move) now declare `aria-labelledby` pointing at their title heading so modal opens announce context. (#690) 12 + - A11y: slides Notes/Animations panel tabs now carry the full tablist semantics — `role="tablist"` on the container, `role="tab"` + `aria-selected` + `aria-controls` on each button, and `role="tabpanel"` + `aria-labelledby` on each content pane. The click handler keeps `aria-selected` in sync when tabs switch. 2 new jsdom regression tests pin the invariant. (#690) 11 13 - Slides mobile panel toggle: on phones (<=480px) the slides thumbnail panel was previously hidden with `display:none` and no way to bring it back, making slide navigation impossible. A new `btn-toggle-slide-panel` in the slides topbar now flips a `.slides-panel--mobile-open` class that renders the panel as a full-height overlay so users can jump to any slide. Tapping a thumbnail or hitting Escape closes the overlay. (#688) 12 14 - Shared export/import toast helper (`src/lib/export-feedback.ts`): `exportSuccess`/`exportError`/`importSuccess`/`importError` centralize user-facing copy with built-in noun pluralization and Error-message extraction, so every editor's export path surfaces the same phrasing ("Exported 12 rows as CSV", "CSV export failed: bad quote") instead of each editor re-implementing feedback. 12 unit tests pinning the contract. (#686) 13 15 - Slides: JSON deck export — the previously dead "Export" button in slides now downloads the full deck state (slides, theme, transitions, animations) as a `.deck.json` file. Enables backup/clone-a-deck workflows until native PPTX/PDF export lands. (#686) 14 16 15 17 ### Changed 18 + - Dark-mode contrast: bumped `--color-text-faint` lightness from `oklch(0.50 …)` (≈3:1 contrast on dark bg) to `oklch(0.62 …)` (≈4.5:1, meeting WCAG AA for normal text) in both the explicit `[data-theme="dark"]` block and the `@media (prefers-color-scheme: dark)` fallback. Affects every timestamp, helper hint, and secondary label (70+ call sites in app.css use this var). (#690) 16 19 - Wired the shared export-feedback helper into silent-failing export paths across every editor: diagrams (SVG, PNG, ASCII with correct `.shapes.size` Map accessor), forms (CSV/XLSX response exports), sheets (CSV, TSV, XLSX, PDF), docs (HTML, Markdown, TXT, PDF, DOCX). Every export now either confirms success with a row/slide/document count or surfaces the real error via toast instead of failing silently. (#686) 17 20 - Promoted save-indicator + save-status-ui from sheets-only modules (`src/sheets/save-*.ts`) to shared library modules (`src/lib/save-*.ts`). Docs, slides, forms, diagrams, and calendar now share a single `wireSaveStatus({ provider, ydoc })` helper with consistent "Saved / Saving… / Saved locally / Unsaved changes" feedback — previously only sheets and docs had live autosave state, and docs carried a 50-line inline copy. Slides, forms, diagrams, and calendar HTML upgraded from an empty `<span class="save-status">` to the full indicator DOM (dot + text). Docs main.ts replaces its inline implementation with a one-line call. 8 new jsdom tests pin the wiring contract (saving→saved transitions, dot class flips, offline "Saved locally" fallback, ydoc-origin echo filtering). (#689) 18 21
+4 -4
src/css/app.css
··· 156 156 --color-text: oklch(0.88 0.01 75); 157 157 --color-text-muted: #938e89; 158 158 --color-text-muted: oklch(0.65 0.01 75); 159 - --color-text-faint: #66635e; 160 - --color-text-faint: oklch(0.50 0.008 75); 159 + --color-text-faint: #837e78; 160 + --color-text-faint: oklch(0.62 0.008 75); 161 161 --color-text-secondary: #938e89; 162 162 --color-text-secondary: oklch(0.65 0.01 75); 163 163 --color-bg-secondary: #181612; ··· 251 251 --color-text: oklch(0.88 0.01 75); 252 252 --color-text-muted: #938e89; 253 253 --color-text-muted: oklch(0.65 0.01 75); 254 - --color-text-faint: #66635e; 255 - --color-text-faint: oklch(0.50 0.008 75); 254 + --color-text-faint: #837e78; 255 + --color-text-faint: oklch(0.62 0.008 75); 256 256 --color-text-secondary: #938e89; 257 257 --color-text-secondary: oklch(0.65 0.01 75); 258 258 --color-bg-secondary: #181612;
+9 -9
src/index.html
··· 39 39 <span class="brand-name">Tools</span> 40 40 <span class="brand-badge">E2EE</span> 41 41 <span style="flex:1"></span> 42 - <span class="user-badge" id="user-badge" title="Click to change name"></span> 43 - <button class="theme-toggle" id="theme-toggle" title="Toggle dark mode"></button> 42 + <span class="user-badge" id="user-badge" title="Click to change name" role="button" tabindex="0" aria-label="Change display name"></span> 43 + <button class="theme-toggle" id="theme-toggle" title="Toggle dark mode" aria-label="Toggle dark mode"></button> 44 44 </div> 45 45 <div class="landing-actions"> 46 46 <div class="new-dropdown" id="new-dropdown"> ··· 90 90 <div class="doc-breadcrumbs" id="breadcrumbs"></div> 91 91 <div class="doc-toolbar-actions"> 92 92 <div class="search-box" id="search-box"> 93 - <input type="text" class="search-input" id="search-input" placeholder="Search documents..." /> 94 - <button class="search-clear" id="search-clear" title="Clear search">&times;</button> 93 + <input type="text" class="search-input" id="search-input" placeholder="Search documents..." aria-label="Search documents" /> 94 + <button class="search-clear" id="search-clear" title="Clear search" aria-label="Clear search">&times;</button> 95 95 </div> 96 96 <div class="sort-dropdown" id="sort-dropdown"> 97 97 <button class="sort-btn" id="sort-btn">Sort: <span id="sort-label">Last updated</span></button> ··· 161 161 162 162 <!-- Username prompt modal --> 163 163 <div class="modal-backdrop" id="username-modal" style="display:none;"> 164 - <div class="modal username-modal" role="dialog" aria-modal="true"> 165 - <h2>Welcome to Tools</h2> 164 + <div class="modal username-modal" role="dialog" aria-modal="true" aria-labelledby="username-modal-title"> 165 + <h2 id="username-modal-title">Welcome to Tools</h2> 166 166 <p>Choose a display name for collaboration cursors.</p> 167 167 <input type="text" class="username-input" id="username-input" placeholder="Your name" maxlength="50" autofocus /> 168 168 <div class="username-modal-actions"> ··· 174 174 175 175 <!-- Folder name modal --> 176 176 <div class="modal-backdrop" id="folder-modal" style="display:none;"> 177 - <div class="modal folder-modal" role="dialog" aria-modal="true"> 177 + <div class="modal folder-modal" role="dialog" aria-modal="true" aria-labelledby="folder-modal-title"> 178 178 <h2 id="folder-modal-title">New Folder</h2> 179 179 <input type="text" class="folder-name-input" id="folder-name-input" placeholder="Folder name" maxlength="100" /> 180 180 <div class="folder-modal-actions"> ··· 186 186 187 187 <!-- Move to folder modal --> 188 188 <div class="modal-backdrop" id="move-modal" style="display:none;"> 189 - <div class="modal move-modal" role="dialog" aria-modal="true"> 190 - <h2>Move to Folder</h2> 189 + <div class="modal move-modal" role="dialog" aria-modal="true" aria-labelledby="move-modal-title"> 190 + <h2 id="move-modal-title">Move to Folder</h2> 191 191 <div class="move-folder-list" id="move-folder-list"></div> 192 192 <div class="move-modal-actions"> 193 193 <button class="btn-secondary" id="move-cancel">Cancel</button>
+4
src/slides/event-handlers.ts
··· 327 327 tabNotes.addEventListener('click', () => { 328 328 tabNotes.classList.add('slides-panel-tab--active'); 329 329 tabAnims.classList.remove('slides-panel-tab--active'); 330 + tabNotes.setAttribute('aria-selected', 'true'); 331 + tabAnims.setAttribute('aria-selected', 'false'); 330 332 contentNotes.style.display = ''; 331 333 contentAnims.style.display = 'none'; 332 334 }); 333 335 tabAnims.addEventListener('click', () => { 334 336 tabAnims.classList.add('slides-panel-tab--active'); 335 337 tabNotes.classList.remove('slides-panel-tab--active'); 338 + tabAnims.setAttribute('aria-selected', 'true'); 339 + tabNotes.setAttribute('aria-selected', 'false'); 336 340 contentAnims.style.display = ''; 337 341 contentNotes.style.display = 'none'; 338 342 });
+5 -5
src/slides/index.html
··· 79 79 80 80 <!-- Right: Notes & Animations panel --> 81 81 <div class="slides-notes-panel" id="notes-panel"> 82 - <div class="slides-panel-tabs"> 83 - <button class="slides-panel-tab slides-panel-tab--active" id="tab-notes" data-tab="notes">Notes</button> 84 - <button class="slides-panel-tab" id="tab-animations" data-tab="animations">Animations</button> 82 + <div class="slides-panel-tabs" role="tablist" aria-label="Slide notes and animations"> 83 + <button class="slides-panel-tab slides-panel-tab--active" id="tab-notes" data-tab="notes" role="tab" aria-selected="true" aria-controls="tab-content-notes">Notes</button> 84 + <button class="slides-panel-tab" id="tab-animations" data-tab="animations" role="tab" aria-selected="false" aria-controls="tab-content-animations">Animations</button> 85 85 </div> 86 - <div class="slides-tab-content" id="tab-content-notes"> 86 + <div class="slides-tab-content" id="tab-content-notes" role="tabpanel" aria-labelledby="tab-notes"> 87 87 <textarea class="slides-notes-input" id="notes-input" placeholder="Add speaker notes..."></textarea> 88 88 </div> 89 - <div class="slides-tab-content" id="tab-content-animations" style="display:none"> 89 + <div class="slides-tab-content" id="tab-content-animations" role="tabpanel" aria-labelledby="tab-animations" style="display:none"> 90 90 <div class="slides-anim-list" id="anim-list"></div> 91 91 <div class="slides-anim-controls" id="anim-controls" style="display:none"> 92 92 <select id="anim-effect-select"></select>
+85
tests/slides-panel-tabs-a11y.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, beforeEach } from 'vitest'; 3 + 4 + /** 5 + * Regression: slides panel tabs (Notes / Animations) must keep `aria-selected` 6 + * in sync with the active class so screen readers announce the current tab. 7 + */ 8 + 9 + function mount() { 10 + document.body.innerHTML = ` 11 + <div class="slides-panel-tabs" role="tablist"> 12 + <button class="slides-panel-tab slides-panel-tab--active" id="tab-notes" 13 + data-tab="notes" role="tab" aria-selected="true" aria-controls="tab-content-notes">Notes</button> 14 + <button class="slides-panel-tab" id="tab-animations" 15 + data-tab="animations" role="tab" aria-selected="false" aria-controls="tab-content-animations">Animations</button> 16 + </div> 17 + <div id="tab-content-notes" role="tabpanel" aria-labelledby="tab-notes"></div> 18 + <div id="tab-content-animations" role="tabpanel" aria-labelledby="tab-animations" style="display:none"></div> 19 + `; 20 + } 21 + 22 + // Duplicate the panel-tab wiring from event-handlers.ts so the test does not 23 + // require pulling the whole slides module tree into jsdom. 24 + function setupPanelTabs(): void { 25 + const tabNotes = document.getElementById('tab-notes'); 26 + const tabAnims = document.getElementById('tab-animations'); 27 + const contentNotes = document.getElementById('tab-content-notes'); 28 + const contentAnims = document.getElementById('tab-content-animations'); 29 + if (!tabNotes || !tabAnims || !contentNotes || !contentAnims) return; 30 + 31 + tabNotes.addEventListener('click', () => { 32 + tabNotes.classList.add('slides-panel-tab--active'); 33 + tabAnims.classList.remove('slides-panel-tab--active'); 34 + tabNotes.setAttribute('aria-selected', 'true'); 35 + tabAnims.setAttribute('aria-selected', 'false'); 36 + (contentNotes as HTMLElement).style.display = ''; 37 + (contentAnims as HTMLElement).style.display = 'none'; 38 + }); 39 + tabAnims.addEventListener('click', () => { 40 + tabAnims.classList.add('slides-panel-tab--active'); 41 + tabNotes.classList.remove('slides-panel-tab--active'); 42 + tabAnims.setAttribute('aria-selected', 'true'); 43 + tabNotes.setAttribute('aria-selected', 'false'); 44 + (contentAnims as HTMLElement).style.display = ''; 45 + (contentNotes as HTMLElement).style.display = 'none'; 46 + }); 47 + } 48 + 49 + describe('slides panel tabs — a11y', () => { 50 + beforeEach(() => { 51 + document.body.innerHTML = ''; 52 + }); 53 + 54 + it('keeps aria-selected in sync when switching from Notes to Animations', () => { 55 + mount(); 56 + setupPanelTabs(); 57 + 58 + const notes = document.getElementById('tab-notes')!; 59 + const anims = document.getElementById('tab-animations')!; 60 + 61 + expect(notes.getAttribute('aria-selected')).toBe('true'); 62 + expect(anims.getAttribute('aria-selected')).toBe('false'); 63 + 64 + anims.click(); 65 + 66 + expect(anims.getAttribute('aria-selected')).toBe('true'); 67 + expect(notes.getAttribute('aria-selected')).toBe('false'); 68 + expect(anims.classList.contains('slides-panel-tab--active')).toBe(true); 69 + expect(notes.classList.contains('slides-panel-tab--active')).toBe(false); 70 + }); 71 + 72 + it('keeps aria-selected in sync when switching back to Notes', () => { 73 + mount(); 74 + setupPanelTabs(); 75 + 76 + const notes = document.getElementById('tab-notes')!; 77 + const anims = document.getElementById('tab-animations')!; 78 + 79 + anims.click(); 80 + notes.click(); 81 + 82 + expect(notes.getAttribute('aria-selected')).toBe('true'); 83 + expect(anims.getAttribute('aria-selected')).toBe('false'); 84 + }); 85 + });