/* ============================================================
 * THE THUS(DIGITAL) CANONICAL STYLEGUIDE — SOURCE OF TRUTH
 * ============================================================
 * Part of the canonical thus(digital) design system, the SINGLE
 * SOURCE OF TRUTH for all frontend UI/UX across every thus(digital)
 * project. (Flutter projects, e.g. Wordaddle, are the exception.)
 *
 *   Repo:  thusdigital/td-styleguide
 *   Live:  https://styles.thusdigital.com
 *
 * Sits within the canonical thus(digital) full-stack workflow:
 *   • Supabase                 - Postgres host
 *   • FastAPI                  - backend / API
 *   • td-styleguide (this)     - frontend UI/UX
 *   • thusdigital/infra        - Hetzner deploy + CI/CD + runbooks
 *
 * ─────────── HARD RULES ───────────
 *   1. EVERY frontend element in EVERY thus(digital) project MUST be
 *      built from components, tokens, partials + conventions defined
 *      HERE. No improvisation. No project-specific drift.
 *   2. If you need something that does NOT exist here:
 *        STOP. TELL SETH CLEARLY what's missing.
 *        Implement + document INTO THIS STYLEGUIDE FIRST.
 *        THEN consume from here in the project.
 *   3. This keeps the styleguide ALWAYS COMPREHENSIVE + UP TO DATE.
 *   4. CONVENTIONS are part of the contract — composable class names
 *      (.btn.btn-sm.btn-narrow.btn-primary stacks), BEM-lite naming
 *      (short, lowercase, hyphen-only, no app/view prefixes),
 *      token-only values (var(--*) — never hardcoded hex/px), Caddy
 *      partials for shared chrome, fetch-on-trigger pattern
 *      (data-content-url + data-pour-on-init), sub-handler re-binding
 *      via td.pour post-pour hook chain. Respect the patterns; don't
 *      invent shortcuts.
 *   5. STYLEGUIDE CSS IS SACROSANCT. tokens.css, base.css, and
 *      components.css are read-only inside any consuming project. NEVER
 *      edit them in-project to bend behaviour for a one-off case - that
 *      drift is exactly what this contract exists to prevent. If you
 *      need new styling: project-specific rules go in a DEDICATED
 *      app-level CSS file (e.g. /app/app.css or similar) loaded AFTER
 *      the styleguide stack. If the styling is genuinely reusable, it
 *      belongs IN the styleguide - see rule 2.
 *   6. DEV-APP PROTOTYPING EXCEPTION (see README rule 5). A bug fix or
 *      missing component MAY be prototyped inside a consuming app, on
 *      that app's dev env, where live content aids the design.
 *      Mandatory: track it in the styleguide bug/feature list on
 *      creation; project CSS only (never inline, never this read-only
 *      copy); back-port INTO this styleguide before the feature ships
 *      to stage/prod; remove the prototype + close the entry on
 *      back-port. Styleguide-first stays the default.
 * ============================================================ */

/*
 * components.css - canonical thus(digital) component classes.
 *
 * Rules (locked from architecture):
 *   1. Generic, app-agnostic class names. No view/page/app prefixes.
 *   2. Token-driven. Components reference var(--*); no hardcoded values.
 *   3. Same-commit rule: component change updates td_styleguide.html.
 *   4. Colour modifiers applied via synonym classes that set --state-* vars
 *      (--state-color, --state-bg, --state-fade). Synonym groups:
 *        .green / .success / .confirmed / .good / .live / .hit / .gained / .up
 *        .amber / .warning / .caution / .fair / .pending
 *        .red / .error / .danger / .poor / .failed / .miss / .lost / .down
 *        .neutral / .flat (grey, used for default trend or any neutral indicator)
 *        .primary / .secondary / .tertiary (theme teals)
 *      Pick the synonym that reads best in surrounding code (e.g. .danger
 *      for destructive UI, .error for validation, .red for raw colour intent).
 *      Components reference var(--state-*) so swapping the synonym swaps
 *      the colour in one class change.
 *   5. Components have explicit size variants (.btn-sm, .btn-md, .btn-lg etc) -
 *      these are size-agnostic and used wherever needed regardless of context.
 *      `.app` is a density context that can OVERRIDE size variants where the
 *      visual must change inside app-mode (e.g. `.app .btn-sm { height: 28px }`
 *      vs default `.btn-sm { height: 32px }`). Both descendant (`.app .x`) and
 *      same-element (`.app.x`) overrides supported.
 *   8. `.app` overrides live NEXT TO their component's default rules (same file,
 *      paired block). NEVER in a separate app-overrides.css. Keeps default + app
 *      visible in one scroll, deletes/renames stay in sync, and grep lands you
 *      in one place per concept.
 *   9. Every component demoed in td_styleguide.html includes `<span class="code">`
 *      tags showing the required element + class names (e.g. `ul`, `li.tick`,
 *      `.eyebrow.thin`). The demo doubles as inline documentation - readers can
 *      see the markup contract without leaving the page. NO BRACKETS around the
 *      code spans - just inline next to the label or instance.
 *  10. Button labels (and other interactive control labels) use Title Case -
 *      capitalise each major word: "Download PDF", "Add Domain", "Email Me
 *      When Done", "View As User". Avoid sentence case in buttons. Acronyms
 *      stay all-caps (PDF, AI, URL).
 *  11. Ellipsis: use three literal periods ("...") not the Unicode glyph ("…").
 *      The glyph has baseline alignment issues across fonts/sizes; literal
 *      periods render predictably at any size. Applies to ALL output: button
 *      labels, placeholders, select options, body copy, everywhere.
 *  12. CONTROLS vs FORMS: form-input components (text inputs, selects, toggles)
 *      live in the Inputs section. Interactive controls that drive UI state
 *      but don't submit values (pagers, disclosure arrows, sheet closers,
 *      tabs, etc) live in the Controls section.
 *   6. Radii in PIXELS (geometric, fixed regardless of zoom). Type + spacing in rem.
 *   7. UI vs prose split (line-height is a FLOOR not a guarantee, do not fight it):
 *      - UI components (buttons, inputs, badges, tags, table cells, nav items):
 *          explicit pixel `height`, `display: inline-flex; align-items: center;`,
 *          `line-height: 1`, `padding: 0 Npx;`. Box dims are fixed; text centres
 *          inside. Never size UI by font + line-height.
 *      - Prose (headings, paragraphs, long copy): natural line-height, accept
 *          1-2px line-box leak, build vertical rhythm via margin not line-grid.
 */

/* ──────────────────────────────────────────────────────────
 * INLINE INDICATOR ALIGNMENT
 * vertical-align on inline-flex pills aligns to (baseline + x-height/2),
 * not (baseline + cap-height/2). For uppercase headings + display fonts
 * the result reads visually low. When a heading / paragraph / list item
 * directly contains a .flag, .badge, .chip or .bubble, switch the parent
 * to a flex row so align-items:center handles the centring on the line
 * midpoint regardless of font. flex-wrap keeps long content safe; gap
 * provides breathing room between the text run and the indicator.
 * Scoped via :has() so only blocks with these inline indicators are
 * affected; everything else stays in normal text flow.
 * ────────────────────────────────────────────────────────── */
:is(h1, h2, h3, h4, h5, h6, p, dt, li, figcaption):has(> .flag, > .badge, > .chip, > .bubble, > .dot) {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4em;
}

/* ──────────────────────────────────────────────────────────
 * COLOUR SYNONYMS - set --state-* vars used by indicators, badges,
 * callouts, status dots, score chips, etc. See rule 4.
 * --state-color: foreground / accent - use for dots, icons, borders,
 *                solid filled indicators (always visible against white)
 * --state-bg:    soft fill - use for badge bgs, callout fills (may be
 *                muted/light, e.g. invert-2 for neutral)
 * --state-fade:  even softer tint - use for backgrounds behind text
 * --state-halo:  hover halo source. Theme teals (.primary/.secondary/
 *                .tertiary) and .neutral default to primary teal halo
 *                so the halo reads as ambient/neutral. State synonyms
 *                (.green/.amber/.red and their aliases) use their own
 *                colour for the halo so meaning is preserved.
 * ────────────────────────────────────────────────────────── */
.green, .success, .confirmed, .good, .live, .hit, .gained, .up {
  --state-color: var(--c-green);
  --state-bg:    var(--c-green);
  --state-fade:  var(--c-green-fade);
  --state-halo:  var(--c-green);
}
.amber, .warning, .caution, .fair, .pending {
  --state-color: var(--c-amber);
  --state-bg:    var(--c-amber);
  --state-fade:  var(--c-amber-fade);
  --state-halo:  var(--c-amber);
}
.red, .error, .danger, .poor, .failed, .miss, .lost, .down {
  --state-color: var(--c-red);
  --state-bg:    var(--c-red);
  --state-fade:  var(--c-red-fade);
  --state-halo:  var(--c-red);
}
.neutral, .flat {
  --state-color: var(--c-mid);
  --state-bg:    var(--c-invert-2);
  --state-fade:  var(--c-invert-2);
  --state-halo:  var(--c-primary);
}
.primary {
  --state-color: var(--c-primary);
  --state-bg:    var(--c-primary);
  --state-fade:  color-mix(in srgb, var(--c-primary) 8%, white);
  --state-halo:  var(--c-primary);
}
.secondary {
  --state-color: var(--c-secondary);
  --state-bg:    var(--c-secondary);
  --state-fade:  color-mix(in srgb, var(--c-secondary) 8%, white);
  --state-halo:  var(--c-primary);
}
.tertiary {
  --state-color: var(--c-tertiary);
  --state-bg:    var(--c-tertiary);
  --state-fade:  color-mix(in srgb, var(--c-tertiary) 16%, white);
  --state-halo:  var(--c-primary);
}

/* ──────────────────────────────────────────────────────────
 * TEXT COLOUR UTILITIES - set `color` directly. Compose onto any text
 * element to recolour it (e.g. .eyebrow.text-mid) without redefining the
 * base. Distinct from the --state-* colour synonyms above, which drive
 * indicators / badges / chips, not plain text colour.
 * ────────────────────────────────────────────────────────── */
.text-body      { color: var(--c-body); }
.text-mid       { color: var(--c-mid); }
.text-primary   { color: var(--c-primary); }
.text-secondary { color: var(--c-secondary); }
.text-tertiary  { color: var(--c-tertiary); }

/* ──────────────────────────────────────────────────────────
 * .page-title-row - canonical pattern for module/page top strip:
 *   h1 on the left, optional tabs / actions / nav floated hard right.
 *   Title baseline aligns with the right-hand content via flex centring.
 *   Wraps on narrow viewports so the right-hand slot drops below.
 *
 *   <div class="page-title-row">
 *     <h1>Module Title</h1>
 *     <div class="tabs">...</div>     // OR any inline-right content
 *   </div>
 *   <p>Description below title...</p>
 * ────────────────────────────────────────────────────────── */
.page-title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  flex-wrap: wrap;
  margin-bottom: var(--s-4);
}
.page-title-row > h1 { margin-bottom: 0; }

/* ──────────────────────────────────────────────────────────
 * Section + subsection headings (M PLUS Rounded, black, uppercase).
 * ────────────────────────────────────────────────────────── */
.section-heading {
  font-family: var(--ff-body);
  font-weight: 700;
  color: #000;
  text-transform: uppercase;
  font-size: 24px;
  line-height: 36px;
  margin-bottom: var(--s-4);
}
.subsection-heading {
  font-family: var(--ff-body);
  font-weight: 700;
  color: #000;
  text-transform: uppercase;
  font-size: 16px;
  line-height: 24px;
  margin-bottom: var(--s-4);
}

/* Section/subsection headings stay constant across .app context - they
   identify the section, not the content density. Override the generic
   .app h2 / .app h3 shrink. */
.app .section-heading    { font-size: 24px; line-height: 36px; }
.app .subsection-heading { font-size: 16px; line-height: 24px; }

/* ──────────────────────────────────────────────────────────
 * .split - two-column flex layout for default / .app comparisons.
 * ────────────────────────────────────────────────────────── */
.split { display: flex; gap: 0; align-items: flex-start; }
.split > * { flex: 1; min-width: 0; text-align: left; padding-left: var(--s-4); padding-right: var(--s-4); }
.split > :first-child + * { border-left: 1px solid var(--c-mid); }

/* .split-flip swaps the divider to the left column (use when the left side
   is taller than the right and you want the border to stretch its full height). */
.split.split-flip > :first-child { border-right: 1px solid var(--c-mid); }
.split.split-flip > :first-child + * { border-left: 0; }

@media (max-width: 799px) {
  body > .split { flex-direction: column; }
  body > .split > :first-child { padding-right: 0; margin: 0 var(--s-4); }
  body > .split > :first-child + * { border-left: 0; padding-left: 0; margin: 0 var(--s-4); }
}

/* ──────────────────────────────────────────────────────────
 * Colour swatch primitives.
 * ────────────────────────────────────────────────────────── */
.swatches { display: flex; flex-wrap: wrap; gap: var(--s-4); margin-bottom: var(--s-4); }
.swatch-panel { display: flex; flex-direction: column; gap: 0.4rem; width: 160px; }
.swatch { height: 80px; border-radius: var(--r-md); }

/* ──────────────────────────────────────────────────────────
 * .mega - hero / landing-page display type. NOT part of the h1-h6
 * hierarchy. Pair with .mega-alt for wordmark two-weight pattern
 * (Fredoka 500 primary outer, Fredoka 300 secondary inner).
 * ────────────────────────────────────────────────────────── */
.mega {
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 64px;
  line-height: 68px;
  color: var(--c-primary);
  letter-spacing: -0.01em;
}
.mega-alt {
  font-weight: 300;
  color: var(--c-secondary);
}

/* ──────────────────────────────────────────────────────────
 * .brand - inline wordmark, two-weight pattern (Fredoka 500 primary
 * outer, Fredoka 300 secondary inner). Use for "thus(digital)",
 * "OptimAI" etc. Inherits font-size from context.
 * ────────────────────────────────────────────────────────── */
.brand {
  font-family: var(--ff-display);
  font-weight: 500;
  color: var(--c-primary);
}
.brand-alt {
  font-weight: 300;
  color: var(--c-secondary);
}

/* ──────────────────────────────────────────────────────────
 * .eyebrow - small uppercase kicker label. Sits above headings,
 * inside cards, or as a section sub-label. Sized at scale-base
 * (body size) for visual parity with surrounding copy.
 * ────────────────────────────────────────────────────────── */
.eyebrow {
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 20px;
  line-height: 30px;
  color: var(--c-primary);
  text-transform: uppercase;
  letter-spacing: 1px;
}
.eyebrow.thin { font-weight: 300; }

/* ──────────────────────────────────────────────────────────
 * Buttons. Fixed-height + inline-flex pattern (line-height: 1) so the
 * box dimensions stay sacrosanct regardless of font.
 * Variants: -primary, -secondary, -outline, -ghost, -danger.
 * Sizes: -sm, -lg. Layout: -full.
 * ────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 40px;
  padding: 0 16px;
  font-family: inherit;
  font-weight: 500;
  font-size: 16px;
  line-height: 1;
  border: 1.5px solid transparent;
  border-radius: var(--r-sm);
  cursor: pointer;
  text-decoration: none;
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
}
.btn:disabled,
.btn[aria-disabled="true"] { opacity: 0.55; cursor: not-allowed; }

.btn:hover { background: var(--c-secondary); border-color: var(--c-secondary); color: white; }

.btn-primary   { background: var(--c-primary); color: white; border-color: var(--c-primary); }
.btn-secondary { background: var(--c-invert-2); color: var(--c-secondary); border-color: var(--c-invert-2); }
.btn-outline   { background: transparent; color: var(--c-primary); border-color: var(--c-primary); }
.btn-ghost     { background: transparent; color: var(--c-primary); border-color: transparent; }
.btn-danger    { background: var(--c-red); color: white; border-color: var(--c-red); }
.btn-danger:hover { background: var(--c-body); border-color: var(--c-body); }

.btn-sm   { height: 32px; padding: 0 12px; font-size: 12px; }
.btn-lg   { height: 48px; padding: 0 24px; font-size: 20px; }
.btn-full { width: 100%; display: flex; }

.btn-narrow            { height: 28px; padding: 0 12px; }
.btn-sm.btn-narrow     { height: 24px; padding: 0 10px; }
.btn-lg.btn-narrow     { height: 36px; padding: 0 16px; }

/* Avatar button - circular icon-only for account/profile menus.
   Lifted directly from OptimAI .footer-account-btn: 30px primary-teal
   circle with white filled icon, secondary-teal on hover. Single size. */
.btn-avatar {
  width: 30px;
  height: 30px;
  padding: 0;
  border-radius: var(--r-pill);
  border: 0;
  background: var(--c-primary);
  color: white;
}
.btn-avatar:hover { background: var(--c-secondary); }
.btn-avatar img { width: 100%; height: 100%; border-radius: var(--r-pill); object-fit: cover; display: block; }

.app .btn              { height: 32px; padding: 0 12px; font-size: 12px; }
.app .btn-sm           { height: 28px; padding: 0 8px;  font-size: 12px; }
.app .btn-lg           { height: 40px; padding: 0 20px; font-size: 16px; }
.app .btn-narrow       { height: 24px; padding: 0 10px; }
.app .btn-sm.btn-narrow{ height: 20px; padding: 0 8px;  }
.app .btn-lg.btn-narrow{ height: 32px; padding: 0 14px; }
.app .btn-avatar       { width: 30px; height: 30px; padding: 0; }

/* .pill - full pill radius. Generic modifier; composes with .btn, .badge, etc. */
.pill { border-radius: var(--r-pill); }

/* ──────────────────────────────────────────────────────────
 * INPUTS
 * .field wrapper + .label + .help-text + .error-msg.
 * .input / .textarea / .select / .checkbox / .radio / .toggle.
 * Modifier: .input-narrow (table-row inline edits, matches OptimAI scan tree).
 * State classes: .error (or [aria-invalid="true"]), [disabled].
 * Filled state (has value) uses :not(:placeholder-shown) for grey fill.
 * ────────────────────────────────────────────────────────── */

.field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.label {
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 12px;
  line-height: 18px;
  color: var(--c-body);
  text-transform: uppercase;
  letter-spacing: 1px;
}
.help-text { font-size: 12px; line-height: 18px; color: var(--c-mid); }
.error-msg { font-size: 12px; line-height: 18px; color: var(--c-red); font-weight: 500; }

.input,
.textarea,
.select {
  font-family: inherit;
  font-size: 16px;
  line-height: 24px;
  padding: 10px 12px;
  border: 1.5px solid var(--c-secondary);
  border-radius: var(--r-sm);
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
  color: var(--c-body);
  background: white;
}
.input::placeholder,
.textarea::placeholder { color: var(--c-mid); }

.input:hover:not(:disabled):not(:focus),
.textarea:hover:not(:disabled):not(:focus),
.select:hover:not(:disabled),
.combo-select-trigger:hover:not(:disabled):not(:focus) {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}
.input.error:hover:not(:disabled):not(:focus),
.textarea.error:hover:not(:disabled):not(:focus),
.select.error:hover:not(:disabled),
.combo-select.error:hover:not(:has(:disabled)) .combo-select-trigger {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-red) 18%, transparent);
}
.input:focus,
.textarea:focus,
.select:focus {
  border-color: var(--c-primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}

/* Filled (has content): grey fill. Detects via :not(:placeholder-shown) - so input must have a placeholder. */
.input:not(:placeholder-shown),
.textarea:not(:placeholder-shown) { background: var(--c-invert-2); }

/* Error state */
.input.error,
.textarea.error,
.select.error,
.input[aria-invalid="true"],
.textarea[aria-invalid="true"],
.select[aria-invalid="true"] {
  border-color: var(--c-red);
  color: var(--c-red);
  background-color: var(--c-red-fade);
}
.input.error:focus,
.textarea.error:focus,
.select.error:focus {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-red) 18%, transparent);
}

/* Disabled */
.input:disabled,
.textarea:disabled,
.select:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  background-color: var(--c-invert-2);
  color: var(--c-mid);
}

.textarea { min-height: 96px; resize: vertical; }

/* Select - filled triangle chevron + divider line + hover fill */
.select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 38px;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='%232A8A81'/%3E%3C/svg%3E"),
    linear-gradient(to right, transparent calc(100% - 38px), var(--c-invert-1) calc(100% - 38px), var(--c-invert-1) calc(100% - 37px), transparent calc(100% - 37px));
  background-repeat: no-repeat, no-repeat;
  background-position: right 12px center, 0 0;
  background-size: 14px 8px, 100% 100%;
  background-color: white;
  cursor: pointer;
}
.select.error {
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='%23DC2626'/%3E%3C/svg%3E"),
    linear-gradient(to right, transparent calc(100% - 38px), var(--c-red) calc(100% - 38px), var(--c-red) calc(100% - 37px), transparent calc(100% - 37px));
}
.select:hover {
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='white'/%3E%3C/svg%3E"),
    linear-gradient(to right, transparent calc(100% - 38px), var(--c-secondary) calc(100% - 38px));
  background-repeat: no-repeat, no-repeat;
  background-position: right 12px center, 0 0;
  background-size: 14px 8px, 100% 100%;
  background-color: white;
  border-color: var(--c-secondary);
}

