/*
 * Design tokens and layout.
 *
 * The palette is **measured, not asserted**. `npm run palette` checks every
 * categorical slot for contrast against the surface it is drawn on, and
 * simulates protanopia, deuteranopia and tritanopia to find two slots a reader
 * could not tell apart. Zero collisions in both themes is the standard, and it
 * is a test rather than a claim — the previous palette carried a comment saying
 * it was validated, and the dark half turned out to have five collisions,
 * including two series at ΔE 2.2 that were simply the same colour.
 *
 * The hues come from the delivered mockup; four in light and five in dark were
 * moved the smallest distance that clears a collision. Cash stays a neutral
 * outside the categorical set, because cash is the absence of a position rather
 * than another holding, and the blue/red diverging pair is untouched.
 */

/*
 * `hidden` means hidden. Once, globally, and with `!important` on purpose.
 *
 * The user agent's `[hidden] { display: none }` is a plain rule, so *any* class
 * that sets a `display` silently beats it and the element stays on screen with
 * `hidden` set. This project shipped that bug three separate times — a zoom bar
 * in 0.12.0, five whole sections in 0.21.0, and the toolbar after that — each
 * time patched on the one element that had been noticed. Three instances of one
 * defect is not three bugs; it is a missing rule, and the per-element patches
 * are deleted with this.
 *
 * `!important` is the point rather than a shortcut: an element with `hidden` on
 * it must not be visible, and nothing further down the stylesheet is allowed to
 * disagree.
 */
[hidden] {
  display: none !important;
}

:root {
  color-scheme: light;

  /* Surfaces, from the 0.46 redesign package: a near-white paper with a warm
     cast, one step of card, one step of fill, and the rail its own value. The
     previous ramp was a warmer cream; these were re-measured rather than
     eyeballed — every categorical slot's contrast is against --surface-1, so
     moving the surface moves ten contrast pairs, and `npm run palette` is the
     only thing that can say whether they still clear 3:1. They do, by more than
     before: a whiter card lifts every slot. */
  --page: #fbfaf8;
  --surface-1: #ffffff;
  --surface-2: #f4f2ee;
  --surface-3: #eae6e0;
  --rail: #f6f4f0;
  --text-primary: #14181d;
  --text-secondary: #5c6672;
  --text-muted: #939ca7;
  --grid: #e9e6e0;
  --axis: #d6d2ca;
  --border: #e9e6e0;
  --border-strong: #d6d2ca;

  /* categorical slots, repaired to zero collisions — see tools/check-palette.mjs */
  --series-1: #557cd4;
  --series-2: #7b52ab;
  --series-3: #00868b;
  --series-4: #b0461a;
  --series-5: #a5851b;
  --series-6: #577f43;
  --series-7: #a03a6f;
  --series-cash: #8a8880;

  --pos: #0b5fc4;
  --neg: #c02434;
  /* Tints of the two signs, for a bar or a chip behind a figure. Derived from
     --pos / --neg rather than picked, so they cannot drift apart from them. */
  --pos-soft: #e4edf9;
  --neg-soft: #fae7e8;


  --good: #0b5fc4;
  --warning: #785000;
  --critical: #9e1628;

  --accent: #b8532f;

  /* Asteria — the brand's own two values, deliberately separate from --accent.
     Not the same colour, and swapping them is not a rename: white on #d9531e
     measures 4.03:1, under the 4.5:1 floor, where the app's accent measures
     4.87:1 — and --accent is a filled button background. Making the brand
     colour the app colour is a measured change to a validated palette, so it
     stays a separate decision.
     Ink is here for the canvas only; in the DOM the mark rides currentColor. */
  --brand-ink: #16213e;
  --brand-accent: #d9531e;

  /* prulwerk.nl — the site's own brand, kept separate from --brand-* (Asteria's).
     Used only by the "back to prulwerk.nl" link, per the prulwerk-huisstijl skill:
     "prulwerk" rides the page's own text colour, ".nl" is the full accent, and the
     border/focus token is a deliberately darker line so it clears 3:1 on a light
     card. */
  --pw-card: #ffffff;
  --pw-line: #e3e3dd;
  --pw-accent: #a6d400;
  --pw-focus: #718f00;
  /* Two roles, because the redesign has two layers and only one of them lifts.
   *
   * A panel sits *on* the page: a hairline and a 1px seat, nothing more. The
   * 22px drop it used to carry is what made card-in-card read as depth, and
   * depth is the thing brief §8 removes — hierarchy comes from weight and size.
   * Only genuinely floating things — the tooltip, later the menu and the share
   * sheet — lift off the page, and those keep a real shadow because they have to
   * read as detached from what is under them. */
  --shadow: 0 1px 2px rgba(20, 25, 30, 0.05);
  --shadow-float: 0 12px 32px rgba(20, 25, 30, 0.13), 0 2px 6px rgba(20, 25, 30, 0.06);

  /* Shape and rhythm, from the mockup's "studio" skin.
   *
   * **Type and spacing are in rem, and the fluid ones are `clamp()`.** A page
   * whose font size is fixed in pixels ignores the size the reader chose in
   * their browser — someone who set 20px because 16 is too small still gets
   * 13.5px — and a `px` media query does not respond to that setting either,
   * where an `em` one does. So the only pixels left are the hairline, which is
   * meant to stay one device pixel, and a couple of decorative dots.
   *
   * `clamp()` also removes most of the breakpoints: the figures shrink
   * continuously with the window instead of stepping down at four fixed widths.
   */
  --radius: 0.625rem;
  --radius-sm: 0.375rem;
  --radius-pill: 999px;
  --pad: clamp(0.875rem, 0.55rem + 1.4vw, 1.375rem);
  --gap: clamp(0.75rem, 0.6rem + 0.6vw, 1rem);
  --outer: clamp(0.75rem, 0.35rem + 1.7vw, 1.625rem);
  /* The scale from brief §8: eyebrow 11 / body 15 / panel title 15 semibold /
     hint 12. Weight and size carry the hierarchy, which is why the borders got
     thinner in the same pass.

     Two sizes from that list are deliberately absent: the fact at 17px and the
     hero at 38–56px. Both belong to elements phase 3 introduces, and a token
     with no caller is a token nobody maintains — the hero size set today would
     only make the seven equal tiles equally huge. */
  --base: 0.9375rem;
  --kpi: clamp(1.25rem, 0.95rem + 1.3vw, 1.8125rem);
  --title: 0.9375rem;
  --hint: 0.75rem;
  /*
   * US-58 — tracking and leading are size-specific, never one value for all
   * sizes. A single global `letter-spacing` is wrong somewhere, and on the
   * biggest number on the page it is wrong most visibly: large display text
   * wants *negative* tracking and tight leading, body wants near-zero and
   * comfortable, and small uppercase labels want positive tracking or the caps
   * run together.
   *
   * Bucketed here rather than set per rule so the relationship between the
   * buckets is visible in one place — and so `npm run type` can measure it. The
   * values were already in the stylesheet, correct and scattered; what was
   * missing was anything that would notice them collapsing back to one.
   */
  --track-display: -0.025em;
  --track-title: -0.01em;
  --track-body: 0em;
  --track-label: 0.07em;
  --lead-display: 1.1;
  --lead-body: 1.55;
  --lead-label: 1.4;
  /* The old name, kept because it is the label bucket and it has thirty
     callers. Aliased rather than renamed: a rename here is a diff nobody can
     read against a bucket change that is the actual point. */
  --track: var(--track-label);
  /* Brief §8: charts are 230–320px tall. They were 144–190, and a 1400×190 line
     chart flattens the movement it exists to show — the aspect ratio was doing
     the opposite of the chart's job. The hero sits at the top of the same band
     rather than above it, because one chart being twice its neighbours is a
     hierarchy claim, and the hero already makes that claim by being first. */
  --chart-h: clamp(14.375rem, 10rem + 8vw, 20rem);
  --hero-h: clamp(15.625rem, 11rem + 8vw, 20rem);
  --kpi-w: 750;
  /*
   * US-58, corrected: tracking travels **with** the size.
   *
   * `--kpi` is re-set in six places — a hero tile takes it to 3.5rem, a fact and
   * the all-figures grid drop it to 1.0625rem, the popup to 0.9375rem — while the
   * tracking was written once, on the shared `.tile .value` rule, at the display
   * value. So a 17px amount was set at -0.025em: display tracking on body-sized
   * text, which is precisely the mistake size-bucketing exists to prevent, and
   * the check written for it did not catch it because it only ever asked whether
   * *display* rules were negative.
   *
   * Pairing them means a context that changes the size has to state the bucket,
   * and `npm run type` fails if the two disagree.
   */
  --kpi-track: var(--track-title);
  --kpi-lead: var(--lead-display);
  /*
   * US-69 — two curves and two durations, named once.
   *
   * Every transition in the app used to pick its own: `120ms ease`, `0.12s` with
   * no curve at all, `100ms ease-out`, `0.15s ease`. None of them was wrong and
   * none of them was shared, so the fifth would have been a fifth guess.
   *
   * `ease-out` in particular is the wrong built-in for something arriving: it
   * starts slowly, at exactly the moment the reader is looking hardest at it.
   *
   * **Exit is shorter than enter, everywhere.** Opening is an announcement and
   * closing is an answer, and an answer that takes as long as the question reads
   * as the interface being reluctant.
   */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  /*
   * The refinement asked for a second curve, `--ease-in-out`, for *something
   * moving across the screen*. Nothing here does: the overlays scale from an
   * origin, a section rises a few pixels, a figure swaps in place, and the
   * chart's edge is a spring rather than a transition. So it is not defined —
   * a token with no caller is a token nobody maintains, which is rule 8 and is
   * also this story's own stop condition. It goes in with the first surface
   * that actually travels.
   */
  --t-press: 100ms;
  --t-surface: 140ms;
  --t-surface-out: 110ms;
  /*
   * Two more, each named by the story that needed it and each with exactly one
   * caller. A duration written at its call site is a duration the next author
   * copies, which is how the four unrelated easings this replaced got here — so
   * "one caller" is not an argument for inlining it, it is the reason to name
   * it. The check in `npm test` deletes any of these that stops having one.
   */
  --t-notice: 180ms;
  --t-theme: 220ms;

  /* Deliberately a pixel: a hairline that scales stops being a hairline. */
  --rule: 1px;
  --font: system-ui, -apple-system, 'Segoe UI', sans-serif;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    color-scheme: dark;

    --page: #0d1116;
    --surface-1: #141920;
    --surface-2: #1b222b;
    --surface-3: #232b35;
    --rail: #10151b;
    --text-primary: #e8edf3;
    --text-secondary: #98a3b0;
    --text-muted: #6b7683;
    --grid: #232b35;
    --axis: #333d49;
    --border: #232b35;
    --border-strong: #333d49;

    --series-1: #4a86de;
    --series-2: #a383d0;
    --series-3: #41babc;
    --series-4: #da7e4b;
    --series-5: #d0ac3e;
    --series-6: #7fae5c;
    --series-7: #f086b9;
    --series-cash: #7b8798;

    --pos: #63a6f6;
    --neg: #f4737f;
    --pos-soft: #16283d;
    --neg-soft: #341c21;


    --good: #63a6f6;
    --warning: #efbf68;
    --critical: #ff9aa4;

    --accent: #e08355;
  --brand-ink: #e8edf5;
  --brand-accent: #f97038;
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
    --shadow-float: 0 16px 40px rgba(0, 0, 0, 0.55), 0 2px 6px rgba(0, 0, 0, 0.4);

    --pw-card: #101010;
    --pw-line: #262626;
    --pw-accent: #c9f531;
    --pw-focus: #c9f531;
  }
}

