feat(a11y): R9 focus foundation — glass shadow fix, focus-visible ring, highlight/selected split

S1 — recipe foundation:
- Move .glass/.glass-card/.glass-menu into @layer components so a JSX shadow-* utility overrides the
  glass box-shadow again; the ~9 dead shadow-* on glass surfaces (modals, menus, save-bar, chat) now
  paint their intended drop-shadow. Perf-mode and :hover overrides stay unlayered so they still win.
- Add a .selectable recipe: the hover tint menu/list rows already use, PLUS the app-wide
  focus-visible ring that was missing on non-input controls (the biggest a11y gap).
- Split the popover roving highlight from the selected state (AnchoredPopover): a selected/checked
  PopoverItem now gets a ring ONLY, so its resting paint (bg-accent fill / text-accent / checkbox)
  survives being highlighted instead of downgrading to a 35% tint or being recoloured. Fixes the 4
  review findings deferred from R10-S0 generically; drops the leftover per-consumer data-active ring
  in tableHeader.

S2 — worst offenders:
- Account-menu trigger + switch/add/sign-out rows adopt .selectable (visible keyboard focus).
- Saved-views rows: group-focus-within reveals the hover-only action buttons for keyboard users;
  aria-current on the active row; focus-visible ring on every button.
- The 3 invisible-focus inputs get an accent focus ring: SearchBar + ShareDialog via focus-within on
  the glass pill/row, AboutCell via focus-visible on the trigger.

Deferred to a later R9 sprint: the full unification of all ~14 selection idioms, a shared <NavItem>,
and .is-current/.chip recipes (no consumer yet in this pass).

