/* ============================================================================
   Vermeiren master dashboard — shared shell.
   ONE source of truth: D:\vermeiren\shared_ui\shell.css
   Copied into each app's static/ by shared_ui/sync_ui.py. Never edit the copies.

   Used by all three apps:
     bot/dashboard_static/       approval queue + analytics   (/)
     translation_pipeline/static/ translations                (/translate/)
     campaign_pipeline/static/    launch campaigns            (/campaign/)
   ========================================================================== */

/* The 2026-08-05 direction (shared_ui/DESIGN_RULES.md): light by default, glass
   over a navy-and-teal atmosphere, one 4px spacing scale, Vermeiren navy as the
   accent and red as danger only. Token NAMES are unchanged from the old shell,
   which is what lets the 11 live pages inherit the direction by value swap:
   87% of their colour decisions already read a token. The reference for every
   value here is ui_mockups/new_shell.html. */
:root {
  /* ── the scale ── */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px; --s8: 64px;

  --frame:    20px;  /* every floating thing keeps this off every edge */
  --rail-col: 44px;  /* the column every icon and the logo centres in */
  --rail-pad: 12px;  /* the rail's own padding, both sides */
  /* DERIVED, never typed: a typed width beside a fixed column is how every rail
     icon sat exactly 1.00px right of centre in the first pass. */
  --rail-w:   calc(var(--rail-col) + var(--rail-pad) * 2);
  --rail-open: 220px;
  --util-h:    42px;  /* the floating capsule, measured; content clears it */

  /* ── light: the default ── */
  --page:           #E9EBEF;   /* cool stone, biased toward the navy */
  --page-2:         #F3F5F8;
  --surface-1:      rgba(255,255,255,0.68);
  --surface-2:      rgba(255,255,255,0.92);
  --glass-lift:     rgba(255,255,255,0.90);
  /* The one surface that is NOT glass, and the reason is legibility rather than
     taste: a modal is text laid over text. --surface-1 is 68% opaque, so the
     campaign cards behind the picture-request dialog read straight through it
     ("Kuarz TIER 1" and "ALESIA" were both legible on the deployed page,
     2026-08-12). Glass is right for a panel that sits IN the page and wrong for
     one that covers it, so every <dialog> takes this instead. */
  --surface-solid:  #FFFFFF;
  --border:         rgba(16,26,44,0.12);
  --grid:           rgba(16,26,44,0.08);
  --ghost:          rgba(16,26,44,0.045);
  --baseline:       rgba(16,26,44,0.25);

  --text-primary:   #101724;
  --text-secondary: #434D5C;
  --muted:          #69737F;

  /* ── the brand ladder, from brand_kit/tokens/vermeiren.tokens.json ──────────
     b700 is the blue MEASURED inside the vector master, and every other blue in
     the ladder is derived from it. Written out here rather than imported because
     nothing yet consumes 61 custom properties, and asserted against the kit's own
     token file by shared_ui/check_brand_tokens.py, so a hand edit here or a new
     kit drop cannot silently disagree.

     This platform was carrying FOUR values for one colour: #163683 in the kit,
     #17408B here, #1F307A in the campaign overlay's brand.json, #061689 in the
     old rail logo. Nobody could see it, because no two of them were ever on the
     same screen. b700 is now the only one. */
  --vm-b900: #0C1F4E;  --vm-b800: #122C6B;  --vm-b700: #163683;
  --vm-b600: #24479C;  --vm-b500: #3A5FB8;  --vm-b400: #6B82BE;
  --vm-b300: #7E93C9;  --vm-b100: #C9D3E8;  --vm-b050: #EEF1F8;

  /* WAS #17408B. b700 on --page measures 9.31:1 against the old 8.21:1, so the
     one colour on this platform that means "Vermeiren" got more legible, not
     less, by becoming the real one. */
  --brand:          var(--vm-b700);
  --brand-ink:      #FFFFFF;
  --brand-soft:     rgba(22,54,131,0.10);
  --teal:           #3B7B92;   /* the second voice; never competes with navy */

  --good:           #1D7A47;
  --warn:           #96650F;
  --critical:       #B4342B;   /* danger only. never a button that is not one */

  --series-1: #2a78d6;  --series-2: #1baf7a;  --series-3: #eda100;
  --series-4: #008300;  --series-5: #4a3aa7;  --series-6: #e34948;

  --radius:      14px;
  --radius-s:    10px;
  --radius-pill: 999px;
  --shadow:    0 1px 2px rgba(16,26,44,.06), 0 8px 26px rgba(16,26,44,.09);
  --shadow-lg: 0 18px 56px rgba(16,26,44,.16);
  --blur: blur(20px) saturate(1.35);

  --sans: system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", sans-serif;
  --mono: ui-monospace, "Cascadia Mono", Consolas, monospace;

  /* Everything the browser draws itself (select popups, date calendars,
     scrollbars, spinners) follows the theme instead of glaring white in dark.
     Declared HERE and in the dark block below, exactly where the tokens switch:
     shell.js always stamps data-theme on the root, so a prefers-color-scheme
     path would let native chrome disagree with the stamped theme. */
  color-scheme: light;
  /* Checkboxes and radios in the brand navy. accent-color is the design-system
     answer at the right cost: never rebuild them as custom elements. */
  accent-color: var(--brand);
  /* The select's closed-state chevron. Drawn inline rather than fetched (the
     box has no CDN); the stroke is --muted's value and lives in this token
     block on purpose, beside the colour it repeats. */
  --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%2369737F' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
:root[data-theme="dark"] {
  --page:           #090C12;   /* near-black, biased toward the navy */
  --page-2:         #0C1017;
  --surface-1:      rgba(255,255,255,0.050);
  --surface-2:      rgba(255,255,255,0.080);
  --glass-lift:     rgba(255,255,255,0.09);
  /* Lifted off --page rather than equal to it, so a dialog still reads as being
     in front of the page once it stops being transparent. */
  --surface-solid:  #151A23;
  --border:         rgba(255,255,255,0.11);
  --grid:           rgba(255,255,255,0.07);
  --ghost:          rgba(255,255,255,0.055);
  --baseline:       rgba(255,255,255,0.28);

  --text-primary:   #E7EAEF;
  --text-secondary: #A3ABB8;
  --muted:          #7A8390;

  /* WAS #6E9BE8, a lift invented here and belonging to no ladder, which made it a
     fifth Vermeiren blue. b300 is the kit's own top of the ladder and the kit
     permits it as informative line work on a b800 or b900 ground; this canvas is
     darker than either. It measures 6.43:1 on --page against the old 7.01:1, so
     it stays well clear of the 4.5:1 body floor and is now a brand value. */
  --brand:          var(--vm-b300);
  --brand-ink:      #060F1E;
  --brand-soft:     rgba(126,147,201,0.15);
  --teal:           #6FA3B8;

  --good:           #6FC08D;
  --warn:           #E0A550;
  --critical:       #E4655C;

  --series-1: #3987e5;  --series-2: #199e70;  --series-3: #c98500;
  --series-4: #008300;  --series-5: #9085e9;  --series-6: #e66767;

  --shadow:    0 1px 2px rgba(0,0,0,.38), 0 8px 28px rgba(0,0,0,.34);
  --shadow-lg: 0 18px 60px rgba(0,0,0,.55);
  --blur: blur(22px) saturate(1.5);

  color-scheme: dark;
  --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%237A8390' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

* { box-sizing: border-box; }

body {
  margin: 0; background: var(--page); color: var(--text-primary);
  font-family: var(--sans);
  font-size: 13.5px; line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  min-height: 100vh;
}

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

/* A link wears the brand, visited included: browser blue on a navy platform
   (and purple after a click) is the same defect as the grey "Choose File"
   button. Element-level, so every class rule a page already has still wins. */
a { color: var(--brand); text-underline-offset: 2px; }

/* A section heading inside a card, a form or a details block. There was no bare
   h3 rule, only `.panel > h3`, `.prose h3` and `.gterm h3`, so the nineteen
   `<h3>` across content, create, deck, index and newsletter were drawn at the
   browser's own 1.17em with a 1em margin above and below: the one place in this
   interface where the type was not ours. Same values as `.panel > h3`, because
   the design already decided what an h3 is and a bare one is not a different
   thing. Element-level, so every class rule a page already has still wins. */
h3 { margin: 0 0 var(--s1); font-size: 15px; font-weight: 620; letter-spacing: -0.015em; }

/* Glass with nothing behind it is just grey, so there is something for it to
   refract: navy low left, teal high right. Fixed, so scrolling moves the
   content across the light rather than moving the light. Negative z-index:
   it paints above the body's background and under everything in flow. */
.sky {
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background:
    radial-gradient(58vw 52vw at 6% 90%,  color-mix(in srgb, var(--brand) 16%, transparent), transparent 62%),
    radial-gradient(52vw 46vw at 92% 2%,  color-mix(in srgb, var(--teal)  16%, transparent), transparent 60%),
    var(--page-2);
}
:root[data-theme="dark"] .sky {
  background:
    radial-gradient(58vw 52vw at 6% 90%,  color-mix(in srgb, var(--brand) 24%, transparent), transparent 62%),
    radial-gradient(52vw 46vw at 92% 2%,  color-mix(in srgb, var(--teal)  20%, transparent), transparent 60%),
    var(--page-2);
}

/* ══ THE RAIL ═════════════════════════════════════════════════════════════
   The app switcher, as a floating vertical pill rail detached from the left
   edge. Each entry carries what is waiting behind it (the live counts), which
   is the thesis: the dashboard tells you where the work is instead of you
   opening nine pages to find out.

   Class names are sb-prefixed on purpose: three live pages define their own
   `.rail`, two their own `.mark`, one its own `.lbl`, and an unprefixed shell
   chrome would restyle their elements (the `.msg.bot a` trap, shell-sized).
   shell.js adds `railed` to <body>, so a page that loads this file without
   mounting the shell (login) keeps its layout. */
body.railed { padding-left: calc(var(--frame) * 2 + var(--rail-w)); padding-top: var(--s7); }

.shellbar {
  position: fixed; left: var(--frame); top: 50%; transform: translateY(-50%);
  z-index: 40;
  width: var(--rail-w);
  padding: var(--rail-pad);
  display: flex; flex-direction: column; gap: var(--s2);
  border-radius: 34px;
  background: var(--surface-1);
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  /* The hairline is an INSET RING, not a border: border-box makes a 1px border
     eat 2px of the content box, which is how every icon in the first pass sat
     exactly 1.00px right of centre. A ring paints and consumes nothing. */
  border: 0;
  box-shadow: var(--shadow-lg),
              inset 0 0 0 1px var(--border),
              inset 0 1px 0 var(--glass-lift);
  transition: width .34s cubic-bezier(.2,.8,.25,1);
  overflow: hidden;
}
.shellbar:hover, .shellbar:focus-within { width: var(--rail-open); }
.shellbar hr { border: 0; border-top: 1px solid var(--grid); margin: var(--s1) 0; width: 100%; }

/* the mark: it fills the column, so it is centred by the same geometry rather
   than by a nudge. A white plate in both themes, because the roundel's navy
   outer ring would disappear against the dark canvas. */
.sb-mark { display: flex; align-items: center; gap: var(--s3); text-decoration: none; color: inherit; }
.sb-mark .sb-glyph {
  width: var(--rail-col); height: var(--rail-col); min-width: var(--rail-col);
  border-radius: var(--radius-pill); background: #FFFFFF;
  display: grid; place-items: center;
  box-shadow: 0 3px 14px color-mix(in srgb, var(--brand) 30%, transparent);
}
.sb-mark .sb-glyph img { width: 100%; height: 100%; display: block; border-radius: var(--radius-pill); }
.sb-mark .sb-wm { white-space: nowrap; opacity: 0; transition: opacity .2s .08s; }
.shellbar:hover .sb-wm, .shellbar:focus-within .sb-wm { opacity: 1; }
.sb-mark .sb-wm b { display: block; font-size: 13px; font-weight: 640; letter-spacing: -0.01em; }
.sb-mark .sb-wm span { display: block; font-size: 10px; color: var(--muted);
                       text-transform: uppercase; letter-spacing: .13em; }

/* a tab. No horizontal padding: the icon box IS the column, so it centres. */
.sb-nav {
  position: relative;
  display: flex; align-items: center; gap: var(--s3);
  padding: 0;
  border-radius: var(--radius-pill);
  text-decoration: none; color: var(--text-secondary);
  transition: background .18s, color .18s;
}
.sb-nav:hover { background: var(--ghost); color: var(--text-primary); }
/* A rail entry that ACTS rather than navigates is a <button>, and a button brings the
   browser's own chrome and font with it. Two of them are drawn here -- the phone's More
   and the admin's consult trigger -- so the reset belongs on the rail's own class rather
   than being written again per control, which is how the second one was done and how a
   third would have been. `.cslt-trigger` keeps what is specific to it (`width: 100%`,
   `text-align: left`); those are not set here, so the sidebar button is unchanged. */
button.sb-nav { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
.sb-nav .sb-ico {
  position: relative;
  width: var(--rail-col); height: var(--rail-col); min-width: var(--rail-col);
  display: grid; place-items: center;
  border-radius: var(--radius-pill);
  transition: color .18s;
}
/* The pill that lights up is `inset` inside the column, so it is centred by
   construction: inset cannot drift the way a typed width did. */
.sb-nav .sb-ico::before {
  content: ""; position: absolute; inset: var(--s1);
  border-radius: var(--radius-pill);
  transition: background .18s, box-shadow .18s;
}
.sb-nav svg { position: relative; z-index: 1; }
.sb-nav .sb-lbl { white-space: nowrap; font-size: 13px; opacity: 0; transition: opacity .2s .08s; }
.shellbar:hover .sb-lbl, .shellbar:focus-within .sb-lbl { opacity: 1; }
.sb-nav.current { color: var(--text-primary); background: var(--brand-soft); }
.sb-nav.current .sb-ico { color: var(--brand-ink); }
.sb-nav.current .sb-ico::before {
  background: var(--brand);
  box-shadow: 0 2px 12px color-mix(in srgb, var(--brand) 42%, transparent);
}

/* What is waiting behind this pill. Anchored right in both states, so it never
   clips against the rail edge and the eye keeps it in one place. */
.sb-wait {
  position: absolute; right: var(--s1); top: var(--s1);
  min-width: 18px; height: 18px; padding: 0 var(--s1);
  border-radius: var(--radius-pill);
  display: grid; place-items: center;
  font-size: 10px; font-weight: 700; font-variant-numeric: tabular-nums;
  transition: right .34s cubic-bezier(.2,.8,.25,1), top .2s, transform .2s;
}
.shellbar:hover .sb-wait, .shellbar:focus-within .sb-wait {
  right: var(--s3); top: 50%; transform: translateY(-50%);
}

/* ── the floating capsule, top right: theme and who ─────────────────────── */
.shellutil {
  position: fixed; top: var(--frame); right: var(--frame); z-index: 40;
  display: flex; align-items: center; gap: var(--s1);
  padding: var(--s1); border-radius: var(--radius-pill);
  background: var(--surface-1);
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  border: 0;
  box-shadow: var(--shadow), inset 0 0 0 1px var(--border), inset 0 1px 0 var(--glass-lift);
}
.shellutil .sb-who {
  display: flex; align-items: center; gap: var(--s2);
  padding: 0 var(--s2) 0 var(--s3);
  font-size: 12px; color: var(--text-secondary); white-space: nowrap;
}
.shellutil .iconbtn { border: 0; border-radius: var(--radius-pill); padding: var(--s2) var(--s3); }
/* The interface language, beside the theme toggle because it is the same kind of
   control: how this page looks to me, on this machine. The code is a label, not a
   translated word, and it names the language the page is IN -- it named where the
   button went while this was a two-way toggle, which it stopped being on
   2026-08-31. Painted for everybody now, admins included: it decides which
   market's figures the products bot answers with, and an admin had no way to say. */
.shellutil .lg-btn { gap: var(--s1); }
.lg-code { font-size: 11px; font-weight: 700; letter-spacing: .03em; }
/* The picker. Same [open] discipline as .cmpdlg and .cslt-dlg: no `display` in
   the base rule, or a <dialog> shows on every page that loads this file. */
.lg-dlg {
  padding: 0; border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-solid); color: var(--text-primary);
  box-shadow: var(--shadow-lg);
  width: min(340px, calc(100vw - 28px)); max-width: none;
  max-height: calc(100vh - 32px);
  flex-direction: column;                 /* only applies once [open] displays it */
}
.lg-dlg[open] { display: flex; }
.lg-dlg::backdrop { background: rgba(11,11,11,.42); }
:root[data-theme="dark"] .lg-dlg::backdrop { background: rgba(0,0,0,.62); }
.lg-head {
  display: flex; align-items: center; gap: var(--s3); flex: 0 0 auto;
  padding: var(--s3) var(--s4);
  border-bottom: 1px solid var(--border); background: var(--surface-2);
}
.lg-head .lg-x { margin-left: auto; flex: none; }
.lg-list { padding: var(--s3); overflow-y: auto; display: grid; gap: 2px; }
.lg-row {
  display: flex; align-items: center; gap: var(--s3); width: 100%;
  padding: var(--s2) var(--s3); border: 0; border-radius: var(--radius-s);
  background: transparent; color: inherit; font: inherit; text-align: left;
  cursor: pointer;
}
.lg-row .lg-name { flex: 1 1 auto; }
.lg-row .lg-code { color: var(--muted); }
.lg-row:hover { background: var(--surface-2); }
/* The one that is on is stated, not merely darker: a colour difference alone is
   not an answer to "which am I reading now" for anyone who cannot see it, which
   is what aria-selected on the row is for. */
.lg-row.on { background: var(--surface-2); font-weight: 600; }
.lg-row.on .lg-code { color: var(--brand); }
/* The name, which for an admin is the link into Accounts (A2, 2026-08-11). Same
   size and colour as the span it replaces, so nothing about the capsule moved;
   it just answers a click now. */
.shellutil .sb-nm { display: inline-flex; align-items: center; gap: var(--s2);
                    color: inherit; text-decoration: none; }
a.sb-nm:hover { color: var(--text-primary); }
/* Below 640 the NAME goes and the glyph stays. Hiding `.sb-nm` outright, which
   is what this rule used to do, would take the only entrance to Accounts off
   every phone. A team account has no glyph, so it renders exactly as before. */
@media (max-width: 640px) { .shellutil .sb-who .sb-nm .sb-lbl { display: none; } }

.iconbtn {
  background: none; border: 1px solid var(--border); border-radius: var(--radius-s);
  padding: 6px 9px; cursor: pointer; color: var(--text-secondary);
  display: inline-flex; align-items: center; justify-content: center;
  line-height: 1; text-decoration: none;
  font: inherit;
}
/* A page that TYPES an iconbtn's size must also zero the padding: border-box
   leaves a 26px box 8px of content for an 11px glyph and the icon sits off
   centre — the typed-width trap again. Sized means padding 0, and the flex
   centring above does the rest. */
.iconbtn:hover { background: var(--ghost); color: var(--text-primary); }

/* ── the rail below 880: it moves to the bottom edge ────────────────────── */
@media (max-width: 880px) {
  body.railed {
    padding-left: 0;
    padding-top: calc(var(--frame) + var(--util-h) + var(--s3));
    /* clears the bottom rail: its height plus the frame it floats in, plus a
       gap, so the last card is never underneath it. */
    padding-bottom: calc(68px + var(--frame) + var(--s7));
  }
  .shellbar {
    flex-direction: row; width: auto; left: var(--frame); right: var(--frame);
    top: auto; bottom: var(--frame); transform: none;
    border-radius: 28px; overflow-x: auto; gap: var(--s1);
    justify-content: space-around;
  }
  .shellbar:hover, .shellbar:focus-within { width: auto; }
  .shellbar .sb-mark, .shellbar hr { display: none; }
  .shellbar .sb-nav .sb-lbl { display: none; }
}

/* ── the phone: the rail keeps what carries work, More holds the rest ────── */
.sb-more { display: none; }
@media (max-width: 560px) {
  :root { --frame: 12px; }
  .shellbar .sb-second { display: none; }
  .shellbar.all .sb-second { display: flex; }
  .shellbar .sb-more { display: flex; }
  .shellbar.all .sb-more .sb-wait { display: none; }
}

/* ── touch: a finger needs 44px, and there is no approach to expand on ───── */
@media (hover: none) {
  button.btn, a.btn, label.btn { min-height: 44px; }
  button.btn.sm, a.btn.sm, label.btn.sm { min-height: 40px; }
  .iconbtn { min-height: 40px; min-width: 40px; justify-content: center; }
  .pill { min-height: 40px; display: inline-flex; align-items: center; }
  .more { grid-template-rows: 1fr; opacity: 1; }
  /* a finger needs more than the 13px default box (the reference sheet's .chk) */
  input[type=checkbox], input[type=radio] { width: 24px; height: 24px; }
}

/* ── page head: title, subtitle, in-app tabs ────────────────────────────── */

.pagehead {
  display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap;
  padding: 18px clamp(16px, 3vw, 28px) 0;
  /* Full width by default (2026-07: dashboard pages are 50/50 panes and want the
     whole viewport). Long-form reading pages opt back down via body.reading. */
  max-width: var(--wrap, 100%); margin: 0 auto; width: 100%;
}
.pagehead .titles { min-width: 0; }
.pagehead h1 { font-size: 21px; margin: 0; font-weight: 680; letter-spacing: -0.02em; }
.pagehead .sub { color: var(--muted); font-size: 12.5px; margin-top: 2px; }

.tabs { display: flex; gap: 3px; margin-left: auto; }
.tabs button, .segbar button {
  background: none; border: 1px solid transparent; border-radius: var(--radius-s);
  padding: 7px 15px; color: var(--text-secondary); cursor: pointer; font: inherit;
}
.tabs button:hover, .segbar button:hover { background: var(--ghost); color: var(--text-primary); }
.tabs button.active, .segbar button.active {
  color: var(--text-primary); font-weight: 650;
  border-color: var(--border); background: var(--surface-1);
}

main {
  max-width: var(--wrap, 100%); margin: 0 auto;
  padding: 18px clamp(16px, 3vw, 28px) 72px; width: 100%;
}
/* ── how wide a page is ──────────────────────────────────────────────────────
   Decided by what KIND of page it is, not by which week it was built. That is
   the whole rule; every page picks exactly one of these three and says so on
   its own <body>, statically:

     (no class)     the whole viewport. A queue, a board, a list of cards, or a
                    two-pane tool where the second pane earns its half: the
                    approval queue, tasks, campaigns, translations, team home.
     .reading       one column of document prose, centred. A deliverable or a
                    translation being read and corrected.
     .conversation  one column of chat, centred, sized so the column is the
                    conversation rather than a frame around it.

   Two things about this that cost real defects:

   Put the class on <body>, never on <main>. The page header reads --wrap too,
   and the two must agree or the title sits wider than the content it labels.

   Declare it in the HTML, never apply it from JavaScript after mount. The
   translation share page used to add "reading" in Shell.mount().then(), so it
   painted at full width and then jumped to 1120px once /api/me came back, on
   the one page a country reviewer opens from a chat message. */
body.reading      { --wrap: 1120px; }
/* 880px is derived, not chosen: a chat bubble is capped at 82ch, which measures
   619px, and the column is sized so a bubble fills about four fifths of it. Any
   wider and left-versus-right stops meaning anything because every bubble is
   short of both edges; the products page was 1120px and left a 413px dead band
   down the right of every answer. */
body.conversation { --wrap: 880px; }

.segbar { display: flex; gap: 3px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.segbar .lbl { color: var(--muted); font-size: 12px; margin-right: 6px; }

/* ── surfaces ───────────────────────────────────────────────────────────── */
/* One kind of card (DESIGN_RULES: "there is no second kind"): --surface-1 plus
   the blur, a hairline, and the inset top highlight that reads as a lit edge.
   .panel is the canonical name going forward; .card keeps working because ten
   pages use it, and both are the same glass. */

.card, .panel {
  background: var(--surface-1); border: 1px solid var(--border);
  border-radius: var(--radius); padding: var(--s4); margin-bottom: 14px;
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-lift);
}
.panel { padding: var(--s5); margin-bottom: 0; }
.panel > h3 { margin: 0 0 var(--s1); font-size: 15px; font-weight: 620; letter-spacing: -0.015em; }

/* One quiet-text class. Absorbs the page-local .hint / .dmuted / .hmuted
   copies as each page migrates (phase 3/4): a page stops defining its own the
   day it is rebuilt, and until then its local rule wins on its own page. */
.muted { color: var(--muted); font-size: 12.5px; }

/* == THE HIGHLIGHT ========================================================
   One treatment for every tinted thing: a wash of its own colour with the type
   at full strength, and NO border and no rule down the side — a hairline drawn
   on top of a blur cancels the blur. `--tint` is the only thing that changes
   between a warning, a danger, a status and a count. */
.tint {
  --tint: var(--warn);
  color: var(--tint);
  background: color-mix(in srgb, var(--tint) 15%, transparent);
  backdrop-filter: blur(16px) saturate(1.7);
  -webkit-backdrop-filter: blur(16px) saturate(1.7);
  border: 0; box-shadow: none;
  border-radius: var(--radius-pill);
}
.tint.crit  { --tint: var(--critical); }
.tint.good  { --tint: var(--good); }
.tint.brand { --tint: var(--brand); }
/* a small thing needs a heavier wash to still read as a marker at 10px */
.tint.sm { background: color-mix(in srgb, var(--tint) 22%, transparent); }

/* THE TWO SHAPES A TINT COMES IN, and they are here rather than copied onto each
   page because `.tint` paints a background and sets NO padding: an element that
   carries the wash and nothing else renders with the colour flush against the
   type. On 2026-09-09 that was live in two places at once -- the campaign index
   wrote `.flagbox tint` twelve times and never carried a `.flagbox` rule (three
   sibling pages each carried an identical private copy), and the tasks home page
   put the red inbound line and the orange requests line on a bare `.tint`. Both
   read as a styling accident rather than as anything failing, which is why they
   sat. One definition, and `check_design_rules.py` now fails a tinted element
   that has no rule giving it padding. */
.flagbox { font-size: 12.5px; padding: var(--s2) var(--s3); border-radius: var(--radius-s);
           margin: var(--s2) 0; }
/* a status word small enough to ride inside a table cell or a heading */
.stpill { display: inline-block; font-size: .72rem; padding: 1px var(--s2); }

/* == EXPAND ON APPROACH ===================================================
   A small box holds the essentials; everything a person only wants once they
   are interested rides in `.more`, which opens on hover AND on focus-within so
   a keyboard reaches it. Exactly ONE child (.mi), or the implicit auto row
   stops it ever collapsing. The gap above the revealed content is a MARGIN on
   the first revealed line, never padding on the box: with border-box a padding
   survives a zero height. */
.more {
  display: grid; grid-template-rows: 0fr; opacity: 0;
  transition: grid-template-rows .26s cubic-bezier(.2,.8,.25,1), opacity .2s;
}
.more > .mi {
  overflow: hidden; min-height: 0;
  display: grid; gap: var(--s2); align-content: start;
}
.more > .mi > :first-child { margin-top: var(--s4); }
.card:hover .more, .card:focus-within .more,
.panel:hover .more, .panel:focus-within .more { grid-template-rows: 1fr; opacity: 1; }
/* Sentence case, not capitals. Three of these titles are DATA, not chrome: a
   deliverable's own title on the campaign share page, a language name on the
   translation share page, and a string from the i18n table, which capitals in
   Czech and Latvian mistreat. A title that says something ("Demand map: what
   people asked about, and whether a distributor is on the lookup list") is a
   sentence, and a sentence in capitals is slower to read than one that is not.
   13px rather than 12px because lowercase at the same nominal size looks
   smaller: it keeps the title's weight on the page unchanged. */
.card > h2 {
  font-size: 13px; font-weight: 700; margin: 0 0 12px; color: var(--muted);
}

.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 158px), 1fr)); gap: 10px; margin-bottom: 20px; }
.tile {
  background: var(--surface-1); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 13px 15px;
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
  box-shadow: var(--shadow), inset 0 1px 0 var(--glass-lift);
}
.tile .v { font-size: 27px; font-weight: 680; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; }
.tile .v small { font-size: 14px; font-weight: 500; color: var(--text-secondary); }
.tile .k { color: var(--muted); font-size: 11.5px; margin-top: 2px; }
.tile.alert { border-color: color-mix(in srgb, var(--critical) 45%, var(--border)); }
.tile.alert .v { color: var(--critical); }

