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]>
491 lines
15 KiB
CSS
491 lines
15 KiB
CSS
@import "tailwindcss";
|
||
|
||
/*
|
||
* Two orthogonal colour systems.
|
||
*
|
||
* hue → WHICH GAME (identity; lives in games.ts, applied inline)
|
||
* heat → HOW URGENT (state; the ramp below)
|
||
*
|
||
* Keeping them separate is what lets one glance answer "whose event is this?"
|
||
* and "how long have I got?" at the same time. Never colour an urgency element
|
||
* with a game hue, or vice versa.
|
||
*
|
||
* Heat is tokens, so it is re-struck per theme below. Hue is data and cannot be
|
||
* — it comes from games.ts and from the reader's own games — so it is read for
|
||
* the theme in state/theme.ts instead, on its way to the elements that apply it.
|
||
*/
|
||
@theme {
|
||
/* Ground is deep blue-black, not neutral near-black — the whole surface
|
||
should feel like a lit instrument panel rather than a void. */
|
||
--color-ground: #12141c;
|
||
--color-surface: #1b1e2a;
|
||
--color-raised: #232739;
|
||
--color-hairline: #2c3145;
|
||
--color-ink: #e9ebf3;
|
||
/* The far end of `ink`: what a solid ink button goes to under the cursor.
|
||
A token rather than `white` because on a light ground ink is nearly black,
|
||
and "brighter" there means darker. */
|
||
--color-ink-strong: #ffffff;
|
||
--color-muted: #888fa6;
|
||
--color-faint: #5a6178;
|
||
/* The veil behind a modal. Carries its own alpha rather than being written
|
||
`bg-ground/80` at the call site, because the two themes need different
|
||
amounts of it — see the light block. */
|
||
--color-scrim: rgb(18 20 28 / 0.8);
|
||
/* Where the page's light comes from: the top of the body gradient. Lighter
|
||
than the ground in the dark theme, and lighter still in the light one, so
|
||
the surface reads as lit from above in both. */
|
||
--color-glow: #1a1e2c;
|
||
|
||
/* Heat ramp: monotonic in salience, dim → blue → amber → red. */
|
||
--color-calm: #55607f;
|
||
--color-near: #5b8def;
|
||
--color-soon: #e8b33c;
|
||
--color-critical: #ff4d6a;
|
||
|
||
--font-display: "Chakra Petch", ui-sans-serif, system-ui, sans-serif;
|
||
--font-body: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||
}
|
||
|
||
/*
|
||
* Dark is what this app is, and what it stays unless a reader says otherwise:
|
||
* the tokens above are the defaults, and light is an override that only applies
|
||
* once `data-theme="light"` is on the root. Nothing here reads
|
||
* `prefers-color-scheme` — a reader whose laptop is in light mode has said
|
||
* something about their laptop, not about this page, and the answer we act on
|
||
* is the one they gave us (`prefs.theme`, resolved in state/theme.ts).
|
||
*
|
||
* Every value below is a *token* override. No component knows which theme it is
|
||
* in; they all name `ink`, `hairline`, `soon`, and get an answer that suits the
|
||
* ground they are standing on. The two exceptions, both unavoidable, are the
|
||
* game hues — reader data, adjusted in state/theme.ts — and the pre-paint
|
||
* script in index.html.
|
||
*/
|
||
:root {
|
||
color-scheme: dark;
|
||
}
|
||
|
||
:root[data-theme="light"] {
|
||
color-scheme: light;
|
||
|
||
/* Paper under daylight, not white: the same cool blue cast as the dark
|
||
ground, so the heat ramp and the game hues still sit on a neutral that is
|
||
slightly blue rather than fighting a pure grey. Cards go *up* to white
|
||
from here, which is the one place the ladder inverts — `raised` is a tint
|
||
rather than a lift, because on a light ground the way to mark a surface as
|
||
interactive is to shade it, not to brighten it. */
|
||
--color-ground: #edf0f7;
|
||
--color-surface: #ffffff;
|
||
--color-raised: #dfe4f0;
|
||
--color-hairline: #c9d1e2;
|
||
--color-ink: #151824;
|
||
--color-ink-strong: #000000;
|
||
--color-muted: #535b73;
|
||
--color-faint: #767e94;
|
||
/* Darker than the ground and much thinner than the dark theme's veil: a
|
||
light scrim over a light page states nothing, and 80% of it would wash the
|
||
page out rather than push it back. */
|
||
--color-scrim: rgb(26 31 48 / 0.42);
|
||
--color-glow: #ffffff;
|
||
|
||
/* The ramp again, re-struck for a light ground. Every step keeps its meaning
|
||
and its ordering; what changes is that each one is now dark enough to read
|
||
as small text on `ground` (≥ 4.5:1), which the dark theme's values are
|
||
nowhere near — `soon` in particular is 1.9:1 on paper, and urgency the
|
||
reader cannot read is not urgency. */
|
||
--color-calm: #8a92a8;
|
||
--color-near: #2b62c6;
|
||
--color-soon: #8a6008;
|
||
--color-critical: #c81e3c;
|
||
}
|
||
|
||
html,
|
||
body,
|
||
#root {
|
||
height: 100%;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--color-ground);
|
||
color: var(--color-ink);
|
||
font-family: var(--font-body);
|
||
-webkit-font-smoothing: antialiased;
|
||
/* Faint vertical gradient so the page reads as a panel with a light source
|
||
rather than a flat fill. Subtle enough not to fight the data. */
|
||
background-image: radial-gradient(
|
||
120% 70% at 50% -10%,
|
||
var(--color-glow) 0%,
|
||
var(--color-ground) 60%
|
||
);
|
||
background-attachment: fixed;
|
||
}
|
||
|
||
/*
|
||
* Anything you can act on says so under the pointer.
|
||
*
|
||
* A `<button>` defaults to `cursor: default`, so the app's own controls — a
|
||
* daily chip, the headline deadline, a settings pill — read as text until you
|
||
* click one and discover otherwise. Sixty-odd of them, which is why this is a
|
||
* rule rather than a class added at each call site: the ones that got it were
|
||
* the ones somebody noticed, and the point of an affordance is that it holds
|
||
* everywhere without being noticed.
|
||
*
|
||
* In `@layer base` on purpose. Utilities beat it, so a control that means
|
||
* something else by the pointer keeps saying it: `cursor-not-allowed` on a
|
||
* disabled zoom step, `cursor-default` on a day the checklist cannot tick yet.
|
||
* `:not(:disabled)` covers the same ground for anything that never bothered.
|
||
*/
|
||
@layer base {
|
||
button:not(:disabled),
|
||
[role="tab"],
|
||
input[type="checkbox"]:not(:disabled),
|
||
label:has(> input[type="checkbox"]:not(:disabled)) {
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
|
||
/* Countdowns tick. Tabular figures keep them from reflowing every second. */
|
||
.tnum {
|
||
font-variant-numeric: tabular-nums;
|
||
font-feature-settings: "tnum" 1;
|
||
}
|
||
|
||
/* Eyebrow labels: the utility voice of the interface. */
|
||
.eyebrow {
|
||
font-family: var(--font-display);
|
||
font-weight: 600;
|
||
font-size: 0.6875rem;
|
||
letter-spacing: 0.14em;
|
||
text-transform: uppercase;
|
||
color: var(--color-faint);
|
||
}
|
||
|
||
/* ---- Signature: the segmented depletion meter -------------------------- */
|
||
/* Discrete ticks, not a smooth bar — lifted from in-game stamina meters.
|
||
Spent ticks recede; remaining ticks carry the urgency colour. */
|
||
.meter {
|
||
display: flex;
|
||
gap: 2px;
|
||
height: 10px;
|
||
align-items: stretch;
|
||
}
|
||
.meter-tick {
|
||
flex: 1 1 0;
|
||
border-radius: 1px;
|
||
background: var(--color-hairline);
|
||
transition: background-color 240ms ease;
|
||
}
|
||
.meter-tick[data-live="true"] {
|
||
background: var(--tick);
|
||
box-shadow: 0 0 6px -1px var(--tick);
|
||
}
|
||
.meter-tick[data-unknown="true"] {
|
||
background: repeating-linear-gradient(
|
||
90deg,
|
||
var(--color-hairline) 0 2px,
|
||
transparent 2px 4px
|
||
);
|
||
box-shadow: none;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.meter-tick {
|
||
animation: tick-in 320ms cubic-bezier(0.2, 0.7, 0.3, 1) backwards;
|
||
}
|
||
@keyframes tick-in {
|
||
from {
|
||
opacity: 0;
|
||
transform: scaleY(0.3);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---- Event rows -------------------------------------------------------- */
|
||
/*
|
||
* Hover should feel instant. Everything here is fast (110–160ms) and eased out,
|
||
* so the 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 60fps.
|
||
*
|
||
* All hover states are behind `hover: hover` so a tap on a touch device does
|
||
* not leave a row stuck in its hover appearance.
|
||
*/
|
||
.event-row {
|
||
--row-bg: transparent;
|
||
background: var(--row-bg);
|
||
transition: background-color 130ms ease-out;
|
||
}
|
||
.event-row.is-complete {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.row-rail {
|
||
background: var(--hue);
|
||
opacity: 0.55;
|
||
transform: scaleY(0.82);
|
||
transform-origin: center;
|
||
transition:
|
||
transform 160ms cubic-bezier(0.2, 0.7, 0.3, 1),
|
||
opacity 130ms ease-out,
|
||
box-shadow 160ms ease-out;
|
||
}
|
||
|
||
.row-title,
|
||
.row-count,
|
||
.row-summary {
|
||
transition: color 130ms ease-out;
|
||
}
|
||
|
||
.row-check {
|
||
transition:
|
||
border-color 130ms ease-out,
|
||
color 130ms ease-out,
|
||
transform 120ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
}
|
||
|
||
@media (hover: hover) {
|
||
.event-row:hover {
|
||
--row-bg: color-mix(in srgb, var(--color-raised) 62%, transparent);
|
||
}
|
||
.event-row:hover .row-rail {
|
||
opacity: 1;
|
||
transform: scaleY(1);
|
||
box-shadow: 0 0 10px -2px var(--hue);
|
||
}
|
||
/* The meter is the signature element, so it is the thing that visibly
|
||
wakes up: live ticks gain a little light under the cursor. */
|
||
.event-row:hover .meter-tick[data-live="true"] {
|
||
filter: brightness(1.28) saturate(1.08);
|
||
}
|
||
.event-row:hover .row-summary {
|
||
color: var(--color-ink);
|
||
}
|
||
.event-row:hover .row-check {
|
||
border-color: var(--color-faint);
|
||
color: var(--color-muted);
|
||
}
|
||
/* The chevron leans towards where the row is about to take you. It is the
|
||
only thing left on the right of a row, so it has to carry "this opens"
|
||
on its own. */
|
||
.event-row:hover .row-open {
|
||
transform: translateX(2px);
|
||
color: var(--color-ink);
|
||
}
|
||
.row-check:hover {
|
||
transform: scale(1.08);
|
||
border-color: var(--color-muted);
|
||
color: var(--color-ink);
|
||
}
|
||
}
|
||
|
||
/* Press feedback lands on both mouse and touch — the tactile half of
|
||
"responsive". */
|
||
.event-row:active {
|
||
--row-bg: color-mix(in srgb, var(--color-raised) 82%, transparent);
|
||
transition-duration: 40ms;
|
||
}
|
||
.row-check:active {
|
||
transform: scale(0.9);
|
||
transition-duration: 60ms;
|
||
}
|
||
|
||
.meter-tick {
|
||
transition:
|
||
background-color 240ms ease,
|
||
filter 130ms ease-out;
|
||
}
|
||
|
||
/* ---- Chips coloured by a game hue --------------------------------------- */
|
||
/*
|
||
* Today's dailies and the focus bar are the same object twice: a pill carrying
|
||
* a game's colour, pressed or not, that you tap. Both were saying none of that
|
||
* — the pointer stayed an arrow and nothing happened under it, so two strips of
|
||
* controls read as rows of status badges.
|
||
*
|
||
* The hover has to be drawn from what the components do *not* own. A chip's
|
||
* border, ink and wash are mixed from the game's hue, which is data rather than
|
||
* a token and therefore arrives as an inline style — and no rule here can
|
||
* override one of those. So a ring in the same hue, a one-pixel lift and the
|
||
* label coming up to full ink carry it instead. `--hue` is the one thing a chip
|
||
* has to hand over; `aria-pressed` it was already saying out loud.
|
||
*
|
||
* The label only brightens on a chip that is *not* pressed. On a pressed one
|
||
* the ink already *is* the game's hue, and overriding it with `ink` would trade
|
||
* the one thing saying which game for a hover state.
|
||
*/
|
||
.hue-chip {
|
||
transition:
|
||
border-color 130ms ease-out,
|
||
background-color 130ms ease-out,
|
||
color 130ms ease-out,
|
||
box-shadow 130ms ease-out,
|
||
transform 120ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||
}
|
||
|
||
.hue-chip-label {
|
||
transition: color 130ms ease-out;
|
||
}
|
||
|
||
@media (hover: hover) {
|
||
.hue-chip:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--hue) 45%, transparent);
|
||
}
|
||
.hue-chip[aria-pressed="false"]:hover .hue-chip-label {
|
||
color: var(--color-ink);
|
||
}
|
||
}
|
||
|
||
/* Press feedback on both mouse and touch, as the event row has. */
|
||
.hue-chip:active {
|
||
transform: scale(0.96);
|
||
transition-duration: 60ms;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.event-row,
|
||
.row-rail,
|
||
.row-title,
|
||
.row-count,
|
||
.row-summary,
|
||
.row-check,
|
||
.meter-tick,
|
||
.hue-chip,
|
||
.hue-chip-label {
|
||
transition: none;
|
||
}
|
||
.row-check:hover,
|
||
.row-check:active,
|
||
.event-row:hover .row-open,
|
||
.event-row:hover .row-rail,
|
||
.hue-chip:hover,
|
||
.hue-chip:active {
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
/* ---- The daily burst ---------------------------------------------------- */
|
||
/*
|
||
* Fires once, when the last daily of the day gets ticked. Purely decorative and
|
||
* pointer-transparent, so it never sits between the reader and a chip.
|
||
*
|
||
* The whole thing is inside `prefers-reduced-motion: no-preference` rather than
|
||
* being merely reduced by it: unlike the meter, nothing here carries meaning
|
||
* that would be lost, so the honest reduced version is nothing at all.
|
||
*/
|
||
.fireworks {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
display: none;
|
||
}
|
||
.firework {
|
||
position: absolute;
|
||
display: block;
|
||
width: 0;
|
||
height: 0;
|
||
}
|
||
.spark {
|
||
position: absolute;
|
||
width: 3px;
|
||
height: 3px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.fireworks {
|
||
display: block;
|
||
}
|
||
.spark {
|
||
animation: spark-fly 900ms cubic-bezier(0.15, 0.7, 0.35, 1) var(--delay, 0ms)
|
||
both;
|
||
}
|
||
@keyframes spark-fly {
|
||
from {
|
||
transform: rotate(var(--a)) translateX(0) scale(0.6);
|
||
opacity: 0;
|
||
}
|
||
18% {
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: rotate(var(--a)) translateX(var(--d)) scale(1);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Focus ring: visible, and never removed. */
|
||
:where(a, button, [tabindex]):focus-visible {
|
||
outline: 2px solid var(--color-near);
|
||
outline-offset: 2px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* ---- Settings: collapsible groups -------------------------------------- */
|
||
/*
|
||
* Native `<details>`, so the disclosure is keyboard- and screen-reader-reachable
|
||
* without a second implementation of the same interaction — the argument the
|
||
* game-order arrows already make. Only three things need CSS. The browser's own
|
||
* marker cannot be placed and differs between engines, so it goes and the
|
||
* summary draws its own chevron; and `summary` is not an `a`, a `button` or a
|
||
* `[tabindex]`, so the focus rule above does not reach it and the one control
|
||
* that opens a group would have no visible focus at all.
|
||
*/
|
||
.settings-group > summary {
|
||
list-style: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.settings-group > summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.settings-group > summary:focus-visible {
|
||
outline: 2px solid var(--color-near);
|
||
outline-offset: 2px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/*
|
||
* The last group closes the list, so its rule would be a second hairline a few
|
||
* pixels above the footer's own — two parallel lines with nothing between them,
|
||
* which reads as a mistake rather than as a boundary.
|
||
*/
|
||
.settings-group:last-child {
|
||
border-bottom-width: 0;
|
||
}
|
||
|
||
.settings-chevron {
|
||
transition: transform 150ms ease;
|
||
}
|
||
|
||
.settings-group[open] > summary .settings-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.settings-chevron {
|
||
transition: none;
|
||
}
|
||
}
|
||
|
||
/* Horizontal scrollers keep their gutter but hide the bar chrome. */
|
||
.scroll-x {
|
||
overflow-x: auto;
|
||
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;
|
||
}
|