/* Styles for app.html. Split out of the page 2026-08-28: the stylesheet is
   274 lines that an agent editing the JS never needs to read, and vice versa.
   check_app.py asserts across app.html + app.css + web/app/*.js together, so
   a rule asserted there may live in either file. */
:root{
  --bg:#151515; --chrome:#1a1a1a; --panel:#212121; --sunken:#111111;
  --hover:#2b2b2b; --rule:#3a3a3a; --rule-soft:#2a2a2a;
  --text:#ececec; --dim:#9a9a9a; --faint:#878787;
  --primary:#E8501F; --contextual:#B8391A; --error:#B8394B;
  --focus:#E8501F;
  /* feat 85: ink for text/icons drawn on top of --primary. Dark's value
     matches --bg (already AA on --primary); light needs true white --
     --text there fails on --primary, see the light block below. */
  --on-primary:#151515;
  /* feat 84 (A1): a five-step type scale, replacing 20 hand-picked sizes down
     to 9px. Floor is 11px -- nothing in the app chrome goes below it. */
  --fs-xs:11px; --fs-sm:12px; --fs-md:13px; --fs-lg:14px; --fs-xl:16px;
  /* Review status (feat 36 row 7): red / amber / green. `--bad` and `--ok` are
     colours the page already uses -- --error, and the axis gizmo's Y arm in
     app.html -- so amber is the one new value here, and it is the only place
     three-way compliance is expressed. Referenced by --bad/--warn/--ok rather
     than by hex at each use, so a colour-blind alternative later is one edit. */
  --bad:var(--error); --warn:#C9A227; --ok:#4C9A5C;
  /* ponytail: --rule is below 3:1 (WCAG 1.4.11) for input edges in both
     themes -- predates feat 85, a Phase 2 accessibility pass picks it up. */
  color-scheme: dark;
}

/* ===== light theme (feat 85) =====
   :root.light overrides every dark hex token above. Neutrals are NODEMENA's
   light Slate; dim/muted are replaced (NODEMENA's fail AA on --hover at
   2.56:1) and the accents are darkened until they pass AA as text. Ratios
   computed 2026-09-29 (WCAG relative luminance); worst case is on --hover.
   check_app.py's token-parity check (row 4) keeps this list matching :root. */
:root.light{
  --bg:#f8fafc; --chrome:#f1f5f9; --panel:#ffffff; --sunken:#f1f5f9;
  --hover:#e2e8f0; --rule:#cbd5e1; --rule-soft:#e2e8f0;
  --text:#0f172a; --dim:#475569; --faint:#556070;
  --primary:#B33B0B; --contextual:#9A3412; --error:#B91C1C;
  --focus:#B33B0B;
  --on-primary:#ffffff;
  --warn:#8A5A06; --ok:#13733A;
  color-scheme:light;
}
/* Self-hosted type (brand DESIGN.md "Delivery"); Inter replaced the Google Fonts
   link. Archivo and Plex Mono are used by the lab overlay only. */
@font-face{ font-family:'Archivo'; src:url(fonts/archivo-latin.woff2) format('woff2'); font-weight:600 800; font-stretch:100% 125%; font-display:swap; }
@font-face{ font-family:'Inter'; src:url(fonts/inter-latin.woff2) format('woff2'); font-weight:400 700; font-display:swap; }
@font-face{ font-family:'IBM Plex Mono'; src:url(fonts/plexmono-400-latin.woff2) format('woff2'); font-weight:400; font-display:swap; }
@font-face{ font-family:'IBM Plex Mono'; src:url(fonts/plexmono-500-latin.woff2) format('woff2'); font-weight:500; font-display:swap; }
:root{
  --font-display:'Archivo','Inter',system-ui,sans-serif;
  --font-body:'Inter',system-ui,-apple-system,"Segoe UI",sans-serif;
  --font-mono:'IBM Plex Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
}
/* Lab boot cover (app.html <head>): the modeler stays unpainted until lab.js
   has built its overlay. visibility, not display, so the viewport still sizes
   its canvas against a real layout while hidden. */
html.lab-boot body > :not(.lab):not(.boot){ visibility:hidden; }

/* ===== boot cover (feat 92 row 8, app.html #boot) ========================
   Shown in both modes from first paint until `nodevsp:ready`. Above everything,
   including the lab overlay, which is only built after ready. */
