const { useState, useRef, useEffect, useMemo } = React;

// ================= settings (persisted locally — per-machine, not part of
// the shared store.json state: theme/token preferences are about how THIS
// installation of the app behaves, not data about any prototype/test) =================
const SETTINGS_KEY = "trace_settings_v1";
function loadSettings() {
  try {
    const raw = localStorage.getItem(SETTINGS_KEY);
    return raw ? JSON.parse(raw) : {};
  } catch (e) {
    console.warn("Couldn't read saved settings:", e);
    return {};
  }
}
function saveSettings(patch) {
  try {
    localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...loadSettings(), ...patch }));
  } catch (e) {
    console.warn("Couldn't persist settings:", e);
  }
}

// Admin-only API calls (managing prototypes/tests, AI analysis, the tunnel —
// see requireAdmin in server.js) need this token; a remote tester's browser
// never has it and never needs to. Desktop mode fetches it once via Electron
// IPC (see preload.cjs) and caches it here; web/CLI mode has no IPC, so the
// admin pastes the value server.js prints to the console on startup into
// Settings → Admin token instead — same one-time-setup pattern as the Figma
// token above.
const ADMIN_TOKEN_KEY = "trace_admin_token";
function getAdminToken() {
  try { return localStorage.getItem(ADMIN_TOKEN_KEY) || ""; } catch { return ""; }
}
function setAdminToken(t) {
  try { localStorage.setItem(ADMIN_TOKEN_KEY, t); } catch (e) { console.warn("Couldn't persist admin token:", e); }
}
function adminHeaders(extra) {
  return { ...extra, "X-Admin-Token": getAdminToken() };
}

// Shared between SettingsPanel (where these are configured) and Dashboard's
// "Analyze trends" (where they're read back out) — one list so adding a
// provider means editing this plus the two provider-specific branches in
// /api/analyze, not hunting down every place providers are enumerated.
// "local" is the odd one out: it talks to a private/self-hosted
// OpenAI-compatible endpoint (Ollama, LM Studio, a company-internal gateway,
// etc.) instead of a public API, so it needs an endpoint+model instead of a
// bare API key, and the key itself is optional.
const AI_PROVIDERS = [
  { key: "anthropic", label: "Anthropic (Claude)", keyField: "anthropicApiKey" },
  { key: "openai", label: "OpenAI (GPT)", keyField: "openaiApiKey" },
  { key: "gemini", label: "Google (Gemini)", keyField: "geminiApiKey" },
  { key: "local", label: "Local / private model", keyField: "localApiKey" },
];

// ================= palette =================
// C is a single mutable object (never reassigned — its PROPERTIES get
// overwritten in place by applyTheme) rather than a plain constant, so that
// switching themes doesn't require threading a theme value through every
// component via props/context: every render already reads C.xxx fresh, so
// mutating it and then forcing one re-render at the root (see App's
// `theme` state) is enough for the whole tree to pick up the new colors.
// The 4 shared style helpers below (primaryBtn/ghostBtn/dangerBtn/panelBox)
// are functions rather than plain objects for the same reason — a plain
// object literal evaluated once at module load would freeze whatever C
// happened to hold at that moment.
// `shadow` is "none" for dark (flat, border-delineated cards — a shadow
// reads as muddy/pointless against a near-black background) and a soft,
// warm-tinted elevation for light (an off-white surface needs depth cues
// other than its border to read as a raised card). See panelBox() below.
// `onAccent` is the text color used ON TOP of an accent-colored background
// (primaryBtn's fill, or a button's "success"/"saved" flash to C.green) — a
// separate field rather than a hardcoded "#06121A" because DARK's cyan/green
// are bright/light (dark text reads best) while LIGHT's are more saturated
// mid-tones (white text reads best) — the two themes need opposite answers.
const DARK = {
  bg: "#0E1116", panel: "#161B22", panel2: "#1C232D", line: "#232B36",
  text: "#E6EDF3", dim: "#8B949E", faint: "#4A5568",
  cyan: "#4CC9F0", green: "#4ADE80", red: "#F87171", onAccent: "#06121A",
  shadow: "none",
};
const LIGHT = {
  bg: "#F0EEE6", panel: "#FAF9F4", panel2: "#E9E6DC", line: "#DBD7C9",
  text: "#242018", dim: "#5C584A", faint: "#8B8674",
  cyan: "#5854FF", green: "#1E9E5A", red: "#DC3545", onAccent: "#FFFFFF",
  shadow: "0 1px 3px rgba(40,35,20,0.08), 0 6px 16px rgba(40,35,20,0.06)",
};
// Light is the default look; dark is the opt-in (a saved {theme:"dark"}
// is the only thing that turns it on) — see the matching default flip in
// index.html's pre-mount boot script.
function currentTheme() { return loadSettings().theme === "dark" ? "dark" : "light"; }
const C = { ...(currentTheme() === "dark" ? DARK : LIGHT) };
function applyTheme(theme) {
  Object.assign(C, theme === "dark" ? DARK : LIGHT);
  // index.html's html/body background (and the pre-mount ".boot" loading
  // screen text color) are driven by these same CSS custom properties so
  // the very first paint matches the saved theme — keep them in sync here
  // too, otherwise body's background would stay stuck at whatever theme was
  // active on page load even after switching live.
  document.documentElement.style.setProperty("--boot-bg", theme === "dark" ? "#0E1116" : "#F0EEE6");
  document.documentElement.style.setProperty("--boot-dim", theme === "dark" ? "#8B949E" : "#5C584A");
  saveSettings({ theme });
}
// A translucent tint of a theme color (e.g. C.cyan) for "selected/active"
// row backgrounds — computed from whatever C currently holds rather than a
// hardcoded rgba(), so these tints stay correct in both themes instead of
// silently staying tuned to dark mode's cyan forever.
function hexToRgba(hex, alpha) {
  const n = parseInt(hex.replace("#", ""), 16);
  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
}
// A pixel size that scales continuously with window width (pure CSS
// clamp()+vw — no resize listener needed) instead of sitting at one fixed
// px value regardless of resolution. `basePx` is the ORIGINAL (pre-bump)
// design size; `bump` inflates it (default 1.15, i.e. +15%) to get the
// value it should render at on a REF-wide (1440px) window — vw is scaled so
// that at exactly that width, the fluid value equals basePx*bump exactly,
// then clamped so it never shrinks below the original size or balloons past
// a reasonable ceiling on an ultra-wide monitor.
function fluidPx(basePx, { bump = 1.15, min = basePx, max = basePx * bump * 1.4, ref = 1440 } = {}) {
  const target = basePx * bump;
  const vw = (target / ref) * 100;
  return `clamp(${min}px, ${vw.toFixed(3)}vw, ${max}px)`;
}
// The player is a "screen" — like a monitor (landscape) or a phone
// (portrait), whichever the imported prototype actually is — sized to
// fill all the space its layout cell has, up to the full browser window.
// Device class comes from the frame's width (see screenRatio below): a
// desktop/tablet frame gets a 16:9 screen, a phone frame gets a 9:16 one.
// Whichever it is, the screen itself is a fixed shape — content that
// doesn't match it exactly still scrolls inside (see frameAspect/
// frameContentWidth below).
// width = min(100% of its cell, (available viewport height) * ratio);
// with aspect-ratio also set, that makes it the largest box of that shape
// that fits both the available width AND height — same trick CSS
// `object-fit: contain` uses, just without a media element. `chromeVh` is
// how much vertical space the view's own header/banners above the screen
// eat, so it doesn't get pushed off the bottom of the window.
// flexBasis is pinned to "auto" (rather than left to default via a `flex`
// shorthand) so this works whether the box is a direct flex-row item or a
// plain block child: a flex item only falls back to its `width` for
// sizing when flex-basis is exactly auto — an explicit numeric/percentage
// basis silently overrides width and breaks this.
// Classify by the frame's WIDTH, not its full width/height ratio: a
// desktop web-page frame is routinely much taller than it is wide (a long
// landing page authored at its full scroll height), so "width >= height"
// would misread it as a phone. Width alone tracks the design breakpoint
// reliably — phone frames run ~320-430px, desktop/tablet ~768px+.
const DESKTOP_MIN_W = 768;
function screenRatio(frame) {
  const w = (frame && frame.width) || 375;
  return w >= DESKTOP_MIN_W ? [16, 9] : [9, 16];
}
function screenBoxStyle(frame, chromeVh) {
  const [rw, rh] = screenRatio(frame);
  return {
    width: `min(100%, calc((100vh - ${chromeVh}px) * ${rw} / ${rh}))`,
    aspectRatio: `${rw} / ${rh}`,
    flexBasis: "auto", flexGrow: 0, flexShrink: 1, minWidth: 0,
  };
}
// The frame's own device shape (phone/desktop/whatever) — used only for
// the content rendered *inside* the 16:9 screen, at up to its real rendered
// resolution (never upscaled beyond that — see frameContentWidth), centered,
// scrolling in whichever direction it overflows the screen. Same as a
// non-responsive page in a browser tab. A frame with prototype scrolling
// (frame.scroll) uses its full virtual page size here, not its own clipped
// box — that's what makes the extra content beyond the viewport actually
// reachable via scroll (see FixedLayer below for the pinned header/nav that
// rides on top of it instead).
function frameAspect(frame) {
  if (frame && frame.scroll) return `${frame.scroll.contentWidth} / ${frame.scroll.contentHeight}`;
  return `${(frame && frame.width) || 375} / ${(frame && frame.height) || 812}`;
}
// Caps display width at the frame's REAL rendered resolution, not its
// logical Figma size — imports are rendered at 2x by default (see
// figma-import.js), so capping at the logical width alone left a lot of the
// available box unused (most visible on phone frames, whose logical width
// is only ~375-430px) even though the underlying image had real pixels to
// fill more of it. renderScale is absent on manifests imported before this
// field existed; 1 reproduces the old (logical-size-only) behavior for those
// until they're re-imported.
function frameContentWidth(frame) {
  const scale = (frame && frame.renderScale) || 1;
  if (frame && frame.scroll) return `min(100%, ${frame.scroll.contentWidth * scale}px)`;
  return `min(100%, ${((frame && frame.width) || 375) * scale}px)`;
}

// Tracks an element's rendered pixel size (post-CSS-layout) — used wherever
// we need real px for a <canvas> or an on-screen readout, while the element
// itself is sized responsively via CSS (%, aspect-ratio).
function useElementSize(ref) {
  const [size, setSize] = useState({ width: 0, height: 0 });
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const update = () => setSize({ width: el.clientWidth, height: el.clientHeight });
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, [ref]);
  return size;
}

// ================= heat gradient =================
const HEAT_STOPS = [[0, [30, 60, 170]], [0.35, [40, 200, 220]], [0.55, [70, 220, 120]], [0.75, [245, 215, 70]], [1, [235, 60, 55]]];
function heatColor(t) {
  t = Math.max(0, Math.min(1, t));
  for (let i = 0; i < HEAT_STOPS.length - 1; i++) {
    const [a, ca] = HEAT_STOPS[i], [b, cb] = HEAT_STOPS[i + 1];
    if (t >= a && t <= b) { const f = (t - a) / (b - a || 1); return [0, 1, 2].map((k) => Math.round(ca[k] + (cb[k] - ca[k]) * f)); }
  }
  return HEAT_STOPS[HEAT_STOPS.length - 1][1];
}

// ================= demo manifest (schematic SVG frames; real import replaces these) =================
const svg = (s) => "data:image/svg+xml," + encodeURIComponent(s);
const homeSVG = svg(`<svg xmlns='http://www.w3.org/2000/svg' width='340' height='604'>
<rect width='340' height='604' fill='#11151B'/>
<rect x='16' y='22' width='16' height='2' fill='#8B949E'/><rect x='16' y='28' width='16' height='2' fill='#8B949E'/><rect x='16' y='34' width='16' height='2' fill='#8B949E'/>
<text x='170' y='34' fill='#E6EDF3' font-family='sans-serif' font-size='15' font-weight='600' text-anchor='middle'>Fjord Bank</text>
<circle cx='318' cy='30' r='11' fill='none' stroke='#4CC9F0' stroke-width='2'/><circle cx='318' cy='30' r='3.5' fill='#4CC9F0'/>
<rect x='16' y='52' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='77' fill='#8B949E' font-family='sans-serif' font-size='13'>Search transactions...</text>
<rect x='16' y='104' width='308' height='110' rx='12' fill='#4CC9F0'/><text x='30' y='130' fill='#06121A' font-family='sans-serif' font-size='11'>Total balance</text><text x='30' y='162' fill='#06121A' font-family='sans-serif' font-size='26' font-weight='700'>$12,480.55</text>
<rect x='30' y='176' width='70' height='28' rx='14' fill='#06121A'/><text x='65' y='195' fill='#fff' font-family='sans-serif' font-size='12' text-anchor='middle'>Send</text>
<rect x='108' y='176' width='84' height='28' rx='14' fill='#06121A'/><text x='150' y='195' fill='#bbb' font-family='sans-serif' font-size='12' text-anchor='middle'>Request</text>
<text x='16' y='240' fill='#8B949E' font-family='sans-serif' font-size='12'>Quick actions</text>
<rect x='16' y='250' width='96' height='52' rx='12' fill='#1C232D' stroke='#232B36'/><text x='64' y='280' fill='#E6EDF3' font-family='sans-serif' font-size='12' text-anchor='middle'>Pay bills</text>
<rect x='122' y='250' width='96' height='52' rx='12' fill='#1C232D' stroke='#232B36'/><text x='170' y='280' fill='#E6EDF3' font-family='sans-serif' font-size='12' text-anchor='middle'>Cards</text>
<rect x='228' y='250' width='96' height='52' rx='12' fill='#1C232D' stroke='#232B36'/><text x='276' y='280' fill='#E6EDF3' font-family='sans-serif' font-size='12' text-anchor='middle'>Invest</text>
<text x='16' y='330' fill='#8B949E' font-family='sans-serif' font-size='12'>Recent</text>
<rect x='16' y='340' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='365' fill='#E6EDF3' font-family='sans-serif' font-size='13'>Spotify</text><text x='308' y='365' fill='#8B949E' font-family='sans-serif' font-size='13' text-anchor='end'>-$9.99</text>
<rect x='16' y='388' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='413' fill='#E6EDF3' font-family='sans-serif' font-size='13'>Payroll</text><text x='308' y='413' fill='#4ADE80' font-family='sans-serif' font-size='13' text-anchor='end'>+$3,200</text>
<rect x='0' y='548' width='340' height='56' fill='#161B22'/><line x1='0' y1='548' x2='340' y2='548' stroke='#232B36'/>
<text x='42' y='582' fill='#4CC9F0' font-family='sans-serif' font-size='11' text-anchor='middle'>Home</text>
<text x='127' y='582' fill='#8B949E' font-family='sans-serif' font-size='11' text-anchor='middle'>Stats</text>
<text x='212' y='582' fill='#8B949E' font-family='sans-serif' font-size='11' text-anchor='middle'>Send</text>
<text x='297' y='582' fill='#8B949E' font-family='sans-serif' font-size='11' text-anchor='middle'>You</text>
</svg>`);
const profileSVG = svg(`<svg xmlns='http://www.w3.org/2000/svg' width='340' height='604'>
<rect width='340' height='604' fill='#11151B'/>
<text x='24' y='40' fill='#8B949E' font-family='sans-serif' font-size='20'>&#8592;</text>
<text x='48' y='38' fill='#E6EDF3' font-family='sans-serif' font-size='15' font-weight='600'>Your profile</text>
<circle cx='170' cy='120' r='38' fill='#4CC9F0'/><circle cx='170' cy='120' r='10' fill='#06121A'/>
<text x='170' y='188' fill='#E6EDF3' font-family='sans-serif' font-size='18' font-weight='600' text-anchor='middle'>Alex Rivera</text>
<text x='170' y='208' fill='#8B949E' font-family='sans-serif' font-size='12' text-anchor='middle'>alex@fjord.bank</text>
<rect x='16' y='236' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='261' fill='#E6EDF3' font-family='sans-serif' font-size='13'>Personal details</text>
<rect x='16' y='284' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='309' fill='#E6EDF3' font-family='sans-serif' font-size='13'>Security</text>
<rect x='16' y='332' width='308' height='40' rx='12' fill='#1C232D' stroke='#232B36'/><text x='30' y='357' fill='#E6EDF3' font-family='sans-serif' font-size='13'>Notifications</text>
<text x='170' y='500' fill='#4ADE80' font-family='monospace' font-size='12' text-anchor='middle'>&#10003; profile page reached</text>
<rect x='0' y='548' width='340' height='56' fill='#161B22'/><line x1='0' y1='548' x2='340' y2='548' stroke='#232B36'/>
<text x='297' y='582' fill='#4CC9F0' font-family='sans-serif' font-size='11' text-anchor='middle'>You</text>
</svg>`);
const statsSVG = svg(`<svg xmlns='http://www.w3.org/2000/svg' width='340' height='604'>
<rect width='340' height='604' fill='#11151B'/>
<text x='24' y='40' fill='#8B949E' font-family='sans-serif' font-size='20'>&#8592;</text>
<text x='48' y='38' fill='#E6EDF3' font-family='sans-serif' font-size='15' font-weight='600'>Spending stats</text>
<rect x='16' y='60' width='308' height='180' rx='12' fill='#1C232D' stroke='#232B36'/>
<rect x='40' y='190' width='30' height='36' fill='#4CC9F0'/><rect x='90' y='150' width='30' height='76' fill='#4CC9F0'/><rect x='140' y='170' width='30' height='56' fill='#4CC9F0'/><rect x='190' y='120' width='30' height='106' fill='#4CC9F0'/><rect x='240' y='160' width='30' height='66' fill='#4CC9F0'/>
</svg>`);
const sendOverlaySVG = svg(`<svg xmlns='http://www.w3.org/2000/svg' width='280' height='220'>
<rect width='280' height='220' rx='16' fill='#161B22'/><rect x='0.5' y='0.5' width='279' height='219' rx='15.5' fill='none' stroke='#232B36'/>
<text x='24' y='36' fill='#E6EDF3' font-family='sans-serif' font-size='16' font-weight='600'>Send money</text>
<text x='248' y='34' fill='#8B949E' font-family='sans-serif' font-size='16' text-anchor='middle'>&#10005;</text>
<rect x='24' y='60' width='232' height='40' rx='10' fill='#1C232D' stroke='#232B36'/><text x='36' y='85' fill='#8B949E' font-family='sans-serif' font-size='13'>Recipient</text>
<rect x='24' y='112' width='232' height='40' rx='10' fill='#1C232D' stroke='#232B36'/><text x='36' y='137' fill='#8B949E' font-family='sans-serif' font-size='13'>Amount</text>
<rect x='24' y='168' width='232' height='36' rx='10' fill='#4CC9F0'/><text x='140' y='191' fill='#06121A' font-family='sans-serif' font-size='13' font-weight='600' text-anchor='middle'>Confirm</text>
</svg>`);

const MOCK_MANIFEST = {
  fileKey: "demo", name: "Fjord Bank (demo)", importedAt: new Date().toISOString(), entryFrameId: "home",
  frames: [
    { id: "home", name: "Home", width: 340, height: 604, image: homeSVG, hotspots: [
      { id: "h1", name: "profile-icon", x: 0.9, y: 0.03, w: 0.09, h: 0.05, trigger: "ON_CLICK", destinationFrameId: "profile", navigation: "NAVIGATE" },
      { id: "h2", name: "nav:You", x: 0.75, y: 0.91, w: 0.24, h: 0.09, trigger: "ON_CLICK", destinationFrameId: "profile", navigation: "NAVIGATE" },
      { id: "h3", name: "nav:Stats", x: 0.25, y: 0.91, w: 0.24, h: 0.09, trigger: "ON_CLICK", destinationFrameId: "stats", navigation: "NAVIGATE" },
      { id: "h6", name: "send-button", x: 0.088, y: 0.291, w: 0.206, h: 0.046, trigger: "ON_CLICK", destinationFrameId: "sendOverlay", navigation: "OVERLAY" },
    ]},
    { id: "profile", name: "Profile", width: 340, height: 604, image: profileSVG, hotspots: [
      { id: "h4", name: "back", x: 0.04, y: 0.03, w: 0.12, h: 0.05, trigger: "ON_CLICK", destinationFrameId: "home", navigation: "NAVIGATE" },
    ]},
    { id: "stats", name: "Stats", width: 340, height: 604, image: statsSVG, hotspots: [
      { id: "h5", name: "back", x: 0.04, y: 0.03, w: 0.12, h: 0.05, trigger: "ON_CLICK", destinationFrameId: "home", navigation: "NAVIGATE" },
    ]},
    { id: "sendOverlay", name: "Send money (overlay)", width: 280, height: 220, image: sendOverlaySVG,
      overlayPositionType: "CENTER", overlayBackground: { r: 0, g: 0, b: 0, a: 0.55 }, overlayBackgroundInteraction: "CLOSE_ON_CLICK_OUTSIDE",
      hotspots: [
        { id: "h7", name: "close", x: 0.857, y: 0.09, w: 0.086, h: 0.109, trigger: "ON_CLICK", destinationFrameId: null, navigation: "CLOSE" },
        { id: "h8", name: "confirm-send", x: 0.086, y: 0.764, w: 0.829, h: 0.164, trigger: "ON_CLICK", destinationFrameId: "home", navigation: "NAVIGATE" },
      ] },
  ],
};

