Learn: a /learn/ deep-dive section — markdown lessons as before/after spec progressions

This commit is contained in:
2026-06-25 12:02:39 +03:00
parent c6ca988e40
commit c19857b0a7
22 changed files with 1291 additions and 13 deletions
+13 -2
View File
@@ -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 (0010) — the WHAT
├── architecture/ # Architecture playbook (0010) — the HOW (self-contained)
├── architecture/ # Architecture playbook (0011) — 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 (M0M6)
@@ -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
+3 -2
View File
@@ -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 0010):
the **what**. This is the contract; implement to it.
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
(0010): the **how** (state, persistence, modals, routing, rendering, inference,
relationships, vega-editor techniques, visual design, interaction & feedback).
(0011): 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).
+1
View File
@@ -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)
+54
View File
@@ -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.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Interactive deep dives into Vega-Lite — the hidden capabilities and how they combine. For people past the basics who want the full power of the spec."
/>
<title>Vega-Lite, deeper — Astrolabe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/learn/main.tsx"></script>
</body>
</html>
+17 -4
View File
@@ -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",
+1
View File
@@ -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",
+21 -1
View File
@@ -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 objects 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}');
});
});
+19 -4
View File
@@ -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);
}
+127
View File
@@ -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',
]);
});
});
+194
View File
@@ -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<string, unknown>;
}
export interface ProseBlock {
type: 'prose';
markdown: string;
}
export interface ChartBlock {
type: 'chart';
spec: Record<string, unknown>;
}
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<string, unknown> {
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<string, unknown>;
}
function parseFrontmatter(raw: string): Map<string, string> {
const map = new Map<string, string>();
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<string, string>, key: string): string {
const v = meta.get(key);
if (!v) throw new Error(`lesson frontmatter: missing "${key}"`);
return v;
}
+49
View File
@@ -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));
});
});
+54
View File
@@ -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<number> {
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<number>(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<number>();
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;
}
+2
View File
@@ -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,
+197
View File
@@ -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;
}
}
+140
View File
@@ -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 <Markdown>.
const LESSON_SOURCES = import.meta.glob<string>('./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<UiTheme>('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 (
<div className={styles.page}>
<header className={styles.header}>
<a className={styles.brand} href="/">
Astrolabe
</a>
<nav className={styles.nav}>
<span className={styles.kicker}>Vega-Lite, deeper</span>
<button className={styles.ghost} type="button" onClick={toggleTheme}>
{theme === 'dark' ? 'Light' : 'Dark'}
</button>
<a className={styles.appLink} href="/app/">
Open the app
</a>
</nav>
</header>
<main className={styles.main}>
{LESSONS.map((lesson) => (
<LessonArticle key={lesson.slug} lesson={lesson} config={config} />
))}
</main>
<footer className={styles.footer}>
<span>An interactive deep dive into the parts of Vega-Lite the docs gloss over.</span>
<a href="/"> Back to Astrolabe</a>
</footer>
</div>
);
}
type ChartConfig = ReturnType<typeof chartConfigForSelection>;
function LessonArticle({
lesson,
config,
}: {
lesson: ParsedLesson;
config: ChartConfig;
}): ReactNode {
return (
<article className={styles.article}>
<p className={styles.kickerLine}>Deep dive</p>
<h1 className={styles.title}>{lesson.title}</h1>
<p className={styles.tagline}>{lesson.tagline}</p>
{lesson.blocks.map((block, i) => (
<LessonBlockView key={i} block={block} config={config} />
))}
<div className={styles.cta}>
<p>
Open this spec in Astrolabe and keep tweaking edit the JSON, swap the data, theme it.
</p>
<a className={styles.ctaButton} href="/app/">
Open in Astrolabe
</a>
</div>
</article>
);
}
/** Render one lesson block by type — the document is assembled in this order. */
function LessonBlockView({
block,
config,
}: {
block: LessonBlock;
config: ChartConfig;
}): ReactNode {
switch (block.type) {
case 'prose':
return <Markdown className={styles.prose} source={block.markdown} />;
case 'progression':
return (
<div className={styles.block}>
<SpecProgression stages={block.stages} config={config} />
</div>
);
case 'chart':
return (
<div className={styles.block}>
<LandingChart spec={block.spec} config={config} className={styles.chart} />
</div>
);
case 'callout':
return (
<Markdown
className={block.variant === 'sharp-edge' ? styles.sharpEdge : styles.callout}
source={block.markdown}
/>
);
default: {
const exhaustive: never = block;
return exhaustive;
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { marked } from 'marked';
import type { ReactNode } from 'react';
/**
* Render first-party lesson markdown to HTML.
*
* The source is always a repo `.md` file authored by us (parsed by
* `@core/lesson-parse`), never user input — so `dangerouslySetInnerHTML` here is
* not an XSS surface. `marked` runs synchronously with default options.
*/
export function Markdown({ source, className }: { source: string; className?: string }): ReactNode {
const html = marked.parse(source, { async: false });
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
+131
View File
@@ -0,0 +1,131 @@
.progression {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--layer-01);
}
/* ---- stage tabs ---- */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: var(--control-height);
padding: 0 var(--space-4);
font: inherit;
font-size: 13px;
color: var(--text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
}
.tab:hover {
color: var(--text);
background: var(--control-hover-fill);
}
.tab:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.tabActive {
color: var(--text);
border-bottom-color: var(--accent);
}
.tabNum {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 11px;
border-radius: 50%;
background: var(--field);
color: var(--text-secondary);
}
.tabActive .tabNum {
background: var(--accent);
color: var(--accent-contrast);
}
/* ---- the two-pane stage ---- */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
}
.source {
margin: 0;
padding: var(--space-4) var(--space-5);
max-height: 420px;
overflow: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--text);
background: var(--field);
border-right: 1px solid var(--border);
}
.source:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -2px;
}
.line {
display: block;
white-space: pre;
padding: 0 var(--space-3);
margin: 0 calc(-1 * var(--space-3));
}
/* The changed lines for this stage — what its edit did. */
.added {
background: var(--accent-soft);
box-shadow: inset 2px 0 0 var(--accent);
}
/* A block (not a centering flexbox): the chart host must inherit a definite
width from the grid column, or Vega's width:"container" fit collapses to ~0
(the §2 trap in docs/embedding-vega-lite.md). min-width:0 lets the column
shrink below the chart's natural content width. */
.chartPane {
min-width: 0;
padding: var(--space-5);
}
.chart {
width: 100%;
min-height: 180px;
}
.note {
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--border);
font-size: 14px;
color: var(--text-secondary);
background: var(--bg);
}
.note p {
margin: 0;
}
.note code {
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--text);
}
/* Stack the panes on narrow screens. */
@media (max-width: 720px) {
.panes {
grid-template-columns: 1fr;
}
.source {
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 280px;
}
}
+118
View File
@@ -0,0 +1,118 @@
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import type { Config } from 'vega-lite';
import type { ProgressionStage } from '@core/lesson-parse';
import { formatSpec } from '@core/json-format';
import { changedLines } from '@core/spec-diff';
import { LandingChart } from '../landing/LandingChart';
import { Markdown } from './Markdown';
import styles from './SpecProgression.module.css';
/**
* The deep-dive viewer: tabs across the stages of a spec's 80%→100% progression,
* with the stage's source (changed lines highlighted vs. the previous stage) on
* the left and its live chart on the right. The chart reuses the landing's
* `LandingChart` — the one canonical chart-embed — so Vega stays lazy-loaded and
* every render-lifecycle fix lives in one place.
*
* Stages are a fixed, ordered authored list that never reorders, so the array
* index is their identity (React key + tab/panel id wiring).
*
* Keyboard: an APG tablist with roving tabindex, Arrow/Home/End navigation, and
* automatic activation (the panel is instant, so moving focus moves the stage).
*/
export function SpecProgression({
stages,
config,
}: {
stages: ProgressionStage[];
config: Config;
}): ReactNode {
const [active, setActive] = useState(0);
const tablistRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const { lines, highlighted } = useMemo(() => {
const text = formatSpec(stages[active].spec);
const prev = active > 0 ? formatSpec(stages[active - 1].spec) : '';
return {
lines: text.split('\n'),
highlighted: active > 0 ? changedLines(prev, text) : new Set<number>(),
};
}, [stages, active]);
function focusTab(index: number): void {
setActive(index);
const tabs = tablistRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
tabs?.[index]?.focus();
}
function onKeyDown(e: KeyboardEvent<HTMLDivElement>): void {
const last = stages.length - 1;
let next: number;
if (e.key === 'ArrowRight') next = active === last ? 0 : active + 1;
else if (e.key === 'ArrowLeft') next = active === 0 ? last : active - 1;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = last;
else return;
e.preventDefault();
focusTab(next);
}
const activeStage = stages[active];
return (
<div className={styles.progression}>
<div
className={styles.tabs}
role="tablist"
aria-label="Lesson stages"
ref={tablistRef}
onKeyDown={onKeyDown}
>
{stages.map((stage, i) => (
<button
key={i}
id={`${baseId}-tab-${i}`}
type="button"
role="tab"
aria-selected={i === active}
aria-controls={`${baseId}-panel`}
tabIndex={i === active ? 0 : -1}
className={`${styles.tab} ${i === active ? styles.tabActive : ''}`}
onClick={() => setActive(i)}
>
<span className={styles.tabNum}>{i + 1}</span>
<span>{stage.label}</span>
</button>
))}
</div>
<div
id={`${baseId}-panel`}
role="tabpanel"
aria-labelledby={`${baseId}-tab-${active}`}
className={styles.panel}
>
<div className={styles.panes}>
<pre className={styles.source} aria-label="Spec source" tabIndex={0}>
<code>
{lines.map((line, i) => (
<span
// Lines have no stable identity; index is the intended key here.
key={i}
className={`${styles.line} ${highlighted.has(i) ? styles.added : ''}`}
>
{line || ' '}
</span>
))}
</code>
</pre>
<div className={styles.chartPane}>
<LandingChart spec={activeStage.spec} config={config} className={styles.chart} />
</div>
</div>
<Markdown className={styles.note} source={activeStage.note} />
</div>
</div>
);
}
+104
View File
@@ -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.
:::
+10
View File
@@ -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(<LearnPage />);
+3
View File
@@ -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