/* ==========================================================================
   Free Stack stylesheet
   The brand source of truth is design-system/colors_and_type.css (tokens,
   fonts, base type). This file consumes those tokens and adds the app layer:
   APP TOKENS → DARK MODE → BASE → COMPONENTS → CURATOR → CLIENT → WORKSPACE
   → PRINT. The WORKSPACE block (js/my/*, the /my surface, PRD-REGISTER) is
   owned by the workspace-builder agent and is otherwise additive: it must
   not redefine anything above it.
   Mapping documented in DESIGN-SYSTEM.md. The vibe: a confidential memo
   printed on good paper. Flat, square, cream and oxblood.
   ========================================================================== */

@import url("../design-system/colors_and_type.css");

/* === APP TOKENS ============================================================ */
:root {
  /* Tool types mapped to the brand's semantic palette.
     Badges are status chips in a data table, the one sanctioned pill. */
  --core-bg: var(--positive-tint);  --core-fg: #445C32;
  --noncore-bg: var(--caution-tint); --noncore-fg: #7A5A20;
  --m365-bg: var(--info-tint);      --m365-fg: #2F4763;
  --sector-bg: var(--lavender-2);   --sector-fg: #5C4A70;

  /* Row accents: a rule of the type colour, not a loud wash */
  --core-rule: var(--positive);
  --noncore-rule: var(--caution);
  --m365-rule: var(--info);
  --sector-rule: #8A79A0;
}

/* === DARK MODE ==============================================================
   Every token below is overridden here and only here; design-system files
   are never edited. Higher specificity than the plain :root blocks above
   (an attribute selector on :root beats a bare :root), so it wins regardless
   of source order. Everything else in the app (cards, table, chips, chart)
   inherits these same custom properties, so this block is the entire theme.

   Computed contrast (WCAG relative-luminance formula, scratch script, see
   task report): every body-text pair below clears 4.5:1, every non-text/
   focus pair clears 3:1, both against --paper and --paper-2. Two tokens
   deliberately do NOT track the light-mode relationship:
     --oxblood-deep ends up LIGHTER than --oxblood (light mode is the other
       way round), because on dark backgrounds it is read as text (error
       copy, the active starter-pack chip) as often as it is read as a
       button fill, and every one of those text uses needs to be light
       enough to sit on a near-black surface.
     --graphite is flipped light-for-dark, because .btn-secondary and the
       cost-chart bar both use it as a hover fill that has to keep working
       against text colours that swap with the theme (see the button rules
       just below the token list). */
:root[data-theme="dark"] {
  --paper:        #16130F;
  --paper-2:      #201C16;
  --paper-edge:   #353028;
  --ink:          #EFEAE0;
  --ink-2:        #C9C2B4;
  --ink-3:        #9C9384;
  --ink-4:        #847C6C;

  --oxblood:      #D97369;  /* lightened: text, links, rules, focus ring */
  --oxblood-deep: #E2887F;  /* lighter still: see note above */
  --oxblood-tint: #301A14;

  --graphite:     #DCD4C2;
  --steel-3:      #4C463A;

  --positive:      #8FBF6F;
  --positive-tint: #2A3320;
  --caution:       #D1A24C;
  --caution-tint:  #3A2E14;
  --info:          #7EA6CC;
  --info-tint:     #1E2A38;
  --lavender-2:    #2E2438;

  /* Badge text: hardcoded hex in APP TOKENS above, so hardcoded again here. */
  --core-fg:    #B7D69C;
  --noncore-fg: #E3B968;
  --m365-fg:    #9FC1E0;
  --sector-fg:  #D8C3EA;
  --sector-rule: #B39FCB;
}

/* .btn-primary keeps its normal rule (background: var(--oxblood); color:
   var(--paper);) unmodified: with the tokens above that already resolves to
   a light red button with near-black text at 5.8:1, no override needed. The
   :hover swap to --oxblood-deep is the one path that would otherwise pair
   two similarly dark colours, so it is the one rule this theme touches. */
:root[data-theme="dark"] .btn-primary:hover { background: var(--oxblood-deep); }

/* === BASE ================================================================== */
*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  font-size: var(--fs-16);
  line-height: var(--lh-16);
}

h1, h2, h3 { margin: 0; }
p { margin: 0; }
img { max-width: 100%; }
button { font: inherit; }

/* The UA stylesheet's [hidden] rule is only display:none, which loses to
   any later same-specificity display declaration in source order (flex
   containers, inline-flex buttons and the like, all defined further down
   this file). Every mount root and control across every surface (curator,
   client, my, discover) toggles the hidden IDL property expecting it to
   actually hide the element, so this needs to win outright rather than
   relying on source order never regressing it again. */
[hidden] { display: none !important; }

:is(a, button, input, select, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--oxblood);
  outline-offset: 2px;
}

/* Phase 15.4, section 18 amended clause: the shared theme-boot inline
   script stamps js-boot on <html> before first paint, so a JS-capable load
   never paints #static-root for the duration of the tools.json fetch (it
   used to stay visible until js/data-loader.js's boot() hid it after the
   fetch resolved, which painted the whole crawler block on slow mobile
   connections). Only index.html has a #static-root element; the stamp
   lands on every page sharing the boot script, harmless where the id is
   absent. The boot failure path in js/data-loader.js removes the stamp so
   a broken app still falls back to the readable static content, and non-JS
   readers never receive the stamp at all. */
html.js-boot #static-root { display: none; }

.app-message {
  max-width: var(--measure);
  margin: var(--s-24) auto;
  padding: var(--s-8);
  color: var(--ink-2);
  background: var(--paper-2);
  border: var(--border);
  border-top: var(--bw-rule) solid var(--oxblood);
}
.app-message.is-error { border-top-color: var(--oxblood); color: var(--oxblood-deep); }

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

/* === COMPONENTS ============================================================ */

/* Buttons: flat, square-ish, no size change on hover */
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: 10px 18px;
  border: var(--bw-hair) solid transparent;
  border-radius: var(--r-2);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-14);
  line-height: 1;
  letter-spacing: var(--tr-loose);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
  white-space: nowrap;
  text-decoration: none;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--oxblood); color: var(--paper); }
.btn-primary:hover { background: var(--oxblood-deep); }
.btn-secondary { background: var(--ink); color: var(--paper); }
.btn-secondary:hover { background: var(--graphite); }
.btn-ghost { background: transparent; color: var(--ink); border-color: var(--ink-3); }
.btn-ghost:hover { background: var(--paper-2); }
.btn-sm { padding: 7px 12px; font-size: var(--fs-12); }
/* Client-mode chrome (print, share): WCAG 2.2 target size, 44px minimum tall */
.btn-lg { min-height: 44px; padding: 12px 20px; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; }

/* Theme toggle: shared between curator (near the tool count) and client
   (in the no-print share/print toolbar). Icon plus a text label that always
   names the mode a click would switch TO, never the current one. */
.theme-toggle { gap: var(--s-1); }
.theme-toggle-icon { flex: none; }

/* Inputs */
.input, .select {
  padding: 9px 12px;
  border: var(--bw-hair) solid var(--steel-3);
  border-radius: var(--r-1);
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-text);
  font-size: var(--fs-16);
  transition: border-color var(--dur-fast) var(--ease-out);
}
.input:hover, .select:hover { border-color: var(--ink-3); }
.input::placeholder { color: var(--ink-4); }

/* Type badges: text label always, colour never alone */
.badge {
  display: inline-block;
  padding: 2px 10px;
  border-radius: var(--r-pill);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-eyebrow);
  white-space: nowrap;
}
.badge-core    { background: var(--core-bg);    color: var(--core-fg); }
.badge-noncore { background: var(--noncore-bg); color: var(--noncore-fg); }
.badge-m365    { background: var(--m365-bg);    color: var(--m365-fg); }
.badge-sector  { background: var(--sector-bg);  color: var(--sector-fg); }

/* Panels: bordered paper, flat at rest */
.panel {
  background: var(--paper);
  border: var(--border);
}

/* Eyebrow label (shared) */
.eyebrow {
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  line-height: 1;
  font-weight: 500;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--oxblood);
}

/* Favicons */
.favicon {
  width: 16px;
  height: 16px;
  vertical-align: -0.15em;
  margin-right: var(--s-1);
  flex: none;
}

/* Toast: an ink slip */
.toast {
  position: fixed;
  left: 50%;
  bottom: var(--s-8);
  transform: translateX(-50%);
  background: var(--ink);
  color: var(--paper);
  padding: 12px 20px;
  border-left: var(--bw-heavy) solid var(--positive);
  box-shadow: var(--shadow-3);
  font-family: var(--font-sans);
  font-size: var(--fs-14);
  z-index: 100;
  animation: toast-in var(--dur-base) var(--ease-out);
}
.toast.is-error { border-left-color: var(--oxblood); }
@keyframes toast-in {
  from { opacity: 0; transform: translateX(-50%) translateY(8px); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0); }
}

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

/* Wave 14.2 (PRD section 16 amended, motion inventory, closing clause: "no
   view transition" under reduced motion): the universal selector above
   cannot reach the ::view-transition tree the browser generates outside the
   normal DOM for an active View Transition, since js/data-loader.js's
   withViewTransition() helper already never starts one under reduced
   motion, this is a defensive second guard, exactly the same belt-and-braces
   posture every other item in the inventory takes (JS decides, CSS confirms
   it behind the same query). */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}

/* Screen-only chrome (print/share controls) is hidden in @media print below
   via the .no-print class; it needs no screen styles. */

/* === CURATOR =============================================================== */

#curator-root {
  max-width: 1360px;
  margin: 0 auto;
  padding: var(--s-8) var(--s-6) var(--s-24);
}

.cur-header {
  display: flex;
  align-items: center;
  gap: var(--s-6);
  flex-wrap: wrap;
  padding-bottom: var(--s-5);
  border-bottom: var(--bw-rule) solid var(--oxblood);
  margin-bottom: var(--s-8);
}
.cur-header .logo { height: 40px; width: auto; }
.cur-header h1 {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-26);
  line-height: var(--lh-26);
  letter-spacing: var(--tr-tight);
}
.cur-header .subtitle { color: var(--ink-2); font-size: var(--fs-14); max-width: none; }
.cur-header .trust-line { color: var(--ink-3); font-size: var(--fs-12); max-width: none; margin-top: var(--s-1); }
.cur-header .tool-count {
  margin-left: auto;
  color: var(--ink-3);
  font-size: var(--fs-12);
  letter-spacing: var(--tr-loose);
  text-transform: uppercase;
  font-family: var(--font-sans);
  max-width: none;
}

/* First-visit walkthrough (Phase 21, PRD section 6, "First-visit
   walkthrough"): the same panel idiom as .linkgen directly below it (an
   oxblood top rule on the shared .panel backing), since the two sit
   stacked and are meant to read as one family, not a new component. Plain
   flow content: no overlay, no backdrop, no focus trap, nothing here reads
   as a dialog. */
.cur-walkthrough {
  padding: var(--s-6) var(--s-8);
  margin-bottom: var(--s-6);
  border-top: var(--bw-rule) solid var(--oxblood);
}
.cur-walkthrough-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  margin-bottom: var(--s-3);
}
.cur-walkthrough-steps {
  margin: 0;
  padding-left: var(--s-6);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  color: var(--ink-2);
}

/* Link generator */
.linkgen {
  padding: var(--s-6) var(--s-8);
  margin-bottom: var(--s-6);
  border-top: var(--bw-rule) solid var(--oxblood);
}
.linkgen h2 {
  margin-bottom: var(--s-4);
  font-size: var(--fs-21);
  line-height: var(--lh-21);
}
.linkgen .eyebrow { display: block; margin-bottom: var(--s-2); }
.linkgen-controls {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
  align-items: center;
}
.linkgen-controls .input { flex: 1 1 16rem; }
/* Plain English checkbox (Batch H, Feature 1): sits with the other link
   generator controls, never resized down to a bare checkbox since the
   label text is what tells a curator what it does. */
.linkgen-checkbox {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-size: var(--fs-14);
  color: var(--ink-2);
  cursor: pointer;
}
.linkgen-result {
  margin-top: var(--s-4);
  display: flex;
  gap: var(--s-2);
  align-items: center;
}
.linkgen-result .generated-url {
  flex: 1;
  font-family: var(--font-mono);
  font-size: var(--fs-12);
  padding: 9px 12px;
  background: var(--paper-2);
  border: var(--border);
  border-radius: var(--r-1);
  overflow-x: auto;
  white-space: nowrap;
}

/* Branded exports (Batch E): a labelled row inside the same panel as the
   link generator, acting on the same selection and name/note fields. */
.linkgen-export {
  margin-top: var(--s-5);
  padding-top: var(--s-4);
  border-top: var(--border);
}
.linkgen-export .eyebrow { display: block; margin-bottom: var(--s-2); }
.linkgen-export-buttons { display: flex; gap: var(--s-2); flex-wrap: wrap; }

/* Starter packs: one-click preset chips, above the filters bar. Selection
   stays fully editable after applying one, see is-modified below. */
.cur-presets {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-bottom: var(--s-5);
}
.cur-presets-label { flex: none; }
.cur-chip-row { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.cur-chip {
  padding: 6px 14px;
  border: var(--bw-hair) solid var(--steel-3);
  border-radius: var(--r-pill);
  background: var(--paper);
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-loose);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.cur-chip:hover { border-color: var(--ink-3); color: var(--ink); }
.cur-chip.is-active {
  background: var(--oxblood-tint);
  border-color: var(--oxblood);
  color: var(--oxblood-deep);
}
/* Modified state rides on top of is-active, never replaces it: the applied
   pack is still the applied pack, just no longer an exact match. */
.cur-chip.is-modified { font-style: italic; }

/* "Start here" need chips: a filter, not a selection, so deliberately
   quieter than the starter pack chips above (dashed ghost outline, muted
   ink rather than oxblood) to read as a different kind of control. */
.cur-needs {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-bottom: var(--s-3);
}
.cur-needs-label { flex: none; }
.cur-chip-ghost {
  background: transparent;
  border-style: dashed;
  color: var(--ink-3);
}
.cur-chip-ghost:hover { border-color: var(--ink-3); color: var(--ink); }
.cur-chip-ghost.is-active {
  background: var(--paper-2);
  border-style: solid;
  border-color: var(--ink-3);
  color: var(--ink);
}

/* Filters + stats */
.cur-toolbar {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: var(--s-4);
}
.cur-toolbar .input { flex: 1 1 14rem; }

.cur-stats {
  display: flex;
  gap: var(--s-10);
  flex-wrap: wrap;
  align-items: baseline;
  padding: var(--s-4) var(--s-6);
  margin-bottom: var(--s-4);
  background: var(--paper-2);
  border: var(--border);
  font-size: var(--fs-14);
  color: var(--ink-2);
  /* Sticks beneath the table's sticky header row, offset set from JS
     (--thead-h) to the header's measured height. Opaque bg, own row. */
  position: sticky;
  top: var(--thead-h, 41px);
  z-index: 1;
}
.cur-stats strong {
  font-family: var(--font-sans);
  font-weight: 500;
  color: var(--ink);
  font-size: var(--fs-21);
}
.cur-stats .stat-value strong { color: var(--positive); }

.legend {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: var(--s-5);
  font-size: var(--fs-12);
  color: var(--ink-3);
}

/* Table: a ledger. Ink header, hairline rows, type marked by a left rule. */
.table-wrap {
  overflow-x: auto;
  border: var(--border);
  border-top: var(--bw-heavy) solid var(--ink);
  background: var(--paper);
}
.tools-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-14);
  line-height: var(--lh-14);
  min-width: 1080px;
}
.tools-table th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: var(--ink);
  color: var(--paper);
  text-align: left;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  letter-spacing: var(--tr-loose);
  text-transform: uppercase;
  padding: 10px 12px;
  white-space: nowrap;
}
.tools-table td {
  padding: 10px 12px;
  border-top: var(--bw-hair) solid var(--paper-edge);
  vertical-align: top;
}
.tools-table tr.row-core    td:first-child { box-shadow: inset 3px 0 0 var(--core-rule); }
.tools-table tr.row-noncore td:first-child { box-shadow: inset 3px 0 0 var(--noncore-rule); }
.tools-table tr.row-m365    td:first-child { box-shadow: inset 3px 0 0 var(--m365-rule); }
.tools-table tr.row-sector  td:first-child { box-shadow: inset 3px 0 0 var(--sector-rule); }
.tools-table tr:hover td { background: var(--paper-2); }
.tools-table a { color: var(--ink); }
.tools-table a:hover { color: var(--oxblood); }
.tools-table .cell-name { min-width: 12rem; font-family: var(--font-sans); font-weight: 500; }
.tools-table .cell-name .tool-urls {
  font-family: var(--font-text);
  font-weight: 400;
  font-size: var(--fs-12);
  margin-top: 4px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.tools-table .cell-desc { min-width: 16rem; max-width: 24rem; color: var(--ink-2); }
.tools-table .cell-links { min-width: 10rem; max-width: 14rem; }
.tools-table .cell-links a { display: block; margin-bottom: 3px; }
.tools-table .cell-value { white-space: nowrap; font-family: var(--font-sans); font-weight: 500; color: var(--positive); }
/* Quiet pricing subline (Feature 1): never louder than the ~£X/yr figure
   it annotates, so ink-3 and fs-12 rather than the positive-green above. */
.tools-table .cell-value-sub {
  margin-top: 2px;
  font-weight: 400;
  font-size: var(--fs-12);
  color: var(--ink-3);
}
/* BYO marker (Feature 1): neutral outline chip, never as loud as the type
   badge it sits beside in the same cell. */
.byo-chip {
  display: inline-block;
  margin-left: var(--s-2);
  padding: 1px 6px;
  vertical-align: middle;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  letter-spacing: var(--tr-eyebrow);
  color: var(--ink-3);
  border: var(--bw-hair) solid var(--steel-3);
  border-radius: var(--r-2);
}
.tools-table .cell-when { min-width: 10rem; max-width: 14rem; color: var(--ink-3); font-size: var(--fs-12); }
.tools-table input[type="checkbox"] {
  width: 17px;
  height: 17px;
  accent-color: var(--oxblood);
  cursor: pointer;
}
.cur-check-label { display: inline-flex; align-items: center; cursor: pointer; }

/* Mobile-only "More" column: same data as the Alternatives/Training/Include
   When columns, duplicated into a single collapsible cell. Hidden above the
   767px breakpoint, where the three columns it duplicates are shown instead. */
.tools-table td.cell-more { display: none; }
.cur-more summary {
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: var(--s-1);
  min-height: 44px;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-14);
  color: var(--oxblood);
}
.cur-more summary::-webkit-details-marker { display: none; }
.cur-more summary::before { content: '+'; }
.cur-more[open] summary::before { content: '\2212'; } /* minus sign */
.cur-more-section + .cur-more-section { margin-top: var(--s-3); }
.cur-more-section:first-of-type { margin-top: var(--s-1); }
.cur-more-label {
  display: block;
  margin-bottom: var(--s-1);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
}
.cur-more-when, .cur-more-text { color: var(--ink-2); font-size: var(--fs-14); line-height: var(--lh-14); }

.cur-actions {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-top: var(--s-6);
}

/* === CLIENT ================================================================ */

#client-root {
  max-width: 960px;
  margin: 0 auto;
  padding: var(--s-8) var(--s-6) var(--s-24);
}

.cli-header {
  padding: var(--s-10) var(--s-10) var(--s-8);
  margin-bottom: var(--s-6);
  border-top: var(--bw-heavy) solid var(--oxblood);
}
.cli-header .logo { height: 40px; width: auto; margin-bottom: var(--s-8); }
.cli-header h1 {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(34px, 5.5vw, 58px);
  line-height: 1.02;
  letter-spacing: var(--tr-tight);
  text-wrap: balance;
}
.cli-header .prepared-for {
  margin-top: var(--s-4);
  max-width: none;
  /* Names and notes come off the URL: an unbroken 80-char string must wrap,
     not push the page wide on a phone. */
  overflow-wrap: anywhere;
}
.cli-header .curated-by { color: var(--ink-3); font-size: var(--fs-14); margin-top: var(--s-1); max-width: none; overflow-wrap: anywhere; }
.cli-header .cli-date { color: var(--ink-3); font-size: var(--fs-14); margin-top: var(--s-2); max-width: none; }
.cli-header .cli-context { margin-top: var(--s-5); color: var(--ink-2); }