// ================= hosted-prototype player =================
function hotspotDiv(h) {
  return (
    <div key={h.id} data-label={h.name} data-goto={h.destinationFrameId || ""} data-nav={h.navigation}
      style={{ position: "absolute", left: `${h.x * 100}%`, top: `${h.y * 100}%`, width: `${h.w * 100}%`, height: `${h.h * 100}%`, cursor: "pointer" }} />
  );
}
function layerImg(l) {
  return (
    <img key={l.id} src={l.image} alt={l.name} draggable={false}
      style={{ position: "absolute", left: `${l.x * 100}%`, top: `${l.y * 100}%`, width: `${l.w * 100}%`, height: `${l.h * 100}%`, display: "block", pointerEvents: "none", userSelect: "none" }} />
  );
}

// A Figma STICKY_SCROLLS layer (e.g. a section header, or one section of a
// "stacked full-screen sections" page): scrolls normally until it would
// scroll past the top of the viewport, then sticks there — until the NEXT
// sticky sibling reaches the same point and covers it. CSS position:sticky
// does this natively, but only if each sticky element's *containing block*
// is scoped to just that element's own slot: a sticky element releases once
// scrolled past its containing block's edge, so if several sticky siblings
// shared one huge containing block (the whole scroll content), the first
// one would stay stuck almost the entire way down instead of handing off to
// the next. Each layer gets its own wrapper div, sized to exactly that
// layer's own height and positioned via margin-top (in normal flow, so it
// doesn't need to know about its absolutely-positioned scrolling siblings)
// — THAT wrapper is what position:sticky sees as its bounds; the actual
// sticky element is nested one level inside it, filling it completely.
// Pixel margin-top (not %) because vertical percentage margins resolve
// against the containing block's WIDTH, not height — `size` (the actual
// rendered content-box pixel size) is required; renders nothing until
// that's known rather than flashing at the wrong spot for one frame.
//
// Telemetry note: click coordinates for a hotspot in here are normalized
// against the frame's full content box (same as regular scroll content),
// which is only exactly right while the layer hasn't stuck yet — once
// pinned, its clicks land at a fixed screen position independent of scroll,
// so recorded heatmap points for it may drift slightly. Navigation itself
// is unaffected either way.
function StickyLayer({ layer: l, hotspots, size }) {
  if (!size || !size.height) return null;
  const topPx = l.y * size.height;
  const heightPx = l.h * size.height;
  // The wrapper is the sticky element's containing block, so it must be
  // absolutely positioned (like every other layer in this same content box)
  // rather than stacked in normal document flow — flow position would
  // compound each wrapper's offset on top of the previous one's. It also
  // needs to be TALLER than the visible slot: a sticky element only stays
  // pinned for as much extra room as its containing block gives it beyond
  // its own height, so a wrapper sized exactly to the slot releases
  // instantly. Doubling it gives exactly one slot's worth of "stuck" room —
  // released right as the next section's wrapper (which starts where this
  // slot ends) scrolls up to cover it.
  const wrapperHeightPx = heightPx * 2;
  return (
    <div style={{ position: "absolute", left: 0, top: `${topPx}px`, width: "100%", height: `${wrapperHeightPx}px` }}>
      <div style={{ position: "sticky", top: 0, zIndex: 4, width: "100%", height: `${heightPx}px` }}>
        <img src={l.image} alt={l.name} draggable={false}
          style={{ position: "absolute", inset: 0, width: "100%", height: "100%", display: "block", pointerEvents: "none", userSelect: "none" }} />
        {hotspots.map((h) => {
          // Hotspot rect is in content-box fractions; re-express relative to
          // this specific sticky layer's own box so it can be positioned as
          // a simple percentage inside this wrapper.
          const rx = (h.x - l.x) / l.w, ry = (h.y - l.y) / l.h, rw = h.w / l.w, rh = h.h / l.h;
          return (
            <div key={h.id} data-label={h.name} data-goto={h.destinationFrameId || ""} data-nav={h.navigation}
              style={{ position: "absolute", left: `${rx * 100}%`, top: `${ry * 100}%`, width: `${rw * 100}%`, height: `${rh * 100}%`, cursor: "pointer" }} />
          );
        })}
      </div>
    </div>
  );
}

// Fills whatever positioned box its caller gives it (the caller owns sizing
// via width/aspect-ratio so it can be responsive + scrollable). For a frame
// with prototype scrolling (frame.scroll), this renders the SCROLLING and
// STICKY layers/hotspots — the pinned FIXED ones are a separate sibling the
// caller renders outside the scroll viewport (see FixedLayer below), same
// as a fixed header staying put while the page scrolls under it. `size` is
// the content box's actual rendered pixel size, needed for sticky layers'
// pixel-based positioning (see StickyLayer).
function FramePlayer({ manifest, frameId, size }) {
  const frame = manifest.frames.find((f) => f.id === frameId) || manifest.frames[0];
  if (frame.scroll) {
    const scrollLayers = frame.scroll.layers.filter((l) => !l.fixed && !l.sticky);
    const stickyLayers = frame.scroll.layers.filter((l) => l.sticky);
    const scrollHotspots = frame.hotspots.filter((h) => !h.fixed && !h.sticky);
    return (
      <>
        {scrollLayers.map(layerImg)}
        {scrollHotspots.map(hotspotDiv)}
        {stickyLayers.map((l) => (
          <StickyLayer key={l.id} layer={l} hotspots={frame.hotspots.filter((h) => h.sticky && h.stickyLayerId === l.id)} size={size} />
        ))}
      </>
    );
  }
  return (
    <>
      <img src={frame.image} alt={frame.name} draggable={false}
        style={{ position: "absolute", inset: 0, display: "block", width: "100%", height: "100%", objectFit: "cover", pointerEvents: "none", userSelect: "none" }} />
      {frame.hotspots.map(hotspotDiv)}
    </>
  );
}

// The pinned counterpart to FramePlayer's scrolling content — renders a
// scrollable frame's FIXED-marked layers/hotspots (e.g. a header/nav bar)
// sized to the frame's own viewport box, not the full scrolled page, and
// positioned as a non-scrolling sibling of the scroll viewport. The wrapper
// itself doesn't capture clicks (pointerEvents: none) so empty space passes
// them through to whatever's actually scrolled underneath; only the
// individual hotspots opt back in.
function FixedLayer({ manifest, frameId }) {
  const frame = manifest.frames.find((f) => f.id === frameId);
  if (!frame || !frame.scroll) return null;
  const fixedLayers = frame.scroll.layers.filter((l) => l.fixed);
  const fixedHotspots = frame.hotspots.filter((h) => h.fixed);
  if (!fixedLayers.length && !fixedHotspots.length) return null;
  return (
    <div data-frame-box="true" style={{ position: "absolute", inset: 0, zIndex: 5, pointerEvents: "none", touchAction: "none" }}>
      {fixedLayers.map(layerImg)}
      {fixedHotspots.map((h) => (
        <div key={h.id} data-label={h.name} data-goto={h.destinationFrameId || ""} data-nav={h.navigation}
          style={{ position: "absolute", left: `${h.x * 100}%`, top: `${h.y * 100}%`, width: `${h.w * 100}%`, height: `${h.h * 100}%`, cursor: "pointer", pointerEvents: "auto" }} />
      ))}
    </div>
  );
}

function overlayPositionStyle(type) {
  const gap = 20;
  switch (type) {
    case "TOP_LEFT": return { top: gap, left: gap };
    case "TOP_CENTER": return { top: gap, left: "50%", transform: "translateX(-50%)" };
    case "TOP_RIGHT": return { top: gap, right: gap };
    case "BOTTOM_LEFT": return { bottom: gap, left: gap };
    case "BOTTOM_CENTER": return { bottom: gap, left: "50%", transform: "translateX(-50%)" };
    case "BOTTOM_RIGHT": return { bottom: gap, right: gap };
    // MANUAL isn't specially positioned yet (would need the triggering
    // hotspot's overlayRelativePosition threaded through) — falls back to
    // CENTER, same as Figma's own default when no position is set.
    case "CENTER":
    default: return { top: "50%", left: "50%", transform: "translate(-50%, -50%)" };
  }
}

// A Figma prototype "overlay" (popup/modal navigation) — rendered as its own
// layer on top of the base screen instead of replacing it, like the real
// thing. Positioned per the destination frame's own overlayPositionType,
// with a dismissible backdrop (click-outside-to-close unless the frame's
// overlayBackgroundInteraction is explicitly "NONE").
function Overlay({ manifest, frameId }) {
  const frame = manifest.frames.find((f) => f.id === frameId);
  if (!frame) return null;
  const bg = frame.overlayBackground;
  const backdropColor = bg
    ? `rgba(${Math.round(bg.r * 255)}, ${Math.round(bg.g * 255)}, ${Math.round(bg.b * 255)}, ${bg.a})`
    : "rgba(0,0,0,0.55)";
  return (
    <div style={{ position: "absolute", inset: 0, zIndex: 10 }}>
      <div data-overlay-backdrop="true" style={{ position: "absolute", inset: 0, background: backdropColor }} />
      <div data-frame-box="true"
        style={{
          position: "absolute", ...overlayPositionStyle(frame.overlayPositionType),
          width: frameContentWidth(frame), aspectRatio: frameAspect(frame),
          maxWidth: "calc(100% - 40px)", maxHeight: "calc(100% - 40px)",
          borderRadius: 14, overflow: "hidden", touchAction: "none",
          boxShadow: "0 24px 64px rgba(0,0,0,0.6)", border: `1px solid ${C.line}`,
        }}>
        <FramePlayer manifest={manifest} frameId={frameId} />
      </div>
    </div>
  );
}

// ================= heatmap =================
// Pure canvas-drawing step (no DOM/React dependency) — shared by the live
// on-screen <Heatmap> below and downloadFrameHeatmap's offscreen export, so
// a downloaded PNG always matches what's rendered on screen exactly.
function paintHeatmap(ctx, points, mode, w, h, perOverride) {
  if (!points.length) return;
  const base = Math.min(w, h);
  // Tuned so a SINGLE point already reads clearly (~65-70% opacity at its
  // center) instead of needing several overlapping clicks before it's
  // visible against the frame underneath — a one-click session is common
  // (e.g. viewing just one recorded session) and shouldn't look blank. Click
  // radius is a bit larger than movement's so an individual click's footprint
  // reads as a deliberate mark rather than a faint dot.
  // perOverride (see averagePer in Dashboard) replaces this per-point alpha
  // for the "aggregate average" export, where it's scaled by how many
  // sessions actually reached this frame so the result reads as "what
  // fraction of testers clicked here" rather than a raw, ever-growing count.
  const radius = mode === "move" ? base * 0.07 : base * 0.12, per = perOverride ?? (mode === "move" ? 0.16 : 0.28);
  points.forEach((p) => {
    const x = p.x * w, y = p.y * h;
    const g = ctx.createRadialGradient(x, y, 0, x, y, radius);
    g.addColorStop(0, `rgba(0,0,0,${per})`); g.addColorStop(1, "rgba(0,0,0,0)");
    ctx.fillStyle = g; ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
  });
  const img = ctx.getImageData(0, 0, w, h), d = img.data;
  for (let i = 0; i < d.length; i += 4) {
    const a = d[i + 3];
    if (!a) continue;
    // heatColor's fraction (blue=cold/rare -> red=hot/frequent) must be
    // computed from the BOOSTED alpha, not the raw pre-boost composited
    // alpha — otherwise a single click (raw alpha ~28%) lands in the
    // blue/cyan end of the gradient, which is nearly the same cyan the
    // app's own UI chrome uses everywhere, so it reads as "barely there"
    // even though it's technically painted. Using the boosted value here
    // means a single click already reads as a clearly warm (yellow/green)
    // color instead of blending into the interface around it.
    const boosted = Math.min(235, a * 2.6);
    const [r, g, b] = heatColor(boosted / 255);
    d[i] = r; d[i + 1] = g; d[i + 2] = b; d[i + 3] = boosted;
  }
  ctx.putImageData(img, 0, 0);
}
function Heatmap({ points, mode, size }) {
  const ref = useRef(null);
  const w = size.width, h = size.height;
  useEffect(() => {
    const canvas = ref.current; if (!canvas || !w || !h) return;
    const ctx = canvas.getContext("2d");
    ctx.clearRect(0, 0, w, h);
    paintHeatmap(ctx, points, mode, w, h);
  }, [points, mode, w, h]);
  return <canvas ref={ref} width={w} height={h} style={{ position: "absolute", inset: 0, pointerEvents: "none" }} />;
}

// A frame's layers flattened into drawable {image,x,y,w,h} pieces in
// content-box fractions — a plain frame is just its one image at (0,0,1,1);
// a scrollable frame's FIXED layers are normalized against the frame's own
// box (see figma-import.js) so they're converted to content-space fractions
// first, matching how the scrolling/sticky layers are already expressed.
function frameToPieces(frame) {
  if (!frame.scroll) return [{ image: frame.image, x: 0, y: 0, w: 1, h: 1 }];
  const cw = frame.scroll.contentWidth, ch = frame.scroll.contentHeight;
  return frame.scroll.layers.map((l) => {
    if (!l.fixed) return { image: l.image, x: l.x, y: l.y, w: l.w, h: l.h };
    return {
      image: l.image,
      x: (l.x * frame.width) / cw, y: (l.y * frame.height) / ch,
      w: (l.w * frame.width) / cw, h: (l.h * frame.height) / ch,
    };
  });
}

// Composites a frame's screenshot(s) + a heatmap of the given points onto
// one canvas at the given pixel size, and triggers a PNG download. Used both
// for the aggregate "Save heatmap PNG" (Dashboard, sized to whatever's
// currently on screen) and the per-session heatmap download (SessionLog,
// sized to the frame's own native resolution since there's no live preview
// to match there).
function downloadFrameHeatmap({ frame, points, mode, width, height, filename, perOverride }) {
  if (!width || !height) {
    console.warn("downloadFrameHeatmap: no on-screen size to render at yet — nothing downloaded. Try again after the frame preview has finished loading.");
    return;
  }
  const pieces = frameToPieces(frame);
  Promise.all(pieces.map((p) => new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => resolve({ ...p, img });
    img.onerror = () => resolve(null);
    img.src = p.image;
  }))).then((loaded) => {
    const out = document.createElement("canvas");
    out.width = width; out.height = height;
    const ctx = out.getContext("2d");
    for (const p of loaded) {
      if (!p) continue;
      ctx.drawImage(p.img, p.x * width, p.y * height, p.w * width, p.h * height);
    }
    // paintHeatmap reads back its own canvas's alpha to recolor by heat, so
    // it must run on a separate, still-transparent canvas — sharing the one
    // the frame screenshot was just drawn on would sweep every opaque frame
    // pixel into the remap and paint the whole image solid red.
    const heatCanvas = document.createElement("canvas");
    heatCanvas.width = width; heatCanvas.height = height;
    paintHeatmap(heatCanvas.getContext("2d"), points, mode, width, height, perOverride);
    ctx.drawImage(heatCanvas, 0, 0);
    out.toBlob((blob) => {
      if (!blob) {
        console.error("downloadFrameHeatmap: the canvas produced no image data — most likely the frame's own image came from a cross-origin URL without CORS headers, which silently taints the canvas.");
        alert("Couldn't generate the heatmap image (see the console for details) — nothing was downloaded.");
        return;
      }
      const url = URL.createObjectURL(blob); const a = document.createElement("a");
      a.href = url; a.download = filename; a.click();
      URL.revokeObjectURL(url);
    }, "image/png");
    // A tainted canvas throws a SecurityError from toBlob() itself (not just
    // returning a null blob) — without this, that throw would otherwise be
    // an unhandled promise rejection: logged to devtools, but invisible in
    // the UI, which is exactly "nothing seems to happen" from the outside.
  }).catch((e) => {
    console.error("downloadFrameHeatmap failed:", e);
    alert(`Couldn't generate the heatmap image: ${e.message || e}`);
  });
}

function primaryBtn() { return { background: C.cyan, color: C.onAccent, border: "none", borderRadius: 8, padding: "10px 16px", fontSize: 13, fontWeight: 600, cursor: "pointer" }; }
function ghostBtn() { return { background: "transparent", color: C.text, border: `1px solid ${C.line}`, borderRadius: 8, padding: "9px 16px", fontSize: 13, cursor: "pointer" }; }
function dangerBtn() { return { background: "transparent", color: C.red, border: `1px solid ${C.red}`, borderRadius: 8, padding: "9px 16px", fontSize: 13, cursor: "pointer" }; }
function panelBox() { return { background: C.panel, border: `1px solid ${C.line}`, borderRadius: 12, padding: 16, boxShadow: C.shadow }; }

