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
+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.
// Round, dark translucent: they sit over the pictures (A2).
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";
return (
@@ -174,10 +174,10 @@ export function PriorityStepper({
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === "Enter") {
editFocus.arm();
editFocus.arm(e);
commitEdit();
} else if (e.key === "Escape") {
editFocus.arm();
editFocus.arm(e);
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
// 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
* the accessible name (the visible row text) since the control itself has no inner text. */
/** Pill on/off switch — the bare control; compose it with your own label/row. `label` is the
* 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({
checked,
onChange,
@@ -16,7 +18,7 @@ export function Switch({
}: {
checked: boolean;
onChange: (v: boolean) => void;
label?: string;
label: string;
disabled?: boolean;
}) {
// 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}
disabled={disabled}
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
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"
}`}
/>
className={`relative h-5 w-9 rounded-full transition ${checked ? "bg-accent" : "bg-border"}`}
>
<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>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
// 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).
//
// 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
// 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
// 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.
//
// `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) {
const target = useRef<T | null>(null);
const armed = useRef(false);
@@ -14,7 +19,8 @@ export function useReturnFocus<T extends HTMLElement = HTMLButtonElement>(open:
armed.current = false;
target.current?.focus();
}, [open]);
const arm = useCallback(() => {
const arm = useCallback((e: { preventDefault(): void }) => {
e.preventDefault();
armed.current = true;
}, []);
return { target, arm };