:root[data-theme='dark'] {
  color-scheme: dark;

  --page: #0d1116;
  --surface-1: #141920;
  --surface-2: #1b222b;
  --surface-3: #232b35;
  --rail: #10151b;
  --text-primary: #e8edf3;
  --text-secondary: #98a3b0;
  --text-muted: #6b7683;
  --grid: #232b35;
  --axis: #333d49;
  --border: #232b35;
  --border-strong: #333d49;

  --series-1: #4a86de;
  --series-2: #a383d0;
  --series-3: #41babc;
  --series-4: #da7e4b;
  --series-5: #d0ac3e;
  --series-6: #7fae5c;
  --series-7: #f086b9;
  --series-cash: #7b8798;

  --pos: #63a6f6;
  --neg: #f4737f;
  --pos-soft: #16283d;
  --neg-soft: #341c21;


  --good: #63a6f6;
  --warning: #efbf68;
  --critical: #ff9aa4;

  --accent: #e08355;
  --brand-ink: #e8edf5;
  --brand-accent: #f97038;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-float: 0 16px 40px rgba(0, 0, 0, 0.55), 0 2px 6px rgba(0, 0, 0, 0.4);

  --pw-card: #101010;
  --pw-line: #262626;
  --pw-accent: #c9f531;
  --pw-focus: #c9f531;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--page);
  color: var(--text-primary);
  font-family: var(--font);
  font-size: var(--base);
  line-height: var(--lead-body);
  letter-spacing: var(--track-body);
  /*
   * US-58, stated plainly rather than promised: **no font is bundled.** The
   * stack is the system UI face, so this does something on the platforms whose
   * system font carries an optical axis — SF on Apple, Segoe UI Variable on
   * Windows — and nothing at all elsewhere. It costs nothing where it is inert,
   * and the tracking and leading buckets above are the part that works
   * everywhere.
   */
  font-optical-sizing: auto;
  -webkit-font-smoothing: antialiased;
}

.main {
  max-width: 1420px;
  width: 100%;
  margin: 0 auto;
  padding: var(--outer);
  min-width: 0;
}
/* --- the shell: rail + main ---------------------------------------------- */

/*
 * A two-column grid rather than a stack, because the rail is persistent: the
 * section you are in stays visible instead of being inferred from which tab
 * still looks pressed. Below 60em it becomes a stack again — a 200px rail on a
 * 380px screen is half the width for six words.
 */
.app {
  display: grid;
  grid-template-columns: 13.5rem 1fr;
  min-height: 100vh;
}

.rail {
  display: flex;
  flex-direction: column;
  gap: var(--gap);
  padding: var(--gap) 0.75rem;
  background: var(--rail);
  border-right: var(--rule) solid var(--border);
  position: sticky;
  top: 0;
  height: 100vh;
  /* The rail is its own stacking context, above the panels.
     Without this its menu loses to the chart cards: those are positioned (they
     carry the watermark layer) and come later in the document, so at equal
     z-index they paint over anything the rail puts on top — the menu was drawn
     complete and then covered from the second card onward. */
  z-index: 30;
}

.rail-head {
  padding: 0.375rem 0.5rem 0.25rem;
  color: var(--text-primary);
}

.rail-nav {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/*
 * A rail item is a button, not a tab: it carries `aria-current` when it is the
 * one you are on, so a screen reader hears "current page" rather than a pressed
 * state on a control that navigates. The count badges are gone — they were card
 * counts and read as unread badges.
 */
.rail-nav button {
  text-align: left;
  border: 0;
  background: none;
  border-radius: var(--radius-sm);
  padding: 0.4375rem 0.5rem;
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text-secondary);
  transition: background var(--t-press) var(--ease-out), color var(--t-press) var(--ease-out);
}

.rail-nav button:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}

.rail-nav button[aria-current='page'] {
  background: var(--surface-1);
  color: var(--text-primary);
  font-weight: 600;
  box-shadow: inset 2px 0 0 var(--accent);
}

.rail-foot {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/*
 * Data-quality facts, not figures. Deliberately small and quiet: the reader
 * should be able to find "does this reconcile" without it competing with the
 * total value, and it must still be able to shout when it disagrees — hence the
 * .bad row keeping the critical colour.
 */
.rail-state {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: 0.6875rem;
  line-height: 1.45;
  color: var(--text-muted);
  padding: 0 0.5rem 2px;
}

.rail-state .row {
  display: flex;
  gap: 6px;
  align-items: baseline;
}

.rail-state .row.bad {
  color: var(--critical);
  font-weight: 600;
}

.rail-state .row.ok .dot {
  background: var(--pos);
}

.rail-state .dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--text-muted);
  flex: none;
  transform: translateY(-1px);
}

button.primary {
  background: var(--text-primary);
  color: var(--surface-1);
  border-color: var(--text-primary);
  width: 100%;
  justify-content: center;
}

button.primary:hover {
  filter: brightness(1.15);
}

/* --- the overflow menu --------------------------------------------------- */

.menu-wrap {
  position: relative;
}

.menu-wrap > button {
  width: 100%;
  justify-content: center;
}

/*
 * The one place in the redesign that is allowed to lift off the page, together
 * with the tooltip and the share sheet: it has to read as detached from what it
 * covers. Opens upward because it hangs off the foot of the rail.
 */
.menu {
  position: absolute;
  bottom: calc(100% + 6px);
  left: 0;
  /* Sized by its widest row rather than by the rail it hangs off: the rail is
     13.5rem and the theme row is wider than that, so a shrink-to-fit box would
     clip its last segment. It is the floating layer — overhanging is the point. */
  width: max-content;
  max-width: min(22rem, calc(100vw - 1.5rem));
  z-index: 40;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 6px;
  background: var(--surface-1);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow-float);
  /*
   * US-70. It hangs off the foot of the rail and opens upward, so that is where
   * it comes from. Never `scale(0)` — nothing in the world appears from nothing,
   * and 0.97 is enough to read as a direction without reading as a zoom.
   */
  transform-origin: bottom left;
  /*
   * The duration is read off the state being transitioned *to*, so opening
   * takes it from here and closing takes it from the `[hidden]` rule below —
   * which is how enter and exit get different lengths out of one mechanism.
   */
  transition:
    opacity var(--t-surface) var(--ease-out),
    transform var(--t-surface) var(--ease-out),
    display var(--t-surface) allow-discrete;
  opacity: 1;
  transform: none;
}

/*
 * The arrival, and the exit, without a timer.
 *
 * `@starting-style` gives the browser a from-state for an element that has just
 * become rendered, and `allow-discrete` holds `display` back long enough for the
 * fade out to finish. A JS class plus a `setTimeout` is the alternative, and it
 * is a class that stays stuck when something interrupts it — the same failure
 * §3 names, under a different name. `hidden` still means `display: none` at the
 * end of it, so a closed menu is never a transparent one that still takes
 * clicks; that bug has shipped three times in this project in another form.
 */
@starting-style {
  .menu,
  .cols-pop {
    opacity: 0;
    transform: scale(0.97);
  }
}

.menu[hidden],
.cols-pop[hidden] {
  opacity: 0;
  transform: scale(0.97);
  /* Shorter than the way in: opening is an announcement, closing is an answer. */
  transition:
    opacity var(--t-surface-out) var(--ease-out),
    transform var(--t-surface-out) var(--ease-out),
    display var(--t-surface-out) allow-discrete;
}

.menu button[role='menuitem'] {
  text-align: left;
  border: 0;
  background: none;
  border-radius: var(--radius-sm);
  padding: 0.4375rem 0.5rem;
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
  color: var(--text-primary);
}

.menu button[role='menuitem']:hover {
  background: var(--surface-2);
}

/* Destructive, below a rule, and it opens a confirm rather than firing. */
.menu button.danger {
  color: var(--critical);
}

.menu button.danger:hover {
  background: var(--neg-soft);
}

.menu-sep {
  height: var(--rule);
  background: var(--border);
  margin: 4px 2px;
}

.menu-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 2px 0.5rem;
  flex-wrap: wrap;
}

/*
 * US-79 — a menu *item* with an "i" beside it, rather than a row of controls.
 *
 * The action has to keep looking like the other menu items (full width, same
 * padding, same hover) while the explanation sits at its end. `flex-wrap` is off
 * here on purpose: a wrapped "i" under the label reads as a second item.
 */
.menu-row-action {
  gap: 4px;
  padding: 0;
  flex-wrap: nowrap;
}

.menu-row-action > [role='menuitem'] {
  flex: 1 1 auto;
  text-align: left;
}

.menu-row-action > .info {
  flex: 0 0 auto;
  margin-right: 0.5rem;
}

.menu-label {
  font-size: 0.6875rem;
  color: var(--text-muted);
}

/* --- top bar ------------------------------------------------------------- */

.topbar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: var(--gap);
}

.topbar-right {
  display: flex;
  align-items: center;
  gap: 6px;
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  width: 2rem;
  height: 2rem;
  color: var(--text-secondary);
}

.icon-btn[aria-pressed='true'] {
  background: var(--surface-3);
  color: var(--text-primary);
}

/* The slash is only there when amounts are hidden — the icon states the state
   rather than needing a label beside it. */
.icon-btn .eye-slash {
  opacity: 0;
}

.icon-btn[aria-pressed='true'] .eye-slash {
  opacity: 1;
}

/* The link back to the project's own home, in prulwerk.nl's own tokens rather
   than the app's --accent — it is a link to a different site, not a control of
   this one. No prulwerk mark here on purpose: a project page gets its own glyph,
   never the prulwerk bars (see the prulwerk-huisstijl skill). */
.prulwerk-link {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  height: 2rem;
  padding: 0 0.75rem;
  border-radius: var(--radius-pill);
  border: 1px solid var(--pw-line);
  background: var(--pw-card);
  color: var(--text-primary);
  font-weight: 900;
  font-size: 0.8125rem;
  letter-spacing: -0.01em;
  text-decoration: none;
  white-space: nowrap;
  transition: border-color var(--t-press) var(--ease-out);
}

.prulwerk-link:hover,
.prulwerk-link:focus-visible {
  border-color: var(--pw-focus);
  outline: none;
}

.prulwerk-link .pw-tld {
  color: var(--pw-accent);
}

/* --- granularity: a label that opens ------------------------------------- */

.gran {
  position: relative;
}

.gran > button {
  border-color: transparent;
  background: none;
  color: var(--text-secondary);
  font-weight: 500;
  letter-spacing: 0;
  text-transform: none;
}

.gran > button:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}

.gran > button .caret {
  opacity: 0.6;
  margin-left: 2px;
}

.gran .menu {
  bottom: auto;
  top: calc(100% + 6px);
  min-width: 13rem;
  /* It drops from under its label rather than rising from the rail's foot. */
  transform-origin: top left;
}

/* A granularity the window cannot carry says why instead of looking broken. */
.gran .menu button[disabled] {
  color: var(--text-muted);
  cursor: not-allowed;
}

.gran .menu .why {
  display: block;
  font-size: 0.6875rem;
  color: var(--text-muted);
  font-weight: 400;
}

/* --- the connection check, as a modal ------------------------------------ */

.modal {
  max-width: min(46rem, calc(100vw - 2rem));
  width: 100%;
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-1);
  color: var(--text-primary);
  box-shadow: var(--shadow-float);
  padding: var(--pad);
  font: inherit;
}

/*
 * US-95, variant A: the ✕ every modal carries, top-right, where a reader looks
 * first — the same idiom as the strip's and the toast's (US-91/92). Visually
 * 28px (the ::before circle), a 44px hit target (the button itself). Press
 * feedback comes from the global button:active rule; hover gets the same
 * quiet circle the strip's ✕ draws, on this stylesheet's own tokens so both
 * themes carry it. Last in each dialog's DOM, so Tab walks the content first.
 */
.sheet-close {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 44px;
  height: 44px;
  padding: 0;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  border-radius: 50%;
  color: var(--text-secondary);
  font-size: 15px;
  line-height: 1;
}
.sheet-close::before {
  content: '✕';
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  transition: background var(--t-press) var(--ease-out), color var(--t-press) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .sheet-close:hover::before {
    background: var(--surface-3);
    color: var(--text-primary);
  }
}
.sheet-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.modal::backdrop {
  background: rgba(10, 14, 18, 0.45);
  /*
   * US-70 trap 3. The grey layer is the largest thing on screen and it was the
   * part that snapped: the sheet materialized over a backdrop that had already
   * arrived. `materialize()` animates the dialog; the backdrop is not reachable
   * from script, so it fades from CSS on the same curve.
   */
  transition:
    opacity var(--t-surface) var(--ease-out),
    display var(--t-surface) allow-discrete,
    overlay var(--t-surface) allow-discrete;
  opacity: 1;
}

@starting-style {
  .modal::backdrop {
    opacity: 0;
  }
}