// ================= capture (task-based) =================
// `minimal` is for a remote tester (see the deep-link mode in App): they get
// the task and the prototype only — no cursor/event/frame telemetry HUD,
// since that's for whoever's watching the test happen, not the person taking
// it. Success also self-saves instead of waiting for a click: the admin
// view keeps an explicit "Save successful session" button so they can look
// the result over first, but a remote tester has no reason to review their
// own telemetry before it's sent.
function CaptureSurface({ test, manifest, onSave, minimal }) {
  const boxRef = useRef(null), viewportRef = useRef(null), contentRef = useRef(null), events = useRef([]), start = useRef(Date.now()), lastMove = useRef(0), successTime = useRef(null);
  const [frame, setFrame] = useState(manifest.entryFrameId || manifest.frames[0].id);
  const [path, setPath] = useState([manifest.entryFrameId || manifest.frames[0].id]);
  const [overlayStack, setOverlayStack] = useState([]); // open overlay frame ids, topmost = last
  const [success, setSuccess] = useState(false);
  const [hud, setHud] = useState({ x: 0, y: 0, count: 0, last: "-", type: "-", t: 0 });
  // "presurvey" (optional, test-issuer-defined questions) → "running" (the
  // actual prototype) → "postcomment" (optional free-text box) → onSave.
  // Both the admin's own Run and a remote tester's minimal view go through
  // the same three steps — surveys/comments matter most for a remote
  // tester, but the admin should see exactly what that tester sees.
  const [phase, setPhase] = useState(test.preQuestions?.length ? "presurvey" : "running");
  const [preAnswers, setPreAnswers] = useState(() => (test.preQuestions || []).map(() => ""));
  const [comment, setComment] = useState("");
  // True from the moment save() is called until this component unmounts
  // (the parent flips away once its save actually reaches the server) —
  // disables the save button so a slow connection can't be double-submitted.
  const [submitting, setSubmitting] = useState(false);
  // True for a brief pause right after finishing (success or giving up),
  // before moving on to the comment box / save — without it, reaching the
  // goal cuts straight to the next screen the instant the frame changes,
  // which reads as abrupt since the tester never gets a moment to see they
  // actually arrived. The task banner/final frame just sit as-is during this.
  const [settling, setSettling] = useState(false);
  const pendingOk = useRef(null);
  // A/B variant assignment: picked once per session and held for its whole
  // duration. Only ever consulted through resolveDisplay below — `frame`,
  // `path`, `overlayStack`, and every goalFrame comparison keep operating on
  // the CANONICAL frame id exactly as a non-A/B test would, so hotspot
  // navigation, overlay handling and success detection need no changes at
  // all. Only what actually gets rendered or recorded is substituted.
  const assignedVariant = useRef(
    test.variantSlot ? test.variantSlot.options[Math.floor(Math.random() * test.variantSlot.options.length)] : null
  );
  const resolveDisplay = (frameId) =>
    test.variantSlot && frameId === test.variantSlot.canonicalFrameId ? assignedVariant.current.frameId : frameId;
  const activeFrame = manifest.frames.find((f) => f.id === resolveDisplay(frame)) || manifest.frames[0];
  const activeFrameId = overlayStack.length ? overlayStack[overlayStack.length - 1] : frame;
  // The canonical id resolved to whatever's actually on screen — used for
  // telemetry/HUD, never for flow logic (see activeFrameId above for that).
  const displayFrameId = resolveDisplay(activeFrameId);
  // Only needed for a scrollable frame's sticky layers, which need real
  // pixel offsets (see StickyLayer) — pointer-event math elsewhere still
  // reads box.clientWidth/Height directly at event time, not this.
  const contentSize = useElementSize(contentRef);

  useEffect(() => { const id = setInterval(() => setHud((h) => ({ ...h, t: ((Date.now() - start.current) / 1000).toFixed(1) })), 100); return () => clearInterval(id); }, []);
  // New base frame = new "page": start scrolled to the top, like real
  // navigation. Opening/closing an overlay doesn't touch this scroll
  // position — same as a modal not resetting the page underneath it.
  useEffect(() => { if (viewportRef.current) viewportRef.current.scrollTop = 0; }, [frame]);

  // Coordinates are normalized against whichever frame box the event
  // actually landed on. That's usually "whichever is topmost," EXCEPT a
  // scrollable frame's FixedLayer only covers its own header/nav area
  // (pointerEvents: none everywhere else) — so a click below it needs to
  // resolve to the scrolling content's box, not the fixed one, even though
  // the fixed layer is later in the DOM. e.target.closest() gets this right
  // for free: pointerEvents:none elements are never the real event target,
  // so it always finds whichever box actually received the event. Falls
  // back to "last in the container" only for clicks with no frame-box
  // ancestor at all (e.g. an overlay's backdrop, which is a sibling of its
  // frame-box, not a parent).
  const activeBox = (e) => {
    const viaTarget = e.target.closest && e.target.closest("[data-frame-box]");
    if (viaTarget) return viaTarget;
    if (!boxRef.current) return null;
    const boxes = boxRef.current.querySelectorAll("[data-frame-box]");
    return boxes[boxes.length - 1] || null;
  };
  const norm = (box, e) => { const r = box.getBoundingClientRect(); return { x: Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), y: Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)) }; };
  const maybeSucceed = (goto) => { if (goto === test.goalFrame && !success) { setSuccess(true); successTime.current = Date.now() - start.current; } };

  const onMove = (e) => {
    const box = activeBox(e); if (!box) return;
    const now = Date.now(); if (now - lastMove.current < 40) return; lastMove.current = now;
    const { x, y } = norm(box, e); events.current.push({ frame: displayFrameId, kind: "move", x, y, t: now - start.current, ptype: e.pointerType });
    setHud((h) => ({ ...h, x: Math.round(x * box.clientWidth), y: Math.round(y * box.clientHeight), count: events.current.length, last: "move", type: e.pointerType }));
  };
  const onDown = (e) => {
    const box = activeBox(e); if (!box) return;
    const { x, y } = norm(box, e);
    const backdrop = e.target.closest("[data-overlay-backdrop]");
    const el = e.target.closest("[data-label]");
    const label = backdrop ? "(overlay backdrop)" : el ? el.getAttribute("data-label") : "(background)";
    const isTap = e.pointerType === "touch";
    events.current.push({ frame: displayFrameId, kind: isTap ? "tap" : "click", x, y, t: Date.now() - start.current, label, ptype: e.pointerType });
    setHud((h) => ({ ...h, x: Math.round(x * box.clientWidth), y: Math.round(y * box.clientHeight), count: events.current.length, last: isTap ? "tap" : "click", type: e.pointerType }));

    if (backdrop) {
      const top = manifest.frames.find((f) => f.id === overlayStack[overlayStack.length - 1]);
      if (top && top.overlayBackgroundInteraction !== "NONE") setOverlayStack((s) => s.slice(0, -1));
      return;
    }
    if (!el) return;
    const goto = el.getAttribute("data-goto") || null;
    const navType = el.getAttribute("data-nav");

    if (navType === "CLOSE") {
      setOverlayStack((s) => s.slice(0, -1));
    } else if (navType === "BACK") {
      if (overlayStack.length) setOverlayStack((s) => s.slice(0, -1));
      else if (path.length > 1) { setFrame(path[path.length - 2]); setPath((p) => p.slice(0, -1)); }
    } else if (navType === "OVERLAY" && goto) {
      setOverlayStack((s) => [...s, goto]);
      maybeSucceed(goto);
    } else if (navType === "SWAP" && overlayStack.length && goto) {
      setOverlayStack((s) => [...s.slice(0, -1), goto]);
      maybeSucceed(goto);
    } else if (goto) {
      // Full navigate — replaces the base screen and dismisses any overlays,
      // same as a real prototype resetting overlay state on navigation.
      setFrame(goto); setPath((p) => [...p, goto]); setOverlayStack([]);
      maybeSucceed(goto);
    }
  };
  const save = (ok) => {
    setSubmitting(true);
    onSave({
      sessionId: "s_" + Math.random().toString(36).slice(2, 9), testId: test.id, taskPrompt: test.task, goalFrame: test.goalFrame,
      success: ok, durationMs: ok && successTime.current != null ? successTime.current : Date.now() - start.current,
      clickCount: events.current.filter((e) => e.kind !== "move").length, path: path.slice(), events: events.current.slice(), recordedAt: new Date().toISOString(),
      // Snapshotted from the test at record time, not read live from it later —
      // same reasoning as taskPrompt/goalFrame above: a later edit to the test
      // shouldn't retroactively change what an already-recorded session says
      // it asked.
      preQuestions: test.preQuestions || [], preAnswers: preAnswers.slice(), comment,
      // Snapshotted for the same reason as preQuestions above — undefined
      // for a regular (non-A/B) test, since assignedVariant.current is null.
      variantLabel: assignedVariant.current?.label,
      variantFrameId: assignedVariant.current?.frameId,
    });
  };
  // Reaching the goal or giving up doesn't move on right away — a brief
  // pause first (see `settling`) so the final frame actually registers, then
  // the post-test comment box if enabled (see the "postcomment" phase
  // below), or straight to save otherwise. pendingOk holds which outcome is
  // about to be saved once the tester gets there.
  const finalize = (ok) => {
    if (submitting || settling) return;
    setSettling(true);
    setTimeout(() => {
      if (test.commentEnabled) { pendingOk.current = ok; setPhase("postcomment"); }
      else save(ok);
    }, 2000);
  };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(() => { if (minimal && success) finalize(true); }, [success]);
  const goalName = manifest.frames.find((f) => f.id === test.goalFrame)?.name || test.goalFrame;
  const textarea = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, boxSizing: "border-box", resize: "vertical", fontFamily: "inherit" };
  const field = { ...textarea, resize: undefined };

  // Shown before the task itself is revealed, deliberately — asking things
  // like "how familiar are you with X" only makes sense unbiased by the
  // specific goal they're about to be given.
  if (phase === "presurvey") {
    return (
      <div style={{ maxWidth: 480, margin: "0 auto" }}>
        <h3 style={{ fontSize: 18, marginBottom: 4 }}>Before you start</h3>
        <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>A couple of quick questions first.</p>
        {test.preQuestions.map((raw, i) => {
          const q = typeof raw === "string" ? { text: raw, type: "text" } : raw;
          const value = preAnswers[i];
          const onChange = (v) => setPreAnswers((a) => a.map((old, j) => (j === i ? v : old)));
          return (
            <label key={i} style={{ fontSize: 12, color: C.dim, display: "block", marginBottom: 16 }}>
              {q.text}
              {q.type === "number" && (
                <input type="number" inputMode="numeric" min={q.min} max={q.max} value={value} style={{ ...field, marginTop: 6 }}
                  onChange={(e) => {
                    const raw = e.target.value;
                    if (raw === "") return onChange("");
                    // Clamped on every keystroke, not just on submit — an
                    // out-of-range value should never even be storable,
                    // let alone something wildly unrealistic like "99999".
                    const n = Math.round(Number(raw));
                    onChange(String(Number.isFinite(n) ? Math.max(q.min, Math.min(q.max, n)) : q.min));
                  }} />
              )}
              {q.type === "select" && (
                <select value={value} style={{ ...field, marginTop: 6 }} onChange={(e) => onChange(e.target.value)}>
                  <option value="">Select…</option>
                  {q.options.map((o) => <option key={o} value={o}>{o}</option>)}
                </select>
              )}
              {(!q.type || q.type === "text") && (
                <textarea rows={2} style={{ ...textarea, marginTop: 6 }} value={value} onChange={(e) => onChange(e.target.value)} />
              )}
            </label>
          );
        })}
        <button style={primaryBtn()} onClick={() => setPhase("running")}>Start test</button>
      </div>
    );
  }
  if (phase === "postcomment") {
    return (
      <div style={{ maxWidth: 480, margin: "0 auto" }}>
        <h3 style={{ fontSize: 18, marginBottom: 4 }}>One more thing</h3>
        <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>How did that feel? Anything that stood out? (optional)</p>
        <textarea rows={5} style={textarea} value={comment} onChange={(e) => setComment(e.target.value)} />
        <button style={{ ...primaryBtn(), marginTop: 16, opacity: submitting ? 0.6 : 1 }} disabled={submitting} onClick={() => save(pendingOk.current)}>{submitting ? "Saving…" : "Submit"}</button>
      </div>
    );
  }

  const taskBanner = (
    <div style={{ ...panelBox(), marginBottom: 16, borderLeft: `3px solid ${C.cyan}` }}>
      <div style={{ fontSize: 11, color: C.cyan, letterSpacing: 1, fontFamily: "ui-monospace,monospace" }}>YOUR TASK</div>
      <div style={{ fontSize: 15, marginTop: 4 }}>{test.task || `Find the ${goalName} page`}</div>
    </div>
  );
  const screenBox = (
    <div ref={boxRef} onPointerMove={onMove} onPointerDown={onDown}
      style={{ ...screenBoxStyle(activeFrame, 230), borderRadius: 14, border: `1px solid ${success ? C.green : C.line}`, background: "#000", overflow: "hidden", position: "relative", cursor: "crosshair" }}>
      <div ref={viewportRef} style={{ position: "absolute", inset: 0, overflow: "auto" }}>
        <div ref={contentRef} data-frame-box="true"
          style={{ position: "relative", width: frameContentWidth(activeFrame), aspectRatio: frameAspect(activeFrame), contain: "layout size", margin: "0 auto", touchAction: "none" }}>
          <FramePlayer manifest={manifest} frameId={resolveDisplay(frame)} size={contentSize} />
        </div>
      </div>
      <FixedLayer manifest={manifest} frameId={resolveDisplay(frame)} />
      {overlayStack.map((id, i) => <Overlay key={id + "-" + i} manifest={manifest} frameId={resolveDisplay(id)} />)}
    </div>
  );

  if (minimal) {
    return (
      <div>
        {taskBanner}
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}>
          {screenBox}
          {/* Hidden during settling, same as success — the point is to let
              the final frame just sit there for a moment, undisturbed. */}
          {!success && !settling && <button onClick={() => finalize(false)} disabled={submitting} style={{ ...ghostBtn(), opacity: submitting ? 0.6 : 1 }}>{submitting ? "Saving…" : "End early"}</button>}
        </div>
      </div>
    );
  }

  return (
    <div>
      {taskBanner}
      <div style={{ display: "flex", gap: 20, alignItems: "flex-start", flexWrap: "wrap" }}>
        {screenBox}
        <div style={{ fontFamily: "ui-monospace,monospace", fontSize: 12, color: C.dim, flex: "0 0 260px" }}>
          <div style={{ color: success ? C.green : C.cyan, letterSpacing: 1, marginBottom: 10 }}>{success ? "GOAL REACHED" : "REC - live telemetry"}</div>
          {[["cursor x", `${hud.x}px`], ["cursor y", `${hud.y}px`], ["frame", displayFrameId], ["events", hud.count], ["last", hud.last], ["input", hud.type], ["elapsed", `${hud.t}s`]].map(([k, v]) => (
            <div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "5px 0", borderBottom: `1px solid ${C.line}` }}><span>{k}</span><span style={{ color: C.text }}>{v}</span></div>
          ))}
          {success
            ? <button onClick={() => finalize(true)} disabled={submitting || settling} style={{ marginTop: 16, width: "100%", ...primaryBtn(), background: C.green, opacity: submitting || settling ? 0.6 : 1 }}>{settling ? "One moment…" : submitting ? "Saving…" : "Save successful session"}</button>
            : <button onClick={() => finalize(false)} disabled={submitting || settling} style={{ marginTop: 16, width: "100%", ...ghostBtn(), opacity: submitting || settling ? 0.6 : 1 }}>{settling ? "One moment…" : submitting ? "Saving…" : "Give up & save as failed"}</button>}
          <p style={{ marginTop: 12, lineHeight: 1.5, color: C.faint }}>Hotspots come straight from the imported manifest. Every move, click and tap is recorded per frame.</p>
        </div>
      </div>
    </div>
  );
}