/* Personal note from the curator: quiet panel, oxblood rule, same family
   as .card-notes but on brand rather than neutral steel. */
.cli-note {
  margin-top: var(--s-5);
  padding: var(--s-4) var(--s-5);
  background: var(--paper-2);
  border-left: var(--bw-heavy) solid var(--oxblood);
}
.cli-note-label { display: block; margin-bottom: var(--s-1); }
.cli-note-text { color: var(--ink-2); max-width: none; overflow-wrap: anywhere; }

/* Screen-only chrome: share + print, hidden entirely in @media print */
.cli-toolbar {
  display: flex;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-bottom: var(--s-6);
}
/* Plain English toggle (Batch H, Feature 1): pressed state reuses the same
   sage treatment as the adoption checklist's card-toggle, so "on" always
   reads the same way across the page regardless of which control it is. */
.plain-toggle[aria-pressed="true"] {
  background: var(--positive-tint);
  border-color: var(--positive);
  color: var(--core-fg);
}

.cli-summary {
  display: flex;
  gap: var(--s-16);
  flex-wrap: wrap;
  padding: var(--s-6) var(--s-10);
  margin-bottom: var(--s-10);
  background: var(--paper-2);
  border: var(--border);
  border-left: var(--bw-heavy) solid var(--positive);
}
.cli-summary .num {
  display: block;
  font-family: var(--font-display);
  font-weight: 900;
  font-size: var(--fs-34);
  line-height: var(--lh-34);
  color: var(--ink);
}
.cli-summary div:last-child .num { color: var(--positive); }
.cli-summary .lbl {
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
}
/* Points at the cost-growth section (Feature 3): a full width row under the
   number blocks, not another flex item beside them, thanks to flex-wrap
   already set on .cli-summary above. */
.cli-summary-note {
  flex-basis: 100%;
  margin-top: var(--s-2);
  font-size: var(--fs-12);
  color: var(--ink-3);
  max-width: none;
}

.cli-category {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-26);
  line-height: var(--lh-26);
  letter-spacing: var(--tr-tight);
  margin: var(--s-12) 0 var(--s-5);
  padding-bottom: var(--s-2);
  border-bottom: var(--bw-rule) solid var(--oxblood);
}
.cli-category:first-of-type { margin-top: 0; }
/* Lucide icons (ISC licence), inlined by hand in client.js */
.cli-category-icon { flex: none; color: var(--oxblood); }

/* Adoption checklist progress line, in the summary bar area */
.cli-progress {
  margin-top: calc(-1 * var(--s-6));
  margin-bottom: var(--s-10);
  font-size: var(--fs-14);
  color: var(--ink-2);
}
.cli-progress p { margin: 0; max-width: none; }
.cli-progress-note {
  margin-top: var(--s-1);
  font-size: var(--fs-12);
  color: var(--ink-3);
  max-width: none;
}
/* Share-back button (Batch H, Feature 2): its own line under the progress
   note, only present at all when the checklist itself renders. */
.cli-progress .btn { margin-top: var(--s-4); }

.card-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-5);
  list-style: none;
  margin: 0;
  padding: 0;
}
/* A 1fr grid track's minimum size defaults to auto (the widest min-content
   contribution among its items), not 0, so a single card with an unusually
   wide row of inline content (e.g. several short "Get started" links laid
   end to end with a wide favicon gap) can grow the whole column past the
   viewport at narrow widths, same trap already fixed on the curator table's
   .cell-name in the RESPONSIVE block. Surfaced by the public directory
   (Batch I), which is the first view to render every card in the catalogue
   at once rather than a hand-picked selection. */
.card-grid > li { min-width: 0; }

/* Cards: square, hairline border, no lift, border darkens on hover */
.tool-card {
  padding: var(--s-8);
  border-radius: var(--r-2);
  transition: border-color var(--dur-fast) var(--ease-out);
}
.tool-card:hover { border-color: var(--ink-3); }

/* Entrance: fade plus a small rise, staggered per card. Opacity/transform
   only, and only when the reader hasn't asked for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
  .tool-card {
    animation: card-in 240ms cubic-bezier(0, 0, 0.38, 0.9) both;
    animation-delay: calc(var(--i, 0) * 40ms);
  }
}
@keyframes card-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Archived tool arriving via an old link: compact, muted, no value claim */
.tool-card-archived { padding: var(--s-6); }
.tool-card-archived h3 { color: var(--ink-2); }
.tool-card-archived .card-archived-note {
  margin-top: var(--s-2);
  color: var(--ink-3);
  font-size: var(--fs-14);
}

.card-verified {
  margin-top: var(--s-3);
  font-size: var(--fs-12);
  /* ink-3, not ink-4: 4.65:1 on paper-2, the card surface (WCAG 4.5:1) */
  color: var(--ink-3);
}
.tool-card .card-top {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s-2) var(--s-4);
}
.tool-card h3 {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-21);
  line-height: var(--lh-21);
  flex: 1;
  min-width: 60%;
}
/* Plain English mode's value text ("worth about £X a year") is far longer
   than the normal "~£X/yr" chip: no nowrap, so it wraps onto its own line
   instead of forcing the card wider than the viewport at 375px. */
.tool-card .card-value {
  font-family: var(--font-sans);
  font-weight: 500;
  color: var(--positive);
  text-align: right;
}
.tool-card .card-domains {
  margin-top: var(--s-2);
  font-size: var(--fs-14);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1) var(--s-4);
}
/* A domain label can be a full path (e.g. blackmagicdesign.com/products/
   davinciresolve) with no space for the browser's default wrap points to
   land on: without this, that single unbroken string forces the card wider
   than the viewport at narrow widths, the same trap the curator table's
   .cell-name already guards against. min-width: 0 lets the flex item shrink
   below its text's natural width so overflow-wrap has room to act. */
.tool-card .card-domains a { min-width: 0; overflow-wrap: anywhere; }
.tool-card .card-desc { margin-top: var(--s-4); color: var(--ink-2); font-size: var(--fs-16); line-height: var(--lh-16); }
.tool-card .card-section-label {
  margin-top: var(--s-6);
  max-width: none;
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  line-height: 1;
  font-weight: 500;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
}
.tool-card .card-links {
  margin-top: var(--s-3);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-5);
  font-size: var(--fs-14);
}
.tool-card .card-links a { display: inline-flex; align-items: center; color: var(--ink); }
.tool-card .card-links a:hover { color: var(--oxblood); }
.tool-card .card-notes {
  margin-top: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--paper-2);
  border-left: var(--bw-rule) solid var(--steel-3);
  font-size: var(--fs-14);
  line-height: var(--lh-14);
  color: var(--ink-2);
}
.tool-card .card-notes p + p { margin-top: var(--s-2); }

/* Pricing honesty (Batch D, Feature 1): a quiet free-tier line plus one
   neutral status pill. Neutral, not semantic-tinted like the type badges,
   since "free forever" is not inherently good and "paid plans from" is not
   inherently bad: the wording carries the meaning, the colour stays flat. */
.tool-card .card-free-tier {
  margin-top: var(--s-3);
  font-size: var(--fs-14);
  line-height: var(--lh-14);
  color: var(--ink-2);
}
.tool-card .card-free-tier-label {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
  margin-right: var(--s-1);
}
.tool-card .card-pricing { margin-top: var(--s-3); }
.badge-pricing {
  background: var(--paper-2);
  color: var(--ink-2);
  border: var(--bw-hair) solid var(--steel-3);
  /* Plain English mode's pricing text ("Costs from £X a month if you
     outgrow the free version") is a full sentence, not a short pill label
     like the type badges: override the shared .badge nowrap so it wraps
     instead of forcing the card wider than the viewport. */
  white-space: normal;
}

/* Build-your-own aside (optional `byo` field, PRD section 4): kin to
   .card-notes but a sage rule, not steel, since this is a genuine option
   rather than a caveat. Sits between Alternatives and Get started. */
.tool-card .card-byo {
  margin-top: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--paper-2);
  border-left: var(--bw-rule) solid var(--positive);
  font-size: var(--fs-14);
  line-height: var(--lh-14);
  color: var(--ink-2);
}
.tool-card .card-byo-label {
  display: block;
  margin-bottom: var(--s-1);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
}

/* Adoption checklist toggle: a real button, not a decorative checkbox, so
   state reads out to assistive tech via aria-pressed plus its own label.
   Colour is never the only signal, the tick character and word carry it. */
.card-toggle {
  display: inline-flex;
  align-items: center;
  margin-top: var(--s-6);
  min-height: 44px;
  padding: 10px 18px;
  border: var(--bw-hair) solid var(--steel-3);
  border-radius: var(--r-2);
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-14);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.card-toggle:hover { border-color: var(--ink-3); }
.card-toggle[aria-pressed="true"] {
  background: var(--positive-tint);
  border-color: var(--positive);
  color: var(--core-fg); /* the same dark sage already used for core badge text */
}

/* Muted-but-legible treatment: background shifts, text keeps its own
   contrast-checked colours, the card never moves or disappears. */
.tool-card.is-done { background: var(--paper-2); }

.cli-footer {
  margin-top: var(--s-20);
  padding-top: var(--s-6);
  border-top: var(--border);
  display: flex;
  align-items: center;
  gap: var(--s-4);
  color: var(--ink-2);
  font-size: var(--fs-14);
}
.cli-footer .logo { height: 26px; width: auto; }

/* ?tool= permalink Q&A (PRD section 18 per-tool surfacing, wave 14.3b): a
   single question/answer pair fetched from data/faq.json by
   renderSingleTool, hidden until the fetch resolves. Same hairline-divider
   spacing as .cli-footer just above, since it sits in the same position
   between the card and the footer. Base BASE-block rules zero out h2/p
   margins site-wide, so this rule supplies the minimum needed for the pair
   to read as a distinct block rather than running into the card above and
   the footer below. */
.cli-tool-faq {
  margin-top: var(--s-8);
  padding-top: var(--s-6);
  border-top: var(--border);
}
.cli-tool-faq h2 { font-size: var(--fs-26); line-height: var(--lh-26); margin-bottom: var(--s-3); }
.cli-tool-faq p { color: var(--ink-2); }

/* How costs could grow (Batch D, Feature 2): single-series bar chart, no
   legend needed since the section heading already names the series. */
.cli-cost-growth { margin-top: var(--s-12); }
.cli-cost-heading {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-26);
  line-height: var(--lh-26);
  letter-spacing: var(--tr-tight);
  margin-bottom: var(--s-5);
  padding-bottom: var(--s-2);
  border-bottom: var(--bw-rule) solid var(--oxblood);
}
.cli-cost-caption { color: var(--ink-2); max-width: none; }
.cli-cost-caption-note {
  margin-top: var(--s-1);
  color: var(--ink-3);
  font-size: var(--fs-14);
  max-width: none;
}
/* Plain English takeaway (Batch H, Feature 1): sits above the chart, so it
   reads before the reader has to interpret the bars at all. */
.cli-cost-takeaway {
  margin-top: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--paper-2);
  border-left: var(--bw-rule) solid var(--oxblood);
  color: var(--ink);
  font-weight: 500;
  max-width: none;
}

.cli-cost-chart-wrap {
  position: relative;
  margin-top: var(--s-6);
  padding: var(--s-6) var(--s-4) var(--s-4);
}
.cli-cost-chart {
  display: block;
  width: 100%;
  max-width: 420px;
  height: auto;
  margin: 0 auto;
}
.cli-cost-grid { stroke: var(--paper-edge); stroke-width: 1; }
.cli-cost-baseline { stroke: var(--paper-edge); stroke-width: 1.5; }
.cli-cost-bar-fill { fill: var(--ink-2); }
.cli-cost-bar-value {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: 15px;
  fill: var(--ink);
}
.cli-cost-bar-stage {
  font-family: var(--font-sans);
  font-size: 12px;
  fill: var(--ink-3);
}
.cli-cost-hit { fill: transparent; cursor: pointer; }
/* [tabindex] already earns a focus-visible ring from the BASE block rule.
   No animation, no hover/focus movement: the chart stays static at rest
   and under interaction, per the reduced-motion rule for this feature. */
.cli-cost-bar:hover .cli-cost-bar-fill,
.cli-cost-bar:focus .cli-cost-bar-fill { fill: var(--graphite); }

.cli-cost-tooltip {
  position: absolute;
  z-index: 5;
  max-width: 220px;
  padding: var(--s-3) var(--s-4);
  background: var(--ink);
  color: var(--paper);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  line-height: var(--lh-12);
  box-shadow: var(--shadow-2);
  pointer-events: none;
}
.cli-cost-tooltip p { margin: 0; max-width: none; color: inherit; }
.cli-cost-tooltip-free { margin-top: var(--s-1) !important; opacity: 0.8; }

.cli-cost-table-details { margin-top: var(--s-5); }
.cli-cost-table-details summary {
  cursor: pointer;
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-14);
  color: var(--ink-2);
}
.cli-cost-table-details summary:hover { color: var(--oxblood); }
.cli-cost-table {
  margin-top: var(--s-3);
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-14);
}
.cli-cost-table th,
.cli-cost-table td {
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--bw-hair) solid var(--paper-edge);
  text-align: left;
}
.cli-cost-table th {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
}
.cli-cost-table th:nth-child(2), .cli-cost-table th:nth-child(3),
.cli-cost-table td:nth-child(2), .cli-cost-table td:nth-child(3) { text-align: right; }

/* Print-only QR bridge (Batch H, Feature 3): invisible on screen, shown and
   sized in @media print below. Self generated SVG, no third party image
   request either way. */
.print-only { display: none; }
.cli-print-qr {
  margin-top: var(--s-12);
  padding-top: var(--s-6);
  border-top: var(--border);
  text-align: center;
}
.cli-print-qr-label {
  margin-bottom: var(--s-3);
  color: var(--ink-2);
  font-size: var(--fs-14);
}
.cli-print-qr-svg { color: var(--ink); }

/* === PUBLIC ================================================================= */
/* Public/staff split (BUILD-PLAN 10.12, Batch I): the read-only, indexable
   directory at root. Shares .card-grid/.tool-card from the CLIENT block
   above (the same card renderer, checklist toggle suppressed) rather than a
   parallel set of card styles; this block covers only the page-level chrome
   that is unique to the public view. Since Phase 14.1 (PRD section 16
   amended, "compact landing") the flat list is 15 collapsed category
   shelves; client.js's own .cli-category heading is no longer used here at
   all (js/public.js discards it in favour of the shelf's own button
   header), though .card-grid/.tool-card/.cli-category-icon are still
   shared, per the file banner above. */

/* 16.4: scroll-padding-top, not a per-call-site fix. js/public.js's mount
   sets this class the instant .pub-topbar exists, so every existing
   scrollIntoView({block: 'start'}) call already in this file (the Discover
   mount, a shelf header's own "collapse must land you back in the list"
   scroll, the #cat- hash deep link) is honoured against the bar's live
   height automatically, none of them individually touched. Without this,
   any of those calls aligns its target's top edge to scrollY's literal
   zero, which used to be empty space and is now the bar's own opaque box:
   proven in this wave's own testing to leave the Discover deck's close
   button roughly half covered the instant the deck opens. --topbar-h
   already tracks the bar's live height (compressed or not, see
   js/public.js), so this can never disagree with it either. */
html.pub-has-topbar { scroll-padding-top: var(--topbar-h, 44px); }

#public-root {
  max-width: 960px;
  margin: 0 auto;
  padding: var(--s-8) var(--s-6) var(--s-24);
}

.pub-header {
  /* Positioning context and stacking context for .pub-hero-bg's
     z-index: -1 (motion inventory item 9). z-index: 0 here, not just
     position: relative, is load-bearing: position: relative alone with
     z-index left at auto does not establish a new stacking context, so
     without this the drifting planes' negative z-index would escape this
     panel and stack against the page's own root context instead of just
     this header's own content, which is what actually keeps the planes
     behind the hero text and nothing else. */
  position: relative;
  z-index: 0;
  padding: var(--s-10) var(--s-10) var(--s-8);
  margin-bottom: var(--s-3);
  border-top: var(--bw-heavy) solid var(--oxblood);
  /* Fold cost (16.4): .pub-topbar below is position: fixed and therefore
     out of flow, so without this the bar would simply paint over the top
     of whatever padding-top the line above left, hiding the logo behind
     it. --topbar-fold-reserve is frozen by js/public.js's ResizeObserver
     callback at the bar's EXPANDED height, read once on mount (the page
     always starts unscrolled, so the first reading is always the expanded
     figure) and never updated again, deliberately: unlike --topbar-h below
     (which tracks the bar live, compressed or not, for the sticky shelf
     header collision), the reservation at the very top of the page must
     stay pinned to the tallest state the bar can be in while still at
     scroll position zero, or a reader who has scrolled down and back up
     would see the layout jump as this padding chased the shrinking bar.
     The static 44px fallback is what a visitor with no JavaScript function
     support at all (no ResizeObserver) sees; it is not a guess, it is the
     bar's own designed height at 0 vertical padding (see .pub-topbar
     below), so the fallback and the measured value agree exactly, and
     longhand after the shorthand above wins on the one property it
     touches without needing higher specificity. This replaces the
     padding-top the line above already set, it does not add to it: the
     PRD's own fold-cost clause is explicit that the hero's existing top
     padding is what pays for the bar, not a second budget on top of it.
     The 375x812 first-shelf budget (880px, see scripts/smoke-test.mjs)
     was measured at 859.5px before this wave with 20.5px of slack; a
     44px bar replacing this breakpoint's 24px padding-top (see the
     max-width: 767px override below) costs +20px net, landing at 879.5px,
     0.5px under the cap. That knife-edge margin is the real, reported
     number, not a rounded one: 44px, at zero vertical padding of its own,
     is the tallest the bar can be here and still fit at all, which is
     also why the compressed state below cannot shrink the bar height any
     further without the burger's own target dropping under 44px (flagged
     in this wave's own report as the trade the budget forced). */
  padding-top: var(--topbar-fold-reserve, 44px);
}
.pub-header .logo { height: 40px; width: auto; margin-bottom: var(--s-5); }

/* --- fixed, self-compressing top bar (16.4, Rocky: "this should be as a
   fixed top menu bar can compress to burger on scroll down") ---------------
   .pub-topbar is the bar itself: position: fixed at every width, so it is
   always one tap away rather than only at the top of the page (PRD section
   16 amended layout item 1's fixed-bar clause). It carries two children
   that are never both visible at once: .pub-hero-nav (the four real
   controls, unchanged nodes, just relocated out of .pub-header) and
   .pub-topbar-burger (the compressed-state disclosure trigger). Which one
   shows is pure CSS, keyed off .is-compressed (js/public.js toggles this
   with a sentinel/IntersectionObserver pair, the same pattern the shelf
   headers already use, never a scroll handler: the motion inventory's ban
   on scroll-linked effects stands) and .is-open (toggled on click, Escape
   or outside-click, see js/public.js). Zero vertical padding is load
   bearing, not a look: at exactly the 44px control height with no padding
   and no border (a box-shadow substitutes for a border so the bar's own
   chrome never adds to its layout height), the bar is the shortest it can
   be while every control still clears the site-wide 44px touch-target
   floor, which is what the .pub-header fold-cost comment above's knife-edge
   arithmetic depends on. background is solid, never an alpha value, in
   both themes (design-system tokens, not overridden here): a card
   scrolling underneath cannot show through a fully opaque fill, so this
   reads as a bar rather than floating controls over content with no pixel
   sampling required to prove it. z-index sits above the sticky shelf
   headers (z-index: 3) and everything in ordinary flow, below the toast
   (z-index: 100, BASE block) so a save confirmation is never hidden behind
   it; the Discover deck panel is mounted inline with no z-index of its own
   (see the sticky-header comment lower in this file), so it can never
   compete with the bar for stacking order regardless of scroll position. */
