/* engine/theme.css — shared tokens and primitives for the hosted pages and the
 * editor. Bundled — never fetched from a CDN; the local editor has no network.
 *
 * Base tokens are declared on :where(:root) — specificity 0 — so editor.html's
 * inline :root block remains their owner there and this file cannot override it,
 * whatever the load order. The web/*.html pages define no tokens of their own,
 * so they take these. Values are identical to editor.html's; they are stated
 * once here for the pages that have no inline block, not forked. */
:where(:root) {
  --bg: #f6f3ec; --panel: #fffdf8; --ink: #23201a; --muted: #6b6255;
  --line: #e2dbcb; --accent: #23201a; --chip: #ede6d6;
  --good: #3d6b3d; --bad: #7c2d3b;
  --serif: ui-serif, Constantia, "Iowan Old Style", "Palatino Linotype", Georgia, serif;
  --sans: ui-sans-serif, "Segoe UI", system-ui, sans-serif;
  --mono: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  --shadow: 0 1px 2px rgba(30,24,12,.05), 0 6px 16px -10px rgba(30,24,12,.20);
  color-scheme: light dark;
  accent-color: var(--ink);
}
@media (prefers-color-scheme: dark) {
  :where(:root) { --bg:#15120d; --panel:#201b14; --ink:#ece4d5; --muted:#a89a85;
                  --line:#372f22; --accent:#ece4d5; --chip:#271f16;
                  --good:#7fbf7f; --bad:#d98a99;
                  --shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 20px -12px rgba(0,0,0,.55); }
}

/* The two new tokens the whole phase is allowed. On plain :root so they win
   everywhere, including inside engine/editor.html. */