// ================= dashboard =================
function Dashboard({ test, sessions, manifest, initialMode, autoAnalyze, initialFrame }) {
  const [mode, setMode] = useState(initialMode || "click");
  // initialFrame: used by ABResultsPage so each variant's own Dashboard
  // instance defaults its frame preview/heatmap to that variant's own frame
  // instead of always the manifest's first one.
  const [frameSel, setFrameSel] = useState(initialFrame || manifest.frames[0].id);
  // Which session (if any) the log is focused on — lifted up from SessionLog
  // rather than kept local to it, because selecting a session doesn't just
  // expand its own row anymore: it re-scopes the frame preview, heatmap and
  // stats above to that one session's data instead of the aggregate across
  // all of them (see scopedSessions below). null = aggregate view.
  const [expandedSession, setExpandedSession] = useState(null);
  const contentRef = useRef(null);
  const selFrame = manifest.frames.find((f) => f.id === frameSel) || manifest.frames[0];
  const size = useElementSize(contentRef);
  const selectedSession = sessions.find((s) => s.sessionId === expandedSession) || null;
  const scopedSessions = selectedSession ? [selectedSession] : sessions;
  // Jump to a frame the selected session actually has data on — otherwise
  // selecting a session that never touched the currently-viewed frame would
  // just show an empty heatmap with no indication why.
  useEffect(() => {
    if (!selectedSession) return;
    if (selectedSession.events.some((e) => e.frame === frameSel)) return;
    const firstFrame = selectedSession.events[0]?.frame;
    if (firstFrame) setFrameSel(firstFrame);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [expandedSession]);
  // The admin view polls /api/state every 4s (see App), which replaces
  // `sessions` with a brand-new array (and brand-new session objects inside
  // it) even when nothing actually changed — so keying these useMemo calls
  // on `scopedSessions` itself would still recompute (and repaint the
  // heatmap canvas) every single poll tick. A session's own sessionId+event
  // count uniquely identifies its content (events never mutate after a
  // session is saved), so a cheap signature string built from those — a
  // stable PRIMITIVE, unlike the array/object references — lets the
  // expensive work below actually skip when the underlying data hasn't
  // changed, not just when it merely got re-fetched.
  const scopedSignature = scopedSessions.map((s) => `${s.sessionId}:${s.events.length}`).join("|");
  const framePoints = useMemo(
    () => scopedSessions.flatMap((s) => s.events).filter((e) => e.frame === frameSel),
    [scopedSignature, frameSel]
  );
  const clicks = useMemo(() => framePoints.filter((e) => e.kind !== "move"), [framePoints]);
  // One point per session: the earliest click/tap on this frame, not every
  // click on it — first-click testing measures where attention lands before
  // a tester has had any chance to explore, so mixing in their 2nd/3rd click
  // on the same screen would just blur it back into an ordinary click heatmap.
  const firstClickPoints = useMemo(
    () => scopedSessions.map((s) => s.events.find((e) => e.frame === frameSel && e.kind !== "move")).filter(Boolean),
    [scopedSignature, frameSel]
  );
  const points = useMemo(() => {
    if (mode === "move") return framePoints.filter((e) => e.kind === "move");
    if (mode === "first") return firstClickPoints;
    return clicks;
  }, [framePoints, clicks, firstClickPoints, mode]);
  // First-click mode ranks what testers clicked FIRST on this frame rather
  // than everything they ever clicked here — same reasoning as
  // firstClickPoints above. A high "(background)" or wrong-element rank here
  // is the classic first-click-test signal that the layout doesn't read as
  // expected.
  const { ranked, maxCount } = useMemo(() => {
    const src = mode === "first" ? firstClickPoints : clicks;
    const byLabel = {}; src.forEach((c) => (byLabel[c.label] = (byLabel[c.label] || 0) + 1));
    const ranked = Object.entries(byLabel).sort((a, b) => b[1] - a[1]);
    return { ranked, maxCount: ranked[0]?.[1] || 1 };
  }, [clicks, firstClickPoints, mode]);
  const { successRate, avgTime, avgClicks } = useMemo(() => {
    const done = scopedSessions.filter((s) => s.success);
    const successRate = scopedSessions.length ? Math.round((done.length / scopedSessions.length) * 100) : 0;
    const avgTime = done.length ? (done.reduce((a, s) => a + s.durationMs, 0) / done.length / 1000).toFixed(1) : "-";
    const avgClicks = done.length ? (done.reduce((a, s) => a + s.clickCount, 0) / done.length).toFixed(1) : "-";
    return { successRate, avgTime, avgClicks };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [scopedSignature]);

  // "Aggregate average" — deliberately independent of scopedSessions/
  // scopedSignature above: it always covers every recorded session
  // regardless of whatever single session the log is currently scoped to,
  // since the whole point is a stable "how testers clicked on average" view.
  const allSessionsSignature = sessions.map((s) => `${s.sessionId}:${s.events.length}`).join("|");
  const { averagePoints, sessionsOnFrame } = useMemo(() => {
    const onFrame = sessions.filter((s) => s.events.some((e) => e.frame === frameSel));
    let pts;
    if (mode === "move") pts = onFrame.flatMap((s) => s.events).filter((e) => e.frame === frameSel && e.kind === "move");
    // Same one-point-per-session rule as firstClickPoints above, just over
    // every session instead of whatever's currently scoped.
    else if (mode === "first") pts = onFrame.map((s) => s.events.find((e) => e.frame === frameSel && e.kind !== "move")).filter(Boolean);
    else pts = onFrame.flatMap((s) => s.events).filter((e) => e.frame === frameSel && e.kind !== "move");
    return { averagePoints: pts, sessionsOnFrame: onFrame.length };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [allSessionsSignature, frameSel, mode]);
  // Per-click alpha scaled so the composited result reads as "what fraction
  // of testers (who reached this frame) clicked here" rather than a raw,
  // ever-growing pile of ink: solving 1-(1-per)^N = 0.5 for `per` means a
  // spot EVERYONE clicked composites to ~50% raw alpha (still comfortably
  // short of the boosted-alpha ceiling — see paintHeatmap — leaving room to
  // visually tell 100% consensus apart from 50%, 25%, etc., rather than
  // everything above ~35% consensus looking identically maxed-out red).
  // Click and first-click modes only (the ask this was built for) — both
  // already contribute at most a handful of overlapping points per session,
  // so the same "100% consensus -> ~50% raw alpha" derivation applies to
  // either. Movement's own "average" isn't separately tuned, so it just
  // reuses the raw/live per-point alpha as-is.
  const averagePer = (mode === "click" || mode === "first") && sessionsOnFrame ? 1 - Math.pow(0.5, 1 / sessionsOnFrame) : undefined;

  const [downloadVariant, setDownloadVariant] = useState("current");
  // Plain text (not raw JSON) — one sessionNotesText-style summary per
  // session (same format as the standalone per-session notes download),
  // followed by that session's full raw event trail so no data is actually
  // lost versus the old JSON dump, just made human-readable.
  const exportDataset = () => {
    const lines = [`Dataset export — ${test.name}`, `Task: ${test.task || `Find the ${goalName} page`}`, `Sessions: ${sessions.length}`, ""];
    sessions.forEach((s) => {
      lines.push("=".repeat(60));
      lines.push(sessionNotesText(s, manifest).trimEnd());
      lines.push("EVENTS (raw)");
      s.events.forEach((e) => {
        const frameName = manifest.frames.find((f) => f.id === e.frame)?.name || e.frame;
        lines.push(`  ${(e.t / 1000).toFixed(2)}s  ${frameName}  ${e.kind}${e.label ? `  ${e.label}` : ""}  x=${e.x.toFixed(3)} y=${e.y.toFixed(3)}`);
      });
      lines.push("");
    });
    const blob = new Blob([lines.join("\n")], { type: "text/plain" });
    const url = URL.createObjectURL(blob); const a = document.createElement("a");
    a.href = url; a.download = `${test.name.replace(/\s+/g, "_")}_dataset.txt`; a.click(); URL.revokeObjectURL(url);
  };
  // Exports at whatever resolution the frame box is currently rendered at
  // on screen (see downloadFrameHeatmap for the shared compositing logic).
  // Filename disambiguates by scope/variant — a raw "current view" export
  // and an "aggregate average" export of the same test+frame+mode used to
  // collide on an identical filename (silently overwriting one another);
  // now each scope gets its own distinct name.
  const exportHeatmapImage = () => {
    const modeLabel = mode === "first" ? "firstclick" : mode;
    const base = `${test.name.replace(/\s+/g, "_")}_${selFrame.name.replace(/\s+/g, "_")}_${modeLabel}`;
    if (downloadVariant === "average") {
      downloadFrameHeatmap({
        frame: selFrame, points: averagePoints, mode, width: size.width, height: size.height,
        perOverride: averagePer, filename: `${base}_aggregate_average_heatmap.png`,
      });
    } else {
      const scopeLabel = selectedSession ? selectedSession.sessionId : "aggregate";
      downloadFrameHeatmap({
        frame: selFrame, points, mode, width: size.width, height: size.height,
        filename: `${base}_${scopeLabel}_heatmap.png`,
      });
    }
  };
  const goalName = manifest.frames.find((f) => f.id === test.goalFrame)?.name || test.goalFrame;

  // Only auto-runs for the "AI Insights" sidebar entry, which lands here
  // specifically to see the analysis — the ordinary Results page (reached
  // via "Run") never sets autoAnalyze, so it still requires the explicit
  // button press as before.
  const { analyzing, analysis, analyzeError, runAnalysis } = useAnalyzeTrends(test, sessions, manifest, autoAnalyze);

  return (
    <div>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
        <div><div style={{ fontSize: 11, color: C.cyan, letterSpacing: 1, fontFamily: "ui-monospace,monospace" }}>RESULTS</div><h2 style={{ margin: "2px 0 0", fontSize: 20 }}>{test.name}</h2></div>
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          {sessions.length > 0 && (
            <>
              <select value={downloadVariant} onChange={(e) => setDownloadVariant(e.target.value)}
                style={{ background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "9px 10px", color: C.text, fontSize: 13 }}>
                <option value="current">Current view</option>
                <option value="average">Aggregate average</option>
              </select>
              <button onClick={exportHeatmapImage} style={ghostBtn()}>Download heatmap</button>
            </>
          )}
          {sessions.length > 0 && <button onClick={exportDataset} style={ghostBtn()}>Export dataset</button>}
          {sessions.length > 0 && <button onClick={runAnalysis} disabled={analyzing} style={{ ...ghostBtn(), opacity: analyzing ? 0.6 : 1 }}>{analyzing ? "Analyzing…" : "Analyze trends"}</button>}
        </div>
      </div>
      <div style={{ fontSize: 13, color: C.dim, marginBottom: 18 }}>Task: {test.task || `Find the ${goalName} page`}</div>
      {(analyzing || analysis || analyzeError) && (
        <div style={{ ...panelBox(), marginBottom: 18 }}>
          <div style={{ fontSize: 12, color: C.dim, marginBottom: 12, fontFamily: "ui-monospace,monospace", letterSpacing: 1 }}>AI TREND ANALYSIS</div>
          {analyzing && <div style={{ color: C.dim, fontSize: 13 }}>Analyzing {sessions.length} session{sessions.length === 1 ? "" : "s"}…</div>}
          {analyzeError && <div style={{ color: C.red, fontSize: 13 }}>{analyzeError}</div>}
          {analysis && <div style={{ color: C.text, fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{analysis}</div>}
        </div>
      )}
      {sessions.length === 0 ? (
        <div style={{ ...panelBox(), textAlign: "center", color: C.dim, padding: 40 }}>No sessions yet. Run the test to collect telemetry.</div>
      ) : (
        <div>
          {selectedSession && (
            <div style={{ ...panelBox(), borderLeft: `3px solid ${C.cyan}`, marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center", padding: "10px 14px" }}>
              <span style={{ fontSize: 12, color: C.dim }}>Viewing session <span style={{ color: C.text, fontFamily: "ui-monospace,monospace" }}>{selectedSession.sessionId}</span> only — the preview, heatmap and stats below are scoped to it</span>
              <button style={ghostBtn()} onClick={() => setExpandedSession(null)}>✕ Clear</button>
            </div>
          )}
          <div style={{ display: "flex", gap: 20, flexWrap: "wrap", alignItems: "flex-start" }}>
            <div style={{ flex: "1 1 480px" }}>
              <div style={{ display: "flex", gap: 6, marginBottom: 10, flexWrap: "wrap" }}>
                {manifest.frames.map((f) => <button key={f.id} onClick={() => setFrameSel(f.id)} style={frameSel === f.id ? { ...primaryBtn(), padding: "6px 12px" } : { ...ghostBtn(), padding: "6px 12px" }}>{f.name}</button>)}
              </div>
              <div style={{ ...screenBoxStyle(selFrame, 280), borderRadius: 14, border: `1px solid ${C.line}`, background: "#000", overflow: "hidden", position: "relative" }}>
                <div style={{ position: "absolute", inset: 0, overflow: "auto" }}>
                  <div ref={contentRef} style={{ position: "relative", width: frameContentWidth(selFrame), aspectRatio: frameAspect(selFrame), contain: "layout size", margin: "0 auto" }}>
                    <div style={{ position: "absolute", inset: 0, filter: "grayscale(0.5) brightness(0.65)" }}><FramePlayer manifest={manifest} frameId={frameSel} size={size} /></div>
                    <Heatmap points={points} mode={mode} size={size} />
                  </div>
                </div>
                <div style={{ position: "absolute", inset: 0, filter: "grayscale(0.5) brightness(0.65)", pointerEvents: "none" }}>
                  <FixedLayer manifest={manifest} frameId={frameSel} />
                </div>
              </div>
            </div>
            <div style={{ flex: 1, minWidth: 260 }}>
              <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
                <button onClick={() => setMode("click")} style={mode === "click" ? primaryBtn() : ghostBtn()}>Clicks / taps</button>
                <button onClick={() => setMode("move")} style={mode === "move" ? primaryBtn() : ghostBtn()}>Movement</button>
                <button onClick={() => setMode("first")} style={mode === "first" ? primaryBtn() : ghostBtn()}>First click</button>
              </div>
              {selectedSession ? (
                <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 18 }}>
                  <Stat label="Result" value={selectedSession.success ? "success" : "failed"} accent={selectedSession.success ? C.green : C.red} />
                  <Stat label="Duration" value={`${(selectedSession.durationMs / 1000).toFixed(1)}s`} />
                  <Stat label="Clicks/taps" value={selectedSession.clickCount} />
                  <Stat label="Recorded" value={new Date(selectedSession.recordedAt).toLocaleDateString()} />
                </div>
              ) : (
                <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 18 }}>
                  <Stat label="Sessions" value={sessions.length} />
                  <Stat label="Task success" value={`${successRate}%`} accent={C.green} />
                  <Stat label="Avg time (success)" value={avgTime === "-" ? "-" : `${avgTime}s`} />
                  <Stat label="Avg clicks (success)" value={avgClicks} />
                </div>
              )}
              <div style={{ ...panelBox(), marginBottom: 18 }}>
                <div style={{ fontSize: 12, color: C.dim, marginBottom: 12, fontFamily: "ui-monospace,monospace", letterSpacing: 1 }}>{mode === "first" ? "FIRST CLICKED" : "MOST-CLICKED"} - {manifest.frames.find((f) => f.id === frameSel)?.name}</div>
                {ranked.length === 0 && <div style={{ color: C.faint, fontSize: 13 }}>{mode === "first" ? "No first clicks recorded on this frame." : "No clicks on this frame."}</div>}
                {ranked.map(([label, n]) => (
                  <div key={label} style={{ marginBottom: 10 }}>
                    <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}><span style={{ color: C.text }}>{label}</span><span style={{ color: C.dim }}>{n}</span></div>
                    <div style={{ height: 6, background: C.line, borderRadius: 4, overflow: "hidden" }}><div style={{ width: `${(n / maxCount) * 100}%`, height: "100%", background: C.cyan }} /></div>
                  </div>
                ))}
              </div>
            </div>
          </div>
          <div style={{ ...panelBox(), marginTop: 4, padding: 20 }}>
            <div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>Sessions</div>
            <div style={{ fontSize: 12, color: C.dim, marginBottom: 14 }}>Click a session to see its full result and scope the preview/heatmap/stats above to it — click it again to go back to viewing all sessions.</div>
            <SessionLog sessions={sessions} manifest={manifest} expanded={expandedSession} onExpand={setExpandedSession} />
          </div>
        </div>
      )}
    </div>
  );
}
// A "download into tray" glyph for the Import button — an inline SVG (not
// a Unicode character, since no single glyph matches this shape) using
// currentColor so it follows the button's text color automatically. Square
// caps + miter joins (not round) for a blockier, more geometric look, and
// the tray is a sharp-cornered open box rather than a rounded one.
function ImportIcon() {
  return (
    <svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="square" strokeLinejoin="miter" style={{ display: "block" }}>
      <path d="M4 14v6h16v-6" />
      <polyline points="7 9 12 14 17 9" />
      <line x1="12" y1="14" x2="12" y2="2" />
    </svg>
  );
}
function Stat({ label, value, accent }) {
  return <div style={{ ...panelBox(), padding: 14 }}><div style={{ fontSize: 22, fontWeight: 700, color: accent || C.text }}>{value}</div><div style={{ fontSize: 11, color: C.dim, marginTop: 2 }}>{label}</div></div>;
}

// A click/tap event's label is either a hotspot's own name, or one of two
// internal sentinel strings CaptureSurface writes for a non-hotspot click
// (see onDown): "(background)" (missed every hotspot on the frame) or
// "(overlay backdrop)" (clicked outside an open overlay to dismiss it).
// Both read fine in TRACE's own UI to someone who already knows the
// convention, but handed raw to an AI provider they've been consistently
// misread as if they named an actual UI element — e.g. "a background
// element appeared" — rather than describing a miss-click. Spelling them
// out in plain English here is the fix, not a caveat added to the prompt's
// instructions, since the ambiguous text is what's actually misleading it.
function describeClickLabel(label) {
  if (label === "(background)") return "clicked empty space (missed every hotspot on this screen)";
  if (label === "(overlay backdrop)") return "clicked outside the open overlay to dismiss it";
  return label;
}

// Builds the text payload sent to the AI provider for trend analysis — a
// distinct, more compact rendering from the human-facing dataset export
// below: it drops the flood of raw pointer-move events (thousands of lines
// for a single session, pure noise for spotting trends) and keeps only
// clicks/taps plus the aggregate stats an LLM actually needs to reason about
// friction points across sessions.
function buildAnalysisPrompt(test, sessions, manifest) {
  const goalName = manifest.frames.find((f) => f.id === test.goalFrame)?.name || test.goalFrame;
  const done = sessions.filter((s) => s.success);
  const successRate = sessions.length ? Math.round((done.length / sessions.length) * 100) : 0;
  const avgTime = done.length ? (done.reduce((a, s) => a + s.durationMs, 0) / done.length / 1000).toFixed(1) : "-";
  const avgClicks = done.length ? (done.reduce((a, s) => a + s.clickCount, 0) / done.length).toFixed(1) : "-";
  const lines = [
    `Usability test: ${test.name}`,
    `Task given to testers: ${test.task || `Find the ${goalName} page`}`,
    `Goal screen: ${goalName}`,
    `Sessions: ${sessions.length} (${successRate}% success, avg ${avgTime}s / ${avgClicks} clicks on successful runs)`,
    "",
  ];
  sessions.forEach((s, i) => {
    lines.push(`--- Session ${i + 1} (${s.sessionId}) ---`);
    lines.push(`Result: ${s.success ? "success" : "failed"} in ${(s.durationMs / 1000).toFixed(1)}s, ${s.clickCount} clicks/taps`);
    lines.push(`Path: ${s.path.map((fid) => manifest.frames.find((f) => f.id === fid)?.name || fid).join(" > ")}`);
    if (s.variantLabel) lines.push(`Variant shown: ${s.variantLabel}`);
    if (s.preQuestions?.length) {
      s.preQuestions.forEach((q, j) => lines.push(`Q: ${typeof q === "string" ? q : q.text} — A: ${s.preAnswers?.[j]?.trim() || "(no answer)"}`));
    }
    if (s.comment?.trim()) lines.push(`Comment: ${s.comment.trim()}`);
    s.events.filter((e) => e.kind !== "move").forEach((e) => {
      const frameName = manifest.frames.find((f) => f.id === e.frame)?.name || e.frame;
      lines.push(`  ${(e.t / 1000).toFixed(1)}s  ${frameName}  ${e.kind}  ${describeClickLabel(e.label)}`);
    });
    lines.push("");
  });
  // Per-session Q&A above is easy for a human to skim but forces the model to
  // cross-reference scattered blocks itself to spot a group pattern (e.g. "did
  // one age bracket or occupation struggle more than another?") — regrouping
  // every answer by its question text, alongside that session's own outcome,
  // turns that into a single flat table it can read straight down. Works for
  // the built-in demographic questions and any custom one identically, since
  // this only ever keys on the literal question text, never a special-cased list.
  const byQuestion = {};
  sessions.forEach((s) => {
    (s.preQuestions || []).forEach((q, j) => {
      const qText = typeof q === "string" ? q : q.text;
      const answer = s.preAnswers?.[j]?.trim() || "(no answer)";
      (byQuestion[qText] ||= []).push({ answer, s });
    });
  });
  // A/B tests: the variant a session was randomly assigned is structurally
  // identical to a question/answer pair (one label per session), so it's fed
  // into the exact same grouping table instead of a separate code path — the
  // comparison instruction below already says "compare groups," which covers
  // "compare variant A vs variant B" without any special-casing.
  if (test.variantSlot) {
    sessions.forEach((s) => {
      if (s.variantLabel) (byQuestion["Which variant did you see?"] ||= []).push({ answer: s.variantLabel, s });
    });
  }
  const questionTexts = Object.keys(byQuestion);
  if (questionTexts.length) {
    lines.push("=".repeat(60));
    lines.push("ANSWERS BY QUESTION (compare groups to spot who struggled with what)");
    questionTexts.forEach((qText) => {
      lines.push(`Q: ${qText}`);
      byQuestion[qText].forEach(({ answer, s }) => {
        lines.push(`  ${answer} -> ${s.success ? "success" : "failed"}, ${(s.durationMs / 1000).toFixed(1)}s, ${s.clickCount} clicks (${s.sessionId})`);
      });
    });
  }
  return lines.join("\n");
}

// Shared by Dashboard's own "Analyze trends" and ABResultsPage's cross-variant
// one — the request/response plumbing (read Settings, call /api/analyze,
// surface errors) is identical either way; only which `sessions` get passed
// in differs (a single variant's for Dashboard, every variant's for
// ABResultsPage, which is what actually lets "Which variant did you see?"
// resolve to more than one answer and produce a real comparison).
function useAnalyzeTrends(test, sessions, manifest, autoAnalyze) {
  const [analyzing, setAnalyzing] = useState(false);
  const [analysis, setAnalysis] = useState(null);
  const [analyzeError, setAnalyzeError] = useState(null);
  const runAnalysis = async () => {
    const settings = loadSettings();
    const provider = settings.aiProvider || "anthropic";
    const providerMeta = AI_PROVIDERS.find((p) => p.key === provider) || AI_PROVIDERS[0];
    const apiKey = settings[providerMeta.keyField];
    const endpoint = settings.localEndpoint, model = settings.localModel;
    if (provider === "local" ? !endpoint : !apiKey) {
      setAnalyzeError(provider === "local"
        ? "No local model endpoint configured — set one in Settings first."
        : `No ${providerMeta.label} API key saved — add one in Settings first.`);
      setAnalysis(null);
      return;
    }
    setAnalyzing(true); setAnalyzeError(null); setAnalysis(null);
    try {
      const res = await fetch("/api/analyze", {
        method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }),
        body: JSON.stringify({ provider, apiKey, endpoint, model, text: buildAnalysisPrompt(test, sessions, manifest) }),
      });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
      setAnalysis(data.analysis);
    } catch (e) {
      setAnalyzeError(e.message || "Analysis failed.");
    } finally {
      setAnalyzing(false);
    }
  };
  useEffect(() => {
    if (autoAnalyze) runAnalysis();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return { analyzing, analysis, analyzeError, runAnalysis };
}

// Every recorded session for one test, each row expandable in place to the
// full result (task, outcome, duration, click count, timestamp, and the
// click/tap event trail) — shared between the Results dashboard and the
// per-test "Sessions" folder in the Tests tab (see App) so both present
// session detail identically.
// Plain-text writeup of one session — the pre-test Q&A and post-test
// comment are the main reason this exists (a downloadable heatmap doesn't
// carry text), but the rest of the session's stats are included too so the
// file is a self-contained record on its own.
function sessionNotesText(s, manifest) {
  const goalName = manifest.frames.find((f) => f.id === s.goalFrame)?.name || s.goalFrame;
  const lines = [
    `Session: ${s.sessionId}`,
    `Task: ${s.taskPrompt || `Find the ${goalName} page`}`,
    `Recorded: ${new Date(s.recordedAt).toLocaleString()}`,
    `Result: ${s.success ? "success" : "failed"}`,
    `Duration: ${(s.durationMs / 1000).toFixed(1)}s`,
    `Clicks/taps: ${s.clickCount}`,
    `Path: ${s.path.join(" > ")}`,
    "",
  ];
  if (s.preQuestions?.length) {
    lines.push("PRE-TEST QUESTIONNAIRE");
    s.preQuestions.forEach((q, i) => {
      lines.push(`${i + 1}. ${typeof q === "string" ? q : q.text}`);
      lines.push(`   ${s.preAnswers?.[i]?.trim() || "(no answer)"}`);
    });
    lines.push("");
  }
  if (s.comment?.trim()) {
    lines.push("POST-TEST COMMENT");
    lines.push(s.comment.trim());
    lines.push("");
  }
  return lines.join("\n");
}
function downloadSessionNotes(s, manifest) {
  const blob = new Blob([sessionNotesText(s, manifest)], { type: "text/plain" });
  const url = URL.createObjectURL(blob); const a = document.createElement("a");
  a.href = url; a.download = `${s.sessionId}_notes.txt`; a.click();
  URL.revokeObjectURL(url);
}
// `expanded`/`onExpand` are controlled by the parent (Dashboard) — selecting
// a session here doesn't just reveal its own detail, it also re-scopes the
// dashboard's frame preview/heatmap/stats above to that one session, so the
// parent needs to know which one is active.
function SessionLog({ sessions, manifest, expanded, onExpand }) {
  if (sessions.length === 0) return <div style={{ color: C.faint, fontSize: 13 }}>No sessions yet.</div>;
  return (
    <>
      {sessions.map((s, i) => {
        const isOpen = expanded === s.sessionId;
        return (
          <div key={s.sessionId} style={{ borderBottom: i < sessions.length - 1 ? `1px solid ${C.line}` : "none", background: isOpen ? hexToRgba(C.cyan, 0.07) : "transparent", borderRadius: 8 }}>
            <div onClick={() => onExpand(isOpen ? null : s.sessionId)}
              style={{ display: "flex", justifyContent: "space-between", fontSize: 13, padding: "10px 10px", cursor: "pointer", borderLeft: isOpen ? `3px solid ${C.cyan}` : "3px solid transparent" }}>
              <span style={{ fontFamily: "ui-monospace,monospace", color: isOpen ? C.cyan : C.dim, fontWeight: isOpen ? 600 : 400 }}>{isOpen ? "▾" : "▸"} {s.sessionId}</span>
              <span style={{ color: C.dim }}>{s.path.join(" > ")}</span>
              <span style={{ color: s.success ? C.green : C.red }}>{s.success ? `${(s.durationMs / 1000).toFixed(1)}s` : "failed"}</span>
            </div>
            {/* A separate component (not inlined here) so the per-session
                event filtering/lookups below only ever run for the one row
                that's actually expanded, instead of every row on every
                render (e.g. every 4s admin poll) whether open or not. */}
            {isOpen && <SessionDetail s={s} manifest={manifest} />}
          </div>
        );
      })}
    </>
  );
}
function SessionDetail({ s, manifest }) {
  const clickEvents = s.events.filter((e) => e.kind !== "move");
  const moveCount = s.events.length - clickEvents.length;
  const goalName = manifest.frames.find((f) => f.id === s.goalFrame)?.name || s.goalFrame;
  return (
    <div style={{ padding: "2px 0 16px 18px", fontSize: 12 }}>
      <div style={{ color: C.dim, marginBottom: 10 }}>Task: <span style={{ color: C.text }}>{s.taskPrompt || `Find the ${goalName} page`}</span></div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, marginBottom: 12 }}>
        <div>Result<br /><span style={{ color: s.success ? C.green : C.red }}>{s.success ? "success" : "failed"}</span></div>
        <div>Duration<br /><span style={{ color: C.text }}>{(s.durationMs / 1000).toFixed(1)}s</span></div>
        <div>Clicks/taps<br /><span style={{ color: C.text }}>{s.clickCount}</span></div>
        <div>Recorded<br /><span style={{ color: C.text }}>{new Date(s.recordedAt).toLocaleString()}</span></div>
      </div>
      {s.preQuestions?.length > 0 && (
        <div style={{ marginBottom: 12 }}>
          <div style={{ color: C.dim, marginBottom: 6 }}>PRE-TEST ANSWERS</div>
          {s.preQuestions.map((q, i) => (
            <div key={i} style={{ marginBottom: 6 }}>
              <div style={{ color: C.faint }}>{typeof q === "string" ? q : q.text}</div>
              <div style={{ color: C.text }}>{s.preAnswers?.[i]?.trim() || "(no answer)"}</div>
            </div>
          ))}
        </div>
      )}
      {s.comment?.trim() && (
        <div style={{ marginBottom: 12 }}>
          <div style={{ color: C.dim, marginBottom: 6 }}>COMMENT</div>
          <div style={{ color: C.text, whiteSpace: "pre-wrap" }}>{s.comment.trim()}</div>
        </div>
      )}
      <div style={{ color: C.dim, marginBottom: 6 }}>
        EVENTS - {clickEvents.length} click{clickEvents.length === 1 ? "" : "s"}/tap{clickEvents.length === 1 ? "" : "s"}{moveCount > 0 ? ` (+ ${moveCount} pointer moves not shown)` : ""}
      </div>
      <div style={{ maxHeight: 220, overflowY: "auto" }}>
        {clickEvents.length === 0 && <div style={{ color: C.faint }}>No clicks recorded.</div>}
        {clickEvents.map((e, j) => {
          const frameName = manifest.frames.find((f) => f.id === e.frame)?.name || e.frame;
          return (
            <div key={j} style={{ display: "flex", justifyContent: "space-between", gap: 8, padding: "3px 0", borderBottom: j < clickEvents.length - 1 ? `1px solid ${C.line}` : "none" }}>
              <span style={{ color: C.faint, flex: "0 0 48px" }}>{(e.t / 1000).toFixed(1)}s</span>
              <span style={{ color: C.dim, flex: "0 0 110px" }}>{frameName}</span>
              <span style={{ color: C.text, flex: 1 }}>{e.label}</span>
              <span style={{ color: C.faint, flex: "0 0 90px", textAlign: "right" }}>{e.kind} · {e.ptype}</span>
            </div>
          );
        })}
      </div>
      <div style={{ marginTop: 12, display: "flex", gap: 8, flexWrap: "wrap" }}>
        {s.events.length > 0 && [...new Set(s.events.map((e) => e.frame))].map((fid) => {
          const f = manifest.frames.find((fr) => fr.id === fid);
          if (!f) return null;
          const clickPts = clickEvents.filter((e) => e.frame === fid).map((e) => ({ x: e.x, y: e.y }));
          const movePts = s.events.filter((e) => e.frame === fid && e.kind === "move").map((e) => ({ x: e.x, y: e.y }));
          const dims = { width: f.scroll ? f.scroll.contentWidth : f.width, height: f.scroll ? f.scroll.contentHeight : f.height };
          return (
            <React.Fragment key={fid}>
              {clickPts.length > 0 && (
                <button style={{ ...ghostBtn(), padding: "6px 12px", fontSize: 11 }}
                  onClick={() => downloadFrameHeatmap({
                    frame: f, points: clickPts, mode: "click", ...dims,
                    filename: `${s.sessionId}_${f.name.replace(/\s+/g, "_")}_click_heatmap.png`,
                  })}>
                  Download clicks - {f.name}
                </button>
              )}
              {movePts.length > 0 && (
                <button style={{ ...ghostBtn(), padding: "6px 12px", fontSize: 11 }}
                  onClick={() => downloadFrameHeatmap({
                    frame: f, points: movePts, mode: "move", ...dims,
                    filename: `${s.sessionId}_${f.name.replace(/\s+/g, "_")}_movement_heatmap.png`,
                  })}>
                  Download movement - {f.name}
                </button>
              )}
            </React.Fragment>
          );
        })}
        <button style={{ ...ghostBtn(), padding: "6px 12px", fontSize: 11 }} onClick={() => downloadSessionNotes(s, manifest)}>
          Download session notes (.txt)
        </button>
      </div>
    </div>
  );
}

