From 1a45b4d3c8182477cefbbaf83da644899cd2fa05 Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:33:33 +0100 Subject: [PATCH 1/6] skills: narrating-a-thought The skill holds what the narration prototype taught: audio only when Tim calls a text final, the voice and the ElevenLabs call, word-for-word scripts with spoken parts, the phrase marker, the four colour blocks, figures that stay in view on a phone, and the checks. A research note on attention and the prototype player come with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- .claude/skills/narrating-a-thought/SKILL.md | 62 ++ .../narrating-a-thought/attention-research.md | 39 ++ .../narrating-a-thought/reference-player.html | 591 ++++++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 .claude/skills/narrating-a-thought/SKILL.md create mode 100644 .claude/skills/narrating-a-thought/attention-research.md create mode 100644 .claude/skills/narrating-a-thought/reference-player.html diff --git a/.claude/skills/narrating-a-thought/SKILL.md b/.claude/skills/narrating-a-thought/SKILL.md new file mode 100644 index 0000000..b7656ce --- /dev/null +++ b/.claude/skills/narrating-a-thought/SKILL.md @@ -0,0 +1,62 @@ +--- +name: narrating-a-thought +description: Use when Tim wants a thought on t7d.dev read aloud, narrated, turned into audio or a podcast, given read-along highlighting, or explained by a voice. Also use when the narration script, the voice, the ElevenLabs audio or the narration player of a thought must change, or when a narrated page feels restless, hides its diagram on a phone, or loses the listener. +--- + +# Narrating a thought + +A narrated thought works two ways from one recording: Tim reads and listens at the same time, or he listens on the go with the screen off. The voice tells more than the page: it explains every figure and bridges the sections. The page follows the voice calmly and never hides the figure that the voice explains. + +The design below was tested with Tim in a prototype on 12 Nov 2026. Do not reopen these decisions unless Tim asks. The research behind them is in `attention-research.md`. The reference implementation is `reference-player.html` in this folder. + +## Audio costs credits: generate only when Tim says so + +- Generate ElevenLabs audio only when Tim says that the text of the thought is final and must be narrated. Approval of a cost estimate is not that instruction. +- Do not make voice samples. The voice is chosen (see the table). +- For all other work (script drafts, player changes, demos), reuse the audio that exists. For humans-md, Tim keeps a demo in `drafts/narration/humans-md/` (gitignored, local only). +- Do not read, print or commit the key. It is in `~/.config/wortzauber/elevenlabs.key`. Send it only as the `xi-api-key` header. +- Before a generation, tell Tim the character count. After it, tell him what it used. + +## Decided design + +| Part | Decision | +| --- | --- | +| Voice | Jonathan Livingston, ElevenLabs library, `PIGsltMj3gFMR34aFDI3`, rate 1.0. Model `eleven_multilingual_v2`, settings stability 0.5, similarity 0.75, style 0.1, speaker boost on. Eric (`cjVigY5qzO86Huf0OWal`) is the second choice. | +| Call | `POST /v1/text-to-speech/{voice}/with-timestamps?output_format=mp3_44100_96`, one call for the whole script. Keep `characters`, `character_start_times_seconds` and `character_end_times_seconds` as `narration.json`. | +| Script | One paragraph of the script per block on the page, separated by a blank line. Page text is read word for word. Say file names in spoken form ("Claude dot M D"). | +| Spoken parts | Text that only the voice says: an intro (title, author, length, "figures are described"), a bridge where the argument jumps between sections, a figure walk-through, a takeaway after a figure, an outro that names the next part. No bridge between paragraphs of one section. | +| Where spoken parts show | In the text, at their place, folded. Each opens in place when the voice reaches it and stays open. A small mono label "Spoken" and muted text mark it. A setting opens all of them. Never a caption box or pop-up over the text. | +| Marker | A phrase marker: a grey tint (`--ink` at 16%) over a phrase of up to 7 words, cut at punctuation or at a pause over 0.5 s. It moves about every 2 s and can cover two lines. Inside it, words go from 50% to full opacity as they are spoken. No per-word block. | +| Colours | Red: problem. Green: Tim's own idea. Blue: prior work and sources. Yellow: a term to remember. A marked term (`.mk`) gets a solid block when the voice says it, and the block stays. Text blocks use deep colours with white text in both themes: `#c62f22`, `#16803e`, `#2554d0`, `#9a6700`. Diagrams use the theme colours. | +| Other paragraphs | Dim them by text colour, never by `opacity`. Opacity also fades the colour blocks until their text disappears. | +| Code words | In read-along text, mono font only, no grey chip. In the dark theme the chip reads as a black block. | +| Figure on a phone | Show one drawing at a time (`.dg-w`, or `.dg-n` below 600 px). Under the figure, show only the spoken paragraph that the voice reads now. Scroll so that the whole figure and that paragraph fit above the player. Scroll again when a spoken part above it finishes opening. | +| Diagram cues | Parts of the figure react to phrases in the script: an axis gets thicker while it is described, a point takes its colour when it is named, a ring pulses on the point that the voice describes now. Derive all cue states from the playback position, so seeking backwards undoes them. | +| Player | A thin strip at the bottom: play, back 15 s, forward 15 s, chapter name and time (tap opens chapters), speed 1, 1.15, 1.3, 0.9. Ticks on the progress line mark chapters. No 2x. | +| Behaviour | Tap a paragraph to play from there. A reader who scrolls away is not pulled back. "Back to the voice" appears. Remember the position and offer "Resume". Media Session for lock screen and headphone controls. Load the mp3 as a blob, so seeking works on any host. | +| Offline | Tim wants a PWA: install the site and keep a thought's audio offline. A service worker and a manifest change the rule "only theme.js and site.js" in CLAUDE.md. Ask Tim before you add them, and update CLAUDE.md and DESIGN.md with them. A podcast RSS feed or an mp3 download link is not decided: ask Tim first, because both are public. | + +## Writing the script + +1. Copy the page text of each block word for word. +2. Add the spoken parts. Write for the ear: short sentences of varied length, one idea each. A bridge names the gap and asks the next question ("So that is the gap... did someone already write the other file?"). +3. Walk through a figure in this order: what kind of drawing it is, its axes or structure, each part in a fixed order with its colour, then the takeaway. Describe the point, not every pixel. For a `data-steps` figure, one script paragraph per step. +4. Add no jokes or anecdotes that do not carry the argument. +5. Show the script to Tim as text before any audio exists. + +## Check before you report + +- At 412 px in light and dark: one drawing per figure, the whole figure plus its current paragraph visible above the player, no sideways scroll, no console errors. +- Every `.mk` block: full opacity and text contrast of at least 4.5:1 while another paragraph plays. Measure it with a script, do not judge it by eye. +- Jump to each chapter. Cues, spoken parts and the marker must match the voice. +- Show the result as a Claude artifact. You cannot judge the sound yourself. Say so. + +## Mistakes that happened + +| Mistake | Fix | +| --- | --- | +| A solid block on every word: about 3 moves per second and a black/white flash per word felt restless. | The phrase marker. | +| A caption box for audio-only text broke the reading. | Folded spoken parts in the text. | +| `.fig svg{display:block}` beat `.dg-n{display:none}`, so both drawings showed. | `.fig svg.dg-n{display:none}`. | +| The whole figure description under the figure pushed the drawing off the phone screen. | Only the current paragraph under the figure. | +| A python `http.server` cannot seek in an mp3 (no range requests). | Load the audio as a blob. | diff --git a/.claude/skills/narrating-a-thought/attention-research.md b/.claude/skills/narrating-a-thought/attention-research.md new file mode 100644 index 0000000..5a56220 --- /dev/null +++ b/.claude/skills/narrating-a-thought/attention-research.md @@ -0,0 +1,39 @@ +# Attention research for narrated thoughts + +What research says about holding a listener's and a reader's attention, and what each finding means for a narrated thought. Checked on 12 Nov 2026. Where only a summary or abstract was read, the entry says so. + +## Findings + +| Finding | Evidence | What it means here | +| --- | --- | --- | +| People learn more from a picture with spoken words than from a picture with printed words (modality principle). | Fiorella and Mayer 2022, review of 76 studies, median d = 0.65 [1]. | Let the voice explain the figure. Do not put the same explanation as text next to the figure. | +| Printed text that repeats the narration word for word, next to a picture, hurts learning (redundancy principle). It does not hurt when the text is very short. | Mayer's reviews, d about 0.7 to 0.87 [2][3]. | Spoken parts stay folded until the voice reaches them. Under a figure, show only the paragraph that the voice reads now, not the whole description. | +| Cues that show what matters help (signaling). | Mayer's reviews, d about 0.46 to 0.69 [2][3]. | The four colours and the phrase marker are cues. A cue must point at what the voice says now, and at nothing else. | +| Learner-paced segments beat one continuous stream (segmenting). | Mayer's reviews, d = 0.70 [2]. | Chapters, skip 15 s, tap a paragraph to play from there. | +| Interesting but irrelevant extras lower learning, more so in short lessons (seductive details). | Rey 2012. Sundararajan and Adesope 2020, 68 experiments. A 2026 multi-level meta-analysis, g = -0.16 [4]. Summaries only. | Bridges carry the argument forward. No jokes, anecdotes or sound effects that do not serve the point. | +| Synchronized word highlighting captures the eyes, but it did not improve children's learning over unsynchronized reading while listening. Weaker readers liked it most. | Gerbier, Bailly and Bosse 2018 [5]. Abstract and summaries only. | A strong word-by-word marker pulls the eye and does not teach more. Use a calm phrase marker. This matches what Tim felt with the solid word block. | +| Reading while listening helps weaker readers and doubled reading volume in one study. Results on comprehension are mixed. | Review of reading-while-listening studies [6]. | The read-along mode is a reason to read, not proof of better understanding. Keep audio-only listening a full experience too. | +| Listening alone produced more mind wandering, weaker memory and less interest than reading aloud or silent reading. | Varao Sousa, Carriere and Smilek 2013 [7]. In everyday settings, outside distraction mattered more than mind wandering (2018) [8]. | Pure listening needs extra help: signposts, short recaps, an outro. Readers on a phone get distracted, so resume and "Back to the voice" matter. | +| Short tests during a lecture halved mind wandering (40% without them) and improved retention. | Szpunar, Khan and Schacter 2013, PNAS, 21-minute lecture [9]. | Open idea: a short question at a section end ("Pause here: which file reads you?"). Not tried yet. | +| Shorter videos held viewers longer. | Guo, Kim and Rubin 2014, 6.9 million sessions on edX [10]. The often quoted "6 minutes" comes from summaries, not checked in the paper. | Keep chapters short, about 1 to 3 minutes. Show the length of each chapter. | +| Narration is usually 150 to 160 words per minute. Listeners picked about 165 wpm as comfortable. Comprehension holds up to about 1.25x and drops near 1.5x to 2x. | Production norm and summaries of speech-rate studies [11]. One ad study found about 180 wpm best [11]. | Default 1x, offer 0.9x, 1.15x and 1.3x. Do not offer 2x. | +| A modern synthetic voice taught as well as a human voice. The human voice still felt more human and engaging. | Craig and Schroeder 2017; Mayer and DaPra 2012 [12]. Summaries only. | ElevenLabs is fine for learning. Choose a calm voice with a natural pace. Keep the same voice for all thoughts, so it becomes familiar. | +| Writing for the ear: short sentences of varied length, one idea per sentence, signposts and previews, deliberate repetition of the main point. | Radio and NPR training material [13]. Practice guidance, not experiments. | Bridges preview the next question. Repeat the key term when a section returns to it. | +| Describe a diagram by its point, not by every detail. Long narrated descriptions of flow charts are hard to follow. Present processes as ordered steps. | WGBH/NCAM guidelines for STEM images; OCALI audio description tips [14]. | Figure walk-through: kind of drawing first, then axes or structure, then each part in a fixed order, then the takeaway. | + +## Sources + +1. Fiorella and Mayer 2022, modality principle review, summarised in Mayer, Multimedia Principles (2020/2021): https://ugc.futurelearn.com/uploads/files/7d/d6/7dd6188d-c343-4311-b064-ac98d2c95abc/Multimedia_Principles._R._E._Mayer__2020.pdf +2. Mayer 2017, Using multimedia for e-learning, Journal of Computer Assisted Learning: https://zendy.io/title/10.1111/jcal.12197 +3. Dartmouth summary of Mayer's principles: https://services.dartmouth.edu/TDClient/1806/Portal/KB/PrintArticle?ID=171655 +4. Seductive details overview: https://en.wikipedia.org/wiki/Seductive_details and https://www.learningandthebrain.com/blog/seductive-details-when-do-cool-stories-and-videos-interfere-with-learning/ +5. Gerbier, Bailly and Bosse 2018: https://hal.archives-ouvertes.fr/hal-01575227 +6. Reading while listening review (LUCS 176): https://www.lucs.lu.se/fileadmin/user_upload/project/lucs/LUCS-pub/LUCS-176.pdf +7. Varao Sousa, Carriere and Smilek 2013, Frontiers in Psychology: https://pmc.ncbi.nlm.nih.gov/articles/PMC3842750 +8. Varao-Sousa, Smilek and Kingstone 2018: https://pmc.ncbi.nlm.nih.gov/articles/PMC6246756 +9. Szpunar, Khan and Schacter 2013, PNAS: https://pmc.ncbi.nlm.nih.gov/articles/PMC3631699 +10. Guo, Kim and Rubin 2014: https://up.csail.mit.edu/other-pubs/las2014-pguo-engagement.pdf +11. Speech rate: https://psychologytoday.com/au/blog/small-talk-and-big-questions/202307/is-speed-listening-right-for-you and https://cdn.warc.com/newsandopinion/news/how-speech-delivery-speed-impacts-audio-ads/43304 +12. Voice principle: https://unpaywall.org/10.1016%2Fj.compedu.2019.103756 +13. Writing for the ear: https://www.npr.org/sections/npr-training/2025/05/30/g-s1-65814/the-journey-from-print-to-radio-storytelling-a-guide-for-navigating-a-new-landscape +14. WGBH guidelines for describing STEM images: https://www.wgbh.org/guidelines-for-describing-stem-images ; OCALI tips: https://conference.ocali.org/up_doc/2024_Tips_AD.pdf diff --git a/.claude/skills/narrating-a-thought/reference-player.html b/.claude/skills/narrating-a-thought/reference-player.html new file mode 100644 index 0000000..2950801 --- /dev/null +++ b/.claude/skills/narrating-a-thought/reference-player.html @@ -0,0 +1,591 @@ + +Narrated Thoughts + + + + + + +
+
+

Prototype · version 2 · not on t7d.dev yet

+

Narrated thoughts

