/* ===== DEMO PLAYER =====
   The dashboard tour player, carried over from the Fulltrace Website build
   (D:\Development\Fulltrace Website\site\demo.css) and re-tokenised onto
   blueprint.css. It supersedes the old SCREENSHOT WALKTHROUGH / TOUR CATALOGUE
   / DEMO CURTAIN / HELD-PAUSED CUE / DASHBOARD WALKTHROUGH / WORKFLOW DETAIL
   FINALE / INSIDE A RUN blocks that used to live in blueprint.css.

   Every behavioural class name here is engine contract with js/cycle.js
   (is-active, is-paused, is-invited, is-holding, is-establishing, demo-*,
   cycle-*, jc-*). Restyle freely, rename nothing.

   Loaded only by fulltrace-dashboard.html. The shared .jc-tip and .cycle-*
   base rules still live in blueprint.css, because the loop and graph diagrams
   on other pages use them, so everything the player restyles is scoped under
   .window--demo rather than left global.

   Re-porting: this file is a mechanical re-skin of the Fulltrace original.
   The token bridge below is the only hand-written part; the rest is that file
   with var(--series) to var(--accent), var(--surf) to var(--bg-panel),
   var(--border-2) to var(--card-border), var(--border) to var(--rule),
   var(--text) to var(--heading), var(--muted) to var(--mono-muted),
   var(--body) to var(--tag-text), var(--font-display) to var(--font-sans),
   and the viewport gutter read from --demo-gutter instead of a literal 66px
   (this container pads 48px a side, theirs 33px). */

/* ------------------------------------------------------------ token bridge
   The player's accent chrome (active tab, dots, frames, badges, hold pill,
   curtain arrow) rides the blueprint accent, which already ships a darkened
   -text and -line variant for the light theme. */

:root {
  --demo-accent: var(--accent);
  --demo-accent-text: var(--accent-text);
  --demo-accent-line: var(--accent-line);
  --demo-card: var(--card);
  --demo-card-hover: var(--card-hover);
  /* Dim/spotlight layers over the screenshots. Deliberately NEUTRAL rather
     than the page background: a tinted dim over the capture's grey UI casts
     the whole demo in that tint. Accent stays on selection chrome only. */
  --demo-dim: #0E0E11;
  /* The canvas the stage floats on: the capture backdrop (#121314) composited
     under the dim above, precomputed so the gutters beside and under the stage
     read as the same surface rather than as black bars. Also the fullscreen
     modal's own backdrop, which is why it is a token and not two literals. */
  --demo-canvas: #0E0F11;
}

:root[data-theme="light"] {
  --demo-dim: var(--bg);
  --demo-canvas: #ffffff;
}

/* The site's framed app-window card. Only the demo player uses it here, so it
   lives with the player rather than in blueprint.css. */
.window {
  background: var(--bg-panel);
  border: 1px solid var(--card-border);
  border-radius: 8px;
  padding: 14px;
}

/* The stage caps its width from the viewport as well as from its own canvas,
   and the gutter it has to leave is the container's padding, which halves
   below 800px (see blueprint.css RESPONSIVE). */
.window--demo { --demo-gutter: 96px; }

@media (max-width: 800px) {
  .window--demo { --demo-gutter: 48px; }
}

/* -------------------------------------------------- window--demo variants
   The player lives inside the site's framed app-window card: tour tabs in
   the chrome header next to the mac dots, transport restyled as a footer
   bar inside each stage. */

/* The window shrink-wraps whichever demo panel is visible: no gutters, the
   frame hugs the stage. Tours share one px-per-unit scale (960/1282 equals
   756.2/1010), so type renders the same size in every tour, and every player
   now sits on the 775-unit canonical canvas. */
.window--demo {
  padding: 0;
  overflow: hidden;
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
}

.window--demo .shot-stage { width: min(960px, calc(100vw - var(--demo-gutter))); }
/* 756.2 = 1010 x 960/1282: a recaptured tour's svg resolves to the same height
   as tour 01's, so the chain hand-off does not move the window. Every -main
   panel is on the canonical window now, so this is one rule again. */
.window--demo .demo-panel-main .shot-stage { width: min(756.2px, calc(100vw - var(--demo-gutter))); }
.window--report { margin-top: 32px; }
/* The findings report plays in the canonical window too now (recaptured
   02/08/2026): the page it shows is taller than the window, so it pans inside
   it rather than sizing the window to itself. That retired the portrait
   exception this rule and the Home findings panel both used to carry. */
.window--report .shot-stage { width: min(756.2px, calc(100vw - var(--demo-gutter))); }

/* Short viewports: the engine's intro fires only once 85% of a stage is
   visible at once, so a stage taller than the screen never starts. Cap each
   stage's width from the viewport HEIGHT too (multiplier = canvas aspect),
   so svg + transport + chrome always fit with room to spare. Each cap
   resolves to its panel's full-size svg height. */
@media (min-width: 700px) {
  .window--demo .shot-stage { width: min(960px, calc(100vw - var(--demo-gutter)), calc((100vh - 130px) * 1.654)); }
  .window--demo .demo-panel-main .shot-stage { width: min(756.2px, calc(100vw - var(--demo-gutter)), calc((100vh - 130px) * 1.3032)); }
  .window--report .shot-stage { width: min(756.2px, calc(100vw - var(--demo-gutter)), calc((100vh - 130px) * 1.3032)); }
}

/* The legend fills the window without driving its intrinsic width. */
.window--demo .shot-legend {
  width: 0;
  min-width: 100%;
}

.window__chrome--demo {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--rule);
}

.window__label {
  flex: none;
  font-family: var(--font-mono);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--mono-muted);
}

/* ------------------------------------------------------- fullscreen modal
   Below 1000px the inline demo is width-starved, so the window can take
   over the viewport: a CSS state on the same element, no DOM moves, so the
   engine never notices. Desktop keeps the inline window, unless the window
   runs past the fold once its heading has taken its room, which only
   js/modal.js can tell (see .is-cramped below). */

.demo-expand,
.demo-close {
  display: none;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  padding: 0;
  margin-left: auto;
  border: 1px solid var(--card-border);
  border-radius: 7px;
  background: transparent;
  color: var(--mono-muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}

.demo-expand:hover,
.demo-close:hover { color: var(--heading); border-color: var(--demo-accent-line); }

/* The static floor: a viewport this small can never hold the demo, so the
   button is right before js/modal.js measures anything. */
@media (max-width: 999px), (max-height: 760px) {
  .demo-expand { display: inline-flex; }
}

/* The measured case: js/modal.js sets .is-cramped whenever the window cannot
   finish inside the viewport under its own heading, at any width. */
.window--demo.is-cramped .demo-expand { display: inline-flex; }

body.demo-modal-lock { overflow: hidden; }

.window--demo.is-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  width: auto;
  max-width: none;
  margin: 0;
  border: 0;
  border-radius: 0;
  overflow-y: auto;
  /* The same canvas as the gutters beside the stage, so the room left over
     under a height-capped demo reads as more of the same surface rather than
     as a band of page background. */
  background: var(--demo-canvas);
  /* Column, so the leftover room can be split above and below the stage
     instead of all of it falling to the bottom. */
  display: flex;
  flex-direction: column;
  /* Stage width per panel, declared once and read twice: by the stage itself,
     and by the header bar above it. The height cap is looser than inline
     (104px vs 130px of furniture): with the page chrome gone, the modal demo
     is the bigger one on short screens. --stage-w is whichever is showing. */
  --stage-base: min(960px, 100vw, calc((100vh - 104px) * 1.654));
  --stage-main: min(756.2px, 100vw, calc((100vh - 104px) * 1.3032));
  --stage-w: var(--stage-base);
}

/* The header bar reads as part of the demo, not as page chrome: the same
   width as the transport bar below the capture, so the two bracket it, and
   carried down with the demo rather than pinned to the top of the screen.
   Sticky still earns its place on a viewport too short to hold the panel,
   where the auto margins below collapse and the bar can scroll away. */
.window--demo.is-modal .window__chrome--demo {
  position: sticky;
  top: 0;
  z-index: 4;
  flex: none;
  width: var(--stage-w);
  margin-top: auto;
  margin-inline: auto;
  background: var(--bg-panel);
  padding-top: max(12px, env(safe-area-inset-top));
}

/* Bar and panel centre as one block: the free space splits between this auto
   and the chrome's. Auto margins rather than justify-content, because they
   collapse to zero the moment the pair is taller than the room, so a short
   viewport still scrolls from the top of the bar instead of cutting it off. */
.window--demo.is-modal > .shot-figure:not([hidden]) { margin-block: 0 auto; }

.window--demo.is-modal .demo-expand { display: none; }
.window--demo.is-modal .demo-close { display: inline-flex; }

/* Stages, from the widths declared on the modal above. */
.window--demo.is-modal .shot-stage { width: var(--stage-base) !important; margin-inline: auto; }
.window--demo.is-modal .demo-panel-main .shot-stage { width: var(--stage-main) !important; }

.window--report.is-modal { --stage-w: var(--stage-main); }
.window--report.is-modal .shot-stage { width: var(--stage-main) !important; }

/* --stage-w is the width of whichever panel is showing. The panels are
   siblings of the chrome, not ancestors, so the only way down to it is to
   resolve the width on the parent they share. Same order as the stage rules
   above. */
.window--demo.is-modal:has(.demo-panel-main:not([hidden])) { --stage-w: var(--stage-main); }

.window__body--demo {
  position: relative;
  border-radius: 0;
  border: 0;
  overflow: hidden;
  aspect-ratio: auto;
  container-type: normal;
  /* Solid, both themes, from the one token the modal backdrop also reads.
     No gradients, no shadows. */
  background: var(--demo-canvas);
}

.window--demo .demo-panel { margin: 0; }

.window--demo .shot-legend { padding-inline: 18px; }

.demo-panel[hidden] { display: none; }

/* --------------------------------------------------------- tour tab strip */

/* Same bargain as .shot-legend above: the strip fills whatever the chrome has
   left over without driving the window's intrinsic width, so adding tours can
   never widen the frame off the stage it is supposed to hug. width:0 gives it
   a definite (zero) flex base and flex-grow hands the free space back;
   contain makes the zero binding, whatever the strip holds. */
.demo-nav-wrap {
  display: flex;
  align-items: center;
  gap: 5px;
  flex: 1 1 auto;
  width: 0;
  min-width: 0;
  contain: inline-size;
}

.demo-nav {
  display: flex;
  flex: 1 1 auto;
  flex-wrap: nowrap;
  gap: 6px;
  min-width: 0;
  overflow-x: auto;
  scroll-behavior: smooth;
  scrollbar-width: none;
}

.demo-nav::-webkit-scrollbar { display: none; }

/* Parked right, against the window buttons, which is where space-between put
   the strip before it grew. An auto margin rather than justify-content: it
   collapses to zero once the tours overflow, so the first one stays reachable
   instead of sitting outside the scrollable region. */
.demo-nav > .demo-tab:first-child { margin-left: auto; }

.demo-tab {
  appearance: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.03em;
  color: var(--mono-muted);
  background: transparent;
  border: 1px solid var(--card-border);
  border-radius: 7px;
  padding: 6px 10px;
  white-space: nowrap;
  transition: color 0.15s, background-color 0.15s, border-color 0.15s;
}

/* The tour number is wayfinding, not content, so it is the first thing spent
   when the strip runs out of room (js/demo-nav.js sets .is-tight). */
.demo-tab__i { opacity: 0.6; }
.demo-nav.is-tight .demo-tab__i { display: none; }

.demo-nav-arrow {
  display: inline-flex;
  flex: none;
  align-self: stretch; /* pill height, whatever the pills end up being */
  align-items: center;
  justify-content: center;
  width: 22px;
  padding: 0;
  border: 1px solid var(--card-border);
  border-radius: 7px;
  background: transparent;
  color: var(--mono-muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, opacity 0.15s;
}

/* display above would otherwise beat the hidden attribute's UA rule. */
.demo-nav-arrow[hidden] { display: none; }

.demo-nav-arrow:hover { color: var(--heading); border-color: var(--demo-accent-line); }
.demo-nav-arrow:focus-visible { outline: 2px solid var(--demo-accent); outline-offset: -2px; }

/* Disabled at the ends rather than hidden: the strip must not change width as
   it is scrolled, or the tours would shuffle under the pointer. */
.demo-nav-arrow:disabled { opacity: 0.3; cursor: default; }
.demo-nav-arrow:disabled:hover { color: var(--mono-muted); border-color: var(--card-border); }

.demo-tab:hover { color: var(--heading); }
.demo-tab:focus-visible { outline: 2px solid var(--demo-accent); outline-offset: -2px; }

/* Selection state is violet, per brand law 7. */
.demo-tab.is-active {
  background: color-mix(in srgb, var(--demo-accent) 22%, transparent);
  border-color: var(--demo-accent-line);
  color: var(--heading);
}

/* ------------------------------------------------------------- transport
   Source floated a pill over the shot's bottom-right; here it is a full-width
   window footer bar in normal flow at the bottom of each stage. Only one
   panel is visible at a time, so the per-stage bars read as one persistent
   footer. z-index 3 keeps it above the curtain (the intro card points the
   reader at these controls, so they must stay visible on it). */

.window--demo .cycle-controls {
  position: relative;
  /* undo the loop player's absolute offsets in blueprint.css */
  top: auto;
  right: auto;
  transform: none;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  padding: 9px 14px;
  border-top: 1px solid var(--rule);
  background: var(--bg-panel);
}

.window--demo .cycle-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: 1px solid var(--card-border);
  border-radius: 6px;
  background: var(--demo-card);
  color: var(--mono-muted);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}

.window--demo .cycle-btn:hover {
  color: var(--demo-accent-text);
  border-color: var(--demo-accent);
  background: var(--demo-card-hover);
}

.window--demo .cycle-btn:focus-visible {
  outline: 2px solid var(--demo-accent);
  outline-offset: 2px;
}

/* One glyph or the other, never both. */
.window--demo .cyc-play { display: none; }
.window--demo .cycle-controls.is-paused .cyc-play { display: block; }
.window--demo .cycle-controls.is-paused .cyc-pause { display: none; }

.window--demo .cycle-dots { display: flex; align-items: center; gap: 7px; margin-right: 8px; }
.window--demo .cycle-dots:empty { display: none; }

.window--demo .cycle-dot {
  width: 9px;
  height: 9px;
  padding: 0;
  border: 1px solid var(--mono-muted);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}

.window--demo .cycle-dot:hover { border-color: var(--demo-accent-line); transform: scale(1.3); }
.window--demo .cycle-dot:focus-visible { outline: 2px solid var(--demo-accent); outline-offset: 2px; }
.window--demo .cycle-dot.is-active { background: var(--demo-accent-line); border-color: var(--demo-accent-line); }

/* is-invited: shown where navigation lives the moment a hover holds the walk. */
.window--demo .cycle-controls.is-invited .cycle-dot,
.window--demo .cycle-controls.is-invited .cycle-btn { border-color: var(--demo-accent-line); }

/* -------------------------------------------------------------- caption cards */

.jc-tip {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}

.jc-tip-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}

.jc-tip-head {
  display: block;
  margin-bottom: 5px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.03em;
  color: var(--demo-accent-text);
}

.jc-tip.is-active { opacity: 1; }

/* Hovering a hotspot previews its card (the walk itself paints the same). */
svg:has(.jc-step[data-step="1"]:hover) .jc-tip[data-step="1"],
svg:has(.jc-step[data-step="2"]:hover) .jc-tip[data-step="2"],
svg:has(.jc-step[data-step="3"]:hover) .jc-tip[data-step="3"],
svg:has(.jc-step[data-step="4"]:hover) .jc-tip[data-step="4"],
svg:has(.jc-step[data-step="5"]:hover) .jc-tip[data-step="5"],
svg:has(.jc-step[data-step="6"]:hover) .jc-tip[data-step="6"],
svg:has(.jc-step[data-step="7"]:hover) .jc-tip[data-step="7"],
svg:has(.jc-step[data-step="8"]:hover) .jc-tip[data-step="8"],
svg:has(.jc-step[data-step="9"]:hover) .jc-tip[data-step="9"],
svg:has(.jc-step[data-step="10"]:hover) .jc-tip[data-step="10"] {
  opacity: 1;
}

/* ---------------------------------------------------- screenshot walkthrough */

.shot-stage {
  position: relative;
  display: flex;
  flex-direction: column;
  /* A tap on the stage is the step control and a double tap is spoken for
     (step back, see the tap model in demo.js), so iOS must not read the
     double as zoom. Pinch zoom is untouched. */
  touch-action: manipulation;
}

.shot-svg {
  display: block;
  width: 100%;
  height: auto;
  font-family: var(--font-mono);
  background: var(--bg-panel);
}

/* Welded literals, deliberately NOT tokens: these are sampled from the
   screenshot pixels themselves (VS Code editor backdrop, capture edges), so
   they must not follow the brand palette. Leave them alone in any sweep. */
.dash-svg { background: #121314; }
:root[data-theme="light"] .dash-svg { background: #fff; }

.shot-dim {
  fill: var(--demo-dim);
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
}

.shot-svg:has(.jc-step.is-active) .shot-dim { opacity: 0.76; }

.shot-spot {
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
}

.jc-step.is-active .shot-spot { opacity: 1; }

.shot-frame {
  fill: none;
  stroke: var(--card-border);
  stroke-width: 1.5px;
  opacity: 0.4;
  transition: stroke 0.35s ease, stroke-width 0.35s ease, opacity 0.35s ease;
}

.jc-step.is-active .shot-frame { stroke: var(--demo-accent-line); stroke-width: 2px; opacity: 1; }

.shot-badge {
  fill: var(--bg-panel);
  stroke: var(--card-border);
  stroke-width: 1.5px;
  opacity: 0.55;
  transition: stroke 0.35s ease, opacity 0.35s ease;
}

.jc-step.is-active .shot-badge { stroke: var(--demo-accent-line); opacity: 1; }

.shot-badge-num {
  fill: var(--mono-muted);
  font-size: 16px;
  font-weight: 600;
  opacity: 0.6;
  transition: fill 0.35s ease, opacity 0.35s ease;
}

.jc-step.is-active .shot-badge-num { fill: var(--demo-accent-text); opacity: 1; }

.shot-step { cursor: default; }

/* Tour caption cards: theme-INVERTED literals (a dark-page card goes light
   grey and vice versa) so they read as a floating annotation layer over the
   dimmed screenshot, never as page panels. Sampled values, keep literal. */
.shot-svg .jc-tip-card {
  width: 100%;
  font-family: var(--font-sans);
  font-size: 15px;
  line-height: 1.5;
  color: #20232b;
  background: #c7cad2;
  border: 1px solid rgba(0, 0, 0, 0.14);
  border-radius: 8px;
  padding: 12px 15px;
  box-shadow: 0 8px 26px rgba(0, 0, 0, 0.30);
}

.shot-svg .jc-tip-head {
  font-size: 13.5px;
  margin-bottom: 5px;
  color: color-mix(in srgb, var(--demo-accent) 60%, black 40%);
}

:root[data-theme="light"] .shot-svg .jc-tip-card {
  color: #f0f1f3;
  background: #454850;
  border-color: rgba(255, 255, 255, 0.12);
}

:root[data-theme="light"] .shot-svg .jc-tip-head { color: #C3BBFB; }

.shot-tip .jc-tip-wrap { align-items: flex-start; }

.jc-tip.shot-tip { transition: opacity 0.65s ease; }

/* Legend: screen readers get the svg <desc>; this list serves the sub-700px
   still and the reduced-motion fallback below. */
.shot-legend {
  display: none;
  list-style: none;
  margin: 12px 0 0;
  padding: 0 0 16px;
  gap: 10px 24px;
}

.shot-legend li {
  display: flex;
  gap: 10px;
  font-size: 12.5px;
  line-height: 1.5;
  color: var(--tag-text);
}

.shot-legend-num {
  flex: none;
  font-family: var(--font-mono);
  font-weight: 600;
  color: var(--demo-accent-text);
}

.shot-legend-head { color: var(--heading); font-weight: 600; }

@media (max-width: 700px) {
  /* A 1299-wide dashboard scaled onto a phone makes the in-svg cards and
     spotlight unreadable, so the shot plays as a plain still and the legend
     carries the words. */
  .shot-tip, .window--demo .cycle-controls { display: none; }
  /* No transport below 700px, so the hold cues have nothing to align to. The
     intro card's arrow is hidden for the same reason, in the curtain block
     below: it has to sit after that rule's base to beat it. */
  .dash-stage::after, .demo-hold { display: none; }
  .dash-stage:has(.dash-sec.is-active) .dash-dim { opacity: 0; }
  .dash-stage .jc-step.is-active .dash-spot { opacity: 0; }
  .dash-stage:has(.jc-step[data-step="4"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="4"],
  .dash-stage:has(.jc-step[data-step="5"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="5"],
  .dash-stage:has(.jc-step[data-step="6"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="6"],
  .dash-stage:has(.jc-step[data-step="7"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="7"],
  .dash-stage:has(.jc-step[data-step="8"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="8"],
  .dash-stage:has(.jc-step[data-step="9"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="9"],
  .dash-stage:has(.jc-step[data-step="10"].is-active) .dash-detail-inner .dash-detail-spot[data-spot="10"] { opacity: 0; }
  .run-stage .run-scene-inner .run-spot { opacity: 0; }
  .dash-frame, .dash-badge, .dash-badge-num { opacity: 0; }
  .run-hit, .run-cursor, .run-ripple, .run-focus { opacity: 0; }
  .shot-legend { display: grid; grid-template-columns: 1fr; }
}

/* The walk never runs under reduced motion (cycle.js bails), so the stages
   are stills; keep the words by showing the legend at every width. */
@media (prefers-reduced-motion: reduce) {
  .window--demo .cycle-controls { display: none; }
  .shot-legend { display: grid; grid-template-columns: 1fr; }
  .demo-nav { scroll-behavior: auto; }
}

/* --------------------------------------------------------------- curtain */

.demo-curtain {
  position: absolute;
  inset: -1px;                /* 1px overscan: no sub-pixel seam on fractional-DPI displays */
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 24px;
  text-align: center;
  background: #05060b;        /* dark: a deep-black screen, richer than --bg */
  opacity: 1;
  transition: opacity 0.62s ease;
  pointer-events: auto;
}

:root[data-theme="light"] .demo-curtain { background: var(--bg); }

.demo-curtain.is-clear { opacity: 0; pointer-events: none; }

.demo-curtain-title {
  font-family: var(--font-mono);
  font-size: clamp(20px, 3vw, 32px);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--heading);
  opacity: 0;
  transition: opacity 0.45s ease;
}

.demo-curtain.is-title .demo-curtain-title { opacity: 1; }

/* The card lands in three beats: the tour's name, then how to drive it, then
   the arrow at the transport. Only the Run button stays hidden; the auto-start
   timer runs regardless, so nothing waits on it. */
.demo-curtain-run { display: none; }

.demo-curtain-hint {
  max-width: 80ch;            /* one row on a full-size stage, wraps below it */
  font-family: var(--font-mono);
  font-size: 12.5px;
  line-height: 1.5;
  letter-spacing: 0.02em;
  color: var(--mono-muted);
}

/* The line arrives word by word, a beat after the name has landed, and rests at
   each comma so the three clauses read as three thoughts. --d is the word's own
   delay, worked out and stamped on the span by js/demo.js. Delays on the way in
   only: on reveal the base rules carry none, so the card clears in one piece
   rather than unwinding word by word. */
.demo-curtain-hint-word {
  opacity: 0;
  transition: opacity 0.4s ease;
}

.demo-curtain.is-title .demo-curtain-hint-word {
  opacity: 1;
  transition-delay: calc(1.1s + var(--d, 0ms));
}

/* One line or the other, never both: below 700px there is no transport and no
   hover, so the wide line would promise two things the reader cannot do. The
   swap lives here, after the base rule, not up in the 700px block above:
   a media query adds no specificity, so source order is what decides it. */
.demo-curtain-hint-narrow { display: none; }

/* Every tour but the first: the reader has already been taught how to drive
   the player by tour 01's card, so every other curtain drops the hint line
   and its arrow and carries the tour's name alone (trialled on the tabs
   tour, then widened; Jason, 31/07/2026). Their intros are halved to 4000
   to match: a name alone does not need the 8s the hint line buys. The
   `.window--report` line is the findings player on features.html, which is
   not one of the Home switcher's panels. The arrow lives inside the hint
   (js/demo.js moves it there), but both are named so the intent survives
   that recomposition changing. */
[data-demo-panel="tabs"] .demo-curtain-hint,
[data-demo-panel="tabs"] .demo-curtain-point,
[data-demo-panel="run-a-workflow"] .demo-curtain-hint,
[data-demo-panel="run-a-workflow"] .demo-curtain-point,
[data-demo-panel="inside-a-run"] .demo-curtain-hint,
[data-demo-panel="inside-a-run"] .demo-curtain-point,
[data-demo-panel="findings"] .demo-curtain-hint,
[data-demo-panel="findings"] .demo-curtain-point,
.window--report .demo-curtain-hint,
.window--report .demo-curtain-point { display: none; }

@media (max-width: 700px) {
  .demo-curtain-hint-wide { display: none; }
  .demo-curtain-hint-narrow { display: inline; }
}

/* Points at the transport, starting where the sentence stops. js/demo.js moves
   this anchor inside the hint, after the last word, so it is an inline marker
   at the end of the line rather than a fixed position on the stage: the arrow
   follows the copy instead of the copy having to match the arrow.

   Source aimed at a pill floating over the shot's bottom-right and drew the
   curve left-to-right. Here the transport is a centred footer bar (z-index 3,
   so it reads on the black card) and the sentence ends to its upper RIGHT, so
   the same path is mirrored to sweep the other way. */
.demo-curtain-point {
  position: relative;
  display: inline-block;
  width: 0;
  height: 0;
  vertical-align: baseline;
  color: var(--demo-accent-text);
  opacity: 0;
  transition: opacity 0.45s ease;
  pointer-events: none;
}

/* The hint line already names the controls, so the arrow carries no label. */
.demo-curtain-point-label { display: none; }

/* Mirrored about its own centre, so the box's top-right is the tail (pinned
   just past the full stop by `right`) and its bottom-left is the head. The
   rotation steepens the descent: the natural 145:88 art alone lands short of
   the bar, and preserveAspectRatio is markup the engine owns.

   max-width is spelled out because the shell's global img, svg { max-width:
   100% } resolves against the 0-width anchor above and would otherwise flatten
   the arrow to nothing. */
.demo-curtain-arrow {
  position: absolute;
  right: 4px;                 /* tail starts inside "controls", head on the bar */
  top: 23px;
  width: 290px;
  max-width: none;
  height: 176px;
  overflow: visible;
  transform: scaleX(-1) rotate(9deg);
}

/* The box is drawn at twice the viewBox, so a stroke-width of 1 is the source's
   2px on screen. Written as half rather than as vector-effect: non-scaling-
   stroke because the draw below dashes the path in user units, and a
   non-scaling stroke takes its dashes in screen units instead. */
.demo-curtain-arrow path { stroke: var(--demo-accent-line); fill: none; }
.demo-curtain-arrow-line { stroke-width: 1; stroke-linecap: round; }
.demo-curtain-arrow-head { stroke-width: 1; stroke-linecap: round; stroke-linejoin: round; }

/* Last in, after the sentence it hangs off has finished arriving: the line
   draws itself toward the transport, and only once it lands does the head
   appear and the nudge start. 156.65 is the path's own length in user units
   (getTotalLength on the engine's d), so the dash covers it exactly.

   Both animations fill BACKWARDS, not forwards, and the resting state is the
   drawn one. The card lifts by dropping .is-title, which takes these rules
   with it: resting on "drawn" means the arrow simply fades out with the rest
   of the card instead of un-drawing itself in the last half second. */
.demo-curtain-arrow-line {
  stroke-dasharray: 156.65;
  stroke-dashoffset: 0;
}

.demo-curtain.is-title .demo-curtain-point { opacity: 1; transition-delay: 4.7s; }

.demo-curtain.is-title .demo-curtain-arrow-line {
  animation: demo-arrow-draw 1.8s ease-out 4.8s backwards;
}

.demo-curtain.is-title .demo-curtain-arrow-head {
  animation: demo-arrow-tip 0.3s ease 6.5s backwards;
}

.demo-curtain.is-title .demo-curtain-arrow {
  animation: demo-point-nudge 1.15s ease-in-out 6.8s infinite;
}

@keyframes demo-arrow-draw {
  from { stroke-dashoffset: 156.65; }
  to { stroke-dashoffset: 0; }
}

@keyframes demo-arrow-tip {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Nothing to point at below 700px (no transport, no hover), and no room for it
   under 520px of viewport height, where the stage shrinks to about the arrow's
   own height and it would cross the words it is there to illustrate. Both live
   here, after the rules they undo: a media query adds no specificity, so source
   order is what decides it. */
@media (max-width: 700px), (max-height: 520px) {
  .demo-curtain-point { display: none; }
}

/* Carries the mirror and the rotation, or the animation would drop them and
   the arrow would flip back mid-nudge. The translate is in the element's own
   space, which after the mirror reads as a nudge down toward the transport. */
@keyframes demo-point-nudge {
  0%, 100% { transform: scaleX(-1) rotate(9deg) translate(0, 0); }
  50% { transform: scaleX(-1) rotate(9deg) translate(5px, 3px); }
}

@media (prefers-reduced-motion: reduce) {
  .demo-curtain { display: none; }
}

/* -------------------------------------------------------- held/paused cue */

/* Held frame wraps the content only: it stops at the top of the 45px
   transport bar, not the bottom of the player. */
.dash-stage::after {
  content: "";
  position: absolute;
  inset: 0 0 45px;
  border: 1px solid color-mix(in srgb, var(--demo-accent-line) 50%, transparent);
  opacity: 0;
  transition: opacity 0.16s ease;
  pointer-events: none;
  z-index: 3;
}

.dash-stage.is-holding::after { opacity: 1; }

.demo-hold {
  position: absolute;
  left: 50%;
  bottom: 56px;               /* just above the transport bar, centred */
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 7px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: var(--heading);
  background: color-mix(in srgb, var(--bg-panel) 85%, transparent);
  border: 1px solid var(--demo-accent-line);
  border-radius: 7px;
  padding: 5px 11px;
  opacity: 0;
  transform: translate(-50%, 4px);
  transition: opacity 0.16s ease, transform 0.16s ease;
  pointer-events: none;
}

.dash-stage.is-holding .demo-hold { opacity: 1; transform: translate(-50%, 0); }

.demo-hold-hint { color: var(--mono-muted); }

.demo-hold-bars {
  width: 8px;
  height: 11px;
  background: linear-gradient(to right, var(--demo-accent-text) 0 34%, transparent 34% 66%, var(--demo-accent-text) 66% 100%);
}

.window--demo .dash-stage.is-holding .cyc-pause { display: none; }
.window--demo .dash-stage.is-holding .cyc-play { display: block; }

/* ------------------------------------------------- dashboard walkthrough */

.dash-img-light { opacity: 0; transition: opacity 0.25s ease; }
:root[data-theme="light"] .dash-img-light { opacity: 1; }

/* Main-panel tours (1027-unit canvas) take their width from the stage rule
   in the window block above; the svg fills its stage, no side gutters. */

/* Tabs cross-dissolve through a theme-matched floor, in the normalised
   captures' own panel colour (welded literals). */
.dash-tab-floor { fill: #1e1e1e; opacity: 0; transition: opacity 0.25s ease; }
:root[data-theme="light"] .dash-tab-floor { fill: #fefefe; }

.dash-stage:has(.jc-step[data-step="2"].is-active) .dash-tab-floor,
.dash-stage:has(.jc-step[data-step="3"].is-active) .dash-tab-floor,
.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-tab-floor,
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-tab-floor,
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-tab-floor,
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-tab-floor { opacity: 1; }

.dash-tab-img, .dash-tab-img-light { opacity: 0; transition: opacity 1s ease 1s; }

.dash-stage:has(.jc-step[data-step="2"].is-active) .dash-tab-img[data-step-img="2"],
.dash-stage:has(.jc-step[data-step="3"].is-active) .dash-tab-img[data-step-img="3"],
.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-tab-img[data-step-img="4"],
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-tab-img[data-step-img="5"],
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-tab-img[data-step-img="6"],
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-tab-img[data-step-img="7"] { opacity: 1; transition-delay: 0s; }

:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="2"].is-active) .dash-tab-img-light[data-step-img="2"],
:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="3"].is-active) .dash-tab-img-light[data-step-img="3"],
:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="4"].is-active) .dash-tab-img-light[data-step-img="4"],
:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="5"].is-active) .dash-tab-img-light[data-step-img="5"],
:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="6"].is-active) .dash-tab-img-light[data-step-img="6"],
:root[data-theme="light"] .dash-stage:has(.jc-step[data-step="7"].is-active) .dash-tab-img-light[data-step-img="7"] { opacity: 1; transition-delay: 0s; }

:root[data-theme="light"] .dash-tab-img { opacity: 0 !important; }

/* Findings report: the page is 945 units tall against a 775-unit window, so it
   pans rather than shrinks. Two stops only, both clamped to the page's own
   edges so no stage floor can come into view: the header for steps 1 and 2,
   then the two finding cards for the rest. The page, its spotlights and its
   frames are one group, so an overlay never slides off the pixels it points
   at. Only this player has a .report-pan, so the step gate needs no more
   scoping than the stage it is read through. */
.report-pan {
  transform: translateY(0);
  transition: transform 1.15s cubic-bezier(0.4, 0, 0.2, 1);
}

.dash-stage:has(.jc-step[data-step="3"].is-active) .report-pan,
.dash-stage:has(.jc-step[data-step="4"].is-active) .report-pan,
.dash-stage:has(.jc-step[data-step="5"].is-active) .report-pan,
.dash-stage:has(.jc-step[data-step="6"].is-active) .report-pan,
.dash-stage:has(.jc-step[data-step="7"].is-active) .report-pan { transform: translateY(-170px); }

.dash-dim { fill: var(--demo-dim); opacity: 0; transition: opacity 0.6s ease; pointer-events: none; }
.dash-stage:has(.dash-sec.is-active) .dash-dim { opacity: 0.88; }

.dash-spot { fill: var(--demo-dim); opacity: 0; transition: opacity 0.65s ease; pointer-events: none; }
.jc-step.is-active .dash-spot { opacity: 0.86; }

.dash-frame { fill: none; stroke: var(--demo-accent-line); stroke-width: 2px; opacity: 0; transition: opacity 0.5s ease; }
.jc-step.is-active .dash-frame { opacity: 1; }

.dash-badge { fill: var(--bg-panel); stroke: var(--demo-accent-line); stroke-width: 1.5px; opacity: 0; transition: opacity 0.5s ease; }
.dash-badge-num { fill: var(--demo-accent-text); font-size: 15px; font-weight: 600; opacity: 0; transition: opacity 0.5s ease; }
.jc-step.is-active .dash-badge, .jc-step.is-active .dash-badge-num { opacity: 1; }

.dash-side-wrap {
  position: absolute; top: 0; left: 0;
  width: 272px; height: 775px;
  transform-origin: top left;
  transform: scale(var(--k, 0.82));
}

/* Floor under the capture window, in the shot's own panel colour, so a shot
   shorter than the 775-unit window meets its own background instead of the
   stage's and the shots above it stay pixel-aligned. */
.dash-panel-floor { fill: #1e1e1e; }
:root[data-theme="light"] .dash-panel-floor { fill: #fefefe; }

.dash-sec { transition: opacity 0.6s ease; }
.dash-side:has(.jc-step.is-active) .dash-sec:not(.is-active):not(:has(.is-active)) { opacity: 0.15; }

.dash-blank { fill: var(--demo-dim); opacity: 0; transition: opacity 0.7s ease 0.7s; pointer-events: none; }
.dash-stage:has(.jc-step[data-step="1"].is-active) .dash-blank { opacity: 1; transition: opacity 0.45s ease; }
.dash-stage.is-establishing .dash-blank { opacity: 1; transition: opacity 0.3s ease; }

.dash-open { position: relative; transition: box-shadow 0.4s ease, filter 0.4s ease; }
.dash-open.is-active { box-shadow: 0 0 0 2px var(--ds-bg), 0 0 0 4px var(--ds-accent), 0 6px 18px rgba(0, 0, 0, 0.4); filter: brightness(1.12); }

.dash-click { position: absolute; left: 62%; top: 58%; width: 0; height: 0; opacity: 0; transition: opacity 0.45s ease; pointer-events: none; }
.dash-open.is-active .dash-click { opacity: 1; }
.dash-click-cursor { position: absolute; left: 1px; top: 1px; width: 13px; height: 17px; fill: var(--heading); stroke: var(--demo-dim); stroke-width: 1.2px; }
.dash-click-ripple { position: absolute; left: -9px; top: -9px; width: 18px; height: 18px; border: 2px solid var(--demo-accent-line); border-radius: 50%; opacity: 0; }

@media (prefers-reduced-motion: no-preference) {
  .dash-open.is-active .dash-click-ripple { animation: dash-click-ripple 1.3s ease-out 2; }
}

@keyframes dash-click-ripple {
  from { transform: scale(0.35); opacity: 0.9; }
  to { transform: scale(2.8); opacity: 0; }
}

/* ------------------------------------------------- workflow detail finale */

.dash-detail, .dash-detail-bg {
  opacity: 0;
  transition: opacity 0.7s ease 1.2s;
  pointer-events: none;
}

.dash-stage:has(.dash-detail-step.is-active) .dash-detail {
  opacity: 1;
  transition: opacity 0.7s ease 0.9s;
}

.dash-stage:has(.dash-detail-step.is-active) .dash-detail-bg {
  opacity: 0.6;
  transition: opacity 0.7s ease 0.9s;
}

.dash-detail-bg { fill: var(--demo-dim); }

/* Zoom stops for the 977x926 detail card, computed from the focus box each step
   frames in index.html: a scale, then a translate that puts the box top at 80
   units, leaving the room under it for the step's card. Native y only ever
   increases as the walk advances, so the card reads as one scroll down rather
   than a set of unrelated jumps.

   Horizontally the box wants to be centred, but a zoomed card is wider than the
   window and centring a box near one edge slides the card off the other, leaving
   a band of undimmed catalogue beside it that fights the spotlight. So the pan is
   clamped to keep the card covering the window (x=272 for a box on the left,
   1282 - 977 x scale for one on the right): the box lands as near centre as the
   card allows and never further.

   Three deliberate exceptions: step 3 is the whole card fitted (0.80, landing
   inside the window with room to breathe, and narrower than the window, so the
   dimmed catalogue reads around it: it IS an overlay); step 4 pins the card's own
   top to the window top, so the title and description stay in view under the
   shape it is describing; and steps 9-10 share one transform, because the card's
   tail is only ~260 units deep and framing it any tighter would strand its bottom
   edge mid-window, so there only the spotlight moves. Scale is capped at 1.55:
   the captures are 1x, and past about that the zoom is upsampling. */
.dash-detail-inner {
  transform-origin: 0 0;
  transform: translate(386.2px, 17.1px) scale(0.8);
  transition: transform 1.45s cubic-bezier(0.4, 0, 0.2, 1) 1.2s;
}

.dash-stage:has(.jc-step[data-step="3"].is-active) .dash-detail-inner { transform: translate(386.2px, 17.1px) scale(0.8); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-detail-inner { transform: translate(272px, 0px) scale(1.35); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-detail-inner { transform: translate(272px, -195.9px) scale(1.55); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-detail-inner { transform: translate(-232.4px, -195.9px) scale(1.55); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-detail-inner { transform: translate(272px, -260.4px) scale(1.15); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="8"].is-active) .dash-detail-inner { transform: translate(272px, -540px) scale(1.55); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="9"].is-active) .dash-detail-inner { transform: translate(293.4px, -146.7px) scale(0.99); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="10"].is-active) .dash-detail-inner { transform: translate(293.4px, -146.7px) scale(0.99); transition-delay: 0s; }

.dash-detail-img-light { opacity: 0; }
:root[data-theme="light"] .dash-detail-img-light { opacity: 1; }
:root[data-theme="light"] .dash-detail-img:not(.dash-detail-img-light) { opacity: 0; }

.dash-stage:has(.jc-step[data-step="1"].is-active) .dash-detail,
.dash-stage:has(.jc-step[data-step="1"].is-active) .dash-detail-bg {
  opacity: 0;
  transition: opacity 0.35s ease 0s;
}

.dash-detail-spot { fill: var(--demo-dim); opacity: 0; transition: opacity 0.65s ease; pointer-events: none; }

.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-detail-spot[data-spot="4"],
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-detail-spot[data-spot="5"],
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-detail-spot[data-spot="6"],
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-detail-spot[data-spot="7"],
.dash-stage:has(.jc-step[data-step="8"].is-active) .dash-detail-spot[data-spot="8"],
.dash-stage:has(.jc-step[data-step="9"].is-active) .dash-detail-spot[data-spot="9"],
.dash-stage:has(.jc-step[data-step="10"].is-active) .dash-detail-spot[data-spot="10"] { opacity: 0.86; }

.dash-focus {
  fill: none;
  stroke: var(--demo-accent-line);
  stroke-width: 2.5px;
  vector-effect: non-scaling-stroke;
  opacity: 0;
  transition: opacity 0.6s ease;
}

.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-focus[data-focus="4"],
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-focus[data-focus="5"],
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-focus[data-focus="6"],
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-focus[data-focus="7"],
.dash-stage:has(.jc-step[data-step="8"].is-active) .dash-focus[data-focus="8"],
.dash-stage:has(.jc-step[data-step="9"].is-active) .dash-focus[data-focus="9"],
.dash-stage:has(.jc-step[data-step="10"].is-active) .dash-focus[data-focus="10"] { opacity: 1; }

.dash-rowhit { fill: none; stroke: var(--demo-accent-line); stroke-width: 2px; opacity: 0; transition: opacity 0.45s ease; }
.dash-cursor { fill: var(--heading); stroke: var(--demo-dim); stroke-width: 1.2px; opacity: 0; transition: opacity 0.45s ease; }
.dash-ripple { fill: none; stroke: var(--demo-accent-line); stroke-width: 2px; opacity: 0; }

.dash-stage:has(.jc-step[data-step="3"].is-active) .dash-rowhit,
.dash-stage:has(.jc-step[data-step="3"].is-active) .dash-cursor { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  .dash-stage:has(.jc-step[data-step="3"].is-active) .dash-ripple { animation: dash-ripple 1.3s ease-out 2; }
}

@keyframes dash-ripple {
  from { r: 5px; opacity: 0.9; }
  to { r: 30px; opacity: 0; }
}

/* ------------------------------------------------------ inside a run (04)
   Three scenes on the canonical 1010x775 window, each a taller-than-the-window
   capture held at native size inside a transformed group, so a step is one
   animated property. Scene A (the Runs view) is the floor and never fades: it
   is always up, and B and C arrive over it on a click. */

.run-scene-b, .run-scene-c,
.run-scene-bg {
  opacity: 0;
  transition: opacity 0.7s ease 1.2s;
  pointer-events: none;
}

.run-scene-a { pointer-events: none; }

/* Behind scene A, in the capture's own page colour rather than the dim: the
   pans below are clamped to the scene's own edges, so this only ever shows as
   a sub-pixel sliver at a fractional-DPI scale, and it should not be black. */
.run-floor { fill: #1e1e1e; }
:root[data-theme="light"] .run-floor { fill: #ffffff; }

.run-stage:has(.run-b-step.is-active) .run-scene-b,
.run-stage:has(.run-c-step.is-active) .run-scene-c {
  opacity: 1;
  transition: opacity 0.7s ease 0.9s;
}

/* The two click steps: the scene they summon waits until the click has read. */
.run-stage:has(.jc-step[data-step="6"].is-active) .run-scene-b,
.run-stage:has(.jc-step[data-step="11"].is-active) .run-scene-c {
  transition: opacity 0.9s ease 2.4s;
}

.run-scene-bg { fill: var(--demo-dim); }

.run-stage:has(.run-b-step.is-active) .run-scene-b .run-scene-bg,
.run-stage:has(.run-c-step.is-active) .run-scene-c .run-scene-bg { opacity: 1; }

/* Lapping back to the top: both overlays go quickly, so step 1 is not read
   through the tail of the node report. */
.run-stage:has(.jc-step[data-step="1"].is-active) .run-scene-b,
.run-stage:has(.jc-step[data-step="1"].is-active) .run-scene-c,
.run-stage:has(.jc-step[data-step="1"].is-active) .run-scene-bg {
  opacity: 0;
  transition: opacity 0.35s ease 0s;
}

.run-scene-inner {
  transform-origin: 0 0;
  transform: translate(272px, 0px) scale(1);
  transition: transform 1.45s cubic-bezier(0.4, 0, 0.2, 1) 1.2s;
}

/* Zoom stops, one per step, each computed from the box that step frames:
   scale = the window over the box plus its margin, capped at 1.55 because the
   captures are 1x, floored at 1 because a scene has to cover the window; the
   pan then centres the box and is clamped so no scene edge comes into view.
   Every page here is full width, so most stops resolve to a pure pan; the two
   that do zoom are the histogram tooltip and the findings report. */
.run-stage:has(.jc-step[data-step="1"].is-active) .run-scene-a .run-scene-inner,
.run-stage:has(.jc-step[data-step="2"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, 0px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="3"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -241.8px) scale(1.55); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="4"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -127.5px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="5"].is-active) .run-scene-a .run-scene-inner,
.run-stage:has(.jc-step[data-step="6"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -370px) scale(1); transition-delay: 0s; }

.run-stage:has(.jc-step[data-step="6"].is-active) .run-scene-b .run-scene-inner,
.run-stage:has(.jc-step[data-step="7"].is-active) .run-scene-b .run-scene-inner { transform: translate(272px, 0px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="8"].is-active) .run-scene-b .run-scene-inner { transform: translate(272px, -11.5px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="9"].is-active) .run-scene-b .run-scene-inner { transform: translate(272px, -269px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="10"].is-active) .run-scene-b .run-scene-inner,
.run-stage:has(.jc-step[data-step="11"].is-active) .run-scene-b .run-scene-inner { transform: translate(213.4px, -763.1px) scale(1.126); transition-delay: 0s; }

.run-stage:has(.jc-step[data-step="11"].is-active) .run-scene-c .run-scene-inner,
.run-stage:has(.jc-step[data-step="12"].is-active) .run-scene-c .run-scene-inner { transform: translate(272px, 0px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="13"].is-active) .run-scene-c .run-scene-inner { transform: translate(272px, -147px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="14"].is-active) .run-scene-c .run-scene-inner { transform: translate(272px, -318px) scale(1.1854); transition-delay: 0s; }

.run-img-light { opacity: 0; transition: opacity 0.25s ease; }
:root[data-theme="light"] .run-img-light { opacity: 1; }

/* The reliability panel's hover state, cross-faded in over the same panel once
   the zoom onto it has landed. It rides inside the scene group, so it pans and
   scales with the capture it covers. */
.run-hover { opacity: 0; transition: opacity 0.4s ease 0s; }

.run-stage:has(.jc-step[data-step="3"].is-active) .run-hover {
  opacity: 1;
  transition: opacity 0.5s ease 0.95s;
}

.run-spot { fill: var(--demo-dim); opacity: 0; transition: opacity 0.65s ease; pointer-events: none; }

.run-stage:has(.jc-step[data-step="1"].is-active) .run-spot[data-spot="1"],
.run-stage:has(.jc-step[data-step="2"].is-active) .run-spot[data-spot="2"],
.run-stage:has(.jc-step[data-step="3"].is-active) .run-spot[data-spot="3"],
.run-stage:has(.jc-step[data-step="4"].is-active) .run-spot[data-spot="4"],
.run-stage:has(.jc-step[data-step="5"].is-active) .run-spot[data-spot="5"],
.run-stage:has(.jc-step[data-step="6"].is-active) .run-spot[data-spot="6"],
.run-stage:has(.jc-step[data-step="7"].is-active) .run-spot[data-spot="7"],
.run-stage:has(.jc-step[data-step="8"].is-active) .run-spot[data-spot="8"],
.run-stage:has(.jc-step[data-step="9"].is-active) .run-spot[data-spot="9"],
.run-stage:has(.jc-step[data-step="10"].is-active) .run-spot[data-spot="10"],
.run-stage:has(.jc-step[data-step="11"].is-active) .run-spot[data-spot="10"],
.run-stage:has(.jc-step[data-step="12"].is-active) .run-spot[data-spot="12"],
.run-stage:has(.jc-step[data-step="13"].is-active) .run-spot[data-spot="13"],
.run-stage:has(.jc-step[data-step="14"].is-active) .run-spot[data-spot="14"] { opacity: 0.86; }

.run-focus {
  fill: none;
  stroke: var(--demo-accent-line);
  stroke-width: 2.5px;
  vector-effect: non-scaling-stroke;
  opacity: 0;
  transition: opacity 0.6s ease;
}

/* No outline on the two click steps: the ring around what is being clicked is
   the outline there, and a second box around the same region reads as noise. */
.run-stage:has(.jc-step[data-step="1"].is-active) .run-focus[data-focus="1"],
.run-stage:has(.jc-step[data-step="2"].is-active) .run-focus[data-focus="2"],
.run-stage:has(.jc-step[data-step="3"].is-active) .run-focus[data-focus="3"],
.run-stage:has(.jc-step[data-step="4"].is-active) .run-focus[data-focus="4"],
.run-stage:has(.jc-step[data-step="5"].is-active) .run-focus[data-focus="5"],
.run-stage:has(.jc-step[data-step="7"].is-active) .run-focus[data-focus="7"],
.run-stage:has(.jc-step[data-step="8"].is-active) .run-focus[data-focus="8"],
.run-stage:has(.jc-step[data-step="9"].is-active) .run-focus[data-focus="9"],
.run-stage:has(.jc-step[data-step="10"].is-active) .run-focus[data-focus="10"],
.run-stage:has(.jc-step[data-step="12"].is-active) .run-focus[data-focus="12"],
.run-stage:has(.jc-step[data-step="13"].is-active) .run-focus[data-focus="13"],
.run-stage:has(.jc-step[data-step="14"].is-active) .run-focus[data-focus="14"] { opacity: 1; }

.run-click { opacity: 0; transition: opacity 0.45s ease; pointer-events: none; }

.run-stage:has(.jc-step[data-step="6"].is-active) .run-click[data-click="6"],
.run-stage:has(.jc-step[data-step="11"].is-active) .run-click[data-click="11"] { opacity: 1; }

.run-hit { fill: none; stroke: var(--demo-accent-line); stroke-width: 2px; }
.run-cursor { fill: var(--heading); stroke: var(--demo-dim); stroke-width: 1.2px; }
.run-ripple { fill: none; stroke: var(--demo-accent-line); stroke-width: 2px; opacity: 0; }

@media (prefers-reduced-motion: no-preference) {
  .run-stage:has(.jc-step[data-step="6"].is-active) .run-click[data-click="6"] .run-ripple,
  .run-stage:has(.jc-step[data-step="11"].is-active) .run-click[data-click="11"] .run-ripple {
    animation: dash-ripple 1.3s ease-out 2;
  }
}

/* ------------------------------------------------------ sidebar mock (01)
   A real-HTML rebuild of the VS Code rail, welded to the screenshot at the
   x=272 divider. Its --ds-* palette imitates the captured app, NOT the
   brand: leave every value alone in any brand sweep. Font is the capture's
   own UI font, not the brand face, for the same reason. */

.dash-side {
  --ds-bg: #191a1b; --ds-fg: #c8ccd2; --ds-muted: #878c94; --ds-head: #e6e8ea;
  --ds-border: #2c2e30; --ds-tile: #212325; --ds-tile-bd: #303234;
  --ds-accent: #4c8dff; --ds-btn: #2a2d30; --ds-btn-bd: #3a3d40;
  --ds-online: #3fb950; --ds-track: #34373a;
  width: 272px; height: 775px; overflow: hidden;
  background: var(--ds-bg); color: var(--ds-fg);
  border-right: 1px solid var(--ds-border);
  font-family: 'Segoe UI', system-ui, sans-serif; font-size: 11px; line-height: 1.35;
  display: flex; flex-direction: column;
}

:root[data-theme="light"] .dash-side {
  --ds-bg: #f3f3f3; --ds-fg: #40454c; --ds-muted: #6b7078; --ds-head: #1f2328;
  --ds-border: #e2e2e2; --ds-tile: #ffffff; --ds-tile-bd: #e0e0e0;
  --ds-accent: #1f6feb; --ds-btn: #ffffff; --ds-btn-bd: #d6d6d6;
  --ds-online: #1a7f37; --ds-track: #e2e2e2;
}

.dash-top { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px 6px; color: var(--ds-muted); font-size: 10px; letter-spacing: 0.05em; }
.dash-sec { border-top: 1px solid var(--ds-border); }
.dash-sec:first-of-type { border-top: none; }
.dash-sec.is-active { box-shadow: inset 2px 0 0 var(--ds-accent); }
.dash-sec-head { display: flex; align-items: center; gap: 6px; padding: 7px 12px; color: var(--ds-head); font-size: 11px; font-weight: 600; letter-spacing: 0.03em; }
.dash-sec.is-active .dash-sec-head { color: var(--ds-accent); }
.dash-chev { width: 9px; height: 9px; flex: none; color: var(--ds-muted); transition: transform 0.3s ease; }
.dash-sec.is-active .dash-chev, .dash-sec-static .dash-chev { transform: rotate(90deg); }
.dash-refresh { margin-left: auto; width: 11px; height: 11px; color: var(--ds-muted); }
.dash-collapsible .dash-sec-body { max-height: 0; overflow: hidden; transition: max-height 0.65s ease; }
.dash-collapsible.is-active .dash-sec-body { max-height: var(--h, 400px); }
.dash-body-in { padding: 2px 12px 10px; }
/* MCP SERVER sits directly under TOOL USE (it used to be pinned to the rail's
   foot with margin-top: auto); the rail below it is empty background, exactly
   like the real VS Code rail under its last section. */

.dash-online { display: flex; align-items: center; gap: 7px; padding: 2px 0 8px; }
.dash-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ds-online); flex: none; }
.dash-online b { color: var(--ds-head); font-weight: 600; }
.dash-online .dash-when { margin-left: auto; color: var(--ds-muted); }
.dash-tiles { display: flex; gap: 6px; }
.dash-tile { flex: 1; border: 1px solid var(--ds-tile-bd); background: var(--ds-tile); border-radius: 5px; padding: 6px 7px; }
.dash-tile-k { color: var(--ds-muted); font-size: 8.5px; letter-spacing: 0.04em; }
.dash-tile-v { color: var(--ds-head); font-weight: 700; font-size: 12px; margin-top: 2px; }
.dash-ctrls { display: flex; gap: 6px; margin-top: 8px; }
.dash-ctrl { flex: 1; display: flex; align-items: center; justify-content: center; gap: 4px; border: 1px solid var(--ds-btn-bd); background: var(--ds-btn); border-radius: 5px; padding: 5px 0; color: var(--ds-fg); font-size: 10px; }
.dash-ctrl.is-off { color: var(--ds-muted); opacity: 0.65; }
.dash-ctrl svg { width: 10px; height: 10px; }
.dash-open { display: flex; align-items: center; justify-content: center; gap: 7px; margin-top: 8px; padding: 8px 0; border-radius: 5px; background: var(--ds-accent); color: #fff; font-weight: 600; font-size: 11px; }
.dash-open svg { width: 13px; height: 13px; }

.dash-sub { color: var(--ds-muted); font-size: 9px; letter-spacing: 0.07em; margin: 9px 0 4px; }
.dash-ulabel { display: flex; align-items: center; color: var(--ds-muted); font-size: 9px; letter-spacing: 0.05em; margin: 7px 0 3px; }
.dash-bar { display: flex; align-items: center; gap: 8px; margin: 3px 0; }
.dash-bar-k { width: 20px; color: var(--ds-muted); font-size: 9.5px; flex: none; }
.dash-track { flex: 1; height: 5px; border-radius: 3px; background: var(--ds-track); overflow: hidden; }
.dash-fill { display: block; height: 100%; border-radius: 3px; }
.dash-bar-p { width: 30px; text-align: right; color: var(--ds-fg); font-size: 9.5px; flex: none; }
.dash-reset { display: flex; justify-content: space-between; gap: 8px; color: var(--ds-muted); font-size: 8.5px; margin-top: 3px; }
.dash-reset span:last-child { text-align: right; }

.dash-tt { display: grid; grid-template-columns: 1fr auto auto; column-gap: 12px; row-gap: 3px; align-items: baseline; }
.dash-tt-h { color: var(--ds-muted); font-size: 8px; letter-spacing: 0.04em; text-align: right; }
.dash-tt-k { color: var(--ds-fg); font-size: 10.5px; }
.dash-tt-v { color: var(--ds-head); font-size: 10.5px; text-align: right; font-variant-numeric: tabular-nums; }
.dash-tt-v.at { color: var(--ds-muted); }

.dash-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 11px; color: var(--ds-fg); }
.dash-tree-chev { width: 8px; height: 8px; color: var(--ds-muted); flex: none; }
.dash-tree-ico { width: 13px; height: 13px; flex: none; }

/* ---------------------------------------------- still-mode step numbering
   Below 700px the in-canvas cards are gone and the legend under the stage
   carries the words (see the 700px block above). That left the walk with
   nothing joining the two: the stage lights a region, and the list sits
   there with every line equally live, so the reader has to work out which
   line the lit thing belongs to.

   So in that mode only, the live step wears its number on the canvas and the
   legend shows that step's line and nothing else. Same numeral, same violet,
   in two places, which is what makes them read as one thing: one caption for
   one lit region, exactly as the in-canvas cards do above 700px. The two
   arrive together and leave together (the shared timings below), because a
   number that landed before its words read as two separate events.

   Shown by stacking, not by hiding the rest. Every line is dealt into the
   SAME grid cell, so the list is always as tall as its longest line and the
   page under it never moves as the walk advances; hiding them outright would
   resize the block on every step, and a two-line step following a one-line
   one would shunt the whole page. Stacked also means the lines that are not
   showing are still in the document, so a screen reader still gets all of
   them in order.

   Three kinds of number, one look, because the tours are built three ways
   (trialled on tour 01, widened to all five on Jason's call, 02/08/2026):

     .dash-num        html chips in tour 01's rebuilt sidebar rail, at the
                      head of the section they belong to
     .dash-badge      the badges tours 02 and 05 already carry in the svg, at
                      the region they annotate
     .dash-step-badge added for tours 03 and 04, which annotate their steps
                      with a zoom rather than a frame and so have nowhere at
                      the region to put one: same chip, parked in the window's
                      top-right corner, in window units outside the zoomed
                      group so the pans leave it where it is

   The last two are drawn in svg units and would shrink with the stage (26
   units is 8px on a phone), so both are counter-scaled by 1 / --k, the stage's
   own px-per-unit, published per stage by js/demo.js. 20/26 because they are
   drawn at 26 units across and the html chip is 20px: one size on the glass,
   whichever tour is playing. */

.dash-num {
  flex: none;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border: 1.5px solid var(--demo-accent-line);
  border-radius: 50%;
  background: var(--bg-panel);
  color: var(--demo-accent-text);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  /* Sized on the glass, not in the rail. The mock is drawn at 272x775 and
     scaled onto the stage by --k, which at the widths this mode runs at is
     about 0.54: a chip sized in the rail's own units would land at 11px.
     Undoing the wrap's scale on the chip alone holds it at 20px whatever
     the stage is doing. Origin on its right edge, so it grows back into the
     rail rather than off the side of it (.dash-side clips). */
  transform: scale(calc(1 / var(--k, 0.82)));
  transform-origin: 100% 50%;
  opacity: 0;
  transition: opacity 0.22s ease;
  pointer-events: none;
}

/* Tours 03 and 04's corner chips. Invisible at every width by default, so the
   desktop player is exactly what it was: the still block below is the only
   thing that ever stands them up, and above 700px the cards number the steps. */
.dash-step-badge, .dash-step-num { opacity: 0; }

/* Step 2 is the Open Dashboard button, not a section head: out of the flex
   row it would otherwise shove the label off centre, and pinned to the
   button's right edge instead. The translate is applied after the scale
   above, so it still centres the chip on the button. */
.dash-open > .dash-num {
  position: absolute;
  top: 50%;
  right: 7px;
  margin: 0;
  transform: translateY(-50%) scale(calc(1 / var(--k, 0.82)));
}

@media (max-width: 700px) {
  /* The corner chips, styled here only: the .dash-badge pair they copy is
     defined up in the walkthrough block, and duplicating the look is cheaper
     than making one rule serve two jobs at two widths. */
  .dash-step-badge { fill: var(--bg-panel); stroke: var(--demo-accent-line); stroke-width: 1.5px; }
  .dash-step-num { fill: var(--demo-accent-text); font-size: 15px; font-weight: 600; }

  /* One size on the glass whatever the stage is scaled to. The scale sits on
     a wrapper group (.dash-badge-g in the markup), not on the circle and
     numeral themselves: engines disagree about transform-box: fill-box on
     svg text, and iOS Safari scales a text element about the canvas origin
     instead, which threw each numeral right of its circle by the whole
     scaled x offset (seen on tour 02, badge 1 landing over the Memory tab).
     Every engine honours fill-box on a group, and one transform moving both
     also means the numeral can never leave its circle. */
  .dash-badge-g {
    transform: scale(calc(20 / 26 / var(--k, 0.6)));
    transform-box: fill-box;
    transform-origin: center;
  }
  .dash-badge, .dash-badge-num,
  .dash-step-badge, .dash-step-num { transition: opacity 0.22s ease; }

  /* Child combinators for the html chips, not descendant: step 2 lives INSIDE
     step 1's section, so a descendant match would light both whenever STATUS
     is live. The svg badges have no such nesting. */
  .dash-sec.jc-step.is-active > .dash-sec-head > .dash-num,
  .dash-open.jc-step.is-active > .dash-num,
  .jc-step.is-active .dash-badge,
  .jc-step.is-active .dash-badge-num,
  .jc-step.is-active .dash-step-badge,
  .jc-step.is-active .dash-step-num {
    opacity: 1;
    transition: opacity 0.35s ease 0.2s;
  }

  /* Gated on the curtain, which is the one piece of markup that exists only
     because cycle.js built it: no engine, no collapse. That covers both
     fallbacks in one condition and without a second media query. Script off,
     nothing builds it; reduced motion, cycle.js bails before it does. Either
     way no walk ever runs, so a list showing one line of seven would be a list
     stuck on line one, and the legend is the whole fallback there.

     .shot-figure rather than a panel name, so this reaches all four Home tours
     and the findings player on features.html, which is not one of the
     switcher's panels and carries no data-demo-panel to name. */
  .shot-figure:has(.demo-curtain) .shot-legend li {
    grid-area: 1 / 1;
    opacity: 0;
    /* Out quickly, in after it has gone, and on the same cue as the number:
       the same handover the walk's own cards make, so two captions are never
       dissolving through each other in the one cell. */
    transition: opacity 0.22s ease;
    pointer-events: none;
  }

  /* One pair per step, up to the longest walk (14, tour 04). Every player
     numbers its legend from 1 in walk order, so nth-child IS the step id; a
     tour simply never matches the steps it does not have. Nothing is lit
     through the intro card and the establishing beat, and the legend is blank
     there by design: the line arrives with its number, not before it. */
  .shot-figure:has(.jc-step[data-step="1"].is-active) .shot-legend li:nth-child(1),
  .shot-figure:has(.jc-step[data-step="2"].is-active) .shot-legend li:nth-child(2),
  .shot-figure:has(.jc-step[data-step="3"].is-active) .shot-legend li:nth-child(3),
  .shot-figure:has(.jc-step[data-step="4"].is-active) .shot-legend li:nth-child(4),
  .shot-figure:has(.jc-step[data-step="5"].is-active) .shot-legend li:nth-child(5),
  .shot-figure:has(.jc-step[data-step="6"].is-active) .shot-legend li:nth-child(6),
  .shot-figure:has(.jc-step[data-step="7"].is-active) .shot-legend li:nth-child(7),
  .shot-figure:has(.jc-step[data-step="8"].is-active) .shot-legend li:nth-child(8),
  .shot-figure:has(.jc-step[data-step="9"].is-active) .shot-legend li:nth-child(9),
  .shot-figure:has(.jc-step[data-step="10"].is-active) .shot-legend li:nth-child(10),
  .shot-figure:has(.jc-step[data-step="11"].is-active) .shot-legend li:nth-child(11),
  .shot-figure:has(.jc-step[data-step="12"].is-active) .shot-legend li:nth-child(12),
  .shot-figure:has(.jc-step[data-step="13"].is-active) .shot-legend li:nth-child(13),
  .shot-figure:has(.jc-step[data-step="14"].is-active) .shot-legend li:nth-child(14) {
    opacity: 1;
    transition: opacity 0.35s ease 0.2s;
    pointer-events: auto;
  }
}

/* --------------------------------------------------- host-page adjustments
   Three things the Fulltrace original does not need, because this page frames
   its figures and animates them in and that one does not. */

/* The panels are .figure elements, which carry the page's own card frame.
   Inside the window that would double-frame every tour, so strip it back to a
   bare container and let the window be the only frame. */
.window--demo .figure {
  margin: 0;
  background: none;
  border: 0;
  border-radius: 0;
  overflow: visible;
}

/* The window is width:fit-content, so anything inside it that can grow gets a
   vote on how wide the frame is. The stage is meant to be the only voter: a
   caption is a paragraph, and its max-content is the whole column, which would
   stretch the frame and strand the shot at its left edge. Zero width with a
   100% floor makes it fill the window without sizing it (same trick as
   .shot-legend and .demo-nav-wrap above). */
.window--demo .figure figcaption {
  width: 0;
  min-width: 100%;
}

/* The window is the reveal target, not the tab strip or the individual panels:
   a nested reveal inside it would leave a switched-to tour stuck at opacity 0,
   because scroll.js only ever fires once per element. Keep this in sync with
   REVEAL_SELECTORS in js/scroll.js. */
@media screen and (prefers-reduced-motion: no-preference) {
  html.fx .window--demo .figure,
  html.fx .window--demo .demo-nav {
    opacity: 1;
    transform: none;
  }
}
