From d6f04d5e4340c92c8c4ddc71acdeadce0985d98e Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 18:19:44 +1000 Subject: [PATCH] editor: one storm picker + a progress line that paints (S15 gates 2.1/2.2, B's halves) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editor_storm.js owns THE storm selection and THE storm list; SCORE IT's dropdown is a view of it (default = southerly, C's old default, kept on purpose). The score panel renders scoreSite's progress ticks — flight x/y, elapsed — and audit.html gets the same line. C's half: subscribe editor.wind.js per the THREADS proposal. Co-Authored-By: Claude Opus 4.8 --- tools/site_audit/audit.html | 12 ++++++- web/world/js/editor_score.js | 52 ++++++++++++++++++---------- web/world/js/editor_storm.js | 66 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 web/world/js/editor_storm.js 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;