The design system shared by blust.ch, companygraph.io and guestgraph.io.
What it is made of, what each part looks like and where each part ships is mapped in docs/design-system, with the color roles and the faces the three brands share. This file is how the package delivers it.
If a visitor downloads it and every copy is the same, it is generated into the repository and committed. If only CI runs it, it is imported from the package. If a visitor downloads it but every copy legitimately differs, only its shape is shared — as an assertion, not as bytes.
The three sites are static, ship no external assets, and are served straight from their repository trees by GitHub Pages. So this package is never a runtime dependency of a published page. It is a devDependency that copies files into a site and lends that site's suite a few checks. A page may call a service, and one does: the chat's script posts to the chat host the site names on the tag, and only after a visitor has opened the panel and pressed send. It is the one runtime call a page of this family makes, it goes to a host the family runs, and it is written down here so that it stays the one.
A page that draws a stage names the file it draws and loads three scripts in this order — d3.v7.min.js, card.js, stage.js. The file is named by a link the stage looks for, <link rel="preload" as="fetch" href="…" data-stage crossorigin>, and a page that carries none throws a message naming that markup rather than drawing an empty figure. Until the file has arrived and the stage has drawn, the canvas shows three quiet dots in no language, and stage.js marks the stage drawn when it has; the head row is as tall as the history's slab from the first paint, since the history is built by the script after the page has painted and a row that grew then moved the whole stage. card.js before stage.js is not a preference either: card.js owns the reader that finds that link and fetches it, rbCard.data(who, cb), and stage.js calls it on load rather than only on the first click. A page that shows cards without drawing a graph reads the model with the same call and gets the same error message; that is why there is one reader and not one per page. A page that only shows cards, blust.ch's timeline, loads card.js alone and reads the same link itself. A page whose site serves the model's images says so on the same link, data-images="../images/", the folder syncImages from @robertblust/design/images writes beside model.json — the step owns that folder whole, removing what no entry names, and dir names another folder for a site that keeps an images/ of its own; a card then draws the picture of an entity that carries an image, named by where the entity sits, its address, since that is the name the pictures are published under, and a page that declares nothing draws the card without one. The stage keeps the same split: its canvas, its breadcrumb and its hash are built from an entity's address, and every edge, owner and lookup goes by its id, which is opaque once an instance carries stable ids. A hash may name either, and one naming an id is written back as the address it found, in place, so a hand-written link such as #decisions/2026-four-months and a cite that hands over a stable id land on the same card. A model written before entities carried an address has its path as its id, and everything here reads the id where the address is missing.
A page that draws a stage can also be embedded, which is how every other page opens the graph over itself. An address carrying embed beside stage=expanded makes theme-boot mark the page before it paints, and stage.css then hides the page by visibility and shows only the stage's dialog, filling the frame, since the dialog sits inside the page's own sections and a hidden ancestor would hide it too. The stage opens expanded, and its history control walks its own trail instead of the browser's history, so the visitor's Back on the page that embeds it stays theirs; on its own page the control moves the browser and nothing else. An embedded stage speaks to the page that holds it only in same-origin messages: it posts rb-graph-ready once it has focused and rb-graph-close for its × and Escape, and it takes rb-graph-focus with an entity's id or its address and rb-graph-look with the parent's theme and language. A site's model page needs nothing more than the stage markup it already carries; on companygraph.io that page is the home page, since data-model names /.
Everything a page opens over itself opens in the family's one modal, modal.js and modal.css, which the stage group copies beside stage.js. It holds one of two viewers: the graph, which on the model page is the stage itself, moved in by Expand and given back on close, and everywhere else the model page embedded as above; and a picture's full screen. It is drawn as the chat's terminal, from the --t-* colors tokens.css carries in a dark and a light set, at the model page's size and as a full-screen sheet on a phone, with a head bar holding a title, the viewer's own controls and a × whose note reads Close · Esc, in German Schliessen · Esc. The page behind is dimmed and does not scroll while it is open, and it gets its scroll position back on close, because a dimmed page says the modal has the attention. There is one at a time: opened while it is open, it replaces what it shows, as a node in a picture's full screen replaces the picture with the graph, so one close always ends it. Escape, the × and a click on the dimmed page close it, and the focus goes back to what opened it first. No page links either file: stage.js and chat.js fetch modal.js from beside themselves on the first open, and it links modal.css, so a visitor who opens nothing loads neither. The modal carries no link to the model page, since it is a frame for what it shows and knows nothing about where that lives; the embedded page keeps #stagemodal as the canvas that fills the frame.
Such a page has to be served over http rather than opened from disk, because fetch from a file:// page is blocked and the stage draws nothing without its data. npm run og starts its own server for that reason; looking at a page by hand needs one too.
A site takes the chat by naming chat in design.config.json's groups, as it does stage; npm run design then copies chat.js, chat.css, mermaid.min.js and the two license files into the site. A page that offers the chat loads one more script, chat.js, with the endpoint and the model page on its own tag, <script src="chat.js" data-chat="https://chat.blust.ch/chat" data-model="/model/">, last in the body and without defer, and links chat.css beside its stylesheet. A conversation the visitor had open comes back on the next page, and a deferred script runs after the browser may already have painted that page without it: the panel vanishes for a frame on every navigation. Last in the body, the script runs before the first paint, and the page is fully parsed by then, which is all a deferred one waited for. It still runs after every script before it, and a page may paint while it waits on them, so theme boot marks a page arriving with the chat open, data-chat-waiting holding the host, and chat.css paints the panel's empty frame and title bar at its size from the first paint; chat.js takes the mark off once the panel stands, and a frame whose script never comes goes after four seconds; a tag without data-chat shows nothing. The button, the panel and every sentence the widget writes are the package's, in both languages; a site adds nothing but the tag. The panel is drawn as the tooling's terminal, the one companygraph.io's /cli/ page plays: a title bar headed as the one modal's, the page's host where the modal puts its title, colors of its own in a dark and a light version, the --t-* tokens the modal reads too, following the page's theme rather than the system's as the page itself does, and Plex Mono, because a terminal is not a page surface; the page's tokens are defined again inside the panel from those colors, so the panel's rules and its pictures follow them. A fresh conversation opens on the page's own lockup, cloned from the a.brand in its header, which every site writes the same way, then a line naming the meta-model's core and the model's commit it answers from, each linked to exactly that on GitHub, a two-line hello naming the model, the notice under an amber rail, opening with the sentence that an AI answers and not a person, then where the message goes and the privacy page, and numbered menus; the lockup is typed in and the rest comes in over about a second, any key or pointer finishes it, and it stays at the top of the log once the conversation starts. A question prints at a prompt. An answer is held until the stream ends, while a spinner counts the seconds, and is then shown whole, with no animation: a card a step lighter than the terminal, with a green rail and headed with its tick, its text in the page's own face and dim like the page's prose, lists with dim markers, tables in mono with a column of numbers aligned right, its picture, and the cite line, with a line under it naming the commit the model was read at. The command line takes a bare number for that row of the menu standing last, /new and /clear to start over, /help for the keys, and ↑ for the last question; none of them reaches the chat host. The three questions offered under the last answer are kept with the conversation and come back with it, the same three and drawn at once, and so are the intro's picks; what the intro reads from the model file, the processes, the counts, the versions and the question titles, is kept for the tab, so the next page draws the intro whole before it paints. A picture once drawn is kept for the tab too, finished and linked, under its source and the colors it was drawn in, so an answer restored on the next page shows its picture at once rather than drawing it again; another theme or the modal's colors draw afresh. Enter sends, a phone keyboard's return key included, and a touch screen also shows a ↵ beside the prompt to tap; an Enter that confirms an input method's composition stays with the composition. A conversation runs as long as the visitor likes: the widget sends only its last seven turns, the tail the chat host reads anyway, so the model sees what the whole would show it and the request stays far under the host's 64 KB. Any element carrying data-chat-open opens the panel on click, as the button does. The line under an answer is the receipt for the rule that every claim comes from a tool's answer: it opens with the page's own icon, read from its <link rel="icon">, so a site that wants one there declares one in its head and adds nothing to the tag, and after each cited title stands GitHub's mark to the file at the commit the host serves; a page that declares no icon keeps the words "From the model". Where the chat host names the moment a limit lifts, the widget's refusal ends with it, in the page's language and the visitor's local time; a host that names none gets the plain sentence. A picture the chat host drew arrives as its own event and is drawn with the rest of the answer by the vendored Mermaid, fetched from beside chat.js only when a first picture arrives, each node a link to where its entity lives, or for a type to its schema's file; where the script cannot be fetched, the picture's source stands in. Every link into the graph on a page that loads chat.js, whether or not the page offers the chat, opens it over the page rather than leaving it: a name, a cite title or a node in the chat, a card on the timeline, a node in a page's own picture, a link in a post, anything whose address resolves to the model page with stage=expanded and a place after the hash. The model page itself moves its own stage, and the talk decks, which load no chat.js, keep their plain links. The graph is the model page, embedded as the paragraph on the stage above says, in the one modal, made on the first open and kept for the page's life, since moving a frame reloads it, and moved to the next entity by message after that; a modified click still opens the page in a new tab. A picture's Expand opens it in the one modal, its caption the title, in the terminal's colors, and there it behaves as a viewer: it opens fitted to the sheet, Ctrl or ⌘ with the wheel, a trackpad's pinch or two fingers on a phone zoom it, a drag or the plain wheel moves it, and −, + and Fit in the modal's head, or the keys -, + and 0, do the same; a drag never follows a node's link, and a still press does. A page may carry a picture of its own as well, written when its site builds, as the Processes page does: a figure with data-diagram and the picture as JSON inside it, which chat.js draws once it nears the screen, in the page's colors, with the same links and the same Expand, whether or not its tag names a chat. Where the site drew the picture when it built, as the model pages section says, the box arrives holding it and marked data-drawn, and chat.js leaves it as it is: no Mermaid is fetched, and a theme or the modal's colors reach it through the tokens it is drawn in.
A tag may also carry data-questions, a same-origin path to the site's own already-published model — blust.ch's /model.json, companygraph.io's /company.json — a file the site already publishes. Once the panel is shown, the widget reads it once and draws two menus into the intro, and the versions line from the file's core, commit and repo: core is what companygraph-meta-model's instance export writes from the vendored core's manifest, and a file without it names the model alone. The first, Try, holds the asks the chat is built for, each with what it brings back: the meta-model as a diagram, always, and two of what the model holds, picked at random: one of its processes as a flow, its organization as a chart where it holds a group, the landscape of one of its systems, one the model writes a connection on where it writes any, one of its use cases, what one of its products is held to where it holds a requirement, and a list of a kind the model holds at least three of as a table, so no row names what the model does not hold. The second holds three of the model's own questions, the name of entities whose type is question, picked at random, one from each of three kinds where the model groups them; where it also ranks its kinds, by rank on a question-kind, the first is of the kind it ranks first among those still open. Every finished answer offers three more as a numbered menu, leaving out any title the conversation has already asked, so a visitor always has a next question to hand. An answer that writes the exact title of an entity the conversation has not yet asked about offers, for the first two it writes, to be told more ("Tell me more about Adoption is not taxed"), leaving out the model's questions, which are offered as themselves. An answer whose cites are all of one type follows it too: the first cited entity's neighbors, then, as far as room is left, that type's schema, named by the first cited entity with its type after it ("Show me the schema of Owner (seat)"), both worded so the chat draws them as pictures, then a question that rests on that entity, or else on anything of its type, read from the Rests on edges of the same file; with no entity named, the schema comes first. A row the conversation already asked is filled from the others. What follows an answer is offered in the answer's language, which the chat host names on its last event, so a German question on the English page is followed in German, a model question in the site's reviewed German where data-questions-de holds it; an answer from a host that names none is followed in the page's language, as the rest of the panel always is. A site whose model holds no question offers neither sort of question, and a tag without data-questions asks nothing and offers the meta-model row alone. That read is same-origin and reaches no service the family runs, so the promise above — that nothing leaves for the chat host before a visitor presses send — holds for it exactly as it holds for the button and the notice. The same list links a question's title wherever an answer writes it, to that question in the model, since the chat reads the titles from its prompt and no tool answer brings them as names; an answer drawn while the panel is closed is linked once it opens, so the read still waits for the panel.
A tag may carry data-questions-de beside it, a same-origin path to the site's questions.de.json, which writeQuestionsDe from @robertblust/design/render/questions writes from the site's reviewed build/questions.de.json. It is read in the same read and under the same rules as data-questions, so nothing reaches the chat host before send. On a page whose <html lang> is de, every model question the intro and the follow-ups offer shows and sends its German, and a title the file lacks, or whose German is longer than the box takes, stays as the model writes it. A question asked in German counts as asked, so it is not offered again. design german questions <model.json> prints the titles the site's file has to hold, for the translator.
// package.json
"scripts": {
"design": "design sync",
"design:check": "design sync --check"
},
"devDependencies": { "@robertblust/design": "github:robertblust/design#v0.1.0" }The ref is a tag, not a range, for the same reason the sites pin "d3": "7.9.0" exactly: this package's bytes end up committed in the consuming repository, so the version should be a visible, reviewable line, not a range that can move under a lockfile refresh. npm ci records the resolved commit SHA in the lockfile, so installs stay reproducible. The repository is public, so installing it needs no token, no login and no npm account.
npm run design:check runs in CI after npm ci and before npm run verify.
Four modules under cards/, imported rather than generated. The rule at the top of this file decides that: a visitor never downloads any of them, only CI and a developer run them.
"scripts": {
"og": "node export-og.mjs",
"og:check": "node og-check.mjs",
"test:og": "node --test verify/og-recipe.test.mjs"
}| module | what it is |
|---|---|
cards/recipe |
sources, recipe, stampOf, state, stamp, and recipeFor(root) which binds them |
cards/check |
checkCards(recipeModule) — staleness and the dark-background check; returns a count |
cards/export |
exportCards({ chromium, recipe }) — the renderer; takes a chromium, never imports one |
cards/recipe-tests |
checkRecipe(recipeModule) — 32 shared assertions about a site's recipe |
decks/export |
exportDecks({ chromium, PDFDocument, root, decks }) — renders each deck to a 16:9 PDF fallback, leaves a PDF alone when every page of it came out as it was, and stamps the deck; takes a chromium and a PDFDocument, never imports either |
decks/recipe |
sourcesOf, recipe, stampOf, state, stamp, pdfOf, and the exporter's knobs, FRAME, LANGS, HIDE and SETTLE; root is always a parameter |
decks/check |
checkDecks({ decks, root }) — a stamp that is missing or stale and a PDF that is not on disk; returns a count |
A site keeps one file with real content — og-recipe.mjs, holding its REPO_ROOT, its frame, its hide rules and its card list — and three thin callers:
// og-recipe.mjs — the data stays here, and so does REPO_ROOT
export const REPO_ROOT = path.dirname(fileURLToPath(import.meta.url));
export const cards = [ /* this site's cards */ ];
export const { sources, recipe, stampOf, state, stamp } = recipeFor(REPO_ROOT);REPO_ROOT belongs to the site, and root is always a parameter here. A package module that works out where it is resolves inside node_modules; that shipped once already, as SITE_ROOT in verify/design.mjs, and cost a release to undo.
Bind through recipeFor, not export * from. Re-exporting the raw functions leaves root unbound and state() throws for the site's own callers. And the specifier carries no .mjs: the exports map has no suffixed entry, so …/cards/recipe.mjs raises ERR_PACKAGE_PATH_NOT_EXPORTED.
Copy each site's FRAME verbatim. The recipe hashes every key of a card, so one added key moves every og.sha in that repository. No two sites' frames are interchangeable: blust.ch and companygraph.io carry clipY and no deviceScaleFactor, guestgraph.io the reverse.
og:check and test:og run after npm ci. They import this package, which is not on disk before that. They still run before npx playwright install, because cards/check.mjs imports only node: builtins and needs no browser — a property asserted by importing it from a child process in a directory with no node_modules above it, rather than by grepping for imports.
A deck's PDFs carry a stamp too. npm run pdf writes pdf.sha beside each deck, the hash of the deck's page, every file of the repository the page names, mermaid.min.js where it names chat.js, the deck's entry and the exporter's own knobs, and checkDecks fails a deck whose stamp is not that hash. A site keeps its decks in one file, decks.mjs, which its export-pdf.mjs and its pdf-check.mjs both import, because a list kept twice is a deck added to one and not the other:
// pdf-check.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
import { checkDecks } from "@robertblust/design/decks/check";
import { decks } from "./decks.mjs";
if (checkDecks({ decks, root: path.dirname(fileURLToPath(import.meta.url)) })) process.exit(1);pdf:check runs where og:check does, after npm ci and before the browser is installed. The exporter compares each PDF it renders with the file on disk page by page and writes only one that differs, so a re-pin that moves a file every deck links commits the stamps and the PDFs that changed, not every PDF. The comparison is exact, and it holds for a slide with a drawn picture only because chat.js gives Mermaid a fixed seed.
Beside the whole files this package copies into a site, some of its blocks live inside a page: a fence is a pair of comment markers the page already carries — design tokens, header contract, title contract, lines, stage contract, prose reset, language, theme boot, theme, nav fit, prose footer, deck transport, deck lockup, deck fit, deck runtime, processes, surfaces, surfaces lineage, principles, index, link, home and model card — and the package owns everything between and including them, prose, version and CSS alike. lines is where a line's color keeps one meaning across the family: the title note's caveat in --c-flag, once per page, and a section's conclusion in --c-sum, a green beside the blue confidence ramp, at most once per section. A section's one point may be lifted out of its paragraph as a key line, .keyline, in --c-firm text with no line of its own, at most once per section and never beside a conclusion. A risk has no line yet, because --warn and --c-flag cannot be told apart by a reader with red-green color blindness, and a risk line waits for a second carrier. That is the whole reason a fence is not just another synced file: a synced file is copied whole, but a fenced block sits in the middle of a page the tool never fully owns, so the markers are what tell it exactly where its part starts and stops.
Editing a fenced block by hand does nothing that lasts. The next npm run design reads the markers, finds the package's own version underneath, and overwrites whatever is between them — the block is generated, not maintained in place. The block to change is the one in blocks/ in this repository; a page only ever carries a copy of it.
The design tokens fence is the one exception with a choice attached: its opening line carries a variant word, page or deck. A prose page closes its :root inside the fence, because nothing after it adds more tokens; a deck leaves the brace open, because the deck still has tokens of its own to declare once the shared ones end. That word is how the tool knows which shape to write back — it is read off the page, never guessed, so a page that forgets it or gets it wrong is an error, not a silent wrong render.
A site opts a page into a fence by putting the markers in it — there is no list of pages this package tracks or needs to know about. The absence of a fence is itself the only state that matters.
Taking a block out of the package works the same way in reverse: delete its fence from the page, and from then on the page owns that CSS outright. That is a visible decision, made in the page's own diff, to diverge from the shared copy — not a way to make a red design:check go quiet without deciding anything.
Five names are not fences: tokens.css, page.css, page.js, deck.css and deck.js are the same blocks blockFor already reads, written as five whole files a site copies exactly as it copies chat.css, rather than into a gap between two markers. lib/assemble.mjs reads a file's blocks in a fixed order, drops each block's own marker comment — a file carries no gap in a page for a marker to bound — and the two spaces a block carried inside a page's <style> or <script>, and opens with a comment naming the blocks it was assembled from, each with its version from versions.json, and saying that editing the file in a site does nothing, because the next npm run design overwrites it. The comment names no release, so a file's bytes change exactly when a block in it changes: a release that touches only the checks, the exporters or this README leaves the five files and every share-card stamp as they were, and a site's re-pin is its pin and lockfile alone. The release a site is on is read from its pin. tokenVersion reads the tokens version from a linked tokens.css's comment and holds it to the same number it holds a fenced page's design tokens marker to. deck.css needs no tokens of its own beyond the shared ones: what a deck page used to add after the design tokens fence, once the brace it left open is put back, turned out to be nothing at all, so tokens.css alone, always closed, is what a deck links too.
Two of the five take a variant, read from a site's design.config.json rather than from a page's own fence: "footer" chooses page.css's credit lockup, "credit" or "plain", and "lockup" chooses deck.css's, "one" or "two" — the same two words blockFor already reads off a fence's opening line, now read off the config instead. A file that needs a variant the config does not carry, or carries one this package does not know, is an error naming the file and the key, the same shape every other error in this package takes.
The files group carries these five names and is written through assemble() rather than copied from assets/; design sync --check compares the bytes assemble() would write against what is on disk, exactly the comparison it already makes for chat.css. Nothing here rewrites a page: a page still carries its fences until it is edited to link these files instead, and both keep working side by side until it is.
Two checks in verify/ read whichever shape a page carries. noFlash fails a page whose theme-boot fence sits after a <link rel="stylesheet"> or a <script src> as readily as after a <style> block, because page.css and page.js are exactly that once a page links them. fontsAvailable reads a linked stylesheet's font-family uses from its own served bytes rather than through its CSSOM, which a linked file refuses to hand back once it is opened over file:// — the document.fonts registry a browser builds from @font-face is unaffected either way, so a name self-hosted in tokens.css is exempted the same way whether the page still carries the fence or links the file.
A prose page adds three lines after its theme boot fence: a <link> to tokens.css, a <link> to page.css, and a <script src="page.js" defer>. A deck adds three of its own, in the same place: tokens.css, deck.css and deck.js. theme boot stays a fence — the one exception "Fences" names above — because it has to run before the first paint; everything that can block a page or repaint it once painted comes after it, in that order, so a page never shows the wrong theme for a frame and never shows unstyled content while page.css is still on the wire.
A page still declares the hook the files read, because a file has no way to see the page's own scope. Before its <script src="page.js">, a prose page's own inline script sets window.rbPage = { lang, applyLang } inside the closure where those two names actually live — the page's own lang variable and its own function for switching it, the same two names it declared when the block was fenced. A deck does the same with window.rbDeck = { talk, ui } before deck.js. What moves is where the value is read, not what the page has to compute.
A page taking the files drops its own trailing call to apply the language on load. page.js makes that call itself, on the language it decides from the hook, the same way the fenced block always did; a page that also calls it decides the language twice.
page.css reaches further than any one fence did: the principles, processes and surfaces rules it carries now sit on every prose page, not only the page that used to fence each of them. A page's own CSS can use a class name page.css also uses for something else — .legend, .card, .row and .board are common enough to collide — and a rule it never met before can now apply to it. A site moving to the files should compare computed styles on its own pages against what they were before the move, element by element, rather than look at the rendered page and trust that nothing changed; a rule that only nudges a margin does not show on a glance.
The cascade order changes, not only which rules exist. A fenced page interleaved its own CSS with each fenced block, in whatever order the page happened to write the two; page.css and deck.css sit in one <link> before any of the page's own <style>, so every shared rule now comes first and the page's own rules come after all of it. A page-own rule that used to lose to a fenced rule at equal specificity — because the page wrote it earlier in the cascade — can now win, since it is later in every page that takes the files. A site moving should look at its own rules on the selectors the shared blocks also touch: *, body, a, .mono, .shell, nav, nav a, .seg, footer, footer a, .title h1, .tagline, and the classes the model pages own (.legend, .card, .row, .board among them, per the paragraph above).
A German visitor can meet one frame of English. The fenced form called applyLang(lang) inline, in the page's own script, before the browser had painted anything; page.js makes that same call, but only once it has arrived, and a <script src> — deferred so it runs after parsing, like the other two links — is a request the page's own inline script never was. Between first paint and that request landing, a page whose markup is English-first (see "Crawlers", and the sourceLang check in verify/pages.mjs) shows English, for however long the file takes to arrive. This is the same reason theme boot stays a fence rather than becoming a file: a wrong theme is seen, instantly, as a flash of the wrong background; a wrong language is read, and a reader is already partway into the first sentence before a language they were not expecting registers. The two failures are the same shape and only one of them stayed a fence — accepted here because reading the wrong language for a moment costs less than seeing the wrong theme for one.
The shape creates one rule with no exception: a page and the files it links are never the same release, the file first. A file can drop a rule or rename a selector, but only across two releases — the file ships the new shape while every page still links the old one, and only once every page has taken that release does the next one remove what nothing links any more.
Principles, Processes and Surfaces are pages generated from an instance's model rather than written by hand, wherever a site carries them. The exports below do the writing and the fences below carry the result into the page; a page opts into any of them the same way it opts into any fence, by carrying its markers, and a page carrying none of them is untouched.
| export | what it writes |
|---|---|
render/note |
NOTE_EN and NOTE_DE — the one sentence, shared by the writers below it, saying why the region it sits in does not translate |
render/principles |
writePrinciples — the vision and the values into principles/index.html, each value with an id; takes an optional de and translates the page |
render/processes |
writeProcesses — the note, the head rail, one board per process with its legend, the phases in words and, where the site gives one, the process's picture into processes/index.html, between <!-- processes-note:start --> and <!-- processes-note:end --> for the note and <!-- processes:start --> and <!-- processes:end --> for the board |
pictures |
drawPictures({ chromium, root }) — draws the Processes page's pictures in the site's Chromium and writes each beside the page as processes/pictures/<process>.svg with its stamp |
render/surfaces |
writeSurfaces — the model, its makers and their surfaces into surfaces/index.html |
render/privacy |
writePrivacy — the visit, the keys the browser keeps and each processing activity with its data processors into privacy/index.html, in the surfaces lineage's markup; takes site, the host its root names, and an optional de that translates the model's words; pathOf and privacyStrings return the drawing and its strings without writing |
render/home |
writeHome — the vision and the values into a site's index.html, between <!-- vision:… --> and <!-- values:… -->; a value's last paragraph opens “I never” for a person or “We never” for a company, and one that opens otherwise stops the build |
render/german |
loadGerman(file) and strings(data) — the German of a model's words, held to its exact English, for any site |
render/ids |
writeIdPages — one redirect page per entity with a stable id at id/<uuid>/index.html; idPages returns the same pages without writing, and idUrl gives an entity's @id |
Each writer takes (data, { check, root }), and writeProcesses also takes order, the names of the processes to draw first, in that order, because core gives a process no rank and the order a page argues in is the site's to choose; the rest follow in the artifact's order, and a name the model does not hold throws. The page carries two regions for it, <!-- processes-note:start --> … <!-- processes-note:end --> and <!-- processes:start --> … <!-- processes:end -->, and the writer throws on a page that lacks either. It also takes diagram, a function from (data, process) to the process's picture as the chat host draws it, { title, mermaid, nodes }, which a site gives as processDiagram from companygraph-mcp-server/diagram over the artifact it builds from, so the page and the chat draw one process one way; each process then shows its picture right under its tagline, before its head rail, and a site that gives none draws none. The picture is drawn when the site builds, not in the visitor's browser: npm run pictures, a site script calling drawPictures with the site's own chromium, lets the site's chat.js draw each one with a placeholder on each token it reads, writes it back with var(--token) in their place, refuses a picture that paints a color no token names, and writes it as processes/pictures/<process>.svg beside a stamp of what went into it, its source and nodes and the site's copies of chat.js and mermaid.min.js. writeProcesses writes a picture whose stamp still matches into its box, marked data-drawn; one that does not match leaves the box empty for chat.js to draw, and in check it is named and the page reported, since only the drawing can bring it back and the check has no browser to draw with. So a site builds with npm run pages, then npm run pictures, then npm run pages again, and draws again whenever a re-pin moves chat.js or Mermaid. Each node links to the page that draws the site's model by its entity's address, since the host names a node by its id, which is a UUID once an instance carries stable ids and unreadable on hover or when copied; a node the model does not hold, or a model whose entities carry no address yet, keeps the host's id. That page is model, relative to the Processes page because a site's own markup carries no root-absolute link: ../model/ unless the site names another, as companygraph.io names ../, where its home page draws the company. writeHome also requires de, a function from an exact English string to its German, and heading, { en, de? } with {n} in each for the number word. On every board the seat that owns the process, as its file names it, is the first row. A seat names nobody and any number of profiles may hold it, so a row carries every holder: a seat held by several draws two marks, one behind the other, with a person's in front when a person is among them, and its label names them all. On the rail, profiles that hold exactly the same seats on a board are one entry that shares its count, so the rail's counts add up to the board's; a profile whose seats differ keeps its own entry and counts every seat it sits in. root is the site's own root, required rather than guessed from where the package sits, and a writer called without it throws the reason why; check reports the files it would change without writing them, as design sync --check does for the whole page.
writeIdPages(data, { check, root, origin, stage }) gives the JSON-LD @id of an entity, <origin>/id/<uuid>, a page to land on, because an @id a crawler keeps has to outlive a rename and the readable address does not. The site passes its origin, such as https://blust.ch, and stage, the path of the page that draws its model, /model/ on blust.ch and guestgraph.io and / on companygraph.io. Each page is English and noindex, and sends its reader at once, by a refresh and a fallback link naming the entity, to its place on the stage, ../../<stage>?stage=expanded#<address>, relative so a page served locally stays local. It names no canonical: a redirect is meant to vanish into its target, and a canonical beside noindex would send a crawler two signals.
A page is written only for an entity whose id is a UUID v7 and differs from its address: an entity the instance has not given a stable id carries its path as its id, and an id of any other shape never becomes a path. The writer owns id/ whole, so a page whose entity left the model is removed by npm run pages and named by pages:check, as a page that no longer matches is; it follows no symbolic link and refuses an id/ that is one. A site lists it in its build beside the other writers, and gives an entity's JSON-LD its @id from idUrl(entity, origin), which is null exactly where no page was written.
The pages stay out of sitemap.xml. They are redirects and not pages to index, and a sitemap URL is one the suite holds to a page the site checks and one design indexnow announces to the engines.
processes, surfaces and principles are each one page's own CSS and nothing else. link is the family's one link style, blue with a line only under the pointer, and rides in both page.css and deck.css so a page writes no link rule of its own. index is the list an index page draws under its title, on /talks/ and /blog/ alike, and is scoped to .index because it rides in page.css. home is the home pages' own sections — vision, values, latest writing, what is built now — scoped to .sec the same way, and carries blust.ch's values unchanged; the blog row it draws, .sec .index, stays that page's own. surfaces lineage and model card are behavior instead. surfaces lineage draws the lineage and holds Surfaces's card glue together, because the two share state: the card in the panel is whichever surface is chosen, and choosing one redraws the wires too. model card is the Processes page's card glue — the rows behind a board and the one Open-all over them. Both need a value the page declares above them in the same script, STAGE_PAGE, ending in /: the page that draws the model on the stage, where the links in a card go, a seat's on Processes and a surface's on Surfaces; model card also needs MODEL_CARD, the name card.js reports a failed read under, declared the same way.
The suite's half is verify/model-pages. It exports MODEL_PAGE_CHECKS, which a site spreads into its checks beside STAGE_CHECKS, and a page opts in with board: true on Processes or lineage: true on Surfaces, or privacyPath: true on Privacy. Neither check carries a name from any model: board reads each board's phase headings, rows and gates from the artifact the page names, through the same helpers writeProcesses uses, and lineage reads each surface's maker the same way. Both fetch the page STAGE_PAGE names and fail unless it draws the same artifact, because a card's links are written when it opens and nothing else could see a wrong target. privacyPath holds the privacy lineage to the stored items, activities and processors of the artifact the page names, an entry found by its id and its group, because one processor is drawn under each activity that names it. The sites' storageKeys check reads the stored items of the artifact /privacy/ names, with the chat opened, and fails on a key written that the model does not hold.
The sites' home check, beside the rest in verify/pages.mjs, reads a site's home page against its model: the values it lists, in the model's order, each pointing at a real anchor on /principles/; the vision's own heading; on a site that carries one, its newest post; and the Ask tile's ability to open the chat. It takes spec.home as { model, blog? }, the site's model file and, only where a blog exists, the blog index to read the newest post from — companygraph.io and guestgraph.io carry neither and skip that part. It sits ahead of seo, typography and translates in the shared checks object, and stacks the page to 390px for its own last step before restoring the desktop viewport the checks after it expect. A site that carries its own home check, as blust.ch does, must replace it with the shared one in the same pull request that re-pins design. CHECKS spreads as { ...pageChecks(), home }, and an overriding key keeps the shared key's position in the check order, not the position the site wrote its own check at; a site's own home, left in place, would then run first rather than last, ahead of seo, typography and translates, and it does not restore the viewport the way the shared check does.
Every substitution a fence makes comes from a value this package owns: the variant word from a fixed set lib/fences.mjs declares, the version from versions.json, a part from a file beside the block. Nothing comes from the site there — a fence's variant is read off the page's own opening line, never off the site's config. design.config.json names the groups a site takes, and, for the files group alone, the two words that choose a variant assemble() needs, "footer" and "lockup"; nothing else, and a key it does not name is an error that names the file and the key.
The storage keys are the family's. lang and theme, the words the address already carries, are what every page on the three origins reads and writes, and they are written into the blocks rather than chosen per site: a storage key is a promise to every visitor, it is one promise across three origins, and a promise the package makes is one no site can quietly make differently. Renaming a storage key starts every visitor over, so a rename is a release whose notes say so, never a site's diff.
FAMILY, the regex naming the three domains a language rides between, is exported from lib/family.mjs and the sites import it: a site that could set its own would be a site that could quietly stop carrying the language to one of its siblings.
Everything here is American English — core's R14 applied to a package whose comments are copied into sixteen pages on three domains, where the sites' own rule is that every word of a page is en-US. test/spelling.test.mjs scans what ships (blocks, lib, verify, cards, decks, bin, assets/stage.js, this file) against a list of British forms and fails on the first hit. It is a list, not a dictionary: a false positive is a word to add to its allow list, never a reason to reword a sentence. German inside a block belongs to the block's data-de or de: branch and is not English at all; the scanner sees so little of it that no exception has been needed.
The sites' translates check — the one shared check that presses DE and reads the German — is here in verify/pages.mjs beside the rest, so the German half of every page in the family is seen by the same code. After the toggle it also holds the German <title> and meta description to WRITING.md's German marks, because those are script strings the cold scan in typography never sees; typography holds the English pair the same way, since both live in the head outside the body it reads. A spec's shows and hides sample three strings a page, so after the toggle the check also walks every element carrying data-de and every one carrying data-de-aria and names any that kept its English: a translation the list never quotes, or one a script rewrote after the switch, fails here instead of shipping. A number slot is the one exception, an empty <span data-v> that the German value carries as well and the page's script fills after every switch: both sides are compared with every slot emptied, so a computed figure inside a translated sentence passes while English words around it still fail. The data-de-aria labels are applied by the language block itself, which watches <html lang> and writes the label for the language into every such element, the English captured on load as data-en-aria; a page adds nothing to its own switch to get it.
typography holds every German value a page carries cold to the marks of WRITING.md, to the informal plural and a bare count of the years, and to the refused forms the member's vendored conventions/GERMAN.md lists in its banned fence, matched in any case and as a whole phrase; an empty German value fails it too, because a section a German visitor cannot read passes every other check. A site without that file fails with a message naming the conventions release that brings it.
design german extract <page> prints a page's German values by id with their English, design german german <page> the same without the English for a role that must not read it, and design german apply <page> <edits.json> writes values back by id and refuses one that would break its attribute. The ids are the n-th German attribute in source order and js.title and js.desc for the page's de object. design german stale <base> <head> fails where an English edit between two commits left its German unchanged; a commit in the range that names the element in a trailer, German-unchanged: <file>#<id>, says the German is right on purpose. translates expects the German title and description the page's own de object declares where a spec names none.
A site tells crawlers about its own changes in two ways, and both come from here so they cannot disagree about which page changed. A page is the index.html its sitemap URL is served from, and it changed when that file did or when the data its <link data-stage> names did. The second half matters because a stage page's markup never varies: a new model reaches the visitor while the page's HTML stays the same, and companygraph.io's /example/ draws example.json, not the model.
design sitemap writes each URL's <lastmod> from git, the author date in UTC of the last commit that changed the page, and design sitemap --check fails when a date has moved. A crawler uses the date only while it stays accurate, and a date typed by hand is accurate on the day it is typed. A page edited and not yet committed is dated today, so the order is edit, npm run sitemap, commit both. The date is the commit's, not the merge's. The check refuses a shallow clone, where one commit would be every page's last change, so the site's verify job checks out with fetch-depth: 0, and it runs after npm ci because the command arrives from this package.
design indexnow <base> <head> sends the pages changed between two commits to IndexNow, which reaches Bing, Yandex, Seznam, Naver and Yep; Google does not take part. Only changed pages are sent, because an engine told about unchanged pages on every deploy learns to ignore the site, and a range that changes no page sends nothing. --dry-run prints the list and sends nothing.
What stays in the site is what differs per host: the key, a file at the root named for its own 32 hex characters and containing them, public by design and committed; and a workflow that runs when pages-build-deployment succeeds, not on push, so the engines never fetch the page being replaced. Every change reaches main as a merge commit, so the range is the merge's first parent to the merge.
# .github/workflows/indexnow.yml
on:
workflow_run: { workflows: [pages-build-deployment], types: [completed] }
workflow_dispatch:
inputs: { base: { required: true }, head: { required: true, default: main } }
permissions: { contents: read }
jobs:
indexnow:
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: "22", cache: npm }
- run: npm ci
- env:
BASE: ${{ inputs.base || format('{0}^1', github.event.workflow_run.head_sha) }}
HEAD: ${{ inputs.head || github.event.workflow_run.head_sha }}
run: npx design indexnow "$BASE" "$HEAD"A link either lands or it does not, and the checks above never ask: they forbid a shape of link or require a named one to be present. design links asks it of every link the site owns and fails the build when one does not land. design links --external asks it of every other link once a week and reports what it finds, because a dead page elsewhere is news and not a reason to stop a deploy.
What is read is what a visitor meets. The command crawls from sitemap.xml in a browser and follows every own page it reaches, so a deck linked from a talks index is read without the sitemap naming it. It reads every href and src, every url() in a stylesheet, and every absolute URL in a <meta content> and in JSON-LD — a JSON-LD @id such as https://blust.ch/#person identifies a node rather than an anchor, so its fragment is dropped and only its path is collected. On every page carrying a <link data-stage> it opens the cards: each Open all is pressed, each surface on Surfaces is clicked, each node of a stage is focused by its hash. The data files those pages name are read for every absolute URL string in them.
A link is own when it is relative or on the host the site's CNAME names, and it lands when the checkout holds its file, or a folder's index.html for a path naming a folder, with or without the closing /, since GitHub Pages redirects the one to the other. A link onto a host reserved for documentation — .example, .invalid, .test, .localhost, example.com, example.net, example.org — is skipped, like mailto:, since none of those names is ever a claim about a real site. A fragment on a page that loads stage.js must name a node in that page's data — an entity, the root, or a folder holding one — because a stage shown an id it does not hold draws its root and looks fine. A fragment anywhere else must name an element on the page after its scripts ran, or be #top, which a browser always finds. A page that plainly redirects, as the id/ pages do, is read from the checkout rather than loaded, since a browser leaves it before anything could be read off it: a <meta http-equiv="refresh"> in its head, outside a comment, a script or a noscript, with a delay of 0 and a url, on a page that is also noindex. Its links are the refresh's target and every href it carries, and they must land like any other, fragment included. Any other page with a refresh is loaded as every page is. A run that loads no page or finds no own link fails too, since a check that read nothing has not passed.
An external link is asked with HEAD, then with GET whenever HEAD does not say yes, ten seconds each, one request per host at a time and four at once, with one retry. It is broken on a 404 or 410 to the GET or a host name that does not resolve, and unverifiable on anything else, a 401, 403, 429, 999, a 5xx, a timeout or a refused connection, which is how some hosts answer every script. The run keeps one issue titled “Broken external links”: opened when something is broken, rewritten by every run, closed with a comment by the first run that finds nothing broken. It fails only when the check itself could not run.
Both modes drive the site's own Playwright, which the bin imports, and read the site served at --base, by default http://127.0.0.1:8000, where the site's CI already serves it. In ci.yml, after the step that runs npm run verify:
- name: Every own link resolves
run: npx design linksAnd a workflow of its own, written by hand as indexnow.yml is, since a group only copies page assets:
# .github/workflows/links.yml
name: links
on:
schedule: [{ cron: "0 6 * * 1" }]
workflow_dispatch:
permissions: { contents: read, issues: write }
jobs:
links:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: "22", cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: python3 -m http.server 8000 > /dev/null 2>&1 &
- env: { GITHUB_TOKEN: "${{ github.token }}" }
run: npx design links --externalThe last step needs no wait before it: the command waits for the server itself.
design serve serves the site from the current directory until it is stopped, on --port or on a port the system picks, and prints its address. It is the server the card and deck exporters start for themselves, and the one a site's pin verify list starts before npm run verify and design links, started as ./node_modules/.bin/design serve --port $PORT & so the PID the list stops is the server's own and not a wrapper's. A folder asked for without its closing / is redirected to it, as GitHub Pages does.
It replaces python3 -m http.server on the machine a person runs the list on. On macOS that server reset connections while a page was still loading, and a font or chat.js that never arrived failed a page with nothing wrong with it: two of eight full runs of companygraph.io's suite, where fifteen runs against this one failed none. CI on Linux has not shown the fault and still serves with Python.
stage.js and card.js are the shared files no deck loads — a deck draws static SVG and needs neither. Never link a deck to stage.js, card.js or stage.css. They are reached only by served prose pages, through a plain <link> and <script src>.
A release is a git tag and a GitHub Release, nothing more — there is no publish step. Every release still needs notes: Dependabot renders them into the pull request it opens in three repositories, and that pull request is the only thing telling someone there what changed.
A change to any synced file is at least a minor — it makes every site's committed copy stale. Before tagging, set version in package.json to the tag: design sync --check in every site compares the tag its pin names with the version of the package it installed and is red when they differ, so a tag made without moving the field turns three sites red on their next re-pin instead of drifting unread. A change needing a site edit beyond npm run design is a major. Dropping a file from a group is also a major — applySync never deletes an orphan a site already has.
assets/d3.v7.min.js is one such synced file, but it is also pinned as a real npm dependency in companygraph.io, whose own suite (verify/instance.test.mjs) asserts its committed copy matches its node_modules/d3. The two only stay consistent if d3 moves here first, tagged, and companygraph.io then takes that design release together with its own d3 Dependabot bump on one branch, running npm run design and re-vendoring by hand — a site edit beyond npm run design alone, which by the rule above makes a d3 bump a major here too, not a minor.
Three published sites pin this repository's tags, so a bad main is a bad release and a release is what the sites take. The protect-main ruleset requires a pull request and one green status check before anything lands, and it forbids deleting or force-pushing the branch.
The ruleset requires two checks, test and conventions / conventions, not CI. A ruleset names the job id. This repository's workflow is called CI and its single job is test — the reverse of the three sites, whose job is verify. Rename that job and the branch still looks protected while nothing ever reports again, which blocks every merge and hides the missing gate behind it. If the job is ever renamed, update the ruleset in the same change. The second check, conventions / conventions, is the shared job every member of the family runs, called from robertblust/conventions at the pinned tag.
Everything else about the ruleset is byte-identical to the three sites' own protect-main, deliberately: one shape to know, and a difference between them means one of them drifted rather than one of them is special. The context is the only field that legitimately differs, for the reason above. Repository admins can bypass, and all three merge methods are allowed, because that is what the sites do — so the ruleset stops an accident, not a decision.
Two conventions it therefore does not enforce, which hold anyway:
- Merge with a merge commit, never a squash. GitHub re-authors a squash to whoever
pressed the button, which quietly launders the commit author that the
includeIfblocks in~/.gitconfigexist to get right.allowed_merge_methodspermits all three; usemerge. - Do not push straight to
main. The admin bypass makes that possible for the one person who commits here. Open the pull request anyway — the status check is the point.