feat(a11y): R9 focus foundation — app-wide focus-visible fallback + nav rail focus

- Global :focus-visible fallback (@layer base): one accent outline for keyboard users on every
  button/link/[role=button]/[role=tab]/summary that doesn't define its own. Non-input controls had no
  visible keyboard focus app-wide; this closes the void with a single rule. More specific focus styles
  (.selectable, an input's focus:ring, a menu item's data-active highlight) override it.
- PopoverItem now carries outline-hidden so menu items keep their data-active highlight without also
  drawing the global outline (no double ring).
- Nav rail rows adopt .selectable (via rowBase); rail chrome buttons (logo/pin/about/sign-out) get a
  focus-visible ring; the settings row already uses rowBase. Deduped the account trigger's selectable.

Deferred to R10: the chip/pill selected-PAINT unification (a .chip/.pill/.is-current repaint of the
~14 selection idioms) — the R10 token redefinition repaints those surfaces anyway, at less risk than a
hand migration now. R9's job (the focus/a11y foundation) is complete.

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style confirms the global ring
renders (2px accent, offset 2px) on a plain button, .selectable overrides it with an inset ring, and
an outline-hidden menu button draws no global outline.
This commit is contained in:
2026-08-08 04:51:02 +02:00
parent 51f9c8d0ed
commit c11e2822e9
3 changed files with 23 additions and 7 deletions
+1 -1
View File
@@ -327,7 +327,7 @@ export function PopoverItem({
tabIndex={focusActive ? 0 : -1}
data-active={visualActive ? "true" : undefined}
disabled={disabled}
className={`${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
className={`outline-hidden ${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
>
{children}
</button>
+7 -6
View File
@@ -168,7 +168,8 @@ export default function NavSidebar({
.filter((p) => sys.has(p))
.map((p) => ({ page: p, ...META(p) }));
const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const rowBase =
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// real role (it's a flag on a `user` row), so surface it first.
@@ -240,7 +241,7 @@ export default function NavSidebar({
{!slim && (
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
@@ -250,7 +251,7 @@ export default function NavSidebar({
onClick={onToggleCollapse}
title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
@@ -289,7 +290,7 @@ export default function NavSidebar({
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
>
<Info className="w-5 h-5" />
</button>
@@ -297,7 +298,7 @@ export default function NavSidebar({
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
>
<LogOut className="w-5 h-5" />
</button>
@@ -322,7 +323,7 @@ export default function NavSidebar({
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} selectable ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
<span className="relative shrink-0">
<AvatarImg
+15
View File
@@ -47,6 +47,21 @@
}
}
/* R9 focus foundation: one accent focus ring for keyboard users on every interactive element that
doesn't define its own. In @layer base so any component/utility focus style (.selectable, an input's
focus:ring, a menu item's data-active highlight + outline-hidden) overrides it. Non-input controls
app-wide had no visible keyboard focus before R9; this is the single biggest a11y win. */
@layer base {
button:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="tab"]:focus-visible,
summary:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: 2px;
}
}
:root {
--font-scale: 1.06;