diff --git a/CLAUDE.md b/CLAUDE.md index 1ee056d..09bede7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ t7d.dev: the personal site of Tim Conrad. Plain HTML, CSS and a little JavaScrip - A new tool page: copy `tools/papercuts/index.html`, keep its `` placeholder, and add the tool to `tools/index.html`, the Tools section of `index.html` and `sitemap.xml`. `scripts/gen-tools.py` puts the README of the latest release into the page at deploy time. Never paste a README by hand. Test: `uv run --no-project --with markdown==3.11.3 python3 -m unittest scripts/test_gen_tools.py`. - `drafts/` holds local drafts. Git ignores it, and it is not in `SITE` or `sitemap.xml`. The repo is public, so a draft never goes into git. To publish a draft, move it to its real folder. - A new or revised thought: use the skill `adding-a-thought`. `llms.txt` and the feeds are made at deploy time (`scripts/gen-feeds.py`); do not commit them. -- Keep the Content-Security-Policy of each page strict: no inline scripts or styles, no `style=""` attributes, and no request to another site on page load. `assets/theme.js` and `assets/site.js` are the only scripts. +- Keep the Content-Security-Policy of each page strict: no inline scripts or styles, no `style=""` attributes, and no request to another site on page load. `assets/theme.js` and `assets/site.js` are the only scripts, plus the characters of each game page (`assets/play/play-kb.js`, `assets/play/play-wz.js`; see "Game pages" in `DESIGN.md`). - `master` is the source. Never edit `gh-pages` by hand: the workflow publishes it after every push to `master`, and the sealed admin app (`Tim-Conrad/sealed`) publishes encrypted sites to `sealed/` on it, a folder that exists only there. `scripts/deploy.sh` must keep `sealed/`. - Classes and scripts that the sealed login page uses (`.sealed`, `.label`, `.btn`, `.nav`, `#theme-toggle`, `/assets/site.js`) are used by another repository too. Keep them, or change `client/themes/t7d/login.html` in sealed with them. - `blog/` and `CVE/` only redirect old addresses (the thoughts were a blog until November 2026). Do not add pages there. diff --git a/DESIGN.md b/DESIGN.md index 64d92e9..0e3f3c7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,6 +11,8 @@ Two rules follow from this: 1. **Black and white.** The only colour on the site is in the pictures and videos, so they are what the eye finds first. 2. **Plain words.** The site says what it holds and what a thing does. It does not sell. +The one place that breaks the calm on purpose is a game page. There, the game's own characters live on the lines of the sheet and can be played with (see "Game pages"). The rest of the site stays still and serious. + ## Wording The tone is honest and down to earth. @@ -63,8 +65,49 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano - **The front page stays short.** It says what exists and links to it: overview, the games, the latest thoughts, the research summary with a button to the write-up, the PGP key, contact. Details such as the attack steps and the disclosure timeline belong in the write-up, not here. - **Title block.** The box next to the name lists site, contents, contact and the PGP fingerprint. It holds facts only. Update "Contents" when a write-up or a game is added. - **Grid.** The faint 48 px grid appears only behind the name on the front page. It is the one decorative element. Do not put it behind text blocks. -- **Square corners.** Nothing has rounded corners or a shadow, except the phone frame, the step numbers and the dots of the timeline. +- **Square corners.** Nothing has rounded corners or a shadow, except the phone frame, the step numbers, the dots of the timeline and the soft smoke of the train. - **Spacing.** Lay out with grid or flex and `gap`. Side padding is `--pad`. +- **A game page** has, in this order: the header (with the line of what the characters can do), the reel, the live demo, the text with the feature list, and the timeline. It has no grid of screenshots: the videos show more, and the pictures made the page slow. + +## Game pages + +On the page of a game, and only there, the game's own characters live in the article. They make the page feel like the game without turning the site into a game. They never appear on the front page, in the game boxes there, in the thoughts or in the research. + +**What they are made of** + +- **The figures are pictures.** They are the game's own art, so they keep their colours, like any picture. + - Wortzauber: the pixel sprite sheets of the game, unchanged (Paul, the Schwebe-Bot, the things a word makes). + - Kleinbahn: rendered from the game's models (`src/models`, the near versions with `detail:'high'`), in side view, with 16 wheel positions per car, once by day and once by night. Render them again when the models change; do not draw them by hand. +- **Everything around them is drawn in ink.** Hills, tunnels, the station, the platform, the signal, speech bubbles and the hint line use the colour tokens, like a diagram. They follow the theme. +- **Smoke and steam are grey, never coloured, and low poly like the models.** Each puff is a faceted blob: a fan of flat triangles lit from the upper left. The exhaust leaves the chimney fast with the train's speed, drag slows it to the breeze, warm smoke keeps rising while it cools, and a shared turbulent flow folds neighbouring puffs together; puffs grow, turn lighter and fade. At speed the plume lies long and low; standing, it climbs. It comes in beats, four to a turn of the driving wheels, and is darker while the engine pulls away. Steam (the cylinders at the start, the brakes, the whistle, the safety valve) is lighter and short-lived. In the dark theme the smoke is light grey. +- **The site loads no game engine.** No three.js, no canvas: the figures are image strips moved by one small script per game page. + +**Where they stand** + +- They stand on lines that are already there: the rules between the sections, the frame of a picture, the lines of the feature list, the tops of the letters of a heading, a button. They do not get boxes, backgrounds or frames of their own. +- Kleinbahn's line is the rule under the header. The header gets the room the railway needs below its text, so nothing covers the text, and the smoke stays below it too. +- The two tunnels are one loop: what goes into the right hill comes out of the left one at once, so the line is never empty for long. Each hill reaches over the track: its paper covers the wheels and the sleepers beneath it, and it draws the rail line again. +- A figure may pass over text for a moment, but never stays on top of a link, a button or a paragraph. + +**How they behave** + +- **Alive, never pasted.** Every figure moves a little on its own: Paul breathes, looks around, walks and casts; the bot floats; the train runs, stops at the station and smokes; people get on and off; sheep graze. A figure that only stands still is wrong here. +- **They answer the reader.** A tap does one small thing (Paul jumps, the train stops or starts, a sheep hops). A drag carries or pushes (pick Paul up and throw him, push the train). A real control stays a real control: the signal is a `button`. +- **Wortzauber acts out the game.** Dotted words in the text (`.mw`) are traced letter by letter, and Paul makes the thing appear. Only words whose thing has a sprite in the game are dotted. +- **Paul follows the reader.** When his line scrolls away, he jumps down to a line on screen, or the bot lifts him up, as in the game. +- **Night is the dark theme.** In the dark theme the train shows its night pictures with lit windows. Load only the pictures of the theme in use. + +**Words** + +- One hint line under the subtitle says what you can do, in plain words: "Paul lives on this page. Pick him up, or tap a dotted word." +- Speech bubbles are short and friendly and say something true about the moment ("Back to Paul.", "Stop!", "Kuchen · cake"). No slogans, no calls to action. + +**Limits** + +- **Reduced motion.** The figures stand still in one pose (the train waits at the station) and nothing moves by itself. Taps and drags still work, because the reader starts them. +- **Weight.** The pictures of one game page stay under about 400 KB for one theme, as WebP or small PNG strips. +- **Phone first.** Check at 1440, 390 and 320 px, in both themes, by touch as well as by mouse. On a narrow screen use a shorter train and leave out what has no room (the signal), rather than shrinking the figures. +- **Access.** Each figure has `role="img"` and a label that says what it is and what a tap or drag does; the dotted words are focusable and work with Enter and Space. ## Components @@ -82,7 +125,8 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano | Thought | `.tmeta`, `.status` | A page in `/thoughts//`: an idea Tim works through and comes back to. Under the title: Status (`Draft`, `Settled` or `Changed my mind`), Started, Last revised. At the end, in this order: Open questions (if any), Starting points (the links the thought began with), Sources, Revisions (`.dates`: date and what changed), Feedback (what people answered when it was shared, and what it changed; "Not shared yet" until then). | | Animated diagram | `figure.anim` | Inline SVG that moves: lines draw (`.draw`), bars grow (`.grow`, `.grow-l`), parts appear (`.pop`, delays `.d1`-`.d8`) and numbers count up (`[data-count]`) once the figure comes on screen; a token can run along a path (``) while it is on screen, with a Pause button. `data-switch="A|B"` adds two buttons that switch the figure between two states (`.w-a`/`.w-b` show, `.hi-a`/`.hi-b` stay strong, `.off-b` fades). `[data-pick]` parts are buttons that show their item of `.pick__list` in a `.pick__panel`. A wide drawing (`.dg-w`) can have a narrow one for phones (`.dg-n`, below 600 px) instead of scrolling. Without the script, the end state and the whole list show. | | Diagram | `.dg` in a `figure` | Inline SVG drawn only with the classes in `site.css` (`.bx`, `.bx-s`, `.bx-i`, `.ln`, `.t-h`, `.t-s`, ...), which use the colour tokens, so a diagram follows the theme. Give it `` and `<desc>`. In a `.fig-wide` figure it scrolls inside its box on a phone instead of shrinking the text. No colours, no `style` attributes. | -| Diagram with steps | `figure[data-steps]`, `.steps__list` | A diagram whose parts carry `data-step="n"`, with one list item per step. The list sits in a box joined to the bottom of the diagram (`.steps__panel`, surface colour). `site.js` puts the step number, the text of the step and Back, Next and Play into that box and shows one step at a time: earlier steps faint, later ones nearly hidden. Without the script the box holds the whole numbered list. Use it where a process has an order; keep a still diagram where it has none. | +| Diagram with steps | `figure[data-steps]`, `.steps__list` | A diagram whose parts carry `data-step="n"`, with one list item per step. The list sits in a box joined to the bottom of the diagram (`.steps__panel`, surface colour). `site.js` puts the step number, the text of the step and Back, Next and Play into that box and shows one step at a time: earlier steps faint, later ones nearly hidden. Without the script the box holds the whole numbered list. Like an animated diagram, it can hold a wide drawing (`.dg-w`) and a narrow one for phones (`.dg-n`); give the parts of both the same `data-step` numbers. Use it where a process has an order; keep a still diagram where it has none. | +| Game characters | `.play-layer`, `.play-spr`, `.play-bubble`, `.mw`, `.kb-*` | Only on a game page (see "Game pages"). One layer over `main` holds the figures, the speech bubble and the smoke (`.kb-smoke`, one SVG of low-poly puffs); `.mw` marks a dotted word; `.kb-*` is the drawn railway (hills, station, platform, signal) and the cars. | | Encrypted site login | `.sealed` | The login page of a site below `/sealed/`, written by sealed (theme `t7d`). Label "Encrypted", the title as `h1`, one hint, username, password, one filled button "Open", a mono status line. Inputs are framed in ink with square corners. | ## Rules that are easy to break @@ -91,12 +135,13 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano - **Links are underlined.** Colour cannot mark a link here. - **Keyboard focus is visible**: a 3 px ink outline. - **Phone first.** Check each change at 1440, 390 and 320 px wide, in both themes. The page must not scroll sideways. Wide tables and code scroll inside their own box. -- **Motion.** The interface does not move, apart from smooth scrolling to a section. The exceptions are recordings of real things: the videos, which loop silently and do not start by themselves for a visitor who asked for reduced motion, and the gameplay loop in a game box: a short animated WebP of the real game, loaded lazily. A visitor who asked for reduced motion gets a still instead, through the `<picture>` element. The other exceptions are diagrams in a thought. A diagram with steps changes only when the reader presses Back, Next or Play. An animated diagram (`figure.anim`) plays its drawing once when it comes on screen, and a running token moves only while the figure is on screen and stops with its Pause button. Animation explains something in the diagram; it is never decoration. A visitor who asked for reduced motion gets the end state at once, and the running token stands still until Play. +- **Motion.** The interface does not move, apart from smooth scrolling to a section. The exceptions are recordings of real things: the videos, which loop silently and do not start by themselves for a visitor who asked for reduced motion, and the gameplay loop in a game box: a short animated WebP of the real game, loaded lazily. A visitor who asked for reduced motion gets a still instead, through the `<picture>` element. The characters on a game page are another exception: they move on their own and answer the reader, and stand still for reduced motion ("Game pages"). The other exceptions are diagrams in a thought. A diagram with steps changes only when the reader presses Back, Next or Play. An animated diagram (`figure.anim`) plays its drawing once when it comes on screen, and a running token moves only while the figure is on screen and stops with its Pause button. Animation explains something in the diagram; it is never decoration. A visitor who asked for reduced motion gets the end state at once, and the running token stands still until Play. - **Pictures.** Give every picture `width`, `height` and a plain `alt` text that describes what it shows. ## Where things are - Styles and tokens: `assets/site.css` -- Scripts: `assets/theme.js` (theme before the first paint), `assets/site.js` (toggle, copy buttons, table of contents, the demo) +- Scripts: `assets/theme.js` (theme before the first paint), `assets/site.js` (toggle, copy buttons, table of contents, the demo), and on a game page its characters: `assets/play/play-wz.js` or `assets/play/play-kb.js`, with `assets/play/play.css` +- Pictures of the characters: `assets/play/` (Wortzauber sprites from the game's `art/pixel/`; Kleinbahn car strips `car-<kind>-d.webp` and `-n.webp` rendered from `src/models/train.js`) - Reels and pictures of the games: `.claude/skills/updating-product-reels/SKILL.md` - Deploy and repository layout: `README.md` diff --git a/assets/play/apple.png b/assets/play/apple.png new file mode 100644 index 0000000..817b707 Binary files /dev/null and b/assets/play/apple.png differ diff --git a/assets/play/bot.png b/assets/play/bot.png new file mode 100644 index 0000000..02f3c29 Binary files /dev/null and b/assets/play/bot.png differ diff --git a/assets/play/cake.png b/assets/play/cake.png new file mode 100644 index 0000000..482902b Binary files /dev/null and b/assets/play/cake.png differ diff --git a/assets/play/candle.png b/assets/play/candle.png new file mode 100644 index 0000000..76bcd5c Binary files /dev/null and b/assets/play/candle.png differ diff --git a/assets/play/car-box-d.webp b/assets/play/car-box-d.webp new file mode 100644 index 0000000..24ef4f4 Binary files /dev/null and b/assets/play/car-box-d.webp differ diff --git a/assets/play/car-box-n.webp b/assets/play/car-box-n.webp new file mode 100644 index 0000000..f01000e Binary files /dev/null and b/assets/play/car-box-n.webp differ diff --git a/assets/play/car-coach-d.webp b/assets/play/car-coach-d.webp new file mode 100644 index 0000000..e82537f Binary files /dev/null and b/assets/play/car-coach-d.webp differ diff --git a/assets/play/car-coach-n.webp b/assets/play/car-coach-n.webp new file mode 100644 index 0000000..3c33244 Binary files /dev/null and b/assets/play/car-coach-n.webp differ diff --git a/assets/play/car-loco-d.webp b/assets/play/car-loco-d.webp new file mode 100644 index 0000000..588d730 Binary files /dev/null and b/assets/play/car-loco-d.webp differ diff --git a/assets/play/car-loco-n.webp b/assets/play/car-loco-n.webp new file mode 100644 index 0000000..2c0adac Binary files /dev/null and b/assets/play/car-loco-n.webp differ diff --git a/assets/play/car-logs-d.webp b/assets/play/car-logs-d.webp new file mode 100644 index 0000000..8455746 Binary files /dev/null and b/assets/play/car-logs-d.webp differ diff --git a/assets/play/car-logs-n.webp b/assets/play/car-logs-n.webp new file mode 100644 index 0000000..b00ebe8 Binary files /dev/null and b/assets/play/car-logs-n.webp differ diff --git a/assets/play/car-rcoach-d.webp b/assets/play/car-rcoach-d.webp new file mode 100644 index 0000000..9da4835 Binary files /dev/null and b/assets/play/car-rcoach-d.webp differ diff --git a/assets/play/car-rcoach-n.webp b/assets/play/car-rcoach-n.webp new file mode 100644 index 0000000..0800c2a Binary files /dev/null and b/assets/play/car-rcoach-n.webp differ diff --git a/assets/play/car-tank-d.webp b/assets/play/car-tank-d.webp new file mode 100644 index 0000000..82dbe4e Binary files /dev/null and b/assets/play/car-tank-d.webp differ diff --git a/assets/play/car-tank-n.webp b/assets/play/car-tank-n.webp new file mode 100644 index 0000000..1bd4762 Binary files /dev/null and b/assets/play/car-tank-n.webp differ diff --git a/assets/play/p-child.png b/assets/play/p-child.png new file mode 100644 index 0000000..8803157 Binary files /dev/null and b/assets/play/p-child.png differ diff --git a/assets/play/p-stand.png b/assets/play/p-stand.png new file mode 100644 index 0000000..d2219d3 Binary files /dev/null and b/assets/play/p-stand.png differ diff --git a/assets/play/p-walk.png b/assets/play/p-walk.png new file mode 100644 index 0000000..acd89fe Binary files /dev/null and b/assets/play/p-walk.png differ diff --git a/assets/play/paul.png b/assets/play/paul.png new file mode 100644 index 0000000..38b0899 Binary files /dev/null and b/assets/play/paul.png differ diff --git a/assets/play/play-kb.js b/assets/play/play-kb.js new file mode 100644 index 0000000..0b15b45 --- /dev/null +++ b/assets/play/play-kb.js @@ -0,0 +1,321 @@ +// Kleinbahn page: the rule under the header is a railway (mock-up). +// The scenery is drawn in ink like the rest of the sheet: two hills with tunnels, a station building, a signal. +// The train is the game's own models (the near versions), rendered in 16 wheel positions, by day and by night. +// The two tunnels are one loop: what goes into the right hill comes out of the left one at once. +// The train stops at the station where people get on and off. The smoke is soft puffs, one burst per beat of the engine. +// Tap a car to stop or start the train, grab it and push it, set the signal. Sheep graze on the hill. +(() => { + const main = document.getElementById('main'), head = document.querySelector('.post-head'), reel = document.querySelector('.product > .reel'); + if (!main || !head || !reel) return; + const A = '/assets/play/'; + const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + const dark = () => { const t = document.documentElement.dataset.theme; return t ? t === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; }; + main.classList.add('has-play', 'has-rail'); + const hint = document.createElement('p'); hint.className = 'label play-hint'; + hint.textContent = 'The train runs on the line below. Tap it to stop or start it, push it along, or set the signal.'; + head.appendChild(hint); + const layer = document.createElement('div'); layer.className = 'play-layer'; main.appendChild(layer); + const mk = (tag, cls, parent = layer) => { const e = document.createElement(tag); if (cls) e.className = cls; parent.appendChild(e); return e; }; + const svgEl = (w, h, cls, inner, below = 0) => { const d = mk('div', cls); d.innerHTML = `<svg viewBox="0 0 ${w} ${h + below}" width="${w}" height="${h + below}" aria-hidden="true">${inner}</svg>`; return d; }; + const put = (el, x, y, extra = '') => { el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)${extra}`; }; + let now = performance.now(); + + // ---------- the cars: frames rendered from src/models/train.js (detail:'high'), 250 px per model unit ---------- + // l, r: the picture's edges from the car's centre; dia: the wheel, so a wheel frame moves with the distance + const CAR = { + loco: { w: 241, h: 150, dia: .17, label: 'The steam engine' }, coach: { w: 200, h: 125, dia: .12, label: 'A coach' }, + rcoach: { w: 216, h: 125, dia: .12, label: 'A coach' }, box: { w: 206, h: 120, dia: .17, label: 'A box van' }, + logs: { w: 206, h: 124, dia: .17, label: 'A wagon with logs' }, tank: { w: 206, h: 128, dia: .17, label: 'A tank wagon' } }; + const FRAMES = 16; + + // ---------- layout ---------- + let W, railY, u, wide; + const measure = () => { const m = main.getBoundingClientRect(), r = reel.getBoundingClientRect(); + W = main.clientWidth; railY = r.top - m.top; wide = W >= 700; u = wide ? 125 : 92; }; + measure(); + + // ---------- the drawn scenery ---------- + const sleepers = mk('div', 'kb-sleepers'); + const BELOW = 12; + const hill = side => { // a hill with a tunnel portal on its inner side; the train passes behind it + const w = wide ? 160 : 78, ph = Math.round(CAR.loco.h * u / 250) + 16, h = ph + (wide ? 36 : 26), id = 'kbh' + side + w; + const pw = 13, px = w - pw, top = `M0 ${h * .36} C${w * .2} 0 ${w * .48} 0 ${w * .68} ${h * .1} C${w * .84} ${h * .17} ${px - 5} ${h - ph - 15} ${px} ${h - ph - 8}`; + const inner = `<defs><pattern id="${id}" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line class="kb-hatch" x1="0" y1="0" x2="0" y2="7"/></pattern></defs> + <path class="kb-hill__fill" d="${top} L${px} ${h} L0 ${h} Z"/><path d="${top} L${px} ${h} L0 ${h} Z" fill="url(#${id})"/> + <path class="kb-ink" d="${top}"/> + <rect class="kb-hill__fill kb-ink" x="${px}" y="${h - ph}" width="${pw}" height="${ph}"/> + <rect class="kb-hill__fill kb-ink" x="${px - 3}" y="${h - ph - 8}" width="${pw + 5}" height="8"/> + ${Array.from({ length: Math.floor(ph / 10) }, (_, k) => `<line class="kb-ink kb-thin" x1="${px}" y1="${h - ph + k * 10 + 10}" x2="${w}" y2="${h - ph + k * 10 + 10}"/>`).join('')} + ${Array.from({ length: Math.floor(ph / 10) }, (_, k) => `<line class="kb-ink kb-thin" x1="${px + (k % 2 ? 4 : 8)}" y1="${h - ph + k * 10}" x2="${px + (k % 2 ? 4 : 8)}" y2="${h - ph + k * 10 + 10}"/>`).join('')} + <rect class="kb-hill__fill" x="-1" y="${h - 2}" width="${w + 2}" height="${BELOW + 2}"/><line class="kb-ink kb-ground" x1="-1" y1="${h + .75}" x2="${w + 1}" y2="${h + .75}"/>`; + // the hill reaches over the track: its paper covers the wheels and the sleepers, and it draws the rail line again + const el = svgEl(w, h, 'kb-hill', inner, BELOW); if (side < 0) el.firstChild.style.transform = 'scaleX(-1)'; + // the outline as a table of heights, so sheep can stand on the slope + const path = el.querySelector('path.kb-ink'), n = path.getTotalLength(), ys = new Array(Math.ceil(w) + 1).fill(h); + for (let k = 0; k <= 200; k++) { const pt = path.getPointAtLength(n * k / 200), i = Math.round(pt.x); if (i >= 0 && i < ys.length) ys[i] = Math.min(ys[i], pt.y); } + for (let i = 1; i < ys.length; i++) if (ys[i] === h) ys[i] = ys[i - 1]; + return { el, w, h, ph, side, plateau: w * .6, yAt: x => ys[Math.max(0, Math.min(ys.length - 1, Math.round(x)))] }; }; + let HL, HR; + const building = svgEl(130, 74, 'kb-building', ` + <path class="kb-ink kb-paper" d="M14 30 L65 6 L116 30 Z"/><rect class="kb-ink kb-paper" x="20" y="30" width="90" height="44"/> + <rect class="kb-ink kb-paper" x="88" y="4" width="8" height="16"/> + <rect class="kb-ink" x="57" y="46" width="16" height="28"/><rect class="kb-ink" x="31" y="44" width="14" height="16"/><rect class="kb-ink" x="85" y="44" width="14" height="16"/> + <line class="kb-ink kb-thin" x1="38" y1="44" x2="38" y2="60"/><line class="kb-ink kb-thin" x1="92" y1="44" x2="92" y2="60"/> + <circle class="kb-ink kb-paper" cx="65" cy="22" r="5"/><line class="kb-ink kb-thin" x1="65" y1="22" x2="65" y2="18.5"/><line class="kb-ink kb-thin" x1="65" y1="22" x2="67.5" y2="22"/> + <text class="kb-name" x="65" y="40" text-anchor="middle">KLEINBAHN</text>`); + const platform = mk('div', 'kb-platform'); + const lamp = svgEl(14, 64, 'kb-lamp', '<line class="kb-ink" x1="7" y1="10" x2="7" y2="64"/><path class="kb-ink kb-paper" d="M2 10 L12 10 L10 2 L4 2 Z"/>'); + const signal = mk('button', 'kb-signal'); signal.type = 'button'; + signal.innerHTML = '<svg viewBox="0 0 34 62" aria-hidden="true"><line class="ink" x1="17" y1="6" x2="17" y2="62"/><line class="ink" x1="10" y1="61" x2="24" y2="61"/>' + + '<g class="arm"><rect class="fill" x="17" y="9" width="17" height="6"/><circle class="hole" cx="29" cy="12" r="1.6"/></g><circle class="ink" cx="17" cy="12" r="2.4"/></svg>'; + let clear = true; + const setSignal = c => { clear = c; signal.setAttribute('aria-pressed', String(c)); signal.setAttribute('aria-label', `Signal: ${c ? 'clear' : 'stop'}. Press to set it to ${c ? 'stop' : 'clear'}.`); }; + setSignal(true); + signal.addEventListener('click', () => { setSignal(!clear); if (clear && T.v < 5 && T.mode === 'run') whistle(); }); + + // ---------- the train ---------- + const T = { s: 0, v: 0, mode: 'run', cars: [], served: false, dwell: 0, inside: 3, held: null, lastPuff: 0, tunnel: 0 }; + const strip = k => `url(${A}car-${k}-${dark() ? 'n' : 'd'}.webp)`; + const build = () => { + for (const c of T.cars) c.el.remove(); + T.cars = []; + const kinds = wide ? ['loco', 'coach', 'rcoach', 'coach', 'box', 'logs', 'tank'] : ['loco', 'coach', 'box']; + for (const k of kinds) { + const d = CAR[k], w = d.w * u / 250, h = d.h * u / 250, el = mk('div', 'kb-car'); + el.style.width = w + 'px'; el.style.height = h + 'px'; el.style.backgroundImage = strip(k); el.style.backgroundSize = `${w * FRAMES}px ${h}px`; + el.setAttribute('role', 'img'); el.setAttribute('aria-label', `${d.label}. Tap to stop or start the train, drag to push it.`); + T.cars.push({ kind: k, el, w, h, x: 0, v: 0, step: Math.PI * d.dia * u / FRAMES, passengers: k === 'coach' || k === 'rcoach' }); grab(el); + } + T.len = T.cars.reduce((a, c) => a + c.w, 0); + }; + new MutationObserver(() => { night = dark(); for (const c of T.cars) c.el.style.backgroundImage = strip(c.kind); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { night = dark(); for (const c of T.cars) c.el.style.backgroundImage = strip(c.kind); }); + + const lay = () => { // the station, the signal and the hills for this width + if (!HL || HL.wide !== wide) { if (HL) { HL.el.remove(); HR.el.remove(); } HL = hill(1); HR = hill(-1); HL.wide = HR.wide = wide; } + const lw = T.cars[0].w, pass = T.cars.filter(c => c.passengers).reduce((a, c) => a + c.w, 0); + T.xs = Math.round(W - HR.w - (wide ? 36 : 10)); // where the engine's front stops + T.pl1 = Math.round(T.xs - lw - pass - 6); T.pl2 = Math.round(T.xs - lw * .3); + T.xsig = Math.round(Math.max(HL.w + 30, T.pl1 - (wide ? 70 : 30))); + // the loop: a car's right edge runs from just inside the left portal to where the right hill hides the whole car + const maxW = Math.max(...T.cars.map(c => c.w)); T.x0 = HL.w - 6; T.L = (W - HR.w + maxW + 6) - T.x0; + put(sleepers, 0, railY + 1.5); sleepers.style.width = W + 'px'; + put(HL.el, 0, railY - HL.h); put(HR.el, W - HR.w, railY - HR.h); + platform.style.width = (T.pl2 - T.pl1) + 'px'; put(platform, T.pl1, railY - 10); + const bs = wide ? 1 : .62; building.firstChild.style.width = 130 * bs + 'px'; building.firstChild.style.height = 74 * bs + 'px'; + put(building, (T.pl1 + T.pl2) / 2 - 65 * bs, railY - 10 - 74 * bs); + put(lamp, T.pl1 + 8, railY - 10 - 64 * (wide ? 1 : .8)); lamp.firstChild.style.height = 64 * (wide ? 1 : .8) + 'px'; + put(signal, T.xsig + 4, railY - 62); + signal.style.display = !wide && T.xsig + 40 > T.pl1 ? 'none' : ''; + }; + const place = s => { T.s = s; let right = s; for (const c of T.cars) { c.x = right; right -= c.w - 1; } for (const c of T.cars) c.d = disp(c.x); T.lap = lapOf(s); }; + // positions run on without end; on screen they wrap round the loop + const disp = X => T.x0 + (((X - T.x0) % T.L) + T.L) % T.L; + const lapOf = X => Math.floor((X - T.x0) / T.L); + const LD = () => disp(T.s); + + // ---------- people on the platform ---------- + const people = []; + const PH = () => .18 * u; + const person = (kind, x, state = 'wait') => { + const img = mk('img', 'play-img'); img.alt = ''; img.draggable = false; + const p = { kind, img, x, face: Math.random() < .5 ? 1 : -1, state, tx: x, t: Math.random() * 30 }; + img.src = A + (kind === 'child' ? 'p-child.png' : 'p-stand.png'); img.style.transition = 'opacity .5s'; + img.addEventListener('click', () => { if (p.state === 'wait') { p.face *= -1; say(['Hello!', 'Is it on time?', 'Morning!'][Math.floor(Math.random() * 3)], p.x, railY - 10 - PH()); } }); + people.push(p); return p; }; + const drawPerson = p => { + const h = p.kind === 'child' ? PH() * .66 : PH(), walking = p.state === 'walk' || p.state === 'board' || p.state === 'leave'; + const step = walking && Math.floor(now / 170) % 2, file = p.kind === 'child' ? 'p-child.png' : step ? 'p-walk.png' : 'p-stand.png'; + if (!p.img.src.endsWith(file)) p.img.src = A + file; + const w = h * (p.kind === 'child' ? 31 / 84 : step ? 54 / 128 : 47 / 129); + p.img.style.height = h + 'px'; p.img.style.width = w + 'px'; + const bob = walking && p.kind === 'child' ? Math.floor(now / 150) % 2 : 0; + put(p.img, p.x - w / 2, railY - 10 - h - bob, ` scaleX(${p.face})`); }; + + // ---------- sheep on the left hill ---------- + const sheep = [0, 1].map(i => { const img = mk('img', 'play-img'); img.src = A + 'sheep2.png'; img.alt = ''; img.draggable = false; img.style.zIndex = 7; + const sh = { img, x: 0, face: i ? -1 : 1, hop: 0, next: 2 + Math.random() * 5, walk: 0 }; + img.addEventListener('click', () => { hop(sh); say('Mäh', sh.x, railY - HL.h - 30); }); return sh; }); + const hop = sh => { if (!sh.hop) sh.hop = now; }; + + // ---------- speech, smoke ---------- + const bubble = mk('div', 'play-bubble'); let bubbleT = 0; + const say = (text, x, y, ms = 1600) => { bubble.textContent = text; bubble.classList.add('is-on'); put(bubble, Math.max(8, Math.min(W - 90, x - 20)), y - 34); bubbleT = now + ms; }; + // ---------- smoke and steam, low poly ---------- + // Each puff is a faceted blob like the game's models: a fan of flat triangles, lit from the upper left. + // The physics: the exhaust leaves the chimney fast with the train's speed, air drag slows it to the breeze, + // warm smoke keeps rising while it cools, a shared turbulent flow folds neighbouring puffs together, + // puffs grow as they take in air, turn lighter and fade. At speed the plume lies long and low; standing, it climbs. + const NS = 'http://www.w3.org/2000/svg'; + const smoke = mk('div', 'kb-smoke'); smoke.innerHTML = `<svg width="100%" height="100%" aria-hidden="true"></svg>`; + const sv = smoke.firstChild, parts = [], pool = []; + let night = dark(); + const facet = () => { // a unit puff: 6 to 8 corners, a little irregular, its centre nudged towards the light + const n = 6 + Math.floor(Math.random() * 3), pts = [], cx = -.12, cy = -.14; + for (let k = 0; k < n; k++) { const a = (k + Math.random() * .5) / n * Math.PI * 2, r = .82 + Math.random() * .3; pts.push([Math.cos(a) * r, Math.sin(a) * r]); } + const g = document.createElementNS(NS, 'g'), base = document.createElementNS(NS, 'polygon'); + base.setAttribute('points', pts.map(p => p.join(',')).join(' ')); base.setAttribute('fill', 'currentColor'); g.appendChild(base); + for (let k = 0; k < n; k++) { + const p1 = pts[k], p2 = pts[(k + 1) % n], mx = (p1[0] + p2[0]) / 2 - cx, my = (p1[1] + p2[1]) / 2 - cy; + const lit = (-mx - my) / Math.hypot(mx, my) / Math.SQRT2; // facing the upper left: lit; facing down right: shade + if (Math.abs(lit) < .25) continue; + const t = document.createElementNS(NS, 'polygon'); + t.setAttribute('points', `${cx},${cy} ${p1.join(',')} ${p2.join(',')}`); + t.setAttribute('fill', lit > 0 ? '#fff' : '#000'); t.setAttribute('fill-opacity', (lit > 0 ? .26 : .16) * Math.abs(lit)); + g.appendChild(t); } + return g; }; + const puff = (x, y, o) => { + if (parts.length > 170) return; + const g = pool.pop() || facet(); sv.appendChild(g); + parts.push({ g, x, y, vx: o.vx || 0, vy: o.vy || 0, r0: o.r0 || 4, gr: o.gr || 14, life: o.life || 3, a: o.a ?? .6, age: 0, steam: !!o.steam, + drag: o.drag ?? 2.2, buoy: o.buoy ?? 26, rot: Math.random() * 360, spin: (Math.random() - .5) * 50, cc: -1 }); }; + const flow = (x, y, t) => [ // a slow turbulent field: neighbours move alike + 9 * Math.sin(.031 * y + .8 * t) + 6 * Math.sin(.047 * x - .55 * t + 1.3), + 5 * Math.cos(.037 * x + .6 * t) + 4 * Math.sin(.052 * y - .9 * t) ]; + const smokeStep = dt => { + const t = now / 1000, wind = -10 * sc(), ceil = railY - (wide ? 150 : 104) * sc() * (wide ? 1 : 1.3); + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i]; p.age += dt * (p.y < ceil ? 2.5 : 1); const k = p.age / p.life; + if (k >= 1) { p.g.remove(); pool.push(p.g); parts.splice(i, 1); continue; } + const dr = Math.exp(-p.drag * dt), [fx, fy] = flow(p.x, p.y, t), heat = Math.exp(-p.age / 1.4); + p.vx = wind + (p.vx - wind) * dr; p.vy = p.vy * dr - p.buoy * heat * dt; + p.x += (p.vx + fx * Math.min(1, p.age * 1.5)) * dt; p.y += (p.vy + fy * Math.min(1, p.age * 1.5)) * dt; + if (p.y > railY - 4) { p.y = railY - 4; p.vy = Math.min(p.vy, 0); } // smoke does not sink into the ground + p.rot += p.spin * dt; + const r = p.r0 + p.gr * Math.sqrt(p.age), fade = Math.min(1, p.age / .08) * Math.pow(1 - k, 1.6); + const m = Math.min(1, k * 1.4), [c0, c1] = p.steam ? (night ? [238, 238] : [214, 236]) : night ? [196, 118] : [62, 176]; + const gC = Math.round(c0 + (c1 - c0) * m); + if (gC !== p.cc) { p.cc = gC; p.g.style.color = `rgb(${gC},${gC},${gC})`; } + p.g.setAttribute('transform', `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) rotate(${p.rot.toFixed(0)}) scale(${r.toFixed(2)})`); + p.g.setAttribute('opacity', (p.a * fade).toFixed(3)); } }; + const sc = () => u / 125; + const loco = () => T.cars[0]; + const chimney = () => { const l = loco(); return [l.d - l.w * .21, railY - l.h + 3]; }; + const visible = x => x > HL.w - 4 && x < W - HR.w + 4; + // one beat of the engine: a burst from the chimney, or out of a portal while the engine is in a tunnel + const chuff = (strong) => { + const [x, y] = chimney(), s = sc(), v = T.v; + if (!visible(x)) { const left = x <= HL.w, px = left ? HL.w + 2 : W - HR.w - 2, hh = left ? HL : HR; + puff(px, railY - hh.ph * .8, { vx: (left ? 40 : -40) * s, vy: -10, r0: 7 * s, gr: 14 * s, life: 3.2, a: .4, drag: 2.5, buoy: 18 }); return; } + for (let i = 0; i < 2; i++) puff(x + (Math.random() - .5) * 3, y - i * 3, { vx: v + (Math.random() - .5) * 14, vy: -(95 + Math.abs(v) * .3 + Math.random() * 30) * s, + r0: (3 + Math.random() * 2) * s, gr: (strong ? 15 : 12) * s * (.85 + Math.random() * .3), life: 3 + Math.random() * 1.2, a: strong ? .78 : .62, drag: 2.4, buoy: 30 * s }); }; + const cocks = () => { const l = loco(), s = sc(); // the cylinder cocks blow steam when the engine starts + for (const dir of [1, -1]) puff(l.d - l.w * .12, railY - 10 * s, { steam: true, vx: dir * (110 + Math.random() * 60) * s + T.v, vy: -10, r0: 3 * s, gr: 13 * s, life: .9, a: .75, drag: 3.5, buoy: 10 }); }; + const whistle = () => { const l = loco(), s = sc(), x = l.d - l.w * .45, y = railY - l.h * .82; + for (let i = 0; i < 6; i++) setTimeout(() => puff(x, y, { steam: true, vx: T.v, vy: -130 * s, r0: 2 * s, gr: 9 * s, life: 1.1, a: .85, drag: 3, buoy: 8 }), i * 55); + T.cocks = 1.6; for (let i = 0; i < 3; i++) setTimeout(() => chuff(true), i * 220); }; + const brakeHiss = () => { const l = loco(), s = sc(); + for (let i = 0; i < 5; i++) setTimeout(() => puff(l.d - l.w * (.15 + .4 * Math.random()), railY - 5 * s, { steam: true, vx: (Math.random() - .5) * 90 * s, vy: -12, r0: 2 * s, gr: 10 * s, life: .8, a: .6, drag: 3.5, buoy: 8 }), i * 80); }; + // ---------- pointer ---------- + function grab(el) { + el.addEventListener('pointerdown', e => { e.preventDefault(); el.setPointerCapture(e.pointerId); T.held = { el, x0: e.clientX, s0: T.s, moved: false, hist: [[now, e.clientX]] }; el.classList.add('is-held'); }); + el.addEventListener('pointermove', e => { const h = T.held; if (!h || h.el !== el) return; if (Math.abs(e.clientX - h.x0) > 5) h.moved = true; + h.hist.push([now, e.clientX]); if (h.hist.length > 6) h.hist.shift(); if (h.moved) h.target = h.s0 + (e.clientX - h.x0); }); + const up = () => { const h = T.held; if (!h || h.el !== el) return; T.held = null; el.classList.remove('is-held'); + if (!h.moved) { toggle(); return; } + const a = h.hist[0], b = h.hist[h.hist.length - 1]; T.v = Math.max(-420, Math.min(420, (b[1] - a[1]) / Math.max(.016, (b[0] - a[0]) / 1000))); + if (Math.abs(T.v) > 160) say(T.v > 0 ? 'Whoosh!' : 'Backwards?', Math.min(W - HR.w - 60, LD() - 40), railY - T.cars[0].h); }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); } + const toggle = () => { const x = Math.min(W - HR.w - 60, LD() - 30); + if (T.mode === 'run') { T.mode = 'halt'; say('Stop!', x, railY - T.cars[0].h); } else { T.mode = 'run'; T.served = T.served || T.v === 0 && Math.abs(LD() - T.xs) < 2; whistle(); } }; + + // ---------- one step ---------- + const CRUISE = () => 78 * u / 125, ACC = 30, BRK = 55; + const drive = dt => { + if (T.held) { if (T.held.target != null) { const ns = T.held.target; T.v = (ns - T.s) / Math.max(dt, .001) * .3 + T.v * .7; T.s = ns; } return; } + const vt = T.mode === 'run' && T.dwell <= 0 ? CRUISE() : 0, dl = LD(); let stopAt = null; + if (T.v >= 0) { + if (!clear && dl < T.xsig - 6) stopAt = T.xsig - 8; + if (!T.served && dl < T.xs + 1 && T.mode === 'run') stopAt = stopAt == null ? T.xs : Math.min(stopAt, T.xs); + } + if (stopAt != null && T.v > 0) { + const d = stopAt - dl; + if (d <= T.v * T.v / (2 * BRK) + 1) { if (d < .6) { T.s += d; T.v = 0; brakeHiss(); } else T.v = Math.max(4, T.v - T.v * T.v / (2 * d) * dt); } + else T.v = Math.min(vt, T.v + ACC * dt); + } else if (T.v < vt) T.v = Math.min(vt, T.v + ACC * dt * (T.v < 0 ? 3 : 1)); + else { const was = T.v; T.v = Math.max(vt, T.v - (T.v > CRUISE() ? 80 : BRK) * dt); if (was > 0 && T.v === 0) brakeHiss(); } + T.s += T.v * dt; + const lap = lapOf(T.s); if (lap > T.lap) T.served = false; T.lap = lap; // a new round: the station waits again + if (!T.served && T.v === 0 && Math.abs(LD() - T.xs) < 1 && T.dwell <= 0 && T.mode === 'run') arrive(); + if (T.dwell > 0) { T.dwell -= dt; if (T.dwell <= 0) { T.served = true; whistle(); } } }; + const doors = () => T.cars.filter(c => c.passengers).map(c => c.d - c.w / 2); + const arrive = () => { + T.dwell = 6; const ds = doors(); if (!ds.length) return; + const out = Math.min(T.inside, 1 + Math.floor(Math.random() * 3)); + for (let i = 0; i < out; i++) setTimeout(() => { // get off, wait a moment, walk away + const p = person(Math.random() < .3 ? 'child' : 'adult', ds[i % ds.length], 'walk'); T.inside--; + p.img.style.opacity = 0; requestAnimationFrame(() => { p.img.style.opacity = 1; }); + p.tx = T.pl1 + 14 + Math.random() * (T.pl2 - T.pl1 - 28) * .5; p.then = 'leave'; }, 400 + i * 650); + for (const p of people.filter(p => p.state === 'wait')) { p.state = 'board'; p.tx = ds[Math.floor(Math.random() * ds.length)] + (Math.random() - .5) * 12; } }; + const peopleStep = dt => { + for (const p of [...people]) { + if (p.state === 'walk' || p.state === 'board' || p.state === 'leave') { + const d = p.tx - p.x, sp = (p.kind === 'child' ? 20 : 15) * u / 125; p.face = d >= 0 ? 1 : -1; + if (Math.abs(d) < sp * dt) { + p.x = p.tx; + if (p.state === 'board' || p.state === 'leave') { if (p.state === 'board') T.inside++; p.img.style.opacity = 0; p.state = 'gone'; + setTimeout(() => { p.img.remove(); people.splice(people.indexOf(p), 1); }, 600); } + else if (p.then === 'leave') { p.state = 'wait'; p.then = null; setTimeout(() => { if (p.state === 'wait') { p.state = 'leave'; p.tx = T.pl1 - 4; } }, 2500 + Math.random() * 2500); } + else p.state = 'wait'; + } else p.x += Math.sign(d) * sp * dt; + } else if (p.state === 'wait' && !reduced()) { p.t -= dt; if (p.t < 0) { p.t = 5 + Math.random() * 25; p.face *= -1; } } + drawPerson(p); } + const waiting = people.filter(p => p.state === 'wait' || (p.state === 'walk' && !p.then)).length; + const away = T.cars.every(c => c.d < T.pl1 + 4 || c.d - c.w > T.pl2 - 4); + if (!reduced() && T.dwell <= 0 && waiting < 3 && away && Math.random() < dt * .3) { + const p = person(Math.random() < .25 ? 'child' : 'adult', T.pl1 - 2, 'walk'); p.img.style.opacity = 0; requestAnimationFrame(() => { p.img.style.opacity = 1; }); + p.tx = T.pl1 + 18 + Math.random() * (T.pl2 - T.pl1 - 36); } }; + + let last = performance.now(); + const tick = t => { + now = t; const dt = Math.min(.05, (t - last) / 1000); last = t; + const s0 = T.s; drive(dt); + // couplings: the cars follow on a spring, so the train bunches up when it brakes and stretches when it pulls + T.cars[0].x = T.s; T.cars[0].v = T.v; + for (let i = 1; i < T.cars.length; i++) { + const c = T.cars[i], p = T.cars[i - 1], target = p.x - p.w + 1; + c.v += (160 * (target - c.x) - 15 * (c.v - p.v)) * dt; c.x += c.v * dt; + c.x = Math.max(target - 6, Math.min(target + 1.5, c.x)); } + const sp = Math.abs(T.v); + for (const c of T.cars) c.d = disp(c.x); + for (const [i, c] of T.cars.entries()) { + const f = ((FRAMES - (Math.floor(c.x / c.step) % FRAMES)) % FRAMES + FRAMES) % FRAMES; + c.el.style.backgroundPosition = `${-f * c.w}px 0`; + const bob = sp > 5 ? Math.round(Math.sin(c.x * .3 + i * 2) * .6) : 0; + put(c.el, c.d - c.w, railY - c.h + 1 + bob); } + // smoke: four beats to a turn of the driving wheels; standing, the engine simmers + T.beat = (T.beat || 0) + Math.abs(T.s - s0); + const beatLen = Math.PI * .17 * u / 4; + if (T.beat >= beatLen) { T.beat %= beatLen; chuff(T.v > 2 && T.v < CRUISE() * .7 && T.mode === 'run'); } + if (!reduced() && sp < 3 && now - T.lastPuff > 380) { T.lastPuff = now; const [x, y] = chimney(); + if (visible(x)) puff(x, y, { vx: 0, vy: -30 * sc(), r0: 2.5 * sc(), gr: 8 * sc(), life: 3, a: .34, drag: 1.5, buoy: 16 * sc() }); + if (Math.random() < .08) { const l = loco(); puff(l.d - l.w * .62, railY - l.h * .74, { steam: true, vy: -70 * sc(), r0: 2 * sc(), gr: 7 * sc(), life: 1.1, a: .7, drag: 2.5, buoy: 8 }); } } + if (T.cocks > 0) { T.cocks -= dt; if (Math.random() < dt * 14) cocks(); } + smokeStep(dt); + // sheep graze on the hill, step about, and look up when the engine comes out of the tunnel + for (const [i, sh] of sheep.entries()) { + const h = .14 * u * 1.4, w = h * 98 / 72; sh.img.style.width = w + 'px'; sh.img.style.height = h + 'px'; + sh.img.style.display = i && !wide ? 'none' : ''; + if (!sh.x) sh.x = HL.plateau * (i ? .72 : .3); + if (!reduced()) { sh.next -= dt; if (sh.next < 0) { sh.next = 4 + Math.random() * 7; if (Math.random() < .45) sh.face *= -1; else sh.walk = 1 + Math.random(); } } + if (sh.walk > 0) { sh.walk -= dt; sh.x += sh.face * 7 * dt; if (sh.x < w / 2 + 2 || sh.x > HL.plateau - w / 2) { sh.face *= -1; sh.x = Math.max(w / 2 + 2, Math.min(HL.plateau - w / 2, sh.x)); } } + const nose = loco().d; if (T.v > 30 && nose > HL.w && nose < HL.w + 30) { hop(sh); sh.face = 1; } + let hy = 0; if (sh.hop) { const k = (now - sh.hop) / 420; if (k >= 1) sh.hop = 0; else hy = Math.sin(Math.PI * k) * 9; } + put(sh.img, sh.x - w / 2, railY - HL.h + HL.yAt(sh.x) - h + 2 - hy, ` scaleX(${sh.face})`); } + peopleStep(dt); + if (bubbleT && now > bubbleT) { bubble.classList.remove('is-on'); bubbleT = 0; } + requestAnimationFrame(tick); }; + + const start = () => { + measure(); build(); lay(); + if (reduced()) { place(T.xs); T.served = true; T.mode = 'halt'; } + else { place(HL.w + T.cars[0].w + 20); T.v = CRUISE() * .8; } + for (const k of [.2, .45, .7]) person(k === .45 ? 'child' : 'adult', T.pl1 + (T.pl2 - T.pl1) * k); + requestAnimationFrame(tick); + let rt, lastW = W; + const relayout = () => { const k = LD() / lastW; measure(); if (Math.abs(W - lastW) > 40) { build(); lay(); place(k * W); lastW = W; for (const p of people) p.x = Math.max(T.pl1 + 10, Math.min(T.pl2 - 10, p.x)); } else lay(); }; + addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(relayout, 150); }); + setInterval(() => { measure(); lay(); }, 1500); }; + const go = () => (document.fonts ? document.fonts.ready : Promise.resolve()).then(start); + if (document.readyState === 'complete') go(); else addEventListener('load', go); +})(); diff --git a/assets/play/play-wz.js b/assets/play/play-wz.js new file mode 100644 index 0000000..878f580 --- /dev/null +++ b/assets/play/play-wz.js @@ -0,0 +1,376 @@ +// Wortzauber page: Paul and the Schwebe-Bot live on the lines of the article (mock-up). +// Paul walks on the rules, the feature list, the pictures and the title. Pick him up and throw him; +// tap a dotted word and he makes the thing appear. When the reader scrolls on, he follows. +(() => { + const main = document.getElementById('main'); + if (!main || !document.querySelector('.post-head')) return; + const A = '/assets/play/'; + const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + const G = 2200; // gravity, px/s² + main.classList.add('has-play'); + const layer = document.createElement('div'); layer.className = 'play-layer'; main.appendChild(layer); + + // ---------- sprites ---------- + const SHEETS = { + paul: { src: 'paul.png', w: 512, h: 1360, fw: 64, fh: 80, cols: 8, ax: 28, ay: 78, anims: { + idle: [0, 4, 6], cast: [8, 8, 12], walk: [16, 8, 10], jump: [24, 8, 12], land: [29, 3, 14], air: [27, 1, 1], channel: [32, 4, 8], + cheer: [40, 8, 10], run: [48, 8, 14], pickup: [96, 6, 14], drop: [104, 4, 12], fly: [112, 2, 6], + oidle: [80, 4, 6], owalk: [88, 8, 10] } }, + bot: { src: 'bot.png', w: 640, h: 864, fw: 64, fh: 96, cols: 10, ax: 32, ay: 88, anims: { + idle: [0, 4, 6], move: [10, 6, 12], wave: [20, 6, 10], carry: [40, 4, 8], point: [60, 6, 8], cheer: [70, 8, 10] } }, + cake: { src: 'cake.png', w: 44, h: 32, fw: 44, fh: 32, cols: 1, ax: 22, ay: 31, anims: { idle: [0, 1, 1] } }, + candle: { src: 'candle.png', w: 120, h: 44, fw: 40, fh: 44, cols: 3, ax: 20, ay: 43, anims: { idle: [0, 3, 5] } }, + apple: { src: 'apple.png', w: 32, h: 31, fw: 32, fh: 31, cols: 1, ax: 16, ay: 30, anims: { idle: [0, 1, 1] } }, + puff: { src: 'smoke_puff.png', w: 768, h: 128, fw: 128, fh: 128, cols: 6, ax: 64, ay: 64, scale: .5, anims: { idle: [0, 6, 14] } }, + sparkle: { src: 'summon_sparkle.png', w: 1024, h: 128, fw: 128, fh: 128, cols: 8, ax: 64, ay: 64, anims: { idle: [0, 8, 14] } }, + }; + let now = performance.now(); + const all = new Set(); + class Spr { + constructor(key, cls = 'play-spr') { + const s = this.s = SHEETS[key], k = s.scale || 1, el = this.el = document.createElement('div'); + el.className = cls; + el.style.width = s.fw * k + 'px'; el.style.height = s.fh * k + 'px'; + el.style.backgroundImage = `url(${A}${s.src})`; el.style.backgroundSize = `${s.w * k}px ${s.h * k}px`; + layer.appendChild(el); + this.x = 0; this.y = 0; this.face = 1; this.rot = 0; this.z = 1; this.play('idle'); all.add(this); + } + play(name, once = false) { if (this.anim === name && !once && !this.once) return; this.anim = name; this.t0 = now; this.once = once; this.ended = false; } + draw() { + const s = this.s, k = s.scale || 1, [f0, n, fps] = s.anims[this.anim]; + let i = Math.floor((now - this.t0) / 1000 * fps); + if (this.once) { if (i >= n) { i = n - 1; this.ended = true; } } else i %= n; + const f = f0 + i, el = this.el; + el.style.backgroundPosition = `${-(f % s.cols) * s.fw * k}px ${-Math.floor(f / s.cols) * s.fh * k}px`; + const r = this.rot ? ` rotate(${this.rot.toFixed(1)}deg)` : ''; + el.style.transformOrigin = this.rot ? `${s.ax * k}px ${8 * k}px` : `${s.ax * k}px ${s.ay * k}px`; + el.style.transform = `translate(${Math.round(this.x - s.ax * k)}px, ${Math.round(this.y - s.ay * k)}px)${r} scaleX(${this.face})`; + el.style.zIndex = this.z; + } + remove() { this.el.remove(); all.delete(this); } + } + const fx = (key, x, y) => { const f = new Spr(key, 'play-fx'); f.x = x; f.y = y; f.z = 4; f.play('idle', true); f.fx = true; return f; }; + + // ---------- the lines of the page that things can stand on ---------- + let surfs = [], W = 0, H = 0; + const mrect = () => main.getBoundingClientRect(); + const cv = document.createElement('canvas').getContext('2d'); + const textTop = el => { // a heading: the top of each letter, so Paul steps up onto the tall ones + const m = mrect(), cs = getComputedStyle(el), node = [...el.childNodes].find(n => n.nodeType === 3 && n.nodeValue.trim()); + const r = document.createRange(); r.selectNodeContents(el); const t = r.getBoundingClientRect(); + cv.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const fm = cv.measureText('Hxg'), base = t.top - m.top + (t.height - (fm.fontBoundingBoxAscent + fm.fontBoundingBoxDescent)) / 2 + fm.fontBoundingBoxAscent; + const prof = []; + if (node) for (let i = 0; i < node.nodeValue.length; i++) { + const ch = node.nodeValue[i]; if (!ch.trim()) continue; + const cr = document.createRange(); cr.setStart(node, i); cr.setEnd(node, i + 1); const b = cr.getBoundingClientRect(); + const gm = cv.measureText(ch); prof.push([b.left - m.left, b.right - m.left, base - gm.actualBoundingBoxAscent]); + } + const ys = prof.map(p => p[2]); + return { x1: t.left - m.left + 4, x2: t.right - m.left - 4, y: ys.length ? Math.min(...ys) : base - 20, prof, el }; + }; + const measure = () => { + const m = mrect(); W = main.clientWidth; H = main.scrollHeight; + const S = [], add = (x1, x2, y, el, kind) => { if (x2 - x1 > 40) S.push({ x1, x2, y: Math.round(y), el, kind }); }; + const box = el => { const r = el.getBoundingClientRect(); return { l: r.left - m.left, r: r.right - m.left, t: r.top - m.top, b: r.bottom - m.top }; }; + const h1 = document.querySelector('.post-head h1'); if (h1) { const t = textTop(h1); add(t.x1, t.x2, t.y, h1, 'title'); S[S.length - 1].prof = t.prof; } + for (const el of document.querySelectorAll('.product > *')) { const b = box(el); add(b.l, b.r, b.t, el, 'rule'); } + for (const el of document.querySelectorAll('.product h2')) { const t = textTop(el); add(t.x1, t.x2, t.y, el, 'title'); if (S.length && S[S.length - 1].el === el) S[S.length - 1].prof = t.prof; } + const feats = document.querySelector('.feats'); + if (feats) { const b = box(feats); add(b.l, b.r, b.t, feats, 'line'); for (const li of feats.children) { const c = box(li); add(c.l, c.r, c.b - 1, li, 'line'); } } + for (const el of document.querySelectorAll('.shots img, .tl .player, .product .btn')) { const b = box(el); if (b.b > b.t) add(b.l, b.r, b.t, el, 'top'); } + add(0, W, H - 2, main, 'floor'); + surfs = S; + }; + const yAt = (s, x) => { // the height of a surface at x (a heading follows its letters) + if (!s.prof || !s.prof.length) return s.y; + let y = null; for (const [a, b, t] of s.prof) if (x >= a - 2 && x <= b + 2) y = y == null ? t : Math.min(y, t); + if (y != null) return Math.round(y); + let best = s.prof[0], d = 1e9; for (const p of s.prof) { const dd = Math.min(Math.abs(x - p[0]), Math.abs(x - p[1])); if (dd < d) { d = dd; best = p; } } return Math.round(best[2]); + }; + const surfAt = (x, yFrom, yTo) => { // the first surface crossed going down from yFrom to yTo at x + let best = null, by = 0; + for (const s of surfs) { if (x < s.x1 || x > s.x2) continue; const y = yAt(s, x); if (y >= yFrom && y <= yTo && (!best || y < by)) { best = s; by = y; } } + return best; + }; + const surfBelow = (x, y) => surfAt(x, y, 1e9); + const view = () => { const m = mrect(); return { t: -m.top + 56, b: -m.top + innerHeight }; }; + + // ---------- Paul, the bot and the things ---------- + const P = { spr: new Spr('paul'), x: 0, y: 0, vx: 0, vy: 0, state: 'ground', surf: null, mode: 'idle', timer: 1.5, tx: 0, after: null, carry: null, goal: null, quiet: 0 }; + const B = { spr: new Spr('bot'), x: 0, y: 0, state: 'follow', vx: 0, vy: 0, tx: 0, ty: 0 }; + P.spr.z = 3; B.spr.z = 2; + const things = []; + const bubble = document.createElement('div'); bubble.className = 'play-bubble'; bubble.setAttribute('aria-live', 'polite'); layer.appendChild(bubble); + let bubbleT = 0; + const say = (text, ms = 3200) => { bubble.textContent = text; bubble.classList.add('is-on'); bubbleT = now + ms; }; + + const paulAnim = () => { + if (P.state === 'held') return 'fly'; + if (P.state === 'air' || P.state === 'hop') return P.jumping ? 'jump' : 'air'; + if (P.state === 'lift') return 'fly'; + if (P.mode === 'walk') return P.carry ? 'owalk' : (P.fast ? 'run' : 'walk'); + if (P.mode === 'anim') return P.animName; + return P.carry ? 'oidle' : 'idle'; + }; + const doAnim = (name, then) => { P.mode = 'anim'; P.animName = name; P.spr.play(name, true); P.after = then || null; }; + const walkTo = (x, then, fast = false) => { const s = P.surf; P.tx = Math.max(s.x1 + 12, Math.min(s.x2 - 12, x)); P.mode = 'walk'; P.after = then || null; P.fast = fast; }; + const idle = (t = 1 + Math.random() * 2.5) => { P.mode = 'idle'; P.timer = t; P.after = null; }; + + // go to a point on another surface: hop down onto it, or let the bot lift him up + const travel = (s, x, then) => { + x = Math.max(s.x1 + 14, Math.min(s.x2 - 14, x)); + if (s === P.surf) { walkTo(x, then, true); return; } + if (P.carry) dropCarry(); + if (s.y > P.y) { // down: a hop that only lands on s + const v = view(); if (P.y < v.t - 200) P.y = v.t - 120; + const vy0 = -420, dy = s.y - P.y, t = (-vy0 + Math.sqrt(vy0 * vy0 + 2 * G * dy)) / G; + P.state = 'hop'; P.hopTo = s; P.vy = vy0; P.vx = (x - P.x) / t; P.jumping = false; P.next = then; P.face = P.vx >= 0 ? 1 : -1; + } else { // up: the bot carries him + const v = view(); if (P.y > v.b + 200) { P.y = v.b + 120; B.y = P.y - 70; } + P.state = 'lift'; P.liftTo = s; P.liftX = x; P.next = then; B.state = 'lift'; say('I can lift you!', 1800); + } + }; + + const land = (s, vy) => { + P.state = 'ground'; P.surf = s; P.y = yAt(s, P.x); P.vx = 0; P.vy = 0; P.spr.rot = 0; P.jumping = false; + if (vy > 700) { fx('puff', P.x, P.y - 10); } + if (P.next) { const n = P.next; P.next = null; idle(.15); P.after = n; P.timer = .15; return; } + if (vy > 1300) { doAnim('land', () => { say(['Wheee!', 'Again!', 'That was high.'][Math.floor(Math.random() * 3)]); doAnim('cheer', () => idle()); }); } + else doAnim('land', () => idle(.8)); + }; + + // ---------- things that words make ---------- + const mkThing = (key, x, y) => { + if (things.length >= 6) { const old = things.shift(); if (P.carry === old) P.carry = null; fx('puff', old.x, old.y - 10); old.spr.remove(); } + const t = { key, spr: new Spr(key), x, y, vx: (Math.random() - .5) * 60, vy: -260, state: 'air', surf: null }; + t.spr.z = 2; things.push(t); grabbable(t.spr.el, t, 'thing'); return t; + }; + const dropCarry = () => { const t = P.carry; if (!t) return; P.carry = null; t.state = 'air'; t.vx = P.face * 80; t.vy = -150; t.spr.z = 2; }; + + // ---------- the brain ---------- + const decide = () => { + if (P.after) { const a = P.after; P.after = null; a(); return; } + if (reduced()) { idle(5); return; } + const s = P.surf; + const near = things.find(t => t.state === 'ground' && t.surf === s && !P.carry && Math.abs(t.x - P.x) < 260); + const r = Math.random(); + if (near && r < .7) { walkTo(near.x - Math.sign(near.x - P.x || 1) * 12, () => pickUp(near)); return; } + if (P.carry && r < .25) { doAnim('drop', () => { const t = P.carry; P.carry = null; t.state = 'ground'; t.surf = s; t.x = P.x + P.face * 22; t.y = s.y; t.spr.z = 2; idle(1.5); }); return; } + if (r < .62) { walkTo(s.x1 + 20 + Math.random() * (s.x2 - s.x1 - 40)); return; } + if (r < .72 && !P.carry) { doAnim('cast', () => { fx('sparkle', P.x + P.face * 30, P.y - 40); idle(1.2); }); return; } + if (r < .8 && !P.carry) { doAnim('channel', () => idle(.6)); return; } + idle(1.5 + Math.random() * 3); + }; + const pickUp = t => { + if (t.state !== 'ground' || P.carry) { idle(); return; } + P.face = t.x >= P.x ? 1 : -1; + doAnim('pickup', () => { if (t.state === 'ground') { P.carry = t; t.state = 'carried'; t.spr.z = 4; } doAnim('cheer', () => idle(1)); }); + }; + + // ---------- pointer: pick up Paul, the bot and the things ---------- + let held = null; + function grabbable(el, obj, kind) { + el.addEventListener('pointerdown', e => { + if (held) return; e.preventDefault(); el.setPointerCapture(e.pointerId); + const m = mrect(), px = e.clientX - m.left, py = e.clientY - m.top; + held = { obj, kind, el, dx: obj.x - px, dy: obj.y - py, x0: px, y0: py, t0: now, moved: false, hist: [[now, px, py]] }; + }); + el.addEventListener('pointermove', e => { + if (!held || held.el !== el) return; + const m = mrect(), px = e.clientX - m.left, py = e.clientY - m.top; + held.hist.push([now, px, py]); if (held.hist.length > 6) held.hist.shift(); + if (!held.moved && Math.hypot(px - held.x0, py - held.y0) > 5) { held.moved = true; startHold(held); } + if (held.moved) { held.tx = px + held.dx; held.ty = py + held.dy; } + }); + const up = () => { + if (!held || held.el !== el) return; + const h = held; held = null; el.classList.remove('is-held'); + if (!h.moved) { tap(h); return; } + const a = h.hist[0], b = h.hist[h.hist.length - 1], dt = Math.max(16, b[0] - a[0]) / 1000; + let vx = (b[1] - a[1]) / dt, vy = (b[2] - a[2]) / dt; const sp = Math.hypot(vx, vy); if (sp > 1800) { vx *= 1800 / sp; vy *= 1800 / sp; } + release(h, vx, vy); + }; + el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); + } + const startHold = h => { + h.el.classList.add('is-held'); h.tx = h.obj.x; h.ty = h.obj.y; + if (h.kind === 'paul') { if (P.carry) dropCarry(); P.goal = null; P.next = null; P.after = null; P.state = 'held'; P.quiet = 0; + if (!P.wasHeld) { P.wasHeld = true; setTimeout(() => say('Careful with Paul!', 2200), 300); } } + if (h.kind === 'bot') { B.state = 'held'; say('Hey!', 1200); } + if (h.kind === 'thing') { if (P.carry === h.obj) P.carry = null; h.obj.state = 'held'; h.obj.spr.z = 4; } + }; + const release = (h, vx, vy) => { + if (h.kind === 'paul') { P.state = 'air'; P.vx = vx; P.vy = vy; P.jumping = false; } + if (h.kind === 'bot') { B.state = 'follow'; B.vx = vx; B.vy = vy; setTimeout(() => say('Back to Paul.', 1600), 400); } + if (h.kind === 'thing') { h.obj.state = 'air'; h.obj.vx = vx; h.obj.vy = vy; h.obj.spr.z = 2; } + }; + const tap = h => { + if (h.kind === 'paul' && P.state === 'ground') { P.goal = null; P.after = null; P.state = 'air'; P.vy = -560; P.vx = 0; P.jumping = true; P.spr.play('jump', true); } + if (h.kind === 'bot') { B.spr.play('wave', true); say(['Hello!', 'I am the Schwebe-Bot.', 'Tap a dotted word.'][Math.floor(Math.random() * 3)]); } + if (h.kind === 'thing' && h.obj.state === 'ground') { h.obj.state = 'air'; h.obj.vy = -380; h.obj.vx = (Math.random() - .5) * 120; } + }; + grabbable(P.spr.el, P, 'paul'); grabbable(B.spr.el, B, 'bot'); + P.spr.el.setAttribute('role', 'img'); P.spr.el.setAttribute('aria-label', 'Paul, the hero of Wortzauber. Drag to pick him up.'); + B.spr.el.setAttribute('role', 'img'); B.spr.el.setAttribute('aria-label', 'The Schwebe-Bot'); + + // ---------- dotted words: write, trace, appear ---------- + const WORDS = { cake: ['cake', 'Kuchen'], candle: ['candle', 'Kerze'], candles: ['candle', 'Kerze'], apples: ['apple', 'Apfel'], apple: ['apple', 'Apfel'] }; + const re = /\b(cake|candles?|apples?)\b/g; + for (const root of document.querySelectorAll('.product__about .prose p, .feats span')) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const n of nodes) { + const txt = n.nodeValue; if (!re.test(txt)) continue; re.lastIndex = 0; + const frag = document.createDocumentFragment(); let last = 0, m; + while ((m = re.exec(txt))) { + frag.append(txt.slice(last, m.index)); + const w = document.createElement('span'); w.className = 'mw'; w.textContent = m[1]; w.tabIndex = 0; w.setAttribute('role', 'button'); + w.dataset.k = m[1]; w.setAttribute('aria-label', `${m[1]}: Paul makes it appear`); frag.append(w); last = m.index + m[1].length; + } + frag.append(txt.slice(last)); n.replaceWith(frag); + } + } + const magic = w => { + if (w.classList.contains('is-tracing')) return; + const [key, de] = WORDS[w.dataset.k]; + w.classList.remove('is-traced'); void w.offsetWidth; w.classList.add('is-tracing'); + setTimeout(() => { w.classList.add('is-traced'); }, 900); + setTimeout(() => w.classList.remove('is-tracing', 'is-traced'), 2600); + measure(); + const m = mrect(), r = w.getBoundingClientRect(), wx = (r.left + r.right) / 2 - m.left, wy = r.top - m.top; + const s = surfBelow(wx, r.bottom - m.top + 2); + say(`${de} · ${key}`, 2600); + let done = false; + const appear = () => { if (done) return; done = true; fx('sparkle', wx, wy + 8); setTimeout(() => mkThing(key, wx, wy + 14), 180); }; + if (P.state === 'ground' && s && !reduced()) { + const side = P.x < wx ? -1 : 1; + P.goal = 'word'; + travel(s, wx + side * 46, () => { + P.goal = null; P.face = wx >= P.x ? 1 : -1; + doAnim('cast', () => { appear(); doAnim('cheer', () => idle(.8)); }); + setTimeout(appear, 2600); + }); + setTimeout(appear, 5200); // he could not get there: the thing appears anyway + } else setTimeout(appear, 700); + }; + document.querySelectorAll('.mw').forEach(w => { + w.addEventListener('click', () => magic(w)); + w.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); magic(w); } }); + }); + + // ---------- the loop ---------- + const fall = (o, dt, only) => { // gravity and landing; returns the surface it landed on + const y0 = o.y; o.vy += G * dt; o.y += o.vy * dt; o.x += o.vx * dt; o.vx *= Math.pow(.6, dt); + if (o.x < 8) { o.x = 8; o.vx = Math.abs(o.vx) * .5; } if (o.x > W - 8) { o.x = W - 8; o.vx = -Math.abs(o.vx) * .5; } + if (o.vy > 0) { + const oy = only ? yAt(only, o.x) : 0, s = only ? (oy >= y0 && oy <= o.y && o.x >= only.x1 - 30 && o.x <= only.x2 + 30 ? only : null) : surfAt(o.x, y0, o.y); + if (s) { o.x = Math.max(s.x1 + 4, Math.min(s.x2 - 4, o.x)); o.y = yAt(s, o.x); return s; } + } + if (o.y > H) { o.y = H - 2; return surfs[surfs.length - 1]; } + return null; + }; + let pointer = null; + addEventListener('pointermove', e => { const m = mrect(); pointer = { x: e.clientX - m.left, y: e.clientY - m.top, t: now }; }, { passive: true }); + let offT = 0; + + let last = performance.now(); + const tick = t => { + now = t; const dt = Math.min(.05, (t - last) / 1000); last = t; + // Paul + if (P.state === 'held' && held) { + const px = P.x; P.x += (held.tx - P.x) * Math.min(1, dt * 18); P.y += (held.ty - P.y) * Math.min(1, dt * 18); + const vx = (P.x - px) / dt; P.spr.rot += (Math.max(-28, Math.min(28, -vx * .05)) - P.spr.rot) * Math.min(1, dt * 8); + if (Math.abs(vx) > 30) P.face = vx > 0 ? 1 : -1; + } else if (P.state === 'air') { + P.spr.rot *= .9; const vy = P.vy; const s = fall(P, dt); if (s) land(s, vy); + } else if (P.state === 'hop') { + const vy = P.vy; const s = fall(P, dt, P.hopTo); if (s) land(s, Math.min(vy, 600)); + } else if (P.state === 'lift') { + const tx = P.liftX, ty = yAt(P.liftTo, P.liftX); + if (Math.hypot(B.x - P.x, B.y - (P.y - 66)) > 12 && !P.lifting) { /* the bot comes first */ } + else { P.lifting = true; P.x += (tx - P.x) * Math.min(1, dt * 1.6); P.y += (ty - 12 - P.y) * Math.min(1, dt * 1.6); + if (Math.abs(P.x - tx) < 3 && Math.abs(P.y - (yAt(P.liftTo, tx) - 12)) < 3) { P.lifting = false; P.state = 'air'; P.vx = 0; P.vy = 0; B.state = 'follow'; P.next = P.next || null; } } + } else if (P.state === 'ground') { + if (P.mode === 'walk') { + const sp = P.fast ? 110 : 46, d = P.tx - P.x; P.face = d >= 0 ? 1 : -1; + if (Math.abs(d) <= sp * dt) { P.x = P.tx; P.mode = 'idle'; P.timer = .1; } else P.x += Math.sign(d) * sp * dt; + } else if (P.mode === 'anim') { if (P.spr.ended) { P.mode = 'idle'; P.timer = 0; if (!P.after) P.timer = .4; } } + else { P.timer -= dt; if (pointer && now - pointer.t < 1500 && Math.abs(pointer.x - P.x) < 220 && Math.abs(pointer.y - P.y) < 160) P.face = pointer.x >= P.x ? 1 : -1; + if (P.timer <= 0) decide(); } + // a line that moved (resize, a picture that loaded) + if (P.surf) { const gy = yAt(P.surf, P.x), d = gy - P.y; // step up onto tall letters with a little hop, down with a drop + if (Math.abs(d) > 1) P.y += d * Math.min(1, dt * (d < 0 ? 16 : 22)); else P.y = gy; } + } + const pa = paulAnim(); if (P.mode !== 'anim' || P.state !== 'ground') P.spr.play(pa); P.spr.x = P.x; P.spr.y = P.y; P.spr.face = P.face; + if (P.carry) { const c = P.carry; c.x = P.x + P.face * 2; c.y = P.y - 70; } + + // the bot + const bob = 4 * Math.sin(now / 430); + if (B.state === 'held' && held) { B.x += (held.tx - B.x) * Math.min(1, dt * 18); B.y += (held.ty - B.y) * Math.min(1, dt * 18); B.spr.play('wave'); } + else { + let tx, ty, k = 3; + if (B.state === 'lift') { tx = P.x; ty = P.y - 66; k = P.lifting ? 30 : 5; } + else if (P.state === 'held') { tx = P.x - 60 * P.face; ty = P.y - 40; k = 4; } + else { tx = P.x - 48 * P.face; ty = P.y - 74 + bob; } + if (B.vx || B.vy) { B.x += B.vx * dt; B.y += B.vy * dt; B.vx *= Math.pow(.02, dt); B.vy *= Math.pow(.02, dt); if (Math.hypot(B.vx, B.vy) < 20) B.vx = B.vy = 0; } + const dx = tx - B.x, dy = ty - B.y; B.x += dx * Math.min(1, dt * k); B.y += dy * Math.min(1, dt * k); + if (!B.spr.once || B.spr.ended) B.spr.play(B.state === 'lift' ? 'carry' : (Math.hypot(dx, dy) > 14 ? 'move' : 'idle')); + if (Math.abs(dx) > 6) B.spr.face = dx > 0 ? 1 : -1; else if (B.state !== 'lift') B.spr.face = P.face; + } + B.spr.x = B.x; B.spr.y = B.y; + { const bw = bubble.offsetWidth, bh = bubble.offsetHeight, vt = view().t; let by = B.y - 52 - bh, bx = B.x - bw / 2; + if (by < Math.max(4, vt + 4)) { by = B.y - 40; bx = B.spr.face > 0 ? B.x + 26 : B.x - 26 - bw; bubble.classList.add('is-side'); } else bubble.classList.remove('is-side'); + bubble.style.transform = `translate(${Math.round(Math.max(8, Math.min(W - bw - 8, bx)))}px, ${Math.round(by)}px)`; } + if (bubbleT && now > bubbleT) { bubble.classList.remove('is-on'); bubbleT = 0; } + + // things + for (const o of things) { + if (o.state === 'held' && held && held.obj === o) { o.x += (held.tx - o.x) * Math.min(1, dt * 18); o.y += (held.ty - o.y) * Math.min(1, dt * 18); } + else if (o.state === 'air') { + const vy = o.vy, s = fall(o, dt); + // Paul catches a thing that flies at him + if (!P.carry && P.state === 'ground' && Math.abs(o.x - P.x) < 26 && o.y > P.y - 90 && o.y < P.y - 40 && vy > -200) { + P.carry = o; o.state = 'carried'; o.spr.z = 4; P.after = null; doAnim('cheer', () => idle(1)); say('Got it!', 1400); + } else if (s) { if (vy > 500) { o.vy = -vy * .3; o.vx *= .6; } else { o.state = 'ground'; o.surf = s; o.y = s.y; o.vx = 0; o.vy = 0; } } + } else if (o.state === 'ground' && o.surf) o.y = yAt(o.surf, o.x); + o.spr.x = o.x; o.spr.y = o.y; + } + + // follow the reader: off screen for a while, he comes along + if (!held && P.state === 'ground' && !P.goal && !reduced()) { + const v = view(), off = P.y < v.t - 10 ? 'up' : P.y > v.b + 30 ? 'down' : null; + if (off) { offT += dt; if (offT > 1.4) { offT = 0; + const cands = surfs.filter(s => s.y > v.t + 130 && s.y < v.b - 50 && s.kind !== 'floor'); + if (cands.length) { const s = off === 'up' ? cands.reduce((a, b) => (a.y < b.y ? a : b)) : cands.reduce((a, b) => (a.y > b.y ? a : b)); + P.goal = 'follow'; travel(s, Math.max(s.x1 + 40, Math.min(s.x2 - 40, P.x)), () => { P.goal = null; idle(.6); }); } } } + else offT = 0; + } + + for (const s of all) { s.draw(); if (s.fx && s.ended) s.remove(); } + requestAnimationFrame(tick); + }; + + // ---------- start ---------- + const start = () => { + measure(); + const t = surfs.find(s => s.kind === 'title') || surfs[0]; + P.surf = t; P.x = t.x2 - 30; P.y = t.y; P.face = -1; + B.x = P.x + 60; B.y = P.y - 80; + const head = document.querySelector('.post-head'); + const hint = document.createElement('p'); hint.className = 'label play-hint'; + hint.textContent = 'Paul lives on this page. Pick him up, or tap a dotted word.'; head.appendChild(hint); + measure(); P.surf = (W < 700 ? surfs.find(s => s.kind === 'rule') : surfs.find(s => s.kind === 'title')) || surfs[0]; P.x = W < 700 ? W * .7 : P.surf.x2 - 30; P.y = yAt(P.surf, P.x); + B.x = P.x + 50; B.y = P.y - 80; + setTimeout(() => say('Hello! You can pick Paul up.', 3400), 1400); + setTimeout(() => { if (!P.wasHeld) say('Or tap a dotted word.', 3000); }, 9000); + requestAnimationFrame(tick); + let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { const old = P.surf; measure(); P.surf = surfs.find(s => s.el === old.el && s.kind === old.kind) || surfBelow(P.x, P.y - 200) || surfs[0]; + P.x = Math.max(P.surf.x1 + 10, Math.min(P.surf.x2 - 10, P.x)); P.y = yAt(P.surf, P.x); + for (const o of things) if (o.surf) { o.state = 'air'; o.y -= 20; } }, 200); }); + setInterval(() => { const old = P.surf; measure(); if (old) { const n = surfs.find(s => s.el === old.el && s.kind === old.kind); if (n) P.surf = n; } + for (const o of things) if (o.surf) { const n = surfs.find(s => s.el === o.surf.el && s.kind === o.surf.kind); if (n) o.surf = n; } }, 1500); + }; + const go = () => (document.fonts ? document.fonts.ready : Promise.resolve()).then(start); + if (document.readyState === 'complete') go(); else addEventListener('load', go); +})(); diff --git a/assets/play/play.css b/assets/play/play.css new file mode 100644 index 0000000..1953626 --- /dev/null +++ b/assets/play/play.css @@ -0,0 +1,48 @@ +/* the game characters that live on a game page (mock-up) */ +#main.has-play { position: relative; } +.play-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 5; } +.play-spr { position: absolute; left: 0; top: 0; background-repeat: no-repeat; image-rendering: pixelated; pointer-events: auto; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; } +.play-spr:active, .play-spr.is-held { cursor: grabbing; } +.play-fx { position: absolute; left: 0; top: 0; background-repeat: no-repeat; image-rendering: pixelated; pointer-events: none; } +.play-img { position: absolute; left: 0; top: 0; max-width: none; pointer-events: auto; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-user-drag: none; } +.play-img.is-static { pointer-events: none; } +.play-ring { position: absolute; left: 0; top: 0; width: 10px; height: 10px; margin: -5px 0 0 -5px; border: 1px solid var(--ink); border-radius: 50%; pointer-events: none; } +.play-bubble { position: absolute; left: 0; top: 0; padding: .35rem .55rem; background: var(--bg); color: var(--ink); border: 1px solid var(--ink); font: 500 var(--step--1)/1.3 var(--mono); letter-spacing: .04em; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity .2s; } +.play-bubble.is-on { opacity: 1; } +.play-bubble::after { content: ""; position: absolute; left: 50%; bottom: -6px; width: 9px; height: 9px; margin-left: -5px; background: var(--bg); border-right: 1px solid var(--ink); border-bottom: 1px solid var(--ink); transform: rotate(45deg); } +.play-hint { margin-top: .25rem; } + +/* Wortzauber: words that make things appear */ +.mw { text-decoration: underline dotted; text-decoration-thickness: 2px; text-underline-offset: 4px; cursor: pointer; border-radius: 0; } +.mw:hover { background: var(--ink); color: var(--bg); } +.mw.is-tracing, .mw.is-tracing:hover { background: none; color: transparent; -webkit-text-stroke: 1px var(--ink); text-decoration-style: solid; transition: color .9s steps(6); } +.mw.is-traced, .mw.is-traced:hover { background: none; color: var(--ink); -webkit-text-stroke: 0; transition: color .9s; } + +/* Kleinbahn: the railway on the rule under the header, with scenery drawn in ink */ +.has-rail .post-head { padding-bottom: calc(clamp(1.75rem, 4.5vw, 3.5rem) + 8.5rem); } +@media (max-width: 700px) { .has-rail .post-head { padding-bottom: calc(clamp(1.75rem, 4.5vw, 3.5rem) + 6rem); } } +.kb-sleepers { position: absolute; left: 0; top: 0; height: 6px; background: repeating-linear-gradient(90deg, var(--ink) 0 1.5px, transparent 1.5px 13px); pointer-events: none; } +.kb-hill, .kb-building, .kb-lamp { position: absolute; left: 0; top: 0; pointer-events: none; } +.kb-hill { z-index: 6; } .kb-hill svg, .kb-building svg, .kb-lamp svg { display: block; overflow: visible; } +.kb-hill__fill { fill: var(--bg); } +.kb-hatch { stroke: var(--soft); stroke-width: 1; } +.kb-ink { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linejoin: round; } +.kb-ink.kb-thin { stroke-width: 1; } +.kb-ink.kb-paper, .kb-hill__fill.kb-ink { fill: var(--bg); } +.kb-name { font: 500 7px var(--mono); letter-spacing: .12em; fill: var(--ink); } +.kb-building { z-index: 1; } .kb-lamp { z-index: 2; } +.kb-platform { position: absolute; left: 0; top: 0; height: 10px; border: 1px solid var(--ink); border-bottom: 0; background: var(--surface); pointer-events: none; z-index: 2; } +.kb-signal { position: absolute; left: 0; top: 0; width: 34px; height: 62px; padding: 0; border: 0; background: none; cursor: pointer; pointer-events: auto; color: var(--ink); z-index: 3; } +.kb-signal svg { display: block; width: 100%; height: 100%; overflow: visible; } +.kb-signal .arm { transform-origin: 17px 12px; transition: transform .45s cubic-bezier(.3, 1.6, .5, 1); } +.kb-signal[aria-pressed="true"] .arm { transform: rotate(-45deg); } +.kb-signal .ink { stroke: currentColor; stroke-width: 1.5; fill: none; } +.kb-signal .fill { fill: currentColor; } +.kb-signal .hole { fill: var(--bg); stroke: currentColor; stroke-width: 1.5; } +.kb-signal:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; } +.kb-car { position: absolute; left: 0; top: 0; background-repeat: no-repeat; pointer-events: auto; cursor: grab; touch-action: none; z-index: 4; } +.kb-car.is-held { cursor: grabbing; } +.has-rail .play-img { z-index: 3; } .has-rail .play-ring { z-index: 7; } .has-rail .play-bubble { z-index: 8; } +.kb-smoke { position: absolute; inset: 0; pointer-events: none; z-index: 5; } +.kb-smoke svg { display: block; overflow: visible; } +.kb-ground { stroke-width: 1.5; } diff --git a/assets/play/sheep2.png b/assets/play/sheep2.png new file mode 100644 index 0000000..77a6cbe Binary files /dev/null and b/assets/play/sheep2.png differ diff --git a/assets/play/smoke_puff.png b/assets/play/smoke_puff.png new file mode 100644 index 0000000..ee385b6 Binary files /dev/null and b/assets/play/smoke_puff.png differ diff --git a/assets/play/summon_sparkle.png b/assets/play/summon_sparkle.png new file mode 100644 index 0000000..80f7094 Binary files /dev/null and b/assets/play/summon_sparkle.png differ diff --git a/assets/site.css b/assets/site.css index 9dd1f13..a896479 100644 --- a/assets/site.css +++ b/assets/site.css @@ -408,9 +408,9 @@ blockquote { margin: 0; padding: .2rem 0 .2rem 1.2rem; border-left: 3px solid va .prose .pick__list a:hover { background: var(--ink); color: var(--bg); outline: 0; } .pick__hint { margin: 0; font-size: .9688rem; color: var(--muted); } .pick__hint[hidden] { display: none; } -/* a figure can hold a wide drawing (.dg-w) and a narrow one for phones (.dg-n) */ -.anim .dg-n { display: none; } -@media (max-width: 600px) { .anim .dg-w { display: none; } .anim .dg-n { display: block; } .fig-wide.anim { overflow-x: visible; } .fig-wide .dg-n { min-width: 0; } } +/* a figure (animated or with steps) can hold a wide drawing (.dg-w) and a narrow one for phones (.dg-n) */ +.anim .dg-n, [data-steps] .dg-n { display: none; } +@media (max-width: 600px) { .anim .dg-w, [data-steps] .dg-w { display: none; } .anim .dg-n, [data-steps] .dg-n { display: block; } .fig-wide.anim { overflow-x: visible; } .fig-wide .dg-n { min-width: 0; } } @media (prefers-reduced-motion: reduce) { .anim .draw, .anim .grow, .anim .grow-l, .anim .pop, .anim .w-a, .anim .w-b, .anim .fade, .anim .lvl { transition: none; } } diff --git a/assets/site.js b/assets/site.js index 938ceb5..81df87b 100644 --- a/assets/site.js +++ b/assets/site.js @@ -119,7 +119,7 @@ $$('figure[data-steps]').forEach(function (fig) { var svg = $('svg', fig), items = $$('.steps__list li', fig), n = items.length; if (!svg || !n) return; - var parts = $$('[data-step]', svg), at = 0, timer = null; + var parts = $$('[data-step]', fig), at = 0, timer = null; var bar = document.createElement('div'); bar.className = 'steps__bar'; function button(label, text) { var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn--ghost btn--s'; diff --git a/index.html b/index.html index 416c3cb..e9fff31 100644 --- a/index.html +++ b/index.html @@ -47,7 +47,7 @@ <h1>Tim Conrad</h1> </div> <dl class="tb"> <div><dt>Site</dt><dd>t7d.dev</dd></div> - <div><dt>Contents</dt><dd>2 games, 2 thoughts, 1 write-up, 1 tool</dd></div> + <div><dt>Contents</dt><dd>2 games, 5 thoughts, 1 write-up, 1 tool</dd></div> <div class="wide"><dt>Contact</dt><dd><a href="https://de.linkedin.com/in/tim-c-94397a200/de" rel="me noopener">LinkedIn</a>, <a href="https://github.com/Tim-Conrad" rel="me noopener">GitHub</a></dd></div> <div class="wide"><dt>PGP fingerprint</dt><dd class="mono">A886 6CB1 6B07 9685 5615<br>74B2 A91D ABBE C996 7390</dd></div> </dl> @@ -93,49 +93,47 @@ <h2>Thoughts</h2> <p>Ideas I am working through. Each one keeps its sources and a list of what changed. All of them are on <a href="/thoughts/">the list</a>, and in the <a href="/thoughts/feed.xml">RSS feed</a>.</p> </div> <div class="projects"> - <a class="proj" href="/thoughts/humans-md/"> - <p class="label"><time datetime="2026-11-10">10 Nov 2026</time> · draft · working with models</p> - <h3>A humans.md for working with models</h3> - <svg class="dg" viewBox="0 0 640 360" role="img" aria-labelledby="hm-t"> - <title id="hm-t">Two files side by side: CLAUDE.md for the agent, HUMANS.md for me, with a log per model. - - CLAUDE.md - - - - - for the agent - - HUMANS.md - - - - - - for me + +

