diff --git a/.claude/commands/think-in-html.md b/.claude/commands/think-in-html.md index 9313fee..8528b4a 100644 --- a/.claude/commands/think-in-html.md +++ b/.claude/commands/think-in-html.md @@ -1,43 +1,71 @@ # /think-in-html -Generate an interactive HTML explainer from code, thinking transcripts, or text. The output is a single self-contained `.html` file that anyone can open in a browser — no server, no dependencies. +Generate an interactive HTML explainer from code, thinking transcripts, or text. The output is a single self-contained `.html` file that anyone can open in a browser — no server, no dependencies. **Every explainer is authored fresh — a different design and a different interactive model each time.** ## Arguments -$ARGUMENTS — the target to analyze. Can be a file path, folder path, or a description of what to analyze. Optionally prefix with `--mode code|thinking|text` (defaults to `code`). +$ARGUMENTS — the target to analyze (a file path, folder, or a description of what to explain). +Optional flags: +- `--mode code|thinking|text` — the kind of input (default `code`). +- `--blocks` — use the legacy template engine (schema-validated teaching blocks + preset skins) instead of freeform. Use only when the user explicitly wants the templated output. +- `-o ` — output file (default `output.html`). -## Steps +## Default flow — Freeform (author the whole page on the fly) -1. **Determine mode.** Parse `$ARGUMENTS` for `--mode`. Default to `code` if not specified. +This is the default. You design and build a complete, bespoke, interactive HTML file for +THIS subject — its own layout, its own visual system, its own interactive logic. **No two +outputs should look alike, and the interactive part must be driven by the subject's real +logic**, not decoration. -2. **Read the instructions.** Read these files from the project root (or from wherever `core/` lives): - - `core/instructions/ANALYZE.md` — how to think like a teacher and compose a lesson - - `core/instructions/mode-{mode}.md` — mode-specific guidance +1. **Read the instructions.** Read `core/instructions/FREEFORM.md` (the quality rubric, + the "make the core mechanic tangible" mandate, the variety requirement, and the hard + self-contained safety constraints). Also read `core/instructions/mode-{mode}.md` for + input-type guidance. -3. **Read the schema.** Read `core/schema/analysis.schema.json` to understand the block contract (`schemaVersion: 2`). +2. **Read the target thoroughly.** Understand the real logic, constants, names, and edge + cases. If it's a folder, pick the single most teachable flow — don't try to cover + everything. -4. **Think like a teacher, then compose.** Don't pour the code into a fixed template. Decide - the best way to teach THIS subject — pick a real-world analogy if one helps — and compose an - ordered list of teaching blocks (`hook`, `analogy`, `flow`, `steps`, `concept`, `code`, - `compare`, `aha`, `quiz`, `recap`, `glossary`) that fits. Every lesson should feel bespoke. +3. **Pick the core mechanic to make tangible.** Decide the one dynamic the reader should + be able to *play with*, and design a small working model of it that reimplements the + subject's real rule (same formula/thresholds/states). -5. **Emit analysis JSON.** Write valid JSON to `analysis.json` in the project root. It MUST - conform to the schema (a non-empty `blocks` array; every block has a known `type`). You may - set a default `meta.skin`, but the viewer can switch skins live regardless. +4. **Choose a bespoke design direction** from the domain — palette, layout, type, and a + recurring motif — deliberately different from anything you've produced before. -6. **Build the HTML.** Run: +5. **Write the single `.html`** to the output path with everything inline (CSS + JS), the + required CSP ``, system fonts, and any imagery as inline SVG / `data:` URIs. + Escape anything echoed verbatim from the source. No network at runtime. + +6. **Validate self-containment:** ``` - node core/build/inline.mjs analysis.json -o output.html + node core/build/check-html.mjs output.html ``` + Fix every error it reports and re-check until it passes. + +7. **Report.** Tell the user where the file is and, in one line, what the interactive + centerpiece lets them do. + +## Legacy flow — Blocks (`--blocks`) + +Only when the user explicitly asks for the templated engine: -7. **Report.** Tell the user where the output file is, and that they can switch the visual skin - (Aurora / Storybook / Blueprint / Terminal, plus 🎲 Surprise me) live in the top-right. +1. Read `core/instructions/ANALYZE.md` and `core/schema/analysis.schema.json`. +2. Compose an ordered `blocks` array (`hook`, `analogy`, `flow`, `steps`, `concept`, + `code`, `compare`, `aha`, `quiz`, `recap`, `glossary`) and an optional bespoke `design` + theme (ANALYZE.md → *Step 3*). +3. Write `analysis.json`, then `node core/build/inline.mjs analysis.json -o output.html`. +4. Report the output path; the reader can switch skins live in the 🎨 picker. ## Important -- The JSON must pass schema validation. If `inline.mjs` reports errors, fix the JSON and rebuild. -- Use `textContent`-safe strings — never include raw HTML in `plain` or `technical` fields. -- The `plain` field is teacher voice: explain like the reader has never seen code before. -- `technical` is optional — only include it when there's meaningful extra detail. -- Keep the walkthrough linear and narrated — each step should feel like "and then..." -- Quiz questions test understanding, not trivia. Always explain the answer. +- **Freeform must pass `check-html.mjs`** — if it flags an external resource, inline it (or + use a `data:` URI) and re-check. The file must work fully offline. +- **Different every time.** If your draft resembles a hero + card grid you've used before, + choose a different structure. Let the subject pick the shape. +- **Real logic, not a cartoon.** The interactive model must reflect the source's actual + behavior — read the code and use its real constants and names. +- **Teacher voice.** Explain like the reader is smart but new to this; gloss jargon in + plain English. +- A worked reference at the intended quality bar: + `examples/novus-pricing-desk.freeform.html`. Study it, then build something different. diff --git a/.gitignore b/.gitignore index 6712eed..3c5cc84 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ output/ /*.analysis.json .vii/ + +# Local-only planning docs (not pushed) +/LANDING-PLAN.md diff --git a/CLAUDE.md b/CLAUDE.md index aec0248..368ff17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,34 +2,62 @@ A tool-agnostic engine that turns code, AI thinking transcripts, or text into self-contained interactive HTML explainers. -## Architecture +There are two generation modes. **Freeform is the default.** + +### Freeform mode (default) — the AI authors the whole page on the fly + +``` +target input → AI reads core/instructions/FREEFORM.md → writes a bespoke output.html → check-html.mjs validates self-containment +``` + +The AI designs and builds a complete, unique, interactive HTML explainer per subject — +its own layout, visual system, and an interactive model of the subject's core mechanic. +No two outputs look alike. The only hard invariant is that the file is a single +self-contained artifact that works offline. + +- `core/instructions/FREEFORM.md` — the rubric: "make the core mechanic tangible", the + variety mandate, and the hard self-contained safety constraints. +- `core/build/check-html.mjs` — zero-dep validator that rejects any external resource + load (external ` + + diff --git a/examples/novus-rate-limit.freeform.html b/examples/novus-rate-limit.freeform.html new file mode 100644 index 0000000..1230b3b --- /dev/null +++ b/examples/novus-rate-limit.freeform.html @@ -0,0 +1,404 @@ + + + + + + +The Window — How Novus Rate-Limits Its AI Endpoints + + + +
+
Novus · rate limiter
+ src/lib/rate-limit.ts + ● ALLOW +
+ +
+
Interactive systems walkthrough
+

