/* Walkthrough.jsx: the Console walkthrough, console.html's one signature
   animated moment. Namespace: wt- (never ch-/cp-). Mounts in Console.jsx
   directly under ConsoleHero: a full-bleed band that extends the hero's own
   dusk.jpg photograph downward (its own crop + scrim, tuned to meet the
   hero's bottom edge in matching dark tone rather than a hard cut) so the
   mountain photography, not app chrome, remains the page's framing asset.
   A mock Console shell floats within that ground as an elevated card
   (--elev-float), with one secondary "6 agents online" chip breaking its
   silhouette. Five beats (Ask / Work / Approve / Track / See) loop on a
   ~36s cycle; a real five-step progress rail below drives/reflects it.

   Usability round (owner note: "not clear you have to click through, not
   engaging yet"): the rail is now a story-style stepper. Each step carries
   a numbered label over its own progress track; the active track visibly
   FILLS across the beat's duration (rAF writes --wt-progress on the rail
   container, the fill is scaleX(var(--wt-progress)); completed steps sit
   full, upcoming sit empty), so "this is playing and these are clickable"
   is legible at a glance, and the hover-pause freeze reads as paused
   rather than broken. A one-line caption above the stepper narrates each
   beat in plain words (WT_BEAT_CAPTIONS, keyed crossfade), and a quiet
   hint line under the stepper says outright that it plays on its own and
   that steps are clickable. In staticMode the caption + stepper remain the
   click-through UI: the active step's track renders full via the
   .wt-progress--static override since no rAF runs there. The rAF loop is
   deliberately NOT tied to the beat-sequence generation counter (a rail
   click bumps genRef, which would kill a gen-checked rAF): it lives in its
   own effect with its own stopped flag, mirrors the active beat through
   beatIndexRef, accumulates only unpaused time, and clamps per-frame dt to
   200ms so the first frame after a hidden tab (rAF stops firing there;
   pausedRef flips before it resumes) can't dump hours into the elapsed
   accumulator.

   Vibrancy round (owner: "more vibrant, more sexy, more interactive, up
   the ante"): the mock Console is now itself clickable, mouse-only and on
   purpose. The four rail icons that map to beats jump the loop there
   (WT_ICON_TO_BEAT), and the Approve pill really approves: onUserApprove
   shows the approved state, lets the moment land (~1.6s of unpaused time),
   then resumes the tour from Track. These are plain spans with onClick, no
   buttons and no tabindex, because the whole shell is aria-hidden: the
   stepper below remains the one accessible control, and keyboard/AT users
   lose nothing that exists only here. Everything else in this round is
   motion, not content: an aurora glow behind the shell (homepage
   .hp-console-glow's conic recipe, slow breathe), magenta on the ACTIVE
   stepper fill only (done steps stay paper-white), a click ripple ring
   spawned per scripted click (own keyed span, since .is-clicking lasts
   120ms and would cut a ::after animation short), drawn-on ticks
   (stroke-dashoffset), chips popping in on the spring token (entrances
   only, per the motion grammar), project stage counts that count up
   (WtCount, instant when animate=false), and nebula scatter twinkle
   (per-dot negative animation-delay so the field never pulses in sync).
   Scene copy untouched: the honesty locks stand.

   Story round (owner: the beats read as disconnected vignettes; the ask
   was about Meridian but Approve showed a Harborview draft, Track a
   renewals project, See an unexplained dot field): one narrative thread
   now runs the whole loop, the same Harborview renewal the site's other
   scenes already share. The ask is the homepage Altitude card's own
   message verbatim (the third App.jsx exception, by direct instruction),
   so the draft in Approve is the ask's own output, the renewals project
   in Track is where that account lives (and the thing that flagged it
   going quiet in the first place), and the captions carry those
   connections in words. The See beat was rebuilt from three abstract
   clusters into one small connected graph (WT_GRAPH_*): Harborview joined
   by drawn-on edges to its project and its knowledge, each node labelled
   with what it is, which is the claim the scene exists to make. All graph
   labels are the same strings the old clusters used (ch07 verbatim); the
   Customer/Project/Knowledge kickers are ch07's own cluster names,
   singularised.

   Story round, second pass (owner: the combined ask mixed two roles'
   work, invoices sit with finance and renewals with sales; and the See
   graph still read unclear): the ask trimmed to its renewal half ("Get
   the Harborview renewal moving.", still the Altitude card's own words),
   one role, one request. The See beat regrounded again: floating
   dot-plus-label points became three connected TILES (the same panel-2
   tile recipe every other beat already uses, so it reads as product UI,
   not a star map), each carrying kicker + name + one verbatim-grounded
   detail that ties it into the story: Harborview "quiet for 9 days" (the
   Track signal), Q3 renewals push "11 of 18 signed" (the Track outcome),
   renewal terms "behind today's draft" (the Approve beat's own label).
   Desktop connects them with the drawn-on edge svg between grid columns;
   mobile stacks them with small vertical connector segments. Scatter
   stars stay as backdrop only (tiles are opaque, so the collision
   exclusion machinery went away with the naked labels).

   All scene copy is reused verbatim from Tour.jsx's own chapters (ch01
   talk, ch03 inbox, ch04 projects, ch05 workforce names, ch07 nebula
   clusters) rather than imported, since each jsx file here is a standalone
   <script type="text/babel"> and top-level consts don't cross files. The
   "6 agents online" chip and the "at a glance" panel's footer line (below)
   are the two deliberate exceptions: verbatim from App.jsx's homepage
   Altitude card, by direct instruction, since they name a real product
   surface rather than inventing a new claim.

   Stage-fill fix (spice round): .wt-content (1086x628 at 1440) was only
   ever ~8-20% visually filled during beats 1-3 (Ask/Work/Approve, ~22s of
   the 36s loop), a 480px scene card left-docked in an otherwise empty dark
   rectangle. Two moves close that without inventing content: the scene
   column itself steps up one size tier for the hero stage (composer, chips
   and rows sized larger than the same idiom's in-page Tour.jsx cards, and
   widened from 480px to ~680px), and a second, permanently-present "at a
   glance" column (WtGlancePanel) fills the stage's other side across every
   beat. That column never changes with the active beat, since it renders
   as a fixed sibling outside the keyed .wt-content-inner, so a beat's own
   remount/fade never touches it, and its rows are verbatim reuse only:
   the same two queue items ch03's Inbox scene shows (CH_INBOX_QUEUE in
   Tour.jsx) plus the Altitude footer line above. It sits at a quiet,
   permanent .75 opacity so the narrative beat stays the eye's first stop.

   Motion architecture: one generation counter (genRef) cancels any
   in-flight async beat sequence the moment the rail jumps or the component
   unmounts, so stale timers can never double-fire. A single interruptible
   `wait()` helper polls a pausedRef flag (off-viewport OR hovered/focused
   inside the shell OR the tab is hidden) so the whole sequence genuinely
   freezes mid-beat and resumes from the same point, rather than restarting.
   Reduced motion and small viewports (<=760px) never run the loop at all:
   every beat's own display values are derived as that beat's finished,
   resting composite, so jumping the rail there is an instant, honest
   snapshot, not a frozen mid-gesture. Beat 3 (Approve, index 2) is that
   default composite, matching the reduced-motion bake requirement. */

// ── verbatim scene data (see file header for provenance) ───────────────────
// The ask is the renewal half of App.jsx's homepage Altitude-card message
// (by direct instruction, story round second pass): the full card line
// mixed invoice chasing and renewals, which sit under different roles in a
// real business, so the demo asks for one thing. It names Harborview, so
// the Approve draft, the Track project and the See graph all follow from
// this one request.
const WT_ASK_TEXT = "Get the Harborview renewal moving."; // App.jsx Altitude card, renewal half
const WT_WORK_CHIPS = ["reading the thread", "CRM", "last week's notes"]; // ch01
const WT_WORK_ROLES = ["Research", "Drafting", "CRM"]; // ch05 specialist names (subset)
const WT_INBOX_LABEL = "Draft to Harborview: renewal terms"; // ch03
const WT_PROJECT = { // ch04, "renewals" project, simplified to fit
  tab: "Q3 renewals push",
  outcome: { headline: "Renew 18 accounts by 30 Sept", health: "on track", detail: "11 of 18", status: "ok" },
  journey: [
    { label: "contacted", count: 18 },
    { label: "quoted", count: 14 },
    { label: "signed", count: 11 },
  ],
  signal: { text: "Harborview quiet for 9 days", dest: "to your Inbox" },
};
// The See diagram's three tiles (labels ch07 verbatim; kickers = ch07's
// cluster names singularised; details reuse this file's own story data:
// the Track signal, the Track outcome figures, the Approve draft).
const WT_GRAPH_TILES = [
  { id: "harborview", kicker: "Customer", label: "Harborview", detail: "quiet for 9 days", hero: true },
  { id: "project", kicker: "Project", label: "Q3 renewals push", detail: "11 of 18 signed" },
  { id: "knowledge", kicker: "Knowledge", label: "renewal terms", detail: "behind today's draft" },
];
const WT_AGENTS_ONLINE = "6 agents online"; // App.jsx Altitude card, reused verbatim by direct instruction
const WT_GLANCE_QUEUE = [ // ch03 CH_INBOX_QUEUE, reused verbatim
  { label: "Invoice chase batch", count: 3 },
  { label: "CRM updates from this morning's calls", count: 2 },
];
const WT_GLANCE_FOOT = "3 drafts awaiting your approval"; // App.jsx Altitude card footer, reused verbatim

const RAIL_STEPS = [
  { id: "ask", label: "Ask" },
  { id: "work", label: "Work" },
  { id: "approve", label: "Approve" },
  { id: "track", label: "Track" },
  { id: "see", label: "See" },
];
// One plain-words narration line per beat, shown above the stepper. These
// describe only what the scene on screen literally shows, and they carry
// the story's connective tissue: the ask produces the draft, the project
// is where the account lives and what flagged it, the graph is why the
// system knew all of that.
const WT_BEAT_CAPTIONS = [
  "You ask in plain English. No prompts to learn.",
  "Specialist agents fan out across your tools and knowledge.",
  "Your ask comes back as a draft in your Inbox. Nothing sends without you.",
  "Behind it, a project tracks all 18 renewals. It flagged Harborview going quiet in the first place.",
  "Why the draft was right: the Console already held the account, its project and its terms, connected.",
];
const BEAT_DURATIONS = [7000, 7500, 8000, 7000, 6500]; // ms, ~36s total
const WT_SMALL_QUERY = "(max-width: 760px)";
// Which beat each mock rail icon opens when clicked (settings maps to none).
const WT_ICON_TO_BEAT = { chat: 0, inbox: 2, projects: 3, nebula: 4 };

// Backdrop scatter, deterministic, same formula as ch07's nebulaScatter
// (duplicated rather than imported per the file-header note). No collision
// exclusions any more: the story round's second pass replaced naked labels
// with opaque tiles, which simply paint over any dot behind them.
function wtGraphScatter(seed, n) {
  const pts = [];
  for (let i = 0; i < n; i++) {
    const a = (i * 53 + seed * 17) % 101;
    const b = (i * 31 + seed * 41) % 97;
    pts.push({
      x: 4 + (a / 101) * 92,
      y: 8 + (b / 97) * 84,
      s: 2 + ((i * 7 + seed) % 3),
      o: 0.16 + (((i * 11 + seed * 5) % 10) / 10) * 0.5,
      d: ((i * 13 + seed * 3) % 26) / 10,
    });
  }
  return pts;
}

// Count-up for the project stage figures: eased rAF from 0, instant when
// animate is false (staticMode), so a snapshot jump is always honest.
function WtCount({ value, animate, delay }) {
  const [n, setN] = React.useState(animate ? 0 : value);
  React.useEffect(() => {
    if (!animate) { setN(value); return; }
    let raf;
    const t0 = performance.now() + (delay || 0);
    const dur = 900;
    function tick(now) {
      const t = Math.min(1, Math.max(0, (now - t0) / dur));
      const e = 1 - Math.pow(1 - t, 3);
      setN(Math.round(value * e));
      if (t < 1) raf = requestAnimationFrame(tick);
    }
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [value, animate, delay]);
  return <span>{n}</span>;
}

function useWtReducedMotion() {
  const [reduced] = React.useState(() => !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches));
  return reduced;
}

// ── minimal rail glyphs, aria-hidden, same flat line-icon family as
// Tour.jsx's ChTickGlyph/ChDocGlyph/ChSwitchGlyph (stroke=currentColor,
// small viewBox). Purely decorative UI chrome, not scene content. ──────────
function WtChatGlyph() {
  return (<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 4.5h13a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H8.2L5 16.2V13.5H3.5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /></svg>);
}
function WtInboxGlyph() {
  return (<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 4.5h14v7.2a2.3 2.3 0 0 1-2.3 2.3H5.3A2.3 2.3 0 0 1 3 11.7V4.5Z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /><path d="M3 9.5h4.1l1.1 1.7h3.6l1.1-1.7H17" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /></svg>);
}
function WtProjectsGlyph() {
  return (<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="6" height="6" rx="1.3" fill="none" stroke="currentColor" strokeWidth="1.4" /><rect x="11" y="3" width="6" height="6" rx="1.3" fill="none" stroke="currentColor" strokeWidth="1.4" /><rect x="3" y="11" width="6" height="6" rx="1.3" fill="none" stroke="currentColor" strokeWidth="1.4" /><rect x="11" y="11" width="6" height="6" rx="1.3" fill="none" stroke="currentColor" strokeWidth="1.4" /></svg>);
}
function WtNebulaGlyph() {
  return (<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="1.6" fill="currentColor" /><circle cx="4.2" cy="6" r="1.2" fill="currentColor" opacity=".7" /><circle cx="16" cy="6.5" r="1" fill="currentColor" opacity=".55" /><circle cx="5.5" cy="15" r="1" fill="currentColor" opacity=".55" /><circle cx="15" cy="14.5" r="1.2" fill="currentColor" opacity=".7" /></svg>);
}
function WtSettingsGlyph() {
  return (<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 5.5h12M4 10h12M4 14.5h12" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /><circle cx="7.5" cy="5.5" r="1.5" fill="currentColor" /><circle cx="13.5" cy="10" r="1.5" fill="currentColor" /><circle cx="9" cy="14.5" r="1.5" fill="currentColor" /></svg>);
}
function WtTickGlyph() {
  return (<svg className="wt-tick" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7" fill="none" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>);
}

const WT_RAIL_ICONS = [
  { id: "chat", Glyph: WtChatGlyph },
  { id: "inbox", Glyph: WtInboxGlyph },
  { id: "projects", Glyph: WtProjectsGlyph },
  { id: "nebula", Glyph: WtNebulaGlyph },
  { id: "settings", Glyph: WtSettingsGlyph },
];

// ── surfaces: the mock Console's own content area, one per beat family ─────
function WtChatSurface({ beatIndex, askText, chipCount, tickCount, chipsShimmer }) {
  const showWork = beatIndex !== 0;
  return (
    <div className="wt-scene">
      <div className="wt-scene-head">
        <span className="wt-scene-id"><span className="wt-scene-mark"><ZMark onDark /></span> Zarco</span>
        <span className="wt-scene-live"><span className="wt-scene-dot" /> live</span>
      </div>
      <div className="wt-scene-body">
        <div className="wt-composer">
          <p className="wt-composer-text">{askText}<span className="wt-caret" /></p>
        </div>
        {showWork && (
          <div className="wt-work">
            <div className="wt-work-chips">
              {WT_WORK_CHIPS.slice(0, chipCount).map((c) => (
                <span key={c} className={"wt-chip" + (chipsShimmer ? " is-shimmer" : "")}>{c}</span>
              ))}
            </div>
            <ul className="wt-work-agents">
              {WT_WORK_ROLES.map((role, i) => (
                <li className={"wt-work-agent" + (i < tickCount ? " is-done" : "")} key={role}>
                  <span className="wt-work-agent-role">{role}</span>
                  <span className="wt-work-agent-tick">{i < tickCount && <WtTickGlyph />}</span>
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    </div>
  );
}

function WtInboxSurface({ approved, approvePillRef, onApprove }) {
  return (
    <div className="wt-scene wt-scene--flush">
      <div className="wt-scene-head">
        <span className="wt-scene-id"><span className="wt-scene-mark"><ZMark onDark /></span> Inbox</span>
        <span className="wt-scene-count">{approved ? "0 waiting" : "1 waiting"}</span>
      </div>
      <div className="wt-scene-body wt-inbox-body">
        <p className="wt-inbox-label">{WT_INBOX_LABEL}</p>
        <div className="wt-inbox-actions">
          {approved ? (
            <span className="wt-approved"><WtTickGlyph /> Approved</span>
          ) : (
            <React.Fragment>
              <span className="wt-pill wt-pill-approve" ref={approvePillRef} onClick={onApprove}>Approve</span>
              <span className="wt-pill wt-pill-quiet">Edit first</span>
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  );
}

function WtProjectsSurface({ animate }) {
  return (
    <div className="wt-scene wt-scene--flush wt-proj-card">
      <div className="wt-scene-head">
        <span className="wt-scene-id"><span className="wt-scene-mark"><ZMark onDark /></span> Project</span>
        <span className="wt-scene-count">{WT_PROJECT.tab}</span>
      </div>
      <div className="wt-scene-body wt-proj-body">
        <div className="wt-proj-tile wt-proj-tile--hero" style={{ "--i": 0 }}>
          <p className="wt-proj-tile-label">Outcome</p>
          <p className="wt-proj-outcome-headline">{WT_PROJECT.outcome.headline}</p>
          <div className="wt-proj-health">
            <span className={"wt-proj-health-dot wt-proj-health-dot--" + WT_PROJECT.outcome.status} />
            <span>{WT_PROJECT.outcome.health}</span>
            <span className="wt-proj-health-sep">&middot;</span>
            <span>{WT_PROJECT.outcome.detail}</span>
          </div>
        </div>
        <div className="wt-proj-tile" style={{ "--i": 1 }}>
          <p className="wt-proj-tile-label">Stages</p>
          <div className="wt-proj-journey">
            {WT_PROJECT.journey.map((s, i) => (
              <div className="wt-proj-stage" key={s.label}>
                <span className="wt-proj-stage-count"><WtCount value={s.count} animate={animate} delay={220 + i * 140} /></span>
                <span className="wt-proj-stage-label">{s.label}</span>
              </div>
            ))}
          </div>
        </div>
        <div className="wt-proj-tile" style={{ "--i": 2 }}>
          <p className="wt-proj-tile-label">Signal</p>
          <p className="wt-proj-signal-text">{WT_PROJECT.signal.text}</p>
          <span className="wt-proj-signal-dest">{WT_PROJECT.signal.dest}</span>
        </div>
      </div>
    </div>
  );
}

function WtGraphTile({ tile, i }) {
  return (
    <div className={"wt-graph-tile" + (tile.hero ? " wt-graph-tile--hero" : "")} style={{ "--i": i }}>
      <p className="wt-graph-kicker">{tile.kicker}</p>
      <p className="wt-graph-label">{tile.hero && <span className="wt-graph-heronode" />}{tile.label}</p>
      <p className="wt-graph-detail">{tile.detail}</p>
    </div>
  );
}

function WtNebulaSurface() {
  const scatter = wtGraphScatter(5, 22);
  return (
    <div className="wt-scene wt-graph-scene" aria-hidden="true">
      <div className="wt-scene-head">
        <span className="wt-scene-id"><span className="wt-scene-mark"><ZMark onDark /></span> Organization</span>
        <span className="wt-scene-count">customers &middot; projects &middot; knowledge</span>
      </div>
      <div className="wt-graph-wrap">
        {scatter.map((p, i) => (<span key={i} className="wt-nebula-dot" style={{ "--x": p.x + "%", "--y": p.y + "%", "--s": p.s + "px", "--o": p.o, "--d": p.d }} />))}
        <div className="wt-graph-diagram">
          <WtGraphTile tile={WT_GRAPH_TILES[0]} i={0} />
          <span className="wt-graph-links" aria-hidden="true">
            <svg viewBox="0 0 100 100" preserveAspectRatio="none">
              <line className="wt-graph-edge" x1="0" y1="50" x2="100" y2="22" />
              <line className="wt-graph-edge" x1="0" y1="50" x2="100" y2="78" />
            </svg>
          </span>
          <span className="wt-graph-vseg" aria-hidden="true" />
          <div className="wt-graph-col">
            <WtGraphTile tile={WT_GRAPH_TILES[1]} i={1} />
            <span className="wt-graph-vseg" aria-hidden="true" />
            <WtGraphTile tile={WT_GRAPH_TILES[2]} i={2} />
          </div>
        </div>
      </div>
    </div>
  );
}

// ── the "at a glance" panel: permanent, quiet, never keyed to the active
// beat (see file header). Content is verbatim reuse only (see WT_GLANCE_*
// above); this component adds no copy of its own beyond the surrounding
// product chrome (a live dot, a pill-shaped count, hairline rows). ────────
function WtGlancePanel() {
  return (
    <div className="wt-glance" aria-hidden="true">
      <span className="wt-glance-dot" />
      <ul className="wt-glance-list">
        {WT_GLANCE_QUEUE.map((q) => (
          <li className="wt-glance-row" key={q.label}>
            <span className="wt-glance-row-label">{q.label}</span>
            <span className="wt-glance-row-count">{q.count}</span>
          </li>
        ))}
      </ul>
      <p className="wt-glance-foot">{WT_GLANCE_FOOT}</p>
    </div>
  );
}

// ── the walkthrough itself ──────────────────────────────────────────────
function Walkthrough() {
  const { useState, useEffect, useRef, useCallback } = React;
  const reducedMotion = useWtReducedMotion();
  const [smallViewport, setSmallViewport] = useState(() => !!(window.matchMedia && window.matchMedia(WT_SMALL_QUERY).matches));
  const staticMode = reducedMotion || smallViewport;

  const [beatIndex, setBeatIndex] = useState(2); // Approve: the resting/bake composite
  const [askText, setAskText] = useState(WT_ASK_TEXT);
  const [chipCount, setChipCount] = useState(WT_WORK_CHIPS.length);
  const [tickCount, setTickCount] = useState(WT_WORK_ROLES.length);
  const [chipsShimmer, setChipsShimmer] = useState(false);
  const [approveStage, setApproveStage] = useState("inbox-approved"); // 'chat' | 'inbox-pending' | 'inbox-approved'
  const [cursor, setCursor] = useState({ show: false, target: null, clicking: false });
  const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
  const [ripple, setRipple] = useState(0); // increments per scripted click; keys a fresh ring

  const genRef = useRef(0);
  const pausedRef = useRef(false);
  const inViewRef = useRef(true);
  const hoverRef = useRef(false);
  const beatIndexRef = useRef(2); // mirrors beatIndex for the rAF progress loop
  const beatElapsedRef = useRef(0); // unpaused ms elapsed within the active beat
  const progressWrapRef = useRef(null); // receives --wt-progress each frame
  const bandRef = useRef(null);
  const shellRef = useRef(null);
  const chatIconRef = useRef(null);
  const inboxIconRef = useRef(null);
  const approvePillRef = useRef(null);

  // ── viewport + reduced-motion-adjacent guards ──
  useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(WT_SMALL_QUERY);
    const onChange = (e) => setSmallViewport(e.matches);
    mq.addEventListener ? mq.addEventListener("change", onChange) : mq.addListener(onChange);
    return () => { mq.removeEventListener ? mq.removeEventListener("change", onChange) : mq.removeListener(onChange); };
  }, []);

  function recomputePaused() {
    pausedRef.current = !inViewRef.current || hoverRef.current || document.hidden;
  }
  useEffect(() => {
    const el = bandRef.current;
    if (!el || !window.IntersectionObserver) return;
    const io = new IntersectionObserver((entries) => {
      inViewRef.current = entries[0].isIntersecting;
      recomputePaused();
    }, { threshold: 0.15 });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  useEffect(() => {
    function onVis() { recomputePaused(); }
    document.addEventListener("visibilitychange", onVis);
    return () => document.removeEventListener("visibilitychange", onVis);
  }, []);
  const onShellEnter = useCallback(() => { hoverRef.current = true; recomputePaused(); }, []);
  const onShellLeave = useCallback(() => { hoverRef.current = false; recomputePaused(); }, []);
  const onShellFocus = useCallback(() => { hoverRef.current = true; recomputePaused(); }, []);
  const onShellBlur = useCallback((e) => {
    if (!e.currentTarget.contains(e.relatedTarget)) { hoverRef.current = false; recomputePaused(); }
  }, []);

  // ── interruptible sleep: resolves after ms of *unpaused* time, genuinely
  // freezing (not just delaying) while pausedRef is true, and resolving
  // early (without acting) once this call's generation goes stale. ──
  function wait(ms, gen) {
    return new Promise((resolve) => {
      let remaining = ms;
      let last = Date.now();
      function tick() {
        if (genRef.current !== gen) { resolve(); return; }
        if (pausedRef.current) { last = Date.now(); setTimeout(tick, 130); return; }
        const now = Date.now();
        remaining -= (now - last);
        last = now;
        if (remaining <= 0) { resolve(); return; }
        setTimeout(tick, Math.min(remaining, 130));
      }
      tick();
    });
  }
  function alive(gen) { return genRef.current === gen; }

  function moveCursorTo(ref, key) {
    setCursor((c) => ({ ...c, target: key }));
    const shell = shellRef.current, target = ref.current;
    if (!shell || !target) return;
    const sRect = shell.getBoundingClientRect();
    const tRect = target.getBoundingClientRect();
    setCursorPos({ x: tRect.left - sRect.left + tRect.width / 2, y: tRect.top - sRect.top + tRect.height / 2 });
  }

  // ── per-beat step scripts ──
  async function stepAsk(gen) {
    setAskText(""); setChipCount(0); setTickCount(0); setChipsShimmer(false);
    await wait(260, gen); if (!alive(gen)) return;
    for (let i = 1; i <= WT_ASK_TEXT.length; i++) {
      if (!alive(gen)) return;
      setAskText(WT_ASK_TEXT.slice(0, i));
      await wait(18, gen); if (!alive(gen)) return;
    }
    const used = 260 + WT_ASK_TEXT.length * 18;
    await wait(Math.max(0, BEAT_DURATIONS[0] - used), gen);
  }
  async function stepWork(gen) {
    // Same reasoning as stepApprove below: the composer must already read
    // as sent by the time Work opens, even on a rail jump that interrupts
    // Ask mid-type.
    setAskText(WT_ASK_TEXT);
    setChipCount(0); setTickCount(0); setChipsShimmer(true);
    await wait(220, gen); if (!alive(gen)) return;
    for (let i = 1; i <= WT_WORK_CHIPS.length; i++) {
      if (!alive(gen)) return;
      setChipCount(i);
      await wait(420, gen); if (!alive(gen)) return;
    }
    setChipsShimmer(false);
    await wait(260, gen); if (!alive(gen)) return;
    for (let i = 1; i <= WT_WORK_ROLES.length; i++) {
      if (!alive(gen)) return;
      setTickCount(i);
      await wait(380, gen); if (!alive(gen)) return;
    }
    const used = 220 + WT_WORK_CHIPS.length * 420 + 260 + WT_WORK_ROLES.length * 380;
    await wait(Math.max(0, BEAT_DURATIONS[1] - used), gen);
  }
  async function stepApprove(gen) {
    // Ask and Work are always finished by the time Approve opens, regardless
    // of whether the loop arrived here in sequence (this is then a no-op)
    // or via a rail jump straight from mid-typed Ask (the composer and the
    // fan-out could otherwise still read a half-typed sentence or a
    // reset-to-zero tick count for the brief opening beat the chat surface
    // shares with Ask/Work).
    setAskText(WT_ASK_TEXT);
    setChipCount(WT_WORK_CHIPS.length); setTickCount(WT_WORK_ROLES.length); setChipsShimmer(false);
    setApproveStage("chat"); setCursor({ show: false, target: null, clicking: false });
    await wait(300, gen); if (!alive(gen)) return;
    moveCursorTo(chatIconRef, "chat"); setCursor((c) => ({ ...c, show: true }));
    await wait(300, gen); if (!alive(gen)) return;
    moveCursorTo(inboxIconRef, "inbox");
    await wait(700, gen); if (!alive(gen)) return;
    setCursor((c) => ({ ...c, clicking: true })); setRipple((r) => r + 1);
    await wait(120, gen); if (!alive(gen)) return;
    setCursor((c) => ({ ...c, clicking: false }));
    setApproveStage("inbox-pending");
    await wait(280, gen); if (!alive(gen)) return;
    await wait(750, gen); if (!alive(gen)) return;
    moveCursorTo(approvePillRef, "approve-pill");
    await wait(700, gen); if (!alive(gen)) return;
    setCursor((c) => ({ ...c, clicking: true })); setRipple((r) => r + 1);
    await wait(120, gen); if (!alive(gen)) return;
    setCursor((c) => ({ ...c, clicking: false }));
    setApproveStage("inbox-approved");
    await wait(280, gen); if (!alive(gen)) return;
    setCursor({ show: false, target: null, clicking: false });
    await wait(300, gen); if (!alive(gen)) return;
    const used = 300 + 300 + 700 + 120 + 280 + 750 + 700 + 120 + 280 + 300;
    await wait(Math.max(0, BEAT_DURATIONS[2] - used), gen);
  }
  async function stepTrack(gen) { await wait(BEAT_DURATIONS[3], gen); }
  async function stepSee(gen) { await wait(BEAT_DURATIONS[4], gen); }

  const STEP_FNS = [stepAsk, stepWork, stepApprove, stepTrack, stepSee];

  async function playFrom(startBeat, gen) {
    let b = startBeat;
    while (alive(gen)) {
      beatIndexRef.current = b;
      beatElapsedRef.current = 0;
      setBeatIndex(b);
      await STEP_FNS[b](gen);
      if (!alive(gen)) return;
      b = (b + 1) % RAIL_STEPS.length;
    }
  }

  useEffect(() => {
    if (staticMode) return; // no timers under reduced motion or small viewport
    genRef.current += 1;
    const gen = genRef.current;
    playFrom(0, gen);
    return () => { genRef.current += 1; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [staticMode]);

  // The stepper's progress loop: independent of genRef on purpose (see file
  // header), so a rail click restarts the beat sequence without killing this.
  useEffect(() => {
    if (staticMode) return;
    let stopped = false;
    let rafId = 0;
    let last = performance.now();
    function frame(now) {
      if (stopped) return;
      const dt = Math.min(now - last, 200); // hidden-tab resume guard
      last = now;
      if (!pausedRef.current) beatElapsedRef.current += dt;
      const dur = BEAT_DURATIONS[beatIndexRef.current] || 1;
      const p = Math.min(1, beatElapsedRef.current / dur);
      if (progressWrapRef.current) progressWrapRef.current.style.setProperty("--wt-progress", p.toFixed(4));
      rafId = requestAnimationFrame(frame);
    }
    rafId = requestAnimationFrame(frame);
    return () => { stopped = true; cancelAnimationFrame(rafId); };
  }, [staticMode]);

  function onRailClick(idx) {
    genRef.current += 1;
    const gen = genRef.current;
    beatIndexRef.current = idx;
    beatElapsedRef.current = 0;
    setBeatIndex(idx);
    if (!staticMode) playFrom(idx, gen);
  }

  // The viewer clicks Approve themselves: show the approved state, let it
  // land (~1.6s of unpaused time; hover keeps it paused, which is right,
  // since their mouse is still inside the shell), then resume from Track.
  function onUserApprove() {
    if (staticMode) return;
    genRef.current += 1;
    const gen = genRef.current;
    beatIndexRef.current = 2;
    beatElapsedRef.current = Math.max(beatElapsedRef.current, BEAT_DURATIONS[2] - 1600);
    setBeatIndex(2);
    setCursor({ show: false, target: null, clicking: false });
    setApproveStage("inbox-approved");
    (async () => {
      await wait(1600, gen);
      if (!alive(gen)) return;
      playFrom(3, gen);
    })();
  }

  // ── derived display values: staticMode always shows each beat's own
  // finished composite regardless of whatever an in-flight sequence last
  // left in state, so a rail jump under reduced motion / small viewport is
  // always a clean snapshot. ──
  const dAskText = staticMode ? WT_ASK_TEXT : askText;
  const dChipCount = staticMode ? WT_WORK_CHIPS.length : chipCount;
  const dTickCount = staticMode ? WT_WORK_ROLES.length : tickCount;
  const dChipsShimmer = staticMode ? false : chipsShimmer;
  const dApproveStage = staticMode ? "inbox-approved" : approveStage;
  const dCursor = staticMode ? { show: false, target: null, clicking: false } : cursor;

  const surface = beatIndex === 0 || beatIndex === 1 ? "chat"
    : beatIndex === 2 ? (dApproveStage === "chat" ? "chat" : "inbox")
    : beatIndex === 3 ? "projects" : "nebula";
  const activeIconId = surface;

  return (
    <section className="wt-band" ref={bandRef} aria-label="A five-beat guided walkthrough of the Zarco Console: ask, work, approve, track, see">
      <div className="wt-band-ground" aria-hidden="true">
        <img src="assets/photo/dusk.jpg" alt="" loading="lazy" />
        <span className="wt-band-scrim" />
      </div>
      <div className="shell wt-shell-outer">
        <div className="wt-frame">
          <span className="wt-shell-glow" aria-hidden="true" />
          <span className="wt-chip-agents">
            <span className="wt-chip-dot" aria-hidden="true" />
            {WT_AGENTS_ONLINE}
          </span>
          <div
            className="wt-shell"
            ref={shellRef}
            aria-hidden="true"
            onMouseEnter={onShellEnter}
            onMouseLeave={onShellLeave}
            onFocus={onShellFocus}
            onBlur={onShellBlur}
          >
            <div className="wt-topbar">
              <span className="wt-topbar-mark"><ZMark onDark /></span>
              <span className="wt-topbar-label">Zarco Console</span>
            </div>
            <div className="wt-body">
              <nav className="wt-rail">
                {WT_RAIL_ICONS.map((icon) => (
                  <span
                    key={icon.id}
                    ref={icon.id === "chat" ? chatIconRef : icon.id === "inbox" ? inboxIconRef : null}
                    className={"wt-rail-icon" + (activeIconId === icon.id ? " is-active" : "") + (icon.id in WT_ICON_TO_BEAT ? " is-clickable" : "")}
                    onClick={icon.id in WT_ICON_TO_BEAT ? () => onRailClick(WT_ICON_TO_BEAT[icon.id]) : undefined}
                  >
                    <icon.Glyph />
                  </span>
                ))}
              </nav>
              <div className="wt-content">
                <div className="wt-content-inner" key={surface}>
                  {surface === "chat" && <WtChatSurface beatIndex={beatIndex} askText={dAskText} chipCount={dChipCount} tickCount={dTickCount} chipsShimmer={dChipsShimmer} />}
                  {surface === "inbox" && <WtInboxSurface approved={dApproveStage === "inbox-approved"} approvePillRef={approvePillRef} onApprove={onUserApprove} />}
                  {surface === "projects" && <WtProjectsSurface animate={!staticMode} />}
                  {surface === "nebula" && <WtNebulaSurface />}
                </div>
                <WtGlancePanel />
              </div>
              {!staticMode && (
                <span
                  className={"wt-cursor" + (dCursor.clicking ? " is-clicking" : "")}
                  style={{
                    opacity: dCursor.show ? 1 : 0,
                    transform: "translate(" + cursorPos.x + "px," + cursorPos.y + "px) scale(" + (dCursor.clicking ? 0.7 : 1) + ")",
                  }}
                />
              )}
              {!staticMode && ripple > 0 && (
                <span
                  key={ripple}
                  className="wt-cursor-ripple"
                  style={{ transform: "translate(" + cursorPos.x + "px," + cursorPos.y + "px)" }}
                />
              )}
            </div>
          </div>
        </div>
      </div>
      <div className="shell wt-progress-outer" ref={progressWrapRef}>
        <p className="wt-caption" key={"cap-" + beatIndex}>
          <span className="wt-caption-n">{String(beatIndex + 1).padStart(2, "0")} / 05</span>
          <span className="wt-caption-text">{WT_BEAT_CAPTIONS[beatIndex]}</span>
        </p>
        <ol className={"wt-progress" + (staticMode ? " wt-progress--static" : "")} role="list">
          {RAIL_STEPS.map((s, i) => (
            <li key={s.id}>
              <button
                type="button"
                className={"wt-progress-btn" + (i === beatIndex ? " is-active" : "") + (i < beatIndex ? " is-done" : "")}
                aria-current={i === beatIndex ? "step" : undefined}
                onClick={() => onRailClick(i)}
              >
                <span className="wt-progress-track" aria-hidden="true"><span className="wt-progress-fill" /></span>
                <span className="wt-progress-text">
                  <span className="wt-progress-num" aria-hidden="true">{String(i + 1).padStart(2, "0")}</span>
                  <span className="wt-progress-label">{s.label}</span>
                </span>
              </button>
            </li>
          ))}
        </ol>
        <p className="wt-hint">{staticMode ? "Tap a step to move through it." : "Plays on its own. Click any step to jump in, or click around the Console itself."}</p>
      </div>
    </section>
  );
}

const walkthroughCss = `
/* ── band: extends the hero's own dusk photo downward (own crop + scrim)
   so the mountain photography stays the framing asset, not app chrome. The
   hero's own bottom mask is removed in Console.jsx (opaque to its own
   frame edge) so the two photo layers meet in matching dark tone rather
   than a paper gap. This band's own photo fades out (mask) over its lower
   fifth, revealing the flat --panel fill below rather than the photo
   itself: the progress rail sits on that solid ground for even, unbroken
   contrast, then the section ends and paper begins for chapter 01, the
   same clean dark-panel-to-paper cut every other dark interlude on this
   site already makes (ch-nebula included) rather than a second fade. */
.wt-band { position: relative; isolation: isolate; overflow-x: clip; background: var(--panel); }
.wt-band-ground {
  position: absolute; inset: 0; z-index: 0;
  mask-image: linear-gradient(to bottom, #000 0%, #000 82%, transparent 100%);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 82%, transparent 100%);
}
.wt-band-ground img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  object-position: 50% 80%;
  filter: saturate(0.55) contrast(1.04) brightness(0.62);
}
.wt-band-scrim {
  position: absolute; inset: 0;
  background-image:
    linear-gradient(180deg, oklch(0.155 0.012 340 / .88) 0%, oklch(0.155 0.012 340 / .32) 22%, oklch(0.155 0.012 340 / .40) 60%, oklch(0.155 0.012 340 / .92) 100%),
    linear-gradient(180deg, rgba(6,7,10,.5) 0%, rgba(6,7,10,.3) 22%, rgba(6,7,10,.4) 60%, rgba(6,7,10,.78) 100%);
}
.wt-shell-outer { position: relative; z-index: 2; padding-top: clamp(56px, 8vw, 96px); }
.wt-frame { position: relative; }

/* ── aurora glow behind the shell: the homepage .hp-console-glow conic
   recipe, scaled to this stage and breathing very slowly. Ambient ground,
   not a signature moment: the beat loop stays this band's one living act. ── */
.wt-shell-glow {
  position: absolute; inset: -28px -44px -52px; z-index: 0; pointer-events: none; border-radius: 60px;
  background: conic-gradient(from 210deg, oklch(0.72 0.19 350 / .55), oklch(0.75 0.12 290 / .4), oklch(0.8 0.1 230 / .45), oklch(0.72 0.19 350 / .55));
  filter: blur(46px); opacity: .55;
  animation: wt-glow-breathe 9s ease-in-out infinite alternate;
}
@keyframes wt-glow-breathe { from { opacity: .4; } to { opacity: .65; } }

/* ── the one floating secondary chip: breaks the shell's single-rectangle
   silhouette. Reuses the site's one real "material" recipe verbatim (an
   opaque dark wash under the glass-dark tint, App.jsx's .hp-console), the
   only place in this file that touches backdrop-filter, and only because
   it overlays the photo directly. ── */
.wt-chip-agents {
  position: absolute; top: -16px; right: clamp(20px, 4vw, 40px); z-index: 3;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 8px 14px; border-radius: 999px; border: 1px solid var(--panel-line);
  color: var(--panel-ink-60); font-size: 12.5px;
  background: linear-gradient(rgba(20,20,23,.82), rgba(20,20,23,.82)), var(--glass-dark);
  -webkit-backdrop-filter: blur(18px) saturate(1.15);
  backdrop-filter: blur(18px) saturate(1.15);
  box-shadow: inset 0 1px 0 var(--glass-dark-edge), var(--elev-raise);
}
@media (prefers-reduced-transparency: reduce) {
  .wt-chip-agents { background: var(--panel-2); -webkit-backdrop-filter: none; backdrop-filter: none; }
}
.wt-chip-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--panel-ok); flex: none; animation: wt-dot-breathe 2.4s ease-in-out infinite alternate; }
@keyframes wt-dot-breathe { from { opacity: .5; } to { opacity: 1; } }

/* ── the shell: opaque surfaces only, no new backdrop-filter. Elevated as
   a floating card (--elev-float) rather than owning the section's whole
   ground, so the photo reads on every side of it. ── */
.wt-shell {
  position: relative; isolation: isolate;
  min-height: clamp(460px, 70vh, 680px);
  display: flex; flex-direction: column;
  background: var(--panel); border: 1px solid var(--panel-line); border-radius: 22px; overflow: hidden;
  box-shadow: inset 0 1px 0 var(--glass-dark-edge), var(--elev-float);
}
.wt-topbar { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--panel-line); flex: none; }
.wt-topbar-mark { width: 20px; height: 20px; border-radius: 6px; background: var(--paper); display: inline-flex; align-items: center; justify-content: center; padding: 3px; flex: none; }
.wt-topbar-mark svg { width: 100%; height: 100%; }
.wt-topbar-label { font-size: 13.5px; font-weight: 500; color: var(--panel-ink); }

.wt-body { position: relative; flex: 1; min-height: 0; display: flex; }
.wt-rail {
  flex: none; width: 64px; padding-top: 22px;
  display: flex; flex-direction: column; align-items: center; gap: 18px;
  background: var(--panel-2); border-right: 1px solid var(--panel-line);
}
.wt-rail-icon {
  width: 36px; height: 36px; border-radius: 10px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--panel-ink-40);
  transition: color var(--dur-ui) var(--ease), background var(--dur-ui) var(--ease);
}
.wt-rail-icon svg { width: 19px; height: 19px; }
.wt-rail-icon.is-active { color: var(--panel-ink); background: var(--panel-3); }
.wt-rail-icon.is-clickable { cursor: pointer; }
.wt-rail-icon.is-clickable:hover { color: var(--panel-ink); background: var(--panel-3); }

.wt-content {
  flex: 1; min-width: 0; display: flex; align-items: center;
  gap: clamp(20px, 3vw, 40px); justify-content: space-between;
  padding: clamp(22px, 3.6vw, 40px); overflow: hidden;
}
/* A definite flex-basis, not width:100% and not shrink-to-fit auto: the
   beat column should hug its own (now ~680px) scene rather than stretch to
   claim the whole stage, so the glance panel has real room to sit at the
   stage's other edge instead of trailing off past the fold. flex-basis:auto
   (shrink-to-fit) was tried first and is wrong here: this column's own
   children are themselves width:100% (the composer/inbox/project cards,
   AND the nebula's 3-column CSS grid), so an auto-width parent asks each
   child "what's your own natural size" while the child asks the parent the
   same question back, an unresolvable percentage/shrink-to-fit loop that
   Chromium answers inconsistently for the nebula's grid specifically
   (collapsed to ~200px total instead of 680px on a real re-render, caught
   by screenshot, not just devtools). A concrete 680px basis, matching the
   scene max-width these children already share, removes the ambiguity: the
   percentage children resolve against a real number every time. Shrink
   stays enabled (flex-shrink defaults to 1 in the shorthand below) so a
   narrower stage (1100px) still compresses the scene before anything
   overflows. */
.wt-content-inner { flex: 0 1 680px; min-width: 0; animation: wt-surface-in var(--dur-base) var(--ease) both; }
@keyframes wt-surface-in { from { opacity: 0; transform: translateY(12px) scale(.985); } to { opacity: 1; transform: none; } }

/* ── the "at a glance" panel: see file header for what this is and why.
   A hairline divider (not a filled card) is the "motivated hairline": it
   marks the seam between the narrative beat and the ambient column rather
   than floating unmatched. Surface ladder: the row-count pill steps up to
   --panel-2 exactly like ch-queue-row-count's own recipe, the one other
   place this same data renders. Permanently quiet at .75 opacity (not
   per-beat: this column never reacts to the active beat at all). ── */
.wt-glance {
  flex: 0 1 auto; min-width: 0; width: clamp(184px, 20vw, 244px);
  align-self: stretch; display: flex; flex-direction: column; justify-content: center;
  padding-left: clamp(22px, 2.6vw, 30px); border-left: 1px solid var(--panel-line);
  opacity: .75;
}
.wt-glance-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--panel-ok); flex: none; margin-bottom: 16px; animation: wt-dot-breathe 2.4s ease-in-out infinite alternate; }
.wt-glance-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.wt-glance-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 11px 0; border-top: 1px solid var(--panel-line); }
.wt-glance-row:first-child { border-top: none; padding-top: 0; }
.wt-glance-row-label { font-size: 12.5px; line-height: 1.35; color: var(--panel-ink); }
/* AA: panel-ink-60, the muted token .ch-queue-row-count's own recipe uses,
   is already dimmed once; stacked under this panel's own .75 container
   opacity it measures 3.95:1 pixel-sampled against the pill's real
   composited background, below the 4.5:1 floor (the footer line at
   panel-ink-60 measured 4.52:1, technically over the line but with no
   margin worth trusting). panel-ink here isn't "louder than intended", it
   is what compensates for the opacity this panel already applies
   everywhere: with it, the pill measures 7.68:1 and the footer line
   9.32:1 (same headless-Chrome pixel-sampling method used elsewhere in
   this file), both comfortably clear. */
.wt-glance-row-count { flex: none; font-family: var(--font-mono); font-size: 11px; color: var(--panel-ink); background: var(--panel-2); border: 1px solid var(--panel-line); border-radius: 999px; padding: 2px 8px; }
.wt-glance-foot { margin: 14px 0 0; padding-top: 13px; border-top: 1px solid var(--panel-line); font-size: 11.5px; line-height: 1.4; color: var(--panel-ink); }

/* ── the cursor: a small ink/paper dot with a soft shadow, one signature
   spring overshoot per travel per DESIGN.md's motion grammar. ── */
.wt-cursor {
  position: absolute; top: 0; left: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px;
  border-radius: 50%; background: var(--paper); box-shadow: 0 3px 10px rgba(0,0,0,.4), 0 0 0 1px rgba(255,255,255,.5);
  pointer-events: none; z-index: 5;
  transition: transform var(--dur-signature) var(--ease-spring), opacity var(--dur-ui) var(--ease);
}
/* the click dip overrides only the transition's duration/easing (to the
   quick, non-spring token), never the transform itself: transform is set
   inline per-frame (translate plus scale together) since an inline style
   always wins over a stylesheet rule on the same property, so a class-based
   transform here would be dead code. */
.wt-cursor.is-clicking { transition: transform var(--dur-micro) var(--ease), opacity var(--dur-ui) var(--ease); }
/* the click ripple: its own keyed span (not ::after on the cursor, whose
   .is-clicking class lasts only 120ms and would cut the animation short).
   Positioned like the cursor, translated inline, ring expands via the
   standalone scale property so it never fights the inline translate. */
.wt-cursor-ripple {
  position: absolute; top: 0; left: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px;
  border-radius: 50%; border: 1.5px solid rgba(255,255,255,.7);
  pointer-events: none; z-index: 5;
  animation: wt-ripple .6s var(--ease) both;
}
@keyframes wt-ripple { from { opacity: .9; scale: .45; } to { opacity: 0; scale: 2; } }

/* ── shared scene chrome, the same surface-ladder recipe as Tour.jsx's
   .ch-scene family (panel / panel-2 / panel-3), kept local since each
   jsx file here is self-contained. Sized one tier up from that same
   in-page idiom throughout this block (composer/chips/rows all a notch
   larger, scene column 480px->680px): this is the hero stage the whole
   page opens with, not an inline card, so it earns its own larger type
   rather than repeating the Tour scenes' size verbatim. ── */
.wt-scene { width: 100%; max-width: 680px; }
.wt-scene-head { display: flex; align-items: center; justify-content: space-between; padding-bottom: 16px; margin-bottom: 18px; border-bottom: 1px solid var(--panel-line); }
.wt-scene-id { display: flex; align-items: center; gap: 10px; color: var(--panel-ink); font-size: 16px; font-weight: 500; }
.wt-scene-mark { width: 22px; height: 22px; border-radius: 6px; background: var(--paper); display: inline-flex; align-items: center; justify-content: center; padding: 3px; }
.wt-scene-mark svg { width: 100%; height: 100%; }
.wt-scene-live { display: flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--panel-ink-60); }
.wt-scene-count { font-size: 13.5px; color: var(--panel-ink-60); }
.wt-scene-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--panel-ok); flex: none; animation: wt-dot-breathe 2.4s ease-in-out infinite alternate; }
.wt-scene-body { display: flex; flex-direction: column; gap: 18px; }

/* ── beat 1/2: composer + work fan-out ── */
.wt-composer { background: var(--panel-2); border: 1px solid var(--panel-line); border-radius: 16px; padding: 16px 19px; min-height: 54px; }
.wt-composer-text { margin: 0; font-size: 17px; line-height: 1.5; color: var(--panel-ink); min-height: 1.5em; }
.wt-caret { display: inline-block; width: 1px; height: 18px; background: var(--panel-ink-60); vertical-align: -4px; margin-left: 1px; }
.wt-work { display: flex; flex-direction: column; gap: 16px; }
.wt-work-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.wt-chip { position: relative; overflow: hidden; display: inline-flex; align-items: center; background: var(--panel-3); border: 1px solid var(--panel-line); color: var(--panel-ink-60); font-size: 13.5px; border-radius: 999px; padding: 7px 15px; animation: wt-chip-pop .45s var(--ease-spring) both; }
@keyframes wt-chip-pop { from { opacity: 0; transform: translateY(6px) scale(.92); } to { opacity: 1; transform: none; } }
.wt-chip.is-shimmer::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,.22) 50%, transparent 60%);
  transform: translateX(-120%);
  animation: wt-chip-shimmer 1.6s linear infinite;
}
@keyframes wt-chip-shimmer { to { transform: translateX(120%); } }
.wt-work-agents { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 11px; }
/* AA: panel-ink-60 is the dark-ground floor for real text (see the
   .chapter-mark/.eyebrow/.ch-proj-tile-label fixes this same audit already
   made elsewhere on the site); the pending/done states differ by weight
   and the tick glyph, not by dropping under that floor. */
.wt-work-agent { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 15px; color: var(--panel-ink-60); transition: color var(--dur-ui) var(--ease); }
.wt-work-agent.is-done { color: var(--panel-ink); }
.wt-work-agent-role { font-weight: 500; }
.wt-work-agent-tick { width: 15px; height: 15px; flex: none; }
.wt-work-agent-tick .wt-tick { stroke: var(--panel-ok); width: 100%; height: 100%; }
/* ticks draw on: the glyph only mounts once its row completes, so a mount
   animation is exactly the completion moment. */
.wt-tick path { stroke-dasharray: 22; animation: wt-tick-draw .45s var(--ease) both; }
@keyframes wt-tick-draw { from { stroke-dashoffset: 22; } to { stroke-dashoffset: 0; } }

/* ── beat 3: inbox approval row ── */
.wt-scene--flush { max-width: 680px; }
.wt-inbox-body { gap: 18px; }
.wt-inbox-label { margin: 0; font-size: 18px; line-height: 1.4; color: var(--panel-ink); font-weight: 500; }
.wt-inbox-actions { display: flex; align-items: center; gap: 9px; min-height: 38px; }
.wt-pill { display: inline-flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 500; border-radius: 999px; padding: 9px 17px; border: 1px solid transparent; white-space: nowrap; }
.wt-pill-approve { background: var(--magenta); color: #fff; cursor: pointer; transition: transform var(--dur-ui) var(--ease), box-shadow var(--dur-ui) var(--ease); }
.wt-pill-approve:hover { transform: translateY(-1px); box-shadow: var(--glow-magenta); }
.wt-pill-quiet { background: var(--panel-2); border-color: var(--panel-line); color: var(--panel-ink-60); }
.wt-approved { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; color: var(--panel-ink-60); animation: wt-surface-in var(--dur-base) var(--ease) both; }
.wt-approved .wt-tick { stroke: var(--panel-ok); width: 17px; height: 17px; }

/* ── beat 4: projects anatomy, simplified to fit ── */
.wt-scene.wt-proj-card { max-width: 680px; }
.wt-proj-body { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.wt-proj-tile { background: var(--panel-2); border: 1px solid var(--panel-line); border-radius: 16px; padding: 16px 17px; display: flex; flex-direction: column; gap: 8px; min-width: 0; animation: wt-surface-in var(--dur-base) var(--ease) both; animation-delay: calc(var(--i, 0) * 90ms); }
.wt-proj-tile--hero { grid-column: span 2; }
.wt-proj-tile-label { margin: 0; font-size: 12.5px; color: var(--panel-ink-60); }
.wt-proj-outcome-headline { margin: 0; font-size: 18px; line-height: 1.35; color: var(--panel-ink); font-weight: 500; }
.wt-proj-health { display: flex; align-items: center; gap: 7px; font-size: 13.5px; color: var(--panel-ink-60); flex-wrap: wrap; }
.wt-proj-health-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--panel-ink-40); }
.wt-proj-health-dot--ok { background: var(--panel-ok); animation: wt-dot-breathe 2.4s ease-in-out infinite alternate; }
.wt-proj-health-sep { color: var(--panel-ink-60); }
.wt-proj-journey { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.wt-proj-stage { display: flex; flex-direction: column; gap: 3px; }
.wt-proj-stage-count { font-family: var(--font-mono); font-size: 16px; color: var(--panel-ink); }
.wt-proj-stage-label { font-size: 12.5px; color: var(--panel-ink-60); }
.wt-proj-signal-text { margin: 0; font-size: 15px; color: var(--panel-ink); line-height: 1.35; }
.wt-proj-signal-dest { font-size: 12.5px; color: var(--panel-ink-60); }

/* ── beat 5: the connected diagram (story round, second pass). Three
   tiles on the panel-2 recipe every other beat already uses, joined by
   two drawn-on edges: the scene IS the claim that the customer, its
   project and its knowledge join up, stated as product UI rather than a
   star map. Edges rest fully drawn (dashoffset 0) and only the keyframes'
   from-state hides them, so reduced motion's animation:none leaves them
   visible, the same trap the tick draw already dodges. Scatter stars are
   backdrop only; the opaque tiles paint over them. ── */
.wt-graph-scene { max-width: 680px; }
.wt-graph-wrap { position: relative; min-height: 210px; display: flex; align-items: center; padding: clamp(14px, 2vw, 24px) 0; }
.wt-nebula-dot { position: absolute; left: var(--x); top: var(--y); width: var(--s); height: var(--s); border-radius: 50%; background: #fff; opacity: var(--o); animation: wt-twinkle 3.4s ease-in-out infinite; animation-delay: calc(var(--d, 0) * -1s); }
@keyframes wt-twinkle { 0%, 100% { opacity: var(--o); } 50% { opacity: calc(var(--o) * .3); } }
.wt-graph-diagram { position: relative; z-index: 1; width: 100%; display: grid; grid-template-columns: 1.1fr clamp(44px, 6vw, 80px) 1.3fr; align-items: center; }
/* The edges carry NO entrance animation of their own, on purpose. Live
   bisect found two Chromium paint traps on this stretched svg
   (preserveAspectRatio: none): (1) vector-effect: non-scaling-stroke stops
   the line painting at all, so stroke width is plain viewBox units (~1px
   on screen at this column's real size); (2) ANY animation directly on the
   svg or its immediate wrapper span, CSS clip-path wipe and WAAPI opacity
   fade both tested, leaves the svg permanently unpainted even after the
   animation completes. An animated ANCESTOR is fine: .wt-content-inner's
   own keyed entrance fades the whole diagram in, which is reveal enough,
   and the tiles carry the staggered motion. Do not re-add a draw/wipe/fade
   here without re-probing an actual screenshot. */
.wt-graph-links { display: block; align-self: stretch; }
.wt-graph-links svg { display: block; width: 100%; height: 100%; overflow: visible; }
.wt-graph-edge { stroke: rgba(255,255,255,.35); stroke-width: .7; }
.wt-graph-col { display: flex; flex-direction: column; gap: 14px; }
.wt-graph-tile { background: var(--panel-2); border: 1px solid var(--panel-line); border-radius: 14px; padding: 13px 16px; animation: wt-surface-in var(--dur-base) var(--ease) both; animation-delay: calc(var(--i, 0) * 130ms); }
.wt-graph-tile--hero { border-color: color-mix(in oklch, var(--magenta) 40%, var(--panel-line)); }
.wt-graph-kicker { margin: 0 0 4px; font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--panel-ink-60); }
.wt-graph-label { margin: 0; font-size: 15px; font-weight: 500; color: var(--panel-ink); display: flex; align-items: center; gap: 8px; }
.wt-graph-heronode { width: 9px; height: 9px; border-radius: 50%; background: var(--magenta); box-shadow: var(--glow-magenta); animation: wt-dot-breathe 2.4s ease-in-out infinite alternate; flex: none; }
.wt-graph-detail { margin: 5px 0 0; font-family: var(--font-mono); font-size: 11.5px; color: var(--panel-ink-60); }
.wt-graph-vseg { display: none; }

/* ── the progress rail: a story-style stepper below the shell, on the same
   photographic ground (its scrim darkens toward solid here for legibility).
   Each step = numbered label over its own track; the active track fills
   across the beat via --wt-progress (rAF-driven, no CSS transition on the
   fill so it never lags the per-frame value). Magenta marks the LIVE edge
   only: the active fill and the Approve pill; done steps stay paper-white
   so the accent never spreads. ── */
.wt-progress-outer { position: relative; z-index: 2; padding-top: clamp(26px, 3.6vw, 40px); padding-bottom: clamp(56px, 8vw, 96px); }
.wt-caption { margin: 0 0 18px; display: flex; align-items: baseline; gap: 14px; max-width: 820px; animation: wt-surface-in var(--dur-base) var(--ease) both; }
.wt-caption-n { flex: none; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .06em; color: var(--panel-ink-60); }
.wt-caption-text { font-size: clamp(17px, 1.5vw, 20px); font-weight: 500; line-height: 1.4; color: var(--panel-ink); }
.wt-progress { display: flex; gap: clamp(10px, 1.4vw, 18px); list-style: none; margin: 0; padding: 0; max-width: 820px; }
.wt-progress li { flex: 1; display: flex; min-width: 0; }
.wt-progress-btn { width: 100%; display: flex; flex-direction: column; gap: 10px; background: none; border: none; padding: 6px 0 4px; cursor: pointer; font: inherit; text-align: left; }
.wt-progress-track { display: block; width: 100%; height: 3px; border-radius: 999px; background: rgba(255,255,255,.16); overflow: hidden; transition: background var(--dur-ui) var(--ease); }
.wt-progress-fill { display: block; height: 100%; border-radius: 999px; background: var(--panel-ink); transform-origin: left center; transform: scaleX(0); }
.wt-progress-btn.is-done .wt-progress-fill { transform: scaleX(1); }
.wt-progress-btn.is-active .wt-progress-fill { transform: scaleX(var(--wt-progress, 0)); background: var(--magenta); }
.wt-progress--static .wt-progress-btn.is-active .wt-progress-fill { transform: scaleX(1); }
.wt-progress-text { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.wt-progress-num { flex: none; font-family: var(--font-mono); font-size: 10.5px; color: var(--panel-ink-40); transition: color var(--dur-ui) var(--ease); }
.wt-progress-label { font-size: 13.5px; color: var(--panel-ink-60); white-space: nowrap; transition: color var(--dur-ui) var(--ease); }
.wt-progress-btn:hover .wt-progress-label { color: var(--panel-ink); }
.wt-progress-btn:hover .wt-progress-track { background: rgba(255,255,255,.30); }
.wt-progress-btn.is-active .wt-progress-label { color: var(--panel-ink); }
.wt-progress-btn.is-active .wt-progress-num { color: var(--panel-ink-60); }
.wt-hint { margin: 14px 0 0; font-size: 12px; color: var(--panel-ink-60); }

@media (max-width: 760px) {
  .wt-shell { min-height: 0; }
  .wt-rail { width: 52px; gap: 14px; padding-top: 16px; }
  .wt-rail-icon { width: 32px; height: 32px; }
  .wt-content { padding: 20px; flex-direction: column; align-items: stretch; gap: clamp(20px, 5vw, 28px); }
  .wt-scene, .wt-scene--flush, .wt-scene.wt-proj-card { max-width: none; }
  /* the diagram stacks; the horizontal edge svg gives way to small
     vertical connector segments between the tiles. */
  .wt-graph-diagram { display: flex; flex-direction: column; }
  .wt-graph-links { display: none; }
  .wt-graph-vseg { display: block; width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 auto; }
  .wt-graph-col { gap: 0; }
  .wt-progress { max-width: none; gap: 8px; }
  .wt-progress-label { font-size: 12.5px; }
  /* stacked, not side-by-side, below the fold: the hairline moves from the
     left edge to the top edge so it still divides the two columns rather
     than floating unmatched once they're no longer side by side. */
  .wt-glance { width: auto; align-self: auto; border-left: none; padding-left: 0; border-top: 1px solid var(--panel-line); padding-top: 20px; }
}
@media (max-width: 460px) {
  .wt-rail { display: none; }
  .wt-proj-body { grid-template-columns: 1fr; }
  .wt-proj-tile--hero { grid-column: auto; }
  .wt-progress-num { display: none; }
}
`;

window.Walkthrough = Walkthrough;
window.walkthroughCss = walkthroughCss;
