diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index fa095232dc..376b62fdea 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -4706,6 +4706,11 @@ "section": "decisions", "catalogued": true }, + { + "path": "docs/decisions/2026-10-26-gentle-accessibility-options.md", + "section": "decisions", + "catalogued": true + }, { "path": "docs/skipped-tests-inventory.md", "section": "root", diff --git a/docs/decisions/2026-10-26-gentle-accessibility-options.md b/docs/decisions/2026-10-26-gentle-accessibility-options.md new file mode 100644 index 0000000000..693305179d --- /dev/null +++ b/docs/decisions/2026-10-26-gentle-accessibility-options.md @@ -0,0 +1,12 @@ +# Decision: Gentle accessibility fixes only, default look unchanged + +- **Status:** decided 2026-10-26 +- **Source:** the owner, in chat + +On 26 Oct 2026 the owner reviewed the first round of accessibility mockups and said "lots of these make the design look worse". After a gentler second round, the owner said "Ok go ahead I approve gentle version but the actual main default doesn't change". + +- The dark mode tag in the More menu uses the plain mode accent colour instead of the deep ink (3.9:1 to 7.6:1). Light mode is unchanged. +- Form and search box edges keep their current colour. +- The first-round proposals (darker box edges, rebuilt quick-action row, near-white tag, single-key shortcut switch, forced-colours Teaching action) are not built. + +If this summary and the source ever differ, the source wins. diff --git a/src/app/work-mode.css b/src/app/work-mode.css index 3d17f05978..0d0e8e4245 100644 --- a/src/app/work-mode.css +++ b/src/app/work-mode.css @@ -1422,6 +1422,11 @@ html:not([data-mode-band]) [data-testid="chrome-safe-area-top"][data-work-band] white-space: nowrap; } +/* The deep ink is dark on dark here (3.9:1). The plain accent reads at 7.6:1 (A11Y-10, gentle option). */ +.dark .work-more-area__tag { + color: var(--mode-identity); +} + .work-more-area__chev { width: 1rem; height: 1rem; diff --git a/src/components/admin/admin-kit.tsx b/src/components/admin/admin-kit.tsx index 616572710d..f64d8aa25e 100644 --- a/src/components/admin/admin-kit.tsx +++ b/src/components/admin/admin-kit.tsx @@ -135,30 +135,45 @@ export function AdminRow({ readonly disabled?: boolean; readonly className?: string; }) { - // A row that acts is named by its title and end status; the sub line and tags describe it, - // so "Copy for workforce" is the button's name, not the whole paragraph under it. + // A row that acts is named by every line it shows, in the order it shows them, so a + // voice-control user can say any of it (WCAG 2.5.3, A11Y-02). An icon lead adds nothing; + // a lead with words ("Start renewing:") is read first, as it is drawn. const baseId = useId(); const hasEnd = end !== undefined && end !== null; - const labelledBy = hasEnd ? `${baseId}-t ${baseId}-e` : `${baseId}-t`; - const describedBy = [sub ? `${baseId}-s` : null, tags ? `${baseId}-g` : null].filter(Boolean).join(" ") || undefined; + const labelledBy = [ + lead ? `${baseId}-l` : null, + `${baseId}-t`, + sub ? `${baseId}-s` : null, + tags ? `${baseId}-g` : null, + hasEnd ? `${baseId}-e` : null, + ] + .filter(Boolean) + .join(" "); const body = ( <> - {lead} + {/* `contents`, so the lead keeps its place in the row while the name can point at it. + The {" "} between parts are word breaks for the spoken and visible text; flex and + grid draw nothing for them. */} + {lead ? ( + + {lead} + + ) : null}{" "} {title} - + {" "} {sub ? ( {sub} - ) : null} + ) : null}{" "} {tags ? ( {tags} ) : null} - + {" "} {hasEnd ? ( {end} @@ -174,13 +189,7 @@ export function AdminRow({ ); const control = href ? ( - + {body} ) : onClick ? ( @@ -191,7 +200,6 @@ export function AdminRow({ className={cn("work-row", ariaExpanded !== undefined && styles.disclosure)} aria-expanded={ariaExpanded} aria-labelledby={labelledBy} - aria-describedby={describedBy} data-testid={testId} > {body} diff --git a/src/components/admin/admin-renewals-page.tsx b/src/components/admin/admin-renewals-page.tsx index 51344188f0..44db33ad59 100644 --- a/src/components/admin/admin-renewals-page.tsx +++ b/src/components/admin/admin-renewals-page.tsx @@ -60,6 +60,7 @@ import { onCallEntrySchema, type OnCallEntry } from "@/lib/on-call/entry-model"; import { cacheOnCallEntries, readCachedOnCallEntries, useOnCallEntries } from "@/lib/on-call/entry-store"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { guardExampleAction } from "@/lib/example-data/guards"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; type CatalogueItem = (typeof ADMIN_REQUIREMENTS_CATALOGUE)[number]; type RenewSubject = { entry: OnCallEntry | null; createItem?: CatalogueItem }; @@ -183,11 +184,12 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) { const [undoBusy, setUndoBusy] = useState(false); const [failedAction, setFailedAction] = useState(null); + const { held: undoHeld, holdProps: undoHoldProps } = useHoldWhileEngaged(undoBar !== null); useEffect(() => { - if (!undoBar || undoBar.failed) return; + if (!undoBar || undoBar.failed || undoHeld) return; const timer = setTimeout(() => setUndoBar(null), UNDO_WINDOW_MS); return () => clearTimeout(timer); - }, [undoBar]); + }, [undoBar, undoHeld]); // Items marked not for this job are left out here: they show once, in their own closing section. const rows = useMemo(() => requirementChecklistRowsForJob(ADMIN_REQUIREMENTS_CATALOGUE, own), [own]); @@ -598,7 +600,7 @@ export function AdminRenewalsPage({ now: nowProp }: { now?: Date } = {}) { )} {undoBar ? ( -
+
{undoBar.failed ? `Undo didn't save · ${undoBar.message}` : undoBar.message} diff --git a/src/components/admin/new-job/admin-records-page.tsx b/src/components/admin/new-job/admin-records-page.tsx index a4d5684f4b..2d4bbbd1da 100644 --- a/src/components/admin/new-job/admin-records-page.tsx +++ b/src/components/admin/new-job/admin-records-page.tsx @@ -62,8 +62,11 @@ function RecordRow({ row }: { row: AdminRecordsRow }) { function RecordGroup({ section, expandAll }: { section: AdminRecordsSection; expandAll: boolean }) { const id = SECTION_ID[section.label]; + // The Admin area band is already a region called "Admin", and two regions with one + // name confuse landmark lists (A11Y-07), so this group is named "Other admin". + const regionName = section.label === "Admin" ? "Other admin" : section.label; return ( -
+
{section.label} {` · ${section.rows.length}`} diff --git a/src/components/admin/new-job/admin-saved-undo-bar.tsx b/src/components/admin/new-job/admin-saved-undo-bar.tsx index 8fc8ebc9c5..0a23d20eeb 100644 --- a/src/components/admin/new-job/admin-saved-undo-bar.tsx +++ b/src/components/admin/new-job/admin-saved-undo-bar.tsx @@ -4,13 +4,15 @@ import { useEffect, useRef } from "react"; import { adminStyles } from "@/components/admin/admin-kit"; import { cn } from "@/components/ui-primitives"; +import { useHoldWhileEngaged } from "@/components/use-hold-while-engaged"; const AUTO_DISMISS_MS = 6_000; /** * The one "Saved · Undo" bar every real toggle on New job shares (standard * §7, ui-lane-rules "Undo"): 48px, sits above where a floating Add would be, - * and clears itself after about six seconds if nobody taps Undo. + * and clears itself after about six seconds if nobody taps Undo. It waits while + * the pointer or keyboard focus is on it. */ export function AdminSavedUndoBar({ label = "Saved", @@ -28,13 +30,16 @@ export function AdminSavedUndoBar({ dismissRef.current = onDismiss; }, [onDismiss]); + // Held open while the pointer or keyboard focus is on the bar (WCAG 2.2.1). + const { held, holdProps } = useHoldWhileEngaged(true); useEffect(() => { + if (held) return; const timer = window.setTimeout(() => dismissRef.current(), AUTO_DISMISS_MS); return () => window.clearTimeout(timer); - }, []); + }, [held]); return ( -
+
{label}
); } @@ -145,7 +145,7 @@ function CrossModeLinkCard({ link, Icon, query, onModeSearch }: CrossModeLinkCar // space matters (e.g. the documents search page). function CrossModeLinkChip({ link, Icon, query, onModeSearch }: CrossModeLinkCardProps) { return ( -
-
+
); } diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index ac1a4d79e9..93a99fd018 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -144,6 +144,7 @@ import { useCommandDropdownDisplayableByPlacement } from "@/components/clinical- import type { ClinicalQueryMode } from "@/lib/types"; import { standaloneModeHomeHref } from "@/lib/search-route-ownership"; import { confirmShellNavigation } from "@/lib/navigation-guards"; +import { nameContainsLabel } from "@/lib/label-in-name"; // Lazy chunks; the clinical bell loads the bell alone, never AI Search. const StaffWorkHeaderControls = dynamic(() => @@ -1032,6 +1033,10 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { function renderModeMenuOption(mode: (typeof visibleAppModeOptions)[number], index: number) { const active = mode.id === modeMenuRowFor(searchMode); + // The phone line is picker B's one-line hint. Work's desktop list shows + // the registry description, as #3351 drew it. + const description = usesPhoneSearchLayout ? modePickerHint(mode.id) : mode.description; + const showDescription = modeMenuSide === "work"; return ( @@ -1198,7 +1202,9 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { const headingId = `desktop-app-mode-group-${group.id}`; return (
-

+ {/* A menu may hold only its items and groups (A11Y-01), so the group's title is + read as the group's name rather than as a heading among the items. */} +

{rows} @@ -1234,6 +1240,25 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { ); } + const modePillLegacyName = + !routeWorkArea && currentPathname !== "/" + ? `Modes, current mode ${selectedAppMode.label}${activeModePage ? `, page ${activeModePage.label}` : ""}` + : activeModePage + ? `Mode ${selectedAppMode.label}, page ${activeModePage.label}` + : `Mode ${selectedAppMode.label}`; + // What the pill paints, in reading order (see the pill's branches below). + const modePillVisibleText = + !routeWorkArea && currentPathname !== "/" + ? "Modes" + : pillPageLine + ? `${pillPageLine.label}, ${pillModeLabel}` + : pillShowsAreaOnly + ? pillModeLabel + : selectedAppMode.label; + const modePillAccessibleName = nameContainsLabel(modePillLegacyName, modePillVisibleText) + ? modePillLegacyName + : `Mode ${selectedAppMode.label}: ${modePillVisibleText}`; + const restoreActionMenuFocusRef = useRef(false); const closeScope = useCallback((restoreFocus = false) => { restoreActionMenuFocusRef.current = restoreFocus; @@ -2069,13 +2094,12 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { // other than `/`), the name must contain it (WCAG 2.5.3 Label in // Name). "Modes, current mode X" still contains `Mode X` for the // substring lookups above. - aria-label={ - !routeWorkArea && currentPathname !== "/" - ? `Modes, current mode ${selectedAppMode.label}${activeModePage ? `, page ${activeModePage.label}` : ""}` - : activeModePage - ? `Mode ${selectedAppMode.label}, page ${activeModePage.label}` - : `Mode ${selectedAppMode.label}` - } + // + // On work pages the pill can show words the name above leaves out or + // reorders ("Week" over "MY DAY", or the area "Roster" on Open shifts). + // Then the name keeps its `Mode …` opening and ends with exactly what + // the pill shows, in order (A11Y-02). + aria-label={modePillAccessibleName} // The mode's own hue, on the mode's own control. Resolves to the // product accent for every mode that names no identity, so this is // inert everywhere but On Call — see the `--mode-identity` block in @@ -2391,7 +2415,12 @@ export function MasterSearchHeader(props: MasterSearchHeaderProps) { return (
-

+ {/* Inside role="menu": named the group, not a heading among its items (A11Y-01). */} +

{group.hint}

diff --git a/src/components/cme/applications/applications-referees.tsx b/src/components/cme/applications/applications-referees.tsx index a4b98a7b1a..ec11a430b1 100644 --- a/src/components/cme/applications/applications-referees.tsx +++ b/src/components/cme/applications/applications-referees.tsx @@ -100,7 +100,6 @@ export function RefereeList({ type="button" onClick={() => onOpen(referee.id)} data-testid="applications-referee" - aria-label={`${referee.name}, ${refereeStatusLabel(referee.status)}. Open`} className={cn(focusRing, "flex min-h-13 w-full min-w-0 items-center gap-3 px-3 py-2 text-left")} > {referee.name} - + {" "} {/* Wraps, never truncates: "no reply in 6 days" is the key fact, even on a 320 px phone. */} {refereeLine(referee, today)} - + {" "} + {/* Named by what it shows, so voice control can say it (WCAG 2.5.3, A11Y-02). */} + . Open ))} diff --git a/src/components/cme/applications/applications-season.tsx b/src/components/cme/applications/applications-season.tsx index b03b6acf3a..15c777e9fa 100644 --- a/src/components/cme/applications/applications-season.tsx +++ b/src/components/cme/applications/applications-season.tsx @@ -86,11 +86,6 @@ export function SeasonRail({ type="button" onClick={() => onEdit(item.stage)} data-testid={`applications-stage-${item.stage}`} - aria-label={ - item.date - ? `${item.label}, ${shortDate(item.date.on, today)}${item.date.time ? ` at ${item.date.time}` : ""}. Change date` - : `${item.label}, date to be confirmed. Add date` - } className={cn(focusRing, "flex min-h-12 min-w-0 flex-1 items-center gap-3 rounded-md text-left")} > @@ -101,20 +96,24 @@ export function SeasonRail({ )} > {item.label} - + {" "} {item.date ? `Added by you${item.date.source ? `, from ${item.date.source}` : ""}${item.date.time ? ` · ${item.date.time}` : ""}` : "Date to be confirmed"} - + {" "} {outOfOrder.has(item.stage) ? ( Earlier than the stage before. Check the advert. ) : null} - + {" "} + {/* Named by what it shows, so voice control can say it (WCAG 2.5.3, A11Y-02). */} {item.date ? ( - + <> + + . Change date + ) : (