/* Narrow variant - tight inline-table inputs (e.g. OptimAI site-audit page tree) */
.input.input-narrow {
  padding: 5px 9px;
  font-size: 12px;
  line-height: 18px;
  border-width: 1px;
  border-radius: 6px;
}
.input.input-narrow:focus {
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 10%, transparent);
}

/* Numeric input modifier - right-align, tabular nums, sensible default
   width cap. For currency, quota, counts, percentages. Use anywhere; in a
   .cell-num table column the right-align + tabular nums are inherited
   automatically (see .table .cell-num cascade in the Tables section), so
   this modifier is only needed for standalone numeric inputs. Width cap
   is overridable via inline style or a wrapping selector. */
.input.input-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  width: 6em;
  min-width: 0;
}

/* Custom checkbox + radio (native accent-color doesn't reliably re-evaluate
   on hover). Native input is restyled via appearance: none + ::before/::after. */
.checkbox,
.radio {
  appearance: none;
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  margin: 0;
  border: 1.5px solid var(--c-mid);
  background: white;
  cursor: pointer;
  position: relative;
  flex-shrink: 0;
  transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.checkbox { border-radius: 3px; }
.radio    { border-radius: 50%; }

.checkbox:checked {
  background-color: var(--c-primary);
  border-color: var(--c-primary);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M9 18 2.5 11.5 4.55 9.45 9 13.9 19.45 3.45 21.5 5.5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 16px 16px;
}
/* :indeterminate - shown when JS sets cb.indeterminate = true (e.g. tree
   tri-state cascade). Same primary teal fill as :checked but with a
   horizontal-bar glyph to read as "partial". */
.checkbox:indeterminate {
  background-color: var(--c-primary);
  border-color: var(--c-primary);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M5 11h14v2H5z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 16px 16px;
}
.radio:checked { border-color: var(--c-primary); }
.radio:checked::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--c-primary);
}

/* .choice - wrapper makes input + text the hover target. Halo appears on
   the widget itself. When checked, fill flips to secondary teal on hover. */
.choice {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}
.choice:has(input:disabled) { cursor: not-allowed; opacity: 0.55; }

.choice:hover .checkbox,
.checkbox:hover {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}
.choice:hover .radio,
.radio:hover {
  outline: 3px solid color-mix(in srgb, var(--c-primary) 12%, transparent);
  outline-offset: 0;
}
.choice:hover .checkbox:checked,
.checkbox:checked:hover,
.choice:hover .checkbox:indeterminate,
.checkbox:indeterminate:hover {
  background-color: var(--c-secondary);
  border-color: var(--c-secondary);
}
.choice:hover .radio:checked,
.radio:checked:hover { border-color: var(--c-secondary); }
.choice:hover .radio:checked::after,
.radio:checked:hover::after { background: var(--c-secondary); }
.choice:has(input:disabled):hover .checkbox,
.checkbox:disabled:hover { box-shadow: none; }
.choice:has(input:disabled):hover .radio,
.radio:disabled:hover { outline: 0; }

/* Toggle switch. Wrap a hidden checkbox; .toggle is the visible track. */
.toggle {
  position: relative;
  display: inline-block;
  width: 36px;
  height: 20px;
  background: var(--c-invert-1);
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.15s;
  flex-shrink: 0;
}
.toggle::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 16px;
  height: 16px;
  background: white;
  border-radius: 50%;
  transition: transform 0.15s;
  box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.toggle input { position: absolute; opacity: 0; width: 100%; height: 100%; cursor: pointer; margin: 0; }
.toggle:hover { box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent); }
.toggle:has(input:checked) { background: var(--c-primary); }
.toggle:has(input:checked):hover { background: var(--c-secondary); }
.toggle:has(input:checked)::after { transform: translateX(16px); }
.toggle:has(input:disabled) { opacity: 0.55; cursor: not-allowed; }
.toggle:has(input:disabled) input { cursor: not-allowed; }
.toggle:has(input:disabled):hover { box-shadow: none; }
.toggle:has(input:checked:disabled):hover { background: var(--c-primary); }

/* Narrow toggle - smaller dimensions for compact chrome contexts
   (footer tools, dense tables, etc). Same behaviour, scaled. */
.toggle.narrow { width: 28px; height: 16px; border-radius: 8px; }
.toggle.narrow::after { width: 12px; height: 12px; }
.toggle.narrow:has(input:checked)::after { transform: translateX(12px); }

/* App density - tighter inputs */
.app .input,
.app .textarea,
.app .select { font-size: 12px; line-height: 18px; padding: 6px 10px; }
.app .input.input-narrow { padding: 4px 8px; font-size: 12px; }

/* ──────────────────────────────────────────────────────────
 * .combo - input + toggle + custom-styled dropdown list.
 * Use when a styled dropdown matters or you need a filterable/contextual
 * list (history, keyword picker, suggestions). Native .select keeps the
 * OS dropdown for simple enum cases (accessibility comes free).
 * Lifted from OptimAI ss-keyword-combo pattern.
 * ────────────────────────────────────────────────────────── */
.combo {
  position: relative;
  display: flex;
  align-items: stretch;
  border-radius: var(--r-sm);
  transition: box-shadow 0.15s;
}
.combo:hover:not(:focus-within):not(:has(:disabled)) {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}
.combo.error:hover:not(:focus-within):not(:has(:disabled)) {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-red) 18%, transparent);
}
/* Suppress individual input/trigger hover halos when inside a .combo -
   the wrapper handles unified hover. Focus halo on inner element still applies. */
.combo .input:hover:not(:focus),
.combo-select-trigger:hover:not(:focus) {
  box-shadow: none;
}
/* Narrow variant - matches .input.input-narrow proportions for compact
   chrome / table-row inline edits. */
.combo-select.input-narrow .combo-select-trigger {
  padding: 5px 9px;
  font-size: 12px;
  line-height: 18px;
  height: auto;
  min-height: 0;
  border-width: 1px;
  border-radius: 6px;
}
.combo-select.input-narrow .combo-toggle { width: 24px; }
.combo .input { flex: 1 1 auto; padding-right: 36px; }
.combo-toggle {
  position: absolute;
  right: 1px;
  top: 1px;
  bottom: 1px;
  width: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 0;
  border-left: 1px solid var(--c-invert-1);
  cursor: pointer;
  padding: 0;
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  transition: background 0.15s;
}
.combo-toggle::after {
  content: '';
  display: block;
  width: 14px;
  height: 8px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='%232A8A81'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
}
.combo-toggle:hover { background: var(--c-secondary); }
.combo-toggle:hover::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='white'/%3E%3C/svg%3E");
}

.combo-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: 4px;
  background: white;
  border: 1px solid var(--c-invert-1);
  border-radius: var(--r-sm);
  box-shadow: 0 4px 12px rgba(0,0,0,0.08);
  max-height: 240px;
  overflow-y: auto;
  z-index: 100;
  padding: 4px;
}
.combo-dropdown.hidden { display: none; }

.combo-option {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 6px 8px;
  background: transparent;
  border: 0;
  border-radius: var(--r-sm);
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  font-size: 14px;
  line-height: 20px;
  color: var(--c-body);
}
.combo-option:hover { background: var(--c-invert-2); }
.combo-option[aria-selected="true"] { background: var(--c-invert-2); font-weight: 500; }

/* App density - tighter combo dropdown */
.app .combo-option { font-size: 12px; line-height: 18px; padding: 4px 8px; }

/* ──────────────────────────────────────────────────────────
 * CONTROLS - interactive UI (not form-input). Pagers, disclosure
 * arrows, sheet/popup closers, tabs etc. Lifted from OptimAI.
 * ────────────────────────────────────────────────────────── */

/* .pager - standard prev/info/next pagination control. */
.pager {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
}
.pager-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 0;
  border-radius: 50%;
  background: var(--c-primary);
  color: white;
  cursor: pointer;
  transition: background 0.15s, opacity 0.15s;
}
.pager-btn:hover:not(:disabled) { background: var(--c-secondary); }
.pager-btn:disabled { opacity: 0.3; cursor: default; }
.pager-btn svg { display: block; }

.pager-info {
  font-size: 14px;
  color: var(--c-mid);
  min-width: 80px;
  text-align: center;
}

.app .pager-btn { width: 32px; height: 32px; }
.app .pager-info { font-size: 12px; }

/* .back-to-top - circular up-arrow button. Default = solid primary teal
   filled circle with white icon; hover = white outline style (transparent
   bg, primary teal border + icon).
   POSITIONING (NOT applied here - set per page):
     - position: fixed; right: 12px; z-index: 95+ (must clear ALL sticky
       footers, which may stack to varying depths).
     - bottom: depends on number of sticky footers above the viewport
       baseline. Adjust per page (e.g. 80px on public pages, 120px in-app).
   .hidden modifier fades out + disables pointer events when not needed
   (typically toggled via scroll position). */
.back-to-top {
  /* Shape-only - in-doc demo buttons render as inline 36x36 circles.
     Fixed positioning is opted in via the #back-to-top ID (singleton). */
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid var(--c-primary);
  background: var(--c-primary);
  color: white;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.2s;
}
/* Singleton positioning - sits above site-footer (and page-footer when
   present, via the chrome var). ID selector so the in-doc demo buttons
   in the Controls section stay inline. */
#back-to-top {
  position: fixed;
  right: var(--s-4);
  bottom: calc(var(--site-footer-height, 0px) + var(--page-footer-height, 0px) + var(--s-4));
  z-index: 95;
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.2s, bottom 0.2s ease;
}
.back-to-top:hover {
  background: white;
  color: var(--c-primary);
  border-color: var(--c-primary);
}
.back-to-top svg { display: block; }
.back-to-top.hidden { opacity: 0; pointer-events: none; }

/* .notice-close - bare close-cross button. Used inside notification cards,
   notice panels, dismissible callouts. Parent component handles positioning
   (typically position:absolute top-right). Hover flips to red. */
.notice-close {
  background: none;
  border: 0;
  color: var(--c-body);
  font-size: 28px;
  line-height: 1;
  padding: 2px 8px;
  cursor: pointer;
  transition: color 0.15s;
}
.notice-close:hover { color: var(--c-red); }

/* .sheet-close - 40px circle close button for full-screen sheets / overlays
   (legal pages, modal dialogs, slide-overs). Stronger presence than
   .notice-close because it dismisses a whole layer.
   POSITIONING (set per page): position: fixed; top: 12px; right: 16px;
   z-index: 200+ to sit above sheet content. */
.sheet-close {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--c-invert-1);
  background: rgba(255, 255, 255, 0.92);
  color: var(--c-mid);
  font-size: 26px;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  transition: color 0.15s, background 0.15s;
}
.sheet-close:hover {
  color: var(--c-primary);
  background: color-mix(in srgb, var(--c-primary) 6%, white);
}

/* .info-btn + .info-tooltip - small "i" tooltip control. Clicking the
   info-btn shows a positioned panel with explanation text from data-info
   (and optional weighting from data-weight). Tooltip pops up below the
   button by default; flips to above if it would go off-screen.
   Lifted from OptimAI scorecard reports. */
.info-btn {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--c-invert-2);
  border: 0;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 0;
  transition: background 0.15s;
  vertical-align: middle;
}
.info-btn:hover { background: var(--c-primary); }
.info-btn span {
  font-family: 'Times New Roman', Times, serif;
  font-size: 12px;
  font-weight: 700;
  font-style: italic;
  color: var(--c-mid);
  line-height: 1;
}
.info-btn:hover span { color: white; }

.info-tooltip {
  position: fixed;
  z-index: 9999;
  background: white;
  border: 1px solid var(--c-invert-1);
  border-radius: var(--r-sm);
  padding: 14px 16px;
  max-width: 340px;
  font-size: 14px;
  line-height: 22px;
  color: var(--c-body);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.15s, transform 0.15s;
}
.info-tooltip.visible { opacity: 1; transform: translateY(0); }
.info-tooltip-title { font-weight: 500; display: block; margin-bottom: 4px; }
.info-tooltip-weight {
  display: block;
  font-size: 12px;
  color: var(--c-mid);
  margin-top: 8px;
}

/* .attention - amber getting-started nudge bubble. Anchored relative to a
   parent component (which must be position: relative). 4 directional
   variants: .up / .down / .left / .right - the direction is the side the
   chevron sits on, pointing AT the attached component.
   - .attention.up = bubble sits BELOW component, chevron at top
   - .attention.down = bubble sits ABOVE component, chevron at bottom
   - .attention.left = bubble sits to the RIGHT of component, chevron at left
   - .attention.right = bubble sits to the LEFT of component, chevron at right
   Bounces subtly in the chevron direction for 3s on first appearance.
   Dismiss behaviour (JS): hide when the attached component is interacted with. */
.attention {
  position: absolute;
  background: var(--c-amber);
  color: white;
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 14px;
  line-height: 20px;
  padding: 8px 12px;
  border-radius: var(--r-md);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  white-space: nowrap;
  z-index: 99;
  cursor: pointer;
}
.attention::after {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
}

.attention.up    { animation: attention-bounce-up    1s ease-in-out 3; }
.attention.down  { animation: attention-bounce-down  1s ease-in-out 3; }
.attention.left  { animation: attention-bounce-left  1s ease-in-out 3; }
.attention.right { animation: attention-bounce-right 1s ease-in-out 3; }

.attention.up::after {
  top: -6px; left: 50%; transform: translateX(-50%);
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-bottom: 6px solid var(--c-amber);
}
.attention.down::after {
  bottom: -6px; left: 50%; transform: translateX(-50%);
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top: 6px solid var(--c-amber);
}
.attention.left::after {
  left: -6px; top: 50%; transform: translateY(-50%);
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-right: 6px solid var(--c-amber);
}
.attention.right::after {
  right: -6px; top: 50%; transform: translateY(-50%);
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 6px solid var(--c-amber);
}

/* Keyframes preserve the centering translate (translateX/Y -50%) throughout
   the bounce so the bubble doesn't jump on animation end. */
@keyframes attention-bounce-up {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50%      { transform: translateX(-50%) translateY(3px); }
}
@keyframes attention-bounce-down {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50%      { transform: translateX(-50%) translateY(-3px); }
}
@keyframes attention-bounce-left {
  0%, 100% { transform: translateY(-50%) translateX(0); }
  50%      { transform: translateY(-50%) translateX(3px); }
}
@keyframes attention-bounce-right {
  0%, 100% { transform: translateY(-50%) translateX(0); }
  50%      { transform: translateY(-50%) translateX(-3px); }
}

/* .disclose - inline expand/collapse with rotating triangle marker.
   Built on native <details>/<summary> for accessibility (no JS needed).
   Triangle is secondary teal by default, rotates 90deg when open.
   Hover flips both label + triangle to primary teal. */
.disclose { border: 0; font-family: inherit; }
.disclose summary {
  font-size: 20px;
  line-height: 30px;
  font-weight: 500;
  color: var(--c-secondary);
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 6px;
  user-select: none;
  transition: color 0.15s;
}
.app .disclose summary { font-size: 16px; line-height: 24px; }
.disclose summary::-webkit-details-marker { display: none; }
.disclose summary::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 8px solid var(--c-secondary);
  transition: transform 0.15s, border-left-color 0.15s;
  flex-shrink: 0;
  margin-top: -2px;
}
.disclose[open] summary::before { transform: rotate(90deg); }
.disclose summary:hover { color: var(--c-primary); }
.disclose summary:hover::before { border-left-color: var(--c-primary); }

/* .max-min - 28px primary-teal circle plus/minus toggle. Built on
   <details>/<summary>. Plus-icon when collapsed, minus-icon when open,
   with 180deg "spin" transition. Used in FAQ accordions and any
   expand-to-reveal section needing more visual prominence than .disclose.
   Whole summary line is the click target. */
.max-min { border-bottom: 1px solid var(--c-invert-2); }
.max-min summary {
  padding: 16px 0;
  cursor: pointer;
  font-weight: 500;
  font-size: 16px;
  line-height: 24px;
  color: var(--c-body);
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.max-min summary::-webkit-details-marker { display: none; }
.max-min summary::after {
  content: '';
  display: flex;
  width: 28px;
  height: 28px;
  min-width: 28px;
  border-radius: 50%;
  background-color: var(--c-primary);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 18px 18px;
  transition: background-color 0.15s, transform 0.3s ease;
}
.max-min summary:hover::after { background-color: var(--c-secondary); }
.max-min[open] summary::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M19 13H5v-2h14v2z'/%3E%3C/svg%3E");
  transform: rotate(180deg);
}

/* .back-nav - on-page back navigation button. NON-STANDARD button colours
   (mid-grey icon, invert-1 border by default; primary teal icon, secondary
   teal border on hover). Use for "back to previous view" navigation that
   sits inline in headers, not as a primary form action.
   Typical placement: inline next to a page h1, functioning as a
   breadcrumb back to the last rendered page. */
.back-nav {
  background: none;
  border: 1px solid var(--c-invert-1);
  border-radius: 8px;
  cursor: pointer;
  padding: 6px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--c-mid);
  transition: border-color 0.15s, color 0.15s;
}
.back-nav:hover {
  border-color: var(--c-secondary);
  color: var(--c-primary);
}
.back-nav svg { display: block; }

/* .tabs / .tab - segmented control / page-section tabs.
   Wrapper has invert-2 fill; the active tab "lifts" with secondary teal
   bg + soft shadow. Lifted from OptimAI ss-tabs/ss-tab. */
.tabs {
  display: inline-flex;
  gap: 2px;
  background: var(--c-invert-2);
  border-radius: 8px;
  padding: 3px;
  position: relative;
}
.tab {
  padding: 6px 16px;
  border: 0;
  background: none;
  border-radius: 6px;
  cursor: pointer;
  font-family: inherit;
  font-size: 14px;
  line-height: 20px;
  font-weight: 500;
  color: var(--c-mid);
  transition: color 0.15s, background 0.15s, box-shadow 0.15s;
}
.tab:hover:not(.active) { color: var(--c-body); }
.tab.active {
  background: var(--c-secondary);
  color: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

/* [data-tab-panel] - paired panel element shown/hidden by td.js when its
   matching .tab[data-tab="X"] is clicked. Same scoped-.hidden pattern as
   .modal / .popover-menu / .combo-dropdown (component-scoped utility, not a
   general .hidden). Panels with no matching active tab on init should be
   marked .hidden in HTML so only the default panel renders before JS runs. */
[data-tab-panel].hidden { display: none; }

/* .combo-select - JS-enhanced native <select> replacement.
   Uses the same trigger styling as .select, dropdown styling as .combo. */
.combo-select-trigger {
  flex: 1 1 auto;
  font-family: inherit;
  font-size: 16px;
  line-height: 24px;
  padding: 10px 36px 10px 12px;
  border: 1.5px solid var(--c-secondary);
  border-radius: var(--r-sm);
  background-color: white;
  color: var(--c-body);
  text-align: left;
  cursor: pointer;
  outline: none;
  transition: border-color 0.15s, box-shadow 0.15s, background-color 0.15s;
}
.combo-select-trigger:focus,
.combo-select-trigger:focus-visible {
  border-color: var(--c-primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}
.combo-select-trigger:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  background-color: var(--c-invert-2);
  color: var(--c-mid);
}

.combo.error .combo-select-trigger {
  border-color: var(--c-red);
  color: var(--c-red);
  background-color: var(--c-red-fade);
}

/* Hover on the whole combo-select swaps the toggle area to secondary teal */
.combo-select:hover:not(:has(:disabled)) .combo-toggle { background: var(--c-secondary); }
.combo-select:hover:not(:has(:disabled)) .combo-toggle::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='white'/%3E%3C/svg%3E");
}
.combo-select:hover:not(:has(:disabled)) .combo-select-trigger { border-color: var(--c-secondary); }
.combo-select.error:hover:not(:has(:disabled)) .combo-toggle { background: var(--c-red); }
.combo-select.error:hover:not(:has(:disabled)) .combo-select-trigger { border-color: var(--c-red); }
.combo-select.error .combo-toggle::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='8' viewBox='0 0 14 8'%3E%3Cpath d='M0 0l7 8 7-8z' fill='%23DC2626'/%3E%3C/svg%3E");
}
.combo-select.error .combo-toggle { border-left-color: var(--c-red); }

.app .combo-select-trigger { font-size: 12px; line-height: 18px; padding: 6px 32px 6px 10px; }

/* ──────────────────────────────────────────────────────────
 * INPUTS - FUTURE / NOT YET BUILT
 * Build each in proper context with real app data, not in a vacuum.
 *   - Required-asterisk indicator on .label (cheap add)
 *   - [readonly] state (subtler than disabled - locked but visually present)
 *   - Input prefix/suffix (https://, %, $, kg)
 *   - Input with leading icon (search, currency)
 *   - type="search" (note: native browsers add a clear-X)
 *   - type="date" / type="time" (native pickers - need styling notes)
 *   - type="file" (custom-styled label-as-button pattern)
 *   - type="range" (sliders - separate component)
 *   - Multi-select (chip-list combo - design with real chips/tags first)
 * ────────────────────────────────────────────────────────── */