.pub-topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding: 0 var(--s-5);
  background: var(--paper);
  box-shadow: var(--shadow-2);
}
/* Zero-height sentinel placed just after .pub-header (js/public.js): the
   same technique the shelf headers' own is-stuck detection already uses.
   Scrolling this sentinel above the viewport is "past the hero", the
   trigger for .is-compressed. */
.pub-topbar-sentinel { height: 0; }
/* "My Stack", "FAQ", Plain English and the light/dark toggle: real nodes,
   never duplicated between the bar and the disclosure panel below, since
   the disclosure IS this same element with different CSS applied, not a
   second copy (a second copy would mean two live aria-pressed states for
   the same toggle, immediately out of sync). Expanded (not .is-compressed)
   this is simply the bar's row content; compressed-and-open it becomes the
   disclosure panel via the .is-open rule further down. */
.pub-hero-nav {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--s-1);
}
.pub-hero-nav a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-width: 44px;
  padding: 0 var(--s-2);
  /* --ink-2, not --ink-3 (Phase 16 close-out). Real pixel sampling behind
     the hero proved --ink-3 here was the binding constraint on the whole
     background treatment: it is the palette's faintest tint, already only
     about 5.4:1 on plain paper, so any texture behind it ate the margin and
     forced the planes down to invisibility. Navigation should not be the
     faintest text on the page, and this is an accessibility gain in its own
     right, independent of the planes. */
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-loose);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out);
}
.pub-hero-nav a:hover, .pub-hero-nav a:focus-visible { color: var(--oxblood); }
/* Plain English and theme toggle: only sizing and spacing are touched here,
   never colour, border or background, which stay entirely the .btn-ghost/
   .plain-toggle[aria-pressed="true"] rules already own (CLIENT block):
   those two selectors and this one share the same specificity (two
   classes), so had this rule set colour/background/border too, source
   order (this PUBLIC block sits after CLIENT) would make it win outright
   and silently erase the pressed-state highlight. Restricting this rule to
   layout properties sidesteps that entirely, regardless of cascade order. */
.pub-hero-nav .btn {
  min-height: 44px;
  min-width: 44px;
  justify-content: center;
  padding: 0 var(--s-3);
  font-size: var(--fs-12);
}
/* Plain English's own icon (js/public.js's plainToggleIcon, this page's
   only user of the class): flex: none the same way COMPONENTS' shared
   .theme-toggle-icon already is, so a squeezed row never shrinks the glyph
   itself. */
.plain-toggle-icon { flex: none; }
/* Below 768px (PRD section 16 amended: "the two toggles may render
   icon-only, with an accessible name, to fit one row"): the visible label
   collapses to the same visually-hidden technique the BASE block's own
   .visually-hidden class uses (position/clip, not display:none, so the
   accessible name and aria-pressed state both survive for assistive tech;
   duplicated rather than applying that class from JS, since neither button
   is built with viewport in mind and a static rule here needs no JS
   involvement at all). Icon plus padding alone still clears the 44px
   floor via the min-width rule above. */
@media (max-width: 767px) {
  .pub-hero-nav .plain-toggle .pub-util-label,
  .pub-hero-nav .theme-toggle span {
    position: absolute; width: 1px; height: 1px;
    margin: -1px; padding: 0; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
  .pub-hero-nav .btn { padding: 0 var(--s-2); gap: 0; }
}

/* --- compressed state and the burger disclosure (16.4) --------------------
   .pub-topbar-burger is a real <button>, never a styled <div>: aria-expanded
   and aria-controls (set in js/public.js) plus its own accessible name are
   what make this a disclosure rather than a decoration, the exact contract
   the Phase 14 coach overlay broke and this spec writes in as non-optional.
   Hidden whenever the bar is not compressed (the four items are already
   directly visible then, so a trigger for a hidden panel would be inert
   chrome); shown only under .is-compressed. Deliberately smaller than the
   site's usual 44px control floor: the 375x812 fold budget's own arithmetic
   (see the .pub-header comment above) leaves exactly 0.5px of slack once
   the EXPANDED bar is paid for at 44px, none left over for a second,
   independently-sized compressed height still large enough to read as
   genuinely shorter while also clearing 44px itself. 36px clears the WCAG
   2.5.8 minimum target size (24px) comfortably; it undershoots this site's
   own 44px convention, and that is a deliberate, reported trade rather than
   an oversight (this wave's own report flags it explicitly). No border, no
   vertical padding, same reasoning as .pub-topbar itself: the bar's total
   height is exactly this button's own height, nothing added by its chrome. */
.pub-topbar-burger {
  display: none;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: none;
  background: transparent;
  color: var(--ink-2);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease-out);
}
.pub-topbar-burger:hover, .pub-topbar-burger:focus-visible { color: var(--oxblood); }
.pub-topbar.is-compressed .pub-topbar-burger { display: inline-flex; }
/* Compressed and closed: the four controls are off, full stop, not merely
   visually hidden, since a display:none subtree also leaves the tab order
   (nothing here to reach by keyboard except the burger itself, exactly
   "reachable via the burger" as the spec asks). Compressed and open: the
   very same nodes become the disclosure panel, positioned directly off the
   bar's own box (top: 100%, so it is always exactly where the bar's
   current height ends, live, with no separate coordinate to keep in sync)
   rather than a copy positioned by JS-read geometry. flex-wrap: nowrap
   guards against the base rule's wrap ever turning four short items into
   an unwanted two-column layout in the narrower column direction; the
   panel's own background, border and shadow (never inherited from the bar,
   this is a new box) are what keep it legible over whatever the page is
   scrolled to behind it, the same "reads as a surface, not floating
   controls" requirement the bar itself carries. Never clipped by the bar's
   own overflow (.pub-topbar sets none), and it sits above ordinary page
   content by inheriting the bar's z-index: 40 stacking context, no
   separate z-index of its own needed. */
.pub-topbar.is-compressed .pub-hero-nav:not(.is-open) { display: none; }
.pub-topbar.is-compressed .pub-hero-nav.is-open {
  position: absolute;
  top: 100%;
  right: var(--s-5);
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: stretch;
  gap: var(--s-1);
  min-width: 220px;
  padding: var(--s-2);
  background: var(--paper);
  border: var(--border);
  box-shadow: var(--shadow-3);
}
/* Compress and panel-open are both instant state changes: no rule in this
   block sets a transition or animation on .pub-topbar, .pub-hero-nav or
   .pub-topbar-burger's layout, display or position, so there is nothing for
   prefers-reduced-motion to need to strip out. This is deliberately
   unconditional (true for every visitor, not gated behind the media query)
   rather than authoring a transition and then disabling it under reduced
   motion, since the spec's own wording ("the compress and the panel open
   are instant state changes") reads as a property of the feature itself,
   not a reduced-motion carve-out of a normally-animated one. The colour
   transition on the burger's hover/focus state above is a different kind
   of motion (feedback on an interaction, not a state change) and is
   already covered by the BASE block's blanket
   prefers-reduced-motion transition-duration override. */

/* Headline (Phase 16, PRD section 16 amended item 1: "the first section
   should be distinctive and say what Free Stack is"). The h1 treatment
   Phase 12.1 built for the two-word "Free Stack" wordmark is the starting
   point, not the ceiling this rule stays at: a full sentence carries the
   page's one big statement, so the clamp's ceiling is raised and the
   line-height and letter-spacing are retuned for legibility across the
   two-to-four lines this now wraps to (a 900-weight display face at
   near-zero tracking, tolerable for a short brand name, gets cramped
   reading a whole sentence). Weight and family are unchanged: still the
   heaviest cut of the display face, still the thing that reads as a
   statement rather than a caption. */
.pub-hero-headline {
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(34px, 7.2vw, 72px);
  line-height: 1.07;
  letter-spacing: -0.01em;
  max-width: 16ch;
}
/* Same design-system `p` line-height trap as .pub-hero-trust-item below:
   the bare `p` rule's 28px line-height (tuned for 18px text) survives a
   font-size-only override otherwise. */
.pub-header .subtitle { margin-top: var(--s-4); color: var(--ink-2); font-size: var(--fs-16); line-height: var(--lh-16); max-width: none; }
/* Sub-line (Phase 16): the runtime count plus the "nobody paid to be
   listed" differentiator, PRD section 16's trust signal 1 made prose. A
   touch larger than the plain .subtitle rule above and capped to a
   comfortable reading measure, since this is now a full sentence doing the
   work the old one-line strapline and the retired count paragraph used to
   split between them. */
.pub-hero-subline { font-size: var(--fs-18); line-height: var(--lh-18); max-width: 46ch; }
.pub-header .trust-line { margin-top: var(--s-2); color: var(--ink-2); font-size: var(--fs-14); max-width: none; }

/* Search, promoted to first-class (PRD section 16, "Ways-in band"): its own
   full-width row above the entry-path grid, on every viewport, not only
   below 768px (the PRD's floor, not a ceiling: there is no reason to shrink
   it back down again once there is room to spare). */
.pub-header .pub-search-row { margin-top: var(--s-3); display: flex; gap: var(--s-2); align-items: stretch; flex-wrap: wrap; }
.pub-header .pub-search { flex: 1 1 16rem; min-width: 0; }
.pub-header .pub-browse-all { flex: 0 0 auto; white-space: nowrap; }
.pub-search-row { margin-bottom: var(--s-5); }
.pub-search { width: 100%; min-width: 0; }

/* The "Recently updated" strip (Feature 3, Batch I) that used to live here
   is removed from the homepage in Phase 16 (Rocky, 2 Aug: "remove the
   recently updated and move it"): it is now the static, generated
   /changelog.html page (scripts/build-seo.mjs), linked from the footer's
   legal and practice line below, and carries its own inline <style> in the
   why-register.html/faq.html mould rather than a rule here. Nothing of
   this page keeps even a hidden trace of it. */

.pub-empty { color: var(--ink-2); padding: var(--s-10) 0; }

/* Sectioned public footer (Phase 19, PRD section 16 layout item 6, Rocky's
   11 Aug direction). The public footer no longer borrows .cli-footer (the
   CLIENT block's flex-row logo-beside-text rule): it is its own grid, four
   columns at 768px and up (brand block plus three headed groups), one
   column below it, with the bottom line always spanning full width as its
   own row. The 1280px page-height budget carries single-digit slack, so
   this replaces rather than adds to the old stacked-paragraph spacing; the
   columns are expected to buy back height against it. */
.pub-footer {
  display: grid;
  grid-template-columns: 1fr;
  row-gap: var(--s-4);
  column-gap: var(--s-5);
  margin-top: var(--s-10);
  padding: var(--s-6) 0 var(--s-2);
  background: var(--paper-2);
  border-top: var(--bw-heavy) solid var(--ink);
}
@media (min-width: 768px) {
  /* The last column carries the CTA sentence plus up to two payment lines
     (docs/PAYMENTS.md section 4), the tallest content of the four; a wider
     track wraps fewer lines there without touching type scale, which is
     what buys the 1280px page-height budget back against the columns. */
  .pub-footer { grid-template-columns: minmax(160px, 1fr) minmax(120px, 0.85fr) minmax(140px, 0.9fr) minmax(170px, 1.25fr); align-items: start; }
}
.pub-footer-brand .logo { height: 26px; width: auto; margin-bottom: var(--s-2); }
/* Same design-system bare `p` trap documented several times elsewhere in
   this block: without max-width: none, the 68ch --measure cap wraps the
   longer lines (the company identity line, the My Stack description) onto a
   second line, which is what pushed the 1280px page-height budget over in
   an earlier verifier pass. */
.pub-footer-brand p, .pub-footer-group p, .pub-footer-bottom p { max-width: none; }
.pub-cta { margin-top: var(--s-1); }
.pub-footer-heading { margin-bottom: var(--s-1); }
.pub-footer-group p + p { margin-top: var(--s-1); }
.pub-footer-links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-1); }
/* Payment links (docs/PAYMENTS.md section 4, Phase 13.1): same quiet t-meta
   treatment the good-practice block already used, adding no visible footer
   height at all while js/payments.js ships an empty url (the element is
   simply never built, see js/public.js). */
.pub-footer-payment, .pub-footer-payment-trust { margin-top: var(--s-1); }
.pub-footer-payment, .pub-footer-payment-trust, .pub-footer-company { font-size: var(--fs-12); line-height: var(--lh-12); color: var(--ink-3); }
/* Bottom line: full width beneath its own hairline rule, last in both DOM
   order and visual order at every width (the PRD's explicit stacking
   order). grid-column spans every track the four-column desktop layout
   defines; on the single-column phone layout there is only one track, so
   the property is a harmless no-op there. */
.pub-footer-bottom {
  grid-column: 1 / -1;
  border-top: var(--bw-hair) solid var(--paper-edge);
  padding-top: var(--s-1);
}

/* --- hero trust signals (Phase 12.1, PRD section 16; Phase 16 retires the
   count line into the sub-line above, leaving two) --------------------------
   The remaining two verifiable claims sit under the sub-line as a quiet
   stacked list, never as boastful metric tiles: same type scale as the
   existing .trust-line. */
.pub-hero-trust {
  margin-top: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.pub-hero-trust-item {
  margin: 0;
  color: var(--ink-2);
  font-size: var(--fs-14);
  /* design-system's bare `p` selector sets an 18px line-height (28px) that
     survives this override, the same h2 trap documented twice elsewhere in
     this block: these are <p> elements, and overriding font-size alone
     leaves a line-height tuned for text nearly a third larger again. */
  line-height: var(--lh-14);
  max-width: none;
}
/* --ink-2 since Phase 16 close-out: this line carries one of section 16's
   three mandated verifiable trust signals, so it should not be the faintest
   text on the page, and as --ink-3 it capped the hero background treatment. */
.pub-hero-trust-item.trust-line { color: var(--ink-2); }
/* The count now lives inline in the sub-line (Phase 16) as the <strong>
   itself, not a nested strong inside a dedicated trust-item paragraph (see
   js/public.js's heroSubline): the selector targets the class directly. */
.pub-hero-count { color: var(--ink); font-family: var(--font-sans); }

/* --- savings ticker (Phase 17, PRD section 16 amended layout item 1's
   savings-ticker clause; BUILD-PLAN 17.1) -----------------------------------
   Sits beneath .pub-hero-subline, above the two trust-signal lines (see
   js/public.js's header build). The visible figure counts up once on
   arrival via js/public.js's animateSavingsCeiling, a terminating rAF
   loop, never a CSS `infinite` animation: nothing in this block carries an
   `animation` or `transition` declaration, so it introduces no third
   ambient exception alongside items 8 and 9 (the exception sweep further
   down this file greps this exact block and would fail if it did), and the
   reduced-motion sweep has nothing here to disable either, since there is
   nothing running to begin with. font-variant-numeric: tabular-nums keeps
   every digit the same width while the count-up runs, so the line does not
   visibly jitter sideways as the figure grows from one digit to five. */
.pub-savings-ticker { margin-top: var(--s-3); }
/* Three facts side by side (Phase 17.2). Grid is right here, unlike the
   ceiling line this phase already had to un-flex: these are three discrete
   blocks, not one sentence broken into fragments. Three abreast from 481px
   up; the phone layout is the media query at the bottom of this section. */
.pub-savings-visible {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-2);
  max-width: none;
  align-items: stretch; /* ragged tile heights otherwise */
}
@media (min-width: 640px) { .pub-savings-visible { gap: var(--s-6); } }
/* The framing sentence spans the full row beneath the three figures, so the
   ceiling can never be read without "if you used all N tools" and without
   the realistic core figure, at any width. */
.pub-fact-detail {
  grid-column: 1 / -1;
  margin: var(--s-1) 0 0;
  max-width: none;
  color: var(--ink-2);
  font-size: var(--fs-14);
  line-height: var(--lh-14);
}
.pub-fact {
  min-width: 0;
  padding: var(--s-2);
  border: var(--border);
  border-radius: var(--radius-sm, 4px);
  background: color-mix(in srgb, var(--paper) 70%, transparent);
}
.pub-fact-figure, .pub-savings-amount {
  display: block;
  margin-top: var(--s-1);
  font-family: var(--font-display);
  font-weight: 900;
  font-size: clamp(15px, 4.6vw, 40px);
  white-space: nowrap; /* never "£1,8 / 96" */
  line-height: 1;
  color: var(--oxblood);
  font-variant-numeric: tabular-nums;
}
.pub-fact-label {
  margin: 0;
  max-width: none;
  color: var(--ink-2);
  font-size: var(--fs-14);
  line-height: var(--lh-14);
  overflow-wrap: break-word; /* "placements" overhangs a narrow column */
  hyphens: auto;
}
/* Phone layout. Below 480px three tiles cannot hold a five-character currency
   figure: at 375px the column is 95px and "£1,896" needs 106px at a legible
   size. Shrinking the type made the lead figure illegible and wrapping it
   produced "£1,8 / 96", so each fact becomes a full-width row, label left and
   figure right. The eye still reads the three in order, which was the point.

   This block must stay BELOW the base rules above. Media queries add no
   specificity, so when it sat higher in the file every declaration here lost
   to its unqualified twin and the phone layout silently did nothing.

   Vertical padding is --s-1, not --s-2: three stacked rows cost more page
   height than the row of three tiles they replace, which broke the 375px
   budget. That budget has already moved twice this phase and moving it a
   third time would make it decoration, so the height comes out of the rows
   instead. The 26px figure still sets the row height. */
@media (max-width: 480px) {
  .pub-savings-visible { grid-template-columns: 1fr; gap: var(--s-1); }
  .pub-fact {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s-3);
    padding: var(--s-1) var(--s-3);
  }
  .pub-fact-figure, .pub-savings-amount { margin-top: 0; font-size: 26px; }
}

/* The standalone .pub-savings-amount block that used to sit here was deleted
   in 17.5. It dated from 17.1, when the saving was one figure set inline in
   a sentence: `display: inline-block`, `vertical-align: baseline` and a
   right margin are all vestiges of that sentence, and there has been no
   sentence to sit in since 17.3 turned the hero into three tiles.

   It survived because it did no visible harm, but it was a duplicate of the
   `.pub-fact-figure, .pub-savings-amount` rule above at equal specificity
   and later in the file, so it won every tie. That meant the lead figure
   rendered 44px against the other two facts' 40px, and, once 17.5 added the
   phone rules, 28px against their 26px while the comment three rules up
   asserted "the 26px figure still sets the row height". Nobody chose either
   number: they are what an old rule for a different layout happened to say.
   If the lead figure should be emphasised, that wants a rule that says so
   on purpose, not a leftover winning on source order. */


/* Contrast proof for motion inventory item 9, and the reasoning behind the
   two numbers below, since both were arrived at the hard way.

   METHOD: ground truth, not arithmetic. scripts/smoke-test.mjs hides every
   hero child except this background, screenshots, decodes the PNG itself and
   reads the real composited pixels behind each rendered LINE box (via Range
   rects, not the parent element box, which is far wider than the glyphs for a
   left-aligned block), taking the worst pixel each line covers, at 10 offsets
   spanning the slowest plane's full 35s travel, in both themes at 375 and
   1280. An earlier version instead composited all four planes as if they
   fully overlapped one pixel. That bound is unreachable (their authored spans
   forbid it) and tuning opacity to satisfy it drove the planes to 0.013,
   which erased the effect entirely. The metric was wrong, not the design.

   WHY --ink-2 AND NOT --ink-3 IN THE HERO: real sampling then showed the true
   binding constraint was never the trust line at all, it was the utility nav
   links, which the planes' rotated silhouette genuinely reaches. As --ink-3
   (#6B645B, the palette's faintest tint, already only about 5.4:1 on plain
   paper) they capped this whole treatment at an invisible opacity. Raising
   the hero's nav links and trust line to --ink-2 is an accessibility gain in
   its own right, navigation and a mandated trust signal should not be the
   faintest text on the page, and it is what lets the background exist at all.

   RESULT at opacity 0.06 the worst sampled ratio was about 9.6:1, so there
   was room to spend: at the shipped 0.11 the worst real pixel behind any hero
   text is 8.57:1 (light, 1280) and 8.56:1 (dark), against a 4.5:1 floor. The
   planes are visible as deliberate texture with the margin still near double.

   The keyframes below animate transform ONLY. That is load bearing: a static
   opacity is what makes the worst case time invariant, and smoke-test.mjs
   enforces it at source level, since a runtime sample taken shortly after
   load always lands near a slow loop's minimum and would miss the breach. */

