From 3aa0a7b404878265af3bd2065744362334a75429 Mon Sep 17 00:00:00 2001 From: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:48:06 -0400 Subject: [PATCH 1/9] feat(type): every pt size and font family names a typography token typography.css has carried a pt scale and three family stacks since the design system was written. On 2026-10-06 the scale had 23 consumers against 1,072 hand-written sizes in 70 distinct values, and 55 family stacks were spelled out by hand (31 of them "Courier New",monospace, the mono token with its fallbacks dropped). - The scale grows to what the site sets: --text-lg 13pt, --text-lede 11pt, --text-2xs 8pt, --text-3xs 7.5pt, --text-4xs 7pt. 8pt alone had 98 uses. - 989 declarations in the sources rewritten to tokens; generated garage and lwe pages regenerated from their specs and generators. - 121 off-grid declarations snapped to the nearest step, ties down. Most move 0.2pt or less (8.6 and 8.4 -> 8.5, 8.8 -> 9, 8.2 and 7.8 -> 8). Six move 0.4-0.5pt: 12.5 -> 12 and 11.5 -> 11 (two each), 6.5 and 6.6 -> 7. - contract-type-comes-from-the-tokens refuses a pt size up to 14pt or a family stack outside var(--font-*). px (pixel-exact chrome, SVG text), relative units and display sizes over 14pt stay legal; three rules say @type demo (horizon's console caret, the polaroid's handwriting, lwe's CJK avatar). - The homepage loads luna.css without blocking, so it inlines the five size tokens it reads; the contract holds that copy to typography.css. - cssSources() now reads lwe specs' demoCss, which neither contract saw. - colors.css's --text-body: var(--ink) is gone. typography.css's --text-body (10.5pt) loads after it in luna.css, so the colour alias never applied. Checked in a browser: no element on 8 sampled pages computes to the 16px default; /garage/useragent's size histogram moves exactly by the snaps. --- cal/src/templates.ts | 22 +- design/tokens/colors.css | 1 - design/tokens/typography.css | 14 +- pipelines/garage/generate.mjs | 2 +- pipelines/garage/specs/av2.json | 2 +- pipelines/garage/specs/dictionary.json | 2 +- pipelines/garage/specs/hidden-flags.json | 2 +- pipelines/garage/specs/htmx.json | 2 +- pipelines/garage/specs/ig-prep.json | 2 +- pipelines/garage/specs/octane.json | 2 +- pipelines/garage/specs/pqc.json | 2 +- pipelines/garage/specs/resample.json | 2 +- pipelines/garage/specs/typed-config.json | 2 +- pipelines/garage/specs/useragent.json | 2 +- pipelines/lwe/generate.mjs | 2 +- pipelines/lwe/specs/dac.json | 2 +- pipelines/lwe/specs/drivers.json | 2 +- pipelines/lwe/specs/encoding.json | 2 +- pipelines/lwe/specs/fuse.json | 2 +- pipelines/lwe/specs/knots.json | 2 +- pipelines/lwe/specs/pcrypto.json | 2 +- serendipity/serendipity.ts | 8 +- src/pages/access/index.html | 66 ++-- src/pages/dotfiles/index.html | 20 +- src/pages/garage/av2.html | 18 +- src/pages/garage/blueprint.html | 24 +- src/pages/garage/chunks.html | 20 +- src/pages/garage/cloudflare.html | 10 +- src/pages/garage/compression.html | 6 +- src/pages/garage/dictionary.html | 10 +- src/pages/garage/encoding.html | 14 +- src/pages/garage/gpt56.html | 22 +- src/pages/garage/hidden-flags.html | 24 +- src/pages/garage/horizon.html | 190 +++++----- src/pages/garage/htmx.html | 16 +- src/pages/garage/ig-prep.html | 6 +- src/pages/garage/index.html | 14 +- src/pages/garage/iroh.html | 14 +- src/pages/garage/masonry.html | 8 +- src/pages/garage/octane.html | 12 +- src/pages/garage/pqc.html | 18 +- src/pages/garage/pretext.html | 14 +- src/pages/garage/resample.html | 10 +- src/pages/garage/safari27.html | 22 +- src/pages/garage/scroll.html | 26 +- src/pages/garage/teardown.html | 18 +- src/pages/garage/tooltips.html | 19 +- src/pages/garage/typed-config.html | 22 +- src/pages/garage/useragent.html | 42 +-- src/pages/garage/vt-check.html | 6 +- src/pages/garage/wire.html | 36 +- src/pages/garage/workers.html | 12 +- src/pages/index.html | 21 +- src/pages/lwe/dac.html | 4 +- src/pages/lwe/drivers.html | 4 +- src/pages/lwe/encoding.html | 4 +- src/pages/lwe/fhe.html | 28 +- src/pages/lwe/fuse.html | 4 +- src/pages/lwe/index.html | 20 +- src/pages/lwe/knots.html | 4 +- src/pages/lwe/lean.html | 50 +-- src/pages/lwe/mpc.html | 16 +- src/pages/lwe/pcrypto.html | 4 +- src/pages/lwe/tee.html | 10 +- src/pages/lwe/utf8.html | 20 +- src/pages/lwe/vigenere.html | 58 +-- src/pages/pixel-peeper/index.html | 44 +-- src/styles/luna.css | 51 +-- src/styles/lwe-base.css | 24 +- src/styles/nav-run.css | 2 +- src/styles/prose.css | 18 +- src/styles/quiz.css | 22 +- src/worker/around.ts | 26 +- src/worker/bot.ts | 12 +- src/worker/census.ts | 34 +- src/worker/dyno.ts | 18 +- src/worker/inbox.ts | 28 +- src/worker/ledger.ts | 46 +-- src/worker/lens.ts | 344 +++++++++--------- src/worker/lib/chrome.ts | 2 +- src/worker/photos.ts | 8 +- src/worker/reading.ts | 28 +- src/worker/rn.ts | 6 +- src/worker/run.ts | 12 +- src/worker/search.ts | 2 +- src/worker/security.ts | 16 +- src/worker/updates.ts | 52 +-- src/worker/whoareyou.ts | 38 +- src/worker/writing.ts | 4 +- ...ntract-type-comes-from-the-tokens.test.mjs | 58 +++ tools/lib/token-literals.ts | 8 +- tools/lib/type-literals.ts | 67 ++++ 92 files changed, 1094 insertions(+), 943 deletions(-) create mode 100644 tools/contract-type-comes-from-the-tokens.test.mjs create mode 100644 tools/lib/type-literals.ts diff --git a/cal/src/templates.ts b/cal/src/templates.ts index 6b5e3e3ac..d3ae03d96 100644 --- a/cal/src/templates.ts +++ b/cal/src/templates.ts @@ -56,7 +56,7 @@ body { reads too "modern" on macOS (which falls back to Arial). Verdana is web-installed everywhere and gives the bolder 2003 weight. */ font-family: var(--font-ui); - font-size: 11pt; + font-size: var(--text-lede); line-height: 1.4; margin: 0; padding: 24px 12px 48px; @@ -108,7 +108,7 @@ h1 { text-wrap: balance; /* horizon: even heading line breaks, no JS */ } .lead { - font-size: 10.5pt; + font-size: var(--text-body); color: var(--ink-soft); margin: 0 0 12px; text-wrap: pretty; /* horizon: avoids orphans/ragged last line */ @@ -136,7 +136,7 @@ a:hover { color: var(--link-hover); } top: -8px; left: 10px; background: var(--paper); padding: 0 6px; - font-size: 10pt; + font-size: var(--text-ui); font-weight: bold; color: var(--blue-40); } @@ -144,7 +144,7 @@ a:hover { color: var(--link-hover); } /* ── slot listing ───────────────────────────────────────────────────── */ .xp-meta { color: var(--ink-dim); - font-size: 9.5pt; + font-size: var(--text-sm); margin: 0 0 8px; } .xp-day-label { @@ -155,7 +155,7 @@ a:hover { color: var(--link-hover); } font in the XP era). Verdana fallback for systems missing it. */ font-family: var(--font-caption); font-weight: bold; - font-size: 10pt; + font-size: var(--text-ui); text-transform: uppercase; letter-spacing: 0.04em; color: var(--blue-40); @@ -197,7 +197,7 @@ button.xp-button { padding: 3px 12px; min-width: 70px; font-family: var(--font-ui); - font-size: 10pt; + font-size: var(--text-ui); color: var(--ink); background: linear-gradient(to bottom, oklch(99% 0 0) 0%, oklch(92% 0.005 240) 100%); border: 1px solid var(--shadow); @@ -236,7 +236,7 @@ button.xp-button:active { cursor: default; } /* an island that could not load leaves its placeholder and says so */ -.slot-failed { display: none; margin: 8px 0 0; font-size: 10pt; } +.slot-failed { display: none; margin: 8px 0 0; font-size: var(--text-ui); } [data-state="failed"] + .slot-failed { display: block; } /* No scripts, no swap: hide the placeholder and keep only the noscript note. */ @media (scripting: none) { @@ -284,14 +284,14 @@ form.book .row.stacked { gap: 4px; } form.book label { - font-size: 10pt; + font-size: var(--text-ui); color: var(--ink); } form.book input[type="text"], form.book input[type="email"], form.book textarea { font-family: var(--font-ui); - font-size: 10.5pt; + font-size: var(--text-body); padding: 3px 6px; background: oklch(100% 0 0); color: var(--ink); @@ -328,7 +328,7 @@ form.book .actions { border: 1px solid oklch(75% 0.10 95); background: oklch(96% 0.06 100); padding: 8px 10px; - font-size: 10pt; + font-size: var(--text-ui); margin: 12px 0; display: flex; gap: 8px; @@ -359,7 +359,7 @@ form.book .actions { body { padding: 8px 4px 32px; } .content { padding: 10px 10px 12px; } form.book .row { grid-template-columns: 1fr; gap: 2px; } - form.book label { font-size: 9.5pt; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.04em; } + form.book label { font-size: var(--text-sm); color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.04em; } } `; diff --git a/design/tokens/colors.css b/design/tokens/colors.css index 567e54adc..a1b42b6ce 100644 --- a/design/tokens/colors.css +++ b/design/tokens/colors.css @@ -84,7 +84,6 @@ --surface-field: var(--window); /* inputs / wells */ --text-title: oklch(100% 0 0); /* caption text (white) */ --text-heading: var(--blue-40); /* navy h1–h3 */ - --text-body: var(--ink); --text-muted: var(--ink-dim); --border-window-color: var(--frame); --border-field: var(--frame); diff --git a/design/tokens/typography.css b/design/tokens/typography.css index 6aa922801..5e25fed0f 100644 --- a/design/tokens/typography.css +++ b/design/tokens/typography.css @@ -19,16 +19,24 @@ /* ── SIZE SCALE — authored in pt, the period-correct unit for XP UI ────── The site sets body at 10.5pt and scales headings up; control text is 10pt. - Kept in pt so it reads like a 2003 dialog, not a 2026 rem grid. */ - --text-h1: 14pt; /* page heading (homepage h1) */ + Kept in pt so it reads like a 2003 dialog, not a 2026 rem grid. Every pt + size up to 14pt names one of these; the type contract refuses a literal. + px stays legal for pixel-exact chrome and SVG text, and anything over 14pt + is display type, composed per page. */ --text-h1-lg: 18pt; /* large heading (coffee page h1) */ + --text-h1: 14pt; /* page heading (homepage h1) */ + --text-lg: 13pt; /* big figures, stat numerals, lwe h2 */ --text-h2: 12pt; /* section heading */ - --text-body: 10.5pt; /* prose / lede */ + --text-lede: 11pt; /* ledes and intro paragraphs */ + --text-body: 10.5pt; /* prose */ --text-ui: 10pt; /* buttons, labels, list rows */ --text-sm: 9.5pt; /* meta, captions */ --text-xs: 9pt; /* footer, status bar */ --text-mono: 9pt; /* mono readouts, badges */ --text-micro: 8.5pt; /* status pills, fine print */ + --text-2xs: 8pt; /* dense tables, chat metadata, code in proofs */ + --text-3xs: 7.5pt; /* sub-labels, tags */ + --text-4xs: 7pt; /* bit and byte labels inside diagrams */ /* ── WEIGHTS — XP UI is normal or bold, nothing in between ───────────────*/ --weight-normal: 400; diff --git a/pipelines/garage/generate.mjs b/pipelines/garage/generate.mjs index 8684caf2b..f73a13267 100644 --- a/pipelines/garage/generate.mjs +++ b/pipelines/garage/generate.mjs @@ -58,7 +58,7 @@ function validateGarageSpec(spec, context = "Garage spec") { // star icon, paragraph rhythm, and the intro and meta lines. const BASE_CSS = `.title-bar .icon{width:14px;height:14px;flex:0 0 14px;background:#fff;border:1px solid #8f4d06}.title-bar .icon:before{content:"✦";position:absolute;inset:0;display:grid;place-items:center;font-size:10px;color:#ef8f24;text-shadow:none} .content p{margin:0 0 12px}.content code,.content pre{font-family:var(--font-mono)} -.garage-intro{font-size:11pt;color:var(--ink-soft);max-width:70ch}.garage-meta{font-size:8.5pt;color:var(--ink-dim);border-top:1px solid var(--rule);margin-top:18px;padding-top:6px} +.garage-intro{font-size:var(--text-lede);color:var(--ink-soft);max-width:70ch}.garage-meta{font-size:var(--text-micro);color:var(--ink-dim);border-top:1px solid var(--rule);margin-top:18px;padding-top:6px} @media(max-width:620px){.window{--axp-gutter:12px}.content{padding:14px 12px 6px}} `; diff --git a/pipelines/garage/specs/av2.json b/pipelines/garage/specs/av2.json index b326ba385..e653f7088 100644 --- a/pipelines/garage/specs/av2.json +++ b/pipelines/garage/specs/av2.json @@ -5,7 +5,7 @@ "status": "watching", "added": "2026-09-27", "bodyHtml": "

AV2, before anyone can see it

\n

AV2 is the successor to AV1, the codec inside every AVIF image, and its reference encoder shipped 1.0.0 in May. AOM's own all-intra report puts it 18 to 24% ahead of AV1 on stills. That number comes from video test sequences scored with PSNR, SSIM and VMAF. I wanted the answer for photographs, scored the way this site already scores its own images, at the exact byte budgets it ships. No browser can decode an AV2 image yet, so everything below is a picture your browser couldn't have drawn.

\n\n

Your browser, right now

\n

These are three copies of the same 128 px crop. The first is AV1, the codec inside the AVIF files browsers decode today. The second is AV2 in the same container, at 2,030 bytes against AV1's 2,097. The third is how you'd try to ship AV2 safely: a <picture> source typed image/avif, with a WebP fallback.

\n
\n
\"AV1
AV1 in AVIF
\n
\"AV2
AV2 in AVIF
\n
\"AV2
AV2, with a fallback
\n
\n

Enable JavaScript and this line reports what each image did.

\n

In Chrome 154 and Canary 156 the third image is broken, and the WebP never loads. Of everything on this page, that's the finding most likely to cost somebody an afternoon, so it comes first.

\n\n

Why the fallback doesn't catch it

\n

A <picture> element chooses a source by its declared type, before any byte decodes. Chrome supports image/avif, so it commits to the source, fetches it, and finds an image it can't read. Nothing sends it back to the <img>, because the fallback only covers types a browser has never heard of.

\n

The file explains the rest. libavif writes AV2 inside the same box structure as AV1, and the brand still says avif, mif1 and miaf. Only the image item changes, from av01 to av02, with an av2C config box beside it. A careful decoder like avifdec skips the item it doesn't know and reports Primary item not found. A browser draws a broken image.

\n

So an AV2 image needs a name a browser can decline, the way image/avif gave AV1 stills a name that browsers without AV1 could skip. Until one exists, serving AV2 under the AVIF name breaks the image in every browser that supports AVIF, which is every current release of Chrome, Firefox, Safari and Edge.

\n\n

At the bytes AVIF actually costs

\n

Corrected 2026-09-28. The first version of this section scored only the 16 detail crops further down, found AV2 losing every call, and called that the answer for photographs. It was the answer for the densest detail in each photo. On the thumbnails this site actually ships, AV2 wins.

\n

The thumbnails are 38 tiles at 600 px, cut the way the site's own pipeline cuts them: the whole frame, squared and reduced about 8x in linear light by zenc square. They come from Fuji photos that none of the tuning below ever saw. Each tile's budget is the file size of the AVIF tier this site ships (avifenc -q 63 -d 10 --speed 2 --yuv 444), and AV2 is encoded onto that budget, file against file. Both are scored against the source with the two perceptual metrics this site ships by: ssimulacra2, where higher is better, and butteraugli, where lower is better.

\n

Matched bytes is the whole method, and AV2 makes it harder than usual. libavif's quality scale is far too coarse for it: on one crop, -q 49 wrote 6,865 bytes of AV2 and -q 50 wrote 9,608, a 40% step. So the probe steps AV2's own quantizer instead, one notch at a time.

\n
\n
38 tiles, AV2 speed 6Δ s2, meanΔ s2, medians2 winsbutteraugli wins
\n
the shipped AVIF (AV1, speed 2)referencereference
\n
AV2, libavif default (tune ssim)+0.77+0.6528 of 3824 of 38
\n
AV2 with per-segment QM (see below)+0.89+0.9030 of 3828 of 38
\n
\n

With no tuning at all, AV2 wins 28 of 38 tiles by ssimulacra2 and 24 by butteraugli. The average margin is 0.77 points. That is modest, and it comes against an AV1 encoder with a still-image tune, while AV2's encoder has only the video defaults. It costs about 14 times the encode time, single-threaded, on a 600 px tile.

\n\n

The crops that disagree

\n

The first version of this page ran on the 16 detail crops from /pixel-peeper, each 320 px, from an X-T50. Each is the single most detailed 320 px window in its photo, at full resolution, picked to be the hardest thing an encoder sees. Each crop gets two budgets. The first is the file size of the shipped AVIF tier, and the second is the size of the JPEG fallback tier, filled with AVIF instead.

\n
\n
AV2 encoderΔ s2, trainΔ s2, holdoutwins of 32, s2 / buone 320 px encode
\n
the shipped AVIF (AV1, speed 2)referencereference0.45 s
\n
AV2 speed 6, libavif default (tune ssim)-1.75-2.160 / 75.4 s
\n
AV2 speed 6, tune psnr-2.04-2.810 / 145.4 s
\n
AV2 speed 3, libavif default (tune ssim)-1.03-1.534 / 1118.3 s
\n
cross-check: raw avmenc, cpu-used 6 (tune psnr)-1.46-2.320 / 18
\n
cross-check: raw avmenc, cpu-used 3 (tune psnr)-0.81-1.666 / 21
\n
\n

Each Δ is AV2 minus the shipped AVIF in ssimulacra2, averaged over 16 crop and budget pairs. Train is the 8 crops /pixel-peeper uses and holdout is 8 it never sees, so a result has to survive a set nobody tuned on. The two cross-check rows drive AV2's raw encoder through my own colour conversion, which shares no code with libavif above the codec. They score AV2 about half a point better than libavif does at the same tune. I haven't traced that gap, and neither instrument puts AV2 ahead on the crops.

\n

On the crops, at speed 6, ssimulacra2 prefers the shipped AV1 on all 32 calls. Speed 3 beats speed 6 on every one of them and closes about a third of the gap, to 1.03 and 1.53, winning 4 of 32. It costs 41 times the encode time of the AVIF this site ships, and none of the speeds I could afford to run closes the whole gap.

\n

These rows let AV2's encoder choose its own superblock size, and at 320 px it picks 64. Pinning the 128 that a real 600 px tile gets moves speed 6 to -1.57 on train and -2.12 on holdout.

\n\n

What decides it: bits per pixel

\n

The tiles and the crops sit at different densities. At the AVIF tier's budget a tile averages 0.46 bits per pixel and a crop 1.08, about 2.3 times as many, because a crop is all detail and a tile is mostly sky, skin and shadow at thumbnail scale. Pool the two, sort by bits per pixel, and AV2's standing follows one curve.

\n
\n
bits per pixelcallsΔ s2AV2 wins
\n
under 0.312 tiles+1.6012 of 12
\n
0.3 to 0.512 tiles+0.7011 of 12
\n
0.5 to 0.811 tiles, 4 crops-0.474 of 15
\n
0.8 to 1.22 tiles, 8 crops-0.942 of 10
\n
over 1.21 tile, 4 crops-2.360 of 5
\n
\n

AV2 wins where the budget is thin and loses where every pixel carries detail, and the flip sits near half a bit per pixel. The trend holds within the tiles alone (a correlation of -0.33 between bits per pixel and AV2's margin), so it is more than a difference between two ways of cutting images. A photo site's thumbnails live at the thin end. A pixel-peeping crop lives at the thick end, which is where the next section finds the loss.

\n\n

Where AV2 loses

\n

At the dense end, the loss has a shape. My first guess was that AV2 smooths away sensor grain, and I nearly published it. It's wrong. Measured on the decodes, AV2 reproduces the source's fine, pixel-scale texture more faithfully than AV1 on 21 of the 32 calls. The correlation between each decode's fine detail and the source's rises from 0.773 to 0.811.

\n

What AV2 gets wrong is the coarse picture. Average each image over 8 by 8 pixel blocks, which is roughly what you'd see from across the room, and compare those averages with the source's. AV2's error is higher than AV1's on 32 of 32 calls, by 50% in brightness (0.26 to 0.39 on an 8-bit scale) and 64% in colour (0.25 to 0.41). It holds on the 8 calls where the two files sit within 1% of the same size, so it isn't a byte shortfall.

\n

So this encoder moves its bits from the broad strokes to the fine ones: slightly truer texture, slightly wronger tone and colour in flat areas. ssimulacra2 scores the picture at several scales and weighs colour heavily, and it reads that trade as a loss.

\n\n
\n
\n \"Source\n \"The\n \"The\n
\n
XT509535, at the AVIF tier's budget. AV1 11,166 B: ssimulacra2 71.00, butteraugli 2.204. AV2 10,824 B (speed 3, qp 107): ssimulacra2 65.91, butteraugli 2.419. Fine texture tracks the source at 0.57 for AV1 and 0.70 for AV2; block-scale error runs 0.26 against 0.40 in brightness and 0.23 against 0.38 in colour.
\n
\n\n
\n
\n \"Source\n \"The\n \"The\n
\n
XT509540, at the AVIF tier's budget. AV1 8,906 B: ssimulacra2 73.54, butteraugli 2.006. AV2 8,308 B (speed 3, qp 105): ssimulacra2 69.70, butteraugli 2.561. Fine texture tracks the source at 0.50 for AV1 and 0.59 for AV2; block-scale error runs 0.26 against 0.45 in brightness and 0.21 against 0.45 in colour.
\n
\n\n
\n
\n \"Source\n \"The\n \"The\n
\n
XT509278, at the JPEG fallback's budget. AV1 20,866 B: ssimulacra2 82.94, butteraugli 1.824. AV2 20,561 B (speed 3, qp 103): ssimulacra2 82.99, butteraugli 1.568. Fine texture tracks the source at 0.80 for AV1 and 0.87 for AV2; block-scale error runs 0.24 against 0.37 in brightness and 0.26 against 0.40 in colour.
\n
\n

Each figure shows the 200 px region where the AV1 and AV2 decodes differ most, at 2x, in lossless WebP, because re-encoding a decode would add artifacts of its own. Click or tap a figure to flip between the three in place, which is the easiest way to see a codec difference. Each AV2 file is the largest that fits at or under the AV1 file, so AV2 never gets the extra bytes.

\n\n

The tune you didn't choose

\n

butteraugli's verdict turned out to depend on a setting nobody asked for. libavif tells AV2's encoder to tune for SSIM unless you pick a tune yourself, and AV2's own command-line encoder defaults to PSNR. At speed 6, switching libavif to tune=psnr costs AV2 about half a point of ssimulacra2 and doubles its butteraugli wins, from 7 of 32 to 14.

\n

AV2's encoder accepts those two tunes and refuses a third: tune=iq, the image-quality tune libaom added for stills in 3.13, which the shipped AVIF uses. So the comparison pits a codec with a tune built for photographs against one that has only the video defaults. That's a fair picture of today, and a reason to expect the gap to narrow.

\n\n

Where the literature's number comes from

\n

AOM's report measures something real and different. Its AV2 beats an AV1 anchor by 17.82% on PSNR-Y and 23.58% on VMAF in all-intra mode, which codes every frame as a still. The anchor is a branch of libaom, the test material is video sequences, and the metrics are PSNR-HVS, PSNR, SSIM, MS-SSIM, CIEDE2000 and VMAF. Neither ssimulacra2 nor butteraugli appears, and I couldn't find the encoder speeds in it.

\n

It also reports cost: on average AV2 took 33 times longer to encode than the anchor and 3.86 times longer to decode. The encode column above agrees in kind. Through libavif, one 320 px thumbnail takes 5.4 seconds at speed 6 and 18.3 at speed 3, against 0.45 for the AVIF this site ships. AV2's raw encoder at cpu-used 1 was still running after 8 minutes when I stopped it.

\n

So the two results don't contradict each other. By the metrics video is judged by, AV2 is the better codec. On photographs, its first encoder turns that into a win by a photo metric at the density a thumbnail ships at, and gives it back on dense detail. AV1 froze its bitstream in June 2018, and Chrome shipped AVIF in August 2020, after faster encoders had arrived. I expect AV2 to need the same run-up before the dense end turns too.

\n\n

Lossless

\n

AV2's lossless mode round-trips all 16 crops bit for bit. It's also 29% larger than AV1's, which surprised me, because AOM lists improved lossless coding among AV2's new tools.

\n
\n
16 crops, losslesstotal bytesvs PNG
\n
PNG (the source)3,436,4410%
\n
AV2 (avifenc -c avm -l)3,048,575-11.3%
\n
AV1 (avifenc -c aom -l)2,363,430-31.2%
\n
JPEG XL (cjxl -d 0 -e 9)1,571,053-54.3%
\n
\n

Both AVIF files used the same setup: 8-bit 4:4:4, full range, and the identity matrix, so the colour conversion isn't the difference. The slower speed 3 made one crop slightly bigger, 161,336 bytes against 160,357. I think libavif's experimental AV2 path is the likelier cause than the format, and I haven't proved it. JPEG XL's lead is the older, bigger story, and nothing here changes it.

\n\n

What the reference encoder leaves on the table

\n

AVM is a reference encoder, and a reference encoder leaves tuning to whoever comes next. I ported four ideas from the encoders that tune AV1 for stills, and one that only AV2 can express. Each is a patch that is off by default, and the untouched reference decoder reads its output pixel for pixel. Each was scored on the crops, where AV2 needs the help most.

\n
\n
patchwhat it doesΔ s2 on cropsverdict
\n
base-delta-qunlocks the sequence-level DC and chroma offsets AVM hard-codes to zero-0.06 (holdout, chroma +4)about zero
\n
variance-boostlibaom and SVT-AV1's Variance Boost: finer quantizers for flat superblocks-0.54 (train)loses
\n
jpegli-maskjpegli's adaptive-quantization field, mapped onto AV2's quantizer-0.25 (train)loses
\n
qm-curveslibaom's all-intra quantization-matrix curve-0.47 (holdout)loses
\n
qm-per-segmenttwo matrix sets per frame, split by texture+0.14 (holdout)wins
\n
\n

Only the last one pays, and it is the one AV1 had no syntax for. AV1 signals one quantization-matrix level per plane for the whole frame. AV2 can carry up to four sets and let each segment pick one. The all-intra curve helps smooth crops and costs 2 to 3.5 points on busy ones, so one frame-wide matrix is the wrong granularity. Splitting the frame with jpegli's masking field, a flatter matrix for textured areas and the curve for smooth ones, turns the curve's 0.47 loss into a 0.14 gain on the holdout crops, winning 11 of 16. On the tiles it adds 0.12 over plain AV2 and wins 28 of 38.

\n

The same build compiles AVM's own AVX2 kernels for Apple silicon through SIMDe. AVM ships almost no NEON code, so on a Mac most of its hot loops ran in plain C. The output is byte-identical to the plain build, and a 600 px encode runs 1.35 times as fast.

\n\n

Run it yourself

\n

Everything above comes from three committed tools. tools/photos/libavif-avm/build.sh builds libavif at a pinned commit with its experimental AV2 codec switched on. No tagged libavif release carries AVM 1.0.0 yet, so the pin is a commit. --tuned adds the five patches and the SIMDe kernels, and refuses to finish unless the result matches the pristine build byte for byte with every option off. av2-tile-probe.ts runs the tile comparison and codec-knob-probe.ts --codec avm runs the crops.

\n
tools/photos/libavif-avm/build.sh --tuned\nbun tools/photos/av2-tile-probe.ts --src <a folder of originals>\nbun tools/photos/codec-knob-probe.ts --codec avm --variants base,tunepsnr
\n

With every scheduling and caching concern stripped out, the probe is four steps per crop. Encode the shipped AVIF and take its size as the budget. Find the two AV2 quantizer settings whose files straddle that size. Decode both and score them against the source. Interpolate the score to the exact budget. The rest of the file is caching, crop selection and parallelism, which change how long it takes and not what it says.

\n\n

What would change this

\n

The result is small, and it cuts both ways. It covers 38 tiles and 16 crops from one camera, the reference encoder rather than a production one, a container libavif itself labels experimental, and two metrics that both come from the JPEG XL project. A production AV2 encoder is the likeliest thing to move the dense end, the way SVT-AV1 and later libaom releases did for AV1, and a still-image tune is the first place to look. The thin end is already a win, so what it waits on is a browser that decodes AV2 and a name that browser can decline. When either arrives, both probes re-run in an afternoon.

", - "pageCss": ".av2-demo{display:flex;flex-wrap:wrap;gap:14px;margin:10px 0}\n.av2-demo figure{margin:0;text-align:center;font-size:8.4pt}\n.av2-demo img{display:block;width:128px;height:128px;border:1px solid #99a8bd;background:repeating-conic-gradient(#e8eef7 0 25%,#fff 0 50%) 0 0/16px 16px}\n.av2-verdict{font-size:8.8pt;padding:6px 10px;border:1px solid #99a8bd;background:#f3f6fa}\n.av2-verdict b{font-family:var(--font-mono)}\n.dd-box{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:6px 0}\n.dd-row{display:grid;gap:10px;align-items:center;padding:3px 10px;font-size:8.6pt;border-bottom:1px solid #e4eaf3}\n.dd-row:last-child{border-bottom:0}\n.dd-row.head{font-weight:700;background:#e8eef7}\n.dd-speeds .dd-row{grid-template-columns:2.2fr 1fr 1fr 1fr 1fr}\n.dd-lossless .dd-row{grid-template-columns:2fr 1fr 1fr}\n.dd-bpp .dd-row{grid-template-columns:1.2fr 1.4fr 1fr 1fr}\n.dd-tune .dd-row{grid-template-columns:1.1fr 2.6fr 1.2fr .7fr}\n.dd-num{font-family:var(--font-mono);font-size:8.2pt;color:#3a4a5e}\n.av2-fig{margin:14px 0}\n.av2-stack{display:flex;flex-wrap:wrap;gap:8px}\n.av2-stack img{width:400px;max-width:100%;height:auto;image-rendering:pixelated;border:1px solid #99a8bd}\n.av2-fig.flip .av2-stack{display:block;cursor:pointer;width:400px;max-width:100%}\n.av2-fig.flip .av2-stack img{display:none}\n.av2-fig.flip .av2-stack img.on{display:block}\n.av2-tag{font-family:var(--font-mono);font-size:8.4pt;margin:4px 0}\n.av2-fig figcaption{font-size:8.4pt;margin-top:4px}\n.av2-note{font-size:8.4pt;color:#3a4a5e}\n.av2-code{font-family:var(--font-mono);font-size:8.4pt;background:#f3f6fa;border:1px solid #99a8bd;padding:6px 10px;overflow-x:auto}\n@media(max-width:620px){.dd-speeds .dd-row,.dd-lossless .dd-row,.dd-bpp .dd-row,.dd-tune .dd-row{grid-template-columns:1fr 1fr}}", + "pageCss": ".av2-demo{display:flex;flex-wrap:wrap;gap:14px;margin:10px 0}\n.av2-demo figure{margin:0;text-align:center;font-size:var(--text-micro)}\n.av2-demo img{display:block;width:128px;height:128px;border:1px solid #99a8bd;background:repeating-conic-gradient(#e8eef7 0 25%,#fff 0 50%) 0 0/16px 16px}\n.av2-verdict{font-size:var(--text-xs);padding:6px 10px;border:1px solid #99a8bd;background:#f3f6fa}\n.av2-verdict b{font-family:var(--font-mono)}\n.dd-box{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:6px 0}\n.dd-row{display:grid;gap:10px;align-items:center;padding:3px 10px;font-size:var(--text-micro);border-bottom:1px solid #e4eaf3}\n.dd-row:last-child{border-bottom:0}\n.dd-row.head{font-weight:700;background:#e8eef7}\n.dd-speeds .dd-row{grid-template-columns:2.2fr 1fr 1fr 1fr 1fr}\n.dd-lossless .dd-row{grid-template-columns:2fr 1fr 1fr}\n.dd-bpp .dd-row{grid-template-columns:1.2fr 1.4fr 1fr 1fr}\n.dd-tune .dd-row{grid-template-columns:1.1fr 2.6fr 1.2fr .7fr}\n.dd-num{font-family:var(--font-mono);font-size:var(--text-2xs);color:#3a4a5e}\n.av2-fig{margin:14px 0}\n.av2-stack{display:flex;flex-wrap:wrap;gap:8px}\n.av2-stack img{width:400px;max-width:100%;height:auto;image-rendering:pixelated;border:1px solid #99a8bd}\n.av2-fig.flip .av2-stack{display:block;cursor:pointer;width:400px;max-width:100%}\n.av2-fig.flip .av2-stack img{display:none}\n.av2-fig.flip .av2-stack img.on{display:block}\n.av2-tag{font-family:var(--font-mono);font-size:var(--text-micro);margin:4px 0}\n.av2-fig figcaption{font-size:var(--text-micro);margin-top:4px}\n.av2-note{font-size:var(--text-micro);color:#3a4a5e}\n.av2-code{font-family:var(--font-mono);font-size:var(--text-micro);background:#f3f6fa;border:1px solid #99a8bd;padding:6px 10px;overflow-x:auto}\n@media(max-width:620px){.dd-speeds .dd-row,.dd-lossless .dd-row,.dd-bpp .dd-row,.dd-tune .dd-row{grid-template-columns:1fr 1fr}}", "pageJs": "(function(){\n // The demo: report whether each image decoded. img.decode() rejects on a file\n // the browser fetched and could not read, which is the case this page is about.\n var out = document.getElementById(\"av2-verdict\");\n var a = document.getElementById(\"av2-a\"), b = document.getElementById(\"av2-b\"), c = document.getElementById(\"av2-c\");\n if (out && a && b && c && a.decode) {\n var ok = function(img){ return img.decode().then(function(){ return img.naturalWidth > 0; }, function(){ return false; }); };\n Promise.all([ok(a), ok(b), ok(c)]).then(function(r){\n var picked = c.currentSrc && c.currentSrc.indexOf(\"demo-av2\") >= 0 ? \"the AV2 source\" : \"the WebP fallback\";\n var s = \"AV1: \" + (r[0] ? \"drew\" : \"broken\") + \". AV2: \" + (r[1] ? \"drew\" : \"broken\") + \". With a fallback, your browser chose \" + picked + \", which \" + (r[2] ? \"drew\" : \"broke\") + \".\";\n if (r[1]) s += \" Your browser decodes AV2, which none did when this was written.\";\n else if (!r[2] && picked === \"the AV2 source\") s += \" That's the trap: the type matched, the decode failed, and the fallback never loaded.\";\n else if (!r[0]) s += \" This browser doesn't decode AVIF at all, so the fallback did its job.\";\n out.innerHTML = s;\n });\n }\n // The figures: flip between the three images in place, one click at a time.\n var figs = document.querySelectorAll(\".av2-fig\");\n for (var i = 0; i < figs.length; i++) (function(fig){\n var imgs = fig.querySelectorAll(\".av2-stack img\"), k = 0;\n if (imgs.length !== 3) return;\n var tag = document.createElement(\"p\"); tag.className = \"av2-tag\";\n var show = function(){ for (var j = 0; j < 3; j++) imgs[j].className = j === k ? \"on\" : \"\"; tag.textContent = imgs[k].getAttribute(\"data-label\") + \" (\" + (k + 1) + \" of 3, click to flip)\"; };\n fig.classList.add(\"flip\"); fig.insertBefore(tag, fig.firstChild); show();\n fig.querySelector(\".av2-stack\").addEventListener(\"click\", function(){ k = (k + 1) % 3; show(); });\n // A lazy image that is display:none is never near the viewport, so the two\n // hidden frames would never fetch and the first flip would show a blank.\n // Promote all three to eager once the figure itself comes close.\n var eager = function(){ for (var j = 0; j < 3; j++) imgs[j].loading = \"eager\"; };\n if (\"IntersectionObserver\" in window) {\n var io = new IntersectionObserver(function(es){ if (es[0].isIntersecting) { eager(); io.disconnect(); } }, { rootMargin: \"400px\" });\n io.observe(fig);\n } else eager();\n })(figs[i]);\n})();", "editorial": { "reader": "A site builder who ships AVIF and has read that AV2 compresses stills about 20% better than AV1. They want to know what that means for their photographs, and when they could use it.", diff --git a/pipelines/garage/specs/dictionary.json b/pipelines/garage/specs/dictionary.json index 4b1e7236d..9a2f85b2f 100644 --- a/pipelines/garage/specs/dictionary.json +++ b/pipelines/garage/specs/dictionary.json @@ -5,7 +5,7 @@ "status": "watching", "added": "2026-09-25", "bodyHtml": "

The dictionary the runtime dropped

\n

Most pages on this site reach a returning Chromium visitor as a delta. The build compresses each one against a 64 KB dictionary made from the site's own markup, and a browser that already holds that dictionary downloads only what differs. Measured against production on 2026-09-25, 51 of the 66 pages here answer that way, and 2 more join them with the release rolling out as I write this.

\n

The other 13 answer as plain brotli. They're the pages the Worker writes when you ask for them, like /ledger, /around and /finger, so no build ever sees their bytes.

\n

The Worker could compute the delta itself, since node:zlib's zstdCompressSync takes a dictionary option. In workerd it accepted that option and threw it away.

\n

I filed that as cloudflare/workerd#6967 and wrote the fix, cloudflare/workerd#7106, which is waiting on review. This page is what the fix is worth to this site, what it costs, and the part it can't reach. /garage/compression covers the build-time half.

\n\n

This page, twice

\n

This page is one of the delta pages. Your browser downloaded it once already, as the document you're reading, and the script below asks for it again with fetch().

\n

A fetch has the destination empty, and the site offers its dictionary only for requests whose destination is document, so the second copy arrives without the delta. Same bytes, two transfers:

\n
enable JavaScript to run the measurement
\n

A page the Worker writes gets the second number both times. That gap, paid on every visit to those 13 pages, is what the fix is for.

\n\n

Why nothing complained

\n

A dropped dictionary fails silently on the compressing side, and zstd's design is why. A frame compressed without a dictionary is still a valid frame, and it decodes fine when the reader supplies one anyway. So the only symptom is a delta the same size as no delta at all.

\n

The site's nightly canary runs a four-line control for exactly this. It compresses one 21,200-byte buffer three ways: with no dictionary, with the right one, and with a wrong one.

\n
\n
runtimenonerightwrong
\n
Node 26.9.073 B24 B73 B
\n
workerd, as pinned here73 B73 B73 B
\n
\n

A runtime that honours the option prints a smaller number for the right dictionary alone. workerd prints the same number three times. Decompressing is louder: a frame that needs a dictionary fails there as Data corruption detected, against bytes that are fine.

\n

The cause was plumbing. The options cross from JavaScript into C++ through a struct that lists the fields it reads. dictionary wasn't one of them, so the value vanished at the language boundary before zstd ever saw it. The fix adds the field and hands the bytes to ZSTD_CCtx_loadDictionary and ZSTD_DCtx_loadDictionary, the way Node does.

\n\n

What it would save

\n

For each of the 13 pages, tools/runtime-dcz-probe.ts compressed the live HTML at zstd level 6 against the 64 KB dictionary production serves. It then compared each result with the brotli the edge sends today.

\n

Together they drop from 81,228 to 63,449 bytes, 21.9% off. Each bar is today's brotli, and the green end is the part a runtime delta removes:

\n
\n
/cache10,333 B3,162 → 1,938 B, 1,224 saved (39%)
\n
/encode10,408 B3,231 → 2,028 B, 1,203 saved (37%)
\n
/finger10,637 B3,111 → 1,914 B, 1,197 saved (38%)
\n
/radar10,669 B3,280 → 2,086 B, 1,194 saved (36%)
\n
/dict11,194 B3,546 → 2,278 B, 1,268 saved (36%)
\n
/agent-ready11,625 B3,611 → 2,381 B, 1,230 saved (34%)
\n
/inbox14,446 B4,316 → 2,943 B, 1,373 saved (32%)
\n
/lens/census16,395 B4,625 → 3,155 B, 1,470 saved (32%)
\n
/ledger20,132 B5,541 → 3,910 B, 1,631 saved (29%)
\n
/around23,996 B5,974 → 4,505 B, 1,469 saved (25%)
\n
/coffee33,539 B7,711 → 6,533 B, 1,178 saved (15%)
\n
/serendipity52,539 B15,276 → 13,606 B, 1,670 saved (11%)
\n
/reading63,199 B17,844 → 16,172 B, 1,672 saved (9%)
\n
\n

The percentages spread from 9% to 39%, and the bytes saved barely move. Every page sheds between 1,178 and 1,672 bytes, a median of 1,268, across a sixfold range in page size.

\n

That constant is the shell. The dictionary is built from this site's own pages, so what it matches is the markup they share: the head, the window frame, the tags that load the shell scripts. Every page carries about the same amount of it.

\n

A utility page like /cache is mostly shell and loses 39%, and /reading is mostly its own list and loses 9%.

\n\n

What it would cost

\n

A build-time delta costs nothing when a visitor arrives. A runtime delta costs CPU on every request, and this site runs on Workers Free, where a request gets about 10 ms of it.

\n
\n
level 368,167 B16.1% offmedian 0.239 ms, max 0.475 ms
\n
level 663,449 B21.9% offmedian 0.165 ms, max 0.503 ms
\n
level 1960,237 B25.8% offmedian 3.451 ms, max 9.904 ms
\n
\n

Those times are Node 26.9 on an M3 Max, calling the same zstd library workerd wraps, because workerd itself can't run the dictionary call until the fix ships. At level 6 the median page took 0.165 ms and the slowest 0.503 ms. Level 19 buys 3.9 more points and spends up to 9.904 ms on /reading, which is the whole allowance, so level 6 is where the curve bends.

\n

More than half of each level-6 call goes to the dictionary. Without it, the same call takes a median 0.072 ms. node:zlib has no way to keep a prepared dictionary between calls, so a Worker would load all 64 KB into a fresh context on every request.

\n\n

What it can't reach

\n

The homepage fetches two fragments after it loads, the photo grid and the now-playing list. Both are fetch() requests, so the dictionary never answers them, for the same reason the demo's second copy arrives whole.

\n

Forced to use it anyway on 2026-09-15, the grid came out at 1,605 bytes at level 6, against the 1,588 bytes of brotli it already ships. A fragment is mostly unique content, with little shell for a dictionary to match.

\n

The fix makes the delta possible and changes nothing here by itself. It still needs a review and a workerd release. The site's wrangler pin picks up a release within a day, and then the canary's control stops printing the same number three times.

\n

After that the Worker needs the dictionary bytes on hand, an ETag per encoding, Vary on Available-Dictionary, and a plain response for any dictionary it doesn't recognise.

\n\n

What testing the fix turned up

\n

The PR's first coverage report said 7% of its changed lines ran, which was mostly an artifact: Codecov counted the test file itself as untested code. Closing the real gap took four more tests and turned up three things.

\n\n\n

The road the site took anyway

\n

While this page was being written, the site moved two of the original 15 off the Worker's per-request path. /whoareyou and /garage/dyno now bake their frame at build time, so it ships as a delta today, and fetch the parts that change as a fragment (#924, #926).

\n

That road needs no workerd release and no CPU per request. It costs a second request and a placeholder that can't shift the layout, and the data arrives as a fragment, which the dictionary can't reach. On /garage/dyno the first visit came out even: 8,158 bytes across two requests, against 8,060 bytes in one.

\n

Most of the remaining 13 could take the same treatment, one page at a time. The runtime fix is the version that needs no second request, no placeholder and no per-page work, which is why it's worth finishing with the other road open.

\n\n

Why bother for 1.3 KB

\n

The objection writes itself: 1.3 KB is a rounding error next to a page of photos. For any single request that's fair.

\n

What changes the arithmetic is the dictionary itself. A returning visitor already paid for it, 14,754 bytes the first time, and today it earns nothing on the 13 pages the Worker writes. The fix lets one download work on every page the site serves, whoever wrote it.

", - "pageCss": ".dd-box{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:6px 0}\n.dd-row{display:grid;grid-template-columns:9em 6em 1fr 17em;gap:10px;align-items:center;padding:3px 10px;font-size:8.6pt;border-bottom:1px solid #e4eaf3}\n.dd-row:last-child{border-bottom:0}\n.dd-row.head{font-weight:700;background:#e8eef7}\n.dd-table .dd-row{grid-template-columns:12em repeat(3,1fr)}\n.dd-levels .dd-row{grid-template-columns:6em 7em 6em 1fr}\n.dd-path{font-family:var(--font-mono);font-size:8.4pt}\n.dd-raw,.dd-num{font-family:var(--font-mono);font-size:8.2pt;color:#3a4a5e}\n.dd-bar{display:flex;height:11px;border:1px solid #b7c3d4;background:#fff}\n.dd-bar .keep{background:#6f8fbf}\n.dd-bar .cut{background:#6fbf73}\n#dd-demo{padding:8px 12px;font-size:8.8pt}\n#dd-demo .n{font-family:var(--font-mono);font-weight:700}\n#dd-demo p{margin:4px 0}\n@media(max-width:620px){.dd-row,.dd-table .dd-row,.dd-levels .dd-row{grid-template-columns:1fr 1fr}.dd-bar{grid-column:1/-1}}", + "pageCss": ".dd-box{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:6px 0}\n.dd-row{display:grid;grid-template-columns:9em 6em 1fr 17em;gap:10px;align-items:center;padding:3px 10px;font-size:var(--text-micro);border-bottom:1px solid #e4eaf3}\n.dd-row:last-child{border-bottom:0}\n.dd-row.head{font-weight:700;background:#e8eef7}\n.dd-table .dd-row{grid-template-columns:12em repeat(3,1fr)}\n.dd-levels .dd-row{grid-template-columns:6em 7em 6em 1fr}\n.dd-path{font-family:var(--font-mono);font-size:var(--text-micro)}\n.dd-raw,.dd-num{font-family:var(--font-mono);font-size:var(--text-2xs);color:#3a4a5e}\n.dd-bar{display:flex;height:11px;border:1px solid #b7c3d4;background:#fff}\n.dd-bar .keep{background:#6f8fbf}\n.dd-bar .cut{background:#6fbf73}\n#dd-demo{padding:8px 12px;font-size:var(--text-xs)}\n#dd-demo .n{font-family:var(--font-mono);font-weight:700}\n#dd-demo p{margin:4px 0}\n@media(max-width:620px){.dd-row,.dd-table .dd-row,.dd-levels .dd-row{grid-template-columns:1fr 1fr}.dd-bar{grid-column:1/-1}}", "pageJs": "(function(){\n // Fetch this page a second time and compare the two transfers. The navigation\n // is destination \"document\", which the family dictionary matches; fetch() is\n // destination \"empty\", which it never does. Same bytes, so any difference in\n // encodedBodySize is the dictionary at work.\n var out = document.getElementById(\"dd-demo\");\n if (!out || !window.performance || !performance.getEntriesByType) return;\n var fmt = function(x){ return x.toLocaleString(\"en-US\") + \" B\"; };\n var line = function(label, e){ return \"

\" + label + \": \" + fmt(e.encodedBodySize) + \" on the wire for \" + fmt(e.decodedBodySize) + \" of HTML

\"; };\n function run(){\n var nav = performance.getEntriesByType(\"navigation\")[0];\n if (!nav || !nav.decodedBodySize) { out.textContent = \"This browser doesn't report response sizes for the page, so there's nothing to compare.\"; return; }\n if (!nav.encodedBodySize) { out.textContent = \"This page came out of your browser's cache, so nothing crossed the wire. Reload to measure it.\"; return; }\n fetch(location.pathname, { cache: \"no-store\" }).then(function(r){ return r.arrayBuffer(); }).then(function(){\n var mine = performance.getEntriesByType(\"resource\").filter(function(e){ return e.initiatorType === \"fetch\" && new URL(e.name).pathname === location.pathname; }).pop();\n if (!mine || !mine.encodedBodySize) { out.textContent = \"The second request didn't report its size, so there's nothing to compare.\"; return; }\n var gap = mine.encodedBodySize - nav.encodedBodySize;\n var verdict = gap > 200\n ? \"Your browser held the dictionary, and it saved \" + fmt(gap) + \" (\" + Math.round(100 * gap / mine.encodedBodySize) + \"%) on this page.\"\n : \"Both copies arrived about the same size, so your browser sent no dictionary this time. That happens on a first visit, since the dictionary loads while the browser is idle, and in browsers without compression dictionaries. Open another page here, come back, and reload.\";\n out.innerHTML = line(\"As the document\", nav) + line(\"As a fetch()\", mine) + \"

\" + verdict + \"

\";\n }).catch(function(){ out.textContent = \"The second request failed, so there's nothing to compare.\"; });\n }\n if (document.readyState === \"complete\") setTimeout(run, 0); else addEventListener(\"load\", function(){ setTimeout(run, 0); });\n})();", "editorial": { "reader": "Someone building on Workers who ships shared-dictionary deltas from a build and wonders whether the Worker could make them too.", diff --git a/pipelines/garage/specs/hidden-flags.json b/pipelines/garage/specs/hidden-flags.json index 9cecd3a8c..45a5a63e7 100644 --- a/pipelines/garage/specs/hidden-flags.json +++ b/pipelines/garage/specs/hidden-flags.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-08-24", "bodyHtml": "

Flags the docs don't list

\n

Wrangler 4.125.0 carries nineteen flags marked hidden, so they appear in no help output and no documentation page. Reading them is easy. The hard part is that an option a tool silently ignores and an option it honours produce the same exit code, the same output, and the same success. This page is about the control that separates those two, why it usually costs nothing, and four times the difference decided something real on this site.

\n\n

The inventory

\n

Every flag below was pulled out of the shipped bundle by matching option definitions that carry hidden: true, then read back with its own description. Filter by what it is for.

\n
\n
\n \n \n
\n
\n

Marked rows are the ones that touch this site. The rest are here so the list is the whole list: a survey that reports only the interesting rows cannot tell you how many rows it skipped.

\n
\n\n

Two questions, and only one of them is free

\n

When you find an undocumented option, there are two separate things to establish, and reading the docs answers neither.

\n

Is it accepted? Does the tool recognise the key at all, or is it dropping an unknown name on the floor. Does it work? Does accepting the key change the behaviour the flag claims to change.

\n

The first question is usually free, and that is the part people skip. A tool with strict argument parsing answers it through its exit code alone, with no work performed and nothing deployed:

\n
$ wrangler deploy --x-bogus-flag ; echo $?\n1\n$ wrangler deploy --x-provision=false --dry-run ; echo $?\n0
\n

Two commands, no account touched, and the answer is decisive: wrangler validates flag names, so a flag it accepts is a flag it knows. Run that before trusting any option a help page omits.

\n

The second question costs a real run, and its answer is worth much less than it looks like when the failure mode is silence.

\n\n

When silence is the failure mode

\n

Four cases from this site, each of which cost time in proportion to how late the control was run.

\n\n

A payload schema that refuses unknown keys

\n

Cloudflare's Browser Run binding takes a JSON payload. A launch post described selecting a rendering engine with one extra key; the Quick Actions reference did not list it. Sending it answered:

\n
{\"code\":\"unrecognized_keys\",\"keys\":[\"browser\"]}
\n

An invented engine name returned the byte-identical error, which is the useful half of the result. The schema is closed, so it refuses the option rather than failing on the value, and a closed schema is a gift: it answers \"is this accepted\" for free, every time, with no render performed and no browser minutes spent.

\n

The same feature then produced the opposite trap through the REST door, where the parameter is merely ignored. A 200 came back either way, so the code labelled the render with the engine it had asked for. On a page whose entire premise is showing what a machine actually saw, that label was a claim nobody had checked. It reads kitesurf-requested now.

\n\n

A compression option three runtimes disagree about

\n

Dictionary compression takes a dictionary option. Node 26 honours it. Workers ignore it for zstd and say nothing. Bun ignored it through 1.3.14 and fixed it in oven-sh/bun#34427. Every one of those failures is silent, because a frame compressed without a dictionary still decodes perfectly with one. Nothing throws. The only symptom is a byte count that never got smaller.

\n

The control is four lines and it needs no deploy: compress one target three times, with no dictionary, with the right one, and with a deliberately wrong one. An engine that honours the option prints a smaller number for the right dictionary alone. An engine that ignores it prints the same number three times.

\n
\n \n \n \n \n
engineno dictionaryright dictionaryverdict
node 2670 B18 Bhonours it
workerdsame number three timesignores it
bun through 1.3.14same number three timesignored it
\n

Sending the option and reading the exit code would have passed on all three.

\n\n

A key that is accepted and still unmeasured

\n

The Browser Run payload takes a waitForTimeout. Whether the binding accepts the key was answered for free, using the closed schema above: send it alongside a deliberately invalid URL, and a bogus key comes back as unrecognized_keys while an accepted one leaves only the URL error. Three keys passed that test individually and together.

\n

Whether the capture actually waits out the timeout is a different question, it needs a real render, and it is still open. Both halves are written down separately for that reason. An entry claiming the key works, on evidence that only shows it is accepted, is the exact error this page is about.

\n\n

A path that routes either way

\n

The engine selector above only functions on one of two URL spellings. Both route. Probed unauthenticated against a real account, each answers 10000 Authentication error rather than 7003 Could not route to. So the wrong path costs no error, no retry, and no log line. It costs the feature. One reference page documents the selector on the newer spelling while older pages show the other, so reading either one alone hands you a path that looks right.

\n\n

A flag that proves the limit is somewhere else

\n

Among the nineteen is --max-versions, on the command that shifts production traffic between Worker versions. This site's release script walks a version to 10%, then 50%, then 100%, and it can only ever name two versions in a step, which shapes the whole procedure: a ramp starting from a three-way split has to drop one of them. The obvious hope is that a hidden flag raises the ceiling. The definition answers it without a single request:

\n
\"max-versions\": {\n  hidden: true,\n  // experimental, not supported long-term\n  describe: \"Maximum allowed versions to select\",\n  type: \"number\",\n  default: 2\n  // (when server-side limitation is lifted, we can update this\n  //  default or just remove the option entirely)\n}
\n

The comment is the finding. The cap lives on the server and this flag is the client's mirror of it, so raising the number locally buys an error instead of a wider split. That is a real answer, arrived at by reading rather than by deploying, and it retires a question that had been open on the strength of a guess.

\n\n

A flag that looks like the fix and is not one

\n

This site has a documented failure where a gradual deployment splits asset requests: a document served by one version asks for a content-hashed asset only the other version built, and gets a 404. So --old-asset-ttl, described as \"expire old assets in given seconds rather than immediate deletion\", reads like the exact repair.

\n

It is threaded into one place in the entire codebase, and that place is the sync routine for the legacy Workers Sites layer, the retired KV-namespace mechanism that predates the modern assets binding this site uses. The flag would apply to a system that is not running here.

\n

Fifteen minutes of reading, and the answer arrives before any config changes. Had it been adopted on the strength of its description, it would have shipped, changed nothing, and become the fix everyone believed was already in place.

\n\n

Pick a situation, get the control

\n

The shape of the doubt decides the experiment. Choose what you are looking at:

\n
\n
\n
\n
\n\n

The rule this leaves

\n

On a closed schema, \"is this accepted\" and \"does this work\" are two questions, and the first one is free. Ask it first, always, because it costs an exit code and it eliminates the most common way an undocumented option wastes a week: being quietly dropped while everything around it reports success.

\n

Then be careful about what a success proves. A 200 from an endpoint that ignores unknown parameters is indistinguishable from a 200 from an endpoint that honoured yours. When you cannot tell those apart, the honest move is to label the result with what you asked for rather than with what you hope happened, and to write the unmeasured half down beside the measured one.

\n

And prefer reading the definition to running the experiment when the definition is in your node_modules. Two of the four cases here were settled by a source comment and a call site, in less time than a deploy takes, and neither answer could have been reached by watching the tool succeed.

\n\n

What would change this

\n

A tool that accepts unknown flags silently would break the free half outright, and the check for that is the bogus-flag control itself: an exit code of 0 on an invented name means every conclusion on this page has to be re-earned with real runs. A hidden flag becoming documented would move its row from this list to an ordinary reference, which is the outcome to hope for rather than a defeat. And a measurement showing the capture does honour waitForTimeout would close the one case here that is deliberately still open.

", - "pageCss": ".hf-code{background:#f4f6fa;border:1px solid #c8d2e2;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:9pt;line-height:1.5}\n.hf-code code{white-space:pre}\n.hf-demo{border:1px solid #9aa7ba;background:#f6f8fb;margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.hf-bar{background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:5px 8px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}\n.hf-filters{display:inline-flex;gap:4px;flex-wrap:wrap}\n.hf-filters button{font:inherit;font-size:8pt;padding:2px 8px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,#e6eaf1);border-radius:3px;cursor:pointer;color:#33415c}\n.hf-filters button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.hf-count{font-size:8.5pt;color:#5c6779}\n.hf-scroll{overflow-x:auto;background:#fff}\n.hf-table{border-collapse:collapse;font-size:8.5pt;width:100%;min-width:560px}\n.hf-table th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:#33415c;position:sticky;top:0}\n.hf-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top;line-height:1.45}\n.hf-table tr:nth-child(even) td{background:#f8fafd}\n.hf-table code{font-size:8pt;font-family:var(--font-mono)}\n.hf-here td{background:#fdf6e0 !important;box-shadow:inset 3px 0 0 #e0a72c}\n.hf-tag{display:inline-block;font-size:7.5pt;padding:0 5px;border-radius:8px;background:#dde5f2;color:#3d4d68;white-space:nowrap}\n.hf-static{margin:0 0 12px}\n.hf-ok{color:#1c6b32;font-weight:bold}\n.hf-no{color:#a3261a;font-weight:bold}\n.hf-case{padding:10px 12px;background:#fff;font-size:9pt;line-height:1.55}\n.hf-case h4{font-family:var(--font-caption);color:#0831d9;font-size:10.5pt;margin:0 0 5px}\n.hf-case pre{background:#f4f6fa;border:1px solid #c8d2e2;padding:7px 9px;margin:0 0 8px;overflow-x:auto;font-size:8.5pt}\n.hf-case pre code{white-space:pre;font-family:var(--font-mono)}\n.hf-case table{border-collapse:collapse;font-size:8.5pt;width:100%;margin:0 0 6px}\n.hf-case th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:3px 7px;color:#33415c}\n.hf-case td{border:1px solid #d8dfeb;padding:3px 7px;vertical-align:top}\n.hf-cost{font-size:8.5pt;color:#5c6779;margin:0}\n@media(max-width:620px){.hf-table{min-width:460px}}", + "pageCss": ".hf-code{background:#f4f6fa;border:1px solid #c8d2e2;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:var(--text-xs);line-height:1.5}\n.hf-code code{white-space:pre}\n.hf-demo{border:1px solid #9aa7ba;background:#f6f8fb;margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.hf-bar{background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:5px 8px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}\n.hf-filters{display:inline-flex;gap:4px;flex-wrap:wrap}\n.hf-filters button{font:inherit;font-size:var(--text-2xs);padding:2px 8px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,#e6eaf1);border-radius:3px;cursor:pointer;color:#33415c}\n.hf-filters button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.hf-count{font-size:var(--text-micro);color:#5c6779}\n.hf-scroll{overflow-x:auto;background:#fff}\n.hf-table{border-collapse:collapse;font-size:var(--text-micro);width:100%;min-width:560px}\n.hf-table th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:#33415c;position:sticky;top:0}\n.hf-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top;line-height:1.45}\n.hf-table tr:nth-child(even) td{background:#f8fafd}\n.hf-table code{font-size:var(--text-2xs);font-family:var(--font-mono)}\n.hf-here td{background:#fdf6e0 !important;box-shadow:inset 3px 0 0 #e0a72c}\n.hf-tag{display:inline-block;font-size:var(--text-3xs);padding:0 5px;border-radius:8px;background:#dde5f2;color:#3d4d68;white-space:nowrap}\n.hf-static{margin:0 0 12px}\n.hf-ok{color:#1c6b32;font-weight:bold}\n.hf-no{color:#a3261a;font-weight:bold}\n.hf-case{padding:10px 12px;background:#fff;font-size:var(--text-xs);line-height:1.55}\n.hf-case h4{font-family:var(--font-caption);color:#0831d9;font-size:var(--text-body);margin:0 0 5px}\n.hf-case pre{background:#f4f6fa;border:1px solid #c8d2e2;padding:7px 9px;margin:0 0 8px;overflow-x:auto;font-size:var(--text-micro)}\n.hf-case pre code{white-space:pre;font-family:var(--font-mono)}\n.hf-case table{border-collapse:collapse;font-size:var(--text-micro);width:100%;margin:0 0 6px}\n.hf-case th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:3px 7px;color:#33415c}\n.hf-case td{border:1px solid #d8dfeb;padding:3px 7px;vertical-align:top}\n.hf-cost{font-size:var(--text-micro);color:#5c6779;margin:0}\n@media(max-width:620px){.hf-table{min-width:460px}}", "pageJs": "// /garage/hidden-flags — the inventory and the control picker.\n//\n// FLAGS is the full set of option definitions carrying `hidden: true` in the\n// wrangler 4.125.0 bundle, read back with each flag's own describe string. The\n// list is complete rather than curated: rows that matter here are marked, and\n// the rest stay so the count is honest.\n(function () {\n var FLAGS = [\n { f: \"--experimental-new-config\", a: \"--x-new-config\", g: \"config\", here: true,\n d: \"[experimental] Use cloudflare.config.ts and wrangler.config.ts\" },\n { f: \"--experimental-cf-build-output\", a: \"--x-cf-build-output\", g: \"config\", here: true,\n d: \"[experimental] Emit a Build Output Specification directory under .cloudflare/output/ (requires --experimental-new-config)\" },\n { f: \"--experimental-auto-create\", a: \"--x-auto-create\", g: \"release\", here: true,\n d: \"Automatically provision draft bindings with new resources\" },\n { f: \"--experimental-provision\", a: \"\", g: \"release\", here: true,\n d: \"No description in the definition. Pinned off here alongside auto-create, because no deploy path on this account may create resources.\" },\n { f: \"--max-versions\", a: \"\", g: \"release\", here: true,\n d: \"Maximum allowed versions to select. Defaults to 2, and its own comment says the limit is server side.\" },\n { f: \"--experimental-deploy-helpers\", a: \"--x-deploy-helpers\", g: \"release\", here: false,\n d: \"Experimental: Gates refactored deploy/upload path\" },\n { f: \"--experimental-include-runtime\", a: \"--x-include-runtime\", g: \"types\", here: false,\n d: \"The path of the generated runtime types file\" },\n { f: \"--enable-containers\", a: \"\", g: \"containers\", here: false,\n d: \"Whether to build and enable containers during development\" },\n { f: \"--config-file\", a: \"-F\", g: \"containers\", here: false,\n d: \"Sets ssh -F: specify an alternative per-user ssh configuration file\" },\n { f: \"--identity-file\", a: \"-i\", g: \"containers\", here: false,\n d: \"Sets ssh -i: select a file from which the identity for public key authentication is read\" },\n { f: \"--escape-char\", a: \"\", g: \"containers\", here: false,\n d: \"Sets ssh -e: set the escape character for sessions with a pty\" },\n { f: \"--log-file\", a: \"\", g: \"containers\", here: false,\n d: \"Sets ssh -E: append debug logs to log_file instead of standard error\" },\n { f: \"--mac-spec\", a: \"\", g: \"containers\", here: false,\n d: \"Sets ssh -m: a comma separated list of message authentication code algorithms, in order of preference\" },\n { f: \"--delegate-c3\", a: \"--c3\", g: \"setup\", here: false, d: \"Delegate to Create Cloudflare CLI (C3)\" },\n { f: \"--install-wrangler\", a: \"\", g: \"setup\", here: false, d: \"Install Wrangler during project setup\" },\n { f: \"--completion-message\", a: \"\", g: \"setup\", here: false,\n d: \"Display a message with deployment details after wrangler setup is complete\" },\n { f: \"--node-compat\", a: \"\", g: \"legacy\", here: false, d: \"Enable Node.js compatibility\" },\n { f: \"--site-include\", a: \"\", g: \"legacy\", here: false,\n d: \"Workers Sites: gitignore style patterns; only matched items will be uploaded\" },\n { f: \"--site-exclude\", a: \"\", g: \"legacy\", here: false,\n d: \"Workers Sites: gitignore style patterns; matched items are skipped\" },\n ];\n\n var GROUPS = [\n { k: \"all\", label: \"all 19\" },\n { k: \"here\", label: \"touches this site\" },\n { k: \"release\", label: \"release path\" },\n { k: \"config\", label: \"config\" },\n { k: \"containers\", label: \"containers\" },\n { k: \"setup\", label: \"setup\" },\n { k: \"legacy\", label: \"legacy\" },\n { k: \"types\", label: \"types\" },\n ];\n\n var CASES = [\n {\n k: \"ignored\",\n label: \"the option may be silently ignored\",\n title: \"Run it three ways and compare the number\",\n body: \"An engine that ignores an option produces output identical to one that never received it, and the output still works. So do not ask whether it succeeded. Ask whether the thing the option claims to change actually changed, against a case where it could not have.\",\n code: \"compress(target) # no option\\ncompress(target, opt: correct) # the option, correct value\\ncompress(target, opt: wrong) # the option, useless value\",\n table: [\n [\"all three identical\", \"the option is being dropped\"],\n [\"correct one smaller, wrong one matches the first\", \"the option is honoured\"],\n [\"correct and wrong both differ from the first\", \"something reads the option and your model of it is off\"],\n ],\n cost: \"Cost: three local runs, no deploy. This is the control that separates node, workerd and bun on the same option.\",\n },\n {\n k: \"closed\",\n label: \"the schema may refuse unknown keys\",\n title: \"Send a key you invented, next to a request that fails anyway\",\n body: \"A closed schema validates the shape before it does any work, which makes it the cheapest oracle you will ever get. Pair the probe with a deliberately invalid input so nothing can execute even if the key is accepted.\",\n code: 'send({ url: \"not-a-url\", definitely_not_a_key_xyz: 1 })\\nsend({ url: \"not-a-url\", theKeyYouAreUnsure: 1 })',\n table: [\n [\"first names your bogus key, second reports only the url error\", \"the key is accepted, and the schema is closed\"],\n [\"both report only the url error\", \"the schema is open, so acceptance proves nothing\"],\n [\"both name the bogus key\", \"your unsure key is being rejected too\"],\n ],\n cost: \"Cost: two rejected requests. Nothing renders, nothing is billed, and it answers acceptance completely.\",\n },\n {\n k: \"undocumented\",\n label: \"the docs skip the failure mode\",\n title: \"Read the exit code on a name that cannot exist\",\n body: \"Argument parsers either validate names or ignore them. One invented flag tells you which, and it tells you before you have trusted a real one.\",\n code: \"tool command --x-bogus-flag ; echo $?\\ntool command --the-real-flag=false --dry-run ; echo $?\",\n table: [\n [\"1 then 0\", \"names are validated, so an accepted flag is a known flag\"],\n [\"0 then 0\", \"unknown names are ignored, and acceptance means nothing at all\"],\n [\"1 then 1\", \"the real flag is being rejected too, so check the spelling and the subcommand\"],\n ],\n cost: \"Cost: one exit code. Pair it with --dry-run so the second command performs no work.\",\n },\n {\n k: \"routing\",\n label: \"two paths both seem to work\",\n title: \"Compare the errors, rather than the successes\",\n body: \"When an old and a new URL spelling both respond, a success on either one proves nothing about which is live. The distinguishing signal lives in what each one says when it refuses, and an unauthenticated request is enough to get it.\",\n code: \"curl -s https://api/OLD-SPELLING/action # unauthenticated\\ncurl -s https://api/NEW-SPELLING/action # unauthenticated\",\n table: [\n [\"both report an auth error\", \"both spellings route, so pick the one the feature's own page documents\"],\n [\"one reports could-not-route\", \"that spelling is dead and the choice is made for you\"],\n [\"one reports auth and the other 404s\", \"same conclusion, through a different status\"],\n ],\n cost: \"Cost: two unauthenticated requests. No credential, so this one is safe to run against somebody else's API.\",\n },\n ];\n\n var filters = document.getElementById(\"hf-filters\");\n var table = document.getElementById(\"hf-table\");\n var count = document.getElementById(\"hf-count\");\n var cases = document.getElementById(\"hf-cases\");\n var caseBox = document.getElementById(\"hf-case\");\n var active = \"all\";\n\n function matches(row) {\n if (active === \"all\") return true;\n if (active === \"here\") return row.here;\n return row.g === active;\n }\n\n function drawTable() {\n if (!table) return;\n table.textContent = \"\";\n var head = document.createElement(\"tr\");\n [\"flag\", \"alias\", \"group\", \"what it says it does\"].forEach(function (h) {\n var th = document.createElement(\"th\");\n th.textContent = h;\n head.appendChild(th);\n });\n table.appendChild(head);\n var shown = 0;\n FLAGS.forEach(function (row) {\n if (!matches(row)) return;\n shown++;\n var tr = document.createElement(\"tr\");\n if (row.here) tr.className = \"hf-here\";\n var c1 = document.createElement(\"td\");\n var code = document.createElement(\"code\");\n code.textContent = row.f;\n c1.appendChild(code);\n var c2 = document.createElement(\"td\");\n if (row.a) {\n var al = document.createElement(\"code\");\n al.textContent = row.a;\n c2.appendChild(al);\n }\n var c3 = document.createElement(\"td\");\n var tag = document.createElement(\"span\");\n tag.className = \"hf-tag\";\n tag.textContent = row.g;\n c3.appendChild(tag);\n var c4 = document.createElement(\"td\");\n c4.textContent = row.d;\n [c1, c2, c3, c4].forEach(function (c) { tr.appendChild(c); });\n table.appendChild(tr);\n });\n if (count) {\n count.textContent = shown === FLAGS.length\n ? shown + \" hidden flags\"\n : shown + \" of \" + FLAGS.length + \" hidden flags\";\n }\n }\n\n if (filters) {\n GROUPS.forEach(function (g) {\n var btn = document.createElement(\"button\");\n btn.type = \"button\";\n btn.textContent = g.label;\n if (g.k === active) btn.className = \"on\";\n btn.addEventListener(\"click\", function () {\n active = g.k;\n Array.prototype.forEach.call(filters.children, function (b) { b.classList.toggle(\"on\", b === btn); });\n drawTable();\n });\n filters.appendChild(btn);\n });\n }\n\n function drawCase(c) {\n if (!caseBox) return;\n caseBox.textContent = \"\";\n var h = document.createElement(\"h4\");\n h.textContent = c.title;\n var p = document.createElement(\"p\");\n p.textContent = c.body;\n var pre = document.createElement(\"pre\");\n var code = document.createElement(\"code\");\n code.textContent = c.code;\n pre.appendChild(code);\n var tbl = document.createElement(\"table\");\n var head = document.createElement(\"tr\");\n [\"what you see\", \"what it means\"].forEach(function (t) {\n var th = document.createElement(\"th\");\n th.textContent = t;\n head.appendChild(th);\n });\n tbl.appendChild(head);\n c.table.forEach(function (pair) {\n var tr = document.createElement(\"tr\");\n pair.forEach(function (cell) {\n var td = document.createElement(\"td\");\n td.textContent = cell;\n tr.appendChild(td);\n });\n tbl.appendChild(tr);\n });\n var cost = document.createElement(\"p\");\n cost.className = \"hf-cost\";\n cost.textContent = c.cost;\n [h, p, pre, tbl, cost].forEach(function (n) { caseBox.appendChild(n); });\n }\n\n if (cases) {\n CASES.forEach(function (c, i) {\n var btn = document.createElement(\"button\");\n btn.type = \"button\";\n btn.textContent = c.label;\n if (i === 0) btn.className = \"on\";\n btn.addEventListener(\"click\", function () {\n Array.prototype.forEach.call(cases.children, function (b) { b.classList.toggle(\"on\", b === btn); });\n drawCase(c);\n });\n cases.appendChild(btn);\n });\n drawCase(CASES[0]);\n }\n\n drawTable();\n})();", "editorial": { "reader": "Someone who has found an undocumented flag or config key and is deciding whether to trust it, having read a description that says what it does and nothing about how it fails.", diff --git a/pipelines/garage/specs/htmx.json b/pipelines/garage/specs/htmx.json index 6e6656d03..79522b578 100644 --- a/pipelines/garage/specs/htmx.json +++ b/pipelines/garage/specs/htmx.json @@ -5,7 +5,7 @@ "status": "watching", "added": "2026-08-28", "bodyHtml": "

Break-even at three submits

\n

htmx shipped 4.0.0 on the day this page was written, and it is a real improvement on 2.x: 36,716 bytes minified against 50,917, with the history cache dropped and XMLHttpRequest traded for fetch. The size is the easy question. The one that decides adoption is how much of your existing JavaScript the library can delete, and that turns on whether your code is already shaped like a request and a swap. This site's mostly isn't. So the measurement went to the one page where htmx should have won, and it lost by a factor of three.

\n\n

The library is priced per visitor, never per repo

\n

A hypermedia library moves rendering to the server and sends HTML fragments back. That trade is good when a page holds many interactions, because the download amortizes across all of them. It is bad when a page holds one, because the visitor pays the whole library to save one response.

\n

Which means the honest unit is bytes per visitor rather than bytes on disk, and the number you need is the count of swaps a single visitor performs. Everything below is that arithmetic, run on real bytes.

\n\n

The page htmx was built for

\n

This site has 54 documents and 17 client modules, and almost none of it fetches HTML. quiz.js makes zero network requests. So do hoist.js, infotip.js, notepad.js and nav-tray.js. The modules that do fetch read JSON and paint structured interfaces from it, which htmx has no opinion about.

\n

One surface is different. /coffee is a booking form: two plain method=\"POST\" forms, server-rendered responses, 874 bytes of inline script for the slot picker. Submitting it reloads the document. That is the shape htmx was designed for, so that is where the experiment went.

\n\n

What was built

\n

htmx 4.0.0 vendored as a client asset, loaded on the booking page alone rather than on the shared shell, so the confirmation and decline pages pay nothing. The form keeps its method and action as the no-JavaScript fallback and gains three attributes:

\n
<form class=\"book\" id=\"bookform\" method=\"POST\" action=\"/coffee/book\" novalidate\n      hx-post=\"/coffee/book\" hx-swap=\"outerHTML\" hx-disabled-elt=\"#submit\">
\n

The Worker grew a branch that answers the body alone when htmx asks for it, and the full document otherwise, which is what a crawler and a visitor with JavaScript off still receive:

\n
function bookingReply(req, env) {\n  return req.headers.get(\"HX-Request\")\n    ? new Response(successFragment(env), { headers: htmlHeaders() })\n    : new Response(successPage(env), { headers: htmlHeaders() });\n}
\n

The build passed with no changes to it. The minifier left htmx byte-identical, since it arrives minified.

\n\n

The diff

\n

Both trees went through bun run perf:snapshot, which self-builds through the wrangler dry run and reports Brotli q11, the quality the edge serves. Base is 3f9f2f9f. Every number here is deterministic, so an unchanged file produces no row at all.

\n
\n \n \n \n \n \n \n \n \n
categorybaseheaddelta
client assets, Brotli86.35 KiB97.81 KiB+11.46 KiB, +13.3%
htmx.jsabsent11.46 KiBnew
Worker bundle, gzip239.88 KiB239.97 KiB+0.09 KiB
pages, Brotli571.62 KiB across 54 filesno change
dictionary deltas110 deltas, 498.41 KiBno change
\n

The page bytes hold still because /coffee is rendered by the Worker rather than staged as a file, so its markup lives in the bundle. The whole cost is one new asset.

\n\n

What the swap actually buys

\n

This is htmx doing its job well. The confirmation response stops being a document and becomes a pane:

\n
\n \n \n \n \n \n \n
POST /coffee/bookrawgzipBrotli q11
full page, today17,0015,1014,187
htmx fragment515291219
saved per submit16,4864,8103,968
\n

95% off the wire on that response. If the argument were only about response size, htmx would win this page outright.

\n\n

The arithmetic that closes it

\n

11,735 bytes of library against 3,968 bytes saved per submit is a break-even at 2.96 submits per visitor. A booking form is submitted at most once, so the third submit never arrives.

\n

Move the sliders. The library is a first-visit cost, because the asset is cached afterwards, and the saving lands on every submit whether or not the visitor is new. Those two facts are the whole model, and they are enough to find the region where htmx wins.

\n
\n
\n \n \n \n \n \n
\n
\n
\n
library
\n
saved
\n
\n

\n

Per 1,000 visits, Brotli bytes. Library cost is 11,735 per first visit; saving is 3,968 per submit. The booking rate would have to pass the crossover for htmx to pay for itself at the current repeat rate.

\n
\n
\n

The default positions are a guess about a personal booking page, and they are the part of this page you should distrust most. The site records no funnel for /coffee, so the 4% is an estimate rather than a measurement. Drag it and see how far it has to go.

\n\n

Three reasons the rest of the site is worse

\n

The booking page is htmx's best case here. The surfaces that carry the actual JavaScript are worse, for reasons that have nothing to do with the library's quality.

\n

Rendering would move onto the scarcest resource

\n

htmx returns HTML fragments, so every swap is Worker CPU. This account runs on the Workers free tier, where CPU under sustained load is what has actually taken surfaces down here: a signature fan-out across 28 probes clamped requests at 10 ms and blacked out the lens for a window in August. Client rendering off a payload already in memory costs the platform nothing.

\n

The caching model runs the other way

\n

The photo tooltips read /images/exif.json, which is 55,674 bytes raw and 2,764 Brotli, warmed once when the browser goes idle, and it carries the metadata for all 158 photographs. Under htmx that becomes 158 separate fragment requests, one per hover, each one a Worker invocation and a round trip. The site front-loads bytes into assets that go nearly free on the second visit, which is the opposite bet.

\n

Boosted navigation is slower than what already ships

\n

Every document here names a speculation ruleset at moderate eagerness in a Speculation-Rules header, so the next page is rendered before the click lands. hx-boost is a fetch on click, which is strictly later. The cross-document View Transition was removed from this site in July for the same reason. htmx 4 also drops the history cache and refetches on back navigation, where the browser's own bfcache restores the page with its state in place.

\n\n

What the snapshot cannot see

\n

A wire-size diff measures files. It cannot measure round trips, and round trips are where the argument above actually lives. So the diff proves the narrow claim, which is that the library is additive on this site's one hypermedia-shaped page, and the three reasons remain reasoning rather than measurement.

\n

Two smaller things the experiment left on the table. htmx landed as a plain top-level asset, outside the content-hashed /a/ tier, so it received no shared-compression dictionary and the delta count held at 110. Giving it that treatment would make repeat visits nearly free and leave the first visit exactly where it is. And the inline slot picker survived untouched, because an aria-pressed toggle and a toLocaleString button label are not requests. Lines of JavaScript this deleted: zero.

\n\n

What would change the verdict

\n

A surface on this site with several swaps per visitor. That is the whole condition. A multi-step form, a filterable list somebody actually pages through, a comment thread: any of those move the submits-per-visitor number above three and the arithmetic flips on its own.

\n

The finding that would falsify the page is a measurement showing the fragment saving is larger than 3,968 bytes on a real interaction, or a Worker rendering cost near zero under load. The first is easy to imagine on a heavier page than this one. The second is the claim the free-tier CPU ceiling makes hard.

", - "pageCss": ".hb-code{background:#f4f6fa;border:1px solid #c9d4e4;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:9pt;line-height:1.45}\n.hb-code code{white-space:pre}\n.hb-scroll{overflow-x:auto;margin:0 0 12px}\n.hb-table{border-collapse:collapse;width:100%;font-size:9.5pt}\n.hb-table th,.hb-table td{border:1px solid #c9d4e4;padding:4px 8px;text-align:right}\n.hb-table th:first-child,.hb-table td:first-child{text-align:left}\n.hb-table thead th{background:#dce6f5;font-family:var(--font-caption);color:#0831d9}\n.hb-table tbody tr:nth-child(even){background:#f6f8fc}\n.hb-table .hb-total td{font-weight:bold;border-top:2px solid #8d9bb0}\n.hb-bad{color:#a8330f}\n.hb-dim{color:#69758a}\n.hb-demo{border:1px solid #8d9bb0;background:#f1f4f8;padding:12px;margin:0 0 14px;box-shadow:inset 1px 1px 0 #fff,inset -1px -1px 0 #b6c0d0}\n.hb-controls{display:grid;gap:4px;margin-bottom:12px}\n.hb-controls label{font-size:9pt;color:#3a4657;display:flex;justify-content:space-between;align-items:baseline}\n.hb-controls output{font-family:var(--font-mono);font-weight:bold;color:#0831d9}\n.hb-controls input[type=range]{width:100%;margin:0 0 6px}\n#hb-reset{justify-self:start}\n.hb-bars{display:grid;gap:5px;margin-bottom:8px}\n.hb-bar-row{display:grid;grid-template-columns:56px 1fr 92px;align-items:center;gap:8px;font-size:9pt}\n.hb-bar-label{color:#3a4657}\n.hb-bar{height:15px;border:1px solid #7f8ea3;min-width:1px;transition:width var(--xp-transition-move)}\n.hb-bar-cost{background:linear-gradient(180deg,#e8967c,#c9501f)}\n.hb-bar-save{background:linear-gradient(180deg,#8fd08a,#2f8b3f)}\n.hb-bar-num{font-family:var(--font-mono);font-size:8.5pt;text-align:right;color:#3a4657}\n.hb-verdict{font-family:var(--font-caption);font-size:11pt;margin:0 0 6px;padding:6px 8px;border:1px solid #b6c0d0;background:#fff}\n.hb-verdict.hb-win{border-color:#2f8b3f;background:#eefaee;color:#1f6b2c}\n.hb-verdict.hb-lose{border-color:#c9501f;background:#fdf1ec;color:#8c3410}\n.hb-legend{font-size:8.5pt;color:#69758a;margin:0}\n@media(max-width:620px){.hb-bar-row{grid-template-columns:48px 1fr 78px}}", + "pageCss": ".hb-code{background:#f4f6fa;border:1px solid #c9d4e4;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:var(--text-xs);line-height:1.45}\n.hb-code code{white-space:pre}\n.hb-scroll{overflow-x:auto;margin:0 0 12px}\n.hb-table{border-collapse:collapse;width:100%;font-size:var(--text-sm)}\n.hb-table th,.hb-table td{border:1px solid #c9d4e4;padding:4px 8px;text-align:right}\n.hb-table th:first-child,.hb-table td:first-child{text-align:left}\n.hb-table thead th{background:#dce6f5;font-family:var(--font-caption);color:#0831d9}\n.hb-table tbody tr:nth-child(even){background:#f6f8fc}\n.hb-table .hb-total td{font-weight:bold;border-top:2px solid #8d9bb0}\n.hb-bad{color:#a8330f}\n.hb-dim{color:#69758a}\n.hb-demo{border:1px solid #8d9bb0;background:#f1f4f8;padding:12px;margin:0 0 14px;box-shadow:inset 1px 1px 0 #fff,inset -1px -1px 0 #b6c0d0}\n.hb-controls{display:grid;gap:4px;margin-bottom:12px}\n.hb-controls label{font-size:var(--text-xs);color:#3a4657;display:flex;justify-content:space-between;align-items:baseline}\n.hb-controls output{font-family:var(--font-mono);font-weight:bold;color:#0831d9}\n.hb-controls input[type=range]{width:100%;margin:0 0 6px}\n#hb-reset{justify-self:start}\n.hb-bars{display:grid;gap:5px;margin-bottom:8px}\n.hb-bar-row{display:grid;grid-template-columns:56px 1fr 92px;align-items:center;gap:8px;font-size:var(--text-xs)}\n.hb-bar-label{color:#3a4657}\n.hb-bar{height:15px;border:1px solid #7f8ea3;min-width:1px;transition:width var(--xp-transition-move)}\n.hb-bar-cost{background:linear-gradient(180deg,#e8967c,#c9501f)}\n.hb-bar-save{background:linear-gradient(180deg,#8fd08a,#2f8b3f)}\n.hb-bar-num{font-family:var(--font-mono);font-size:var(--text-micro);text-align:right;color:#3a4657}\n.hb-verdict{font-family:var(--font-caption);font-size:var(--text-lede);margin:0 0 6px;padding:6px 8px;border:1px solid #b6c0d0;background:#fff}\n.hb-verdict.hb-win{border-color:#2f8b3f;background:#eefaee;color:#1f6b2c}\n.hb-verdict.hb-lose{border-color:#c9501f;background:#fdf1ec;color:#8c3410}\n.hb-legend{font-size:var(--text-micro);color:#69758a;margin:0}\n@media(max-width:620px){.hb-bar-row{grid-template-columns:48px 1fr 78px}}", "pageJs": "(function () {\n // Two constants, both measured, both Brotli q11 because that is what the edge\n // serves: the library is a per-first-visit cost, the saving is per submit.\n var LIB = 11735, SAVE = 3968, VISITS = 1000;\n\n var book = document.getElementById(\"hb-book\");\n var repeat = document.getElementById(\"hb-repeat\");\n if (!book || !repeat) return;\n\n var bookOut = document.getElementById(\"hb-book-out\");\n var repeatOut = document.getElementById(\"hb-repeat-out\");\n var barCost = document.getElementById(\"hb-bar-cost\");\n var barSave = document.getElementById(\"hb-bar-save\");\n var costNum = document.getElementById(\"hb-cost\");\n var saveNum = document.getElementById(\"hb-save\");\n var verdict = document.getElementById(\"hb-verdict\");\n var cross = document.getElementById(\"hb-cross\");\n\n // Per 1,000 visits the totals run into megabytes, so the unit follows the\n // number rather than printing four digits of KiB.\n var bytes = function (n) {\n return n >= 1048576 ? (n / 1048576).toFixed(2) + \" MiB\" : (n / 1024).toFixed(1) + \" KiB\";\n };\n\n function paint() {\n var b = Number(book.value) / 100;\n var r = Number(repeat.value) / 100;\n\n var cost = VISITS * (1 - r) * LIB;\n var saved = VISITS * b * SAVE;\n var net = cost - saved;\n\n bookOut.textContent = book.value + \"%\";\n repeatOut.textContent = repeat.value + \"%\";\n\n // Both bars share one scale, so their lengths stay comparable.\n var top = Math.max(cost, saved, 1);\n barCost.style.width = (cost / top * 100).toFixed(1) + \"%\";\n barSave.style.width = (saved / top * 100).toFixed(1) + \"%\";\n costNum.textContent = bytes(cost);\n saveNum.textContent = bytes(saved);\n\n if (net > 0) {\n verdict.className = \"hb-verdict hb-lose\";\n verdict.textContent = \"htmx costs \" + bytes(net) + \" more per 1,000 visits.\";\n } else if (net < 0) {\n verdict.className = \"hb-verdict hb-win\";\n verdict.textContent = \"htmx saves \" + bytes(-net) + \" per 1,000 visits.\";\n } else {\n verdict.className = \"hb-verdict\";\n verdict.textContent = \"Exactly break-even.\";\n }\n\n // The booking rate that would pay for the library at this repeat rate. Above\n // 100% it needs more than one submit per visitor, which a booking form cannot\n // produce, so the readout says so rather than printing an unreachable number.\n var needed = (1 - r) * LIB / SAVE;\n cross.textContent = needed > 1\n ? Math.round(needed * 100) + \"%, which is more than one submit per visitor\"\n : Math.round(needed * 100) + \"%\";\n }\n\n book.addEventListener(\"input\", paint);\n repeat.addEventListener(\"input\", paint);\n\n var reset = document.getElementById(\"hb-reset\");\n if (reset) reset.addEventListener(\"click\", function () {\n book.value = \"4\";\n repeat.value = \"15\";\n paint();\n });\n\n paint();\n})();", "editorial": { "reader": "Someone who read the htmx 4.0 release, finds the hypermedia argument persuasive, and wants to know what adopting it would cost a site whose JavaScript was written without it.", diff --git a/pipelines/garage/specs/ig-prep.json b/pipelines/garage/specs/ig-prep.json index 4c481d35a..a48faf6f0 100644 --- a/pipelines/garage/specs/ig-prep.json +++ b/pipelines/garage/specs/ig-prep.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-08-24", "bodyHtml": "

Handing Instagram pixels it has no reason to resample

\n

Instagram resizes and re-encodes whatever it is given. What it does not do is resample a frame that already arrives at the size it wants. ig-prep does the destructive work here, carefully, so the platform has as little left to do as possible. It also speaks MCP, so a model can run it against photographs on your own machine without any of them moving.

\n\n

The geometric fact the default rests on

\n

Instagram shows frames between 4:5 and 1.91:1. A 3:2 portrait, which is most of what a Fujifilm or a Leica produces held vertically, is 0.667 and falls outside that band, so something has to give.

\n

The default gives nothing up, because cropping a portrait to 4:5 removes height only. A frame delivered at exactly the target width is already the right width for any vertical crop you drag in the app, so the crop costs no resample at all. You keep the framing decision. The expensive step, a 40 megapixel downscale in linear light with Lanczos3, stays here where it is done once and done carefully.

\n

One thing breaks it: pinch-zooming in the crop UI changes the scale and hands the resample straight back. Drag instead.

\n\n

Rotation is stated twice, and applying both turns the picture sideways

\n

A HEIF can say how to turn itself in two places: as a container transform (irot and imir) and as an EXIF Orientation tag. Every Fujifilm HIF writes both, saying the same thing each way. Applying either is correct. Applying both is a photograph on its side, which is why one file can look right in one app and wrong in another on the same phone.

\n

So no decoder is trusted about this. sips applies neither and passes the tag forward, while djxl bakes rotation in, and a program that believes either one plans a portrait as a landscape. ig-prep compares the decoded dimensions against the metadata's display dimensions and decides from the measurement. Across a 160-file corpus, 114 files state their rotation twice and zero disagree, which is the number ig-prep --check exists to report.

\n\n

Point a model at it

\n

ig-prep mcp speaks MCP on stdin and stdout. Three tools: ig_plan reports what a conversion would do and writes nothing, ig_convert does it, and ig_check_rotation is the rotation report above.

\n
{\n  \"mcpServers\": {\n    \"ig-prep\": {\n      \"command\": \"ig-prep\",\n      \"args\": [\"mcp\", \"--root\", \"/Users/you/Pictures\"]\n    }\n  }\n}
\n

Then you can ask for the conversion in a sentence, and the model reads back what happened per file.

\n

The photographs never move and never enter the conversation. They are already on the machine the server runs on, so a result carries paths, sizes and dimensions instead of image data. That is a deliberate limit. A converted frame is a few hundred kilobytes, which is roughly a megabyte of base64, and forty of them would spend forty megabytes of a model's context on pixels it cannot look at.

\n

--root is the boundary, and it is checked after symlinks are resolved: comparing the path string a caller wrote would let root/link-to-elsewhere through. One call converts at most 200 files and reports how many it left, because a result listing 200 conversions of a 900-file directory otherwise reads as a complete run.

\n

Eight 40 megapixel HEIFs through one call take 2.41 seconds of wall clock on an M-series Mac, at 354% CPU across the cores.

\n\n

What the chroma choice costs

\n

Instagram re-encodes to 4:2:0 regardless, so the question is not what survives on disk but what its encoder is handed. Full-resolution chroma means its subsampling step averages real detail rather than detail already halved once, and two successive halvings visibly smear saturated edges. The cost is bytes on an upload nobody sees. On one 1440x2160 frame:

\n
\n

A Fujifilm HIF halves chroma horizontally in its own landscape orientation, so held vertically the halved axis lands on the long edge of the portrait. Against a 1440x2160 output that is still 3.58x horizontally and 1.79x vertically of real chroma to average down, which is why 4:4:4 output is measured rather than invented. Somebody delivering at or near native size has a source that cannot fill it, and should say --422 and mean it.

\n\n

Three constants are guesses, and they are marked as guesses

\n

The target width of 1440, the 4:4:4 default, and the ratio band itself are all assumptions. 1440 is chosen over 1080 because the failure modes are asymmetric: if Instagram wants 1080 it downscales cleanly from 1440, and if it wants 1440 and gets 1080 it upscales and invents detail. Guessing high costs a resample. Guessing low costs the picture.

\n

None of the three becomes a fact until a round trip measures it, which means uploading a variant grid, saving what Instagram serves back, and scoring each against its source. The upload is the one step a program cannot do for you, so that harness is still unbuilt and the constants live in one block of geometry.rs where calibrating means editing one place.

\n\n

The bug that only black-and-white photographs could find

\n

Building the MCP mode turned up a failure that had been in the tool since it was written. Every grayscale JPEG died with source image: Size of buffer is smaller than required, an error naming neither the file nor the cause, raised two layers away from it inside the resizer.

\n

The decoder's jpeg_set_out_colorspace(RGB) is a request rather than an instruction. For a single-channel source it declines and returns Luma anyway, silently, so the buffer came back at one byte per pixel while the code labelled it three. The repair reads the colourspace the decoder actually used instead of the one it was asked for, and checks the buffer length against the dimensions at the decode boundary, where the file name is still in hand. One file in a 160-file library failed before. None do now.

\n

The general shape is worth more than the instance. An API that takes a preference and reports no error when it declines will be read as an instruction, and the mismatch surfaces wherever the wrong assumption is first load-bearing, which is rarely where it was made.

", - "pageCss": ".ig-warn{border-left:3px solid #d98a00;background:#fffaf0;padding:6px 10px;margin:10px 0}\n.ig-conf{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:8.6pt;margin:8px 0}\n.ig-table{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:8px 0;padding:2px 0}\n.ig-table .row{display:grid;grid-template-columns:5em 7em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:8.8pt;border-bottom:1px solid #e4eaf3}\n.ig-table .row:last-child{border-bottom:0}\n.ig-table .row.head{font-weight:700;background:#e8eef7}\n.ig-table .n{text-align:right;font-family:var(--font-mono)}", + "pageCss": ".ig-warn{border-left:3px solid #d98a00;background:#fffaf0;padding:6px 10px;margin:10px 0}\n.ig-conf{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:var(--text-micro);margin:8px 0}\n.ig-table{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:8px 0;padding:2px 0}\n.ig-table .row{display:grid;grid-template-columns:5em 7em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:var(--text-xs);border-bottom:1px solid #e4eaf3}\n.ig-table .row:last-child{border-bottom:0}\n.ig-table .row.head{font-weight:700;background:#e8eef7}\n.ig-table .n{text-align:right;font-family:var(--font-mono)}", "pageJs": "(function(){\n // The bytes are the measurement, so they are written here once and rendered\n // rather than restated in prose where the two copies could drift.\n var rows = [\n [\"4:4:4\", 1885, \"chroma at luma resolution, what this ships by default\"],\n [\"4:2:2\", 1699, \"halved horizontally, what the camera shot\"],\n [\"4:2:0\", 1585, \"halved on both axes, what Instagram stores anyway\"]\n ];\n var el = document.getElementById(\"ig-chroma\");\n if (!el) return;\n var html = '
chromaKBwhat it is
';\n for (var i = 0; i < rows.length; i++) {\n html += '
' + rows[i][0] + '' +\n rows[i][1] + '' + rows[i][2] + '
';\n }\n el.innerHTML = html;\n})();", "editorial": { "reader": "A photographer who has noticed their pictures look worse after posting, and who wants to know which part of that is recoverable before the upload rather than after it.", diff --git a/pipelines/garage/specs/octane.json b/pipelines/garage/specs/octane.json index b18606617..339326e3b 100644 --- a/pipelines/garage/specs/octane.json +++ b/pipelines/garage/specs/octane.json @@ -6,7 +6,7 @@ "added": "2026-07-31", "favicon": "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Cpath%20d='M5%2023%20A11%2011%200%200%201%2027%2023'%20stroke='%23166aee'%20stroke-width='2.8'%20fill='none'%20stroke-linecap='round'/%3E%3Cpath%20d='M16%2023%20L10%2015'%20stroke='%230855dd'%20stroke-width='2.8'%20fill='none'%20stroke-linecap='round'/%3E%3Ccircle%20cx='16'%20cy='23'%20r='2.6'%20fill='%23003bda'/%3E%3C/svg%3E", "bodyHtml": "

Octane, priced against no framework

\n

A benchmark told me Nextane ships 60.9% less client JavaScript than Next.js 16. That is a real number, honestly measured, and I checked it against the deployed demo. What the table cannot tell you is where zero sits, because every row in it is a framework. This site makes a useful zero: it ships no framework at all, so putting it on the same axis turns a ranking into a distance.

\n\n

The benchmark, and what it leaves out

\n

Nextane rebuilds the Next.js Pages Router on Octane, a compiled React replacement descended from Inferno that drops the virtual DOM and has its compiler infer reactivity instead of asking you for dependency arrays. Its README publishes this, measured on one small hydrated getServerSideProps page:

\n\n \n \n \n \n
RuntimeClient JavaScript (gzip)Against Next.js
Next.js 16.2.12110.0 KiBbaseline
Vinext 1.0.0-beta.477.8 KiB29.2% less
Nextane + Octane 0.1.2143.0 KiB60.9% less
\n

The README is unusually candid about its own standing, calling the project a fun experiment rather than a serious framework and pointing you at Vinext for real work. That candour is why the number is worth trusting, and worth extending.

\n

Every row here is a framework, so the table ranks. Ranking is a weaker thing than measuring, because a measurement needs an origin and this one has none.

\n\n

Re-measuring it on the live demo

\n

The demo is deployed, so the claim is checkable without building anything. Here is what it puts on the wire, brotli as served and gzip so it lines up with the README's axis:

\n\n \n \n \n \n \n \n
AssetRawgzipbrotli
document2,6521,2471,130
styles.css3,4061,3641,224
index-903Z56aL.js8,1933,3362,974
router-bwM-JHXE.js125,15240,04835,159
JavaScript total133,34543,38438,133
\n

43,384 bytes gzip against a claimed 43.0 KiB. The benchmark holds, which is worth saying plainly, because a page that only re-measures claims it can knock down is not measuring.

\n

One thing to notice before moving on. The document is 2,652 bytes and the JavaScript is 133,345. The page's actual content is 2% of what the page costs.

\n\n

Where the baseline lands

\n

Predict before you reveal. The three framework rows are drawn below. This site's homepage and its heaviest page are hidden. Guess where they fall relative to the 43.0 KiB row that won the benchmark.

\n
\n
\n Client JavaScript per page load, gzip\n \n
\n
    \n

    \n
    \n

    The homepage loads one script, nav.js, at 14.9 KiB gzip. That script builds the wallpaper, the draggable desktop icons, the taskbar, the clock, the Start orb, and the Run palette that filters 158 photos plus every page on the site. The heaviest page here is /lens at 36.6 KiB, and it still lands under the framework row that won.

    \n

    Now the complication I would rather state than bury. Every script this site serves, summed across all 43 pages, is 49.0 KiB gzip, which is more than Nextane's 43.0. A visitor never downloads that sum, and a Nextane visitor pays 43.0 on their first page, so per page load the gap is real and roughly 3x. As a whole-site total the comparison reverses. Both numbers are true, they answer different questions, and only the per-page one describes what a browser does.

    \n\n

    The chunk is the framework, not the glue

    \n

    The obvious objection is that most of that 125 KB is Next.js compatibility shim, which a plain Octane app would never load. I counted identifiers in the shipped chunk rather than arguing about it:

    \n\n \n \n \n \n \n \n
    Identifier in router-bwM-JHXE.jsOccurrences
    effect48
    octane34
    Suspense9
    prefetch5
    router1
    \n

    The chunk is named for the router and contains almost none of one. What fills it is the reactive runtime, Suspense, and the scheduler. So 43.0 KiB sits close to Octane's own floor in this build, and stripping the Next.js surface off the top would not recover much of it.

    \n

    That reading rests on one alpha build. Octane calls itself alpha software and has beta in view, so a tuned release may tree-shake considerably further. Treat the number as current rather than settled.

    \n\n

    What the comparison actually measures

    \n

    Here is where I argue against my own table. A framework competes with the version of your application you would otherwise hand-write and then maintain for years, which is a different opponent from a hand-written document. Octane's 43 KiB buys a component model, hydration, Suspense, and a compiler that tracks what your code reads so you stop maintaining dependency arrays. This site buys none of that because it needs none of it. It serves documents, and its interactivity is a desktop shell bolted on top of them.

    \n

    So the honest reading is narrower than \"frameworks are heavy.\" A framework's floor is set by its runtime, and this site's floor is set by how much behaviour it actually has. Those are different quantities. A benchmark containing only frameworks quietly makes them look like one quantity, and that is the thing the extra rows are for.

    \n\n

    Where Octane would win here

    \n

    nav.js is the one module on this site with enough state to make the question live. It handles window dragging and resizing, taskbar state, a command palette filtering 158 photos alongside every page and six profiles, and icon positions that deliberately reset each visit. All of it is imperative DOM code, and all of it is 14.9 KiB.

    \n

    The crossover is a maintenance threshold rather than a byte one. Octane's runtime alone is roughly three times what nav.js weighs today, so a rewrite loses on size until that module grows several times over. If it ever does, the calculation changes.

    \n

    The idea underneath is worth taking even while the package is not. Octane compiles the reactivity bookkeeping away so the browser receives direct DOM updates with no virtual DOM in between. Hand-written imperative DOM code is that output. This site already stands where the compiler is trying to arrive, having paid in author effort rather than in runtime bytes, and that is the whole reason the framework has nothing to sell it.

    \n\n

    The objection I think is right

    \n

    The strongest reading of this page is that it stacks the deck. I picked a document site as the zero, then reported that an application framework costs more than zero, which was never in doubt. Nobody building a dashboard can adopt this site's answer, because the answer is \"have less behaviour.\"

    \n

    I think that is fair, and it is why the middle sections exist rather than just the chart. What the extra rows buy is a scale rather than a verdict on Octane. A reader who has only ever seen framework-versus-framework tables can rank three options and still have no idea whether the winner is close to the metal or a long way from it. Now there is a distance on the axis, and 43.0 KiB reads as a genuine achievement against React and a large number against nothing at all. Both readings survive, and holding them together is the point.

    \n

    What would change my mind: a tuned, non-alpha Octane build landing anywhere near nav.js's 14.9 KiB. That would mean the runtime cost measured here is a property of an early build rather than of the architecture, and the crossover argument above would collapse with it. The beta is where that shows up.

    ", - "pageCss": ".oct-table{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:9.5pt}\n.oct-table th{text-align:left;background:#eaf0f9;border:1px solid #c3ced0;padding:4px 7px;font-family:var(--font-caption)}\n.oct-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.oct-table td:not(:first-child){text-align:right;font-family:var(--font-mono)}\n.oct-table tr.mine td{background:#fffbe8}\n.oct-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.oct-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:9.5pt;color:#20406e}\n.oct-bars{list-style:none;margin:0;padding:10px 12px 4px}\n.oct-bars li{display:grid;grid-template-columns:11em 1fr auto;align-items:center;gap:8px;margin:0 0 7px;font-size:9pt}\n.oct-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.oct-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.oct-bars li.mine .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.oct-bars li.hidden .t{background:repeating-linear-gradient(135deg,#e4e9f2,#e4e9f2 5px,#cfd8e6 5px,#cfd8e6 10px);border-style:dashed;border-color:#9aa8bd;width:14%}\n.oct-bars .v{font-family:var(--font-mono);font-size:8.5pt;color:#4a5870;white-space:nowrap}\n.oct-read{margin:0;padding:2px 12px 10px;font-size:8.5pt;color:#5b6879}\n.oct-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.oct-bars li{grid-template-columns:8.5em 1fr;row-gap:2px}.oct-bars .v{grid-column:2}}", + "pageCss": ".oct-table{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:var(--text-sm)}\n.oct-table th{text-align:left;background:#eaf0f9;border:1px solid #c3ced0;padding:4px 7px;font-family:var(--font-caption)}\n.oct-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.oct-table td:not(:first-child){text-align:right;font-family:var(--font-mono)}\n.oct-table tr.mine td{background:#fffbe8}\n.oct-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.oct-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.oct-bars{list-style:none;margin:0;padding:10px 12px 4px}\n.oct-bars li{display:grid;grid-template-columns:11em 1fr auto;align-items:center;gap:8px;margin:0 0 7px;font-size:var(--text-xs)}\n.oct-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.oct-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.oct-bars li.mine .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.oct-bars li.hidden .t{background:repeating-linear-gradient(135deg,#e4e9f2,#e4e9f2 5px,#cfd8e6 5px,#cfd8e6 10px);border-style:dashed;border-color:#9aa8bd;width:14%}\n.oct-bars .v{font-family:var(--font-mono);font-size:var(--text-micro);color:#4a5870;white-space:nowrap}\n.oct-read{margin:0;padding:2px 12px 10px;font-size:var(--text-micro);color:#5b6879}\n.oct-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.oct-bars li{grid-template-columns:8.5em 1fr;row-gap:2px}.oct-bars .v{grid-column:2}}", "pageJs": "(function(){\n // Framework rows are the Nextane README's published benchmark (one small\n // hydrated getServerSideProps page, gzip). The two site rows are measured\n // from this repo's own build output at the same compression, 2026-07-31.\n var ROWS = [\n { n: \"Next.js 16.2.12\", kib: 110.0 },\n { n: \"Vinext 1.0.0-b4\", kib: 77.8 },\n { n: \"Nextane + Octane\", kib: 43.0 },\n { n: \"aadhar.sh /lens\", kib: 36.6, mine: true, hide: true },\n { n: \"aadhar.sh home\", kib: 14.9, mine: true, hide: true }\n ];\n var BEFORE = \"Three frameworks, ranked. The two hidden rows are this site's heaviest page and its homepage. Where do they land?\";\n var AFTER = \"The homepage is 14.9 KiB against the winning framework's 43.0, so roughly a third. The heaviest page here still sits under that row. Read it as a scale rather than a verdict: 43.0 KiB is a real win against React and a large number against no framework.\";\n\n var host = document.getElementById(\"oct-demo\");\n if (!host) return;\n var list = host.querySelector(\".oct-bars\");\n var read = host.querySelector(\".oct-read\");\n var button = host.querySelector(\".oct-reveal\");\n var max = 0, i;\n for (i = 0; i < ROWS.length; i++) if (ROWS[i].kib > max) max = ROWS[i].kib;\n var revealed = false;\n\n function draw() {\n list.textContent = \"\";\n for (var j = 0; j < ROWS.length; j++) {\n var row = ROWS[j];\n var covered = row.hide && !revealed;\n var li = document.createElement(\"li\");\n li.className = (row.mine ? \"mine\" : \"\") + (covered ? \" hidden\" : \"\");\n var n = document.createElement(\"span\");\n n.className = \"n\";\n n.textContent = row.n;\n var t = document.createElement(\"span\");\n t.className = \"t\";\n if (!covered) t.style.width = (row.kib / max) * 100 + \"%\";\n var v = document.createElement(\"span\");\n v.className = \"v\";\n v.textContent = covered ? \"?\" : row.kib.toFixed(1) + \" KiB\";\n li.appendChild(n);\n li.appendChild(t);\n li.appendChild(v);\n list.appendChild(li);\n }\n read.textContent = revealed ? AFTER : BEFORE;\n }\n\n button.addEventListener(\"click\", function(){\n revealed = true;\n button.disabled = true;\n button.textContent = \"revealed\";\n draw();\n });\n draw();\n})();", "editorial": { "reader": "Someone who has seen a framework benchmark claiming a large percentage win and wants to know what the percentage is measured against.", diff --git a/pipelines/garage/specs/pqc.json b/pipelines/garage/specs/pqc.json index 176549339..3ec32b256 100644 --- a/pipelines/garage/specs/pqc.json +++ b/pipelines/garage/specs/pqc.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-07-27", "bodyHtml": "

    Post-quantum signatures, priced

    \n

    Half of this site's TLS handshake is already post-quantum and I did nothing to make that happen. The other half, authentication, moves on a completely different schedule, because signatures live in certificates and registries that a personal site does not control. So I went looking for the signature I do control, and measured what it would cost to migrate it.

    \n\n

    What is already done, and what it covers

    \n

    Ask the live site what it negotiated:

    \n
    $ openssl s_client -connect aadhar.sh:443 -groups X25519MLKEM768 -tls1_3\n\nNegotiated TLS1.3 group: X25519MLKEM768\nPeer signature type:     ecdsa_secp256r1_sha256\nCertificate:             ecdsa-with-SHA256, P-256
    \n

    The key agreement is post-quantum. The certificate and the handshake signature are classical, and they will stay that way for a while: no public CA issues ML-DSA certificates into the WebPKI, and Cloudflare's own position is that ML-DSA chains add roughly 14KB per handshake, which is why they are pushing Merkle Tree Certificates instead.

    \n

    That split matters more than it looks. Key agreement is where harvest now, decrypt later bites: an attacker recording today's ciphertext can decrypt it whenever a quantum computer arrives, so the migration had to happen years early. A signature has no such property. Forging a 2026 signature in 2035 proves nothing about 2026, because by then the request is long over and nobody is still trusting it. The urgency argument for key agreement simply does not transfer.

    \n\n

    Three tiers of signature, and only one is mine

    \n\n \n \n \n \n \n
    SurfaceAlgorithmWhose problem
    TLS to visitorsECDSA P-256The CA and browser ecosystem
    DNSSEC on the zoneECDSA P-256 SHA-256The registry, plus DNS response sizes
    Booking links, cover URLsHMAC-SHA256Already fine. Symmetric, so Grover halves it to 128-bit
    AadharshBot request signingEd25519Mine. About 30 lines in one file
    \n

    AadharshBot signs every outbound request per RFC 9421 using the Web Bot Auth profile, so receiving sites can check that a request really came from here. That signature is the only asymmetric one this site owns end to end. The whole post-quantum question collapses to it.

    \n\n

    What the candidates actually cost

    \n

    When I first measured this in July, Cloudflare's runtime had no post-quantum algorithm in WebCrypto. I checked the shipping binary rather than the docs: workerd 1.20260722 contained the strings Ed25519, X25519, and ECDSA, and no ML-DSA at any parameter size. So the blue bars are pure JavaScript, via @noble/post-quantum, signing the site's real signature base on an M3 Max under Node 26. The amber bar arrived in October, and the last section explains it.

    \n
    \n
    \n Cost of one signature\n \n \n \n \n
    \n
      \n

      \n \n
      \n

      Flip the toggle before you read on and predict where each bar lands. The point of the exercise is that the ordering changes. SLH-DSA-128s has the smallest public key of anything here at 32 bytes, and it takes two and a half seconds to produce one signature. Its sibling 128f signs in 132ms and spends 22KB of header doing it. There is no row that is small on both axes, which is the actual state of post-quantum signatures rather than a quirk of this library.

      \n\n

      How much of that 8.58ms is the algorithm?

      \n

      Less than it looks, and the panel above will prove it on your own hardware if it can. Chrome ships ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc, implementing the same WICG proposal whose JWK shape this site already publishes. If that flag is on, the panel offers a button that generates an ML-DSA-44 key in your browser, signs this page's signature base 200 times natively, and drops the result in beside the JavaScript number. The amber workerd bar answers the same question for Cloudflare's runtime.

      \n

      This matters for how you read the whole table. The 8.58ms is a ceiling set by running a lattice scheme in a JIT, and the byte counts underneath it are properties of FIPS 204 that no implementation will improve. Sizes are the permanent cost. Speed is the temporary one, and it goes away on its own when the runtimes catch up.

      \n\n

      Why the hash-based options lost

      \n

      I wanted SLH-DSA to work. It rests on hash functions alone, so it survives even if lattices turn out to be broken, and its 32-byte public key is charming. It fails here twice over.

      \n

      The header is the harder failure. A 128f signature is 22,784 bytes once base64'd into a structured-fields value, and most servers cap total request headers around 8KB, so the crawl would start collecting 431s instead of pages. The latency is the other one: 2.5 seconds of single-threaded work per signature is not something to put in front of an HTTP request, and a Worker bills CPU time.

      \n

      Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a Worker isolate is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints.

      \n\n

      Shipping it without a codepoint

      \n

      Here is the part that made this cheap. The IANA HTTP Signature Algorithms registry has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

      \n

      But Signature-Input and Signature are structured-fields Dictionaries. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:

      \n
      Signature-Input: sig1=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o\";alg=\"ed25519\";tag=\"web-bot-auth\",\n                 sig2=(\"@authority\" \"signature-agent\");created=1785186311;\n                   keyid=\"rn-mldsa-2026-07-27\";alg=\"ml-dsa-44\";tag=\"web-bot-auth\"
      \n

      A verifier that only knows Ed25519 reads sig1 and skips the label it does not recognise. Nothing breaks, and the cost of ignoring sig2 is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token ml-dsa-44 is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and /bot says plainly that you should verify sig1 and ignore sig2.

      \n

      The public key is published under the same JWKS as an AKP key per RFC 9964, which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:

      \n
      \n
      Live, from this site's key directory
      \n
      reading /.well-known/http-message-signatures-directory…
      \n
      \n\n

      What it costs in production

      \n

      About 8.6ms of CPU and 3,228 bytes of request header per signed fetch, plus 11.96KB gzip of lattice code in the Worker bundle, which is the price of carrying an implementation the runtime should be providing. Zero bytes reach a browser, because this is a server-to-server signature and no page loads it. The /around crawl signs roughly 21 origins per refresh, so a full cycle gains around 180ms of CPU on a cached background path.

      \n

      Two of those three costs are temporary. When workerd gains native ML-DSA, the bundle cost goes to zero and the signing cost drops to whatever the native bar in the panel above showed you. The 3,228 bytes of header stay forever. That asymmetry is the argument for migrating on the size budget now and letting the speed sort itself out.

      \n

      One implementation detail worth stealing: ML-DSA-44 keygen expands a 32-byte seed into a 2,560-byte secret key and costs about 1.4ms. Worker isolates outlive a request, so the expanded key is derived once and cached in module scope, keyed by the raw secret so a rotation inside a live isolate re-derives instead of signing with a retired key.

      \n\n

      The objection I think is right

      \n

      The strongest reading of this page is that it is theatre. Nothing verifies sig2. Signatures have no harvest-now-decrypt-later exposure, so there is no clock forcing the migration. By that argument the honest move was to write the analysis and ship nothing.

      \n

      I mostly agree, and it is why sig2 is second rather than first, why a missing key degrades to Ed25519 alone instead of failing the request, and why /bot tells verifiers to ignore it. What I get in exchange is a migration already paid for and measured, on a surface where being wrong costs a crawl rather than a login. When the registry does land a codepoint, the remaining work is renaming a string.

      \n

      What would change my mind: a receiving server rejecting requests because the extra 3.2KB pushes headers past its limit, or a verifier that chokes on a dictionary label it does not know. Either result would mean a second label is not free after all, and sig2 would come straight back out. The crawl logs are where that would show up first.

      \n\n

      What actually happened: retired after 19 days

      \n

      Neither of those things went wrong. No server rejected the headers, and no verifier choked on the extra label. The prediction above was accurate and it was aimed in the wrong direction, because it priced the cost to other people and treated the cost to this site as a settled line item: 8.58ms, listed once, in a table.

      \n

      The number that mattered was 8.58ms multiplied by fan-out, against a per-invocation budget. This site runs on the Workers free plan, which allows 10ms of CPU per request. So one signature spent most of a request's entire budget, and every surface that signs more than once had already blown it:

      \n\n \n \n \n \n
      SurfaceSignatures per requestML-DSA CPU
      A single crawl of one origin1~8.5ms of a 10ms budget
      The /rn playlist scrape21, one per track~180ms
      /lens discovery28 probes~240ms
      \n

      Both of those were dark and neither said so. The playlist scrape catches a failed track embed per track and degrades that one track to no cover, so 21 signatures timing out rendered as a tracklist that had quietly stopped showing album art. On /lens it was louder but no better attributed: sampling production logs on 2026-08-15, 31 of 51 requests died exceededCpu, nearly all of them /lens/fetch and /lens/tools.

      \n

      Removing sig2 took the scrape from 379ms of CPU to 95ms, measured on the same 21-track playlist. The key is out of the JWKS too, because publishing a key nothing signs with is the dangling-pointer problem this site refuses elsewhere.

      \n

      The honest reading is that the objection above was right for a reason it did not name. Shipping the migration early was cheap to build and the build was never the risk. What it cost was a recurring tax on every outbound request, on the one axis a free-tier Worker has least of, and the page that measured the tax in isolation is the same page that missed it in aggregate. A per-request cost is only a number once you know how many requests, and how much budget.

      \n

      None of this changes the sizing argument, which is the part worth keeping. The 3,228 bytes are still permanent, the speed is still temporary, and when workerd ships native ML-DSA the CPU objection evaporates and sig2 can come back with no code to write beyond deleting this section. Until then the migration is measured, documented, and turned off.

      \n\n

      Then the runtime caught up

      \n

      That last paragraph made a prediction, and on 2026-10-01 I got to check it. Cloudflare has put ML-KEM and ML-DSA into WebCrypto on Workers. They run on BoringSSL, and each Worker opts in with the webcrypto_modern_algorithms compatibility flag (the announcement). This site already pins a workerd build from 2026-09-26 that carries both, so I measured it on the same M3 Max that drew the JavaScript bars.

      \n\n \n \n \n \n
      Same laptop, same signature baseJavaScriptworkerd, native
      Sign once with ML-DSA-448.58ms0.12ms
      Expand the 32-byte seed into a signing key~1.4ms0.03ms
      Signature in the header3,228 bytes3,228 bytes
      \n

      Signing got 72 times faster, and the header didn't lose a single byte. That's the asymmetry this page bet on in July. Natively, one ML-DSA-44 signature costs about as much as 10 Ed25519 signatures (11.3µs each on the same rig), where the JavaScript version cost as much as 170.

      \n

      Here is the table that retired sig2, repriced:

      \n\n \n \n \n \n
      SurfaceSignatures per requestJavaScriptNative, on the laptop
      A single crawl of one origin1~8.5ms~0.12ms
      The /rn playlist scrape21~180ms~2.5ms
      /lens discovery28~240ms~3.4ms
      \n

      On the laptop, one signature takes about 1% of a 10ms request budget, and the worst fan-out on the site would sign everything it needs in about a third of a single budget. Hold that projection loosely; production priced it at 3 times that.

      \n\n

      How I checked it

      \n

      A 72x speedup deserves a control, so I ran three.

      \n

      Without the flag, the same runtime refuses outright: Unrecognized key generation algorithm \"ML-DSA-44\" requested. With it, a key shaped like the one still sitting in this site's secrets (a bare 32-byte seed in an AKP JWK, with no public half) imports and signs. And Node 26's WebCrypto, which runs on OpenSSL instead of BoringSSL, verifies workerd's signature and rejects it the moment one byte of the signed base changes.

      \n

      A Worker's clock only advances across I/O, so a Worker can't time its own crypto. The timings are wall-clock from outside instead: 2,000 signatures inside one request, minus an empty request so the dispatch cost cancels, median of 9 runs. The browser button above times yours the same way.

      \n\n

      What it leaves open

      \n

      The objection I think is right survives intact. Nothing on the internet verifies sig2, the IANA registry still lists six classical algorithms, and every signed request would still carry 3.2KB more header for a signature with no harvest-now clock behind it.

      \n

      The retirement section also promised sig2 could return \"with no code to write.\" That was wrong. The library left with the label, so bringing it back means a compatibility flag on the whole Worker plus a signing path through crypto.subtle. It's a small change, and a real one.

      \n

      What production said

      \n

      Those were laptop numbers, and production bills CPU on different hardware, under a clamp that tightens with load. That is exactly where this page went wrong the first time. So before sig2 came back, I deployed it at 0% beside the live version and pinned six /lens scans to it, each against a fresh origin so no cache could skip the signing.

      \n\n \n \n \n \n
      Version/lens scan CPU, median of 6
      Live, sig1 only22ms
      sig2 on every request32ms
      sig2 on primary fetches only22ms (live read 20ms in the same run)
      \n

      Signing everything cost 10ms a scan, about 0.33ms a signature, which is 3 times the laptop figure. August's lesson applied a second time: the per-signature price was right, and the price that mattered was that number on production's CPU, multiplied by fan-out.

      \n

      So sig2 now rides on the requests that are this crawler's identity (the page being scanned, /around, the playlist) and skips the 28 discovery probes and the robots.txt reads that only look around. That costs about 2ms a scan, inside the noise between two runs of the live version.

      \n

      The flag carries its own risk, since Cloudflare gates it \"while the specification is still moving.\" If the API breaks, this site loses the second label and nothing else: any ML-DSA failure drops sig2, logs once, and sig1 ships alone.

      \n

      In July this page priced three costs: CPU, bundle size, and header bytes. Two belonged to the runtime, and the runtime has now paid them. The 3,228 bytes belong to FIPS 204, so they're the only number left to decide on. That's the rule I'd hand anyone timing a post-quantum migration: decide on the sizes, because the speeds come down on someone else's schedule.

      ", - "pageCss": ".pqc-tiers{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:9.5pt}\n.pqc-tiers th{text-align:left;background:#eaf0f9;border:1px solid #c3ceE0;padding:4px 7px;font-family:var(--font-caption)}\n.pqc-tiers td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.pqc-tiers tr.mine td{background:#fffbe8}\n.pqc-demo,.pqc-live{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.pqc-demo-head,.pqc-live-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:9.5pt;color:#20406e}\n.pqc-toggle button{font:inherit;font-size:8.5pt;font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,#e4e9f2);border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.pqc-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.pqc-bars{list-style:none;margin:0;padding:10px 12px 4px;display:grid;grid-template-columns:max-content 1fr auto;gap:7px 8px;font-size:9pt}\n.pqc-bars li{display:grid;grid-column:1/-1;grid-template-columns:subgrid;align-items:center}\n.pqc-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap}\n.pqc-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.pqc-bars li.now .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.pqc-bars li.native .t{background:linear-gradient(180deg,#e6b45c,#c8880f);border-color:#9a6a08}\n.pqc-native{border-top:1px solid #ccd6e4;padding:8px 12px;display:flex;align-items:center;gap:9px;flex-wrap:wrap}\n.pqc-native-note{font-size:8.5pt;color:#5b6879;flex:1;min-width:14em}\n.pqc-bars .v{font-family:var(--font-mono);font-size:8.5pt;color:#4a5870;white-space:nowrap}\n.pqc-read{margin:0;padding:2px 12px 10px;font-size:8.5pt;color:#5b6879}\n.pqc-live-body{padding:8px 10px;font-family:var(--font-mono);font-size:8.5pt;color:#33455f;word-break:break-all;line-height:1.6}\n.pqc-live-body b{font-family:var(--font-ui)}\n.pqc-retired{border-left:3px solid var(--blue-40);background:oklch(96.5% 0.012 250.51);padding:8px 10px;margin:0 0 14px}\n.pqc-retired p{margin:0}\n.pqc-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.pqc-bars{grid-template-columns:max-content 1fr}.pqc-bars li{row-gap:2px}.pqc-bars .v{grid-column:2}}", + "pageCss": ".pqc-tiers{border-collapse:collapse;width:100%;margin:0 0 14px;font-size:var(--text-sm)}\n.pqc-tiers th{text-align:left;background:#eaf0f9;border:1px solid #c3ceE0;padding:4px 7px;font-family:var(--font-caption)}\n.pqc-tiers td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.pqc-tiers tr.mine td{background:#fffbe8}\n.pqc-demo,.pqc-live{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.pqc-demo-head,.pqc-live-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.pqc-toggle button{font:inherit;font-size:var(--text-micro);font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,#e4e9f2);border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.pqc-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.pqc-bars{list-style:none;margin:0;padding:10px 12px 4px;display:grid;grid-template-columns:max-content 1fr auto;gap:7px 8px;font-size:var(--text-xs)}\n.pqc-bars li{display:grid;grid-column:1/-1;grid-template-columns:subgrid;align-items:center}\n.pqc-bars .n{font-family:var(--font-mono);color:#33455f;white-space:nowrap}\n.pqc-bars .t{height:15px;background:linear-gradient(180deg,#7fb0e8,#3f7fd0);border:1px solid #2f5f9e;min-width:2px;transition:width var(--xp-transition-move)}\n.pqc-bars li.now .t{background:linear-gradient(180deg,#8fd98f,#3ea23e);border-color:#2c7a2c}\n.pqc-bars li.native .t{background:linear-gradient(180deg,#e6b45c,#c8880f);border-color:#9a6a08}\n.pqc-native{border-top:1px solid #ccd6e4;padding:8px 12px;display:flex;align-items:center;gap:9px;flex-wrap:wrap}\n.pqc-native-note{font-size:var(--text-micro);color:#5b6879;flex:1;min-width:14em}\n.pqc-bars .v{font-family:var(--font-mono);font-size:var(--text-micro);color:#4a5870;white-space:nowrap}\n.pqc-read{margin:0;padding:2px 12px 10px;font-size:var(--text-micro);color:#5b6879}\n.pqc-live-body{padding:8px 10px;font-family:var(--font-mono);font-size:var(--text-micro);color:#33455f;word-break:break-all;line-height:1.6}\n.pqc-live-body b{font-family:var(--font-ui)}\n.pqc-retired{border-left:3px solid var(--blue-40);background:oklch(96.5% 0.012 250.51);padding:8px 10px;margin:0 0 14px}\n.pqc-retired p{margin:0}\n.pqc-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n@media(max-width:620px){.pqc-bars{grid-template-columns:max-content 1fr}.pqc-bars li{row-gap:2px}.pqc-bars .v{grid-column:2}}", "pageJs": "(function(){\n // Measured with @noble/post-quantum 0.6.1 on an M3 Max under Node 26.\n // sign is the median of 5 runs of 300 signatures over this site's real\n // signature base; ML-DSA is rejection-sampled, so it varies by design.\n var ROWS = [\n { n: \"Ed25519\", bytes: 88, sign: 0.05, now: true },\n { n: \"ML-DSA-44\", bytes: 3228, sign: 8.58 },\n // workerd 2026-09-26 behind webcrypto_modern_algorithms, same M3 Max,\n // 2026-10-01: median of 9 runs of 2,000 signatures, wall-clock per request\n // differenced against an empty one (a Worker's clock is frozen inside it).\n { n: \"ML-DSA-44 (workerd)\", bytes: 3228, sign: 0.12, native: true },\n { n: \"SLH-DSA-128s\", bytes: 10476, sign: 2650 },\n { n: \"SLH-DSA-128f\", bytes: 22784, sign: 132.34 }\n ];\n var NOTE = {\n bytes: \"Base64'd signature as it sits in the Signature header. Most servers cap total request headers near 8KB, which rules out both hash-based rows.\",\n sign: \"Log scale. The blue bars are single-threaded JavaScript; the amber one is the same ML-DSA-44 native in Cloudflare's runtime, 72x faster on the same M3 Max at the same 3,228 bytes.\"\n };\n var host = document.getElementById(\"pqc-demo\");\n if (!host) return;\n var list = host.querySelector(\".pqc-bars\"), read = host.querySelector(\".pqc-read\");\n var metric = \"bytes\";\n\n function fmt(v) {\n if (metric === \"bytes\") return v.toLocaleString(\"en-US\") + \" B\";\n // a native ML-DSA result can land well under 0.01ms, where two decimals\n // would round a real measurement down to \"0.00\".\n var s = v < 1 ? v.toPrecision(2) : v < 100 ? v.toFixed(1) : Math.round(v).toLocaleString(\"en-US\");\n return s + \" ms\";\n }\n // bytes read honestly on a linear scale; sign times span microseconds to\n // seconds, where linear would collapse every fast row into nothing.\n function width(v, max) {\n if (metric === \"bytes\") return (v / max) * 100;\n // floor at 1us so a sub-microsecond result cannot produce a negative width\n var lo = Math.log10(Math.max(v, 0.001) * 1000);\n return Math.max(2, (lo / Math.log10(max * 1000)) * 100);\n }\n function render() {\n var max = ROWS.reduce(function (m, r) { return Math.max(m, r[metric]); }, 0);\n list.innerHTML = ROWS.map(function (r) {\n return '
    1. ' + r.n +\n '' + fmt(r[metric]) + \"
    2. \";\n }).join(\"\");\n read.textContent = NOTE[metric];\n }\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (b) {\n b.addEventListener(\"click\", function () {\n metric = b.getAttribute(\"data-metric\");\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (o) {\n o.classList.toggle(\"on\", o === b);\n });\n render();\n });\n });\n render();\n\n // Chrome puts ML-DSA in WebCrypto behind chrome://flags/#webcrypto-pqc. If the\n // reader has it on, let their hardware answer \"how much of 8.58ms was the JIT?\"\n // rather than asserting a speedup this page never measured.\n var nativeBox = host.querySelector(\".pqc-native\");\n var BASE = new TextEncoder().encode(\n '\"@authority\": example.com\\n\"signature-agent\": \"https://aadhar.sh/\"\\n' +\n '\"@signature-params\": (\"@authority\" \"signature-agent\");created=1785186311;' +\n 'keyid=\"rn-mldsa-2026-07-27\";alg=\"ml-dsa-44\";tag=\"web-bot-auth\"');\n\n function show(msg) { nativeBox.hidden = false; nativeBox.innerHTML = msg; }\n\n crypto.subtle.generateKey({ name: \"ML-DSA-44\" }, true, [\"sign\", \"verify\"]).then(function (key) {\n show('' +\n 'Your browser has native ML-DSA. 200 signatures, on your hardware.');\n nativeBox.querySelector(\".pqc-run\").addEventListener(\"click\", function (e) {\n e.target.disabled = true;\n e.target.textContent = \"signing\\u2026\";\n // yield first so the button repaints before the main thread is busy\n setTimeout(function () {\n var alg = { name: \"ML-DSA-44\" }, warm = [];\n for (var i = 0; i < 20; i++) warm.push(crypto.subtle.sign(alg, key.privateKey, BASE));\n Promise.all(warm).then(function () {\n var t0 = performance.now(), runs = [];\n for (var j = 0; j < 200; j++) runs.push(crypto.subtle.sign(alg, key.privateKey, BASE));\n return Promise.all(runs).then(function (sigs) {\n var ms = (performance.now() - t0) / 200;\n ROWS.push({\n n: \"ML-DSA-44 (browser)\",\n bytes: Math.ceil(sigs[0].byteLength / 3) * 4,\n sign: ms, native: true,\n });\n metric = \"sign\";\n [].forEach.call(host.querySelectorAll(\".pqc-toggle button\"), function (o) {\n o.classList.toggle(\"on\", o.getAttribute(\"data-metric\") === \"sign\");\n });\n render();\n show('' + (ms < 0.1 ? ms.toPrecision(2) : ms.toFixed(2)) +\n \"ms per signature natively, versus 8.58ms in JavaScript: about \" +\n Math.round(8.58 / ms) + \"x. Same \" + sigs[0].byteLength.toLocaleString(\"en-US\") +\n \" bytes on the wire, because that part is FIPS 204 and no implementation gets to improve it.\");\n });\n }).catch(function () { show('The native run failed partway.'); });\n }, 30);\n });\n }).catch(function () {\n show('Your browser has no native ML-DSA. Chrome hides it behind ' +\n 'chrome://flags/#webcrypto-pqc; turn it on and reload to benchmark the native ' +\n 'implementation against the JavaScript number above.');\n });\n\n // Read the site's own key directory so the page cannot drift from the key\n // the bot actually signs with.\n var live = document.querySelector(\"#pqc-live .pqc-live-body\");\n if (!live) return;\n fetch(\"/.well-known/http-message-signatures-directory\").then(function (r) {\n return r.json();\n }).then(function (dir) {\n var akp = (dir.keys || []).filter(function (k) { return k.kty === \"AKP\"; })[0];\n var ed = (dir.keys || []).filter(function (k) { return k.crv === \"Ed25519\"; })[0];\n if (!akp) {\n live.innerHTML = (ed ? \"\" + ed.kid + \" · kty \" + ed.crv + \" · 32 bytes, and the only key this bot signs with.
      \" : \"\") +\n \"No AKP key: the ML-DSA label was retired on 2026-08-15. See below.\";\n return;\n }\n var raw = atob(akp.pub.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n live.innerHTML =\n \"\" + akp.kid + \" · kty \" + akp.kty + \" · alg \" + akp.alg +\n \" · \" + raw.length.toLocaleString(\"en-US\") + \" bytes of public key\" +\n (ed ? \"
      \" + ed.kid + \" · kty \" + ed.crv + \" · 32 bytes, and the one your verifier should use\" : \"\") +\n \"
      \" + akp.pub.slice(0, 96) + \"… (\" +\n akp.pub.length.toLocaleString(\"en-US\") + \" base64url chars)\";\n }).catch(function () {\n live.textContent = \"Could not read the key directory from here.\";\n });\n})();", "editorial": { "reader": "Someone who has read that post-quantum migration is coming and wants to know what it costs on a real surface rather than in a roadmap.", diff --git a/pipelines/garage/specs/resample.json b/pipelines/garage/specs/resample.json index 985a60b20..a6db10a3e 100644 --- a/pipelines/garage/specs/resample.json +++ b/pipelines/garage/specs/resample.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-08-26", "bodyHtml": "

      Half the light

      \n

      A one pixel black and white checkerboard is exactly half black and half white, so any honest downscale has to average it. The right answer is sRGB 188, because the average is over light. Every resizer I measured returns about 128: sips, ffmpeg, the Rust image crate, fast_image_resize, Bun.Image, and the canvas in whatever browser is rendering this sentence. Their kernels agree with each other to seven decimal places. All of them average in the wrong space, which Eric Brasseur wrote up in 2007 and the defaults have outlived.

      \n\n

      Your browser is doing it right now

      \n

      This runs in your machine, on a checkerboard built a moment ago. The left square is what drawImage produces when it shrinks that checkerboard 16 times. The right square is the same reduction with the samples converted to light first.

      \n
      enable JavaScript to run the measurement
      \n

      The two grey squares should be visibly different, and the numbers under them say by how much. Nothing about the left square is a bug in your browser: it is what almost every image pipeline shipped this decade does.

      \n\n

      Why 128 is the wrong grey

      \n

      sRGB encodes perceived brightness rather than light. The code 128 sits near the middle of what your eye does with the range, and it carries about 21% of the light of code 255. So averaging the codes of black and white gives a grey with a fifth of the light of the pair it replaced, and the picture darkens everywhere fine detail used to be.

      \n

      Textures are where this lands hardest, because texture is exactly where neighbouring pixels differ most. A photograph reduced this way loses contrast in foliage, fabric, hair and grain, and gains it nowhere.

      \n\n

      The resampler, with every operational concern taken back out

      \n

      Here is the whole idea at an integer reduction factor. It is the load-bearing version, and the shipping one is a few hundred lines around this shape.

      \n
      function reduce(src, w, factor) {\n  const out = [], n = factor * factor;\n  for (let y = 0; y < w / factor; y++)\n    for (let x = 0; x < w / factor; x++) {\n      let sum = 0;\n      for (let dy = 0; dy < factor; dy++)\n        for (let dx = 0; dx < factor; dx++)\n          sum += toLinear(src[(y * factor + dy) * w + x * factor + dx]);\n      out.push(toSrgb(sum / n));\n    }\n  return out;\n}
      \n

      Convert to light, average, convert back. Everything the real implementation adds is a separate concern wearing this function's clothes, and each one earns its place for a reason worth naming.

      \n

      Arbitrary ratios need weights. An integer factor lets every source sample count once. A 3.86 times reduction does not, so each output sample carries a list of taps and weights instead of a rectangle.

      \n

      The filter has to widen with the reduction. On a downscale the filter must low pass at the output Nyquist, so its support in source pixels grows by the reduction factor. A kernel evaluated at fixed width is sampling rather than filtering, and it aliases. This is why naive Lanczos3 downscales shimmer.

      \n

      Weights are renormalised per output sample. At the frame edge the support window is clipped, and the weights that survive have to sum to one again or the border darkens into a vignette nobody can explain.

      \n

      The two axes run separately. A 2D filter of this family factors into two 1D passes, which turns work per output pixel from support squared into support: about 13 taps instead of 44 at the reduction this site uses.

      \n

      The forward transfer is a table. It takes an 8 bit value, so it has exactly 256 answers and does not need a powf 119 million times. That one change cut the resample core from 42.9 ms to 22.9 ms with byte identical output.

      \n\n

      Six wrong answers, and the control that caught each one

      \n

      The gamma defect is the easy part of this story. The hard part is that it survived years of measurement, and so did five other things, because in each case the instrument could not see the defect it was pointed at.

      \n
      \n

      The pattern that connects them: a measurement that compares a thing against itself always agrees. Scoring an encode against the reference it came from cannot see a bad reference. Resizing to the size you already are cannot test a kernel that short circuits equal dimensions. Reading a metric you invented cannot tell you the metric is insensitive.

      \n

      What worked every time was cheaper than it sounds. Build the control that would fail if the thing were broken, then break the thing on purpose and watch the control go red. The regression test for the rotation fix was written that way, and so was the probe column that finally caught sips leaving noise on a flat field.

      \n\n

      What the correct version costs

      \n

      Correct pixels compress worse, because the gamma incorrect average was quietly destroying high frequency energy that a correct one keeps. Regenerating this site's 660 thumbnail files moved them 15164 KB to 15392 KB, which is 1.5%.

      \n

      Against a ground truth downscale, delivered quality on real photographs moved from 57.2 to 84.8 as a mean over 10 frames. On the Instagram export the same change let three Leica frames beat their old versions by about 4 ssimulacra2 points while landing 43% smaller, because the search could stop reaching for quality it was spending on aliasing.

      \n

      Speed is a fair question, and the first answer this page gave was wrong. It said the whole gamma correct job here beat fast_image_resize's own gamma correct path, 70 ms against 60.3. The 70 was this kernel on Box and the 60.3 was theirs on Lanczos3, which is a comparison between two filters wearing the costume of a comparison between two libraries. Measured apples to apples on 2026-09-02, one process per cell and best of five, the same 5952 by 3968 frame to 900 by 600, whole 8 bit job against their sRGB mapper into 16 bit linear and back: Box 43.9 ms against 37.5, Lanczos3 123.6 against 63.0. Theirs is ahead by 1.2 times and 2 times. The kernels themselves are at parity when both are fed linear f32, 23.1 against 19.6 on Box and 99.6 against 102.0 on Lanczos3, agreeing to within f32 rounding; what wins them the whole job is that a 16 bit lane is half an f32 lane in SIMD, and that this transfer step is scalar. Their gamma wrong default, at 8.4 and 28.6 ms, is faster still. So the honest sentence is the one the old paragraph ended on: the gap is the price of the transfer function rather than of the kernel, and the price is theirs to skip and mine to pay.

      \n\n

      Offered upstream

      \n

      Two of these were somebody else's to fix, and the two went differently. image-rs/image#3101 proposed an opt in linear light resize, with measurements aimed at the objection that converting to a 16 bit linear space is too expensive to do transparently. It was closed the same day, with one reply pointing at the project's policy that issue comments must be human written. That is a process answer rather than a technical one, and it is theirs to give; the kernel and its property tests ship on their own instead, as halflight, a crate and an npm package whose conformance suite is where the corrected speed numbers above come from. oven-sh/bun#40510 reported the same defect in Bun.Image with a self contained repro, where the argument was different: a runtime whose image API shipped this year has no back compatible golden images anchoring it to the wrong default. It was reproduced upstream, and the fix in flight adds an opt in linear colourspace option rather than changing the default, which is the one outcome the argument could not reach.

      \n\n

      What would change my mind

      \n

      The byte cost is real and it is the honest objection. If a wider corpus showed the 1.5% growing into double digits at some other reduction ratio or subject matter, the right answer would be to spend the difference on the quality knob instead and take a smaller correctness win. The measurement that would settle it is a matched bytes comparison across a few hundred frames from several cameras, and this site has 165 frames from two.

      \n

      The other open edge is the transfer curve itself. This pipeline assumes sRGB except where a file declares Gray Gamma 2.2, which covers its two cameras and would be wrong for anything shot in Display P3 or a log profile. Reading the curve from the ICC profile rather than from a filename pattern is the fix, and it is not written yet.

      ", - "pageCss": ".hl-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:12px;margin:10px 0;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}\n.hl-demo .cell{text-align:center;font-size:8.6pt}\n.hl-demo .sw{width:104px;height:104px;border:1px solid #7a8ba3;display:block;margin:0 0 5px}\n.hl-demo .n{font-family:var(--font-mono);font-size:11pt;font-weight:700}\n.hl-demo .lbl{color:#3a4a5e}\n.hl-code{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:8.4pt;margin:8px 0}\n.hl-table{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:2px 0}\n.hl-table .row{display:grid;grid-template-columns:11em 10em 1fr;gap:10px;align-items:baseline;padding:4px 10px;font-size:8.6pt;border-bottom:1px solid #e4eaf3}\n.hl-table .row:last-child{border-bottom:0}\n.hl-table .row.head{font-weight:700;background:#e8eef7}\n@media(max-width:560px){.hl-table .row{grid-template-columns:1fr}}", + "pageCss": ".hl-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:12px;margin:10px 0;display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}\n.hl-demo .cell{text-align:center;font-size:var(--text-micro)}\n.hl-demo .sw{width:104px;height:104px;border:1px solid #7a8ba3;display:block;margin:0 0 5px}\n.hl-demo .n{font-family:var(--font-mono);font-size:var(--text-lede);font-weight:700}\n.hl-demo .lbl{color:#3a4a5e}\n.hl-code{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:var(--text-micro);margin:8px 0}\n.hl-table{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:10px 0;padding:2px 0}\n.hl-table .row{display:grid;grid-template-columns:11em 10em 1fr;gap:10px;align-items:baseline;padding:4px 10px;font-size:var(--text-micro);border-bottom:1px solid #e4eaf3}\n.hl-table .row:last-child{border-bottom:0}\n.hl-table .row.head{font-weight:700;background:#e8eef7}\n@media(max-width:560px){.hl-table .row{grid-template-columns:1fr}}", "pageJs": "(function(){\n // The checkerboard is BUILT here rather than shipped as an image, so the\n // measurement is of the reader's own browser and cannot be a picture of\n // someone else's result.\n var el = document.getElementById(\"hl-demo\");\n if (el) {\n var N = 256, F = 16, S = N / F;\n var lin = function(c){ var s = c / 255; return s <= 0.04044993 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };\n var enc = function(l){ var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055; return Math.round(s * 255); };\n var a = document.createElement(\"canvas\"); a.width = a.height = N;\n var ax = a.getContext(\"2d\", { willReadFrequently: true });\n var im = ax.createImageData(N, N);\n for (var y = 0; y < N; y++) for (var x = 0; x < N; x++) {\n var v = ((x + y) & 1) ? 255 : 0, i = (y * N + x) * 4;\n im.data[i] = im.data[i + 1] = im.data[i + 2] = v; im.data[i + 3] = 255;\n }\n ax.putImageData(im, 0, 0);\n var b = document.createElement(\"canvas\"); b.width = b.height = S;\n var bx = b.getContext(\"2d\", { willReadFrequently: true });\n bx.imageSmoothingEnabled = true; bx.imageSmoothingQuality = \"high\";\n bx.drawImage(a, 0, 0, S, S);\n var bd = bx.getImageData(0, 0, S, S).data, cs = 0, cn = 0;\n for (var p = 0; p < bd.length; p += 4) { cs += bd[p]; cn++; }\n var canvasMean = cs / cn;\n var ls = 0;\n for (var oy = 0; oy < S; oy++) for (var ox = 0; ox < S; ox++) {\n var sum = 0;\n for (var dy = 0; dy < F; dy++) for (var dx = 0; dx < F; dx++)\n sum += lin(im.data[(((oy * F + dy) * N) + (ox * F + dx)) * 4]);\n ls += enc(sum / (F * F));\n }\n var linMean = ls / (S * S);\n var cell = function(v, label) {\n var g = Math.round(v);\n return '
      ' +\n '' + v.toFixed(2) + '
      ' + label + '
      ';\n };\n el.innerHTML = cell(canvasMean, \"canvas drawImage\") + cell(linMean, \"averaged over light\") +\n '
      The correct answer is 187.5. ' +\n 'Your browser reports ' + canvasMean.toFixed(2) + ', which is the average of the codes rather than of the light.
      ';\n }\n\n // The six rows are the page's argument, so they live in one place and render\n // rather than being restated in prose where the copies could drift.\n var t = document.getElementById(\"hl-table\");\n if (!t) return;\n var rows = [\n [\"the shipped resize\", \"score against its own reference\", \"a defective reference scores itself perfectly\"],\n [\"the quality target\", \"the same ssimulacra2 number\", \"a clean reference is easier to match, so 70 bought a worse file\"],\n [\"dropping a resize step\", \"mean linear luminance\", \"insensitive here; ssimulacra2 against ground truth said +27.5\"],\n [\"jpegtran rotation\", \"the exit code\", \"off the block grid it degrades and still exits 0\"],\n [\"a claim about image-rs\", \"resize to the current size\", \"both entry points short circuit, so the kernel never ran\"],\n [\"a container contract test\", \"the first 12 files on disk\", \"readdir order differs by filesystem, so CI and the laptop sampled different files\"]\n ];\n var html = '
      what looked finethe instrumentwhat it could not see
      ';\n for (var i = 0; i < rows.length; i++)\n html += '
      ' + rows[i][0] + '' + rows[i][1] + '' + rows[i][2] + '
      ';\n t.innerHTML = html;\n})();", "editorial": { "reader": "Someone who runs images through a resize step and assumes a well regarded library made that step correct.", diff --git a/pipelines/garage/specs/typed-config.json b/pipelines/garage/specs/typed-config.json index 4a8736db3..30e9fe356 100644 --- a/pipelines/garage/specs/typed-config.json +++ b/pipelines/garage/specs/typed-config.json @@ -5,7 +5,7 @@ "status": "watching", "added": "2026-08-24", "bodyHtml": "

      The config that types itself

      \n

      A Worker config is data, so every binding name in it is a string that nothing checks. Wrangler can load a config that is a TypeScript program instead, behind a flag it still hides, and Cloudflare's new cf CLI loads the same file with no flag at all. Both infer the Worker's Env from it. This page measured that twice, in August and again on 2026-10-05. The upload still comes out byte-identical. The Durable Object type still degrades for a Worker that binds its own class, and now carries the class through when the binding names a second Worker. And one August claim was wrong: wrangler types already wrote a plain-text variable's value into the type.

      \n\n

      Two runs, two toolchains

      \n

      The first run, on 2026-08-24, used wrangler 4.125.0 with @cloudflare/config 0.7.0. The second, on 2026-10-05, used wrangler 4.147.0 (the pkg.pr.new build at f025bbf this site pins), @cloudflare/config 0.23.0, cf 1.0.0-beta.12, TypeScript 7.0.2 and Node 26.10.0. Config 0.23.0 is what cf installs today, so it is what a reader gets. Every row was cross-checked on 0.20.0, this site's own pin, and came back the same except where a row says otherwise.

      \n

      Where the two runs disagree, the page says so in place and keeps the August result beside the October one.

      \n\n

      The thing a config cannot do

      \n

      Here is the whole problem in one line. In August this site's Worker declared 21 bindings across two config files, and its request handler typed them like this:

      \n
      @typedef {(request: Request, env: any, ctx: any, url: URL) => Response} RouteHandler
      \n

      any. Every KV namespace, every D1 database, the R2 bucket, the Durable Object, all of it. Rename a binding in wrangler.jsonc and nothing in the tree goes red; the failure arrives at runtime as a property read on undefined, in production, on whichever route touched it first.

      \n

      That is a property of the format. JSON cannot export a type. The usual repair is wrangler types, which reads the config and writes a .d.ts, and it works: it produces the full interface, Durable Object generics and all. On 4.125.0 and on 4.147.0 alike, it also prints this when it finishes.

      \n
      Remember to rerun 'wrangler types' after you change your wrangler.jsonc file.
      \n

      A generated file plus a reminder is a snapshot plus a promise. The snapshot is correct on the day it is taken and silently wrong afterwards, which is the same shape as every committed artifact this site has replaced with a build step.

      \n\n

      What the flag is, and the door beside it

      \n

      Wrangler loads a cloudflare.config.ts, and an optional wrangler.config.ts beside it, behind --x-new-config. On 4.147.0 the flag is still marked hidden: true, and it appears in the help of none of build, deploy, dev, types or versions upload. The package still describes itself as \"not yet stable enough for external use\".

      \n

      Two things moved around the flag since August. Cloudflare now documents the file as an open beta, in a page last updated 2026-09-30, where in August a search found no documentation at all. And the documented door is cf, which imports the helpers from cf/config and, for a JavaScript Worker, hands the build to wrangler. On 2026-10-02 a wrangler maintainer answered workers-sdk#16002 with \"we don't intend to support cloudflare.config.ts in more Wrangler commands\" and recommended cf. So the flag keeps working where it works today, and new commands for this format arrive in cf.

      \n

      The two filenames are the reverse of the guess. cloudflare.config.ts holds the Worker: name, compatibility date, entrypoint, triggers, bindings, exports, observability, placement, limits. wrangler.config.ts holds the tooling: the build command, the assets directory, dev settings, tsconfig, source maps. Put a Worker field in the tooling file and the error names the mistake and the fix, on 4.147.0 as on 4.125.0:

      \n
      compatibilityDate is not a supported field in wrangler.config.ts. Move it to cloudflare.config.ts.
      \n\n

      Build a config and watch three things move

      \n

      Toggle bindings on the left. The middle pane is the config you would write, in the shape that builds today. The right pane switches between what the platform receives and what your env becomes. Every row below was measured through a real Worker on 2026-10-05: the resolved JSON is what wrangler build --x-new-config --x-cf-build-output wrote, and each type is what the compiler reported for that key of the generated interface.

      \n
      \n
      \n
      \n
      bindings
      \n
      \n
      exports
      \n
      \n
      \n
      \n
      cloudflare.config.tswhat you write
      \n
      \n
      \n
      \n
      \n \n \n \n \n
      \n
      \n
      \n
      \n

      The resolved pane is the Build Output Specification wrangler writes to .cloudflare/output/v0/workers/default/worker.config.json, which is the readable answer to \"what did my config actually become\". In August that file was config.json and opened with \"type\": \"worker\"; today it drops that key and ends with a manifest. The Env pane is what tsc reported for each key of the generated interface.

      \n
      \n

      The two front doors agree. cf build (1.0.0-beta.12) printed \"Delegating to Wrangler\" and wrote the same four files wrangler build did, sha256 for sha256. The snippet imports from @cloudflare/config/public, as this site's own cloudflare.config.ts does. The documentation's cf/config re-exports that module in one line, so either import works, and the resolved output is identical.

      \n

      One trap sits between the build and the types. The generated .cloudflare/types/index.d.ts imports cf/config. Install wrangler and @cloudflare/config without cf, keep skipLibCheck on as most templates do, and nothing complains: Env comes back empty, and every binding read is TS2339 on a value typed any. Only with skipLibCheck off does TS2307 name the missing module. Add cf as a dev dependency, as the docs say, or map cf/config in paths, which is what this site does.

      \n\n

      A variable arrives in the type as its own value, in both formats

      \n

      Thirteen binding kinds went through the compiler. Most land where you would guess, and three carry more than a bare type.

      \n
      \n

      bindings.text(\"default\") gives env.AI_GATEWAY the type \"default\", so the variable's value is in the type and a comparison against a string that was never configured is a compile error. bindings.json({ dcz: true, tier: 2 }) comes back as { dcz: true; tier: number }, where the boolean narrows to a literal and the number widens, which is ordinary TypeScript inference showing through. And bindings.secret() types the secret as string, so a secret you forgot to set is a name the compiler already knows about.

      \n

      This section used to say a JSON config structurally cannot do this. That was wrong, and it was already wrong on 4.125.0. Run against an equivalent wrangler.jsonc, wrangler types writes AI_GATEWAY: \"default\" on both 4.125.0 and 4.147.0. It types the required secret as string too, and it narrows the JSON variable further than inference does, to {\"dcz\":true,\"tier\":2}. What the TypeScript config changes is when the type is computed. wrangler types takes a snapshot that the reminder above asks you to retake. The inferred Env reads the config every time the compiler runs, so a renamed binding is a type error with no regeneration step.

      \n

      One row is new since August. bindings.analyticsSQL(), added in config 0.22.0, resolves to {\"type\":\"analytics\"} and types as AnalyticsSQLBinding. Wrangler 4.147.0 builds it. cf 1.0.0-beta.5, which carries config 0.20.0, lets wrangler write the build output and then refuses that output on its own schema check, with \"Invalid discriminator value\", and exits 1. Beta.12 accepts it.

      \n\n

      The row that went backwards, and the way around it

      \n

      The Durable Object type is where the new format lost in August, and the October run found a way around it that works for some Workers and leaves the rest where they were. Against a JSON config, wrangler types threads the class through, on 4.147.0 as on 4.125.0:

      \n
      COUNTER: DurableObjectNamespace<import(\"./src/index\").Counter>;\nBOOKING: Workflow<Parameters<import(\"./src/index\").Booking['run']>[0]['payload']>;
      \n

      Under inference, a binding that names its Worker by string still comes back as DurableObjectNamespace<undefined>, so a stub method call fails with Property 'increment' does not exist on type 'DurableObjectStub<undefined>'. That holds with the entrypoint given as a path string and with it given as a module through the { type: \"cf-worker\" } import attribute, the same two forms August tried.

      \n

      What changed is the binding helper. Its worker option (spelled workerName in August) now takes either a name or a Worker definition. Bind a class that a second Worker exports, pass that Worker's definition, and give that definition a module entrypoint:

      \n
      import * as counterModule from \"./src/index.ts\" with { type: \"cf-worker\" };\n\nexport const counterWorker = defineWorker({\n  name: \"probe-counter\",\n  entrypoint: counterModule,\n  exports: { Counter: exports.durableObject({ storage: \"sqlite\" }) },\n});\n\n// in the binding Worker's env:\nCOUNTER: bindings.durableObject({ worker: counterWorker, exportName: \"Counter\" }),
      \n

      Now env.COUNTER types as DurableObjectNamespace<Counter>, and await stub.increment(1) is a number the compiler checks. A Workflow bound the same way types as Workflow<{ slot: string }>. The resolved JSON is the same as the string form, \"worker\": \"probe-counter\", so the definition buys a type and changes nothing the platform receives.

      \n

      Two controls say which half does the work. The same definition with a path-string entrypoint gives DurableObjectNamespace<DurableObjectBranded>, which still has no methods, so the module import is load-bearing. And a Worker that binds its own class cannot take this route: pointing the binding at its own definition through a factory is circular, tsc reports TS7022, and the binding collapses to never. One more thing the build does not do is type-check the config. A Workflow binding whose exportName the definition did not declare was a compile error, and wrangler build still exited 0.

      \n

      So the scoreboard moved. Cross-Worker Durable Objects and Workflows now type as precisely as wrangler types types them, and stay current without a regeneration step. A Worker that binds its own Durable Object, the common single-Worker case, still types worse than the snapshot does.

      \n\n

      Migrations become a state on an export

      \n

      The cumulative migration list is gone. A class declares what it is on its own export, and the storage engine sits beside it:

      \n
      \n \n \n \n \n
      wrangler.tomlcloudflare.config.ts
      [[migrations]] tag = \"v1\"
      new_sqlite_classes = [\"Counter\"]
      Counter: exports.durableObject({ storage: \"sqlite\" })
      deleted_classes = [\"Old\"]Old: exports.durableObject({ state: \"deleted\" })
      renamed_classes = [{ from, to }]Old: exports.durableObject({ state: \"renamed\", renamedTo: \"New\" })
      \n

      This is a different upload path. Wrangler 4.147.0 still decides between them in one function, resolveDoLifecyclePayload: when a config declares Durable Object exports it sends those exports and sends no migrations at all, and it computes a migration only for configs that declare none.

      \n

      In August that left one question a dry run structurally cannot answer, because a dry run never computes a migration: would the API accept the export form for a class that already exists under an applied migration tag? This site's demo Worker answered it on 2026-09-21. It deployed on this format (wrangler 4.136.0, pin b168333) against a Counter created under tag = \"v1\", the API accepted it, and the counter read 18 and then 20 on the calls after, where a recreated namespace would have restarted from 0. That answer covers wrangler deploy. It says nothing about versions upload, which refuses Durable Object lifecycle changes on its own path.

      \n\n

      One thing it still will not do, and one it now does

      \n

      The dependency instrumentation key still has no home. The old config set [dependencies_instrumentation] enabled = true, and config 0.23.0 has no field for it. The escape hatch offers no route around it, because unsafe carries metadata and capnp schemas alone. This cost nothing, for a reason only readable in wrangler's source: 4.147.0 still tests the key as enabled !== false, so an absent block behaves exactly like the explicit true. A dry run reports on none of this, which is why the answer came from reading the upload path.

      \n

      The types file now comes from a build. wrangler types --x-new-config still fails, with \"Unknown arguments: x-new-config, xNewConfig\". In August the generated file came only from wrangler dev, so the inferred types existed on a machine somebody had run the dev server on. On 4.147.0, wrangler build --x-new-config --x-cf-build-output writes it too, and so do cf build and cf workers types, which wrote the same 622,861 bytes. That is the build step this page asked for in August.

      \n\n

      The bun trap, and why it is a trap

      \n

      The loader refuses bun, in a message that names itself, and on 4.147.0 a second warning follows it:

      \n
      cloudflare.config.ts loading is not supported on Bun. Please use Node.js v22.18.0 or higher.\nWrangler does not support the Bun runtime. Please try this command again using Node.js via `npm` or `pnpm`.
      \n

      On a repository whose whole toolchain is bun, that reads as a blocker for about a minute. It is a claim about the process that ends up running wrangler, and the package manager that launched it does not enter into it. Re-run on bun 1.4.3-canary.1:

      \n
      \n \n \n \n \n \n
      invocationresult
      bun x --no-install wrangler … --x-new-configloads, builds
      bun run <script>, resolving the same shimloads
      bun run --bun <script>refused
      bun ./node_modules/wrangler/bin/wrangler.js …refused
      \n

      The first two resolve node_modules/.bin/wrangler, whose #!/usr/bin/env node shebang hands the process to node before any config is read. The last two put bun in front of the loader, one by overriding the shebang and one by running wrangler's entry file directly. So \"does this tool support bun\" turned out to be a question about a shebang, and the answer changed with the invocation while the tool stayed the same.

      \n\n

      What this cost, and what it did not

      \n

      The conversion ran first on this site's smallest deployed Worker, the one behind /garage/cf/*, chosen because it is a demo that a person deploys by hand. In August the upload came out at 8.83 KiB, 3.07 KiB gzip, the same as before to the byte, with the same four bindings. Re-measured on 4.147.0 against the old wrangler.toml with its flags and minify setting brought level, both configs produce the same index.js, sha256 for sha256, at 5.94 KiB, 2.63 KiB gzip. The source maps differ only in their output-relative paths.

      \n

      In August the main Worker still typed env as any, because converting it meant folding two config files of 24,662 and 10,987 bytes into one program and putting a hidden flag on the path that publishes production. It moved on 2026-09-28, with an upload byte-identical to the JSON's. Its handlers take env: Env now, from a hand-written interface that a check diffs against cloudflare.config.ts in both directions. That choice is about optional secrets, which the interface marks with ? so every read has to face their absence, and about the Durable Object: its Counter lives in a second Worker that still has a wrangler.jsonc, so there is no definition to import yet.

      \n\n

      What would change the verdict

      \n

      Two of August's three triggers have fired. The API accepted the export-based Durable Object form on a real deploy, so the migration table is a translation. Documentation arrived, though the flag stayed hidden and the documented door became cf.

      \n

      The third half-fired: the class type now threads through a binding to another Worker. What would finish it is a self-bound Durable Object that types as its class, which would remove the last column where wrangler types wins. The other result worth watching is the format leaving open beta, since several shapes on this page moved between the two runs: the import path, the default export, the Durable Object binding key and the build output file.

      ", - "pageCss": ".tc-code{background:#f4f6fa;border:1px solid #c8d2e2;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:9pt;line-height:1.5}\n.tc-code code{white-space:pre}\n.tc-demo{border:1px solid #9aa7ba;background:#eef1f6;margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.tc-cols{display:grid;grid-template-columns:minmax(150px,1fr) minmax(0,1.5fr) minmax(0,1.5fr);gap:1px;background:#9aa7ba}\n.tc-pick{background:#f6f8fb;padding:0 0 8px}\n.tc-pick-head{font-family:var(--font-caption);font-size:8.5pt;text-transform:uppercase;letter-spacing:.06em;color:#4a5568;background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:5px 8px}\n.tc-pick-head-2{margin-top:8px;border-top:1px solid #c8d2e2}\n.tc-row{display:flex;align-items:flex-start;gap:6px;padding:4px 8px;font-size:8.5pt;line-height:1.35;cursor:pointer}\n.tc-row:hover{background:#e8eefa}\n.tc-row input{margin:2px 0 0;flex:0 0 auto}\n.tc-row b{font-family:var(--font-mono);font-weight:normal;color:#123;display:block}\n.tc-row span{color:#6b7688;display:block;font-size:8pt}\n.tc-pane{background:#fff;min-width:0;display:flex;flex-direction:column}\n.tc-pane-head{background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:4px 8px;font-size:8.5pt;color:#4a5568;display:flex;justify-content:space-between;align-items:center;gap:6px;min-height:26px}\n.tc-dim{color:#8b95a6}\n.tc-tabs{display:inline-flex;gap:4px}\n.tc-tabs button{font:inherit;font-size:8pt;padding:1px 7px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,#e6eaf1);border-radius:3px;cursor:pointer;color:#33415c}\n.tc-tabs button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.tc-src{margin:0;padding:8px 10px;font-size:8.5pt;line-height:1.45;overflow:auto;max-height:390px;flex:1}\n.tc-src code{white-space:pre;font-family:var(--font-mono)}\n.tc-legend{font-size:8.5pt;color:#5c6779;margin:0;padding:7px 10px;border-top:1px solid #c8d2e2;background:#f6f8fb}\n.tc-scroll{overflow-x:auto;margin:0 0 12px}\n.tc-table{border-collapse:collapse;font-size:8.5pt;width:100%;min-width:520px}\n.tc-table th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:#33415c}\n.tc-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.tc-table td code{font-size:8pt}\n.tc-table tr:nth-child(even) td{background:#f8fafd}\n.tc-ok{color:#1c6b32;font-weight:bold}\n.tc-no{color:#a3261a;font-weight:bold}\n.tc-mig td,.tc-bun td{line-height:1.5}\n@media(max-width:760px){.tc-cols{grid-template-columns:1fr}.tc-src{max-height:250px}}", + "pageCss": ".tc-code{background:#f4f6fa;border:1px solid #c8d2e2;border-left:3px solid #0831d9;padding:8px 10px;margin:0 0 12px;overflow-x:auto;font-size:var(--text-xs);line-height:1.5}\n.tc-code code{white-space:pre}\n.tc-demo{border:1px solid #9aa7ba;background:#eef1f6;margin:14px 0 16px;box-shadow:inset 1px 1px 0 #fff}\n.tc-cols{display:grid;grid-template-columns:minmax(150px,1fr) minmax(0,1.5fr) minmax(0,1.5fr);gap:1px;background:#9aa7ba}\n.tc-pick{background:#f6f8fb;padding:0 0 8px}\n.tc-pick-head{font-family:var(--font-caption);font-size:var(--text-micro);text-transform:uppercase;letter-spacing:.06em;color:#4a5568;background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:5px 8px}\n.tc-pick-head-2{margin-top:8px;border-top:1px solid #c8d2e2}\n.tc-row{display:flex;align-items:flex-start;gap:6px;padding:4px 8px;font-size:var(--text-micro);line-height:1.35;cursor:pointer}\n.tc-row:hover{background:#e8eefa}\n.tc-row input{margin:2px 0 0;flex:0 0 auto}\n.tc-row b{font-family:var(--font-mono);font-weight:normal;color:#123;display:block}\n.tc-row span{color:#6b7688;display:block;font-size:var(--text-2xs)}\n.tc-pane{background:#fff;min-width:0;display:flex;flex-direction:column}\n.tc-pane-head{background:#e3e9f2;border-bottom:1px solid #c8d2e2;padding:4px 8px;font-size:var(--text-micro);color:#4a5568;display:flex;justify-content:space-between;align-items:center;gap:6px;min-height:26px}\n.tc-dim{color:#8b95a6}\n.tc-tabs{display:inline-flex;gap:4px}\n.tc-tabs button{font:inherit;font-size:var(--text-2xs);padding:1px 7px;border:1px solid #a9b4c6;background:linear-gradient(180deg,#fff,#e6eaf1);border-radius:3px;cursor:pointer;color:#33415c}\n.tc-tabs button.on{background:linear-gradient(180deg,#cfe0fb,#a8c6f4);border-color:#5f86c8;color:#12305e;font-weight:bold}\n.tc-src{margin:0;padding:8px 10px;font-size:var(--text-micro);line-height:1.45;overflow:auto;max-height:390px;flex:1}\n.tc-src code{white-space:pre;font-family:var(--font-mono)}\n.tc-legend{font-size:var(--text-micro);color:#5c6779;margin:0;padding:7px 10px;border-top:1px solid #c8d2e2;background:#f6f8fb}\n.tc-scroll{overflow-x:auto;margin:0 0 12px}\n.tc-table{border-collapse:collapse;font-size:var(--text-micro);width:100%;min-width:520px}\n.tc-table th{text-align:left;background:#e3e9f2;border:1px solid #c8d2e2;padding:4px 7px;font-family:var(--font-caption);color:#33415c}\n.tc-table td{border:1px solid #d8dfeb;padding:4px 7px;vertical-align:top}\n.tc-table td code{font-size:var(--text-2xs)}\n.tc-table tr:nth-child(even) td{background:#f8fafd}\n.tc-ok{color:#1c6b32;font-weight:bold}\n.tc-no{color:#a3261a;font-weight:bold}\n.tc-mig td,.tc-bun td{line-height:1.5}\n@media(max-width:760px){.tc-cols{grid-template-columns:1fr}.tc-src{max-height:250px}}", "pageJs": "// /garage/typed-config — the config explorer.\n//\n// Every row below was measured on 2026-10-05 against a real Worker on wrangler\n// 4.147.0 (pkg.pr.new f025bbf) with @cloudflare/config 0.23.0 and cf\n// 1.0.0-beta.12, cross-checked on config 0.20.0: `resolved` is what\n// `wrangler build --x-new-config --x-cf-build-output` wrote to\n// .cloudflare/output/v0/workers/default/worker.config.json for that binding\n// (`cf build` wrote the same bytes), and `env` is what tsc 7.0.2 reported for\n// that key of the generated interface. ASSETS is the one row where tsc printed\n// the expansion of a type alias; the config's type map names it `Fetcher`.\n// The first run, on 2026-08-24, used wrangler 4.125.0 with config 0.7.0.\n// Nothing here is derived at runtime, so the page states measurements rather\n// than guesses.\n(function () {\n var BINDINGS = [\n { key: \"RN_KV\", note: \"KV namespace\", call: 'bindings.kv({ id: \"3cb8a107c58e47dc9244e75b33401f36\" })',\n resolved: { type: \"kv\", id: \"3cb8a107c58e47dc9244e75b33401f36\" }, env: \"KVNamespace\", on: true },\n { key: \"RESTORE_DB\", note: \"D1 database\", call: 'bindings.d1({ id: \"88c8daf1-3a36-4f8e-a2ad-dba8a74e1b9f\", name: \"aadhar-restore\" })',\n resolved: { type: \"d1\", name: \"aadhar-restore\", id: \"88c8daf1-3a36-4f8e-a2ad-dba8a74e1b9f\" }, env: \"D1Database\", on: true },\n { key: \"PHOTOS_R2\", note: \"R2 bucket\", call: 'bindings.r2({ name: \"aadhar-photos\" })',\n resolved: { type: \"r2\", name: \"aadhar-photos\" }, env: \"R2Bucket\", on: true },\n { key: \"AI\", note: \"Workers AI\", call: \"bindings.ai()\", resolved: { type: \"ai\" }, env: \"Ai\", on: false },\n { key: \"BROWSER\", note: \"Browser Run\", call: \"bindings.browser()\", resolved: { type: \"browser\" }, env: \"BrowserRun\", on: false },\n { key: \"BOT_LEDGER\", note: \"Analytics Engine, the write side\", call: 'bindings.analyticsEngineDataset({ name: \"aadhar_bot_ledger\" })',\n resolved: { type: \"analytics-engine-dataset\", name: \"aadhar_bot_ledger\" }, env: \"AnalyticsEngineDataset\", on: false },\n { key: \"LEDGER_SQL\", note: \"Analytics SQL, the read side; new in config 0.22.0\", call: \"bindings.analyticsSQL()\",\n resolved: { type: \"analytics\" }, env: \"AnalyticsSQLBinding\", on: false },\n { key: \"CF_VERSION_METADATA\", note: \"version metadata\", call: \"bindings.versionMetadata()\",\n resolved: { type: \"version-metadata\" }, env: \"WorkerVersionMetadata\", on: false },\n { key: \"ASSETS\", note: \"static assets; the directory lives in wrangler.config.ts\", call: \"bindings.assets()\",\n resolved: { type: \"assets\" }, env: \"Fetcher\", on: false },\n { key: \"COUNTER\", note: \"Durable Object stub, its Worker named by string\", call: 'bindings.durableObject({ worker: \"probe\", exportName: \"Counter\" })',\n resolved: { type: \"durable-object\", worker: \"probe\", exportName: \"Counter\" }, env: \"DurableObjectNamespace\", on: true },\n { key: \"SIGNING_SECRET\", note: \"secret; the value never enters the config\", call: \"bindings.secret()\",\n resolved: { type: \"secret\" }, env: \"string\", on: true },\n { key: \"AI_GATEWAY\", note: \"plain text; the value reaches the type\", call: 'bindings.text(\"default\")',\n resolved: { type: \"text\", value: \"default\" }, env: '\"default\"', on: true },\n { key: \"FEATURES\", note: \"JSON; booleans narrow, numbers widen\", call: \"bindings.json({ dcz: true, tier: 2 })\",\n resolved: { type: \"json\", value: { dcz: true, tier: 2 } }, env: \"{ dcz: true; tier: number; }\", on: true },\n ];\n\n var EXPORTS = [\n { key: \"Counter\", note: \"created, SQLite storage\", call: 'exports.durableObject({ storage: \"sqlite\" })',\n resolved: { type: \"durable-object\", storage: \"sqlite\" }, on: true },\n { key: \"Legacy\", note: \"deleted; this destroys its stored data\", call: 'exports.durableObject({ state: \"deleted\" })',\n resolved: { type: \"durable-object\", state: \"deleted\" }, on: false },\n ];\n\n var rows = document.getElementById(\"tc-rows\");\n var rowsX = document.getElementById(\"tc-rows-x\");\n var srcEl = document.getElementById(\"tc-src\");\n var outEl = document.getElementById(\"tc-out\");\n var table = document.getElementById(\"tc-table\");\n if (!rows || !srcEl || !outEl) return;\n var mode = \"resolved\";\n\n function pick(list, host) {\n list.forEach(function (item) {\n var label = document.createElement(\"label\");\n label.className = \"tc-row\";\n var box = document.createElement(\"input\");\n box.type = \"checkbox\";\n box.checked = item.on;\n box.addEventListener(\"change\", function () { item.on = box.checked; render(); });\n var text = document.createElement(\"span\");\n var name = document.createElement(\"b\");\n name.textContent = item.key;\n var note = document.createElement(\"span\");\n note.textContent = item.note;\n text.appendChild(name);\n text.appendChild(note);\n label.appendChild(box);\n label.appendChild(text);\n host.appendChild(label);\n });\n }\n\n function chosen(list) { return list.filter(function (i) { return i.on; }); }\n\n // The shape that builds on wrangler 4.147.0 and cf 1.0.0-beta.12, written the\n // way this site's cloudflare.config.ts is: helpers from @cloudflare/config/public\n // (workers-sdk#15914 moved them out of wrangler/experimental-config; cf/config\n // re-exports the same module), and one default export, defineConfig({ worker })\n // (workers-sdk#15713 retired the defineWorker default export this page emitted\n // in August).\n function source() {\n var env = chosen(BINDINGS);\n var ex = chosen(EXPORTS);\n var helpers = [\"bindings\", \"defineConfig\", \"defineWorker\"];\n if (ex.length) helpers.push(\"exports\");\n helpers.push(\"triggers\");\n var lines = [];\n lines.push(\"import { \" + helpers.join(\", \") + ' } from \"@cloudflare/config/public\";');\n lines.push(\"\");\n lines.push(\"const worker = defineWorker({\");\n lines.push(' name: \"probe\",');\n lines.push(' compatibilityDate: \"2026-08-15\",');\n lines.push(' entrypoint: \"./src/index.ts\",');\n lines.push(' triggers: [triggers.fetch({ pattern: \"aadhar.sh/probe/*\", zone: \"aadhar.sh\" })],');\n if (env.length) {\n lines.push(\" env: {\");\n env.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.call + \",\"); });\n lines.push(\" },\");\n }\n if (ex.length) {\n lines.push(\" exports: {\");\n ex.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.call + \",\"); });\n lines.push(\" },\");\n }\n lines.push(\"});\");\n lines.push(\"\");\n lines.push(\"export default defineConfig({ worker });\");\n return lines.join(\"\\n\");\n }\n\n function resolved() {\n var out = {\n name: \"probe\",\n compatibilityDate: \"2026-08-15\",\n triggers: [{ type: \"fetch\", pattern: \"aadhar.sh/probe/*\", zone: \"aadhar.sh\" }],\n };\n var env = chosen(BINDINGS);\n var ex = chosen(EXPORTS);\n if (env.length) {\n out.env = {};\n env.forEach(function (b) { out.env[b.key] = b.resolved; });\n }\n if (ex.length) {\n out.exports = {};\n ex.forEach(function (b) { out.exports[b.key] = b.resolved; });\n }\n out.manifest = { type: \"complete\", mainModule: \"index.js\", modules: { \"index.js\": { type: \"esm\" } } };\n return JSON.stringify(out, null, 2);\n }\n\n function envType() {\n var env = chosen(BINDINGS);\n if (!env.length) return \"interface Env {}\\n\\n// No bindings selected, so env carries nothing\\n// and every property read is a compile error.\";\n var lines = [\"interface Env {\"];\n env.forEach(function (b) { lines.push(\" \" + b.key + \": \" + b.env + \";\"); });\n lines.push(\"}\");\n return lines.join(\"\\n\");\n }\n\n function render() {\n srcEl.textContent = source();\n outEl.textContent = mode === \"resolved\" ? resolved() : envType();\n }\n\n var tabs = document.querySelectorAll(\".tc-tabs button\");\n Array.prototype.forEach.call(tabs, function (btn) {\n btn.addEventListener(\"click\", function () {\n mode = btn.getAttribute(\"data-t\");\n Array.prototype.forEach.call(tabs, function (b) { b.classList.toggle(\"on\", b === btn); });\n render();\n });\n });\n\n if (table) {\n var head = document.createElement(\"tr\");\n [\"binding\", \"written as\", \"reaches the platform as\", \"env type\"].forEach(function (h) {\n var th = document.createElement(\"th\");\n th.textContent = h;\n head.appendChild(th);\n });\n table.appendChild(head);\n BINDINGS.forEach(function (b) {\n var tr = document.createElement(\"tr\");\n [b.key, b.call, JSON.stringify(b.resolved), b.env].forEach(function (cell, i) {\n var td = document.createElement(\"td\");\n var code = document.createElement(\"code\");\n code.textContent = cell;\n td.appendChild(code);\n if (i === 3) td.className = \"tc-envcell\";\n tr.appendChild(td);\n });\n table.appendChild(tr);\n });\n }\n\n pick(BINDINGS, rows);\n if (rowsX) pick(EXPORTS, rowsX);\n render();\n})();", "editorial": { "reader": "Someone who maintains a Cloudflare Worker whose config is JSON, wondering whether the TypeScript config format is worth moving to now that cf documents it and wrangler still hides the flag.", diff --git a/pipelines/garage/specs/useragent.json b/pipelines/garage/specs/useragent.json index 6e55909dc..58c8ba2f7 100644 --- a/pipelines/garage/specs/useragent.json +++ b/pipelines/garage/specs/useragent.json @@ -5,7 +5,7 @@ "status": "shipped", "added": "2026-08-21", "bodyHtml": "

      What a crawler name is worth

      \n

      A user-agent string costs nothing to type and nothing verifies it. So this asked sixteen identities to fetch the same eleven pages, three times each, and recorded what came back. Sites answer a name in four different ways, three of which any name absent from a list walks straight through. One site catches the lie. And on one of them the crawler is handed 1,640 words where a browser gets one.

      \n\n

      The claim under test

      \n

      The prompt for this was an observation going around: that spoofing is worth doing again, and that inventing a plausible AI-company user-agent gets you served like a first-class citizen. Two strings were named specifically, OpenAI File Downloader and XaiImageApiFetch/1.0. Neither is a documented crawler token. Both are in the identity list below for exactly that reason, and both turn out to work.

      \n\n

      What the survey sends

      \n

      Sixteen identities, grouped by what they claim. Three are controls, and they carry the weight of the whole table.

      \n
      \n

      The row labelled ua-survey (ours) is the honest one. It claims nothing it cannot back and points at this page. Read it against googlebot further down, because the gap between those two rows is the argument in miniature.

      \n\n

      The control is what makes the table readable

      \n

      A crawler row returning 403 has two explanations that look identical from one sample. The origin refuses that name, or the origin refuses this instrument and would refuse anything wearing any name. Chrome, curl, and our own string answer the second question, so a refusal below is about the name only when a control got through.

      \n

      That check disqualifies four of the eleven targets, which is the point of running it. On medium.com and quora.com every identity gets 403 including Chrome, so nothing on those hosts is evidence about user-agent policy. Reporting them as sites that block AI crawlers would be reporting our own exclusion as their policy.

      \n\n
      \n
      \n What each identity was served\n \n \n \n \n \n \n \n
      \n
      \n

      ✓ served a readable document · × refused, with the status beside it · ◐ a 200 carrying under 50 words, so the request was allowed and the content is still absent · hover any cell for its status and word count, or switch to with counts. Counts are the median of three trials with scripts and styles stripped.

      \n
      \n\n

      Answer one: a list of names, and it fails open

      \n

      The New York Times refuses five identities and serves everybody else. GPTBot, ClaudeBot, Claude-User and PerplexityBot each get 403 and the same 135-word error page, identically across all three trials, and Amazonbot gets a 403 carrying a different 21-word body, which suggests two rules rather than one list. Chrome gets the paper. So does curl.

      \n

      So do both invented strings. OpenAI File Downloader was served 1,255 words, the same count Chrome received, and XaiImageApiFetch/1.0 was served 1,438. The claim that started this checks out precisely: two made-up names cleared a wall that four real ones could not, and one of them sounds more like OpenAI than GPTBot does.

      \n

      A denylist has to enumerate the thing it stops, so every identity it has never heard of sits on the allowed side by default. The population it actually filters is the crawlers polite enough to announce themselves with a documented token. Declaring yourself is what gets you blocked.

      \n\n

      Answer two: a price

      \n

      The Atlantic does something more interesting than refusing. Applebot, ClaudeBot, Claude-User and PerplexityBot each receive 402 Payment Required and a seven-word body reading {\"message\":\"Please contact the site owner for access.\"}, while the other twelve identities receive the full 1,344-word homepage. 402 is the status code HTTP reserved in 1997 and left undefined for twenty-odd years, and it is now carrying pay-per-crawl.

      \n

      Read what it is aimed at, though. GPTBot pays nothing and gets the homepage, and so do both fabricated names. The bill is addressed to a string, so this is the denylist above with a different status code, and it inherits the same hole. Charging for access is a real change in posture and it is orthogonal to being able to tell who is asking.

      \n\n

      Answer three: reputation, and it fails closed

      \n

      LinkedIn inverts the denylist. An earlier run against a public profile, taken before this survey had made many requests, put three clean bands on the same URL: about 11,800 words to Chrome, about 10,400 to ClaudeBot, and about 7,900 to Claude-User, with Contact Info present for the browser and absent for every crawler. Both invented strings got 999, LinkedIn's house refusal code.

      \n

      An unknown name is refused instead of admitted, which stops the trick that works on the Times. It also refuses a real, well-behaved, newly launched crawler on its first request and every request after, because being unknown is the disqualifying property rather than behaving badly.

      \n
      \n

      The survey broke this row by running it. By the final pass LinkedIn was answering 999 to fifteen of sixteen identities including Chrome, so the control gate marks the target unmeasurable and the bands above are no longer reproducible from this address. The numbers are real and were taken before the block. An instrument that changes its subject by measuring it has to say so, and a survey that keeps sampling a defended origin is training that origin to refuse it. The same thing happened to stackoverflow.com partway through, which slid from 403 into 429.

      \n
      \n\n

      Answer four: verification, and it catches the lie

      \n

      Wikimedia is the only site in the sample doing something a string cannot defeat. It served the full 8,243-word article to Chrome, to curl, to our own invented survey string, to GPTBot, to Claude-User, to PerplexityBot, and to both fabricated names. It refused six: googlebot, bingbot, Applebot, DuckDuckBot, facebookexternalhit and ClaudeBot, each with a 9 or 10 word body reading Unauthorized request. Please contact bot-traffic@wikimedia.org to request unblocking.

      \n

      Every one of those six publishes the addresses its crawler uses, or a reverse DNS procedure, or both. Claiming to be nobody is fine. A nonsense token invented on the spot is fine. Claiming a checkable identity from an address that cannot back it is refused. Three AI crawlers read the article in full during the same run, so what Wikimedia turns away is impersonation rather than crawling. The honest versions of those six are unaffected, because their addresses match what they say.

      \n

      One caveat kept because it spoils the tidy version: GPTBot passes, and OpenAI publishes crawler addresses too. So the check is applied unevenly rather than universally, and the honest claim is narrower than the pattern invites. What held across every trial is the contrast between claiming nothing and claiming Googlebot.

      \n\n

      The direction nobody mentions, where the crawler gets more

      \n

      Reddit is the strongest result in the survey and it points the opposite way from a paywall. A browser asking for /r/programming is served one word. facebookexternalhit, the Facebook link-preview fetcher, is served 1,640. Same URL, same address, same three trials.

      \n

      The mechanism is mundane once you see it, and it is worth stating because the exciting reading is wrong. Reddit renders for humans in the browser and pre-renders for clients that cannot run JavaScript, because a link preview has to work without a JS engine. So the crawler is handed server-rendered HTML while the browser is handed an empty frame and a script tag. Nobody decided to be generous to bots.

      \n

      That still leaves a real and widely useful trick, and it is a better description of what spoofing buys than the paywall story. Claiming to be a link-preview bot is a way to get server-rendered HTML out of a client-rendered application. The win is a rendering path rather than a permission, which is also why it works on sites with no AI policy whatsoever.

      \n\n

      The sites with no opinion at all

      \n

      Three targets served all sixteen identities the same document to the word: GitHub at 1,135 words, Hacker News at 747, and this site at 314. That is the baseline the other rows should be read against, and it is a reminder that most of the web is still answering a request rather than interrogating it.

      \n\n

      What makes a name checkable

      \n

      Reverse DNS is the old answer and it works. Resolve the connecting address, confirm the name belongs to the crawler's operator, resolve it forward again to confirm it points back. Wikimedia's six refusals are that check failing, and it is why a fabricated token sailed past while googlebot did not.

      \n

      It is also available to about a dozen companies, because it requires owning address space and publishing a procedure that site operators bother to implement. Everybody else gets a string, which is where this page started.

      \n

      Web Bot Auth moves the check off the address. The crawler signs each request with an Ed25519 key under RFC 9421 HTTP Message Signatures and publishes the public half as a JWKS document. Three headers carry it: Signature-Input naming the covered components with tag=\"web-bot-auth\", a keyid thumbprint and created and expires stamps; Signature carrying the signature; and Signature-Agent naming the directory to fetch the key from. The directory lives at /.well-known/http-message-signatures-directory.

      \n

      The property that matters is the one this survey keeps demonstrating in the negative. Anyone can type GPTBot. Producing a valid signature over a request needs the private key, so the claim stops depending on which address it arrives from, and a crawler owning no network blocks can still hold an identity somebody is able to check.

      \n\n

      The program that already runs this, and the part still waiting

      \n

      None of that is a proposal. Cloudflare's Verified Bots program accepts exactly three proofs of identity, and they map onto this page's answers one for one: a Web Bot Auth signature, a published IP list paired with a stable user-agent, or reverse DNS validation. Verification carries behavioural terms too, since a verified bot has to respect robots.txt, hold a reasonable request rate, and avoid working around a site owner's stated preferences. Breaking any of them removes the service from the global allowlist. The signature route is the one that widens the field, because the other two proofs are facts about the address a request comes from and a signature is not.

      \n

      Two labels sit close together here and are worth keeping apart. A valid Web Bot Auth signature classifies traffic as a signed agent, and Verified Bot status is a further step judging behaviour rather than cryptography. Cloudflare's own announcement uses itself as the example: Browser Rendering sends signed Web Bot Auth headers and does not qualify as a verified bot, while Radar's URL Scanner does. Signing proves who is asking. Verification judges whether that identity has earned an allowlist entry.

      \n

      So the fix for everything above is deployed, at one of the largest reverse proxies on the web, and a site gets it by turning it on rather than by building it. Whether anything in this sample looks for it is a separate question, and the survey carries one small piece of evidence.

      \n
      \n

      That row sends the pointer and signs nothing. It sets Signature-Agent to a directory that genuinely exists and omits Signature and Signature-Input entirely, so an origin actually verifying has everything it needs to reject the request. Read it against the plain ua-survey (ours) row in the matrix, which is the same user-agent carrying no header at all.

      \n

      Worth stating the limit of that probe. It shows whether an origin reacts to an unsigned Web Bot Auth attempt, and it cannot show whether an origin would honour a correctly signed one, because signing needs AadharshBot's private key and that key is a Worker secret this survey cannot reach. A run from inside the Worker would answer the second question and is the obvious next experiment.

      \n

      One more data point arrived by accident while this page was being written. Cloudflare's own Radar bot directory answered 403 to the unverified fetcher used to read it, and served the same page to an ordinary browser seconds later. The registry of verified bots sits behind the wall that verification exists to open.

      \n\n

      What this site serves

      \n

      The last row of the matrix is aadhar.sh, which answered all sixteen identities with the same 314-word document. That is a deliberate position rather than an absence of policy: the pages here publish a Markdown twin, an MCP endpoint and a machine catalog precisely so a crawler has a cheaper option than scraping the HTML. Serving one document to everyone is easy when there is nothing you are trying to keep from them.

      \n

      It also means this page cannot claim to have tested its own defense, because it has none to test.

      \n

      The crawler is a different story, and the honest version implicates us. AadharshBot signs its outbound requests under RFC 9421 and publishes an Ed25519 key at the exact well-known path Cloudflare's documentation specifies, served as application/jwk-set+json. That clears the technical bar and stops there, because the bot has never been submitted to the Verified Bots program. So the signature it has been sending all along buys it nothing at any origin that checks a registry rather than a signature, and this page argues for a thing its own crawler has half done.

      \n\n
      \n

      What would falsify this. The denylist reading dies if the Times refuses OpenAI File Downloader on a later run, which would mean the list is maintained faster than a survey can exploit it. The verification reading dies if Wikimedia turns out to refuse googlebot from every address including Google's, which would make it an ordinary denylist that happens to name six crawlers. The rendering explanation for Reddit dies if facebookexternalhit is served more than a browser on a server-rendered page, where there is no JavaScript gap to explain it. The adoption reading dies if any origin treats the unsigned Signature-Agent row differently from the bare row beside it. All four are one re-run of bun run ua:survey away, and the survey prints its own control rows so a future reader can tell a changed policy from a changed instrument.

      \n
      ", - "pageCss": ".ua-ids{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 10px;padding:2px 0}\n.ua-ids .row{display:grid;grid-template-columns:10em 6.5em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:8.8pt;border-bottom:1px solid #e4eaf3}\n.ua-ids .row:last-child{border-bottom:0}\n.ua-ids .row.ctl{background:#eef3fa}\n.ua-ids b{font-family:var(--font-mono);color:#20406e}\n.ua-ids .who{color:#69758a;font-size:8.2pt}\n.ua-ids .ua{font-family:var(--font-mono);font-size:8pt;color:#4a5870;word-break:break-all}\n.ua-ids .tag{display:inline-block;margin-left:5px;padding:0 4px;border-radius:2px;font-size:7pt;text-transform:uppercase;letter-spacing:.04em;color:#31527d;background:#cddcf2}\n.ua-note{font-size:9pt;color:#5b6879}\n.ua-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.ua-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:9.5pt;color:#20406e}\n.ua-toggle button{font:inherit;font-size:8.5pt;font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,#e4e9f2);border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.ua-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.ua-scroll{overflow-x:auto}\n.ua-matrix{border-collapse:collapse;font-size:8.2pt;width:100%}\n.ua-matrix th{background:#eaf0f9;border:1px solid #c3ceE0;padding:3px 5px;text-align:left;font-family:var(--font-caption);font-weight:normal;white-space:nowrap}\n.ua-matrix th.ctl{background:#d9e5f6}\n.ua-matrix td{border:1px solid #d8dfeb;padding:3px 5px;white-space:nowrap;font-family:var(--font-mono)}\n.ua-matrix td.site{font-family:var(--font-ui);max-width:15em;white-space:normal}\n.ua-matrix td.site b{color:#20406e}\n.ua-matrix td.site span{display:block;color:#69758a;font-size:7.8pt}\n.ua-matrix tr.unmeasurable td{background:#f2f2f2;color:#8b96a6}\n.ua-matrix tr.unmeasurable td.site b{color:#69758a}\n.ua-matrix .ok{color:#1d6b1d}\n.ua-matrix .no{color:#a32b13}\n.ua-matrix .sh{color:#9a6a08}\n.ua-matrix td.ctl{background:#f4f8fd}\n.ua-matrix tr.unmeasurable td.ctl{background:#ededed}\n.ua-verdict{font-family:var(--font-ui);font-size:7.8pt;text-transform:uppercase;letter-spacing:.04em}\n.ua-legend{margin:0;padding:6px 10px 8px;font-size:8.2pt;color:#5b6879;border-top:1px solid #ccd6e4}\n.ua-quote{font-family:var(--font-mono);font-size:8.6pt;background:#fffbe8;border:1px solid #e5d9a8;padding:1px 5px}\n.ua-selfharm{border-left:3px solid #a32b13;background:#fdf2ef;padding:8px 10px;margin:0 0 14px}\n.ua-selfharm p{margin:0}\n.ua-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n.ua-falsify p{margin:0}\n@media(max-width:620px){.ua-ids .row{grid-template-columns:1fr;row-gap:1px}}\n.ua-wba{border:1px solid #99a8bd;background:#f8fafd;box-shadow:inset 1px 1px 0 #fff;margin:0 0 12px;padding:2px 0}\n.ua-wba .r{display:grid;grid-template-columns:11em 1fr 1fr;gap:8px;align-items:center;padding:4px 10px;font-size:8.8pt;border-bottom:1px solid #e4eaf3}\n.ua-wba .r:last-child{border-bottom:0}\n.ua-wba .r.h{background:#eaf0f9;font-family:var(--font-caption);color:#20406e;font-size:8.4pt}\n.ua-wba .t{font-family:var(--font-mono);color:#20406e}\n.ua-wba .v{font-family:var(--font-mono)}\n.ua-wba .same{color:#5b6879}\n.ua-wba .diff{color:#a32b13;font-weight:bold}\n.ua-wba .verdict{padding:6px 10px;font-size:8.6pt;color:#33455f;border-top:1px solid #ccd6e4;background:#eef3fa}\n@media(max-width:620px){.ua-wba .r{grid-template-columns:1fr}}\n\n.ua-sep{display:inline-block;width:1px;height:13px;background:#b6c2d4;margin:0 3px;vertical-align:-2px}\n.ua-matrix.compact{width:auto;min-width:100%}\n.ua-scroll{padding-right:2.2em}\n.ua-matrix.compact td.c{text-align:center;width:1.75em;padding:3px 1px;font-size:10.5pt;line-height:1.15;font-family:var(--font-ui)}\n.ua-matrix.compact td.c .s{display:block;font-family:var(--font-mono);font-size:6.6pt;line-height:1;color:inherit;opacity:.72}\n.ua-matrix.compact .ok{color:#1d6b1d}\n.ua-matrix.compact .no{color:#a32b13}\n.ua-matrix.compact .sh{color:#9a6a08}\n/* The header turns on its side, which is what gives 16 identity columns the room\n to fit a 760px window. A horizontal header needs ~7em a column and there is not\n 7em x 16 anywhere on this page. */\n.ua-matrix.compact th.rot{height:7em;vertical-align:bottom;padding:0 0 4px;width:1.75em;position:relative}\n.ua-matrix.compact th.rot>span{display:block;transform-origin:left bottom;transform:translateX(1.35em) rotate(-60deg);white-space:nowrap;font-size:7.8pt;text-align:left;width:0}\n.ua-matrix.compact th.hsite{vertical-align:bottom}\n.ua-matrix.compact th.ctl>span{font-weight:bold;color:#20406e}\n.ua-matrix.compact td.site{max-width:11em}\n.ua-matrix.compact td,.ua-matrix.compact th{padding-left:2px;padding-right:2px}\n@media(max-width:620px){\n .ua-matrix.compact th.rot{height:6.4em}\n .ua-matrix.compact th.rot>span{font-size:7pt}\n .ua-matrix.compact td.c{width:1.85em;font-size:9.5pt}\n}\n", + "pageCss": ".ua-ids{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 10px;padding:2px 0}\n.ua-ids .row{display:grid;grid-template-columns:10em 6.5em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:var(--text-xs);border-bottom:1px solid #e4eaf3}\n.ua-ids .row:last-child{border-bottom:0}\n.ua-ids .row.ctl{background:#eef3fa}\n.ua-ids b{font-family:var(--font-mono);color:#20406e}\n.ua-ids .who{color:#69758a;font-size:var(--text-2xs)}\n.ua-ids .ua{font-family:var(--font-mono);font-size:var(--text-2xs);color:#4a5870;word-break:break-all}\n.ua-ids .tag{display:inline-block;margin-left:5px;padding:0 4px;border-radius:2px;font-size:var(--text-4xs);text-transform:uppercase;letter-spacing:.04em;color:#31527d;background:#cddcf2}\n.ua-note{font-size:var(--text-xs);color:#5b6879}\n.ua-demo{border:1px solid #99a8bd;background:#f3f6fa;box-shadow:inset 1px 1px 0 #fff;margin:0 0 14px}\n.ua-demo-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;background:linear-gradient(180deg,#eaf0f9,#dbe4f1);border-bottom:1px solid #b6c2d4;padding:5px 9px;font-family:var(--font-caption);font-weight:bold;font-size:var(--text-sm);color:#20406e}\n.ua-toggle button{font:inherit;font-size:var(--text-micro);font-weight:normal;font-family:var(--font-ui);padding:2px 8px;cursor:pointer;background:linear-gradient(180deg,#fff,#e4e9f2);border:1px solid #8c9ab0;border-radius:3px;color:#243}\n.ua-toggle button.on{background:linear-gradient(180deg,#cddcf2,#aec4e6);border-color:#5a7398;box-shadow:inset 1px 1px 2px rgba(0,0,0,.18)}\n.ua-scroll{overflow-x:auto}\n.ua-matrix{border-collapse:collapse;font-size:var(--text-2xs);width:100%}\n.ua-matrix th{background:#eaf0f9;border:1px solid #c3ceE0;padding:3px 5px;text-align:left;font-family:var(--font-caption);font-weight:normal;white-space:nowrap}\n.ua-matrix th.ctl{background:#d9e5f6}\n.ua-matrix td{border:1px solid #d8dfeb;padding:3px 5px;white-space:nowrap;font-family:var(--font-mono)}\n.ua-matrix td.site{font-family:var(--font-ui);max-width:15em;white-space:normal}\n.ua-matrix td.site b{color:#20406e}\n.ua-matrix td.site span{display:block;color:#69758a;font-size:var(--text-2xs)}\n.ua-matrix tr.unmeasurable td{background:#f2f2f2;color:#8b96a6}\n.ua-matrix tr.unmeasurable td.site b{color:#69758a}\n.ua-matrix .ok{color:#1d6b1d}\n.ua-matrix .no{color:#a32b13}\n.ua-matrix .sh{color:#9a6a08}\n.ua-matrix td.ctl{background:#f4f8fd}\n.ua-matrix tr.unmeasurable td.ctl{background:#ededed}\n.ua-verdict{font-family:var(--font-ui);font-size:var(--text-2xs);text-transform:uppercase;letter-spacing:.04em}\n.ua-legend{margin:0;padding:6px 10px 8px;font-size:var(--text-2xs);color:#5b6879;border-top:1px solid #ccd6e4}\n.ua-quote{font-family:var(--font-mono);font-size:var(--text-micro);background:#fffbe8;border:1px solid #e5d9a8;padding:1px 5px}\n.ua-selfharm{border-left:3px solid #a32b13;background:#fdf2ef;padding:8px 10px;margin:0 0 14px}\n.ua-selfharm p{margin:0}\n.ua-falsify{border-left:3px solid #c9a227;background:#fffbe8;padding:8px 10px;margin:14px 0 0}\n.ua-falsify p{margin:0}\n@media(max-width:620px){.ua-ids .row{grid-template-columns:1fr;row-gap:1px}}\n.ua-wba{border:1px solid #99a8bd;background:#f8fafd;box-shadow:inset 1px 1px 0 #fff;margin:0 0 12px;padding:2px 0}\n.ua-wba .r{display:grid;grid-template-columns:11em 1fr 1fr;gap:8px;align-items:center;padding:4px 10px;font-size:var(--text-xs);border-bottom:1px solid #e4eaf3}\n.ua-wba .r:last-child{border-bottom:0}\n.ua-wba .r.h{background:#eaf0f9;font-family:var(--font-caption);color:#20406e;font-size:var(--text-micro)}\n.ua-wba .t{font-family:var(--font-mono);color:#20406e}\n.ua-wba .v{font-family:var(--font-mono)}\n.ua-wba .same{color:#5b6879}\n.ua-wba .diff{color:#a32b13;font-weight:bold}\n.ua-wba .verdict{padding:6px 10px;font-size:var(--text-micro);color:#33455f;border-top:1px solid #ccd6e4;background:#eef3fa}\n@media(max-width:620px){.ua-wba .r{grid-template-columns:1fr}}\n\n.ua-sep{display:inline-block;width:1px;height:13px;background:#b6c2d4;margin:0 3px;vertical-align:-2px}\n.ua-matrix.compact{width:auto;min-width:100%}\n.ua-scroll{padding-right:2.2em}\n.ua-matrix.compact td.c{text-align:center;width:1.75em;padding:3px 1px;font-size:var(--text-body);line-height:1.15;font-family:var(--font-ui)}\n.ua-matrix.compact td.c .s{display:block;font-family:var(--font-mono);font-size:var(--text-4xs);line-height:1;color:inherit;opacity:.72}\n.ua-matrix.compact .ok{color:#1d6b1d}\n.ua-matrix.compact .no{color:#a32b13}\n.ua-matrix.compact .sh{color:#9a6a08}\n/* The header turns on its side, which is what gives 16 identity columns the room\n to fit a 760px window. A horizontal header needs ~7em a column and there is not\n 7em x 16 anywhere on this page. */\n.ua-matrix.compact th.rot{height:7em;vertical-align:bottom;padding:0 0 4px;width:1.75em;position:relative}\n.ua-matrix.compact th.rot>span{display:block;transform-origin:left bottom;transform:translateX(1.35em) rotate(-60deg);white-space:nowrap;font-size:var(--text-2xs);text-align:left;width:0}\n.ua-matrix.compact th.hsite{vertical-align:bottom}\n.ua-matrix.compact th.ctl>span{font-weight:bold;color:#20406e}\n.ua-matrix.compact td.site{max-width:11em}\n.ua-matrix.compact td,.ua-matrix.compact th{padding-left:2px;padding-right:2px}\n@media(max-width:620px){\n .ua-matrix.compact th.rot{height:6.4em}\n .ua-matrix.compact th.rot>span{font-size:var(--text-4xs)}\n .ua-matrix.compact td.c{width:1.85em;font-size:var(--text-sm)}\n}\n", "pageJs": "(function () {\n var D = {\"measuredAt\":\"2026-08-21\",\"trials\":3,\"ids\":[{\"k\":\"chrome\",\"l\":\"Chrome\",\"o\":\"a browser\",\"r\":\"control\",\"ua\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36\",\"h\":null},{\"k\":\"curl\",\"l\":\"curl\",\"o\":\"a plain HTTP client\",\"r\":\"control\",\"ua\":\"curl/8.7.1\",\"h\":null},{\"k\":\"honest\",\"l\":\"ua-survey (ours)\",\"o\":\"aadhar.sh\",\"r\":\"control\",\"ua\":\"aadhar-ua-survey/1.0 (+https://aadhar.sh/garage/useragent)\",\"h\":null},{\"k\":\"bingbot\",\"l\":\"Bingbot\",\"o\":\"Microsoft\",\"r\":\"search\",\"ua\":\"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)\",\"h\":null},{\"k\":\"googlebot\",\"l\":\"Googlebot\",\"o\":\"Google\",\"r\":\"search\",\"ua\":\"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\",\"h\":null},{\"k\":\"applebot\",\"l\":\"Applebot\",\"o\":\"Apple\",\"r\":\"search\",\"ua\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)\",\"h\":null},{\"k\":\"duckduckbot\",\"l\":\"DuckDuckBot\",\"o\":\"DuckDuckGo\",\"r\":\"search\",\"ua\":\"DuckDuckBot/1.1; (+http://duckduckgo.com/duckduckbot.html)\",\"h\":null},{\"k\":\"amazonbot\",\"l\":\"Amazonbot\",\"o\":\"Amazon\",\"r\":\"train\",\"ua\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.214 Safari/537.36 (compatible; Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot)\",\"h\":null},{\"k\":\"facebookbot\",\"l\":\"facebookexternalhit\",\"o\":\"Meta\",\"r\":\"answers\",\"ua\":\"facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)\",\"h\":null},{\"k\":\"gptbot\",\"l\":\"GPTBot\",\"o\":\"OpenAI\",\"r\":\"train\",\"ua\":\"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.1; +https://openai.com/gptbot\",\"h\":null},{\"k\":\"claudebot\",\"l\":\"ClaudeBot\",\"o\":\"Anthropic\",\"r\":\"train\",\"ua\":\"Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)\",\"h\":null},{\"k\":\"claudeuser\",\"l\":\"Claude-User\",\"o\":\"Anthropic\",\"r\":\"answers\",\"ua\":\"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; Claude-User/1.0; +Claude-User@anthropic.com\",\"h\":null},{\"k\":\"perplexity\",\"l\":\"PerplexityBot\",\"o\":\"Perplexity\",\"r\":\"answers\",\"ua\":\"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot\",\"h\":null},{\"k\":\"wba_unsigned\",\"l\":\"Signature-Agent, unsigned\",\"o\":\"aadhar.sh\",\"r\":\"probe\",\"ua\":\"aadhar-ua-survey/1.0 (+https://aadhar.sh/garage/useragent)\",\"h\":{\"signature-agent\":\"\\\"https://aadhar.sh\\\"\"}},{\"k\":\"oai_fd\",\"l\":\"OpenAI File Downloader\",\"o\":\"OpenAI (claimed)\",\"r\":\"claimed\",\"ua\":\"OpenAI File Downloader\",\"h\":null},{\"k\":\"xai_img\",\"l\":\"XaiImageApiFetch\",\"o\":\"xAI (claimed)\",\"r\":\"claimed\",\"ua\":\"XaiImageApiFetch/1.0\",\"h\":null}],\"tg\":[{\"k\":\"linkedin-profile\",\"u\":\"https://www.linkedin.com/in/williamhgates\",\"n\":\"a public person profile, the source claim's own example\",\"v\":\"unmeasurable\",\"r\":{\"chrome\":[999,0,1,0],\"curl\":[999,0,1,0],\"honest\":[999,0,1,0],\"bingbot\":[999,0,1,0],\"googlebot\":[999,0,1,0],\"applebot\":[999,0,1,0],\"duckduckbot\":[999,0,1,0],\"amazonbot\":[999,0,1,0],\"facebookbot\":[200,3285,0,0],\"gptbot\":[999,42,1,0],\"claudebot\":[999,0,1,0],\"claudeuser\":[999,0,1,0],\"perplexity\":[999,0,1,0],\"wba_unsigned\":[999,0,1,0],\"oai_fd\":[999,0,1,0],\"xai_img\":[999,0,1,0]}},{\"k\":\"nytimes\",\"u\":\"https://www.nytimes.com/\",\"n\":\"a paywalled newsroom homepage\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,1255,0,0],\"curl\":[200,1255,0,0],\"honest\":[200,1438,0,0],\"bingbot\":[200,1438,0,0],\"googlebot\":[200,1438,0,0],\"applebot\":[200,1438,0,0],\"duckduckbot\":[200,1438,0,0],\"amazonbot\":[403,21,1,0],\"facebookbot\":[200,1438,0,0],\"gptbot\":[403,135,1,0],\"claudebot\":[403,135,1,0],\"claudeuser\":[403,135,1,0],\"perplexity\":[403,135,1,0],\"wba_unsigned\":[200,1438,0,0],\"oai_fd\":[200,1255,0,0],\"xai_img\":[200,1438,0,0]}},{\"k\":\"stackoverflow\",\"u\":\"https://stackoverflow.com/\",\"n\":\"answers 402 to some crawlers\",\"v\":\"unmeasurable\",\"r\":{\"chrome\":[403,9,1,0],\"curl\":[403,9,1,0],\"honest\":[403,9,1,0],\"bingbot\":[403,9,1,0],\"googlebot\":[403,9,1,0],\"applebot\":[403,9,1,0],\"duckduckbot\":[403,9,1,0],\"amazonbot\":[403,9,1,0],\"facebookbot\":[429,9,1,0],\"gptbot\":[429,9,1,0],\"claudebot\":[429,9,1,0],\"claudeuser\":[429,9,1,0],\"perplexity\":[429,9,1,0],\"wba_unsigned\":[429,9,1,0],\"oai_fd\":[429,9,1,0],\"xai_img\":[429,9,1,0]}},{\"k\":\"wikipedia\",\"u\":\"https://en.wikipedia.org/wiki/Web_crawler\",\"n\":\"an open reference article\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,8243,0,0],\"curl\":[200,8243,0,0],\"honest\":[200,8243,0,0],\"bingbot\":[403,10,1,0],\"googlebot\":[403,10,1,0],\"applebot\":[403,10,1,0],\"duckduckbot\":[403,10,1,0],\"amazonbot\":[200,8243,0,0],\"facebookbot\":[403,10,1,0],\"gptbot\":[200,8243,0,0],\"claudebot\":[403,9,1,0],\"claudeuser\":[200,8243,0,0],\"perplexity\":[200,8243,0,0],\"wba_unsigned\":[200,8243,0,0],\"oai_fd\":[200,8243,0,0],\"xai_img\":[200,8243,0,0]}},{\"k\":\"reddit\",\"u\":\"https://www.reddit.com/r/programming/\",\"n\":\"post-Google-deal access control\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,1,0,1],\"curl\":[200,1,0,1],\"honest\":[200,1,0,1],\"bingbot\":[403,133,1,0],\"googlebot\":[403,132,1,0],\"applebot\":[200,1,0,1],\"duckduckbot\":[200,1,0,1],\"amazonbot\":[429,0,1,0],\"facebookbot\":[200,1640,0,0],\"gptbot\":[403,133,1,0],\"claudebot\":[429,0,1,0],\"claudeuser\":[200,1,0,1],\"perplexity\":[200,1,0,1],\"wba_unsigned\":[200,1,0,1],\"oai_fd\":[200,44,0,1],\"xai_img\":[200,44,0,1]}},{\"k\":\"theatlantic\",\"u\":\"https://www.theatlantic.com/\",\"n\":\"a second paywalled newsroom, testing whether the denylist shape repeats\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,1344,0,0],\"curl\":[200,1344,0,0],\"honest\":[200,1344,0,0],\"bingbot\":[200,1344,0,0],\"googlebot\":[200,1344,0,0],\"applebot\":[402,7,1,0],\"duckduckbot\":[200,1344,0,0],\"amazonbot\":[200,1344,0,0],\"facebookbot\":[200,1344,0,0],\"gptbot\":[200,1344,0,0],\"claudebot\":[402,7,1,0],\"claudeuser\":[402,7,1,0],\"perplexity\":[402,7,1,0],\"wba_unsigned\":[200,1344,0,0],\"oai_fd\":[200,1344,0,0],\"xai_img\":[200,1344,0,0]}},{\"k\":\"github\",\"u\":\"https://github.com/\",\"n\":\"a developer platform with no AI-crawler position\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,1135,0,0],\"curl\":[200,1135,0,0],\"honest\":[200,1135,0,0],\"bingbot\":[200,1135,0,0],\"googlebot\":[200,1135,0,0],\"applebot\":[200,1135,0,0],\"duckduckbot\":[200,1135,0,0],\"amazonbot\":[200,1135,0,0],\"facebookbot\":[200,1135,0,0],\"gptbot\":[200,1135,0,0],\"claudebot\":[200,1135,0,0],\"claudeuser\":[200,1135,0,0],\"perplexity\":[200,1135,0,0],\"wba_unsigned\":[200,1135,0,0],\"oai_fd\":[200,1135,0,0],\"xai_img\":[200,1135,0,0]}},{\"k\":\"hackernews\",\"u\":\"https://news.ycombinator.com/\",\"n\":\"plain HTML, no CDN bot management\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,747,0,0],\"curl\":[200,747,0,0],\"honest\":[200,747,0,0],\"bingbot\":[200,747,0,0],\"googlebot\":[200,747,0,0],\"applebot\":[200,747,0,0],\"duckduckbot\":[200,747,0,0],\"amazonbot\":[200,747,0,0],\"facebookbot\":[200,747,0,0],\"gptbot\":[200,747,0,0],\"claudebot\":[200,747,0,0],\"claudeuser\":[200,747,0,0],\"perplexity\":[200,747,0,0],\"wba_unsigned\":[200,747,0,0],\"oai_fd\":[200,747,0,0],\"xai_img\":[200,747,0,0]}},{\"k\":\"medium\",\"u\":\"https://medium.com/\",\"n\":\"expected to fail its controls\",\"v\":\"unmeasurable\",\"r\":{\"chrome\":[403,127,1,0],\"curl\":[403,127,1,0],\"honest\":[403,127,1,0],\"bingbot\":[403,127,1,0],\"googlebot\":[403,127,1,0],\"applebot\":[403,127,1,0],\"duckduckbot\":[403,127,1,0],\"amazonbot\":[403,127,1,0],\"facebookbot\":[403,127,1,0],\"gptbot\":[403,127,1,0],\"claudebot\":[403,127,1,0],\"claudeuser\":[403,127,1,0],\"perplexity\":[403,127,1,0],\"wba_unsigned\":[403,127,1,0],\"oai_fd\":[403,127,1,0],\"xai_img\":[403,127,1,0]}},{\"k\":\"quora\",\"u\":\"https://www.quora.com/\",\"n\":\"expected to fail its controls\",\"v\":\"unmeasurable\",\"r\":{\"chrome\":[403,9,1,0],\"curl\":[403,9,1,0],\"honest\":[403,9,1,0],\"bingbot\":[403,9,1,0],\"googlebot\":[403,9,1,0],\"applebot\":[403,9,1,0],\"duckduckbot\":[403,9,1,0],\"amazonbot\":[403,9,1,0],\"facebookbot\":[403,9,1,0],\"gptbot\":[403,9,1,0],\"claudebot\":[403,9,1,0],\"claudeuser\":[403,9,1,0],\"perplexity\":[403,9,1,0],\"wba_unsigned\":[403,9,1,0],\"oai_fd\":[403,9,1,0],\"xai_img\":[403,9,1,0]}},{\"k\":\"aadharsh\",\"u\":\"https://aadhar.sh/\",\"n\":\"this site, which serves everyone the same document\",\"v\":\"measurable\",\"r\":{\"chrome\":[200,314,0,0],\"curl\":[200,314,0,0],\"honest\":[200,314,0,0],\"bingbot\":[200,314,0,0],\"googlebot\":[200,314,0,0],\"applebot\":[200,314,0,0],\"duckduckbot\":[200,314,0,0],\"amazonbot\":[200,314,0,0],\"facebookbot\":[200,314,0,0],\"gptbot\":[200,314,0,0],\"claudebot\":[200,314,0,0],\"claudeuser\":[200,314,0,0],\"perplexity\":[200,314,0,0],\"wba_unsigned\":[200,314,0,0],\"oai_fd\":[200,314,0,0],\"xai_img\":[200,314,0,0]}}]};\n var ids = document.getElementById(\"ua-ids\");\n var matrix = document.getElementById(\"ua-matrix\");\n var toggle = document.getElementById(\"ua-toggle\");\n if (!ids || !matrix) return;\n // The compact header rotates, so a long label costs HEIGHT rather than width and\n // \"Signature-Agent, unsigned\" clipped to \"Signat-\" at any header size worth\n // having. Abbreviations are for the rotated header alone; the identity list\n // above the matrix and every cell title still carry the full name and the exact\n // user-agent string, so nothing is only ever shown in short form.\n var SHORT = { honest: \"ua-survey\", facebookbot: \"facebook\", perplexity: \"Perplexity\",\n wba_unsigned: \"sig-agent\", oai_fd: \"OpenAI FD\", xai_img: \"xAI\" };\n var esc = function (s) { return String(s).replace(/[&<>\"]/g, function (c) { return ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\" })[c]; }); };\n\n ids.innerHTML = D.ids.map(function (i) {\n return '
      ' + esc(i.l) +\n (i.r === \"control\" ? 'control' : \"\") + '' + esc(i.o) +\n '' + esc(i.ua) + \"
      \";\n }).join(\"\");\n\n // Two densities, and COMPACT is the default on purpose. The detail view is\n // 16 columns of \"200 ok 1,255w\", which overflows the window and hides the AI\n // crawlers and the two invented names behind a horizontal scroll. Those are\n // the columns the page is about, so the default has to fit them on screen.\n // One glyph per cell answers \"was this identity served\" at a glance; the\n // numbers stay one click away, and on the title of every glyph.\n function cell(v, mode) {\n if (!v || v[0] == null) return mode === \"compact\"\n ? '·'\n : 'no answer';\n var status = v[0], words = v[1], blocked = v[2], shell = v[3];\n var cls = blocked ? \"no\" : shell ? \"sh\" : \"ok\";\n var what = blocked ? \"blocked\" : shell ? \"shell\" : \"ok\";\n if (mode === \"compact\") {\n var glyph = blocked ? \"\\u00d7\" : shell ? \"\\u25d0\" : \"\\u2713\";\n return '' + glyph +\n (blocked ? '' + status + \"\" : \"\") + \"\";\n }\n return '' + status + \" \" + what + \"
      \" + (words == null ? \"-\" : words.toLocaleString()) + \"w\";\n }\n\n function draw(filter, mode) {\n var rows = D.tg.filter(function (t) { return filter === \"all\" || t.v === \"measurable\"; });\n var head = 'target' + D.ids.map(function (i) {\n // Compact turns the header on its side, which is what buys 16 columns the\n // room to fit. The full label stays on the title for a hover and for AT.\n return '' +\n esc(mode === \"compact\" ? (SHORT[i.k] || i.l) : i.l) + \"\";\n }).join(\"\") + \"\";\n matrix.innerHTML = head + rows.map(function (t) {\n return '' + esc(t.k) + '' + esc(t.n) +\n '' + t.v + \"\" +\n D.ids.map(function (i) { return cell(t.r[i.k], mode); }).join(\"\") + \"\";\n }).join(\"\");\n // Control COLUMNS are marked here rather than in the cell builder, because the\n // builder returns one cell without knowing which column it lands in.\n Array.prototype.forEach.call(matrix.rows, function (row) {\n D.ids.forEach(function (i, n) {\n if (i.r === \"control\" && row.cells[n + 1]) row.cells[n + 1].classList.add(\"ctl\");\n });\n });\n }\n\n // The Web Bot Auth enforcement probe, rendered as a paired comparison. Same\n // user-agent, one row carrying a Signature-Agent pointer and one carrying\n // nothing. A difference in either column is an origin that parsed the header.\n var wba = document.getElementById(\"ua-wba\");\n if (wba) {\n var rows = D.tg.filter(function (t) { return t.v === \"measurable\"; });\n var diffs = 0;\n var html = '
      targetua-survey, no headerua-survey + Signature-Agent
      ' +\n rows.map(function (t) {\n var plain = t.r.honest, probe = t.r.wba_unsigned;\n if (!plain || !probe) return \"\";\n var same = plain[0] === probe[0] && plain[1] === probe[1];\n if (!same) diffs++;\n var fmt = function (v) { return v[0] + \" \" + (v[2] ? \"blocked\" : v[3] ? \"shell\" : \"ok\") + \", \" + (v[1] == null ? \"-\" : v[1].toLocaleString()) + \"w\"; };\n return '
      ' + esc(t.k) + '' + fmt(plain) +\n '' + fmt(probe) + \"
      \";\n }).join(\"\");\n wba.innerHTML = html + '
      ' + diffs + \" of \" + rows.length +\n \" measurable targets answered the two differently. \" + (diffs === 0\n ? \"Every one served the signature pointer exactly what it served the bare string, to the word. Nothing in this sample read the header.\"\n : \"Something parsed the header, so the reading above needs revisiting against those rows.\") + \"
      \";\n }\n\n var filter = \"all\", mode = \"compact\";\n if (toggle) {\n toggle.addEventListener(\"click\", function (e) {\n var b = e.target.closest(\"button\");\n if (!b) return;\n var group = b.getAttribute(\"data-f\") ? \"f\" : \"m\";\n Array.prototype.forEach.call(toggle.querySelectorAll(\"button\"), function (x) {\n var same = (x.getAttribute(\"data-f\") ? \"f\" : \"m\") === group;\n if (same) x.classList.toggle(\"on\", x === b);\n });\n if (group === \"f\") filter = b.getAttribute(\"data-f\"); else mode = b.getAttribute(\"data-m\");\n matrix.className = mode === \"compact\" ? \"ua-matrix compact\" : \"ua-matrix\";\n draw(filter, mode);\n });\n }\n matrix.className = \"ua-matrix compact\";\n draw(filter, mode);\n})();", "editorial": { "reader": "Someone who runs a site and is deciding whether a user-agent denylist is worth maintaining, or who has read that spoofing a crawler token is free and wants to know what it actually buys.", diff --git a/pipelines/lwe/generate.mjs b/pipelines/lwe/generate.mjs index ba4bc180e..0178d5193 100644 --- a/pipelines/lwe/generate.mjs +++ b/pipelines/lwe/generate.mjs @@ -54,7 +54,7 @@ function chromeCss(c) { const [g0, g1] = c.picGrad; const glyphFont = c.glyphFont ? `;--glyph-font:${c.glyphFont}` : ""; return `:root{--accent:${c.accent};--accent-soft:${soft};--glyph:"${c.glyph}";--pic-g0:${g0};--pic-g1:${g1};--pic-border:${c.picBorder};--name-color:${c.nameColor}${glyphFont}} -.msg .bubble code { font-family: var(--font-mono); font-size: 9.5pt; background: #f1ece0; padding: 0 3px; border-radius: 2px; }`; +.msg .bubble code { font-family: var(--font-mono); font-size: var(--text-sm); background: #f1ece0; padding: 0 3px; border-radius: 2px; }`; } // ---- message rendering ---- diff --git a/pipelines/lwe/specs/dac.json b/pipelines/lwe/specs/dac.json index d2c9dda3c..5be56cefc 100644 --- a/pipelines/lwe/specs/dac.json +++ b/pipelines/lwe/specs/dac.json @@ -170,6 +170,6 @@ {"who":"you","time":"14:34","html":"

      clearest version of this I've read. thanks.

      → Schiit Happened (Jason Stoddard) · the head-fi thread · delta-sigma on Wikipedia · back to Learning With Errors

      "}, {"scrollnote":"end of first pass"} ], - "demoCss": ".lead{font-size:9.5pt;color:#5a4a38;margin:0 0 9px;line-height:1.5}.dac-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.dac-row label{display:flex;align-items:center;gap:8px;font-size:9pt;color:#6b5640}.dac-row input[type=range]{accent-color:#a8541f}canvas#stairCanvas,canvas#nsCanvas{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block}.dac-cap{font-size:8.5pt;color:#8a7a64;margin:6px 0 0;line-height:1.5}.demo .mono{font-family:var(--font-mono)}.demo .dim{color:#66707d}.dac-bit{font:inherit;font-size:8.5pt;padding:4px 8px;cursor:pointer;border:1px solid #c79a6a;border-radius:3px;background:#f6ece1;color:#6b3f17;display:flex;flex-direction:column;align-items:center;line-height:1.2;min-width:46px}.dac-bit span{font-family:var(--font-mono);font-size:7pt;color:#9a7a55}.dac-bit[aria-pressed=true]{background:linear-gradient(180deg,#cf7a3a,#a8541f);color:#fff;border-color:#83400f}.dac-bit[aria-pressed=true] span{color:#f6ddc6}canvas#ladderXfer{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block}.filt-wrap{display:flex;gap:8px;flex-wrap:wrap}.filt-wrap canvas{flex:1;min-width:160px;max-width:276px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9}.mu-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:4px 0 2px}.mu-col{min-width:0}.mu-h{font-size:8.5pt;font-weight:bold;color:#6b3f17;margin:0 0 3px}canvas#muMbCanvas,canvas#muDsCanvas{width:100%;height:auto;border:1px solid #e0d3c2;border-radius:3px;background:#fffdf9;display:block}canvas#tubeSpec{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block;margin-top:8px}.tmode[aria-pressed=true]{background:linear-gradient(180deg,#cf7a3a,#a8541f);color:#fff;border-color:#83400f}.dac-play[aria-pressed=true]{background:linear-gradient(180deg,#6fae67,#3f8f45);color:#fff;border-color:#2f6b34}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", + "demoCss": ".lead{font-size:var(--text-sm);color:#5a4a38;margin:0 0 9px;line-height:1.5}.dac-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.dac-row label{display:flex;align-items:center;gap:8px;font-size:var(--text-xs);color:#6b5640}.dac-row input[type=range]{accent-color:#a8541f}canvas#stairCanvas,canvas#nsCanvas{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block}.dac-cap{font-size:var(--text-micro);color:#8a7a64;margin:6px 0 0;line-height:1.5}.demo .mono{font-family:var(--font-mono)}.demo .dim{color:#66707d}.dac-bit{font:inherit;font-size:var(--text-micro);padding:4px 8px;cursor:pointer;border:1px solid #c79a6a;border-radius:3px;background:#f6ece1;color:#6b3f17;display:flex;flex-direction:column;align-items:center;line-height:1.2;min-width:46px}.dac-bit span{font-family:var(--font-mono);font-size:var(--text-4xs);color:#9a7a55}.dac-bit[aria-pressed=true]{background:linear-gradient(180deg,#cf7a3a,#a8541f);color:#fff;border-color:#83400f}.dac-bit[aria-pressed=true] span{color:#f6ddc6}canvas#ladderXfer{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block}.filt-wrap{display:flex;gap:8px;flex-wrap:wrap}.filt-wrap canvas{flex:1;min-width:160px;max-width:276px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9}.mu-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:4px 0 2px}.mu-col{min-width:0}.mu-h{font-size:var(--text-micro);font-weight:bold;color:#6b3f17;margin:0 0 3px}canvas#muMbCanvas,canvas#muDsCanvas{width:100%;height:auto;border:1px solid #e0d3c2;border-radius:3px;background:#fffdf9;display:block}canvas#tubeSpec{width:100%;max-width:560px;height:auto;border:1px solid #e0d3c2;border-radius:4px;background:#fffdf9;display:block;margin-top:8px}.tmode[aria-pressed=true]{background:linear-gradient(180deg,#cf7a3a,#a8541f);color:#fff;border-color:#83400f}.dac-play[aria-pressed=true]{background:linear-gradient(180deg,#6fae67,#3f8f45);color:#fff;border-color:#2f6b34}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", "demoJs": "// Retina-crisp canvas: enlarge the backing store by devicePixelRatio (capped 2x,\n// matching pretext.html) and scale the context so all drawing keeps using the\n// canvas's logical width/height. Returns the logical {w,h} to read in draw code\n// (reading cv.width after this returns the scaled size). Without it the fixed-size\n// bitmap is upscaled by CSS and reads soft on Retina. Call once per canvas.\nfunction hd(cv, cx){ var d = Math.min(window.devicePixelRatio || 1, 2), w = cv.width, h = cv.height; cv.width = w * d; cv.height = h * d; cx.setTransform(d, 0, 0, d, 0, 0); return { w: w, h: h }; }\n// ── shared live audio for the demos ──────────────────────────────────\n// One AudioContext, one voice at a time (starting a demo stops the others), so\n// you hit play once and then HEAR the knobs move: turning a slider or flipping a\n// toggle retunes the running graph in real time instead of firing a new one-shot\n// (the old autopause meant re-clicking to hear a change). Memoryless transfer\n// curves — the quantizer staircase, the tube curve, the R-2R ladder — ride a\n// WaveShaperNode whose .curve we swap live (oversampled 4x to tame aliasing);\n// the delta-sigma feedback loop and the reconstruction filter need their own\n// small graphs. Audio only ever starts on a click, so autoplay policy and\n// prefers-reduced-motion need nothing special here.\nvar DACAudio = (function(){\n var ac = null, current = null; // current = the running demo's stop fn\n function ctx(){ try { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return null; } if (ac.resume) ac.resume(); return ac; }\n // sample a transfer function fn(x), x in [-1,1], into a WaveShaper curve,\n // DC-removed + peak-normalized so loudness stays put as you turn the knob\n function curve(fn, n){\n n = n || 2048; var a = new Float32Array(n), i, mean = 0, peak = 1e-6;\n for (i = 0; i < n; i++){ a[i] = fn(i / (n - 1) * 2 - 1); mean += a[i]; }\n mean /= n;\n for (i = 0; i < n; i++){ a[i] -= mean; var m = Math.abs(a[i]); if (m > peak) peak = m; }\n for (i = 0; i < n; i++) a[i] /= peak;\n return a;\n }\n function attack(g, c, level){ var t = c.currentTime; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(level, t + 0.03); }\n function release(g, c){ var t = c.currentTime; g.gain.cancelScheduledValues(t); g.gain.setValueAtTime(Math.max(g.gain.value, 0.0001), t); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05); return t + 0.07; }\n // a sine → WaveShaper(curveFn) → gain voice; retune() re-bakes the curve live\n function shaperVoice(c, freq, curveFn, level){\n var osc = c.createOscillator(); osc.type = 'sine'; osc.frequency.value = freq;\n var ws = c.createWaveShaper(); ws.oversample = '4x'; ws.curve = curveFn();\n var g = c.createGain(); attack(g, c, level || 0.2);\n osc.connect(ws); ws.connect(g); g.connect(c.destination); osc.start();\n return { stop: function(){ osc.stop(release(g, c)); }, retune: function(){ ws.curve = curveFn(); } };\n }\n // wire a play/stop button to a builder(ctx) -> {stop, retune}. returns a handle\n // whose .retune() the demo calls on any knob change (a no-op when not playing)\n function wire(btn, build){\n var voice = null;\n function stopMe(){ if (voice){ try { voice.stop(); } catch (e) {} voice = null; } btn.textContent = 'play'; btn.setAttribute('aria-pressed', 'false'); }\n btn.addEventListener('click', function(){\n if (voice){ stopMe(); current = null; return; } // stop me\n var c = ctx(); if (!c) return;\n if (current) current(); // stop whoever else is playing\n voice = build(c); btn.textContent = 'stop'; btn.setAttribute('aria-pressed', 'true'); current = stopMe;\n });\n return { retune: function(){ if (voice && voice.retune) voice.retune(); }, attack: attack, release: release };\n }\n return { ctx: ctx, curve: curve, shaperVoice: shaperVoice, wire: wire, attack: attack, release: release };\n})();\n\n// ── Demo A: multibit staircase ───────────────────────────────────────\n(function(){\n var c=document.getElementById('stairCanvas'); if(!c) return;\n var ctx=c.getContext('2d'), bits=document.getElementById('stairBits'), lbl=document.getElementById('stairN');\n var W=c.width, H=c.height, mid=H/2, amp=H*0.40, cycles=2, samples=48; hd(c, ctx);\n function draw(){\n var N=+bits.value, L=Math.pow(2,N), maxv=L/2-1;\n lbl.textContent=N+'-bit · '+L.toLocaleString()+' levels';\n ctx.clearRect(0,0,W,H);\n if(N<=5){ ctx.strokeStyle='#efe6d8'; ctx.lineWidth=1; for(var k=-maxv;k<=maxv;k++){ var yy=mid-(k/maxv)*amp; ctx.beginPath(); ctx.moveTo(0,yy); ctx.lineTo(W,yy); ctx.stroke(); } }\n ctx.strokeStyle='#cbb89e'; ctx.lineWidth=1.5; ctx.beginPath();\n for(var x=0;x<=W;x++){ var t=x/W*cycles*2*Math.PI, y=mid-Math.sin(t)*amp; x?ctx.lineTo(x,y):ctx.moveTo(x,y); }\n ctx.stroke();\n ctx.strokeStyle='#a8541f'; ctx.lineWidth=2; ctx.beginPath();\n for(var i=0;i1?1:(v<-1?-1:v); }\n function spectrum(){\n var maxv=Math.pow(2,BITS)/2-1, k0=6, y=new Array(N), e=0;\n for(var n=0;n0?Math.round(s*maxv)/maxv:0)*env;\n }\n var src=actx.createBufferSource(); src.buffer=buf; src.connect(actx.destination); src.start();\n });\n})();\n// ── Demo: the R-2R ladder (binary weighting + why precision matters) ──\n// An N-bit voltage-mode R-2R ladder. Each bit i contributes Vref * 2^i / 2^N,\n// so the MSB is half of Vref, the next a quarter, and so on, built from just\n// two resistor values. A tolerance on those resistors perturbs each bit's\n// weight; errors in the big bits bend the transfer curve and can break\n// monotonicity (a higher code yields a lower voltage), the failure a real\n// ladder must engineer away. Per-bit error directions are fixed so the slider\n// only scales them.\n(function(){\n var wrap = document.getElementById('ladderBits'); if (!wrap) return;\n var out = document.getElementById('ladderOut'), tol = document.getElementById('ladderTol'), tolL = document.getElementById('ladderTolL');\n var xfer = document.getElementById('ladderXfer'), xctx = xfer && xfer.getContext('2d');\n var xdim = xctx ? hd(xfer, xctx) : { w: 0, h: 0 };\n var N = 6, MAX = (1 << N) - 1, code = 1 << (N - 1);\n var dir = [0.7, -0.9, 0.4, -0.6, 0.85, -0.3]; // fixed error direction per bit (index = bit i, LSB..MSB)\n var html = '';\n for (var b = N - 1; b >= 0; b--) html += '';\n wrap.innerHTML = html;\n function weights(t) { var w = []; for (var i = 0; i < N; i++) w[i] = (Math.pow(2, i) / Math.pow(2, N)) * (1 + dir[i] * t); return w; }\n function vout(c, w) { var v = 0; for (var i = 0; i < N; i++) if (c & (1 << i)) v += w[i]; return v; }\n function render() {\n var t = (+tol.value) / 100; if (tolL) tolL.textContent = (+tol.value).toFixed(1) + '%';\n var w = weights(t);\n if (out) out.textContent = 'Vout = ' + vout(code, w).toFixed(3) + ' Vref (code ' + code + ' of ' + MAX + ')';\n var btns = wrap.querySelectorAll('.dac-bit');\n for (var k = 0; k < btns.length; k++) { var bb = +btns[k].getAttribute('data-b'); btns[k].setAttribute('aria-pressed', (code & (1 << bb)) ? 'true' : 'false'); }\n if (!xctx) return;\n var W = xdim.w, H = xdim.h; xctx.clearRect(0, 0, W, H);\n xctx.strokeStyle = '#cbb89e'; xctx.lineWidth = 1.2; xctx.beginPath();\n for (var c = 0; c <= MAX; c++) { var x = 5 + c / MAX * (W - 10), y = H - 5 - (c / MAX) * (H - 10); c ? xctx.lineTo(x, y) : xctx.moveTo(x, y); }\n xctx.stroke();\n var vfull = vout(MAX, w) || 1, px = null, pv = 0;\n for (var c2 = 0; c2 <= MAX; c2++) {\n var v = vout(c2, w), X = 5 + c2 / MAX * (W - 10), Y = H - 5 - (v / vfull) * (H - 10);\n if (px) { var nm = v < pv - 1e-9; xctx.strokeStyle = nm ? '#c0392b' : '#a8541f'; xctx.lineWidth = nm ? 2.4 : 1.6; xctx.beginPath(); xctx.moveTo(px.x, px.y); xctx.lineTo(X, Y); xctx.stroke(); }\n px = { x: X, y: Y }; pv = v;\n }\n }\n var lVoice = DACAudio.wire(document.getElementById('ladderPlay'), function(ac){\n return DACAudio.shaperVoice(ac, 180, function(){ // map a full-scale sine through the code→voltage transfer curve\n var w = weights((+tol.value) / 100), vfull = vout(MAX, w) || 1;\n return DACAudio.curve(function(x){ var cd = Math.round((x + 1) / 2 * MAX); if (cd < 0) cd = 0; if (cd > MAX) cd = MAX; return vout(cd, w) / vfull * 2 - 1; });\n }, 0.2);\n });\n wrap.addEventListener('click', function(e) { var b = e.target.closest && e.target.closest('.dac-bit'); if (!b) return; code ^= (1 << +b.getAttribute('data-b')); render(); });\n if (tol) tol.addEventListener('input', function(){ render(); lVoice.retune(); });\n render();\n})();\n\n// ── Demo: the reconstruction filter, a time vs frequency tradeoff ─────\n// A windowed-sinc reconstruction filter. Raising the tap count sharpens the\n// frequency roll-off near Nyquist but lengthens the impulse response, so the\n// ringing in time grows. You cannot be arbitrarily sharp in both domains: that\n// is the Fourier uncertainty tradeoff that NOS, oversampling, apodizing, and\n// closed-form filters each answer differently.\n(function(){\n var sh = document.getElementById('filtSharp'); if (!sh) return;\n var shL = document.getElementById('filtSharpL');\n var tC = document.getElementById('filtTime'), fC = document.getElementById('filtFreq');\n var tctx = tC && tC.getContext('2d'), fctx = fC && fC.getContext('2d');\n var td = tctx ? hd(tC, tctx) : { w: 0, h: 0 }, fd = fctx ? hd(fC, fctx) : { w: 0, h: 0 };\n function sinc(x) { return x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x); }\n function build(s) {\n var L = Math.round(7 + s * 7) * 2 + 1, c = (L - 1) / 2, fc = 0.45, h = [];\n for (var n = 0; n < L; n++) {\n var win = 0.5 - 0.5 * Math.cos(2 * Math.PI * n / (L - 1)); // Hann\n h.push(sinc(2 * fc * (n - c)) * win);\n }\n return h;\n }\n function dft(h, M) {\n var mag = [];\n for (var k = 0; k < M; k++) {\n var re = 0, im = 0, f = 0.5 * k / (M - 1); // 0..Nyquist\n for (var n = 0; n < h.length; n++) { var a = 2 * Math.PI * f * n; re += h[n] * Math.cos(a); im -= h[n] * Math.sin(a); }\n mag.push(Math.sqrt(re * re + im * im));\n }\n var peak = Math.max.apply(null, mag) || 1;\n return mag.map(function(m) { return m / peak; });\n }\n function draw() {\n var s = +sh.value; if (shL) shL.textContent = (s < 3 ? 'gentle' : s < 6 ? 'medium' : 'sharp') + ' (' + (Math.round(7 + s * 7) * 2 + 1) + ' taps)';\n var h = build(s);\n if (tctx) {\n var W = td.w, H = td.h, mid = H / 2; tctx.clearRect(0, 0, W, H);\n tctx.strokeStyle = '#efe6d8'; tctx.beginPath(); tctx.moveTo(0, mid); tctx.lineTo(W, mid); tctx.stroke();\n tctx.strokeStyle = '#a8541f'; tctx.lineWidth = 1.6; tctx.beginPath();\n for (var n = 0; n < h.length; n++) { var x = n / (h.length - 1) * W, y = mid - h[n] * (H * 0.40); n ? tctx.lineTo(x, y) : tctx.moveTo(x, y); }\n tctx.stroke();\n }\n if (fctx) {\n var W2 = fd.w, H2 = fd.h, mag = dft(h, 160); fctx.clearRect(0, 0, W2, H2);\n fctx.fillStyle = '#f3ece1'; fctx.fillRect(0, 0, 0.9 * W2, H2); // passband shade to ~0.45 Nyquist\n fctx.strokeStyle = '#a8541f'; fctx.lineWidth = 1.6; fctx.beginPath();\n for (var k = 0; k < mag.length; k++) { var db = 20 * Math.log10(mag[k] + 1e-6), x2 = k / (mag.length - 1) * W2, y2 = H2 - ((db + 80) / 80) * H2; k ? fctx.lineTo(x2, y2) : fctx.moveTo(x2, y2); }\n fctx.stroke();\n }\n }\n function irBuf(ac){ var h=build(+sh.value), len=h.length, b=ac.createBuffer(1,len,ac.sampleRate), d=b.getChannelData(0), pk=1e-6; for(var i=0;ipk)pk=m; } for(i=0;i 1 ? 1 : v < -1 ? -1 : v; }\n function quantize(B, shape){\n var maxv = Math.pow(2, B - 1) - 1, y = new Array(N), e = 0;\n for (var n = 0; n < N; n++){\n var x = AMP * Math.sin(2 * Math.PI * K0 * n / N), q;\n if (shape){ var w = x - e; q = clamp(Math.round(w * maxv) / maxv); e = q - w; }\n else { q = clamp(Math.round(x * maxv) / maxv); }\n y[n] = q;\n }\n return y;\n }\n function spectrum(y){\n var half = N / 2, mag = new Array(half);\n for (var k = 0; k < half; k++){ var re = 0, im = 0; for (var n = 0; n < N; n++){ var a = 2 * Math.PI * k * n / N; re += y[n] * Math.cos(a); im -= y[n] * Math.sin(a); } mag[k] = Math.sqrt(re * re + im * im) / N; }\n return mag;\n }\n function drawSpec(ctx, mag, accent, dim){\n var W = dim.w, H = dim.h, half = mag.length;\n ctx.clearRect(0, 0, W, H);\n ctx.fillStyle = '#f3ece1'; ctx.fillRect((K0 - 1) / half * W, 0, 3 / half * W, H); // tone band\n function py(db){ return H - ((db + 120) / 120) * H; }\n ctx.strokeStyle = accent; ctx.lineWidth = 1.4; ctx.beginPath();\n for (var k = 0; k < half; k++){ var x = k / half * W, db = 20 * Math.log10(mag[k] + 1e-9), y = py(db); k ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }\n ctx.stroke();\n }\n function render(){\n var B = +bits.value; if (bitsL) bitsL.textContent = B + '-bit';\n var sm = spectrum(quantize(B, false)), sd = spectrum(quantize(B, true));\n if (mbCtx) drawSpec(mbCtx, sm, '#a8541f', mbd);\n if (dsCtx) drawSpec(dsCtx, sd, '#2f6fd0', dsd);\n var harm = 0; for (var h = 2; h * K0 < N / 2; h++) harm += sm[h * K0] * sm[h * K0]; // multibit distortion\n var noise = 0; for (var k = 1; k < N / 2; k++){ if (Math.abs(k - K0) <= 1) continue; noise += sd[k] * sd[k]; } // ds noise\n if (mbInfo) mbInfo.textContent = harm > 2e-6 ? 'harmonic distortion' : 'clean';\n if (dsInfo) dsInfo.textContent = noise > 2e-6 ? 'shaped noise floor' : 'clean';\n }\n function play(shape){\n try { actx = actx || new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return; }\n if (actx.resume) actx.resume();\n var B = +bits.value, sr = actx.sampleRate, len = Math.floor(sr * 1.3), buf = actx.createBuffer(1, len, sr), ch = buf.getChannelData(0);\n var maxv = Math.pow(2, B - 1) - 1, f = 440, e = 0;\n for (var i = 0; i < len; i++){\n var env = Math.min(1, i / (sr * 0.02), (len - i) / (sr * 0.06)), x = AMP * Math.sin(2 * Math.PI * f * i / sr), q;\n if (shape){ var w = x - e; q = clamp(Math.round(w * maxv) / maxv); e = q - w; }\n else { q = clamp(Math.round(x * maxv) / maxv); }\n ch[i] = q * env * 2.2; // gain the quiet tone up so the imperfection is audible (kept gentle)\n }\n var s = actx.createBufferSource(); s.buffer = buf; s.connect(actx.destination); s.start();\n }\n bits.addEventListener('input', render);\n var grid = document.querySelector('.mu-grid');\n if (grid) grid.addEventListener('click', function(ev){ var b = ev.target.closest && ev.target.closest('[data-topo]'); if (b) play(b.getAttribute('data-topo') === 'ds'); });\n render();\n})();\n\n// ── Demo: tube distortion — the \"warmth\" axis, even vs odd harmonics ──\n// After the DAC the analog signal meets an amplifier, and this is where the word\n// \"warmth\" is actually earned. A single tube's transfer curve is gently\n// asymmetric, so a driven sine grows mostly the 2nd harmonic — one octave up,\n// which the ear reads as consonant/full. A hard SYMMETRIC clip cancels the even\n// harmonics and grows odd ones (3rd, 5th, 7th...), marching toward a square wave:\n// the buzz of a signal slammed into its rails. Two deliberately clean, contrasting\n// curves: tube = x + c*x^2 (dominant 2nd, even), clip = clamp(g*x) (odd, → square).\n// The spectrum bars read a DFT sampled over an integer number of cycles (K0 cycles\n// / N samples), so every harmonic lands exactly on a bin. Renders on interaction\n// only — no animation loop, so prefers-reduced-motion needs nothing special.\n(function(){\n var driveEl = document.getElementById('tubeDrive'); if (!driveEl) return;\n var driveL = document.getElementById('tubeDriveL'), info = document.getElementById('tubeInfo');\n var xferC = document.getElementById('tubeXfer'), waveC = document.getElementById('tubeWave'), specC = document.getElementById('tubeSpec');\n var xc = xferC && xferC.getContext('2d'), wc = waveC && waveC.getContext('2d'), sc = specC && specC.getContext('2d');\n var xd = xc ? hd(xferC, xc) : { w: 0, h: 0 }, wd = wc ? hd(waveC, wc) : { w: 0, h: 0 }, sd = sc ? hd(specC, sc) : { w: 0, h: 0 };\n var modeBtns = document.querySelectorAll('.tmode'), playBtn = document.getElementById('tubePlay');\n var mode = 'tube', A = 0.9, N = 600, K0 = 6;\n function clampv(v){ return v > 1 ? 1 : v < -1 ? -1 : v; }\n function shape(x, drive){\n if (mode === 'clip') { var g = 1 + drive * 0.9; return clampv(g * x); } // symmetric hard clip → odd / square\n var c = drive * 0.11; return x + c * x * x; // asymmetric soft curve → 2nd harmonic\n }\n function series(drive){ // one window, DC-removed + peak-normalized\n var y = new Array(N), mean = 0, peak = 1e-9, n;\n for (n = 0; n < N; n++){ y[n] = shape(A * Math.sin(2 * Math.PI * K0 * n / N), drive); mean += y[n]; }\n mean /= N;\n for (n = 0; n < N; n++){ y[n] -= mean; var a = Math.abs(y[n]); if (a > peak) peak = a; }\n for (n = 0; n < N; n++) y[n] /= peak;\n return y;\n }\n function harmonics(y, H){ // amplitude at each harmonic bin K0*h\n var out = [], h, n;\n for (h = 1; h <= H; h++){ var k = K0 * h, re = 0, im = 0; for (n = 0; n < N; n++){ var a = 2 * Math.PI * k * n / N; re += y[n] * Math.cos(a); im -= y[n] * Math.sin(a); } out.push(2 * Math.sqrt(re * re + im * im) / N); }\n return out;\n }\n function draw(){\n var drive = +driveEl.value;\n if (driveL) driveL.textContent = drive <= 0.05 ? 'clean' : drive < 3.5 ? 'gentle' : drive < 7 ? 'pushed' : 'slammed';\n if (info) info.textContent = mode === 'tube' ? 'asymmetric curve · even (2nd)' : 'symmetric clip · odd → square';\n var y = series(drive);\n if (xc){ // transfer curve: output vs input, identity ghosted\n var W = xd.w, H = xd.h; xc.clearRect(0, 0, W, H);\n xc.strokeStyle = '#efe6d8'; xc.lineWidth = 1; xc.beginPath(); xc.moveTo(0, H / 2); xc.lineTo(W, H / 2); xc.moveTo(W / 2, 0); xc.lineTo(W / 2, H); xc.stroke();\n xc.strokeStyle = '#d9cbb6'; xc.setLineDash([3, 3]); xc.beginPath(); xc.moveTo(6, H - 6); xc.lineTo(W - 6, 6); xc.stroke(); xc.setLineDash([]);\n var pk = 1e-9; for (var t = 0; t <= 140; t++){ var av = Math.abs(shape(t / 140 * 2 - 1, drive)); if (av > pk) pk = av; } // scale the curve to fill the box (honest asymmetry, no false clamp)\n xc.strokeStyle = '#a8541f'; xc.lineWidth = 1.9; xc.beginPath();\n for (var i = 0; i <= 140; i++){ var xin = i / 140 * 2 - 1, yo = shape(xin, drive) / pk, px = 6 + (xin + 1) / 2 * (W - 12), py = (H - 6) - (yo + 1) / 2 * (H - 12); i ? xc.lineTo(px, py) : xc.moveTo(px, py); }\n xc.stroke();\n }\n if (wc){ // waveform: input sine (ghost) + driven output\n var W2 = wd.w, H2 = wd.h, mid = H2 / 2, amp = H2 * 0.40; wc.clearRect(0, 0, W2, H2);\n wc.strokeStyle = '#efe6d8'; wc.beginPath(); wc.moveTo(0, mid); wc.lineTo(W2, mid); wc.stroke();\n wc.strokeStyle = '#cbb89e'; wc.lineWidth = 1.2; wc.beginPath();\n for (var s = 0; s <= W2; s++){ var th = s / W2 * 4 * Math.PI, yy = mid - Math.sin(th) * amp * A; s ? wc.lineTo(s, yy) : wc.moveTo(s, yy); }\n wc.stroke();\n wc.strokeStyle = '#a8541f'; wc.lineWidth = 1.9; wc.beginPath();\n var span = (N / K0) * 2; // 2 cycles of the driven wave\n for (var j = 0; j <= span; j++){ var xx = j / span * W2, yv = mid - y[j % N] * amp; j ? wc.lineTo(xx, yv) : wc.moveTo(xx, yv); }\n wc.stroke();\n }\n if (sc){ // harmonic bars, labeled by multiple of the note\n var W3 = sd.w, H3 = sd.h, HN = 12, mags = harmonics(y, HN), f0 = mags[0] || 1e-9;\n var base = H3 - 16, top = 10, slot = W3 / (HN + 1); sc.clearRect(0, 0, W3, H3);\n sc.strokeStyle = '#e0d3c2'; sc.lineWidth = 1; sc.beginPath(); sc.moveTo(0, base); sc.lineTo(W3, base); sc.stroke();\n sc.font = '7.5pt \"Courier New\",monospace'; sc.textAlign = 'center';\n for (var h = 1; h <= HN; h++){\n var rel = mags[h - 1] / f0; if (rel > 1) rel = 1;\n var bx = slot * h, bw = Math.min(16, slot * 0.5), bh = rel * (base - top), even = (h % 2) === 0;\n sc.fillStyle = h === 1 ? '#7a6141' : even ? '#a8541f' : '#d29a6a'; // fundamental neutral, even = accent, odd = light\n sc.fillRect(bx - bw / 2, base - bh, bw, Math.max(bh, 0.6));\n sc.fillStyle = h === 2 ? '#83400f' : '#a8977c';\n sc.fillText(h + '×', bx, H3 - 4);\n }\n }\n }\n var tVoice = DACAudio.wire(playBtn, function(ac){ // live: a sine through the transfer curve, retuned as you move\n return DACAudio.shaperVoice(ac, 165, function(){ var d = +driveEl.value; return DACAudio.curve(function(x){ return shape(x, d); }); }, 0.2);\n });\n for (var b = 0; b < modeBtns.length; b++){\n modeBtns[b].addEventListener('click', function(){ var btn = this; mode = btn.getAttribute('data-mode'); for (var k = 0; k < modeBtns.length; k++) modeBtns[k].setAttribute('aria-pressed', modeBtns[k] === btn ? 'true' : 'false'); draw(); tVoice.retune(); });\n }\n driveEl.addEventListener('input', function(){ draw(); tVoice.retune(); });\n draw();\n})();" } diff --git a/pipelines/lwe/specs/drivers.json b/pipelines/lwe/specs/drivers.json index af6a181db..a311ccf07 100644 --- a/pipelines/lwe/specs/drivers.json +++ b/pipelines/lwe/specs/drivers.json @@ -176,6 +176,6 @@ "scrollnote": "end of first pass" } ], - "demoCss": ".lead{font-size:9.5pt;color:#5a4250;margin:0 0 9px;line-height:1.5}.drv-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.drv-row label{display:flex;align-items:center;gap:8px;font-size:9pt;color:#6b4658}.drv-row input[type=range]{accent-color:#a23b6c}canvas#drvCanvas{width:100%;max-width:560px;height:auto;border:1px solid #e0cdd6;border-radius:4px;background:#fffafc;display:block}.demo .mono{font-family:var(--font-mono)}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", + "demoCss": ".lead{font-size:var(--text-sm);color:#5a4250;margin:0 0 9px;line-height:1.5}.drv-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.drv-row label{display:flex;align-items:center;gap:8px;font-size:var(--text-xs);color:#6b4658}.drv-row input[type=range]{accent-color:#a23b6c}canvas#drvCanvas{width:100%;max-width:560px;height:auto;border:1px solid #e0cdd6;border-radius:4px;background:#fffafc;display:block}.demo .mono{font-family:var(--font-mono)}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", "demoJs": "// ── Demo: diaphragm motion, point drive vs distributed drive ──────────\n// Honest physical model: a dynamic cone is driven at its center, so a bending\n// wave propagates outward with a phase lag proportional to frequency; at high\n// frequency the rim lags the center and the surface breaks up. A planar membrane\n// is driven evenly across its area, so every point moves in phase (pistonic).\n(function(){\n var c = document.getElementById('drvCanvas'); if (!c) return;\n var ctx = c.getContext('2d'), fr = document.getElementById('drvFreq'), frL = document.getElementById('drvFreqL');\n var W = 560, H = 240, N = 120, amp = 24, t = 0;\n // Retina-crisp: the backing store is W*H logical px scaled by devicePixelRatio,\n // and the context is scaled so all drawing below stays in the 560x240 logical\n // space. Without this the 560px bitmap gets upscaled by CSS and reads soft.\n var dpr = 1;\n function fitDPR() {\n var d = Math.min(window.devicePixelRatio || 1, 2);\n if (d === dpr && c.width === W * d) return;\n dpr = d; c.width = W * dpr; c.height = H * dpr;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n fitDPR();\n var reduce = (window.matchMedia && matchMedia('(prefers-reduced-motion:reduce)').matches);\n function panel(cy, dynamic, f) {\n ctx.strokeStyle = '#ece0e6'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(18, cy); ctx.lineTo(W - 18, cy); ctx.stroke();\n ctx.strokeStyle = dynamic ? '#7d8794' : '#a23b6c'; ctx.lineWidth = 2.3; ctx.beginPath();\n for (var i = 0; i <= N; i++) {\n var x = i / N, xp = 18 + (W - 36) * x, d = Math.abs(x - 0.5) * 2;\n var disp = dynamic ? Math.sin(t - f * d) * amp * (1 - 0.18 * d) : Math.sin(t) * amp;\n var y = cy - disp;\n i ? ctx.lineTo(xp, y) : ctx.moveTo(xp, y);\n }\n ctx.stroke();\n // drive indicator: a single coil at center (dynamic) vs evenly spaced points (planar)\n var ctr = cy - Math.sin(t) * amp;\n if (dynamic) { ctx.fillStyle = '#5a6470'; ctx.fillRect(W / 2 - 3, ctr - 5, 6, 10); }\n else { ctx.fillStyle = '#a23b6c'; for (var k = 0; k < 9; k++) { var xx = 18 + (W - 36) * (k + 0.5) / 9; ctx.fillRect(xx - 2, ctr - 4, 4, 8); } }\n }\n function draw() {\n var f = +fr.value;\n if (frL) frL.textContent = (f < 2.5 ? 'low' : f < 5 ? 'mid' : 'high') + ' (lag ' + (f).toFixed(1) + ' rad at the rim)';\n ctx.clearRect(0, 0, W, H);\n ctx.fillStyle = '#66707d'; ctx.font = '10px monospace';\n ctx.fillText('dynamic cone (driven at the coil)', 18, 14);\n ctx.fillText('planar membrane (driven everywhere)', 18, H / 2 + 12);\n panel(H * 0.30, true, f);\n panel(H * 0.80, false, f);\n }\n fr.addEventListener('input', draw);\n window.addEventListener('resize', function () { fitDPR(); draw(); });\n var running = false, raf = 0, last = 0; function loop(now) { if (!running) return; t += last ? Math.min(now - last, 50) * 0.0048 : 0.08; last = now || 0; draw(); raf = requestAnimationFrame(loop); }\n if (reduce) { t = 1.2; draw(); } else if ('IntersectionObserver' in window) { new IntersectionObserver(function (es) { es.forEach(function (e) { if (e.isIntersecting) { if (!running) { running = true; last = 0; loop(); } } else { running = false; if (raf) cancelAnimationFrame(raf); } }); }, { threshold: 0.1 }).observe(c); } else { running = true; loop(); }\n})();" } diff --git a/pipelines/lwe/specs/encoding.json b/pipelines/lwe/specs/encoding.json index 4d0100586..acb6b1fe4 100644 --- a/pipelines/lwe/specs/encoding.json +++ b/pipelines/lwe/specs/encoding.json @@ -422,6 +422,6 @@ "scrollnote": "end of first pass" } ], - "demoCss": ".bpp-wrap{display:flex;gap:13px;align-items:flex-start;flex-wrap:wrap}.bpp-img{border:1px solid #cfd8e6;border-radius:3px;flex:0 0 auto}.bpp-tbl{border-collapse:collapse;font-size:9pt;flex:1;min-width:230px}.bpp-tbl th{text-align:left;color:#6b7280;font-weight:bold;font-size:8pt;border-bottom:1px solid #cfd8e6;padding:3px 6px}.bpp-tbl td{padding:3px 6px;border-bottom:1px solid #eef2f7}.bpp-tbl tr.win td{background:#eef4fa}.bpp-tbl tr.win b{color:#2c5d8f}.bpp-tbl .dim{color:#9aa3b2}.bpp-tbl .mono{font-family:var(--font-mono);color:#33415c}.cs-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.cs-row label{display:flex;align-items:center;gap:8px;font-size:9pt;color:#46617f}.cs-row select{font:inherit;font-size:9pt;padding:2px 6px;border:1px solid #9fb3c9;border-radius:3px}.cs-row .mono{font-family:var(--font-mono)}.cs-row .dim{color:#9aa3b2}canvas#csCanvas{width:100%;max-width:560px;height:auto;border:1px solid #cfd8e6;border-radius:3px;background:#fff;display:block;image-rendering:pixelated}.zgrid{display:grid;gap:5px;margin:4px 0 2px;align-items:center}.zgrid.fmt{grid-template-columns:42px 1fr 1fr 1fr}.zgrid.enc{grid-template-columns:1fr 1fr 1fr 1fr}.zgrid.chroma{grid-template-columns:1fr 1fr 1fr}.zgrid .zh{font-size:8pt;color:#6b7280;text-align:center;font-family:var(--font-mono)}.zgrid .zlabel{font-size:8.5pt;color:#46617f;font-weight:bold}.zcell{margin:0}.zcell img{width:100%;height:auto;display:block;border:1px solid #cfd8e6;border-radius:2px;image-rendering:pixelated;background:#fff}.zcell figcaption{font-size:7.5pt;color:#6b7280;text-align:center;font-family:var(--font-mono);margin-top:2px;line-height:1.35}.pj-panes{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:4px 0 6px}.pj-pane{margin:0}.pj-frame{position:relative;background:#e9edf3;border:1px solid #cfd8e6;border-radius:2px;aspect-ratio:400/266}.pj-frame canvas{display:block;width:100%;height:100%}.pj-empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-size:8pt;color:#6b7280;text-align:center;padding:4px}.pj-none canvas{visibility:hidden}.pj-none .pj-empty{display:flex}.pj-pane figcaption{font-size:7.5pt;color:#46617f;text-align:center;margin-top:2px;line-height:1.35}.pj-mono,.pj-ctl output{font-family:var(--font-mono)}.pj-ctl{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:9pt;color:#46617f;margin:4px 0}.pj-ctl input[type=range]{flex:1;min-width:160px}.pj-ctl select,.pj-ctl button{font:inherit;font-size:9pt;padding:1px 6px;border:1px solid #9fb3c9;border-radius:3px;background:#fff}.pj-scans{border-collapse:collapse;font-size:8.5pt;width:100%;margin:4px 0 2px}.pj-scans th{text-align:left;color:#6b7280;font-size:8pt;font-weight:bold;border-bottom:1px solid #cfd8e6;padding:2px 5px}.pj-scans td{padding:2px 5px;border-bottom:1px solid #eef2f7;font-family:var(--font-mono)}.pj-scans .pj-done td{color:#33415c;background:#eef4fa}.pj-scans .pj-part td{color:#33415c;background:#fff7dd}.pj-scans .pj-wait td{color:#9aa3b2}.pj-scans .pj-gate td:first-child::after{content:\" \\2605\";color:#c2410c}canvas#crCanvas{width:100%;max-width:560px;height:auto;border:1px solid #cfd8e6;border-radius:3px;background:#fff;display:block}.cs-row input[type=range]{min-width:140px}.cr-info{margin-top:6px;color:#33415c}", + "demoCss": ".bpp-wrap{display:flex;gap:13px;align-items:flex-start;flex-wrap:wrap}.bpp-img{border:1px solid #cfd8e6;border-radius:3px;flex:0 0 auto}.bpp-tbl{border-collapse:collapse;font-size:var(--text-xs);flex:1;min-width:230px}.bpp-tbl th{text-align:left;color:#6b7280;font-weight:bold;font-size:var(--text-2xs);border-bottom:1px solid #cfd8e6;padding:3px 6px}.bpp-tbl td{padding:3px 6px;border-bottom:1px solid #eef2f7}.bpp-tbl tr.win td{background:#eef4fa}.bpp-tbl tr.win b{color:#2c5d8f}.bpp-tbl .dim{color:#9aa3b2}.bpp-tbl .mono{font-family:var(--font-mono);color:#33415c}.cs-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:4px 0 10px}.cs-row label{display:flex;align-items:center;gap:8px;font-size:var(--text-xs);color:#46617f}.cs-row select{font:inherit;font-size:var(--text-xs);padding:2px 6px;border:1px solid #9fb3c9;border-radius:3px}.cs-row .mono{font-family:var(--font-mono)}.cs-row .dim{color:#9aa3b2}canvas#csCanvas{width:100%;max-width:560px;height:auto;border:1px solid #cfd8e6;border-radius:3px;background:#fff;display:block;image-rendering:pixelated}.zgrid{display:grid;gap:5px;margin:4px 0 2px;align-items:center}.zgrid.fmt{grid-template-columns:42px 1fr 1fr 1fr}.zgrid.enc{grid-template-columns:1fr 1fr 1fr 1fr}.zgrid.chroma{grid-template-columns:1fr 1fr 1fr}.zgrid .zh{font-size:var(--text-2xs);color:#6b7280;text-align:center;font-family:var(--font-mono)}.zgrid .zlabel{font-size:var(--text-micro);color:#46617f;font-weight:bold}.zcell{margin:0}.zcell img{width:100%;height:auto;display:block;border:1px solid #cfd8e6;border-radius:2px;image-rendering:pixelated;background:#fff}.zcell figcaption{font-size:var(--text-3xs);color:#6b7280;text-align:center;font-family:var(--font-mono);margin-top:2px;line-height:1.35}.pj-panes{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:4px 0 6px}.pj-pane{margin:0}.pj-frame{position:relative;background:#e9edf3;border:1px solid #cfd8e6;border-radius:2px;aspect-ratio:400/266}.pj-frame canvas{display:block;width:100%;height:100%}.pj-empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-size:var(--text-2xs);color:#6b7280;text-align:center;padding:4px}.pj-none canvas{visibility:hidden}.pj-none .pj-empty{display:flex}.pj-pane figcaption{font-size:var(--text-3xs);color:#46617f;text-align:center;margin-top:2px;line-height:1.35}.pj-mono,.pj-ctl output{font-family:var(--font-mono)}.pj-ctl{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:var(--text-xs);color:#46617f;margin:4px 0}.pj-ctl input[type=range]{flex:1;min-width:160px}.pj-ctl select,.pj-ctl button{font:inherit;font-size:var(--text-xs);padding:1px 6px;border:1px solid #9fb3c9;border-radius:3px;background:#fff}.pj-scans{border-collapse:collapse;font-size:var(--text-micro);width:100%;margin:4px 0 2px}.pj-scans th{text-align:left;color:#6b7280;font-size:var(--text-2xs);font-weight:bold;border-bottom:1px solid #cfd8e6;padding:2px 5px}.pj-scans td{padding:2px 5px;border-bottom:1px solid #eef2f7;font-family:var(--font-mono)}.pj-scans .pj-done td{color:#33415c;background:#eef4fa}.pj-scans .pj-part td{color:#33415c;background:#fff7dd}.pj-scans .pj-wait td{color:#9aa3b2}.pj-scans .pj-gate td:first-child::after{content:\" \\2605\";color:#c2410c}canvas#crCanvas{width:100%;max-width:560px;height:auto;border:1px solid #cfd8e6;border-radius:3px;background:#fff;display:block}.cs-row input[type=range]{min-width:140px}.cr-info{margin-top:6px;color:#33415c}", "demoJsFile": "encoding-demo.js" } diff --git a/pipelines/lwe/specs/fuse.json b/pipelines/lwe/specs/fuse.json index 69479f13c..cae3470ba 100644 --- a/pipelines/lwe/specs/fuse.json +++ b/pipelines/lwe/specs/fuse.json @@ -300,6 +300,6 @@ "scrollnote": "end of first pass" } ], - "demoCss": ".fz-bank{display:flex;gap:3px;flex-wrap:wrap;margin:0 0 7px}.fz-cell{width:19px;height:24px;display:grid;place-items:center;font-family:var(--font-mono);font-size:10pt;color:#8d96a6;background:#f7f8fa;border:1px solid #c3cbdb;box-shadow:inset 1px 1px 0 #fff}.fz-cell.on{color:#fff;background:linear-gradient(180deg,#d9614a,#b0311d);border-color:#8a2413;box-shadow:inset 0 1px 0 #e79683}.fz-meta{font-size:8.5pt;color:#6b7280;margin-bottom:9px}.fz-meta b{font-family:var(--font-mono);font-size:10pt;color:#8a2413}.fz-row{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap}.fz-row label{font-size:8.5pt;color:#6b7280}.fz-slide{flex:1;min-width:120px;max-width:230px;accent-color:#b0311d}.fz-row>b{font-family:var(--font-mono);font-size:10pt;color:#8a2413;min-width:16px}.fz-log{font-size:8.5pt;color:#6b7280;background:#faf6f4;border:1px solid #e8d5cf;border-radius:3px;padding:6px 8px;min-height:15px}.fz-log.ok{color:#245c36;background:#f0f7f1;border-color:#c8e0ce}.fz-log.bad{color:#8a2413;background:#fbeeea;border-color:#e8c4b9}.fz-imgs{margin-bottom:10px}.fz-imgs .xp-button.sel{border-color:#b0311d;background:linear-gradient(180deg,#fff,#fae9e4)}.fz-pair{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:8px}.fz-pane{flex:1 1 190px;border:1px solid #c3cbdb;border-radius:3px;overflow:hidden;background:#fff}.fz-pane-h{font-size:8pt;color:#4b5563;background:linear-gradient(180deg,#f7f8fa,#eceff4);border-bottom:1px solid #c3cbdb;padding:3px 7px;font-weight:bold}.fz-verdict{font-size:9pt;padding:8px 9px;color:#6b7280;min-height:34px}.fz-verdict.ok{color:#245c36}.fz-verdict.bad{color:#8a2413}@media(max-width:520px){.fz-pair{flex-direction:column}}", + "demoCss": ".fz-bank{display:flex;gap:3px;flex-wrap:wrap;margin:0 0 7px}.fz-cell{width:19px;height:24px;display:grid;place-items:center;font-family:var(--font-mono);font-size:var(--text-ui);color:#8d96a6;background:#f7f8fa;border:1px solid #c3cbdb;box-shadow:inset 1px 1px 0 #fff}.fz-cell.on{color:#fff;background:linear-gradient(180deg,#d9614a,#b0311d);border-color:#8a2413;box-shadow:inset 0 1px 0 #e79683}.fz-meta{font-size:var(--text-micro);color:#6b7280;margin-bottom:9px}.fz-meta b{font-family:var(--font-mono);font-size:var(--text-ui);color:#8a2413}.fz-row{display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap}.fz-row label{font-size:var(--text-micro);color:#6b7280}.fz-slide{flex:1;min-width:120px;max-width:230px;accent-color:#b0311d}.fz-row>b{font-family:var(--font-mono);font-size:var(--text-ui);color:#8a2413;min-width:16px}.fz-log{font-size:var(--text-micro);color:#6b7280;background:#faf6f4;border:1px solid #e8d5cf;border-radius:3px;padding:6px 8px;min-height:15px}.fz-log.ok{color:#245c36;background:#f0f7f1;border-color:#c8e0ce}.fz-log.bad{color:#8a2413;background:#fbeeea;border-color:#e8c4b9}.fz-imgs{margin-bottom:10px}.fz-imgs .xp-button.sel{border-color:#b0311d;background:linear-gradient(180deg,#fff,#fae9e4)}.fz-pair{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:8px}.fz-pane{flex:1 1 190px;border:1px solid #c3cbdb;border-radius:3px;overflow:hidden;background:#fff}.fz-pane-h{font-size:var(--text-2xs);color:#4b5563;background:linear-gradient(180deg,#f7f8fa,#eceff4);border-bottom:1px solid #c3cbdb;padding:3px 7px;font-weight:bold}.fz-verdict{font-size:var(--text-xs);padding:8px 9px;color:#6b7280;min-height:34px}.fz-verdict.ok{color:#245c36}.fz-verdict.bad{color:#8a2413}@media(max-width:520px){.fz-pair{flex-direction:column}}", "demoJs": "(function(){\nvar bank=document.getElementById('fz-bank');\nif(bank){\n var floorEl=document.getElementById('fz-floor'),ver=document.getElementById('fz-ver'),\n verOut=document.getElementById('fz-verout'),flash=document.getElementById('fz-flash'),\n undo=document.getElementById('fz-undo'),log=document.getElementById('fz-log'),N=12,floor=3;\n function draw(){\n while(bank.firstChild)bank.removeChild(bank.firstChild);\n for(var i=0;i 1 ? 1 : u; u = u * u * (3 - 2 * u); return a + (b - a) * u; }\n function crossings() {\n // pull cinches the two crossings; a granny also slides them apart (slipping)\n if (square) return [ss(0.34, 0.44, t), ss(0.66, 0.56, t)];\n return [ss(0.34, 0.16, t), ss(0.66, 0.84, t)];\n }\n function yOf(which, x, c1, c2) {\n var sw = 0.06, lane = which === 'A' ? [topY, botY] : [botY, topY];\n if (x < c1 - sw) return lane[0];\n if (x < c1 + sw) return ss(lane[0], lane[1], (x - (c1 - sw)) / (2 * sw));\n if (x < c2 - sw) return lane[1];\n if (x < c2 + sw) return ss(lane[1], lane[0], (x - (c2 - sw)) / (2 * sw));\n return lane[0];\n }\n function path(which, c1, c2) {\n ctx.beginPath();\n for (var i = 0; i <= 200; i++) { var x = i / 200, X = 16 + x * (W - 32), Y = yOf(which, x, c1, c2); i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); }\n }\n function overSeg(which, xc, c1, c2) {\n // redraw a short segment of `which` over the crossing, with a background gap under it\n ctx.lineWidth = 11; ctx.strokeStyle = '#fffafa'; ctx.beginPath();\n for (var i = -10; i <= 10; i++) { var x = xc + i / 200, X = 16 + x * (W - 32), Y = yOf(which, x, c1, c2); i === -10 ? ctx.moveTo(X, Y) : ctx.lineTo(X, Y); }\n ctx.stroke();\n ctx.lineWidth = 7; ctx.strokeStyle = which === 'A' ? RED : BLUE; ctx.beginPath();\n for (var j = -10; j <= 10; j++) { var x2 = xc + j / 200, X2 = 16 + x2 * (W - 32), Y2 = yOf(which, x2, c1, c2); j === -10 ? ctx.moveTo(X2, Y2) : ctx.lineTo(X2, Y2); }\n ctx.stroke();\n }\n function draw() {\n var cr = crossings(), c1 = cr[0], c2 = cr[1];\n ctx.clearRect(0, 0, W, H); ctx.lineCap = 'round'; ctx.lineWidth = 7;\n ctx.strokeStyle = RED; path('A', c1, c2); ctx.stroke();\n ctx.strokeStyle = BLUE; path('B', c1, c2); ctx.stroke();\n overSeg('A', c1, c1, c2); // crossing 1: A over (fixed)\n overSeg(square ? 'B' : 'A', c2, c1, c2); // crossing 2: opposite (square) or same (granny)\n if (btn) btn.textContent = square ? 'knot: square (reef)' : 'knot: granny';\n if (verdict) verdict.innerHTML = square\n ? 'Square (reef). The second crossing mirrors the first, so the knot seats flat and each rope nips the other. Pull and it locks.'\n : 'Granny. The second crossing repeats the first, so the knot is unbalanced. Pull and it capsizes and slides apart, a slip waiting to happen.';\n }\n function loop(now) { if (t < 1) { t = Math.min(1, t + (last ? Math.min(now - last, 50) * 0.0018 : 0.03)); last = now; draw(); anim = requestAnimationFrame(loop); } }\n function startPull() { if (anim) cancelAnimationFrame(anim); t = 0; last = 0; draw(); anim = requestAnimationFrame(loop); }\n if (btn) btn.addEventListener('click', function () { square = !square; t = 0; draw(); });\n if (pull) pull.addEventListener('click', startPull);\n draw();\n})();" } diff --git a/pipelines/lwe/specs/pcrypto.json b/pipelines/lwe/specs/pcrypto.json index 630173e98..cdd19dc55 100644 --- a/pipelines/lwe/specs/pcrypto.json +++ b/pipelines/lwe/specs/pcrypto.json @@ -151,6 +151,6 @@ { "who": "you", "time": "22:37", "html": "

      got it. the four pieces are one compiler target, not four gadgets. thanks.

      → the 0xPARC primer (PDF) · FHE · MPC · TEEs · back to Learning With Errors

      " }, { "scrollnote": "end of first pass" } ], - "demoCss": ".snk-row{display:flex;gap:7px;align-items:center;margin-bottom:8px}.snk-row label{font-size:8.5pt;color:#6b7280;flex:0 0 auto}.snk-in{font-family:var(--font-mono);font-size:12pt;padding:4px 8px;flex:1;min-width:0;border:1px solid #7d8aa3;box-shadow:inset 1px 1px 0 #c3cbdb}.snk-lab{font-size:8pt;color:#7a4eb0;margin-bottom:3px}.snk-out{font-family:var(--font-mono);font-size:9pt;word-break:break-all;color:#7a4eb0;background:#faf7fe;border:1px solid #d9c9ef;border-radius:3px;padding:6px 8px}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", + "demoCss": ".snk-row{display:flex;gap:7px;align-items:center;margin-bottom:8px}.snk-row label{font-size:var(--text-micro);color:#6b7280;flex:0 0 auto}.snk-in{font-family:var(--font-mono);font-size:var(--text-h2);padding:4px 8px;flex:1;min-width:0;border:1px solid #7d8aa3;box-shadow:inset 1px 1px 0 #c3cbdb}.snk-lab{font-size:var(--text-2xs);color:#7a4eb0;margin-bottom:3px}.snk-out{font-family:var(--font-mono);font-size:var(--text-mono);word-break:break-all;color:#7a4eb0;background:#faf7fe;border:1px solid #d9c9ef;border-radius:3px;padding:6px 8px}\nbody { text-wrap: pretty; } /* progressive; older engines ignore it */", "demoJs": "(function(){var inp=document.getElementById('snk-in'),out=document.getElementById('snk-out');if(!inp||!out)return;function hex(b){return Array.prototype.map.call(new Uint8Array(b),function(x){return x.toString(16).padStart(2,'0')}).join('')}function go(){Promise.resolve(crypto.subtle.digest('SHA-256',new TextEncoder().encode(inp.value))).then(function(d){out.textContent='0x'+hex(d)})}inp.addEventListener('input',go);go();})();" } diff --git a/serendipity/serendipity.ts b/serendipity/serendipity.ts index 930facab2..3de270e1d 100644 --- a/serendipity/serendipity.ts +++ b/serendipity/serendipity.ts @@ -286,7 +286,7 @@ function shellCss() { .pane .brand{display:block;padding:2px 4px 10px;color:oklch(16% 0 0);text-decoration:none} .pane .brand b{font:600 16pt var(--font-caption);display:block;line-height:1} .pane .brand span{font-size:10px;color:oklch(45% 0.01 250)} - .pane-head{font:bold 8.5pt var(--font-caption);color:#fff;padding:3px 10px;border-radius:3px 3px 0 0;text-shadow:0 1px 1px rgba(0,30,90,.5);background:linear-gradient(180deg,oklch(66% 0.16 255),oklch(54% 0.20 260))} + .pane-head{font:bold var(--text-micro) var(--font-caption);color:#fff;padding:3px 10px;border-radius:3px 3px 0 0;text-shadow:0 1px 1px rgba(0,30,90,.5);background:linear-gradient(180deg,oklch(66% 0.16 255),oklch(54% 0.20 260))} .pane-body{border:1px solid #bcd0ec;border-top:0;background:rgba(255,255,255,.55);padding:4px;display:flex;flex-direction:column} .pane-body a{padding:3px 8px;border-radius:0;text-decoration:none;color:oklch(42% 0.235 264)} .pane-body a:hover{background:#2f6fde;color:#fff} @@ -296,7 +296,7 @@ function shellCss() { h1.page{font-size:19pt;font-weight:bold;color:oklch(33% 0.10 255);margin:0 0 2px;letter-spacing:-.01em} .lede{color:oklch(40% 0.01 250);font-size:11px;margin:0 0 16px;max-width:62ch} hr.sep{border:0;border-top:1px solid oklch(85% 0.03 250);margin:14px 0} - .grp{font:bold 8.5pt var(--font-caption);text-transform:uppercase;letter-spacing:.06em;color:oklch(45% 0.02 255);margin:18px 0 8px} + .grp{font:bold var(--text-micro) var(--font-caption);text-transform:uppercase;letter-spacing:.06em;color:oklch(45% 0.02 255);margin:18px 0 8px} /* event card — sunken-bevel list item */ .ev{display:block;text-decoration:none;color:inherit;border:1px solid oklch(80% 0.035 250);border-top-color:oklch(70% 0.05 250);border-left-color:oklch(70% 0.05 250);background:#fff;padding:10px 12px;margin:0 0 7px;border-radius:0;content-visibility:auto;contain-intrinsic-size:auto 66px} .ev:hover{border-color:oklch(50% 0.18 263);background:oklch(98% 0.02 250)} @@ -331,7 +331,7 @@ function shellCss() { .att .soc{display:flex;gap:6px;flex:0 0 auto} .att .soc a{font-size:10px} .empty{text-align:center;color:oklch(50% 0.01 250);padding:34px 12px;border:1px dashed oklch(78% 0.04 250);border-radius:0;background:oklch(98% 0.01 250)} - .xp-button{display:inline-block;min-width:73px;padding:4px 14px;font:8pt/1.3 var(--font-ui);color:#000;cursor:pointer;border:1px solid #8e9dad;border-radius:0;text-decoration:none;background:linear-gradient(180deg,#fff,#fdfdfd 45%,#f3f2ec 55%,#e9e7dc)} + .xp-button{display:inline-block;min-width:73px;padding:4px 14px;font:var(--text-2xs)/1.3 var(--font-ui);color:#000;cursor:pointer;border:1px solid #8e9dad;border-radius:0;text-decoration:none;background:linear-gradient(180deg,#fff,#fdfdfd 45%,#f3f2ec 55%,#e9e7dc)} .xp-button:hover{border-color:#e9994a;box-shadow:inset 0 0 0 1px #fdd78b,0 0 3px 1px rgba(255,199,60,.55)} .xp-button.primary{color:#fff;border-color:#2c4d7e;font-weight:bold;background:linear-gradient(180deg,#5b9bf0,#3f81e8 12%,#2f6fde 50%,#2a64d4 88%,#2a60cc)} .note{font-size:11px;color:oklch(42% 0.01 250)} @@ -350,7 +350,7 @@ function shellCss() { .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:0 0 14px} .toolbar .search{flex:1;min-width:170px;margin:0} .chips{display:flex;gap:4px;flex-wrap:wrap} - .chip{font:8.5pt var(--font-ui);padding:3px 11px;border:1px solid #8e9dad;border-radius:0;background:linear-gradient(180deg,#fff,#f3f2ec);color:#222;cursor:pointer} + .chip{font:var(--text-micro) var(--font-ui);padding:3px 11px;border:1px solid #8e9dad;border-radius:0;background:linear-gradient(180deg,#fff,#f3f2ec);color:#222;cursor:pointer} .chip.on{color:#fff;border-color:#2c4d7e;font-weight:bold;background:linear-gradient(180deg,#5b9bf0,#2f6fde 60%,#2a60cc)} .ev[hidden],.grp[hidden]{display:none} .toolbar:has(+ [data-island] .empty){display:none} diff --git a/src/pages/access/index.html b/src/pages/access/index.html index f9b94d375..429a56b0d 100644 --- a/src/pages/access/index.html +++ b/src/pages/access/index.html @@ -58,49 +58,49 @@ wallpaper either side and the graph rendering at 1:1 in an 1188px canvas, so half the pixels of a projector went to Bliss. A maximized MMC console is also what the title bar's box glyph has always claimed this window is. */ -.window{--axp-maxw:1880px;--axp-gutter:16px;font-family:var(--font-ui);font-size:10.5pt;color:var(--ink)} +.window{--axp-maxw:1880px;--axp-gutter:16px;font-family:var(--font-ui);font-size:var(--text-body);color:var(--ink)} h1{font-family:var(--font-caption);font-size:17pt;font-weight:700;color:var(--navy);margin:0 0 2px;letter-spacing:-.01em;text-wrap:balance} -h2{font-family:var(--font-caption);font-size:12.5pt;font-weight:700;color:var(--navy2);margin:22px 0 6px} -h3{font-family:var(--font-caption);font-size:10.5pt;font-weight:700;color:var(--navy);margin:14px 0 4px} +h2{font-family:var(--font-caption);font-size:var(--text-h2);font-weight:700;color:var(--navy2);margin:22px 0 6px} +h3{font-family:var(--font-caption);font-size:var(--text-body);font-weight:700;color:var(--navy);margin:14px 0 4px} p{margin:0 0 10px;line-height:1.52;color:var(--ink-soft)} /* Widening the window widened the PROSE with it, to about 190 characters a line. The console wants the whole screen and a paragraph wants 80 characters, so the measure is declared on the text rather than inherited from the frame. Direct children only: the panel, the legend, the table and the console set their own. */ .content>p,.content>h2,.content>h3,.content>ul,.content>ol,.gb,footer{max-width:82ch} -.lede{color:var(--ink);font-size:11pt;max-width:74ch} +.lede{color:var(--ink);font-size:var(--text-lede);max-width:74ch} b{color:var(--ink)} a{color:var(--link)} -small,.meta{font-size:9pt;color:var(--ink-dim);font-family:var(--font-mono)} +small,.meta{font-size:var(--text-mono);color:var(--ink-dim);font-family:var(--font-mono)} /* ---- the MMC console ------------------------------------------------- */ .mmc{position:relative;border:1px solid var(--shadow);box-shadow:var(--bevel-sunken);background:var(--face);margin:14px 0 6px} -.mmc-menu{display:flex;gap:2px;padding:2px 3px;border-bottom:1px solid var(--face);background:var(--face-light);font-size:calc(9.5pt * var(--sc))} -.mmc-menu span,.mmc-menu button{font:inherit;font-size:calc(9.5pt * var(--sc));padding:2px 8px;border:1px solid transparent;border-radius:2px;color:var(--ink-soft);background:transparent} +.mmc-menu{display:flex;gap:2px;padding:2px 3px;border-bottom:1px solid var(--face);background:var(--face-light);font-size:calc(var(--text-sm) * var(--sc))} +.mmc-menu span,.mmc-menu button{font:inherit;font-size:calc(var(--text-sm) * var(--sc));padding:2px 8px;border:1px solid transparent;border-radius:2px;color:var(--ink-soft);background:transparent} .mmc-menu span:first-child{color:var(--ink)} .mmc-menu button{color:var(--ink);cursor:pointer} .mmc-menu button:hover,.mmc-menu button[aria-expanded=true]{background:var(--selection);color:#fff;border-color:var(--selection)} .mmc-menu button:focus-visible{outline:1px dotted var(--navy);outline-offset:1px} /* the one real dropdown. XP menus really did drop a soft shadow. */ .menu-pop{position:fixed;inset:auto;margin:0;z-index:30;min-width:196px;background:var(--face-light);border:1px solid var(--darkshadow);box-shadow:2px 3px 7px rgba(0,20,90,.28);padding:2px} -.menu-pop button{font:inherit;font-size:calc(9.5pt * var(--sc));display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:transparent;border:0;padding:4px 10px 4px 6px;cursor:pointer;color:var(--ink)} +.menu-pop button{font:inherit;font-size:calc(var(--text-sm) * var(--sc));display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:transparent;border:0;padding:4px 10px 4px 6px;cursor:pointer;color:var(--ink)} .menu-pop button:hover{background:var(--selection);color:#fff} -.menu-pop .tick{width:13px;flex:0 0 13px;font-family:var(--font-ui);font-weight:700;font-size:10pt;line-height:1} +.menu-pop .tick{width:13px;flex:0 0 13px;font-family:var(--font-ui);font-weight:700;font-size:var(--text-ui);line-height:1} .mmc-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:5px 6px;border-top:1px solid var(--highlight);border-bottom:1px solid var(--shadow);background:var(--face)} /* a caption and the control it names wrap as one item, so a narrow toolbar cannot leave the word ZOOM stranded on a row above its own buttons */ .bar-grp{display:flex;gap:8px;align-items:center} .seg{display:flex;box-shadow:var(--bevel-raised);border:1px solid var(--darkshadow);background:var(--grad-button)} -.seg button{font:inherit;font-size:calc(9.5pt * var(--sc));color:var(--ink);background:transparent;border:0;padding:4px 11px;cursor:pointer} +.seg button{font:inherit;font-size:calc(var(--text-sm) * var(--sc));color:var(--ink);background:transparent;border:0;padding:4px 11px;cursor:pointer} .seg button+button{border-left:1px solid var(--shadow)} .seg button[aria-pressed=true]{background:linear-gradient(180deg,#c9d9f0,#e4ecf9);box-shadow:var(--bevel-sunken);font-weight:700} .seg button:hover{background:var(--grad-btn-hover)} /* luna's command button, scaled with the chrome like everything above */ -.xp-button{font-size:calc(8pt * var(--sc))}.xp-button.compact{font-size:calc(9pt * var(--sc))} +.xp-button{font-size:calc(var(--text-2xs) * var(--sc))}.xp-button.compact{font-size:calc(var(--text-xs) * var(--sc))} .xp-button[aria-pressed=true]{background:linear-gradient(180deg,#f6d9d3,#fbeae6);border-color:var(--danger);font-weight:700} .seg button:focus-visible{outline:1px dotted var(--navy);outline-offset:1px} -.bar-lab{font-size:calc(9pt * var(--sc));color:var(--ink-dim);text-transform:uppercase;letter-spacing:.05em} -select{font:inherit;font-size:calc(9.5pt * var(--sc));border:1px solid var(--darkshadow);box-shadow:var(--bevel-sunken);background:var(--window);padding:3px 4px} +.bar-lab{font-size:calc(var(--text-xs) * var(--sc));color:var(--ink-dim);text-transform:uppercase;letter-spacing:.05em} +select{font:inherit;font-size:calc(var(--text-sm) * var(--sc));border:1px solid var(--darkshadow);box-shadow:var(--bevel-sunken);background:var(--window);padding:3px 4px} /* THE SPLIT IS SIZED OFF THE VIEWPORT, never off a constant. A 380px floor and a 580px ceiling showed 42% of a 1385px board through a scroller nested inside @@ -117,7 +117,7 @@ below keeps the graph from resizing by a scrollbar width as one appears. */ .mmc-canvas{overflow-x:auto;overflow-y:scroll} .mmc-canvas.z-fit{overflow:hidden} -.mmc-status{display:flex;gap:0;border-top:1px solid var(--highlight);background:var(--face);font-size:calc(9pt * var(--sc));color:var(--ink-soft);flex-wrap:wrap} +.mmc-status{display:flex;gap:0;border-top:1px solid var(--highlight);background:var(--face);font-size:calc(var(--text-xs) * var(--sc));color:var(--ink-soft);flex-wrap:wrap} .mmc-status span{padding:3px 8px;box-shadow:var(--bevel-sunken);flex:0 0 auto} .mmc-status span:last-child{flex:1 1 auto;color:var(--ink-dim);min-width:180px} @@ -161,11 +161,11 @@ /* left pane: the tree, as a tree */ .tree,.tree ul{list-style:none;margin:0;padding:0} .tree ul{margin-left:15px;border-left:1px dotted var(--shadow)} -.tree li{font-size:calc(9.5pt * var(--sc));line-height:1.35} -.tree button{font:inherit;font-size:calc(9.5pt * var(--sc));display:flex;align-items:center;gap:5px;width:100%;text-align:left;background:transparent;border:0;padding:2px 6px 2px 4px;cursor:pointer;color:var(--ink)} +.tree li{font-size:calc(var(--text-sm) * var(--sc));line-height:1.35} +.tree button{font:inherit;font-size:calc(var(--text-sm) * var(--sc));display:flex;align-items:center;gap:5px;width:100%;text-align:left;background:transparent;border:0;padding:2px 6px 2px 4px;cursor:pointer;color:var(--ink)} .tree button:hover{background:var(--sel-soft)} .tree button[aria-current=true]{background:var(--selection);color:#fff} -.tree .tw{width:9px;flex:0 0 9px;font-family:var(--font-mono);font-size:8pt;color:var(--ink-dim)} +.tree .tw{width:9px;flex:0 0 9px;font-family:var(--font-mono);font-size:var(--text-2xs);color:var(--ink-dim)} .tree button[aria-current=true] .tw{color:#dbe6f7} .tree .gi{width:13px;height:13px;flex:0 0 13px} .tree .bn{font-weight:700;color:var(--navy)} @@ -233,14 +233,14 @@ /* the shared hover engine (/hoist.js) shows it as a manual popover and writes the cursor to --x/--y; the clamp keeps it on screen. The resets undo the UA popover box (inset:0, a border, a Canvas background, overflow:auto). */ -#tip{position:fixed;z-index:60;top:0;left:0;right:auto;bottom:auto;margin:0;overflow:visible;width:auto;height:auto;max-width:calc(342px * var(--sc));display:none;background:#ffffe1;border:1px solid #000;box-shadow:2px 2px 4px rgba(0,0,0,.3);padding:6px 8px;font-size:calc(9.5pt * var(--sc));line-height:1.42;color:#1a1a1a;pointer-events:none; +#tip{position:fixed;z-index:60;top:0;left:0;right:auto;bottom:auto;margin:0;overflow:visible;width:auto;height:auto;max-width:calc(342px * var(--sc));display:none;background:#ffffe1;border:1px solid #000;box-shadow:2px 2px 4px rgba(0,0,0,.3);padding:6px 8px;font-size:calc(var(--text-sm) * var(--sc));line-height:1.42;color:#1a1a1a;pointer-events:none; transform:translate(clamp(8px,calc(var(--x) + 14px),calc(100vw - 100% - 8px)),clamp(8px,calc(var(--y) + 18px),calc(100vh - 100% - 8px)))} #tip:popover-open{display:block} /* keyboard focus tethers under the device instead (there is no cursor to follow) */ #tip.anchored{position-anchor:--xp-tip;position-area:bottom span-right;top:auto;left:auto;transform:none;margin:6px 0 0} #tip .t{font-weight:700;color:var(--navy);display:block;margin-bottom:2px} -#tip .st{display:block;font-family:var(--font-mono);font-size:calc(8.5pt * var(--sc));color:#5a5a52;margin-bottom:4px} -#tip .x{display:block;margin-top:4px;font-family:var(--font-mono);font-size:calc(8.5pt * var(--sc));color:#4a4a4a} +#tip .st{display:block;font-family:var(--font-mono);font-size:calc(var(--text-micro) * var(--sc));color:#5a5a52;margin-bottom:4px} +#tip .x{display:block;margin-top:4px;font-family:var(--font-mono);font-size:calc(var(--text-micro) * var(--sc));color:#4a4a4a} #tip .bl{display:block;margin-top:4px;color:#8a2f20} #tip .bet{display:block;margin-top:4px;color:#4a3b7a} /* the skill-tooltip pair: what the pick buys, what it spends */ @@ -254,48 +254,48 @@ /* properties sheet */ dialog.props{border:2px solid var(--navy);padding:0;background:var(--face);max-width:calc(530px * var(--sc));width:calc(100vw - 32px);box-shadow:3px 4px 12px rgba(0,20,90,.4);color:var(--ink)} dialog.props::backdrop{background:rgba(10,36,106,.28)} -.props .tb{display:flex;align-items:center;gap:6px;padding:4px 5px 4px 8px;color:#fff;font-family:var(--font-caption);font-weight:700;font-size:calc(11pt * var(--sc));text-shadow:1px 1px #0f1089;background:linear-gradient(180deg,#5a86d8,#2f55b8 60%,#3f66c4)} +.props .tb{display:flex;align-items:center;gap:6px;padding:4px 5px 4px 8px;color:#fff;font-family:var(--font-caption);font-weight:700;font-size:calc(var(--text-lede) * var(--sc));text-shadow:1px 1px #0f1089;background:linear-gradient(180deg,#5a86d8,#2f55b8 60%,#3f66c4)} .props .tb .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .props .tb .x{width:19px;height:17px;display:grid;place-items:center;border:1px solid #d8401c;border-radius:3px;background:linear-gradient(180deg,#e8795f,#ae3110);font-size:9px;color:#fff;cursor:pointer;font-family:var(--font-ui)} .props .tb .x:hover{filter:brightness(1.12)} .props .tabs{display:flex;gap:2px;padding:6px 8px 0;background:var(--face)} -.props .tabs button{font:inherit;font-size:calc(9.5pt * var(--sc));padding:3px 11px;border:1px solid var(--shadow);border-bottom:0;border-radius:3px 3px 0 0;background:linear-gradient(180deg,#e3e0d2,#d7d4c6);color:var(--ink-soft);cursor:pointer;position:relative;top:1px} +.props .tabs button{font:inherit;font-size:calc(var(--text-sm) * var(--sc));padding:3px 11px;border:1px solid var(--shadow);border-bottom:0;border-radius:3px 3px 0 0;background:linear-gradient(180deg,#e3e0d2,#d7d4c6);color:var(--ink-soft);cursor:pointer;position:relative;top:1px} .props .tabs button[aria-selected=true]{background:var(--face);color:var(--ink);font-weight:700;top:0;padding-bottom:5px;z-index:2} .props .sheet{border:1px solid var(--shadow);box-shadow:var(--bevel-raised);background:var(--face);margin:0 8px 8px;padding:11px 12px;min-height:calc(186px * var(--sc))} -.props dl{margin:0;font-size:calc(9.5pt * var(--sc))} +.props dl{margin:0;font-size:calc(var(--text-sm) * var(--sc))} .props dt{font-weight:700;color:var(--navy);margin-top:8px} .props dt:first-child{margin-top:0} .props dd{margin:2px 0 0;color:var(--ink-soft);line-height:1.48} .props dd.spec{color:#4a3b7a} .props ul.ex{margin:2px 0 0;padding-left:17px;color:var(--ink-soft);line-height:1.5} -.props .conf{font-family:var(--font-mono);font-size:calc(8.5pt * var(--sc));color:var(--ink-dim)} +.props .conf{font-family:var(--font-mono);font-size:calc(var(--text-micro) * var(--sc));color:var(--ink-dim)} .props .foot{display:flex;justify-content:flex-end;gap:6px;padding:0 8px 9px} /* the flat table */ .tw-scroll{overflow-x:auto;border:1px solid var(--shadow);box-shadow:var(--bevel-sunken);background:var(--window)} -table.devs{border-collapse:collapse;font-size:9pt;min-width:1020px;width:100%} -table.devs caption{text-align:left;font-size:9pt;color:var(--ink-dim);padding:5px 7px;font-family:var(--font-mono)} +table.devs{border-collapse:collapse;font-size:var(--text-xs);min-width:1020px;width:100%} +table.devs caption{text-align:left;font-size:var(--text-mono);color:var(--ink-dim);padding:5px 7px;font-family:var(--font-mono)} table.devs th,table.devs td{border:1px solid #e2e0d6;padding:5px 7px;text-align:left;vertical-align:top;line-height:1.45} -table.devs thead th{background:linear-gradient(180deg,#f4f2e8,#e6e3d5);font-size:8.5pt;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-soft);position:sticky;top:0} +table.devs thead th{background:linear-gradient(180deg,#f4f2e8,#e6e3d5);font-size:var(--text-micro);text-transform:uppercase;letter-spacing:.04em;color:var(--ink-soft);position:sticky;top:0} table.devs tbody th{font-weight:700;color:var(--ink);width:14ch} table.devs tbody tr:nth-child(even){background:#fafaf6} table.devs tbody tr.ph th{font-style:italic} -table.devs .st{font-family:var(--font-mono);font-size:8.5pt;white-space:nowrap} +table.devs .st{font-family:var(--font-mono);font-size:var(--text-micro);white-space:nowrap} table.devs .st-shipped{color:var(--go)} table.devs .st-partial{color:var(--amber)} table.devs .st-frontier{color:#5f5290} table.devs .st-passed{color:var(--danger)} table.devs .st-phantom{color:#71716a} -table.devs .seen{font-family:var(--font-mono);font-size:8.5pt;white-space:nowrap} -table.devs td.ex{font-size:8.5pt} -.legend{display:flex;gap:13px;flex-wrap:wrap;margin:8px 0 2px;font-size:calc(9pt * var(--sc));color:var(--ink-soft)} +table.devs .seen{font-family:var(--font-mono);font-size:var(--text-micro);white-space:nowrap} +table.devs td.ex{font-size:var(--text-micro)} +.legend{display:flex;gap:13px;flex-wrap:wrap;margin:8px 0 2px;font-size:calc(var(--text-xs) * var(--sc));color:var(--ink-soft)} .legend span{display:flex;align-items:center;gap:5px} .legend i{width:11px;height:11px;display:inline-block;border:1px solid var(--shadow)} .legend i.gh{border-style:dashed;background:#f8f8f5} .legend i.rv{border:0;height:0;width:22px;border-top:2px dashed #b8860b} .gb{position:relative;border:1px solid var(--shadow);box-shadow:var(--bevel-sunken);background:var(--paper);border-radius:2px;padding:16px 14px 13px;margin:14px 0} -.gb-legend{position:absolute;top:-9px;left:9px;background:var(--face);padding:0 5px;font-size:9pt;color:var(--ink-dim);font-weight:700;text-transform:uppercase;letter-spacing:.05em} -footer{margin-top:22px;padding-top:9px;border-top:1px solid var(--shadow);font-size:9pt;color:var(--ink-dim)} +.gb-legend{position:absolute;top:-9px;left:9px;background:var(--face);padding:0 5px;font-size:var(--text-xs);color:var(--ink-dim);font-weight:700;text-transform:uppercase;letter-spacing:.05em} +footer{margin-top:22px;padding-top:9px;border-top:1px solid var(--shadow);font-size:var(--text-xs);color:var(--ink-dim)} @media (max-width:820px){ /* Stacked, so the split's height is now shared rather than spent on one pane, and the tree gives most of it back to the board. Fill scales the graph down diff --git a/src/pages/dotfiles/index.html b/src/pages/dotfiles/index.html index 70762b612..227015770 100644 --- a/src/pages/dotfiles/index.html +++ b/src/pages/dotfiles/index.html @@ -25,7 +25,7 @@ diff --git a/src/pages/garage/blueprint.html b/src/pages/garage/blueprint.html index d86891b9f..c246292d3 100644 --- a/src/pages/garage/blueprint.html +++ b/src/pages/garage/blueprint.html @@ -20,38 +20,38 @@ - diff --git a/src/pages/garage/chunks.html b/src/pages/garage/chunks.html index 7bc18ca22..55841be5a 100644 --- a/src/pages/garage/chunks.html +++ b/src/pages/garage/chunks.html @@ -20,9 +20,9 @@ - diff --git a/src/pages/garage/hidden-flags.html b/src/pages/garage/hidden-flags.html index f9cad2aa6..434e1baee 100644 --- a/src/pages/garage/hidden-flags.html +++ b/src/pages/garage/hidden-flags.html @@ -21,36 +21,36 @@ diff --git a/src/pages/garage/horizon.html b/src/pages/garage/horizon.html index 88ec05b0b..e18e6fc08 100644 --- a/src/pages/garage/horizon.html +++ b/src/pages/garage/horizon.html @@ -26,9 +26,9 @@