.modal:not([open])::backdrop {
  opacity: 0;
  transition:
    opacity var(--t-surface-out) var(--ease-out),
    display var(--t-surface-out) allow-discrete,
    overlay var(--t-surface-out) allow-discrete;
}

/* --- the share sheet ------------------------------------------------------ */

/*
 * Wider than the diagnostics dialog because it holds a picture, and the picture
 * is the point: the controls are worth nothing without seeing what they did.
 */
.share-sheet {
  max-width: min(56rem, calc(100vw - 2rem));
}

.share-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 15rem;
  gap: var(--pad);
  align-items: start;
  margin-top: 0.75rem;
}

/*
 * The preview holds the real canvas, at its real pixel size, scaled down by
 * `max-width`/`max-height`. So a 9:16 story and a 16:9 card both fit the same
 * box and neither is redrawn at a preview-only size — what is on screen is the
 * bytes that reach the clipboard.
 *
 * The checkerboard-free flat backdrop is deliberate: the card draws its own
 * opaque surface, and a transparency pattern behind an opaque image only
 * suggests the PNG has an alpha channel to worry about.
 */
.share-preview {
  display: grid;
  place-items: center;
  min-height: 18rem;
  max-height: min(60vh, 30rem);
  padding: 0.75rem;
  border: var(--rule) solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  overflow: hidden;
}

.share-preview canvas {
  max-width: 100%;
  max-height: min(56vh, 28rem);
  height: auto;
  width: auto;
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
}

.share-controls {
  display: grid;
  gap: 0.875rem;
  /*
   * The column is 15rem wide (`.share-body`); its contents were 394 px.
   *
   * A grid item's `min-width` is `auto`, which means *min-content*, and the
   * widest thing in here is a `<select>` whose min-content is its longest option
   * — "Portfolio value (all time)" and the like. So the implicit column grew to
   * fit that option, every row stretched to match, and the whole control column
   * overflowed the dialog by 154 px. `width: 100%` on the select could not save
   * it: 100 % of a column that has already been widened is the widened width.
   *
   * Found with US-78: the shape strip is a window three items wide, and the
   * measurements said three shapes fitted while the screen showed two — because
   * the third was outside the dialog rather than outside the strip. Zeroing the
   * minimums here is what makes the sheet's right-hand column obey its own
   * width, and it is why the chevrons had nowhere to appear.
   */
  min-width: 0;
}

.share-controls > *,
.share-controls .field > * {
  min-width: 0;
}

.field {
  display: grid;
  gap: 0.375rem;
}

.field-label {
  font-size: var(--hint);
  color: var(--text-muted);
}

/*
 * US-57, corrected by US-78 — the format strip.
 *
 * A window with a track sliding behind it, a chevron either side of the window.
 * `overflow: hidden` on the window is what makes it a strip rather than a row
 * that has been pushed off its container — and it stays `hidden` rather than
 * `auto`: the spring writes `transform` per frame, and a scroll position plus a
 * transform is two mechanisms fighting over one x.
 *
 * **Three shapes fit the window at any width, by construction.** The item is a
 * third of the window minus the two gaps, so the strip cannot go back to showing
 * two of four shapes because a column changed width — which is exactly how it
 * shipped in 0.47.0, with a 6.5rem item that stopped being a third of anything.
 */
.fmt-strip {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 3px;
}

.fmt-strip .fmt-window {
  /* A `0` basis rather than `auto`: with `auto` the window's width comes from its
     own content, and a percentage width on the items then has nothing definite to
     resolve against — the browser falls back to their content width and three
     stop filling the window. */
  flex: 1 1 0;
  /* Without this a flex item refuses to be narrower than its content, and the
     track's five items would push the whole control past the column. */
  min-width: 0;
  overflow: hidden;
  /* The horizontal drag is ours; vertical scrolling still belongs to the page,
     which is what `pan-y` says and what a bare `none` would take away. */
  touch-action: pan-y;
  cursor: grab;
}

.fmt-strip:active .fmt-window {
  cursor: grabbing;
}

/*
 * Everything below is scoped under `.fmt-strip` rather than written on the bare
 * class, and that is load-bearing rather than tidiness: the host is also a
 * `.group`, and `@media (max-width: 39em)` gives every `.group button`
 * `flex: 1 1 auto` so eight range buttons can share a phone's width. Applied to
 * this strip it made the chevrons 110 px wide and squeezed each shape into 23 px
 * with a 30 px drawing inside it. `.fmt-strip .fmt` outranks `.group button`
 * whatever the source order, which is what a control with its own geometry inside
 * a shared one needs.
 */

/*
 * The chevrons. Small, quiet, and outside the window so neither of them lands
 * between two shapes in the tab order.
 *
 * They keep their space when they have nothing to do — `visibility`, not
 * `display` — because the item width is a share of the window, so a chevron that
 * came and went would resize every shape in the strip as you paged it.
 */
.fmt-strip .fmt-page {
  flex: 0 0 auto;
  padding: 2px 4px;
  border-color: transparent;
  background: transparent;
  color: var(--text-muted);
  line-height: 1;
  font-size: var(--base);
}

.fmt-strip .fmt-page:disabled {
  visibility: hidden;
}

.fmt-strip .fmt-page:hover:not(:disabled) {
  color: var(--text-primary);
  background: var(--surface-3);
  border-color: transparent;
}

.fmt-strip .fmt-track {
  display: flex;
  gap: 8px;
  /* The transform is written per frame by the spring; nothing here transitions
     it, or the two would fight and the strip would lag the finger. */
  will-change: transform;
}

.fmt-strip .fmt {
  flex: 0 0 auto;
  /* Two gaps of 8px, three items, one window. */
  width: calc((100% - 16px) / 3);
  display: flex;
  /* Stacked rather than side by side: three of these have to fit where two used
     to, and the label under the shape is the space that was available. */
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 5px 2px;
  /* The hint size, which is the scale's label size — the id under the shape is a
     label for a picture, not a piece of body text. */
  font-size: var(--hint);
  border-color: transparent;
  background: transparent;
}

.fmt-strip .fmt-shape {
  display: block;
  border: 1.5px solid var(--text-muted);
  border-radius: 3px;
  flex: none;
}

.fmt-strip .fmt[aria-pressed='true'] {
  background: var(--surface-1);
  border-color: var(--border);
}

.fmt-strip .fmt[aria-pressed='true'] .fmt-shape {
  border-color: var(--text-primary);
}

/*
 * The sheet arrives as a pane of glass rather than fading in: blur and scale
 * move together, which is what makes it read as an object approaching instead of
 * a picture becoming opaque. Written by `materialize()` through the Web
 * Animations API so the open and the close are one reversible path, and so
 * grabbing it mid-flight has something to interrupt.
 */
@media (prefers-reduced-transparency: reduce) {
  /* Glass with nothing behind it is just a slow fade, so the blur goes and the
     scale stays — the arrival is still an arrival. */
  .share-sheet {
    --sheet-blur: 0px;
  }
}

.share-controls select,
.share-controls input[type='text'] {
  width: 100%;
  padding: 0.375rem 0.5rem;
  font: inherit;
  font-size: var(--base);
  color: var(--text-primary);
  background: var(--surface-2);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius-sm);
}

/* One column on a narrow screen: the picture stays first, because it is what
   the sheet is for. */
@media (max-width: 48em) {
  .share-body {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 60em) {
  .app {
    grid-template-columns: 1fr;
  }

  .rail {
    position: static;
    height: auto;
    border-right: 0;
    border-bottom: var(--rule) solid var(--border);
  }

  .rail-nav {
    flex-direction: row;
    flex-wrap: wrap;
  }

  /*
   * The accent moves to the bottom edge once the list is horizontal.
   *
   * An `inset 2px 0 0` bar reads as "this row is selected" down a column and as
   * a stray tick beside a word in a row — it is the vertical idiom, borrowed. An
   * underline is the horizontal one, and it is the same two pixels of the same
   * token, so nothing about the palette or the weight changes with the viewport.
   */
  .rail-nav button[aria-current='page'] {
    box-shadow: inset 0 -2px 0 var(--accent);
  }

  .rail-foot {
    /* No auto margin here: stacked, there is no column height to push against,
       and it left ~90px of empty rail between the nav and the sync row. */
    margin-top: 0;
    flex-direction: row;
    align-items: center;
    flex-wrap: wrap;
  }

  .rail-state {
    flex: 1;
  }

  button.primary,
  .menu-wrap > button {
    width: auto;
  }

  /* The trigger sits at the right end of a wrapped row here, not at the foot
     of a column, so a menu that still grows from its own left edge runs off
     the right of the viewport. Growing from the right edge of the trigger
     keeps it the same distance from where it was, whichever way it opens. */
  .menu {
    left: auto;
    right: 0;
    transform-origin: bottom right;
  }
}


/* --- header ------------------------------------------------------------- */

header.top {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--gap);
  padding: 18px var(--pad);
  background: var(--surface-1);
  border: var(--rule) solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

h1 {
  font-size: 1.25rem;
  font-weight: 700;
  margin: 0;
  letter-spacing: var(--track-title);
  display: flex;
  align-items: center;
  gap: 10px;
}

/* The one piece of accent colour on the page, and it is decoration rather than
   information — which is why it is the only place the accent is allowed. */
h1::before {
  content: '';
  width: 5px;
  height: 20px;
  border-radius: 2px;
  background: var(--accent);
}

.sub {
  color: var(--text-secondary);
  font-size: var(--hint);
  margin-top: 6px;
}

.actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

/**
 * Sentence case, at 11.5px.
 *
 * Uppercase survives in this file only on eyebrow labels at 11px and under —
 * `.tile .label`, `.chip`, `th` — where it reads as a label rather than as
 * emphasis. On a button it costs word-shape legibility for nothing: when every
 * control shouts, none of them is emphasised, and it is the most recognisable
 * machine-written tic in the stylesheet.
 */
button {
  font: inherit;
  font-size: 0.7188rem;
  font-weight: 600;
  letter-spacing: 0;
  color: var(--text-primary);
  background: var(--surface-1);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-pill);
  padding: 9px 16px;
  cursor: pointer;
  transition: background var(--t-press) var(--ease-out), border-color var(--t-press) var(--ease-out);
}

button:hover {
  background: var(--surface-2);
  border-color: var(--text-muted);
}

/* The header's four actions are not equals: one is what you came to do and one
   throws your data away. They looked identical, which is how a destructive
   button gets pressed by accident. */
.actions #btn-sync {
  background: var(--text-primary);
  color: var(--surface-1);
  border-color: var(--text-primary);
}

.actions #btn-sync:hover {
  background: var(--text-secondary);
  border-color: var(--text-secondary);
}

.actions #btn-wipe {
  color: var(--text-muted);
  border-style: dashed;
}

.actions #btn-wipe:hover {
  color: var(--critical);
  border-color: var(--critical);
}

button[aria-pressed='true'] {
  background: var(--text-primary);
  color: var(--surface-1);
  border-color: var(--text-primary);
}

button:disabled {
  opacity: 0.5;
  cursor: default;
}

/* --- stat tiles --------------------------------------------------------- */

.tiles {
  display: grid;
  /* 13rem, not the 11.1rem this used to be: at the old width a seven-figure
     total did not fit and was clipped by the container's `overflow: hidden`.
     The `--len` rule on `.tile .value` is what actually guarantees it fits;
     this only stops the guarantee from being met with unreadably small type. */
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  /* The rules between tiles are not borders. The cells are separated by a
     one-pixel gap and each paints its own hairline into it, so every rule is
     exactly one pixel and two neighbours cannot double up.

     The container used to be painted the *border* colour with the tiles laid on
     top, which was simpler and had one bad case: a row that does not fill left
     the remainder of the grid showing as a solid block of border colour. Seven
     tiles in a six-column grid drew five empty cells of beige. The surface
     colour underneath fixes that — an unfilled cell is now indistinguishable
     from the card it sits in. */
  gap: var(--rule);
  margin-bottom: var(--gap);
  border: var(--rule) solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface-1);
}

/* Nothing to show on a section with no figures of its own; an empty bordered
   strip under the tabs reads as something that failed to load. */
.tiles:empty {
  display: none;
}

