diff --git a/AGENTS.md b/AGENTS.md index 77c19af..775ba36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -907,6 +907,19 @@ to an open page). Four things hold it up: because touch fires no drag events at all: the arrows are the mechanism and the drag is the pointer fast path, and being ordinary buttons is what makes the whole thing reachable by keyboard and screen reader without a second implementation of the same interaction. +- **The settings panel is groups that state their own answer, and they ship closed.** Five + `
` groups — games, reading, what you see, your own, your progress — each with its current + state on the summary line (`Europe · Dark`, `17 of 18 on · A–Z`, `plus finished, not started`). It + was one open block in two columns, which is readable at four games and not at eighteen, where the + game list is eighteen rows of four controls sitting above the checkbox somebody came to tick. The + state line is the half that makes collapsing honest: without it every question about how the app is + set up costs a click, so the closed panel has to be a report and not a menu. Two rules follow. + **Do not default a group open** to make something inside it findable — the empty states name the + group as well as the switch, which is what makes that unnecessary. And the summaries are **derived + from `prefs` at render**, never stored, so they cannot drift from the controls they describe. + Native `
` for the reason the reorder arrows are ordinary buttons: keyboard and screen + reader reach it without a second implementation. `summary` is not an `a`, `button` or `[tabindex]`, + so it needs its own `:focus-visible` rule in `styles.css` — the shared one does not reach it. - **A game's dailies stay together, and grouping never re-sorts inside a group.** `dailyGroups` (`Dailies.tsx`) emits a game's standing chore followed by that game's repeating events, in the order those arrived; it replaced `[...chores, ...repeating]`, which put a game's chore and its own diff --git a/docs/PRD.md b/docs/PRD.md index b1cc3c1..0f559a5 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -203,6 +203,20 @@ but de-emphasized; a filter toggles them out entirely. Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views. Preferences persist in `localStorage`. +The panel holding them is **five collapsed groups, each stating its own answer on the summary line** +— games, reading, what you see, your own games and events, your progress. It was one open block of +everything, laid out in two columns on a wide screen, which read fine at four games and stopped +working at eighteen: the game list alone is eighteen rows of four controls, and it sat above the +checkbox a reader had come down here to tick. Collapsing on its own would only trade that friction +for another, though — a group that shows nothing but its name turns *what is my region set to?* into +a click — so each summary answers its group's question, and the closed panel is a five-line report +of how the app is configured. Opening one is for changing an answer, not for reading it. + +Two things follow. **The groups ship closed**, including the one holding the filters that other copy +points at: the empty states name both the switch and the group it is in, which is more findable than +a checkbox in a wall was. And **the summary states are derived, never stored** — they are a reading +of `prefs`, so they cannot disagree with the controls inside. + **F4a — Focus one game at a time.** Switching games on and off says *which games the reader plays*, and is set once. It is the wrong tool for the thing a player of four games actually does while reading: clear one game, move to the diff --git a/src/client/App.tsx b/src/client/App.tsx index 171417f..fa5bbb3 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -567,7 +567,7 @@ export function App() { upcoming.length === 1 ? "One event has" : `${upcoming.length} events have` - } not started yet — switch on “Show events that haven't started” below to list them.` + } not started yet — switch on “Show events that haven't started”, under “What you see” in settings below.` : focus !== null ? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.` : "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 60cf717..93d9728 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -32,19 +32,41 @@ const THEMES: Array<{ id: ThemeChoice; label: string }> = [ * switched off. A checkbox would name one of them and leave the other as * whatever is left over. */ -const SPLITS: Array<{ split: boolean; label: string; hint: string }> = [ +const SPLITS: Array<{ id: boolean; label: string; hint: string }> = [ { - split: true, + id: true, label: "In their own group", - hint: "Each lane runs out, then a \u201cNot started yet\u201d heading and what is queued behind it \u2014 the shape the checklist has either way.", + hint: "Each lane runs out, then a “Not started yet” heading and what is queued behind it — the shape the checklist has either way.", }, { - split: false, + id: false, label: "Mixed in", hint: "One deadline order, started or not — so something opening Friday and closing Sunday sits above an event running until October.", }, ]; +/** + * The settings panel. + * + * Five groups, each collapsed until asked for, and each stating its own answer + * on the summary line. It used to be one open block of everything in two + * columns, which was readable at four games and is not at eighteen: the game + * list alone is eighteen rows of four controls, and it sat above the pills and + * checkboxes a reader had actually come down here to find. Scrolling past a + * wall to reach a checkbox is the whole complaint. + * + * Collapsing it is only half an answer, though — a closed group that says + * nothing turns "what is my region set to?" into a click. So every summary + * carries its group's current state, which makes the closed panel a five-line + * report of how the app is configured, and makes opening one a deliberate act + * rather than the price of reading it. + * + * One column rather than the two this replaced. The two-column split was there + * because the panel was tall and a wide screen had room to halve it; with the + * groups closed the whole thing is shorter than the header above it, so the + * argument is gone, and full-width rows give the summary state somewhere to sit + * on the right of the name it belongs to. + */ export function Controls({ games, prefs, @@ -65,14 +87,29 @@ export function Controls({ /** Everything the reader entered themselves, and the ways to change it. */ own: React.ComponentProps; }) { + const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length; + const ownGames = Object.keys(own.games).length; + const ownEvents = Object.keys(own.events).length; + return ( -
- {/* Which games and how they are read on one side, what the reader has - added and what they can take away with them on the other. Two short - columns beat one tall one here: settings are scanned for the one row - you came to change. */} -
-
+
+

+ Settings +

+

+ All of this stays in this browser. Each group says where it stands, so + you only have to open the one you came to change. +

+ + {/* Held to a readable measure rather than the container's full width. Each + row pairs a name on the left with its state on the right, and across a + wide screen those two ends stop reading as one line — which is the whole + point of putting the state there. */} +
+ -
-
-

Server region

-
- {REGIONS.map((r) => ( - - ))} -
-
- - {/* Next to the region rather than off in a corner: both are - "how do I read this?", and neither changes what the page - knows. Switching is instant and costs nothing — no reload, and - nothing marked, typed or ticked is touched. */} -
-

Appearance

-
- {THEMES.map((t) => ( - - ))} -
-
- -
- - - {/* One of the three "what am I allowed to look at" rows, and it - reaches both views: the checklist's "Not started yet" section - and the board's future bars are the same events answering the - same question. Off is the default because this app answers - *what expires next* — see PRD F1. */} - - - {/* Only while there is something to arrange. A choice about how - unstarted events sit on the board is unanswerable when none - are on it, and offering it anyway is a control that does - nothing — the stored answer is kept either way, so switching - the row above back on restores it rather than a default. */} - {prefs.showUpcoming && ( -
-
- {SPLITS.map((s) => ( - - ))} -
-

- On the timeline.{" "} - {SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming) - ?.hint} -

-
- )} - - {/* Detection reads the source's wording and is wrong in both - directions, so it ships off and says so. Off leaves only the - events the reader marked, and discards nothing — every mark and - logged day survives, so it can be switched back on. */} - - - {ignoredCount > 0 && ( - - )} -
+ {/* Region and appearance together: both are "how do I read this?", and + neither changes what the page knows. Switching either is instant and + costs nothing — no reload, and nothing marked, typed or ticked is + touched. */} + t.id === prefs.theme)?.label ?? prefs.theme + }`} + > +
+ onUpdate({ region, regionConfirmed: true })} + /> + onUpdate({ theme })} + />
-
+ -
+ 0, + )} + > +
+ onUpdate({ showCompleted })} + label="Show events I've finished" + /> + + {/* One of the three "what am I allowed to look at" rows, and it + reaches both views: the checklist's "Not started yet" section + and the board's future bars are the same events answering the + same question. Off is the default because this app answers + *what expires next* — see PRD F1. */} + onUpdate({ showUpcoming })} + label="Show events that haven't started" + hint="Adds the checklist's “Not started yet” section, and plots them on the timeline — which draws its span from what it plots, so the board stretches weeks past today." + /> + + {/* Only while there is something to arrange. A choice about how + unstarted events sit on the board is unanswerable when none + are on it, and offering it anyway is a control that does + nothing — the stored answer is kept either way, so switching + the row above back on restores it rather than a default. */} + {prefs.showUpcoming && ( +
+ + onUpdate({ timelineSplitUpcoming }) + } + /> +

+ On the timeline.{" "} + {SPLITS.find((s) => s.id === prefs.timelineSplitUpcoming)?.hint} +

+
+ )} + + {/* Detection reads the source's wording and is wrong in both + directions, so it ships off and says so. Off leaves only the + events the reader marked, and discards nothing — every mark and + logged day survives, so it can be switched back on. */} + onUpdate({ detectDaily })} + label="Spot daily events automatically" + badge="Experimental" + hint="Guessed from what the source wrote, so it misses some and invents others. Off, only events you mark yourself get a checklist. Your ticks and streaks are kept either way." + /> + + {ignoredCount > 0 && ( + onUpdate({ showIgnored })} + label={`Show the ${ignoredCount} event${ + ignoredCount > 1 ? "s" : "" + } I'm ignoring`} + /> + )} +
+
+ + + -
-

Your progress

-

- What you've finished, and every daily you've ticked off, are saved in - this browser only — there is no account. Anything you added yourself is - in there too. Move it all to another device with a file. -

-
- - -
+ +

+ What you've finished, and every daily you've ticked off, are saved in + this browser only — there is no account. Anything you added yourself is + in there too. Move it all to another device with a file. +

+
+ +
-
+
); } +/** "12 of 18 on · your order" — what the games group is set to, without opening it. */ +function gamesState(total: number, shown: number, ordered: boolean): string { + if (total === 0) return "none yet"; + return `${shown} of ${total} on · ${ordered ? "your order" : "A–Z"}`; +} + +/** + * What the visibility group is letting through. + * + * Named as additions to the deadlines, because that is what they are: the app's + * answer is *what expires next*, and each of these switches puts something else + * alongside it. + */ +function visibilityState( + completed: boolean, + upcoming: boolean, + ignored: boolean, +): string { + const also: string[] = []; + if (completed) also.push("finished"); + if (upcoming) also.push("not started"); + if (ignored) also.push("ignored"); + return also.length === 0 ? "live deadlines only" : `plus ${also.join(", ")}`; +} + +function ownState(games: number, events: number): string { + if (games === 0 && events === 0) return "none yet"; + const plural = (n: number, word: string) => + `${n} ${word}${n === 1 ? "" : "s"}`; + return `${plural(games, "game")} · ${plural(events, "event")}`; +} + +/** + * One collapsible group of settings. + * + * Native `
` rather than a button and a piece of state, for the reason + * the game-order arrows are ordinary buttons: the disclosure is then reachable + * by keyboard and screen reader without a second implementation of the same + * interaction, and it survives with JavaScript half-loaded. + * + * The state line is not decoration. A group that collapses to its name alone + * turns every question about how the app is set up into a click, which trades + * one kind of friction for another — so the summary answers the group's own + * question, and opening it is for changing the answer rather than reading it. + */ +function Group({ + name, + state, + children, +}: { + name: string; + /** This group's current answer, in the reader's words. */ + state: string; + children: React.ReactNode; +}) { + return ( +
+ + + + + + {name} + + {state} + + {/* Indented to the group name, clear of the chevron. */} +
{children}
+
+ ); +} + +/** + * One row of mutually exclusive answers. + * + * The region, the theme and the board's grouping of unstarted events were three + * copies of the same markup carrying the same pressed-state rules. Being one + * component is also what gives each of them an accessible name: the first two + * had none, so a screen reader read six unlabelled buttons in a row with + * nothing saying which question either half answered — the `role="group"` the + * board's own controls already carry. + */ +function PillGroup({ + label, + options, + value, + onChange, + small = false, +}: { + label: string; + options: ReadonlyArray<{ id: T; label: string }>; + value: T; + onChange: (id: T) => void; + /** Subordinate to the control above it, rather than a question of its own. */ + small?: boolean; +}) { + return ( +
+

{label}

+
+ {options.map((option) => { + const on = option.id === value; + return ( + + ); + })} +
+
+ ); +} + +/** + * One switch, with room to say what it does. + * + * The explanations were inline in the panel, which is what made three checkboxes + * as tall as the game list. They belong to the control, so they live with it. + */ +function Check({ + checked, + onChange, + label, + hint, + badge, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + /** Why a reader might want this, when the label cannot carry it. */ + hint?: string | undefined; + /** A caveat on the control itself — "Experimental" and nothing else so far. */ + badge?: string | undefined; +}) { + return ( + + ); +} + /** * Which games the reader plays, and the order they read them in. * @@ -320,30 +478,28 @@ function GameOrder({ return ( <> + {/* Both affordances, always visible. Touch fires no drag events at all, so + the arrows are the mechanism and the handle is the fast path where a + pointer exists — and the arrows are ordinary buttons, which is what + makes this reachable by keyboard and screen reader without a second + implementation of the same interaction. */}
-

