← 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
/* ─────────────── 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 “${escapeHtml(q)}”.</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);
})();