:root {
  --field-line: #8f8369;   /* 3.68:1 on --panel, 3.37:1 on --bg — WCAG 1.4.11 */
  --note:       #f0ead9;   /* quiet surface; never used without a rule + label */
}
@media (prefers-color-scheme: dark) {
  :root { --field-line: #7d7059; --note: #2a2318; }
}

/* The tier ramp — garnet -> rust -> ochre -> olive -> teal -> slate, one
   warm-to-cool gradient rather than stock status-badge hues. Same in light
   and dark (every value is dark/muted enough to hold white text at AA,
   checked 5.0-9.2:1). This is the legitimate home for these six values;
   engine/render.py:67-72 is a second hand-kept copy that must stay, since
   that file has to remain self-contained and double-clickable and so cannot
   link this stylesheet. Change one, change the other. */
:root {
  --t1:   #7c2d3b; /* Essential to the gospel */
  --t1-5: #8a4a24; /* Near-essential */
  --t2:   #8c6a1f; /* Church-defining */
  --t2-5: #5f6b35; /* Strains partnership */
  --t3:   #2f6b63; /* Important, not divisive */
  --t4:   #33526e; /* Matters of liberty */
}

:where(:root) {
  /* Spacing — 4px base, eight steps. Gaps come from this scale or they are a bug. */
  --s1: .25rem;   /*  4px — inside a chip */
  --s2: .5rem;    /*  8px — between a label and its value */
  --s3: .75rem;   /* 12px — between rows in a card */
  --s4: 1rem;     /* 16px — card padding, gap in a grid */
  --s5: 1.5rem;   /* 24px — between blocks */
  --s6: 2rem;     /* 32px — between sections */
  --s7: 3rem;     /* 48px — page top/bottom */
  --s8: 4.5rem;   /* 72px — the one big silence, above a screen title */
}

:where(:root) {
  /* Radius — press it: --r2. Holds other things: --r3. Is a value: --r1.
     Is a state: --r-pill. */
  --r1: 4px;
  --r2: 8px;
  --r3: 12px;
  --r-pill: 999px;
}

:where(:root) {
  /* Elevation — tinted off --ink so a cream-theme shadow is warm brown-grey
     (paper under paper), never the stock neutral SaaS-card grey. */
  --e1: 0 1px 2px -1px color-mix(in oklab, var(--ink) 20%, transparent);
  --e2: 0 2px 4px -2px color-mix(in oklab, var(--ink) 16%, transparent),
        0 8px 16px -12px color-mix(in oklab, var(--ink) 28%, transparent);
  --e3: 0 4px 8px -4px color-mix(in oklab, var(--ink) 18%, transparent),
        0 24px 40px -24px color-mix(in oklab, var(--ink) 40%, transparent);
}
@media (prefers-color-scheme: dark) {
  :where(:root) {
    --e1: 0 1px 2px -1px rgb(0 0 0 / .5);
    --e2: 0 2px 4px -2px rgb(0 0 0 / .45), 0 8px 20px -12px rgb(0 0 0 / .6);
    --e3: 0 4px 8px -4px rgb(0 0 0 / .5),  0 24px 44px -24px rgb(0 0 0 / .7);
  }
}

:where(:root) {
  /* Motion — three durations, three easings. --ease-out's control points are
     deliberately aggressive: a hard decelerate reads as immediate and physical,
     a gentle ease reads as floaty. */
  --dur-1: 120ms;   /* state under the finger: hover, selection tint, press */
  --dur-2: 200ms;   /* something appears or leaves */
  --dur-3: 320ms;   /* something that exists moves somewhere else */

  --ease-out:   cubic-bezier(.2, 0, 0, 1);    /* arriving — decelerate hard */
  --ease-in:    cubic-bezier(.4, 0, 1, 1);    /* leaving */
  --ease-move:  cubic-bezier(.4, 0, .2, 1);   /* moving between two on-screen places */
}
@media (prefers-reduced-motion: reduce) {
  :where(:root) { --dur-1: 1ms; --dur-2: 1ms; --dur-3: 1ms; }
}

:where(:root) {
  /* Type — seven steps against today's twenty-two. vw appears only above 15px:
     fluid micro-type resizes into fractional pixels and looks blurry, so the
     label and meta steps stay fixed. */
  --fs-000: .6875rem;  /* 11px   — the ONE uppercase label register */
  --fs-00:  .78125rem; /* 12.5px — meta, counts, timestamps (sans) */
  --fs-0:   .875rem;   /* 14px   — secondary prose, hints */
  --fs-1: clamp(.9375rem, .89rem + .22vw, 1rem);      /* 15 → 16 body */
  --fs-2: clamp(1.0625rem, .99rem + .35vw, 1.1875rem);/* 17 → 19 card heading */
  --fs-3: clamp(1.25rem, 1.10rem + .70vw, 1.5rem);    /* 20 → 24 section heading */
  --fs-4: clamp(1.625rem, 1.32rem + 1.5vw, 2.25rem);  /* 26 → 36 screen title */

  --lh-tight: 1.2;   /* --fs-3 and up */
  --lh-snug:  1.35;  /* headings at --fs-2, chips, controls */
  --lh-body:  1.6;   /* serif prose */
  --lh-ui:    1.45;  /* sans UI text */

  --measure: 58ch;       /* the documented prose cap — unchanged */
  --measure-wide: 68ch;  /* lead paragraphs and notes only */
}

/* Interactive control boundaries take --field-line. Decorative dividers keep
   --line. Do not merge these two. */
input[type=text], input[type=search], select, textarea,
.tm-card, .chip-select, .mfield textarea, .mfield input[type=text] {
  border-color: var(--field-line);
}

/* Native auto-grow where supported, alongside map-view.js's autosize() JS
   fallback (kept, not removed, until field-sizing support is everywhere) --
   an unsupporting browser ignores this and keeps the JS-driven height. Both
   converge on the same scrollHeight-based result, so there is no visible
   conflict where both are active. */
.mfield textarea { field-sizing: content; }

.tm-note {
  background: var(--note);
  /* .tm-note is never used without a rule and a label beside it, because --note
     alone is not perceivable: in dark mode it is #2a2318 against the #15120d page
     background, roughly 1.5:1 contrast, nowhere near readable. Raising --note's
     value would defeat the whole point of it being a quiet surface, so the
     border-left carries all the perceivability. Not optional in dark. */
  border-left: 3px solid var(--muted);
  padding: var(--s3) var(--s4);
  border-radius: 0 var(--r2) var(--r2) 0;
  font: var(--fs-00)/var(--lh-body) var(--serif);
  max-width: var(--measure);
}

/* Every citation web/refs.js's citeLink() produces carries this class, and has
   to read as a link — plain text it inherited var(--muted) with no underline,
   indistinguishable from the prose around it (.wz-hint/.wz-explain/.wz-pop/
   .who in wizard.html, .lp-prose/.lp-hint/.lp-sources/.lp-who in learn.html —
   both pages used to declare this pair of rules once each, under their own
   six-selector prefix list). */
.cite-link {
  color: var(--ink); text-decoration: underline;
  text-decoration-color: var(--muted); text-underline-offset: 2px;
}
.cite-link:hover {
  text-decoration-color: var(--ink);
}

.tm-card {
  background: var(--panel);
  border: 1px solid var(--field-line);
  border-radius: var(--r3);
  padding: var(--s4);
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}
.tm-card h3 { margin: 0; font: 600 var(--fs-2)/var(--lh-snug) var(--serif); }
.tm-card p  { margin: 0; font: var(--fs-0)/var(--lh-body) var(--serif); color: var(--ink); max-width: var(--measure); }
.tm-card .tm-go { margin-top: auto; font: 600 var(--fs-00)/var(--lh-ui) var(--sans); color: var(--muted); }
.tm-card-primary {
  background: var(--ink); color: var(--bg); border-color: var(--ink);
}
.tm-card-primary p, .tm-card-primary .tm-go { color: var(--bg); }

.tm-stat { font: var(--fs-00)/var(--lh-snug) var(--sans); color: var(--muted); font-variant-numeric: tabular-nums; }

/* The product's four secondary text styles. Declared once here rather than
   copied into wizard.html, compare.html and learn.html - three near-identical
   sets was the CSS-layer expression of "pages that look alike by copying".
   Text only, no layout: engine/theme.css is loaded by the offline editor too. */
.tm-quiet   { margin: 0; font: var(--fs-00)/var(--lh-ui) var(--sans); color: var(--muted); }
.tm-hint    { margin: 0; font: var(--fs-00)/var(--lh-body) var(--serif); color: var(--muted);
              max-width: var(--measure); }
.tm-framing { margin: 0; font: var(--fs-0)/var(--lh-body) var(--serif); color: var(--ink);
              max-width: var(--measure); }
/* D4: one uppercase register with a stated job -- everything else sentence
   case. This was the last of six near-identical uppercase-label variants;
   .tm-chrome .kicker below is the one that survives, deliberately, because
   it is naming the whole page's chrome, not a field inside a card. */
.tm-lab { margin: 0; font: 600 var(--fs-000)/var(--lh-ui) var(--sans); color: var(--muted); }

/* Small win 1: let the browser pick better line breaks than "whatever fits". */
h1, h2, h3, .tm-card h3 { text-wrap: balance; }
p, dd, .tm-prose, .tm-hint, .tm-framing { text-wrap: pretty; }

details.optional > summary {
  cursor: pointer;
  font: 600 var(--fs-00)/var(--lh-body) var(--sans);
  color: var(--muted);
  padding: var(--s2) 0;
  list-style: none;
}
details.optional > summary::-webkit-details-marker { display: none; }
details.optional > summary::before { content: '\25B8\00a0'; display: inline-block; transition: transform var(--dur-1) var(--ease-out); }
details.optional[open] > summary::before { transform: rotate(90deg); }
details.optional > summary:hover { color: var(--ink); }

@media (pointer: coarse) {
  button, .tabbtn, select, .nodebtn, details.optional > summary { min-height: 44px; }
  .mdomain-edit, .tagchip button { min-width: 44px; min-height: 44px; }
  /* Ruling F: these three used to be a plain class / compound selector tying
     at (0,1,0) / (0,1,1) with a later, equally-specific page-local rule --
     the exact mechanism CLAUDE.md §10 already burned twice. Each is bumped
     one selector to win on SPECIFICITY, not source order, so no future
     page-side edit can defeat it again. */
  a.lp-row { padding: var(--s3); }
  /* (0,1,1) beats web/learn.html's .lp-row (0,1,0). Re-derived after P7
     Task 4 re-tokenised that page: the row's tallest child is .lp-tier
     (600 11px/1 + 5px padding = 21px), just over .lp-row .nm's
     var(--fs-1)/var(--lh-snug) = 20.3px at 360px. 21 + var(--s3)*2 (24px)
     + the row's own 1px+1px border = 47px, clears 44. */
  details.cmp-row > summary { padding: var(--s3) 0; }
  /* (0,1,2) beats web/compare.html's .cmp-row > summary (0,1,1).
     Re-derived after P7 Task 5 re-tokenised that page: .cmp-q is now
     600 var(--fs-1)/var(--lh-snug). --fs-1 is fluid, so the WORST case is
     the narrowest viewport: at 360px it resolves to 15.03px, x1.35 =
     20.29px line box, + var(--s3)*2 (24px) = 44.29px. That clears 44 by
     less than a third of a pixel, and the margin comes from a fluid token
     declared in THIS file. Widen --lh-snug or shrink --fs-1 and this
     touch target silently drops below the WCAG floor. It is comfortable
     everywhere else (45.6px once --fs-1 saturates at 16px). */
  details.cmp-acc > summary { padding: var(--s4) 0; }
  /* (0,1,2) beats web/compare.html's .cmp-acc > summary (0,1,1).
     Re-derived after P7 Task 5: the summary is 600 var(--fs-0)/var(--lh-snug)
     = 14 x 1.35 = 18.9px line box. var(--s3) (12px) still falls short --
     18.9 + 24 = 42.9px, under 44 -- so var(--s4) is the step that clears
     it: 18.9 + 32 = 50.9px. The conclusion is unchanged from Task 1's, but
     the numbers it was argued from no longer existed. */
  .wz-radio input + span, .mradio input + span { min-height: 44px; }
}

/* Shared product chrome (web/chrome.js), scoped under .tm-chrome so it cannot
   collide with the editor's own header.

   This used to say it mirrored engine/editor.html's header rules. It no longer
   does, and that is now deliberate: Thomas ruled on 2026-09-12 that the offline
   file:// tool keeps its own visual language. P7 moved these rules onto the type
   and spacing scales and left editor.html's header where it was. Do NOT re-sync
   the two - it was a hand-kept mirror that nothing enforced and that drifted
   silently the first time a phase touched one side.

   The NAV LIST in chrome.js and editor.html is a different thing and is still a
   permanent hand-kept lockstep. See CLAUDE.md section 8. */
.tm-chrome { padding:var(--s4) var(--s5) var(--s3); border-bottom:1px solid var(--line); }
.tm-chrome .kicker { margin:0 0 var(--s1); font:600 var(--fs-000)/var(--lh-ui) var(--sans); letter-spacing:.16em;
                      text-transform:uppercase; color:var(--muted); }
.tm-chrome h1 { margin:0 0 var(--s1); font-family:var(--serif); font-size:var(--fs-3); line-height:var(--lh-tight); letter-spacing:-.012em; }
/* align-items:center, not the flex default `stretch`: .tm-morebtn is a <button>
   with its own font (600 18px/1) and so its own line box (18px) against the
   links' var(--fs-00)/var(--lh-ui) (18.13px). Stretched, those two differ by
   .13px at the top and the ⋯ sits a hair high; centred, any line-box
   difference splits evenly and the glyph reads as level with the labels. */
.tm-chrome .toplinks { margin-top:var(--s2); display:flex; align-items:center; gap:var(--s3); font:600 var(--fs-00)/var(--lh-ui) var(--sans); }
.tm-chrome .toplinks a { color:var(--muted); text-decoration:none; border-bottom:1px solid transparent; }
.tm-chrome .toplinks a:hover { color:var(--ink); border-color:var(--muted); }
/* Ties with .tm-chrome .toplinks a above at (0,2,1); declared after it in the
   same file so it wins the tie on source order. Unprefixed on purpose — a
   .tm-chrome-scoped version would also beat editor.html's local
   .toplinks a ((0,1,1)) on specificity alone, but the editor's header has no
   .tm-chrome ancestor, so it would never apply there. One selector serves
   both surfaces. Only the colour changes: :222's rule already puts a
   transparent 1px border-bottom on every nav link, so the marker moves with
   no layout shift. */
.toplinks a[aria-current] { color: var(--ink); border-bottom-color: var(--ink); }
/* M2: the rule above only reaches .toplinks — the six items that moved into
   the ⋯ overflow are in .tm-more, so on /edit, /history, /compare and
   /admin nothing in the header showed where you are. .tm-more a already
   sets color: var(--ink) unconditionally (below), so its own [aria-current]
   state borrows the hover treatment instead — a background tint — as the
   in-menu marker. .tm-morebtn[aria-current] is the one visible with the
   menu closed: JS sets it when any .tm-more item matches (see chrome.js's
   mount() and editor.html's boot()). */
.tm-more a[aria-current] { background: var(--chip); }
.tm-morebtn[aria-current] { color: var(--ink); }

/* The ⋯ overflow popover. Scoped on its own class names, with no .tm-chrome
   ancestor, on purpose: web/'s header carries .tm-chrome, the editor's does
   not, and one rule set has to serve both. The button is a <button>, not a
   .toplinks a, so it gets its own font/colour/padding rather than inheriting
   link styling it was never going to get. */
.tm-morebtn {
  font: 600 18px/1 var(--sans); /* optical: the ⋯ glyph, not a type step */
  color: var(--muted); background: none;
  border: none; padding: 0 var(--s1); cursor: pointer; anchor-name: --tm-more-anchor;
}
.tm-morebtn:hover { color: var(--ink); }
.tm-more {
  position: absolute; position-anchor: --tm-more-anchor;
  /* [popover]'s UA stylesheet is position: fixed; inset: 0; width/height:
     fit-content; margin: auto — inset: 0 is what centres an unstyled
     popover. Overriding position/top/right/margin below without resetting
     inset first leaves the UA's left: 0 and bottom: 0 still in effect
     alongside our own top/right, over-constraining the box; the engine
     drops `right` in LTR, and the menu lands at the page's bottom-left
     instead of under the button. inset: auto clears all four before the
     two edges below set the ones actually wanted. */
  inset: auto;
  top: anchor(bottom); right: anchor(right);
  margin: var(--s2) 0 0; min-width: 180px; padding: var(--s1) 0;
  border: 1px solid var(--field-line); border-radius: var(--r3);
  background: var(--panel); box-shadow: var(--e3);
}
/* padding was var(--s2) var(--s4) (8px 16px) unconditionally, which is a
   32.88px row on a mouse or trackpad against the visible nav's 18.13px —
   the menu read as a different, chunkier control than the row it hangs off.
   var(--s1) var(--s3) (4px 12px) puts it at 24.88px, near the nav's scale
   while keeping a row you can hit. The touch floor is NOT this rule's job:
   the @media (pointer: coarse) override below still raises it to 48.9px. */
.tm-more a {
  display: block; padding: var(--s1) var(--s3); color: var(--ink);
  text-decoration: none; font: 600 var(--fs-00)/var(--lh-snug) var(--sans);
}
.tm-more a:hover { background: var(--chip); }
/* Safari and Firefox have no anchor positioning yet; without this the
   popover would render wherever the UA default top-layer placement lands
   it, which is effectively unreachable. Not a mitigation of the anchored
   case above — both branches ship. Same inset: auto reset as above and for
   the same reason: the UA's left: 0 would otherwise survive alongside this
   fixed rule's own right, putting the fallback box in the top-LEFT corner
   (over the kicker and h1) instead of the top-right one it targets.

   The top/right values here are only the pre-open resting place. Pinning
   the menu to the viewport corner is what made it read as disjointed from
   its ⋯ button on a wide screen, so chrome.js and editor.html both set
   top/right inline from the button's own getBoundingClientRect() when the
   menu opens, gated on CSS.supports('position-anchor: --x') — the JS
   mirror of this very @supports. An engine WITH anchor support never runs
   that code and stays on pure CSS. margin is var(--s2) 0 0, not 0, so the
   JS can set top to the button's bottom edge and get the same 8px gap the
   anchored rule above already has. */
@supports not (position-anchor: --x) {
  .tm-more { position: fixed; inset: auto; top: var(--s4); right: var(--s4); margin: var(--s2) 0 0; }
}
/* C4: .tm-more a above sets padding: var(--s1) var(--s3) (4px 12px) — a
   16.88px line box (var(--fs-00)/var(--lh-snug) = 12.5px/1.35) plus that
   padding is 24.88px, under the 44px floor. This coarse-pointer override
   ties with it at (0,1,1) — a media query adds no specificity — so SOURCE
   ORDER is the only thing that can decide it, and it must be declared
   after the base rule to win. (It was declared earlier, inside the
   general @media (pointer: coarse) block above, and silently lost every
   time — the exact mechanism that left the old 44px floor inert on
   /wizard for a whole phase; see CLAUDE.md §10.) It also sets its own
   var(--s4) (16px) horizontal inset rather than inheriting the base rule's
   — the old `padding: 16px 0` here zeroed it, putting the menu's labels
   flush against the popover edge on a touch device, and the base rule is
   now var(--s3), which is a desktop measure, not a thumb one.
   16px + 16.88px + 16px = 48.9px, clearing 44px
   with room to spare — this time for real, because this declaration is
   the one that actually wins. */
@media (pointer: coarse) {
  .tm-more a { padding: 16px var(--s4); }
  /* P7 Ruling A: .tm-chrome .toplinks a used to live in the general coarse
     block above (:203), where it tied with the base .tm-chrome .toplinks a
     rule below (:222) at (0,2,1) each and won only because that base rule
     sets no padding of its own — the exact fragile arrangement that just
     bit .tm-more a above, and .wz-radio span before that (CLAUDE.md §10).
     Moved here so it wins on SOURCE ORDER instead. .tm-chrome .toplinks is
     now 600 var(--fs-00)/var(--lh-ui) var(--sans) (12.5px/1.45 = 18.13px
     line box), so 16px + 18.13px + 16px = 50.1px, clearing 44px with
     margin to spare. 16px stays a literal, not var(--s4): its job is to
     reach 44px, and --s4 equalling 16px today is a coincidence a future
     token change would break. */
  .tm-chrome .toplinks a { padding: 16px 0; }
}

/* Laptop and wider: one type step up, var(--fs-00) (12.5px) to var(--fs-0)
   (14px). The nav had no responsive variant at all and read undersized on a
   big screen. Modest on purpose — this is chrome, not content, so it is one
   step and only the size; weight, family and line-height stay put.

   860px is WIDE_NAV, the same number web/chrome.js and engine/editor.html
   use to decide whether Browse is in the row or the ⋯ menu, so the row
   gains its fifth item and its larger type at one width rather than two.
   Ties with the base .tm-chrome .toplinks rule at (0,2,0) — a media query
   adds nothing — so it wins only by being declared after it, here. Do not
   move it above that rule.

   The 44px coarse-pointer floor derived above gets safer, not tighter:
   var(--fs-0)/var(--lh-ui) is a 20.3px line box, so 16 + 20.3 + 16 = 52.3px
   on a touch device wide enough to match this query. */
@media (min-width: 860px) {
  .tm-chrome .toplinks { font-size: var(--fs-0); }
  /* .tm-morebtn's line box is hard-pinned to 18px (font: 600 18px/1 — an
     optical size, not a type step, unprefixed above so editor.html's own
     .toplinks shares it) so it no longer tracks the row once the row's own
     line box grows with the type step above: var(--fs-0)/var(--lh-ui) =
     20.3px vs the button's fixed 18px. align-items:center still centres
     each box, but a 2.3px-taller neighbour reads as the ⋯ sitting a couple
     px high. Scoped to .tm-chrome, not bare .tm-morebtn: editor.html's own
     .toplinks has no matching font-size bump at this width (§8, the header
     styling fork stays unsynced), so its row's line box is still 18.13px
     and must not get this override. */
  .tm-chrome .tm-morebtn { line-height: 20.3px; }
}

/* Task 1: an optional right-aligned actions row in the header, passed as
   mount()'s second argument. Wraps whenever the row doesn't fit (a 360px
   phone included) because flex-wrap does that regardless of a breakpoint. */
.tm-chrome-titlerow { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
.tm-chrome-actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
.tm-action-btn { font:600 var(--fs-000)/var(--lh-ui) var(--sans); padding:var(--s2) var(--s3); border:1px solid var(--field-line);
                 border-radius:var(--r2); background:var(--panel); color:var(--ink); cursor:pointer;
                 text-decoration:none; white-space:nowrap; }
.tm-action-btn:hover { border-color:var(--muted); }

@media (max-width: 640px) {
  .tm-chrome .toplinks { flex-wrap:nowrap; overflow-x:auto; white-space:nowrap; scrollbar-width: none; mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent); }
}

body.tm-page { margin:0; background:var(--bg); color:var(--ink); font:var(--fs-1)/var(--lh-body) var(--serif); }
body.tm-page main, body.tm-page .tm-main { padding:var(--s5); max-width:1200px; margin-inline:auto; }

/* Card grid — shared by the gallery, first run and the "start from someone
   else's map" picker, so it lives here rather than in three inline blocks.
   auto-fill for the gallery (many cards, fill the row); the first-run block
   uses auto-fit and a narrower cap so its two cards sit side by side on a wide
   screen instead of shrinking into a four-track row. Both collapse to one
   column at any viewport, checked at 320px and 360px — the min() clamps the
   track to the viewport itself so a bare 300px floor cannot overflow a
   narrower one. */
.tm-grid { display:grid; gap:var(--s4); max-width:1200px; margin-inline:auto;
           grid-template-columns:repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }
.tm-firstrun { grid-template-columns:repeat(auto-fit, minmax(min(300px, 100%), 1fr)); max-width:780px; margin-inline:0; }
.tm-grid .tm-span { grid-column:1 / -1; }

/* A whole card that is itself the control — an <a> in the gallery, a
   role="button" div in first run. Same look either way. */
a.tm-card, .tm-cardlink { text-decoration:none; color:var(--ink); cursor:pointer; }
a.tm-card-primary, .tm-card-primary.tm-cardlink { color:var(--bg); }
a.tm-card:focus-visible, .tm-cardlink:focus-visible { outline:2px solid var(--ink); outline-offset:2px; }
a.tm-card-primary:focus-visible { outline-color:var(--bg); }

.tm-prose { max-width:var(--measure); }
.tm-prose p { margin:0 0 var(--s3); }
.tm-lead { font:600 var(--fs-0)/var(--lh-body) var(--sans); color:var(--muted); }

/* Set while a launchpad first-run card is mid-request. The real double-click
   guard is a flag in web/wizard.js (the offers moved there when web/first-run.js
   and /app were deleted); this is only the visible half of it. */
.tm-working { opacity:.6; pointer-events:none; cursor: progress; }

/* .wz-pop (web/wizard.html's Read-more popover), nav.tree (engine/editor.html's
   sidebar tree) and main.form (the editor's List-tab form pane) are the
   vertically-scrolling containers win 4's mask-fade treatment doesn't reach
   -- a tinted scrollbar is the lighter cue that fits them. */
.wz-pop, nav.tree, main.form { scrollbar-color: var(--field-line) transparent; }

.tm-picker { background:var(--panel); color:var(--ink); border:1px solid var(--field-line);
             border-radius:var(--r3); padding:var(--s5); max-width:min(1100px, 92vw); }
.tm-picker::backdrop { background:rgba(0,0,0,.45); }
.tm-picker h2 { margin:0 0 var(--s2); font:600 var(--fs-3)/var(--lh-snug) var(--serif); letter-spacing:-.012em; }
.tm-picker .tm-grid { margin:var(--s4) 0; }

/* One focus ring for the whole product. --ink reads against both grounds
   (it is the light value in dark mode); the offset is what makes it
   visible on an already-bordered control. */
:where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
  border-radius: var(--r2);
}