Games

+

+ Drag a row, or use the arrows, to put your games in order. Everything + that lists a game follows it. +

{custom && ( )}
- {/* Both affordances, always visible. Touch fires no drag events at all, so - the arrows are the mechanism and the handle is the fast path where a - pointer exists — and the arrows are ordinary buttons, which is what - makes this reachable by keyboard and screen reader without a second - implementation of the same interaction. */} -

- Drag a row, or use the arrows, to put your games in order. Everything - that lists a game follows it. -

- -
    +
      {games.map((id, i) => { const game = gameMeta(id); const on = !hidden.includes(id); diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index 7c18010..b144c7b 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -276,8 +276,8 @@ export function Timeline({ {waiting.length === 1 ? "One event has not started yet" : `${waiting.length} events have not started yet`}{" "} - — switch on “Show events that haven't started” below to see when they - begin. + — switch on “Show events that haven't started”, under “What you see” + in settings below.

      ) : (
      -

      Your own games and events

      -

      + // No heading or rule of its own: this is the body of a settings group that + // already carries both, and drew a second border under the first one. +

      +

      Track something this app doesn't cover, or an event a source missed. Your dates are yours — they're never presented as coming from a wiki, and they travel in your export. diff --git a/src/client/styles.css b/src/client/styles.css index 07220eb..ac5c7a2 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -423,6 +423,45 @@ body { border-radius: 3px; } +/* ---- Settings: collapsible groups -------------------------------------- */ +/* + * Native `

      `, so the disclosure is keyboard- and screen-reader-reachable + * without a second implementation of the same interaction — the argument the + * game-order arrows already make. Only three things need CSS. The browser's own + * marker cannot be placed and differs between engines, so it goes and the + * summary draws its own chevron; and `summary` is not an `a`, a `button` or a + * `[tabindex]`, so the focus rule above does not reach it and the one control + * that opens a group would have no visible focus at all. + */ +.settings-group > summary { + list-style: none; + cursor: pointer; +} + +.settings-group > summary::-webkit-details-marker { + display: none; +} + +.settings-group > summary:focus-visible { + outline: 2px solid var(--color-near); + outline-offset: 2px; + border-radius: 3px; +} + +.settings-chevron { + transition: transform 150ms ease; +} + +.settings-group[open] > summary .settings-chevron { + transform: rotate(90deg); +} + +@media (prefers-reduced-motion: reduce) { + .settings-chevron { + transition: none; + } +} + /* Horizontal scrollers keep their gutter but hide the bar chrome. */ .scroll-x { overflow-x: auto; diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 4f0b34b..c120fb8 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -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>/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 · A–Z"); + 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);