/* CockroachDB Demos.
 *
 * Every colour is a token. SVG marks are coloured through CSS classes only, so
 * light and dark mode are two sets of tokens rather than two renderers.
 *
 * THE BRAND PALETTE IS THE ONLY SOURCE OF COLOUR. Four ramps and two neutrals:
 *
 *   Timeless Taupe  #F9F8F3  #F2F1EE  #E5E4DC  #949187
 *   Sci-fi Blue     #DFFBFF  #AFF6FF  #60ECFF  #144F6B
 *   Bio Green       #EFF8DD  #D7EEA9  #AEDE54  #43541F
 *   Data Purple     #E9DEFF  #C8ACFF  #9059FF  #502482
 *   Neutrals        #FFFFFF  #101913  #000000
 *
 * Every surface, border, text and chrome colour below is one of those hexes, or
 * one of them at an alpha. Chart marks are the one exception, and only because
 * they have to be: the ramps jump straight from very light (#60ECFF, #AEDE54)
 * to very dark (#144F6B, #43541F) with no mid-tone, and the literal steps fail
 * the separation gates - #43541F against #144F6B is ΔE 12.7 to normal vision,
 * below the floor of 15. So each plot hue is a step derived inside its own
 * brand ramp: same hue, lightness moved to where a chart can use it.
 *
 * Validated with the dataviz palette checker in both modes on the harder
 * all-pairs list - lightness band, chroma floor, CVD separation, normal-vision
 * separation and contrast against the surface all pass:
 *   light: #1D8FB4 (Sci-fi Blue) #9059FF (Data Purple) on #F9F8F3
 *   dark:  #2FA0C0 (Sci-fi Blue) #9059FF (Data Purple) on #000000
 *
 * Status is the exception the checker can't clear: good is Bio Green and bad
 * is a functional red, and red against green is ΔE 1.7 to a deutan reader. That
 * is legal here only because status never travels as colour alone - every
 * status mark carries a dash pattern, a glyph or a word beside it.
 */

/* Two faces, in the order the stacks below fall through them.
 *
 * Geist ships as one variable file per family (69KB and 71KB), which is why
 * there's a font-weight range rather than one face per weight: the whole
 * 100-900 axis costs one request. SIL OFL, licence alongside the files.
 */
@font-face {
  font-family: "Geist";
  src: url("/static/fonts/Geist-Variable.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Geist Mono";
  src: url("/static/fonts/GeistMono-Variable.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

:root {
  color-scheme: light dark;

  --surface: #f9f8f3;        /* Warm Gray 1 */
  --surface-raised: #ffffff;
  --surface-sunken: #f2f1ee; /* Warm Gray 2 */
  --border: #e5e4dc;         /* Warm Gray 3 */
  --border-strong: #949187;  /* Warm Gray 8 */

  /* The catalogue's backdrop. Its own token rather than --border-strong, which
     happens to be the same taupe: the two are free to move apart, and a colour
     read by JavaScript wants a name that says what it paints. The same step in
     both modes - held back by opacity, it reads as a tint either way. */
  --backdrop: #949187;       /* Warm Gray 8 */

  /* Text is the brand black at an alpha, which is how the taupe ramp's own
     steps would read if they were dark enough: #949187 on this surface is
     2.97:1, well under the 4.5:1 small text needs. */
  --ink: #101913;
  --ink-secondary: rgba(16, 25, 19, 0.78); /* 8.6:1 */
  --ink-muted: rgba(16, 25, 19, 0.62);     /* 4.9:1 */

  /* Chrome accent. --brand is the vivid violet'sed for rings, underlines and
     borders; --brand-ink is the step that stays legible as text; --brand-solid
     is the one a filled button can sit on under --brand-contrast text. The
     vivid step is only 4.2:1 against white, so it never carries type. */
  --brand: #9059ff;
  --brand-ink: #502482;
  --brand-solid: #502482;
  --brand-contrast: #ffffff;
  --brand-glow: rgba(144, 89, 255, 0.28);
  --brand-wash: rgba(144, 89, 255, 0.08);

  /* Two categorical hues is all any chart here needs: no pane plots more than
     two series. Bio Green and the functional red are held back for status. */
  --series-a: #1d8fb4; /* Sci-fi Blue: the incumbent - consumed demand, the shared cluster, the EBS lane */
  --series-b: #9059ff; /* Data Purple: the new thing - virtual clusters, the Disagg lane */

  --good: #4e7c1f;     /* Bio Green, stepped dark enough to read as text */
  --critical: #c22e27; /* functional red: the one colour outside the brand set, reserved for bad */

  /* Each fill is the tint of the line it sits under, so an area and its stroke
     are never two different hues. The gap is the money burnt on capacity that
     idles, so it wears the bad colour rather than a hue of its own. */
  --gap-fill: rgba(194, 46, 39, 0.12);
  --area-fill: rgba(29, 143, 180, 0.16);
  --area-fill-soft: rgba(144, 89, 255, 0.12);

  /* The page gutter. Anything that bleeds full-bleed pulls itself back out by
     this much, so the two have to move together. */
  --page-pad: 1.25rem;

  /* Height of the decorative band at the foot of the window. The page pads
     itself by this much, so the two have to move together too. */
  --band-h: 170px;

  --radius: 14px;
  --radius-sm: 8px;
  --measure: 68ch;
  --font: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  /* Headings, labels and numbers wear the monospace. Prose doesn't. Its own
     token rather than var(--mono) at every call site: display type and code
     are free to move apart later. */
  --display: var(--mono);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Black is the page; #101913 is what sits on top of it. The brand's two
       darkest neutrals are the whole dark-mode surface stack. */
    --surface: #000000;
    --surface-raised: #101913;
    --surface-sunken: #000000;
    --border: rgba(148, 145, 135, 0.32);  /* Warm Gray 8, held back */
    --border-strong: #949187;

    --ink: #f9f8f3;
    --ink-secondary: rgba(249, 248, 243, 0.82); /* 11.6:1 */
    --ink-muted: rgba(249, 248, 243, 0.66);     /* 7.8:1 */

    --brand: #9059ff;
    --brand-ink: #c8acff;
    --brand-solid: #c8acff;
    --brand-contrast: #101913;
    --brand-glow: rgba(144, 89, 255, 0.45);
    --brand-wash: rgba(144, 89, 255, 0.16);

    --series-a: #2fa0c0;
    --series-b: #9059ff;

    --good: #79a62f;
    --critical: #e0574a;

    --gap-fill: rgba(224, 87, 74, 0.18);
    --area-fill: rgba(47, 160, 192, 0.22);
    --area-fill-soft: rgba(144, 89, 255, 0.20);
  }
}

/* ---------- base ---------- */

* { box-sizing: border-box; }

/* The slider, the checkboxes and the radios are native controls; this is the
   only way the brand colour reaches them. */
input, select, progress { accent-color: var(--brand); }

body {
  margin: 0 auto;
  padding: 0 var(--page-pad) 4rem;
  max-width: 1180px;
  font-family: var(--font);
  font-size: 16px;
  line-height: 1.55;
  color: var(--ink);
  background-color: var(--surface);
  /* The brand's glow, once, behind the masthead. Fixed so it stays a light
     source rather than scrolling away as a shape. */
  background-image: radial-gradient(70rem 30rem at 50% -12rem, var(--brand-wash), transparent 70%);
  background-repeat: no-repeat;
  background-attachment: fixed;
}

h1, h2, h3, h4 {
  font-family: var(--display);
  letter-spacing: -0.02em;
  line-height: 1.25;
  margin: 0 0 0.4em;
}
h1 { font-size: 1.6rem; }
h2 { font-size: 1.3rem; }
h3 { font-size: 1.05rem; }
h4 { font-size: 0.95rem; }
p { margin: 0 0 0.75em; max-width: var(--measure); }
code { font-family: var(--mono); font-size: 0.92em; }

a { color: var(--brand-ink); }

:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 1rem; top: 1rem;
  z-index: 10;
  padding: 0.5rem 0.75rem;
  background: var(--surface-raised);
  border: 2px solid var(--brand);
  border-radius: var(--radius);
}

.fineprint {
  font-size: 0.8rem;
  color: var(--ink-muted);
}

/* htmx's own indicator rules, verbatim. It writes these into a <style> tag at
   boot, which the CSP blocks and reports once per page, so the injection is
   switched off in the layout's htmx-config and the rules live here instead. */
.htmx-indicator { opacity: 0; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { opacity: 1; transition: opacity 200ms ease-in; }

/* ---------- shell ---------- */

/* The name and the four figures, pinned to the top of the window. It's
   full-bleed - pulled out of the page's centred column to the window's own
   edges - so it lines up with the band fixed behind it rather than floating in
   the middle of it.

   Translucent, not painted: the band shows through, and what scrolls under is
   blurred rather than hidden, which is what keeps the numbers on top of it
   readable. Browsers without backdrop-filter fall back to the flat colour,
   which is legible but plain. */
.topbar {
  position: sticky;
  top: 0;
  z-index: 5;
  margin-inline: calc(50% - 50vw);
  padding-inline: max(var(--page-pad), calc(50vw - 590px + var(--page-pad)));
  background: color-mix(in srgb, var(--surface) 72%, transparent);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  border-bottom: 1px solid var(--border);
}

/* The bar is three layers: its own backdrop, the band, then what it says. */
.topbar > .masthead,
.topbar > .kpi {
  position: relative;
  z-index: 1;
}

/* The bar is what's full-bleed; 50vw counts a classic scrollbar, and the
   overhang that leaves is trimmed rather than scrolled to. It has to be body
   rather than html: overflow on the root element moves the scrollport, and
   sticky positioning goes with it. */
body { overflow-x: clip; }

/* The masthead is furniture now, not a hero: the page's own heading carries
   the argument, so this shrinks to a name, a mark, and a way back to the
   start. */
.masthead {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 0 0.75rem;
}
.masthead-title {
  display: flex;
  gap: 0.7rem;
  align-items: center;
  color: inherit;
  text-decoration: none;
}
.masthead-name {
  display: block;
  font-family: var(--display);
  font-size: 1rem;
  font-weight: 600;
}
.masthead-sub { display: block; font-size: 0.82rem; color: var(--ink-secondary); }
/* The mark colours itself from inside the file, so all this has to do is size
   it and light it. */
.brand-mark {
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  filter: drop-shadow(0 0 1.1rem var(--brand-glow));
}
.masthead-actions { display: flex; gap: 0.5rem; }

.btn {
  display: inline-block;
  padding: 0.4rem 0.85rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  background: var(--surface-raised);
  color: var(--ink);
  font-family: var(--display);
  font-size: 0.82rem;
  text-decoration: none;
  cursor: pointer;
}
.btn:hover { border-color: var(--brand); }
.btn-primary {
  background: var(--brand-solid);
  border-color: var(--brand-solid);
  color: var(--brand-contrast);
  /* The glow is the brand's one flourish. It only ever marks the action the
     page wants you to take next. */
  box-shadow: 0 0 1.4rem var(--brand-glow);
}
.btn-primary:hover { box-shadow: 0 0 2rem var(--brand-glow); }
/* The chosen option in a pair of toggles. It marks state, so it's quieter
   than .btn-primary, which marks the next action. */
.btn-on {
  background: var(--brand-glow);
  border-color: var(--brand);
  color: var(--ink);
  font-weight: 600;
}
/* Chosen, but not yet doing anything: the bill page's day carried in from the
   intro. Quieter than .btn-on, which is reserved for the day on the clock, so
   the two states don't read as the same claim. */
.btn-sel {
  border-color: var(--border-strong);
  color: var(--ink);
  font-weight: 600;
}
.btn-quiet { background: none; border-color: var(--border); color: var(--ink-secondary); }
.btn-kill { font-family: var(--mono); font-size: 0.8rem; }

/* KPI bar. The spine of the demo: it's above every scene and every action in
   every scene moves at least one of these. */
.kpi {
  margin: 0.5rem 0 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  /* The cells are painted right up to the edge, so the corners have to clip
     them or the rounding disappears under the first and last item. */
  overflow: hidden;
}
.kpi-list {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  margin: 0;
  padding: 0;
  list-style: none;
  background: var(--border);
}
.kpi-item {
  padding: 0.7rem 0.9rem;
  background: var(--surface-raised);
}
.kpi-label {
  display: block;
  font-family: var(--display);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
.kpi-value {
  display: block;
  font-family: var(--display);
  font-size: 1.4rem;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
}
.kpi-delta { display: block; font-size: 0.78rem; color: var(--ink-muted); }
/* Coloured by whether the figure moved the way the demo argues it should, not
   by which way it moved: nodes falling and CPU rising are both the good news. */
.kpi-good { color: var(--good); }
.kpi-bad { color: var(--critical); }
.kpi-moved { background: var(--surface-sunken); }
.kpi-moved .kpi-value { animation: kpi-flash 700ms ease-out; }

@keyframes kpi-flash {
  from { background: var(--brand-wash); }
  to { background: transparent; }
}

/* The rail is the whole argument in one line: where you are, what's left, and
   which earlier page moved a number you can still see in the KPI bar. It
   scrolls sideways rather than wrapping, so the reading order stays a line. */
.rail {
  margin: 0 0 1.5rem;
  border-bottom: 1px solid var(--border);
  overflow-x: auto;
  scrollbar-width: thin;
  /* app.js scrolls the current step into view by the smallest amount that
     works, which on its own leaves it flush against the edge and reading as
     the end of the rail. This is the gap it stops short by, so there's always
     a sliver of the neighbouring step showing as the reason to scroll. */
  scroll-padding-inline: 1.5rem;
}
.rail-list {
  display: flex;
  /* The nine are the width of their own words, which is well short of the rail
     on most screens - and the rail is a line across the page, so the steps
     bunched at the left of it read as a list that stopped early rather than as
     a story with eight stops. The leftover goes between them instead.
     Free space only: once they overflow there is none to share out and this
     falls back to packing them tight, which is when the rail starts scrolling. */
  justify-content: space-between;
  gap: 0.1rem;
  margin: 0;
  /* No trailing padding: the last step is meant to land on the same right edge
     as the bar of figures above it, and an inset here reads as the rail not
     quite reaching. When the steps do overflow, being flush at the end is what
     a scrolled-to-the-end scroller should look like anyway. */
  padding: 0 0 0.4rem;
  list-style: none;
}
/* Eight steps and a front door is a lot of pills to get on one line, and what
   decides whether they fit is the space around the words rather than the words
   themselves. Tight enough here that all nine fit a windowed laptop; below that
   the labels go entirely, which is the rule at the foot of this file. */
.rail-link {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.4rem 0.5rem;
  border-radius: 999px;
  color: var(--ink-muted);
  font-family: var(--display);
  font-size: 0.82rem;
  text-decoration: none;
  white-space: nowrap;
}
.rail-link:hover { background: var(--surface-raised); color: var(--ink); }
.rail-n {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  border: 1px solid var(--border-strong);
  border-radius: 999px;
  font-size: 0.7rem;
  font-variant-numeric: tabular-nums;
}
.rail-here .rail-link {
  background: var(--surface-raised);
  color: var(--ink);
  font-weight: 600;
}
.rail-here .rail-n {
  background: var(--brand-solid);
  border-color: var(--brand-solid);
  color: var(--brand-contrast);
}
/* A page whose controls have moved a figure the reader can see. Quiet on
   purpose: it's a breadcrumb, not an alert. */
.rail-dot {
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 999px;
  background: var(--brand);
}

/* One way forward, one way back, always in the same place. */
.storyfoot {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  align-items: stretch;
  margin: 2rem 0 0;
}
.storyfoot-step {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.7rem 1.1rem;
  border-radius: var(--radius);
  min-width: 9rem;
}
.storyfoot-empty { border: 0; background: none; }
.storyfoot-next { text-align: right; }
/* The last step is one line, having no next page to name, so it centres
   against the two-line Back button beside it. */
.storyfoot-end { justify-content: center; }
.storyfoot-dir {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: 0.75;
}
.storyfoot-title { font-size: 0.95rem; font-weight: 600; }

.site-footer {
  margin-top: 2rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
  color: var(--ink-secondary);
  font-size: 0.9rem;
}
.limits { margin: 0 0 0.8rem; padding-left: 1.1rem; }

/* ---------- pages ---------- */

/* One page, one claim. Everything a page has to say fits above the fold plus
   one scroll, which is the whole reason the site is eight pages and not three
   tabs with four arguments stacked in each. */
.page { padding: 0 0 1rem; }
.page-head { margin-bottom: 1.25rem; }
.page-head h2 {
  margin: 0.1rem 0 0.4rem;
  font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem);
}
.page-step {
  margin: 0;
  font-family: var(--display);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--brand);
}
.page-lede {
  margin: 0;
  max-width: var(--measure);
  color: var(--ink-secondary);
  font-size: 1.02rem;
}
/* The one line on a page that says what the next page does with what you just
   changed. It's the seam between two pages, so it reads as a handover. */
.page-onward {
  margin: 1.25rem 0 0;
  padding-left: 0.9rem;
  border-left: 3px solid var(--brand);
  max-width: var(--measure);
  color: var(--ink-secondary);
  font-size: 0.92rem;
}

.intro-note {
  margin: 1.5rem 0 0;
  max-width: var(--measure);
  color: var(--ink-secondary);
  font-size: 0.92rem;
}

/* The front page's estate form, as a spec sheet: one line per number, name on
   the left, control in the middle, value on the right, all three columns
   aligned down the form. Cards and per-field prose made seven questions read
   as a wall, so the explaining is done once per section instead. */
/* Questions on the left, the estate they describe on the right. The summary
   column is sized to the longest of its own values rather than a share of the
   row, so the form keeps the width its three columns were drawn for. */
.intro-config {
  display: grid;
  grid-template-columns: minmax(0, 44rem) minmax(0, max-content);
  gap: 1.25rem;
  align-items: stretch;
  margin-bottom: 1.5rem;
}
.config {
  max-width: 44rem;
  margin: 0;
  padding: 0.9rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
}
.config-rows {
  display: grid;
  grid-template-columns: minmax(6rem, max-content) minmax(7rem, 1fr) minmax(3.5rem, max-content);
  gap: 0.55rem 0.9rem;
  align-items: center;
}
.config-rows label,
.config-rows .config-label { font-size: 0.88rem; }
.config-value {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: 0.88rem;
  text-align: right;
  white-space: nowrap;
}
.config-rows input[type="range"] { width: 100%; }
.config-rows input[type="number"] {
  width: 100%;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink);
  font-family: var(--mono);
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
}
/* Chips and notes belong to the row above them, so they start where that
   row's control starts rather than at the label. */
