feat(layout): use the width instead of a column in the middle of the screen

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-18 03:30:47 +02:00
co-authored by Claude Opus 5
parent 6403dec944
commit 1cd9579280
6 changed files with 296 additions and 236 deletions
+5
View File
@@ -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 - 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 parser still handles the previous format. Fixtures are pinned and permanent; `snapshots/` is the
current page and gets overwritten. Do not conflate them. 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 - **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 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 on the timeline — so the deadline guarantee two bullets down holds for what is hidden exactly as it
+5
View File
@@ -28,6 +28,11 @@ A single-page web app that answers three questions:
## Users ## Users
One persona: a player of 25 gacha games who checks in a few times a week, most often on mobile. One persona: a player of 25 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 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. event, because a missing event sends them to a wiki while a wrong one makes them miss content.
+26 -5
View File
@@ -335,9 +335,9 @@ export function App() {
return ( return (
<GameMetaProvider value={gameMeta}> <GameMetaProvider value={gameMeta}>
<Shell> <Shell>
<header className="flex items-center justify-between px-4 pb-3 pt-5"> <header className="flex items-center justify-between gap-4 border-b border-hairline px-4 pb-3 pt-5">
<div> <div>
<p className="font-display text-[0.9375rem] font-bold tracking-[0.02em]"> <p className="font-display text-[0.9375rem] font-bold tracking-[0.02em] lg:text-lg">
EVENT<span className="text-near">CLOCK</span> EVENT<span className="text-near">CLOCK</span>
</p> </p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-faint"> <p className="mt-0.5 flex items-center gap-1.5 text-xs text-faint">
@@ -391,7 +391,24 @@ export function App() {
/> />
{view === "soon" ? ( {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.
*/
<div className="lg:grid lg:grid-cols-[20rem_minmax(0,1fr)] xl:grid-cols-[22rem_minmax(0,1fr)]">
<aside>
{/* The rule belongs to the panel, not to the column: the deadlines
and the dailies are short and the list beside them is long, so a
full-height divider would spend most of its length walling off
an empty gap. It travels with the panel as that pins. */}
<div className="scroll-pane lg:sticky lg:top-0 lg:max-h-screen lg:overflow-y-auto lg:border-r lg:border-hairline">
<NextUp <NextUp
rows={headline} rows={headline}
focused={focus === null ? null : gameMeta(focus).name} focused={focus === null ? null : gameMeta(focus).name}
@@ -413,7 +430,10 @@ export function App() {
daysFor={daily.daysFor} daysFor={daily.daysFor}
onToggleDay={daily.toggleDay} onToggleDay={daily.toggleDay}
/> />
</div>
</aside>
<div className="min-w-0">
{live.length > 0 && ( {live.length > 0 && (
<Section <Section
legend legend
@@ -463,7 +483,8 @@ export function App() {
: "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."} : "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."}
</p> </p>
)} )}
</> </div>
</div>
) : ( ) : (
<Timeline rows={visible} now={now} onOpen={setOpenId} isDone={isDone} /> <Timeline rows={visible} now={now} onOpen={setOpenId} isDone={isDone} />
)} )}
@@ -575,7 +596,7 @@ export function App() {
function Shell({ children }: { children: React.ReactNode }) { function Shell({ children }: { children: React.ReactNode }) {
const update = useAppUpdate(); const update = useAppUpdate();
return ( return (
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x"> <div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
{children} {children}
{update.available && ( {update.available && (
<UpdateNotice <UpdateNotice
+11 -3
View File
@@ -109,6 +109,12 @@ export function Colophon({
return ( return (
<footer className="border-t border-hairline px-4 pb-12 pt-6 text-xs leading-relaxed text-faint"> <footer className="border-t border-hairline px-4 pb-12 pt-6 text-xs leading-relaxed text-faint">
{/* 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. */}
<div className="lg:grid lg:grid-cols-3 lg:gap-x-10">
<div>
<p> <p>
Dates are shown in your local time. Every event links to the page it came Dates are shown in your local time. Every event links to the page it came
from check there before the last hours. from check there before the last hours.
@@ -170,8 +176,9 @@ export function Colophon({
)} )}
</p> </p>
)} )}
</div>
<div className="mt-5"> <div className="mt-5 lg:mt-0">
<p className="eyebrow">With thanks to</p> <p className="eyebrow">With thanks to</p>
<p className="mt-1.5"> <p className="mt-1.5">
{sites.map((site, i) => ( {sites.map((site, i) => (
@@ -208,7 +215,7 @@ export function Colophon({
</p> </p>
</div> </div>
<div className="mt-5 border-t border-hairline pt-4"> <div className="mt-5 border-t border-hairline pt-4 lg:mt-0 lg:border-t-0 lg:pt-0">
<p> <p>
<strong className="font-semibold text-muted">Not affiliated</strong>{" "} <strong className="font-semibold text-muted">Not affiliated</strong>{" "}
with {named.join(", ")}, or any other publisher or source named here. with {named.join(", ")}, or any other publisher or source named here.
@@ -221,8 +228,9 @@ export function Colophon({
the authority, not this one. the authority, not this one.
</p> </p>
</div> </div>
</div>
<p className="mt-5"> <p className="mt-6 border-t border-hairline pt-5">
Built by{" "} Built by{" "}
<a <a
href={AUTHOR.site} href={AUTHOR.site}
+10
View File
@@ -33,6 +33,12 @@ export function Controls({
const gameMeta = useGameMeta(); const gameMeta = useGameMeta();
return ( return (
<section className="border-t border-hairline px-4 py-5"> <section className="border-t border-hairline px-4 py-5">
{/* 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. */}
<div className="lg:grid lg:grid-cols-2 lg:gap-x-10">
<div>
<p className="eyebrow">Games</p> <p className="eyebrow">Games</p>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{games.map((id) => { {games.map((id) => {
@@ -130,7 +136,9 @@ export function Controls({
)} )}
</div> </div>
</div> </div>
</div>
<div>
<YourOwn {...own} /> <YourOwn {...own} />
<div className="mt-6 border-t border-hairline pt-4"> <div className="mt-6 border-t border-hairline pt-4">
@@ -163,6 +171,8 @@ export function Controls({
</label> </label>
</div> </div>
</div> </div>
</div>
</div>
</section> </section>
); );
} }
+11
View File
@@ -290,3 +290,14 @@ body {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--color-hairline) transparent; 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;
}