From fa6e112cb2938ac250c30bd7849d79c9b5f84b53 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Tue, 1 Sep 2026 07:20:35 +0200 Subject: [PATCH 1/6] The transport says where the deck ends, and the language control says what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go() has always clamped, so prev at slide zero and next at the last slide were already no-ops. Nothing said so: the buttons looked live and did nothing. render() now sets disabled on both, and on first alongside prev, which is equally inert at slide zero, in both decks. The styling arrives with the next design release — .tbtn lives inside the shared transport fence, so a disabled treatment is a package change. role=group and aria-label go on both decks' language control, which measured pixel-identical: they change only what a screen reader announces, from two unlabelled buttons to one named control. mental-model's narration comments also move to essential-complexity's wording. Both quoted an example from a note, and the quoted phrase lives only in guestgraph's content — a comment shared by four decks cannot illustrate itself with one deck's speaker notes. essential-complexity's deck-agnostic phrasing is what the shared runtime carries now. --- talks/essential-complexity/index.html | 8 +++++++- talks/mental-model/index.html | 13 +++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/talks/essential-complexity/index.html b/talks/essential-complexity/index.html index dabca4a..95e6509 100644 --- a/talks/essential-complexity/index.html +++ b/talks/essential-complexity/index.html @@ -795,7 +795,7 @@

Tools c
-
+
@@ -969,6 +969,12 @@

Sprecher-Notiz
-
+
@@ -988,6 +988,12 @@