Verified: tsc/eslint/prettier/knip/vitest (79) green; browser computed-style checks confirm the glass
shadow now wins, the focus outlines render (2px accent), and the split renders (selected ring-only,
unselected fill).
This commit is contained in:
2026-08-08 04:27:51 +02:00
parent 280ba87f25
commit 51f9c8d0ed
8 changed files with 108 additions and 78 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
tabIndex={0} tabIndex={0}
className="block max-w-88 truncate text-muted cursor-help outline-hidden" className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
> >
{preview} {preview}
</span> </span>
+9 -5
View File
@@ -250,11 +250,15 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow * focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
* key. (Since the Tailwind v4 migration `bg-accent/NN` works — v4 does the opacity modifier via * key. (Since the Tailwind v4 migration `bg-accent/NN` works — v4 does the opacity modifier via
* color-mix — so the recipe no longer needs a hand-written color-mix arbitrary value.) */ * color-mix — so the recipe no longer needs a hand-written color-mix arbitrary value.) */
// Applied to EVERY PopoverItem so the roving-focus row is clearly visible across all schemes: an // The roving-focus highlight, SPLIT by selection state (R9). A NON-selected row gets the familiar
// accent fill plus a 1px inset accent ring for a defined edge. (The previous per-consumer 24% fill // accent fill + inset ring. A SELECTED/checked row gets ring-ONLY, so the consumer's own resting
// alone read as barely-there — a real UAT finding, 2026-08-08.) // selected paint (a bg-accent fill, text-accent, a checkbox) SURVIVES being highlighted instead of
const ACTIVE_HIGHLIGHT = // being downgraded to a 35% tint or having its text recoloured (the R10-S0 review findings). The two
// can't be merged onto one recipe: the fill and the selected paint both drive background/color on the
// same element, so the highlight must yield to the selected state rather than overwrite it.
const HIGHLIGHT_UNSELECTED =
"data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60"; "data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60";
const HIGHLIGHT_SELECTED = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
export function PopoverItem({ export function PopoverItem({
children, children,
@@ -323,7 +327,7 @@ export function PopoverItem({
tabIndex={focusActive ? 0 : -1} tabIndex={focusActive ? 0 : -1}
data-active={visualActive ? "true" : undefined} data-active={visualActive ? "true" : undefined}
disabled={disabled} disabled={disabled}
className={`${ACTIVE_HIGHLIGHT}${className ? ` ${className}` : ""}`} className={`${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
> >
{children} {children}
</button> </button>
+4 -4
View File
@@ -322,7 +322,7 @@ export default function NavSidebar({
ref={acctPop.refs.setReference} ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()} {...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined} title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} selectable ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
<span className="relative shrink-0"> <span className="relative shrink-0">
<AvatarImg <AvatarImg
@@ -367,7 +367,7 @@ export default function NavSidebar({
key={a.id} key={a.id}
onClick={() => switchTo(a.id)} onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })} title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition" className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
> >
<AvatarImg <AvatarImg
src={a.avatar_url} src={a.avatar_url}
@@ -393,14 +393,14 @@ export default function NavSidebar({
clearActiveAccount(); clearActiveAccount();
window.location.href = "/auth/login"; window.location.href = "/auth/login";
}} }}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
> >
<UserPlus className="w-4 h-4" /> <UserPlus className="w-4 h-4" />
{t("header.account.addAccount")} {t("header.account.addAccount")}
</button> </button>
<button <button
onClick={logout} onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition" className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{t("header.account.signOut")} {t("header.account.signOut")}
+8 -7
View File
@@ -288,14 +288,15 @@ function ViewRow({
{...attributes} {...attributes}
{...listeners} {...listeners}
title={t("views.reorder")} title={t("views.reorder")}
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0" className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0 rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
> >
<GripVertical className="w-3.5 h-3.5" /> <GripVertical className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onApply} onClick={onApply}
title={t("views.apply")} title={t("views.apply")}
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${ aria-current={active ? "true" : undefined}
className={`flex-1 min-w-0 text-left text-xs py-1 truncate rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 ${
active ? "text-accent font-medium" : "text-fg" active ? "text-accent font-medium" : "text-fg"
}`} }`}
> >
@@ -305,10 +306,10 @@ function ViewRow({
<button <button
onClick={onToggleDefault} onClick={onToggleDefault}
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")} title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
className={`p-1 rounded transition ${ className={`p-1 rounded outline-hidden transition focus-visible:outline-2 focus-visible:outline-accent/60 ${
view.is_default view.is_default
? "text-accent" ? "text-accent"
: "text-muted opacity-0 group-hover:opacity-100 hover:text-accent" : "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
}`} }`}
> >
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} /> <Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
@@ -316,21 +317,21 @@ function ViewRow({
<button <button
onClick={onShare} onClick={onShare}
title={t("views.share")} title={t("views.share")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
> >
<Share2 className="w-3.5 h-3.5" /> <Share2 className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onStartRename} onClick={onStartRename}
title={t("views.rename")} title={t("views.rename")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
title={t("views.delete")} title={t("views.delete")}
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition" className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-red-400 transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function SearchBar({
if (onGo && !goDisabled) onGo(); if (onGo && !goDisabled) onGo();
}; };
return ( return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2"> <div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60">
<Search className="w-4 h-4 shrink-0 text-muted" /> <Search className="w-4 h-4 shrink-0 text-muted" />
<input <input
value={value} value={value}
+1 -1
View File
@@ -130,7 +130,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
return ( return (
<div className="rounded-lg glass-card p-2.5 space-y-2"> <div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" /> <Link2 className="w-4 h-4 text-muted shrink-0" />
<input <input
readOnly readOnly
+2 -2
View File
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
checked={!filterOn} checked={!filterOn}
onSelect={() => onApply(col.key, "")} onSelect={() => onApply(col.key, "")}
label={t("datatable.all")} label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${ className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" !filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`} }`}
> >
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
checked={value === o.value} checked={value === o.value}
onSelect={() => onApply(col.key, o.value)} onSelect={() => onApply(col.key, o.value)}
label={o.label} label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition data-active:ring-1 data-active:ring-accent ${ className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`} }`}
> >
+82 -57
View File
@@ -265,69 +265,94 @@ body {
} }
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
.glass { /* In @layer components so a JSX shadow-* utility (which lives in @layer utilities, always above
/* Frosted overlay surface: translucent enough that the blurred backdrop shows components) can override a glass surface's default box-shadow again. Before v4/R9 these rules were
unlayered and always won, so ~9 `shadow-*` on glass surfaces were silent no-ops. The perf-mode and
:hover overrides below stay UNLAYERED on purpose (they must beat the utility). */
@layer components {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
through (the glassy look), opaque enough that the background isn't distracting. through (the glassy look), opaque enough that the background isn't distracting.
The strong blur turns whatever is behind into soft colour, not a sharp image. The strong blur turns whatever is behind into soft colour, not a sharp image.
Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */ Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */
background: background:
linear-gradient( linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
), ),
color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent); color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent); border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent);
border-top-color: color-mix( border-top-color: color-mix(
in srgb, in srgb,
#fff var(--glass-edge), #fff var(--glass-edge),
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent) color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
); );
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent), inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-card { .glass-card {
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
where blur adds almost nothing visually but is GPU-expensive and triggers reflow. where blur adds almost nothing visually but is GPU-expensive and triggers reflow.
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
background: background:
linear-gradient( linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
), ),
color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent); color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
border-top-color: color-mix( border-top-color: color-mix(
in srgb, in srgb,
#fff var(--glass-edge), #fff var(--glass-edge),
color-mix(in srgb, var(--border) 65%, transparent) color-mix(in srgb, var(--border) 65%, transparent)
); );
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent), inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45); 0 8px 22px -14px rgba(0, 0, 0, 0.45);
} }
.glass-menu { .glass-menu {
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), /* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
so they must be near-opaque to stay readable — only a hint of translucency + the blur. */ so they must be near-opaque to stay readable — only a hint of translucency + the blur. */
background: background:
linear-gradient( linear-gradient(
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent),
color-mix(in srgb, var(--bg) var(--glass-scrim), transparent) color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)
), ),
color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent); color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-top-color: color-mix( border-top-color: color-mix(
in srgb, in srgb,
#fff var(--glass-edge), #fff var(--glass-edge),
color-mix(in srgb, var(--border) 80%, transparent) color-mix(in srgb, var(--border) 80%, transparent)
); );
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
/* R9: standard interactive-row recipe. The hover tint menu/list rows already use, PLUS the
app-wide focus-visible ring that was missing on non-input controls (buttons, rows, menu items) —
the biggest single a11y win. In @layer components so a consumer's own utilities still win; the
accent ring renders correctly under v4 (color-mix), matching the input ring in ui/form.tsx. */
.selectable {
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.selectable:hover {
background-color: var(--card);
}
.selectable:focus-visible {
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
outline-offset: -2px;
}
} }
.glass-hover:hover { .glass-hover:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent); border-color: color-mix(in srgb, var(--accent) 55%, transparent);