/* ──────────────────────────────────────────────────────────
 * blockquote / .pullquote (synonym) - emphasised quote block.
 * Subtle teal gradient bg, 4px secondary left border, oversized
 * serif opening quote glyph at head of first line. Optional <cite>
 * inside renders as small body-family attribution.
 * ────────────────────────────────────────────────────────── */
blockquote, .pullquote, .pq {
  margin: 24px 0;
  padding: 16px 24px;
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--c-secondary) 4%, transparent),
    color-mix(in srgb, var(--c-secondary) 8%, transparent)
  );
  border-left: 4px solid var(--c-secondary);
  border-radius: var(--r-sm);
  font-family: var(--ff-display);
  font-size: 24px;
  line-height: 36px;
  font-weight: 500;
  color: var(--c-secondary);
}
blockquote::before, .pullquote::before {
  content: '\201C';
  display: inline-block;
  font-family: Georgia, 'Times New Roman', serif;
  font-weight: 400;
  font-size: 96px;
  line-height: 0;
  vertical-align: calc(-0.4em - 8px);
  margin-right: 0.25rem;
  color: var(--c-secondary);
  opacity: 0.35;
}
blockquote cite, .pullquote cite {
  display: block;
  margin-top: 8px;
  font-family: var(--ff-body);
  font-size: 12px;
  line-height: 18px;
  font-style: normal;
  font-weight: 400;
  color: var(--c-mid);
}

/* ──────────────────────────────────────────────────────────
 * Scale-map row visualization.
 * ────────────────────────────────────────────────────────── */
.scale-row {
  float: left;
  width: 100%;
  margin-bottom: 20px;
}
.scale-row > * { float: left; }
.scale-row > .scale-line {
  height: 30px;
  width: 80px;
  margin-right: 20px;
  background: var(--c-secondary);
}
.scale-row > .scale-line > .scale-text {
  float: left;
  height: 20px;
  width: 80px;
  margin-top: 5px;
  background: var(--c-tertiary);
}

.scale-up5 { font-size: 40px; line-height: 60px; margin-bottom: 40px; }
.scale-up5 > .scale-line { height: 60px; }
.scale-up5 > .scale-line > .scale-text { height: 40px; margin-top: 10px; }

.scale-up4 { font-size: 36px; line-height: 54px; margin-bottom: 36px; }
.scale-up4 > .scale-line { height: 54px; }
.scale-up4 > .scale-line > .scale-text { height: 36px; margin-top: 9px; }

.scale-up3 { font-size: 32px; line-height: 48px; margin-bottom: 32px; }
.scale-up3 > .scale-line { height: 48px; }
.scale-up3 > .scale-line > .scale-text { height: 32px; margin-top: 8px; }

.scale-up2 { font-size: 28px; line-height: 42px; margin-bottom: 28px; }
.scale-up2 > .scale-line { height: 42px; }
.scale-up2 > .scale-line > .scale-text { height: 28px; margin-top: 7px; }

.scale-up1 { font-size: 24px; line-height: 36px; margin-bottom: 24px; }
.scale-up1 > .scale-line { height: 36px; }
.scale-up1 > .scale-line > .scale-text { height: 24px; margin-top: 6px; }

.scale-base { font-size: 20px; line-height: 30px; margin-bottom: 20px; }

.scale-down1 { font-size: 16px; line-height: 24px; margin-bottom: 16px; }
.scale-down1 > .scale-line { height: 24px; }
.scale-down1 > .scale-line > .scale-text { height: 16px; margin-top: 4px; }

.scale-down2 { font-size: 12px; line-height: 18px; margin-bottom: 12px; }
.scale-down2 > .scale-line { height: 18px; }
.scale-down2 > .scale-line > .scale-text { height: 12px; margin-top: 3px; }

.scale-down3 { font-size: 8px; line-height: 12px; margin-bottom: 8px; }
.scale-down3 > .scale-line { height: 12px; }
.scale-down3 > .scale-line > .scale-text { height: 8px; margin-top: 2px; }

/* .app shifts every scale step down by one. */
.app .scale-up5 { font-size: 36px; line-height: 54px; margin-bottom: 36px; }
.app .scale-up5 > .scale-line { height: 54px; }
.app .scale-up5 > .scale-line > .scale-text { height: 36px; margin-top: 9px; }

.app .scale-up4 { font-size: 32px; line-height: 48px; margin-bottom: 32px; }
.app .scale-up4 > .scale-line { height: 48px; }
.app .scale-up4 > .scale-line > .scale-text { height: 32px; margin-top: 8px; }

.app .scale-up3 { font-size: 28px; line-height: 42px; margin-bottom: 28px; }
.app .scale-up3 > .scale-line { height: 42px; }
.app .scale-up3 > .scale-line > .scale-text { height: 28px; margin-top: 7px; }

.app .scale-up2 { font-size: 24px; line-height: 36px; margin-bottom: 24px; }
.app .scale-up2 > .scale-line { height: 36px; }
.app .scale-up2 > .scale-line > .scale-text { height: 24px; margin-top: 6px; }

.app .scale-up1 { font-size: 20px; line-height: 30px; margin-bottom: 20px; }
.app .scale-up1 > .scale-line { height: 30px; }
.app .scale-up1 > .scale-line > .scale-text { height: 20px; margin-top: 5px; }

.app .scale-base { font-size: 16px; line-height: 24px; margin-bottom: 16px; }
.app .scale-base > .scale-line { height: 24px; }
.app .scale-base > .scale-line > .scale-text { height: 16px; margin-top: 4px; }

.app .scale-down1 { font-size: 12px; line-height: 18px; margin-bottom: 12px; }
.app .scale-down1 > .scale-line { height: 18px; }
.app .scale-down1 > .scale-line > .scale-text { height: 12px; margin-top: 3px; }

.app .scale-down2 { font-size: 8px; line-height: 12px; margin-bottom: 8px; }
.app .scale-down2 > .scale-line { height: 12px; }
.app .scale-down2 > .scale-line > .scale-text { height: 8px; margin-top: 2px; }

/* ──────────────────────────────────────────────────────────
 * INDICATORS - status / state markers.
 * Colour comes from --state-color (foreground accent), set by
 * a synonym class (.green/.success/.amber/.warning/.red/.error/
 * .neutral/.primary/.secondary/.tertiary). See rule 4.
 * ────────────────────────────────────────────────────────── */

/* .dot - 8px solid circle (6px in .app). Inline-block so it sits next to text.
 *
 * Purpose:    Tiny passive status marker. Pure visual signal, no text.
 * Use when:   Binding a state colour to a row, list item, or label.
 *             Status-at-a-glance in dense UI (sidebar nav, inline lists,
 *             message rows).
 * Avoid for:  Standalone use without an adjacent label (illegible meaning);
 *             counts (use bubble); classifications (use badge).
 * Pairs with: Row labels, list items, inline headings, table-cell prefixes.
 */
.dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--state-color, var(--c-mid));
  flex-shrink: 0;
  vertical-align: middle;
}
.app .dot { width: 6px; height: 6px; }

/* .tick / .cross - inline mark icons for hit/miss / gained/lost columns
   in tables, score lists, citation reports. Compose with synonym classes
   (.hit, .gained = green by default; .miss, .lost = red by default) or
   any other state synonym.
   Two visual styles:
     default:  text glyph (\2713 / \2715)
     .circle:  Material Symbols Rounded filled circle icon
               (check_circle / cancel) for outline-style table cells
   Optional inline label e.g. <span class="cell-label gained">gained</span>
   for the small coloured word adjacent to the circle.
 *
 * Purpose:    Discrete binary outcome marker.
 * Use when:   Communicating a per-item pass/fail, hit/miss, or
 *             gained/lost in dense table cells or summary rows.
 * Avoid for:  Quantities (use bubble), categorical state (use badge),
 *             ambient row state (use dot).
 * Pairs with: Table cells, grid rows, scorecard checklists.
 */
/* :not(li) scope keeps these rules from clobbering the list-item
   markers in base.css (li.tick / li.cross use list-style-type glyphs). */
.tick:not(li), .cross:not(li) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  font-size: 16px;
  font-weight: 700;
  line-height: 1;
  color: var(--state-color, var(--c-mid));
  vertical-align: middle;
}
.tick:not(li)::before  { content: '\2713'; }
.cross:not(li)::before { content: '\2715'; }
/* Sizes (text glyph variants) - on type scale 16/20/24/32. */
.tick.lg:not(li),  .cross.lg:not(li)  { width: 20px; height: 20px; font-size: 20px; }
.tick.xl:not(li),  .cross.xl:not(li)  { width: 24px; height: 24px; font-size: 24px; }
.tick.xxl:not(li), .cross.xxl:not(li) { width: 32px; height: 32px; font-size: 32px; }

/* .circle variant - swap the text glyph for a Material Symbols circle
   icon. Reads as a more deliberate "result" marker rather than an
   inline checkmark.
   Default = filled (FILL 1). Add .outline for the unfilled version
   (FILL 0) - matches OptimAI's exec citation table cell style.
   TODO when styleguide ships: subset Material Symbols to just the
   icons we use (check_circle, cancel, trending_up/down/flat) or
   convert to SVG to avoid loading the full font family. */
.tick.circle:not(li)::before, .cross.circle:not(li)::before {
  font-family: 'Material Symbols Rounded';
  font-weight: 400;
  font-size: inherit;
  line-height: 1;
  font-variation-settings: 'FILL' 1;
  -webkit-font-feature-settings: 'liga';
}
.tick.circle.outline:not(li)::before,
.cross.circle.outline:not(li)::before { font-variation-settings: 'FILL' 0; }
/* No size override on .circle - inherits from base .tick/.cross
   (16px default), and from .lg/.xl/.xxl when those are also applied. */
.tick.circle:not(li)::before  { content: 'check_circle'; }
.cross.circle:not(li)::before { content: 'cancel'; }
/* Sizes (circle variant) inherit from the size modifier on the host. */

/* .cell-label - small coloured word label that pairs with .tick.circle
   or .cross.circle in citation/result table cells (e.g. "gained" / "lost"
   under or beside the icon). Composes with synonym classes for colour. */
.cell-label {
  display: inline-block;
  font-size: 10px;
  line-height: 1;
  font-weight: 500;
  color: var(--state-color, var(--c-mid));
  vertical-align: middle;
}

/* .trend - directional change indicator. Two visual styles:
 *   .trend (default):  arrow glyph + value label (e.g. "+12%")
 *   .trend.spark:      Material Symbols trending_up/down/flat glyph
 *                      (filled). The "trend-icon" style from OptimAI's
 *                      exec summary citation tables.
 * Default = grey/.flat; add .up (green) or .down (red) to direct, or any
 * synonym (e.g. .amber) for semantic override.
 * Sizes: .trend (default), .trend.lg (hero/card headers), .trend.xl
 * (oversized stat tiles).
 *
 * Purpose:    Compact directional change between two reference points.
 * Use when:   Showing whether a metric improved/declined since a prior
 *             scan/period. Standalone in a cell or alongside a value.
 * Avoid for:  Absolute state (use badge), full chart context (use line
 *             chart), pure counts (use bubble).
 * Pairs with: Metric rows, exec summary cards, citation tables.
 */
.trend {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--ff-body);
  font-size: 14px;
  font-weight: 500;
  line-height: 1;
  color: var(--state-color, var(--c-mid));
  vertical-align: middle;
}
.trend::before {
  display: inline-block;
  font-size: 16px;
  line-height: 1;
}
/* Default trend (no .up/.down) renders a flat right-arrow in grey. */
.trend::before      { content: '\2192'; }
.trend.up::before   { content: '\2191'; }
.trend.down::before { content: '\2193'; }
/* Size variants for the arrow style. */
.trend.lg          { font-size: 16px; gap: 6px; }
.trend.lg::before  { font-size: 20px; }
.trend.xl          { font-size: 20px; gap: 6px; }
.trend.xl::before  { font-size: 28px; }

/* Spark variant - swap arrow for Material Symbols trending_* filled glyph.
   Used in dense table cells where the glyph reads as a micro-sparkline.
   Sizes follow the type scale: 16 / 20 / 24 / 32. */
.trend.spark::before {
  font-family: 'Material Symbols Rounded';
  font-weight: 400;
  font-variation-settings: 'FILL' 1;
  -webkit-font-feature-settings: 'liga';
  content: 'trending_flat';
  font-size: 16px;
}
.trend.spark.up::before   { content: 'trending_up'; }
.trend.spark.down::before { content: 'trending_down'; }
.trend.spark.lg::before   { font-size: 20px; }
.trend.spark.xl::before   { font-size: 24px; }
.trend.spark.xxl::before  { font-size: 32px; }

/* .cell-mark - wrapper for tick/cross + cell-label combinations in
   table cells. Default = inline row (gap 4px). Add .stack to flow the
   label beneath the glyph (used in narrow cells where horizontal space
   is tight, e.g. exec citation table per-engine columns). */
.cell-mark {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
.cell-mark.stack {
  flex-direction: column;
  gap: 2px;
}

/* .blink - discrete on/off (warning lights, alerts).
   .throb - smooth pulse via scale + opacity (notifications, "live" pings). */
@keyframes dot-blink {
  0%, 49.99% { opacity: 1; }
  50%, 100%  { opacity: 0; }
}
@keyframes dot-throb {
  0%, 100% { transform: scale(1);   opacity: 1; }
  50%      { transform: scale(1.5); opacity: 0.55; }
}
.dot.blink { animation: dot-blink 1s steps(1, end) infinite; }
.dot.throb { animation: dot-throb 1.6s ease-in-out infinite; }

/* .badge - rectangular text label. Filled/solid by default (state-color
   bg, white text); .outline variant flips to fade-tinted bg with matching
   coloured border + text. Always uppercase + bold + tight letter-spacing.
 *
 * Purpose:    Categorical STATE label from a finite enumerated set.
 * Use when:   Communicating one of a small number of discrete states for
 *             an entity - "Status: LIVE", "Plan: PRO", "Mode: EDIT".
 *             Stable property that wouldn't surprise a returning user.
 * Avoid for:  Variable values or properties (use chip), pure counts (use
 *             bubble), one-shot attention nudges (use flag), passive row
 *             markers (use dot).
 * Pairs with: Entity headings, table cells, list-row metadata.
 */
.badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: var(--r-sm);
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 700;
  line-height: 1.4;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  white-space: nowrap;
  background: var(--state-color, var(--c-mid));
  color: white;
  border: 1px solid transparent;
}
.badge.outline {
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
  border-color: var(--state-color, var(--c-mid));
}
/* .small variant - tighter type + padding. Pattern break: .app keeps the
   same sizes as default (no shrink), so use .badge.small explicitly when
   you want the smaller variant in either context. */
.badge.small { font-size: 10px; padding: 1px 6px; letter-spacing: 0.4px; }

/* .chip - pill-shaped value display. Same dimensions as .badge but
   pill-rounded, natural case, normal weight. Default = solid state-color
   bg + white text. .fade variant = fade bg + state-color text (NO border
   - distinct from badge.outline). Numeric component (use <strong> or
   .num) gets body-strong weight (500). Same .app counter-pattern as
   badge: no shrink.
   FUTURE: may add .split-color variant where numeric uses a different
   colour from text - leave hook open.
 *
 * Purpose:    Variable PROPERTY, quality, or value of an entity. Often a
 *             tag-with-count, metric, or item from a set.
 * Use when:   Displaying properties that vary across entities or
 *             instances - "Sector: Healthcare", "Posts: 42", "Score: 87",
 *             "Tags: ai, ml, vision". Often interactive (filter chips,
 *             drill-down counters).
 * Avoid for:  Discrete enumerated state (use badge), pure counts without
 *             a label (use bubble), prose attention (use flag), passive
 *             list markers (use dot).
 * Pairs with: Filter bars, metric blocks, table cells, inline body copy,
 *             taxonomical tag lists.
 */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  border: 1px solid var(--c-invert-2);
  border-radius: var(--r-pill);
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 400;
  line-height: 1.4;
  white-space: nowrap;
  background: var(--state-color, var(--c-mid));
  color: white;
  vertical-align: 2px;
}
.chip.fade {
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
}
.chip.small { font-size: 10px; padding: 1px 6px; }
.chip strong, .chip .num { font-weight: 500; }

/* Chip hover - inverts variant (solid <-> fade) and adds a state-aware
   halo (theme teals + neutral → primary teal halo; green/amber/red →
   matching colour halo). Apply where chip is clickable; static chips
   keep the hover but consumers can suppress with `pointer-events: none`. */
.chip {
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.chip:hover {
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--state-halo, var(--c-primary)) 12%, transparent);
}
.chip.fade:hover {
  background: var(--state-color, var(--c-mid));
  color: white;
}

/* .bubble - circular numeric counter. Outline default uses fade-tinted
   bg + state-color border + state-color text. .solid variant flips to
   state-color bg + white text. Three sizes (.small / default / .large).
   Sized to comfortably fit up to 3 digits (max 999). Heavier weight (500)
   matching body <strong>. .app shifts each size one step down per the
   type-scale hierarchy.
 *
 * Purpose:    Pure numeric COUNT. Maximum 3 digits (cap at "999+" if
 *             higher counts possible).
 * Use when:   Surfacing a count of related items - "12 unread",
 *             "5 errors", "999+ notifications". Always bound to an entity
 *             that owns the count.
 * Avoid for:  Counts paired with a label (use chip with <strong> child),
 *             discrete state (use badge), text content (use chip).
 * Pairs with: Nav items, tabs, toolbar buttons, list-item suffixes,
 *             notification bells.
 */
.bubble {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1.5px solid var(--state-color, var(--c-mid));
  border-radius: var(--r-pill);
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
  font-family: var(--ff-body);
  font-size: 16px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  vertical-align: middle;
}
.bubble.solid {
  background: var(--state-color, var(--c-mid));
  color: white;
}
.bubble.small { width: 32px; height: 32px; font-size: 12px; }
.bubble.large { width: 48px; height: 48px; font-size: 20px; }

.app .bubble       { width: 32px; height: 32px; font-size: 12px; }
.app .bubble.small { width: 24px; height: 24px; font-size: 8px; }
.app .bubble.large { width: 40px; height: 40px; font-size: 16px; }

/* Bubble hover - inverts variant (outline <-> solid) and adds the same
   state-aware halo as chips. */
.bubble {
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.bubble:hover {
  background: var(--state-color, var(--c-mid));
  color: white;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--state-halo, var(--c-primary)) 16%, transparent);
}
.bubble.solid:hover {
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
}

/* .flag - pill-shaped attention grabber. Fade-tinted bg, invert-2 border,
   uppercase + wide letter-spacing. Larger than .badge. Designed to sit
   inline with text/headings/labels. May contain a leading .dot and/or a
   trailing .info-btn.
 *
 * Purpose:    Contextual ATTENTION nudge in flowing text or alongside
 *             headings. Ephemeral, decorative emphasis with semantic
 *             colour.
 * Use when:   Drawing the eye to ad-hoc context - "LIVE" next to a stream
 *             title, "BETA" next to a feature label, "PENDING" annotation
 *             on a list header. Sits inline; not a structural element.
 * Avoid for:  Stable enumerated state (use badge), variable property
 *             values (use chip), table cell content (use chip or text),
 *             pure counts (use bubble).
 * Pairs with: Headings, label rows, paragraph leads, footer copy.
 */
.flag {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 28px;
  padding: 0 12px;
  border: 1px solid var(--c-invert-2);
  font-family: var(--ff-body);
  font-size: 16px;
  font-weight: 500;
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: 1px;
  border-radius: var(--r-pill);
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
  white-space: nowrap;
  vertical-align: middle;
}
.flag.small { height: 22px; padding: 0 10px; font-size: 12px; letter-spacing: 0.6px; }
.app .flag { height: 22px; padding: 0 10px; font-size: 12px; letter-spacing: 0.6px; }
.app .flag.small { height: 16px; padding: 2px 6px 0; font-size: 8px; letter-spacing: 0.4px; }
/* Info-btn inside a flag uses the standard 18px grey circle as-is. */

/* ──────────────────────────────────────────────────────────
 * CHARTS
 * Charts use D3 v7 (loaded from CDN) for non-trivial work (line, area,
 * treemap, bubble) and pure SVG / CSS for simpler primitives (gauge,
 * hero bars). Charts do NOT scale between default and .app contexts -
 * their size is controlled by explicit size modifiers (.sm / default /
 * .lg / .xl) following the 4px micro-grid.
 *
 * TODO before ship: subset Material Symbols icons, decide whether to
 * self-host D3 or stay on the d3js.org CDN.
 * ────────────────────────────────────────────────────────── */

