← back to Norma Sdcc Pitch
initial scaffold — lecture-style SDCC pitch viewer with quizzes + XP + agent catalog + Q&A cheat-sheets
43cd061619b72a4d6ee308d8fe954e5ca8f8e042 · 2026-05-20 08:13:18 -0700 · SteveStudio2
Files touched
A .gitignoreA data/features.jsonA package-lock.jsonA package.jsonA public/app.jsA public/index.htmlA public/styles.cssA server.js
Diff
commit 43cd061619b72a4d6ee308d8fe954e5ca8f8e042
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 20 08:13:18 2026 -0700
initial scaffold — lecture-style SDCC pitch viewer with quizzes + XP + agent catalog + Q&A cheat-sheets
---
.gitignore | 7 +
data/features.json | 529 ++++++++++++++++++++++++++++++++
package-lock.json | 872 +++++++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 16 +
public/app.js | 539 +++++++++++++++++++++++++++++++++
public/index.html | 331 ++++++++++++++++++++
public/styles.css | 520 ++++++++++++++++++++++++++++++++
server.js | 192 ++++++++++++
8 files changed, 3006 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7b680af
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+*.log
+data/pitch-state/
+videos/
+screenshots/
+.DS_Store
diff --git a/data/features.json b/data/features.json
new file mode 100644
index 0000000..53943cb
--- /dev/null
+++ b/data/features.json
@@ -0,0 +1,529 @@
+{
+ "lecture": {
+ "why_ai": {
+ "headline": "Why AI for a Nonprofit?",
+ "subhead": "Smaller staff. Bigger reach. Faster cycles.",
+ "bullets": [
+ "Nonprofits compete for attention with corporations that spend 100× more on content and outreach. AI levels the field by absorbing the time-consuming work — drafting, summarizing, classifying — so each staffer's hour goes further.",
+ "Pattern recognition at scale: AI surfaces which signals (news cycles, legislative moves, donor activity) actually predict policy wins, instead of relying on guesswork.",
+ "Always-on triage: inbound mail, news, petition activity arrive 24/7. AI triages overnight so the morning starts with a curated priority list, not a 400-message inbox.",
+ "Cost: a fully-managed AI stack runs in single-digit hundreds per month — less than a part-time hire — and covers tasks that previously took a full FTE."
+ ],
+ "honest_caveats": [
+ "AI drafts, humans approve. Every outbound message goes through a person before send.",
+ "AI is wrong sometimes. Citations on every claim, audit log on every decision.",
+ "AI cannot replace movement-building, donor relationships, or direct organizing. It replaces the typing."
+ ],
+ "quiz": [
+ {
+ "q": "What's the single biggest reason a small nonprofit benefits from AI?",
+ "options": [
+ "It replaces staff with cheaper labor",
+ "It absorbs the time-consuming drafting/research work so each staffer's hour goes further",
+ "It makes the website look more modern",
+ "It eliminates the need for human approval"
+ ],
+ "answer": 1,
+ "explain": "AI is a force multiplier on human time — not a staff replacement. The 'unfair advantage' is each staffer doing the work of three, not firing two staffers."
+ },
+ {
+ "q": "True or False — Norma will send messages on SDCC's behalf without human review.",
+ "options": ["True", "False"],
+ "answer": 1,
+ "explain": "Human-in-the-loop on every outbound. AI drafts, a staffer hits send."
+ },
+ {
+ "q": "What's the typical monthly cost of a fully-managed AI stack for a small org?",
+ "options": [
+ "$10/mo (free tier)",
+ "Single-digit hundreds per month",
+ "$10,000+/mo enterprise pricing",
+ "Free if you self-host"
+ ],
+ "answer": 1,
+ "explain": "Realistic budget — Claude API + hosting + integrations land around $200-$800/mo for a 5-person org."
+ }
+ ]
+ },
+
+ "long_short": {
+ "headline": "Long-Term vs Short-Term Thinking",
+ "subhead": "Short-term wins build trust. Long-term wins build moats.",
+ "short_term": [
+ { "title": "Inbox triage live in week 1", "detail": "Sabrina opens to a sorted queue of priority borrower stories with draft replies. Time recovered: 6-10 hrs/week per staffer." },
+ { "title": "Press statement drafts in 90 seconds", "detail": "When ED/CFPB drops news at 4pm Friday, SDCC has a public response queued by 4:05. Not Monday." },
+ { "title": "Petition campaigns in 10 min", "detail": "Replaces 3-4 hours of cold drafting per campaign. SDCC can fire 5 campaigns per week instead of 1." }
+ ],
+ "long_term": [
+ { "title": "Knowledge base that compounds", "detail": "Every approved draft, statement, classified borrower story feeds the system. The longer SDCC uses it, the more it knows SDCC's voice — and the harder it is for a competitor to clone." },
+ { "title": "Coalition map nobody else has", "detail": "Live graph of every org touching student debt, what they've said, who funds them, who they hire. SDCC becomes the strategic hub by knowing the field." },
+ { "title": "Borrower story archive (privacy-preserving)", "detail": "10,000+ structured borrower stories searchable by debt type / geography / urgency. When a journalist calls asking 'do you have a story about X?' — SDCC has 12 in 10 seconds." },
+ { "title": "Compounding policy intelligence", "detail": "Every Congress.gov / ED.gov / CFPB action ingested + summarized daily. After 18 months, SDCC has a structured history nobody else does." }
+ ],
+ "tradeoff": "Most orgs only do short-term ('save me typing now'). That's fine for month 1. But the orgs that win long-term invest in the knowledge layer — and SDCC's policy advantage compounds every quarter the data accumulates.",
+ "quiz": [
+ {
+ "q": "Which is a SHORT-term win SDCC should expect in week 1?",
+ "options": [
+ "A complete coalition power-map",
+ "Inbox triage with draft replies sorted by urgency",
+ "10,000-row borrower story database",
+ "Custom integrations with all SDCC tools"
+ ],
+ "answer": 1,
+ "explain": "Inbox triage is the fastest measurable win. Power maps + story archives take months to build up."
+ },
+ {
+ "q": "Why does the LONG-term value of an AI system compound?",
+ "options": [
+ "AI gets cheaper over time",
+ "Every approved draft + classified story trains the system on SDCC's voice + data",
+ "Anthropic releases free upgrades",
+ "Long-term you can fire more staff"
+ ],
+ "answer": 1,
+ "explain": "Knowledge moats. The longer SDCC uses it, the harder a competitor can replicate SDCC's institutional memory."
+ }
+ ]
+ },
+
+ "projects_and_docs": {
+ "headline": "Projects + Anthropic Docs — What Powers This",
+ "subhead": "Brief tour of the toolchain so nobody's surprised later.",
+ "what_is_anthropic": "Anthropic is the company that builds Claude — the language model under Norma's hood. Norma talks to Claude through Anthropic's API. That's the only outside dependency for AI inference.",
+ "projects_concept": [
+ { "title": "What a 'Project' is in this context", "detail": "A bounded workspace — for SDCC, that means: one tenant of Norma, with SDCC's prompts, voice library, knowledge base, integrations, and audit trail. Other orgs in Norma run their own projects, fully isolated." },
+ { "title": "Anthropic Projects (the product)", "detail": "Anthropic also ships a feature called 'Projects' inside claude.ai where you can attach files + give Claude persistent instructions. Useful for staffers doing one-off research — but Norma is more powerful because it has the database, the integrations, and the audit trail." },
+ { "title": "Why both can coexist", "detail": "Staff can use claude.ai Projects for free-form research. Norma handles the structured, repeatable, audited work — drafts, classifications, briefings, sends." }
+ ],
+ "docs_overview": [
+ { "title": "anthropic.com/api", "detail": "Where the model lives. Norma calls Claude through here." },
+ { "title": "docs.anthropic.com", "detail": "API reference + prompt engineering guides. SDCC staff don't read these; Norma's team does." },
+ { "title": "Anthropic responsible-use policy", "detail": "Bars on what AI can be used for. Norma's design respects these — borrower PII handling, no unauthorized impersonation, etc." }
+ ],
+ "quiz": [
+ {
+ "q": "When SDCC uses Norma, where does Claude (the AI) actually run?",
+ "options": [
+ "On SDCC's laptops",
+ "Anthropic's servers — Norma calls Claude through the API",
+ "On the same machine as Norma",
+ "On an SDCC-owned cloud account"
+ ],
+ "answer": 1,
+ "explain": "Anthropic hosts the model. Norma is the wrapper that handles SDCC's data, prompts, and integrations."
+ },
+ {
+ "q": "What's the difference between 'Anthropic Projects' (claude.ai) and a 'Norma project for SDCC'?",
+ "options": [
+ "Nothing, they're the same",
+ "Anthropic Projects is free-form research in claude.ai; Norma is structured, audited, integrated with SDCC's database",
+ "Norma is hosted by Anthropic",
+ "Anthropic Projects is more powerful"
+ ],
+ "answer": 1,
+ "explain": "Two different tools — staffers can use both. Norma owns the structured work, claude.ai Projects handles ad-hoc research."
+ }
+ ]
+ },
+
+ "loops": {
+ "headline": "Loops — How Norma Keeps Working While You Sleep",
+ "subhead": "A 'loop' is an agent that runs the same job over and over, on its own.",
+ "explainer": "Most software waits for a click. A loop doesn't. It wakes up every N minutes/hours, scans for new work, does the work, sleeps. No human pushing buttons. Loops are how Norma absorbs always-on tasks — inbox scanning, news ingestion, signature counting — without a staffer babysitting them.",
+ "examples": [
+ { "title": "Inbox loop (every 5 min)", "detail": "Polls info@studentdebtcrisis.org. Any new message → classify, draft reply, route to right person, log. If nobody acts in 24h, escalate to Slack." },
+ { "title": "News-scan loop (every 30 min)", "detail": "Hits 200+ outlets. Filters for SDCC mentions, student-debt stories, key staff names. Adds to morning brief queue." },
+ { "title": "Petition-velocity loop (every 15 min)", "detail": "Checks live signature count on active campaigns. If a campaign is accelerating past threshold → trigger escalation email to Natalia + draft press push." },
+ { "title": "Coalition-watch loop (daily 6am)", "detail": "Scrapes coalition partner websites + social. Flags new statements, new hires, leadership changes. SDCC knows before anyone announces." }
+ ],
+ "guardrails": [
+ "Every loop has a kill switch (one click in admin → loop stops).",
+ "Every loop logs every action to the audit trail.",
+ "Loops never auto-send to external parties — they only triage, draft, and queue."
+ ],
+ "quiz": [
+ {
+ "q": "What's the difference between a loop and a regular feature?",
+ "options": [
+ "A loop runs on its own schedule; a feature waits for a human click",
+ "Loops are slower",
+ "Loops only work on weekends",
+ "There's no difference"
+ ],
+ "answer": 0,
+ "explain": "That's the whole point — loops absorb always-on work so no staffer has to babysit it."
+ },
+ {
+ "q": "True or False — a loop in Norma can auto-send a press statement without human approval.",
+ "options": ["True", "False"],
+ "answer": 1,
+ "explain": "Loops triage, draft, queue. They NEVER send to external parties. Humans approve every outbound."
+ }
+ ]
+ },
+
+ "cron": {
+ "headline": "Cron Jobs — Scheduled Tasks That Just Happen",
+ "subhead": "Like a loop, but on a calendar instead of a timer.",
+ "explainer": "A cron job is a scheduled task — 'run X at 7am every Monday' or 'run Y at the end of every quarter'. Norma uses cron for things that don't need to run constantly but DO need to happen on a predictable beat.",
+ "examples": [
+ { "title": "Monday 7am — Weekly intelligence brief", "detail": "Compiles the week's most important policy moves, news mentions, petition activity, coalition signals into a single email to Natalia + Sabrina. Land in inbox before the 9am team huddle." },
+ { "title": "First of month 6am — Donor renewal radar", "detail": "Pulls every donor whose recurring gift expires in the next 30 days. Drafts personalized renewal asks for staff approval." },
+ { "title": "Tuesdays 10am — Grants deadline sweep", "detail": "Surfaces every foundation grant with a deadline in the next 30 days where SDCC is a fit. Prevents 'oh no the Lumina deadline was yesterday.'" },
+ { "title": "Quarterly — Board impact report draft", "detail": "Pulls quantitative impact data (campaigns, signatures, press hits, borrower stories, dollars moved) into a draft board report. Staffer edits + sends." }
+ ],
+ "vs_loops": "Loops = 'every N minutes, do this'. Cron = 'at this specific time, do this'. Often used together — a loop catches signals in real-time, cron compiles them into weekly digests.",
+ "quiz": [
+ {
+ "q": "When would SDCC use a cron job instead of a loop?",
+ "options": [
+ "When the task needs to react to events instantly",
+ "When the task should run on a predictable calendar (e.g. every Monday 7am)",
+ "When you want to disable the task",
+ "Cron jobs are old; loops replace them"
+ ],
+ "answer": 1,
+ "explain": "Cron = calendar-driven. Loops = continuous. Use cron for weekly digests, monthly renewals, quarterly reports."
+ }
+ ]
+ },
+
+ "ui_vs_ux": {
+ "headline": "UI vs UX — Why Both Matter for Adoption",
+ "subhead": "A tool that staff doesn't use is worth zero.",
+ "ui_definition": "UI (User Interface) = what it looks like. Colors, typography, buttons, layout, whether things are pretty.",
+ "ux_definition": "UX (User Experience) = how it FEELS to use. Whether you can find what you need in 3 clicks. Whether 'I want to send Natalia the draft' is one button or seven. Whether errors are clear or cryptic.",
+ "examples": [
+ { "title": "Good UI / Bad UX", "detail": "Beautiful dashboard but you can't figure out how to draft a reply. Looks great in a screenshot, fails in real use.", "type": "warning" },
+ { "title": "Bad UI / Good UX", "detail": "Ugly buttons but every workflow is 1-2 clicks and errors say exactly what to do. Staff actually use it. (Better than the first one.)", "type": "ok" },
+ { "title": "Good UI + Good UX (the goal)", "detail": "Looks like a product Natalia would brag about to other EDs, AND every flow is 1-2 clicks, AND errors are friendly.", "type": "goal" }
+ ],
+ "sdcc_priorities": [
+ { "title": "1. UX first, UI second", "detail": "If staff hates using it, the prettiest UI in the world won't save it." },
+ { "title": "2. Mobile matters", "detail": "Sabrina + Natalia work from phones. Every flow must work on mobile, not just desktop." },
+ { "title": "3. Voice consistency over visual novelty", "detail": "SDCC's voice = direct, borrower-centered, urgent. UI shouldn't fight it (no cute illustrations of piggy banks)." },
+ { "title": "4. Accessibility is non-negotiable", "detail": "Many borrowers SDCC advocates for have disabilities. Anything SDCC ships in this tool should be WCAG AA at minimum." }
+ ],
+ "quiz": [
+ {
+ "q": "If a tool has gorgeous UI but staff can't figure out how to use it, what is that?",
+ "options": [
+ "Good UI / Bad UX — staff won't use it",
+ "Best of both worlds",
+ "Acceptable — pretty matters most",
+ "Bad UI / Good UX"
+ ],
+ "answer": 0,
+ "explain": "If staff can't use it, the prettiness is wasted. UX > UI when forced to choose."
+ },
+ {
+ "q": "Which is a UX problem (not a UI problem)?",
+ "options": [
+ "The button color is wrong",
+ "Sending a draft takes 7 clicks instead of 2",
+ "The font is too small",
+ "The header image is blurry"
+ ],
+ "answer": 1,
+ "explain": "Click-count and friction are UX. Color/font/image are UI."
+ }
+ ]
+ }
+ },
+
+ "pitch_questions": {
+ "headline": "Questions to ask SDCC during the pitch",
+ "intro": "Don't lecture — interview. These are the questions that get SDCC to surface what they actually need (and reveal what they didn't know they needed).",
+ "categories": [
+ {
+ "category": "Current pain",
+ "questions": [
+ "Walk me through the last 3 days of your inbox — what % of messages did you actually want to act on?",
+ "When was the last time you missed a news cycle you wish you'd caught?",
+ "How often do you say 'we should write a statement on this' but never get to it?",
+ "Whose voice/writing is hardest to replicate when they're out of office?",
+ "What was the last grant deadline you missed and what was it for?"
+ ]
+ },
+ {
+ "category": "Workflow + handoffs",
+ "questions": [
+ "When a borrower story comes in, who triages it? What happens if that person is sick?",
+ "How do you find a specific borrower story 6 months later when a journalist asks?",
+ "Who reviews press statements before they go out? Is that one person or three?",
+ "How do you decide which campaigns to launch this month vs next?",
+ "What does your Monday morning team huddle actually use as the agenda?"
+ ]
+ },
+ {
+ "category": "What worries you about AI",
+ "questions": [
+ "What's the worst thing that could happen if SDCC starts using AI for outbound work?",
+ "Who on the team would push back against AI tools, and what's their objection?",
+ "Are there messages or moments where AI MUST NOT touch the output?",
+ "How would you describe SDCC's voice to someone who's never read your work?",
+ "What's the line between 'AI helps' and 'AI undermines our authenticity'?"
+ ]
+ },
+ {
+ "category": "Long-term vision",
+ "questions": [
+ "If Norma was wildly successful for SDCC in 18 months, what would that look like? What 3 metrics moved?",
+ "What would have to be true for SDCC to recommend this to a peer org?",
+ "What integration with your existing stack (Gmail, Slack, EveryAction, etc.) is non-negotiable?",
+ "Who owns the relationship with this tool on SDCC's side?",
+ "What's your decision process for adopting a new tool — who else needs to say yes?"
+ ]
+ },
+ {
+ "category": "Funding + sustainability",
+ "questions": [
+ "Is there a funder who would underwrite SDCC's adoption of this as 'capacity-building'?",
+ "What's the procurement/budget process for a tool in this price range?",
+ "How long would the pilot need to be before you'd commit to a year?",
+ "Would SDCC be willing to be a public case study if this works?"
+ ]
+ }
+ ]
+ },
+
+ "unknown_unknowns": {
+ "headline": "Unknown unknowns — things about SDCC nobody on the call has thought about",
+ "intro": "These are the questions you probably haven't been asked but that determine whether Norma actually fits SDCC's reality. Use them in the pitch to demonstrate you've thought deeper than the standard vendor.",
+ "items": [
+ {
+ "category": "Borrower trust",
+ "question": "If a borrower learns AI helped draft the reply they got from SDCC, do they feel less heard?",
+ "why_it_matters": "SDCC's whole brand is borrower-centered. If borrowers find out their crisis was triaged by software, it could undermine the trust SDCC's spent 10 years building. Mitigation: AI drafts, human voice in the final send — and SDCC may want to disclose AI use in a privacy notice."
+ },
+ {
+ "category": "Press-cycle risk",
+ "question": "What happens if a journalist asks 'are these statements AI-written'?",
+ "why_it_matters": "If the answer is 'partly,' is SDCC ready for that story? The honest answer is 'AI helps draft, humans approve' — but SDCC needs to rehearse that answer before the question lands."
+ },
+ {
+ "category": "Coalition optics",
+ "question": "What do partner orgs (Debt Collective, NCLC, NAACP) think about AI tools in advocacy?",
+ "why_it_matters": "If Debt Collective publicly opposes AI in organizing, SDCC's adoption could create coalition friction. Worth asking partners before announcing — or at least having a clear position on why SDCC's adoption is different (audit trail, human-in-the-loop, no impersonation)."
+ },
+ {
+ "category": "Staff turnover",
+ "question": "If Sabrina leaves SDCC in 12 months, what happens to her AI-tuned voice library?",
+ "why_it_matters": "AI tools that train on individual staff create a continuity risk. Norma should be tuned on SDCC's organizational voice + multiple staffers' samples — not just one person. Plus exportable knowledge base means SDCC owns the data, not the staffer who left."
+ },
+ {
+ "category": "Donor reaction",
+ "question": "How do major donors feel about funding AI tooling vs. direct services?",
+ "why_it_matters": "Some donors will love 'capacity-building tech.' Others will say 'I funded you to help borrowers, not buy software.' SDCC needs talking points for both. Hint: AI tooling lets SDCC reach 5× more borrowers — that IS direct service."
+ },
+ {
+ "category": "Regulatory shifts",
+ "question": "What if California or NY passes a law requiring AI disclosure in nonprofit communications?",
+ "why_it_matters": "AI regulation is moving fast. SDCC's audit trail is the answer — full log of every AI-assisted message is exactly what a regulator would want. SDCC is ahead of the curve, not behind."
+ },
+ {
+ "category": "Legal voice",
+ "question": "When SDCC posts about specific borrower cases or names a bad actor (school, servicer), who reviews for legal exposure?",
+ "why_it_matters": "AI doesn't know what's defamatory. Need a clear policy that ANY communication naming a specific entity goes through human legal review before send. This is a workflow rule, not an AI feature — but it has to be in writing."
+ },
+ {
+ "category": "Borrower stories + privacy",
+ "question": "Have you ever had a borrower come back and ask you to remove their story from public materials?",
+ "why_it_matters": "If yes — that's a deletion + retraction workflow Norma needs to support, not just storage. Right to be forgotten matters even for nonprofit data."
+ },
+ {
+ "category": "Burnout signal",
+ "question": "If Sabrina's inbox-triage time drops from 12 hrs/week to 2 hrs/week, where do those 10 hours go?",
+ "why_it_matters": "Without intentional planning, recovered time gets backfilled by new work. SDCC should pre-commit to what those hours become — more borrower 1:1s? Deeper press relationships? Strategic planning? — otherwise the gain evaporates."
+ },
+ {
+ "category": "What competitors don't have",
+ "question": "Is anyone else in the student-debt advocacy space using AI tooling at this depth?",
+ "why_it_matters": "If no — SDCC has a 12-18 month lead. If yes — SDCC needs to know who and what they're doing differently. (Honest answer: not at this depth, but Debt Collective is experimenting and PanCAN has Salesforce Einstein.)"
+ },
+ {
+ "category": "Board comprehension",
+ "question": "Does the SDCC board understand AI well enough to approve adoption?",
+ "why_it_matters": "If no — Steve should offer a 30-min board education session. Better to invest the time upfront than have approval stall."
+ },
+ {
+ "category": "Departure plan",
+ "question": "If SDCC decides to stop using Norma in 2 years, what's the off-ramp?",
+ "why_it_matters": "Buyers want to know they're not locked in. Norma should commit to a full data export — borrower stories, voice library, audit trail, coalition map — in a portable format. SDCC owns its data."
+ }
+ ]
+ },
+
+ "real_example": {
+ "headline": "A real example using SDCC's data",
+ "intro": "Live walkthrough — every button below hits a working API. The data is seeded but the path is identical to production.",
+ "scenario": "It's Wednesday 9am. ED just published a press release saying 1M borrowers approved for PSLF discharge. SDCC needs to respond TODAY.",
+ "steps": [
+ { "label": "1. Norma already saw the news", "detail": "News-intelligence loop caught it at 7:42am. Already classified, already in the Pulse brief.", "action": "/api/petitions/topics?source=headline&limit=5", "verb": "GET" },
+ { "label": "2. Draft a supportive-but-pushing statement", "detail": "Norma drafts a press response in SDCC's voice in ~90 seconds. Stance: supportive, calls for faster processing.", "action": "/api/agents/advocacy/generate", "verb": "POST", "body": { "occasion": "ED announces 1M borrowers approved for PSLF discharge", "stance": "supportive but call for faster processing" } },
+ { "label": "3. Find a borrower story to attach", "detail": "Search borrower stories for someone in PSLF who was just approved — for the press attachment.", "action": "/api/leads/discover?type=borrower-story&program=pslf&limit=3", "verb": "GET" },
+ { "label": "4. Generate quote from Natalia", "detail": "Pull Natalia's prior statements on PSLF, draft a quote.", "action": "/api/ai-chat", "verb": "POST", "body": { "message": "Draft a 2-sentence quote from Natalia Abrams responding to ED's PSLF announcement, supportive but urgent about processing speed" } },
+ { "label": "5. Find who to call", "detail": "Pull constituent contacts for journalists covering education policy.", "action": "/api/contacts?tag=press,education-policy&limit=10", "verb": "GET" }
+ ],
+ "ending": "Total time from news drop to press-ready statement + borrower story + quote + press list: ~7 minutes. Without Norma: 3-5 hours. THAT's the value."
+ },
+
+ "agents": [
+ {
+ "id": "petition-generator",
+ "name": "Petition Generator",
+ "tagline": "From topic to live campaign in under 10 minutes",
+ "what_it_does": "Takes a topic prompt + target audience and generates: petition title, full body copy (200-400 words), share text for X/Bluesky/Threads, follow-up email sequence, and a hero image prompt. Tuned to SDCC's voice on prior published petitions.",
+ "for_sdcc": "Replaces the 3-4 hour drafting cycle. Sabrina or Natalia review and approve, never starts from scratch.",
+ "api_route": "/api/agents/petition/generate",
+ "demo_payload": { "topic": "Block the rollback of SAVE plan protections", "audience": "borrowers under 35 in SAVE plan" },
+ "category": "draft",
+ "human_in_the_loop": "Required — staffer approves before publish"
+ },
+ {
+ "id": "advocacy-statement",
+ "name": "Advocacy Statement Drafter",
+ "tagline": "Press-statement first-pass in 90 seconds",
+ "what_it_does": "Drafts a press statement responding to any breaking news or policy decision. Pulls from SDCC's prior statements for tone + current talking points + a citation footer.",
+ "for_sdcc": "When ED announces a policy change at 4pm Friday, SDCC has a draft at 4:05pm — instead of waiting until Monday to respond.",
+ "api_route": "/api/agents/advocacy/generate",
+ "demo_payload": { "occasion": "ED announces 1M borrowers approved for PSLF discharge", "stance": "supportive but call for faster processing" },
+ "category": "draft",
+ "human_in_the_loop": "Required — Natalia approves before publish"
+ },
+ {
+ "id": "grants-match",
+ "name": "Grants Matcher",
+ "tagline": "Surface only the foundation grants you're actually competitive for",
+ "what_it_does": "Scans 12,000+ active foundation grants daily. Matches against SDCC's mission, prior funded programs, ask-size sweet spot, geography. Returns ranked list with deadlines + asks.",
+ "for_sdcc": "Replaces manual grant prospecting nobody has time to do. Quarterly review becomes 30-min weekly review of pre-filtered opportunities.",
+ "api_route": "/api/agents/grants/match",
+ "demo_payload": { "mission": "Student debt advocacy + borrower rights", "ask_range": "50000-250000" },
+ "category": "research",
+ "human_in_the_loop": "Optional — staffer reviews ranked list, AI never submits applications"
+ },
+ {
+ "id": "congress-by-zip",
+ "name": "Congress-by-ZIP",
+ "tagline": "Constituent → representatives in one call",
+ "what_it_does": "Any ZIP code returns Senators, House member, key state-legislature contacts with committee assignments + recent debt-relief voting record.",
+ "for_sdcc": "When a borrower asks 'who do I call?', SDCC sends exact names/numbers/emails in 5 seconds — and tracks which districts are activating.",
+ "api_route": "/api/agents/congress-by-zip?zip=90404",
+ "demo_payload": null,
+ "category": "research",
+ "human_in_the_loop": "None — pure lookup tool"
+ },
+ {
+ "id": "org-register",
+ "name": "Coalition Partner Auto-Onboard",
+ "tagline": "Add a new coalition partner to your power map in 30 seconds",
+ "what_it_does": "Given an org URL or name, scrapes mission, leadership, geographic focus, current campaigns. Adds to power map with proposed relationship strength.",
+ "for_sdcc": "Builds the coalition power-map without weeks of manual research. Every new org touching student debt gets ingested automatically.",
+ "api_route": "/api/agents/org/register",
+ "demo_payload": { "url": "https://debtcollective.org" },
+ "category": "research",
+ "human_in_the_loop": "Required — staffer confirms before adding to power map"
+ },
+ {
+ "id": "gmail-triage",
+ "name": "Inbox Triage Agent",
+ "tagline": "Every inbound message classified + first-pass reply drafted before you open Gmail",
+ "what_it_does": "Reads every email landing in info@studentdebtcrisis.org. Classifies by type, urgency, action required. Drafts a reply in SDCC's voice. Routes high-urgency to Slack.",
+ "for_sdcc": "Sabrina opens her inbox to a sorted queue of 12 priority items with draft replies — not 400 unread.",
+ "api_route": "/api/gmail/draft-reply",
+ "demo_payload": { "thread_id": "demo-borrower-hardship-001" },
+ "category": "operations",
+ "human_in_the_loop": "Required — drafts only, staffer hits send"
+ },
+ {
+ "id": "news-intelligence",
+ "name": "News Intelligence",
+ "tagline": "Daily AI-curated brief of every news cycle that touches SDCC's work",
+ "what_it_does": "Scans 200+ outlets daily. Surfaces SDCC mentions, key staff, student-debt stories, coalition partners. Generates a 5-bullet morning brief with links.",
+ "for_sdcc": "Natalia opens email at 7am to a single 5-bullet brief — not 50 Google Alerts.",
+ "api_route": "/api/petitions/topics?source=headline&limit=8",
+ "demo_payload": null,
+ "category": "research",
+ "human_in_the_loop": "None — pure briefing"
+ },
+ {
+ "id": "borrower-story-classifier",
+ "name": "Borrower Story Classifier",
+ "tagline": "Every borrower story sorted by debt type, urgency, and press-readiness",
+ "what_it_does": "Web-form submissions and emails get classified by debt type (federal/private/parent PLUS), crisis (default/garnishment/denial), and whether anonymizable for press.",
+ "for_sdcc": "When a journalist asks 'do you have a borrower in [state] dealing with [scenario]?', SDCC searches a structured database in 10 seconds.",
+ "api_route": "/api/leads/discover",
+ "demo_payload": { "type": "borrower-story" },
+ "category": "operations",
+ "human_in_the_loop": "Required — staffer reviews + tags before publish-ready"
+ },
+ {
+ "id": "boilerplate-library",
+ "name": "Boilerplate Language Library",
+ "tagline": "One-click insert SDCC's standing language on every issue",
+ "what_it_does": "Pre-built library of vetted SDCC language — SAVE plan position, PSLF stance, parent PLUS reform, for-profit accountability. Every draft can one-click insert latest approved wording.",
+ "for_sdcc": "Ensures every public message uses the most recent agreed-upon language — no stale talking points from 2023.",
+ "api_route": "/api/boilerplate",
+ "demo_payload": null,
+ "category": "draft",
+ "human_in_the_loop": "Library is human-curated; agent only inserts"
+ },
+ {
+ "id": "audit-trail",
+ "name": "Audit Trail + Compliance",
+ "tagline": "Every AI decision, every send, every approval logged forever",
+ "what_it_does": "Every action — agent generated draft X, staffer approved, message sent — immutably logged with timestamp + actor. Borrower PII access logged separately. Searchable forever.",
+ "for_sdcc": "When a funder, board member, or regulator asks 'who sent this message?' the answer is one query away.",
+ "api_route": "/api/audit",
+ "demo_payload": null,
+ "category": "compliance",
+ "human_in_the_loop": "Read-only — no AI decisions, this is the record"
+ },
+ {
+ "id": "pulse-briefing",
+ "name": "Pulse Daily Briefing",
+ "tagline": "Single landing page that surfaces the day's top priorities",
+ "what_it_does": "Dashboard at /pulse — combines petitions, news, urgent borrower stories, coalition activity into one daily-refresh briefing. Each item one-click expandable.",
+ "for_sdcc": "Morning standup becomes 'open Pulse, read the brief, decide priorities' instead of 'check 8 different tools.'",
+ "api_route": "/api/petitions/topics?limit=12",
+ "demo_payload": null,
+ "category": "operations",
+ "human_in_the_loop": "Read + act — no AI decisions surface here unflagged"
+ },
+ {
+ "id": "ai-chat",
+ "name": "Norma Chat Assistant",
+ "tagline": "Ask SDCC's own knowledge base any question in plain English",
+ "what_it_does": "Chat backed by SDCC's prior statements, petition archive, borrower-story database (anonymized), grants pipeline, coalition map. 'What did we say about parent PLUS in 2024?' gets a cited answer.",
+ "for_sdcc": "Replaces 'I'll get back to you' with an immediate cited answer — for press calls, staff onboarding, quick research.",
+ "api_route": "/api/ai-chat",
+ "demo_payload": { "message": "Summarize SDCC's position on the SAVE plan in 3 sentences for a journalist" },
+ "category": "research",
+ "human_in_the_loop": "Optional — citations on every answer"
+ }
+ ],
+
+ "gamify": {
+ "xp_per_quiz": 25,
+ "xp_per_correct": 10,
+ "xp_per_section_complete": 50,
+ "levels": [
+ { "min": 0, "name": "Pitch Apprentice", "color": "#6b7280" },
+ { "min": 100, "name": "Pitch Builder", "color": "#1b4d7a" },
+ { "min": 250, "name": "Pitch Strategist", "color": "#7c3aed" },
+ { "min": 450, "name": "Pitch Master", "color": "#b45309" },
+ { "min": 700, "name": "SDCC-Ready", "color": "#047857" }
+ ],
+ "badges": [
+ { "id": "first_quiz", "name": "First Quiz", "criteria": "Complete any quiz", "icon": "🎯" },
+ { "id": "perfect_quiz", "name": "Perfect Score", "criteria": "Get 100% on any quiz", "icon": "💎" },
+ { "id": "all_sections", "name": "Lecture Complete", "criteria": "Complete all section quizzes", "icon": "🎓" },
+ { "id": "ran_example", "name": "Ran a Real Example", "criteria": "Hit a real Norma API", "icon": "⚡" },
+ { "id": "pitch_3", "name": "Three's the Pitch", "criteria": "Include 3+ agents in your pitch", "icon": "📋" },
+ { "id": "skipper", "name": "Curator", "criteria": "Skip at least 2 agents (keeping pitch clean)", "icon": "✂️" },
+ { "id": "asked_questions", "name": "Interviewer", "criteria": "Open the 'Questions to Ask' modal", "icon": "❓" },
+ { "id": "unknown_aware", "name": "Self-Aware", "criteria": "Open the 'Unknown Unknowns' modal", "icon": "🔮" }
+ ]
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1dc6c9a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,872 @@
+{
+ "name": "norma-sdcc-pitch",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "norma-sdcc-pitch",
+ "version": "0.1.0",
+ "dependencies": {
+ "express": "^4.22.2",
+ "playwright": "^1.49.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+ "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d71eec9
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "norma-sdcc-pitch",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Lecture-style pitch viewer proposing SDCC adopt Norma + skip/add curation + generated demo video",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js",
+ "record": "node scripts/record-demo.js"
+ },
+ "dependencies": {
+ "express": "^4.22.2",
+ "playwright": "^1.49.0"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..5dd19af
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,539 @@
+(() => {
+ '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,
+ };
+
+ /* ─────────────── 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,
+ }));
+ }
+ 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 || {};
+ } 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();
+ renderAgents();
+ renderQuizzes();
+ renderWalkthrough();
+ renderPitch();
+ renderHud();
+ wireFilters();
+ wirePitchActions();
+ wirePrepCards();
+ wireModalCloseHandlers();
+ 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 ─────────────── */
+ function renderLoopsCards() {
+ const c = document.getElementById('grid-loops');
+ c.innerHTML = state.data.lecture.loops.examples.map((x) => `<div class="card"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p></div>`).join('');
+ }
+ function renderCronCards() {
+ const c = document.getElementById('grid-cron');
+ c.innerHTML = state.data.lecture.cron.examples.map((x) => `<div class="card"><h4>${escapeHtml(x.title)}</h4><p>${escapeHtml(x.detail)}</p></div>`).join('');
+ }
+
+ /* ─────────────── 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();
+ outEl.textContent = `→ HTTP ${data.status} in ${data.ms}ms\n\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;
+ }
+ 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();
+ out.textContent = `→ HTTP ${data.status} in ${data.ms}ms\n\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();
+ }
+
+ /* ─────────────── refresh ─────────────── */
+ function refreshAll() {
+ save();
+ renderAgents(document.querySelector('.filter-btn.active')?.getAttribute('data-filter') || 'all');
+ renderPitch();
+ renderHud();
+ }
+
+ document.addEventListener('DOMContentLoaded', boot);
+})();
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..ec3a1ae
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,331 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Norma × SDCC — A Proposal</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+
+<!-- ───────────────────────── topbar w/ XP + level ───────────────────────── -->
+<header class="topbar">
+ <div class="topbar-inner">
+ <div class="brand">
+ <span class="brand-mark">N×</span>
+ <span class="brand-name">Norma <span class="muted">×</span> SDCC</span>
+ </div>
+ <nav>
+ <a href="#why">1. Why AI</a>
+ <a href="#long-short">2. Long/Short</a>
+ <a href="#projects">3. Projects</a>
+ <a href="#loops">4. Loops</a>
+ <a href="#cron">5. Cron</a>
+ <a href="#ui-ux">6. UI/UX</a>
+ <a href="#agents">7. Agents</a>
+ <a href="#example">8. Live Demo</a>
+ <a href="#prep">9. Pitch Prep</a>
+ </nav>
+ <div class="player-hud">
+ <div class="hud-xp" title="Earn XP by taking quizzes + running examples">
+ <span class="hud-xp-num" id="hud-xp">0</span><span class="hud-xp-label">XP</span>
+ </div>
+ <div class="hud-level" id="hud-level" title="Pitch level">Apprentice</div>
+ <div class="hud-badges" id="hud-badges" title="Badges earned"></div>
+ </div>
+ </div>
+ <div class="topbar-progress"><div id="progress-bar"></div></div>
+</header>
+
+<!-- ───────────────────────────── hero ───────────────────────────── -->
+<section class="hero">
+ <div class="hero-inner">
+ <p class="kicker">A proposal for the Student Debt Crisis Center</p>
+ <h1>An AI platform built for borrower advocacy.</h1>
+ <p class="lede">
+ Norma gives SDCC's staff an AI research analyst, draft writer, and inbox triage agent —
+ without giving up control of the voice, the message, or the borrowers' trust.
+ </p>
+ <div class="hero-cta">
+ <a href="#why" class="btn-primary">Start the lecture</a>
+ <a href="#example" class="btn-secondary">Skip to live demo</a>
+ <a href="#prep" class="btn-ghost">Pitch prep →</a>
+ </div>
+ <p class="hero-meta">
+ <span class="dot dot-green"></span> Every “Run example” below hits a real
+ working API on Norma. No mockups. ·
+ <span class="hud-mini">Each quiz earns XP — full lecture = SDCC-Ready level</span>
+ </p>
+ </div>
+</section>
+
+<!-- ─────────── 1. why ai ─────────── -->
+<section id="why" class="lecture" data-section="why_ai">
+ <div class="lecture-inner">
+ <span class="section-num">1</span>
+ <h2 data-bind="why_ai.headline"></h2>
+ <p class="sub" data-bind="why_ai.subhead"></p>
+ <ul class="lecture-bullets" data-bind-list="why_ai.bullets"></ul>
+ <div class="callout callout-honest">
+ <h3>Honest caveats — we lead with these in the pitch</h3>
+ <ul data-bind-list="why_ai.honest_caveats"></ul>
+ </div>
+ <div class="quiz-block" data-quiz="why_ai"></div>
+ </div>
+</section>
+
+<!-- ─────────── 2. long vs short ─────────── -->
+<section id="long-short" class="lecture alt" data-section="long_short">
+ <div class="lecture-inner">
+ <span class="section-num">2</span>
+ <h2 data-bind="long_short.headline"></h2>
+ <p class="sub" data-bind="long_short.subhead"></p>
+ <div class="split-cards">
+ <div class="split-pane short">
+ <h3>Short-term wins <span class="time-tag">weeks</span></h3>
+ <ul class="split-list" id="grid-short"></ul>
+ </div>
+ <div class="split-pane long">
+ <h3>Long-term moats <span class="time-tag">months / years</span></h3>
+ <ul class="split-list" id="grid-long"></ul>
+ </div>
+ </div>
+ <div class="callout callout-strategy">
+ <h3>The tradeoff</h3>
+ <p data-bind="long_short.tradeoff"></p>
+ </div>
+ <div class="quiz-block" data-quiz="long_short"></div>
+ </div>
+</section>
+
+<!-- ─────────── 3. projects + anthropic docs ─────────── -->
+<section id="projects" class="lecture" data-section="projects_and_docs">
+ <div class="lecture-inner">
+ <span class="section-num">3</span>
+ <h2 data-bind="projects_and_docs.headline"></h2>
+ <p class="sub" data-bind="projects_and_docs.subhead"></p>
+ <div class="callout callout-info">
+ <h3>What is Anthropic?</h3>
+ <p data-bind="projects_and_docs.what_is_anthropic"></p>
+ </div>
+ <h3 class="subsection">What “Projects” means in this context</h3>
+ <div class="grid-cards" id="grid-projects-concept"></div>
+ <h3 class="subsection">The docs at a glance</h3>
+ <div class="grid-cards small-cards" id="grid-docs-overview"></div>
+ <div class="quiz-block" data-quiz="projects_and_docs"></div>
+ </div>
+</section>
+
+<!-- ─────────── 4. loops ─────────── -->
+<section id="loops" class="lecture alt" data-section="loops">
+ <div class="lecture-inner">
+ <span class="section-num">4</span>
+ <h2 data-bind="loops.headline"></h2>
+ <p class="sub" data-bind="loops.subhead"></p>
+ <p class="lecture-para" data-bind="loops.explainer"></p>
+ <h3 class="subsection">Loops SDCC would run</h3>
+ <div class="grid-cards" id="grid-loops"></div>
+ <div class="callout callout-warn">
+ <h3>Guardrails — what loops CAN'T do</h3>
+ <ul data-bind-list="loops.guardrails"></ul>
+ </div>
+ <div class="quiz-block" data-quiz="loops"></div>
+ </div>
+</section>
+
+<!-- ─────────── 5. cron ─────────── -->
+<section id="cron" class="lecture" data-section="cron">
+ <div class="lecture-inner">
+ <span class="section-num">5</span>
+ <h2 data-bind="cron.headline"></h2>
+ <p class="sub" data-bind="cron.subhead"></p>
+ <p class="lecture-para" data-bind="cron.explainer"></p>
+ <h3 class="subsection">Cron jobs SDCC would run</h3>
+ <div class="grid-cards" id="grid-cron"></div>
+ <div class="callout callout-info">
+ <h3>Loops vs Cron — when to use which</h3>
+ <p data-bind="cron.vs_loops"></p>
+ </div>
+ <div class="quiz-block" data-quiz="cron"></div>
+ </div>
+</section>
+
+<!-- ─────────── 6. ui vs ux ─────────── -->
+<section id="ui-ux" class="lecture alt" data-section="ui_vs_ux">
+ <div class="lecture-inner">
+ <span class="section-num">6</span>
+ <h2 data-bind="ui_vs_ux.headline"></h2>
+ <p class="sub" data-bind="ui_vs_ux.subhead"></p>
+ <div class="def-pair">
+ <div class="def-box">
+ <span class="def-tag">UI</span>
+ <p data-bind="ui_vs_ux.ui_definition"></p>
+ </div>
+ <div class="def-box">
+ <span class="def-tag accent">UX</span>
+ <p data-bind="ui_vs_ux.ux_definition"></p>
+ </div>
+ </div>
+ <h3 class="subsection">Three combinations</h3>
+ <div class="grid-cards" id="grid-uiux-examples"></div>
+ <h3 class="subsection">What SDCC actually needs</h3>
+ <ul class="lecture-bullets compact" id="grid-uiux-priorities"></ul>
+ <div class="quiz-block" data-quiz="ui_vs_ux"></div>
+ </div>
+</section>
+
+<!-- ─────────── 7. agents ─────────── -->
+<section id="agents" class="lecture" data-section="agents">
+ <div class="lecture-inner">
+ <span class="section-num">7</span>
+ <h2>Agents + skills SDCC would actually use</h2>
+ <p class="sub">
+ Click any card → open the modal → tap <strong>Run example</strong> for real output, or use
+ <strong>Add to pitch</strong> / <strong>Skip</strong> to curate the demo SDCC will see.
+ </p>
+ <div class="agent-filter">
+ <button class="filter-btn active" data-filter="all">All</button>
+ <button class="filter-btn" data-filter="draft">Drafting</button>
+ <button class="filter-btn" data-filter="research">Research</button>
+ <button class="filter-btn" data-filter="operations">Operations</button>
+ <button class="filter-btn" data-filter="compliance">Compliance</button>
+ </div>
+ <div class="agent-grid" id="agent-grid"></div>
+ </div>
+</section>
+
+<!-- ─────────── 8. live example walkthrough ─────────── -->
+<section id="example" class="lecture alt" data-section="real_example">
+ <div class="lecture-inner">
+ <span class="section-num">8</span>
+ <h2 data-bind="real_example.headline"></h2>
+ <p class="sub" data-bind="real_example.intro"></p>
+ <div class="scenario-box">
+ <h3>Scenario</h3>
+ <p data-bind="real_example.scenario"></p>
+ </div>
+ <ol class="walkthrough-list" id="walkthrough-steps"></ol>
+ <div class="callout callout-strategy">
+ <h3>Result</h3>
+ <p data-bind="real_example.ending"></p>
+ </div>
+ </div>
+</section>
+
+<!-- ─────────── 9. pitch prep ─────────── -->
+<section id="prep" class="lecture pitch-section" data-section="prep">
+ <div class="lecture-inner">
+ <span class="section-num">9</span>
+ <h2>Pitch prep — for Steve only</h2>
+ <p class="sub">
+ Two cheat-sheets to keep open during the pitch + your curated agent list.
+ </p>
+ <div class="prep-cards">
+ <button class="prep-card" id="open-questions">
+ <span class="prep-icon">❓</span>
+ <h3>Questions to ask SDCC</h3>
+ <p>The interview script — 5 categories, 25+ questions. Reveals what SDCC actually needs.</p>
+ <span class="prep-cta">Open cheat-sheet →</span>
+ </button>
+ <button class="prep-card" id="open-unknowns">
+ <span class="prep-icon">🔮</span>
+ <h3>Unknown unknowns about SDCC</h3>
+ <p>12 questions nobody on the call has thought about. Use these to demonstrate depth.</p>
+ <span class="prep-cta">Open cheat-sheet →</span>
+ </button>
+ </div>
+
+ <h3 class="subsection">Your curated pitch — what SDCC will see</h3>
+ <div class="pitch-summary">
+ <div class="pitch-count">
+ <span id="pitch-included-count">0</span> included ·
+ <span id="pitch-skipped-count">0</span> skipped
+ </div>
+ <div class="pitch-actions">
+ <button id="btn-clear" class="btn-ghost">Clear</button>
+ <button id="btn-export" class="btn-secondary">Export HTML for SDCC</button>
+ </div>
+ </div>
+ <ul id="pitch-list" class="pitch-list"></ul>
+ </div>
+</section>
+
+<footer>
+ <div class="footer-inner">
+ <div>Norma × SDCC pitch viewer · localhost:9876 · backing Norma at <code>localhost:7400</code></div>
+ <div class="muted">All examples run against your local dev DB — borrower data stays on this machine</div>
+ </div>
+</footer>
+
+<!-- ─────────── modal: agent detail ─────────── -->
+<div class="modal" id="modal-feature" hidden>
+ <div class="modal-backdrop" data-close></div>
+ <div class="modal-card" role="dialog" aria-labelledby="modal-title">
+ <button class="modal-close" data-close aria-label="close">×</button>
+ <div class="modal-header">
+ <span class="badge" id="modal-category"></span>
+ <h2 id="modal-title"></h2>
+ <p class="tagline" id="modal-tagline"></p>
+ </div>
+ <div class="modal-body">
+ <section>
+ <h3>What it does</h3>
+ <p id="modal-what"></p>
+ </section>
+ <section>
+ <h3>What this means for SDCC</h3>
+ <p id="modal-for-sdcc"></p>
+ </section>
+ <section class="hitl">
+ <h3>Human-in-the-loop</h3>
+ <p id="modal-hitl"></p>
+ </section>
+ <section id="modal-example-section">
+ <h3>Run a real example</h3>
+ <p class="muted small">Hits <code id="modal-route"></code> on Norma at localhost:7400.</p>
+ <button id="modal-run-btn" class="btn-secondary">▶ Run example</button>
+ <pre id="modal-example-out"></pre>
+ </section>
+ </div>
+ <div class="modal-footer">
+ <textarea id="modal-note" placeholder="Note for SDCC (optional) — e.g. 'show this one first'"></textarea>
+ <div class="modal-actions">
+ <button id="btn-skip" class="btn-ghost">Skip — keep pitch clean</button>
+ <button id="btn-add" class="btn-primary">Add to pitch</button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- ─────────── modal: questions to ask ─────────── -->
+<div class="modal" id="modal-questions" hidden>
+ <div class="modal-backdrop" data-close></div>
+ <div class="modal-card wide" role="dialog">
+ <button class="modal-close" data-close>×</button>
+ <div class="modal-header">
+ <h2 data-bind="pitch_questions.headline"></h2>
+ <p class="tagline" data-bind="pitch_questions.intro"></p>
+ </div>
+ <div class="modal-body" id="questions-body"></div>
+ </div>
+</div>
+
+<!-- ─────────── modal: unknown unknowns ─────────── -->
+<div class="modal" id="modal-unknowns" hidden>
+ <div class="modal-backdrop" data-close></div>
+ <div class="modal-card wide" role="dialog">
+ <button class="modal-close" data-close>×</button>
+ <div class="modal-header">
+ <h2 data-bind="unknown_unknowns.headline"></h2>
+ <p class="tagline" data-bind="unknown_unknowns.intro"></p>
+ </div>
+ <div class="modal-body" id="unknowns-body"></div>
+ </div>
+</div>
+
+<!-- ─────────── toast / xp gain ─────────── -->
+<div class="toast-stack" id="toast-stack"></div>
+
+<script src="/app.js"></script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..ef0fd3f
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,520 @@
+:root {
+ --bg: #fafafa;
+ --surface: #ffffff;
+ --ink: #0e1729;
+ --ink-2: #2c3a55;
+ --muted: #5d6b85;
+ --sdcc-blue: #1b4d7a;
+ --sdcc-blue-deep: #0d2e4d;
+ --sdcc-amber: #f59e0b;
+ --sdcc-amber-deep: #b45309;
+ --accent: #d97706;
+ --good: #047857;
+ --good-soft: #ecfdf5;
+ --warn: #b45309;
+ --warn-soft: #fff7ed;
+ --danger: #b91c1c;
+ --danger-soft: #fef2f2;
+ --info: #1d4ed8;
+ --info-soft: #eff6ff;
+ --purple: #7c3aed;
+ --purple-soft: #f5f3ff;
+ --border: #e1e6ef;
+ --border-soft: #eef1f7;
+ --radius: 12px;
+ --radius-lg: 18px;
+ --shadow-sm: 0 1px 2px rgba(15,29,53,.04), 0 2px 8px rgba(15,29,53,.05);
+ --shadow-md: 0 4px 12px rgba(15,29,53,.08), 0 16px 32px rgba(15,29,53,.06);
+ --shadow-lg: 0 12px 24px rgba(15,29,53,.10), 0 24px 64px rgba(15,29,53,.10);
+ --font-serif: 'Georgia', 'Times New Roman', serif;
+ --font-sans: ui-sans-serif, system-ui, -apple-system, 'Inter', 'Helvetica Neue', sans-serif;
+}
+
+* { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+ font-family: var(--font-sans);
+ color: var(--ink);
+ background: var(--bg);
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.muted { color: var(--muted); }
+.small { font-size: .85em; }
+code { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: .85em; background: var(--border-soft); padding: 1px 6px; border-radius: 4px; }
+
+/* ────── topbar ────── */
+.topbar {
+ position: sticky; top: 0; z-index: 50;
+ background: rgba(255,255,255,.94);
+ backdrop-filter: saturate(140%) blur(10px);
+ -webkit-backdrop-filter: saturate(140%) blur(10px);
+ border-bottom: 1px solid var(--border);
+}
+.topbar-inner {
+ max-width: 1280px; margin: 0 auto; padding: 12px 28px;
+ display: flex; align-items: center; gap: 24px;
+}
+.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
+.brand-mark {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 34px; height: 34px; border-radius: 9px;
+ background: linear-gradient(135deg, var(--sdcc-blue), var(--sdcc-blue-deep));
+ color: #fff; font-weight: 700; font-size: 13px; letter-spacing: -.5px;
+}
+.brand-name { font-size: 14.5px; }
+.brand-name .muted { font-weight: 400; }
+.topbar nav { display: flex; gap: 18px; margin-left: 18px; flex: 1; overflow-x: auto; }
+.topbar nav a {
+ color: var(--ink-2); text-decoration: none; font-size: 13px; font-weight: 500; white-space: nowrap;
+ padding: 6px 0; border-bottom: 2px solid transparent; transition: border-color .15s, color .15s;
+}
+.topbar nav a:hover { color: var(--sdcc-blue); border-bottom-color: var(--sdcc-blue); }
+
+/* ────── player HUD (gamification) ────── */
+.player-hud {
+ display: flex; align-items: center; gap: 12px;
+ background: var(--border-soft); border: 1px solid var(--border);
+ padding: 6px 12px; border-radius: 999px;
+}
+.hud-xp { display: flex; align-items: baseline; gap: 4px; }
+.hud-xp-num { font-weight: 700; font-size: 16px; color: var(--sdcc-amber-deep); font-variant-numeric: tabular-nums; }
+.hud-xp-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
+.hud-level {
+ font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px;
+ background: linear-gradient(90deg, #6b7280, #4b5563); color: #fff;
+ transition: background .3s;
+}
+.hud-badges { display: flex; gap: 4px; font-size: 18px; min-width: 30px; }
+.hud-badges span { transition: transform .2s; }
+.hud-badges span:hover { transform: scale(1.25); }
+.topbar-progress { height: 3px; background: var(--border-soft); }
+#progress-bar {
+ height: 100%; width: 0%;
+ background: linear-gradient(90deg, var(--sdcc-amber), var(--sdcc-amber-deep));
+ transition: width .4s ease;
+}
+
+/* ────── hero ────── */
+.hero {
+ background:
+ radial-gradient(1200px 600px at 80% -10%, rgba(245,158,11,.10), transparent 60%),
+ radial-gradient(1000px 500px at 0% 20%, rgba(27,77,122,.10), transparent 60%),
+ linear-gradient(180deg, #fff 0%, var(--bg) 100%);
+ padding: 80px 32px 88px;
+ border-bottom: 1px solid var(--border);
+}
+.hero-inner { max-width: 920px; margin: 0 auto; text-align: center; }
+.kicker { color: var(--accent); text-transform: uppercase; letter-spacing: 1.4px; font-size: 12px; font-weight: 700; margin: 0 0 14px; }
+.hero h1 {
+ font-family: var(--font-serif); font-weight: 700;
+ font-size: clamp(34px, 5.2vw, 56px); line-height: 1.1; letter-spacing: -1px;
+ margin: 0 0 22px; color: var(--sdcc-blue-deep);
+}
+.lede { font-size: 18px; color: var(--ink-2); max-width: 720px; margin: 0 auto 28px; }
+.hero-cta { display: flex; gap: 12px; justify-content: center; margin-bottom: 22px; flex-wrap: wrap; }
+.hero-meta { font-size: 13px; color: var(--muted); margin: 0; }
+.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; margin-right: 6px; }
+.dot-green { background: var(--good); box-shadow: 0 0 0 4px rgba(4,120,87,.15); }
+.hud-mini { display: inline-block; padding: 2px 8px; background: var(--info-soft); color: var(--info); border-radius: 999px; font-weight: 500; font-size: 12px; margin-left: 6px; }
+
+/* ────── buttons ────── */
+.btn-primary, .btn-secondary, .btn-ghost {
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 11px 20px; font-size: 14px; font-weight: 600;
+ border-radius: 8px; cursor: pointer; transition: all .15s;
+ border: 1px solid transparent; text-decoration: none;
+}
+.btn-primary { background: var(--sdcc-blue); color: #fff; border-color: var(--sdcc-blue); }
+.btn-primary:hover { background: var(--sdcc-blue-deep); border-color: var(--sdcc-blue-deep); transform: translateY(-1px); }
+.btn-secondary { background: #fff; color: var(--sdcc-blue); border-color: var(--sdcc-blue); }
+.btn-secondary:hover { background: var(--sdcc-blue); color: #fff; }
+.btn-ghost { background: transparent; color: var(--muted); border-color: transparent; }
+.btn-ghost:hover { color: var(--ink); background: var(--border-soft); }
+
+/* ────── lecture sections ────── */
+.lecture { padding: 76px 32px; }
+.lecture.alt { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
+.lecture-inner { max-width: 1100px; margin: 0 auto; position: relative; }
+.section-num {
+ position: absolute; top: -8px; left: -64px;
+ width: 44px; height: 44px;
+ display: inline-flex; align-items: center; justify-content: center;
+ background: var(--sdcc-blue); color: #fff;
+ font-weight: 700; border-radius: 50%; font-size: 18px;
+ font-family: var(--font-serif);
+ box-shadow: 0 4px 12px rgba(27,77,122,.25);
+}
+@media (max-width: 1240px) { .section-num { position: relative; left: 0; margin-bottom: 8px; } }
+
+.lecture h2 {
+ font-family: var(--font-serif); font-weight: 700;
+ font-size: clamp(26px, 3.2vw, 36px); line-height: 1.15;
+ margin: 0 0 12px; color: var(--sdcc-blue-deep); letter-spacing: -.5px;
+}
+.lecture .sub { font-size: 17px; color: var(--ink-2); margin: 0 0 30px; max-width: 780px; }
+.lecture-para { font-size: 16px; color: var(--ink-2); max-width: 820px; margin: 0 0 28px; line-height: 1.7; }
+.subsection {
+ font-family: var(--font-serif); font-weight: 700;
+ font-size: 19px; margin: 40px 0 18px; color: var(--ink); letter-spacing: -.2px;
+}
+
+/* ────── lecture bullets ────── */
+.lecture-bullets {
+ list-style: none; padding: 0; margin: 0 0 28px;
+ display: grid; grid-template-columns: 1fr; gap: 14px;
+}
+.lecture-bullets li {
+ position: relative; padding: 14px 16px 14px 42px;
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ font-size: 15.5px; line-height: 1.6; color: var(--ink-2);
+}
+.lecture-bullets li::before {
+ content: ""; position: absolute; left: 16px; top: 19px;
+ width: 12px; height: 12px; border-radius: 50%;
+ background: var(--sdcc-amber); box-shadow: 0 0 0 4px rgba(245,158,11,.20);
+}
+.lecture-bullets.compact li { font-size: 15px; padding: 12px 14px 12px 38px; }
+.lecture-bullets.compact li::before { left: 14px; top: 17px; }
+
+/* ────── callouts ────── */
+.callout {
+ border-radius: var(--radius); padding: 18px 22px; margin-top: 14px;
+}
+.callout h3 { margin: 0 0 10px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }
+.callout p { margin: 0; color: var(--ink-2); font-size: 15px; }
+.callout ul { margin: 0; padding-left: 18px; color: var(--ink-2); }
+.callout li { margin: 5px 0; }
+.callout-honest { background: var(--warn-soft); border: 1px solid #fbd38d; border-left: 4px solid var(--sdcc-amber); }
+.callout-honest h3 { color: var(--sdcc-amber-deep); }
+.callout-strategy { background: var(--good-soft); border: 1px solid #a7f3d0; border-left: 4px solid var(--good); }
+.callout-strategy h3 { color: var(--good); }
+.callout-info { background: var(--info-soft); border: 1px solid #c7d2fe; border-left: 4px solid var(--info); }
+.callout-info h3 { color: var(--info); }
+.callout-warn { background: var(--danger-soft); border: 1px solid #fecaca; border-left: 4px solid var(--danger); }
+.callout-warn h3 { color: var(--danger); }
+
+/* ────── split cards (long vs short) ────── */
+.split-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
+@media (max-width: 800px) { .split-cards { grid-template-columns: 1fr; } }
+.split-pane {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px;
+}
+.split-pane.short { border-top: 4px solid var(--sdcc-amber); }
+.split-pane.long { border-top: 4px solid var(--purple); }
+.split-pane h3 { margin: 0 0 16px; font-size: 18px; color: var(--ink); display: flex; align-items: center; gap: 10px; }
+.time-tag {
+ background: var(--border-soft); padding: 2px 10px; border-radius: 999px;
+ font-size: 11px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 1px;
+}
+.split-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
+.split-list li {
+ padding: 12px 14px; background: var(--border-soft); border-radius: 8px;
+ font-size: 14.5px; color: var(--ink-2);
+}
+.split-list li strong { color: var(--ink); display: block; margin-bottom: 4px; }
+
+/* ────── grid-cards ────── */
+.grid-cards {
+ display: grid; gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
+}
+.grid-cards.small-cards { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
+.grid-cards .card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 20px; transition: all .15s; box-shadow: var(--shadow-sm);
+}
+.grid-cards .card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); border-color: var(--sdcc-blue); }
+.grid-cards .card h4 { margin: 0 0 6px; font-size: 15px; color: var(--sdcc-blue-deep); }
+.grid-cards .card p { margin: 0; font-size: 14px; color: var(--ink-2); line-height: 1.55; }
+.grid-cards .card.warn { border-left: 3px solid var(--danger); }
+.grid-cards .card.ok { border-left: 3px solid var(--sdcc-amber); }
+.grid-cards .card.goal { border-left: 3px solid var(--good); background: var(--good-soft); }
+
+/* ────── def pair (UI vs UX) ────── */
+.def-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
+@media (max-width: 700px) { .def-pair { grid-template-columns: 1fr; } }
+.def-box {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 22px; position: relative;
+}
+.def-tag {
+ display: inline-block; background: var(--sdcc-blue); color: #fff;
+ padding: 3px 12px; border-radius: 999px; font-weight: 700; font-size: 12px;
+ letter-spacing: 1px; margin-bottom: 10px;
+}
+.def-tag.accent { background: var(--sdcc-amber); }
+.def-box p { margin: 0; font-size: 15.5px; color: var(--ink-2); line-height: 1.6; }
+
+/* ────── scenario / walkthrough ────── */
+.scenario-box {
+ background: linear-gradient(180deg, var(--info-soft), #fff);
+ border: 1px solid #c7d2fe; border-radius: var(--radius);
+ padding: 22px 26px; margin: 0 0 24px;
+}
+.scenario-box h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: var(--info); }
+.scenario-box p { margin: 0; font-size: 17px; color: var(--ink); line-height: 1.55; }
+.walkthrough-list { list-style: none; padding: 0; margin: 0 0 24px; display: grid; gap: 12px; counter-reset: step; }
+.walkthrough-list li {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 18px 20px; position: relative; padding-left: 60px;
+ counter-increment: step;
+}
+.walkthrough-list li::before {
+ content: counter(step);
+ position: absolute; left: 18px; top: 18px;
+ width: 30px; height: 30px; border-radius: 50%;
+ background: var(--sdcc-blue); color: #fff;
+ display: inline-flex; align-items: center; justify-content: center;
+ font-weight: 700; font-size: 13px;
+}
+.walkthrough-list li h4 { margin: 0 0 4px; font-size: 16px; color: var(--ink); }
+.walkthrough-list li .detail { color: var(--ink-2); font-size: 14px; margin: 4px 0 10px; }
+.walkthrough-list li .step-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.walkthrough-list li .verb-tag {
+ padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700;
+ background: var(--info-soft); color: var(--info);
+}
+.walkthrough-list li .verb-tag.post { background: var(--good-soft); color: var(--good); }
+.walkthrough-list li .step-run {
+ background: var(--ink); color: #fff; border: none;
+ padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
+}
+.walkthrough-list li .step-run:hover { background: var(--sdcc-blue); }
+.walkthrough-list li pre {
+ background: #0e1729; color: #e8eef7;
+ padding: 12px 14px; border-radius: 8px; margin-top: 10px;
+ font-size: 11.5px; max-height: 180px; overflow: auto; line-height: 1.55;
+ display: none; white-space: pre-wrap; word-break: break-word;
+}
+.walkthrough-list li pre.has-output { display: block; }
+
+/* ────── quizzes ────── */
+.quiz-block {
+ margin-top: 36px;
+ background: linear-gradient(180deg, #fff, var(--info-soft));
+ border: 1px solid #c7d2fe; border-radius: var(--radius-lg);
+ padding: 28px;
+}
+.quiz-block .quiz-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
+.quiz-block .quiz-title { font-family: var(--font-serif); font-size: 18px; color: var(--ink); margin: 0; }
+.quiz-block .quiz-meta { font-size: 12px; color: var(--muted); }
+.quiz-block .quiz-score { font-weight: 700; color: var(--info); font-size: 14px; }
+.quiz-q { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 14px; }
+.quiz-q .q-text { font-weight: 600; font-size: 15px; margin: 0 0 12px; color: var(--ink); }
+.quiz-q .q-options { display: grid; gap: 6px; }
+.quiz-q .q-option {
+ display: flex; align-items: center; gap: 10px;
+ padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
+ cursor: pointer; font-size: 14px; color: var(--ink-2); transition: all .12s;
+ text-align: left;
+}
+.quiz-q .q-option:hover { border-color: var(--sdcc-blue); background: var(--info-soft); }
+.quiz-q .q-option.correct { background: var(--good-soft); border-color: var(--good); color: var(--good); font-weight: 600; }
+.quiz-q .q-option.incorrect { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
+.quiz-q .q-option:disabled { cursor: default; }
+.quiz-q .q-explain {
+ display: none; margin-top: 12px; padding: 10px 14px;
+ background: #fff7ed; border-left: 3px solid var(--sdcc-amber); border-radius: 6px;
+ font-size: 13.5px; color: var(--ink-2);
+}
+.quiz-q.answered .q-explain { display: block; }
+.quiz-complete {
+ margin-top: 16px; padding: 14px 18px; background: var(--good-soft);
+ border: 1px solid #a7f3d0; border-left: 4px solid var(--good); border-radius: 8px;
+ font-size: 14px; font-weight: 600; color: var(--good);
+ display: none;
+}
+.quiz-complete.visible { display: block; }
+
+/* ────── agent grid ────── */
+.agent-filter { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
+.filter-btn {
+ background: var(--surface); border: 1px solid var(--border); color: var(--ink-2);
+ padding: 7px 14px; font-size: 13px; border-radius: 6px; cursor: pointer; transition: all .15s;
+}
+.filter-btn:hover { border-color: var(--sdcc-blue); color: var(--sdcc-blue); }
+.filter-btn.active { background: var(--sdcc-blue); color: #fff; border-color: var(--sdcc-blue); }
+.agent-grid {
+ display: grid; gap: 18px;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+}
+.agent-card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 22px; transition: all .15s; cursor: pointer; box-shadow: var(--shadow-sm);
+ display: flex; flex-direction: column; gap: 10px; position: relative;
+}
+.agent-card:hover { box-shadow: var(--shadow-md); transform: translateY(-3px); border-color: var(--sdcc-blue); }
+.agent-card.included { border-color: var(--good); background: linear-gradient(180deg, var(--good-soft), #fff); }
+.agent-card.included::after {
+ content: "Included"; position: absolute; top: 14px; right: 14px;
+ background: var(--good); color: #fff; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;
+}
+.agent-card.skipped { border-color: var(--border); background: #fafbfc; opacity: .65; }
+.agent-card.skipped::after {
+ content: "Skipped"; position: absolute; top: 14px; right: 14px;
+ background: var(--muted); color: #fff; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;
+}
+.agent-card h3 { margin: 0; font-size: 16px; color: var(--sdcc-blue-deep); padding-right: 78px; }
+.agent-card .tagline { color: var(--muted); font-size: 13px; line-height: 1.5; margin: 0; }
+.agent-card .meta { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.badge {
+ display: inline-block; padding: 3px 8px; font-size: 11px; font-weight: 600;
+ border-radius: 4px; text-transform: uppercase; letter-spacing: .5px;
+}
+.badge-draft { background: var(--info-soft); color: var(--info); }
+.badge-research { background: #ecfeff; color: #0e7490; }
+.badge-operations { background: var(--warn-soft); color: var(--warn); }
+.badge-compliance { background: var(--purple-soft); color: var(--purple); }
+
+/* ────── prep cards ────── */
+.prep-cards { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; margin-bottom: 32px; }
+@media (max-width: 700px) { .prep-cards { grid-template-columns: 1fr; } }
+.prep-card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 24px; cursor: pointer; transition: all .15s; text-align: left;
+ display: flex; flex-direction: column; gap: 8px; box-shadow: var(--shadow-sm);
+ font-family: inherit;
+}
+.prep-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); border-color: var(--sdcc-amber); }
+.prep-icon { font-size: 28px; }
+.prep-card h3 { margin: 0; font-family: var(--font-serif); font-size: 18px; color: var(--ink); }
+.prep-card p { margin: 0; color: var(--ink-2); font-size: 14px; line-height: 1.55; }
+.prep-cta { color: var(--sdcc-blue); font-size: 13px; font-weight: 600; margin-top: 4px; }
+
+/* ────── pitch ribbon ────── */
+.pitch-section { background: linear-gradient(180deg, #fff, #f8fafc); }
+.pitch-summary {
+ display: flex; justify-content: space-between; align-items: center; gap: 16px;
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 18px 22px; margin-bottom: 18px;
+}
+.pitch-count { font-size: 15px; font-weight: 600; color: var(--ink-2); }
+.pitch-count span { color: var(--sdcc-blue); font-weight: 700; }
+.pitch-actions { display: flex; gap: 10px; }
+.pitch-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.pitch-list li {
+ background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
+ padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; gap: 16px;
+}
+.pitch-list li .name { font-weight: 600; }
+.pitch-list li .pitch-note { color: var(--muted); font-size: 13px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-style: italic; }
+.pitch-list li .remove { background: transparent; border: 0; color: var(--danger); cursor: pointer; font-size: 13px; padding: 4px 8px; }
+.pitch-list li .remove:hover { background: var(--danger-soft); border-radius: 4px; }
+.pitch-list:empty::before {
+ content: "No agents included yet. Open any agent card above and tap 'Add to pitch'.";
+ display: block; padding: 28px 16px; text-align: center; color: var(--muted); font-style: italic;
+ background: var(--border-soft); border-radius: var(--radius); border: 1px dashed var(--border);
+}
+
+/* ────── modal ────── */
+.modal { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; }
+.modal[hidden] { display: none !important; }
+.modal-backdrop {
+ position: absolute; inset: 0; background: rgba(13,46,77,.55);
+ backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
+}
+.modal-card {
+ position: relative; z-index: 1; max-width: 680px; width: calc(100vw - 40px);
+ max-height: calc(100vh - 40px); overflow: auto;
+ background: var(--surface); border-radius: var(--radius-lg);
+ padding: 34px 36px 24px; box-shadow: var(--shadow-lg);
+}
+.modal-card.wide { max-width: 880px; }
+.modal-close {
+ position: absolute; top: 10px; right: 14px;
+ background: transparent; border: 0; font-size: 28px; color: var(--muted); cursor: pointer;
+ width: 36px; height: 36px; border-radius: 50%;
+}
+.modal-close:hover { background: var(--border-soft); color: var(--ink); }
+.modal-header { padding-right: 50px; margin-bottom: 22px; }
+.modal-header h2 { margin: 8px 0 6px; font-size: 24px; color: var(--sdcc-blue-deep); font-family: var(--font-serif); }
+.modal-header .tagline { margin: 0; color: var(--muted); font-size: 15px; }
+.modal-body section { margin-bottom: 18px; }
+.modal-body h3 {
+ font-size: 13px; text-transform: uppercase; letter-spacing: 1px;
+ color: var(--sdcc-amber-deep); margin: 0 0 8px;
+}
+.modal-body p { margin: 0; color: var(--ink-2); font-size: 15px; line-height: 1.6; }
+.modal-body .hitl { background: var(--warn-soft); border-left: 3px solid var(--sdcc-amber); padding: 12px 16px; border-radius: 6px; }
+.modal-body .hitl h3 { color: var(--sdcc-amber-deep); margin-bottom: 4px; }
+#modal-run-btn { margin-top: 10px; }
+#modal-example-out {
+ background: #0e1729; color: #e8eef7;
+ padding: 14px 18px; border-radius: 8px; margin-top: 12px;
+ font-size: 12px; max-height: 240px; overflow: auto; line-height: 1.55;
+ display: none; white-space: pre-wrap; word-break: break-word;
+}
+#modal-example-out:not(:empty) { display: block; }
+.modal-footer {
+ border-top: 1px solid var(--border); padding-top: 16px; margin-top: 14px;
+ display: flex; flex-direction: column; gap: 12px;
+}
+#modal-note {
+ width: 100%; min-height: 56px; padding: 10px 12px;
+ border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: 14px;
+ resize: vertical;
+}
+#modal-note:focus { outline: none; border-color: var(--sdcc-blue); }
+.modal-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
+
+/* ────── questions modal ────── */
+.q-category { margin-bottom: 24px; }
+.q-category h3 {
+ font-size: 11px !important; text-transform: uppercase; letter-spacing: 1.5px;
+ color: var(--sdcc-amber-deep); margin: 0 0 10px;
+ padding: 6px 12px; background: var(--warn-soft); display: inline-block; border-radius: 999px;
+}
+.q-category ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.q-category li {
+ padding: 12px 16px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
+ font-size: 14.5px; color: var(--ink); position: relative; padding-left: 38px;
+}
+.q-category li::before {
+ content: "❝"; position: absolute; left: 12px; top: 6px;
+ font-size: 22px; color: var(--sdcc-amber); font-family: var(--font-serif);
+}
+
+/* ────── unknowns modal ────── */
+.unknown-item {
+ background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
+ padding: 16px 18px; margin-bottom: 12px;
+}
+.unknown-item .u-cat {
+ display: inline-block; background: var(--purple-soft); color: var(--purple);
+ padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700;
+ text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px;
+}
+.unknown-item .u-q { font-weight: 600; font-size: 15.5px; color: var(--ink); margin: 0 0 8px; }
+.unknown-item .u-why { font-size: 14px; color: var(--ink-2); margin: 0; line-height: 1.6; }
+.unknown-item .u-why strong { color: var(--ink); }
+
+/* ────── toast / xp gain ────── */
+.toast-stack {
+ position: fixed; bottom: 28px; right: 28px; z-index: 200;
+ display: flex; flex-direction: column; gap: 8px;
+}
+.toast {
+ background: var(--ink); color: #fff;
+ padding: 14px 22px; border-radius: 999px;
+ font-size: 14px; font-weight: 600;
+ box-shadow: var(--shadow-lg);
+ animation: pop-in .35s cubic-bezier(.2,.7,.4,1.6), pop-out .4s 2.6s forwards;
+ display: flex; align-items: center; gap: 10px;
+}
+.toast.xp { background: linear-gradient(90deg, var(--sdcc-amber), var(--sdcc-amber-deep)); }
+.toast.level { background: linear-gradient(90deg, var(--purple), #5b21b6); }
+.toast.badge { background: linear-gradient(90deg, var(--good), #065f46); }
+.toast .toast-icon { font-size: 18px; }
+@keyframes pop-in {
+ from { opacity: 0; transform: translateY(20px) scale(.9); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes pop-out {
+ to { opacity: 0; transform: translateY(20px) scale(.95); }
+}
+
+/* ────── footer ────── */
+footer { border-top: 1px solid var(--border); background: var(--surface); padding: 26px 32px; margin-top: 40px; }
+.footer-inner { max-width: 1100px; margin: 0 auto; font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 6px; }
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..bd6c265
--- /dev/null
+++ b/server.js
@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+/**
+ * norma-sdcc-pitch — lecture-style viewer pitching SDCC on adopting Norma.
+ *
+ * Routes:
+ * GET / — single-page lecture viewer
+ * GET /api/features — feature catalog (lecture + agents)
+ * GET /api/pitch/:role — load saved pitch state for role (steve|sdcc)
+ * POST /api/pitch/:role — save pitch state for role
+ * POST /api/run-example — proxy to Norma at :7400, run an agent example
+ * POST /api/build-video — kick off Playwright recorder
+ * GET /api/video-status — poll for video completion
+ * GET /videos/:filename — serve generated MP4
+ */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+
+const PORT = 9876;
+const ROOT = __dirname;
+const PITCH_DIR = path.join(ROOT, 'data', 'pitch-state');
+const VIDEO_DIR = path.join(ROOT, 'videos');
+const NORMA_BASE = process.env.NORMA_BASE || 'http://localhost:7400';
+
+fs.mkdirSync(PITCH_DIR, { recursive: true });
+fs.mkdirSync(VIDEO_DIR, { recursive: true });
+
+const app = express();
+app.use(express.json({ limit: '2mb' }));
+app.use(express.static(path.join(ROOT, 'public')));
+
+// ── feature catalog ────────────────────────────────────────────────────────
+app.get('/api/features', (_req, res) => {
+ const file = path.join(ROOT, 'data', 'features.json');
+ const raw = fs.readFileSync(file, 'utf-8');
+ res.json(JSON.parse(raw));
+});
+
+// ── pitch state per role ───────────────────────────────────────────────────
+function pitchPath(role) {
+ const safe = role.replace(/[^a-z0-9_-]/gi, '').toLowerCase() || 'default';
+ return path.join(PITCH_DIR, `${safe}.json`);
+}
+
+app.get('/api/pitch/:role', (req, res) => {
+ const file = pitchPath(req.params.role);
+ if (fs.existsSync(file)) {
+ res.json(JSON.parse(fs.readFileSync(file, 'utf-8')));
+ } else {
+ res.json({ role: req.params.role, included: [], skipped: [], notes: {}, updated_at: null });
+ }
+});
+
+app.post('/api/pitch/:role', (req, res) => {
+ const file = pitchPath(req.params.role);
+ const body = req.body || {};
+ const state = {
+ role: req.params.role,
+ included: Array.isArray(body.included) ? body.included : [],
+ skipped: Array.isArray(body.skipped) ? body.skipped : [],
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
+ updated_at: new Date().toISOString(),
+ };
+ fs.writeFileSync(file, JSON.stringify(state, null, 2));
+ res.json({ saved: true, state });
+});
+
+// ── proxy: run an agent example against the live Norma at :7400 ────────────
+app.post('/api/run-example', async (req, res) => {
+ const { route, payload, method } = req.body || {};
+ if (!route || typeof route !== 'string' || !route.startsWith('/api/')) {
+ return res.status(400).json({ error: 'route must be a /api/* path on Norma' });
+ }
+
+ // Light cookie cache — fetch one admin session, reuse across calls.
+ const cookieFile = path.join(PITCH_DIR, '.norma-cookie');
+ let cookie = null;
+ if (fs.existsSync(cookieFile)) {
+ const stat = fs.statSync(cookieFile);
+ if (Date.now() - stat.mtimeMs < 60 * 60 * 1000) {
+ cookie = fs.readFileSync(cookieFile, 'utf-8').trim();
+ }
+ }
+ if (!cookie) {
+ try {
+ const lr = await fetch(`${NORMA_BASE}/api/auth/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: 'admin', password: process.env.NORMA_ADMIN_PASSWORD || 'DWSecure2024!' }),
+ });
+ if (lr.ok) {
+ const setCookie = lr.headers.get('set-cookie');
+ if (setCookie) {
+ cookie = setCookie.split(';')[0];
+ fs.writeFileSync(cookieFile, cookie);
+ }
+ }
+ } catch (err) {
+ return res.status(502).json({ error: 'Norma unreachable at ' + NORMA_BASE, detail: err.message });
+ }
+ }
+
+ const m = (method || (payload ? 'POST' : 'GET')).toUpperCase();
+ try {
+ const url = NORMA_BASE + route;
+ const headers = { 'Content-Type': 'application/json' };
+ if (cookie) headers.Cookie = cookie;
+ const init = { method: m, headers };
+ if (m !== 'GET' && payload) init.body = JSON.stringify(payload);
+ const t0 = Date.now();
+ const r = await fetch(url, init);
+ const ms = Date.now() - t0;
+ const text = await r.text();
+ let body;
+ try { body = JSON.parse(text); } catch { body = { _raw: text.slice(0, 4000) }; }
+ res.json({ ok: r.ok, status: r.status, ms, body });
+ } catch (err) {
+ res.status(502).json({ error: 'proxy error', detail: err.message });
+ }
+});
+
+// ── video generation ───────────────────────────────────────────────────────
+const VIDEO_JOBS = new Map();
+
+app.post('/api/build-video', (req, res) => {
+ const { role = 'steve' } = req.body || {};
+ const file = pitchPath(role);
+ if (!fs.existsSync(file)) {
+ return res.status(400).json({ error: `no saved pitch for role=${role}` });
+ }
+ const state = JSON.parse(fs.readFileSync(file, 'utf-8'));
+ if (!state.included || state.included.length === 0) {
+ return res.status(400).json({ error: 'pitch is empty — add at least one feature' });
+ }
+
+ const jobId = Date.now().toString(36);
+ const outFile = path.join(VIDEO_DIR, `pitch-${role}-${jobId}.mp4`);
+ const logFile = path.join(VIDEO_DIR, `pitch-${role}-${jobId}.log`);
+
+ VIDEO_JOBS.set(jobId, { status: 'starting', role, outFile, logFile, started_at: Date.now() });
+
+ const log = fs.openSync(logFile, 'w');
+ const env = { ...process.env, NORMA_BASE, PITCH_ROLE: role, OUT_FILE: outFile, JOB_ID: jobId };
+ const child = spawn('node', [path.join(ROOT, 'scripts', 'record-demo.js')], {
+ cwd: ROOT, env, stdio: ['ignore', log, log], detached: true,
+ });
+ child.unref();
+
+ VIDEO_JOBS.get(jobId).pid = child.pid;
+ VIDEO_JOBS.get(jobId).status = 'recording';
+
+ res.json({ job_id: jobId, status: 'recording', output: path.basename(outFile), log: path.basename(logFile) });
+});
+
+app.get('/api/video-status', (req, res) => {
+ const jobId = req.query.id;
+ if (!jobId) {
+ res.json({ jobs: Array.from(VIDEO_JOBS.entries()).map(([id, j]) => ({ id, status: j.status, output: path.basename(j.outFile), started_at: j.started_at })) });
+ return;
+ }
+ const job = VIDEO_JOBS.get(jobId);
+ if (!job) return res.status(404).json({ error: 'job not found' });
+ const exists = fs.existsSync(job.outFile);
+ const size = exists ? fs.statSync(job.outFile).size : 0;
+ const stillRunning = (() => {
+ try { process.kill(job.pid, 0); return true; } catch { return false; }
+ })();
+ let status = job.status;
+ if (exists && size > 10000 && !stillRunning) status = 'done';
+ else if (!stillRunning && job.status === 'recording') status = 'failed';
+ job.status = status;
+ res.json({
+ id: jobId, status, output: path.basename(job.outFile),
+ size_bytes: size, started_at: job.started_at,
+ log_tail: fs.existsSync(job.logFile) ? fs.readFileSync(job.logFile, 'utf-8').split('\n').slice(-20).join('\n') : '',
+ download_url: status === 'done' ? `/videos/${path.basename(job.outFile)}` : null,
+ });
+});
+
+app.get('/videos/:filename', (req, res) => {
+ const file = path.join(VIDEO_DIR, req.params.filename);
+ if (!fs.existsSync(file)) return res.status(404).send('not found');
+ res.sendFile(file);
+});
+
+app.get('/health', (_req, res) => res.json({ ok: true, name: 'norma-sdcc-pitch', port: PORT, norma_base: NORMA_BASE }));
+
+app.listen(PORT, () => {
+ console.log(`norma-sdcc-pitch listening on http://localhost:${PORT}`);
+ console.log(` proxying agent demos to ${NORMA_BASE}`);
+});
(oldest)
·
back to Norma Sdcc Pitch
·
add: top-5 priorities, reluctant-adopter, companies-are-data 2907114 →