Tour

An interactive tour of Typebulb: apps that live in a single markdown file. What bulbs are, how they call AI, and how coding agents build and run them.

---
format: typebulb/v1
name: Tour
---

**code.tsx**

```tsx
import React, { useState, useEffect, useRef } from "react"
import { createRoot } from "react-dom/client"

const CHAPTERS = ["Overview", "Anatomy", "AI", "Data", "CLI"]

// The CLI is one top-level chapter with parts of its own — everything behind it
// needs an install, so it stays folded away from the site's own story.
const CLI_PARTS = ["Overview", "Bulb Explorer", "Agent Mirror", "Server", "Automation", "Lifecycle"]
const CLI_CH = CHAPTERS.length - 1

// One block of a bulb's source, rendered the way the file writes it.
function Block({ name, lines, cls = "on" }: { name: string; lines: string[]; cls?: string }) {
  return (
    <div className={"block " + cls}>
      <div className="bname"><span className="ast">**</span>{name}<span className="ast">**</span></div>
      <div className="code">{lines.map((l, j) => <div key={j}>{l}</div>)}</div>
    </div>
  )
}

// ───────────────────────────────── Overview ────────────────────────────────

// The bulb card is a sheet in 3D: sample a grid across it, wave it on z, project
// through a pinhole camera, and rebuild the paths each frame. No mesh library —
// the card is simple enough that its outline, rules and bars are just bands.
const SX = 250, SY = 165, SW = 140, SH = 90
const SCX = SX + SW / 2, SCY = SY + SH / 2
const CAM = 460
const COLS = Array.from({ length: 26 }, (_, i) => i / 25)

function pt(u: number, v: number, t: number) {
  const phase = u * 5.4 - t * 1.6
  const z = 18 * Math.sin(phase) * (0.3 + 0.7 * u) + 6 * Math.sin(v * 3.2 - t * 1.15)
  const s = CAM / (CAM - z)
  return {
    x: SCX + (SX + u * SW - SCX) * s,
    y: SCY + (SY + v * SH - SCY) * s + 4 * Math.sin(phase),
  }
}

const poly = (ps: { x: number; y: number }[]) =>
  "M " + ps.map(p => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" L ")

const spanU = (u0: number, u1: number) => COLS.map(c => u0 + c * (u1 - u0))

// A filled quad on the surface — the card body, and each code bar.
const band = (v0: number, v1: number, u0: number, u1: number, t: number) =>
  poly([...spanU(u0, u1).map(u => pt(u, v0, t)),
        ...spanU(u0, u1).map(u => pt(u, v1, t)).reverse()]) + " Z"

// A single line riding the surface — the rule, and the path the filename sits on.
const row = (v: number, u0: number, u1: number, t: number) =>
  poly(spanU(u0, u1).map(u => pt(u, v, t)))

// Both toggle handlers accept ctrl or meta, so naming the one key the reader
// actually has is accurate, not a simplification.
const MOD = /Mac|iPhone|iPad/.test(navigator.userAgent) ? "⌘" : "Ctrl"

function Overview() {
  const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
  const sheet = useRef<SVGGElement>(null)

  // Squares borrow the Lifecycle chapter's per-tier palette, so a colour a reader
  // meets here still means the same place when they get to the tier diagram.

  useEffect(() => {
    const g = sheet.current!
    const p = (n: string) => g.querySelector(`[data-p="${n}"]`) as SVGPathElement
    const outline = p("outline"), fnp = p("fnp"), hair = p("hair")
    const bars = [p("b0"), p("b1"), p("b2")]

    const draw = (t: number) => {
      outline.setAttribute("d", band(0, 1, 0, 1, t))
      fnp.setAttribute("d", row(0.267, 0.08, 0.92, t))
      hair.setAttribute("d", row(0.367, 0.1, 0.9, t))
      bars[0].setAttribute("d", band(0.5, 0.556, 0.129, 0.729, t))
      bars[1].setAttribute("d", band(0.644, 0.7, 0.129, 0.543, t))
      bars[2].setAttribute("d", band(0.789, 0.845, 0.129, 0.814, t))
    }

    draw(0)
    if (calm) return
    let raf = 0
    const loop = (ms: number) => { draw(ms / 1000); raf = requestAnimationFrame(loop) }
    raf = requestAnimationFrame(loop)
    return () => cancelAnimationFrame(raf)
  }, [calm])

  return (
    <div className="overview">
      <h2>What's a bulb?</h2>
      <p className="tag">An app that lives in a single markdown file. Perfect for tools,
      visualizations &amp; experiments. This tour itself is a bulb.</p>

      {/* Deliberately no trust vocabulary — that's chapter 3's question. This one
          only answers where a bulb lives, and the hub is the file itself. */}
      <svg className="omap" viewBox="0 0 640 280" role="img"
        aria-label="One bulb file and the three places it runs: your agent's reply (via the typebulb CLI's agent mirror), localhost (the typebulb CLI), and typebulb.com">
        <defs>
          <marker id="oarr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
            <path d="M0,0 L10,5 L0,10 z" fill="currentColor" opacity="0.5" />
          </marker>
        </defs>

        <g ref={sheet}>
          {/* Paper stays paper in both themes — same call as the terminal and the
              bulb explorer, which keep their own chrome rather than following the host. */}
          <path data-p="outline" fill="#f3e7cf" fillOpacity="0.97"
            stroke="#d9c7a0" strokeWidth="1.4" strokeLinejoin="round" />
          <path data-p="fnp" id="fnp" fill="none" />
          <text textAnchor="middle" fontSize="13"
            fontFamily="ui-monospace, Consolas, monospace" fill="#3f3a30">
            <textPath href="#fnp" startOffset="50%">birds.bulb.md</textPath>
          </text>
          <path data-p="hair" fill="none" stroke="#3f3a30" strokeOpacity="0.22" />
          <path data-p="b0" fill="#3f3a30" fillOpacity="0.32" />
          <path data-p="b1" fill="#3f3a30" fillOpacity="0.32" />
          <path data-p="b2" fill="#3f3a30" fillOpacity="0.32" />
        </g>

        <g className="ospoke d1">
          <path d="M 246 210 L 152 210" fill="none" stroke="currentColor" strokeOpacity="0.45"
            strokeWidth="1.5" markerEnd="url(#oarr)" />
          <rect x="16" y="150" width="132" height="120" rx="10"
            style={{ fill: ACCENT.inline, stroke: ACCENT.inline }} fillOpacity="0.09" strokeOpacity="0.55" strokeWidth="1.4" />
          <text x="82" y="184" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.55">in your</text>
          <text x="82" y="205" textAnchor="middle" fontSize="14" fill="currentColor">agent's reply</text>
          <text x="82" y="238" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.45">typebulb CLI's</text>
          <text x="82" y="254" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.45">agent mirror</text>
        </g>

        <g className="ospoke d2">
          <path d="M 394 210 L 488 210" fill="none" stroke="currentColor" strokeOpacity="0.45"
            strokeWidth="1.5" markerEnd="url(#oarr)" />
          <rect x="492" y="150" width="132" height="120" rx="10"
            style={{ fill: ACCENT.restricted, stroke: ACCENT.restricted }} fillOpacity="0.09" strokeOpacity="0.55" strokeWidth="1.4" />
          <text x="558" y="184" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.55">on</text>
          <text x="558" y="205" textAnchor="middle" fontSize="14" fill="currentColor">localhost</text>
          <text x="558" y="254" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.45">typebulb CLI</text>
        </g>

        <g className="ospoke d3">
          <path d="M 320 161 L 320 132" fill="none" stroke="currentColor" strokeOpacity="0.45"
            strokeWidth="1.5" markerEnd="url(#oarr)" />
          <rect x="254" y="8" width="132" height="120" rx="10"
            style={{ fill: ACCENT.published, stroke: ACCENT.published }} fillOpacity="0.09" strokeOpacity="0.55" strokeWidth="1.4" />
          <text x="320" y="60" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.55">on</text>
          <text x="320" y="81" textAnchor="middle" fontSize="14" fill="currentColor">typebulb.com</text>
        </g>
      </svg>

      {/* Full screen is IDE chrome (commandsTypebulb.ts), so that hint stays gated. The theme
          toggle is bound in every tier (Specs/Theme.md), so it needs no gate. */}
      <div className="hints">
        {tb.mode === "ide" && <div>Hit <kbd>Alt</kbd> <kbd>Enter</kbd> to view this bulb full screen.</div>}
        <div>Hit <kbd>{MOD}</kbd> <kbd>Shift</kbd> <kbd>L</kbd> to toggle light and dark — the quickest way to check a
        bulb you're building looks great in both.</div>
      </div>
    </div>
  )
}

// ───────────────────────────────── Anatomy ─────────────────────────────────
// The source unfurls block by block; the pane beside it stays empty until every
// block has finished landing, then the running bulb arrives whole — it compiles
// as a unit, so a half-built one is something you'd never actually see.

const RAD = 137.5 * Math.PI / 180
const DOTS = Array.from({ length: 260 }, (_, i) => {
  const r = 7 * Math.sqrt(i)
  return { x: +(Math.cos(i * RAD) * r).toFixed(1), y: +(Math.sin(i * RAD) * r).toFixed(1) }
})

const BLOCKS = [
  {
    name: "code.tsx",
    lines: [
      'const a = 137.5 * Math.PI / 180',
      'const dot = (i: number, r = 7 * Math.sqrt(i)) =>',
      '  `<circle cx="${Math.cos(i*a)*r}" cy="${Math.sin(i*a)*r}" r="3"/>`',
      'const dots = Array.from({ length: 260 }, (_, i) => dot(i))',
      'document.getElementById("root")!.innerHTML =',
      '  `<svg viewBox="-120 -120 240 240" fill="currentColor">` +',
      '  dots.join("") + `</svg>`',
    ],
  },
  {
    name: "index.html",
    lines: ['<div id="root"></div>'],
  },
  {
    name: "styles.css",
    lines: [
      'svg { width: 100%; height: 100dvh }',
      'circle { fill: #00cc00 }',
    ],
  },
]

function Anatomy() {
  const [p, setP] = useState(-1)

  useEffect(() => {
    const t = [0, 1, 2, 3].map(i => setTimeout(() => setP(i), 500 + i * 667))
    return () => t.forEach(clearTimeout)
  }, [])

  return (
    <div className="anatomy">
      <h2>Anatomy of a bulb</h2>
      <p className="tag">A bulb consists of a set of blocks. The most common ones are
      {" "}<code>code.tsx</code>, <code>index.html</code>, and <code>styles.css</code>.</p>
      <div className="panes">
        <div className={"pane src" + (p < 3 ? " lit" : "")}>
          <div className="filelabel">sunflower.bulb.md</div>
          <div className="source">
            {BLOCKS.map((b, i) => (
              <Block key={i} name={b.name} lines={b.lines}
                cls={(i <= p ? "on" : "") + (i < p && p < 3 ? " past" : "")} />
            ))}
          </div>
        </div>
        <div className={"pane" + (p >= 3 ? " lit" : "")}>
          {p >= 3 ? (
            <>
              <div className="filelabel">running</div>
              <div className="stage">
                <svg viewBox="-120 -120 240 240">
                  {DOTS.map((d, i) => <circle key={i} cx={d.x} cy={d.y} r={3} />)}
                </svg>
              </div>
            </>
          ) : (
            /* Also what holds the pane open: stacked on mobile it has no sibling to
               stretch against, so an empty one collapses to its padding. */
            <div className="stage waiting">writing bulb…</div>
          )}
        </div>
      </div>
      <div className="afterword">
        <p>A bulb can access a uniform set of APIs that make it easy to run anywhere, including
        APIs that let you call AI. The bulb format and the typebulb CLI that runs bulbs locally
        are open source.</p>
      </div>
    </div>
  )
}

// ─────────────────────────────── CLI · Server ──────────────────────────────

const FILES = [
  { name: "bundle.ts", kb: 128 },
  { name: "resolver.ts", kb: 84 },
  { name: "compile.ts", kb: 46 },
  { name: "watch.ts", kb: 31 },
  { name: "index.ts", kb: 12 },
]

const SERVER_BLOCKS = [
  {
    name: "server.ts",
    lines: [
      'import { readdir, stat } from "node:fs/promises"',
      '',
      'export async function biggest(dir: string) {',
      '  const names = await readdir(dir)',
      '  const files = await Promise.all(names.map(async n => ({',
      '    name: n, kb: Math.round((await stat(`${dir}/${n}`)).size / 1024),',
      '  })))',
      '  return files.sort((a, b) => b.kb - a.kb).slice(0, 5)',
      '}',
    ],
  },
  {
    name: "code.tsx",
    lines: [
      'const files = await tb.server.biggest("./src")',
      'setRows(files)',
      '…',
    ],
  },
]

function Server() {
  return (
    <div className="server">
      <h2>server.ts</h2>
      <p className="tag">Local bulbs may have a <code>server.ts</code> block, that runs in Node, not
      the browser. This is useful for local server work such as writing database reports, and will
      pick up the local <code>.env</code> file (the same one used for your AI keys).</p>

      <div className="panes">
        <div className="pane src">
          <div className="filelabel">big-files.bulb.md</div>
          <div className="source">
            {SERVER_BLOCKS.map(b => <Block key={b.name} name={b.name} lines={b.lines} />)}
          </div>
        </div>
        <div className="pane">
          <div className="filelabel">running</div>
          <div className="stage">
            <div className="files">
              {FILES.map(f => (
                <div key={f.name} className="frow">
                  <span className="fbar" style={{ width: (f.kb / FILES[0].kb) * 100 + "%" }} />
                  <span className="fname">{f.name}</span>
                  <span className="fkb">{f.kb} kb</span>
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>

      <ul className="legend">
        <li><b>Exports become calls</b> — every <code>export</code> is <code>tb.server.&lt;name&gt;()</code>
        {" "}in the browser. No routes, no <code>fetch</code>, no JSON plumbing you have to write. An
        {" "}<code>export async function*</code> streams, consumed with <code>for await</code>.</li>
      </ul>
    </div>
  )
}

// ─────────────────────────────────── Data ──────────────────────────────────
// The preview is drawn by hand from the same numbers the data.txt block shows,
// so the landing page doesn't pay to fetch recharts. The source is the real one.
// recharts is declared in config.json but never imported: the import lives in a
// string below, and the lint reads that as a real bare import.

const CHART = [
  { item: "Stars in Milky Way", value: 1e11 },
  { item: "Trees on Earth", value: 3e12 },
  { item: "Cells in body", value: 4e13 },
  { item: "Insects on Earth", value: 1e19 },
  { item: "Atoms in body", value: 7e27 },
]

// Bars are drawn on a log scale, because the range they span is the whole point.
const LOG_MIN = 10, LOG_MAX = 28.5
const barW = (v: number) => ((Math.log10(v) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 196
const exp = (v: number) => v.toExponential(0).replace("e+", "e")
const dataRow = (d: typeof CHART[0]) => `  { item: "${d.item}", value: ${exp(d.value)} },`

const CHART_BLOCKS = [
  {
    name: "code.tsx",
    lines: [
      'import { BarChart, Bar, XAxis, YAxis, Tooltip } from "recharts"',
      '…',
      'type Row = { item: string; value: number }',
      'const data = tb.json<Row[]>(0)',
      '…',
      '<BarChart data={data} layout="vertical">',
      '  <XAxis type="number" scale="log" domain={[1e10, 1e28]} />',
      '  <YAxis type="category" dataKey="item" width={120} />',
      '  <Bar dataKey="value" fill="#a600ff" />',
      '</BarChart>',
    ],
  },
  {
    name: "data.txt",
    lines: ["[", dataRow(CHART[0]), "  …", dataRow(CHART[4]).replace(/,$/, ""), "]"],
  },
  {
    name: "config.json",
    lines: ['{ "dependencies": { "recharts": "^3.8.1" } }'],
  },
]

function Charts() {
  return (
    <div className="charts">
      <h2>Data and Config</h2>
      <p className="tag">Two blocks you haven't met yet: <code>data.txt</code> gives the app's data
      a home of its own outside the code, and <code>config.json</code> names the npm packages it
      imports.</p>

      <div className="panes">
        <div className="pane src">
          <div className="filelabel">comparisons.bulb.md</div>
          <div className="source">
            {CHART_BLOCKS.map(b => <Block key={b.name} name={b.name} lines={b.lines} />)}
          </div>
        </div>
        <div className="pane">
          <div className="filelabel">running</div>
          <div className="stage">
            <svg viewBox="0 0 340 200" className="chart" role="img"
              aria-label="Five quantities on a log scale, from stars in the galaxy to atoms in a body">
              {CHART.map((d, i) => (
                <g key={d.item}>
                  <text x={0} y={22 + i * 38} fontSize={12} fill="currentColor" fillOpacity={0.75}>{d.item}</text>
                  <rect x={0} y={28 + i * 38} width={barW(d.value)} height={12} rx={3}
                    fill="#a600ff" fillOpacity={0.75} />
                  <text x={barW(d.value) + 7} y={38 + i * 38} fontSize={11}
                    fill="currentColor" fillOpacity={0.55}>{exp(d.value)}</text>
                </g>
              ))}
            </svg>
          </div>
        </div>
      </div>

      <ul className="legend">
        <li><b>data.txt</b> — what the code reads, via <code>tb.json(0)</code>. The AI only ever sees
        a truncated, schema-aware view of it, so a large file doesn't eat the conversation.</li>
        <li><b>dependencies</b> — any npm package, fetched from a CDN when the bulb runs. No install
        step and no <code>node_modules</code>: the pinned version travels inside the file.</li>
      </ul>
    </div>
  )
}

// ──────────────────────────────────── AI ───────────────────────────────────

const AI_BLOCKS = [
  {
    name: "code.tsx",
    lines: [
      'const SYSTEM = "You are a text cleaner. Keep the wording."',
      '',
      'async function clean(messy: string) {',
      '  const { text } = await tb.ai({',
      '    system: SYSTEM,',
      '    messages: [{ role: "user", content: messy }],',
      '  })',
      '  setCleaned(text)',
      '}',
      '…',
    ],
  },
]

// The pasted-from-a-terminal mess describes itself: gutter marks, a hard wrap
// mid-sentence, and double spaces the reflow closes up. One cheap call, so this
// panel could be made live without needing a key per provider.
const MESSY = [
  "▎ Pasted from a terminal, text",
  "▎  arrives  hard-wrapped, with",
  "▎ gutter marks and stray spaces.",
]

// The one chapter whose source pane is the code actually running beside it, so
// this prompt is the real one — keep the two in step when either changes.
const CLEAN_SYSTEM = "You are a text cleaner. Keep the wording."

function Ai() {
  const [messy, setMessy] = useState(MESSY.join("\n"))
  const [cleaned, setCleaned] = useState("")
  const [busy, setBusy] = useState(false)
  const [access, setAccess] = useState<AiAccess>("none")

  useEffect(() => { tb.aiAccess().then(setAccess) }, [])

  const clean = async () => {
    setBusy(true)
    try {
      const { text } = await tb.ai({
        system: CLEAN_SYSTEM,
        messages: [{ role: "user", content: messy }],
      })
      setCleaned(text.trim())
    } catch (e: any) {
      setCleaned(e?.message || "The AI call failed.")
    } finally {
      setBusy(false)
    }
  }

  return (
    <div className="ai">
      <h2>Build Apps that Think</h2>
      <p className="tag">Bulbs can call AI at runtime.</p>

      <div className="panes">
        <div className="pane src">
          <div className="filelabel">clean-text.bulb.md</div>
          <div className="source">
            {AI_BLOCKS.map(b => <Block key={b.name} name={b.name} lines={b.lines} />)}
          </div>
        </div>
        <div className="pane">
          <div className="filelabel">running</div>
          <div className="stage">
            <div className="aiapp">
              <div className="aimeta">sloppy input</div>
              <textarea className="aifield messy" value={messy} spellCheck={false}
                onChange={e => setMessy(e.target.value)} />
              {/* Below the input it reads as the action on it, and it's what the nudge
                  in the empty output box points up at. Absent when there's no AI. */}
              {access !== "none" &&
                <button className="aibtn" onClick={clean} disabled={busy}>
                  {busy ? "Cleaning…" : "AI Clean"}
                </button>}
              <div className="aimeta">clean output</div>
              {/* Empty until they run it: the box only ever holds a real reply, so the
                  no-AI case says so rather than showing a result nothing produced. */}
              <div className="aifield">
                {cleaned || (access === "none" ? <span className="hint">AI isn't available here.</span>
                  : <span className="hint">Click <span className="arrow">↑</span></span>)}
              </div>
            </div>
          </div>
        </div>
      </div>

      <ul className="legend">
        <li>For published bulbs, the AI is a courtesy model with a quota. For your own
        consumption, you can use any AI model you want with your own keys (the CLI even
        supports Ollama models).</li>
        <li>You can also use <code>tb.ai.stream</code> to access results incrementally, and see
        reasoning tokens separately.</li>
      </ul>
    </div>
  )
}

// ───────────────────────────── CLI · Lifecycle ─────────────────────────────

type TierId = "inline" | "restricted" | "trusted" | "published"

const HI = "#ec4899"

// House colors (appThemes.ts): textAccent / textAlt / textAlt2 / textPrimary. CSS
// vars, so every usage must be a style prop — SVG attributes don't substitute var().
const ACCENT: Record<TierId, string> = {
  inline: "var(--accent)",
  restricted: "var(--alt)",
  trusted: "var(--alt2)",
  published: "var(--primary)",
}

const NODES: Record<TierId, { x: number; y: number; w: number; h: number; title: string; sub: string; caps: string }> = {
  inline:     { x: 16,  y: 210, w: 200, h: 84, title: "Inline",     sub: "iframe in the agent mirror", caps: "🌐 client-only · no storage" },
  restricted: { x: 280, y: 210, w: 200, h: 84, title: "Restricted", sub: ".bulb.md on localhost",      caps: "🌐 + 💾 storage · workers · GPU" },
  trusted:    { x: 544, y: 210, w: 200, h: 84, title: "Trusted",    sub: "the same local file",        caps: "🌐 💾 + 🖥 fs · server.ts · AI" },
  published:  { x: 280, y: 14,  w: 200, h: 70, title: "Published",  sub: "typebulb.com/u/you/slug",    caps: "🌐 client-only again" },
}

const STEPS: { label: string; edges: string[]; sel: TierId; text: React.ReactNode }[] = [
  {
    label: "1 · Emit", edges: ["emit"], sel: "inline",
    text: <>The agent writes a whole bulb — frontmatter and all — straight into its reply, and the mirror renders it as an <b>Inline</b> bulb: a sandboxed, client-only iframe. No storage, no Node, no API keys. It's conversational and throwaway; re-emit under the same <code>name:</code> and the mirror keeps the newest one live, folding each earlier version into an expandable stub where it stood.</>,
  },
  {
    label: "2 · Breakout", edges: ["breakout"], sel: "restricted",
    text: <>The <b>breakout ↗</b> control on any inline bulb saves it as <code>typebulbs/&lt;slug&gt;.bulb.md</code>. Run with <code>npx typebulb</code> it becomes a <b>Restricted</b> top-level localhost page: storage, cookies, workers and WebGPU now work — but Node-backed calls still 403.</>,
  },
  {
    label: "3 · Trust", edges: ["trust"], sel: "trusted",
    text: <><code>typebulb trust &lt;file&gt;</code> remembers the file as <b>Trusted</b> (or <code>--trust</code> for a single run). That unlocks the privileged tier: <code>tb.fs</code>, a <code>server.ts</code> block callable as <code>tb.server.*</code>, and <code>tb.ai</code> / <code>tb.infer</code> billed to your <code>.env</code> keys. <code>untrust</code> drops it back.</>,
  },
  {
    label: "4 · Push", edges: ["push", "pushT"], sel: "published",
    text: <><code>typebulb push</code> uploads the file to <code>typebulb.com/u/&lt;you&gt;/&lt;slug&gt;</code> (unlisted at first) — the local path <i>is</i> the remote identity. The site copy is client-only: any <code>server.ts</code> is stripped, so <b>Published</b> trades power for reach.</>,
  },
  {
    label: "5 · Pull", edges: ["pull"], sel: "restricted",
    text: <>Anyone can <code>typebulb pull &lt;url&gt;</code> to land a copy at <code>typebulbs/u/&lt;user&gt;/&lt;slug&gt;.bulb.md</code> — arriving back at <b>Restricted</b>, ready to be trusted, edited, and pushed again. The lifecycle is a loop, and trust never travels with the file.</>,
  },
]

const TIER_INFO: Record<TierId, React.ReactNode> = {
  inline: <><b>Inline</b> — created when bulb markdown appears in an agent reply; the mirror renders it in a sandboxed iframe. Client code and network only: no storage, no workers or GPU, and <code>tb.fs</code> / <code>tb.ai</code> / <code>server.ts</code> simply don't exist here. Its one exit is <b>breakout ↗</b>.</>,
  restricted: <><b>Restricted</b> — the default for any local <code>.bulb.md</code> run with <code>npx typebulb</code>. A real top-level localhost page: storage, cookies, workers, WebGPU all work, but privileged <code>tb.*</code> calls 403 until the bulb is trusted.</>,
  trusted: <><b>Trusted</b> — the same file after <code>typebulb trust</code> (remembered per file, per user) or <code>--trust</code> (one run). Adds Node access: <code>tb.fs</code>, <code>server.ts</code> exports as <code>tb.server.*</code>, and <code>tb.ai</code> on your keys. <code>--no-trust</code> forces one Restricted run without forgetting the grant.</>,
  published: <><b>Published</b> — the bulb as a stand-alone web app on typebulb.com, created by <code>typebulb push</code>. <code>server.ts</code> is stripped from the site copy; assets travel along. Others reach it by URL, or <code>pull</code> it down to start their own local lifecycle.</>,
}

function Lifecycle() {
  const [step, setStep] = useState(0)
  const [sel, setSel] = useState<TierId>("inline")
  const edges = step >= 0 ? STEPS[step].edges : []
  const hi = (id: string) => edges.includes(id)

  const go = (i: number) => { setStep(i); setSel(STEPS[i].sel) }
  const pick = (id: TierId) => { setSel(id); setStep(-1) }

  const Edge = ({ id, d, dashed }: { id: string; d: string; dashed?: boolean }) => (
    <path d={d} fill="none"
      stroke={hi(id) ? HI : "currentColor"} strokeOpacity={hi(id) ? 1 : 0.4}
      strokeWidth={hi(id) ? 2.6 : 1.5} strokeDasharray={dashed ? "5 4" : undefined}
      markerEnd={`url(#${hi(id) ? "arrHi" : "arr"})`} />
  )
  // Diagram text picks from the same ladder as the CSS: 16 title, 14 name, 12 label.
  const Lbl = ({ id, x, y, anchor = "middle", children }: any) => (
    <text x={x} y={y} textAnchor={anchor} fontSize={12}
      fill={hi(id) ? HI : "currentColor"} fillOpacity={hi(id) ? 1 : 0.7}
      fontWeight={hi(id) ? 650 : 400}>{children}</text>
  )
  const Node = ({ id }: { id: TierId }) => {
    const n = NODES[id], a = ACCENT[id], on = sel === id
    const cy = id === "published" ? [24, 41, 58] : [28, 46, 68]
    return (
      <g className="node" onClick={() => pick(id)}>
        <rect x={n.x} y={n.y} width={n.w} height={n.h} rx={10}
          style={{ fill: a, stroke: a }} fillOpacity={on ? 0.22 : 0.09}
          strokeOpacity={on ? 1 : 0.55} strokeWidth={on ? 2.5 : 1.4} />
        <text x={n.x + n.w / 2} y={n.y + cy[0]} textAnchor="middle" fontSize="16" fontWeight="650" fill="currentColor">{n.title}</text>
        <text x={n.x + n.w / 2} y={n.y + cy[1]} textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.6">{n.sub}</text>
        <text x={n.x + n.w / 2} y={n.y + cy[2]} textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.85">{n.caps}</text>
      </g>
    )
  }

  const TIERS: TierId[] = ["inline", "restricted", "trusted", "published"]

  return (
    <div className="lifecycle">
      <h2>The life of a bulb</h2>
      <p className="tag">One markdown file, four homes. Click nodes, or step through the loop.</p>

      <svg className="diagram" viewBox="0 0 760 302" role="img" aria-label="Bulb trust-tier lifecycle diagram">
        <defs>
          <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
            <path d="M0,0 L10,5 L0,10 z" fill="currentColor" opacity="0.45" />
          </marker>
          <marker id="arrHi" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
            <path d="M0,0 L10,5 L0,10 z" fill={HI} />
          </marker>
        </defs>

        <Lbl id="emit" x={116} y={130}>a bulb block in the agent's reply</Lbl>
        <Edge id="emit" d="M 116 142 L 116 206" dashed />

        <Lbl id="breakout" x={247} y={234}>breakout ↗</Lbl>
        <Edge id="breakout" d="M 218 246 L 276 246" />

        <Lbl id="trust" x={512} y={228}>trust</Lbl>
        <Edge id="trust" d="M 482 238 L 540 238" />
        <Lbl id="untrust" x={512} y={290}>untrust</Lbl>
        <Edge id="untrust" d="M 542 270 L 484 270" />

        <Lbl id="push" x={404} y={150} anchor="start">push</Lbl>
        <Edge id="push" d="M 396 206 L 396 88" />
        <Lbl id="pull" x={356} y={150} anchor="end">pull</Lbl>
        <Edge id="pull" d="M 364 88 L 364 204" />

        <Lbl id="pushT" x={640} y={112}>push (server.ts stripped)</Lbl>
        <Edge id="pushT" d="M 648 206 C 648 120, 570 54, 488 50" />

        {TIERS.map(t => <Node key={t} id={t} />)}
      </svg>

      <div className="stepbar">
        {STEPS.map((s, i) => (
          <button key={i} className={"chip" + (i === step ? " on" : "")} onClick={() => go(i)}>{s.label}</button>
        ))}
      </div>

      <div className="caption">{step >= 0 ? STEPS[step].text : TIER_INFO[sel]}</div>
    </div>
  )
}

// ────────────────────────────── CLI · Overview ─────────────────────────────
// The mirror is drawn inside the CLI because it ships inside it, and its page
// projected below because the page is the only part the user ever touches. The
// arrow down the right is the point of the picture: nothing sits in that gap.

function CliOverview({ goPart }: { goPart: (i: number) => void }) {
  return (
    <div className="cliOverview">
      <h2>The typebulb CLI</h2>
      <p className="tag">The typebulb CLI runs bulbs locally. It comprises two halves: a command
      surface for your agent to build and automate bulbs, and the agent mirror, for you to view
      the results.</p>

      <svg className="diagram" viewBox="0 0 760 454" role="img"
        aria-label="The typebulb CLI holds the agent mirror, which serves a browser page below it. Your agent drives the CLI to build bulbs, you use the page to use them, and the two of you talk to each other directly as always.">
        <defs>
          <marker id="tw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
            <path d="M0,0 L10,5 L0,10 z" fill="currentColor" opacity="0.55" />
          </marker>
        </defs>

        {/* the CLI: one package, with the mirror inside it — each half in its actor's color */}
        <rect x="100" y="14" width="240" height="196" rx="12" style={{ fill: "var(--accent)", stroke: "var(--accent)" }}
          fillOpacity="0.04" strokeOpacity="0.5" strokeWidth="1.5" />
        <text x="220" y="44" textAnchor="middle" fontSize="16" fontWeight="650" fill="currentColor">typebulb CLI</text>
        <text x="220" y="62" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.6">for your agent — to build bulbs</text>
        <text x="220" y="90" textAnchor="middle" fontSize="12" fontFamily="ui-monospace, Consolas, monospace"
          fill="currentColor" fillOpacity="0.75">run · check · call · send</text>
        <text x="220" y="108" textAnchor="middle" fontSize="12" fontFamily="ui-monospace, Consolas, monospace"
          fill="currentColor" fillOpacity="0.75">wait · logs · push · trust</text>

        <rect x="125" y="120" width="190" height="78" rx="10" style={{ fill: "var(--primary)", stroke: "var(--primary)" }}
          fillOpacity="0.1" strokeOpacity="0.6" strokeWidth="1.5" />
        <text x="220" y="143" textAnchor="middle" fontSize="15" fontWeight="650" fill="currentColor">agent mirror</text>
        <text x="220" y="161" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.6">for you — to use bulbs</text>
        <text x="220" y="183" textAnchor="middle" fontSize="12" fontFamily="ui-monospace, Consolas, monospace" fill="currentColor" fillOpacity="0.75">typebulb agent</text>

        {/* the page it serves, projected down out of the mirror */}
        <path d="M 125 198 L 100 238" fill="none" style={{ stroke: "var(--primary)" }} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="5 4" />
        <path d="M 315 198 L 340 238" fill="none" style={{ stroke: "var(--primary)" }} strokeOpacity="0.4" strokeWidth="1.2" strokeDasharray="5 4" />
        <text x="220" y="228" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.55">serves</text>

        <rect x="100" y="238" width="240" height="208" rx="9" fill="currentColor" fillOpacity="0.03"
          style={{ stroke: "var(--primary)" }} strokeOpacity="0.55" strokeWidth="1.5" />
        <path d="M 100 262 L 340 262" style={{ stroke: "var(--primary)" }} strokeOpacity="0.35" strokeWidth="1.2" />
        <circle cx="116" cy="250" r="3" fill="currentColor" fillOpacity="0.35" />
        <circle cx="128" cy="250" r="3" fill="currentColor" fillOpacity="0.35" />
        <circle cx="140" cy="250" r="3" fill="currentColor" fillOpacity="0.35" />
        <text x="158" y="254" fontSize="11" fontFamily="ui-monospace, Consolas, monospace"
          fill="currentColor" fillOpacity="0.5">localhost:20100</text>
        <rect x="120" y="276" width="70" height="8" rx="4" fill="currentColor" fillOpacity="0.25" />
        <rect x="120" y="292" width="115" height="8" rx="4" fill="currentColor" fillOpacity="0.18" />
        {/* the inline bulb slot runs the same Fireworks the Agent Mirror part draws */}
        <foreignObject x="120" y="308" width="200" height="76">
          <Fireworks width={200} height={76} scale={0.5} className="fwslot" />
        </foreignObject>
        <rect x="120" y="308" width="200" height="76" rx="7" fill="none"
          style={{ stroke: "var(--primary)" }} strokeOpacity="0.45" strokeWidth="1.2" />
        <rect x="120" y="396" width="90" height="8" rx="4" fill="currentColor" fillOpacity="0.22" />
        {/* the status bar, and the 💡 pill at its right that opens the bulb explorer */}
        <path d="M 100 410 L 340 410" style={{ stroke: "var(--primary)" }} strokeOpacity="0.25" strokeWidth="1.2" />
        {/* The one instruction in the diagram, so it does what it says. */}
        <g className="pillhit" role="button" tabIndex={0} aria-label="Open the bulb explorer"
          onClick={() => goPart(1)}
          onKeyDown={e => { if (e.key === "Enter" || e.key === " ") goPart(1) }}>
          <rect x="146" y="414" width="190" height="24" rx="6" fill="transparent" />
          <text x="306" y="430" textAnchor="end" fontSize="11" fill="currentColor" fillOpacity="0.55">click to open bulb explorer →</text>
          <text x="322" y="430" textAnchor="middle" fontSize="14">💡</text>
        </g>

        {/* your agent, beside the half it drives */}
        <path d="M 344 102 L 547 102" fill="none" stroke="currentColor" strokeOpacity="0.55"
          strokeWidth="1.5" markerStart="url(#tw)" markerEnd="url(#tw)" />
        <text x="446" y="90" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.7">commands</text>
        <rect x="552" y="83" width="46" height="38" rx="9" fill="none"
          style={{ stroke: "var(--accent)" }} strokeOpacity="0.6" strokeWidth="1.5" />
        <circle cx="566" cy="102" r="3.5" style={{ fill: "var(--accent)" }} fillOpacity="0.8" />
        <circle cx="584" cy="102" r="3.5" style={{ fill: "var(--accent)" }} fillOpacity="0.8" />
        <path d="M 575 83 L 575 73" style={{ stroke: "var(--accent)" }} strokeOpacity="0.6" strokeWidth="1.5" />
        <circle cx="575" cy="70" r="3" style={{ fill: "var(--accent)" }} fillOpacity="0.6" />
        <text x="575" y="146" textAnchor="middle" fontSize="14" fill="currentColor">your agent</text>
        <text x="575" y="164" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.55">claude · codex · pi</text>

        {/* you, beside the half you touch */}
        <path d="M 344 341 L 547 341" fill="none" stroke="currentColor" strokeOpacity="0.55"
          strokeWidth="1.5" markerStart="url(#tw)" markerEnd="url(#tw)" />
        <text x="446" y="329" textAnchor="middle" fontSize="12" fill="currentColor" fillOpacity="0.7">clicks · reads</text>
        <circle cx="575" cy="328.5" r="9.5" fill="none" style={{ stroke: "var(--primary)" }} strokeOpacity="0.6" strokeWidth="1.5" />
        <path d="M 557 356 a 18 18 0 0 1 36 0" fill="none" style={{ stroke: "var(--primary)" }} strokeOpacity="0.6" strokeWidth="1.5" />
        <text x="575" y="384" textAnchor="middle" fontSize="14" fill="currentColor">you</text>

        {/* nothing in the middle: you and your agent still talk as you always did */}
        <path d="M 575 178 L 575 310" fill="none" stroke="currentColor" strokeOpacity="0.55"
          strokeWidth="1.5" markerStart="url(#tw)" markerEnd="url(#tw)" />
        <text x="591" y="237" fontSize="12" fill="currentColor" fillOpacity="0.6">your usual</text>
        <text x="591" y="255" fontSize="12" fill="currentColor" fillOpacity="0.6">conversation</text>
      </svg>
    </div>
  )
}