· draft · security · coding agents

+

Agents without long-lived secrets, approved from my phone

+ + Today an agent reads keys that never expire; with grants it holds a short grant from the phone, bound to its own key. + + API_KEY=sk-… + DEPLOY_KEY + GH_TOKEN=… + no expiry + what an agent reads today + + + git push + 2 h + + Approve + a grant from the phone -

My repositories tell the agent how to work with me, but nothing tells me how to work with the agent. A first version of that file, and what changes when a new model ships.

+

My agents can read keys that never expire. A design where they hold none: tokens bound to a key with DPoP, a phone that shows what an agent wants to do, and a gateway that keeps the last keys.

Read the thought
- -

· draft · updates · key release

-

Encrypted patches, unlocked on patch day

- - The encrypted bundle reaches every host before patch day; at T only the key is published. - patch day T - - - encrypted bundle - - install - - diff → exploit - - key at T - - −7 days + +

· draft · encryption · static sites

+

A private site on public static hosting

+ + Readable files for the reader, random blob names of the same sizes for the host. + + index.html + map.png + theme.mp3 + chapter-2 + for the reader + + 3f9a0c71 + e6c1a30f + 58ab07e9 + 0d2f98a5 + for the host -

Ship a security fix early as ciphertext and publish the key on patch day, so teams can stage it and apply it together. Games, a design with signed keys, open source in private, and the patents.