Thirty requests a minute. Then the window says no.

+

Every paid AI call on Novus first passes one gate: a per-user counter that allows 30 requests every 60 seconds, resets on the clock, and — surprisingly — lets you through when it breaks. Below is a live model of that exact gate. Drive it.

+
+ limit = 30 + window = 60s + store = atomic Postgres counter + on error = fail open +
+
+ +
+

The gate, live

+

Fire requests and watch the current window fill. At 30 it flips to 429. When the 60-second window rolls over, the count resets to zero and you're clear again. The clock runs fast so you can watch it — speed is yours to set.

+ +
+
+ + check_ai_rate_limit(user, 30, 60) + t = 0.0s · window #0 +
+ +
+
+
+ + + + + +
0of 30 used
+
+
+
60.0s left in window
+
+
+
+ +
+
+

Requests per 60s window

current: #0
+
limit 30
+
+
+

Request log

+
No requests yet — hit “Send request”.
+
+
+
+ +
+ + +
rate 6/s
+ + +
Simulate infra down
+
+
+ allowed 0 + blocked 0 + windows elapsed 0 +
+
+
+ +
+

Two design decisions worth understanding

+
+
+
🪟
+

Fixed-window counting

+

Time is chopped into fixed 60-second windows. Each request bumps a counter for the current window; cross 30 and you're blocked until the clock ticks into the next window, where the counter starts fresh at zero. Simple, cheap, and atomic in Postgres.

+ watch the counter reset on rollover +
+
+
🚪
+

Fail open, on purpose

+

If the counter’s database call errors — say the migration hasn’t run yet — the limiter returns true and lets the request through. It’s best-effort hardening that must never take the app down. Errors aren’t attacker-controllable, so fail-open isn’t a bypass. Flip “infra down” to see it.

+ availability > strictness +
+
+
+ +
+

The whole limiter is this small

+
+
src/lib/rate-limit.ts
+
export async function checkRateLimit(
+  userId: string, limit = 30, windowSeconds = 60,
+): Promise<boolean> {
+  try {
+    const { data, error } = await supabaseAdmin.rpc('check_ai_rate_limit', {
+      p_user: userId, p_limit: limit, p_window_seconds: windowSeconds,
+    })
+    if (error) return true   // ← fail OPEN: infra error must not take the app down
+    return data === true
+  } catch {
+    return true          // ← same story: allow rather than crash
+  }
+}
+
+ +
+
+
The insight
+
A fixed window is the simplest limiter that still holds across serverless instances — one atomic counter, no memory. Its known cost is the window-edge burst: 30 near the end of one window plus 30 at the start of the next means ~60 land in seconds. Novus accepts that, and chooses to fail open, because the limiter is a guardrail on cost — not a security boundary.
+
+
+
+ +
+

Recap

+ +
+ + + + + + diff --git a/package.json b/package.json index 9d4445a..e11947c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "think-in-html", "version": "1.0.0", - "description": "Turn code, an AI thinking transcript, or any text into a single self-contained, interactive HTML lesson — composed by an AI teacher from a kit of tested teaching blocks.", + "description": "Turn code, an AI thinking transcript, or any text into a single self-contained, interactive HTML lesson — a bespoke design and interactive model authored fresh for each subject.", "type": "module", "bin": { "think-in-html": "bin/cli.mjs" @@ -10,6 +10,7 @@ "bin/", "core/", "adapters/", + "examples/", "README.md", "LICENSE" ], diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index fab55b5..cca7c67 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -14,6 +14,7 @@ import { readFileSync, readdirSync } from 'fs'; import { resolve, dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { buildHtml, validateAnalysis, KNOWN_BLOCKS } from '../core/build/inline.mjs'; +import { checkSelfContained } from '../core/build/check-html.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -78,6 +79,73 @@ for (const file of analysisFiles) { }); } +// ── 2b: at least one example exercises a bespoke `design` theme, and it stays self-contained ── +check('a bespoke design theme builds self-contained', () => { + const withDesign = analysisFiles + .map(f => JSON.parse(readFileSync(join(EXAMPLES, f), 'utf-8'))) + .find(a => a.design && typeof a.design === 'object'); + assert(withDesign, 'no example carries a `design` theme to exercise the feature'); + assert(withDesign.design.vars && typeof withDesign.design.vars === 'object', 'design.vars should be an object'); + + const html = buildHtml(withDesign); + for (const token of ['{{STYLES}}', '{{APP_JS}}', '{{ANALYSIS_JSON}}']) { + assert(!html.includes(token), `unsubstituted placeholder ${token} left in output`); + } + const external = (html.match(/(?:src|href)\s*=\s*["']https?:\/\/[^"']+/gi) || []) + .filter(ref => !/github\.com/i.test(ref)); + assert(external.length === 0, `design theme introduced external reference(s): ${external.join(', ')}`); +}); + +// ── 2c: malformed `design` shapes are rejected ── +check('rejects non-object design', () => { + let threw = false; + try { validateAnalysis({ schemaVersion: 2, meta: {}, blocks: [{ type: 'hook' }], design: 'nope' }); } + catch { threw = true; } + assert(threw, 'accepted a string design'); +}); +check('rejects non-string design.css', () => { + let threw = false; + try { validateAnalysis({ schemaVersion: 2, meta: {}, blocks: [{ type: 'hook' }], design: { css: 42 } }); } + catch { threw = true; } + assert(threw, 'accepted a numeric design.css'); +}); + +// ── 2d: freeform mode — every committed examples/*.freeform.html is self-contained ── +const freeformFiles = readdirSync(EXAMPLES).filter(f => f.endsWith('.freeform.html')); +if (freeformFiles.length > 0) { + console.log(`Freeform examples (${freeformFiles.length}):`); + for (const file of freeformFiles) { + check(`${file} — self-contained`, () => { + const html = readFileSync(join(EXAMPLES, file), 'utf-8'); + const { ok, errors } = checkSelfContained(html); + assert(ok, `not self-contained: ${errors.join('; ')}`); + }); + } +} + +// ── 2e: the self-contained checker actually rejects external references ── +console.log('Freeform validator guards:'); +check('accepts a minimal self-contained doc', () => { + const html = ''; + assert(checkSelfContained(html).ok, 'rejected a valid self-contained doc'); +}); +check('rejects external '; + assert(!checkSelfContained(html).ok, 'accepted an external script'); +}); +check('rejects external stylesheet ', () => { + const html = ''; + assert(!checkSelfContained(html).ok, 'accepted an external stylesheet'); +}); +check('rejects remote CSS url()', () => { + const html = ''; + assert(!checkSelfContained(html).ok, 'accepted a remote CSS url()'); +}); +check('allows an hyperlink (navigation, not a resource load)', () => { + const html = 'link'; + assert(checkSelfContained(html).ok, 'wrongly rejected an hyperlink'); +}); + // ── 3: negative test — the validator must reject bad input ── console.log('Validator guards:'); check('rejects wrong schemaVersion', () => {