editor_stormsel.js -> editor_storm.js (B's name); globalThis.EDITOR_STORM
(B's name); setStorm/setTime carry B's source tag in the payload; default
becomes B's storm_03_southerly at my receipted t=40 (the pin stays pinned
in c.test — it never needed to be the default, and the c.test assert now
pins the CONVERGED pair instead so a private default cannot regrow).
Zero-delta second harness: envelope.html week mode, both shipped yards,
all five nights vs my S13 tables — byte-identical everywhere (site_02 x
earlybuster all 11 rows; backyard h1-h3 Pa/hint 266/946-1037/2093-2505/
1844-1933; branch ladder 0.88/0.76; t1 southerly 15.45). Measured on
lane/c @ gate-2.2 commit atop main c182fc1, pre-E-re-bake; zero expected
under the 1.00 ruling, zero measured, nothing to flag.
Selftest 413/0/0; mutations 6 (default drift) and 7 (source dropped)
red-then-green on top of the five in the previous commit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
134 lines
6.3 KiB
JavaScript
134 lines
6.3 KiB
JavaScript
/**
|
||
* 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',
|
||
];
|
||
|
||
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;
|
||
}
|