···88## [Unreleased]
991010### Fixed
1111+- 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)
1112- 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)
1213- 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)
1314
···7171 return `${y}-${m}-${day}`;
7272}
73737474+/**
7575+ * Format a date as a full spoken string suitable for screen-reader
7676+ * `aria-label`s, e.g. "Tuesday, March 29, 2026". Uses en-US so the weekday
7777+ * and month names are always spelled out — shorter locale formats can elide
7878+ * the weekday, which is exactly the context screen-reader users need.
7979+ */
8080+export function formatLongDate(d: Date): string {
8181+ return d.toLocaleDateString('en-US', {
8282+ weekday: 'long',
8383+ year: 'numeric',
8484+ month: 'long',
8585+ day: 'numeric',
8686+ });
8787+}
8888+7489export function parseEventDate(dateStr: string): Date {
7590 const parts = dateStr.split('-').map(Number);
7691 const y = parts[0] ?? 0;
···11+import { describe, it, expect } from 'vitest';
22+import { formatLongDate } from '../src/calendar/helpers.js';
33+44+/**
55+ * Regression test for chainlink #696:
66+ * Mini-calendar day cells (`.cal-mini-day` buttons) contained only a day
77+ * number with no aria-label, so screen readers said "button, 29" with no
88+ * date context.
99+ *
1010+ * The fix exposes `formatLongDate(d)` which produces the spoken string
1111+ * attached to each button via aria-label, and `renderMiniCalendar` was
1212+ * updated to emit `aria-label="..."` on every `.cal-mini-day` button.
1313+ */
1414+1515+describe('Calendar mini-day aria labels (#696)', () => {
1616+ it('formatLongDate returns a full spoken date including weekday, month, day, and year', () => {
1717+ const d = new Date(2026, 2, 29); // Tuesday... actually March 29, 2026 is a Sunday.
1818+ const label = formatLongDate(d);
1919+ expect(label).toMatch(/2026/);
2020+ expect(label).toMatch(/March/);
2121+ expect(label).toMatch(/29/);
2222+ // Weekday should be a full word, not abbreviation
2323+ expect(label).toMatch(/Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/);
2424+ });
2525+2626+ it('formatLongDate includes the correct weekday for a known date', () => {
2727+ // 2026-04-17 is a Friday
2828+ const d = new Date(2026, 3, 17);
2929+ const label = formatLongDate(d);
3030+ expect(label).toContain('Friday');
3131+ expect(label).toContain('April');
3232+ expect(label).toContain('17');
3333+ expect(label).toContain('2026');
3434+ });
3535+3636+ it('formatLongDate produces distinct labels for adjacent days', () => {
3737+ const d1 = new Date(2026, 3, 17);
3838+ const d2 = new Date(2026, 3, 18);
3939+ expect(formatLongDate(d1)).not.toEqual(formatLongDate(d2));
4040+ });
4141+4242+ it('calendar renderMiniCalendar attaches aria-label to every .cal-mini-day button', async () => {
4343+ // Static source-check: grep the source for the aria-label attribute on the
4444+ // mini-day button template. We don't boot the full DOM here because
4545+ // renderMiniCalendar has module-level state; the behavioral contract is
4646+ // that the button HTML string includes `aria-label="..."`.
4747+ const { readFileSync } = await import('node:fs');
4848+ const { resolve } = await import('node:path');
4949+ const src = readFileSync(
5050+ resolve(__dirname, '..', 'src/calendar/main.ts'),
5151+ 'utf-8',
5252+ );
5353+5454+ // Find the line(s) emitting `<button class="cal-mini-day...` and confirm
5555+ // an aria-label attribute is present on that button tag.
5656+ const miniDayButtonLines = src
5757+ .split('\n')
5858+ .filter((line) => line.includes('cal-mini-day') && line.includes('<button'));
5959+ expect(miniDayButtonLines.length).toBeGreaterThan(0);
6060+ for (const line of miniDayButtonLines) {
6161+ expect(line).toContain('aria-label=');
6262+ }
6363+ });
6464+});