// ================= desktop package drop zone (renders only in the Electron app) =================
function DesktopDropZone({ onImport, onError }) {
  const [over, setOver] = useState(false);
  const [busy, setBusy] = useState(false);
  if (!window.traceDesktop) return null;

  const ingest = async (file) => {
    if (!file) return;
    setBusy(true);
    try {
      const filePath = window.traceDesktop.getPathForFile(file);
      const res = await window.traceDesktop.importPackage(filePath);
      if (res.error) onError(res.error);
      else onImport(res.manifest);
    } catch (e) { onError(e.message); }
    finally { setBusy(false); setOver(false); }
  };

  return (
    <div
      onDragOver={(e) => { e.preventDefault(); setOver(true); }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => { e.preventDefault(); ingest(e.dataTransfer.files[0]); }}
      style={{
        border: `2px dashed ${over ? C.cyan : C.line}`, borderRadius: 12,
        padding: 28, textAlign: "center", marginBottom: 16,
        background: over ? hexToRgba(C.cyan, 0.06) : C.panel, transition: "border-color 0.15s",
      }}
    >
      <div style={{ fontSize: 13, color: over ? C.cyan : C.text, fontWeight: 600 }}>
        {busy ? "Extracting package…" : "Drop a .trace or .zip package here"}
      </div>
      <div style={{ fontSize: 12, color: C.faint, marginTop: 6 }}>
        Exported from the TRACE Figma plugin — or use File → Open Prototype Package (⌘O)
      </div>
    </div>
  );
}

// ================= import (calls backend → Figma) =================
function ImportPanel({ prototypeCount, onImport }) {
  const [fileKey, setFileKey] = useState("");
  // Prefilled from whatever's saved on the Settings tab, so a token you've
  // already saved once doesn't need retyping for every import — still just
  // a normal editable field, so a one-off different token works fine too.
  const [token, setToken] = useState(() => loadSettings().figmaToken || "");
  const [page, setPage] = useState("");
  const [scale, setScale] = useState("2");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };

  const runImport = async () => {
    setErr(""); setBusy(true);
    try {
      const res = await fetch("/api/import", { method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ fileKey: fileKey.trim(), token: token.trim() || undefined, page: page.trim() || undefined, scale }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Import failed");
      onImport(data.manifest);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  };

  return (
    <div style={{ maxWidth: 520 }}>
      <h2 style={{ fontSize: 20, marginBottom: 8 }}>Import prototype</h2>
      <DesktopDropZone onImport={onImport} onError={setErr} />
      <p style={{ color: C.dim, fontSize: 13, lineHeight: 1.6 }}>
        {window.traceDesktop
          ? "Or import via the Figma REST API (legacy): the server pulls your file, renders each frame, and extracts the click hotspots."
          : "The server pulls your Figma file, renders each frame, and extracts the click hotspots. Leave the token blank if you started the server with "}
        {!window.traceDesktop && <code style={{ color: C.cyan }}>FIGMA_TOKEN</code>}
        {!window.traceDesktop && " set."}
      </p>
      <div style={{ ...panelBox(), margin: "12px 0" }}>
        <div style={{ fontSize: 12, color: C.dim }}>
          {prototypeCount > 0
            ? <>{prototypeCount} prototype{prototypeCount === 1 ? "" : "s"} imported so far — each gets its own folder on the Tests tab. Importing the same file again refreshes it in place.</>
            : "No prototypes imported yet."}
        </div>
      </div>
      <label style={{ fontSize: 12, color: C.dim }}>Prototype link <span style={{ color: C.faint }}>(paste the figma.com/proto/, /file/, or /design/ URL — or just the file key)</span>
        <input style={inp} value={fileKey} onChange={(e) => setFileKey(e.target.value)} placeholder="https://www.figma.com/proto/aBc123XyZ/My-Prototype?node-id=0-1" />
      </label>
      <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16 }}>Personal access token <span style={{ color: C.faint }}>(optional if set on server — save one on the Settings tab to skip re-entering it here)</span>
        <input style={inp} type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="figd_..." />
      </label>
      <div style={{ display: "flex", gap: 12 }}>
        <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16, flex: 2 }}>Page name <span style={{ color: C.faint }}>(optional)</span>
          <input style={inp} value={page} onChange={(e) => setPage(e.target.value)} placeholder="all pages" />
        </label>
        <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16, flex: 1 }}>Scale
          <input style={inp} value={scale} onChange={(e) => setScale(e.target.value)} />
        </label>
      </div>
      {err && <div style={{ color: C.red, fontSize: 12, marginTop: 10 }}>{err}</div>}
      <div style={{ display: "flex", gap: 10, marginTop: 16 }}>
        <button style={{ ...primaryBtn(), opacity: busy || !fileKey.trim() ? 0.5 : 1 }} disabled={busy || !fileKey.trim()} onClick={runImport}>{busy ? "Importing…" : "Import from Figma"}</button>
        <button style={ghostBtn()} onClick={() => onImport(MOCK_MANIFEST)}>Reset to demo</button>
      </div>
    </div>
  );
}

// ================= enterprise mode: setup & login =================
// TRACE_MODE=enterprise only (see server.js) — a self-hosted deployment's
// first-run "create the admin account" screen and its ordinary sign-in
// screen. Classic desktop/web mode never renders either of these (App()
// only shows them once /api/auth/status confirms enterprise mode is active).
// Shared shell for every full-page enterprise-auth screen (setup/login/
// forgot/reset) — a centered panel on the app background, same as CaptureSurface's
// remote-tester "Loading…"/"This test link isn't available" screens use.
function AuthShell({ children }) {
  return (
    <div style={{ background: C.bg, minHeight: "100vh", color: C.text, fontFamily: "ui-sans-serif,system-ui,sans-serif", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
      <div style={{ ...panelBox(), width: "100%", maxWidth: 380 }}>{children}</div>
    </div>
  );
}
const authInputStyle = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };
const authLinkBtn = { background: "none", border: "none", padding: 0, color: C.cyan, fontSize: 12, cursor: "pointer" };

const authTabBtn = (active) => ({
  flex: 1, padding: "8px 0", background: "none", border: "none",
  borderBottom: active ? `2px solid ${C.cyan}` : `1px solid ${C.line}`,
  color: active ? C.text : C.dim, fontWeight: active ? 600 : 400, fontSize: 13, cursor: "pointer",
});

function AuthForm({ title, subtitle, submitLabel, busyLabel, endpoint, onDone, footer, header }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [err, setErr] = useState("");
  const [busy, setBusy] = useState(false);
  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    try {
      const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Something went wrong.");
      // A full reload rather than reconciling client state in place — the
      // pre-login initial-load effect may have already fetched /api/state
      // and gotten a 401 (harmless, just wasted), and a fresh mount is the
      // simplest way to guarantee every effect re-runs against the now-valid
      // session cookie instead of trying to patch state around it.
      onDone();
    } catch (e2) {
      setErr(e2.message);
      setBusy(false);
    }
  };
  return (
    <AuthShell>
      {header}
      <form onSubmit={submit}>
        <h2 style={{ fontSize: 20, marginBottom: 4 }}>{title}</h2>
        <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>{subtitle}</p>
        <label style={{ fontSize: 12, color: C.dim }}>Email
          <input style={authInputStyle} type="email" required autoFocus value={email} onChange={(e) => setEmail(e.target.value)} />
        </label>
        <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 12 }}>Password
          <input style={authInputStyle} type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
        </label>
        {err && <div style={{ color: C.red, fontSize: 12, marginTop: 10 }}>{err}</div>}
        <button type="submit" style={{ ...primaryBtn(), marginTop: 16, width: "100%", opacity: busy ? 0.5 : 1 }} disabled={busy}>{busy ? busyLabel : submitLabel}</button>
        {footer && <div style={{ marginTop: 14, textAlign: "center" }}>{footer}</div>}
      </form>
    </AuthShell>
  );
}
function SetupScreen({ onDone }) {
  return (
    <AuthForm
      title="Welcome to TRACE"
      subtitle="Create the first admin account for this deployment — everyone you add afterward shares this same workspace."
      submitLabel="Create admin account" busyLabel="Creating…"
      endpoint="/api/auth/setup" onDone={onDone}
    />
  );
}
// Open self-registration (see /api/auth/signup): anyone reaching this
// deployment can create their own account here, always as a Member — an
// admin still has to invite someone for them to get Admin access. A tab bar
// switches between this and the ordinary sign-in form below; "Forgot
// password?" only makes sense on the sign-in side.
function LoginScreen({ onDone }) {
  // Lets the landing page's separate "Sign up"/"Log in" buttons deep-link
  // straight to the right tab (?mode=signup) instead of always landing on
  // Sign in first.
  const [mode, setMode] = useState(() => (new URLSearchParams(window.location.search).get("mode") === "signup" ? "signup" : "login")); // "login" | "signup" | "forgot"
  if (mode === "forgot") return <ForgotPasswordScreen onBack={() => setMode("login")} />;
  const tabs = (
    <div style={{ display: "flex", marginBottom: 20 }}>
      <button type="button" style={authTabBtn(mode === "login")} onClick={() => setMode("login")}>Sign in</button>
      <button type="button" style={authTabBtn(mode === "signup")} onClick={() => setMode("signup")}>Sign up</button>
    </div>
  );
  if (mode === "signup") {
    return (
      <AuthForm
        header={tabs}
        title="Create your account"
        subtitle="Joins this workspace — you'll see and share the same prototypes, tests, and sessions as everyone else here."
        submitLabel="Sign up" busyLabel="Creating…"
        endpoint="/api/auth/signup" onDone={onDone}
      />
    );
  }
  return (
    <AuthForm
      header={tabs}
      title="Sign in to TRACE"
      subtitle="This deployment requires an account."
      submitLabel="Sign in" busyLabel="Signing in…"
      endpoint="/api/auth/login" onDone={onDone}
      footer={<button type="button" style={authLinkBtn} onClick={() => setMode("forgot")}>Forgot password?</button>}
    />
  );
}

// Email-only — always shows the same confirmation regardless of whether the
// address matched an account (the server already returns an identical
// response either way; this screen just doesn't add any client-side signal
// on top of that).
function ForgotPasswordScreen({ onBack }) {
  const [email, setEmail] = useState("");
  const [sent, setSent] = useState(false);
  const [busy, setBusy] = useState(false);
  const submit = async (e) => {
    e.preventDefault();
    setBusy(true);
    await fetch("/api/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) }).catch(() => {});
    setSent(true);
    setBusy(false);
  };
  return (
    <AuthShell>
      <h2 style={{ fontSize: 20, marginBottom: 4 }}>Reset your password</h2>
      {sent ? (
        <p style={{ color: C.dim, fontSize: 13, marginTop: 12 }}>If an account exists for that email, we've sent a link to reset your password. Check your inbox.</p>
      ) : (
        <form onSubmit={submit}>
          <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>Enter your account's email and we'll send a link to set a new password.</p>
          <label style={{ fontSize: 12, color: C.dim }}>Email
            <input style={authInputStyle} type="email" required autoFocus value={email} onChange={(e) => setEmail(e.target.value)} />
          </label>
          <button type="submit" style={{ ...primaryBtn(), marginTop: 16, width: "100%", opacity: busy ? 0.5 : 1 }} disabled={busy}>{busy ? "Sending…" : "Send reset link"}</button>
        </form>
      )}
      <div style={{ marginTop: 14, textAlign: "center" }}>
        <button type="button" style={authLinkBtn} onClick={onBack}>Back to sign in</button>
      </div>
    </AuthShell>
  );
}

// Reached via a ?resetToken= link (see parseResetToken/App()) — the token
// itself identifies the account, so this only ever asks for a new password.
function ResetPasswordScreen({ token, onDone }) {
  const [newPassword, setNewPassword] = useState("");
  const [err, setErr] = useState("");
  const [busy, setBusy] = useState(false);
  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    try {
      const res = await fetch("/api/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, newPassword }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Something went wrong.");
      onDone();
    } catch (e2) {
      setErr(e2.message);
      setBusy(false);
    }
  };
  return (
    <AuthShell>
      <form onSubmit={submit}>
        <h2 style={{ fontSize: 20, marginBottom: 4 }}>Set a new password</h2>
        <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>At least 8 characters.</p>
        <label style={{ fontSize: 12, color: C.dim }}>New password
          <input style={authInputStyle} type="password" required autoFocus minLength={8} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
        </label>
        {err && <div style={{ color: C.red, fontSize: 12, marginTop: 10 }}>{err}</div>}
        <button type="submit" style={{ ...primaryBtn(), marginTop: 16, width: "100%", opacity: busy ? 0.5 : 1 }} disabled={busy}>{busy ? "Saving…" : "Set new password"}</button>
      </form>
    </AuthShell>
  );
}

