animader-cli 0.0.0-stage → 0.1.0
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.
- package/LICENSE +121 -0
- package/adapters/build-node.mjs +261 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +601 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +211 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +68 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +129 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- 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
|
+
}
|