/* .gauge - circular score indicator. Pure SVG. State-fade fill inside,
   invert-2 track ring, state-color arc proportional to data-score
   (0-100). Score number rendered as <text> in the centre. JS at the
   bottom of td_styleguide.html hydrates dashoffset on page load.
 *
 * Purpose:    Single-value 0-100 score visualisation.
 * Use when:   Showing an overall score, completion percentage, or
 *             readiness rating where a circle conveys "out of 100".
 * Avoid for:  Multi-value comparisons (use bar chart), trends over
 *             time (use line/sparkline), counts (use bubble).
 * Pairs with: Top-of-report headers, scorecard tiles, exec summaries.
 *
 * Markup:
 *   <svg class="gauge primary" data-score="87" viewBox="0 0 100 100">
 *     <circle cx="50" cy="50" r="40" class="gauge-bg"/>
 *     <circle cx="50" cy="50" r="44" class="gauge-track"/>
 *     <circle cx="50" cy="50" r="44" class="gauge-arc"/>
 *     <text x="50" y="50" class="gauge-label">87</text>
 *   </svg>
 *
 * Optional: data-label="A+" overrides the score number with a string.
 */
.gauge {
  display: inline-block;
  width: 96px;
  height: 96px;
  vertical-align: middle;
  border-radius: 50%;
  cursor: pointer;
  transition: box-shadow 0.15s;
}
/* Same hover halo as .bubble: 3px / 16% mix of --state-halo. Suppress on
   static (non-clickable) instances with `pointer-events: none`. */
.gauge:hover {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--state-halo, var(--c-primary)) 16%, transparent);
}
.gauge.sm { width: 64px;  height: 64px;  }
.gauge.lg { width: 128px; height: 128px; }
.gauge.xl { width: 160px; height: 160px; }

.gauge-bg    { fill: var(--state-fade, var(--c-invert-2)); }
.gauge-track { fill: none; stroke: var(--c-invert-2); stroke-width: 8; }
.gauge-arc {
  fill: none;
  stroke: var(--state-color, var(--c-mid));
  stroke-width: 8;
  stroke-linecap: round;
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  /* dasharray + dashoffset set by JS based on data-score */
  transition: stroke-dashoffset 0.4s ease-out;
}
.gauge-label {
  fill: var(--c-body);
  font-family: var(--ff-body);
  font-weight: 500;
  font-size: 28px;
  text-anchor: middle;
  dominant-baseline: central;
}

/* .hero-bars / .hero-bar - horizontal data bars with optional dashed
   threshold markers (typically 40% and 70%, the rating thresholds).
   Default orientation is horizontal (label left, fill right). Add
   .vertical to .hero-bars for column orientation (label below, fill
   bottom-up). Fill colour from --state-color.
 *
 * Purpose:    Single-axis comparison of multiple values against a
 *             0-100 scale, with optional pass/warn thresholds.
 * Use when:   Showing per-dimension scores in a hero/header (e.g.
 *             OptimAI report top), or compact period-comparison bars.
 * Avoid for:  Trends over time (use line / sparkline), counts (bubble),
 *             nested/composition (stacked bars - separate component).
 * Pairs with: Report headers, exec summary breakdowns, scorecards.
 *
 * Markup (horizontal):
 *   <div class="hero-bars">
 *     <div class="hero-bar">
 *       <span class="hero-bar-label">Schema</span>
 *       <div class="hero-bar-track">
 *         <div class="hero-bar-threshold" style="left:40%"></div>
 *         <div class="hero-bar-threshold" style="left:70%"></div>
 *         <div class="hero-bar-fill green" style="width:82%">82</div>
 *       </div>
 *     </div>
 *   </div>
 *
 * Vertical: add .vertical to .hero-bars; swap left/width for bottom/height.
 */