/* The belief field. The ring belongs on the WRAPPER, which is the thing
   that looks like the control. */
.wz-holdfield:focus-within {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
  border-color: var(--ink);
}

/* Press feedback, which coarse pointers get no hover substitute for today. */
@media (prefers-reduced-motion: no-preference) {
  .tm-card, .wz-card, .lens, .wz-qrow, .lp-row, .tm-action-btn, .wz-ghost {
    transition: border-color var(--dur-1) var(--ease-out),
                background-color var(--dur-1) var(--ease-out),
                box-shadow var(--dur-1) var(--ease-out);
  }
  :where(.tm-card, .wz-card, .lens, .wz-qrow, .lp-row, button):active {
    scale: .995;
    transition-duration: 60ms;
  }
}

a.tm-card:hover, .tm-cardlink:hover, .lp-row:hover, .wz-qrow:hover {
  border-color: var(--field-line);
  background: color-mix(in oklab, var(--chip) 45%, var(--panel));
  box-shadow: var(--e1);
}

:where(button, .tm-action-btn, .wz-primary, .wz-ghost):disabled,
[aria-disabled="true"] {
  opacity: .45;
  cursor: default;
  box-shadow: none;
}

::selection { background: color-mix(in oklab, var(--t2-5) 25%, transparent); }

