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..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(`
      `); + 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/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 @@