+
+
Voice
Jonathan Livingston
+
Player
B · Strip
+
Marker
Phrase
+
+
    +
  • Phrase marker. A grey tint covers the short phrase that the voice reads. It moves about every two seconds, and each word darkens as it is spoken. Colour blocks on marked terms stay, with white text.
  • +
  • Spoken parts. Four parts exist only in the audio: the introduction, a bridge between the sections, the walk through Fig. 1, and what comes next. They are folded in the text. When the voice reaches one, it opens in place, marked “Spoken”, and stays open.
  • +
+
+
+ While it plays + + +
+
+ The four colours +
+
RedA problem or a risk. Here: “go by feel”.
+
GreenMy own idea or answer. Here: humans.md, “this file”.
+
BluePrior work and sources. Here: attogram.
+
YellowA term or a name to remember. Here: CLAUDE.md.
+
+
+
+
+ +
+ +
+ +
+ ← Thoughts +

A humans.md for working with models

+

My repositories tell the agent how to work with me. Nothing tells me how to work with the agent. A first version of that file, and what I found when I looked for one.

+
+
Status
Draft
+
Started
10 Nov 2026
+
Listen
2:02
+
+
+ +

Narrated by Jonathan Livingston. The voice also describes every figure, so it works without the screen.

+ +
+

SpokenA humans.md for working with models. A thought by Tim Conrad, read aloud. It takes about two minutes. When a figure comes up, the narration describes it, so you can listen without the screen.

+
+ +
+

The missing file

+

Every repository of mine has a CLAUDE.md. It tells the agent how to build the project, what to leave alone and how I want my commits. When the agent gets something wrong, I add a line.

+

There is no such file for me. When a new model comes out, I switch, try a few tasks and go by feel. Sometimes I start a task without any CLAUDE.md, to see whether the new model does better without my old rules. Mostly I just notice things.

+

SpokenSo that is the gap. The agent gets a manual, and I get a hunch. The obvious next question: did someone already write the other file?

+
+

The name is taken

+

I was not the first to think of a humans.md. I found three projects that use the name, and they mean three different things.

+
+
+ + Files called humans.md, by who reads them and what they are about + + + the agent reads it + I read it + about how I work + about how the agent treats me + attogram/agentsSep 2025 · for humans + this file+ a log per model + cmdai-hq/HUMANS.mdOct 2026 · how to reach me + thellmwhisperer/human.mdFeb 2026 · hours and breaks + + + Files called humans.md, by who reads them and what they are about + + + agent reads it + I read it + about how I work + about how it treats me + attogramSep 2025 + this file+ log per model + cmdai-hqOct 2026 + thellmwhispererFeb 2026 + +
+

SpokenHave a look at figure one. It is a simple map, with two lines that cross in the middle. Left to right, it shows who reads the file: the agent on the left, me on the right. Top to bottom, it shows what the file is about: how I work at the top, how the agent treats me at the bottom.

+

SpokenUp in the top right, in blue, sits attogram’s HUMANS file, from September 2025. Down in the lower left, in yellow, a file that sets your working hours and your breaks. Just above it, in red, a profile that tells agents how to reach you.

+

SpokenAnd here, in green, close to the blue one, is the file this thought is about. It is for me to read, like attogram’s. What it adds is a log, one entry for every new model.

+
+
Fig. 1 · Three files with one name. Their places on the map are my reading of each project.
+
+

So humans.md is not my term, and it is not a standard either. Two of the three are files for the agent, about how it should treat me. Mine is the other way round: a file for me, about how I should work with it.

+

SpokenThat is where this demo ends. In the full thought, the next part looks at why the models change under me, and why feel is a bad meter.

+
+
+
+
+ + +

Chapters

