feat: settings become five groups that state their own answer

The panel was one open block of everything in two columns. That reads fine at
four games and stops working at eighteen: the game list alone is eighteen rows
of four controls each, and it sat above the checkbox somebody had scrolled down
here to tick. Nothing was findable and everything was in the way.

Collapsing it into groups is only half an answer, though — a group showing
nothing but its name turns "what is my region set to?" into a click, which
trades one kind of friction for another. So each summary carries its group's
current state: "Europe · Dark", "17 of 18 on · A–Z", "plus finished, not
started". Closed, the panel is a five-line report of how the app is configured;
opening one is for changing an answer rather than reading it. The states are
derived from `prefs` at render, never stored, so they cannot drift from the
controls they describe.

Native `<details>`, for the reason the reorder arrows are ordinary buttons:
keyboard and screen reader reach it with no second implementation. `summary` is
not an `a`, `button` or `[tabindex]`, so it needed its own focus-visible rule —
the shared one does not reach it.

Two things fell out along the way. The region and appearance rows had no
accessible name at all, so a screen reader read six unlabelled buttons in a row;
they and the board's split pills are one `PillGroup` now, with the `role="group"`
the board's own controls already carry. And the empty states that point at
"Show events that haven't started" now name the group holding it, which is what
makes shipping the groups closed safe.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:29:28 +02:00
co-authored by Claude Opus 5
parent 14f6f2f0dc
commit fb390c1af8
8 changed files with 498 additions and 215 deletions
+61 -1
View File
@@ -6,12 +6,17 @@ import { metaFor } from "../src/shared/games.ts";
import type { Prefs } from "../src/client/state/usePrefs.ts";
/**
* The settings panel's view filters.
* The settings panel: its view filters, its group summaries, its game order.
*
* Three rows answer the same question — *what am I allowed to look at?* — and
* they are the only place two of them can be reached from, so what they are
* bound to is worth pinning. A checkbox wired to the wrong preference is
* invisible in a diff and obvious only to the reader it happens to.
*
* The summaries are pinned for a related reason. The groups ship closed, so
* those lines are the only account a reader gets of how the app is set up
* without opening anything, and a line that drifts from the control inside it
* is worse than no line at all.
*/
const PREFS: Prefs = {
@@ -134,6 +139,61 @@ describe("Controls: what am I allowed to look at", () => {
});
});
describe("Controls: what a closed group says", () => {
/**
* The summaries are the whole reason collapsing the panel is not a regression.
* A group that shows only its name turns "what is my region set to?" into a
* click, so each one has to answer its own question from `prefs` — and being
* derived rather than stored is what stops them disagreeing with the controls
* inside.
*/
const summaries = (html: string): string[] =>
[...html.matchAll(/<summary[^>]*>(.*?)<\/summary>/g)].map((m) =>
(m[1] ?? "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(),
);
test("every group is named, and states where it stands", () => {
const lines = summaries(render(PREFS));
expect(lines).toHaveLength(5);
expect(lines[0]).toBe("Games 2 of 2 on · AZ");
expect(lines[1]).toBe("Reading Europe · Dark");
expect(lines[3]).toBe("Your own games and events none yet");
});
test("the games line counts what is on, and whose order it is in", () => {
const some = render({ ...PREFS, hiddenGames: ["hsr"], gameOrder: ["hsr", "genshin"] });
expect(summaries(some)[0]).toBe("Games 1 of 2 on · your order");
});
test("the visibility line names the additions, not the switches", () => {
// The app's answer is what expires next; each of these puts something else
// alongside it, so that is how the line reads.
expect(summaries(render({ ...PREFS, showCompleted: false }))[2]).toBe(
"What you see live deadlines only",
);
expect(
summaries(render({ ...PREFS, showUpcoming: true }))[2],
).toBe("What you see plus finished, not started");
});
test("ignored counts in the line only once it is actually revealed", () => {
// `showIgnored` with nothing ignored is a filter over an empty set, and the
// row itself is not even offered — so the summary must not claim it either.
expect(summaries(render({ ...PREFS, showIgnored: true }, 0))[2]).toBe(
"What you see plus finished",
);
expect(summaries(render({ ...PREFS, showIgnored: true }, 3))[2]).toBe(
"What you see plus finished, ignored",
);
});
test("the panel has a heading of its own", () => {
// It used to begin with an unannounced wall of controls, which reads as more
// of the page rather than as the place settings live.
expect(render(PREFS)).toContain("Settings");
});
});
describe("Controls: the game order editor", () => {
const html = () => render(PREFS);