/* Board Pro signage — 1920×1080 logical viewport, gen1-safe styling:
   animations restricted to transform/opacity, no filters or shadows on
   anything that moves, system font stack. */

:root {
  /* Momentum, the app's single theme (the old Room & Board palette retired
     2026-07-19): native RoomOS neutrals per Cisco's Webex Frame — pure-black
     canvas, #121212 surfaces, white-alpha .11 tiles/wells/hairlines. */
  --bg: #000;
  --bg-card: #121212;
  --bg-card-2: rgba(255, 255, 255, 0.11);
  --ink: rgba(255, 255, 255, 0.95);
  --ink-mid: rgba(255, 255, 255, 0.84);
  --ink-dim: rgba(255, 255, 255, 0.72);
  --ink-faint: rgba(255, 255, 255, 0.56);
  --accent: #64b4fa;
  --good: #3cc29a;
  --bad: #fc8b98;
  --warn: #f2990a;
  --radius: 24px;
  --gap: 20px;
  /* Solid "wells" under bright semantic text — the native badge idiom
     (Webex Frame: #0a274a well + #64b4fa text) — instead of alpha tints,
     which go muddy on the OLED black. */
  --warn-tint: #36220c;
  --good-tint: #0e2b20;
  --bad-tint: #4f0e10;
  /* The board's own face first, then whatever the viewing device has. Named
     once so the metric-pinned alias below can mirror it EXACTLY — the two lists
     must resolve to the same physical font or a badge would render in a
     different typeface from the copy beside it. */
  --font-board: 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  --font-centred: 'RB Centred', var(--font-board);
}

/* Text centred inside its own box, independent of the rendering font.
   CSS centres the LINE BOX, never the glyphs: whatever the construction —
   line-height == height, symmetric padding, or flex align-items:center — the
   baseline lands at box-centre + (ascent − descent)/2, and ascent/descent are
   read off the font. So a badge that is centred with the desktop fallback rides
   high with the board's CiscoSansTT, whose metrics are taller (see the
   .train__linechip note) and descend further. Pixel-measured over four synthetic
   metric profiles: a 20px label travelled 7px between the extremes, by the same
   amount in all three constructions, so no amount of re-centring in CSS fixes
   it — the variable is the font, so the fix has to be too.
   This face is the SAME glyphs the board already draws — the src list mirrors
   --font-board, so it resolves to whatever --font-board resolves to — with its
   line metrics pinned so ascent − descent equals a typical cap height. The
   baseline then lands at box-centre + cap/2, i.e. the CAPS are centred, on any
   font the OS hands us. If none of the names resolve the rule simply drops out
   and the element falls through to --font-board, unchanged.
   Use it on boxes whose height is explicit (a pill, a bullet); on a box sized by
   its own line box, pinning the metrics would change its height. */
@font-face {
  font-family: 'RB Centred';
  src: local('CiscoSansTT'), local('Cisco SansTT'), local('SF Pro Display'),
       local('Segoe UI'), local('Roboto'), local('Helvetica Neue'), local('Arial');
  ascent-override: 86%;
  descent-override: 14%;
  line-gap-override: 0%;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

/* The hidden attribute must always win, even over rules that set display. */
[hidden] { display: none !important; }

/* RoomOS keeps a "Tap here to start" bar along the bottom of the screen in
   signage mode. The long-held belief was that it OVERLAID the bottom
   --roomos-bar px of our viewport (a FAB at bottom:14px came back ~45% covered,
   which fit that story); Sean's on-device diagnostic of 2026-07-28 found the
   truer version — the Board Pro lays the page out in 1920x1040 and the bar lives
   in the 40 physical px BELOW that box. Nothing is painted over page content;
   the bottom 40px of our fixed 1080px page simply fall off the screen, which
   crops anything sitting there exactly as an overlay would have.
   --safe-bottom (84px) is the clearance that keeps the PAGE's content (grid,
   settings, editor) off that edge; do not spend it without re-measuring.
   --roomos-bar is the leftover of the old story: anything that used it to "sit
   flush with the last visible pixel" is now laid out against the REAL viewport
   instead (position:fixed + bottom:0), which is self-maintaining and needs no
   device number at all. Only .iptv__mute still asks for it, as a plain margin. */
:root { --safe-bottom: 84px; --roomos-bar: 40px; }

/* A FIXED PAGE, not a viewport. Every device gets these exact 1920x1080 CSS px,
   so the grid, the editor and the settings overlay have identical geometry on a
   1040 board, a 1200 Room Navigator and an 1080 desktop preview — and the widget
   capacity tables in js/capacity.js are measured against THIS, not against any
   screen. Only the position:fixed full-screen contexts are sized by the real
   viewport; js/expand.js BOARD_VIEWPORT_H is the number they model against.
   This page height is deliberately NOT viewport-derived and must stay that way:
   coupling it to the glass would put every future capacity change on a
   per-device matrix. test/viewport.test.js is the guard.

   `touch-action` on the root is what makes a pinch-zoom impossible, and it is
   here because a fixed page has no business being zoomable. Someone at a board
   pinched the dashboard by accident; browser zoom is ENGINE state, not page
   state, so it outlived the reload that Save does after a layout edit and the
   board came back still at roughly 200%, unusable until a person zoomed it out
   by hand. Nothing in the page can undo an applied zoom from script, so the
   gesture has to be stopped before it starts. Touch behaviour is the
   INTERSECTION of the touched element's touch-action with every ancestor's,
   which is why declaring it once at the root covers the whole document.
   The value is `pan-x pan-y` rather than `manipulation`, which still carries
   the pinch-zoom token (it only gives up double-tap zoom), and rather than
   `none`, which would take panning with it and leave the settings rail, the
   reading views and every scrollable overlay unable to scroll. The stricter
   rules further down (the edit-mode drag and resize handles, the slideshow)
   keep winning on their own elements: intersecting anything with none is none.
   The viewport meta deliberately CANNOT do this job. Read the comment above
   the meta tag in index.html: a scale constraint there is a verified past
   regression that showed a Room Navigator only the top-left ~1280px. */
html, body {
  width: 1920px;
  height: 1080px;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-board);
  -webkit-font-smoothing: antialiased;
  touch-action: pan-x pan-y;
}

/* …and on a viewport TALLER than the page (a Room Navigator's 1200), the
   leftover splits evenly instead of pooling at the bottom as a band. Purely
   cosmetic — the page block keeps its exact 1080px height, so nothing about
   capacity, the grid or any card moves. `max()` clamps the offset to 0 on every
   viewport at or below 1080 (a board's 1040 would otherwise compute -20px), so
   the board and the desktop preview are untouched to the pixel; an engine with
   no dvh support drops the declaration and lands on that same 0. Deliberately
   `top`, not `transform`: a transformed body would become the containing block
   for every position:fixed overlay and re-anchor them to the page we are
   trying to escape. --bg is #000, the same colour the root canvas paints, so
   the split reads as margin rather than as two bars. */
body { position: relative; top: max(0px, calc((100dvh - 1080px) / 2)); }

#app { position: relative; width: 100%; height: 100%; padding: 32px 40px; padding-bottom: var(--safe-bottom); display: flex; flex-direction: column; }

/* ---------- top bar ---------- */
.topbar { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 24px; }
.topbar__greeting { font-size: 40px; font-weight: 600; letter-spacing: -0.01em; }
.topbar__clock { display: flex; align-items: baseline; gap: 18px; }
.topbar__time { font-size: 48px; font-weight: 700; font-variant-numeric: tabular-nums; } /* 1.2x the greeting — anchor, not shout */
.topbar__date { font-size: 26px; color: var(--ink-dim); }