.boot{
  /* Brand values, as .lab does below (the modeler's own --primary is darker in light). */
  --primary:#E8501F; --contextual:#B8391A; --glow:rgba(232,80,31,.22); --glow2:rgba(232,80,31,.15);
  position:fixed; inset:0; z-index:10000; background:var(--bg); color:var(--text); isolation:isolate; overflow:hidden;
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:12px; padding:24px; text-align:center; font-family:var(--font-body);
  transition:opacity .2s ease;
}
:root.light .boot{ --primary:#C43D14; --contextual:#8f2d10; --glow:rgba(196,61,20,.12); --glow2:rgba(196,61,20,.08); }
.boot.boot-out{ opacity:0; pointer-events:none; }
/* Glow From Above: two soft blobs whose centres stay in the top ~25% of the screen,
   drifting by transform only (no blur, no gradient animation). Offset in the
   ::before/::after box (inset -20%): screen y = -20% + box y * 1.4. */
.boot::before, .boot::after{ content:""; position:absolute; inset:-20%; z-index:-1; pointer-events:none; will-change:transform; animation-timing-function:ease-in-out; animation-iteration-count:infinite; animation-direction:alternate; }
.boot::before{ background:radial-gradient(ellipse 55% 30% at 30% 18%, var(--glow), transparent 65%); animation-name:bootDriftA; animation-duration:57s; }
.boot::after{ background:radial-gradient(ellipse 45% 26% at 72% 26%, var(--glow2), transparent 65%); animation-name:bootDriftB; animation-duration:43s; animation-delay:-17s; }
@keyframes bootDriftA{ from{ transform:translate(-6%,0) scale(1); } to{ transform:translate(9%,5%) scale(1.12); } }
@keyframes bootDriftB{ from{ transform:translate(7%,4%) scale(1.08); } to{ transform:translate(-8%,-1%) scale(1); } }
.boot .boot-mark{ width:88px; height:88px; }   /* beats .c-mark (15px, later in the file) */
.boot-word{ font-family:var(--font-display); font-weight:800; font-stretch:125%; font-size:22px; letter-spacing:.1em; padding-left:.1em; }
.boot-phrase{ margin:0; height:20px; display:flex; align-items:center; gap:8px; font-family:var(--font-mono); font-weight:500; font-size:13px; letter-spacing:.02em; color:var(--dim); }
.boot-dot{ width:6px; height:6px; border-radius:50%; background:var(--primary); flex:none; }
.boot-phrase span{ display:block; animation:bootPhrase 2.2s cubic-bezier(.16,1,.3,1) both; }
@keyframes bootPhrase{ 0%{ opacity:0; transform:translateY(4px); } 18%,82%{ opacity:1; transform:none; } 100%{ opacity:0; transform:translateY(-4px); } }
/* #boot-msg is the one stable status text: screen-reader only until it carries an error. */
.boot:not(.boot-err) .boot-msg{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
.boot.boot-err .boot-phrase{ display:none; }
.boot.boot-err::before, .boot.boot-err::after{ animation-play-state:paused; }
.boot-msg{ margin:0; font-size:var(--fs-lg); }
.boot-sub{ margin:0; max-width:36ch; min-height:1.4em; font-size:var(--fs-md); color:var(--dim); }
.boot.boot-err .boot-msg{ color:var(--error); }
.boot.boot-err .c-mark, .boot.boot-err .c-mark *{ animation:none; }
.boot-retry{
  margin-top:4px; min-height:36px; padding:0 18px; border:0; border-radius:6px; cursor:pointer;
  background:var(--primary); color:var(--on-primary); font-size:var(--fs-md); font-weight:600;
}
.boot-retry[hidden]{ display:none; }
@media (prefers-reduced-motion:reduce){ .boot{ transition:none; } .boot::before, .boot::after{ animation:none; } .boot-phrase span{ animation:none; } }
*{ box-sizing:border-box; }
html,body{ height:100%; }
html{ scrollbar-color:var(--rule) var(--bg); scrollbar-width:thin; }
body{
  margin:0; background:var(--bg); color:var(--text); overflow:hidden;
  font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
  -webkit-font-smoothing:antialiased;
  display:flex; flex-direction:column;
}
::-webkit-scrollbar{ width:10px; height:10px; }
::-webkit-scrollbar-track{ background:var(--bg); }
::-webkit-scrollbar-thumb{ background:var(--rule); border-radius:5px; border:2px solid var(--bg); }
button,input{ font-family:inherit; }
:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
[disabled]{ opacity:.38; cursor:default; }

/* ---- tab strip ----
   .app-tabs is the outer bar; the generated tab list lives one level deeper
   in .app-tabs-tabs (id="tabs") so the static collapse/prefs buttons that
   also live in .app-tabs don't inherit tab-button styling from a bare
   descendant selector. position:relative makes .app-tabs the containing
   block for .prefs-panel's absolute popover. */
.app-tabs{
  display:flex; align-items:stretch; height:38px; background:var(--chrome);
  border-bottom:1px solid var(--rule); flex:none; position:relative;
}
.app-tabs-tabs{ display:flex; align-items:stretch; flex:1; min-width:0; overflow-x:auto; }
.app-tabs-tabs button{
  border:0; background:transparent; color:var(--dim); font:inherit; font-size:var(--fs-md);
  padding:0 18px; cursor:pointer; position:relative;
}
.app-tabs-tabs button:not([disabled]):hover{ background:var(--hover); color:var(--text); }
.app-tabs-tabs button[aria-selected="true"]{ background:var(--panel); color:var(--text); }
.app-tabs-tabs button[aria-selected="true"]::after{
  content:""; position:absolute; inset:0 0 auto 0; height:2px; background:var(--primary);
}

/* ===== collapse toggles + layout presets (feat 14) ======================
   .panel-collapse is the one small icon-button look shared by the three
   collapse buttons (#tree-collapse, #options-collapse, #ribbon-collapse) --
   same shape as .chat .c-toggle below, just not scoped to .chat. */
.panel-collapse{
  border:0; background:transparent; color:var(--faint); cursor:pointer;
  width:24px; height:24px; flex:none; align-self:center;
  display:flex; align-items:center; justify-content:center; border-radius:4px;
}
.panel-collapse:hover{ background:var(--hover); color:var(--text); }

/* feat 84 row 1 (A7/A11 decision): classes for two controls chrome.js builds
   with `element.style.cssText` today (the header length-unit <select> and the
   tree header's clear/trash button) -- row 2 switches those call sites to
   these classes, this row only adds the rule. */
.hdr-select{
  align-self:center; margin-left:4px; background:var(--panel); color:var(--dim);
  border:1px solid var(--rule); border-radius:4px; padding:4px 7px;
  font:inherit; font-size:var(--fs-sm); cursor:pointer;
}
.hdr-select:hover{ color:var(--text); }
.wt-clear{
  background:var(--panel); color:var(--dim); border:1px solid var(--rule);
  border-radius:4px; padding:4px 5px; display:flex; align-items:center;
  cursor:pointer; margin-right:auto;
}
.wt-clear:hover{ color:var(--error); border-color:var(--error); }

/* Ribbon has no header bar of its own (unlike worktree/options) -- collapse
   just removes it outright rather than hiding a body under a kept bar.
   `.ribbon-clip` (app.html) wraps it for hero mode's slide, below --
   `display:contents` here means it is not a layout box outside hero mode. */
#ribbon.collapsed{ display:none; }
.ribbon-clip{ display:contents; }

/* Preferences gear + popover -- proportions ported from flow.html:470-494,
   colours reused from this app's own tokens (--chrome/--rule/--dim/--hover/
   --text/--faint/--primary, all defined at app.css:5-11). */
.prefs-btn{
  align-self:center; margin-left:4px; width:28px; height:28px; flex:none;
  display:flex; align-items:center; justify-content:center;
  background:transparent; border:0; border-radius:4px; color:var(--dim); cursor:pointer;
}
.prefs-btn:hover, .prefs-btn[aria-expanded="true"]{ background:var(--hover); color:var(--text); }
/* Its own `display:flex` above beats the UA [hidden] rule, same trap as
   .chat/.param/.foil-fields (web/README.md documents it) -- lab mode
   (chrome.js guard 5) sets `.hidden` on both the button and the popover. */
.prefs-btn[hidden]{ display:none; }

.prefs-panel{
  position:absolute; top:44px; right:8px; width:260px; z-index:20;
  background:var(--chrome); border:1px solid var(--rule); border-radius:8px; padding:14px;
  box-shadow:0 16px 36px rgba(0,0,0,.45);
}
.prefs-panel h3{ margin:0 0 3px; font-size:var(--fs-md); font-weight:600; color:var(--text); }
.prefs-panel .prefs-sub{ margin:0 0 12px; font-size:var(--fs-sm); color:var(--faint); }
/* feat 85: the Theme .seg sits above Layout's h3 -- same 12px a prefs-sub
   leaves under itself, so the two stacked groups read as separate. */
.prefs-panel .seg{ margin-bottom:12px; }
.prefs-row{
  display:block; width:100%; text-align:left; border:0; background:transparent;
  padding:8px 9px; border-radius:5px; cursor:pointer; margin-bottom:2px; font:inherit;
}
.prefs-row:hover{ background:var(--hover); }
.prefs-row .pr-name{ display:block; font-size:var(--fs-md); font-weight:600; color:var(--text); }
.prefs-row .pr-desc{ display:block; font-size:var(--fs-xs); color:var(--faint); margin-top:1px; }

/* ---- hero mode (dev only, ?hero -- app.html sets body.hero-mode) ----
   A capture aid for recording a collapse-then-reveal video, not a feature:
   the three existing collapse buttons (#tree-collapse/#options-collapse/
   #ribbon-collapse, wired in chrome.js) slide instead of instantly popping.
   Their `.collapsed` class and click handlers are untouched -- this only
   adds a transition on top of the same state change.
   Worktree/options: `width` already carries a real from/to value (var(--wt-w)
   or 250px fallback <-> 40px, app.css below), so transitioning it directly is
   the "reclaim real layout space" case a transform/opacity substitute cannot
   cover -- .viewport's own ResizeObserver (viewport.js) already re-syncs the
   canvas on every intermediate frame, same as a manual drag. `.resizing`
   (chrome.js's drag handles) turns the transition back off for that drag --
   a live pointermove sets the same property every frame, and a transition
   would lag a step behind the cursor. `overflow-x:hidden` throughout (not
   only while `.collapsed`) keeps the icon-rail content clipped mid-slide. */
body.hero-mode .worktree, body.hero-mode .options{
  transition:width .3s ease; overflow-x:hidden;
}
body.hero-mode .worktree.resizing, body.hero-mode .options.resizing{ transition:none; }
/* Ribbon: `#ribbon.collapsed` is plain `display:none` above, an all-or-
   nothing swap with no from/to size to animate. `.ribbon-clip` instead
   animates `grid-template-rows` (0fr <-> 1fr) around the un-collapsed
   `.ribbon` -- the auto-height-reveal pattern, since the ribbon's own height
   varies with fitRibbon()'s zoom and compact state and is never a fixed
   number here. `:has()` is Chromium-only, same precedent already used below
   (`.tree .item-row:has(...)`) -- this app already targets Chromium. */
body.hero-mode .ribbon-clip{
  display:grid; grid-template-rows:1fr; overflow:hidden;
  transition:grid-template-rows .3s ease;
}
body.hero-mode .ribbon-clip:has(#ribbon.collapsed){ grid-template-rows:0fr; }
body.hero-mode #ribbon.collapsed{ display:flex; }

/* ---- ribbon ---- */
.ribbon{
  display:flex; align-items:stretch; background:var(--panel);
  border-bottom:1px solid var(--rule); flex:none; overflow-x:auto;
}
.r-group{
  display:flex; flex-direction:column; padding:6px 10px 0;
  border-right:1px solid var(--rule-soft); flex:none;
}
/* A fixed row height -- three 22px small buttons + two 1px gaps -- so every
   tab's ribbon is the same height by construction, whatever its groups hold
   (File's Import + unit column outgrew it once the icons went to 24px). */
.r-row{ display:flex; align-items:center; gap:4px; height:68px; }
/* Large buttons centre on the row; a short column of small ones stays on the
   top rows of the three-row grid, like File's Exchange group. */
.r-col{ display:flex; flex-direction:column; gap:1px; align-self:flex-start; }
/* margin-top:auto pins the caption to the bottom, so a group with one short
   column (Propulsion) lines its label up with its neighbours'. */
.g-label{ margin-top:auto; text-align:center; font-size:var(--fs-xs); color:var(--dim); letter-spacing:.05em; padding:4px 0 5px; }
/* width:auto up to 84px keeps "Landing Gear" on one line (a 60px box wrapped
   it and made that group read taller than the rest), while a really long
   caption still wraps. */
.r-btn{
  min-width:60px; max-width:84px; display:flex; flex-direction:column; align-items:center; gap:3px;
  padding:3px 4px; border:0; background:transparent; color:var(--dim);
  border-radius:4px; cursor:pointer; transition:background-color .1s ease, color .1s ease;
}
.r-btn:not([disabled]):hover{ background:var(--hover); color:var(--text); }
.r-btn .cap{ font-size:var(--fs-xs); text-align:center; line-height:1.15; }
.r-btn.sm{
  min-width:0; max-width:none; flex-direction:row; align-items:center; gap:6px;
  height:22px; padding:0 7px; text-align:left;
}
/* Small-button caption stays one step up from the large row's (--fs-sm not
   --fs-xs): a .sm row has room and reads better a notch bigger. */
.r-btn.sm .cap{ font-size:var(--fs-sm); white-space:nowrap; text-align:left; }
/* feat 84: too narrow even at RB_MIN zoom -- small buttons go icon-only
   (chrome.js: fitRibbon/setCompact gives each a title + aria-label). */
.ribbon.compact .r-btn.sm .cap{ display:none; }
.ribbon.compact .r-btn.sm{ padding:0 4px; }
/* Icon as a CSS mask so the theme colour paints it and a disabled button dims
   it. The filename is the wiring -- see icons/README.md. */
.ico{
  width:17px; height:17px; flex:none; background:currentColor;
  -webkit-mask:var(--i) center/contain no-repeat; mask:var(--i) center/contain no-repeat;
}
/* Ribbon icons were 17/14px; 24/18 (owner, 2026-09-26: "make the icons
   bigger"). Both still fit the existing boxes -- a large button is shorter
   than its three-row column of small ones, and a small row is 22px -- so the
   ribbon keeps its height. Scoped to .r-btn: tree rows keep 17px. */
.r-btn .ico{ width:24px; height:24px; }
.r-btn.sm .ico{ width:18px; height:18px; }
/* examples.js: the model-header Examples button is LAB MODE only; everyone
   else opens the gallery from File -> Project -> Examples. */
body:not([data-lab="1"]) .ex-btn{ display:none; }

/* ---- work area ---- */
.workarea{ flex:1; display:flex; min-height:0; position:relative; }

/* width is a custom prop (feat 64 row 1: drag-resize), 250px fallback keeps
   the old default for anyone reading just this rule. `position:relative`
   anchors the resize handle appended into this panel below -- checked live,
   nothing else in here assumes `absolute` against a different ancestor (the
   appearance popover and length-unit select are parked on <body>, not here). */
.worktree{
  width:var(--wt-w, 250px); flex:none; background:var(--chrome); border-right:1px solid var(--rule);
  display:flex; flex-direction:column; overflow-y:auto; position:relative;
}
.worktree h2, .options h2, .chat h2{
  margin:0; padding:14px 16px 8px; font-size:var(--fs-xs); letter-spacing:.08em;
  text-transform:uppercase; color:var(--dim);
}
/* Header row wrapping the h2 + collapse button (feat 14). .collapsed hides
   everything else in the panel and leaves this row as the panel's whole
   footprint -- same "keep the bar" idea as .chat.compact, different shape
   since these panels have a real header instead of an always-on bar. */
.wt-head, .o-head{ display:flex; align-items:center; justify-content:space-between; padding-right:12px; }
/* Collapsed shrinks the panel itself to an icon rail -- not just its content
   -- so .viewport reclaims the freed width (ResizeObserver in viewport.js
   picks up the resulting box change and keeps the canvas in sync). */
.worktree.collapsed, .options.collapsed{ width:40px; overflow-x:hidden; }
.options.collapsed > *:not(.o-head){ display:none; }
/* The worktree keeps its TREE when collapsed -- an icon rail, so the panel
   still says what has been built and stays clickable, instead of going blank
   the way the options panel does (which has no such list). Everything with a
   width of its own goes: headings, names, the per-row toggles and the x. The
   rows themselves are untouched markup -- renderTree() does not know the panel
   is collapsed, and must not learn. */
.worktree.collapsed > *:not(.wt-head):not(.tree){ display:none; }
.worktree.collapsed .tree{ padding:0 4px 10px; }
.worktree.collapsed .tree-head, .worktree.collapsed .t-name,
.worktree.collapsed .t-tog, .worktree.collapsed .item-rm,
.worktree.collapsed .item-add, .worktree.collapsed .item-caret,
.worktree.collapsed .tree .empty{ display:none; }
.worktree.collapsed .t-row{ padding:6px 0; gap:0; justify-content:center; }
/* The rail is icons alone, so they carry the whole meaning: 22px, not 17. */
.worktree.collapsed .t-row .ico{ width:22px; height:22px; }
.worktree.collapsed .item-children{ margin-left:0; }
/* feat 21: a nested geom's row carries its own inline `margin-left` (depth *
   18px, chrome.js's renderTree()) -- meaningless once the rail is icon-only,
   and it would push a deep row's icon half out of the 40px strip. `!important`
   is the only way to beat an inline style from a stylesheet rule; same
   renderTree()-must-not-learn constraint as the rest of this block. */
.worktree.collapsed .item-row{ margin-left:0 !important; }
/* The rail's whole job is "what exists, and what is selected", so the icon
   itself carries the selection colour -- at 40px wide the row's own tint and
   left rule are too small a signal to read at a glance. */
.worktree.collapsed .t-row[aria-selected="true"] .ico{ color:var(--primary); }
.worktree.collapsed .wt-head, .options.collapsed .o-head{ justify-content:center; padding-right:0; }
.worktree.collapsed .wt-head h2, .options.collapsed .o-head h2{ display:none; }
.worktree.collapsed .panel-collapse svg, .options.collapsed .panel-collapse svg{ transform:rotate(180deg); }

.tree{ display:flex; flex-direction:column; padding:0 8px 14px; }
.tree .empty, .params .empty{ padding:6px 10px; font-size:var(--fs-sm); color:var(--faint); }
/* Sub-heading inside #tree for a top-level family (Surfaces, Bodies -- feat
   09 row 9). Smaller and inline vs. the panel's own .wt-head h2, which stays
   the panel's one true title; this is a second-level label, not a duplicate
   of it. */
.tree-head{
  margin:10px 0 4px; padding:0 2px; font-size:var(--fs-xs); letter-spacing:.06em;
  text-transform:uppercase; color:var(--dim);
}
.tree-head:first-child{ margin-top:2px; }
.t-row{
  display:flex; align-items:center; gap:7px; padding:6px 8px; border-radius:4px;
  cursor:pointer; font-size:var(--fs-md); color:var(--dim); text-align:left;
  background:transparent; border:0; border-left:2px solid transparent;
}
.t-row:hover{ background:var(--hover); color:var(--text); }
/* Selected PARENT: a full outline round the row. `box-shadow: inset` rather
   than `border`, so the ring appears without changing the row's box and
   shifting every label 1px when selection moves. */
.t-row[aria-selected="true"]{
  background:color-mix(in srgb, var(--primary) 16%, transparent);
  color:var(--text); border-left-color:var(--primary); font-weight:600;
  box-shadow:inset 0 0 0 1px var(--primary);
}
/* Selected CHILD (a control surface under its wing): text colour only. A
   child is a detail of the outlined parent, not a peer of it -- giving it
   the same ring reads as two things selected at once. */
.item-children .t-row[aria-selected="true"]{
  background:transparent; border-left-color:transparent; box-shadow:none;
  color:var(--primary);
}
.item-children .t-row[aria-selected="true"] .ico{ color:var(--primary); }
.item-row.picked .t-row{
  background:color-mix(in srgb, var(--primary) 9%, transparent);
  color:var(--text); box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--primary) 55%, transparent);
}
.multi-actions{ display:flex; gap:6px; align-items:center; padding:4px 8px; font-size:var(--fs-sm); color:var(--dim); }
.multi-actions button{ background:none; border:0; padding:0; color:var(--primary); cursor:pointer; font:inherit; text-decoration:underline; }
.multi-actions{ flex-wrap:wrap; }
/* feat 70: wing-body blend group (blend.js). The tag is an outline chip, not a
   fill: it labels risk, it is not a button. */
.blend-tag{ font-size:var(--fs-xs); font-weight:600; letter-spacing:.04em; text-transform:uppercase;
  color:var(--text); border:1px solid var(--warn); border-radius:8px; padding:0 6px; margin-left:6px; vertical-align:middle; }
.blend-warn{ font-size:var(--fs-sm); color:var(--text); border:1px solid var(--warn); border-radius:6px; padding:4px 8px; }
.blend-warn[hidden]{ display:none; }
.blend-warn.busy{ border-color:var(--rule); color:var(--dim); }   /* feat 76: the fillet is computing, not failing */
.blend-remove{ align-self:flex-start; min-height:28px; padding:0 10px; background:none; color:var(--text);
  border:1px solid var(--rule); border-radius:6px; cursor:pointer; font:inherit; font-size:var(--fs-sm); }
.blend-remove:hover{ border-color:var(--primary); }
.blend-remove:focus-visible{ outline:2px solid var(--primary); outline-offset:2px; }
.t-row .ico{ width:16px; height:16px; color:var(--contextual); }
.t-name{ flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* A row being renamed (widgets.js: onRename swaps the button for this field on
   a double click). Same height and radius as .t-row, so the list does not jump
   as the two swap. */
.t-rename{
  flex:1; min-width:0; padding:5px 7px; border-radius:4px; font:inherit; font-size:var(--fs-md);
  color:var(--text); background:var(--sunken); border:1px solid var(--primary);
}
.t-rename:focus{ outline:none; }
/* Per-geom visibility / edit-lock toggles. aria-pressed true == hidden/locked.
   24x24 minimum hit target (was 19x19 -- a 15px icon in 2px of padding). */
.t-tog{
  flex:none; display:flex; align-items:center; justify-content:center;
  min-width:24px; min-height:24px; padding:2px; border:0; border-radius:3px;
  background:transparent; color:var(--faint); cursor:pointer;
}
.t-tog:hover{ background:var(--rule-soft); color:var(--text); }
.t-tog[aria-pressed="true"]{ color:var(--primary); }
.t-tog .ico{ width:15px; height:15px; color:inherit; }

/* feat 84 (A7): eye/lock/remove read as always-on chrome on every row even
   when nothing is happening there. Dim them until the row is worth looking
   at -- hovered, focused, selected -- and always show a PRESSED toggle (a
   hidden or locked geom must stay visibly so, not just on hover).
   `:has()` is Chromium-only, which this app already targets. Scoped to
   `.tree` so the same classes elsewhere (station/curve item lists) are
   unaffected -- those rows have no hover-to-reveal convention. */
.tree .item-row .t-tog, .tree .item-row .item-rm{
  opacity:.45; transition:opacity .12s ease;
}
.tree .item-row:hover .t-tog, .tree .item-row:hover .item-rm,
.tree .item-row:focus-within .t-tog, .tree .item-row:focus-within .item-rm,
.tree .item-row:has(.t-row[aria-selected="true"]) .t-tog,
.tree .item-row:has(.t-row[aria-selected="true"]) .item-rm,
.tree .item-row .t-tog[aria-pressed="true"]{
  opacity:1;
}
@media (prefers-reduced-motion:reduce){
  .tree .item-row .t-tog, .tree .item-row .item-rm{ transition:none; }
}

.viewport{ flex:1; background:var(--sunken); position:relative; min-width:0; }
/* The size here is load-bearing, not tidiness. viewport.js calls
   `renderer.setSize(w, h, false)` -- the `false` is "CSS owns the layout size,
   do not write style.width" -- so without these two the canvas falls back to
   its INTRINSIC size, which is the drawing buffer: w * devicePixelRatio CSS
   px. That is only right at dpr 1. On a Windows display at 125%/150% (the
   laptop default) the canvas came out 25%/50% wider and taller than its box,
   overflowed, painted over the Options panel, and showed a cropped corner of
   the render. Stays in normal flow (never `position:absolute`), or it would
   paint over the gizmo/HUD/chat overlays instead of under them. */
.viewport canvas{ display:block; width:100%; height:100%; }
.hint{
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  color:var(--dim); font-size:var(--fs-md); pointer-events:none;
}
.hud{
  position:absolute; top:12px; left:14px; pointer-events:none; white-space:pre;
  font:var(--fs-xs)/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--faint);
}
/* feat 84 (A5): the dev HUD is opt-in via the viewport toolbar's Stats toggle,
   not always-on chrome a user never asked to see. */
body:not(.show-hud) .hud{ display:none; }

/* ---- the sync-error banner (feat 62 row 3) ----
   Replaces `.hud.err`, which was the whole report a rejected /aircraft POST
   ever got: 11px monospace in --faint, pointer-events:none, in the corner.
   Every later edit re-posts the same invalid spec, so the app reads as frozen
   rather than as having refused something. This sits across the top of the
   viewport instead, above the HUD's own corner (which app/viewport.js clears
   while this is up, so the two never stack), and it is the ONE thing in the
   viewport that takes the pointer -- `Ver detalle` has to be clickable and
   keyboard-reachable, which is why there is no pointer-events:none here.
   `[hidden]` needs restating because display:flex would otherwise win. */
.sync-err{
  position:absolute; top:10px; left:14px; right:14px; z-index:6;
  display:flex; flex-wrap:wrap; align-items:baseline; gap:8px 12px;
  padding:10px 12px; border-radius:5px;
  background:var(--panel); border:1px solid var(--error);
  box-shadow:0 6px 20px rgba(0,0,0,.45);
}
.sync-err[hidden]{ display:none; }
.sync-err-msg{
  margin:0; flex:1 1 220px; min-width:0; color:var(--text);
  font-size:var(--fs-md); line-height:1.45;
}
.sync-err-more{
  flex:none; background:transparent; border:1px solid var(--rule); color:var(--dim);
  font:inherit; font-size:var(--fs-sm); padding:5px 10px; border-radius:4px; cursor:pointer;
}
.sync-err-more:hover{ color:var(--text); border-color:var(--error); }
.sync-err-raw{
  flex-basis:100%; margin:0; max-height:130px; overflow:auto; white-space:pre-wrap;
  word-break:break-word; color:var(--faint);
  font:var(--fs-xs)/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;
}

/* ---- the "updating" cue (app/viewport.js: updateSyncCue) ----
   Lives in .vp-status (top-right, under .viewbar; feat 96) with .blend-busy, so a
   sync and a fillet compute stack instead of overlapping; top-left is the HUD and
   toast's. .sync-err spans the top (and hides this while up). The outer div is a
   permanent live region with no box of its own; the pill is what shows. The
   boot cover's phrase pattern: the animated V mark + Plex Mono label (Three Voices Rule). */
/* feat 96: one right-aligned column under the toolbar (.viewbar top:10 + ~36px). */
.vp-status{
  position:absolute; top:52px; right:12px; z-index:3; pointer-events:none;
  display:flex; flex-direction:column; align-items:flex-end; gap:6px;
}
/* .blend-busy shares it: every pill in .vp-status looks the same. */
.sync-cue-pill, .blend-busy{
  display:flex; align-items:center; gap:8px; padding:5px 11px; border-radius:999px;
  background:var(--panel); border:1px solid var(--rule); white-space:nowrap;
  font-family:var(--font-mono); font-weight:500; font-size:var(--fs-sm); letter-spacing:.02em; color:var(--dim);
  animation:cueIn .2s ease-out both;
}
.sync-cue-pill[hidden], .blend-busy[hidden]{ display:none; }
@keyframes cueIn{ from{ opacity:0; } to{ opacity:1; } }
@media (prefers-reduced-motion:reduce){ .sync-cue-pill, .blend-busy{ animation:none; } }   /* the V's own rule stills it (.c-mark) */

/* ---- preset toast (feat 62 row 2b) ----
   The fuselage preset's one-line description. It was a <p class="hint"> in the
   Options panel, and `.hint` is the EMPTY-VIEWPORT PLACEHOLDER -- inset:0 over
   .workarea -- so the sentence rendered centred across the entire work area
   and could not be clicked away. It is now what the owner asked for: the
   viewport's top-left corner, left-aligned, no box, fading out on its own.
   top:52 clears the toolbar row; with the HUD on (3 lines) it drops to 70.
   Transparent means the text sits straight on the render, so the shadow is
   load-bearing legibility, not decoration. Below the HUD's own corner, and
   pointer-events:none so it can never take a click meant for the canvas. */
.preset-toast{
  position:absolute; top:52px; left:16px; max-width:min(44ch, 46%); z-index:4;
  pointer-events:none; text-align:left;
  opacity:1; transition:opacity .6s ease;
  text-shadow:0 1px 4px var(--bg), 0 0 2px var(--bg);
}
.preset-toast.fade{ opacity:0; }
body.show-hud .preset-toast{ top:70px; }   /* clears the 3-line HUD (feat 84 A5) */
.preset-toast strong{
  display:block; color:var(--text); font-size:var(--fs-md); font-weight:600; letter-spacing:-.01em;
}
.preset-toast span{
  display:block; margin-top:3px; color:var(--dim); font-size:var(--fs-sm); line-height:1.5;
}
@media (prefers-reduced-motion: reduce){ .preset-toast{ transition:none; } }
/* An error outranks a caption. Measured at 1024px: with both up the toast's
   title rendered behind the banner and its body text hung out below, reading
   as one broken paragraph. A sibling combinator rather than :has() -- the two
   are both children of #view, and the banner is created at viewport.js's
   module load, so it is always the earlier sibling. */
.sync-err:not([hidden]) ~ .preset-toast{ display:none; }

/* ---- body cross-section overlays (feat 67 row 1, revised again) ----
   Two draggable, resizable boxes in #view holding fuselage.js's front/side
   SVG pictures -- the previewer that used to live inline in the Options
   panel. Frosted glass: a dark, semi-transparent fill over a
   `backdrop-filter` blur, so the picture reads with contrast against ANY
   render behind it (a light fuselage over a light patch of sky was the
   failure a fully transparent background had) while the viewport stays
   visible, softened, underneath -- not the opaque `var(--panel)` card
   `.sync-err` uses, and not `.preset-toast`'s "no box at all" either; this
   is the middle case, a box that still keeps `pointer-events` on because it
   must stay draggable/resizable. `.bodyview` (app.css:1398) carries its OWN
   opaque `--sunken` fill and border for its other context (a plot inline in
   the Options panel); the override below strips that back to nothing
   specifically INSIDE this overlay, so the sidebar's own use of `.bodyview`
   (the preset gallery, via `.bodyview-tile`, and any future inline plot) is
   untouched -- the frosted card is the ONE background now, not two stacked
   boxes. `overflow:hidden` keeps the picture's own square corners from
   poking past the card's rounded ones. No padding, so the resize handle's
   corner sits flush on the picture's own corner rather than offset out into
   empty space -- unaffected by the card's own edge now existing, since a
   background matches an element's box exactly regardless of padding.
   `z-index:3` sits below `.preset-toast`'s 4 and `.sync-err`'s 6, and also
   below `.chat`'s 4 (app.css:708) -- a box dragged down near the assistant
   bar renders BEHIND it, never over it.
   `max-width` gives `.bodyview{width:100%;height:auto}` (feat 63 row 2)
   something concrete to size against until the user drags the resize handle
   (`.body-overlay-resize`, bodyoverlay.js's makeResizable -- clears
   `max-width` on first grab so an explicit `style.width` can exceed it).
   Position/size are clamped fully inside #view on every drag and resize
   (bodyoverlay.js's clampToView) -- never merely "partially visible" -- so
   this box can never spill into the ribbon or either sidebar, which sit
   OUTSIDE #view entirely.
   `touch-action:none` keeps a touchscreen drag from also scrolling.
   `[hidden]` is restated for the same reason `.sync-err[hidden]` is (:271):
   belt-and-suspenders against a later rule giving this a non-`none` display. */
.body-overlay{
  position:relative; z-index:3; max-width:240px;   /* in the dock; [data-moved] leaves it */
  cursor:grab; touch-action:none;
  background:color-mix(in srgb, var(--panel) 62%, transparent);
  backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px);
  border-radius:6px; overflow:hidden;
  /* Same grey rule the sidebars are edged in (.worktree/.options, :141/:413),
     so a floating card reads as part of the same chrome rather than a
     stray box -- reinstated after an earlier pass removed it; the frosted
     fill is now the thing carrying contrast, the border is purely edge
     definition, same division of labour the sidebars already use. */
  border:1px solid var(--rule);
}
/* The dock: covers #view (so a dragged box's absolute left/top share its
   origin), stacks default-placed boxes bottom-left ABOVE the gizmo (bottom:74 +
   64 + 10 = 148), growing upward (feat 96), and passes pointer events through.
   `safe` clips a tall stack at the bottom rather than past the top. */
.body-dock{
  position:absolute; inset:0; padding:0 0 148px 12px; pointer-events:none;
  display:flex; flex-direction:column; justify-content:safe flex-end; align-items:flex-start; gap:8px;
}
.body-dock > .body-overlay{ pointer-events:auto; }
.body-overlay[data-moved]{ position:absolute; }
.body-overlay .bodyview{ background:none; border:0; border-radius:0; }
.body-overlay.dragging{ cursor:grabbing; }
.body-overlay[hidden]{ display:none; }
/* Default positions -- the ONLY source of truth for "reset to default" on
   toggle-off-then-on (bodyoverlay.js's setFrontOn/setSideOn): the dock above,
   bottom-left over the gizmo, clear of `.hud` and `.preset-toast` (top-left). */
.body-overlay-front{ max-width:144px; }   /* 144 = 240 * 0.6, 40% smaller than the side view */
/* feat 89 round 2: the gear side view, never open together with a body's two
   previews in practice (different selection) but laid out clear of them anyway. */
.body-overlay-gear{ max-width:320px; }
/* Title (labels the drag handle) and the how-to-reopen line: small and quiet. */
.body-overlay-title{ padding:4px 28px 0 8px; font-size:var(--fs-xs); font-weight:600; color:var(--dim); line-height:1.3; }
.body-overlay-hint{ padding:0 8px 4px; font-size:var(--fs-xs); color:var(--faint); line-height:1.3; }
/* The gear box's attitude caption: one quiet line hugging the drawing. */
.body-overlay-cap{ margin:0; padding:2px 8px 4px; font-size:var(--fs-xs); color:var(--dim); line-height:1.3; }
/* Close x, top-right. Smaller than the shared .hint-close's 24px hit area is
   not an option (real hit targets), so it keeps 24px and just sits tighter. */
.body-overlay-close{ top:2px; right:2px; z-index:1; }
.body-overlay-close:focus-visible{ outline:2px solid var(--primary); outline-offset:-2px; }
/* Bottom-right resize tab, flush on the picture's own corner (no padding on
   `.body-overlay` to offset it). Same "invisible until hover, a wide hit
   target around a slim visible cue" shape as `.wt-handle`/`.opt-handle`
   (:389), a corner bracket instead of a straight line since this resizes
   off one dragged corner (only `width` is actually written -- the SVG's own
   `height:auto` follows it, keeping the picture's aspect ratio). */
.body-overlay-resize{
  position:absolute; right:0; bottom:0; width:16px; height:16px;
  cursor:nwse-resize; touch-action:none;
  border-right:2px solid transparent; border-bottom:2px solid transparent;
}
.body-overlay-resize:hover, .body-overlay-resize:focus-visible{
  border-color:var(--primary);
}

/* Native <select>: three mutually exclusive styles, no custom menu to maintain. */
.viewstyle{
  position:absolute; top:10px; right:12px; background:var(--panel);
  border:1px solid var(--rule); color:var(--dim); font:inherit; font-size:var(--fs-sm);
  padding:4px 7px; border-radius:4px; cursor:pointer;
}
.viewstyle:hover{ color:var(--text); }
/* feat 76's .blend-busy is styled with .sync-cue-pill (feat 96, the "updating" cue). */

/* Axis gizmo -- bottom-left, clearing the compact chat bar (16px + 42px). The
   arms are positioned by app/viewport.js; only colour, size and the hit state
   live here. `.ax-hit` is an invisible 22px pad behind each 12px head, so the
   click target meets the pointer-target minimum without growing the drawing. */
.gizmo{ position:absolute; left:18px; bottom:74px; z-index:3; overflow:visible; }
/* Below the 11px floor everywhere else, but the label sits centred in a 12px
   head circle on a 64px svg (viewport.js: GIZMO_R=22) -- var(--fs-xs) here
   still fits inside that circle with margin, checked against the markup. */
.gizmo text{ font-size:var(--fs-xs); font-weight:600; pointer-events:none; }
.gizmo .ax, .gizmo .ax-home{ cursor:pointer; }
.gizmo .ax:hover circle:not(.ax-hit){ filter:brightness(1.35); }
/* the home pad is transparent; the sibling AFTER it is the dot you see */
.gizmo .ax-home:hover + circle{ filter:brightness(1.6); }
/* parallel projection on: the centre dot reads as a pressed button, which is
   the only thing on screen that says which projection you are looking at. */
.gizmo.ortho .ax-home + circle{ fill:var(--primary); r:4; }
.gizmo .ax:focus-visible, .gizmo .ax-home:focus-visible{ outline:2px solid var(--primary); outline-offset:2px; }

/* Same custom-prop/fallback/position:relative story as .worktree above. */
.options{
  width:var(--opt-w, 280px); flex:none; background:var(--chrome); border-left:1px solid var(--rule);
  display:flex; flex-direction:column; position:relative;
}
/* ---- panel resize handles (feat 64 row 1) ----
   One handle per panel, anchored to the edge that faces .viewport (right for
   the tree, left for options) so it moves for free when the panel's own
   width changes -- app/chrome.js only ever writes --wt-w/--opt-w, it never
   repositions the handle. 8px, not a bare 1px line, for a real hit target;
   the visible line is the hover/focus highlight, not a permanent rule, so an
   untouched panel edge still reads as a plain border. Hidden while
   `.collapsed` -- a collapsed panel has no width left to drag. */
.wt-handle, .opt-handle{
  position:absolute; top:0; bottom:0; width:8px; cursor:ew-resize;
  touch-action:none; background:transparent;
}
.wt-handle{ right:0; }
.opt-handle{ left:0; }
/* The 8px box above is the hit target, kept wide for usability; the visible
   highlight is a separate 2px line centered in it, via ::after, so a wider
   grab area doesn't read as a thick bar on the panel edge. */
.wt-handle::after, .opt-handle::after{
  content:''; position:absolute; top:0; bottom:0; width:2px; background:transparent;
}
.wt-handle::after{ right:0; }
.opt-handle::after{ left:0; }
.wt-handle:hover::after, .wt-handle:focus-visible::after,
.opt-handle:hover::after, .opt-handle:focus-visible::after{ background:var(--primary); }
.wt-handle:focus-visible, .opt-handle:focus-visible{
  outline:2px solid var(--focus); outline-offset:-2px;
}
.worktree.collapsed .wt-handle, .options.collapsed .opt-handle{ display:none; }
/* Basic/Advanced sub-tabs: native radios styled as underlined labels, same
   precedent as flow.html's o-tabs -- a radiogroup is the correct native role
   for a mutually-exclusive set, so no fake tablist ARIA is layered on. */
.o-tabs{ display:flex; gap:18px; padding:0 16px; border-bottom:1px solid var(--rule); margin-bottom:2px; }
.o-tabs input{ position:absolute; opacity:0; width:1px; height:1px; }
.o-tabs label{ font-size:var(--fs-md); color:var(--dim); padding:0 0 10px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; }
.o-tabs input:checked + label{ color:var(--text); font-weight:600; border-color:var(--primary); }
.o-tabs input:focus-visible + label{ outline:2px solid var(--focus); outline-offset:2px; }
.params{ display:flex; flex-direction:column; gap:12px; padding:0 16px 16px; overflow-y:auto; }
.params h3{
  margin:6px 0 -4px; font-size:var(--fs-xs); letter-spacing:.06em; text-transform:uppercase;
  color:var(--faint); font-weight:600;
}
/* One line of explanation under a heading or a toggle. --dim, not --faint:
   the axis convention and the symmetry consequence are not guessable from the
   field names, so this is text that has to be readable, not decoration. */
.params .note{ margin:-4px 0 0; font-size:var(--fs-xs); line-height:1.45; color:var(--dim); }
/* feat 74: the body's overall size under its header -- a reminder read while
   typing, so full --text and figures that do not jitter as they change. */
.params .note.dims{ color:var(--text); font-size:var(--fs-sm); font-variant-numeric:tabular-nums; }
/* The same size, in the viewport's bottom-right while something is selected
   (dims.js). bottom:74px mirrors .gizmo on the left: clear of the compact chat
   bar (bottom:16px) and of the lab chip. pointer-events:none -- never eats a
   pick or an orbit. */
.dims-badge{
  position:absolute; right:16px; bottom:74px; z-index:3; pointer-events:none;
  display:flex; flex-direction:column; gap:2px; padding:8px 12px;
  background:color-mix(in srgb, var(--panel) 85%, transparent);
  border:1px solid var(--rule); border-radius:8px;
  font-size:var(--fs-sm); line-height:1.4; color:var(--dim); font-variant-numeric:tabular-nums;
}
.dims-badge strong{ color:var(--text); font-weight:600; }
.dims-badge[hidden]{ display:none; }
/* Collapsible option group: native <details>, no JS open/close state to
   keep. feat 84 (A6): the summary used to be 11px uppercase grey text with a
   6px triangle, and read as a label rather than a clickable row -- now a
   full-width 32px row with a real chevron and a hover fill, sentence case,
   still shut by default. */
.o-sect{ display:flex; flex-direction:column; gap:12px; }
/* Hairline between consecutive groups (not before the first, or after the
   last, which would double up against .params's own padding). */
.o-sect + .o-sect{ border-top:1px solid var(--rule-soft); padding-top:4px; margin-top:-8px; }
/* A shut <details> still lays out its (empty) content slot as a flex item,
   so the 12px gap made every shut group ~70px tall. */
.o-sect:not([open]){ gap:0; }
.o-sect > summary{
  list-style:none; cursor:pointer; display:flex; align-items:center; gap:6px;
  min-height:32px; padding:6px 8px; margin:0 -8px; border-radius:4px;
}
.o-sect > summary:hover{ background:var(--hover); }
.o-sect > summary::-webkit-details-marker{ display:none; }
/* A CSS-drawn chevron (two borders, mitred to a corner) rather than a glyph --
   crisp at any zoom, and the colour is a plain `border-color`, not a font
   fighting antialiasing at 7px. Points right shut, down open, same sense as
   the tree's own .item-caret. */
.o-sect > summary::before{
  content:""; flex:none; width:7px; height:7px;
  border-right:1.6px solid var(--dim); border-bottom:1.6px solid var(--dim);
  transform:rotate(-45deg); margin-left:1px;
  transition:transform .12s ease;
}
.o-sect[open] > summary::before{ transform:rotate(45deg); }
/* "More…" inside a group (feat 90 rows 3/5a): one native <details>, shut by
   default; params.js moreBox() builds it. 32px summary = a real hit target. */
.o-more > summary{
  cursor:pointer; display:flex; align-items:center; gap:8px; min-height:32px;
  width:100%; box-sizing:border-box; padding:0 4px; border-radius:4px;
  color:var(--text); font-size:var(--fs-sm); font-weight:600;
}
.o-more > summary::before{
  /* no colour of its own: the chevron is the label's text colour -- the
     whole button is white in dark theme (owner, 2026-10-02), not orange */
  content:""; flex:none; width:7px; height:7px;
  border-right:1.6px solid currentColor; border-bottom:1.6px solid currentColor;
  transform:rotate(-45deg); transition:transform .12s ease;
}
.o-more[open] > summary::before{ transform:rotate(45deg); }
.o-more > summary:hover{ background:var(--hover); }
.o-more > summary:focus-visible{ outline:2px solid var(--focus); outline-offset:-2px; }
.o-more-count{ color:var(--dim); font-weight:400; font-variant-numeric:tabular-nums; }
.o-more-body{ display:flex; flex-direction:column; gap:12px; padding-top:8px; }
@media (prefers-reduced-motion:reduce){
  .o-sect > summary::before, .o-more > summary::before{ transition:none; }
}
.o-sect > summary h3{
  margin:0; font-size:var(--fs-md); font-weight:600; color:var(--text);
  text-transform:none; letter-spacing:0;
}

.param{ display:flex; flex-direction:column; gap:5px; }
/* An author `display` beats the UA sheet's `[hidden]{display:none}`, so every
   `el.hidden = true` in foil.js was a no-op on these three -- which is why the
   "+ Upload airfoil" button showed for shapes that have no file to upload.
   One rule, not `!important`: they are the only flex boxes the JS hides.
   `.chat` (line 555, also an author `display:flex`) joined this list for lab
   mode (feat 60 row 2), which hides the assistant panel entirely. */
.param[hidden], .foil-custom-row[hidden], .foil-fields[hidden], .chat[hidden]{ display:none; }
.param label{ font-size:var(--fs-sm); color:var(--dim); }
.p-control{ display:flex; align-items:stretch; }
.param input{
  flex:1; min-width:0; background:var(--sunken); border:1px solid var(--rule);
  color:var(--text); padding:7px 9px; font-size:var(--fs-md); border-radius:4px;
  font-variant-numeric:tabular-nums;
}
/* The colour swatch (widgets.js: colorField) is the one .param input whose
   JOB is to show a colour, and the rule above was hiding it: 7px of padding
   over a --sunken background squashed the swatch to a thin strip, so the
   control read as an empty grey box whatever colour it held. Zero the
   padding, give it a real size, and let the swatch fill it. The two -webkit-
   pseudo-elements are how chromium exposes the inner swatch; without them the
   default 2px inset border keeps a visible frame around the colour. */
.param input[type="color"]{
  flex:none; width:52px; height:26px; padding:2px; background:var(--sunken);
  cursor:pointer;
}
.param input[type="color"]::-webkit-color-swatch-wrapper{ padding:0; }
.param input[type="color"]::-webkit-color-swatch{ border:0; border-radius:2px; }
.param input:focus-visible{ outline:2px solid var(--focus); outline-offset:-1px; }
/* Set from script, not :invalid -- :invalid also matches a step mismatch, and
   an off-grid value like 1.50 is perfectly good geometry. */
.param input.bad{ border-color:var(--error); }
.param.has-unit input{ border-right:0; border-radius:4px 0 0 4px; }
.param .unit{
  background:var(--panel); border:1px solid var(--rule); color:var(--faint);
  font-size:var(--fs-sm); padding:0 9px; display:flex; align-items:center; border-radius:0 4px 4px 0;
}
.param select{
  flex:1; min-width:0; background:var(--sunken); border:1px solid var(--rule);
  color:var(--text); padding:7px 9px; font-size:var(--fs-md); border-radius:4px;
}
.param select:focus-visible{ outline:2px solid var(--focus); outline-offset:-1px; }

/* Inherit-vs-override checkboxes (section dihedral / section airfoil). */
.inherit-toggle{
  display:flex; align-items:center; gap:7px; font-size:var(--fs-sm); color:var(--dim); cursor:pointer;
}
.inherit-toggle input{ accent-color:var(--primary); width:14px; height:14px; margin:0; }

/* ===== generated-field widgets (widgets.js toggleField/choiceField) =====
   Used by the server-generated component panel and the Pose tab. The
   `.inherit-toggle` style above stays for the two hand-written flags that
   read as a sentence ("Mirror about the centreline"); these two are for a
   LABELLED field, so they keep .param's own label treatment. */

/* A bool. `.param` is a column, so the switch row overrides it to a row --
   the label and the switch are one line, unlike a number field. */
.param.param-row{ flex-direction:row; align-items:center; justify-content:space-between; gap:10px; }
.param.param-row label{ flex:1; }
input.switch{
  appearance:none; -webkit-appearance:none; flex:none;
  width:34px; height:19px; padding:0; border-radius:999px;
  background:var(--sunken); border:1px solid var(--rule); cursor:pointer;
  position:relative; transition:background .12s, border-color .12s;
}
input.switch::after{
  content:''; position:absolute; top:2px; left:2px; width:13px; height:13px;
  border-radius:50%; background:var(--faint); transition:transform .12s, background .12s;
}
input.switch:checked{ background:var(--primary); border-color:var(--primary); }
input.switch:checked::after{ transform:translateX(15px); background:#fff; }
input.switch:focus-visible{ outline:2px solid var(--focus); outline-offset:2px; }
input.switch:disabled{ opacity:.45; cursor:not-allowed; }

/* A small int that names states rather than counting -- gender, BOR surface
   mode, gear retraction config. Segmented radios: same hidden-input +
   styled-label trick as `.o-tabs`, boxed rather than underlined so it does
   not read as a second tab bar inside the panel. */
/* position:relative contains the visually-hidden radios below: without it
   their containing block sat OUTSIDE the scrolling Options panel, so a seg
   deep in a long panel (feat 89's gear strut rows) put its radio below the
   window and the click's focus scrolled the whole page off the viewport. */
.seg{ position:relative; display:flex; border:1px solid var(--rule); border-radius:4px; overflow:hidden; }
.seg input{ position:absolute; opacity:0; width:1px; height:1px; }
.seg label{
  flex:1; text-align:center; font-size:var(--fs-sm); color:var(--dim); cursor:pointer;
  padding:6px 8px; background:var(--sunken); border-left:1px solid var(--rule);
  white-space:nowrap;
}
.seg label:first-of-type{ border-left:0; }
.seg input:checked + label{ background:var(--primary); color:var(--on-primary); font-weight:600; }
.seg input:focus-visible + label{ outline:2px solid var(--focus); outline-offset:-2px; }
.seg input:disabled + label{ opacity:.45; cursor:not-allowed; }

/* A generated field the Pose tab has greyed out (a left-side angle while
   left/right symmetry is on) -- it still SHOWS the mirrored value, so it must
   read as driven rather than as broken. */
.foil-field-ctl input:disabled{ opacity:.45; cursor:not-allowed; }

/* Compact Root/Tip airfoil EDIT CURSOR (both tabs) -- pill buttons, not the
   checkbox-with-sentence style above. Single-select: at most one .active and
   at most one aria-pressed="true" (params.js airfoilCursorToggles). They sit
   at the right-hand end of the Shape field's LABEL row (widgets.js
   shapeSelect's optional labelExtra), so the field itself keeps .param's
   label-over-control column. */
.param-label-row{ display:flex; align-items:center; justify-content:space-between; gap:8px; }
.part-toggle-group{ display:flex; gap:4px; }
.part-toggle-btn{
  font-size:var(--fs-xs); padding:2px 8px; border-radius:4px; border:1px solid var(--rule);
  background:transparent; color:var(--dim); cursor:pointer;
}
.part-toggle-btn:hover:not(:disabled){ background:var(--hover); color:var(--text); }
.part-toggle-btn.active{ border-color:var(--primary); color:var(--text); }
.part-toggle-btn:focus-visible{ outline:2px solid var(--focus); outline-offset:1px; }
/* Root on station 2+ : its root IS the previous station's tip, one shared
   XSec. `disabled` drops it out of the tab order and its title with it, so the
   same explanation is ALSO a visible note() in the group -- a tooltip is not
   an accessible way to state a rule. */
.part-toggle-btn:disabled{ color:var(--faint); border-style:dashed; cursor:not-allowed; }
/* The one state with no single current airfoil to draw: root and tip differ
   and no cursor is set. Takes the plot's own box so the panel does not jump,
   and reads as information, not as the error .foil-status normally carries. */
.foil-status.foil-notice{
  font-size:var(--fs-sm); line-height:1.45; color:var(--dim); text-align:center;
  background:var(--sunken); border:1px solid var(--rule); border-radius:4px;
  padding:22px 14px; min-height:0;
}

/* ---- station list editor (Advanced tab) ---- */
.station-list-wrap{ display:flex; flex-direction:column; gap:6px; margin-bottom:2px; }
.item-list{ display:flex; flex-direction:column; gap:2px; }
.item-row{ display:flex; align-items:stretch; gap:4px; }
.item-row .t-row{ flex:1; }
/* 24x24 minimum hit target -- was 26x~18 (a 14px glyph, no explicit height). */
.item-rm{
  flex:none; width:26px; min-height:24px; display:flex; align-items:center; justify-content:center;
  background:transparent; border:0; color:var(--faint);
  border-radius:4px; cursor:pointer; font-size:var(--fs-lg); line-height:1;
}
.item-rm:hover{ background:var(--hover); color:var(--error); }
.item-add{
  align-self:flex-start; background:transparent; border:1px dashed var(--rule); color:var(--dim);
  font-size:var(--fs-sm); padding:5px 10px; border-radius:4px; cursor:pointer;
}
.item-add:hover:not([disabled]){ background:var(--hover); color:var(--text); }
/* ---- nested children under an item-row (control surfaces under a wing in
   the tree, renderItemList's `childrenFor`) -- NOT `.item-list`, a distinct
   class on purpose (check_app.py counts item-list containers). 24x24 minimum
   hit target -- was 16x~14. ---- */
.item-caret{
  flex:none; width:24px; min-height:24px; display:flex; align-items:center; justify-content:center;
  background:transparent; border:0; color:var(--faint);
  cursor:pointer; font-size:var(--fs-xs); transform:rotate(0deg); transition:transform .12s;
}
.item-caret:hover{ color:var(--text); }
.item-caret[aria-expanded="true"]{ transform:rotate(90deg); }
.item-children{ display:flex; flex-direction:column; gap:2px; margin:0 0 2px 22px; }
.item-children[hidden]{ display:none; }

/* ---- foil editor: shared by the Basic tab's wing airfoil and the Advanced
   tab's per-station override (renderFoilEditor is one function, mounted
   twice with different get/set/preview callbacks -- see script). ---- */
.foil-editor{ display:flex; flex-direction:column; gap:10px; margin-top:2px; }
.foil-plot{ background:var(--sunken); border:1px solid var(--rule); border-radius:4px; padding:6px; }
.foil-plot svg{ display:block; width:100%; height:auto; }
.foil-curve{ fill:color-mix(in srgb, var(--contextual) 30%, transparent); stroke:var(--contextual); stroke-width:1.5; }
.foil-chord{ stroke:var(--rule); stroke-width:1; stroke-dasharray:3 3; }
.foil-status{ font-size:var(--fs-xs); color:var(--error); min-height:14px; }
.foil-status:empty{ display:none; }
.foil-custom-row{ display:flex; }
.foil-custom-btn{
  flex:1; background:transparent; border:1px solid var(--rule); color:var(--dim);
  font-size:var(--fs-sm); padding:6px 10px; border-radius:4px; cursor:pointer;
}
.foil-custom-btn:hover{ background:var(--hover); color:var(--text); }
.foil-custom-btn[aria-expanded="true"]{ color:var(--text); border-color:var(--primary); }
.foil-upload .item-add{ width:100%; text-align:center; }
.foil-fields{ display:flex; flex-direction:column; gap:10px; }
.foil-fields h3{ margin:4px 0 -2px; }
.foil-field{ display:flex; flex-direction:column; gap:4px; }
.foil-field label{ font-size:var(--fs-sm); color:var(--dim); }
.foil-field-ctl{ display:flex; align-items:center; gap:8px; }
.foil-field-ctl input[type="range"]{ flex:1; accent-color:var(--primary); background:transparent; }
.foil-field-ctl input[type="number"]{
  width:64px; flex:none; background:var(--sunken); border:1px solid var(--rule);
  color:var(--text); padding:5px 7px; font-size:var(--fs-md); border-radius:4px;
  font-variant-numeric:tabular-nums;
}
.foil-field-ctl input[type="number"]:focus-visible{ outline:2px solid var(--focus); outline-offset:-1px; }
.foil-field-ctl input[type="number"].bad{ border-color:var(--error); }
/* placementField's unit label, reusing the foil editor's slider+number row --
   the foil editor itself has no unit (curve params are all 0..1 or degrees
   folded into the label), so this is the one addition placement needs. */
.foil-field-ctl .unit{ flex:none; font-size:var(--fs-xs); color:var(--faint); }
/* Placement's number box needs more digits than the foil editor's 0..1
   fields ever do -- min/max is +-10000, and a value truncated in the box is
   a value the user cannot read before it goes in a report. */
.placement-field .foil-field-ctl input[type="number"]{ width:84px; }

/* ---- blade curve editor (feat 27 row 6, web/app/curve.js) -- a compact
   two-column (r, value) row, genuinely new: .param's full label+control row
   would make a 10-40 point CEDIT curve (the corpus's real point counts,
   feat doc "The nine curves") an unreadably long panel. ---- */
.curve-plot{ background:var(--sunken); border:1px solid var(--rule); border-radius:4px; padding:4px; margin-bottom:6px; }
.curve-plot svg{ display:block; width:100%; height:40px; }
.curve-plot polyline{ fill:none; stroke:var(--contextual); stroke-width:1.5; vector-effect:non-scaling-stroke; }
.curve-points{ display:flex; flex-direction:column; gap:3px; max-height:220px; overflow-y:auto; margin-bottom:6px; }
.curve-row{ display:flex; gap:6px; }
.curve-row input{
  flex:1; min-width:0; background:var(--sunken); border:1px solid var(--rule);
  color:var(--text); padding:4px 6px; font-size:var(--fs-sm); border-radius:4px;
  font-variant-numeric:tabular-nums;
}
.curve-row input:focus-visible{ outline:2px solid var(--focus); outline-offset:-1px; }
.curve-row input.bad{ border-color:var(--error); }
.curve-actions{ display:flex; gap:6px; }
.curve-actions .item-add{ flex:1; text-align:center; }

/* ===== cross-section editor (feat 19 row 7, web/app/xsec.js) ==========
   Two plots: .xsec-plot (read-only preview, the engine's real (y,z) outline
   -- foil.js's TRUE-1:1-aspect rule, same reason) and .xsec-edit-plot
   (EDIT_CURVE only, draggable/nudgeable control points -- square viewBox,
   fit to the current points' own extent). Both square so the section is
   never visually stretched. */
.xsec-status{ font-size:var(--fs-xs); color:var(--error); min-height:14px; }
.xsec-status:empty{ display:none; }
.xsec-plot{
  background:var(--sunken); border:1px solid var(--rule); border-radius:4px;
  padding:6px; margin-bottom:8px; aspect-ratio:1/1;
}
.xsec-plot svg{ display:block; width:100%; height:100%; }
.xsec-centerline{ stroke:var(--rule); stroke-width:1; stroke-dasharray:3 3; }
.xsec-curve{ fill:color-mix(in srgb, var(--contextual) 30%, transparent); stroke:var(--contextual); stroke-width:1.5; }

.xsec-controls{ display:flex; flex-direction:column; gap:2px; margin-bottom:4px; }
.xsec-outline{
  fill:color-mix(in srgb, var(--primary) 12%, transparent);
  stroke:var(--dim); stroke-width:.6; vector-effect:non-scaling-stroke;
}
.xsec-outline.xsec-open{ fill:none; }              /* close_flag off: a path, not a fillable loop */
.xsec-handle-stem{ stroke:var(--faint); stroke-width:.4; vector-effect:non-scaling-stroke; }
.xsec-point, .xsec-handle{
  fill:var(--panel); stroke:var(--primary); stroke-width:.6; cursor:grab;
  vector-effect:non-scaling-stroke;
}
.xsec-handle{ fill:var(--sunken); stroke:var(--dim); }
.xsec-point.sel, .xsec-handle.sel{ fill:var(--primary); }
.xsec-point:active, .xsec-handle:active{ cursor:grabbing; }
.xsec-point:focus-visible, .xsec-handle:focus-visible{ stroke:var(--focus); stroke-width:1.2; outline:none; }
.xsec-actions{ display:flex; gap:6px; margin-top:6px; }
.xsec-actions .item-add{ flex:1; text-align:center; }

/* ===== assistant panel (app/chat.js) ================================== */

/* Always on, like flow.html's .chatbar mock -- there is no hidden state, only
   two SHAPES of the same element (`.compact` / `.expanded`, toggled in
   app/chat.js's setMode). Positioned inside `.viewport` (which is the
   `position:relative` anchor) rather than as a `.workarea` flex sibling, so it
   floats OVER the viewport and is centered on it regardless of how wide
   worktree/options are -- the bug in the first version was exactly that: a
   flex-sibling `.chat` squeezed a narrow window until the panel's own text
   clipped. An absolutely positioned overlay cannot be squeezed. */
.chat{ position:absolute; z-index:4; display:flex; flex-direction:column; }

.chat .c-head{ display:flex; align-items:center; justify-content:space-between; }
.chat .c-toggle{
  border:0; background:transparent; color:var(--faint); cursor:pointer;
  width:24px; height:24px; flex:none; display:flex; align-items:center; justify-content:center;
  border-radius:4px;
}
.chat .c-toggle:hover{ background:var(--hover); color:var(--text); }
.chat .c-icon{ display:none; width:16px; height:16px; color:var(--contextual); flex:none; }

.c-msg{
  font-size:var(--fs-md); line-height:1.5; white-space:pre-wrap; overflow-wrap:anywhere;
  border-radius:6px; padding:7px 10px;
}
.c-hint{ color:var(--faint); background:transparent; padding:6px 0; }
.c-msg.user{ background:var(--panel); color:var(--text); align-self:flex-end; max-width:88%; }
.c-msg.bot{ background:transparent; color:var(--dim); padding:0; }
.c-msg.err{ background:transparent; color:var(--error); padding:0; }

.c-status{ font-size:var(--fs-sm); color:var(--faint); }
/* :not([hidden]), NOT a bare `.c-status{display:flex}` -- any display value
   here beats the hidden attribute's own display:none, and the mark then
   animates over the viewport forever. */
.c-status:not([hidden]){ display:flex; align-items:center; gap:7px; }

/* The thinking mark: flow.html's #screen-load V, drawn on a 1.8s loop instead
   of 4.5s and with no wordmark. Same geometry, same two tokens, so "the agent
   is working" reads as the same brand beat as "the app is starting". */
.c-mark{ width:15px; height:15px; flex:none; }
@keyframes cmDotL { 0%,4%{opacity:0;transform:scale(.55);} 12%,100%{opacity:1;transform:scale(1);} }
@keyframes cmLine1{ 0%,12%{stroke-dashoffset:227;} 30%,100%{stroke-dashoffset:0;} }
@keyframes cmDotV { 0%,26%{opacity:0;transform:scale(.55);} 34%,100%{opacity:1;transform:scale(1);} }
@keyframes cmLine2{ 0%,34%{stroke-dashoffset:227;} 52%,100%{stroke-dashoffset:0;} }
@keyframes cmDotR { 0%,50%{opacity:0;transform:scale(.55);} 58%,100%{opacity:1;transform:scale(1);} }
@keyframes cmFade { 0%,80%{opacity:1;} 94%,100%{opacity:0;} }
.cm-dot-l{ transform-origin:126.21px 155.32px; animation:cmDotL 1.8s ease-out infinite; }
.cm-line-1{ stroke-dasharray:227; animation:cmLine1 1.8s ease-out infinite; }
.cm-dot-v{ transform-origin:256px 340.68px; animation:cmDotV 1.8s ease-out infinite; }
.cm-line-2{ stroke-dasharray:227; animation:cmLine2 1.8s ease-out infinite; }
.cm-dot-r{ transform-origin:385.79px 155.32px; animation:cmDotR 1.8s ease-out infinite; }
.c-mark{ animation:cmFade 1.8s ease-in-out infinite; }
@media (prefers-reduced-motion:reduce){
  .c-mark, .cm-dot-l, .cm-dot-v, .cm-dot-r{ animation:none; opacity:1; transform:scale(1); }
  .cm-line-1, .cm-line-2{ stroke-dashoffset:0; animation:none; }
}

.c-form{ display:flex; gap:8px; }
.c-form textarea{
  flex:1; min-width:0; resize:none; background:transparent; color:var(--text);
  border:0; padding:0; font:inherit; font-size:var(--fs-md); line-height:1.4;
}
.c-form textarea:focus{ outline:0; }
.c-form textarea::placeholder{ color:var(--dim); }
.c-form button[type="submit"]{
  align-self:flex-end; background:var(--primary); color:var(--on-primary); border:0;
  border-radius:4px; padding:8px 14px; font:inherit; font-size:var(--fs-md);
  font-weight:600; cursor:pointer; flex:none;
}
.c-form button[type="submit"]:hover:not([disabled]){ background:var(--contextual); }

/* ---- compact: the always-visible bar, flow.html's .chatbar geometry ----
   Only `bottom` is set (no `top`), so the box is content-sized and grows
   UPWARD as the log preview / status line above the bar gain height -- the
   bar itself never moves. */
.chat.compact{
  left:16px; right:16px; bottom:16px; gap:8px;
}
.chat.compact .c-head{ display:none; }
.chat.compact .c-icon{ display:block; }
.chat.compact .c-form{
  background:var(--panel); border:1px solid var(--rule); border-radius:6px;
  box-shadow:0 10px 28px rgba(0,0,0,.35); align-items:center;
  height:42px; padding:0 8px 0 14px; flex:none;
}
.chat.compact .c-form textarea{ height:20px; }
.chat.compact .c-form button[type="submit"]{ display:none; }   /* Enter-only, like the mock */

/* The log has no visible chrome in compact -- every bubble but the LAST is
   hidden, and that one floats above the bar as a card, styled after
   flow.html's `.chat-pill`. This needs no JS: it is the exact same #chat-log
   append() already uses for full history, so a reply STREAMS into this card
   live, and switching to `.expanded` shows the very same nodes in full. */
.chat.compact .c-log{ display:flex; flex-direction:column; flex:none; }
.chat.compact .c-log > *{ display:none; }
.chat.compact .c-log > .c-hint{ display:none; }        /* placeholder lives in the textarea instead */
.chat.compact .c-log > *:last-child:not(.c-hint){
  display:block; max-height:160px; overflow-y:auto;
  transition:opacity .7s ease;
  background:color-mix(in srgb, var(--panel) 94%, transparent); border:1px solid var(--rule);
  border-radius:10px; padding:9px 13px; color:var(--text);
  box-shadow:0 12px 28px rgba(0,0,0,.4);
  -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
}
/* app/chat.js hangs `.faded` on the log ten seconds after a turn that APPLIED
   a change -- the answer is on screen in the viewport, the sentence is not
   worth the clutter. Compact only: `.expanded` is the history panel and
   nothing there ever fades. */
.chat.compact .c-log.faded > *:last-child:not(.c-hint){ opacity:0; pointer-events:none; }

.chat.compact .c-status:not([hidden]){
  background:color-mix(in srgb, var(--panel) 94%, transparent); border:1px solid var(--rule);
  border-radius:8px; padding:6px 13px; box-shadow:0 12px 28px rgba(0,0,0,.4);
  -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); flex:none;
}

/* ---- expanded: the original right-hand panel, full history ----
   `top` clears .viewstyle/.hud instead of `top:0`, or the panel would sit
   under the view-style dropdown at the same corner. */
.chat.expanded{
  top:52px; right:16px; bottom:16px; width:340px;
  background:var(--chrome); border:1px solid var(--rule); border-radius:8px;
  box-shadow:0 10px 28px rgba(0,0,0,.35); padding:0;
}
.chat.expanded .c-head{ padding:14px 16px 8px; }
.chat.expanded .c-head h2{ margin:0; }
/* flex:1 + min-height:0 is what makes the log scroll instead of pushing the
   composer off the bottom of a fixed-height column. */
.chat.expanded .c-log{
  flex:1; min-height:0; overflow-y:auto; display:flex; flex-direction:column;
  gap:10px; padding:4px 16px 12px;
}
.chat.expanded .c-status:not([hidden]){ padding:0 16px 8px; }
.chat.expanded .c-form{ flex-direction:column; padding:10px 16px 14px; border-top:1px solid var(--rule); }
.chat.expanded .c-form textarea{
  background:var(--sunken); border:1px solid var(--rule); border-radius:4px; padding:7px 9px;
  font-size:var(--fs-md); min-height:44px;  /* rows=1 is the compact bar's height; grow it back here */
}
.chat.expanded .c-icon{ display:none; }
.chat.expanded .c-form .c-toggle{ display:none; }   /* #chat-expand is compact-only; the header's #chat-collapse is the one toggle here */

/* ===== interface presets (feat 14) =======================================
   body[data-preset] is set by app/chrome.js (task 4); default/unset means
   'studio'. Studio shows everything, so it needs no rule of its own here --
   only the three presets that hide something get one, and an unset
   data-preset falls through to the same "show everything" baseline as an
   explicit studio, satisfying the doc's "absent == studio" requirement for
   free rather than repeating :not([data-preset]) on every selector below. */
body[data-preset="focus"] #ribbon, body[data-preset="focus"] .app-tabs-tabs,
body[data-preset="copilot"] #ribbon, body[data-preset="copilot"] .app-tabs-tabs{
  display:none;
}
body[data-preset="focus"] .worktree, body[data-preset="focus"] .options,
body[data-preset="copilot"] .worktree, body[data-preset="copilot"] .options,
body[data-preset="direct"] .worktree, body[data-preset="direct"] .options{
  display:none;
}
body[data-preset="focus"] .chat, body[data-preset="direct"] .chat{ display:none; }
/* Copilot wants the chat forced to its compact "prompt bar" reading. The
   chat starts .compact by default (app.html) and chat.js's setMode() only
   ever adds .expanded on an explicit click of #chat-expand, so hiding that
   one button is enough to keep it compact for the preset's own session --
   this deliberately does not fight an .expanded state the user already
   opened in another preset (see feat doc's ".chat:not(.expanded)" escape
   hatch); switching a still-expanded chat back to compact is chat.js's own
   setMode(), not a CSS concern. */
body[data-preset="copilot"] #chat-expand{ display:none; }

/* ===== appearance popover (feat 35) =====
   Right-click a geometry tree row. `position:fixed` because chrome.js anchors
   it to the row's viewport rect and clamps it into the window -- an absolute
   box would be positioned against the tree's own scrolled origin instead.
   Same visual language as .prefs-panel above (the app's other popover). */
.geom-menu{
  position:fixed; z-index:40; width:224px;
  background:var(--chrome); border:1px solid var(--rule); border-radius:8px;
  padding:10px 12px 8px; box-shadow:0 16px 36px rgba(0,0,0,.45);
}
.geom-menu .gm-head{
  font-size:var(--fs-md); font-weight:600; color:var(--text);
  margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid var(--rule);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* The colour swatch arrives as widgets.js's standard `.param` row. */
.geom-menu .param{ margin:0 0 8px; }
.geom-menu .gm-row{ display:flex; align-items:center; gap:8px; }
.geom-menu .gm-row label{ font-size:var(--fs-sm); color:var(--dim); flex:none; }
.geom-menu .gm-row input[type="range"]{ flex:1; min-width:0; }
/* Tabular figures and a fixed width, so the number does not jitter the slider
   sideways as it counts through 9% -> 10% -> 100% during a drag. */
.geom-menu .gm-pct{
  font-size:var(--fs-sm); color:var(--dim); flex:none;
  width:34px; text-align:right; font-variant-numeric:tabular-nums;
}
/* Mirror popover (edit.js: mirrorFields): two rows of .part-toggle-btn --
   planes, then rotation icon | X Y Z | Instances [n]. Wider than the
   appearance popover so the rotation row stays on one line. */
/* Everything here is 1.4x the appearance popover's scale (owner, 2026-09-26:
   "the mirror popover altogether, 40% bigger") -- sizes, not `zoom`, since
   edit.js positions the box from the button's rect in unzoomed px. */
/* Docked in #view's bottom-left, stacked above .gizmo (bottom:74px + its 64px
   + a gap), not anchored to the ribbon button -- anchored, it covered the
   ribbon's other menus (owner, 2026-10-01). z-index above .gizmo's 3. */
#mirror-menu{
  position:absolute; left:18px; bottom:148px; z-index:5;
  max-height:calc(100% - 160px); overflow:auto;
  width:auto; padding:14px 17px 11px; border-radius:11px;
}
#mirror-menu .gm-head{ cursor:grab; touch-action:none; font-size:17.5px; margin-bottom:11px; padding-bottom:8px; padding-right:24px; }
#mirror-menu .hint-close{ top:10px; right:10px; }
#mirror-menu .params .note{ font-size:var(--fs-xl); margin:-2px 0 0; }
.geom-menu .sym-row{ display:flex; align-items:center; gap:6px; margin-bottom:11px; }
.geom-menu .sym-row .part-toggle-btn{
  display:flex; align-items:center; justify-content:center;
  min-width:39px; height:39px; padding:0 8px; font-size:17px; border-radius:6px;
}
.geom-menu .sym-row .ico{ width:25px; height:25px; }
.geom-menu .sym-row > .ico{ color:var(--dim); margin-right:3px; }
/* Pressed reads at a glance: the accent paints the icon/letter and a tint
   fills the pill, on top of .active's accent border. */
.geom-menu .sym-row .part-toggle-btn.active{ color:var(--primary); background:var(--hover); }
.geom-menu .sym-row .param{ flex-direction:row; align-items:center; gap:8px; margin:0 0 0 11px; }
.geom-menu .sym-row .param label{ font-size:17px; }
.geom-menu .sym-row .param input{ width:78px; height:39px; box-sizing:border-box; padding-block:0; font-size:17px; }

/* ===== example gallery (feat 36) ========================================
   A full-window overlay, lifted from flow.html's `#screen-welcome` Examples
   tab -- the mock's `.p-tile` grid, `.thumb`/`.meta`/`.p-name`/`.p-date` class
   names kept verbatim so the two stay recognisably the same screen when the
   real welcome screen lands and this overlay goes away. Nothing here is a new
   colour: --chrome/--panel/--sunken/--rule/--dim/--faint/--primary/
   --contextual are all app.css:5-11's own tokens. */
.gallery{
  position:fixed; inset:0; z-index:60; display:flex; flex-direction:column;
  background:var(--bg); padding:22px 28px 8px;
}
.gallery[hidden]{ display:none; }
.gal-top{ display:flex; align-items:baseline; gap:14px; flex:none; }
.gal-top h2{ margin:0; font-size:20px; font-weight:600; letter-spacing:-.01em; }
.gal-count{ font-size:var(--fs-sm); color:var(--faint); flex:1; }
.gal-x{
  flex:none; width:28px; height:28px; border-radius:4px; border:0; cursor:pointer;
  background:transparent; color:var(--dim); font-size:20px; line-height:1;
}
.gal-x:hover{ background:var(--hover); color:var(--text); }
.gal-search{
  flex:none; margin:14px 0 0; max-width:320px; font:inherit; font-size:var(--fs-md);
  background:var(--chrome); border:1px solid var(--rule); color:var(--text);
  border-radius:5px; padding:8px 12px;
}
.gal-search::placeholder{ color:var(--faint); }
/* The Phase 1 / Phase 2 strip (feat 55). `.gate-seg-btn` is flex:1, which in a
   full-screen overlay stretches two tabs across the whole window; capped to the
   search box's width so the pair reads as one control above it. */
.gate-seg.gal-phase{ flex:none; max-width:320px; padding:14px 0 0; }
/* Two banners share this look and nothing else: `.gal-note` (why an example
   cannot open) and `.gal-save` (a note that failed to save). They are separate
   classes so each is uniquely addressable -- they were one, and `.gal-note`
   then matched both. */
.gal-banner{
  flex:none; margin-top:12px; padding:9px 12px; border-radius:5px;
  background:color-mix(in srgb, var(--contextual) 16%, transparent);
  border:1px solid var(--contextual); color:var(--text);
  font-size:var(--fs-sm); line-height:1.5; max-width:640px;
}
.gal-scroll{ flex:1; overflow-y:auto; margin-top:16px; padding:2px 6px 20px 2px; }
.gal-grid{
  display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:16px;
}
.gal-grid .p-tile{
  background:var(--chrome); border:1px solid var(--rule); border-radius:6px;
  overflow:hidden; display:flex; flex-direction:column; text-align:left;
  cursor:pointer; color:inherit; padding:0; font:inherit;
  transition:border-color .15s ease, transform .15s ease;
}
.gal-grid .p-tile:hover:not(:disabled){
  border-color:var(--contextual); transform:translateY(-3px);
}
.gal-grid .p-tile:disabled{ opacity:.45; cursor:not-allowed; }
@media (prefers-reduced-motion:reduce){
  .gal-grid .p-tile{ transition:none; }
  .gal-grid .p-tile:hover:not(:disabled){ transform:none; }
}
.gal-grid .thumb{
  height:104px; background:var(--sunken); display:flex;
  align-items:center; justify-content:center; color:var(--rule);
}
.gal-grid .thumb svg{ width:46px; height:46px; }
.gal-grid .meta{ padding:11px 13px 12px; display:flex; flex-direction:column; gap:3px; }
.gal-grid .p-name{ font-size:var(--fs-md); font-weight:600; color:var(--text); overflow-wrap:anywhere; }
.gal-grid .p-date{ font-size:var(--fs-sm); color:var(--dim); overflow-wrap:anywhere; }
/* How deep into the parity roadmap this model's last blocker sits. The list is
   ordered by it, and an order with nothing explaining it reads as no order. */
.p-step{
  align-self:flex-start; margin-top:4px; padding:1px 6px; border-radius:3px;
  font-size:var(--fs-xs); letter-spacing:.04em; text-transform:uppercase;
  color:var(--faint); border:1px solid var(--rule-soft);
}
.p-step-0{ color:var(--primary); border-color:var(--contextual); }

/* ---- the tree, while an example is open. Read-only rows: examples.js draws
   them through the same renderItemList the real tree uses, so .t-row and the
   depth indentation need no rules of their own here. */
.ex-title{ padding:0 2px; font-size:var(--fs-md); font-weight:600; color:var(--text); overflow-wrap:anywhere; }
.ex-sub{ padding:0 2px 8px; font-size:var(--fs-xs); color:var(--faint); }
.ex-warn{
  margin:0 0 8px; padding:7px 9px; border-radius:4px; font-size:var(--fs-xs); line-height:1.45;
  color:var(--text); background:color-mix(in srgb, var(--error) 18%, transparent);
  border:1px solid var(--error); overflow-wrap:anywhere;
}
.ex-tag{
  /* .item-row is `align-items:stretch`, so without the inner centring the
     label sits at the top of a row whose height the button sets. */
  display:flex; align-items:center; flex:none;
  padding-left:6px; font-size:var(--fs-xs); letter-spacing:.03em;
  text-transform:uppercase; color:var(--faint); white-space:nowrap;
}
/* A geom that exported no triangles AND should have. It is in the file and in
   the tree and it is NOT in the viewport -- half of what the compliance report
   is asking. A surfaceless geom (BLANK/HINGE/ROUTING) is NOT given this: it
   draws a marker glyph instead and is not a finding (feat 37 F1). */
.ex-tag-none{ color:var(--error); }
/* A sub-surface -- a control surface, a line, a rectangle (feat 37 F4). Not a
   geom and not a row: it hangs off its parent's row, because `blendedwingbody`
   carries five of them and the tree showed none. Dimmer than .ex-tag and NOT
   uppercased, because a sub-surface carries the modeller's own name and
   `SS_CONT_0` shouted in caps reads as an error code. */
.ex-tag-sub{
  text-transform:none; letter-spacing:0;
  color:var(--dim); opacity:.75;
}
.worktree.collapsed .ex-title, .worktree.collapsed .ex-sub,
.worktree.collapsed .ex-warn, .worktree.collapsed .ex-tag,
.worktree.collapsed .ex-tag-sub{ display:none; }

/* ---- the notepad (feat 36 row 7). The gallery tile carries the three
   compliance columns of docs/local/EXAMPLE_COMPLIANCE.md, and the SAME editor
   is mounted in the tree panel beside an open model. */

/* The previz colour IS the review status, which is the point of colouring it:
   the board reads at a glance from wherever you have scrolled to. Unset stays
   the app's own inert grey -- an unreviewed model must not look like a verdict. */
.gal-grid .p-tile[data-status="no"]     { border-color:var(--bad); }
.gal-grid .p-tile[data-status="partial"]{ border-color:var(--warn); }
.gal-grid .p-tile[data-status="yes"]    { border-color:var(--ok); }
.gal-grid .p-tile[data-status="no"]      .thumb{ color:var(--bad);
  background:color-mix(in srgb, var(--bad) 14%, var(--sunken)); }
.gal-grid .p-tile[data-status="partial"] .thumb{ color:var(--warn);
  background:color-mix(in srgb, var(--warn) 14%, var(--sunken)); }
.gal-grid .p-tile[data-status="yes"]     .thumb{ color:var(--ok);
  background:color-mix(in srgb, var(--ok) 14%, var(--sunken)); }

/* Shorter than it was: the tile now holds three controls under the glyph, and
   the glyph is a placeholder anyway (no thumbnails -- see examples.js THUMB). */
.gal-grid .thumb{ height:74px; }
.gal-grid .thumb svg{ width:34px; height:34px; }

.ex-open{
  margin-top:8px; width:100%; padding:6px 8px; font:inherit; font-size:var(--fs-sm);
  background:var(--panel); color:var(--dim); border:1px solid var(--rule);
  border-radius:4px; cursor:pointer;
}
.ex-open:hover:not(:disabled){ background:var(--hover); color:var(--text); border-color:var(--contextual); }
.ex-open:disabled{ opacity:.5; cursor:not-allowed; }

.ex-note{ display:flex; flex-direction:column; gap:6px; padding:0 13px 13px; }
/* In the tree panel there is no tile padding to inherit. */
.tree .ex-note{ padding:2px 2px 8px; }

.ex-seg{ display:flex; gap:4px; }
.ex-sw{
  flex:1; min-width:0; padding:5px 2px; font:inherit; font-size:var(--fs-xs);
  letter-spacing:.03em; text-transform:uppercase; cursor:pointer;
  background:transparent; color:var(--faint);
  border:1px solid var(--rule-soft); border-radius:4px;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.ex-sw:hover{ background:var(--hover); color:var(--text); }
/* Only the CHOSEN one is coloured. Three lit swatches would say the status is
   all three at once, which is what a plain hover/idle tint would look like. */
.ex-sw-no[aria-checked="true"]{
  background:color-mix(in srgb, var(--bad) 26%, transparent);
  border-color:var(--bad); color:var(--text);
}
.ex-sw-partial[aria-checked="true"]{
  background:color-mix(in srgb, var(--warn) 26%, transparent);
  border-color:var(--warn); color:var(--text);
}
.ex-sw-yes[aria-checked="true"]{
  background:color-mix(in srgb, var(--ok) 26%, transparent);
  border-color:var(--ok); color:var(--text);
}

.ex-field{
  width:100%; font:inherit; font-size:var(--fs-sm); padding:6px 8px;
  background:var(--sunken); color:var(--text);
  border:1px solid var(--rule-soft); border-radius:4px;
}
.ex-field::placeholder{ color:var(--faint); }
.ex-field:focus-visible{ border-color:var(--primary); }
.ex-text{ resize:vertical; min-height:44px; line-height:1.4; font-family:inherit; }

/* A save failure. Same shape as .gal-note but --error, because an optimistic
   editor that lost a write looks exactly like one that succeeded. */
.gal-save{
  background:color-mix(in srgb, var(--error) 18%, transparent);
  border-color:var(--error);
}
.worktree.collapsed .ex-note{ display:none; }

/* ===== the agent gallery (feat 39 row 4) ===== */
/* The gate's review strip, under the example tree. Deliberately quiet: it sits
   beside the compliance notepad and must not compete with the viewport, which
   is the thing actually being reviewed. */
.gate-panel {
  border-top: 1px solid var(--rule);
  margin-top: 8px;
  padding-top: 8px;
  /* body sets no size, so without this the panel read at the UA's 16px beside
     a 13px tree. Everything inside inherits it unless it sets its own. */
  font-size: var(--fs-sm);
}
.gate-head {
  font-weight: 600;
  padding: 0 8px 6px;
}
.gate-seg {
  gap: 4px;
  padding: 0 8px 6px;
}
/* The Geometry/Agent tab strip (chrome.js) and the gallery's Phase 1/Phase 2
   strip (examples.js) both carry `.ex-tabs` and stay a plain flex row of two,
   same as before the grid rule below existed. */
.gate-seg.ex-tabs { display: flex; }
/* feat 58 row 7: gate.js's own segment strip (Reference/Agent/Batch/Optimiser
   [/Ladder]) grew a 5th button and no longer fits one flex row at 12px in the
   ~250px tree column -- "Reference Agent Batch Optimiser" already filled it
   (screenshot, 2026-09-16). A 3-column grid wraps to a second row instead of
   shrinking the buttons further; the level strip below reuses this same
   selector (it is a `.gate-seg` too, five chips of its own). Phase 2's
   2-button case (Reference/Agent only, same bare `.gate-seg` with no `.ex-tabs`)
   still lands on one row -- 2 items in 3 columns just leaves the third empty. */
.gate-seg:not(.ex-tabs) {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}
.gate-seg-btn { flex: 1; min-width: 0; justify-content: center; font-size: var(--fs-sm); padding: 5px 4px; }
/* The selected pipeline. Same affordance the compliance strip's segmented
   status buttons already use, so the two strips read as one review surface. */
.gate-seg-btn[aria-selected="true"] {
  /* The 16%-of-primary mix is this stylesheet's own selected-row
     convention -- see `.item-children .t-row[aria-selected="true"]`. Reused
     rather than invented so the two strips cannot drift apart. */
  background: color-mix(in srgb, var(--primary) 16%, transparent);
  color: var(--text);
}
.gate-status {
  padding: 0 8px 6px;
  font-size: var(--fs-sm);
  color: var(--dim);
}

/* The brief, always visible -- see gate.js for why it is not a <details>.
   Scrolls in its own box rather than growing the tree column: at ~170 words it
   would push the notepad off screen, and the notepad is what the grader is
   here to fill in. */
.gate-brief-head { padding-top: 4px; }
.gate-brief-body {
  margin: 0 8px 8px;
  max-height: 220px;
  overflow-y: auto;
  white-space: pre-wrap;
  line-height: 1.5;
  color: var(--dim);
}

/* ===== the optimiser stepper (feat 43 row 2) =====
   Prev/next over the scored iterations of a `gate_optimise.py` run. Toggled by
   `hidden` rather than rebuilt, because gate.js's segment switches views
   without going through renderTree() -- so both flex containers need their own
   `[hidden]` rule: an author `display` beats the UA sheet's, and without these
   `hidden = true` is a silent no-op (see web/README.md). */
.gate-opt[hidden], .gate-step[hidden], .gate-opt-notes[hidden]{ display:none; }
.gate-opt{ padding-bottom:2px; }
/* Label above, buttons below. Between them the label wrapped to two lines in a
   ~230px tree column and squeezed both buttons; stacked, each button takes half
   the width, which is also the bigger hit target. */
.gate-step{
  display:flex; align-items:center; gap:6px; padding:0 8px 6px;
}
/* A soft outline the segment buttons do not have: those are a pick-one-of-three
   whose selected state carries the affordance, these are two ordinary controls
   and read as plain text without it. */
.gate-step-btn{
  flex:1; min-width:0; justify-content:center;
  border:1px solid var(--rule-soft); border-left-width:1px;
}
.gate-step-btn:not([disabled]):hover{ border-color:var(--rule); }
.gate-step-label{
  padding:0 8px 4px; text-align:center; font-size:var(--fs-sm); color:var(--text);
  font-variant-numeric:tabular-nums;
}
/* The measured numbers. Tabular figures so stepping does not shuffle the row
   sideways as digits change width. */
.gate-step-meta{
  padding:0 8px 4px; font-size:var(--fs-sm); color:var(--dim);
  font-variant-numeric:tabular-nums;
}
.gate-step-meta[hidden]{ display:none; }
/* A qualifying sentence: skipped iterations, the rejected-rewrite note, and
   the "written AFTER this iteration" label under REVIEWER's notes. */
.gate-hint{
  padding:0 8px 6px; font-size:var(--fs-sm); line-height:1.4; color:var(--faint);
}
.gate-hint[hidden]{ display:none; }
.gate-step-note{ color:var(--warn); }
/* The promote command, printed rather than run -- promotion writes the owner's
   ledger. Selectable, wraps rather than truncates: a command the reader cannot
   see the end of is a command they cannot run. */
.gate-cmd{
  margin:0 8px 8px; padding:6px 8px; font-size:var(--fs-sm); line-height:1.4;
  font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
  background:var(--sunken); color:var(--dim);
  border:1px solid var(--rule-soft); border-radius:4px;
  overflow-wrap:anywhere; user-select:text;
}
.gate-cmd[hidden]{ display:none; }
/* REVIEWER's notes reuse .gate-brief-body's scroll box: same kind of content
   (prose, in its own box, under the brief) and one rule to keep them alike. */
.gate-opt-notes .gate-brief-body{ max-height:180px; }

/* ===== the Ladder segment (feat 58 row 7) =====
   The level strip is a `.gate-seg` (the grid rule above already wraps its
   five L0-L4 chips), disabled state and selected tint both borrowed for free
   from `.gate-seg-btn`/`[disabled]`. The turn stepper reuses `.gate-opt`/
   `.gate-step*` verbatim -- ladder.js gives it the extra `ladder-step-wrap`/
   `ladder-step-btn` classes only so a test can tell it apart from the
   optimiser's own stepper; `.gate-opt[hidden]` above already covers it. Only
   the run picker is new layout: a plain wrapping row, hidden like everything
   else here with an explicit rule because it gets an author `display`. */
.ladder-runs{
  display:flex; flex-wrap:wrap; gap:4px; padding:0 8px 6px;
}
.ladder-runs[hidden]{ display:none; }

/* The example column's two tabs (Geometry / Agent review). Same segmented
   affordance as .gate-seg, whose rules it borrows by carrying both classes --
   one visual language for "pick one of two" across the review surfaces. */
.ex-tabs { padding-top: 8px; }
/* First thing in the column, so the panel below it needs no second divider. */
.ex-tabs + .gate-panel { border-top: 0; margin-top: 0; }

/* ===== lab flow (feat 60, restyled to the brand system 2026-10-01) ==========
   The consent -> begin -> survey -> done screens of web/app/lab.js, and its
   timer chip. Follows nodevsp-branding/DESIGN.md: three voices (Archivo for
   the wordmark and headings, Inter for everything read, Plex Mono only for a
   one-line value: the clock and the code), pill buttons, raised panels with a
   strong hairline, and one soft glow falling from the top edge. No shadows.
   The brand's values are set as the app's own token NAMES, scoped to `.lab` and
   `.lab-chip` (the chip lives inside #view, not inside .lab), so every rule
   below keeps reading var(--text) etc. while the modeler's :root tokens, and
   check_app.py's parity check on them, are untouched. Both themes are defined below.
   `.lab-btn` is also the dialog's button (modal.js): its SHAPE is app-wide, its
   brand colours live under `.lab`, so the dialog stays right in the light theme. */
.lab, .lab-chip{
  --chrome:#1a1a1a; --panel:#212121; --hover:#2b2b2b;
  --rule:#3a3a3a; --rule-soft:#2e2e2e;
  --text:#ececec; --dim:#a3a3a3; --faint:#858585;
  --hot:#f2622f; --ember:#f0916c; --ease:cubic-bezier(.16,1,.3,1);
  /* Dark brand values; the light ones follow below, under :root.light. */
  --bg:#151515; --sunken:#111111; --primary:#E8501F; --error:#B8394B; --on-primary:#151515;
  color:var(--text);
  font-family:var(--font-body);
}
:root.light .lab, :root.light .lab-chip{
  /* DESIGN.md "Light theme (toggle)": ground #fafafa, raised #fff, step #f0f0f0,
     hairlines #e5e5e5/#d4d4d4, text #151515/#4b4b4b/#6b6b6b, accent #C43D14. */
  --bg:#fafafa; --chrome:#ffffff; --panel:#f0f0f0; --sunken:#f0f0f0; --hover:#f0f0f0;
  --rule:#d4d4d4; --rule-soft:#e5e5e5;
  --text:#151515; --dim:#4b4b4b; --faint:#6b6b6b;
  --primary:#C43D14; --hot:#a93410; --ember:#C43D14; --on-primary:#ffffff; --error:#B3261E;
}
:root.light .lab{ background-image:radial-gradient(ellipse 90% 35% at 50% 0, rgba(196,61,20,.12), transparent 70%); }
:root.light .lab-err{ color:var(--error); }
.lab{
  position:fixed; inset:0; z-index:80; overflow-y:auto; background:var(--bg);
  background-image:radial-gradient(ellipse 90% 35% at 50% 0, rgba(232,80,31,.16), transparent 70%);
  display:flex; flex-direction:column;
}
.lab[hidden]{ display:none; }
.lab ::selection{ background:color-mix(in srgb, var(--primary) 45%, transparent); color:var(--text); }
.lab a{ color:var(--ember); }
.lab-bar{ display:flex; align-items:center; gap:10px; padding:20px clamp(20px,4vw,48px) 0; flex:none; }
.lab-bar svg{ width:22px; height:22px; }
.lab-bar span{
  font-family:var(--font-display); font-size:13px; font-weight:800; font-stretch:125%;
  letter-spacing:.1em; text-transform:uppercase;
}

.lab-screen{
  width:100%; max-width:1080px; margin:0 auto; flex:1;
  padding:clamp(20px,5vh,56px) clamp(20px,4vw,48px) 64px;
  display:grid; gap:clamp(28px,5vw,64px); align-content:start; align-items:center;
}
.lab-screen[data-wide]{ grid-template-columns:minmax(0,1.05fr) minmax(0,1fr); }
@media (max-width:860px){
  .lab-screen[data-wide]{ grid-template-columns:1fr; }
  .lab-hero{ display:none; }
}
.lab h1{
  margin:0; font-family:var(--font-display); font-size:clamp(28px,3.9vw,46px); line-height:1.08;
  font-weight:800; font-stretch:118%; letter-spacing:-.02em; text-wrap:balance; outline:none;
}
.lab-lead{ margin:18px 0 0; max-width:52ch; font-size:clamp(15px,1.5vw,16.5px); line-height:1.65; color:var(--dim); }
.lab-fine{ margin:14px 0 0; max-width:56ch; font-size:13.5px; line-height:1.55; color:var(--dim); }

.lab-panel{ margin:26px 0 0; padding:26px 28px; border:1px solid var(--rule); border-radius:14px; background:var(--chrome); max-width:520px; }
.lab-panel h2{ margin:0 0 14px; font-family:var(--font-display); font-size:15px; font-weight:700; font-stretch:112%; letter-spacing:-.015em; }
.lab-panel ul{ margin:0; padding:0; list-style:none; display:grid; gap:10px; font-size:14px; line-height:1.5; color:var(--dim); }
.lab-panel strong{ color:var(--text); font-weight:600; }
/* The landing's .lab-goals marker: an 11px orange dot. The controls list keeps
   its glyphs instead. */
.lab-panel ul:not(.lab-controls) li{ position:relative; padding-left:26px; }
.lab-panel ul:not(.lab-controls) li::before{
  content:""; position:absolute; left:0; top:.4em; width:11px; height:11px; border-radius:50%; background:var(--primary);
}
.lab-controls li{ display:flex; align-items:center; gap:10px; }
.lab-ico{ flex:none; width:22px; height:22px; color:var(--dim); }
.lab-ico svg{ display:block; width:100%; height:100%; }

.lab-agree{ display:flex; gap:12px; align-items:flex-start; margin:22px 0 0; min-height:44px; font-size:14.5px; line-height:1.5; cursor:pointer; }
.lab-agree input{ flex:none; width:20px; height:20px; margin:2px 0 0; accent-color:var(--primary); cursor:pointer; }
.lab-actions{ display:flex; gap:12px; align-items:center; flex-wrap:wrap; margin-top:22px; }

.lab-btn{
  display:inline-flex; align-items:center; justify-content:center; min-height:48px; padding:0 26px;
  font-family:var(--font-body); font-size:15px; font-weight:600; text-decoration:none; cursor:pointer;
  color:var(--text); background:transparent; border:1px solid var(--rule); border-radius:999px;
  transition:background-color .25s var(--ease), border-color .25s var(--ease), color .25s var(--ease), transform .25s var(--ease);
}
.lab-btn:hover:not(:disabled){ background:var(--hover); }
.lab-btn:active:not(:disabled){ transform:translateY(1px); }
.lab-btn:focus-visible{ outline:2px solid var(--primary); outline-offset:3px; }
.lab-btn:disabled{ cursor:not-allowed; color:var(--faint); background:var(--chrome); border-color:var(--rule-soft); }
.lab-btn-go{ color:var(--on-primary); background:var(--primary); border-color:var(--primary); }
.lab-btn-go:hover:not(:disabled){ background:var(--hot, color-mix(in srgb, var(--primary) 86%, #fff)); border-color:var(--hot, transparent); }
.lab-btn-go:disabled{ color:var(--faint); background:var(--chrome); border-color:var(--rule-soft); }

/* The ES/EN and Light/Dark switches (lab.js prefsSwitches): quiet pills at the bar's far edge. */
.lab-prefs{ margin-left:auto; display:flex; gap:8px; }
.lab-lang{
  min-width:48px; min-height:44px; padding:0 14px; cursor:pointer;
  font-family:var(--font-body); font-size:13px; font-weight:600; letter-spacing:.04em; color:var(--text);
  background:transparent; border:1px solid var(--rule); border-radius:999px;
  transition:background-color .25s var(--ease), border-color .25s var(--ease);
}
.lab-lang:hover{ background:var(--hover); }
.lab-lang:focus-visible{ outline:2px solid var(--primary); outline-offset:3px; }

/* The reference set, as pictures. One authored moment: the tiles rise in once,
   exponential ease-out, from a default that is already visible. */
.lab-hero{ display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:0; padding:0; list-style:none; }
.lab-hero li{
  border:1px solid var(--rule-soft); border-radius:14px; overflow:hidden; background:var(--sunken);
  animation:lab-rise .6s var(--ease) backwards;
}
.lab-hero li:nth-child(even){ position:relative; top:20px; }
.lab-hero li:nth-child(2){ animation-delay:.08s; }
.lab-hero li:nth-child(3){ animation-delay:.16s; }
.lab-hero li:nth-child(4){ animation-delay:.24s; }
.lab-hero img{ display:block; width:100%; aspect-ratio:16/10; object-fit:cover; }
@keyframes lab-rise{ from{ opacity:0; transform:translateY(14px); } }
@media (prefers-reduced-motion:reduce){
  .lab-hero li{ animation:none; }
  .lab-btn, .lab-lang, .lab-scale span{ transition:none; }
}

.lab-clock{
  margin:26px 0 0; font-family:var(--font-mono);
  font-size:clamp(64px,12vw,96px); font-weight:500; line-height:1; letter-spacing:-.02em;
  font-variant-numeric:tabular-nums;
}

.lab-form{ display:grid; gap:34px; max-width:640px; margin-top:8px; }
.lab-q{ display:block; margin:0; padding:0; border:0; min-width:0; }
.lab-label{ display:block; padding:0; margin-bottom:10px; font-size:15px; font-weight:600; line-height:1.4; }
.lab-hint{ display:block; margin-top:2px; font-size:13.5px; font-weight:400; color:var(--faint); }
.lab-field{
  display:block; width:100%; font:inherit; font-size:15px; line-height:1.5; color:var(--text);
  background:var(--chrome); border:1px solid var(--rule); border-radius:12px; padding:12px 16px;
  caret-color:var(--primary);
}
.lab-field:focus-visible{ outline:2px solid var(--primary); outline-offset:3px; border-color:var(--primary); }
textarea.lab-field{ resize:vertical; min-height:96px; }
.lab-brief{ min-height:150px; font-size:16px; }

.lab-scale{ display:grid; grid-auto-flow:column; grid-auto-columns:1fr; gap:6px; }
.lab-scale label{ position:relative; display:block; }
.lab-scale input{ position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; }
.lab-scale span{
  display:flex; align-items:center; justify-content:center; min-height:44px; padding:6px 8px; text-align:center;
  font-size:14px; font-weight:500; color:var(--dim); background:var(--chrome);
  border:1px solid var(--rule); border-radius:12px; transition:background-color .25s var(--ease), color .25s var(--ease);
}
.lab-scale label:hover input:not(:checked) + span{ background:var(--hover); color:var(--text); }
.lab-scale input:checked + span{ color:var(--on-primary); background:var(--primary); border-color:var(--primary); font-weight:600; }
.lab-scale input:focus-visible + span{ outline:2px solid var(--primary); outline-offset:3px; }
.lab-ends{ display:flex; justify-content:space-between; margin-top:6px; font-size:12.5px; color:var(--faint); }

/* role=alert, so it stays in the layout (display:none regions are announced
   unreliably) and collapses to nothing while empty. */
.lab-err{
  margin:0; padding:12px 16px; border:1px solid color-mix(in srgb, var(--error) 70%, transparent); border-radius:12px;
  background:color-mix(in srgb, var(--error) 14%, transparent);
  color:color-mix(in srgb, var(--error) 40%, #fff); font-size:14px; line-height:1.5;
}
.lab-err:empty{ padding:0; border:0; background:none; }

.lab-code{
  display:inline-block; margin:8px 0 0; padding:14px 22px; border:1px solid var(--rule); border-radius:14px; background:var(--chrome);
  font-family:var(--font-mono); font-weight:500; font-size:clamp(28px,5vw,40px); letter-spacing:.06em;
}
.lab-shot{ margin:0; }
.lab-shot canvas{
  display:block; width:100%; aspect-ratio:4/3; border:1px solid var(--rule-soft); border-radius:14px;
  background:var(--sunken); cursor:grab; touch-action:none;
}
.lab-shot canvas:active{ cursor:grabbing; }
.lab-shot figcaption{ margin:8px 0 18px; font-size:13px; color:var(--faint); }
/* Four buttons under a half-width canvas: tighter pills that share the row,
   so they sit on one line down to a phone-width column; narrower still, they wrap. */
.lab-shot .lab-actions{ gap:8px; margin-top:0; }
.lab-shot .lab-btn{ flex:1 1 auto; padding:0 14px; white-space:nowrap; }
@media (max-width:480px){ .lab-shot .lab-actions{ gap:6px; } .lab-shot .lab-btn{ padding:0 10px; font-size:14px; } }

/* `position:absolute` inside `.viewport` (`#view`, `position:relative`), NOT
   `fixed` on the page -- same containment as `.gizmo`/`.hud`/`.chat` below.
   `fixed; right:14px` measured from the BROWSER edge, so on a normal window
   it sat on top of the Options panel; `.viewport` is the flexible middle
   column, and its right edge is the boundary with that panel, not inside it.
   A pill on the raised ground, a hairline and no shadow. */
.lab-chip{
  position:absolute; right:14px; bottom:14px; z-index:5; display:flex; align-items:center; gap:12px;
  padding:6px 6px 6px 20px; background:var(--chrome); border:1px solid var(--rule); border-radius:999px;
}
.lab-chip[hidden]{ display:none; }
.lab-time{ font-family:var(--font-mono); font-size:17px; font-weight:500; font-variant-numeric:tabular-nums; }
.lab-chip[data-low] .lab-time{ color:var(--ember); }
.lab-chip[data-paused] .lab-time{ opacity:.5; }
.lab-chip .lab-btn{ min-height:44px; padding:0 18px; font-size:13.5px; }

/* The operator's "this isn't me" escape (lab.js: resetSession). A text link,
   not a lab-btn: it must read as a quiet exit, never as the next step. */
.lab-reset{
  margin-left:auto; display:inline-flex; align-items:center; min-height:44px; background:none; border:0; padding:0 8px; cursor:pointer;
  font:inherit; font-size:12px; color:var(--faint); text-decoration:underline;
  text-decoration-color:var(--rule); text-underline-offset:2px;
}
.lab-reset:hover{ color:var(--dim); text-decoration-color:var(--dim); }
.lab-reset:focus-visible{ outline:2px solid var(--primary); outline-offset:3px; }
.lab-chip .lab-reset{ margin-left:0; font-size:11.5px; }

/* ---- the one confirmation dialog (feat 62 row 8, app/modal.js) ----
   A native <dialog>, so the focus trap, Escape, the backdrop and the top layer
   are the browser's; only the box is ours. `color`/`background` are set because
   the UA stylesheet hands a dialog `canvas`/`canvastext`, which is white on a
   dark app. Buttons wear the lab screens' `.lab-btn` rather than a second pair
   of button rules -- those declarations are not lab-specific, only lab-named,
   and this dialog is app-wide (chrome.js's Clear uses it outside lab mode). */
.confirm{
  max-width:min(420px, calc(100vw - 32px)); padding:22px 24px 18px;
  color:var(--text); background:var(--chrome);
  border:1px solid var(--rule); border-radius:10px;
  box-shadow:0 18px 48px rgba(0,0,0,.55);
}
.confirm::backdrop{ background:rgba(0,0,0,.55); }
.confirm p{ margin:0; max-width:46ch; font-size:15px; line-height:1.55; }
.confirm-actions{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; margin-top:20px; }

/* ===== body views (feat 63 row 2, web/app/bodyviews.js) ==============
   The front/side pair above the fuselage panel, and the preset gallery's
   silhouettes. There is deliberately no orange rule here: the highlighted
   outline wears `.xsec-curve` (line 610) unchanged, so the cross-section and
   airfoil tabs and these two views cannot drift into two different
   --contextual treatments. Only the GREY half is new. `.xsec-centerline` is
   reused the same way.
   The svg carries its own box rather than sitting in a wrapper div like
   .foil-plot/.xsec-plot: bodyviews.js returns a bare detached <svg>, and its
   viewBox is already proportioned to the body (one scale on both axes), so
   `height:auto` is what keeps the aspect honest -- an aspect-ratio here would
   fight it and squash a 12 m fuselage back into a square. */
.bodyview{
  display:block; width:100%; height:auto;
  background:var(--sunken); border:1px solid var(--rule); border-radius:4px;
}
.bodyview-ghost{ fill:none; stroke:var(--dim); stroke-width:1; }
/* --faint, not --rule: a station tick on --sunken has to stay visible at
   3:1, and --rule is a divider colour that all but vanishes on it. */
.bodyview-tick{ stroke:var(--faint); stroke-width:1; }
/* A preset tile is art beside its own name, not a plot: no box, and no
   centreline either -- the dashes run the full padded width, which on a
   boxless tile reads as a stray line rather than an axis. */
.bodyview-tile{ background:none; border:0; border-radius:0; }
.bodyview-tile .xsec-centerline{ display:none; }

/* ===== ruler (feat 83) ===================================================
   The label for app/ruler.js's drag ruler -- a pointer-events:none overlay in
   #view, same shape as .dims-badge above: a translucent panel over the
   render, never eating a pick or an orbit. The 3D line and ticks are drawn in
   the scene itself (ruler.js), not here -- there is nothing to style on a
   THREE.Line. Positioned in JS (style.left/top, screen px, following the
   dragged point) rather than in CSS, since that point moves every frame. */
.ruler-label{
  position:absolute; z-index:4; pointer-events:none;
  padding:2px 7px; border-radius:5px; font-size:var(--fs-sm); line-height:1.3;
  font-variant-numeric:tabular-nums; white-space:nowrap;
  background:color-mix(in srgb, var(--panel) 85%, transparent);
  border:1px solid var(--rule); color:var(--text);
}
.ruler-label[hidden]{ display:none; }

/* ===== viewport toolbar, tooltip, shortcuts, empty state (feat 84) ==========
   Row 2's one appended section -- everything above is Row 1's (app.css's
   existing sections plus the untouched lab block). Four unrelated features
   share it only because the file-split rule is "one new section per feature",
   not "one per file this feature touches". */

/* ---- viewport toolbar (app/viewbar.js, A4/A5) ------------------------------
   Top-right of #view, where the old <select id="viewstyle"> sat (top:10px
   right:12px) -- a horizontal pill of 28px icon buttons instead of a native
   dropdown and a separate tab-bar text button. */
.viewbar{
  position:absolute; top:10px; right:12px; z-index:3;
  display:flex; align-items:center; gap:2px;
  padding:3px; background:var(--panel); border:1px solid var(--rule);
  border-radius:6px; box-shadow:0 4px 14px rgba(0,0,0,.3);
}
.vb-group{ display:flex; gap:2px; }
.vb-sep{ width:1px; height:18px; margin:0 3px; flex:none; background:var(--rule); }
.vb-btn{
  width:28px; height:28px; flex:none;
  display:flex; align-items:center; justify-content:center;
  background:transparent; border:0; border-radius:4px; color:var(--dim); cursor:pointer;
}
.vb-btn:hover{ background:var(--hover); color:var(--text); }
.vb-btn[aria-checked="true"], .vb-btn[aria-pressed="true"]{ background:var(--hover); color:var(--primary); }

/* ---- chat Send (A11) --------------------------------------------------------
   #chat-send: an id selector, so this beats .c-form button[type="submit"]'s
   class+attribute rule (app.css, existing) for every property listed here --
   `display` is deliberately NOT one of them, so
   `.chat.compact .c-form button[type="submit"]{ display:none; }` (Enter-only
   in the compact bar) still wins for that property. Centred with
   line-height/text-align instead of flex for the same reason: `display:flex`
   here would also beat that `display:none`. */
#chat-send{
  width:32px; height:32px; padding:0; border-radius:50%;
  text-align:center; line-height:32px;
}
#chat-send svg{ vertical-align:middle; }

/* ---- empty state card (app.html's #hint, A8) -------------------------------
   `.hint` itself (app.css, existing) already centres its content and sets
   pointer-events:none -- the card opts back in so its buttons are clickable,
   the rest of the overlay still lets an orbit drag through to the canvas. */
.hint.onboard{ align-items:flex-start; padding-top:14vh; }   /* above the origin, not on it */
.hint-card{
  pointer-events:auto; display:none; flex-direction:column; align-items:center;
  gap:10px; padding:14px 18px 12px; text-align:center; position:relative;
  background:color-mix(in srgb, var(--panel) 70%, transparent);
  -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
  border:1px solid var(--rule-soft); border-radius:10px;
}
.hint-card h2{ margin:0; font-size:var(--fs-md); font-weight:600; color:var(--dim); }
.hint-actions{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; }
.hint-actions button{
  display:flex; align-items:center; gap:6px; padding:6px 11px;
  background:transparent; border:1px solid var(--rule); border-radius:6px;
  color:var(--text); font:inherit; font-size:var(--fs-sm); cursor:pointer;
}
.hint-actions button:hover{ border-color:var(--primary); background:var(--hover); }
.hint-actions svg{ flex:none; width:16px; height:16px; color:var(--dim); }
.hint-sub{ margin:0; font-size:var(--fs-xs); color:var(--faint); }
.hint-close{
  position:absolute; top:6px; right:6px; width:24px; height:24px;
  display:flex; align-items:center; justify-content:center;
  background:transparent; border:0; border-radius:4px; color:var(--faint); cursor:pointer;
}
.hint-close:hover{ background:var(--hover); color:var(--text); }
/* viewport.js inertiaNote: the one-time "inertia can be turned off" note,
   bottom-centre of #view, clear of the gizmo and the compact chat. */
.vp-note{
  position:absolute; left:50%; bottom:74px; transform:translateX(-50%); z-index:5;
  max-width:min(440px, calc(100% - 32px)); padding:9px 36px 9px 12px;
  background:var(--chrome); border:1px solid var(--rule); border-radius:8px;
  box-shadow:0 8px 24px rgba(0,0,0,.35); font-size:var(--fs-sm); color:var(--text);
  display:flex; align-items:center; gap:12px;
}
.vp-note-btn{
  flex:none; padding:4px 10px; font:inherit; color:var(--text); cursor:pointer;
  background:transparent; border:1px solid var(--rule); border-radius:6px;
}
.vp-note-btn:hover{ border-color:var(--primary); background:var(--hover); }
.vp-note .hint-close{ top:50%; transform:translateY(-50%); }
.hint-lab{ margin:0; }
/* feat 84: the card is opt-in. `.onboard` is added by app.html's entry script
   OUTSIDE lab mode only (feat 60's timed Spanish-copy study keeps the original
   one-line hint, and never paints the card, not even for the first frame), and
   the close button removes it again, which drops back to that one line. */
.hint.onboard .hint-card{ display:flex; }
.hint.onboard .hint-lab{ display:none; }

/* ---- tooltip (app/tooltip.js, A10) ------------------------------------------
   One floating element, positioned in JS (place()) because its anchor moves
   with whatever it is attached to; only its rest-state look lives here. */
.tip{
  position:fixed; z-index:30; pointer-events:none;
  display:flex; align-items:center; gap:6px;
  max-width:260px; padding:5px 8px; border-radius:5px;
  background:var(--chrome); border:1px solid var(--rule); color:var(--text);
  font-size:var(--fs-xs); line-height:1.35; box-shadow:0 6px 18px rgba(0,0,0,.4);
  opacity:0; transform:translateY(-4px); transition:opacity .1s ease, transform .1s ease;
}
.tip.show{ opacity:1; transform:none; }
.tip.above{ transform:translateY(4px); }
.tip.above.show{ transform:none; }
.tip-kbd{
  flex:none; padding:1px 5px; border-radius:3px;
  background:var(--sunken); border:1px solid var(--rule); color:var(--faint);
  font:10px ui-monospace,SFMono-Regular,Menlo,monospace;
}

/* ---- shortcuts dialog (app/shortcuts.js, A9) --------------------------------
   `.confirm` (app.css, existing) is app/modal.js's own dialog box -- reused
   here for the background/border/radius/shadow/::backdrop, with a wider
   max-width and its own padding overriding it below (same specificity, later
   in the file wins). Its Close button wears `.lab-btn`/`.lab-btn-go`, same
   reuse `.confirm`'s own buttons make (web/README.md: those classes are
   lab-NAMED, not lab-SPECIFIC). */
.shortcuts-dialog{ max-width:min(560px, calc(100vw - 32px)); padding:22px 26px 20px; }
.shortcuts-dialog h2{ margin:0 0 4px; font-size:var(--fs-lg); font-weight:600; color:var(--text); }
.shortcuts-list{
  list-style:none; margin:14px 0 0; padding:0; display:grid; gap:2px;
  max-height:60vh; overflow:auto;
}
.shortcuts-list li{
  display:flex; align-items:baseline; justify-content:space-between; gap:16px;
  padding:6px 0; border-bottom:1px solid var(--rule-soft);
  font-size:var(--fs-sm); color:var(--text);
}
.shortcuts-list li:last-child{ border-bottom:0; }
.shortcuts-list kbd{
  flex:none; padding:1px 6px; border-radius:3px;
  background:var(--sunken); border:1px solid var(--rule); color:var(--faint);
  font:11px ui-monospace,SFMono-Regular,Menlo,monospace;
}

/* ---- image export dialog (app/capture.js) ---------------------------------
   `.confirm` box and `.lab-btn` buttons, same reuse as .shortcuts-dialog. */
.capture-dialog h2{ margin:0 0 4px; font-size:var(--fs-lg); font-weight:600; color:var(--text); }
.capture-dialog p{ color:var(--dim); font-size:var(--fs-sm); margin-bottom:12px; }
.capture-dialog label{ display:flex; align-items:center; gap:8px; padding:4px 0; font-size:var(--fs-sm); cursor:pointer; }
.capture-size{ display:flex; flex-wrap:wrap; align-items:center; gap:8px 12px; }
.capture-dialog input[type=number], .capture-dialog select{
  background:var(--panel); color:var(--text); border:1px solid var(--rule);
  border-radius:4px; padding:3px 6px; font:inherit;
}
.capture-size input{ width:10ch; }
.capture-dialog .capture-out{ margin:8px 0 10px; font-variant-numeric:tabular-nums; }
