Skip to content

perf: a 200px tier, histograms on the tile, and one fewer copy of the photo data - #441

Merged
oddharsh merged 5 commits into
mainfrom
photo-tier-and-hover-cost
Aug 17, 2026
Merged

oddharsh merged 5 commits into
mainfrom
photo-tier-and-hover-cost

Conversation

@oddharsh

Copy link
Copy Markdown
Owner

Five commits on the homepage's photo path: what the grid downloads, what a hover
costs, and how many representations of the same facts the repo carries.

Every number here was measured in a real headful browser or against the live
encoder, not inferred.

1. The grid shipped 2.3x more pixels than it can show

The tile is a fixed 184px column and every visitor got the 400px file. Measured
at each DPR:

DPR tier picked bytes before
1 200px 56.3 KiB 131.2 (-57%)
2 400px 131.2 KiB unchanged, already right
3 600px 249.8 KiB got the 400px file, ie soft

Images were 81% of the load, so the 1x case is ~40% off the whole page. The 600px
tier already existed, committed at 3,257 KiB, and nothing served it.

Why srcset is not the <picture> this file twice removed. <picture>
selects on TYPE and leaves a loser to be instantiated: a Chromium hover capture
on 2026-08-11 caught 13 JPEG loads for one tile. srcset selects on SIZE and
resolves to one candidate. I ran that control before writing any of it and again
on the finished page: 12 tiles, two full hover passes, DPR 1/2/3, zero image
loads beyond the initial twelve
, identical to a single-URL control. All three
candidates are AVIF, so no type selection happens anywhere.

Additive by construction, which was most of the work. An /i/ URL names its
bytes, so re-encoding an unchanged tier remints its hash, rewrites every page
referencing it, and orphans the a-dict and p-dict snapshots built against it.
reencode-thumbnails.sh takes TIERS=xs; the backfill ran from canonical
sources with 0 existing tiers lost, 0 changed, 474 -> 632 files.

Two bugs fixed on the way, both of which would have hashed zero photos or
silently dropped tiers: hash-thumbnails.sh discovered stems from .jpg only,
and its per-stem write ASSIGNED the entry where the file's own header calls it a
merge.

2. A hover cost a round trip

Bars fetched /images/meta/<stem>.json and could not draw until it landed:
135ms then 117ms on production, once per photo. They ride on the tile now.