.pub-hero-bg {
  position: absolute;
  inset: 0;
  overflow: hidden;
  z-index: -1;
  pointer-events: none;
}
.pub-hero-plane {
  position: absolute;
  top: -25%;
  width: 62%;
  height: 150%;
  opacity: 0.11;
  clip-path: polygon(22% 0%, 100% 0%, 78% 100%, 0% 100%);
}
.pub-hero-plane-1 {
  left: -14%;
  background: var(--oxblood);
  transform: rotate(-6deg) translate(0%, 0%);
  animation: pub-hero-drift-1 20s ease-in-out infinite alternate;
}
.pub-hero-plane-2 {
  left: 18%;
  background: var(--paper-2);
  transform: rotate(5deg) translate(0%, 0%);
  animation: pub-hero-drift-2 21s ease-in-out infinite alternate;
  animation-delay: -9s; /* negative delay: starts partway through its own loop, so the four planes never read as synchronised */
}
.pub-hero-plane-3 {
  left: 46%;
  background: var(--oxblood-deep);
  transform: rotate(-4deg) translate(0%, 0%);
  animation: pub-hero-drift-3 20s ease-in-out infinite alternate;
  animation-delay: -14s;
}
.pub-hero-plane-4 {
  left: 70%;
  background: var(--paper-edge);
  transform: rotate(7deg) translate(0%, 0%);
  animation: pub-hero-drift-4 22s ease-in-out infinite alternate;
  animation-delay: -21s;
}
@keyframes pub-hero-drift-1 {
  from { transform: rotate(-6deg) translate(0%, 0%); }
  to   { transform: rotate(-2deg) translate(13%, 8%); }
}
@keyframes pub-hero-drift-2 {
  from { transform: rotate(5deg) translate(0%, 0%); }
  to   { transform: rotate(10deg) translate(-11%, 10%); }
}
@keyframes pub-hero-drift-3 {
  from { transform: rotate(-4deg) translate(0%, 0%); }
  to   { transform: rotate(-8deg) translate(9%, -9%); }
}
@keyframes pub-hero-drift-4 {
  from { transform: rotate(7deg) translate(0%, 0%); }
  to   { transform: rotate(3deg) translate(-12%, -7%); }
}
/* Reduced motion: a single static composed frame, not the resting (0%)
   keyframe of any single plane repeated four times, since a flat identical
   pose would read as an accident rather than the deliberate arrangement
   the clause demands. Each plane keeps its own distinct rotation and
   offset, frozen partway through its own travel. */
@media (prefers-reduced-motion: reduce) {
  .pub-hero-plane { animation: none; }
  .pub-hero-plane-1 { transform: rotate(-4deg) translate(3%, 2%); }
  .pub-hero-plane-2 { transform: rotate(7deg) translate(-3%, 3%); }
  .pub-hero-plane-3 { transform: rotate(-6deg) translate(2%, -2%); }
  .pub-hero-plane-4 { transform: rotate(5deg) translate(-3%, -2%); }
}

/* --- entry paths (Phase 12.1, PRD section 16; Phase 14.1 amends this to two
   items) -------------------------------------------------------------------
   Discover and persona packs, equal weight. The "Browse all" entry card
   (Phase 12.1) is retired: its job passes to the shelf band below plus its
   own Expand all / Collapse all toggle. One shared grid for every viewport:
   mobile stacks to a single column (Discover naturally leading, since it is
   first in source order and this grid never duplicates the markup per
   breakpoint), 768px and up lays the two out side by side. */
.pub-entry { margin-bottom: var(--s-5); }
.pub-entry-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-4);
}
@media (min-width: 768px) {
  .pub-entry-grid { grid-template-columns: repeat(2, 1fr); }
}
/* No panel chrome (padding/background/border): PRD section 16 describes the
   Discover entry as "button plus one-line pitch" and the persona entry as
   just its chips, not a padded card, and the compact landing's page-height
   budget (particularly "first shelf rows visible within the first mobile
   viewport") depends on the ways-in band actually being lean. */
.pub-entry-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
  /* A grid item's default min-width is its content's intrinsic size (the
     same trap as .card-grid > li above): without this, the persona item's
     un-wrapped chip row grows this item, its grid track and the page itself
     wide enough to fit all five chips end to end instead of clipping and
     scrolling internally. */
  min-width: 0;
}
/* Same design-system `p` line-height trap as .pub-hero-trust-item above. */
.pub-entry-pitch { max-width: none; color: var(--ink-2); font-size: var(--fs-14); line-height: var(--lh-14); }

/* --- motion: Discover button, house CTA treatment (motion inventory item 8,
   PRD section 16 amended 15.4, rewritten on Rocky's phone-test direction,
   airl.io reference) --------------------------------------------------------
   The one deliberate, recorded exception to this section's ban on looping
   and ambient motion: a three-stop brand-red gradient that drifts slowly, a
   soft ambient pulse and a periodic sheen, all infinite, all stopped for
   free whenever the ways-in band hides (display:none halts every animation
   on its subtree) and resumed when it returns.

   Theme-stable colour, deliberately not the live --oxblood/--oxblood-deep
   custom properties: those tokens INVERT under dark mode (lighten, so text
   sitting on them stays readable, see the DARK MODE block's own note further
   up this file), which is exactly wrong for this button. This is the site's
   one ambient brand CTA, modelled on an external reference with its own
   fixed identity, and its white text needs 4.5:1 against the gradient's
   lightest stop in EITHER theme, never the lightened dark-mode salmon every
   other .btn-primary gets. These three custom properties therefore pin the
   light theme's own --oxblood (#A40000) and --oxblood-deep (#7A0000) hex
   values directly, the same duplication pattern the DARK MODE block already
   uses for tokens whose per-theme value cannot follow the normal variable
   relationship (see its own "hardcoded hex" comment). The bright stop is a
   color-mix lighten off that same red, close to the airl.io reference's
   range without copying its literal hex values. Scoped to this selector, not
   :root, so it never touches the frozen APP TOKENS block. */
.pub-discover-btn {
  --pub-cta-bright: color-mix(in srgb, #A40000 88%, white 12%);
  --pub-cta-mid: #A40000;
  --pub-cta-deep: #7A0000;
  --pub-cta-gradient: linear-gradient(115deg, var(--pub-cta-bright) 0%, var(--pub-cta-mid) 45%, var(--pub-cta-deep) 100%);
  align-self: stretch;
  justify-content: center;
  position: relative;
  overflow: hidden;
  color: #fff;
  background: var(--pub-cta-gradient);
  background-size: 220% 100%;
  background-position: 0% 50%;
  animation: pub-discover-drift 5.5s ease-in-out infinite alternate;
  transition: transform var(--dur-base) var(--ease-swift), box-shadow var(--dur-base) var(--ease-swift);
}
@keyframes pub-discover-drift {
  from { background-position: 0% 50%; }
  to   { background-position: 100% 50%; }
}
/* Ambient pulse: an inset glow plus a whisper of scale on a full-cover
   ::before layer, never the real button's own box-shadow or transform. Kept
   off the real element on purpose: the real button's transform is reserved
   for the hover-lift/press-scale responses below, and its own box-shadow for
   the hover glow, so neither ever fights a running keyframe animation for
   control of the same property (a paused animation still wins a property
   over a plain declaration, so sharing was not an option). inset keeps the
   glow fully contained by the overflow:hidden above with no bleed to manage;
   any sliver that scale pushes past the edge is quietly clipped by the same
   rule, which reads as the glow breathing right at the button's own edge. */
@keyframes pub-discover-pulse {
  0%, 100% { box-shadow: inset 0 0 0 0 color-mix(in srgb, var(--pub-cta-mid) 0%, transparent); transform: scale(1); }
  50% { box-shadow: inset 0 0 26px 2px color-mix(in srgb, var(--pub-cta-mid) 55%, transparent); transform: scale(1.02); }
}
.pub-discover-btn::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  animation: pub-discover-pulse 2.8s ease-in-out infinite;
}
/* Sheen sweep, now on a timer rather than hover-triggered: a light gradient
   highlight crossing the button's face every few seconds, contained by the
   overflow:hidden above so it never escapes the button's own edges. Fixed
   white, not a design token deliberately: this is a lighting effect (glare
   crossing a glossy surface), not a brand colour, and needs to read as a
   highlight against the red fill in both themes. The rest phase (35% to
   100%) is deliberate: the sweep crosses quickly then holds off-face until
   the next loop, rather than crossing continuously. */
@keyframes pub-discover-sheen {
  0%   { transform: translateX(-140%); }
  35%  { transform: translateX(140%); }
  100% { transform: translateX(140%); }
}
.pub-discover-btn::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.35) 50%, transparent 65%);
  animation: pub-discover-sheen 3.8s ease-in-out infinite;
  pointer-events: none;
}
/* Hover/focus-visible: the pulse (::before only) pauses so it never competes
   with the hover response; the real button lifts and gains an outward
   brand-red glow, both plain declarations since nothing else ever animates
   this element's own transform or box-shadow. */
.pub-discover-btn:hover::before,
.pub-discover-btn:focus-visible::before {
  animation-play-state: paused;
}
.pub-discover-btn:hover,
.pub-discover-btn:focus-visible {
  /* Re-declared, not inherited from the base rule: .btn-primary:hover
     (COMPONENTS block) sets a flat background at the same (0,2,0)
     specificity as this selector, and a flat background would have quietly
     replaced the gradient on hover, including the theme-flipped
     --oxblood-deep in dark mode, exactly the pale, low-contrast fill this
     whole treatment exists to avoid. Re-stating the same custom property
     here, later in source at equal specificity, wins outright against the
     plain light/dark-agnostic .btn-primary:hover rule. */
  background: var(--pub-cta-gradient);
  background-size: 220% 100%;
  transform: translateY(-2px);
  box-shadow: 0 8px 30px color-mix(in srgb, var(--pub-cta-mid) 40%, transparent);
}
/* DARK MODE also carries its OWN, higher-specificity .btn-primary:hover
   override (:root[data-theme="dark"] .btn-primary:hover, (0,4,0)), the one
   this button needs the light-mode-flipped --oxblood-deep to avoid most of
   all. Matched here at equal (0,4,0) specificity, later in source, so it
   wins in turn. Not touching the DARK MODE block itself: this stays a
   PUBLIC-block rule that happens to be scoped by the theme attribute. */
:root[data-theme="dark"] .pub-discover-btn:hover,
:root[data-theme="dark"] .pub-discover-btn:focus-visible {
  background: var(--pub-cta-gradient);
  background-size: 220% 100%;
}
.pub-discover-btn:active { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
  .pub-discover-btn {
    /* Static mid-gradient face: the drift stops on a fixed mid position
       rather than the 0% starting stop, so a reduced-motion visitor still
       sees the full three-stop range at rest, never just the bright end. */
    animation: none;
    background-position: 50% 50%;
    transition: none;
  }
  .pub-discover-btn::before { animation: none; box-shadow: none; transform: none; }
  .pub-discover-btn::after { content: none; animation: none; }
  .pub-discover-btn:hover,
  .pub-discover-btn:focus-visible,
  .pub-discover-btn:active {
    /* Same re-declaration as the normal-motion hover rule above, and for
       the same reason: without it .btn-primary:hover's flat background
       (equal specificity, the theme-flipped --oxblood-deep in dark mode)
       would replace the static gradient face the moment this is hovered. */
    background: var(--pub-cta-gradient);
    background-position: 50% 50%;
    background-size: 220% 100%;
    /* Colour and shadow response only, per the inventory: no lift, no
       press, no pulse, no sheen, but a static glow still marks the
       hover/focus state. */
    transform: none;
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--pub-cta-mid) 35%, transparent);
  }
  /* Same dark-mode-specific specificity fight as the normal-motion rule
     above, a media query changes nothing about it. */
  :root[data-theme="dark"] .pub-discover-btn:hover,
  :root[data-theme="dark"] .pub-discover-btn:focus-visible,
  :root[data-theme="dark"] .pub-discover-btn:active {
    background: var(--pub-cta-gradient);
    background-position: 50% 50%;
    background-size: 220% 100%;
  }
}

/* --- motion: deck-open morph (motion inventory item 4, PRD section 16
   amended) -------------------------------------------------------------
   js/public.js toggles this one class between the Discover button (before
   the guarded transition captures its "old" state) and the mounted
   .discover-panel (inside the transition's callback, before the "new"
   state is captured), never on both elements at once: that shared name is
   what tells the browser to morph the old box into the new one rather than
   simple cross-fading them independently. The group's own duration and
   easing is set on the generated pseudo-element, not on either real
   element, since ::view-transition-group only exists for the duration of
   an active transition. The reduced-motion guard for this whole pseudo-
   element family already lives in the COMPONENTS block above (JS never
   starts a transition under reduced motion in the first place; this is the
   belt-and-braces CSS confirmation, not a second, item-specific one). */
.pub-vt-discover { view-transition-name: discover-panel; }
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-group(discover-panel) {
    animation-duration: 380ms;
    animation-timing-function: var(--ease-swift);
  }
}

.pub-persona-chip-row { display: flex; flex-wrap: wrap; gap: var(--s-2); }
/* Verifier fix round (page-height budget, PRD section 16 amended, "first
   shelf rows visible within the first mobile viewport"): below 768px, five
   packs whose names are long enough to wrap one-per-row cost roughly 220px
   of height on their own. Presentation only, behaviour identical: a single
   horizontally scrollable row, momentum touch-scrolling, chips kept at
   their full 44px height and never shrunk. The trailing mask fade is the
   "more to scroll" affordance; it only ever clips transparent padding, so
   no chip content is ever obscured, ellipsis-truncated or unreadable. */
@media (max-width: 767px) {
  .pub-persona-chip-row {
    flex-wrap: nowrap;
    align-self: stretch;
    width: 100%;
    /* A flex item's default min-width is its content's intrinsic size, not
       0: without this, the un-wrapped row of chips grows the row (and every
       ancestor up to the page itself) to fit all five end to end instead of
       clipping and scrolling internally, the same trap documented on
       .card-grid > li and .cur-toolbar .input elsewhere in this file. */
    min-width: 0;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scroll-snap-type: x proximity;
    padding-bottom: var(--s-1);
    mask-image: linear-gradient(to right, black calc(100% - 28px), transparent 100%);
    -webkit-mask-image: linear-gradient(to right, black calc(100% - 28px), transparent 100%);
  }
  .pub-persona-chip-row .pub-persona-chip { flex: none; scroll-snap-align: start; }
}
.pub-persona-chip {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 8px 16px;
  border: var(--bw-hair) solid var(--steel-3);
  border-radius: var(--r-pill);
  background: var(--paper);
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-loose);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.pub-persona-chip:hover { border-color: var(--ink-3); color: var(--ink); }
.pub-persona-chip.is-active {
  background: var(--oxblood-tint);
  border-color: var(--oxblood);
  color: var(--oxblood-deep);
}

/* --- category shelves (Phase 14.1, PRD section 16 amended, "compact
   landing", "Category shelves" and "Shelf mechanics") ------------------
   One <section> per category, a single 44px header row when collapsed.
   Collapse is CSS only: js/public.js toggles the grid's `hidden` IDL
   property, which the BASE block's `[hidden] { display: none !important; }`
   rule already turns into the closed state, so no dedicated display rule is
   needed here at all. Row height is pinned to exactly the button's own
   min-height (no extra vertical padding on the header or the section), so
   15 collapsed shelves stay well inside the page-height budget. */
/* No bottom margin (Rocky's 11 Aug "Why this exists" wave): the gap this
   used to buy before the FAQ section now belongs to .pub-why, the section
   that sits immediately after this one instead. The 1280px page-height
   budget carries only about 14px of slack over the pre-existing content, far
   less than a second full margin plus a 44px disclosure row would cost, so
   the two sections share one gap rather than stacking two. */
/* Phase 19 zeroed this margin to pay for the sectioned footer, safe because
   the why-section below the band provided the separation from the FAQ.
   Phase 21 then moved the why-section ABOVE the band, so the band sat flush
   against the FAQ heading (Rocky's 14 Aug screenshot). Restored at s-8
   inside the reconciled 1280 budget's 59px slack (Phase 23). */
.pub-shelf-band { margin-bottom: var(--s-8); }
/* Phase 16 retires the second row this header used to carry (Plain English
   and the theme toggle, moved into the hero utility bar above): title and
   Expand all are the whole header again, the shape it had before the
   verifier fix round that first split it into two rows. */
.pub-shelf-band-header {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  margin-bottom: var(--s-3);
}
.pub-shelf-band-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--s-4);
}
.pub-shelf-band-title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--fs-21);
  /* Same design-system h2 trap the shelf-heading fix above documents: the
     bare `h2` rule's line-height is a fixed 50px tuned for its 44px font
     size, and overriding font-size alone here leaves that 50px line-height
     behind, taller than a 21px line needs by nearly double. */
  line-height: var(--lh-21);
  letter-spacing: var(--tr-tight);
  color: var(--ink);
}
.pub-shelf-match-count { margin: 0 0 var(--s-4); color: var(--ink-2); font-size: var(--fs-14); line-height: var(--lh-14); }

.pub-shelves { display: flex; flex-direction: column; gap: var(--s-2); }
.pub-shelf { background: var(--paper); border: var(--border); }
/* design-system/colors_and_type.css sets a bare `h2` selector to 44px with a
   tall line-height (its own heading scale); this wraps a 44px-minimum
   *button*, not a page heading, and BASE's `button { font: inherit; }` would
   otherwise pull that whole 44px/line-height pair down onto the button,
   nearly doubling every collapsed shelf row's height. Reset here, in the
   PUBLIC block, rather than touching the frozen design-system file. */
/* display: contents (Phase 15.4): a sticky element is clamped to stay
   within its own containing block, normally its DOM parent's box. Before
   this, that parent was this h2, auto-height and therefore exactly as tall
   as the button it wraps, leaving the header nowhere to visually "stick"
   once scrolled even a few pixels into a shelf: measured on the built page,
   position: sticky computed correctly yet the header still scrolled away
   almost immediately. display: contents removes the h2's own box without
   removing the h2 element or its heading semantics (supported in every
   evergreen engine this project targets), so the header's containing block
   becomes .pub-shelf itself, the full header-plus-grid section, which is
   exactly the range the header needs to stay pinned across. */
.pub-shelf-heading { display: contents; margin: 0; font-size: var(--fs-16); line-height: 1; letter-spacing: normal; }
.pub-shelf-header {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  min-height: 44px;
  padding: 0 var(--s-5);
  background: transparent;
  border: none;
  cursor: pointer;
  text-align: left;
  font-family: var(--font-sans);
  color: var(--ink);
  transition: background var(--dur-fast) var(--ease-out);
}
/* Sticky shelf headers (Phase 15.4, section 16 amended shelf mechanics):
   an open shelf's header pins to the viewport top at every width, so
   collapsing an eleven-tool shelf never needs a scroll back up. Keyed
   purely off the aria-expanded state js/public.js's setShelfOpen() already
   writes, so a collapsed header (aria-expanded="false") never sticks. This
   is positioning, not motion: it adds nothing to the motion inventory.
   background is set explicitly (matching .pub-shelf's own panel background
   below) rather than relying on the ancestor showing through, since once
   pinned the header no longer shares its ancestor's painted box: cards
   scrolling underneath would otherwise show through the gap. Placed before
   the :hover rule below so hover's own background still wins on hover,
   same specificity, source order. z-index sits above the shelf's cards
   (which form no stacking context of their own) and below the bar (40)
   and the toast (100); the deck panel is mounted inline, never overlapping
   a shelf spatially, so needs no explicit ordering here.
   16.4, the collision: a fixed .pub-topbar sits in this exact top-of-
   viewport spot too, and without this, top: 0 would slide the header
   (and the collapse control it carries) under the bar the instant it
   pinned. top: var(--topbar-h) is the one property this shares with the
   bar's own live-measured height (js/public.js's ResizeObserver, same
   value, never a second hand-rolled number that could drift out of sync):
   the header docks against whatever the bar's CURRENT rendered height is,
   compressed or not, so the two can never disagree. The 44px fallback
   matches the bar's own no-JS fallback for the same reason given there. */