.hero-bars {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.hero-bar {
  display: flex;
  align-items: center;
  gap: 8px;
}
.hero-bar-label {
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 500;
  color: var(--c-body);
  width: 120px;
  flex-shrink: 0;
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.hero-bar-track {
  flex: 1;
  height: 20px;
  background: var(--c-invert-2);
  border-radius: var(--r-sm);
  position: relative;
  overflow: visible;
}
.hero-bar-fill {
  height: 100%;
  border-radius: var(--r-sm);
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding: 0 8px;
  background: var(--state-color, var(--c-mid));
  color: white;
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 700;
  white-space: nowrap;
  transition: width 0.6s ease-out, height 0.6s ease-out;
  box-sizing: border-box;
}
/* Low-score pattern (lifted from OptimAI): when the fill would be too
   narrow to fit the score number inside (typically score < 15), render
   the fill empty and place the score in the TRACK with
   .hero-bar-score-outside, positioned via inline `style="left:8%"`
   immediately past the fill end. Consumer toggles between the two
   patterns based on a score threshold. */
.hero-bar-score-outside {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  padding-left: 6px;
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 700;
  color: var(--c-body);
  white-space: nowrap;
  z-index: 2;
}
.hero-bars.vertical .hero-bar-score-outside {
  top: auto;
  left: 50%;
  transform: translateX(-50%);
  padding-left: 0;
  padding-bottom: 4px;
}
.hero-bar-threshold {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 0;
  border-right: 1px dashed var(--c-invert-1);
  pointer-events: none;
  z-index: 1;
}

/* Vertical orientation - bars rise from a baseline. Track flows column,
   fill positioned at the bottom growing upward, threshold lines run
   horizontally. Use style="bottom:40%" on thresholds and
   style="height:82%" on fill. */
.hero-bars.vertical {
  flex-direction: row;
  align-items: flex-end;
  gap: 24px;
}
.hero-bars.vertical .hero-bar {
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.hero-bars.vertical .hero-bar-track {
  flex: 0 0 auto;
  width: 32px;
  height: 200px;
  position: relative;
}
.hero-bars.vertical .hero-bar-fill {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  width: 100%;
  height: 50%; /* overridden inline */
  border-radius: var(--r-sm);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 4px 0 0;
  min-height: 22px;
  min-width: 0;
}
.hero-bars.vertical .hero-bar-threshold {
  left: 0;
  right: 0;
  top: auto;
  bottom: 50%; /* overridden inline */
  width: 100%;
  height: 0;
  border-right: 0;
  border-top: 1px dashed var(--c-invert-1);
}
.hero-bars.vertical .hero-bar-label {
  width: auto;
  max-width: 80px;
  text-align: center;
}

/* .sparkline - inline SVG mini line chart for compact trend display.
   Sizes follow OptimAI defaults so cells port 1:1: .sm 64x20 (table
   cells), default 200x40, .lg 300x80 (tile / hero contexts).
   Two visual styles:
     default (bare): just the polyline (state-color stroke)
     .fill:          adds a state-fade area path below the line
   Stroke uses currentColor so the colour synonym sets it via
   .sparkline.green / .amber / etc. Non-scaling stroke keeps the line
   crisp at any size.
 *
 * Purpose:    Compact trend over a series of values.
 * Use when:   Showing recent direction of a metric in a table cell,
 *             tile, or alongside a value (citation history rows,
 *             portfolio score timelines).
 * Avoid for:  Single-direction summary (use .trend.spark glyph),
 *             single value (use bubble/chip), full multi-series chart
 *             (use line chart with axes).
 * Pairs with: Table cells, tile headers, scorecards.
 *
 * Markup:
 *   <svg class="sparkline green" data-points="3,5,4,8,12,9,15,11"
 *        viewBox="0 0 200 40" preserveAspectRatio="none">
 *     <path     class="sparkline-fill"/>
 *     <polyline class="sparkline-line"/>
 *   </svg>
 *
 * JS at bottom of td_styleguide.html hydrates points + fill path on
 * page load. data-points is a comma-separated list of numbers.
 */
.sparkline {
  display: inline-block;
  width: 200px;
  height: 40px;
  vertical-align: middle;
  color: var(--state-color, var(--c-mid));
}
.sparkline.sm { width: 64px;  height: 20px; }
.sparkline.lg { width: 300px; height: 80px; }

.sparkline-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.sparkline-fill { display: none; }
.sparkline.fill .sparkline-fill {
  display: block;
  fill: var(--state-fade, var(--c-invert-2));
  stroke: none;
}
/* Invert-2 baseline rule along the bottom of the .fill variant - anchors
   the area visually so adjacent green / amber tints don't blur into each
   other when stacked. Inset 0.5px so it sits just inside the bottom edge. */
.sparkline.fill .sparkline-baseline {
  display: block;
  stroke: var(--c-invert-1);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
.sparkline:not(.fill) .sparkline-baseline { display: none; }

/* .heatmap - D3 treemap visualisation. Proportional rectangles sized
   by weight, coloured by score (green/amber/red bands at 70/40 thresholds).
   Lifted from OptimAI's report-top dimension breakdown. JS at the bottom
   of td_styleguide.html populates the SVG via D3 v7.
 *
 * Purpose:    Compact at-a-glance breakdown of a weighted score across
 *             many dimensions, where rectangle SIZE = weight and
 *             rectangle COLOUR = score.
 * Use when:   Multi-dimension scorecards, weighted category breakdowns,
 *             where reading both magnitude (size) and quality (colour)
 *             at the same time is useful.
 * Avoid for:  Time series (use line/sparkline), simple counts (bubble),
 *             single-axis comparison (hero bars).
 * Pairs with: Report headers, exec summary tiles, drill-down launchers.
 *
 * Markup: <div class="heatmap" id="my-heatmap"></div>  (JS injects SVG)
 */
.heatmap {
  width: 100%;
  margin-bottom: 24px;
}
.heatmap svg { display: block; width: 100%; height: auto; }
.heatmap .group-hdr {
  font-family: var(--ff-body);
  font-size: 11px;
  font-weight: 600;
  fill: var(--c-mid);
}
.heatmap .leaf rect {
  stroke: rgba(255,255,255,0.5);
  stroke-width: 1;
  rx: 2;
  cursor: pointer;
  transition: stroke 0.15s, stroke-width 0.15s;
}
.heatmap .leaf:hover rect {
  stroke: var(--c-secondary);
  stroke-width: 2;
}
.heatmap .leaf-label {
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 500;
  fill: white;
  pointer-events: none;
}
/* Score is the dominant value in each tile - significantly larger than the
   label and weight badge. JS sets a size class (.score-sm/.score-md/
   .score-lg) based on cell dimensions. */
.heatmap .leaf-score {
  font-family: var(--ff-body);
  font-weight: 700;
  fill: white;
  pointer-events: none;
}
.heatmap .leaf-score.score-sm { font-size: 24px; }
.heatmap .leaf-score.score-md { font-size: 32px; }
.heatmap .leaf-score.score-lg { font-size: 40px; }
/* Weight badge - small white % in the bottom-right corner of each tile. */
.heatmap .leaf-weight {
  font-family: var(--ff-body);
  font-size: 11px;
  font-weight: 700;
  fill: white;
  fill-opacity: 0.75;
  pointer-events: none;
}

/* .line-chart - SVG line chart for time series. JS-rendered. Composable
   modifiers determine what's drawn on top of the base line + dots:
     .fill    - state-fade area below the line
     .tracks  - green/amber/red zone bands at 70/40 thresholds with
                dashed grid lines (lifted from OptimAI history panel)
     .axes    - Y-axis tick labels (0/40/70/100) + X-axis date labels at
                first + last points (exec summary improvement tracking)
   Combine freely (e.g. .line-chart.tracks.fill.axes for the full-feature
   exec summary look).
 *
 * Purpose:    Time-series of a 0-100 score with optional thresholds.
 * Use when:   Showing how a value moves over time with semantic context
 *             (good/fair/poor zones), e.g. portfolio score history,
 *             single-page score timeline.
 * Avoid for:  Many series at once (use multi-series chart, separate
 *             component), single-direction summary (sparkline / trend),
 *             non-temporal categorical data (use bar chart).
 * Pairs with: History panels, exec summary cards, scorecard timelines.
 *
 * Markup: <div class="line-chart [.fill] [.tracks] [.axes]"
 *              id="my-line-chart"></div>
 *
 * JS hydration uses a sample dataset by default; override via
 * window.__LINE_DATA[id] = [{date, value}, ...] before page load.
 */
.line-chart {
  width: 100%;
  margin-bottom: 24px;
}
.line-chart svg { display: block; width: 100%; height: auto; }

.line-chart .lc-line {
  fill: none;
  stroke: var(--c-primary);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.line-chart .lc-fill {
  fill: var(--c-primary);
  fill-opacity: 0.10;
  stroke: none;
}
/* When fill is overlaid on tracks the combined opacity reads muddy.
   Drop fill opacity further for the combo so the zone bands stay clean. */
.line-chart.fill.tracks .lc-fill { fill-opacity: 0.04; }
.line-chart .lc-track-green { fill: var(--c-green-fade); fill-opacity: 0.6; }
.line-chart .lc-track-amber { fill: var(--c-amber-fade); fill-opacity: 0.6; }
.line-chart .lc-track-red   { fill: var(--c-red-fade);   fill-opacity: 0.6; }
.line-chart .lc-threshold {
  stroke: var(--c-invert-1);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  fill: none;
}
.line-chart .lc-border {
  stroke: var(--c-invert-2);
  stroke-width: 1;
  fill: none;
}
.line-chart .lc-axis-label {
  font-family: var(--ff-body);
  font-size: 10px;
  fill: var(--c-mid);
}
.line-chart .lc-axis-value {
  font-family: var(--ff-body);
  font-size: 10px;
  font-weight: 500;
  fill: var(--c-mid);
}
.line-chart .lc-dot {
  stroke: white;
  stroke-width: 1.5;
}
.line-chart .lc-dot.green { fill: var(--c-green); }
.line-chart .lc-dot.amber { fill: var(--c-amber); }
.line-chart .lc-dot.red   { fill: var(--c-red); }
.line-chart .lc-last-label {
  font-family: var(--ff-body);
  font-size: 11px;
  font-weight: 700;
}
.line-chart .lc-last-label.green { fill: var(--c-green); }
.line-chart .lc-last-label.amber { fill: var(--c-amber); }
.line-chart .lc-last-label.red   { fill: var(--c-red); }

/* .bubble-chart - multi-row dot timeline. Lifted from OptimAI's AI search
   visibility "Citation History: Best of Day" pattern. Each row is an
   engine; each column is a day; dot size scales with citation count;
   colour signals cited (green) vs miss (red, faded). Click any dot to
   see a tooltip with date + count + scan source.
 *
 * Purpose:    Sparse multi-series binary/count timeline where most cells
 *             are zero or low - bubbles let large values "punch through"
 *             without losing the empty cells in the grid.
 * Use when:   Citation tracking, attendance, hit/miss-with-magnitude grids
 *             across multiple sources over time.
 * Avoid for:  Continuous time series (use line chart), single-series
 *             counts (use sequential bar chart).
 * Pairs with: Citation history panels, multi-engine dashboards.
 *
 * Markup: <div class="bubble-chart" id="my-bubble"></div>
 * JS uses a sample dataset; override via window.__BUBBLE_DATA[id] = {
 *   engines: ['Google', 'OpenAI', 'Perplexity'],
 *   days: [{ date: '2026-04-25',
 *            citations: { Google: 0, OpenAI: 2, Perplexity: 1 },
 *            scans: 1, scanLabel: '25 Apr 12:34' }, ...]
 * };
 */
.bubble-chart {
  width: 100%;
  margin-bottom: 24px;
  position: relative;
}
.bubble-chart svg { display: block; width: 100%; height: auto; }

.bubble-chart .bc-row-line {
  stroke: var(--c-invert-2);
  stroke-width: 1;
}
.bubble-chart .bc-row-line.dashed { stroke-dasharray: 3 3; }
.bubble-chart .bc-row-label {
  font-family: var(--ff-body);
  font-size: 11px;
  font-weight: 500;
  fill: var(--c-body);
}
.bubble-chart .bc-row-label.scans { fill: var(--c-secondary); font-weight: 600; }
.bubble-chart .bc-axis-label {
  font-family: var(--ff-body);
  font-size: 9px;
  fill: var(--c-mid);
}
.bubble-chart .bc-bubble {
  stroke: white;
  stroke-width: 1.5;
  cursor: pointer;
  transition: stroke 0.15s, stroke-width 0.15s;
}
.bubble-chart .bc-bubble:hover {
  stroke: var(--c-secondary);
  stroke-width: 2;
}
.bubble-chart .bc-bubble.cited { fill: var(--c-green); }
.bubble-chart .bc-bubble.miss  { fill: var(--c-red); fill-opacity: 0.4; }
/* Scan bubble = tertiary chip-fade pattern: pale tertiary fill + invert-2
   border + tertiary text inside. */
.bubble-chart .bc-bubble.scan {
  fill: color-mix(in srgb, var(--c-tertiary) 16%, white);
  stroke: var(--c-invert-2);
}
.bubble-chart .bc-bubble-label {
  font-family: var(--ff-body);
  font-size: 9px;
  font-weight: 700;
  fill: white;
  pointer-events: none;
  text-anchor: middle;
  dominant-baseline: central;
}
.bubble-chart .bc-bubble-label.scan { fill: var(--c-tertiary); }

/* Tooltip - HTML element appended to the chart container, absolute.
   Light-themed to match the standard .info-tooltip pattern. */
.bubble-chart .bc-tip {
  position: absolute;
  background: white;
  border: 1px solid var(--c-invert-1);
  border-radius: var(--r-sm);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  padding: 10px 14px;
  font-family: var(--ff-body);
  font-size: 13px;
  line-height: 1.5;
  color: var(--c-body);
  white-space: nowrap;
  opacity: 0;
  transform: translate(-50%, calc(-100% - 8px));
  transition: opacity 0.15s;
  pointer-events: none;
  z-index: 100;
}
.bubble-chart .bc-tip.visible { opacity: 1; pointer-events: auto; }
.bubble-chart .bc-tip strong { font-weight: 500; color: var(--c-body); }
.bubble-chart .bc-tip .bc-tip-meta { color: var(--c-mid); font-size: 11px; }

/* .bar-chart - sequential bar chart for time series or categorical data.
   Same modifier system as .line-chart for consistency:
     .frame       - left + bottom border (X/Y axis lines)
     .axes        - Y tick labels + category X labels
     .tracks      - green/amber/red zone bands (forces 0-100 scale)
     .stacked     - bars composed of multiple segments (varying total heights)
     .normalised  - combined with .stacked, all bars 100% height showing
                    proportional composition. Forces 0-100 scale.
   Combine freely. Default Y range: 0 to data-max * 1.1.
 *
 * Purpose:    Discrete period / category comparison of values, with
 *             optional composition breakdown (stacked).
 * Use when:   Citation counts per period, items per category, status
 *             composition over time, channel mix breakdown.
 * Avoid for:  Continuous trends (use line chart), proportional whole
 *             (use donut), single value (use bubble/chip).
 * Pairs with: Exec summary headers, history breakdowns, scorecard mixes.
 *
 * Markup: <div class="bar-chart [.stacked] [.normalised] [.frame] [.axes]
 *              [.tracks]" id="my-bar"></div>
 *
 * Override per-instance: window.__BAR_DATA[id] = [...] before page load.
 */
.bar-chart {
  width: 100%;
  margin-bottom: 24px;
}
.bar-chart svg { display: block; width: 100%; height: auto; }
.bar-chart .br-rect {
  rx: 2;
  cursor: pointer;
  transition: opacity 0.15s;
}
.bar-chart .br-rect:hover { opacity: 0.82; }
.bar-chart .br-rect.green     { fill: var(--c-green); }
.bar-chart .br-rect.amber     { fill: var(--c-amber); }
.bar-chart .br-rect.red       { fill: var(--c-red); }
.bar-chart .br-rect.primary   { fill: var(--c-primary); }
.bar-chart .br-rect.secondary { fill: var(--c-secondary); }
.bar-chart .br-rect.tertiary  { fill: var(--c-tertiary); }
.bar-chart .br-rect.empty     { fill: var(--c-invert-2); }
/* Series palette for stacked segments. Cycles through 6 colours. */
.bar-chart .br-rect.s0 { fill: var(--c-primary); }
.bar-chart .br-rect.s1 { fill: var(--c-secondary); }
.bar-chart .br-rect.s2 { fill: var(--c-tertiary); }
.bar-chart .br-rect.s3 { fill: var(--c-green); }
.bar-chart .br-rect.s4 { fill: var(--c-amber); }
.bar-chart .br-rect.s5 { fill: var(--c-red); }

.bar-chart .br-axis-label,
.bar-chart .br-axis-value {
  font-family: var(--ff-body);
  font-size: 10px;
  fill: var(--c-mid);
}
.bar-chart .br-axis-value { font-weight: 500; }
.bar-chart .br-border {
  stroke: var(--c-invert-2);
  stroke-width: 1;
  fill: none;
}
.bar-chart .br-track-green { fill: var(--c-green-fade); fill-opacity: 0.6; }
.bar-chart .br-track-amber { fill: var(--c-amber-fade); fill-opacity: 0.6; }
.bar-chart .br-track-red   { fill: var(--c-red-fade);   fill-opacity: 0.6; }
.bar-chart .br-threshold {
  stroke: var(--c-invert-1);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  fill: none;
}
.bar-chart .br-value-label {
  font-family: var(--ff-body);
  font-size: 10px;
  font-weight: 500;
  fill: var(--c-body);
  pointer-events: none;
}

/* Legend - rendered above the chart for stacked variants. */
.bar-chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 8px;
  font-family: var(--ff-body);
  font-size: 12px;
  color: var(--c-body);
}
.bar-chart-legend span { display: inline-flex; align-items: center; gap: 6px; }
.bar-chart-legend i {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  display: inline-block;
}

/* .donut - circular proportional breakdown chart. SVG arc paths with
   stroke-width creating the donut thickness; hollow centre houses an
   optional total / label. Series segments use the same s0..s5 palette
   as the bar chart for visual consistency.
 *
 * Purpose:    Proportional whole - showing how a total breaks down
 *             across categories.
 * Use when:   Mix / share / composition where there's a single total
 *             being divided (citations by engine, plan by tier, etc).
 * Avoid for:  Trends over time (use line chart), many categories
 *             (>6 segments - use bar chart), absolute values (use
 *             bubble or chip).
 * Pairs with: Exec summary breakdowns, scorecard mixes, dashboard tiles.
 *
 * Markup:
 *   <svg class="donut" viewBox="0 0 100 100"
 *        data-points="18,42,24"
 *        data-labels="Google,OpenAI,Perplexity"
 *        data-center="84" data-sub="citations">
 *   </svg>
 *
 * data-points: comma-separated values (any units; segments sized by share)
 * data-labels: optional segment names (for hover tooltips)
 * data-center: optional centre label (defaults to total)
 * data-sub:    optional second-line centre label (e.g. unit, "total")
 */
.donut {
  display: inline-block;
  width: 120px;
  height: 120px;
  vertical-align: middle;
}
.donut.sm { width: 80px;  height: 80px; }
.donut.lg { width: 160px; height: 160px; }
.donut.xl { width: 200px; height: 200px; }

.donut .donut-segment {
  fill: none;
  stroke-width: 16;
  stroke-linecap: butt;
  cursor: pointer;
  transition: stroke-width 0.15s;
}
.donut .donut-segment:hover { stroke-width: 18; }
.donut .donut-segment.s0 { stroke: var(--c-primary); }
.donut .donut-segment.s1 { stroke: var(--c-secondary); }
.donut .donut-segment.s2 { stroke: var(--c-tertiary); }
.donut .donut-segment.s3 { stroke: var(--c-green); }
.donut .donut-segment.s4 { stroke: var(--c-amber); }
.donut .donut-segment.s5 { stroke: var(--c-red); }

.donut .donut-center {
  fill: var(--c-body);
  font-family: var(--ff-body);
  font-weight: 500;
  font-size: 22px;
  text-anchor: middle;
  dominant-baseline: central;
}
.donut .donut-center-sub {
  fill: var(--c-mid);
  font-family: var(--ff-body);
  font-weight: 400;
  font-size: 10px;
  text-anchor: middle;
  dominant-baseline: central;
}

/* ──────────────────────────────────────────────────────────
 * TABLES (Default / Marketing variant)
 * Lifted from the v1 styleguide, normalised to current scale tokens.
 * .table - base data table for marketing pages, research pages, pricing
 *          comparisons. Generous padding, body-scale type, header row in
 *          primary teal on invert-2.
 * .table.comparison - feature/pricing comparison style: centred cells,
 *          first column left-aligned + bolder.
 *
 * Row helpers:
 *   tr.row-total      - bold last summary row with invert-1 top border
 *   tr.row-highlight  - subtle primary tint row background
 *
 * Cell helpers:
 *   td.cell-num       - right-aligned, tabular nums (numeric values)
 *   td.cell-num + <strong>  - emphasised numeric (primary teal)
 *
 * Indicator content: compose with existing .tick / .cross / .chip / .flag
 * components inside cells - no separate .cell-yes/.cell-no helpers.
 *
 * App-context data tables are a separate component (see Tables - App).
 * Use .app modifier on the wrapper to apply data-table density to a
 * marketing-page table (e.g. embedded research data).
 * ────────────────────────────────────────────────────────── */
.table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--ff-body);
  font-size: 16px;
  line-height: 24px;
  color: var(--c-body);
}
.table th,
.table td {
  padding: 12px 16px;
  text-align: left;
  border-bottom: 1px solid var(--c-invert-2);
  vertical-align: middle;
}
.table th {
  font-family: var(--ff-body);
  font-weight: 700;
  font-size: 12px;
  line-height: 18px;
  color: var(--c-primary);
  text-transform: uppercase;
  letter-spacing: 1px;
  background: var(--c-invert-2);
  /* Override the inherited 1px invert-2 border (which would blend
     invisibly with the same-colour header bg) with a 2px invert-1
     underline that actually shows. Matches .headers-grey. */
  border-bottom: 2px solid var(--c-invert-1);
}
.table tr:last-child td { border-bottom: 0; }

/* .table.top - top-align every cell. The default centres cell content
   (vertical-align: middle), which misaligns multi-line cells (lists,
   rowspan groups) against single-line neighbours. Use .top for data
   tables where first-line alignment across cells matters.
   The .app / .table.app selectors are required so .top also wins inside
   an .app context, where `.app .table td` otherwise re-asserts middle
   at equal specificity. */
.table.top th,
.table.top td,
.app .table.top th,
.app .table.top td,
.table.app.top th,
.table.app.top td { vertical-align: top; }

/* A list used as cell content carries the body-flow bottom margin from
   base.css, leaving slack at the foot of the cell. Drop it - inside a
   table cell the list IS the content, not a flow block. */
.table td ul,
.table td ol { margin-bottom: 0; }

/* Comparison variant - centred cells, first column left-aligned bolder. */
.table.comparison th,
.table.comparison td { text-align: center; }
.table.comparison th:first-child,
.table.comparison td:first-child {
  text-align: left;
  font-weight: 500;
}

/* Cell-alignment helpers. Apply per cell for explicit control, OR add
   .auto-align to the table - JS at the bottom of the page detects column
   types from the first body row and applies these automatically:
     - numeric-only column   -> .cell-num (right-align, tabular nums)
     - indicator-only column -> .cell-mid (centre)
     - mixed column          -> .cell-mid (centre)
     - text-only column      -> default left-align (no class) */
.table .cell-num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.table .cell-num strong { color: var(--c-primary); font-weight: 500; }
.table .cell-mid { text-align: center; }

/* Inputs inside a .cell-num cell inherit the column's right-align +
   tabular-nums treatment - so numeric editable columns render the input
   text right-aligned without per-input markup. Scoped to the canonical
   .input class only; arbitrary content inside .cell-num cells is not
   touched. The auto-align JS handles th + td .cell-num application. */
.table .cell-num .input {
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.table.comparison .cell-num { text-align: center; }

/* .thin-headers variant - no fill, eyebrow-style light Fredoka header,
   scale-down1 (16/24), invert-1 underline. Use when the table sits in
   prose on a marketing page and the bold filled header reads too loud. */
.table.thin-headers th {
  background: transparent;
  font-family: var(--ff-display);
  font-weight: 300;
  font-size: 16px;
  line-height: 24px;
  color: var(--c-secondary);
  text-transform: uppercase;
  letter-spacing: 1px;
  border-bottom: 2px solid var(--c-invert-1);
}

/* Row helpers. */
.table tr.row-highlight td {
  background: color-mix(in srgb, var(--c-primary) 4%, transparent);
}
/* Summary/total row - enforces heavier weight on all cell content
   (including <strong> children) so values pop without per-cell markup. */
.table tr.row-total td,
.table tr.row-total td strong {
  font-weight: 700;
}
.table tr.row-total td {
  border-top: 2px solid var(--c-invert-1);
  border-bottom: 0;
  padding-top: 16px;
}

/* ──────────────────────────────────────────────────────────
 * TABLES (App / Data variant)
 * `.app .table` overrides the marketing styling for in-app data tables:
 * 16/20 body type, 10x16 padding, eyebrow-style secondary teal headers
 * (12/18, no fill), row hover affordance for clickable rows.
 * Composes with .auto-align and .row-total / .row-highlight as before.
 * Use directly inside an `.app` context, or apply both `.table.app` to
 * an individual table outside .app context (same overrides).
 *
 * DIRECTIVES for cell content:
 *   - Buttons in table cells: ALWAYS use `.btn-narrow` AND `.pill` by
 *     default. Full-size + rect buttons crowd cell padding.
 *   - Form inputs in table cells (input/select): ALWAYS use the narrow
 *     variant (`.input.input-narrow` / `.select.input-narrow`).
 *   - Indicators in `.dense` tables auto-render at their .small sizes
 *     (badge, chip, flag, bubble) - see rule block below. Consumers
 *     do NOT need to add .small explicitly inside .dense.
 *   - Cell contents NEVER truncate (no text-overflow: ellipsis). Long
 *     URLs / paths wrap cleanly on space, "-", "/", "." - use the
 *     `tdWrap()` JS helper (lifted from OptimAI's escUrl) to inject
 *     <wbr> tags after these characters. Fallback CSS rule below uses
 *     `overflow-wrap: anywhere` so unbroken strings still wrap if
 *     needed.
 * ────────────────────────────────────────────────────────── */
.app .table,
.table.app {
  font-size: 16px;
  line-height: 20px;
}
.app .table th,
.app .table td,
.table.app th,
.table.app td {
  padding: 10px 16px;
  vertical-align: middle;
  border-bottom: 1px solid var(--c-invert-2);
  overflow-wrap: anywhere;
  word-break: normal;
}
.app .table th,
.table.app th {
  background: transparent;
  font-family: var(--ff-body);
  font-weight: 700;
  font-size: 12px;
  line-height: 18px;
  color: var(--c-secondary);
  text-transform: uppercase;
  letter-spacing: 0.8px;
  border-bottom: 1px solid var(--c-invert-1);
}

/* .dense - tighter type + padding variant. Composes with any other
   table modifier. Use when row count is high and you need to fit more
   on screen (ops dashboards, audit lists, scan logs etc). Indicators
   inside .dense rows auto-shrink to their .small dimensions so the
   cell padding stays balanced - no need to add .small explicitly. */
.app .table.dense,
.table.app.dense {
  font-size: 12px;
  line-height: 18px;
}
.app .table.dense th,
.app .table.dense td,
.table.app.dense th,
.table.app.dense td {
  padding: 6px 12px;
}
/* Auto-shrink indicators inside .dense to their small variants. */
.app .table.dense .badge,
.table.app.dense .badge {
  font-size: 10px;
  padding: 1px 6px;
  letter-spacing: 0.4px;
}
.app .table.dense .chip,
.table.app.dense .chip {
  font-size: 10px;
  padding: 1px 6px;
}
.app .table.dense .flag,
.table.app.dense .flag {
  height: 22px;
  padding: 0 10px;
  font-size: 12px;
  letter-spacing: 0.6px;
}
.app .table.dense .bubble,
.table.app.dense .bubble {
  width: 24px;
  height: 24px;
  font-size: 8px;
}
.table tbody tr {
  transition: background 0.15s;
}
.table tbody tr:hover {
  background: var(--c-invert-2);
  cursor: pointer;
}
.app .table tr.row-total td,
.table.app tr.row-total td {
  padding-top: 10px;
}

/* Table header variants. Default header style is invert-2 fill +
   primary teal text (from .table th base rule). Add a class to opt
   into a different look:
     .headers-grey    - explicit grey fill + 2px invert-1 underline
                        (same fill as default but with the underline)
     .headers-primary - solid primary teal fill + white labels
     .headers-bare    - transparent + no underline (pair with .zebra)
   Apply on any .table - works in both default + .app contexts. */
.table.headers-grey th {
  background: var(--c-invert-2);
  color: var(--c-primary);
  border-bottom: 2px solid var(--c-invert-1);
}
.table.headers-primary th {
  background: var(--c-primary);
  color: white;
  border-bottom: 0;
}
.table.headers-bare th {
  background: transparent;
  border-bottom: 0;
}

/* .zebra - alternating row fill in --c-bg. Body rows only; summary
   (.row-total) row stays clean. Hover override still applies. */
.table.zebra tbody tr:nth-child(even) td {
  background: var(--c-bg);
}
.table.zebra tr.row-total td { background: transparent; }
.table.zebra tbody tr:hover td {
  background: var(--c-invert-2);
}

/* State-shaded rows - apply a colour-synonym class to <tr> and the
   row picks up that synonym's --state-fade as its background. Use for
   status-laden lists (keyword opportunities, scan results, audit rows
   etc). State shading wins over zebra; hover overlay darkens the row
   slightly via invert-2 mix so the hover affordance still reads. */
.table tbody tr.green  td,
.table tbody tr.amber  td,
.table tbody tr.red    td,
.table tbody tr.success td,
.table tbody tr.warning td,
.table tbody tr.error   td,
.table tbody tr.hit     td,
.table tbody tr.miss    td,
.table tbody tr.gained  td,
.table tbody tr.lost    td {
  background: var(--state-fade);
}
.table tbody tr.green:hover td,
.table tbody tr.amber:hover td,
.table tbody tr.red:hover td,
.table tbody tr.success:hover td,
.table tbody tr.warning:hover td,
.table tbody tr.error:hover td,
.table tbody tr.hit:hover td,
.table tbody tr.miss:hover td,
.table tbody tr.gained:hover td,
.table tbody tr.lost:hover td {
  background: color-mix(in srgb, var(--state-fade) 60%, var(--c-invert-2));
}

/* State-shaded CELLS - apply a synonym class to a single <td> instead
   of the whole row. Cell picks up state-fade as bg AND state-color as
   text colour (so "8" in a green cell reads green/bold). Wins over row
   shading via specificity (td > tr td). Useful for competitor analysis
   tables where you want to call out winners/losers per column. */
.table tbody td.green,
.table tbody td.amber,
.table tbody td.red,
.table tbody td.success,
.table tbody td.warning,
.table tbody td.error,
.table tbody td.hit,
.table tbody td.miss,
.table tbody td.gained,
.table tbody td.lost {
  background: var(--state-fade);
  color: var(--state-color);
  font-weight: 700;
}
.table tbody td.green strong,
.table tbody td.amber strong,
.table tbody td.red strong,
.table tbody td.success strong,
.table tbody td.warning strong,
.table tbody td.error strong { color: var(--state-color); }
/* Row hover wins over cell shading - resets cell bg to the standard
   invert-2 hover so the active row reads as one unit. Text colour
   stays state-coloured so the cell's meaning isn't lost. */
.table tbody tr:hover td.green,
.table tbody tr:hover td.amber,
.table tbody tr:hover td.red,
.table tbody tr:hover td.success,
.table tbody tr:hover td.warning,
.table tbody tr:hover td.error,
.table tbody tr:hover td.hit,
.table tbody tr:hover td.miss,
.table tbody tr:hover td.gained,
.table tbody tr:hover td.lost {
  background: var(--c-invert-2);
}

/* .table.expandable - row-level disclosure pattern. Each expandable
   row pair: <tr class="row-expand"> + <tr class="row-detail">. Toggling
   .is-open on the row-expand reveals the detail row beneath. The detail
   row's <td> uses colspan=N (where N = total column count) and contains
   arbitrary content (chart, paragraph, mini-table, etc).
   Two variants depending on where the triangle sits:
     Variant A: dedicated triangle column with no header (.cell-expander
                td as the first column).
     Variant B: triangle prefixed to the first column's text value (the
                whole cell is the click target).
   Both use the same .row-toggle <button>.
   .table.nested is a sibling modifier that uses the same mechanic but
   also styles a nested <table> inside the .row-detail (typically with
   different columns/headers from the parent). See the .nested rule
   block below. */
.table.expandable .row-toggle,
.table.nested .row-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  color: var(--c-secondary);
  font: inherit;
  font-weight: 500;
  text-align: left;
  transition: color 0.15s;
}
.table.expandable .row-toggle:hover,
.table.nested .row-toggle:hover { color: var(--c-primary); }
.table.expandable .row-toggle::before,
.table.nested .row-toggle::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 8px solid var(--c-secondary);
  transition: transform 0.15s, border-left-color 0.15s;
  flex-shrink: 0;
}
.table.expandable .row-toggle:hover::before,
.table.nested .row-toggle:hover::before { border-left-color: var(--c-primary); }
.table.expandable .row-expand.is-open .row-toggle::before,
.table.nested .row-expand.is-open .row-toggle::before {
  transform: rotate(90deg);
}
/* Cell holding the triangle - tighten padding when used as a dedicated
   triangle-only column (Variant A). */
.table.expandable .cell-expander,
.table.nested .cell-expander {
  width: 28px;
  padding-right: 0;
}
/* Detail row - hidden by default, shown when toggled. JS adds .is-open
   to BOTH the row-expand (so the triangle rotates) AND the row-detail
   (so it shows). Using a class on the detail row directly is more
   robust than relying on adjacent sibling selectors. */
.table.expandable .row-detail,
.table.nested .row-detail { display: none; }
.table.expandable .row-detail.is-open,
.table.nested .row-detail.is-open { display: table-row; }
.table.expandable .row-detail > td,
.table.nested .row-detail > td {
  padding: 16px;
  background: var(--c-bg);
  border-top: 0;
  border-bottom: 1px solid var(--c-invert-2);
}

/* .table.nested - same mechanic as .expandable but tuned for embedding
   another <table> inside the .row-detail. The inner table typically
   has DIFFERENT columns/headers from the parent (e.g. domain row
   expands to show its unscanned pages, with their own keyword columns).
   Inner table styling: dense padding, slightly smaller body, secondary
   teal eyebrow header without underline so the visual hierarchy
   reads parent → inner without the inner's header competing. */
.table.nested .row-detail .table {
  margin: 0;
  font-size: 12px;
  line-height: 18px;
  background: white;
  border: 1px solid var(--c-invert-2);
  border-radius: var(--r-sm);
  overflow: hidden;
}
.table.nested .row-detail .table th,
.table.nested .row-detail .table td {
  padding: 6px 12px;
  background: transparent;
}
.table.nested .row-detail .table th {
  font-size: 11px;
  line-height: 16px;
  letter-spacing: 0.5px;
  border-bottom: 1px solid var(--c-invert-2);
  background: transparent;
}
.table.nested .row-detail .table tbody tr:hover td {
  background: var(--c-invert-2);
}
.table.nested .row-detail-label {
  display: block;
  font-family: var(--ff-body);
  font-size: 12px;
  font-weight: 700;
  color: var(--c-mid);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: 8px;
}

/* .table.editable - inline row editing pattern. Two variants:
 *
 * VARIANT A (always-editable): every row's data cells render as form
 * inputs prefilled with the current value. Last cell holds a cancel +
 * commit pair.
 *
 * VARIANT B (click-to-edit): rows render as plain values by default.
 * Last cell has an icon-only ghost edit button. Clicking it swaps the
 * row to edit mode (.is-editing class), revealing prefilled inputs in
 * each data cell and replacing the edit button with cancel + commit.
 *
 * Cell markup pairs a .cell-value (read mode) and .cell-input (edit
 * mode) inside each <td>; .is-editing on the <tr> swaps which is shown.
 *
 * Inputs in cells MUST use .input-narrow (per the table directives).
 * Action icons use .tick.circle.outline.hit (commit) and
 * .cross.circle.outline.miss (cancel); both swap to filled on hover via
 * the .cell-action button wrapper. Edit button is ghost / icon-only,
 * Material Symbols `edit` glyph at 18px.
 */

/* Cell value/input swap. .cell-input is hidden by default and shown
   when the row carries .is-editing (Variant B click-to-edit, OR
   Variant A always-editing rows have .is-editing applied permanently).
   .cell-value is hidden ONLY when its td has a .cell-input sibling -
   so cells with no .cell-input (static / read-only columns like row
   identifiers, timestamps, computed fields) keep their .cell-value
   visible always. Mixed editable + static columns compose freely. */
.table.editable .cell-input { display: none; }
.table.editable tr.is-editing td:has(.cell-input) .cell-value { display: none; }
.table.editable tr.is-editing .cell-input {
  display: flex;
  align-items: center;
  width: 100%;
}

/* Action cell holds edit OR cancel+commit depending on row state */
.table.editable .cell-actions {
  display: inline-flex;
  gap: 8px;
  align-items: center;
  justify-content: flex-end;
}
.table.editable tr.is-editing .cell-edit-btn { display: none; }
.table.editable tr:not(.is-editing) .cell-action-commit,
.table.editable tr:not(.is-editing) .cell-action-cancel { display: none; }

/* Compress td padding for cells holding interactive content. Inputs
   keep some vertical breathing room (4px top/bottom) so the input's
   border doesn't kiss the row edge. Action/edit-button cells get all
   padding stripped - the button provides its own internal padding. */
.table.editable td:has(.cell-input) {
  padding-top: 4px;
  padding-bottom: 4px;
}
.table.editable td:has(.cell-actions),
.table.editable td:has(.cell-edit-btn) {
  padding: 0;
}

/* .cell-action - bare button wrapper around a tick/cross indicator.
   Originally scoped to editable tables; promoted to canonical so trays,
   modals, and other dismiss controls can reuse the same pattern (outline
   glyph that flips to filled + state-aware halo on hover). */
.cell-action {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
}
.cell-action .tick.circle,
.cell-action .cross.circle {
  border-radius: 50%;
  transition: box-shadow 0.15s;
}
.cell-action:hover .tick.circle.outline::before,
.cell-action:hover .cross.circle.outline::before {
  font-variation-settings: 'FILL' 1;
}
/* Hover halo - matches the clickable-indicator pattern (chip / bubble /
   gauge). Uses --state-halo from the synonym (.hit -> green, .miss -> red)
   so commit/cancel get semantically-coloured halos. */
.cell-action:hover .tick.circle,
.cell-action:hover .cross.circle {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--state-halo, var(--c-primary)) 16%, transparent);
}
/* Editable-table size override - bumps the inner indicator one step up
   from the default 16px to 22px for in-cell affordance. */