Control: with /images/meta/* aborted outright, bars drew on 6 of 6 hovers.
The same six hovers cost 6 requests before and 3 after, and the 3 that remain are
TEXT racing the shared EXIF index, never bars.

It had to be in the markup. Warming from tooltip.js was the cheaper build and
cannot work: index.html loads that module ON THE FIRST HOVER, deliberately
("visitors who never hover transfer no tooltip JavaScript"), so anything it warms
is too late for the hover that caused the load. I wrote the warm first and
measured 0 warmed requests before any hover, which is what sent me to the loader.

Sourcing it is one memoised ASSETS read per isolate, like alt.json already is.
Bundling all 158 costs 22.9 KiB gzip, 83% of the bundle's headroom; reading
twelve files per request is twelve subrequests against a 50 cap (gotcha 36).

Two real bugs this caught in the previous commit, both on the AVIF decode
repair and both silent: its stem regex spelled (?:-400)? so a failing 200px
tile matched nothing, and it assigned image.src while srcset was still set,
where srcset wins and the browser re-picks the AVIF it just failed to decode.

3. Album art at q63

/rn/art asked Cloudflare for quality 82 while the photo pipeline gives avifenc
-q 63: 0.323 bytes per pixel against 0.066. The numbers are not comparable across
encoders, so the sweep is the justification. Measured against the REAL Cloudflare
AVIF encoder on four covers from the live playlist, ssimulacra2 at the 120px size
the art is DISPLAYED at:

q82   88.67  89.85  88.67  88.82     16013  7348  15148  13331 B
q63   83.73  86.53  83.83  85.12     11136  5293  10929  10313 B

About 27% off. The floor worth comparing against is what this site already ships:
the 200px photo tier every DPR-1 visitor now gets measures 78-80 at ITS display
size, so q63 art sits above the grid beside it.

Judge at display size. The same sweep at the encoded 240px reads ~5 points
harsher and puts q63 at 67.9, which looks like real degradation and is measuring
pixels nobody is shown. ART_VERSION goes to 2, which is not optional: those
responses are immutable for a year.

4. Base64 was fighting brotli

data-hist was 68% of the 12-tile fragment. Base64 packs three bytes into four
characters, destroying the byte alignment brotli's context modelling exploits,
and histogram bins are smooth.

encoding chars/tile br (12 attrs)
base64 344 1,964
one char per bin 256 1,263 (-36%)
5-bit packed + base64 216 1,240

704 br off every homepage. The 5-bit variant wins by 23 bytes and costs a
bit-packing loop at both ends, which is a bad trade for a value decoded on a
hover.

ASCII 63..126 holds none of & < > " (34, 38, 60, 62, all below the range), so
escaping is a no-op by construction. It DOES hold a backtick, which forces
minify-html to keep the quotes: 7 of 12 tiles quoted, 5 unquoted, all 12 intact
at 256 characters. Worst round-trip error is 1 of 100 against all 158 originals,
and the SVG is 32 units tall, so one level is half a pixel. The decode drops from
atob plus a loop to charCodeAt minus 63.

5. One representation of the photo data was redundant

images/meta/ was 158 committed files that are a pure projection of two
other committed files. Verified 158/158, text half and histogram half both exact.

Same argument the Markdown twins won. They are build output now: 158 files and
151 KiB leave the tree, the URL contract does not move, and routes:check proves
it by asserting /images/meta/<stem>.json answers 200 against the BUILT tree —
confirmed with the source directory removed entirely.

The drift checks are tiered rather than deleted. Where images/meta/ exists
(a workstation that just ran the pipeline, the only place these indexes can go
stale) both are rebuilt from it and any disagreement fails. Where it does not,
the check says so and verifies coverage instead. Controlled both ways: absent
skips and exits 0, present-and-drifted fails by name and exits 1. Nothing is lost,
because the drift it guarded was two committed copies disagreeing and there is
one copy now.

Measured and deliberately not done

  • hashes.json is 100% derivable from fingerprints.json (632/632 tiers, hash8
    is the digest prefix). Deriving it means bundling 52 KB instead of 11.5 KB into
    the hot path.
  • The pool prebuilds 4 URL strings per photo, 16,922 bytes, 40% of it, when a
    render uses 12 of 158. Saves module memory only.
  • thumb_jpg looked unused by the grid; /photos renders it, so it stays.
  • data-uploaded ships full ISO with milliseconds where the formatter renders
    HH:MM. Worth ~20 br, and it is not dead: it is the date shown before
    exif.json lands.

Verification

Build, 292/292 contract tests, lint (oxlint incl. type-aware), typecheck,
photos:check, pages:check, routes:check (136 routes), perf-budget, and
wrangler deploy --dry-run all green.

Expect a large diff: 158 new AVIF files for the 200px tier, and 158 deletions for
the retired meta/ tree.

… to everyone

The grid tile is a fixed 184px column and every visitor got the 400px file. That
is 2.3x what a 1x display can show, while a DPR-3 phone asks for 552 device
pixels and was handed 400, so the one-tier choice was wrong at both ends.

Measured on the built tree, 12 tiles, real browser at each DPR:

  DPR 1   200px tier   56.3 KiB   was 131.2   -57%
  DPR 2   400px tier  131.2 KiB   unchanged, and it was already right
  DPR 3   600px tier  249.8 KiB   was the 400px file, ie soft

Images were 81% of the homepage load, so the 1x case is roughly 40% off the
whole page. The document pays ~200 brotli bytes for the extra candidates.

THE 600px TIER ALREADY EXISTED. It is built, hashed and committed at 3,257 KiB
across 158 files and nothing served it. reencode-thumbnails.sh's own header has
described it as "the ~197px tile at DPR-3" the whole time.

WHY srcset IS NOT THE <picture> THIS FILE TWICE REMOVED. <picture>/<source>
select on TYPE and leave a loser to be instantiated: a Chromium hover capture on
2026-08-11 caught 13 JPEG loads for one tile. srcset selects on SIZE and resolves
to a single candidate. I ran that exact control before writing any of this, then
again on the finished page: 12 tiles, two full hover passes, DPR 1/2/3, ZERO
image loads beyond the initial twelve, identical to a single-URL control. All
three candidates are AVIF, so no type selection happens anywhere.

The contract test now pins that invariant instead of its old proxy. It banned
`srcset=` outright because at the time one URL and one format were the same
policy; it asserts no <picture>/<source>/JPEG and that every candidate is one
AVIF with a width descriptor.

ADDITIVE BY CONSTRUCTION, which is most of the work here. An /i/ URL names its
bytes, so re-encoding a tier that did not need to change remints its hash,
rewrites every page referencing it, and orphans the a-dict and p-dict snapshots
built against it. reencode-thumbnails.sh takes TIERS=xs to write the new tier
alone, and the 158 photos were backfilled from canonical sources that way:
0 existing tiers lost, 0 changed, 474 -> 632 files in www/i.

Two bugs that blocked that and are worth fixing regardless. hash-thumbnails.sh
discovered stems from .jpg only, so a run writing one AVIF and no JPG silently
hashed zero photos. And its per-stem write ASSIGNED the entry where the file's
own header calls it a merge, so an additive run would have dropped a, j and s
while their files sat on disk in www/i -- damage that reads as a map that forgot
them rather than as anything missing.

Also: width/height go 600x600 to 184x184. They were describing a file that has
not been served since the grid moved to the 400px tier, and with srcset the
intrinsic size depends on which candidate wins while the tile is always 184.

The x tier is REQUIRED in check-photo-pipeline.mjs rather than optional. The
serving path degrades fine without it, which is exactly why nothing would ever
notice it missing. Its digest-count assertion went red on this change because it
hardcoded 3 tiers; it reads TIER_KEYS.length now.
Hovering a photo fetched /images/meta/<stem>.json and the bars could not draw
until it landed: measured on production at 135ms then 117ms, once per photo. The
worker already knows which twelve it drew, so it packs their four channels into
data-hist on each tile (64 bins x 4, one byte each, base64, 344 chars).

Controlled in a browser with /images/meta/* aborted outright: bars drew on 6 of
6 hovers. Same six hovers cost 6 requests before and 3 after, and the 3 that
remain are for TEXT racing the shared EXIF index, never for bars.

IT HAD TO BE IN THE MARKUP. Warming from tooltip.js was the obvious cheaper
build and cannot work: index.html loads that module ON THE FIRST HOVER, on
purpose ("visitors who never hover transfer no tooltip JavaScript"), so anything
it warms is by construction too late for the hover that caused the load. I wrote
the warm first and measured 0 warmed requests before any hover, which is what
sent me looking at the loader.

WHERE THE WORKER GETS THEM is the rest of the design. The fragment draws a random
twelve per request, so all 158 must be reachable. Bundling them costs 22.9 KiB
gzip, 83% of the bundle's remaining headroom to its advisory. Reading twelve
files per request costs twelve subrequests against a 50 cap on this plan
(gotcha 36). So it is ONE file, memoised per isolate exactly like alt.json and
hashes.json already are: one read per isolate, free on every warm one.

This does not contradict build-exif-index.mjs's reasoning for keeping histograms
out of exif.json. That is about a file every visitor downloads whole, where all
158 would take it from 2.6 KiB to 24 KiB for bars most people never see. Only
the twelve a visitor can actually hover ship here, measured at +2055 br on the
document.

TWO REAL BUGS THE CONTRACT TEST CAUGHT, both introduced by the 200px tier in the
previous commit and both on the AVIF decode repair:

- its stem regex spelled `(?:-400)?`, so a failing 200px tile matched nothing
  and the repair silently did not run. That tier is what DPR-1 visitors get, ie
  most of them.
- it assigned image.src while srcset was still set. srcset WINS, so the browser
  re-picks the AVIF candidate it just failed to decode. The handler runs, the
  attribute changes, and the image stays broken.

Both now have assertions naming them. The test that caught this was pinning
`(?:-400)?` as "the small tier is the only suffix we mint", which stopped being
true one commit earlier.

Also takes the dead `const img = slot.querySelector("img")` in tooltip.js, whose
note said to remove it on the next real edit to that file since it is
content-hashed and would otherwise mint a new /a/ URL for nothing. This is that
edit.

histograms.json is generated by build-histogram-index.mjs, wired into
extract-photo-metadata.sh so `pnpm run photos` keeps it current, and
check-photo-pipeline.mjs rebuilds it through the generator's OWN packing function
and fails on drift. Controlled three ways: a corrupted entry, a missing entry,
and the clean tree.
/rn/art asked Cloudflare Image Resizing for quality 82 while the photo pipeline
gives avifenc -q 63. That showed up as 0.323 bytes per pixel against the photo
tier's 0.066, a 4.9x gap, on art displayed at 120x120.

The number alone proves nothing, because the two encoders are different, so the
sweep is the justification. Measured against the REAL Cloudflare AVIF encoder
(the on-zone /rn/art JPEG variant as source, via /cdn-cgi/image), on four covers
from the live playlist, ssimulacra2 at the 120px size the art is displayed at:

  q82   88.67  89.85  88.67  88.82     16013  7348  15148  13331 bytes
  q70   86.43  87.74  85.33  87.30     12765  5947  12289  11263
  q63   83.73  86.53  83.83  85.12     11136  5293  10929  10313

About 27% off. The floor worth comparing against is what this site already
ships rather than a number from a table: the 200px photo tier every DPR-1
visitor now receives measures 78-80 at ITS display size, so q63 art sits above
the grid it sits beside.

EVALUATE AT DISPLAY SIZE. The same sweep judged at the encoded 240px reads about
5 points harsher and puts q63 at 67.9, which looks like a real degradation and
is measuring pixels nobody is shown. The art has been a 2x asset for a 120px box
since artUrls() dropped the 120w candidate on purpose.

ART_VERSION goes to 2, which is not optional: /rn/art responses are immutable
for a year, so a quality change reaches nobody without a new path. Its own
comment has said "bump it to re-tune width or quality" the whole time. Old URLs
keep serving their q82 bytes for the rest of their year, by design.
data-hist was 68% of the 12-tile fragment (2014 of 2948 br). Base64 packs three
bytes into four characters, which destroys the byte alignment brotli's context
modelling exploits, and histogram bins are SMOOTH: neighbours are close, so
neighbouring characters are close and the compressor can use that.

  base64             344 chars/tile   1964 B br
  one char per bin   256 chars/tile   1263 B br   -36%
  5-bit packed+b64   216 chars/tile   1240 B br

704 br off every homepage document. The 5-bit variant wins by 23 bytes and costs
a bit-packing loop at both ends, which is a bad trade for a value the tooltip
decodes on a hover.

ASCII 63..126 is 64 contiguous characters holding NONE of & < > " (34, 38, 60,
62, all below the range), so escAttr is a no-op here by construction rather than
by luck. Asserted over every committed histogram rather than a fixture, because
the day it breaks is the day a bin goes out of range.

It does hold a backtick, which is one of the characters that forces minify-html
to keep the quotes. Measured on the staged document: 7 of 12 tiles quoted, 5
unquoted, all 12 intact at 256 characters. Both are spec-legal and dataset reads
them the same; noted because an attribute whose quoting varies per value looks
like a bug the first time you diff the output.

64 levels against a 0-100 source costs at most 1 unit of round-trip error,
verified against all 158 originals, and the SVG is 32 units tall so one level is
half a pixel. The decode goes from atob plus a loop to charCodeAt minus 63, and
refuses a truncated or out-of-range value outright rather than drawing a curve
with a hole in it.

Also corrects the photo pool's header, which called thumb_avif "the <picture>
primary", thumb_jpg "the universal <img src> fallback" and thumb_small "the
400px mobile AVIF". All three stopped being true, in three separate changes,
without the comment moving.
images/meta/ was 158 committed files that are a PURE PROJECTION of two other
committed files. Verified 158/158, text half and histogram half both exact:
meta/<stem>.json is exif.json[stem] plus the unpacked histograms.json[stem].

Same argument the Markdown twins won. A pure function of committed bytes should
not be a second committed copy that can fall behind, and generating it in
build.mjs means there is no step anyone can forget. 158 files and 151 KiB of data
leave the tree; the URL contract does not move, and routes:check is what proves
that, since it asserts /images/meta/<stem>.json answers 200 against the BUILT
tree. Confirmed with the source directory removed entirely.

CAUSALITY NOW RUNS ONE WAY. The pipeline still writes images/meta/ locally,
build-exif-index.mjs and build-histogram-index.mjs still read it, and the two
indexes they emit are what gets committed. build.mjs derives the served
per-photo files back out of those indexes, so a stale one is no longer a state
this repository can be in. www/images/meta joins www/scripts in STAGE_SKIP for
exactly that reason: copying whatever is on the local disk and then writing over
it would make the built tree depend on pipeline leftovers.

THE DRIFT CHECKS ARE TIERED RATHER THAN DELETED. Where images/meta/ exists — a
workstation that just ran the pipeline, which is the only place these indexes can
go stale — check-photo-pipeline.mjs rebuilds both from it and fails on any
disagreement. Where it does not, it says so and checks coverage instead.
Controlled both ways: absent skips with a note and exits 0, present-and-drifted
fails by name and exits 1.

Nothing is lost by that tiering. The drift it guarded was two committed copies
disagreeing, and there is one copy now.

The per-stem histogram shape assertion moves off meta/ and onto histograms.json,
so it still runs in CI where the directory is gone.

One honest cost: regenerated files carry histogram values quantised to the
index's 64 levels rather than the original 0-100, so they differ from the retired
committed ones by at most 1. That is 0.32px in the 32-unit SVG they feed, and the
real source of truth is the photograph, which `zenc histogram` can re-bake.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
aadhar-sh 42777a7 Aug 17 2026, 12:59 PM

@github-actions

Copy link
Copy Markdown
Contributor

Wire-size diff vs merge base

da7f047 → 017e9a4

Brotli q11, the quality the edge serves. Every number here is deterministic:
an unchanged file produces no row. Advisory — this check fails on nothing.

Client assets

Total Brotli: 74.97 KiB → 75.15 KiB (+0.17 KiB, +0.2%) across 21 files, 1 changed.

file base head Δ
tooltip.js 2.61 KiB 2.78 KiB +0.17 KiB (+6.6%)

Pages

Total Brotli: 500.45 KiB → 502.05 KiB (+1.60 KiB, +0.3%) across 48 files, 1 changed.

file base head Δ
index.html 7.89 KiB 9.49 KiB +1.60 KiB (+20.3%)

Worker bundle

228.47 KiB → 229.72 KiB gzip (+1.25 KiB, +0.5%)

Largest module deltas (raw bytes in bundle):

file base head Δ
.build/www/images/hashes.json 7.73 KiB 9.73 KiB +2.01 KiB (+26.0%)
.build/www/_worker.js/lib/photo-grid.js 0.81 KiB 1.12 KiB +0.31 KiB (+37.9%)
.build/www/_worker.js/photos.js 9.80 KiB 10.07 KiB +0.27 KiB (+2.8%)
.build/www/_worker.js/home.js 0.97 KiB 1.09 KiB +0.12 KiB (+12.7%)
.build/www/_worker.js/image-tools.js 9.21 KiB 9.25 KiB +0.04 KiB (+0.4%)

Dictionary deltas (pd/)

144 → 144 deltas, 448.45 KiB → 454.20 KiB (+5.75 KiB).

@oddharsh
oddharsh merged commit db94a90 into main Aug 17, 2026
7 checks passed
@oddharsh
oddharsh deleted the photo-tier-and-hover-cost branch August 17, 2026 13:05
oddharsh added a commit that referenced this pull request Aug 17, 2026
The rebase onto main brought #439/#440/#441 (the 200px tier, packed histograms,
the fingerprint index and one fewer copy of the photo data), and that code was
written against www/. Four integration fixes it needed:

- build.mjs's meta-twin derivation wrote to ${OUT}/www/images/meta and read
  exif.json + histograms.json from the same place, so it generated 0 files. Its
  own floor caught it, which is the tripwire doing exactly its job.
- the homepage bake lost its `histograms` load in a conflict resolution and
  failed with a bare ReferenceError. Restored from main.
- contract-tests imported ../www/scripts/build-histogram-index.mjs and read
  www/images/histograms.json.
- tooltip.js cited www/scripts/build-histogram-index.mjs in a comment.

That last one was caught by the served-path contract test added earlier on this
branch, which is the first time it has found something it was not written for.

Also un-resurrected 158 files. #441 DELETED www/images/meta and gitignored it:
the per-photo twins are derived from exif.json + histograms.json now, and a pure
function of committed bytes should not be a second committed copy. The bulk
conflict resolution added them back at their moved path, which would have undone
that silently. The .gitignore rule and build.mjs's STAGE_SKIP both survive,
repointed to public/images/meta.

307 contract tests (main's 12 coverage tests came across), typecheck, lint,
check-wrangler, pages:check, tools:check and perf-budget all clean. Bundle
669.37 KiB, up from 666.62 because main's photo work is now in it.
oddharsh added a commit that referenced this pull request Aug 17, 2026
Closes the drift the previous commit diagnosed. `zenc histogram --check` now
reports "histograms match on disk for 158 photos"; it reported 158 of 158
DRIFTING before.

This is the one deliberate rebake that commit said was needed. The committed set
came from an unrecorded build, so no pin could reproduce it retroactively — the
gap only closes by re-baking once and committing the result. Done under
rust-toolchain.toml (rustc 1.93.0, override confirmed active) and a --locked
build, so this set IS reproducible from the repo from here on.

WHAT MOVED: 12,635 of 40,448 bins, 31.2%, worst delta 37 levels. Same numbers
the investigation measured, which is the point — this is that difference being
accepted rather than a new one. 62% of it is one level, the decoder noise the
format was always going to carry; the tail is the peak normalisation amplifying
it, and that is unchanged by this commit.

BLAST RADIUS, measured against a pre-rebake build manifest: 164 staged files.
158 are the derived per-photo twins, which are gitignored and rebuilt from this
index. The rest are images/histograms.json, index.html (the homepage bakes
histograms into the grid since #441) and its 3 page deltas. NO /a/ asset changed,
so no content-hashed URL was re-minted and no a-dict entry orphaned. One
committed file: public/images/histograms.json.

The peak-normalisation fragility is NOT addressed here and is the open item: a
stable denominator would make the bake robust to the decoder differences
histogram.rs already documents as unavoidable. That changes the format and every
histogram once more, so it stays a separate decision.

photos:check reports 158 photos, 632 hashed tiers, index in bijection. 307
contract tests, lint, typecheck, tools:check, pages:check, perf-budget and the
136-route oracle all clean.
oddharsh added a commit that referenced this pull request Aug 24, 2026
… from it

Eight sites across six photo and asset scripts find their output with
`$SCRIPT_DIR/..`. That was the served tree when these lived in www/scripts/;
it has been tools/ since #452 and #453, so extract-photo-metadata.sh was
writing tools/images/metadata.json, hash-thumbnails.sh was hashing into
tools/i/, and add-car-photo.sh was aiming at tools/cars. add-photos.sh passed
`--root "$PROJECT_DIR/www"`, and www/ stopped existing on 2026-08-18.

A rename sweep greps for the old string, and there is no old string here, only
a relative expression whose meaning is a function of where the file sits. Two
things then kept it silent: `zenc histogram` prints "cannot read" and exits 0,
and both callers pipe it to `tail -1`, so the bake skips without failing. And
the two scripts resolving through $PROJECT_DIR were correctly updated by the
split, which left the pipeline looking half-migrated rather than broken. No
photo has been added since the move, so nobody ran into any of it.

The same rename left build.mjs's taste exemption anchored on /^www\/(garage|
lwe)\//, matching nothing, so the demo pages it exists to exempt lost the
exemption and the build printed 14 warnings on every run since 2026-08-18. A
warn block nobody can clear is how a warn block gets ignored. Re-anchored on
the SERVED_SOURCES roots. Controls both ways: an injected @font-face url() on
a garage page still hard-fails, and a 99px radius there is silent again.

lens-seed.mjs still spawned execFileSync("pnpm", ["exec", "wrangler", ...]),
which refuses outright on a bun tree. The contract test that bans package
manager spawns carried lens-seed.mjs as its one RECORDED exception, justified
as driving a package script rather than using a manager to reach another
binary. That described no code in the file. So the single genuine offender was
the single exempted file and the suite reported green. The spawn goes through
wranglerCommand() now and the allowlist is gone; a control confirms the guard
catches what it used to exempt.

Also dead or wrong, removed or corrected:

- explorerChrome(), exported and called by nothing anywhere, including its own
  file
- an isCallable doc comment orphaned fifteen lines above its function
- build.mjs told you to run node public/scripts/build-histogram-index.mjs,
  which is not a path
- check-wrangler.mjs told you to run `pnpm install`. Its transitive-copy scan
  reads node_modules/.pnpm and so reports 0 forever on a bun tree; the logic is
  unchanged and the blind spot is now named at the code
- MAINTENANCE.md documented `zenc histogram --root www` and two .ts modules as
  .js

CLAUDE.md counts re-derived from the tree: 36 to 37 documents, 46 to 49 test
files, 117 to 147 relative specifiers, 474 to 632 hashed tiers (the 200px tier
from #441 was never written down), 2645/48 to 2921/50 link refs. Gotcha 16
named contract-tests.mjs under `node --test`; both runners were measured, and
the rule holds while both of its symptoms changed, so the documented error
string now finds nothing and the blast radius is one file rather than the run.
Gotcha 39 records the $SCRIPT_DIR/.. class so the next rename has something to
check against.

353 tests, typecheck, lint, build, pages, photos and the 142-route oracle all
green.
oddharsh added a commit that referenced this pull request Aug 24, 2026
… from it (#525)

Eight sites across six photo and asset scripts find their output with
`$SCRIPT_DIR/..`. That was the served tree when these lived in www/scripts/;
it has been tools/ since #452 and #453, so extract-photo-metadata.sh was
writing tools/images/metadata.json, hash-thumbnails.sh was hashing into
tools/i/, and add-car-photo.sh was aiming at tools/cars. add-photos.sh passed
`--root "$PROJECT_DIR/www"`, and www/ stopped existing on 2026-08-18.

A rename sweep greps for the old string, and there is no old string here, only
a relative expression whose meaning is a function of where the file sits. Two
things then kept it silent: `zenc histogram` prints "cannot read" and exits 0,
and both callers pipe it to `tail -1`, so the bake skips without failing. And
the two scripts resolving through $PROJECT_DIR were correctly updated by the
split, which left the pipeline looking half-migrated rather than broken. No
photo has been added since the move, so nobody ran into any of it.

The same rename left build.mjs's taste exemption anchored on /^www\/(garage|
lwe)\//, matching nothing, so the demo pages it exists to exempt lost the
exemption and the build printed 14 warnings on every run since 2026-08-18. A
warn block nobody can clear is how a warn block gets ignored. Re-anchored on
the SERVED_SOURCES roots. Controls both ways: an injected @font-face url() on
a garage page still hard-fails, and a 99px radius there is silent again.

lens-seed.mjs still spawned execFileSync("pnpm", ["exec", "wrangler", ...]),
which refuses outright on a bun tree. The contract test that bans package
manager spawns carried lens-seed.mjs as its one RECORDED exception, justified
as driving a package script rather than using a manager to reach another
binary. That described no code in the file. So the single genuine offender was
the single exempted file and the suite reported green. The spawn goes through
wranglerCommand() now and the allowlist is gone; a control confirms the guard
catches what it used to exempt.

Also dead or wrong, removed or corrected:

- explorerChrome(), exported and called by nothing anywhere, including its own
  file
- an isCallable doc comment orphaned fifteen lines above its function
- build.mjs told you to run node public/scripts/build-histogram-index.mjs,
  which is not a path
- check-wrangler.mjs told you to run `pnpm install`. Its transitive-copy scan
  reads node_modules/.pnpm and so reports 0 forever on a bun tree; the logic is
  unchanged and the blind spot is now named at the code
- MAINTENANCE.md documented `zenc histogram --root www` and two .ts modules as
  .js

CLAUDE.md counts re-derived from the tree: 36 to 37 documents, 46 to 49 test
files, 117 to 147 relative specifiers, 474 to 632 hashed tiers (the 200px tier
from #441 was never written down), 2645/48 to 2921/50 link refs. Gotcha 16
named contract-tests.mjs under `node --test`; both runners were measured, and
the rule holds while both of its symptoms changed, so the documented error
string now finds nothing and the blast radius is one file rather than the run.
Gotcha 39 records the $SCRIPT_DIR/.. class so the next rename has something to
check against.

353 tests, typecheck, lint, build, pages, photos and the 142-route oracle all
green.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
oddharsh added a commit that referenced this pull request Aug 24, 2026
…erved

#394 re-encoded 316 JPEG thumbnails on 2026-08-14, minting a new content hash
for each, and re-baked no histograms. images/histograms.json has described
thumbnails nobody is served ever since. All 158 photos move here: 31.2% of the
40,448 bins, mean 0.95 of 100, but 65 photos carry at least one bin past 15 and
the worst is 59, so this is a visible bar rather than decoder noise.

The decoder was the obvious suspect and is innocent. image 0.25.10 and
zune-jpeg 0.5.15 are pinned identically at every commit involved, and
Cargo.lock has not moved since the pipeline was relocated. What changed is the
INPUT, which is the one thing a version pin cannot hold still: the j-tier hash
for L1000069_3 went 0e092e5f -> 2b30aedc in #394, and the bars stayed.

WHY NOTHING CAUGHT IT, which is the more useful half.

photos:check compares histograms.json against images/meta/. Since #441 made
meta/ build OUTPUT that build.mjs derives FROM histograms.json, those two agree
no matter what the JPEGs contain. It is gotcha 24's shape: two values from one
source, so the check proves that copying works.

zenc histogram --check does read the pixels, and pointing it at a built tree is
guaranteed to report drift whether or not any exists. The index packs each bin
into ONE character over 64 levels while zenc computes 0..100, so a build-derived
meta/ has been through a lossy 101 -> 64 -> 101 round trip. Measured: the
derived tree reproduces roundTrip(committed) on 100.00% of 40,448 bins, so the
packing is faithful and that drift was an artifact of where it was pointed. The
real signal was underneath it and needed the pre-#441 committed meta to see.

THE FIX AND THE GUARD.

The data: materialise meta/, re-run zenc against the current j-tier, roll it
back through build-histogram-index. The result satisfies zenc's own check
("histograms match on disk for 158 photos") and round-trips into the built
meta/ at 256 of 256 bins for a spot-checked stem.

The guard: hash-thumbnails.sh snapshots each stem's j hash and warns when one
moves, naming the re-bake. That script is the choke point where new bytes become
served bytes. add-photos.sh re-bakes the whole library every run and was never at
risk; the gap is a standalone reencode-thumbnails.sh, whose own header tells you
to re-run hash-thumbnails.sh and stops there.

Controlled both ways: a no-op run stays silent, and a stem whose JPEG bytes
change prints the warning naming it. The control mutated real files and the
tree was restored to 632 tracked / 632 on disk afterwards.

353 tests, typecheck, lint, build, photos:check and the 142-route oracle green.
oddharsh added a commit that referenced this pull request Aug 24, 2026
…erved (#535)

#394 re-encoded 316 JPEG thumbnails on 2026-08-14, minting a new content hash
for each, and re-baked no histograms. images/histograms.json has described
thumbnails nobody is served ever since. All 158 photos move here: 31.2% of the
40,448 bins, mean 0.95 of 100, but 65 photos carry at least one bin past 15 and
the worst is 59, so this is a visible bar rather than decoder noise.

The decoder was the obvious suspect and is innocent. image 0.25.10 and
zune-jpeg 0.5.15 are pinned identically at every commit involved, and
Cargo.lock has not moved since the pipeline was relocated. What changed is the
INPUT, which is the one thing a version pin cannot hold still: the j-tier hash
for L1000069_3 went 0e092e5f -> 2b30aedc in #394, and the bars stayed.

WHY NOTHING CAUGHT IT, which is the more useful half.

photos:check compares histograms.json against images/meta/. Since #441 made
meta/ build OUTPUT that build.mjs derives FROM histograms.json, those two agree
no matter what the JPEGs contain. It is gotcha 24's shape: two values from one
source, so the check proves that copying works.

zenc histogram --check does read the pixels, and pointing it at a built tree is
guaranteed to report drift whether or not any exists. The index packs each bin
into ONE character over 64 levels while zenc computes 0..100, so a build-derived
meta/ has been through a lossy 101 -> 64 -> 101 round trip. Measured: the
derived tree reproduces roundTrip(committed) on 100.00% of 40,448 bins, so the
packing is faithful and that drift was an artifact of where it was pointed. The
real signal was underneath it and needed the pre-#441 committed meta to see.

THE FIX AND THE GUARD.

The data: materialise meta/, re-run zenc against the current j-tier, roll it
back through build-histogram-index. The result satisfies zenc's own check
("histograms match on disk for 158 photos") and round-trips into the built
meta/ at 256 of 256 bins for a spot-checked stem.

The guard: hash-thumbnails.sh snapshots each stem's j hash and warns when one
moves, naming the re-bake. That script is the choke point where new bytes become
served bytes. add-photos.sh re-bakes the whole library every run and was never at
risk; the gap is a standalone reencode-thumbnails.sh, whose own header tells you
to re-run hash-thumbnails.sh and stops there.

Controlled both ways: a no-op run stays silent, and a stem whose JPEG bytes
change prints the warning naming it. The control mutated real files and the
tree was restored to 632 tracked / 632 on disk afterwards.

353 tests, typecheck, lint, build, photos:check and the 142-route oracle green.

Co-authored-by: Aadharsh Pannirselvam <19518661+oddharsh@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant