feat(skills): system-atlas — explorable isometric architecture atlases (port of inkboard/system-atlas, MIT)

Ports the system-atlas agent skill: one data.mjs file renders both an
interactive isometric HTML map (progressive-disclosure chapters, moving
data packets, question tracking by Q-ID) and a generated text twin
(SYSTEM.md) for the repo.

Why: architecture discussions that outgrow a single diagram — the skill
encodes hard-earned rules (max 3 structures per chapter, shapes+labels,
docs-policy ask before committing). Upstream 410 stars since Aug 20,
VoltAgent-listed; complements architecture-diagram (static SVG) with an
explorable, stateful artifact.

- optional-skills/creative/system-atlas/: SKILL.md (89 lines),
  references/ (design language, process lessons), assets/ (build.mjs,
  template.html, data.example.mjs) vendored verbatim; LICENSE.txt (MIT,
  Harshyt Goel)
- Live smoke: node --check both .mjs OK; node build.mjs produced
  atlas.html (41.5 KB) + SYSTEM.md from the example data, zero deps
- docs: own catalog row + generated page + sidebar entry only
This commit is contained in:
teknium1
2026-09-12 10:12:07 -07:00
committed by Teknium
parent ad0398eed8
commit f41b2c615d
10 changed files with 918 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harshyt Goel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,89 @@
---
name: system-atlas
description: "Build explorable isometric architecture atlases as HTML."
version: 1.0.0
author: Harshyt Goel (adapted by Nous Research)
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [architecture, diagrams, isometric, documentation]
category: creative
related_skills: [architecture-diagram, excalidraw]
upstream: https://github.com/inkboard/system-atlas (pinned f7005f2)
---
# System Atlas Skill
An atlas is one data file (`data.mjs`) that renders two views: an **interactive isometric map** (a single self-contained `atlas.html` — hover to read, click to pin, go inside for steps, moving data packets you can inspect, chapters that reveal the system a few structures at a time), and a **generated text twin** (`SYSTEM.md`) with the decisions table, every structure, the flows, and the open questions by ID. The data file is the only thing anyone edits; both views rebuild from it. It sits beside a hand-written glossary (`CONTEXT.md`) and ADRs.
**Does:** interactive architecture maps with progressive disclosure, question tracking across feedback rounds, a generated text twin, and a repeatable update loop.
**Doesn't:** static one-off diagrams (use the architecture-diagram or excalidraw skill), finished systems that only need a README, or a single diagram for a PR.
## When to Use
Use whenever someone wants to discuss, design, review, or explain an architecture visually — "make an atlas", "map the system", "make the architecture explorable", "visualize the codebase/agent/pipeline so we can talk about it", "a diagram I can click around", "walk me through how it fits together" — or when an architecture discussion is producing a pile of open questions that need tracking across feedback rounds. Also use it to update an existing atlas after decisions change. Best when the system is new enough that vocabulary, decisions, and questions are still moving and there will be more than one feedback round.
## Prerequisites
- Node.js (any recent version; the build uses only `node:fs`, `node:path`, `node:url` — no npm install needed).
- A static server for verification (`npx serve` or `python3 -m http.server`).
## How to Run
```bash
mkdir -p <atlas home>/atlas
cp <skill>/assets/{template.html,build.mjs} <atlas home>/atlas/
cp <skill>/assets/data.example.mjs <atlas home>/atlas/data.mjs # then fill it in
node <atlas home>/atlas/build.mjs # writes ../SYSTEM.md and ../atlas.html
```
Every field of the data file is documented in `assets/data.example.mjs`.
## Quick Reference
| File | Role | Edit it? |
|---|---|---|
| `atlas/data.mjs` | Single source of truth: structures, flows, chapters, decisions, questions, prose | Yes |
| `atlas/template.html` + `atlas/build.mjs` | Renderer + generator | Presentation only |
| `atlas.html` | Built atlas; republish at the same URL after every rebuild | No (generated) |
| `SYSTEM.md` | Built text twin | No (generated) |
| `CONTEXT.md` | Glossary, one line per noun | By hand |
| `adr/` | Hard-to-reverse decisions | By hand |
| `research/` | Deep-dive evidence | Append-only |
## Procedure
Follow the order — each step was earned by a correction the first time round.
1. **Read the inputs before drawing.** The vision doc, the repo's existing surfaces, and whatever prior art the user allows (ask — they may forbid a branch or a source). If you will build on a framework, read its docs first; hand long docs to a subagent via `delegate_task` with your specific design questions and have it return a primer with gotchas and a "what it does not give us" list. Drawing before this produces boxes that don't map to anything real.
2. **Discuss before drawing.** Propose the structure in chat, mapped to the runtime's real primitives, and ask only the questions you cannot derive from the repo. Take defaults for the rest and say which.
3. **First atlas — the whole system.** Copy `assets/` into the atlas home (rename `data.example.mjs` to `data.mjs`), fill the data, build with `node`, publish. **Where the atlas home is depends on the repo's docs policy** — ask before committing anything. Docs-friendly repos: `docs/<system>/atlas/` in-tree. Repos that commit only ADRs and `CONTEXT.md`: put the atlas, `SYSTEM.md`, and `research/` in a git-ignored scratch dir and attach `SYSTEM.md` + research to the spec issue when published. (Committing the whole set once produced a 3,900-line docs PR and four review rounds reconciling three restatements of one design.) Load the design-md or architecture-diagram skill via skill_view for HTML-artifact guidance if useful; read `references/design-language.md` for the visual rules either way.
4. **Progressive disclosure.** A whole system at once reads as noise. Ten-ish chapters; each adds at most three structures and runs one small flow that only touches revealed structures; the last chapter shows everything with a flow picker. Unrevealed structures stay in the index, dimmed, with their chapter number. Panels are summary-first: one sentence, then *Read more* and *Steps* folded.
5. **Shapes and labels.** Letters on boxes are not enough. Give each role a shape and put a readable name label on the canvas under every structure — see design-language.
6. **Text twin.** `CONTEXT.md` is a glossary and nothing else (the nouns, one line each); ADRs only for decisions that are hard to reverse, surprising without context, and the result of a real trade-off — these two are the in-tree pieces. `SYSTEM.md` is generated and `research/` holds evidence; both live with the atlas (scratch dir or `docs/`, per step 3). Don't open issues unless asked.
7. **Feedback by question ID.** Every question is `Q-<code><n>` with a state: open (a string), resolved `{q, r}` (answer + date), or routed `{q, to}` (handed to a named next step). Record the user's words. If they call something "not a question", drop it; if they say "I don't get this", explain with a concrete example *before* resolving. After each round: rebuild, republish, update memory.
8. **Deep dives feed back.** Research with subagents (`delegate_task`) against one shared brief (the interface we own, the requirements that separate candidates, a usage model for cost, a fixed deliverable shape). Write a synthesis with a normalized cost/fit grid. Fold resolutions into the data as `{q, r: '… (from the deep dive, date)'}`. If the user rejects a proposal, sweep *every* file and rewrite — a banner on top of a stale section is not enough.
9. **Keep it current.** One source, rebuild and republish after every change, never hand-edit generated files, and leave a `README.md` in the docs folder explaining the set (table in `references/process-and-lessons.md`).
## Publishing
`atlas.html` is one self-contained file — no build step, no external assets beyond a Google Fonts stylesheet. Serve the folder with any static server (`npx serve`, `python3 -m http.server`) and hand over the URL, or let the repo's pages host serve the committed file. One URL, republished after every data change, never a second copy. If you keep a stable published URL, put it in `META.artifactUrl` so `SYSTEM.md` links to it.
## Pitfalls
- Keep `<!doctype html>` first and `<meta charset="utf-8">` immediately after — otherwise quirks mode and mojibake arrows.
- The renderer rebuilds its whole scene on every draw: a stray `render()` in a hover handler detaches the element under the cursor and the browser stops synthesising clicks — the map looks perfect in a screenshot while nothing responds.
- Some in-app browsers render `file://` as a static snapshot; verify via a static server, not from disk.
- Never delete a question — resolve or mark it dropped, so IDs stay stable.
- After every decision, grep the outputs for stale words (`pending`, the old model name, the rejected design) — the person reads everything.
- Large HTML/JS via shell heredocs is brittle; use `write_file` and keep the data block JSON-serializable.
## Verification
- `node <atlas home>/atlas/build.mjs` exits 0 and writes both `SYSTEM.md` and `atlas.html`.
- Syntax-check the built script (`new Function(js)`), then open the served page in a real browser at ~1280×800; check a first chapter, a middle chapter, the last chapter, an inside view, and the light theme.
- Click a structure and confirm the panel says **pinned** and offers *Go inside*; click a packet dot and confirm the payload opens.
- Every structure has `one`, `what`, `how`, a `short` label, a role `kind`, and its questions; ghosts are marked; chapters exist with per-chapter flows; the last chapter is the whole system.
- `SYSTEM.md` carries the decisions table, the question index with IDs and states, and the "how this file is maintained" footer.
- Project memory records the atlas URL, docs paths, locked decisions with dates, what the user rejected and why, and the next step.

View File

