fix(design): claim audit part 1 — a closing key no longer re-presses its trigger, the Switch box and name, named z-levels (E28)

- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper,
  Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same
  keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting
  the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and
  Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default.
- Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does.
- Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and
  the Plex two-way-sync switch had no name.
- z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract.
- The /watch file card's Download is the filled primary: the page's one action.
- Stale comments: the 1024x768 floor, the retired glass tuner.
This commit is contained in:
2026-09-25 02:25:54 +02:00
parent b4a6569d0d
commit 0521149cf1
25 changed files with 227 additions and 41 deletions
+7
View File
@@ -318,6 +318,11 @@ const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/"; "/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
const BOX_PX_MESSAGE = const BOX_PX_MESSAGE =
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx]."; "px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
const Z_RAW_MESSAGE =
"z-index off the layer scale: use a named level (z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
@@ -431,6 +436,8 @@ export default tseslint.config(
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn. // 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE }, { selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE }, { selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
], ],
}, },
}, },
+2 -2
View File
@@ -882,11 +882,11 @@ export default function Playlists() {
onBlur={saveRename} onBlur={saveRename}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(); renameFocus.arm(e);
saveRename(); saveRename();
} }
if (e.key === "Escape") { if (e.key === "Escape") {
renameFocus.arm(); renameFocus.arm(e);
setRenaming(false); setRenaming(false);
} }
}} }}
@@ -103,7 +103,8 @@ describe("PlaylistsRail · rename from the keyboard", () => {
it("Escape cancels and puts the focus back on the row", async () => { it("Escape cancels and puts the focus back on the row", async () => {
const field = await openRename(); const field = await openRename();
fireEvent.change(field, { target: { value: "alpha2" } }); fireEvent.change(field, { target: { value: "alpha2" } });
fireEvent.keyDown(field, { key: "Escape" }); // Cancelled: the closing key must not go on to press the row the focus lands on (E28 audit).
expect(fireEvent.keyDown(field, { key: "Escape" })).toBe(false);
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
expect(rename).not.toHaveBeenCalled(); expect(rename).not.toHaveBeenCalled();
}); });
@@ -111,7 +112,7 @@ describe("PlaylistsRail · rename from the keyboard", () => {
it("Enter commits and puts the focus back on the row", async () => { it("Enter commits and puts the focus back on the row", async () => {
const field = await openRename(); const field = await openRename();
fireEvent.change(field, { target: { value: "beta" } }); fireEvent.change(field, { target: { value: "beta" } });
fireEvent.keyDown(field, { key: "Enter" }); expect(fireEvent.keyDown(field, { key: "Enter" })).toBe(false);
expect(rename).toHaveBeenCalledWith(1, "beta"); expect(rename).toHaveBeenCalledWith(1, "beta");
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton())); await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
}); });
@@ -332,12 +332,16 @@ export default function PlaylistsRail({
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)} onBlur={() => void commitRename(pl)}
onKeyDown={(e) => { onKeyDown={(e) => {
// preventDefault: the focus lands on the row before this keystroke is done,
// and a real Enter's own activation would then press it (see useReturnFocus).
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault();
refocusRowRef.current = pl.id; refocusRowRef.current = pl.id;
void commitRename(pl); void commitRename(pl);
} }
// Unmounting the input fires no blur in React, so Escape really does cancel. // Unmounting the input fires no blur in React, so Escape really does cancel.
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault();
refocusRowRef.current = pl.id; refocusRowRef.current = pl.id;
setRenamingId(null); setRenamingId(null);
} }
@@ -282,11 +282,11 @@ export default function PlexCollectionView({
onBlur={doRename} onBlur={doRename}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(); renameFocus.arm(e);
doRename(); doRename();
} }
if (e.key === "Escape") { if (e.key === "Escape") {
renameFocus.arm(); renameFocus.arm(e);
setRenaming(false); setRenaming(false);
} }
}} }}
@@ -5,7 +5,7 @@
// (E9 review). The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list // (E9 review). The runtime sums the WHOLE list, an unknown duration counts as nothing, and a list
// nothing sums for says no runtime at all. // nothing sums for says no runtime at all.
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen } from "@testing-library/react"; import { render, cleanup, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({ vi.mock("react-i18next", async (importOriginal) => ({
@@ -71,3 +71,21 @@ describe("PlexPlaylistView · the header's meta line", () => {
expect(await meta([movie("1")])).toBe("plex.browse.items"); expect(await meta([movie("1")])).toBe("plex.browse.items");
}); });
}); });
// E28 audit: Enter on an unchanged (or emptied) title wrote the same name back to Plex, and an empty
// one armed the focus hand-back while leaving the field open. Now both close with no write, as the
// collection rename does.
describe("PlexPlaylistView · rename", () => {
it("closes without a write when the title is unchanged or empty", async () => {
const write = vi.spyOn(api, "plexRenamePlaylist").mockResolvedValue({} as never);
await meta([movie("1", 5400)]);
for (const value of [null, " "]) {
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.rename" }));
const field = screen.getByRole("textbox", { name: "plex.playlist.rename" });
if (value !== null) fireEvent.change(field, { target: { value } });
fireEvent.keyDown(field, { key: "Enter" });
await screen.findByRole("heading", { name: "Films" });
}
expect(write).not.toHaveBeenCalled();
});
});
+8 -3
View File
@@ -401,7 +401,12 @@ export default function PlexPlaylistView({
async function rename() { async function rename() {
const title = name.trim(); const title = name.trim();
if (!title) return; // Nothing to write (as the collection rename): close, so an armed focus hand-back fires now and
// not on some later close.
if (!title || title === q.data?.title) {
setRenaming(false);
return;
}
try { try {
await api.plexRenamePlaylist(playlistId, title); await api.plexRenamePlaylist(playlistId, title);
} catch (e) { } catch (e) {
@@ -533,12 +538,12 @@ export default function PlexPlaylistView({
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
renameFocus.arm(); renameFocus.arm(e);
rename(); rename();
} }
// Unmounting the field fires no blur, so Escape really does cancel. // Unmounting the field fires no blur, so Escape really does cancel.
if (e.key === "Escape") { if (e.key === "Escape") {
renameFocus.arm(); renameFocus.arm(e);
setRenaming(false); setRenaming(false);
} }
}} }}
+1 -1
View File
@@ -140,7 +140,7 @@ export default function PlexSearchBox() {
id={listId} id={listId}
role="listbox" role="listbox"
aria-label={t("plex.suggest.aria")} aria-label={t("plex.suggest.aria")}
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto animate-[popIn_0.16s_ease]`} className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70vh] overflow-y-auto animate-[popIn_0.16s_ease]`}
> >
{suggestions.length === 0 && suggestQ.isFetching && ( {suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div> <div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
@@ -165,6 +165,16 @@ describe("Scheduler · the job interval editor", () => {
), ),
); );
}); });
// E28 audit: a real Enter re-opened the editor — the keystroke's own activation landed on the trigger
// the focus had just returned to. The closing keys cancel their default action.
it("closing by a key does not press the trigger again", async () => {
await mount();
for (const key of ["Enter", "Escape"]) {
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(fireEvent.keyDown(intervalBox()!, { key })).toBe(false);
}
});
}); });
describe("Scheduler · the maintenance batch editor", () => { describe("Scheduler · the maintenance batch editor", () => {
+10 -2
View File
@@ -196,8 +196,16 @@ function JobRow({
aria-errormessage={valid ? undefined : rangeId} aria-errormessage={valid ? undefined : rangeId}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") save(); // preventDefault: close() returns the focus to the trigger before this keystroke
if (e.key === "Escape") close(); // is done, and a real Enter's own activation would press it again (useReturnFocus).
if (e.key === "Enter") {
e.preventDefault();
save();
}
if (e.key === "Escape") {
e.preventDefault();
close();
}
}} }}
/> />
<span className="text-muted text-xs">{t("scheduler.minutes")}</span> <span className="text-muted text-xs">{t("scheduler.minutes")}</span>
+6 -1
View File
@@ -598,7 +598,12 @@ function PlexWatchSync() {
<Section title={t("settings.plexSync.title")}> <Section title={t("settings.plexSync.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}> <SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
<Switch checked={shown} disabled={busy} onChange={(v) => !busy && toggle.mutate(v)} /> <Switch
label={t("settings.plexSync.toggle")}
checked={shown}
disabled={busy}
onChange={(v) => !busy && toggle.mutate(v)}
/>
</SettingRow> </SettingRow>
{busy ? ( {busy ? (
<div className="mt-2 flex items-center gap-2 text-xs text-muted"> <div className="mt-2 flex items-center gap-2 text-xs text-muted">
+1 -1
View File
@@ -110,7 +110,7 @@ function Actions({
// overlay for the thumbnailed views, and inline beside the actions for the one-line row. // overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL = const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"; "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-10"); const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-base");
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click // Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href. // keep the native "open youtube.com in a new tab" behavior via the underlying href.
@@ -122,6 +122,8 @@ describe("WatchPage media element (S3 · 5a)", () => {
await findByText(/PDF · 2\.3 MB/); await findByText(/PDF · 2\.3 MB/);
const dl = container.querySelector('a[href="/f.pdf"]'); const dl = container.querySelector('a[href="/f.pdf"]');
expect(dl).not.toBeNull(); expect(dl).not.toBeNull();
// E28: the page's one action is the filled primary, not a muted outline.
expect(dl!.className).toContain("state-filled");
}); });
it("omits the Download for a view-only file link (S5d)", async () => { it("omits the Download for a view-only file link (S5d)", async () => {
+8 -2
View File
@@ -305,7 +305,8 @@ export default function WatchPage() {
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the /* A generic uploaded file has no player — just an info card (name/type/size) and, if the
link allows it, a Download. The backend serves the file force-download regardless, so link allows it, a Download. The backend serves the file force-download regardless, so
gating the button on allow_download only mirrors the owner's ACL, matching the media gating the button on allow_download only mirrors the owner's ACL, matching the media
branch's download affordance below. */ branch's download affordance below. It is the page's one action, so it is the filled
primary (E28), as the unlock card's Watch is. */
<div className="flex-1 grid place-items-center"> <div className="flex-1 grid place-items-center">
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}> <div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4"> <div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
@@ -320,7 +321,12 @@ export default function WatchPage() {
.join(" · ")} .join(" · ")}
</div> </div>
{meta.allow_download && ( {meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} className="mt-5"> <LinkButton
href={meta.file_url}
variant="primary"
icon={<Download />}
className="mt-5"
>
{T.download} {T.download}
</LinkButton> </LinkButton>
)} )}
+4 -4
View File
@@ -89,7 +89,7 @@ export function PlexPosterCard({
decoding="async" decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/> />
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the dot
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none): ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
without it the overlay sits on top and swallows the cursor, so the dot's native `title` without it the overlay sits on top and swallows the cursor, so the dot's native `title`
tooltip never fired on hover (a regression once the reveal overlay was added). */} tooltip never fired on hover (a regression once the reveal overlay was added). */}
@@ -100,7 +100,7 @@ export function PlexPosterCard({
<span <span
role="img" role="img"
aria-label={t(`plex.playable.${cls}`)} aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
title={t(`plex.playable.${cls}`)} title={t(`plex.playable.${cls}`)}
/> />
); );
@@ -181,7 +181,7 @@ export function PlexPosterCard({
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */} {/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
{isPlayable && ( {isPlayable && (
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1"> <div className="reveal absolute right-1.5 top-1.5 z-base flex gap-1">
<IconButton <IconButton
variant="media" variant="media"
onClick={(e) => { onClick={(e) => {
@@ -206,7 +206,7 @@ export function PlexPosterCard({
)} )}
{pct > 0 && ( {pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-black/40"> <div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} /> <div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div> </div>
)} )}
+1 -1
View File
@@ -99,7 +99,7 @@ export function Carousel({
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
// Round, dark translucent: they sit over the pictures (A2). // Round, dark translucent: they sit over the pictures (A2).
const arrowBtn = const arrowBtn =
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " + "reveal-hover absolute top-1/2 -translate-y-1/2 z-base backdrop-blur-xs border border-white/15 shadow-lg " +
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100"; "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
return ( return (
@@ -174,10 +174,10 @@ export function PriorityStepper({
onBlur={commitEdit} onBlur={commitEdit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
editFocus.arm(); editFocus.arm(e);
commitEdit(); commitEdit();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
editFocus.arm(); editFocus.arm(e);
setEditing(false); setEditing(false);
} }
}} }}
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
//
// E28 audit: the Switch was a bare 36×20 track — under the 24 floor on a mouse, never the 44 touch box
// DESIGN.md A3 lists it under, and its name was optional, so the Plex-sync switch shipped nameless.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { Switch } from "./form";
afterEach(cleanup);
describe("Switch", () => {
it("is a named switch whose own box meets the 24 floor and grows to the touch target", () => {
const onChange = vi.fn();
render(<Switch label="Dark mode" checked={false} onChange={onChange} />);
const sw = screen.getByRole("switch", { name: "Dark mode" });
expect(sw.getAttribute("aria-checked")).toBe("false");
expect(sw.className).toContain("h-6");
expect(sw.className).toContain("pointer-coarse:min-h-(--touch-target)");
fireEvent.click(sw);
expect(onChange).toHaveBeenCalledWith(true);
});
it("cannot be rendered without a name", () => {
// @ts-expect-error — `label` is required: a switch has no text of its own to be named by.
render(<Switch checked onChange={() => {}} />);
expect(screen.getByRole("switch")).toBeTruthy();
});
});
+14 -8
View File
@@ -6,8 +6,10 @@ import { SURFACE } from "./surfaces";
// Shared form/settings primitives, factored out of the many panels that each re-declared // Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for /** Pill on/off switch — the bare control; compose it with your own label/row. `label` is the
* the accessible name (the visible row text) since the control itself has no inner text. */ * accessible name (the visible row text), required: the control has no inner text to be named by.
* The button is its own box around the 36×20 track: 24 tall on a mouse (the B1 floor) and the 44
* touch target on a coarse pointer (A3), a real box rather than an invisible hit area. */
export function Switch({ export function Switch({
checked, checked,
onChange, onChange,
@@ -16,7 +18,7 @@ export function Switch({
}: { }: {
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
label?: string; label: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
// FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a // FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
@@ -29,13 +31,17 @@ export function Switch({
aria-label={label} aria-label={label}
disabled={disabled} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 disabled:opacity-50 disabled:cursor-not-allowed ${checked ? "bg-accent" : "bg-border"}`} className="group inline-flex h-6 min-w-9 shrink-0 items-center justify-center rounded-full disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-(--touch-target) pointer-coarse:min-w-(--touch-target)"
> >
<span <span
className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${ className={`relative h-5 w-9 rounded-full transition ${checked ? "bg-accent" : "bg-border"}`}
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white" >
}`} <span
/> className={`absolute top-0.5 w-4 h-4 rounded-full shadow transition-all ${
checked ? "left-4.5 bg-accent-fg" : "left-0.5 bg-white"
}`}
/>
</span>
</FocusSafeButton> </FocusSafeButton>
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// VirtualGrid's column-count policy, extracted as a pure function so the viewport contract's UPPER // VirtualGrid's column-count policy, extracted as a pure function so the viewport contract's UPPER
// end is unit-testable without a layout engine (the lower end, the 1024x768 floor, needs no policy — // end is unit-testable without a layout engine (the lower end, the 768-wide tablet floor, needs no policy —
// the grid simply drops to the columns that fit). // the grid simply drops to the columns that fit).
// //
// An auto-fill grid answers "more room" with more columns, never bigger ones, so it always settles // An auto-fill grid answers "more room" with more columns, never bigger ones, so it always settles
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
//
// E28 audit: an in-place editor closed by a REAL Enter re-opened at once. The keydown commits and hands
// the focus back to the trigger button; the same keystroke's default action (the keypress activation)
// then lands on that button and clicks it. A synthetic keydown has no default action, so the first
// test passed while the live rename was broken. Arming now cancels the key's default action.
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useReturnFocus } from "./useReturnFocus";
afterEach(cleanup);
function Editor() {
const [open, setOpen] = useState(false);
const focus = useReturnFocus(open);
return open ? (
<input
aria-label="name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "Escape") {
focus.arm(e);
setOpen(false);
}
}}
/>
) : (
<button ref={focus.target} onClick={() => setOpen(true)}>
Rename
</button>
);
}
describe("useReturnFocus", () => {
it("returns the focus to the trigger, and the closing key does not press it again", () => {
render(<Editor />);
for (const key of ["Enter", "Escape"]) {
fireEvent.click(screen.getByRole("button", { name: "Rename" }));
// fireEvent returns false when a handler cancelled the event's default action.
expect(fireEvent.keyDown(screen.getByRole("textbox", { name: "name" }), { key })).toBe(false);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Rename" }));
}
});
});
+8 -2
View File
@@ -4,8 +4,13 @@ import { useCallback, useEffect, useRef } from "react";
// and back. Leaving it by Enter or Escape unmounts the focused field, and the browser drops the focus // and back. Leaving it by Enter or Escape unmounts the focused field, and the browser drops the focus
// on <body>: the next Tab starts again at the top of the page (WCAG 2.4.3). This hands the focus back // on <body>: the next Tab starts again at the top of the page (WCAG 2.4.3). This hands the focus back
// to the trigger once the editor has CLOSED — after the commit, so the trigger is mounted and visible // to the trigger once the editor has CLOSED — after the commit, so the trigger is mounted and visible
// again whether it stayed in the tree or was swapped out. `arm()` it from the keys only: leaving by a // again whether it stayed in the tree or was swapped out. `arm(e)` it from the keys only: leaving by a
// click elsewhere means the user already put the focus where they wanted it. // click elsewhere means the user already put the focus where they wanted it.
//
// `arm` takes the key event and cancels its default action. The focus lands on the trigger BEFORE the
// same keystroke has finished: a real Enter's keypress then activates the trigger button and re-opens
// the editor it just closed (the playlist rename did exactly that; a synthetic keydown in a test has no
// default action, so only a real key showed it).
export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open: boolean) { export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open: boolean) {
const target = useRef<T | null>(null); const target = useRef<T | null>(null);
const armed = useRef(false); const armed = useRef(false);
@@ -14,7 +19,8 @@ export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open:
armed.current = false; armed.current = false;
target.current?.focus(); target.current?.focus();
}, [open]); }, [open]);
const arm = useCallback(() => { const arm = useCallback((e: { preventDefault(): void }) => {
e.preventDefault();
armed.current = true; armed.current = true;
}, []); }, []);
return { target, arm }; return { target, arm };
+4 -5
View File
@@ -1035,11 +1035,10 @@ html[data-theme="light"] {
.player-stage--max { .player-stage--max {
position: fixed; position: fixed;
inset: 0; inset: 0;
/* rail-level (40): this stage only has to beat the modal chrome it is nested inside (whose own /* rail level: this stage only has to beat the modal chrome it is nested inside (whose own
children top out at z-menu/20). The modal root is a z-overlay stacking context, so nothing children top out at the menu level). The modal root is a z-overlay stacking context, so nothing
here escapes it — 9999 is the glass tuner's reserved level and claimed an authority this here escapes it — a higher level would claim an authority this layer neither has nor needs. */
layer neither has nor needs. */ z-index: var(--z-index-rail);
z-index: 40;
} }
/* Thin, theme-aware scrollbars */ /* Thin, theme-aware scrollbars */
+1 -1
View File
@@ -23,7 +23,7 @@ export interface FeedViewSpec {
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */ * scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
colMaxWidth: number | null; colMaxWidth: number | null;
/** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of /** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of
* the 1024x768..ultrawide contract (E10 S4). An auto-fill grid always settles just above `minCol`, * the tablet..ultrawide contract (E10 S4; tablet portrait since E28). An auto-fill grid always settles just above `minCol`,
* so without a cap a wider screen buys more, smaller items rather than better ones — measured at * so without a cap a wider screen buys more, smaller items rather than better ones — measured at
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near * 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped * 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
+34
View File
@@ -0,0 +1,34 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// The layer-scale rule in eslint.config.js (DESIGN.md Tokens: "No ad-hoc z-index"). A numeric `z-10`
// happened to equal `base` and passed every review; a `no-restricted-syntax` selector that matches
// nothing looks exactly like a clean codebase, so pin that it fires.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
async function refused(source: string): Promise<number> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath: "src/components/__probe.tsx" });
return (result?.messages ?? []).filter((m) => m.message.startsWith("z-index off the layer scale"))
.length;
}
describe("a z-index off the layer scale", () => {
// Assembled, so this file does not trip the very rule it probes.
const z = (v: string) => `z-${v}`;
it("is refused as a number, an arbitrary value or a variant, in a string and a template", async () => {
expect(await refused(`export const a = "absolute ${z("10")} top-0";\n`)).toBe(1);
expect(await refused(`export const b = "hover:${z("[5]")}";\n`)).toBe(1);
expect(await refused("export const c = `x ${1} -" + z("20") + "`;\n")).toBe(1);
});
it("leaves a named level, z-0 and z-auto alone", async () => {
expect(await refused(`export const d = "${z("base")} ${z("menu")} ${z("tooltip")}";\n`)).toBe(
0,
);
expect(await refused(`export const e = "${z("0")} md:${z("auto")}";\n`)).toBe(0);
});
});