.config-chips {
  grid-column: 2 / -1;
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin-top: -0.2rem;
}
.config-chips .btn { padding: 0.2rem 0.55rem; font-size: 0.78rem; text-align: center; }
/* The counts are a scale, so their buttons are one size: a row of chips that
   grow with their labels puts "100" three times the width of "1" and reads as
   a bar chart of the numbers rather than a list of choices. Every row uses the
   same column basis, so the columns line up down the form too, and the rows
   with fewer steps leave the trailing columns empty rather than stretching. */
.config-chips-even {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(4.25rem, 1fr));
}
.config-chips-even .btn { font-variant-numeric: tabular-nums; }
/* The shapes of the day are words rather than counts, so they take the width
   they need - shared equally, which is the same promise in a row of three. */
.config-chips-words {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
.config-note {
  grid-column: 2 / -1;
  margin: -0.3rem 0 0.2rem;
  font-size: 0.78rem;
  color: var(--ink-muted);
}
/* The assumptions are arguable rather than descriptive, so a rule separates
   them from the four questions that describe the estate. */
.config-more { margin-top: 0.9rem; border-top: 1px solid var(--border); padding-top: 0.7rem; }
.config-more-title { margin: 0; font-size: 0.85rem; color: var(--ink-secondary); }
.config-more .config-rows { margin-top: 0.7rem; }
.config-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.9rem;
}
/* The same height as the form it answers, so the two read as one panel split
   down the middle rather than a card with a card floating beside it. */
.config-summary {
  display: flex;
  flex-direction: column;
  padding: 0.85rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
/* Beside the form rather than under it, the numbers are a column: a wrapping
   row would re-flow every time a value changed width. With nothing below them
   they take the whole card, spread evenly rather than bunched at the top. */
.config-summary .readout {
  flex: 1;
  flex-direction: column;
  flex-wrap: nowrap;
  justify-content: space-between;
  margin: 0;
  gap: 0.7rem;
}
.config-summary dt { font-size: 0.7rem; }
.config-summary dd { font-size: 0.95rem; font-weight: 600; }


.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  margin: 0.75rem 0 1.25rem;
}
/* A floor rather than a fixed width: where two of these rows sit one above the
   other, the labels are shorter than the floor and the buttons beside them
   start at the same edge, which is what makes the rows read as one control
   rather than two. A label longer than the floor keeps its own width - it's on
   a page with nothing under it to line up with. */