@@ -0,0 +1,104 @@
// Builds <outDir>/SYSTEM.md and <outDir>/atlas.html from data.mjs (same folder).
// Usage: bun <atlasDir>/build.mjs — outDir = parent of atlasDir by default, or META.outDir
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { META, DECISIONS, GROUPS, NODES, FLOWS, CH, HOW_HTML } from './data.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const outDir = META.outDir ? join(here, META.outDir) : join(here, '..');
// ---------- shared helpers ----------
const Q = (c) => (typeof c === 'string' ? { q: c } : c);
const md = (s) =>
String(s)
.replace(/<code>(.*?)<\/code>/g, '`$1`')
.replace(/<mark>(.*?)<\/mark>/g, '**$1**')
.replace(/<em>(.*?)<\/em>/g, '_$1_')
.replace(/<b>(.*?)<\/b>/g, '**$1**')
.replace(/<\/p>\s*<p>/g, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
const groupTitle = Object.fromEntries(GROUPS.map((g) => [g.id, g.title]));
const cnt = { open: 0, res: 0 };
NODES.forEach((n) => (n.cond || []).map(Q).forEach((c) => (c.r || c.to ? cnt.res++ : cnt.open++)));
// ---------- SYSTEM.md ----------
function buildSystemMd() {
const out = [];
out.push(`# ${META.title} — System Definition`, '');
out.push(META.intro, '');
out.push(`_Question status: **${cnt.open} open · ${cnt.res} resolved**._`, '');
out.push('## One paragraph', '', META.onePara, '');
out.push('## Decisions locked', '', '| Axis | Decision | ADR |', '|---|---|---|');
DECISIONS.forEach((d) => out.push(`| ${d.axis} | ${d.decision} | ${d.adr} |`));
out.push('');
out.push('## Cost model', '');
META.costModel.forEach((l) => out.push(l));
if (META.deepDive) out.push('## Deep dives', '', META.deepDive, '');
out.push('## Reading order (the atlas chapters)', '');
CH.forEach((c, i) => out.push(`${i + 1}. **${c.title}** — ${md(c.lede)}${c.reveal.length ? ` _(adds ${c.reveal.join(', ')})_` : ''}`));
out.push('');
out.push('## Structures', '');
const index = [];
for (const g of GROUPS) {
out.push(`### ${g.title}${g.id === 'off' ? ' (designed for, not built)' : ''}`, '');
for (const n of NODES.filter((n) => n.group === g.id)) {
out.push(`#### ${n.code} · ${n.name}${n.ghost ? ' _(not switched on)_' : ''}`, '');
out.push(`**In one line.** ${md(n.one)}`, '');
out.push(`**What it does.** ${md(n.what)}`, '');
out.push(`**How it's built.** ${md(n.how)}`, '');
if (n.steps) {
out.push('**Steps in execution.**', '');
n.steps.forEach((s, i) => out.push(`${i + 1}. **${s[0]}** — ${s[1]}`));
out.push('');
}
const cs = (n.cond || []).map(Q);
if (cs.length) {
out.push('**Questions.**', '');
cs.forEach((c, i) => {
const id = `Q-${n.code}${i + 1}`;
out.push(c.r ? `- ~~**${id}** ${md(c.q)}~~ ✓ ${md(c.r)}` : c.to ? `- **${id}** ${md(c.q)} → _${md(c.to)}_` : `- **${id}** ${md(c.q)}`);
index.push([id, n.code, c]);
});
out.push('');
}
}
}
out.push('## Flows (representative packets)', '', 'Payload shapes are what the design implies, not measured traffic.', '');
for (const f of FLOWS) {
out.push(`### ${f.name}`, '', '| # | From → To | Packet | Representative payload |', '|---|---|---|---|');
f.hops.forEach((h, i) => out.push(`| ${i + 1} | ${h[0]} → ${h[1]} | ${h[2]} | \`${JSON.stringify(h[3]).replace(/\|/g, '\\|')}\` |`));
out.push('');
}
out.push('## Questions — index', '', 'Reference by ID. ✓ resolved (with date) · otherwise open.', '');
index.forEach(([id, code, c]) => out.push(c.r ? `- ~~**${id}**~~ (${code}) ✓ ${md(c.r)}` : `- **${id}** (${code}) ${md(c.q)}`));
out.push('');
if (META.platformGives || META.weOwn) out.push('## What the platform gives vs what we own', '', `**Platform gives:** ${META.platformGives||''}`, '', `**We own:** ${META.weOwn||''}`, '');
if (META.filesystem) out.push('## Planned filesystem', '', '```', META.filesystem.trimEnd(), '```', '');
out.push('## How this file is maintained', '', `Generated from \`${META.sourcePath||'atlas/data.mjs'}\` by \`${META.buildCmd||'bun atlas/build.mjs'}\`, which also builds the interactive atlas (\`atlas.html\`${META.artifactUrl?`, published at ${META.artifactUrl}`:''}). Edit the data file, rebuild, republish — never edit this file by hand.`, '');
return out.join('\n');
}
// ---------- atlas.html ----------
function buildAtlasHtml() {
const tpl = readFileSync(join(here, 'template.html'), 'utf8');
const decisionsHtml = DECISIONS.map((d) => `<li><b>${d.axis}.</b> ${md(d.decision).replace(/\*\*(.*?)\*\*/g, '<b>$1</b>').replace(/`(.*?)`/g, '<code>$1</code>').replace(/\[(.*?)\]\((.*?)\)/g, '$1')}</li>`).join('');
const data = [
`const GROUPS = ${JSON.stringify(GROUPS)};`,
`const NODES = ${JSON.stringify(NODES)};`,
`const FLOWS = ${JSON.stringify(FLOWS)};`,
`const CH = ${JSON.stringify(CH)};`,
`const HOW_HTML = ${JSON.stringify(HOW_HTML)};`,
`const DECISIONS_HTML = ${JSON.stringify(decisionsHtml)};`,
].join('\n');
return tpl.replace('__TITLE__', META.title + ' Atlas').replace('/*__DATA__*/', data + `\nconst STATS = ${JSON.stringify(META.stats||[])};\nconst TITLE = ${JSON.stringify(META.title||'System')};`);
}
writeFileSync(join(outDir, 'SYSTEM.md'), buildSystemMd());
writeFileSync(join(outDir, 'atlas.html'), buildAtlasHtml());
console.log(`built SYSTEM.md + atlas.html · ${cnt.open} open · ${cnt.res} resolved · ${NODES.length} structures · ${DECISIONS.length} decisions`);

View File

@@ -0,0 +1,87 @@
// Single source of truth for one atlas. Copy to <atlas home>/data.mjs and edit.
// Build: bun <atlas home>/build.mjs → writes ../SYSTEM.md and ../atlas.html
// The atlas home is docs/<system>/atlas/ in repos that commit design docs, or a
// git-ignored scratch directory in repos that only commit ADRs + CONTEXT.md.
export const META = {
title: 'Example Agent', // "<title> Atlas" in the tab; "<title> — System Definition" in SYSTEM.md
artifactUrl: '', // fill after first publish; keep it stable across rebuilds
sourcePath: 'docs/example/atlas/data.mjs',
buildCmd: 'bun docs/example/atlas/build.mjs',
stats: [{ k: 'System', v: 'example · v0' }, { k: 'Model roles', v: '2' }], // static top-strip stats; chapter/shown/questions are added automatically
intro: `_**This file is the living source of truth for the design.** The interactive atlas is built from the same data._`,
onePara: `One paragraph a newcomer can read in 30 seconds: what the system is, what the loop is, what is not built yet.`,
costModel: ['Optional: cost assumptions and a table. Leave [] to omit.'],
deepDive: '', // optional: summary + links to research/
platformGives: 'What the runtime/framework provides for free.',
weOwn: 'What we have to build ourselves.',
filesystem: `apps/example/\n agent/…`,
};
// Decisions render as the SYSTEM.md table and as chapter-10's "Decisions locked" list.
export const DECISIONS = [
{ axis: 'Runtime', decision: 'What and why, in one line', adr: '[0001](./adr/0001-slug.md)' },
];
export const GROUPS = [
{ id: 'loop', title: 'The main loop' },
{ id: 'mem', title: 'Memory' },
{ id: 'sup', title: 'Supporting the loop' },
{ id: 'off', title: 'Not yet switched on' },
];
// Structure = one isometric box. Fields:
// id/code: 1–2 letters shown on the box · name · short (≤14 chars, canvas label) · group
// gx,gy: grid position · w,d: footprint · h: height px · kind: box|tall|store|cards|slab|screen|gate|job
// ghost: true → dashed "designed for, not built"
// one: one-sentence summary (shown first) · what: plain description · how: implementation (may use <code>, <mark>)
// steps: [[name, desc], …] → the "go inside" view
// cond: questions — string (open) | {q, r} (resolved, r = answer + date) | {q, to} (routed to a named next step)
export const NODES = [
{ id: 'U', code: 'U', name: 'Web chat', short: 'WEB CHAT', group: 'loop', gx: 1.5, gy: 7.5, w: 2, d: 2, h: 44, kind: 'screen',
one: 'The page where you talk to the agent.',
what: 'Plain-language description for a non-engineer.',
how: 'Implementation with <code>identifiers</code> and <mark>key phrases</mark>.',
steps: [['Open', 'Create or continue a session.'], ['Stream', 'Render deltas.']],
cond: ['Where does the page live?', { q: 'Auth scheme?', r: 'Reuse existing session JWT (2026-01-01).' }] },
{ id: 'I', code: 'I', name: 'Root agent', short: 'AGENT', group: 'loop', gx: 10, gy: 2.5, w: 3, d: 3, h: 64, kind: 'tall',
one: 'The brain — one durable conversation per person.', what: '…', how: '…', steps: [['turn.started', '…'], ['Model call', '…'], ['Tools', '…'], ['Stream', '…']], cond: [] },
{ id: 'M', code: 'M', name: 'Memory', short: 'MEMORY', group: 'mem', gx: 6.5, gy: 9.5, w: 3, d: 3, h: 24, kind: 'store',
one: 'What the agent knows about you.', what: '…', how: '…', steps: [['Write', '…'], ['Read', '…']], cond: [{ q: 'Vendor?', to: 'Memory deep dive' }] },
{ id: 'P', code: 'P', name: 'Proactive', short: 'PROACTIVE', group: 'off', ghost: true, gx: 12.5, gy: -1, w: 2, d: 2, h: 34,
one: 'Later: the agent reaches out on a schedule.', what: '…', how: '…', steps: [['Tick', '…']], cond: ['Notification etiquette.'] },
];
// Flows for the final "whole system" chapter. Hop = [from, to, label, payload, bend('xy'|'yx')]
export const FLOWS = [
{ id: 'turn', name: 'One turn', hops: [
['U', 'I', 'user message', { message: 'can you move my 3pm?' }, 'yx'],
['I', 'M', 'recall', { query: 'move my 3pm', k: 12 }, 'xy'],
['M', 'I', 'memories', { hits: 2 }, 'xy'],
['I', 'U', 'message.appended', { delta: 'Sure —' }, 'yx'],
] },
];
// Chapters = progressive disclosure. Each reveals a few structures and runs ONE small flow among revealed ones.
// The last chapter has reveal: [] and flow: null → shows everything with a flow picker.
export const CH = [
{ id: 'you', title: 'You and the agent', reveal: ['U', 'I'],
lede: `Strip everything away and this is the system: you type, it answers.`,
story: `<p>Two or three sentences. <mark>Highlight</mark> the one idea this chapter adds.</p>`,
flow: [['U', 'I', 'user message', { message: '…' }], ['I', 'U', 'reply', { delta: '…' }]] },
{ id: 'know', title: 'Knowing you', reveal: ['M'],
lede: `Before answering, the agent recalls what it knows about you.`,
story: `<p>…</p>`,
flow: [['U', 'I', 'user message', { message: '…' }], ['I', 'M', 'recall', { k: 12 }], ['M', 'I', 'memories', { hits: 2 }], ['I', 'U', 'reply', { delta: '…' }]] },
{ id: 'later', title: 'Later', reveal: ['P'],
lede: `Designed for, not switched on.`, story: `<p>…</p>`,
flow: [['P', 'I', 'scheduled turn', { kind: 'morning_brief' }]] },
{ id: 'all', title: 'The whole system', reveal: [],
lede: `Everything at once, for free exploration.`,
story: `<p>Choose which flow runs (bottom left). Hover anything; click to pin; → goes inside. The <mark>Open questions</mark> tab lists every question by ID.</p>`,
flow: null },
];
// "How it's built" tab with nothing selected (HTML).
export const HOW_HTML = `<div class="eyebrow">Example · v0</div><h1 class="t">How it's built</h1><div class="sub">the shape and what sits around it</div>
<h3 class="sec">Filesystem</h3><pre>apps/example/…</pre>`;

View File

@@ -0,0 +1,423 @@
<!doctype html>
<meta charset="utf-8">
<title>__TITLE__</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap">
<style>
:root{
--paper:#E6DFBE; --paper-2:#DCD4AF; --ink:#17170F; --ink-2:#6E6B54; --rule:#B9B293; --ghost:#8E8A70;
--hi-bg:#17170F; --hi-fg:#E6DFBE; --face:#EFE9CC; --grid:#CFC8A3;
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#1E1D15; --paper-2:#26251B; --ink:#E6DFBE; --ink-2:#A8A388; --rule:#4A4838; --ghost:#6E6B54;
--hi-bg:#E6DFBE; --hi-fg:#1E1D15; --face:#2A2920; --grid:#33321F;
}
}
:root[data-theme="dark"]{
--paper:#1E1D15; --paper-2:#26251B; --ink:#E6DFBE; --ink-2:#A8A388; --rule:#4A4838; --ghost:#6E6B54;
--hi-bg:#E6DFBE; --hi-fg:#1E1D15; --face:#2A2920; --grid:#33321F;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{background:var(--paper);color:var(--ink);font-family:var(--mono);font-size:13px;line-height:1.55;overflow:hidden}
#app{display:grid;grid-template-rows:52px 1fr 30px;height:100vh;min-height:600px}
#top{display:flex;align-items:stretch;border-bottom:1.5px solid var(--ink);overflow:hidden}
.stat{padding:6px 14px 4px;border-right:1.5px solid var(--ink);min-width:0;white-space:nowrap}
.stat .k{font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-2)}
.stat .v{font-size:15px;font-variant-numeric:tabular-nums}
.stat.grow{flex:1;border-right:none;overflow:hidden}
#controls{display:flex;gap:8px;align-items:center;padding:0 14px;border-left:1.5px solid var(--ink)}
button.ctl{font:inherit;font-size:11px;letter-spacing:.06em;text-transform:uppercase;background:var(--paper);color:var(--ink);border:1.5px solid var(--ink);padding:5px 10px;cursor:pointer;box-shadow:2px 2px 0 var(--ink);white-space:nowrap}
button.ctl:hover{background:var(--face)}
button.ctl:active{transform:translate(1px,1px);box-shadow:1px 1px 0 var(--ink)}
button.ctl:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
button.ctl.primary{background:var(--hi-bg);color:var(--hi-fg)}
button.ctl.primary:hover{background:var(--hi-bg);opacity:.9}
button.ctl:disabled{opacity:.4;cursor:default;box-shadow:none}
#main{display:grid;grid-template-columns:232px 1fr 440px;min-height:0}
#index{border-right:1.5px solid var(--ink);overflow-y:auto;padding:10px 8px 20px}
#index h4{font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-2);margin:12px 4px 6px;font-weight:500}
#index h4:first-child{margin-top:2px}
.ix{display:flex;align-items:flex-start;gap:8px;width:100%;text-align:left;font:inherit;font-size:11.5px;line-height:1.3;background:var(--paper);color:var(--ink);border:1.5px solid var(--ink);padding:7px 8px;margin:0 0 6px;cursor:pointer;box-shadow:2px 2px 0 var(--ink);text-transform:uppercase;letter-spacing:.02em}
.ix .code{color:var(--ink-2);min-width:16px;font-size:10px;padding-top:1px}
.ix .nm{flex:1}
.ix .n{color:var(--ink-2);font-size:10.5px;font-variant-numeric:tabular-nums}
.ix.ghost{border-style:dashed;box-shadow:none;color:var(--ink-2)}
.ix.later{border-color:var(--rule);box-shadow:none;color:var(--ink-2);opacity:.75}
.ix.later .n{font-size:9px;letter-spacing:.06em}
.ix.new{outline:1.5px dashed var(--ink);outline-offset:2px}
.ix.on{background:var(--hi-bg);color:var(--hi-fg)}
.ix.on .code,.ix.on .n{color:var(--hi-fg);opacity:.75}
.ix:hover:not(.on){background:var(--face)}
.ix:focus-visible{outline:2px solid var(--ink);outline-offset:1px}
#canvasWrap{position:relative;min-width:0;background:var(--paper)}
#svg{width:100%;height:100%;display:block;cursor:grab;touch-action:none}
#svg.dragging{cursor:grabbing}
#zoom{position:absolute;right:12px;top:12px;display:flex;flex-direction:column;gap:6px}
#zoom button{width:30px;height:26px;font:inherit;font-size:14px;background:var(--paper);border:1.5px solid var(--ink);color:var(--ink);cursor:pointer;box-shadow:2px 2px 0 var(--ink)}
#rail{position:absolute;left:12px;top:12px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;max-width:calc(100% - 70px)}
#rail .ch{width:22px;height:22px;font:inherit;font-size:10px;background:var(--paper);border:1.5px solid var(--ink);color:var(--ink);cursor:pointer;padding:0;line-height:1}
#rail .ch.done{background:var(--face)}
#rail .ch.on{background:var(--hi-bg);color:var(--hi-fg)}
#rail .ch.locked{border-style:dashed;color:var(--ink-2)}
#rail .ch:focus-visible{outline:2px solid var(--ink);outline-offset:1px}
#rail .title{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-2);margin-left:6px}
#rail .title b{color:var(--ink);font-weight:500}
#flowSel{position:absolute;left:12px;bottom:12px;display:none;gap:6px}
#flowSel button{font:inherit;font-size:10px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);border:1.5px solid var(--ink);color:var(--ink);padding:3px 8px;cursor:pointer}
#flowSel button.on{background:var(--hi-bg);color:var(--hi-fg)}
#tip{position:absolute;pointer-events:none;background:var(--hi-bg);color:var(--hi-fg);font-size:11px;padding:4px 8px;max-width:280px;line-height:1.35;display:none;z-index:5}
#panel{border-left:1.5px solid var(--ink);display:flex;flex-direction:column;min-height:0}
#tabs{display:flex;border-bottom:1.5px solid var(--ink)}
#tabs button{flex:1;font:inherit;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;padding:9px 6px;background:var(--paper);color:var(--ink);border:none;border-right:1.5px solid var(--ink);cursor:pointer}
#tabs button:last-child{border-right:none}
#tabs button.on{background:var(--hi-bg);color:var(--hi-fg)}
#tabs button:focus-visible{outline:2px solid var(--ink);outline-offset:-3px}
#body{overflow-y:auto;padding:14px 18px 40px;flex:1}
.eyebrow{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-2)}
h1.t{font-size:22px;font-weight:500;line-height:1.2;margin:2px 0 4px;text-wrap:balance}
.lede{font-size:14.5px;line-height:1.5;margin:0 0 14px;max-width:56ch}
.sub{color:var(--ink-2);font-size:11px;margin-bottom:14px}
h3.sec{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-2);font-weight:500;margin:20px 0 6px;padding-bottom:4px;border-bottom:1.5px solid var(--ink)}
#body p{margin:0 0 12px;max-width:62ch}
#body ul{margin:0 0 12px;padding-left:18px;max-width:62ch}
#body li{margin-bottom:6px}
mark{background:var(--hi-bg);color:var(--hi-fg);padding:0 3px}
.chip{display:inline-block;border:1.5px solid var(--ink);padding:1px 6px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:0 6px 6px 0;background:var(--paper);color:var(--ink);font-family:inherit;cursor:pointer}
.chip.ghost{border-style:dashed;color:var(--ink-2)}
.chip.static{cursor:default}
pre{background:var(--face);border:1.5px solid var(--rule);padding:10px 12px;font-size:11px;line-height:1.45;overflow-x:auto;margin:0 0 12px;white-space:pre}
.step{display:grid;grid-template-columns:24px 1fr;gap:8px;margin:0 0 8px;font-size:12px}
.step .no{color:var(--ink-2);font-variant-numeric:tabular-nums}
.step.cur .no{color:var(--ink);font-weight:600}
.step.cur .tx{background:var(--face);outline:1.5px solid var(--ink);padding:2px 6px}
.actions{display:flex;gap:8px;margin:14px 0 0;flex-wrap:wrap}
.pk{border:1.5px solid var(--ink);padding:10px 12px;margin:0 0 12px}
.pk .lbl{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-2)}
.pk .route{font-size:12px;margin:2px 0 8px}
details{border-top:1.5px solid var(--rule);margin:8px 0 0;padding:6px 0 0}
details summary{cursor:pointer;font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-2);list-style:none;display:flex;gap:8px;align-items:center}
details summary::before{content:'+';display:inline-block;width:12px;color:var(--ink)}
details[open] summary::before{content:'−'}
details summary::-webkit-details-marker{display:none}
details > *:not(summary){margin-top:8px}
#hint{border-top:1.5px solid var(--ink);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-2);display:flex;align-items:center;padding:0 14px;gap:14px;overflow:hidden;white-space:nowrap}
.q{padding-left:0;list-style:none}
.q li{position:relative;padding-left:18px}
.q li::before{content:'?';position:absolute;left:0;top:0;color:var(--ink-2)}
.q li.res::before{content:'✓'}
.q li.to::before{content:'→'}
.q li.res>span,.q li.to>span{color:var(--ink-2);text-decoration:line-through;text-decoration-color:var(--rule)}
.q li.to>span{text-decoration:none}
.q .ans{margin-top:2px;padding-left:10px;border-left:1.5px solid var(--ink)}
.pkt>.lbl{visibility:hidden}
.pkt:hover>.lbl,.pkt.lbl-on>.lbl{visibility:visible}
.node>.hl{visibility:hidden}
.node:hover>.hl,.node.sel>.hl,.node.hov>.hl{visibility:visible}
@keyframes halo{0%{opacity:.9}50%{opacity:.25}100%{opacity:.9}}
.halo{animation:halo 1.6s ease-in-out infinite}
@media (prefers-reduced-motion: reduce){.halo{animation:none}}
@media (max-width:1100px){#main{grid-template-columns:200px 1fr 360px}}
</style>
<div id="app">
<div id="top">
<div id="stats" style="display:flex;min-width:0;flex:1"></div>
<div id="controls">
<button class="ctl" id="btnBack">◂ Back</button>
<button class="ctl primary" id="btnNext">Next ▸</button>
<button class="ctl" id="btnFlow">‖ Pause</button>
<button class="ctl" id="btnStep">Trace one step</button>
<button class="ctl" id="btnReset">Refit</button>
</div>
</div>
<div id="main">
<nav id="index" aria-label="Structures"></nav>
<div id="canvasWrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.1"/></pattern>
<pattern id="hatchLight" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)"><line x1="0" y1="0" x2="0" y2="7" stroke="currentColor" stroke-width=".8" opacity=".55"/></pattern>
</defs>
<g id="world"></g>
</svg>
<div id="rail"></div>
<div id="flowSel"></div>
<div id="zoom"><button id="zin" aria-label="Zoom in">+</button><button id="zout" aria-label="Zoom out">−</button></div>
<div id="tip"></div>
</div>
<aside id="panel">
<div id="tabs">
<button data-tab="what" class="on">What it does</button>
<button data-tab="how">How it's built</button>
<button data-tab="cond">Open questions</button>
</div>
<div id="body"></div>
</aside>
</div>
<div id="hint">
<span>enter / ] next chapter</span><span>[ back</span><span>hover to read</span><span>click to pin</span><span>→ go inside</span><span>← come out</span><span>click a dot to inspect its packet</span>
</div>
</div>
<script>
/*__DATA__*/
/* ---------------- CHAPTERS (progressive disclosure) ---------------- */
/* ---------------- ISO ---------------- */
const TW=72, TH=36;
function P(gx,gy,z=0){ return [(gx-gy)*TW/2, (gx+gy)*TH/2 - z]; }
function pts(a){ return a.map(p=>p.join(',')).join(' '); }
const byId = Object.fromEntries(NODES.map(n=>[n.id,n]));
const center = n => [n.gx+n.w/2, n.gy+n.d/2];
const NS='http://www.w3.org/2000/svg';
const el=(t,a={},...kids)=>{const e=document.createElementNS(NS,t);for(const k in a)e.setAttribute(k,a[k]);kids.forEach(k=>e.appendChild(k));return e;};
const question=c=>typeof c==='string'?{q:c}:c;
const qOpen=n=>(n.cond||[]).map(question).filter(c=>!c.r&&!c.to).length; const qHtml=list=>`<ul class="q">${list.map(question).map(c=>c.r?`<li class="res"><span>${c.q}</span><div class="ans">✓ ${c.r}</div></li>`:c.to?`<li class="to"><span>${c.q}</span><div class="ans">→ ${c.to}</div></li>`:`<li>${c.q}</li>`).join('')}</ul>`;
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
/* ---------------- STATE ---------------- */
const S = { ch:0, view:'world', inside:null, selected:null, hover:null, tab:'what', playing:true, tx:0, ty:0, k:1, packets:[], selPacket:null, flowSel:(typeof FLOWS!=='undefined'&&FLOWS[0]?FLOWS[0].id:'turn'), maxCh:0 };
const svg=document.getElementById('svg'), world=document.getElementById('world'), tip=document.getElementById('tip'), body=document.getElementById('body');
let sceneNodes=[], sceneHops=[];
const revealedIds=()=>{ if(S.ch>=CH.length-1) return NODES.map(n=>n.id); const s=new Set(); for(let i=0;i<=S.ch;i++) CH[i].reveal.forEach(id=>s.add(id)); return [...s]; };
const isNew=id=>S.ch<CH.length-1 && CH[S.ch].reveal.includes(id);
const revealChapterOf=id=>{ for(let i=0;i<CH.length-1;i++) if(CH[i].reveal.includes(id)) return i; return CH.length-1; };
/* ---------------- SCENES ---------------- */
function buildWorldScene(){
const ids=new Set(revealedIds()); sceneNodes=NODES.filter(n=>ids.has(n.id)); sceneHops=[];
const c=CH[S.ch];
if(c.flow){ c.flow.forEach((h,i)=>{ let bend=bendFor(h[0],h[1]); const rev=c.flow.findIndex(x=>x[0]===h[1]&&x[1]===h[0]); if(rev>-1&&rev<i) bend=bend==='xy'?'yx':'xy'; sceneHops.push({flow:'ch',idx:i,from:h[0],to:h[1],label:h[2],payload:h[3],bend}); }); }
else { FLOWS.forEach(f=>{ if(S.flowSel!=='both'&&f.id!==S.flowSel)return; f.hops.forEach((h,i)=>sceneHops.push({flow:f.id,idx:i,from:h[0],to:h[1],label:h[2],payload:h[3],bend:h[4]||'xy'})); }); }
}
function bendFor(a,b){ const t=FLOWS.flatMap(f=>f.hops||[]).find(h=>h[0]===a&&h[1]===b); return t?(t[4]||'xy'):'yx'; }
function buildInsideScene(n){
const steps=n.steps||[]; sceneNodes=steps.map((s,i)=>({id:'s'+i,code:String(i+1),name:s[0],short:s[0],desc:s[1],gx:2+i*3.4,gy:2+i*0.6,w:2,d:2,h:34,inside:true}));
sceneHops=[]; for(let i=0;i<steps.length-1;i++) sceneHops.push({flow:'inside',idx:i,from:'s'+i,to:'s'+(i+1),label:steps[i][0]+' → '+steps[i+1][0],payload:{step:i+1,handsOff:steps[i+1][0]},bend:'xy'});
}
function hopPath(h){
const A=sceneNodes.find(n=>n.id===h.from), B=sceneNodes.find(n=>n.id===h.to); if(!A||!B) return null;
const [ax,ay]=center(A), [bx,by]=center(B); const mid = h.bend==='xy' ? [bx,ay] : [ax,by];
const g=[[ax,ay],mid,[bx,by]].filter((p,i,arr)=>i===0||p[0]!==arr[i-1][0]||p[1]!==arr[i-1][1]);
const scr=g.map(p=>P(p[0],p[1],0)); let len=0; const seg=[]; for(let i=1;i<scr.length;i++){const l=Math.hypot(scr[i][0]-scr[i-1][0],scr[i][1]-scr[i-1][1]);seg.push(l);len+=l;}
return {grid:g,scr,seg,len};
}
function pointAt(path,t){ let d=t*path.len; for(let i=0;i<path.seg.length;i++){ if(d<=path.seg[i]||i===path.seg.length-1){const u=path.seg[i]?Math.min(1,d/path.seg[i]):1;const a=path.scr[i],b=path.scr[i+1];return [a[0]+(b[0]-a[0])*u,a[1]+(b[1]-a[1])*u];} d-=path.seg[i]; } return path.scr[0]; }
/* ---------------- RENDER ---------------- */
function render(){
world.innerHTML='';
const gg=el('g',{opacity:.55}); const R=[-4,26];
for(let i=R[0];i<=R[1];i+=2){ const a=P(i,R[0]),b=P(i,R[1]);gg.appendChild(el('line',{x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:'var(--grid)','stroke-width':.6})); const c=P(R[0],i),d=P(R[1],i);gg.appendChild(el('line',{x1:c[0],y1:c[1],x2:d[0],y2:d[1],stroke:'var(--grid)','stroke-width':.6})); }
world.appendChild(gg);
if(S.view==='world'&&S.ch>=CH.length-1){
const plate=(x0,y0,x1,y1,dash)=>{const p=el('polygon',{points:pts([P(x0,y0),P(x1,y0),P(x1,y1),P(x0,y1)]),fill:'none',stroke:'var(--ink)','stroke-width':.9,opacity:.5});if(dash)p.setAttribute('stroke-dasharray','5 4');return p;};
world.appendChild(plate(0.5,1.5,17,10.5,false)); world.appendChild(plate(2,9,17.5,16.5,false)); world.appendChild(plate(-2,-2,24,17.5,true));
}
const hg=el('g'); world.appendChild(hg);
sceneHops.forEach(h=>{ h.path=hopPath(h); if(!h.path)return; const ghost=byId[h.from]?.ghost||byId[h.to]?.ghost;
const pl=el('polyline',{points:pts(h.path.scr),fill:'none',stroke:'var(--ink)','stroke-width':h.flow!=='turn'&&h.flow!=='ch'&&h.flow!=='inside'?1:1.4,opacity:(h.flow!=='turn'&&h.flow!=='ch'&&h.flow!=='inside')||ghost?.7:1});
if((h.flow!=='turn'&&h.flow!=='ch'&&h.flow!=='inside')||ghost)pl.setAttribute('stroke-dasharray','3 3'); hg.appendChild(pl);
h.path.scr.slice(1,-1).forEach(p=>hg.appendChild(el('polygon',{points:pts([[p[0],p[1]-3.5],[p[0]+3.5,p[1]],[p[0],p[1]+3.5],[p[0]-3.5,p[1]]]),fill:'var(--ink)'})));
});
const ng=el('g'); world.appendChild(ng);
[...sceneNodes].sort((a,b)=>(a.gx+a.gy+a.w+a.d)-(b.gx+b.gy+b.w+b.d)).forEach(n=>ng.appendChild(drawNode(n)));
const lg=el('g'); world.appendChild(lg); sceneNodes.forEach(n=>lg.appendChild(drawLabel(n)));
world.appendChild(el('g',{id:'pkts'})); drawPackets(); renderRail();
}
function drawLabel(n){
const g=el('g',{style:'pointer-events:none'});
const b=P(n.gx+n.w, n.gy+n.d, 0); const txt=(n.short||n.name).toUpperCase(); const w=txt.length*6.6+10; const y=b[1]+9;
const dim=n.ghost&&!n.inside;
g.appendChild(el('rect',{x:b[0]-w/2,y:y,width:w,height:14,fill:S.selected===n.id?'var(--hi-bg)':'var(--paper)',stroke:dim?'var(--ghost)':'var(--ink)','stroke-width':.9,'stroke-dasharray':dim?'3 2':'none'}));
const t=el('text',{x:b[0],y:y+10.5,'text-anchor':'middle','font-size':'9','font-family':'var(--mono)','letter-spacing':'.06em',fill:S.selected===n.id?'var(--hi-fg)':(dim?'var(--ink-2)':'var(--ink)'),'font-weight':'500'}); t.textContent=txt; g.appendChild(t);
return g;
}
function drawNode(n){
const {gx,gy,w,d,h}=n; const sel=S.selected===n.id, hov=S.hover===n.id, fresh=!n.inside&&isNew(n.id);
const g=el('g',{class:'node'+(sel?' sel':'')+(hov?' hov':''),style:'cursor:pointer','data-id':n.id});
const sw=sel?2:1.2, dash=n.ghost?'4 3':null, face=n.ghost?'none':'var(--face)';
const face3=(p,fill,hatch,extra={})=>{const poly=el('polygon',{points:pts(p),fill:fill,stroke:'var(--ink)','stroke-width':sw,'stroke-linejoin':'round',...extra});if(dash)poly.setAttribute('stroke-dasharray',dash);g.appendChild(poly);if(hatch&&!n.ghost){g.appendChild(el('polygon',{points:pts(p),fill:'url(#'+hatch+')',style:'color:var(--ink)',stroke:'none'}));}};
const box=(z0,z1,hatchTop)=>{ face3([P(gx,gy+d,z0),P(gx+w,gy+d,z0),P(gx+w,gy+d,z1),P(gx,gy+d,z1)],face,'hatch'); face3([P(gx+w,gy,z0),P(gx+w,gy+d,z0),P(gx+w,gy+d,z1),P(gx+w,gy,z1)],face,'hatch'); face3([P(gx,gy,z1),P(gx+w,gy,z1),P(gx+w,gy+d,z1),P(gx,gy+d,z1)],face,hatchTop||null); };
const kind=n.kind||'box';
if(kind==='store'){ const L=3, gap=3, lh=(h-gap*(L-1))/L; for(let i=0;i<L;i++){ const z0=i*(lh+gap); box(z0,z0+lh,null);} }
else if(kind==='cards'){ const L=5, lh=h/L; for(let i=0;i<L;i++) box(i*lh,(i+1)*lh,null); }
else if(kind==='slab'){ box(0,h,'hatchLight'); }
else if(kind==='screen'){ box(0,h,null); const inset=.3; if(!n.ghost){ const scr=[P(gx+inset,gy+inset,h),P(gx+w-inset,gy+inset,h),P(gx+w-inset,gy+d-inset,h),P(gx+inset,gy+d-inset,h)]; g.appendChild(el('polygon',{points:pts(scr),fill:'var(--paper)',stroke:'var(--ink)','stroke-width':.9})); for(let i=1;i<=3;i++){const a=P(gx+inset+.2,gy+inset+i*.4,h),b=P(gx+w-inset-.4-(i===3?.5:0),gy+inset+i*.4,h);g.appendChild(el('line',{x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:'var(--ink)','stroke-width':.8,opacity:.7}));} } }
else if(kind==='gate'){ box(0,h,null); if(!n.ghost){ const z0=h*.55,z1=h*.75; const band=[[P(gx,gy+d,z0),P(gx+w,gy+d,z0),P(gx+w,gy+d,z1),P(gx,gy+d,z1)],[P(gx+w,gy,z0),P(gx+w,gy+d,z0),P(gx+w,gy+d,z1),P(gx+w,gy,z1)]]; band.forEach(p=>g.appendChild(el('polygon',{points:pts(p),fill:'var(--ink)',opacity:.85,stroke:'none'}))); } }
else if(kind==='tall'){ box(0,h,null); if(!n.ghost){ // roof ridge line on top to read as "the brain"
const c=P(gx+w/2,gy+d/2,h); const a=P(gx,gy+d,h),b=P(gx+w,gy,h); g.appendChild(el('line',{x1:a[0],y1:a[1],x2:b[0],y2:b[1],stroke:'var(--ink)','stroke-width':.8,opacity:.5})); } }
else if(kind==='job'){ box(0,h,'hatchLight'); }
else { box(0,h,null); }
const top=[P(gx,gy,h),P(gx+w,gy,h),P(gx+w,gy+d,h),P(gx,gy+d,h)];
g.appendChild(el('polygon',{class:'hl',points:pts(top),fill:sel?'var(--ink)':'none',opacity:sel?.12:1,stroke:'var(--ink)','stroke-width':sel?2.5:1.8}));
if(fresh&&!sel){ g.appendChild(el('polygon',{class:'halo',points:pts([P(gx-.35,gy-.35,0),P(gx+w+.35,gy-.35,0),P(gx+w+.35,gy+d+.35,0),P(gx-.35,gy+d+.35,0)]),fill:'none',stroke:'var(--ink)','stroke-width':1.4,'stroke-dasharray':'4 3'})); }
const c=P(gx+w/2,gy+d/2,h); const chipW=16;
g.appendChild(el('rect',{x:c[0]-chipW/2,y:c[1]-8,width:chipW,height:14,fill:sel?'var(--hi-bg)':'var(--paper)',stroke:'var(--ink)','stroke-width':1}));
const t=el('text',{x:c[0],y:c[1]+3,'text-anchor':'middle','font-size':'9','font-family':'var(--mono)',fill:sel?'var(--hi-fg)':'var(--ink)','font-weight':'600'}); t.textContent=n.code; g.appendChild(t);
/* No render() on hover: it clears #world and detaches this very element, which
re-fires hover and kills the click. CSS :hover draws the highlight instead. */
g.addEventListener('mouseenter',e=>{S.hover=n.id;showTip(e,n);renderPanel();});
g.addEventListener('mousemove',e=>moveTip(e));
g.addEventListener('mouseleave',()=>{S.hover=null;hideTip();renderPanel();});
/* No click/dblclick here: hovering rebuilds the scene, so this element is gone
before the browser can pair press with release. Hit-testing lives on the svg. */
return g;
}
/* ---------------- PACKETS ---------------- */
function initPackets(){ S.packets=[]; const flows=[...new Set(sceneHops.map(h=>h.flow))]; flows.forEach(f=>S.packets.push({flow:f,hop:0,t:0,speed:.5,pause:0})); S.selPacket=null; }
function hopsOf(flow){ return sceneHops.filter(h=>h.flow===flow); }
function drawPackets(){
const pg=world.querySelector('#pkts'); if(!pg)return; pg.innerHTML='';
S.packets.forEach((p,i)=>{ const hs=hopsOf(p.flow); const h=hs[p.hop]; if(!h||!h.path)return; const [x,y]=pointAt(h.path,p.t);
const isSel=S.selPacket===i; const g=el('g',{class:'pkt'+(isSel||S.ch<CH.length-1?' lbl-on':''),style:'cursor:pointer','data-i':i});
if(isSel) g.appendChild(el('circle',{cx:x,cy:y,r:11,fill:'none',stroke:'var(--ink)','stroke-width':1.2,'stroke-dasharray':'2 2'}));
g.appendChild(el('circle',{cx:x,cy:y,r:5.5,fill:'var(--ink)',stroke:'var(--paper)','stroke-width':1.6}));
const lg=el('g',{class:'lbl'}); const lbl=el('text',{x:x+10,y:y-8,'font-size':'10','font-family':'var(--mono)',fill:'var(--ink)','font-weight':'500'}); lbl.textContent=h.label;
lg.appendChild(el('rect',{x:x+7,y:y-19,width:h.label.length*6.4+6,height:14,fill:'var(--paper)',stroke:'var(--ink)','stroke-width':.8})); lg.appendChild(lbl); g.appendChild(lg);
pg.appendChild(g);
});
}
let last=0;
function tick(ts){ const dt=Math.min(.05,(ts-last)/1000||0); last=ts; tweenTick(); if(S.playing){ S.packets.forEach(p=>advance(p,dt)); drawPackets(); if(S.selPacket!=null) renderPanel(); } requestAnimationFrame(tick); }
function advance(p,dt){ const hs=hopsOf(p.flow); if(!hs.length)return; if(p.pause>0){p.pause-=dt;return;} const h=hs[p.hop]; if(!h||!h.path){p.hop=(p.hop+1)%hs.length;return;} p.t+=dt*p.speed*(320/Math.max(120,h.path.len)); if(p.t>=1){p.t=0;p.hop=(p.hop+1)%hs.length;p.pause=(p.hop===0?1.2:.35);} }
function stepOnce(){ S.playing=false; syncFlowBtn(); let i=S.selPacket??0; const p=S.packets[i]; if(!p)return; const hs=hopsOf(p.flow); p.hop=(p.hop+1)%hs.length; p.t=.5; p.pause=0; S.selPacket=i; drawPackets(); renderPanel(); }
function syncFlowBtn(){ document.getElementById('btnFlow').textContent = S.playing?'‖ Pause':'▸ Play'; }
requestAnimationFrame(tick);
/* ---------------- PANEL ---------------- */
function nodeChip(id){ const n=byId[id]; return `<button class="chip${n.ghost?' ghost':''}" onclick="select('${id}')">${esc(n.code)} · ${esc(n.name)}</button>`; }
function renderPanel(){
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===S.tab));
if(S.selPacket!=null && S.packets[S.selPacket]){ const p=S.packets[S.selPacket]; const hs=hopsOf(p.flow); const h=hs[p.hop];
const A=sceneNodes.find(n=>n.id===h.from), B=sceneNodes.find(n=>n.id===h.to); const flowName=p.flow==='ch'?CH[S.ch].title:p.flow==='inside'?'inside '+byId[S.inside].name:(FLOWS.find(f=>f.id===p.flow)||{}).name;
body.innerHTML=`<div class="eyebrow">Data packet · ${esc(flowName)} · hop ${p.hop+1} of ${hs.length}</div><h1 class="t">${esc(h.label)}</h1>
<div class="pk"><div class="lbl">Route</div><div class="route">${esc(A.code)} ${esc(A.name)} &nbsp;→&nbsp; ${esc(B.code)} ${esc(B.name)}</div><div class="lbl">Payload (representative)</div><pre>${esc(JSON.stringify(h.payload,null,2))}</pre></div>
<p>Payload shapes are what the design implies, not measured traffic.</p>
<div class="actions"><button class="ctl" onclick="stepOnce()">Trace one step</button><button class="ctl" onclick="S.selPacket=null;S.playing=true;syncFlowBtn();renderPanel();drawPackets()">Release & play</button><button class="ctl" onclick="select('${esc(B.id)}')">Open ${esc(B.code)}</button></div>`; return; }
const id=S.selected??S.hover; const n=id?sceneNodes.find(x=>x.id===id):null;
if(!n){ renderChapterPanel(); return; }
if(n.inside){ const parent=byId[S.inside]; const i=+n.id.slice(1);
body.innerHTML=`<div class="eyebrow">Inside ${esc(parent.code)} · ${esc(parent.name)} · step ${i+1} of ${parent.steps.length}</div><h1 class="t">${esc(n.name)}</h1><p class="lede">${esc(n.desc)}</p>
<h3 class="sec">All steps</h3>${parent.steps.map((s,j)=>`<div class="step ${j===i?'cur':''}"><div class="no">${j+1}</div><div class="tx"><b>${esc(s[0])}</b> — ${esc(s[1])}</div></div>`).join('')}
<div class="actions"><button class="ctl" onclick="comeOut()">← Come back out</button></div>`; return; }
const status = n.ghost?'<span class="chip ghost static">Not switched on</span>':`<span class="chip static">${n.group==='loop'?'turn loop':n.group==='mem'?'memory':'supporting'}</span>`;
const pinned=S.selected===n.id;
let content='';
if(S.tab==='what') content=`<p class="lede">${n.one}</p><details${pinned?' open':''}><summary>Read more</summary><p>${n.what}</p></details>${n.steps?`<details><summary>Steps in execution</summary>${n.steps.map((s,j)=>`<div class="step"><div class="no">${j+1}</div><div class="tx"><b>${esc(s[0])}</b> — ${esc(s[1])}</div></div>`).join('')}</details>`:''}`;
else if(S.tab==='how') content=`<p class="lede">${n.one}</p><p>${n.how}</p>`;
else content=`<p class="lede">${n.one}</p>`+(n.cond&&n.cond.length?qHtml(n.cond):`<p>Nothing open here right now.</p>`);
body.innerHTML=`<div class="eyebrow">${esc(n.code)} · ${pinned?'pinned':'hovering'}${isNew(n.id)?' · new in this chapter':''}</div><h1 class="t">${esc(n.name)}</h1><div class="sub">${status}</div>${content}
${pinned?`<div class="actions">${n.steps?`<button class="ctl" onclick="goInside('${n.id}')">→ Go inside</button>`:''}<button class="ctl" onclick="select(null)">Back to chapter</button></div>`:''}`;
}
function renderChapterPanel(){
const c=CH[S.ch]; const last=S.ch===CH.length-1;
if(S.tab==='how'){ body.innerHTML=HOW_HTML; return; }
if(S.tab==='cond'){ body.innerHTML=`<div class="eyebrow">${esc(TITLE)}</div><h1 class="t">Open questions</h1><div class="sub">${last?'everything unresolved, by structure':'for the structures revealed so far'}</div>`+NODES.filter(n=>revealedIds().includes(n.id)&&n.cond&&n.cond.length).map(n=>`<h3 class="sec">${n.code} · ${esc(n.name)}${n.ghost?' <span class="chip ghost static">later</span>':''}</h3>${qHtml(n.cond)}`).join(''); return; }
const chips = last ? '' : `<h3 class="sec">New in this chapter</h3><div>${c.reveal.map(nodeChip).join('')}</div>`;
const nav=`<div class="actions"><button class="ctl" onclick="prevCh()" ${S.ch===0?'disabled':''}>◂ Back</button><button class="ctl primary" onclick="nextCh()" ${last?'disabled':''}>${S.ch===CH.length-2?'Show the whole system ▸':'Next ▸'}</button></div>`;
body.innerHTML=`<div class="eyebrow">Chapter ${S.ch+1} of ${CH.length}</div><h1 class="t">${esc(c.title)}</h1><p class="lede">${c.lede}</p>${c.story}${chips}${nav}
${last?`<h3 class="sec">Decisions locked</h3><ul>${DECISIONS_HTML}</ul>`:''}`;
}
document.querySelectorAll('#tabs button').forEach(b=>b.addEventListener('click',()=>{S.tab=b.dataset.tab;S.selPacket=null;renderPanel();drawPackets();}));
/* ---------------- STATS ---------------- */
function renderStats(){ const el=document.getElementById('stats'); const open=NODES.reduce((a,n)=>a+qOpen(n),0), routed=NODES.reduce((a,n)=>a+(n.cond||[]).filter(c=>c.to).length,0), res=NODES.reduce((a,n)=>a+(n.cond||[]).filter(c=>c.r).length,0);
const dyn={chapter:`${S.ch+1} / ${CH.length}`, shown:`${revealedIds().length} / ${NODES.length}`, questions:`${open} open · ${routed} routed · ${res} resolved`};
const all=[...(STATS||[]), {k:'Chapter',v:dyn.chapter},{k:'Structures shown',v:dyn.shown},{k:'Open questions',v:dyn.questions,grow:true}];
el.innerHTML=all.map(s=>`<div class="stat${s.grow?' grow':''}"><div class="k">${s.k}</div><div class="v">${s.v}</div></div>`).join(''); }
/* ---------------- INDEX + RAIL ---------------- */
function renderIndex(){
const ix=document.getElementById('index'); ix.innerHTML=''; const rev=new Set(revealedIds());
GROUPS.forEach(g=>{ const h=document.createElement('h4'); h.textContent=g.title; ix.appendChild(h);
NODES.filter(n=>n.group===g.id).forEach(n=>{ const b=document.createElement('button'); const shown=rev.has(n.id);
b.className='ix'+(n.ghost&&shown?' ghost':'')+(!shown?' later':'')+(S.selected===n.id?' on':'')+(isNew(n.id)?' new':''); b.dataset.id=n.id;
b.innerHTML=`<span class="code">${n.code}</span><span class="nm">${esc(n.name)}</span><span class="n" title="${shown?'open questions':'revealed in chapter'}">${shown?qOpen(n):'ch '+(revealChapterOf(n.id)+1)}</span>`;
b.addEventListener('click',()=>{ if(!shown){ gotoCh(revealChapterOf(n.id)); select(n.id); return; } if(S.view!=='world')comeOut(); select(n.id); focusNode(n); });
b.addEventListener('mouseenter',()=>{ if(S.view==='world'&&shown){S.hover=n.id;renderPanel();render();} });
b.addEventListener('mouseleave',()=>{ if(S.view==='world'){S.hover=null;renderPanel();render();} });
ix.appendChild(b); }); });
renderStats();
}
function renderRail(){ const r=document.getElementById('rail'); if(S.view!=='world'){ r.innerHTML=`<span class="title">THE SYSTEM &nbsp;›&nbsp; <b>${esc(byId[S.inside].code)} ${esc(byId[S.inside].name)}</b> &nbsp;·&nbsp; steps in execution</span>`; document.getElementById('flowSel').style.display='none'; return; }
r.innerHTML=CH.map((c,i)=>`<button class="ch ${i===S.ch?'on':i<S.ch?'done':''} ${i>S.maxCh+1?'locked':''}" title="${esc(c.title)}" onclick="gotoCh(${i})">${i+1}</button>`).join('')+`<span class="title">chapter ${S.ch+1} · <b>${esc(CH[S.ch].title)}</b></span>`;
const fs=document.getElementById('flowSel'); fs.style.display = S.ch===CH.length-1?'flex':'none';
if(!fs.dataset.built){ fs.innerHTML=[...FLOWS.map(f=>`<button data-f="${f.id}">${f.name}</button>`),'<button data-f="both">All flows</button>'].join(''); fs.dataset.built='1'; fs.querySelectorAll('button').forEach(b=>b.addEventListener('click',()=>{S.flowSel=b.dataset.f;buildWorldScene();initPackets();render();})); }
fs.querySelectorAll('button').forEach(b=>b.classList.toggle('on',b.dataset.f===S.flowSel));
document.getElementById('btnBack').disabled=S.ch===0; document.getElementById('btnNext').disabled=S.ch===CH.length-1;
}
/* ---------------- NAV ---------------- */
function select(id){ S.selected=id; S.selPacket=null; renderIndex(); renderPanel(); render(); }
function gotoCh(i){ i=Math.max(0,Math.min(CH.length-1,i)); if(S.view!=='world'){S.view='world';S.inside=null;} S.ch=i; S.maxCh=Math.max(S.maxCh,i); S.selected=null; S.hover=null; S.tab='what'; buildWorldScene(); initPackets(); S.playing=true; syncFlowBtn(); renderIndex(); render(); renderPanel(); fitView(true); }
function nextCh(){ gotoCh(S.ch+1); } function prevCh(){ gotoCh(S.ch-1); }
function goInside(id){ const n=byId[id]; if(!n||!n.steps)return; S.view='inside'; S.inside=id; S.selected='s0'; buildInsideScene(n); initPackets(); fitView(true); renderIndex(); render(); renderPanel(); }
function comeOut(){ const was=S.inside; S.view='world'; S.inside=null; buildWorldScene(); initPackets(); S.selected=was; fitView(true); renderIndex(); render(); renderPanel(); }
function orderList(){ return S.view==='world'?sceneNodes.map(n=>n.id):sceneNodes.map(n=>n.id); }
document.addEventListener('keydown',e=>{ if(e.target.tagName==='INPUT')return; const L=orderList(); let i=L.indexOf(S.selected);
if(e.key==='ArrowDown'){e.preventDefault();select(L[Math.min(L.length-1,i+1)]);}
else if(e.key==='ArrowUp'){e.preventDefault();select(L[Math.max(0,i-1)]);}
else if(e.key==='ArrowRight'){e.preventDefault(); if(S.view==='world'&&S.selected)goInside(S.selected);}
else if(e.key==='ArrowLeft'){e.preventDefault(); if(S.view==='inside')comeOut(); else select(null);}
else if(e.key==='Enter'||e.key===']'){e.preventDefault(); if(S.view==='world')nextCh();}
else if(e.key==='['){e.preventDefault(); if(S.view==='world')prevCh();}
else if(e.key===' '){e.preventDefault();toggleFlow();}
else if(e.key==='Escape'){select(null);} });
/* ---------------- VIEW / PAN / ZOOM ---------------- */
let tween=null;
function applyView(){ world.setAttribute('transform',`translate(${S.tx},${S.ty}) scale(${S.k})`); }
function tweenTick(){ if(!tween)return; const now=performance.now(); let u=Math.min(1,(now-tween.t0)/tween.dur); u=1-Math.pow(1-u,3); S.tx=tween.a.tx+(tween.b.tx-tween.a.tx)*u; S.ty=tween.a.ty+(tween.b.ty-tween.a.ty)*u; S.k=tween.a.k+(tween.b.k-tween.a.k)*u; applyView(); if(u>=1)tween=null; }
function setView(tx,ty,k,anim){ tween=null; if(anim&&!matchMedia('(prefers-reduced-motion: reduce)').matches){ tween={a:{tx:S.tx,ty:S.ty,k:S.k},b:{tx,ty,k},t0:performance.now(),dur:520}; } else { S.tx=tx;S.ty=ty;S.k=k;applyView(); } }
function bbox(){ let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9; sceneNodes.forEach(n=>{[P(n.gx,n.gy,n.h),P(n.gx+n.w,n.gy,n.h),P(n.gx+n.w,n.gy+n.d,-24),P(n.gx,n.gy+n.d,0),P(n.gx,n.gy,0)].forEach(p=>{x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);});}); return {x0,y0,x1,y1}; }
function fitView(anim){ const r=svg.getBoundingClientRect(); if(!sceneNodes.length)return; const b=bbox(); const pad=40; const k=Math.min((r.width-pad*2)/Math.max(1,b.x1-b.x0),(r.height-pad*2)/Math.max(1,b.y1-b.y0)); if(r.width<80||r.height<80||!isFinite(k)||k<=0)return; const kk=Math.max(.2,Math.min(k,1.5)); setView(r.width/2-((b.x0+b.x1)/2)*kk, r.height/2-((b.y0+b.y1)/2)*kk+8, kk, anim); }
function focusNode(n){ const r=svg.getBoundingClientRect(); const c=P(n.gx+n.w/2,n.gy+n.d/2,n.h/2); setView(r.width/2-c[0]*S.k, r.height/2-c[1]*S.k, S.k, true); }
function zoomAt(f,cx,cy){ tween=null; const nk=Math.max(.35,Math.min(3,S.k*f)); S.tx=cx-(cx-S.tx)*(nk/S.k); S.ty=cy-(cy-S.ty)*(nk/S.k); S.k=nk; applyView(); }
svg.addEventListener('wheel',e=>{e.preventDefault(); const r=svg.getBoundingClientRect(); zoomAt(e.deltaY<0?1.1:0.9,e.clientX-r.left,e.clientY-r.top);},{passive:false});
/* Selection runs on pointerdown rather than click. render() and drawPackets() each
clear their layer, and both run on hover, so the box or dot under the cursor is
replaced between press and release — the browser then never synthesises a click on
it and click-to-pin silently did nothing. Pointer capture is deferred until a drag
actually starts for the same reason: capturing on pointerdown retargets the click
to the svg, away from the node. */
function selectPacket(i){ S.selPacket=i; S.playing=false; syncFlowBtn(); renderPanel(); drawPackets(); }
let drag=null, lastTap={id:null,t:0};
svg.addEventListener('pointerdown',e=>{ if(e.button!==0)return; tween=null;
const hit=e.target.closest?e.target.closest('g.node,g.pkt'):null, isPkt=hit&&hit.classList.contains('pkt');
drag={x:e.clientX,y:e.clientY,tx:S.tx,ty:S.ty,moved:false,pid:e.pointerId,
pkt:isPkt?+hit.dataset.i:null, id:hit&&!isPkt?hit.dataset.id:null}; });
svg.addEventListener('pointermove',e=>{ if(!drag)return; const dx=e.clientX-drag.x,dy=e.clientY-drag.y;
if(!drag.moved){ if(Math.hypot(dx,dy)<=3)return; drag.moved=true; svg.classList.add('dragging'); try{svg.setPointerCapture(drag.pid);}catch(_){} }
S.tx=drag.tx+dx; S.ty=drag.ty+dy; applyView(); });
svg.addEventListener('pointercancel',()=>{ drag=null; svg.classList.remove('dragging'); });
svg.addEventListener('pointerup',()=>{ const d=drag; drag=null; svg.classList.remove('dragging');
if(!d||d.moved)return; // a pan, not a tap — leave selection alone
if(d.pkt!=null){ selectPacket(d.pkt); return; }
if(d.id==null){ select(null); return; } // empty canvas clears the pin
const n=sceneNodes.find(x=>x.id===d.id), now=performance.now();
if(lastTap.id===d.id&&now-lastTap.t<400){ lastTap={id:null,t:0}; if(n&&!n.inside&&n.steps){ goInside(d.id); return; } }
else lastTap={id:d.id,t:now};
select(d.id); });
document.getElementById('zin').onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(1.2,r.width/2,r.height/2);};
document.getElementById('zout').onclick=()=>{const r=svg.getBoundingClientRect();zoomAt(1/1.2,r.width/2,r.height/2);};
window.addEventListener('resize',()=>fitView(false));
/* ---------------- TIP ---------------- */
function showTip(e,n){ tip.style.display='block'; tip.textContent=n.inside?n.desc:(n.ghost?'Later · ':'')+n.name+' — '+(n.one||'').replace(/<[^>]+>/g,''); moveTip(e); }
function moveTip(e){ const r=svg.getBoundingClientRect(); let x=e.clientX-r.left+14,y=e.clientY-r.top+14; if(x+290>r.width)x-=300; if(y+60>r.height)y-=70; tip.style.left=x+'px'; tip.style.top=y+'px'; }
function hideTip(){ tip.style.display='none'; }
/* ---------------- CONTROLS ---------------- */
function toggleFlow(){ S.playing=!S.playing; if(S.playing)S.selPacket=null; syncFlowBtn(); renderPanel(); drawPackets(); }
document.getElementById('btnFlow').onclick=toggleFlow;
document.getElementById('btnStep').onclick=stepOnce;
document.getElementById('btnReset').onclick=()=>{ select(null); fitView(true); };
document.getElementById('btnNext').onclick=nextCh; document.getElementById('btnBack').onclick=prevCh;
/* ---------------- BOOT ---------------- */
gotoCh(0);
</script>