/* ── controls ───────────────────────────────────────────────────────────── */

/* Every button is a pill and its shape says what it does (DESIGN_RULES):
   filled navy is the one action you most likely want, at most one per group;
   outlined is everything else; outlined red destroys or refuses. */
/* `a.btn` too: a download or an open-the-PDF is an action, and three pages
   already write <a class="btn sm"> expecting the pill. Until 2026-08-05 only
   <button> got it, so those anchors quietly rendered as bare links.
   `label.btn` for the same reason: a file input rides inside a label so the
   person sees a pill, and until the native-controls pass the campaign's
   packshot label quietly rendered as bare text. */
button.btn, a.btn, label.btn {
  font: inherit; font-weight: 550; border-radius: var(--radius-pill); padding: 8px 16px;
  cursor: pointer; border: 1px solid var(--border);
  background: var(--surface-2); color: var(--text-primary);
  display: inline-flex; align-items: center; gap: 6px;
  transition: border-color .16s, background .16s;
  text-decoration: none;
}
button.btn:hover:not(:disabled), a.btn:hover, label.btn:hover { border-color: var(--brand); }
button.btn:disabled { opacity: .45; cursor: default; }
button.btn.primary {
  background: var(--brand); color: var(--brand-ink); border-color: transparent; font-weight: 650;
  box-shadow: 0 2px 14px color-mix(in srgb, var(--brand) 30%, transparent);
}
button.btn.primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--brand); }
button.btn.good { border-color: var(--good); color: var(--good); font-weight: 650; }
button.btn.danger {
  color: var(--critical); background: none;
  border-color: color-mix(in srgb, var(--critical) 42%, transparent);
}
button.btn.danger:hover:not(:disabled) { border-color: var(--critical); }
button.btn.sm, a.btn.sm, label.btn.sm { padding: 5px 11px; font-size: 13px; font-weight: 500; }