.pub-shelf-header[aria-expanded="true"] {
  position: sticky;
  top: var(--topbar-h, 44px);
  z-index: 3;
  background: var(--paper);
}
/* is-stuck: set by a small, passive IntersectionObserver in js/public.js
   watching a zero-height sentinel placed immediately before the header (the
   standard sticky-detection pattern; CSS alone cannot yet select an
   element's own "currently pinned" state). Gated by aria-expanded so a
   collapsed shelf, whose sentinel the observer still quietly tracks, never
   shows the shadow. Shadow only, no colour or size change, so it reads as
   separation from the cards beneath, not emphasis. */
.pub-shelf-header[aria-expanded="true"].is-stuck { box-shadow: var(--shadow-2); }
.pub-shelf-sentinel { height: 0; }
/* Close hint (Phase 15.5, PRD section 16 amended, "Collapse must land you
   back in the list"): a plain-language affordance next to the chevron,
   shown only while the header is genuinely stuck, so a reader scrolled deep
   into a shelf sees an explicit "tap to close" cue rather than relying on
   the chevron's implied meaning alone. flex: none, so it never grows or
   wraps; the row's own only flexible item is .pub-shelf-name below (the
   scent line this comment used to reference was retired in Phase 15.6), so
   the category name is what shrinks and ellipsizes as this hint claims its
   own space, never the hint or the chevron. Same eyebrow-label styling used
   throughout the CLIENT block (uppercase, --tr-eyebrow), already legible in
   both themes via --ink-3, so no per-theme override is needed. */
.pub-shelf-close-hint {
  display: none;
  flex: none;
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
}
.pub-shelf-header[aria-expanded="true"].is-stuck .pub-shelf-close-hint { display: inline; }
.pub-shelf-header:hover { background: var(--paper-2); }
.pub-shelf-header .cli-category-icon { flex: none; color: var(--oxblood); }
/* Phase 15.6 (PRD section 16 amended layout item 4, Rocky's mobile finesse
   pass): the muted scent line of tool names is retired outright (truncated
   to two or three words at 375px it read as noise, not scent; search still
   finds every tool name regardless), so .pub-shelf-name is now the row's
   one flexible, truncating item, min-width: 0 required for a flex child to
   actually shrink rather than pushing the row wider (the same trap the
   card-grid comment above documents for grid tracks). Count, the Close
   hint and the chevron stay flex: none, fixed width, never wrapping: the
   category name is the only thing that ever truncates, and only as a last
   resort when the row is genuinely tight (mainly a long name plus the
   Close hint sharing 375px at once). */
.pub-shelf-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 500;
  font-size: var(--fs-16);
}
.pub-shelf-count { flex: none; color: var(--ink-3); font-size: var(--fs-14); }
.pub-shelf-chevron { flex: none; color: var(--ink-3); }
/* A static state flip keyed off aria-expanded, not an animation: this wave
   introduces no new animation styles, the shelf-open stagger itself is wave
   14.2's (PRD section 16 motion inventory item 2). */
.pub-shelf-header[aria-expanded="true"] .pub-shelf-chevron { transform: rotate(180deg); }

.pub-shelf-grid { padding: 0 var(--s-5) var(--s-5); }

/* Verifier fix round: the CLIENT block gives every .tool-card an
   unconditional "card-in" entrance (Phase 4, unedited, gated only on
   prefers-reduced-motion: no-preference). Toggling a shelf grid's `hidden`
   property off is a display:none-to-block transition, which re-triggers a
   CSS animation on every card inside, every time a shelf opens (including
   all 15 at once on Expand all): an unlisted motion against the amended
   section 16's exhaustive inventory, and the old 12.1 values rather than
   wave 14.2's forthcoming shelf-open stagger. This is a subtraction, scoped
   to cards inside a public shelf only, so client mode's own entrance
   (#client-root) is untouched: this selector cannot match there, .pub-shelf
   only ever exists inside #public-root.

   Wave 14.2: this subtraction stays exactly as is, per the phase brief
   ("the suppression of the old card-in stays"). The new shelf-open stagger
   (motion inventory item 2, see .pub-shelf-stagger below) is a fully
   separate mechanism, applied to the <li> wrapper rather than .tool-card,
   under its own JS-chosen, transient class: the two do not conflict, since
   this rule only ever silences the OLD, always-on @keyframes animation. */
.pub-shelf .tool-card { animation: none; }

/* --- motion: reveal (motion inventory item 1, PRD section 16) -------------
   One class pair, chosen in js/public.js before either is ever applied: the
   transform-bearing one for a normal-motion visitor, the opacity-only one
   for prefers-reduced-motion. Scoped since Phase 14.1 to the hero and the
   two ways-in entry items only: the per-category card reveal Phase 12.1
   built here is retired along with the flat list it revealed (see the
   js/public.js file banner). The media query below is a second, CSS-only
   guard on top of that JS choice, per the phase brief: if the transform
   class ever landed on a reduced-motion visitor by mistake, this still
   strips the transform out of the transition entirely, not just its
   duration (the COMPONENTS block's blanket *,*::before,*::after rule above
   only shortens durations, it does not remove transform from the property
   list, so a dedicated override here is needed for the two classes below to
   ever measure as opacity-only). */
/* Wave 14.2 recalibration: translateY(18px), 480ms, --ease-swift, per the
   amended spec's exact item 1 values (previously translateY(10px)/360ms/
   --ease-out from Phase 12.1, imperceptible per Rocky's 29 Jul direction). */
.pub-reveal {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 480ms var(--ease-swift), transform 480ms var(--ease-swift);
}
.pub-reveal.is-in { opacity: 1; transform: translateY(0); }
.pub-reveal-reduced {
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out);
}
.pub-reveal-reduced.is-in { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .pub-reveal { transition: opacity var(--dur-fast) var(--ease-out); transform: none; }
  .pub-reveal.is-in { transform: none; }
}

/* --- motion: shelf-expansion stagger (motion inventory item 2, "the
   showpiece", PRD section 16 amended) ---------------------------------
   js/public.js's staggerShelfCards applies one of these two classes (never
   both) to the first six <li> cards of a shelf grid the instant it opens,
   the same JS-chooses/CSS-confirms split as .pub-reveal above. Repeatable,
   unlike .pub-reveal: cleanup after the transition removes the class again,
   so the same shelf staggers again on its next open, not only its first. */
.pub-shelf-stagger {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 300ms var(--ease-swift), transform 300ms var(--ease-swift);
}
.pub-shelf-stagger.is-in { opacity: 1; transform: translateY(0); }
.pub-shelf-stagger-reduced {
  opacity: 0;
  transition: opacity 120ms var(--ease-swift);
}
.pub-shelf-stagger-reduced.is-in { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .pub-shelf-stagger { transition: opacity 120ms var(--ease-swift); transform: none; }
  .pub-shelf-stagger.is-in { transform: none; }
}

/* --- motion: hover lift and focus (motion inventory item 7, PRD section 16
   amended) -----------------------------------------------------------
   Scoped to #public-root rather than editing the CLIENT block's .tool-card
   rule above (owned by Phase 4): additive/overriding rules only, layered on
   top rather than touching that file. The lift itself is set on the
   card-grid's <li> wrapper, not on .tool-card: the CLIENT block's own
   entrance keyframes (card-in, "both" fill mode) never release their hold
   on .tool-card's transform property once they finish, which silently
   defeats any later hover-triggered transition on that same element and
   property (verified in isolation: identical rules on a plain element
   transition correctly, the same rules on an element also carrying a "both"
   fill-mode animation on transform do not). The <li> carries no such
   animation and, with no padding or border of its own, occupies exactly the
   same box as the card inside it, so the lift reads identically to the
   reader while sidestepping the conflict.

   The border-colour half targets .tool-card itself (that is where the
   border lives), overriding the CLIENT block's own hover rule (which shifts
   to --ink-3) via an ID-anchored selector rather than editing that file,
   the same technique this block already used before this wave.

   Wave 14.2 recalibration to the amended spec's exact values: translateY
   was -2px with a box-shadow swap (Phase 12.1); it is now -3px with a
   border-colour shift to oxblood, 180ms, --ease-swift, no shadow term at
   all (the inventory names only the two effects below). Reduced motion:
   "colour change only, no translate", so the border shift is deliberately
   NOT dropped under reduced motion, only the translate is.

   Site-wide :focus-visible (2px oxblood outline, 2px offset, zero
   duration) is the BASE block's existing `:is(a, button, input, select,
   [tabindex]):focus-visible` rule: outline carries no transition anywhere
   in this file, so focus never lags, satisfying this item's other half
   with no new rule needed here. */
@media (hover: hover) {
  #public-root .card-grid > li {
    transition: transform 180ms var(--ease-swift);
  }
  #public-root .card-grid > li:hover { transform: translateY(-3px); }
  #public-root .tool-card { transition: border-color 180ms var(--ease-swift); }
  #public-root .card-grid > li:hover .tool-card { border-color: var(--oxblood); }
}
@media (hover: hover) and (prefers-reduced-motion: reduce) {
  #public-root .card-grid > li:hover { transform: none; }
  #public-root .card-grid > li:hover .tool-card { border-color: var(--oxblood); }
}

/* --- judgement parity (Phase 12.3, PRD section 16, "Grid quick-judge and
   list parity") -------------------------------------------------------
   The state chip/chooser and the quick-judge rail both sit in normal flow,
   appended after the CLIENT block's own .tool-card inside the same <li>
   (js/public.js does this at the DOM level rather than editing that file).

   Re-verify round 2: an earlier shape absolutely positioned the rail over
   the card, its vertical offset read in JS from .card-top's rendered
   height. That measurement raced the grid's own layout on fresh load (a
   wrapped two-line title had not reached its settled height yet when read)
   and never recomputed on viewport resize, so the overlay landed back on
   top of the header it was meant to clear. This shape carries no position,
   so there is nothing to measure or recompute: the rail is a plain
   block-level sibling of .tool-card, sized only by its own two buttons, so
   it can never occupy the same pixels as the card's own name, favicon or
   value by construction, at any width, wrap state or load timing.

   No new transition or animation either: the rail's box is always present
   (reserved) the instant the media query matches, dimmed at rest, and only
   its opacity swaps on hover/focus-within, a plain value change with no
   transition property. The PRD section 16 motion inventory stays
   exhaustive, and no layout shift ever accompanies a hover, since the space
   was already reserved from the moment the card was decorated. */
.pub-judge-rail { display: none; }
@media (hover: hover) and (pointer: fine) {
  .pub-judge-rail {
    display: flex;
    justify-content: flex-end;
    gap: var(--s-2);
    margin-top: var(--s-3);
    opacity: 0.45;
  }
  .card-grid > li:hover .pub-judge-rail,
  .card-grid > li:focus-within .pub-judge-rail {
    opacity: 1;
  }
}
.pub-judge-rail-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  border-radius: var(--r-pill);
  border: var(--bw-hair) solid var(--steel-3);
  background: var(--paper);
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-16);
  font-weight: 700;
  cursor: pointer;
}
.pub-judge-rail-btn:hover { border-color: var(--ink-3); color: var(--ink); }
.pub-judge-rail-btn[aria-pressed="true"] {
  background: var(--oxblood-tint);
  border-color: var(--oxblood);
  color: var(--oxblood-deep);
}

.pub-judge-chip-wrap:empty { display: none; }
.pub-judge-chip-wrap {
  margin-top: var(--s-3);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
}
.pub-judge-chip {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 8px 16px;
  border: var(--bw-hair) solid var(--oxblood);
  border-radius: var(--r-pill);
  background: var(--oxblood-tint);
  color: var(--oxblood-deep);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  font-weight: 500;
  letter-spacing: var(--tr-loose);
  cursor: pointer;
}
.pub-judge-chip:hover { background: var(--oxblood); color: var(--paper); }

/* --- motion: judged-chip pop (motion inventory item 5, PRD section 16
   amended) -------------------------------------------------------------
   js/public.js only ever adds .is-new to a chip rendering a decision the
   reader just made this instant (discover.js's wasFreshlyDecided, consumed
   once), never one merely re-rendered from stored state. --ease-spring is
   reserved for small elements only, per the inventory's own note; a pill
   chip comfortably qualifies. No fill-mode: the keyframe's own 100% state
   (scale(1), opacity 1) already equals the chip's unstyled resting style,
   so there is nothing to hold onto once the animation ends. */
@keyframes pub-judge-chip-pop {
  from { opacity: 0; transform: scale(0.85); }
  to { opacity: 1; transform: scale(1); }
}
.pub-judge-chip.is-new {
  transform-origin: left;
  animation: pub-judge-chip-pop 220ms var(--ease-spring);
}
@media (prefers-reduced-motion: reduce) {
  .pub-judge-chip.is-new { animation: none; }
}
.pub-judge-chooser {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}
.pub-judge-chooser .btn { min-height: 44px; padding: 10px 14px; justify-content: center; }

/* The footer's quiet links are the directory's only route into My Stack and
   the awareness page, so on touch widths they must be genuinely tappable,
   not 17px slivers of text (Rocky's 26 Jul phone test: taps on them failed).
   Inline-block plus vertical padding lifts each to the 44px floor without
   changing how the sentence reads. */
@media (max-width: 768px) {
  /* The negative margin cancels the padding on all four sides, not just the
     vertical pair. With `margin: -4px 0` the 4px of horizontal padding was
     real layout width, so every sentence rendered a visible gap before its
     full stop: "Kaipability Ltd ." The tap area is unchanged, since only
     the box's effect on surrounding text is pulled back. */
  .pub-footer a { display: inline-block; padding: 12px 6px; margin: -12px -6px; min-height: 44px; box-sizing: border-box; }
}
/* Phase 19 retires the 600px flex-column override this comment used to
   document: .pub-footer's own grid-template-columns (1fr below 768px, four
   tracks at and above it, declared with .pub-footer above) now IS the
   stacking rule, at the width the PRD's brand-block-first, bottom-line-last
   clause actually names, so there is no separate breakpoint left to keep in
   step with it. */

/* Homepage FAQ slot (PRD section 16 item 5, filled in wave 14.3b): ten
   native <details>/<summary> items, one per site Q&A, quiet editorial
   hairline rules, list-style marker on the summary, no panel chrome around
   the whole section since it sits directly on the page like the shelf
   band. All colour comes from the existing --ink/--paper-edge tokens, so
   dark mode is automatic, the same as every other PUBLIC rule.
   No motion here at all, deliberately: details/summary toggles instantly by
   native browser behaviour, so there is nothing for the reduced-motion query
   to guard and nothing added to the motion inventory. */
.pub-faq { margin-bottom: var(--s-10); }
/* Same design-system h2 line-height trap .pub-shelf-band-title's own comment
   documents: fs-21/lh-21 (not the bare h2 default of 44px/50px) keeps this
   heading in proportion with the shelf band's own section title just above
   it, and out of the page-height budget the collapsed FAQ items below are
   already careful with. */
.pub-faq-heading {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--fs-21);
  line-height: var(--lh-21);
  letter-spacing: var(--tr-tight);
  margin-bottom: var(--s-3);
}
/* One column below 768px (matches .pub-entry-grid's own breakpoint), two
   columns from 768px up: ten 44px-floor rows would otherwise cost roughly
   480px of collapsed height on every viewport including the ones with the
   least budget to spare (the 1280px page-height budget is tighter than
   375px's, 2200px against 3200px, since the two-column card grid already
   packs more per screenful there). Halving the row count at the width
   where the budget is tightest is what keeps the FAQ slot inside it without
   shrinking any individual summary below the 44px floor. */
.pub-faq-list { display: flex; flex-direction: column; }
@media (min-width: 768px) {
  .pub-faq-list { display: grid; grid-template-columns: repeat(2, 1fr); column-gap: var(--s-8); }
}
.pub-faq-item { border-top: var(--bw-hair) solid var(--paper-edge); }
.pub-faq-item:last-child { border-bottom: var(--bw-hair) solid var(--paper-edge); }
@media (min-width: 768px) {
  /* In the two-column layout the true DOM :last-child (item 10) only closes
     the second column; the fifth item is the visual foot of the first
     column and needs the same closing rule, or that column reads as
     unterminated. nth-child(5) is safe against the site's own data (the ten
     canonical Q&As are a fixed, PRD-quoted list, never fewer than ten). */
  .pub-faq-item:nth-child(5) { border-bottom: var(--bw-hair) solid var(--paper-edge); }
}
/* 44px minimum touch target (WCAG 2.5.5 / this site's own floor throughout),
   pinned the same way the shelf header's own comment describes ("no extra
   vertical padding on the header... so 15 collapsed shelves stay well inside
   the page-height budget"): border-box folds the padding into the 44px
   floor itself rather than stacking on top of it, and the item wrapper
   above carries no padding of its own, only the hairline divider. The
   answer paragraph is not measured here at all: a closed <details> renders
   only its <summary>, per native HTML behaviour, so it costs nothing
   against the collapsed page height regardless of how long the answer is.
   list-style-position outside keeps the marker from eating into the text
   measure the way the default "inside" position would. */
.pub-faq-summary {
  cursor: pointer;
  list-style-position: outside;
  box-sizing: border-box;
  min-height: 44px;
  display: list-item;
  padding: 10px 0;
  font-family: var(--font-sans);
  font-weight: 500;
}
.pub-faq-answer { margin-top: var(--s-2); color: var(--ink-2); }

/* "Why this exists" disclosure (PRD section 16 amended, Rocky's 11 Aug
   direction): a single collapsed <details>/<summary>, between the shelf band
   and the FAQ block above, in the FAQ items' own visual language (quiet
   hairline rules, list-style marker on the summary, no panel chrome) so it
   reads as belonging rather than as a new component. Collapsed cost is one
   summary row: a closed <details> renders only its <summary> per native HTML
   behaviour, the same free ride .pub-faq-summary already relies on, so this
   section costs the page-height budget almost nothing at rest. No motion
   here either, for the same reason as the FAQ items: native details/summary
   toggles instantly, so there is nothing for the reduced-motion sweep to
   guard and nothing added to the motion inventory.
   No margin of its own, top or bottom: the 1280px budget's roughly 14px of
   slack cannot absorb a second full section margin on top of a 44px
   disclosure row (see .pub-shelf-band's own comment, the section directly
   above this one, for where that margin went instead). The hairline
   border-top and border-bottom on .pub-why-item below still separate it
   visually from the shelf band above and the FAQ heading below, the same
   flush-with-hairline-divider treatment adjoining .pub-faq-item rows already
   use against each other. */
.pub-why { margin-bottom: 0; }
.pub-why-item {
  border-top: var(--bw-hair) solid var(--paper-edge);
  border-bottom: var(--bw-hair) solid var(--paper-edge);
}
/* Byte-identical box model to .pub-faq-summary above (same 44px floor,
   box-sizing discipline and 10px vertical padding): with the body's own
   fs-16/lh-16 line box, 24px of line-height plus 20px of padding lands
   exactly on the 44px min-height, never over it. A larger, heading-styled
   summary (fs-21/lh-21, tried first) pushed this row to 50px, which alone
   blew the 1280px budget's roughly 14px of slack; see .pub-shelf-band's own
   comment for where the margin that used to sit here went instead. */
.pub-why-summary {
  cursor: pointer;
  list-style-position: outside;
  box-sizing: border-box;
  min-height: 44px;
  display: list-item;
  padding: 10px 0;
  font-family: var(--font-sans);
  font-weight: 500;
}
.pub-why-body { max-width: var(--measure); padding-bottom: var(--s-6); }
.pub-why-answer { margin-top: var(--s-3); color: var(--ink-2); }
.pub-why-answer:first-child { margin-top: var(--s-2); }

/* Embed mode (Feature 2, Batch I / BUILD-PLAN 10.11): chrome-free, sized to
   sit inside an iframe rather than assume a full browser viewport. */
#embed-root {
  max-width: 640px;
  margin: 0 auto;
  padding: var(--s-6) var(--s-5) var(--s-8);
}
.embed-footer {
  margin-top: var(--s-8);
  padding-top: var(--s-4);
  border-top: var(--border);
  font-size: var(--fs-12);
  color: var(--ink-3);
  text-align: center;
}
.embed-footer a { color: inherit; }

