diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index e2af1a75..9ddb995f 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -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} diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index f807375d..f4c173fe 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index d9cac4c0..15c7d638 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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`} > 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" > {t("header.account.addAccount")} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 7b2983bd..2694d3bb 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -31,7 +31,7 @@ export default function SearchBar({ if (onGo && !goDisabled) onGo(); }; return ( -
+
void } return (
-
+
({ 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({ 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" }`} > diff --git a/frontend/src/index.css b/frontend/src/index.css index 300cf9bf..f44109df 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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);