.tile {
  background: var(--surface-1);
  padding: 16px 18px;
  /* So the value below can size itself against this cell rather than against
     the viewport. Also `min-width: 0`, because a grid item's default `auto`
     minimum is its content and one long number would otherwise widen the whole
     column and push the grid sideways. */
  container-type: inline-size;
  min-width: 0;
  /* The hairline, painted outward into the gap. A shadow rather than a border
     so it costs no layout space and so two neighbours land on the same pixel
     instead of stacking two. */
  box-shadow: 0 0 0 var(--rule) var(--border);
}

.tile .label {
  color: var(--text-secondary);
  font-size: 0.6562rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--track);
  display: flex;
  align-items: center;
  gap: 7px;
}

/* A square the colour of the series this number describes, so the tiles and the
   charts are visibly the same subject. */
.tile .label::before {
  content: '';
  width: 7px;
  height: 7px;
  border-radius: 2px;
  background: var(--tile-dot, var(--text-muted));
  flex: none;
}

.tile .value {
  /* Long amounts shrink to fit instead of being cut off.
     `€ 111.784,99` at the full KPI size needs about 230 px and the cell was
     193 px, so the demo shipped `€ 111.784,9` with the last digit sliced away
     by the container's `overflow: hidden` — a wrong number, silently, which is
     the one thing this project must not do. Wrapping is not an option either:
     an amount must never break between its sign and its digits.

     `--len` is the character count, set inline where the tile is built. With
     `tabular-nums` every glyph in an amount is the same width, so the widest
     the string can be is a constant times that count — no measuring, no
     reflow loop, and a million-euro account fits for the same reason. */
  --fits: calc((100cqw - 0.5rem) / (var(--len, 8) * 0.58));
  font-size: min(var(--kpi), var(--fits));
  font-variant-numeric: tabular-nums;
  font-weight: var(--kpi-w);
  margin-top: 8px;
  /* Whatever bucket the context put the size in — never a fixed value here, or
     the six contexts that re-set `--kpi` all inherit one. */
  letter-spacing: var(--kpi-track);
  line-height: var(--kpi-lead);
  white-space: nowrap;
}

/* --- US-65: the honest number change ------------------------------------- */

/*
 * A figure that changed swaps: the old string leaves, the new one arrives, and
 * there is never anything in between. The rejected alternative is a count-up,
 * where every frame shows a value the account never had.
 *
 * Both spans hold complete formatted strings. Nothing here interpolates, and
 * nothing can — CSS has no access to the digits, which is a large part of why
 * the transition lives in the stylesheet rather than in a rAF loop.
 */
.tile .value.swap {
  position: relative;
  display: grid;
}

.tile .value.swap > .swap-in,
.tile .value.swap > .swap-out {
  grid-area: 1 / 1;
  white-space: nowrap;
}

/* The departing figure is out of the accessibility tree and out of the way of
   the pointer: it is a ghost of something that is no longer true. */
.tile .value.swap > .swap-out {
  pointer-events: none;
  animation: figure-out 0.26s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}

.tile .value.swap > .swap-in {
  animation: figure-in 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Transform and opacity only — nothing that reflows. */
@keyframes figure-out {
  to { opacity: 0; transform: translateY(-0.32em); }
}

@keyframes figure-in {
  from { opacity: 0; transform: translateY(0.32em); }
}

/*
 * Reduced motion is an instant swap. The old figure is not faded out, it is
 * simply not there — a cross-fade is still motion, and there is nothing here it
 * would help anyone understand.
 */
@media (prefers-reduced-motion: reduce) {
  .tile .value.swap > .swap-out { display: none; }
  .tile .value.swap > .swap-in { animation: none; }
}

.tile .note {
  color: var(--text-muted);
  font-size: var(--hint);
  margin-top: 6px;
}

.up {
  color: var(--good);
}

.down {
  color: var(--critical);
}

/* --- controls ----------------------------------------------------------- */

.controls {
  display: flex;
  gap: 18px;
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: var(--gap);
  padding: 12px 16px;
  background: var(--surface-1);
  border: var(--rule) solid var(--border);
  border-radius: var(--radius);
}

.group {
  display: flex;
  gap: 2px;
  align-items: center;
  background: var(--surface-2);
  border-radius: var(--radius-pill);
  padding: 3px;
}

/* Inside a segmented control the buttons lose their own chrome: the track is
   the frame, and only the chosen one is raised out of it. */
.group button {
  border-color: transparent;
  background: transparent;
  padding: 6px 13px;
}

.group button:hover {
  background: var(--surface-3);
  border-color: transparent;
}

/* The selected segment is told apart by its surface and its ink, not by being
   lifted: it sits inside a control that sits inside a panel, and a shadow at
   that depth is the third rounded container brief §1.5 counts. */
.group button[aria-pressed='true'] {
  background: var(--surface-1);
  color: var(--text-primary);
  border-color: var(--border);
}

.group > .glabel {
  color: var(--text-secondary);
  font-size: 0.75rem;
  margin-right: 4px;
}

label.check {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.8125rem;
  color: var(--text-secondary);
  cursor: pointer;
}

/* --- chart cards -------------------------------------------------------- */

.grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gap);
}

@media (min-width: 56em) {
  .grid.two {
    grid-template-columns: 1fr 1fr;
  }
}

.card {
  background: var(--surface-1);
  border: var(--rule) solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: var(--pad) var(--pad) 14px;
}

.card h2 {
  font-size: var(--title);
  font-size: 0.875rem;
  font-weight: 600;
  margin: 0 0 2px;
}

.card p.hint {
  font-size: var(--hint);
  margin: 0 0 12px;
  color: var(--text-secondary);
  font-size: 0.75rem;
}

.chart-box {
  position: relative;
  height: var(--chart-h);
}

.chart-box.tall {
  height: var(--hero-h);
}

/* --- banners ------------------------------------------------------------ */

/*
 * US-73. The row a notice lives in, so adding or removing one does not shove
 * everything below it.
 *
 * `grid-template-rows: 0fr → 1fr` is the one way to transition to a height
 * nobody has measured: `height: auto` cannot be transitioned at all, and a
 * `max-height` guess is wrong the moment a message wraps to two lines.
 *
 * The margin lives on the banner *inside* the clip, not on the slot, or a
 * collapsed row would still occupy sixteen pixels of nothing.
 */
.banner-slot {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows var(--t-notice) var(--ease-out),
    opacity var(--t-notice) var(--ease-out);
}

.banner-slot.open {
  grid-template-rows: 1fr;
  opacity: 1;
}

.banner-clip {
  overflow: hidden;
  min-height: 0;
}

.banner {
  border-radius: var(--radius);
  padding: 12px 14px;
  margin-bottom: 16px;
  border: 1px solid var(--border);
  background: var(--surface-1);
  display: flex;
  gap: 10px;
  align-items: flex-start;
  font-size: 0.8125rem;
}

.banner .icon {
  flex: none;
  font-weight: 700;
  line-height: 1.4;
}

.banner.error {
  border-left: 4px solid var(--critical);
}

.banner.warn {
  border-left: 4px solid var(--warning);
}

.banner.info {
  border-left: 4px solid var(--series-1);
}

.banner.ok {
  border-left: 4px solid var(--good);
}

.banner code {
  font-size: 0.75rem;
  color: var(--text-secondary);
}

/* --- the "i" on a tile (and, since US-79, in the menu) -------------------- */

.tile .info,
.menu .info {
  /* Reset the pill button: this is a marker, not an action. */
  all: unset;
  cursor: help;
  flex: none;
  width: 0.95rem;
  height: 0.95rem;
  margin-left: auto;
  border: var(--rule) solid var(--border-strong);
  border-radius: 50%;
  color: var(--text-muted);
  font-size: 0.625rem;
  font-weight: 700;
  font-style: italic;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.tile .info:hover,
.tile .info:focus-visible,
.menu .info:hover,
.menu .info:focus-visible {
  color: var(--text-primary);
  border-color: var(--text-muted);
}

.tile .info:focus-visible,
.menu .info:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
}

.tip {
  position: fixed;
  z-index: 50;
  max-width: min(22rem, calc(100vw - 1rem));
  padding: 10px 12px;
  background: var(--surface-1);
  color: var(--text-secondary);
  border: var(--rule) solid var(--border-strong);
  border-radius: var(--radius);
  box-shadow: var(--shadow-float);
  font-size: var(--hint);
  line-height: 1.5;
  /* It follows the pointer's target, so it must never become the target. */
  pointer-events: none;
}

/* --- notices panel ------------------------------------------------------ */

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 4px;
}

/* The severity, as a word rather than only as a colour. Eight percent of men
   cannot separate the red one from the green one, and a notice that says
   "something is wrong" only in hue says it to nobody. */
.chip {
  display: inline-block;
  flex: none;
  padding: 3px 9px;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: var(--track);
  text-transform: uppercase;
  color: var(--text-secondary);
  white-space: nowrap;
}

.chip.error {
  border-color: var(--critical);
  color: var(--critical);
}

.chip.warn {
  border-color: var(--warning);
  color: var(--warning);
}

.chip.info {
  border-color: var(--series-1);
  color: var(--series-1);
}

.chip.ok {
  border-color: var(--good);
  color: var(--good);
}

.note-row {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  padding: 14px 0;
  border-top: var(--rule) solid var(--border);
}

.note-row:first-of-type {
  border-top: 0;
}

.note-row > div {
  min-width: 0;
}

.note-title {
  font-weight: 600;
  font-size: 0.8125rem;
}

.note-body {
  color: var(--text-secondary);
  font-size: var(--hint);
  margin-top: 4px;
}

/* A count that is a problem should not be the same grey as a count that is a
   number of charts. */
.tabs .count.bad {
  color: var(--critical);
  font-weight: 700;
}

.tabs .count.warn {
  color: var(--warning);
  font-weight: 700;
}

/* --- table -------------------------------------------------------------- */

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

th,
td {
  text-align: right;
  padding: 11px 12px;
  border-bottom: 1px solid var(--grid);
  white-space: nowrap;
}

th {
  color: var(--text-muted);
  font-weight: 600;
  font-size: 0.6562rem;
  text-transform: uppercase;
  letter-spacing: var(--track);
  border-bottom-color: var(--border);
}

th:first-child,
td:first-child {
  text-align: left;
}

.swatch {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  margin-right: 8px;
  vertical-align: baseline;
}

.table-scroll {
  overflow-x: auto;
}

/* --- month × year matrix ------------------------------------------------- */

.card-head {
  display: flex;
  gap: 16px;
  align-items: flex-start;
  justify-content: space-between;
  flex-wrap: wrap;
}

table.matrix {
  min-width: 760px;
}

table.matrix th,
table.matrix td {
  text-align: center;
  padding: 6px 4px;
  border-bottom: none;
}

table.matrix thead th {
  border-bottom: 1px solid var(--grid);
}

table.matrix th.year,
table.matrix td.year {
  text-align: left;
  font-weight: 600;
  padding-right: 12px;
}

table.matrix td.cell {
  /* 2px of surface between cells instead of a border, so the diverging tint
     reads as separate blocks without a grid of lines. */
  border: 2px solid var(--surface-1);
  border-radius: 6px;
  background-clip: padding-box;
  font-variant-numeric: tabular-nums;
  min-width: 62px;
}

table.matrix td.empty {
  color: var(--text-muted);
  opacity: 0.45;
}

table.matrix td.total {
  border-left: 2px solid var(--grid);
  font-weight: 600;
}

table.matrix tr.head-total th {
  color: var(--text-secondary);
}

/* The extreme month gets a ring rather than a louder colour. */
table.matrix td.extreme {
  outline: 2px solid var(--text-secondary);
  outline-offset: -2px;
}

/* Every cell is clickable, so the button has to disappear into the cell —
   a grid of 80 buttons that look like buttons is unreadable as a heatmap. */
button.cell-pick {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  width: 100%;
  text-align: right;
  cursor: pointer;
}

button.cell-pick:focus-visible {
  outline: 2px solid var(--text-primary);
  outline-offset: 2px;
}

/* Picked reads as a ring so the diverging tint underneath still means what it
   means. The colour is set inline, matching this month's bar in the chart. */
table.matrix td.picked {
  outline: 3px solid var(--text-primary);
  outline-offset: -3px;
}

button.month-pick {
  font: inherit;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--text-secondary);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 6px;
  padding: 3px 8px;
  cursor: pointer;
}

button.month-pick:hover {
  border-color: var(--axis);
  color: var(--text-primary);
}

