diff --git a/AGENTS.md b/AGENTS.md index 51621b0..59d022c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -433,6 +433,11 @@ to an open page). Four things hold it up: - Keep old fixtures when a source changes shape — the old one is the regression test proving the parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the current page and gets overwritten. Do not conflate them. +- **The page is two columns past `lg`, and the split is the one below.** What the page *tells* the + reader to do — the next deadlines, tonight's dailies — pins to a rail on the left and stays put + while the lists it *shows* them scroll beside it. Below that breakpoint it is one column in the + same order. The rail's rule belongs to the panel, not the column: the panel is short and the list + is long, so a full-height divider would spend most of its length walling off a gap. - **Truncating a list is not re-sorting it.** Each section shows `LIST_CAP` rows and offers "show all N". The rows below the cut keep their place in the order, stay counted in the header, and stay on the timeline — so the deadline guarantee two bullets down holds for what is hidden exactly as it diff --git a/docs/PRD.md b/docs/PRD.md index 3a9b19a..68efebd 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -28,6 +28,11 @@ A single-page web app that answers three questions: ## Users One persona: a player of 2–5 gacha games who checks in a few times a week, most often on mobile. + +Most often is not only, and the desktop layout is not the phone layout stretched. Past `lg` the page +splits: what it is *telling* the reader to do — the next deadlines, tonight's dailies — pins to a +rail on the left and stays put, while the lists it is *showing* them scroll beside it. Below that +breakpoint the same split produces the same answer in one column: instructions first. They care about accuracy of end dates above everything else — a wrong date is worse than a missing event, because a missing event sends them to a wiki while a wrong one makes them miss content. diff --git a/src/client/App.tsx b/src/client/App.tsx index b174128..0268ccd 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -335,9 +335,9 @@ export function App() { return ( -
+
-

+

EVENTCLOCK

