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
+26 -5
View File
@@ -335,9 +335,9 @@ export function App() {
return (
<GameMetaProvider value={gameMeta}>
<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>
<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>
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-faint">
@@ -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.
*/
<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
rows={headline}
focused={focus === null ? null : gameMeta(focus).name}
@@ -413,7 +430,10 @@ export function App() {
daysFor={daily.daysFor}
onToggleDay={daily.toggleDay}
/>
</div>
</aside>
<div className="min-w-0">
{live.length > 0 && (
<Section
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."}
</p>
)}
</>
</div>
</div>
) : (
<Timeline rows={visible} now={now} onOpen={setOpenId} isDone={isDone} />
)}
@@ -575,7 +596,7 @@ export function App() {
function Shell({ children }: { children: React.ReactNode }) {
const update = useAppUpdate();
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}
{update.available && (
<UpdateNotice
+116 -108
View File
@@ -109,120 +109,128 @@ export function Colophon({
return (
<footer className="border-t border-hairline px-4 pb-12 pt-6 text-xs leading-relaxed text-faint">
<p>
Dates are shown in your local time. Every event links to the page it came
from check there before the last hours.
</p>
{/* 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>
Dates are shown in your local time. Every event links to the page it came
from check there before the last hours.
</p>
{/*
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.
*/}
<p className="mt-2">
<span className="text-muted">Event data last refreshed</span>{" "}
{refreshedAt === null ? (
"— no source has been fetched yet."
) : (
<>
<time dateTime={refreshedAt} className="text-muted">
{formatAbsolute(refreshedAt, true)}
</time>
{`${ago} ago.`}
</>
)}
</p>
{/*
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.
*/}
<p className="mt-2">
<span className="text-muted">Event data last refreshed</span>{" "}
{refreshedAt === null ? (
"— no source has been fetched yet."
) : (
<>
<time dateTime={refreshedAt} className="text-muted">
{formatAbsolute(refreshedAt, true)}
</time>
{`${ago} ago.`}
</>
)}
</p>
{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.
<p className="mt-2 text-soon">
{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) => (
<span key={s.game}>
{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)`}
</span>
))}
{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.
<p className="mt-2 text-soon">
{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) => (
<span key={s.game}>
{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)`}
</span>
))}
{stale.length > STALE_NAMES &&
` and ${stale.length - STALE_NAMES} other game${
stale.length - STALE_NAMES > 1 ? "s" : ""
}`}
{"."}
</>
)}
</p>
)}
</p>
)}
</div>
<div className="mt-5">
<p className="eyebrow">With thanks to</p>
<p className="mt-1.5">
{sites.map((site, i) => (
<span key={site.name}>
{i > 0 && (i === sites.length - 1 ? " and " : ", ")}
<a
href={site.url}
target="_blank"
rel="noreferrer noopener"
className="text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near"
>
{site.name}
</a>
</span>
))}
{", whose editors compile and maintain the event calendars this reads from. The schedules are their work; this page only rearranges them."}
</p>
<p className="mt-2">
And to{" "}
{studios.map((studio, i) => (
<span key={studio}>
{i > 0 && (i === studios.length - 1 ? " and " : ", ")}
{studio}
</span>
))}
, who make the games worth keeping track of {" "}
{games.map((game, i) => (
<span key={game.id}>
{i > 0 && ", "}
<span style={{ color: game.hue }}>{game.name}</span>
</span>
))}
{"."}
</p>
<div className="mt-5 lg:mt-0">
<p className="eyebrow">With thanks to</p>
<p className="mt-1.5">
{sites.map((site, i) => (
<span key={site.name}>
{i > 0 && (i === sites.length - 1 ? " and " : ", ")}
<a
href={site.url}
target="_blank"
rel="noreferrer noopener"
className="text-muted underline decoration-hairline underline-offset-2 transition-colors duration-150 hover:text-ink hover:decoration-near"
>
{site.name}
</a>
</span>
))}
{", whose editors compile and maintain the event calendars this reads from. The schedules are their work; this page only rearranges them."}
</p>
<p className="mt-2">
And to{" "}
{studios.map((studio, i) => (
<span key={studio}>
{i > 0 && (i === studios.length - 1 ? " and " : ", ")}
{studio}
</span>
))}
, who make the games worth keeping track of {" "}
{games.map((game, i) => (
<span key={game.id}>
{i > 0 && ", "}
<span style={{ color: game.hue }}>{game.name}</span>
</span>
))}
{"."}
</p>
</div>
<div className="mt-5 border-t border-hairline pt-4 lg:mt-0 lg:border-t-0 lg:pt-0">
<p>
<strong className="font-semibold text-muted">Not affiliated</strong>{" "}
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.
</p>
<p className="mt-2">
Event dates can be wrong or go out of date. Treat the source page as
the authority, not this one.
</p>
</div>
</div>
<div className="mt-5 border-t border-hairline pt-4">
<p>
<strong className="font-semibold text-muted">Not affiliated</strong>{" "}
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.
</p>
<p className="mt-2">
Event dates can be wrong or go out of date. Treat the source page as
the authority, not this one.
</p>
</div>
<p className="mt-5">
<p className="mt-6 border-t border-hairline pt-5">
Built by{" "}
<a
href={AUTHOR.site}
+133 -123
View File
@@ -33,134 +33,144 @@ export function Controls({
const gameMeta = useGameMeta();
return (
<section className="border-t border-hairline px-4 py-5">
<p className="eyebrow">Games</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const on = !prefs.hiddenGames.includes(id);
return (
<button
key={id}
type="button"
onClick={() => onToggleGame(id)}
aria-pressed={on}
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: on ? game.hue : "var(--color-hairline)",
color: on ? game.hue : "var(--color-faint)",
background: on
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
: "transparent",
}}
>
{game.short}
</button>
);
})}
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
{/* 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">Server region</p>
<div className="mt-2 flex gap-1.5">
{REGIONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onUpdate({ region: r.id, regionConfirmed: true })}
aria-pressed={prefs.region === r.id}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
prefs.region === r.id
? "border-ink/70 text-ink"
: "border-hairline text-faint hover:text-muted"
}`}
>
{r.label}
</button>
))}
<p className="eyebrow">Games</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const on = !prefs.hiddenGames.includes(id);
return (
<button
key={id}
type="button"
onClick={() => onToggleGame(id)}
aria-pressed={on}
className="rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: on ? game.hue : "var(--color-hairline)",
color: on ? game.hue : "var(--color-faint)",
background: on
? `color-mix(in srgb, ${game.hue} 12%, transparent)`
: "transparent",
}}
>
{game.short}
</button>
);
})}
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-4">
<div>
<p className="eyebrow">Server region</p>
<div className="mt-2 flex gap-1.5">
{REGIONS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onUpdate({ region: r.id, regionConfirmed: true })}
aria-pressed={prefs.region === r.id}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
prefs.region === r.id
? "border-ink/70 text-ink"
: "border-hairline text-faint hover:text-muted"
}`}
>
{r.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showCompleted}
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show events I've finished
</label>
{/* 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. */}
<label className="flex cursor-pointer select-none items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.detectDaily}
onChange={(e) => onUpdate({ detectDaily: e.target.checked })}
className="mt-px size-4 accent-[var(--color-near)]"
/>
<span>
Spot daily events automatically
<span className="ml-1.5 rounded-full border border-hairline px-1.5 py-0.5 align-[1px] text-[0.5625rem] font-medium uppercase tracking-wider text-faint">
Experimental
</span>
<span className="mt-0.5 block max-w-xs leading-relaxed text-faint">
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.
</span>
</span>
</label>
{ignoredCount > 0 && (
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showIgnored}
onChange={(e) => onUpdate({ showIgnored: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show the {ignoredCount} event{ignoredCount > 1 ? "s" : ""} I'm
ignoring
</label>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showCompleted}
onChange={(e) => onUpdate({ showCompleted: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show events I've finished
</label>
<div>
<YourOwn {...own} />
{/* 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. */}
<label className="flex cursor-pointer select-none items-start gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.detectDaily}
onChange={(e) => onUpdate({ detectDaily: e.target.checked })}
className="mt-px size-4 accent-[var(--color-near)]"
/>
<span>
Spot daily events automatically
<span className="ml-1.5 rounded-full border border-hairline px-1.5 py-0.5 align-[1px] text-[0.5625rem] font-medium uppercase tracking-wider text-faint">
Experimental
</span>
<span className="mt-0.5 block max-w-xs leading-relaxed text-faint">
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.
</span>
</span>
</label>
{ignoredCount > 0 && (
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={prefs.showIgnored}
onChange={(e) => onUpdate({ showIgnored: e.target.checked })}
className="size-4 accent-[var(--color-near)]"
/>
Show the {ignoredCount} event{ignoredCount > 1 ? "s" : ""} I'm
ignoring
</label>
)}
</div>
</div>
<YourOwn {...own} />
<div className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">Your progress</p>
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
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.
</p>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={onExport}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Export
</button>
<label className="cursor-pointer rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink">
Import
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onImport(file);
e.target.value = "";
}}
/>
</label>
<div className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">Your progress</p>
<p className="mt-1.5 max-w-md text-xs leading-relaxed text-faint">
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.
</p>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={onExport}
className="rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink"
>
Export
</button>
<label className="cursor-pointer rounded-lg border border-hairline px-3 py-1.5 text-xs text-muted transition-colors hover:text-ink">
Import
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onImport(file);
e.target.value = "";
}}
/>
</label>
</div>
</div>
</div>
</div>
</section>
+11
View File
@@ -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;
}