.table.editable .cell-action .tick.circle,
.table.editable .cell-action .cross.circle {
  width: 22px;
  height: 22px;
  font-size: 22px;
}
/* Edit button hover halo - neutral primary teal (no state synonym on the
   button). Box-shadow follows the .pill border-radius so the halo reads
   as a pill ring around the button. Adds box-shadow to the existing
   .btn transition list. */
.table.editable .cell-edit-btn {
  transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s, box-shadow 0.15s;
}
.table.editable .cell-edit-btn:hover {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 12%, transparent);
}

/* Edit button - canonical pill ghost narrow button
   (.btn.btn-ghost.btn-narrow.pill) with an oversized Material Symbols
   `edit_attributes` glyph that dominates the small button footprint.
   Markup pairs the button with a span carrying BOTH the Material Symbols
   font class AND the local `.cell-edit-icon` hook so the oversized sizing
   only targets this one icon - other Material Symbols icons in tables
   keep their inherited size.
     <button class="btn btn-ghost btn-narrow pill cell-edit-btn">
       <span class="cell-edit-icon material-symbols-rounded">edit_attributes</span>
     </button>
*/
.cell-actions {
  height: 42px;
}

.btn.btn-narrow.cell-edit-btn {
  padding: 2px;
  height: 28px;
  position: relative;
  width: 56px;
}
.btn.btn-narrow.cell-edit-btn .cell-edit-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 48px;
  line-height: 1;
  padding: 0;
}

/* Inputs in editable cells: 100% cell width, no min-width clipping.
   Border + focus styling come from the canonical .input rules above
   (1.5px secondary teal default, primary teal on focus + halo). */
/* Fixed table layout so columns share width predictably - default
   auto-layout sizes by content min-width, which lets selects (small
   min-content) collapse while text inputs eat the remainder. */
.table.editable { table-layout: fixed; }
.table.editable .cell-input { width: 100%; }
.table.editable .cell-input .input,
.table.editable .cell-input .select,
.table.editable .cell-input .combo {
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
}
/* The native .select rules are kept as a no-JS fallback; the visible
   widget post-hydration is .combo.combo-select (the auto-enhance JS
   wraps the select and hides the original), so .combo above is the
   one doing real work in normal use. */
.table.editable .cell-input .select {
  display: block;
  padding-right: 38px;
}

/* Row hover: swap input bg to white so the filled state doesn't merge
   with the row hover bg. Overrides the :not(:placeholder-shown) rule. */
.app .table.editable tbody tr:hover .input,
.app .table.editable tbody tr:hover .select,
.app .table.editable tbody tr:hover .textarea,
.table.app.editable tbody tr:hover .input,
.table.app.editable tbody tr:hover .select,
.table.app.editable tbody tr:hover .textarea {
  background-color: white;
}

/* ──────────────────────────────────────────────────────────
 * CONTAINERS
 * Page / section-level structural blocks. (Currently: .notice, .callout,
 * .callout-feature, .ribbon, .card, .card-row, .slot, .modal, .sheet,
 * .tray-left, .tray-right, .tree. More to follow: .hero, .cta-band,
 * .container, .section etc.)
 * ────────────────────────────────────────────────────────── */

/* .notice - full-width status card. Sits at the top of a page or major
   section to surface state changes, instructions, alerts. Border + headings
   use --state-color, fill uses --state-fade (same colour mechanic as
   .badge.outline at card scale). Body copy stays --c-body for readability.
   Compose with state synonyms (.success / .warning / .danger / .neutral /
   .primary / .secondary / .tertiary). The corner slot (top-right, absolute)
   accepts a single child .badge or .notice-close. */
.notice {
  width: 100%;
  padding: var(--s-4) var(--s-5);
  margin-bottom: var(--s-4);
  border: 1px solid var(--state-color, var(--c-mid));
  border-radius: var(--r-lg);
  background: var(--state-fade, var(--c-invert-2));
  position: relative;
}
.notice .eyebrow,
.notice h3 { color: var(--state-color, var(--c-primary)); }
.notice h3 { margin-bottom: var(--s-2); }
.notice p { color: var(--c-body); margin: 0; }
.notice p:not(:last-child) { margin-bottom: var(--s-2); }
/* <strong> inside a notice picks up the state colour so emphasis reads
   semantically (e.g. "3 of 8" in a danger notice reads red). Weight stays
   at the base.css strong default (500). */
.notice strong { color: var(--state-color, var(--c-primary)); }
/* Tertiary notices use --c-mid for body copy - the lighter teal background
   pairs more sympathetically with mid grey than the standard --c-body. */
.notice.tertiary p { color: var(--c-mid); }
/* Links inherit the global a styling (primary teal, secondary on hover) -
   the state colour does NOT cascade to links inside the notice. */

/* Corner slot - direct-child badge or notice-close sits absolute top-right.
   Scoped to direct children so a .badge nested inside body copy isn't
   pulled out of flow. When BOTH a badge and a notice-close exist, the
   notice-close keeps the canonical corner spot and the badge offsets
   leftward to sit beside it. */
.notice > .badge,
.notice > .notice-close {
  position: absolute;
  top: var(--s-3);
  right: var(--s-4);
}
.notice:has(> .notice-close) > .badge {
  right: calc(var(--s-4) + 48px);
  top: var(--s-4);
}

/* App density - tighter padding to balance the smaller heading + body type. */
.app .notice {
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md);
}
.app .notice > .badge,
.app .notice > .notice-close {
  top: var(--s-2);
  right: var(--s-3);
}
.app .notice:has(> .notice-close) > .badge {
  right: calc(var(--s-3) + 40px);
  top: var(--s-3);
}

/* .callout - interstitial aside box. Sits inline within main content body
   (NOT full-section like .notice). Default = neutral tip with --c-invert-2
   bg + 3px --c-secondary left border. Compose with state synonyms
   (.success / .warning / .danger / .primary / .secondary / .tertiary /
   .neutral) to swap bg to --state-fade and border to --state-color.
   Headings, eyebrows, and <strong> inside the callout pick up the state
   colour (matches the .notice convention).
 *
 * Purpose:    Inline aside / tip / note within flowing content.
 * Use when:   Drawing attention to a related fact, instruction, or
 *             contextual status without breaking the reading flow.
 * Avoid for:  Page-level state communication (use .notice), inline
 *             emphasis (use <strong>), modal alerts (use .modal).
 * Pairs with: Long-form articles, documentation pages, in-content help.
 */
.callout {
  background: var(--state-fade, var(--c-invert-2));
  border: 1px solid var(--c-invert-1);
  border-left: 3px solid var(--state-color, var(--c-secondary));
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-sm);
  margin: var(--s-4) 0;
}
.callout > :first-child { margin-top: 0; }
.callout > :last-child  { margin-bottom: 0; }
.callout .eyebrow,
.callout h3,
.callout h4 { color: var(--state-color, var(--c-secondary)); }
.callout strong { color: var(--state-color, var(--c-secondary)); }

/* .callout-feature - emphasised neutral container. Subtle teal gradient
   bg, 4px --c-secondary left border, more padding than .callout. Use for
   worked examples, feature highlights, sidebar tips that want more
   visual weight than a tip but don't carry status semantics. */
.callout-feature {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--c-secondary) 4%, transparent),
    color-mix(in srgb, var(--c-secondary) 8%, transparent)
  );
  border: 1px solid var(--c-invert-1);
  border-left: 4px solid var(--c-secondary);
  padding: var(--s-4) var(--s-5);
  border-radius: var(--r-sm);
  margin: var(--s-4) 0;
}
.callout-feature > :first-child { margin-top: 0; }
.callout-feature > :last-child { margin-bottom: 0; }

/* Stroke side variants - default has the border on the left edge. Add a
   modifier to shift the accent to another side. The pattern (stroke-{side})
   is reusable on other containers (cards, tiles) where a directional
   accent matters; left is canonical so needs no modifier. */
.callout.stroke-top {
  border-left: 1px solid var(--c-invert-1);
  border-top: 3px solid var(--state-color, var(--c-secondary));
}
.callout.stroke-right {
  border-left: 1px solid var(--c-invert-1);
  border-right: 3px solid var(--state-color, var(--c-secondary));
}
.callout.stroke-bottom {
  border-left: 1px solid var(--c-invert-1);
  border-bottom: 3px solid var(--state-color, var(--c-secondary));
}

/* App density - tighter padding + margin to suit the smaller body type. */
.app .callout         { padding: var(--s-2) var(--s-3); margin: var(--s-3) 0; }
.app .callout-feature { padding: var(--s-3) var(--s-4); margin: var(--s-4) 0; }

/* .ribbon - mini interstitial notice. Centred, content-width, pill-shaped.
   Border + fill use --state-color / --state-fade like .notice, but ALL
   text inside (body, links, strong) takes the state colour for a
   self-contained tonal block. Typically transient (first-view nudges,
   loading hints, brief status bumps) - sits above a card, between
   sections, or attached to a display component. Compose with state
   synonyms (.success / .warning / .danger / .primary / .secondary /
   .tertiary / .neutral).
 *
 * Purpose:    Mini transient state nudge in flowing content.
 * Use when:   Brief, tonally-coloured signal that something has just
 *             happened or about to ("Saved", "Loading...", "Trial expires
 *             soon"). Smaller and lighter than .notice, more visible than
 *             a status .badge.
 * Avoid for:  Persistent page-level state (use .notice), inline emphasis
 *             (use .badge / .flag), interstitial articles (use .callout).
 * Pairs with: Card tops, post-action confirmations, transient banners.
 */
.ribbon {
  display: block;
  width: fit-content;
  max-width: 100%;
  margin: var(--s-3) auto;
  padding: var(--s-2) var(--s-4);
  border: 1px solid var(--state-color, var(--c-mid));
  border-radius: var(--r-md);
  background: var(--state-fade, var(--c-invert-2));
  color: var(--state-color, var(--c-body));
  text-align: center;
  font-size: 14px;
  line-height: 20px;
}
/* strong + a inherit from canonical rules - strong picks up state colour
   via parent inheritance + base.css 500 weight; links follow the global
   primary-teal -> secondary-teal hover style. No ribbon-local overrides. */

/* .heavy modifier - 2px border + all body text at strong (500) weight.
   For higher-priority transient signals. */
.ribbon.heavy {
  border-width: 2px;
  font-weight: 500;
}
/* .pill modifier - composes with the canonical .pill (--r-pill) but needs
   an explicit .ribbon.pill rule to win source-order over the .ribbon
   border-radius declared later in the file. */
.ribbon.pill { border-radius: var(--r-pill); }

.app .ribbon {
  padding: var(--s-1) var(--s-3);
  font-size: 12px;
  line-height: 18px;
  margin: var(--s-2) auto;
}

/* .card - generic content container. White bg, 1px --c-invert-2 frame,
   rounded corners (--r-md). Stacks content vertically; first/last child
   margins are zeroed so block headings/copy sit cleanly inside the
   padding box. Compose with .stroke for a coloured edge accent or wrap
   in .card-row to build n-up grids.
 *
 * Purpose:    Generic content container - the workhorse of section /
 *             dashboard / grid layouts.
 * Use when:   Any bounded content block sitting inside flowing layout -
 *             entity tiles, list items in a grid, dashboard panels,
 *             feature blocks, settings sections.
 * Avoid for:  Inline asides (use .callout), full-width state cards (use
 *             .notice), modal dialogs (use .modal).
 * Pairs with: .card-row (grid wrapper), .stroke + .stroke-{side}
 *             modifiers, embedded indicators (badge / chip / flag /
 *             dot / bubble / trend etc - use the .small variants where
 *             space is tight).
 */
.card {
  background: white;
  border: 1px solid var(--state-color, var(--c-invert-1));
  border-radius: var(--r-md);
  padding: var(--s-4);
  margin-bottom: var(--s-4);
  transition: box-shadow 0.15s, border-color 0.15s, transform 0.15s;
}
.card > :first-child { margin-top: 0; }
.card > :last-child { margin-bottom: 0; }
/* Inside a .card-row the grid gap handles spacing - zero per-card margin
   so it doesn't compound with the row's gap. */
.card-row > .card { margin-bottom: 0; }

/* Hover lift - opt-in only, NOT applied to base .card. Picked up by the
   visually-loaded variants (.card-featured, .card-image, .card-callout)
   AND by an explicit .card.lift utility when a plain card needs to
   be interactive. Variants that already carry a box-shadow ring (featured
   + callout use 0 0 0 1px var(--c-primary) for their visible 2-stack
   frame) stack the ring AND the lift in a single box-shadow declaration -
   otherwise the lift would overwrite the ring and the border would
   appear to thin on hover. */
.card.card-image:hover,
.card.lift:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  transform: translateY(-2px);
}
.card.card-featured:hover {
  box-shadow: 0 0 0 1px var(--c-primary), 0 4px 12px rgba(0, 0, 0, 0.08);
  transform: translateY(-2px);
}
.card.card-callout:hover {
  box-shadow: 0 0 0 1px var(--state-color, var(--c-primary)), 0 4px 12px rgba(0, 0, 0, 0.08);
  transform: translateY(-2px);
}

/* Fill variants - alternate non-white backgrounds. Use when a card needs
   to sit visibly on a white page or to differentiate ambient panels. */
.card.fill-bg        { background: var(--c-bg); }
.card.fill-invert-2  { background: var(--c-invert-2); }

/* .fade - matches the state synonym's fade fill. Compose with a colour
   synonym (.card.success.fade etc) for a tonal panel. Without a synonym
   this is a no-op (falls back to white). */
.card.fade { background: var(--state-fade, white); }

/* .flat - borderless invert-2 panel. Reads as a soft section block rather
   than a discrete card. Use for inline summary blocks ("74% cheaper"
   takeover panels) and ambient supporting copy that shouldn't compete
   with surrounding bordered cards. Border kept (transparent) so layout
   dimensions match a normal card. */
.card.flat {
  background: var(--c-invert-2);
  border-color: transparent;
}
/* Lighter variant - same borderless treatment but uses --c-bg for an
   even quieter recess (good when sitting against an invert-2 page bg
   or for layered nesting). */
.card.flat.light {
  background: var(--c-bg);
}

/* Structural slots - optional header / body / footer / media regions for
   richer card layouts. Header + footer use flex space-between so a primary
   label sits left and a secondary control / metadata sits right. Body
   strips first/last child margins. Media is a full-bleed image slot used
   inside .card-image (no-padding) variants. */
.card-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  margin-bottom: var(--s-3);
}
/* Headings inside a .card-header sit flush in the row - no bottom margin
   inherited from base.css h2..h4. Eyebrows too. */
.card-header h2,
.card-header h3,
.card-header h4,
.card-header .eyebrow { margin-bottom: 0; }
/* Heading colour follows --state-color so coloured cards (.card.success
   etc) and .card-callout state variants get tonally-aligned headers.
   Defaults to primary teal when no synonym is applied. */
.card .card-header h2,
.card .card-header h3,
.card .card-header h4,
.card .card-header .eyebrow { color: var(--state-color, var(--c-primary)); }

/* Filled header bands - .headers-primary / .headers-grey variants on
   .card-header. Bleed past the parent card's padding via negative margins
   (top + horizontal) so the band sits flush with the card edges. Top
   corners radius-matched to the card so they hug cleanly. Mirrors the
   .table.headers-primary / .table.headers-grey naming and intent. */
.card-header.headers-primary,
.card-header.headers-grey {
  margin: calc(-1 * var(--s-4)) calc(-1 * var(--s-4)) var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-radius: var(--r-md) var(--r-md) 0 0;
}
.card-header.headers-primary { background: var(--c-primary); }
.card .card-header.headers-primary h2,
.card .card-header.headers-primary h3,
.card .card-header.headers-primary h4,
.card .card-header.headers-primary .eyebrow,
.card-header.headers-primary { color: white; }
.card-header.headers-grey { background: var(--c-invert-2); }
/* .headers-grey keeps the default state-color heading (primary teal). */

/* App density - tighter padding + matching negative margins for filled bands. */
.app .card-header.headers-primary,
.app .card-header.headers-grey {
  margin: calc(-1 * var(--s-3)) calc(-1 * var(--s-3)) var(--s-2);
  padding: var(--s-2) var(--s-3);
}
.card-body > :first-child { margin-top: 0; }
.card-body > :last-child { margin-bottom: 0; }
.card-footer {
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  border-top: 1px solid var(--c-invert-1);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
}
.card-media {
  display: block;
  width: 100%;
  max-height: 200px;
  object-fit: cover;
  background: var(--c-invert-2);
}
/* .card-aside - sidebar slot used inside .card-hero for a fixed-width
   media / chart / gauge / illustration. Sits side-by-side with .card-body.
   Width is driven by content (flex-shrink: 0). */
.card-aside {
  flex-shrink: 0;
}

/* .card-featured - primary teal border + 1px ring + soft diagonal gradient.
   Use to anchor the most important card in a row. */
.card.card-featured {
  border-color: var(--c-primary);
  box-shadow: 0 0 0 1px var(--c-primary);
  background: linear-gradient(
    135deg,
    color-mix(in srgb, var(--c-primary) 3%, white),
    color-mix(in srgb, var(--c-secondary) 3%, white)
  );
}

/* .card-image - strips padding so .card-media bleeds to the edges. Inner
   .card-body provides padding for the text. overflow:hidden keeps the
   media radius-clipped. */
.card.card-image { padding: 0; overflow: hidden; }
.card.card-image .card-body { padding: var(--s-4); }

/* .card-hero - full-width card with a side-by-side layout: a sidebar slot
   (.card-aside) for media / chart / illustration and a body slot
   (.card-body) for copy. Larger padding than a default card to read as a
   hero. Sidebar position follows DOM order - put .card-aside before
   .card-body for sidebar-left (the typical case), or after for sidebar-
   right. Lifted from OptimAI's report hero pattern. */
.card.card-hero {
  display: flex;
  align-items: center;
  gap: var(--s-6);
  padding: var(--s-6);
}
.card.card-hero .card-body { flex: 1; min-width: 0; }
.app .card.card-hero { gap: var(--s-5); padding: var(--s-5); }

/* .card-callout - same primary teal ring as featured + a floating narrow
   pill straddling the top border. Pill content is data-driven via
   `data-label="..."` (default empty if attr is missing). Default position
   is centred; .left / .right modifiers shift the pill along the top edge.
   Use to single out one card in a row (recommended pack, popular plan,
   highlighted entity). */
.card.card-callout {
  position: relative;
  border-color: var(--state-color, var(--c-primary));
  box-shadow: 0 0 0 1px var(--state-color, var(--c-primary));
}
.card.card-callout::before {
  content: attr(data-label);
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
  height: 20px;
  line-height: 20px;
  padding: 0 10px;
  background: var(--state-color, var(--c-primary));
  color: white;
  font-family: var(--ff-body);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  white-space: nowrap;
  border-radius: var(--r-pill);
}
.card.card-callout.left::before {
  left: var(--s-4);
  transform: translateY(-50%);
}
.card.card-callout.right::before {
  left: auto;
  right: var(--s-4);
  transform: translateY(-50%);
}
/* .large modifier - chunkier pill (28px tall, 13px font, more padding)
   for hero / pricing cards where a longer label needs presence. Default
   pill stays narrow for tile-grid use. */
.card.card-callout.large::before {
  height: 28px;
  line-height: 28px;
  padding: 0 16px;
  font-size: 13px;
}
/* Card-callout state-colour header tinting is now covered by the general
   .card .card-header heading rule (see Structural slots block above). */

/* Stroke variant - same mechanic as .callout BUT the bg stays white (cards
   live on the page, not in flowing prose, so a state-fade fill on top of a
   coloured stroke would dominate a tile grid). Default stroke side is
   left; .stroke-top / -right / -bottom modifiers shift the accent.
   Resets non-stroke sides to default --c-invert-1 even when a state
   synonym is also applied (the base .card border is state-aware for the
   plain .card.success / .card.warning etc colour-variant pattern; here
   we want only the stroked side to carry the state colour). */
.card.stroke {
  background: white;
  border: 1px solid var(--c-invert-1);
  border-left: 3px solid var(--state-color, var(--c-secondary));
}
.card.stroke.stroke-top {
  border-left: 1px solid var(--c-invert-1);
  border-top: 3px solid var(--state-color, var(--c-secondary));
}
.card.stroke.stroke-right {
  border-left: 1px solid var(--c-invert-1);
  border-right: 3px solid var(--state-color, var(--c-secondary));
}
.card.stroke.stroke-bottom {
  border-left: 1px solid var(--c-invert-1);
  border-bottom: 3px solid var(--state-color, var(--c-secondary));
}