View File

@@ -0,0 +1,34 @@
# Atlas design language
When to load: before filling `data.mjs` or touching `template.html` — layout, palette, isometric grammar, shapes by role, labels, copy rules, and the chapter recipe live here.
The reference was a "codebase as interactive isometric diagram" screenshot: khaki paper, black hatched isometric structures, a left index of components, a right panel with *What it does / How it's built / Condition*, moving dots that are data packets you can inspect, hover to read, go inside a structure to see its steps, pan/zoom. Keep that grammar.
## Layout
- **Top strip** — stats (system, model roles, chapter n/N, structures shown n/N, questions open·routed·resolved) + controls: `◂ Back`, `Next ▸` (primary), `‖ Pause / ▸ Play`, `Trace one step`, `Refit`.
- **Left index** — grouped buttons (code · name · count). Unrevealed structures dimmed with `ch N` (click → jump to that chapter). New-in-this-chapter gets a dashed outline. Ghost (not built) = dashed border.
- **Canvas** — isometric SVG, pan by drag, wheel zoom, `+/−`. Chapter rail top-left (numbered squares + title). Flow picker bottom-left on the last chapter only.
- **Right panel** — tabs *What it does / How it's built / Open questions*. Nothing selected → the chapter story (title, lede, 2–3 sentences, "New in this chapter" chips, Back/Next). Structure selected → eyebrow (code · pinned/hovering · new), name, status chip, one-sentence `one`, then `Read more` and `Steps in execution` as `<details>`. Packet selected → route + representative JSON payload.
- **Hint bar** — keys: `enter / ] next chapter · [ back · hover to read · click to pin · → go inside · ← come out · click a dot to inspect`.
## Palette and type
Paper `#E6DFBE` · ink `#17170F` · muted `#6E6B54` · rule `#B9B293` · face `#EFE9CC` · grid `#CFC8A3`; dark theme swaps to dark olive paper `#1E1D15` / pale ink `#E6DFBE`. Tokens on `:root`, redefined under `prefers-color-scheme: dark` (guarded `:not([data-theme="light"])`) and `[data-theme="dark"]`. One face: IBM Plex Mono (Google Fonts) with a monospace fallback. Key phrases use `<mark>` = ink background, paper text. Buttons: 1.5px ink border + 2px hard shadow; primary = inverted.
## Isometric grammar
- Tile 72×36; `P(gx,gy,z) = [(gx−gy)·36, (gx+gy)·18 − z]`. Structures sorted by `gx+gy+w+d` for painter's order. Hops are ground-plane polylines with diamond bend markers; route from footprint centre to centre with one bend (`xy` or `yx`); **return hops take the other bend** so request and reply dots don't overlap.
- **Shapes by role** (the user asked for "better box shapes"): `tall` = the brain (big block with a ridge line); `store` = three stacked drums (memory, ledgers, directories); `cards` = a deck of five thin slabs (tools); `slab` = wide flat hatched top (existing backend); `screen` = thin slab with an inset rectangle and text lines (surfaces: web chat, mobile, device); `gate` = box with a dark band (confirm/approval); `job` = hatched-top box (scheduled passes); `box` = everything else. Ghosts: dashed outline, no fill.
- **Labels** (the user asked for "better labelling"): a 1–2-letter code chip on the top face **and** a readable uppercase `short` name (≤14 chars) on a paper-coloured tag under the front corner of every structure. Ghost labels dashed/muted.
- New-in-chapter: pulsing dashed halo on the ground footprint (respect `prefers-reduced-motion`). Selected: top face tinted + chip inverted + label inverted.
- Packets: ink dot with paper stroke; label visible on chapters 1–N−1 (always) and on hover/selection in the last chapter; clicking pauses everything and opens the payload. Dot pauses briefly at each hop and longer at loop start.
- Inside view: steps laid diagonally (`gx 2+i·3.4, gy 2+i·0.6`, 2×2 footprint) with a single packet walking them; breadcrumb replaces the rail; `← Come back out`.
## Copy rules
Plain words from the person's side (per the glossary): "confirm card" not "HITL prompt". Structure names are nouns; `one` is a single sentence; `what` is for a non-engineer; `how` names files, tables, APIs with `<code>`; `cond` items are questions or tasks, one line each. Chapter ledes are one sentence; stories 2–3 sentences with one `<mark>` idea. Numbers carry their date and source.
## Progressive-disclosure recipe
1. You and X (2 structures, one hop each way) → 2. Knowing (context + memory) → 3. Doing (tools + backend) → 4. Asking first (gate) → 5. Learning (hooks, signals, log) → 6. Reflecting (nightly job) → 7. Many voices (characters + directory) → 8. Keeping it honest (evals, o11y) → 9. Later (ghosts) → 10. The whole system (flow picker). Adapt the nouns; keep the shape: each chapter adds ≤3 structures and one flow that only touches revealed structures.