.controls-label {
  min-width: 7.5rem;
  font-family: var(--display);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
/* Choices in a row are one size, whatever they're called. A row where the
   buttons are as wide as their labels reads as a ranking of the options, and
   the widest option isn't the recommended one. The floor is set past the
   longest label any of these carry, so the row stays even. */
.controls .btn-choice,
.controls .btn-toggle {
  min-width: 15rem;
  text-align: center;
}
/* The one boolean on the page, drawn as the switch it is rather than as a
   button that happens to be lit. The dot is the knob: it sits on the side the
   switch is thrown to, and takes the brand colour only when the thing is on.
   Everything else about the pill holds still between the two states, so the
   knob is the only thing to read. */
.controls .btn-toggle {
  display: inline-flex;
  align-items: center;
  /* Spread, so the knob sits against the end it's thrown to rather than
     floating beside the label in the middle of a pill held much wider than its
     contents. Spreading two items would pin the label to the opposite edge, so
     there are three: an empty spacer the same width as the knob stands at the
     other end, and the label centres itself in what's left between two equal
     ends. The spacer is ::before, which reverses along with everything else. */
  justify-content: space-between;
  gap: 0.75rem;
  /* Narrower ends than a plain button: the dot is a circle inside a pill, and
     the button's own 0.85rem reads as too much air around it. The vertical
     padding is left alone, so this stands exactly as tall as the choices it
     shares a row with. The knob's end gets the wider of the two: a letter is
     inset by its own side bearing before its ink starts, and a circle isn't,
     so equal padding puts the dot nearer its edge than the label is to the
     other one. The pair below hands the wider end to whichever side the knob
     is standing on - padding doesn't follow the row when it reverses. */
  padding: 0.4rem 0.5rem;
  text-align: left;
}
.controls .btn-toggle::after {
  content: "";
  flex: none;
  width: 1.05rem;
  height: 1.05rem;
  border-radius: 999px;
  background: var(--border-strong);
}
/* The knob's counterweight: same width, no ink. It holds the far end so the
   label sits in the middle of the pill instead of against the opposite edge. */
.controls .btn-toggle::before {
  content: "";
  flex: none;
  width: 1.05rem;
}
/* Off throws the knob to the left. The dot is the last child either way, so
   reversing the row moves it without the markup knowing which state it's in. */
.controls .btn-toggle:not(.btn-on) {
  flex-direction: row-reverse;
  padding-left: 0.6rem;
  padding-right: 0.5rem;
  color: var(--ink-secondary);
}
/* The lit pill would otherwise wear the wash .btn-on gives the radio rows. Here
   that would say the same thing twice, and less clearly than the knob does. */
.controls .btn-toggle.btn-on {
  padding-left: 0.5rem;
  padding-right: 0.6rem;
  background: var(--surface-raised);
  border-color: var(--border-strong);
}
.controls .btn-toggle.btn-on::after { background: var(--brand); }
.controls .btn-toggle.btn-on:hover { border-color: var(--brand); }

/* A control that can't do anything where it's standing. It stays on the page so
   the option is still discoverable, but it isn't a link, because a link that
   answers with an identical page is worse than no link. */
.btn[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
}
.controls-note { margin: -0.9rem 0 1.1rem; }

/* A grid rather than a flex line, so the note underneath can start at the
   slider's own left edge. Wrapping a flex item would drop it back to the form's
   left edge, under the label, which reads as a caption for the wrong control.
   The trailing 1fr soaks up the spare width so the first three columns keep
   their natural size. */
.control-form {
  display: grid;
  grid-template-columns: auto auto auto 1fr;
  gap: 0.5rem;
  align-items: center;
}
.control-form .fineprint {
  grid-column: 2 / -1;
  margin: 0;
}
/* Controls that apply themselves keep a submit button for the reader without
   JavaScript. app.js marks the document when it loads, which takes the button
   away from everyone who doesn't need it. */
[data-js="on"] .js-optional { display: none; }

.control-form label { font-size: 0.85rem; }
.control-form output { font-variant-numeric: tabular-nums; font-weight: 600; }
.control-choice { display: flex; gap: 0.35rem; }

/* The data-volume row: presets sit tight together as one choice, and the
   free-text box that follows them is set apart so it doesn't read as a sixth
   preset. */
.controls-data { gap: 0.35rem; }
.controls-data .controls-label { margin-right: 0.15rem; }
.control-form-inline {
  gap: 0.35rem;
  margin-left: 0.6rem;
  padding-left: 0.75rem;
  border-left: 1px solid var(--border);
}
.control-form-inline label { color: var(--ink-muted); }
.control-form-inline input[type="number"] {
  width: 6rem;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--ink);
  font-family: var(--mono);
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
}

