/* ===== DEMO PLAYER =====
   The studio 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 / STUDIO 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-studio.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); }

/* Offered at every resolution (Jason, 17/08/2026). It used to appear only
   where the inline window fell short: under a 999px/760px static floor, or
   when js/modal.js measured the window as unable to finish inside the viewport
   and set .is-cramped. But a demo that fits is still a demo someone may want
   bigger, and the button costs one 30px square of chrome. .is-cramped stays as
   modal.js's own signal (it still decides whether a #demo link opens full
   screen instead of scrolling); it no longer gates this. */
.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.

   The frame (site addition, lifted out of css/demo-dict.css 24/08/2026 when
   the studio page went fullscreen-only too): the window card draws 1px around
   chrome and stage together, and the modal state above drops it (border: 0),
   which leaves the demo bleeding into the canvas with nothing to say where
   it stops. Put it back. Chrome and stage are siblings, not nested, so the
   frame comes in two halves: the chrome takes the top and the sides, the
   stage the sides and the bottom, and the chrome's own border-bottom is
   already the join between them. Every width below grows by exactly the 2px
   the borders add, so each stage's CONTENT box still resolves to its own
   token and the captures still render at their own pixels on a screen big
   enough for it. */
.window--demo.is-modal .window__chrome--demo {
  position: sticky;
  top: 0;
  z-index: 4;
  flex: none;
  width: calc(var(--stage-w) + 2px);
  /* The width tokens are capped at 100vw, so the +2px above would push the
     frame 2px past the viewport on any screen small enough to be sitting on
     that cap, and the modal would scroll sideways by the width of its own
     border. Clamped here rather than by taking the 2px off every cap: on a
     screen already downscaling the capture, 2px of content width is the
     right thing to give up, and on one big enough to show it at 1x this
     never binds. Border-box, so the frame is inside the clamp. */
  max-width: 100%;
  margin-top: auto;
  margin-inline: auto;
  background: var(--bg-panel);
  padding-top: max(12px, env(safe-area-inset-top));
  border: 1px solid var(--card-border);
  border-bottom-color: var(--rule);
  border-radius: 8px 8px 0 0;
}

/* 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, plus the bottom half
   of the frame (see the chrome rule). overflow clips the capture's square
   corners off against the rounded ones. */
.window--demo.is-modal .shot-stage {
  width: calc(var(--stage-base) + 2px) !important;
  max-width: 100%;                       /* see the chrome rule above */
  margin-inline: auto;
  border: 1px solid var(--card-border);
  border-top: 0;
  border-radius: 0 0 8px 8px;
  overflow: hidden;
}
.window--demo.is-modal .demo-panel-main .shot-stage { width: calc(var(--stage-main) + 2px) !important; }

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

/* The curtain overscans by 1px to kill a sub-pixel seam on fractional-DPI
   displays, which against a bordered stage means it paints over the border
   for the whole intro and the frame appears only when the black lifts.
   Inside the border instead: the border is itself what covers the seam it
   was overscanning for. */
.window--demo.is-modal .demo-curtain { inset: 0; }

/* --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; }

/* --------------------------------------------------- window--dash sizing
   The Fulltrace studio page's five tours sit on one 1282x580 canvas
   (aspect 2.2103: the Data Dictionary's frame), wider and shorter than the
   shared 960px window, so its stage takes the dict page's width. One rule
   covers every panel, because every tour is on the one canvas now: the
   demo-panel-main split above no longer applies, and the second selector
   only exists to out-rank it. Placed after the whole window/modal block so
   the specificity ties resolve this way. Same viewport-height guard as the
   shared rules, so the intro's 85% visibility test can always fire. */
.window--dash .shot-stage,
.window--dash .demo-panel-main .shot-stage { width: min(1040px, calc(100vw - var(--demo-gutter))); }

@media (min-width: 700px) {
  .window--dash .shot-stage,
  .window--dash .demo-panel-main .shot-stage {
    width: min(1040px, calc(100vw - var(--demo-gutter)), calc((100vh - 130px) * 2.2103));
  }
}

/* How wide the demo is allowed to go full screen, and the one number to
   change if that call gets revisited (undecided, 18/08/2026):

     1282px  the captures' own native 1x, so text is never upsampled. The
             Data Dictionary opens to 1408 because ITS captures are 1467
             wide, so this page reads about 9% smaller than that one.
     1408px  size parity with the Data Dictionary, at up to ~10% upsampling.

   Declared on the window rather than inside the modal state so it can be
   overridden live from DevTools (select .window--dash, set the property)
   without a reload, which is the quickest way to judge the two side by side. */
.window--dash { --dash-modal-max: 1282px; }

/* demo.css reads these tokens with !important, so only they need overriding.
   Both, because panels 02-05 carry demo-panel-main and read --stage-main. */
.window--dash.is-modal {
  --stage-base: min(var(--dash-modal-max), 100vw, calc((100vh - 104px) * 2.2103));
  --stage-main: min(var(--dash-modal-max), 100vw, calc((100vh - 104px) * 2.2103));
}

/* --------------------------------------------------- fullscreen only
   Site addition, moved here from css/demo-dict.css 24/08/2026 when the
   studio tours went the same way as the Data Dictionary's.

   Inline, the widest either stage gets is 1040 against captures drawn at
   1282 (studio) and 1467 (Data Dictionary): a 71-81% render of app UI
   that was small to begin with. Full screen is the only size these tours
   are legible at, so that is the only way they play now (Jason, 19/08/2026
   for the Data Dictionary, 24/08/2026 for the studio page). The player is not
   removed, just withheld: js/modal.js still opens this same window, and
   .is-modal is what lets it through.

   Keyed off has-js because the launcher's link cannot open anything without
   js/modal.js. A reader without scripts keeps the inline player and its
   static first frame, and never sees a button that would do nothing. Both
   pages set the class in a blocking script in <head>, so neither the player
   nor the launcher flashes before the other is decided. */
html.has-js .window--fs-only:not(.is-modal) { display: none; }

/* ---------------------------------------------------------- the launcher
   Stands where the player used to sit. It was a framed card echoing the
   window's own chrome bar and label, dropped 26/08/2026: on a page where every
   other frame holds a screenshot, an empty one reads as a demo that failed to
   load, and its label and tour count repeated the label bar and the h2 right
   above it. What is left is the section's own rhythm, a lede naming the tours
   with a button under it, laid out like the hero's actions. The wrapper
   survives as the has-js hook: only shown when scripts can honour it (see
   has-js above), so it and the inline player are never both on the page.

   Phones get told rather than offered. Full screen on a handset is still a
   1282px capture on a 390px screen, which is the unreadable render the
   fullscreen-only rule exists to prevent. The button and its Escape note go,
   and .demo-mobile-note stands in for them; blueprint.css owns that swap and
   the breakpoint behind it, since the same call is made at every other demo
   button on the site. The lede naming the tours sits outside this wrapper, so
   it stays either way: it is worth more to a reader who cannot watch them than
   to one who can. */

.demo-launch { display: none; margin-top: 24px; }
html.has-js .demo-launch { display: block; }

/* .demo-btn is display:block, which would stretch it the full measure. */
.demo-launch-btn {
  display: inline-block;
  font-size: 13px;
  padding: 10px 20px;
}

.demo-launch-note {
  margin-top: 12px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--mono-muted);
}

/* Once the button and note above it are gone, the wrapper's own margin-top is
   the whole gap from the lede. */
.demo-launch .demo-mobile-note { margin-top: 0; }

/* .demo-launch-btn sets its own display, at the same specificity as
   blueprint.css's .demo-btn and in a file that loads after it, so blueprint's
   hide cannot reach it and the launcher has to undo its own rule. The
   breakpoint is copied from there and has to stay in step with it. */
@media (max-width: 700px), (max-height: 520px) and (pointer: coarse) {
  .demo-launch-btn,
  .demo-launch-note { 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;
  /* The transport bar sits inside the stage, so this is what gives its
     buttons an immediate tap instead of iOS's 300ms wait on a possible
     double. It used to be here for the canvas tap model as well (a double
     tap meant step back, and must not read as zoom); that model is gone and
     the buttons are reason enough on their own. 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 studio panel 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 { display: none; }
  /* The transport stays at this width (Jason, 22/08/2026). It used to go with
     the tips, back when a tap on the canvas stepped the walk and a phone had
     that instead; the canvas is deaf now (see js/demo.js), so these buttons
     are the only way to drive a tour here and the intro card's narrow hint
     line names them.

     The hold cues still go: they are the hover-hold's feedback and the hold
     itself has been off since 17/08/2026. The intro card's arrow goes too, in
     the curtain block below, for want of room rather than want of a target: it
     has to sit after that rule's base to beat it. */
  .dash-stage::after { 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;
  /* Its own size container, which is what the intro's two container queries
     below measure: how tall the card is decides where the hint sentence sits
     and how big the arrow is drawn, and neither answer follows the viewport
     (the same stage is one size inline and another full screen). The curtain
     is pinned to the stage by inset, so its height never depends on its
     children and size containment is safe on it. Moved up from
     css/demo-dict.css 24/08/2026, when the studio's sidebar tour took the
     same intro card. */
  container-type: size;
  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, what the toured component
   is for, then how to drive it, the last of those sitting down by the
   controls it names. Only the Run button stays hidden; the auto-start timer
   runs regardless, so nothing waits on it. */
.demo-curtain-run { display: none; }

/* --------------------------------------------------------- curtain purpose
   One line under the tour's name saying what the toured component is for,
   slotted in by js/demo.js from the stage's data-cycle-sub. Only a stage
   carrying that attribute gets one; every tour that does keeps it, hint or no
   hint, and on the later tours it is all the card says beyond the name.

   Built for the Data Dictionary (Jason, 19/08/2026) and moved here from
   css/demo-dict.css on 24/08/2026, when the studio's sidebar tour took the
   same card: the two pages now share one choreography instead of keeping two
   copies of it that could drift.

   Dressed like the hint (same type, same muted colour) so the card still
   reads as name then detail, and revealed in the gap between the name (at
   once) and the hint's first word (6s), so the beats stay separate. The wait
   before it is deliberate and long, and the fade deliberately slow: the name
   has to be read and finished with before a second line arrives, or the two
   land as one block of text and neither gets read. Tuned by eye over three
   passes, ending here. Every stage carrying a data-cycle-sub needs an intro
   long enough to seat it: this line is not finished arriving until 3.6s.

   Slow and late on the way IN only. A transition is read off the state being
   moved TO, so both of those timings belong in the is-title rule: the base
   rule is what plays on the way OUT, when js/cycle.js drops is-title, and it
   carries the name's own 0.45s (TITLE_FADE in js/cycle.js, which is also the
   wait before the black lifts). Leaving the 1.2s on the base rule let this
   line hang over the lifting screen for three quarters of a second after the
   name and the hint had gone. */
.demo-curtain-sub {
  margin-top: -6px;           /* the curtain's 14px gap alone reads detached; hug the name */
  font-family: var(--font-mono);
  font-size: 12.5px;
  line-height: 1.5;
  letter-spacing: 0.02em;
  color: var(--mono-muted);
  opacity: 0;
  transition: opacity 0.45s ease;
}

.demo-curtain.is-title .demo-curtain-sub {
  opacity: 1;
  transition: opacity 1.2s ease 2.4s;
}

.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, once the purpose line above it has been read,
   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.

   6s is the third beat of the card: the name is done by 0.45s, the purpose
   line by 3.6s, and the sentence itself takes 2.1s to arrive word by word, so
   it is complete at 8.1s. That is a slow card by design and it costs the whole
   intro: a stage showing this line carries data-cycle-intro="12500" to seat
   it, which leaves the finished card standing for about four and a half
   seconds. It was 1.1s before either tour carried a purpose line. */
.demo-curtain-hint-word {
  opacity: 0;
  transition: opacity 0.4s ease;
}

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

/* One line or the other, never both: below 700px the demo is rarely full
   screen and a phone has no keyboard either way, so the wide line would
   promise keys the reader cannot press. The narrow line names the transport
   instead, which is on show at this width. 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. */
/* Sized off the stage rather than in fixed pixels, and only ever drawn on a
   stage too short for the low sentence (see the navigation beat below).

   The art was first laid out at a fixed 290x176 against a 1.654 stage, ~625px
   tall inline, which put the head ~28px clear of the 45px transport bar. Both
   tours now sit on the 2.21 canvas, where the same width is only ~516px tall
   and that arrow runs 27px INTO the bar, which sits above the curtain on
   z-index 3 and clips the head off. cqh tracks the curtain's own height at
   every width instead, inline and full screen alike: 25.6cqh is what leaves
   the head the same ~28px of air, and the width holds the art's 290:176.

   Where the head LANDS, not just how big the arrow is: the tail hangs off the
   end of the hint sentence, which is fixed-size type, but the arrow's reach
   grows with cqh, so on a bigger stage the head would slide further left of
   the transport it points at. Cancel that by giving right the same cqh term.
   38.5cqh is the head's horizontal reach back from the box's right edge, with
   the scaleX(-1) rotate(9deg) folded in: (0.95298 x 42.2) - (0.06755 x 25.6),
   the coefficients being cos/sin of 9deg against the head's position in the
   145x88 art. 169px is the anchor's distance right of the stage's centre (half
   the rendered hint line, tied to its 12.5px type) minus a small lean toward
   the buttons, landing the head just right of the dots. Recompute the px if
   the hint's copy or type size changes; the cqh if the art or its transform
   does. Both lengths are against the curtain's CONTENT box, which its own 24px
   padding puts 48px under the stage. */
.demo-curtain-arrow {
  position: absolute;
  right: calc(169px - 38.5cqh);
  top: 23px;
  width: 42.2cqh;
  max-width: none;
  height: 25.6cqh;
  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: 8.3s; }

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

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

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

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

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

/* No room for the arrow below 700px, where the whole card is about as wide as
   the art, nor 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. The transport it points at is on show at both sizes, and the
   narrow hint line names it in words instead. 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; }
}

/* ------------------------------------------- the navigation beat
   Where the sentence about the controls sits, and whether the arrow is drawn
   at all. Trialled on the Data Dictionary's explorer tour (Jason, 19/08/2026),
   widened to every tour that still shows a hint on 24/08/2026, which is that
   one and the studio's sidebar.

   Two changes to how the card lands. The sentence sits DOWN by the controls
   rather than centred under the name, out of the flex flow so the name and the
   purpose line stay centred as a pair. And the arrow it used to hang off is
   gone, because the journey is now short: proximity is what points.

   88px puts the line 43px clear of the 45px bar, about one bar-height: close
   enough that it reads as a label on the transport rather than as a third line
   of the card. That is a measurement rather than a preference, and it is why
   the arrow is dropped here rather than shortened.

   The art cannot simply be scaled down to suit: it is a 145x88 viewBox with a
   stroke-width of 1 and a ~14-unit head, so both scale with the box. At 290px
   wide the stroke renders 2px and the head 28px; at 122px, 0.84px and 11.8px,
   about the floor for a curve that still reads as an arrow; at the 64px this
   position would allow, 0.44px and 6.2px, which is a smudge. And the size is
   not free to choose, because preserveAspectRatio is the engine's markup: the
   fall and the reach are locked at 1.115 to 1.377 of each other, so a legible
   122px arrow must drop 83px, which pins the sentence back at ~150px off the
   floor. Arrow or proximity, not both. To have it back, put bottom at 150px
   and restore width 122px / height 74px / right -9px / top 12px, which lands
   the head ~30px above the bar with the tail at the sentence's end.

   Behind a container query because a bottom-anchored line only works on a
   stage with room for it: the name and the purpose line are centred, so the
   shorter the stage, the closer they sit to a line pinned 88px off the floor.
   Under 400 of curtain (~446 of stage) the treatment lifts and the rules above
   take back over: the line recentres in the flex flow and the arrow returns,
   at the stage-relative sizing that pairing was tuned for. Nothing below needs
   a second set of numbers. */
@container (min-height: 400px) {
  .demo-curtain-hint {
    position: absolute;
    left: 24px;
    right: 24px;
    bottom: 88px;
    margin-inline: auto;      /* max-width is set; without this it hugs `left` */
  }

  .demo-curtain-point { display: none; }
}

/* Under ~420px of stage the title, the hint and the arrow crowd the bar: the
   card's text block is a fixed size, so it eats a bigger share of a shorter
   stage and the head closes to single figures. The hint line names the
   controls on its own, so the arrow is what goes, the same bargain the 700px
   media query above strikes. */
@container (max-height: 370px) {
  .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 */

/* Both cues answer .is-holding, which nothing sets any more: the hover-hold
   went on 17/08/2026 and the canvas click that could still step a held walk
   on 22/08/2026. Neither can appear, and the pill is aria-hidden besides, so
   this is not a fix for anything visible. It is a stop on stale copy: the
   pill's words are cycle.js's own ('paused. Click to progress.'), the engine
   is kept verbatim, and that sentence now describes a control the demo does
   not have. Turning hover-hold back on must not bring it back with it. Drop
   the display line, and rewrite the copy, to have the cue again. */

/* 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 {
  display: none;              /* dead cue, see the note above */
  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; }

/* ---------------------------------------------------- studio 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; }

/* Every step opens at the top of its capture, whatever the step before it was
   showing, and Analytics, the one tab deep enough to need it, then scrolls its
   remainder into view.

   Those two wants fight each other on a single shared group. Reset the group on
   the step change and the outgoing screen jumps: the cross-fade holds the old
   capture at full opacity for the first second, so it rides 195 units upward in
   plain sight before the new one has begun to show. Give the group a ride back
   instead and it is the same scroll in reverse, which is what made Context look
   like it moved when all it did was follow Analytics.

   So the fold gets its own group around the Analytics pair and the step-3 frame,
   and nothing else moves at all. Leaving the step, .dash-tab-fold holds its
   scrolled position for 2.05s and only then resets, in 0s. 2.05s is the life of
   the outgoing capture itself: the cross-fade holds it at full opacity for 1s
   and fades it over the next 1s, so it is gone by 2s and the reset lands on
   nothing. Covering it with the incoming capture instead would be shorter but
   only works forwards; step back to Config and it is Analytics that paints on
   top, because paint order here is document order. Outlasting the fade holds
   either way. Entering, the 1.6s delay is what buys the tab's fade and its frame
   their moment before the screen moves.

   The screens are 775 units tall against the 580-unit window, so the fold crops
   the bottom 195. Studio (content to y=518), Config (590) and Context (425) end
   at or near the fold and hold still. Analytics runs to 758. Memory (752), Logs
   (755) and Development (758) are just as deep and take the same wrap plus one
   selector whenever the walk wants it; they hold still for now. */
.dash-tab-pan { transform: translateY(0); }

.dash-tab-fold {
  transform: translateY(0);
  transition: transform 0s linear 2.05s;
}

@media (prefers-reduced-motion: no-preference) {
  .dash-stage:has(.jc-step[data-step="3"].is-active) .dash-tab-fold {
    transform: translateY(-195px);
    transition: transform 1.45s cubic-bezier(0.4, 0, 0.2, 1) 1.6s;
  }
}

/* Findings report: the page is 945 units tall against the 580-unit window, so
   it pans rather than shrinks. Four stops, all clamped to the page's own
   edges so no stage floor can come into view: the header for steps 1 and 2,
   the finding cards for 3 to 5, then deeper for the Prepare fix button (6)
   and the decision row (7), which sat inside the old window but are below
   this fold. 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 { transform: translateY(-170px); }
.dash-stage:has(.jc-step[data-step="6"].is-active) .report-pan { transform: translateY(-341px); }
.dash-stage:has(.jc-step[data-step="7"].is-active) .report-pan { transform: translateY(-365px); }

.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: 580px;
  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 fulltrace-studio.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.60, 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, bottom-anchored,
   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.43: the 1040px stage renders 0.811px per unit, so past
   about that the zoom is upsampling the 1x captures (the old 775-unit window's
   1.55 cap at its 0.749, same rendered magnification). */
.dash-detail-inner {
  transform-origin: 0 0;
  transform: translate(483.9px, 12.2px) scale(0.6);
  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(483.9px, 12.2px) scale(0.6); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="4"].is-active) .dash-detail-inner { transform: translate(272px, 0px) scale(1.25); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="5"].is-active) .dash-detail-inner { transform: translate(272px, -174.5px) scale(1.43); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="6"].is-active) .dash-detail-inner { transform: translate(-115.1px, -174.5px) scale(1.43); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="7"].is-active) .dash-detail-inner { transform: translate(272px, -233.8px) scale(1.06); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="8"].is-active) .dash-detail-inner { transform: translate(272px, -492px) scale(1.43); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="9"].is-active) .dash-detail-inner { transform: translate(332.5px, -267.7px) scale(0.91); transition-delay: 0s; }