+ +
+
+
+ + + + + +
+
+ + From d81f7e9fb310b856e43a9e9fcdb4f4b4f741fc6b Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:04:30 +0100 Subject: [PATCH 2/6] encrypted-patches: rewritten for listeners, with a narration The text explains patch diffing, n-days, TUF, Uptane, transparency logs, drand and timelock encryption for readers who do not know them. The games section folds into "The idea" and ends with its four rules; the patents shrink to one paragraph. Spoken parts, colour terms and a narrow drawing for every figure prepare it to be read aloud. assets/narration.js reads the page as the script and follows it with a phrase marker, colour blocks, figure cues and steps. Until audio exists the timing is simulated. DESIGN.md and CLAUDE.md name the new script and the narration colours; both skills carry what this taught. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- .claude/skills/adding-a-thought/SKILL.md | 9 + .claude/skills/narrating-a-thought/SKILL.md | 76 ++- .../narrating-a-thought/reference-player.html | 591 ------------------ CLAUDE.md | 2 +- DESIGN.md | 10 +- assets/narration.js | 355 +++++++++++ assets/site.css | 112 +++- assets/site.js | 3 +- thoughts/encrypted-patches/index.html | 255 ++++++-- thoughts/encrypted-patches/narration.json | 30 + 10 files changed, 754 insertions(+), 689 deletions(-) delete mode 100644 .claude/skills/narrating-a-thought/reference-player.html create mode 100644 assets/narration.js create mode 100644 thoughts/encrypted-patches/narration.json diff --git a/.claude/skills/adding-a-thought/SKILL.md b/.claude/skills/adding-a-thought/SKILL.md index d03af4d..0f8045f 100644 --- a/.claude/skills/adding-a-thought/SKILL.md +++ b/.claude/skills/adding-a-thought/SKILL.md @@ -38,6 +38,15 @@ The voice is DESIGN.md "Wording": first person, short sentences, plain words, no - Figures carry the ideas that are easier to see than to read. Use `figure.anim` (DESIGN.md "Animated diagram"): a drawing that plays once on screen, a two-state switch, parts to tap, a running token. Draw a narrow version (`.dg-n`) for phones. Use only the `.dg` classes, no colours, no `style` attributes. - End with what Tim takes from it: a rule, a draft, a file, or open questions. +Write for a technical reader who does not know every concept. Tim's feedback on encrypted-patches (12 Nov 2026): the TUF and drand parts were too short and had too many new words, and the games and patents sections felt meaningless. + +- Explain each new term where it first appears, in one plain sentence: what it is, then why it matters here. "A bug that is already fixed and public is called an n-day." A name alone (TUF, Uptane, drand, tlock) is not an explanation. +- Give a mechanism the room it needs. If the argument rests on it, explain how it works in a few short paragraphs, with an everyday comparison where one fits. Do not compress it into a list of terms. +- Every section must carry the argument. End a detour or an example with what it teaches for the idea ("So the lesson from games is four rules"). If it teaches nothing, cut it or fold it into the section it serves. Background such as patents gets one short paragraph with the practical consequence. +- Use few numbers in a row. After a dense list, say in one sentence what the numbers show. +- Check every new explanation against a primary source, like any other claim. +- If the thought will be read aloud, use the skill `narrating-a-thought` too. Text that reads well aloud also reads well on the page. + ## 5. The page Copy `thoughts/humans-md/index.html` and keep its structure: diff --git a/.claude/skills/narrating-a-thought/SKILL.md b/.claude/skills/narrating-a-thought/SKILL.md index b7656ce..3f0f88e 100644 --- a/.claude/skills/narrating-a-thought/SKILL.md +++ b/.claude/skills/narrating-a-thought/SKILL.md @@ -1,55 +1,67 @@ --- name: narrating-a-thought -description: Use when Tim wants a thought on t7d.dev read aloud, narrated, turned into audio or a podcast, given read-along highlighting, or explained by a voice. Also use when the narration script, the voice, the ElevenLabs audio or the narration player of a thought must change, or when a narrated page feels restless, hides its diagram on a phone, or loses the listener. +description: Use when Tim wants a thought on t7d.dev read aloud, narrated, turned into audio or a podcast, given read-along highlighting, or explained by a voice. Also use when the narration script, the voice, the ElevenLabs audio, narration.json or assets/narration.js must change, or when a narrated page feels restless, hides its diagram on a phone, or loses the listener. --- # Narrating a thought A narrated thought works two ways from one recording: Tim reads and listens at the same time, or he listens on the go with the screen off. The voice tells more than the page: it explains every figure and bridges the sections. The page follows the voice calmly and never hides the figure that the voice explains. -The design below was tested with Tim in a prototype on 12 Nov 2026. Do not reopen these decisions unless Tim asks. The research behind them is in `attention-research.md`. The reference implementation is `reference-player.html` in this folder. +The design was tested with Tim on 12 Nov 2026, first in a prototype, then on `thoughts/encrypted-patches/`, the reference page. Do not reopen these decisions unless Tim asks. The research behind them is in `attention-research.md`. ## Audio costs credits: generate only when Tim says so - Generate ElevenLabs audio only when Tim says that the text of the thought is final and must be narrated. Approval of a cost estimate is not that instruction. - Do not make voice samples. The voice is chosen (see the table). -- For all other work (script drafts, player changes, demos), reuse the audio that exists. For humans-md, Tim keeps a demo in `drafts/narration/humans-md/` (gitignored, local only). +- Until then, the page runs on simulated timing (about 160 words a minute). Tim reads along and judges the text, the spoken parts and the figures without audio. - Do not read, print or commit the key. It is in `~/.config/wortzauber/elevenlabs.key`. Send it only as the `xi-api-key` header. - Before a generation, tell Tim the character count. After it, tell him what it used. +## How it is built + +| Part | Where | +| --- | --- | +| Player, marker, cues, scrolling | `assets/narration.js` (no inline script, the CSP stays strict). The page loads it with ` diff --git a/CLAUDE.md b/CLAUDE.md index 1ee056d..a7b208d 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 `assets/narration.js` on a narrated thought (skill `narrating-a-thought`). - `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..48bb3af 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,7 +8,7 @@ The site is one drawing sheet from a small office: black ink on white paper, a t 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. +1. **Black and white.** The only colour on the site is in the pictures and videos, so they are what the eye finds first. The one exception is a narration while it plays (see Narration below). 2. **Plain words.** The site says what it holds and what a thing does. It does not sell. ## Wording @@ -38,6 +38,7 @@ Every colour is a token at the top of `assets/site.css`. A component never uses | `--code-bg`, `--code-fg` | `#111111`, `#f2f2f2` | `#1a1a1a`, `#f2f2f2` | Code blocks | - There is no accent colour. Emphasis is an inverted block: ink background, paper text. Use it once per group (the main button, the CVE tag). +- Narration colours: `--red` (a problem), `--green` (my own idea), `--blue` (prior work and sources), `--yellow` (a term to remember), with darker values in the dark theme, for parts of a diagram. Words get a solid block in `--blk-red`, `--blk-green`, `--blk-blue` or `--blk-yellow` with `--on-blk` text: deep enough for white text (at least 4.5:1) in both themes. They show only after the reader presses Listen. Use about three marked terms per section. - The dark theme is the same drawing in white on black. It follows the system setting, and the toggle in the nav overrides it. A new token needs a value in all three blocks: `:root`, the `prefers-color-scheme` block and `[data-theme="dark"]`. - Pictures and videos keep their own colours. Do not tint, filter or desaturate them. @@ -80,9 +81,10 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano | Timeline | `.changes`, `.tl` | On a game page: one entry per merged pull request that changed play, newest first, on a vertical ink line with a dot per entry (the newest dot filled). Each entry: a mono label with date and PR, the title as `h3`, one sentence on what the clip shows, a short list of the other changes, and the silent clip (the video component) beside the text. One line at the end names the PRs that are not shown. | | Post list | `.posts` | On `/thoughts/`: date as a label and the status, the title underlined, one sentence. Newest first. | | 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. | +| 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. This works in every figure; a narrated thought needs it, because the whole drawing must be in view while the voice explains it. 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. | +| Narration | `article[data-narration]`, `.say`, `.mk`, `assets/narration.js` | A thought read aloud. `narration.json` next to the page holds the chapters, the figure cues and, once it exists, the audio with its timings; without audio the timing is simulated. Listen sits under the title; the player is a thin strip at the bottom. A grey tint marks the phrase that the voice reads, words darken as they are spoken, and the other blocks dim by text colour (never opacity). `.mk` terms get a colour block that stays. `.say` holds text that only the voice says: folded until the voice reaches it, then open, with the label "Spoken". Under a figure, only the paragraph that the voice reads shows, and the caption waits. The skill `narrating-a-thought` has the rules. | | 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 +93,12 @@ 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 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 narration moves only while it plays: the phrase marker, the colour blocks and the figure steps follow the voice. 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), `assets/narration.js` (the narration of a thought) - Reels and pictures of the games: `.claude/skills/updating-product-reels/SKILL.md` - Deploy and repository layout: `README.md` diff --git a/assets/narration.js b/assets/narration.js new file mode 100644 index 0000000..d8199a9 --- /dev/null +++ b/assets/narration.js @@ -0,0 +1,355 @@ +/* Narration for a thought: the page follows a voice that reads it. + The page opts in with <article class="prose" data-narration="/thoughts/<slug>/narration.json">. + The script is the page itself: every h2, p and li of the article up to #sources, plus the spoken parts + (.say, text that only the voice says). narration.json holds the chapters, the figure cues and, once it exists, + the audio with its character timings from ElevenLabs. Without audio the timing is simulated, so the page can be + read along before any audio is generated. Styles: "Narration" in site.css. */ +(function () { + 'use strict'; + var art = document.querySelector('article.prose[data-narration]'); + if (!art || !window.fetch) return; + var $ = function (s, el) { return (el || document).querySelector(s); }; + var $$ = function (s, el) { return Array.prototype.slice.call((el || document).querySelectorAll(s)); }; + var reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; + var KEY = 'narr:' + location.pathname; + var store = { + get: function () { try { return JSON.parse(localStorage.getItem(KEY)); } catch (e) { return null; } }, + set: function (v) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch (e) {} } + }; + + fetch(art.getAttribute('data-narration')).then(function (r) { return r.json(); }).then(init).catch(function () {}); + + function init(N) { + /* ---- the script: blocks of the page in reading order ---- */ + var stop = $('#sources', art), segs = []; + var walker = document.createTreeWalker(art, NodeFilter.SHOW_ELEMENT, { acceptNode: function (n) { + if (stop && (n === stop || (stop.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING))) return NodeFilter.FILTER_REJECT; + if (n.matches('svg, figcaption, .code, .steps__bar, .refs')) return NodeFilter.FILTER_REJECT; + return n.matches('h2, p, li') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + } }); + while (walker.nextNode()) { + var el = walker.currentNode, say = el.closest('.say'); + var text = ((N.spoken || {})[el.id] || spokenText(el)).trim(); + if (text) segs.push({ el: el, say: say, text: text, pause: say ? +(say.getAttribute('data-pause') || 0) : 0 }); + } + var T = '', i; + segs.forEach(function (s, k) { if (k) T += '\n\n'; s.s = T.length; T += s.text; s.e = T.length; s.el.classList.add('narr-seg'); }); + + var A = N.timing || simulate(T, segs); // {start: [...], end: [...]} per character of T + var dur = A.end[A.end.length - 1]; + var sim = !N.timing; + + /* ---- words: wrap the printed words and give each the time of its spoken twin ---- */ + var words = [], m, spoken = [], wr = /\S+/g; + while ((m = wr.exec(T))) spoken.push({ n: norm(m[0]), s: A.start[m.index], c: m.index }); + segs.forEach(function (s, k) { + var printed = wrapWords(s.el), mine = spoken.filter(function (w) { return w.c >= s.s && w.c < s.e; }); + align(mine, printed); + var last = A.start[s.s]; + printed.forEach(function (w) { if (w.s === Infinity) w.s = last; else last = w.s; w.k = k; }); + // phrases: cut at punctuation, at a pause in the voice, or after 7 words + var ph = null; + printed.forEach(function (w, j) { + if (!ph) ph = { words: [] }; + ph.words.push(w); w.ph = ph; + var nx = printed[j + 1], t = w.el.textContent; + if (!nx || (/[,.;:?!]$/.test(t) && ph.words.length >= 2) || ph.words.length >= 7 || (nx.s - w.s > 0.5 && ph.words.length >= 2)) ph = null; + }); + s.words = printed; + words.push.apply(words, printed); + }); + var marks = $$('.mk', art).map(function (mk) { + var ws = words.filter(function (w) { return mk.contains(w.el); }); + return { el: mk, s: ws.length ? ws[0].s : Infinity }; + }); + + /* ---- sentences, chapters, cues ---- */ + var sents = [], re = /[^.!?\n]+(?:[.!?]+|$)/g; + while ((m = re.exec(T))) { var lead = m[0].length - m[0].replace(/^\s+/, '').length; if (m[0].trim()) sents.push(m.index + lead); } + var chapters = (N.chapters || []).map(function (c) { + var k = segs.findIndex(function (s) { return s.el.id === c[0] || !!s.el.closest('#' + c[0]) || (s.say && s.say.id === c[0]); }); + return { k: k, title: c[1] }; + }).filter(function (c) { return c.k >= 0; }); + if (!chapters.length || chapters[0].k !== 0) chapters.unshift({ k: 0, title: N.title || 'Start' }); + var cues = (N.cues || []).map(function (q) { + var host = document.getElementById(q['in']); if (!host) return null; + var s = segs.find(function (x) { return x.el === host || host.contains(x.el); }); if (!s) return null; + var scope = host.closest('figure') || art, at = T.indexOf(q.p, s.s), to = q.until ? T.indexOf(q.until, at) : -1; + return { els: $$(q.sel, scope), cls: q.cls || 'on', at: at, to: q.until ? (to < 0 ? T.length : to) : Infinity }; + }).filter(function (q) { return q && q.at >= 0; }); + + /* ---- the player ---- */ + var head = $('.post-head'), main = $('#main'); + var listen = node('div', 'narr-listen'); + var go = node('button', 'btn narr-go'); go.type = 'button'; + go.innerHTML = icon() + '<span></span>'; + var note = node('p', 'narr-note', sim ? 'Simulated timing: the audio is not made yet. The page moves the way the voice will.' : 'Narrated by ' + (N.voice || 'a voice') + '. The voice also describes every figure, so it works without the screen.'); + var again = node('button', 'btn btn--ghost btn--s narr-again', 'Start over'); again.type = 'button'; again.hidden = true; + listen.appendChild(go); listen.appendChild(note); listen.appendChild(again); + head.appendChild(listen); + + var bar = node('div', 'narr-bar'); + bar.innerHTML = + '<div class="narr-scrub"><i class="narr-rail"></i><i class="narr-fill"></i><i class="narr-knob"></i></div>' + + '<div class="narr-row">' + + '<button type="button" class="narr-play" aria-label="Play or pause">' + icon() + '</button>' + + '<button type="button" class="narr-skip" data-act="back" aria-label="Back to the previous sentence">' + arrow(true) + '</button>' + + '<button type="button" class="narr-skip" data-act="fwd" aria-label="Forward 15 seconds">' + arrow(false) + '<span>15</span></button>' + + '<button type="button" class="narr-chap" aria-label="Chapters"><span></span><small></small></button>' + + '<button type="button" class="narr-speed" aria-label="Speed">1×</button></div>'; + document.body.appendChild(bar); + var scrub = $('.narr-scrub', bar), fill = $('.narr-fill', bar), knob = $('.narr-knob', bar); + chapters.slice(1).forEach(function (c) { var t = node('i', 'narr-tick'); t.style.left = (A.start[segs[c.k].s] / dur * 100) + '%'; scrub.appendChild(t); }); + var sheet = node('div', 'narr-sheet'); + sheet.innerHTML = '<p class="label">Chapters</p><ol></ol><label class="narr-opt"><input type="checkbox" id="narr-all"> Show all spoken parts</label>'; + document.body.appendChild(sheet); + chapters.forEach(function (c) { + var li = node('li'), b = node('button'); b.type = 'button'; + b.innerHTML = '<span></span><span class="narr-t"></span>'; + b.firstChild.textContent = c.title; b.lastChild.textContent = fmt(A.start[segs[c.k].s]); + b.addEventListener('click', function () { seekChar(segs[c.k].s); refollow(); if (!playing()) play(); sheet.classList.remove('open'); }); + li.appendChild(b); $('ol', sheet).appendChild(li); c.li = li; + }); + var back = node('button', 'narr-follow', 'Back to the voice'); back.type = 'button'; + document.body.appendChild(back); + var boxes = [0, 1, 2].map(function () { var h = node('div', 'narr-hl'); h.setAttribute('aria-hidden', 'true'); main.appendChild(h); return h; }); + + /* ---- the clock: real audio, or simulated time ---- */ + var SPEEDS = [1, 1.15, 1.3, 0.9], speedI = 0, audio = null; + var clock = { on: false, base: 0, t0: 0, rate: 1, + now: function () { return this.on ? Math.min(dur, this.base + (performance.now() - this.t0) / 1000 * this.rate) : this.base; }, + play: function () { if (!this.on) { this.t0 = performance.now(); this.on = true; } }, + pause: function () { if (this.on) { this.base = this.now(); this.on = false; } }, + seek: function (t) { this.base = t; this.t0 = performance.now(); }, + speed: function (r) { this.base = this.now(); this.t0 = performance.now(); this.rate = r; } }; + if (N.audio) { + audio = new Audio(); + fetch(N.audio).then(function (r) { return r.blob(); }).then(function (b) { audio.src = URL.createObjectURL(b); }); // a blob seeks on any host + clock = { now: function () { return audio.currentTime; }, play: function () { var p = audio.play(); if (p && p.catch) p.catch(function () {}); }, + pause: function () { audio.pause(); }, seek: function (t) { audio.currentTime = t; }, speed: function (r) { audio.playbackRate = r; }, get on() { return !audio.paused; } }; + } + function playing() { return clock.on; } + + var started = false, follow = true, lastC = -2, curK = -1, shownKey = null, boxY = [], autoAt = 0, pausedAt = 0, raf = 0, stepFig = null; + function play() { + if (!started) { started = true; document.body.classList.add('narr-on'); again.hidden = false; var r = store.get(); if (r && r.c > 0 && r.c < T.length - 30) seekChar(r.c); } + else if (pausedAt && Date.now() - pausedAt > 30000) seekChar(sentenceStart(charAt(clock.now()))); // back in after a break: start of the sentence + clock.play(); document.body.classList.add('narr-playing'); refollow(); loop(); + } + function pause() { clock.pause(); pausedAt = Date.now(); document.body.classList.remove('narr-playing'); store.set({ c: Math.max(0, lastC) }); render(true); } + function toggle() { if (playing()) pause(); else play(); } + function seek(t) { clock.seek(Math.max(0, Math.min(dur - 0.05, t))); render(true); } + function seekChar(c) { seek(A.start[Math.max(0, c)] + 0.001); } + function refollow() { follow = true; document.body.classList.remove('narr-lost'); curK = -1; } + function loop() { if (raf) return; (function f() { render(false); if (clock.now() >= dur - 0.01 && !audio) { pause(); store.set({ c: 0 }); } raf = playing() ? requestAnimationFrame(f) : 0; })(); } + + /* ---- one frame ---- */ + function render(force) { + var t = clock.now(), c = charAt(t); + times(t); + if (c === lastC && !force) { marker(false); return; } + lastC = c; + var k = c < 0 ? 0 : segOf(c), s = segs[k]; + // the block that the voice reads, its figure, the spoken parts that it has reached + segs.forEach(function (x, j) { x.el.classList.toggle('cur', started && j === k); }); + $$('figure', art).forEach(function (f) { f.classList.toggle('cur', started && f.contains(s.el)); }); + var all = $('#narr-all').checked; + document.body.classList.toggle('narr-all', all); + $$('.say', art).forEach(function (box) { + var first = segs.findIndex(function (x) { return x.say === box; }); + box.classList.toggle('open', all || (started && k >= first)); + }); + // a figure with steps follows the voice: its intro shows the whole picture, each list item one step + var fig = s.el.closest('figure[data-steps]'), step = 0; + if (fig && s.el.matches('.steps__list li')) step = $$('.steps__list li', fig).indexOf(s.el) + 1; + if (stepFig && stepFig !== fig) stepFig.dispatchEvent(new CustomEvent('narration:step', { detail: 0 })); + if (fig && started) fig.dispatchEvent(new CustomEvent('narration:step', { detail: step })); + stepFig = started ? fig : null; + // read words, colour blocks, figure cues: all derived from the position, so seeking back undoes them + words.forEach(function (w) { w.el.classList.toggle('rd', started && w.k === k && w.s <= t + 0.03); }); + marks.forEach(function (x) { x.el.classList.toggle('on', started && t >= x.s); }); + cues.forEach(function (q) { var on = started && c >= q.at && c < q.to; q.els.forEach(function (e) { e.classList.toggle(q.cls, on); }); }); + var ch = chapterOf(k); + $('.narr-chap span', bar).textContent = chapters[ch].title; + chapters.forEach(function (x, j) { x.li.classList.toggle('is-now', started && j === ch); }); + if (started && playing() && follow && k !== curK) requestAnimationFrame(function () { frame(s.el); }); + curK = k; + marker(force); + if (audio && 'mediaSession' in navigator && render.ch !== ch) { render.ch = ch; + navigator.mediaSession.metadata = new MediaMetadata({ title: chapters[ch].title, artist: 'Tim Conrad', album: N.title || document.title }); } + } + function times(t) { + $('.narr-chap small', bar).textContent = fmt(t) + ' / ' + fmt(dur); + var f = (t / dur * 100) + '%'; fill.style.width = f; knob.style.left = f; + var r = store.get(), rc = r && r.c > 0 && r.c < T.length - 30 ? r.c : 0; + $('span', go).textContent = !started ? (rc ? 'Resume · ' + fmt(A.start[rc]) : 'Listen · ' + fmt(dur)) : (playing() ? 'Pause · ' : 'Resume · ') + fmt(t); + } + + /* ---- the phrase marker: a grey tint over the phrase that the voice reads ---- */ + function marker(force) { + var t = clock.now(), w = null; + if (started) for (var j = 0; j < words.length; j++) { var x = words[j]; if (x.k === curK && x.s <= t + 0.03) w = x; } + if (!w) { if (shownKey) { draw(null); shownKey = null; } return; } + if (w.ph === shownKey && !force) return; + draw(w.ph.words); shownKey = w.ph; + } + function draw(ws) { + var list = ws ? lines(ws) : []; + boxes.forEach(function (h, j) { + var b = list[j]; + if (!b) { h.classList.remove('show'); boxY[j] = null; return; } + var jump = !h.classList.contains('show') || (boxY[j] != null && Math.abs(b.y - boxY[j]) > 4); // a new line: appear there, do not slide across the text + h.classList.toggle('jump', jump); + h.style.transform = 'translate(' + b.x + 'px,' + b.y + 'px)'; h.style.width = b.w + 'px'; h.style.height = b.h + 'px'; + h.classList.add('show'); boxY[j] = b.y; + if (jump) requestAnimationFrame(function () { requestAnimationFrame(function () { h.classList.remove('jump'); }); }); + }); + } + function lines(ws) { + var pr = main.getBoundingClientRect(), r = document.createRange(), out = []; + r.setStartBefore(ws[0].el); r.setEndAfter(ws[ws.length - 1].el); + Array.prototype.forEach.call(r.getClientRects(), function (q) { + if (q.width < 1) return; + var L = out.find(function (l) { return Math.abs(l.top - q.top) < 8; }); + if (!L) out.push({ top: q.top, left: q.left, right: q.right, bottom: q.bottom }); + else { L.left = Math.min(L.left, q.left); L.right = Math.max(L.right, q.right); L.bottom = Math.max(L.bottom, q.bottom); L.top = Math.min(L.top, q.top); } + }); + return out.slice(0, 3).map(function (l) { return { x: l.left - pr.left - 4, y: l.top - pr.top - 1, w: l.right - l.left + 8, h: l.bottom - l.top + 2 }; }); + } + + /* ---- scrolling: keep the block in view; in a figure, keep the whole figure and its current paragraph ---- */ + function frame(el) { + var nav = $('.nav'), top = nav ? nav.getBoundingClientRect().height : 0; // the sticky site header covers the top + var fig = el.closest('figure'), vh = innerHeight - bar.offsetHeight - top, target; + if (fig) target = scrollY + fig.getBoundingClientRect().top - top - Math.max(8, (vh - fig.offsetHeight) / 2); + else { var r = el.getBoundingClientRect(); target = scrollY + r.top - top - Math.max(24, (vh - Math.min(r.height, vh * 0.8)) / 2.4); } + autoAt = Date.now(); + scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' }); + } + function lose() { if (started && playing() && Date.now() - autoAt > 900) { follow = false; document.body.classList.add('narr-lost'); } } + addEventListener('wheel', lose, { passive: true }); + addEventListener('touchmove', lose, { passive: true }); + addEventListener('resize', function () { boxY = []; shownKey = null; marker(true); }); + $$('.say', art).forEach(function (b) { b.addEventListener('transitionend', function (e) { + if (e.propertyName !== 'grid-template-rows') return; + boxY = []; shownKey = null; marker(true); + if (follow && playing() && segs[curK]) frame(segs[curK].el); // a part above opened and pushed the block down + }); }); + + /* ---- controls ---- */ + go.addEventListener('click', toggle); + $('.narr-play', bar).addEventListener('click', toggle); + again.addEventListener('click', function () { seekChar(0); refollow(); if (!playing()) play(); }); + back.addEventListener('click', function () { refollow(); render(true); }); + $('.narr-chap', bar).addEventListener('click', function (e) { e.stopPropagation(); sheet.classList.toggle('open'); }); + $('.narr-speed', bar).addEventListener('click', function () { + speedI = (speedI + 1) % SPEEDS.length; clock.speed(SPEEDS[speedI]); + this.textContent = String(SPEEDS[speedI]).replace(/^0/, '') + '×'; + }); + $$('.narr-skip', bar).forEach(function (b) { b.addEventListener('click', function () { + if (b.getAttribute('data-act') === 'fwd') { seek(clock.now() + 15); return; } + // back: to the start of this sentence, or of the one before when the voice has only just begun it + var c = Math.max(0, charAt(clock.now())), st = sentenceStart(c); + if (clock.now() - A.start[st] < 1.2) st = sentenceStart(Math.max(0, st - 2)); + seekChar(st); + }); }); + $('#narr-all').addEventListener('change', function () { render(true); }); + scrub.addEventListener('pointerdown', function (ev) { + if (!started) play(); + scrub.setPointerCapture(ev.pointerId); scrub.classList.add('drag'); + var at = function (e) { var r = scrub.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) * dur; }; + seek(at(ev)); + var mv = function (e) { seek(at(e)); }; + scrub.addEventListener('pointermove', mv); + scrub.addEventListener('pointerup', function () { scrub.removeEventListener('pointermove', mv); scrub.classList.remove('drag'); refollow(); render(true); }, { once: true }); + }); + document.addEventListener('click', function (e) { + if (!e.target.closest('.narr-sheet')) sheet.classList.remove('open'); + // tap a block to play from there + var el = e.target.closest('.narr-seg'); + if (!started || !el || e.target.closest('a, button') || String(getSelection())) return; + var k = segs.findIndex(function (s) { return s.el === el; }); + if (k >= 0 && k !== curK) { seekChar(segs[k].s); refollow(); if (!playing()) play(); } + }); + addEventListener('keydown', function (e) { + if (!started || e.target.closest('input, textarea, button, a')) return; + if (e.key === ' ') { e.preventDefault(); toggle(); } + if (e.key === 'ArrowLeft') seek(clock.now() - 10); + if (e.key === 'ArrowRight') seek(clock.now() + 10); + if (e.key === 'Escape') sheet.classList.remove('open'); + }); + if (audio) { + audio.addEventListener('pause', function () { document.body.classList.remove('narr-playing'); }); + audio.addEventListener('ended', function () { store.set({ c: 0 }); render(true); }); + if ('mediaSession' in navigator) try { + navigator.mediaSession.setActionHandler('play', play); + navigator.mediaSession.setActionHandler('pause', pause); + navigator.mediaSession.setActionHandler('seekbackward', function () { seek(clock.now() - 15); }); + navigator.mediaSession.setActionHandler('seekforward', function () { seek(clock.now() + 15); }); + } catch (e) {} + } + setInterval(function () { if (playing()) store.set({ c: Math.max(0, lastC) }); }, 4000); + render(true); + + /* ---- helpers that need the script ---- */ + function charAt(t) { var s = A.start, lo = 0, hi = s.length - 1; if (t < s[0]) return -1; while (lo < hi) { var md = (lo + hi + 1) >> 1; if (s[md] <= t) lo = md; else hi = md - 1; } return lo; } + function segOf(c) { for (var j = 0; j < segs.length; j++) if (c < segs[j].e + 2) return j; return segs.length - 1; } + function chapterOf(k) { var r = 0; chapters.forEach(function (c, j) { if (c.k <= k) r = j; }); return r; } + function sentenceStart(c) { var r = 0; sents.forEach(function (x) { if (x <= c) r = x; }); return r; } + } + + /* text that the voice says for a block: without source marks and labels */ + function spokenText(el) { + var c = el.cloneNode(true); + Array.prototype.forEach.call(c.querySelectorAll('a[href^="#s"], .say-k'), function (x) { x.remove(); }); + return c.textContent.replace(/\s+/g, ' '); + } + /* simulated timing, about 160 words a minute, with pauses at punctuation and between blocks */ + function simulate(T, segs) { + var start = new Float64Array(T.length), end = new Float64Array(T.length), t = 0.4, extra = {}; + segs.forEach(function (s) { if (s.pause) extra[s.e - 1] = s.pause; }); + for (var i = 0; i < T.length; i++) { + var ch = T[i], nx = T[i + 1], d = 0.062; + if (ch === ',') d += 0.18; + else if (ch === ':' || ch === ';') d += 0.22; + else if ('.!?'.indexOf(ch) >= 0 && (nx === ' ' || nx === '\n' || nx === undefined)) d += 0.38; + else if (ch === '\n') d = 0.45; + start[i] = t; t += d; end[i] = t; + if (extra[i]) t += extra[i]; + } + return { start: start, end: end }; + } + function norm(s) { return s.toLowerCase().replace(/\.md\b/, '').replace(/[^a-z0-9'$%]/g, '').replace(/'s$/, ''); } + /* wrap every word of a block in a span; words in a marked term keep its colour */ + function wrapWords(root) { + var out = [], nodes = [], w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (w.nextNode()) { var p = w.currentNode.parentElement; if (!p.closest('a[href^="#s"], .say-k')) nodes.push(w.currentNode); } + nodes.forEach(function (n) { + var frag = document.createDocumentFragment(); + n.textContent.split(/(\s+)/).forEach(function (part) { + if (!part) return; + if (/^\s+$/.test(part) || !norm(part)) { frag.appendChild(document.createTextNode(part)); return; } + var sp = document.createElement('span'); sp.className = 'w'; sp.textContent = part; + frag.appendChild(sp); out.push({ el: sp, n: norm(part), s: Infinity }); + }); + n.parentNode.replaceChild(frag, n); + }); + return out; + } + /* match spoken words to printed words: longest common subsequence */ + function align(sp, pr) { + var m = sp.length, n = pr.length, L = [], i, j; + for (i = 0; i <= m; i++) L.push(new Uint16Array(n + 1)); + for (i = m - 1; i >= 0; i--) for (j = n - 1; j >= 0; j--) + L[i][j] = sp[i].n && sp[i].n === pr[j].n ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]); + i = 0; j = 0; + while (i < m && j < n) { + if (sp[i].n && sp[i].n === pr[j].n) { pr[j].s = sp[i].s; i++; j++; } + else if (L[i + 1][j] >= L[i][j + 1]) i++; else j++; + } + } + function node(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; } + function fmt(s) { s = Math.max(0, Math.round(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); } + function icon() { return '<svg viewBox="0 0 24 24" aria-hidden="true"><path class="i-play" d="M7 4.5v15l12.5-7.5z"/><path class="i-pause" d="M6.5 4.5h4v15h-4zm7 0h4v15h-4z"/></svg>'; } + function arrow(left) { return '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6">' + (left ? '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4h4"/>' : '<path d="M20 12a8 8 0 1 1-2.4-5.7"/><path d="M20 4v4h-4"/>') + '</svg>'; } +})(); diff --git a/assets/site.css b/assets/site.css index 9dd1f13..b47e7db 100644 --- a/assets/site.css +++ b/assets/site.css @@ -1,5 +1,5 @@ /* Layout: the page is one drawing sheet. Black ink on white paper, a thin frame, a small label per section. - The only colour on the site is in the pictures and videos. */ + The only colour on the site is in the pictures and videos, and in the markers of a narration while it plays. */ @font-face { font-family: "Barlow Condensed"; @@ -45,6 +45,16 @@ --code-line: #3a3a3a; --video-bg: #111111; /* controls on top of a video: the same in both themes */ --video-fg: #ffffff; + /* narration markers: the four colours, only while a thought is read aloud (DESIGN.md "Narration") */ + --red: #d33a2c; + --green: #1d8a4a; + --blue: #2558d4; + --yellow: #e2a900; + --blk-red: #c62f22; /* colour blocks behind words: white text on them is >= 4.5:1 in both themes */ + --blk-green: #16803e; + --blk-blue: #2554d0; + --blk-yellow: #9a6700; + --on-blk: #ffffff; --display: "Barlow Condensed", "Arial Narrow", "Helvetica Neue", Arial, sans-serif; --body: "Karla", "Helvetica Neue", Helvetica, Arial, sans-serif; @@ -67,6 +77,10 @@ --soft: #2c2c2c; --surface: #1a1a1a; --code-bg: #1a1a1a; + --red: #ff6b5d; + --green: #4fcf86; + --blue: #7aa5ff; + --yellow: #f3c64a; } } :root[data-theme="dark"] { @@ -77,6 +91,10 @@ --soft: #2c2c2c; --surface: #1a1a1a; --code-bg: #1a1a1a; + --red: #ff6b5d; + --green: #4fcf86; + --blue: #7aa5ff; + --yellow: #f3c64a; } *, *::before, *::after { box-sizing: border-box; } @@ -409,8 +427,8 @@ blockquote { margin: 0; padding: .2rem 0 .2rem 1.2rem; border-left: 3px solid va .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; } } +figure .dg-n { display: none; } +@media (max-width: 600px) { figure:has(.dg-n) .dg-w { display: none; } figure .dg-n { display: block; } .fig-wide:has(.dg-n) { 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; } } @@ -434,5 +452,91 @@ blockquote { margin: 0; padding: .2rem 0 .2rem 1.2rem; border-left: 3px solid va .sealed__msg { min-height: 1.5em; margin: 0; font: 500 var(--step--1)/1.5 var(--mono); letter-spacing: .04em; color: var(--muted); overflow-wrap: anywhere; } .sealed__msg.bad { color: var(--ink); font-weight: 700; } +/* ---- Narration (narration.js): the page follows a voice. Nothing here shows until the reader presses Listen. ---- */ +[data-c=red] { --c: var(--red); --b: var(--blk-red); } +[data-c=green] { --c: var(--green); --b: var(--blk-green); } +[data-c=blue] { --c: var(--blue); --b: var(--blk-blue); } +[data-c=yellow] { --c: var(--yellow); --b: var(--blk-yellow); } +/* spoken parts: text that only the voice says, folded until the voice reaches it */ +.say { display: none; } +.narr-on .say, .narr-all .say { display: grid; grid-template-rows: 0fr; opacity: 0; margin-block: -.55rem; transition: grid-template-rows .55s cubic-bezier(.3,.7,.2,1), opacity .45s ease, margin .55s; } +.say > * { overflow: hidden; min-height: 0; } +.say.open { grid-template-rows: 1fr; opacity: 1; margin-block: 0; } +.say p { color: var(--muted); font-size: .9688rem; } +.say .w.rd { color: var(--ink); } +.say-k { font: 500 var(--step--1) var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-right: .5rem; white-space: nowrap; } +.say-k::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; border-radius: 50%; margin-right: .4rem; vertical-align: 1px; } +.prose figure .say { margin-top: .9rem; } +/* the block that the voice reads: a bar on the left; the other blocks dim by text colour, never by opacity, + so a colour block keeps its contrast */ +.narr-seg { position: relative; transition: color .45s ease; } +.narr-on .narr-seg.cur::before { content: ""; position: absolute; left: -.9rem; top: .25em; bottom: .25em; width: 2px; background: var(--ink); } +.narr-playing .narr-seg:not(.cur) { color: color-mix(in srgb, var(--ink) 36%, var(--bg)); } +.narr-playing .say .narr-seg:not(.cur) { color: color-mix(in srgb, var(--ink) 30%, var(--bg)); } +.narr-playing .prose figure:not(.cur) svg, .narr-playing .prose figure:not(.cur) figcaption { opacity: .34; transition: opacity .45s; } +.narr-on .narr-seg.cur .w:not(.rd) { opacity: .5; } +.narr-on .say .w:not(.rd) { opacity: 1; } +.w { position: relative; z-index: 1; transition: opacity .25s ease; } +.narr-on .prose .narr-seg code { background: transparent; border: 0; padding: 0; } +.narr-on .narr-seg { cursor: pointer; } +.narr-on .prose figure.fig-wide.cur:not(:has(.dg-n)) { overflow-x: visible; } /* no narrow drawing: fit the wide one while the voice explains it */ +.narr-on .prose figure.fig-wide.cur:not(:has(.dg-n)) .dg { min-width: 0; } +.narr-on .prose figure.cur .steps__bar, .narr-on .prose figure.cur .steps__hint, .narr-on .prose figure.cur .steps__at { display: none; } +.narr-playing .prose figure.cur .say:not(:has(.cur)), .narr-playing .prose figure.cur .say .narr-seg:not(.cur) { display: none; } /* under a figure, only the paragraph that the voice reads now */ +.narr-playing .prose figure.cur figcaption { display: none; } /* the voice explains the figure; the caption returns afterwards */ +/* the phrase marker: a grey tint over the phrase that the voice reads */ +.narr-on #main { position: relative; } +.narr-hl { position: absolute; left: 0; top: 0; z-index: 0; pointer-events: none; opacity: 0; background: color-mix(in srgb, var(--ink) 16%, transparent); + transition: transform .32s cubic-bezier(.25,.8,.25,1), width .32s cubic-bezier(.25,.8,.25,1), height .2s, opacity .35s ease; } +.narr-hl.show { opacity: 1; } +.narr-hl.jump { transition: opacity .35s ease; } +/* marked terms: a colour block with white text once the voice says them; plain text otherwise */ +.mk { transition: background-color .4s ease, color .4s ease; } +.mk.on { background: var(--b); color: var(--on-blk); padding: 0 .12em; margin: 0 -.12em; } +.mk.on .w { color: var(--on-blk); opacity: 1; } +.mk.on code { color: inherit; } +/* figure cues: the part that the voice describes stands out, the rest of the figure steps back */ +.dg [class^="cue-"] { transition: opacity .35s ease; } +.dg:has(.hot) [class^="cue-"]:not(.hot) { opacity: .3; } +.dg .hot .t-s { fill: var(--ink); } +.dg [data-c] { transition: fill .5s ease, stroke .5s ease; } +.dg [data-c].on { fill: var(--c); stroke: var(--c); } +/* the listen row and the player strip */ +.narr-listen { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; padding-block: .75rem; border-block: 1px solid var(--soft); } +.narr-go { display: inline-flex; align-items: center; gap: .55rem; } +.narr-go svg, .narr-play svg { width: 1.25rem; height: 1.25rem; fill: currentColor; } +.i-pause { display: none; } .narr-playing .i-play { display: none; } .narr-playing .i-pause { display: block; } +.narr-note { margin: 0; flex: 1 1 16rem; color: var(--muted); font-size: .9375rem; line-height: 1.4; } +.narr-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; background: var(--bg); border-top: var(--rule) solid var(--ink); padding-bottom: env(safe-area-inset-bottom, 0px); display: none; } +.narr-on .narr-bar { display: block; } +.narr-on { padding-bottom: 4.5rem; } +.narr-scrub { position: relative; height: 16px; margin-top: -9px; cursor: pointer; touch-action: none; } +.narr-rail, .narr-fill { position: absolute; left: 0; top: 7px; height: 2px; } +.narr-rail { right: 0; background: var(--soft); } +.narr-fill { width: 0; background: var(--ink); } +.narr-tick { position: absolute; top: 5px; width: 1.5px; height: 6px; background: var(--muted); } +.narr-knob { position: absolute; top: 3px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: var(--ink); opacity: 0; transition: opacity .2s; } +.narr-scrub:hover .narr-knob, .narr-scrub.drag .narr-knob { opacity: 1; } +.narr-row { display: flex; align-items: center; gap: .25rem; max-width: 82rem; margin: 0 auto; padding: 0 var(--pad) .35rem; } +.narr-row button { border: 0; background: none; color: var(--ink); cursor: pointer; min-width: 44px; min-height: 44px; display: grid; place-items: center; font: inherit; } +.narr-row .narr-play { background: var(--ink); color: var(--bg); } +.narr-skip { position: relative; } +.narr-skip svg { width: 1.6rem; height: 1.6rem; } +.narr-skip span { position: absolute; inset: 0; display: grid; place-items: center; padding-top: 2px; font: 500 .66rem var(--mono); } +.narr-row .narr-chap { flex: 1; min-width: 0; display: grid; justify-items: start; text-align: left; padding-inline: .5rem; } +.narr-chap span { font-weight: 700; font-size: .9375rem; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.narr-chap small { font: 400 .75rem var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; } +.narr-row .narr-speed { font: 500 .8rem var(--mono); border: 1px solid var(--soft); min-width: 3.2rem; } +.narr-sheet { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(4.1rem + env(safe-area-inset-bottom, 0px)); z-index: 40; width: min(26rem, calc(100% - 2rem)); background: var(--bg); border: var(--rule) solid var(--ink); padding: .7rem 1rem; display: none; } +.narr-sheet.open { display: block; } +.narr-sheet ol { list-style: none; margin: .4rem 0 .6rem; padding: 0; display: grid; } +.narr-sheet button { width: 100%; display: flex; justify-content: space-between; gap: 1rem; border: 0; border-top: 1px solid var(--soft); background: none; color: var(--ink); padding: .55rem .1rem; cursor: pointer; text-align: left; font: inherit; font-size: .9375rem; } +.narr-sheet .is-now button { font-weight: 700; } +.narr-t { font: 400 .75rem var(--mono); color: var(--muted); } +.narr-opt { display: flex; gap: .5rem; align-items: center; font-size: .875rem; color: var(--muted); } +.narr-follow { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(4.6rem + env(safe-area-inset-bottom, 0px)); z-index: 31; display: none; border: 0; background: var(--ink); color: var(--bg); padding: .5rem .9rem; font: 500 var(--step--1) var(--mono); letter-spacing: .06em; text-transform: uppercase; cursor: pointer; } +.narr-playing.narr-lost .narr-follow { display: block; } +@media (prefers-reduced-motion: reduce) { .say, .narr-hl, .narr-seg, .mk, .w { transition: none !important; } } + @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } -@media print { .nav, .toc, .copy, .toggle, .demo { display: none !important; } body { border: 0; } } +@media print { .nav, .toc, .copy, .toggle, .demo, .narr-bar, .narr-listen { display: none !important; } body { border: 0; } } diff --git a/assets/site.js b/assets/site.js index 938ceb5..5f8c148 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; // the wide and the narrow drawing 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'; @@ -155,6 +155,7 @@ show(1); play.textContent = 'Pause'; play.setAttribute('aria-label', 'Pause'); timer = setInterval(function () { if (at >= n) stop(); else show(at + 1); }, 2800); }); + fig.addEventListener('narration:step', function (e) { stop(); show(e.detail); }); // narration.js: the voice sets the step show(0); }); diff --git a/thoughts/encrypted-patches/index.html b/thoughts/encrypted-patches/index.html index 25d7f80..df5eab6 100644 --- a/thoughts/encrypted-patches/index.html +++ b/thoughts/encrypted-patches/index.html @@ -3,13 +3,13 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; font-src 'self'; media-src 'self'; base-uri 'none'; form-action 'none'"> +<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; font-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'"> <title>Encrypted patches, unlocked on patch day · Tim Conrad - + - + @@ -20,6 +20,7 @@ + @@ -48,7 +49,7 @@

