diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e0b1e170b..a1f3caac4 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -22,6 +22,7 @@ import { import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; export default function LoginPage() { const router = useRouter(); @@ -92,6 +93,16 @@ export default function LoginPage() { // stale `["auth","me"]`, so the shell refetches it fresh on mount and the // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. queryClient.clear(); + // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry): + // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW + // data/page caches — so a previous account's persisted dashboard never + // lingers on disk for the new session. The restore is already per-account + // bound, but a fresh login on a shared browser should leave nothing of the + // prior account behind. Order is clear → prefetch → navigate: the wipe is + // started here (best-effort, like logout) before the prefetch warms THIS + // user's data, and the persister's 1 s debounce gives the single IDB delete + // ample headroom to land before the new snapshot is written. + void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 7211a8f40..e4036657e 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -585,6 +585,39 @@ export function SettingsShell({ } : null); + // v1.25.1 (A11Y-L6) — move focus to the section heading when the active + // section changes WITHIN the mounted shell (a sub-tab tap or a rail click; + // the shell instance is reused across `/settings/[section]` param changes, + // which is also why the mobile-strip effect above keys on `activeSlug`). + // Without this, keyboard / screen-reader users stay focused on the nav link + // they activated and the new content is never announced. The very first + // mount (a deep link or a fresh entry into Settings) is skipped on purpose: + // auto-focusing on initial page load is itself a jarring, unexpected focus + // move. The heading id is shared by two instances (mobile above the strip, + // desktop in the grid) — only one is visible per breakpoint — so we focus + // the visible one. `preventScroll` leaves the global scroll-reset in charge. + const headingId = resolvedHeading?.headingId; + const firstHeadingFocusRef = React.useRef(true); + React.useEffect(() => { + if (firstHeadingFocusRef.current) { + firstHeadingFocusRef.current = false; + return; + } + if (!headingId || typeof document === "undefined") return; + const candidates = document.querySelectorAll( + `[data-settings-heading="${headingId}"]`, + ); + for (const el of candidates) { + // `offsetParent === null` ⇒ the element (or an ancestor) is + // `display:none` — i.e. the hidden-for-this-breakpoint instance. Skip it + // and focus the visible heading. + if (el.offsetParent !== null) { + el.focus({ preventScroll: true }); + break; + } + } + }, [activeSlug, headingId]); + const headingBlock = resolvedHeading ? (
{resolvedHeading.topSlot ?
{resolvedHeading.topSlot}
: null} @@ -592,7 +625,14 @@ export function SettingsShell({

{resolvedHeading.title}

@@ -773,7 +813,13 @@ export function SettingsShell({ "flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", isActive ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground", + : // v1.25.1 (A11Y-L3) — inactive tab text over the + // `bg-muted/50` strip. `text-muted-foreground` is tuned + // for ~AA on the page background; over the tinted strip + // the effective ratio drops below 4.5:1 in light mode. + // `text-foreground/70` keeps the de-emphasised look but + // clears the WCAG AA floor on the strip. + "text-foreground/70 hover:text-foreground", )} > {t(`settings.sections.${slug}.title`)}