// ──────────────────────────── CLI · Agent mirror ───────────────────────────
// The terminal text below is this CLI's real output, not a dramatisation.

const PROMPT = "run 'npx typebulb agent' & draw fireworks"

// The mirror shows this same conversation with the tool calls folded away, so
// the assistant's prose has to be identical in both panes — hence one source.
const SAID = {
  intro: "Mirror's up — here's fireworks.",
  outro: "It's live in the mirror ↗ http://localhost:20100",
}

const TERM: { at: number; text: string; cmd?: boolean; you?: boolean; bulb?: boolean }[] = [
  { at: 0, text: "$ claude", cmd: true },
  { at: 0, text: "  Welcome to Claude Code" },
  { at: 1, text: "" },
  { at: 1, text: "> " + PROMPT, you: true },
  { at: 2, text: "" },
  { at: 2, text: "  Bash(npx typebulb agent)" },
  { at: 2, text: "    typebulb v0.50.0" },
  { at: 2, text: "    Agent mirror (claude) is live💡" },
  { at: 2, text: "      ● http://localhost:20100" },
  { at: 3, text: "" },
  { at: 3, text: "  " + SAID.intro },
  // The reply really does carry the whole bulb; elide it rather than pretend
  // a 30-line markdown block fits in half a pane.
  { at: 3, text: "  [inline bulb · Fireworks · 30 lines]", bulb: true },
  { at: 4, text: "" },
  { at: 4, text: "  " + SAID.outro },
]

