From 1cd9579280391674e55505c6ba8ff70817743199 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 18 Aug 2026 03:30:47 +0200 Subject: [PATCH] feat(layout): use the width instead of a column in the middle of the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen got the same 672px column, so a desktop reader read a phone page with two thirds of the window empty — and the one design criticism the release thread produced was formed from a screenshot. Past lg the page splits along the line this codebase already draws: what it tells the reader to do — the next deadlines, tonight's dailies — pins to a rail on the left and stays there while the lists it shows them scroll beside it. The footer and the settings panel become columns rather than a long fall of small grey text. Below that breakpoint nothing moves: the same split puts the instructions first in one column. The rail's rule sits on the panel rather than the column. The panel is short and the list beside it is long, so a full-height divider would spend most of its length walling off a gap. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 5 + docs/PRD.md | 5 + src/client/App.tsx | 31 +++- src/client/components/Colophon.tsx | 224 +++++++++++++------------ src/client/components/Controls.tsx | 256 +++++++++++++++-------------- src/client/styles.css | 11 ++ 6 files changed, 296 insertions(+), 236 deletions(-) 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; +}