Encrypted patches, unlocked on patch day

Status
Draft
Started
-
Last revised
+
Last revised
@@ -57,70 +58,90 @@

Encrypted patches, unlocked on patch day

On this page

  1. The problem
  2. -
  3. What games do
  4. +
  5. The idea
  6. A design
  7. Without a key server
  8. Air-gapped sites
  9. Open source in private
  10. What it does not fix
  11. -
  12. Prior art and patents
  13. +
  14. Prior art
  15. +
  16. What I take from it
  17. Sources
  18. Revisions
  19. Feedback
-
+
+

SpokenEncrypted patches, unlocked on patch day. A thought by Tim Conrad, read aloud. It takes about twenty minutes, in ten short chapters. Three words carry the idea. Patch day, the moment a fix becomes public, written as T. The encrypted bundle, which arrives early. And the key, which arrives at T. When a figure comes up, the narration describes it, so you can listen without the screen.

The problem

-

A security fix tells an attacker where the bug was. That was always true. What changed in the last year is the price of turning a fix into an exploit.

-

Keycloak is a good example. Between November 2025 and November 2026 the project tagged 47 releases, about one every eight days, across four supported lines, and fixed at least 50 CVEs [1]. In August 2026 one of them, CVE-2026-18963, let an unauthenticated attacker take over accounts through the password-reset flow [2]. The fixed builds carry tag dates a week before the CVE was published [1], and the public fix is a small change of 57 added lines [3]. A public proof of concept credits Claude for "research and PoC development assistance" and ships "the annotated patch diff" [4]. A team that runs Keycloak has to patch every week, and every patch is a map for someone else.