@@ -391,7 +391,24 @@ export function App() { /> {view === "soon" ? ( - <> + /* + * Two columns once the screen has room for them, and the split is the + * one this codebase already draws everywhere else: what the page is + * *telling* the reader to do on the left, what it is *showing* them on + * the right. The deadlines and tonight's dailies are instructions, they + * are short, and they are what the reader came for — so on a wide + * screen they stop scrolling away and stay pinned beside the list. + * + * Below `lg` this is one column in exactly the old order, because on a + * phone the same argument produces the same answer: put them first. + */ +

+ +
{live.length > 0 && (
+
) : ( )} @@ -575,7 +596,7 @@ export function App() { function Shell({ children }: { children: React.ReactNode }) { const update = useAppUpdate(); return ( -
+
{children} {update.available && ( -

- Dates are shown in your local time. Every event links to the page it came - from — check there before the last hours. -

+ {/* Three columns once there is room: the age of the data, who compiled + it, and what it is not. Stacked they are a long scroll of small grey + text that a reader gives up on before reaching the disclaimer, which + is the one part that has to be read. */} +
+
+

+ Dates are shown in your local time. Every event links to the page it came + from — check there before the last hours. +

- {/* - Stated on every load, not only when something is wrong. A page that says - nothing about its own age reads as current, and "how old is this?" is the - question a reader has to be able to answer before trusting a countdown - (PRD F7). The date is absolute *and* relative on purpose: the relative - half is what gets read, the absolute half is what can be checked. - */} -

- Event data last refreshed{" "} - {refreshedAt === null ? ( - "— no source has been fetched yet." - ) : ( - <> - - {` — ${ago} ago.`} - - )} -

+ {/* + Stated on every load, not only when something is wrong. A page that says + nothing about its own age reads as current, and "how old is this?" is the + question a reader has to be able to answer before trusting a countdown + (PRD F7). The date is absolute *and* relative on purpose: the relative + half is what gets read, the absolute half is what can be checked. + */} +

+ Event data last refreshed{" "} + {refreshedAt === null ? ( + "— no source has been fetched yet." + ) : ( + <> + + {` — ${ago} ago.`} + + )} +

- {stale.length > 0 && ( - // Named per game rather than counted, because a count is not something a - // reader can act on: knowing *which* lane is behind tells them which - // source page to go and check, which is the whole remedy on offer. - // - // Except when the answer is "all of them", which is what a refresh that - // stopped running looks like. Ten names each repeating the same age is - // less readable than the count this replaced, and the headline above - // already gives the date — so that case gets a sentence, not a list. -

- {stale.length === games.length ? ( - `Nothing has refreshed in over two days, so any end date here may have moved.` - ) : ( - <> - {stale.length === 1 ? "This game has" : "These games have"} not - refreshed in over two days, so some of their end dates may have - moved:{" "} - {stale.slice(0, STALE_NAMES).map((s, i, shown) => ( - - {i > 0 && (i === shown.length - 1 && stale.length <= STALE_NAMES ? " and " : ", ")} - {gameMeta(s.game).name} - {s.lastSuccessAt === null - ? " (never)" - : ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`} - - ))} - {stale.length > STALE_NAMES && - ` and ${stale.length - STALE_NAMES} other game${ - stale.length - STALE_NAMES > 1 ? "s" : "" - }`} - {"."} - + {stale.length > 0 && ( + // Named per game rather than counted, because a count is not something a + // reader can act on: knowing *which* lane is behind tells them which + // source page to go and check, which is the whole remedy on offer. + // + // Except when the answer is "all of them", which is what a refresh that + // stopped running looks like. Ten names each repeating the same age is + // less readable than the count this replaced, and the headline above + // already gives the date — so that case gets a sentence, not a list. +

+ {stale.length === games.length ? ( + `Nothing has refreshed in over two days, so any end date here may have moved.` + ) : ( + <> + {stale.length === 1 ? "This game has" : "These games have"} not + refreshed in over two days, so some of their end dates may have + moved:{" "} + {stale.slice(0, STALE_NAMES).map((s, i, shown) => ( + + {i > 0 && (i === shown.length - 1 && stale.length <= STALE_NAMES ? " and " : ", ")} + {gameMeta(s.game).name} + {s.lastSuccessAt === null + ? " (never)" + : ` (${formatRemaining(now - Date.parse(s.lastSuccessAt))} ago)`} + + ))} + {stale.length > STALE_NAMES && + ` and ${stale.length - STALE_NAMES} other game${ + stale.length - STALE_NAMES > 1 ? "s" : "" + }`} + {"."} + + )} +

)} -

- )} +
-
-

With thanks to

-

- {sites.map((site, i) => ( - - {i > 0 && (i === sites.length - 1 ? " and " : ", ")} - - {site.name} - - - ))} - {", whose editors compile and maintain the event calendars this reads from. The schedules are their work; this page only rearranges them."} -

-

- And to{" "} - {studios.map((studio, i) => ( - - {i > 0 && (i === studios.length - 1 ? " and " : ", ")} - {studio} - - ))} - , who make the games worth keeping track of —{" "} - {games.map((game, i) => ( - - {i > 0 && ", "} - {game.name} - - ))} - {"."} -

+
+

With thanks to

+

+ {sites.map((site, i) => ( + + {i > 0 && (i === sites.length - 1 ? " and " : ", ")} + + {site.name} + + + ))} + {", whose editors compile and maintain the event calendars this reads from. The schedules are their work; this page only rearranges them."} +

+

+ And to{" "} + {studios.map((studio, i) => ( + + {i > 0 && (i === studios.length - 1 ? " and " : ", ")} + {studio} + + ))} + , who make the games worth keeping track of —{" "} + {games.map((game, i) => ( + + {i > 0 && ", "} + {game.name} + + ))} + {"."} +

+
+ +
+

+ Not affiliated{" "} + with {named.join(", ")}, or any other publisher or source named here. + This is an unofficial fan-made tool, not endorsed by or connected to + any of them. All game names, event names and trademarks belong to their + respective owners. +

+

+ Event dates can be wrong or go out of date. Treat the source page as + the authority, not this one. +

+
-
-

- Not affiliated{" "} - with {named.join(", ")}, or any other publisher or source named here. - This is an unofficial fan-made tool, not endorsed by or connected to - any of them. All game names, event names and trademarks belong to their - respective owners. -

-

- Event dates can be wrong or go out of date. Treat the source page as - the authority, not this one. -

-
- -

+

Built by{" "} -

Games

-
- {games.map((id) => { - const game = gameMeta(id); - const on = !prefs.hiddenGames.includes(id); - 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. */} +
-

Server region

-
- {REGIONS.map((r) => ( - - ))} +

Games

+
+ {games.map((id) => { + const game = gameMeta(id); + const on = !prefs.hiddenGames.includes(id); + return ( + + ); + })} +
+ +
+
+

Server region

+
+ {REGIONS.map((r) => ( + + ))} +
+
+ +
+ + + {/* 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 && ( + + )} +
-
- +
+ - {/* 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 && ( - - )} -
-
- - - -
-

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. -

-
- - +
+

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. +

+
+ + +
+
diff --git a/src/client/styles.css b/src/client/styles.css index ec36017..05484e3 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -290,3 +290,14 @@ body { scrollbar-width: thin; scrollbar-color: var(--color-hairline) transparent; } + +/* + * Panes that scroll in both directions — the timeline board, and the pinned + * deadline rail on a wide screen. Same quiet bar chrome as `.scroll-x`, but the + * overflow itself is left to the element, because both of those only become + * scrollers at a breakpoint. + */ +.scroll-pane { + scrollbar-width: thin; + scrollbar-color: var(--color-hairline) transparent; +}