+

sealed puts a whole static site on a public host in encrypted form, with a password per reader. What the host sees, how a password opens a file, and what it does not protect.

Read the thought
diff --git a/projects/kleinbahn/index.html b/projects/kleinbahn/index.html index d8be74b..02b4708 100644 --- a/projects/kleinbahn/index.html +++ b/projects/kleinbahn/index.html @@ -18,6 +18,8 @@ + + @@ -84,13 +86,6 @@

Play it here

-
-
A steam train crossing a road next to a carousel.
-
The town centre with timber-framed houses, a windmill and a train passing.
-
A space shuttle lifting off from the launch site in a cloud of smoke.
-
The main island at night with street lamps lit.
-
-

How it changed

One entry per merged pull request that changed play, newest first. Each clip is filmed from the game at the state of that pull request.

diff --git a/projects/wortzauber/index.html b/projects/wortzauber/index.html index ff3fce1..81eaf51 100644 --- a/projects/wortzauber/index.html +++ b/projects/wortzauber/index.html @@ -18,6 +18,8 @@ + + @@ -84,13 +86,6 @@

Play it here

-
-
The word apfel being traced letter by letter on school lines.
-
Berta the baker greets the hero in her bakery.
-
The hero and the hovering robot at the castle scaffold with two letters collected.
-
The map between the castle and the bakery village.
-
-