/* .card-row - grid wrapper for n-up card layouts. Default = 1 column
   (effectively full-width single card). Add .cols-2 or .cols-3 for
   multi-column rows. Equal column widths via 1fr; align-items: stretch
   default keeps card heights matched in a row. */
.card-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-4);
  margin-bottom: var(--s-4);
}
.card-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.card-row.cols-3 { grid-template-columns: repeat(3, 1fr); }

/* App density - tighter card padding. The card-row gap + margin stay at
   the default --s-4 (16px): the --s-3 (12px) app-density gutter read too
   cramped for card grids, so card-rows use the standard spacing in .app
   context too. */
.app .card { padding: var(--s-3); }

/* Table directly inside a card bleeds horizontally past the card's
   padding so row dividers + cell fills meet the card frame; cells keep
   their normal horizontal padding so values breathe. When the table is
   the LAST child it also bleeds to the card's bottom edge. A banded
   header (.headers-primary / .headers-grey) immediately above zeros its
   bottom margin so the band sits flush with the table's first row. */
.card > .table {
  margin-left: calc(-1 * var(--s-4));
  margin-right: calc(-1 * var(--s-4));
  width: calc(100% + 2 * var(--s-4));
}
.card > .table:first-child {
  margin-top: calc(-1 * var(--s-4));
}
.card > .table:last-child {
  margin-bottom: calc(-1 * var(--s-4));
}
.card > .card-header.headers-primary:has(+ .table),
.card > .card-header.headers-grey:has(+ .table) {
  margin-bottom: 0;
}
.app .card > .table {
  margin-left: calc(-1 * var(--s-3));
  margin-right: calc(-1 * var(--s-3));
  width: calc(100% + 2 * var(--s-3));
}
.app .card > .table:first-child {
  margin-top: calc(-1 * var(--s-3));
}
.app .card > .table:last-child {
  margin-bottom: calc(-1 * var(--s-3));
}

/* When a table is the .card's first child (i.e. its thead IS the
   header band, no separate .card-header above), round the thead's outer
   corners to match the card's --r-md so the filled thead fill meets the
   card frame cleanly. Same for last-child bottom corners. The -1px
   accounts for the card's 1px border so the radii hug. */
.card > .table:first-child thead tr:first-child th:first-child {
  border-top-left-radius: calc(var(--r-md) - 1px);
}
.card > .table:first-child thead tr:first-child th:last-child {
  border-top-right-radius: calc(var(--r-md) - 1px);
}
.card > .table:last-child tbody tr:last-child td:first-child {
  border-bottom-left-radius: calc(var(--r-md) - 1px);
}
.card > .table:last-child tbody tr:last-child td:last-child {
  border-bottom-right-radius: calc(var(--r-md) - 1px);
}

/* .slot - inline content container. Smaller, single-line cousin of .card -
   bigger than a .chip / .badge because it carries content (label + value).
   Used for pricing tiers, credit packs, option chips, sidebar items.
   Border picks up --state-color so colour synonyms (.success / .warning /
   .danger / .primary etc) work the same as on .card. Composes with
   .fade for state-fade fill, and .slot-callout for the primary ring +
   floating pill.
 *
 * Purpose:    Inline labelled value / option container.
 * Use when:   Showing a small bound pair like "100 credits £100", a
 *             selectable plan tile in a row, a sidebar nav item.
 * Avoid for:  Pure category tags (use .chip), bounded content panels
 *             (use .card), state nudges (use .ribbon / .badge / .flag).
 * Pairs with: Pricing rows, credit packs, sidebar menus, inline option
 *             pickers.
 */
.slot {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  background: white;
  border: 1px solid var(--state-color, var(--c-invert-1));
  border-radius: var(--r-md);
  font-family: var(--ff-body);
  font-size: 14px;
  line-height: 20px;
  white-space: nowrap;
  margin-bottom: var(--s-2);
  transition: box-shadow 0.15s, border-color 0.15s, transform 0.15s;
}
/* .fade - state-fade fill + state-coloured text (matches .ribbon's tonal
   block convention). Compose with a state synonym for a self-contained
   tonal slot; without a synonym this falls back to white bg + body text. */
.slot.fade {
  background: var(--state-fade, white);
  color: var(--state-color, var(--c-body));
}

/* .heavy - 2px border + strong-weight text. Same pattern as .ribbon.heavy.
   For slots that need higher visual presence (selected option, primary
   plan tier in a row of choices). */
.slot.heavy {
  border-width: 2px;
  font-weight: 500;
}

/* .slot-callout - primary teal ring + floating narrow pill (mirrors
   .card-callout). Pill content is data-driven via `data-label="..."`. */
.slot.slot-callout {
  position: relative;
  border-color: var(--state-color, var(--c-primary));
  box-shadow: 0 0 0 1px var(--state-color, var(--c-primary));
}
.slot.slot-callout::before {
  content: attr(data-label);
  position: absolute;
  top: 0;
  left: 50%;
  transform: translate(-50%, -50%);
  height: 18px;
  line-height: 18px;
  padding: 0 8px;
  background: var(--state-color, var(--c-primary));
  color: white;
  font-family: var(--ff-body);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  white-space: nowrap;
  border-radius: var(--r-pill);
}
/* Hover - stack the ring + lift shadow + 2px upward shift, same pattern
   as .card-callout. State-aware via --state-color so coloured slot-callouts
   get matching halos. */
.slot.slot-callout:hover {
  box-shadow: 0 0 0 1px var(--state-color, var(--c-primary)), 0 4px 12px rgba(0, 0, 0, 0.08);
  transform: translateY(-2px);
}

/* App density - tighter padding + smaller font. */
.app .slot {
  padding: var(--s-2) var(--s-3);
  font-size: 12px;
  line-height: 18px;
}

/* .tree - hierarchical "tree" row container (file/folder, page tree,
   content library, entity relations, etc). NOT a real <table> - uses
   nested <div>s + CSS grid so sections can nest semantically in the DOM
   and collapse via a class on the parent (real <table> rows can't nest).
   Visually aligned to .table.app: same row dividers, hover bg, paddings,
   small body type.
 *
 * Purpose:    Hierarchical row visualisation with collapse/expand,
 *             multi-level cascading selection, and per-row metadata
 *             columns alongside the path label.
 * Use when:   File/folder browsers, page-tree sitemaps, content
 *             libraries, entity relations / dependency trees.
 * Avoid for:  Flat data (use .table), single-level lists (use plain
 *             <ul>), drill-down detail (use .table.expandable).
 * Pairs with: .chip.small.secondary for child-count badges, .checkbox
 *             for selection, table-in-card patterns for header bands +
 *             outer stats. JS hooks live in the styleguide script.
 *
 * Markup:
 *   <div class="tree" id="my-tree">
 *     <div class="tree-section">
 *       <div class="tree-row" style="--depth:0" data-id="/products">
 *         <div class="tree-row-lead">
 *           <span class="tree-toggle"></span>
 *           <input type="checkbox" class="checkbox">
 *           <span class="chip small secondary">12</span>
 *           <span class="tree-path">/products</span>
 *         </div>
 *         <div class="tree-cell">87</div>
 *         <div class="tree-cell"><span class="trend spark up"></span></div>
 *       </div>
 *       <div class="tree-body">
 *         <!-- nested .tree-section + .tree-row, each with --depth one
 *              greater than parent. Leaf rows use .tree-toggle-spacer
 *              instead of .tree-toggle to keep column alignment. -->
 *       </div>
 *     </div>
 *   </div>
 *
 * Selection: cascading with tri-state - section checkbox toggles all
 * descendants; parent goes .indeterminate when children are mixed.
 * Click any row (outside the toggle / checkbox) to toggle that row's
 * own checkbox. Selected items retrievable via:
 *   window.td.tree.getSelected('my-tree')  // returns array of data-id
 *
 * Count chip semantics: by default the chip shows ONLY the descendants
 * (e.g. a section with 5 child rows shows "5"). Add .count-self to the
 * .tree to include the parent itself in the count (e.g. "6" = 5 children
 * + the parent row, since some apps treat the section row as a real
 * selectable item alongside its descendants - matches OptimAI default). */
.tree {
  margin-bottom: var(--s-4);
  font-family: var(--ff-body);
  font-size: 12px;
  line-height: 18px;
  color: var(--c-body);
  /* No outer frame by default - the bare tree is just stacked rows
     with dividers. The frame comes from a wrapper (e.g. a .card) when
     composed. */
}
.tree-section.collapsed > .tree-body { display: none; }

.tree-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 60px 60px;
  align-items: center;
  gap: var(--s-3);
  padding: 6px var(--s-4);
  border-bottom: 1px solid var(--c-invert-2);
  cursor: pointer;
  transition: background 0.15s;
}
.tree-row:hover { background: var(--c-invert-2); }

/* First cell holds the controls slot (count chip + toggle + checkbox)
   followed by the path label. Indentation per nesting level via the
   --depth custom property set inline on each .tree-row (depth 0 = no
   indent). */
.tree-row-lead {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding-left: calc(var(--s-5) * var(--depth, 0));
  min-width: 0;
}
/* Controls slot - fixed-width, right-aligned. Chip (if any) sits to the
   left, toggle (if any) middle, checkbox always flush-right. The fixed
   width keeps the checkbox in the same X-position regardless of whether
   the row has a chip / toggle / both / neither - so leaf rows + section
   headers + mixed-depth rows all line up cleanly. Lifted from OptimAI's
   .ss-row-controls pattern. */
.tree-controls {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--s-2);
  width: 80px;
  flex-shrink: 0;
}
.tree-path {
  font-family: var(--ff-mono);
  font-size: 12px;
  color: var(--c-body);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.tree-cell {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

/* Toggle triangle - matches the .disclose pattern (secondary teal,
   rotates 90deg when section is expanded). Click to toggle collapse. */
.tree-toggle {
  display: inline-block;
  width: 0;
  height: 0;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 8px solid var(--c-secondary);
  transition: transform 0.15s, border-left-color 0.15s;
  flex-shrink: 0;
  cursor: pointer;
}
.tree-section:not(.collapsed) > .tree-row > .tree-row-lead > .tree-controls > .tree-toggle {
  transform: rotate(90deg);
}
.tree-toggle:hover { border-left-color: var(--c-primary); }

/* Count chip alignment - chips use vertical-align: 2px by default which
   nudges them above the line; tree rows are flex-aligned so we want the
   chip back on the line midline. */
.tree-row .chip { vertical-align: baseline; }

/* .modal - full-viewport scrim + flex-centred host for arbitrary content
   (typically a .card). Lifted from OptimAI's modal-overlay pattern but
   composes our canonical .card instead of duplicating styles into a
   parallel .modal-card class.
 *
 * Purpose:    Blocking dialog over the page, demanding user response.
 * Use when:   Confirmations, focused inputs (sign-in, edit details),
 *             destructive-action prompts, full-screen interruptions.
 * Avoid for:  Persistent state (use .notice), inline asides (use
 *             .callout), transient signals (use .ribbon).
 * Pairs with: A .card containing .card-header / .card-body / .card-footer
 *             slots; a .notice-close in the corner for explicit dismiss.
 *
 * Markup:
 *   <div class="modal hidden" id="my-modal">
 *     <div class="card" style="max-width: 400px;">
 *       <button class="notice-close" aria-label="Close">×</button>
 *       <div class="card-header"><h3>Title</h3></div>
 *       <p>Body...</p>
 *       <div class="card-footer">
 *         <button class="btn btn-outline">Cancel</button>
 *         <button class="btn btn-primary">Confirm</button>
 *       </div>
 *     </div>
 *   </div>
 *
 * Wiring (see styleguide JS): a button with `data-modal-open="my-modal"`
 * opens; click on the scrim, the .notice-close, or ESC dismisses. */
.modal {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  backdrop-filter: blur(4px);
  padding: var(--s-4);
}
.modal.hidden { display: none; }

/* The .card inside a .modal anchors the absolute corner-positioned
   .notice-close and zeros its bottom margin so flex centring isn't
   skewed by the trailing space. */
.modal .card {
  position: relative;
  margin-bottom: 0;
}
.modal .notice-close {
  position: absolute;
  top: var(--s-3);
  right: var(--s-3);
}

/* .sheet - full-page overlay that slides up from the bottom. White panel,
   no scrim - covers the entire viewport once open. Inner content sits
   centred with a 1200px max width. Lifted from OptimAI's legal-sheet
   pattern, normalised to canonical tokens.
 *
 * Purpose:    Full-page interruption with substantial scrollable content.
 * Use when:   Long-form content that needs full screen real estate -
 *             legal pages (terms, privacy), help docs, settings panels,
 *             onboarding flows.
 * Avoid for:  Quick confirmations (use .modal), persistent state (use
 *             .notice), inline asides (use .callout).
 * Pairs with: .sheet-inner for centred content; .sheet-close for the
 *             dismiss control (auto-positioned top-right when inside);
 *             any canonical heading / body / list components for the
 *             content itself.
 *
 * Markup:
 *   <div class="sheet" id="my-sheet">
 *     <button class="sheet-close" aria-label="Close">×</button>
 *     <div class="sheet-inner">
 *       <h1>Sheet title</h1>
 *       <p>Body...</p>
 *     </div>
 *   </div>
 *
 * Wiring (see styleguide JS): a button with `data-sheet-open="my-sheet"`
 * opens (adds .open); the .sheet-close inside or ESC dismisses. */
.sheet {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: white;
  transform: translateY(100%);
  transition: transform 0.4s cubic-bezier(0.25, 0.1, 0.25, 1);
  overflow-y: auto;
  padding: var(--s-6) var(--s-5) var(--s-8);
}
.sheet.open { transform: translateY(0); }

.sheet-inner {
  max-width: 1200px;
  margin: 0 auto;
}

/* Auto-position the .sheet-close at top-right when it sits directly inside
   a .sheet. The control's own visuals are defined in the Controls section. */
.sheet > .sheet-close {
  position: fixed;
  top: var(--s-3);
  right: var(--s-4);
  z-index: 200;
}

/* .tray-left / .tray-right - app-shell sidebars that PUSH the page rather
   than overlay it. Fixed-positioned to the viewport edges, full window
   height minus a small top + bottom margin so the inner-shadow border
   reads as a "cut-out" of the window edge (you never see the outside
   edge - radii on inner-facing corners only). Body padding adjusts via
   --tray-left-width / --tray-right-width CSS variables that the tray JS
   updates on every state change, so main content squeezes / reflows.
 *
 * Left tray states:
 *   default (no class) = peek (icons only, 56px wide)
 *   .expanded          = icons + labels (240px)
 *   .hidden            = collapsed off-screen (0px), set by external "Hide" toggle
 *
 * Right tray states:
 *   default (no class) = hidden (0px)
 *   .peek              = vertical heading visible (40px)
 *   .expanded          = full content (320px)
 *
 * See the styleguide JS for state-change wiring + body-padding sync. */
.tray-left,
.tray-right {
  position: fixed;
  /* Top + bottom anchors consume chrome vars + s-3 gap so trays sit
     between site/page headers and footers. Vars default to 0 when the
     respective chrome is absent. */
  top:    calc(var(--site-header-height, 0px) + var(--page-header-height, 0px) + var(--s-3));
  bottom: calc(var(--site-footer-height, 0px) + var(--page-footer-height, 0px) + var(--s-3));
  z-index: 50;
  background: var(--c-bg);
  /* Stacked inset shadows: 1px invert-1 hairline border + soft blurred
     inner shadow gives the "cut-out of the page surface" skeuomorphic
     depth Seth asked for. */
  box-shadow:
    inset 0 0 0 1px var(--c-invert-1),
    inset 0 2px 12px rgba(0, 0, 0, 0.08);
  overflow: hidden;
  white-space: nowrap;
  transition: width 0.3s ease;
}

.tray-left {
  left: 0;
  width: 56px;
  border-radius: 0 var(--r-lg) var(--r-lg) 0;
}
.tray-left.expanded { width: 240px; }
.tray-left.hidden   { width: 0; }

.tray-right {
  right: 0;
  width: 0;
  border-radius: var(--r-lg) 0 0 var(--r-lg);
}
.tray-right.peek     { width: 40px; }
.tray-right.expanded { width: 320px; }

/* Left tray - visibility toggle at top + nav menu below with extra space.
   Icons + labels render primary teal default, secondary teal on hover.
   Labels use Fredoka 300 (light) all-caps for the OS-sidebar look. */
.tray-left-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  margin-top: 4px;
  background: none;
  border: 0;
  cursor: pointer;
  color: var(--c-primary);
  transition: color 0.15s;
  flex-shrink: 0;
}
.tray-left-toggle:hover { color: var(--c-secondary); }
.tray-left-toggle .material-symbols-rounded { font-size: 24px; }

/* Agnostic inner slot for whatever gets poured into the left tray
   (typically a .tray-left-nav <ul> but could be anything: search box,
   custom widget, embedded form, etc). Parallel to .tray-right-content. */
.tray-left-content {
  height: 100%;
  overflow-y: auto;
}

.tray-left-nav {
  list-style: none;
  margin: 0;
  padding: var(--s-3) 0 0;
}
.tray-left-nav li { margin: 0; }
.tray-left-nav a {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  color: var(--c-primary);
  text-decoration: none;
  font-family: var(--ff-display);
  font-weight: 300;
  text-transform: uppercase;
  letter-spacing: 1px;
  transition: background 0.15s, color 0.15s;
}
/* Extra right padding when expanded so labels have breathing room
   against the inner-facing edge. */
.tray-left.expanded .tray-left-nav a { padding-right: var(--s-5); }
.tray-left-nav a:hover {
  background: var(--c-invert-2);
  color: var(--c-secondary);
}
.tray-left-nav .material-symbols-rounded {
  font-size: 22px;
  flex-shrink: 0;
  color: inherit;
  transition: color 0.15s;
}
/* Hide nav labels in peek / hidden states without reflow. Timing tuned
   to the 0.3s width transition: collapsing fades labels out immediately
   (so leading characters don't peek through the narrowing edge);
   expanding delays the fade-in until the tray is wide enough to host
   the full label. Labels stay in the layout flow so the .a width stays
   constant - only the visible glyphs flip opacity. */
.tray-left-nav a > span:not(.material-symbols-rounded) {
  opacity: 1;
  transition: opacity 0.1s ease 0.2s;
}
.tray-left:not(.expanded) .tray-left-nav a > span:not(.material-symbols-rounded) {
  opacity: 0;
  transition: opacity 0.1s ease 0s;
}

/* Right tray - peek heading (vertical) vs expanded content (horizontal).
   Heading style is shared - Fredoka 500, all caps, base scale, slightly
   wider letter-spacing than the canonical .eyebrow. Click peek heading
   to expand. */
.tray-right-peek,
.tray-right-heading {
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 20px;
  line-height: 30px;
  color: var(--c-primary);
  text-transform: uppercase;
  letter-spacing: 1.5px;
  margin: 0;
}
.tray-right-peek {
  width: 40px;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  cursor: pointer;
  padding-top: var(--s-4);
  writing-mode: vertical-rl;
  white-space: nowrap;
  user-select: none;
}
.tray-right .tray-right-peek    { display: none; }
.tray-right.peek .tray-right-peek { display: flex; }
.tray-right .tray-right-content { display: none; }
.tray-right.expanded .tray-right-content { display: block; width: 320px; }

.tray-right-content {
  height: 100%;
  padding: var(--s-4);
  overflow-y: auto;
}
.tray-right-content-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--s-4);
}

/* ============================================================
 * APP-SHELL CHROME — site-header / page-header / site-footer / page-footer
 * ============================================================
 * Four sticky chrome slots. Heights declared in tokens.css; this section
 * defines the visual + layout rules. All four are position:fixed so they
 * stay out of normal flow; body padding (top + bottom) consumes the
 * height vars to keep content clear.
 *
 *   site-header  — full width, top, always present, brand + nav + tools
 *   page-header  — sticky below site-header, contextual (e.g. section nav)
 *   page-footer  — sticky above site-footer, contextual (e.g. budget bar)
 *   site-footer  — full width, bottom, always present, copyright + tools
 *
 * Trays' top + bottom anchors also consume the chrome vars so they sit
 * cleanly between the headers and footers.
 * ============================================================ */

/* ---------- site-header ---------- */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--site-header-height);
  z-index: 100;
  background: white;
  border-bottom: 1px solid var(--c-invert-2);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  padding: 0 var(--s-5);
}
/* When page-header is present it takes the edge - drop site-header's
   shadow so we don't get a double cast at the top boundary. */
body.has-page-header .site-header { box-shadow: none; }
.site-header-inner {
  height: 100%;
  display: flex;
  align-items: center;
  gap: var(--s-5);
}

/* Site-header wordmark size override - the canonical .brand + .brand-alt
   from the inline-mention rules above (line ~209) handle weight + colour;
   here we just upsize for the chrome context. */
.site-header .brand {
  display: inline-flex;
  align-items: baseline;
  text-decoration: none;
  font-size: 24px;
  line-height: 1;
  letter-spacing: -0.01em;
  /* Visual tuning: lift the wordmark 1px so the optical centre of the
     descender-bearing brand glyphs lines up with the all-caps nav text
     to its right. Pairs with the +3px nudge on .site-nav-links below. */
  transform: translateY(-1px);
}

