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:
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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…"). */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,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", () => {
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user