:root {
  color-scheme: light;
  font-family: "Inter", sans-serif;
  --ink: oklch(0.14 0.01 250);
  --ink-strong: oklch(0.08 0.005 250);
  --muted: oklch(0.16 0.01 250);
  --glass: oklch(0.96 0.005 250 / 0.58);
  --glass-border: oklch(1 0 0 / 0.55);
  --blue: oklch(0.78 0.1 232);
  --red: oklch(0.58 0.22 25);
  /* .content-row/.content-left/.main-grid/.right-rail/.rail-subgrid/
     .profile-cards keep a fixed 22px gap (not this variable) above
     STACK_BREAKPOINT because app.js's column-width math (GRID_GAP,
     PROFILE_CARD_GAP) assumes those exact values. Below the breakpoint
     that math doesn't run, so the @media (max-width: 900px) block
     switches all of them to this same fluid value as .kiosk-shell's own
     gap - otherwise, once every container stacks into one visual column
     of cards, the fixed-vs-fluid gaps next to each other look uneven.
     Keep this in sync with .kiosk-shell's "gap" value below. */
  --stack-gap: clamp(12px, 1.6vh, 22px);
  /* The ticket/maintenance/room rail and the announcement/news/quote
     stack both read as a tighter sub-group than the rest of the page by
     shaving 8px off --stack-gap (see .right-rail/.rail-subgrid below, and
     .news-row/.fact-row's matching margin-top). A flat -8px was fine
     while --stack-gap's own floor was 12px (net >= 4px), but the
     short-screen override below drops that floor to 8px - the same flat
     -8px against an 8px floor collapses the gap to 0, and the two
     sub-groups looked like they'd merged into one on a real laptop
     display. The max(4px, ...) floor keeps them visibly grouped without
     ever fully touching. */
  --group-gap: max(4px, calc(var(--stack-gap) - 8px));
  /* Dialogs sit on a near-white background, unlike the glass cards above -
     --ink/--muted are tuned for glass-over-photo contrast and read as
     near-black there, so dialogs get their own lighter gray scale. These
     consolidate ~8 near-duplicate one-off oklch() grays that were
     previously scattered across the dialog rules below. */
  --dialog-strong: oklch(0.2 0.02 250);
  --dialog-muted: oklch(0.45 0.02 250);
  --dialog-faint: oklch(0.5 0.02 250);
  --dialog-title-size: clamp(24px, 2.2vw, 32px);
  --dialog-label-size: clamp(11px, .8vw, 13px);
  --dialog-body-size: clamp(15px, 1.05vw, 18px);
  --dialog-meta-size: clamp(12px, .85vw, 15px);
  --primary-title-size: clamp(20px, 1.5vw, 30px);
  --primary-subtitle-size: clamp(12px, .8vw, 15px);
  --primary-reading-size: clamp(32px, 3.96vw, 76px);
  --primary-detail-size: clamp(14px, 1.15vw, 22px);
}

*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { position: relative; background: oklch(0.15 0.018 250); color: var(--ink); }
button { font: inherit; }

/* Scrolling follows actual rendered content rather than device type: when
   everything fits there is no scroll range; when it does not, every widget
   remains reachable. */
.kiosk-viewport { position: fixed; inset: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: none; background: oklch(0.15 0.018 250); }

/* .kiosk-scale used to be a fixed 1920x1080 canvas, uniformly scaled via JS
   transform to fit the viewport (letterboxed). It's now a plain fluid
   container - the layout below reflows natively instead of being scaled. */
.kiosk-scale { position: relative; z-index: 2; width: 100%; height: auto; min-height: 100vh; min-height: 100lvh; }

/* position: fixed (not absolute) is required here: .kiosk-viewport scrolls
   internally when stacked content is taller than the screen (see above),
   and an absolutely-positioned element scrolls along with its scrolling
   ancestor's content. Fixed positioning pins the background to the actual
   browser viewport instead, so it stays put and full-bleed no matter how
   far the content underneath scrolls. */
#background-layer {
  position: fixed;
  inset: 0;
  z-index: 0;
  background-color: oklch(0.15 0.018 250);
}
.bg-layer {
  position: absolute;
  inset: 0;
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
  opacity: 0;
  transition: opacity 1.2s ease;
}
.bg-layer.active { opacity: 1; }
.background-scrim { position: fixed; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(180deg, oklch(0.1 0.02 250 / 0.42), oklch(0.1 0.02 250 / 0.18) 35%, oklch(0.1 0.02 250 / 0.18) 65%, oklch(0.1 0.02 250 / 0.48)); }
.kiosk-shell {
  position: relative;
  width: 100%;
  height: auto;
  /* Tie the flex canvas directly to the visible viewport. A percentage
     min-height through the auto-height .kiosk-scale parent was indefinite,
     so spare vertical space could collect below the dashboard and make the
     bottom margin look larger than the top margin. */
  min-height: 100vh;
  /* lvh deliberately ignores transient browser chrome. Safari changes dvh
     when its top controls appear on hover, which made the dashboard jump
     between two vertical layouts even though the physical display had not
     changed. The kiosk canvas should follow the stable display-sized
     viewport, even when browser controls temporarily cover part of it. */
  min-height: 100lvh;
  max-width: 2400px;
  margin: 0 auto;
  padding: clamp(18px, 2.6vw, 50px) clamp(18px, 3.1vw, 60px);
  display: flex;
  flex-direction: column;
  gap: var(--stack-gap);
}

.datetime-header { display: flex; align-items: flex-end; justify-content: space-between; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
#datetime-weekday { font-size: clamp(14px, 1.15vw, 22px); font-weight: 600; color: #fff; letter-spacing: .02em; text-shadow: 0 2px 12px oklch(0 0 0 / .5); line-height: 1; }
#datetime-date { margin-top: 6px; font-size: clamp(20px, 1.875vw, 36px); font-weight: 700; color: #fff; letter-spacing: -.01em; text-shadow: 0 2px 12px oklch(0 0 0 / .5); line-height: 1; }
#datetime-time { color: #fff; font-size: clamp(40px, 6.25vw, 120px); font-weight: 800; line-height: 1; letter-spacing: -.03em; font-variant-numeric: tabular-nums; text-shadow: 0 2px 16px oklch(0 0 0 / .5); }

.content-row { flex: 1; min-height: 0; display: flex; gap: 22px; }
.content-left { flex: 4 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 22px; }
/* flex: 1 1 0 (a literal length), not the "flex: 1" shorthand's 0%
   (a percentage) - a percentage flex-basis falls back to content-based
   sizing whenever the flex container's own height is indefinite (which
   .content-left's is, being itself grow-resolved), so "flex: 1" here
   was silently giving this row a large content-based starting size
   while .profile-cards' own literal-0 basis started from true zero -
   skewing the "equal flex-grow" split between them (see .profile-cards)
   toward this row every time, however much surplus height existed. */
.main-grid { container-type: inline-size; container-name: main-widgets; flex: 1 1 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 22px; }
.glass-card, .rail-card { color: var(--ink); background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(14px) saturate(1.15); }
.primary-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; padding: clamp(16px, 1.7vw, 32px); border-radius: 28px; box-shadow: 0 20px 60px oklch(0 0 0 / .35); }
.primary-card { container-type: inline-size; container-name: primary-widget; }
.card-glow { position: absolute; right: -50px; bottom: -60px; width: 180px; height: 180px; border-radius: 50%; pointer-events: none; }
.climate-card .card-glow { background: radial-gradient(circle, oklch(0.7 0.13 232 / .16), transparent 68%); }
.transit-card .card-glow { background: radial-gradient(circle, oklch(0.72 0.12 155 / .16), transparent 68%); }
.weather-card .card-glow { top: -60px; bottom: auto; width: 200px; height: 200px; background: radial-gradient(circle, oklch(0.78 0.13 75 / .16), transparent 68%); }
.card-heading { position: relative; z-index: 1; display: flex; align-items: center; gap: 14px; }
.card-heading h2 { margin: 0; font-size: var(--primary-title-size); font-weight: 700; line-height: 1.15; }
.card-heading p { margin: 2px 0 0; color: var(--muted); font-size: var(--primary-subtitle-size); line-height: 1.2; }
.transit-route { position: relative; display: grid; grid-template-columns: 10px 1fr; align-items: center; row-gap: 4px; column-gap: 10px; }
.transit-route::before { content: ""; position: absolute; left: 4px; top: 38%; bottom: 10px; width: 0; border-left: 2px dotted var(--muted); opacity: .5; }
.transit-route-dot { position: relative; z-index: 1; box-sizing: border-box; width: 8px; height: 8px; border-radius: 50%; justify-self: center; }
.transit-route-dot--origin { background: var(--ink); }
.transit-route-dot--dest { background: transparent; border: 2px solid var(--muted); }
.transit-route-name { line-height: 1.15; }
.transit-route-name--origin { color: var(--ink); font-size: var(--primary-title-size); font-weight: 700; line-height: 1.15; }
.transit-route-name--dest { color: var(--muted); font-size: var(--primary-subtitle-size); font-weight: 500; line-height: 1.2; }
.large-icon { position: relative; width: clamp(38px, 2.7vw, 52px); height: clamp(38px, 2.7vw, 52px); flex: 0 0 clamp(38px, 2.7vw, 52px); display: grid; place-items: center; border-radius: 16px; }
.climate-icon { color: oklch(0.78 0.1 232); background: oklch(0.4 0.09 232 / .35); }
.climate-icon span { position: relative; width: 11px; height: 24px; border: 3px solid currentColor; border-radius: 8px 8px 5px 5px; }
.climate-icon span::after { content: ""; position: absolute; width: 15px; height: 15px; left: -5px; bottom: -8px; border-radius: 50%; background: currentColor; }
.transit-icon { color: oklch(0.8 0.1 155); background: oklch(0.42 0.09 155 / .35); }
.transit-icon span { position: relative; width: 24px; height: 20px; border: 3px solid currentColor; border-radius: 6px; }
.transit-icon span::after { content: ""; position: absolute; left: 2px; bottom: -6px; width: 5px; height: 5px; border-radius: 50%; background: currentColor; box-shadow: 12px 0 currentColor; }
.weather-chip { background: oklch(0.44 0.1 75 / .35); }
.weather-chip img { width: 30px; height: 30px; }
.primary-value-block { position: relative; z-index: 1; flex: 1; display: flex; flex-direction: column; justify-content: center; }
.primary-value { font-size: var(--primary-reading-size); font-weight: 800; line-height: 1; letter-spacing: -.02em; white-space: nowrap; }
.primary-sub { margin-top: 8px; color: var(--muted); font-size: var(--primary-detail-size); line-height: 1.2; }
.departure-list { position: relative; z-index: 1; flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 14px; font-variant-numeric: tabular-nums; }

/* Transit tile's own departure rows - visually related to but distinct
   from .departure-row/.departure-badge (the dialog's, at dialog scale) so
   this tile's tighter width/height budget can't affect the dialog. Shares
   modeIcon() glyphs and the same raised-next-row / fading-later-rows idea
   at tile scale: max 3 rows (next departure + up to 2 more). */
.tile-departure-list { position: relative; z-index: 1; flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: center; margin-top: clamp(10px, 3.5cqw, 18px); font-variant-numeric: tabular-nums; }

.tile-departure-row { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: clamp(10px, 3.1cqw, 16px); padding: clamp(11px, 3.3cqw, 17px) clamp(8px, 2.3cqw, 12px); border-top: 1px solid oklch(1 0 0 / .28); }
.tile-departure-row:nth-child(2) { border-top-color: transparent; }

/* The next service is the only raised row. A pale green wash and inset
   route stripe keep it legible on every rotating photo without introducing
   the dark blocks that fought the rest of the glass dashboard. */
.tile-departure-row-next { margin-bottom: clamp(4px, 1.5cqw, 8px); padding: clamp(13px, 3.8cqw, 20px) clamp(12px, 3.5cqw, 18px); border: 1px solid oklch(.78 .07 155 / .5); border-radius: clamp(14px, 3.7cqw, 19px); background: linear-gradient(110deg, oklch(.93 .045 155 / .72), oklch(1 0 0 / .32)); box-shadow: inset 4px 0 0 oklch(.67 .13 155 / .78), 0 10px 24px oklch(.28 .04 155 / .09); }

/* Badge/minutes/clock sizing below is cqw (the card's own width via the
   .primary-card container query), not vw. This card is one of four columns
   sharing the window's width (COLUMN_WEIGHTS in app.js), so it's often only
   ~20% as wide as the viewport - vw-based sizing here scaled these three
   as if the card had the whole window to grow into, so on an ordinary
   desktop window (roughly 1000-1550px wide, before the ≥520px-*card*-wide
   scale-up below even applies) the card itself stays ~200-280px wide while
   the badge+minutes+clock kept growing with the window, outgrowing the
   card and spilling the clock past its clipped right edge (confirmed live:
   a 1050px window left the clock 28px past the card's edge). cqw ties
   these to the space that's actually there, on any viewport shape. */
.tile-departure-badge { min-width: clamp(68px, 19.6cqw, 102px); height: clamp(30px, 7.3cqw, 38px); padding: 0 clamp(8px, 2.3cqw, 12px); border: 1px solid oklch(.55 .09 155 / .24); border-radius: 999px; display: flex; align-items: center; justify-content: center; gap: 6px; color: oklch(.34 .1 155); background: oklch(.9 .055 155 / .64); font-size: clamp(13px, 3.3cqw, 17px); font-weight: 750; white-space: nowrap; }
.tile-departure-badge svg { width: 1.05em; height: 1.05em; flex: 0 0 auto; }
.tile-departure-row-next .tile-departure-badge { height: clamp(34px, 8.1cqw, 42px); color: oklch(.28 .11 155); background: oklch(.96 .04 155 / .9); font-size: clamp(15px, 3.7cqw, 19px); font-weight: 800; }
.tile-departure-badge-rail, .tile-departure-badge-bus { color: oklch(.34 .1 155); background: oklch(.9 .055 155 / .64); }

