chore(comments): drop epic, sprint and review-round ids from the code

The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
This commit is contained in:
2026-10-05 00:19:39 +02:00
parent c73d8be094
commit 4aaaaa7860
669 changed files with 1831 additions and 1904 deletions
@@ -46,7 +46,7 @@ describe("ActionMenu", () => {
// A `title` on a disabled row reached nobody: a disabled button fires no pointer events and
// roving focus skips it. The reason is visible text now, undimmed so it can be read. An enabled
// row's state ("Downloaded" under "Download…") is the same line: a `title` shows to a resting
// mouse only, never to a key or a finger (round 2, F5).
// mouse only, never to a key or a finger.
renderMenu([
{ id: "move", label: "Move to", onSelect: vi.fn(), disabled: true, note: "No other list" },
{ id: "dl", label: "Download…", onSelect: vi.fn(), note: "Downloaded" },
@@ -76,7 +76,7 @@ describe("ActionMenu", () => {
// Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
// back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run
// back to the trigger behind the dialog's scrim — measured in Chrome. jsdom does not run
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
const onSelect = vi.fn();
@@ -182,7 +182,7 @@ describe("ActionMenu", () => {
});
});
// PageUp/PageDown in a roving menu (E11, owner request) — the same page as the palette's and the
// PageUp/PageDown in a roving menu (owner request) — the same page as the palette's and the
// combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover).
describe("ActionMenu · paging", () => {
it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => {
+4 -4
View File
@@ -8,12 +8,12 @@ import { Pressable } from "./Pressable";
import { SURFACE } from "./surfaces";
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// Built on the AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
// and LanguageSwitcher. Use it to keep occasional actions out of a crowded toolbar. The items are
// fire-and-close (`onSelect`) — all but a `check: { kind: "many" }` row, which toggles in place; a
// `disabled` item (e.g. a mutation already in flight) is inert.
//
// E10 S2 split the ITEM LIST out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
// The ITEM LIST is split out of the trigger (`MenuItemList`) so the ⋯ menu and the right-click
// context menu (`ContextMenu`) render the same rows from the same item type. That is the whole point
// of the split: a context menu that drifts from the overflow menu beside it is two menus, and the
// user has to learn both.
@@ -46,11 +46,11 @@ interface MenuItemBase {
title?: string;
/** Why a DISABLED row is off, shown as a line under its label. Not a `title`: a disabled button
* fires no pointer events (no tooltip) and roving focus skips it, so a reason there reached
* nobody. An `aria-disabled` row was tried and reverted (E10 review rounds 1–2: floating-ui skips
* nobody. An `aria-disabled` row was tried and reverted (floating-ui skips
* it identically, and the mouse got a highlight that then no-opped). Visible text is the one
* place a disabled row's reason can be: seen, and read in a screen reader's browse mode.
* On an ENABLED row it is the state of what the row opens ("Downloaded" under "Download…"),
* for the same reason: a `title` shows to a resting mouse only (round 2, F5). */
* for the same reason: a `title` shows to a resting mouse only. */
note?: string;
/** Draw a divider above this item. Ignored on the first item — a menu never opens with a rule. */
separatorBefore?: boolean;
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Which surfaces give the focus back to a card focused as a whole (deep-review E11 rounds 2 and 3). A
// Which surfaces give the focus back to a card focused as a whole (found in code review). A
// menu does, by hand — floating-ui's own return would land on the card's first link (see
// ContextMenu.dom.test). A hover card never returned focus, and one closing after the reader clicked a
// link in it must not pull the focus back to the card under it.
+11 -11
View File
@@ -18,7 +18,7 @@ import {
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import type { useAnchoredPopover } from "./anchoredPopoverHooks";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// The popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
// - flip() → measured two-way flip (top/bottom) when a side runs out of room
@@ -28,9 +28,9 @@ import type { useAnchoredPopover } from "./anchoredPopoverHooks";
// - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
// outside-press dispatch is topmost-only across nested layers (the Modal and players included).
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// This layer adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only;
// PageUp/PageDown (a page, no loop) are ours, for every list surface incl. the combobox
@@ -45,9 +45,9 @@ import type { useAnchoredPopover } from "./anchoredPopoverHooks";
//
// The trigger takes its ref DESTRUCTURED: `const { setReference } = pop.refs;` then
// `ref={setReference} {...pop.getReferenceProps()}`. Written `ref={pop.refs.setReference}`, the React
// Compiler treats the whole `pop` as a ref and skips the component (E32 S3 — 21 call sites did).
// Compiler treats the whole `pop` as a ref and skips the component (21 call sites once did).
//
// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
// A "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input
// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips /
// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model:
// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick;
@@ -93,7 +93,7 @@ type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
* 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.) */
// The roving-focus highlight. A hovered/roving row gets the SAME strong accent fill whether or not it
// is selected — a checked row no longer reads as a weak ring-only afterthought (R10 weak-ring fix). The
// is selected — a checked row no longer reads as a weak ring-only afterthought. The
// selected state stays legible ON the fill because these menus mark "selected" with a CHILD element (a
// checkmark / checkbox) that paints over the row tint; the selected row just gets a stronger ring
// (ring-2, accent/80) than an unselected one (ring-1, accent/60) to keep the two apart.
@@ -242,7 +242,7 @@ interface PopoverSurfaceProps {
passthrough?: boolean;
/** The panel's role (a combobox's `listbox`) goes on an inner `<PopoverList>` instead of the panel
* itself, so the panel can also hold lines ABOUT the list — loading, nothing found, a failed load
* with its Retry — outside it: a listbox may own options only (round 2, F4). */
* with its Retry — outside it: a listbox may own options only. */
listInside?: boolean;
}
@@ -308,7 +308,7 @@ function PopoverSurfaceInner({
// handled by the element-identity dependency. (Keying the portal to remount instead would rebuild
// the subtree and lose all of that.) Focus does NOT survive on its own — `appendChild` on an
// already-parented node is remove-then-insert, which blurs to <body>; the hook saves and restores
// it. This used to be a second copy of Modal's re-parent that lacked exactly that (round 5).
// it. This used to be a second copy of Modal's re-parent that lacked exactly that.
const container = useFullscreenPortalHost();
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
@@ -336,7 +336,7 @@ function PopoverSurfaceInner({
// but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even
// when handed the element as a ref (its return runs through `getFirstTabbableElement` either way).
// A card the keyboard is on (lib/listNav gives its root tabindex=-1) is exactly that: measured in
// Chrome (deep-review E11 round 2), j, Shift+F10, Escape left focus on the card's thumbnail link, the
// Chrome, j, Shift+F10, Escape left focus on the card's thumbnail link, the
// ring off the card and the next Enter on a control. So for such an element the library's return is
// off and this gives the focus back to it as it is — captured at open, before focus moves into the
// panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed:
@@ -345,7 +345,7 @@ function PopoverSurfaceInner({
//
// Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never
// returned focus, and a hover card closing after the reader clicked a link in it must not pull the
// focus back to the card under it (deep-review E11 round 3).
// focus back to the card under it.
const [returnTo] = useState(() => {
const active = document.activeElement;
return managed &&
@@ -360,7 +360,7 @@ function PopoverSurfaceInner({
return () => {
// "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the
// panel a commit after this surface mounts, so the panel's ref is still empty when this effect
// runs (deep-review E11 round 3).
// runs.
const active = document.activeElement;
const stillOurs =
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 review F7: while hidden the button only went transparent — a Tab still landed on the invisible
// While hidden the button only went transparent — a Tab still landed on the invisible
// control. And a keyboard press scrolled to the top and hid it with the focus still on it.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+1 -1
View File
@@ -44,7 +44,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
}, [dep, threshold]);
// A keyboard press hides the button it was on: the focus goes to the page it scrolled (its <main>
// is a focus rest, not a Tab stop), not left on an invisible control (E28 review F7).
// is a focus rest, not a Tab stop), not left on an invisible control.
const toTop = (e: React.MouseEvent) => {
const main = scrollerRef.current ?? document.querySelector("main");
main?.scrollTo({ top: 0, behavior: "smooth" });
+1 -1
View File
@@ -1,7 +1,7 @@
import clsx from "clsx";
import type { ReactNode } from "react";
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner, E28 S3d).
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner's call).
// Every chip that does something has an edge, so "edged = pressable, flat = just says" holds at a
// glance; a static tag painted like a chip is clicked and does nothing. The tint is the text colour
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
+13 -13
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
//
// S5: the shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
// on: variant/size paint (the E28 B1 ladder and B3 layers), the loading state (disabled + spinner +
// aria-busy), the pressed toggle, and the type="button" default that stops an action button from
// The shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
// on: variant/size paint (the DESIGN.md B1 ladder and B3 layers), the loading state (disabled +
// spinner + aria-busy), the pressed toggle, and the type="button" default that stops an action button from
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
@@ -10,7 +10,7 @@ import { Button } from "./Button";
afterEach(cleanup);
describe("Button primitive (S5)", () => {
describe("Button primitive", () => {
it("defaults to type=button so it never submits a form", () => {
render(<Button>Go</Button>);
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
@@ -85,7 +85,7 @@ describe("Button primitive (S5)", () => {
expect(onClick).not.toHaveBeenCalled();
});
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
// Measured in Chrome: a focused button that becomes natively disabled drops the focus to
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
it("loading keeps the button natively enabled, so it keeps the focus", () => {
@@ -159,7 +159,7 @@ describe("Button primitive (S5)", () => {
it("carries the coarse-pointer minimum target and the 24 floor, a link variant too", () => {
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link variant was once excluded as "text in a sentence", but every
// one stands alone, and the E28 audit measured them at 16–20px.
// one stands alone, and an audit measured them at 16–20px.
const { unmount } = render(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
@@ -180,16 +180,16 @@ describe("Button primitive (S5)", () => {
// --- the accessible-name contract, pinned at the TYPE level ------------------------------------
// A button that renders no text must carry a name, and that rule is a union in Button.tsx rather
// than a doc comment because the comment version let two nameless icon buttons ship (E10 review
// round 1). But a type with no test is a type someone widens to silence a stubborn call site:
// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green
// (round 2). Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening,
// than a doc comment because the comment version let two nameless icon buttons ship. But a
// type with no test is a type someone widens to silence a stubborn call site:
// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green.
// Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening,
// so weakening the union turns the whole typecheck lane red.
//
// Rendered, not just declared, so eslint sees them used and the file stays one test suite.
describe("Button · disabled while it holds the focus", () => {
// A focused button that turns natively disabled loses the focus to <body> in Chrome (measured, E11
// S2) — and ~60 call sites disable the very button that was just pressed while its mutation runs.
// A focused button that turns natively disabled loses the focus to <body> in Chrome (measured)
// — and ~60 call sites disable the very button that was just pressed while its mutation runs.
// jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is
// unavailable by aria-disabled + a click guard, never by the native attribute.
it("keeps the native attribute off while focused, and blocks activation", () => {
@@ -255,7 +255,7 @@ describe("Button accessible-name contract (type level)", () => {
<>
{/* @ts-expect-error an icon with no children renders no text and no name */}
<Button icon={<span />} data-testid="bad-c" />
{/* @ts-expect-error the icon-only sizes moved to IconButton (E28 S3b) */}
{/* @ts-expect-error the icon-only sizes moved to IconButton */}
<Button size="icon" label="gone" />
<Button icon={<span />} label="by label" />
<Button icon={<span />} aria-label="by aria-label" />
+1 -1
View File
@@ -10,7 +10,7 @@ import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from
// holds. The app's other button families have their own primitives beside it: `IconButton` (a glyph
// with no text), `Pressable` (a card, row or tile whose content is the control), `Chip` and
// `FilterChipGroup` (filters), `SegmentedToggle`, `DragHandle`. A hand-rolled `<button>` is none of
// them (E28 S3b).
// them.
//
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
@@ -93,7 +93,7 @@ describe("Carousel", () => {
});
});
// E11 S4b: a rail on Home, Discover or an info page is a list the keyboard walks — but only when the
// A rail on Home, Discover or an info page is a list the keyboard walks — but only when the
// caller says its cards carry `navItemAttrs`. A carousel of plain content must not claim `j`.
describe("Carousel · keyboard", () => {
const cards = () =>
+3 -3
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// The chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
@@ -42,7 +42,7 @@ describe("Chip", () => {
expect(onClick).toHaveBeenCalledOnce();
});
// Round 2 (F6): the feed's and Plex's active-filter chips unmount with the filter they remove,
// The feed's and Plex's active-filter chips unmount with the filter they remove,
// and a keyboard press left the focus on <body>. The chip hands it on itself, for every caller.
it("a removing chip pressed from the keyboard hands the focus to its neighbour", () => {
function Row() {
@@ -70,7 +70,7 @@ describe("Chip", () => {
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove News" }));
});
// Round 3: the neighbour it hands the focus to can go in the same removal — the "Clear all" that
// The neighbour it hands the focus to can go in the same removal — the "Clear all" that
// only shows beside enough filters. The focus then goes to the chip before, not to <body>.
it("a removing chip whose neighbour goes with it hands the focus to the chip before", async () => {
function Row() {
+3 -3
View File
@@ -58,7 +58,7 @@ export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
className={clsx(chipClass(variant, size), removeLabel && "pr-1.5", className)}
onClick={(e) => {
// A removal chip unmounts with what it removes: from the keyboard it holds the focus, which
// went to <body> (round 2, F6 — the active-filter chips of the feed and Plex). Every
// went to <body> (the active-filter chips of the feed and Plex). Every
// removal chip gets it here, once, instead of each caller remembering.
if (removeLabel && e.currentTarget === document.activeElement)
handOffFocus(e.currentTarget, () => onClick?.(e));
@@ -126,8 +126,8 @@ export function DismissibleChip({
type="button"
onClick={(e) => {
// From the keyboard the × holds the focus, and the chip unmounts: hand the focus to the
// next chip (else the previous one, else what follows the row) first, not to <body> (E28
// review F5). A mouse press on it leaves the focus where it was.
// next chip (else the previous one, else what follows the row) first, not to <body>.
// A mouse press on it leaves the focus where it was.
const chip = e.currentTarget.parentElement;
if (chip && e.currentTarget === document.activeElement) handOffFocus(chip, onDismiss);
else onDismiss();
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
//
// A clamped text offers More only when it is actually cut (E30 S6 on the person page, shared with the
// collection page in the E30 review, where a one-line summary carried a More that did nothing).
// A clamped text offers More only when it is actually cut (built for the person page, shared with
// the collection page, where a one-line summary carried a More that did nothing).
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ClampedText } from "./ClampedText";
@@ -207,7 +207,7 @@ describe("Combobox", () => {
});
it("names a chip by a label that arrives with later options, in that same render", () => {
// E32 S3: the label cache was a ref an effect filled AFTER the render that needed it, and nothing
// The label cache was a ref an effect filled AFTER the render that needed it, and nothing
// re-rendered afterwards — the chip kept showing the raw id until something unrelated did.
const view = (options: { value: string; label?: string }[]) => (
<FloatingTree>
@@ -298,12 +298,12 @@ describe("Combobox", () => {
});
});
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
// PageUp/PageDown (owner request): the sidebar's channel filter and the Plex pickers run to
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
// E28 review J1 + J2: a failed list said "No matches" (an error dressed as an empty result), and a
// A failed list said "No matches" (an error dressed as an empty result), and a
// picker whose pick was still being written took another pick.
// E28 review F5: a chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
// A chip's × pressed from the keyboard unmounted with the focus on it — to <body>,
// out of the picker. The focus goes to the next chip's × (else the previous one, else the field).
describe("Combobox · removing a chip from the keyboard", () => {
function Harness({ initial }: { initial: string[] }) {
@@ -335,7 +335,7 @@ describe("Combobox · honest states", () => {
expect(screen.queryByText("common.noResults")).toBeNull();
});
// A failed list with no way back but closing its dialog was a dead end (E28 review, owner go).
// A failed list with no way back but closing its dialog was a dead end.
it("offers a Retry on the error, by click and by Enter in the field, keeping the focus", () => {
const onRetry = vi.fn();
wrap(
@@ -363,7 +363,7 @@ describe("Combobox · honest states", () => {
expect(document.activeElement).toBe(field);
});
// Round 2 (F4): a listbox may own options only — the alert, "Loading…", "No matches" and the
// A listbox may own options only — the alert, "Loading…", "No matches" and the
// "n more" line sat inside it (axe: aria-required-children). They sit beside it now.
it("keeps the lines about the list outside the listbox, which the field still controls", () => {
const { rerender } = wrap(
+6 -6
View File
@@ -15,7 +15,7 @@ import { ChipLabelPending } from "./Skeleton";
// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are
// removable chips that live INSIDE a text field you type into, and a checklist of options opens below.
// It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the E5
// It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the
// Plex filters (genre / age-rating / studio, multi-select) and the collection picker (single-select,
// server-searched) are both instances, and the header search typeahead can move onto it later.
//
@@ -56,12 +56,12 @@ export interface ComboboxProps {
clientFilter?: boolean;
loading?: boolean;
/** The options failed to load: said in the list instead of "No matches" (an error is not an empty
* result — E28 review J1). */
* result). */
error?: string;
/** Loads the options again after `error`: a Retry beside the message, and Enter in the field. A
* failed list with no way back but closing the dialog was a dead end. */
onRetry?: () => void;
/** Takes no pick (e.g. the last one is still being written, J2). Soft, like `FocusSafeButton`: the
/** Takes no pick (e.g. the last one is still being written). Soft, like `FocusSafeButton`: the
* field keeps the focus (a natively disabled one drops it to <body>), announced as unavailable. */
disabled?: boolean;
/** Shown when there are no options to list. */
@@ -81,7 +81,7 @@ export interface ComboboxProps {
/** Remember each value's display label as options flow through, so a selected chip can name itself
* even after the option leaves the query-narrowed list (async single-select). State, merged DURING
* render: a ref written in an effect was read by the render before it, so a chip could name itself
* by its raw value until something else re-rendered (E32). */
* by its raw value until something else re-rendered. */
function useLabelCache(options: readonly ComboboxOption[]): Record<string, string> {
const [labelCache, setLabelCache] = useState<Record<string, string>>({});
const newLabels = options.filter(
@@ -126,7 +126,7 @@ export default function Combobox({
onQueryChange?.(q);
};
// The input node as STATE, not a ref: the handlers below are handed to getReferenceProps during
// render, and a ref inside them made the React Compiler skip the whole component (E32).
// render, and a ref inside them made the React Compiler skip the whole component.
const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
@@ -294,7 +294,7 @@ export default function Combobox({
>
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
<div ref={fadeRef} style={fadeStyle} className="max-h-64 overflow-y-auto no-scrollbar">
{/* The listbox holds the options alone; the lines about the list sit beside it (F4). */}
{/* The listbox holds the options alone; the lines about the list sit beside it. */}
<PopoverList>
{!showError &&
visible.map((o) => {
@@ -124,7 +124,7 @@ describe("CommandPalette", () => {
).toContain("page:feed");
});
// E28 F35: the active row wore `bg-card`, ~1.07:1 on the palette's glass. It is the menus' row now.
// The active row wore `bg-card`, ~1.07:1 on the palette's glass. It is the menus' row now.
it("marks the active row with the menus' roving highlight, and only that row", () => {
open();
const rows = screen.getAllByRole("option");
@@ -174,7 +174,7 @@ describe("CommandPalette", () => {
});
});
// The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every
// The owner's case: typing "theme" put a channel matched only through its handle above every
// "Theme: …" row, and Enter opened the channel.
describe("CommandPalette · ranking", () => {
const noop = () => {};
@@ -276,7 +276,7 @@ describe("CommandPalette · accessibility", () => {
});
});
// Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown
// Owner request: with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown
// move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping.
describe("CommandPalette · paging", () => {
const many = (): CommandGroup[] => [
@@ -9,7 +9,7 @@
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
// in-flight programmatic back() behind — under which this one either hangs or, worse, goes green on
// somebody else's popstate. That is exactly how it used to pass: it asserted nothing on its own, and
// running it with `-t` failed (E10 review round 1). Its own file gives it clean module state.
// running it with `-t` failed. Its own file gives it clean module state.
import { useState } from "react";
import { describe, it, expect, vi, beforeAll } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
@@ -11,7 +11,7 @@ import { ROVING_HIGHLIGHT } from "./AnchoredPopover";
import { fieldClass } from "./fieldStyles";
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" idea — reaching a channel three
// modules away should cost a keystroke and a word, not a rail, a page and a scroll.
//
// Domain-free on purpose: the items arrive as a prop, because `components/ui` may not import a
@@ -58,7 +58,7 @@ const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1];
* the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so
* a row whose label holds every word beats one that needed its handle for any of them. Before this
* the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its
* handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel (E11 S3). */
* handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel. */
function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null {
const label = fold(item.label);
const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
@@ -262,21 +262,21 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
// these rows. Leaving them tabbable put DOM focus on a row while
// aria-activedescendant named a different one — two competing "current" options —
// and made Tab walk every result (E10 review round 1).
// and made Tab walk every result.
tabIndex={-1}
onClick={() => choose(item)}
onMouseMove={(e) => {
// Only a pointer that actually MOVED may take the selection. After the arrow keys
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
// cursor position to refresh hover — which handed the highlight straight back to
// whatever row slid under a resting cursor (E10 review round 1). The browser
// whatever row slid under a resting cursor. The browser
// reports zero movement for exactly that synthetic event, so ask it directly:
// round 1 remembered the last position instead, which could not judge the FIRST
// move of a session and went stale across a close (round 2).
// remembering the last position instead could not judge the FIRST
// move of a session and went stale across a close.
if (e.movementX === 0 && e.movementY === 0) return;
setPicked(item.id);
}}
// The menus' roving highlight: a `bg-card` row was ~1.07:1 on the palette's glass (E28).
// The menus' roving highlight: a `bg-card` row was ~1.07:1 on the palette's glass.
data-active={item.id === activeId ? "true" : undefined}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-muted hover:text-fg ${ROVING_HIGHLIGHT}`}
>
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Contract of the right-click / long-press context menu (E10 S2). Same floating-ui recipe as
// Contract of the right-click / long-press context menu. Same floating-ui recipe as
// ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and
// query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays
// `visibility: hidden` and the a11y tree treats its contents as hidden.
@@ -176,7 +176,7 @@ describe("useContextMenu / ContextMenu", () => {
// A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and
// holds tabbable controls. floating-ui handed the focus back to the first of them instead of the
// card — measured in Chrome (deep-review E11 round 2): j, Shift+F10, Escape left it on the thumbnail.
// card — measured in Chrome: j, Shift+F10, Escape left it on the thumbnail.
// Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened
// menu — Escape is measured in the browser).
it("gives the focus back to a card focused as a whole, not to a control inside it", async () => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// The R14 zoom-fit foundation: DataTable's opt-in `fixedLayout` renders a <colgroup> and puts the
// The zoom-fit foundation: DataTable's opt-in `fixedLayout` renders a <colgroup> and puts the
// table into `table-layout: fixed`, so columns with a width keep it and the rest (fluid text columns)
// split the remainder + truncate instead of pushing the table wider than its container. Off by
// default so existing content-sized tables are untouched.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// DataTable's `keyboardNav` (E11 S4c): the rows are a keyboard list — `j`/`k` walk them and Enter
// DataTable's `keyboardNav`: the rows are a keyboard list — `j`/`k` walk them and Enter
// clicks the element a column marks `data-nav-open`. It used to render its rows twice (a table and,
// below `md`, a card list, one hidden by CSS), so `j` past the last row could carry on into the hidden
// copy. Now it mounts exactly one layout, chosen by the `md` media query.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E31 S4d: the channel manager's "All" page (~400 rows) blocked one frame for 0.7–0.9 s on prod to
// The channel manager's "All" page (~400 rows) blocked one frame for 0.7–0.9 s on prod to
// mount, and every key in its search re-rendered every row. A long page now mounts its first rows at
// once and the rest in small background steps (progressiveRows), and a step leaves the rows it holds
// alone (a row is its own component, whose cells the React Compiler caches).
@@ -101,7 +101,7 @@ describe("DataTable — a long page mounts in steps", () => {
await waitFor(() => expect(bodyRows()).toBe(150));
});
// E31 S5: Channels turns the page to the row a priority pick moved and focuses it in the commit
// Channels turns the page to the row a priority pick moved and focuses it in the commit
// that follows. That row was at index 40 of a 50-row page: not mounted yet, focus fell to <body>.
it("mounts a revealed row in the render that turns to its page", () => {
host = document.createElement("div");
@@ -125,7 +125,7 @@ describe("DataTable — a long page mounts in steps", () => {
expect(host.querySelector("tbody")?.textContent).toContain("r 90");
});
// E31 review 2: the reveal floor held for the page's lifetime. Channels never clears its `follow`, so
// The reveal floor once held for the page's lifetime. Channels never clears its `follow`, so
// after one priority pick every later sort or search mounted every row up to that channel at once.
it("lets a revealed row go once the reader moves to another view", () => {
host = document.createElement("div");
+1 -1
View File
@@ -270,7 +270,7 @@ export default function DataTable<T>({
const [revealSeen, setRevealSeen] = useState(reveal?.seq);
// The view the reveal was made for: the floor below holds only there. A caller keeps its reveal
// after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every
// row up to that channel on each later sort or search (E31 review 2).
// row up to that channel on each later sort or search.
const [revealView, setRevealView] = useState<string | null>(null);
if (reveal && reveal.seq !== revealSeen) {
setRevealSeen(reveal.seq);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3d4 (B4): below ~600px of height a band's filter row folds into one "Filters · n" button that
// DESIGN.md B4: below ~600px of height a band's filter row folds into one "Filters · n" button that
// shows it again in place; on a tall window there is no button and the row is always there.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
@@ -38,7 +38,7 @@ describe("a selectable button", () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
// E28 review H2: Enter / Space on a focused title with its words still selected (selected, then
// Enter / Space on a focused title with its words still selected (selected, then
// tabbed to) did nothing — a keyboard click ends no selection; it carries no pointer at all.
it("a keyboard click opens even with the title's words selected", () => {
const onClick = vi.fn();
@@ -12,7 +12,7 @@ import { endsTextSelection, movedPastSlop } from "../../lib/textSelection";
// A `<button>` whose `disabled` never throws the focus out of the page.
//
// A focused button that turns natively disabled loses the focus — Chrome moves it to <body>
// (measured, E11 S2) — and the app disables the very button that was just pressed on ~60 sites (a
// (measured) — and the app disables the very button that was just pressed on ~60 sites (a
// mutation pending, a form gone clean after its save), so a keyboard user was dropped out of the page
// on every submit. While THIS button holds the focus, `disabled` (and `busy`) is expressed as
// `aria-disabled` plus a click guard instead: still focusable, announced as unavailable, and not
@@ -69,7 +69,7 @@ export const FocusSafeButton = forwardRef<
// not focused (another app in front) `.focus()` moves `activeElement` but fires no focus event
// (measured in Chrome), and the button disabled in that state lost the focus all the same. Read
// through useSyncExternalStore, which React asks on EVERY render, never from a memo: a bare
// render-time DOM read made the React Compiler skip the component (E32), and compiled it would be
// render-time DOM read made the React Compiler skip the component, and compiled it would be
// cached. Nothing to subscribe to — the focus events above already re-render it.
const activeNow = useSyncExternalStore(
noSubscription,
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 review F2: a column filter is shown only by its column's header funnel, so a filter set on a
// A column filter is shown only by its column's header funnel, so a filter set on a
// column that is off screen — a `tabletHidden` column on a 1024 window, any column in the card layout
// — narrowed the rows with nothing on screen saying so. The cue names it, and its chip removes it.
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -9,7 +9,7 @@ import {
} from "../../lib/tableColumns";
import { Chip } from "./Chip";
// E28 F2: a column filter lives in its column's header funnel, so when the column is off screen — a
// A column filter lives in its column's header funnel, so when the column is off screen — a
// `tabletHidden` / `narrowHidden` column below its width, or any column in a card layout, which has no
// header at all — the rows are narrowed by something nobody can see ("12 of 392", and no why). This
// names each such filter above the rows, and its chip removes it (owner's pick A, 2026-09-27).
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3b: the glyph-only button (DESIGN.md A2) and the two other click primitives that sit beside
// The glyph-only button (DESIGN.md A2) and the two other click primitives that sit beside
// `Button`: `Pressable` (a surface whose content is the control) and `DragHandle`.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3c: the field primitives (docs/DESIGN.md A4-A6): one frame per rung, the native controls handing
// The field primitives (docs/DESIGN.md A4-A6): one frame per rung, the native controls handing
// back values, and the dropdown trigger by context.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+1 -1
View File
@@ -2,7 +2,7 @@ import clsx from "clsx";
import type { InputHTMLAttributes, Ref, TextareaHTMLAttributes } from "react";
import { fieldClass, type FieldSize } from "./fieldStyles";
// The text-field primitives (docs/DESIGN.md A4, E28 S3c). Every text, number, password, e-mail, search
// The text-field primitives (docs/DESIGN.md A4). Every text, number, password, e-mail, search
// and date box is an `Input`; a multi-line one a `Textarea`; a box whose frame also holds an icon,
// chips or a clear button is a wrapper painted with `fieldClass` around an `InnerInput`. A raw
// `<input>` or `<textarea>` anywhere else is a lint error (`local/input-primitive`), so the
@@ -71,7 +71,7 @@ describe("ItemCount", () => {
expect(screen.getByText("5 items").className).toContain("opacity-60");
});
// E31 review 2: the dim and aria-busy were the only stale cue, and a screen reader voices neither on
// The dim and aria-busy were once the only stale cue, and a screen reader voices neither on
// plain text; a failed count looked like a first load.
it("tells a screen reader a stale count is updating", () => {
render(<ItemCount count={5} stale />);
+2 -2
View File
@@ -8,7 +8,7 @@ import { formatNumber } from "../../lib/format";
const OVERFLOW_AT = 1_000_000;
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
// and tonal like a `Badge` — no edge, because it does nothing (E28 S3d: edged = pressable, flat = just
// and tonal like a `Badge` — no edge, because it does nothing (edged = pressable, flat = just
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
// changing number must never shove the controls beside it, so the box is sized to the widest value
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
@@ -17,7 +17,7 @@ const OVERFLOW_AT = 1_000_000;
// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box
// of a first load (E31 review 2).
// of a first load.
export default function ItemCount({
count,
stale = false,
@@ -98,9 +98,9 @@ describe("KeyboardHelp", () => {
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
// sheet only offers Shift+F10 where something actually answers it. Advertising it globally was a
// promise the feed, Plex, Downloads and Settings did not keep (E10 review round 1) — the test above
// promise the feed, Plex, Downloads and Settings did not keep — the test above
// pins the other half, that a bare app lists the two global keys and nothing else.
// Through the REAL hook, not a scope stub: round 2 showed that commenting out
// Through the REAL hook, not a scope stub: commenting out
// `useKeyScope("contextMenu")` inside `useContextMenu` left the whole suite green, because the
// only coverage registered the scope by hand. This binds the sheet to the thing that owns it.
it("offers the context-menu key on a surface that has one, and not otherwise", () => {
+1 -1
View File
@@ -42,7 +42,7 @@ export default function KeyboardHelp() {
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
// only for a module this account can actually reach.
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
// focus Shift+F10 needs is inside the overlay (pre-existing since E10, found in S4 UAT).
// focus Shift+F10 needs is inside the overlay (found in UAT).
const live = overLayer
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
: scopes;
+1 -1
View File
@@ -7,7 +7,7 @@ export type MatchMode = "any" | "all";
// a captioned segment on the panel, right ABOVE its picker — never in the picker's popover, where a
// closed picker hid that "all" was on (the owner, 2026-09-27; DESIGN "Action placement": the state
// that shapes the results stays in sight). With fewer than two picks any and all are the same filter,
// so it draws nothing then. The panel's captioned toggle is the one 24px segment (DESIGN log, S3g).
// so it draws nothing then. The panel's captioned toggle is the one 24px segment (DESIGN log).
export default function MatchToggle({
picked,
value,
+1 -1
View File
@@ -5,7 +5,7 @@ import { iconButtonClass } from "./buttonStyles";
import { MoreButton } from "./MoreButton";
import { SURFACE } from "./surfaces";
// The ONE media card (docs/DESIGN.md "Media card", E28 S3h): a YouTube video, a Plex movie, show,
// The ONE media card (docs/DESIGN.md "Media card"): a YouTube video, a Plex movie, show,
// season or episode — in the feed, the Plex library, Home's shelves and the media pages' strips — is
// the same object: one glass frame holding the picture, then the title, then a meta line PINNED to the
// frame's bottom edge. Cards in a grid row stretch to the row's height, so pinning the meta puts every
+1 -1
View File
@@ -3,7 +3,7 @@ import { PopoverSurface } from "./AnchoredPopover";
import type { useMediaPreview } from "./useMediaPreview";
import { SURFACE } from "./surfaces";
// A media card's preview (docs/DESIGN.md "Media card", E28 S3h — the owner's pick C, 2026-09-26):
// A media card's preview (docs/DESIGN.md "Media card" — the owner's pick C, 2026-09-26):
// rest the mouse on a card for a moment and a panel beside it shows what the card has no room for —
// the whole title, the facts, the synopsis in lines short enough to read. It replaced the synopsis as
// the picture's native tooltip, which fired anywhere on the art, ran one long line, and shared its
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// Modal: focus management (FE-9). This was DEFERRED from the E4 deep-review because it's
// Modal: focus management (FE-9). This was DEFERRED from a code review because it's
// jsdom-untestable as written — `focusablesIn` filters on `el.offsetParent !== null`, and jsdom
// returns null for EVERY element (it never lays out), so the trap would see zero focusables and do
// nothing. We stub `HTMLElement.prototype.offsetParent` to report "visible" (non-null) for any
@@ -225,7 +225,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
// The Tab trap deliberately spans the dialog ROOT so a toast's Undo/✕ is reachable; ownership
// was tracked over the PANEL, so touching that ✕ read as "the user left the dialog" and the
// restore was skipped — and removing the toast fires no focusin to re-arm it. Measured landing
// on <body> (deep-review round 6).
// on <body>.
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
@@ -281,7 +281,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
// layer is above me" (measured). So a dialog that closes on its own — an in-flight mutation's
// `onSuccess`, not a click, since a scrim covers it — under a modal raised above it yanked the
// focus out of that standing dialog onto its own trigger, behind the scrim, where Enter still
// activated it (deep-review round 7). Sibling dialogs, not nested: that is the arrangement
// activated it. Sibling dialogs, not nested: that is the arrangement
// `ConfirmProvider` and the app-wide `ErrorDialog` actually use.
const trigger = document.createElement("button");
document.body.appendChild(trigger);
@@ -366,7 +366,7 @@ describe("Modal — toasts raised while it is open", () => {
});
});
// E28: one Escape in a dialog's open list closed the list AND the dialog (the share dialog's user
// One Escape in a dialog's open list closed the list AND the dialog (the share dialog's user
// picker). The list's `document` listener closes it with a synchronous flush, which pops its layer
// before the dialog's `window` listener runs — so "am I topmost?" reads yes. The dialog must honour
// the list's stamp, as the players already do (UX-8).
@@ -4,7 +4,7 @@
// <body> is painted beneath it, i.e. invisible). Leaving fullscreen changes that container — and a
// React portal whose container changes is torn down and recreated, so everything the dialog held went
// with it: a half-filled download form reset to its defaults and focus fell to <body> (a verifier's
// side finding in deep-review round 4).
// side finding in a review).
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
import { vi } from "vitest";
@@ -58,7 +58,7 @@ describe("a dialog with an autoFocus child", () => {
// has already called `.focus()` on a disconnected node — a no-op — and Modal's own fallback
// then puts the focus on the first focusable in the panel, the header's ✕. That is what the
// command palette, the download rename dialog and every non-danger confirm shipped with after
// deep-review round 4 (found in round 5).
// the first fix (found by a later review).
render(
<Modal initialFocus="close" title="Search" onClose={() => {}}>
<input aria-label="query" autoFocus />
@@ -4,11 +4,11 @@
// mount→cleanup→mount is what the code under test reacts to, and the module-level layer stack in
// ui/Modal (and the overlay-host registry in ui/Toaster) leaks between tests in one file.
//
// Round 5 fixed this in Chrome and shipped it with NO test: the second mount found the focus
// A fix settled this in Chrome and shipped it with NO test: the second mount found the focus
// outside the dialog (its own cleanup had just handed it back to the trigger) and moved it to the
// first focusable — taking it off an `autoFocus` field a beat after the browser had put it there.
// The command palette opened focused on its ✕ in dev and on its search field in prod, from the same
// code. Round 6 measured that deleting the fix left all 1009 tests green, which is why this exists.
// code. A check measured that deleting the fix left all 1009 tests green, which is why this exists.
//
// Same offsetParent stub as Modal.dom.test.tsx: jsdom never lays out, so every element reports
// offsetParent null and `focusablesIn` would see nothing.
+7 -7
View File
@@ -52,8 +52,8 @@ const PANEL = "relative flex flex-col overflow-hidden outline-none panel-scroll-
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
// dismisses just the error and returns to the editor underneath, and a player below defers to it.
// Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
// download dialog) would otherwise open invisibly.
export default function Modal({
@@ -85,15 +85,15 @@ export default function Modal({
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
// element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child
// still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it.
// element — and attached before the children commit, so an `autoFocus` child
// still gets the focus. Both rules live in the hook; `ui/AnchoredPopover` shares it.
const host = useFullscreenPortalHost();
const panelRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Focus lands in the panel; OWNERSHIP is the whole dialog — the same element the Tab trap below
// is scoped to, toast host included. Tracking ownership over the panel alone meant tabbing to an
// in-dialog toast counted as leaving, and the close-time restore was then skipped (round 6).
// in-dialog toast counted as leaving, and the close-time restore was then skipped.
const bodyRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [footerSlot, setFooterSlot] = useState<HTMLDivElement | null>(null);
@@ -108,11 +108,11 @@ export default function Modal({
// to take the focus at open, so it lands on the ✕. When the form arrives the focus moves to its
// first field — but only while it still rests on the ✕: a user who has moved it keeps it there.
// The form takes it ONCE: arrived while the focus was elsewhere, it disarms all the same, or a later
// mutation would pull the focus off a ✕ the user went back to (E32 review round 2). The watch
// mutation would pull the focus off a ✕ the user went back to. The watch
// re-arms only when the focus is taken away, not moved: the body has no control again, or the
// focused one went (a failed load's Retry giving way to the spinner, or straight to the form) and
// the focus fell to <body>. It then goes to the ✕, and on to the first field as soon as the body
// has one — at once, when other controls stayed beside the one that went (E32 review). Only
// has one — at once, when other controls stayed beside the one that went. Only
// <body>: focus in a dialog stacked on top is not this one's to take.
useEffect(() => {
const body = bodyRef.current;
@@ -1,7 +1,7 @@
import clsx from "clsx";
import type { InputHTMLAttributes, ReactNode, Ref } from "react";
// A6 (docs/DESIGN.md, E28 S3c): checkbox, slider and colour well are the BROWSER's controls, tinted
// A6 (docs/DESIGN.md): checkbox, slider and colour well are the BROWSER's controls, tinted
// with the accent — keyboard, screen reader and forced-colours support come free, and a drawn copy
// would have to rebuild all three. What the browser does not give is the 44 touch target: the labelled
// row (`CheckRow`) or the slider's own box grows to it on a coarse pointer. Like `Input`, a raw
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3d (B7): the nav rail's row. Icon-only, its name is the module's full name (with an unread
// DESIGN.md B7: the nav rail's row. Icon-only, its name is the module's full name (with an unread
// count, which was only ever drawn); with the labels on, the name is the short label the user SEES
// (WCAG 2.5.3), the full one staying in the tooltip. "You are here" is aria-current plus the tint.
import { readFileSync } from "node:fs";
@@ -49,7 +49,7 @@ describe("NavRailItem", () => {
expect(row.getAttribute("aria-current")).toBeNull();
});
// E28 audit: labelled, the name was the row's text, the corner badge first, so 20 unread was heard
// Audit: labelled, the name was the row's text, the corner badge first, so 20 unread was heard
// as "9+Alerts" where the icon-only row says "Notifications (20)".
it("labelled with a count: the visible label, then the exact count", () => {
render(
@@ -66,7 +66,7 @@ describe("NavRailItem", () => {
expect(screen.getByRole("link", { name: "Alerts (20)" })).toBeTruthy();
});
// E31 (the owner): a middle-click on a rail row opens the page in a new tab. The row is a real link
// Owner's call: a middle-click on a rail row opens the page in a new tab. The row is a real link
// to the page, so the browser's own gestures work; only a plain left click navigates in-app.
it("is a link to its page, navigating in-app only on a plain left click", () => {
const onClick = vi.fn();
@@ -124,7 +124,7 @@ describe("NavRailAction", () => {
});
});
// E31 (the owner's pick): the five admin rows sat below the fold at 133% zoom behind a fade alone, so
// The owner's pick: the five admin rows sat below the fold at 133% zoom behind a fade alone, so
// they are one "Admin" row opening a panel of them. The panel's pages stay links, as the rail's are.
describe("NavRailGroup", () => {
const pages = (active: string | null, onSelect = vi.fn()) =>
+2 -2
View File
@@ -92,7 +92,7 @@ export function NavRailItem({
active?: boolean;
/** An unread / in-progress count on the icon's corner (0 hides it). */
badge?: number;
/** The page's own address. A page row is a real link (the owner, E31): a middle-click, a
/** The page's own address. A page row is a real link (the owner's call): a middle-click, a
* Ctrl/Cmd-click or the browser's "Open in new tab" opens the page in a tab of its own. */
href: string;
/** The in-app navigation, run only for a plain left click. */
@@ -186,7 +186,7 @@ export interface NavRailGroupPage {
onSelect: () => void;
}
/** One rail row standing for a set of rare pages (E31, the owner's pick: the admin pages), opening a
/** One rail row standing for a set of rare pages (the owner's pick: the admin pages), opening a
* panel of them to its right. The rail fits a 133–150% zoom without a scroll nobody sees: five admin
* rows sat below the fold behind a fade alone. The row is "you are here" while any of its pages is.
* The panel's pages are real links, as the rail's own rows are (a middle-click opens a tab), so it is a
+1 -1
View File
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
// The app's overlay-root convention. Any full-screen or floating overlay renders THROUGH this so it
// escapes whatever ancestor stacking context it was declared in — a `mask-image`/`transform`/`filter`
// on the page scroller (or a rail's `backdrop-filter`) would otherwise trap a `fixed` child and let
// the chrome paint over it. That is the exact class of bug that buried the players in E3; the
// the chrome paint over it. That is the exact class of bug that once buried the players; the
// modal/player family already dodges it by portaling to <body>, and this makes that the one blessed
// way. Stacking among overlays is then decided by DOM order + the z-index token scale (tailwind
// `zIndex`), never by an ad-hoc magic number. Portals to <body> (the existing de-facto target).
@@ -81,7 +81,7 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(200);
});
// Round 2 (F8): the page's very end, whose Back-to-top spacer only shows once scrolled, is out of
// The page's very end, whose Back-to-top spacer only shows once scrolled, is out of
// reach when Back rebuilds the page — and the restore gave up with the page at the top.
it("Back to a spot within the end's spacer lands at the page's end, not its top", () => {
setEntry(1);
@@ -125,7 +125,7 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(450);
});
// Round 3: landing at the end shows Back to top, whose spacer then brings the spot within reach.
// Landing at the end shows Back to top, whose spacer then brings the spot within reach.
it("finishes the way to a spot the spacer brings within reach after it lands", () => {
let frames: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
@@ -154,7 +154,7 @@ describe("PageScroller scroll memory", () => {
expect(main.scrollTop).toBe(500);
});
// Round 3: the give-up put the page at its end 1.5 s in, wherever the reader had scrolled to.
// The give-up put the page at its end 1.5 s in, wherever the reader had scrolled to.
// Every way a reader takes the page: a wheel, a finger, the scrollbar (a pointer), a key.
it.each([
["a wheel", (main: HTMLElement) => fireEvent.wheel(main)],
@@ -213,7 +213,7 @@ describe("PageScroller scroll memory", () => {
});
});
// E28 UAT: Back to top floats over the bottom-right corner, and at the end of a long table it covered
// UAT: Back to top floats over the bottom-right corner, and at the end of a long table it covered
// the closing pager's "100" and "All". While it is up, the page ends a button's height later.
describe("PageScroller · room for Back to top", () => {
it("ends the page later while the button is up, and not otherwise", () => {
+2 -2
View File
@@ -37,10 +37,10 @@ function rememberScroll(key: string, top: number): void {
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
// A spot the page can no longer reach — the last stretch of a page scrolled to its very end, whose
// Back-to-top spacer only shows once scrolled — goes as far as the page does: it used to be left at
// the top (round 2, F8). Only a spacer's worth short, and only once the height has held still, is
// the top. Only a spacer's worth short, and only once the height has held still, is
// that answer early; a list still loading is further short than that, and gets the full wait. And
// landing there is not the end of it: that scroll is what shows Back to top, whose spacer then
// makes the spot reachable, so the loop watches a few frames more and finishes the way (round 3).
// makes the spot reachable, so the loop watches a few frames more and finishes the way.
//
// The reader scrolling meanwhile — a wheel, a finger, a key, the scrollbar — takes the page over:
// the restore stops where they are, instead of pulling them back or dropping them at the end.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E5 S7: PageShell measures the floating header and publishes its real height as `--hdr-h` onto the
// PageShell measures the floating header and publishes its real height as `--hdr-h` onto the
// content wrapper, so a header that flex-wraps to 2–3 rows on a narrow window never overlaps the
// content below it. jsdom does no layout (offsetHeight is 0) and has no ResizeObserver, so we stub
// both: a ResizeObserver that hands us its callback, and offset metrics on the measured header.
@@ -32,7 +32,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe("PageShell --hdr-h (E5 S7)", () => {
describe("PageShell --hdr-h", () => {
it("publishes the measured header height onto the content wrapper", () => {
const { container } = render(
<PageShell header={<div data-testid="hdr">H</div>}>content</PageShell>,
@@ -139,7 +139,7 @@ describe("PageShell --rail-pinned-w / --content-mw", () => {
});
});
// E28 S3d4 (B4): every page has one h1 — the page's name, visually hidden, unless a view shows its
// DESIGN.md B4: every page has one h1 — the page's name, visually hidden, unless a view shows its
// own title (a channel, a Plex show), which is then the page's h1 instead.
describe("PageShell page heading", () => {
it("names the page with a visually hidden h1", () => {
+1 -1
View File
@@ -111,7 +111,7 @@ export default function PageShell({
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
// would let the content toolbar slide up under it (E5 S7). Measure the header and write its height
// would let the content toolbar slide up under it. Measure the header and write its height
// straight onto the content wrapper that consumes `--hdr-h`.
//
// Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`:
@@ -34,7 +34,7 @@ describe("Pager · rows per page", () => {
});
});
// The page box DERIVES the current page (E27 S2): it holds a draft only while being edited, so the
// The page box DERIVES the current page: it holds a draft only while being edited, so the
// parent moving the page needs no re-sync effect. The layout-phase probe is what proves "derived"
// rather than "re-synced after paint" — a passive effect could not have run yet when it reads.
describe("Pager · the page box follows the page without an effect", () => {
@@ -62,7 +62,7 @@ describe("PanelGroups — reordering without a drag", () => {
});
});
// E28 review F4: every group's grip was "Drag to reorder" — a screen reader heard the same name five
// Every group's grip was "Drag to reorder" — a screen reader heard the same name five
// times and could not tell which group it would lift. It names its group, as the playlist grips do.
describe("PanelGroups — the drag grip", () => {
it("is named by its group", () => {
+2 -2
View File
@@ -73,7 +73,7 @@ export function LoadingState({
}
// Centered inline status line for a page/section's loading and error(+retry) states, in the house
// style (mirrors Playlists' error block). Text-only by design — R3's goal is to stop pages
// style (mirrors Playlists' error block). Text-only by design — the goal is to stop pages
// rendering an error or a still-loading fetch AS an "empty" state, not to add skeletons. The
// `role=status`/`aria-live` also gives screen readers the state change (the app had zero live
// regions before).
@@ -118,6 +118,6 @@ export function StateMessage({
// The honest-state convention every page now follows: check `isError && <no data>` BEFORE the empty
// branch, so a failed fetch renders this message (with Retry) instead of the page's "empty" text
// — the R3 bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
// — the bug where a backend restart read as "No channels". The `&& <no data>` guard matters: a
// transient refetch error while data is already on screen keeps the data rather than blanking it to
// an error. `StateMessage` is the shared building block pages wire into their loading/empty ternary.
@@ -32,7 +32,7 @@ describe("SegmentedToggle", () => {
expect(onChange).toHaveBeenCalledWith("and");
});
it("sits on the toolbar rung unless a panel's caption asks for the small one (S3g)", () => {
it("sits on the toolbar rung unless a panel's caption asks for the small one", () => {
render(<SegmentedToggle options={OPTS} value="or" onChange={vi.fn()} ariaLabel="Default" />);
const plain = screen.getByRole("group", { name: "Default" });
expect(plain.className).toContain("text-xs");
@@ -39,7 +39,7 @@ export default function SegmentedToggle<T extends string>({
fullWidth?: boolean;
// md (28, text-xs) is the toolbar rung: it lines up with the search pill, the triggers and an `sm`
// Button (DESIGN B1). sm (24, text-3xs) is only a side panel's captioned match toggle. md is the
// default because the old sm default slid a pager and a toolbar count onto 10px type (S3g).
// default because the old sm default slid a pager and a toolbar count onto 10px type.
size?: "sm" | "md";
className?: string;
}) {
+3 -3
View File
@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
// The dropdown trigger (docs/DESIGN.md A5, E28 S3c): the one button that opens a list of choices —
// The dropdown trigger (docs/DESIGN.md A5): the one button that opens a list of choices —
// `SortControl`, `FilterSelect`, the view switcher, the date filter. By context: a pill at the toolbar's
// 28 (in a toolbar the pill means "a filter or a choice"), or r8 at the form's 32, full width, lining up
// with the inputs around it. The form trigger is one of the field family, so it wears the field's frame
@@ -45,8 +45,8 @@ export const SelectTrigger = forwardRef<
{icon}
{label != null && <span className="shrink-0 text-muted">{label}:</span>}
{/* The toolbar value is a row, whatever the caller puts in it: an <svg> is display:block under the
preflight, and in a plain span it broke the value onto its own line (the saved-view chip,
E28). The form value is text that truncates, which a flex box would not do — nor would a bare
preflight, and in a plain span it broke the value onto its own line (the saved-view chip).
The form value is text that truncates, which a flex box would not do — nor would a bare
text node in the toolbar row, so a text value gets its own truncating span: in a narrow place
(the playlists rail) a long value ("Rendezés: Egyéni sorrend") used to wrap out of the pill. */}
<span
@@ -84,7 +84,7 @@ describe("SidePanel · a mouse on a wide window", () => {
});
});
// E28 review F6: the pinned (or peeking) card covers the tab, which only goes transparent — yet it
// The pinned (or peeking) card covers the tab, which only goes transparent — yet it
// stayed a Tab stop, an invisible control the focus landed on, named just "Unpin panel".
describe("SidePanel · the tab under an open card", () => {
it("is out of the Tab order while the card is open, and names its panel", () => {
+2 -2
View File
@@ -79,7 +79,7 @@ export default function SidePanel({
// adjusted during render, so it cannot come back open on the next turn.
if (drawerOpen && (!drawerMode || pinned)) setDrawerOpen(false);
// A key landing on the tab peeks the card open over it, and the tab then goes inert below — with
// the focus still on it: a hidden control, out of the tree, that Enter still pinned (round 2, F1).
// the focus still on it: a hidden control, out of the tree, that Enter still pinned.
// The focus steps into the card as it opens, onto its pin: the same control. In the commit that
// mounts the card, not a frame later — a frame never comes in a page the browser is not painting.
useLayoutEffect(() => {
@@ -156,7 +156,7 @@ export default function SidePanel({
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable, and a focus already on it stays.
But no Tab lands on it while the card covers it (E28 review F6): an invisible stop, where the
But no Tab lands on it while the card covers it: an invisible stop, where the
card's own pin says the same. A keyboard pin hands the focus into the card it opened. */}
<Pressable
{...(drawerMode ? {} : peek.hoverHandlers)}
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 S3d4 (UX-4): "Skip to content" moves the focus to the page's <main> itself. Following its
// "Skip to content" moves the focus to the page's <main> itself. Following its
// `#content` fragment would push a history entry, and the app reads every popstate as a page change.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick for the Statistics overview): a figure's name sits by its number in a tile, and a
// The owner's pick for the Statistics overview: a figure's name sits by its number in a tile, and a
// breakdown is horizontal bars, largest first, so what spends the most is seen before a number is read.
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { HintLabel } from "./form";
import { SURFACE } from "./surfaces";
// E31 (the owner's pick for the Statistics overview): label…value rows stretched across a wide column
// The owner's pick for the Statistics overview: label…value rows stretched across a wide column
// made the eye travel from a name to its number. A tile keeps the two together, a row of them is read at
// a glance, and a breakdown is a set of horizontal bars, so what spends the most is seen before a number
// is read.
+1 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 review F3: the tab rail was a row of `aria-pressed` toggle buttons — every tab a Tab stop, no
// The tab rail was a row of `aria-pressed` toggle buttons — every tab a Tab stop, no
// arrow keys, and nothing tying a tab to the content it shows. It is an ARIA tab list now.
import { useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
+3 -3
View File
@@ -9,12 +9,12 @@ import CountBadge from "./CountBadge";
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
// it is a horizontal row that scrolls. A page whose sub-tab is worth keeping persists it with
// lib/storage's useTabPersistedState (per browser tab, E31 S4b: Admin Users, Config, Settings, Stats):
// lib/storage's useTabPersistedState (per browser tab: Admin Users, Config, Settings, Stats):
// a reload keeps the user where they were, and a fresh tab opens on the first sub-tab. Not every
// caller: the channel manager's view is account-wide (ChannelsProvider), and Downloads, a channel page
// and the player settings start on their first tab each time.
//
// An ARIA tab list (E28 review F3), not a row of toggle buttons: `tablist` / `tab` / `aria-selected`,
// An ARIA tab list, not a row of toggle buttons: `tablist` / `tab` / `aria-selected`,
// ONE Tab stop (the selected tab; roving tabindex), the arrows move and select (either axis, since the
// vertical rail is a row below `sm`), Home/End jump to the ends. The panel it controls is wired with
// `useTabPanel`: the tabs point at it (`aria-controls`) and it is labelled by the selected tab.
@@ -27,7 +27,7 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
icon?: LucideIcon; // optional leading icon (the vertical settings rail)
testId?: string; // optional data-testid, to preserve existing e2e hooks
/** The vertical rail's heading this tab sits under (E31: Configuration's sixteen groups under four).
/** The vertical rail's heading this tab sits under (Configuration's sixteen groups under four).
* Drawn above the first tab of each run; tabs of one heading stay together. */
group?: string;
}
@@ -95,7 +95,7 @@ describe("Toaster — handing the toasts to another host", () => {
});
it("takes the inherited set ONCE, at the takeover: a toast of its own stays an alert", () => {
// E32: the handover is a snapshot made when the host becomes top, not a read of what the top
// The handover is a snapshot made when the host becomes top, not a read of what the top
// host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then).
render(<Toaster />);
@@ -124,7 +124,7 @@ describe("Toaster — handing the toasts to another host", () => {
// A dialog that notifies and closes in ONE handler (UploadDialog: "added to the queue" + close)
// puts its toast into the very batch that hands the app host the top spot. Classifying it from
// the store live at the takeover render counted it among the inherited, so it went in with no
// role into a region that was still `aria-live="off"` — announced by neither path (round 5).
// role into a region that was still `aria-live="off"` — announced by neither path.
function Stage() {
const [open, setOpen] = useState(true);
return (
@@ -4,19 +4,19 @@
// viewport at z-overlay paints over it, so a toast raised while that overlay is open is unreadable and
// a click on it lands on the overlay's backdrop. The fix is a convention — the overlay mounts
// `<Toaster overlay order={useOverlayOrder()} />` inside itself — and a convention stated in a comment
// is one the next overlay forgets (deep-review round 3 found every ui/Modal and the onboarding wizard
// is one the next overlay forgets (a review found every ui/Modal and the onboarding wizard
// without one). So it is checked here: a component that renders a viewport-covering layer at
// z-overlay or above mounts an overlay host, ranked by when the overlay opened.
//
// The class TOKENS are matched in any order, and the layer need not be `inset-0`: the first version
// matched the exact string "fixed inset-0 z-overlay", so PlexPlayer's own root (`fixed left-0 top-0
// z-overlay`) was never checked at all, and a new viewer copying it would have slipped past
// (deep-review round 4).
// (found by a later review).
import { describe, it, expect } from "vitest";
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
// (deep-review round 4).
// (found by a later review).
const sources = import.meta.glob(["../**/*.tsx", "./*.tsx"], {
query: "?raw",
import: "default",
@@ -3,11 +3,11 @@
// The toast handover under React 18 StrictMode — its own file, because `lastTopIds` is module-level
// state that leaks between tests.
//
// Round 5 made a taking-over host inherit "what the PREVIOUS top host last had on screen" by reading
// One fix made a taking-over host inherit "what the PREVIOUS top host last had on screen" by reading
// a module global in a `useMemo` and overwriting it in the same render pass. React re-invokes a
// component body — and every `useMemo` factory in it — on a StrictMode double render, and the LAST
// invocation is what commits: so the second pass re-read what the first had just written, the host
// classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5
// classified its OWN new toast as inherited, and the dev build behaved exactly like the code it
// replaced. Measured in Chrome and in jsdom: role "status" without StrictMode, null with it.
//
// The named case is UploadDialog's — notify and close the overlay in ONE handler, so the toast is
+4 -4
View File
@@ -22,7 +22,7 @@ import { SURFACE } from "./surfaces";
// "Opened last", not "host mounted last": a host can remount while its overlay stays open —
// PlayerModal's moves into the fullscreen stage and back — and ranked by mount time it came back on
// top of a dialog opened over the player (the download dialog after leaving true fullscreen), so the
// toasts were drawn under that dialog's scrim again (deep-review round 4). So the OVERLAY takes its
// toasts were drawn under that dialog's scrim again. So the OVERLAY takes its
// rank once, when it opens (`useOverlayOrder`, in overlayOrder.ts), and hands it to every host it renders.
type Host = { id: string; order: number };
const overlayHosts = createStore<readonly Host[]>([]);
@@ -70,16 +70,16 @@ export default function Toaster({
// the takeover render. Reading the store live also silenced a toast RAISED in the same commit as
// the takeover: a dialog that notifies and closes in one handler (UploadDialog) put its success
// toast up in the very batch that handed the app host the top spot, so it was classified as
// inherited and announced by neither path (deep-review round 5). The top host records its screen
// inherited and announced by neither path. The top host records its screen
// after each commit (the layout effect below); a host taking over reads it in a LATER render, after
// that commit, so it always sees the outgoing host's last committed screen. (It was written during
// render, which made the React Compiler skip the Toaster — E32 — and let a discarded render
// render, which made the React Compiler skip the Toaster and let a discarded render
// overwrite it.)
//
// Snapshotted into state when `isTop` flips, adjusted DURING render — not a `useMemo`, which React
// may recompute (and the compiler is free to): a recompute while top would re-read this host's OWN
// screen and silence its next toast. Under StrictMode the double invocation reads the same,
// unchanged global, so both passes agree (the round-6 failure was a render-phase WRITE racing it).
// unchanged global, so both passes agree (an earlier failure was a render-phase WRITE racing it).
const [handover, setHandover] = useState<{ top: boolean; ids: ReadonlySet<number> }>({
top: false,
ids: EMPTY_IDS,
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E11 S4: a hover surface (tooltip, hover card) is a PASSIVE layer. It still sits on the layer stack —
// A hover surface (tooltip, hover card) is a PASSIVE layer. It still sits on the layer stack —
// Escape closes it before the surface beneath — but it does not own the keyboard, so the page-level
// keys (`j`/`k`, `s`, the `g` chords) keep working while one happens to be open under a resting pointer.
// Without the flag, hovering any hinted icon silently disabled keyboard navigation.
+1 -1
View File
@@ -7,7 +7,7 @@ import { SURFACE } from "./surfaces";
type Side = "top" | "bottom" | "left" | "right";
/** Wrap any element to show a short glass hint caption on hover — by default only while the
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the popover platform, so it
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge.
* `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav
+1 -1
View File
@@ -21,7 +21,7 @@ export default function UndoToolbar({
}) {
const { t } = useTranslation();
// Only while a key actually DOES something: the sheet must not advertise Ctrl+Z on a page where
// nothing has been done yet and the handler returns immediately (E10 review round 1) — the same
// nothing has been done yet and the handler returns immediately — the same
// honest-states gate PlexBrowse uses for its Backspace.
useKeyScope("undo", canUndo || canRedo);
+2 -2
View File
@@ -44,7 +44,7 @@ export interface VirtualGridProps<T> {
/** Single-column layout only (`minCol == null`): the reading-width cap in px (null = fill). */
colMaxWidth?: number | null;
/** Multi-column layout only (`minCol != null`): the most columns to lay out, or null for as many
* as fit. This is the UPPER end of the viewport contract (E10 S4): an auto-fill grid always settles
* as fit. This is the UPPER end of the viewport contract: an auto-fill grid always settles
* just above its minimum column, so on an ultrawide it answers "more room" with more, smaller
* items forever — 3440px gave the feed 11 columns of 264px. With a cap the extra width goes into
* the items instead (8 columns of ~370px), which is what a screen watched from a distance wants.
@@ -131,7 +131,7 @@ export default function VirtualGrid<T>({
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
// The React Compiler (E27 S1) SKIPS memoising this whole component, and says so: TanStack
// The React Compiler SKIPS memoising this whole component, and says so: TanStack
// Virtual's `useVirtualizer()` hands back functions that cannot be memoised without going stale,
// so the compiler bails rather than risk a frozen viewport. That is the correct trade here — this
// component's cost is the row window it mounts, not its render closures — and it is the ONE such
@@ -23,7 +23,7 @@ import {
import { useLayer } from "../../lib/layerStack";
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
// The non-component half of the R8 popover platform: the `useAnchoredPopover` engine hook and the
// The non-component half of the popover platform: the `useAnchoredPopover` engine hook and the
// `closeThenAct` row helper. The surfaces it drives (PopoverSurface / PopoverItem) and the platform's
// design notes live in AnchoredPopover.tsx; the split keeps that file components-only (Fast Refresh).
@@ -69,13 +69,13 @@ export interface AnchoredPopoverOptions {
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
* scrolls under it while its actions still target the row you opened it on (E10 review round 1).
* scrolls under it while its actions still target the row you opened it on.
* This is also what a native context menu does. */
dismissOnScroll?: boolean;
/** Menu/listbox only: ↑/↓ on the CLOSED trigger open the panel (the APG menu-button default, on).
* Turn it off where those keys already mean something on that trigger — a media player's row,
* where ↑/↓ are the volume from anywhere, and a trigger that swallowed them killed it (E28 review
* round 2, P2). Enter/Space/click still open it, and the arrows still rove once it is open. */
* where ↑/↓ are the volume from anywhere, and a trigger that swallowed them killed it.
* Enter/Space/click still open it, and the arrows still rove once it is open. */
openOnArrowKey?: boolean;
}
@@ -308,7 +308,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
* Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
* Chrome, a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
+1 -1
View File
@@ -2,7 +2,7 @@ import { createStore } from "../../lib/store";
// Whether the Back-to-top button is up. It floats over the page's bottom-right corner, so PageScroller
// reads this to keep the END of the page clear of it while it shows: a table's closing pager sat under
// the button — "100" and "All" covered (E28 UAT, the channel manager on a short window).
// the button — "100" and "All" covered (found in UAT, the channel manager on a short window).
export const backToTopShown = createStore(false);
export function useBackToTopShown(): boolean {
+1 -1
View File
@@ -151,7 +151,7 @@ export function buttonClass({
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
// The link variants were once excluded as "a run of text inside a sentence", but none is: every
// one stands on its own ("See all", "Forgot your passphrase?", a notification's action), and the
// E28 audit measured them at 16–20px on a tablet. Text inside a sentence is a plain <a>.
// an audit measured them at 16–20px on a tablet. Text inside a sentence is a plain <a>.
"touch-target",
fullWidth && "w-full",
);
+7 -7
View File
@@ -4,7 +4,7 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea
// through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is
// not a titled dialog with a header row) and used to have NONE of this — no trap, no initial focus,
// no restore — so a keyboard user tabbed straight out of the first-login wizard into the app behind
// the scrim, where the focus ring is invisible (deep-review round 5). A second hand-written copy is
// the scrim, where the focus ring is invisible. A second hand-written copy is
// how two shells drift apart; this is the shared one.
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
@@ -19,7 +19,7 @@ export const focusablesIn = (root: HTMLElement): HTMLElement[] =>
/** A form's first field — where a dialog opened to fill something in takes the focus: its first
* control, unless one is marked `data-initial-focus`. The mark is for a form with a control above
* its fields that is not one of them — the issue composer's Edit / Preview switch took the focus,
* and typing went nowhere (E32 review). */
* and typing went nowhere. */
export const firstFieldIn = (root: HTMLElement): HTMLElement | undefined => {
const all = focusablesIn(root);
return all.find((el) => el.hasAttribute("data-initial-focus")) ?? all[0];
@@ -58,7 +58,7 @@ export function trapTab(
// ONE body scroll lock for every open dialog, counted. Each dialog used to save and restore
// `overflow` on its own, so two unmounting out of order (the player's cleanup running before a Modal
// opened above it) put back a stale "hidden". Invisible today — the app scrolls inside <main>, not
// the document (measured, E11 S2) — but a count cannot be restored out of order, and PlayerModal's
// the document (measured) — but a count cannot be restored out of order, and PlayerModal's
// second copy of the lock is gone.
let scrollLocks = 0;
let unlockedOverflow = "";
@@ -100,7 +100,7 @@ export function useDialogChrome(
// the cleanup hands focus back to the trigger, and the second mount then found the focus outside
// the dialog and moved it to the first focusable — taking it off the `autoFocus` field a beat
// after the browser had put it there. Measured in Chrome: the command palette opened with the
// focus on its ✕ in dev and on its search field in prod, from the same code (round 5).
// focus on its ✕ in dev and on its search field in prod, from the same code.
const focusedRef = useRef<HTMLElement | null>(null);
// Whether the dialog still holds the focus. Read at cleanup rather than computed there: by the
// time a passive cleanup runs on a real unmount the panel is already out of the document and the
@@ -112,7 +112,7 @@ export function useDialogChrome(
// stays reachable — so tabbing to that ✕ read as "the user left the dialog", and since removing
// the toast fires no `focusin` to re-arm, closing the dialog afterwards stranded the focus on
// <body> instead of handing it back to the trigger. Measured in jsdom, and a regression: before
// this hook existed the restore was unconditional (deep-review round 6).
// this hook existed the restore was unconditional.
const heldRef = useRef(true);
// Read at open, not a dependency: a caller's fresh function each render must not re-run the open.
const pickInitial = useEffectEvent(() => initial?.() ?? null);
@@ -144,7 +144,7 @@ export function useDialogChrome(
// it — and while it held the focus we read that as the user walking away. Then both closing
// together left the focus on <body>: ours skipped the restore, and the child's own
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
// and it is the ordinary case — an error dialog raised over a dialog (round 6).
// and it is the ordinary case — an error dialog raised over a dialog.
heldRef.current =
(owner?.contains(active) ?? false) ||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
@@ -171,7 +171,7 @@ export function useDialogChrome(
// a layer closing with us is already disconnected while one that stays open is not. Without
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
// scrim — where Enter still activated it (deep-review round 7).
// scrim — where Enter still activated it.
const owner = ownerAtOpen;
const active = document.activeElement;
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
+1 -1
View File
@@ -1,6 +1,6 @@
// @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
// 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";
@@ -17,7 +17,7 @@ describe("columnsFor", () => {
// The GUTTERS have to be in the count, not just in itemWidthFor. Every other assertion in this file
// survives replacing `floor((w + GAP) / (minCol + GAP))` with the naive `floor(w / (minCol + GAP))`
// — the fixed widths agree, the capped cases are cap-bound, and the two loops compare columnsFor
// against itself, so they cancel any shared change (E10 review round 1). These are the exact-fit
// against itself, so they cancel any shared change. These are the exact-fit
// boundaries where the two formulas first disagree: an n-column row needs n*minCol + (n-1)*GAP, so
// at exactly that width the naive form loses the last column and wastes a third of the row.
it("counts the gutters, not one gap per column", () => {
+1 -1
View File
@@ -3,7 +3,7 @@
// 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
// just above its minimum column width no matter how wide the screen is. Measured at 3440px (E10 S4),
// just above its minimum column width no matter how wide the screen is. Measured at 3440px,
// the feed laid out 11 columns of 264px — a diluted feed is as bad a failure of the viewport contract
// as a broken one, so `maxCols` stops the multiplication and lets the leftover width flow into the
// items instead. The cap can only bind ABOVE the width at which that many columns already fit, so the
@@ -1,7 +1,7 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// E28 review F2: a view that applies the column filters itself (`filterRows`) — DataTable, the
// A view that applies the column filters itself (`filterRows`) — DataTable, the
// playlist manager's own table, the channel manager's cards — can hide the column a filter lives in,
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
describe("every view that filters rows by column", () => {
@@ -22,14 +22,14 @@ export const MEDIA_BADGE =
* Every picture box carries it: `mediaPictureBox` and the video card's own box. A box that holds a
* hover row (MEDIA_BAR) also sets `--bar-room`, the band the row needs: the narrow-picture rule
* below lifts the Play by it, and a box with no row (the feed's row and tile views, a Plex card
* without actions) lifts it by nothing (E31 review 2: it sat off-centre there). */
* without actions) lifts it by nothing (it once sat off-centre there). */
export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-room:2.5rem]";
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row).
* On a narrow picture the Play centres in the band ABOVE the hover row instead of on the whole
* picture: the picture's height follows the card's width, but the Play and the row grow with the
* text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row (E31 S5,
* measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide.
* text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row
* (measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide.
* Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */
export const MEDIA_HOVER_LAYER =
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)";
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
// A card skips its off-screen rendering (`cv-card` / `cv-poster`: content-visibility auto with a
// reserved height), but not inside a carousel. A rail stretches every card to its tallest, and an
// off-screen card's reserve WAS the tallest — Home's shelves measured 338px around 170–216px cards
// (E28 S3h). The pair lives in two files and neither can be seen in jsdom, so it is held here.
// off-screen card's reserve WAS the tallest — Home's shelves measured 338px around 170–216px cards.
// The pair lives in two files and neither can be seen in jsdom, so it is held here.
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
describe("a carousel turns the cards' content-visibility off", () => {
@@ -9,7 +9,7 @@ const sources = () =>
/** Checkable menu rows a file renders: a literal `role="menuitemradio"`, and a role CHOSEN by an
* expression that can come out checkable — FilterSelect's and ActionMenu's rows, every filter select
* and checkable action menu in the app, which a literal-only count skipped (round 3). */
* and checkable action menu in the app, which a literal-only count skipped. */
function checkableRoles(src: string): number {
const literal = (src.match(/role="menuitem(?:radio|checkbox)"/g) ?? []).length;
const chosen = [...src.matchAll(/role=\{([^{}]*)\}/g)].filter(([, e]) =>
@@ -18,7 +18,7 @@ function checkableRoles(src: string): number {
return literal + chosen;
}
// E28 review L2: `ui/MenuCheck` is the ONE drawn check box of a checkable row (S3e3b replaced seven
// `ui/MenuCheck` is the ONE drawn check box of a checkable row (S3e3b replaced seven
// hand-drawn copies), yet FilterSelect and Combobox still painted their own — a 14px box beside the
// menus' 16px one. The filled-box paint is the tell: it may live in MenuCheck only.
describe("the drawn check box", () => {
@@ -30,7 +30,7 @@ describe("the drawn check box", () => {
expect(offenders).toEqual([]);
});
// Round 2 (P6): the player's track rows marked the choice only by an accent label — which the
// The player's track rows marked the choice only by an accent label — which the
// highlight's white text wiped — and four more menus ticked it their own way (a trailing tick, a
// dot, a filled row). A file with a checkable menu row draws MenuCheck, and no tick of its own.
it("marks every checkable menu row, with no tick of its own beside it", () => {
@@ -48,7 +48,7 @@ const PLAIN: Record<string, [number, string]> = {
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [
2,
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link (E31)",
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
],
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"ui/SidePanel.tsx": [2, "panel controls"],
@@ -1,7 +1,7 @@
import { startTransition, useEffect, useState } from "react";
// A long page of table rows mounts in steps: the first FIRST_ROWS at once, then STEP_ROWS more per
// background transition until all are in (E31 S4d). The channel manager's "All" page (~400 rows) took
// background transition until all are in. The channel manager's "All" page (~400 rows) took
// 0.7–0.9 s of one blocked frame to mount on prod, and every sort and every keystroke in its search
// re-rendered all of them (300–400 ms per key). No single cell is to blame: measured column by column,
// each costs its share, and all of them together cost ~9× an empty row. The first rows fill more than
@@ -21,7 +21,7 @@ const STEP_ROWS = 40;
//
// `through` is a row that must be in the DOM in THIS render: the row a page turn was made to show
// (DataTable's `reveal`). Its page is a new view, and the caller focuses that row right after the
// commit — at index 40 of a 50-row page it was not mounted yet, and focus fell to <body> (E31 S5).
// commit — at index 40 of a 50-row page it was not mounted yet, and focus fell to <body>.
export function useProgressiveRows<T>(rows: T[], view: string, through = -1): T[] {
const [seen, setSeen] = useState(view);
const [limit, setLimit] = useState(FIRST_ROWS);
+1 -1
View File
@@ -1,4 +1,4 @@
// A card's hover chrome (E28 C1): shown when the card is hovered or holds keyboard focus on a mouse,
// A card's hover chrome: shown when the card is hovered or holds keyboard focus on a mouse,
// removed on touch (`.reveal-hover`, index.css), where the card offers the same actions through its
// "…" beside the title. One string, so the video card and the Plex cards reveal the same way.
export const HOVER_REVEAL =
@@ -1,4 +1,4 @@
// E31 (the owner's pick): the channel page's tall header folds to one row once the grid scrolls. The header
// The owner's pick: the channel page's tall header folds to one row once the grid scrolls. The header
// is fixed, so folding gives the scroller its height back — these are the rules that keep that from
// flickering on a page barely taller than the window.
import { describe, expect, it } from "vitest";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { CONTENT_ID } from "./landmarks";
// A tall page header that folds to one row once the page's content scrolls (E31, the owner's pick for the
// A tall page header that folds to one row once the page's content scrolls (the owner's pick for the
// channel page: the banner and the identity block took ~40% of a 133% screen before the first video).
//
// The header is FIXED (it sits in the shell's toolbar band, outside the scroller), so folding it makes the
+2 -2
View File
@@ -23,7 +23,7 @@ import { SURFACE } from "./surfaces";
// Why it lives here and not in DataTable: DataTable owns its filter/sort/page state internally,
// while the playlist manager's body is a drag-reorderable list whose order is the persisted
// server-side one. Two owners of the same ordering fight; composing these pieces instead lets the
// playlist keep its stored order and still get real column headers. Same move as E4 S3's
// playlist keep its stored order and still get real column headers. Same move as
// ChannelLayoutGrid, which builds a card view straight from the `Column<T>` render closures.
// The multi-select filter's option list, capped and scrollable. Its own component because it owns a
@@ -46,7 +46,7 @@ function FilterOptionList({ children }: { children: ReactNode }) {
// so the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
// `autoFocus` masked it well enough that it read as "the filter is just twitchy".
//
// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel
// The funnel and its panel live in ONE component because the popover platform anchors the panel
// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the
// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go
// back), the restore-focus effect that stopped Escape stranding focus on <body>, and the useDismiss
@@ -14,7 +14,7 @@ import { type ChipSize, type ChipVariant, chipClass } from "./chipStyles";
// Tailwind generates CSS only for the class names it FINDS in the source, token by token. A class
// assembled at runtime (`${ACT}hover:text-fg`) is never found whole, so it ships with no rule at all:
// every hover colour of Button, IconButton and Chip was dead that way, found in the E28 audit by
// every hover colour of Button, IconButton and Chip was dead that way, found in an audit by
// reading the stylesheet (0 rules for `not-aria-disabled`). So: every class a primitive can emit has
// to stand in its source file whole, between the delimiters the scanner splits on.
const tokens = (file: string) =>
+4 -4
View File
@@ -10,7 +10,7 @@ import { matchesShortcut, useKeyScope } from "../../lib/keymap";
import { pointOnSelection } from "../../lib/textSelection";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
// E10 S2 — the right-click context menu's INPUT half: the gestures that open it and the row they
// The right-click context menu's INPUT half: the gestures that open it and the row they
// belong to. The surface itself is `<ContextMenu>` (ContextMenu.tsx); they are two files only so
// that the component file exports a component and nothing else (react-refresh).
//
@@ -68,8 +68,8 @@ export interface ContextMenuController<T> {
anchor: Element | null;
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
triggerProps: (payload: T) => Record<string, unknown>;
/** Spread on the row's visible "…" button (E28 C1: nothing is reachable only by a gesture). A
* click opens the SAME menu under the button; a second click on it while its menu is open closes
/** Spread on the row's visible "…" button (DESIGN.md C1: nothing is reachable only by a gesture).
* A click opens the SAME menu under the button; a second click on it while its menu is open closes
* it, like any menu button. Pair it with `aria-expanded` from the caller, which knows its row. */
buttonProps: (payload: T) => Record<string, unknown>;
/** Opens the menu at a viewport point for `payload`, anchored to `contextElement` — for an input
@@ -148,7 +148,7 @@ export function useContextMenu<T>({
// never produce that click (a pan ends in pointercancel, the finger lifts over the portalled menu,
// Android Chrome suppresses the trailing click after its own contextmenu). The flag then stayed
// armed, and since it is one ref per LIST, the next genuine tap on ANY row of that list was eaten
// and the row looked dead until tapped twice (E10 review round 1). So a fresh press also disarms
// and the row looked dead until tapped twice. So a fresh press also disarms
// it: whatever the last gesture left behind cannot outlive the start of the next one.
const swallowClick = useRef(false);
const cancelPress = useCallback(() => {
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
//
// E28 audit: an in-place editor closed by a REAL Enter re-opened at once. The keydown commits and hands
// 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.