/* `input:not([type])` too: an input written without a type IS text in the DOM,
   but `input[type=text]` never matches it, so it rendered as the browser's
   square default. Four shipped that way (the leads row's six fields, the
   content search, two approval note fields) and the catch-all is what stops
   the fifth. */
input:not([type]),
input[type=text], input[type=password], input[type=date], input[type=search],
input[type=email], input[type=number], input[type=datetime-local],
select, textarea {
  font: inherit; padding: 8px 11px; border-radius: var(--radius-s);
  border: 1px solid var(--border); background: var(--surface-2); color: var(--text-primary);
  width: 100%;
}
textarea { resize: vertical; line-height: 1.55; }

/* The select's CLOSED state is ours: our chevron in the token colour, room for
   it, and no stock arrow. The OPEN popup is the operating system's and cannot
   be styled; color-scheme (token blocks above) is what keeps it from flashing
   light-on-dark, and that is the whole treatment. */
select {
  appearance: none; -webkit-appearance: none;
  background-image: var(--chev);
  background-repeat: no-repeat;
  background-position: right 11px center;
  background-size: 12px;
  padding-right: 32px;
}
select[multiple] { background-image: none; padding-right: 11px; }

/* The OPEN popup, where the browser lets us in. Chromium's customizable
   select (appearance: base-select) puts the popup into the page, where the
   tokens reach it: rounded surface, padded options, the selection as the
   brand wash. Pure CSS behind @supports — Firefox and anything older keeps
   the native popup and never sees this. Adopted 2026-08-05 on the user's
   direct feedback (the catalogue picker's popup was a wall of square blue),
   which reverses the native-controls pickup's "do not adopt" line: that line
   guarded against experimental MARKUP, and this block carries none. */