-

The numbers behind that feeling are from 2026:

+

A security fix tells an attacker where the bug was. That was always true: compare the old and the new version, a step called patch diffing, and the change points at the bug. What changed in the last year is the price of turning a fix into an exploit.

+

Keycloak is a good example. Between November 2025 and November 2026 the project tagged 47 releases, about one every eight days, across four supported lines, and fixed at least 50 CVEs [1]. In August 2026 one of them, CVE-2026-18963, let an unauthenticated attacker take over accounts through the password-reset flow [2]. The fixed builds carry tag dates a week before the CVE was published [1], and the public fix is a small change of 57 added lines [3]. A public proof of concept credits Claude for "research and PoC development assistance" and ships "the annotated patch diff" [4]. A team that runs Keycloak has to patch every week, and every patch is a map for someone else.

+

A bug that is already fixed and public is called an n-day. One that nobody has fixed yet is a zero-day. The numbers for n-days are from 2026:

    -
  • Anthropic gave its Mythos Preview model only public patches. The first working Firefox exploit came less than an hour after the patch; Windows kernel exploits cost about $2,000 each. Its summary: "The binding constraint to N-days is now just a few thousand dollars and API access" [5].
  • -
  • Elastic found the bug behind a Windows patch "within minutes" of reading the diff with an LLM; the exploit took hours instead of weeks [6].
  • -
  • Google counted 141 exploited vulnerabilities from January to August 2026, more than in all of 2025, and puts the growth in "the rapid weaponization of n-days" [7]. Mandiant's estimate for the mean time-to-exploit in 2025 is minus seven days [8], and Rapid7 measured a median of five days from publication to the CISA list of exploited bugs [9].
  • +
  • Anthropic gave its Mythos Preview model only public patches. The first working Firefox exploit came less than an hour after the patch. Windows kernel exploits cost about $2,000 each. Its summary: "The binding constraint to N-days is now just a few thousand dollars and API access" [5].
  • +
  • Elastic found the bug behind a Windows patch "within minutes" of reading the diff with an LLM. The exploit took hours instead of weeks [6].
  • +
  • Google counted 141 exploited vulnerabilities from January to August 2026, more than in all of 2025, and puts the growth in "the rapid weaponization of n-days" [7].
  • +
  • Attacks start early. Mandiant estimates that in 2025 exploitation began on average seven days before the fix was public [8]. Rapid7 measured a median of five days from publication to the CISA list of exploited bugs [9].
+

SpokenThree numbers to keep from that list: an exploit from a patch in under an hour, for about two thousand dollars, and attacks that begin within days of the fix.

Finding a new bug with AI works, but it is a search, and search costs. XBOW spent about $40,000 on API calls for a six-day campaign that produced a full Chrome exploit chain [10]. Anthropic's OpenBSD campaign took about 1,000 runs for under $20,000, and the FFmpeg one about $10,000 [11]. A patch removes the search: it says where to look.

- + What it costs: finding new bugs against exploiting patched ones Log scale. Finding new bugs, per campaign: XBOW Chrome full chain about 40,000 dollars; Anthropic OpenBSD about 1,000 runs under 20,000 dollars; Anthropic FFmpeg about 10,000 dollars. Exploiting a patched bug, per exploit: Windows kernel exploit about 2,000 dollars; Linux kernel n-day under 1,000 dollars; Windows crash proof of concept about 120 dollars. $100$1k$10k$100k - Find new bugs · per campaign - Exploit a patched bug · per exploit + + Find new bugs · per campaign XBOW · Chrome full chain, 6 days~$40k Anthropic · OpenBSD, ~1,000 runs<$20k - Anthropic · FFmpeg, some 100 runs~$10k - Windows kernel exploit, from patch~$2k - Linux kernel n-day exploit<$1k - Windows crash PoC, from patch~$120 - + Anthropic · FFmpeg, some 100 runs~$10k + Exploit a patched bug · per exploit + Windows kernel exploit, from patch~$2k + Linux kernel n-day exploit<$1k + Windows crash PoC, from patch~$120 + + What it costs: finding new bugs against exploiting patched ones + $100$1k$10k$100k + Find new bugs · per campaign + XBOW · Chrome chain~$40k + Anthropic · OpenBSD<$20k + Anthropic · FFmpeg~$10k + Exploit a patched bug · per exploit + Windows kernel, from patch~$2k + Linux kernel n-day<$1k + Windows crash PoC~$120 + +

SpokenFigure one compares costs on a log scale: each grid line to the right means ten times more money. The top group is finding new bugs, one whole campaign per bar. About forty thousand dollars for a Chrome exploit chain, under twenty thousand for OpenBSD, about ten thousand for FFmpeg.

SpokenThe bottom group is exploiting a bug that a patch already gave away, one exploit per bar. About two thousand dollars for Windows, under one thousand for Linux, and about a hundred and twenty for a crash proof of concept. The costs in the bottom group are ten to a hundred times lower. That difference is what a patch gives away.

Fig. 1 · Cost per result on a log scale, from [5], [10] and [11]. Discovery is a whole campaign, the n-day rows are one exploit each. Some discovery is cheaper: depthfirst reports 21 FFmpeg bugs for about $1,000 [12].
-

The idea of this post: ship the fix days early, but encrypted. Teams download it, plan the change and stage it. At patch day T, only the key is published, and everyone applies the fix at the same moment. Nobody could diff it before T. The goal is not to win the race against the attackers, but to stop it from being a race.

+

SpokenPause here for a moment. How many days does your team need from a published fix to production?

+ +

The idea

+

The idea of this post: ship the fix days early, but encrypted. Teams download it, plan the change and stage it. At patch day T, only the key is published, and everyone applies the fix at the same moment. Nobody could diff it before T. The goal is not to win the race against the attackers, but to stop it from being a race.

- + Timeline: today and with an encrypted pre-load Today the attacker can diff the patch as soon as the first host downloads it at T, while installs spread over weeks. With a pre-load, the encrypted bundle arrives in the week before T, the key arrives at T, and automatic updates install within minutes, while the attacker also only starts at T. T · patch day - Today + Today installs spread over weeks - + diff → exploit from the first download - nothing on the hosts yet + nothing on the hosts yet - Pre-load + Pre-load encrypted bundle on every host - + installed within minutes - + diff → exploit - same start line + same start line 32-byte key published @@ -128,13 +149,35 @@

The problem

T +7 days + + Timeline: today and with an encrypted pre-load + + T · patch day + Today + nothing on hosts + installs: weeks + diff → exploit + from first download + + Pre-load + bundle on hosts + in minutes + diff → exploit + same start line + + 32-byte key published + + −7 days + T + +7 days + +

SpokenFigure two shows two timelines around patch day T, the vertical line in the middle. The top row is today. Nothing is on the hosts before T. At T the patch goes out, installs spread over weeks, and an attacker can diff it from the first download.

SpokenThe bottom row is the pre-load. The encrypted bundle sits on every host during the week before T. At T only the thirty-two byte key is published. Hosts that update by themselves install within minutes, and the attacker starts at the same moment as everyone else.

Fig. 2 · With a pre-load, the attacker and the defenders start at the same moment, and hosts that update by themselves are done within minutes.
- -

What games do

-

Games have done this for years. A pre-load puts the game on your disk before launch, and it opens only at release. Steam, as SteamDB describes it, encrypts each depot with AES-256 and one key that is the same for every user, so mirrors and caches can serve the same bytes, and only owners can ask for the key [13].

-

GTA Online shows the other side. Rockstar ships the content of later updates early inside earlier ones and switches it on from its servers. The files are encrypted, but the keys are in the game, so dataminers read upcoming cars and missions long before they go live. A server switch decides when something is available. It does not keep it secret while the key sits on the device.

-

For patches, four rules follow:

+

SpokenHas anyone shipped something locked before? Games do it all the time.

+

Games already ship this way, and at a scale that no patch reaches. A pre-load puts a new game on your disk days before launch, and it opens only at release. Steam, as SteamDB describes it, encrypts each depot with AES-256 and one key that is the same for every user, so mirrors and caches can serve the same bytes, and only owners can ask for the key [13]. That is the half I want: millions of copies in place before the moment, and one small key at the moment.

+

GTA Online shows what goes wrong. Rockstar ships the content of later updates early inside earlier ones and switches it on from its servers. The files are encrypted, but the keys are in the game, so dataminers read upcoming cars and missions long before they go live. For a patch, that would undo everything: with the key on the device, anyone can diff the fix before T.

+

So the lesson from games is four rules for patches:

  • The key is not on the device before T.
  • The device clock never opens anything. Only the published key does.
  • @@ -142,16 +185,26 @@

    What games do

  • Decryption at T is cheap. One key, one pass.
+

SpokenThose four rules are easy to write down. The next question is how to build them on update systems that already exist.

A design

-

The update frameworks TUF and Uptane already solve most of this: an offline root key, roles with narrow rights, thresholds of signatures, and protection against rollback and freeze attacks [14][15]. Uptane even names patch diffing as an attacker goal and can carry wrapped keys for encrypted images. What they lack is a key that is published later. The design adds one role for that.

+