.tile-departure-middle { min-width: 0; overflow: hidden; color: oklch(.35 .025 250 / .72); font-size: clamp(11px, 2.9cqw, 15px); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.tile-departure-row-next .tile-departure-middle { color: oklch(.34 .08 155); }

.tile-departure-minutes-wrap { min-width: clamp(86px, 22.3cqw, 116px); display: grid; grid-template-columns: auto 1fr; align-items: baseline; justify-items: end; column-gap: clamp(8px, 2.3cqw, 12px); }
.tile-departure-minutes { color: oklch(.2 .02 250); font-size: clamp(19px, 5cqw, 26px); font-weight: 800; line-height: 1; letter-spacing: -.03em; white-space: nowrap; }
.tile-departure-minutes::after { content: " min"; font-size: .48em; font-weight: 650; letter-spacing: 0; }
.tile-departure-row-next .tile-departure-minutes { color: oklch(.24 .08 155); font-size: clamp(25px, 6.5cqw, 34px); }
.tile-departure-clock { color: oklch(.36 .02 250 / .78); font-size: clamp(11px, 2.9cqw, 15px); text-align: right; white-space: nowrap; }

.weather-card, #transit-widget { cursor: pointer; }
.weather-card:focus-visible, .rail-card:focus-visible, .profile-card:focus-visible, #transit-widget:focus-visible, .rotating-bar:focus-visible, .news-item:focus-visible { outline: 3px solid oklch(0.78 0.1 232); outline-offset: -4px; }
.weather-current { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; gap: clamp(14px, 1.46vw, 28px); }
.weather-icon { width: clamp(56px, 5.7vw, 110px); height: clamp(56px, 5.7vw, 110px); }
.weather-readout { min-width: 0; flex: 1 1 0; }
.weather-value { font-size: var(--primary-reading-size); font-weight: 800; line-height: 1; letter-spacing: -.02em; white-space: nowrap; }
.weather-mini-forecast { position: relative; z-index: 1; display: flex; gap: clamp(10px, 1.25vw, 24px); min-height: 66px; padding-top: 20px; border-top: 1px solid oklch(1 0 0 / .08); }
.mini-forecast-day { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; }
.mini-forecast-day img { width: 30px; height: 30px; }
.mini-forecast-label { color: var(--muted); font-size: 15px; }
.mini-forecast-temp { margin-top: 2px; display: flex; align-items: baseline; gap: .3em; font-size: 19px; font-weight: 650; white-space: nowrap; }
.mini-forecast-high { color: var(--ink); }
.mini-forecast-low, .mini-forecast-separator { color: var(--muted); }

/* gap uses --group-gap (rather than the shared 22px GRID_GAP - unlike
   .content-row/.main-grid, nothing in app.js's column-width math depends
   on this one) so the ticket/maintenance cards read as just as
   tight a group as the announcement/news/quote stack, at any viewport
   height. .rail-subgrid's value is read live by app.js's layoutRailCards()
   rather than duplicated as a constant there, so it can't drift out of
   sync. */
.right-rail { flex: .95 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: var(--group-gap); }
.rail-subgrid { min-height: 0; display: flex; flex-direction: column; gap: var(--group-gap); }
.rail-card { container-type: inline-size; container-name: rail-widget; width: 100%; height: 100%; min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-start; gap: 8px; padding: clamp(16px, 1.7vw, 32px); border-radius: 24px; text-align: left; cursor: pointer; }
.maintenance-live-card { padding: clamp(16px, 1.7vw, 32px); }
/* Keep the maintenance status at a stable vertical position. The base card
   centers all content as one group, which made the status jump when the
   description below it wrapped onto a different number of lines. */
.maintenance-live-card { justify-content: flex-start; }
.rail-heading { display: flex; align-items: center; gap: 10px; }
.rail-icon { width: clamp(38px, 2.7vw, 52px); height: clamp(38px, 2.7vw, 52px); flex: 0 0 clamp(38px, 2.7vw, 52px); display: grid; place-items: center; border-radius: 10px; }
.rail-icon > span, .rail-icon > svg { transform: scale(1.4); }
.maintenance-icon { color: oklch(0.82 0.1 75); background: oklch(0.45 0.11 75 / .35); }
/* nowrap+ellipsis used to hard-truncate this label ("MAINTENANCE" ->
   "MAINTE...") anywhere from ~900px up to ~1300px wide - the rail column's
   fixed 0.95 weight (see COLUMN_WEIGHTS in app.js) gives it only ~150-200px
   here, and the font-size clamp sits pinned at its 11px floor for most of
   that range (.73vw only clears 11px above ~1500px viewport), so there's no
   further shrink room to compensate. Wrapping to 2 lines instead - same
   line-clamp approach .rail-card strong already uses below, and the same
   "wrap don't truncate" call #maintenance-live-value makes - keeps the full
   word readable instead of cutting it mid-word. */
.rail-label { color: var(--muted); font-size: var(--primary-title-size); text-transform: uppercase; letter-spacing: .06em; white-space: normal; overflow-wrap: break-word; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; min-width: 0; }
.rail-dots { display: flex; gap: 6px; flex: 0 0 auto; margin-left: auto; }
.rail-dots .dot { width: 6px; height: 6px; border-radius: 50%; background: oklch(0.16 0.01 250 / .18); }
.rail-dots .dot.active { background: var(--red); }
.rail-card strong { font-size: clamp(14px, 1.15vw, 22px); font-weight: 700; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.time-pill { width: max-content; padding: 5px 12px; border-radius: 999px; color: var(--red); background: oklch(0.93 0.06 25); font-size: clamp(12px, .83vw, 16px); font-weight: 700; font-variant-numeric: tabular-nums; }
.rail-sub { color: var(--muted); font-size: clamp(12px, .78vw, 15px); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
/* Wraps instead of truncating: a fixed-height card (see layoutRailCards in
   app.js) has the vertical room for a short phrase like "Overdue by 17
   days" to wrap onto a second line, which reads better than an ellipsis
   mid-word and reflows more predictably while the column is resized. This
   is also a <strong> inside .rail-card, so it must explicitly undo the
   .rail-card strong line-clamp above (display/overflow/-webkit-line-clamp)
   or that rule would silently clamp it too. */
#maintenance-live-value { color: var(--red); font-size: clamp(14px, 1.15vw, 22px); font-weight: 700; white-space: normal; overflow-wrap: break-word; display: block; overflow: visible; -webkit-line-clamp: none; }

/* flex: 1 1 0, matching .main-grid's own "flex: 1" - both are rows
   inside .content-left's column flow, so giving them equal grow/basis
   makes them split .content-left's height evenly instead of .main-grid
   filling all the leftover space and this row staying pinned to a
   small fixed band (that used to leave the outside/transit widgets
   towering over the birthday card below them).
   min-height: min-content (was a flat 190px) floors the row at exactly
   what its tallest card needs: 190px clipped the card's name/message on
   wide-but-not-tall screens (cards need ~213px there) yet still cost
   ~20px more than needed on short laptops, where it pushed the news and
   quote rows off-screen. */
.profile-cards { flex: 1 1 0; min-height: min-content; display: flex; gap: 22px; overflow-x: auto; overflow-y: hidden; }
/* With no birthday cards, don't hold the empty row open:
   its 190px floor alone pushed the news and quote rows off the bottom of
   1366x768 laptops and 1920x1080 screens. The freed height goes to the
   gap above the announcement bar, not the main cards, so they don't
   resize when a card appears. display: none (rather than a zero-height
   box) also drops the row out of .content-left's gap, whatever that gap
   is at the current breakpoint (22px here, var(--stack-gap) below 900px). */
/* Home: with no birthday cards the empty row still keeps its half of
   .content-left (invisible), so the top cards are the same size as when
   cards are showing - as on the office screen - and the background photo
   shows below them. The stacked phone layout drops it (rule below). */
.profile-cards:empty { visibility: hidden; }
/* --profile-card-width = the Indoor climate column's width, set by
   updateMainGridColumns() in app.js on the side-by-side layout. */
.profile-card { flex: 0 0 var(--profile-card-width, min(364px, 86vw)); min-width: 0; display: flex; flex-direction: column; justify-content: safe center; gap: 10px; padding: 20px 22px; border-radius: 26px; color: var(--ink); cursor: pointer; background: oklch(0.96 0.005 250 / .58); border: 1px solid oklch(1 0 0 / .5); box-shadow: 0 16px 40px oklch(0 0 0 / .18); text-align: left; backdrop-filter: blur(14px) saturate(1.15); }
.profile-top { display: flex; align-items: center; gap: 14px; }
.avatar { width: 56px; height: 56px; flex: 0 0 56px; overflow: hidden; display: grid; place-items: center; border: 2px solid oklch(1 0 0 / .7); border-radius: 50%; color: oklch(0.98 0.005 250); background: oklch(0.4 0.09 232 / .4); font-size: 24px; font-weight: 700; }
.avatar-tone-2 { background: oklch(0.55 0.11 155 / .4); }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.profile-info { min-width: 0; }
.profile-eyebrow { overflow: hidden; color: oklch(0.16 0.1 232); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; text-overflow: ellipsis; }
.profile-name { margin-top: 4px; overflow: hidden; color: var(--ink-strong); font-size: 24px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
/* min-height (not just font-size) because this line is sometimes empty
   (a person with no role) - an empty
   block collapses to 0 height, which shifts the divider below it up and
   breaks row-to-row alignment across the profile-cards row. */
.profile-role { margin-top: 2px; color: var(--ink); font-size: 16px; line-height: 1.2; min-height: 1.2em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.profile-fact { padding-top: 8px; border-top: 1px solid oklch(0.14 0.01 250 / .16); color: oklch(0.12 0.01 250); font-size: 15px; font-style: italic; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }

/* margin-top (on top of .kiosk-shell's own gap) separates this from
   .content-row above, and the negative margin-top on .news-row/.fact-row
   below pulls the three ticker widgets closer together - together these
   read as one distinct group under the main dashboard grid instead of
   being evenly spaced like every other row in the stack. Expressed as
   var(--stack-gap) (doubling the shell's own gap) rather than a flat px
   value so this separation scales with it - a fixed px addition on top of
   a vh-based gap drifted out of proportion between windows with different
   effective viewport heights (e.g. a browser tab vs. the same page as a
   chromeless installed app), making the gap look inconsistent between them. */
.announcement-row { flex: 0 0 clamp(60px, 7.4vh, 80px); display: flex; margin-top: var(--stack-gap); }
.rotating-bar { flex: 1; display: flex; align-items: center; gap: clamp(10px, 1.85vw, 20px); min-width: 0; padding: clamp(10px, 1.3vw, 14px) clamp(16px, 3vw, 32px); border-radius: 24px; background: oklch(0.42 0.1 232 / .55); border: 1px solid oklch(1 0 0 / .45); overflow: hidden; cursor: pointer; backdrop-filter: blur(14px) saturate(1.15); }
.rotating-icon { font-size: 22px; line-height: 1; }
.rotating-icon-chip { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 14px; background: oklch(0.4 0.09 232 / .4); display: grid; place-items: center; }
.rotating-eyebrow { flex: 0 0 auto; color: #fff; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.rotating-divider { width: 1px; align-self: stretch; background: oklch(1 0 0 / .25); flex: 0 0 1px; }
.rotating-message { flex: 1; min-width: 0; color: #fff; font-size: clamp(18px, 1.67vw, 32px); font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; opacity: 1; transition: opacity .3s ease; text-shadow: 0 1px 8px oklch(0 0 0 / .35); }
.rotating-message.fading { opacity: 0; }
/* Poll items: the question keeps the bar's weight; the tally and "scan to
   vote" follow after a gap and a thin divider, lighter and a touch smaller.
   Inline spans inside the same 2-line clamp, so the bar's box is unchanged. */
.rotating-poll-answers { font-weight: 500; font-size: .86em; opacity: .78; }
.rotating-poll-answers::before { content: ""; display: inline-block; width: 1px; height: .95em; margin: 0 .8em 0 .9em; background: currentColor; opacity: .55; vertical-align: -.12em; }
.rotating-message.warning { color: var(--red); }
.rotating-dots { display: flex; flex: 0 0 auto; gap: 8px; }
.rotating-dots .dot { width: 8px; height: 8px; border-radius: 50%; background: oklch(1 0 0 / .3); }
.rotating-dots .dot.active { background: #fff; }

/* Sits between the announcement bar and the fact row - deliberately
   shorter than both, since it only ever needs to hold a topic label, a
   headline clamped to 2 lines, and a source/time meta line. flex-basis is
   vw-driven (not vh, unlike the other ticker rows) because the content
   that determines its height - .news-headline's font-size and .news-panel's
   padding - are themselves vw-clamped; a vh-based basis drifted out of
   sync with that and measured 10-24px too short for a real 2-line headline
   (Playwright, 1024-1920px wide), so a long headline forced the row taller
   than its flex-basis and pushed the fact row down. overflow: hidden is a
   backstop so that can't happen again even outside the measured range. */
.news-row { flex: 0 0 clamp(90px, calc(80px + 3vw), 138px); display: flex; margin-top: calc(var(--group-gap) - var(--stack-gap)); overflow: hidden; }
/* align-items: flex-start (not center) so the two news items' eyebrow/
   headline/meta top-align with each other regardless of which one's
   headline wraps to 2 lines - each item shrinks to its own content height,
   so centering them against the panel put a longer item's eyebrow visibly
   higher than the shorter item's, making the shorter item look like it had
   shifted or shrunk whenever its neighbor's headline happened to wrap.
   Same root cause .fact-panel's justify-content: flex-start already
   guards against (below). */
.news-panel { flex: 1; display: flex; align-items: flex-start; gap: clamp(14px, 2vw, 28px); min-width: 0; padding: clamp(12px, 1.6vw, 20px) clamp(18px, 3vw, 32px); border-radius: 24px; background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(14px) saturate(1.15); }
.news-icon-chip { flex: 0 0 40px; width: 40px; height: 40px; border-radius: 14px; background: oklch(0.4 0.1 170 / .35); color: oklch(0.3 0.12 170); display: grid; place-items: center; }
/* min-height: 0 (alongside the existing min-width: 0) matters here: without
   it, a flex item's automatic minimum size defaults to its content's natural
   height, so a 3-line headline could force this item taller than .news-panel
   actually has room for and spill past the panel's own bottom edge instead
   of being contained/clipped by .news-row's overflow: hidden (see its base
   rule) - the panel itself has no overflow: hidden of its own. */
.news-item { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; flex-direction: column; justify-content: center; gap: 3px; cursor: pointer; border-radius: 12px; }
.news-eyebrow { color: oklch(0.32 0.12 170); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.news-video-badge { margin-left: 8px; padding: 1px 7px; border-radius: 999px; background: oklch(0.55 0.19 25); color: #fff; font-size: 0.85em; letter-spacing: .04em; vertical-align: 1px; }
.news-headline { color: var(--ink); font-weight: 600; line-height: 1.25; font-size: clamp(16px, 1.2vw, 21px); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; opacity: 1; transition: opacity .3s ease; }
.news-headline.fading { opacity: 0; }
.news-meta { color: var(--muted); font-size: clamp(11px, .75vw, 13px); }
.news-divider { width: 1px; align-self: stretch; background: oklch(0.14 0.01 250 / .22); flex: 0 0 1px; }

.fact-row { flex: 0 0 clamp(140px, 18vh, 196px); display: flex; position: relative; margin-top: calc(var(--group-gap) - var(--stack-gap)); }
/* justify-content: flex-start (not center) so the label's position depends
   only on the panel's fixed padding, never on how many lines the following
   text wraps to — centering here caused the label to jump between facts
   of different lengths. */
.fact-panel { flex: 1; cursor: pointer; text-align: left; border: 1px solid oklch(1 0 0 / .45); background: var(--glass); display: flex; flex-direction: column; justify-content: flex-start; min-width: 0; padding: clamp(14px, 1.85vw, 20px) clamp(18px, 3vw, 32px); padding-top: clamp(20px, 2.4vw, 28px); border-radius: 24px; overflow: hidden; font: inherit; color: inherit; backdrop-filter: blur(14px) saturate(1.15); }
.fact-label { color: oklch(0.22 0.14 300); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 12px; }
.fact-text { color: var(--ink); font-weight: 500; line-height: 1.35; font-size: clamp(17px, 1.4vw, 22px); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; opacity: 1; transition: opacity .3s ease; margin-bottom: 8px; }
.fact-text.fading { opacity: 0; }
/* margin-top: auto pushes the sub-line to consume all remaining flex space
   above it, anchoring it to the panel's bottom-right corner for both fact
   types alike (quote attribution and on-this-day's usually-empty sub);
   margin-bottom clears #fact-credit's pill, which sits absolutely
   positioned in that same corner (see #fact-credit below). */
.fact-sub { color: var(--muted); font-size: 16px; margin-top: auto; margin-bottom: 28px; text-align: right; }
.fact-expand-hint { font-size: 12px; font-weight: 600; color: oklch(0.35 0.14 300); }

/* Quote of the Day and On This Day share the same layout (label always on
   top) so the widget doesn't jump when it rotates between fact types; they're
   distinguished only by style: quote text is italic, and the on-this-day
   label uses --blue's hue rather than the quote label's violet. */
.fact-panel[data-type="quote"] .fact-text { font-style: italic; }
.fact-panel[data-type="onthisday"] .fact-label { color: oklch(0.22 0.14 232); }

/* ZenQuotes free-tier terms require attribution with a link back to
   zenquotes.io; sibling of the fact-panel button (not nested inside it,
   since buttons can't legally contain interactive <a> elements) but
   visually anchored to its corner, same subtle "fine print" idea as
   #background-credit below. */
#fact-credit { position: absolute; right: clamp(14px, 1.8vw, 22px); bottom: clamp(10px, 1.3vw, 14px); z-index: 2; padding: 3px 9px; border-radius: 999px; background: oklch(1 0 0 / .55); color: var(--muted); font-size: 11px; line-height: 1.3; backdrop-filter: blur(6px); }
#fact-credit a { color: inherit; text-decoration-color: oklch(0.16 0.01 250 / .45); }
#fact-credit:empty { display: none; }

.detail-dialog { width: min(680px, 92vw); max-height: 88vh; overflow: auto; padding: 0 clamp(22px, 4.2vw, 40px) clamp(22px, 3.75vw, 36px); border: 0; border-radius: 26px; color: var(--ink); background: oklch(0.98 0.005 250); box-shadow: 0 30px 90px oklch(0 0 0 / .4); }
.detail-dialog::backdrop { background: oklch(0.1 0.02 250 / .55); }
.dialog-header { position: sticky; top: 0; z-index: 5; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-top: clamp(22px, 3.75vw, 36px); padding-bottom: 14px; border-bottom: 1px solid oklch(0.14 0.01 250 / .08); background: oklch(0.98 0.005 250); cursor: grab; touch-action: none; }
.dialog-header.dragging { cursor: grabbing; }
.dialog-heading { display: flex; align-items: center; gap: 14px; min-width: 0; }
.dialog-heading > div { min-width: 0; }
.dialog-icon-chip { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 14px; background: oklch(0.94 0.015 250); display: grid; place-items: center; font-size: 22px; }
.dialog-icon-chip:empty { display: none; }
.dialog-label { color: var(--dialog-muted); font-size: var(--dialog-label-size); font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.dialog-header h2 { margin: 6px 0 0; font-size: var(--dialog-title-size); overflow-wrap: anywhere; }
.dialog-close { width: 44px; height: 44px; flex: 0 0 44px; border: 1.5px solid oklch(0.14 0.01 250 / .25); border-radius: 50%; background: transparent; color: var(--dialog-strong); font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.detail-list { margin-top: 26px; display: grid; gap: 14px; }
/* Built into #operational-dialog-body only by renderNewsArticleDetails()
   (via replaceChildren) - every other dialog only ever populates its own
   dedicated classes, so these rules have no effect on them. */
.news-dialog-snippet { margin: 0 0 20px; font-size: 18px; line-height: 1.6; max-width: 60ch; color: oklch(0.86 0.015 250); }
.news-dialog-snippet em { font-style: normal; font-weight: 700; padding: 0 2px; border-radius: 2px; background: oklch(0.62 0.1 90 / .3); box-shadow: 0 0 0 2px oklch(0.62 0.1 90 / .3); }
.news-dialog-snippet-empty { color: oklch(0.64 0.015 250); font-style: italic; }
.news-dialog-qr { margin-top: 22px; display: flex; align-items: center; gap: 16px; }
.news-dialog-qr img { width: 108px; height: 108px; border-radius: 8px; background: #fff; padding: 6px; }
.news-dialog-qr-caption { margin: 0; font-size: 17px; font-weight: 600; color: oklch(0.97 0.01 250); }
.news-dialog-qr-domain { margin: 2px 0 0; font-size: 14px; color: oklch(0.64 0.015 250); }
.detail-row { display: flex; justify-content: space-between; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid oklch(0.14 0.01 250 / .1); font-size: var(--dialog-body-size); }
.detail-row span { color: var(--dialog-muted); }
.detail-row strong { text-align: right; }
.weather-dialog { width: min(760px, 92vw); }
.forecast-list { margin-top: 22px; }
.forecast-row { display: grid; grid-template-columns: 88px 34px 150px 66px 44px 1fr 52px; gap: 14px; align-items: center; min-height: 58px; border-bottom: 1px solid oklch(1 0 0 / 0.08); font-variant-numeric: tabular-nums; }
.forecast-row:last-child { border-bottom: 0; }
.forecast-row img { width: 34px; height: 34px; }
.forecast-day { font-weight: 600; color: oklch(0.78 0.015 250); }
.forecast-row:first-child .forecast-day { font-weight: 700; color: oklch(0.97 0.01 250); }
.forecast-condition { color: oklch(0.78 0.015 250); }
.forecast-row:first-child .forecast-condition { color: oklch(0.97 0.01 250); }
.forecast-rain { display: flex; align-items: center; gap: 7px; color: oklch(0.64 0.015 250); font-weight: 600; }
.forecast-rain-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; background: oklch(0.6 0.03 232); }
.forecast-low { text-align: right; color: oklch(0.72 0.015 250); font-weight: 600; }
.forecast-bar-track { position: relative; height: 8px; border-radius: 999px; background: oklch(1 0 0 / 0.09); overflow: hidden; }
.forecast-bar-fill { position: absolute; top: 0; bottom: 0; min-width: 6px; border-radius: 999px; background: linear-gradient(90deg, oklch(0.71 0.1 232), oklch(0.83 0.14 68)); }
.forecast-high { font-size: 20px; font-weight: 800; color: oklch(0.97 0.01 250); }

/* Dark frosted-glass variant. Applied to #operational-dialog whenever
   showOperationalDetails() is called with a `variant` (departures,
   onthisday, quote, maintenance, updates, news) and hardcoded in the HTML for
   #weather-dialog and #profile-dialog,
   which are always dark glass. The generic label/value fallback below is
   the only remaining near-white-card path; every current call site now
   passes a variant, so this fallback is unreachable in practice (kept for
   any future caller that doesn't need a bespoke variant). */
.dialog-glass {
  background: oklch(0.22 0.018 250 / 0.94);
  backdrop-filter: blur(20px);
  border: 1px solid oklch(1 0 0 / 0.14);
  box-shadow: 0 30px 90px oklch(0 0 0 / 0.45);
  color: oklch(0.97 0.01 250);
}
.dialog-glass::backdrop { background: oklch(0.1 0.02 250 / 0.55); }
/* Header must stay opaque (matching the sheet's own glass background) -
   it's position: sticky in the base .dialog-header rule, and relies on an
   opaque background to hide list rows scrolling underneath it. */
.dialog-glass .dialog-header { background: oklch(0.22 0.018 250 / 0.94); backdrop-filter: blur(20px); border-bottom-color: oklch(1 0 0 / 0.12); }
.dialog-glass .dialog-close { border: 1.5px solid oklch(1 0 0 / 0.28); color: oklch(0.9 0.015 250); }
/* Icon chips are retired for these sheets - hidden, not removed, so the
   #operational-dialog-icon / #weather-dialog-icon ids (and the JS that
   still writes to them) keep working for non-glass callers. */
.dialog-glass .dialog-icon-chip { display: none; }

/* Right-hand cluster of the header: an optional "NOW" readout plus the
   close button, grouped so the close button stays pinned to the edge
   regardless of whether the NOW block is present. Hidden by default -
   only the departures variant and the forecast dialog show it. */
.dialog-header-side { display: flex; align-items: center; gap: 18px; flex: 0 0 auto; }
.dialog-now { display: none; flex-direction: column; align-items: flex-end; }
.dialog-now-label { color: oklch(0.64 0.015 250); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.dialog-now-value { margin-top: 2px; font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; }
.variant-departures .dialog-now, .weather-dialog .dialog-now { display: flex; }

/* --- Next departures --- */
.variant-departures .dialog-label { color: oklch(0.8 0.1 155); display: flex; align-items: center; gap: 8px; }
.variant-departures .dialog-label::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
.variant-departures .dialog-header h2 { font-size: 30px; font-weight: 700; }
.variant-departures .dialog-header h2 .departure-route-arrow { margin: 0 10px; color: oklch(0.6 0.02 250); font-weight: 400; }
.variant-departures .dialog-header h2 .departure-route-dest { color: oklch(0.78 0.015 250); font-weight: 600; }

.weather-dialog .weather-chip { display: none; }
.weather-dialog .dialog-label { color: oklch(0.71 0.1 232); text-transform: uppercase; }

.departure-list { margin-top: 22px; display: flex; flex-direction: column; font-variant-numeric: tabular-nums; }
.departure-row { display: grid; grid-template-columns: 96px 1fr auto; align-items: center; gap: 18px; padding: 14px 6px; border-bottom: 1px solid oklch(1 0 0 / 0.08); }
.departure-row:last-child { border-bottom: 0; }
.departure-badge { width: 96px; height: 38px; border-radius: 11px; display: flex; align-items: center; justify-content: center; gap: 5px; font-size: 15px; font-weight: 700; white-space: nowrap; }
.departure-badge-rail { background: oklch(0.4 0.03 250); color: oklch(0.97 0.01 250); }
.departure-badge-bus { background: oklch(0.42 0.09 155 / 0.4); color: oklch(0.86 0.08 155); }
.departure-dest-wrap { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.departure-dest { font-size: 18px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.departure-status { font-size: 14px; color: oklch(0.78 0.015 250); }
.departure-minutes-wrap { display: flex; flex-direction: column; align-items: flex-end; }
.departure-minutes { font-size: 22px; font-weight: 800; }
.departure-clock { margin-top: 2px; font-size: 15px; color: oklch(0.64 0.015 250); }

/* Progressive fade toward the bottom of the list, applied to rows after
   the raised next-departure card, so the eye stops after ~3 rows. */
.departure-row:nth-child(2) .departure-dest, .departure-row:nth-child(2) .departure-minutes { color: oklch(0.88 0.015 250); }
.departure-row:nth-child(3) .departure-dest, .departure-row:nth-child(3) .departure-minutes { color: oklch(0.85 0.015 250); }
.departure-row:nth-child(4) .departure-dest, .departure-row:nth-child(4) .departure-minutes { color: oklch(0.83 0.015 250); }
.departure-row:nth-child(n+5) .departure-dest, .departure-row:nth-child(n+5) .departure-minutes { color: oklch(0.8 0.015 250); }

/* The next departure: a raised panel, distinct from the flat rows below. */
.departure-row-next {
  grid-template-columns: 96px 1fr auto;
  margin-bottom: 6px;
  padding: 16px 18px;
  border: 1px solid oklch(0.7 0.1 232 / 0.3);
  border-bottom: 1px solid oklch(0.7 0.1 232 / 0.3);
  border-radius: 20px;
  background: oklch(0.42 0.09 232 / 0.35);
}
.departure-row-next .departure-badge { height: 44px; }
.departure-row-next .departure-dest { font-size: 19px; }
.departure-row-next .departure-minutes { font-size: 34px; }
.departure-row-next .departure-minutes-wrap, .departure-row-next .departure-dest, .departure-row-next .departure-status { color: oklch(0.97 0.01 250); }

.departure-empty-message { margin-top: 22px; color: oklch(0.78 0.015 250); font-size: var(--dialog-body-size); }

/* --- On This Day --- */
.variant-onthisday .dialog-label { color: oklch(0.74 0.08 60); }
.variant-onthisday .dialog-header h2 { font-size: 26px; }
.onthisday-body { margin-top: 26px; }
.onthisday-content { display: flex; gap: 22px; align-items: flex-start; }
.onthisday-year { flex: 0 0 auto; font-size: 52px; font-weight: 800; letter-spacing: -0.03em; color: oklch(0.8 0.1 60); font-variant-numeric: tabular-nums; }
.onthisday-title { font-size: 21px; font-weight: 700; margin-bottom: 8px; }
.onthisday-text { margin: 0; font-size: 17px; line-height: 1.55; text-wrap: pretty; color: oklch(0.85 0.015 250); }

/* --- Quote of the day --- */
.variant-quote .dialog-label { color: oklch(0.78 0.09 300); }
.variant-quote .dialog-header h2 { display: none; }
.quote-body { margin-top: 28px; min-height: 220px; display: flex; flex-direction: column; justify-content: center; }
.quote-text { margin: 0; font-size: 31px; font-weight: 600; line-height: 1.3; letter-spacing: -0.02em; text-wrap: pretty; }
.quote-attribution { margin-top: 34px; padding-top: 16px; border-top: 1px solid oklch(1 0 0 / 0.14); font-size: 18px; font-weight: 600; color: oklch(0.7 0.11 300); }

.dialog-footer-rule { margin-top: 26px; padding-top: 16px; border-top: 1px solid oklch(1 0 0 / 0.1); font-size: var(--dialog-meta-size); color: oklch(0.64 0.015 250); }
.dialog-footer-rule a { color: inherit; }
.variant-quote .dialog-footer-rule { border-top: 0; padding-top: 0; margin-top: 18px; }

.variant-maintenance .dialog-label { color: oklch(0.8 0.11 25); }
.maintenance-body { margin-top: 26px; display: flex; flex-direction: column; gap: 22px; font-variant-numeric: tabular-nums; }
.maintenance-dialog-group-label { margin-top: 8px; color: oklch(0.68 0.015 250); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .07em; }
.maintenance-item:not(.maintenance-item-primary) { padding-top: 18px; border-top: 1px solid oklch(1 0 0 / 0.08); }
.maintenance-anchor-row { display: flex; align-items: baseline; gap: 18px; }
.maintenance-anchor-number { font-size: 64px; font-weight: 800; line-height: .85; letter-spacing: -0.04em; color: oklch(0.78 0.14 25); }
.maintenance-item:not(.maintenance-item-primary) .maintenance-anchor-number { font-size: 40px; }
.maintenance-anchor-number.not-overdue { color: oklch(0.78 0.015 250); }
.maintenance-anchor-copy { display: flex; flex-direction: column; gap: 4px; }
.maintenance-item-name { font-size: 24px; font-weight: 700; }
.maintenance-item-status { font-size: 16px; color: oklch(0.78 0.015 250); }
.maintenance-item-status.not-overdue { color: oklch(0.64 0.015 250); }
.maintenance-bar-track { margin-top: 16px; position: relative; height: 10px; border-radius: 999px; background: oklch(1 0 0 / 0.09); overflow: hidden; }
.maintenance-bar-neutral { position: absolute; top: 0; bottom: 0; left: 0; background: oklch(0.5 0.04 250); }
.maintenance-bar-red { position: absolute; top: 0; bottom: 0; background: linear-gradient(90deg, oklch(0.6 0.13 25), oklch(0.75 0.16 25)); }
.maintenance-bar-labels { margin-top: 8px; display: flex; justify-content: space-between; font-size: 13px; color: oklch(0.64 0.015 250); }
.maintenance-bar-due-label { color: oklch(0.78 0.14 25); font-weight: 700; }

.variant-updates .dialog-label { color: oklch(0.78 0.09 300); }
.variant-updates .dialog-header h2 { display: none; }
.updates-body { margin-top: 22px; display: flex; flex-direction: column; gap: 14px; }
.updates-empty-message { color: oklch(0.78 0.015 250); font-size: var(--dialog-body-size); }
.updates-card { padding: 18px 20px; border-radius: 16px; background: oklch(1 0 0 / 0.05); border: 1px solid oklch(1 0 0 / 0.08); }
.updates-card-title { font-size: 28px; font-weight: 700; line-height: 1.2; }
.updates-card-warning { background: oklch(0.42 0.11 25 / 0.18); border-color: oklch(0.68 0.14 25 / 0.32); }

.variant-news .dialog-label { color: oklch(0.76 0.08 60); text-transform: none; letter-spacing: 0; font-weight: 600; }
.news-eyebrow-category { color: oklch(0.66 0.015 250); }
.variant-news .dialog-header h2 { font-size: 32px; font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; text-wrap: pretty; }
.news-article-body { margin-top: 24px; }
.news-byline { padding-top: 16px; border-top: 1px solid oklch(1 0 0 / 0.1); font-size: 15px; color: oklch(0.78 0.015 250); }
.news-byline strong { color: oklch(0.97 0.01 250); font-weight: 700; }

/* var(--dialog-muted) (the base .dialog-label color) is tuned for the
   near-white dialog surface and reads as near-illegible on this dialog's
   dark glass background - override with the same green accent hue (155)
   every other glass dialog's eyebrow label already uses. */
.profile-dialog .dialog-label { color: oklch(0.8 0.1 155); }
/* Top-level body children (.profile-dialog-fact, .profile-dialog-start,
   .profile-dialog-facts) are spaced via this flex gap rather than
   individual margin-tops. */
.profile-dialog[open] { display: flex; flex-direction: column; gap: 22px; }
.profile-dialog-identity { display: flex; align-items: center; gap: 16px; }
.profile-dialog-photo { width: 72px; height: 72px; flex: 0 0 72px; overflow: hidden; display: grid; place-items: center; border-radius: 50%; color: #fff; background: oklch(0.5 0.14 232); font-size: 28px; font-weight: 700; }
.profile-dialog-photo img { width: 100%; height: 100%; object-fit: cover; }
/* var(--dialog-muted) is tuned for the near-white dialog surface and reads
   as near-invisible on this dialog's dark glass background - use the same
   light muted tone the other glass dialogs use for secondary text (e.g.
   .forecast-day). */
.profile-dialog-role { margin-top: 3px; color: oklch(0.76 0.015 250); }
/* Same subtle translucent-card treatment .updates-card uses elsewhere for
   free-standing text on dark glass, in place of the old near-white panel
   background. Hidden entirely (no generic fallback copy) when the person
   has no real fun_fact. */
.profile-dialog-fact { padding: 16px; border-radius: 12px; background: oklch(1 0 0 / 0.05); border: 1px solid oklch(1 0 0 / 0.08); font-size: var(--dialog-body-size); }
.profile-dialog-fact:empty { display: none; }

/* Highlighted start-date block: weekday + "Starts <date>" + relative
   countdown, in a mint-tinted glass card matching this file's other
   established green accent hue (155). */
.profile-dialog-start { padding: 18px 20px; border-radius: 18px; background: oklch(0.45 0.09 155 / 0.16); border: 1px solid oklch(0.7 0.1 155 / 0.28); display: flex; align-items: baseline; gap: 16px; }
.profile-start-weekday { font-size: 40px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; color: oklch(0.78 0.14 155); }
.profile-start-headline { font-size: 19px; font-weight: 700; }
.profile-start-sub { margin-top: 3px; font-size: 15px; color: oklch(0.78 0.03 155); }

/* Label-above-value 2-column grid, with a divider rule above it. */
.profile-dialog-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 22px 32px; padding-top: 20px; border-top: 1px solid oklch(1 0 0 / 0.1); }
.profile-fact-label { color: oklch(0.68 0.015 250); font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .07em; }
.profile-fact-value { margin-top: 4px; font-size: var(--dialog-body-size); }

/* These dialogs are width: min(..., 92vw), so their available content width
   tracks viewport width directly - below this, the fixed-px columns above
   (100px+36px for forecast) leave too little room for
   the 1fr text column to stay readable. Instead of shrinking further,
   each row becomes two lines: a compact top line for the fixed-width bits
   (date + icon + rain%), and the full-width
   text (condition + temperatures) on its own line below. */
@media (max-width: 720px) {
  .forecast-row {
    grid-template-columns: 32px 1fr auto;
    grid-template-areas: "icon date rain" "icon condition condition" "low bar high";
    row-gap: 6px;
    align-items: start;
    padding: 12px 0;
  }
  .forecast-row img { grid-area: icon; align-self: center; width: 28px; height: 28px; }
  .forecast-day { grid-area: date; }
  .forecast-rain { grid-area: rain; justify-self: end; }
  .forecast-condition { grid-area: condition; }
  .forecast-low { grid-area: low; text-align: left; align-self: center; }
  .forecast-bar-track { grid-area: bar; margin: 0 4px; }
  .forecast-high { grid-area: high; align-self: center; }

  .departure-row, .departure-row-next { grid-template-columns: 96px auto; grid-template-areas: "badge minutes" "dest dest"; row-gap: 6px; }
  .departure-badge { grid-area: badge; }
  .departure-minutes-wrap { grid-area: minutes; }
  .departure-dest-wrap { display: none; }

  .onthisday-year { font-size: 40px; }
  .onthisday-title { font-size: 19px; }
  .onthisday-text { font-size: 16px; }
  .maintenance-item-primary .maintenance-anchor-number { font-size: 44px; }
  .quote-text { font-size: 24px; }

  .news-dialog-snippet { font-size: 16px; }
}

/* position: fixed pins this to the corner of the browser viewport, same
   idea as #background-layer above - correct here too as long as nothing
   ever scrolls underneath it. Below the max-width:900px breakpoint that
   stops being true (.kiosk-viewport scrolls internally once every widget
   is stacked into one column), so that block overrides this to static
   flow instead - see the comment there.

   left, not right: #fact-credit (the Quote of the Day's own "via
   ZenQuotes" pill, required by ZenQuotes' attribution terms) is also
   bottom-right, inside whatever card ends up last on screen - on any
   viewport where nothing scrolls (the normal kiosk case, not just one
   laptop size) that's the same corner as this badge, and the two
   silently overlapped there before this - easy to miss since both are
   dark pills of similar size, but it was hiding the required ZenQuotes
   attribution behind the Unsplash one. Opposite corner avoids the clash
   entirely without adding any page height, unlike reserving clearance
   would - this file has already fought hard elsewhere to avoid exactly
   that kind of change reintroducing forced scroll on a short screen. */
#background-credit {
  position: fixed;
  left: clamp(10px, 1.67vw, 32px);
  bottom: clamp(10px, 1.25vw, 24px);
  z-index: 4;
  max-width: min(420px, 70vw);
  padding: 6px 10px;
  border-radius: 8px;
  color: oklch(1 0 0 / .92);
  background: oklch(0.08 0.01 250 / .58);
  box-shadow: 0 3px 12px oklch(0 0 0 / .2);
  font-size: 12px;
  line-height: 1.3;
  white-space: normal;
  backdrop-filter: blur(5px);
}
#background-credit:empty { display: none; }
#background-credit a { color: inherit; text-decoration-color: oklch(1 0 0 / .5); }
.hidden { display: none !important; }

/* Below ~900px there isn't room for the weighted side-by-side columns
   app.js computes (see updateMainGridColumns) to stay readable, so this
   breakpoint switches to a single stacked column instead. app.js checks
   the same 900px threshold and clears its inline width/flex overrides in
   that mode so these rules (rather than leftover inline styles) apply. */
@media (max-width: 900px) {
  .content-row { flex-direction: column; }
  .content-left, .right-rail { flex: 1 1 auto; width: 100%; }
  .main-grid { display: flex; flex-direction: column; }
  .primary-card { min-height: 190px; }
  .rail-card { min-height: 90px; }
  .profile-cards:empty { display: none; }

  /* Every one of these containers stacks into what reads as a single
     vertical column of cards down here, so their gaps need to visually
     match each other and .kiosk-shell's own gap - otherwise the fixed
     22px/18px they use above the breakpoint (kept fixed there to match
     app.js's GRID_GAP/PROFILE_CARD_GAP column-width math) looks uneven
     next to the fluid gap everywhere else once that math no longer runs. */
  .content-row, .content-left, .main-grid, .right-rail, .rail-subgrid, .profile-cards {
    gap: var(--stack-gap);
  }

  /* .right-rail/.rail-subgrid's own gap just switched from the tightened
     --group-gap to the plain .kiosk-shell-matching value above - undo
     the matching --group-gap margin-top squeeze on .news-row/.fact-row here too,
     so the announcement/news/quote group keeps pace with that same switch
     instead of staying tighter than the rail group only at these widths. */
  .news-row, .fact-row { margin-top: 0; }

  /* Only one birthday card is shown at a time down here (see
     profileRowCapacity in app.js) - let it take the full row width
     instead of staying pinned to the multi-card desktop row's narrower
     min(364px, 86vw) card width. flex-basis (set via the "flex"
     shorthand on the base .profile-card rule) wins over a plain "width"
     override for a flex item's size, so this has to override "flex"
     itself - a bare "width: 100%" here would be silently ignored. */
  .profile-card { flex: 1 1 100%; min-height: 190px; }

  /* .profile-cards keeps the base rule's "flex: 1 1 0" here (not reset
     to content-sized) so it still shares .content-left's height equally
     with .main-grid at this breakpoint - without this, .main-grid (also
     flex: 1 here) soaks up 100% of any leftover vertical space on its
     own, leaving the climate/transit/weather cards towering over a
     comparatively tiny birthday card below them. */
  .profile-cards { overflow: visible; }

  /* The eyebrow label and divider are fixed chrome that eats a big chunk
     of a narrow phone's width before the actual message even starts,
     leaving too little room for it to stay readable. Dropping them (the
     icon alone still signals "this is an announcement") gives the message
     the width it needs, and the row allows up to 3 wrapped lines instead
     of clipping at a fixed short height. Height is still a fixed px (not
     "auto") sized for that worst case - measured live via Playwright at
     320-900px, a forced 3-line message tops out at 97px - so the row
     never grows/shrinks between a one-line and three-line message; short
     messages just leave more empty space instead of shrinking the row. */
  .rotating-eyebrow, .rotating-divider { display: none; }
  .announcement-row { flex: 0 0 104px; padding: 6px 0; }
  .rotating-message { -webkit-line-clamp: 3; }

  /* Only room for one headline down here - the second slot and its divider
     drop out rather than squeezing two illegibly narrow columns side by
     side, and the remaining slot gets an extra clamped line since there's
     no neighbor competing for width. Same fixed-height reasoning as
     .announcement-row above: a forced 3-line headline measured 121px
     across 320-900px widths (font is already pinned to its 16px clamp
     floor down here), so the row is fixed at that worst case rather than
     "auto" - otherwise the widget visibly grows/shrinks depending on
     which headline happens to be showing. */
  .news-item-secondary, .news-divider { display: none; }
  .news-row { flex: 0 0 128px; }
  .news-headline { -webkit-line-clamp: 3; }

  /* .news-panel's base rule uses align-items: flex-start so two side-by-side
     items with different headline lengths still top-align with each other
     (see that rule's own comment) - irrelevant down here since only one
     item ever shows (.news-item-secondary is hidden above), so flex-start
     was instead just dumping 100% of the row's leftover height below the
     item, making the gap under a short headline look nothing like the gap
     above it. Centering only matters/applies at this single-item width. */
  .news-panel { align-items: center; }

  /* Pre-existing overlap, not caused by anything above - the comment on
     the 600-900px tablet block below already names this exact failure
     mode ("overlapped/clipped text at a plain 1/3 share") and fixes it
     there by giving .weather-card a full-width row instead of a 1/3
     share. That's not enough down here: even at full phone width, three
     icon+label+temp columns sharing it still don't have room side by
     side - measured live, .mini-forecast-temp's nowrap text needed
     113-114px per column but only had 97.6px with the icon and its gap
     eating into that. Stacking the icon above the label/temp instead of
     beside them (rather than shrinking further, which live testing
     found still didn't close the gap at a legible font size) gives the
     text the column's full width instead of width-minus-icon, with
     room to spare. */
  .mini-forecast-day { flex-direction: column; align-items: center; gap: 2px; text-align: center; }
  .mini-forecast-day img { width: 24px; height: 24px; }
  .mini-forecast-label { font-size: 13px; }
  .mini-forecast-temp { font-size: 15px; justify-content: center; }

  /* The base rule pins this to the browser viewport's corner via
     position: fixed - fine when nothing scrolls, but .kiosk-viewport
     scrolls internally at this width once the stack above doesn't fit
     one screen, and a viewport-fixed badge doesn't scroll with it: it
     stays glued over whichever widget is currently at the bottom of the
     screen, including - once scrolled all the way down - the Quote of
     the Day panel, masking its text. Static flow instead: the markup
     already moved this to be .kiosk-scale's last child for exactly this,
     so it renders once, after the last widget, and scrolls normally with
     everything else like an ordinary footer note. */
  #background-credit { position: static; margin: 14px auto 0; width: fit-content; max-width: calc(100% - 32px); }
}

/* 600-900px (portrait tablets, e.g. a 810x1080 iPad) still can't fit
   app.js's weighted 4-column split (see STACK_BREAKPOINT in app.js) any
   better than a phone can, but they have far more width to spare than a
   phone does - the phone-tuned single-column stack above piles all 6
   cards (3 main + 3 rail) into one column and pushes ~800px of content
   below the fold. Rearranging each group into rows (without touching
   app.js's column math, which stays off for the whole sub-900px range)
   recovers most of that height inside a viewport whose width alone is
   routinely 700-830px in this range. */
@media (min-width: 600px) and (max-width: 900px) {
  /* Climate/transit pair up top; weather gets the full-width second row
     to itself instead of a 1/3 column - its mini-forecast needs three
     day-columns of its own (icon + label + temps) and overlapped/clipped
     text at a plain 1/3 share. Both rows get an explicit minmax(0, 1fr)
     (not the default single-row minmax(0, 1fr) plus an implicit "auto"
     row for weather) so climate/transit and weather split .main-grid's
     height evenly instead of weather's row claiming its full natural
     content height first and squeezing climate/transit into whatever's
     left - which, now that .main-grid itself only gets half of
     .content-left (see .main-grid's own flex rule), was little enough
     to visibly crush them into slivers. */
  .main-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
  .weather-card { grid-column: 1 / -1; }
  .primary-card { min-height: 0; }

  /* display: contents unwraps .rail-subgrid so its two children
     (maintenance/room) join .ticket-live-card as flat siblings in the
     grid below - without it, the grid would only ever see two items
     (.ticket-live-card and the .rail-subgrid box itself) and give the
     nested maintenance/room pair a column each of its own instead of
     lining up with the main cards' row. */
  .right-rail { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--stack-gap); }
  .rail-subgrid { display: contents; }
  .rail-card { min-height: 0; }

  /* Both trims below only shave the slack a short tablet viewport doesn't
     have room for - neither lowers a safety floor tuned for content
     (.fact-row's 140px minimum, .kiosk-shell's own gap on wider/shorter
     screens), so nothing here can newly clip text that fit before. */
  .kiosk-shell { gap: clamp(8px, 1.1vh, 22px); }
  .fact-row { flex-basis: clamp(140px, 13vh, 196px); }
}

/* A portrait kiosk can be wider than the phone breakpoint while still being
   far too narrow for four horizontal columns. Treat display shape as a
   first-class input: two main-card columns, followed by one operational row. */
@media (orientation: portrait) and (min-width: 901px) {
  .content-row { flex-direction: column; }
  .content-left, .right-rail { flex: 1 1 auto; width: 100%; }
  .main-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
  .weather-card { grid-column: 1 / -1; }
  .right-rail { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--stack-gap); }
  .rail-subgrid { display: contents; }
  .primary-card, .rail-card { min-height: 180px; }
}

/* Let a widget use space gained when neighboring widgets are disabled. The
   query follows the card's own width, not the physical screen width. */
@container primary-widget (min-width: 520px) {
  .weather-icon { width: clamp(110px, 21cqw, 184px); height: clamp(110px, 21cqw, 184px); }
}

/* Below ~900px viewport width the stack layout gives every card the full
   row width, and above ~900px viewport height (max-height:900px) is what
   shrinks the tile's rows to fit - but a window that's neither (e.g. an
   1280x1024 monitor, or a browser resized to ~1280x1000) hits neither
   rescue and can still land the card at ~240px wide (measured live), where
   the raised row's badge+minutes+clock alone already fill the available
   width. The status text ("+2 min" / "On time") - the only element with
   `flex: 1` slack to give - silently clips to 0 width there rather than
   overflowing visibly, so nothing looks broken except the status just
   isn't there. Drop it outright below this card width instead of letting
   it vanish invisibly; badge/countdown/clock stay fully visible either way. */
@container primary-widget (max-width: 300px) {
  .tile-departure-middle { display: none; }

  /* Even with the status text gone, badge and clock still don't reliably
     share one line once the card gets genuinely narrow (down to ~165px,
     measured live right above the 900px viewport-width stack breakpoint
     where every widget gets squeezed into a full 4/5-column row): the
     badge's own width is driven by real content ("Bus 845" + icon +
     padding), which has a legibility floor no clamp() can shrink past
     without the label becoming unreadable, and that floor alone can
     exceed the available width here. Stack badge above minutes/clock
     instead of letting either one run past the card's edge - each gets
     the row's full width to lay out in, so nothing needs to shrink below
     its floor or get clipped, regardless of how narrow the card gets. */
  .tile-departure-row { grid-template-columns: 1fr; grid-template-areas: "badge" "minutes"; row-gap: 4px; }
  .tile-departure-badge { grid-area: badge; justify-self: start; }
  .tile-departure-minutes-wrap { grid-area: minutes; }
}

/* Large-display scale-up for the tile's departure rows, container-query
   based (not viewport-media-query based) so it tracks the CARD's actual
   width, not the browser window's. On an ordinary 1080p desktop this
   card sits around 368px wide (COLUMN_WEIGHTS split in app.js) and never
   crosses 520px, so this never fires there - it only activates once the
   card itself is genuinely wide, which only happens on displays large
   enough for .kiosk-shell's own max-width (2400-3200px) to let the
   weighted column split produce a ~500px+ card (measured: ~500px at
   2560x1440, ~618px at 3840x2160). A prior viewport-media-query version
   of this rule (min-width:1800px/min-height:950px) fired for an ordinary
   1920x1080 desktop too, where the card stays ~368px, and starved the
   row of width - this container query can't repeat that mistake because
   it reads the card's own size, not the window's. */
@container primary-widget (min-width: 520px) {
  .tile-departure-list { margin-top: clamp(12px, 3cqw, 20px); }
  .tile-departure-row { gap: clamp(12px, 3cqw, 20px); padding: clamp(12px, 3cqw, 20px) clamp(14px, 3.5cqw, 24px); }
  .tile-departure-badge { min-width: clamp(105px, 21cqw, 150px); height: clamp(34px, 9cqw, 56px); font-size: clamp(18px, 3.5cqw, 26px); }
  .tile-departure-row-next .tile-departure-badge { height: clamp(38px, 10cqw, 62px); font-size: clamp(19px, 3.8cqw, 28px); }
  .tile-departure-middle { font-size: clamp(15px, 3.2cqw, 20px); }
  .tile-departure-minutes { font-size: clamp(21px, 4.5cqw, 32px); }
  .tile-departure-row-next .tile-departure-minutes { font-size: clamp(28px, 6cqw, 42px); }
  .tile-departure-minutes-wrap { min-width: clamp(112px, 23cqw, 164px); }
  .tile-departure-clock { font-size: clamp(15px, 3.2cqw, 20px); }
}

/* .transit-route-name--origin shares --primary-title-size with the wide
   primary-card headings too, and for the same reason as .rail-label above,
   wraps to two lines on an ordinary 1920x1080 desktop (~368px transit
   card). That extra wrapped line eats into the fixed height content-row's
   JS sizing (layoutRailCards in app.js) gives the transit card, leaving
   #departures-list too little room for kiosk-size rows - .departure-list's
   justify-content:center then centers that overflow, pushing it up into
   the now-taller heading instead of just past the card's own bottom edge
   (which .primary-card's overflow:hidden would clip cleanly). Shrinking
   the heading back to one line on a narrow card fixes the actual cause;
   flex-start is also strictly safer than center as a fallback for any
   remaining edge case, since .primary-card always clips overflow downward,
   never upward into content above it. */
@container primary-widget (max-width: 460px) {
  .transit-route-name--origin { font-size: clamp(18px, 7cqw, 34px); }
}
.departure-list { justify-content: flex-start; }
/* The main dashboard tile almost always renders .tile-departure-list, not
   .departure-list above - updateTransit() in app.js only uses the latter
   for the empty/error state ("no departures"/"unavailable"), so this is
   actually the more common case of the same centered-overflow-into-heading
   bug documented above, confirmed live at 3840x1080: a single-line heading
   (wide enough card that the shrink rule below never needs to apply) still
   overlapped, because .tile-departure-list's own three badge/tile rows -
   which scale up once the card crosses the 520px container-query threshold
   above - simply didn't fit the cardHeight app.js had available at that
   size. Same fix, same reasoning: flex-start can only overflow downward
   into .primary-card's own overflow:hidden clip, never upward into the
   heading. */
.tile-departure-list { justify-content: flex-start; }

/* Large wall displays keep scaling beyond the old 1080p ceilings. vmin makes
   the growth respect both dimensions, while the caps preserve readability. */
@media (min-width: 1800px) and (min-height: 950px) {
  /* The clock's very large font has substantial invisible ascent space in
     its line box. Equal numeric block padding therefore looks unequal: the
     first visible pixels sit much farther from the top than the last card
     sits from the bottom. Use a smaller fluid top inset as optical
     compensation while preserving the full bottom safe area. */
  .kiosk-shell {
    max-width: 3200px;
    --primary-title-size: clamp(40px, 2.45vmin, 60px);
    --primary-subtitle-size: clamp(18px, 1.1vmin, 25px);
    --primary-reading-size: clamp(96px, 7vmin, 150px);
    --primary-detail-size: clamp(24px, 1.6vmin, 36px);
    padding: clamp(4px, .35vmin, 12px) clamp(60px, 3.6vmin, 110px) clamp(50px, 3vmin, 88px);
  }
  #datetime-weekday { font-size: clamp(22px, 1.35vmin, 34px); }
  #datetime-date { font-size: clamp(36px, 2.1vmin, 54px); }
  #datetime-time { font-size: clamp(120px, 7vmin, 176px); }
  .primary-card { padding: clamp(32px, 2vmin, 52px); }
  .weather-card {
    display: grid;
    grid-template-columns: minmax(0, 1.65fr) minmax(300px, .45fr);
    grid-template-rows: auto minmax(0, 1fr);
    column-gap: clamp(34px, 2.5vmin, 64px);
  }
  .weather-card > .card-heading { grid-column: 1 / -1; }
  .weather-current { grid-column: 1; grid-row: 2; min-height: 0; }
  .weather-mini-forecast {
    grid-column: 2;
    grid-row: 2;
    min-height: 0;
    flex-direction: column;
    justify-content: center;
    gap: 0;
    padding: clamp(12px, .75vmin, 18px) 0 0 clamp(28px, 2vmin, 48px);
    border-top: 0;
    border-left: 0;
  }
  .weather-mini-forecast::before {
    content: "";
    position: absolute;
    top: calc(50% + clamp(6px, .375vmin, 9px));
    left: 0;
    height: clamp(204px, 16.4vmin, 324px);
    transform: translateY(-50%);
    border-left: 1px solid oklch(1 0 0 / .12);
  }
  .mini-forecast-day {
    position: relative;
    flex: 0 0 auto;
    gap: clamp(14px, 1vmin, 22px);
    padding: clamp(16px, 1.25vmin, 28px) 0;
    border-bottom: 0;
  }
  .mini-forecast-day:not(:last-child)::after {
    content: "";
    position: absolute;
    bottom: 0;
    left: 0;
    width: 82%;
    border-bottom: 1px solid oklch(1 0 0 / .1);
  }
  .mini-forecast-day img { width: clamp(36px, 2.2vmin, 52px); height: clamp(36px, 2.2vmin, 52px); }
  .mini-forecast-label { font-size: clamp(17px, 1.05vmin, 24px); }
  .mini-forecast-temp { font-size: clamp(22px, 1.4vmin, 32px); }
  /* The weather readout is substantially taller than the climate value
     because it pairs the large temperature with a condition line and icon.
     Centering that entire group put the temperature's baseline visibly above
     the climate value/transit content. Offset only the readout so the main
     values share one optical horizontal line while the forecast remains
     anchored to the card bottom. */
  .weather-icon { transform: translateY(clamp(-14px, -.7vmin, -12px)); }
  .large-icon { width: clamp(64px, 4vmin, 102px); height: clamp(64px, 4vmin, 102px); flex-basis: clamp(64px, 4vmin, 102px); }
  .large-icon > span, .weather-chip img { transform: scale(1.15); }
  .departure-list { gap: clamp(18px, 1.4vmin, 32px); }
  /* No .tile-departure-* scale-up here, unlike .departure-list above (the
     dialog's) - this query fires for an ordinary 1920x1080 desktop, not
     just genuine ultrawide displays, and the tile's own card width is
     capped by updateMainGridColumns()'s weighted column split (app.js),
     not free to grow with viewport size the way a lone icon/value can.
     Measured live at 1920x1080: scaling the badge/minutes/clock up here
     the same way .large-icon/.weather-icon do left 0px for the status
     text ("+1 min" present in the DOM, rendered at 0 width, invisible) -
     the base clamp() rules below already reach their own ceiling by
     ~1390px viewport width, so this card needs no separate large-display
     rule at all. */
  .rail-card, .maintenance-live-card { padding: clamp(32px, 2vmin, 52px); }
  .rail-icon { width: clamp(64px, 4vmin, 102px); height: clamp(64px, 4vmin, 102px); flex-basis: clamp(64px, 4vmin, 102px); }
  .rail-icon > span, .rail-icon > svg { transform: scale(1.65); }
  .rail-label { font-size: var(--primary-title-size); }
  .rail-card strong, #maintenance-live-value { font-size: clamp(28px, 1.8vmin, 44px); }
  .rail-sub { font-size: clamp(18px, 1.2vmin, 28px); }
  .time-pill { font-size: clamp(16px, 1.1vmin, 26px); padding: 8px 16px; }
  .announcement-row { flex-basis: clamp(80px, 7.4vh, 120px); }
  .rotating-message { font-size: clamp(32px, 2vmin, 48px); }
  .news-row { flex-basis: clamp(138px, 10vh, 190px); }
  .news-headline { font-size: clamp(21px, 1.35vmin, 32px); }
  .fact-row { flex-basis: clamp(196px, 18vh, 280px); }
  .fact-text { font-size: clamp(22px, 1.45vmin, 34px); }
  .profile-card { flex-basis: var(--profile-card-width, clamp(364px, 20vw, 520px)); }
}

/* Ultrawide signage should use its horizontal canvas rather than being
   letterboxed by the former 2400px kiosk maximum. */
@media (min-aspect-ratio: 21 / 9) and (min-width: 2200px) {
  .kiosk-shell { max-width: none; }
  .kiosk-page--links { max-width: none; }
}

/* A landscape tablet (e.g. a 1180x820 iPad) is wide enough to keep
   app.js's normal >900px side-by-side columns - the widget stacking above
   is irrelevant here - but is just as short as a portrait one, so the same
   two height-driven trims apply on their own, independent of width. Kept
   as a separate query (rather than folded into the one above) since this
   one must also reach the >900px band the width query intentionally
   excludes. */
@media (max-height: 900px) {
  /* Redefines the --stack-gap custom property itself, inherited by every
     descendant below (.right-rail/.rail-subgrid's own calc(--stack-gap -
     8px) rule, and the max-width:900px block's plain var(--stack-gap)
     rule), rather than overriding .kiosk-shell's own "gap" property with
     its own separate clamp() literal - the rail/ticker spacing match this
     file otherwise goes out of its way to keep (see --stack-gap's own
     definition comment above) depends on every consumer of "the current
     stack gap" reading the exact same value, at every combination of
     width and height, not a hand-copied literal that can drift out of
     sync with it. */
  .kiosk-shell { --stack-gap: clamp(8px, 1.1vh, 22px); padding-top: clamp(10px, 2vh, 30px); padding-bottom: clamp(10px, 2vh, 30px); }

  /* Everything below this point was still sized purely off vw (width),
     never vh - fine for a short-but-narrow phone, where the max-width:900px
     block above already shrinks it, but a short-and-WIDE screen (a laptop's
     built-in display, no external monitor) has plenty of width to keep
     these at their large vw-driven size while having no vertical room to
     spare, so the page overflowed the window and fell back to the
     .kiosk-viewport scroll safety net even at ordinary desktop widths.
     Measured live at 1440x818 (a 13" MacBook window minus title bar): the
     six rows below totalled 950px against an 818px viewport before this
     block, 132px of forced scrolling. These clamp()s reuse the vh-based
     approach above instead so a short window shrinks them too, regardless
     of width. */
  #datetime-time { font-size: clamp(32px, 8vh, 120px); }
  /* padding/.large-icon/.primary-value use clamp(floor, calc(100vh +
     originalVwTerm - 900px), ceiling) rather than a flat vh value - climate
     ("Indoor climate") and transit ("Uster, Linde") were the two cards
     reported as visibly snapping on resize (2026-08-14), and this is why:
     a flat vh value is a *different formula* than the un-queried vw-based
     rule, so it only ever agreed with it by coincidence at one specific
     height, and visibly jumped everywhere else when a resize crossed the
     900px line. calc(100vh + originalVwTerm - 900px) instead is
     guaranteed to equal exactly originalVwTerm when height is exactly
     900px, for any width (100vh is then literally 900px, so it cancels
     out) - below 900px it's smaller, so clamp()'s floor catches it, and
     stays pinned at that floor until height gets within
     ceiling-floor of 900px. Climate/transit/weather sit in one CSS Grid
     row and stretch to match whichever is tallest (default grid item
     align-items:stretch) - so even though only climate/transit's own
     content was reported as jumpy, weather's still-unfixed icon/value
     below kept dragging their rendered *box* height along with it
     regardless (confirmed live: climate/transit's own font/padding/icon
     stopped jumping first, but their card height didn't, until weather's
     were fixed too) - all three have to move together here for the same
     reason .content-row's own stretch to .right-rail required the rail
     fix earlier. */
  .primary-card { padding: clamp(12px, calc(100vh + 1.7vw - 900px), 32px); }
  .large-icon { width: clamp(30px, calc(100vh + 2.7vw - 900px), 52px); height: clamp(30px, calc(100vh + 2.7vw - 900px), 52px); flex-basis: clamp(30px, calc(100vh + 2.7vw - 900px), 52px); }
  .weather-icon { width: clamp(40px, calc(100vh + 5.7vw - 900px), 110px); height: clamp(40px, calc(100vh + 5.7vw - 900px), 110px); }
  .weather-value, .primary-value { font-size: clamp(26px, calc(100vh + 3.96vw - 900px), 76px); }
  .weather-mini-forecast { min-height: clamp(48px, calc(100vh - 834px), 66px); padding-top: clamp(12px, calc(100vh - 880px), 20px); }
  /* .primary-value (climate/transit's big readout, e.g. "31.4°C") is
     .weather-value's twin on the other two primary cards - same vw-only
     blind spot, and the most visible jump of the three (biggest font on
     the card), which is why it's fixed here even though .weather-value
     itself isn't yet. */

  /* The climate/transit/weather primary cards sit in one CSS Grid row and
     stretch to match whichever is tallest (default grid item
     align-items:stretch) - the transit tile's departure rows (3 bus rows)
     turned out to be that tallest one, and entirely vw/fixed-px sized (185px
     measured at 1366x768), taller than either of the other two cards' own
     content even after the trims above. Shrinking it is what actually caps
     the whole card row's height on a short screen, more than any single
     property on weather/climate did. Same calc()-ramp technique as above for
     the same reason - the .tile-departure-* rules just below are transit's
     ("Uster, Linde") own content; .departure-list itself is the dialog's
     shared list wrapper and isn't part of this card's height at all. */
  .departure-list { gap: clamp(6px, calc(100vh - 886px), 14px); }
  .tile-departure-list { margin-top: clamp(4px, calc(100vh + 2.9cqw - 900px), 12px); }
  .tile-departure-row { gap: clamp(4px, calc(100vh + 2.8cqw - 900px), 12px); padding: clamp(4px, calc(100vh + 2.8cqw - 900px), 11px) clamp(6px, calc(100vh + 3.2cqw - 900px), 14px); }
  /* Same vw→cqw fix as the base rules above (this block reused their old vw
     coefficients when it was written, so it inherited the same "sized for
     the whole window, not the ~20%-of-window card it's actually in" bug -
     it just showed up together with the short-screen shrink this block is
     otherwise for, instead of on its own). */
  .tile-departure-badge { min-width: clamp(54px, calc(100vh + 19.6cqw - 900px), 100px); height: clamp(20px, calc(100vh + 7.3cqw - 900px), 34px); font-size: clamp(11px, calc(100vh + 3.3cqw - 900px), 17px); }
  .tile-departure-row-next .tile-departure-badge { height: clamp(24px, calc(100vh + 8.1cqw - 900px), 38px); font-size: clamp(12px, calc(100vh + 3.7cqw - 900px), 18px); }
  .tile-departure-middle { font-size: clamp(10px, calc(100vh + 3.1cqw - 900px), 15px); }
  .tile-departure-minutes { font-size: clamp(13px, calc(100vh + 4.5cqw - 900px), 21px); }
  .tile-departure-row-next .tile-departure-minutes { font-size: clamp(17px, calc(100vh + 6.3cqw - 900px), 30px); }
  .tile-departure-minutes-wrap { min-width: clamp(72px, calc(100vh + 22.3cqw - 900px), 116px); }
  .tile-departure-clock { font-size: clamp(10px, calc(100vh + 3.1cqw - 900px), 15px); }

  /* .news-row keeps its own overflow:hidden backstop (see the comment on
     its base rule) so a too-tall headline clips instead of pushing the
     fact row down - but that only stays invisible if .news-headline's font
     shrinks in step with the row's own basis. Both are pinned to the same
     8.5vh mid-value for that reason - don't change one without the other. */
  .news-row { flex-basis: clamp(64px, 8.5vh, 138px); }
  .news-panel { padding: clamp(8px, 1.6vh, 20px) clamp(14px, 2.4vh, 32px); }
  .news-icon-chip { flex-basis: 32px; width: 32px; height: 32px; }
  /* One line on short screens: the 8.5vh row only holds eyebrow +
     one headline line + meta, so a 2-line headline pushed its own
     meta line (and half of line 2) out of the panel. */
  .news-headline { font-size: clamp(13px, 1.9vh, 21px); -webkit-line-clamp: 1; }
  .news-meta { font-size: 11px; }

  /* .fact-row (below) has no overflow:hidden of its own, so unlike
     .news-row it can't just clip - its rendered height is always at least
     its content's, no matter how small the flex-basis claims to be. So the
     basis alone (already vh-based above the -8.5vh news-row addition) was
     never the real ceiling; the content sizing here is. */
  .fact-row { flex-basis: clamp(96px, 13vh, 196px); }
  .fact-panel { padding: clamp(10px, 1.6vh, 20px) clamp(14px, 2.4vh, 32px); padding-top: clamp(12px, 2vh, 28px); }
  .fact-label { margin-bottom: 6px; }
  .fact-text { font-size: clamp(13px, 1.9vh, 22px); margin-bottom: 4px; }
  /* #fact-credit's own size/position isn't touched by this query (still
     11px font, 14px bottom offset), so its ~20px pill height still needs
     the same clearance as before - shrinking this past ~26px lets the
     attribution line's box overlap the credit pill (measured 4px of
     overlap at 1440x818 with a 16px margin). */
  .fact-sub { margin-bottom: 26px; }

  /* Rail-card padding/gap/icon/text - the cards' own height still comes
     from app.js's layoutRailCards(), which reads window.innerHeight
     against the same 900px threshold and swaps in a shorter _COMPACT
     height constant (98px) sized for this tighter padding. The two must
     move together: shrinking padding without also shrinking the JS-set
     height (or vice versa) either wastes the padding cut or clips
     content. Because .content-row stretches .content-left to match
     .right-rail's height (default flex align-items:stretch), this rail
     height is also the single biggest lever on the *whole page's* height
     on a short screen - more than any property on the main cards/profile
     row above, confirmed by forcing it smaller directly on a live page
     and watching the whole page shrink in step. Icon/label/strong/sub
     were still their large kiosk-tuned sizes even at the 129px card
     height this replaced; a real laptop window (measured live at
     1366x768 and 1920x700-800) still needed the .kiosk-viewport scroll
     fallback with those, so they shrink here too, not just the
     padding/gap. */
  .maintenance-live-card { padding: clamp(12px, calc(100vh + 1.7vw - 900px), 32px); }
  .rail-card { gap: 3px; }
  .rail-icon { width: clamp(30px, calc(100vh + 2.7vw - 900px), 52px); height: clamp(30px, calc(100vh + 2.7vw - 900px), 52px); flex: 0 0 clamp(30px, calc(100vh + 2.7vw - 900px), 52px); }
  .rail-icon > span, .rail-icon > svg { transform: scale(1.1); }
  .rail-label { font-size: var(--primary-title-size); }
  .rail-sub { font-size: 11px; }
  .rail-card strong, #maintenance-live-value { font-size: clamp(14px, 1.15vw, 22px); }

  /* profile-cards no longer carries its own fixed/clamped height here -
     it shares .content-left's height 50/50 with .main-grid (see the
     base .profile-cards rule), so a short screen just shrinks both rows
     together rather than needing a separate vh-tuned band for this one.
     The padding/gap/avatar/name/role shrinks below still matter on
     their own: even at a taller row, a short screen leaves less width
     to work with per the "wide-but-short" laptop shape this block
     targets. */
  .profile-card { padding: clamp(10px, calc(100vh - 880px), 20px) clamp(14px, calc(100vh - 878px), 22px); gap: clamp(4px, calc(100vh - 890px), 10px); }
  .avatar { width: clamp(36px, calc(100vh - 844px), 56px); height: clamp(36px, calc(100vh - 844px), 56px); flex-basis: clamp(36px, calc(100vh - 844px), 56px); font-size: clamp(15px, calc(100vh - 876px), 24px); }
  .profile-name { font-size: clamp(18px, calc(100vh - 876px), 24px); margin-top: clamp(2px, calc(100vh - 896px), 4px); }
  .profile-role { font-size: clamp(13px, calc(100vh - 884px), 16px); }
}

/* The max-height:900px block above trades vw-based sizing for vh-based on
   the theory that a short screen is also reasonably wide (a laptop's
   built-in display) - see its own opening comment. A phone in portrait
   breaks that assumption: it's short in the same literal sense (e.g.
   844px CSS height, under the 900px line) but nowhere near as wide, so
   formulas tuned for "plenty of width, no height" produce oversized
   results instead of compensating for anything - #datetime-time measured
   67.5px on a 390px-wide phone via its 8vh clamp, nearly double the 40px
   floor its own un-queried vw clamp would give at that width. Scoped to
   max-width:599px (the phone/tablet line used elsewhere in this file, one
   under the tablet block's 600px floor) so it only reclaims the phone
   case; the laptop-tuned formulas above stay untouched for the short-wide
   screens they exist for.

   .news-row/.news-headline have the opposite-looking but same-cause bug:
   the laptop block's 8.5vh/1.9vh clamps *undersize* them on a short phone
   (e.g. 64px/13px at a 667px-tall SE) well below what the max-width:900px
   block's own un-queried 128px/16px phone sizing already measured live as
   the real requirement for a wrapped 3-line headline. .news-row clips via
   overflow:hidden (see its base rule), so that undersizing didn't push
   other rows around - it silently ate the bottom of the headline/meta
   instead, which is what made the news card's top and bottom padding look
   uneven (confirmed live: a 3-line headline overflowed the panel's own
   bottom edge by 30-40px on SE/XR/12 Pro before this fix). Restoring both
   to their un-queried phone values here, same pattern as #datetime-time
   above. */
/* Both the base and short-screen rules size this row in vh. Give every
   narrow phone the established 140px mobile/tablet safety height instead,
   including tall Pro Max models outside the max-height query. This also
   prevents expanding/collapsing mobile browser chrome from resizing it.

   A flex-basis is only a floor here, though: .fact-row has no
   overflow/min-height of its own, so whenever the shown item was taller
   than the basis the row grew to fit it - 140px for a 1-line quote, 150px
   for 2 lines + author, 173px for 3 lines + author (measured at 390x844),
   so the card changed height on every rotation. Phones therefore clamp to
   2 lines (longer items get the usual fade + "Tap to read in full") and
   the basis is the height of the tallest item that can then occur,
   2 lines + author: 166px with the base spacing (phones taller than
   900px), and 120px + the panel's own top/bottom padding inside the
   max-height:900px block below (that padding is vh-based there - the
   max() floors mirror its clamp() minimums). Every item now
   fits the basis, so the row keeps exactly that height. No min-height: 0
   on purpose - if a fallback font ever makes an item taller, the row still
   grows instead of cutting the author line off. */
@media (max-width: 599px) {
  .fact-row { flex: 0 0 166px; }
  .fact-text { -webkit-line-clamp: 2; }
}

@media (max-height: 900px) and (max-width: 599px) {
  #datetime-time { font-size: clamp(40px, 6.25vw, 120px); }
  .fact-row { flex-basis: calc(120px + max(12px, 2vh) + max(10px, 1.6vh)); }
  .fact-text { font-size: clamp(17px, 1.4vw, 22px); }
  .news-row { flex-basis: 128px; }
  .news-headline { font-size: clamp(16px, 1.2vw, 21px); }
}

/* These two rules deliberately sit at the very end of the file. Both need
   to win over every other .rail-label / .weather-card grid-template-columns
   declaration above regardless of which viewport media query also matches
   (base, the max-height:900px compact block, and the
   min-width:1800px/min-height:950px kiosk block all set one or the other) -
   since specificity is equal throughout, only source order decides, and a
   container query gets no special priority over a media query for that.
   An earlier version of both rules was placed right after the properties
   they were meant to override and silently lost every one of those fights.

   .rail-label normally follows --primary-title-size, shared with the wide
   primary-card headings - fine at most sizes, but that variable jumps to
   40-60px in kiosk mode regardless of how wide the rail card itself
   actually is, and even the base/compact sizing can be too big for a
   narrow rail card at in-between sizes. An ordinary 1920x1080 desktop
   still only gives each rail card ~333px (measured live), where a 40px
   "Tickets today" cannot fit even the icon+label row - it wraps and gets
   ellipsis-truncated mid-word. Same mistake this file already fixed once
   for the departures dialog's tile rows (see the
   `@container primary-widget (min-width:520px)` block above) - a container
   query reads the card's own width instead of the window's, so it only
   softens the font where the card is genuinely narrow and leaves genuinely
   wide kiosk cards (e.g. ~542px at 3008x1624) untouched. */
@container rail-widget (max-width: 460px) {
  .rail-label { font-size: clamp(15px, 7cqw, 24px); }
  /* Same fix, same reason, for the value/subtitle text below the label.
     Both are viewport-width-driven (1.15vw/.78vw base, and even bigger
     1.8vmin in the min-width:1800px/min-height:950px kiosk block, which
     floors at 28px regardless of how narrow the card actually is) - fine
     on a genuinely wide kiosk card (~542px at 3008x1624), but on an
     ordinary 1920x1080 desktop's ~333px card, or the narrow card a window
     just past the kiosk block's own threshold still gets, that's too
     large for #maintenance-live-value's deliberately-unclamped "Overdue by
     N days" text or .rail-sub's "last done" line to fit on one line -
     confirmed live: at 1920x1080 the wrap this forces was the single
     biggest contributor to the rail needing 256px/card instead of the
     150px RAIL_CARD_HEIGHT this file assumes elsewhere, pushing the whole
     page 252px past the window even after every JS shrink step
     (layoutRailCards in app.js) had already hit its own floor. Capped at
     each element's own ordinary (non-kiosk) ceiling, so this only ever
     softens the oversized kiosk-tier font on a card that's actually
     narrow - a genuinely wide kiosk card is untouched, matching how
     .rail-label above already behaves. */
  .rail-card strong, #maintenance-live-value {
    font-size: clamp(14px, 6cqw, 22px);
  }
  .rail-sub { font-size: clamp(11px, 4cqw, 15px); }
}

/* The kiosk breakpoint's weather-card grid gives the mini-forecast column a
   hard 300px floor, sized for genuinely wide kiosk displays. At an ordinary
   1920x1080 desktop the card is only ~666px wide (measured live), so that
   fixed 300px leaves just ~366px for the icon+temperature column - not
   enough for the kiosk-size reading font, which then visibly overlaps the
   forecast column instead of sitting cleanly beside it. Same repeat of the
   viewport-vs-card-width mistake documented above; the fix is the same too
   - read the card's own width instead of assuming kiosk-mode implies a
   wide card. Left untouched on the genuinely wide cards this grid was
   designed for (~940px+ at 3008x1624 and up).

   Queries .main-grid (container-name main-widgets), not .weather-card
   itself, even though .weather-card is also a container (via
   .primary-card's container-type, used by the `primary-widget`-named
   queries elsewhere in this file): a container query's condition can only
   read an *ancestor* container's size, never the size of the element the
   query is about to style - .weather-card querying its own width to set
   its own grid-template-columns is exactly the resize-loop CSS containment
   forbids, and is silently ignored rather than erroring, which is why an
   earlier version of this rule (identical condition, `primary-widget`
   instead of `main-widgets`) never actually applied. main-grid's width
   tracks weather-card's own width at a fixed ~0.46 ratio (both driven by
   the same COLUMN_WEIGHTS split in app.js), so 1630px here lines up with
   the same ~750px card width the comment above and the rest of this file
   reasons about. */
@container main-widgets (max-width: 1630px) {
  .weather-card { grid-template-columns: minmax(0, 1.65fr) minmax(150px, .45fr); }
  /* The min-width:520px container-query rule above sizes this icon up to
     184px for wide primary cards - reasonable when the whole card is wide,
     but at the narrow end of this range (~666px weather-card, right at the
     kiosk breakpoint's minimum qualifying width) it's still ~140px, tight
     enough against the reading font to graze the forecast column at
     exactly 1800px wide (gone again by ~1900px). 110px matches this same
     property's own base (non-kiosk) ceiling a few rules above, not a new
     number. */
  .weather-icon { width: 110px; height: 110px; }
}

/* Same narrow kiosk weather card: that 150px forecast column leaves each
   day's text only 72px beside its icon, but "H -12° · L -15°" at the
   kiosk's 22px needs ~147px (~150px in German, "T"), so the line ran past
   the card's edge and its overflow:hidden cut it off ("L -1", and even
   "L 2" for warm days) at 1920x1080. High and low go on two lines here
   instead (separator dropped, it would only dangle).

   That extra line must not add layout height: the forecast's content
   height is what sizes this whole top row on the kiosk (app.js
   syncMainGridMinHeight reads the cards' scrollHeight), so a taller
   forecast grew every card in the row and pushed the page down. So the
   low line hangs below the high one with height 0 (its text still shows),
   the days share the column's existing spare height (flex-grow) instead
   of growing it, and the text block is shifted up by half a line
   (transform: visual only, the 22px kiosk temperature size x 0.6) so the
   three lines sit centred next to the icon. */
@media (min-width: 1800px) and (min-height: 950px) {
  @container main-widgets (max-width: 1630px) {
    .mini-forecast-day { flex-grow: 1; }
    .mini-forecast-day > div { transform: translateY(calc(clamp(22px, 1.4vmin, 32px) * -.6)); }
    .mini-forecast-temp { flex-direction: column; align-items: flex-start; gap: 0; }
    .mini-forecast-low { height: 0; min-height: 0; }
    .mini-forecast-separator { display: none; }
  }
}

/* Laptop-size windows (not kiosk, wider than the 900px stacked layout):
   the forecast is a row of three days, and on a narrow weather card
   (e.g. 442px of content width at 1440x900) each day has ~93px beside its
   icon while "H -10° · T -18°" at 19px needs ~130px - it ran into the next day's
   icon, and the last day's low was cut off at the card's edge. Same
   two-line fix as the kiosk rule above, for the same reason with height 0
   on the low line: the forecast row's height (and so the big temperature
   readout above it, which shares the card's height) must not change. The
   half-line shift (the 19px temperature size x 0.6) centres the three lines
   on the icon. 559px: the card's content width below which the widest
   one-line value (German, two-digit negatives) no longer fits its day
   (measured: ~1px over at 559-560px wide, 11px over at 527px, fits at
   600px, e.g. 1920x900). */
@media (min-width: 901px) and (max-width: 1799px), (min-width: 1800px) and (max-height: 949px) {
  @container primary-widget (max-width: 559px) {
    .mini-forecast-day > div { transform: translateY(calc(19px * -.6)); }
    .mini-forecast-temp { flex-direction: column; align-items: flex-start; gap: 0; }
    .mini-forecast-low { height: 0; min-height: 0; }
    .mini-forecast-separator { display: none; }
  }
}

/* --- Page 2: Quick links (swipeable second kiosk page) ---
   The spec (LINKS_PAGE_ADDENDUM.md §3) assumes the old fixed-1920x1080
   design-tool canvas, scaled to the real screen. This app deliberately
   moved off that model (see the .kiosk-scale comment above) so it reflows
   natively across kiosk/laptop/tablet/phone - so instead of translating by
   literal 1920px steps, .page-track's children are each flex:0 0 100% of
   whatever the real viewport is, and app.js drives the same 420ms
   cubic-bezier snap / 0.25 rubber-band / ~13.5%-of-width commit threshold
   physics against that real, measured width. */
.page-track {
  display: flex;
  align-items: flex-start;
  touch-action: pan-y;
  transition: transform 420ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
.page-track.page-track--dragging { transition: none; }
/* The track is as tall as its tallest page, so whichever page is NOT
   showing is cut to screen height - otherwise a page that has to scroll
   makes the other one scroll too. */
.page-track--links .kiosk-page--dashboard,
.page-track:not(.page-track--links) .kiosk-page--links { max-height: 100vh; overflow: hidden; }
.kiosk-page { flex: 0 0 100%; min-width: 0; box-sizing: border-box; }
.kiosk-page--links {
  min-height: 100vh;
  min-height: 100lvh;
  display: flex;
  flex-direction: column;
  gap: 26px;
  padding: 50px 60px 84px;
  box-sizing: border-box;
}
.links-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; flex: 0 0 auto; }
.links-eyebrow { font-size: 22px; font-weight: 600; color: #fff; letter-spacing: 0.02em; text-shadow: 0 2px 12px oklch(0 0 0 / 0.5); }
.links-title { font-size: 36px; font-weight: 700; margin-top: 6px; letter-spacing: -0.01em; color: #fff; text-shadow: 0 2px 12px oklch(0 0 0 / 0.5); }
.links-hint { font-size: 20px; font-weight: 600; color: oklch(1 0 0 / 0.75); text-shadow: 0 2px 12px oklch(0 0 0 / 0.5); }
/* Side by side on landscape screens: wrapping the hint under the title
   cost a whole extra line and pushed the last category below the fold
   on ~1024px-wide screens. */
@media (min-width: 901px) {
  .links-header { flex-wrap: nowrap; }
  .links-hint { max-width: 45%; text-align: right; }
}
.links-body { flex: 1; min-height: 0; display: flex; flex-direction: column; justify-content: space-between; gap: 22px; }
.links-group { display: flex; flex-direction: column; gap: 12px; }
.links-group-header { display: flex; align-items: center; gap: 14px; }
.links-cat-label { font-size: 15px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.09em; text-shadow: 0 2px 10px oklch(0 0 0 / 0.5); }
.links-cat-rule { flex: 1; height: 1px; background: oklch(1 0 0 / 0.25); }
.links-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.link-card {
  cursor: pointer;
  /* Same glass treatment as every other clickable dashboard card
     (.glass-card/.rail-card/.profile-card/.news-panel/.fact-panel) -
     this used to be a one-off flat, far more transparent color with no
     blur, which is why these cards looked visibly different (background
     photo showing through sharp instead of frosted) from the rest of
     the kiosk. */
  background: var(--glass);
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(14px) saturate(1.15);
  border-radius: 22px;
  padding: 28px 24px;
  box-shadow: 0 16px 40px oklch(0 0 0 / 0.22);
  display: flex;
  align-items: center;
  gap: 18px;
  min-width: 0;
  color: oklch(0.14 0.01 250);
}
.link-mark {
  width: 56px;
  height: 56px;
  flex: 0 0 56px;
  border-radius: 17px;
  display: grid;
  place-items: center;
  font-size: 21px;
  font-weight: 800;
  letter-spacing: -0.02em;
  background: oklch(0.45 0.1 var(--link-hue) / 0.35);
  color: oklch(0.24 0.09 var(--link-hue));
}
.link-text { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.link-title, .link-desc, .link-host { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-title { font-size: 21px; font-weight: 700; line-height: 1.15; }
.link-desc { font-size: 15px; color: oklch(0.24 0.01 250); }
.link-host { font-size: 13px; font-weight: 600; color: oklch(0.32 0.02 250 / 0.8); }
.links-empty { flex: 1; display: grid; place-items: center; font-size: 26px; font-weight: 600; color: oklch(1 0 0 / 0.7); text-align: center; padding: 0 40px; }

.page-dots { position: fixed; left: 0; right: 0; bottom: 26px; z-index: 5; display: flex; align-items: center; justify-content: center; gap: 14px; pointer-events: none; }
.page-dot { pointer-events: auto; cursor: pointer; width: 12px; height: 12px; border-radius: 50%; background: oklch(1 0 0 / 0.35); box-shadow: 0 1px 6px oklch(0 0 0 / 0.45); }
.page-dot.active { background: #fff; }

.browser-overlay { position: fixed; inset: 0; z-index: 20; background: oklch(0.1 0.02 250 / 0.6); display: flex; align-items: center; justify-content: center; padding: 60px; box-sizing: border-box; }
/* Not in the brief's literal CSS list: without this, the browser's UA
   stylesheet rule for the `hidden` attribute (`[hidden]{display:none}`)
   loses to the `display:flex` above, because author-origin styles beat
   UA-origin styles at equal specificity - so `<div ... hidden>` rendered
   the overlay covering the whole dashboard on page load. This is an
   attribute selector (specificity 0-1-0) added to the class selector
   (0-1-0) for 0-2-0 total, so it wins over the plain `.browser-overlay`
   rule above regardless of source order. Needed to satisfy this task's
   own "page 1 renders exactly as before" checkpoint; Task 4's JS toggles
   the `hidden` attribute to show/hide this overlay. */
.browser-overlay[hidden] { display: none; }
.browser-sheet { width: 100%; height: 100%; background: oklch(0.98 0.005 250); border-radius: 26px; overflow: hidden; box-shadow: 0 30px 90px oklch(0 0 0 / 0.45); display: flex; flex-direction: column; color: oklch(0.14 0.01 250); }
.browser-chrome { flex: 0 0 auto; display: flex; align-items: center; gap: 16px; padding: 18px 22px; border-bottom: 1px solid oklch(0.14 0.01 250 / 0.1); }
.browser-back { flex: 0 0 auto; height: 46px; padding: 0 20px; border: 1.5px solid oklch(0.14 0.01 250 / 0.2); border-radius: 999px; background: transparent; color: oklch(0.2 0.02 250); font-size: 16px; font-weight: 700; font-family: inherit; cursor: pointer; }
.browser-url { flex: 1; min-width: 0; height: 46px; border-radius: 999px; background: oklch(0.93 0.005 250); display: flex; align-items: center; padding: 0 20px; font-size: 16px; color: oklch(0.35 0.02 250); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.browser-title { flex: 0 0 auto; max-width: 30%; font-size: 15px; font-weight: 700; color: oklch(0.4 0.02 250); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.browser-body { flex: 1; min-height: 0; position: relative; }
.browser-body iframe { width: 100%; height: 100%; border: 0; display: block; }
.browser-fallback-note { position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 22px; background: oklch(0.96 0.005 250); border-top: 1px solid oklch(0.14 0.01 250 / 0.08); font-size: 13px; color: oklch(0.45 0.02 250); }

/* Same narrow-viewport treatment the rest of the kiosk already uses
   (see the existing max-width:900px block) - shrinks Quick Links to fit
   a portrait tablet/phone instead of overflowing at 4 columns. */
@media (max-width: 900px) {
  .kiosk-page--links { padding: 28px 20px 96px; gap: 18px; }
  .links-title { font-size: 26px; }
  .links-eyebrow { font-size: 17px; }
  .links-hint { font-size: 14px; }
  .links-body { gap: 16px; }
  .links-group { gap: 8px; }
  .links-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
  .link-card { padding: 14px 16px; gap: 12px; }
  .link-mark { width: 44px; height: 44px; flex-basis: 44px; font-size: 17px; }
  .link-title { font-size: 17px; }
  .link-desc { font-size: 13px; }
  .browser-overlay { padding: 16px; }
  .browser-title { display: none; }
}

/* Short-but-wide screens (a laptop's built-in display) have the same
   problem page 1's own max-height:900px block exists to fix: this page
   was only ever sized off width, so a short window couldn't shrink it
   and whole categories fell below the fold (measured live at 1440x818:
   1058px of content against an 818px viewport). Each clamp()'s ceiling
   equals the un-queried base value and its floor/vh-offset is picked so
   the value equals that ceiling exactly at height:1060px (the full page
   needs ~1058px with four categories) - so, like the
   pattern this mirrors elsewhere in the file, there's no visible jump
   at the query boundary, only a smooth shrink below it. Horizontal
   sizing (padding-inline, .link-desc/.link-host, grid column count) is
   left alone - width isn't the scarce dimension here. */
@media (max-height: 1060px) {
  .kiosk-page--links { padding-top: clamp(18px, calc(100vh - 1010px), 50px); padding-bottom: clamp(28px, calc(100vh - 976px), 84px); gap: clamp(12px, calc(100vh - 1034px), 26px); }
  .links-eyebrow { font-size: clamp(15px, calc(100vh - 1038px), 22px); }
  .links-title { font-size: clamp(22px, calc(100vh - 1024px), 36px); }
  .links-hint { font-size: clamp(13px, calc(100vh - 1040px), 20px); }
  .links-body { gap: clamp(10px, calc(100vh - 1038px), 22px); }
  .links-group { gap: clamp(6px, calc(100vh - 1048px), 12px); }
  .links-grid { gap: clamp(8px, calc(100vh - 1044px), 16px); }
  .link-card { padding: clamp(12px, calc(100vh - 1032px), 28px) 24px; gap: clamp(8px, calc(100vh - 1038px), 18px); }
  .link-mark { --mark-size: clamp(32px, calc(100vh - 1004px), 56px); width: var(--mark-size); height: var(--mark-size); flex-basis: var(--mark-size); font-size: clamp(13px, calc(100vh - 1024px), 21px); }
  .link-title { font-size: clamp(14px, calc(100vh - 1039px), 21px); }
  .link-desc { font-size: clamp(11px, calc(100vh - 1045px), 15px); }
}

/* Very short windows (a laptop with browser zoom, bookmarks bar or a
   docked taskbar - measured overflow starting below ~640px tall): every
   clamp above is already at its floor, so drop the least important line
   (the bare hostname, still visible in the browser bar once opened) and
   tighten the remaining spacing so the whole page stays on one screen. */
@media (max-height: 640px) and (min-width: 901px) {
  .kiosk-page--links { padding-top: 12px; padding-bottom: 40px; gap: 8px; }
  .links-title { font-size: 20px; margin-top: 2px; }
  .links-body { gap: 6px; }
  .links-group { gap: 4px; }
  .links-grid { gap: 6px; }
  .link-card { padding: 8px 18px; }
  .link-mark { --mark-size: 28px; font-size: 12px; border-radius: 10px; }
  .link-host { display: none; }
}

/* Large wall displays: same idea as .kiosk-shell's own min-width:1800px
   block, so Quick Links doesn't look comparatively tiny/sparse next to
   page 1's vmin-scaled cards on a huge screen. Capped and centered
   rather than left to stretch edge-to-edge at 4 equal columns. */
@media (min-width: 1800px) and (min-height: 1061px) {
  .kiosk-page--links { max-width: 3200px; margin: 0 auto; }
  .links-title { font-size: clamp(36px, 2.6vmin, 50px); }
  .links-eyebrow { font-size: clamp(22px, 1.5vmin, 28px); }
  .links-hint { font-size: clamp(20px, 1.35vmin, 25px); }
  .link-card { padding: clamp(28px, 2vmin, 38px) clamp(24px, 1.7vmin, 32px); }
  .link-mark { width: clamp(56px, 3.8vmin, 72px); height: clamp(56px, 3.8vmin, 72px); flex-basis: clamp(56px, 3.8vmin, 72px); font-size: clamp(21px, 1.4vmin, 26px); }
  .link-title { font-size: clamp(21px, 1.45vmin, 26px); }
  .link-desc { font-size: clamp(15px, 1.05vmin, 18px); }
}

/* Live warnings (routes/warnings.js) take a turn in the announcement bar
   like any other message. While one is showing, app.js adds
   .rotating-bar--alert and the bar turns a restrained amber instead of its
   usual blue - colours only (same padding, border width and size), so
   nothing on the page moves. The transition matches the message's 0.3s fade. */
#rotating-bar { transition: background-color .3s ease, border-color .3s ease; }
.rotating-bar.rotating-bar--alert { background: oklch(0.5 0.11 68 / .62); border-color: oklch(0.86 0.11 80 / .7); }
.rotating-bar--alert .rotating-icon-chip { background: oklch(0.42 0.1 62 / .45); }

/* Night screen (office view outside office hours, holidays and closures -
   updateNightMode in app.js). The element is created by app.js only when
   night mode first turns on, so it can't affect the normal page; fixed and
   above everything else (the Quick links browser overlay is z-index 20,
   the page dots 5). Deliberately very dim: it runs all night. */
.night-screen { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; background: #000; cursor: none; user-select: none; }
.night-screen[hidden] { display: none; }
.night-clock { text-align: center; font-variant-numeric: tabular-nums; transition: transform 6s ease-in-out; }
.night-clock-time { color: oklch(0.38 0.01 250); font-size: clamp(72px, 16vmin, 280px); font-weight: 600; line-height: 1; letter-spacing: -0.02em; }
.night-clock-date { margin-top: clamp(8px, 1.8vmin, 28px); color: oklch(0.3 0.01 250); font-size: clamp(18px, 3.2vmin, 52px); font-weight: 500; }

/* The "Warning" eyebrow is narrower than "Announcement", which shifted the
   message sideways (~50px) each time the rotation passed between the two.
   Same width as the normal eyebrow (measured 117.75px at 12px, so 9.82em)
   keeps the message's left edge fixed. */
.rotating-bar--alert .rotating-eyebrow { min-width: 9.82em; }

/* Quick polls (routes/polls.js) take a turn in the announcement bar like
   any other message. "Poll" gets the same eyebrow width as "Announcement"
   so the message doesn't shift sideways. The poll's QR <img> is created by
   app.js only when a poll shows and is sized there, in whole pixels, to
   the bar's existing inner height (showRotatingQr), so it can't change the
   bar's box. */
.rotating-bar--poll .rotating-eyebrow { min-width: 9.82em; }
.rotating-qr { flex: 0 0 auto; display: block; padding: 3px; border-radius: 6px; background: #fff; image-rendering: pixelated; }
.rotating-qr[hidden] { display: none; }
/* Phones: one fixed dot per item plus the poll's QR used to squeeze the
   message to 0-72px once polls and notices joined the rotation.
   Here the QR stays out of the bar (a phone can't scan its own screen; the
   Updates dialog still shows it) and only the five dots around the current
   item (.near, set by tickRotatingBar) are shown. Wider screens: unchanged. */
@media (max-width: 599px) {
  .rotating-bar .rotating-qr { display: none; }
  .rotating-dots .dot:not(.near) { display: none; }
}

/* German (<html lang="de">, language.js): the eyebrow words have other
   widths ("Ankündigung" measures 103.2px at 12px; "Warnung", "Dankeschön",
   "Umfrage" and "Team" are narrower), so in German every eyebrow gets the
   widest one's width (8.61em) and the message's left edge stays put as the
   rotation moves on. English keeps the rules above. */
html[lang="de"] .rotating-eyebrow { min-width: 8.61em; }

/* Maintenance rail card: rotation dots sit bottom-centre of the card, out of
   the header row, so the title gets the full width (many reminders = many dots
   used to push the last letter of "MAINTENANCE" onto a second line). Absolute
   positioning keeps them out of the flow, so the card's measured content
   height and layoutRailCards() are unaffected. */
.maintenance-live-card { position: relative; }
.maintenance-live-card .rail-dots { position: absolute; left: 50%; bottom: clamp(3px, .6vw, 8px); transform: translateX(-50%); margin-left: 0; max-width: calc(100% - 16px); flex-wrap: nowrap; overflow: hidden; justify-content: center; }
/* Very long reminder lists on narrow cards: tighter dots, still one row (a
   second row would overlap the sub text); beyond that they clip at the edges. */
@media (max-width: 599px) { .maintenance-live-card .rail-dots { gap: 4px; } }
.maintenance-live-card .rail-dots .dot { flex: 0 0 auto; }

/* Poll QR in the Updates dialog: large and centred so phones can scan it from
   a comfortable distance. Pixelated scaling keeps the modules sharp. */
.news-dialog-qr.poll-dialog-qr { flex-direction: column; text-align: center; gap: 14px; }
.news-dialog-qr.poll-dialog-qr img { width: clamp(220px, 34vmin, 380px); height: auto; aspect-ratio: 1; padding: 12px; border-radius: 14px; image-rendering: pixelated; }
.updates-card-poll-answers { margin-top: 8px; font-size: 0.82em; font-weight: 500; opacity: .75; }
.updates-card-poll-by { margin-top: 4px; font-size: 0.72em; opacity: .6; }

/* While at least one birthday card is shown (class set by
   renderProfileRow() in app.js), that row costs ~150-200px a landscape
   screen has no spare room for - so the quote and news rows give it back:
   quote to ~half height (1-2 lines, smaller text, attribution moved up
   beside the label), news headlines to one line. Landscape side-by-side
   layout only; with no cards nothing here applies. */
@media (min-width: 901px) and (min-aspect-ratio: 1/1) {
  .kiosk-shell.has-profile-cards .news-row { flex-basis: clamp(48px, min(calc(46px + 2.5vw), 6.5vh), 100px); }
  .kiosk-shell.has-profile-cards .news-panel { padding-top: clamp(8px, 1.1vh, 14px); padding-bottom: clamp(8px, 1.1vh, 14px); align-items: center; }
  .kiosk-shell.has-profile-cards .news-headline { -webkit-line-clamp: 1; }
  .kiosk-shell.has-profile-cards .fact-row { flex-basis: clamp(64px, 7vh, 100px); }
  .kiosk-shell.has-profile-cards .fact-panel { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "label sub" "text text"; align-content: start; column-gap: 16px; padding-top: clamp(8px, 1.2vh, 16px); padding-bottom: clamp(8px, 1.2vh, 16px); }
  .kiosk-shell.has-profile-cards .fact-label { grid-area: label; margin-bottom: 4px; }
  .kiosk-shell.has-profile-cards .fact-text { grid-area: text; -webkit-line-clamp: 2; font-size: clamp(13px, min(1.1vw, 1.7vh), 19px); margin-bottom: 0; }
  .kiosk-shell.has-profile-cards .fact-sub { grid-area: sub; align-self: start; margin: 0; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .kiosk-shell.has-profile-cards .fact-row:has(#fact-credit:not(:empty)) .fact-sub { margin-right: 300px; }
  .kiosk-shell.has-profile-cards #fact-credit { top: clamp(7px, 1.1vh, 14px); bottom: auto; }
}
/* Shortest laptops (1280x720-class): one quote line is all that fits. */
@media (min-width: 901px) and (min-aspect-ratio: 1/1) and (max-height: 760px) {
  .kiosk-shell.has-profile-cards .fact-text { -webkit-line-clamp: 1; }
}

/* "keine Daten" / "unavailable" instead of a reading (no sensor yet, or the
   service is down): a calm, smaller line instead of the huge reading size,
   so the word fits even a narrow card. */
#temp-value.is-unavailable, #weather-temp.is-unavailable { font-size: clamp(18px, 1.6vw, 30px) !important; color: var(--muted); }
.maintenance-live-card.is-all-done { cursor: default; }
.maintenance-live-card.is-all-done #maintenance-live-value { color: var(--muted); }