@supports (appearance: base-select) {
  select:not([multiple]) { appearance: base-select; }
  select::picker(select) {
    appearance: base-select;
    background: var(--page-2); color: var(--text-primary);
    border: 1px solid var(--border); border-radius: var(--radius-s);
    box-shadow: var(--shadow-lg);
    padding: var(--s1);
    margin-top: var(--s1);
  }
  select option {
    padding: 6px 10px; border-radius: var(--radius-s);
    background: none;
  }
  select option:hover, select option:focus-visible { background: var(--ghost); }
  /* the highlight rule: a wash of the brand, full-strength type, no border */
  select option:checked { background: var(--brand-soft); font-weight: 600; }
  select optgroup { font-weight: 650; color: var(--muted); }
  /* our chevron stays the one arrow; the UA would draw a second one */
  select::picker-icon { display: none; }
}

/* ── file inputs ──────────────────────────────────────────────────────────
   The browser's own "Choose file" chrome never appears: the input rides
   visually hidden inside a <label class="btn">, so the person sees a pill.
   Never display:none — the harness sets files on it, a screen reader needs
   it focusable, and the label stays its accessible name. The chosen filename
   is echoed in a .fileecho span beside the label (a hidden input also hides
   "which file did I pick"); shell.js keeps it current. */
label.btn { position: relative; }
label.btn input[type=file] {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  border: 0; clip: rect(0 0 0 0); clip-path: inset(50%);
  overflow: hidden; white-space: nowrap;
}
label.btn:focus-within { outline: 2px solid var(--brand); outline-offset: 2px; }
.fileecho { color: var(--muted); font-size: 12.5px; }
.fileecho:empty { display: none; }

/* A TICKABLE CHOICE: a checkbox or a radio and the words that belong to it.
   `accent-color` in the token block already paints the box navy; what a bare
   <label><input> cannot do is hold the two together, so the box and its words
   sit at a real gap and stay on one line as a unit.

   IT WAS DECLARED, DOCUMENTED, USED -- AND SHIPPED BY NOTHING. `.chk` is in the
   component sheet (`ui_mockups/design_rules.html`, generated from the prototype)
   and four live labels across three pages ask for it -- the campaigns page's
   translation markets, two on Create, one on the tasks page -- and it existed in
   no stylesheet any browser loads. Those four rendered as the browser's own 13px
   box with a single text space beside it. Measured 2026-09-02, after the user
   said the tick fields "are not in the UI of the platform".

   `label.chk`, NOT bare `.chk`, and that is a collision and not a preference: the
   tasks page has its own `.chk`, a round done-badge, and it is a `<button>`. Every
   use of THIS component is a `<label>` wrapping its input, so the element narrows
   the selector onto exactly the four that mean it and cannot reach the badge. The
   shared-vocabulary rule says to sweep every page before taking a name; the sweep
   found this, which is the whole reason it is written down.

   The reference sheet is the spec and these are its values, including the 15px
   box -- minus its `accent-color`, which the sheet repeats and this file must not:
   `accent-color` is declared exactly once, in the token block, so it follows
   --brand across themes, and `check_design_rules` enforces the single declaration.

   The touch override is repeated here rather than left to the global
   `input[type=checkbox]` rule in the phone block: that rule sits EARLIER in this
   file, so `label.chk input` would otherwise beat it and shrink every tick target
   back to 15px on a finger. */
label.chk { display: inline-flex; gap: var(--s2); align-items: center; font-size: 13px; }
label.chk input { width: 15px; height: 15px; }
@media (hover: none) {
  label.chk { min-height: 44px; }
  label.chk input { width: 24px; height: 24px; }
}

/* One shared disclosure marker: the campaign page alone uses <details> nine
   times, each with the browser's stock triangle in the text colour. */
summary { cursor: pointer; }
summary::marker { color: var(--muted); }

/* ── .saymore: the one control for text that is not on the screen ────────────
   2026-08-11. The person this platform is for drove it and said: "there's a lot
   of text on the create, on the content board, all of it. So that's maybe why I
   cannot understand it." Measured that day, Create went from 287 visible words at
   the door to 699 as it was ANSWERED, and the Calendar explained seven colours in
   one 118-word paragraph.

   Almost every one of those sentences was added to fix a defect somebody hit, so
   the fix is not shorter writing, which two sessions had already tried. It is that
   a sentence somebody needs ONCE is one press away rather than on the screen for
   ever. A sentence behind this is not a deleted sentence.

   ONE control, and it is here rather than on the pages so that it stays one: this
   platform's own history is that two copies of anything drift. `<details>` and not
   a dialog, because it costs no JavaScript, it survives the innerHTML repaints
   these pages run on, it is keyboard-reachable for nothing, and a closed one counts
   as its summary in `measure_screens.py`, which is how the change is measured at
   all.

   The summary carries the FACT and the body carries the detail. A summary that has
   to be opened to be understood has moved the problem rather than solved it, and on
   a refusal it would be worse than that: silence and refusal must never render
   identically, so what is behind this is always the reason and never the fact that
   there is one. */
details.saymore { margin: var(--s3) 0 0; }
details.saymore:first-child { margin-top: 0; }
details.saymore > summary {
  font-size: 12.5px; font-weight: 600; color: var(--muted);
  padding: var(--s1) 0; line-height: 1.5;
}
details.saymore > summary:hover { color: var(--text-secondary); }
details.saymore > summary:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
details.saymore[open] > summary { margin-bottom: var(--s2); }
details.saymore > :not(summary) { font-size: 13px; color: var(--text-secondary); }
details.saymore p { margin: 0 0 var(--s2); max-width: 84ch; }
details.saymore p:last-child { margin-bottom: 0; }
details.saymore ul { margin: 0; padding-left: var(--s5); }
details.saymore li { margin: 0 0 var(--s1); }

/* Scrollbars inside the working surfaces follow the tokens; the viewport
   scrollbar is left to color-scheme. Thin where a panel scrolls. */
main, dialog { scrollbar-color: var(--border) transparent; }
.overflow, .glossbody, .cmpbody, .prose .tablewrap { scrollbar-width: thin; }
/* This class used to uppercase its text, which made a form look like a tax
   return and produced a real defect: it rendered a product name in capitals
   inside a "type this name exactly to confirm" prompt, so the name on screen
   was a different name from the one on the campaign. Sentence case now. 12px
   rather than 11px for the same reason as .card > h2 above. */
label.fld {
  font-size: 12px; color: var(--muted);
  font-weight: 600; display: block; margin: 14px 0 6px;
}
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > div { flex: 1; min-width: 200px; }

.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
  border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px;
  cursor: pointer; user-select: none; font-size: 13px; background: var(--surface-2);
}
.pill:hover { border-color: var(--brand); }
.pill.on { background: var(--brand); color: var(--brand-ink); border-color: var(--brand); font-weight: 600; }

/* ── the product picker ──────────────────────────────────────────────────────
   Shell.productPicker's skin, moved here from the campaign page and the content
   page in phase A (2026-08-07). Both had their own copy of these classes; they
   had already drifted in padding and in which absence they warned about, and two
   page-local blocks defining the same class names is exactly what the no-shadow
   rule exists to stop. One block, one component.                             */

.pk-results {
  border: 1px solid var(--border); border-radius: var(--radius-s);
  max-height: 300px; overflow: auto; margin-top: var(--s2);
}
.pk-row {
  display: flex; gap: var(--s3); align-items: baseline; width: 100%; text-align: left;
  padding: var(--s2) var(--s3); border: 0; border-bottom: 1px solid var(--grid);
  background: none; font: inherit; color: inherit; cursor: pointer;
}
.pk-row:last-child { border-bottom: 0; }
.pk-row:hover, .pk-row:focus-visible { background: var(--ghost); }
.pk-row .pk-name { font-weight: 600; flex: 1; }
.pk-row .pk-fact { font-size: 12px; color: var(--muted); }
/* An absence is a warning, not a count: it changes what the person may do next. */
.pk-nospec, .pk-nophoto { color: var(--warn); font-size: 12px; font-weight: 650; }
/* ...and a STATE is neither. "not listed" reads at a warning's weight so it is
   seen, in the neutral token so it does not accuse: no country website carrying a
   product is equally what a product that has not launched yet looks like, and
   this is the tool launch campaigns are written in. --warn here would flag the
   most legitimate thing anyone does with the picker. */
.pk-quiet { color: var(--muted); font-size: 12px; font-weight: 650; }
.pk-picked {
  border: 1px solid var(--good); border-radius: var(--radius-s);
  padding: var(--s1) var(--s4); margin-bottom: var(--s3);
  background: color-mix(in srgb, var(--good) 8%, transparent);
}
.pk-picked .pk-picked-row {
  display: flex; gap: var(--s3); align-items: baseline;
  padding: var(--s2) 0; border-bottom: 1px solid var(--grid);
}
.pk-picked .pk-picked-row:last-child { border-bottom: 0; }
.pk-picked .pk-picked-row b { flex: 1; }

/* ── bits ───────────────────────────────────────────────────────────────── */