button.month-pick[aria-pressed='true'] {
  background: var(--text-primary);
  color: var(--surface-1);
  border-color: var(--text-primary);
}

.scale {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
  font-size: 0.75rem;
  color: var(--text-secondary);
  flex-wrap: wrap;
}

.scale .ramp {
  display: flex;
  height: 10px;
  border-radius: 3px;
  overflow: hidden;
}

.scale .ramp span {
  width: 22px;
}

.muted {
  color: var(--text-muted);
}

/*
 * US-71's table twin, and the control that reaches it.
 *
 * A quiet link rather than a segmented control: it is a second way to read the
 * same figures, not a choice the reader has to make before the chart means
 * anything. It sits under the chart, where the thing it switches is.
 */
.twin-toggle {
  margin-top: 8px;
  padding: 4px 0;
  border: 0;
  background: none;
  color: var(--text-secondary);
  font-size: var(--hint);
  text-decoration: underline;
  text-underline-offset: 3px;
}

.twin-toggle:hover {
  background: none;
  color: var(--text-primary);
}

.chart-twin table {
  width: 100%;
}

/* --- US-75: data arrives per card ---------------------------------------- */

/*
 * A card rises six pixels and fades, holding its final content the whole way —
 * nothing here interpolates a value, and nothing waits for it: the class is a
 * layer over a live, interactive screen.
 */
.card.arriving {
  animation: card-arrive 260ms var(--ease-out) both;
  animation-delay: calc(var(--arrive-i, 0) * 60ms);
}

@keyframes card-arrive {
  from { opacity: 0; transform: translateY(6px); }
}

/*
 * The chart's reveal is a **mask over a drawing Chart.js has already finished**.
 * That is the decision that makes this story affordable: `animation: false`
 * stays off in `charts.js`, so nothing re-computes, and a two-thousand-point
 * series costs exactly what it costs today.
 */
.card.arriving canvas {
  animation: chart-wipe 360ms var(--ease-out) both;
  animation-delay: calc(var(--arrive-i, 0) * 60ms + 60ms);
}

/* The hero chart is bigger, so its wipe is slower — the same speed across a
   wider surface reads as faster, not as equal. */
.card.arriving .chart-box.tall canvas,
.card.arriving #c-value {
  animation-duration: 520ms;
}

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

/* Rows stagger, capped at ten: ninety positions at 28 ms is two and a half
   seconds of waiting for your own data. */
.card.arriving tbody tr {
  animation: card-arrive 220ms var(--ease-out) both;
  animation-delay: calc(var(--arrive-i, 0) * 28ms + 80ms);
}

/* --- US-74: the theme change is a light switch --------------------------- */

/*
 * Only while `data-theme-fade` is on the root, which `setTheme` puts there and
 * takes off again. A permanent transition would fade the *first* paint in from
 * the stylesheet's default, so every load would open by dissolving out of the
 * wrong theme.
 *
 * The properties are named rather than `all`: `all` would drag every unrelated
 * property into a 220 ms ease, including ones a gesture is driving.
 *
 * It is not inside a reduced-motion guard, deliberately. There is no travel in
 * it — it is colour — and an abrupt brightness jump is exactly what §14 asks to
 * be softened rather than removed.
 */
:root[data-theme-fade] body,
:root[data-theme-fade] .card,
:root[data-theme-fade] .rail,
:root[data-theme-fade] .tile,
:root[data-theme-fade] .modal,
:root[data-theme-fade] table,
:root[data-theme-fade] th,
:root[data-theme-fade] td,
:root[data-theme-fade] button,
:root[data-theme-fade] select,
:root[data-theme-fade] input {
  transition:
    background-color var(--t-theme) var(--ease-out),
    color var(--t-theme) var(--ease-out),
    border-color var(--t-theme) var(--ease-out);
}

/*
 * The canvases are the seam. They do not cross-fade — Chart.js repaints them
 * from the new tokens in one go — so a page fading around a chart that snapped
 * would draw attention to exactly the thing this is smoothing. They fade *in*
 * on the new theme instead, over the same 220 ms, which is a cross-fade for a
 * surface that cannot have one. The stop condition said ship without the fade
 * rather than accept the seam; this closes it instead.
 */
:root[data-theme-fade] canvas {
  animation: theme-repaint var(--t-theme) var(--ease-out);
}

@keyframes theme-repaint {
  from { opacity: 0; }
}

/* --- popup -------------------------------------------------------------- */

/*
 * US-60. The popup shares this stylesheet, which is why it came through the
 * 0.46.0 redesign looking plausible and carrying none of it: the tokens changed
 * underneath it and the structure did not. It was four equal tiles in a 2×2 and
 * two equal buttons, on a page that had moved to one hero figure with three
 * supporting ones and a single primary action.
 *
 * What is ported is the app's *reasoning* at 320 px, not its layout. The rules
 * below only re-proportion `.hero-row`, `.tile` and `.facts` — the same classes
 * the app builds — so a change to how a tile works reaches both.
 */
body.popup {
  width: 320px;
  padding: 14px;
  background: var(--surface-1);
}

/* The mark, and the build beside it. The popup is where a tester looks first. */
.popup-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 14px;
  color: var(--text-primary);
}

/* The lockup is an inline `<svg>`, so its box was a *line* box: 31,3px tall for
   a 24px mark, the extra 7px hanging underneath as a descender that is not
   there. `align-items: center` then centred the row on that phantom, and the
   build number landed 3,6px below the middle of the wordmark it sits beside —
   the header read as two things that had missed each other. A flex box wraps
   the mark exactly, so what is centred is what is drawn. */
.popup-head #lockup {
  display: flex;
}

.popup-head .version {
  margin-top: 0;
}

/* At 320px the hero stacks above its three facts rather than sitting beside
   them. Same hierarchy, and the only arrangement that fits. */
body.popup .hero-row {
  display: block;
  padding: 0;
}

body.popup .tile.is-hero {
  width: 100%;
  /* 28px: big for a 320px panel, but not the 38–56px the page's hero reaches,
     so it takes the title bucket rather than the display one. */
  --kpi: 1.75rem;
  --kpi-track: var(--track-title);
  margin-bottom: 14px;
}

body.popup .facts {
  gap: 10px;
}

/* Thirds that may shrink. `--fits` still guards the digits, so a seven-figure
   total narrows its type rather than losing its last digit. */
body.popup .facts > .tile {
  flex: 1 1 0;
  width: auto;
  min-width: 0;
  --kpi: 0.9375rem;
  --kpi-track: var(--track-body);
  --kpi-lead: var(--lead-label);
}

/* The swatch ties a tile to its series in the app. There is one series here and
   it is the sparkline, so four grey squares would tie nothing to anything. */
body.popup .tile .label::before {
  display: none;
}

body.popup .spark {
  height: 64px;
  margin: 14px 0 12px;
}

/* Before the first sync there are no figures and no shape. Reserving the room
   for them leaves a blank block above the status line that reads as something
   that failed to load — the same reasoning `.tiles:empty` already carries in the
   app, applied to the one panel that is usually opened before there is data. */
body.popup #tiles:empty,
body.popup #tiles:empty + .spark {
  display: none;
}

/* Sync is what you opened this for; the full page is where you go afterwards.
   Stacked rather than side by side: at 292 px of usable width the two labels do
   not fit on one line in either language, and a flex row would wrap into this
   anyway — with the ordering left to chance. The primary treatment comes from
   `.actions #btn-sync`, which the app already defines. */
body.popup .actions {
  flex-direction: column;
  gap: 8px;
  /* And separated from the status line above it, which it was not: `.actions`
     carries no margin of its own because in the app it sits in a header row
     with a card's worth of space under it, while here the sentence is directly
     above the buttons. Measured at 320px, `#status` ended at y=96,5 and "Sync
     nu" began at y=96,4 — the text touching the primary button is what made the
     whole panel look mis-set. 14px is the popup's own rhythm (the header's
     margin, the sparkline's). */
  margin-top: 14px;
}

/*
 * A comfortable target, in a panel that is often used on a touchscreen laptop.
 *
 * The shared button padding gives 38px, which is fine for a mouse and under the
 * 44px an unaided finger wants. Applied here rather than raising it everywhere:
 * the page's buttons sit in dense toolbars where 44px would push the controls
 * apart, and this panel has two buttons and room for them.
 */
body.popup .actions button {
  min-height: 2.75rem;
}

.footer-note {
  margin-top: 28px;
  color: var(--text-muted);
  font-size: 0.75rem;
}

/* The dragged-range bar. Sits above the chart it describes, and always carries
   the way out — a zoom you cannot leave is a trap. */
.zoom-state {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-left: 4px solid var(--series-1);
  border-radius: var(--radius);
  font-size: 0.8125rem;
}

#c-value {
  cursor: crosshair;
  /*
   * US-66. The value chart owns the horizontal gesture on it.
   *
   * Without this a drag on a touch screen scrolls the page underneath and the
   * selection is half of what was drawn — the browser takes the gesture away
   * partway through, which reads as the chart losing track of your finger.
   * `none` rather than `pan-y`: the brush is the whole point of touching this
   * canvas, and there is nothing on it to scroll.
   */
  touch-action: none;
}

/* A signed result in a table cell. The sign is already in the number, so the
   colour is emphasis and never the only channel that carries the meaning —
   same reason the charts keep a zero baseline. */
td.pos { color: var(--pos); }
td.neg { color: var(--neg); }

/* --- narrow windows ------------------------------------------------------
 *
 * The page had exactly one breakpoint — the two-column chart grid at 900px —
 * and below that everything simply kept its desktop measurements and overflowed.
 * Nobody had looked, because the extension opens in a full tab.
 *
 * Four steps down, each fixing what actually breaks at that width rather than
 * restyling the page again.
 */

/* Sizes are fluid, so what is left here is only what genuinely changes shape:
 * how things stack, wrap and scroll. Queries are in `em` so they move with the
 * reader's font size rather than ignoring it. */

@media (max-width: 51em) {
  header.top {
    align-items: flex-start;
    flex-direction: column;
  }

  /* Four pill buttons in a row do not fit; they wrap rather than shrink,
     because a truncated "Wipe & resync" is a button nobody should press
     without reading. */
  .actions {
    width: 100%;
  }

  .actions button {
    flex: 1 1 auto;
    justify-content: center;
  }

  .tiles {
    grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
  }
}

@media (max-width: 39em) {
  .tiles {
    grid-template-columns: 1fr 1fr;
  }

  .controls {
    gap: 10px;
  }

  /* Every group keeps its own line, so a range and a granularity never read as
     one row of eight equal buttons. */
  .controls > .group,
  .controls > label.check {
    width: 100%;
  }

  .group {
    justify-content: space-between;
  }

  .group button {
    flex: 1 1 auto;
    padding: 6px 8px;
  }
}

@media (max-width: 26em) {
  .tiles {
    grid-template-columns: 1fr;
  }
}

/* The holdings table is the accessible relief for the chart colours, so it may
   never be the thing that breaks. It scrolls sideways inside its own card
   rather than pushing the page wider. */
.table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

@media (max-width: 51em) {
  .table-scroll table {
    min-width: 560px;
  }
}

/* A grid or flex item defaults to `min-width: auto`, which means it refuses to
 * shrink below its own content. One card with a long title and a segmented
 * control was therefore 1 509px wide inside an 820px window, and the whole page
 * scrolled sideways — at every width, including 1 560, because the content was
 * simply wider than the column it sat in.
 *
 * This is the fix and it is load-bearing: without it the breakpoints above do
 * nothing, because nothing is allowed to get narrower in the first place.
 */
.grid > *,
.card,
.card-head > * {
  min-width: 0;
}

.card-head > div {
  flex: 1 1 220px;
}

/* A segmented control caught the rule above too, since it is also a bare
 * `div`: `.group`'s track stretched to fill the row instead of hugging its
 * buttons, leaving a dead-space tail on the pill (Table/Share, This
 * range/Everything, and both Performance view toggles, measured 118-422px
 * of empty track at 1440px). A control is not the growing element in a
 * card-head; the heading text beside it is. */
.card-head > .group {
  flex: 0 0 auto;
}

.card h2,
.card p.hint {
  overflow-wrap: anywhere;
}

/* --- sections ------------------------------------------------------------ */