.dash-stage:has(.jc-step[data-step="10"].is-active) .dash-detail-inner { transform: translate(332.5px, -267.7px) scale(0.91); 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.43 (the same
   rendered magnification as the old 775-window's 1.55: the wider stage
   renders 0.811px per unit against its 0.749), floored at 1 because a scene
   has to cover the window; the pan then centres the box, nudged where the
   caption needs its room, and clamped so no scene edge comes into view.
   Every page here is full width, so most stops resolve to a pure pan.

   One exception earns its own note: the graph (steps 10-11) is a 530-unit
   box that cannot share a 580-unit window with its caption at scale 1, so
   the scene drops to 0.83 and reads as an overlay, dimmed page either side,
   exactly like the workflow tour's fitted card. */
.run-stage:has(.jc-step[data-step="1"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, 0px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="2"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -36px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="3"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -300px) scale(1.43); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="4"].is-active) .run-scene-a .run-scene-inner { transform: translate(272px, -225px) 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, -475px) 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, -109px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="9"].is-active) .run-scene-b .run-scene-inner { transform: translate(272px, -430px) 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(357.9px, -556.9px) scale(0.83); 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, -304px) scale(1); transition-delay: 0s; }
.run-stage:has(.jc-step[data-step="14"].is-active) .run-scene-c .run-scene-inner { transform: translate(272px, -425px) scale(1.09); 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;
  /* 580, the shared canvas height: the closed stack is ~300 units and the
     tallest open body adds 260, so every walk state fits with the same
     empty-rail band below that the real VS Code rail has. */
  width: 272px; height: 580px; 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 Studio 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;
  }
}
