fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph

The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier.

A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
This commit is contained in:
2026-10-25 13:51:26 +01:00
parent 13f65b4844
commit 0bc1024287
18 changed files with 629 additions and 103 deletions
@@ -0,0 +1,34 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// DESIGN "A glyph means one thing": a menu row wears the glyph its action wears everywhere else. The
// "…" menus' Play from the beginning wore the skip arrow ⏮, which beside the player is Previous, while
// the card's own Restart wore RotateCcw — and RotateCcw was the same menu's Reset, the Files menu's
// Redownload. Each row is a `label: t("…")` followed by its `icon`.
const ROW_GLYPHS: Record<string, string> = {
"card.restartTitle": "RotateCcw",
"card.resetState": "Eraser",
"files.actions.redownload": "RefreshCw",
};
const root = new URL("../", import.meta.url);
const sources = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.map((f) => f.replace(/\\/g, "/"))
.filter((f) => /\.tsx?$/.test(f) && !f.includes(".test.") && !f.startsWith("design-lab/"))
.map((f) => ({ f, src: readFileSync(new URL(f, root), "utf8") }));
describe("a menu row's glyph", () => {
for (const [key, glyph] of Object.entries(ROW_GLYPHS))
it(`${key} wears ${glyph}`, () => {
const row = new RegExp(
`label: t\\("${key.replace(/\./g, "\\.")}"\\),\\s*icon: (?:<|icon\\()(\\w+)`,
"g",
);
const worn = sources().flatMap(({ f, src }) =>
[...src.matchAll(row)].map((m) => `${f}: ${m[1]}`),
);
expect(worn.length, key).toBeGreaterThan(0);
expect(worn.filter((w) => !w.endsWith(`: ${glyph}`))).toEqual([]);
});
});
+22 -3
View File
@@ -147,7 +147,7 @@ it("styles exactly the schemes the app offers", () => {
// advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its
// own state; the primitives (Button, IconButton) rely on the utilities to hold this for them.
it("the state layers stand down on a disabled, busy or pressed control", () => {
const guard = /:not\(:disabled, \[aria-disabled="true"\]/;
const guard = /:not\(\s*:disabled,\s*\[aria-disabled="true"\]/;
for (const name of ["state-layer", "state-filled", "state-danger"]) {
const at = css.indexOf(`@utility ${name} {`);
const body = css.slice(at, css.indexOf("\n}", at));
@@ -155,9 +155,28 @@ it("the state layers stand down on a disabled, busy or pressed control", () => {
expect(states.length, name).toBe(2);
for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard);
}
expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch(
/&:hover:not\([^)]*\[aria-pressed="true"\][^)]*\)/,
// A pressed toggle and a lit mode cycle keep their look under the pointer: no layer over them.
const layer = css.slice(
css.indexOf("@utility state-layer {"),
css.indexOf("@utility state-filled {"),
);
const states = [...layer.matchAll(/&:(hover|active):not\(([^)]*)\)/g)];
expect(states.map(([, state]) => state)).toEqual(["hover", "active"]);
for (const [, state, exempt] of states) {
expect(exempt, state).toContain('[aria-pressed="true"]');
expect(exempt, state).toContain("[data-lit]");
}
});
// …and their colours outrank a variant's hover colour (`act:hover:text-fg`, the overlay's
// `act:hover:bg-white/15`), the more specific selector: only an important one does.
it("a pressed or lit control's colours are marked important", () => {
const styles = readFileSync(new URL("../components/ui/buttonStyles.ts", import.meta.url), "utf8");
for (const name of ["PRESSED", "LIT", "ICON_PRESSED", "ICON_LIT"]) {
const value = new RegExp(`const ${name} =\\s*"([^"]+)"`).exec(styles)?.[1];
expect(value, name).toBeTruthy();
for (const cls of value!.split(/\s+/)) expect(cls, name).toMatch(/!$/);
}
});
// The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent