/* Screens added in the second pass: shell layers, Map, Issues, Analytics, Reports.
 * Tokens and the shared component language live in app.css. */

/* ===================================================================== shell
 * Layers that must sit OUTSIDE the smoother's transform. */
/* 🔴 The canvas paints UNDER the scroll column and shows through a transparent
   hole in the stage panel. At a z-index above the column it covered the pins,
   the rail and the hero — and the pins were positioned correctly the whole time,
   which is exactly what makes this one hard to see. */
#gl {
  position: fixed; left: 0; top: 0; z-index: 1;
  transform-origin: 0 0; pointer-events: auto;
  border-radius: var(--r-lg);
  /* 🔴 ITS OWN COMPOSITOR LAYER, OR IT TEARS AGAINST THE PAGE WHEN THE WHEEL SCROLLS.
   * The canvas is `position: fixed` and re-placed by JS every frame from the stage rect, while
   * the page it has to stay glued to is moved by ScrollSmoother as a compositor transform. The
   * numbers agree — measured over 84 frames of wheel scrolling, canvas translateY minus stage
   * rect top was 0.00 on every single one — so this was never a calculation lag. What differed
   * was WHERE the two were composited: a 2D translate on a layer the browser had not promoted is
   * free to repaint on the main thread, a frame out of step with content that is already on the
   * GPU. Wheel deltas are large enough per frame for that seam to read as flicker; a drag moves
   * in small enough steps that it does not.
   * `will-change` promotes it, and the stage writes translate3d so the transform itself is a
   * compositor operation rather than a repaint. */
  will-change: transform;
  backface-visibility: hidden;
}
#gl[hidden] { display: none; }

/* The dissolve layer: a still copy of the last frame, fading off while the next date draws
   underneath it. Same z-index as the canvas and immediately after it in the DOM, so painting
   order puts it on top of `#gl` and `#smooth-wrapper` (z-index 2) still covers both - the
   interface is never under it. It must never take a pointer: the orbit handlers live on `#gl`,
   and a 220 ms layer that swallowed a drag would read as the camera sticking. */
#glfade {
  position: fixed; left: 0; top: 0; z-index: 1;
  transform-origin: 0 0; pointer-events: none;
  border-radius: var(--r-lg);
}
#glfade[hidden] { display: none; }
#glfade.full { border-radius: 0; }

.mast .mastxt { min-width: 0; }
.langtog { margin-inline-start: 12px; font-weight: 700; }

.panel.empty { padding: 64px var(--sp-6); text-align: center; }
.panel.empty h2 { margin: 0 0 6px; font-size: var(--t-h2); }
.panel.empty p { margin: 0; color: var(--slate); }

.panel > header .note {
  font-size: var(--t-label); color: var(--stone-text); max-width: 46ch;
  text-align: end; line-height: 1.4;
}
.kpis.kpis-4 { grid-template-columns: repeat(4, 1fr); }

.pill {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  min-height: 38px; padding: 0 16px; border-radius: var(--r-pill);
  background: var(--ink); color: var(--canvas); font-size: 13px; font-weight: 600;
}
.pill svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 1.9;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
.pill.ghost { background: var(--card); color: var(--ink); border: 1px solid var(--hairline); }
.field input[type="date"] {
  min-height: 38px; padding: 0 12px; border-radius: var(--r-sm);
  border: 1px solid var(--hairline); background: var(--card); font-size: 13px;
  font-family: inherit;
}

/* ===================================================================== map */
.mapcols {
  display: grid; grid-template-columns: minmax(0, 1fr) 320px;
  gap: var(--sp-4); align-items: start;
}
/* transparent so the canvas underneath is visible; the border still reads as a panel */
.stagewrap { padding: 0; overflow: hidden; background: transparent; }
.stage {
  position: relative; height: min(62vh, 620px); min-height: 380px;
  border-radius: var(--r-lg); overflow: hidden;
  background: transparent;
  /* 🔴 A transparent region layered over the canvas still takes every pointer
     hit, so orbit, zoom and click would be silently dead with no error. Only
     the chrome takes events; everything else falls through to the canvas. */
  pointer-events: none;
}
.stage.noscene-on { background: var(--hairline-soft); }
.stage .pins, .stage .stagechrome { pointer-events: none; }
/* 🔴 Every new control in the chrome must be named HERE. `.stage` is pointer-events:none so the
   canvas underneath can be orbited, and `.stagechrome` inherits it — a button added without
   being added to this list renders perfectly and does nothing at all. */
.stage .pin, .stage .namechip, .stage .noscene, .stage .stageback,
.stage .stagefull, .stage .stagetools, .stage .stageinput,
/* 🔴 `.wai` WAS MISSING, and had been since the locator shipped: it rendered perfectly and its
   head could not be clicked, because `.stage` is pointer-events:none and nothing re-enabled it.
   Exactly the failure the note above describes. Measured with elementFromPoint over the widget's
   own head, which returned whatever was behind it. */
.stage .wai { pointer-events: auto; }

/* 🔴 THE CAMERA'S INPUT SURFACE, and the reason the viewer was never navigable.
 *
 * The canvas is a fixed layer BEHIND the scrolling page, showing through a transparent hole —
 * so the hole is only VISUAL. Hit-testing still lands on whatever page element covers that
 * region, and the orbit handlers were bound to `#gl`, which never received a single event.
 * Measured with a real drag, against the deployed site and this build, identically:
 *
 *     az 20.1 -> 20.9, auto still true   = the drag never arrived; that 0.8 is the idle spin
 *
 * Making `.stagewrap` transparent to hits does not fix it either — `.pjmain` simply becomes the
 * target instead, and pushing `pointer-events: none` up the tree would disable the page.
 * So the stage gets its own surface, sized to the canvas, which TAKES the events and forwards
 * them to the camera. Below the pins and the chrome so neither is blocked. */
.stage .stageinput { position: absolute; inset: 0; z-index: 1; cursor: grab; touch-action: none; }
.stage .stageinput:active { cursor: grabbing; }

.stagechrome {
  position: absolute; inset: 0; padding: var(--sp-4);
  display: flex; align-items: flex-start; gap: var(--sp-3); z-index: 3;
}
.namechip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 13px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .92); border: 1px solid var(--hairline);
  font-size: 13px; font-weight: 600;
}
/* The loading screen, over the stage rect rather than beside it. It is a CHILD of .stage, so it
 * follows the stage into full-bleed for free - the same reason the tour frame and the pins are.
 *
 * 🔴 A REAL `background-color`, not only the still. `audit()` composites the PARENT CHAIN, so text
 * over an image with no colour underneath is measured against whatever ancestor has one; the
 * capture scrubber already shipped that bug once. The still is a layer ON TOP of this colour at a
 * fixed opacity, so what renders behind the card is deterministic, and the card itself is solid.
 *
 * 🔴 z-index 2, BELOW THE CHROME, and that is operability rather than taste. At 3 it covered the
 * chrome's own row - so during a 70 MB capture load there was no Back and no Expand, i.e. no way
 * out of a wait you had just started by mistake. It still covers the input surface (1) and the
 * pins (2, same layer, later in the DOM), because orbiting a scene that is not there and clicking
 * a pin onto a half-built one are both worse than nothing. */
.stageload {
  position: absolute; inset: 0; z-index: 2; overflow: hidden;
  border-radius: inherit; pointer-events: auto;
  background-color: var(--nav);
  display: flex; align-items: center; justify-content: center;
}
.stageload .slshot {
  position: absolute; inset: 0;
  background-size: cover; background-position: center;
  /* Dimmed against --nav underneath. `opacity` on the layer, not a translucent overlay on top:
     one composite, and the card's contrast stays computable - the readout sits on a SOLID card, so
     this value is free to be chosen by eye. At .42 the room was ghosted to a grey rectangle, which
     is the one thing this screen exists to avoid showing. */
  opacity: .58;
}
.stageload .slcard {
  position: relative; z-index: 1;
  display: flex; flex-direction: column; gap: var(--sp-2);
  min-width: 232px; max-width: min(78%, 340px);
  padding: var(--sp-4) var(--sp-5); border-radius: var(--r-md);
  background: var(--canvas); border: 1px solid var(--hairline);
  box-shadow: 0 8px 28px oklch(0 0 0 / .28);
}
.stageload .slname {
  font-size: 13px; font-weight: 700; color: var(--ink);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stageload .sltrack {
  height: 4px; border-radius: 2px; overflow: hidden;
  background: var(--bar-track);
}
.stageload .slbar {
  height: 100%; width: 0;
  background: var(--brand-yellow); border-radius: 2px;
  transition: width .25s cubic-bezier(.16, 1, .3, 1);
}
/* Indeterminate: there is no content-length yet, so the bar says "working" instead of claiming a
   fraction. `transform` only, which is what this repo is allowed to animate per frame. */
.stageload .slbar.wait {
  width: 34%;
  animation: slwait 1.25s cubic-bezier(.45, 0, .55, 1) infinite;
}
@keyframes slwait {
  0%   { transform: translateX(-110%); }
  100% { transform: translateX(400%); }
}
.stageload .slbytes {
  font-size: 11px; font-weight: 600; color: var(--stone-text);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-variant-numeric: tabular-nums; letter-spacing: .04em;
}

/* 🔴 `animation: none`, NOT a duration. The global reduced-motion block (app.css:520) sets
   `animation-duration: 0.01ms !important` - which on an INFINITE animation is not a stop, it is a
   repaint storm: the same cycle, thousands of times a second. The duration override is right for
   one-shot animations and wrong for every looping one, so a loop has to be turned off by name.
   The bar then holds at the indeterminate width and the byte count carries the signal. */
@media (prefers-reduced-motion: reduce) {
  .stageload .slbar.wait { animation: none !important; width: 34%; }
}

.pins { position: absolute; inset: 0; z-index: 2; }
.pin {
  position: absolute; left: 0; top: 0; z-index: var(--z, 1);
  display: flex; flex-direction: column; align-items: center;
  transform: translate(-50%, -50%); padding: 0; will-change: transform;
}
.pin > * { pointer-events: none; }
.pin .shot {
  position: relative; width: 62px; height: 46px; border-radius: 9px;
  overflow: hidden; border: 2px solid var(--canvas); background: var(--hairline);
  box-shadow: 0 2px 10px oklch(0.227 0.004 286 / .28);
}
.pin .shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pin.locked .shot img { filter: grayscale(1) brightness(.8); }
.pin .hazard {
  position: absolute; inset: 0;
  background: repeating-linear-gradient(45deg,
    oklch(0.487 0.180 25 / .6) 0 5px, oklch(0.227 0.004 286 / .6) 5px 10px);
}
.pin .lk {
  position: absolute; inset-block-start: 3px; inset-inline-end: 3px;
  width: 15px; height: 15px; border-radius: 4px; background: var(--late);
  display: grid; place-items: center;
}
.pin .lk svg { width: 9px; height: 9px; stroke: var(--canvas); stroke-width: 2.4;
  fill: none; stroke-linecap: round; }
.pin .lbl {
  margin-top: 5px; padding: 3px 8px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .94); border: 1px solid var(--hairline);
  display: flex; flex-direction: column; align-items: center; line-height: 1.25;
  white-space: nowrap;
}
.pin .lbl .nm { font-size: 11px; font-weight: 700; }
.pin .lbl .dt { font-size: 11px; color: var(--slate); }
.pin .leader { width: 1px; height: 14px; background: var(--ink); opacity: .5; }
.pin:not(.locked) { cursor: pointer; }
.pin:not(.locked):hover .shot { border-color: var(--brand-yellow); }

.noscene {
  position: absolute; inset: 0; z-index: 4;
  display: grid; place-content: center; justify-items: center; gap: var(--sp-4);
  padding: var(--sp-6); background: var(--surface); text-align: center;
}
.noscene .ph { width: min(380px, 70%); border-radius: var(--r-md); overflow: hidden; }
.noscene .ph img { width: 100%; display: block; }
.noscene p { margin: 0; color: var(--slate); font-size: 13px; max-width: 36ch; }

.viewlist ul { list-style: none; margin: 0; padding: var(--sp-2); }
.vrow {
  display: grid; grid-template-columns: 54px 1fr auto; gap: 10px; align-items: center;
  padding: var(--sp-2); border-radius: var(--r-md);
  text-decoration: none; color: inherit; min-height: 48px;
}
a.vrow:hover { background: var(--surface); }
.vrow .th {
  position: relative; width: 54px; height: 40px; border-radius: 7px;
  overflow: hidden; background: var(--hairline);
}
.vrow .th img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vrow.locked .th img { filter: grayscale(1) brightness(.8); }
.vrow .th .hazard {
  position: absolute; inset: 0;
  background: repeating-linear-gradient(45deg,
    oklch(0.487 0.180 25 / .55) 0 5px, oklch(0.227 0.004 286 / .55) 5px 10px);
}
.vrow .meta { min-width: 0; }
.vrow .nm { display: block; font-size: 13px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vrow .dt { display: block; font-size: var(--t-label); color: var(--stone-text); }
.vrow .rs {
  display: inline-flex; align-items: center; gap: 4px; margin-top: 2px;
  font-size: var(--t-label); font-weight: 600; color: var(--late);
}
.vrow .rs svg { width: 11px; height: 11px; stroke: currentColor; stroke-width: 2.2; fill: none; }

/* ================================================================== issues */
.issues { list-style: none; margin: 0; padding: var(--sp-2) var(--sp-3) var(--sp-4); }
.issue {
  display: grid; grid-template-columns: 92px minmax(0, 1fr) 128px 164px;
  gap: var(--sp-4); align-items: start;
  padding: var(--sp-4) var(--sp-3);
  border-bottom: 1px solid var(--hairline-soft);
}
.issue:last-child { border-bottom: 0; }
.issue .th {
  width: 92px; height: 68px; border-radius: var(--r-sm); overflow: hidden;
  background: var(--hairline); display: block;
}
.issue .th img { width: 100%; height: 100%; object-fit: cover; display: block; }
.issue .hd { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.issue .ttl { font-size: 14px; font-weight: 600; }
.issue .anchor {
  display: flex; align-items: center; gap: 5px; margin-top: 4px;
  font-size: var(--t-label); color: var(--slate);
}
.issue .anchor svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 1.7;
  fill: none; stroke-linejoin: round; }
.issue .desc { margin: 7px 0 0; font-size: 13px; color: var(--slate); }
.issue .meta {
  margin-top: 6px; font-size: var(--t-label); color: var(--stone-text);
  display: flex; gap: 6px; flex-wrap: wrap;
}
.issue .sep { opacity: .5; }
.issue .side2 { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.issue .type { font-size: var(--t-label); font-weight: 600; color: var(--slate); }
.issue .act {
  display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
  text-align: end; font-size: var(--t-label); color: var(--stone-text);
}
.issue .act .who { font-weight: 600; color: var(--slate); }
.issue .cmt { display: inline-flex; align-items: center; gap: 4px; }
.issue .cmt svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 1.8;
  fill: none; stroke-linejoin: round; }
/* The filing time, under its date. Muted and smaller: the date is what you scan the column by,
   the clock is what you check once you have found the row. It is absent on demo rows by design. */
.issue .when .clock {
  display: block; font-size: 11px; color: var(--stone-text, #8a8a93);
  font-variant-numeric: tabular-nums; line-height: 1.3;
}

.issue .open,
#i-write .open {
  display: inline-flex; align-items: center; gap: 5px; margin-top: 4px;
  font-size: var(--t-label); font-weight: 600; color: var(--ink);
  text-decoration: none; min-height: 28px;
}
.issue .open svg,
#i-write .open svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
:dir(rtl) .issue .open svg,
:dir(rtl) #i-write .open svg { transform: scaleX(-1); }
.issue .open:hover,
#i-write .open:hover { text-decoration: underline; text-underline-offset: 3px; }

/* The way into authoring, at the end of the Issues panel header. `.grow` is what pushes it there:
   the header is `space-between`, which with four children would spread them evenly instead.
   44px because this is a standalone primary action, not a trailing link inside a row. */
.panel > header .grow { flex: 1; }
#i-write { flex: none; }
#i-write .open { margin-top: 0; min-height: 44px; white-space: nowrap; }
#i-write .note { max-width: 32ch; }

.state {
  display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px;
  border-radius: var(--r-pill); font-size: var(--t-label); font-weight: 700;
  border: 1px solid transparent;
}
.state.open     { background: var(--brand-yellow); color: var(--ink); }
.state.resolved { background: var(--tint-teal); color: var(--ok); }
.state.closed   { background: var(--card); color: var(--slate); border-color: var(--hairline); }

.issues .empty { padding: 56px var(--sp-4); text-align: center; color: var(--slate); }

/* =============================================================== analytics */
.anal {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-4); align-items: start;
}
.anal .wide { grid-column: 1 / -1; }
.anal .chartbox { padding: var(--sp-3) var(--sp-5) var(--sp-5); }
.anal .caption {
  padding: 0 var(--sp-5) var(--sp-5); font-size: var(--t-label); color: var(--slate);
}
.legend { display: flex; align-items: center; gap: 7px; font-size: var(--t-label);
  color: var(--slate); }
.legend .lg { width: 14px; height: 3px; border-radius: 2px; background: var(--planned);
  margin-inline-start: var(--sp-2); }
.legend .lg.actual { background: var(--ok-fill); margin-inline-start: 0; }
.legend .lg.actual.warn { background: var(--warn-fill); }
.legend .lg.actual.late { background: var(--late-fill); }

.chart { width: 100%; height: auto; display: block; }
.chart .grid line { stroke: var(--hairline-soft); stroke-width: 1; }
.chart .ax { fill: var(--stone-text); font-size: 11px; font-weight: 600; }
.chart .axv { fill: var(--slate); font-size: 11px; font-weight: 700; }
.chart .ln { fill: none; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.chart .dot { stroke: var(--canvas); stroke-width: 2; r: 4; }
.chart .ser.plan .ln, .chart .ser.plan .dot { stroke: var(--planned); }
.chart .ser.plan .dot { fill: var(--planned); }
.chart .ser.actual .ln { stroke: var(--ok-fill); }
.chart .ser.actual .dot { fill: var(--ok-fill); }
.chart .ser.actual.warn .ln { stroke: var(--warn-fill); }
.chart .ser.actual.warn .dot { fill: var(--warn-fill); }
.chart .ser.actual.late .ln { stroke: var(--late-fill); }
.chart .ser.actual.late .dot { fill: var(--late-fill); }
.chart .bar { fill: var(--planned); }
.chart .bar.ok { fill: var(--ok-fill); }
.chart .bar.warn { fill: var(--warn-fill); }
.chart .bar.late { fill: var(--late-fill); }

.fresh { list-style: none; margin: 0; padding: var(--sp-2) var(--sp-5) var(--sp-5); }
.fresh li {
  display: grid; grid-template-columns: minmax(0, 260px) 1fr 56px;
  gap: var(--sp-4); align-items: center; padding: 7px 0;
}
.fresh .nm { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fresh .dt { text-align: end; font-size: var(--t-label); font-weight: 700; color: var(--slate); }

/* ================================================================= reports */
.sheet { padding: var(--sp-7); max-width: 1000px; }
.sheethead { border-bottom: 1px solid var(--hairline); padding-bottom: var(--sp-5); }
.sheethead .eyebrow {
  font-size: var(--t-label); font-weight: 700; letter-spacing: .06em;
  text-transform: uppercase; color: var(--stone-text);
}
.sheethead h2 { margin: 6px 0 0; font-size: 26px; font-weight: 700; letter-spacing: -.02em; }
.sheethead .sub { margin: 4px 0 0; color: var(--slate); font-size: 13px; }
.sheethead .range { margin: 2px 0 0; color: var(--stone-text); font-size: var(--t-label); }
/* Which KIND of data the sheet is made of, printed with the figures: a printed page has left the
   screen that could have explained it. `issues-unreadable` reads at full weight because it is the
   one case where the counters are absent and the reader must know why. */
.sheethead .note { margin: 10px 0 0; max-width: 70ch; color: var(--slate); font-size: var(--t-label); }
.sheethead .note.issues-unreadable { color: var(--ink); font-weight: 600; }

.counters {
  display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-3);
  padding: var(--sp-5) 0;
}
.counter {
  border: 1px solid var(--hairline); border-radius: var(--r-md);
  padding: var(--sp-4); display: flex; flex-direction: column; gap: 4px;
}
.counter .k { font-size: var(--t-label); font-weight: 700; color: var(--slate); }
.counter .n { font-size: var(--t-metric); font-weight: 700; letter-spacing: -.02em; }
.counter.open .n { color: var(--warn); }
.counter.resolved .n { color: var(--ok); }

.acth { margin: var(--sp-4) 0 var(--sp-3); font-size: 15px; font-weight: 700; }
.replist { list-style: none; margin: 0; padding: 0; }
.replist li {
  display: grid; grid-template-columns: 76px minmax(0, 1fr) 140px;
  gap: var(--sp-4); align-items: start;
  padding: var(--sp-3) 0; border-bottom: 1px solid var(--hairline-soft);
}
.replist .th { width: 76px; height: 56px; border-radius: var(--r-sm); overflow: hidden;
  background: var(--hairline); display: block; }
.replist .th img { width: 100%; height: 100%; object-fit: cover; display: block; }
.replist .hd { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.replist .ttl { font-size: 14px; font-weight: 600; }
.replist .meta { margin-top: 4px; font-size: var(--t-label); color: var(--stone-text);
  display: flex; gap: 6px; flex-wrap: wrap; }
.replist .sep { opacity: .5; }
.replist .desc { margin: 6px 0 0; font-size: 13px; color: var(--slate); }
.replist .act { text-align: end; font-size: var(--t-label); color: var(--stone-text);
  display: flex; flex-direction: column; gap: 3px; }
.replist .act .who { font-weight: 600; color: var(--slate); }
.sheet .empty { color: var(--slate); font-size: 13px; }

/* ===================================================================== print
 * Everything but the report sheet is chrome. */
@media print {
  #rail, #gl, #depth, #pop, .hero, .mast { display: none !important; }
  #smooth-wrapper { position: static !important; overflow: visible !important; }
  #smooth-content { padding: 0 !important; transform: none !important; }
  .sheet { border: 0; padding: 0; max-width: none; }
  .replist li { break-inside: avoid; }
}

/* =============================================================== responsive */
@media (max-width: 1360px) {
  .mapcols { grid-template-columns: minmax(0, 1fr) 280px; }
  .issue { grid-template-columns: 76px minmax(0, 1fr) 120px; }
  .issue .act { grid-column: 2 / -1; flex-direction: row; gap: var(--sp-3);
    align-items: center; justify-content: flex-start; text-align: start; }
  .kpis.kpis-4 { grid-template-columns: repeat(2, 1fr); }
  .kpis.kpis-4 .kpi:nth-child(n+4) { display: flex; }
}
@media (max-width: 1080px) {
  .mapcols, .anal { grid-template-columns: minmax(0, 1fr); }
  .counters { grid-template-columns: repeat(2, 1fr); }
  .issue { grid-template-columns: 64px minmax(0, 1fr); }
  .issue .side2 { grid-column: 2; flex-direction: row; }
}

/* leader lines from each fanned billboard back to its true ground point */
.leaders {
  position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%;
  pointer-events: none; overflow: visible;
}
.leaders line { stroke: var(--ink); stroke-width: 1; opacity: .45; }
.leaders circle { fill: var(--ink); opacity: .75; }

/* ============================================================== demo marker
 * The chip makes the note get seen; the note is what actually says what is
 * illustrative. Both are chrome, so neither prints. */
.titlerow { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.demochip {
  display: inline-flex; align-items: center; min-height: 24px; padding: 0 10px;
  border-radius: var(--r-pill); border: 1px solid var(--hairline);
  background: var(--card); color: var(--slate);
  font-size: 11px; font-weight: 700; letter-spacing: .07em;
  white-space: nowrap; align-self: center;
}
/* A button now, not a span: it discloses the note. Same shape it always had, plus the states a
   control owes the reader. */
.demochip { cursor: pointer; }
.demochip:hover { border-color: var(--hover-nav); color: var(--hover-nav); }
.demochip[aria-expanded="true"] { background: var(--ink); color: var(--canvas); border-color: var(--ink); }
.demonote[hidden] { display: none; }
.demonote {
  margin: var(--sp-4) 0 0; padding-top: var(--sp-3);
  border-top: 1px solid var(--hairline);
  font-size: var(--t-label); color: var(--stone-text);
  max-width: 78ch; line-height: 1.5;
}
@media print { .demochip, .demonote { display: none; } }

/* Which capture the 3D scene actually is, when it is not the project's own site scan.
 * The backdrop is opaque-over-white like .namechip so audit() can resolve it. */
.viachip {
  display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px;
  border-radius: var(--r-pill); background: var(--hairline-soft);
  color: var(--slate); font-size: 11px; font-weight: 700;
}

/* ============================================================ project screen
 * `/project/<id>` — a project and its captures. Reuses `.vrow` (the Map view list),
 * `.attn` (the portfolio's needs-attention list) and `.kpis` verbatim; everything new is
 * namespaced `pj*` because screens.css already owns short names like `.legend .lg` and a
 * collision looks almost right. Grepped against app.css, screens.css and spine.css: 0 hits.
 */
 /* 🔴 Only I.ok / I.warn / I.late / I.total carry stroke="currentColor"; I.cube does not, and
    `.kpi .ic svg` sets stroke-width and fill but never stroke — so the Captures icon painted
    nothing at all. Scoped here so no other screen's icons change. */
/* ------------------------------------------------- the figure strip (was five KPI cards)
 * The portfolio's `.pstrip` anatomy, with a second line because this screen's footnotes are
 * facts and not decoration. Separator is a 1px rule between cells, not five card borders.
 *
 * 🔴 `--hero-k` still drives it. The hero scrub writes that variable on every screen; a strip
 * that ignored it would stay tall while everything around it collapsed. Padding only — the
 * figures do not shrink, because a number you cannot read has not saved you anything.
 * 🔴 NOTHING HERE GOES UNDER 11px. `audit()` fails on that floor regardless of contrast, and the
 * footnote at 11 is the smallest thing on the row. */
.pjstrip {
  display: flex; align-items: stretch; flex-wrap: wrap;
  border-block: 1px solid var(--hairline);
  margin-bottom: var(--sp-4);
}
.pjcell {
  display: flex; flex-direction: column; gap: 2px; position: relative;
  padding: calc(12px - var(--hero-k) * 4px) var(--sp-5);
}
.pjcell:first-child { padding-inline-start: 0; }
.pjcell + .pjcell::before {
  content: ''; position: absolute; inset-inline-start: 0; top: 50%; translate: 0 -50%;
  width: 1px; height: 28px; background: var(--hairline);
}
.pjfig { display: flex; align-items: baseline; gap: 7px; }
.pjcell b.n {
  font-size: calc(21px - var(--hero-k) * 3px); font-weight: 700;
  line-height: 1.1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
}
.pjcell .k { font-size: 13px; font-weight: 600; color: var(--slate); white-space: nowrap; }
.pjcell .f { font-size: 11px; color: var(--stone-text); white-space: nowrap; }
/* Status colour lands on the FIGURE, which is the thing the status is about. The old card tinted
   a 44px icon tile instead, which coloured the decoration and left the number neutral. */
.pjcell.ok   b.n { color: var(--ok); }
.pjcell.warn b.n { color: var(--warn); }
.pjcell.late b.n { color: var(--late); }
.pjcell.crit { margin-inline-start: auto; padding-inline-end: 0; }
.pjcell.crit b.n, .pjcell.crit .k { color: var(--late); }
@media (max-width: 1100px) { .pjcell.crit { margin-inline-start: 0; } }

.pjhead {
  display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
  padding-bottom: var(--sp-4);
}
.pjback {
  display: inline-flex; align-items: center; gap: 5px;
  min-height: 32px; padding: 0 12px 0 8px;
  border-radius: var(--r-pill); border: 1px solid var(--hairline);
  background: var(--card); color: var(--slate);
  font-size: var(--t-label); font-weight: 700; text-decoration: none;
}
.pjback:hover { background: var(--surface); color: var(--ink); }
/* The chevron points BACK. :dir() then flips it again for RTL, where back is the other way. */
.pjback svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; transform: scaleX(-1); }
:dir(rtl) .pjback svg { transform: none; }
.pjloc {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-label); font-weight: 600; color: var(--stone-text);
}
.pjloc svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }

/* The project screen is the stage and its captures, side by side, and nothing else.
 *
 * 🔑 It used to be 1fr + a 320px rail of Milestones and open issues. Both of those have a screen
 * of their own (/analytics, /issues) and the KPI band directly above already carries Actual,
 * Planned, Variance, Captures and Open as its five figures — the rail was the third telling of
 * the same numbers, and it was charging a third of the width for it. What it charged that width
 * against is the only thing this screen can do that no other surface can.
 *
 * The capture rail takes the slot rather than the width being reclaimed, because once the
 * milestones are gone this screen has exactly two jobs: look at a space, and pick which space.
 * Side by side, choosing a capture does not scroll the capture out of view. */
/* 🔴 ONE SOURCE FOR THE STAGE'S HEIGHT. It was written out twice — once on `.pjmain .stage` and
 * once inside `.pjrail`'s max-height, as the same `clamp()` with the same three magic numbers —
 * so changing the budget meant changing both and the rail silently kept the old one. It is a
 * token now; the rail is `calc(var(--stage-h) + 64px)` and cannot disagree.
 *
 * `--stage-chrome` is what is NOT the picture, measured on the real screen rather than guessed:
 * 236px of hero + figure strip above (was 308 with the five bordered KPI cards), 116px of
 * scrubber below, 28px of air. The CAP is the other half of the fix — at 720 a 1260px-tall
 * window threw away 92px it had already paid for, which is most of what "give the visual more
 * room" was asking for. */
:root { --stage-chrome: 380px; --stage-h: clamp(380px, calc(100vh - var(--stage-chrome)), 980px); }

/* 🔴 420 -> 392, and 364 was WRONG. The trim is bounded by the widest actions row, which is
   MEASURED, not estimated: "Compare dates" + "Published view" needs 187px and the Diner card is
   the one that has it. At 364 the row had 161 and clipped the published link on every card by
   8px and on the Diner by 34 — a 56px gift to the stage paid for by cutting a label off nine
   cards. At 392 with a 124px thumbnail the row gets ~199 and the worst case fits with slack. */
.pjcols {
  display: grid; grid-template-columns: minmax(0, 1fr) 392px;
  gap: var(--sp-4); align-items: start;
}
.pjmain { display: flex; flex-direction: column; gap: var(--sp-4); min-width: 0; }
/* 🔴 Taller than the 460px it replaced, but NOT a free `74vh`. Something does still sit below the
 * stage: the date scrubber, and on a multi-date capture that band is the reason the capture opens
 * here at all. At 720px it started at y=1040 — off the fold on anything under ~1160, and the
 * browser check reaching for it had to scroll far enough to trip a navigation.
 *
 * So the height is what is LEFT once the things that must stay visible are paid for, measured on
 * the real screen: 308px of hero + KPI band above, 116px of scrubber below, 24px of air.
 * 448 = 308 + 116 + 24. The floor keeps a usable stage on a short window. */
.pjmain .stage { height: var(--stage-h); }

/* a capture row that opens in this screen's own stage */
.vrow.opens { cursor: pointer; }
.vrow.opens:hover { background: var(--surface); }
.vrow.opens:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
.vrow.sel { background: var(--surface); box-shadow: inset 2px 0 0 var(--brand-yellow); }
:dir(rtl) .vrow.sel { box-shadow: inset -2px 0 0 var(--brand-yellow); }
.pjin {
  display: inline-block; margin-top: 2px;
  font-size: var(--t-label); font-weight: 700; color: var(--ok);
}

/* leaving a capture, back out to the site it belongs to */
.stageback {
  display: inline-flex; align-items: center; gap: 5px;
  min-height: 30px; padding: 0 12px 0 8px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .92); border: 1px solid var(--hairline);
  color: var(--ink); font-size: var(--t-label); font-weight: 700;
}
.stageback:hover { border-color: var(--ink); }
.stageback svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; transform: scaleX(-1); }
:dir(rtl) .stageback svg { transform: none; }

/* expand the stage to the viewport, and come back. Pushed to the far end of the chrome; the
   loading pill also claims `margin-inline-start:auto`, and only one of them is ever shown. */