.tabs {
  display: flex;
  gap: 2px;
  flex-wrap: wrap;
  margin-bottom: var(--gap);
  padding: 0 4px;
  border-bottom: var(--rule) solid var(--border);
}

.tabs .tab {
  border: 0;
  background: transparent;
  border-radius: 0;
  padding: 12px 14px;
  color: var(--text-secondary);
  /* The underline is drawn on the button itself so it sits exactly on the
     container's rule rather than a pixel above or below it. */
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}

.tabs .tab:hover {
  background: transparent;
  color: var(--text-primary);
}

.tabs .tab.is-on {
  color: var(--text-primary);
  border-bottom-color: var(--accent);
}

/* How many cards are behind this label. Quieter than the label, because it is
   a fact about the section and not its name. */
.tabs .count {
  margin-left: 7px;
  color: var(--text-muted);
  font-weight: 500;
}

.tabs .tab.is-on .count {
  color: var(--text-secondary);
}

@media (max-width: 39em) {
  .tabs .tab {
    padding: 10px;
    flex: 1 1 auto;
  }
}

/* The build, small and out of the way, in the popup. The full page carries it
   in the footer line beside the row counts. */
.version {
  margin-top: 10px;
  text-align: right;
  font-size: 0.6875rem;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

/* How much of a holding is your own money and how much it made. Two segments
   that always sum to the whole, so the eye reads a proportion rather than two
   lengths it has to compare. */
td.split {
  text-align: left;
  min-width: 9rem;
}

td.split .bar {
  display: flex;
  height: 6px;
  border-radius: 3px;
  overflow: hidden;
  background: var(--surface-3);
  margin-bottom: 4px;
}

td.split .bar i {
  background: var(--text-muted);
}

td.split .bar em.up {
  background: var(--pos);
}

td.split .bar em.down {
  background: var(--neg);
}

td.split .muted {
  font-size: 0.6875rem;
}

/* --- US-61: responsive Positions columns --------------------------------- */

/* The share action is a narrow, centred column. */
th.act,
td.act {
  text-align: center;
  width: 1px;
}

/* The instrument name is the width hog: long names, `nowrap`, so without a cap
   the table's natural width forces the responsive pass to drop columns even on a
   wide desktop. Capped and ellipsised, most columns fit before anything drops;
   the full name is still in the row's title and the swatch never truncates. */
#holdings td[data-col='instrument'] {
  max-width: 15rem;
  overflow: hidden;
  text-overflow: ellipsis;
}
#holdings td[data-col='instrument'] .swatch,
#holdings td[data-col='instrument'] .expander {
  flex: none;
}

/* Let the headers wrap and tighten the padding, so a column's width is set by
   its numbers (narrow) rather than its uppercase header phrase (wide). This is
   what lets a desktop keep most columns before the responsive pass drops any. */
#holdings thead th {
  white-space: normal;
  vertical-align: bottom;
  min-width: 3.2rem;
}
#holdings th,
#holdings td {
  padding: 9px 9px;
}

/* US-87. Every header except the action column is a real button: click sorts,
   drag reorders. The button is invisible chrome — it inherits the header's
   type entirely — and the arrow only exists on the sorted column, coloured so
   the eye finds the active sort without reading every header. */
#holdings thead th .col-head {
  display: inline-flex;
  align-items: flex-end;
  gap: 4px;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-transform: inherit;
  letter-spacing: inherit;
  text-align: inherit;
  cursor: pointer;
  touch-action: none; /* the header is a drag surface; the wrap scrolls, not this */
}
#holdings thead th.num .col-head {
  justify-content: flex-end;
}
#holdings thead th .arrow {
  font-size: 0.55rem;
  line-height: 1.4;
  visibility: hidden;
}
#holdings thead th[aria-sort] .arrow {
  visibility: visible;
  color: var(--accent);
}
@media (hover: hover) and (pointer: fine) {
  #holdings thead th .col-head:hover {
    color: var(--text-primary);
  }
}
/* The drag itself: the lifted column dims, the drop edge is a 2px accent line
   on the side of the header it will land against. Re-sorting and re-ordering
   are instant — no transition on any of this (US-49's floor). */
#holdings thead th.dragging {
  opacity: 0.35;
}
#holdings thead th.drop-before {
  box-shadow: inset 2px 0 0 var(--accent);
}
#holdings thead th.drop-after {
  box-shadow: inset -2px 0 0 var(--accent);
}
#holdings td.split {
  min-width: 7.5rem;
}

/* The disclosure that opens a row's dropped columns. It exists only when the
   table is actually hiding something — at full width there is nothing to open,
   so it is absent rather than inert. */
.expander {
  display: none;
  width: 1.1rem;
  height: 1.1rem;
  margin-right: 6px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  vertical-align: baseline;
}
#holdings[data-has-hidden] .pos-row .expander {
  display: inline-grid;
  place-items: center;
}
.expander::before {
  content: '\203A'; /* › */
  font-size: 0.95rem;
  line-height: 1;
  transition: transform var(--t-surface) var(--ease-out);
}
.expander[aria-expanded='true']::before {
  transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
  .expander::before {
    transition: none;
  }
}

/* The expand: a row's dropped columns as label/value chips beneath it. No data
   becomes unreachable at any width (US-61 AC2). */
tr.pos-detail > .detail-cell {
  text-align: left;
  padding: 2px 12px 12px 40px;
  white-space: normal;
  background: var(--surface-2);
}
.pos-detail .kv {
  display: inline-flex;
  gap: 6px;
  align-items: baseline;
  margin: 6px 16px 0 0;
  font-size: 0.75rem;
}
.pos-detail .kv b {
  color: var(--text-muted);
  font-weight: 600;
  text-transform: uppercase;
  font-size: 0.625rem;
  letter-spacing: var(--track);
}

/* The column chooser — the escape hatch, a popover off its button in the
   filter row. */
.cols-group {
  position: relative;
}
.cols-pop {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  /* Right-aligned under its button, so it opens from that corner. */
  transform-origin: top right;
  transition:
    opacity var(--t-surface) var(--ease-out),
    transform var(--t-surface) var(--ease-out),
    display var(--t-surface) allow-discrete;
  opacity: 1;
  transform: none;
  z-index: 20;
  display: grid;
  gap: 2px;
  min-width: 12rem;
  padding: 8px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-float);
}
.cols-pop label {
  display: flex;
  gap: 8px;
  align-items: center;
  padding: 5px 7px;
  border-radius: var(--radius-sm);
  font-size: 0.8125rem;
  color: var(--text-secondary);
  cursor: pointer;
}
.cols-pop label:hover {
  background: var(--surface-3);
}
.cols-pop input {
  accent-color: var(--accent);
}

/* --- product and transaction tables -------------------------------------- */

/* Amounts right-aligned so their digits line up; text left as it reads. */
th.num,
td.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Two tables that can run to hundreds of rows. A card that grows to the height
   of its longest table pushes everything below it off the page, so they scroll
   inside a fixed frame — the same reason the mockup drew a scrollbar. */
.tall-scroll {
  max-height: 30rem;
  overflow-y: auto;
}

/* The magnitude of a result, beside its percentage. Purely comparative: it has
   no axis and claims none, which is why it is a hairline under the number
   rather than a bar pretending to be a chart. */
.minibar {
  display: block;
  height: 3px;
  width: var(--w, 0);
  margin-top: 4px;
  margin-left: auto;
  border-radius: 2px;
  background: var(--c, var(--text-muted));
  opacity: 0.55;
}

/* A chip inside a table row sits on the text baseline rather than above it. */
td .chip {
  padding: 2px 8px;
  font-size: 0.625rem;
}

/* The language toggle. A flag alone is a poor label — several countries share
   one, and it says nothing to a screen reader — so the code sits beside it. */
.flag {
  padding: 7px 12px;
  letter-spacing: 0;
}

/* One number, large, because the card exists to state it. */
.bignum {
  font-size: clamp(1.75rem, 1.2rem + 2.4vw, 2.75rem);
  font-weight: var(--kpi-w);
  letter-spacing: -0.03em;
  line-height: 1.1;
  margin: 4px 0 6px;
  font-variant-numeric: tabular-nums;
}

/* ---------------------------------------------------------------------------
   Optimism Mode — "put that frown upside down"
   ---------------------------------------------------------------------------
   The stamp is load-bearing. Everything else here is comedy; the stamp is what
   makes a screenshot of this impossible to pass off as the real page, which is
   the only reason a feature that inverts losses is allowed to exist at all.
   See src/ui/frown.js.
   --------------------------------------------------------------------------- */

.frown-bar {
  display: flex;
  justify-content: flex-end;
  margin: 0 0 var(--gap);
}

/* A real button, not a faint checkbox. The first version was a small tick in
   the corner and the answer to "where is it" was "you cannot see it". */
.frown-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 1.1rem;
  line-height: 1;
  padding: 0.3rem 0.55rem;
  filter: grayscale(0.6);
  transition: filter var(--t-surface) var(--ease-out), transform var(--t-surface) var(--ease-out);
}

/*
 * US-67. A decorative transform, and on a touch pointer a tap sets `:hover` and
 * leaves it set — so the button stayed rotated until you tapped somewhere else.
 * Hover effects that move something belong behind the query; hover effects that
 * only change a surface are harmless either way and are left alone.
 */
@media (hover: hover) and (pointer: fine) {
  .frown-btn:hover { filter: none; transform: rotate(-15deg) scale(1.15); }
}
.frown-btn.on {
  filter: none;
  /* It was upside down and still. Now it keeps going, because a switch that is
     visibly out of control is a switch nobody mistakes for a setting. */
  animation: frown-spin 3.2s linear infinite;
}

@keyframes frown-spin {
  from { transform: rotate(180deg); }
  to   { transform: rotate(540deg); }
}

.frown-btn.on {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}

.frown-wrap { position: relative; }

/* The whole tile grid gets a lurch, and keeps a permanent lean so it never
   settles into looking like a normal page. */
.frown .frown-wrap .tiles {
  animation: frown-lurch 900ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
  transform-origin: 50% 50%;
}

@keyframes frown-lurch {
  0%   { transform: rotate(0deg) scale(1); }
  35%  { transform: rotate(190deg) scale(0.82); }
  70%  { transform: rotate(352deg) scale(1.06); }
  100% { transform: rotate(360deg) scale(1) rotate(-1.5deg); }
}

.frown .frown-wrap .tiles { transform: rotate(-1.5deg); }

/* A flipped tile stands on its head, then puts its text back the right way up —
   an upside-down number nobody can read is not a joke, it is a broken page. */
.frown .tile-flipped {
  animation: frown-wobble 2.4s ease-in-out infinite;
}

/* The label and the euphemism stay on their heads — that is the gag. The
   *value* is counter-rotated, because the cheerful number is the punchline and
   a punchline you cannot read is not one.

   The first version put the wobble on the `rotate` property while the base sat
   in `transform`, so the two composed to a full 360° and every child inherited
   an upside-down frame nothing undid. Both live in `transform` now. */
@keyframes frown-wobble {
  0%, 100% { transform: rotate(180deg); }
  25%      { transform: rotate(184deg); }
  75%      { transform: rotate(176deg); }
}

.frown .tile-flipped .value { transform: rotate(180deg); display: block; }

.frown .tile-flipped .value.flipped {
  color: var(--pos);
  text-shadow: 0 0 18px color-mix(in srgb, var(--pos) 45%, transparent);
}

.frown .tile-flipped .note {
  font-style: italic;
  color: var(--pos);
}

/* Nothing about the charts is done here. Optimism Mode draws two *different*
   charts (US-35d), which is a data decision — and the transform it replaced was
   a data decision too, for the same reason a CSS flip is not available: the axis
   labels are drawn inside the canvas and mirror into unreadable glyphs. */

.frown-stamp {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 3;
}

.frown .frown-stamp { display: flex; }

.frown-stamp span {
  transform: rotate(-11deg);
  border: 0.35rem solid var(--neg);
  color: var(--neg);
  border-radius: 0.75rem;
  padding: 0.4rem 1.5rem;
  font-size: clamp(1.1rem, 0.6rem + 2.6vw, 2.75rem);
  font-weight: 800;
  letter-spacing: 0.08em;
  opacity: 0.85;
  background: color-mix(in srgb, var(--surface-1) 70%, transparent);
  animation: frown-stamp-in 500ms cubic-bezier(0.34, 1.8, 0.64, 1) both;
}