// Enterprise-mode Settings panel: lists this shared workspace's team,
// lets a workspace admin add or remove someone and pick their role, and any
// signed-in account can sign out. No email-sending infra for invites — an
// admin sets a new teammate's initial password directly (they can reset it
// themselves later via the sign-in screen's "Forgot password?").
function TeamPanel({ authUser }) {
  const [users, setUsers] = useState([]);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [role, setRole] = useState("member");
  const [err, setErr] = useState("");
  const [busy, setBusy] = useState(false);
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };
  const isAdmin = authUser?.role === "admin";
  const adminCount = users.filter((u) => u.role === "admin").length;

  const loadTeam = () => fetch("/api/auth/team").then((r) => r.json()).then((d) => setUsers(d.users || [])).catch(() => {});
  useEffect(() => { loadTeam(); }, []);

  const addTeammate = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    try {
      const res = await fetch("/api/auth/team", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, role }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't add teammate");
      setUsers(data.users || []);
      setEmail(""); setPassword(""); setRole("member");
    } catch (e2) { setErr(e2.message); }
    setBusy(false);
  };
  const removeTeammate = async (u) => {
    if (!window.confirm(`Remove ${u.email} from the team?`)) return;
    try {
      const res = await fetch(`/api/auth/team/${u.id}`, { method: "DELETE" });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't remove teammate");
      setUsers(data.users || []);
    } catch (e2) { setErr(e2.message); }
  };
  const signOut = async () => {
    await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
    window.location.reload();
  };

  return (
    <div style={{ ...panelBox(), marginTop: 20 }}>
      <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Team</div>
      <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>
        Everyone below shares this one workspace — same prototypes, tests, and sessions, not separate per-person data. Only admins can add or remove teammates.
      </div>
      <div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 14 }}>
        {users.map((u) => {
          const isSelf = authUser?.id === u.id;
          const isLastAdmin = u.role === "admin" && adminCount <= 1;
          return (
            <div key={u.id} style={{ fontSize: 13, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
              <span>{u.email}{isSelf ? <span style={{ color: C.faint }}> (you)</span> : ""} <span style={{ color: C.faint, fontSize: 11 }}>· {u.role}</span></span>
              {isAdmin && !isSelf && !isLastAdmin && (
                <button style={{ ...dangerBtn(), padding: "3px 10px", fontSize: 11 }} onClick={() => removeTeammate(u)}>Remove</button>
              )}
            </div>
          );
        })}
      </div>
      {isAdmin && (
        <form onSubmit={addTeammate}>
          <label style={{ fontSize: 12, color: C.dim }}>Add a teammate — email
            <input style={inp} type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
          </label>
          <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 8 }}>Initial password <span style={{ color: C.faint }}>(at least 8 characters — share it with them directly, or they can reset it via "Forgot password?")</span>
            <input style={inp} type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
          </label>
          <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 8 }}>Role
            <select style={inp} value={role} onChange={(e) => setRole(e.target.value)}>
              <option value="member">Member</option>
              <option value="admin">Admin</option>
            </select>
          </label>
          {err && <div style={{ color: C.red, fontSize: 12, marginTop: 10 }}>{err}</div>}
          <button type="submit" style={{ ...primaryBtn(), marginTop: 12, opacity: busy ? 0.5 : 1 }} disabled={busy}>{busy ? "Adding…" : "Add teammate"}</button>
        </form>
      )}
      <div style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${C.line}` }}>
        <button style={ghostBtn()} onClick={signOut}>Sign out{authUser?.email ? ` (${authUser.email})` : ""}</button>
      </div>
    </div>
  );
}

// Enterprise-mode Settings panel: lets a signed-in user update their own
// password without any admin involvement.
function ChangePasswordPanel() {
  const [currentPassword, setCurrentPassword] = useState("");
  const [newPassword, setNewPassword] = useState("");
  const [err, setErr] = useState("");
  const [justSaved, setJustSaved] = useState(false);
  const [busy, setBusy] = useState(false);
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };

  const submit = async (e) => {
    e.preventDefault();
    setErr(""); setBusy(true);
    try {
      const res = await fetch("/api/auth/change-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword, newPassword }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't change password");
      setCurrentPassword(""); setNewPassword("");
      setJustSaved(true);
      setTimeout(() => setJustSaved(false), 1400);
    } catch (e2) { setErr(e2.message); }
    setBusy(false);
  };

  return (
    <div style={{ ...panelBox(), marginTop: 20 }}>
      <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Change your password</div>
      <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>Signs out every other session for your account — this one stays signed in.</div>
      <form onSubmit={submit}>
        <label style={{ fontSize: 12, color: C.dim }}>Current password
          <input style={inp} type="password" required value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} />
        </label>
        <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 8 }}>New password <span style={{ color: C.faint }}>(at least 8 characters)</span>
          <input style={inp} type="password" required minLength={8} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
        </label>
        {err && <div style={{ color: C.red, fontSize: 12, marginTop: 10 }}>{err}</div>}
        <button type="submit" style={{ ...primaryBtn(), marginTop: 12, transition: "background-color 0.15s", ...(justSaved ? { background: C.green } : {}), opacity: busy ? 0.5 : 1 }} disabled={busy}>{justSaved ? "Saved ✓" : busy ? "Saving…" : "Change password"}</button>
      </form>
    </div>
  );
}

// ================= settings =================
function SettingsPanel({ theme, onSetTheme, enterprise, authUser }) {
  const [token, setToken] = useState(() => loadSettings().figmaToken || "");
  const [justSaved, setJustSaved] = useState(false);
  const [meta, setMeta] = useState(null);
  const [adminToken, setAdminTokenField] = useState(() => getAdminToken());
  const [adminJustSaved, setAdminJustSaved] = useState(false);
  const [aiProvider, setAiProviderState] = useState(() => loadSettings().aiProvider || "anthropic");
  const aiKeyField = (provider) => (AI_PROVIDERS.find((p) => p.key === provider) || AI_PROVIDERS[0]).keyField;
  const [aiKey, setAiKey] = useState(() => loadSettings()[aiKeyField(aiProvider)] || "");
  const [localEndpoint, setLocalEndpoint] = useState(() => loadSettings().localEndpoint || "");
  const [localModel, setLocalModel] = useState(() => loadSettings().localModel || "");
  const [aiJustSaved, setAiJustSaved] = useState(false);
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };

  useEffect(() => { fetch("/api/meta").then((r) => r.json()).then(setMeta).catch(() => {}); }, []);

  const saveToken = () => {
    saveSettings({ figmaToken: token.trim() });
    setJustSaved(true);
    setTimeout(() => setJustSaved(false), 1400);
  };
  const clearToken = () => { setToken(""); saveSettings({ figmaToken: "" }); };
  const saveAdminToken = () => {
    setAdminToken(adminToken.trim());
    setAdminJustSaved(true);
    setTimeout(() => setAdminJustSaved(false), 1400);
  };
  const clearAdminToken = () => { setAdminTokenField(""); setAdminToken(""); };

  // Each provider keeps its own key in storage so switching the dropdown
  // back and forth doesn't clobber whichever one isn't currently shown.
  const setAiProvider = (provider) => {
    setAiProviderState(provider);
    setAiKey(loadSettings()[aiKeyField(provider)] || "");
    saveSettings({ aiProvider: provider });
  };
  const saveAiKey = () => {
    if (aiProvider === "local") saveSettings({ localEndpoint: localEndpoint.trim(), localModel: localModel.trim(), localApiKey: aiKey.trim() });
    else saveSettings({ [aiKeyField(aiProvider)]: aiKey.trim() });
    setAiJustSaved(true);
    setTimeout(() => setAiJustSaved(false), 1400);
  };
  const clearAiKey = () => {
    setAiKey("");
    if (aiProvider === "local") { setLocalEndpoint(""); setLocalModel(""); saveSettings({ localEndpoint: "", localModel: "", localApiKey: "" }); }
    else saveSettings({ [aiKeyField(aiProvider)]: "" });
  };

  return (
    <div style={{ maxWidth: 480 }}>
      <h2 style={{ fontSize: 20, marginBottom: 20 }}>Settings</h2>

      <div style={{ ...panelBox(), marginBottom: 20 }}>
        <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Appearance</div>
        <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>Applies immediately and is remembered on this device.</div>
        <div style={{ display: "flex", gap: 8 }}>
          <button style={theme === "dark" ? primaryBtn() : ghostBtn()} onClick={() => onSetTheme("dark")}>Dark</button>
          <button style={theme === "light" ? primaryBtn() : ghostBtn()} onClick={() => onSetTheme("light")}>Light</button>
        </div>
      </div>

      <div style={panelBox()}>
        <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Figma personal access token</div>
        <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>
          Saved on this device only (never sent anywhere except to Figma's API when you import). Once saved, the Import tab's token field is pre-filled automatically.
        </div>
        <input style={inp} type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="figd_..." />
        <div style={{ display: "flex", gap: 10, marginTop: 12 }}>
          <button style={{ ...primaryBtn(), transition: "background-color 0.15s", ...(justSaved ? { background: C.green } : {}) }} onClick={saveToken}>{justSaved ? "Saved ✓" : "Save token"}</button>
          <button style={ghostBtn()} onClick={clearToken}>Clear</button>
        </div>
      </div>

      {!window.traceDesktop && !enterprise && (
        <div style={{ ...panelBox(), marginTop: 20 }}>
          <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Admin token</div>
          <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>
            Required for admin actions (creating/deleting tests, imports, AI analysis, sharing) so a remote tester's link can't reach them too. Printed to the terminal when you run <code style={{ color: C.cyan }}>npm start</code> — paste it here once. Saved on this device only.
          </div>
          <input style={inp} type="password" value={adminToken} onChange={(e) => setAdminTokenField(e.target.value)} placeholder="paste the token from the terminal" />
          <div style={{ display: "flex", gap: 10, marginTop: 12 }}>
            <button style={{ ...primaryBtn(), transition: "background-color 0.15s", ...(adminJustSaved ? { background: C.green } : {}) }} onClick={saveAdminToken}>{adminJustSaved ? "Saved ✓" : "Save token"}</button>
            <button style={ghostBtn()} onClick={clearAdminToken}>Clear</button>
          </div>
        </div>
      )}

      {enterprise && <ChangePasswordPanel />}
      {enterprise && <TeamPanel authUser={authUser} />}

      <div style={{ ...panelBox(), marginTop: 20 }}>
        <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>AI trend analysis</div>
        <div style={{ fontSize: 12, color: C.dim, marginBottom: 12 }}>
          Saved on this device only. Sent directly to whichever provider you pick below when you click "Analyze trends" on a test's results — never stored on our servers.
        </div>
        <div style={{ display: "flex", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
          {AI_PROVIDERS.map((p) => (
            <button key={p.key} style={aiProvider === p.key ? primaryBtn() : ghostBtn()} onClick={() => setAiProvider(p.key)}>{p.label}</button>
          ))}
        </div>
        {/* Specifically an API key, not the provider's regular consumer
            login — easy to conflate since none of them call it out on their
            own sign-in page, and the two aren't interchangeable (a
            claude.ai/chatgpt.com/gemini session grants no API access, and API
            usage is billed separately from a consumer subscription). "local"
            skips this entirely — it's not a public API with an account behind
            it, just whatever's listening on the endpoint below. */}
        {aiProvider !== "local" && (
          <div style={{ fontSize: 12, color: C.dim, marginBottom: 8 }}>
            {aiProvider === "openai" && <>An OpenAI <strong>API key</strong> — not your ChatGPT login. Generate one at <a href="https://platform.openai.com/api-keys" target="_blank" rel="noreferrer" style={{ color: C.dim }}>platform.openai.com/api-keys</a> (billed separately from a ChatGPT subscription).</>}
            {aiProvider === "gemini" && <>A Google AI <strong>API key</strong> — not your Google account login. Generate one at <a href="https://aistudio.google.com/apikey" target="_blank" rel="noreferrer" style={{ color: C.dim }}>aistudio.google.com/apikey</a> (billed separately from any Google subscription).</>}
            {aiProvider === "anthropic" && <>An Anthropic <strong>API key</strong> — not your claude.ai login. Generate one at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noreferrer" style={{ color: C.dim }}>console.anthropic.com/settings/keys</a> (billed separately from a Claude subscription).</>}
          </div>
        )}
        {aiProvider === "local" && (
          <div style={{ fontSize: 12, color: C.dim, marginBottom: 8 }}>
            Points at any OpenAI-compatible chat endpoint on your machine or network (e.g. Ollama, LM Studio) — nothing leaves it. An API key is only needed if that endpoint requires one.
          </div>
        )}
        {aiProvider === "local" ? (
          <>
            <input style={inp} type="text" value={localEndpoint} onChange={(e) => setLocalEndpoint(e.target.value)} placeholder="http://localhost:11434/v1/chat/completions" />
            <input style={{ ...inp, marginTop: 8 }} type="text" value={localModel} onChange={(e) => setLocalModel(e.target.value)} placeholder="Model name, e.g. llama3.1" />
            <input style={{ ...inp, marginTop: 8 }} type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder="API key (optional)" />
          </>
        ) : (
          <input style={inp} type="password" value={aiKey} onChange={(e) => setAiKey(e.target.value)} placeholder={aiProvider === "openai" ? "sk-..." : aiProvider === "gemini" ? "AIza..." : "sk-ant-..."} />
        )}
        <div style={{ display: "flex", gap: 10, marginTop: 12 }}>
          <button style={{ ...primaryBtn(), transition: "background-color 0.15s", ...(aiJustSaved ? { background: C.green } : {}) }} onClick={saveAiKey}>{aiJustSaved ? "Saved ✓" : "Save"}</button>
          <button style={ghostBtn()} onClick={clearAiKey}>Clear</button>
        </div>
      </div>

      {meta && (
        <div style={{ marginTop: 24, fontSize: 11, color: C.faint }}>
          TRACE v{meta.version}{meta.author ? ` · ${meta.author}` : ""}
          {meta.homepage && <> · <a href={meta.homepage} target="_blank" rel="noreferrer" style={{ color: C.faint }}>GitHub</a></>}
        </div>
      )}
    </div>
  );
}

// ================= create test =================
// Quick-toggle common intake questions. Each becomes a preQuestions[] entry
// shaped like { text, type, ...constraints } — type drives which control
// CaptureSurface's presurvey step renders (see renderPreAnswerInput): a
// free-text textarea for "text", a bounded number field for "number", a
// <select> for "select". Custom questions typed below are always "text".
const DEMOGRAPHIC_QUESTIONS = [
  { key: "age", label: "Age", text: "What is your age?", type: "number", min: 1, max: 120 },
  { key: "gender", label: "Gender", text: "What is your gender?", type: "select", options: ["Female", "Male", "Non-binary", "Prefer not to say"] },
  { key: "occupation", label: "Occupation", text: "What is your occupation?", type: "text" },
];
function CreateTest({ manifest, onCreate, onCancel }) {
  const [name, setName] = useState(""); const [task, setTask] = useState("");
  const [goalFrame, setGoalFrame] = useState(manifest.frames.find((f) => f.id !== manifest.entryFrameId)?.id || manifest.frames[0].id);
  const [demographics, setDemographics] = useState(() => new Set());
  const [preQuestions, setPreQuestions] = useState([]);
  const [commentEnabled, setCommentEnabled] = useState(false);
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };
  const updateQuestion = (i, v) => setPreQuestions((qs) => qs.map((q, j) => (j === i ? v : q)));
  const removeQuestion = (i) => setPreQuestions((qs) => qs.filter((_, j) => j !== i));
  const toggleDemographic = (key) => setDemographics((s) => {
    const next = new Set(s);
    next.has(key) ? next.delete(key) : next.add(key);
    return next;
  });
  const allPreQuestions = () => [
    ...DEMOGRAPHIC_QUESTIONS.filter((d) => demographics.has(d.key)).map(({ key, label, ...q }) => q),
    ...preQuestions.map((q) => q.trim()).filter(Boolean).map((text) => ({ text, type: "text" })),
  ];
  return (
    <div style={{ maxWidth: 480 }}>
      <h2 style={{ fontSize: 20, marginBottom: 4 }}>New test</h2>
      <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>Hosting: <span style={{ color: C.text }}>{manifest.name}</span>. Set the task and the goal screen that counts as success.</p>
      <label style={{ fontSize: 12, color: C.dim }}>Test name<input style={inp} value={name} onChange={(e) => setName(e.target.value)} placeholder="Find profile - v1" /></label>
      <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16 }}>Task shown to the tester<input style={inp} value={task} onChange={(e) => setTask(e.target.value)} placeholder="Go find the profile page" /></label>
      <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16 }}>Goal screen (success = tester reaches this)
        <select style={inp} value={goalFrame} onChange={(e) => setGoalFrame(e.target.value)}>{manifest.frames.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}</select>
      </label>

      <div style={{ fontSize: 12, color: C.dim, marginTop: 20, marginBottom: 8 }}>
        Common questions <span style={{ color: C.faint }}>(optional quick-add)</span>
      </div>
      <div style={{ display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 8 }}>
        {DEMOGRAPHIC_QUESTIONS.map((d) => (
          <label key={d.key} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: C.dim, cursor: "pointer" }}>
            <input type="checkbox" checked={demographics.has(d.key)} onChange={() => toggleDemographic(d.key)} />
            {d.label}
          </label>
        ))}
      </div>

      <div style={{ fontSize: 12, color: C.dim, marginTop: 16, marginBottom: 8 }}>
        Custom pre-test questions <span style={{ color: C.faint }}>(optional — shown to the tester before they start)</span>
      </div>
      {preQuestions.map((q, i) => (
        <div key={i} style={{ display: "flex", gap: 8, marginBottom: 8 }}>
          <input style={{ ...inp, marginTop: 0 }} value={q} onChange={(e) => updateQuestion(i, e.target.value)} placeholder={`Question ${i + 1}`} />
          <button style={dangerBtn()} onClick={() => removeQuestion(i)}>✕</button>
        </div>
      ))}
      <button style={ghostBtn()} onClick={() => setPreQuestions((qs) => [...qs, ""])}>+ Add question</button>

      <label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 20, fontSize: 13, color: C.dim, cursor: "pointer" }}>
        <input type="checkbox" checked={commentEnabled} onChange={(e) => setCommentEnabled(e.target.checked)} />
        Let the tester leave a comment after finishing
      </label>

      <div style={{ display: "flex", gap: 10, marginTop: 24 }}>
        <button style={primaryBtn()} onClick={() => name.trim() && onCreate({ name: name.trim(), task, goalFrame, preQuestions: allPreQuestions(), commentEnabled })}>Create test</button>
        <button style={ghostBtn()} onClick={onCancel}>Cancel</button>
      </div>
    </div>
  );
}

// ================= create A/B test =================
// Deliberately a separate component from CreateTest, not a variant-aware
// version of it — keeps the regular test-creation flow completely untouched
// (see the "A/B Testing" nav item's own reasoning in TestPicker/App). Task,
// goal, pre-questions and comment toggle are identical fields to CreateTest;
// the only new concept is the variant slot itself.
function CreateABTest({ manifest, onCreate, onCancel }) {
  const [name, setName] = useState(""); const [task, setTask] = useState("");
  const [goalFrame, setGoalFrame] = useState(manifest.frames.find((f) => f.id !== manifest.entryFrameId)?.id || manifest.frames[0].id);
  const [demographics, setDemographics] = useState(() => new Set());
  const [preQuestions, setPreQuestions] = useState([]);
  const [commentEnabled, setCommentEnabled] = useState(false);
  const inp = { width: "100%", background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "10px 12px", color: C.text, fontSize: 14, marginTop: 6, boxSizing: "border-box" };
  const updateQuestion = (i, v) => setPreQuestions((qs) => qs.map((q, j) => (j === i ? v : q)));
  const removeQuestion = (i) => setPreQuestions((qs) => qs.filter((_, j) => j !== i));
  const toggleDemographic = (key) => setDemographics((s) => {
    const next = new Set(s);
    next.has(key) ? next.delete(key) : next.add(key);
    return next;
  });
  const allPreQuestions = () => [
    ...DEMOGRAPHIC_QUESTIONS.filter((d) => demographics.has(d.key)).map(({ key, label, ...q }) => q),
    ...preQuestions.map((q) => q.trim()).filter(Boolean).map((text) => ({ text, type: "text" })),
  ];

  // "Vary the entry screen" is just the common case of "vary any screen" —
  // both end up setting the same canonicalFrameId, so there's one underlying
  // mechanism here, not two. Entry mode just skips showing the frame picker.
  const entryFrameId = manifest.entryFrameId || manifest.frames[0].id;
  const [varyMode, setVaryMode] = useState("entry"); // "entry" | "specific"
  const [canonicalFrame, setCanonicalFrame] = useState(entryFrameId);
  const [variantFrameIds, setVariantFrameIds] = useState([]); // additional alternates beyond the canonical frame
  const updateVariant = (i, v) => setVariantFrameIds((ids) => ids.map((old, j) => (j === i ? v : old)));
  const removeVariant = (i) => setVariantFrameIds((ids) => ids.filter((_, j) => j !== i));
  const canonicalName = manifest.frames.find((f) => f.id === canonicalFrame)?.name || canonicalFrame;
  const validVariantIds = [...new Set(variantFrameIds.filter(Boolean))];
  const canCreate = name.trim() && validVariantIds.length > 0;

  const create = () => {
    if (!canCreate) return;
    const options = [
      { frameId: canonicalFrame, label: "Variant A" },
      // B, C, D... — plenty of headroom for however many alternates get added.
      ...validVariantIds.map((frameId, i) => ({ frameId, label: `Variant ${String.fromCharCode(66 + i)}` })),
    ];
    onCreate({
      name: name.trim(), task, goalFrame, preQuestions: allPreQuestions(), commentEnabled,
      variantSlot: { canonicalFrameId: canonicalFrame, options },
    });
  };

  return (
    <div style={{ maxWidth: 480 }}>
      <h2 style={{ fontSize: 20, marginBottom: 4 }}>New A/B test</h2>
      <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>Hosting: <span style={{ color: C.text }}>{manifest.name}</span>. Testers get a random variant of one screen; the task and goal are the same for everyone.</p>
      <label style={{ fontSize: 12, color: C.dim }}>Test name<input style={inp} value={name} onChange={(e) => setName(e.target.value)} placeholder="Homepage layout - v1" /></label>
      <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16 }}>Task shown to the tester<input style={inp} value={task} onChange={(e) => setTask(e.target.value)} placeholder="Go find the profile page" /></label>
      <label style={{ fontSize: 12, color: C.dim, display: "block", marginTop: 16 }}>Goal screen (success = tester reaches this)
        <select style={inp} value={goalFrame} onChange={(e) => setGoalFrame(e.target.value)}>{manifest.frames.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}</select>
      </label>

      <div style={{ fontSize: 12, color: C.dim, marginTop: 20, marginBottom: 8 }}>Which screen varies?</div>
      <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
        <button style={varyMode === "entry" ? primaryBtn() : ghostBtn()} onClick={() => { setVaryMode("entry"); setCanonicalFrame(entryFrameId); }}>Entry screen</button>
        <button style={varyMode === "specific" ? primaryBtn() : ghostBtn()} onClick={() => setVaryMode("specific")}>A specific screen</button>
      </div>
      {varyMode === "specific" && (
        <select style={inp} value={canonicalFrame} onChange={(e) => setCanonicalFrame(e.target.value)}>
          {manifest.frames.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
        </select>
      )}

      <div style={{ fontSize: 12, color: C.dim, marginTop: 20, marginBottom: 8 }}>
        Alternate versions of "{canonicalName}" <span style={{ color: C.faint }}>(pick 1 or more other frames)</span>
      </div>
      {variantFrameIds.map((id, i) => (
        <div key={i} style={{ display: "flex", gap: 8, marginBottom: 8 }}>
          <select style={{ ...inp, marginTop: 0 }} value={id} onChange={(e) => updateVariant(i, e.target.value)}>
            <option value="">Select a frame…</option>
            {manifest.frames.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
          </select>
          <button style={dangerBtn()} onClick={() => removeVariant(i)}>✕</button>
        </div>
      ))}
      <button style={ghostBtn()} onClick={() => setVariantFrameIds((ids) => [...ids, ""])}>+ Add variant</button>

      {validVariantIds.length > 0 && (
        <div style={{ fontSize: 12, color: C.dim, marginTop: 12, padding: 10, background: C.panel2, borderRadius: 8, lineHeight: 1.5 }}>
          Make sure your alternate frames have the same hotspot destinations as your original screen so testers can actually complete the flow.
        </div>
      )}

      <div style={{ fontSize: 12, color: C.dim, marginTop: 20, marginBottom: 8 }}>
        Common questions <span style={{ color: C.faint }}>(optional quick-add)</span>
      </div>
      <div style={{ display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 8 }}>
        {DEMOGRAPHIC_QUESTIONS.map((d) => (
          <label key={d.key} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: C.dim, cursor: "pointer" }}>
            <input type="checkbox" checked={demographics.has(d.key)} onChange={() => toggleDemographic(d.key)} />
            {d.label}
          </label>
        ))}
      </div>

      <div style={{ fontSize: 12, color: C.dim, marginTop: 16, marginBottom: 8 }}>
        Custom pre-test questions <span style={{ color: C.faint }}>(optional — shown to the tester before they start)</span>
      </div>
      {preQuestions.map((q, i) => (
        <div key={i} style={{ display: "flex", gap: 8, marginBottom: 8 }}>
          <input style={{ ...inp, marginTop: 0 }} value={q} onChange={(e) => updateQuestion(i, e.target.value)} placeholder={`Question ${i + 1}`} />
          <button style={dangerBtn()} onClick={() => removeQuestion(i)}>✕</button>
        </div>
      ))}
      <button style={ghostBtn()} onClick={() => setPreQuestions((qs) => [...qs, ""])}>+ Add question</button>

      <label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 20, fontSize: 13, color: C.dim, cursor: "pointer" }}>
        <input type="checkbox" checked={commentEnabled} onChange={(e) => setCommentEnabled(e.target.checked)} />
        Let the tester leave a comment after finishing
      </label>

      <div style={{ display: "flex", gap: 10, marginTop: 24 }}>
        <button style={{ ...primaryBtn(), opacity: canCreate ? 1 : 0.5 }} onClick={create}>Create A/B test</button>
        <button style={ghostBtn()} onClick={onCancel}>Cancel</button>
      </div>
    </div>
  );
}

// ================= test picker (AI Insights / First Click nav entries) =================
// Both "AI Insights" and "First Click" operate on one specific test's
// sessions, not the app as a whole — so unlike Tests/Settings, picking either
// from the sidebar can't jump straight to a page; it has to ask which test
// first. Landing here just deep-links into that test's normal Results page
// with the relevant thing already switched on (see initialMode/autoAnalyze
// on Dashboard) rather than duplicating the heatmap/analysis UI a second time.
// onCreateNew (optional): when given, every prototype folder is listed (not
// just ones that already have a qualifying test) with its own "+ New..."
// button — used by the A/B panel, which needs a way to create the first A/B
// test for a folder. AI Insights/First Click don't pass it, so they keep the
// original behavior of only showing folders that already have something to pick.
function TestPicker({ prototypes, title, subtitle, onPick, onCreateNew, createLabel }) {
  const folders = onCreateNew ? prototypes : prototypes.filter((p) => p.tests.length > 0);
  return (
    <div>
      <h2 style={{ fontSize: 20, marginBottom: 4 }}>{title}</h2>
      <p style={{ color: C.dim, fontSize: 13, marginBottom: 20 }}>{subtitle}</p>
      {folders.length === 0 && <div style={{ ...panelBox(), color: C.dim }}>No tests yet — create one from the Tests tab first.</div>}
      {folders.map((p) => (
        <div key={p.id} style={{ marginBottom: 22 }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
            <div style={{ fontSize: 14, fontWeight: 600 }}>{p.manifest.name}</div>
            {onCreateNew && <button style={{ ...primaryBtn(), padding: "6px 12px" }} onClick={() => onCreateNew(p.id)}>{createLabel || "+ New test"}</button>}
          </div>
          {p.tests.length === 0 && onCreateNew && <div style={{ ...panelBox(), color: C.dim, marginBottom: 10 }}>None yet for this prototype.</div>}
          <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
            {p.tests.map((t) => {
              const ss = p.sessions[t.id] || [];
              return (
                <button key={t.id} onClick={() => onPick(p.id, t.id)}
                  style={{ ...panelBox(), textAlign: "left", cursor: "pointer", width: "100%", display: "flex", justifyContent: "space-between", alignItems: "center", font: "inherit", color: "inherit" }}>
                  <div>
                    <div style={{ fontSize: 15, fontWeight: 600 }}>{t.name}</div>
                    <div style={{ fontSize: 12, color: C.dim, marginTop: 3 }}>{ss.length} session{ss.length === 1 ? "" : "s"}</div>
                  </div>
                  <span style={{ color: C.cyan, fontSize: 15 }}>→</span>
                </button>
              );
            })}
          </div>
        </div>
      ))}
    </div>
  );
}

// ================= test page (share link + results, one destination) =================
// Reached by clicking "Run" on a test in the Tests tab — the one page for
// everything about that specific test: the share link (so a remote tester
// can take it), and the stats/heatmap/session-log dashboard. A separate
// "Run locally" button (see App) still exists for actually taking the test
// yourself, same as the old "Run" button used to.
function TestPage({ proto, test, sessions, tunnel, tunnelBusy, tunnelError, startSharing, stopSharing, copyLink, justCopied, initialMode, autoAnalyze, alwaysOn }) {
  // Enterprise mode (alwaysOn): this deployment already sits at a real,
  // permanent URL, so the "share link" is just that origin + query params —
  // no tunnel to start/stop, nothing that can go stale on a restart like the
  // classic-mode Cloudflare tunnel link can.
  const shareLink = alwaysOn
    ? `${window.location.origin}/app/?proto=${encodeURIComponent(proto.id)}&test=${test.id}`
    : (tunnel.url ? `${tunnel.url}/?proto=${encodeURIComponent(proto.id)}&test=${test.id}` : "");
  const copied = justCopied?.prototypeId === proto.id && justCopied?.testId === test.id;
  return (
    <div>
      <div style={{ ...panelBox(), marginBottom: 20 }}>
        <div style={{ fontSize: 11, color: C.cyan, letterSpacing: 1, fontFamily: "ui-monospace,monospace", marginBottom: 10 }}>SHARE THIS TEST</div>
        {!alwaysOn && !tunnel.url && !tunnelBusy && !tunnelError && (
          <button style={primaryBtn()} onClick={startSharing}>Start sharing</button>
        )}
        {!alwaysOn && tunnelBusy && <div style={{ color: C.dim, fontSize: 13 }}>Starting tunnel… this can take a few seconds the first time (downloads a small helper).</div>}
        {!alwaysOn && !tunnelBusy && tunnelError && (
          <div>
            <div style={{ color: C.red, marginBottom: 8, fontSize: 13 }}>{tunnelError}</div>
            <button style={ghostBtn()} onClick={startSharing}>Retry</button>
          </div>
        )}
        {!tunnelBusy && !tunnelError && shareLink && (
          <div>
            <div style={{ color: C.dim, marginBottom: 8, fontSize: 13 }}>Send this link to a remote tester — they'll land straight in this test, nothing else:</div>
            <div style={{ display: "flex", gap: 8 }}>
              <input readOnly value={shareLink} onFocus={(e) => e.target.select()}
                style={{ flex: 1, background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "8px 10px", color: C.text, fontSize: 12, fontFamily: "ui-monospace,monospace" }} />
              <button
                style={{ ...ghostBtn(), transition: "background-color 0.15s, border-color 0.15s, color 0.15s", ...(copied ? { background: C.green, borderColor: C.green, color: C.onAccent } : {}) }}
                onClick={() => copyLink(proto.id, test.id, shareLink)}>
                {copied ? "Copied ✓" : "Copy"}
              </button>
            </div>
            {!alwaysOn && (
              <div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
                <span style={{ color: C.faint, fontSize: 12 }}>One shared link for the whole app — stopping it disables every test's share link, not just this one.</span>
                <button style={{ ...dangerBtn(), whiteSpace: "nowrap" }} onClick={stopSharing}>Stop sharing</button>
              </div>
            )}
          </div>
        )}
      </div>
      <Dashboard test={test} sessions={sessions} manifest={proto.manifest} initialMode={initialMode} autoAnalyze={autoAnalyze} />
    </div>
  );
}

// ================= A/B results =================
// Reached from the "A/B Testing" sidebar entry's own test picker. Deliberately
// its own page rather than an extra mode bolted onto Dashboard/TestPage — a
// comparison strip up top (session count/success/time/clicks per variant,
// for an at-a-glance read of which is winning), then one full Dashboard per
// variant behind a tab, each scoped to just that variant's sessions. This
// gets every existing Dashboard capability (heatmap modes, downloads,
// Analyze trends) for free per variant instead of building a parallel
// results UI from scratch.
function ABResultsPage({ proto, test, sessions, tunnel, tunnelBusy, tunnelError, startSharing, stopSharing, copyLink, justCopied, onRunLocally, alwaysOn }) {
  const manifest = proto.manifest;
  const shareLink = alwaysOn
    ? `${window.location.origin}/app/?proto=${encodeURIComponent(proto.id)}&test=${test.id}`
    : (tunnel.url ? `${tunnel.url}/?proto=${encodeURIComponent(proto.id)}&test=${test.id}` : "");
  const copied = justCopied?.prototypeId === proto.id && justCopied?.testId === test.id;
  const options = test.variantSlot.options;
  const [activeVariant, setActiveVariant] = useState(options[0].frameId);
  const goalName = manifest.frames.find((f) => f.id === test.goalFrame)?.name || test.goalFrame;
  // Deliberately over the FULL, unfiltered `sessions` (every variant), not
  // whichever one is active below — a per-variant Dashboard only ever sees
  // its own variant's sessions, so there'd be nothing to contrast if this
  // used that instead. This is what actually lets "Which variant did you
  // see?" in the prompt resolve to more than one answer.
  const { analyzing, analysis, analyzeError, runAnalysis } = useAnalyzeTrends(test, sessions, manifest, false);

  return (
    <div>
      <div style={{ ...panelBox(), marginBottom: 20 }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
          <div style={{ fontSize: 11, color: C.cyan, letterSpacing: 1, fontFamily: "ui-monospace,monospace" }}>SHARE THIS TEST</div>
          {/* A/B tests don't live on the Tests tab (see the filter there), so
              this is the only place to reach "Run locally" for one — unlike
              a regular test, which gets that button on its Tests-tab card. */}
          <button style={ghostBtn()} onClick={onRunLocally}>Run locally</button>
        </div>
        {!alwaysOn && !tunnel.url && !tunnelBusy && !tunnelError && (
          <button style={primaryBtn()} onClick={startSharing}>Start sharing</button>
        )}
        {!alwaysOn && tunnelBusy && <div style={{ color: C.dim, fontSize: 13 }}>Starting tunnel… this can take a few seconds the first time (downloads a small helper).</div>}
        {!alwaysOn && !tunnelBusy && tunnelError && (
          <div>
            <div style={{ color: C.red, marginBottom: 8, fontSize: 13 }}>{tunnelError}</div>
            <button style={ghostBtn()} onClick={startSharing}>Retry</button>
          </div>
        )}
        {!tunnelBusy && !tunnelError && shareLink && (
          <div>
            <div style={{ color: C.dim, marginBottom: 8, fontSize: 13 }}>Send this link to a remote tester — they'll land straight in this test and get a random variant, nothing else:</div>
            <div style={{ display: "flex", gap: 8 }}>
              <input readOnly value={shareLink} onFocus={(e) => e.target.select()}
                style={{ flex: 1, background: C.panel2, border: `1px solid ${C.line}`, borderRadius: 8, padding: "8px 10px", color: C.text, fontSize: 12, fontFamily: "ui-monospace,monospace" }} />
              <button
                style={{ ...ghostBtn(), transition: "background-color 0.15s, border-color 0.15s, color 0.15s", ...(copied ? { background: C.green, borderColor: C.green, color: C.onAccent } : {}) }}
                onClick={() => copyLink(proto.id, test.id, shareLink)}>
                {copied ? "Copied ✓" : "Copy"}
              </button>
            </div>
            {!alwaysOn && (
              <div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
                <span style={{ color: C.faint, fontSize: 12 }}>One shared link for the whole app — stopping it disables every test's share link, not just this one.</span>
                <button style={{ ...dangerBtn(), whiteSpace: "nowrap" }} onClick={stopSharing}>Stop sharing</button>
              </div>
            )}
          </div>
        )}
      </div>

      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
        <div style={{ fontSize: 11, color: C.cyan, letterSpacing: 1, fontFamily: "ui-monospace,monospace" }}>A/B RESULTS</div>
        {sessions.length > 0 && <button onClick={runAnalysis} disabled={analyzing} style={{ ...ghostBtn(), opacity: analyzing ? 0.6 : 1 }}>{analyzing ? "Analyzing…" : "Analyze trends"}</button>}
      </div>
      <h2 style={{ margin: "2px 0 4px", fontSize: 20 }}>{test.name}</h2>
      <div style={{ fontSize: 13, color: C.dim, marginBottom: 18 }}>Task: {test.task || `Find the ${goalName} page`}</div>
      {(analyzing || analysis || analyzeError) && (
        <div style={{ ...panelBox(), marginBottom: 18 }}>
          <div style={{ fontSize: 12, color: C.dim, marginBottom: 12, fontFamily: "ui-monospace,monospace", letterSpacing: 1 }}>AI TREND ANALYSIS</div>
          {analyzing && <div style={{ color: C.dim, fontSize: 13 }}>Analyzing {sessions.length} session{sessions.length === 1 ? "" : "s"} across both variants…</div>}
          {analyzeError && <div style={{ color: C.red, fontSize: 13 }}>{analyzeError}</div>}
          {analysis && <div style={{ color: C.text, fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{analysis}</div>}
        </div>
      )}

      <div style={{ display: "flex", gap: 16, marginBottom: 24, flexWrap: "wrap" }}>
        {options.map((opt) => {
          const optSessions = sessions.filter((s) => s.variantFrameId === opt.frameId);
          const done = optSessions.filter((s) => s.success);
          const successRate = optSessions.length ? Math.round((done.length / optSessions.length) * 100) : 0;
          const avgTime = done.length ? (done.reduce((a, s) => a + s.durationMs, 0) / done.length / 1000).toFixed(1) : "-";
          const avgClicks = done.length ? (done.reduce((a, s) => a + s.clickCount, 0) / done.length).toFixed(1) : "-";
          const frameName = manifest.frames.find((f) => f.id === opt.frameId)?.name || opt.frameId;
          return (
            <div key={opt.frameId} style={{ ...panelBox(), flex: "1 1 240px" }}>
              <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{opt.label} <span style={{ fontWeight: 400, color: C.dim, fontSize: 12 }}>· {frameName}</span></div>
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
                <Stat label="Sessions" value={optSessions.length} />
                <Stat label="Success" value={`${successRate}%`} accent={C.green} />
                <Stat label="Avg time" value={avgTime === "-" ? "-" : `${avgTime}s`} />
                <Stat label="Avg clicks" value={avgClicks} />
              </div>
            </div>
          );
        })}
      </div>

      <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
        {options.map((opt) => (
          <button key={opt.frameId} style={activeVariant === opt.frameId ? primaryBtn() : ghostBtn()} onClick={() => setActiveVariant(opt.frameId)}>{opt.label}</button>
        ))}
      </div>
      {options.map((opt) => activeVariant === opt.frameId && (
        <Dashboard key={opt.frameId} test={test} sessions={sessions.filter((s) => s.variantFrameId === opt.frameId)} manifest={manifest} initialFrame={opt.frameId} />
      ))}
    </div>
  );
}

// ================= root =================
const DEFAULT_TESTS = [{ name: "Find profile page", task: "Go find the profile page", goalFrame: "profile" }];
// Tabs are peers you switch between directly (Tests / Import / Results) —
// no "back" concept needed among them, same as any tab bar. Create-test and
// Run are launched FROM a specific test in a specific prototype's folder in
// the Tests tab, so they're a sub-flow layered on top of the current tab
// rather than tabs of their own: the app-chrome Back button closes that
// sub-flow (abandoning an in-progress run without saving, same as backing
// out of any unsaved form), and Home always lands on the Tests tab, closing
// any sub-flow along the way.
//
// Every imported Figma prototype is kept (not replaced) as its own "folder"
// — { id, manifest, tests, sessions } — keyed by the prototype's Figma file
// key, so tests/sessions never get mixed up across unrelated prototypes and
// re-importing the same file just refreshes its folder in place (see
// store.applyManifest). `prototypes` mirrors server state exactly; every
// mutation goes server-first then adopts its response, except session saves
// (optimistic — see saveSession).
// A link opened with ?proto=&test= is a remote-tester link (see the
// "Share link" button in the Tests tab / tunnel.js) — that visitor gets
// nothing but the bare test-taking surface, none of the admin UI, since
// they're not us and shouldn't see every other prototype/test/session.
function parseRemoteRun() {
  const p = new URLSearchParams(window.location.search);
  const proto = p.get("proto"), test = p.get("test");
  return proto && test ? { proto, test: Number(test) } : null;
}

function parseResetToken() {
  return new URLSearchParams(window.location.search).get("resetToken");
}

function App() {
  const [prototypes, setPrototypes] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [tab, setTab] = useState("tests"); // "tests" | "import" | "settings" | "ai" | "firstclick"
  // Mirrors C's current theme purely to trigger a re-render — C itself is a
  // mutated-in-place object (see applyTheme), not derived from this state.
  const [theme, setThemeState] = useState(currentTheme);
  const setTheme = (t) => { applyTheme(t); setThemeState(t); };
  // Every press adds another full turn rather than resetting to 0 — since
  // the rotation is animated via CSS transition on `transform`, going from
  // e.g. 360deg to 720deg still animates smoothly forward instead of
  // snapping back to 0deg first (which would happen if this just toggled
  // between two fixed angles).
  const [gearSpins, setGearSpins] = useState(0);
  // null | { name: "create", prototypeId } | { name: "run", prototypeId, testId }
  // (starts the admin's own local run) | { name: "page", prototypeId, testId,
  // initialMode?, autoAnalyze? } (the test's dedicated page — share link +
  // stats + session log, see TestPage below). initialMode/autoAnalyze are
  // only set when arriving via the "First Click"/"AI Insights" sidebar
  // pickers, so a plain "Run" still opens on the ordinary click heatmap with
  // no analysis auto-triggered.
  const [subView, setSubView] = useState(null);
  const [remoteRun] = useState(parseRemoteRun);
  // Captured once at mount, before stripping it from the URL bar — the raw
  // value is still needed to render ResetPasswordScreen, but a sensitive
  // one-time token shouldn't linger in the address bar/browser history any
  // longer than the moment it's used.
  const [resetToken] = useState(parseResetToken);
  useEffect(() => {
    if (!resetToken) return;
    const url = new URL(window.location);
    url.searchParams.delete("resetToken");
    window.history.replaceState({}, "", url);
  }, []);
  const [remoteDone, setRemoteDone] = useState(false);
  const [tunnel, setTunnel] = useState({ running: false, url: null });
  const [tunnelBusy, setTunnelBusy] = useState(false);
  const [tunnelError, setTunnelError] = useState("");
  const [justCopied, setJustCopied] = useState(null); // { prototypeId, testId } | null — brief "Copied!" feedback
  // null while unchecked; otherwise { active, needsSetup, user } once known.
  // /api/auth/status only exists at all in enterprise mode (TRACE_MODE=
  // enterprise, see server.js) — a 404 there means classic mode, skip the
  // login gate entirely. A remote tester's link must never hit this gate,
  // so it's not even checked when remoteRun is set.
  const [authState, setAuthState] = useState(null);
  useEffect(() => {
    if (remoteRun) return;
    fetch("/api/auth/status")
      .then((r) => (r.ok ? r.json().then((d) => ({ active: true, ...d })) : { active: false }))
      .then(setAuthState)
      .catch(() => setAuthState({ active: false }));
  }, []);

  // Hydrate from whatever's persisted server-side (data/store.json) — every
  // previously-imported prototype, its tests, and their sessions all survive
  // a page reload / server restart instead of resetting every time. A fresh
  // install has no folders at all yet, so the built-in demo is seeded as a
  // real persisted prototype (same path a real import takes) rather than
  // being a frontend-only special case.
  useEffect(() => {
    (async () => {
      try {
        // Enterprise mode gates /api/state behind login (see the BYOC plan's
        // confidentiality note) — a remote tester's link has no account and
        // never will, so it uses this scoped, unauthenticated route instead,
        // which only ever returns the one {proto, test} pair the link
        // already names. Classic mode doesn't mount this route at all (404),
        // so a remote tester there falls straight through to the plain
        // /api/state fetch below exactly as before — unauthenticated there
        // too, just not scoped, since classic mode never needed scoping.
        if (remoteRun) {
          const scoped = await fetch(`/api/public-test?proto=${encodeURIComponent(remoteRun.proto)}&test=${remoteRun.test}`);
          if (scoped.ok) {
            const { prototype, test } = await scoped.json();
            setPrototypes([{ id: prototype.id, manifest: prototype.manifest, tests: [test], sessions: {} }]);
            return;
          }
        }
        const s = await fetch("/api/state").then((r) => r.json());
        if (s.prototypes?.length) {
          setPrototypes(s.prototypes);
        } else {
          const res = await fetch("/api/manifest", { method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ manifest: MOCK_MANIFEST }) });
          if (!res.ok) throw new Error((await res.json()).error || "Couldn't seed the demo prototype");
          for (const t of DEFAULT_TESTS) {
            await fetch("/api/tests", { method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ prototypeId: MOCK_MANIFEST.fileKey, ...t }) });
          }
          const s2 = await fetch("/api/state").then((r) => r.json());
          setPrototypes(s2.prototypes || []);
        }
      } catch (e) {
        console.error("Couldn't load saved state:", e);
        // Offline/unreachable server: still show the demo so the app is usable.
        setPrototypes([{ id: MOCK_MANIFEST.fileKey, manifest: MOCK_MANIFEST, tests: [], sessions: {} }]);
      } finally {
        setLoaded(true);
      }
    })();
  }, []);

  // A remote tester's browser doesn't need to know whether a tunnel is
  // running — that's an admin-only concern.
  useEffect(() => {
    if (remoteRun) return;
    fetch("/api/tunnel/status").then((r) => r.json()).then(setTunnel).catch(() => {});
  }, []);

  // Desktop mode: grab the admin token over IPC (see preload.cjs) so this
  // browser's own admin actions (create/delete tests, imports, AI analysis,
  // tunnel control) authenticate against server.js's requireAdmin routes.
  // Web/CLI mode has no IPC bridge — the admin pastes it into Settings
  // instead (see SettingsPanel), so there's nothing to do here.
  useEffect(() => {
    if (remoteRun || !window.traceDesktop?.getAdminToken) return;
    window.traceDesktop.getAdminToken().then((t) => { if (t) setAdminToken(t); });
  }, []);

  // Keeps the admin view in sync without a manual reload — a session
  // recorded from a remote tester's own browser (or a second admin window)
  // only ever reaches this one via the server, never through local state, so
  // without this a freshly-finished remote session would just sit invisible
  // until the page happened to be reloaded for some other reason. A remote
  // tester's own tab doesn't need this — it only ever needs the one
  // prototype/test its link points at, already loaded once up front.
  useEffect(() => {
    if (remoteRun) return;
    const id = setInterval(() => {
      fetch("/api/state").then((r) => r.json()).then((s) => { if (s.prototypes) setPrototypes(s.prototypes); }).catch(() => {});
    }, 4000);
    return () => clearInterval(id);
  }, [remoteRun]);

  const goHome = () => { setSubView(null); setTab("tests"); };
  const goBack = () => setSubView(null);
  const findProto = (id) => prototypes.find((p) => p.id === id);

  const startSharing = async () => {
    if (tunnel.url) return;
    setTunnelBusy(true); setTunnelError("");
    try {
      const res = await fetch("/api/tunnel/start", { method: "POST", headers: adminHeaders() });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't start the tunnel");
      setTunnel({ running: true, url: data.url });
    } catch (e) { setTunnelError(e.message); }
    setTunnelBusy(false);
  };
  const stopSharing = async () => {
    setTunnel({ running: false, url: null });
    await fetch("/api/tunnel/stop", { method: "POST", headers: adminHeaders() }).catch(() => {});
  };
  const copyLink = (prototypeId, testId, link) => {
    navigator.clipboard.writeText(link);
    setJustCopied({ prototypeId, testId });
    setTimeout(() => setJustCopied((c) => (c?.prototypeId === prototypeId && c?.testId === testId ? null : c)), 1400);
  };

  const createTest = async (prototypeId, t) => {
    try {
      const res = await fetch("/api/tests", { method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ prototypeId, ...t }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't create test");
      setPrototypes(data.prototypes);
    } catch (e) { console.error(e); }
    setSubView(null);
  };
  const deleteTest = async (prototypeId, testId) => {
    try {
      const res = await fetch(`/api/tests/${encodeURIComponent(prototypeId)}/${testId}`, { method: "DELETE", headers: adminHeaders() });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't delete test");
      setPrototypes(data.prototypes);
      // Don't leave the now-deleted test's own page open.
      if (subView?.name === "page" && subView.prototypeId === prototypeId && subView.testId === testId) setSubView(null);
    } catch (e) { console.error(e); }
  };
  const deletePrototype = async (prototypeId) => {
    try {
      const res = await fetch(`/api/prototypes/${encodeURIComponent(prototypeId)}`, { method: "DELETE", headers: adminHeaders() });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't delete prototype");
      setPrototypes(data.prototypes);
      // Don't leave a page/create/run view open for the now-deleted folder.
      if (subView && subView.prototypeId === prototypeId) setSubView(null);
    } catch (e) { console.error(e); }
  };
  const saveSession = async (prototypeId, testId, data) => {
    // Optimistic local update so the dashboard shows the result immediately —
    // but the transition to "done" (the test's own page / the remote
    // tester's "Thanks" screen) waits for the POST to actually complete, not
    // just fire it and move on. Over a tunnel's extra latency, a tester who
    // closes the tab right after seeing "done" could otherwise cut the
    // request off mid-air and silently lose the session server-side.
    setPrototypes((ps) => ps.map((p) => p.id !== prototypeId ? p : { ...p, sessions: { ...p.sessions, [testId]: [...(p.sessions[testId] || []), data] } }));
    try {
      await fetch("/api/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prototypeId, ...data }) });
    } catch (e) {
      console.error("Couldn't save session:", e);
    }
    if (remoteRun) setRemoteDone(true);
    // data carries variantFrameId only for an A/B test's session (see
    // CaptureSurface's save()) — route back to its own results page rather
    // than the regular one, which wouldn't know to split by variant.
    else setSubView({ name: data.variantFrameId ? "abResults" : "page", prototypeId, testId });
  };
  const importManifest = async (m) => {
    try {
      const res = await fetch("/api/manifest", { method: "POST", headers: adminHeaders({ "Content-Type": "application/json" }), body: JSON.stringify({ manifest: m }) });
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "Couldn't save prototype");
      setPrototypes(data.prototypes);
    } catch (e) {
      console.error(e);
      // Still add/update the folder locally even if persistence failed —
      // better a working-but-unsaved session than silently ignoring the import.
      setPrototypes((ps) => {
        const i = ps.findIndex((p) => p.id === m.fileKey);
        if (i === -1) return [...ps, { id: m.fileKey, manifest: m, tests: [], sessions: {} }];
        const validIds = new Set(m.frames.map((f) => f.id));
        const tests = ps[i].tests.filter((t) => validIds.has(t.goalFrame));
        return ps.map((p, j) => j !== i ? p : { ...p, manifest: m, tests });
      });
    }
    setTab("tests");
  };

  // Desktop: packages opened via File → Open or Finder double-click arrive here.
  useEffect(() => {
    if (window.traceDesktop) window.traceDesktop.onPackageImported(importManifest);
  }, []);

  const runProto = subView?.name === "run" ? findProto(subView.prototypeId) : null;
  const runTest = runProto && subView?.name === "run" ? runProto.tests.find((t) => t.id === subView.testId) : null;
  const createProto = subView?.name === "create" ? findProto(subView.prototypeId) : null;
  const pageProto = subView?.name === "page" ? findProto(subView.prototypeId) : null;
  const pageTest = pageProto && subView?.name === "page" ? pageProto.tests.find((t) => t.id === subView.testId) : null;
  const createABProto = subView?.name === "createAB" ? findProto(subView.prototypeId) : null;
  const abProto = subView?.name === "abResults" ? findProto(subView.prototypeId) : null;
  const abTest = abProto && subView?.name === "abResults" ? abProto.tests.find((t) => t.id === subView.testId) : null;

  // Sidebar nav item — clears any open subView too (not just switching
  // `tab`), so the sidebar always works as direct navigation regardless of
  // how deep you are in a create/run/results subView, matching how a
  // persistent SaaS sidebar nav is expected to behave.
  const navItem = (key, label, icon) => (
    <button
      onClick={() => { setSubView(null); setTab(key); }}
      style={{
        display: "flex", alignItems: "center", gap: fluidPx(10), width: "100%", boxSizing: "border-box",
        textAlign: "left", background: !subView && tab === key ? hexToRgba(C.cyan, 0.12) : "transparent",
        color: !subView && tab === key ? C.cyan : C.dim, border: "none", borderRadius: 8,
        padding: `${fluidPx(9)} ${fluidPx(12)}`, fontSize: fluidPx(13), fontWeight: !subView && tab === key ? 600 : 400, cursor: "pointer",
      }}>
      {/* A literal space (not just flex `gap`, which only adds visual
          spacing, not an actual character) between icon and label — so the
          button's own text content reads correctly for anything that reads
          it as text (copy/paste, screen readers, or a test script). */}
      {icon && <span style={{ fontSize: fluidPx(14), lineHeight: 1 }}>{icon}</span>}{icon ? " " : ""}{label}
    </button>
  );

  // A password-reset link takes priority over everything else — including
  // remoteRun, though the two are mutually exclusive in practice (nobody
  // gets handed a reset link and a test link at once).
  if (resetToken) {
    return <ResetPasswordScreen token={resetToken} onDone={() => window.location.reload()} />;
  }

  // Remote-tester link: just the test surface, none of the admin chrome —
  // this visitor only ever gets the one prototype/test the link points at.
  if (remoteRun) {
    const proto = findProto(remoteRun.proto);
    const test = proto && proto.tests.find((t) => t.id === remoteRun.test);
    return (
      <div style={{ background: C.bg, minHeight: "100vh", color: C.text, fontFamily: "ui-sans-serif,system-ui,sans-serif", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
        {!loaded && <div style={{ color: C.dim }}>Loading…</div>}
        {loaded && !test && <div style={{ ...panelBox(), color: C.dim }}>This test link isn't available anymore.</div>}
        {loaded && test && remoteDone && (
          <div style={{ ...panelBox(), textAlign: "center", padding: 40 }}>
            <div style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Thanks — you're done!</div>
            <div style={{ color: C.dim, fontSize: 13 }}>Your session has been recorded.</div>
          </div>
        )}
        {loaded && test && !remoteDone && (
          <div style={{ width: "100%", maxWidth: 1300 }}>
            <CaptureSurface test={test} manifest={proto.manifest} onSave={(data) => saveSession(proto.id, test.id, data)} minimal />
          </div>
        )}
      </div>
    );
  }

  // Enterprise-mode login gate — comes after the remoteRun early-return
  // above (a tester's link must never hit this) and before the main app
  // renders at all, since the pre-login initial-load effect's /api/state
  // fetch 401s and falls back to local-only demo data that must never
  // actually be shown. `authState === null` is the brief window before the
  // very first /api/auth/status response lands (classic mode resolves this
  // to { active: false } just as quickly).
  if (!authState) {
    return <div style={{ background: C.bg, minHeight: "100vh", color: C.dim, fontFamily: "ui-sans-serif,system-ui,sans-serif", display: "flex", alignItems: "center", justifyContent: "center" }}>Loading…</div>;
  }
  if (authState.active && authState.needsSetup) {
    return <SetupScreen onDone={() => window.location.reload()} />;
  }
  if (authState.active && !authState.user) {
    return <LoginScreen onDone={() => window.location.reload()} />;
  }

  return (
    <div style={{ background: C.bg, minHeight: "100vh", color: C.text, fontFamily: "ui-sans-serif,system-ui,sans-serif", display: "flex" }}>
      {/* Persistent left sidebar (modern SaaS layout) — stays visible and
          clickable regardless of tab or subView, instead of the old top tab
          row that disappeared once you were inside a create/run/results
          subView. Sticky rather than fixed so it participates in normal flex
          layout (no separate width bookkeeping for the main column) while
          still staying pinned to the viewport as the main content scrolls. */}
      <nav style={{
        width: fluidPx(220), flexShrink: 0, boxSizing: "border-box", padding: `${fluidPx(22)} ${fluidPx(14)}`,
        display: "flex", flexDirection: "column",
        borderRight: `1px solid ${C.line}`,
        position: "sticky", top: 0, alignSelf: "flex-start", height: "100vh",
      }}>
        <button onClick={goHome} style={{ display: "flex", alignItems: "center", alignSelf: "flex-start", background: "none", border: "none", padding: 0, marginBottom: 2, cursor: "pointer" }}>
          <img src="/logo.png" alt="TRACE" style={{ height: fluidPx(26), width: "auto", display: "block" }} />
        </button>
        <div style={{ fontSize: fluidPx(11), color: C.faint, marginBottom: fluidPx(28), marginLeft: fluidPx(20) }}>hosted-prototype usability telemetry</div>

        {/* Import moved to a button on the Tests page itself (top-right of
            the "Tests" heading, next to "+ New test") rather than being its
            own top-level sidebar item — it's conceptually part of managing
            tests, and this leaves room in the sidebar for future sections. */}
        <div style={{ display: "flex", flexDirection: "column", gap: fluidPx(4) }}>
          {navItem("tests", "Tests", "▤")}
          {navItem("ai", "AI Insights", "✦")}
          {navItem("firstclick", "First Click", "◉")}
          {navItem("ab", "A/B Testing", "⇄")}
        </div>

        {/* Left-aligned (not centered) to line up with the Tests/Import
            labels above it, using the same horizontal padding as navItem. */}
        <div style={{ marginTop: "auto", paddingTop: fluidPx(16), borderTop: `1px solid ${C.line}` }}>
          <button
            title="Settings" aria-label="Settings"
            onClick={() => { setGearSpins((n) => n + 1); setSubView(null); setTab("settings"); }}
            style={{
              display: "flex", alignItems: "center", justifyContent: "flex-start", width: "100%",
              background: !subView && tab === "settings" ? hexToRgba(C.cyan, 0.12) : "transparent",
              border: "none", borderRadius: 8, padding: `${fluidPx(10)} ${fluidPx(12)}`, cursor: "pointer",
            }}>
            <span style={{
              fontSize: fluidPx(26), lineHeight: 1, display: "inline-block", color: !subView && tab === "settings" ? C.cyan : C.dim,
              transform: `rotate(${gearSpins * 360}deg)`, transition: "transform 0.5s ease",
            }}>⚙</span>
          </button>
        </div>
      </nav>

      <main style={{ flex: 1, minWidth: 0, boxSizing: "border-box", padding: "22px clamp(16px, 3vw, 48px) 60px" }}>
        {subView && (
          <div style={{ marginBottom: 18 }}>
            <button style={ghostBtn()} onClick={goBack}>← Back</button>
          </div>
        )}

        {subView?.name === "create" && createProto && (
          <CreateTest manifest={createProto.manifest} onCreate={(t) => createTest(createProto.id, t)} onCancel={goBack} />
        )}
        {subView?.name === "run" && runProto && runTest && (
          <CaptureSurface test={runTest} manifest={runProto.manifest} onSave={(data) => saveSession(runProto.id, runTest.id, data)} />
        )}
        {subView?.name === "page" && pageProto && pageTest && (
          <TestPage proto={pageProto} test={pageTest} sessions={pageProto.sessions[pageTest.id] || []}
            tunnel={tunnel} tunnelBusy={tunnelBusy} tunnelError={tunnelError}
            startSharing={startSharing} stopSharing={stopSharing} copyLink={copyLink} justCopied={justCopied}
            initialMode={subView.initialMode} autoAnalyze={subView.autoAnalyze} alwaysOn={authState.active} />
        )}
        {subView?.name === "createAB" && createABProto && (
          <CreateABTest manifest={createABProto.manifest} onCreate={(t) => createTest(createABProto.id, t)} onCancel={goBack} />
        )}
        {subView?.name === "abResults" && abProto && abTest && (
          <ABResultsPage proto={abProto} test={abTest} sessions={abProto.sessions[abTest.id] || []}
            tunnel={tunnel} tunnelBusy={tunnelBusy} tunnelError={tunnelError}
            startSharing={startSharing} stopSharing={stopSharing} copyLink={copyLink} justCopied={justCopied}
            onRunLocally={() => setSubView({ name: "run", prototypeId: abProto.id, testId: abTest.id })} alwaysOn={authState.active} />
        )}

        {!subView && tab === "tests" && (
          <div>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
              <h2 style={{ fontSize: 20, margin: 0 }}>Tests</h2>
              <button style={{ ...primaryBtn(), display: "flex", alignItems: "center", gap: 8 }} onClick={() => setTab("import")}>
                <ImportIcon /> Import
              </button>
            </div>
            {!loaded && <div style={{ ...panelBox(), color: C.dim }}>Loading…</div>}
            {loaded && prototypes.length === 0 && <div style={{ ...panelBox(), color: C.dim }}>No prototypes imported yet. Click "Import" above to add one.</div>}
            <div style={{ display: "flex", flexDirection: "column", gap: 22 }}>
              {prototypes.map((p) => (
                <div key={p.id}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 10 }}>
                    <div style={{ fontSize: 14, fontWeight: 600 }}>{p.manifest.name} <span style={{ fontWeight: 400, color: C.dim, fontSize: 12 }}>· {p.manifest.frames.length} frames</span></div>
                    <div style={{ display: "flex", gap: 8 }}>
                      <button style={{ ...dangerBtn(), padding: "6px 12px" }} onClick={() => {
                        const sessionCount = Object.values(p.sessions).reduce((n, arr) => n + arr.length, 0);
                        const detail = p.tests.length ? ` This also deletes its ${p.tests.length} test${p.tests.length === 1 ? "" : "s"}${sessionCount ? ` and ${sessionCount} recorded session${sessionCount === 1 ? "" : "s"}` : ""}.` : "";
                        if (window.confirm(`Delete "${p.manifest.name}"?${detail}`)) deletePrototype(p.id);
                      }}>Delete</button>
                      <button style={{ ...primaryBtn(), padding: "6px 12px" }} onClick={() => setSubView({ name: "create", prototypeId: p.id })}>+ New test</button>
                    </div>
                  </div>
                  {p.manifest.warnings?.length > 0 && (
                    <div style={{ ...panelBox(), borderLeft: "3px solid #F5A623", marginBottom: 10 }}>
                      <div style={{ fontSize: 11, color: "#F5A623", letterSpacing: 1, fontFamily: "ui-monospace,monospace", marginBottom: 8 }}>
                        {p.manifest.warnings.length} HOTSPOT{p.manifest.warnings.length === 1 ? "" : "S"} DIDN'T RESOLVE
                      </div>
                      <div style={{ maxHeight: 140, overflowY: "auto" }}>
                        {p.manifest.warnings.map((w, i) => <div key={i} style={{ fontSize: 12, color: C.dim, marginBottom: 6, lineHeight: 1.5 }}>{w}</div>)}
                      </div>
                    </div>
                  )}
                  {/* A/B tests live in their own "A/B Testing" panel, not
                      here — same reasoning as AI Insights/First Click: a
                      per-test feature gets its own picker instead of piling
                      onto this list. */}
                  {p.tests.filter((t) => !t.variantSlot).length === 0 && <div style={{ ...panelBox(), color: C.dim }}>No tests for this prototype yet. Hit “+ New test”.</div>}
                  <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
                    {p.tests.filter((t) => !t.variantSlot).map((t) => {
                      const ss = p.sessions[t.id] || []; const rate = ss.length ? Math.round((ss.filter((x) => x.success).length / ss.length) * 100) : null;
                      return (
                        <div key={t.id} style={panelBox()}>
                          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
                            <div><div style={{ fontSize: 15, fontWeight: 600 }}>{t.name}</div>
                              <div style={{ fontSize: 12, color: C.dim, marginTop: 3 }}>"{t.task}" · <span style={{ fontFamily: "ui-monospace,monospace" }}>{ss.length} sessions{rate != null ? ` · ${rate}% success` : ""}</span></div>
                            </div>
                            <div style={{ display: "flex", gap: 8 }}>
                              <button style={dangerBtn()} onClick={() => { if (window.confirm(`Delete test "${t.name}"?${ss.length ? ` This also deletes its ${ss.length} recorded session${ss.length === 1 ? "" : "s"}.` : ""}`)) deleteTest(p.id, t.id); }}>Delete</button>
                              <button style={ghostBtn()} onClick={() => setSubView({ name: "run", prototypeId: p.id, testId: t.id })}>Run locally</button>
                              <button style={primaryBtn()} onClick={() => setSubView({ name: "page", prototypeId: p.id, testId: t.id })}>Run</button>
                            </div>
                          </div>
                        </div>
                      );
                    })}
                  </div>
                </div>
              ))}
            </div>
          </div>
        )}
        {!subView && tab === "import" && <ImportPanel prototypeCount={prototypes.length} onImport={importManifest} />}
        {!subView && tab === "settings" && <SettingsPanel theme={theme} onSetTheme={setTheme} enterprise={authState.active} authUser={authState.user} />}
        {!subView && tab === "ai" && (
          <TestPicker prototypes={prototypes} title="AI Insights" subtitle="Pick a test to get an AI-generated summary of trends across its sessions."
            onPick={(prototypeId, testId) => setSubView({ name: "page", prototypeId, testId, autoAnalyze: true })} />
        )}
        {!subView && tab === "firstclick" && (
          <TestPicker prototypes={prototypes} title="First Click" subtitle="Pick a test to see where testers click first on each screen."
            onPick={(prototypeId, testId) => setSubView({ name: "page", prototypeId, testId, initialMode: "first" })} />
        )}
        {!subView && tab === "ab" && (
          <TestPicker
            prototypes={prototypes.map((p) => ({ ...p, tests: p.tests.filter((t) => t.variantSlot) }))}
            title="A/B Testing" subtitle="Pick a test to compare variants, or create a new one."
            onPick={(prototypeId, testId) => setSubView({ name: "abResults", prototypeId, testId })}
            onCreateNew={(prototypeId) => setSubView({ name: "createAB", prototypeId })}
            createLabel="+ New A/B test" />
        )}
      </main>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