View File

@@ -0,0 +1,53 @@
# Process and lessons
When to load: before your first feedback round, a deep dive, or when deciding docs layout — this is the session-by-session record the skill was distilled from, plus the README table, the subagent deep-dive pattern, cost-model habits, and things that bit.
From the session this skill was distilled from: one agent-architecture atlas, built and then reworked across several rounds of feedback.
## How the session actually went — and what to repeat
| Step | What happened | Repeat / avoid |
|---|---|---|
| Inputs | Fetched the vision doc; looked for a whiteboard photo (not attached — asked, moved on); the user forbade one prior-art branch mid-way | Ask which prior art is allowed; never assume |
| Runtime digest | A subagent read the framework's bundled docs against 13 concrete design questions and returned a ~2.5k-word primer with gotchas and a BYO list | Do this before proposing structure; cite the primer's gotchas in the atlas |
| Discussion | Proposed 7 structures mapped to runtime primitives; 7 sharp questions; user answered with one-liners | Take defaults where the user says "defaults are fine" and say which |
| v1 atlas | Whole system at once, 21 structures, two packet flows | Fine as a first draft — but expect "hard to parse" |
| Feedback 1 | "Make it easier via progressive disclosure"; "better box shapes/labelling" | Chapters + role shapes + labels — now the default |
| Text twin | "Keep a text version in context/ADRs" → CONTEXT.md (glossary only), 7 ADRs, SYSTEM.md generated from atlas data, README | Generate the text from the atlas data from day one |
| Question rounds | The user answered by structure; several "this is not a question", "I don't get this — give a concrete example", "this is a stupid question because…" | Explain before resolving; drop non-questions; thank and move on |
| Deep dive | Scope set by the user (two vendors + a simpler DIY); three researchers on one brief with a shared usage model; synthesis with a normalized $/user/month grid; two different model cost bases | Normalize costs so columns carry the same components; fetch prices live (a cached price was wrong by 33%) |
| Rejected proposal | The synthesis proposed a "truth table in Neon, vendor as index"; the user asked what it was, then rejected it as v0 state ("YAGNI"), and later noticed the doc still described it | After a rejection, sweep every file and rewrite — a banner is not enough |
| Brain swap | The user switched the underlying model choice after an earlier decision had already been written up | Sweep every mention of the old choice; re-run the cost model; note which conclusions flip (on a cheap brain, the memory vendor dominates cost) |
| Sprawl | The user: "you now have a ton of competing docs rather than coordinated" and "the atlas is great for me — but not if it's not up to date" | One source file in the repo, one build script, both views generated, README; rebuild + republish every change |
## Docs-folder table (copy into README.md)
| File | Role | Edit it? |
|---|---|---|
| `atlas/data.mjs` | Single source of truth: structures, flows, chapters, decisions, questions, cost model, prose | Yes |
| `atlas/template.html` + `atlas/build.mjs` | Rendering + generator | Presentation only |
| `atlas.html` | Built atlas; republished at the same URL after every rebuild | No (generated) |
| `SYSTEM.md` | Built text twin | No (generated) |
| `CONTEXT.md` | Glossary (domain-modeling convention) | By hand |
| `adr/` | Hard-to-reverse decisions | By hand |
| `research/` | Evidence | Append-only |
## Subagent pattern for deep dives
- Write one `BRIEF.md`: the port/interface we own, requirements that separate candidates, a usage model (scenarios × cadences × fleet sizes) and a fixed deliverable shape (sections, citations, return only a ≤250-word summary + grid + path).
- One subagent per candidate via `delegate_task`, in parallel, writing reports to files; the main agent synthesizes (fit table, normalized cost grid, verdict, per-question resolutions, "considered and rejected").
- Copy reports into the atlas home's `research/`; fold resolutions into `data.mjs` as `{q, r: '… (from the deep dive, date)'}`.
## Cost-model habits
- Always state: model price with fetch date and source, calls per turn, tokens per call (cached vs not), output tokens incl. thinking, turns/day scenarios, fleet sizes.
- Present at least two brain bases if the choice is open; the memory/vendor share of the bill flips with the brain price.
- A nightly job can use a batch API at 50% off; say so.
## Things that bit
- The published file needs `<meta charset="utf-8">` at the top, or the arrows render as mojibake.
- `fitView` with a zero-size rect produced a negative scale once a paused-tab tween resumed; guard and cancel tweens.
- Some in-app browsers render `file://` as a static snapshot (no scripts, no fonts) — serve the folder with a static server and verify there, not from disk.
- Bash heredocs with large HTML/JS are brittle; write files with `write_file`, patch with small Python/Node scripts, and keep the data block as JSON-serializable objects so scripts can mutate it safely.
- Index badges count only open questions; keep IDs stable by never deleting a question — resolve it or mark it dropped.