.badge {
  font-size: 11px; font-weight: 650; padding: 2px 8px; border-radius: 999px;
  border: 1px solid var(--border); display: inline-flex; align-items: center; gap: 5px;
}
.badge .dot { width: 8px; height: 8px; border-radius: 50%; }
.chip {
  font-size: 11px; padding: 2px 8px; border-radius: 999px;
  background: var(--ghost); color: var(--text-secondary);
  display: inline-flex; align-items: center; gap: 4px;
}
.status-pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); white-space: nowrap; }
.empty { color: var(--muted); text-align: center; padding: 34px 0; }
.notice { font-size: 12.5px; padding: 9px 13px; border-radius: var(--radius-s); margin-bottom: 12px; }
.notice.err { background: color-mix(in srgb, var(--critical) 12%, transparent); color: var(--critical); }
.notice.ok  { background: color-mix(in srgb, var(--good) 12%, transparent); color: var(--good); }
.notice.warn{ background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); }

table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 11.5px; padding: 7px 8px; border-bottom: 1px solid var(--grid); text-transform: uppercase; letter-spacing: .03em; }
td { padding: 8px; border-bottom: 1px solid var(--grid); vertical-align: top; }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.trunc { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.overflow { overflow-x: auto; }

.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; }
.legend .item { display: inline-flex; align-items: center; gap: 6px; }
.legend .sw { width: 10px; height: 10px; border-radius: 3px; }
.tooltip {
  position: fixed; pointer-events: none; z-index: 40; display: none;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-s);
  padding: 8px 10px; font-size: 12px; box-shadow: var(--shadow-lg);
}
.tooltip .t { font-weight: 650; margin-bottom: 3px; }
.tooltip .row { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); }
.tooltip .sw { width: 8px; height: 8px; border-radius: 2px; }

/* ── waiting is the company's own mark, filling ──────────────────────────────
   Asked for by the user, 2026-08-31: "I want the logo to be filled out"
   wherever the platform makes somebody wait -- a press, a translation, a send,
   anything that loads. It replaces a two-colour ring that could have belonged
   to any product on earth.

   ONE RULE CHANGED AND EVERY WAIT CHANGED WITH IT. `.spinner` is the shared
   class: `Shell.busy()`, `Shell.working()` and `.btn[aria-busy]` all draw it,
   and all 15 pages ask for it by name rather than rolling their own (swept
   before this was written: no page defines `.spinner` or a `spin` keyframe).
   So this is one rule, not sixteen, which is also why it is worth doing well.

   THE MARK CARRIES ITS NAME, at 16 px as everywhere else. `logo-mask.png` is
   derived in make_brand_icons.py from the kit's own master as INK COVERAGE,
   so the navy is opaque and the wordmark, the W and the dots are HOLES. The
   element is masked to that shape and the fill rises behind it, which means
   the letters are drawn BY the fill rather than printed on top of it. A
   silhouette would have been easier and would have filled as a plain blue
   counter with no name on it, which is the thing this platform refuses at
   every other size (make_brand_icons.py, rule 1).

   The rise is a block sweeping up through the mark rather than a background
   position, so the loop has no visible reset: it enters below the mark, fills
   it, and leaves above it, and the moment it is out of sight above is
   identical to the moment before it appears below.

   The token on the URL is the same discipline as the pages' `?v=`: nginx holds
   an asset for a day, and a changed mask under an unmoved URL is a deploy that
   reads as not having worked. `sync_ui.py` carries logo-mask.png in TOKENED and
   check_platform_ux asserts this token equals the one the pages pin.          */
.spinner {
  width: 16px; height: 16px; display: inline-block; position: relative;
  vertical-align: -3px; overflow: hidden;
  -webkit-mask: url(logo-mask.png?v=20260909a) center / contain no-repeat;
          mask: url(logo-mask.png?v=20260909a) center / contain no-repeat;
}
.spinner::before, .spinner::after { content: ""; position: absolute; inset: 0; }
/* The empty state: the mark is always THERE, so the fill has something to fill.
   A TINT OF THE BRAND, not `--ghost`. Measured in a browser at 3x: --ghost is
   4.5% ink and a 16 px mark drawn in it is a smudge you have to look for, which
   makes the wait read as a rendering fault rather than as the logo waiting.
   Both tokens flip with the theme, so this is one rule for light and dark. */
.spinner::before { background: color-mix(in srgb, var(--brand) 22%, transparent); }
.spinner::after {
  background: var(--brand);
  animation: vfill 1.5s cubic-bezier(.4, 0, .3, 1) infinite;
  transform: translateY(100%);
}
@keyframes vfill {
  from { transform: translateY(100%); }
  to   { transform: translateY(-100%); }
}

/* The same wait, given room: an empty page, a panel that has not arrived, a
   bot turn being thought about. Same mark, same fill, one size up, and the
   modifier is on the same class so nothing new enters the shared vocabulary
   (swept: no page defines `.spinner-lg` or `.vload`). */
.spinner-lg { width: 44px; height: 44px; vertical-align: middle; }

/* ── a REGION that has not arrived: the same mark, given the room ───────────
   Asked for by the user 2026-09-02, the day after the fill shipped: "whenever
   we have loading, it should have this loading". The fill covered PRESSES and
   nothing else. Measured that morning across all 15 pages: 187 network waits,
   36 of them said something, 151 said nothing at all, and 86 of those silent
   ones REPAINT A REGION. `renderTeam()` on the tasks page is the shape: `await
   get("board")` and then `view.innerHTML = ...`, so from the click until Asana
   answers the screen holds the PREVIOUS view and then snaps. Nothing was
   drawn, so nothing said the click had landed. `.spinner-lg` had existed since
   the fill shipped with a comment naming exactly this case, and no page used
   it: the size was built and never wired to anything.

   IT APPEARS LATE, AND IT DOES NOT FADE. Measured the same day: TTFB on all
   five apps is about 50 ms, so a panel painted at once would flash the logo on
   every swap and read as a glitch rather than as a wait. The reveal is a
   ZERO-DURATION animation with a 0.16s delay, which is a delayed APPEARANCE and
   not a fade: the design rules say nothing fades in on load, and a fade here
   would be a movement nobody asked for. Nothing to disable under reduced
   motion, because nothing moves.

   THE DELAY IS IN CSS AND NOT IN A TIMER, on purpose. A JS timer that paints
   the panel later must be cancelled by every call site when the data wins the
   race, and one forgotten cancel repaints the panel OVER the view that already
   arrived: a worse defect than the one being fixed, and one that only appears
   on a slow morning. Here the panel is in the DOM from the first millisecond
   and the render that replaces it takes the pending appearance with it, so
   there is no cancel to forget and no call site that can get it wrong.

   The height is pinned by `Shell.loading` and not here: the panel replaces a
   region that had a size, and an unpinned one collapses the page under the
   scroll position.                                                           */
.ld-panel {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--s3); padding: var(--s6) var(--s4); text-align: center;
  color: var(--text-secondary); font-size: 13px;
  opacity: 0; animation: ld-show 0s linear .16s forwards;
}
@keyframes ld-show { to { opacity: 1; } }

/* The same wait beside a line rather than filling a panel: a row that is being
   refreshed, a count being recounted, a poll's first tick. Small mark, same
   words, no pinned height, because there is nothing here to collapse. */
.ld-panel.ld-row { flex-direction: row; justify-content: flex-start;
                   padding: var(--s2) 0; gap: var(--s2); }

/* ON A FILLED CONTROL the mark is drawn in the control's own ink, or it is a
   navy logo on a navy button and the press that is running looks like a press
   that did nothing. Found in the browser, not in the source: the primary
   button is the one place on this platform where a busy state sits on
   `--brand` itself. This is the small version of the kit's rule that the mark
   takes a white plate on a dark ground -- at 16 px a plate would be a white
   blob, so the mark takes the ground's ink instead. */
button.btn.primary .spinner::before {
  background: color-mix(in srgb, var(--brand-ink) 34%, transparent);
}
button.btn.primary .spinner::after { background: var(--brand-ink); }

/* ── working: a press that is running, and a picture that is arriving ────────
   Reported 2026-08-12: "you click the buttons and the buttons are static, so you
   do not understand if it is generating." 26 of the campaign app's 32 network
   presses said nothing at all, 29 of 36 across all 14 live pages, and three of
   the six that did say something said it in a span BESIDE the button, which is
   the one place that does not answer the question: the thing that looks broken
   is the button.

   Styled off ATTRIBUTES and not new classes. A class here enters the SHARED
   vocabulary and can shadow a page -- `.pn` for the picker collided with the
   tasks page's own `.pn` ("3 open") -- and `aria-busy` is the fact itself rather
   than a name for it, so assistive technology gets it without a second
   mechanism. The three names that ARE new are namespaced `wk-`, like `pk-`,
   `sb-` and `cslt-` before them, and all three were swept to 0 hits across the
   tree immediately before they were written. */
.btn[aria-busy="true"] { cursor: progress; }
.btn[aria-busy="true"] > .spinner { margin-right: var(--s2); }

.wk-line { display: flex; align-items: baseline; gap: var(--s2); flex-wrap: wrap;
           font-size: 13px; color: var(--text-secondary); margin: var(--s3) 0 var(--s2); }
.wk-line > .spinner { align-self: center; }
.wk-line b { font-weight: 650; color: var(--text-primary); }
.wk-bar { height: 3px; border-radius: var(--radius-pill); background: var(--ghost);
          overflow: hidden; margin: 0 0 var(--s3); }
.wk-bar > i { display: block; height: 100%; width: 34%; border-radius: inherit;
              background: var(--brand);
              animation: wk-slide 1.4s cubic-bezier(.2, .8, .25, 1) infinite; }
@keyframes wk-slide { from { transform: translateX(-105%); } to { transform: translateX(305%); } }
.wk-log { font: 12px/1.55 var(--mono); white-space: pre-wrap; color: var(--text-secondary);
          max-height: 220px; overflow-y: auto; margin-top: var(--s2); }
.wk-stale { color: var(--warn); }

/* A picture that has not arrived says so WHERE it will be, so the layout does
   not jump when it does. The skeleton is a BACKGROUND on the <img> and never a
   wrapper: a wrapper changes the box model under `.centry img`, `.cphotos img`
   and `.sh-sheet img`, and `.msg.bot a` already records what happens when a
   page's own two-class rule outranks a helpful one-class one. */
img[data-load] { background-color: var(--ghost); }
img[data-load="pending"] {
  background-image: linear-gradient(100deg, var(--ghost) 28%, var(--surface-2) 50%, var(--ghost) 72%);
  background-size: 220% 100%; animation: wk-sheen 1.3s linear infinite;
}
img[data-load="failed"] { background-color: color-mix(in srgb, var(--warn) 12%, transparent); }
@keyframes wk-sheen { from { background-position: 180% 0; } to { background-position: -80% 0; } }