/* Judgement explainer dialog (PRD section 16 amended, Phase 21,
   "First-judgement explainer"): the first genuine modal on the public
   surface, role="dialog" and aria-modal per the 16.4 contract, built by
   js/judge-coach.js. Every other panel in this block is either inline
   (the discover mount) or a plain outside-click popover (the judge
   chooser), so this needed a new idiom rather than reuse: a fixed backdrop
   plus a centred card in the same visual family as DISCOVER's own
   .discover-coach below (--paper-2 backing, --shadow-2, an oxblood rule),
   never a new colour. Instant appearance throughout: no entrance
   transition anywhere here, so the exhaustive motion inventory (PRD
   section 16) gains nothing from this dialog. */
.pub-judgecoach-backdrop {
  position: fixed;
  inset: 0;
  /* Above the fixed topbar (z-index: 40) and the sticky shelf headers
     (z-index: 3), below the toast (z-index: 100, BASE block): a save
     confirmation firing while this dialog happens to be open must still
     read on top of it, the same ordering the topbar's own comment states. */
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-4);
  background: color-mix(in srgb, var(--ink) 55%, transparent);
}
.pub-judgecoach {
  max-width: 440px;
  width: 100%;
  padding: var(--s-6);
  background: var(--paper-2);
  border: var(--border);
  border-top: var(--bw-heavy) solid var(--oxblood);
  border-radius: var(--r-3);
  box-shadow: var(--shadow-2);
}
.pub-judgecoach h2 { margin: 0 0 var(--s-3); font-size: var(--fs-21); line-height: var(--lh-21); }
.pub-judgecoach p { margin: 0 0 var(--s-3); color: var(--ink-2); }
.pub-judgecoach p:last-of-type { margin-bottom: 0; }
.pub-judgecoach-actions {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  flex-wrap: wrap;
  margin-top: var(--s-4);
}
.pub-judgecoach-actions .btn { min-height: 44px; }
.pub-judgecoach-actions a { min-height: 44px; display: inline-flex; align-items: center; }

/* === DISCOVER ================================================================
   Wave 12.2, js/discover.js, PRD section 17. The inline panel public.js
   mounts on demand above the browse list, never a modal. Every colour comes
   from the shared custom properties, so dark mode (the :root[data-theme]
   overrides in DARK MODE above) applies with no extra rules here. */

/* Wave 14.2: overflow-anchor: none opts this whole subtree out of the
   browser's scroll anchoring heuristic. The deck swaps its card (a
   different height each time) on every judgement, right at the scroll
   position js/public.js's deck-open morph just settled on; without this,
   scroll anchoring reads that swap as content changing near the anchor
   point and silently adjusts scrollY to compensate, which is exactly the
   sort of scroll-linked effect the motion inventory bans and, measured
   during this wave's build, could carry the freshly dealt card off-screen
   the instant a judgement landed. */
.discover-mount { margin-bottom: var(--s-8); overflow-anchor: none; }

.discover-panel {
  background: var(--paper-2);
  border: var(--border);
  border-top: var(--bw-heavy) solid var(--oxblood);
  padding: var(--s-6);
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  /* Never taller than one screenful. Without this a long card (a long
     description plus a long free_limit line) can push the panel taller
     than the viewport, carrying the control row (Got it / Skip / Add to
     my list) below the fold. Interacting with an off-screen button then
     triggers the browser's own "scroll the newly focused element into
     view" correction, an uncontrolled jump this app never asked for and
     cannot see coming, which is what a phone tester reads as an error: the
     deck appears to leap away and reveal unrelated page content beneath
     it. dvh (falls back to vh on older engines, this rule simply loses to
     the later one when unsupported) tracks the real, dynamic viewport
     height on mobile Chrome, where the address bar shows and hides. */
  max-height: 100vh;
  max-height: 100dvh;
}
.discover-panel:focus-visible { outline: 2px solid var(--oxblood); outline-offset: 2px; }

.discover-panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
}
/* Phase 15.6, Rocky's mobile pass ("could not see the counter at 375px"):
   the previous fs-14 / --ink-2 pairing was smaller than body text and a
   muted secondary colour, easy to read as chrome rather than content next
   to the close button's own 44px target. Bumped to fs-18 (body, --fs-16,
   or larger) and full-contrast --ink, the same pairing the rest of the app
   relies on for primary text against --paper/--paper-2 (already measured
   to clear 4.5:1 in both themes, see the DARK MODE block's own contrast
   note). Left exactly where it was, in .discover-panel-head above the
   card: PRD 17 amended says "plainly visible alongside the card", not
   moved onto or over it, so this is a size and colour fix only, never a
   position change, and cannot overlap the stamps or the card title, which
   live inside .discover-stage below this row entirely. */
.discover-progress {
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-18);
  font-weight: 600;
  color: var(--ink);
}
.discover-close {
  min-height: 44px;
  min-width: 44px;
  justify-content: center;
  padding: 8px;
  font-size: var(--fs-21);
  line-height: 1;
}

/* flex: 1 1 260px, not a bare min-height: this is the one part of the
   panel allowed to shrink when the panel's own max-height (above) runs
   out of room, so the card's content scrolls internally instead of the
   controls below it being pushed off-screen. min-height: 0 overrides the
   flex default of min-height: auto, which would otherwise refuse to
   shrink the stage below the card's intrinsic content height and defeat
   this entirely. align-items on this row-direction flex container is
   still the default stretch, so the card itself always fills exactly
   whatever height the stage resolves to. */
.discover-stage {
  position: relative;
  display: flex;
  justify-content: center;
  flex: 1 1 260px;
  min-height: 0;
}

/* The card itself: touch-action: pan-y is the gesture contract's other
   half (js/discover.js never calls preventDefault until it has claimed a
   horizontal drag), transform-origin bottom centre matches the "roughly 1
   degree per 20px" rotation the module applies live in pointermove.
   overflow-y: auto is the "scrollable card body" half of the panel-height
   fix above: stretched by the stage (see above) to a bounded height, a
   card whose own content (a long description, a long free_limit line)
   would otherwise need more room than that scrolls internally rather than
   growing the card, and the panel with it, past one screenful. touch-action
   and overflow-y coexist normally here, the same as any ordinary
   scrollable element: a vertical drag pans the card's own content first,
   then chains to the page once the card has nothing further to scroll. */
.discover-card {
  position: relative;
  width: 100%;
  max-width: 420px;
  background: var(--paper);
  border: var(--border);
  border-radius: var(--r-3);
  box-shadow: var(--shadow-2);
  padding: var(--s-6);
  touch-action: pan-y;
  transform-origin: bottom center;
  user-select: none;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.discover-card-dragging { transition: none; }
.discover-card-springback { transition: transform var(--dur-base) var(--ease-out); }
.discover-card-exit { transition: transform 220ms var(--ease-in), opacity 220ms var(--ease-in); }
.discover-card-exit-reduced { transition: opacity 150ms var(--ease-out); }
.discover-card-enter-reduced { transition: opacity var(--dur-base) var(--ease-out); }

/* Verdict stamps: opacity only, proportional to drag distance, set inline
   by js/discover.js. Never touched by rotation transforms of their own
   beyond the fixed static tilt below, so reduced motion needs no override
   here at all.

   Rocky's phone-test finding (production, dark mode, mid-drag left): the
   stamp was text and a border with no backing at all, so a green "GOT IT"
   landed directly on top of the card's own white title text and both
   became illegible together. Two independent fixes, not one:

   1. A solid, token-based backing (--paper-2, shared with .discover-coach-
      stamp below so the two visuals never drift apart) with a shadow, so
      the stamp reads as a sticker sitting above the card rather than bare
      coloured text fighting whatever is directly behind it. --paper-2 is a
      solid hex colour in both themes (never rgba), so its computed
      background-color always carries alpha 1; the existing drag-
      proportional opacity still applies to the whole block (backing
      included), which is fine, since the one hard requirement is that the
      composite is fully opaque and legible at the commit threshold, where
      opacity reaches 1 regardless of how translucent it was earlier in
      the drag.
   2. Moved from pinned to the card's top edge (where the h3/category row
      always lives) to vertically centred on the card instead: top: 50%
      plus a translateY(-50%) folded into the same transform that already
      carries the rotation. .discover-stage stretches every card to the
      same resolved height regardless of its own content length (see the
      align-items: stretch note above), so this clears the title row on
      the shortest card exactly as reliably as on the longest one, rather
      than depending on a fixed pixel offset that only worked for cards
      whose title happened to be short enough to fit above it. */
.discover-stamp {
  position: absolute;
  top: 50%;
  padding: 8px 16px;
  border: var(--bw-rule) solid currentColor;
  border-radius: var(--r-2);
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: var(--fs-18);
  letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase;
  opacity: 0;
  pointer-events: none;
}
.discover-stamp-have { left: var(--s-6); color: var(--positive); transform: translateY(-50%) rotate(-8deg); }
.discover-stamp-want { right: var(--s-6); color: var(--info); transform: translateY(-50%) rotate(8deg); }

/* Shared backing for both the real drag stamp and the coach's static
   direction stamps (below), one declaration so the two can never render
   differently: this is the entire point of the coach, teaching the exact
   visual a reader then sees while actually dragging. */
.discover-stamp, .discover-coach-stamp {
  background: var(--paper-2);
  box-shadow: var(--shadow-2);
}

.discover-card-body { display: flex; flex-direction: column; gap: var(--s-3); }
/* Rocky's third phone-test note ("check fonts and text sizes... here too
   big"): the h3 element selector in design-system/colors_and_type.css
   pairs h3 with its own font-size (34px) AND line-height (40px). The rule
   below already overrode the size to fs-21 to read as a card title rather
   than a page heading, but never touched line-height, so the 40px leading
   from the unrelated 34px scale stayed in effect regardless, loosening the
   title far more than a 21px line needs. Set explicitly here so the two
   always travel together. */
.discover-card-name {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-21);
  line-height: var(--lh-21);
  color: var(--ink);
}
/* font-family and font-weight added to match the shared .eyebrow
   convention used everywhere else a small-caps label appears (COMPONENTS
   block above); colour deliberately stays --ink-3 rather than the
   eyebrow's oxblood, since a card is dealt one at a time and the one-
   accent-colour house style already spends oxblood on this same panel's
   top border and primary buttons a few pixels away. */
.discover-card-category {
  margin: 0;
  color: var(--ink-3);
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-12);
  text-transform: uppercase;
  letter-spacing: var(--tr-eyebrow);
}
/* fs-16, not the inherited body default of fs-18: client mode's own
   .card-desc (css/styles.css CLIENT block) sizes its description at fs-16,
   and a deck card is the same kind of editorial card read at the same
   distance, so it matches that register rather than the site's full body
   copy size, which reads oversized in a compact panel this narrow. */
.discover-card-desc { margin: 0; color: var(--ink-2); font-size: var(--fs-16); line-height: var(--lh-16); max-width: none; }
.discover-card-limit { margin: 0; color: var(--ink-3); font-size: var(--fs-14); }
.discover-card-more { align-self: flex-start; color: var(--ink-3); font-size: var(--fs-12); }

/* New-card deal-in (motion inventory, "a few fancy animations"): a subtle
   rise and fade as the next card arms, clearly distinct from the exit
   (which travels sideways with rotation) and from undo's own animate-back
   (js/discover.js's animateCardIn). Only ever added when !reduced in JS,
   the CSS query below is the belt-and-braces second guard on top of that,
   consistent with every other motion path in this block. */
@keyframes discover-card-enter {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
.discover-card-enter { animation: discover-card-enter 200ms var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .discover-card-enter { animation: none; }
}

/* Movability hint (PRD 17 amended, "Deck composition", Phase 15.6): one
   brief rotate-and-return nudge on the top card each time a deck opens, to
   show it can be dragged rather than only tapped. Finite (iteration count
   1, the shorthand's implicit default), never looping; js/discover.js adds
   this class at most once per deck open (shakenThisOpen) and removes it on
   animationend. Same transform-origin as the real drag physics (bottom
   centre, set on .discover-card itself further up this block), so the
   pivot matches what a reader is about to feel under their thumb. Small
   translateX alongside the rotation, per the spec's own "small translateX
   allowed", kept subtle (a few px) since this is a hint, not the drag
   itself. Only ever added when !reduced in JS (also gated on no pointer
   currently down on the card); the query below is the belt-and-braces
   second guard, consistent with every other motion path in this block. */
@keyframes discover-card-shake {
  0%   { transform: rotate(0deg) translateX(0); }
  20%  { transform: rotate(-2deg) translateX(-3px); }
  55%  { transform: rotate(1.5deg) translateX(2px); }
  80%  { transform: rotate(-0.6deg) translateX(-1px); }
  100% { transform: rotate(0deg) translateX(0); }
}
.discover-card-shake { animation: discover-card-shake 600ms var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .discover-card-shake { animation: none; }
}

/* Stamp pop on commit: a single quick scale settle as the verdict locks,
   fired by js/discover.js on every commit regardless of input method (a
   button or keyboard judgement never touched stamp opacity at all before
   this, since only the drag gesture did, so a click used to show no stamp
   whatsoever). Full transform is repeated at each stage rather than only
   the scale term, since a CSS animation replaces the whole transform value
   for its duration: without this the pop would visibly reset the stamp's
   static translateY(-50%) rotate(...) positioning for as long as it ran. */
@keyframes discover-stamp-pop-have {
  0% { transform: translateY(-50%) rotate(-8deg) scale(0.85); }
  60% { transform: translateY(-50%) rotate(-8deg) scale(1.08); }
  100% { transform: translateY(-50%) rotate(-8deg) scale(1); }
}
@keyframes discover-stamp-pop-want {
  0% { transform: translateY(-50%) rotate(8deg) scale(0.85); }
  60% { transform: translateY(-50%) rotate(8deg) scale(1.08); }
  100% { transform: translateY(-50%) rotate(8deg) scale(1); }
}
.discover-stamp-have.discover-stamp-pop { animation: discover-stamp-pop-have 220ms var(--ease-out); }
.discover-stamp-want.discover-stamp-pop { animation: discover-stamp-pop-want 220ms var(--ease-out); }
@media (prefers-reduced-motion: reduce) {
  .discover-stamp-have.discover-stamp-pop, .discover-stamp-want.discover-stamp-pop { animation: none; }
}

/* First-open coaching overlay (Phase 12 close-out): a solid panel-toned
   layer, not a translucent scrim, so the semantic --positive/--info text
   colours below read exactly as they already do on the verdict stamps
   elsewhere in this block, correct in both themes with no bespoke colour
   maths. Positioned absolutely within .discover-stage (already
   position: relative), so it covers exactly the card area, "over the top
   card" per the spec, while the control row below is blocked by disabling
   those three buttons in js/discover.js rather than by stretching this
   overlay down over them too. */
.discover-coach {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-4);
  padding: var(--s-6);
  background: var(--paper-2);
  border: var(--border);
  border-radius: var(--r-3);
  box-shadow: var(--shadow-2);
  text-align: center;
}
.discover-coach-ghost {
  width: 90px;
  height: 60px;
  background: var(--paper);
  border: var(--bw-rule) dashed var(--ink-3);
  border-radius: var(--r-2);
  /* Reinforcement only, per Rocky's phone-test note: the two stamps below
     carry the message on their own, statically, so this small sliding
     silhouette is free to be modest. Two cycles, never a loop (motion
     inventory): animation-iteration-count is the last value in this
     shorthand. Reduced motion strips this to none entirely below, losing
     nothing, since the stamps were never animated in the first place. */
  animation: discover-coach-slide 1.8s ease-in-out 2;
}

/* The direction stamps: same visual language as the real in-card verdict
   stamps (.discover-stamp-have/.discover-stamp-want further up this
   block), reused deliberately so the coach teaches the exact thing a
   reader then sees while dragging, colour for colour. Distinct classes
   rather than the literal .discover-stamp selectors because the real
   ones are position: absolute, sized for a small corner badge that shares
   the card with actual content (fs-18, opacity fading in with drag
   distance); here there is no competing content and the whole point is
   maximum legibility on a single static glance, so these run much larger
   and start at full opacity. */
.discover-coach-directions {
  display: flex;
  gap: var(--s-3);
  width: 100%;
}
.discover-coach-stamp {
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-1);
  padding: var(--s-4) var(--s-2);
  border: var(--bw-rule) solid currentColor;
  border-radius: var(--r-2);
  font-family: var(--font-sans);
}
.discover-coach-stamp-have { color: var(--positive); transform: rotate(-4deg); }
.discover-coach-stamp-want { color: var(--info); transform: rotate(4deg); }
.discover-coach-stamp-arrow { font-size: var(--fs-44); line-height: 1; font-weight: 700; }
.discover-coach-stamp-text {
  font-weight: 700;
  font-size: var(--fs-18);
  text-transform: uppercase;
  letter-spacing: var(--tr-eyebrow);
}
.discover-coach-hint { margin: 0; color: var(--ink-3); font-size: var(--fs-12); max-width: none; }
.discover-coach-dismiss { min-height: 44px; }

@keyframes discover-coach-slide {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  25% { transform: translateX(-28px) rotate(-4deg); }
  75% { transform: translateX(28px) rotate(4deg); }
}

@media (prefers-reduced-motion: reduce) {
  .discover-coach-ghost { animation: none !important; transform: none !important; }
}

/* Controls: "Got it" and "Add to my list" are equally weighted primary
   actions either side of a deliberately narrower Skip, all three still
   clearing the 44px target floor (WCAG 2.5.5/2.5.8), since Skip is a real
   control, just a quieter one. */
.discover-controls {
  display: flex;
  align-items: stretch;
  gap: var(--s-3);
  flex-wrap: wrap;
}
.discover-controls .btn { min-height: 44px; justify-content: center; }
.discover-btn-have, .discover-btn-want { flex: 1 1 40%; }
.discover-btn-skip { flex: 0 1 auto; }

.discover-undo { align-self: center; min-height: 44px; }

.discover-completion, .discover-empty {
  max-width: 420px;
  margin: 0 auto;
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding: var(--s-6) 0;
}
/* The bare <h3>/<p> js/discover.js builds here carry no class of their own,
   so with no rule scoped to this panel they fell straight through to the
   design-system's page-level h3 (34px/40px) and body p (18px) defaults,
   the same "too big for a compact panel" pattern the card content had.
   Sized to match the deck card's own title and body registers instead, so
   the whole panel reads at one consistent scale. */
.discover-completion h3, .discover-empty h3 {
  font-family: var(--font-sans);
  font-weight: 500;
  font-size: var(--fs-21);
  line-height: var(--lh-21);
  color: var(--ink);
}
.discover-completion p, .discover-empty p {
  font-size: var(--fs-16);
  line-height: var(--lh-16);
  color: var(--ink-2);
}
.discover-completion-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  justify-content: center;
}
.discover-completion-actions .btn { min-height: 44px; }

/* Completion-card staggered reveal ("a few fancy animations"): the same
   once-only, transform+opacity, JS-gated-then-CSS-guarded pattern
   js/public.js already uses for the homepage's first-paint reveal, scoped
   locally here rather than imported, since public.js depends on this
   module and never the other way round. */
.discover-reveal {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}
.discover-reveal.is-in { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .discover-reveal { transition: opacity 120ms var(--ease-out); transform: none; }
  .discover-reveal.is-in { transform: none; }
}

/* Reduced motion: belt-and-braces CSS guard behind the same query
   js/discover.js checks before ever touching a transform. Even if a
   transform-bearing class landed here by mistake, this still strips
   transform and rotation out of the transition entirely. */
@media (prefers-reduced-motion: reduce) {
  .discover-card,
  .discover-card-dragging,
  .discover-card-springback {
    transition: none !important;
    transform: none !important;
  }
  .discover-card-exit {
    transition: opacity 150ms var(--ease-out) !important;
    transform: none !important;
  }
}

@media (max-width: 480px) {
  .discover-btn-have, .discover-btn-want { flex-basis: 100%; }
  .discover-btn-skip { flex-basis: 100%; }
}

/* === RESPONSIVE ============================================================ */