@keyframes frown-stamp-in {
  0%   { transform: rotate(-11deg) scale(3); opacity: 0; }
  100% { transform: rotate(-11deg) scale(1); opacity: 0.85; }
}

/*
 * The stamp over a chart, rather than over the tile grid.
 *
 * Smaller and quieter: the tile version is meant to be the first thing you see,
 * and this one has to leave the chart readable — the chart is the joke here, so
 * hiding it behind the disclaimer would throw away the punchline to make the
 * disclaimer louder. Still large enough that a cropped screenshot carries it.
 */
.frown-stamp-sm span {
  font-size: clamp(0.7rem, 0.45rem + 1.1vw, 1.25rem);
  border-width: 0.2rem;
  border-radius: 0.5rem;
  padding: 0.25rem 0.75rem;
  opacity: 0.75;
}

.frown-confetti {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 9999;
}

@media (prefers-reduced-motion: reduce) {
  .frown .frown-wrap .tiles,
  .frown-stamp span { animation: none; }
  /* Still upside down — that is the feature — just not moving. */
  .frown .tile-flipped { animation: none; transform: rotate(180deg); }
}


/* --- Optimism Mode, turned all the way up ---------------------------------
   Once the switch is on there is nothing on this page worth trusting, so the
   restraint that governs everything else here does not apply. The stamp is the
   only element that still has a job. */

.frown .app,
.frown main {
  animation: frown-hue 6s linear infinite;
}

@keyframes frown-hue {
  0%   { filter: hue-rotate(0deg) saturate(1.15); }
  50%  { filter: hue-rotate(25deg) saturate(1.5); }
  100% { filter: hue-rotate(0deg) saturate(1.15); }
}

/* The header joins in. */
.frown #subtitle::after {
  content: ' · 🚀 ALL-TIME HIGH · 📈 UP ONLY · 🌕 NEXT STOP: THE MOON · 💎 NEVER SELLING';
  color: var(--pos);
  font-weight: 700;
}

.frown h1::after {
  content: ' 🚀';
  display: inline-block;
  animation: frown-launch 1.6s ease-in-out infinite;
}

@keyframes frown-launch {
  0%, 100% { transform: translateY(0) rotate(0deg); }
  50%      { transform: translateY(-0.35em) rotate(12deg); }
}

/* Every tile gets a shimmer, because why not. */
.frown .tile {
  animation: frown-glow 2.8s ease-in-out infinite;
}

@keyframes frown-glow {
  0%, 100% { box-shadow: 0 0 0 rgba(0, 0, 0, 0); }
  50%      { box-shadow: 0 0 22px color-mix(in srgb, var(--pos) 35%, transparent); }
}

/* The charts lean, so even the shapes look pleased with themselves. */
.frown #panel-overview .card { transform: rotate(0.6deg); }

/* --- and then further -----------------------------------------------------
   The owner's note on seeing 0.39.0 was that it *"mag nog meer over de top"*,
   which in this feature is a safety instruction as much as a comic one: the
   thesis in frown.js is that plausibility is the danger and absurdity is the
   protection, so more of it is strictly better. Every element below is either a
   real figure in the wrong voice or a piece of motion that could not appear on a
   page anybody would forward to an accountant. */

/* The crawl. Hidden outside the mode by `display: none`, so nothing about it can
   be seen — or read out — on the real page. */
.frown-ticker { display: none; }

.frown .frown-ticker {
  display: block;
  overflow: hidden;
  margin: 0 0 var(--gap);
  padding: 0.3rem 0;
  border-block: var(--rule) solid var(--pos);
  background: color-mix(in srgb, var(--pos) 12%, transparent);
  font-size: var(--hint);
  font-weight: 700;
  letter-spacing: var(--track-label);
  color: var(--pos);
  white-space: nowrap;
  /* A crawl is a strip of text, and a strip of text that never wraps would push
     the page sideways at any width without this. */
  max-width: 100%;
}

/* The lower crawl runs the other way, so the two do not read as one long line
   that happens to be printed twice. */
.frown .frown-ticker-back { margin: var(--gap) 0 0; }

.frown .frown-ticker-back .frown-ticker-track {
  animation-direction: reverse;
}

.frown-ticker-track {
  display: inline-flex;
  /* Two copies of the same list live in here, so translating by exactly half the
     track puts the copy where the original was and the loop has no seam. */
  animation: frown-crawl 28s linear infinite;
}

.frown-ticker-track span::after {
  content: ' 🚀 ';
}

.frown-ticker-track span {
  padding-inline: 0.75rem;
}

@keyframes frown-crawl {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* Every tile leans its own way, so the block reads as a wall of hype rather than
   as a tidy grid that has been rotated. Six offsets on a cycle: enough that no
   two neighbours agree, few enough to stay a pattern rather than noise. */
.frown .tiles .tile:nth-child(6n + 1) { transform: rotate(-1.2deg); }
.frown .tiles .tile:nth-child(6n + 2) { transform: rotate(0.9deg); }
.frown .tiles .tile:nth-child(6n + 3) { transform: rotate(-0.5deg); }
.frown .tiles .tile:nth-child(6n + 4) { transform: rotate(1.4deg); }
.frown .tiles .tile:nth-child(6n + 5) { transform: rotate(-1.6deg); }
.frown .tiles .tile:nth-child(6n)     { transform: rotate(0.6deg); }

/* The figures glow in the direction the mode insists everything is going. */
.frown .tile .value {
  text-shadow: 0 0 14px color-mix(in srgb, var(--pos) 55%, transparent);
}

/* The stamp breathes. It is the one element with a job, so it gets the one
   animation that draws the eye rather than decorating an edge. */
.frown .frown-stamp span {
  animation:
    frown-stamp-in 500ms cubic-bezier(0.34, 1.8, 0.64, 1) both,
    frown-stamp-pulse 2.2s ease-in-out 500ms infinite;
}

@keyframes frown-stamp-pulse {
  0%, 100% { transform: rotate(-8deg) scale(1); }
  50%      { transform: rotate(-8deg) scale(1.06); }
}

/* One rocket, across the page, on switch-on. Launched from `confetti()`, which
   already refuses to run under reduced motion, so there is one guard rather than
   two that can disagree. */
.frown-rocket {
  position: fixed;
  left: -12vw;
  bottom: 12vh;
  z-index: 60;
  /* Display-sized, so `npm run type` asks it to state its tracking, and the
     bucket's own token is the answer — never `--track-display` directly, which is
     the pin US-58 measured its way out of. One emoji has no letter-spacing to
     speak of; stating it anyway is cheaper than an exception, and an exception is
     how a measurable bucket stops being measurable. */
  font-size: 3rem;
  letter-spacing: var(--kpi-track);
  pointer-events: none;
  animation: frown-fly 2.2s cubic-bezier(0.45, 0, 0.55, 1) both;
}

@keyframes frown-fly {
  from { transform: translate(0, 0) rotate(-8deg); opacity: 0; }
  10%  { opacity: 1; }
  to   { transform: translate(130vw, -70vh) rotate(18deg); opacity: 1; }
}

/*
 * On a phone the leaning stops.
 *
 * A rotated grid in a 380 px column pushes its own left edge off screen, and at
 * that point the figures are not readable — which breaks the one rule this
 * feature does have. The thesis is *absurd and legible*; a joke you cannot read
 * is just a broken page, so the tilt is the part that goes and the crawl, the
 * stamp, the glow and the confetti stay.
 */
@media (max-width: 39em) {
  /* `:nth-child(n)` matches every child and buys one pseudo-class of specificity,
     which is what it takes to outrank the six `:nth-child(6n + …)` rules above.
     Without it this whole block is a no-op that reads as though it works — which
     is what the first version of it was, and a phone screenshot said so. */
  .frown .frown-wrap .tiles,
  .frown .tiles .tile:nth-child(n),
  .frown #panel-overview .card { transform: none; }
  /*
   * And the lurch goes with it, because it does not merely animate the lean — it
   * *holds* it: `both` fill means the last keyframe's `transform` wins over any
   * declaration, so zeroing the transform alone left the grid exactly as tilted
   * as before. Measured on a 380 px viewport: the hero tile's left edge at −8 px.
   */
  .frown .frown-wrap .tiles { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .frown .app,
  .frown main,
  .frown h1::after,
  .frown .tile { animation: none; }
  /*
   * US-68's rule, applied to the loudest thing on the page: what *travels*
   * stops, what *states* stays. The crawl holds still at its start — the words
   * are the joke and they are still readable — the stamp keeps its arrival and
   * loses its pulse, and the rocket never existed, because `confetti()` is what
   * creates it and that returns early here.
   */
  .frown .frown-ticker-track,
  .frown .frown-btn.on { animation: none; }
  /* The switch still has to *state* that it is on, so what it loses is the spin
     and not the upside-down — US-68's rule again: travel stops, statements stay. */
  .frown .frown-btn.on { transform: rotate(180deg); }
  .frown .frown-stamp span { animation: frown-stamp-in 500ms cubic-bezier(0.34, 1.8, 0.64, 1) both; }
}


/* --- US-48 -----------------------------------------------------------------
   The mark behind a table card. Charts get theirs from a Chart.js plugin so it
   composites into the pixels; a card is DOM, so it gets a DOM child and rides
   currentColor exactly like every other placement of the mark. */
.card {
  position: relative;
}

.card > .card-watermark {
  position: absolute;
  top: 14px;
  right: 16px;
  opacity: 0.35;
  pointer-events: none;
  color: var(--brand-ink);
  z-index: 0;
  /* Sized here rather than only by the SVG's width/height attributes: a
     stylesheet rule beats an attribute, and one elsewhere was collapsing it to
     zero. The ratio still comes from the mark's own box — width is derived,
     never chosen, because the lockup may not be stretched. */
  height: 18px;
  width: calc(18px * 44 / 40);
}

/* The share action on a holdings row. Quiet until the row is hovered: it is an
   action on one line of a long table, not a call to action. */
/* US-54: the block's share button sits at the end of the hero row, pushed to
   the far edge so it does not read as a fourth fact. Always visible, unlike the
   row buttons that appear on hover — a block has no row to hover. */
.hero-row .block-share {
  margin-left: auto;
  align-self: flex-start;
  opacity: 1;
}

/*
 * US-67. The un-hovered state is the **default**, not the fallback.
 *
 * This control used to sit at `opacity: 0.45` and come up to full on
 * `tr:hover`. On a pointer with no hover — a phone, a tablet — that is
 * permanent: a share button at 45 % is not "quiet", it is a control claiming to
 * be off, on the one device where there is no way to find out otherwise.
 *
 * So the dimming lives behind a hover query and the plain rule is the usable
 * one. Writing it the other way round is how the bug happened.
 */
button.snap {
  padding: 2px 8px;
  font-size: 0.95em;
  line-height: 1.4;
}

/*
 * The quieting, and it only exists where there is a way to undo it. On a fine
 * pointer the row action rests at 45 % and comes up on hover or focus; on a
 * touch pointer the rule never matches and the button is simply visible.
 */
@media (hover: hover) and (pointer: fine) {
  button.snap {
    opacity: 0.45;
  }

  /* `:focus-visible` is how a keyboard reaches this at all, and it is inside the
     query for the same reason the dimming is: outside it there is nothing to
     reveal. */
  tr:hover button.snap,
  button.snap:focus-visible {
    opacity: 1;
  }
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* A broker's mark beside its name: in ink, never in its own blue, because blue
   means gain here — see BROKER_MARKS in brand.js. */
.menu button[role='menuitem'] {
  display: flex;
  align-items: center;
  gap: 7px;
}

.broker-mark {
  flex: none;
  color: var(--text-secondary);
}

#diag-title .broker-mark {
  vertical-align: -2px;
  margin-right: 6px;
}

/* --- hierarchy: one hero, three facts, the rest behind a disclosure -------- */

/*
 * The tile row used to be seven equal boxes, so the number the page exists for
 * rendered at the same size as its data coverage. Weight and size carry the
 * hierarchy now; the boxes are gone.
 */
.tiles {
  display: block;
  border: 0;
  background: none;
}

.hero-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--pad) clamp(1.5rem, 4vw, 3.5rem);
  padding: 0.5rem 0 var(--gap);
}

.tile {
  border: 0;
  background: none;
  box-shadow: none;
  padding: 0;
}

