Skip to content

Commit 2388e32

Browse files
feat: render inline schematics and surface deep lessons
Add a declarative schematic engine (src/ui/schematic.ts) that turns a small JSON spec into a self-contained SVG via a schematic fence in renderMarkdown. It gives diagrams (e.g. view-vs-copy on a shared buffer) a home in lessons, matching the memory-canvas palette. Surface the per-topic deep lesson: the level lesson modal now shows the full per-level lesson (objective, lesson, what this teaches, field notes) and a Deep-lesson button; the levels modal gets a per-series deep-lesson entry point. Write the numpy series deep lesson (ELI5 to ELI-PHD) with an embedded view-vs-copy schematic as the worked exemplar.
1 parent 176c847 commit 2388e32

7 files changed

Lines changed: 601 additions & 9 deletions

File tree

‎src/levels/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,10 @@ export function getLevel(id: string): Level | null {
5050
export function levelIndex(id: string): number {
5151
return allLevels().findIndex((l) => l.id === id);
5252
}
53+
54+
/**
55+
* The series a level belongs to (for its deep lesson), or null.
56+
*/
57+
export function sequenceForLevel(id: string): Sequence | null {
58+
return sequences.find((s) => s.levels.some((l) => l.id === id)) ?? null;
59+
}

‎src/levels/numpy.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,107 @@
44

55
import { preds } from '../engine/checks';
66
import type { Sequence } from '../engine/types';
7+
import { schematicBlock, type SchematicSpec } from '../ui/schematic';
8+
9+
/**
10+
* The one picture the whole series is built on: a window over a buffer, where
11+
* a slice is a second window over the SAME buffer (dashed) and a copy owns its
12+
* own separate buffer.
13+
*/
14+
const VIEW_VS_COPY: SchematicSpec = {
15+
id: 'numpy-view-vs-copy',
16+
title: 'A window over a buffer — view vs copy',
17+
nodes: [
18+
{ id: 'a', label: 'a', sub: 'ndarray · own' },
19+
{ id: 'v', label: 'v = a[:2]', sub: 'ndarray · view' },
20+
{ id: 'c', label: 'b = a.copy()', sub: 'ndarray · own' },
21+
{ id: 'bufA', label: 'buffer A', sub: 'float64[3]', type: 'buffer' },
22+
{ id: 'bufB', label: 'buffer B', sub: 'float64[2]', type: 'buffer' },
23+
],
24+
edges: [
25+
{ from: 'a', to: 'bufA', label: 'owns' },
26+
{ from: 'v', to: 'bufA', label: 'same buffer', dashed: true },
27+
{ from: 'c', to: 'bufB', label: 'owns (new)' },
28+
],
29+
legend: [
30+
{ color: 'amber', label: 'points at / owns' },
31+
{ color: 'coral', label: 'shares the buffer' },
32+
],
33+
};
734

835
export const series: Sequence[] = [
936
{
1037
id: 'numpy',
1138
title: 'NumPy: Buffers & Views',
39+
deepLesson: `## ELI5
40+
41+
A numpy array is like a **frame** you slide over a row of beads. The beads (the
42+
numbers) sit in one long, unbroken row — that row is the **buffer**. The frame
43+
is the **array**: it just says "start here, show this many beads." When you take
44+
a slice, you don't copy any beads; you make a *smaller frame* over the **same**
45+
row. When you copy, you pour the beads into a brand-new row.
46+
47+
${schematicBlock(VIEW_VS_COPY)}
48+
49+
## ELI10
50+
51+
Two objects, always. \`np.array([1, 2, 3])\` allocates a **buffer** (a flat,
52+
contiguous run of raw numbers) and an **ndarray** — a *window* (an offset plus a
53+
length) that says which part of that buffer to show. The name \`a\` points at the
54+
window; the window points at the buffer.
55+
56+
- \`a.copy()\` reads \`a\`'s buffer and mints a **second** buffer with the same
57+
numbers. Two windows, two buffers — edits to one are invisible to the other.
58+
- \`a[:2]\` makes a **view**: a second window over the *same* buffer. No data
59+
moves; only the offset and length change.
60+
- Because a view shares its buffer, \`v[0] = 9\` writes into the shared run, and
61+
\`a\` sees the change. A copy, never.
62+
63+
## ELI15
64+
65+
The mental model that matters in real code: **a slice is a view, not a copy.**
66+
This is the single most common numpy foot-gun.
67+
68+
- A slice \`a[i:j]\` is O(1) — it allocates a new window, moves no data, and
69+
shares the buffer. The result's \`base\` attribute points back at the original.
70+
- Any operation that *builds new data* (an element-wise op like \`a + 1\`, a
71+
fancy index, \`a.copy()\`) allocates a **fresh** buffer.
72+
- So the question to always ask: *does this expression share my data, or make
73+
new data?* Chaining views is free; chaining real operations is where you pay
74+
memory.
75+
76+
## ELI20
77+
78+
The memory mechanics, precisely.
79+
80+
- A buffer is one contiguous \`float64\` run. An ndarray stores \`{buffer,
81+
offset, shape, dtype, view}\`. Two ndarrays are "the same data" iff they
82+
point at the same buffer box (that is exactly what the RAM view's shared
83+
window strip shows).
84+
- Element-wise ops are vectorized: \`a + 1\` reads \`a\`'s window and writes a
85+
new buffer. Chained expressions allocate an intermediate buffer per step —
86+
the cost to watch in a hot loop.
87+
- \`a.sum()\` is a reduction over the window: one pass, a scalar out, no new
88+
buffer.
89+
- Watch the RAM tab while you do this: addresses are monotonic, the buffer row
90+
shows its raw byte cells, and each view paints its \`[offset..offset+shape)\`
91+
window as a strip aligned to those cells.
92+
93+
## ELI-PHD
94+
95+
Where this model pays off and where it breaks.
96+
97+
- **Strides & broadcast**: real ndarrays carry a stride, so a non-contiguous
98+
slice is still a view (a window with a non-1 stride). This visualizer keeps a
99+
flat 1-D window to stay honest about the buffer, and says so in the lessons.
100+
- **\`base\` / \`.copy()\` contract**: in production, \`arr.base\` is the canonical
101+
"does this view own its data" check; \`copy(arr)\` and \`arr.copy()\` are the
102+
escape hatch. A view outliving its buffer is the classic memory-safety trap.
103+
- **Performance**: prefer in-place ops and views when you don't need the
104+
independence, and \`np.ascontiguousarray\` before a call that wants
105+
contiguity. The identity picture — shared vs owned buffer — is the whole
106+
decision: copy only when you must own the data.
107+
`,
12108
levels: [
13109
{
14110
id: 'np-make-array',

‎src/style.css‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,35 @@ button.ghost,
11841184
border-color: color-mix(in srgb, var(--ok) 40%, var(--line));
11851185
}
11861186

1187+
.series-deep {
1188+
margin-top: 10px;
1189+
display: inline-flex;
1190+
align-items: center;
1191+
gap: 6px;
1192+
font-family: var(--mono);
1193+
font-size: 11px;
1194+
color: var(--teal);
1195+
background: color-mix(in srgb, var(--teal) 10%, var(--panel-2));
1196+
border: 1px solid color-mix(in srgb, var(--teal) 40%, var(--line));
1197+
border-radius: 8px;
1198+
padding: 5px 10px;
1199+
cursor: pointer;
1200+
transition: border-color 160ms var(--ease), background 160ms var(--ease);
1201+
}
1202+
1203+
.series-deep:hover {
1204+
border-color: var(--teal);
1205+
background: color-mix(in srgb, var(--teal) 20%, var(--panel-2));
1206+
}
1207+
1208+
/* —— Inline schematics (deep-lesson diagrams) —— */
1209+
.modal-body .schematic {
1210+
background: var(--panel);
1211+
border: 1px solid var(--line);
1212+
border-radius: 10px;
1213+
padding: 8px;
1214+
}
1215+
11871216
.level-row .id {
11881217
font-family: var(--mono);
11891218
color: var(--haze);

‎src/ui/app.ts‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77

88
import { createSession, type Session, referenceEdges, liveObjectIds } from '../engine/session';
99
import { checkGoal } from '../engine/checks';
10-
import type { GoalSpec, Level, MemoryState, Snapshot } from '../engine/types';
10+
import type { GoalSpec, Level, MemoryState, Sequence, Snapshot } from '../engine/types';
1111
import { splitPythonStatements } from '../engine/steps';
12-
import { allLevels, getLevel, levelIndex, sequences } from '../levels/index';
12+
import { allLevels, getLevel, levelIndex, sequenceForLevel, sequences } from '../levels/index';
1313
import { renderMemory, renderRam } from './memory';
1414
import { BoardView } from './board';
1515
import { TerminalView } from './terminal';
@@ -695,7 +695,10 @@ export class App {
695695
</button>`;
696696
})
697697
.join('');
698-
return `<div class="series-block"><h3>${escapeHtml(s.title)}</h3><div class="level-list">${rows}</div></div>`;
698+
const deepBtn = s.deepLesson
699+
? `<button type="button" class="series-deep" data-deep="${escapeHtml(s.id)}" title="Read the full ${escapeHtml(s.title)} lesson (ELI5→ELI-PHD)">▤ deep lesson</button>`
700+
: '';
701+
return `<div class="series-block"><h3>${escapeHtml(s.title)}</h3><div class="level-list">${rows}</div>${deepBtn}</div>`;
699702
})
700703
.join('');
701704

@@ -720,18 +723,57 @@ export class App {
720723
if (id) this.enterLevel(id, { openLesson: true });
721724
});
722725
});
726+
modal.el.querySelectorAll<HTMLButtonElement>('[data-deep]').forEach((btn) => {
727+
btn.addEventListener('click', () => {
728+
const seq = sequences.find((s) => s.id === btn.dataset.deep);
729+
modal.close();
730+
if (seq) this.openDeepLesson(seq);
731+
});
732+
});
723733
}
724734

725735
private openLesson(): void {
726736
if (!this.level) {
727737
this.openWelcome();
728738
return;
729739
}
730-
const lesson =
731-
this.level.brief + '\n\n' + (this.level.hint ? `**Hint:** ${this.level.hint}` : '');
740+
const level = this.level;
741+
const seq = sequenceForLevel(level.id);
742+
const parts: string[] = [];
743+
parts.push(`### ${level.name}`);
744+
parts.push(level.brief);
745+
if (level.lesson) {
746+
parts.push('');
747+
parts.push(level.lesson);
748+
}
749+
if (level.learning?.length) {
750+
parts.push('', '### What this teaches');
751+
for (const b of level.learning) parts.push(`- ${b}`);
752+
}
753+
if (level.fieldNotes?.length) {
754+
parts.push('', '### In production');
755+
for (const n of level.fieldNotes) parts.push(`- ${n}`);
756+
}
757+
if (level.hint) {
758+
parts.push('', `**Hint:** ${level.hint}`);
759+
}
760+
const actions: ModalSpec['actions'] = [];
761+
if (seq?.deepLesson) {
762+
actions.push({ label: 'Deep lesson', className: 'ghost', onClick: () => this.openDeepLesson(seq) });
763+
}
764+
actions.push({ label: 'Close', className: 'primary', onClick: () => this.terminal.focus() });
765+
showModal({
766+
title: level.name,
767+
bodyHtml: renderMarkdown(parts.join('\n')),
768+
actions,
769+
onDismiss: () => this.terminal.focus(),
770+
});
771+
}
772+
773+
private openDeepLesson(seq: Sequence): void {
732774
showModal({
733-
title: `${this.level.name}`,
734-
bodyHtml: renderMarkdown(lesson),
775+
title: `${seq.title} — deep lesson`,
776+
bodyHtml: renderMarkdown(seq.deepLesson ?? ''),
735777
actions: [{ label: 'Close', className: 'primary', onClick: () => this.terminal.focus() }],
736778
onDismiss: () => this.terminal.focus(),
737779
});

‎src/ui/dialog.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* HTML escaping, minimal markdown, and modal dialogs (ported from learn-r).
33
*/
44

5+
import { parseSchematic, renderSchematic } from './schematic';
6+
57
export function escapeHtml(s: string): string {
68
return s
79
.replaceAll('&', '&amp;')
@@ -134,15 +136,33 @@ function renderProse(text: string): string {
134136
return out.join('');
135137
}
136138

139+
/**
140+
* Parse + render a ```schematic block, or fall back to showing the raw source
141+
* if the spec is malformed (a broken diagram should read as data, not vanish).
142+
*/
143+
function renderSchematicSafe(json: string): string {
144+
try {
145+
return renderSchematic(parseSchematic(json));
146+
} catch {
147+
return `<pre class="md-code" data-lang="schematic"><code>${escapeHtml(json)}</code></pre>`;
148+
}
149+
}
150+
137151
export function renderMarkdown(md: string): string {
138152
const blocks = md.split(/```/);
139153
let html = '';
140154
blocks.forEach((block, i) => {
141155
if (i % 2 === 1) {
142156
const firstNewline = block.indexOf('\n');
143157
const lang = firstNewline >= 0 ? block.slice(0, firstNewline).trim() : '';
144-
const code = firstNewline >= 0 ? block.slice(firstNewline + 1) : block;
145-
html += `<pre class="md-code"${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code.trimEnd())}</code></pre>`;
158+
const code = (firstNewline >= 0 ? block.slice(firstNewline + 1) : block).trimEnd();
159+
if (lang === 'schematic') {
160+
// A diagram: render the JSON spec to an inline SVG. On any parse error
161+
// fall back to showing the raw source so a broken spec degrades visibly.
162+
html += renderSchematicSafe(code);
163+
} else {
164+
html += `<pre class="md-code"${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code)}</code></pre>`;
165+
}
146166
} else {
147167
const sections = block.split(/(^#{1,3}\s+.*$)/m);
148168
for (const sec of sections) {

0 commit comments

Comments
 (0)