// The bulb explorer, as it actually renders: running bulbs carry a stop square, a
// trust pill, a logs link and their port; idle ones just their age. The
// pull/push arrows belong to the hovered row alone, so one row carries them.
const LAUNCH: { name: string; folder?: string; trust?: string; port?: string; age?: string; sel?: boolean }[] = [
  { name: "Bach", folder: "samples", age: "1w" },
  { name: "PDF Extractor", folder: "samples", age: "5d" },
  { name: "OCR Highlighter", folder: "samples", age: "5d" },
  { name: "Rocket Balancer", folder: "samples", age: "3d" },
  { name: "Image Classifier", folder: "samples", age: "2d" },
  { name: "Chess Arena", folder: "samples", age: "2d" },
  { name: "Claude Code Usage", trust: "trusted", port: ":20122" },
  { name: "Video Clip Crop", folder: "samples", trust: "restricted", port: ":20123", sel: true },
]

// The middle two stanzas are real output — they are the probes that caught this
// page's own bugs. The call and wait stanzas are generic illustrations.
const AUTO: { text: string; cmd?: boolean }[] = [
  { text: "$ typebulb call report.bulb.md summarize --trust", cmd: true },
  { text: '  {"rows":128,"flagged":7}' },
  { text: "" },
  { text: "$ typebulb send tour.bulb.md selftest --wait", cmd: true },
  { text: '  {"bars":[104,169,78,221,143],"stage":260}' },
  { text: "" },
  { text: "$ typebulb send tour.bulb.md tb:png", cmd: true },
  { text: "  canvas 320×150 → PNG" },
  { text: "" },
  { text: '$ typebulb wait game.bulb.md --match "[turn]"', cmd: true },
  { text: "  [turn] player moved e2e4" },
]

function Fireworks({ width = 320, height = 150, scale = 1, className = "fw" }:
  { width?: number; height?: number; scale?: number; className?: string }) {
  const ref = useRef<HTMLCanvasElement>(null)
  useEffect(() => {
    const cv = ref.current!
    const ctx = cv.getContext("2d")!
    const calm = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches
    let parts: { x: number; y: number; vx: number; vy: number; life: number; hue: number }[] = []
    let raf = 0
    let last = -2000
    // Burst size follows the canvas height, times the host's scale knob — the
    // CLI-overview slot passes 0.5 to fit whole fireworks in its short band.
    const k = (cv.height / 150) * scale
    const burst = () => {
      const x = cv.width * 0.15 + Math.random() * cv.width * 0.7
      const y = cv.height * 0.17 + Math.random() * cv.height * 0.45
      const hue = Math.floor(Math.random() * 360)
      for (let i = 0; i < 46; i++) {
        const a = (i / 46) * Math.PI * 2
        const s = (0.6 + Math.random() * 1.9) * k
        parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: 1, hue })
      }
    }
    const step = (t: number) => {
      if (t - last > 750) { burst(); last = t }
      ctx.fillStyle = "rgba(14,14,14,0.16)"
      ctx.fillRect(0, 0, cv.width, cv.height)
      parts = parts.filter(p => p.life > 0.03)
      for (const p of parts) {
        p.x += p.vx; p.y += p.vy; p.vy += 0.02 * k; p.life *= 0.986
        ctx.fillStyle = `hsla(${p.hue}, 95%, 66%, ${p.life})`
        ctx.fillRect(p.x, p.y, 2.4, 2.4)
      }
    }
    // Reduced motion: the same sim run once, leaving a mid-burst still.
    if (calm) { for (let t = 0; t < 2200; t += 16) step(t); return }
    const loop = (t: number) => { step(t); raf = requestAnimationFrame(loop) }
    raf = requestAnimationFrame(loop)
    return () => cancelAnimationFrame(raf)
  }, [])
  return <canvas ref={ref} width={width} height={height} className={className} role="img" aria-label="fireworks" />
}

