diff --git a/README.md b/README.md
index 26800ef..ad645df 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ assets. Working conventions and the traps that break this site silently are in
/talks/ talks index — the descriptions live here
/talks/mental-model/ deck: The Mental Model
/talks/essential-complexity/ deck: Essential Complexity
+/surfaces/ every place the model is published and how each is made — reads the same model.json as /model/
/team/ the two profiles and the eight seats they hold — reads the same model.json as /model/
/timeline/ the experiences as a ledger — reads the same model.json as /model/
```
@@ -28,7 +29,7 @@ works from `file://` as well as a local server.
npm install && npx playwright install chromium
npm run serve # python3 -m http.server 8000
npm run verify # Playwright DOM assertions against all four pages, plus the sitemap
-npm run og # regenerate the nine 1200×630 og:image share cards
+npm run og # regenerate the 1200×630 og:image share cards
npm run og:check # do those cards still show the pages they were rendered from?
npm run test:og # unit tests for the card recipe the check compares
npm run pdf # regenerate both decks' PDF fallbacks
@@ -37,7 +38,7 @@ npm run model # fetch the pinned model and write model.json — needs t
npm run model:check # is model.json still what that commit parses to?
npm run pages # render model.json into every derived page region
npm run pages:check # do those pages still match model.json?
-npm run test:build # unit tests for the three renderers
+npm run test:build # unit tests for the renderers
npm run sitemap # date each sitemap URL from its page's last commit — run before committing a page
npm run sitemap:check # are those dates still what git says?
diff --git a/build/jsonld.mjs b/build/jsonld.mjs
index bbf9b21..ed120c9 100644
--- a/build/jsonld.mjs
+++ b/build/jsonld.mjs
@@ -23,7 +23,7 @@ const SITE = "https://blust.ch";
// Every page whose graph defines the person, rather than merely pointing at them.
const PAGES = ["index.html", "ideas/index.html", "model/index.html", "principles/index.html",
- "privacy/index.html", "talks/index.html", "team/index.html", "timeline/index.html",
+ "privacy/index.html", "surfaces/index.html", "talks/index.html", "team/index.html", "timeline/index.html",
"talks/mental-model/index.html", "talks/essential-complexity/index.html"];
// The person is the profile whose name is the root's — the company of one — and the addresses
diff --git a/build/note.mjs b/build/note.mjs
index c1a707f..a73c7ee 100644
--- a/build/note.mjs
+++ b/build/note.mjs
@@ -1,5 +1,5 @@
// The one sentence that says why a generated region does not translate, and the only copy of
-// it. Two renderers write it — the principles page and the team page — and a note explaining
+// it. Every renderer that writes a generated page's note takes it from here, and a note explaining
// why a page does not translate is exactly the note that must not say two different things on
// two pages.
export const NOTE_EN = "Generated from the model, so the words below are its own — and in the one " +
diff --git a/build/pages.mjs b/build/pages.mjs
index e86754e..ddfb295 100644
--- a/build/pages.mjs
+++ b/build/pages.mjs
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { writePrinciples } from "./principles.mjs";
import { writeJsonLd } from "./jsonld.mjs";
import { writeTeam } from "./team.mjs";
+import { writeSurfaces } from "./surfaces.mjs";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const { repo, commit } = JSON.parse(fs.readFileSync(path.join(ROOT, "source.json"), "utf8"));
@@ -30,7 +31,7 @@ if (data.commit !== commit) {
}
const check = process.argv.includes("--check");
-const RENDERERS = [writePrinciples, writeTeam, writeJsonLd];
+const RENDERERS = [writePrinciples, writeTeam, writeSurfaces, writeJsonLd];
const stale = RENDERERS.flatMap((write) => write(data, { check }));
diff --git a/build/renderers.test.mjs b/build/renderers.test.mjs
index 4e4098e..38a8b85 100644
--- a/build/renderers.test.mjs
+++ b/build/renderers.test.mjs
@@ -252,7 +252,8 @@ test("the board carries one details per seat, with its slug as an address", () =
test("the note that says why a region does not translate has one home", () => {
const princ = fs.readFileSync(new URL("./principles.mjs", import.meta.url), "utf8");
const team = fs.readFileSync(new URL("./team.mjs", import.meta.url), "utf8");
- for (const [name, src] of [["principles.mjs", princ], ["team.mjs", team]]) {
+ const surf = fs.readFileSync(new URL("./surfaces.mjs", import.meta.url), "utf8");
+ for (const [name, src] of [["principles.mjs", princ], ["team.mjs", team], ["surfaces.mjs", surf]]) {
assert.match(src, /from "\.\/note\.mjs"/, `${name} does not import the note`);
assert.ok(!/Generated from the model, so the words below/.test(src),
`${name} carries its own copy of the note`);
@@ -338,3 +339,73 @@ test("the board is followed by what each phase is, in the model's own words", ()
const block = html.slice(html.indexOf('
'));
assert.ok(!/data-de/.test(block), "a phase tagline carries a translation it should not");
});
+
+// ── the surfaces lineage ──────────────────────────────────────────────────────────────
+import { writeSurfaces, makersOf, hostOf } from "./surfaces.mjs";
+
+// One written surface and three built by two repositories, deliberately not the real model:
+// this asserts the grouping, and the real model's shape is asserted by pages:check and verify.
+const surface = (slug, name, fields) => ({ id: `surfaces/${slug}`, type: "surface", name, tagline: "t.",
+ path: `model/surfaces/${slug}.md`, fields: { source: "Local", ...fields }, sections: [] });
+const SURFACES_FIXTURE = {
+ ...FIXTURE,
+ repo: "someone/a-model",
+ entities: [
+ surface("zine", "Zine", { production: "written", url: "https://www.example.org/zine/" }),
+ surface("site", "Site", { production: "built", "built-by": "https://github.com/someone/zz-site", url: "https://example.org" }),
+ surface("feed", "feed listing", { production: "built", "built-by": "https://github.com/someone/aa-server", url: "https://example.org/feed?x=1" }),
+ surface("api", "API server", { production: "built", "built-by": "https://github.com/someone/aa-server", url: "https://api.example.org/" }),
+ ],
+};
+
+function renderSurfacesInto(fixture) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-surfaces-"));
+ fs.mkdirSync(path.join(dir, "surfaces"));
+ fs.writeFileSync(path.join(dir, "surfaces/index.html"),
+ "
t
\n\n\n" +
+ "
The surfaces
\n\n");
+ writeSurfaces(fixture, { root: dir });
+ return fs.readFileSync(path.join(dir, "surfaces/index.html"), "utf8");
+}
+
+test("makersOf puts the hand first, then each build by repository", () => {
+ assert.deepEqual(makersOf(SURFACES_FIXTURE).map((m) => m.key),
+ ["hand", "https://github.com/someone/aa-server", "https://github.com/someone/zz-site"]);
+});
+
+test("makersOf groups every surface under the maker its own fields name, sorted by name", () => {
+ const m = makersOf(SURFACES_FIXTURE);
+ assert.deepEqual(m.map((g) => g.surfaces.map((s) => s.name)), [["Zine"], ["API server", "feed listing"], ["Site"]]);
+ assert.equal(m[1].repo, "someone/aa-server");
+});
+
+test("a surface the page cannot place is an error, not a node left out", () => {
+ const f = structuredClone(SURFACES_FIXTURE);
+ f.entities[1].fields.production = "generated";
+ assert.throws(() => makersOf(f), /production this page does not draw: generated/);
+ const g = structuredClone(SURFACES_FIXTURE);
+ delete g.entities[1].fields["built-by"];
+ assert.throws(() => makersOf(g), /is built but names no built-by/);
+});
+
+test("hostOf shows an address without its scheme, www, query or trailing slash", () => {
+ assert.equal(hostOf("https://www.example.org/zine/"), "example.org/zine");
+ assert.equal(hostOf("https://example.org/feed?x=1"), "example.org/feed");
+});
+
+test("each surface is a button with its slug as an address, under its maker", () => {
+ const html = renderSurfacesInto(SURFACES_FIXTURE);
+ assert.equal((html.match(/class="ln-s"/g) || []).length, 4);
+ assert.match(html, /
a-model<\/div>/);
+ assert.ok(html.indexOf('id="zine"') < html.indexOf('id="api"'), "the hand's surfaces do not come first");
+});
+
+test("the surfaces page missing either marker is an error, not a page half-generated", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rb-surfaces-"));
+ fs.mkdirSync(path.join(dir, "surfaces"));
+ fs.writeFileSync(path.join(dir, "surfaces/index.html"),
+ "\n");
+ assert.throws(() => writeSurfaces(SURFACES_FIXTURE, { root: dir }), /surfaces-note:start/);
+});
diff --git a/build/surfaces.mjs b/build/surfaces.mjs
new file mode 100644
index 0000000..b78dbb5
--- /dev/null
+++ b/build/surfaces.mjs
@@ -0,0 +1,124 @@
+// Renders the lineage — the model, whoever makes each surface, and the surfaces — into
+// surfaces/index.html from the artifact.
+//
+// The nodes are generated for the reason the team board is: a crawler and an assistant read them
+// without running anything, and pages:check fails the moment they fall behind model.json. The
+// lines between them are not. They are drawn by the page from where the nodes landed, because
+// only the browser knows that, and they carry nothing the nesting of the lists does not already
+// say. The cards are fetched on demand, as on /team/.
+//
+// No line is typed. A surface sits under the maker its own `production` and `built-by` name, so
+// a surface that changes how it is made moves without anyone editing this file.
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { NOTE_EN, NOTE_DE } from "./note.mjs";
+
+const HERE = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
+const START = "";
+const END = "";
+const NOTE_START = "";
+const NOTE_END = "";
+const GITHUB = "https://github.com/";
+
+const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+
+// What a visitor reads as the address: host and path, without the scheme or a trailing slash,
+// the way the card writes a link out of the model.
+export const hostOf = (url) => String(url).replace(/^https?:\/\//, "").replace(/^www\./, "")
+ .replace(/\?.*$/, "").replace(/\/$/, "");
+
+// The makers in the order the drawing stacks them: the hand first, because a person is the one
+// maker nothing re-runs, then each build by the repository's name. Inside a maker, surfaces sort
+// by name in English, one rule a reader can predict.
+export function makersOf(data) {
+ const surfaces = data.entities.filter((e) => e.type === "surface");
+ if (!surfaces.length) throw new Error("the model holds no surface; the page would draw nothing");
+ const byKey = new Map();
+ for (const s of surfaces) {
+ const f = s.fields || {};
+ let key;
+ if (f.production === "written") key = "hand";
+ else if (f.production === "built") {
+ if (!f["built-by"]) throw new Error(`${s.path} is built but names no built-by`);
+ if (!f["built-by"].startsWith(GITHUB)) throw new Error(`${s.path} is built by something that is not a repository: ${f["built-by"]}`);
+ key = f["built-by"];
+ } else throw new Error(`${s.path} has a production this page does not draw: ${f.production}`);
+ if (!f.url) throw new Error(`${s.path} names no url`);
+ if (!byKey.has(key)) byKey.set(key, []);
+ byKey.get(key).push(s);
+ }
+ return [...byKey.entries()]
+ .sort(([a], [b]) => (a === "hand" ? -1 : b === "hand" ? 1 : a.localeCompare(b, "en")))
+ .map(([key, list]) => ({
+ key,
+ hand: key === "hand",
+ repo: key === "hand" ? null : key.slice(GITHUB.length),
+ surfaces: list.sort((a, b) => a.name.localeCompare(b.name, "en")),
+ }));
+}
+
+const slug = (s) => s.id.split("/").slice(1).join("-");
+
+// The same two natures /team/ tells apart by form, never by a second hue: the pen is filled
+// because a person holds it, the build is outlined because a machine runs it. The symbols are
+// in the page.
+const MARK_HAND = '';
+const MARK_BUILD = '';
+
+function render(data) {
+ const makers = makersOf(data);
+ const out = [];
+ const name = String(data.repo || "").split("/")[1];
+ if (!name) throw new Error(`model.json names no repository to draw as the model: ${data.repo}`);
+
+ out.push(`
`);
+ out.push(` `);
+ out.push(`
The model
` +
+ `
${esc(name)}
@${esc(data.commit.slice(0, 7))}
`);
+ out.push(`
`);
+ for (const m of makers) {
+ const who = m.hand
+ ? `The ownerby hand`
+ : `${esc(m.repo.split("/").pop())}build`;
+ out.push(`
`);
+ out.push(`
${m.hand ? MARK_HAND : MARK_BUILD}` +
+ `${who}
`);
+ out.push(`
`);
+ for (const s of m.surfaces) {
+ out.push(` `);
+ }
+ out.push(`
`],
+ [START, END, " ", () => render(data)],
+ ];
+ let next = page;
+ for (const [start, end, indent, body] of regions) {
+ const re = new RegExp(`${start}[\\s\\S]*?${end}`);
+ if (!re.test(next)) throw new Error(`${rel} has no ${start} … ${end} block`);
+ // The function form, so a `$&` in a surface's name is two characters and not a reference.
+ next = next.replace(re, () => `${start}\n${body()}\n${indent}${end}`);
+ }
+
+ if (next === page) return [];
+ if (check) return [rel];
+ fs.writeFileSync(file, next);
+ return [];
+}
diff --git a/card.js b/card.js
index aae4df8..e7d8192 100644
--- a/card.js
+++ b/card.js
@@ -69,17 +69,24 @@
return a;
}
function resolve(data, text){ for (var i = 0; i < data.entities.length; i++) if (data.entities[i].name === text) return data.entities[i].id; return null; }
- // Markdown inline code — the one span-level mark the model's fixed shape uses — becomes
- // code.mono; a URL inside a sentence becomes a link. Appended as nodes, never as innerHTML:
- // these strings come out of the data block, and the day a name contains a "<" an innerHTML
- // assignment would start parsing it as markup.
+ // Two span-level marks reach a card from the model's fixed shape. Inline code becomes
+ // code.mono, and bold becomes b, because a list such as a surface's What it shows writes every
+ // item as a bold name and a sentence. Code is split out first, so asterisks inside backticks
+ // stay characters. A URL inside a sentence becomes a link. Appended as nodes, never as
+ // innerHTML: these strings come out of the data block, and the day a name contains a "<" an
+ // innerHTML assignment would start parsing it as markup.
function inline(el, text){
String(text).split(/`([^`]+)`/).forEach(function(part, i){
if (!part) return;
if (i % 2) { el.appendChild(h("code", part, "mono")); return; }
- part.split(/(https?:\/\/[^\s)\]]+)/).forEach(function(bit, j){
- if (!bit) return;
- el.appendChild(j % 2 ? extLink(bit.replace(/[.,;:]+$/, "")) : document.createTextNode(bit));
+ part.split(/\*\*([^*]+)\*\*/).forEach(function(run, k){
+ if (!run) return;
+ var into = el;
+ if (k % 2) { into = h("b", null); el.appendChild(into); }
+ run.split(/(https?:\/\/[^\s)\]]+)/).forEach(function(bit, j){
+ if (!bit) return;
+ into.appendChild(j % 2 ? extLink(bit.replace(/[.,;:]+$/, "")) : document.createTextNode(bit));
+ });
});
});
return el;
diff --git a/docs/superpowers/specs/2026-09-17-surfaces-page-design.md b/docs/superpowers/specs/2026-09-17-surfaces-page-design.md
new file mode 100644
index 0000000..ab4bda7
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-17-surfaces-page-design.md
@@ -0,0 +1,110 @@
+# The surfaces page — design
+
+> A prose page after the team page, `/surfaces/`: every surface the model holds, drawn as a
+> lineage from the model at its pinned commit, through whoever makes each surface, to the surface
+> itself, each surface opening the card the model page draws. One release of the design package
+> first, for the nav order and for bold in a card; then one pull request here.
+
+Status: proposed. Decided on 2026-09-17 against three clickable prototypes generated from
+`model.json` at the commit `source.json` pins. Where this says "the prototype", it means variant
+B as Rob chose it.
+
+---
+
+## 1. What is true today, measured
+
+Read on 2026-09-17 in `model.json` at `db44174`, the commit `source.json` names.
+
+| Fact | Value |
+|---|---|
+| Surface files under `model/surfaces/` | 4 |
+| Of those, `production: written`, `production: built` | 1, 3 |
+| Distinct `built-by` repositories | 2 |
+| Surfaces carrying `## Projection rules` or `## Constraints` | 1, the written one |
+| Span-level marks `card.js` drew at design v0.61.0 | inline code only |
+| Items of a surface's What it shows written as `**Name** — sentence` | all of them |
+
+The last two rows decide part of the release: every card on this page would have shown each unit
+name wrapped in four asterisks.
+
+## 2. What was decided
+
+**The lineage, not the register and not the split.** Three figures were prototyped over one
+fixed title block. A register in the shape of the team board showed each surface's size as a
+row of marks, but read as a comparison of four things rather than as where they come from. Two
+columns, written against built, made the argument by their length only once the rules were open,
+and put every rule of the written surface into the page at rest. The lineage draws the claim the
+MCP server's own instructions make, that every surface a reader can reach derives from one
+model: every line starts at one commit. Rob picked it.
+
+**A surface sits under the maker its own file names.** Written surfaces sit under the owner, by
+hand; a built surface sits under the repository its `built-by` names. No line is typed, so a
+surface that changes how it is made moves without anyone editing the page. A `production` the
+page does not know, or a built surface with no repository, fails the build rather than dropping
+a node.
+
+**Written and built are told apart by form, never by color.** A filled pen for the hand and an
+outlined build mark, in `--c-firm` and `--c-mid`, the way `/team/` tells a human from an agent;
+and on the wires, a dashed line for the hand and a solid one for a build. `tokens.css` allows one
+hue at four brightnesses.
+
+**The nodes are generated, the wires and the cards are not.** `build/surfaces.mjs` writes the
+model node, the makers and the surfaces between `` and
+``, as nested lists, so a crawler reads the tree without running anything
+and `pages:check` fails when the page falls behind the model. The wires are drawn by the page
+from where the nodes landed, because only the browser knows that, and they say nothing the
+nesting does not. A card is rendered by `rbCard.render` when a surface is chosen, as a seat's is
+on `/team/`.
+
+**Choosing a surface lights its path.** Its two wires take `--c-path`, the token that names the
+way back to the root, the chosen node is pressed and its card opens full width under the
+drawing. Choosing it again clears the choice. The hash is the surface's slug, and arriving with
+one chooses it.
+
+**Surfaces follows Team in the nav.** Where the work is published comes straight after who does
+it. `ORDER` becomes Team, Surfaces, API, Ideas, Principles, Model, Timeline, Example, Talks,
+Billing, Privacy.
+
+## 3. The page
+
+Title "Surfaces — Robert Blust", with canonical, description, the `og:` block, `twitter:card`
+and the JSON-LD graph of `/team/`, its WebPage and BreadcrumbList named Surfaces. Its own share
+card at `/surfaces/og.png`.
+
+The page's own words are the title, "One model," light over "every surface." heavy; the tagline;
+the shared note from `build/note.mjs`; the section label "The surfaces"; a legend for the two
+wires; a hint shown until a surface is chosen; a caption; and a "How to read it" section of three
+rules: a surface is a page and not a place, written or built says where the rules are kept, and
+no line is typed.
+
+The German is made from this English after Rob has reviewed it, by the translator role. Until
+then the page carries no `data-de` of its own and its `verify` entry no `translates` spec.
+
+## 4. The phone
+
+Below 760 px the drawing becomes the tree it already is: the wires are hidden, and the nesting is
+drawn with rules on the left edge, dashed under the hand. A surface's address truncates with an
+ellipsis rather than widening its column. Measured at 360, 800, 1000 and 1280 px: nothing scrolls
+sideways.
+
+## 5. The design release
+
+v0.62.0, a minor. `verify/pages.mjs` gains Surfaces second in `ORDER` and the header contract's
+comment names the same list, so the header fence is v12. `card.js` reads bold as well as inline
+code, code first, so asterisks inside backticks stay characters.
+
+## 6. This repository
+
+Re-pin to v0.62.0 and sync, which moves `card.js` and the header fence on every page carrying it.
+Then the page, the renderer and its tests, the nav item on every page, the page in
+`build/jsonld.mjs`, the card in `og-recipe.mjs` and every card whose page's header moved, the
+page in `README.md`, the sitemap, and a `verify` entry with the prose-page battery plus
+`lineage`: every surface in the block is drawn under its maker, nothing is shown before a
+choice, choosing one draws its card without asterisks and lights two wires, and a hash lands.
+
+## 7. Not in this design
+
+The marks and the lineage do not move into the design package. The MCP Registry listing's
+pointer at the MCP server is not drawn, because the model states it only in prose. The glossary
+row for `surface` still describes a surface as a place no script writes, which stopped being true
+when built surfaces were listed; that is a conventions change, not this page's.
diff --git a/ideas/index.html b/ideas/index.html
index da51dab..1723c10 100644
--- a/ideas/index.html
+++ b/ideas/index.html
@@ -297,7 +297,7 @@
.brand .rb{fill:var(--c-mid)}
.brand b{font-weight:600; font-size:1.06rem; letter-spacing:-.01em}
.brand b span{color:var(--c-mid)}
- /* ─── header contract · v11 · shared ──────────────────────────────────
+ /* ─── header contract · v12 · shared ──────────────────────────────────
One row, three sites, and no stylesheet between them — a deck opens from file://, so
this is a copy, generated from @robertblust/design. Editing it here has no effect — the
next `npm run design` overwrites it. Change it in the package.
@@ -308,10 +308,10 @@
not that every site declares it the same way, and `mobileNav` asserts the outcome.
The contract:
- · order Team, API, Ideas, Principles, Model, Timeline, Example, Talks, Billing,
- Privacy, then the language control. A site skips what it does not have
- and reorders nothing. Read right to left, the switcher is at the edge
- and each step left is more the site's own subject. API was missing from
+ · order Team, Surfaces, API, Ideas, Principles, Model, Timeline, Example, Talks,
+ Billing, Privacy, then the language control. A site skips what it does
+ not have and reorders nothing. Read right to left, the switcher is at the
+ edge and each step left is more the site's own subject. API was missing from
this list for one release while the check enforced it; the test that
compares the two lists is what stops that happening again.
· baseline One line runs through the middle of every text in the row. The links
@@ -657,6 +657,7 @@