/* In dark, --line (#372f22) on --panel (#201b14) is ~1.3:1, so card edges
   are invisible and every screen flattens into one brown field. Raising
   --line is not the fix — it would shout in the light theme's borrowed
   rules. Give dark containers elevation instead of relying on the edge.
   Routed 1: this rule used to also carry a border-color half, meant to
   brighten these nine selectors' borders toward --panel. It only ever
   reached two of them. Seven — .wz-card, .wz-area, .lp-pos, .lp-mine,
   .cmp-row, .cmp-acc, .wz-stat — declare their own unconditional `border`
   shorthand in their own page's <style>, at this same (0,1,0) specificity
   and later in source order (every web/*.html page links this file before
   its own <style>), so the page's border always won there and this rule
   never reached them. The two it does reach, .tm-card and .tm-picker,
   already border on --field-line, and mixing that toward --panel made
   the border dimmer, not clearer — the opposite of this rule's job.
   Deleted; box-shadow: var(--e1) is the half that actually does the work,
   and it lands on all nine. */
@media (prefers-color-scheme: dark) {
  .tm-card, .wz-card, .lp-pos, .lp-mine, .wz-area, .cmp-row, .cmp-acc,
  .tm-picker, .wz-stat {
    box-shadow: var(--e1);
  }
}