How it changed

One entry per merged pull request that changed play, newest first. Each clip is filmed from the game at the state of that pull request. The clips are silent.

diff --git a/sitemap.xml b/sitemap.xml index 6d9878d..d0d8ba1 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,6 +2,9 @@ https://t7d.dev/ https://t7d.dev/thoughts/ + https://t7d.dev/thoughts/no-long-lived-secrets/ + https://t7d.dev/thoughts/sealed/ + https://t7d.dev/thoughts/decision-models/ https://t7d.dev/thoughts/humans-md/ https://t7d.dev/tools/ https://t7d.dev/tools/papercuts/ diff --git a/thoughts/decision-models/index.html b/thoughts/decision-models/index.html new file mode 100644 index 0000000..500f663 --- /dev/null +++ b/thoughts/decision-models/index.html @@ -0,0 +1,344 @@ + + + + + + +When a small decision model can replace an LLM call · Tim Conrad + + + + + + + + + + + + + + + + + + + + + +
+ +
+ ← Thoughts +

When a small decision model can replace an LLM call

+

Amazon replaced Claude Sonnet 4.6 with a small model that only picks from a list. How that is done, step by step, and how I would try it at home and at work.

+

Tim Conrad · Language models · Method · Cost

+
+
Status
Draft
+
Started
+
Last revised
+
+
+ +
+ + +
+

