fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs

A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
This commit is contained in:
2026-10-25 04:01:43 +01:00
parent b25179c06a
commit 84db570951
31 changed files with 699 additions and 189 deletions
@@ -52,6 +52,18 @@ describe("Button primitive", () => {
}
});
it("a lit cycle paints the pressed look without announcing a toggle", () => {
render(
<Button variant="overlay" lit>
Loop: All
</Button>,
);
const btn = screen.getByRole("button", { name: "Loop: All" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:bg-accent");
});
it("a toggle announces aria-pressed and paints the pressed state", () => {
const { rerender } = render(
<Button variant="outline" pressed={false}>
+6 -1
View File
@@ -30,6 +30,9 @@ interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "
loading?: boolean;
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
pressed?: boolean;
/** A mode cycle that is not off: the pressed look without aria-pressed — its name says the mode (a
* cycle is no two-state switch). */
lit?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
@@ -55,6 +58,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
label,
loading = false,
pressed,
lit,
round = false,
fullWidth = false,
className,
@@ -84,9 +88,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
onBlur={onBlur}
aria-label={label}
aria-pressed={pressed}
data-lit={lit || undefined}
title={label}
className={clsx(
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed, lit }),
className,
)}
{...rest}
@@ -59,6 +59,25 @@ describe("IconButton", () => {
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
});
// A mode cycle (auto-advance, loop) is no two-state switch: it looks on, its name says the mode.
it("a lit cycle tints its glyph without announcing a toggle", () => {
const { rerender } = render(
<IconButton label="Auto: Next" lit>
<svg />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Auto: Next" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:text-chosen");
rerender(
<IconButton label="Auto: Off" lit={false}>
<svg />
</IconButton>,
);
expect(btn.hasAttribute("data-lit")).toBe(false);
});
it("loading swaps the glyph for a spinner and swallows the click", () => {
const onClick = vi.fn();
render(
+6 -1
View File
@@ -23,6 +23,9 @@ export interface IconButtonProps
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
* amber on the player chrome). */
pressed?: boolean;
/** A mode cycle that is not off: the pressed tint without aria-pressed — its `label` says the mode
* (a cycle is no two-state switch). */
lit?: boolean;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
@@ -40,6 +43,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
variant = "ghost",
size = "sm",
pressed,
lit,
round = false,
loading = false,
noTitle = false,
@@ -56,8 +60,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
busy={loading}
aria-label={label}
aria-pressed={pressed}
data-lit={lit || undefined}
title={noTitle ? undefined : label}
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
className={clsx(iconButtonClass({ variant, size, round, pressed, lit }), className)}
{...rest}
>
{loading ? <Loader2 className="animate-spin" /> : children}
@@ -60,6 +60,11 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
const PRESSED =
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
// A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle —
// its name says the mode, and aria-pressed would announce a two-state switch whose name changes on
// every press. Keyed to an attribute like PRESSED, so it outranks the variant's own colours.
const LIT =
"data-lit:bg-accent data-lit:text-accent-fg data-lit:border-accent data-lit:no-underline";
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
@@ -122,6 +127,7 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
const ICON_PRESSED = "aria-pressed:text-chosen";
const ICON_LIT = "data-lit:text-chosen";
const ICON_SIZES: Record<IconButtonSize, string> = {
sm: "size-7 [&>svg]:size-4",
@@ -138,17 +144,20 @@ export function buttonClass({
round = false,
fullWidth = false,
pressed,
lit,
}: {
variant: ButtonVariant;
size: ButtonSize;
round?: boolean;
fullWidth?: boolean;
pressed?: boolean;
lit?: boolean;
}) {
return clsx(
BASE,
BUTTON_VARIANTS[variant],
pressed !== undefined && PRESSED,
lit !== undefined && LIT,
SIZES[size],
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
@@ -165,17 +174,20 @@ export function iconButtonClass({
size,
round = false,
pressed,
lit,
}: {
variant: IconButtonVariant;
size: IconButtonSize;
round?: boolean;
pressed?: boolean;
lit?: boolean;
}) {
return clsx(
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-chosen disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
ICON_VARIANTS[variant],
round || variant === "media" ? "rounded-full" : "rounded-lg",
pressed !== undefined && ICON_PRESSED,
lit !== undefined && ICON_LIT,
ICON_SIZES[size],
);
}