diff --git a/AGENTS.md b/AGENTS.md
index 0923d39..f4230da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,7 +43,9 @@ the spec_; do not port legacy code.
view-state routing is unchanged by the base path). The landing reuses `src/core` and the
`chart-renderer` service only — never stores, orchestration, modals, or components — and
lazy-loads Vega, so `/` stays light. The PWA service worker and manifest are scoped to
- `/app/`, leaving the landing uncontrolled and always-fresh.
+ `/app/`, leaving the landing uncontrolled and always-fresh. **`/learn/` is a second such
+ entry** (`src/learn/`) — the markdown-authored deep-dive section, under the same rules
+ (see architecture 11).
See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each
layer (state, persistence, modals, routing, rendering, inference, relationships) and
@@ -57,9 +59,11 @@ external repo is needed to work from them.
```
index.html # Landing entry (served at /)
app/index.html # App entry (served at /app/)
+learn/index.html # Learning-section entry (served at /learn/)
src/
├── main.tsx # App bootstrap (font wiring, startup, render)
├── landing/ # Marketing landing at / — standalone page; reuses core + chart-renderer
+├── learn/ # /learn/ deep-dive — markdown lessons + the SpecProgression engine (arch 11)
├── core/ # Portable spec engine (no browser/React/Monaco)
├── app/
│ ├── components/ # React UI (CSS Modules co-located)
@@ -71,7 +75,7 @@ src/
styles/ # Global CSS (tokens, base)
docs/
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
-├── architecture/ # Architecture playbook (00–10) — the HOW (self-contained)
+├── architecture/ # Architecture playbook (00–11) — the HOW (self-contained)
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0–M6)
@@ -151,6 +155,13 @@ High coverage on `src/core/` (parsing, detection, profiling, reference resolutio
transforms, import normalization). Lighter on components. Extract testable logic out of
components into core/stores where practical.
+Don't test static presentational components — copy, markup, and links with no logic behind
+them. Content assertions ("renders the heading X", "this link exists") are change-detectors:
+they break on intentional copy edits and catch no real bug. Test the logic a component
+carries — platform branches, state transitions, config-path writes, render serialization —
+not the strings it renders; if that logic is worth guarding, lift it into core/stores and
+test it there.
+
Component tests (happy-dom) share a harness shape: `createRoot` + `act` with
`IS_REACT_ACT_ENVIRONMENT = true` set at module level, stores reset in `beforeEach`, and
`vi.mock('../services/chart-renderer', …)` for anything that embeds a chart (vega-embed is
diff --git a/CLAUDE.md b/CLAUDE.md
index a6ff6a7..b437ad6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,8 +8,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10):
the **what**. This is the contract; implement to it.
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
- (00–10): the **how** (state, persistence, modals, routing, rendering, inference,
- relationships, vega-editor techniques, visual design, interaction & feedback).
+ (00–11): the **how** (state, persistence, modals, routing, rendering, inference,
+ relationships, vega-editor techniques, visual design, interaction & feedback, learning
+ section).
Self-contained — no external repo needed. Companion:
**[visual-specimen.html](docs/architecture/visual-specimen.html)** — token sandbox +
reusable-primitive catalog (open in a browser).
diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md
index 88e517b..1d3b7dc 100644
--- a/docs/architecture/00-overview.md
+++ b/docs/architecture/00-overview.md
@@ -47,6 +47,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
| 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. |
+| 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). |
## The non-negotiable layering (every doc assumes this)
diff --git a/docs/architecture/11-learning-section.md b/docs/architecture/11-learning-section.md
new file mode 100644
index 0000000..d3b8640
--- /dev/null
+++ b/docs/architecture/11-learning-section.md
@@ -0,0 +1,54 @@
+# 11 — The Learning Section (`/learn/`)
+
+An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate
+from the app, where each lesson walks a spec from an 80%-naive version to a polished one to
+teach the grammar's dormant power and funnel readers into the app.
+
+## A marketing surface, like the landing
+
+`/learn/` is a third Vite entry (`learn/index.html` → `src/learn/`) alongside the landing
+(`/`) and the app (`/app/`), under the same marketing-surface rules:
+
+- Reuses **`src/core` and the landing's `LandingChart`** only — never stores, modals,
+ orchestration, or app components. Vega is lazy-loaded through `LandingChart`, so the entry
+ stays light.
+- **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay
+ uncontrolled, always-fresh, and indexable — the point for organic-reach content.
+
+## Lessons are markdown; the renderer is general
+
+A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.glob` — so
+**adding a lesson is dropping a file**, with no registry to edit. A lesson is a _free-form
+document of ordered blocks_, not a fixed template:
+
+| Authored as | Block | Rendered by |
+| ---------------------------------------------------- | ------------- | --------------------------- |
+| plain markdown | `prose` | `Markdown` |
+| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
+| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
+| `:::name … :::` | `callout` | `Markdown` (styled by name) |
+
+Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note,
+the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there
+is no shared-data construct.
+
+## The pipeline
+
+`lessons/*.md` → `import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
+`LessonBlock[]` → `LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`.
+`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and
+highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff).
+
+## Rules
+
+- **The engine consumes plain data, so the authoring surface is swappable.**
+ `SpecProgression`, `spec-diff`, and `formatSpec` take parsed blocks and specs and know
+ nothing about markdown — the parser is the only thing coupled to the `.md` format. The
+ authoring format can change without touching the widget.
+- **Parsing, diff, and formatting are pure and live in `core`** (tested hardest); rendering
+ and the markdown library live in `src/learn`. **Core stays dependency-free** — `marked` is
+ imported only by `src/learn/Markdown`.
+- **`dangerouslySetInnerHTML` renders first-party lesson files** (repo content authored by
+ us), never user input — not an XSS surface.
+- Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with
+ `formatSpec` so the shown JSON matches the editor's house style.
diff --git a/learn/index.html b/learn/index.html
new file mode 100644
index 0000000..dfe149f
--- /dev/null
+++ b/learn/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+ Vega-Lite, deeper — Astrolabe
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 620a6ba..ad2bde0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0",
+ "marked": "^18.0.5",
"monaco-editor": "^0.54.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -6386,15 +6387,15 @@
}
},
"node_modules/marked": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
- "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "version": "18.0.5",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
+ "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
- "node": ">= 18"
+ "node": ">= 20"
}
},
"node_modules/math-intrinsics": {
@@ -6456,6 +6457,18 @@
"marked": "14.0.0"
}
},
+ "node_modules/monaco-editor/node_modules/marked": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
+ "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index 22243ac..9db9eac 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"@fontsource/space-mono": "^5.2.9",
"@fontsource/spectral": "^5.2.8",
"json-stringify-pretty-compact": "^4.0.0",
+ "marked": "^18.0.5",
"monaco-editor": "^0.54.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
diff --git a/src/core/json-format.test.ts b/src/core/json-format.test.ts
index 3fd6128..2675fd9 100644
--- a/src/core/json-format.test.ts
+++ b/src/core/json-format.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { formatJson } from './json-format';
+import { formatJson, formatSpec } from './json-format';
describe('formatJson', () => {
it('pretty-prints a valid object with the default indent', () => {
@@ -43,3 +43,23 @@ describe('formatJson', () => {
expect(formatJson('true')).toBe('true');
});
});
+
+describe('formatSpec', () => {
+ it('pretty-prints an object in the same house style as formatJson', () => {
+ expect(formatSpec({ a: 1, b: 2 })).toBe('{"a": 1, "b": 2}');
+ });
+
+ it('agrees with formatJson for the equivalent text', () => {
+ const obj = { mark: 'bar', encoding: { x: { field: 'a', type: 'nominal' } } };
+ expect(formatSpec(obj)).toBe(formatJson(JSON.stringify(obj)));
+ });
+
+ it('preserves the object’s own key order (no canonical sort)', () => {
+ // Insertion order is the contract — a lesson author orders keys for teaching.
+ expect(formatSpec({ z: 1, a: 2 })).toBe('{"z": 1, "a": 2}');
+ });
+
+ it('respects indent and line-length options', () => {
+ expect(formatSpec({ a: 1, b: 2 }, { maxLength: 5 })).toBe('{\n "a": 1,\n "b": 2\n}');
+ });
+});
diff --git a/src/core/json-format.ts b/src/core/json-format.ts
index b69ad31..5473c3d 100644
--- a/src/core/json-format.ts
+++ b/src/core/json-format.ts
@@ -17,6 +17,24 @@ const DEFAULT_MAX_LINE = 80;
/** Default indent (spaces) — matches the editor's default tab size (spec §07). */
const DEFAULT_INDENT = 2;
+/**
+ * Pretty-print a spec *object* (not text) in the same Vega house style as
+ * {@link formatJson}. Object-in callers — the lesson source panes, anything that
+ * holds a spec as a parsed value — use this so their rendered JSON matches the
+ * editor's formatting exactly. Key order is the object's own insertion order,
+ * deliberately: where consecutive specs are small edits of one another (a lesson's
+ * 80%→100% chain), a stable order is what keeps a line-diff between them legible.
+ */
+export function formatSpec(
+ value: unknown,
+ opts: { indent?: number; maxLength?: number } = {},
+): string {
+ return stringify(value, {
+ indent: opts.indent ?? DEFAULT_INDENT,
+ maxLength: opts.maxLength ?? DEFAULT_MAX_LINE,
+ });
+}
+
/**
* Reformat `text` as consistently-indented JSON, or return `null` when it is not
* valid JSON. Returning `null` (rather than throwing) lets callers skip a no-op
@@ -34,8 +52,5 @@ export function formatJson(
} catch {
return null;
}
- return stringify(parsed, {
- indent: opts.indent ?? DEFAULT_INDENT,
- maxLength: opts.maxLength ?? DEFAULT_MAX_LINE,
- });
+ return formatSpec(parsed, opts);
}
diff --git a/src/core/lesson-parse.test.ts b/src/core/lesson-parse.test.ts
new file mode 100644
index 0000000..7cfdd47
--- /dev/null
+++ b/src/core/lesson-parse.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, it } from 'vitest';
+import { parseLesson, type LessonBlock } from './lesson-parse';
+
+const LESSON = `---
+slug: binning
+title: From bars to a real histogram
+tagline: One word turns a noisy bar chart into a distribution.
+---
+
+Counting a continuous field is *almost* right.
+
+:::progression
+## raw counts
+A bar per distinct value — a picket fence.
+
+\`\`\`vega-lite
+{ "mark": "bar", "encoding": { "x": { "field": "v" } } }
+\`\`\`
+
+## + bin
+\`bin: true\` is the whole fix.
+
+\`\`\`vega-lite
+{ "mark": "bar", "encoding": { "x": { "field": "v", "bin": true } } }
+\`\`\`
+:::
+
+Then a standalone chart:
+
+\`\`\`vega-lite
+{ "mark": "point" }
+\`\`\`
+
+:::sharp-edge
+Tempted to wire a slider to bin width? It won't work.
+:::
+`;
+
+const types = (blocks: LessonBlock[]) => blocks.map((b) => b.type);
+
+describe('parseLesson — block model', () => {
+ it('reads frontmatter as lesson metadata', () => {
+ const lesson = parseLesson(LESSON);
+ expect(lesson.slug).toBe('binning');
+ expect(lesson.title).toBe('From bars to a real histogram');
+ expect(lesson.tagline).toBe('One word turns a noisy bar chart into a distribution.');
+ });
+
+ it('emits blocks in document order', () => {
+ expect(types(parseLesson(LESSON).blocks)).toEqual([
+ 'prose',
+ 'progression',
+ 'prose',
+ 'chart',
+ 'callout',
+ ]);
+ });
+
+ it('parses progression stages: label, note, parsed spec', () => {
+ const block = parseLesson(LESSON).blocks[1];
+ if (block.type !== 'progression') throw new Error('expected progression');
+ const [raw, binned] = block.stages;
+ expect(raw.label).toBe('raw counts');
+ expect(raw.note).toBe('A bar per distinct value — a picket fence.');
+ expect(raw.spec).toEqual({ mark: 'bar', encoding: { x: { field: 'v' } } });
+ expect((binned.spec.encoding as { x: { bin?: unknown } }).x.bin).toBe(true);
+ });
+
+ it('parses a standalone chart block and a callout variant', () => {
+ const { blocks } = parseLesson(LESSON);
+ const chart = blocks[3];
+ const callout = blocks[4];
+ expect(chart.type === 'chart' && chart.spec).toEqual({ mark: 'point' });
+ expect(callout.type === 'callout' && callout.variant).toBe('sharp-edge');
+ expect(callout.type === 'callout' && callout.markdown).toMatch(/slider to bin width/);
+ });
+
+ it('handles a prose-only lesson', () => {
+ const proseOnly = `---\nslug: x\ntitle: t\ntagline: g\n---\nJust words.`;
+ expect(types(parseLesson(proseOnly).blocks)).toEqual(['prose']);
+ });
+
+ it('supports multiple progressions in one lesson', () => {
+ const two = `---\nslug: x\ntitle: t\ntagline: g\n---
+:::progression
+## a
+\`\`\`vega-lite
+{ "mark": "bar" }
+\`\`\`
+:::
+:::progression
+## b
+\`\`\`vega-lite
+{ "mark": "line" }
+\`\`\`
+:::`;
+ expect(types(parseLesson(two).blocks)).toEqual(['progression', 'progression']);
+ });
+
+ it('throws on missing frontmatter', () => {
+ expect(() => parseLesson('## a')).toThrow(/frontmatter/);
+ });
+
+ it('throws on a missing required field', () => {
+ expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
+ });
+
+ it('throws with stage context on invalid JSON', () => {
+ const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`;
+ expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
+ });
+
+ it('throws on an invalid standalone chart spec', () => {
+ const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n\`\`\`vega-lite\n[1,2]\n\`\`\``;
+ expect(() => parseLesson(bad)).toThrow(/chart: spec must be a JSON object/);
+ });
+
+ it('normalizes CRLF line endings', () => {
+ expect(types(parseLesson(LESSON.replace(/\n/g, '\r\n')).blocks)).toEqual([
+ 'prose',
+ 'progression',
+ 'prose',
+ 'chart',
+ 'callout',
+ ]);
+ });
+});
diff --git a/src/core/lesson-parse.ts b/src/core/lesson-parse.ts
new file mode 100644
index 0000000..8f87824
--- /dev/null
+++ b/src/core/lesson-parse.ts
@@ -0,0 +1,194 @@
+/**
+ * Parser for markdown lesson files (the deep-dive learning section). A lesson is
+ * authored as a `.md` — prose is native markdown, specs are fenced JSON — and this
+ * turns one into the ordered **blocks** the page renders in document order.
+ *
+ * A lesson is a free-form document, not a fixed template: prose, interactive
+ * progressions, standalone charts, and callouts in any arrangement. The author
+ * only ever writes markdown, fenced specs, and `:::directives` — never JSX.
+ *
+ * Pure and portable (string in → data out; no DOM, Vega, or React): the
+ * structural parse and the spec `JSON.parse` live here and are tested hardest.
+ * Rendering the prose markdown to HTML is the UI layer's job (`src/learn`), kept
+ * out of core so core stays dependency-free.
+ *
+ * Format:
+ *
+ * ---
+ * slug: binning
+ * title: From bars to a real histogram
+ * tagline: One word turns a noisy bar chart into a distribution.
+ * ---
+ *
+ * Free intro prose (markdown)…
+ *
+ * :::progression
+ * ## raw counts
+ * Note prose for this stage (markdown)…
+ * ```vega-lite
+ * { …spec… }
+ * ```
+ * ## + bin
+ * …
+ * :::
+ *
+ * More prose, then a standalone chart:
+ *
+ * ```vega-lite
+ * { …one-off chart… }
+ * ```
+ *
+ * :::sharp-edge
+ * A callout (markdown)…
+ * :::
+ *
+ * A `:::progression` block holds `##`-delimited stages (heading → tab label, prose
+ * → note, fenced `vega-lite` → spec). A bare fenced `vega-lite` block is a
+ * standalone chart. Any other `:::name` block is a callout. Everything else is
+ * prose, emitted in the order it appears.
+ */
+
+export interface ProgressionStage {
+ /** Tab label — the `##` heading text. */
+ label: string;
+ /** Markdown prose shown under the chart for this stage. */
+ note: string;
+ /** The stage's Vega-Lite spec, parsed from its fenced block. */
+ spec: Record;
+}
+
+export interface ProseBlock {
+ type: 'prose';
+ markdown: string;
+}
+export interface ChartBlock {
+ type: 'chart';
+ spec: Record;
+}
+export interface ProgressionBlock {
+ type: 'progression';
+ stages: ProgressionStage[];
+}
+export interface CalloutBlock {
+ type: 'callout';
+ /** The directive name, e.g. `sharp-edge` — the UI styles by variant. */
+ variant: string;
+ markdown: string;
+}
+export type LessonBlock = ProseBlock | ChartBlock | ProgressionBlock | CalloutBlock;
+
+export interface ParsedLesson {
+ slug: string;
+ title: string;
+ tagline: string;
+ /** The lesson body, in document order. */
+ blocks: LessonBlock[];
+}
+
+const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/;
+// A top-level region: a `:::name … :::` directive (which may itself contain
+// vega-lite fences) OR a standalone vega-lite fence. The directive alternative is
+// tried first and its lazy body runs to the next `\n:::`, so a progression's inner
+// fences are consumed by its span rather than matched as standalone charts.
+const REGION = /:::([a-z-]+)\n([\s\S]*?)\n:::|```vega-lite\n([\s\S]*?)\n```/g;
+const STAGE_FENCE = /```vega-lite\n([\s\S]*?)\n```/;
+
+export function parseLesson(source: string): ParsedLesson {
+ const text = source.replace(/\r\n/g, '\n');
+
+ const fm = FRONTMATTER.exec(text);
+ if (!fm) throw new Error('lesson: missing `---` frontmatter block');
+ const meta = parseFrontmatter(fm[1]);
+ const slug = required(meta, 'slug');
+ const title = required(meta, 'title');
+ const tagline = required(meta, 'tagline');
+
+ return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) };
+}
+
+function parseBlocks(body: string, slug: string): LessonBlock[] {
+ const blocks: LessonBlock[] = [];
+ const pushProse = (text: string): void => {
+ const trimmed = text.trim();
+ if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
+ };
+
+ REGION.lastIndex = 0;
+ let cursor = 0;
+ let m: RegExpExecArray | null;
+ while ((m = REGION.exec(body)) !== null) {
+ pushProse(body.slice(cursor, m.index));
+ if (m[1] !== undefined) {
+ const variant = m[1];
+ const inner = m[2];
+ // TODO: if lessons multiply, add a `:::data` directive whose values stages
+ // inherit — today a lesson's inline data is repeated in every fenced spec.
+ blocks.push(
+ variant === 'progression'
+ ? { type: 'progression', stages: parseStages(inner, slug) }
+ : { type: 'callout', variant, markdown: inner.trim() },
+ );
+ } else {
+ blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') });
+ }
+ cursor = m.index + m[0].length;
+ }
+ pushProse(body.slice(cursor));
+
+ if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`);
+ return blocks;
+}
+
+function parseStages(inner: string, slug: string): ProgressionStage[] {
+ const parts = inner.split(/^## +/m);
+ parts.shift(); // text before the first `##` (blank by convention)
+ if (parts.length === 0) {
+ throw new Error(`lesson "${slug}": a :::progression has no \`##\` stages`);
+ }
+ return parts.map((part) => parseStage(part, slug));
+}
+
+function parseStage(part: string, slug: string): ProgressionStage {
+ const nl = part.indexOf('\n');
+ const label = (nl === -1 ? part : part.slice(0, nl)).trim();
+ const rest = nl === -1 ? '' : part.slice(nl + 1);
+
+ const block = STAGE_FENCE.exec(rest);
+ if (!block) throw new Error(`lesson "${slug}", stage "${label}": no \`\`\`vega-lite block`);
+
+ const spec = parseSpec(block[1], slug, `stage "${label}"`);
+ const note = (rest.slice(0, block.index) + rest.slice(block.index + block[0].length)).trim();
+ return { label, note, spec };
+}
+
+function parseSpec(json: string, slug: string, where: string): Record {
+ let spec: unknown;
+ try {
+ spec = JSON.parse(json);
+ } catch (e) {
+ throw new Error(`lesson "${slug}", ${where}: invalid JSON — ${(e as Error).message}`, {
+ cause: e,
+ });
+ }
+ if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
+ throw new Error(`lesson "${slug}", ${where}: spec must be a JSON object`);
+ }
+ return spec as Record;
+}
+
+function parseFrontmatter(raw: string): Map {
+ const map = new Map();
+ for (const line of raw.split('\n')) {
+ if (!line.trim()) continue;
+ const i = line.indexOf(':');
+ if (i === -1) throw new Error(`lesson frontmatter: expected \`key: value\`, got "${line}"`);
+ map.set(line.slice(0, i).trim(), line.slice(i + 1).trim());
+ }
+ return map;
+}
+
+function required(meta: Map, key: string): string {
+ const v = meta.get(key);
+ if (!v) throw new Error(`lesson frontmatter: missing "${key}"`);
+ return v;
+}
diff --git a/src/core/spec-diff.test.ts b/src/core/spec-diff.test.ts
new file mode 100644
index 0000000..7a9119c
--- /dev/null
+++ b/src/core/spec-diff.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { changedLines } from './spec-diff';
+
+const set = (...n: number[]) => new Set(n);
+
+describe('changedLines', () => {
+ it('marks nothing when the text is identical', () => {
+ const text = '{\n "mark": "bar"\n}';
+ expect(changedLines(text, text)).toEqual(new Set());
+ });
+
+ it('marks a line inserted in the middle', () => {
+ const prev = 'a\nb\nc';
+ const next = 'a\nb\nNEW\nc';
+ expect(changedLines(prev, next)).toEqual(set(2));
+ });
+
+ it('marks a replaced line (old dropped, new flagged)', () => {
+ const prev = 'a\nb\nc';
+ const next = 'a\nB!\nc';
+ expect(changedLines(prev, next)).toEqual(set(1));
+ });
+
+ it('flags a genuinely-new duplicate line, not the pre-existing one', () => {
+ // Vega specs repeat lines; a naive membership test would mishighlight here.
+ const prev = '"type": "quantitative"';
+ const next = '"type": "quantitative"\n"type": "quantitative"';
+ expect(changedLines(prev, next).size).toBe(1);
+ });
+
+ it('marks an appended block of lines', () => {
+ const prev = '{\n "mark": "bar"\n}';
+ const next = '{\n "mark": "bar",\n "params": [\n { "name": "step" }\n ]\n}';
+ // The closing brace line is unchanged content but shifts position; the params
+ // lines and the comma-suffixed mark line are the changes.
+ const changed = changedLines(prev, next);
+ expect(changed.has(2)).toBe(true); // "params": [
+ expect(changed.has(3)).toBe(true); // { "name": "step" }
+ expect(changed.has(0)).toBe(false); // opening brace unchanged
+ });
+
+ it('treats an empty previous text as an all-new baseline', () => {
+ expect(changedLines('', 'a\nb')).toEqual(set(0, 1));
+ });
+
+ it('marks everything when all lines change', () => {
+ expect(changedLines('a\nb', 'x\ny')).toEqual(set(0, 1));
+ });
+});
diff --git a/src/core/spec-diff.ts b/src/core/spec-diff.ts
new file mode 100644
index 0000000..93c5609
--- /dev/null
+++ b/src/core/spec-diff.ts
@@ -0,0 +1,54 @@
+/**
+ * Line-level diff for the lesson source panes (the deep-dive learning section).
+ *
+ * A lesson walks a spec from an 80%-naive version to a polished one as a chain of
+ * small edits; each step shows the new spec with *the lines this step changed*
+ * highlighted, so a reader sees exactly what the edit did. Given the previous
+ * step's formatted text and the current step's, {@link changedLines} reports which
+ * line indices of the *current* text are new or changed.
+ *
+ * Pure: operates on already-formatted text (see {@link formatSpec}), no Vega, no
+ * DOM. Standard longest-common-subsequence over lines — every current line not
+ * part of the LCS with the previous text is "changed". This is why it beats a
+ * naive "line not present in the previous text" test: Vega specs repeat lines
+ * (`"type": "quantitative"` many times over), and only the LCS classifies a
+ * genuinely-new occurrence as changed while leaving the unchanged duplicates be.
+ */
+
+/**
+ * Indices (0-based, into `nextText`'s lines) that are new or changed relative to
+ * `prevText`. Identical inputs yield an empty set; an empty `prevText` (a first
+ * step with nothing before it) marks every line — callers that render a baseline
+ * step simply skip the highlight.
+ */
+export function changedLines(prevText: string, nextText: string): Set {
+ const a = prevText.length ? prevText.split('\n') : [];
+ const b = nextText.length ? nextText.split('\n') : [];
+ const m = a.length;
+ const n = b.length;
+
+ // dp[i][j] = length of the LCS of a[i..] and b[j..]. Filled bottom-up so the
+ // forward walk below can pick the branch that preserves the longer subsequence.
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
+ for (let i = m - 1; i >= 0; i--) {
+ for (let j = n - 1; j >= 0; j--) {
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
+ }
+ }
+
+ const changed = new Set();
+ let i = 0;
+ let j = 0;
+ while (j < n) {
+ if (i < m && a[i] === b[j]) {
+ i++; // line common to both — unchanged
+ j++;
+ } else if (i < m && dp[i + 1][j] >= dp[i][j + 1]) {
+ i++; // previous line dropped — not a change in `next`
+ } else {
+ changed.add(j); // new/changed line in `next`
+ j++;
+ }
+ }
+ return changed;
+}
diff --git a/src/landing/Landing.tsx b/src/landing/Landing.tsx
index e695474..d9d3b43 100644
--- a/src/landing/Landing.tsx
+++ b/src/landing/Landing.tsx
@@ -29,6 +29,8 @@ import { DEMO_CUSTOM_THEMES } from './demo-themes';
import { LandingChart } from './LandingChart';
import styles from './Landing.module.css';
+// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the
+// learn entry already uses the canonical one.
type UiTheme = 'light' | 'dark';
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
diff --git a/src/learn/LearnPage.module.css b/src/learn/LearnPage.module.css
new file mode 100644
index 0000000..cc64378
--- /dev/null
+++ b/src/learn/LearnPage.module.css
@@ -0,0 +1,197 @@
+.page {
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ color: var(--text);
+}
+
+/* ---- header ---- */
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-5);
+ height: var(--header-height);
+ padding: 0 var(--space-6);
+ border-bottom: 1px solid var(--border);
+}
+.brand {
+ font-weight: 600;
+ font-size: 15px;
+ color: var(--text);
+ text-decoration: none;
+}
+.nav {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+.kicker {
+ font-size: 12px;
+ color: var(--text-secondary);
+ letter-spacing: 0.02em;
+}
+.ghost {
+ height: var(--control-height);
+ padding: 0 var(--space-4);
+ font: inherit;
+ font-size: 13px;
+ color: var(--text);
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+.appLink {
+ height: var(--control-height);
+ display: inline-flex;
+ align-items: center;
+ padding: 0 var(--space-5);
+ font-size: 13px;
+ color: var(--accent-contrast);
+ background: var(--accent);
+ border-radius: var(--radius);
+ text-decoration: none;
+}
+
+/* ---- article ---- */
+.main {
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: center;
+ padding: var(--space-8) var(--space-6);
+}
+.article {
+ width: 100%;
+ max-width: 880px;
+}
+.kickerLine {
+ margin: 0 0 var(--space-2);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+.title {
+ margin: 0 0 var(--space-3);
+ font-size: 30px;
+ line-height: 1.15;
+ font-weight: 600;
+}
+.tagline {
+ margin: 0 0 var(--space-6);
+ font-size: 17px;
+ color: var(--text-secondary);
+}
+/* ---- prose blocks ---- */
+.prose {
+ margin: var(--space-6) 0 0;
+ font-size: 15px;
+ line-height: 1.6;
+ max-width: 64ch;
+}
+.prose p {
+ margin: 0 0 var(--space-4);
+}
+.prose p:last-child {
+ margin-bottom: 0;
+}
+.prose ul,
+.prose ol {
+ margin: 0 0 var(--space-4);
+ padding-left: var(--space-6);
+}
+.prose code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+.prose a {
+ color: var(--accent);
+}
+
+/* ---- interactive blocks (progression, standalone chart) ---- */
+.block {
+ margin: var(--space-7) 0 0;
+}
+.chart {
+ width: 100%;
+ min-height: 180px;
+}
+
+/* ---- callouts ---- */
+.callout,
+.sharpEdge {
+ margin: var(--space-6) 0 0;
+ padding: var(--space-5);
+ font-size: 14px;
+ line-height: 1.6;
+ border: 1px solid var(--border);
+ border-left-width: 3px;
+ border-radius: var(--radius);
+ background: var(--layer-01);
+}
+/* The sharp-edge variant warns — a "this will bite you" callout. */
+.sharpEdge {
+ border-color: var(--support-warning);
+}
+.callout p,
+.sharpEdge p {
+ margin: 0;
+}
+.callout code,
+.sharpEdge code {
+ font-family: var(--font-mono);
+ font-size: 0.9em;
+}
+
+/* ---- CTA ---- */
+.cta {
+ margin: var(--space-7) 0 0;
+ padding: var(--space-6);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-4);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--accent-soft);
+}
+.cta p {
+ margin: 0;
+ font-size: 15px;
+}
+.ctaButton {
+ display: inline-flex;
+ align-items: center;
+ height: var(--control-height-lg);
+ padding: 0 var(--space-6);
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--accent-contrast);
+ background: var(--accent);
+ border-radius: var(--radius);
+ text-decoration: none;
+}
+
+/* ---- footer ---- */
+.footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ border-top: 1px solid var(--border);
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+.footer a {
+ color: var(--text-secondary);
+}
+
+@media (max-width: 720px) {
+ .title {
+ font-size: 24px;
+ }
+}
diff --git a/src/learn/LearnPage.tsx b/src/learn/LearnPage.tsx
new file mode 100644
index 0000000..84901b8
--- /dev/null
+++ b/src/learn/LearnPage.tsx
@@ -0,0 +1,140 @@
+import { useMemo, useState, type ReactNode } from 'react';
+import { chartConfigForSelection } from '@core/vega-themes';
+import type { UiTheme } from '@core/theme';
+import { parseLesson, type LessonBlock, type ParsedLesson } from '@core/lesson-parse';
+import { SpecProgression } from './SpecProgression';
+import { LandingChart } from '../landing/LandingChart';
+import { Markdown } from './Markdown';
+import styles from './LearnPage.module.css';
+
+// Lessons are markdown files: drop a `.md` in lessons/ and it's picked up here —
+// no registry to edit. Loaded raw at build (Vite glob) and parsed by the pure
+// core parser into ordered blocks; the prose renders to HTML in .
+const LESSON_SOURCES = import.meta.glob('./lessons/*.md', {
+ query: '?raw',
+ import: 'default',
+ eager: true,
+});
+
+const LESSONS: ParsedLesson[] = Object.values(LESSON_SOURCES).map(parseLesson);
+
+/**
+ * The deep-dive learning section — a marketing surface at `/learn/`, separate
+ * from the app, reusing only core + the landing's chart embed. One lesson for
+ * now; the glob above means more arrive by adding files.
+ *
+ * Theme handling mirrors the landing (light/dark via `data-theme` on the root).
+ * It's duplicated rather than shared because the two entries are independent
+ * pages; a `useUiTheme` hook could unify them if a third surface appears.
+ */
+export function LearnPage(): ReactNode {
+ const [theme, setTheme] = useState('light');
+ const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]);
+
+ // TODO: this light/dark toggle is now duplicated verbatim in landing/Landing.tsx —
+ // extract a shared `useUiTheme()` hook into a home both marketing entries can import
+ // (not src/app/hooks, which is app-only).
+ function toggleTheme(): void {
+ const next: UiTheme = theme === 'dark' ? 'light' : 'dark';
+ setTheme(next);
+ document.documentElement.dataset.theme = next;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/learn/lessons/binning.md b/src/learn/lessons/binning.md
new file mode 100644
index 0000000..691af18
--- /dev/null
+++ b/src/learn/lessons/binning.md
@@ -0,0 +1,104 @@
+---
+slug: binning
+title: From bars to a real histogram
+tagline: How one word turns a noisy bar chart into a distribution — and where binning stops short.
+---
+
+Counting a continuous field is the most common chart that's _almost_ right. The naive
+version compiles, renders, and quietly lies about your data. Walk the four stages below —
+each tab is one small edit — and watch a picket fence become a distribution, then a finished
+chart.
+
+:::progression
+
+## raw counts
+
+A bar chart of a continuous field gives you _one bar per distinct value_ — a spiky picket
+fence, not a distribution. It looks almost right, which is exactly what makes it the classic
+80%.
+
+```vega-lite
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
+ "mark": "bar",
+ "encoding": {
+ "x": { "field": "minutes", "type": "quantitative" },
+ "y": { "aggregate": "count" }
+ },
+ "data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
+}
+```
+
+## + bin
+
+`bin: true` is the whole fix. Vega-Lite chooses ~10 "nice" boundaries, buckets the rows, and
+counts each bucket — a real histogram from a one-word edit.
+
+```vega-lite
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
+ "mark": "bar",
+ "encoding": {
+ "x": { "field": "minutes", "type": "quantitative", "bin": true },
+ "y": { "aggregate": "count" }
+ },
+ "data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
+}
+```
+
+## + tuned bins
+
+Take control of the resolution: `bin: { step: 10 }` forces clean 10-minute buckets (or set
+`maxbins` for a target count). Real titles turn axes from raw field names into a sentence.
+
+```vega-lite
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
+ "mark": "bar",
+ "encoding": {
+ "x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
+ "y": { "aggregate": "count", "title": "Orders" }
+ },
+ "data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
+}
+```
+
+## polished
+
+The last 20%: round the bar tops, add a tooltip that reports each bucket's range and count,
+and _layer_ a mean line over the bars. Composition is where polish lives — one spec, two
+marks sharing an axis.
+
+```vega-lite
+{
+ "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
+ "layer": [
+ {
+ "mark": { "type": "bar", "cornerRadiusEnd": 2 },
+ "encoding": {
+ "x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
+ "y": { "aggregate": "count", "title": "Orders" },
+ "tooltip": [
+ { "field": "minutes", "bin": { "step": 10 }, "title": "Range (min)" },
+ { "aggregate": "count", "title": "Orders" }
+ ]
+ }
+ },
+ {
+ "mark": { "type": "rule", "color": "#d6336c", "size": 2 },
+ "encoding": { "x": { "field": "minutes", "aggregate": "mean" } }
+ }
+ ],
+ "data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
+}
+```
+
+:::
+
+:::sharp-edge
+**The sharp edge.** Tempted to wire a slider to the bin width? It won't work. In Vega-Lite,
+`bin.step` and `maxbins` are fixed numbers — no parameter or expression drives them, so a
+bound slider compiles fine and then does nothing. The one binning property you _can_ make
+interactive is `extent`: brush an interval selection to re-bin a chosen range. That's a
+lesson of its own.
+:::
diff --git a/src/learn/main.tsx b/src/learn/main.tsx
new file mode 100644
index 0000000..c4223d4
--- /dev/null
+++ b/src/learn/main.tsx
@@ -0,0 +1,10 @@
+import { createRoot } from 'react-dom/client';
+import { LearnPage } from './LearnPage';
+// Same base styles as the landing — design tokens and self-hosted IBM Plex — so
+// the section shares the app's visual language. Like the landing, this entry
+// wires no store, no persistence, and no service worker: the learning pages stay
+// uncontrolled and always-fresh (the SW is scoped to /app/), and indexable.
+import '../../styles/base.css';
+import '../../styles/chart-fonts.css';
+
+createRoot(document.getElementById('root')!).render();
diff --git a/vite.config.ts b/vite.config.ts
index 7227cda..0411925 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -23,9 +23,12 @@ export default defineConfig({
// Multi-page: the marketing landing is served at `/` (index.html → the
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
// src/main.tsx). The app is hash-routed, so it runs unchanged under /app/.
+ // `/learn/` is the deep-dive learning section — another light marketing
+ // entry (src/learn), reusing core + the landing's chart embed only.
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
app: fileURLToPath(new URL('./app/index.html', import.meta.url)),
+ learn: fileURLToPath(new URL('./learn/index.html', import.meta.url)),
},
output: {
// Split the heavy vendors so the chunk graph stays legible and the PWA