input, textarea { caret-color: var(--ink); }

/* P4 Task 1: View transitions for cross-document navigation.

   The @view-transition directive enables shared-element transitions between pages
   in this hosted app. Navigation between full pages (/, /gallery, /view, etc.)
   will smoothly animate the chrome.

   .tm-chrome carries a unique view-transition-name on every page it appears.
   The element is unique per document because web/chrome.js's mount() replaces
   #tmChrome with a single <header class="tm-chrome">, so the name never collides.

   engine/editor.html has no .tm-chrome (its header is #editorToplinks's own
   markup), so /edit gets @view-transition with no named group. That is the accepted
   risk documented in the phase file: no transition for the editor's chrome.

   The reduced-motion guard zeros all transitions for users with
   prefers-reduced-motion: reduce, turning the animation into an instant swap
   using the !important flag to win on importance — evaluated before
   specificity in the cascade — across all view-transition pseudo-elements. */
@view-transition { navigation: auto; }
.tm-chrome { view-transition-name: tm-chrome; }
/* F3: put the phase's two most visible animations on the token scale instead
   of Chrome's UA default timing. Must sit before the reduced-motion guard
   below so that guard still wins -- on importance, a cascade stage decided
   before specificity or source order, not because of position, but position
   keeps the intent legible next to it. */