@media (min-width: 1200px) {
  .card-grid { grid-template-columns: 1fr 1fr; }
  #client-root, #public-root { max-width: 1240px; }
  /* A category with exactly one card would otherwise leave a half-page of
     empty cream beside it. Read as a deliberate wide card instead. */
  .card-solo { grid-column: 1 / -1; }
}

@media (max-width: 767px) {
  #curator-root, #client-root, #public-root { padding: var(--s-5) var(--s-4) var(--s-16); }
  .cur-header .tool-count { margin-left: 0; width: 100%; }
  .cur-toolbar { flex-direction: column; align-items: stretch; }
  .cur-toolbar .select, .cur-toolbar .input { width: 100%; }
  /* In a column, the desktop flex-basis 14rem would become height. */
  .cur-toolbar .input { flex: 0 0 auto; }
  .linkgen { padding: var(--s-5); }
  .linkgen-controls { flex-direction: column; align-items: stretch; }
  /* In a column, the desktop flex-basis 16rem would become height. */
  .linkgen-controls .input { flex: 0 0 auto; }
  /* padding-top re-declared after the shorthand (16.4): this media query's
     .pub-header rule sits later in source order than the base rule's own
     padding-top: var(--topbar-fold-reserve, 44px) (PUBLIC block above), so
     without this line the shorthand here would silently win and reset the
     fold reservation back to var(--s-6) below 768px, the one width the
     880px budget actually gates. Same property, same fallback, so the two
     rules can never disagree about what the bar costs at this breakpoint. */
  .pub-header { padding: var(--s-6) var(--s-5); padding-top: var(--topbar-fold-reserve, 44px); }
  .cli-header { padding: var(--s-6) var(--s-5); }
  .cli-summary { gap: var(--s-6); padding: var(--s-5); }
  .tool-card { padding: var(--s-6); }
  /* Generous touch targets */
  .card-links a, .card-domains a { padding: 6px 0; }

  /* Mobile curator table: each row becomes a bordered, type-tinted card via
     CSS grid on the existing <tr>/<td> DOM. No row is added or removed, so
     filters, search, presets, checkboxes and bulk actions keep working
     against the same elements as the desktop table. */
  .table-wrap { overflow-x: visible; border-top: none; background: transparent; }
  .tools-table {
    display: block;
    min-width: 0;
    width: 100%;
    border: none;
  }
  .tools-table thead { display: none; }
  .tools-table tbody { display: block; }
  .tools-table tr {
    display: grid;
    width: 100%;
    grid-template-columns: auto 1fr auto;
    grid-template-areas:
      "check name  value"
      "type  cat   cat"
      "desc  desc  desc"
      "more  more  more";
    column-gap: var(--s-3);
    row-gap: var(--s-2);
    padding: var(--s-4);
    margin-bottom: var(--s-3);
    background: var(--paper);
    border: var(--bw-hair) solid var(--paper-edge);
    border-radius: var(--r-2);
  }
  .tools-table tr:hover td { background: transparent; }
  .tools-table tr.row-core    { background: var(--core-bg);    border-left: var(--bw-heavy) solid var(--core-rule); }
  .tools-table tr.row-noncore { background: var(--noncore-bg); border-left: var(--bw-heavy) solid var(--noncore-rule); }
  .tools-table tr.row-m365    { background: var(--m365-bg);    border-left: var(--bw-heavy) solid var(--m365-rule); }
  .tools-table tr.row-sector  { background: var(--sector-bg);  border-left: var(--bw-heavy) solid var(--sector-rule); }
  .tools-table tr td:first-child { box-shadow: none; }
  .tools-table td { display: block; padding: 0; border: none; }

  .tools-table .cell-check { grid-area: check; align-self: start; }
  .cur-check-label { min-width: 44px; min-height: 44px; justify-content: center; }
  /* Grid and flex items default to a min-width equal to their content's
     min-content size, which for an unbroken domain name is the whole word:
     without this, a single long tool URL forces the card wider than the
     viewport regardless of the grid track width above it. */
  .tools-table .cell-name { grid-area: name; min-width: 0; }
  .tools-table .cell-name .tool-urls a { min-width: 0; overflow-wrap: anywhere; }
  .tools-table .cell-value { grid-area: value; align-self: start; text-align: right; }
  .tools-table .cell-type { grid-area: type; align-self: start; }
  .tools-table .cell-category { grid-area: cat; align-self: center; color: var(--ink-2); font-size: var(--fs-14); }
  .tools-table .cell-desc { grid-area: desc; min-width: 0; max-width: none; }
  .tools-table td.cell-links, .tools-table td.cell-when { display: none; }
  .tools-table td.cell-more { grid-area: more; display: block; }
}

/* === WORKSPACE ==============================================================
   The /my surface (PRD-REGISTER section 5, "recessive... a few notches
   quieter than the content, cream-on-cream, oxblood rule marks the active
   item, no icon zoo"). Reuses .panel, .btn, .input, .badge and the type
   scale from COMPONENTS above rather than inventing a parallel set. */

.my-gate {
  min-height: 60vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-6);
}
.my-gate-panel {
  max-width: 560px;
  padding: var(--s-8);
  border-top: var(--bw-heavy) solid var(--oxblood);
}
.my-gate-panel h1 { font-size: var(--fs-34); line-height: var(--lh-34); margin: var(--s-2) 0 var(--s-4); }
.my-gate-warn { border-top-color: var(--caution); }
.my-gate-actions { display: flex; gap: var(--s-3); flex-wrap: wrap; margin-top: var(--s-4); }

.my-firstrun { max-width: 900px; margin: 0 auto; padding: var(--s-6) var(--s-4); }
.my-firstrun-header { padding: var(--s-8); border-top: var(--bw-heavy) solid var(--oxblood); margin-bottom: var(--s-6); }
/* auto-fit, not a fixed 2 columns: Wave B adds a third "start from your
   stack" choice when ?from= is present, so this needs to flex between two
   and three cards without a separate rule for each count. */
.my-firstrun-choices { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--s-6); padding: var(--s-6); }
.my-firstrun-choice { display: flex; flex-direction: column; gap: var(--s-3); align-items: start; }
.my-firstrun-privacy { text-align: center; margin-top: var(--s-4); }
.my-review-block { margin: var(--s-3) 0; }

.my-setup { display: flex; justify-content: center; padding: var(--s-6) var(--s-4); }
.my-setup-panel { max-width: 560px; width: 100%; padding: var(--s-8); border-top: var(--bw-heavy) solid var(--oxblood); }
.my-setup-panel h1 { font-size: var(--fs-26); line-height: var(--lh-26); margin: var(--s-2) 0 var(--s-4); }
.my-setup-actions { display: flex; gap: var(--s-3); margin-top: var(--s-4); flex-wrap: wrap; }
.my-field { display: block; margin-bottom: var(--s-3); }
.my-field .input { width: 100%; margin-top: var(--s-1); }
.my-error { color: var(--oxblood-deep); font-size: var(--fs-14); margin: var(--s-2) 0; }
.my-ok { color: var(--positive); font-size: var(--fs-14); margin: var(--s-2) 0; }
.my-consequence {
  font-family: var(--font-sans);
  font-size: var(--fs-18);
  line-height: 1.4;
  padding: var(--s-4);
  background: var(--caution-tint);
  border-left: var(--bw-heavy) solid var(--caution);
  margin: var(--s-4) 0;
}

/* App shell */
.my-shell { display: flex; min-height: 100vh; align-items: stretch; }
.my-sidebar {
  flex: 0 0 220px;
  background: var(--paper-2);
  border-right: var(--border);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: var(--s-4) 0;
}
.my-sidebar-brand { padding: 0 var(--s-4) var(--s-4); }
.my-nav { display: flex; flex-direction: column; gap: 2px; }
.my-nav-item {
  display: block;
  padding: var(--s-2) var(--s-4);
  color: var(--ink-2);
  text-decoration: none;
  border-left: var(--bw-heavy) solid transparent;
  font-family: var(--font-sans);
  font-size: var(--fs-14);
}
.my-nav-item:hover { color: var(--ink); background: var(--paper); }
.my-nav-item[aria-current="page"] {
  color: var(--ink);
  border-left-color: var(--oxblood);
  background: var(--paper);
  font-weight: 500;
}
.my-sidebar-foot { display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-4); border-top: var(--border); margin-top: var(--s-4); }
.my-menu-toggle { display: none; }

.my-content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.my-topbar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: var(--border);
  background: var(--paper);
}
.my-topbar-name { font-size: var(--fs-21); line-height: var(--lh-21); margin-right: auto; }
.my-topbar-search { max-width: 260px; }

.my-banner { padding: var(--s-2) var(--s-4); display: flex; align-items: center; gap: var(--s-3); font-size: var(--fs-14); }
.my-banner-example { background: var(--lavender-2); border-bottom: var(--bw-hair) solid var(--sector-rule, var(--ink-3)); }
.my-banner-reload { background: var(--caution-tint); border-bottom: var(--bw-hair) solid var(--caution); }

.my-main { flex: 1; padding: var(--s-6) var(--s-4); }
.my-screen { max-width: var(--content-max); }
.my-screen h2 { font-size: var(--fs-26); line-height: var(--lh-26); margin-bottom: var(--s-4); }

.my-tiles { display: flex; gap: var(--s-4); flex-wrap: wrap; margin-bottom: var(--s-4); }
.my-tile {
  flex: 1 1 200px;
  text-align: left;
  padding: var(--s-4);
  border-top: var(--bw-heavy) solid var(--ink-3);
  cursor: pointer;
  font-family: inherit;
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
}
.my-tile-value { font-family: var(--font-sans); font-size: var(--fs-34); font-weight: 500; color: var(--oxblood); }
/* Sentence-scale value (Rocky's phone-test fix): the big stat-numeral style
   above suits a short count ("1", "0"); it is wrong for a status sentence
   (the backup-age tile's "Last exported 2 days ago (11 changes since
   then): export again soon" rendered as four lines of giant text at
   375px). This modifier drops to body size and normal line height while
   keeping the tile's own status colour, one level lower per parent class
   below: quiet ink-2 at rest, --noncore-fg for amber and --oxblood for red,
   the same colour pairing already used by the .my-age-chip in the sidebar
   footer and Backup screen. --noncore-fg (not raw --caution) is
   deliberate: raw --caution on --paper clears only ~2.8:1 in light mode at
   body size, below the 4.5:1 body-text minimum, though it is fine as a
   border/non-text accent (3:1) or on the numeral scale (large-text 3:1
   rule). --oxblood already clears 4.5:1 at body size in both themes
   (7.2:1 light, 5.8:1 dark against --paper), so red keeps it unchanged. */
.my-tile-value--text { font-size: var(--fs-16); line-height: var(--lh-16); font-weight: 400; color: var(--ink-2); }
.my-tile-amber .my-tile-value--text { color: var(--noncore-fg); }
.my-tile-red .my-tile-value--text { color: var(--oxblood); }
.my-tile-label { font-size: var(--fs-14); color: var(--ink-2); }
.my-tile-ok { border-top-color: var(--positive); }
.my-tile-amber { border-top-color: var(--caution); }
.my-tile-red { border-top-color: var(--oxblood); }

/* Overview pull-quote (section 9.1, "broadsheet pull-quote styling"): the
   positioning sentence, set off from the tiles rather than buried in body
   copy. */
.my-quote {
  border-left: var(--bw-heavy) solid var(--oxblood);
  padding-left: var(--s-4);
  margin-bottom: var(--s-6);
  max-width: 640px;
}

/* Unassigned-owner attention bucket (section 9.1) */
.my-attention { padding: var(--s-4); border-top: var(--bw-heavy) solid var(--caution); margin-bottom: var(--s-4); max-width: 640px; }
.my-attention h3 { font-size: var(--fs-18); margin-bottom: var(--s-2); }
.my-attention-list { list-style: none; margin: var(--s-2) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }

/* Banners: example / reload-offer already existed (Wave A); merge preview
   and delete-undo (Wave B) reuse the same flat, left-accented shape. */
.my-banner-merge { background: var(--info-tint); border-bottom: var(--bw-hair) solid var(--info); flex-wrap: wrap; }
.my-banner-merge-open { flex-direction: column; align-items: flex-start; padding: var(--s-4); }
.my-banner-undo { background: var(--paper-2); border-bottom: var(--bw-hair) solid var(--ink-3); }

/* Risk filter chips (section 9.2): toggle buttons, not decoration, so they
   are real <button>s with aria-pressed rather than styled checkboxes. */
.my-filter-bar { display: flex; gap: var(--s-2); flex-wrap: wrap; margin: var(--s-3) 0; }
.my-chip-filter {
  padding: 6px 14px;
  border: var(--bw-hair) solid var(--ink-3);
  border-radius: var(--r-pill);
  background: var(--paper);
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  cursor: pointer;
}
.my-chip-filter:hover { border-color: var(--ink); color: var(--ink); }
/* Border matches the text tone: --caution on the tint sat at 2.5:1, below
   the 3:1 non-text minimum; --noncore-fg holds 5:1 on the same tint. */
.my-chip-filter.is-active { background: var(--caution-tint); border-color: var(--noncore-fg); color: var(--noncore-fg); }

/* Per-row risk chip (amber, "text label always, colour never alone") */
.my-chip { display: inline-block; margin-left: var(--s-2); padding: 1px 8px; border-radius: var(--r-pill); font-size: var(--fs-12); white-space: nowrap; }
.my-chip-risk { background: var(--caution-tint); color: var(--noncore-fg); border: var(--bw-hair) solid var(--caution); }

.my-accounts-actions { display: flex; gap: var(--s-2); margin: var(--s-3) 0; flex-wrap: wrap; }
.my-bulk-bar { display: flex; align-items: center; gap: var(--s-2); margin: var(--s-3) 0; padding: var(--s-2) var(--s-3); background: var(--paper-2); flex-wrap: wrap; }
.my-bulk-bar .input { max-width: 220px; }

.my-templates-panel { padding: var(--s-4); margin: var(--s-3) 0; max-width: 420px; }
.my-template-row { display: flex; align-items: center; gap: var(--s-2); padding: var(--s-1) 0; font-size: var(--fs-14); cursor: pointer; }

.my-table-wrap { overflow-x: auto; }
.my-accounts-table { width: 100%; border-collapse: collapse; }
.my-accounts-table th, .my-accounts-table td {
  text-align: left;
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--border);
  font-size: var(--fs-14);
  vertical-align: top;
}
.my-accounts-table th { color: var(--ink-2); font-weight: 500; }
.my-acc-input { width: 100%; min-width: 8rem; }
.my-acc-readonly { display: inline-block; padding: 9px 0; }
.my-acc-details-btn { margin-top: var(--s-1); }
.my-acc-drawer-row td { background: var(--paper-2); }
.my-acc-drawer { padding: var(--s-2) 0; }
.my-acc-drawer-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--s-3); margin: var(--s-3) 0; }
.my-field-wide { grid-column: 1 / -1; }
.my-field-wide textarea.input { width: 100%; min-height: 4rem; font-family: var(--font-text); }

.my-completeness-bar { display: block; width: 100%; max-width: 120px; height: 4px; background: var(--paper-edge); margin-top: var(--s-1); }
.my-completeness-fill { display: block; height: 100%; background: var(--oxblood); }

.my-status-list { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-4); max-width: 560px; margin: var(--s-4) 0; }
.my-status-list dt { color: var(--ink-2); font-size: var(--fs-14); }
.my-status-list dd { margin: 0; font-size: var(--fs-14); }

/* Adoption / positive chip variant (Wave C: My tools, "in use"), sitting
   alongside the existing amber .my-chip-risk on the same base .my-chip. */
.my-chip-ok { background: var(--positive-tint); color: var(--positive); border: var(--bw-hair) solid var(--positive); }

/* Quiet status chip (Phase 12 Wave D, section 16): "planned" is neither a
   risk (amber) nor a success (green), so it gets its own neutral tone
   rather than borrowing either vocabulary, "text label always, colour
   never alone" as ever. */
.my-chip-quiet { background: var(--paper-2); color: var(--ink-2); border: var(--bw-hair) solid var(--ink-3); }

/* "To sign up" group (section 16, section 9.2): visually separate from the
   live register table above it, same flat left-accent shape as the other
   grouped panels on this surface (.my-attention, .my-quote), never a modal
   or an accordion, so a planned row is always in view when it exists. */
.my-signup-group {
  margin-top: var(--s-6);
  padding-top: var(--s-4);
  border-top: var(--bw-heavy) solid var(--ink-3);
}
.my-signup-group h3 { font-size: var(--fs-18); margin-bottom: var(--s-1); }
.my-signup-group > p { margin-bottom: var(--s-3); }

/* Batch add (PRD-REGISTER section 17, BUILD-PLAN 12.5): an in-flow panel on
   Accounts, the same shape .my-templates-panel already uses (no modal
   system exists on this surface, and none is introduced here). */
.my-batch-sheet { max-width: 640px; margin: var(--s-3) 0; padding: var(--s-4); border-top: var(--bw-heavy) solid var(--oxblood); }
.my-batch-sheet h3 { font-size: var(--fs-18); margin-bottom: var(--s-2); }
.my-batch-picklist {
  max-height: 220px;
  overflow-y: auto;
  border: var(--bw-hair) solid var(--paper-edge);
  padding: var(--s-2) var(--s-3);
  margin-bottom: var(--s-3);
}

/* Sign-up generator (PRD-REGISTER section 18, BUILD-PLAN 12.5): rendered at
   the shell level (workspace.js's container assembly) so it stays visible
   across a screen switch while the reader works through it, same flat
   left-accent panel shape as the rest of this surface. */
.my-generator-sheet { max-width: 720px; margin: var(--s-4) 0; padding: var(--s-6); border-top: var(--bw-heavy) solid var(--oxblood); }
.my-generator-head { display: flex; justify-content: space-between; align-items: center; gap: var(--s-3); margin-bottom: var(--s-2); }
.my-generator-head h2 { margin: 0; font-size: var(--fs-21); }
.my-signup-sheet-item { margin: var(--s-3) 0; padding-bottom: var(--s-3); border-bottom: var(--bw-hair) solid var(--paper-edge); }
.my-signup-sheet-item h3 { font-size: var(--fs-16); margin-bottom: var(--s-1); }
.my-generator-preseed { display: flex; align-items: center; gap: var(--s-3); margin: var(--s-3) 0; flex-wrap: wrap; }

/* Backup-age chip (section 8), sidebar footer and Backup screen alike: a
   real button (a one-tap jump to Backup), never colour-only (the text
   already states the age in words). */
.my-age-chip {
  text-align: left;
  padding: var(--s-1) var(--s-2);
  border: var(--bw-hair) solid var(--ink-3);
  background: var(--paper);
  color: var(--ink-2);
  font-family: var(--font-sans);
  font-size: var(--fs-12);
  cursor: pointer;
}
.my-age-chip-amber { border-color: var(--caution); color: var(--noncore-fg); background: var(--caution-tint); }
.my-age-chip-red { border-color: var(--oxblood); color: var(--oxblood); background: var(--oxblood-tint); }
.my-banner-nag { background: var(--oxblood-tint); border-bottom: var(--bw-hair) solid var(--oxblood); }

/* Wave C screens (My tools, Costs, Leavers) and the full Backup screen all
   reuse .panel, .btn, .input, .my-field, .my-attention-list, .my-chip and
   the rest of the vocabulary already established above rather than
   inventing a second set: only the shapes that are genuinely new to these
   three screens get their own rules from here down. */

/* --- My tools (section 9.3) ------------------------------------------------ */
.my-tool-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--s-4); margin: var(--s-4) 0 var(--s-6); }
.my-tool-card { display: flex; flex-direction: column; gap: var(--s-2); padding: var(--s-4); border-top: var(--bw-heavy) solid var(--ink-3); }
.my-tool-card-head { display: flex; align-items: center; gap: var(--s-2); }
.my-tool-card-head h3 { flex: 1; font-size: var(--fs-16); margin: 0; }
.my-tool-card-desc { flex: 1; }
.my-tool-card-actions { display: flex; gap: var(--s-2); flex-wrap: wrap; }

/* --- Costs (section 9.4): flex rows throughout, deliberately never a
   <table>, so a long service name or amount wraps onto its own line
   instead of forcing horizontal scroll at narrow widths. ------------------- */
