/* studio-decodable.jsx — Decodable Passages workspace.
   Wires controls → server-side agent pipeline → validator report → preview. */

(function () {
  const {
    SCOPE_AND_SEQUENCE, PASSAGE_TEMPLATES, GRADE_LEVELS, DEFAULT_SPEC,
    graphemesThroughWeek, newGraphemesAtWeek, normalizeWordList,
    newJob, appendTrace, makeId
  } = window.STUDIO_DATA;
  const { Icon, Mascot } = window.STUDIO_ICONS;

  /* Icon picker for trace steps. The single-loop agent emits dynamic stage
     names (`agent`, `turn-1`, `turn-2`, …); pick an icon based on what the
     stage actually did. */
  function iconForStage(stage, info) {
    if (stage === "agent")                          return "agent_done";
    if (typeof info === "string" && info.includes("validate_passage"))  return "agent_validator";
    if (typeof info === "string" && info.includes("finalize"))          return "agent_editor";
    if (typeof info === "string" && info.includes("get_scope"))         return "agent_planner";
    if (stage.startsWith("turn-"))                   return "agent_generator";
    return "cog";
  }

  function prettyStageName(stage) {
    if (stage === "agent") return "Agent loop";
    if (stage.startsWith("turn-")) return "Turn " + stage.slice(5);
    return stage;
  }

  /* ----- Subcomponents ----- */

  const GEN_MESSAGES = [
    "Crafting your story...",
    "Picking the perfect words...",
    "Building sentences with care...",
    "Making reading fun...",
    "Checking every phonics rule...",
    "Polishing the passage...",
    "Adding a dash of magic...",
    "Almost there...",
  ];

  function GeneratingAnimation({ title }) {
    const [msgIdx, setMsgIdx] = React.useState(0);
    React.useEffect(() => {
      const id = setInterval(() => setMsgIdx((i) => (i + 1) % GEN_MESSAGES.length), 3000);
      return () => clearInterval(id);
    }, []);

    const letters = (title || "Reading is fun").split("");

    return (
      <div className="gen-anim">
        <div className="gen-anim-rainbow"/>
        <div className="gen-anim-content">
          <div className="gen-anim-pencil">
            <svg viewBox="0 0 64 64" width="72" height="72">
              <g className="gen-pencil-bob">
                <rect x="22" y="8" width="20" height="46" rx="3" fill="#FFD54F" stroke="#333" strokeWidth="2"/>
                <polygon points="22,54 42,54 32,64" fill="#FFB74D" stroke="#333" strokeWidth="2"/>
                <rect x="22" y="8" width="20" height="8" rx="2" fill="#EF5350" stroke="#333" strokeWidth="2"/>
                <line x1="32" y1="54" x2="32" y2="62" stroke="#333" strokeWidth="1.5"/>
              </g>
            </svg>
          </div>
          <div className="gen-anim-letters" aria-hidden="true">
            {letters.map((ch, i) => (
              <span key={i} className="gen-letter" style={{ animationDelay: `${i * 0.12}s` }}>
                {ch === " " ? " " : ch}
              </span>
            ))}
          </div>
          <div className="gen-anim-msg" key={msgIdx}>{GEN_MESSAGES[msgIdx]}</div>
          <div className="gen-anim-dots">
            <span className="gen-dot"/>
            <span className="gen-dot"/>
            <span className="gen-dot"/>
          </div>
        </div>
      </div>
    );
  }

  function ScopeChips({ week, grade }) {
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(grade || "1");
    const all = Array.from(graphemesThroughWeek(week, scopeGrade)).sort();
    const isNew = new Set(newGraphemesAtWeek(week, scopeGrade).map((u) => u.g.toLowerCase()));
    return (
      <div className="scope-chips">
        {all.map((g) => (
          <span key={g} className={"scope-chip" + (isNew.has(g) ? " new" : "")}>
            {g}
          </span>
        ))}
      </div>
    );
  }

  /* Categorize a grapheme token for display grouping. */
  function classifyGrapheme(g) {
    if (g.startsWith("-") || g.endsWith("-")) return "morpheme";
    if (g.startsWith("y_long")) return "vowel";   // y-as-vowel (y_long_e / y_long_i), not magic-e
    if (g.includes("_e")) return "magic-e";
    if (g.length === 1) return /[aeiou]/.test(g) ? "vowel" : "consonant";
    return "digraph";
  }

  /* Side drawer showing the cumulative taught scope, grouped by category.
     Replaces the inline scope-chips strip. */
  function ScopeDrawer({ week, grade, scopeGrade, taughtCount, onClose }) {
    React.useEffect(() => {
      const onKey = (e) => { if (e.key === "Escape") onClose(); };
      document.addEventListener("keydown", onKey);
      // Lock body scroll while drawer is open
      const prev = document.body.style.overflow;
      document.body.style.overflow = "hidden";
      return () => {
        document.removeEventListener("keydown", onKey);
        document.body.style.overflow = prev;
      };
    }, [onClose]);

    const all = Array.from(graphemesThroughWeek(week, scopeGrade)).sort();
    const newThisWeek = new Set(newGraphemesAtWeek(week, scopeGrade).map((u) => u.g.toLowerCase()));

    // Group
    const groups = { consonant: [], vowel: [], digraph: [], "magic-e": [], morpheme: [] };
    for (const g of all) {
      const kind = classifyGrapheme(g);
      (groups[kind] || groups.consonant).push(g);
    }
    const groupOrder = [
      { id: "consonant", label: "Consonants" },
      { id: "vowel", label: "Short vowels" },
      { id: "digraph", label: "Digraphs / blends" },
      { id: "magic-e", label: "Magic-e (silent-e)" },
      { id: "morpheme", label: "Prefixes / suffixes / roots" }
    ];

    return (
      <div className="scope-drawer-backdrop" onClick={onClose}>
        <aside className="scope-drawer" onClick={(e) => e.stopPropagation()}>
          <header className="scope-drawer-head">
            <div>
              <div className="scope-drawer-eyebrow">{scopeGrade} · Week {week}</div>
              <h2 className="scope-drawer-title">Taught scope</h2>
              <div className="scope-drawer-sub">
                {taughtCount} grapheme{taughtCount === 1 ? "" : "s"} cumulative · {newThisWeek.size} new this week
              </div>
            </div>
            <button type="button" className="scope-drawer-close" onClick={onClose} aria-label="Close">×</button>
          </header>
          <div className="scope-drawer-body">
            {newThisWeek.size > 0 && (
              <section className="scope-group">
                <h3>New this week</h3>
                <div className="scope-chips">
                  {Array.from(newThisWeek).sort().map((g) => (
                    <span key={g} className="scope-chip new">{g}</span>
                  ))}
                </div>
              </section>
            )}
            {groupOrder.map((grp) => {
              const items = (groups[grp.id] || []).filter((g) => !newThisWeek.has(g));
              if (!items.length) return null;
              return (
                <section key={grp.id} className="scope-group">
                  <h3>{grp.label} <span className="scope-group-count">{items.length}</span></h3>
                  <div className="scope-chips">
                    {items.map((g) => (
                      <span key={g} className="scope-chip">{g}</span>
                    ))}
                  </div>
                </section>
              );
            })}
          </div>
        </aside>
      </div>
    );
  }

  /* Side drawer of recent passages — replaces the inline Recent collapse.
     Slides in from the right; same backdrop pattern as ScopeDrawer. */
  function HistoryDrawer({ jobs, currentJobId, onPick, onClose }) {
    React.useEffect(() => {
      const onKey = (e) => { if (e.key === "Escape") onClose(); };
      document.addEventListener("keydown", onKey);
      const prev = document.body.style.overflow;
      document.body.style.overflow = "hidden";
      return () => {
        document.removeEventListener("keydown", onKey);
        document.body.style.overflow = prev;
      };
    }, [onClose]);

    return (
      <div className="scope-drawer-backdrop" onClick={onClose}>
        <aside className="scope-drawer" onClick={(e) => e.stopPropagation()}>
          <header className="scope-drawer-head">
            <div>
              <div className="scope-drawer-eyebrow">History</div>
              <h2 className="scope-drawer-title">Recent passages</h2>
              <div className="scope-drawer-sub">
                {jobs.length} saved · click to load
              </div>
            </div>
            <button type="button" className="scope-drawer-close" onClick={onClose} aria-label="Close">×</button>
          </header>
          <div className="scope-drawer-body history-drawer-body">
            {jobs.map((j) => {
              const pass = j.validator?.passed;
              const badge = pass ? "pass" : (j.status === "failed" ? "fail" : "draft");
              const badgeLabel = pass ? "Pass" : (j.status === "failed" ? "Fail" : "Draft");
              return (
                <button
                  key={j.id}
                  type="button"
                  className={"history-card" + (currentJobId === j.id ? " is-current" : "")}
                  onClick={() => onPick(j)}
                >
                  <div className="history-card-main">
                    <div className="history-card-title">{j.title || "(untitled)"}</div>
                    <div className="history-card-meta">
                      Week {j.spec.week} · {(j.spec.targetWords?.length || 0)} target{(j.spec.targetWords?.length || 0) === 1 ? "" : "s"}
                    </div>
                  </div>
                  <span className={"history-badge " + badge}>{badgeLabel}</span>
                </button>
              );
            })}
          </div>
        </aside>
      </div>
    );
  }

  function AgentTrace({ trace, showAgentTrace }) {
    if (!showAgentTrace) return null;
    const steps = Array.isArray(trace) ? trace : [];
    if (!steps.length) {
      return (
        <div className="agent-trace">
          <h3>{Icon.cog()}Agent trace</h3>
          <div style={{ fontSize: 13, color: "var(--ink-soft)", fontWeight: 700 }}>
            No turns recorded yet.
          </div>
        </div>
      );
    }
    return (
      <div className="agent-trace">
        <h3>{Icon.cog()}Agent trace · single-loop</h3>
        <div className="trace-steps">
          {steps.map((step, idx) => {
            const status = step.status === "active" ? "active" :
              step.status === "done"   ? "done"   :
              step.status === "failed" ? "failed" : step.status;
            const klass = "trace-step " + (status || "");
            const took = step.finishedAt ? `${Math.round((step.finishedAt - step.startedAt)/100)/10}s` : "";
            const iconName = iconForStage(step.stage, step.info);
            return (
              <div key={idx + "-" + step.stage} className={klass}>
                <div className="ts-icon">{Icon[iconName] ? Icon[iconName]() : Icon.cog()}</div>
                <div className="ts-body">
                  <div className="ts-name">{prettyStageName(step.stage)}</div>
                  <div className="ts-sub">{step.info || ""}</div>
                </div>
                <div className="ts-time">{took || (status === "active" ? "…" : "")}</div>
              </div>
            );
          })}
        </div>
      </div>
    );
  }

  function ValidatorReport({ report }) {
    if (!report) return null;
    const cls = report.passed ? "pass" : "fail";
    return (
      <div className="validator-report">
        <h3>
          {Icon.agent_validator()}Validator
          <span className={"badge-pill " + cls}>{report.passed ? "Pass" : "Fail"}</span>
        </h3>
        <ul className="validator-list">
          {report.checks.map((c) => (
            <li key={c.id} className={c.pass ? "ok" : "fail"}>
              <span className="v-mark">{c.pass ? "✓" : "!"}</span>
              <div>
                <div>{c.label}</div>
                {c.detail && <div className="v-detail">{c.detail}</div>}
              </div>
            </li>
          ))}
        </ul>
        {report.stats && (
          <div style={{ marginTop: 10, fontSize: 12, color: "var(--ink-soft)", fontWeight: 700 }}>
            {report.stats.totalWords} words · {report.stats.distinctTokens} distinct · sight words {report.stats.sightCount} · decodability {report.stats.decodabilityPct}%
          </div>
        )}
      </div>
    );
  }

  function buildProvenanceText(job) {
    const p = job.provenance || {};
    const stages = Array.isArray(p.stages) ? p.stages : [];
    const lines = [];
    lines.push("=== Passage Studio — Provenance Record ===");
    lines.push(`Title: ${job.title || "(untitled)"}`);
    lines.push(`Job ID: ${job.id}`);
    lines.push(`Generated: ${p.startedAt || job.createdAt || "(unknown)"}`);
    if (p.finishedAt) lines.push(`Finished: ${p.finishedAt}`);
    lines.push(`Spec: week ${job.spec?.week}, grade ${job.spec?.grade}, ~${job.spec?.wordCount} words, sentences ≤ ${job.spec?.maxSentenceLen}`);
    if (Array.isArray(job.spec?.targetWords) && job.spec.targetWords.length) {
      lines.push(`Target words: ${job.spec.targetWords.join(", ")}`);
    }
    lines.push("");
    lines.push("Pipeline stages:");
    for (const s of stages) {
      lines.push(`  • ${s.stage} → ${s.model} [${s.license}${s.sellable ? "" : " — TOS-restricted"}]`);
    }
    if (p.summary) {
      lines.push("");
      lines.push(p.summary);
    }
    if (job.validator) {
      lines.push("");
      lines.push(`Validator: ${job.validator.passed ? "PASS" : "FAIL"} — ${job.validator.checks.filter((c) => c.pass).length}/${job.validator.checks.length} checks pass, ${job.validator.stats?.decodabilityPct}% decodable`);
    }
    lines.push("");
    lines.push("Generated by Passage Studio (Decodable passages, built to a scope and sequence). Validator-checked.");
    lines.push("Outputs are owned by the operator per the listed model licences.");
    return lines.join("\n");
  }

  /* ── Student worksheet sheet ──
     The print-perfect, hand-to-a-kid artifact. Rendered on screen as a paper
     sheet and reused verbatim by the print stylesheet (only `.sheet` prints). */

  const WKS_CONSONANTS = "bcdfghjklmnpqrstvwxz";

  /* Regex that detects a grapheme inside a word ("a_e" → a-consonant-e).
     Morphemes (-s, re-) return null — too noisy for warm-up matching. */
  function graphemeMatcher(g) {
    const t = String(g || "").toLowerCase();
    if (t.startsWith("-") || t.endsWith("-")) return null;
    if (t.includes("_")) {
      const parts = t.split("_");
      return new RegExp(parts[0] + "[" + WKS_CONSONANTS + "]" + (parts[1] || "e"));
    }
    const core = t.replace(/[^a-z']/g, "");
    return core ? new RegExp(core) : null;
  }

  /* Words the student reads before the passage: the teacher's target words,
     else passage words that contain this week's NEW graphemes, else the
     meatiest decodable words in the passage. */
  function pickWarmUpWords(text, spec, scopeGrade) {
    const targets = normalizeWordList(spec.targetWords);
    if (targets.length) return targets.slice(0, 8);
    const matchers = (newGraphemesAtWeek(spec.week, scopeGrade) || [])
      .map((u) => graphemeMatcher(u.g)).filter(Boolean);
    const hearts = window.STUDIO_DATA.heartWordsThroughWeek(scopeGrade, spec.week);
    // Skip likely character names: words that only ever appear capitalized
    // ("Fern", "Bert") read oddly lowercased in a warm-up row.
    const capCounts = {};
    const allCounts = {};
    for (const m of (String(text).match(/[A-Za-z]+(?:'[A-Za-z]+)?/g) || [])) {
      const lower = m.toLowerCase();
      allCounts[lower] = (allCounts[lower] || 0) + 1;
      if (/^[A-Z]/.test(m)) capCounts[lower] = (capCounts[lower] || 0) + 1;
    }
    const isName = (w) => (capCounts[w] || 0) === allCounts[w] && allCounts[w] > 0;
    const seen = new Set();
    const toks = (window.STUDIO_DATA.tokenize(text) || []).filter((w) => {
      if (seen.has(w)) return false;
      seen.add(w);
      return true;
    });
    let picks = toks.filter((w) => w.length >= 2 && !hearts.has(w) && !isName(w) && matchers.some((re) => re.test(w)));
    if (picks.length < 4) {
      const extras = toks.filter((w) =>
        w.length >= 3 && !hearts.has(w) && !isName(w) && !window.STUDIO_DATA.FRY_SET.has(w) && !picks.includes(w));
      picks = picks.concat(extras);
    }
    return picks.slice(0, 8);
  }

  /* Shared sheet chrome: Name / Date header. */
  function SheetTop() {
    return (
      <div className="sheet-top">
        <span className="sheet-id-label">Name</span>
        <span className="sheet-id-line"/>
        <span className="sheet-id-label">Date</span>
        <span className="sheet-id-line sheet-id-line-sm"/>
      </div>
    );
  }

  function SheetFoot({ scopeGrade, week, extra }) {
    return (
      <div className="sheet-foot">
        <span>Passage Studio · decodable practice</span>
        <span>{scopeGrade} · Week {week}{extra ? ` · ${extra}` : ""}</span>
      </div>
    );
  }

  /* ── Reading font: Andika (default, designed for beginning readers) ⇄
     OpenDyslexic (toggle). Applied as a class on the product sheet so the
     passage / worksheet reading text switches face; both are OFL, resale-safe. */
  function readingFontClass(font) { return font === "dyslexic" ? "font-dyslexic" : ""; }

  /* ── Spot art ──
     Open-licensed Twemoji (CC-BY 4.0) rendered as SVG — consistent house
     style, prints razor-sharp in colour and B/W, and resale-safe. Matched to
     the story's key nouns by keyword. */
  const KEYWORD_EMOJI = {
    // animals
    cat:"🐈", kitten:"🐈", dog:"🐕", pup:"🐕", puppy:"🐕", pig:"🐖", hog:"🐖", hen:"🐔",
    chick:"🐤", chicken:"🐔", fox:"🦊", bug:"🐛", ant:"🐜", bee:"🐝", frog:"🐸", fish:"🐟",
    duck:"🦆", cow:"🐄", goat:"🐐", sheep:"🐑", lamb:"🐑", horse:"🐎", pony:"🐎", rat:"🐀",
    mouse:"🐭", bird:"🐦", owl:"🦉", snail:"🐌", crab:"🦀", bear:"🐻", lion:"🦁", tiger:"🐯",
    rabbit:"🐰", bunny:"🐰", bat:"🦇", snake:"🐍", turtle:"🐢", monkey:"🐒", elephant:"🐘",
    spider:"🕷", ladybug:"🐞", whale:"🐳", shark:"🦈", deer:"🦌", wolf:"🐺",
    // food
    ham:"🍖", jam:"🍓", egg:"🥚", eggs:"🥚", cake:"🍰", milk:"🥛", bread:"🍞", apple:"🍎",
    banana:"🍌", nut:"🌰", corn:"🌽", peach:"🍑", pie:"🥧", bun:"🥐", tea:"🍵", cup:"🥤",
    pizza:"🍕", grapes:"🍇", lemon:"🍋", plum:"🫐", candy:"🍬", honey:"🍯", soup:"🍲",
    // nature
    sun:"☀️", moon:"🌙", star:"⭐", tree:"🌳", rain:"🌧️", cloud:"☁️", leaf:"🍃", flower:"🌸",
    rose:"🌹", snow:"❄️", fire:"🔥", wave:"🌊", hill:"⛰️", rock:"🪨", web:"🕸️",
    // objects
    hat:"🎩", cap:"🧢", map:"🗺️", bus:"🚌", car:"🚗", van:"🚐", ship:"🚢", boat:"⛵", kite:"🪁",
    ball:"⚽", bed:"🛏️", box:"📦", pot:"🍲", pan:"🍳", book:"📖", bell:"🔔", drum:"🥁",
    bag:"🎒", key:"🔑", clock:"🕰️", gift:"🎁", lamp:"💡", mug:"☕", jar:"🫙", pen:"🖊️",
    // people / places
    mom:"👩", mum:"👩", dad:"👨", baby:"👶", girl:"👧", boy:"👦", king:"🤴", home:"🏠", house:"🏠",
    farm:"🚜", school:"🏫", park:"🏞️", shop:"🏪"
  };
  function twemojiUrl(ch) {
    const cp = Array.from(ch).map((c) => c.codePointAt(0)).filter((c) => c !== 0xfe0f)
      .map((c) => c.toString(16)).join("-");
    return `https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/svg/${cp}.svg`;
  }
  /* Key nouns mapped to art, ranked so the hero is the story's real subject:
     a title noun wins; otherwise the most-repeated matched noun in the body. */
  function pickIllustrations(text, title, max) {
    const titleWords = new Set(String(title || "").toLowerCase().match(/[a-z]+/g) || []);
    const counts = new Map();   // emoji → { word, n, inTitle }
    const order = [];
    for (const w of String(text || "").toLowerCase().match(/[a-z]+/g) || []) {
      const e = KEYWORD_EMOJI[w];
      if (!e) continue;
      if (!counts.has(e)) { counts.set(e, { word: w, emoji: e, n: 0, inTitle: titleWords.has(w) }); order.push(e); }
      counts.get(e).n += 1;
    }
    // Seed any title-only nouns that never appear in the body.
    for (const w of titleWords) {
      const e = KEYWORD_EMOJI[w];
      if (e && !counts.has(e)) { counts.set(e, { word: w, emoji: e, n: 1, inTitle: true }); order.push(e); }
    }
    return order.map((e) => counts.get(e))
      .sort((a, b) => (b.inTitle - a.inTitle) || (b.n - a.n))
      .slice(0, max);
  }
  function Illustration({ emoji, word, className }) {
    // Twemoji SVG; if the CDN is blocked the emoji glyph still shows as alt.
    return (
      <img className={className} src={twemojiUrl(emoji)} alt={word}
        loading="eager" crossOrigin="anonymous"
        onError={(e) => { e.target.replaceWith(document.createTextNode(emoji)); }}/>
    );
  }

  /* This week's NEW graphemes as display tokens — the focus-sound banner. */
  function focusSoundsForWeek(spec, scopeGrade) {
    return Array.from(new Set(
      (newGraphemesAtWeek(spec.week, scopeGrade) || [])
        .map((u) => u.g)
        .filter((g) => !g.startsWith("-") && !g.endsWith("-"))
        .map((g) => (g.startsWith("y_long") ? "y" : g.replace("_e", "_e")))
    )).slice(0, 6);
  }
  function SoundBanner({ sounds }) {
    if (!sounds || !sounds.length) return null;
    return (
      <div className="sound-banner">
        {sounds.map((g) => <span className="sound-banner-chip" key={g}>{g}</span>)}
      </div>
    );
  }

  /* The word-building lesson a job carries: passage jobs stash it on
     provenance.wordBuilding; scope-artifact jobs serialize it as finalArtifact.
     Both are the same `artifact` shape, so one set of components renders both. */
  function wordBuildingArtifactOf(job, kind) {
    if (kind === "scope-artifact") {
      try { return JSON.parse(job.finalArtifact || "{}"); } catch (_) { return null; }
    }
    return (job.provenance && job.provenance.wordBuilding) || null;
  }

  /* Title for the Word-Building product (the job title belongs to the passage
     on passage weeks, so derive a lesson title from the scope). */
  function wordBuildingTitle(artifact, job, spec, kind) {
    if (kind === "scope-artifact") return job.title || `Week ${spec.week}`;
    const label = artifact && artifact.grapheme && artifact.grapheme.label;
    return label ? `Word building — ${label}` : `Word building · Week ${spec.week}`;
  }

  /* ════ PASSAGE PRODUCT — the decorated, hand-to-a-kid reading page ════
     Story only: appealing border + corner motifs, dyslexic-friendly type. */
  function PassageSheet({ job, spec, font }) {
    const text = job.finalArtifact || job.draft || "";
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const title = job.title || spec.title || "My reading";
    const stats = job.validator?.stats;
    const sounds = focusSoundsForWeek(spec, scopeGrade);
    const ills = pickIllustrations(text, title, 3);
    const hero = ills[0];
    const spots = ills.slice(1);
    const corner = (d) => (
      <svg viewBox="0 0 40 40" width="34" height="34" aria-hidden="true">
        <path d={d} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
      </svg>
    );
    return (
      <div className={"sheet product-sheet passage-sheet wks-" + scopeGrade.toLowerCase()} data-product="passage">
        <div className="psheet-frame">
          <span className="psheet-corner tl">{corner("M6 20a14 14 0 0 1 14-14M20 6l3 5 5-2-2 5")}</span>
          <span className="psheet-corner tr">{corner("M12 4l2 5 5 2-5 2-2 5-2-5-5-2 5-2z")}</span>
          <span className="psheet-corner bl">{corner("M6 22c4 6 12 8 16 4M10 30h12")}</span>
          <span className="psheet-corner br">{corner("M8 8h18a4 4 0 0 1 4 4v18M8 8v18h18")}</span>
        </div>
        <div className="psheet-inner">
          <SheetTop/>
          <div className="psheet-banner"><h1 className="psheet-title">{title}</h1></div>
          {sounds.length > 0 && <SoundBanner sounds={sounds}/>}
          {hero && (
            <div className="psheet-hero">
              <Illustration emoji={hero.emoji} word={hero.word} className="psheet-hero-img"/>
              {spots.map((s) => (
                <Illustration key={s.emoji} emoji={s.emoji} word={s.word} className="psheet-spot-img"/>
              ))}
            </div>
          )}
          <div className={"psheet-passage " + readingFontClass(font)}>{text}</div>
          <SheetFoot scopeGrade={scopeGrade} week={spec.week}
            extra={stats ? `${stats.decodabilityPct}% decodable` : "reading passage"}/>
          {hero && <div className="psheet-credit">Art: Twemoji · CC BY 4.0</div>}
        </div>
      </div>
    );
  }

  /* ════ WORD-BUILDING PRODUCT — the lesson / teaching page ════
     Instructions + sound graphics + words & sentences to read. A4 of useful
     info the teacher works through; the doing happens on the Worksheet. */
  function lessonSteps(artifact) {
    const L = (artifact && artifact.lesson) || {};
    const steps = [];
    if ((L.soundLessons || []).length) steps.push("Say each new sound, then trace its letter.");
    if ((artifact.groups || []).length || (L.blendingLines || []).length) steps.push("Blend the sounds to read each word.");
    if ((artifact.sentences || []).length) steps.push("Read the sentences out loud.");
    if ((L.heartWordTips || []).length) steps.push("Learn the heart words by sight — they break the rules.");
    if (!steps.length) steps.push("Say the sounds, blend the words, and read together.");
    return steps;
  }

  function WordBuildingSheet({ artifact, spec, title }) {
    const L = (artifact && artifact.lesson) || {};
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const groups = artifact.groups || [];
    const sentences = artifact.sentences || [];
    const steps = lessonSteps(artifact);
    return (
      <div className={"sheet product-sheet wb-sheet wks-" + scopeGrade.toLowerCase()} data-product="wordbuilding">
        <SheetTop/>
        <h1 className="wks-title">{title}</h1>

        <div className="wb-howto">
          <div className="wb-howto-label">How to use this lesson</div>
          <ol className="wb-steps">{steps.map((s, i) => <li key={i}>{s}</li>)}</ol>
        </div>

        {(L.soundLessons || []).length > 0 && (
          <div className="sa-section">
            <div className="sa-label">Our sounds</div>
            <div className="sa-cards">
              {L.soundLessons.map((s) => (
                <div className="sa-card" key={s.letter}>
                  <div className="sa-card-letter">{s.upper} {s.letter}</div>
                  {s.emoji && <div className="sa-card-pic"><Illustration emoji={s.emoji} word={s.keyword}/></div>}
                  <div className="sa-card-word">{s.keyword}</div>
                  {s.phoneme && <div className="wb-card-phon">{s.phoneme}</div>}
                </div>
              ))}
            </div>
          </div>
        )}

        {groups.map((g, gi) => (
          <div className="sa-section" key={gi}>
            <div className="sa-label">{g.label}</div>
            <div className="wb-wordrow">
              {(g.words || []).slice(0, 14).map((w) => <span className="sa-blend-word" key={w}>{w}</span>)}
            </div>
          </div>
        ))}

        {sentences.length > 0 && (
          <div className="sa-section">
            <div className="sa-label">Read these sentences</div>
            <div className="sa-sentences">{sentences.map((s, i) => <div className="sa-sentence" key={i}>{s}</div>)}</div>
          </div>
        )}

        {L.syllableLesson && (
          <div className="sa-section wb-rule">
            <div className="sa-label">🧩 {L.syllableLesson.title}</div>
            <div className="wb-rule-explain">{L.syllableLesson.explain}</div>
            <div className="wb-rule-egs">
              {L.syllableLesson.examples.map(([w, note], i) => (
                <span className="wb-eg" key={i}><b>{w}</b><span className="wb-eg-note">{note}</span></span>
              ))}
            </div>
          </div>
        )}

        {(L.spellingRules || []).length > 0 && (
          <div className="sa-section wb-rule">
            <div className="sa-label">✏️ Spelling rules for endings</div>
            {L.spellingRules.map((r) => (
              <div className="wb-rule-item" key={r.id}>
                <div className="wb-rule-title">{r.title}</div>
                <div className="wb-rule-explain">{r.explain}</div>
                <div className="wb-rule-egs">
                  {r.examples.map(([base, end, result], i) => (
                    <span className="wb-eg" key={i}><b>{base}</b> + {end} → <b>{result}</b></span>
                  ))}
                </div>
              </div>
            ))}
          </div>
        )}

        {(L.heartWordTips || []).length > 0 && (
          <div className="sa-section sa-cut-zone">
            <div className="sa-label">Heart words — learn by heart</div>
            <div className="sa-cards">
              {L.heartWordTips.map((h) => (
                <div className="sa-card sa-heart-card" key={h.word}>
                  <div className="sa-card-pic"><Illustration emoji="❤️" word="heart"/></div>
                  <div className="sa-heart-word">{h.word}</div>
                </div>
              ))}
            </div>
          </div>
        )}

        {(L.review || []).length > 0 && (
          <div className="sa-section">
            <div className="sa-label">Sounds we know</div>
            <div className="sa-chips">{L.review.map((g) => <span key={g} className="sa-chip review-chip">{g}</span>)}</div>
          </div>
        )}

        <SheetFoot scopeGrade={scopeGrade} week={spec.week} extra="word building"/>
      </div>
    );
  }

  /* Practice page for word-building weeks: the doing — fill the boxes, write
     the words, learn the heart words, draw. */
  function WordBuildingWorksheetSheet({ artifact, spec, title, font }) {
    const L = (artifact && artifact.lesson) || {};
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const isKG = scopeGrade === "KG";
    const focusSounds = ((artifact.grapheme && artifact.grapheme.items) || [])
      .filter((g) => !g.startsWith("-") && !g.endsWith("-"))
      .map((g) => (g.startsWith("y_long") ? "y" : g)).slice(0, 6);
    const boxes = (L.soundBoxWords || []).slice(0, 6);
    const firstGroup = (artifact.groups || [])[0];
    const writeWords = ((firstGroup && firstGroup.words) || (L.blendingLines || []).flat() || []).slice(0, 6);
    const hearts = (L.heartWordTips || []).slice(0, 4);
    return (
      <div className={"sheet product-sheet wks wks-" + scopeGrade.toLowerCase() + " " + readingFontClass(font)} data-product="worksheet">
        <SheetTop/>
        <h1 className="wks-title">{title}</h1>

        {focusSounds.length > 0 && (
          <div className="wks-warmup">
            <div className="wks-block wks-block-sounds">
              <div className="wks-sec-label">My sounds</div>
              <div className="wks-chips">{focusSounds.map((g) => <span className="wks-sound" key={g}>{g}</span>)}</div>
            </div>
          </div>
        )}

        {boxes.length > 0 && (
          <div className="sa-section">
            <div className="sa-label">Sound it out — tap each box, then write the word</div>
            <div className="sa-boxes">
              {boxes.map((b) => (
                <div className="sa-box-word" key={b.word}>
                  <div className="sa-box-row">{b.sounds.map((_, j) => <span className="sa-box" key={j}/>)}</div>
                  <div className="wks-write-line wks-box-write"/>
                </div>
              ))}
            </div>
          </div>
        )}

        {writeWords.length > 0 && (
          <div className="wks-questions">
            <div className="wks-sec-label">Read it, then write it</div>
            <div className="wb-write-list">
              {writeWords.map((w) => (
                <div className="wb-writeword" key={w}>
                  <span className="wb-writeword-cue">{w}</span>
                  <span className="wks-write-line"/>
                </div>
              ))}
            </div>
          </div>
        )}

        {hearts.length > 0 && (
          <div className="wks-questions">
            <div className="wks-sec-label">Heart words — trace and write</div>
            <div className="wb-write-list">
              {hearts.map((h) => (
                <div className="wb-writeword" key={h.word}>
                  <span className="wb-writeword-cue heart">{h.word}</span>
                  <span className="wks-write-line"/>
                </div>
              ))}
            </div>
          </div>
        )}

        <div className="wks-draw">
          <div className="wks-sec-label">Draw a picture for one of your words</div>
          <div className="wks-draw-box"/>
        </div>

        <SheetFoot scopeGrade={scopeGrade} week={spec.week} extra="word-building practice"/>
      </div>
    );
  }

  /* A single student-facing comprehension item, rendered by format:
     multiple-choice (lettered bubbles), fill-in-the-blank (with a word bank),
     or open written response (answer lines). */
  function StudentQuestion({ q, answerLines }) {
    const fmt = q.format || (q.options && q.options.length ? "mc" : "open");
    if (fmt === "mc" && (q.options || []).length >= 2) {
      return (
        <li className="wks-qi">
          <div className="wks-q">{q.question}</div>
          <div className="wks-mc">
            {q.options.map((o, k) => (
              <span className="wks-mc-opt" key={k}>
                <span className="wks-mc-bub">{String.fromCharCode(65 + k)}</span>{o}
              </span>
            ))}
          </div>
        </li>
      );
    }
    if (fmt === "fill-blank") {
      return (
        <li className="wks-qi">
          <div className="wks-q">{q.question}</div>
          {(q.options || []).length > 0 && (
            <div className="wks-wordbank">
              <span className="wks-bank-label">word bank</span>
              {q.options.map((o, k) => <span className="wks-bank-word" key={k}>{o}</span>)}
            </div>
          )}
          <div className="wks-write-line"/>
        </li>
      );
    }
    return (
      <li className="wks-qi">
        <div className="wks-q">{q.question}</div>
        {Array.from({ length: answerLines }).map((_, j) => <div className="wks-write-line" key={j}/>)}
      </li>
    );
  }

  /* ════ WORKSHEET PRODUCT (passage variant) — practice for a reading passage ════
     Warm-up, fluency tracker, comprehension. The passage itself is its own
     product, so this page references it rather than reprinting it. */
  function WorksheetSheet({ job, spec, font }) {
    const text = job.finalArtifact || job.draft || "";
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const isKG = scopeGrade === "KG";
    const focusSounds = Array.from(new Set(
      (newGraphemesAtWeek(spec.week, scopeGrade) || [])
        .map((u) => u.g)
        .filter((g) => !g.startsWith("-") && !g.endsWith("-"))
        .map((g) => (g.startsWith("y_long") ? "y" : g))   // y_long_e / y_long_i → show as "y"
    )).slice(0, 6);
    const warmUps = pickWarmUpWords(text, spec, scopeGrade);
    const questions = (job.provenance?.comprehensionQuestions || []).slice(0, 3);
    const answerLines = isKG ? 1 : 2;
    const stats = job.validator?.stats;

    return (
      <div className={"sheet product-sheet wks wks-" + scopeGrade.toLowerCase() + " " + readingFontClass(font)} data-product="worksheet">
        <SheetTop/>
        <h1 className="wks-title">{job.title || spec.title || "My reading"}</h1>
        <div className="wks-usehint">📖 Read the passage first, then complete this page.</div>

        {(focusSounds.length > 0 || warmUps.length > 0) && (
          <div className="wks-warmup">
            {focusSounds.length > 0 && (
              <div className="wks-block wks-block-sounds">
                <div className="wks-sec-label">My sounds</div>
                <div className="wks-chips">
                  {focusSounds.map((g) => <span className="wks-sound" key={g}>{g}</span>)}
                </div>
              </div>
            )}
            {warmUps.length > 0 && (
              <div className="wks-block wks-block-words">
                <div className="wks-sec-label">Warm-up words</div>
                <div className="wks-chips">
                  {warmUps.map((w) => <span className="wks-word" key={w}>{w}</span>)}
                </div>
              </div>
            )}
          </div>
        )}

        <div className="wks-reread">
          <span className="wks-sec-label">I read it</span>
          {[1, 2, 3].map((n) => (
            <span className="wks-star" key={n} aria-hidden="true">
              <svg viewBox="0 0 24 24" width="32" height="32">
                <path d="M12 2.6l2.8 5.9 6.4.8-4.7 4.4 1.2 6.3L12 17l-5.7 3 1.2-6.3L2.8 9.3l6.4-.8z"
                  fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/>
              </svg>
            </span>
          ))}
          <span className="wks-reread-hint">Colour a star each time you read.</span>
        </div>

        {questions.length > 0 && (
          <div className="wks-questions">
            <div className="wks-sec-label">Think about it</div>
            <ol className="wks-q-list">
              {questions.map((q, i) => (
                <StudentQuestion key={i} q={q} answerLines={answerLines}/>
              ))}
            </ol>
          </div>
        )}

        {isKG && (
          <div className="wks-draw">
            <div className="wks-sec-label">
              {spec.template === "informational" ? "Draw what you learned" : "Draw the story"}
            </div>
            <div className="wks-draw-box"/>
          </div>
        )}

        <SheetFoot scopeGrade={scopeGrade} week={spec.week}
          extra={stats ? `${stats.decodabilityPct}% decodable` : ""}/>
      </div>
    );
  }

  /* Teacher reference: the passage with decodability markup. Lives in the
     ResultTabs panel (screen-only), never on the sellable student sheets. */
  function AnnotatedPassage({ job, spec }) {
    const text = job.finalArtifact || job.draft || "";
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const cumulativeScope = window.STUDIO_DATA.getCumulativeScope(scopeGrade, spec.week);
    const annotated = (text && window.STUDIO_VALIDATOR)
      ? window.STUDIO_VALIDATOR.annotatePassage(text, { ...spec, targetWords: normalizeWordList(spec.targetWords) }, cumulativeScope)
      : null;
    const chunks = annotated?.chunks;
    return (
      <div className="annotated-passage">
        <div className="pc-title">{job.title || (spec.title || "Untitled passage")}</div>
        <div className="passage-body">
          {chunks
            ? chunks.map((chunk, i) => {
                if (chunk.kind === "plain") return chunk.text;
                return <span key={i} className={chunk.kind}>{chunk.text}</span>;
              })
            : text}
        </div>
        {chunks && (
          <div className="pc-legend">
            <span className="pc-legend-item"><span className="pc-swatch target"/>target word</span>
            <span className="pc-legend-item"><span className="pc-swatch sight"/>sight / heart word</span>
            <span className="pc-legend-item"><span className="pc-swatch untaught"/>untaught pattern</span>
          </div>
        )}
      </div>
    );
  }

  /* ── Per-product download / print helpers ── */

  function downloadBlob(content, type, filename) {
    const blob = new Blob([content], { type });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = filename; a.click();
    URL.revokeObjectURL(url);
  }

  function slugify(s) { return String(s || "page").replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "page"; }

  /* One-click PDF of a single product sheet. Falls back to Print if html2pdf
     hasn't loaded (CDN blocked / offline) so the action never dead-ends. */
  function downloadProductPdf(node, filename) {
    if (!node) return;
    if (!window.html2pdf) { printNode(node); return; }
    const opt = {
      margin: [8, 8, 8, 8],
      filename: filename + ".pdf",
      image: { type: "jpeg", quality: 0.98 },
      html2canvas: { scale: 2, useCORS: true, backgroundColor: "#ffffff", windowWidth: node.scrollWidth },
      jsPDF: { unit: "mm", format: "a4", orientation: "portrait" },
      pagebreak: { mode: ["css", "legacy"] }
    };
    try { window.html2pdf().set(opt).from(node).save(); }
    catch (e) { printNode(node); }
  }

  /* Print just the active product. Only one product sheet is mounted at a
     time, so the print stylesheet (which prints .product-sheet and hides app
     chrome) naturally emits a single A4 page. */
  function printNode() { window.print(); }

  /* Flatten a product to plain text for the .txt export. */
  function flattenProductText(productId, job, spec, kind, wb) {
    const lines = [];
    const text = job.finalArtifact || job.draft || "";
    if (productId === "passage") {
      lines.push(job.title || "Reading passage", "", text, "", "----", buildProvenanceText(job));
    } else if (productId === "worksheet" && kind === "passage") {
      lines.push((job.title || "Worksheet") + " — Worksheet", "", "Read the passage, then answer:");
      (job.provenance?.comprehensionQuestions || []).slice(0, 3).forEach((q, i) => {
        lines.push(`  ${i + 1}. ${q.question}`);
        if ((q.options || []).length) {
          q.options.forEach((o, k) => lines.push(`      ${String.fromCharCode(65 + k)}. ${o}`));
        }
      });
    } else if (productId === "fluency" && wb) {
      lines.push("Roll & Read" + (wb.grapheme?.label ? ` — ${wb.grapheme.label}` : ""), "",
        "Roll a die, read down that column:");
      const fw = fluencyWords(wb);
      for (let i = 0; i < fw.length; i += 6) lines.push("  " + fw.slice(i, i + 6).join("   "));
    } else if (wb) {
      const L = wb.lesson || {};
      lines.push((productId === "worksheet" ? "Word-building practice" : "Word building") + (wb.grapheme?.label ? ` — ${wb.grapheme.label}` : ""), "");
      if (productId !== "worksheet") {
        (L.soundLessons || []).forEach((s) => lines.push(`  ${s.upper}${s.letter}  ${s.phoneme || ""}  (${s.keyword || ""})`));
        (wb.groups || []).forEach((g) => lines.push(`${g.label}: ${(g.words || []).join(", ")}`));
        (wb.sentences || []).forEach((s) => lines.push(`  ${s}`));
        if ((L.heartWordTips || []).length) lines.push("Heart words: " + L.heartWordTips.map((h) => h.word).join(", "));
      } else {
        (L.soundBoxWords || []).forEach((b) => lines.push(`  ${b.word}: ${b.sounds.map(() => "[ ]").join(" ")}`));
        const firstGroup = (wb.groups || [])[0];
        if (firstGroup) lines.push("Write the words: " + (firstGroup.words || []).slice(0, 6).join(", "));
        if ((L.heartWordTips || []).length) lines.push("Heart words to write: " + L.heartWordTips.map((h) => h.word).join(", "));
      }
    }
    return lines.filter((l) => l !== undefined).join("\n");
  }

  /* ════ ROLL & READ — UFLI-style fluency grid ════
     Decodable words from the week's scope laid into a 6-column grid. The child
     rolls a die, reads down that column (or across rows) to build automaticity. */
  function fluencyWords(wb) {
    const L = (wb && wb.lesson) || {};
    const out = [];
    const seen = new Set();
    const push = (w) => {
      const s = String(w || "").toLowerCase().trim();
      if (s && /^[a-z']+$/.test(s) && s.length <= 9 && !seen.has(s)) { seen.add(s); out.push(s); }
    };
    (wb.groups || []).forEach((g) => (g.words || []).forEach(push));
    (L.blendingLines || []).forEach((row) => row.forEach(push));
    (L.soundBoxWords || []).forEach((b) => push(b.word));
    return out.slice(0, 36);
  }

  function DieFace({ n }) {
    // pip positions on a 3x3 grid (0..2)
    const P = { tl: [1, 1], tm: [2, 1], tr: [3, 1], ml: [1, 2], mm: [2, 2], mr: [3, 2], bl: [1, 3], bm: [2, 3], br: [3, 3] };
    const faces = { 1: ["mm"], 2: ["tl", "br"], 3: ["tl", "mm", "br"], 4: ["tl", "tr", "bl", "br"], 5: ["tl", "tr", "mm", "bl", "br"], 6: ["tl", "tr", "ml", "mr", "bl", "br"] };
    return (
      <svg viewBox="0 0 16 16" width="26" height="26" aria-label={`die ${n}`}>
        <rect x="1" y="1" width="14" height="14" rx="3" fill="#fff" stroke="currentColor" strokeWidth="1.4"/>
        {(faces[n] || []).map((k, i) => <circle key={i} cx={P[k][0] * 4} cy={P[k][1] * 4} r="1.3" fill="currentColor"/>)}
      </svg>
    );
  }

  function RollAndReadSheet({ words, spec, title, font }) {
    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const cols = 6;
    const rows = [];
    for (let i = 0; i < words.length; i += cols) rows.push(words.slice(i, i + cols));
    // keep only full-ish rows for a tidy grid
    const grid = rows.filter((r) => r.length >= 3).slice(0, 6);
    return (
      <div className={"sheet product-sheet rr-sheet wks-" + scopeGrade.toLowerCase() + " " + readingFontClass(font)} data-product="fluency">
        <SheetTop/>
        <h1 className="wks-title">Roll &amp; Read{title ? " — " + title.replace(/^Word building — /, "") : ""}</h1>
        <div className="rr-howto">🎲 Roll a die. Find that number column. Read the word — then read the whole row. Roll again!</div>
        <table className="rr-grid">
          <thead>
            <tr>{[1, 2, 3, 4, 5, 6].map((n) => <th key={n}><DieFace n={n}/></th>)}</tr>
          </thead>
          <tbody>
            {grid.map((row, ri) => (
              <tr key={ri}>
                {Array.from({ length: cols }).map((_, ci) => <td key={ci}>{row[ci] || ""}</td>)}
              </tr>
            ))}
          </tbody>
        </table>
        <SheetFoot scopeGrade={scopeGrade} week={spec.week} extra="fluency · roll & read"/>
      </div>
    );
  }

  /* ════ RESULT WORKSPACE — product switcher + per-product student sheets ════
     One job → up to four independently downloadable A4 products. Only the
     active product is mounted, so Print / PDF target exactly that page. */
  function ResultWorkspace({ job, spec, kind, tweaks, font, setFont }) {
    const provenance = job.provenance || {};
    const validator = job.validator;
    const cls = validator ? (validator.passed ? "ok" : "fail") : "";
    const sellable = kind === "scope-artifact" || (provenance.sellable !== false && (provenance.stages || []).length > 0);
    const wb = wordBuildingArtifactOf(job, kind);
    const wbTitle = wb ? wordBuildingTitle(wb, job, spec, kind) : "";
    const fWords = wb ? fluencyWords(wb) : [];
    const fluencyProduct = fWords.length >= 12
      ? { id: "fluency", label: "Roll & Read", icon: "🎲", reading: true,
          render: () => <RollAndReadSheet words={fWords} spec={spec} title={wbTitle} font={font}/> }
      : null;

    const products = [];
    if (kind === "passage") {
      products.push({ id: "passage", label: "Passage", icon: "📖", reading: true,
        render: () => <PassageSheet job={job} spec={spec} font={font}/> });
      products.push({ id: "worksheet", label: "Worksheet", icon: "✏️", reading: true,
        render: () => <WorksheetSheet job={job} spec={spec} font={font}/> });
      if (wb) products.push({ id: "wordbuilding", label: "Word Building", icon: "🔤",
        render: () => <WordBuildingSheet artifact={wb} spec={spec} title={wbTitle}/> });
    } else if (wb) {
      products.push({ id: "wordbuilding", label: "Word Building", icon: "🔤",
        render: () => <WordBuildingSheet artifact={wb} spec={spec} title={wbTitle}/> });
      products.push({ id: "worksheet", label: "Worksheet", icon: "✏️", reading: true,
        render: () => <WordBuildingWorksheetSheet artifact={wb} spec={spec} title={wbTitle} font={font}/> });
    }
    if (fluencyProduct) products.push(fluencyProduct);

    const [active, setActive] = React.useState(products[0] ? products[0].id : "passage");
    React.useEffect(() => {
      if (!products.find((p) => p.id === active)) setActive(products[0] ? products[0].id : "passage");
    }, [job.id]);
    const activeProduct = products.find((p) => p.id === active) || products[0];
    const stageRef = React.useRef(null);

    if (!activeProduct) return null;

    const baseName = () => slugify(`${activeProduct.label}-${job.title || "week-" + spec.week}`);
    function copyActive() { try { navigator.clipboard.writeText(flattenProductText(activeProduct.id, job, spec, kind, wb)); } catch (e) {} }
    function exportActiveTxt() { downloadBlob(flattenProductText(activeProduct.id, job, spec, kind, wb), "text/plain", baseName() + ".txt"); }
    function pdfActive() {
      const node = stageRef.current && stageRef.current.querySelector(".product-sheet");
      downloadProductPdf(node, baseName());
    }
    function printActive() {
      // Only the active product sheet is mounted, so a plain print emits it.
      window.print();
    }

    return (
      <div className="passage-card result-workspace">
        <div className="pc-meta">
          <span className="meta-pill">Week {spec.week}</span>
          <span className="meta-pill">Grade {spec.grade}</span>
          {kind === "passage" && <span className="meta-pill">{PASSAGE_TEMPLATES.find((p) => p.id === spec.template)?.label || spec.template}</span>}
          {validator && <span className={"meta-pill " + cls}>{validator.passed ? "Validated" : "Failed checks"}</span>}
          {validator?.stats && <span className="meta-pill">{validator.stats.decodabilityPct}% decodable</span>}
          <span className={"meta-pill " + (sellable ? "ok" : "warn")}>{sellable ? "Sellable" : "TOS-restricted"}</span>
          {Array.isArray(provenance.ccssTags) && provenance.ccssTags.map((tag) => (
            <span key={tag} className="meta-pill ccss" title={`CCSS standard ${tag}`}>{tag}</span>
          ))}
        </div>

        <div className="product-switch" role="tablist" aria-label="Products">
          {products.map((p) => (
            <button key={p.id} type="button" role="tab" aria-selected={active === p.id}
              className={"product-tab" + (active === p.id ? " is-active" : "")} onClick={() => setActive(p.id)}>
              <span className="product-tab-ico" aria-hidden="true">{p.icon}</span>
              <span>{p.label}</span>
            </button>
          ))}
        </div>

        {activeProduct.reading && (
          <div className="product-toolbar">
            <div className="font-toggle" role="radiogroup" aria-label="Reading font">
              <span className="font-toggle-label">Reading font</span>
              <button type="button" role="radio" aria-checked={font !== "dyslexic"}
                className={"ft-opt" + (font !== "dyslexic" ? " active" : "")} onClick={() => setFont("andika")}>Andika</button>
              <button type="button" role="radio" aria-checked={font === "dyslexic"}
                className={"ft-opt" + (font === "dyslexic" ? " active" : "")} onClick={() => setFont("dyslexic")}>OpenDyslexic</button>
            </div>
          </div>
        )}

        <div className="product-stage" ref={stageRef}>
          {activeProduct.render()}
        </div>

        <div className="passage-actions">
          <button className="tool-btn" onClick={copyActive}>{Icon.copy()}Copy</button>
          <button className="tool-btn" onClick={exportActiveTxt}>{Icon.download()}Export .txt</button>
          <button className="tool-btn" onClick={printActive}>{Icon.print()}Print this page</button>
          <button className="tool-btn ws-generate-mini" onClick={pdfActive}>{Icon.download()}Download PDF</button>
        </div>
      </div>
    );
  }

  /* Renders a deterministic pre-decodable / word-building artifact (early weeks
     where a connected passage isn't possible). The body is a JSON artifact. */
  /* Teacher coaching notes for a word-building lesson. Screen-only (a
     ResultTabs panel), parametrised by the parsed word-building artifact so it
     works for both passage jobs (provenance.wordBuilding) and scope-artifact
     jobs (finalArtifact). */
  function TeacherNotes({ artifact, job, spec }) {
    const art = artifact || {};
    const L = art.lesson || {};
    const spell = (w) => String(w).split("").join("-");
    return (
      <div className="sa-teacher">
        <div className="pc-title">{job.title || `Week ${spec.week}`}</div>
        {art.note && <div className="sa-intro">{art.note}</div>}

            {L.readinessFocus && (
              <div className="sa-section">
                <div className="sa-label">This week's focus</div>
                <div className="sa-activity">{L.readinessFocus}</div>
              </div>
            )}

            {(L.soundLessons || []).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">Sound coaching</div>
                <div className="sa-sounds">
                  {L.soundLessons.map((s) => (
                    <div className="sa-sound" key={s.letter}>
                      <div className="sa-sound-head">
                        <span className="sa-letter">{s.upper} {s.letter}</span>
                        <span className="sa-phoneme">{s.phoneme}</span>
                        <span className={"sa-type sa-type-" + s.type}>{s.type}</span>
                        {s.keyword && <span className="sa-keyword">as in <strong>{s.keyword}</strong></span>}
                        <span className={"sa-diff sa-diff-" + (s.l1 === "easy" ? "easy" : "watch")}>{s.difficulty}</span>
                      </div>
                      <div className="sa-sound-row"><span className="sa-ic">🗣️</span><span><strong>Say it:</strong> {s.say}</span></div>
                      <div className={"sa-l1 sa-l1-" + s.l1}>
                        <span className="sa-ic">🇨🇳</span>
                        <span><strong>For Mandarin families:</strong> {s.l1Note}</span>
                      </div>
                      <div className="sa-sound-row"><span className="sa-ic">✏️</span><span><strong>Write it</strong> (lowercase {s.letter}): {s.form}</span></div>
                      <div className="sa-sound-row sa-blend"><span className="sa-ic">🔗</span><span>{s.blendTip}</span></div>
                    </div>
                  ))}
                </div>
              </div>
            )}

            {(Array.isArray(L.phonemicAwareness) ? L.phonemicAwareness : (L.phonemicAwareness ? [L.phonemicAwareness] : [])).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">👂 Phonemic awareness</div>
                <div className="sa-stack">
                  {(Array.isArray(L.phonemicAwareness) ? L.phonemicAwareness : [L.phonemicAwareness]).map((p, i) => (
                    <div className="sa-activity" key={i}>{p}</div>
                  ))}
                </div>
              </div>
            )}

            {(L.heartWordTips || []).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">❤️ Teaching the heart word{L.heartWordTips.length > 1 ? "s" : ""}</div>
                {L.heartWordTips.map((h) => (
                  <div className="sa-heart" key={h.word}>
                    <span className="sa-chip heart-chip">{h.word}</span>
                    <span className="sa-heart-tip">read it · spell it (<em>{spell(h.word)}</em>) · say it aloud in a sentence: <em>"{h.sentence}"</em></span>
                  </div>
                ))}
              </div>
            )}

            {(L.printConcepts || []).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">Concepts of print</div>
                <ul className="sa-list">{L.printConcepts.map((p, i) => <li key={i}>{p}</li>)}</ul>
              </div>
            )}

            {(L.review || []).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">Sounds learned so far</div>
                <div className="sa-chips">{L.review.map((g) => <span key={g} className="sa-chip review-chip">{g}</span>)}</div>
              </div>
            )}

            {L.quickCheck && (
              <div className="sa-section">
                <div className="sa-label">✓ Quick check</div>
                <div className="sa-activity">{L.quickCheck}</div>
              </div>
            )}

            {(L.activities || []).length > 0 && (
              <div className="sa-section">
                <div className="sa-label">✏️ Try this</div>
                <ul className="sa-list">{L.activities.map((a, i) => <li key={i}>{a}</li>)}</ul>
              </div>
            )}

            {L.parentTip && (
              <div className="sa-section sa-parent">
                <div className="sa-label">🏠 Send home</div>
                <div className="sa-activity">{L.parentTip}</div>
              </div>
            )}
      </div>
    );
  }

  /* Shown when a passage genuinely failed validation (away from the floor,
     where the word-building fallback doesn't apply). Never renders the broken
     body — only an explanation, a retry, and the validator detail. */
  function ErrorState({ job, onRetry, busy }) {
    const failTrace = (job.trace || []).filter((t) => t.status === "failed").slice(-1)[0];
    return (
      <div className="ws-empty ws-error">
        <div className="we-mark">{Mascot.book ? Mascot.book() : null}</div>
        <h3>This passage didn't pass the checks</h3>
        <p>
          The generator couldn't produce a passage that meets every decodability and
          quality rule for this week.{failTrace?.info ? ` (${failTrace.info})` : ""} Nothing
          broken is shown — try again, or adjust the scope.
        </p>
        <button className="tool-btn" style={{ background: "var(--c-reading)" }} onClick={onRetry} disabled={busy}>
          {Icon.refresh ? Icon.refresh() : null}Try again
        </button>
        <ResultTabs job={job} tweaks={{}}/>
      </div>
    );
  }

  function QuestionsCard({ questions }) {
    if (!Array.isArray(questions) || questions.length === 0) return null;
    return (
      <div className="validator-report">
        <h3>
          {Icon.copy()}Comprehension questions
          <span className="badge-pill pass">{questions.length}</span>
        </h3>
        <ol className="questions-list">
          {questions.map((q, i) => {
            const fmt = q.format || (q.options && q.options.length ? "mc" : "open");
            return (
              <li key={i}>
                <div className="q-text">{q.question}</div>
                {fmt === "mc" && (q.options || []).length > 0 && (
                  <div className="q-options">
                    {q.options.map((o, k) => {
                      const correct = String(o).toLowerCase() === String(q.answer).toLowerCase();
                      return (
                        <span key={k} className={"q-opt" + (correct ? " correct" : "")}>
                          {String.fromCharCode(65 + k)}. {o}{correct ? " ✓" : ""}
                        </span>
                      );
                    })}
                  </div>
                )}
                <div className="q-answer"><strong>Answer:</strong> {q.answer}</div>
                <div className="q-meta">
                  {fmt}{q.type ? ` · ${q.type}` : ""}{q.ccss ? ` · ${q.ccss}` : ""}{q.difficulty ? ` · ${q.difficulty}` : ""}
                </div>
              </li>
            );
          })}
        </ol>
      </div>
    );
  }

  /* Tabbed container for the secondary panels: teacher references (annotated
     decodability, teacher notes), plus validator / questions / provenance /
     trace. Screen-only — the sellable product sheets live above this. */
  function ResultTabs({ job, spec, kind, tweaks }) {
    const questions = job.provenance?.comprehensionQuestions || [];
    const hasQuestions = questions.length > 0;
    const hasProvenance = job.provenance?.stages?.length > 0;
    const hasTrace = (job.trace || []).length > 0 && tweaks.showAgentTrace !== false;
    const validator = job.validator;
    const checksTotal = validator?.checks?.length || 0;
    const checksPass = (validator?.checks || []).filter((c) => c.pass).length;
    const wb = (spec && kind) ? wordBuildingArtifactOf(job, kind) : null;
    const isPassage = kind === "passage";

    const tabs = [
      isPassage && spec && {
        id: "decodability",
        label: "Decodability",
        badge: "info",
        badgeText: "teacher",
        render: () => <AnnotatedPassage job={job} spec={spec}/>
      },
      wb && spec && {
        id: "teacher",
        label: "Teacher notes",
        badge: "info",
        badgeText: null,
        render: () => <TeacherNotes artifact={wb} job={job} spec={spec}/>
      },
      {
        id: "validator",
        label: "Validator",
        badge: validator
          ? (validator.passed ? "pass" : "fail")
          : null,
        badgeText: validator ? `${checksPass}/${checksTotal}` : null,
        render: () => <ValidatorReport report={validator}/>
      },
      hasQuestions && {
        id: "questions",
        label: "Questions",
        badge: "info",
        badgeText: String(questions.length),
        render: () => <QuestionsCard questions={questions}/>
      },
      hasProvenance && {
        id: "provenance",
        label: "Provenance",
        badge: job.provenance?.sellable !== false ? "pass" : "warn",
        badgeText: job.provenance.sellable !== false ? "OK" : "TOS",
        render: () => <ProvenanceCard provenance={job.provenance}/>
      },
      hasTrace && {
        id: "trace",
        label: "Trace",
        badge: null,
        badgeText: `${(job.trace || []).length}`,
        render: () => <AgentTrace trace={job.trace} showAgentTrace={true}/>
      }
    ].filter(Boolean);

    const [active, setActive] = React.useState(tabs[0]?.id || "validator");
    React.useEffect(() => {
      // If the active tab disappears (e.g. job changes), reset to first.
      if (!tabs.find((t) => t.id === active)) setActive(tabs[0]?.id || "validator");
    }, [job.id]);

    if (!tabs.length) return null;
    const activeTab = tabs.find((t) => t.id === active) || tabs[0];

    return (
      <div className="result-tabs">
        <div className="result-tab-strip" role="tablist">
          {tabs.map((t) => (
            <button
              key={t.id}
              type="button"
              role="tab"
              aria-selected={active === t.id}
              className={"result-tab" + (active === t.id ? " is-active" : "")}
              onClick={() => setActive(t.id)}
            >
              <span className="result-tab-label">{t.label}</span>
              {t.badgeText && (
                <span className={"result-tab-badge " + (t.badge || "info")}>{t.badgeText}</span>
              )}
            </button>
          ))}
        </div>
        <div className="result-tab-panel" role="tabpanel">
          {activeTab.render()}
        </div>
      </div>
    );
  }

  function ProvenanceCard({ provenance }) {
    if (!provenance || !provenance.stages || !provenance.stages.length) return null;
    const sellable = provenance.sellable !== false;
    return (
      <div className="validator-report">
        <h3>
          {Icon.copy()}Provenance
          <span className={"badge-pill " + (sellable ? "pass" : "fail")}>
            {sellable ? "Sellable" : "TOS-restricted"}
          </span>
        </h3>
        <ul className="validator-list">
          {provenance.stages.map((s, i) => (
            <li key={i} className={s.sellable ? "ok" : "fail"}>
              <span className="v-mark">{s.sellable ? "✓" : "!"}</span>
              <div>
                <div><strong>{s.stage}</strong> · {s.model}</div>
                <div className="v-detail">{s.license}{s.tokensOut ? ` · ${s.tokensOut} tokens out` : ""}</div>
              </div>
            </li>
          ))}
        </ul>
        {provenance.summary && (
          <div style={{ marginTop: 10, fontSize: 12, color: "var(--ink-soft)", fontWeight: 700 }}>
            {provenance.summary}
          </div>
        )}
      </div>
    );
  }

  /* ----- Main workspace ----- */

  function StudioDecodable({ apiFetch, teacher, tweaks, showToast, jobs, setJobs }) {
    // Every week 1..max is selectable now; early weeks produce non-passage
    // artifacts rather than being hidden.
    function clampPassageWeek(week, gradeLevel) {
      const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(gradeLevel || "1");
      const maxW = window.STUDIO_DATA.maxWeekForGrade(scopeGrade);
      const n = Math.round(Number(week));
      const safe = Number.isFinite(n) ? n : 1;
      return Math.max(1, Math.min(maxW, safe));
    }

    // Word count follows the curriculum §3 length bracket for the week. A
    // supplied value is clamped into the bracket; otherwise the midpoint.
    function bandWordCount(gradeLevel, week, current) {
      const sg = window.STUDIO_DATA.scopeGradeForLevel(gradeLevel || "1");
      const band = window.STUDIO_DATA.passageLengthBand
        ? window.STUDIO_DATA.passageLengthBand(sg, week) : null;
      if (!band) return current;
      const n = Math.round(Number(current));
      if (Number.isFinite(n)) return Math.max(band.min, Math.min(band.max, n));
      return band.target;
    }

    function normalizePassageSpec(raw) {
      const next = { ...DEFAULT_SPEC, ...(raw || {}) };
      next.week = clampPassageWeek(next.week, next.grade);
      /* Demo build: every spec must land on a combination that has a
         pre-written, pre-validated passage. */
      if (window.STUDIO_DEMO) {
        const grades = window.STUDIO_DEMO.gradesAvailable();
        if (grades.length && grades.indexOf(String(next.grade)) < 0) next.grade = grades[0];
        const weeks = window.STUDIO_DEMO.weeksFor(next.grade);
        if (weeks.length && weeks.indexOf(next.week) < 0) next.week = weeks[0];
        const tpls = window.STUDIO_DEMO.templatesFor(next.grade, next.week);
        if (tpls.length && tpls.indexOf(next.template) < 0) next.template = tpls[0];
        const sg = window.STUDIO_DATA.scopeGradeForLevel(next.grade);
        const defaults = window.STUDIO_DATA.SPEC_DEFAULTS_BY_GRADE[sg];
        if (defaults) Object.assign(next, defaults);
        const p = window.STUDIO_DEMO.findPassage(next);
        if (p && !raw?.targetWords) next.targetWords = p.targetWords;
      }
      next.wordCount = bandWordCount(next.grade, next.week, next.wordCount);
      return next;
    }

    // Resolve a job's artifact kind: "scope-artifact" (deterministic
    // pre-decodable / word-building JSON) vs "passage".
    function artifactKindOf(job) {
      const k = job && job.provenance && job.provenance.artifactKind;
      if (k === "word-building" || k === "pre-decodable") return "scope-artifact";
      const body = (job && (job.finalArtifact || job.draft)) || "";
      if (typeof body === "string" && /^\s*\{\s*"kind"\s*:\s*"(word-building|pre-decodable)"/.test(body)) return "scope-artifact";
      return "passage";
    }

    const [spec, setSpec] = React.useState(() => normalizePassageSpec(DEFAULT_SPEC));
    const [currentJob, setCurrentJob] = React.useState(null);
    const [busy, setBusy] = React.useState(false);
    const [serverErr, setServerErr] = React.useState("");
    const [modelChains, setModelChains] = React.useState(null);
    const [lastFailedSpec, setLastFailedSpec] = React.useState(null);
    const [showScope, setShowScope] = React.useState(false);
    const [showAdvanced, setShowAdvanced] = React.useState(false);
    const [showRecent, setShowRecent] = React.useState(false);
    // Reading font for the printable Passage / Worksheet products. Default
    // Andika (beginning readers); OpenDyslexic on toggle. Persisted.
    const [readingFont, setReadingFontState] = React.useState(() => {
      try { return localStorage.getItem("passage-studio.readingFont") || "andika"; } catch (_) { return "andika"; }
    });
    const setReadingFont = React.useCallback((f) => {
      setReadingFontState(f);
      try { localStorage.setItem("passage-studio.readingFont", f); } catch (_) {}
    }, []);

    /* Pull the live model stack so teachers can see what will generate. */
    React.useEffect(() => {
      let cancelled = false;
      (async () => {
        try {
          const result = await apiFetch("/api/config/models");
          if (!cancelled) setModelChains(result.chains);
        } catch (_) { /* non-fatal */ }
      })();
      return () => { cancelled = true; };
    }, []);

    const decodableJobs = React.useMemo(
      () => (jobs || []).filter((j) => j.toolId === "decodable").slice(0, 25),
      [jobs]
    );

    React.useEffect(() => {
      if (currentJob || decodableJobs.length === 0) return;
      setCurrentJob(decodableJobs[0]);
      // Load its spec as the next-generation baseline, clamped to valid passage weeks.
      if (decodableJobs[0].spec) setSpec(normalizePassageSpec(decodableJobs[0].spec));
    }, [decodableJobs, currentJob]);

    function bind(field, parse) {
      return (event) => {
        const raw = event.target ? event.target.value : event;
        const v = parse ? parse(raw) : raw;
        setSpec((prev) => {
          const next = { ...prev, [field]: v };
          // When grade changes, clamp week to the new grade's max week
          // and snap curriculum-aligned defaults (word count, sentence cap,
          // decodability floor, sight-word cap) for the new scope grade.
          if (field === "grade") {
            const sg = window.STUDIO_DATA.scopeGradeForLevel(v);
            const defaults = window.STUDIO_DATA.SPEC_DEFAULTS_BY_GRADE[sg];
            if (defaults) Object.assign(next, defaults);
          }
          next.week = clampPassageWeek(next.week, next.grade);
          /* Demo build: snap to a scope combination that has a pre-written
             passage, so the pickers can never land on an empty selection, and
             carry that passage's target words across — the targets belong to
             the scope, so keeping the old grade's words would (correctly) fail
             the validator. */
          if (window.STUDIO_DEMO && (field === "grade" || field === "week" || field === "template")) {
            const weeks = window.STUDIO_DEMO.weeksFor(next.grade);
            if (weeks.length && weeks.indexOf(next.week) < 0) next.week = weeks[0];
            const tpls = window.STUDIO_DEMO.templatesFor(next.grade, next.week);
            if (tpls.length && tpls.indexOf(next.template) < 0) next.template = tpls[0];
            const p = window.STUDIO_DEMO.findPassage(next);
            if (p) next.targetWords = p.targetWords;
          }
          // Keep word count aligned to the curriculum length bracket. Scope
          // changes snap to the bracket midpoint; an explicit edit is clamped.
          if (field === "grade" || field === "week") {
            next.wordCount = bandWordCount(next.grade, next.week);
          } else if (field === "wordCount") {
            next.wordCount = bandWordCount(next.grade, next.week, v);
          }
          return next;
        });
      };
    }

    async function generate(retrySpec) {
      const effectiveSpec = normalizePassageSpec(retrySpec || spec);
      const targets = normalizeWordList(effectiveSpec.targetWords);
      const payload = {
        toolId: "decodable",
        spec: { ...effectiveSpec, targetWords: targets }
      };
      const optimistic = newJob("decodable", payload.spec);
      optimistic.title = effectiveSpec.title || "Generating…";
      optimistic.status = "running";
      appendTrace(optimistic, "planning", "active", "Picking phonics targets…");
      setCurrentJob(optimistic);
      setBusy(true);
      setServerErr("");
      setLastFailedSpec(null);

      const finalize = (job) => {
        // Deterministic pre-decodable / word-building artifacts ship a
        // synthesized (passing) validator — don't run the passage validator on
        // their JSON body.
        if (artifactKindOf(job) === "scope-artifact") {
          setCurrentJob(job);
          setJobs((prev) => [job, ...((prev || []).filter((j) => j.id !== job.id))].slice(0, 100));
          showToast("Ready");
          return;
        }
        const sg = window.STUDIO_DATA.scopeGradeForLevel(effectiveSpec.grade || "1");
        const cumScope = window.STUDIO_DATA.getCumulativeScope(sg, effectiveSpec.week);
        const liveValidator = window.STUDIO_VALIDATOR.validatePassage(
          job.finalArtifact || job.draft,
          { ...effectiveSpec, targetWords: targets },
          cumScope
        );
        job.validator = liveValidator;
        setCurrentJob(job);
        setJobs((prev) => [job, ...((prev || []).filter((j) => j.id !== job.id))].slice(0, 100));
        showToast(liveValidator.passed ? "Validated" : "Checks failed — see the validator");
      };

      try {
        const result = await apiFetch("/api/jobs/run", {
          method: "POST",
          body: JSON.stringify(payload)
        });
        const initialJob = result.job;

        if (initialJob.status !== "running") {
          /* Legacy synchronous path — server already finished. */
          finalize(initialJob);
          return;
        }

        /* Async path — server returned {status: 'running'}. Poll until done.
           Show incremental trace updates so the UI feels alive. */
        const jobId = initialJob.id;
        setCurrentJob(initialJob);

        const POLL_MS = 700;   /* demo: the recorded trace replays quickly */
        const MAX_POLLS = 80; // 80 * 700ms ≈ 56s, well past the 7s replay
        for (let i = 0; i < MAX_POLLS; i += 1) {
          await new Promise((r) => setTimeout(r, POLL_MS));
          let polled;
          try {
            polled = await apiFetch(`/api/jobs/${jobId}`, { method: "GET" });
          } catch (err) {
            // transient network error — keep polling
            continue;
          }
          const job = polled.job;
          setCurrentJob(job);
          if (job.status === "done" || job.status === "failed") {
            finalize(job);
            if (job.status === "failed") {
              const lastTrace = (job.trace || []).filter((t) => t.status === "failed").slice(-1)[0];
              setServerErr(lastTrace ? `Agent failed: ${lastTrace.info || "unknown"}` : "Generation failed.");
              setLastFailedSpec(effectiveSpec);
            }
            return;
          }
        }

        /* Polling timed out client-side. The server sweeper will eventually
           mark the row failed; show that state. */
        setServerErr("The replay stalled — that shouldn't happen here. Try again.");
        setLastFailedSpec(effectiveSpec);
        showToast("Stalled — try again");
      } catch (err) {
        setServerErr(err.message || "Generation failed.");
        setLastFailedSpec(effectiveSpec);
        showToast("Generation failed");
      } finally {
        setBusy(false);
      }
    }

    function pickHistory(job) {
      setCurrentJob(job);
      if (job.spec) setSpec(normalizePassageSpec(job.spec));
    }

    const scopeGrade = window.STUDIO_DATA.scopeGradeForLevel(spec.grade || "1");
    const gradeScope = window.STUDIO_DATA.SCOPE_BY_GRADE[scopeGrade] || [];
    // Every week selectable; early weeks route to non-passage artifacts.
    /* Demo build: the live app generates for any week; here the picker is
       limited to the weeks that have a pre-written, pre-validated passage so
       every run ends in a genuine pass. */
    const demoWeeks = window.STUDIO_DEMO.weeksFor(spec.grade);
    const passageWeekRows = gradeScope.filter((r) => demoWeeks.indexOf(r.week) > -1);
    const modeForWeek = (wk) => window.STUDIO_DATA.outputModeForWeek
      ? window.STUDIO_DATA.outputModeForWeek(scopeGrade, wk) : "passage";
    const currentMode = modeForWeek(spec.week);
    const weekRow = gradeScope.find((r) => r.week === spec.week) || gradeScope[0] || { week: 1, phase: "", new: [] };
    const taughtCount = Array.from(window.STUDIO_DATA.graphemesThroughWeek(spec.week, scopeGrade)).length;

    return (
      <div className="workspace">
        <aside className="ws-controls">
          {/* ╔════ SCOPE ════╗ */}
          <div className="ws-group">
            <div className="ws-group-label">
              <span className="ws-group-dot" style={{ background: "var(--c-decodable)" }}/>
              <span>Scope</span>
            </div>
            <div className="ws-row ws-row-tight ws-scope-row">
              <div className="ws-field ws-field-compact">
                <label>Grade</label>
                <select value={spec.grade} onChange={bind("grade")}>
                  {GRADE_LEVELS.filter((g) => window.STUDIO_DEMO.weeksFor(g.id).length).map((g) => (
                    <option key={g.id} value={g.id}>{g.label}</option>
                  ))}
                </select>
              </div>
              <div className="ws-field ws-field-compact">
                <label>Week</label>
                <select value={spec.week} onChange={bind("week", Number)}>
                  {passageWeekRows.map((row) => {
                    const m = modeForWeek(row.week);
                    const tag = m === "pre-decodable" ? " · letters" : m === "word-building" ? " · word building" : "";
                    return (
                      <option key={row.week} value={row.week}>
                        Week {row.week}{tag}
                      </option>
                    );
                  })}
                </select>
              </div>
            </div>
            <div className="ws-week-hero">
              <div className="ws-week-badge">{scopeGrade}·W{spec.week}</div>
              <div className="ws-week-info">
                <div className="ws-week-title">Week {spec.week}</div>
                <div className="ws-week-unit">{(weekRow.phase || "").replace(/^(KG|G1|G2) · /, "")}</div>
              </div>
              <button
                type="button"
                className="ws-scope-btn"
                onClick={() => setShowScope(true)}
                title="View taught graphemes"
              >
                <span className="ws-scope-count">{taughtCount}</span>
                <span className="ws-scope-label">graphemes</span>
              </button>
            </div>
          </div>

          {/* ╔════ CONTENT + OUTPUT — passage weeks only ════╗
              Letter/word weeks (KG W1-8) are built deterministically from the
              curriculum, so style/title/target-words/mode/advanced don't apply. */}
          {currentMode === "passage" ? (<>
          <div className="ws-group">
            <div className="ws-group-label">
              <span className="ws-group-dot" style={{ background: "var(--c-reading)" }}/>
              <span>Content</span>
            </div>
            <div className="ws-field">
              <label>Style</label>
              <select value={spec.template} onChange={bind("template")}>
                {PASSAGE_TEMPLATES.filter((t) => window.STUDIO_DEMO.templatesFor(spec.grade, spec.week).indexOf(t.id) > -1).map((t) => (
                  <option key={t.id} value={t.id}>{t.label}</option>
                ))}
              </select>
            </div>
            <div className="ws-field">
              <label>Title <span className="ws-hint">(optional)</span></label>
              <input
                type="text"
                value={spec.title}
                onChange={bind("title")}
                placeholder="e.g. Sam and the Map"
              />
            </div>
            <div className="ws-field">
              <label>Target words <span className="ws-hint">(comma-separated)</span></label>
              <textarea
                value={spec.targetWords}
                onChange={bind("targetWords")}
                placeholder="stamp, rust, lamp, hand, sand"
              />
            </div>
          </div>

          {/* ╔════ OUTPUT ════╗ */}
          <div className="ws-group">
            <div className="ws-group-label">
              <span className="ws-group-dot" style={{ background: "var(--c-assess)" }}/>
              <span>Output</span>
            </div>
            <div className="ws-mode-wrap">
              <label className="ws-mini-label">Mode</label>
              <div className="ws-mode" role="radiogroup" aria-label="Generation mode">
                <button
                  type="button"
                  role="radio"
                  aria-checked={spec.mode !== "pro"}
                  className={"ws-mode-opt" + (spec.mode !== "pro" ? " active" : "")}
                  onClick={() => setSpec((s) => ({ ...s, mode: "regular" }))}
                  disabled={busy}
                  title="Live app: DeepSeek via OpenRouter · faster, pay-as-you-go"
                >
                  <span className="ws-mode-label">Regular</span>
                  <span className="ws-mode-sub">DeepSeek · fast</span>
                </button>
                <button
                  type="button"
                  role="radio"
                  aria-checked={spec.mode === "pro"}
                  className={"ws-mode-opt" + (spec.mode === "pro" ? " active" : "")}
                  onClick={() => setSpec((s) => ({ ...s, mode: "pro" }))}
                  disabled={busy}
                  title="Live app: Claude via the Agent SDK · higher quality, billed to your own subscription"
                >
                  <span className="ws-mode-label">Pro</span>
                  <span className="ws-mode-sub">Claude · best quality</span>
                </button>
              </div>
              <span className="ws-hint" style={{ display: "block", marginTop: 6 }}>
                Both modes replay the same recorded run here. The validator that
                follows is the real one.
              </span>
            </div>
            <button type="button" className="ws-group-toggle ws-adv-toggle" onClick={() => setShowAdvanced((a) => !a)}>
              <span>Advanced rules</span>
              <span className="ws-chev">{showAdvanced ? "−" : "+"}</span>
            </button>
            {showAdvanced && (
              <div className="ws-adv-fields">
                <div className="ws-row">
                  <div className="ws-field">
                    <label>Words</label>
                    <input type="number" min="20" max="500" value={spec.wordCount}
                      onChange={bind("wordCount", (v) => Math.max(20, Math.min(500, Number(v) || 80)))}/>
                  </div>
                  <div className="ws-field">
                    <label>Max sentence</label>
                    <input type="number" min="6" max="40" value={spec.maxSentenceLen}
                      onChange={bind("maxSentenceLen", (v) => Math.max(6, Math.min(40, Number(v) || 12)))}/>
                  </div>
                </div>
                <div className="ws-row">
                  <div className="ws-field">
                    <label>Decode %</label>
                    <input type="number" min="50" max="100" value={spec.decodabilityFloor}
                      onChange={bind("decodabilityFloor", (v) => Math.max(50, Math.min(100, Number(v) || 95)))}/>
                  </div>
                  <div className="ws-field">
                    <label>Sight cap</label>
                    <input type="number" min="0" max="100" value={spec.sightWordCap}
                      onChange={bind("sightWordCap", (v) => Math.max(0, Math.min(100, Number(v) || 12)))}/>
                  </div>
                </div>
              </div>
            )}
          </div>
          </>) : (
            <p className="ws-mode-hint">
              {currentMode === "pre-decodable" ? "Letter" : "Word-building"} weeks build straight from the curriculum — no story options to set. Pick the week and hit Build.
            </p>
          )}

          {/* ── Generate CTA ── */}
          <div className="ws-cta">
            <button
              className="tool-btn ws-generate"
              onClick={() => generate()}
              disabled={busy || !teacher}
            >
              {busy ? <span className="spinner"/> : Icon.wand()}
              {busy ? "Generating…"
                : currentMode === "pre-decodable" ? "Build letters"
                : currentMode === "word-building" ? "Build words"
                : "Generate passage"}
            </button>
          </div>

          {serverErr && (
            <div className="auth-error" style={{ marginTop: 8 }}>
              {serverErr}
              {lastFailedSpec && (
                <button className="tool-btn" style={{ marginTop: 8, background: "var(--c-reading)" }}
                  onClick={() => generate(lastFailedSpec)} disabled={busy}>
                  {Icon.refresh()}Retry
                </button>
              )}
            </div>
          )}

          {/* ── History trigger (opens drawer) ── */}
          {decodableJobs.length > 0 && (
            <button
              type="button"
              className="ws-history-btn"
              onClick={() => setShowRecent(true)}
              title={`Open recent passages (${decodableJobs.length})`}
            >
              <span className="ws-history-icon">{Icon.history ? Icon.history() : "↺"}</span>
              <span>Recent passages</span>
              <span className="ws-history-count">{decodableJobs.length}</span>
            </button>
          )}
        </aside>

        <main className="ws-output">
          {busy ? (
            <GeneratingAnimation title={spec.title}/>
          ) : currentJob ? (
            currentJob.status === "failed" && artifactKindOf(currentJob) !== "scope-artifact" ? (
              <ErrorState job={currentJob} onRetry={() => generate(currentJob.spec || spec)} busy={busy}/>
            ) : (() => {
              const kind = artifactKindOf(currentJob);
              const jobSpec = currentJob.spec || spec;
              return (
                <>
                  <ResultWorkspace
                    job={currentJob} spec={jobSpec} kind={kind} tweaks={tweaks}
                    font={readingFont} setFont={setReadingFont}/>
                  <ResultTabs job={currentJob} spec={jobSpec} kind={kind} tweaks={tweaks}/>
                </>
              );
            })()
          ) : (
            <div className="ws-empty">
              <div className="we-mark">{Mascot.book()}</div>
              <h3>Generate your first passage</h3>
              <p>
                Pick a grade and week, then hit <strong>Generate passage</strong>.
                The target words come with the scope. The recorded agent run replays
                in a few seconds, and the validator then checks the passage in your
                browser — the pass badge is computed on the spot, not canned.
              </p>
            </div>
          )}
        </main>
        {/* Drawers rendered as top-level siblings of the workspace columns so
            their fixed-positioning isn't trapped by .ws-controls' sticky
            stacking context. */}
        {showScope && (
          <ScopeDrawer
            week={spec.week}
            grade={spec.grade}
            scopeGrade={scopeGrade}
            taughtCount={taughtCount}
            onClose={() => setShowScope(false)}
          />
        )}
        {showRecent && (
          <HistoryDrawer
            jobs={decodableJobs}
            currentJobId={currentJob?.id}
            onPick={(j) => { pickHistory(j); setShowRecent(false); }}
            onClose={() => setShowRecent(false)}
          />
        )}
      </div>
    );
  }

  window.StudioDecodable = StudioDecodable;
})();