::view-transition-group(*) {
  /* Travel: an element that already exists (the header, the crumb, the nav)
     moving to a different place in the new document. */
  animation-duration: var(--dur-3);
  animation-timing-function: var(--ease-move);
}
::view-transition-old(*), ::view-transition-new(*) {
  /* Settle half of the pair: the old/new snapshot crossfade. */
  animation-duration: var(--dur-2);
  animation-timing-function: var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* P4 Task 4: Disclosures that Settle instead of snap.

   interpolate-size lets a <details> animate to its natural (auto) height with
   no JS and no measured pixel values. Plain :root, not :where(:root) — this
   file's :where(:root) exists so editor.html's inline :root stays the owner
   of token *values*; interpolate-size is not a token and has no second copy
   for that inline block to own.

   Class-wide is the right root-cause call: details.optional matches every
   details with that class, not an enumerated list. It currently reaches
   #q-readmore (wizard.html:330), #who (:356), buildCustom's Advanced details
   in both the corpus-answer and open-answer branches (wizard.js,
   el('details', 'optional')), web/landing.html:46's #what-it-is (ships open
   in the markup on the front page — safe from first-paint motion, since an
   element open at parse time has no before-change style and so runs no
   transition), engine/editor.html:840's field disclosure, and the leaf tile
   built by engine/map-view.js:321. .cmp-row and .cmp-acc are compare.html's
   own classes. Durations and easing are P2's tokens, not the .25s ease in the
   source assessment (constrained §4.3) — that hard-coding is superseded.
   Where ::details-content is unsupported these rules simply drop and the
   disclosure snaps exactly as it does today: no JS fallback.

   overflow: clip risk checked: nothing absolutely positioned escapes any of
   these. wizard.js's readMoreButton appends .wz-pop to the card host, not
   inside a <details>, so it is never clipped by this rule. */