.my-renewal-block { margin-bottom: var(--s-4); max-width: 640px; }
.my-renewal-block h3 { font-size: var(--fs-18); margin-bottom: var(--s-2); }
.my-renewal-list { display: flex; flex-direction: column; gap: var(--s-1); }
.my-renewal-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  padding: var(--s-2) 0;
  border-bottom: var(--bw-hair) solid var(--paper-edge);
  font-size: var(--fs-14);
}
.my-renewal-date { color: var(--ink-2); min-width: 7rem; }
.my-renewal-service { flex: 1 1 160px; }
.my-renewal-amount { font-family: var(--font-sans); }
/* Phase 22: the per-row exposure note on a renewal row always takes its own
   line rather than crowding onto the date/service/amount row it explains,
   the same "text label always" discipline as the risk chips elsewhere. */
.my-renewal-exposure { flex-basis: 100%; }
.my-costs-total { max-width: 420px; margin-bottom: var(--s-4); }
.my-costs-toggle { display: flex; gap: var(--s-2); margin-bottom: var(--s-2); }
.my-costs-figure { font-family: var(--font-sans); font-size: var(--fs-34); font-weight: 500; color: var(--oxblood); margin: 0; }
/* Phase 22.1 category subtotals: a breakdown of the total above, not a
   second headline, so it sits at the same quiet scale as the renewal rows
   rather than the exposure line's fs-21. */
.my-costs-subtotals { max-width: 480px; margin-bottom: var(--s-4); }
.my-costs-subtotal-list { display: flex; flex-direction: column; gap: var(--s-1); margin-top: var(--s-2); }
.my-costs-subtotal-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: var(--s-1) 0;
  border-bottom: var(--bw-hair) solid var(--paper-edge);
  font-size: var(--fs-14);
  color: var(--ink-2);
}
.my-costs-subtotal-head { display: flex; justify-content: space-between; gap: var(--s-3); }
.my-costs-subtotal-amount { font-family: var(--font-sans); color: var(--ink); }
/* Spend-by-category bars (Phase 23, section 9.4): a flat track/fill pair,
   width set per row from JS as an inline percentage (the same
   `style="width:…%"` idiom as .my-completeness-fill above: a data-driven
   share cannot be a static class). Industrial neutral fill, deliberately
   not oxblood: this is reinforcement, not an alarm. Decorative only
   (aria-hidden in the markup); the figures above carry the real reading,
   so the ledger-not-dashboard rule stands even for a reader who never sees
   the bar at all. */
.my-costs-subtotal-bar { width: 100%; height: 6px; background: var(--paper-edge); border: var(--bw-hair) solid var(--steel-3); }
.my-costs-subtotal-bar-fill { display: block; height: 100%; background: var(--steel); }
/* Billing currency (Phase 23): the honest "no rate set" count, same quiet
   scale as the uncosted-list heading it sits beside. */
.my-costs-norate { max-width: 480px; margin-bottom: var(--s-2); }
/* Billing currency (Phase 23): the Rates row, editable from this screen.
   Two number fields side by side down to a reasonable width, stacking
   below it exactly like the drawer grid already does. */
.my-costs-rates { max-width: 480px; margin-bottom: var(--s-4); }
.my-costs-rates-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--s-3); margin: var(--s-2) 0; }
.my-costs-rates .my-field { margin-bottom: 0; }
/* Phase 22 conversion exposure (section 9.4): deliberately quieter than
   .my-costs-figure above it. This is an indicative "from" line, not the
   real total, and must never compete with it for the reader's eye. */
.my-costs-exposure { max-width: 480px; margin-bottom: var(--s-4); padding-top: var(--s-3); border-top: var(--bw-hair) solid var(--paper-edge); }
.my-costs-exposure-figure { font-family: var(--font-sans); font-size: var(--fs-21); font-weight: 500; color: var(--ink-2); margin: 0 0 var(--s-1); }
.my-costs-exposure-unknown { max-width: 480px; margin-bottom: var(--s-4); }
.my-costs-uncosted { max-width: 480px; margin-bottom: var(--s-4); }
.my-costs-uncosted-item { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.my-costs-chart-note { max-width: 480px; }
/* Phase 22.1 scope note: always renders, at the foot of the screen, quieter
   than everything above it (the ledger stating its own boundary, not
   another figure competing for attention). */
.my-costs-scope { max-width: 560px; margin-top: var(--s-4); }

/* --- Leavers (section 9.5) ------------------------------------------------- */
.my-leaver-picker { max-width: 480px; display: flex; flex-direction: column; gap: var(--s-3); align-items: start; }
/* A column flex container with align-items:start sizes its children to
   their own content width, not the container's, which otherwise starves
   .my-field's own "width:100%" rule of anything to be 100% OF: the select
   and the free-text input both need the full picker width, only the
   generate/regenerate button below them should stay shrink-to-fit. */
.my-leaver-picker .my-field { width: 100%; }
.my-leaver-checklist { max-width: 720px; }
.my-leaver-heading { font-size: var(--fs-26); margin-bottom: var(--s-1); }
.my-leaver-phase { margin: var(--s-5) 0; padding-top: var(--s-3); border-top: var(--bw-heavy) solid var(--ink-3); }
.my-leaver-phase h3 { font-size: var(--fs-18); margin-bottom: var(--s-2); }
.my-leaver-item-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
.my-leaver-item { padding: var(--s-1) 0; }
/* 44px tick targets throughout (accessibility brief and the mobile
   correction alike): the label itself is the tap target, not just the tiny
   checkbox glyph inside it, so a thumb only has to land anywhere on the
   sentence, not thread a needle. */
.my-leaver-tick-label { display: flex; align-items: flex-start; gap: var(--s-3); min-height: 44px; cursor: pointer; }
.my-leaver-tick { width: 22px; height: 22px; flex: none; margin-top: 2px; accent-color: var(--oxblood); cursor: pointer; }
.my-leaver-caveat { display: block; color: var(--oxblood); }
.my-leaver-reassign { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: var(--s-2) 0 0 calc(22px + var(--s-3)); }
.my-leaver-reassign-input { max-width: 220px; }

/* Honesty line (Phase 12.4 fix round, 27 Jul, section 16): sits between
   phase 1 and phase 2, so it reads as introducing the phases 2-4 region
   rather than as a stray aside; the same flat left-accent shape as
   .my-consequence, quieter (paper tint, not caution) since this is a plain
   statement of fact, not a warning. */
.my-leaver-honesty {
  padding: var(--s-3) var(--s-4);
  background: var(--paper-2);
  border-left: var(--bw-heavy) solid var(--ink-3);
  margin: var(--s-4) 0;
  font-size: var(--fs-14);
}

/* --- Backup (section 9.6) -------------------------------------------------- */
.my-backup-section { max-width: 640px; margin: var(--s-6) 0; padding-top: var(--s-4); border-top: var(--border); }
.my-backup-section h3 { font-size: var(--fs-18); margin-bottom: var(--s-2); }
.my-backup-wipe h3 { color: var(--oxblood); }
.my-import-drop {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--s-2);
  padding: var(--s-5) var(--s-4);
  border: var(--bw-hair) dashed var(--ink-3);
  margin: var(--s-3) 0;
}
.my-import-drop.is-dragover { border-color: var(--oxblood); background: var(--paper-2); }
.my-import-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.my-import-preview { max-width: 480px; margin-top: var(--s-3); }

@media (max-width: 768px) {
  .my-shell { flex-direction: column; }
  .my-sidebar {
    position: fixed;
    inset: 0 30% 0 0;
    z-index: 20;
    transform: translateX(-100%);
    transition: transform var(--dur-base) var(--ease-out);
    box-shadow: var(--shadow-3);
  }
  .my-sidebar.is-open { transform: translateX(0); }
  .my-menu-toggle { display: inline-flex; }
  .my-firstrun-choices { grid-template-columns: 1fr; }
  /* Search stays reachable on a phone (section 9.2 composes it with the risk
     chips): it drops onto its own full-width line instead of vanishing, as
     Wave A's rule did before Wave B wired the input up. */
  .my-topbar { flex-wrap: wrap; }
  .my-topbar-search { flex: 1 1 100%; max-width: none; order: 3; }
  .my-topbar-name { font-size: var(--fs-18); }

  /* Accounts table becomes a stacked card per row, the same pattern the
     curator table already uses at this width (RESPONSIVE block, .cell-*
     grid-template-areas): thead hidden, each <tr> a small grid. */
  .my-acc-table thead { display: none; }
  .my-acc-table tbody { display: block; }
  .my-acc-table tr.my-acc-row {
    display: grid;
    width: 100%;
    grid-template-columns: auto 1fr 1fr;
    grid-template-areas:
      "check   service  service"
      "identity identity identity"
      "owner   owner    owner"
      "mfa     mfa      renewal"
      "completeness completeness completeness"
      "actions actions  actions";
    column-gap: var(--s-3);
    row-gap: var(--s-2);
    padding: var(--s-3);
    margin-bottom: var(--s-3);
    background: var(--paper);
    border: var(--bw-hair) solid var(--paper-edge);
  }
  .my-acc-table td { display: block; padding: 0; border: none; }
  .my-acc-table .my-acc-check { grid-area: check; }
  .my-acc-table .my-acc-service { grid-area: service; display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap; }
  .my-acc-table .my-acc-identity { grid-area: identity; }
  .my-acc-table .my-acc-owner { grid-area: owner; }
  .my-acc-table .my-acc-mfa { grid-area: mfa; }
  .my-acc-table .my-acc-renewal { grid-area: renewal; }
  .my-acc-table .my-acc-completeness { grid-area: completeness; }
  .my-acc-table .my-acc-actions { grid-area: actions; }
  .my-acc-table tr.my-acc-drawer-row { display: block; padding: 0 var(--s-3) var(--s-3); margin-top: calc(-1 * var(--s-3)); margin-bottom: var(--s-3); border: var(--bw-hair) solid var(--paper-edge); border-top: none; }
  .my-acc-table tr.my-acc-drawer-row td { display: block; }

  /* Rocky's mobile correction: My tools, Costs and Leavers are first-class
     at 375-390px, not a squeezed-down desktop layout. */
  .my-tool-cards { grid-template-columns: 1fr; }
  .my-status-list { grid-template-columns: 1fr; }
  .my-status-list dd { margin-bottom: var(--s-2); }
  /* Reassign control drops below the label instead of indenting under it,
     so there is no horizontal reach needed to find it one-handed. */
  .my-leaver-reassign { margin-left: 0; }
  /* Verifier finding #4: btn-sm alone measured under the 44px floor
     (section 14, primary workspace actions); flooring it here rather than
     on .btn-sm globally keeps every OTHER small button (delete, close
     details, filter chips) at their existing, deliberately compact size on
     the screens where reach is not the concern this control has. */
  .my-leaver-reassign .btn { min-height: 44px; }
  .my-leaver-picker { max-width: none; }
  .my-import-drop { align-items: stretch; text-align: center; }
  .my-import-drop .btn { width: 100%; }

  /* Same 44px floor, same reasoning as the verifier finding noted just
     above (section 14, primary workspace actions), extended to Phase 12
     Wave E's new entry points and outputs: batch add's own actions, the
     sign-up generator sheet, and the reading-copy download/print buttons. */
  .my-accounts-actions .btn,
  .my-batch-sheet .btn,
  .my-generator-sheet .btn,
  .my-bulk-bar .btn,
  .my-reading-copy-section .btn { min-height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
  .my-sidebar { transition: none; }
}

/* Quiet entry-point links to the awareness page (Wave D, section 12), shared
   by js/client.js and js/public.js: a footnote line, never a button, so it
   never competes with the workspace CTA or the "Talk to Kaipability" line
   next to it. */
.my-awareness-link-line { margin-top: var(--s-2); }

/* --- Awareness page ("Why we built this", section 12): a standalone static
   page outside the app shell, reusing .panel, .eyebrow, the .t- classes and
   .my-quote rather than inventing a parallel type system. Narrower measure
   than the public directory: read top to bottom, not scanned like a card
   grid. (Never write a class glob ending in star-slash inside a comment:
   ".t-" followed by "*" and "/" closes the comment mid-sentence, and the
   leftover words glue onto the next selector, which silently killed
   #awareness-root's margins in production for a phase.) */
#awareness-root {
  max-width: 760px;
  margin: 0 auto;
  padding: var(--s-8) var(--s-6) var(--s-24);
}
.my-awareness-back { font-size: var(--fs-14); display: inline-block; margin-bottom: var(--s-6); }
.my-awareness-header {
  padding: var(--s-8);
  margin-bottom: var(--s-8);
  border-top: var(--bw-heavy) solid var(--oxblood);
}
.my-awareness-header .logo { height: 32px; width: auto; margin-bottom: var(--s-6); }
.my-awareness-section { margin-bottom: var(--s-10); }
.my-awareness-section h2 { font-size: var(--fs-34); line-height: var(--lh-34); margin-bottom: var(--s-4); }
.my-awareness-section p + p,
.my-awareness-section p + ul,
.my-awareness-section ul + p { margin-top: var(--s-3); }

.my-awareness-evidence { margin: 0; padding-left: var(--s-5); display: flex; flex-direction: column; gap: var(--s-3); }
.my-awareness-evidence li { max-width: var(--measure); }

/* Honest threat table (PRD-REGISTER section 10): two lists, not a table, so
   narrow viewports stack them with no horizontal scroll risk, the same
   auto-fit trick as .my-firstrun-choices rather than a bespoke breakpoint. */
.my-awareness-threat {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--s-6);
  margin-top: var(--s-4);
}
.my-awareness-threat > div { padding: var(--s-4); border-top: var(--bw-heavy) solid var(--ink-3); }
.my-awareness-threat > div.is-protects { border-top-color: var(--positive); }
.my-awareness-threat > div.is-not { border-top-color: var(--oxblood); }
.my-awareness-threat h3 { font-size: var(--fs-21); line-height: var(--lh-21); margin-bottom: var(--s-3); }
.my-awareness-threat ul { margin: 0; padding-left: var(--s-5); display: flex; flex-direction: column; gap: var(--s-2); }

.my-awareness-footer {
  margin-top: var(--s-12);
  padding-top: var(--s-6);
  border-top: var(--border);
  display: flex;
  align-items: center;
  gap: var(--s-4);
  color: var(--ink-2);
  font-size: var(--fs-14);
}
.my-awareness-footer .logo { height: 26px; width: auto; }

@media (max-width: 767px) {
  #awareness-root { padding: var(--s-5) var(--s-4) var(--s-16); }
  .my-awareness-header { padding: var(--s-6) var(--s-5); }
}

/* === PRINT ================================================================= */

/* Recovery sheet, printed from the current page (workspace.js
   printRecoverySheet). Invisible on screen; when body carries
   .my-printing-sheet under print media, the sheet is the only thing
   that prints. */
.my-print-sheet { display: none; }

@media print {
  body.my-printing-sheet > *:not(.my-print-sheet) { display: none !important; }
  body.my-printing-sheet .my-print-sheet { display: block; padding: 2rem; font-size: 12pt; color: #000; }
}

@media print {
  /* Print is always light, regardless of the reader's on-screen choice: a
     PDF or printed page in the reversed palette wastes ink and is exactly
     the surface (a client deliverable) this rule protects. Every token the
     DARK MODE block overrides is re-asserted here to its light-mode value,
     read straight off that block. Selector matches :root[data-theme="dark"]
     exactly (not just [data-theme="dark"]): an attribute selector alone is
     LESS specific than the screen rule's :root[data-theme="dark"], so a
     plain attribute selector here would lose the cascade and never apply,
     regardless of appearing later in the file. */
  :root[data-theme="dark"] {
    --paper:        #F4F1EA;
    --paper-2:      #EAE5DA;
    --paper-edge:   #DCD5C6;
    --ink:          #1A1714;
    --ink-2:        #3A332D;
    --ink-3:        #6B645B;
    --ink-4:        #A39B8E;
    --oxblood:      #A40000;
    --oxblood-deep: #7A0000;
    --oxblood-tint: #F3E0E0;
    --graphite:     #2B2A28;
    --steel-3:      #C5C0B6;
    --positive:      #4F6B3A;
    --positive-tint: #E4EAD9;
    --caution:       #B8893B;
    --caution-tint:  #F1E4CC;
    --info:          #3A5878;
    --info-tint:     #DCE3EC;
    --lavender-2:    #F2ECF5;
    --core-fg:    #445C32;
    --noncore-fg: #7A5A20;
    --m365-fg:    #2F4763;
    --sector-fg:  #5C4A70;
    --sector-rule: #8A79A0;
  }
  :root[data-theme="dark"] .btn-primary:hover { background: var(--oxblood-deep); }

  @page { size: A4; margin: 14mm; }
  body { background: #fff; }
  .no-print { display: none !important; }

  .tool-card { break-inside: avoid; }
  .cli-category { break-after: avoid; }
  .card-desc { orphans: 3; widows: 3; }

  /* Backgrounds that carry meaning (summary bar, notes block) must survive
     the print pipeline, which otherwise strips them by default. Same for
     the cost-growth bar fill: without this the bars print hollow. */
  .cli-summary, .card-notes, .card-byo, .cli-cost-bar-fill {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* Cost-growth chart (Batch D): tooltip is already .no-print, hidden above.
     The <details> table fallback is expanded by client.js on the browser's
     beforeprint event (the native <open> attribute, not a CSS override: a
     recent Chromium <details> uses content-visibility internally, which a
     display override cannot see past). The disclosure triangle serves no
     purpose once the table is already showing, so it is hidden here. */
  .cli-cost-growth { break-inside: avoid; }
  .cli-cost-table-details summary { display: none; }

  .cli-header { border-top-width: 6px; }
  /* Single column card flow on paper, regardless of the 2-col screen grid */
  .card-grid { grid-template-columns: 1fr; }

  #client-root a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-size: 0.7em;
    color: var(--ink-3);
    word-break: break-all;
  }
  .favicon { display: none; }
  #curator-root { display: none; }

  /* Print-only QR bridge (Batch H, Feature 3): invisible on screen, shown
     here at a scannable physical size regardless of the page's zoom. */
  .print-only { display: block; }
  .cli-print-qr { break-inside: avoid; }
  .cli-print-qr-svg { width: 26mm; height: 26mm; }

  /* Leaver checklist (PRD-REGISTER section 9.5, BUILD-PLAN 11.3): plain,
     numbered A4. The .no-print rule above already strips the app shell
     (sidebar, topbar, every banner) on any /my screen, so what is left here
     is just the checklist itself.

     Deliberately does NOT reuse the client-mode print-only QR bridge
     (.cli-print-qr / .print-only above): that pattern exists to hand a
     reader a live link back into a shared, read-only stack page. A leaver
     checklist is the opposite kind of document, something to hand to the
     person leaving, or file away once they are gone, so it must never
     carry a live link back into this business's own register. No QR code
     is built for this screen at all, on screen or in print. */
  .my-leaver-checklist { max-width: none; }
  .my-leaver-phase { break-inside: avoid; }
  .my-leaver-item { break-inside: avoid; }
  .my-leaver-caveat { color: #000; text-decoration: underline; }
  /* Real checkboxes print as boxes with a native tick when checked in every
     evergreen engine this site already targets; sized up here so an
     unticked box still reads clearly as a box on paper, not a stray dot. */
  .my-leaver-tick { width: 12pt; height: 12pt; accent-color: #000; }

  /* Sign-up generator print sheet (section 18) and reading-copy print sheet
     (section 20): both mount through the same .my-print-sheet mechanism as
     the recovery sheet and leaver checklist above, so nothing further is
     needed to make them print-only; these two rules only tidy pagination
     and give the reading-copy table visible borders once on paper (the
     .my-readingcopy-table itself is never shown on screen, only inside a
     mounted .my-print-sheet, so this styling lives here and nowhere else). */
  .my-signup-sheet-item { break-inside: avoid; }
  .my-readingcopy-group { break-inside: avoid; }
  .my-readingcopy-table { width: 100%; border-collapse: collapse; margin-bottom: 1em; }
  .my-readingcopy-table th, .my-readingcopy-table td { border: 1px solid #000; padding: 4px 8px; text-align: left; font-size: 10pt; }
}