/* The design rules: three durations, one curve, nothing moves that a person did
   not touch, and everything is off here. THIS BLOCK DID NOT EXIST UNTIL TODAY,
   so the .spinner above had been spinning through it since the shell was
   written, and the rule was unimplemented rather than merely unenforced.

   A frozen spinner reads as nothing at all, which is why every busy state
   carries WORDS and the label is not optional: under this block the words ARE
   the state. The bar becomes a full quiet rule rather than a stopped sliver,
   because a 34%-wide bar that never moves reads as "34% done". */
@media (prefers-reduced-motion: reduce) {
  .spinner::after, .wk-bar > i, img[data-load="pending"] { animation: none; }
  .tour-lit { transition: none; }
  /* The mark STANDS THERE, filled and quiet, rather than half-filled at
     whatever height the animation would have frozen at -- a fill stopped at 40%
     reads as "40% done", which is the same wrong reading this block already
     rewrites the progress bar to avoid. The words beside it carry the state,
     which is why the label is not optional anywhere this is drawn. */
  .spinner::after { transform: none; opacity: .45; }
  /* THE PANEL KEEPS ITS DELAY, and it is named here rather than left out so the
     rule above cannot be satisfied by silence. `ld-show` has a ZERO duration:
     nothing about it moves, it only holds the mark back for 0.16s so a 50ms
     answer never flashes a logo. Switching that off would make every fast view
     swap blink one, which is MORE visual churn under a setting that asks for
     less. So the declaration is restated unchanged, as a decision on the record. */
  .ld-panel { animation: ld-show 0s linear .16s forwards; }
  .wk-bar > i { width: 100%; opacity: .4; }
  img[data-load="pending"] { background-image: none; }
}

.hide { display: none !important; }

/* ── prose: rendered campaign copy and translations ─────────────────────── */
/* These deliverables are documents. They should read like documents, not like a
   terminal dump of the markdown they happen to be stored as.
   The CONTAINER fills the card (DESIGN_RULES.md: "No max-width on a page
   region"); the cap goes on the text elements only, at 90ch to match the rest
   of the platform. Same fix already shipped once on each app's own share page
   on 2026-07-15 (translation_pipeline/static/index.html, the old
   campaign_pipeline/static/index.html's .blk .doc) -- this is that fix reaching
   the shared class both apps' /share pages still render through, where a fixed
   74ch column measured at 578px sat inside an 882px card and left 287px of
   empty card on the right. Measured 2026-08-13. */

.prose { font-family: var(--sans); font-size: 14.5px; line-height: 1.65; }
.prose > :first-child { margin-top: 0; }
.prose > :last-child { margin-bottom: 0; }
.prose h1, .prose h2, .prose h3 {
  font-weight: 680; line-height: 1.3; letter-spacing: -0.01em;
  margin: 22px 0 8px; color: var(--text-primary); max-width: 90ch;
}
.prose h1 { font-size: 19px; }
.prose h2 { font-size: 16px; }
.prose h3 {
  font-size: 13px; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); font-weight: 700;
}
.prose p { margin: 0 0 11px; max-width: 90ch; }
.prose ul, .prose ol { margin: 0 0 11px; padding-left: 22px; max-width: 90ch; }
.prose li { margin: 3px 0; }
.prose strong { font-weight: 650; }
.prose hr { border: 0; border-top: 1px solid var(--grid); margin: 20px 0; }
.prose code { font-family: var(--mono); font-size: .9em; background: var(--ghost); padding: 1px 5px; border-radius: 4px; }
.prose blockquote {
  margin: 0 0 11px; padding: 6px 13px; border-left: 3px solid var(--baseline);
  background: var(--ghost); border-radius: 0 var(--radius-s) var(--radius-s) 0; color: var(--text-secondary);
  max-width: 90ch;
}
.prose table { max-width: 100%; margin: 0 0 14px; }
.prose .tablewrap { overflow-x: auto; max-width: 100%; }
/* A social post is pasted verbatim into Meta, so its line breaks are content. */
.prose .verbatim {
  white-space: pre-wrap; font-family: var(--sans);
  background: var(--ghost); border-radius: var(--radius-s); padding: 10px 13px; margin: 0 0 11px;
}

/* ── the glossary ────────────────────────────────────────────────────────────
   Some of the vocabulary here has to be learned: a tier, an occasion, what
   "unknown" does to a market, what a [CHECK] marker is asking of you. Each used
   to be re-explained in a paragraph beside the field it applied to, on a screen
   where somebody is trying to get work done rather than read. The definitions
   are in shell.js, in one table; this is what they look like.

   Three presentations, all routing through the one delegated listener:
     .glossall   one quiet entry point under the page title ("Words used here")
     .glossbtn   a `?` badge beside a field label, for one term
     .glossterm  the word itself, underlined, for use inside a sentence          */

.glossall {
  background: none; border: 0; padding: 4px 0 0; margin: 0; cursor: pointer;
  font: inherit; font-size: 12px; color: var(--text-secondary);
  text-decoration: underline dotted; text-underline-offset: 3px;
}
.glossall:hover { color: var(--brand); }

/* A label's own `?`. Sized in em so it follows whatever label it sits beside,
   and never so large that it competes with the label itself. */
.glossbtn {
  background: none; border: 1px solid var(--border); border-radius: 50%;
  width: 1.35em; height: 1.35em; padding: 0; margin-left: 5px; cursor: pointer;
  font: inherit; font-size: .92em; font-weight: 700; line-height: 1;
  color: var(--muted); vertical-align: -.12em;
}
.glossbtn:hover { color: var(--brand); border-color: var(--brand); }

.glossterm {
  background: none; border: 0; padding: 0; cursor: pointer; font: inherit;
  color: inherit; text-decoration: underline dotted; text-underline-offset: 2px;
  text-decoration-color: var(--baseline);
}
.glossterm:hover { color: var(--brand); text-decoration-color: var(--brand); }

/* showModal() brings Esc, the focus trap and focus restore with it, so the
   dialog is the element rather than a div pretending to be one. */
.glossdlg {
  padding: 0; border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-solid); color: var(--text-primary);
  box-shadow: var(--shadow-lg);
  width: min(640px, calc(100vw - 28px)); max-width: none;
  max-height: min(80vh, 720px); overflow: hidden;
  flex-direction: column;                 /* only applies once [open] displays it */
}
.glossdlg[open] { display: flex; }
.glossdlg::backdrop { background: rgba(11,11,11,.42); }
:root[data-theme="dark"] .glossdlg::backdrop { background: rgba(0,0,0,.62); }

.glosshead {
  display: flex; align-items: center; gap: 12px; flex: 0 0 auto;
  padding: 13px 16px; border-bottom: 1px solid var(--border); background: var(--surface-2);
}
.glosshead h2 { margin: 0; font-size: 15px; font-weight: 680; }
.glosshead .iconbtn { margin-left: auto; }

.glossbody { padding: 4px 16px 16px; overflow-y: auto; flex: 1 1 auto; }
.gterm { padding: 14px 0; border-bottom: 1px solid var(--grid); scroll-margin-top: 4px; }
.gterm:last-child { border-bottom: 0; padding-bottom: 4px; }
.gterm h3 { margin: 0 0 5px; font-size: 14px; font-weight: 700; }
/* The term somebody asked for, when they came in through a `?` rather than the
   whole list: marked, not isolated, because the neighbouring definitions are
   usually the other half of the answer. */
.gterm.on {
  border-left: 3px solid var(--brand); padding-left: 11px;
  background: color-mix(in srgb, var(--brand) 6%, transparent);
  border-radius: 0 var(--radius-s) var(--radius-s) 0;
}
.gterm .def { font-size: 13.5px; }
.gterm .def p:last-child { margin-bottom: 0; }

/* == THE TOUR =============================================================
   What each button on this page is, said beside the button. A POSITIONED CARD
   and not a modal: the page has to stay readable behind it, because the point
   is to look at the thing being described. Namespaced `tour-` like every other
   shell component (`sb-`, `pk-`, `cslt-`), and swept: no page carried the word
   before this. Its position is set inline by tourPlace(); everything about how
   it looks is here. */
.tour-card {
  position: fixed; z-index: 60;
  width: min(340px, calc(100vw - 24px));
  padding: var(--s4);
  border-radius: var(--radius);
  /* --surface-solid, like every dialog and for the same reason: this is text
     laid over text, and glass at 68% leaves the card behind it legible through
     the sentence explaining it. */
  background: var(--surface-solid); color: var(--text-primary);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
}
.tour-card:focus { outline: none; }

/* THE TRIGGER INSIDE AN OPEN DIALOG, drawn by the shell and by no page. A modal
   dialog makes the utility capsule inert, so the permanent trigger up there
   cannot be pressed by the person most likely to want it: somebody standing in
   the image studio wondering what the next press costs.

   It takes the free space itself so it lands beside whatever closes the dialog
   rather than beside the title, and it is `flex: none` for the reason the close
   buttons in those heads already are: the row is `align-items: flex-start` over
   a two-line title block. */
.tour-in { margin-left: auto; flex: none; align-self: flex-start; }
.tour-head { display: flex; align-items: center; gap: var(--s3); }
.tour-head h2 { margin: 0; font-size: 15px; font-weight: 680; }
.tour-head .iconbtn { margin-left: auto; flex: 0 0 auto; }
.tour-body { font-size: 13.5px; margin-top: var(--s2); }
.tour-body p:last-child { margin-bottom: 0; }
.tour-foot { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s4); }
.tour-n { color: var(--muted); font-size: 12px;
          font-variant-numeric: tabular-nums; margin-right: auto; }

/* THE SPOTLIGHT, and it is a WASH. DESIGN_RULES, "The highlight": anything
   tinted is coloured glass with no border and no rule down the side, because a
   hairline drawn on top of a blur cancels the blur. So no outline ring around
   the target, ever.

   It is not the `.tint` class itself, which is the one place this departs from
   the plan: `.tint` also pills the corners and recolours the type, which is
   right on a 10px badge and wrong laid over a whole card. The RULE is what
   carries, and it is the same wash at the same strength. */
.tour-lit { background: color-mix(in srgb, var(--brand) 14%, transparent);
            transition: background .16s cubic-bezier(.2,.8,.25,1); }
/* Depth-matched, or the thing already carrying a background keeps it: the
   current rail entry is `.shellbar .sb-nav.current` at three classes, and a bare
   `.tour-lit` would lose to it and light nothing. */