:root { interpolate-size: allow-keywords; }

details.optional::details-content,
.cmp-row::details-content,
.cmp-acc::details-content {
  block-size: 0;
  overflow: clip;
  /* F5: only block-size is overridden on [open] below, so the clip stays on
     while the disclosure is open, and this project's single focus ring
     (2px solid, outline-offset 2px -- 4px outside the box) gets shaved by an
     ancestor overflow: clip. This keeps the clip's purpose while letting the
     ring show. Affected without it: web/compare.html's .cmp-row a, the
     wizard's Advanced field rows, and the editor's optional field rows. */
  overflow-clip-margin: 4px;
  transition: block-size var(--dur-2) var(--ease-out),
              content-visibility var(--dur-2) allow-discrete;
}
details.optional[open]::details-content,
.cmp-row[open]::details-content,
.cmp-acc[open]::details-content { block-size: auto; }

/* F4: the editor's Map view measures offsetHeight synchronously in a toggle
   handler (map-view.js:348-350) and redraw() never re-measures, so an
   animated disclosure is measured at its collapsed height and the tiles
   overlap. map-view.js is frozen until P9; this opts the map's own
   disclosure out instead. .mbox is the leaf tile's own class
   (map-view.js:366/381) and details.optional is appended inside it via
   _leafDetail, so this selector reaches it as a descendant. */
.mbox details.optional::details-content { transition: none; }

/* P4 Task 2: The global reduced-motion guard — last rule in the file.

   This is a backstop, not the primary guarantee: P2 already zeroes
   --dur-1/2/3 to 1ms under prefers-reduced-motion: reduce in all three :root
   copies, so any rule reading those tokens is already guarded by
   construction. This blanket rule exists only to catch hard-coded durations
   (render.py's .mbox transition; Task 6 covers that file's own copy of this
   guard, since render.py is self-contained and cannot link this stylesheet).
   One backstop plus P2's tokens is the whole mechanism — no per-rule
   @media (prefers-reduced-motion) anywhere in this phase, because a per-rule
   copy is the thing that rots.

   Every declaration below is !important, so it wins on importance in both
   link orders this file ships in: web/*.html links theme.css before its own
   <style>, engine/editor.html links it after (:289) — importance is decided
   before specificity or source order, so this rule beats both, including
   editor.html's .mbox transition (:151) and .mchev (:167). */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }
}
