From 1bde31212cd87e6b3aa0678a547daf0fa8446e32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:23:22 +0000 Subject: [PATCH 01/24] Give the focus ring and green text colours that pass on navy and in dark mode The green focus outline measured 2.6:1 on the navy sidebar and 2.2 to 2.7:1 everywhere in dark mode. Add --focus-ring and --text-green tokens: light mode keeps today's green, dark mode and navy surfaces (sidebar, commune list, phone tab bar and top bar, Operations toast) take the Hub's approved lighter green. Dark mode also gets a placeholder colour, switches whose on and off states differ, readable Operations week details and out-of-month days, and the sidebar's secondary text rises to 0.6 white. --danger and --danger-soft give error text one token in both themes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/screens/Money.css | 4 ++-- apps/web/src/screens/Notes.css | 14 +++++------ apps/web/src/styles/app.css | 8 +++---- apps/web/src/styles/design-system.css | 13 +++++++++- apps/web/src/styles/overrides.css | 34 +++++++++++++++++++++++++-- apps/web/src/styles/shared.css | 6 +++++ apps/web/src/ui/QuickSearch.css | 6 ++--- 7 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 0a4202a0..3c451cff 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -8,8 +8,8 @@ .c-expense-footer-copy { flex: 1; } .c-expense-card-foot small { display: block; margin-top: 3px; } .c-expense-card-foot svg { flex: none; } -.c-expense-entry:hover .c-expense-copy strong { color: var(--sage); } -.c-expense-entry:focus-visible { outline: 2px solid var(--sage); outline-offset: 3px; } +.c-expense-entry:hover .c-expense-copy strong { color: var(--text-green); } +.c-expense-entry:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; } .c-money-icon { width: 37px; height: 37px; flex: none; border-radius: 10px; background: #edf0f3; color: #637689; display: grid; place-items: center; } .c-expense-copy { flex: 1; min-width: 0; } .c-expense-copy strong, .c-expense-amount strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } diff --git a/apps/web/src/screens/Notes.css b/apps/web/src/screens/Notes.css index 5f920912..22befc07 100644 --- a/apps/web/src/screens/Notes.css +++ b/apps/web/src/screens/Notes.css @@ -73,7 +73,7 @@ background: #fcfcf8; } .note-tile.is-selected { - border-color: var(--sage, #2d6a4f); + border-color: var(--text-green, #2d6a4f); background: #f0f6f0; } .note-tile-icon { @@ -121,7 +121,7 @@ position: absolute; right: 16px; top: 20px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); } .notes-pinned .note-tile { flex-direction: row; @@ -173,7 +173,7 @@ display: flex; align-items: center; gap: 9px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); font-size: 12px; font-weight: 500; } @@ -194,7 +194,7 @@ margin: 0 0 28px; } .note-pin-status svg { - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); } .note-details { margin: 28px 0 0; @@ -286,7 +286,7 @@ font-size: 14px; background: transparent; border: 0; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); min-height: 44px; padding: 0 8px; cursor: pointer; @@ -297,7 +297,7 @@ } .note-feedback { font-size: 12px; - color: var(--sage, #2d6a4f); + color: var(--text-green, #2d6a4f); min-height: 18px; margin: 12px 0; } @@ -321,7 +321,7 @@ .notes-layout button:focus-visible, .notes-layout select:focus-visible, .notes-layout input:focus-visible { - outline: 2px solid var(--sage, #2d6a4f); + outline: 2px solid var(--focus-ring, #2d6a4f); outline-offset: 4px; } .is-reading .notes-grid { diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e0df5c6a..2b8bf1d1 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -258,7 +258,7 @@ .a-hub-switch span { display: block; font-size: 11px; - color: rgba(255, 255, 255, 0.45); + color: rgba(255, 255, 255, 0.6); } .a-hub-switch .grow span, @@ -330,7 +330,7 @@ .a-hub-option .grow span { display: block; font-size: 11px; - color: rgba(255, 255, 255, 0.45); + color: rgba(255, 255, 255, 0.6); } .a-hub-option .grow { @@ -449,7 +449,7 @@ font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; - color: rgba(255, 255, 255, 0.32); + color: rgba(255, 255, 255, 0.6); } .a-sub { @@ -529,7 +529,7 @@ .a-side-account-role, .a-side-account-separator { font-size: 11px; - color: rgba(255, 255, 255, 0.45); + color: rgba(255, 255, 255, 0.6); } .a-side-account-meta { diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css index ae6f4164..d126fafb 100644 --- a/apps/web/src/styles/design-system.css +++ b/apps/web/src/styles/design-system.css @@ -35,9 +35,20 @@ font-weight: 500; } .a-body :is(button, a, input, select, textarea, summary):focus-visible { - outline: 2px solid var(--sage); + outline: 2px solid var(--focus-ring); outline-offset: 3px; } +/* The green ring is too faint on navy: the sidebar and its commune list, the + phone tab bar and top bar, and the Operations toast take the lighter one. */ +.a-side, +.a-hub-menu, +.a-bottom, +.ops-toast { + --focus-ring: #649578; +} +@media (max-width: 560px) { + .a-header { --focus-ring: #649578; } +} .a-body .a-btn:disabled { cursor: not-allowed; opacity: 0.48; diff --git a/apps/web/src/styles/overrides.css b/apps/web/src/styles/overrides.css index a9b8353d..bd7ecc86 100644 --- a/apps/web/src/styles/overrides.css +++ b/apps/web/src/styles/overrides.css @@ -34,6 +34,10 @@ body, --border-strong: rgba(245, 241, 234, 0.16); --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.18); --shadow: 0 8px 24px rgba(0, 0, 0, 0.24); + --text-green: #9fd3b8; + --focus-ring: #649578; + --danger: #f2b9a2; + --danger-soft: rgba(242, 185, 162, 0.12); color: var(--ink); background: #151923; color-scheme: dark; @@ -188,7 +192,7 @@ body, background: #242b39; border-color: var(--border); } -.a-body.is-dark :is(.ops-note, .ops-board-head small, .ops-cuts button:not(.is-on)) { +.a-body.is-dark :is(.ops-note, .ops-board-head small, .ops-cuts button:not(.is-on), .ops-week-row span) { color: var(--ink-soft); } .a-body.is-dark :is(.ops-board-head, .ops-week-day + .ops-week-day) { @@ -229,7 +233,7 @@ body, } .a-body.is-dark .ops-tile.is-out { background: transparent; - color: rgba(245, 241, 234, 0.32); + color: rgba(245, 241, 234, 0.55); } .a-body.is-dark .ops-tile.is-on { background: #23352f; @@ -276,3 +280,29 @@ body, .a-body.is-dark .a-reminder-usage-track { background: rgba(245, 241, 234, 0.14); } + +/* Settings switches: on and off looked alike on the dark card. Off is a dark + track with a grey knob; on is a cream track with a navy knob. */ +.a-body.is-dark .a-switch { + background: #1d2330; + box-shadow: inset 0 0 0 1px rgba(245, 241, 234, 0.28); +} +.a-body.is-dark .a-switch::after { + background: #b5bdca; + box-shadow: none; +} +.a-body.is-dark .a-switch[aria-checked="true"] { + background: var(--ink); + box-shadow: none; +} +.a-body.is-dark .a-switch[aria-checked="true"]::after { + background: #1a1e2b; +} +.a-body.is-dark ::placeholder { + color: #9aa3b2; + opacity: 1; +} +/* The search field's own outline follows the focus colour of its surface. */ +.a-search:focus-within { + outline-color: var(--focus-ring); +} diff --git a/apps/web/src/styles/shared.css b/apps/web/src/styles/shared.css index 4d1de6bd..f630e71e 100644 --- a/apps/web/src/styles/shared.css +++ b/apps/web/src/styles/shared.css @@ -9,6 +9,12 @@ --sage-strong: #1b4332; --sage-soft: #d9ebe5; --sage-mist: #d7e6dd; + /* Green text, focus rings and error text. Dark mode lightens each one + (overrides.css); navy surfaces take the lighter ring in both themes. */ + --text-green: var(--sage); + --focus-ring: var(--sage); + --danger: #9b2c2c; + --danger-soft: #fde8e4; --navy: #1a1e2b; --navy-2: #1f2330; --navy-3: #21262f; diff --git a/apps/web/src/ui/QuickSearch.css b/apps/web/src/ui/QuickSearch.css index 44be1715..6df6ee93 100644 --- a/apps/web/src/ui/QuickSearch.css +++ b/apps/web/src/ui/QuickSearch.css @@ -42,7 +42,7 @@ border-radius: var(--radius-control); background: transparent; color: var(--ink-soft); font: inherit; font-size: 12px; cursor: pointer; } -.c-search-scope button[aria-pressed="true"] { background: var(--paper); border-color: var(--sage); color: var(--sage); } +.c-search-scope button[aria-pressed="true"] { background: var(--paper); border-color: var(--text-green); color: var(--text-green); } .c-search-scope > span { margin-left: 5px; max-width: 230px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--ink-soft); } .c-quick-search-body { overflow-y: auto; max-height: min(440px, calc(100dvh - 300px)); min-height: 142px; } .c-search-group { padding: 12px 8px 0; } @@ -65,7 +65,7 @@ .c-search-state span { display: block; line-height: 1.6; } .c-search-state .a-btn { margin-top: 16px; } .c-quick-search-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 14px 22px; border-top: 1px solid var(--border); color: var(--ink-soft); font-size: 11px; } -.c-quick-search-footer button { border: 0; padding: 3px 0; background: transparent; color: var(--sage); font: inherit; cursor: pointer; } +.c-quick-search-footer button { border: 0; padding: 3px 0; background: transparent; color: var(--text-green); font: inherit; cursor: pointer; } .c-search-key-hint { display: flex; gap: 5px; align-items: center; } .c-search-announcement { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } .c-record-filter, .notes-mobile-search.c-record-filter { @@ -78,7 +78,7 @@ .c-search-full .c-search-scope { padding: 0 0 18px; margin-bottom: 18px; } .c-search-full .c-search-results { border: 1px solid var(--border); border-radius: var(--radius-surface); background: var(--white); } .c-search-full .c-search-count { color: var(--ink-soft); font-size: 12px; margin-bottom: 16px; } -.c-search-record-highlight { outline: 2px solid var(--sage); outline-offset: 3px; scroll-margin-top: 90px; } +.c-search-record-highlight { outline: 2px solid var(--focus-ring); outline-offset: 3px; scroll-margin-top: 90px; } .c-window-record-loading { display: grid; gap: 14px; padding: 12px 0; } .c-window-record-loading span { display: block; height: 18px; width: 85%; border-radius: var(--radius-control); background: var(--paper); } .c-window-record-loading span:nth-child(2) { width: 60%; } From eb45ba5c581760e6f00068dab51104ef4200934d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:25:27 +0000 Subject: [PATCH 02/24] Add a skip link and main landmark, and move focus to the heading after navigating Keyboard users pressed Tab 14 times before reaching any content and a page change announced nothing. The content area is now
with a skip link as the first stop, and moving to another page or commune puts focus on the new page's heading. A window, the note reader or a search result keeps the focus it takes; when it closes and focus would be lost, focus goes to the heading. The phone tab bar marks the current page with aria-current and a small bar, so it no longer relies on brighter text alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/App.tsx | 47 +++++++- apps/web/src/shell/Shell.tsx | 15 ++- apps/web/src/styles/app.css | 17 +++ apps/web/src/styles/design-system.css | 29 +++++ apps/web/tests/accessibility.test.tsx | 165 ++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 apps/web/tests/accessibility.test.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7b5192c2..fb007e00 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { diagnosticsEnabled } from "./live/diagnostics"; import { Redirect, Route, Switch } from "wouter"; import { LoginPage } from "./auth/Login"; @@ -111,9 +111,10 @@ function AppUnavailable() { } function ProductShell() { - const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh } = useApp(); + const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget } = useApp(); const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]); const modalOpen = Boolean(win) && !noteReader; + usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget)); return (
+ Skip to content
{diagnosticsEnabled() ? : null} {apiError ?
{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available. : apiError}
: null} {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity.
: null} -
+
-
+
@@ -148,6 +150,43 @@ function ProductShell() { ); } +// Focus moves to an element that only holds it until the next step away. +function focusOnce(element: HTMLElement) { + if (!element.hasAttribute("tabindex")) { + element.tabIndex = -1; + element.addEventListener("blur", () => element.removeAttribute("tabindex"), { once: true }); + } + element.focus({ preventScroll: true }); +} + +function skipToContent(event: MouseEvent) { + const main = document.getElementById("main-content"); + if (!main) return; + event.preventDefault(); + focusOnce(main); +} + +// After moving to another page or commune, start at the new page's heading +// rather than on a control that has gone. A window, the note reader or a +// search result places focus itself; if one was opening, wait until it closes +// and only step in when focus would otherwise be lost. +function usePageFocus(screen: ScreenId, hubId: string, held: boolean) { + const shown = useRef({ screen, hubId }); + const waiting = useRef(false); + useEffect(() => { + const previous = shown.current; + shown.current = { screen, hubId }; + const moved = previous.screen !== screen || (Boolean(previous.hubId) && previous.hubId !== hubId); + if (moved) waiting.current = true; + if (!waiting.current || held) return; + waiting.current = false; + const active = document.activeElement; + if (!moved && active && active !== document.body) return; + const heading = document.querySelector(".a-header-title h1"); + if (heading) focusOnce(heading); + }, [screen, hubId, held]); +} + function ConnectionCheck() { const { hubId } = useApp(); const [running, setRunning] = useState(false); diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 47929bde..c95b7cb7 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -298,20 +298,19 @@ export function PhoneTabs({ screen }: { screen: ScreenId }) { { id: "expenses", label: "Expenses", icon: "expenses" }, { id: "settle", label: "Settle", icon: "settle" }, ]; + const current = (id: ScreenId) => + (id === "home" && (screen === "home" || screen === "home-empty" || screen === "home-inbox")) || + (id === "hub" && (screen === "hub" || screen === "hub-empty" || screen === "commune-settings")) || + (id === "expenses" && (screen === "expenses" || screen === "expenses-empty")) || + (id === "settle" && (screen === "settle" || screen === "settle-even")); return (
- Switch commune + {" "}Switch commune @@ -253,6 +253,7 @@ export function Header({ screen }: { screen: ScreenId }) { } = useApp(); const [searchOpen, setSearchOpen] = useState(false); const searchingAll = ["home", "home-empty", "communes", "home-activity"].includes(screen); + const unread = noticesOn ? notifications.filter((notice) => !notice.readAt).length : 0; useEffect(() => { function shortcut(event: KeyboardEvent) { if (event.isComposing || event.repeat) return; @@ -278,16 +279,16 @@ export function Header({ screen }: { screen: ScreenId }) {

{pageTitle}

{pageLede ?

{pageLede}

: null} -
-
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 46cfbed9..75a116d6 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -2204,9 +2204,9 @@ button.a-kpi { } .a-reminder-usage summary::-webkit-details-marker { display: none; } -.a-reminder-usage summary::after { content: "⌄"; } +.a-reminder-usage summary::after { content: "⌄"; content: "⌄" / ""; } .a-reminder-usage summary strong { color: var(--ink); } -.a-reminder-usage summary:focus-visible { outline: 2px solid var(--sage); outline-offset: 3px; } +.a-reminder-usage summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; } .a-reminder-usage-details { position: absolute; right: 0; @@ -5882,7 +5882,7 @@ button.a-item { .ops-tile.is-on { background: #f4faf6; - box-shadow: inset 0 0 0 1.5px rgba(45, 106, 79, 0.28); + box-shadow: inset 0 0 0 2px var(--text-green); } .ops-tile-num { @@ -5935,6 +5935,33 @@ button.a-item { color: #7f3b2c; } +/* Tasks, events and late tasks also differ by shape: a square, a circle and + a triangle before the title. */ +.ops-tile-item::before { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin: 0 5px 1px 0; + background: currentColor; + vertical-align: middle; +} + +.ops-tile-item.is-task::before { + border-radius: 1px; +} + +.ops-tile-item.is-event::before { + border-radius: 50%; +} + +.ops-tile-item.is-warn::before { + width: 8px; + height: 7px; + border-radius: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} + .ops-month-selected { display: grid; grid-template-columns: 180px 1fr; diff --git a/apps/web/src/ui/CalendarDate.tsx b/apps/web/src/ui/CalendarDate.tsx index 6c8052d3..b4202947 100644 --- a/apps/web/src/ui/CalendarDate.tsx +++ b/apps/web/src/ui/CalendarDate.tsx @@ -17,8 +17,11 @@ export function CalendarDate({ date, today = todayIso(), showMonth = true, label if (!Number.isFinite(value.getTime()) || isoFromDate(value) !== date) return null; const relative = date === today ? "Today" : date === addDays(today, 1) ? "Tomorrow" : ""; const description = `${label ? `${label}: ` : ""}${fullDate.format(value)}${relative ? ` (${relative})` : ""}`; + // The full date is real (visually hidden) text: some screen readers skip + // an aria-label on