/* FORGE MOTION — the styles the motion module needs.

   Timings are the Calculating study's own: a 3.1s cycle with .19s between
   characters, the crest at 7% and the settle by 24%. Those were tuned; the
   numbers are not arbitrary and should not drift.

   These are AMBIENT loops, and by the intensity ladder ambient motion belongs
   to the loudest channel only. Applied to a verdict word or a running state,
   never to body copy — a page where everything shimmers has no signal left. */

/* ── the heat wave through a line of text ────────────────────────────────── */
.flow{--flow-period:3.1s}
.flow-ch{
  color:inherit;
  animation:fmLetter var(--flow-period) ease-in-out infinite;
  animation-delay:calc(var(--i) * .19s);
  /* the glyph must not reflow as it lights, or the whole line jitters */
  will-change:color,text-shadow}
@keyframes fmLetter{
  0%,100%{color:inherit;text-shadow:none}
  7%     {color:#FFF6D8;
          text-shadow:0 0 18px rgba(242,209,139,.80),0 0 52px rgba(205,150,64,.45)}
  24%    {color:#DDCDA8;text-shadow:0 0 10px rgba(242,209,139,.22)}
}

/* the trailing dots of a working state carry their own faster cycle */
.flow-dot{
  display:inline-block;
  animation:fmDot 1.8s ease-in-out infinite;
  animation-delay:calc(var(--i) * .19s)}
@keyframes fmDot{
  0%,60%,100%{opacity:.25}
  30%        {opacity:1;color:#F2D18B;text-shadow:0 0 12px rgba(242,209,139,.6)}
}

/* ── THE TRACE ───────────────────────────────────────────────────────────
   The Kill Floor's brimming plate, dialled down for a control.

   SCOPE, set by John 2026-08-12 on sight: every control in the app, not just
   the primary. I had written the opposite rule here — one traced control per
   surface — on the reasoning that a channel only means something while it stays
   rare. That reasoning holds for AMBIENT motion, which runs whether you are
   there or not; it does not hold for this, because the trace draws nothing at
   rest and only ever answers a pointer. One control is lit at a time because
   only one is under the cursor, so the channel stays rare by construction.

   The exclusion is window chrome: minimise, maximise and close are the
   operating system's controls sitting in our frame, and lighting them up claims
   they are ours.

   Dialled down from the floor deliberately: 1px instead of the plate's frame,
   roughly a third of the alpha, and a slower lap. A drop standing in a beam is
   the loudest thing in the app; a button waking under a pointer is not, and it
   must not read as though it were.

   Opt in with class="fm-trace". It draws nothing at rest.

   Fold headers, tabs, rail items and row headers take the same 1px rim
   (JSV 2026-09-01): hover is the travelling edge, never a wash. */
@property --fmang{syntax:'<angle>';inherits:false;initial-value:0deg}

.fm-trace{position:relative}

/* A REAL ELEMENT, not ::after.

   The first version used .fm-trace::after, and twenty-six of fifty-seven
   controls in this app already own an ::after — a 1-3px underline, a corner
   tick, a caret. Two rules then merged: mine supplied the conic gradient, the
   room's supplied the geometry, and the result was the crest painting into a
   3px strip. That is the red line cutting through Exalted Orb, the filter
   glyph, the mod rows and the segmented buttons.

   An injected child cannot collide with a rule the room already wrote. */
.fm-edge{position:absolute;inset:0;border-radius:inherit;pointer-events:none;
  padding:1px;opacity:0;box-sizing:border-box;
  background:conic-gradient(from var(--fmang),
    rgba(226,96,44,.06) 0deg,
    color-mix(in srgb, var(--fire,#E2481A) 52%, #3A3060) 40deg,
    var(--fire,#E2481A) 58deg,
    color-mix(in srgb, var(--fire,#E2481A) 46%, #FFF6DC) 66deg,
    var(--fire,#E2481A) 74deg,
    color-mix(in srgb, var(--fire,#E2481A) 52%, #3A3060) 94deg,
    rgba(226,96,44,.06) 140deg,
    rgba(226,96,44,.06) 360deg);
  -webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
  -webkit-mask-composite:xor;
  mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);
  mask-composite:exclude;
  transition:opacity 180ms cubic-bezier(.2,0,0,1)}

.fm-trace:hover > .fm-edge,
.fm-trace:focus-visible > .fm-edge{opacity:.7;animation:fmTrace 3.4s linear infinite}
.fm-trace:active > .fm-edge{opacity:1;animation:fmTrace 620ms linear 1}
@keyframes fmTrace{to{--fmang:360deg}}

/* RAIL GLYPH GLOW — behind the icon, never a wash over the label.
   JSV 2026-09-01: a glow behind an icon is fine when it makes the icon
   feel more powerful, like CONTINUE and RESTART. The sweep removed the
   rising ::after strips; this is a ::before sized to the glyph box plus
   6px a side, clamped so it cannot reach the word under the mark. */
html body .rail .rm{anchor-scope:--rm-glyph}
html body .rail .rm b{
  anchor-name:--rm-glyph;
  width:max-content;margin-left:auto;margin-right:auto
}
html body .rail .rm::before{
  content:"";position:absolute;pointer-events:none;
  position-anchor:--rm-glyph;
  left:calc(anchor(left) - 6px);top:calc(anchor(top) - 6px);
  width:calc(anchor-size(width) + 12px);
  /* 6px above, left and right. The label sits 2px under the glyph box, so
     the bottom is clamped flush with the glyph — inside glyph+6 and clear
     of the word. */
  height:calc(anchor-size(height) + 6px);
  max-height:calc(anchor-size(height) + 6px);
  background:radial-gradient(circle, var(--fire,#E2481A) 0%, transparent 70%);
  opacity:0;transition:opacity 120ms cubic-bezier(.2,0,0,1)
}
html body .rail .rm:hover:not(.on)::before{
  opacity:.12;transition-duration:240ms
}
html body .rail .rm.on::before{opacity:.22}

/* ── the drifting motes ──────────────────────────────────────────────────── */
.fm-embers{position:absolute;inset:0;width:100%;height:100%;
  pointer-events:none;z-index:1}

/* ── the burst, while something is genuinely being worked out ────────────── */
.fm-burst{position:absolute;inset:0;width:100%;height:100%;
  pointer-events:none;z-index:2;opacity:0;
  transition:opacity 260ms cubic-bezier(.2,0,0,1)}
.fm-burst.on{opacity:1}

@media (prefers-reduced-motion:reduce){
  .flow-ch,.flow-dot{animation:none;color:inherit;text-shadow:none}
  .fm-burst{transition:none}
  /* the control still answers the pointer, it just does not travel */
  .fm-edge{transition:none;animation:none}
  .fm-trace:hover > .fm-edge,.fm-trace:focus-visible > .fm-edge{opacity:.55;animation:none}
  html body .rail .rm::before{transition:none}
}
html[data-reduce-fx] .flow-ch, html[data-reduce-fx] .flow-dot,
html[data-reduce-fx] .fm-edge,
html[data-fk-motion="off"] .flow-ch, html[data-fk-motion="off"] .flow-dot,
html[data-fk-motion="off"] .fm-edge{animation:none}
html[data-reduce-fx] .rail .rm::before,
html[data-fk-motion="off"] .rail .rm::before,
html[data-fk-reduce="on"] .rail .rm::before{transition:none}

/* ANSWERS ARRIVE.

   A search result that is simply present reads as a wall that was always
   there. A short rise, staggered down the list, reads as the book answering.
   The stagger is capped in paint() at eighteen steps so a 300-row page does
   not schedule the last row minutes away; past that they land together, which
   is what the eye expects once a list is longer than a screen.

   Transform and opacity only, so this stays off the layout and compositing
   path. Under the ExileCraft ceremony ceiling: 240ms, plus at most 396ms of
   stagger. */
@keyframes fmRise{
  from{opacity:0;transform:translateY(6px)}
  to{opacity:1;transform:none}
}
.rising{animation:fmRise 240ms cubic-bezier(.22,.61,.36,1) both;animation-delay:var(--rise,0ms)}

@media (prefers-reduced-motion: reduce){
  .rising{animation:none;opacity:1;transform:none}
}

/* THE SCROLLBARS.

   One room out of thirteen styled its own; everywhere else Windows drew its
   default light bar straight through a black room, which is the ugly bar on
   the plan tab. A scrollbar is chrome, not content: it sits in the same iron
   as the panel it rides, brightens on hover so it can still be grabbed, and
   never competes with the ember the app reserves for meaning. */
*{scrollbar-width:thin;scrollbar-color:#2E2A26 transparent}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:#2A2521;border-radius:5px;
  border:2px solid transparent;background-clip:content-box}
::-webkit-scrollbar-thumb:hover{background:#3C342D;background-clip:content-box}
::-webkit-scrollbar-corner{background:transparent}

/* ---- the shared chrome leaves ------------------------------------------
   forge-live.js appends these when a room's top bar does not already carry
   them, so the league stamp and the crafting-data date are on every screen
   rather than on the six rooms that happened to have the markup typed in.
   Sized to sit quietly beside the price age, which is the chip they stand
   next to in every room. */
/* gap, not a trailing space: the label and the date are separate flex
   items here, and a flex container trims the space inside <i>Crafting
   data </i>, so every room above 1000px read "CRAFTING DATA2026-08-27"
   run together (panel-13 chair #3). The gap survives the label being
   hidden at narrow width because it only applies between items. */
.tbar .dataage,[data-dataage]{
  font:500 9.5px/1 var(--mono,ui-monospace,monospace);
  letter-spacing:.08em;text-transform:uppercase;
  color:var(--ink-4,#8a7f74);white-space:nowrap;
  display:inline-flex;align-items:center;gap:.35em
}
/* THE LEAGUE IS PART OF THE TRUTH BAR, and no rule anywhere owned it, so
   each room squeezed it out on its own at narrow width - it measured
   width 0 at 900px on journal and settings and vanished from the codex
   header, while the date and the rates age stayed (panel-13 chair #1,
   blocked by two judges). Stated here once, for every room. */
.tbar .league,.tbar [data-league]{
  white-space:nowrap;flex:0 0 auto;min-width:0
}
.tbar .dataage:empty,[data-dataage]:empty{display:none}
/* The crumb ellipsizes under pressure instead of being painted through;
   the containment itself lives on the authoritative chrome rules below
   (panel-20 chair #1). */
.tbar .crumb{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* In a clipped right-anchored row the LEFTMOST item is the first casualty.
   Buttons are conveniences the rail and hotkeys duplicate; the truth
   stamps (data date, league, rates age) are not. Buttons go leftmost so
   they clip first and the truth bar survives the squeeze. */
.tbar .right .tbtn{order:-1}
/* The chip used to go entirely at 1000px, because the whole phrase renders
   143.6px wide and the journal's bar cannot hold that below about 980px.
   That took the dataset DATE with it - and the date is the one thing the
   truth bar must always carry, on a window size the app itself ships a
   Compact bench for (panel-12 chair #2, blocked by two judges). The label
   words go instead. The date alone is ~72px and fits. */
/* was 1000px: the chair measured the bar clean at 1920 and colliding at
   1440, so the label words leave at the widths where the space actually
   runs out. The DATE always survives (truth-bar law). */
@media (max-width:1600px){
  .tbar .dataage .dstamp-l,[data-dataage] .dstamp-l{display:none}
  /* Short prefix so a dropped "Crafting data" still names the stamp. W6's
     finding was that a bare 2026-08-28 can read as today, the build date or
     the league date.
     COPY PASS: the prefix was "DATA ", which does not answer the question it
     was added to answer - this bar carries TWO dated facts, the crafting-data
     date and the currency-rates age, and "DATA" fits either. "CRAFT DATA"
     names this one and reads against the sibling chip's "Currency rates".
     PANEL 21 FIX 31: this noun used to fire only below 1000px, so the whole
     1000-1600 band - including 1440 - showed a bare date with no noun on it,
     the exact ambiguity the prefix exists to prevent. The label words stay
     dropped for space (the measured 1440 collision); the noun rides the date
     at every width below 1600 instead.
     PANEL 28: the noun is a real text node (.dstamp-n inside .dstamp-v),
     not ::before, so innerText and the accessible name both carry it.
     Visual is the same one-line prefix at 1440 and 900. */
  .tbar .dataage .dstamp-n,
  [data-dataage] .dstamp-n{
    display:inline;
    font-weight:500;
    white-space:nowrap
  }
}
@media (min-width:1601px){
  .tbar .dataage .dstamp-n,[data-dataage] .dstamp-n{display:none}
}
/* THE RATES AGE IS THE OTHER TRUTH STAMP. Rooms used to display:none the
   whole chip below ~1100. Keep the chip. forge-live writes #priceage as
   one text node, so a label-only drop is not available from CSS. */
.tbar #priceage,.tbar .age,.tbar #agetxt{
  white-space:nowrap;flex:0 0 auto
}

/* ---- one chrome band at every measured width --------------------------------
   Room styles wrap .tbar below ~1000px into two or three bands and strand
   window controls in an empty row, or let THE ROOM and #winctl run past
   innerWidth inside a clipping ancestor (body.scrollWidth stays honest).
   This file loads before each room style, so these selectors have to out-
   specify a single .tbar rule. Shed Find; keep the stamps, Refresh, THE
   ROOM, and the window cluster. Below 1180 the stamp and Refresh drop
   onto .chrome-sub (a second row) rather than computing none. */
/* overflow:hidden, both boxes (panel-20 chair #1, blocked four judges):
   the right-anchored cluster's surplus used to spill LEFT and print over
   the crumb (56px of ink collision at 1440) and, narrower, over the
   wordmark. Nothing legitimate is clipped - the room popover is a native
   popover in the top layer and the window cluster is position:fixed. */
html body .app > .tbar,
html body .app > header.tbar{
  display:flex;flex-wrap:nowrap;align-items:center;
  height:44px;max-height:44px;min-height:44px;
  max-width:100%;min-width:0;box-sizing:border-box;
  white-space:nowrap;overflow:hidden
}
html body .app > .tbar > .right,
html body .app > header.tbar > .right{
  display:flex;flex-wrap:nowrap;align-items:center;
  flex:0 0 auto;flex-shrink:0;min-width:auto;max-width:100%;height:44px;
  white-space:nowrap;overflow:visible;justify-content:flex-end
}
html body .tbar .brand,
html body .tbar .alpha-leaf,
html body .tbar .crumb,
html body .tbar .roomsw,
html body .tbar .wc,
html body .tbar .win,
html body .tbar .winctl,
html body .tbar #winctl,
html body .tbar .room-btn,
html body .tbar [popovertarget="room-pop"],
html body .tbar .tbtn,
html body .tbar .tb,
html body .tbar .mini,
html body .head .mini,
html body .tbar .leaguechip,
html body .tbar .age{
  flex:0 0 auto;white-space:nowrap
}
html body .tbar .league,
html body .tbar [data-league],
html body .tbar .leaguechip,
html body .tbar #priceage,
html body .tbar .age,
html body .tbar #agetxt,
html body .tbar .tbtn,
html body .tbar .tb,
html body .tbar .mini,
html body .head .mini{
  flex:0 0 auto;white-space:nowrap;overflow:visible
}
/* Kill Floor has no .tbar; its header buttons still wear .mini. Without a
   paint rule that reaches .head they stay the UA button (Arial, outset,
   rgb(240,240,240)). Background is --raise, never lighter. */
html body .head .mini{
  background:var(--raise,#1E1B18);border:1px solid var(--line-hi,#2E2A26);
  border-radius:3px;color:var(--ink-2,#D1C5A8);
  font:500 9.5px/1 var(--mono,Consolas,"Cascadia Mono",monospace);
  letter-spacing:.1em;text-transform:uppercase;padding:6px 8px;cursor:pointer;
  appearance:none;-webkit-appearance:none
}
html body .tbar .dataage,
html body .tbar [data-dataage]{
  flex:0 0 auto;white-space:nowrap;display:inline-flex
}
html body .tbar .dataage:empty,
html body .tbar [data-dataage]:empty{display:none}
html body .tbar #refreshprices{
  flex:0 0 auto;white-space:nowrap;-webkit-app-region:no-drag
}
/* second row under the 44px band: the crafting-data date and Refresh
   live here once the .right cluster would print through .brand. Empty
   until forge-live.js moves the leaves; :empty keeps the 44px lock. */
html body .tbar .chrome-sub{
  flex:1 0 100%;display:none;align-items:center;justify-content:flex-start;
  gap:12px;box-sizing:border-box;min-width:0;height:28px;max-width:100%;
  padding:0 12px 0 16px;border-top:1px solid var(--line,#221F1C);
  white-space:nowrap;-webkit-app-region:no-drag
}
html body .tbar .chrome-sub:not(:empty){display:flex}
html body .tbar .chrome-sub #refreshprices{margin-left:auto}
/* a two-ended row needs no dot between its ends; the moved separator
   would otherwise hang after the date with nothing to its right */
html body .tbar .chrome-sub > .chrome-sep{display:none}
html body .app > .tbar:has(.chrome-sub:not(:empty)),
html body .app > header.tbar:has(.chrome-sub:not(:empty)){
  flex-wrap:wrap;height:auto;max-height:none;min-height:44px;overflow:hidden
}
html body .app > .tbar:has(.chrome-sub:not(:empty)) > .right,
html body .app > header.tbar:has(.chrome-sub:not(:empty)) > .right{
  height:44px
}
html body .tbar .wc button,
html body .tbar .win button,
html body .tbar .winctl button,
html body .tbar #winctl button,
html body .tbar .wc i,
html body .tbar .wbtn{
  width:32px;height:44px
}
html body .rail .rm,
html body .fm-trace.fhd,
html body .fm-trace.famhd,
html body .fm-trace.rowhd,
html body .fm-trace.tab,
html body .fm-trace.ptab{
  overflow:visible;min-width:0
}
@media (max-width:1180px){
  html body .app{grid-template-rows:44px minmax(0,1fr) auto}
  html body .tbar{gap:8px}
  html body .tbar .right{gap:8px}
  html body .tbar .lamp,
  html body .tbar .leaguechip .lamp{display:none}
  html body .tbar [data-fh-click*="qf"]{display:none}
  html body .rail .rm{letter-spacing:.06em}
  /* TRUTH CONTENT NEVER TRUNCATES (panel 16, W6): the ellipsis caps here
     rendered 'CURRENCY RATES 3...' with ESTIMATED cut off at 900 - the
     provenance word forge-live.js deliberately moved onto the chip FACE
     was being clipped straight back off it. The caps are gone; the crumb
     (a nav convenience The Room button duplicates) steps out to fund the
     width instead. Find still hides; Refresh stays reachable - if it has
     not yet lifted into .chrome-sub it shrinks to its glyph rather than
     computing none. */
  html body .tbar .crumb{display:none}
  html body .tbar .right #refreshprices{
    font-size:0;letter-spacing:0;padding:4px 6px;min-width:22px;line-height:1
  }
  html body .tbar .right #refreshprices::before{
    content:"↻";font-size:13px;letter-spacing:0
  }
}
@media (max-width:1000px){
  .tbar .dataage .dstamp-l,[data-dataage] .dstamp-l{display:none}
  /* the "CRAFT DATA " noun now rides the 1600px band above, so this block
     only keeps the label words dropped. The 22ch/20ch caps that used to sit
     here clipped the league name and the price provenance at 900 - truth
     content, removed with the 1180 ellipsis block above */
}
@media (max-width:900px){
  html body .tbar{gap:6px;padding-left:12px}
  html body .tbar .right{gap:6px}
  html body .tbar .brand{letter-spacing:.08em}
  html body .tbar .wc button,
  html body .tbar .win button,
  html body .tbar .winctl button,
  html body .tbar #winctl button,
  html body .tbar .wc i,
  html body .tbar .wbtn{width:28px}
  /* fallback if the stamp is still in .right: the noun used to ride
     ::before and clipped to RAFT DATA. It is a real text node now, so
     this rule no longer drops it; chrome-sub is the overflow path. */
  html body .tbar .right .dataage .dstamp-v::before,
  html body .tbar .right [data-dataage] .dstamp-v::before{content:none}
}

/* ---- AreaLevel split (Filter Forge + Kill Floor) ------------------------
   The exported file already gates blocks on AreaLevel <= 64 / >= 65. These
   nodes are the surface for that split. Count <b> tags stay empty until
   the wire writes the engine's own gated-block lengths. */
.areasplit{margin-top:12px;min-width:0;max-width:100%}
.areasplit .splitk{font:500 9.5px/1 var(--mono,ui-monospace,monospace);
  letter-spacing:.16em;text-transform:uppercase;color:var(--ink-4,#7E766B);
  margin:0 0 8px}
.splitseg{display:flex;flex-wrap:wrap;gap:0;min-width:0;max-width:100%}
.splitbtn{flex:1 1 7em;min-width:0;display:flex;flex-direction:column;
  align-items:flex-start;gap:4px;background:none;border:0;
  border-bottom:2px solid var(--line,#221F1C);border-radius:0;cursor:pointer;
  padding:8px 10px;color:var(--ink-4,#7E766B);
  font:500 13px/1 var(--ui,system-ui,sans-serif);position:relative}
.splitbtn:has(input:checked),.splitbtn.on{
  color:var(--ink,#EDE5D4);border-bottom-color:var(--armed,#A83415)}
.splitbtn input{position:absolute;opacity:0;width:1px;height:1px;
  overflow:hidden;clip:rect(0,0,0,0)}
.splitbtn[aria-disabled="true"],.splitbtn:has(input:disabled){cursor:default}
.splitbtn b:empty{display:none}
.splitbtn b{font:500 13px/1 var(--mono,ui-monospace,monospace);color:var(--ink,#EDE5D4);
  font-variant-numeric:tabular-nums}
.splitnote{margin:8px 0 0;font:400 11px/1.5 var(--ui,system-ui,sans-serif);
  color:var(--ink-4,#7E766B);max-width:70ch}
.splitnote:empty{display:none}
.zonelab{font:500 9.5px/1 var(--mono,ui-monospace,monospace);letter-spacing:.12em;
  text-transform:uppercase;color:var(--ink-4,#7E766B);flex:0 0 auto}
.zonein{width:64px;min-width:0;flex:0 0 auto;background:var(--raise,#1E1B18);
  border:1px solid var(--line-hi,#2E2A26);border-radius:3px;color:var(--ink,#EDE5D4);
  font:500 13px/1 var(--mono,ui-monospace,monospace);padding:6px 8px}
.zonein:focus-visible{outline:1px solid var(--armed,#A83415);border-color:var(--armed,#A83415)}
.zonein:disabled{opacity:.38;cursor:default}
#zonenote:empty,#zone-gated-n:empty{display:none}
#zone-gated-n{font:500 11px/1 var(--mono,ui-monospace,monospace);
  color:var(--ink-2,#D1C5A8);font-variant-numeric:tabular-nums}

/* ---- per-target boolean groups (Plan) ----------------------------------
   Chips exist once the wire emits .tgrp inside a .tline. Until then the
   3-column target row is unchanged. */
.tgrp{display:inline-flex;gap:2px;flex:0 0 auto;align-items:center}
.tgb{background:none;border:0;border-bottom:2px solid var(--line,#221F1C);
  border-radius:0;cursor:pointer;color:var(--ink-4,#7E766B);
  font:500 9.5px/1 var(--mono,ui-monospace,monospace);padding:4px 6px 6px}
.tgb.on,.tgb[aria-pressed="true"]{color:var(--ink,#EDE5D4);
  border-bottom-color:var(--armed,#A83415)}
.groupstrip{margin-top:10px;min-width:0;max-width:100%}
.groupread{margin:0;font:400 13px/1.55 var(--ui,system-ui,sans-serif);
  color:var(--ink-2,#D1C5A8);max-width:70ch}
.groupread:empty{display:none}
@media (max-width:900px){
  .splitbtn{padding:6px 8px}
  .zonein{width:56px;padding:6px}
}

/* r27 space: lock the 44 / 1fr / 58 chrome so extra children (.rooms,
   a mounted paste node) cannot steal the 1fr track and leave the dock
   sitting on the stage. Paste stays a fixed strip above the 58px row. */
/* row 3 is auto, not a fixed 58px: rooms whose dock is NOT a grid child
   (fixed docks, journal's in-flow footer) were reserving 58px of phantom
   space - a dead band under the paste strip on every such room (round-27
   sweep). A grid-child dock still gets its natural height. */
/* r28: the 3-row 44px lock is for rooms that ship a title bar. Kill Floor
   has no .tbar — its first child is .head — so the same lock crushed the
   H1 into 44px and let .floorwrap paint over it. Scope the lock. */
html body .app:has(> .tbar){
  height:100%;max-height:100%;
  display:grid;grid-template-columns:minmax(0,1fr);
  grid-template-rows:44px minmax(0,1fr);
  grid-auto-rows:0
}
html body .app:has(> .tbar):has(> .dock){
  grid-template-rows:44px minmax(0,1fr) 58px
}
html body .app:has(> .tbar) > .tbar,
html body .app:has(> .tbar) > header.tbar{grid-row:1;grid-column:1}
html body .app:has(> .tbar) > .body{grid-row:2;grid-column:1;min-height:0;min-width:0}
html body .app:has(> .tbar) > .dock{grid-row:3;grid-column:1;height:58px;max-height:58px;min-height:58px}
/* :has() cannot nest inside :has() (the rule is dropped whole and the
   44px row clips the second line), so the populated sub-row is matched
   with one relative selector reaching through the bar. */
html body .app:has(> .tbar > .chrome-sub:not(:empty)){
  grid-template-rows:auto minmax(0,1fr)
}
html body .app:has(> .tbar > .chrome-sub:not(:empty)):has(> .dock){
  grid-template-rows:auto minmax(0,1fr) 58px
}
html body .app:has(> .tbar) > .rooms,
html body .app:has(> .tbar) > #room-pop{grid-row:1;grid-column:1}
/* Paste is position:fixed so it must not auto-place into an implicit 4th
   row (that stranded the dock ~70px above the strip). Park it on row 1
   of the chrome grid; the coordinates still come from `bottom`. */
html body .app > #forge-paste{grid-row:1;grid-column:1}
html body #forge-paste{
  position:fixed;left:0;right:0;bottom:0;z-index:60
}
/* The grid-row dock and the paste lane stack, they do not compensate.
   forge-live's lane sets body{padding-bottom:73px} so the app ends 73px
   above the window and the fixed paste strip owns that band. Its
   .dock{bottom:73px} is for docks that are themselves fixed to the window;
   a grid-row dock is position:relative, so the same rule shoved it 73px
   up over the room's last rows, and a 58px lift on the paste strip then
   left a dead band under it. Measured at 1440x900 in the planner, the
   workshop and the codex: dock 696-754, paste 769-842, nothing 842-900.
   With both compensations off the rows read top to bottom: main, dock
   row (58px), paste (73px), window edge. */
html[data-forge-lane] body .app:has(> .tbar) > .dock{bottom:0}
html body:has(.app > .dock) #forge-paste{bottom:0}

/* ---- round 37 D-A: the shared chrome in motion ----------------------------
   Three mechanisms landed on the chrome every room shares: the rail's
   selection pill, the room-to-room change, and the first-paint reveal.
   New rules only; nothing above this line is touched. Transform and opacity
   animate, never a layout property, so every motion here stays on the
   compositor. All hooks are set at runtime by forge-live.js (a class or a
   data attribute on an element the room already ships); no room HTML carries
   any of this. */

:root{
  /* pick 5, the pill's travel: the reference's fitted curve, unrounded.
     It shares its out-handle (0,1) with --ease; only the slow entry differs,
     and that entry is the travel's whole character. --ease itself does not
     move — it is the hard snap the whole app uses. */
  --ease-glide:cubic-bezier(.62,.02,0,1);
  /* pick 4, the material-change sweep: the reference's measured sine, and a
     duration derived from its measured speed (1683px/s across a 1920 frame)
     rather than its 1000ms showcase tempo. */
  --fm-sweep:520ms;
  --ease-sweep:cubic-bezier(.37,0,.63,1);
  /* pick 4, the first-paint wave: --t3, and the return curve the reference
     measured, which is the curve .rising already carries. */
  --fm-plate:320ms;
}

/* == PICK 5 — the rail's selection pill =====================================
   The rail is page navigations, not tabs, so the travel lands as an ARRIVAL:
   forge-live.js hands the departing item's index across the document boundary
   in sessionStorage, places the pill there with the transition suppressed,
   and moves it to the current item on the first frame. The pill is a real
   child of .rail — an injected element cannot collide with a rule a room
   already wrote (the same reason .fm-edge is an element, and .rm::before is
   already the glyph glow). Selection, not hover: .fm-edge stays the
   travelling rim on hover, and neither does the other's job. */
html body .rail{position:relative}
.fm-pill{
  position:absolute;top:0;right:0;width:3px;border-radius:2px;
  background:var(--fire,#E2481A);pointer-events:none;
  /* the bloom rides the same element, so it travels for free: 15px of reach
     at the measured peak lift, 26px carrying the long tail */
  /* derived from --fire, never a literal beside it (PANEL 32, item 21) */
  box-shadow:0 0 15px 0 color-mix(in srgb,var(--fire,#E2481A) 19%,transparent),0 0 26px 0 color-mix(in srgb,var(--fire,#E2481A) 6%,transparent);
  /* fixed duration however many items the trip crosses — the reference's own
     rule (4.4x the distance bought 1.16x the duration), and what keeps an
     eight-item rail calm. transform only, so the travel composites. */
  transition:transform var(--t3,320ms) var(--ease-glide)
}
.fm-pill.set{transition:none}

/* the ink handoff: the destination does not brighten until the pill has
   arrived. 240ms is 75% of the travel (the reference lights the incoming
   label at 71.4%); 200ms holds its out-to-in ratio against the 120ms ramp
   the room already runs on the way out. Longhands only — the room's own
   transition shorthand and curve stay exactly as shipped. */
html body .rail .rm.on{transition-duration:var(--t2,200ms);transition-delay:240ms}
html body .rail .rm.on b{transition-duration:var(--t2,200ms);transition-delay:240ms}

/* the glyph settle: our rail glyphs are single serif characters, so the
   reference's path morph cannot land — the envelope can. scaleY only, origin
   at the bottom so the label under the glyph never moves; amplitude pulled
   in from the measured 0.77/1.12 because a 17px glyph at those amplitudes
   reads as jitter, not settle. Same 240ms delay as the ink. */
html body .rail .rm.on b{
  animation:fmGlyphSettle 280ms var(--ease) 240ms both;
  transform-origin:bottom
}
@keyframes fmGlyphSettle{
  0%{transform:scaleY(1)}
  25%{transform:scaleY(.82)}
  45%{transform:scaleY(1.10)}
  100%{transform:scaleY(1)}
}

/* == PICK 1 — the room change ==============================================
   Rooms are separate documents, so the change is an out phase on the dying
   document and an in phase on the new one, both owned by forge-live.js.
   The reference's whole trick is restraint: the outgoing surface never
   moves, one layer carries the transition, and the body ground between
   documents is the same near-black as both rooms. The amplitude and the
   durations are ours (--t1/--t3, a 1.5% settle); the staging, the curve and
   the single moving layer are the reference's. */

/* B — the out dissolve: the room fades to 0 over --t1, then navigation
   fires (the reference's equivalent beat measured 100ms). Under any reduced
   motion gate forge-live.js does not intercept the click at all, so the
   delay never exists without the fade. */
.fm-out{opacity:0;transition:opacity var(--t1,120ms) var(--ease)}

/* A — the room plate settles in: one container, scale .985 to 1 and fade,
   --t3 with --ease. The classes land before first paint and are dropped on
   transitionend, so the container is a compositing layer for 320ms and then
   stops being one. */
.fm-in{opacity:0;transform:scaleX(.985)}
.fm-in.go{opacity:1;transform:none;will-change:opacity,transform;
  transition:opacity var(--t3,320ms) var(--ease),transform var(--t3,320ms) var(--ease)}

/* ROUND 37 WAVE 12, third layer: on every room except the Kill Floor this
   container is `.app > .body` (class="body"), which also wraps .rail - CSS
   opacity has no inheritance, but it DOES compound down the render tree, so
   while the container faded 0->1 over --t3 the rail rendered at that same
   reduced opacity too (invisible to a `getComputedStyle(rail).opacity`
   check, which only ever reads the rail's OWN rule - never true black, but
   the same "goes invisible between rooms" defect through a second door).
   scaleX(.985) on the same container resizes its whole render box by 1.5%
   for the same 320ms, which the Layout Instability API scores as a real
   shift for the container AND for whatever sits at its edge (measured:
   DIV.body + SPAN.right at bench-result/codex 900x900, CLS up to 0.29).
   Pin the container opaque and untransformed, and move the identical fade
   onto main.stage and aside.panel instead - descendants, not ancestors of
   .rail, so neither the opacity compounding nor the resize ever reaches it.
   The squeeze (scaleX) is dropped rather than moved: main.stage already
   carries its own scaleY from D below (`.fm-in-y`, added by the same
   fmArrive() call), and a second transform rule on the same element cannot
   compose with that one - CSS keeps only the more specific value, so
   redirecting scaleX here would have replaced main.stage's vertical axis
   lead instead of running beside it. Moving scaleX onto aside.panel was
   worse: every room's own `.panel{transform:translateX(...)}` (specificity
   0,1,0) holds the CLOSED, off-screen position, and this rule at 0,3,1
   would have replaced that outright, parking the closed panel on-screen at
   ~98.5% scale. The fade (this layer) plus main.stage's own axis lead
   (D, untouched) still read as one settling plate; only the imperceptible
   1.5% horizontal squeeze on the outer frame is gone, and only for the
   rooms this layer applies to. The Kill Floor's own container (.floorwrap,
   which IS the content, not a chrome wrapper) is untouched: .fm-in{opacity:0;
   transform:scaleX(.985)} still applies to it exactly as shipped. */
.body.fm-in,.body.fm-in.go{opacity:1;transform:none}
.body.fm-in main.stage,.body.fm-in aside.panel{opacity:0}
.body.fm-in.go main.stage,.body.fm-in.go aside.panel{
  opacity:1;transition:opacity var(--t3,320ms) var(--ease)
}

/* D — the axis lead: the vertical squash rides one nested wrapper 40ms
   behind the container's horizontal one (the reference's measured peak lead
   was +0.131 of progress; 40ms of 320ms is 0.125). */
.fm-in-y{transform:scaleY(.985)}
.fm-in-y.go{transform:none;will-change:transform;
  transition:transform var(--t3,320ms) var(--ease) 40ms}

/* C — the corner vignette clearing: the corners arrive out of --shell rather
   than out of a blur, opacity capped at .55 so it reads as weight in the
   corners, not fog. A real injected element, never a ::after, for the same
   collision reason as the pill. */
.fm-vig{position:absolute;inset:0;pointer-events:none;z-index:5;opacity:.55;
  background:radial-gradient(120% 100% at 50% 45%,transparent 62%,var(--shell,#100E0D) 100%)}
.fm-vig.go{opacity:0;transition:opacity var(--t3,320ms) var(--ease)}

/* E — the dock plate sweep: a solid --shell bar crosses the incoming room's
   dock strip and leaves; what was underneath was there the whole time. Two
   nested elements carry the two stages — the outer opens origin left, the
   inner shuts origin right 140ms in (the reference's 0.71 offset ratio
   applied to --t2), so the left edge chases the right edge off the strip. */
.fm-docksw{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:6}
.fm-docksw>i{position:absolute;inset:0;background:var(--shell,#100E0D);
  transform-origin:left;animation:fmDockOpen var(--t2,200ms) var(--ease) both}
.fm-docksw>i>b{position:absolute;inset:0;background:inherit;
  transform-origin:right;
  animation:fmDockShut var(--t2,200ms) cubic-bezier(.22,.61,.36,1) 140ms both}
@keyframes fmDockOpen{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes fmDockShut{from{transform:scaleX(1)}to{transform:scaleX(0)}}

/* F — the chrome stagger: after the plate has landed, the top bar's leaves
   fade up at 200ms and the right-hand chips at 480ms, --t1 each, opacity
   only. The last chip is done at 600ms, inside the ceremony ceiling this
   file already states (240ms plus at most 396ms of stagger). Window chrome
   is excluded by the same rule the trace uses — those controls are the
   operating system's, and they do not take our entrances. */
.fm-cpre [data-fm-c]{opacity:0}
.fm-cpre.go [data-fm-c]{opacity:1;transition:opacity var(--t1,120ms) var(--ease)}
.fm-cpre.go [data-fm-c="1"]{transition-delay:var(--t2,200ms)}
.fm-cpre.go [data-fm-c="2"]{transition-delay:480ms}

/* == PICK 4 — the sweep and the first-paint wave ===========================
   The wave on first paint (once per load, never on a re-render) serves every
   room. The sweep on a material change is Settings-only. The stage flips its
   tokens instantly underneath; one full-stage plate painted with the
   OUTGOING ground slides out to the right with a hard edge, so the room
   re-materialises outward from the rail, which is where the reference's
   line starts too. */
.fm-sweep{position:fixed;inset:0;z-index:40;pointer-events:none;
  background:var(--sweep-from,#100E0D);transform:translateX(0);will-change:transform}
.fm-sweep.go{animation:fmSweep var(--fm-sweep) var(--ease-sweep) both}
@keyframes fmSweep{to{transform:translateX(100%)}}

/* the things that float above the stage cannot take the line, so they carry
   their own cross-fade on the reference's own delay ratio (0.35 of the
   sweep, rounded to 180ms). The class rides the element only for the life of
   the sweep, so hover feedback elsewhere keeps its own ramp. */
html body .fm-float{transition:background-color var(--t3,320ms) 180ms var(--ease),
  color var(--t3,320ms) 180ms var(--ease),
  border-color var(--t3,320ms) 180ms var(--ease)}

/* the first-paint wave: the room's top-level panels rise 12px and fade,
   staggered down the room, the whole reveal under the sweep's measured
   total. The curve is the one the reference measured for its own re-entry,
   which .rising already ships. */
.fm-wave{animation:fmWave var(--fm-plate) cubic-bezier(.22,.61,.36,1) both;
  animation-delay:var(--wave-d,0ms)}
@keyframes fmWave{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}

/* == reduced motion: every mechanism above resolves to a fade or nothing ===
   Three gates, the same three the rest of the app honours: the OS query,
   data-reduce-fx, and the in-app data-fk-motion="off". The reduced state is
   the SETTLED state — the pill still lands on the active item, the room is
   fully painted, the theme still changes in one frame; only the travel is
   gone. The ink delay collapses to 0 so a reduced room is not briefly
   unlabelled. forge-live.js also skips the JS side under the same gates, so
   the plate is never inserted rather than run at zero duration. */
@media (prefers-reduced-motion:reduce){
  .fm-pill{transition:none}
  html body .rail .rm.on,html body .rail .rm.on b{transition-delay:0s}
  html body .rail .rm.on b{animation:none}
  .fm-in,.fm-in.go,.fm-in-y,.fm-in-y.go,.fm-vig,.fm-vig.go{opacity:1;transform:none;transition:none}
  .fm-cpre [data-fm-c],.fm-cpre.go [data-fm-c]{opacity:1;transition:none}
  .fm-docksw,.fm-docksw>i,.fm-docksw>i>b,.fm-sweep{display:none;animation:none}
  .fm-wave{animation:none;opacity:1;transform:none}
}
html[data-reduce-fx] .fm-pill,html[data-fk-motion="off"] .fm-pill,
html[data-fk-reduce="on"] .fm-pill{transition:none}
html[data-reduce-fx] .rail .rm.on,html[data-reduce-fx] .rail .rm.on b,
html[data-fk-motion="off"] .rail .rm.on,html[data-fk-motion="off"] .rail .rm.on b,
html[data-fk-reduce="on"] .rail .rm.on,html[data-fk-reduce="on"] .rail .rm.on b{
  transition-delay:0s}
html[data-reduce-fx] .rail .rm.on b,html[data-fk-motion="off"] .rail .rm.on b,
html[data-fk-reduce="on"] .rail .rm.on b{animation:none}
html[data-reduce-fx] .fm-in,html[data-reduce-fx] .fm-in.go,
html[data-reduce-fx] .fm-in-y,html[data-reduce-fx] .fm-in-y.go,
html[data-reduce-fx] .fm-vig,html[data-reduce-fx] .fm-vig.go,
html[data-fk-motion="off"] .fm-in,html[data-fk-motion="off"] .fm-in.go,
html[data-fk-motion="off"] .fm-in-y,html[data-fk-motion="off"] .fm-in-y.go,
html[data-fk-motion="off"] .fm-vig,html[data-fk-motion="off"] .fm-vig.go{
  opacity:1;transform:none;transition:none}
html[data-reduce-fx] .fm-cpre [data-fm-c],html[data-reduce-fx] .fm-cpre.go [data-fm-c],
html[data-fk-motion="off"] .fm-cpre [data-fm-c],
html[data-fk-motion="off"] .fm-cpre.go [data-fm-c]{opacity:1;transition:none}
html[data-reduce-fx] .fm-docksw,html[data-reduce-fx] .fm-sweep,
html[data-fk-motion="off"] .fm-docksw,html[data-fk-motion="off"] .fm-sweep{display:none}
html[data-reduce-fx] .fm-wave,html[data-fk-motion="off"] .fm-wave{animation:none;opacity:1;transform:none}

/* PANEL 33 item 13 / PANEL 34 item 6: the shared top chrome was three type
   ramps and two contrast tiers across the rooms (the league chip 11px in two
   rooms and 9.5px in two, the separator 11/9.5/13px, the smaller text brighter
   than the larger beside it). One ramp, one tier, in the one sheet every room
   loads: the chips, the stamps and the separators share 9.5px, --ink-3 and
   the stamp's own .08em tracking. The Kill Floor's .head sits outside .tbar,
   so the partial names .head and .bar as well. A later --ink-4 override on
   .chrome-sep was deleted: it left the separator at 4.3:1 after the chips
   had moved. */
html body .tbar .leaguechip, html body .tbar .league, html body .tbar [data-league], html body #league,
html body .tbar .chrome-sep,
html body .tbar .dataage, html body .tbar [data-dataage],
html body .tbar #priceage, html body .tbar .age, html body .tbar #agetxt,
html body .head .leaguechip, html body .head .league, html body .head [data-league],
html body .head .chrome-sep,
html body .head .dataage, html body .head [data-dataage],
html body .bar .leaguechip, html body .bar .league, html body .bar [data-league],
html body .bar .chrome-sep,
html body .bar .dataage, html body .bar [data-dataage]{
  font-size:9.5px;color:var(--ink-3);letter-spacing:.08em
}

/* PANEL 33 item 16: one segmented picker. Planner, workshop and the bench
   ALL/ANY control share .seg.hasbar; the travelling .segbar moves by
   transform only. Geometry of the bar (underline vs pill) stays in the
   room sheet. */
.seg.hasbar{position:relative}
.seg.hasbar > button{position:relative;z-index:1;color:var(--ink-3);
  transition:color var(--t2,200ms) var(--ease,cubic-bezier(.2,0,0,1))}
.seg.hasbar > button.on{color:var(--ink-1,var(--ink))}
.seg.hasbar > button:hover:not([disabled]){color:var(--ink-2)}
.seg.hasbar > .segbar{
  pointer-events:none;z-index:0;
  transform-origin:left center;
  transition:transform var(--t3,320ms) var(--ease-throw,cubic-bezier(.64,.34,.28,1.32))}
.seg.hasbar > .segbar[data-lean]{
  transition:transform var(--t1,120ms) var(--ease,cubic-bezier(.2,0,0,1))}


/* WAVE 10: the hold as a first-style rule. The JS hold lands at the end of
   forge-live.js, after the parser has already offered a frame; the rooms'
   own chrome (subjectbar, fresh tape, chips) then pops 100-145ms later and
   the shift tracker counts it under reduced motion. Holding from the first
   style resolution (this sheet is head-loaded) means no painted baseline
   exists before the settle, so the pop is never a measured shift. The
   marker is set by fmMarkArmed on both the waved and the reduced arm, and
   up front when the wave is skipped, so the hold always lifts.

   ROUND 37 WAVE 12: that hold caught the whole `body`, so a rail click swapped
   documents and the ENTIRE frame - rail, top bar, everything - went black
   until the new room armed. Reported as lag (JSV, 2026-09-04): a tab click
   should never black out the chrome around it. The rail and top bar never
   move between rooms and carry none of the first-paint wave's payload, so
   they never needed the hold in the first place; only the room's own
   content does - main.stage (every room), the item panel it can wave in
   (aside.panel), and the Kill Floor's ground, which carries no main.stage of
   its own: THREE direct children of its own `.app` join its wave, per
   test_round37_design_wave.mjs's own killfloor measurement (.floorwrap,
   .floorlegend, .seamwrap - `html body .app > .floorlegend` and `.app >
   .seamwrap` are killfloor's own rules, proof the class names are not
   shared at that depth by any other room; every other room's .seamwrap sits
   deep inside main.stage already). Scoping the hold to those five keeps the
   WAVE 10 promise (no painted baseline before the settle, so a late chip is
   never a measured shift) while the rail, the top bar and the room's ground
   plate (the .app/body paint under all of them) stay lit from the very
   first style. forge-live.js's fmHoldSel() carries the same selector list
   so the JS-side hold never re-hides the whole body either. Checked
   app-wide by tools/test_round37w12_no_black_between_rooms.mjs.

   The top bar's own async-fetched chips (league, price age, the craft-data
   stamp) are the one payload this hold still has to cover even though they
   sit inside the now-always-visible .tbar: they start empty and are
   populated off /api/league and the pricing routes, and with .tbar visible
   from frame one that population resizes `.right` (var-width text in a flex
   row) and shifts its visible siblings - exactly the pop WAVE 10 already
   named, just moved from "the whole chrome" to "this one cluster" now that
   the rest of the bar never hides. `.right` is a small, purely cosmetic
   sliver of the bar (Find, the window controls and the data chips), never
   the brand or the room switcher, so holding it alone still leaves the top
   bar's frame - background, border, brand, crumb - lit immediately. The
   Kill Floor carries the identical league/dataage pair in its own head,
   under `.headside > .right` rather than `.tbar > .right` (it has no tbar);
   same fetch, same pop, same fix.

   display:none was tried here instead of the visibility hold below, to stop
   the growing-while-hidden chip from ever wrapping `.tbar` to a second row
   (which resizes `.body`, sized as the grid's remaining `1fr`, and shifts
   whatever sits at either edge - measured intermittently, CLS up to 0.29
   across several rooms at 900x900). It measured WORSE and deterministic
   (every load, 0.11-0.22): going from zero width to its real flex width in
   one frame at arm time is a bigger, guaranteed shift than a box that was
   already reserving its (possibly still-growing) width the whole time.
   visibility:hidden keeps the box's space reserved throughout, so the
   reveal itself never moves anything; only the width GROWING while hidden
   can still wrap and resize .tbar, and only if that growth happens to cross
   the wrap threshold - real, but occasional rather than certain. */
html:not([data-fm-wave-armed]) main.stage,
html:not([data-fm-wave-armed]) aside.panel,
html:not([data-fm-wave-armed]) .floorwrap,
html:not([data-fm-wave-armed]) .app>.floorlegend,
html:not([data-fm-wave-armed]) .app>.seamwrap,
html:not([data-fm-wave-armed]) .tbar>.right,
html:not([data-fm-wave-armed]) header.tbar>.right,
html:not([data-fm-wave-armed]) .headside>.right{
  opacity:0!important;visibility:hidden!important
}

/* ROUND 37 WAVE 12, second layer: seven rooms (bench-result, codex, filters,
   journal, planner, sim, workshop) also mark .rail and .tbar `dimmable`, and
   each room's own inline sheet carries `body.arriving .dimmable{opacity:.28}`
   - the plate-settle dim (PICK 1) - lifted only when that room's own page.js
   drops `.arriving` off body, ~160ms after ITS OWN script runs near the foot
   of the document. With the whole-body hold gone this dim is no longer
   hidden behind it, so the rail and top bar sat at 28% opacity for most of
   a rail-click's first half-second: a softer repeat of the same defect this
   file just fixed, name for name. The rail and the top bar never needed
   that dim any more than they needed the hold, so this holds them at full
   opacity through it; every OTHER `.dimmable` element (the spine, the
   panel's own fields) keeps the plate-settle dim exactly as shipped. */
html body.arriving .rail.dimmable,
html body.arriving .tbar.dimmable{
  opacity:1!important
}

/* ROUND 37 WAVE 12: THE FOUR WORLDS, ONE SOURCE, EVERY ROOM.
   Settings picked a world and only Settings changed colour: it wrote the
   palette as inline styles on <html> (settings.page.js:184-185), and every
   other room only ever received the data-fk-theme stamp itself (forge-
   chrome.js's applyStoredSettings, read from forge.settings.theme). No sheet
   mapped that stamp to a palette, so the stamp was inert everywhere but the
   room that set it.

   This sheet is head-loaded by all thirteen rooms, so it is the one place
   a stamp can reach every room before first paint. Cast Iron is the default
   already baked into each room's own :root block, so it takes no rule here.
   The other three worlds are exactly settings.page.js's THEMES[*].v, token
   for token - one source; tools/test_round37w12_themes_appwide.mjs checks
   the two never drift apart. An attribute selector on html (specificity
   0,1,1) outranks a bare :root rule (0,1,0) regardless of which stylesheet
   loads first, so this wins over each room's own hardcoded Cast Iron
   :root even though that inline <style> block follows this link in <head>.

   Ordering proof: forge-chrome.js's stamp runs at DOMContentLoaded (first
   statement of boot()), which fires once parsing reaches the closing tag
   after forge-chrome.js's own end-of-body <script>. The first-style hold
   above only lifts once forge-live.js's fmMarkArmed runs - after
   document.fonts.ready and a requestAnimationFrame settle poll (or, failing
   that, forge-deadman.js's 2000ms failsafe) - so the stamp always lands
   long before the room becomes visible. Verified 2026-09-04: every one of
   the thirteen rooms loads forge-deadman.js in <head> and forge-live.js,
   forge-motion.js, forge-chrome.js in that fixed order at the end of body,
   so this ordering holds app-wide, not just in the room checked. */
html[data-fk-theme="daylight"]{
  /* ROUND 37 WAVE 12, panel 35: the base white was a flash bang on the bench
     and when an item pops; the planner read as parchment beside it (JSV,
     2026-09-04). Ground and heat[0] move to a warmer parchment tone -
     token for token identical to settings.page.js's THEMES daylight entry;
     tools/test_round37w12_themes_appwide.mjs checks the two never drift. */
  --void:#E9E1CF; --shell:#E1D8C4; --surf:#E6DECB; --raise:#D8CEB8; --raise-hi:#CBBFA6;
  --line:#CFC4AF; --line-hi:#B9AC94; --ink:#1A1714; --ink-2:#3B342B; --ink-3:#6E675C;
  --ink-4:#8A8276; --faint:#A79E8E; --idle:#DCCFBB; --wake:#D08A5E; --armed:#C4531F;
  --fire:#B03A12; --flash:#8E2408; --core:#C4531F; --on-fire:#F3E6D4; --gold:#8A6A16;
  --onwash:rgba(176,58,18,.14);
}
html[data-fk-theme="verdigris"]{
  --void:#090A07; --shell:#0F1109; --surf:#151810; --raise:#1D2116; --raise-hi:#262B1D;
  --line:#1B2015; --line-hi:#2E3624; --ink:#E9EDDF; --ink-2:#C6D0B7; --ink-3:#98A48C;
  --ink-4:#75806C; --faint:#4A5240; --idle:#0B1512; --wake:#17453C; --armed:#227064;
  --fire:#37A08B; --flash:#8FEAD4; --core:#CFF7EA; --on-fire:#04221C; --gold:#D4AF37;
  --onwash:rgba(55,160,139,.14);
}
html[data-fk-theme="quenched"]{
  --void:#06080B; --shell:#0C1015; --surf:#12171D; --raise:#1A2028; --raise-hi:#232B34;
  --line:#171D24; --line-hi:#2C3640; --ink:#E6ECF2; --ink-2:#BFC9D3; --ink-3:#94A0AB;
  --ink-4:#6F7C87; --faint:#445059; --idle:#08131A; --wake:#124656; --armed:#1B7086;
  --fire:#2AA0BE; --flash:#8FE4F5; --core:#D9F6FF; --on-fire:#04222C; --gold:#D4AF37;
  --onwash:rgba(42,160,190,.14);
}

/* ============================================================================
   ROUND 37 WAVE 12, PANEL 35, PIECE 1: THE LOADING PLATE.

   The WAVE 10/12 hold above (line ~867) sets opacity:0!important and
   visibility:hidden!important directly on main.stage while html carries no
   data-fm-wave-armed, so the stage is a flat empty plate until the wave
   arms. JSV reported that empty plate reads as lag (2026-09-04) and asked
   for something there while a room is truly loading - the ExileCraft alpha
   mark.

   Verified before writing this (a throwaway Playwright page, not shipped):
   a pseudo-element cannot escape its OWN host element's opacity:0, even
   when the pseudo declares its own opacity:1 and visibility:visible -
   opacity composites an element's entire rendered subtree, pseudo content
   included, to the parent's alpha. So a plate living on
   main.stage::before/::after needs main.stage's OWN opacity/visibility
   reopened while unarmed, or it stays invisible no matter what the
   pseudo-elements themselves declare.

   The rule below reopens main.stage itself (opacity:1, visibility:visible,
   background:var(--void) - the plate is the theme's own void, so nothing
   else changes per theme) but immediately re-hides every REAL child one
   level down with the same visibility:hidden the WAVE 10/12 hold already
   used, so the room's actual unfinished content still never flashes in
   before the wave arms; only the two new pseudo-elements are exempt.
   visibility:hidden keeps every child's box reserved, so this introduces
   no new layout shift - the same reasoning WAVE 10/12 used for the whole
   hold, just moved one level down. The Kill Floor has no main.stage, so
   none of this reaches it. Both selectors below share main.stage's exact
   specificity with the WAVE 10/12 hold and carry !important too, so being
   later in this file is what lets them win the tie. */
html:not([data-fm-wave-armed]) main.stage{
  opacity:1!important;visibility:visible!important;background:var(--void)!important;
}
html:not([data-fm-wave-armed]) main.stage>*{
  visibility:hidden!important;
}

/* THE PLATE'S MOTION (seat pass, 2026-09-04). Measured before this pass: the
   wordmark went 0 -> 1 in one frame at the delay (a 0s animation) and 1 -> 0
   in one frame on arm, because removing an animation never starts a
   transition (the base opacity was 0 before and after the arm), so the
   mark read as a blink. Now the two directions never share a property:
   the swell IN animates opacity from a base of 1 (fill both, so the 80ms
   delay holds it at 0 and the end holds it at 1), and the dissolve OUT
   transitions COLOUR (the word) and BACKGROUND-COLOUR (the line) to
   transparent over 360ms with visibility following 360ms later. A room
   that arms early simply starts dissolving while the swell is still
   rising; the product of the two curves is smooth in every case. */
main.stage::before,
main.stage::after{
  position:absolute;left:50%;pointer-events:none;opacity:1;visibility:hidden;
  transition:color 360ms cubic-bezier(.4,0,.2,1),background-color 360ms cubic-bezier(.4,0,.2,1),visibility 0s linear 360ms;
}
main.stage::before{
  content:"EXILECRAFT";top:50%;transform:translate(-50%,-50%);
  font:600 22px/1 var(--ui);letter-spacing:.35em;color:var(--ink-4);white-space:nowrap;
  animation:fmPlateWordIn 420ms cubic-bezier(.2,0,0,1) 80ms both;
}
main.stage::after{
  content:"";top:calc(50% + 26px);width:64px;height:2px;margin-left:-32px;
  background-color:var(--fire);opacity:.6;
  animation:fmPlateLineIn 420ms cubic-bezier(.2,0,0,1) 80ms both,fmPlateLineBreathe 1.6s ease-in-out 500ms infinite;
}
/* UNARMED: painted. The swell runs from element creation, so a room that
   arms inside the 80ms delay never shows the mark at all. */
html:not([data-fm-wave-armed]) main.stage::before,
html:not([data-fm-wave-armed]) main.stage::after{visibility:visible}
@keyframes fmPlateWordIn{from{opacity:0}to{opacity:1}}
@keyframes fmPlateLineIn{from{opacity:0}to{opacity:.8}}
@keyframes fmPlateLineBreathe{0%,100%{opacity:.8}50%{opacity:.35}}
/* ARMED: the colours go transparent under the arriving room (the wave's
   own fade is 320ms, so the mark is still faintly there as the room lands
   and gone just after); visibility flips once the dissolve has finished. */
html[data-fm-wave-armed] main.stage::before{color:transparent;visibility:hidden}
html[data-fm-wave-armed] main.stage::after{background-color:transparent;visibility:hidden}
/* Reduced motion needs no rule of its own: every room's inline sheet
   kills animations and shortens transitions to 1ms under the OS query,
   data-reduce-fx and data-fk-motion="off", which leaves the base opacity
   (word 1, line .6) as a static mark from first paint and an instant hide
   on arm. */
