editor: one storm picker + a progress line that paints (S15 gates 2.1/2.2, B's halves)

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 18:19:44 +10:00
parent b1d2d2a8e6
commit d6f04d5e43
3 changed files with 112 additions and 18 deletions

View File

@ -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,

View File

@ -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) {

View File

@ -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;