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

Configure Feed

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

fix(calendar): aria-label for mini-day buttons (#696)

Closes #696

scott e982c015 348217de

+99 -4
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Fixed 11 + - 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) 11 12 - 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) 12 13 - 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) 13 14
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.2", 3 + "version": "0.62.3", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+15
src/calendar/helpers.ts
··· 71 71 return `${y}-${m}-${day}`; 72 72 } 73 73 74 + /** 75 + * Format a date as a full spoken string suitable for screen-reader 76 + * `aria-label`s, e.g. "Tuesday, March 29, 2026". Uses en-US so the weekday 77 + * and month names are always spelled out — shorter locale formats can elide 78 + * the weekday, which is exactly the context screen-reader users need. 79 + */ 80 + export function formatLongDate(d: Date): string { 81 + return d.toLocaleDateString('en-US', { 82 + weekday: 'long', 83 + year: 'numeric', 84 + month: 'long', 85 + day: 'numeric', 86 + }); 87 + } 88 + 74 89 export function parseEventDate(dateStr: string): Date { 75 90 const parts = dateStr.split('-').map(Number); 76 91 const y = parts[0] ?? 0;
+18 -3
src/calendar/main.ts
··· 31 31 MONTHS_SHORT, 32 32 REMINDER_PRESETS, 33 33 formatDate, 34 + formatLongDate, 34 35 parseEventDate, 35 36 isSameDay, 36 37 isToday, ··· 1651 1652 } 1652 1653 1653 1654 const dateStr = formatDate(cellDate); 1654 - const todayCls = isToday(cellDate) ? ' cal-mini-today' : ''; 1655 + const isTodayCell = isToday(cellDate); 1656 + const isSelectedCell = isSameDay(cellDate, state.currentDate); 1657 + const todayCls = isTodayCell ? ' cal-mini-today' : ''; 1655 1658 const otherCls = otherMonth ? ' cal-mini-other' : ''; 1656 - const selectedCls = isSameDay(cellDate, state.currentDate) ? ' cal-mini-selected' : ''; 1659 + const selectedCls = isSelectedCell ? ' cal-mini-selected' : ''; 1657 1660 const dayEvents = eventsOnDate(dateStr); 1658 1661 const hasDot = dayEvents.length > 0 ? ' cal-mini-has-events' : ''; 1659 1662 1660 - html += `<button class="cal-mini-day${todayCls}${otherCls}${selectedCls}${hasDot}" data-mini-date="${dateStr}" type="button">`; 1663 + // Full spoken date for screen readers, with state/event context appended 1664 + // (#696). Example: "Tuesday, March 29, 2026, today, 2 events". 1665 + const labelParts = [formatLongDate(cellDate)]; 1666 + if (isTodayCell) labelParts.push('today'); 1667 + if (isSelectedCell) labelParts.push('selected'); 1668 + if (dayEvents.length > 0) { 1669 + labelParts.push(`${dayEvents.length} event${dayEvents.length === 1 ? '' : 's'}`); 1670 + } 1671 + const ariaLabel = labelParts.join(', '); 1672 + const ariaCurrent = isTodayCell ? ' aria-current="date"' : ''; 1673 + const ariaPressed = isSelectedCell ? ' aria-pressed="true"' : ''; 1674 + 1675 + html += `<button class="cal-mini-day${todayCls}${otherCls}${selectedCls}${hasDot}" data-mini-date="${dateStr}" type="button" aria-label="${ariaLabel}"${ariaCurrent}${ariaPressed}>`; 1661 1676 html += `${displayNum}`; 1662 1677 if (dayEvents.length > 0) { 1663 1678 html += '<span class="cal-mini-dot"></span>';
+64
tests/calendar-mini-day-aria.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { formatLongDate } from '../src/calendar/helpers.js'; 3 + 4 + /** 5 + * Regression test for chainlink #696: 6 + * Mini-calendar day cells (`.cal-mini-day` buttons) contained only a day 7 + * number with no aria-label, so screen readers said "button, 29" with no 8 + * date context. 9 + * 10 + * The fix exposes `formatLongDate(d)` which produces the spoken string 11 + * attached to each button via aria-label, and `renderMiniCalendar` was 12 + * updated to emit `aria-label="..."` on every `.cal-mini-day` button. 13 + */ 14 + 15 + describe('Calendar mini-day aria labels (#696)', () => { 16 + it('formatLongDate returns a full spoken date including weekday, month, day, and year', () => { 17 + const d = new Date(2026, 2, 29); // Tuesday... actually March 29, 2026 is a Sunday. 18 + const label = formatLongDate(d); 19 + expect(label).toMatch(/2026/); 20 + expect(label).toMatch(/March/); 21 + expect(label).toMatch(/29/); 22 + // Weekday should be a full word, not abbreviation 23 + expect(label).toMatch(/Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/); 24 + }); 25 + 26 + it('formatLongDate includes the correct weekday for a known date', () => { 27 + // 2026-04-17 is a Friday 28 + const d = new Date(2026, 3, 17); 29 + const label = formatLongDate(d); 30 + expect(label).toContain('Friday'); 31 + expect(label).toContain('April'); 32 + expect(label).toContain('17'); 33 + expect(label).toContain('2026'); 34 + }); 35 + 36 + it('formatLongDate produces distinct labels for adjacent days', () => { 37 + const d1 = new Date(2026, 3, 17); 38 + const d2 = new Date(2026, 3, 18); 39 + expect(formatLongDate(d1)).not.toEqual(formatLongDate(d2)); 40 + }); 41 + 42 + it('calendar renderMiniCalendar attaches aria-label to every .cal-mini-day button', async () => { 43 + // Static source-check: grep the source for the aria-label attribute on the 44 + // mini-day button template. We don't boot the full DOM here because 45 + // renderMiniCalendar has module-level state; the behavioral contract is 46 + // that the button HTML string includes `aria-label="..."`. 47 + const { readFileSync } = await import('node:fs'); 48 + const { resolve } = await import('node:path'); 49 + const src = readFileSync( 50 + resolve(__dirname, '..', 'src/calendar/main.ts'), 51 + 'utf-8', 52 + ); 53 + 54 + // Find the line(s) emitting `<button class="cal-mini-day...` and confirm 55 + // an aria-label attribute is present on that button tag. 56 + const miniDayButtonLines = src 57 + .split('\n') 58 + .filter((line) => line.includes('cal-mini-day') && line.includes('<button')); 59 + expect(miniDayButtonLines.length).toBeGreaterThan(0); 60 + for (const line of miniDayButtonLines) { 61 + expect(line).toContain('aria-label='); 62 + } 63 + }); 64 + });