Looked at on a 1440px screen it was the one block on the page not using
the column it sits in. The rows were held to max-w-3xl inside an 1150px
shell, but the section's own rule spans the shell — so every hairline
inside the panel stopped a third of the column short of the one above it,
between a two-column checklist and a three-column footer that both go
full width. The measure was there to keep a name and its state reading as
one line, but that pairing is what "running now" already does with a
title and its countdown directly above, so full width is the house
pattern rather than a stretch. Prose inside a group keeps its measure.
The game list was the worse half: eighteen rows of four controls at
max-w-md is a thousand pixels of ribbon down the left with two thirds of
the screen empty beside it. It takes two columns past lg once there are
nine or more games, flowing down then across so the numbers still read in
a straight line and an arrow still swaps a row with its neighbour. Below
nine, and on a phone, it is unchanged.
The last group also loses its bottom rule, which was a second hairline a
few pixels above the footer's own.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The panel was one open block of everything in two columns. That reads fine at
four games and stops working at eighteen: the game list alone is eighteen rows
of four controls each, and it sat above the checkbox somebody had scrolled down
here to tick. Nothing was findable and everything was in the way.
Collapsing it into groups is only half an answer, though — a group showing
nothing but its name turns "what is my region set to?" into a click, which
trades one kind of friction for another. So each summary carries its group's
current state: "Europe · Dark", "17 of 18 on · A–Z", "plus finished, not
started". Closed, the panel is a five-line report of how the app is configured;
opening one is for changing an answer rather than reading it. The states are
derived from `prefs` at render, never stored, so they cannot drift from the
controls they describe.
Native `<details>`, for the reason the reorder arrows are ordinary buttons:
keyboard and screen reader reach it with no second implementation. `summary` is
not an `a`, `button` or `[tabindex]`, so it needed its own focus-visible rule —
the shared one does not reach it.
Two things fell out along the way. The region and appearance rows had no
accessible name at all, so a screen reader read six unlabelled buttons in a row;
they and the board's split pills are one `PillGroup` now, with the `role="group"`
the board's own controls already carry. And the empty states that point at
"Show events that haven't started" now name the group holding it, which is what
makes shipping the groups closed safe.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
They are the same object twice — a pill carrying a game's colour, pressed
or not, that you tap — so they get the same hover rather than a second
one: a ring in the game's hue, a one-pixel lift, and the label coming up
to full ink while the chip is not the pressed one.
Sharing it meant `.tick-chip` becoming `.hue-chip`, and dropping the
`data-done` attribute it keyed off. Both chips were already saying the
same thing in `aria-pressed`, and a second attribute repeating it is one
more thing to keep in step — a rule that reads the announced state cannot
disagree with it.
The focus strip needed a pixel of headroom for that lift. `scroll-x` sets
`overflow-x: auto`, which computes the vertical axis to `auto` as well,
so a chip that rises loses that pixel and its ring to the scroller's
edge; `pt-1` is the room, and `mt` gives the same total gap back.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The strip is the fastest thing on the page to act on and nothing happened
under the cursor, so a row of tappable jobs read as a row of status
badges.
The hover has to be drawn from what the component does not own: a chip's
border, ink and wash are mixed from the game's hue, which is data rather
than a token and so arrives as an inline style that no rule can override.
A ring in the same hue, a one-pixel lift and the label coming up to full
ink do the work instead, off `--hue` and `data-done`. Only an outstanding
chip brightens its label — on a finished one the ink *is* the hue, and
overriding it would trade the one thing saying which game for a hover
state.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A `<button>` defaults to `cursor: default`, so most of this app read as
text until you clicked it and found out otherwise — the headline
deadline, the daily chips, the settings pills. Sixty-five controls on the
checklist view alone, two of which had picked up a `cursor-pointer` at
some point because somebody noticed them.
So it is a base-layer rule rather than a class per call site: an
affordance that has to be remembered is one that goes missing. Utilities
still win over it, which is what keeps `cursor-not-allowed` on a disabled
zoom step and `cursor-default` on a checklist day that cannot be ticked
yet saying what they mean.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The dark tokens re-struck under `[data-theme="light"]`. Nothing sets that
attribute yet, so the app still renders exactly as it did.
Two things could not be tokens. Game hues are data — ours and the
reader's — and every one was picked against a near-black ground, so
`readableHue` darkens them along the same hue until they read on paper,
and returns dark untouched by construction. And the attribute has to be
on the root before React can mount, or a reader who chose light gets a
dark flash on every load, so the shell reads the preference itself.
That leaves the ground colour written in three files that cannot import
each other, which is what the test pins together.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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]>
The one moment in this app that is unambiguously good news, and it read
the same as any other tick.
Fires only on a tick that completes the set — the list also shortens when
the reader filters, and a burst there would be congratulating them for
filtering. Nothing on mount either, so arriving at an already-finished
day is not treated as having just finished it.
Decorative, aria-hidden and under a second; the "All done" text beside it
is what a screen reader gets. Fully off under prefers-reduced-motion
rather than merely reduced — nothing here carries meaning, so the honest
reduced version is nothing at all.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The row already opened the event; the tick sat on top of it, so the two
smallest targets on the page did different things a few pixels apart.
"Done" was never the only thing a reader wants to say about an event
either — status, effort and notes all live in the detail sheet.
The tick becomes a chevron: decorative, aria-hidden, and leaning towards
where the row is about to take you on hover. The full-bleed button is
the only control, so there is no second stop for anyone tabbing or using
a screen reader. Undo on a revealed ignored row stays a real button —
it is a real action with nowhere better to sit.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The whole row is now one click target, which gives a generous tap area and
a single unambiguous hover region.
Hover transitions are fast and eased out (110-160ms) so a row responds on
the way in and settles on the way out — a slow symmetric fade is what makes
an interface feel laggy even at full framerate. The meter is the element
that visibly wakes up, since it is the signature. Press feedback lands on
touch as well as mouse, and every hover rule sits behind `hover: hover` so a
tap never leaves a row stuck looking hovered.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Tailwind v4 @theme carries the two colour axes as tokens: a deep blue-black
ground, per-game hues for identity, and a heat ramp for urgency that is
monotonic in salience (dim, blue, amber, red).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>