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. */}
+
{group.label}
{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.label}
{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
+ >
) : (
diff --git a/src/components/cme/cme-dashboard-catch-up.tsx b/src/components/cme/cme-dashboard-catch-up.tsx
index 016d992de4..279e01e16a 100644
--- a/src/components/cme/cme-dashboard-catch-up.tsx
+++ b/src/components/cme/cme-dashboard-catch-up.tsx
@@ -216,12 +216,13 @@ function HeroLegend({
type="button"
data-testid="cme-close-gap"
onClick={onOpenGap}
- aria-label={`Your routines will likely add ${formatCmeHours(routines)} h. See how they would close the gap`}
className="-my-3 flex min-h-12 w-full min-w-0 items-center gap-1.75 text-left text-inherit focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--mode-identity-contrast)]"
>
- Routines likely
+ {/* Named by what it shows, so voice control can say it (WCAG 2.5.3, A11Y-02). */}
+ Routines likely{" "}
{`${formatCmeHours(routines)} h`}
+ . See how your routines would close the gap
) : null}
diff --git a/src/components/cme/cme-dashboard.tsx b/src/components/cme/cme-dashboard.tsx
index 41697a9478..1e55f923d0 100644
--- a/src/components/cme/cme-dashboard.tsx
+++ b/src/components/cme/cme-dashboard.tsx
@@ -285,7 +285,7 @@ export function CmeDashboard({
+ {" "}
{item.snippet ? (
setSheetOpen(true)}
className={cn(
focusRing,
diff --git a/src/components/developer-area/developer-gate-screen.tsx b/src/components/developer-area/developer-gate-screen.tsx
index a8514f09f9..582a73e133 100644
--- a/src/components/developer-area/developer-gate-screen.tsx
+++ b/src/components/developer-area/developer-gate-screen.tsx
@@ -106,7 +106,10 @@ export function DeveloperGateScreen({
return (
@@ -231,7 +234,6 @@ export function DeveloperGateScreen({
type="button"
onClick={() => void chooseProvider(provider)}
disabled={busy}
- aria-label={`Continue with ${provider}`}
className={cn(
controlDisabled,
"flex min-h-12 w-full items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] px-3 text-left text-sm font-semibold text-[color:var(--text-heading)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-wait",
diff --git a/src/components/document-viewer/section-nav.tsx b/src/components/document-viewer/section-nav.tsx
index 1ef4180b0c..8151fb98ca 100644
--- a/src/components/document-viewer/section-nav.tsx
+++ b/src/components/document-viewer/section-nav.tsx
@@ -165,7 +165,8 @@ export function DocumentViewDensityToggle({
type="button"
data-testid="document-view-density-toggle"
aria-pressed={compact}
- aria-label={compact ? "Show full document content" : "Condense document content"}
+ // Named by the words it shows, with the pressed state saying whether it is on
+ // (WCAG 2.5.3, A11Y-02).
title={compact ? "Show the full indexed text" : "Collapse long document sections"}
onClick={() => onCompactChange(!compact)}
className={cn(
@@ -179,11 +180,11 @@ export function DocumentViewDensityToggle({
>
- {compact ? "Condensed view" : "Full view"}
+ {compact ? "Condensed view" : "Full view"}{" "}
{compact ? "Long sections collapsed" : "All indexed text shown"}
-
+ {" "}
{compact ? "On" : "Off"}
);
diff --git a/src/components/dsm/dsm-search-page.tsx b/src/components/dsm/dsm-search-page.tsx
index 18ca4071e3..0b8e15a763 100644
--- a/src/components/dsm/dsm-search-page.tsx
+++ b/src/components/dsm/dsm-search-page.tsx
@@ -126,9 +126,10 @@ function DsmSearchResultCard({
+ {/* Named by what the card shows, after what it does (WCAG 2.5.3, A11Y-02). */}
+ Open{" "}
{compact ? (
Ticks cleared
diff --git a/src/components/first-nations/inner-page.tsx b/src/components/first-nations/inner-page.tsx
index cd47183234..8fa4df77c3 100644
--- a/src/components/first-nations/inner-page.tsx
+++ b/src/components/first-nations/inner-page.tsx
@@ -54,7 +54,15 @@ export function InnerPageView({
{sections.map((section) => (
-
+ // A named group, not a region: each module inside is already a region, often with
+ // the same name as its tab, and two regions with one name confuse landmark lists (A11Y-07).
+
{section.modules.map((m) => (
))}
diff --git a/src/components/formulation/formulation-builder-page.tsx b/src/components/formulation/formulation-builder-page.tsx
index becc4712fa..fdb7f7e09e 100644
--- a/src/components/formulation/formulation-builder-page.tsx
+++ b/src/components/formulation/formulation-builder-page.tsx
@@ -135,7 +135,7 @@ function BuilderThread({
completedQuality: number;
}) {
return (
-