.hero { margin-bottom: 0.5rem; }
.hero-label {
  margin: 0;
  font-family: var(--display);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
.hero-number {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(2.4rem, 6.4vw, 3.7rem);
  font-weight: 650;
  letter-spacing: -0.04em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.hero-sub { margin: 0.2rem 0 0; color: var(--ink-secondary); font-variant-numeric: tabular-nums; max-width: var(--measure); }
/* A scene waiting to be started says so in words, and the words don't get the
   size a figure gets: there's no figure yet, and one this big would be read as
   the finding rather than as the absence of one. */
.hero-idle {
  font-size: clamp(1.3rem, 3vw, 1.7rem);
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--ink-muted);
}

.chart svg { display: block; width: 100%; height: auto; }

/* An SVG with no width attribute fills its container, and the type inside it
   scales with the box. Cap the marks that carry text at roughly the size they
   were drawn at, so a wide pane gets whitespace rather than 30px captions. */
.viz { display: block; width: 100%; height: auto; }
.viz-lane { max-width: 420px; }
.viz-gauge { max-width: 180px; margin: 0 auto; }
.viz-latency { max-width: 480px; }
.viz-topology { max-width: 860px; margin: 0 auto; }
/* The node drill draws one topology per engine, side by side, so each is
   capped at the width of the lane bar under it rather than at the column. */
.kv-lane .viz-topology { max-width: 420px; margin: 0 auto; }
.viz-storage, .viz-storage-shape { max-width: 860px; margin: 0 auto; }
.viz-summary { max-width: 860px; margin: 0 auto; }

.counters {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
  margin: 0.75rem 0 0;
}
.counter dt {
  font-family: var(--display);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
.counter dd {
  margin: 0;
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
}
.split {
  display: grid;
  /* min() rather than a bare 320px: on a narrower screen a fixed track floor
     would push the whole document wider than the viewport. */
  grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
  gap: 1.25rem;
}
/* Grid and flex children default to min-width: auto, so one wide table inside
   a pane stretches its whole track and takes the document with it. */
.split > *, .estate-body > * { min-width: 0; }

.split-pane {
  padding: 0.9rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
}

.readout { display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0.5rem 0 0; }
.readout-wide { margin-top: 1rem; }
.readout dt {
  font-family: var(--display);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
}
.readout dd { margin: 0; font-variant-numeric: tabular-nums; }
.readout-headline dd { font-size: 1.6rem; font-weight: 650; }
.readout .good { color: var(--good); }
.readout .bad { color: var(--critical); }

.estate-body {
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
  gap: 1.25rem;
  align-items: start;
}
.estate-gauge { text-align: center; }
.estate-gauge svg { max-width: 180px; margin: 0 auto; }
.gauge-caption { font-size: 0.85rem; color: var(--ink-secondary); }

.steps { border-collapse: collapse; width: 100%; font-size: 0.88rem; }
.steps th, .steps td {
  padding: 0.35rem 0.6rem 0.35rem 0;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid var(--border);
}
.steps thead th { font-family: var(--display); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-muted); }
.steps .expr { font-family: var(--mono); font-size: 0.82rem; color: var(--ink-secondary); }
.steps .value { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; }

/* A figure in the working that has a reason behind it rather than a control.
   Dotted rather than solid so it doesn't read as a link. */
.steps .expr .gloss {
  text-decoration: underline dotted currentColor;
  text-underline-offset: 0.25em;
  cursor: help;
  color: var(--ink);
}

.notes { margin: 0 0 1rem; padding-left: 1.1rem; color: var(--ink-secondary); font-size: 0.9rem; }
.notes li { margin-bottom: 0.3rem; max-width: var(--measure); }

/* One column of the node drill: the engine's topology over the lane it belongs
   to. The lane card takes the rest of the column so both cards end level, and
   the rows inside them are given a height so the two tables read across the
   gap rather than drifting apart wherever one cell wraps. */
.kv-lane { display: flex; flex-direction: column; gap: 0.9rem; }
.kv-lane > .lane { flex: 1; }
.kv-frame .steps tr { height: 3.4rem; }

.lane-headline { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.4rem; }
.lane-log { font-size: 0.85rem; color: var(--ink-secondary); min-height: 2.4em; }
.lane-done .lane-headline { color: var(--good); }

.log, .mechanisms {
  margin: 1rem 0 0;
  padding-left: 1.2rem;
  font-size: 0.9rem;
  color: var(--ink-secondary);
}
.log li, .mechanisms li { margin-bottom: 0.35rem; max-width: var(--measure); }

.claim {
  margin-top: 1.25rem;
  padding: 0.9rem 1.1rem;
  border-left: 4px solid var(--series-b);
  background: var(--surface-sunken);
  border-radius: 0 var(--radius) var(--radius) 0;
}

.capabilities {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 1rem;
  margin-top: 1.25rem;
}
.capability {
  padding: 0.8rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.9rem;
}
.capability p { margin-bottom: 0.4rem; }
/* The pill sits on its own line under the name, so a long capability name and a
   long ships label can't crowd each other onto one ragged line. */
.capability h4 { margin-bottom: 0.3rem; }
.capability .ships { display: block; width: fit-content; margin-bottom: 0.6rem; }
.caveat { color: var(--ink-muted); font-size: 0.85rem; }
.ships {
  display: inline-block;
  padding: 0.05rem 0.45rem;
  border-radius: 999px;
  font-size: 0.7rem;
  font-weight: 500;
  white-space: nowrap;
}
.ships-preview { background: var(--brand-wash); color: var(--ink); }
.ships-ga { background: var(--surface-sunken); color: var(--ink-secondary); border: 1px solid var(--border); }

.badge {
  display: inline-block;
  padding: 0.2rem 0.5rem;
  border-radius: var(--radius-sm);
  font-family: var(--display);
  font-size: 0.78rem;
}
.badge-preview { background: var(--brand-wash); }

.illustrative { font-size: 0.78rem; color: var(--ink-muted); margin: 0; }

/* The blob drill's clock. It's the one scene that compresses time, so the
   cluster clock and the compression ratio sit together above the counters
   rather than being buried in them. */
.blob-clock {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.6rem;
  margin: 0.9rem 0 0.2rem;
  font-variant-numeric: tabular-nums;
}
.blob-clock strong { font-size: 1.1rem; }
.blob-detail { margin: 0; font-size: 0.9rem; color: var(--ink-secondary); max-width: var(--measure); min-height: 2.6em; }

.blob-phase {
  padding: 0.15rem 0.5rem;
  border-radius: var(--radius-sm);
  font-family: var(--display);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  background: var(--surface-sunken);
  color: var(--ink-secondary);
}
/* Suspect is the interesting one: it looks like trouble and costs nothing, so
   it's warned rather than alarmed. Nothing here is ever critical. */
.blob-phase-suspect, .blob-phase-repairing { background: var(--brand-wash); color: var(--ink); }
.blob-phase-repaired, .blob-phase-recovered { background: var(--brand-wash); color: var(--good); }

/* The two storage pictures: the ratio, then where the bytes sit. They stack on
   anything narrow, because both are drawn wide. */
.storage-viz {
  display: grid;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.storage-summary { margin-bottom: 1rem; }
.storage-headline { font-size: 1.15rem; max-width: var(--measure); }
.storage-summary .explain { color: var(--ink-secondary); font-size: 0.9rem; }

details { margin: 1rem 0; }
summary { cursor: pointer; font-size: 0.9rem; color: var(--ink-secondary); }
.deep-dive > *, .table-view > * { margin-top: 0.6rem; }
/* Expanders open under full-width tables and pictures, and the reader has asked
   for what's inside them, so their prose takes the column rather than the
   measure. Held at the measure it reads as a narrow strip bolted to the side of
   the thing it explains. */
.deep-dive p, .deep-dive li { max-width: none; }
.table-view table { border-collapse: collapse; font-size: 0.85rem; }
.table-view caption { text-align: left; color: var(--ink-muted); font-size: 0.8rem; padding-bottom: 0.4rem; }
.table-view th, .table-view td {
  padding: 0.2rem 0.9rem 0.2rem 0;
  text-align: left;
  font-variant-numeric: tabular-nums;
  border-bottom: 1px solid var(--border);
}

/* ---------- SVG marks ----------
 * Recessive grid and axes, thin marks, direct labels in ink tokens rather than
 * series colours. Fills carry a surface-coloured gap so adjacent marks read as
 * separate objects. */

/* Chart text stays in the narrower sans. The renderer positions labels at fixed
   coordinates it computed for those metrics: monospace is wide enough that a
   card label and its right-anchored node count collide. Mono is the page
   furniture's voice, not the plot's. */
.viz text { font-family: var(--font); fill: var(--ink-secondary); }
/* Corner rounding on plain <rect> marks is a CSS geometry property, so the
   brand's rounding lands on every box without changing what the renderer
   emits or the golden SVGs it's tested against. */
.topo-node, .topo-s3, .topo-store, .estate-card, .estate-host, .estate-vc { rx: 8px; }
.viz-gridline { stroke: var(--border); stroke-width: 1; }
.viz-baseline { stroke: var(--border-strong); stroke-width: 1; }
.viz-tick { font-size: 10px; fill: var(--ink-muted); }
.viz-tick-y { text-anchor: end; }
.viz-tick-x { text-anchor: middle; }
.viz-axis-title { font-size: 10px; fill: var(--ink-muted); text-anchor: end; }
.viz-caption { font-size: 12px; fill: var(--ink-muted); }
/* Direct labels are drawn over the plot, so each carries a surface halo:
   stroke first, fill on top. Same rule as the surface ring on overlapping
   marks - without it a label that lands on a line is unreadable. */
.viz-label {
  font-size: 12px;
  font-weight: 600;
  fill: var(--ink);
  paint-order: stroke fill;
  stroke: var(--surface-raised);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.viz-label-end { text-anchor: end; }
.viz-label-muted { font-weight: 400; fill: var(--ink-muted); }

/* An animated element that isn't showing on this frame. The animation patches
   the elements the first frame drew rather than replacing the markup, so
   something that comes and goes has to be in the document the whole time. */
.viz-off { display: none; }

/* Bill chart. The shaded gap is the point of the picture, so it's the only
   texture-weight fill; the consumed area sits inside it. */
.viz-gap { fill: var(--gap-fill); stroke: none; }
.viz-area-consumed { fill: var(--area-fill); stroke: none; }
.viz-line-consumed { fill: none; stroke: var(--series-a); stroke-width: 2; stroke-linejoin: round; }
.viz-line-provisioned {
  fill: none;
  stroke: var(--border-strong);
  stroke-width: 2;
  stroke-dasharray: 6 4;
}
.viz-dot-consumed { fill: var(--series-a); stroke: var(--surface-raised); stroke-width: 2; }
.viz-cursor { stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 3 3; }

/* Latency panes. Shared y-scale, 2px lines, and one hue across both panes:
   tenant A is the same tenant either side, so it's the same colour either side
   and the difference between the panes is left to be the shape of its line. */
.viz-area-tenant { fill: var(--area-fill-soft); stroke: none; }
.viz-line-tenant { fill: none; stroke: var(--series-b); stroke-width: 2; stroke-linejoin: round; }
.viz-dot-tenant { fill: var(--series-b); stroke: var(--surface-raised); stroke-width: 2; }
/* Tenant B: the tenant running the query. A neutral dashed reference line in
   both panes, because it's the same tenant on both and it's context for the
   line the scene is actually about. */
.viz-line-neighbour {
  fill: none;
  stroke: var(--ink-muted);
  stroke-width: 2;
  stroke-dasharray: 5 4;
  stroke-linejoin: round;
}
.viz-dot-neighbour { fill: var(--ink-muted); stroke: var(--surface-raised); stroke-width: 2; }
/* The throttle marker, which is why tenant B's line goes flat. */
.viz-marker { stroke: var(--border-strong); stroke-width: 1; stroke-dasharray: 2 3; }
.viz-marker-label { font-size: 10px; fill: var(--ink-muted); }

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1rem;
  margin: 0.2rem 0 0;
  padding: 0;
  list-style: none;
  font-size: 0.78rem;
  color: var(--ink-secondary);
}
.legend li { display: inline-flex; align-items: center; gap: 0.4rem; }
.legend-swatch {
  width: 0.85rem;
  height: 0.2rem;
  border-radius: 999px;
  background: var(--ink-muted);
}
.legend-tenant { background: var(--series-b); }
/* The dashed line in the plot, in the width a swatch has to say it with. */
.legend-neighbour {
  background: repeating-linear-gradient(90deg, var(--ink-muted) 0 0.22rem, transparent 0.22rem 0.38rem);
}

/* Gauge. A status scale, not a series: idle hardware is the problem. */
.gauge-track { fill: none; stroke: var(--border); stroke-width: 12; stroke-linecap: round; }
.gauge-fill { fill: none; stroke-width: 12; stroke-linecap: round; }
.gauge-idle { stroke: var(--critical); }
.gauge-warm { stroke: var(--series-a); }
.gauge-healthy { stroke: var(--good); }
.gauge-value { font-size: 20px; font-weight: 650; text-anchor: middle; fill: var(--ink); }
.gauge-label { font-size: 9px; text-anchor: middle; fill: var(--ink-muted); }

/* Estate chart. */
.estate-card { fill: var(--surface-raised); stroke: var(--border); stroke-width: 1; }
.estate-card-label { font-size: 12px; font-weight: 600; fill: var(--ink); }
.estate-card-util { font-size: 11px; fill: var(--ink-secondary); }
.estate-card-nodes { font-size: 10px; fill: var(--ink-muted); text-anchor: end; }
/* Tracks use the border token, not the sunken surface: sunken-on-raised is a
   5% delta in dark mode, which is invisible. */
.estate-bar-track { fill: var(--border); }
.estate-bar-fill { fill: var(--series-a); }
.estate-host { fill: var(--surface-sunken); stroke: var(--border-strong); stroke-width: 1; }
.estate-host-label { font-size: 13px; font-weight: 600; fill: var(--ink); }
.estate-host-nodes { font-size: 11px; fill: var(--ink-secondary); text-anchor: end; }
.estate-vc { fill: var(--surface-raised); stroke: var(--border); stroke-width: 1; }
.estate-vc-label { font-size: 12px; font-weight: 600; fill: var(--ink); }
.estate-vc-sub { font-size: 9px; fill: var(--ink-muted); }
/* The pooled node row: a progress bar of average against peak, because the
   pool runs from three nodes to four figures and ticks stop being countable
   long before that. */
.estate-pool-track { fill: var(--border); }
.estate-pool-fill { fill: var(--series-b); }
.estate-pool-label { font-size: 11px; fill: var(--ink-secondary); text-anchor: end; }

/* Two-lane progress. Both lanes run at 1x, so the fill is a real fraction of a
   real transfer and nothing here pulses to imply progress it can't measure. */
.lane-track { fill: var(--border); }
.lane-fill-ebs { fill: var(--series-a); }
.lane-fill-disagg { fill: var(--series-b); }
.lane-caption { font-size: 11px; fill: var(--ink-muted); }
.lane-caption.lane-done { fill: var(--good); }

/* Topology. */
.topo-node { fill: var(--surface-raised); stroke: var(--border-strong); stroke-width: 1; }
.topo-dead { fill: var(--surface-sunken); stroke: var(--critical); stroke-width: 2; stroke-dasharray: 4 3; }
.topo-repairing { stroke: var(--series-a); stroke-width: 2; }
.topo-replaced { stroke: var(--good); stroke-width: 2; }
.topo-label { font-size: 12px; font-weight: 600; text-anchor: middle; fill: var(--ink); }
.topo-state { font-size: 10px; text-anchor: middle; fill: var(--ink-muted); }
.topo-state-dead { fill: var(--critical); font-weight: 600; }
.topo-state-repair { fill: var(--series-a); }
.topo-link { stroke: var(--border); stroke-width: 1; }
.topo-link-active { stroke: var(--series-a); stroke-width: 2; stroke-dasharray: 4 3; }
.topo-s3, .topo-store { fill: var(--surface-sunken); stroke: var(--border-strong); stroke-width: 1; }
.topo-s3-label, .topo-store-label { font-size: 12px; text-anchor: middle; fill: var(--ink-secondary); }

/* Storage. Two models on one scale, so the segment a byte is spent on carries
   the meaning and the engine keeps its own colour for the copies it holds. The
   model that isn't selected is dimmed rather than dropped: it's the comparison,
   and a chart of one bar has nothing to say. */
.stor-off { opacity: 0.5; }
.stor-seg { stroke: var(--surface-raised); stroke-width: 1; }
.stor-copies-ebs { fill: var(--series-a); }
.stor-copies-disagg { fill: var(--series-b); }
/* Free space is the part of the bill nothing is stored in, so it reads as an
   absence: the gap texture the bill chart uses for exactly the same idea. */
.stor-slack { fill: var(--gap-fill); }
/* The object storage copy is the cheap one, so it's drawn as the empty step of
   the ramp - outlined rather than filled, or it disappears into the page in
   dark mode. */
.stor-s3 { fill: var(--surface-sunken); stroke: var(--border-strong); }
.stor-tick { stroke: var(--surface-raised); stroke-width: 1; opacity: 0.7; }
.stor-seg-label { font-size: 10px; text-anchor: middle; fill: var(--ink-muted); }
/* The cold tier bills bytes differently without moving any, so it's a brace
   under the share of the bar it applies to rather than a slice of it. */
.stor-brace { stroke: var(--series-b); stroke-width: 1; }
.stor-brace-label { text-anchor: start; fill: var(--ink-secondary); }
/* The counterfactual rail - what an untiered meter would have charged - is the
   number that doesn't happen, so it gives up the series colour to the bill
   above it and wears the furniture's greys instead. */
.stor-brace-alt { stroke: var(--border-strong); }
.stor-brace-label-alt { fill: var(--ink-muted); }
/* The run the cold tier takes off that rail, outlined rather than filled: the
   summary chart draws a saving this way for the same reason, which is that a
   solid block reads as more bar rather than as less bill. Not the gap texture -
   that one is red, and it means capacity paid for and wasted, which is the
   opposite of what this is. */
.stor-save { fill: none; stroke: var(--series-b); stroke-width: 2; }
.stor-save-label { font-size: 10px; font-weight: 600; text-anchor: middle; fill: var(--series-b); }
.stor-mult { font-size: 12px; font-weight: 650; fill: var(--ink-secondary); }
.stor-frame { fill: none; stroke: var(--border); stroke-width: 1; rx: 10px; }
.stor-frame-on { stroke: var(--border-strong); }
.stor-panel-title { text-anchor: middle; }
.stor-note { font-size: 11px; text-anchor: middle; fill: var(--ink-muted); }

/* Summary. Two bars, today over tomorrow, and the length the second one gives
   up is the saving. Both bars are the page furniture's greys: they're the two
   ends of one comparison and neither is a series, so colour is free to mean
   something else here. What it means is the gap between them - virtualization
   in the incumbent's blue, storage in the purple it wears on the three pages
   before this one. */
.sum-bar, .sum-save, .sum-over, .sum-swatch { rx: 3px; }
/* Both bars are the same grey. They're the two ends of one comparison, and a
   reader who has to work out what a second colour on a second bar means is
   being asked a question the chart doesn't want answered. The only colour here
   is the length the second bar gave up, which is the whole point of it. */
.sum-bar { fill: var(--surface-sunken); stroke: var(--border-strong); stroke-width: 1; }
/* The legend's swatches, which are the only solid ink on the chart: they're the
   ten pixels that say what a hue means, so they say it at full strength. */
.sum-virt { fill: var(--series-a); }
.sum-stor { fill: var(--series-b); }
/* The saved segments, outlined rather than filled. They're the same greys'
   worth of bar as the two bars they sit beside - money not being spent, drawn
   the way the bars are - and the hue only has to name which of the two things
   bought it. Solid blocks made the saving look like a third bar with more
   weight than the estate it came off.
   The pair below has to out-specify the swatch fills above, which is why
   they're two-class selectors rather than a later single-class one: at equal
   specificity the last rule wins, and that would paint both segments the same
   colour whichever it was. */
.sum-save { fill: none; stroke: var(--border-strong); stroke-width: 2; }
.sum-save.sum-virt { fill: none; stroke: var(--series-a); }
.sum-save.sum-stor { fill: none; stroke: var(--series-b); }
/* Tomorrow is dearer than today. The gap runs the other way and stops being a
   saving, so it wears the bill chart's texture for capacity paid for and not
   used. */
.sum-over { fill: var(--gap-fill); stroke: var(--critical); stroke-width: 1; }
/* The gap named, once, above the bars rather than written across the segments:
   the sheet has no ink colour guaranteed to read on a series fill, and two
   figures inside two coloured blocks is more furniture than the counters
   underneath already carry better. */
.sum-gap-label { font-size: 11px; font-weight: 600; fill: var(--ink-secondary); text-anchor: middle; font-variant-numeric: tabular-nums; }
.sum-row-label { font-size: 12px; font-weight: 600; fill: var(--ink); text-anchor: end; }
.sum-row-note { font-size: 10px; fill: var(--ink-muted); text-anchor: end; }
.sum-value { font-size: 12px; fill: var(--ink-secondary); font-variant-numeric: tabular-nums; }
.sum-value-now { font-weight: 650; fill: var(--ink); }

/* ---------- social cards ----------
   Only ever seen by the rasteriser in scripts/make-og.sh, at exactly
   1200x630. Fixed pixel sizes rather than the responsive scale the rest of
   the sheet uses, because the viewport is known and never changes. */

.og-body {
  width: 1200px;
  height: 630px;
  margin: 0;
  padding: 0;
  overflow: hidden;
}

.og-card {
  display: grid;
  /* The art gets the larger share: these charts are wide and short, so width
     is what sets how big their type renders. */
  grid-template-columns: 1fr 1.35fr;
  align-items: center;
  gap: 48px;
  width: 100%;
  height: 100%;
  padding: 64px 72px;
  background: var(--surface);
  /* The brand keyline, so a card is recognisable as ours at thumbnail size. */
  border-top: 10px solid var(--brand);
}

.og-kicker {
  margin: 0 0 14px;
  font-family: var(--display);
  font-size: 20px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.og-title { margin: 0 0 24px; font-size: 42px; line-height: 1.1; letter-spacing: -0.02em; }
.og-number { margin: 0; font-family: var(--display); font-size: 70px; font-weight: 650; letter-spacing: -0.04em; line-height: 1; font-variant-numeric: tabular-nums; }
.og-number-label { margin: 8px 0 22px; font-size: 20px; color: var(--ink-secondary); }
.og-sub { margin: 0; font-size: 20px; line-height: 1.4; color: var(--ink-secondary); }
.og-brand { margin: 28px 0 0; font-size: 17px; font-weight: 600; color: var(--ink-muted); }

/* The per-chart max-widths exist to stop type scaling up in a wide pane. A
   card has one chart and 500px to give it, so lift them here only. */
.og-art .viz { max-width: none; }
.og-lanes { display: grid; gap: 10px; }
.og-lane-label { margin: 0; font-size: 17px; font-weight: 600; color: var(--ink-secondary); }

/* ---------- the band at the head of the window ---------- */

/* Layer one of the bar, above its translucent backdrop and below everything it
   says. Outside the bar it would be behind the blur, which washes it out to
   nothing. It's decoration: nothing in it is clickable and nothing in it is
   read out.

   The library grows its mass from the bottom edge up, and this hangs from the
   top edge down, so the canvas is flipped rather than the generator taught a
   second direction. */
.page-band {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  z-index: 0;
  display: block;
  width: 100%;
  /* The whole bar, not a strip across the top of it: the shapes are sized off
     this, so the height is what decides how far down the header they reach. */
  height: 100%;
  transform: scaleY(-1);
  pointer-events: none;
  /* Held back: at full strength the rim crosses the masthead and the labels in
     the top row of the KPI cards, and the reader has to work to get them
     back. */
  opacity: 0.55;
}

/* A wipe rather than a fade, so it reads as being drawn on rather than turned
   up. app.js adds the class, and only when motion is on, so this never runs
   for a reader who asked for stillness. */
.page-band-draw {
  animation: band-draw 1.5s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

@keyframes band-draw {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* ---------- reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* ---------- narrow screens ---------- */

/* Nine steps side by side with their words need about 1025px, which a maximised
   window has and a windowed one often does not - and the step that falls off
   the end is the summary, the one the whole argument is walking towards. So the
   rail gives its words up in two moves rather than one, and both of these are
   about the length of nine labels rather than about a phone.

   The first move is at 1060 rather than at the 1025 they stop fitting at,
   because that measurement is of one font: Geist Mono not arriving puts the
   same nine words in whatever the fallback is, and a rail that clips only when
   the webfont fails is a bug nobody reproduces.

   First the number goes above its word instead of beside it. A pill is then as
   wide as its longest line rather than the sum of both, which is the circle
   and its gap off every one of the nine - and the words survive, which is the
   point: the rail is the table of contents, and eight numbers are not one. */
@media (max-width: 1060px) and (min-width: 801px) {
  .rail-link {
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.3rem 0.4rem;
  }
}

@media (max-width: 800px) {
  /* The four figures stack here, and a bar tall enough to hold them stacked is
     most of a phone screen. Below this width they go back to being the top of
     the page rather than a fixture on it, and the band goes with them. */
  .topbar {
    /* relative, not static: the band is absolute, and with no positioned
       ancestor it would size itself against the viewport and paint over half
       the page. */
    position: relative;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    border-bottom: 0;
  }
  /* The stacked bar is most of a phone screen, and a band filling it would be
     the page. Back to a strip across the top of it. */
  .page-band { height: var(--band-h); }
  .kpi-list { grid-template-columns: repeat(2, 1fr); }
  .estate-body { grid-template-columns: minmax(0, 1fr); }

  /* The second of the rail's two moves, and the last one it has: stacked, the
     nine still fit down to about here, but a two-line rail costs height on the
     screen with the least of it. So the words go, the step the reader is on
     keeps its own so the rail still says where they are rather than only how
     far along, and it goes back to one line - a lone circle has nothing to
     stack under. */
  .rail-label { display: none; }
  .rail-here .rail-label { display: inline; }

  /* The working-out tables carry monospace arithmetic that can't wrap
     usefully. Let them scroll inside their own box rather than widen the
     page. */
  .steps { display: block; overflow-x: auto; }
  .readout { gap: 0.9rem; }
  /* Two columns of a form and its answer need the form's own three columns to
     survive first. Below that the summary goes back under it. */
  .intro-config { grid-template-columns: minmax(0, 1fr); }
}

@media (max-width: 560px) {
  .kpi-list { grid-template-columns: minmax(0, 1fr); }
  /* Three columns need about 20rem to stay legible. Below that the value goes
     under its own control rather than squeezing the slider to nothing. */
  .config-rows { grid-template-columns: minmax(0, 1fr) minmax(3.5rem, max-content); }
  .config-rows label,
  .config-rows .config-label { grid-column: 1 / -1; }
  .config-chips, .config-note { grid-column: 1 / -1; }
  /* A 15rem floor is wider than the screen, so the choices go back to sharing
     what there is: still even with each other, and still on the page. */
  .controls .btn-choice,
  .controls .btn-toggle { min-width: 0; flex: 1 1 9rem; }
  /* Too narrow to hold the label, the slider and the note on one line, so each
     control takes its own row and they all share the form's left edge. */
  .control-form { grid-template-columns: minmax(0, 1fr); }
  .control-form .fineprint { grid-column: 1 / -1; }
  /* A phone gets a shorter band: at 170px it's a third of the screen. */
  body { --page-pad: 0.85rem; --band-h: 110px; padding-bottom: 3rem; }
  .hero-number { font-size: clamp(2.2rem, 12vw, 3rem); }
}

/* ---------- the catalogue ---------- */

/* The front door. It shares the chrome, the tokens and the type with every
   demo behind it, which is the whole argument for one binary: a demo and the
   page that lists it can't drift apart if they're the same stylesheet.
   Everything below is layout - not one new colour. */

/* The whole window, behind the whole page. Fixed rather than absolute so it
   stays a backdrop rather than a very tall picture that scrolls away, and
   negative so it sits over the body's wash but under every word on top of it -
   body's own background propagates to the viewport, so there is no box of its
   own here for a negative layer to hide behind.

   The opacity is the legibility budget: #949187 is 2.97:1 against the light
   surface, so at full strength the mass would be competing with the type it's
   behind rather than sitting under it. */
.page-backdrop {
  position: fixed;
  inset: 0;
  z-index: -1;
  display: block;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0.22;
}

/* No KPI bar under the masthead here, so the bar is a masthead and nothing
   else: no bottom rule, and it doesn't stick. There's nothing on this page
   that has to stay readable while something else moves. */
.topbar-flat {
  position: static;
  border-bottom: 0;
}
/* Site name, separator, demo name. Only the demo pages have all three; here
   the middle and right are absent and the lockup is just the mark. */
.masthead-home {
  display: flex;
  gap: 0.7rem;
  align-items: center;
  color: inherit;
  text-decoration: none;
}
.masthead-site {
  font-family: var(--display);
  font-size: 1rem;
  font-weight: 600;
}
.masthead-sep {
  color: var(--ink-muted);
  font-family: var(--display);
}
/* The demo's own name takes the space the site's name gives up, so the lockup
   reads left to right as site, then demo, then what the demo argues. */
.masthead-title { margin-right: auto; }

.catalogue { padding-bottom: 1rem; }

/* One thing said large, then the list. The count is under it because "how many
   demos are there" is the first question the page is asked and the answer
   shouldn't need counting. */
.marquee {
  padding: 2.5rem 0 2rem;
  max-width: var(--measure);
}
.marquee-eyebrow {
  margin: 0 0 0.6rem;
  font-family: var(--display);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--brand-ink);
}
.marquee-title {
  margin: 0 0 0.75rem;
  font-size: clamp(2rem, 6vw, 3.1rem);
}
/* Two lines, one sentence each, and the second is the invitation. The break is
   a block rather than a <br>, so it wraps within each half on a phone instead
   of breaking wherever the width runs out. */
.marquee-title > span { display: block; }
.marquee-soft { color: var(--ink-muted); }
.marquee-sub {
  margin: 0;
  color: var(--ink-secondary);
  font-size: 1.05rem;
}
.marquee-count {
  margin: 1rem 0 0;
  font-family: var(--display);
  font-size: 0.85rem;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* Filters are links, so they're styled like the choice buttons on the demo's
   own forms rather than like a control the page owns. */
.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  padding: 0 0 1.5rem;
}
.chip {
  display: inline-flex;
  gap: 0.4rem;
  align-items: baseline;
  padding: 0.25rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-raised);
  color: var(--ink-secondary);
  font-family: var(--display);
  font-size: 0.8rem;
  text-decoration: none;
}
.chip:hover { border-color: var(--brand); color: var(--ink); }
.chip-on {
  border-color: var(--brand);
  background: var(--brand-wash);
  color: var(--ink);
  font-weight: 600;
}
.chip-count {
  font-size: 0.72rem;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* One card per row, not a grid of tiles. Each demo gets a paragraph to say
   what it argues, and a paragraph doesn't fit in a tile - the list is short
   enough that scanning it costs nothing and long enough copy is what tells two
   demos apart. */
.demo-list {
  display: grid;
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.demo-card {
  position: relative;
  display: grid;
  grid-template-columns: 3.5rem minmax(0, 1fr);
  gap: 0 0.5rem;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  transition: border-color 150ms ease, box-shadow 150ms ease;
}
.demo-card:hover {
  border-color: var(--brand);
  box-shadow: 0 0 2.5rem var(--brand-glow);
}
/* The number is the card's handle in conversation, and the only thing on the
   page set in the monospace at this weight. */
.demo-index {
  margin: 0;
  font-family: var(--mono);
  font-size: 0.85rem;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}
.demo-body { min-width: 0; }
.demo-meta {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin: 0 0 0.35rem;
  font-family: var(--display);
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.demo-category { color: var(--ink-muted); }
/* Status is a word, not a colour: the pill is tinted, but a reader who can't
   see the tint still reads "Internal". */
.pill {
  padding: 0.05rem 0.5rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  color: var(--ink-secondary);
}
.pill-live { border-color: var(--good); color: var(--good); }
.pill-preview { border-color: var(--brand); color: var(--brand-ink); }
.pill-internal { border-color: var(--critical); color: var(--critical); }

.demo-title { margin: 0 0 0.25rem; font-size: 1.35rem; }
/* The link is the title; the pseudo-element is what makes the whole card
   clickable without wrapping a second link around the tags inside it. */
.demo-link { color: inherit; text-decoration: none; }
.demo-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
}
.demo-card:hover .demo-link { color: var(--brand-ink); }
.demo-tagline {
  margin: 0 0 0.5rem;
  color: var(--ink);
  font-size: 1rem;
}
.demo-summary {
  margin: 0 0 0.7rem;
  max-width: var(--measure);
  color: var(--ink-secondary);
  font-size: 0.92rem;
}
.demo-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0;
}
.tag {
  font-family: var(--mono);
  font-size: 0.76rem;
  color: var(--ink-muted);
}
/* Anything worth reading beside the demo sits above the card's own hit area,
   or the card would swallow the click. */
.demo-links {
  position: relative;
  z-index: 1;
  display: flex;
  gap: 0.9rem;
  margin: 0.7rem 0 0;
}
.demo-aside {
  color: var(--brand-ink);
  font-family: var(--display);
  font-size: 0.8rem;
}

/* The 404 is the catalogue with a line on top. */
.notice {
  margin: 1.25rem 0 0;
  padding: 0.7rem 1rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  font-size: 0.92rem;
}
.notice code { font-family: var(--mono); font-size: 0.88em; }

@media (max-width: 560px) {
  /* The index column costs a quarter of a phone screen and says the least of
     anything on the card. */
  .demo-card { grid-template-columns: minmax(0, 1fr); }
  .demo-index { margin-bottom: 0.4rem; }
  .masthead-sep, .masthead-sub { display: none; }
}

/* The card's own call to action, set apart from the optional reading beside
   it: one of these is where the card goes, the rest are where it points. */
.demo-go { font-weight: 600; }

/* ---------- the videos ---------- */

/* Tiles, unlike the demos above: a recording says what it is with a still and a
   runtime, and there is no argument to make room for. Same tokens, same card
   chrome, so the two sections read as one page with two kinds of thing on it
   rather than two pages stacked. */
.videos { padding: 2.5rem 0 0; }
.videos-heading {
  margin: 0 0 0.3rem;
  font-size: 1.6rem;
}
.videos-sub {
  margin: 0 0 1.25rem;
  max-width: var(--measure);
  color: var(--ink-secondary);
  font-size: 0.95rem;
}
/* auto-fill rather than a fixed count: the row reflows on its own from a phone
   up to a wide window, and the last row doesn't stretch to fill. */
.video-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 0.75rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
.video-card {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-raised);
  transition: border-color 150ms ease, box-shadow 150ms ease;
}
.video-card:hover {
  border-color: var(--brand);
  box-shadow: 0 0 2.5rem var(--brand-glow);
}
/* Fixed aspect so a row of tiles keeps one baseline whatever the still's own
   proportions are. */
.video-thumb {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-bottom: 1px solid var(--border);
}
.video-body {
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: 0.9rem 1rem 1rem;
}
.video-title { margin: 0 0 0.4rem; font-size: 1.02rem; line-height: 1.3; }
.video-link { color: inherit; text-decoration: none; }
.video-link::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
}
.video-card:hover .video-link { color: var(--brand-ink); }
.video-summary {
  margin: 0 0 0.9rem;
  color: var(--ink-secondary);
  font-size: 0.88rem;
}
/* mt-auto in spirit: the runtime and the call to action sit on the tile's
   floor, so they line up across a row of cards with different copy lengths. */
.video-foot {
  display: flex;
  gap: 0.75rem;
  align-items: baseline;
  justify-content: space-between;
  margin: auto 0 0;
  font-family: var(--display);
  font-size: 0.8rem;
}
.video-duration { color: var(--ink-muted); font-variant-numeric: tabular-nums; }
.video-go { color: var(--brand-ink); font-weight: 600; }