Sprecher-Notiz Sprecher-Notiz - spans are Regie-Instruktionen ("the title stays up while I greet"), the rest is - Sprechtext. Only the latter is spoken. + spans are Regie-Instruktionen, the rest is Sprechtext. Only the latter is spoken. A recorded clip is played when one exists (audio//.mp3), and the browser's own voice reads the note when one does not — so a half-recorded deck still plays end to end. @@ -1138,7 +1143,7 @@

Sprecher-Notiz is emphasis inside the sentence and must be spoken, - // or the voice says "muss zuerst wissen, Gast" and loses the word the line turns on + // or the voice loses the word the line turns on d.querySelectorAll('em.cue').forEach(function(e){ e.remove(); }); var body = (d.textContent || '').replace(/\s+/g, ' ').trim(); var title = slideTitle(slide); From 85c40b846a0c0ae219e7eca67bd50fbebe2a74b6 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Tue, 1 Sep 2026 08:20:36 +0200 Subject: [PATCH 2/6] Assert the order the fences are in, because the cascade depends on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release shipped the deck lockup visible on mobile, on two decks, because the lockup and the transport fences both carry a .name rule at identical specificity and one site emitted them in the opposite order. Every gate was green: design:check compares each fence's bytes independently, and the fences check asserts presence. Neither can see a relationship between two fences. fenceOrder takes the order from PAGES — the site's own declaration of what it ships — and asserts the fences appear in it. Demonstrated by swapping two adjacent blocks and watching it name both orders. --- verify/check.mjs | 2 ++ verify/design.mjs | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/verify/check.mjs b/verify/check.mjs index 7b33bf4..febb18f 100644 --- a/verify/check.mjs +++ b/verify/check.mjs @@ -56,6 +56,7 @@ const PAGES = [ fontsLoaded: ["Bricolage Grotesque", "Instrument Sans"], fontsAvailable: true, tokens: true, sky: true, monoScope: true, contrast: true, tokenVersion: true, fences: ["design tokens", "language", "deck transport", "deck lockup", "deck fit"], + fenceOrder: ["design tokens", "deck lockup", "deck transport", "language", "deck fit"], lockupCollapses: true, internalLinks: true }, { path: "/talks/essential-complexity/", storageKeys: true, opensFromFile: true, carriesLang: true, seo: true, noNewTab: true, wayOut: "../", title: /Essential Complexity/, lang: "en", sourceLang: "en", @@ -64,6 +65,7 @@ const PAGES = [ fontsLoaded: ["Bricolage Grotesque", "Instrument Sans"], fontsAvailable: true, tokens: true, sky: true, monoScope: true, contrast: true, tokenVersion: true, fences: ["design tokens", "language", "deck transport", "deck lockup", "deck fit"], + fenceOrder: ["design tokens", "deck lockup", "deck transport", "language", "deck fit"], lockupCollapses: true, internalLinks: true }, { path: "/talks/", storageKeys: true, mobileNav: true, carriesLang: true, headerBaseline: true, navOrder: true, footer: true, seo: true, noNewTab: true, title: /talks/i, lang: "en", sourceLang: "en", diff --git a/verify/design.mjs b/verify/design.mjs index 6225532..6f32d2c 100644 --- a/verify/design.mjs +++ b/verify/design.mjs @@ -20,6 +20,7 @@ // contrast the text colours clear their ratios on --ground // tokenVersion the page's `design tokens · vN` marker matches this suite // fences presence of every fence a page declares, version-blind +// fenceOrder the fences a page declares appear in the order it says they must // lockupCollapses at a narrow viewport, `.name` renders display:none — a cascade outcome, // asserted by rendering, not by reading which fence comes first // @@ -331,6 +332,26 @@ export const DESIGN_CHECKS = { : null; }, + // Two fences whose rules share a selector are order-dependent: equal specificity means + // the later one wins, and a media query adds none. That is not hypothetical — the deck + // lockup and the deck transport both carry a `.name` rule, and a release once shipped + // the lockup visible on mobile on two decks because one site emitted them in the + // opposite order. design:check compares each fence's bytes independently and cannot see + // it; `fences` asserts presence and cannot see it either. + // + // Fetched raw, like `fences`: a fence marker is a comment and comments do not survive + // into the rendered stylesheet. + async fenceOrder(page, spec) { + const res = await fetch(spec.absolute); + const html = await res.text(); + const seen = [...html.matchAll(/─── ([a-z ]+?) · v\d+/g)].map((m) => m[1]); + const want = spec.fenceOrder; + const got = seen.filter((n) => want.includes(n)); + return got.join(" → ") === want.join(" → ") + ? null + : `fences appear as ${got.join(" → ")}, expected ${want.join(" → ")}`; + }, + // The class of defect `fences` and `design:check` cannot see: two fences whose CSS // interacts once both are on the page. `deck transport` carries `.name{display:none}` // inside a mobile media query; `deck lockup` carries `.name{ … display:flex … }` with no From 08ac94c726b9cfebb65109b8d55e67a260e23400 Mon Sep 17 00:00:00 2001 From: Robert Blust Date: Tue, 1 Sep 2026 09:14:24 +0200 Subject: [PATCH 3/6] blust.ch's two decks adopt deck runtime and deck transport v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins @robertblust/design v0.6.0 with a real install (node_modules and the lockfile both move to v0.6.0, not just the lockfile). Each deck declares its own TALK payload — four strings extracted verbatim from its current UI — above a new `deck runtime · v1 · shared` fence, and the nested `language` fence stays exactly where it already was, untouched. Since the runtime block was extracted from mental-model's own code, syncing it back returns the runtime body byte-identical except for the intended TALK.xx.title/desc substitution — verified line by line. deck transport v2 arrives with the boundary-state styling (.tbtn:disabled{opacity:.35}) and brings `.lcd:has(.n.msg)` inside the fence, so each deck's own unfenced copy of that rule — and the comment noting a later plan would decide it — is dropped. Both decks' `fences` and `fenceOrder` arrays in verify/check.mjs now carry "deck runtime" in the position it actually occupies: between `deck transport` and `language`, matching where its opening marker sits relative to the nested language fence. npm run verify, design:check and og:check all pass; og.sha refreshed for both decks since their markup moved, even though the rendered cards are pixel-identical. Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 6 +-- package.json | 2 +- talks/essential-complexity/index.html | 74 +++++++++++++++++++-------- talks/essential-complexity/og.sha | 2 +- talks/mental-model/index.html | 74 +++++++++++++++++++-------- talks/mental-model/og.sha | 2 +- verify/check.mjs | 8 +-- 7 files changed, 118 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index b45e8bd..e41ef87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "blust-ch", "version": "1.0.0", "devDependencies": { - "@robertblust/design": "github:robertblust/design#v0.5.0", + "@robertblust/design": "github:robertblust/design#v0.6.0", "pdf-lib": "^1.17.1", "playwright": "^1.49.1" } @@ -34,8 +34,8 @@ } }, "node_modules/@robertblust/design": { - "version": "0.5.0", - "resolved": "git+ssh://git@github.com/robertblust/design.git#424a9978e0b1c56e5b4134d4c0ec44bcd0d66d7a", + "version": "0.6.0", + "resolved": "git+ssh://git@github.com/robertblust/design.git#e376447a1e6e649b1f4945ef694d0c7284a7815a", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index c5b6991..262a90d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:instance": "node --test verify/instance.test.mjs" }, "devDependencies": { - "@robertblust/design": "github:robertblust/design#v0.5.0", + "@robertblust/design": "github:robertblust/design#v0.6.0", "pdf-lib": "^1.17.1", "playwright": "^1.49.1" } diff --git a/talks/essential-complexity/index.html b/talks/essential-complexity/index.html index 95e6509..cc0520b 100644 --- a/talks/essential-complexity/index.html +++ b/talks/essential-complexity/index.html @@ -384,7 +384,7 @@ .name a:hover, .name a:focus-visible{color:var(--ink)} /* ─── end deck lockup ───────────────────────────────────────────────────*/ - /* ─── deck transport · v1 · shared ───────────────────────────────── + /* ─── deck transport · v2 · shared ───────────────────────────────── Generated from @robertblust/design — edit it there and run `npm run design`. The transport bar and the chrome around it, byte-identical on all four decks @@ -396,16 +396,21 @@ `deck lockup`. They were two components wearing one name, and only one of them legitimately differs between sites. - This block holds 53 rules, not the round number a first read of the region + This block holds 57 rules, not the round number a first read of the region might suggest. The marker never closed, so "which rules are inside it" was never a settled question; measured mechanically — everything from the `---------- transport ----------` comment through the transport's own three - mobile breakpoints, minus the lockup's own rules, minus the one rule that shows - the LCD while it carries a message (staying per-deck), minus the speaker-notes drawer and the - slide-layout rules that happen to share the same breakpoints — 53 is what's - left, and all 53 are byte-identical on every deck. Anyone re-deriving this - should get the same 53; if a cut to a smaller, rounder number seems - obviously right, it wasn't obvious here and is worth writing down why. */ + mobile breakpoints, minus the lockup's own rules, minus the speaker-notes drawer + and the slide-layout rules that happen to share the same breakpoints — 57 is + what's left, and all 57 are byte-identical on every deck. That count includes + the rule that shows the LCD while it carries a message, `.lcd:has(.n.msg)`: + below 400px `.lcd{display:none}` hides the counter to make room, but + `lcdMessage()` puts transient text in that same element, so without this rule + two of the four decks — companygraph's and guestgraph's — could never show a + message at that width at all. It belongs to every deck, not just the two that + happened to carry it first. Anyone re-deriving this should get the same 57; if + a cut to a smaller, rounder number seems obviously right, it wasn't obvious + here and is worth writing down why. */ .transport{ grid-column:2; justify-self:center; display:flex; align-items:center; gap:.45rem; @@ -439,6 +444,8 @@ .tbtn svg{width:18px; height:18px; fill:currentColor; display:block} .tbtn svg.stroked{fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round} .tbtn:hover{color:var(--ink); background:#20242e} + .tbtn:disabled{opacity:.35; cursor:default} + .tbtn:disabled:hover{color:inherit; background:none} .tbtn:focus-visible{outline:2px solid var(--c-mid); outline-offset:2px; color:var(--ink)} .tbtn[aria-pressed="true"]{color:var(--c-mid); background:#1b2231} @@ -497,6 +504,10 @@ /* below this the seven controls need every pixel */ @media (max-width: 400px){ .lcd{display:none} + /* the counter is hidden to make room, but lcdMessage() adds .msg to #lcdn to show + transient text in that same element — a message with nowhere to appear is worse + than a hidden counter, so bring it back when it is carrying one. */ + .lcd:has(.n.msg){display:flex} .transport{padding:.45rem .35rem} .tbtn{width:40px; height:40px} .tside{gap:.1rem} @@ -557,14 +568,6 @@ .kicker{margin-bottom:5vw} } - @media (max-width: 400px){ - /* the LCD is the only surface a "no voice" message ever appears on; hiding it - unconditionally leaves a dead play button on narrow phones with nothing to - say why. Keep it hidden except while it is showing a message (.n.msg). - Per-deck and unfenced — a later plan decides whether it belongs in the shared block. */ - .lcd:has(.n.msg){display:flex} - } - /* ─── display face ──────────────────────────────────────────────────── Set last so it wins over the rules above. The slide headline carries the same face the site's does; everything else on a slide is a label or a @@ -823,6 +826,38 @@

Sprecher-Notiz