Shared conventions of the robertblust, guestgraph and companygraph organizations live in conventions/, vendored from robertblust/conventions at the release conventions.json names. Read them before writing or committing anything here.
conventions/WRITING.md— how we write: one voice, three registers, English and German.conventions/WORKING.md— how we work with git and GitHub.conventions/REPOSITORIES.md— the family: what each repository is and what pins what.conventions/PINS.md— what a member pins and how the family resync moves it.conventions/WRITER.md,conventions/TRANSLATOR.md,conventions/EDITOR.md,conventions/BACKREADER.md,conventions/GLOSSARY.md,conventions/GERMAN.md— the four roles that make a text, the terms they keep and the German they write.
Everything below this block is this repository's own. sh conventions/conventions-sync check says whether the copy matches the release, sync brings it to the release the pin names, and sh conventions/conventions-check holds this repository's own Markdown to WRITING.md, and sh conventions/conventions-format to its one form, which fix writes. Edit a shared file in robertblust/conventions, never here.
Robert Blust's profile page and its talks, self-contained, no bundler. What the pages are, the URL map and the commands live in README.md; this file is about the ways this site breaks silently.
npm install && npx playwright install chromium
npm run serve # → localhost:8000, every page
npm run verify # Playwright DOM assertions — the tests
npm run og # every page's 1200×630 share card, listed in og-recipe.mjs
npm run pdf # every deck's PDFsVerify by rendering, never by reading the diff. A passing npm run verify after any change to index.html, talks/index.html, or a deck is not optional — it is the only check that catches a page that parses fine and renders wrong.
-
The repository name is forced. GitHub only serves a user site at the domain root from a repository named exactly
robertblust.github.io. Renaming it, or forking under another name, losesblust.ch— Pages falls back to<user>.github.io/<repo>/, which is a different site at a different URL, silently. -
At Hostpoint, only
A,AAAAand thewwwrecord belong to this site. Leave the rest of the zone alone while chasing a Pages problem: it is the domain's mail, and mail fails where nobody looks. TheMXpoints at Google Workspace,SMTP.GOOGLE.COM, and threeTXTrecords carry its SPF, its DKIM key atgoogle._domainkeyand a DMARC policy whose reports go toinfo@blust.ch. Mail torobert@blust.ch, the address the model's identity card publishes, arrives through theMX, and mail sent from the domain is trusted through the other three. Touching any of them breaks mail with no error and no bounce anyone would notice — the failure shows up as mail that never arrived, days later, from someone who gave up asking. Two moreTXTrecords at the apex are not the site's either: one verifies the domain to Google, and thev=MCPv1key is how the MCP registry verifies the MCP server's name under this domain. -
A deck links shared files, and is no longer required to open from
file://. It used to be a single file with no bundler and no shared JS, because a deck had to present from a folder with no server running. That requirement is dropped:deck.cssanddeck.js, generated by@robertblust/designand copied here bynpm run design, are what a talk now presents from, and what gets sent to anyone is still the PDF, not the folder. A talk is normally read on the site, served.The relative paths this leaves behind —
../../tokens.css,../../deck.css,../../fonts/— happen to still resolve underfile://too, andverify'sopensFromFilestill opens each deck that way. What it actually covers isdeck.js's own runtime, not the chrome the linked files carry: measured against a copy of a deck withtokens.cssanddeck.cssboth unlinked, it still renders every slide, still reports a loaded font and a scaled canvas, and still throws no error — because slide rendering,fit()and the font-loaded check all run fromdeck.jsalone, with no dependency on either linked stylesheet. So it is afile://smoke test of the deck's own script, real coverage as far as it goes, and it is left armed for that; it does not check thattokens.cssordeck.cssresolved.
A talk is named in one place. talks/index.html is the only page that names a talk; the root index.html links to /talks/ and lists nothing. It used to carry a teaser copy of the list, which meant every new talk was two edits and the second was the one that got forgotten. That teaser is gone, and this note exists so nobody helpfully adds it back.
The root page links talks/ from its nav and from the line under the newest post, and neither needs touching when the talks change.
Not a style preference. The brief for this site is durability, and a Google Fonts link would be the one third-party dependency an otherwise self-contained site has — it also sends every visitor's IP to Google from a site carrying no privacy policy, which German courts have held to be a GDPR breach on its own. Every page falls back to the system sans stack. If a design pass ever wants a webfont, that is the conversation to have first, not a <link> to add and forgive later.
generate.py reads the decks directly, so the speaker notes are the single source for what is said — there is no separate script to keep in step. Clips cache on a content hash of voice, model and text, so editing one note regenerates exactly one clip and costs a few hundred characters rather than a full run.
./tts/generate.py --dry-run # what would be billed, and for which slides
./tts/generate.py # every deck
./tts/generate.py --deck mental-model --only 04 # one slide of one deckOne generator serves every talk rather than a copy per deck. The two copies of the PDF exporter this repository was assembled from had already drifted apart — different defaults, different comments — which is the argument for not repeating the pattern.
clipsSeenistrue, and that is now correct. It wasfalsewhile these decks had no audio, because browser speech synthesis was the only narration path and a viewer without installed voices needed the no-voice message to explain the silence. With clips present the optimism is right:clip.onerrorfalls back to the browser voice when a clip is missing, and reportingfalsewould refuse to play for a voiceless browser even though the recordings would play perfectly.- Measurements before mechanisms.
voice_settings.speedis accepted by the API and ignored byeleven_v3; audio tags and paragraph breaks move the speaking rate by a few percent. The numbers are in the generator's docstring. Real pauses would mean silence between separate clips, owned by the player. Don't re-litigate this by feel. - Audio is committed, not LFS. GitHub Pages does not resolve LFS objects — it would
serve the pointer text where the audio should be.
.gitattributesrecords why. - The length quoted is the one a visitor plays. Each deck is about ten minutes presented
live, but a visitor hears the narration: every clip plus the player's pause between slides
(
SETTLE_MSandREAD_MSindeck.js), rounded to the minute and stated per language on /talks/ and in each talk's README. The live figure was quoted until Sep 25, 2026, when the owner chose the one a visitor hears. Measure before quoting, and re-measure when a clip is regenerated. - Slide 00 speaks only its title. Its whole note is a stage direction about greeting the room, so cue-stripping leaves nothing but the heading — about two seconds against thirty for every other slide. That is honest to what was written; the greeting was never authored as spoken words. Writing one would regenerate four clips.
ELEVENLABS_API_KEY is the only credential this repository needs, and it lives in ~/.zshrc. Only an interactive zsh sources that file, so a tool shell starts without it — and so does a login shell, which is the surprising half. An empty variable is not evidence the key is missing. Pull it in for the one command that needs it:
export ELEVENLABS_API_KEY="$(zsh -ic 'printf %s "$ELEVENLABS_API_KEY"' 2>/dev/null)"
./tts/generate.py- Never print an environment variable's value. Not to check it, not in a debug line,
not buried inside a larger
echo. A transcript outlives the session, and a key that reaches one has to be rotated. ${VAR:-UNSET}prints the value whenever the variable is set. It reads like a set/unset probe and does the exact opposite. This is not hypothetical: writingecho "${VAR:+SET}${VAR:-UNSET}"put a live API key into a transcript and forced a rotation. Probe with${VAR:+SET}alone, or[ -n "$VAR" ] && echo set || echo unset— forms that can only ever emit a fixed string.- Never
evalan extraction from the shell profile. A bareexportwith no match prints the whole environment.
Not one link on this site opens in a new tab, outbound ones included — github.com, linkedin.com, 3ap.ch and likemagic.tech are no exception. A new tab takes away the visitor's back button, and every deck carries its own way out, so nothing needs one. noNewTab asserts it on every page.
The one exception, which this site does not use, is a link inside a slide. A presenter who clicks one mid-talk in the same tab loses the deck, and no back-button muscle memory saves that in front of a room. It keys on where a link sits, not where it points, so it needs no list of hrefs to maintain. Neither deck here has an outbound link in a slide; companygraph's has two, which is why the exception is written the same way in all three suites.
Each deck's transport bar has an All talks control on the far side of the divider, beside the language and notes buttons rather than beside play and next: one button away from those, a misclick mid-talk would leave the deck instead of skipping a slide.
The bottom-left corner carries two destinations, not one. The lockup goes to the landing page (../../) and Talks / Vorträge goes to the index (../) — the same place the transport control goes, which is the deliberate duplicate: the corner offers both levels of "out", and the corner is the one place nobody clicks by accident.
wayOut covers the index link and is satisfied by either it or the transport control; landing covers the lockup and nothing else does, because a relative ../../ is invisible to the links check and a dead one looks like a working deck until somebody clicks it.
Neither half is visible to the links check, which only inspects absolute http hrefs — a relative mental-model/ slips straight past it. That blind spot is what the sameTab and wayOut checks exist to cover, and it is why flipping any of this means editing a check and not just an href.
Both pages open with the rb mark left of Robert Blust, the same shape guestgraph.io uses. The mark is favicon.svg reproduced as inline SVG rather than <img src=...>, because a linked asset renders as a broken box under file:// — see No external assets above. It draws its colors from the CSS tokens instead of the favicon's hard-coded hexes, so a palette change moves both together; the favicon keeps its own hexes because it has to stand alone. verify/check.mjs asserts the inline mark on both pages.
Speaker notes are data-notes (English) / data-notes-de (German) attribute values, so anything that ends the attribute swallows the rest of the tag with it:
- Nested markup uses single quotes —
<em class='cue'>, neverclass="cue". - German quotes are guillemets,
«…»with‹…›inside — the Swiss form WRITING.md sets. They also cannot end an attribute: one straight ASCII"inside a note ends it early and dumps the rest of the note onto the slide, which is what the old „…“ rule existed to prevent. - Never put an HTML comment inside a start tag. The parser reads it as part of the
tag's attributes;
data-notesand everything after is lost. Comments go above the tag.
slides() scans for <section class="slide — the exact characters, not a parse. Anything inserted between the tag name and class makes a slide stop being a slide, silently:
<section data-say-title="no" class="slide title-slide"> <!-- invisible to the generator -->
<section class="slide title-slide" data-say-title="no"> <!-- correct -->Nothing errors. The deck still renders, the notes panel still works, and the only symptom is a clip that never gets generated — which looks exactly like a clip that was already up to date. ./generate.py --dry-run is what catches it: the slide count drops. Check it against the number of slides in the deck before assuming a quiet run means a cached one.
data-say-title="no" suppresses the spoken title for slides whose note already delivers the headline: the title slides, whose notes open by naming the talk, and any slide whose first spoken sentence restates its <h1>. Without it the voice reads the line, takes a beat, and reads it again. Which slides those are is a question for the decks, not for a note like this one — it used to say "all three title slides do", and that count was wrong in both directions while three slides repeated themselves. To find them, compare each <h1> against its cue-stripped note in both languages. And per the note above, the flag is matched by a substring test over the whole slide block, so writing it in a comment sets it on the neighboring slide. Explaining a flag must never set it.
Both decks originally used bare <em> for everything in the notes. Turning on narration turned every stage direction — pause here, lean in, make it personal — into something the synthesized voice read aloud along with the actual content. class='cue' marks a direction as silent; anything without it is spoken. Where a direction and spoken content shared one span, the fix was to split the span, not to pick a side — marking the whole thing drops the content, leaving it bare narrates the instruction.
spokenText() deletes em.cue elements outright before reading the rest of the note aloud. Two artifacts follow from that, and both are harmless, not bugs to fix:
- The sentence that follows a removed cue can end up starting lowercase (German nouns aside, this shows up more in English notes). A synthesized voice renders lowercase identically to capitalized, so nothing is heard wrong.
- Removing a cue mid-sentence can leave a verbless fragment behind it — see
essential-complexityslide 3, where stripping "Ich betone, dass ich das real gemacht habe:" leaves "kanonische Datenmodelle bei der UBS, eigene DSLs bei 3AP und LIKE MAGIC." with no verb of its own.
Both are harmless in practice because spokenText() always prepends the slide title before the note body — the title supplies the lead-in a stripped cue would otherwise have provided. Do not "fix" either symptom by inventing connecting words the author never wrote; that changes what is said, not just how it parses.
Each deck's <html lang> is en, and that is right, because the source markup is English: applyLang() collects every [data-de] element and captures each one's existing innerHTML as its data-en before anything is swapped. German is the translation carried in the attribute, not the other way round. So a crawler that never runs JavaScript sees English under lang="en", which is exactly what the file says. applyLang() then sets documentElement.lang to whichever language is showing.
Two consequences worth knowing before editing a deck: new translatable text needs only a data-de — writing a data-en by hand is redundant and will be overwritten on load — and verify's sourceLang check fetches the raw file and fails unless the static attribute reads en.
One rule, positional, with no exceptions to remember: every word of a page is American English, and the value of any attribute whose name ends -de is Swiss Standard German, the written German of Switzerland and not the dialect — data-de, and in a deck data-notes-de.
It reads as one line now because the attributes were made to agree. Notes used to invert the page's convention: data-notes held German and English was the suffixed data-notes-en, so the base attribute meant a different language depending on which pair you were reading. Design v0.25.0 swapped them, which is what leaves nothing here to remember.
It matters most to anything that rewrites text in bulk. Organisation inside a data-de is correct German, not a British spelling left behind, and renaming it produces German that is wrong in a way nothing here can catch: every DOM check reads the rendered page, and the rendered page is only ever one language. A sweep masks those two attributes and touches nothing else.
This is core's R14 resolved for a bilingual page. R14 says what the spelling is; this says where it applies.
Three more places carry German, and none of their names ends in -de. The UI.de object on every prose page and TALK.de in every deck hold the German <title> and description; a deck's UI.de also holds its control labels; and data-de-href on a talks index holds the German PDF's path, a URL rather than a sentence. A sweep that masks only the two attributes rewrites the first two, so mask the de: branches of those objects as well. The translates specs quote a few German strings in shows, and nothing else: the page's German title and description are read from its own de object. German is made by the pipeline of conventions/WRITING.md, and a pull request that edits English and leaves its German as it was fails CI's design german stale; where the German is right on purpose, a commit in the pull request says so in a trailer, German-unchanged: <file>#<id>, with the id from npx design german extract <file>.
<html lang> carries the bare language, en in the source and de after a switch, and the region rides in og:locale alone. The rule's name says where the spelling and the grammar come from, not what the attribute says: every check that reads the language compares against en and de, and a regional tag would tell a crawler nothing og:locale does not already.
A page generated from a pinned model shows the model's own words. A translation not held to its source would be a second thing to keep true, which is why companygraph.io's /model/ and /example/ and blust.ch's /model/ show the model's words in the one language it is written in, the German page showing them unchanged under lang="de", each page saying so in its own note while the site's words around them stay bilingual like everything else. blust.ch's /principles/ and the home page's vision and values carry the German of build/principles.de.json instead, looked up by the exact English, so a sentence reworded in the model finds no German and stops pages:check. Re-translating it starts from npm run german:strings, which prints every English string the two pages translate for the translator to work from. The chat's question chips follow the same rule: build/questions.de.json holds the German of every question title the model asks, made by the German pipeline and looked up by the exact English, npm run pages writes it to questions.de.json for the chat tag's data-questions-de, and a question added or reworded in the model stops pages:check until its German is made; npm run german:questions prints the titles for the translator.
translates is the check that clicks. Every other DOM check reads the page as it first renders, which is English; this one presses DE, asserts the German is there and the English is gone — body text, <title>, meta description and, on a talks index, the PDF link — then presses EN and requires the page back exactly as it was. It is shared, in @robertblust/design, and every page in PAGES declares a spec for it, because a page without one is a page whose German half no test has seen.
The rendered DOM is only ever one language. German lives in data-de as markup that does not exist until a visitor switches, so noNewTab, sameTab, links and internalLinks see the English half and nothing else.
That is not hypothetical. The privacy page's German credit kept target='_blank' — in single quotes, because it is nested inside an attribute — and survived both a source-wide strip of target="_blank" and the check itself. It was found by reading the source, not by a failing test.
noNewTab now parses every [data-de] value into a template and inspects the links inside it, reporting them with a [de] suffix. Any new link check must do the same. Two things follow when editing translated markup:
- nested markup uses single quotes, so a source-wide search for
target="_blank"misses it; - a translated link and its English original must agree about opening in a new tab. They are two separate attributes and nothing pairs them.
The kicker on the slide, the counter in the transport bar, and (for the reference deck this pattern comes from) the audio filename all agree on the same zero-based number. npm run verify's zeroBased check exists because these two numbers drift independently the moment one of them is hand-edited.
That deck is where the transport bar, the language toggle, and the narration scaffolding were worked out first. It lived in a repository of its own, guestgraph/talks, until the talks were merged into the site they are served from — the same move companygraph.io made, and the same one this repository has always had: decks are folders under the site, not a second repository copying its chrome. Duplication across these decks and that one is deliberate, not an oversight to fix later — a shared runtime between repositories would mean fetching deck.js across an origin, which no site here does: each pins and copies its own. Port a fix by hand; do not link the two.
Type, color and chrome are shared across blust.ch, guestgraph.io and companygraph.io, and the sharing is a copy rather than an import: each site pins @robertblust/design by tag and npm run design writes what it names into the repository, so nothing is fetched across an origin at request time. It used to have to be a copy pasted into every page's own <style> and <script>, fenced by markers, because a deck had to open from file:// with nothing to import. That requirement is dropped: five names — tokens.css, page.css, page.js, deck.css and deck.js — are whole files now, written once at the repository root. A prose page links or loads three of them, tokens.css, page.css and page.js; a deck links or loads the other three, tokens.css, deck.css and deck.js. Neither is a new pattern for a prose page: it already linked chat.css the same way. A deck never linked chat.css — the chat widget is a prose-page feature — so for a deck this is the first file it has ever linked rather than fenced.
The copies have a source now. They are generated from @robertblust/design, which this repository pins by tag, and npm run design writes them. What that changes about editing them is in Changing a token below, and it is the opposite of what this file said for most of its life.
-
Brightness is confidence, and each stop has exactly one job.
--c-weaka candidate considered and not accepted;--c-midanything interactive — links, controls, the brand accent;--c-firmthe resolved thing — the thesis, the current page;--c-flaga reversal, at most once per page and never decoration. Before adding a color, ask which of the four jobs it is doing. If the answer is "none", it does not belong. -
Mono means data. Record values, lengths, language pairs, URLs, code. Not navigation, not buttons, not prose. It was on all of those before, which is why it had stopped meaning anything.
verifyfails the build if mono appears outside data. -
Fonts are self-hosted, same origin. Not a preference: a font CDN sends every visitor's IP to a third party, and a bare family name with no
@font-face— which is what these sites shipped for months — silently renders in system-ui instead. Both failures are invisible in the source.verifymeasures the rendered text and fails if a declared family matches the fallback width. -
A font this site does not ship cannot be named, not even as a first choice. The face is
Plex Mono— the name the@font-faceblocks actually declare.IBM Plex Monois what it is called upstream, it is declared nowhere, and no machine has it, so naming it first meant the browser fell straight through to whatever mono the visitor's OS had. It survived on every page and infavicon.svgfor months, becausefontsLoadedonly measures the families a page's spec lists, and this one was hiding in an SVGfont-familyattribute on the brand mark.fontsAvailableis the general rule that replaces looking: every family named anywhere on a page — stylesheet or attribute — must be@font-face'd by that page or be a generic keyword or a platform face. Platform faces are allowed and listed inSYSTEM_FACES; several are one OS's only —Segoe UIis Windows,MenloandSF Monoare Apple — and that is what a fallback chain is for. The failure being caught is the other thing: a name that is on no machine and served from nowhere.favicon.svgis checked separately, by fetch, because it is the one place the mark lives outside a page and no DOM check can reach it — it can load nothing and inherit nothing, so it names only generic and platform faces, and diverges from the pages' inline copies exactly as its hard-coded hexes do. -
One display face across both sites: Bricolage Grotesque. Its weight axis carries each page's argument — light where the sentence describes the solved or unresolved half, heavy where it lands. blust.ch sets Building fast is solved against Deciding well is not; guestgraph.io sets Five strangers against One guest.
It replaced Redaction, whose seven grades of decay were used to make the strangers arrive degraded and the guest resolve clean. That is a better idea on paper than in a browser: it was read as a page that had failed to load, twice, by the person who commissioned it — and on a site whose one genuine bug was a font that never loaded, that is the worst sentence type can utter. Weight says the same thing and never needs explaining. Do not reintroduce a degraded display face to make this point.
Do not edit a generated file, or a fenced block, in place. The next npm run design overwrites both and nothing warns you. This section used to say "edit the block, run npm run verify, bump vN in all three repositories". That was true while the blocks were maintained by hand. It is now the one instruction in this file that silently does nothing.
What used to be ten fenced blocks are mostly whole files now: tokens.css, page.css and page.js carry what design tokens, header contract, title contract, prose reset, prose footer, language, theme and nav fit used to fence into every prose page; deck.css and deck.js carry deck transport, deck lockup, deck fit and deck runtime — the last with a language fence nested inside it — for a deck. A page still carries a handful of fences a file cannot replace: theme boot, the one script that has to run inline and synchronously in <head>, before the first paint, so it is the sole copy left in every page's own source; and, on the pages that draw the model, stage contract, model card and surfaces lineage, which are this site's own glue to what each page draws rather than shared boilerplate.
npm run design # rewrite every generated file and fenced block from the pinned release
npm run design:check # report drift without writing — runs in CI, before the browser suiteTo change one of them:
- Edit it in
robertblust/designunderblocks/, and bump both its entry inversions.jsonand the version typed into the block's own first line. A test there fails if the two disagree — it exists because nothing else reconciled them. - Tag a release. The sites pin an exact tag, never a commit SHA: Dependabot's version detection rejects a SHA, and this site would then never be told a release happened.
- Here: take the Dependabot pull request, run
npm run design && npm run og, commit what changed. The design package has its own Dependabot group so a design bump never arrives beside a Playwright one — it is the pull request that has to be read rather than merged on sight. When the release moveschat.jsor Mermaid, runnpm run pages && npm run pictures && npm run pagestoo and commitprocesses/pictures/: the Processes page's process pictures are drawn when the site builds, bybuild/pictures.mjs, andnpm run pages:checkfails until they are drawn from what the page shows now.
design:check runs in CI, so a page that drifts from the pinned release goes red without anyone remembering to look. That is the guarantee the old habit-with-a-tripwire never was.
A page and the files it links are never on the same release by the same commit, and that gap is accepted, not a bug to close. The pin here names a tag already cut in robertblust/design; this repository's own commit that takes it — running npm run design, committing what changed — always lands after. So no release may ask for both halves at once: a rule renamed, a class dropped, or a selector a page starts relying on that the current pinned tag does not yet carry is two releases, never one — the file first, tagged in robertblust/design, and the page after, once that tag is pinned here. Writing a page against a rule the pinned tag has not shipped yet is writing against a file that is not there.
Two escape hatches that are decisions, not build fixes. Removing a fence's name from a page's fences array in PAGES, or a group from design.config.json, each clears a red design:check with a one-line diff. Neither describes a page that links the files group and declares an empty fences: [] — that page has nothing left to fence, which is the point, and design:check's comparison of the generated files it does carry is what still holds it to the release. The escape hatch is a fence whose name comes off a page's list while the fence itself stays in its markup, unread by the suite: that is a real choice to diverge, and it belongs in a commit that says so.
Not everything is generated. The <head> contract is a copy with no fence at all — see below.
Every page loads its faces from the one fonts/ directory at the root — ../../fonts/ from a deck, as on both sibling sites. Each page also preloads the faces its own @font-face rules name, <link rel="preload" as="font" type="font/woff2" href="…" crossorigin> in its head, and declares them font-display: fallback: a page painted before its font file was ready showed one frame in the fallback font and then swapped, on every page change, and with the preload the first frame is already in the right font. Right after its @font-face rules it also starts loading them, document.fonts.forEach(function(f){f.load()}): a browser loads a face only when a layout first asks for it, and even from its cache that lands a frame after the first paint, so without the call the first frame of every page showed its text without its font. Starting them is not enough on its own: the load lands a moment after the first paint, and a page painted then shows its text in the fallback face and moves it when the font arrives. So a render-blocking module follows the call, <script type="module" blocking="render">, and holds the first paint until the fonts have loaded, for at most 300ms; the browser keeps the previous page on screen meanwhile. A new page copies all four from its neighbor.
Do not give a deck its own fonts/. Nobody is sent a deck folder; what ships is the PDF, which has the outlines baked in. A per-deck copy buys nothing and has to be kept in step with the root by hand, with nothing checking that it is.
The deck's whole chrome and runtime used to be four generated fences — deck transport, deck lockup, deck fit and deck runtime, the last carrying a nested language fence of its own — the way the deck footer's hand-maintained copy was retired for fences in an earlier plan. deck.css and deck.js carry all four now. A deck links both, states its own TALK and UI — the talk's title and description in both languages, and the transport's strings built from them — and declares window.rbDeck = { talk: TALK, ui: UI } before <script src="../../deck.js" defer>, the same shape a prose page declares window.rbPage before page.js. theme and deck fit ship inside deck.js alongside deck runtime; only theme boot stays a fence, for the reason above.
What the files cover is a contract, not a look: the lockup goes to the landing page, the person to blust.ch, the third link to the talks index, and none of them opens in a new tab. That contract still holds; design:check enforces it, comparing the generated files' bytes against the pinned release — the same guarantee the old deck footer · vN marker and its retired footerVersion check reached for by hand, in three repositories, and never as strongly.
verify/design.mjs now lives in @robertblust/design, alongside the shared page checks — edited there, released as a tag, and taken here by re-pinning that tag in package.json. verify/check.mjs imports it by package specifier, @robertblust/design/verify/design; a verify/design.mjs created in this repository is never resolved by that import and would be silently ignored — the suite would still report green, having run the pinned release's code instead of the one just edited.
The row across the top — wordmark, links, language control — is one design on three sites, and like the tokens it is a copy: each site pins the same release and takes what it writes rather than importing it live. It is fenced in every page as header contract · vN on guestgraph.io and companygraph.io, which have not taken the whole-file shape; here, page.css carries it, one of the blocks assemble() folds into that file. Fence or file, it is byte-identical wherever a page carries it, because both forms are the same pinned release's own bytes. It is generated, like the tokens: change it in robertblust/design, tag a release, then run npm run design here. Editing it in this file does nothing — the next sync overwrites it.
What the contract says:
- Order. CLI, Principles, Processes, Surfaces, API, Ideas, Model, Timeline, Example, Blog, 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.
- One baseline. A single line runs through the middle of every text in the row. The links carry equal space above and below: the hover underline hangs below the word, and centering the boxes instead would ride the text high — which it did, by 5px, until the language control sat next to it and made it visible.
- States are different things. Hover is an underline and nothing else. The current page is brighter ink and carries no line. When both drew the same line, the page you were on read as permanently hovered.
- The wordmark never breaks. It is
white-space:nowrapand does not shrink. This rule sits outsidepage.css, in the page's own markup, because each site's mark has its own colors; the rule is the outcome, not the declaration, andmobileNavasserts it. - Under 640px the links collapse behind a button. The language control stays on the bar — two characters, reached for constantly by a bilingual audience, and one a visitor cannot find costs more than the tap it saves. The button sits to its left, so the order still reads. The links are wrapped, not duplicated: one list presented two ways, so there is no second copy to drift.
navOrder, headerBaseline and mobileNav assert all of it, per repository. What they cannot do is see a sibling — that is the whole reason the block carries a version. Before this was written down there were five different mobile behaviors across the family and eight different wordings of the same CSS, and nothing failed anywhere.
A deck lays its slides out once at a fixed height of 900, and the whole plane is scaled to the screen — the way a presentation tool does it, not the way a web page does. Two things that used to be worth re-testing are now guarantees: a slide can never scroll, because the canvas always fits, and the composition is identical on every screen, because there is only one composition.
- Only the height is fixed. The width follows the screen's aspect, so the canvas covers the viewport exactly and there are never letterbox bars. A fixed 16:9 canvas put 96px of black top and bottom on a 4:3 screen, which is the wrong trade on the minimum supported size.
- Every length is in
cqmin, nevervmin.cqminis 1% of the canvas's shorter side, and since the height is pinned at 900 and any landscape screen is wider than it is tall, that is a constant 9px. Type keeps its size and a wider screen buys real width.vmindid the opposite: it derived width from viewport height, so content width could never track the frame — at 2560×1080 the slides used 36–51% of the width and the rest was margin. That was the bug, and it is invisible unless you measure it. - Media needs a ceiling.
.slide svg, .slide img{max-height:60cqmin}. Anything sized as a fraction of width grows taller as the canvas widens: a square 300×300 diagram in a half-width column reached 780px inside a 900px frame on an ultrawide screen and pushed the slide into overflow. The cap sits well above any inline icon, so it only bites on a figure that was about to break the guarantee. - Below the breakpoint the canvas is switched off —
transform:none,container-type: normal— and the deck reflows into the scrolling reading view it always had. That is what "minimum supported width 1024" means in practice: canvas above, reflow below.
The scale is driven by one fit() function in deck.js now, not at the end of each deck's own script — no deck's own <script> contains it any more. Both exporters ride on it unchanged: the share card renders at 1200×675 and the PDF at 1280×720, and in each case the canvas fills the frame exactly with no bars.
og.png is not a banner someone drew: npm run og renders it from the page itself — an index card is the page, a deck's card is its title slide — so a link preview shows what the visitor is about to land on. The cost of that is a copy that has to be re-rendered whenever the page moves, and nothing about a stale card looks wrong. Two of the four here advertised the site as it read two days earlier, through several commits, and every check passed the whole time.
- One file of knobs and three thin callers over a shared harness.
og-recipe.mjsholds the card list, the frame and the hide rules and nothing else, and binds the machinery withrecipeFor(REPO_ROOT);export-og.mjs,og-check.mjsandverify/og-recipe.test.mjsare four lines each over@robertblust/design/cards/{export,check,recipe-tests}. A knob kept in the exporter as well would be a knob that can be edited without the hash moving, which is the one failure this mechanism exists to make impossible — and the harness holds none, so there is nowhere for a second copy to live. All three sibling sites carry this shape, which is why it is worth having in a package: the three had drifted into each holding a capability the other two had lost, and every one of those losses renders a card that looks plausible. REPO_ROOTis this file's, and is passed in. The package never derives a root from its ownimport.meta.url— that line is correct here, where the file really does sit at the repository root, and points insidenode_modulesonce the same code ships as a dependency. Bind throughrecipeFor(REPO_ROOT)rather thanexport * from, which leavesrootunbound and makesstate()throw for this site's own callers.- The frame is this site's and is not interchangeable. blust.ch and companygraph carry
clipYand nodeviceScaleFactor; guestgraph carriesdeviceScaleFactorand noclipY. The recipe hashes every key of a card, so a single spuriousdeviceScaleFactor: 1copied in from a sibling moves all nineog.shahere while no picture changes. - The hash covers every key of a card, sorted, not a hand-written list of them. That is why
settleandfromcan exist in companygraph's cards and not here without the mechanism differing: a knob added later enters the recipe by existing. It also means changing the recipe format moves everyog.shawhile the cards themselves stay byte-identical — which is what happened when this repository adopted the shared module, and is not a sign anything about the pictures changed. npm run og:checkcompares the recipe, never the pixels. Two machines rasterize the same text differently, so a card compared by its bytes reports which machine rendered it. The check re-derives a hash of what went into the card and compares it with theog.shacommitted beside it. It renders nothing and needs no browser, so it runs in CI beforenpx playwright install— but afternpm ci, because the check now arrives from the package. Left where it used to sit, ahead ofnpm ci, every push fails withERR_MODULE_NOT_FOUND, and a local run cannot catch that becausenode_modulesis already on the machine.npm run test:dupesimports nothing outsidenode:and still runs first.- The recipe is the page plus every local file the page names plus the exporter's own
frame. Fonts and images count: a font swap changes every card while no HTML changes at
all. Every page names the root
fonts/, so perturbing it marks every card stale — the layout described above, observable. - A link is not an asset. The walk skips
<a href>. The shared comment states the rule in the family's terms — a talks index links each deck's multi-megabyte PDF — and this is the site it was learned on: the four PDFs of the two talks here reported that card stale on everynpm run pdf, over a page that had not moved a pixel. Everything else a page names —<link>,<img>,url()in the inline CSS — is an asset and counts. The attribute pattern also admits?and#and strips them afterwards, because excluding them from the character class meanshref="a.css?v=2"matches nothing and leaves the recipe silently: over-reporting is survivable, under-reporting is the failure this mechanism exists to prevent. - Both files are committed together.
og.pngandog.sha, in the same commit as the page that moved. The stamp is written after the screenshot, so an exporter that dies half way leaves the card reported stale rather than reported current. - It over-reports and never under-reports, deliberately. Editing a comment in a page
marks its card stale even though the render would be identical. Clearing that is
npm run ogand a commit — cheap, and the opposite error is a card nobody notices for two days.
Canonical, description, the og: block, twitter:card and a JSON-LD graph, on every page. verify's seo check asserts the lot. Three of its assertions exist because the thing they catch had already shipped green:
- The canonical is compared against the page's own URL, not merely against
og:url. Agreeing withog:urlproves two tags say the same thing, and both can say the same wrong thing — a canonical pointing at another page removes this one from the index and hands its signals over, silently, which is worse than any tag being absent. - Every page points at its own share card.
cardonly asks whether the image resolves at its declared size, and a borrowed card does./ideas/advertised the landing page's card, and after that was fixed/privacy/still did. - Structured data has to resolve, not merely parse. Every
@ida page references must be defined on that page — Google reads@graphwithin one document — and every same-origin URL in the graph is fetched.blust.ch/logo.svgwas named as the publisher's logo for months and has never existed.
Two traps worth knowing before editing that check:
page.evaluateruns in the browser, whereSITEdoes not exist, and it takes exactly one argument. Both mistakes were made writing it. Pass{ url, site }as an object.- Deriving the public origin from
BASEmakes the check vacuous off the default port. It used to rewrite the literalhttp://localhost:8000; run with127.0.0.1and the URL filter matched nothing, so every graph URL was skipped and the check still printed ✓. Use theSITEconstant.
PAGES is the single list: the sitemap's expected URLs derive from it, and the suite fails if any page lacks seo: true — the runner skips a check whose key is undefined, so deleting that one line would otherwise turn the contract off in silence. The suite also asserts that whatever is on BASE is actually this site: a sibling repository left serving on :8000 produced a full run of failures belonging to a site nobody was testing.
og:locale is Open Graph only. No search engine reads it. It is en_US, with og:locale:alternate de_CH, and the prose is American to match. Google reads <html lang>, which sourceLang fetches cold on every page — lang alone cannot, because it reads documentElement.lang after applyLang() has already corrected it.
No hreflang. It names another address for the other language and there is none: one URL per page, German swapped in at runtime from data-de. It becomes correct the day /de/ URLs ship, and not before.
The head contract is a third copy, shared with guestgraph.io and companygraph.io and carrying no · vN tripwire, unlike the tokens and the rest of what @robertblust/design generates — the files above and theme boot, stage contract, model card and surfaces lineage below. Port changes by hand to all three.
model.json is the parsed model at the commit source.json pins, committed like the share cards and the PDFs are. npm run model writes it and is the only script here that needs both GitHub and the parser; npm run pages renders it into every page derived from it and needs neither. So re-pinning is source.json, then npm run model, then npm run pages, and forgetting the last step is caught rather than shipped: npm run pages refuses to run when the artifact names a commit other than the one pinned, and npm run pages:check holds every page in CI, right after npm ci, because three of the renderers it runs come from @robertblust/design. The same run copies each picture the model names to images/<entity id>.<extension>, committed like model.json and held by model:check the same way: a copy that differs from the pinned commit's, one that is missing and one that nothing names each fail by name. The site serves its own copy so that a visitor's browser asks no third party for a picture, which is what the privacy page says.
Every region npm run pages writes is derived, and /model/ and /timeline/ are not among them. Those two draw the model, and they name it rather than carry it: one <link rel="preload" as="fetch" href="../model.json" data-stage crossorigin> in the head, which the stage and the ledger both find by the attribute and fetch. Nothing derives that link — its href and its marker never vary — so it is ordinary markup, and the page holds no commit at all. What holds those pages to the pin is that there is only one copy: model.json, which pages:check holds against source.json. A page that names no data does not fail quietly; the stage throws, and the suite reports it on every page through the listener it already has. The vision and the values are rendered as HTML into /principles/, and, through @robertblust/design/render/home, into the home page's own vision and values regions, because a crawler has to read them without running JS. The home page's newest post is derived too, though not from the model: npm run pages copies the blog index's first entry, and pages:check fails while the two disagree. And the JSON-LD nodes that do not vary from page to page — Person, Dataset, WebSite — are written into all nine pages that carry a graph, while WebPage and BreadcrumbList differ per page and stay by hand. That split is the rule: a node that does not vary from page to page is written from one definition. Nine hand-typed copies is nine chances for eight of them to be right, which is how two talk decks came to describe the person without an address the other seven pages carried. The renderer re-serializes the whole block rather than the three nodes alone, so it owns the formatting of the nodes that stay by hand too: compacting an isPartOf onto one line reports the page stale, and reads as a model change when nothing about the model moved.
.github/workflows/ci.ymlruns the suite on push tomainand on every pull request — the samenpm run verifyas above, run by something other than a person remembering to.- The job is named
verifybecause the status-check context a branch ruleset requires is the job id, not the workflow name. Rename the job andprotect-mainkeeps requiring the old name, which will never report again — the branch looks protected and silently isn't. - The suite drives a real browser against a served page, so the job has to serve one. It
checks out, installs Node with the npm cache,
npm ci, installs Chromium with its system dependencies, startspython3 -m http.server 8000in the background, waits for it to answer, and only then runsnpm run verify. The job carriestimeout-minutes: 10, because the two ways of hanging it below are not hypothetical, and the default is six hours. Started in the foreground, the server step never returns. Backgrounded without redirecting its output, it hangs the same way — a backgrounded process keeps the step's log pipe open and the runner waits on a descriptor that never closes, so the output has to go to/dev/null, not just to the background. package-lock.jsonis committed, and.gitignoreused to hide it.npm cifails outright without a lockfile in the tree — this is the first CI run's actual failure, not a hypothetical — andcache: npmhas nothing to key on either. Ignoring it also let the Playwright version float, on a suite whose whole job is a deterministic browser. The sibling sites commit theirs for the same reason.npm run test:ogruns beside the card check, and before it. The check is only worth the CI minute if the recipe it compares is right, and a recipe that quietly stops tracking a file reports every card current for the wrong reason. The tests build throwaway trees intmprather than asserting against this site's own pages, so they keep meaning something after the pages change.- The narration check runs first, before anything is installed.
./tts/generate.py --dry-runbills nothing and needs no key, and it catches the one failure a browser cannot see: a note edited without regenerating its clip renders perfectly, passes every DOM assertion, and speaks the old words. The step fails onwould writein that output. npm run ogandnpm run pdfnever run here. Both write files this repository commits — four share cards and two PDFs per deck — so in CI they would either overwrite the committed artifacts or fail on a dirty tree, and neither is a check.npm run og:checkdoes run, and is the check they are not: see the section above..github/workflows/indexnow.ymltells Bing and the other IndexNow engines which pages a deploy changed. It runs whenpages-build-deploymentsucceeds, not on push, because a ping sent while Pages is still publishing has the engines fetch the page being replaced. The 32-character.txtfile at the root is the key, and it looks like a stray: deleting it makes every ping answer 403. It is public by design, which is why it is committed and not a secret.- Every sitemap URL carries a
<lastmod>that git wrote, andsitemap:checkfails when it moves. Runnpm run sitemapafter editing a page and commit both together; a page edited and not yet committed is dated today.verifychecks out full history, because the dates are read from it. - Both come from
@robertblust/design,design indexnowanddesign sitemap, because all three sites need them and they must agree on which page changed: itsindex.html, or the file its<link data-stage>names. The rule and its tests live in the package's Crawlers section, andnpx design indexnow <base> <head> --dry-runshows what a range would send.
Two jobs, both required by the ruleset on main: verify, this repository's own suite, and conventions, called from robertblust/conventions at the pinned tag and shown by GitHub as conventions / conventions. The prose check leaves out node_modules, third-party text; .superpowers, tooling scratch; and docs/superpowers, whose specs and plans quote the very words it scans for. Everything about how to write and how to work with git is in conventions/; this file is only what is this site's own.