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}
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user