function Cli({ goPart }: { goPart: (i: number) => void }) {
  const [b, setB] = useState(-1)

  useEffect(() => {
    const t = [0, 1, 2, 3, 4].map(i => setTimeout(() => setB(i), 600 + i * 1500))
    return () => t.forEach(clearTimeout)
  }, [])

  return (
    <div className="cli">
      <h2>Agent mirror</h2>
      <p className="tag">Your session, reflected in a browser tab, where your agent can answer with
      inline bulbs.</p>

      <div className="panes">
        <div className="term">
          <div className="mhead">typebulb CLI · your terminal</div>
          {b < 0 && <div className="cmd">$</div>}
          {TERM.filter(l => l.at <= b).map((l, i) => (
            <div key={i} className={l.cmd ? "cmd" : l.you ? "you-line" : l.bulb ? "bulb-line" : ""}>{l.text || " "}</div>
          ))}
        </div>

        <div className={"mirror" + (b >= 1 ? " on" : "")}>
          <div className="mhead">agent mirror · claude</div>
          <div className="conv">
            {b >= 2 && <div className="row"><span className="who">you</span>{PROMPT}</div>}
            {b >= 3 && (
              <div className="row">
                <span className="who">claude</span>
                <div className="msg">
                  {SAID.intro}
                  <div className="card">
                    <div className="chead"><span>inline bulb · Fireworks</span><span className="bk">breakout ↗</span></div>
                    {b >= 4 ? <Fireworks /> : <div className="cbody">▸ bulb markdown</div>}
                  </div>
                  {b >= 4 && <div className="outro">{SAID.outro}</div>}
                </div>
              </div>
            )}
          </div>
        </div>
      </div>

      <div className="afterword">
        <p><b>breakout ↗</b> on the card above turns an inline bulb into a local one. Local bulbs
        are more powerful: good for iterating on, they hold state, talk to Node when explicitly
        trusted, and call AI with your own keys from <code>.env</code>. That's the
        {" "}<button className="link" onClick={() => goPart(5)}>lifecycle</button>.</p>
      </div>
    </div>
  )
}

// ─────────────────────────── CLI · Bulb Explorer ───────────────────────────

function BulbExplorer() {
  return (
    <div className="explorerCh">
      <h2>Bulb Explorer</h2>
      <p className="tag">Without Typebulb, it's easy to end up with a graveyard of hundreds of little
      projects for every app and experiment, not to mention the node_modules black holes. With
      Typebulb, each app is a neat single file, launchable in an instant via the bulb explorer.</p>

      <div className="launcher">
        {LAUNCH.map((r, i) => (
          <div key={i} className={"lrow" + (r.sel ? " sel" : "")}>
            <span className="lbtn">{r.port ? "■" : r.sel ? "▶" : ""}</span>
            <span className={"lname" + (r.port ? " run" : "")}>{r.name}</span>
            <span className="lfold">{r.folder}</span>
            {r.sel && r.folder && <span className="lsync">↓ ↑</span>}
            {r.trust && <span className={"lpill " + r.trust}>{r.trust}</span>}
            {r.port && <span className="llogs">logs</span>}
            <span className={"lport" + (r.port ? " on" : "")}>{r.port || r.age}</span>
          </div>
        ))}
        <div className="lfilter">
          <span>Filter 264 bulbs…</span>
          {/* the real filter box's full-text toggle (statusPill.ts) */}
          <span>🔬</span>
        </div>
      </div>

      <p className="tag">Each local bulb gets a sticky port number, unique across all your projects.
      You can also push &amp; pull bulbs to &amp; from typebulb.com with a single click.</p>
    </div>
  )
}

// ───────────────────────────── CLI · Automation ────────────────────────────

function Automation() {
  return (
    <div className="automation">
      <h2>Automating bulbs with your agent</h2>
      <p className="tag">A bulb is a white box to an agent: it can probe what it built — measuring
      it, snapshotting it, clicking through it.</p>

      <div className="term">
        {AUTO.map((l, i) => (
          <div key={i} className={l.cmd ? "cmd" : ""}>{l.text || " "}</div>
        ))}
      </div>

      <p className="tag">After your agent calls <code>npx typebulb agent</code>, it's instructed to
      read typebulb's agent skill. This skill has been honed through thousands of agent
      interactions.</p>
    </div>
  )
}

