From 61b77b28b2b90b72595e1e8fe7f61dfd7d14b839 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Thu, 17 Sep 2026 11:58:25 +0200 Subject: [PATCH 1/8] The surfaces page is specified Rob chose the lineage from three prototypes generated from model.json: every surface drawn from the model at its pinned commit, through whoever makes it, to the surface, each opening its card. The spec records why that figure and not the other two, what the page generates and what it draws at runtime, the phone form, and the design release it needs first. Verified: the prototypes rendered at desktop and phone widths; this file is excluded from conventions-check with the rest of docs/superpowers. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-09-17-surfaces-page-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-17-surfaces-page-design.md 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. From 7f6a1c7977563835572400cb72c924de2069adf6 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Thu, 17 Sep 2026 11:58:25 +0200 Subject: [PATCH 2/8] The site takes design from the surfaces-nav branch The surfaces page needs Surfaces in the nav order and bold in a card, and both are in robertblust/design#84. This pins that branch so the page can be built and verified against it; the pin moves to v0.62.0 once the release is tagged. The sync moves card.js and the header fence's comment on every page that carries it, and nothing else. Verified: npm run design:check reports every file and fence in sync. Co-Authored-By: Claude Opus 5 (1M context) --- card.js | 21 ++++++++++++++------- ideas/index.html | 10 +++++----- index.html | 10 +++++----- model/index.html | 10 +++++----- package-lock.json | 6 +++--- package.json | 2 +- principles/index.html | 10 +++++----- privacy/index.html | 10 +++++----- talks/index.html | 10 +++++----- team/index.html | 10 +++++----- timeline/index.html | 10 +++++----- 11 files changed, 58 insertions(+), 51 deletions(-) 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/ideas/index.html b/ideas/index.html index da51dab..33b8e33 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 diff --git a/index.html b/index.html index bb74bd5..bf34747 100644 --- a/index.html +++ b/index.html @@ -282,7 +282,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. @@ -293,10 +293,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 diff --git a/model/index.html b/model/index.html index ca200fb..f9a3f16 100644 --- a/model/index.html +++ b/model/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 diff --git a/package-lock.json b/package-lock.json index 252a214..62a49d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "blust-ch", "version": "1.0.0", "devDependencies": { - "@robertblust/design": "github:robertblust/design#v0.61.0", + "@robertblust/design": "github:robertblust/design#surfaces-nav", "companygraph-meta-model": "github:companygraph/meta-model#v0.27.0", "pdf-lib": "^1.17.1", "playwright": "^1.63.0" @@ -35,8 +35,8 @@ } }, "node_modules/@robertblust/design": { - "version": "0.61.0", - "resolved": "git+ssh://git@github.com/robertblust/design.git#dc4a6dcd52a45cb042017c39033d630f9ffbdf6c", + "version": "0.62.0", + "resolved": "git+ssh://git@github.com/robertblust/design.git#fb741e7fd468c5b31b5b8d2dea67ee8d395123bc", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 9eeb6ac..5346a29 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "pin:check": "node pin-check.mjs" }, "devDependencies": { - "@robertblust/design": "github:robertblust/design#v0.61.0", + "@robertblust/design": "github:robertblust/design#surfaces-nav", "companygraph-meta-model": "github:companygraph/meta-model#v0.27.0", "pdf-lib": "^1.17.1", "playwright": "^1.63.0" diff --git a/principles/index.html b/principles/index.html index d614df1..e2862ee 100644 --- a/principles/index.html +++ b/principles/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 diff --git a/privacy/index.html b/privacy/index.html index 8936ffe..f6c2ca0 100644 --- a/privacy/index.html +++ b/privacy/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 diff --git a/talks/index.html b/talks/index.html index 3390c41..a134549 100644 --- a/talks/index.html +++ b/talks/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 diff --git a/team/index.html b/team/index.html index 8afb110..e4f87e1 100644 --- a/team/index.html +++ b/team/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 diff --git a/timeline/index.html b/timeline/index.html index e92516c..1b4e69c 100644 --- a/timeline/index.html +++ b/timeline/index.html @@ -301,7 +301,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. @@ -312,10 +312,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 From 6df0115597d7f4b08b9383c44e882bc2376df775 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Thu, 17 Sep 2026 11:59:18 +0200 Subject: [PATCH 3/8] Every surface is drawn from the model, on a page of its own /surfaces/ draws each surface the model holds under whoever makes it, the owner by hand or the repository whose build writes it, and every line starts at the commit the site pins. A new renderer writes the nodes as nested lists, so a crawler reads the tree without running anything and pages:check fails when the page falls behind; the page draws the wires from where the nodes landed and renders a surface's card when it is chosen. A surface whose production the page cannot place fails the build rather than dropping out of the drawing. Surfaces follows Team in the nav on every page. The page joins the JSON-LD renderer's list and the share cards; every card whose page's header moved is rendered again, and verify gains the page with a lineage check that reads each surface's place off the page against model.json. The page's own words are English only until they are reviewed; the German follows from the translator. Verified: npm run test:build, pages:check, og:check, design:check and conventions-check pass; npm run verify passes every page but the sitemap, which is regenerated in the next commit; no width from 360 to 1280 px scrolls sideways. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- build/jsonld.mjs | 2 +- build/note.mjs | 2 +- build/pages.mjs | 3 +- build/renderers.test.mjs | 73 ++- build/surfaces.mjs | 124 ++++ ideas/index.html | 1 + ideas/og.png | Bin 285720 -> 285905 bytes ideas/og.sha | 2 +- index.html | 1 + model/index.html | 1 + model/og.png | Bin 226623 -> 226747 bytes model/og.sha | 2 +- og-recipe.mjs | 1 + og.png | Bin 144441 -> 144837 bytes og.sha | 2 +- principles/index.html | 1 + principles/og.png | Bin 286924 -> 286990 bytes principles/og.sha | 2 +- privacy/index.html | 1 + privacy/og.png | Bin 201456 -> 201710 bytes privacy/og.sha | 2 +- surfaces/index.html | 1167 ++++++++++++++++++++++++++++++++++++++ surfaces/og.png | Bin 0 -> 177701 bytes surfaces/og.sha | 1 + talks/index.html | 1 + talks/og.png | Bin 124049 -> 124190 bytes talks/og.sha | 2 +- team/index.html | 1 + team/og.png | Bin 194204 -> 194298 bytes team/og.sha | 2 +- timeline/index.html | 1 + timeline/og.png | Bin 289224 -> 289358 bytes timeline/og.sha | 2 +- verify/check.mjs | 56 ++ 35 files changed, 1444 insertions(+), 14 deletions(-) create mode 100644 build/surfaces.mjs create mode 100644 surfaces/index.html create mode 100644 surfaces/og.png create mode 100644 surfaces/og.sha 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, /
  • /); + assert.match(html, /id="api" data-id="surfaces\/api" data-maker="https:\/\/github.com\/someone\/aa-server" aria-pressed="false"/); + 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..0ab850f --- /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(`
      `); + out.push(`
    • `); + } + out.push(`
    `); + out.push(`
    `); + return out.join("\n"); +} + +export function writeSurfaces(data, { check = false, root = HERE } = {}) { + const rel = "surfaces/index.html"; + const file = path.join(root, rel); + const page = fs.readFileSync(file, "utf8"); + + const regions = [ + [NOTE_START, NOTE_END, " ", + () => `

    ${esc(NOTE_EN)}

    `], + [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/ideas/index.html b/ideas/index.html index 33b8e33..1723c10 100644 --- a/ideas/index.html +++ b/ideas/index.html @@ -657,6 +657,7 @@