One model for everything

+

I use whatever model the session runs, and that is a frontier model, for everything. I rarely think about what one call costs. A large model that writes a paragraph of reasoning before it picks one word from a list of twenty is the same model that refactors a codebase.

+

A post from the Strands Agents team made me stop on that. Two Amazon engineers replaced Claude Sonnet 4.6 with a small decision model in a ticketing system, at a fraction of the cost [1]. The case itself matters less to me than the method. How do you do this well, and how would I do it at home and at work?

+ +

What Amazon did

+

The post is by Carr Onstott, Mehul Jaiswal and the Strands Agents team, from 9 November 2026 [1]. Sonnet 4.6 put each ticket into one of 20 categories, with a long system prompt and a JSON answer with reasoning. They replaced it with Strands Decider on Gemma 4 E4B. Per ticket, 6,724 tokens became 2,010, the average call took 897 ms instead of 4,215 ms, and the cost at list prices fell from $0.0222 to about $0.0005.

+

They name the limits themselves. It worked because the task is "a closed choice from a known catalog, the volume is high, and the latency of the old path mattered." If the labels need explanation text, the catalog changes weekly, or the context does not fit into a few thousand tokens, "a decision model probably can't replace the LLM's job" [1].

+ +

A model that only picks

+

A decision model does not write text. It gets a state and a question with options, and returns a probability for each option and a confidence [1]. It answers three kinds of question: pick one of several options, yes or no, and a level on a scale [4].

+
+ + The same ticket, labelled by an LLM and by a decision model + A ticket goes into a model. The LLM reads a long prompt of 6,554 tokens and writes JSON with a label and its reasoning: 170 tokens out, 4.2 seconds on average. The decision model reads a short prompt of 2,009 tokens and returns one probability per label. The top label, capacity request at 0.71, is above the threshold, so it is applied: 1 token out, 0.9 seconds on average. The token and time figures are Amazon's; the probabilities are an example. + Ticket + + + + + + + comments, alarms, logs + + + + LLM + Decision model + 6,554 tokens in + 2,009 tokens in + + + Writes an answer + Returns probabilities + + + {"label": "capacity", + "reasoning": "The ticket + asks for more hosts in + one region before a + launch, so ..."} + 170 tokens out · 4.2 s + + + capacity + + alarm + + security + + escalation + + none + + + threshold + 1 token out · 0.9 s + + + + The same ticket, labelled by an LLM and by a decision model + Ticket + + + + + + LLM + Decision model + 6,554 tokens in + 2,009 tokens in + + + + {"label": "capacity", + "reasoning": "The ticket asks + for more hosts in one region + before a launch, so ..."} + 170 tokens out · 4.2 s + + + capacity + + alarm + + security + + escalation + + none + + + threshold + 1 token out · 0.9 s + + +
Fig. 1 · An LLM writes an answer; a decision model returns one probability per label, and the top one counts only above a threshold. Tokens and times are Amazon's averages [1]; the probabilities are an example.
+
+

