/** * editor_stormsel.js — THE storm + second selection for the editor page. * [Lane C, SPRINT15 gate 2.2 — one storm picker, built with Lane B] * * 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 wildnight at t=60 — the gate-2.3 PIN, on purpose. It is the * storm every Sprint-13 number was argued over (B's reasoning, kept) at the * second the repo already agreed is THE representative moment: the funnel is * worth 33% at the throat there, sitting on the 59–63 s plateau, so the page * opens showing a yard's weather doing something rather than a funnel that * looks dead (my southerly rationale, retired — it argued against a default * nobody shares now). */ /** 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', ]; export const DEFAULT_SEL = { storm: 'storm_02_wildnight', time: 60 }; /** * 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 = () => { // snapshot payload + snapshot listener set: a listener that unsubscribes // (or subscribes) mid-emit must not skew delivery for the rest. const payload = { storm: state.storm, time: state.time }; 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) { 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(); return true; }, /** @returns {boolean} true if the selection changed (false = no-op, silent) */ setTime(t) { 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(); 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 // for console spelunking, same convention as __editorScore / __envelope. let _sel = null; export function stormSel() { if (!_sel) { _sel = createStormSel(); if (typeof globalThis !== 'undefined') globalThis.__stormSel = _sel; } return _sel; }