A signature on the patch alone is not enough. If the one signing key leaks, the thief can sign malware. A mirror can also cheat without any key. It can serve an older version with a known bug, a rollback attack. Or it can keep serving yesterday's list of updates, so the client never learns that a fix exists, a freeze attack.

+

The Update Framework, TUF, was designed against exactly these attacks [14]. It splits trust into roles, and each role has its own keys:

+
    +
  • Root names the keys of all other roles. Its keys stay offline and are needed only to change those keys.
  • +
  • Targets signs the list of files, with the hash and size of each.
  • +
  • Snapshot signs which version of each list is current, so a mirror cannot mix old and new lists.
  • +
  • Timestamp signs a short note that says what is newest. The note expires quickly, so a freeze shows.
  • +
+

A role can also require a threshold: k of its n keys must sign, so one stolen key is not enough. Uptane is TUF adapted for cars [15]. It adds a second repository, the director, which tells each vehicle which images to install, and the director can encrypt images for each control unit. The Uptane standard even names patch diffing as an attacker goal.

+

What neither has is a key that is published later. The design adds one role for that, the release role, next to targets:

  1. Pre-load. Days before T, the vendor publishes the encrypted bundle as an ordinary target. The manifest with its hash, size and release time is signed by the targets role under the organisation's root key. Clients download it and check the hash.
  2. -
  3. Release. At T a separate release role signs a short statement: patch id, hash of the ciphertext, the 32-byte content key. A threshold, say two of three officers, keeps one person from releasing early.
  4. -
  5. Log. Every statement goes into an append-only transparency log, so a release before T, or a different key for some customers, can be seen [16].
  6. +
  7. Release. At T the release role signs a short statement: patch id, hash of the ciphertext, the 32-byte content key. A threshold keeps one person from releasing early: say three officers each hold a key, and any two of them must sign.
  8. +
  9. Log. Every statement goes into a transparency log. That is a public list that only grows: entries can be added but not changed or removed, and anyone can check that an entry is in it. Certificate Transparency uses such logs for TLS certificates [16]. Anyone who watches the log sees a release before T, or a different key for some customers.
  10. Install. The client checks the statement and the log entry, decrypts with the key and installs.
- + Who signs what An offline organisation root key delegates to a targets role and a release role. Days before T the targets role signs a manifest with the hash of the encrypted bundle, and mirrors carry both. At T a release role, two of three officers, signs a key-release statement that is written to a transparency log. The client verifies the manifest, the bundle hash, the statement signature and the log entry, then decrypts and installs. Organisation @@ -197,6 +250,29 @@

A design

5 decrypt, install AES-GCM, key from 4 + + Who signs what + Organisation + org root keyoffline · delegates roles + + targets rolesigns the manifest + release role2 of 3 officers + −7 days + manifest + bundlehash, size, id, T + + at T + key releaseid, hash, key · 104 B + transparency logappend only, public + + Client + + 1 check manifestagainst org key + 2 check bundlehash from manifest + 3 wait for Tthe key opens, not the clock + 4 check statementsignature, log proof + 5 decrypt, installAES-GCM, key from 4 + +