/* ---------- dashboard grid ---------- */
.grid {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-template-rows: repeat(8, minmax(0, 1fr));
  gap: var(--gap);
  min-height: 0;
}
.card {
  background: var(--bg-card);
  border-radius: var(--radius);
  padding: 22px 26px;
  overflow: hidden;
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
.card__title { font-size: 20px; font-weight: 600; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 14px; }
.card__body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
.card__stamp { position: absolute; top: 24px; right: 26px; font-size: 20px; color: var(--warn); }
.card.is-stale { opacity: 0.75; }

/* Empty/prompt states center on the card (auto margins absorb the flex
   body's free space — placement no longer depends on which cards carry
   elastic body rules). The via-phrase never breaks across lines. */
.empty { color: var(--ink-faint); font-size: 22px; margin: auto; text-align: center; padding: 0 12px; }
.empty__via { white-space: nowrap; }

/* ---------- world clock ---------- */
/* 35px rows (capacity constant 45 = row + gap): five zones fit a 3-tall card.
   City stays at the 20px legibility floor. */
.wc-row { display: flex; align-items: baseline; min-height: 35px; flex-wrap: nowrap; }
.wc-row__city { flex: 1 1 auto; min-width: 0; padding-right: 12px; font-size: 20px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wc-row__time { flex: none; font-size: 23px; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* fixed-width hour cell → colon lands on one axis for 1- and 2-digit hours */
.wc-row__hh { display: inline-block; width: 2ch; text-align: right; }
/* day-offset badge: 0-width by default; the row reserves a real gutter only
   when some city crosses a day boundary (.wc-has-day), so the time never moves */
/* Day offset is information, not a warning — quiet dim, and 15px meets the type floor. */
.wc-row__day { flex: none; width: 0; overflow: hidden; font-size: 15px; color: var(--ink-dim); font-weight: 600; text-align: left; transform: translateY(-2px); }
.wc-has-day .wc-row__day { width: 30px; margin-left: 4px; }
/* Shallow cards (h=2, legal since 2026-08-01 — see MIN_ALTS): three cities fit
   where the full-size row fits two. The row is FIXED at 28px rather than left to
   its line boxes, because this is the one size where the fit is exact and a
   device font with taller metrics must not be able to spend the margin — the
   same contract the news list's pinned pixels run. 21px is the largest time that
   sits in 28px on any of them; the city stays at the 20px legibility floor, and
   weight plus full ink still make the time the thing the eye lands on.
   28px row + the 10px gap floor = the 38 in capacity.js. Keep them together. */
.card--worldclock.t-s .wc-row { min-height: 0; height: 28px; }
.card--worldclock.t-s .wc-row__time { font-size: 21px; }
/* Bounded elastic rows, same pattern as markets: gaps absorb slack up to a
   cap, then the list centers. --n stamped by the renderer. --wcrow is the
   WORST-CASE row height (overshooting it only leaves centered air; undershooting
   would overflow), so the shallow tier restates it alongside its own row. */
.card--worldclock .card__body {
  --wcrow: 35px;
  justify-content: center;
  row-gap: clamp(10px, calc((100% - var(--n, 5) * var(--wcrow)) / max(var(--n, 5) - 1, 1)), 26px);
}
/* Shallow tier: TOP-packed on the fit model's own fixed 10px gap, not the
   centered elastic above. The elastic fills the body to its bottom edge, and
   at 3x2 (the one exact-fill size) that parks the last row's time inside the
   corner badge's zone — the "+2" sat ON "1:13 PM" (Sean's board, 2026-08-02).
   Top-packing leaves the fit's ~18px of slack at the BOTTOM, which is exactly
   where the badge lives. */
.card--worldclock.t-s .card__body { --wcrow: 28px; justify-content: flex-start; row-gap: 10px; }
/* Same bounded elastic for the other uniform-row list cards. Row vars are
   worst-case heights: overshooting a var only leaves centered air, while
   undershooting would overflow. Subway/services opt OUT: their rows expand
   (alerts/incident notes) and both shed rows via measure-trim instead. */
.card--sports .card__body {
  --trow: 66px;
  justify-content: center;
  row-gap: clamp(10px, calc((100% - var(--n, 3) * var(--trow)) / max(var(--n, 3) - 1, 1)), 22px);
}
.card--sports.t-s .card__body { --trow: 47px; }
/* Subway stretches too: 66px = the worst-case (2-line alert) row, so gaps
   never over-grow on alert days — real overflow stays the trim loop's job. */
.card--subway .card__body {
  justify-content: center;
  row-gap: clamp(10px, calc((100% - var(--n, 3) * 66px) / max(var(--n, 3) - 1, 1)), 20px);
}
.card--citibike .card__body,
.card--tfl .card__body,
.card--services .card__body,
.card--tennis .card__body {
  justify-content: center;
  row-gap: clamp(10px, calc((100% - var(--n, 3) * 36px) / max(var(--n, 3) - 1, 1)), 20px);
}

/* ---------- weather ---------- */
.alert { display: flex; gap: 10px; align-items: center; background: var(--warn-tint); color: var(--warn); border-radius: 10px; padding: 6px 14px; font-size: 20px; font-weight: 600; }
/* The weather body zeroes the card gap (its sections space themselves), which
   left the NWS alert banner sitting directly on the big temperature. 10px +
   the trimmed banner padding is what the h=4 budget affords (audited). */
.card--weather .alert { margin-bottom: 10px; }
.icon { width: 28px; height: 28px; }
/* Glyph inline with prompt text (the gear in "or via ⚙ → …"). */
.icon--inline { width: 1.05em; height: 1.05em; display: inline-block; vertical-align: -0.15em; }
.icon--sm { width: 24px; height: 24px; }
.icon--xl { width: 96px; height: 96px; }

/* ---------- weather layout (editorial redesign) ----------
   Placed after the .icon utilities on purpose: the glyphs render as
   `.icon .wx-now__icon` / `.icon .wx-day__ico`, so these same-specificity
   rules must come later in source to win the size over the base .icon. */
/* condition-driven accent (current conditions) */
.card--weather { --wx-accent: var(--accent); }
.card--weather[data-cond="clear"]    { --wx-accent: #e3b341; }
.card--weather[data-cond="partly"]   { --wx-accent: #9db2c9; }
.card--weather[data-cond="cloudy"],
.card--weather[data-cond="fog"]      { --wx-accent: #8a97ab; }
.card--weather[data-cond="rain"],
.card--weather[data-cond="drizzle"]  { --wx-accent: var(--accent); }
.card--weather[data-cond="snow"],
.card--weather[data-cond="sleet"]    { --wx-accent: #7fc8e6; }
.card--weather[data-cond="thunder"]  { --wx-accent: var(--warn); }

/* per-condition glyph tints (current glyph + each daily glyph) */
.wx-ico--clear    { color: #e3b341; }
.wx-ico--partly   { color: #9db2c9; }
.wx-ico--cloudy,
.wx-ico--fog      { color: #8a97ab; }
.wx-ico--rain,
.wx-ico--drizzle  { color: var(--accent); }
.wx-ico--snow,
.wx-ico--sleet    { color: #7fc8e6; }
.wx-ico--thunder  { color: var(--warn); }

/* the sections own their spacing; override the base body gap */
.card--weather .card__body { gap: 0; }

/* hero */
/* Slight right inset: the nowrap feels-like line otherwise rides the card's
   padding edge and reads cramped on-device. */
.wx-now { display: flex; align-items: center; gap: 16px; padding-right: 16px; }
.wx-now__icon { width: 50px; height: 50px; flex: none; }
.wx-now__temp { font-size: 72px; font-weight: 700; line-height: 0.9; letter-spacing: -0.03em; }
.wx-now__meta { display: flex; flex-direction: column; gap: 2px; margin-left: auto; text-align: right; min-width: 0; }
.wx-now__label { font-size: 22px; font-weight: 600; color: var(--ink-mid); white-space: nowrap; }
.wx-now__feels { font-size: 18px; color: var(--ink-faint); white-space: nowrap; }
/* Narrow cards (3-wide default): long conditions spilled past the card edge —
   nowrap text overflows the shrunk meta column (worse on-device, where the
   Cisco Medium face runs wider than desktop fonts). Wrap to balanced lines. */
.card--weather.t-narrow .wx-now__label { white-space: normal; text-wrap: balance; }

.wx-rule { height: 1px; background: rgba(255, 255, 255, 0.07); margin: 16px 0 10px; flex: none; }

/* hourly trend */
.wx-trend { display: flex; flex-direction: column; gap: 4px; }
.wx-trend__row { display: flex; }
.wx-trend__row > span { flex: 1; min-width: 0; text-align: center; font-size: 18px; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
.wx-trend__row--hours > span { font-size: 14px; font-weight: 400; color: var(--ink-dim); } /* AA: ink-faint fails below 18px */
/* The labels breathe away from the curve above (margin-top) and keep
   guaranteed air from the day chips below (.wx-days' margin-top:auto
   collapses to zero on a full card, landing the labels on the chips).
   Growing the block also drains the space-between slack that pooled
   between the hours and the chips. */
.wx-trend__row--hours { margin-top: 6px; margin-bottom: 8px; }
/* Hourly chance of precipitation, only under the temps on tall cards (the
   renderer builds the row at h >= 5, where the dead band is; no container
   queries on gen1 boards). Text only, quiet by default, and sky-blue (the
   snow/sleet accent) once a column is worth planning around. */
/* nowrap: the glyph + number must stay one line whatever the value, or the row
   grows and the measured height budget below goes with it. */
.wx-trend__row--precip > span { font-size: 15px; font-weight: 400; color: var(--ink-dim); white-space: nowrap; } /* AA: ink-faint fails below 18px */
.wx-trend__row--precip > span.wx-pp--wet { color: #7fc8e6; }
/* Droplet sits on the numerals' optical baseline. Alignment and gap only: the
   glyph is filled with currentColor, so it inherits whichever of the two
   colours above its cell already has. */
.wx-pp__drop { vertical-align: -1px; margin-right: 2px; }
.wx-trend__chart { width: 100%; height: 60px; display: block; }
.wx-trend__line { fill: none; stroke: var(--wx-accent); stroke-width: 3px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.wx-trend__g0 { stop-color: var(--wx-accent); stop-opacity: 0.30; }
.wx-trend__g1 { stop-color: var(--wx-accent); stop-opacity: 0; }

/* daily strip (tiles) */
/* Full width again (2026-08-01, later the same day). The strip runs flush to
   the body's bottom edge at every tier, so while weather carried a bare expand
   mark the last tile stood in that mark's corner and the row paid a right
   gutter to clear it. The bare mark is retired (weather hides nothing, so it
   draws no badge at all), and the reserve went with it: four or five flex:1
   tiles divide the whole width evenly again, which is the balance the gutter
   had cost. */
.wx-days { display: flex; gap: 8px; margin-top: auto; }
.wx-day { flex: 1; min-width: 0; background: var(--bg-card-2); border-radius: 12px; padding: 10px 4px; display: flex; flex-direction: column; align-items: center; gap: 5px; }
.wx-day__name { font-size: 16px; color: var(--ink-dim); }
.wx-day__ico { width: 26px; height: 26px; }
.wx-day__hi { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
.wx-day__lo { font-size: 15px; color: var(--ink-dim); font-variant-numeric: tabular-nums; } /* AA */

/* --- wider cards (w >= 5): larger hero + tiles, 8-hour trend, 5 tiles --- */
.card--weather:not(.t-narrow) .wx-now__temp { font-size: 92px; }
.card--weather:not(.t-narrow) .wx-now__icon { width: 62px; height: 62px; }
.card--weather:not(.t-narrow) .wx-now__label { font-size: 26px; }
.card--weather:not(.t-narrow) .wx-now__feels { font-size: 20px; }
.card--weather:not(.t-narrow) .wx-trend__chart { height: 44px; }
.card--weather:not(.t-narrow) .wx-day { padding: 9px 4px; }
.card--weather:not(.t-narrow) .wx-day__name { font-size: 17px; }
.card--weather:not(.t-narrow) .wx-day__ico { width: 30px; height: 30px; }
.card--weather:not(.t-narrow) .wx-day__hi { font-size: 24px; }
.card--weather:not(.t-narrow) .wx-day__lo { font-size: 16px; }

/* --- tall cards (h >= 5): scale hero + trend up, even vertical rhythm --- */
.card--weather.t-l .card__body { justify-content: space-between; }
.card--weather.t-l .wx-rule { display: none; }
.card--weather.t-l .wx-now__temp { font-size: 112px; }
.card--weather.t-l .wx-now__icon { width: 72px; height: 72px; }
.card--weather.t-l .wx-trend__chart { height: 116px; }
.card--weather.t-l .wx-days { margin-top: 0; }
.card--weather.t-l .wx-day { padding: 11px 4px; gap: 6px; }
.card--weather.t-l .wx-day__name { font-size: 18px; }
.card--weather.t-l .wx-day__ico { width: 34px; height: 34px; }
.card--weather.t-l .wx-day__hi { font-size: 27px; }
.card--weather.t-l .wx-day__lo { font-size: 17px; }

/* --- measured-fit tweaks (resize-fit audit) --- the NWS alert banner adds
   ~41px of height the base tiers don't budget for. At h==4 (t-m) trim the
   hairline margin + chart, and cap the wide-card hero so hero+8h-trend+strip
   still clear the body; at h==5 the fixed t-l chart must shrink to fit the
   shorter body. Verified zero-overflow at every w×h from 3×4→12×8, alert
   present or not. (Placed last so these win over the tier rules above.) */
/* Like t-l: slack spreads between the sections instead of pooling into the
   chips' auto margin (one dead band under the hours on alert-less cards).
   With the NWS alert present the content is exact-fit and this is a no-op. */
.card--weather.t-m .card__body { justify-content: space-between; }
.card--weather.t-m .wx-days { margin-top: 0; }
.card--weather.t-m .wx-rule { margin: 8px 0 6px; }
.card--weather.t-m .wx-trend__chart { height: 36px; } /* 40 -> 36 funds the hours/chips seam gap at the tight 3x4 */
/* (t-m hero cap removed: Momentum's 84px non-narrow hero out-specified it
   even before the fold — keeping it would shrink the hero post-fold.) */
.card--weather.t-m:not(.t-narrow) .wx-day { padding: 7px 4px; }
.card--weather.t-l .wx-trend__chart { height: 100px; }
/* Precip row + NWS banner at h == 5: measured, that is the ONLY tier where the
   extra row does not fit (the wide hero keeps a taller reserve, and h >= 6 has
   slack to spare), so there the row buys its 22px (18px + the trend gap) back
   from the chart. The renderer marks the banner case with .wx-trend--banner. */
.card--weather.t-narrow[data-h="5"] .wx-trend--banner .wx-trend__chart { height: 76px; }
/* Past h == 5 the chart grows with the card instead of leaving the slack to
   pool above the day chips (a 100px chart left ~200px of dead band at h == 8).
   One flat height per height tier, graded by measurement, because gen1 boards
   have no container queries: the card's own data-h is the only height signal
   CSS gets. Two ladders, because the NWS banner turns the body's two
   space-between gaps into three, so the same slack divides differently:
   .wx-trend--banner (renderer-set) is the banner case. Measured remaining band
   above the chips (headless 1920x1080, w 3..12, longest condition label, with
   and without precip data): 31-56px clean, 35-51px banner, zero overflow. */
.card--weather.t-l[data-h="6"] .wx-trend__chart { height: 192px; }
.card--weather.t-l[data-h="7"] .wx-trend__chart { height: 304px; }
.card--weather.t-l[data-h="8"] .wx-trend__chart { height: 420px; }
.card--weather.t-l[data-h="6"] .wx-trend--banner .wx-trend__chart { height: 112px; }
.card--weather.t-l[data-h="7"] .wx-trend--banner .wx-trend__chart { height: 224px; }
.card--weather.t-l[data-h="8"] .wx-trend--banner .wx-trend__chart { height: 336px; }

/* ---------- surf ----------
   Written in the weather card's idiom on purpose: the hero shares .wx-now's
   geometry, the chart reuses the .wx-trend gradient-area + non-scaling-stroke
   pattern, and no new token is introduced. Note that a 4-wide card is ALSO
   .t-narrow (main.js: rect.w <= 4), so the hero does not grow at 4 wide — the
   extra 155px buys content (the groundswell height, the provenance stamp),
   not size. */
.card--surf { --sf-accent: var(--accent); }
.card--surf .card__body { gap: 0; }

.sf-now { display: flex; align-items: center; gap: 14px; padding-right: 4px; }
.sf-now__val {
  font-size: 72px; font-weight: 700; line-height: 0.9;
  letter-spacing: -0.03em; font-variant-numeric: tabular-nums;
}
.sf-now__unit { font-size: 26px; font-weight: 600; color: var(--ink-dim); align-self: flex-end; margin-bottom: 6px; margin-left: -4px; }
.sf-now__meta { display: flex; flex-direction: column; gap: 3px; margin-left: auto; text-align: right; min-width: 0; }
.sf-now__label { font-size: 22px; font-weight: 600; color: var(--ink-mid); white-space: nowrap; }
/* AA: 18px is the --ink-faint floor (see the news typography note). */
.sf-now__sub { font-size: 18px; color: var(--ink-faint); white-space: nowrap; display: flex; align-items: center; justify-content: flex-end; gap: 6px; }

/* Direction glyph. The base path points NORTH; the renderer rotates it to the
   direction the water TRAVELS (bearing + 180) while the letters beside it keep
   the conventional FROM reading. Stroke-based to match icons.js. */
.sf-dir { width: 22px; height: 22px; flex: none; color: var(--ink-dim); }
.sf-dir path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

/* Hourly wave-height chart (the .wx-trend idiom, made elastic).
   The weather card spends its spare height on a row of day chips; surf has no
   third block, so the chart itself takes the slack. Elastic rather than a
   per-height ladder: the two label rows are fixed, the hero and the foot are
   fixed, and what is left is by definition the chart — which is the part of
   this card that actually rewards the space. */
.sf-trend { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; flex: 1 1 auto; min-height: 0; }
.sf-trend__row { display: flex; }
.sf-trend__row > span {
  flex: 1; min-width: 0; text-align: center; font-size: 18px; font-weight: 600;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
.sf-trend__row--hours { margin-top: 6px; }
.sf-trend__row--hours > span { font-size: 14px; font-weight: 400; color: var(--ink-dim); } /* AA: ink-faint fails below 18px */
/* 42px is the measured legibility floor for a 6-column build; below that the
   curve stops reading as a shape and becomes a smudge. */
.sf-trend__chart { width: 100%; display: block; flex: 1 1 auto; min-height: 42px; }
.sf-trend__line { fill: none; stroke: var(--sf-accent); stroke-width: 3px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.sf-trend__g0 { stop-color: var(--sf-accent); stop-opacity: 0.30; }
.sf-trend__g1 { stop-color: var(--sf-accent); stop-opacity: 0; }

/* Footer meta strip: the two supporting readings, then the honesty stamp.
   Runs to the card's right edge again (2026-08-01, later the same day). Like
   weather's day strip, this row lands at the body's floor (margin-top:auto,
   with .sf-foot__stamp pushed right by its own auto margin), so it used to
   reserve a gutter for surf's bare expand mark. Surf hides nothing and so
   draws no badge now, and the stamp goes back to sitting on the card's own
   right edge with the readings above it. */
.sf-foot { display: flex; align-items: baseline; gap: 18px; margin-top: auto; padding-top: 12px; }
.sf-foot__item { display: flex; align-items: baseline; gap: 7px; white-space: nowrap; }
.sf-foot__k { font-size: 17px; color: var(--ink-dim); }
.sf-foot__v { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sf-foot__q { font-size: 17px; color: var(--ink-dim); }
.sf-foot__stamp { margin-left: auto; font-size: 17px; color: var(--ink-dim); white-space: nowrap; }

/* Size variants. h=3 is the floor: hero 65 + two label rows + foot 51 leaves
   the chart its 42px minimum in the 237px body, and every taller card simply
   hands the extra rows to the chart. */
.card--surf.t-l .sf-now__val { font-size: 92px; }
/* Wide cards (w >= 5) are not .t-narrow, so the hero can grow like weather's.
   The 20px it costs comes out of the chart at h=3, exactly as the weather
   card's does when it widens (measured: without the elastic chart absorbing
   it, the body overflowed by 11px at every width from 5 to 12). */
.card--surf:not(.t-narrow) .sf-now__val { font-size: 92px; }
.card--surf:not(.t-narrow) .sf-now__label { font-size: 26px; }
.card--surf:not(.t-narrow) .sf-now__sub { font-size: 20px; }

/* ---------- transit ---------- */
.stop-group { display: flex; flex-direction: column; gap: 8px; }
.stop-group__head { display: flex; justify-content: space-between; align-items: baseline; }
.stop-group__name { font-size: 22px; font-weight: 600; }
.stop-group__dir { font-size: 20px; color: var(--ink-faint); }
.stop-group__arrivals { display: flex; flex-direction: column; gap: 6px; }
.arrival { display: flex; align-items: center; gap: 12px; font-size: 24px; }
.arrival__min { font-weight: 700; font-variant-numeric: tabular-nums; }
.arrival__unit { color: var(--ink-faint); font-size: 21px; }

/* The full-screen Express Bus board (Sean's pick): the rail overlay's grammar
   grouped by stop — a quiet uppercase stop name over big tabular countdowns,
   route then destination. Bespoke rather than .trains--board because that board
   is ONE flat centered list on a single rhythm and has nowhere to put a header:
   dropped in as another flex child, a stop name would sit exactly as far from
   its own arrivals as from the next stop's. Here the gap INSIDE a group is
   small and the gap BETWEEN groups is large, which is the whole point of the
   view. The rows themselves are the shared .train markup at the split board's
   type scale. Worst case inside the 814px body, on PINNED line-heights (the
   .expand__head idiom, so the fit is arithmetic rather than a measurement in
   whatever face this was modelled in): two stops x (a 27.5px header, three 60px
   rows, 18 + 40px of gaps) plus the 56px between them = 587px, and /bus/stops
   serves at most two legs of at most three arrivals each. */
.expand__body .bus-board {
  height: 100%; display: flex; flex-direction: column;
  justify-content: center; align-items: center; gap: 56px; overflow: hidden;
  line-height: 1.25;
}
.bus-board__group { width: 820px; display: flex; flex-direction: column; gap: 18px; }
.bus-board__stop {
  font-size: 22px; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.08em; color: var(--ink-dim);
}
.bus-board__rows { display: flex; flex-direction: column; gap: 20px; }
.bus-board__none { font-size: 24px; color: var(--ink-dim); }
.bus-board .train { gap: 24px; }
.bus-board .train__min { min-width: 110px; }
.bus-board .train__min span { font-size: 40px; }
.bus-board .train__min small { font-size: 20px; }
/* The route takes the prominent slot the rail rows give a destination, and the
   destination takes the quiet one: on a bus the route number IS the identity,
   and "to Wall St" is the qualifier. */
.bus-board .train__dest { font-size: 26px; }
.bus-board .train__line { font-size: 22px; }

/* The line badge: a fixed circle with one glyph centred in it, so it wants the
   metric-pinned face — the box cannot change size, only the glyph inside it can
   sit off-centre (which it does on the board's own font). */
.bullet {
  width: 36px; height: 36px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-centred);
  font-weight: 700; font-size: 20px; color: #fff; background: #808183;
  flex: none;
}
/* MTA line colors */
.bullet--1, .bullet--2, .bullet--3 { background: #ee352e; }
.bullet--4, .bullet--5, .bullet--6 { background: #00933c; }
.bullet--7 { background: #b933ad; }
.bullet--A, .bullet--C, .bullet--E { background: #0039a6; }
.bullet--B, .bullet--D, .bullet--F, .bullet--M { background: #ff6319; }
.bullet--G { background: #6cbe45; }
.bullet--J, .bullet--Z { background: #996633; }
.bullet--L { background: #a7a9ac; }
.bullet--N, .bullet--Q, .bullet--R, .bullet--W { background: #fccc0a; color: #111; }
.bullet--S, .bullet--GS { background: #808183; }
.bullet--SI { background: #0f3d92; }
/* A bullet set INSIDE a line of prose (transit-alerts.js routeBullets: "no [1]
   between 14 St and South Ferry"). Everything is em-relative to the text it
   lands in, so one class serves the 20px card row, the expanded wall's larger
   copy and the 42px text reader; the box stays inside the line's own ascent, so
   a substituted token costs the row no height.
   Mind the unit: `em` in here is the bullet's OWN 0.7em face, so the 1.5em
   circle is 1.05em of the surrounding text and any offset written here lands at
   0.7x what it reads as. That is how the shipped `vertical-align: -0.3em` put
   the circle most of a line below the words: 0.21em of push under a baseline the
   inline-flex box was already hanging below.
   `middle` is the layout anchor: the box's own midpoint set on the parent's
   baseline plus half the parent's x-height. That is the centre of the x-BAND,
   and a circle taller than the caps is read against the CAP band, whose centre
   sits (cap - x)/2 higher: in CiscoSansTT (cap 0.714em, x 0.517em) that is
   ~0.099em of the parent, so the middle-aligned circle sat flush with the cap
   tops while hanging visibly below the baseline (Sean, on a live board, twice).
   The `top` lift is that same metric expressed in the bullet's OWN 0.7em face
   (0.0985 / 0.7 = 0.14em), not a tuned number. It rides on position: relative,
   which moves the box AFTER layout, so the line still costs no height — the
   -0.3em vertical-align this rule once shipped both hung the circle low and
   grew every alert line it landed on; a raising vertical-align would push the
   line box open the same way from the other side. */
.bullet--inline {
  font-size: 0.7em; width: 1.5em; height: 1.5em;
  vertical-align: middle; position: relative; top: -0.14em; margin: 0 0.1em;
}

.train { display: flex; align-items: center; gap: 16px; font-size: 22px; }
.train__min { display: flex; align-items: baseline; gap: 4px; min-width: 76px; }
/* A board holding a 3-digit countdown: 76px is an exact "99 min" fit, and a
   third tabular digit runs the cell to ~95px. fitTrainRows toggles this per
   card; the expand overlay reserves its own wider columns always. */
.trains--widemin .train__min, .trains--board .train__min { min-width: 97px; }
.train__min span { font-size: 34px; font-weight: 700; font-variant-numeric: tabular-nums; }
.train__min small { color: var(--ink-faint); font-size: 20px; }
.train__info { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.train__dest { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.train__line { font-size: 20px; color: var(--ink-dim); }
/* LIRR / Metro-North line name as a filled signage chip in the MTA's official
   line color (background and ink both come from lines.js, per line). 17px text
   on an 18px line box + 2px padding = a 22px chip, which fits inside the meta
   line's natural 24px box: audited rows measure 51px with and without the chip
   at every supported size, so no card loses a row to it. vertical-align stays
   at baseline on purpose — that lands the chip's own text baseline on the
   "· 8:46 PM" baseline exactly (measured 0.00px off) and keeps doing so on the
   board's taller CiscoSansTT metrics, which a px nudge would not. Deliberately
   no height pin on .train__line: at 3 columns the meta line wraps, and pinning
   the box there clips the departure time. */
.train__linechip {
  display: inline-block; padding: 2px 9px; border-radius: 6px;
  font-size: 17px; font-weight: 600; line-height: 18px;
  vertical-align: baseline; margin-right: 3px;
}
.train__track { background: var(--bg-card-2); border-radius: 8px; padding: 6px 10px; font-size: 20px; color: var(--accent); white-space: nowrap; }
/* Amtrak status pill (right column): on-time neutral, delays warn, cancelled bad. */
.train__status { align-self: center; font-size: 18px; color: var(--ink-dim); white-space: nowrap; }
.train__status.is-warn { color: var(--warn); }
.train__status.is-bad { color: var(--bad); }

/* ---------- art card ---------- */
.artwork { display: flex; flex-direction: column; height: 100%; gap: 10px; }
/* The well matches the card surface so contain-fit letterboxing (chart/apod
   aspect mismatches) doesn't read as a highlighted box around the image. */
.artwork__img { flex: 1; min-height: 0; width: 100%; object-fit: cover; border-radius: 10px; background: var(--bg-card); }
/* Data images (Chart of the Day): never crop — letterbox on the card tone.
   Art/photos keep the aesthetic cover crop; the viewer always shows full. */
.artwork--contain .artwork__img { object-fit: contain; }
/* Tone-map the white Statista chart: luminance rank should follow information
   rank — untreated it's the brightest object on the whole board. 0.72 turns
   the white ground soft grey (0.88 still glared on the glass, per Sean). */
.card--chart .artwork__img { filter: brightness(0.72); }
.artwork__caption { display: flex; flex-direction: column; gap: 2px; }
.artwork__title { font-size: 22px; font-weight: 600; }
.artwork__artist { font-size: 20px; color: var(--ink-dim); }

/* ---------- photos settings thumbnail ---------- */
.photo-preview__img { max-width: 240px; border-radius: 10px; margin-top: 12px; }
/* Revealed photo-code keypad (Settings → Photos → "Enter code"). */
.photo-code { margin-top: 14px; }
.photo-code .osk__display { margin-bottom: 10px; }
.qr--photos { margin-bottom: 20px; } /* air between the QR tile and Enter code */
/* ---------- history / quote / sky / markets ---------- */
.history { display: flex; flex-direction: column; gap: 10px; }
/* Year column hugs four tabular digits; right-aligned so short years ("324")
   end where long ones do and the text column starts sooner. */
.history__item { display: grid; grid-template-columns: 54px 1fr; gap: 10px; font-size: 22px; align-items: baseline; }
/* Weight comes from the Momentum light-numeral block below (Sean, 2026-07-31:
   the bold years were the last numerals on the board not thinned to 300). */
.history__year { color: var(--accent); font-variant-numeric: tabular-nums; text-align: right; }
.history__text { color: var(--ink-dim); }

.quote { display: flex; flex-direction: column; justify-content: center; height: 100%; gap: 12px; }
.quote__text { font-size: 24px; line-height: 1.4; font-style: italic; }
.quote__author { font-size: 21px; color: var(--ink-dim); text-align: right; }

.sky { display: flex; flex-direction: column; gap: 12px; }
/* AQI + UV as equal labeled stat blocks; single column when UV is absent */
.sky__stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sky__stats--single { grid-template-columns: 1fr; }
.sky__aqi { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sky__aqi-head { font-size: 17px; font-weight: 700; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* AA */
/* Category sits under the number so both get the block's full width */
.sky__aqi-reading { display: flex; flex-direction: column; min-width: 0; }
/* The category never wraps under the number — it shrinks then ellipsizes */
.sky__aqi-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.sky__aqi-value { font-size: 54px; font-weight: 700; line-height: 1.05; }
.sky__aqi--good .sky__aqi-value { color: var(--good); }
.sky__aqi--moderate .sky__aqi-value { color: var(--warn); }
.sky__aqi--bad .sky__aqi-value { color: var(--bad); }
.sky__aqi-label { font-size: 22px; color: var(--ink-dim); }
.sky__row { display: flex; align-items: center; gap: 10px; font-size: 20px; color: var(--ink-dim); }

/* One shared grid for all rows (rows are display:contents) so the spark and
   delta columns align down the card; the auto track fits the widest delta. */
/* 1fr | auto | auto | auto = info · sparkline · change · pct. Info flexes (its
   space-between at 2-tall pushes the price to a shared right edge so decimals
   line up); the spark is a fixed readable width; the CHANGE number right-aligns
   in its own shared track so its decimals align across rows too (tabular-nums),
   with the pct left-aligned beside it. */
.indexes { display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; column-gap: 16px; row-gap: 10px; }
.delta__chg { justify-self: end; }
/* pct hugs its change number: pull back most of the uniform column gap. */
.delta__pct { justify-self: start; margin-left: -8px; }
.index { display: contents; }
.index__info { display: flex; flex-direction: column; min-width: 0; }
/* One line, ellipsized: long instrument names ("Close Brothers Group plc")
   were wrapping to four lines and breaking the row rhythm. */
.index__name { font-size: 21px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.index__price { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
.spark { width: 160px; height: 28px; }
/* Sparkline colours. Today is coloured against the prior close (green above /
   red below the baseline, so a mid-session cross shows both); the prior session
   in the 2-day view reads WHITE, and the day boundary is a bright dashed rule.
   The rule extends past the chart box (overflow:visible + y -5..33) so a big
   swing at the boundary can't swallow it, and it reads at board distance. */
.spark { overflow: visible; }
.spark path { stroke-linecap: round; stroke-linejoin: round; }
.spark__up { stroke: var(--good); }
.spark__down { stroke: var(--bad); }
.spark__prev { stroke: var(--ink); }
.spark__div { stroke: var(--ink); opacity: 0.85; stroke-width: 1.75; stroke-dasharray: 3 3; }
.delta { font-size: 24px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.delta--up { color: var(--good); }
.delta--down { color: var(--bad); }

/* ---------- ambient mode ---------- */
/* FIXED TO THE VIEWPORT, not to the page — the one context in the app that can
   safely be, and the reason ship 2 exists. The screensaver is a single image
   plus an info band: it has no capacity math, no row tables and no per-size
   audit, so nothing about it needs the fixed 1080 canvas the dashboard depends
   on. As a child of #app it was 1080 tall on every device, which on a 1200
   Room Navigator left a 120px black band under the picture and on a 1040 board
   pushed the bottom 40px of the artwork (and 28px of the caption) off the
   glass. `inset: 0` on a position:fixed box is the whole fix, expressed in
   viewport terms and therefore self-maintaining: it is exactly what the
   tap-opened image viewer (.art-viewer) and the settings preview (.ss-preview)
   have always done, which is why THEY filled the screen correctly and this did
   not. Everything inside now measures from the real bottom edge, so the offsets
   below lose the --roomos-bar lift that only ever existed to undo the 1080/1040
   mismatch (bottom:40 off a 1080 page == bottom:0 off a 1040 glass — the strip
   and the gear do not move on a board). */
.ambient { position: fixed; inset: 0; }
.slideshow { position: absolute; inset: 0; background: #000; touch-action: none; /* swipes must reach the pointer handlers, not the browser's pan gesture */ }
.slide {
  position: absolute; inset: 0;
  /* Contain + centered + no-repeat: letterbox every work on the black bg,
     matching the full-screen viewer (show() also sets contain inline). */
  background-size: contain; background-position: center; background-repeat: no-repeat;
  opacity: 0;
  transition: opacity 2.5s ease-in-out;
}
.slide[data-active] { opacity: 1; }
/* A swiped slide flips with NO transition: the flip happens in the dark, mid
   swipeFadeThrough (the stage is faded out), where a dissolve would only
   bleed the old photo into the rise. The ambient advance keeps the slow
   dissolve above. */
.slide.slide--cut { transition: none; }
/* This Day in History, full screen (Sean's pick, mockup A): the grand
   centered reading list — every event of the day, year leading each line in
   the light numeral style, ~70ch measure. Ten events at these sizes use
   roughly 640px of the 814px body; overflow hidden is the backstop for a
   feed that some day returns more than the mapper's nine. */
.expand__body .history-board {
  height: 100%; display: flex; flex-direction: column; justify-content: center;
  gap: 24px; max-width: 1280px; margin: 0 auto; overflow: hidden;
}
.history-board .history__item { grid-template-columns: 84px 1fr; gap: 20px; font-size: 26px; line-height: 1.4; }
/* The year keeps the CARD's accent (Sean, 2026-08-01): it is the same column of
   numerals the card leads its rows with, and the tap that opened this view was
   a tap on those. Only the size grows; weight rides the light-numeral block. */
.history-board .history__year { font-size: 30px; color: var(--accent); }
.history-board .history__text { color: var(--ink-mid); }

/* (Swiped photo surfaces need no further CSS: swipeFadeThrough in
   imageshow.js fades the existing layer itself with inline transitions —
   the initial-load grammar, one layer, no ghost, every device.) */
@media (prefers-reduced-motion: reduce) {
  .slide { transition: none; }
}
.slide-caption {
  /* Sit just above the info band (which sits at the very bottom). Both are
     measured off the same viewport bottom now: 82px clears the band's 70px by
     12px. It was tuned in the .art-viewer, which has always been viewport-fixed
     — in the ambient slideshow, which was not, the band ate the bottom 28px of
     this box on every board until the .ambient fix above. */
  position: absolute; left: 48px; bottom: 82px;
  display: flex; flex-direction: column; gap: 4px;
  background: rgba(0, 0, 0, 0.55);
  padding: 14px 22px; border-radius: 12px;
}
/* Backstop only. `:empty` matches an element with NO child nodes, so it cannot
   hide a container holding blank <span>s — imageshow.js is therefore the real
   guard: it emits no empty spans, and the full-screen viewer removes the box
   outright when an image has no caption text. This still earns its keep for the
   ambient slideshow's placeholder caption, which is genuinely childless until
   the first show(). Kept as `:empty` rather than `:not(:has(*))` because an
   engine without `:has()` would drop the whole rule and un-hide that
   placeholder — the wrong way to fail on a board we can't upgrade. */
.slide-caption:empty { display: none; }
.slide-caption__title { font-size: 24px; font-weight: 600; }
.slide-caption__meta { font-size: 21px; color: var(--ink-dim); }
.slide-caption__desc { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin-top: 8px; font-size: 22px; line-height: 1.35; color: var(--ink-dim); max-width: 70ch; }
.strip {
  /* Translucent info band, flush with the last visible pixel — one rule for all
     three hosts (the ambient screensaver, the tap-opened art viewer, the
     settings preview), because all three are now position:fixed to the viewport
     and share one bottom edge. It used to carry a --roomos-bar lift in ambient
     and drop to 0 in the other two; that split existed only because ambient was
     the one host laid out against the 1080 PAGE, where 40px up from the page
     bottom IS 0px up from a 1040 board's glass. Same pixels on a board, and now
     also the right pixels on a Navigator. */
  position: absolute; left: 0; right: 0; bottom: 0;
  display: flex; align-items: center; justify-content: center; gap: 36px;
  padding: 18px 48px;
  background: rgba(0, 0, 0, 0.65);
  /* Same thin Cisco Light face as the clock faces; hierarchy is carried by
     colour (--ink vs --ink-dim), not weight, so it stays light like the dials. */
  font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', sans-serif;
  font-weight: 300; color: var(--ink-dim); font-size: 24px;
}
.strip__time { font-size: 28px; color: var(--ink); font-variant-numeric: tabular-nums; }
.strip__wx { color: var(--ink-mid); font-variant-numeric: tabular-nums; }
.strip__wx b { color: var(--ink); font-weight: 300; }
.strip__transit { color: var(--ink-dim); }
.strip__transit b { color: var(--ink); font-weight: 300; font-variant-numeric: tabular-nums; }
body.mode-ambient .topbar, body.mode-ambient #grid { visibility: hidden; }

/* ---------- welcome ---------- */
.welcome { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: var(--bg); }
.welcome__inner {
  max-width: 760px; text-align: center; display: flex; flex-direction: column; gap: 22px;
  background: var(--bg-card); border-radius: var(--radius); padding: 48px 56px;
}
/* Onboarding brand lockup. Pivot the composition on the wordmark's "&" (~12.5%
   right of the lockup's own centre) so the wordmark reads centred under the
   heading while the board glyph anchors to the left; %-based so it's
   scale-independent. */
.welcome__logo { align-self: center; width: 340px; max-width: 70%; height: auto; transform: translateX(-12.5%); }
/* 40px keeps the heading on one line in the 760px card, with margin to spare for
   the board's system-font metrics. */
.welcome__inner h1 { font-size: 40px; }
.welcome__inner p { font-size: 24px; color: var(--ink-dim); }
/* Buttons are the fallback under the recommended QR path — a little extra
   space above sets them apart as the "or, alternatively" group. */
.welcome__actions { display: flex; gap: 18px; justify-content: center; margin-top: 12px; }
.welcome__hint { font-size: 21px !important; color: var(--ink-faint) !important; }
.welcome__qr { display: flex; justify-content: center; margin-top: 10px; }

/* One button standard (settings-UX unification): every .btn shares the same
   visual mass — 64px control height, 160px floor so short labels ("Reset",
   "Change") stop rendering as mismatched small buttons. Three tiers:
   primary (accent, max one per pane) / secondary (default) / ghost. */
.btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 64px; min-width: 160px;
  font-size: 23px; padding: 0 30px; border-radius: 12px;
  border: 1px solid rgba(147, 160, 180, 0.16); background: var(--bg-card);
  color: var(--ink); cursor: pointer;
}
.btn--primary { background: var(--accent); border-color: var(--accent); color: #06131f; font-weight: 700; }
.btn--ghost { background: transparent; border-color: transparent; color: var(--ink-dim); }

/* ---------- gear ---------- */
.gear {
  /* Edit + settings FABs. BOARD-VERIFIED: on the dashboard the RoomOS
     "Tap to start" bar OVERLAYS the bottom ~40 logical px of the viewport
     (a FAB at bottom:14px was ~45% covered — 26 of 56px = the 40px bar).
     The free strip between bar top (40px) and grid edge (84px) is only 44px,
     so a 56px FAB can't fully fit: bottom:44px clears the bar with 4px to
     spare and intrudes just ~16px into the lower-right widget (was 63px at
     the old calc(--safe-bottom + 7px) spot). In ambient mode they drop onto
     the info strip. */
  position: absolute; right: 28px; bottom: 44px;
  width: 56px; height: 56px; border-radius: 50%;
  font-size: 26px; border: none; cursor: pointer;
  background: rgba(255, 255, 255, 0.08); color: var(--ink-dim);
}
/* In ambient the gear belongs to the screensaver, not to the page: it rides the
   info band, so it is fixed to the same viewport the band is. bottom:4px is the
   board's existing position restated honestly — absolute bottom:44px off a
   1080px page is 4px off a 1040px glass, so a board sees the identical FAB in
   the identical place, while a Navigator stops stranding it 160px above the
   band it is supposed to sit on. The Edit-layout pencil hides: there's no
   dashboard to arrange, so only the settings gear shows. */
body.mode-ambient .gear { position: fixed; bottom: 4px; }
body.mode-ambient .gear--edit { display: none; }

/* ---------- settings overlay ---------- */
.settings {
  position: absolute; inset: 0; z-index: 40;
  display: grid; grid-template-columns: 270px 1fr; /* rail hugs its longest label; panes get the width */
  background: var(--bg);
}
.settings__rail {
  /* 52px = the 40px of PAGE a Board Pro's glass never shows (1080 page − 1040
     viewport, measured on-device 2026-07-28) + 12px breathing room. The old
     comment here credited the number to Cisco's bar "overlaying" the bottom
     ~40px; that model was wrong (the bar sits BELOW the viewport — see
     DESIGN.md and test/viewport.test.js) but the number survives it unchanged,
     because the page/glass mismatch it now stands for is the same 40px. So this
     is NOT spare room: trim it and the Save/Cancel/wordmark footer slides off
     the bottom of a real board. test/viewport.test.js pins the floor. */
  background: var(--bg-card); padding: 20px 24px 52px;
  display: flex; flex-direction: column; gap: 12px;
  min-height: 0; /* children may scroll; the footer must never leave view */
  position: relative; /* hosts the nav's .scrollind overlay */
}
.settings__brand { font-size: 28px; font-weight: 700; flex: none; margin: 0; }
.settings__nav {
  display: flex; flex-direction: column; gap: 4px;
  flex: 1; min-height: 0; overflow-y: auto; /* nav scrolls, never the footer */
}
.settings__navitem {
  text-align: left; font-size: 22px; padding: 9px 16px; min-height: 44px;
  border: none; border-radius: 10px; background: none; color: var(--ink-dim); cursor: pointer;
  flex: none;
}
.settings__navitem.is-active { background: var(--bg-card-2); color: var(--ink); font-weight: 600; }
.settings__navgroup {
  display: flex; align-items: center; gap: 10px;
  text-align: left; font-size: 22px; padding: 9px 16px; min-height: 44px;
  border: none; border-radius: 10px; background: none; color: var(--ink-dim); cursor: pointer; flex: none;
}
.settings__chev {
  width: 0; height: 0; flex: none;
  border-left: 7px solid currentColor; border-top: 5px solid transparent; border-bottom: 5px solid transparent;
  transition: transform 0.15s ease;
}
.settings__navgroup.is-open .settings__chev { transform: rotate(90deg); }
.settings__navchild { padding-left: 34px; font-size: 21px; }
/* Accordion expand/collapse: grid-template-rows 0fr↔1fr animates to exact height
   (degrades to an instant snap on older engines). The inner clips during collapse. */
.settings__navkids {
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows 0.2s cubic-bezier(0.22, 1, 0.36, 1), margin 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings__navkids.is-open { grid-template-rows: 1fr; }
/* A CLOSED group's wrapper is zero-height but still takes the nav's 4px flex
   gap on both sides, so every collapsed category sat 8px from its neighbour
   while every other pair of rows sat 4px — the rail's spacing was uneven and
   nobody had noticed. Pulling it back evens the rail and returns 16px (4
   groups x 4px), which is the difference between the footer's What's new entry
   costing the nav its last pixel and it costing the nav nothing that shows.
   Physical margins on purpose: margin-block is a Chrome 87 feature and this
   ships to a gen1 Qt WebEngine. */
.settings__navkids:not(.is-open) { margin-top: -2px; margin-bottom: -2px; }
.settings__navkids__inner { min-height: 0; overflow: hidden; display: flex; flex-direction: column; gap: 4px; }
.settings__navkids.is-open .settings__navkids__inner { padding-top: 4px; }
@media (prefers-reduced-motion: reduce) {
  .settings__chev, .settings__navkids { transition: none; }
}
.settings__railfoot { display: flex; flex-direction: column; gap: 8px; flex: none; }
/* Brand wordmark beneath the footer buttons, set apart by space alone
   (calm-utility — no divider needed). It used to be purely decorative; it is
   now the face of the What's new control below, which is what lets that
   control cost the rail ~24px instead of a whole row. */
.settings__lockup { align-self: center; width: 180px; max-width: 100%; height: auto; }
/* The rail footer's What's new entry: the wordmark, and one line that opens the
   release notes. It lives in the FOOTER, which is `flex: none` and never
   scrolls, rather than in the nav, which is the list that grows with every
   widget that ships and the only part of the rail that can scroll away from you.
   Calm at rest — no fill, no ring, quiet ink — and it ignites only on press,
   per the calm-affordance rule; the caret is the resting signifier that says
   this is a control at all. ~72px tall, comfortably over the 44px touch floor.
   The line carried the short build id until it read as noise nobody had asked
   for; the version now lives only in the pane's colophon. */
.settings__whatsnew {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  width: 100%; min-height: 44px; padding: 0; margin: 0;
  font: inherit; background: none; border: 0; color: var(--ink-dim); cursor: pointer;
}
.settings__whatsnew:active { color: var(--ink); }
.settings__whatsnew.is-active { color: var(--ink); }
/* One line, always: the rail's content box is 222px and a wrap here costs the
   nav another 24px it does not have. nowrap makes a regression visible (the
   line overhangs) instead of silently eating a nav row. Label plus caret
   measures 119px of that 222 (it was 199 with the build id on it), so the
   guard is cheap insurance now rather than a binding constraint. */
.settings__wnline { display: flex; align-items: baseline; gap: 8px; font-size: 20px; white-space: nowrap; }
.settings__wnline::after {
  content: ""; align-self: center; width: 0.38em; height: 0.38em;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(-45deg); margin-left: -2px;
}
.settings__pane { padding: 44px 56px calc(44px + var(--safe-bottom)); overflow-y: auto; }
/* RoomOS-style scroll indicator. Nav + pane use a custom overlay thumb
   (settings.js attachScrollIndicator) — full control of offset, shortened
   length, and the rubber-band squish at the ends; visible whenever the
   surface is scrollable. Native bars are hidden there. Drill lists keep a
   styled native bar, also persistent while their content overflows. */
.settings__nav::-webkit-scrollbar,
.settings__pane::-webkit-scrollbar { display: none; }
.scrollind {
  position: absolute; width: 6px; border-radius: 999px; background: #fff;
  opacity: 0; pointer-events: none; z-index: 5;
  transition: opacity 0.25s ease-out, height 0.14s ease-out, top 0.14s ease-out;
}
.scrollind.is-on { opacity: 1; }
.drill__list::-webkit-scrollbar { width: 6px; }
.drill__list::-webkit-scrollbar-track { background: transparent; margin: 10px 0; }
.drill__list::-webkit-scrollbar-thumb { background: #fff; border-radius: 999px; }
.pane__title { font-size: 34px; font-weight: 700; margin-bottom: 10px; }
.pane__hint { font-size: 21px; color: var(--ink-dim); margin: 14px 0; }
/* Sub-section label inside a pane (e.g. Display's Mode / Greeting name):
   bigger and brighter than a hint so the groups read as separate settings. */
.pane__label { font-size: 25px; font-weight: 600; color: var(--ink); margin: 26px 0 12px; }
/* The photo panes' phone-setup heading is the pane's primary section, not a
   field label; a step up from .pane__label without competing with the title. */
.pane__label--head { font-size: 29px; }
.pane__empty { font-size: 21px; color: var(--ink-faint); }
.pane__rule { border: none; border-top: 1px solid var(--bg-card-2); margin: 28px 0; }

.rows { display: flex; flex-direction: column; gap: 8px; max-width: 860px; }
.row {
  display: flex; align-items: center; gap: 18px;
  min-height: 64px; padding: 8px 18px;
  background: var(--bg-card); border-radius: 12px;
  max-width: 860px; /* standalone rows sit on the same spine as .rows */
}
.row__label { font-size: 24px; flex: 1; }
.row__actions { display: flex; gap: 10px; }
.row--tap { border: none; color: var(--ink); font-size: 23px; text-align: left; cursor: pointer; }
.row--tap.is-selected { outline: 2px solid var(--accent); }
/* Dense multi-column variant for long toggle lists (e.g. the 19 Chart topics)
   so they fit the pane without scrolling; flows into as many columns as fit. */
.rows--grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 8px 12px; max-width: 1120px; }
/* Pill pickers (PATH station, Ferry landing) size to their longest label:
   a max-content grid with equal 1fr columns resolves every pill to the widest
   text in the pane, so short names stop stretching across half-empty pills. */
.rows--grid.rows--pill { grid-template-columns: repeat(3, 1fr); width: max-content; max-width: 100%; }
/* A distinct control group separated from what precedes it by a divider + space. */
.pane__section { max-width: 860px; margin-top: 28px; padding-top: 24px; border-top: 1px solid var(--bg-card-2); }
/* Master control (e.g. Select all) above a grid — bare, not carded, so it reads
   as a control over the options rather than one of them. */
.row--control { background: transparent; padding-left: 0; margin-bottom: 2px; }

/* Settings → Widgets is a signpost, not a second editor (see renderWidgets in
   settings.js): one quiet centred column ending in the only action it has. The
   glyph is the pencil FAB's own mark at .icon--xl, so the button the copy sends
   you to is the shape you just looked at. --ink-faint is a tertiary tint, legal
   at large sizes only — 96px is the largest thing in the pane, and the copy
   below it carries the meaning in --ink/--ink-dim. */
.wredirect { max-width: 720px; margin: 140px auto 0; text-align: center; }
.wredirect__glyph { display: block; color: var(--ink-faint); margin-bottom: 28px; }
.wredirect__head { font-size: 29px; font-weight: 600; color: var(--ink); margin: 0 0 14px; }
.wredirect__body { font-size: 23px; line-height: 1.45; color: var(--ink-dim); margin: 0 0 36px; text-wrap: pretty; }

/* 56px tall: the board's touch floor (was 76x44 — under the floor). */
.toggle {
  width: 72px; height: 40px; border-radius: 20px; border: none; cursor: pointer;
  background: rgba(255, 255, 255, 0.3); position: relative; flex: none;
  transition: background 0.2s;
}
.toggle__knob {
  position: absolute; top: 4px; left: 4px; width: 32px; height: 32px;
  border-radius: 50%; background: #fff;
  transition: transform 0.2s ease-out, background 0.2s;
}
.toggle.is-on { background: var(--accent); }
.toggle.is-on .toggle__knob { transform: translateX(32px); background: rgba(0, 0, 0, 0.9); }
/* A toggle the pane has blocked (its precondition is unmet, e.g. Only my teams
   with no teams picked) reads as dead rather than merely unresponsive — the
   chips' treatment, so one disabled language runs across the settings. */
.toggle:disabled { opacity: 0.45; cursor: default; }
/* RoomOS toggle: grey off-track with a white knob, solid-blue on-track with
   a dark knob — the knob's ink flips to black on the bright fill, same as
   the pill buttons. (Base theme keeps its blue-on-translucent-blue style.) */
/* Pill tap-rows (PATH stations): the button-group pill language instead of
   the accent-outline tile selection — transparent + white-30% ring, selected
   = white fill / black text (like Save/Cancel). Momentum-scoped. */
.rows--pill .row--tap {
  background: none; border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 999px;
}
.rows--pill .row--tap.is-selected {
  outline: none; background: rgba(255, 255, 255, 0.95);
  border-color: rgba(255, 255, 255, 0.3); color: rgba(0, 0, 0, 0.95);
}

/* Screensaver options: a selectable pill + a Preview button OUTSIDE the pill,
   so the button sits on the dark page and never clashes with the pill's
   white selected fill. */
.ss-opt { display: flex; align-items: center; gap: 12px; }
.ss-opt .row--pill { flex: 1; background: none; border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 999px; }
.ss-opt .row--pill.is-selected { outline: none; background: rgba(255, 255, 255, 0.95); border-color: rgba(255, 255, 255, 0.3); color: rgba(0, 0, 0, 0.95); }
.ss-prev { flex: none; min-height: 64px; padding: 0 26px; }
.ss-suboption { margin-left: 26px; } /* nests the Hour-markers toggle under the World clocks option */
.ss-striprow { margin-top: 22px; } /* breathing room between the source list and the info-strip toggle */

.iconbtn {
  width: 56px; height: 56px; border-radius: 12px; border: none; cursor: pointer;
  background: var(--bg-card-2); color: var(--ink); font-size: 26px;
}
.chips { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 20px; }

/* ---- ordered ticker list (Settings › Markets; skinned again in setup.css) ----
   The ONE two-column settings pane. The Markets pane is 1538px of content wide
   and every other control in it caps at 860, so half of it was dead space; the
   ordered list takes a 660px left column and the add-a-ticker keypad keeps the
   right. Twelve rows would otherwise push the keypad 671px below the pane's
   bottom edge (browser-measured on the mockups) and adding a ticker would mean
   scrolling past the whole list. Nothing here scrolls but the list, inside its
   own column. */
.pane__cols { display: grid; grid-template-columns: 660px minmax(0, 1fr); gap: 48px; align-items: start; max-width: 1538px; }
.pane__col { min-width: 0; }
.colhead { display: flex; align-items: baseline; gap: 14px; margin-bottom: 12px; }
.colhead h3 { font-size: 25px; font-weight: 600; color: var(--ink); }
/* 20px is the --ink-faint AA floor, so a 20px sub-label takes --ink-dim. */
.colhead span { font-size: 20px; color: var(--ink-dim); }
/* 640px = nine and a half 68px rows, and the largest cap that keeps the list's
   last pixel clear of a Board Pro's 1040px glass with the 84px safe-bottom
   reserve intact (measured at the 20-ticker cap: the list ends at 895). The
   title, hint, column head and fold band spend the rest. */
.tk-listwrap { position: relative; max-width: 660px; }
.tk-list { display: flex; flex-direction: column; gap: 8px; position: relative; max-height: 640px; overflow-y: auto; }
/* The shipped .drill__list scroll idiom: a persistent 6px white thumb. */
.tk-list::-webkit-scrollbar { width: 6px; }
.tk-list::-webkit-scrollbar-track { background: transparent; margin: 10px 0; }
.tk-list::-webkit-scrollbar-thumb { background: #fff; border-radius: 999px; }
.tk-row {
  display: flex; align-items: center; gap: 16px; min-height: 60px; padding: 0 14px;
  background: var(--bg-card); border: 1px solid transparent; border-radius: 12px; flex: none;
  /* A drag over text otherwise leaves the row's label selected behind it. */
  -webkit-user-select: none; user-select: none;
}
/* Below the fold: same text contrast (AA is not negotiable), no card fill — the
   demotion is carried by the surface, not by dimming the words. */
.tk-row--below { background: transparent; border-color: rgba(147, 160, 180, 0.14); }
.tk-pos { width: 34px; flex: none; font-size: 20px; color: var(--ink-dim); font-variant-numeric: tabular-nums; text-align: right; }
.tk-txt { flex: 1 1 auto; min-width: 0; display: flex; align-items: baseline; gap: 12px; }
.tk-lead { font-size: 24px; font-weight: 600; color: var(--ink); white-space: nowrap; }
.tk-sub { font-size: 21px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The drag handle, and the ONLY thing that starts a drag: touch-action:none
   lives here and nowhere else, so a finger on the row body still scrolls the
   column (and, on the phone, the page). A stacked chevron pair reads as "move
   this"; a grip that cannot drag is never rendered at all (one-item lists). */
.tk-grip {
  width: 56px; height: 56px; flex: none; border-radius: 12px; border: none; cursor: grab;
  background: var(--bg-card-2); color: var(--ink-dim); touch-action: none;
  display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.tk-grip svg { width: 30px; height: 30px; pointer-events: none; }
.tk-grip:active { background: rgba(88, 166, 255, 0.35); color: var(--ink); }
/* Held apart from the handle: a control you press repeatedly must not sit
   flush against a destructive one (the shipped .sched__rm 20px precedent). */
.tk-rm { margin-left: 24px; }
.tk-rm:active { background: var(--bad); color: #06131f; }
/* Lifted row: out of flow in the list's content coordinates, so it keeps
   tracking the finger while the column auto-scrolls underneath it. */
.tk-row--drag {
  position: absolute; left: 0; right: 6px; z-index: 4;
  background: #1c1c1c; border-color: rgba(255, 255, 255, 0.18);
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.8);
}
/* Drop target. A dashed accent outline, matching edit mode's .editor__cell
   drop target — the dash IS the affordance here, not decoration. */
.tk-slot { background: transparent; border: 2px dashed rgba(100, 180, 250, 0.55); }
.is-reordering { cursor: grabbing; }
/* The fold: drawn at the card's REAL capacity, so ordering stops being
   abstract ("what number is CSCO?") and becomes "get CSCO above the line". */
.tk-fold { display: flex; align-items: center; gap: 16px; margin: 6px 0; min-height: 44px; flex: none; }
.tk-fold__rule { flex: 1 1 auto; height: 1px; background: rgba(100, 180, 250, 0.45); }
.tk-fold__label { font-size: 20px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); white-space: nowrap; }
.tk-fold--below .tk-fold__rule { background: rgba(147, 160, 180, 0.3); }
.tk-fold--below .tk-fold__label { color: var(--ink-dim); }
.tk-note { max-width: 660px; }

/* One chip primitive: the shipped outline language (transparent + hairline,
   radius 12) — the old solid radius-28 pill read as a different control. */
.chip {
  font-size: 22px; padding: 14px 20px; min-height: 56px;
  border: 1px solid rgba(147, 160, 180, 0.16); border-radius: 12px; cursor: pointer;
  background: transparent; color: var(--ink);
}
/* Disabled chips stay visible but quiet: the Sports News takeover grays the
   sport chips while Only my teams is choosing for them. */
.chip:disabled { opacity: 0.45; cursor: default; }
.chip--on { background: var(--accent); border-color: var(--accent); color: #06131f; }
/* Label/value rows (Batch 2 of the settings unification) replace the old .kv
   gutter rows. row--nav is a full-width tap target ("Trains stopping at —
   Any station ›"): the WHOLE row opens the picker, so no small button sits
   marooned at the end of a long invisible container. */
.row--nav { border: none; cursor: pointer; width: 100%; max-width: 860px; color: var(--ink); font-size: 23px; text-align: left; }
.row--nav:active { outline: 2px solid var(--accent); }
.row__label--dim { color: var(--ink-dim); }
/* Explicit size so a plain .row value matches a .row--nav value (which
   inherited 23px from the button) — pairs with the 24px .row__label. */
.row__value { margin-left: auto; font-size: 24px; font-weight: 600; display: inline-flex; align-items: center; gap: 12px; white-space: nowrap; }
.row__chev { color: var(--ink-faint); font-size: 22px; }
.row .btn--ghost { min-width: 0; min-height: 48px; } /* compact in-row action (Remove) */
.settings__pane .row--nav + .btn--ghost { margin-top: 10px; } /* Display's Remove-name breathes below its row */
/* Read-only fact sheet (Diagnostics): quiet two-column grid — the label
   gutter is exactly as wide as the longest label, not a fixed 260px. */
.kv-grid { display: grid; grid-template-columns: max-content 1fr; gap: 14px 32px; font-size: 22px; max-width: 1000px; align-items: baseline; margin: 6px 0 24px; }
.kv-grid > span { color: var(--ink-dim); }
.kv-grid > b { font-weight: 600; }
.btnrow { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0 24px; }
/* .kv (the 260px-gutter label/value rows) retired in Batch 2 — replaced by
   .row--nav tap-rows, .row value rows, and the Diagnostics .kv-grid. */
.kv__small { font-size: 20px; font-weight: 400; color: var(--ink-dim); }

/* Scheduled-mode window editor: tap ▲/▼ steppers around each time. Steppers
   get a fixed compact width (the .btn 34px side padding read as one long
   strip of buttons); the dash and ✕ carry extra margin so the two time
   groups and the remove control read as separate units. */
.sched { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; margin: 12px 0 16px; }
.sched__win { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sched__step { width: 64px; min-width: 64px; padding: 0; text-align: center; } /* compact square stepper — exempt from the .btn 160px floor */
.sched__time { font-size: 22px; min-width: 130px; text-align: center; font-variant-numeric: tabular-nums; }
.sched__dash { color: var(--ink-dim); margin: 0 12px; }
.sched__rm { margin-left: 20px; }
.sched__warn { color: var(--warn); font-size: 18px; margin-left: 12px; }

.drill { margin-top: 22px; max-width: 860px; }
.drill__head { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
.drill__head h3 { font-size: 26px; }
.drill__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; max-height: 640px; overflow-y: auto; }
.drill__item {
  min-height: 64px; font-size: 23px; padding: 10px 16px;
  border: none; border-radius: 12px; cursor: pointer;
  background: var(--bg-card); color: var(--ink);
  display: flex; align-items: center; gap: 12px;
  text-align: left; /* names that wrap (e.g. "Atlantic City Rail Terminal") would otherwise inherit the button default (center) and look misaligned next to short ones */
}
.drill__letter { color: var(--accent); }

/* ONE keyboard display style. Default tracking suits words (city names,
   tickers, handles); the wide 0.35em survives as --pin for fixed-length codes. */
.code__display, .zip__display {
  display: block; font-size: 52px; letter-spacing: 0.12em;
  font-variant-numeric: tabular-nums; margin: 16px 0; min-height: 64px;
}
.code__display--pin { letter-spacing: 0.35em; }
.key {
  min-height: 64px; font-size: 26px; font-weight: 600;
  border: none; border-radius: 12px; cursor: pointer;
  background: var(--bg-card); color: var(--ink);
}
.key:active { background: var(--bg-card-2); }
/* The default is a problem: amber, because for most of this line's life the
   only thing it had to say was that the code didn't work. The two other things
   it says get their own register (settings.js setStatus) — "Checking…" is not a
   warning, and a code that worked must never answer in the colour of one. */
.code__status, .zip__status { font-size: 22px; color: var(--warn); margin-top: 14px; min-height: 30px; }
.code__status--busy { color: var(--ink-dim); }
/* Success on its own solid well, the badge idiom: bright semantic ink never
   sits on an alpha tint (it goes muddy on OLED black). Inline-block so the well
   is the width of the sentence rather than a band across the pane. */
.code__status--good {
  color: var(--good); background: var(--good-tint);
  display: inline-block; padding: 6px 14px; border-radius: 10px;
}
/* Solid container (like .row cards) with a clearly-visible outer border and
   internal dividers, sized to match the row height so it reads as a peer of
   the other controls. Shared by the weather °F/°C toggle and the Art/Photos
   interval + photo-source choosers. */
.segmented { display: inline-flex; background: var(--bg-card); border: 2px solid rgba(147, 160, 180, 0.4); border-radius: 12px; overflow: hidden; }
.seg { min-height: 64px; padding: 0 26px; font-size: 23px; border: none; background: none; color: var(--ink-dim); cursor: pointer; }
.seg + .seg { border-left: 1px solid rgba(147, 160, 180, 0.4); } /* dividers sit lighter than the 2px ring */
.seg.is-active { background: var(--accent); color: #06131f; font-weight: 700; }
/* Rows directly after a segmented control breathe (Weather's Current row sat
   flush against the °F/°C picker — other panes have a label between). */
.segmented + .row, .segmented + .rows { margin-top: 14px; }
.qr { margin-top: 20px; }
/* White padding grows the quiet zone so the corner radius rounds into it,
   never into code modules (the board's rendering clipped a corner cell). */
.qr svg { background: #fff; border-radius: 12px; padding: 10px; }

/* ---------- line chips (subway settings) ---------- */
.linechips { display: flex; flex-wrap: wrap; gap: 12px; max-width: 720px; margin-bottom: 16px; }
.linechip { width: 56px; height: 56px; font-size: 24px; border: none; cursor: pointer; }
.linechip--off { opacity: 0.25; }

/* ---------- edit mode ---------- */
.gear--edit { right: 96px; }
.editor { position: absolute; inset: 0; z-index: 50; background: var(--bg); display: flex; flex-direction: column; padding: 32px 40px; padding-bottom: var(--safe-bottom); }
.editor__stage { position: relative; flex: 1; min-height: 0; }
.editor__cells, .editor__blocks {
  position: absolute; inset: 0;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-template-rows: repeat(8, minmax(0, 1fr));
  gap: 10px;
}
/* Radius matches the card's (--radius): a cell is a card's footprint, so a
   tighter corner made the placed card overhang the drop-target outline. */
.editor__cell { border: 1px dashed rgba(147, 160, 180, 0.10); border-radius: var(--radius); }
.edit-block {
  position: relative;
  background: var(--bg-card);
  border: 1px solid rgba(147, 160, 180, 0.14);
  border-radius: var(--radius);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
  display: flex; align-items: center; justify-content: center;
  cursor: grab; touch-action: none; user-select: none;
}
.edit-block.is-dragging { opacity: 0.85; z-index: 5; cursor: grabbing; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5); }
.edit-block__title { font-size: 26px; font-weight: 600; color: var(--ink-dim); pointer-events: none; }
/* Delete: a neutral grey badge in the TOP-LEFT (iOS home-screen convention) so
   it reads as "remove this widget" not "close", and stays calm at rest — the
   minus bar goes red only on press, reserving the destructive colour for the
   action. ::after pads the 48px visual to a >=56px hit area (board touch floor). */
.edit-remove {
  position: absolute; top: 10px; left: 10px;
  width: 48px; height: 48px; border-radius: 50%; border: none; cursor: pointer;
  background: var(--ink-dim); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
  display: flex; align-items: center; justify-content: center;
  transition: background 0.15s, transform 0.15s ease-out;
}
.edit-remove::after { content: ''; position: absolute; inset: -6px; }
.edit-remove__bar { width: 16px; height: 3px; border-radius: 2px; background: var(--bg); }
.edit-remove:active { background: var(--bad); transform: scale(1.08); }
.edit-remove:active .edit-remove__bar { background: #fff; }
/* Draggability signifier: a grip glyph centered at the tile top — cursor:grab is
   invisible to a finger. */
.edit-grip {
  position: absolute; top: 11px; left: 50%; transform: translateX(-50%);
  width: 34px; height: 15px; opacity: 0.7; pointer-events: none;
  background: radial-gradient(circle, var(--ink-faint) 2px, transparent 2.4px);
  background-size: 11px 7.5px;
}
/* Resize chip: a filled square whose visual IS the touch target — testers
   aimed at the old thin corner bracket instead of the (invisible) hit area.
   The ::after pad forgives near-misses just outside the chip. */
.edit-handle {
  position: absolute; bottom: 8px; right: 8px;
  width: 64px; height: 64px; cursor: nwse-resize; touch-action: none;
  display: flex; align-items: center; justify-content: center;
  border: 2px solid var(--accent); border-radius: 12px;
  background: rgba(88, 166, 255, 0.10); color: var(--accent);
  transition: background 0.15s, transform 0.15s ease-out;
}
.edit-handle::after { content: ''; position: absolute; inset: -12px; }
.edit-handle:active { background: rgba(88, 166, 255, 0.4); transform: scale(1.08); }
@media (prefers-reduced-motion: reduce) {
  .edit-handle, .edit-remove { transition: background 0.15s; }
  .edit-handle:active, .edit-remove:active { transform: none; }
}
/* Top-align so the tray can grow/collapse rows without re-centering the actions;
   the tray takes the width, Done/Cancel stay compact on the right. */
/* Actions are pinned to the editor's lower-right corner (below), so the bar
   is just the tray now; reserve the corner so chips don't run under them. */
.editor__bar { display: flex; align-items: flex-start; gap: 24px; padding-top: 20px; padding-right: 340px; }
.edit-tray { flex: 1; min-width: 0; }
/* "Add a widget" region heading — replaces the old orphaned inline "Add:" token. */
.edit-tray__head { font-size: 15px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); margin: 0 0 12px; }
.edit-tray__chips { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
/* Add chips are OUTLINE controls (transparent, NOT the tiles' --bg-card fill) so
   the palette reads as controls on the canvas, not a second field of mini-tiles.
   min-height 56px holds the board's touch floor. */
.edit-tray__chip {
  font-size: 20px; padding: 12px 18px; min-height: 56px;
  border: 1px solid rgba(147, 160, 180, 0.16); border-radius: 12px; cursor: pointer;
  background: transparent; color: var(--ink);
}
.edit-tray__chip:active { border-color: var(--accent); }
/* Blocked: dim the border + text but KEEP the shape (opacity:0.4 collapsed the
   chip to ~1.3:1 against the bg, so it vanished). */
.edit-tray__chip:disabled { color: var(--ink-faint); border-color: rgba(147, 160, 180, 0.08); cursor: default; }
.edit-tray__chip small { color: var(--ink-faint); font-size: 16px; margin-left: 6px; }
/* The big categories (Commute, Images, Sports) each get a collapsed expander so
   they don't wall the tray; several can be open at once. */
.edit-tray__toggle {
  font-size: 20px; min-height: 56px; padding: 0 18px;
  border: 1px solid rgba(88, 166, 255, 0.4); border-radius: 12px; cursor: pointer;
  background: transparent; color: var(--ink);
  display: inline-flex; align-items: center; gap: 9px;
}
.edit-tray__caret { color: var(--accent); font-size: 16px; }
.edit-tray__count { color: var(--ink-faint); font-size: 17px; }
/* Revealed chips sit in a faint accent inset so they read as "inside" their category. */
.edit-tray__group {
  display: inline-flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 8px;
  border-radius: 14px; background: rgba(88, 166, 255, 0.05); border: 1px dashed rgba(88, 166, 255, 0.25);
}
.edit-tray__group[hidden] { display: none; }
.edit-tray__legend { width: 100%; font-size: 16px; color: var(--ink-faint); margin: 12px 0 0; }
/* Pinned to the editor's lower-right corner; 52px clears the RoomOS bar,
   matching the settings footer. (The .editor is the positioning context.) */
.editor__actions { position: absolute; right: 40px; bottom: 52px; display: flex; gap: 12px; flex: none; }
@media (prefers-reduced-motion: reduce) {
  .slide { transition: none; }
}

/* ---------- full-screen art viewer (tap art card to open, tap to close) ---------- */
.art-viewer {
  /* FIXED, like every other full-screen view (.expand, .text-viewer,
     .ss-preview, .iptv--full). It is appended to document.body and used to be
     `absolute`, which happened to resolve to the viewport only because body was
     unpositioned — an invariant nothing stated and nothing tested, and the
     first rule that positioned body (the centring above) silently re-anchored
     this viewer to the 1080 page and stretched it 40px past a board's glass.
     `fixed` says what was always meant and cannot be broken from the outside. */
  position: fixed; inset: 0; z-index: 45;
  background: #000; cursor: pointer;
  /* Without this, a touch swipe is claimed by the browser's gesture
     arbitration: pointercancel fires instead of pointerup and the swipe
     classifier never runs (mouse drags were unaffected — no arbitration). */
  touch-action: none;
}
/* Default contain: art and personal photos are never cropped. A widget whose
   SCREENSAVER fills the screen (the curated sources — Landscapes) opens its
   tapped image the same way, or the same photo reads as two different pictures
   depending on how you got to it. imageFit() in config.js is the one rule both
   paths ask. */
.art-viewer__img { width: 100%; height: 100%; object-fit: contain; }
.art-viewer--fill .art-viewer__img { object-fit: cover; }

/* ---------- full-screen text viewer (tap truncated text) ---------- */
.text-viewer {
  position: fixed; inset: 0; z-index: 46; /* above the art viewer's 45 */
  background: rgba(13, 17, 23, 0.96);
  display: flex; justify-content: center;
  /* SAFE centring, and the two declarations are deliberate. Plain `center`
     overflows a too-tall panel equally past BOTH edges, and the top half is
     unreachable — there is no scroller here on purpose (a passerby will not
     discover a scroll on a wall), so the meta line and the top of the headline
     were simply gone. `safe` centres exactly as before while the panel fits and
     falls back to start-alignment the instant it does not, which puts every
     remaining pixel of overflow at the BOTTOM where it can at least be seen to
     be missing. Ordered as a fallback pair because an unknown keyword
     invalidates its own declaration only: an engine that has never heard of
     `safe` drops the second line at parse time and keeps plain `center`, i.e.
     exactly today's behaviour, while fitStoryDesc() below keeps the panel
     inside the viewport on every engine regardless. */
  align-items: center;
  align-items: safe center;
  cursor: pointer;
}
.text-viewer__panel { max-width: 1400px; padding: 60px; display: flex; flex-direction: column; gap: 26px; }
.text-viewer__title { font-size: 24px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-dim); }
.text-viewer__body { font-size: 42px; line-height: 1.45; color: var(--ink); text-wrap: pretty; }
.text-viewer__hint { font-size: 20px; color: var(--ink-faint); }

/* Story view: a tapped headline's summary + a QR to the full article (the board
   is a shared kiosk and finance sources are paywalled — read it on your phone). */
.text-viewer__panel.story { max-width: 1200px; gap: 22px; }
.story__meta { display: flex; align-items: baseline; gap: 14px; font-size: 22px; text-transform: uppercase; letter-spacing: 0.08em; }
.story__src { font-weight: 600; color: var(--ink-dim); }
.story__age { color: var(--ink-faint); text-transform: none; } /* keep "12m", not "12M" */
/* break-word: a headline is untrusted feed text. A real URL wraps on its own
   (`/` and `-` are break opportunities), but one 160-character run with no
   break at all painted 3468px past the right edge of a 1920 board. */
.story__title { font-size: 46px; line-height: 1.18; font-weight: 700; color: var(--ink); letter-spacing: -0.01em; text-wrap: balance; overflow-wrap: break-word; }
/* The summary is the panel's one ELASTIC block, so it is the one that yields.
   fitStoryDesc() measures what the meta line, the headline and the QR block
   actually cost at this viewport and writes the lines that honestly remain into
   --desc-lines; the QR is the affordance a truncated summary hands off to, so it
   must never be the thing that gets cut. Unset — no layout engine, or a story
   with no summary — it resolves to `none`, i.e. the pre-clamp behaviour. */
.story__desc {
  font-size: 30px; line-height: 1.5; color: var(--ink); text-wrap: pretty; max-width: 34ch; /* full ink: it's the content they tapped for, read at 6 ft */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: var(--desc-lines, none); overflow: hidden;
}
.story__more { display: flex; align-items: center; gap: 32px; margin-top: 10px; }
/* QR must sit on white to scan; the padding is its quiet zone. */
.story__qr { width: 232px; height: 232px; flex: none; background: #fff; border-radius: 18px; padding: 16px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; }
.story__qr svg { width: 100%; height: 100%; display: block; }
.story__qr:empty { background: var(--bg-card-2); } /* while the QR chunk loads */
.story__more-text { display: flex; flex-direction: column; gap: 8px; }
.story__more-label { font-size: 26px; font-weight: 600; color: var(--ink); }
.story__more-host { font-size: 24px; color: var(--ink-dim); }
.story__more-hint { font-size: 20px; color: var(--ink-faint); }
@media (prefers-reduced-motion: no-preference) {
  .text-viewer__panel.story { animation: story-in 200ms cubic-bezier(0.22, 1, 0.36, 1); }
}
@keyframes story-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }

/* Calm tap affordance: no chrome at rest, a brief press tint on touch so the
   headline feels responsive (the "there's more here" signal is the press). */
.headline--more { cursor: pointer; border-radius: 8px; }
.headline--more:active { background: var(--bg-card-2); }

/* ---------- tap-to-expand overlay (the +N badge's contents, full screen) ----------
   Z-order 44 extends the existing scale: above the settings pane (40) and above
   the gear/pencil FABs (no z of their own, so any positive value clears them —
   settings can't be entered mid-read), and BELOW the art viewer (45) and the
   story view (46), which a tapped row inside a future list expansion stacks on
   top of. Flush at bottom:0 per the RoomOS bar rule — a tap-opened full-screen
   overlay is not lifted; only idle contexts are. */
.expand {
  position: fixed; inset: 0; z-index: 44;
  /* The story view's scrim colour, taken OPAQUE: the tiles below are themselves
     translucent (--bg-card-2), so at the story view's 0.96 the numbers on the
     card you just tapped ghost through the numbers in the overlay — unreadable
     on a data surface, and not what the approved mockup shows (it composited
     over black). A text panel can afford the see-through; a full canvas can't. */
  background: rgb(13, 17, 23);
  display: flex; flex-direction: column;
  padding: 56px 72px 0;
  cursor: pointer;
}
/* Pinned, not measured: the head is baseline-aligned type, so its natural height
   follows the rendering font's ascent — which would make the canvas the models
   in markets.js/subway.js reserve against (OVERLAY_BODY_H) a different number on
   the board than in the browser they were measured in. 34px is that measured
   height; fixing it makes the canvas arithmetic exact everywhere. Anything a
   taller face adds simply eats into the 36px below it. */
.expand__head { flex: none; height: 34px; display: flex; align-items: baseline; gap: 20px; }
.expand__title { font-size: 28px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-dim); }
.expand__note { font-size: 24px; color: var(--ink-faint); }
.expand__stamp { font-size: 24px; color: var(--warn); } /* the source card's stale stamp, carried through */
.expand.is-stale .expand__body { opacity: 0.75; } /* same dimming as a stale card */
/* The hint band, in three parts that add up to the body's padding-bottom:
     32.0px  the hint's margin off the floor;
     26.4px  the hint's line box, pinned by a unitless line-height so the band
             measures the same on the board as in the browser it was measured in;
     41.6px  air between the data and the hint.
   The hint used to sit 52px up, which left it ~22px under the content and
   reading as part of the data rather than as chrome. Those 52px were bar
   clearance: the belief was that RoomOS paints its "Tap here to start" bar OVER
   the bottom 40px of our viewport. Sean's on-device diagnostic on 2026-07-28
   settled it — the Board Pro hands the page a 1920x1040 viewport and the bar
   lives in the 40 physical px BELOW it, so nothing is painted over page content
   and there is no band to reserve. The clearance becomes air where it is wanted,
   above the hint instead of under it, and the band as a whole does not grow:
   the canvas the widget models reserve against (expand.js OVERLAY_BODY_H) is
   untouched by this change.
   All of it is viewport-relative — .expand is position:fixed and the body flexes
   — so the same rules give the same 41.6px gap on a 1040 board, a 1200 Room
   Navigator and an 1080 desktop preview. */
.expand__body { flex: 1; min-height: 0; margin-top: 36px; padding-bottom: 100px; }
/* The rail overlay's departures board sizes to its content (Sean's pick,
   2026-07-31): up to six trains stand in ONE grand centered column at roughly
   double the card's row scale; beyond six the same rows deal into two
   balanced centered columns a step smaller (--board-rows = ceil(n/2), set
   inline by train-expand.js). Worst cases inside the 814px body: grand, six
   68px rows on 40px gaps = 608px; split, six 50px rows on 24px gaps = 420px.
   Widths are fixed per row so left edges align while the block centers. */
.expand__body .trains--board {
  height: 100%; display: flex; flex-direction: column;
  justify-content: center; align-items: center; gap: 40px; overflow: hidden;
}
.trains--board .train { gap: 24px; }
.trains--board--grand .train { width: 760px; }
.trains--board--grand .train__min { min-width: 165px; }
.trains--board--grand .train__min span { font-size: 56px; }
.trains--board--grand .train__min small { font-size: 22px; }
.trains--board--grand .train__dest { font-size: 34px; }
.trains--board--grand .train__line { font-size: 26px; }
.trains--board--grand .train__linechip { font-size: 21px; line-height: 23px; padding: 3px 12px; border-radius: 8px; }
.trains--board--grand .train__track { font-size: 26px; padding: 8px 14px; }
.expand__body .trains--board--split {
  display: grid; grid-template-columns: repeat(2, 660px);
  grid-template-rows: repeat(var(--board-rows, 6), min-content);
  grid-auto-flow: column; gap: 24px 80px; justify-content: center; align-content: center;
}
.trains--board--split .train__min { min-width: 120px; }
/* Tap-an-alert reading view (Sean's pick, mockup A): story-view calm — white
   reading type on the plain canvas, centered, ~70ch measure. The amber stays
   on the card banner; here the words are the whole point. */
.expand__body .alert-view {
  height: 100%; display: flex; flex-direction: column;
  justify-content: center; align-items: center;
}
.alert-view__head { font-size: 44px; font-weight: 600; line-height: 1.25; max-width: 900px; margin: 0 0 28px; text-wrap: balance; }
.alert-view__body { font-size: 30px; line-height: 1.5; color: var(--ink-mid); max-width: 900px; margin: 0; }
.trains--board--split .train__min span { font-size: 40px; }
.trains--board--split .train__min small { font-size: 20px; }
.trains--board--split .train__dest { font-size: 28px; }
.trains--board--split .train__line { font-size: 22px; }
.trains--board--split .train__linechip { font-size: 18px; line-height: 20px; padding: 2px 10px; }
.trains--board--split .train__track { font-size: 22px; padding: 6px 12px; }
.expand__hint {
  position: absolute; left: 0; right: 0; bottom: 32px;
  text-align: center; font-size: 22px; line-height: 1.2; color: var(--ink-faint);
}

/* Markets: a ticker wall. Indices lead on their own shelf; the watchlist fills
   the grid below. Each band renders only when it has tiles, so removing the
   indices leaves a plain full-canvas stock grid with no empty region. */
.wall { height: 100%; display: flex; flex-direction: column; }
.wall--shelf-only { justify-content: center; }
.wall__shelf { flex: none; display: grid; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); gap: 20px; }
.wall__rule { flex: none; height: 1px; background: rgba(255, 255, 255, 0.14); margin: 26px 0; }
.wall__grid {
  flex: 1; min-height: 0;
  display: grid;
  grid-template-columns: repeat(var(--cols, 5), minmax(0, 1fr));
  grid-auto-rows: minmax(0, 1fr);
  gap: 20px;
}
/* Grid tiles fill their row (height:100% against the 1fr track) so rows sit a
   clean 20px apart, and cap out at 280px — a one-row wall then reads as a band
   of well-proportioned tiles, centered, instead of one stretched to 500px. */
.wall__grid .tile { height: 100%; }
.tile {
  background: var(--bg-card-2);
  border-radius: 12px;
  padding: 14px 18px 16px;
  display: flex; flex-direction: column; justify-content: center; gap: 6px;
  min-width: 0; max-height: 280px; align-self: center;
}
.tile__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
.tile__sym { font-size: 34px; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tile__pct { font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tile__row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.tile__price { font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; }
.tile__chg { font-size: 21px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tile__spark { width: 100%; height: 36px; } /* overrides the card sparkline's fixed 160x28 */
/* The chart takes the slack in a tall tile rather than leaving dead air under
   the numbers: more room means more curve, which is the point of the wall. */
.wall__grid .tile__spark { flex: 1 1 36px; min-height: 36px; max-height: 90px; }
.tile__name { font-size: 19px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* AA: dim, not faint */
/* Six across — a full 20-ticker wall under an index shelf — leaves a 279px tile
   instead of 339. The symbol keeps its size, because the symbol is what the
   wall is read by; the width comes back out of the padding and the secondary
   type, so a five-character ticker still reads whole instead of ellipsing. */
.wall__grid--dense .tile { padding: 12px 13px 14px; }
.wall__grid--dense .tile__head { gap: 8px; }
.wall__grid--dense .tile__pct { font-size: 21px; }
.wall__grid--dense .tile__chg { font-size: 19px; }
.wall__grid--dense .tile__name { font-size: 17px; } /* AA: dim, not faint */
/* Shelf tiles: bigger, and led by the friendly name (an index symbol is config
   vocabulary, not reading vocabulary — it goes to the quiet sub-line). */
.wall__shelf .tile { padding: 18px 24px 20px; gap: 8px; max-height: none; }
.tile--index .tile__sym { font-size: 30px; font-weight: 600; color: var(--ink-dim); }
.wall__shelf .tile__pct { font-size: 28px; }
.wall__shelf .tile__price { font-size: 46px; }
.wall__shelf .tile__chg { font-size: 26px; }
.wall__shelf .tile__spark { height: 42px; }

/* Subway: a status board on the same wall skeleton. Every healthy line collapses
   into ONE compact band of bullets (it scales to a full 25-service config in a
   single sweep); the canvas below belongs to the lines with a problem, each a
   full-width well carrying its complete alert text — every header, stacked,
   including the second alert the card has no room for. Either band renders only
   when it has lines: no empty band, no reserved space. */
/* The label and the count sit on the FIRST bullet row (a 60px line box centers
   them on it), not in the middle of a band that wrapped to two rows — a
   25-service config wraps, and a label floating between rows reads as detached. */
.wall__good { flex: none; display: flex; align-items: flex-start; gap: 28px; }
.wall__goodlabel { font-size: 22px; font-weight: 700; line-height: 60px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--good); flex: none; }
.wall__bullets { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; min-width: 0; }
.wall__bullets .bullet { width: 60px; height: 60px; font-size: 32px; }
.wall__count { margin-left: auto; flex: none; font-size: 22px; line-height: 60px; color: var(--ink-faint); }
.wall--good-only { justify-content: center; } /* an all-good morning centers its one band */
/* overflow-y is the never-clip backstop, not the plan: subway.js sizes the wells
   so they fit, and a transient overlay is the one place the board may scroll
   (auto-close guarantees no residue). It only ever engages past a dozen-odd
   two-alert lines at once, which no legible type could fit on 1080 lines. */
.wall__alerts {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; justify-content: flex-start;
  row-gap: var(--well-space, 20px); /* uniform — subway.js stamps the number it packs with */
}
/* A problem well. The defaults ARE the design — one full-width well in the
   biggest type on the canvas; subway.js overrides the --well-* scale (and only
   that) on the tighter rungs of its ladder, so the type it measures against and
   the type that renders can never drift apart.
   The surface is the board's standard translucent card, the body copy is ink:
   eight amber wells at once turned the whole canvas into a warning and the words
   into decoration, so alerts read as CONTENT and the pill carries the alarm. */
/* flex:none for the same reason .wall__good and .wall__rule carry it, and it was
   the one band that missed it: in the SINGLE-column rung the wells are the flex
   items of a height-constrained column, so without it they shrink to fit and
   their text is cropped away inside the overflow:hidden above — silently, with
   scrollHeight still equal to clientHeight, so nothing downstream could even
   tell. A well must overflow the wall honestly and let the ladder step down. In
   the two- and three-column rungs the .wall__col wrappers already absorbed this,
   which is why it only ever bit the widest rung. */
.sbalert {
  flex: none;
  display: flex; align-items: center; min-width: 0; overflow: hidden;
  background: var(--bg-card-2); border-radius: 16px;
  gap: var(--well-gap, 30px); padding: var(--well-pad, 20px) var(--well-px, 30px);
}
/* The well's OWN bullet, and the child combinator is load-bearing: statusBoard
   writes it as a direct child and the route bullets inside the copy live a level
   down in .sbalert__text. As a descendant selector this rule outranked
   .bullet--inline on specificity alone and blew every mid-sentence token up to
   the 80px lead size, which is the one bullet on the wall that is NOT part of a
   sentence. Scoped here, the inline ones keep their em-relative sizing and track
   whichever rung of the ladder their paragraph is set at. */
.sbalert > .bullet { width: var(--well-bullet, 80px); height: var(--well-bullet, 80px); font-size: var(--well-bfs, 42px); }
/* Metric-pinned too, and for the model's sake rather than the copy's: the pill
   below hangs off THIS line box, and whether the pill fits inside it (the claim
   that the pill costs the well no height) otherwise depends on the ascent the OS
   font happens to have. The line box itself does not move — line-height is a
   unitless 1.34, so the well's measured 41px line holds either way. */
.sbalert__text {
  font-family: var(--font-centred);
  font-size: var(--well-fs, 30px); line-height: 1.34; color: var(--ink); min-width: 0;
}
.sbalert__text p + p { margin-top: var(--well-para, 9px); } /* a line's second alert */
/* The only amber left in a well: one pill at the head of the lead alert naming
   what is wrong (subway.js reads the label off the prose). Every dimension is
   em-relative to the body copy, so it rides all five rungs of the ladder at one
   proportion — 0.74em, i.e. 20px against rung 2's 27px — and its 1.5em box stays
   inside the 1.34 line box, which is how the pill costs the well no height.
   The label is centred by LAYOUT (flex, not a line-height that happens to equal
   the height) on the metric-pinned face: on the board the shipped rule put the
   words visibly high in the pill, because line-height centring follows the
   font's ascent and descent and CiscoSansTT's differ from the desktop
   fallback's. Both halves are needed — pinning the metrics under a line-height
   construction still leaves the label a pixel high. */
.sbstatus {
  display: inline-flex; align-items: center; justify-content: center;
  margin-right: 0.6em; padding: 0 0.65em;
  height: 1.5em; line-height: 1; vertical-align: 0.1em;
  border-radius: 999px;
  background: var(--warn-tint); color: var(--warn);
  font-family: var(--font-centred);
  font-size: 0.74em; font-weight: 600; letter-spacing: 0.06em;
  text-transform: uppercase; white-space: nowrap;
}
/* Adaptive columns: past the single-column ceiling the wells flow into two, and
   each column is its OWN top-packed stack. It was one grid whose row tracks were
   shared between the columns — a row stood as tall as its TALLER well, so a short
   well beside a tall one floated in dead air and the gaps read as ragged.
   Config order is unchanged, still DOWN column one and then down column two
   (subway.js deals the wells into the wrappers), but now every well sits exactly
   one gap below the one above it, in both columns. */
.wall__alerts--split { flex-direction: row; align-items: flex-start; column-gap: 24px; }
.wall__col {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; justify-content: flex-start;
  row-gap: var(--well-space, 20px);
}

/* Weather: the full meteo board. The other two overlays UNCAP a list; this one
   has no hidden rows to reveal, so it re-reads the same payload at a richness the
   card has no room for — current conditions dominant on the left with today's
   fixed facts under them, a 24-hour graph and the 7-day cards on the right, and
   one quiet stats band along the bottom for the live readings.
   The accent rides the CURRENT condition, exactly as the card's does (the body
   markup carries its own data-cond so the shared .expand shell stays generic). */
.wxf[data-cond]              { --wx-accent: var(--accent); }
.wxf[data-cond="clear"]      { --wx-accent: #e3b341; }
.wxf[data-cond="partly"]     { --wx-accent: #9db2c9; }
.wxf[data-cond="cloudy"],
.wxf[data-cond="fog"]        { --wx-accent: #8a97ab; }
.wxf[data-cond="rain"],
.wxf[data-cond="drizzle"]    { --wx-accent: var(--accent); }
.wxf[data-cond="snow"],
.wxf[data-cond="sleet"]      { --wx-accent: #7fc8e6; }
.wxf[data-cond="thunder"]    { --wx-accent: var(--warn); }

.wxf { height: 100%; display: flex; flex-direction: column; }
.wxf__rule { height: 1px; background: rgba(255, 255, 255, 0.10); flex: none; }
/* The NWS banner is US-only (gated by inUS) and structurally optional: the
   layout flexes around it and nothing below depends on it. Same markup the card
   already uses for the same alert. */
.wxf .alert {
  flex: none; align-self: flex-start; gap: 14px; border-radius: 14px;
  padding: 13px 24px; margin-bottom: 24px; font-size: 26px;
}
.wxf .alert .icon { width: 30px; height: 30px; flex: none; }
/* The board's Cisco face ships as a separate FAMILY per weight, so light type has
   to name the family — a bare font-weight falls back to the regular face and the
   big reading loses its air (the clock faces pair them the same way). */
.wxf__temp { font-family: 'CiscoSansTT ExtraLight', 'CiscoSansTT', 'Cisco SansTT', sans-serif; }
.wxf__kv b,
.wxf__row--temp > span,
.wxf__dayhi,
.wxf__stat b { font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', sans-serif; }
.wxf__cond { font-family: 'CiscoSansTT Medium', 'CiscoSansTT', 'Cisco SansTT', sans-serif; }

.wxf__split { flex: 1; min-height: 0; display: grid; grid-template-columns: 540px 1fr; gap: 48px; }
/* Left pane: the reading, then the condition, then today's fixed facts pinned to
   the foot. Splitting the fixed facts (rail) from the live ones (bottom band) is
   what stops the rail bottoming out into dead canvas. */
.wxf__nowpane { display: flex; flex-direction: column; justify-content: space-between; gap: 24px; min-width: 0; padding-top: 8px; }
.wxf__nowtop { display: flex; align-items: center; gap: 24px; }
.wxf__glyph { width: 88px; height: 88px; flex: none; }
.wxf__temp {
  font-weight: 200; font-size: 150px; line-height: 0.86; letter-spacing: -0.015em;
  font-variant-numeric: tabular-nums;
}
.wxf__herometa { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.wxf__cond { font-size: 38px; font-weight: 500; color: var(--ink-mid); text-wrap: balance; }
.wxf__feels { font-size: 27px; color: var(--ink-dim); }
.wxf__todayrail { display: flex; flex-direction: column; gap: 14px; }
.wxf__todayrail .wxf__rule { margin: 6px 0 10px; }
.wxf__kv { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
.wxf__kv span { font-size: 26px; color: var(--ink-dim); }
.wxf__kv b { font-size: 34px; font-weight: 300; color: var(--ink); font-variant-numeric: tabular-nums; }

.wxf__colright { display: flex; flex-direction: column; min-height: 0; gap: 24px; }
.wxf__chart { flex: 1 1 46%; min-height: 0; display: flex; flex-direction: column; }
/* Every hour holds a column so the printed readings, the curve and the bars stay
   in register; only every other column prints (see LABEL_EVERY), which buys the
   type its breathing room without costing the graph any resolution. */
.wxf__row { display: flex; flex: none; }
/* nowrap, and a printed reading may overhang its 50px column by a few px: its
   neighbours are the unprinted columns, so the overhang lands on empty canvas
   rather than on another number. Wrapping instead would cost the curve a line's
   worth of height and break the one-reading-per-column rhythm. */
.wxf__row > span { flex: 1; min-width: 0; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
.wxf__row--temp > span { font-size: 30px; font-weight: 300; color: var(--ink); }
.wxf__row--pp { margin-top: 4px; }
.wxf__row--pp > span { font-size: 22px; color: var(--ink-dim); }
.wxf__row--pp > span.is-wet { color: #7fc8e6; }
.wxf__row--hours { margin-top: 12px; }
.wxf__row--hours > span { font-size: 22px; color: var(--ink-dim); }
/* Two layers the card has no room for, behind the same curve it draws: the night
   shaded from the vm's own sunrise/sunset, and the hourly chance of rain as a bar
   band at the foot. Rects and lines only — gen1 Qt WebEngine has no clipPath. */
.wxf-canvas { width: 100%; flex: 1 1 auto; min-height: 0; display: block; }
.wxf-canvas__night { fill: rgba(255, 255, 255, 0.045); }
.wxf-canvas__pp { fill: rgba(127, 200, 230, 0.22); }
.wxf-canvas__pp.is-wet { fill: rgba(127, 200, 230, 0.5); }
.wxf-canvas__line {
  fill: none; stroke: var(--wx-accent); stroke-width: 4px;
  stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke;
}
.wxf-canvas__g0 { stop-color: var(--wx-accent); stop-opacity: 0.30; }
.wxf-canvas__g1 { stop-color: var(--wx-accent); stop-opacity: 0; }
.wxf-canvas__sun { stroke: rgba(255, 255, 255, 0.20); stroke-width: 1px; vector-effect: non-scaling-stroke; }

/* The forecast strip is the card's own .wx-day chip, scaled up and carrying the
   day's rain chance — the one field the card's chip has no room for. */
.wxf__days { display: flex; gap: 14px; flex: none; }
.wxf__day {
  flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.07); border-radius: 16px;
  padding: 15px 6px; display: flex; flex-direction: column; align-items: center; gap: 7px;
}
.wxf__day--today { background: rgba(255, 255, 255, 0.12); }
.wxf__dayname { font-size: 24px; color: var(--ink-dim); }
.wxf__dayico { width: 46px; height: 46px; }
.wxf__dayhi { font-size: 40px; font-weight: 300; font-variant-numeric: tabular-nums; }
.wxf__daylo { font-size: 24px; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.wxf__daypp { font-size: 22px; color: var(--ink-dim); white-space: nowrap; }
.wxf__daypp.is-wet { color: #7fc8e6; }

/* Live readings, quiet: value first, label under it (at 6 ft the eye lands on the
   number and the label only has to confirm it). */
.wxf__stats {
  flex: none; display: flex;
  border-top: 1px solid rgba(255, 255, 255, 0.10); padding-top: 22px; margin-top: 24px;
}
.wxf__stat { flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 5px; padding: 0 32px; }
.wxf__stat + .wxf__stat { border-left: 1px solid rgba(255, 255, 255, 0.10); }
.wxf__stat:first-child { padding-left: 0; }
.wxf__stat:last-child { padding-right: 0; }
.wxf__stat b { font-size: 34px; font-weight: 300; color: var(--ink); font-variant-numeric: tabular-nums; white-space: nowrap; }
.wxf__stat span { font-size: 20px; color: var(--ink-dim); letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }

/* Surf, full screen. Sits on the .expand grammar above (opaque scrim, 56/72
   padding, head + body + hint) and adds nothing to the shell. */
.sff { --sf-accent: var(--accent); height: 100%; display: flex; flex-direction: column; }
.sff__rule { height: 1px; background: rgba(255, 255, 255, 0.10); flex: none; }

/* The left pane is fixed-width like the meteo board's, so the chart's column
   pitch does not move when the hero reads 3.3 one hour and 12.5 the next. */
.sff__split { flex: 1; min-height: 0; display: grid; grid-template-columns: 520px 1fr; gap: 52px; }

/* Three zones distributed by space-between, so the slack the hero leaves reads
   as two deliberate breaks rather than one hole above the foot. */
.sff__nowpane { display: flex; flex-direction: column; justify-content: space-between; gap: 26px; min-width: 0; padding-top: 4px; }
.sff__block { display: flex; flex-direction: column; gap: 14px; }
.sff__hero { display: flex; align-items: flex-end; gap: 6px; }
.sff__val { font-size: 168px; font-weight: 200; line-height: 0.84; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.sff__unit { font-size: 48px; font-weight: 300; color: var(--ink-dim); line-height: 1; margin-bottom: 14px; }
.sff__sub { display: flex; align-items: center; gap: 14px; font-size: 30px; color: var(--ink-mid); }
.sff__sub .sf-dir { width: 30px; height: 30px; color: var(--ink-dim); }
.sff__sep { color: var(--ink-faint); }
/* The honesty stamp: how far the model had to go to find water. */
.sff__src { font-size: 22px; color: var(--ink-faint); }

/* Section label — quiet, and at 22px --ink-faint clears AA. */
.sff__lbl { font-size: 22px; color: var(--ink-faint); letter-spacing: 0.06em; text-transform: uppercase; }

/* Component table: name | height | period | direction, two rows on one grid so
   the numbers stack on a shared axis. This is the block the card can never
   carry, and it is what explains the chart — how much of the total is real
   groundswell and how much is local chop that will die with the wind. */
.sff__cmps { display: grid; grid-template-columns: 1fr max-content max-content max-content; gap: 18px 28px; align-items: baseline; }
.sff__cmps > span { font-size: 26px; color: var(--ink-dim); white-space: nowrap; }
.sff__cmps > b { font-size: 36px; font-weight: 300; color: var(--ink); font-variant-numeric: tabular-nums; white-space: nowrap; justify-self: end; }
.sff__cmpdir { display: inline-flex; align-items: center; gap: 9px; }
.sff__cmpdir .sf-dir { width: 23px; height: 23px; color: var(--ink-faint); }

/* Today / tomorrow peaks — the fixed facts, the same shape as the meteo
   board's today rail (label left, number right). */
.sff__kv { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
.sff__kv span { font-size: 27px; color: var(--ink-dim); }
.sff__kv b { font-size: 36px; font-weight: 300; color: var(--ink); font-variant-numeric: tabular-nums; }
.sff__kv b small { font-size: 25px; font-weight: 400; color: var(--ink-dim); margin-left: 14px; }
.sff__block .sff__rule { margin: 2px 0; }

/* Right: the 48-hour chart, then the week. */
.sff__colright { display: flex; flex-direction: column; min-height: 0; gap: 26px; }
.sff__chart { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.sff__charthead { flex: none; display: flex; align-items: baseline; justify-content: space-between; gap: 24px; margin-bottom: 14px; }
.sff__legend { display: flex; align-items: center; gap: 26px; }
.sff__leg { display: inline-flex; align-items: center; gap: 10px; font-size: 21px; color: var(--ink-dim); white-space: nowrap; }
.sff__legswatch { width: 30px; height: 4px; border-radius: 2px; background: var(--sf-accent); flex: none; }
.sff__legswatch--swell { height: 3px; background: rgba(255, 255, 255, 0.42); }

/* Every hour holds a column so the printed readings, the curve and the night
   wash stay in register; only the anchor columns print. */
.sff__row { display: flex; flex: none; }
.sff__row > span { flex: 1; min-width: 0; text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; }
.sff__row--h > span { font-size: 30px; font-weight: 300; color: var(--ink); }
.sff__row--h > span.is-peak { color: var(--sf-accent); font-weight: 500; }
.sff__row--hours { margin-top: 12px; }
.sff__row--hours > span { font-size: 22px; color: var(--ink-dim); line-height: 1.25; }
.sff__day2 { display: block; font-size: 19px; color: var(--ink-faint); }

.sff__canvaswrap { position: relative; flex: 1 1 auto; min-height: 0; display: flex; }
.sf-canvas { width: 100%; flex: 1 1 auto; min-height: 0; display: block; }
.sf-canvas__night { fill: rgba(255, 255, 255, 0.038); }
.sf-canvas__line { fill: none; stroke: var(--sf-accent); stroke-width: 4px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.sf-canvas__swell { fill: none; stroke: rgba(255, 255, 255, 0.42); stroke-width: 2.5px; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.sf-canvas__g0 { stop-color: var(--sf-accent); stop-opacity: 0.28; }
.sf-canvas__g1 { stop-color: var(--sf-accent); stop-opacity: 0; }
.sf-canvas__now { stroke: rgba(255, 255, 255, 0.34); stroke-width: 2px; vector-effect: non-scaling-stroke; }
/* Two positioned HTML labels rather than SVG text: the canvas is stretched with
   preserveAspectRatio="none", which would smear any glyph inside it. "Now" is
   an interior mark (the chart carries six hours of past), so it needs a word
   and not just a rule; it rides the top of its own hairline, where the curve
   never reaches on a building swell. "Night" is said once, in the first shaded
   band — two unlabelled slabs read as highlighted regions rather than as dark. */
.sff__nowtag {
  position: absolute; top: 0; transform: translateX(-50%);
  font-size: 19px; font-weight: 700; letter-spacing: 0.1em; color: var(--ink-mid);
  background: rgb(13, 17, 23); padding: 0 9px;
}
.sff__nightlbl {
  position: absolute; bottom: 6px; transform: translateX(-50%);
  font-size: 19px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint);
}

/* The week strip: the card's own tile well, scaled up, carrying the day's peak
   height and the period that goes with it. */
.sff__days { display: flex; gap: 14px; flex: none; }
.sff__day {
  flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.07); border-radius: 16px;
  padding: 15px 6px; display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.sff__day--today { background: rgba(255, 255, 255, 0.12); }
.sff__dayname { font-size: 24px; color: var(--ink-dim); }
.sff__dayhi { font-size: 42px; font-weight: 300; font-variant-numeric: tabular-nums; }
.sff__dayper { font-size: 22px; color: var(--ink-dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.sff__daydir { font-size: 20px; color: var(--ink-faint); letter-spacing: 0.04em; }

/* Live readings, quiet — the shipped .wxf__stats shape. */
.sff__stats {
  flex: none; display: flex;
  border-top: 1px solid rgba(255, 255, 255, 0.10); padding-top: 22px; margin-top: 26px;
}
.sff__stat { flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 5px; padding: 0 28px; }
.sff__stat + .sff__stat { border-left: 1px solid rgba(255, 255, 255, 0.10); }
.sff__stat:first-child { padding-left: 0; }
.sff__stat:last-child { padding-right: 0; }
.sff__stat b { font-size: 34px; font-weight: 300; color: var(--ink); font-variant-numeric: tabular-nums; white-space: nowrap; }
.sff__stat span { font-size: 20px; color: var(--ink-dim); letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }

/* The expandable card is the tap target. For a capped list the +N badge is its
   signifier; weather has nothing to count, so the press tint is the whole
   signifier — calm at rest, a brief tint on touch, the same affordance the
   headlines use. */
.card.is-expandable { cursor: pointer; }
.card.is-expandable:active { background: var(--bg-card-2); }

/* ---------- per-size compact variants (gen1 Chromium lacks container queries;
   cards carry data-w/data-h and the tier classes t-s/t-m/t-l/t-narrow that
   main.js derives from the layout: t-s when h≤2, t-narrow when w≤4) ---------- */
/* Quote in a shallow card: smaller type, and a clamp as the backstop. The count
   comes from quote.js (--quote-lines), which is the same number the day's quote
   was CHOSEN to fit — a fitting quote never reaches this rule, and the 2 is only
   what a card renders at before the widget has run. */
.card--quote.t-s .quote__text {
  font-size: 22px;
  display: -webkit-box; -webkit-line-clamp: var(--quote-lines, 2); -webkit-box-orient: vertical; overflow: hidden;
}
/* Markets in a shallow card: single-line rows (name beside price) — stacked
   rows plus the +N hint clip a 2-tall body (measured: 141px into 121px) */
/* The info wrapper dissolves into the grid (display:contents) so name and
   price are their own snug columns: the price track hugs the widest price
   (right-aligned → decimals share an edge) right beside the names — no dead
   1fr stretch between them — and the freed middle goes to the sparkline. */
.card--markets.t-s .indexes { grid-template-columns: auto auto 1fr auto auto; }
.card--markets.t-s .index__info { display: contents; }
.card--markets.t-s .index__price { font-size: 22px; text-align: right; }
.card--markets.t-s .index__name { font-size: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card--markets.t-s .delta { font-size: 16px; }
.card--markets.t-s .spark { height: 20px; width: 100%; min-width: 40px; }
/* Stacked narrow cards (name over price, ≤4 cols, ≥3 tall): a fixed spark
   crushed the 1fr info column, so the wide price overflowed onto the sparkline
   (3×3 overlap). Give info its own content width instead — the spark fills
   what's left (min 40) and the delta tightens; the name caps + ellipsizes so a
   long instrument name can't blow the column out. */
.card--markets.t-narrow:not(.t-s) .indexes { grid-template-columns: auto 1fr auto auto; column-gap: 12px; }
.card--markets.t-narrow:not(.t-s) .spark { width: 100%; min-width: 40px; }
.card--markets.t-narrow:not(.t-s) .delta { font-size: 21px; }
.card--markets.t-narrow:not(.t-s) .index__name { max-width: 150px; }
/* The info column is ONE shared grid track, so right-aligning the price
   inside it anchors every row's decimal on a common edge (tabular-nums)
   while the block itself stays left-justified under the names. */
.card--markets.t-narrow:not(.t-s) .index__price { text-align: right; }
.card--markets.t-narrow:not(.t-s) .index__name { text-align: left; }
/* Row trim, all widths (markets caps at 4 wide): rows give up ~7px each
   (price 24/name 20, tighter leading, 8px gaps) so a 4-tall card fits five
   tickers — barely below the classic scale, not a visible shrink. t-s keeps
   its own compact set. */

.card--markets:not(.t-s) .index__info { line-height: 1.25; }
.card--markets:not(.t-s) .index__price { font-size: 24px; }
.card--markets:not(.t-s) .index__name { font-size: 20px; }
/* Bounded elastic rows: leftover height grows the gaps (departure-board
   style) up to a cap, then the block centers — the content-aware max height
   keeps the slack sub-row in practice. --n stamped by the renderer. */
.card--markets .indexes {
  flex: 1; align-content: center; --mrow: 61px;
  row-gap: clamp(8px, calc((100% - var(--n, 3) * var(--mrow)) / max(var(--n, 3) - 1, 1)), 24px);
}
.card--markets.t-s .indexes { --mrow: 33px; }
/* History when shallow or narrow: clamp each event to two lines so the rows
   stay bounded and never overflow (narrow cards wrap text into tall rows) */
.card--history.t-s .history__text,
.card--history.t-narrow .history__text {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* Shallow: one line per event so a 2nd row fits the 121px body (after the
   shared rule so it wins the tie when t-s and t-narrow both apply). */
.card--history.t-s .history__text { -webkit-line-clamp: 1; }
/* Air & Sky measured variants (nth-of-type counts ALL div siblings of .sky:
   .sky__stats is div #1, sky rows start at #2).
   Shallow (h<=2): compact dials, sunrise row only. */
.card--aqi.t-s .sky { gap: 8px; }
.card--aqi.t-s .sky__aqi-value { font-size: 36px; }
.card--aqi.t-s .sky__aqi-label { font-size: 17px; }
.card--aqi.t-s .sky__row { font-size: 18px; }
.card--aqi.t-s .sky__row:nth-of-type(n+3) { display: none; }
/* 2-wide: half-columns can't hold the categories — stack the stats full
   width with slightly smaller dials; at 2x3 the moon row goes too. */
.card--aqi[data-w="2"] .sky__stats { grid-template-columns: 1fr; }
.card--aqi[data-w="2"]:not(.t-s) .sky { gap: 8px; }
.card--aqi[data-w="2"]:not(.t-s) .sky__aqi-value { font-size: 40px; }
.card--aqi[data-w="2"]:not(.t-s) .sky__aqi-head { font-size: 15px; }
.card--aqi[data-w="2"]:not(.t-s) .sky__aqi-label { font-size: 17px; }
.card--aqi[data-w="2"][data-h="3"] .sky__row:nth-of-type(n+3) { display: none; }
/* 2x2 minimum: the two dials fill the whole body — no sky rows; value at
   the 34px primary-data floor */
.card--aqi[data-w="2"].t-s .sky { gap: 6px; }
.card--aqi[data-w="2"].t-s .sky__stats { gap: 6px; } /* stacked dials: 12px gap overflows the 121px body by 4 */
.card--aqi[data-w="2"].t-s .sky__aqi { gap: 0; }
.card--aqi[data-w="2"].t-s .sky__aqi-value { font-size: 34px; }
.card--aqi[data-w="2"].t-s .sky__row { display: none; }
/* Art at its smallest (shallow + narrow): caption reduces to the title */
.card--art.t-s.t-narrow .artwork__artist { display: none; }
/* Transit rows tighten when narrow */
.card.t-narrow .train__track { padding: 4px 8px; font-size: 17px; }
.card.t-narrow .train__linechip { padding: 1px 7px; font-size: 15px; }


/* ---------- transit service alerts ---------- */
.talert {
  display: flex; align-items: center; gap: 8px;
  background: var(--warn-tint); border-radius: 8px; /* = .alert's warn tint (one alpha everywhere) */
  padding: 6px 10px; flex: none;
}
.talert__icon { color: var(--warn); font-size: 18px; flex: none; }
/* Two-line clamp (matches the subway alert treatment) — the old single-line
   ellipsis deleted the actionable clause of alerts like "Port Washington
   Branch trains may…". Shallow (t-s) cards keep one line: their row budget
   charges alerts a single row. */
.talert__text {
  color: var(--warn); font-size: 20px;
  overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.card.t-s .talert__text { -webkit-line-clamp: 1; }
.bullet--sm { width: 26px; height: 26px; font-size: 15px; }
.trains { display: flex; flex-direction: column; gap: 10px; min-height: 0; overflow: hidden; flex: 1; } /* fills the body so an .empty inside centers */

/* ---------- subway line-status board ---------- */
.linestatus { display: flex; align-items: center; gap: 14px; min-height: 50px; }
.linestatus__text { font-size: 22px; color: var(--ink-dim); }
.linestatus--alert .linestatus__text {
  color: var(--warn); font-size: 20px; line-height: 1.25;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.linestatus__icon { color: var(--warn); font-size: 18px; margin-left: auto; flex: none; }
/* 2-wide: the ⚠ is redundant (alert rows are already amber) — its ~36px goes
   back to the clamped alert text, which is what makes 2×2 viable at all. */
.card--subway[data-w="2"] .linestatus__icon { display: none; }

/* ---------- bus ---------- */
.buspill {
  display: inline-block; background: #0039a6; color: #fff;
  border-radius: 8px; padding: 2px 10px; font-size: 19px; margin-right: 6px;
}
.train__dist { color: var(--ink-dim); font-size: 18px; line-height: 1.1; display: inline-block; max-width: 76px; }

/* ---------- PATH ---------- */
.path-section { display: flex; flex-direction: column; gap: 8px; }
.path-section + .path-section { margin-top: 8px; }
.path-section__label { font-size: 17px; font-weight: 700; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.08em; } /* AA */
.pathdot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; margin-right: 6px; vertical-align: -1px; }
.pathdot + .pathdot { margin-left: -2px; }

/* ---------- NYC Ferry ---------- */
.ferryroute { display: inline-block; width: 14px; height: 14px; border-radius: 4px; margin-right: 8px; vertical-align: -1px; }

/* ---------- word of the day ---------- */
.wotd { display: flex; flex-direction: column; justify-content: center; height: 100%; gap: 8px; }
.wotd__word { font-size: 40px; font-weight: 700; letter-spacing: -0.01em; }
.wotd__meta { font-size: 20px; color: var(--ink-dim); }
.wotd__def { font-size: 22px; line-height: 1.35; }
.wotd__ex { font-size: 20px; color: var(--ink-dim); font-style: italic; }
/* Shallow cards: smaller word, clamp the definition */
.card--wotd.t-s .wotd__word { font-size: 30px; }
.card--wotd.t-s .wotd__def { font-size: 19px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Narrow cards: smaller word, clamped definition and example, so even
   worst-case entries (long word + long pronunciation) never scroll */
.card--wotd.t-narrow .wotd__word { font-size: 32px; }
.card--wotd.t-narrow .wotd__def { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card--wotd.t-narrow .wotd__ex { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* 2x2 (shallow AND narrow): both rules above apply with equal specificity and
   the later 3-line clamp was winning — the definition then overflowed the
   centered flex column and hard-clipped mid-line. Two lines is the budget. */
.card--wotd.t-s.t-narrow .wotd__def { -webkit-line-clamp: 2; }
/* Shallow budget is ~121px: word 36 + meta + 2-line def + gaps must land
   under it, so the meta drops a step and the column tightens. */
.card--wotd.t-s .wotd { justify-content: flex-start; gap: 5px; }
.card--wotd.t-s .wotd__meta { font-size: 18px; }

/* ---------- edit-mode gesture preview ---------- */
.edit-placeholder {
  border: 3px dashed var(--good);
  border-radius: var(--radius);
  background: var(--good-tint);
  pointer-events: none;
}
.edit-placeholder--invalid {
  border-color: var(--bad);
  background: var(--bad-tint);
}
/* During a resize gesture the placeholder lifts above the blocks (they're
   grid items, so z-index applies): a shrink target lies inside the block's
   footprint and is invisible beneath its opaque background otherwise.
   4 stays under .edit-block.is-dragging's 5. */
.editor__blocks.is-resize-gesture .edit-placeholder { z-index: 4; }
.edit-block__size {
  position: absolute; left: 0; right: 0; bottom: 14px;
  /* Clear the corner controls (resize handle right, delete badge left) so a
     wrapped hint never runs underneath them on small tiles. */
  padding: 0 60px;
  text-align: center; font-size: 18px; color: var(--ink-faint);
  pointer-events: none;
}
.edit-block { flex-direction: column; gap: 4px; }

/* Navigational chrome, not content: small + --ink-dim (--ink-dim stays AA-safe
   at 15px where --ink-faint would drop to 4.49:1). */

/* ---------- Formula 1 ---------- */
.f1-next { margin-bottom: 12px; }
.f1-next__hd { font-size: 22px; font-weight: 600; }
.f1-next__tag { font-size: 15px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: 0.08em; margin-right: 6px; }
.f1-next__meta { font-size: 18px; color: var(--ink-dim); margin-top: 2px; }
.f1-sec { margin-bottom: 12px; }
/* One intra-card section-label system: 17px/700/0.08em (matches PATH). */
.f1-sec__h, .f1-col .f1-sec__h { font-size: 17px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-dim); margin-bottom: 6px; font-weight: 700; }
.f1-row { display: flex; align-items: center; gap: 7px; font-size: 21px; padding: 3px 0; }
.f1-pos { width: 24px; text-align: right; color: var(--ink-faint); font-size: 18px; font-variant-numeric: tabular-nums; flex: none; }
.f1-row--p1 .f1-pos { color: var(--ink); font-weight: 700; } /* amber = warning, not gold */
.f1-dot { width: 11px; height: 11px; border-radius: 50%; flex: none; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.28); }
/* Image flag (flagcdn 4:3) — replaces the emoji glyph, the same image-flag
   treatment Golf and Tennis use. */
.f1-flag { width: 24px; height: 18px; object-fit: cover; border-radius: 3px; flex: none; align-self: center; }
.f1-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.f1-team, .f1-pts { color: var(--ink-dim); font-size: 18px; flex: none; font-variant-numeric: tabular-nums; }
.f1-team { max-width: 42%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.f1-cols { display: flex; gap: 18px; }
.f1-col { flex: 1; min-width: 0; }

/* Formula 1, full screen: the season in three pillars (Sean's approved mockup,
   2026-08-02). Deliberately the DENSEST view on the board — a season has three
   things going on at once (the weekend ahead, the race just run, the two
   championships) and a reader who taps an F1 card wants all three, so the type
   runs 19-22px where every other view reads at 26-40. 19px is the floor and it
   is spent only on the quiet columns the mockup spends it on.

   Widths are fixed per pillar (510 / 570 / 520) and the GROUP is centred, so
   the columns hold their proportions and the block sits in the middle of the
   glass: 1600 of content + 2 x 56 gap = 1712, inside the 1776 the overlay's
   72px side padding leaves. The middle pillar is sized to its own row rather
   than to the mockup's 620: 34 pos + 12 + 12 dot + 26 flag + a surname + 12 +
   52 grid + 12 + 52 points + 12 + 122 gap. Give it more and the name column
   (the flexible one) grows into a gulf between the surname and the numbers
   that belong to it. Nothing here measures itself — the tall pillar is the
   next race, whose map takes the leftover height instead of forcing one. */
.expand__body .f1v {
  height: 100%; display: flex; justify-content: center; gap: 56px; overflow: hidden;
}
/* flex: none — the widths below are the layout, not a starting point. Without
   it a flex item shrinks to fit whatever box it is in, and the pillars would
   quietly narrow (and start ellipsizing surnames) on any viewport the group
   does not fit in rather than staying the columns they were measured as. */
.f1v__pillar { min-width: 0; flex: none; display: flex; flex-direction: column; }
.f1v__pillar--next { width: 510px; }
.f1v__pillar--last { width: 570px; }
.f1v__pillar--stand { width: 520px; }
/* Section rule, shared by all three pillars. Wider tracking than the card's
   labels because it is spanning a 520px column, not a 300px one. */
.f1v__h {
  flex: none; font-size: 19px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--ink-faint); margin: 0 0 14px; padding-bottom: 10px;
  border-bottom: 1px solid var(--bg-card-2);
}
/* ---- pillar 1: the weekend ahead ---- */
.f1v-gp { flex: none; font-size: 40px; font-weight: 600; line-height: 1.1; }
.f1v-gp__sub { flex: none; font-size: 22px; color: var(--ink-dim); margin-top: 8px; }
.f1v-sessions { flex: none; margin-top: 18px; }
.f1v-sess {
  display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
  padding: 8px 0; border-top: 1px solid var(--bg-card-2);
}
.f1v-sess span { font-size: 20px; color: var(--ink-faint); }
.f1v-sess b { font-size: 21px; font-weight: 400; color: var(--ink-mid); font-variant-numeric: tabular-nums; }
/* The race is the row the schedule exists for; the rest are the run-up. */
.f1v-sess--race b { font-weight: 600; color: var(--ink); }
.f1v-count { flex: none; font-size: 20px; color: var(--ink-faint); margin-top: 14px; }
/* The outline eats the pillar's leftover height rather than dictating one, so a
   sprint weekend's two extra schedule rows shorten the drawing instead of
   pushing it off the canvas. preserveAspectRatio (meet, the default) centres a
   circuit of any proportion inside whatever box is left. */
.f1v-track { flex: 1; min-height: 0; display: flex; flex-direction: column; margin-top: 26px; }
.f1v-track__box { flex: 1; min-height: 0; }
.f1v-track__box svg { width: 100%; height: 100%; display: block; }
/* Tier 1: Formula 1's own circuit diagram, hotlinked from their media CDN and
   swapped in over the outline once it has decoded (see the note in
   widgets/f1.js). The artwork is transparent-backed white-and-colour linework,
   so it composites straight onto the overlay's dark canvas — verified against
   the live asset. Contain, never cover: a track map that is cropped is wrong. */
.f1v-track__img { width: 100%; height: 100%; object-fit: contain; display: block; }
.f1v-track__line {
  fill: none; stroke: var(--ink-mid); stroke-width: 5; stroke-linejoin: round; stroke-linecap: round;
}
/* F1 red, this view's one saturated accent, on the start/finish line. Its own
   plain <path>: gen1 Qt WebEngine has no SVG clipPath, so a two-tone line is
   two lines (DESIGN.md). */
.f1v-track__sf { fill: none; stroke: #e10600; stroke-width: 7; stroke-linecap: butt; }
.f1v-track__lbl {
  flex: none; font-size: 19px; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--ink-faint); margin-top: 8px;
}
/* ---- pillar 2: the race just run ---- */
.f1v-last__head { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.f1v-last__id { min-width: 0; }
.f1v-last__name { font-size: 26px; font-weight: 600; line-height: 1.2; }
.f1v-last__sub { font-size: 19px; color: var(--ink-faint); margin-top: 4px; }
/* The same outline as pillar 1, quieter and small: it names the place without
   competing with the classification beside it. */
.f1v-minitrack { flex: none; width: 150px; height: 84px; }
.f1v-minitrack svg { width: 100%; height: 100%; display: block; }
.f1v-minitrack .f1v-track__line { stroke: var(--ink-faint); stroke-width: 6; }
.f1v-res { margin-top: 14px; }
/* Row heights are PINNED (the .expand__head idiom) so twenty rows are twenty
   rows on the board too, whatever face it renders in. The pillar's worst case:
   an 84px head (the mini map, the tallest thing in it) + 14 + a 30px column
   head + 20 x 31 = 748px inside the 814px body. */
.f1v-rrow { display: flex; align-items: baseline; gap: 12px; height: 31px; }
.f1v-drv { font-size: 22px; font-weight: 600; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Grid and race points. Two bare numbers in a row need saying what they are —
   one quiet word each, in the column's own alignment, and never repeated. */
.f1v-cell {
  flex: none; width: 52px; text-align: right; font-size: 19px;
  color: var(--ink-faint); font-variant-numeric: tabular-nums;
}
.f1v-rrow--head { height: 30px; }
.f1v-rrow--head .f1v-cell { font-size: 17px; letter-spacing: 0.06em; text-transform: uppercase; }
/* Fixed, so the times and the gaps make a column rather than a ragged edge. */
.f1v-gap { flex: none; width: 122px; text-align: right; font-size: 20px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.f1v-gap--dnf { color: var(--bad); }
/* F1 purple, the sport's own colour for the fastest lap of the race. It outranks
   the DNF tone by rule order for the same reason it can never co-occur: a car
   that did not finish did not set the race's fastest lap. */
.f1v-gap--flap { color: #b360e6; }
/* ---- pillar 3: the two championships ---- */
.f1v-srow { display: flex; align-items: baseline; gap: 12px; height: 30px; }
.f1v-who { font-size: 21px; font-weight: 600; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.f1v-who small { font-weight: 400; font-size: 19px; color: var(--ink-faint); margin-left: 8px; }
.f1v-wins { width: 44px; text-align: right; font-size: 19px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.f1v-pts { font-size: 20px; color: var(--ink-mid); font-variant-numeric: tabular-nums; }
.f1v__stand2 { margin-top: 24px; }
/* ---- shared ---- */
.f1v-pos { flex: none; width: 34px; font-size: 20px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.f1v-srow .f1v-pos { width: 30px; font-size: 19px; }
.f1v-dot {
  display: inline-block; width: 12px; height: 12px; border-radius: 50%;
  margin-right: 10px; vertical-align: 0;
}
/* The card's flagcdn image flag, at the view's scale (Sean, 2026-08-02: the
   flags come with). Same 4:3 box and same treatment as .f1-flag — a view keeps
   the treatment of the card it came from. */
.f1v-flag {
  display: inline-block; width: 26px; height: 19.5px; object-fit: cover;
  border-radius: 3px; margin-right: 10px; vertical-align: -0.15em;
}

/* ---------- service status ---------- */
.svc { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
.svc__row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
/* Type matches the World Clock rows (they sit side by side on boards):
   name = .wc-row__city (20/dim), state = .wc-row__time (23/700). */
.svc__name { font-size: 20px; color: var(--ink-dim); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Same metrics as the name (20/400/CiscoSansTT): only ink level and the
   issue-bolding differentiate the sides — a size gap here reads as a
   different font even when the family matches. */
.svc__state { font-size: 20px; font-weight: 400; white-space: nowrap; flex: none; }
/* "All fine" rests quiet — green is reserved for deviation (calm-at-rest principle). */
/* Operational reads calm: full ink at regular weight. Only real issues
   (minor/major) go bold, mirroring the bolded name on the left. */
.svc__state--minor { color: var(--warn); font-weight: 700; }
.svc__state--major { color: var(--bad); font-weight: 700; }
.svc__state--unknown { color: var(--ink-faint); }
/* An actual issue lights the LEFT side up too: full ink + bold name
   (unknown states stay quiet — no evidence of a problem). */
.svc__name--alert { color: var(--ink); font-weight: 700; }
/* One per INCIDENT since 2026-08-02, so a degraded row can stack several of
   these. The clamp stays at 1 line and needs no stacking rule: each incident is
   its own box, clamped on its own, which is what keeps a long title from eating
   the line below it. The 2px .svc gap is deliberate at three lines too — the
   lines want to read as one list under their service name, and the separation
   that groups them is the elastic row-gap BETWEEN .svc blocks (10-26px above),
   which is always the larger of the two. Verified in the browser at 3x2, 3x4
   and 3x8; do not widen this gap without re-checking the 3x2 floor, where the
   card has room for one service and nothing to spare. */
.svc__note { font-size: 17px; color: var(--warn); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
.svc--tap { cursor: pointer; }

/* ---------- the ledger, full screen (Cloud Services + TfL Status) ----------
   Sean's pick 2026-08-01, mockup 6B: a TYPOGRAPHIC ledger, deliberately not the
   subway wall's wells. Any trouble leads in reading type with its status prose
   UNCUT — the card clamps that note to one line, and completing it used to cost
   a per-row tap — and everything that is fine settles underneath as two quiet
   columns of hairline rows. No boxes anywhere: hairlines and type carry the
   whole structure, which is what keeps a full eleven-service config calm on one
   canvas. Shared by both cards (site/js/ledger.js builds it); the TfL block
   below adds only the line-colour dot. */
.ledger { height: 100%; overflow-y: auto; }
/* min-height plus centred flex is the pattern that centres a SHORT ledger
   without stranding a long one: once the content is taller than the canvas
   there is no free space left, so the centring goes inert and the top of the
   list can never be cut away above the scroller's origin. The narrow band is
   the point of 6B — a ledger read at arm's length wants a column, not the full
   1776px of glass. */
.ledger__band {
  width: 980px; max-width: 100%; margin: 0 auto; min-height: 100%;
  display: flex; flex-direction: column; justify-content: center;
  padding-top: 16px; /* ~52px clear of the title, per the mockups */
}
.ledger__row { border-bottom: 1px solid rgba(255, 255, 255, 0.10); }
.ledger__row--lead { padding: 18px 0 20px; }
.ledger__head { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
/* A flex box rather than a bare span so TfL's dot can sit beside the name and
   stay centred on it; the text is still what the row aligns on. */
.ledger__name { display: flex; align-items: center; gap: 14px; min-width: 0; font-size: 26px; font-weight: 600; color: var(--ink); }
.ledger__state { flex: none; font-size: 22px; color: var(--ink-dim); white-space: nowrap; }
.ledger__row--lead .ledger__state { font-weight: 600; }
.ledger__state--minor, .ledger__state--alert { color: var(--warn); }
.ledger__state--major { color: var(--bad); }
/* Unknown is a FAILED status fetch, and the CARD has always said so in amber:
   the state word rests quiet there, but "Status unavailable" sits under it in
   --warn (.svc__note). This surface has no second line to carry that — the
   note slot holds the operator's prose in --ink-dim — so the amber rides the
   state word instead, which is where the ledger keeps every tone. Shipped
   neutral 2026-08-01 and corrected the same day (Sean: the ledger had dropped
   an amber the card shows). Still not --bad: a fetch that failed is a "might
   be", never a confirmed outage, and the WORD is what separates it from a
   real minor issue — colour is never the only signal. */
.ledger__state--unknown { color: var(--warn); }
.ledger__note { margin: 10px 0 0; font-size: 22px; line-height: 1.45; color: var(--ink-dim); }
.ledger__note + .ledger__note { margin-top: 14px; }
.ledger__since { color: var(--ink-faint); }
.ledger__update { display: block; margin-top: 6px; color: var(--ink-faint); }
/* The all-clear, in two columns dealt by ledger.js (not multi-column layout:
   the wall deals its own columns for the same reason subway does, so the split
   is testable and the reading order is fixed). */
.ledger__quiet { display: flex; align-items: flex-start; column-gap: 72px; margin-top: 26px; }
.ledger__lead + .ledger__quiet { margin-top: 34px; }
.ledger__col { flex: 1 1 0; min-width: 0; }
.ledger__col .ledger__row { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; padding: 13px 0; }
/* Quiet rows drop to regular weight; the state word stays --ink-dim rather than
   --ink-faint, which fails AA at this size. */
.ledger__col .ledger__name { font-size: 24px; font-weight: 400; color: var(--ink-mid); }
/* No trailing hairline dangling under the last row of a group. */
.ledger__col .ledger__row:last-child,
.ledger__lead:last-child .ledger__row--lead:last-child { border-bottom: none; }

/* ---------- citi bike ---------- */
.cb { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 5px 0; }
.cb__name { font-size: 21px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb__stat { font-size: 20px; color: var(--ink-dim); white-space: nowrap; flex: none; }
/* The operative number — the only count-card whose datum wasn't emphasized. */
.cb__n { font-size: 24px; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }
.cb__stat--off { color: var(--ink-faint); }
.cb__e { color: var(--accent); font-weight: 600; }

/* ---------- citi bike, full screen: one well per station ----------
   Sean's pick 2026-08-01. The card's .cb__n emphasis taken to reading scale:
   BIKES is the operative number, the one fact that decides whether you walk
   over, so it leads each well and the docks/e-bikes line stays secondary. Two
   columns by three rows holds the configured maximum (config.js caps the
   station picker at six), so this grid has no scrolling case in practice —
   overflow-y is the same never-clip backstop the subway wall carries. */
.cbboard { height: 100%; display: flex; flex-direction: column; justify-content: center; overflow-y: auto; padding-top: 16px; }
.cbboard__grid {
  width: 1000px; max-width: 100%; margin: 0 auto;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 40px;
}
.cbwell { display: flex; align-items: center; gap: 28px; min-width: 0; background: var(--bg-card-2); border-radius: 16px; padding: 22px 28px; }
.cbwell__count { flex: none; width: 132px; display: flex; flex-direction: column; align-items: center; gap: 2px; }
/* Momentum's rule is that the big numerals go LIGHT, and 60px is firmly big
   (the rail overlay's 56px minutes are light for the same reason). The board's
   Cisco face ships as a separate FAMILY per weight, so light type has to name
   the family — declared here rather than joining the shared light-numeral list,
   exactly as .wxf__temp does for the weather overlay. */
.cbwell__n {
  font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
  font-weight: 300; font-size: 60px; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums;
}
.cbwell__unit { font-size: 20px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-dim); }
.cbwell__offword { font-size: 24px; color: var(--ink-faint); text-align: center; }
.cbwell__body { min-width: 0; }
.cbwell__name { font-size: 26px; font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cbwell__sub { margin-top: 6px; font-size: 22px; color: var(--ink-dim); }
.cbwell__e { color: var(--accent); font-weight: 600; }
.cbwell--off .cbwell__name { color: var(--ink-dim); }
/* Search-result buttons (weather cities, Citi Bike stations). The container
   had no rule at all — spacing was an accident of inline flow, and the new
   inline-flex .btn removed it, leaving results flush against the keyboard. */
.picklist { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0 18px; max-width: 860px; }
/* Search results for stations already on the card: visible but inert. */
.picklist__item--added { opacity: 0.45; pointer-events: none; }

/* ---------- tfl status ---------- */
.tfl { display: flex; align-items: center; gap: 10px; padding: 5px 0; }
.tfl__dot { width: 14px; height: 14px; border-radius: 50%; flex: none; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3); }
.tfl__name { font-size: 21px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.tfl__status { font-size: 19px; color: var(--ink-dim); white-space: nowrap; flex: none; }
.tfl--alert .tfl__status { color: var(--warn); font-weight: 600; }
.tfl--tap { cursor: pointer; }
/* TfL's half of the shared full-screen ledger above: the line colour, at the
   reading scale that surface is set in (the card's dot is 14px against 21px
   type). Same ring as the card's, so an almost-black line still reads as a
   disc rather than a hole. */
.ledger__dot { flex: none; width: 20px; height: 20px; border-radius: 50%; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3); }
.tflchips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
/* Joined the standard chip primitive: 56px touch floor (was ~41px), 20px type
   floor, outline treatment; the line-colour dot stays inside. */
.tflchip { display: inline-flex; align-items: center; gap: 8px; min-height: 56px; padding: 8px 16px; border-radius: 12px; border: 1px solid rgba(147, 160, 180, 0.16); background: transparent; color: var(--ink); font-size: 20px; cursor: pointer; }
.tflchip__dot { width: 12px; height: 12px; border-radius: 50%; box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3); }
.tflchip--off { opacity: 0.4; }
.pane__subhead, .setup__subhead { font-size: 18px; color: var(--ink-dim); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.06em; }
/* Incident detail rides the shared text viewer; keep its paragraph breaks. */
.text-viewer__body { white-space: pre-line; }

.icon--btn { width: 28px; height: 28px; display: block; margin: 0 auto; }
.gear { display: flex; align-items: center; justify-content: center; color: var(--ink-dim); }
/* nowrap: the widget name never wraps to a second line — the corner note
   (min-width 0) shrinks and ellipsizes first on narrow cards */
.card__title { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; white-space: nowrap; }
.card__asof { font-size: 17px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--ink-dim); white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; } /* AA */
.card.is-stale .card__asof { visibility: hidden; } /* the amber stale stamp takes over */
/* The corner badge: pinned at the truncation point (bottom-right) instead of
   costing a list row. Safe at every card width, and .card__stamp is
   top-anchored, so the badge and the amber "as of" stamp never meet. ONE form
   board-wide (see util.js paintMoreBadge): "+8", the compact count and nothing
   else, on every card hiding something and on no card that is not. It got there
   by three cuts. 2026-08-01: the bare expand mark went for saying nothing a
   board of tappable cards does not already say, and the second count dialect
   ("N more" beside the mark vs a plain "+N" without it) went because one board
   speaking two languages reads as inconsistency before it reads as meaning.
   2026-08-02: the mark itself went from beside the count, because a board
   carrying several count cards showed the same glyph in every corner and it
   read as clutter rather than as information.

   20px, the board's type floor, up from 15px: a signifier that has to be read
   from 6 ft cannot be the smallest thing on the card. --ink-dim, never
   --ink-faint, which fails AA below 18px.

   EQUAL insets, 12px on both axes (2026-08-01). The badge inherited right:26px
   from an earlier text-only "+N", which aligned it with the card's 26px side
   padding because it was reading matter that belonged to the list above it. A
   corner mark is not reading matter, and 26/10 read as dropped in the wrong
   place, the defect Sean shot. What a corner mark owes is the corner: the
   card's 24px radius meets the 45-degree diagonal 7px in, so 12px is the
   smallest equal inset that clears the curve, and it clears it by another 7px.
   The count kept those insets when the glyph left: a count in a corner is the
   same corner mark the glyph was, and the digits sit no closer to the curve.

   Why 12 and not the 14 that would hit 24,24 exactly: the badge only ever
   appears on a card that is FULL, and its 20px line box already reaches 10px
   past the card's 22px bottom padding into the last row. Every further pixel of
   rise is spent against the ~7px of air a centred last-row element (a track
   pill, a service state) keeps below itself. 12 spends 2 of those and grazes
   nothing new; 14 spends 4 and puts the badge on a departure card's track pill.
   The 14px the badge moved RIGHT is free in the other direction: it pulls the
   badge OFF the content column, which ends at 26px, so every list card's last
   row gained 14px of horizontal clearance.

   Nothing owes the corner a reserve any more. A row running flush to the
   card's BOTTOM edge cannot be cleared sideways, so weather and surf used to
   buy the corner back with a --more-gutter; both carried the mark with no
   count, and no card that shows a count has a bottom-flush row, so the token
   and its two payments went out with the bare mark (2026-08-01). Bring both
   back together if a counting card ever grows one. */
.card__more {
  position: absolute; right: 12px; bottom: 12px;
  font-size: 20px; line-height: 1; font-weight: 400; letter-spacing: 0;
  text-transform: none; color: var(--ink-dim); white-space: nowrap;
}
/* (The badge used to be a flex row: an inline SVG expand mark sized 1em and
   centred against the count on an 8px gap. Text alone needs no row and no
   sizing rule, so both went with the glyph on 2026-08-02.)

   (An image card's artwork used to reserve 12px at its floor so the bare mark
   would sit on card surface rather than on a bright photo, where --ink-dim
   vanishes. No image card carries a badge now, since none of them counts
   anything hidden, so the artwork has its full height back: 2026-08-01.) */
/* Rail +N (expand wave 2): the whole card is the tap target (the markets
   grammar, standard .is-expandable affordances above), and the corner badge
   carries the count in the one board-wide form. The first cut's pill trigger
   reserved 46px under .trains and cost exactly one row on exactly-filled
   cards. */
/* (The old has-more bottom fade is gone, 2026-07-31: it predated a badge that
   spelled the overflow out and read as smudge once one did. The class stays
   on the card as a hook; the badge alone is the continues cue.) */

/* ---------- my teams ---------- */
.team { display: flex; align-items: center; gap: 14px; min-height: 64px; }
.team__abbr { width: 64px; font-size: 24px; font-weight: 800; letter-spacing: 0.02em; flex: none; }
.team__info { display: flex; flex-direction: column; min-width: 0; }
.team__name { font-size: 21px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.team__name small { color: var(--ink-faint); font-weight: 400; font-size: 18px; }
.team__line { font-size: 20px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.team--live .team__line { color: var(--ink); font-weight: 600; }
.team__livedot { color: var(--bad); font-size: 14px; }
/* The full-screen My Teams view (Sean's pick): ONE grand centered column
   carrying everything the card row carries, at reading size — the club's own
   artwork, the name with its record beside it, the Last/Next line beneath, and
   the status line held out on the right where the eye can run down it. 980px is
   the band: wide enough for the longest name beside a live score, narrow enough
   that the logo and the score stay in one glance. The cap is the config's own
   six teams, so the column always fills the canvas and never overruns it.

   The row's line-height is PINNED (the .expand__head idiom) so the fit is
   arithmetic rather than a measurement taken in whatever face this happened to
   be modelled in: a two-line stack of 28 + 21 at 1.25 is 61.25px and outgrows
   the 60px logo, so six rows on 40px gaps = 568px inside the 814px body. */
.expand__body .team-board {
  height: 100%; display: flex; flex-direction: column;
  justify-content: center; align-items: center; gap: 40px; overflow: hidden;
}
.team--board { width: 980px; gap: 24px; min-height: 0; line-height: 1.25; }
/* Same wrapper model as the card (see the .team__crest note): the disc is
   painted behind the mark, so nothing clips at any size. The grand row
   affords a 72px disc and a 46px mark, the largest of any attempt. */
.team--board .team__crest { width: 72px; height: 72px; }
.team--board .team__logo { width: 46px; height: 46px; }
.team--board .team__abbr { width: 72px; font-size: 34px; }
.team--board .team__info { flex: 1; line-height: 1.25; }
.team--board .team__name { font-size: 28px; }
/* 21px clears the AA floor that keeps --ink-faint off small text. */
.team--board .team__name small { font-size: 21px; color: var(--ink-dim); }
.team--board .team__last { font-size: 21px; }
.team--board .team__line { flex: none; max-width: 460px; font-size: 25px; text-align: right; }
.team--board .team__livedot { font-size: 17px; }

/* (World Cup's .wc-section / .wc-match / .wc-flag / .flag-inline rules were
   deleted with the widget on 2026-07-29. The .wc-row* rules further up are
   WORLD CLOCK, not World Cup — same prefix, different card.) */

/* Screensaver preview overlay (Settings → Screensaver → Preview): sits above
   the settings UI; any tap closes it. */
.ss-preview { position: fixed; inset: 0; z-index: 60; background: #000; }
/* Preview clock face reserves exactly what ambient's .clockface reserves, off
   the same viewport bottom: 8px of air normally, 84px when the info band is
   shown (70px band + 14px). The two used to disagree by --roomos-bar on every
   device — the preview is viewport-fixed and ambient was page-fixed, so the
   preview drew its clock 20px higher than the screensaver it was previewing.
   Keep these two pairs equal; test/viewport.test.js fails if they drift. */
.ss-preview .cf { bottom: 8px; }
.ss-preview.has-strip .cf { bottom: 84px; }
.ss-preview__img { width: 100%; height: 100%; object-fit: cover; }
/* Above the info strip band (~64px) so the two don't overlap. */
.ss-preview__hint { position: absolute; bottom: 92px; left: 0; right: 0; text-align: center; font-size: 20px; color: var(--ink-dim); z-index: 1; }

/* ---------- clock-face screensavers (Screensaver page: clock / worldclocks / clockrow) ---------- */
/* The clock content centers within .clockface, which reserves the bottom air
   (always) and the info strip's 70px band (when shown) so a wrapped two-row
   world-clock grid centers ABOVE the strip instead of colliding with it. Both
   offsets are measured off the real viewport now — .clockface sits inside the
   position:fixed .ambient — so the old --roomos-bar term is gone: 48px up from
   a 1080 page and 8px up from a 1040 glass are the same line on a board, and
   only the second one is still true on a Navigator. Mirrored by .ss-preview .cf
   above; the two must stay equal or the preview stops being a preview. */
.clockface { position: absolute; top: 0; left: 0; right: 0; bottom: 8px; }
body.has-strip .clockface { bottom: 84px; }
.cf { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.cf-time {
  display: flex; align-items: baseline; gap: 28px;
  font-family: 'CiscoSansTT ExtraLight', 'CiscoSansTT', 'Cisco SansTT', sans-serif;
  font-weight: 200; font-size: 330px; line-height: 1; letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums; color: var(--ink);
}
.cf-time--row { font-size: 250px; }
.cf-ampm { font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', sans-serif; font-weight: 300; font-size: 64px; color: var(--ink-dim); }
.cf-time--row + .cf-date { margin-top: 2px; }
.cf-date { font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', sans-serif; font-weight: 300; font-size: 44px; color: var(--ink-mid); margin-top: 8px; }
.cf-dials { display: flex; flex-direction: column; align-items: center; row-gap: 44px; }
.cf-drow { display: flex; justify-content: center; align-items: flex-start; column-gap: var(--dgap, 110px); }
.cf-dial { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.cf-dial__svg { width: var(--dial, 330px); height: var(--dial, 330px); }
.cf-dial__name { font-weight: 300; font-size: 34px; color: rgba(255, 255, 255, 0.72); }
.cf-dial--night .cf-dial__name { color: var(--ink-dim); }
.cf-dial--home .cf-dial__name { color: var(--ink); }
.cf-dial__time { font-weight: 300; font-size: 26px; color: var(--ink-dim); margin-top: 4px; font-variant-numeric: tabular-nums; }
.cf-dial__sub { color: var(--ink-faint); }
.cf-cities { display: flex; flex-direction: column; align-items: center; row-gap: 30px; margin-top: 84px; }
.cf-crow { display: flex; justify-content: center; column-gap: 72px; }
.cf-city { text-align: center; }
.cf-city__name { font-weight: 300; font-size: 26px; color: var(--ink-dim); }
.cf-city__time { font-weight: 300; font-size: 44px; margin-top: 6px; color: rgba(255, 255, 255, 0.85); font-variant-numeric: tabular-nums; }
.cf-city--night .cf-city__time { color: rgba(255, 255, 255, 0.45); }
.cf-city__sub { font-weight: 300; font-size: 20px; color: var(--ink-dim); margin-top: 2px; }
/* The World Clock card's expansion reuses this analog face VERBATIM
   (clockFaceHtml('worldclocks'), see worldclock.js), and the face is written as
   an absolutely positioned layer filling .clockface. Inside the tap-to-expand
   overlay there is no .clockface, so .cf would resolve against the
   position:fixed .expand instead and cover the title and the "tap anywhere to
   close" hint. Re-flow it as an ordinary block filling the body's canvas: the
   flex centring inside it, the dial scale, the night dimming and the home-city
   treatment are all untouched, which is the whole point of reusing it. */
.expand__body .cf { position: relative; inset: auto; height: 100%; overflow: hidden; }

/* ---------- clock-face photo backdrop (opt-in) ---------- */
/* An optional daily photo behind the clock screensavers. #backdrop sits below
   #clockface in the ambient stack. The scrim keeps the light clock elements
   legible over arbitrary photos: a soft central vignette (darker where the clock
   sits) over a light uniform tint, so the clock reads while the photo still
   breathes at the edges. No blur/backdrop-filter — gen1 Board Pro animates only
   transform/opacity, and a flat scrim is both cheaper and more reliable.
   --ss-scrim is the single knob to dial the darkness up/down. */
:root {
  --ss-scrim: radial-gradient(ellipse 72% 66% at 50% 48%, rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0) 78%), rgba(13, 17, 23, 0.40);
}
.backdrop {
  position: absolute; inset: 0;
  background-color: var(--bg); /* fallback before the image loads / if it fails */
  background-size: cover; background-position: center; background-repeat: no-repeat;
  /* calm fade-in on show. Fill BACKWARDS, not both: a forwards-filled
     animation owns opacity forever and outranks the swipe fade's inline
     styles (swipeFadeThrough), which silently disabled it. */
  animation: backdrop-in 900ms ease-out backwards;
}
.backdrop::after { content: ''; position: absolute; inset: 0; background: var(--ss-scrim); }
/* Let a touch swipe reach the pointer handlers (step to the next backdrop) instead
   of being claimed by the browser's pan gesture — same trick as .slideshow. */
body.ss-backdrop .clockface, body.ss-backdrop .backdrop { touch-action: none; }
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .backdrop { animation: none; } }

/* Legibility lift when a backdrop is on: a soft dark text-shadow on the digital
   text (cheap; the info strip uses the same trick) and a subtle dark disc behind
   each analog dial so it reads as a watch face over the photo. Same treatment in
   the settings preview (.ss-preview.ss-backdrop) so the preview is faithful. */
body.ss-backdrop :is(.cf-time, .cf-date, .cf-ampm, .cf-dial__name, .cf-dial__time,
  .cf-dial__sub, .cf-city__name, .cf-city__time, .cf-city__sub),
.ss-preview.ss-backdrop :is(.cf-time, .cf-date, .cf-ampm, .cf-dial__name, .cf-dial__time,
  .cf-dial__sub, .cf-city__name, .cf-city__time, .cf-city__sub) {
  text-shadow: 0 2px 14px rgba(0, 0, 0, 0.7);
}
body.ss-backdrop .cf-dial__svg, .ss-preview.ss-backdrop .cf-dial__svg {
  border-radius: 50%;
  background: radial-gradient(circle at center, rgba(13, 17, 23, 0.66), rgba(13, 17, 23, 0.42) 72%, rgba(13, 17, 23, 0) 100%);
}

/* Settings preview: mirror the backdrop scrim so a clock preview shows what the
   real screensaver will look like with the photo behind it. */
.ss-preview.ss-backdrop { background-size: cover; background-position: center; }
.ss-preview.ss-backdrop::before { content: ''; position: absolute; inset: 0; background: var(--ss-scrim); }

/* ---------- live video (iptv) ---------- */
.card--iptv .card__body { display: flex; }
.iptv { flex: 1; min-height: 0; display: flex; }
/* contain letterboxes on the near-black card; cover would crop tickers. */
.iptv__video { width: 100%; height: 100%; object-fit: contain; border-radius: 12px; background: #000; }

/* Tap-to-full-screen, in place (no reparent — that aborts a progressive
   stream). Above the art viewer's 45/46 tier, below the editor's 50.
   Compound .iptv.iptv--full (0,2,0) so it beats the later
   .iptv{position:relative} (0,1,0); otherwise full screen never escapes the
   card's flow. Sound exists ONLY here. */
.iptv.iptv--full { position: fixed; inset: 0; z-index: 47; background: #000; }
.iptv--full .iptv__video { border-radius: 0; }
.iptv__mute,
.iptv__expand {
  position: absolute; width: 64px; height: 64px;
  border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(0, 0, 0, 0.55); color: var(--ink);
  display: flex; align-items: center; justify-content: center;
}
/* Lower-right, next to the Desk Pro's physical volume up/down keys. */
/* Clear the RoomOS "Tap to start" bar: at bottom:28px the lower ~12px sat in
   the overlaid 40px zone, cropping the app's only sound control and sending a
   low tap to RoomOS (which exits signage). Matches the gear/ss-preview idiom. */
.iptv__mute { bottom: calc(var(--roomos-bar) + 8px); right: 28px; }
/* pointer-events:none so a tap always targets the button, never the glyph —
   keeps the stopPropagation guard against exiting full screen reliable. */
.iptv__mute svg,
.iptv__expand svg { width: 30px; height: 30px; pointer-events: none; }
/* Camera-share mode: the wrapper is the positioning context for the corner
   button; the card-sized button shrinks so it doesn't dominate a 3x3. */
.iptv { position: relative; }
.iptv .iptv__expand { top: 10px; right: 10px; width: 52px; height: 52px; }
.iptv--full .iptv__expand { top: 28px; right: 28px; width: 64px; height: 64px; }
.iptv__frame { border: 0; background: #000; display: block; }
/* Camera crop: the fixed-viewport iframe is transformed so only the player
   region shows; the wrap clips it to the card's rounded corners. */
.iptv--cam { overflow: hidden; border-radius: 12px; }
.iptv--cam .iptv__frame { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
.iptv--cam.iptv--full { border-radius: 0; }

/* ---------- golf / tennis ---------- */
/* Golf runs denser rows than the citibike elastic family (30px vs 36px). */
.card--golf .card__body { justify-content: center; row-gap: clamp(6px, calc((100% - var(--n, 3) * 30px) / max(var(--n, 3) - 1, 1)), 14px); }
.golf-row { display: flex; align-items: baseline; gap: 10px; min-height: 30px; }
.golf-row__flag { width: 26px; height: 17px; object-fit: contain; flex: none; align-self: center; }
.golf-row__pos { flex: none; width: 34px; text-align: right; color: var(--ink-dim); font-size: 18px; font-variant-numeric: tabular-nums; }
.golf-row__name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 21px; }
.golf-row__today { flex: none; color: var(--ink-dim); font-size: 18px; font-variant-numeric: tabular-nums; }
.golf-row__score { flex: none; font-size: 23px; font-variant-numeric: tabular-nums; min-width: 44px; text-align: right; }
.golf-row__score--under { color: var(--good); }
.tennis-row { display: flex; align-items: baseline; gap: 10px; min-height: 35px; }
.tennis-row__match { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
.tennis-row__match b { font-weight: 600; }
.tennis-row__quiet { color: var(--ink-dim); }
.tennis-row__flag { width: 21px; height: 14px; object-fit: contain; vertical-align: -1px; margin-right: 5px; }
.tennis-row__score { flex: none; font-size: 19px; color: var(--ink-dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.tennis-row--live .tennis-row__score { color: var(--ink); font-weight: 600; }
.tennis-row__live { color: var(--bad); font-size: 13px; }

/* The full-screen leaderboard (Sean's pick): the card's own four fields at
   reading size on hairline rules, dealt into two centered columns so a deep
   field reads DOWN then across. --board-rows (set inline by golf.js) is the
   rows per column, so a short field fills the left column before it spills
   right and never straddles. Twelve is the canvas: with the row's line-height
   PINNED (the .expand__head idiom, so the fit is arithmetic rather than a
   measurement in whatever face this was modelled in) a row is 12 + 12 + 35 +
   its 1px rule = 60px, and twelve of them are 720px inside the 814px body. A
   third column would push the player names under the 20px floor. Flags are
   dropped here — 24 of them turn a leaderboard into bunting, and the names
   carry identity at 26px. */
.expand__body .golf-board {
  height: 100%; display: grid;
  grid-template-columns: 640px;
  grid-template-rows: repeat(var(--board-rows, 12), min-content);
  grid-auto-flow: column;
  align-content: center; justify-content: center; column-gap: 80px; overflow: hidden;
}
.expand__body .golf-board--split { grid-template-columns: repeat(2, 600px); }
.golf-board__row {
  display: flex; align-items: baseline; gap: 16px; line-height: 1.25;
  padding: 12px 0; border-bottom: 1px solid var(--bg-card-2);
}
.golf-board__row .golf-row__pos { width: 46px; font-size: 22px; }
.golf-board__row .golf-row__name { font-size: 26px; }
.golf-board__row .golf-row__today { min-width: 96px; text-align: right; font-size: 22px; }
.golf-board__row .golf-row__score { min-width: 72px; font-size: 28px; }

/* The full-screen tennis view (Sean's pick): a narrow centered column of
   stacked match rows — the matchup on its own line, the score or the start time
   quiet beneath it, so a mixed board of finals and fixtures reads as one list.
   Eight is the canvas, on PINNED line-heights (the .expand__head idiom, so the
   fit is arithmetic rather than a measurement in whatever face this was
   modelled in): a 32.5 + 4 + 28.75 = 65.25px row on a 28px rhythm puts eight
   rows at 718px inside the 814px body, and nine at 811px with nothing left. */
.expand__body .tennis-board {
  height: 100%; display: flex; flex-direction: column;
  justify-content: center; align-items: center; gap: 28px; overflow: hidden;
}
.tennis-board__row { width: 980px; display: flex; flex-direction: column; gap: 4px; }
.tennis-board__match { font-size: 26px; line-height: 1.25; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tennis-board__score { font-size: 23px; line-height: 1.25; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.tennis-board__row.tennis-row--live .tennis-board__score { color: var(--ink); font-weight: 600; }
.tennis-board .tennis-row__flag { width: 27px; height: 18px; margin-right: 8px; }
.tennis-board .tennis-row__live { font-size: 16px; }

/* ---------- headlines ---------- */
/* Source + age on a small meta line above the full-width headline; the
   headline clamps to two lines (one in shallow cards) — no marquee, per the
   calm-motion rule. */
.headline { display: flex; flex-direction: column; gap: 3px; }
.headline__meta { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
/* Source + age are secondary metadata: a quiet grey unit, NOT a per-row accent
   eyebrow. The old blue-700-UPPERCASE-tracked treatment (4 emphasis channels on
   the least-important element, repeated every row) out-shouted the headline at a
   glance. Labels are stored title-case ("NYT Top Stories") so dropping uppercase
   reads naturally; --ink-dim keeps both AA-safe at 15px (--ink-faint was 4.49:1). */
.headline__src { font-size: 15px; font-weight: 600; color: var(--ink-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.headline__age { font-size: 15px; color: var(--ink-dim); flex: none; }
/* The headline is the content — weight 600 makes it the salience winner. */
.headline__title { font-size: 21px; font-weight: 600; line-height: 1.25; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-width: 0; }
.card--news.t-s .headline__title, .card--marketsnews.t-s .headline__title, .card--sportsnews.t-s .headline__title, .card--substack.t-s .headline__title, .card--bsky.t-s .headline__title { -webkit-line-clamp: 1; }
/* Shallow news cards tighten the row gap so a 2nd one-line headline fits the
   121px body (2 rows + 10px gap runs 3.5px over; 6px clears it). */
.card--news.t-s .card__body, .card--marketsnews.t-s .card__body, .card--substack.t-s .card__body, .card--bsky.t-s .card__body { gap: 6px; }
/* Fill-to-fit's slack spender: the final headline rides leftover space with a
   one-line ellipsized title (renderHeadlines adds this class only when the
   full item wouldn't fit). */
.headline--clamp .headline__title { -webkit-line-clamp: 1; }

/* ---------- the news family's expanded reading list (wave 3) ----------
   Every headline the card holds, full screen, at reading size. It reuses the
   card's own .headline markup (the history/rail overlays do the same) so the
   row a reader taps here is the row they tapped there, and the story view
   reads its source/age/link back off identical elements.

   It NEVER scrolls. newscore.js caps the item count to listRows() *
   listColumns() instead, and every number that sum depends on is pinned here
   as a fixed px so the arithmetic holds on a board whose font is not the one
   this was measured in — the same contract as .expand__head's pinned height.
   Change a number here and you must change its twin in newscore.js:
     24px  .news-board .headline__meta line-height   (LIST_META_H)
      4px  .news-board .headline row gap             (LIST_META_GAP)
     34px  .news-board .headline__title line-height  (LIST_TITLE_LH)
     8+1+10px  row padding + hairline + row-gap      (LIST_ROW_GAP = 19)
     16px  padding-top                               (LIST_TOP_PAD)
   Worst case at three columns: 7 rows * 96px + 6 * 19px = 786 of the 798px
   left after the padding (an 8th row would need 901). overflow:hidden is the
   backstop only. */
.expand__body .news-board {
  height: 100%; box-sizing: border-box; padding-top: 16px;
  display: grid; grid-auto-flow: column;
  grid-template-rows: repeat(var(--list-rows, 7), min-content);
  align-content: center; justify-content: center;
  column-gap: 70px; row-gap: 10px;
  margin: 0 auto; overflow: hidden;
}
/* Centered, widening with the column count: one grand column reads at the
   story view's measure, two hold the ~66% band, and the three-column list
   flexes past it (Sean's approved 20-item layout ran ~1640px). */
.news-board--c1 { grid-template-columns: 900px; max-width: 900px; }
.news-board--c2 { grid-template-columns: repeat(2, 600px); max-width: 1270px; }
.news-board--c3 { grid-template-columns: repeat(3, 500px); max-width: 1640px; }
/* Reading size. The meta line keeps the card's order (source and age ABOVE the
   headline) and its quiet grey, but climbs to the 20px floor — at overlay
   distance the card's 15px micro-metadata is simply unreadable. */
.news-board .headline { gap: 4px; padding-bottom: 8px; border-bottom: 1px solid var(--bg-card-2); }
/* The separator divides rows, so the last row of each COLUMN drops it (it would
   otherwise underline nothing). newscore.js marks those rows, because no
   selector can name "last in its grid column" — and dropping the rule is also
   what makes the 96px row model exact for the tallest column. */
.news-board .headline--tail { padding-bottom: 0; border-bottom: none; }
.news-board .headline__meta { line-height: 24px; }
.news-board .headline__src, .news-board .headline__age { font-size: 20px; }
/* Weight 400, NOT the card's 600 (Sean, 2026-08-01: "the expand view font feels
   heavy/thick/loud... not comfortable to scan to find something to read"). Bold
   buys salience by being RARE: three headlines on a card are three things worth
   noticing, but twenty-one of them are a wall where nothing leads and the eye
   has no rest to move between. Size and full ink still separate the headline
   from its metadata — this list has no competition to win, only a column to be
   read down. The colours are untouched, and the card keeps its 600. */
.news-board .headline__src { font-weight: 400; }
.news-board .headline__title { font-size: 26px; font-weight: 400; line-height: 34px; -webkit-line-clamp: 2; }
/* A row with a story behind it presses; one without is inert and closes the
   overlay like any other tap (newscore.js marks only the former). */
.news-board .headline--more { padding-left: 12px; padding-right: 12px; margin-left: -12px; margin-right: -12px; }

/* THE CIRCLE IS A WRAPPER, NEVER THE IMAGE. border-radius on a replaced
   element trims its content to the CONTENT-EDGE curve (the spec's own rule),
   whose radius shrinks by the padding — so a rounded <img> masks every logo
   with a small circle no padding value can escape. Sean diagnosed it from the
   glass (2026-08-02): "an invisible ring mask overlayed at a specific padding
   measurement. The size of the images was never a problem." Two padding
   rounds proved him right. The disc is painted BEHIND the mark by
   .team__crest; the img itself has no radius and can never be cut. The disc
   spends the height the row already reserves (64px min-height). */
.team__crest { width: 60px; height: 60px; border-radius: 50%; background: rgba(232, 237, 244, 0.08); flex: none; display: flex; align-items: center; justify-content: center; }
.team__logo { width: 38px; height: 38px; object-fit: contain; flex: none; }
.team__last { font-size: 17px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* AA */
/* Shallow cards: two compact rows fit (a 3×2 showed just one team over dead
   space) — smaller logo/type, no "Last:" line; the result line carries it. */
.card--sports.t-s .team { min-height: 0; gap: 10px; }
.card--sports.t-s .team__crest { width: 36px; height: 36px; }
.card--sports.t-s .team__logo { width: 22px; height: 22px; }
.card--sports.t-s .team__abbr { width: 36px; font-size: 18px; }
.card--sports.t-s .team__name { font-size: 19px; }
.card--sports.t-s .team__line { font-size: 18px; }
.card--sports.t-s .team__last { display: none; }
/* Medium cards (h 3-4): shave the row footprint a touch so a 3×3 packs 3
   full three-line teams (name+record / matchup / Last) without clipping any
   data — worst-case 3 rows measured 233px in a 234px body before this, too
   tight for the board's taller CiscoSansTT metrics. ~9px/row of headroom. */
/* (t-m body gap removed: it outranked the elastic row-gap clamp above and
   froze My Teams at 8px gaps — the clamp's 10px floor still fits 3 worst-case
   rows at 3x3.) */
.card--sports.t-m .team { min-height: 0; gap: 12px; }
.card--sports.t-m .team__info { line-height: 1.2; }
.card--sports.t-m .team__name { font-size: 20px; }
.card--sports.t-m .team__name small { font-size: 17px; }
.card--sports.t-m .team__line { font-size: 19px; }
.card--sports.t-m .team__last { font-size: 16px; }

/* ---------- on-screen keyboard (OSK) ---------- */
/* Keyboards live on the same 860px left spine as the pane content — the old
   uncapped block centered its rows across the full pane, putting the keys a
   ~700px saccade away from the field the user is reading. Rows stay centered
   WITHIN the block (that's what keeps the QWERTY stagger). */
.osk { display: flex; flex-direction: column; gap: 10px; max-width: 860px; }
.osk__display { display: block; font-size: 52px; letter-spacing: 0.12em; min-height: 64px; margin: 10px 0; word-break: break-all; }
.osk__row { display: flex; gap: 10px; justify-content: center; }
.osk__key { min-width: 72px; min-height: 64px; font-size: 28px; }
.osk__key--wide { min-width: 150px; }
/* The space bar: wide like a real keyboard's, with a dim label so "space"
   reads as a key name, not typed text. */
.osk__key--space { min-width: 240px; font-size: 20px; color: var(--ink-dim); }
.osk__key--primary { background: var(--accent); color: #06131f; font-weight: 700; }
.osk__key.is-on { background: rgba(88, 166, 255, 0.35); }

/* ==================== Theme: Momentum ====================
   Cisco-Momentum-inspired: pure-black canvas, solid neutral surfaces with a
   hairline edge, white-alpha ink ramp, mint/salmon semantics, light-weight
   numerals. Pure token overrides + a few scoped treatments — card markup and
   every capacity/size number are untouched. (Palette values follow the public
   momentum-design tokens; no Cisco fonts or marks ship with this.) */
/* (Momentum token block folded into :root and body, 2026-07-19.) */
/* Cards on pure black need a hairline edge to hold their shape. */
.card { border: 1px solid rgba(255, 255, 255, 0.07); }
/* Momentum's signature: the big numerals go light (300), not bold. */
.topbar__time,
.wx-now__temp,
.index__price,
.delta,
.train__min > span,
.wc-row__time,
.golf-row__score,
.wx-trend__row:not(.wx-trend__row--hours) > span,
.sky__aqi-value,
.strip__time,
.strip__temp,
.strip__transit b,
.history__year {
  /* Deterministic Light: the device exposes 'CiscoSansTT Light' as its own
     family (Diagnostics inventory) — don't rely on the base family
     resolving a 300 request. Absent families fall through harmlessly. */
  font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  font-weight: 300;
}
/* Ambient text over photos gets the native depth treatment (Cisco's Webex
   Frame ships this exact shadow on the same gen-1 hardware; the no-shadow
   rule is for animated elements, and these sit static above the slides). */
.strip,
.slide-caption { text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); }
/* The semibold tier (600 in the base design) snapped UP to full Bold on the
   board — too heavy (and Cisco Bold blends the ff in "office"). The device
   exposes 'CiscoSansTT Medium' as its OWN family (Diagnostics inventory,
   board-verified), so lead the stack with it for a TRUE middle weight; off
   Cisco hardware the family is absent and weight 500 picks the system
   Medium. The 700 tier stays intentionally bold. */
:is(.topbar__greeting, .card__title, .artwork__title,
  .slide-caption__title, .headline__title, .wx-now__label,
  .stop-group__name, .train__dest, .alert, .f1-next__hd,
  .wc-row__day, .pane__label, .row__value, .kv-grid > b,
  .settings__navitem.is-active, .edit-block__title,
  .welcome__inner h1) {
  font-family: 'CiscoSansTT Medium', 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  font-weight: 500;
}
/* Buttons take Webex Frame's native pill language: white fill for the
   primary action, transparent with a white-alpha ring otherwise, regular
   weight. Sizes stay ours (64px touch floor for arm's-length boards). */
.btn {
  border-radius: 999px;
  background: none;
  border: 2px solid rgba(255, 255, 255, 0.3);
  font-weight: 400;
}
.btn:active { background: rgba(255, 255, 255, 0.11); }
.btn--primary {
  background: rgba(255, 255, 255, 0.95);
  border-color: rgba(255, 255, 255, 0.3);
  color: rgba(0, 0, 0, 0.95);
}
.btn--primary:active { background: rgba(255, 255, 255, 0.8); }
.btn--ghost { border-color: transparent; background: none; }
/* Two-tap Remove: the ghost stays calm (no container) at rest; ARMING is the
   ignition — a persistent red pill, not just red text, so the destructive
   state reads as a state and not a flicker of :active. Must sit after
   .btn/.btn--ghost/.btn:active — same specificity, order decides. */
.btn--armed { color: var(--bad); border-color: rgba(252, 139, 152, 0.45); background: var(--bad-tint); }
.btn--armed:active { background: #6a1418; }
/* Segmented pickers and chips join the pill language: round radius, the
   white-alpha ring, and selection = the white fill (accent stays for data,
   not control chrome). The 2px divider stays (Sean's explicit earlier pick).
   Keyboard keys keep their tile grid — a typing surface, not actions. */
.segmented { border-radius: 999px; border-color: rgba(255, 255, 255, 0.3); background: none; }
.seg { color: var(--ink-dim); }
.seg + .seg { border-left-color: rgba(255, 255, 255, 0.3); }
.seg.is-active { background: rgba(255, 255, 255, 0.95); color: rgba(0, 0, 0, 0.95); font-weight: 500; }
.chip,
.tflchip { border-radius: 999px; border: 2px solid rgba(255, 255, 255, 0.3); }
.chip--on { background: rgba(255, 255, 255, 0.95); border-color: rgba(255, 255, 255, 0.3); color: rgba(0, 0, 0, 0.95); }
/* Rail Save/Cancel: equal-width pills (the wider label sets the shared grid
   track, floored by the 216px wordmark) centered in the rail — the reference
   ratio, not full-width slabs. Edit-mode's Cancel earns the ring + full ink —
   it's a peer action beside Done, unlike the quiet inline ghosts ("Remove
   name") which stay borderless. */
.settings__railfoot { display: grid; justify-content: center; }
.settings__railfoot .btn { min-width: 0; padding: 0 32px; }
.editor__actions .btn--ghost { border-color: rgba(255, 255, 255, 0.3); color: var(--ink); }
/* The resize placeholder overlays the tile (z-index 4 during the gesture) —
   its fill must stay translucent so the underlying card shows through.
   Momentum's solid wells (right for banners) blanked it out. */
.edit-placeholder { background: rgba(60, 194, 154, 0.12); }
.edit-placeholder--invalid { background: rgba(252, 139, 152, 0.14); }
/* Edit tiles join the card treatment: white-alpha edge instead of the base
   blue-grey hairline (invisible-ish on pure black). */
.edit-block { border-color: rgba(255, 255, 255, 0.14); }
/* Empty grid cells: the base blue-grey dashed hairline is near-invisible on
   pure black, so the drop targets vanish. Brighter white-alpha dashes. */
.editor__cell { border-color: rgba(255, 255, 255, 0.18); }
/* The widget tray joins the pill language — the base blue-grey hairlines are
   hard to see on pure black. Disabled chips dim the ring but keep the shape. */
.edit-tray__chip,
.edit-tray__toggle { border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 999px; }
.edit-tray__chip:active { border-color: var(--accent); }
.edit-tray__chip:disabled { border-color: rgba(255, 255, 255, 0.12); }
/* Group inset: NOT a full pill — with two rows of chips the 999px end-caps
   crowd the corner chips. 28px (half a chip) echoes the chip curve instead. */
.edit-tray__group { border: 1px dashed rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.05); border-radius: 28px; padding: 10px 14px; }

/* ---------- Diagnostics display test ---------- */
/* Full-screen alignment pattern for board captures. Static gradients only
   (gen1-safe). Tap anywhere to dismiss. z 60 tops the editor's 50. */
.displaytest {
  position: fixed; inset: 0; z-index: 60; background: #000; cursor: pointer;
  background-image:
    repeating-linear-gradient(to right, rgba(0, 255, 102, 0.8) 0 1px, transparent 1px 100px),
    repeating-linear-gradient(to bottom, rgba(0, 255, 102, 0.8) 0 1px, transparent 1px 100px);
  border: 2px solid #00ff66;
  font-family: ui-monospace, Menlo, monospace;
}
.displaytest__info { position: absolute; top: 12px; left: 116px; color: #00ff66; font-size: 20px; }
.displaytest__lx { position: absolute; top: 2px; margin-left: 3px; color: #00ff66; font-size: 12px; }
.displaytest__ly { position: absolute; left: 4px; margin-top: 2px; color: #00ff66; font-size: 12px; }
.displaytest__safe { position: absolute; left: 0; right: 0; bottom: 84px; border-top: 1px dashed #ffd83d; }
.displaytest__safe span { position: absolute; right: 10px; bottom: 4px; color: #ffd83d; font-size: 14px; }
.displaytest__unsafe {
  position: absolute; left: 0; right: 0; bottom: 0; height: 40px;
  background: repeating-linear-gradient(45deg, rgba(255, 70, 70, 0.35) 0 12px, transparent 12px 24px);
  border-top: 2px solid #ff5050;
}
.displaytest__unsafe span { position: absolute; right: 10px; top: 6px; color: #ff6b6b; font-size: 14px; }

/* Momentum weather type: the hero temperature goes properly thin (the device
   exposes CiscoSansTT ExtraLight) and a step smaller; tight negative tracking
   relaxes for the thin face; day-chip highs calm from bold to Medium. */
.wx-now__temp {
  font-family: 'CiscoSansTT ExtraLight', 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  font-weight: 200;
  font-size: 72px;
  letter-spacing: -0.01em;
}
.card--weather:not(.t-narrow) .wx-now__temp { font-size: 84px; }
.wx-day__hi {
  /* Chip highs join the Light-numerals voice (same face as the clock times
     and hourly temps) — was Medium 500. */
  font-family: 'CiscoSansTT Light', 'CiscoSansTT', 'Cisco SansTT', -apple-system, 'SF Pro Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
  font-weight: 300;
}
/* The shared tile alpha (--bg-card-2, .11) is tuned for interactive tiles;
   on the passive forecast chips it reads as the brightest surface on the
   board. Dim just the chips — buttons/keys/nav keep the .11 idiom. */
.wx-day { background: rgba(255, 255, 255, 0.07); }

/* ---------- in-card image cross-fade (art, photos, landscapes, APOD) ---------- */
/* The frame takes the flex slot the single <img> used to hold, so the geometry
   is unchanged; its layers are absolute, which also means a swap can never push
   the caption around. Two layers exist only for the length of one dissolve —
   the outgoing one is removed when the transition ends. */
/* The frame's own surface is the quiet stand-in a card shows while its FIRST
   bitmap decodes: the tile well, the same surface the empty states sit on,
   instead of seconds of black card (the chart card's boot, worst on a cold
   network). It needs no JS and no timer of its own, because the layer above it
   carries `background: var(--bg-card)` — the picture fading up covers the well
   exactly as it becomes opaque, and the frame is never seen again (a
   letterboxed chart keeps its card-tone bars). `:empty` is the guard for a card
   with no image at all (an art manifest that came back empty): no layer, no
   well, just the card. */
.artwork__frame {
  position: relative; flex: 1; min-height: 0;
  background: var(--bg-card-2); border-radius: 10px;
}
.artwork__frame:empty { background: none; }
/* Opacity only, no filters, no layout properties: this runs every rotation,
   all day, on a gen1 web engine. object-fit is deliberately NOT set here so
   .artwork--contain keeps deciding crop vs letterbox. 700 ms sits between the
   2.5 s ambient dissolve (whole glass) and the 150-250 ms UI register (chrome),
   on the project's standard ease-out curve. */
.artwork__frame > .artwork__img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  transition: opacity 700ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Incoming layer starts invisible; it is only ever added to the DOM once its
   bitmap has decoded, so removing this class dissolves in a finished picture. */
.artwork__frame > .artwork__img.is-entering { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .artwork__frame > .artwork__img { transition: none; }
}

/* ---------- Settings → What's new (js/settings/whatsnew.js) ----------
   The /info changelog's geometry at board scale. Deliberately NOT cards: a
   changelog is a list, and reusing the row rhythm the settings pane already has
   keeps it from becoming its own little design (the same reasoning as the
   .log block in css/info.css, which this is the 6 ft port of — same class
   names, same rhythm, retuned; info.css is a separate page's stylesheet and is
   never loaded here).

   Type is 1.5x the web page's — 24px body against /info's 16 — which is the
   ratio the rest of this stylesheet already runs at (.row__label 24, .kv-grid
   22, .pane__hint 21). Nothing here drops below 20px. */
.pane__head { display: flex; align-items: center; gap: 16px; }
.pane__head .pane__title { margin-bottom: 0; }
.log { display: flex; flex-direction: column; max-width: 1460px; margin-top: 26px; }
.log__group { display: flex; flex-wrap: wrap; gap: 6px 32px; padding: 22px 2px; border-top: 1px solid var(--bg-card-2); }
/* The date gutter, sized to hold the longest label the file actually carries
   ("July 20 and 21") on one line at 24px. */
.log__date { font-size: 24px; font-weight: 600; color: var(--ink-dim); margin: 0; flex: 0 0 220px; }
/* The items flow into two measure-sized columns rather than running one strip
   down the left of a 1,460px row. MULTICOL, not the grid /info uses: at board
   scale the items are three and four lines tall, and a grid aligns its rows —
   so a two-line note beside a six-line note leaves a four-line hole. Multicol
   balances by height and packs them. (The Widgets pane's .wgroups ran the same
   `columns: 2` until that pane became a hand-off to edit mode, 2026-08-01;
   this is the last multicol left.) Reading order becomes column-wise, which is
   what a two-column list wants anyway.
   The `width` (not `count`) form degrades to one column if the pane is ever
   narrower, and 520px is ~43ch at 24px so a full pane yields two ~50ch
   columns — inside the 45-75ch band where the eye finds the next line without
   hunting. */
.log__items { columns: 520px 2; column-gap: 40px; flex: 1 1 320px; min-width: 0; }
/* An item never straddles a column break, and the cap keeps a lone item inside
   the measure when there is only one column to fill — the job --measure does
   on /info. */
.log__item { font-size: 24px; line-height: 1.45; color: var(--ink-mid); margin: 0 0 16px; text-wrap: pretty; max-width: 66ch; break-inside: avoid; }
.log__item:last-child { margin-bottom: 0; }
.log__lead { font-weight: 600; color: var(--ink); margin-right: 0.4em; }

/* The disclosure for older groups. Calm at rest — one more quiet row in the
   list, not a button competing with the copy — and it ignites only on press
   (the calm-affordance rule). 64px is the pane's control height (.btn, .row),
   comfortably over the 44px touch floor. */
.log__toggle {
  font: inherit; font-size: 22px; text-align: left;
  display: flex; align-items: center; gap: 12px;
  min-height: 64px; padding: 0 2px; margin: 0; width: 100%;
  background: none; border: 0; border-top: 1px solid var(--bg-card-2);
  color: var(--ink-dim); cursor: pointer;
}
.log__toggle:active { color: var(--accent); }
.log__count { color: var(--ink-faint); font-size: 20px; } /* AA: 20px is the --ink-faint floor */
/* A control has to look like one before it is touched, so the caret carries the
   affordance the calm colour deliberately does not. Down while there is more
   below, up once there is not. */
.log__toggle::after {
  content: ""; display: inline-block; width: 0.42em; height: 0.42em;
  margin-left: 4px;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: translateY(-0.15em) rotate(45deg);
  transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.log__toggle[aria-expanded="true"]::after { transform: translateY(0.1em) rotate(-135deg); }
.log__more { display: flex; flex-direction: column; }
.log__more[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
  .log__toggle::after { transition: none; }
}
/* Fetch failed, or the file came back unusable. Quiet: an absence, not an error.
   Full width so its hairline matches a group's rather than stopping short at a
   measure; EMPTY_COPY is a module constant kept to a single line, so there is
   no long prose here to cap. */
.log__empty { font-size: 24px; line-height: 1.45; color: var(--ink-dim); margin: 0; padding: 22px 2px; border-top: 1px solid var(--bg-card-2); }
/* min-height:100% resolves against .settings__pane's definite height (the
   settings grid row on the fixed 1080px page), so the column is exactly the
   pane's content box — 1080 − 44 top − 128 bottom = 908px — and its
   scrollHeight equals the pane's clientHeight when the content is short. That
   equality matters: fitChangelog tests `scrollHeight > clientHeight`, so a
   full-height wrapper must not itself register as overflow. (The pane's 128px
   bottom reserve also means anything the fit keeps ends by y=952, well clear
   of a Board Pro's 1040 glass.) */
.whatsnew { display: flex; flex-direction: column; min-height: 100%; }
/* Closing hairline + the one fact this pane can offer that no other Settings
   surface does: which version is running. margin-top:auto puts it on the pane's
   floor, so the air a lumpy history leaves behind sits INSIDE the panel instead
   of trailing off the bottom of it. 20px is the --ink-faint floor, so this
   takes --ink-dim. */
.log__foot {
  font-size: 20px; color: var(--ink-dim); max-width: 1460px;
  margin-top: auto; padding: 22px 2px 0; border-top: 1px solid var(--bg-card-2);
}