.site-nav-links {
  display: flex;
  align-items: center;
  gap: var(--s-5);
  list-style: none;
  margin: 0;
  padding: 0;
  /* Visual tuning: drop the nav 3px. With the brand at translateY(-1px)
     above, the brand baseline and the nav cap-line sit on the same
     optical row (the brand's descenders give it a higher visual centre
     under pure align-items:center). */
  transform: translateY(3px);
}
.site-nav-links li { margin: 0; }
.site-nav-links a {
  color: var(--c-primary);
  text-decoration: none;
  font-family: var(--ff-display);
  font-weight: 300;
  font-size: 16px;
  text-transform: uppercase;
  letter-spacing: 1px;
  transition: color 0.15s;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}
.site-nav-links a:hover { color: var(--c-secondary); font-weight: 500; }
.site-nav-links a.active { color: var(--c-secondary); font-weight: 500; }

/* Width-reservation trick: the link text bumps from 300 -> 500 on
   hover (and active), which would shift sibling links horizontally.
   Pre-render an invisible bold copy via ::after on the text span so
   each link reserves its bold-weight width up front. Requires
   data-label="..." on the .site-nav-text span. Lifted from
   optimai .nav-link. */
.site-nav-text {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  /* Override the global [class$="-label"] catch-all in base.css that
     forces color: #000 - we want this span to inherit the primary/
     secondary teal colour from its parent <a>. */
  color: inherit;
}
/* Pre-render BOTH weights as hidden pseudos so label width = max of
   either (don't have to assume which weight is wider in the variable
   font - can be non-monotonic). Both stacked in column flex below the
   visible text node. */
.site-nav-text::before,
.site-nav-text::after {
  content: attr(data-label);
  letter-spacing: 1px;
  height: 0;
  visibility: hidden;
  overflow: hidden;
  user-select: none;
  pointer-events: none;
}
.site-nav-text::before { font-weight: 300; }
.site-nav-text::after  { font-weight: 500; }
.site-nav-links .material-symbols-rounded {
  font-size: 20px;
  color: inherit;
  /* Lock the wght axis - Material Symbols are variable fonts whose
     stroke weight follows font-weight. Without this, the icon glyphs
     also subtly change width when the link bumps 300 -> 500 on hover,
     undoing the data-label width-reservation for the text. */
  font-variation-settings: 'wght' 400;
  font-weight: 400;
}

/* Right-side tools slot. App authors pour icon buttons (.btn.btn-avatar)
   or status indicators here. The slot itself is layout-only - children
   carry their own canonical button styling. */
.header-tools {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

/* Hamburger toggle - hidden above 800px breakpoint. */
.nav-hamburger {
  display: none;
  background: transparent;
  border: 0;
  padding: var(--s-2);
  cursor: pointer;
  width: 40px;
  height: 40px;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 5px;
  margin-left: auto;
}
.nav-hamburger span {
  display: block;
  width: 22px;
  height: 2px;
  background: var(--c-primary);
  border-radius: 2px;
  transition: transform 0.2s, opacity 0.2s;
}
.nav-hamburger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.nav-hamburger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-hamburger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

@media (max-width: 800px) {
  .nav-hamburger { display: flex; }
  .header-tools { margin-left: 0; }
  .site-nav-links {
    position: fixed;
    top: var(--site-header-height);
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    background: white;
    border-bottom: 1px solid var(--c-invert-2);
    padding: var(--s-3) 0;
    transform: translateY(-100%);
    transition: transform 0.25s ease;
    z-index: 99;
  }
  .site-nav-links.nav-open { transform: translateY(0); }
  .site-nav-links a { padding: var(--s-3) var(--s-5); }
  body.nav-locked { overflow: hidden; }
}

/* ---------- page-header ---------- */
/* Sticky contextual header beneath site-header. Typically scroll-to
   nav for in-page sections; can also host status readouts. Hard-left
   aligned for full-width layouts. Height becomes 0 when body lacks
   .has-page-header (default).

   z-index hierarchy: persistent chrome (site-header / site-footer) at
   100. Contextual chrome (page-header / page-footer) at 99 so a .down
   popover anchored INSIDE site-header (z-index 100) can extend across
   page-header (99) without being clipped. site-footer's .up popovers
   already worked via source order; page-footer at 99 mirrors the rule
   so behaviour doesn't depend on source order. */
.page-header {
  position: fixed;
  top: var(--site-header-height);
  left: 0;
  right: 0;
  height: var(--page-header-height);
  z-index: 99;
  background: white;
  border-bottom: 1px solid var(--c-invert-2);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  padding: 0 var(--s-5);
  overflow: hidden;
  transition: height 0.2s ease;
}
body.has-page-header .page-header { overflow: visible; }
.page-header-inner {
  height: 100%;
  display: flex;
  align-items: center;
  gap: var(--s-2);
  overflow-x: auto;
  scrollbar-width: none;
}
.page-header-inner::-webkit-scrollbar { display: none; }

/* Page-nav buttons - pill-style, ghost variant. Lifted EXACTLY from
   optimai .section-nav-btn: M PLUS body font, weight 500, normal case,
   mid-grey unstyled. Hover = secondary text + invert-2 bg. Active =
   white text + secondary bg. Visually quieter than .site-nav-links
   (which uses Fredoka uppercase) so the page-header reads as
   subordinate contextual chrome. */
.page-header .page-nav-btn {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 0 12px;
  font-family: var(--ff-body);
  font-weight: 400;
  font-size: 14px;
  line-height: 1;
  color: var(--c-mid);
  background: none;
  border: 0;
  border-radius: var(--r-pill);
  cursor: pointer;
  white-space: nowrap;
  text-decoration: none;
  transition: color 0.15s, background 0.15s;
}
.page-header .page-nav-btn:hover {
  color: var(--c-secondary);
  background: var(--c-invert-2);
}
.page-header .page-nav-btn.active {
  color: white;
  background: var(--c-secondary);
}

/* ---------- site-footer ---------- */
/* 3-zone sticky footer: tools-left | center copyright | tools-right.
   Lifted from optimai .app-footer pattern. Tool zones host icons +
   .btn-sm.btn-narrow buttons that vary by page context. */
.site-footer {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: var(--site-footer-height);
  z-index: 100;
  background: white;
  border-top: 1px solid var(--c-invert-2);
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
  padding: 0 var(--s-5);
}
/* When page-footer is present it takes the edge - drop site-footer's
   shadow so we don't get a double cast at the bottom boundary. */
body.has-page-footer .site-footer { box-shadow: none; }
.site-footer-inner {
  height: 100%;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-3);
}
.footer-tools-left,
.footer-tools-right {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}
.footer-tools-left  { justify-self: start; }
.footer-tools-right { justify-self: end; }
.footer-center {
  font-size: 12px;
  color: var(--c-mid);
  text-align: center;
  white-space: nowrap;
}
.footer-center a {
  color: var(--c-primary);
  font-weight: 400;
  transition: color 0.15s;
}
.footer-center a:hover { color: var(--c-secondary); }


/* ---------- page-footer ---------- */
/* Sticky contextual footer above site-footer. Hosts page-scoped
   summary content - selection counts, totals, status, action buttons.
   Lifted shape from optimai .ss-budget "budget bar". Height becomes
   0 when body lacks .has-page-footer (default). */
.page-footer {
  position: fixed;
  bottom: var(--site-footer-height);
  left: 0;
  right: 0;
  height: var(--page-footer-height);
  z-index: 99;  /* matches page-header rationale - see comment above */
  background: white;
  border-top: 1px solid var(--c-invert-2);
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
  padding: 0 var(--s-5);
  /* overflow:hidden when collapsed (height 0) so the contracted state
     is clean; overflow:visible when expanded so popover-menus / select.up
     dropdowns can spill outside the strip. */
  overflow: hidden;
  transition: height 0.2s ease;
}
body.has-page-footer .page-footer { overflow: visible; }
.page-footer-inner {
  height: 100%;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-4);
}
.page-footer-left,
.page-footer-center,
.page-footer-right {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  min-width: 0;
}
.page-footer-left   { justify-self: start; }
.page-footer-center { justify-self: center; }
.page-footer-right  { justify-self: end; }
.page-footer-summary {
  font-family: var(--ff-display);
  font-weight: 500;
  font-size: 14px;
  color: var(--c-primary);
  white-space: nowrap;
}
.page-footer-meta {
  font-size: 13px;
  color: var(--c-mid);
  white-space: nowrap;
}
.page-footer-actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

/* Scroll-to anchor offset - any element with an id that page-header
   buttons target needs to scroll-margin past the fixed chrome above
   it. Convention: anchor IDs prefixed `section-`. */
[id^="section-"] {
  scroll-margin-top: calc(var(--site-header-height, 0px) + var(--page-header-height, 0px) + var(--s-4));
}

/* ──────────────────────────────────────────────────────────
 * .popover-menu - small floating popup panel attached to a trigger
 * (button, link, anything). Lifted from optimai .account-menu.
 *
 * Composition:
 *   <span class="menu-anchor">
 *     <button data-popover-menu-open="my-menu">Account</button>
 *     <div class="popover-menu down hidden" id="my-menu">
 *       <div class="menu-header">...optional...</div>
 *       <button class="menu-item"><span class="material-symbols-rounded">...</span> Label</button>
 *       <div class="menu-divider"></div>
 *       <a class="menu-item" href="...">...</a>
 *     </div>
 *   </span>
 *
 * Direction modifiers: .popover-menu.down = below trigger, .popover-menu.up = above.
 * Toggled via JS: data-popover-menu-open="<id>" on trigger; outside-click +
 * ESC dismiss. See the canonical doc block in the script section.
 * ────────────────────────────────────────────────────────── */
.popover-menu-anchor {
  position: relative;
  display: inline-flex;
  align-items: center;
}
.popover-menu {
  position: absolute;
  left: 0;
  background: white;
  border: 1px solid var(--c-invert-1);
  border-radius: var(--r-md);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  min-width: 180px;
  padding: var(--s-1) 0;
  /* Chrome elements (site-header, page-header, page-footer, site-footer)
     all sit at z-index:100. A .down popover anchored inside site-header
     extends below the header's bottom edge, and a .up popover anchored
     inside site-footer extends above its top edge - both need to paint
     above any chrome element they overlap. z-index 200 puts them above
     all four. */
  z-index: 200;
  white-space: nowrap;
}
.popover-menu.hidden { display: none; }
.popover-menu.down { top: calc(100% + var(--s-1)); }
.popover-menu.up   { bottom: calc(100% + var(--s-1)); }
.popover-menu-header {
  padding: var(--s-3) var(--s-4);
  font-size: 12px;
  color: var(--c-mid);
  line-height: 1.4;
}
.popover-menu-header strong {
  display: block;
  font-size: 13px;
  color: var(--c-body);
  font-weight: 500;
}
.popover-menu-item {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  padding: var(--s-3) var(--s-4);
  font-family: var(--ff-body);
  font-size: 14px;
  line-height: 20px;
  font-weight: 400;
  color: var(--c-body);
  background: none;
  border: 0;
  cursor: pointer;
  text-decoration: none;
  text-align: left;
  transition: background 0.12s, color 0.12s;
}
.popover-menu-item:hover {
  background: var(--c-invert-2);
  color: var(--c-secondary);
}
.popover-menu-item .material-symbols-rounded {
  font-size: 18px;
  color: var(--c-primary);
  flex-shrink: 0;
}
.popover-menu-item:hover .material-symbols-rounded { color: var(--c-secondary); }
.popover-menu-divider {
  height: 1px;
  background: var(--c-invert-2);
  margin: var(--s-1) 0;
}

/* ──────────────────────────────────────────────────────────
 * .select.up - select variant whose dropdown opens ABOVE the
 * trigger and whose disclose arrow points up. Same as default
 * .select otherwise. Class is propagated onto the .combo-select
 * wrapper at JS-enhancement time.
 * ────────────────────────────────────────────────────────── */
.combo-select.up .combo-dropdown {
  top: auto;
  bottom: 100%;
  margin-top: 0;
  margin-bottom: 4px;
}
.combo-select.up .combo-toggle::after {
  transform: rotate(180deg);
}



/* ──────────────────────────────────────────────────────────
 * .spinner - circular loading indicator. Pure CSS. Default
 * size 32px; .sm = 16px (inline-in-button), .lg = 48px
 * (block / state container). Colour synonyms swap the arc
 * colour and the track fade.
 * ────────────────────────────────────────────────────────── */
.spinner {
  display: inline-block;
  width: 32px;
  height: 32px;
  border: 3px solid var(--c-invert-2);
  border-top-color: var(--c-primary);
  border-radius: 50%;
  animation: tdSpin 1.4s linear infinite;
  vertical-align: middle;
}
.spinner.sm { width: 16px; height: 16px; border-width: 2px; }
.spinner.lg { width: 48px; height: 48px; border-width: 4px; }
.spinner.secondary { border-top-color: var(--c-secondary); }
.spinner.success   { border-top-color: var(--c-green); }
.spinner.warning   { border-top-color: var(--c-amber); }
.spinner.danger    { border-top-color: var(--c-red); }
.spinner.white {
  border-color: rgba(255, 255, 255, 0.3);
  border-top-color: white;
}
.spinner.fast { animation-duration: 0.7s; }
@keyframes tdSpin { to { transform: rotate(360deg); } }

/* ──────────────────────────────────────────────────────────
 * .progress - linear progress bar. Track + fill, --value
 * (0-100) controls fill width. Use for uploads, scans,
 * batch ops, anything time-bounded with a known end.
 * Colour synonyms swap the fill colour. Add .indeterminate
 * for an unknown-progress shimmer.
 * ────────────────────────────────────────────────────────── */
.progress {
  width: 100%;
  height: 8px;
  background: var(--c-invert-2);
  border-radius: var(--r-pill);
  overflow: hidden;
}
.progress.lg { height: 12px; }
.progress.sm { height: 4px; }
.progress-bar {
  height: 100%;
  width: calc(var(--value, 0) * 1%);
  background: var(--c-primary);
  border-radius: inherit;
  transition: width 0.4s ease;
}
.progress.secondary .progress-bar { background: var(--c-secondary); }
.progress.success   .progress-bar { background: var(--c-green); }
.progress.warning   .progress-bar { background: var(--c-amber); }
.progress.danger    .progress-bar { background: var(--c-red); }
.progress.indeterminate .progress-bar {
  width: 40%;
  animation: tdProgressIndet 2.4s ease-in-out infinite;
}
.progress.indeterminate.fast .progress-bar { animation-duration: 1.2s; }
@keyframes tdProgressIndet {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(350%); }
}

/* ──────────────────────────────────────────────────────────
 * .skeleton - shimmering placeholder for content not yet
 * loaded. Use a stack of skeleton blocks while a fetch
 * resolves (pairs with the canonical fetch-on-trigger
 * pattern). Default = block; .line for text rows; .circle
 * for avatars/icons. Width/height inline as needed.
 * ────────────────────────────────────────────────────────── */
.skeleton {
  display: block;
  background:
    linear-gradient(90deg,
      var(--c-invert-2) 0%,
      var(--c-invert-1) 50%,
      var(--c-invert-2) 100%);
  background-size: 200% 100%;
  border-radius: var(--r-sm);
  animation: tdShimmer 2.8s ease-in-out infinite;
  min-height: 16px;
}
.skeleton.line { height: 14px; border-radius: var(--r-sm); }
.skeleton.line.lg { height: 20px; }
.skeleton.line.sm { height: 10px; }
.skeleton.circle { border-radius: 50%; aspect-ratio: 1; }
.skeleton.fast { animation-duration: 1.4s; }
@keyframes tdShimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}


/* ──────────────────────────────────────────────────────────
 * Layout switcher (driven by footer-tools-right "Show styles"
 * select). When body has .show-default-only, the .app panels in
 * .split.split-flip wrappers are hidden; opposite for .show-app-only.
 * Default state (no class) shows both panels side-by-side.
 *
 * Border + padding suppression on the now-orphan visible panel
 * keeps the divider from appearing without its sibling.
 * ────────────────────────────────────────────────────────── */
body.show-default-only .split.split-flip > section.app,
body.show-app-only     .split.split-flip > section:not(.app) {
  display: none;
}
/* Drop ALL vertical section dividers in single-mode views. Targets
   both the .split-flip border-right on first-child AND the standard
   .split border-left on second-child, so the shared (non-paired)
   Colours / Spacing / Radii block also loses its inter-column
   divider in single-mode views. */
body.show-default-only .split > :first-child + *,
body.show-app-only     .split > :first-child + * {
  border-left: 0;
  padding-left: 0;
}
body.show-default-only .split.split-flip > :first-child,
body.show-app-only     .split.split-flip > :first-child {
  border-right: 0;
  padding-right: 0;
}

/* In single-mode views, the shared (non-flipped) .split wrappers
   stack their two child sections vertically so we get a single column
   flow throughout the page. (Currently only the Colours/Spacing+Radii
   block uses .split without .split-flip.) */
body.show-default-only .split:not(.split-flip),
body.show-app-only     .split:not(.split-flip) {
  flex-direction: column;
}
body.show-default-only .split:not(.split-flip) > *,
body.show-app-only     .split:not(.split-flip) > * {
  width: 100%;
  flex: 0 0 auto;
}

/* Single-mode-only Colours layout: H2 spans the top, swatch rows 1+2
   (brand + invert) sit in the left column, remaining swatch rows
   (body/mid + status + status-fade) sit in the right column. */
body.show-default-only > .split:not(.split-flip) > section:first-child,
body.show-app-only     > .split:not(.split-flip) > section:first-child {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--s-5);
  align-items: start;
}
body.show-default-only > .split:not(.split-flip) > section:first-child > h2,
body.show-app-only     > .split:not(.split-flip) > section:first-child > h2 {
  grid-column: 1 / -1;
}
body.show-default-only > .split:not(.split-flip) > section:first-child > .swatches:nth-child(n+5),
body.show-app-only     > .split:not(.split-flip) > section:first-child > .swatches:nth-child(n+5) {
  grid-column: 2;
}


/* ──────────────────────────────────────────────────────────
 * Fixed-width mode (toggled by footer-tools-right "Full width" toggle).
 * Default = full width edge to edge. Fixed mode caps the in-flow content
 * AND the inner-container of site-header / page-header / page-footer at
 * 1200px (lifted from optimai). Chrome backgrounds + site-footer remain
 * edge-to-edge so the page-tools (avatar, settings, copy, controls) stay
 * hard-pinned to the viewport edges in either mode.
 * ────────────────────────────────────────────────────────── */
body.fixed-width > .split,
body.fixed-width > hr {
  max-width: 1200px;
  margin-left: auto;
  margin-right: auto;
}
body.fixed-width .site-header-inner,
body.fixed-width .page-header-inner,
body.fixed-width .page-footer-inner {
  max-width: 1200px;
  margin-left: auto;
  margin-right: auto;
}
/* Fixed-width nav alignment overrides:
   - page-header nav-pills centred as a group
   - site-header nav-links right-aligned (sit immediately left of the
     header-tools cluster). header-tools loses its margin-left:auto so
     nav and tools form a single right-anchored cluster instead of
     splitting the free space. */
body.fixed-width .page-header-inner { justify-content: center; }
body.fixed-width .site-nav-links { margin-left: auto; }
body.fixed-width .header-tools   { margin-left: 0; }


/* Page-bg switcher (driven by footer Page BG select). */
body.page-bg-bg       { background: var(--c-bg); }
body.page-bg-gradient {
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--c-tertiary) 25%, white) 0%,
    white 50%,
    color-mix(in srgb, var(--c-tertiary) 25%, white) 100%);
  background-attachment: fixed;
}


/* Main-bg switcher (driven by footer Main BG select). Renders a
   fixed-positioned layer behind the content area between the chrome
   strips. In full-width mode it spans viewport-wide; in fixed-width
   it clamps to the 1200px content column. z-index:-1 keeps it above
   body bg (Page BG) but below content. */
body::before {
  content: '';
  position: fixed;
  top: calc(var(--site-header-height, 0px) + var(--page-header-height, 0px));
  bottom: calc(var(--site-footer-height, 0px) + var(--page-footer-height, 0px));
  /* Tray-aware AND symmetric: max() of the two tray widths on each side
     so the bg stays centred when trays differ in width (matches body
     padding logic in base.css). */
  left:  max(var(--tray-left-width, 0px), var(--tray-right-width, 0px));
  right: max(var(--tray-left-width, 0px), var(--tray-right-width, 0px));
  z-index: -1;
  pointer-events: none;
  background: transparent; /* default - matches main-bg=none */
  transition: left 0.3s ease, right 0.3s ease;
}
body.fixed-width::before {
  /* In fixed-width mode, centre within the body's content area (the
     viewport minus any visible tray push), capped at 1200px. */
  left: calc(50% + (var(--tray-left-width, 0px) - var(--tray-right-width, 0px)) / 2);
  right: auto;
  width: min(1200px, calc(100vw - var(--tray-left-width, 0px) - var(--tray-right-width, 0px)));
  transform: translateX(-50%);
}
body.main-bg-white::before { background: white; }
body.main-bg-bg::before    { background: var(--c-bg); }