Three properties make the method work:

+
    +
  • A confidence I can act on. The README says that on short classification tasks the model has never seen, answers with a confidence of 0.9 or more are right about 95% of the time; below that, "confirm or ask a person" [2]. On unseen tasks, picking an option is useful behind such a gate; a level on a scale is not [3].
  • +
  • More questions cost almost nothing. The state is read once, and only the short questions are added for each one. On their GPU, eight questions about a 2,000-token state took 369 ms, against 1,964 ms when each was asked on its own [5]. So "which team", "how urgent" and "is it a duplicate" can be one call.
  • +
  • Open and local. The repository is under Apache 2.0, and the models are open, from 2B to 26B parameters. They can serve on a CPU [2].
  • +
+ +

The method, step by step

+

The best description of the method I found is not the blog post, but a guide in the same repository, written for a coding agent that helps a user try the model on their own data [4]. It agrees with what Amazon did, and goes further. Its rule: measure first, and train only when the numbers show a gap that training can close. Here is my summary, with Amazon's experience added where it fits.

+
+ + Eight steps from an LLM call to a decision model + Check the fit, write the task card, build a frozen test set, score every candidate on it, climb the ladder from rewording to training, set the thresholds, run old and new side by side, then watch and re-check. + 1Fit?fixed options + 2Task cardoptions, error costs + 3Test setreal items, frozen + 4Baselinesall on the same items + 5Laddertrain only if needed + 6Thresholdsact, confirm, person + 7Side by sideboth on real traffic + 8Watchlog and re-check + + + Eight steps from an LLM call to a decision model + 1Fit?fixed options + 2Task cardoptions, error costs + 3Test setreal items, frozen + 4Baselinesall on the same items + 5Laddertrain only if needed + 6Thresholdsact, confirm, person + 7Side by sideboth on real traffic + 8Watchlog and re-check + +
+
    +
  1. Is it a decision? The answer must be one of a fixed set of options, known before the call, and what the model needs to see must fit into its window of 4,096 tokens.
  2. +
  3. Write the task card: the question, each option with a one-line description, a "none" option, what happens after each answer, and what each kind of mistake costs.
  4. +
  5. Build a test set from real items, with labels from an outcome that is already recorded. Split it by time, and freeze it.
  6. +
  7. Score the current way, every model size and the simplest guess on the same items. Only then is a number worth anything.
  8. +
  9. Climb the ladder and stop at the first rung that is good enough: reword, recalibrate, a larger model, and only then train.
  10. +
  11. Set the thresholds from the costs of the mistakes: act alone above one confidence, ask below it.
  12. +
  13. Run the old and the new way on the same real traffic. The old one decides; every disagreement gets read.
  14. +
  15. Switch with a setting that can go back. Log every answer with its probabilities, label a fresh sample each month, and score it again.
  16. +
+
+
Fig. 2 · The order of the work. Steps 1 to 6 need no traffic and no money; only step 5 may need a GPU, and only at its last rung.
+
+ +

1. Is it a decision?

+

The guide names good fits: routing, triage, choosing a tool, guardrails and policy checks, checking whether an answer is adequate, grading against a rubric. Poor fits: the answer is free text, the options are not known before the call, the decision needs many steps of reasoning that are not in the input, or the input is much longer than the window [4]. If it is a poor fit, stop here.

+ +

2. The task card

+

One short document per decision. The question in exact words. Each option with a one-line description, because the model reads the descriptions. What the application does with each answer. And the cost of each kind of mistake: a false "urgent" wakes a person at night, a missed "urgent" loses a customer [4].

+

Amazon did the same. Their label descriptions alone were longer than the model's window, so they rewrote each of the 20 labels as a one- or two-sentence criterion and added "none" [1]. The guide says to try the wording on 20 to 50 real examples: rewording costs nothing and "often moves accuracy by several points" [4]. I like that this step is useful even if no model ever reads the card.

+ +

3. A test set from real outcomes

+

The labels should come from an outcome that is already recorded, not from someone's first guess. The guide's example: if each ticket records the team that finally closed it, that team is the label. The share of tickets that were passed on to another team then gives the accuracy of the current process for free. The passed-on tickets are the hard cases, and worth a group of their own [4].

+

Size: at least 1,000 items per decision for the test, and about as many again to calibrate. Split by time: the most recent period is the test, the older data is for training. Then freeze the files with their hashes. A test set that changes makes every earlier number useless [4].

+ +

4. Baselines first

+

Score everything on the same items: the current way, every model size, and the simplest guess of all, always the most common answer. A model below that simple guess has learned nothing, however good its accuracy looks: 29% on a choice of ten is worse than a most common answer that holds 30% [4]. Report the accuracy with its interval, the share of answers above 0.9 confidence, their accuracy, the latency and the memory.

+

Amazon did a short version: they tried the 2B model, saw the E4B do clearly better on a test set, and switched [1].

+ +

5. The ladder

+

Climb and stop at the first rung that meets the target [4]:

+
    +
  1. Reword the question and the descriptions.
  2. +
  3. Recalibrate a released model on my own data. This needs no training, and is often the largest cheap gain.
  4. +
  5. Take a larger released model. It usually gains more than training a small one, and needs no training data.
  6. +
  7. Fine-tune, only when the gap is larger than the noise of the test set, there are a few thousand correct labels per decision, and the GPU cost is accepted. Train three seeds, not one, and keep the result only if it beats the released model on the same items.
  8. +
+

Amazon seems to have stopped at rung 3: a larger released model and a tuned threshold [1]. That is the part I find most encouraging. The training I would expect to be the hard part is the last resort.

+ +

6. Thresholds from the cost of mistakes

+

The guide's starting bands: act alone at 0.9 or more, confirm between 0.5 and 0.9, send to a person below 0.5. "A start, not a rule" [4]. The real thresholds come from the task card: where a mistake is expensive, the bar goes up. A yes-or-no question has no separate confidence, only the probability of yes, so it gets one or two cut points, for example: page a person above 0.8, queue for review between 0.4 and 0.8, ignore below 0.4.

+

Below the threshold, the item does not fail. It goes to a person, or to the large model. The small model takes the clear cases off the large one, and the large one stays for the rest.

+ +

7. Side by side on real traffic

+

Amazon's switch had three settings: only Sonnet; both, with Sonnet's answer applied and the decider's compared; only the decider [1]. They call it the part they would copy "regardless of model": running both on real traffic and judging every disagreement "told us more than any offline eval would have."

+ +

8. Switch, then watch

+

Switching back was a 45-second config change for Amazon [1]. After the switch: log every answer with its probabilities. Each month, or when the traffic changes, label a fresh sample and score it again. If the accuracy falls at the same confidence, the traffic moved; recalibrate or retrain [4].

+

And keep the input stable. At Amazon the order of the keys in the JSON input moved the probabilities by up to 0.19, so they now sort the keys on every request [1]. A changed format is a changed test.

+ +

At home

+

At home the first five steps fit into a laptop, and nothing has to leave it. The model installs as a Python package with a command-line tool: strands-decider ask answers one question, strands-decider serve runs a small local web server for many [2]. The command-line tool takes options without descriptions; for the full task card, the guide sends requests to the local server [4].

+

It runs on a CPU, an Apple GPU or an NVIDIA GPU. A CPU is much slower: the repository measured several seconds per question for the larger models on a large CPU server, and a median of 234 ms for the 2B model on an M3 Pro laptop [3] [5]. For a first test on a few hundred items that is enough.

+

What I need for a first try:

+
    +
  • One decision I already make, or have a model make, often, with an outcome I can check.
  • +
  • A task card for it.
  • +
  • A few hundred items with the right answer, to start with. The 1,000 of the guide come later, if it looks promising.
  • +
  • A count: how often does the model agree with the right answer at each confidence, and how does that compare with always guessing the most common answer?
  • +
+

That tells me whether the idea holds for my data before I spend anything.

+ +

At work

+

At work the steps are the same. Four things change.

+
    +
  • The data. The guide asks before anything moves the data off the machine: a cloud host, an outside LLM used to label, a storage bucket [4]. A small open model on our own hardware keeps tickets, findings and customer text in house. For many teams that matters more than the price.
  • +
  • The cost. The tokens per ticket fell by 70%, not 98%. Most of Amazon's saving comes from paying per instance-hour, $1.408, instead of per token. An instance costs the same busy or idle. My own sum from the post's numbers: one instance-hour costs as much as about 63 Sonnet tickets. Below about 63 tickets an hour, or about 46,000 a month, an instance that runs all the time costs more than calling Sonnet. That leaves out cheaper ways to run it: on a CPU we already have, or on AWS Lambda, which the repository supports for the 2B model, with requests that take seconds [6].
  • +
  • The people. Someone owns the labels, someone agrees on what each mistake costs, and someone reads the disagreements in step 7. That is the real work, more than the model.
  • +
  • The input may be hostile. In security work a report or a log line can be written by an attacker, with text meant to steer the model. A decision model cannot write text or call tools, so an injected instruction can at most move the label. That is still bad when the label decides that a finding is ignored. So "dismiss" needs a higher confidence than "look at this".
  • +
+ +

What the post leaves out

+
    +
  • No accuracy figure. The post says the E4B was "beating production Sonnet" and "performed just as well". It gives no number, no count of disagreements, and does not say how each disagreement was judged, or by whom. The guide in the same repository asks for exactly that: intervals, and a paired comparison on the same items [4].
  • +
  • Two latencies. One request took about 640 ms; the average call took 897 ms. The second probably includes the network and queueing, but the post does not say.
  • +
  • A vendor post. It is written by the team that makes the model, on its own site. That does not make it wrong. It means I want my own numbers before I believe "just as well".
  • +
+ +

When to think about a specialised model

+

My checklist, for now. The more of these are true, the more a decision model is worth a try:

+
    +
  • The answer is one item from a fixed list, a yes or no, or a level. Nobody reads the reasoning.
  • +
  • The list changes rarely, not every week.
  • +
  • What the model needs to see fits into a few thousand tokens.
  • +
  • The same question comes up often: hundreds of times a day, not ten.
  • +
  • There is a recorded outcome to build a test set from.
  • +
  • Speed matters, or the data should not leave the house.
  • +
  • I can say what each mistake costs, and route the unsure cases to a person.
  • +
  • I can run the old and the new way side by side before I switch.
  • +
+

If only the first one is true, the frontier model stays. It is the default for a reason: it is the right tool for the tasks that are not a list.

+ +

Open questions

+
    +
  • How did Amazon judge the disagreements between Sonnet and the decision model, and how many were there?
  • +
  • Which decision of mine is the first good candidate, with a recorded outcome to test against?
  • +
  • How well does a 2B model do on German text, and on technical jargon?
  • +
  • How easy is it to move the label with text written into the input? That needs a test of its own.
  • +
+ +

Starting points

+
    +
  • Carr Onstott, Mehul Jaiswal, Strands Agents team: "Amazon saves millions by replacing Sonnet 4.6 with Strands Decider", 9 Nov 2026. It claims that a small decision model labels tickets as well as Sonnet 4.6 at 98% less cost [1].
  • +
  • My own question: what is the method behind it, and how would I do it at home and at work?
  • +
+ +

Sources

+
    +
  1. Carr Onstott, Mehul Jaiswal, Strands Agents team: Amazon saves millions by replacing Sonnet 4.6 with Strands Decider, 9 Nov 2026.
  2. +
  3. strands-labs: strands-decider, README.
  4. +
  5. strands-labs: strands-decider, evaluation/results.md.
  6. +
  7. strands-labs: strands-decider, skills/customize-decider/SKILL.md, the guide for trying and customising the model on your own data.
  8. +
  9. strands-labs: strands-decider, docs/inference.md.
  10. +
  11. strands-labs: strands-decider, deploy/README.md.
  12. +
+

I read the post [1] as text copied from the page; its numbers are at us-east-1 list prices. I read the repository [2]–[6] at its commit of 6 Nov 2026.

+ +

Revisions

+
    +
  • First version.
  • +
+ +

Feedback

+

Not shared yet. When I post about it, what people answer goes here, with what it changed.

+
+
+ +
+ +
+
+ Tim Conrad + No cookies, no trackers, no third-party requests +
+
+ + + diff --git a/thoughts/index.html b/thoughts/index.html index d5965b6..9f72a03 100644 --- a/thoughts/index.html +++ b/thoughts/index.html @@ -48,6 +48,27 @@

