fix(a11y): address R9 code-review findings (glass shadow, focus dedup)

- #1: remove the 9 dead `shadow-*` from glass surfaces instead of letting them override the tuned glass
  box-shadow. The @layer components move made a JSX shadow-* win AND atomically drop the glass inset
  top-edge highlight; deleting the (always-dead) utilities keeps the intended glass depth incl. the
  inset sheen. Verified in-browser: a .glass box-shadow again includes its inset segment.
- #3: drop the per-element focus-visible outlines from the rail chrome buttons and SavedViews action
  buttons — the global button:focus-visible fallback already draws the identical 2px accent ring, so
  they were redundant. Verified: the pin button rings from the global rule alone.
- #5: unify the focus-within outline offset (offset-2) on the SearchBar and ShareDialog pills.
- #2 (double outline on the pill when an inner button is focused): NOT fixed — the clean fix
  (has-[input:focus] so only the input rings the pill) does not generate under Tailwind v4 (measured:
  no rule emitted). Kept the working focus-within, which is a11y-correct (every element still shows a
  focus indicator); the overlapping ring is an accepted cosmetic edge-case.
- #4 (weak ring on solid-fill selected items): deferred to R10 with the selected-paint tokenisation.

Gate green (tsc/eslint/prettier/knip/vitest 79).
This commit is contained in:
2026-08-08 09:28:23 +02:00
parent c11e2822e9
commit 205f18a2e1
13 changed files with 25 additions and 26 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ export default function AddToPlaylist({
<ListPlus className="w-4 h-4" />
</button>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
+1 -1
View File
@@ -51,7 +51,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal(
<button
onClick={toTop}
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
+1 -1
View File
@@ -73,7 +73,7 @@ export default function ChatDock({ meId }: { meId: number }) {
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
+1 -1
View File
@@ -649,7 +649,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
+1 -1
View File
@@ -284,7 +284,7 @@ export default function GlassTuner() {
{open && (
<div
style={{ pointerEvents: "auto" }}
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden shadow-2xl"
className="glass-menu rounded-l-2xl rounded-r-none w-[300px] max-h-[82vh] flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg">◐ Glass Tuner</span>
+1 -3
View File
@@ -66,9 +66,7 @@ export default function Modal({
role="dialog"
aria-modal="true"
>
<div
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
>
<div className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl`}>
<div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
<button
+4 -4
View File
@@ -241,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 outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")}
>
Sift<span className="text-accent">lode</span>
@@ -251,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 outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
@@ -290,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 outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
@@ -298,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 outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
+1 -1
View File
@@ -916,7 +916,7 @@ export default function PlayerModal({
<div
ref={cardRef}
tabIndex={-1}
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-hidden"
className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl outline-hidden"
onClick={(e) => e.stopPropagation()}
>
<div
+6 -6
View File
@@ -288,7 +288,7 @@ function ViewRow({
{...attributes}
{...listeners}
title={t("views.reorder")}
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"
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
@@ -296,7 +296,7 @@ function ViewRow({
onClick={onApply}
title={t("views.apply")}
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 ${
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
active ? "text-accent font-medium" : "text-fg"
}`}
>
@@ -306,7 +306,7 @@ function ViewRow({
<button
onClick={onToggleDefault}
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
className={`p-1 rounded outline-hidden transition focus-visible:outline-2 focus-visible:outline-accent/60 ${
className={`p-1 rounded transition ${
view.is_default
? "text-accent"
: "text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-accent"
@@ -317,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 group-focus-within:opacity-100 hover:text-fg transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
>
<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 group-focus-within:opacity-100 hover:text-fg transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-fg transition"
>
<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 group-focus-within:opacity-100 hover:text-red-400 transition outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60"
className="p-1 rounded-sm text-muted opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 hover:text-red-400 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
+1 -1
View File
@@ -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 focus-within:outline-2 focus-within:outline-accent/60">
<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 focus-within:outline-offset-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
+1 -1
View File
@@ -61,7 +61,7 @@ function UserShare({ job }: { job: DownloadJob }) {
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
+1 -1
View File
@@ -37,7 +37,7 @@ export function DraftSaveBar({
const saving = state === "saving";
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
+5 -4
View File
@@ -280,10 +280,11 @@ body {
}
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
/* 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). */
/* In @layer components so the recipe classes below (.selectable) compose with utilities instead of
silently beating them the way these rules did while unlayered. (The ~9 dead `shadow-*` that used to
sit on glass surfaces were REMOVED in R9, not activated: the tuned glass box-shadow — including its
inset top-edge highlight — is the intended depth, and a shadow-* utility would atomically replace it
and drop that highlight.) The perf-mode and :hover overrides below stay UNLAYERED on purpose. */
@layer components {
.glass {
/* Frosted overlay surface: translucent enough that the blurred backdrop shows