/** * editor_storm.js — THE storm + second selection for the editor page. * [Lane C owns the file, SPRINT15 gate 2.2 — one storm picker, CONVERGED with * Lane B's THREADS proposal (2026-07-20): B's filename, B's global name * (`EDITOR_STORM`), B's `source` argument and B's default storm are all taken; * the API keeps my `storm/time` symmetric shape because the gate asks for a * shared storm+TIME selection — B's panel simply ignores the half it doesn't * use. Countered-and-matched the same way we converged the gate-2.3 pin.] * * Sprint 14 shipped two storm pickers on one page: the WIND panel defaulted the * southerly (mine), SCORE IT defaulted the wildnight (B's), and D scored the * wildnight WHILE LOOKING AT the southerly — two harnesses disagreeing inside a * single page, the exact bug class this repo keeps paying for (three harnesses * measured a funnel-off yard in Sprint 11 for the same species of reason). * * The fix is ownership, not UI: this module is the ONE owner of "which storm, * which second" on the editor page. Panels are views of it — any number of * selects and scrubbers may DISPLAY the selection, but they all write here and * they all re-read from here, so there is nothing for two panels to disagree * about. Same shape as A's editor owning the site and every panel calling * `markDirty()`: one place remembers, nobody else has to. * * ── The contract ──────────────────────────────────────────────────────────── * · `stormSel()` returns the page singleton (import identity — both panels * import this same specifier, so they cannot get different stores). * · `setStorm(key)` REFUSES garbage (throws on a key not in STORM_KEYS) — * a selection that can silently hold a storm that doesn't exist is the * `_v1`-suffix 404 one layer up. * · No-op writes are SILENT: setting the value it already holds fires no * listener and returns false. This is load-bearing, not politeness — it is * what lets a subscriber clamp the selection from inside its own change * handler (wind panel: `time > duration` after a storm switch) without * ringing forever. * · `time` is seconds into the storm, ≥ 0, finite. The store deliberately * does NOT know storm durations (it would have to fetch to learn them, and * a synchronous store is the point) — the wind panel, which has the loaded * def, clamps over-long times back into the storm via the same setTime. * · Listener errors are caught and logged, never propagated — one broken * panel must not sever the selection for the others (A's emit() rule). * * DEFAULT: the southerly at t=40 — B's converged pick, and the reasons are * authoring reasons, not courtesy: the wind overlay is the surface that is * LIVE the moment the page opens and needs a storm that shows something (a * funnel authored under the gentle storm looks like it does nothing); SCORE IT * sits behind an explicit click; and site_03 — the yard most likely to be * iterated next — was authored and played on the southerly. t=40 is the second * my S14 receipt demonstrated: align 100%, peak gain ×1.46 on site_02. (My * first cut defaulted the gate-2.3 pin, wildnight@60 — retired when B argued * the authoring case; the pin stays pinned in c.test.js either way, it never * needed to be the default.) c.test.js asserts THIS pair so neither lane's * refactor can silently regrow a private default. */ /** THE storm list, one copy. editor.wind.js re-exports it; keep in step with * data/storms/ (c.test.js pins this list against its own, so drift is red). */ export const STORM_KEYS = [ 'storm_01_gentle', 'storm_02_wildnight', 'storm_02b_icenight', 'storm_03_southerly', 'storm_03b_earlybuster', 'storm_06_soaker', ]; export const DEFAULT_SEL = { storm: 'storm_03_southerly', time: 40 }; /** * A fresh store — exported for c.test.js, which must be able to make throwaway * instances without touching the page singleton. */ export function createStormSel({ storm = DEFAULT_SEL.storm, time = DEFAULT_SEL.time } = {}) { if (!STORM_KEYS.includes(storm)) throw new Error(`[stormsel] unknown storm '${storm}'`); const listeners = new Set(); const state = { storm, time }; const emit = (source) => { // snapshot payload + snapshot listener set: a listener that unsubscribes // (or subscribes) mid-emit must not skew delivery for the rest. `source` // is B's ask: a debug string saying WHO wrote ('wind-panel', 'score', // 'console'…), carried in the payload, never used for logic. const payload = { storm: state.storm, time: state.time, source: source ?? null }; for (const fn of [...listeners]) { try { fn(payload); } catch (err) { console.error('[stormsel] listener threw:', err); } } }; return { get storm() { return state.storm; }, get time() { return state.time; }, /** @returns {boolean} true if the selection changed (false = no-op, silent) */ setStorm(key, source) { if (!STORM_KEYS.includes(key)) { throw new Error(`[stormsel] unknown storm '${key}' — keys: ${STORM_KEYS.join(', ')}`); } if (key === state.storm) return false; state.storm = key; emit(source); return true; }, /** @returns {boolean} true if the selection changed (false = no-op, silent) */ setTime(t, source) { const v = Number(t); if (!Number.isFinite(v) || v < 0) { throw new Error(`[stormsel] time must be a finite second ≥ 0, got ${t}`); } if (v === state.time) return false; state.time = v; emit(source); return true; }, /** @returns {() => void} unsubscribe */ onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; } // --- the page singleton ------------------------------------------------------ // Module-import identity IS the sharing mechanism: editor.wind.js and // editor_score.js import this same file, so stormSel() hands both the same // object and there is no registration step to forget. Exposed on globalThis // as EDITOR_STORM (B's name) for consoles and spelunking. let _sel = null; export function stormSel() { if (!_sel) { _sel = createStormSel(); if (typeof globalThis !== 'undefined') globalThis.EDITOR_STORM = _sel; } return _sel; }