SpokenFigure three shows who signs what, in three parts: the organisation, the mirrors and the log, and the client. It builds up in five steps, one for each line below the drawing.

  1. The organisation’s root key stays offline. It gives two roles their rights: one signs the manifest, the other releases keys.
  2. @@ -219,10 +295,12 @@

    A design

    Without a key server

    The key server is the weak spot. It can go down at the wrong moment, someone can force it to wait, or someone can leak the key early. There is a way to release a key on time without any server of the vendor.

    -

    A public service called drand publishes a new signed number every three seconds, and each number has a sequence position. A group of independent organisations runs it together, the League of Entropy. No single member can make the number for a position alone: it takes enough of them together, and they make it only when that moment has come [17].

    -

    The trick: you can lock data to a number that does not exist yet. The vendor picks the position that falls on patch day T and locks the patch key to it. Until T nobody, the vendor included, can open the lock. At T drand publishes the number, and that number opens it. The free tool tlock does exactly this [17].

    +

    drand is a public randomness beacon [17]. Every three seconds it publishes a new round: a round number and a value. The value is a signature over the round number, made with a key that nobody holds as a whole. When the network was set up, its members, a group of independent organisations called the League of Entropy, split that key into shares, and each member keeps one share.

    +

    For each round, every member signs the round number with its share when that round's time has come. Enough of these partial signatures together give the full signature, and anyone can check it against one public key that never changes. Cryptographers call this a threshold signature. So nobody can compute the value of a future round, not even a member alone. It takes enough members, and honest members sign a round only at its time.

    +

    The second part is a kind of encryption where the key can be a name. In identity-based encryption, anyone can encrypt to a name, an email address for example, with one public master key. The private key for that name is made later, by whoever holds the master secret. In drand, the name is a round number, the master key is drand's public key, and the signature for a round is the private key for it [17]. The master secret is the split key of the League, so the private key for a round appears only when the members sign that round.

    +

    So the vendor picks the round that falls on patch day T and encrypts the patch key to that round. Until T nobody, the vendor included, can decrypt it. At T drand publishes the signature for that round, and the signature opens it. The free tool tlock, from the drand team, does exactly this [17].

    - + How a drand number opens a patch key at patch day Independent organisations make a new numbered value every three seconds; the value for a position exists only when enough of them have taken part, at that moment. Days before patch day the vendor locks the patch key to the value at position T, which does not exist yet. At T the organisations make that value, it is published, and anyone can use it to open the lock and install the patch. League of Entropy @@ -253,6 +331,20 @@

    Without a key server

    patch key → install + + How a drand number opens a patch key at patch day + League of Entropyorg Aorg Borg Corg Dorg E + T + drand valuesone every 3 seconds + publishednot made yet + Vendor · before T + key, locked + to the value at T + Everyone · at T + T opens it + key → install + +

    SpokenFigure four has three layers. At the top, the organisations of the League of Entropy. In the middle, the row of drand values, one every three seconds, with the value for T marked. At the bottom, the vendor on the left and everyone else on the right. It builds up in four steps.

    1. drand makes a new value every three seconds. Values for later moments do not exist yet.
    2. @@ -273,7 +365,7 @@

      Without a key server

      Air-gapped sites

      Offline networks already carry updates in on removable media. With a pre-load, the large part crosses days before T and can be checked against the organisation's key. At T only the key has to cross: the signed statement, or the 48-byte drand number for T, which the site checks against drand's public key, stored there in advance [17]. Either fits in one QR code, which holds up to 2,953 bytes [18], or can be typed in as hex.

      - + Updates for an air-gapped site Days before T, the encrypted bundle and its signed manifest cross the air gap on removable media, gigabytes in size. At T only a key-release statement of about 104 bytes, or a 48-byte drand number, crosses, small enough for one QR code. The offline site checks both against pinned keys, then decrypts and installs. @@ -308,15 +400,30 @@

      Air-gapped sites

      48 B drand no. + + Updates for an air-gapped site + Connected + vendor mirrorbundle, manifest, key release + + days before TUSB or discbundle · gigabytes + at T + + QR code104 B statementor 48 B drand no. + air gap + + Offline site + stored early: org key, drand public keyat T: verify offline, decrypt, install + +

      SpokenFigure five has three zones: the connected network, the air gap, and the offline site. Days before T, the encrypted bundle crosses on a USB stick or a disc, gigabytes in size. At T only a QR code crosses: a signed statement of about a hundred bytes, or the forty-eight byte drand value. The site checks it against keys that it stored earlier, then decrypts and installs.

      Fig. 5 · The gigabytes cross early. At T only a few bytes cross, and they are public, so the channel needs to reach the site, not to be secret.

      A checked drand number also proves that its moment has passed. A site without a reliable clock learns that it is at least that late.

      Open source in private

      For an open-source project the hard part comes earlier: the fix has to be written and tested without showing it. Today it often shows. In the Keycloak case the fixed builds were tagged a week before the CVE, the port to the main branch was a public pull request whose title does not mention security, and the proof of concept was written from that diff [3][4].

      -

      The tools for private work are thin. On GitHub, maintainers can open a temporary private fork from a security advisory. Only invited collaborators see it, but no CI runs there, branch protection does not apply, and merging and publishing are manual steps [19]. On GitLab a merge request in a public project is public; private work needs a separate private fork [20]. Distributions coordinate by mail: the linux-distros list allows an embargo of at most 14 days [21], and the kernel security team holds a fix back for at most 7 days, 14 in exceptional cases [22]. The Git project comes closest to the idea of this post: it prepares signed tags in a private repository, sends a bundle to the distributions less than a week ahead, and pushes in public on release day [23]. That bundle is plaintext and goes to a few.

      +

      The tools for private work are thin. On GitHub, maintainers can open a temporary private fork from a security advisory. Only invited collaborators see it, but no CI runs there, branch protection does not apply, and merging and publishing are manual steps [19]. On GitLab a merge request in a public project is public; private work needs a separate private fork [20]. Distributions coordinate by mail, under an embargo: a fixed time in which everyone who knows about the bug keeps quiet. The linux-distros list allows an embargo of at most 14 days [21], and the kernel security team holds a fix back for at most 7 days, 14 in exceptional cases [22]. The Git project comes closest to the idea of this post: it prepares signed tags in a private repository, sends a bundle to the distributions less than a week ahead, and pushes in public on release day [23]. That bundle is plaintext and goes to a few.

      - + A security fix in an open-source project, from private work to release The maintainers write and test the fix on a private branch with private CI. Days before T the project publishes the encrypted, signed build. The embargo list of distributions gets the key early to test. Everyone else stages the ciphertext. At T the key is published, everyone applies the fix, and the public repository gets the merge and the advisory. @@ -342,7 +449,24 @@

      Open source in private

      T +6 days -
      Fig. 6 · Work in private, publish the ciphertext early, open it for everyone at T. The embargo list keeps what it has today: early access to test.
      + + A security fix in an open-source project, from private work to release + T · release + Maintainersprivate work, CI + + Releaseencrypted, signed + key published + Embargo listearly key + Everyonestaged + apply at T + Public repomerge + advisory + + −10 days + T + +6 days + +

      SpokenFigure six is a timeline with five rows, from ten days before T to six days after. The maintainers work on a private branch with private CI. Then the encrypted, signed build is published. The embargo list gets the key early to test, and everyone else stages the ciphertext. At T the key is published, everyone applies the fix, and the public repository gets the merge and the advisory.

      +
      Fig. 6 ·Work in private, publish the ciphertext early, open it for everyone at T. The embargo list keeps what it has today: early access to test.

      To make this normal, a forge such as GitHub or GitLab would need (Fig. 7):

        @@ -352,7 +476,7 @@

        Open source in private

      • One action that, at T, publishes the key, merges the branch and publishes the advisory.
      - + What a forge would need for a private fix in a public repository Inside the public repository a private branch holds the fix; only maintainers and a security role see it. Private CI with closed logs tests it. Days before T the forge publishes the encrypted, signed release, and the embargo list gets the key early. At T one action publishes the key and the advisory and merges the branch, and everyone applies the fix. @@ -368,6 +492,23 @@

      Open source in private

      encrypted release, signedembargo listearly key: test mergekey + advisoryeveryoneapplies at T + + What a forge would need for a private fix in a public repository + Public repository + T + main · public + + private branchmaintainers + security role + + CI, closed logs + releases + encrypted release + embargo listearly key: test + merge + key + advisory + everyoneapplies at T + +

      SpokenFigure seven shows what this looks like inside one public repository, in four steps.

      1. The fix is written on a private branch inside the public repository. Only the maintainers and a security role can see it.
      2. @@ -380,6 +521,7 @@

        Open source in private

      The embargo lists fit into this as they are: their members get the key early and test, everyone else gets the ciphertext early. Each early key is a chance to leak, so the list stays short and the embargo stays short.

      +

      SpokenThat is the design. Here is what it does not solve.

      What it does not fix

      • Diffing after T. At T the patch is public. The scheme moves the start line; it does not stop the race. It helps the hosts that install at once.
      • @@ -389,16 +531,14 @@

        What it does not fix

      • Insiders. The key exists before T. Create it late, keep it in an HSM, split it between people.
      • Testing. Companies test patches before they roll them out. Most can stage before T but test only after it. Early keys for a few, as embargo lists do today, bring back the risk of leaks.
      +

      SpokenWhich of these limits matters most for your systems?

      -

      Prior art and patents

      -

      The idea is not new, and the patents below show it. I found no vendor that does it for security patches today.

      -

      Microsoft has patents on it. They are old, but they matter now. This is my reading, not legal advice, and I could not open the claims or the fee records:

      -
        -
      • US 7,876,902, "Distribution of encrypted software update to reduce attack window", priority date 31 August 2006, granted 2011 [24]. A US patent runs 20 years from filing, so it ends around 31 August 2026, later only if the patent office added term for its own delays, and earlier if a maintenance fee was not paid. Its description releases the key once enough machines have the update or after testing, not at a fixed time.
      • -
      • US 8,171,560, "Secure content pre-distribution to designated systems", granted 2012 [25]. It is closer to this post: content goes out encrypted and the key follows at a set moment. Filed around 2008, it may run to about 2028.
      • -
      -

      So the design can be built from public parts, and a fixed release time, a transparency log and a public beacon in place of a vendor key server differ from the first patent's description. Whether that is enough depends on the claims. Anyone who wants to ship this as a product in the US before about 2028 should have a patent attorney read both patents first.

      -

      My conclusion: the tool is cheap for an update system that already signs its metadata. It does not stop patch diffing, and with LLMs diffing is now cheap. It lets every team have the fix ready when it becomes public, and that turns patch day from a race into a date.

      +

      Prior art

      +

      The idea is not new. Microsoft patented an encrypted update with a key that follows later, first in 2006 [24] and in a version with a set release moment around 2008 [25]. I found no vendor that does it for security patches today. By my reading, which is not legal advice, the first patent ends around August 2026 and the second may run to about 2028. Anyone who wants to ship this as a product in the US before then should have a patent attorney read both.

      + +

      What I take from it

      +

      The tool is cheap for an update system that already signs its metadata. It does not stop patch diffing, and with LLMs diffing is now cheap. It lets every team have the fix ready when it becomes public, and that turns patch day from a race into a date.

      +

      SpokenTo sum up. Patches are now cheap to turn into exploits. Ship the fix early but encrypted, publish only the key at T, and every team gets the same start line. The parts exist: TUF or Uptane for the signatures, a transparency log for the release, and drand with tlock for a release without a key server. The sources are on the page.

      Sources

        @@ -418,7 +558,7 @@

        Sources

      1. The Update Framework specification.
      2. Uptane Standard.
      3. RFC 9162: Certificate Transparency 2.0; Sigstore Rekor.
      4. -
      5. drand: tlock.
      6. +
      7. drand: tlock; quicknet is live, Oct 2023 (a round every 3 seconds); Gailly, Melissaris and Romailler: tlock: Practical Timelock Encryption from Threshold BLS, IACR ePrint 2023/189.
      8. ISO/IEC 18004 capacity table, summarised by Keyence.
      9. GitHub Docs: Collaborating in a temporary private fork.
      10. GitLab Docs: Confidential merge requests.
      11. @@ -431,6 +571,7 @@

        Sources

        Revisions

          +
        • Rewrote it to be read aloud and for readers who do not know every term: explained patch diffing, n-days, TUF, Uptane, transparency logs, drand and timelock encryption; folded the games section into "The idea" and cut the patents to one paragraph; added a narration with spoken parts.
        • First version, as a blog post.
        diff --git a/thoughts/encrypted-patches/narration.json b/thoughts/encrypted-patches/narration.json new file mode 100644 index 0000000..ed1a734 --- /dev/null +++ b/thoughts/encrypted-patches/narration.json @@ -0,0 +1,30 @@ +{ + "title": "Encrypted patches, unlocked on patch day", + "voice": "Jonathan Livingston", + "audio": null, + "timing": null, + "chapters": [ + ["say-intro", "Introduction"], + ["problem", "The problem"], + ["idea", "The idea"], + ["say-design", "A design"], + ["beacon", "Without a key server"], + ["airgap", "Air-gapped sites"], + ["oss", "Open source in private"], + ["say-limits", "What it does not fix"], + ["prior", "Prior art"], + ["take", "What I take from it"] + ], + "spoken": {}, + "cues": [ + {"in": "say-fig1", "p": "log scale", "until": "The top group", "sel": ".cue-scale", "cls": "hot"}, + {"in": "say-fig1", "p": "The top group", "until": "The bottom group", "sel": ".cue-find", "cls": "hot"}, + {"in": "say-fig1", "p": "The bottom group", "until": "That difference", "sel": ".cue-exploit", "cls": "hot"}, + {"in": "say-fig1", "p": "The costs in the bottom group", "sel": ".cue-exploit [data-c]"}, + {"in": "say-fig2", "p": "The top row is today", "until": "The bottom row", "sel": ".cue-today", "cls": "hot"}, + {"in": "say-fig2", "p": "an attacker can diff", "sel": ".cue-today [data-c]"}, + {"in": "say-fig2", "p": "The bottom row", "until": "Has anyone shipped", "sel": ".cue-preload", "cls": "hot"}, + {"in": "say-fig2", "p": "install within minutes", "sel": ".cue-preload [data-c=green]"}, + {"in": "say-fig2", "p": "the attacker starts", "sel": ".cue-preload [data-c=red]"} + ] +} From bf8be56eb484a2a70880ad1ea53f78ed8ecf5fb1 Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:04:58 +0100 Subject: [PATCH 3/6] skills: narrating-a-thought names the switch figures as an open gap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- .claude/skills/narrating-a-thought/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/narrating-a-thought/SKILL.md b/.claude/skills/narrating-a-thought/SKILL.md index 3f0f88e..99ff0c2 100644 --- a/.claude/skills/narrating-a-thought/SKILL.md +++ b/.claude/skills/narrating-a-thought/SKILL.md @@ -30,6 +30,7 @@ The design was tested with Tim on 12 Nov 2026, first in a prototype, then on `th | `narration.json` | `title`, `voice`, `audio` (mp3 path or null), `timing` (`{start: [...], end: [...]}` per character of the script, or null for simulated), `chapters` (`[id, title]`, id of an `h2` or a `.say`), `spoken` (id to spoken form, where TTS must hear other words than the page shows), `cues`. | | Figure cues | `{"in": "say-fig1", "p": "phrase in the script", "until": "phrase", "sel": ".cue-find", "cls": "hot"}`. `sel` is looked up inside the figure of `in`. `hot` makes that part stand out while the rest of the figure steps back. Without `cls`, the part gets `on`, and `[data-c].on` fills with its colour. Without `until`, the cue stays on. | | Step figures | The list items of `figure[data-steps]` are part of the script. The voice sets the step (`narration:step` event in `site.js`). | +| Switch and pick figures | Not driven by the voice yet (`data-switch`, `data-pick`). Use `hot` cues on their parts, and describe both states of a switch in order. To let the voice flip a switch, add an event to `site.js` like `narration:step`, and ask Tim first. | ## Decided design From aee84dde81e20d96baa7dc0e6038c0988cdb57db Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:03:13 +0100 Subject: [PATCH 4/6] encrypted-patches: the voice animates the step figures Fig. 3, 4 and 7 lose the step box with Back, Next and Play: under a narrated figure it competed with the voice. The steps are a plain numbered list; narration.js brings in the parts of the step that the voice reads and shows only that step. Read without the voice, the whole drawing and the whole list show. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- .claude/skills/narrating-a-thought/SKILL.md | 3 +- DESIGN.md | 2 +- assets/narration.js | 20 ++++++---- assets/site.css | 7 +++- assets/site.js | 1 - thoughts/encrypted-patches/index.html | 44 +++++++++------------ 6 files changed, 39 insertions(+), 38 deletions(-) diff --git a/.claude/skills/narrating-a-thought/SKILL.md b/.claude/skills/narrating-a-thought/SKILL.md index 99ff0c2..eb6ac66 100644 --- a/.claude/skills/narrating-a-thought/SKILL.md +++ b/.claude/skills/narrating-a-thought/SKILL.md @@ -29,7 +29,7 @@ The design was tested with Tim on 12 Nov 2026, first in a prototype, then on `th | Colour blocks | `term` in the text. It turns on when the voice reaches its first word. Nothing else is needed. | | `narration.json` | `title`, `voice`, `audio` (mp3 path or null), `timing` (`{start: [...], end: [...]}` per character of the script, or null for simulated), `chapters` (`[id, title]`, id of an `h2` or a `.say`), `spoken` (id to spoken form, where TTS must hear other words than the page shows), `cues`. | | Figure cues | `{"in": "say-fig1", "p": "phrase in the script", "until": "phrase", "sel": ".cue-find", "cls": "hot"}`. `sel` is looked up inside the figure of `in`. `hot` makes that part stand out while the rest of the figure steps back. Without `cls`, the part gets `on`, and `[data-c].on` fills with its colour. Without `until`, the cue stays on. | -| Step figures | The list items of `figure[data-steps]` are part of the script. The voice sets the step (`narration:step` event in `site.js`). | +| Step figures | Use `figure[data-voice-steps]`, not `data-steps`: no step box and no Back, Next or Play buttons, because they compete with the voice. Parts of the drawing carry `data-step="n"`. Under the drawing is a plain `
          ` with `
        1. ` (the value keeps the number when the other items hide). `narration.js` brings in the parts of the step that the voice reads. When only reading, the whole drawing and the whole list show. | | Switch and pick figures | Not driven by the voice yet (`data-switch`, `data-pick`). Use `hot` cues on their parts, and describe both states of a switch in order. To let the voice flip a switch, add an event to `site.js` like `narration:step`, and ask Tim first. | ## Decided design @@ -74,4 +74,5 @@ Use the skill `adding-a-thought` for the text. Then: | A walk-through said "on the left" while the phone showed a stacked drawing. | Layout-neutral words. | | The sticky site header covered the top of the framed figure. | Subtract the header height when scrolling. | | A wide drawing scrolled sideways inside its box, so half of it was out of view. | A narrow drawing. | +| The manual step viewer (box, Back, Next, Play, "Step 2 of 5") stayed under a narrated figure and broke the immersion. | Voice steps: a plain list, the voice animates. | | A python `http.server` cannot seek in an mp3 (no range requests). | Load the audio as a blob. | diff --git a/DESIGN.md b/DESIGN.md index 48bb3af..e850ef8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -84,7 +84,7 @@ All fonts are in `assets/fonts/` (SIL Open Font License). Nothing loads from ano | 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. This works in every figure; a narrated thought needs it, because the whole drawing must be in view while the voice explains it. 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. | -| Narration | `article[data-narration]`, `.say`, `.mk`, `assets/narration.js` | A thought read aloud. `narration.json` next to the page holds the chapters, the figure cues and, once it exists, the audio with its timings; without audio the timing is simulated. Listen sits under the title; the player is a thin strip at the bottom. A grey tint marks the phrase that the voice reads, words darken as they are spoken, and the other blocks dim by text colour (never opacity). `.mk` terms get a colour block that stays. `.say` holds text that only the voice says: folded until the voice reaches it, then open, with the label "Spoken". Under a figure, only the paragraph that the voice reads shows, and the caption waits. The skill `narrating-a-thought` has the rules. | +| Narration | `article[data-narration]`, `.say`, `.mk`, `assets/narration.js` | A thought read aloud. `narration.json` next to the page holds the chapters, the figure cues and, once it exists, the audio with its timings; without audio the timing is simulated. Listen sits under the title; the player is a thin strip at the bottom. A grey tint marks the phrase that the voice reads, words darken as they are spoken, and the other blocks dim by text colour (never opacity). `.mk` terms get a colour block that stays. `.say` holds text that only the voice says: folded until the voice reaches it, then open, with the label "Spoken". Under a figure, only the paragraph that the voice reads shows, and the caption waits. A narrated figure with steps uses `data-voice-steps`: a plain numbered list and no step buttons, because the voice animates the drawing. The skill `narrating-a-thought` has the rules. | | 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 diff --git a/assets/narration.js b/assets/narration.js index d8199a9..9fd60cd 100644 --- a/assets/narration.js +++ b/assets/narration.js @@ -24,7 +24,7 @@ var stop = $('#sources', art), segs = []; var walker = document.createTreeWalker(art, NodeFilter.SHOW_ELEMENT, { acceptNode: function (n) { if (stop && (n === stop || (stop.compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING))) return NodeFilter.FILTER_REJECT; - if (n.matches('svg, figcaption, .code, .steps__bar, .refs')) return NodeFilter.FILTER_REJECT; + if (n.matches('svg, figcaption, .code, .refs')) return NodeFilter.FILTER_REJECT; return n.matches('h2, p, li') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }); while (walker.nextNode()) { @@ -130,7 +130,7 @@ } function playing() { return clock.on; } - var started = false, follow = true, lastC = -2, curK = -1, shownKey = null, boxY = [], autoAt = 0, pausedAt = 0, raf = 0, stepFig = null; + var started = false, follow = true, lastC = -2, curK = -1, shownKey = null, boxY = [], autoAt = 0, pausedAt = 0, raf = 0; function play() { if (!started) { started = true; document.body.classList.add('narr-on'); again.hidden = false; var r = store.get(); if (r && r.c > 0 && r.c < T.length - 30) seekChar(r.c); } else if (pausedAt && Date.now() - pausedAt > 30000) seekChar(sentenceStart(charAt(clock.now()))); // back in after a break: start of the sentence @@ -159,12 +159,16 @@ var first = segs.findIndex(function (x) { return x.say === box; }); box.classList.toggle('open', all || (started && k >= first)); }); - // a figure with steps follows the voice: its intro shows the whole picture, each list item one step - var fig = s.el.closest('figure[data-steps]'), step = 0; - if (fig && s.el.matches('.steps__list li')) step = $$('.steps__list li', fig).indexOf(s.el) + 1; - if (stepFig && stepFig !== fig) stepFig.dispatchEvent(new CustomEvent('narration:step', { detail: 0 })); - if (fig && started) fig.dispatchEvent(new CustomEvent('narration:step', { detail: step })); - stepFig = started ? fig : null; + // a figure with voice steps: its intro shows the whole drawing, each list item brings in the parts of its step + $$('figure[data-voice-steps]', art).forEach(function (f) { + var k = 0; + if (started && f.contains(s.el) && s.el.matches('.fig-steps li')) k = $$('.fig-steps li', f).indexOf(s.el) + 1; + $$('[data-step]', f).forEach(function (part) { + var n = +part.getAttribute('data-step'); + part.classList.toggle('is-later', k > 0 && n > k); + part.classList.toggle('is-past', k > 0 && n < k); + }); + }); // read words, colour blocks, figure cues: all derived from the position, so seeking back undoes them words.forEach(function (w) { w.el.classList.toggle('rd', started && w.k === k && w.s <= t + 0.03); }); marks.forEach(function (x) { x.el.classList.toggle('on', started && t >= x.s); }); diff --git a/assets/site.css b/assets/site.css index b47e7db..99e6c37 100644 --- a/assets/site.css +++ b/assets/site.css @@ -481,8 +481,11 @@ figure .dg-n { display: none; } .narr-on .narr-seg { cursor: pointer; } .narr-on .prose figure.fig-wide.cur:not(:has(.dg-n)) { overflow-x: visible; } /* no narrow drawing: fit the wide one while the voice explains it */ .narr-on .prose figure.fig-wide.cur:not(:has(.dg-n)) .dg { min-width: 0; } -.narr-on .prose figure.cur .steps__bar, .narr-on .prose figure.cur .steps__hint, .narr-on .prose figure.cur .steps__at { display: none; } -.narr-playing .prose figure.cur .say:not(:has(.cur)), .narr-playing .prose figure.cur .say .narr-seg:not(.cur) { display: none; } /* under a figure, only the paragraph that the voice reads now */ +.narr-on li.narr-seg.cur::before { left: -2.2rem; } /* in a list, the bar sits left of the number */ +/* figures with voice steps: a plain numbered list under the drawing; the voice brings in the parts of each step */ +.prose .fig-steps { margin: .9rem 0 0; padding-left: 1.6rem; font-size: .9688rem; line-height: 1.5; } +.narr-playing .prose figure.cur .say:not(:has(.cur)), .narr-playing .prose figure.cur .say .narr-seg:not(.cur), +.narr-playing .prose figure.cur .fig-steps .narr-seg:not(.cur) { display: none; } /* under a figure, only the paragraph that the voice reads now */ .narr-playing .prose figure.cur figcaption { display: none; } /* the voice explains the figure; the caption returns afterwards */ /* the phrase marker: a grey tint over the phrase that the voice reads */ .narr-on #main { position: relative; } diff --git a/assets/site.js b/assets/site.js index 5f8c148..fe502b0 100644 --- a/assets/site.js +++ b/assets/site.js @@ -155,7 +155,6 @@ show(1); play.textContent = 'Pause'; play.setAttribute('aria-label', 'Pause'); timer = setInterval(function () { if (at >= n) stop(); else show(at + 1); }, 2800); }); - fig.addEventListener('narration:step', function (e) { stop(); show(e.detail); }); // narration.js: the voice sets the step show(0); }); diff --git a/thoughts/encrypted-patches/index.html b/thoughts/encrypted-patches/index.html index df5eab6..3660897 100644 --- a/thoughts/encrypted-patches/index.html +++ b/thoughts/encrypted-patches/index.html @@ -203,7 +203,7 @@ <h2 id="design">A design</h2> <li><b>Log.</b> Every statement goes into a transparency log. That is a public list that only grows: entries can be added but not changed or removed, and anyone can check that an entry is in it. Certificate Transparency uses such logs for TLS certificates <a href="#s16">[16]</a>. Anyone who watches the log sees a release before T, or a different key for some customers.</li> <li><b>Install.</b> The client checks the statement and the log entry, decrypts with the key and installs.</li> </ol> - <figure class="fig-wide" data-steps> + <figure class="fig-wide" data-voice-steps> <svg class="dg dg-w" viewBox="0 0 760 420" role="img" aria-labelledby="d2t d2d"> <title id="d2t">Who signs what An offline organisation root key delegates to a targets role and a release role. Days before T the targets role signs a manifest with the hash of the encrypted bundle, and mirrors carry both. At T a release role, two of three officers, signs a key-release statement that is written to a transparency log. The client verifies the manifest, the bundle hash, the statement signature and the log entry, then decrypts and installs. @@ -273,15 +273,13 @@

          A design

          5 decrypt, installAES-GCM, key from 4

          SpokenFigure three shows who signs what, in three parts: the organisation, the mirrors and the log, and the client. It builds up in five steps, one for each line below the drawing.

          -
          -
            -
          1. The organisation’s root key stays offline. It gives two roles their rights: one signs the manifest, the other releases keys.
          2. -
          3. Days before T the targets role signs the manifest, and mirrors carry it together with the encrypted bundle.
          4. -
          5. Every client checks the manifest against the organisation key and the bundle against the manifest. Then it waits; its clock opens nothing.
          6. -
          7. At T two of three release officers sign the key-release statement, and it goes into the public log.
          8. -
          9. The client checks the statement and its log entry, decrypts the bundle with the key and installs.
          10. +
              +
            1. The organisation’s root key stays offline. It gives two roles their rights: one signs the manifest, the other releases keys.
            2. +
            3. Days before T the targets role signs the manifest, and mirrors carry it together with the encrypted bundle.
            4. +
            5. Every client checks the manifest against the organisation key and the bundle against the manifest. Then it waits; its clock opens nothing.
            6. +
            7. At T two of three release officers sign the key-release statement, and it goes into the public log.
            8. +
            9. The client checks the statement and its log entry, decrypts the bundle with the key and installs.
            -
          Fig. 3 · Two roles under one offline root. The release role can only publish keys for ciphertext that is already pinned, so a stolen release key cannot push code.

    The statement is small and public, so any mirror, CDN or even DNS can carry it. That makes the release hard to block.

    @@ -299,7 +297,7 @@

    Without a key server

    For each round, every member signs the round number with its share when that round's time has come. Enough of these partial signatures together give the full signature, and anyone can check it against one public key that never changes. Cryptographers call this a threshold signature. So nobody can compute the value of a future round, not even a member alone. It takes enough members, and honest members sign a round only at its time.

    The second part is a kind of encryption where the key can be a name. In identity-based encryption, anyone can encrypt to a name, an email address for example, with one public master key. The private key for that name is made later, by whoever holds the master secret. In drand, the name is a round number, the master key is drand's public key, and the signature for a round is the private key for it [17]. The master secret is the split key of the League, so the private key for a round appears only when the members sign that round.

    So the vendor picks the round that falls on patch day T and encrypts the patch key to that round. Until T nobody, the vendor included, can decrypt it. At T drand publishes the signature for that round, and the signature opens it. The free tool tlock, from the drand team, does exactly this [17].

    -
    +
    How a drand number opens a patch key at patch day Independent organisations make a new numbered value every three seconds; the value for a position exists only when enough of them have taken part, at that moment. Days before patch day the vendor locks the patch key to the value at position T, which does not exist yet. At T the organisations make that value, it is published, and anyone can use it to open the lock and install the patch. @@ -345,14 +343,12 @@

    Without a key server

    key → install

    SpokenFigure four has three layers. At the top, the organisations of the League of Entropy. In the middle, the row of drand values, one every three seconds, with the value for T marked. At the bottom, the vendor on the left and everyone else on the right. It builds up in four steps.

    -
    -
      -
    1. drand makes a new value every three seconds. Values for later moments do not exist yet.
    2. -
    3. Days before T the vendor locks the patch key to the value at T. Nobody can open it now, the vendor included.
    4. -
    5. At T enough of the organisations together make the value for T. Not every one is needed.
    6. -
    7. The value is public. It opens the lock, and everyone installs the patch.
    8. +
        +
      1. drand makes a new value every three seconds. Values for later moments do not exist yet.
      2. +
      3. Days before T the vendor locks the patch key to the value at T. Nobody can open it now, the vendor included.
      4. +
      5. At T enough of the organisations together make the value for T. Not every one is needed.
      6. +
      7. The value is public. It opens the lock, and everyone installs the patch.
      -
    Fig. 4 · The lock points at a value that does not exist yet. Only the organisations together can make it, and only at T.

    The vendor can use both ways at once: its own key server and the drand lock. Then the patch opens at T even if the vendor's server is down. Two limits remain:

    @@ -475,7 +471,7 @@

    Open source in private

  3. Release artefacts that are published encrypted and signed, with the key held by the forge.
  4. One action that, at T, publishes the key, merges the branch and publishes the advisory.
  5. -
    +
    What a forge would need for a private fix in a public repository Inside the public repository a private branch holds the fix; only maintainers and a security role see it. Private CI with closed logs tests it. Days before T the forge publishes the encrypted, signed release, and the embargo list gets the key early. At T one action publishes the key and the advisory and merges the branch, and everyone applies the fix. @@ -509,14 +505,12 @@

    Open source in private

    everyoneapplies at T

    SpokenFigure seven shows what this looks like inside one public repository, in four steps.

    -
    -
      -
    1. The fix is written on a private branch inside the public repository. Only the maintainers and a security role can see it.
    2. -
    3. Private CI builds and tests the branch. Its logs stay closed.
    4. -
    5. Days before T the forge publishes the encrypted, signed release. The embargo list gets the key early and tests.
    6. -
    7. At T one action publishes the key and the advisory and merges the branch. Everyone applies the fix.
    8. +
        +
      1. The fix is written on a private branch inside the public repository. Only the maintainers and a security role can see it.
      2. +
      3. Private CI builds and tests the branch. Its logs stay closed.
      4. +
      5. Days before T the forge publishes the encrypted, signed release. The embargo list gets the key early and tests.
      6. +
      7. At T one action publishes the key and the advisory and merges the branch. Everyone applies the fix.
      -
    Fig. 7 · What a forge would need for a private fix in a public repository.

    The embargo lists fit into this as they are: their members get the key early and test, everyone else gets the ciphertext early. Each early key is a chance to leak, so the list stays short and the embargo stays short.

    From bd4f1e460544d2c668fbb280a8a26c224a557e81 Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:36:48 +0100 Subject: [PATCH 5/6] encrypted-patches: no empty space under the figures A spoken part with two paragraphs now folds as one row, and a folded part inside a figure no longer adds a gap to the figure grid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- .claude/skills/narrating-a-thought/SKILL.md | 3 ++- assets/site.css | 5 ++++- thoughts/encrypted-patches/index.html | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.claude/skills/narrating-a-thought/SKILL.md b/.claude/skills/narrating-a-thought/SKILL.md index eb6ac66..34f0cfa 100644 --- a/.claude/skills/narrating-a-thought/SKILL.md +++ b/.claude/skills/narrating-a-thought/SKILL.md @@ -25,7 +25,7 @@ The design was tested with Tim on 12 Nov 2026, first in a prototype, then on `th | Styles and the colour tokens | "Narration" in `assets/site.css`. DESIGN.md has the rules. | | Opt-in | `
    ` | | The script | The page itself: every `h2`, `p` and `li` of the article up to `#sources`, in order, without source marks, plus the `.say` blocks. Figures, captions and code blocks are not read. | -| Spoken parts | `

    Spoken...

    ` in the page. `data-pause="2.5"` adds silence after it. | +| Spoken parts | `

    Spoken...

    ` in the page. `data-pause="2.5"` adds silence after it. A spoken part with several paragraphs wraps them in one `
    `: the fold closes only one grid row, and a second paragraph would leave an empty space. | | Colour blocks | `term` in the text. It turns on when the voice reaches its first word. Nothing else is needed. | | `narration.json` | `title`, `voice`, `audio` (mp3 path or null), `timing` (`{start: [...], end: [...]}` per character of the script, or null for simulated), `chapters` (`[id, title]`, id of an `h2` or a `.say`), `spoken` (id to spoken form, where TTS must hear other words than the page shows), `cues`. | | Figure cues | `{"in": "say-fig1", "p": "phrase in the script", "until": "phrase", "sel": ".cue-find", "cls": "hot"}`. `sel` is looked up inside the figure of `in`. `hot` makes that part stand out while the rest of the figure steps back. Without `cls`, the part gets `on`, and `[data-c].on` fills with its colour. Without `until`, the cue stays on. | @@ -75,4 +75,5 @@ Use the skill `adding-a-thought` for the text. Then: | The sticky site header covered the top of the framed figure. | Subtract the header height when scrolling. | | A wide drawing scrolled sideways inside its box, so half of it was out of view. | A narrow drawing. | | The manual step viewer (box, Back, Next, Play, "Step 2 of 5") stayed under a narrated figure and broke the immersion. | Voice steps: a plain list, the voice animates. | +| Empty space under a figure: a second paragraph in a spoken part kept its height, and a folded part in the figure grid still added one gap. | One wrapper per spoken part. Folded parts in a figure use `display: none`. Measure the space from the drawing to the next element before and after Listen. | | A python `http.server` cannot seek in an mp3 (no range requests). | Load the audio as a blob. | diff --git a/assets/site.css b/assets/site.css index 99e6c37..5b4115d 100644 --- a/assets/site.css +++ b/assets/site.css @@ -463,10 +463,13 @@ figure .dg-n { display: none; } .say > * { overflow: hidden; min-height: 0; } .say.open { grid-template-rows: 1fr; opacity: 1; margin-block: 0; } .say p { color: var(--muted); font-size: .9688rem; } +.say p + p { margin-top: .6rem; } /* a spoken part with several paragraphs keeps them in one wrapper, so it folds as one row */ .say .w.rd { color: var(--ink); } .say-k { font: 500 var(--step--1) var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-right: .5rem; white-space: nowrap; } .say-k::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; border-radius: 50%; margin-right: .4rem; vertical-align: 1px; } -.prose figure .say { margin-top: .9rem; } +.prose figure .say { margin-block: 0; } +.prose figure .say:not(.open) { display: none; } /* in the figure grid even an empty row adds a gap */ +.prose figure .say.open { margin-top: .3rem; } /* the block that the voice reads: a bar on the left; the other blocks dim by text colour, never by opacity, so a colour block keeps its contrast */ .narr-seg { position: relative; transition: color .45s ease; } diff --git a/thoughts/encrypted-patches/index.html b/thoughts/encrypted-patches/index.html index 3660897..e7810e3 100644 --- a/thoughts/encrypted-patches/index.html +++ b/thoughts/encrypted-patches/index.html @@ -113,7 +113,7 @@

    The problem

    Linux kernel n-day<$1k Windows crash PoC~$120 -

    SpokenFigure one compares costs on a log scale: each grid line to the right means ten times more money. The top group is finding new bugs, one whole campaign per bar. About forty thousand dollars for a Chrome exploit chain, under twenty thousand for OpenBSD, about ten thousand for FFmpeg.

    SpokenThe bottom group is exploiting a bug that a patch already gave away, one exploit per bar. About two thousand dollars for Windows, under one thousand for Linux, and about a hundred and twenty for a crash proof of concept. The costs in the bottom group are ten to a hundred times lower. That difference is what a patch gives away.

    +

    SpokenFigure one compares costs on a log scale: each grid line to the right means ten times more money. The top group is finding new bugs, one whole campaign per bar. About forty thousand dollars for a Chrome exploit chain, under twenty thousand for OpenBSD, about ten thousand for FFmpeg.

    SpokenThe bottom group is exploiting a bug that a patch already gave away, one exploit per bar. About two thousand dollars for Windows, under one thousand for Linux, and about a hundred and twenty for a crash proof of concept. The costs in the bottom group are ten to a hundred times lower. That difference is what a patch gives away.

    Fig. 1 · Cost per result on a log scale, from [5], [10] and [11]. Discovery is a whole campaign, the n-day rows are one exploit each. Some discovery is cheaper: depthfirst reports 21 FFmpeg bugs for about $1,000 [12].

    SpokenPause here for a moment. How many days does your team need from a published fix to production?

    @@ -171,7 +171,7 @@

    The idea

    T +7 days -

    SpokenFigure two shows two timelines around patch day T, the vertical line in the middle. The top row is today. Nothing is on the hosts before T. At T the patch goes out, installs spread over weeks, and an attacker can diff it from the first download.

    SpokenThe bottom row is the pre-load. The encrypted bundle sits on every host during the week before T. At T only the thirty-two byte key is published. Hosts that update by themselves install within minutes, and the attacker starts at the same moment as everyone else.

    +

    SpokenFigure two shows two timelines around patch day T, the vertical line in the middle. The top row is today. Nothing is on the hosts before T. At T the patch goes out, installs spread over weeks, and an attacker can diff it from the first download.

    SpokenThe bottom row is the pre-load. The encrypted bundle sits on every host during the week before T. At T only the thirty-two byte key is published. Hosts that update by themselves install within minutes, and the attacker starts at the same moment as everyone else.

    Fig. 2 · With a pre-load, the attacker and the defenders start at the same moment, and hosts that update by themselves are done within minutes.

    SpokenHas anyone shipped something locked before? Games do it all the time.

    From a904ec06e20b5778e8a8263b00c43b17babe147d Mon Sep 17 00:00:00 2001 From: Tim Conrad <4759569+Tim-Conrad@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:45:04 +0100 Subject: [PATCH 6/6] narration: Start over stays hidden until playback starts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KFxuEteUiyeGMzLJ2VGnGc --- assets/site.css | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/site.css b/assets/site.css index 5b4115d..adaa7e9 100644 --- a/assets/site.css +++ b/assets/site.css @@ -513,6 +513,7 @@ figure .dg-n { display: none; } .narr-go svg, .narr-play svg { width: 1.25rem; height: 1.25rem; fill: currentColor; } .i-pause { display: none; } .narr-playing .i-play { display: none; } .narr-playing .i-pause { display: block; } .narr-note { margin: 0; flex: 1 1 16rem; color: var(--muted); font-size: .9375rem; line-height: 1.4; } +.narr-again[hidden] { display: none; } /* .btn sets display, which would win over [hidden] */ .narr-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; background: var(--bg); border-top: var(--rule) solid var(--ink); padding-bottom: env(safe-area-inset-bottom, 0px); display: none; } .narr-on .narr-bar { display: block; } .narr-on { padding-bottom: 4.5rem; }