animader-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. package/README.md +0 -3
@@ -0,0 +1,366 @@
1
+ // The player's state-to-interface decisions, kept portable so the same
2
+ // decisions are unit-tested without a browser. The actual show transport is
3
+ // injected as commands; the browser surface is injected as a view. This module
4
+ // owns no clock, no tick, no audio graph and no renderer call — it maps the
5
+ // actual show state to labels, focus, keyboard intent and the display-change
6
+ // policy, and never steps or forces the show.
7
+ //
8
+ // Ownership: UI state vocabulary, keyboard intent, focus handoff, notes/errors.
9
+
10
+ const ACTIVE_STATES = new Set(['playing', 'paused', 'press-to-resume']);
11
+ const SPACE_STATES = new Set(['ready', 'playing', 'paused', 'press-to-resume']);
12
+ const RESTARTABLE_STATES = new Set(['ended', 'stopped']);
13
+
14
+ /** A host length in the design's one-decimal form when it is a whole second. */
15
+ export function formatSeconds(seconds) {
16
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return String(seconds);
17
+ return Number.isInteger(seconds) ? seconds.toFixed(1) : String(seconds);
18
+ }
19
+
20
+ /**
21
+ * The actual decoded soundtrack length measured against the host end. A long
22
+ * track is cut at the host end; a short one leaves silence. This is a decode
23
+ * measurement, never a claim that music was heard.
24
+ *
25
+ * @param {number} decodedSeconds
26
+ * @param {number} hostSeconds
27
+ * @returns {string|null}
28
+ */
29
+ export function audioDurationNote(decodedSeconds, hostSeconds) {
30
+ if (typeof decodedSeconds !== 'number' || !Number.isFinite(decodedSeconds)
31
+ || typeof hostSeconds !== 'number' || !Number.isFinite(hostSeconds)) {
32
+ return null;
33
+ }
34
+ const difference = decodedSeconds - hostSeconds;
35
+ if (Math.abs(difference) <= 1e-3) return null;
36
+ const host = formatSeconds(hostSeconds);
37
+ if (difference > 0) {
38
+ return `decoded soundtrack is ${decodedSeconds.toFixed(3)} s; cut at the ${host} s host end`;
39
+ }
40
+ return `decoded soundtrack is ${decodedSeconds.toFixed(3)} s; silence follows to the ${host} s host end`;
41
+ }
42
+
43
+ function normalizeError(error) {
44
+ if (error && typeof error === 'object') {
45
+ const diagnostics = Array.isArray(error.diagnostics) ? error.diagnostics : [];
46
+ return {
47
+ code: typeof error.code === 'string' ? error.code : 'error',
48
+ message: typeof error.message === 'string' ? error.message : String(error),
49
+ stage: typeof error.stage === 'string' ? error.stage : '',
50
+ document: typeof error.document === 'string' ? error.document : '',
51
+ diagnostics,
52
+ cause: error.cause,
53
+ };
54
+ }
55
+ return { code: 'error', message: String(error), stage: '', document: '', diagnostics: [] };
56
+ }
57
+
58
+ function isTextEntry(target) {
59
+ if (!target) return false;
60
+ if (target.isContentEditable === true) return true;
61
+ const tag = typeof target.tagName === 'string' ? target.tagName.toLowerCase() : '';
62
+ return tag === 'input' || tag === 'textarea' || tag === 'select';
63
+ }
64
+
65
+ function controlKind(target) {
66
+ return target?.dataset?.playerControl
67
+ ?? target?.getAttribute?.('data-player-control')
68
+ ?? '';
69
+ }
70
+
71
+ function keepsNativeSpace(target) {
72
+ const tag = typeof target?.tagName === 'string' ? target.tagName.toLowerCase() : '';
73
+ return tag === 'summary' || tag === 'a';
74
+ }
75
+
76
+ /**
77
+ * @param {object} options
78
+ * @param {object} options.actions actual show commands: start, pause, resume,
79
+ * stop, restart, fullscreen. Each may return a promise.
80
+ * @param {object} options.view browser surface: apply(ui), activeElement(),
81
+ * focusPrimary(), focusSecondary(), focusError().
82
+ * @param {number|null} [options.seconds] the host show length from config.
83
+ * @param {boolean} [options.fullscreenAvailable]
84
+ */
85
+ export function createPlayerController({ actions, view, seconds = null, fullscreenAvailable = true }) {
86
+ if (!actions || typeof actions !== 'object') throw new TypeError('createPlayerController needs actual show action commands');
87
+ if (!view || typeof view.apply !== 'function' || typeof view.activeElement !== 'function') {
88
+ throw new TypeError('createPlayerController needs a view with apply() and activeElement()');
89
+ }
90
+
91
+ let state = 'loading';
92
+ let loadingStep = 'preparing';
93
+ let currentSeconds = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
94
+ let canFullscreen = Boolean(fullscreenAvailable);
95
+ let error = null;
96
+ let notes = [];
97
+ let displayChanged = false;
98
+ let visibilityHidden = false;
99
+ let hiddenPause = false;
100
+ let ui = null;
101
+
102
+ function errorView() {
103
+ if (!error) {
104
+ const diagnostics = notes.map((note) => `note: ${note}`);
105
+ return { errorVisible: false, errorText: '', diagnostics, diagnosticsOpen: diagnostics.length > 0 };
106
+ }
107
+ const diagnostics = [];
108
+ for (const note of notes) diagnostics.push(`note: ${note}`);
109
+ diagnostics.push(`error: ${error.message}`);
110
+ if (error.stage) diagnostics.push(`stage: ${error.stage}`);
111
+ if (error.document) diagnostics.push(`document: ${error.document}`);
112
+ for (const diagnostic of error.diagnostics) {
113
+ const message = typeof diagnostic === 'string' ? diagnostic : diagnostic?.message;
114
+ if (message) diagnostics.push(`error: ${message}`);
115
+ }
116
+ return { errorVisible: true, errorText: error.message, diagnostics, diagnosticsOpen: true };
117
+ }
118
+
119
+ function derive() {
120
+ let primary = null;
121
+ if (state === 'ready') primary = { label: 'Start', action: 'start' };
122
+ else if (state === 'playing') primary = { label: 'Stop', action: 'stop' };
123
+ else if (state === 'paused' || state === 'press-to-resume') primary = { label: 'Resume', action: 'resume' };
124
+ else if (state === 'ended' || state === 'stopped') primary = { label: 'Restart', action: 'restart' };
125
+
126
+ const secondary = state === 'ready' && canFullscreen
127
+ ? { label: 'Fullscreen', action: 'fullscreen' }
128
+ : null;
129
+
130
+ let status;
131
+ if (state === 'loading') status = `loading — ${loadingStep || 'preparing'}`;
132
+ else if (state === 'ready') {
133
+ status = notes.length === 0
134
+ ? 'ready — press Start'
135
+ : `ready — press Start; ${notes.join('; ')}`;
136
+ } else if (state === 'playing') status = 'playing';
137
+ else if (state === 'paused') status = 'paused — Space to resume';
138
+ else if (state === 'press-to-resume') status = 'playback paused — press to resume';
139
+ else if (state === 'ended') status = currentSeconds === null ? 'ended' : `ended — ${formatSeconds(currentSeconds)} s`;
140
+ else if (state === 'stopped') {
141
+ status = displayChanged ? 'display changed — press Restart' : 'stopped — press Restart';
142
+ } else {
143
+ status = `error — ${error?.stage || 'load'}`;
144
+ }
145
+
146
+ return {
147
+ state,
148
+ status,
149
+ primary,
150
+ secondary,
151
+ ...errorView(),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Apply one view model, then move focus only when the control that had it was
157
+ * replaced or removed. Reading activeElement before apply() is load-bearing:
158
+ * apply() has already hidden the old button by the time focus could move.
159
+ */
160
+ function commit(next) {
161
+ const activeBefore = view.activeElement();
162
+ const previousPrimary = ui?.primary?.label ?? null;
163
+ const hadSecondary = Boolean(ui?.secondary);
164
+ ui = next;
165
+ view.apply(next);
166
+
167
+ if (activeBefore === 'primary') {
168
+ if (next.primary && next.primary.label !== previousPrimary) view.focusPrimary();
169
+ else if (!next.primary && next.errorVisible) view.focusError();
170
+ } else if (activeBefore === 'fullscreen' && hadSecondary && !next.secondary) {
171
+ if (next.primary) view.focusPrimary();
172
+ else if (next.errorVisible) view.focusError();
173
+ }
174
+ }
175
+
176
+ function render() {
177
+ commit(derive());
178
+ }
179
+
180
+ function setLoading(step) {
181
+ if (state !== 'loading') return;
182
+ loadingStep = String(step);
183
+ render();
184
+ }
185
+
186
+ function setSeconds(value) {
187
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return;
188
+ currentSeconds = value;
189
+ render();
190
+ }
191
+
192
+ function setFullscreenAvailable(value) {
193
+ canFullscreen = Boolean(value);
194
+ render();
195
+ }
196
+
197
+ function addNote(note) {
198
+ const text = String(note ?? '').trim();
199
+ if (text === '' || notes.includes(text)) return;
200
+ notes.push(text);
201
+ render();
202
+ }
203
+
204
+ function fail(cause) {
205
+ error = normalizeError(cause);
206
+ state = 'error';
207
+ render();
208
+ return state;
209
+ }
210
+
211
+ function handleState(next, showError = null) {
212
+ state = next;
213
+ if (next === 'error') error = normalizeError(showError ?? error ?? { message: 'the show failed' });
214
+ else error = null;
215
+ if (next === 'ready' || next === 'ended') displayChanged = false;
216
+ render();
217
+ return state;
218
+ }
219
+
220
+ function pressPrimary() {
221
+ if (state === 'ready') return Promise.resolve(actions.start());
222
+ if (state === 'playing') return Promise.resolve(actions.stop());
223
+ if (state === 'paused' || state === 'press-to-resume') return Promise.resolve(actions.resume());
224
+ if (state === 'ended' || state === 'stopped') {
225
+ displayChanged = false;
226
+ return Promise.resolve(actions.restart());
227
+ }
228
+ return Promise.resolve(state);
229
+ }
230
+
231
+ async function pressFullscreen() {
232
+ if (state !== 'ready' || !canFullscreen) return false;
233
+ let accepted = false;
234
+ try {
235
+ accepted = (await actions.fullscreen()) === true;
236
+ } catch {
237
+ accepted = false;
238
+ }
239
+ if (!accepted) addNote('fullscreen unavailable — playing windowed');
240
+ return accepted;
241
+ }
242
+
243
+ function handleFullscreenExit() {
244
+ if (state === 'ready' && ui?.secondary) view.focusSecondary();
245
+ else if (ui?.primary) view.focusPrimary();
246
+ else if (ui?.errorVisible) view.focusError();
247
+ }
248
+
249
+ function spaceAction() {
250
+ if (state === 'ready') return actions.start();
251
+ if (state === 'playing') return actions.pause();
252
+ if (state === 'paused' || state === 'press-to-resume') return actions.resume();
253
+ return undefined;
254
+ }
255
+
256
+ /**
257
+ * Fire a command from a browser event and swallow its rejection here: the
258
+ * transport already moved to its structured error state, which the view
259
+ * renders. Synchronous action calls stay synchronous for tests.
260
+ */
261
+ function dispatch(command) {
262
+ try {
263
+ const result = command();
264
+ if (result && typeof result.catch === 'function') result.catch(() => {});
265
+ return result;
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }
270
+
271
+ function handleKey(event) {
272
+ if (!event || event.defaultPrevented) return false;
273
+ if (isTextEntry(event.target)) return false;
274
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
275
+
276
+ const key = event.key;
277
+ if (key === ' ' || key === 'Spacebar') {
278
+ // A focused secondary button or native disclosure keeps its Space
279
+ // activation. The primary's Space is the transport contract, so it is
280
+ // handled once here.
281
+ if (controlKind(event.target) === 'fullscreen' || keepsNativeSpace(event.target)) return false;
282
+ if (!SPACE_STATES.has(state)) return false;
283
+ event.preventDefault?.();
284
+ dispatch(spaceAction);
285
+ return true;
286
+ }
287
+
288
+ if (key === 'Escape' && ACTIVE_STATES.has(state)) {
289
+ event.preventDefault?.();
290
+ dispatch(() => actions.stop());
291
+ return true;
292
+ }
293
+
294
+ if ((key === 'r' || key === 'R') && RESTARTABLE_STATES.has(state)) {
295
+ event.preventDefault?.();
296
+ displayChanged = false;
297
+ dispatch(() => actions.restart());
298
+ return true;
299
+ }
300
+
301
+ return false;
302
+ }
303
+
304
+ function handleResize() {
305
+ if (displayChanged || !ACTIVE_STATES.has(state)) return false;
306
+ displayChanged = true;
307
+ dispatch(() => actions.stop());
308
+ return true;
309
+ }
310
+
311
+ async function setVisibility(hidden) {
312
+ visibilityHidden = Boolean(hidden);
313
+ if (visibilityHidden) {
314
+ if (state !== 'playing') return;
315
+ hiddenPause = true;
316
+ try {
317
+ await actions.pause();
318
+ } catch {
319
+ return;
320
+ }
321
+ // A visible event may have arrived while the suspend was pending. The
322
+ // frozen clock is the same clock; resuming here never rebases it.
323
+ if (!visibilityHidden && hiddenPause && state === 'paused') {
324
+ hiddenPause = false;
325
+ try {
326
+ await actions.resume();
327
+ } catch {
328
+ // A denied resume lands in the show's press-to-resume state.
329
+ }
330
+ }
331
+ return;
332
+ }
333
+
334
+ if (!hiddenPause) return;
335
+ if (state === 'playing') return; // the pending hide will resume after suspend settles
336
+ hiddenPause = false;
337
+ if (state === 'paused') {
338
+ try {
339
+ await actions.resume();
340
+ } catch {
341
+ // A denied resume lands in the show's press-to-resume state.
342
+ }
343
+ }
344
+ }
345
+
346
+ render();
347
+
348
+ return {
349
+ get state() { return state; },
350
+ get ui() { return ui; },
351
+ get notes() { return [...notes]; },
352
+ get error() { return error; },
353
+ setLoading,
354
+ setSeconds,
355
+ setFullscreenAvailable,
356
+ addNote,
357
+ fail,
358
+ handleState,
359
+ handleKey,
360
+ handleResize,
361
+ handleFullscreenExit,
362
+ pressPrimary,
363
+ pressFullscreen,
364
+ setVisibility,
365
+ };
366
+ }
@@ -0,0 +1,306 @@
1
+ // The development-only channel client (plan section 5). It is imported by
2
+ // the dev entry (page.mjs) alone; the release entry, its bundle and the
3
+ // exported file set never reach this module.
4
+ //
5
+ // The protocol is deliberately small and honest: on a successful build the
6
+ // server broadcasts `reload` with the URL of the new generation, and the
7
+ // page does a full location.replace onto it — playback stops, the page
8
+ // boots into READY and requires Start again. No state is preserved and no
9
+ // hot reload is claimed or attempted. On a failed build the server
10
+ // broadcasts `stale` with the build error: the page keeps the last
11
+ // successful view and shows a clear indication; recovery is the next
12
+ // successful edit's reload. A (re)connecting client is greeted with the
13
+ // CURRENT generation, so a channel that reconnects after a reload lands on
14
+ // whatever is served now.
15
+ //
16
+ // The client uses the browser's own EventSource reconnection; no timers,
17
+ // no workers, no second clock.
18
+ //
19
+ // The stale indication itself is built here too: the console warning below
20
+ // is for the person debugging in devtools, and `showStaleNotice` is for the
21
+ // person looking at the page. It overlays the viewport top out of flow —
22
+ // the stylesheet owns that contract (`#dev-stale` in player.css) — so it
23
+ // can never reflow the page: an in-flow notice once pushed the primary
24
+ // control below the fold on a 1280x800 laptop, exactly when the person
25
+ // needed to press it. The notice is a native <details> collapsed at rest:
26
+ // its summary carries the failure's lead line (the code plus the message's
27
+ // own identifying token), the recovery sentence and a line that says the
28
+ // full failure is one press away, so the reassurance is visible at rest
29
+ // instead of below a scroll; below the covered range the summary swaps the
30
+ // sentence for a compact phrase that shares its line with the affordance,
31
+ // because the cap at 320px holds three lines and the lead alone takes two
32
+ // (round 3, finding 2). The summary is reachable and operable by keyboard
33
+ // alone on every engine without scripting, and it is the notice's FIRST
34
+ // stop: the element that scrolls is the opened body (`.stale-body`,
35
+ // overflow-y: auto in the stylesheet) and it is focusable by markup —
36
+ // tabindex="0" with the house :focus-visible ring — so once the disclosure
37
+ // is open the capped body is scrollable by keyboard (ArrowDown/PageDown)
38
+ // and the tab order is summary → body → out. The notice element itself
39
+ // never scrolls and never carries a tabindex: round 2 made the notice the
40
+ // scroller, which measured (2026-10-29) as a tab stop BEFORE its own
41
+ // summary while Tab forward from the summary left the notice — the two
42
+ // stops and the dead end this round removes (round 3, finding 1). When the
43
+ // opened body is taller than its box, the builder measures it and the
44
+ // stylesheet pins a cue to the scroller's bottom edge, so an at-rest
45
+ // partial line never reads as the end of the message (round 3, finding 3).
46
+ // The stylesheet caps the notice's height at the canvas's central third —
47
+ // geometry measured off the stage, not a viewport breakpoint — and scrolls
48
+ // anything taller inside the body, so the delivered text is never
49
+ // truncated or hidden: the expanded body carries the whole failure
50
+ // verbatim. The element enters the DOM before #status as a live region,
51
+ // and repeat deliveries update the one notice instead of stacking notices.
52
+ // The expanded/collapsed state rides on the native pair: the engine tree
53
+ // exposes the summary as a disclosure triangle with its expanded state on
54
+ // exactly this markup, so the builder manages no ARIA of its own.
55
+
56
+ /**
57
+ * @param {Object} options
58
+ * @param {number|null} options.generation the generation this page booted on
59
+ * @param {(error: {code?: string|null, message?: string, remedy?: string|null}) => void} [options.onStale]
60
+ * the dev entry's stale-build indication; the last successful view stays
61
+ * @param {typeof EventSource} [options.eventSourceClass] injectable for tests
62
+ * @returns {{close(): void}}
63
+ */
64
+ export function connectDevChannel({ generation, onStale = () => {}, eventSourceClass = globalThis.EventSource }) {
65
+ if (typeof eventSourceClass !== 'function') {
66
+ return { close() {} };
67
+ }
68
+
69
+ const events = new eventSourceClass('/dev');
70
+
71
+ events.addEventListener('reload', (event) => {
72
+ let detail = null;
73
+ try {
74
+ detail = JSON.parse(event.data);
75
+ } catch {
76
+ return;
77
+ }
78
+ if (detail && typeof detail.url === 'string' && detail.url.startsWith('/')) {
79
+ // Stop playback and reload onto the new generation: a fresh boot, a
80
+ // fresh READY, Start pressed again by the person editing.
81
+ window.location.replace(detail.url);
82
+ }
83
+ });
84
+
85
+ events.addEventListener('hello', (event) => {
86
+ let detail = null;
87
+ try {
88
+ detail = JSON.parse(event.data);
89
+ } catch {
90
+ return;
91
+ }
92
+ // A reconnected channel greets us with the current generation; if the
93
+ // server has moved on (including a restarted session), follow it.
94
+ if (detail
95
+ && detail.generation !== null
96
+ && detail.generation !== generation
97
+ && typeof detail.url === 'string'
98
+ && detail.url.startsWith('/')) {
99
+ window.location.replace(detail.url);
100
+ }
101
+ });
102
+
103
+ // A failed dev build must also reach the console — where a developer
104
+ // looks when the page is the thing that broke — exactly once per failed
105
+ // build, naming the code and the message. It is a warning, not an error:
106
+ // the page keeps working on the last successful generation. The key is
107
+ // the failure itself, so the channel redelivering the same failed build
108
+ // stays silent while a new build failing another way is reported. The
109
+ // release entry never imports this module, so a release page can neither
110
+ // receive a stale frame nor leave a console trace.
111
+ let warnedFailure = null;
112
+ events.addEventListener('stale', (event) => {
113
+ let detail = null;
114
+ try {
115
+ detail = JSON.parse(event.data);
116
+ } catch {
117
+ detail = null;
118
+ }
119
+ const error = detail?.error ?? { code: null, message: 'the last build failed; showing the last successful generation', remedy: null };
120
+ onStale(error);
121
+ const key = `${error.code ?? ''}\u0000${error.message ?? ''}`;
122
+ if (key !== warnedFailure) {
123
+ warnedFailure = key;
124
+ console.warn(`animader: the dev build failed (${error.code ?? 'unknown'}): ${error.message ?? ''}`);
125
+ }
126
+ });
127
+
128
+ return {
129
+ close() {
130
+ events.close();
131
+ },
132
+ };
133
+ }
134
+
135
+ // The recovery sentence is the channel's advice, never part of the build
136
+ // error. A message that already spans lines gets the advice on its own
137
+ // line, so the diagnosis cannot read as one run-on with the advice (the
138
+ // collapsed `…host registry 1 error, 0 warnings — showing…`); a
139
+ // single-line message keeps the inline em-dash join.
140
+ const RECOVERY = 'showing the last successful generation; it recovers on the next successful edit';
141
+
142
+ // The compact recovery phrase for very narrow viewports (round 3, finding
143
+ // 2). Geometry, not taste: the collapsed summary's cap is a third of the
144
+ // canvas, and at 320px that cap (70px) holds exactly three 14.4px lines
145
+ // while the lead line alone — code plus token — takes two of them, so the
146
+ // reassurance and the affordance must share the third. This phrase plus
147
+ // `the full failure …` is 36 characters against the 38-character line the
148
+ // guard's conservative model holds at 320px, so both fit on that one line;
149
+ // the stylesheet swaps it in below 359px, the last uncovered pixel under
150
+ // the range the full sentence is proven for. It keeps the sentence's first
151
+ // half (what is showing: the last good view) and hands the "it recovers"
152
+ // half to the opened body, which always carries the full sentence.
153
+ const RECOVERY_COMPACT = 'last good view;';
154
+
155
+ // The identifying name: the message's own backquoted token with at most the
156
+ // ONE word that introduces it ("target `x`"). The collapsed summary paints
157
+ // the lead first and the person at rest reads it before anything else, so
158
+ // the notice must open by naming what failed — derived from whatever
159
+ // message arrives, never written for one build error. The word budget is
160
+ // geometry, not taste: the lead must stay one line at 360px, where the
161
+ // stylesheet's cap is 77.5px and the modelled line holds ~44 characters.
162
+ // With up to three introducing words the measured lead was 48 characters —
163
+ // two lines at phone width — which is exactly what pushed the collapsed
164
+ // notice over its cap at 390px and below (round 2, finding 3); the dropped
165
+ // words stay in the expanded body verbatim.
166
+ const NAMED_FAILURE = /(?:[A-Za-z][\w-]*[ \t]+)?`[^`\n]+`/;
167
+
168
+ function leadLine(error) {
169
+ const named = typeof error?.message === 'string' ? error.message.match(NAMED_FAILURE)?.[0] ?? null : null;
170
+ if (named === null) return null;
171
+ return error?.code ? `${error.code} — ${named}` : named;
172
+ }
173
+
174
+ /**
175
+ * The text one stale notice carries: a derived lead line — the code plus
176
+ * the message's identifying token — when the message names one, then the
177
+ * full failure, then the recovery sentence, inline after a single-line
178
+ * message and on its own line once the message already spans lines. The
179
+ * code is named exactly once: with a lead line the diagnosis follows
180
+ * unprefixed, without one the code prefixes the message as before.
181
+ *
182
+ * @param {{code?: string|null, message?: string, remedy?: string|null}} error
183
+ * the failed build, as the stale frame carried it
184
+ * @returns {string} the notice's whole text
185
+ */
186
+ function staleNoticeText(error) {
187
+ const message = error?.message ?? 'the last build failed';
188
+ const lead = leadLine(error);
189
+ const prefix = lead === null && error?.code ? `${error.code}: ` : '';
190
+ const separator = message.includes('\n') ? '\n— ' : ' — ';
191
+ const body = `${prefix}${message}${separator}${RECOVERY}`;
192
+ return lead === null ? body : `${lead}\n${body}`;
193
+ }
194
+
195
+ /**
196
+ * The notice's collapsed lead: the derived lead line when the message names
197
+ * a token, else the failure's code, else the fallback's own opening. Always
198
+ * short — the summary must stay inside the stylesheet's geometric cap at
199
+ * rest — while the full, untruncated failure stays one press away in the
200
+ * body.
201
+ *
202
+ * @param {{code?: string|null, message?: string, remedy?: string|null}} error
203
+ * the failed build, as the stale frame carried it
204
+ * @returns {string} the summary's first line
205
+ */
206
+ function summaryLead(error) {
207
+ const named = leadLine(error);
208
+ if (named !== null) return named;
209
+ return error?.code ?? 'the last build failed';
210
+ }
211
+
212
+ // The summary's second, third and fourth lines: the recovery guidance,
213
+ // visible at rest (the review found it buried below the old cap's scroll),
214
+ // the compact phrase that replaces it below the covered range, and the
215
+ // affordance that says more text exists and opens it. Native <details>
216
+ // semantics make the whole summary the control: keyboard-operable on every
217
+ // engine, with no scripting and no tabindex of its own — the opened body
218
+ // (the scroller) is the element that carries the markup tab stop, after
219
+ // the summary in reading order.
220
+ const AFFORDANCE = 'the full failure …';
221
+
222
+ // Round 3, finding 3: the body is the scroller, and a scroller at rest
223
+ // shows a partial last line. When the opened body is taller than its box,
224
+ // it wears the class the stylesheet's sticky cue rides on. Measuring is
225
+ // layout, so it only runs where the element has boxes — a real DOM after
226
+ // insertion — and it runs twice per lifecycle: on every delivery, and on
227
+ // the disclosure's own toggle event, because deliveries arrive while the
228
+ // notice is closed, when the body has no boxes and nothing to measure.
229
+ // A closed body has no client box (clientHeight 0) while its content still
230
+ // reports a scrollHeight, so the closed state must read as "no cue", never
231
+ // as "overflowing". No timers, no observers: the two moments the cue can
232
+ // change are exactly the two moments the text or the state changes.
233
+ function cueBeyond(body) {
234
+ if (typeof body.scrollHeight !== 'number' || typeof body.clientHeight !== 'number') return;
235
+ const beyond = body.clientHeight > 0 && body.scrollHeight - body.clientHeight > 1;
236
+ if (typeof body.classList?.toggle === 'function') body.classList.toggle('scrolls-more', beyond);
237
+ }
238
+
239
+ /**
240
+ * The page-side stale indication: the notice the person looking at the page
241
+ * reads when the last dev build failed. Created once, then updated in place
242
+ * by every delivery. The element is created unstyled — `#dev-stale` in
243
+ * player.css owns the out-of-flow layout contract — and nothing here may
244
+ * reintroduce flow layout or a style attribute, so the notice can never
245
+ * move the controls. It is a native <details> collapsed at rest: the
246
+ * summary carries the derived lead line, the recovery sentence (the
247
+ * compact phrase below the covered range) and the affordance as block
248
+ * lines, so at rest a person identifies the failure, knows the last good
249
+ * generation is showing and knows how to open the rest. The expanded body
250
+ * carries `staleNoticeText` — the lead, the code-less message and the full
251
+ * recovery sentence, wrapping rather than truncating — and it is the
252
+ * scroller: the cap's pressure lands on it (the engine's ::details-content
253
+ * row carries min-height: 0 and the body fills it under max-height: 100%),
254
+ * it is focusable by markup with the house ring, and when it overflows it
255
+ * wears the cue class, so the cap never trims the delivered text and a
256
+ * partial line never reads as the end.
257
+ *
258
+ * @param {Document} document the page document
259
+ * @param {{code?: string|null, message?: string, remedy?: string|null}} error
260
+ * the failed build, as the stale frame carried it
261
+ */
262
+ export function showStaleNotice(document, error) {
263
+ let banner = document.getElementById('dev-stale');
264
+ if (banner === null) {
265
+ banner = document.createElement('details');
266
+ banner.id = 'dev-stale';
267
+ banner.setAttribute('role', 'status');
268
+ // The notice element itself carries NO tabindex and never scrolls: the
269
+ // element that scrolls is the opened body (overflow-y: auto on
270
+ // .stale-body), and that body is the focusable one — tabindex as
271
+ // markup, so ArrowDown/PageDown move the capped text on every engine,
272
+ // as the second stop, after the summary, in reading order. Round 2
273
+ // had put the tabindex and the scrolling on the notice itself, which
274
+ // measured (2026-10-29) as a tab stop BEFORE its own summary, while
275
+ // Tab forward from the summary left the notice with the body
276
+ // unreachable but by Shift+Tab — the round-3 finding 1 defect. The
277
+ // closed disclosure does not render the body, so it is not a stop
278
+ // until it can be read. Nothing here moves focus: the attribute is
279
+ // the whole mechanism.
280
+ const summary = document.createElement('summary');
281
+ for (const className of ['stale-lead', 'stale-recovery', 'stale-recovery-compact', 'stale-more']) {
282
+ const line = document.createElement('span');
283
+ line.className = className;
284
+ summary.appendChild(line);
285
+ }
286
+ banner.appendChild(summary);
287
+ const body = document.createElement('p');
288
+ body.className = 'stale-body';
289
+ body.setAttribute('tabindex', '0');
290
+ banner.appendChild(body);
291
+ const status = document.getElementById('status');
292
+ status?.parentNode?.insertBefore(banner, status);
293
+ // The cue must appear when the disclosure opens, not only when a
294
+ // delivery happens to arrive while it is open: the native toggle event
295
+ // is the moment the body gains boxes and can be measured.
296
+ banner.addEventListener?.('toggle', () => cueBeyond(body));
297
+ }
298
+ const [summary, body] = banner.children;
299
+ const [lead, recovery, compact, more] = summary.children;
300
+ lead.textContent = summaryLead(error);
301
+ recovery.textContent = `— ${RECOVERY}`;
302
+ compact.textContent = `— ${RECOVERY_COMPACT} `;
303
+ more.textContent = AFFORDANCE;
304
+ body.textContent = staleNoticeText(error);
305
+ cueBeyond(body);
306
+ }