.stagefull {
  margin-inline-start: auto;
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 30px; padding: 0 12px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .92); border: 1px solid var(--hairline);
  color: var(--ink); font-size: var(--t-label); font-weight: 700;
}
.stagefull:hover { border-color: var(--ink); }
.stagefull:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.stagefull svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
/* The loading readout used to sit in this row and claim the same auto margin; it is a full stage
   overlay now (.stageload), so nothing competes with Expand for the free space. */

/* hand this capture to its own published tour, at the view on screen. Same pill as .stagefull and
   deliberately NOT given the auto margin: .stagefull already claimed it, so this sits beside it. */
.stagetools {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 30px; padding: 0 12px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .92); border: 1px solid var(--hairline);
  color: var(--ink); font-size: var(--t-label); font-weight: 700;
}
.stagetools:hover { border-color: var(--ink); }
.stagetools:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.stagetools svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
/* The expand button is hidden on the Map screen, so the tools pill must claim the free space in
   that case or it lands next to the name chip. */
.stagefull[hidden] + .stagetools { margin-inline-start: auto; }

/* 🔴 The published tour fills the stage and must be ABOVE .stagechrome (z-index 3): our Expand
   and Back pills cannot be allowed to float over someone else's viewer, where they would look
   like its controls and do something else. z-index 4, and the only control over it is the close.
   OPAQUE: the canvas behind it is hidden while it is up (stage.js suspend), and a translucent
   frame over a hidden canvas shows the page body, not the scan. */
.stagetour {
  position: absolute; inset: 0; z-index: 4;
  background: var(--ink); border-radius: inherit; overflow: hidden;
  pointer-events: auto;
  /* Enters on opacity alone, AFTER the expand ease has landed. Two transitions over the same rect
     read as a stutter, and the iframe would reflow on every frame of the first one. 180ms ease-out:
     entering motion is ease-out, and anything slower reads as a page that is struggling. */
  opacity: 0; transition: opacity .18s cubic-bezier(0, 0, .2, 1);
}
.stagetour.in { opacity: 1; }
.stagetour .tourframe { display: block; width: 100%; height: 100%; border: 0; }
/* 🔴 TOP-LEFT, and that is a collision rather than a preference: the published tour pins its own
   control strip (.preview-controls) top-right, so a close button there sits on the viewer's own
   buttons. Top-left is also where this screen's "Back to site" lives, so the control that takes you
   back is in the same corner whichever surface you are looking at. */
.stagetour .tourclose {
  position: absolute; inset-block-start: var(--sp-4); inset-inline-start: var(--sp-4); z-index: 1;
  display: inline-flex; align-items: center; gap: 6px;
  min-height: 30px; padding: 0 12px; border-radius: var(--r-pill);
  background: oklch(1 0 0 / .92); border: 1px solid var(--hairline);
  color: var(--ink); font-size: var(--t-label); font-weight: 700;
}
.stagetour .tourclose:hover { border-color: var(--ink); }
.stagetour .tourclose:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.stagetour .tourclose svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }

/* open issues on the capture itself, on its billboard */
.pin .pinbadge {
  position: absolute; inset-block-start: 3px; inset-inline-start: 3px;
  min-width: 17px; height: 17px; padding: 0 4px; border-radius: var(--r-pill);
  background: var(--late); color: var(--canvas);
  font-size: 11px; font-weight: 700; line-height: 17px; text-align: center;
  font-variant-numeric: tabular-nums;
}

/* Tightened from sp-4/sp-5: this bar carries one fact and one button, and it was spending the
   height of a card on them - the second region Yosi marked as wasted. */
.pjbar {
  display: flex; align-items: center; gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  border-bottom: 1px solid var(--hairline);
}
.pjbarmeta { min-width: 0; flex: 1; }
.pjms  { display: block; font-size: var(--t-body); font-weight: 600; }
.pjmsl { display: block; font-size: var(--t-label); color: var(--stone-text); }
.pjbtn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  flex: none; min-height: 36px; padding: 0 var(--sp-4);
  border-radius: var(--r-pill); background: var(--ink); color: var(--canvas);
  font-size: 13px; font-weight: 600; text-decoration: none; white-space: nowrap;
}
.pjbtn svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
:dir(rtl) .pjbtn svg { transform: scaleX(-1); }
/* Not a button: there is nothing to open. A disabled control invites a click; a label does not. */
.pjbtn.off {
  background: var(--card); color: var(--stone-text);
  border: 1px solid var(--hairline); font-weight: 600;
}
.pjbtn.off svg { display: none; }

/* the end cell of a capture row: open-issue count, then the chevron if it opens */
.pjend { display: inline-flex; align-items: center; gap: var(--sp-2); }
.pjbadge {
  display: inline-grid; place-items: center; min-width: 20px; height: 20px;
  padding: 0 6px; border-radius: var(--r-pill);
  background: var(--tint-coral); color: var(--late);
  font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.pjno {
  display: inline-block; margin-top: 2px;
  font-size: var(--t-label); font-weight: 600; color: var(--stone-text);
}

/* ========================================================= the capture rail
 * The captures, in the slot the milestones used to hold. A LIST, not a grid: at 420px a 16/10
 * thumbnail beside its name reads at a glance and nine of them fit in one sticky column, which
 * is the whole point of putting them here rather than under the stage.
 *
 * It reuses `.capcard` and its `c*` children verbatim rather than declaring a parallel set —
 * same markup, same audited contrast, one anatomy to keep passing. Only the axis changes. */
.pjrail {
  position: sticky; inset-block-start: var(--sp-4);
  display: flex; flex-direction: column;
  max-height: calc(var(--stage-h) + 64px);
  /* Fades out while the stage grows into its space. NOT display:none — see below. */
  opacity: 1; visibility: visible;
  transition: opacity .18s ease, visibility 0s linear 0s;
}
.pjrail > header { flex: none; }
.pjrail .capgrid {
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-3); overflow: auto; overscroll-behavior: contain;
  padding-inline: var(--sp-4); padding-block-end: var(--sp-4);
}
/* 🔑 186px — Option B, chosen off a slider against the measured budget (104 -> 250 -> 156 -> 124
   -> 186; the mockup put the consequence of each size on screen beside it).
   The thumbnail IS the capture: it is how you know which room this is before you read anything.

   🔑 AT THIS SIZE THE CARD IS THUMBNAIL-DRIVEN, and that is what makes the rest of this block
   free. The picture is 186 x 116; the text column needs 84px with its actions showing and 40
   without. 84 < 116, so revealing the actions on hover CANNOT change the card's height and the
   nine rows below it cannot jump. A smaller thumbnail would have made the same reveal a
   layout-shifting one. */
.pjrail .capcard {
  display: grid; grid-template-columns: 186px minmax(0, 1fr);
  align-items: center; gap: var(--sp-3); padding: var(--sp-2);
}
.pjrail .capcard .cshot { border-radius: var(--r-sm); }
/* 🔴 STRETCHED, and the actions keep their place in it. The card grid is `align-items: center`,
   so `.cbody` was only as tall as its text and sat centred in the row — which meant revealing
   `.cact` on hover grew it by 29px and a centred box grows BOTH ways, lifting the name and date
   by exactly half. Measured: -14.5px here, -21.9px on production, where the row still wrapped.
   🔑 And hiding them bought nothing: the row is governed by the 186px thumbnail, so the card
   measured 133.6px at rest AND on hover. There are ~62px of unused height in this column; the
   24px the actions need is free. Stretching `.cbody` and leaving `.cact` in flow (hidden by
   `visibility`, not `display`) fixes the position of every box, so hover changes appearance and
   nothing else. Worst case fits: 73px of text + 5 gap + 24 actions = 102 in 116.3. */
.pjrail .capcard .cbody {
  display: flex; flex-direction: column; align-items: flex-start; gap: 5px;
  align-self: stretch; justify-content: center;
  padding: 0 var(--sp-2) 0 0;
}
:dir(rtl) .pjrail .capcard .cbody { padding: 0 0 0 var(--sp-2); }
/* 🔴 The date MUST be allowed to wrap here. At a 186px thumbnail the text column is 145px and
   "23 Aug 2026 · 41 days ago" sets wider than that; `nowrap` would ellipsize the capture's own
   identity. There is room for the extra line — see the 84-against-116 note above — so it wraps
   instead of truncating. */
.pjrail .capcard .cmeta { width: 100%; }
.pjrail .capcard .cdt { white-space: normal; }
.pjrail .capcard .cdt > span { white-space: nowrap; }
/* The actions, on one line, with the published link pushed to the card's own edge. */
/* 🔴 WRAPS rather than clips. The widths above are measured in English; Hebrew sets wider, and
   a label that silently loses its last glyph looks like a rendering bug rather than a layout
   one. A card that is one line taller because it genuinely has more to say is the honest
   failure mode. */
.pjrail .capcard .cact {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 4px var(--sp-2); width: 100%;
}
/* 🔑 THE ACTIONS LEAVE THE RESTING ROW. That is what the height is bought with — not by
   shrinking the picture, which is the one thing on this card worth looking at.
   They come back on hover, on keyboard focus anywhere inside the card, and on the selected
   card, which is the one whose actions you are most likely to want.
   🔴 `:focus-within`, not `:hover` alone: hidden with `display:none` the links are out of the
   tab order entirely, so a keyboard would have no way to reach "Published view" at all.
   🔴 And `(hover: none)` puts them back permanently — on a touch screen there is no pointer to
   reveal them with, so "on hover" would mean "never". */
/* `visibility`, NOT `display`. The space stays reserved either way (see `.cbody` above for the
   measurement), and both remove the links from the tab order identically — which is why
   `:focus-within` is still what brings them back: the card itself is `tabindex="0"`, so focusing
   it reveals them and only then are they reachable. */
.pjrail .capcard .cact { visibility: hidden; }
.pjrail .capcard:hover .cact,
.pjrail .capcard:focus-within .cact,
.pjrail .capcard.sel .cact { visibility: visible; }
@media (hover: none) { .pjrail .capcard .cact { visibility: visible; } }
/* 🔴 Tightened against the 104px thumbnail, but NOT below 11px: `audit()` fails anything under
   that floor regardless of contrast, and 10px here scored a perfectly good 6.97 and still failed.
   The box shrinks; the type does not. */
.pjrail .capcard .cbadge { inset-block-start: 5px; inset-inline-start: 5px;
  min-width: 19px; height: 19px; padding: 0 5px; }

/* ===================================================== the stage, expanded
 * 🔴 `inset` is written inline by stage.js from the measured mini rect and then eased to 0, so
 * the stage GROWS into the space the rail is leaving. The canvas reads this element's rect every
 * frame, which is what keeps the camera continuous — and a straight `inset: 0` would cover the
 * rail on frame one and make its fade invisible. */
body.stage-full { overflow: hidden; }
/* 🔴 THE HAIRLINE LEFT BEHIND BY THE MINI VIEWER (Yosi, 2026-10-07).
 * `.stagewrap` is a `.panel`, so it carries a 0.667px hairline border. The moment `#stage` goes
 * `position: fixed` the wrapper has no content left and collapses onto itself — measured
 * 703 x 1.33, which is top border + bottom border and nothing in between. That is a 703px long
 * line sitting in the page exactly where the docked viewer was, and because the stage is still
 * easing out to full-bleed you watch it appear, then watch it again on the way back.
 *
 * 🔑 COLOUR, NOT GEOMETRY. Collapsing the box (height 0, border 0) would move the page under an
 * animating fixed element, and this block's own rule a few lines down is that not one box changes
 * size while the rect is being eased — it uses `visibility` for the same reason. Transparent
 * border keeps every number identical and simply stops painting the line. */
body.stage-full .pjmain .stagewrap { border-color: transparent; }
body.stage-full .pjmain .stage {
  position: fixed; z-index: 60; height: auto; min-height: 0; border-radius: 0;
  transition: inset .3s cubic-bezier(.4, 0, .2, 1), border-radius .3s ease;
}
#gl.full { border-radius: 0; }

/* 🔴 The canvas is NOT raised, and must not be. `#gl` is z-index 1 and `#smooth-wrapper` is 2,
 * so the page deliberately sits ABOVE the canvas and the stage panel is a transparent hole
 * punched through it (see the note at the top of this file: a canvas lifted over the column
 * covered the pins, the rail and the hero). Lifting it here would also bury the stage chrome,
 * which lives inside the wrapper and can never out-stack a sibling of <body>.
 *
 * So full-bleed makes the whole PAGE the hole. Everything but the stage is hidden, the canvas
 * shows through what is left, and the chrome keeps painting over it exactly as it does at
 * 1001x720. `visibility`, again, so not one box changes size while the rect is being eased. */
body.stage-full #rail,
body.stage-full #address,
body.stage-full #depth,
body.stage-full .page > *:not(#body),
body.stage-full #body > *:not(.pjcols),
body.stage-full .pjmain > *:not(.stagewrap) {
  opacity: 0; visibility: hidden;
  transition: opacity .16s ease, visibility 0s linear .16s;
}

/* 🔴 `visibility: hidden`, never `display: none`. Collapsing the 420px column would change the
 * stage's own rect a frame BEFORE it goes fixed, and the canvas — which reads that rect every
 * frame — would snap wide and then snap again. `visibility` takes the rail out of the tab order
 * and off the screen while the column keeps its width, so the only geometry change is the one
 * being animated, and the way back has nothing to re-measure.
 *
 * 🔴 No `prefers-reduced-motion` block here: app.css:520 already sets
 * `transition-duration: 0.01ms !important` globally and beats anything declared after it. A
 * local rule would be cover that is not there — the same dead rule removed from scrubber.css. */
body.stage-full .pjrail {
  opacity: 0; visibility: hidden;
  transition: opacity .16s ease .2s, visibility 0s linear .36s;
}
/* The cards leave one after another, top down, and come back the same way. Out is FASTER than
   in — an exit that dawdles reads as lag, an entrance that hurries reads as a flicker. */
.pjrail .capgrid > li {
  opacity: 1; transform: none;
  transition: opacity .22s cubic-bezier(.33, .9, .28, 1) calc(var(--i, 0) * 35ms),
              transform .22s cubic-bezier(.33, .9, .28, 1) calc(var(--i, 0) * 35ms);
}
body.stage-full .pjrail .capgrid > li {
  opacity: 0; transform: translateX(16px);
  transition: opacity .14s ease-in calc(var(--i, 0) * 25ms),
              transform .14s ease-in calc(var(--i, 0) * 25ms);
}
:dir(rtl) body.stage-full .pjrail .capgrid > li { transform: translateX(-16px); }

/* 🔴 This block is NOT the dead one removed from scrubber.css. `app.css:519` zeroes
 * `transition-duration` and nothing else — a `transition-delay` survives it untouched. Both the
 * rail's 200ms lead-out and the cards' `i * 25ms` stagger are delays, so with reduced motion the
 * stage snapped to full in under 60ms while the rail was still fully opaque, visible AND
 * focusable, for another 200ms. Measured, not reasoned. Zero the delays; the durations are
 * already handled, which is why this block does not mention them. */
@media (prefers-reduced-motion: reduce) {
  .pjrail, .pjrail .capgrid > li,
  body.stage-full .pjrail, body.stage-full .pjrail .capgrid > li,
  body.stage-full #rail, body.stage-full #address, body.stage-full #depth,
  body.stage-full .page > *:not(#body),
  body.stage-full #body > *:not(.pjcols),
  body.stage-full .pjmain > *:not(.stagewrap) { transition-delay: 0s !important; }
}

.pjnone {
  list-style: none; padding: var(--sp-3) var(--sp-4) var(--sp-4);
  font-size: var(--t-label); color: var(--stone-text);
}

/* The project screen's five figures are all load-bearing — Captures and Open issues most of all
   — so the strip WRAPS rather than hiding a cell. A wrapped row is two rules tall, so the chrome
   budget has to grow with it or the stage overlaps what it pushed down. */
@media (max-width: 1360px) {
  .pjcell:first-child { padding-inline-start: var(--sp-5); }
  .pjcell.crit { margin-inline-start: 0; padding-inline-end: var(--sp-5); }
  :root { --stage-chrome: 452px; }
}
/* Below the split's useful width the rail goes back under the stage and becomes a grid again —
   a 420px column beside a 700px stage is two cramped things instead of one good one. */
@media (max-width: 1240px) {
  .pjcols { grid-template-columns: minmax(0, 1fr); }
  .pjrail { position: static; max-height: none; }
  .pjrail .capgrid {
    grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
    gap: var(--sp-4); overflow: visible;
  }
  .pjrail .capcard { display: flex; flex-direction: column; align-items: stretch;
    gap: 0; padding: 0; }
  .pjrail .capcard .cbody {
    flex-direction: row; align-items: flex-end; justify-content: space-between;
    gap: var(--sp-3); padding: 11px 13px 13px;
  }
  :dir(rtl) .pjrail .capcard .cbody { padding: 11px 13px 13px; }
  .pjrail .capcard .cact { width: 100%; }
}
@media (max-width: 1080px) {
  .pjbar { flex-wrap: wrap; }
  .pjmain .stage { height: min(42vh, 380px); }
}
@media print {
  .pjback, .pjbtn, .pjend .ch { display: none; }
  /* A WebGL canvas prints as a blank rectangle; the pins print as a pile in the corner. */
  .pjmain .stagewrap { display: none; }
}

/* ====================================================== capture card grid
 * The thin list left the right half of the panel empty and wasted the one thing a capture
 * actually has — its picture. Cards fill the width and the image is the card.
 * Namespaced `c*` inside `.capgrid` so the short names cannot reach anything else. */
.capgrid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
  gap: var(--sp-4); list-style: none; margin: 0;
  padding: var(--sp-2) var(--sp-5) var(--sp-5);
}
.capgrid > li { min-width: 0; }
.capcard {
  display: flex; flex-direction: column; height: 100%;
  border: 1px solid var(--hairline); border-radius: var(--r-md); overflow: hidden;
  background: var(--card); text-decoration: none; color: inherit;
}
.capcard.opens { cursor: pointer; }
.capcard:not(.flat):hover { border-color: var(--ink); }
.capcard:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.capcard.sel { border-color: var(--brand-yellow); box-shadow: 0 0 0 2px var(--brand-yellow); }

.capcard .cshot {
  position: relative; display: block; aspect-ratio: 16 / 10;
  background: var(--hairline-soft); overflow: hidden;
}
.capcard .cshot img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: transform .45s cubic-bezier(.33,.9,.28,1);
}
.capcard:not(.flat):hover .cshot img { transform: scale(1.045); }
.capcard .cbadge {
  position: absolute; inset-block-start: 8px; inset-inline-start: 8px;
  display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px;
  border-radius: var(--r-pill); background: var(--late); color: var(--canvas);
  font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
}

.capcard .cbody {
  display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-3);
  padding: 11px 13px 13px; flex: 1;
}
.capcard .cmeta { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.capcard .cnm {
  display: block; font-size: 13px; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.capcard .cdt { display: block; font-size: var(--t-label); color: var(--stone-text); }
.capcard .ctag {
  flex: none; display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--t-label); font-weight: 700; color: var(--ok); white-space: nowrap;
}
.capcard .ctag.off { color: var(--stone-text); font-weight: 600; }
.capcard .ctag svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2.2;
  fill: none; stroke-linecap: round; stroke-linejoin: round; }
:dir(rtl) .capcard .ctag svg { transform: scaleX(-1); }

@media (prefers-reduced-motion: reduce) {
  .capcard .cshot img, .capcard:not(.flat):hover .cshot img { transition: none; transform: none; }
}
@media (max-width: 1080px) { .capgrid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } }
@media print { .capcard .ctag { display: none; } }

/* ================================================== actual vs plan, one cell
 * Three columns (62% / 71% / -9%) made the reader subtract what a tick shows at a glance, and
 * spent three column widths saying one thing. Bar = actual, tick = plan, number = the gap. */
.avp {
  display: grid; grid-template-columns: minmax(64px, 1fr) auto auto;
  gap: 10px; align-items: center; min-width: 170px;
}
.avp:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; border-radius: 4px; }
.avpbar { position: relative; display: block; }
/* 🔴 `.track` is a <span> and app.css never sets `display` on it — it only ever worked because
   `.pct` is a flex column, which blockifies its children. In a plain block wrapper it stays
   inline, height is ignored, and the bar vanishes leaving just the plan tick. */
.avp .track { display: block; width: 100%; }
.avp .track > i.ok   { background: var(--ok-fill); }
.avp .track > i.warn { background: var(--warn-fill); }
.avp .track > i.late { background: var(--late-fill); }
/* OUTSIDE the track, which is overflow:hidden — a plan at 100% would otherwise be clipped away
   and read as "no plan" instead of "plan is at the end". */
.plantick {
  position: absolute; inset-block-start: -4px; width: 2px; height: 14px;
  border-radius: 1px; background: var(--ink); transform: translateX(-50%);
}
.avp .v { font-size: 13px; font-weight: 700; }
/* 🔴 A signed number needs an explicit base direction as well as isolation, or RTL renders
   -9% as 9%- . "on plan" is a word and is left to the page direction. */
.avp .var { font-size: var(--t-label); font-weight: 700; min-width: 54px; text-align: end;
  unicode-bidi: isolate; direction: ltr; }
:dir(rtl) .avp .var { direction: rtl; }
.card .avp { margin-top: 6px; }

@media (max-width: 1360px) { .avp { min-width: 140px; grid-template-columns: minmax(44px, 1fr) auto auto; gap: 8px; } }

/* A multi-date capture offers BOTH: scrub its dates here, and open the published date on tier 3.
   🔴 `auto`, not `--sp-2`: the label beside it is gone, and `space-between` on a single child
   leaves it at the START - the arrow would drift to the text's left edge instead of the card's
   trailing one. 🔴 And a 12px glyph is not a target: the padding gives the icon-only link a
   24px box without changing where the arrow sits (the negative inline-end keeps it flush). */
.capcard .ctag.open3d {
  margin-inline-start: auto;
  margin-inline-end: -6px;
  padding: 6px;
  border-radius: 4px;
  text-decoration: none;
  white-space: nowrap;
}
.capcard .ctag.open3d:hover { background: var(--surface); }

/* ------------------------------------------- the capture's activity line
 * Icon + number, and the number only. "2 ISSUES / 5 COMMENTS" in caps needs more width than the
 * 128px text column has, and the icon already says which number it is. Zero-suppressed in JS, so
 * a quiet capture renders nothing here at all and the active ones carry the only marks in the
 * rail — which is the whole point of a pulse.
 * 🔴 INK, not red. Nine red flags down a rail teaches the reader that red means "capture". The
 * count is a fact; red is reserved for a state that is actually wrong. */
/* Chosen on the real rail at the real 392px, against the real counts, with the tuning panel that
   used to live in tweak.js. These are the values that came back from it. */
:root {
  --abit-size: 14px;
  --abit-icon: 19px;
  --abit-stroke: 1.5;
  --abit-gap: 12px;
  --abit-weight: 700;
  /* 🔴 The TOKEN, not the hex the panel emitted. `--late` renders to exactly #af1c24, measured, so
     this is byte-identical today and stays correct if the brand red ever moves. Contrast on white,
     through a canvas round-trip rather than a computed-style parse (which reports oklch() and
     silently parsed as black, see the history of this block):
       --late        #af1c24  6.97:1  ✓  issues
       --retro-blue  #4e86a8  3.96:1  ✗  the house blue FAILS the 4.5:1 floor at this size
       --cmt-color   #0c71a0  5.40:1  ✓  the same hue dropped to L 0.520, so comments
     The blue is a darkened sibling of the house blue, not a new colour. */
  --iss-color: var(--late);
  --cmt-color: oklch(0.520 0.110 237);
}
/* 🔴 A BAND under both columns, not a line inside the text column. The inline form was measured
   and does not fit: with the words shown it needs 165px in a 120px column, and `.cact-stat` is a
   non-wrapping flex row inside a card that clips, so "Comments" lost its last four glyphs — the
   exact silent-truncation failure the actions row above was rewritten to avoid. The band has the
   full 326px. It costs ~44px, but ONLY on a card that has something to say.

   🔑 IT ECHOES THE CARD'S OWN GRID instead of spreading. `.capcard` is `grid-template-columns:
   186px 128px` with a 12px gap, and `space-around` put the two counts at x=49 and x=189 — aligned
   to nothing above them, in a card whose every other element sits on one of those two columns.
   Inheriting the same template puts "Issues" under the picture and "Comments" under the name, so
   the band reads as part of the card rather than as a strip glued to it. (HIG layout: align and
   indent to show hierarchy.)

   Spacing is on the 4/8 grid and symmetric: 12px above the rule's content, 4px below plus the
   card's own 8px padding = 12px. The old `10px 12px 2px` was off-grid in all three. */
.capcard .cact-stat {
  display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
  align-items: center;
  margin: 0; padding: 12px 0 4px;
  border-top: 1px solid var(--hairline);
  font-size: var(--abit-size); color: var(--stone-text);
}
/* `subgrid` is the honest way to say "the card's columns"; where it is not supported the explicit
   template is the same two tracks, so the alignment holds either way. */
@supports not (grid-template-columns: subgrid) {
  .capcard .cact-stat { grid-template-columns: 186px 128px; gap: var(--abit-gap); }
}
/* 🔴 An explicit line-height and a 28px floor, for two different reasons.
   The two counts measured 19.0px and 20.3px tall because their glyph boxes differ, which is both
   a visible half-pixel misalignment and what made an earlier wrap check report "2 lines" on a
   63px row. A fixed line-height makes them the same object.
   The 28px is the hit region: these counts become links to the filtered list, and 28x28 is the
   HIG floor for a dense pointer control (44x44 is the touch figure; this rail is pointer-first
   and the band already carries ~33px). The padding grows the target without moving the glyph. */
.capcard .abit {
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
  line-height: 20px; min-height: 28px; padding: 4px 0;
  /* No rule at rest. The same choice `.ctag.open3d` already makes: a permanent underline on two
     counts per card turns a quiet band into ruled text, and the colour plus the icon already say
     these are not labels. The underline comes back on hover, where it answers "is this a link". */
  text-decoration: none;
}
.capcard .abit:hover i, .capcard .abit:focus-visible i { text-decoration: underline; }
.capcard .abit:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 4px; }
.capcard .abit svg {
  width: var(--abit-icon); height: var(--abit-icon); stroke: currentColor;
  stroke-width: var(--abit-stroke);
  fill: none; stroke-linecap: round; stroke-linejoin: round;
}
/* 🔑 The ICON carries the colour and the NUMBER stays ink. Colouring both put two saturated marks
   per card on a rail of nine; the glyph alone is enough to say which number this is, and the
   figure stays at full text contrast where it is actually being read. */
.capcard .abit.iss { color: var(--iss-color); }
.capcard .abit.cmt { color: var(--cmt-color); }
/* The word. Hidden by default; `--abit-label: inline` shows it. Inherits the bit's colour so a
   labelled count reads as one object rather than an icon with a caption. */
.capcard .abit i { font-style: normal; font-weight: 600; }
/* Tabular figures so a 9 and an 11 do not shuffle the row as the rail re-renders. */
.capcard .abit b {
  font-weight: var(--abit-weight); color: var(--ink); font-variant-numeric: tabular-nums;
}
/* 🔑 What makes a feed feel alive is RECENCY, not faces. This is the only coloured mark on the
   line, and it appears only while something was actually written this week. */
.capcard .abit.new {
  padding: 1px 6px; border-radius: 999px; font-weight: 700;
  color: var(--late); background: oklch(0.487 0.180 25 / .10);
}
:dir(rtl) .capcard .abit svg { transform: scaleX(-1); }


@media print { .capcard .cact-stat { display: none; } }

/* The thumbnail badge, so the tweak panel can take it away and show what the card looks like when
   the count lives ONLY on the activity line. `--tw-badge` is unset in production, so this rule
   resolves to the `flex` the badge already had and changes nothing. */


/* ── "Where Am I" — AsMade.AI 3D Viewer Handoff v1 ─────────────────────────────────────────────
   Tokens are the handoff's own (02_Developer_Specs/design_tokens.json). Bottom RIGHT per the
   spec. Positioned against #stage rather than the viewport: the stage goes `position: fixed`
   when it expands, so a child rides along to full screen without a second rule. */
.wai {
  --wai-glass: rgba(39, 45, 58, 0.72);
  --wai-stroke: rgba(255, 255, 255, 0.16);
  --wai-inner: rgba(255, 255, 255, 0.06);
  --wai-text: #fff;
  /* 🔴 .85, was the handoff's textSecondary .74. `audit()` has been failing `.wai-dir` at 4.06:1
     against the 4.5:1 text floor since this card shipped — on glass the background is the SCENE,
     so the parent-chain composite lands on the page beneath and .74 cannot clear it. Same
     WCAG-forced departure as `--pw-text-2`, and the two cards now carry one value. */
  --wai-text-2: rgba(255, 255, 255, 0.85);

  position: absolute;
  right: 14px;
  bottom: 14px;
  /* 🔑 5: ABOVE the published tour (4). Everything else of ours goes inert under that frame, because
     the tour already carries its own version of it; this is the one piece that is ADDITIVE, since a
     tier-3 link holds exactly one scan and cannot say where in the building that scan is. Bottom
     right is chosen against the tour's own furniture: its strip is top-right, its background toggle
     and movement hint bottom-left, its transport bottom-centre. */
  z-index: 5;
  width: 264px;
  max-width: calc(100% - 28px);
  border-radius: 28px;
  border: 1px solid var(--wai-stroke);
  background: var(--wai-glass);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  box-shadow: 0 16px 30px rgba(9, 16, 24, 0.22), inset 0 1px 0 var(--wai-inner);
  overflow: hidden;
  /* Fades with the card, on the same curve, for the loading and tour-arriving states. Its own
     `hidden` attribute (no room to point at) stays a hard flip: that is absence, not a wait. */
  transition: opacity .22s ease, visibility .22s;
}
.wai-head {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  height: 52px;
  padding: 0 15px;
  border: 0;
  background: none;
  color: var(--wai-text);
  font: 600 13px/1.2 inherit;
  cursor: pointer;
  text-align: start;
}
.wai-head:focus-visible { outline: 2px solid var(--ok, #42d978); outline-offset: -3px }
.wai-pin {
  flex: none; display: grid; place-items: center;
  width: 28px; height: 28px; border-radius: 50%; background: var(--wai-inner);
}
.wai-pin svg { width: 15px; height: 15px; fill: none; stroke: var(--wai-text); stroke-width: 1.7 }
.wai-title { flex: 1 1 auto; min-width: 0 }
.wai-level { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.wai-dir { flex: none; white-space: nowrap; font-weight: 400; font-size: 12px; color: var(--wai-text-2) }
.wai-dir b { color: var(--wai-text); font-weight: 600 }
.wai-chev { flex: none; display: grid; place-items: center; width: 18px; height: 18px }
.wai-chev svg {
  width: 17px; height: 17px; fill: none; stroke: var(--wai-text-2);
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
  transition: transform 160ms ease;
}
/* compact: label and chevron only, exactly as the interaction spec says */
.wai[data-state="compact"] .wai-level,
.wai[data-state="compact"] .wai-dir,
.wai[data-state="compact"] .wai-body { display: none }
.wai[data-state="expanded"] .wai-title { display: none }
.wai[data-state="expanded"] .wai-chev svg { transform: rotate(180deg) }

.wai-body { padding: 0 11px 11px }
.wai-body canvas { display: block; width: 100%; height: auto }

/* The expanded stage has room for more; the panel in a project card does not.
 *
 * 🔴 `body.stage-full`, not `.stage.full`. #stage NEVER takes a `.full` class — stage.js:1250
 * puts the expanded state on the BODY, and `.full` goes on the canvas (`#gl`). So this rule has
 * never matched since the relayout shipped and the minimap stayed at its docked 264px on the
 * expanded stage, which is the one place it was written to be bigger. Specificity is unchanged
 * 🔴 AND THE SMALL-SCREEN CASE IS NOT "UNCHANGED IN EFFECT" — an earlier version of this comment
 * said it was, and that was wrong. `body.stage-full .wai` is (0,2,1) and the `.wai` rule inside
 * `@media (max-width: 900px)` is (0,1,0), so repairing the selector does not merely restore the
 * author's intent, it newly overrides the small-screen rule: at <=900px the expanded minimap
 * would jump from 200px/right:10px to 300px/right:18px, on the screens with least room for it.
 * The media query therefore states the expanded case too, and keeps it proportionate. */
body.stage-full .wai { width: 300px; right: 18px; bottom: 18px }

@media (max-width: 900px) {
  .wai { width: 200px; right: 10px; bottom: 10px }
  /* Expanded still gets a little more than docked, but nothing like the 300px a desktop gets. */
  body.stage-full .wai { width: 232px; right: 12px; bottom: 12px }
  .wai-head { height: 46px; padding: 0 12px; font-size: 12px }
}

/* ============================================================ progress card
   "How far along is this job", on the expanded stage — see progress.js for why it is the
   PROJECT's progress and not the capture's.

   Material: handoff v1 `widgetGlass`, identical to .wai above and to the tour's own tool rail
   (sampled off the handoff PNG: the rail composites to rgb(81,84,90) over bright scene, which
   solves to exactly rgba(39,45,58,.72)). Three overlays, one material.

   🔴 Positioned against #stage, not the viewport, for the reason .wai states: the stage goes
   `position: fixed` when it expands, so a child rides along to full screen without a second rule.
   Top LEFT, physical — NOT inset-inline-start. The tour's furniture does not flip in Hebrew
   (strip top-right, hint bottom-left, transport bottom-centre), so neither may this, or in
   Hebrew the card lands on top of the strip. */
.pw {
  --pw-glass: rgba(39, 45, 58, 0.72);
  --pw-stroke: rgba(255, 255, 255, 0.16);
  --pw-inner: rgba(255, 255, 255, 0.06);
  --pw-text: #fff;
  /* 🔴 .85, not the handoff's textSecondary .74. On glass the background is the SCENE: over a
     blown-out ceiling .74 measures 3.92:1, under the 4.5:1 text floor, and .85 clears it at
     4.59:1. A WCAG-forced departure of the kind app.css already records for --warn-fill. */
  --pw-text-2: rgba(255, 255, 255, 0.85);
  --pw-line: rgba(255, 255, 255, 0.14);
  --pw-fill: rgba(255, 255, 255, 0.10);
  /* Status on glass. The light-background tokens are tuned against --canvas and go muddy here. */
  --pw-ok: #4ad88e;
  --pw-warn: #f3c44b;
  --pw-late: #ff7a6e;
  --pw-planned: rgba(255, 255, 255, 0.55);

  position: absolute;
  /* Below the chrome row: its padding plus a 34px pill plus the row gap. The chrome is a
     flex row at this same corner, so without the offset the card lands under Back/Exit full. */
  inset-block-start: calc(var(--sp-4) + 34px + var(--sp-3));
  left: 14px;
  /* 🔑 5: ABOVE the published tour (4), for the same reason .wai is — this is additive, the tour
     carries no schedule of its own. Top left is the one corner its furniture leaves clear. */
  z-index: 5;
  /* 🔴 `.stage` is `pointer-events: none` and only its chrome takes events (stage.js:14), so a
     child that does not opt back in is INVISIBLE TO THE POINTER while looking perfectly normal.
     Measured without this: elementFromPoint at the card's own head returned `tourframe`, and a
     programmatic .click() still passed because it dispatches straight at the node — the check
     was green on a control nobody could press. .wai carries the same line. It is granted on
     `.pw.on` below rather than here, so a card that is faded out cannot swallow a press either. */
  width: 392px;
  max-width: calc(100% - 28px);
  border-radius: 28px;
  border: 1px solid var(--pw-stroke);
  background: var(--pw-glass);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  box-shadow: 0 16px 30px rgba(9, 16, 24, 0.22), inset 0 1px 0 var(--pw-inner);
  overflow: hidden;
  color: var(--pw-text);
  font-variant-numeric: tabular-nums;

  /* 🔴 FADED, NOT SWITCHED. Every state that shows or hides this card is a hard flip of some
     other thing — a capture opening, a loading screen going up, a tour frame arriving — so
     appearing instantly read as a pop in the corner of the eye while the stage was still moving.
     The card is hidden by DEFAULT and `.on` reveals it, so the resting state is the quiet one.

     🔴 `visibility` is transitioned WITH A DURATION, never a delay. Under
     `prefers-reduced-motion` app.css:565 forces `transition-duration: 0.01ms` but leaves
     `transition-delay` alone, so the usual `visibility 0s linear .22s` trick would survive it and
     hold the card invisible-but-present for 220ms on exactly the setting that asked for less.
     A duration flips at the right end of the tween and collapses to nothing when zeroed.

     🔴 NOT the `hidden` attribute: app.css:66 is `[hidden] { display: none !important }`, which
     beats anything here. `hidden` is kept for true absence — no project — where there is nothing
     to fade. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity .22s ease, visibility .22s;

  /* 🔴 A CEILING, OR THE LIST IS SIMPLY CUT OFF. The card is positioned against the stage and had
     no height limit, so on a short window (a landscape phone, a half-height browser) the later
     milestones fell past the bottom edge with no way to reach them — and the stage is exactly the
     surface where a page scroll is not available. The head and the four figures stay put; only
     the list scrolls, because losing the list is recoverable and losing the figures is not. */
  max-height: calc(100% - 28px);
  display: flex;
  flex-direction: column;
}
.pw-head, .pw-kpis { flex: none }
.pw-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain }
.pw.on { opacity: 1; visibility: visible; pointer-events: auto }
.pw-head {
  display: flex; align-items: center; gap: 11px;
  width: 100%; padding: 14px 16px; text-align: start;
}
.pw-head:hover { background: rgba(255, 255, 255, 0.06) }
.pw-head:focus-visible { outline: 2px solid var(--pw-ok); outline-offset: -3px }
.pw-ic {
  flex: none; width: 32px; height: 32px; border-radius: var(--r-sm);
  background: var(--pw-fill); display: grid; place-items: center;
}
.pw-ic svg {
  width: 16px; height: 16px; fill: none; stroke: var(--pw-text);
  stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
.pw-htx { flex: 1; min-width: 0 }
.pw-k { display: block; font-size: 11.5px; line-height: 1.3; color: var(--pw-text-2) }
.pw-v {
  display: block; font-size: 15px; line-height: 1.35; font-weight: 700; letter-spacing: -0.012em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pw-count { flex: none; font-size: 12px; color: var(--pw-text-2); white-space: nowrap }
.pw-chev { flex: none; display: grid; place-items: center; width: 18px; height: 18px }
.pw-chev svg {
  width: 13px; height: 13px; fill: none; stroke: var(--pw-text-2);
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.pw[data-state="expanded"] .pw-chev svg { transform: rotate(180deg) }
.pw[data-state="compact"] .pw-count,
.pw[data-state="compact"] .pw-body { display: none }

/* ---- the four figures: the same four the project screen's KPI band prints */
.pw-kpis { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--pw-line) }
/* 🔴 LOGICAL, NOT PHYSICAL, INSIDE THE CARD. The card's POSITION is deliberately physical — the
   tour's furniture does not flip in Hebrew, so neither may the card — but its contents must flow
   with the text. The grid reverses under `dir=rtl`, so a physical `padding-left` put the gap on
   the trailing edge of every cell and `left: 0` drew each separator on the cell's own outer edge:
   a rule hard against the card's border, and none at all between the first two cells. */
.pw-cell { position: relative; padding: 9px 0 11px; padding-inline-start: 16px }
.pw-cell + .pw-cell::before {
  content: ''; position: absolute; inset-inline-start: 0; top: 9px; bottom: 11px;
  width: 1px; background: var(--pw-line);
}
.pw-cell .k { display: block; font-size: 11px; line-height: 1.3; color: var(--pw-text-2) }
.pw-cell .n { display: block; font-size: 19px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.3 }
/* Colour follows STATUS, never the sign: at the shipped thresholds -2% is `ok`. */
.pw-cell .n.ok { color: var(--pw-text) }
.pw-cell .n.warn { color: var(--pw-warn) }
.pw-cell .n.late { color: var(--pw-late) }
.pw-cell .dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--pw-warn); margin-inline-start: 6px; vertical-align: middle;
}
.pw-cell .dot.crit { background: var(--pw-late) }
.pw-track {
  display: block; height: 3px; margin-top: 6px; margin-inline-end: 16px;
  /* pairs with the cell's inline-start padding, so the bar matches the label in both directions */
  border-radius: var(--r-pill); background: var(--pw-fill); overflow: hidden;
}
.pw-track i { display: block; height: 100%; border-radius: var(--r-pill) }
.pw-track.a i { background: var(--pw-ok) }
.pw-track.p i { background: var(--pw-planned) }

/* ---- the milestone list */
.pw-body { padding: 4px 8px 10px; border-top: 1px solid var(--pw-line) }
.pw-ttl {
  padding: 8px 8px 4px; font-size: 11px; font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--pw-text-2);
}
.pw-row {
  position: relative; display: grid; align-items: center; gap: 9px;
  grid-template-columns: 16px 1fr 62px 36px 74px;
  padding: 7px 8px; border-radius: var(--r-sm); font-size: 12.5px;
}
/* The rail's own current-item marker (app.css:114): a 3px brand-yellow bar, so "which milestone
   am I on" reads exactly like "which screen am I on". */
.pw-row.cur { background: var(--pw-fill); font-weight: 600 }
.pw-row.cur::before {
  content: ''; position: absolute; inset-inline-start: -2px; inset-block: 6px;
  width: 3px; border-radius: var(--r-pill); background: var(--brand-yellow);
}
.pw-st { width: 16px; height: 16px }
.pw-st svg {
  width: 16px; height: 16px; fill: none;
  stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round;
  stroke: var(--pw-planned);
}
.pw-row.done .pw-st svg { stroke: var(--pw-ok) }
.pw-row.cur .pw-st svg { stroke: var(--brand-yellow) }
.pw-row.todo .pw-st svg { stroke: rgba(255, 255, 255, 0.38) }
.pw-nm {
  min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--pw-text-2);
}
.pw-row.cur .pw-nm, .pw-row.done .pw-nm { color: var(--pw-text) }
/* 🔴 The current row washes its ground with rgba(255,255,255,.10), which LIGHTENS what the
   secondary text is measured against: `audit()` caught its date at 3.87:1 while every other
   row's passed, so a spot check on any other row would have cleared it. Full white on the one
   row that carries a wash — which is also the row most worth reading. */
.pw-row.cur .pw-dt { color: var(--pw-text) }
.pw-bar { height: 5px; border-radius: var(--r-pill); background: var(--pw-fill); overflow: hidden }
.pw-bar i { display: block; height: 100%; border-radius: var(--r-pill); background: var(--pw-planned) }
.pw-row.done .pw-bar i { background: var(--pw-ok) }
.pw-pc { text-align: end; color: var(--pw-text) }
.pw-dt { text-align: end; font-size: 11.5px; color: var(--pw-text-2); white-space: nowrap }
/* 🔴 A SOLID background, not red text on the glass. `audit()` composites the PARENT CHAIN, and the
   chain above this is a translucent card over a stage with no colour of its own — so an 11.5px
   red measured 2.3:1 against the page beneath it and the check was right to fail. Glass cannot
   carry small coloured text at all; a chip with its own opaque ground can, and it reads as a flag
   rather than as a tint. Measured on these two values: 8.05:1. */
.pw-row.late .pw-dt {
  color: #ffb3ab;
  background: #40232a;
  padding: 2px 7px;
  margin-inline-end: -1px;
  border-radius: var(--r-pill);
}

/* 🔴 NOTHING OF OURS FLOATS OVER THE LOADING SCREEN (Yosi, 2026-10-07). The loader deliberately
   sits at z-index 2, below the chrome, so Back and Expand stay reachable during a 70 MB wait —
   but these two are at 5, so they hung over the room's own still annotating a scan that is not
   there yet, and the card's figures described a project whose capture had not arrived.

   🔑 Expressed as a SIBLING SELECTOR rather than a flag, because `#stage-load` is shown from
   three places in stage.js (loadBegin, sayProgress, loadEnd) and a flag set in two of them is a
   widget that reappears mid-load. The widgets are appended to #stage, so they are always after
   the loader in document order — asserted in tools/e2e/prog.mjs so the rule cannot die silently. */
#stage-load:not([hidden]) ~ .pw,
#stage-load:not([hidden]) ~ .wai { opacity: 0; visibility: hidden; pointer-events: none }

/* 🔴 ...AND THE SAME WHILE A TOUR IS ARRIVING, which the rule above cannot see. Expand on a
   published capture enters that capture's own `/s` tour, and the tour brings its own loading —
   `#stage-load` is never raised for it. Measured before this: from the press of Expand, through
   the 300ms ease, through the frame mounting and through the whole cross-origin load, both
   widgets stayed painted over an easing stage and then a blank viewer, for 10s locally and a
   recorded 56s against the deployed site. The flag goes up at the PRESS and comes down on the
   frame's `load` — see markTourPending in stage.js. */
#stage[data-tour="loading"] .pw,
#stage[data-tour="loading"] .wai { opacity: 0; visibility: hidden; pointer-events: none }

/* 🔴 `body.stage-full`, not `.stage.full`. #stage never takes a `.full` class — the expanded
   state is a BODY class (stage.js:1250). The `.wai` rule above carried the dead form and was
   corrected with this work. */
@media (max-width: 1100px) {
  body.stage-full .pw { width: 330px }
  body.stage-full .pw-row { grid-template-columns: 16px 1fr 44px 34px 70px; gap: 7px }
}
@media (max-width: 760px) {
  body.stage-full .pw { width: calc(100% - 28px) }
  body.stage-full .pw-cell { padding-left: 12px }
}

/* Issues: the unreadable state is a statement, not an empty list. */
.issues .issues-unreadable p { margin: 0; color: var(--ink); }
.issues .issues-unreadable p.sub { margin-top: 6px; font-size: var(--t-label); color: var(--stone-text); }
.field[hidden] { display: none; }

/* ── Trust ────────────────────────────────────────────────────────────────────────────────
   The page says what protects a client's scans, and CHECKS ITSELF while they read it. The
   mark on each row is driven by views/trust.js from this origin's real response headers, so
   `[data-state]` is live state, not decoration. Colours come from the status tokens the rest
   of the product already uses — a red here means the same thing a red means on an issue. */
.trusthero { max-width: 72ch; }
.trustlede { font-size: 1.05rem; line-height: 1.55; color: var(--ink); margin: 0 0 14px; }
.trustnot {
  margin: 0; padding: 12px 14px; border-radius: var(--r-md);
  border: 1px solid var(--hairline);
  background: color-mix(in oklab, var(--tint-orange) 45%, var(--canvas));
  color: var(--ink); line-height: 1.5;
}
.trustnot strong { display: block; margin-bottom: 2px; }

.trustpanel { margin-bottom: 18px; }
.trustpanel h2 { margin: 0 0 4px; }
.trustsub { margin: 0 0 16px; color: var(--slate); max-width: 72ch; line-height: 1.5; }

.trustlist { list-style: none; margin: 0; padding: 0; }
.trustrow {
  display: grid; grid-template-columns: 22px 1fr auto; gap: 12px; align-items: start;
  padding: 12px 0; border-top: 1px solid var(--hairline-soft);
}
.trustrow:first-child { border-top: 0; }
.trusttext { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.trusttext strong { color: var(--ink); }
.trustwhy { color: var(--slate); line-height: 1.5; }
.trusthdr {
  align-self: start; margin-top: 2px; font-size: .78rem; color: var(--stone-text);
  background: var(--surface); border: 1px solid var(--hairline-soft);
  border-radius: var(--r-sm); padding: 1px 6px;
}
.trustcc { color: var(--stone-text); font-size: .8rem; white-space: nowrap; padding-top: 2px; }

/* 🔴 The mark carries its meaning in a SHAPE as well as a colour. A client reading this page
   may not see the difference between the ok and the late token, and "is your security on" is
   the last place to hand somebody colour alone. */
.trustmark {
  width: 20px; height: 20px; border-radius: var(--r-pill); margin-top: 1px;
  border: 1.5px solid var(--hairline); display: block; position: relative; flex: none;
}
.trustmark::after {
  position: absolute; inset: 0; display: grid; place-items: center;
  font-size: 12px; font-weight: 700; line-height: 1;
}
.trustmark[data-state="pass"]    { border-color: var(--ok);   background: color-mix(in oklab, var(--ok-fill) 16%, var(--canvas)); }
.trustmark[data-state="pass"]::after    { content: "✓"; color: var(--ok); }
.trustmark[data-state="fail"]    { border-color: var(--late); background: color-mix(in oklab, var(--late-fill) 18%, var(--canvas)); }
.trustmark[data-state="fail"]::after    { content: "✕"; color: var(--late); }
.trustmark[data-state="stated"]::after  { content: "·"; color: var(--stone-text); font-size: 18px; }
.trustmark[data-state="unknown"]::after { content: "?"; color: var(--warn); }
.trustmark[data-state="pending"]::after { content: "…"; color: var(--stone-text); }

.trustverdict {
  margin: 16px 0 0; padding: 10px 12px; border-radius: var(--r-md);
  border: 1px solid var(--hairline); color: var(--slate); background: var(--surface);
}
.trustverdict[data-state="pass"] { border-color: color-mix(in oklab, var(--ok) 45%, var(--hairline)); color: var(--ok); }
.trustverdict[data-state="fail"] { border-color: color-mix(in oklab, var(--late) 55%, var(--hairline)); color: var(--late); }
.trustverdict[data-state="unknown"] { color: var(--warn); }

.trustopen { list-style: none; margin: 0; padding: 0; display: grid; gap: 14px; }
.trustopen li {
  display: flex; flex-direction: column; gap: 3px;
  padding-inline-start: 12px; border-inline-start: 3px solid var(--warn-fill);
}
.trustopen strong { color: var(--ink); }
.trustopen span { color: var(--slate); line-height: 1.5; max-width: 76ch; }

.truststeps { margin: 0; padding-inline-start: 20px; color: var(--slate); line-height: 1.6; }
.truststeps li { margin-bottom: 6px; max-width: 76ch; }
.trustmail a { color: var(--ink); font-weight: 600; }

@media (max-width: 720px) {
  .trustrow { grid-template-columns: 22px 1fr; }
  .trustcc { grid-column: 2; padding-top: 0; }
}

/* ── Trust: compliance and subprocessors ──────────────────────────────────────────────────
   The two sections a buyer's security reviewer looks for first. A status chip is a FACT, so
   the colour is deliberately restrained: `building` is the product's warning tone, not a
   green that reads as a pass. Nothing here may look like a certification badge. */
.trustfws, .trustvendors { list-style: none; margin: 0; padding: 0; }
.trustfw {
  display: grid; grid-template-columns: minmax(150px, 1fr) auto; gap: 6px 14px;
  padding: 12px 0; border-top: 1px solid var(--hairline-soft); align-items: start;
}
.trustfws > li:first-child, .trustvendors > li:first-child { border-top: 0; }
.trustfwname { font-weight: 600; color: var(--ink); }
.trustfwnote { grid-column: 1 / -1; color: var(--slate); line-height: 1.5; max-width: 80ch; }
.trustchip {
  justify-self: end; white-space: nowrap; font-size: .8rem; font-weight: 600;
  border-radius: var(--r-pill); padding: 3px 10px; border: 1px solid var(--hairline);
  color: var(--slate); background: var(--surface);
}
.trustchip[data-state="building"] { color: var(--warn); border-color: color-mix(in oklab, var(--warn) 45%, var(--hairline)); }
.trustchip[data-state="review"]   { color: var(--slate); }
.trustchip[data-state="none"]     { color: var(--stone-text); }

.trustvendor {
  display: grid; grid-template-columns: minmax(120px, 170px) 1fr auto; gap: 4px 14px;
  padding: 11px 0; border-top: 1px solid var(--hairline-soft); align-items: baseline;
}
.trustvname { font-weight: 600; color: var(--ink); }
.trustvwhat { color: var(--slate); line-height: 1.5; }
.trustvdata { color: var(--stone-text); font-size: .85rem; white-space: nowrap; }
.trustfact { margin: 14px 0 0; color: var(--slate); line-height: 1.5; max-width: 80ch; }
.trustsubhead { margin-top: 22px; }

@media (max-width: 720px) {
  .trustfw { grid-template-columns: 1fr auto; }
  .trustvendor { grid-template-columns: 1fr; }
  .trustvdata { white-space: normal; }
}

/* ── /trust in print ──────────────────────────────────────────────────────────────────────
   The page is also the handout. A prospect's procurement team asks for "your security
   documentation" and this is what gets attached, so Ctrl-P has to produce something a buyer
   would accept rather than a screenshot of an app.
   🔴 The marks must survive. They are the whole point of the page, and a background colour
   is the first thing a browser drops when printing, so each state carries a GLYPH and a
   border as well. `print-color-adjust` asks for the fill back; the page reads correctly
   without it. */
@media print {
  .rail, .langtog, .mast .pjback, .demochip { display: none !important; }
  .trustfw, .trustvendor { break-inside: avoid; }
  .trustpanel {
    break-inside: avoid; border: 1px solid #ccc; box-shadow: none;
    margin-bottom: 10pt; padding: 10pt 12pt;
  }
  .trustrow { break-inside: avoid; padding: 6pt 0; }
  .trustmark { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  .trustnot { -webkit-print-color-adjust: exact; print-color-adjust: exact; border: 1pt solid #333; }
  .trustlede, .trustwhy, .trustopen span, .truststeps { font-size: 10pt; line-height: 1.4; }
  .trusthero { max-width: none; }
  /* A printed page leaves the origin behind, so it has to say where it came from and when it
     was true. A handout with neither is a handout nobody can date. */
  .trustverdict::after {
    display: block; margin-top: 4pt; font-size: 9pt; color: #555;
    content: "Verified at https://dashboard.helyoslabs.com/trust — re-check the live page, which re-verifies itself on every load.";
  }
  @page { margin: 14mm; }
}