Thoughts

    +
  • + + Draft + Agents without long-lived secrets, approved from my phone + My agents can read keys that never expire. A design where they hold none: DPoP-bound tokens, a broker that knows which program asks, a phone that shows the intent and signs a short grant, and a gateway that keeps the last keys. + +
  • +
  • + + Draft + A private site on public static hosting + sealed puts a whole static site on a public host in encrypted form, with a password per reader. What the host sees, how a password opens a file, and what it does not protect. + +
  • +
  • + + Draft + When a small decision model can replace an LLM call + Amazon replaced Claude Sonnet 4.6 with a small model that only picks from a list. The method behind it, step by step, and how I would try it at home and at work. + +
  • Draft diff --git a/thoughts/no-long-lived-secrets/index.html b/thoughts/no-long-lived-secrets/index.html new file mode 100644 index 0000000..593a0fc --- /dev/null +++ b/thoughts/no-long-lived-secrets/index.html @@ -0,0 +1,656 @@ + + + + + + +Agents without long-lived secrets, approved from my phone · Tim Conrad + + + + + + + + + + + + + + + + + + + + + +
    + +
    + ← Thoughts +

    Agents without long-lived secrets, approved from my phone

    +

    My agents can read keys that never expire. A design where they hold none: tokens bound to a key, a broker that knows which program asks, a phone that shows what the agent wants to do, and a gateway that keeps the last keys.

    +

    Tim Conrad · Security · Coding agents · OAuth · Android

    +
    +
    Status
    Draft
    +
    Started
    +
    Last revised
    +
    +
    + +
    + + +
    +

    Keys that live for years

    +

    My agents run as me. Whatever I can read, they can read. On my machines that includes API keys for the models and for a voice service, deploy keys for the repositories, GitHub and cloud tokens, and the service passwords of my lab. Most of them have no expiry date. They stay valid until someone rotates them.

    +

    An agent does not need to be evil to leak one. It prints an environment variable while it debugs. It pastes a config file into a log. It reads a web page that tells it to send ~/.config somewhere. A key that leaks once works for whoever has it, from anywhere, for as long as it lives.

    +

    I want to get rid of that kind of secret, at least within reach of an agent. This thought is the design I would build: what exists already, what is missing, and a phone app that approves what an agent may do instead of handing it a key. I call the app Countersign for now.

    + +

    A secret is something you can copy

    +

    A password, an API key and a bearer token have one thing in common: whoever shows it, gets in. The server cannot tell the owner from someone with a copy. That is the problem, more than the lifetime.

    +

    DPoP changes that. RFC 9449, from September 2023, binds an OAuth access token to a key pair of the client [1]. The token names the key by its thumbprint (cnf.jkt). With each request the client sends a fresh, signed proof: a small JWT that contains the HTTP method (htm), the URL (htu), the time (iat), a unique id (jti) and a hash of the token (ath). The server can also send a nonce that the next proof must contain [2].

    +
    +
    a DPoP proof, decoded
    +
    header   { "typ": "dpop+jwt", "alg": "ES256", "jwk": { public key of the agent } }
    +payload  { "htm": "POST",
    +           "htu": "https://gw.lab/github/repos/Tim-Conrad/sealed/git/refs",
    +           "iat": 1794826800,
    +           "jti": "e1j3V_bKic8-LAEB",
    +           "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo" }
    +signed   with the private key, which never leaves the agent's process
    +
    +

    A copied token is now useless without the private key. And a copied proof is useless too: it is good for one method on one URL, for a short time, once.

    +
    + + What a copied token is worth + Top row: the agent sends a request to the API and the API accepts it. Bottom row: another machine sends a copy of the same token. With a bearer token the API accepts the copy too. With a DPoP-bound token the copy comes without a proof from the key, and the API rejects it. + The agent + + agent, my laptop + holds the token + holds token and key + + + Bearer <token> + DPoP <token> + the token alone + + proof, signed just now + + + API + accepts + + A copy of the token + + another machine + copied from a log + + + Bearer <token> + DPoP <token> + the same token + no key, so no proof + + + API + accepts the copy + + API + rejects: no proof + + + What a copied token is worth + The agent + + agent, my laptop + holds the token + holds token and key + + + Bearer <token> + DPoP <token> + the token alone + + proof, signed just now + + + API + accepts + + A copy of the token + + another machine + copied from a log + + + Bearer <token> + DPoP <token> + the same token + no key, so no proof + + + API + accepts the copy + + API + rejects: no proof + +
    Fig. 1 · Switch between a bearer token and a DPoP-bound token, and look at the bottom row.
    +
    +

    RFC 8705 does the same with a TLS client certificate [3]. DPoP works at the HTTP layer, so it also works through proxies, and a short-lived program can make a fresh key pair whenever it starts. Keycloak supports it fully since 26.4 [4], Spring Security can check DPoP-bound tokens [5], and Okta offers it as an app setting [6].

    +

    So the rule I want: nothing an agent holds may work without a key that cannot be copied. A private key is still a secret. But if it lives only in the memory of one process, or in a TPM or a phone's secure element, there is nothing to paste into a log.

    + +

    The design in one picture

    +

    DPoP solves the copy. It does not answer who decides that the agent gets a token, for what, and for how long. That is where the phone comes in. There are five parts. Tap one to read what it does and what it holds.

    +
    + + The five parts of the design + On my Linux machine, the agent talks to a local broker over a Unix socket. The broker talks end to end to my phone, through a relay that sees only ciphertext. The agent sends its requests to a gateway with a DPoP proof and a grant from the phone. The gateway trusts the phone's public key and calls the real APIs with a short upstream token or a key that it keeps. + + my Linux machine, or a server + + + Agent + Claude Code + + + + Broker + local daemon + + + Unix socket + + + Relay + ciphertext only + + + + end to end (Noise) + + + Phone + Android app + key in StrongBox + + + + Gateway + agentgateway + + + DPoP proof + grant + + public key + + + APIs + GitHub, … + + + short token or kept key + + + The five parts of the design + + my Linux machine, or a server + AgentClaude Code + Brokerlocal daemon + + + Noise + Relayciphertext only + + Phonekey in StrongBox + + proof + + grant + + public key + Gatewayagentgateway + + APIsGitHub, … + +
    +

    Tap a part to read what it does and what it holds.

    +
      +
    • Agent: Claude Code, a cron job, a CI runner. It makes a key pair when it starts and keeps the private half in memory. It asks the broker for what it needs, then sends its requests to the gateway with a DPoP proof. Holds: its own key, a grant for a few minutes or hours. Nothing that works without the key.
    • +
    • Broker: a small daemon on the same machine, reached over a Unix socket. It finds out which program is asking, the way macOS does with code signatures, and sends the request to my phone. Holds: its pairing key with the phone, ideally in the TPM.
    • +
    • Relay: a mailbox on the internet, so the phone and the machine find each other behind any network. It passes ciphertext and a push to wake the phone. It cannot read or change a request. Holds: nothing worth stealing.
    • +
    • Phone: the Android app. It shows who asks, what for and why, and signs a grant with a key in the secure element after my fingerprint. Holds: the one key that matters, and it cannot leave the chip.
    • +
    • Gateway: an identity-aware proxy, for example agentgateway on my lab. It checks the proof, the grant and that the request matches the grant, then calls the real API. Holds: the phone's public key, and the upstream keys that cannot be replaced yet.
    • +
    • APIs: GitHub, the model providers, my lab's services. Where they accept short tokens from a federation (a GitHub App, a cloud's workload identity), the gateway gets one per request. Where they only take a static key, that key lives in the gateway and nowhere else.
    • +
    +
    +
    Fig. 2 · The five parts. The dashed line carries only a public key.
    +
    +

    Two things are worth saying at once. The phone never sends a secret anywhere: it signs a statement. And the agent never sees an upstream key: the gateway adds it on the way out. Claude Code's cloud sessions, which I use, already do the second part: I save an API key on the environment, a proxy adds it to requests to that host, and the session never sees the value.

    + +

    Who is asking

    +

    "An agent wants to push" is not enough. Which program? Started by whom? The same request from Claude Code in a session I started, and from an unknown binary in /tmp, should look very different on the phone.

    +

    How macOS knows

    +

    On macOS every app carries a code signature, and the system identifies an app by it, not by its path or name. The key idea is the designated requirement: in Apple's words, "the code's way of saying 'If you see me again, here's how you tell it's really me'" [7]. For an app from a registered developer, it names the developer's Team ID in the certificate. A new version of the app meets the same requirement, so the privacy permissions you gave it (camera, files, screen) stay with it. A changed binary does not, so they do not [7].

    +

    Two more pieces make it work. Notarization: Apple scans software signed with a Developer ID, and Gatekeeper checks for that ticket [8]. And a service can demand a signature from the process on the other end of its connection: setCodeSigningRequirement drops an XPC connection whose peer does not meet a requirement [9]. Apple's engineers advise identifying the peer by its audit token, not by its process id, because a process id can be reused by another process [10].

    +

    What Linux has

    +

    Linux has no single signing system for programs. But it has most of the pieces to build the same check in a broker:

    +
      +
    • The peer of a socket. SO_PEERCRED gives the user and process id of the other end of a Unix socket. Since Linux 6.5, SO_PEERPIDFD gives a pidfd instead [11]: a handle to exactly that process, which cannot point to another one if the id is reused. That is the audit token of Linux.
    • +
    • The program. From the pidfd, /proc/<pid>/exe leads to the binary. Its hash can come from fs-verity, which the kernel keeps for a file and checks on each read (since 5.4), or from IMA, which measures files as they are run [11]. SPIRE's Unix workload attestor already does this for workloads: it gives the user, the group, the path and the SHA-256 of the binary as selectors [12].
    • +
    • The context. The process's cgroup names its systemd unit or scope, so the broker can see "a terminal session of tim" or "the backup timer". Flatpak's portals identify an app by the app id in the sandbox, and use pidfds to do it safely [13].
    • +
    +

    So the broker can write a requirement much like Apple's, only from other parts:

    +
    +
    a requirement for the broker, a sketch
    +
    requirement claude-code
    +  exe      = /usr/bin/node
    +  script   = ~/.local/lib/node_modules/@anthropic-ai/claude-code/cli.js
    +  script.sha256 in npm-integrity(@anthropic-ai/claude-code)
    +  uid      = tim
    +  unit     ~ "session-*.scope"        # started from my login, not a timer
    +  ptrace   = denied                   # Yama scope 2, or its own user
    +
    +

    Two things are harder than on macOS, and the design must be honest about them.

    +
      +
    • Interpreters. Many agents run in Node or Python. The binary is the interpreter; the program is a script. The broker has to identify the script too, and check its hash against the package it came from. A program that loads more code at run time is only as trustworthy as that code.
    • +
    • Same user, same power. On a normal desktop, any process of mine can attach a debugger to another one and change it. macOS blocks that for hardened apps. On Linux the broker can only trust an identity if the agent runs as its own user, in a sandbox, or with ptrace restricted. Otherwise the check is a hint, and the phone should say so.
    • +
    + +

    Show the intent, not a button

    +

    The push prompts I know ask "Is this you?" with two buttons. People approve them without reading; attackers count on it and send prompts until someone taps yes. Microsoft made number matching mandatory in its Authenticator app in May 2023 for that reason: the person types a number from the login screen, so a prompt they did not cause cannot be approved by reflex [14]. That fixes "did I cause this". It does not say what I agree to.

    +

    There is better prior art for that. Secure Payment Confirmation in the browser must show the payee, the amount and the card, and the signature covers exactly that data [15]. Android's Protected Confirmation shows a message in a screen the app cannot draw over, and signs it with a key that works only for confirmed messages [16]. OAuth has a format for the content: Rich Authorization Requests (RFC 9396) carry a list of authorization_details, each with a type and the fields that say what exactly is allowed [17]. And CIBA, the OpenID flow where one device asks and another one approves, shows a binding_message on both [18].

    +

    Put together, the phone shows a request in four parts, and what it signs is exactly what it shows. Tap a part.

    +
    + + The approval screen on the phone + A phone screen with six parts. Who: claude, node, verified because the package hash matches, on lab-1, session 7f3a. What: git push to Tim-Conrad/Tim-Conrad.github.io, branch claude/thought-grants, 3 commits. Why, in the agent's words: push the new thought for review. Allow: this push, or this session for 2 hours. A code that also shows in my terminal. Deny and Approve. + + Countersign · lab-1 asks + + + claude · node + verified: package hash + lab-1 · session 7f3a + + Who: checked by the broker + + + + git push + repo Tim-Conrad.github.io + branch claude/thought-grants + 3 commits · not main + + What: the exact request + + + + why · the agent's words + Push the new thought + for review. + + Why: what the agent says + + + + allow + + this push + + session, 2 h + + Grant: how far, how long + + + + code 47 · also in the terminal + + Code: did I cause this? + + + + Deny + + Approve + + Approve: fingerprint, then sign + + + + The approval screen on the phone + + Countersign · lab-1 asks + claude · nodeverified: package hashlab-1 · session 7f3a + git pushrepo Tim-Conrad.github.iobranch claude/thought-grants3 commits · not main + why · the agent's wordsPush the new thoughtfor review. + allowthis pushsession, 2 h + code 47 · also in the terminal + DenyApprove + +
    +

    Tap a part of the screen to read what it shows and where it comes from.

    +
      +
    • Who: the program as the broker checked it, not as the agent describes itself. The machine, the session it runs in, and whether the requirement matched. If the agent shares its user with other programs, this says "not isolated", and the screen says so plainly.
    • +
    • What: the request as structured data, an authorization_details entry, drawn by a template for its type. For a push: the repository, the branch, the number of commits, whether it is the default branch. A type without a template shows its raw fields, and asks for a second look.
    • +
    • Why: the agent's own reason, and the task it came from. It is useful, and it is a claim. A prompt injection can write a good reason too, so the screen marks it as the agent's words and keeps it apart from what the broker checked.
    • +
    • Grant: how far the approval goes. "This push" is used once. "This session, 2 h" lets the same session do the same kind of thing again, within the same limits, until it ends or the time runs out. I can end any grant from the phone.
    • +
    • Code: when I sit at the machine, the terminal shows the same two digits. It proves that this request is the one I just caused. When an agent runs on its own at night, there is no code, and the screen says so.
    • +
    • Approve: my fingerprint unlocks the key in the secure element, and the phone signs exactly the data on the screen. Deny tells the agent no, with an optional reason it can read.
    • +
    +
    +
    Fig. 3 · The approval screen. The agent's reason and the checked facts are kept apart.
    +
    +

    The most important line on that screen is the difference between "the broker checked this" and "the agent says this". An agent that read a poisoned web page can give a perfect reason. It cannot fake the program, the branch or the repository, because the gateway will refuse anything outside the grant.

    + +

    Grants instead of secrets

    +

    What the phone signs I call a grant. It is a short JWT, signed with ES256 by the key in the phone's StrongBox chip. Android keeps such a key out of the app's memory, and can bind its use to a fingerprint [19]. A grant names the agent's key, so only that agent can use it:

    +
    +
    a grant, decoded
    +
    { "iss": "phone:tim-pixel",
    +  "sub": "tim",
    +  "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" },   # the agent's key
    +  "req": { "machine": "lab-1", "requirement": "claude-code",
    +           "session": "7f3a", "isolated": true },                  # what the broker checked
    +  "authorization_details": [{
    +      "type": "git.push",
    +      "repository": "Tim-Conrad/Tim-Conrad.github.io",
    +      "ref": "refs/heads/claude/thought-grants",
    +      "default_branch": false,
    +      "uses": 1 }],
    +  "why": "Push the new thought for review.",                       # the agent's words
    +  "iat": 1794826800, "exp": 1794834000,                            # two hours at most
    +  "jti": "g_8Qm2" }
    +
    +

    Three properties follow from that shape.

    +
      +
    • It is useless when copied. The gateway accepts it only with a DPoP proof from the key in cnf.jkt.
    • +
    • It is narrow. The gateway compares each request with authorization_details. A push to main is outside a grant for a branch.
    • +
    • It ends by itself. Two hours at most, and earlier when I end it on the phone. There is nothing to rotate.
    • +
    +

    Standing grants

    +

    Asking forty times per session would train me to tap without reading, which is the problem I started with. So the screen offers "this session, 2 h": the same session may repeat the same kind of action within the same limits. The phone shows a list of open grants, each with a button to end it.

    +

    1Password's SSH agent already works this way for keys: it asks once per application, and I can approve for that app, for a terminal session or for one request [20]. Tailscale's SSH check mode asks again after a period, 12 hours by default [21]. Krypton did it from a phone years ago: the SSH key lived on the phone and never left it [22]. What none of them carry is the intent: they approve a key, not an action.

    +

    Grants for subagents

    +

    Agents start other agents. A subagent should get its own key and a grant that is narrower than its parent's, never wider. The parent can sign that grant itself, as long as it only removes rights. The gateway then sees a chain: phone, agent, subagent. Token Exchange (RFC 8693) already has a claim for that chain, act, the party that acts for the subject [23]. The phone can show the chain when the subagent asks for something new.

    + +

    One request, step by step

    +

    Here is a push from start to end. Step through it, or press Play.

    +
    + + One request from the agent to GitHub + The agent asks the broker, the broker checks who asks and sends the request to the phone, I approve, the phone signs a grant bound to the agent's key, the agent calls the gateway with a proof and the grant, the gateway checks them and calls GitHub with a short token, and sends a receipt to the phone. + Agent + Broker + Phone + Gateway + GitHub + + + + + + 1 · need: git push, key jkt + 2 · check caller + 3 · request, sealed + 4 · read, approve + 5 · grant, bound to the agent's key + 6 · push + DPoP proof + grant + 7 · check all 3token, 1 h + 8 · receipt + + + One request from the agent to GitHub + Agent + Broker + Phone + Gateway + GitHub + + + + + + 1 need + 2 who? + 3 ask + 4 read + 5 grant + 6 proof + grant + 7 checktoken + 8 receipt + +
    +
      +
    1. The agent made a key pair when it started. It asks the broker over the Unix socket: I want to push this branch, and here is the thumbprint of my key.
    2. +
    3. The broker takes the pidfd of the caller from the socket and checks it against its requirements: the binary, the script and its hash, the user, the systemd unit, whether ptrace is restricted. A request that matches no requirement still goes to the phone, marked "unknown program".
    4. +
    5. The broker seals the request for the phone and puts it on the relay. A push wakes the app. The relay sees a few hundred bytes of ciphertext.
    6. +
    7. I read the screen. If I sit at the machine, I compare the code with the terminal. My fingerprint unlocks the key in the secure element.
    8. +
    9. The phone signs the grant, with the agent's key thumbprint, the checked facts, the exact request and an expiry, and sends it back the same way. The broker hands it to the agent.
    10. +
    11. The agent sends its push to the gateway, with Authorization: DPoP, a fresh proof for this method and URL, and the grant.
    12. +
    13. The gateway checks the grant's signature against the phone's public key, the proof against the key in the grant, and the request against authorization_details. Then it asks GitHub for an installation token of a GitHub App, which expires after one hour [24], and forwards the push with it. The agent never sees that token.
    14. +
    15. The gateway sends a signed receipt to the phone: what was approved, and what was done. If the two differ, the phone says so.
    16. +
    +
    +
    Fig. 4 · One push, eight steps. Only steps 3 to 5 need me.
    +
    + +

    A gateway keeps the last keys

    +

    Some services will take only a static API key for years to come. Those keys do not disappear. But they can move to one place that no agent can read, and that place can be strict about who uses them.

    +

    That place is an identity-aware proxy. The idea is old: Google's BeyondCorp moved access decisions from the network edge to a proxy in front of each service, which checks the user and the device on every request [25]. Pomerium is an open-source proxy of that kind [26]. CyberArk's Secretless Broker was built to keep secrets away from client applications: the broker connects to the database, the app does not get the password [27].

    +

    agentgateway is the same idea built for agents. It is an open-source proxy for MCP, A2A and LLM traffic, from Solo.io and now a Linux Foundation project [28]. Three of its features fit this design directly [28]:

    +
      +
    • It checks JWTs and runs policies in CEL, so it can verify a grant against the phone's public key and compare a request with its fields.
    • +
    • Its backendAuth policy adds stored credentials to the upstream request. The agent sends its request without a key, and the key is added on the way out.
    • +
    • It can keep a private key and sign a fresh JWT for each upstream request (jwtSign), or do an RFC 8693 token exchange. For services that accept that, there is no static key at all, only a key that signs.
    • +
    +

    I could not find DPoP proof checks in agentgateway. If they are missing, a small check in front of it can do that part. Tailscale's Aperture, in beta, keeps model provider keys in a gateway too, and lets clients in by their Tailscale identity [29].

    +

    The upstream keys that remain should be few, listed in one file, and used only by the gateway. Where a service offers federation (a GitHub App, a cloud's workload identity), the gateway uses that instead and the static key goes away.

    + +

    Pairing the phone

    +

    The phone and the broker meet once, in person. The broker shows a QR code with its public key and a one-time code. The phone scans it, and both run a Noise handshake: a framework for building secure channels from Diffie-Hellman key exchanges [30]. Afterwards each side knows the other's public key, and every message between them is encrypted end to end. The one-time code from the QR goes into the handshake, so a relay in the middle cannot join it.

    +

    Without a camera, a short code works, as in Magic Wormhole: a few words, typed on the other device, run through a PAKE so that a wrong guess fails at once [31].

    +

    The phone's key gets an attestation from Android: a certificate chain to Google's root that says the key was made in StrongBox and cannot leave it [32]. The gateway checks it once, when I add the phone, and then trusts the public key.

    + +

    How long each credential lives

    +

    Switch between what an agent can read today and what it would hold with grants.

    +
    + + Lifetime of each credential, today and with grants + On a scale from a minute to no expiry. Today: an API key for an agent, a deploy key, a GitHub or cloud token and a lab password, all valid until I rotate them, which in practice means years or forever. With grants: the access token lives about 5 minutes, the grant at most 2 hours, the agent's own key until its session ends, and the upstream keys still never expire, but stay in the gateway. + API key, agent + Access token + Deploy key + Grant + GitHub, cloud + Agent key + Lab password + Upstream key + + until I rotate it + until I rotate it + until it expires or I rotate it + until I change it + + about 5 min, DPoP-bound + 2 h at most + until the session ends + no expiry, but only in the gateway + + + 1 min + 1 h + 1 day + 1 month + 1 year + never → + + + Lifetime of each credential, today and with grants + API key, agentAccess token + Deploy keyGrant + GitHub, cloud tokenAgent key + Lab passwordUpstream key + until I rotate it + until I rotate it + until it expires or I rotate it + until I change it + about 5 min + 2 h at most + session + no expiry, only in the gateway + + 1 min + 1 h + 1 day + 1 mo + 1 yr + never → + +
    Fig. 5 · Lifetimes on a log scale. Today's bars are rough: most of these keys have no expiry date at all. The upstream keys stay long-lived with grants too; what changes is who can read them.
    +
    +

    The last bar is the honest part. "No long-lived secrets ever" is not reachable while some services only take static keys. What is reachable: no long-lived secret that an agent can read, and every one that remains in one place, in a list I can see.

    + +

    Further out

    +

    The ideas above I would build. These go further. Some may be bad; I want to think them through anyway.

    +
      +
    • Approve the plan, not each click. At the start of a task the agent sends its plan as a list of authorization_details: push to this branch, open one pull request, call the voice API for at most 40 lines. I approve the plan once. The gateway refuses anything outside it. When the plan changes, the phone shows the difference, like a diff. That is intent in its strongest form: I agree to what the agent means to do before it does it.
    • +
    • Receipts. Every request through the gateway produces a signed record. Once a week the phone shows what I approved and what happened. A grant that was never used was too wide.
    • +
    • Rules that grow from approvals. After I approve the same request the fifth time, the phone offers a rule: "Claude Code in a session of mine may push to claude/* branches, for 30 days." A rule that is not used for a month expires.
    • +
    • Two keys for big actions. A push to main, a deploy, a payment, deleting data: these need a second approval, from a second device or a second person. Like two keys for a safe.
    • +
    • Tripwires. If no real secrets lie around, any secret that does is bait. Put fake API keys where agents look. Any use of one is an alarm, and the gateway knows which session read it.
    • +
    • Lending access. A friend's agent needs to read one repository. Their broker sends a request to my phone, and I sign a grant for their agent's key: one repository, read only, a week. No account, no token to revoke later.
    • +
    • The agent asks better questions. If every request must carry a type, fields and a reason, the agent has to say what it is about to do. That alone may make agents work better on my behalf: a request I can read is also a plan I can correct.
    • +
    + +

    What I would build first

    +

    The smallest version that removes a real key from my machines:

    +
      +
    1. The broker. A small daemon with a Unix socket. It reads SO_PEERPIDFD, finds the binary, the script and the systemd unit, and matches requirements from a file. First it only logs, so I can see what my agents actually ask for.
    2. +
    3. The phone app. Android, a key in StrongBox that needs my fingerprint, pairing by QR code and a Noise handshake, a relay with push. Templates for three types: git.push, http.call to an API, secret.use for the old keys.
    4. +
    5. The gateway. agentgateway on my lab, in front of the GitHub API and the model APIs. It checks the grant as a JWT with the phone's public key; a small service in front checks the DPoP proof. Pushes go out with a GitHub App's installation token, and the voice service's key moves from ~/.config into the gateway.
    6. +
    7. One agent. Claude Code on the lab, as its own user, with a git credential helper that asks the broker. Then the deploy keys, one by one.
    8. +
    +

    A good test of the design: after step 4, grep -r for keys in the agent's home finds nothing that works.

    + +

    What it does not solve

    +
      +
    • A wrong action I approved. If I approve a bad push, the push happens. The design makes the request readable; it cannot make me read it.
    • +
    • Approval fatigue. Standing grants and plans reduce the number of questions. Whether I still read the twentieth one, I will only know by trying.
    • +
    • A program that pretends. On a desktop where the agent shares my user, another process can change it while it runs. The identity check is only strong for agents in their own user or sandbox.
    • +
    • Root, a broken phone, a broken gateway. The broker trusts the kernel. The grant trusts the phone. The upstream keys trust the gateway. Each is one place to protect instead of a hundred, but it is still a place.
    • +
    • Losing the phone. A second paired device is the clean answer. A paper recovery key in a drawer is the honest one, and it is a long-lived secret again, only one that no program can reach.
    • +
    • Services with static keys. They stay, in the gateway, until they offer something better.
    • +
    +

    The same pattern runs through sealed, which keeps the keys in the browser where a script cannot export them. A key that cannot be copied is the thing I trust most. Grants and receipts are how I would extend that trust to agents, one narrow, short promise at a time.

    + +

    Open questions

    +
      +
    • Should the wire format be CIBA, so that an existing identity provider such as Keycloak can be the broker's partner, or is my own small protocol simpler?
    • +
    • How do I name and version the request types, so that a template on the phone and a check in the gateway always agree on what a field means?
    • +
    • How reliably can a broker identify a script inside Node or Python? Is the package hash enough?
    • +
    • Does agentgateway check DPoP proofs, or will it soon?
    • +
    • Android's Protected Confirmation would be the right screen for the approval, but it seems to be on its way out. Is a normal screen with a biometric prompt good enough?
    • +
    • Is there a way to recover from a lost phone without any long-lived secret at all?
    • +
    + +

    Starting points

    +
      +
    • My own question: can DPoP replace the long-lived passwords, API keys and tokens on my machines, and can a phone approve what my agents may do, over an encrypted link to the Linux machine they run on?
    • +
    • How macOS identifies an app by its signature, and whether a Linux broker can do the same.
    • +
    • agentgateway, as a place to keep the keys that cannot go away yet [28].
    • +
    + +

    Sources

    +
      +
    1. D. Fett, B. Campbell, J. Bradley, T. Lodderstedt, M. Jones, D. Waite: RFC 9449, OAuth 2.0 Demonstrating Proof of Possession (DPoP), Sep 2023.
    2. +
    3. The DPoP specification source, with the proof fields, cnf.jkt and the nonce: danielfett/draft-dpop.
    4. +
    5. RFC 8705, OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, Feb 2020.
    6. +
    7. Keycloak: release notes 23.0, 24.0 and 26.4.
    8. +
    9. Spring Security: OAuth 2.0 DPoP-bound access tokens.
    10. +
    11. Okta: Configure OAuth 2.0 Demonstrating Proof-of-Possession.
    12. +
    13. Apple: TN3127, Inside code signing: requirements.
    14. +
    15. Apple: Notarizing macOS software before distribution.
    16. +
    17. Apple: NSXPCConnection setCodeSigningRequirement, macOS 13 and later.
    18. +
    19. Apple Developer Forums, answers by Apple's developer technical support: thread 72881 and thread 681053.
    20. +
    21. Linux kernel source, torvalds/linux: SO_PEERPIDFD first in v6.5, pidfd_open in v5.3, fs-verity in v5.4, IMA in v2.6.30.
    22. +
    23. SPIFFE: SPIRE agent plugin, Unix workload attestor.
    24. +
    25. xdg-desktop-portal: xdp-app-info-flatpak.c.
    26. +
    27. BleepingComputer: Microsoft enforces number matching to fight MFA fatigue attacks, May 2023.
    28. +
    29. W3C: Secure Payment Confirmation.
    30. +
    31. Android: Android Protected Confirmation.
    32. +
    33. T. Lodderstedt, J. Richer, B. Campbell: RFC 9396, OAuth 2.0 Rich Authorization Requests, May 2023.
    34. +
    35. OpenID Foundation: Client-Initiated Backchannel Authentication Core 1.0, Sep 2021.
    36. +
    37. Android: Android Keystore system.
    38. +
    39. 1Password: SSH agent authorization.
    40. +
    41. Tailscale: Tailscale SSH.
    42. +
    43. KryptCo: kr, now deprecated in favour of akamai/akr.
    44. +
    45. RFC 8693, OAuth 2.0 Token Exchange, Jan 2020.
    46. +
    47. GitHub Docs: Generating an installation access token for a GitHub App.
    48. +
    49. Rory Ward, Betsy Beyer: BeyondCorp: a new approach to enterprise security, ;login: 39(6), 2014.
    50. +
    51. Pomerium.
    52. +
    53. CyberArk: Secretless Broker.
    54. +
    55. agentgateway, its jwtSign example, and Solo.io contributes agentgateway to the Linux Foundation, Aug 2025.
    56. +
    57. Tailscale: What is Aperture.
    58. +
    59. Trevor Perrin: The Noise Protocol Framework, revision 34, 2018.
    60. +
    61. Magic Wormhole.
    62. +
    63. Android: Verify hardware-backed key pairs with key attestation.
    64. +
    +

    The dates of the RFCs, CIBA and BeyondCorp, the Okta, 1Password, Tailscale and Microsoft pages [6], [14], [20], [21], [29], and the CIBA binding_message I could check only through search summaries, not on the pages themselves. The rest I read in the source: the specification repositories, the kernel tags, and Apple's and Android's documentation.

    + +

    Revisions

    +
      +
    • First version.
    • +
    + +

    Feedback

    +

    Not shared yet. When I post about it, what people answer goes here, with what it changed.

    +
    +
    + +
    + +
    +
    + Tim Conrad + No cookies, no trackers, no third-party requests +
    +
    + + + diff --git a/thoughts/sealed/index.html b/thoughts/sealed/index.html new file mode 100644 index 0000000..bfb9c8d --- /dev/null +++ b/thoughts/sealed/index.html @@ -0,0 +1,427 @@ + + + + + + +A private site on public static hosting · Tim Conrad + + + + + + + + + + + + + + + + + + + + + +
    + +
    + ← Thoughts +

    A private site on public static hosting

    +

    sealed puts a whole static site on a public host in encrypted form. Each reader has their own password, and the browser decrypts every file. How it works, in pictures, and what it does not protect.

    +

    Tim Conrad · Encryption · Static sites · Service workers

    +
    +
    Status
    Draft
    +
    Started
    +
    Last revised
    +
    +
    + +
    + + +
    +

    Private, without a server

    +

    Some things I make are for a few people only. The first one is a pen-and-paper game for friends. I wanted it on my own site, next to everything else, but only for the people I give a password to.

    +

    And I wanted to keep the hosting I have: static files on GitHub Pages, with no server of my own that checks passwords. A static host cannot check anything. It hands every file to whoever asks.

    +

    So the files themselves have to be locked. sealed is the tool I built for that. It takes a finished static site, encrypts every file, and adds a login page. The host gets ciphertext. The browser of a reader with a password gets the site back, and it works as before [1].

    + +

    What the host sees

    +

    The first question for any design like this: what does the host learn? Switch between the two views.

    +
    + + The same site, as a reader sees it and as the host sees it + A reader sees file names such as index.html, story/chapter-1.html, map.png and theme.mp3, and three users with their groups. The host sees random blob names with the same sizes, a keyring with three entries and no names, and a key index with two groups. The sizes are the same in both views. + Files + Blobs + Size + + + index.html + story/chapter-1.html + story/chapter-2.html + style.css + map.png + theme.mp3 + + + _sealed/c/3f9a0c71… + _sealed/c/b71e44d2… + _sealed/c/0d2f98a5… + _sealed/c/c94d1237… + _sealed/c/e6c1a30f… + _sealed/c/58ab07e9… + + 4 KB + 12 KB + 15 KB + 6 KB + 820 KB + 3.1 MB + + Readers + Keyring and key index + + + Annaall, chapter 2 + Benall, chapter 2 + Cemall + each with their own password + + + entry a91f04c2… · sealed + entry 07c3e85b… · sealed + entry 5e2d9a10… · sealed + key 41d0… → manifest (sealed) + key 7c1e… → manifest (sealed) + + names, paths, groups: readable + 3 entries, 2 groups, no names + + + The same site, as a reader sees it and as the host sees it + Files + Blobs + Size + + + index.html + story/chapter-1.html + story/chapter-2.html + style.css + map.png + theme.mp3 + + + _sealed/c/3f9a0c71… + _sealed/c/b71e44d2… + _sealed/c/0d2f98a5… + _sealed/c/c94d1237… + _sealed/c/e6c1a30f… + _sealed/c/58ab07e9… + + 4 KB + 12 KB + 15 KB + 6 KB + 820 KB + 3.1 MB + Readers + Keyring and key index + + + Annaall, chapter 2 + Benall, chapter 2 + Cemall + each with their own password + + + entry a91f04c2… · sealed + entry 07c3e85b… · sealed + entry 5e2d9a10… · sealed + key 41d0… → manifest (sealed) + key 7c1e… → manifest (sealed) + + names, paths, groups: readable + 3 entries, 2 groups, no names + +
    Fig. 1 · The host keeps the sizes, the number of files, the number of keyring entries and the number of groups. It loses every name, path and word. An example site; the names and sizes are made up.
    +
    +

    Besides the blobs, the host serves a login page, a small script, a keyring and a key index. None of them holds a name or a path. That is the whole promise, and it is also the first limit: a picture of 820 KB stays a file of about 820 KB, and anyone can count the readers.

    + +

    From password to file

    +

    Each reader types a name and a password. From those two things alone, the browser finds the right keys. Step through it.

    +
    + + From a name and a password to a decrypted file + The name and the password give a master secret through a slow function. The master gives an entry key and an entry id for this site. The id finds the reader's entry in the public keyring, and the entry key opens it. The entry holds the keys of the reader's groups. Each group key opens the manifest of its group, which maps paths to blob names. The browser fetches the blob and decrypts the file. + 1Name, passwordtyped at the login + 2Masterslow on purpose + 3Entry key and ida pair per site + 4Keyring entrypublic, but sealed + 5Group keysone per group + 6Manifestpath → blob name + 7The fileopened in the browser + + + From a name and a password to a decrypted file + 1Name, passwordtyped at the login + 2Masterslow on purpose + 3Entry key and ida pair per site + 4Keyring entrypublic, but sealed + 5Group keysone per group + 6Manifestpath → blob name + 7The fileopened in the browser + +
    +
      +
    1. The reader types a name and a password on the login page. Nothing is sent anywhere.
    2. +
    3. The browser turns them into a master secret with a function that is slow on purpose: 600,000 rounds (PBKDF2). A right guess costs the same as a wrong one, so guessing many passwords is expensive. When I set a password, my admin page does the same in my browser, and the server keeps only the master, never the password.
    4. +
    5. From the master, a fast function (HKDF) makes two values for this one site: a key for the reader's entry, and the entry's id. Another site gives another pair, so two keyrings do not show that they share a reader.
    6. +
    7. The keyring is a public file. The id finds the reader's entry, and the key opens it. A wrong name or password gives an id that is not there, so it finds nothing.
    8. +
    9. The entry holds the keys of the groups the reader may open. The browser hands them to its worker, which keeps them where they cannot be read out again.
    10. +
    11. The key index says which manifest belongs to which group key. A manifest, opened with its group key, maps each path of the site to the name of its blob.
    12. +
    13. When the page asks for a path, the worker fetches that blob and decrypts it (AES-256-GCM). The page gets the plain file.
    14. +
    +
    +
    Fig. 2 · Seven steps, all in the browser. Only steps 1 and 2 are slow, and they run once at the login.
    +
    +

    The words, for those who want them: the slow step is PBKDF2 with SHA-256, the split into entry key and id is HKDF, and every file, entry and manifest is sealed with AES-256-GCM. All three come with the browser (WebCrypto). sealed has no other dependency [2].

    + +

    The worker in the browser

    +

    A site is many files: pages, pictures, sound. If each page had to decrypt its own pictures, every app would need to change. sealed puts a service worker in between: a small script that the browser runs beside the page and that answers the page's requests [2].

    +
    + + A request through the service worker + The page asks for map.png. The service worker looks up the blob name, asks the host for the blob, gets ciphertext back, decrypts it with the group key and answers the page with the picture. + + Page + the app, as before + + Worker + decrypts + holds the keys, + cannot export them + + Host + ciphertext only + + map.png + + e6c1a30f… + + ciphertext + + picture + + The page never sees a key, and the host never sees a name. + + + A request through the service worker + + Page + the app, as before + + Worker + decrypts, + holds the keys, + cannot export them + + Host + ciphertext only + + map.png + + e6c1a30f… + + ciphertext + + picture + + The page never sees a key, the host never a name. + +
    Fig. 3 · One request. The worker sits between the page and the host and answers every request of the site.
    +
    +

    What the worker does besides that, in plain words [2]:

    +
      +
    • The keys stay put. They are stored in the browser as keys that cannot be exported. A script can ask the browser to use them, but cannot read them out.
    • +
    • Sound and video work. A player asks for a file in many small pieces. The worker keeps recently opened files in memory, so it does not decrypt the whole file again for each piece.
    • +
    • Offline works. If a site allows it, the worker downloads the files after the login, all but sound and video, and keeps them in the browser's cache, still encrypted.
    • +
    • A new version keeps you logged in. On each page load the worker reads the key index again. As long as the reader's keys are still in it, the new files open with the old keys.
    • +
    • Logout deletes the keys from the browser.
    • +
    + +

    Names that stay the same

    +

    Every blob gets its name from three things: the key of its group, its path and its content. Only someone with the key can work out which name belongs to which path. Without the key, the names look random [2].

    +

    The same three things always give the same name. So a file that did not change keeps its name from one version to the next. That has two good effects: a browser that already has it in its cache does not download it again, and on GitHub only the files that really changed show up as changed.

    +

    The name is also bound into the encryption of the blob. If the host swapped two blobs, the browser would refuse to decrypt both.

    + +

    Groups and new keys

    +

    A site can have groups: for example one for everything, and one for a chapter that only some readers get. Each file belongs to exactly one group, and each group has its own key. A reader's entry holds the keys of their groups [1]. Switch to see what happens when a reader loses a group.

    +
    + + Taking a reader out of a group gives that group a new key + Three readers and two groups. Anna and Ben have both groups, Cem has only "all". When Ben loses chapter 2, chapter 2 gets a new key and its files are encrypted again. Anna logs in once more and gets the new key. Ben keeps "all". His old key for chapter 2 is no longer in the key index, so his browser forgets it. + all + chapter 2 + + Anna + Ben + Cem + + + + + + + + key 41d0… + key 7c1e… + new key 2e9b… + + + Three readers, + two groups. + Each entry holds the + keys of its groups. + + + chapter 2: new key, + its files sealed again. + Anna logs in once more. + Ben keeps "all". + Cem: nothing changes. + + + + Taking a reader out of a group gives that group a new key + all + chapter 2 + + Anna + Ben + Cem + + + + + + + + key 41d0… + key 7c1e… + new 2e9b… + + + Three readers, two groups. + Each entry holds the keys + of its groups. + + + chapter 2: new key, its files + sealed again. + Anna logs in once more. + Ben keeps "all". + Cem: nothing changes. + + +
    Fig. 4 · A group keeps its key from version to version, so readers stay logged in. Only taking a reader out of a group, or a "new key" by hand, makes a new one. The readers are an example.
    +
    +

    Why a new key at all? Ben's browser still has the old key of chapter 2. Without a new key, he could keep opening the new files of chapter 2. With it, his old key is not in the key index any more, and his browser forgets it [1].

    +

    A new password is different. It changes only the reader's entry, not the group keys. Devices that are logged in stay logged in. To log a reader out everywhere, I take away the access, or make a new key.

    + +

    On this site

    +

    A sealed site can live on its own host, or in a folder below another site. On t7d.dev, sealed sites go below /sealed/, with a login page in the look of this site. Each folder has its own worker, its own key store and its own cache, so several sealed sites can share one address without seeing each other's keys [1].

    +

    You can try it: the demo is a small site with a picture, a sound and a bonus page. Log in as reader with the password open-the-demo, or as member with open-the-bonus; only member can open the bonus page. The demo protects nothing: its passwords and keys are public on purpose. A workflow in the sealed repository builds it and publishes it here after each change [1].

    +

    The deploy of this site and the publishing of sealed each replace only their own part of the branch that GitHub Pages serves, so they do not overwrite each other. Search engines are asked to stay out of /sealed/.

    + +

    Compared with StatiCrypt

    +

    I am not the first to encrypt static pages. StatiCrypt is the best-known tool: it encrypts an HTML file with a password and returns a page with a password prompt. It also uses AES-256 and 600,000 rounds of PBKDF2, the number OWASP recommends [3] [4]. For one page and one password it is simpler than sealed.

    +

    What I needed beyond that:

    +
      +
    • Every file, not only HTML. Pictures, sound and video are encrypted too, and stream through the worker.
    • +
    • A password per reader. StatiCrypt has one password per page; its README suggests a workaround for several users [3]. In sealed each reader has their own, and I can take one away without changing the others.
    • +
    • Groups. Different readers can open different parts of one site.
    • +
    • No changes to the app. The site is built as a normal static site, and the worker makes it work behind the login, offline too.
    • +
    +

    The price is more moving parts: a build, a worker, and an admin page that keeps the keys.

    + +

    What it does not protect

    +

    These are the limits I know of. They are in the README, and they belong here too [1].

    +
      +
    • Guessing passwords offline. The keyring is public. Anyone can copy it and try passwords on their own computer, as fast as their hardware allows. The slow step makes each try expensive, but only a long password makes guessing hopeless. That is why sealed generates the passwords.
    • +
    • Old keyrings stay in the history. On GitHub Pages the published files are in a public repository, with all of their history. After a password change, the old keyring is still there, and so is everything it opens.
    • +
    • A reader can copy what they opened. Once a file is decrypted in a browser, it can be saved. A new key protects the next version only.
    • +
    • Permissions work per file. If one file holds the content of all groups, every reader who gets that file can read all of it. The site has to be split into files along the groups.
    • +
    • Sizes and counts show. The number of files, their sizes, the number of readers and the number of groups are visible.
    • +
    • Static sites only. An app with its own server needs something else. And the app cannot bring its own service worker, because sealed's worker takes that place.
    • +
    • A delay on GitHub Pages. It caches files for up to ten minutes, so a new password or a new group can take that long to work there.
    • +
    + +

    Starting points

    +
      +
    • My own need: put a game for a few friends on my public site, each friend with their own password, on static hosting without a server.
    • +
    + +

    Sources

    +
      +
    1. sealed, README. My own tool, in a private repository; read on 11 Nov 2026, with the changes for the demo.
    2. +
    3. sealed, the crypto core, the service worker and the build (client/core.js, client/sw.js, lib/build.mjs), same commit.
    4. +
    5. Robin Moisson: StatiCrypt, README, read at the commit of 8 Jun 2026.
    6. +
    7. OWASP: Password Storage Cheat Sheet, PBKDF2, as cited by StatiCrypt.
    8. +
    + +

    Revisions

    +
      +
    • Added the demo below /sealed/demo/, with its public passwords.
    • +
    • First version.
    • +
    + +

    Feedback

    +

    Not shared yet. When I post about it, what people answer goes here, with what it changed.

    +
    +
    + +
    + + + + +