// ──────────────────────────────── the shell ────────────────────────────────

function initialIndex(key: string, max: number) {
  const n = Number(new URLSearchParams(window.location.search).get(key))
  return n >= 1 && n <= max ? n - 1 : 0
}

function App() {
  const [ch, setCh] = useState(() => initialIndex("chapter", CHAPTERS.length))
  const [part, setPart] = useState(() => initialIndex("part", CLI_PARTS.length))

  // Chapters share one bulb and one URL: ?chapter=2 deep-links, chapter 1 stays
  // clean, and a CLI part adds &part=3.
  useEffect(() => {
    const path = window.location.pathname
    const sub = ch === CLI_CH && part > 0 ? "&part=" + (part + 1) : ""
    history.replaceState(null, "", ch === 0 ? path : `${path}?chapter=${ch + 1}${sub}`)
  }, [ch, part])

  return (
    <div className="tour">
      <header>
        <nav className="tabs">
          {CHAPTERS.map((c, i) => (
            <button key={i} className={i === ch ? "on" : ""}
              onClick={() => { setCh(i); setPart(0) }}>{c}</button>
          ))}
        </nav>
        {ch === CLI_CH && (
          <nav className="subtabs">
            {CLI_PARTS.map((c, i) => (
              <button key={i} className={i === part ? "on" : ""} onClick={() => setPart(i)}>{c}</button>
            ))}
          </nav>
        )}
      </header>

      <main key={ch + ":" + part}>
        {ch === 0 && <Overview />}
        {ch === 1 && <Anatomy />}
        {ch === 2 && <Ai />}
        {ch === 3 && <Charts />}
        {ch === CLI_CH && part === 0 && <CliOverview goPart={setPart} />}
        {ch === CLI_CH && part === 1 && <BulbExplorer />}
        {ch === CLI_CH && part === 2 && <Cli goPart={setPart} />}
        {ch === CLI_CH && part === 3 && <Server />}
        {ch === CLI_CH && part === 4 && <Automation />}
        {ch === CLI_CH && part === 5 && <Lifecycle />}
      </main>

    </div>
  )
}

createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**

```css
.tour {
  /* The whole tour's type ladder. Four sans steps plus one mono step for code;
     a size outside these is a new decision, not a nudge. */
  --title: 24px;
  --lead: 18px;
  --body: 16px;
  --ui: 14px;
  --fine: 12px;
  --code: 13px;
  --mono: ui-monospace, Consolas, monospace;
  --measure: 640px;          /* one reading width for every centred prose block */
  --panel: 560px;            /* a UI depiction sits narrower than the column it's in */
  --flow: 22px;              /* one gap between a visual and the prose about it */

  /* The site's colors (appThemes.ts), per theme below. Accents take the STRONG
     green in dark (plain textPrimary reads dim there); solid fills keep
     textPrimary so white text stays readable on them. */
  --primary: rgb(0, 255, 0);
  --primary-fill: rgb(0, 170, 0);
  --accent: rgb(166, 0, 255);
  --alt: rgb(0, 128, 255);
  --alt2: rgb(220, 140, 0);

  max-width: 860px;
  margin: 0 auto;            /* horizontal centering only */
  padding: 22px 16px 28px;   /* vertical space as padding */
  font: var(--body)/1.6 system-ui, sans-serif;
}
html[data-theme="light"] .tour {
  --primary: rgb(0, 120, 0);
  --primary-fill: rgb(0, 120, 0);
  --accent: rgb(96, 0, 148);
  --alt: rgb(0, 98, 196);
  --alt2: rgb(180, 100, 0);
}

header { margin-bottom: 18px; }
h2 { font-size: var(--title); margin: 0 0 5px; text-align: center; }
h3 { font-size: var(--lead); margin: 26px 0 6px; }
/* The tour's recurring motif is a visual with prose about it, so every prose
   block opens the same gap from whatever sits above it. The two exceptions are
   positional, not per-chapter: a subline hugs its heading, and prose following
   prose stays tight. Nothing below sets its own top margin. */
.tag, .hints, .stepbar, .legend, .caption, .afterword {
  margin-block-start: var(--flow);
}
h2 + .tag { margin-block-start: 0; }
.tag + .legend { margin-block-start: 4px; }

/* Every centred prose block also reads at one width — the chapter subline, the
   beat under an animation, the overview's hints, and the legend, caption and
   afterword that close a chapter. */
.tag, .hints, .legend, .caption, .afterword p {
  max-width: var(--measure);
  margin-inline: auto;
  text-align: center;
  text-wrap: balance;
}
.tag { margin-block-end: var(--flow); opacity: .72; }

.tabs { display: flex; justify-content: center; gap: 6px; flex-wrap: wrap; }
.tabs button {
  font: 600 var(--body) system-ui, sans-serif;
  padding: 7px 15px 8px;
  background: transparent;
  color: inherit;
  border: none;
  border-bottom: 2px solid transparent;
  opacity: .6;
  cursor: pointer;
}
.tabs button.on { opacity: 1; border-bottom-color: var(--primary); }

/* Subordinate to the tabs above: pills, not underlined, and no numbers — so the
   two rows never read as one flat list. */
.subtabs { display: flex; justify-content: center; gap: 6px; flex-wrap: wrap; margin: 12px 0 0; }
.subtabs button {
  padding: 5px 14px;
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  border-radius: 999px;
  background: transparent;
  color: inherit;
  font: var(--ui) system-ui, sans-serif;
  opacity: .65;
  cursor: pointer;
}
.subtabs button:hover { opacity: 1; }
.subtabs button.on { opacity: 1; border-color: var(--primary); color: var(--primary); }

main { min-height: 560px; }

/* ── chapter 0 ── */
.omap { display: block; width: 100%; max-width: 620px; height: auto; margin: 8px auto 0; }
/* One-shot reveal: the animated sheet already supplies the perpetual motion. */
.ospoke { opacity: 0; animation: ofade .7s ease forwards; }
.d1 { animation-delay: .45s; }
.d2 { animation-delay: .95s; }
.d3 { animation-delay: 1.45s; }
@keyframes ofade { to { opacity: 1; } }
.hints {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  opacity: .7;
}
/* Keycaps: the thicker bottom edge is what sells them as keys. */
.hints kbd {
  font: 600 var(--fine)/1 var(--mono);
  padding: 3px 7px 2px;
  border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
  border-bottom-width: 2px;
  border-radius: 7px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  white-space: nowrap;
}

/* ── chapter 1 ── */
.anatomy .panes, .charts .panes, .ai .panes, .server .panes { display: flex; gap: 18px; }
.pane {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 14px 16px;
  border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 4%, transparent);
  transition: border-color .6s;
}
/* The brand-green border tracks where the action is: the source while it's being
   written, then the run once it arrives. */
.pane.lit { border-color: var(--primary); }
.filelabel { font: var(--ui) var(--mono); opacity: .6; }
.pane.lit .filelabel { color: var(--primary); opacity: 1; }

.source { display: flex; flex-direction: column; gap: 14px; }
.block { opacity: 0; transform: translateY(6px); transition: opacity .5s, transform .5s; }
.block.on { opacity: 1; transform: none; }
.block.past { opacity: .45; }
.bname { font: 600 var(--code) var(--mono); color: var(--primary); }
.ast { opacity: .5; font-weight: 400; }
.code { margin-top: 6px; font: var(--code)/1.6 var(--mono); }
/* One element per line, so a line too long for the pane ellipses instead of
   wrapping — reflowed source reads as broken code. */
.code div { white-space: pre; overflow: hidden; text-overflow: ellipsis; }

/* auto on both sides, so the running pane centres in the space its taller
   source-pane sibling leaves — margin-top alone pinned it to the bottom. */
.stage { height: 260px; margin-block: auto; display: grid; place-items: center; }
.stage.waiting { font: var(--ui) var(--mono); opacity: .45; }
.anatomy .stage svg, .chart { width: 100%; height: 100%; animation: ofade .8s ease both; }
.anatomy .stage circle { fill: #00cc00; }   /* fixed: must match the depicted styles.css beside it */

.aiapp {
  width: 100%;
  max-width: 300px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  animation: ofade .8s ease both;
}
.aimeta { font: var(--fine) var(--mono); opacity: .5; }
.aifield {
  padding: 7px 9px;
  border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
  border-radius: 6px;
  font: var(--fine)/1.5 var(--mono);
  opacity: .8;
}
.aibtn:disabled { opacity: .55; cursor: default; }
.aifield.messy { opacity: .5; }
textarea.aifield { width: 100%; height: 68px; resize: none; background: transparent; color: inherit; }
.hint { opacity: .55; }
.arrow { display: inline-block; animation: nudge 1.8s ease-in-out infinite; }
@keyframes nudge {
  0%, 60%, 100% { transform: translateY(0); }
  30% { transform: translateY(-5px); }
}
.aibtn {
  align-self: flex-start;
  padding: 6px 15px;
  border: none;
  border-radius: 6px;
  background: var(--primary-fill);
  color: #fff;
  font: 600 var(--ui) system-ui, sans-serif;
  cursor: pointer;
}

.files { width: 100%; display: flex; flex-direction: column; gap: 4px; animation: ofade .8s ease both; }
.frow {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  font: var(--code) var(--mono);
}
/* The bar is the row's background, so the text sits on top of it. */
.fbar {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  border-radius: 4px;
  background: var(--primary);
  opacity: .22;
}
.fname { position: relative; flex: 1; min-width: 0; }
.fkb { position: relative; opacity: .6; }

/* ── chapter 2 ── */
svg.diagram { width: 100%; height: auto; display: block; }
.pillhit { cursor: pointer; }
.pillhit:hover text { fill-opacity: 1; }
/* The UA ring is drawn round the group's whole bbox, which reads as a stray border
   on click. Keep it for keyboards only, where it's doing a job. */
.pillhit:focus { outline: none; }
.pillhit:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.node { cursor: pointer; }
.stepbar { display: flex; justify-content: center; align-items: center; gap: 7px; flex-wrap: wrap; }
.stepbar button {
  font: var(--ui) system-ui, sans-serif;
  padding: 5px 12px;
  border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  border-radius: 999px;
  background: transparent;
  color: inherit;
  cursor: pointer;
}
.stepbar button:hover { border-color: currentColor; }
.stepbar .chip.on { border-color: #ec4899; color: #ec4899; font-weight: 650; }
.caption {
  min-height: 5.6em;
}
code {
  font: var(--code) var(--mono);
  background: color-mix(in srgb, currentColor 10%, transparent);
  padding: 1px 4px;
  border-radius: 4px;
}

/* ── chapter 3 ── */
.cli .panes { display: flex; gap: 18px; align-items: stretch; }
.term, .mirror {
  flex: 1;
  min-width: 0;
  border-radius: 10px;
  padding: 12px 14px;
}
.cli .panes > * { min-height: 300px; }
.term {
  background: #121212;
  color: #d8d8d8;
  border: 1px solid #2b2b2b;
  font: var(--code)/1.75 var(--mono);
  white-space: pre-wrap;
}
.term .cmd { color: rgb(0, 255, 0); }   /* fixed: the terminal chrome never follows the theme */
/* Standalone, not sharing a row with a second pane, so it takes the panel width. */
.automation .term { max-width: var(--panel); margin-inline: auto; }
.term .you-line { color: #fff; }
.term .bulb-line { color: #8f8f8f; }

/* The bulb explorer is a depiction of the mirror's own dark chrome, so it keeps
   those colours in both themes — same reasoning as the terminal above. It's a
   popover in the real UI, so it stays narrower than the column it sits in. Its
   green and blue are the mirror's own --fold-marker and --accent values. */
.launcher {
  max-width: var(--panel);
  margin-inline: auto;
  border: 1px solid #2b2b2b;
  border-radius: 10px;
  background: #1b1b1b;
  color: #d8d8d8;
  padding: 6px;
  font-size: var(--ui);
}
.lrow {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 5px 8px;
  border-radius: 6px;
}
.lrow.sel { background: #262b33; }
.lbtn {
  flex: none;
  width: 15px;
  text-align: center;
  font-size: var(--fine);
  color: #8a8a8a;
}
.lrow.sel .lbtn { color: #00ff00; }
.lname { flex: 1; min-width: 0; }
.lname.run { color: #7aa2fa; }
.lfold { color: #8a8a8a; font-size: var(--fine); }
.lpill {
  flex: none;
  padding: 2px 9px;
  border: 1px solid #4a4a4a;
  border-radius: 6px;
  color: #b6b6b6;
  font-size: var(--fine);
}
.lpill.trusted { border-color: #7aa2fa; color: #7aa2fa; }
.llogs { color: #9a9a9a; font-size: var(--fine); }
.lsync { flex: none; color: #b6b6b6; }
.lport { flex: none; width: 46px; text-align: right; color: #8a8a8a; font-size: var(--fine); }
.lport.on { color: #00ff00; }
.lfilter {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin: 6px 2px 2px;
  padding: 8px 10px;
  border: 1px solid #333;
  border-radius: 8px;
  color: #8a8a8a;
}

/* The bold lead-in opens each point, so markers would only give the block a
   ragged left edge. */
.legend {
  padding: 0;
  list-style: none;
}
.legend li { margin-bottom: 11px; opacity: .8; }
.legend b { opacity: 1; }
.mirror {
  border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
  background: color-mix(in srgb, currentColor 4%, transparent);
  opacity: 0;
  transition: opacity .6s;
}
.mirror.on { opacity: 1; }
.mhead {
  font: var(--fine) var(--mono);
  opacity: .55;
  padding-bottom: 9px;
  margin-bottom: 11px;
  border-bottom: 1px solid color-mix(in srgb, currentColor 14%, transparent);
}
.conv { display: flex; flex-direction: column; gap: 12px; font-size: var(--code); }
.row { display: flex; gap: 9px; }
.who {
  flex: none;
  width: 44px;
  padding-top: 2px;
  font: 600 var(--fine) var(--mono);
  opacity: .55;
}
.msg { min-width: 0; }
.card {
  margin-top: 8px;
  border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
  border-radius: 8px;
  overflow: hidden;
}
.chead {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  padding: 5px 8px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  font: var(--fine) var(--mono);
  opacity: .75;
}
.bk { color: var(--primary); opacity: .9; }
.cbody { padding: 16px 8px; font: var(--fine) var(--mono); opacity: .45; }
.outro { margin-top: 8px; }
.fw { display: block; width: 100%; height: auto; background: #0e0e0e; }
.fwslot { display: block; width: 100%; height: 100%; background: #0e0e0e; border-radius: 7px; }

/* ── shared trim ── */
.afterword {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
.afterword p { margin-block: 0; opacity: .75; }
.link {
  font: var(--ui) system-ui, sans-serif;
  padding: 0;
  border: none;
  background: none;
  color: var(--primary);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}

@media (max-width: 680px) {
  /* Five tabs on one line at 360px: the padding is what gives, not the size. */
  .tabs { gap: 2px; }
  .tabs button { padding: 6px 8px 7px; }
  .subtabs { gap: 4px; }
  .subtabs button { padding: 4px 10px; }
  .anatomy .panes, .charts .panes, .ai .panes, .server .panes, .cli .panes { flex-direction: column; }
  main { min-height: 0; }
  .stage { height: 200px; }
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; }
  .ospoke { animation-delay: 0s !important; animation-duration: .01ms !important; }
  .arrow { animation: none !important; }
}
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "description": "An interactive tour of Typebulb: apps that live in a single markdown file. What bulbs are, how they call AI, and how coding agents build and run them.",
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "recharts": "^3.8.1"
  }
}
```

Markdown source · More bulbs by samples · Typebulb home