diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 2086d92..d5041b5 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -118,6 +118,18 @@ touches_promptcaps=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\.
# growth/ is gitignored, so these paths only appear here when someone force-adds them — which is when it matters.
touches_launchfacts=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|README\.md|growth/(launch-checklist|show-hn-draft|product-hunt-draft|launch-alternativeto|2026-07-17-ecosystem-shares|shares)\.md|scripts/check-launch-facts\.mjs)$' || true)
+# index.html + play.html share 893 hand-maintained identical lines (identical after the leading and
+# trailing whitespace strip); the targeted twin checks (resize/pricing/combine/cost) only pin named
+# functions. This one pins the WHOLE overlap: a one-sided edit to any shared line fails, a 2-sided
+# DIVERGENT edit fails, and new duplication cannot enter silently.
+touches_twindrift=$(printf '%s\n' "$staged" | grep -E '(^|/)(index\.html|play\.html|scripts/check-twin-drift\.mjs|scripts/twin-drift-baseline\.json)$' || true)
+# The sandbox matrix behind that guard: 16 mutations of the 2 surfaces, each with the verdict the
+# guard must return — the 9 planned extractions of docs/twin-drift.md report 0 divergences (8 of them
+# are fully silent; row 8 keeps the 5 one-sided deletions it cannot avoid), and a 2-sided DIVERGENT
+# edit must still fail. It re-reads both surfaces, so it runs when the RULES move (the guard, the
+# baseline or the matrix), not on every index.html edit.
+touches_twindriftcases=$(printf '%s\n' "$staged" | grep -E '(^|/)(scripts/check-twin-drift\.mjs|scripts/check-twin-drift-cases\.mjs|scripts/twin-drift-baseline\.json)$' || true)
+
# an imported (#a=) app's spend-capable bridge calls must be refused until a real Run gesture arms it (consent gate).
touches_consent=$(printf '%s\n' "$staged" | grep -E '(^|/)(play\.html|scripts/check-bridge-consent\.mjs)$' || true)
# play.html bakes creator sample results into #a= shares so keyless recipients see what the app makes;
@@ -130,7 +142,7 @@ touches_llmsfull=$(printf '%s\n' "$staged" | grep -E '(^|/)(llms-full\.txt|llms\
# Nothing to check unless an .html file, one of the checkers, or a guarded doc changed.
# Every touches_* variable belongs in this chain — a gate left out of it never runs on a
# commit that stages only its own files, which is exactly the commit it exists to catch.
-[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && exit 0
+[ -z "$staged_html" ] && [ -z "$touches_launchfacts" ] && [ -z "$touches_njseditor" ] && [ -z "$touches_llmgate" ] && [ -z "$touches_export" ] && [ -z "$touches_login" ] && [ -z "$touches_sw" ] && [ -z "$touches_compat" ] && [ -z "$touches_run" ] && [ -z "$touches_jsparity" ] && [ -z "$touches_seedcache" ] && [ -z "$touches_imgports" ] && [ -z "$touches_langpages" ] && [ -z "$touches_editorports" ] && [ -z "$touches_settings" ] && [ -z "$touches_quickadd" ] && [ -z "$touches_i18n" ] && [ -z "$touches_updates" ] && [ -z "$touches_createapp" ] && [ -z "$touches_stale" ] && [ -z "$touches_vresume" ] && [ -z "$touches_lipsync" ] && [ -z "$touches_aresume" ] && [ -z "$touches_share" ] && [ -z "$touches_pricing" ] && [ -z "$touches_combine" ] && [ -z "$touches_pacing" ] && [ -z "$touches_graphpersist" ] && [ -z "$touches_graphsigs" ] && [ -z "$touches_describeapply" ] && [ -z "$touches_authlc" ] && [ -z "$touches_mediapriv" ] && [ -z "$touches_verhist" ] && [ -z "$touches_rungating" ] && [ -z "$touches_appsstorage" ] && [ -z "$touches_linkerr" ] && [ -z "$touches_deploy" ] && [ -z "$touches_hiddencss" ] && [ -z "$touches_connect" ] && [ -z "$touches_undo" ] && [ -z "$touches_costaccrue" ] && [ -z "$touches_resize" ] && [ -z "$touches_catalog" ] && [ -z "$touches_upscale" ] && [ -z "$touches_drift" ] && [ -z "$touches_promptcaps" ] && [ -z "$touches_consent" ] && [ -z "$touches_sharesamples" ] && [ -z "$touches_llmsfull" ] && [ -z "$touches_examples" ] && [ -z "$touches_twindrift" ] && [ -z "$touches_twindriftcases" ] && exit 0
if ! command -v node >/dev/null 2>&1; then
echo "pre-commit: node not found, skipping HTML JS checks" >&2
exit 0
@@ -516,3 +528,54 @@ fi
if [ -n "$touches_llmsfull" ]; then
node "$root/scripts/check-llms-full.mjs"
fi
+
+# 43) TWIN DRIFT: index.html (editor) and play.html (app player + export) carry 893 hand-maintained
+# identical lines — identical after a leading/trailing whitespace strip, because play.html nests
+# the same code deeper; only 415 of the 893 are byte-identical in the files. The other twin
+# checks each pin ONE named function; nothing compared the two files as wholes, so a one-sided
+# edit anywhere else shipped silently. This guard pins the whole shared set against
+# scripts/twin-drift-baseline.json. It FAILS on a line that leaves the set because one surface
+# moved and the other did not, on a line deleted from one surface while still live on the other,
+# on a line that loses a copy on one surface only (the baseline stores per-surface occurrence
+# counts, because a line can appear twice in one file), and on a line that leaves BOTH surfaces
+# while each surface keeps its own DIFFERENT replacement of it. The count is a ratchet —
+# extraction may lower it freely, new duplication may not raise it silently. The ratchet number
+# is DERIVED from the baseline line list, and the required digest pins the per-line counts and
+# the stored text, so no hand edit of the baseline can lift it.
+# What leaves the set without failing: a line gone from BOTH surfaces where at most ONE surface
+# grew a NEW near-identical replacement. The baseline stores, per line, the hashes of the lines
+# that ALREADY looked like it, because a "replacement" that was there first replaced nothing —
+# without that memory the guard failed 4 of the 9 planned extractions in docs/twin-drift.md.
+# Generated regions are excluded, so the library is never booked as hand duplication; the
+# njs-engine block is found by its data-hash, NOT by a script-tag regex that play.html's own
+# export-builder string literal can trigger. Offline. Timings vary by machine and by load, and
+# the load matters more than the code: each range below is scoped to the load band it was
+# measured in and says nothing outside it. On 2026-07-29, over 30 samples of each case on a
+# shared 18-core machine with the load average between 3.19 and 5.68, a clean tree took 0.14s to
+# 0.18s. The worst case is a refactor that moves many twins at once: at the 200-departure
+# classification ceiling it took 1.99s to 3.19s when every departure left both surfaces, and
+# 0.91s to 1.64s when they left one. Never a hang:
+# 201 departures reports totals instead. Each failure list NAMES its first 12 lines and counts
+# the rest.
+# Refresh deliberately:
+# TWIN_DRIFT_UPDATE=1 node scripts/check-twin-drift.mjs
+# Ranked extraction plan: docs/twin-drift.md
+if [ -n "$touches_twindrift" ]; then
+ node "$root/scripts/check-twin-drift.mjs"
+fi
+
+# 44) The sandbox matrix behind check 43. 16 mutations of index.html + play.html in a scratch copy,
+# each with the verdict the guard must return: the clean tree, the 9 planned extractions of
+# docs/twin-drift.md deleted from BOTH surfaces (8 silent, row 8 with the 5 one-sided reports it
+# cannot avoid), a 2-sided DIVERGENT edit, a 1-sided edit, a 1-sided deletion, a correctly
+# mirrored edit, and a fresh duplication. It exists because the guard's 2 directions pull against
+# each other: the first divergence rule failed 4 of those 9 extractions, row 1 included, where
+# the deletion is perfectly mirrored and sig = deletes = 25. Runs when the RULES change (the
+# guard, the baseline, or the matrix), not on every surface edit: it runs the guard 16 times over
+# mutated copies of 1.7 MB of HTML, which took 10.38s to 13.13s wall over 6 samples on 2026-07-29
+# at a load average of 3.18 to 5.19, against the guard's own 0.14s to 0.18s. Load dominates that
+# figure: an earlier 15-case run of the same matrix, at a load average of 23.5 and 6 GB into
+# swap, took 65.9s — roughly 5 times today's slowest sample, on the same verdicts.
+if [ -n "$touches_twindriftcases" ]; then
+ node "$root/scripts/check-twin-drift-cases.mjs"
+fi
diff --git a/docs/twin-drift.md b/docs/twin-drift.md
new file mode 100644
index 0000000..b412518
--- /dev/null
+++ b/docs/twin-drift.md
@@ -0,0 +1,683 @@
+# Twin drift: the index.html ↔ play.html extraction map
+
+Date: 2026-07-28, revised 2026-07-29. Line ranges are on `index.html` and `play.html` as this branch
+commits them, after `origin/main` was merged in (which moved both surfaces); every count here comes
+from `scripts/check-twin-drift.mjs` or `scripts/twin-drift-worklist.mjs`, every verdict from
+`scripts/check-twin-drift-cases.mjs`, and every timing in the Runtime table from
+`scripts/twin-drift-bench.mjs`. The 2 timings outside that table — the baseline refresh and the
+sandbox matrix — were timed by running those 2 commands directly. Every timing states the load
+average of the machine it was measured on.
+
+## The measurement
+
+`index.html` is 12,604 lines. `play.html` is 13,658 lines. They are the two engine surfaces:
+
+- `index.html` — the editor. It may load files from `vendor/`.
+- `play.html` — the app player and the single-file `.html` export. It must stay 1 self-contained file.
+
+**893 distinct lines longer than 40 characters are identical in both files.** They occur
+958 times in `index.html` and 1,011 times in `play.html`. The generated `/` that starts on that literal does not close until the
+last real `` in the file. That match blanked `play.html:11259-13656`: 2,398 lines, 17.6% of
+the file, all of it hand-written player code. 197 shared lines were invisible while it did.
+
+The guard therefore matches `` match. play.html
+// also contains the STRING LITERAL that the export builder uses to re-emit the tag:
+// const engTag = engText ? '` in the file. That blanked 2,398
+// lines of hand-written player code — boot(), the export builder, the balance cache, the
+// __appready__ handoff, the model picker — and made a one-sided edit anywhere in them invisible.
+//
+// scripts/gen-js-engine.mjs:165 writes data-hash = sha256(bundle).slice(0,16). We re-derive it. A
+// quoted string inside RUNTIME_JS cannot forge a body whose sha256 matches its own declared hash.
+const NJS_BLOCK = /",
+ "64ec20d158e524c1 1 1 - - return { url: normalizeLoraUrl(r.url), scale: (r.strength==null||r.strength===\"\") ? 1 : Number(r.strength) };",
+ "6552cb2e39aa8a3d 1 1 - - const n = samples.length, dataLen = n*2, ab = new ArrayBuffer(44+dataLen), dv = new DataView(ab);",
+ "65d35cc6f5cd7704 17 13 8a61b799060b3516,c5521b76b218c14a,e1893250b982420d 8a61b799060b3516,c5521b76b218c14a,e1893250b982420d /* ======================================================================",
+ "6630ac328cbea226 1 1 - - for(let i=0;i rej(new Error(\"couldn’t read that image to resize\"));",
+ "67c3dca541f159ed 1 1 - - if(f.reasoningEffort && f.reasoningEffort!==\"default\") o.reasoning_effort = f.reasoningEffort;",
+ "67ddf1729306bc1c 1 1 - - // buttons can rebuild them against a freshly-shortened link at click time (see below).",
+ "67f344afa5ef0f66 1 1 - - function audioUnitUsd(pricing, chars, seconds){",
+ "68e31cdca7ce4479 1 1 - - if(prompt) body.prompt = prompt; // omit when blank — upscalers run with no instruction; every other path guarantees a non-empty prompt before reaching here",
+ "696fa37f0d59a1c8 1 1 - - if(p.per_target_megapixel_second!=null){ const mp=_num(p.default_target_megapixels)||1; v=_num(p.per_target_megapixel_second)*mp*dur; return Math.max(v, _num(p.minimum_price)||0); }",
+ "6982f55283b9e63f 1 1 - - ",
+ "69f32ca62ef39f92 1 1 - - //