← back to Norma Sdcc Pitch

public/app.js

1009 lines

(() => {
  'use strict';

  /* ─────────────── state ─────────────── */
  const state = {
    data: null,
    pitch: { included: [], skipped: [], notes: {} }, // included[]/skipped[] are agent ids
    xp: 0,
    badges: new Set(),
    quizState: {}, // section -> { questionsAnswered: [bool], correctCount, total }
    currentAgentId: null,
    learnedTerms: new Set(), // glossary terms marked as learned (by id "category::term")
    glossFilter: { category: 'all', search: '' },
  };

  /* ─────────────── persistence ─────────────── */
  const STORAGE_KEY = 'norma-sdcc-pitch.v1';
  function save() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify({
      pitch: state.pitch,
      xp: state.xp,
      badges: Array.from(state.badges),
      quizState: state.quizState,
      learnedTerms: Array.from(state.learnedTerms),
    }));
  }
  function load() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return;
      const obj = JSON.parse(raw);
      state.pitch = obj.pitch || state.pitch;
      state.xp = obj.xp || 0;
      state.badges = new Set(obj.badges || []);
      state.quizState = obj.quizState || {};
      state.learnedTerms = new Set(obj.learnedTerms || []);
    } catch (err) { /* ignore */ }
  }

  /* ─────────────── boot ─────────────── */
  async function boot() {
    load();
    const res = await fetch('/api/features');
    state.data = await res.json();
    bindStaticContent();
    renderSplitCards();
    renderProjectsCards();
    renderLoopsCards();
    renderCronCards();
    renderUiUxCards();
    renderUiUxPriorities();
    renderPriorities();
    renderObjections();
    renderShiftTable();
    renderCrmCompare();
    renderWixOptions();
    renderWixPrinciples();
    renderAgents();
    renderQuizzes();
    renderWalkthrough();
    renderPitch();
    renderGlossary();
    renderVault();
    renderHud();
    wireFilters();
    wirePitchActions();
    wirePrepCards();
    wireModalCloseHandlers();
    wireGlossarySearch();
    wireVaultActions();
    setupScrollProgress();
  }

  /* ─────────────── data binding helpers ─────────────── */
  function get(path) {
    return path.split('.').reduce((o, k) => (o == null ? o : o[k]), state.data.lecture);
  }
  function bindStaticContent() {
    document.querySelectorAll('[data-bind]').forEach((el) => {
      const path = el.getAttribute('data-bind');
      // Try lecture.<path> first; fall back to root sections (pitch_questions, unknown_unknowns, real_example)
      let val = get(path);
      if (val == null) {
        val = path.split('.').reduce((o, k) => (o == null ? o : o[k]), state.data);
      }
      if (typeof val === 'string') el.textContent = val;
    });
    document.querySelectorAll('[data-bind-list]').forEach((el) => {
      const path = el.getAttribute('data-bind-list');
      let val = get(path);
      if (val == null) {
        val = path.split('.').reduce((o, k) => (o == null ? o : o[k]), state.data);
      }
      if (Array.isArray(val)) {
        el.innerHTML = val.map((v) => `<li>${escapeHtml(v)}</li>`).join('');
      }
    });
  }

  function escapeHtml(s) {
    return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  /* ─────────────── render: long vs short ─────────────── */
  function renderSplitCards() {
    const ls = state.data.lecture.long_short;
    const sh = document.getElementById('grid-short');
    const lg = document.getElementById('grid-long');
    sh.innerHTML = ls.short_term.map((c) => `<li><strong>${escapeHtml(c.title)}</strong>${escapeHtml(c.detail)}</li>`).join('');
    lg.innerHTML = ls.long_term.map((c) => `<li><strong>${escapeHtml(c.title)}</strong>${escapeHtml(c.detail)}</li>`).join('');
  }

  /* ─────────────── render: projects cards ─────────────── */
  function renderProjectsCards() {
    const p = state.data.lecture.projects_and_docs;
    const c = document.getElementById('grid-projects-concept');
    const d = document.getElementById('grid-docs-overview');
    c.innerHTML = p.projects_concept.map((x) => `<div class="card"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p></div>`).join('');
    d.innerHTML = p.docs_overview.map((x) => `<div class="card"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p></div>`).join('');
  }

  /* ─────────────── render: loops + cron cards (clickable for detail modal) ─────────────── */
  function renderLoopsCards() {
    const c = document.getElementById('grid-loops');
    const examples = state.data.lecture.loops.examples;
    c.innerHTML = examples.map((x, i) => `<div class="card clickable-card" data-loop-idx="${i}"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p><span class="card-cta-mini">Click for detail →</span></div>`).join('');
    c.querySelectorAll('[data-loop-idx]').forEach((card) => {
      card.addEventListener('click', () => {
        const i = parseInt(card.getAttribute('data-loop-idx'), 10);
        const x = examples[i];
        openDetailModal({
          tag: 'LOOP', title: x.title, tagline: 'Always-on background agent',
          sections: [
            { h: 'What it does', p: x.detail },
            { h: 'Guardrails', list: state.data.lecture.loops.guardrails },
            { h: 'How it fits the workflow', p: 'Triggered automatically on the listed cadence. Outputs land in Norma\'s queue, not in SDCC\'s direct outbox — staff approves before send. Every action is audited.' },
          ],
        });
      });
    });
  }
  function renderCronCards() {
    const c = document.getElementById('grid-cron');
    const examples = state.data.lecture.cron.examples;
    c.innerHTML = examples.map((x, i) => `<div class="card clickable-card" data-cron-idx="${i}"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p><span class="card-cta-mini">Click for detail →</span></div>`).join('');
    c.querySelectorAll('[data-cron-idx]').forEach((card) => {
      card.addEventListener('click', () => {
        const i = parseInt(card.getAttribute('data-cron-idx'), 10);
        const x = examples[i];
        openDetailModal({
          tag: 'CRON', title: x.title, tagline: 'Scheduled task — calendar-driven',
          sections: [
            { h: 'What it does', p: x.detail },
            { h: 'Loops vs Cron', p: state.data.lecture.cron.vs_loops },
            { h: 'How SDCC will see it', p: 'Outputs land as a draft / brief / suggestion in Norma at the scheduled time. Staff reviews and approves. Configurable schedule per cron in Settings.' },
          ],
        });
      });
    });
  }

  /* ─────────────── render: UI vs UX ─────────────── */
  function renderUiUxCards() {
    const c = document.getElementById('grid-uiux-examples');
    c.innerHTML = state.data.lecture.ui_vs_ux.examples.map((x) => `<div class="card ${x.type}"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p></div>`).join('');
  }
  function renderUiUxPriorities() {
    const c = document.getElementById('grid-uiux-priorities');
    c.innerHTML = state.data.lecture.ui_vs_ux.sdcc_priorities.map((x) => `<li><strong>${escapeHtml(x.title)}</strong> — ${escapeHtml(x.detail)}</li>`).join('');
  }

  /* ─────────────── render: agents grid ─────────────── */
  function renderAgents(filter = 'all') {
    const grid = document.getElementById('agent-grid');
    const agents = state.data.agents.filter((a) => filter === 'all' || a.category === filter);
    grid.innerHTML = agents.map((a) => {
      const status = state.pitch.included.includes(a.id) ? 'included'
                  : state.pitch.skipped.includes(a.id) ? 'skipped' : '';
      return `
        <article class="agent-card ${status}" data-agent-id="${escapeHtml(a.id)}">
          <h3>${escapeHtml(a.name)}</h3>
          <p class="tagline">${escapeHtml(a.tagline)}</p>
          <div class="meta">
            <span class="badge badge-${a.category}">${escapeHtml(a.category)}</span>
            <span class="muted small">${escapeHtml(a.human_in_the_loop)}</span>
          </div>
        </article>`;
    }).join('');
    grid.querySelectorAll('.agent-card').forEach((card) => {
      card.addEventListener('click', () => openAgentModal(card.getAttribute('data-agent-id')));
    });
  }
  function wireFilters() {
    document.querySelectorAll('.filter-btn').forEach((btn) => {
      btn.addEventListener('click', () => {
        document.querySelectorAll('.filter-btn').forEach((b) => b.classList.remove('active'));
        btn.classList.add('active');
        renderAgents(btn.getAttribute('data-filter'));
      });
    });
  }

  /* ─────────────── render: agent modal ─────────────── */
  function openAgentModal(id) {
    const a = state.data.agents.find((x) => x.id === id);
    if (!a) return;
    state.currentAgentId = id;
    document.getElementById('modal-category').textContent = a.category;
    document.getElementById('modal-category').className = 'badge badge-' + a.category;
    document.getElementById('modal-title').textContent = a.name;
    document.getElementById('modal-tagline').textContent = a.tagline;
    document.getElementById('modal-what').textContent = a.what_it_does;
    document.getElementById('modal-for-sdcc').textContent = a.for_sdcc;
    document.getElementById('modal-hitl').textContent = a.human_in_the_loop;
    document.getElementById('modal-route').textContent = a.api_route;
    const out = document.getElementById('modal-example-out');
    out.textContent = '';
    document.getElementById('modal-note').value = state.pitch.notes[id] || '';
    document.getElementById('modal-feature').hidden = false;
    document.getElementById('modal-run-btn').onclick = () => runExample(a, out);
    document.getElementById('btn-add').onclick = () => addToPitch(id);
    document.getElementById('btn-skip').onclick = () => skipFromPitch(id);
  }

  async function runExample(agent, outEl) {
    outEl.textContent = '⏳ Calling ' + agent.api_route + ' on Norma…';
    try {
      const method = agent.demo_payload ? 'POST' : 'GET';
      // Strip query string from route for proxy body
      const [routePath, query] = agent.api_route.split('?');
      const fullRoute = query ? `${routePath}?${query}` : routePath;
      const r = await fetch('/api/run-example', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ route: fullRoute, method, payload: agent.demo_payload }),
      });
      const data = await r.json();
      const tag = data.source === 'demo-mock' ? '◆ DEMO MODE' : data.source === 'live' ? '◆ LIVE' : '◆ ERROR';
      const note = data._note ? `\n${data._note}\n` : '';
      outEl.textContent = `${tag}  →  HTTP ${data.status || '?'} in ${data.ms || 0}ms${note}\n` + JSON.stringify(data.body, null, 2);
      if (!state.badges.has('ran_example')) {
        state.badges.add('ran_example');
        toast('badge', '⚡ Badge unlocked — Ran a Real Example');
      }
      addXp(15, 'Ran example: ' + agent.name);
    } catch (err) {
      outEl.textContent = '✗ Error: ' + err.message;
    }
  }

  function addToPitch(id) {
    const note = document.getElementById('modal-note').value.trim();
    state.pitch.included = Array.from(new Set([...state.pitch.included.filter((x) => x !== id), id]));
    state.pitch.skipped = state.pitch.skipped.filter((x) => x !== id);
    if (note) state.pitch.notes[id] = note; else delete state.pitch.notes[id];
    addXp(10, 'Added to pitch');
    if (state.pitch.included.length >= 3 && !state.badges.has('pitch_3')) {
      state.badges.add('pitch_3');
      toast('badge', '📋 Badge unlocked — Three\'s the Pitch');
    }
    closeModal();
    refreshAll();
  }
  function skipFromPitch(id) {
    state.pitch.skipped = Array.from(new Set([...state.pitch.skipped.filter((x) => x !== id), id]));
    state.pitch.included = state.pitch.included.filter((x) => x !== id);
    delete state.pitch.notes[id];
    if (state.pitch.skipped.length >= 2 && !state.badges.has('skipper')) {
      state.badges.add('skipper');
      toast('badge', '✂️ Badge unlocked — Curator');
    }
    closeModal();
    refreshAll();
  }

  function closeModal() {
    document.querySelectorAll('.modal').forEach((m) => (m.hidden = true));
    state.currentAgentId = null;
  }

  /* generic detail modal — for priorities, loops, cron, anything */
  function openDetailModal({ tag, title, tagline, sections }) {
    const m = document.getElementById('modal-detail');
    if (!m) return;
    document.getElementById('detail-tag').textContent = tag || '';
    document.getElementById('detail-title').textContent = title || '';
    document.getElementById('detail-tagline').textContent = tagline || '';
    const body = document.getElementById('detail-body');
    body.innerHTML = (sections || []).map((s) => {
      let inner = '';
      if (s.p) inner += `<p>${escapeHtml(s.p)}</p>`;
      if (s.list) inner += `<ul class="lecture-bullets compact" style="margin-top:8px">${s.list.map((x) => `<li>${escapeHtml(x)}</li>`).join('')}</ul>`;
      if (s.beforeAfter) inner += `
        <div class="priority-before-after" style="margin:8px 0">
          <div class="ba-cell before"><span class="ba-tag">Before</span>${escapeHtml(s.beforeAfter.before)}</div>
          <div class="ba-cell after"><span class="ba-tag">After</span>${escapeHtml(s.beforeAfter.after)}</div>
        </div>`;
      if (s.pills) inner += `<div class="priority-agents" style="margin-top:6px">${s.pills.map((p) => `<span class="agent-pill">${escapeHtml(p)}</span>`).join('')}</div>`;
      return `<section><h3>${escapeHtml(s.h)}</h3>${inner}</section>`;
    }).join('');
    m.hidden = false;
    addXp(3, 'Opened detail modal');
  }
  function wireModalCloseHandlers() {
    document.querySelectorAll('[data-close]').forEach((el) => el.addEventListener('click', closeModal));
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
  }

  /* ─────────────── pitch panel ─────────────── */
  function renderPitch() {
    document.getElementById('pitch-included-count').textContent = state.pitch.included.length;
    document.getElementById('pitch-skipped-count').textContent = state.pitch.skipped.length;
    const list = document.getElementById('pitch-list');
    list.innerHTML = state.pitch.included.map((id) => {
      const a = state.data.agents.find((x) => x.id === id);
      if (!a) return '';
      return `<li data-agent-id="${escapeHtml(id)}">
        <div><span class="name">${escapeHtml(a.name)}</span> <span class="muted small">· ${escapeHtml(a.category)}</span></div>
        <span class="pitch-note">${escapeHtml(state.pitch.notes[id] || '')}</span>
        <button class="remove" data-remove="${escapeHtml(id)}">Remove</button>
      </li>`;
    }).join('');
    list.querySelectorAll('[data-remove]').forEach((b) => {
      b.addEventListener('click', () => {
        state.pitch.included = state.pitch.included.filter((x) => x !== b.getAttribute('data-remove'));
        delete state.pitch.notes[b.getAttribute('data-remove')];
        refreshAll();
      });
    });
  }
  function wirePitchActions() {
    document.getElementById('btn-clear').onclick = () => {
      if (!confirm('Clear all pitch selections?')) return;
      state.pitch = { included: [], skipped: [], notes: {} };
      refreshAll();
    };
    document.getElementById('btn-export').onclick = exportPitch;
  }
  function exportPitch() {
    if (!state.pitch.included.length) {
      alert('Add at least one agent to your pitch before exporting.');
      return;
    }
    const items = state.pitch.included.map((id) => state.data.agents.find((x) => x.id === id)).filter(Boolean);
    const html = `<!doctype html><html><head><meta charset="utf-8"><title>Norma × SDCC — Curated Pitch</title>
<style>body{font-family:Georgia,serif;max-width:800px;margin:40px auto;padding:0 24px;line-height:1.6;color:#0e1729;}
h1{font-size:32px;color:#0d2e4d;}h2{color:#1b4d7a;margin-top:32px;font-size:22px;}
.card{background:#f8fafc;border-left:4px solid #1b4d7a;padding:16px 20px;margin:14px 0;border-radius:6px;}
.tag{display:inline-block;background:#1b4d7a;color:#fff;padding:2px 10px;border-radius:4px;font-size:11px;margin-bottom:8px;text-transform:uppercase;}
.note{background:#fff7ed;border-left:3px solid #f59e0b;padding:8px 14px;margin-top:8px;font-style:italic;color:#92400e;font-size:14px;}
</style></head><body>
<h1>Norma × SDCC — Curated Pitch</h1>
<p>Generated ${new Date().toLocaleString()} · ${items.length} agent${items.length === 1 ? '' : 's'} included</p>
${items.map((a) => `
<div class="card">
  <span class="tag">${a.category}</span>
  <h2>${a.name}</h2>
  <p><strong>${a.tagline}</strong></p>
  <p>${a.what_it_does}</p>
  <p><em>For SDCC:</em> ${a.for_sdcc}</p>
  <p><em>Human-in-the-loop:</em> ${a.human_in_the_loop}</p>
  ${state.pitch.notes[a.id] ? `<div class="note">Note: ${state.pitch.notes[a.id]}</div>` : ''}
</div>`).join('')}
</body></html>`;
    const blob = new Blob([html], { type: 'text/html' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `norma-sdcc-pitch-${Date.now()}.html`;
    a.click();
  }

  /* ─────────────── quizzes ─────────────── */
  function renderQuizzes() {
    document.querySelectorAll('.quiz-block[data-quiz]').forEach((block) => {
      const key = block.getAttribute('data-quiz');
      const quiz = (state.data.lecture[key] || {}).quiz;
      if (!Array.isArray(quiz) || quiz.length === 0) { block.remove(); return; }
      const st = state.quizState[key] || { answered: new Array(quiz.length).fill(false), correct: 0 };
      state.quizState[key] = st;
      block.innerHTML = `
        <div class="quiz-head">
          <h3 class="quiz-title">📝 Quick check — earn XP</h3>
          <div>
            <span class="quiz-score" id="quiz-score-${key}">${st.correct}/${quiz.length}</span>
            <span class="quiz-meta">${state.data.gamify.xp_per_correct} XP per correct · ${state.data.gamify.xp_per_section_complete} bonus on full clear</span>
          </div>
        </div>
        ${quiz.map((q, i) => `
          <div class="quiz-q ${st.answered[i] ? 'answered' : ''}" data-quiz-key="${key}" data-q-idx="${i}">
            <p class="q-text">${escapeHtml(q.q)}</p>
            <div class="q-options">
              ${q.options.map((opt, j) => `<button class="q-option" data-opt-idx="${j}" ${st.answered[i] ? 'disabled' : ''}>${escapeHtml(opt)}</button>`).join('')}
            </div>
            <div class="q-explain">${escapeHtml(q.explain)}</div>
          </div>
        `).join('')}
        <div class="quiz-complete ${st.correct === quiz.length ? 'visible' : ''}" id="quiz-done-${key}">
          ✓ Section quiz complete — +${state.data.gamify.xp_per_section_complete} XP bonus earned
        </div>
      `;
      block.querySelectorAll('.q-option').forEach((opt) => {
        opt.addEventListener('click', (e) => answerQuiz(block, opt));
      });
    });
  }

  function answerQuiz(block, optBtn) {
    const key = block.querySelector('.quiz-q').getAttribute('data-quiz-key');
    const qDiv = optBtn.closest('.quiz-q');
    const qIdx = parseInt(qDiv.getAttribute('data-q-idx'), 10);
    const optIdx = parseInt(optBtn.getAttribute('data-opt-idx'), 10);
    const quiz = state.data.lecture[key].quiz;
    const q = quiz[qIdx];
    const st = state.quizState[key];
    if (st.answered[qIdx]) return;
    st.answered[qIdx] = true;
    const correct = optIdx === q.answer;
    qDiv.querySelectorAll('.q-option').forEach((b, j) => {
      b.disabled = true;
      if (j === q.answer) b.classList.add('correct');
      else if (j === optIdx) b.classList.add('incorrect');
    });
    qDiv.classList.add('answered');
    if (correct) {
      st.correct++;
      addXp(state.data.gamify.xp_per_correct, '+correct');
    } else {
      toast('xp', '✗ Not quite — see explanation');
    }
    document.getElementById('quiz-score-' + key).textContent = `${st.correct}/${quiz.length}`;
    if (!state.badges.has('first_quiz')) {
      state.badges.add('first_quiz');
      toast('badge', '🎯 Badge unlocked — First Quiz');
    }
    // Full section clear?
    if (st.answered.every(Boolean)) {
      if (st.correct === quiz.length) {
        addXp(state.data.gamify.xp_per_section_complete, 'Section quiz complete');
        document.getElementById('quiz-done-' + key).classList.add('visible');
        if (!state.badges.has('perfect_quiz')) {
          state.badges.add('perfect_quiz');
          toast('badge', '💎 Badge unlocked — Perfect Score');
        }
        // All sections complete?
        const allSections = ['why_ai', 'long_short', 'projects_and_docs', 'loops', 'cron', 'ui_vs_ux'];
        const allDone = allSections.every((s) => {
          const sq = state.data.lecture[s]?.quiz;
          const stt = state.quizState[s];
          return sq && stt && stt.answered.every(Boolean) && stt.correct === sq.length;
        });
        if (allDone && !state.badges.has('all_sections')) {
          state.badges.add('all_sections');
          toast('badge', '🎓 Badge unlocked — Lecture Complete');
        }
      }
    }
    save();
    renderHud();
  }

  /* ─────────────── walkthrough ─────────────── */
  function renderWalkthrough() {
    const list = document.getElementById('walkthrough-steps');
    const r = state.data.real_example;
    list.innerHTML = r.steps.map((s, i) => `
      <li>
        <h4>${escapeHtml(s.label)}</h4>
        <p class="detail">${escapeHtml(s.detail)}</p>
        <div class="step-actions">
          <span class="verb-tag ${(s.verb || 'GET').toLowerCase() === 'post' ? 'post' : ''}">${escapeHtml(s.verb || 'GET')}</span>
          <code>${escapeHtml(s.action)}</code>
          <button class="step-run" data-step="${i}">▶ Run</button>
        </div>
        <pre id="walkthrough-out-${i}"></pre>
      </li>
    `).join('');
    list.querySelectorAll('.step-run').forEach((btn) => {
      btn.addEventListener('click', () => runWalkthroughStep(parseInt(btn.getAttribute('data-step'), 10)));
    });
  }
  async function runWalkthroughStep(i) {
    const s = state.data.real_example.steps[i];
    const out = document.getElementById('walkthrough-out-' + i);
    out.classList.add('has-output');
    out.textContent = '⏳ ' + s.verb + ' ' + s.action + ' …';
    try {
      const r = await fetch('/api/run-example', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ route: s.action, method: s.verb || 'GET', payload: s.body || null }),
      });
      const data = await r.json();
      const tag = data.source === 'demo-mock' ? '◆ DEMO MODE' : data.source === 'live' ? '◆ LIVE' : '◆ ERROR';
      const note = data._note ? `\n${data._note}\n` : '';
      out.textContent = `${tag}  →  HTTP ${data.status || '?'} in ${data.ms || 0}ms${note}\n` + JSON.stringify(data.body, null, 2);
      addXp(5, 'Walkthrough step');
    } catch (err) {
      out.textContent = '✗ Error: ' + err.message;
    }
  }

  /* ─────────────── prep cards (questions + unknowns) ─────────────── */
  function wirePrepCards() {
    document.getElementById('open-questions').addEventListener('click', openQuestions);
    document.getElementById('open-unknowns').addEventListener('click', openUnknowns);
  }
  function openQuestions() {
    const pq = state.data.pitch_questions;
    const body = document.getElementById('questions-body');
    body.innerHTML = pq.categories.map((c) => `
      <div class="q-category">
        <h3>${escapeHtml(c.category)}</h3>
        <ul>${c.questions.map((q) => `<li>${escapeHtml(q)}</li>`).join('')}</ul>
      </div>
    `).join('');
    document.getElementById('modal-questions').hidden = false;
    if (!state.badges.has('asked_questions')) {
      state.badges.add('asked_questions');
      toast('badge', '❓ Badge unlocked — Interviewer');
      save(); renderHud();
    }
  }
  function openUnknowns() {
    const uu = state.data.unknown_unknowns;
    const body = document.getElementById('unknowns-body');
    body.innerHTML = uu.items.map((it) => `
      <div class="unknown-item">
        <span class="u-cat">${escapeHtml(it.category)}</span>
        <p class="u-q">${escapeHtml(it.question)}</p>
        <p class="u-why"><strong>Why it matters:</strong> ${escapeHtml(it.why_it_matters)}</p>
      </div>
    `).join('');
    document.getElementById('modal-unknowns').hidden = false;
    if (!state.badges.has('unknown_aware')) {
      state.badges.add('unknown_aware');
      toast('badge', '🔮 Badge unlocked — Self-Aware');
      save(); renderHud();
    }
  }

  /* ─────────────── gamification: XP, level, badges, HUD ─────────────── */
  function addXp(amount, reason) {
    const before = state.xp;
    state.xp += amount;
    const beforeLvl = currentLevel(before);
    const afterLvl = currentLevel(state.xp);
    toast('xp', `+${amount} XP · ${reason}`);
    if (afterLvl.name !== beforeLvl.name) {
      toast('level', `⬆️ Level up — ${afterLvl.name}`);
    }
    save();
    renderHud();
  }
  function currentLevel(xp) {
    const levels = state.data.gamify.levels;
    let lvl = levels[0];
    for (const l of levels) if (xp >= l.min) lvl = l;
    return lvl;
  }
  function renderHud() {
    if (!state.data) return;
    document.getElementById('hud-xp').textContent = state.xp;
    const lvl = currentLevel(state.xp);
    const hudLvl = document.getElementById('hud-level');
    hudLvl.textContent = lvl.name;
    hudLvl.style.background = `linear-gradient(90deg, ${lvl.color}, ${shadeColor(lvl.color, -15)})`;
    // badges
    const badgesEl = document.getElementById('hud-badges');
    const earnedIcons = state.data.gamify.badges
      .filter((b) => state.badges.has(b.id))
      .map((b) => `<span title="${escapeHtml(b.name)}: ${escapeHtml(b.criteria)}">${b.icon}</span>`)
      .join('');
    badgesEl.innerHTML = earnedIcons;
  }
  function shadeColor(hex, percent) {
    const h = hex.replace('#', '');
    const num = parseInt(h, 16);
    const r = Math.max(0, Math.min(255, ((num >> 16) & 0xff) + percent));
    const g = Math.max(0, Math.min(255, ((num >> 8) & 0xff) + percent));
    const b = Math.max(0, Math.min(255, (num & 0xff) + percent));
    return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
  }
  function toast(kind, msg) {
    const t = document.createElement('div');
    t.className = 'toast ' + kind;
    t.innerHTML = `<span class="toast-icon">${kind === 'xp' ? '⚡' : kind === 'badge' ? '🏆' : kind === 'level' ? '✨' : '•'}</span><span>${escapeHtml(msg)}</span>`;
    document.getElementById('toast-stack').appendChild(t);
    setTimeout(() => t.remove(), 3200);
  }

  /* ─────────────── scroll progress ─────────────── */
  function setupScrollProgress() {
    const bar = document.getElementById('progress-bar');
    const update = () => {
      const h = document.documentElement;
      const total = h.scrollHeight - h.clientHeight;
      const scrolled = h.scrollTop || document.body.scrollTop;
      bar.style.width = Math.min(100, (scrolled / total) * 100) + '%';
    };
    window.addEventListener('scroll', update, { passive: true });
    update();
  }

  /* ─────────────── priorities ─────────────── */
  function renderPriorities() {
    const p = state.data.priorities;
    if (!p) return;
    const list = document.getElementById('priority-list');
    if (!list) return;
    list.innerHTML = p.items.map((it, i) => `
      <article class="priority-card clickable-card" data-priority-idx="${i}">
        <div class="priority-rank">${it.rank}</div>
        <div class="priority-body">
          <span class="priority-icon-tag">${escapeHtml(it.icon || '')}</span>
          <h3>${escapeHtml(it.title)}</h3>
          <p class="pain"><span class="priority-section-label">Pain</span>${escapeHtml(it.the_pain)}</p>
          <p class="solution"><span class="priority-section-label">Solution</span>${escapeHtml(it.the_solution)}</p>
          ${it.before_after ? `
            <div class="priority-before-after">
              <div class="ba-cell before"><span class="ba-tag">Before</span>${escapeHtml(it.before_after.before)}</div>
              <div class="ba-cell after"><span class="ba-tag">After</span>${escapeHtml(it.before_after.after)}</div>
            </div>` : ''}
          <div class="card-cta-row">
            <span class="card-cta">Click for full breakdown →</span>
            ${it.agents_skills ? `<div class="priority-agents">${it.agents_skills.map((a) => `<span class="agent-pill" data-pill="${escapeHtml(a)}">${escapeHtml(a)}</span>`).join('')}</div>` : ''}
          </div>
        </div>
      </article>
    `).join('');
    list.querySelectorAll('.priority-card').forEach((card) => {
      card.addEventListener('click', (e) => {
        // Skip if click hit an agent pill
        if (e.target.closest('.agent-pill')) return;
        openPriorityModal(parseInt(card.getAttribute('data-priority-idx'), 10));
      });
    });
    list.querySelectorAll('.agent-pill').forEach((p) => {
      p.addEventListener('click', (e) => {
        e.stopPropagation();
        document.querySelector('#agents').scrollIntoView({ behavior: 'smooth' });
      });
    });
  }

  function openPriorityModal(idx) {
    const it = state.data.priorities.items[idx];
    if (!it) return;
    openDetailModal({
      tag: `Priority ${it.rank}`,
      title: it.icon + ' ' + it.title,
      tagline: '',
      sections: [
        { h: 'The pain', p: it.the_pain },
        { h: 'The solution', p: it.the_solution },
        ...(it.how_norma_helps ? [{ h: 'How Norma helps (4 specific moves)', list: it.how_norma_helps }] : []),
        ...(it.wix_specific_steps ? [{ h: 'Wix-specific implementation steps', list: it.wix_specific_steps }] : []),
        ...(it.before_after ? [{ h: 'Before / after', beforeAfter: it.before_after }] : []),
        ...(it.agents_skills ? [{ h: 'Agents + skills involved', pills: it.agents_skills }] : []),
      ],
    });
  }

  /* ─────────────── reluctant adopter objections ─────────────── */
  function renderObjections() {
    const ra = state.data.reluctant_adopter;
    if (!ra) return;
    const list = document.getElementById('objections-list');
    if (!list) return;
    list.innerHTML = ra.objections.map((o) => `
      <article class="objection-card">
        <p class="obj-q">${escapeHtml(o.objection)}</p>
        <p class="obj-a">${escapeHtml(o.answer)}</p>
        ${o.before_after ? `
          <div class="obj-ba">
            <div class="ba-cell before"><span class="ba-tag">Before</span>${escapeHtml(o.before_after.before)}</div>
            <div class="ba-cell after"><span class="ba-tag">After</span>${escapeHtml(o.before_after.after)}</div>
          </div>` : ''}
      </article>
    `).join('');
  }

  /* ─────────────── shift table (companies are databases) ─────────────── */
  function renderShiftTable() {
    const c = state.data.companies_are_databases;
    if (!c) return;
    const t = document.getElementById('shift-table');
    if (!t) return;
    t.innerHTML = c.shift_table.map((row) => `
      <tr>
        <td class="before-col">${escapeHtml(row.before)}</td>
        <td class="after-col">${escapeHtml(row.after)}</td>
      </tr>
    `).join('');
  }

  /* ─────────────── CRM compare table ─────────────── */
  function renderCrmCompare() {
    const w = state.data.why_custom_crm;
    if (!w) return;
    const t = document.getElementById('crm-compare');
    if (!t) return;
    t.innerHTML = `
      <thead>
        <tr>
          <th>Axis</th>
          <th>Salesforce</th>
          <th>EveryAction</th>
          <th>HubSpot</th>
          <th class="norma-col">Norma</th>
        </tr>
      </thead>
      <tbody>
        ${w.comparison.map((row) => `
          <tr>
            <td class="axis">${escapeHtml(row.axis)}</td>
            <td class="${row.winner === 'salesforce' ? 'win' : ''}">${escapeHtml(row.salesforce)}</td>
            <td class="${row.winner === 'everyaction' ? 'win' : ''}">${escapeHtml(row.everyaction)}</td>
            <td class="${row.winner === 'hubspot' ? 'win' : ''}">${escapeHtml(row.hubspot)}</td>
            <td class="norma-col ${row.winner === 'norma' ? 'win' : ''}">${escapeHtml(row.norma)}</td>
          </tr>
        `).join('')}
      </tbody>
    `;
  }

  /* ─────────────── wix options + principles ─────────────── */
  function renderWixOptions() {
    const w = state.data.wix_integration;
    if (!w) return;
    const list = document.getElementById('wix-options');
    if (!list) return;
    list.innerHTML = w.options.map((o) => {
      const diffClass = o.difficulty.toLowerCase().replace(/[^a-z]/g, '');
      return `
        <article class="wix-option-card">
          <div class="wix-head">
            <h4>${escapeHtml(o.name)}</h4>
            <span class="wix-difficulty ${diffClass}">${escapeHtml(o.difficulty)}</span>
            <span class="wix-time">${escapeHtml(o.time)}</span>
          </div>
          <ol class="wix-steps">${o.steps.map((s) => `<li>${escapeHtml(s)}</li>`).join('')}</ol>
        </article>`;
    }).join('');
  }
  function renderWixPrinciples() {
    const w = state.data.wix_integration;
    if (!w) return;
    const list = document.getElementById('wix-principles');
    if (!list) return;
    list.innerHTML = w.wix_ux_ui_principles.map((p) => `<li><strong>${escapeHtml(p.title)}</strong> — ${escapeHtml(p.detail)}</li>`).join('');
  }

  /* ─────────────── glossary ─────────────── */
  function termId(catId, term) { return catId + '::' + term; }
  function totalGlossaryTerms() {
    return state.data.glossary.categories.reduce((n, c) => n + c.terms.length, 0);
  }

  function renderGlossary() {
    if (!state.data.glossary) return;
    const gloss = state.data.glossary;
    // chips
    const chipsEl = document.getElementById('gloss-chips');
    if (chipsEl) {
      const total = totalGlossaryTerms();
      chipsEl.innerHTML = `<button class="gloss-chip ${state.glossFilter.category === 'all' ? 'active' : ''}" data-cat="all">All <span class="gloss-chip-count">${total}</span></button>` +
        gloss.categories.map((c) => `<button class="gloss-chip ${state.glossFilter.category === c.id ? 'active' : ''}" data-cat="${c.id}"><span>${c.icon}</span>${escapeHtml(c.label)} <span class="gloss-chip-count">${c.terms.length}</span></button>`).join('');
      chipsEl.querySelectorAll('.gloss-chip').forEach((b) => {
        b.addEventListener('click', () => {
          state.glossFilter.category = b.getAttribute('data-cat');
          renderGlossary();
        });
      });
    }
    // search input value persist
    const searchEl = document.getElementById('gloss-search');
    if (searchEl && state.glossFilter.search) searchEl.value = state.glossFilter.search;

    // body
    const list = document.getElementById('glossary-list');
    const q = (state.glossFilter.search || '').trim().toLowerCase();
    const cats = gloss.categories.filter((c) => state.glossFilter.category === 'all' || c.id === state.glossFilter.category);
    let totalShown = 0;
    const html = cats.map((c) => {
      const matched = c.terms.filter((t) => {
        if (!q) return true;
        return (t.term + ' ' + t.tldr + ' ' + (t.detail || '')).toLowerCase().includes(q);
      });
      if (!matched.length) return '';
      totalShown += matched.length;
      return `
        <div class="gloss-category" data-cat-id="${c.id}">
          <div class="gloss-cat-header">
            <span class="icon">${c.icon}</span>
            <h3>${escapeHtml(c.label)}</h3>
            <span class="cat-count">${matched.length} / ${c.terms.length}</span>
          </div>
          <div class="gloss-cat-body">
            ${matched.map((t) => {
              const id = termId(c.id, t.term);
              const learned = state.learnedTerms.has(id);
              return `
                <div class="gloss-term ${learned ? 'learned' : ''}" data-term-id="${escapeHtml(id)}">
                  <div class="gloss-term-head">
                    <div>
                      <p class="gloss-term-name">${escapeHtml(t.term)}</p>
                      <p class="gloss-term-tldr">${escapeHtml(t.tldr)}</p>
                    </div>
                    <span class="gloss-term-toggle">▼</span>
                  </div>
                  <div class="gloss-term-detail">
                    <p>${escapeHtml(t.detail || '')}</p>
                    ${t.example ? `<div class="gloss-example">${escapeHtml(t.example)}</div>` : ''}
                    <div class="gloss-term-actions">
                      <button class="mark-learned" data-mark="${escapeHtml(id)}">${learned ? '✓ Learned' : 'Mark as learned (+5 XP)'}</button>
                    </div>
                  </div>
                </div>`;
            }).join('')}
          </div>
        </div>`;
    }).join('');
    list.innerHTML = html || `<div class="gloss-empty">No terms match &ldquo;${escapeHtml(q)}&rdquo;.</div>`;

    // stats
    const total = totalGlossaryTerms();
    const learnedCount = state.learnedTerms.size;
    const pct = Math.round((learnedCount / total) * 100);
    const statsEl = document.getElementById('glossary-stats');
    if (statsEl) {
      statsEl.innerHTML = `
        <div><strong>${learnedCount}</strong> of ${total} terms marked learned · ${totalShown} showing</div>
        <div class="stats-progress"><div style="width:${pct}%"></div></div>
      `;
    }

    // wire term clicks
    list.querySelectorAll('.gloss-term-head').forEach((h) => {
      h.addEventListener('click', () => {
        h.closest('.gloss-term').classList.toggle('open');
      });
    });
    list.querySelectorAll('.mark-learned').forEach((b) => {
      b.addEventListener('click', (e) => {
        e.stopPropagation();
        const id = b.getAttribute('data-mark');
        if (state.learnedTerms.has(id)) {
          state.learnedTerms.delete(id);
        } else {
          state.learnedTerms.add(id);
          addXp(state.data.gamify.xp_per_term_learned || 5, 'Term learned');
          // badge checks
          if (state.learnedTerms.size >= 5 && !state.badges.has('vocab_starter')) {
            state.badges.add('vocab_starter');
            toast('badge', '📖 Badge unlocked — Vocab Starter');
          }
          if (state.learnedTerms.size >= 20 && !state.badges.has('vocab_champ')) {
            state.badges.add('vocab_champ');
            toast('badge', '📚 Badge unlocked — Vocab Champ');
          }
        }
        save();
        renderGlossary();
        renderHud();
      });
    });
  }

  function wireGlossarySearch() {
    const s = document.getElementById('gloss-search');
    if (!s) return;
    let timer;
    s.addEventListener('input', () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        state.glossFilter.search = s.value;
        renderGlossary();
      }, 150);
    });
  }

  /* ─────────────── token vault ─────────────── */
  async function renderVault() {
    const tv = state.data.token_vault;
    if (!tv) return;
    const list = document.getElementById('vault-list');
    if (!list) return;

    // Fetch current state
    let serverState = {};
    try {
      const r = await fetch('/api/vault');
      const data = await r.json();
      serverState = data.vault || {};
    } catch { /* ignore */ }

    list.innerHTML = tv.categories.map((c) => {
      const setCount = c.tokens.filter((t) => serverState[t.key]).length;
      const total = c.tokens.length;
      return `
        <div class="vault-category">
          <div class="vault-cat-head">
            <span class="icon">${c.icon}</span>
            <h3>${escapeHtml(c.label)}</h3>
            <span class="set-count ${setCount === 0 ? 'empty' : ''}">${setCount} / ${total} set</span>
          </div>
          <div class="vault-cat-body">
            ${c.tokens.map((t) => {
              const ss = serverState[t.key];
              const setClass = ss ? 'set' : '';
              const reqClass = t.required ? 'required' : '';
              return `
                <div class="vault-row ${setClass} ${reqClass}">
                  <div class="vault-row-label">
                    <span class="name">${escapeHtml(t.label)}${t.required ? '<span class="req">REQ</span>' : ''}</span>
                    <span class="key">${escapeHtml(t.key)}</span>
                    ${t.where ? `<span class="where">${escapeHtml(t.where)}</span>` : ''}
                    ${t.notes ? `<span class="notes">${escapeHtml(t.notes)}</span>` : ''}
                  </div>
                  <div class="vault-input-wrap">
                    <input type="password"
                           placeholder="${escapeHtml(t.placeholder || '')}"
                           data-vault-key="${escapeHtml(t.key)}"
                           autocomplete="off"
                           spellcheck="false">
                    <span class="vault-status ${setClass}">
                      ${ss ? `Set <span class="last4">${escapeHtml(ss.last4 || '')}</span> ${ss.set_at ? ' · ' + new Date(ss.set_at).toLocaleDateString() : ''}` : 'Not set'}
                    </span>
                  </div>
                  <div class="vault-row-actions">
                    <button class="show-btn" type="button" data-toggle="${escapeHtml(t.key)}">Show</button>
                    ${ss ? `<button class="delete-btn" type="button" data-del="${escapeHtml(t.key)}">Delete</button>` : ''}
                  </div>
                </div>`;
            }).join('')}
          </div>
        </div>`;
    }).join('');

    // toggle show/hide
    list.querySelectorAll('[data-toggle]').forEach((b) => {
      b.addEventListener('click', (e) => {
        const key = b.getAttribute('data-toggle');
        const inp = list.querySelector(`input[data-vault-key="${CSS.escape(key)}"]`);
        if (inp) {
          inp.type = inp.type === 'password' ? 'text' : 'password';
          b.textContent = inp.type === 'password' ? 'Show' : 'Hide';
        }
      });
    });

    // delete
    list.querySelectorAll('[data-del]').forEach((b) => {
      b.addEventListener('click', async (e) => {
        const key = b.getAttribute('data-del');
        if (!confirm(`Delete ${key}? Norma will lose access to that service.`)) return;
        try {
          await fetch('/api/vault/' + encodeURIComponent(key), { method: 'DELETE' });
          renderVault();
        } catch (err) { alert('Delete failed: ' + err.message); }
      });
    });
  }

  async function saveVault() {
    const updates = {};
    document.querySelectorAll('[data-vault-key]').forEach((inp) => {
      if (inp.value && inp.value.trim()) {
        updates[inp.getAttribute('data-vault-key')] = inp.value.trim();
      }
    });
    if (Object.keys(updates).length === 0) {
      toast('xp', 'No new values to save');
      return;
    }
    try {
      const r = await fetch('/api/vault', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });
      const data = await r.json();
      // clear inputs
      document.querySelectorAll('[data-vault-key]').forEach((inp) => { inp.value = ''; });
      const ind = document.getElementById('vault-saved');
      if (ind) { ind.hidden = false; setTimeout(() => { ind.hidden = true; }, 3000); }
      toast('badge', `🔐 Saved ${data.saved} key${data.saved === 1 ? '' : 's'}`);
      await renderVault();
    } catch (err) {
      alert('Save failed: ' + err.message);
    }
  }

  function wireVaultActions() {
    const saveBtn = document.getElementById('vault-save-all');
    const refBtn = document.getElementById('vault-refresh');
    if (saveBtn) saveBtn.addEventListener('click', saveVault);
    if (refBtn) refBtn.addEventListener('click', renderVault);
  }

  /* ─────────────── refresh ─────────────── */
  function refreshAll() {
    save();
    renderAgents(document.querySelector('.filter-btn.active')?.getAttribute('data-filter') || 'all');
    renderPitch();
    renderHud();
  }

  document.addEventListener('DOMContentLoaded', boot);
})();