From b07e57738cefa92bba47b2e34e7eec1819a23fee Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:17:15 +0900 Subject: [PATCH 1/8] Accessibility: fail the unit suite when a control lacks a title The repo rule gives every interactive control a title, and nothing checked it: 248 controls had none. The test parses every component with the TypeScript compiler and lists each button, link, form field, summary and role-carrying control without a title attribute or a spread that could carry one. The map, visits and search components are held out while their rewrite lands. --- .../tests/unit/controlTitles.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/postcards/tests/unit/controlTitles.spec.ts diff --git a/apps/postcards/tests/unit/controlTitles.spec.ts b/apps/postcards/tests/unit/controlTitles.spec.ts new file mode 100644 index 0000000..34ad956 --- /dev/null +++ b/apps/postcards/tests/unit/controlTitles.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import ts from "typescript"; + +// Vitest runs from apps/postcards (jsdom rewrites import.meta.url, so cwd it is). +const ROOT = process.cwd(); + +// Roles that make any element a control someone operates. +const CONTROL_ROLES = new Set(["button", "tab", "switch", "checkbox", "menuitem", "radio"]); + +// Files whose controls get their titles with the rewrite in flight there, +// pending the ux-flags-map merge. +const PENDING = [ + "src/app/App.tsx", + "src/features/map/", + "src/features/visits/", + "src/features/travel/PlacePicker.tsx", +]; + +function tsxFiles(dir: string, acc: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) tsxFiles(path, acc); + else if (path.endsWith(".tsx")) acc.push(path); + } + return acc; +} + +/** The literal string an attribute holds, true for a bare one, undefined otherwise. */ +function literal(attr: ts.JsxAttribute): string | true | undefined { + const init = attr.initializer; + if (!init) return true; + if (ts.isStringLiteral(init)) return init.text; + if (ts.isJsxExpression(init) && init.expression && ts.isStringLiteralLike(init.expression)) { + return init.expression.text; + } + return undefined; +} + +/** Each control in the file lacking a `title`, as `path:line `. */ +function untitled(path: string): string[] { + const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hits: string[] = []; + const visit = (node: ts.Node) => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tag = node.tagName.getText(source); + const attrs = new Map(); + let spread = false; + for (const prop of node.attributes.properties) { + if (ts.isJsxSpreadAttribute(prop)) spread = true; + else attrs.set(prop.name.getText(source), literal(prop)); + } + const role = attrs.get("role"); + const control = + tag === "button" || + tag === "select" || + tag === "textarea" || + tag === "summary" || + (tag === "a" && attrs.has("href")) || + (tag === "input" && attrs.get("type") !== "hidden" && attrs.get("hidden") !== true) || + (typeof role === "string" && CONTROL_ROLES.has(role)) || + (role === "option" && attrs.has("onClick")); + if (control && !spread && !attrs.has("title")) { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + hits.push(`${relative(ROOT, path)}:${line} <${tag}>`); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return hits; +} + +describe("interactive controls", () => { + // Repo rule: every control a pointer or keyboard reaches carries a title, the + // tooltip that names it for a mouse user. A spread may carry one, so it passes. + it("each carry a title", () => { + const missing = tsxFiles(join(ROOT, "src")) + .flatMap(untitled) + .filter((hit) => !PENDING.some((p) => hit.startsWith(p))); + expect(missing).toEqual([]); + }); +}); From 7787ab649578b7ccd22385092576b632673e9451 Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:23:54 +0900 Subject: [PATCH 2/8] Accessibility: give every control outside the map and search a title A mouse user hovering a button, link or field got no tooltip naming it on 199 controls, against the repo rule. Each now carries a title in the words of its visible label, its aria-label or its field label, from the existing strings in every language. MoreButton takes its label as a string so it can reuse it as the title. --- apps/postcards/src/features/backup/Backup.tsx | 48 ++++++++-- .../src/features/city/CityScreen.tsx | 12 ++- .../src/features/country/CountryScreen.tsx | 13 ++- .../src/features/guides/GuideButton.tsx | 65 +++++++++++-- .../src/features/journal/JournalScreen.tsx | 95 ++++++++++++++++-- .../src/features/journal/StoryMap.tsx | 7 +- .../src/features/moved/MovedScreen.tsx | 4 +- .../src/features/passport/PassportScreen.tsx | 28 +++++- .../src/features/publish/PublishScreen.tsx | 30 +++++- .../features/settings/DataPacksSection.tsx | 11 ++- .../src/features/settings/SettingsScreen.tsx | 45 ++++++++- .../src/features/settings/SyncSection.tsx | 44 +++++++-- .../src/features/stats/StatsView.tsx | 4 +- .../features/travel/BoardingPassImport.tsx | 31 +++++- .../src/features/travel/MyPlacesPicker.tsx | 5 + .../src/features/travel/RouteMap.tsx | 1 + .../src/features/travel/TravelScreen.tsx | 27 +++++- .../src/features/travel/TripComposer.tsx | 23 ++++- apps/postcards/src/ui/AboutModal.tsx | 8 +- apps/postcards/src/ui/FilterPanel.tsx | 96 ++++++++++++------- apps/postcards/src/ui/FilterSummary.tsx | 8 +- .../src/ui/GitHubConnectorFields.tsx | 6 +- apps/postcards/src/ui/IntroScreen.tsx | 12 ++- apps/postcards/src/ui/LanguageToggle.tsx | 1 + apps/postcards/src/ui/MoreButton.tsx | 5 +- apps/postcards/src/ui/ShortcutsHelp.tsx | 8 +- apps/postcards/src/ui/Toast.tsx | 9 +- apps/postcards/src/ui/UpdateBanner.tsx | 8 +- 28 files changed, 548 insertions(+), 106 deletions(-) diff --git a/apps/postcards/src/features/backup/Backup.tsx b/apps/postcards/src/features/backup/Backup.tsx index abb1f29..165f317 100644 --- a/apps/postcards/src/features/backup/Backup.tsx +++ b/apps/postcards/src/features/backup/Backup.tsx @@ -221,12 +221,18 @@ export function Backup() { {t("backup.reminder.suffix")} - - - - - { setResetText(""); setResetOpen(true); @@ -326,11 +359,13 @@ export function Backup() { autoCorrect="off" spellCheck={false} aria-label={t("backup.reset.inputAria", { word: RESET_WORD })} + title={t("backup.reset.inputAria", { word: RESET_WORD })} />