.shellbar .sb-nav.tour-lit,
.shellutil .iconbtn.tour-lit,
.card.tour-lit, .panel.tour-lit {
  background: color-mix(in srgb, var(--brand) 14%, transparent);
}

/* The rail is 44px of icons until something hovers it, and nothing hovers it
   while you are reading a card two hundred pixels away. Held open for the
   duration of a rail step so the label the tour names is the label on screen.
   Only where the rail IS a rail: below 880 it is a horizontal bottom bar with
   its labels off, and widening that would be nonsense. */
@media (min-width: 881px) {
  .shellbar.tour-open { width: var(--rail-open); }
  .shellbar.tour-open .sb-lbl, .shellbar.tour-open .sb-wm { opacity: 1; }
  .shellbar.tour-open .sb-wait { right: var(--s3); top: 50%; transform: translateY(-50%); }
}

/* The markers are what a human must act on. Same colours everywhere they appear. */
.mk-check { background: color-mix(in srgb, var(--warn) 26%, transparent); border-bottom: 1px dotted var(--warn); padding: 0 3px; border-radius: 3px; }
.mk-local { background: color-mix(in srgb, var(--series-2) 22%, transparent); padding: 0 3px; border-radius: 3px; }
.mk-quote { background: color-mix(in srgb, var(--critical) 20%, transparent); padding: 0 3px; border-radius: 3px; }
/* Highlighted is not findable: in a 36-paragraph document an amber wash three
   screens down is found by scrolling. This is what the jump lands on. */
.mk-on { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 3px; }

/* ── source beside translation ───────────────────────────────────────────────
   The reading page stays a document at 1120px, which is the right measure for
   one column of prose and the wrong one for two. So the comparison is a dialog
   over the whole window, opened deliberately, rather than a second layout the
   reader did not ask for. Same [open] discipline as .glossdlg: no `display` in
   the base rule, or a <dialog> shows on every page that loads this file. */
.cmpdlg {
  padding: 0; border: 0; background: var(--surface-solid); color: var(--text-primary);
  width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh;
  flex-direction: column;                 /* only applies once [open] displays it */
}
.cmpdlg[open] { display: flex; }
.cmpdlg::backdrop { background: rgba(11,11,11,.42); }
:root[data-theme="dark"] .cmpdlg::backdrop { background: rgba(0,0,0,.62); }

.cmphead {
  display: flex; align-items: center; gap: 10px; flex: 0 0 auto; flex-wrap: wrap;
  padding: 11px 18px; border-bottom: 1px solid var(--border); background: var(--surface-2);
}
.cmphead h2 { margin: 0; font-size: 15px; font-weight: 680; }
.cmphead .sp { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.cmpbody { overflow-y: auto; flex: 1 1 auto; padding: 0 18px 44px; }

/* minmax(0, 1fr), not 1fr: a bare 1fr floors at min-content, and one long URL in
   a translation then pushes the whole dialog sideways. */
.cmpgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 26px; }
.cmpcol {
  /* Sticky over scrolling prose, so it is the same legibility question as the
     dialog itself: a translucent header shows the paragraphs sliding under it. */
  position: sticky; top: 0; z-index: 1; background: var(--surface-solid);
  padding: 10px 0 8px; font-size: 12px; font-weight: 650; letter-spacing: .02em;
  color: var(--text-secondary); border-bottom: 1px solid var(--border);
}
.cmpcell {
  padding: 11px 0; border-bottom: 1px solid var(--grid); min-width: 0;
  font-size: 14.5px; line-height: 1.65;
}
/* Without this, a <p> here falls back to the browser's own 1em margin (14.5px)
   instead of the 11px every other prose surface uses, and has no measure cap
   at all -- at 1920px a long paragraph ran to ~123ch with nothing to stop it.
   Mirrors .prose p exactly. Measured 2026-08-14. */
.cmpcell p { margin: 0 0 11px; max-width: 90ch; }
.cmpcell > :first-child { margin-top: 0; }
.cmpcell > :last-child { margin-bottom: 0; }
.cmpcell.src { color: var(--text-secondary); }
/* A row where one paragraph answered two, or answered none. Marked on both cells
   so the reader sees it whichever side they are reading. */
.cmpcell.odd { background: color-mix(in srgb, var(--warn) 7%, transparent); }
.cmpcell .why { display: block; font-size: 11.5px; color: var(--warn); margin-top: 5px; }
.cmpcell .nothing { color: var(--muted); font-style: italic; font-size: 13px; }

/* Below this a two-column comparison is two half-columns of nothing. Stack them:
   the grid order is already source, translation, source, translation. */
@media (max-width: 760px) {
  .cmpgrid { grid-template-columns: minmax(0, 1fr); }
  /* The two column headings stop heading anything once the columns are stacked, so
     each paragraph carries its own language instead. */
  .cmpcol { display: none; }
  .cmpcell { padding: 8px 0; }
  .cmpcell::before {
    content: attr(data-lang); display: block; margin-bottom: 3px;
    font-size: 11px; font-weight: 650; letter-spacing: .02em; color: var(--muted);
  }
  .cmpcell.src { border-bottom: 0; padding-bottom: 4px; }
}

/* ── the consult box ─────────────────────────────────────────────────────────
   The drawer's first form (phase C, 2026-08-07): the product bot's own
   conversation, over whatever page you are on, opened from the rail or from a
   button on Home. Built in shell.js, never page-local, because phase D upgrades
   this same component rather than adding a second one.

   EVERY class here is prefixed `cslt-`, and that is not a style preference. A
   class added to this file enters the shared vocabulary and can shadow a page
   that had the name first: `.msg` is page-local on three migrated pages,
   `.thread`, `.turn`, `.cbox` and `.log` on others, and a collision fails the
   innocent page's shadow rule rather than this one. Swept programmatically
   against every page before it was written; zero collisions.

   Same [open] discipline as .glossdlg and .cmpdlg: no `display` in the base
   rule, or a <dialog> shows on every page that loads this file.               */
.cslt-dlg {
  padding: 0; border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-solid); color: var(--text-primary);
  box-shadow: var(--shadow-lg);
  width: min(620px, calc(100vw - 28px)); max-width: none;
  height: min(74vh, 680px); max-height: calc(100vh - 32px);
  flex-direction: column;                 /* only applies once [open] displays it */
}
.cslt-dlg[open] { display: flex; }
.cslt-dlg::backdrop { background: rgba(11,11,11,.42); }
:root[data-theme="dark"] .cslt-dlg::backdrop { background: rgba(0,0,0,.62); }

.cslt-head {
  display: flex; align-items: flex-start; gap: var(--s3); flex: 0 0 auto;
  padding: var(--s3) var(--s4);
  border-bottom: 1px solid var(--border); background: var(--surface-2);
}
.cslt-titles { min-width: 0; }
/* The header is where the box says whose conversation this is. It is not
   decoration: the same thread is open on the Products page, and a person who
   thinks this is a scratch pad would be surprised by what the bot remembers. */
.cslt-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.cslt-head .cslt-x { margin-left: auto; flex: none; }

.cslt-fail { flex: 0 0 auto; padding: 0 var(--s4); }
.cslt-fail:empty { display: none; }
.cslt-fail .notice { margin-top: var(--s3); }

.cslt-thread {
  flex: 1 1 auto; overflow-y: auto; padding: var(--s4);
  display: flex; flex-direction: column; gap: var(--s3);
}
.cslt-turn { max-width: 82%; font-size: 13.5px; line-height: 1.6; }
.cslt-turn > :first-child { margin-top: 0; }
.cslt-turn > :last-child { margin-bottom: 0; }
.cslt-me {
  align-self: flex-end; background: var(--brand); color: var(--brand-ink);
  padding: var(--s2) var(--s3); border-radius: var(--radius-s);
}
.cslt-bot {
  align-self: flex-start; background: var(--surface-2);
  padding: var(--s2) var(--s3); border-radius: var(--radius-s);
}
.cslt-pending { color: var(--muted); }
.cslt-note { align-self: center; color: var(--muted); font-size: 12.5px; text-align: center; }

.cslt-form {
  flex: 0 0 auto; display: flex; gap: var(--s2); align-items: flex-end;
  padding: var(--s3) var(--s4); border-top: 1px solid var(--border);
  background: var(--surface-2);
}
.cslt-in { flex: 1 1 auto; min-width: 0; resize: vertical; }
.cslt-send { flex: none; }

/* ── the two segments (phase D) ──────────────────────────────────────────────
   Two NAMED CONVERSATIONS in one mount, never one blended chat: Products is the
   product bot, with its own per-user memory, sticky country and repetition
   ledger, and Platform is the agent, where every turn is independent. A router
   deciding per turn which brain owns a sentence would have to route follow-ups,
   and a follow-up carries its meaning in the OTHER brain's state, so the router
   breaks whichever thread it interrupts. The segments make the boundary a fact
   the reader can see instead of a guess a router makes.                       */
.cslt-seg {
  display: flex; gap: 2px; flex: none; margin-top: var(--s2);
  background: var(--surface-2); border-radius: var(--radius-s); padding: 2px;
}
.cslt-segbtn {
  border: 0; background: none; font: inherit; cursor: pointer;
  color: var(--muted); padding: var(--s1) var(--s3);
  border-radius: calc(var(--radius-s) - 2px);
}
.cslt-segbtn[aria-selected="true"] {
  background: var(--surface-1); color: var(--text-primary); font-weight: 600;
}

/* A result and a choice are BUTTONS, because the drawer never builds a URL and
   the page decides what opening one means. They are appended with appendChild,
   so a listener on the dialog is what makes them work. */
.cslt-list { display: flex; flex-direction: column; gap: var(--s2); margin-top: var(--s2); }
.cslt-hit {
  display: block; width: 100%; text-align: left; cursor: pointer; font: inherit;
  background: var(--surface-1); border: 1px solid var(--border);
  border-radius: var(--radius-s); padding: var(--s2) var(--s3);
}
.cslt-hit:hover { background: var(--surface-2); }
.cslt-rank { color: var(--muted); font-variant-numeric: tabular-nums; }
.cslt-why, .cslt-quote { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.cslt-quote { border-left: 2px solid var(--border); padding-left: var(--s2); }
.cslt-can { margin: var(--s2) 0 0; padding-left: var(--s4); font-size: 12.5px; }

/* Two classes deep, deliberately. `.msg.bot a` on the products page is two
   classes and would outrank a one-class rule, and the failure mode is a drawer
   that looks almost right: the wrong link colour, a nowrap, and a download
   arrow it never asked for. This drawer is on that page. */
.cslt-dlg .cslt-turn a { color: var(--brand); text-decoration: underline; white-space: normal; }
.cslt-dlg .cslt-turn a::after { content: none; }

/* The rail's own entry for it. It is not a page, so it carries no count and is
   not one of the four the phone keeps: it is the door to the drawer. */
.cslt-trigger {
  background: none; border: 0; width: 100%; cursor: pointer;
  font: inherit; text-align: left;
}

@media (max-width: 560px) {
  .cslt-dlg { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; }
  .cslt-turn { max-width: 92%; }
}

/* ── a picture at its OWN shape ───────────────────────────────────────────────
   A generated asset is 1:1, 4:5, 9:16, 16:9 or 1.91:1 depending on where it is
   going. A box that fixes BOTH dimensions letterboxes every one of them that is
   not square, and the bars read as a rendering fault rather than as a mount.
   Measured on the 84px square the country's request card used: 20.2% of the box
   is empty tint for a 4:5, 44.0% for a 9:16, 47.6% for a 1.91:1.

   The fix is arithmetic, not taste. You cannot avoid bars in a box that
   constrains both dimensions, so constrain ONE and let the other follow: the box
   becomes the shape of the picture. No bars, no crop, no JS, and nothing has to
   know the ratio in advance.

   `object-fit: cover` is the other obvious answer and it is wrong HERE, on
   purpose. These are review surfaces. Cropping means the thing somebody clicked
   to approve is not the thing they were shown, and what cover takes first is the
   edges -- which is exactly where `placement.py` scores whether the logo landed
   badly and where the legal band sits. A tidy thumbnail that hides the defect
   the reviewer is there to catch is worse than an untidy one.

   Two variants, because which dimension is free depends on the container, and
   getting that backwards puts the bars straight back:
     -h  in a ROW, where there is horizontal room. Height is the constant.
     -w  in a COLUMN of fixed width. Width is the constant, height follows, and
         there is deliberately no max-height: capping the height re-constrains
         the second dimension and the letterbox returns. */
.vm-shot { display: block; object-fit: contain; border-radius: var(--radius-s); }
.vm-shot-h { height: var(--shot-h, 84px); width: auto;
             max-width: var(--shot-max, 150px); flex: none; }
.vm-shot-w { width: 100%; height: auto; }

/* ── the picture, full size ───────────────────────────────────────────────────
   Every content image on the platform opens here, because on this platform an
   image is EVIDENCE and a thumbnail cannot be checked. The rule that made this
   necessary is already written down: zoom the wordmark, THEN zoom the line under
   it. A 130px card cannot answer either question, and the reviewer whose job is
   to catch an invented badge was being asked to catch it at 130px.

   Dark chrome regardless of theme: this is a viewer, the picture is the only
   thing that should carry colour, and a white surround shifts how a photograph's
   own white balance reads. Same [open] discipline as every other dialog here. */
.lb-dlg {
  padding: 0; border: 0; background: transparent;
  width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh;
  overflow: hidden; flex-direction: column; color: #FFFFFF;
}
.lb-dlg[open] { display: flex; }
.lb-dlg::backdrop { background: rgba(6,8,12,.92); }

.lb-bar {
  display: flex; align-items: center; gap: var(--s3); flex: 0 0 auto;
  padding: var(--s3) var(--s4); background: rgba(0,0,0,.55);
  border-bottom: 1px solid rgba(255,255,255,.10);
}
.lb-cap {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: 12.5px; color: rgba(255,255,255,.82);
}
.lb-sp { margin-left: auto; display: flex; align-items: center; gap: var(--s2); flex: none; }
/* The zoom readout is also the reset control, which is why it is a button and
   not a label: at 240% the thing you want is one press back to fitted. */
.lb-pct {
  font: 12px/1 var(--mono); font-variant-numeric: tabular-nums;
  color: rgba(255,255,255,.82); background: rgba(255,255,255,.10);
  border: 0; border-radius: var(--radius-pill); padding: var(--s2) var(--s3);
  cursor: pointer; min-width: 62px; text-align: center;
}
.lb-pct:hover { background: rgba(255,255,255,.18); }
.lb-dlg .iconbtn { color: #FFFFFF; }
.lb-dlg .iconbtn:hover { background: rgba(255,255,255,.16); }

/* The stage clips; the image is moved by transform inside it. Nothing here
   scrolls, because a scrollbar plus a drag-to-pan is two answers to one
   question and they fight on a trackpad. */
.lb-stage {
  flex: 1 1 auto; position: relative; overflow: hidden;
  display: grid; place-items: center; touch-action: none;
}
.lb-img {
  position: absolute; top: 0; left: 0; transform-origin: 0 0;
  max-width: none; max-height: none;
  user-select: none; -webkit-user-drag: none;
}
.lb-stage.lb-fit  { cursor: zoom-in; }
.lb-stage.lb-can  { cursor: grab; }
.lb-stage.lb-drag { cursor: grabbing; }
.lb-hint {
  position: absolute; left: 50%; bottom: var(--s4); transform: translateX(-50%);
  font-size: 12px; color: rgba(255,255,255,.72); background: rgba(0,0,0,.55);
  border-radius: var(--radius-pill); padding: var(--s2) var(--s3);
  pointer-events: none;
}
/* Never silent: a picture that will not load has to say so, exactly as the
   thumbnails it was opened from do. */
.lb-fail { color: rgba(255,255,255,.82); font-size: 13px; padding: var(--s5); text-align: center; }

/* What tells a reader an image is worth pressing. Applied by the page, so a
   logo or an icon never gets it. */
img[data-lb] { cursor: zoom-in; }

@media (max-width: 560px) {
  .lb-hint { display: none; }       /* no wheel and no hover to explain */
  .lb-cap  { display: none; }
}

/* ── asking before something irreversible ────────────────────────────────────
   Until 2026-08-12 twenty-five presses across four pages asked with the
   BROWSER's own confirm() and prompt(): a grey sheet dropping from the top of
   the tab, carrying the origin URL, an OK and a Cancel, in the browser's
   chrome and the browser's language. It reads as an error or a security
   warning, and it sat on exactly the presses that most need a person calm and
   reading — the four that spend credits, the release of a picture to a
   country, the withdrawal of an approval, the send of a message to a real
   customer. Somebody startled by the box asking the question is not reading
   the question.

   None of what replaces it is decoration. confirm() cannot say what a press
   costs, cannot colour a destruction red, cannot name its own button
   ("Generate", never "OK"), cannot speak the country reviewer's language, and
   cannot be driven by the browser suites, which is why not one of these
   presses had ever been driven. It also blocks the whole tab while it is up,
   so the page behind it is frozen rather than merely covered.

   EVERY class here is prefixed `cf-`, swept against every page's class_vocab
   before it was written (zero collisions), for the reason the `.pn` collision
   cost: a name added to this file enters the shared vocabulary and can shadow
   a page that had the name first. Same [open] discipline as .glossdlg,
   .cmpdlg and .cslt-dlg: no `display` in the base rule, or a <dialog> shows on
   every page that loads this file.                                          */
.cf-dlg {
  padding: 0; border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-solid); color: var(--text-primary);
  box-shadow: var(--shadow-lg);
  width: min(500px, calc(100vw - 28px)); max-width: none;
  max-height: calc(100vh - 32px);
  flex-direction: column;                 /* only applies once [open] displays it */
}
.cf-dlg[open] { display: flex; }
.cf-dlg::backdrop { background: rgba(11,11,11,.42); }
:root[data-theme="dark"] .cf-dlg::backdrop { background: rgba(0,0,0,.62); }

.cf-head {
  display: flex; align-items: flex-start; gap: var(--s3); flex: 0 0 auto;
  padding: var(--s5) var(--s5) 0;
}
.cf-head h2 { margin: 0; font-size: 16px; font-weight: 680; line-height: 1.35; }

/* The one thing that says which KIND of question this is before a word of it
   is read: red destroys, refuses or spends, navy is everything else. An icon
   AND a colour, never a colour on its own. */
.cf-mark {
  flex: none; width: 30px; height: 30px; border-radius: var(--radius-pill);
  display: grid; place-items: center;
  color: var(--brand); background: color-mix(in srgb, var(--brand) 14%, transparent);
}
.cf-dlg.cf-danger .cf-mark {
  color: var(--critical); background: color-mix(in srgb, var(--critical) 14%, transparent);
}

.cf-body {
  padding: var(--s3) var(--s5) 0; overflow-y: auto; flex: 1 1 auto;
  font-size: 14px; line-height: 1.6; color: var(--text-secondary);
}
.cf-body p { margin: 0 0 var(--s2); }
.cf-body p:last-child { margin-bottom: 0; }

/* What the press costs, or what about it cannot be taken back: the single fact
   a person is being asked to weigh, so it is never left as the third sentence
   of a paragraph. Tinted, which per the design rules is coloured glass with no
   border and no rule down the side. */
.cf-cost {
  --tint: var(--warn);
  margin: var(--s3) var(--s5) 0; padding: var(--s2) var(--s3);
  border-radius: var(--radius-s); font-size: 13px; font-weight: 600;
  display: flex; align-items: center; gap: var(--s2);
  color: var(--tint); background: color-mix(in srgb, var(--tint) 15%, transparent);
  border: 0; box-shadow: none;
}
.cf-dlg.cf-danger .cf-cost { --tint: var(--critical); }

.cf-fields { padding: var(--s4) var(--s5) 0; display: grid; gap: var(--s4); }
.cf-f { display: grid; gap: var(--s1); min-width: 0; }
.cf-f > label { font-size: 12.5px; font-weight: 650; color: var(--text-primary); }
.cf-f .cf-hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
.cf-f input, .cf-f select, .cf-f textarea { width: 100%; }
.cf-f textarea { min-height: 78px; }
/* Never silent, and never a dialog that simply refuses to close: an empty
   required field says which one and why, next to itself. */
.cf-bad { font-size: 12px; color: var(--critical); font-weight: 650; }

.cf-foot {
  display: flex; justify-content: flex-end; gap: var(--s2); flex-wrap: wrap;
  padding: var(--s5); flex: 0 0 auto;
}
/* At 320px a right-aligned pair wraps into a column and lands the destructive
   press at the BOTTOM, which is exactly where a thumb rests. Reversed, so the
   button that acts is on top and Cancel is under the thumb. */
@media (max-width: 460px) {
  .cf-foot { flex-direction: column-reverse; }
  .cf-foot .btn { width: 100%; justify-content: center; }
}