/*
 * Sizes come from an explicit basis, never from max-content.
 *
 * `.tile` is a container-query container (`container-type: inline-size`, above),
 * which is what lets a long amount shrink to fit its cell instead of being
 * clipped — and it also makes the tile's own max-content width exactly 0. Two
 * attempts at `min-width: max-content` here collapsed the hero to nothing and
 * spilled a 56px figure across the facts beside it. So each kind gets a width,
 * and the existing `--len` rule sizes the type inside it.
 */
.tile.is-hero {
  flex: 0 0 auto;
  width: min(100%, 23rem);
  /* 38–56px: display, and the one figure on the page that genuinely is. */
  --kpi-track: var(--track-display);
  /* Raising --kpi rather than setting font-size: the value's own rule is
     `min(--kpi, --fits)`, so the fit guard still wins on a seven-figure total
     and nothing can be silently sliced. */
  --kpi: clamp(2.375rem, 1.4rem + 3.2vw, 3.5rem);
}

.tile.is-hero .label {
  white-space: nowrap;
}

.facts > .tile {
  flex: 0 0 auto;
  width: 9.5rem;
  /* 17px is body, and it was being tracked as display. */
  --kpi: 1.0625rem;
  --kpi-track: var(--track-body);
  --kpi-lead: var(--lead-label);
}

.figures-grid > .tile {
  --kpi: 1.0625rem;
  --kpi-track: var(--track-body);
  --kpi-lead: var(--lead-label);
}

.tile .label {
  font-size: 0.6875rem;
}

.facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--pad) clamp(1.25rem, 3vw, 2.5rem);
}

.allfigures {
  border-top: var(--rule) solid var(--border);
  padding-top: 0.5rem;
  margin-bottom: var(--gap);
}

.allfigures > summary {
  cursor: pointer;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: var(--track);
  text-transform: uppercase;
  color: var(--text-muted);
  padding: 4px 0;
}

.figures-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  gap: 0.75rem var(--pad);
  padding: 0.5rem 0 0.25rem;
}


/* --- hints behind a ? ----------------------------------------------------- */

/*
 * Every card carried a permanent paragraph, an (i) per figure and a footnote.
 * The copy is good — that is the problem: it was compensating for a layout that
 * had not made the meaning obvious. The words are kept verbatim and moved one
 * click away rather than shortened.
 */
.hint-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.125rem;
  height: 1.125rem;
  padding: 0;
  margin-left: 6px;
  border-radius: 50%;
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--text-muted);
  background: var(--surface-2);
  border-color: transparent;
  vertical-align: 2px;
}

.hint-toggle:hover,
.hint-toggle[aria-expanded='true'] {
  color: var(--text-primary);
  background: var(--surface-3);
}

.card p.hint[hidden] {
  display: none !important;
}

/*
 * A wide table scrolls inside its own panel, never sideways with the page.
 *
 * `.table-scroll` already had `overflow-x: auto`, but a grid item's default
 * minimum width is its content, so the holdings table pushed its card, its grid
 * and then the document — 101px of horizontal page scroll at 1440 and 923 at
 * 380. It only surfaced with the rail in place, because the main column got
 * narrower than the table had ever had to fit in.
 */
.grid,
.card {
  min-width: 0;
}

/* The page itself never scrolls sideways. The holdings table is wider than the
   main column and scrolls inside its own wrapper, but the overflow still reached
   the document — measured at 101px on a 1440 viewport and 923 on a 380 one, and
   only visible once the rail narrowed the column. Clipping the column is the
   rule from brief section 8 rather than a patch: wide content scrolls in its own
   container, the body does not. */
.main {
  overflow-x: clip;
}

.card .table-scroll,
#holdings-table-wrap,
#compare-summary-wrap {
  max-width: 100%;
  overflow-x: auto;
}

/* --- press feedback, and the motion nobody asked for -------------------- */

/*
 * Feedback on pointer-down, not on release.
 *
 * The moment a control answers only after you let go, directness "falls off a
 * cliff" — Apple's *Designing Fluid Interfaces*. Every pressable thing here now
 * answers on the press. 100ms and 0.97 are the standard values: enough to feel,
 * too small to read as an animation.
 */
button:active:not(.press-cancelled),
.rail-nav button:active:not(.press-cancelled),
.menu button:active:not(.press-cancelled) {
  transform: scale(0.97);
  transition: transform var(--t-press) var(--ease-out);
}

/* A full-width rail item scaling reads as a wobble; it takes the surface
   instead, which is the same "it heard me" without the movement. */
.rail-nav button:active:not(.press-cancelled),
button.primary:active:not(.press-cancelled) {
  transform: none;
  background: var(--surface-3);
}

/*
 * US-56. `.press-cancelled` is put on by `wirePressFeedback` the moment the
 * pointer leaves the control it is pressing, and taken off again if it comes
 * back. It is negated above rather than overriding afterwards so there is one
 * definition of what a press looks like, and the keyboard path — which never
 * gets the class, because it has no pointer — keeps it unchanged.
 */

/*
 * US-56 — the two preferences this stylesheet had never answered.
 *
 * Reduced motion was already handled in five places; transparency and contrast
 * were not asked at all. They sit here beside the press feedback because that is
 * what they are: the same "does this page answer honestly" question, asked by a
 * reader whose setting says the default answer does not work for them.
 *
 * They are token overrides rather than per-component rules, for the reason
 * US-46's mask lives inside the formatters: a control added next year inherits
 * them because it uses the tokens, and a per-component sprinkle is the denylist
 * mistake in a different costume.
 */

/*
 * Translucency, reduced.
 *
 * There is exactly one translucent surface in this stylesheet — the modal
 * scrim — and it is listed here rather than the rule being written for chrome
 * that does not exist. If a frosted bar is ever added, it joins this block; the
 * point of having it is that there is now somewhere obvious for it to go.
 */
@media (prefers-reduced-transparency: reduce) {
  .modal::backdrop {
    background: rgb(10 14 18 / 92%);
  }
}

/*
 * More contrast.
 *
 * Borders that were hairlines become defined, muted text steps up to the
 * secondary tone, and every interactive surface gains an edge it did not have —
 * a button that reads as a button because of its background alone disappears for
 * a reader who asked for this.
 *
 * **What is deliberately not softened here**: the reconciliation red, the
 * warning amber and the critical tone are already the strongest values in the
 * palette and this only ever moves things *towards* contrast, never away. Rule 6
 * outranks the aesthetic, and a disagreement with the broker that a reader
 * cannot see is the failure this project exists not to ship.
 */
@media (prefers-contrast: more) {
  :root {
    --border: var(--text-secondary);
    --border-strong: var(--text-primary);
    --text-muted: var(--text-secondary);
    --rule: 1px;
  }

  button,
  select,
  input[type='text'],
  .tile,
  .card {
    border: 1px solid var(--border-strong);
  }

  /*
   * The two places that erase their border *by design*, restored. A segmented
   * control uses its track as the frame and a hero tile drops its box entirely —
   * both good decisions at the default contrast and both wrong for a reader who
   * asked for more of it. Naming them is not the per-component sprinkle the
   * token overrides above avoid; it is undoing two deliberate erasures.
   */
  .group button {
    border-color: var(--border-strong);
  }

  .hero-row .tile {
    border: 1px solid var(--border);
  }

  /* The press has to remain distinguishable from the rest once everything has
     an edge, so it takes the strongest surface rather than a subtler one. */
  button:active {
    background: var(--surface-3);
  }
}

/*
 * Reduced motion means gentler, not none.
 *
 * The colour and surface changes stay — they are what tells you a control
 * responded. What goes is movement: the press scale and every transform
 * transition. There is no parallax or slide here to remove, which is why this
 * block is short rather than thorough.
 */
@media (prefers-reduced-motion: reduce) {
  /*
   * US-68. This used to be
   *
   *   * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
   *
   * which is short because it does not think. It stops *everything*, including
   * the surface and colour changes that are the only thing telling a reader
   * their press registered — and §14 and US-56 both say the same sentence:
   * reduced motion is gentler feedback, never no feedback.
   *
   * The replacement names what must not move. It is longer, and that is the
   * point: four named properties can be read and can be tested, where a star
   * selector can only be trusted.
   *
   * Today the damage would be small, because little moves. It stops being small
   * the moment a story lands whose reduced form *is* an opacity change — the
   * star rule deletes exactly the fallback the story wrote.
   */
  *,
  *::before,
  *::after {
    /*
     * Movement, gone. Colour, surface and opacity transitions are untouched,
     * because those are the answer rather than the journey.
     *
     * `!important` on an **allowlist**, which is the opposite of what it
     * replaced. The old rule forced every duration to zero, so it also silenced
     * the colour change that says a press registered; this forces the *set of
     * properties* and leaves their timing alone. It has to be forceful for the
     * same reason the list has to be a list: a rule that sets its own
     * `transition` shorthand beats a plain declaration on specificity, and a
     * first attempt without it left the row expander still rotating under
     * reduced motion. A denylist of the three rules that do that would ship the
     * next one with the same defect.
     */
    transition-property: background-color, border-color, color, box-shadow, opacity, fill, stroke, display, overlay !important;
    animation-name: none !important;
    scroll-behavior: auto;
  }

  button:active,
  .rail-nav button:active,
  .menu button:active {
    transform: none;
  }

  /*
   * US-74's exception, named here because this is where the four motion stories
   * state their reduced forms.
   *
   * The theme cross-fade deliberately survives reduced motion: it is colour with
   * no travel, and an abrupt brightness jump is the thing §14 asks to be
   * *softened* rather than removed. The page fades either way — so if the
   * allowlist above silenced the canvas's fade, the chart would snap inside a
   * page that did not, which is the seam US-74 exists to close.
   */
  :root[data-theme-fade] canvas {
    animation-name: theme-repaint !important;
  }

  /*
   * US-73's reduced form. The row still opens — that is layout settling, not
   * vestibular motion, and the point of the story is that the page below stops
   * jumping. Only the travel would have gone, and there is none here.
   */
  .banner-slot {
    transition-property: grid-template-rows, opacity !important;
  }

  /*
   * US-75's reduced form. The arrival stays — knowing your data landed is
   * comprehension — and the travel goes: no rise, and the chart's wipe becomes a
   * cross-fade, because a mask crossing a large surface is exactly what §14 is
   * about.
   */
  .card.arriving,
  .card.arriving tbody tr {
    animation-name: card-fade !important;
  }

  .card.arriving canvas {
    animation-name: card-fade !important;
  }
}

@keyframes card-fade {
  from { opacity: 0; }
}

/* The three filters of the merged positions table, on one row above it. */
.filter-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 12px;
  margin-bottom: 12px;
}

/* The window every figure below belongs to. Quiet by design — it is a label,
   not a heading — and loud only when it is refusing to draw. */
.window-crumb {
  font-size: 0.6875rem;
  letter-spacing: var(--track);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 6px;
}

.window-crumb.thin {
  text-transform: none;
  letter-spacing: 0;
  font-size: var(--hint);
  color: var(--critical);
}

/* A month the period excludes: still legible, visibly not part of the figures
   above it. Dimmed rather than removed, because the grid exists to compare
   across years and a filtered grid has nothing to compare against. */
#months td.cell.out {
  opacity: 0.38;
}

#months td.cell.out button {
  color: var(--text-muted);
}

/* --- the two toggles in the top bar, on one line ------------------------- */

/*
 * The eye and the 🙃 were two different shapes at two different heights: one a
 * 2rem icon button, the other a padded pill inside a flex row that still carried
 * the bottom margin from when it sat above the tiles on its own. Side by side
 * that reads as a mistake, and it is one — same role, same rank, so same box.
 *
 * Apple's *spatial consistency*, in the small: two controls that do the same
 * kind of thing must look the same and sit in the same place.
 */
.topbar-right .frown-bar {
  margin: 0;
  display: flex;
  align-items: center;
}

.topbar-right .frown-btn {
  width: 2rem;
  height: 2rem;
  padding: 0;
  font-size: 1rem;
  border-radius: var(--radius-sm);
}

.topbar-right .icon-btn {
  border-radius: var(--radius-sm);
}

/* The rotate belongs to the joke; it must not resize the row it lives in. */
.topbar-right .frown-btn:hover {
  transform: rotate(-15deg);
}