View File

@@ -78,6 +78,7 @@ hermes skills uninstall <skill-name>
| [**simple-english**](/docs/user-guide/skills/optional/creative/creative-simple-english) | Rewrite text to ASD-STE100 Simplified Technical English. |
| [**sketch**](/docs/user-guide/skills/optional/creative/creative-sketch) | Throwaway HTML mockups: 2-3 design variants to compare. |
| [**social-media-content-calendar**](/docs/user-guide/skills/optional/creative/creative-social-media-content-calendar) | Plan multi-platform social campaigns: briefs to posting. |
| [**system-atlas**](/docs/user-guide/skills/optional/creative/creative-system-atlas) | Build explorable isometric architecture atlases as HTML. |
| [**tldraw-offline**](/docs/user-guide/skills/optional/creative/creative-tldraw-offline) | Drive and script tldraw offline canvases with an agent. |
| [**unreal-mcp**](/docs/user-guide/skills/optional/creative/creative-unreal-mcp) | Automate Unreal Engine editor scenes, actors, and renders. |

View File

@@ -0,0 +1,105 @@
---
title: "System Atlas — Build explorable isometric architecture atlases as HTML"
sidebar_label: "System Atlas"
description: "Build explorable isometric architecture atlases as HTML"
---
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
# System Atlas
Build explorable isometric architecture atlases as HTML.
## Skill metadata
| | |
|---|---|
| Source | Optional — install with `hermes skills install official/creative/system-atlas` |
| Path | `optional-skills/creative/system-atlas` |
| Version | `1.0.0` |
| Author | Harshyt Goel (adapted by Nous Research) |
| License | MIT |
| Platforms | linux, macos |
| Tags | `architecture`, `diagrams`, `isometric`, `documentation` |
| Related skills | [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram), [`excalidraw`](/docs/user-guide/skills/optional/creative/creative-excalidraw) |
## Reference: full SKILL.md
:::info
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
:::
# System Atlas Skill
An atlas is one data file (`data.mjs`) that renders two views: an **interactive isometric map** (a single self-contained `atlas.html` — hover to read, click to pin, go inside for steps, moving data packets you can inspect, chapters that reveal the system a few structures at a time), and a **generated text twin** (`SYSTEM.md`) with the decisions table, every structure, the flows, and the open questions by ID. The data file is the only thing anyone edits; both views rebuild from it. It sits beside a hand-written glossary (`CONTEXT.md`) and ADRs.
**Does:** interactive architecture maps with progressive disclosure, question tracking across feedback rounds, a generated text twin, and a repeatable update loop.
**Doesn't:** static one-off diagrams (use the architecture-diagram or excalidraw skill), finished systems that only need a README, or a single diagram for a PR.
## When to Use
Use whenever someone wants to discuss, design, review, or explain an architecture visually — "make an atlas", "map the system", "make the architecture explorable", "visualize the codebase/agent/pipeline so we can talk about it", "a diagram I can click around", "walk me through how it fits together" — or when an architecture discussion is producing a pile of open questions that need tracking across feedback rounds. Also use it to update an existing atlas after decisions change. Best when the system is new enough that vocabulary, decisions, and questions are still moving and there will be more than one feedback round.
## Prerequisites
- Node.js (any recent version; the build uses only `node:fs`, `node:path`, `node:url` — no npm install needed).
- A static server for verification (`npx serve` or `python3 -m http.server`).
## How to Run
```bash
mkdir -p <atlas home>/atlas
cp <skill>/assets/{template.html,build.mjs} <atlas home>/atlas/
cp <skill>/assets/data.example.mjs <atlas home>/atlas/data.mjs # then fill it in
node <atlas home>/atlas/build.mjs # writes ../SYSTEM.md and ../atlas.html
```
Every field of the data file is documented in `assets/data.example.mjs`.
## Quick Reference
| File | Role | Edit it? |
|---|---|---|
| `atlas/data.mjs` | Single source of truth: structures, flows, chapters, decisions, questions, prose | Yes |
| `atlas/template.html` + `atlas/build.mjs` | Renderer + generator | Presentation only |
| `atlas.html` | Built atlas; republish at the same URL after every rebuild | No (generated) |
| `SYSTEM.md` | Built text twin | No (generated) |
| `CONTEXT.md` | Glossary, one line per noun | By hand |
| `adr/` | Hard-to-reverse decisions | By hand |
| `research/` | Deep-dive evidence | Append-only |
## Procedure
Follow the order — each step was earned by a correction the first time round.
1. **Read the inputs before drawing.** The vision doc, the repo's existing surfaces, and whatever prior art the user allows (ask — they may forbid a branch or a source). If you will build on a framework, read its docs first; hand long docs to a subagent via `delegate_task` with your specific design questions and have it return a primer with gotchas and a "what it does not give us" list. Drawing before this produces boxes that don't map to anything real.
2. **Discuss before drawing.** Propose the structure in chat, mapped to the runtime's real primitives, and ask only the questions you cannot derive from the repo. Take defaults for the rest and say which.
3. **First atlas — the whole system.** Copy `assets/` into the atlas home (rename `data.example.mjs` to `data.mjs`), fill the data, build with `node`, publish. **Where the atlas home is depends on the repo's docs policy** — ask before committing anything. Docs-friendly repos: `docs/<system>/atlas/` in-tree. Repos that commit only ADRs and `CONTEXT.md`: put the atlas, `SYSTEM.md`, and `research/` in a git-ignored scratch dir and attach `SYSTEM.md` + research to the spec issue when published. (Committing the whole set once produced a 3,900-line docs PR and four review rounds reconciling three restatements of one design.) Load the design-md or architecture-diagram skill via skill_view for HTML-artifact guidance if useful; read `references/design-language.md` for the visual rules either way.
4. **Progressive disclosure.** A whole system at once reads as noise. Ten-ish chapters; each adds at most three structures and runs one small flow that only touches revealed structures; the last chapter shows everything with a flow picker. Unrevealed structures stay in the index, dimmed, with their chapter number. Panels are summary-first: one sentence, then *Read more* and *Steps* folded.
5. **Shapes and labels.** Letters on boxes are not enough. Give each role a shape and put a readable name label on the canvas under every structure — see design-language.
6. **Text twin.** `CONTEXT.md` is a glossary and nothing else (the nouns, one line each); ADRs only for decisions that are hard to reverse, surprising without context, and the result of a real trade-off — these two are the in-tree pieces. `SYSTEM.md` is generated and `research/` holds evidence; both live with the atlas (scratch dir or `docs/`, per step 3). Don't open issues unless asked.
7. **Feedback by question ID.** Every question is `Q-<code><n>` with a state: open (a string), resolved `{q, r}` (answer + date), or routed `{q, to}` (handed to a named next step). Record the user's words. If they call something "not a question", drop it; if they say "I don't get this", explain with a concrete example *before* resolving. After each round: rebuild, republish, update memory.
8. **Deep dives feed back.** Research with subagents (`delegate_task`) against one shared brief (the interface we own, the requirements that separate candidates, a usage model for cost, a fixed deliverable shape). Write a synthesis with a normalized cost/fit grid. Fold resolutions into the data as `{q, r: '… (from the deep dive, date)'}`. If the user rejects a proposal, sweep *every* file and rewrite — a banner on top of a stale section is not enough.
9. **Keep it current.** One source, rebuild and republish after every change, never hand-edit generated files, and leave a `README.md` in the docs folder explaining the set (table in `references/process-and-lessons.md`).
## Publishing
`atlas.html` is one self-contained file — no build step, no external assets beyond a Google Fonts stylesheet. Serve the folder with any static server (`npx serve`, `python3 -m http.server`) and hand over the URL, or let the repo's pages host serve the committed file. One URL, republished after every data change, never a second copy. If you keep a stable published URL, put it in `META.artifactUrl` so `SYSTEM.md` links to it.
## Pitfalls
- Keep `<!doctype html>` first and `<meta charset="utf-8">` immediately after — otherwise quirks mode and mojibake arrows.
- The renderer rebuilds its whole scene on every draw: a stray `render()` in a hover handler detaches the element under the cursor and the browser stops synthesising clicks — the map looks perfect in a screenshot while nothing responds.
- Some in-app browsers render `file://` as a static snapshot; verify via a static server, not from disk.
- Never delete a question — resolve or mark it dropped, so IDs stay stable.
- After every decision, grep the outputs for stale words (`pending`, the old model name, the rejected design) — the person reads everything.
- Large HTML/JS via shell heredocs is brittle; use `write_file` and keep the data block JSON-serializable.
## Verification
- `node <atlas home>/atlas/build.mjs` exits 0 and writes both `SYSTEM.md` and `atlas.html`.
- Syntax-check the built script (`new Function(js)`), then open the served page in a real browser at ~1280×800; check a first chapter, a middle chapter, the last chapter, an inside view, and the light theme.
- Click a structure and confirm the panel says **pinned** and offers *Go inside*; click a packet dot and confirm the payload opens.
- Every structure has `one`, `what`, `how`, a `short` label, a role `kind`, and its questions; ghosts are marked; chapters exist with per-chapter flows; the last chapter is the whole system.
- `SYSTEM.md` carries the decisions table, the question index with IDs and states, and the "how this file is maintained" footer.
- Project memory records the atlas URL, docs paths, locked decisions with dates, what the user rejected and why, and the next step.

View File

@@ -374,6 +374,7 @@ const sidebars: SidebarsConfig = {
'user-guide/skills/optional/creative/creative-simple-english',
'user-guide/skills/optional/creative/creative-sketch',
'user-guide/skills/optional/creative/creative-social-media-content-calendar',
'user-guide/skills/optional/creative/creative-system-atlas',
'user-guide/skills/optional/creative/creative-tldraw-offline',
'user-guide/skills/optional/creative/creative-unreal-mcp',
],