diff --git a/tools/site_audit/audit.html b/tools/site_audit/audit.html
index f1c0d98..15644d0 100644
--- a/tools/site_audit/audit.html
+++ b/tools/site_audit/audit.html
@@ -91,7 +91,17 @@ async function run() {
? (sepKey === stormName ? stormDef : await loadJSON(`../../web/world/data/storms/${sepKey}.json`))
: null;
- const score = await scoreSite({ site, stormDef, stormName, sepStormDef });
+ // scoreSite yields between flights now (SPRINT15 gate 2.1), so this page can
+ // say where it is instead of sitting on "loading…" for a minute.
+ const t0 = performance.now();
+ const score = await scoreSite({ site, stormDef, stormName, sepStormDef, onProgress: (p) => {
+ const secs = ((performance.now() - t0) / 1000).toFixed(1);
+ el('sub').textContent = p.phase === 'sweep'
+ ? `${siteName} / ${stormName} — sweeping: flight ${p.done}/${p.total} quads · ${secs} s`
+ : p.phase === 'fly'
+ ? `${siteName} / ${stormName} — flying the garden: line ${p.done}/${p.total}${p.label ? ` (${p.label})` : ''} · ${secs} s`
+ : `${siteName} / ${stormName} — judging the separation target (${p.label}) · ${secs} s`;
+ } });
const {
dressed, cands, rows, verdict, winners, marginalWinners,
flown, skipped, bare, separation: sep, sepStormName,
diff --git a/web/world/js/editor_score.js b/web/world/js/editor_score.js
index 4bd0457..847e308 100644
--- a/web/world/js/editor_score.js
+++ b/web/world/js/editor_score.js
@@ -40,17 +40,11 @@
import { loadStorm } from './weather.js';
import { START_BUDGET } from './contracts.js';
import { scoreSite, buildScoringWorld, cheapestHonest, marginFlags, collateralExposure } from '../../../tools/site_audit/scorecard.js';
-
-/** The storms a yard gets judged against. `storm_02_wildnight` leads because
- * it is the storm every Sprint-13 number was argued over — score against the
- * one people remember. */
-const STORMS = [
- 'storm_02_wildnight',
- 'storm_01_gentle',
- 'storm_02b_icenight',
- 'storm_03_southerly',
- 'storm_03b_earlybuster',
-];
+// SPRINT15 gate 2.2: the storm list and the SELECTION live in editor_storm.js
+// now — one picker's state, shared with C's WIND panel. D scored the wildnight
+// while viewing the southerly because this file and editor.wind.js each
+// defaulted a private stormKey; neither carries one any more.
+import { stormSel } from './editor_storm.js';
const el = (tag, cls, text) => {
const n = document.createElement(tag);
@@ -93,7 +87,14 @@ function boot() {
const rowStorm = el('div', 'ed-row');
rowStorm.append(el('span', 'ed-label', 'storm'));
const sel = el('select', 'ed-sel');
- for (const s of STORMS) sel.append(new Option(s.replace(/^storm_/, ''), s));
+ for (const s of stormSel.STORM_KEYS) sel.append(new Option(s.replace(/^storm_/, ''), s));
+ // One storm selection for the whole page (gate 2.2): this select is a VIEW of
+ // stormSel, not an owner — change it here and C's wind panel follows, change
+ // it there and this follows. Scoring one storm while reading another's wind
+ // field is how D got quietly wrong numbers.
+ sel.value = stormSel.key;
+ sel.addEventListener('change', () => stormSel.set(sel.value, 'score'));
+ stormSel.onChange((k) => { sel.value = k; });
rowStorm.append(sel);
body.append(rowStorm);
@@ -108,7 +109,8 @@ function boot() {
const note = el('div', 'ed-note',
'Scores a fresh dressed world built from the EXPORT clone — not the editor\'s '
+ 'view, whose wind is a calm stub. Every number flies windForSite() and the real '
- + 'commit→attach chain. Takes a few seconds; that is the point.');
+ + 'commit→attach chain. Slow is honest — every candidate flies the full storm — '
+ + 'but the page stays live and the progress line says where it is.');
body.append(note);
// A score describes the yard as it was WHEN SCORED. The moment anything
@@ -129,14 +131,30 @@ function boot() {
stale = false;
btn.disabled = true;
btn.textContent = 'scoring…';
- out.replaceChildren(el('div', 'ed-note', 'building a dressed world, sweeping every quad, flying the winners…'));
- // yield so the button repaints before we block the thread for seconds
+ // The progress line (gate 2.1). D's verdict on the old blocking run: the
+ // worst part "is not the wait, it's not knowing whether it died". scoreSite
+ // yields between flights now, so every update here actually PAINTS.
+ const prog = el('div', 'ed-note', 'building a dressed world…');
+ out.replaceChildren(prog);
+ // yield so the button repaints before the first heavy chunk
await new Promise((r) => setTimeout(r, 0));
const t0 = performance.now();
+ const secs = () => `${((performance.now() - t0) / 1000).toFixed(1)} s`;
+ const onProgress = (p) => {
+ if (p.phase === 'sweep') {
+ prog.textContent = `sweeping — flight ${p.done}/${p.total} quads · ${secs()}`;
+ } else if (p.phase === 'fly') {
+ prog.textContent = `flying the garden — line ${p.done}/${p.total}${p.label ? ` · ${p.label}` : ''} · ${secs()}`;
+ } else if (p.phase === 'separation') {
+ prog.textContent = p.done
+ ? `separation target judged · ${secs()}`
+ : `judging the pinned separation target (${p.label}) · ${secs()}`;
+ }
+ };
try {
const site = EDITOR.siteClone();
- const stormName = sel.value;
+ const stormName = stormSel.key;
const stormDef = await loadStorm(stormName);
// The separation block names its OWN storm — judging A's pinned target on
@@ -146,7 +164,7 @@ function boot() {
const sepStormDef = sepKey ? (sepKey === stormName ? stormDef : await loadStorm(sepKey)) : null;
const built = await buildScoringWorld(site);
- const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built });
+ const score = await scoreSite({ site, stormDef, stormName, sepStormDef, prebuilt: built, onProgress });
render(out, score, performance.now() - t0);
globalThis.__editorScore = score; // for the selftest + console spelunking
} catch (err) {
diff --git a/web/world/js/editor_storm.js b/web/world/js/editor_storm.js
new file mode 100644
index 0000000..7d152d5
--- /dev/null
+++ b/web/world/js/editor_storm.js
@@ -0,0 +1,66 @@
+/**
+ * editor_storm.js — the editor's ONE storm selection. [Lane B, SPRINT15 gate 2.2]
+ *
+ * D scored the wildnight while looking at the southerly: B's SCORE IT and C's
+ * WIND panel each carried a private `stormKey`, independently defaulted — the
+ * exact two-harnesses-disagreeing failure this repo keeps paying for, arrived
+ * inside a single page. This module is the fix: one storm selection, one storm
+ * LIST, owned here; every panel that shows or uses a storm reads and writes
+ * THIS and never a private copy.
+ *
+ * Deliberately not on A's `EDITOR.on` — emit is private to editor.js, and
+ * widening A's seam for one dropdown is more contract than the problem needs.
+ * B owns this file; C's panel subscribes (THREADS 2026-07-20, gate 2.2
+ * proposal). The default is the southerly — C's old default, kept on purpose:
+ * the WIND panel is live the moment the page opens and needs a storm that
+ * shows something, while SCORE IT is an explicit click behind a visible
+ * dropdown. (A funnel authored under the gentle storm looks like it does
+ * nothing — C's own comment.)
+ *
+ * No I/O here: this holds the NAME, not the storm def. Fetching defs stays
+ * with the panels (loadStorm), which already cache.
+ */
+
+/** Keep in step with data/storms/ — THE list; panels must not carry their own. */
+export const STORM_KEYS = [
+ 'storm_01_gentle',
+ 'storm_02_wildnight',
+ 'storm_02b_icenight',
+ 'storm_03_southerly',
+ 'storm_03b_earlybuster',
+];
+
+export const DEFAULT_STORM = 'storm_03_southerly';
+
+let key = DEFAULT_STORM;
+const subs = new Set();
+
+export const stormSel = {
+ /** The currently selected storm key. */
+ get key() { return key; },
+
+ /**
+ * Change the selection. No-op on the same key; throws on a stranger — a
+ * typo'd storm silently becoming the default is setHardware's lesson again.
+ * `source` is a free label ('score', 'wind', …) so a subscriber can skip
+ * reacting to its own write.
+ */
+ set(next, source = null) {
+ if (next === key) return;
+ if (!STORM_KEYS.includes(next)) {
+ throw new Error(`unknown storm "${next}" — the editor knows: ${STORM_KEYS.join(', ')}`);
+ }
+ key = next;
+ for (const fn of [...subs]) {
+ try { fn(key, source); } catch (err) { console.error('[storm] subscriber threw:', err); }
+ }
+ },
+
+ /** Subscribe to changes; returns an unsubscribe function. */
+ onChange(fn) { subs.add(fn); return () => subs.delete(fn); },
+
+ STORM_KEYS,
+};
+
+// For consoles and selftests — same object, not a copy.
+globalThis.EDITOR_STORM = stormSel;