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:
@@ -45,7 +45,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
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}
|
||||
</span>
|
||||
|
||||
@@ -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
|
||||
* 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.) */
|
||||
// Applied to EVERY PopoverItem so the roving-focus row is clearly visible across all schemes: an
|
||||
// accent fill plus a 1px inset accent ring for a defined edge. (The previous per-consumer 24% fill
|
||||
// alone read as barely-there — a real UAT finding, 2026-08-08.)
|
||||
const ACTIVE_HIGHLIGHT =
|
||||
// The roving-focus highlight, SPLIT by selection state (R9). A NON-selected row gets the familiar
|
||||
// accent fill + inset ring. A SELECTED/checked row gets ring-ONLY, so the consumer's own resting
|
||||
// selected paint (a bg-accent fill, text-accent, a checkbox) SURVIVES being highlighted instead of
|
||||
// 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";
|
||||
const HIGHLIGHT_SELECTED = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
||||
|
||||
export function PopoverItem({
|
||||
children,
|
||||
@@ -323,7 +327,7 @@ export function PopoverItem({
|
||||
tabIndex={focusActive ? 0 : -1}
|
||||
data-active={visualActive ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={`${ACTIVE_HIGHLIGHT}${className ? ` ${className}` : ""}`}
|
||||
className={`${checked || selected ? HIGHLIGHT_SELECTED : HIGHLIGHT_UNSELECTED}${className ? ` ${className}` : ""}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function NavSidebar({
|
||||
ref={acctPop.refs.setReference}
|
||||
{...acctPop.getReferenceProps()}
|
||||
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">
|
||||
<AvatarImg
|
||||
@@ -367,7 +367,7 @@ export default function NavSidebar({
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
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
|
||||
src={a.avatar_url}
|
||||
@@ -393,14 +393,14 @@ export default function NavSidebar({
|
||||
clearActiveAccount();
|
||||
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" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
{t("header.account.signOut")}
|
||||
|
||||
@@ -288,14 +288,15 @@ function ViewRow({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
@@ -305,10 +306,10 @@ function ViewRow({
|
||||
<button
|
||||
onClick={onToggleDefault}
|
||||
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
|
||||
? "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" : ""}`} />
|
||||
@@ -316,21 +317,21 @@ function ViewRow({
|
||||
<button
|
||||
onClick={onShare}
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onStartRename}
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function SearchBar({
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
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" />
|
||||
<input
|
||||
value={value}
|
||||
|
||||
@@ -130,7 +130,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<input
|
||||
readOnly
|
||||
|
||||
@@ -158,7 +158,7 @@ function FilterFunnel<T>({
|
||||
checked={!filterOn}
|
||||
onSelect={() => onApply(col.key, "")}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
@@ -171,7 +171,7 @@ function FilterFunnel<T>({
|
||||
checked={value === o.value}
|
||||
onSelect={() => onApply(col.key, o.value)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
|
||||
+82
-57
@@ -265,69 +265,94 @@ body {
|
||||
}
|
||||
|
||||
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
|
||||
.glass {
|
||||
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
|
||||
/* In @layer components so a JSX shadow-* utility (which lives in @layer utilities, always above
|
||||
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.
|
||||
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. */
|
||||
background:
|
||||
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(--surface) var(--glass-surface-alpha), transparent);
|
||||
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-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.glass-card {
|
||||
/* No backdrop-filter here: cards render in bulk (feed grid) over a solid background
|
||||
background:
|
||||
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(--surface) var(--glass-surface-alpha), transparent);
|
||||
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-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent),
|
||||
0 18px 44px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.glass-card {
|
||||
/* 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.
|
||||
Translucency + border + depth keep the look; blur is reserved for .glass overlays. */
|
||||
background:
|
||||
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(--card) var(--glass-card-alpha), transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 65%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
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);
|
||||
}
|
||||
.glass-menu {
|
||||
/* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs),
|
||||
background:
|
||||
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(--card) var(--glass-card-alpha), transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
border-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 65%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
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);
|
||||
}
|
||||
.glass-menu {
|
||||
/* 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. */
|
||||
background:
|
||||
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(--surface) var(--glass-menu-alpha), transparent);
|
||||
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-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 80%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
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);
|
||||
background:
|
||||
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(--surface) var(--glass-menu-alpha), transparent);
|
||||
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-top-color: color-mix(
|
||||
in srgb,
|
||||
#fff var(--glass-edge),
|
||||
color-mix(in srgb, var(--border) 80%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
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);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user