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,35 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>Animader demo</title>
8
+ <link rel="icon" href="data:,">
9
+ <link rel="stylesheet" href="/player/player.css">
10
+ </head>
11
+ <body>
12
+ <main>
13
+ <h1 class="visually-hidden">Animader demo</h1>
14
+ <div id="performance">
15
+ <div id="stage" class="stage">
16
+ <canvas id="canvas" width="1280" height="720" role="img" aria-label="Live performance canvas"></canvas>
17
+ </div>
18
+ <p id="status" class="status" role="status" aria-live="polite">loading</p>
19
+ <div id="controls" class="panel" role="group" aria-label="Playback controls">
20
+ <button id="primary" type="button" data-player-control="primary" hidden>Start</button>
21
+ <button id="fullscreen" type="button" class="secondary" data-player-control="fullscreen" hidden>Fullscreen</button>
22
+ </div>
23
+ <div id="error" class="banner" role="alert" tabindex="-1" hidden>
24
+ <span class="marker">error:</span> <span id="error-message"></span>
25
+ </div>
26
+ </div>
27
+ <details id="diagnostics" class="diagnostics" hidden>
28
+ <summary>diagnostics</summary>
29
+ <pre id="diagnostics-text"></pre>
30
+ </details>
31
+ <noscript>This demo needs JavaScript to load the prepared viewer and its audio clock.</noscript>
32
+ </main>
33
+ <script type="module" src="/player/page.mjs"></script>
34
+ </body>
35
+ </html>
@@ -0,0 +1,134 @@
1
+ // The dev entry: the original server-mounted UI and URLs, injected into the
2
+ // shared page implementation. The release entry replaces this profile with
3
+ // static imports and relative export assets; it does not import this file.
4
+ //
5
+ // Generation pinning (plan section 5): the page boots either on the
6
+ // generation the reload URL carries (?gen=N) or, on a bare load, learns the
7
+ // current generation from /config.json before any show asset is requested —
8
+ // then every project-derived fetch (config, payload, timeline, soundtrack)
9
+ // is pinned to that one immutable /gen/<id>/ prefix, so a rebuild under a
10
+ // live page can never mix a new timeline with an old payload or audio. The
11
+ // development-only channel in devchannel.mjs reloads the page onto a new
12
+ // generation when the server says so.
13
+
14
+ import { boot, reportBootFailure } from './boot.mjs';
15
+ import { connectDevChannel, showStaleNotice } from './devchannel.mjs';
16
+
17
+ function pinnedGenerationFromUrl() {
18
+ const param = new URLSearchParams(window.location.search).get('gen');
19
+ if (param !== null && /^[1-9][0-9]*$/.test(param)) return Number(param);
20
+ return null;
21
+ }
22
+
23
+ async function currentGeneration() {
24
+ const pinned = pinnedGenerationFromUrl();
25
+ if (pinned !== null) return pinned;
26
+ try {
27
+ const response = await fetch('/config.json', { cache: 'no-store' });
28
+ if (!response.ok) return null;
29
+ const config = await response.json();
30
+ if (Number.isInteger(config?.generation) && config.generation > 0) return config.generation;
31
+ return null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const generation = await currentGeneration();
38
+ const prefix = generation === null ? '' : `/gen/${generation}`;
39
+
40
+ const documents = {
41
+ runtimeModule: '/runtime/pngine.mjs',
42
+ config: `${prefix}/config.json`,
43
+ payload: 'out/show.png',
44
+ manifest: 'manifest.json',
45
+ };
46
+
47
+ async function importModule(path) {
48
+ try {
49
+ return await import(path);
50
+ } catch (cause) {
51
+ const error = new Error(`${path} could not be loaded: ${cause?.message ?? cause}`);
52
+ error.code = 'module_failed';
53
+ error.stage = 'modules';
54
+ error.document = path;
55
+ error.cause = cause;
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ const devProfile = {
61
+ name: 'dev',
62
+ remedy: 're-run the one-time prepare (node demo/prepare.mjs) to re-vendor it from the actual build',
63
+ documents,
64
+ loadController: () => importModule('./controller.mjs'),
65
+ loadModules: async () => {
66
+ const [
67
+ scoreModule,
68
+ showModule,
69
+ soundtrackData,
70
+ webaudioModule,
71
+ pngineModule,
72
+ pacerModule,
73
+ routeModule,
74
+ pieceModule,
75
+ runModule,
76
+ ] = await Promise.all([
77
+ importModule('/app/lib/score.mjs'),
78
+ importModule('/app/lib/show.mjs'),
79
+ importModule('/app/lib/soundtrack.mjs'),
80
+ importModule('/app/adapters/webaudio.mjs'),
81
+ importModule('/runtime/pngine.mjs'),
82
+ importModule('/runtime/pacer/index.js'),
83
+ importModule('/runtime/pacer/route.js'),
84
+ importModule('/runtime/animader/piece.js'),
85
+ importModule('/examples/hello/player/run.js'),
86
+ ]);
87
+ return {
88
+ score: scoreModule,
89
+ show: showModule,
90
+ soundtrack: soundtrackData,
91
+ webaudio: webaudioModule,
92
+ pngine: pngineModule,
93
+ pacer: pacerModule,
94
+ route: routeModule,
95
+ piece: pieceModule,
96
+ run: runModule,
97
+ };
98
+ },
99
+ configUrl: `${prefix}/config.json`,
100
+ manifestUrl: null,
101
+ configManifestPath: null,
102
+ assets: [
103
+ { key: 'payload', url: `${prefix}/project/out/show.png`, kind: 'bytes' },
104
+ { key: 'timeline', url: `${prefix}/project/timeline.piece.sjon`, kind: 'text' },
105
+ { key: 'provenance', url: '/runtime/provenance.json', kind: 'json' },
106
+ { key: 'pacerWasm', url: '/runtime/pacer/pacer.wasm', kind: 'bytes' },
107
+ { key: 'pieceWasm', url: '/runtime/animader/animader.wasm', kind: 'bytes' },
108
+ ],
109
+ // The audio URL comes from the validated config selection, never from a
110
+ // hardcoded name: the loader appends this asset after config validation.
111
+ soundtrackAsset: (name) => ({ key: 'soundtrack', url: `${prefix}/project/${name}`, kind: 'bytes' }),
112
+ loadScore: ({ modules, assets, reflection }) => modules.score.loadShow({
113
+ document: {
114
+ name: 'timeline.piece.sjon',
115
+ text: assets.timeline.text,
116
+ },
117
+ dep: {
118
+ pieceWasm: assets.pieceWasm.bytes,
119
+ pacerWasm: assets.pacerWasm.bytes,
120
+ reflection,
121
+ },
122
+ factories: {
123
+ instantiatePieceHost: modules.piece.PieceHost.instantiate,
124
+ loadPacer: modules.pacer.Pacer.load,
125
+ },
126
+ }),
127
+ };
128
+
129
+ // The stale notice is built by the dev channel module; the stylesheet owns
130
+ // its out-of-flow layout contract, so it overlays without reflowing the
131
+ // page and the primary control stays reachable.
132
+ connectDevChannel({ generation, onStale: (error) => showStaleNotice(document, error) });
133
+
134
+ boot(devProfile).catch(reportBootFailure);
@@ -0,0 +1,393 @@
1
+ /* The demo player extends the incumbent hello tokens; the canvas is the only
2
+ thing that scales. The artboard's CSS content box stays 1280x720 and pngine
3
+ owns its backing store at the actual device DPR. There is deliberately no
4
+ canvas media query: the wrapper letterboxes by transform, so a narrow window
5
+ never changes the canvas content box or fires a mid-run reallocation. */
6
+
7
+ :root {
8
+ color-scheme: dark;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ background: #0a0608;
18
+ color: #d8cfc4;
19
+ font: 14px/1.4 system-ui, sans-serif;
20
+ }
21
+
22
+ .visually-hidden {
23
+ position: absolute;
24
+ width: 1px;
25
+ height: 1px;
26
+ padding: 0;
27
+ margin: -1px;
28
+ overflow: hidden;
29
+ clip: rect(0 0 0 0);
30
+ white-space: nowrap;
31
+ border: 0;
32
+ }
33
+
34
+ main {
35
+ display: grid;
36
+ grid-template-columns: minmax(0, 1fr);
37
+ gap: 12px;
38
+ width: 100%;
39
+ max-width: 1312px;
40
+ margin: 0 auto;
41
+ padding: 16px;
42
+ }
43
+
44
+ #performance {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 12px;
48
+ }
49
+
50
+ .stage {
51
+ position: relative;
52
+ width: 100%;
53
+ max-width: 1280px;
54
+ aspect-ratio: 16 / 9;
55
+ overflow: hidden;
56
+ background: #000;
57
+ border: 1px solid #2c2026;
58
+ }
59
+
60
+ #canvas {
61
+ position: absolute;
62
+ top: 0;
63
+ left: 0;
64
+ display: block;
65
+ background: #000;
66
+ transform-origin: 0 0;
67
+ }
68
+
69
+ #performance:fullscreen {
70
+ background: #0a0608;
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 12px;
74
+ padding: 16px;
75
+ }
76
+
77
+ #performance:fullscreen .stage {
78
+ flex: 1;
79
+ min-height: 0;
80
+ max-width: none;
81
+ aspect-ratio: auto;
82
+ }
83
+
84
+ .status {
85
+ margin: 0;
86
+ color: #8f857a;
87
+ font-variant-numeric: tabular-nums;
88
+ overflow-wrap: anywhere;
89
+ }
90
+
91
+ .panel {
92
+ display: flex;
93
+ gap: 16px;
94
+ align-items: center;
95
+ flex-wrap: wrap;
96
+ }
97
+
98
+ button {
99
+ background: #241a20;
100
+ color: #d8cfc4;
101
+ border: 1px solid #3a2a33;
102
+ padding: 4px 10px;
103
+ font: inherit;
104
+ }
105
+
106
+ button:hover {
107
+ border-color: #8f857a;
108
+ }
109
+
110
+ button:focus-visible {
111
+ outline: 2px solid #f0b27a;
112
+ outline-offset: 2px;
113
+ }
114
+
115
+ #primary {
116
+ border-color: #f0b27a;
117
+ color: #f0b27a;
118
+ }
119
+
120
+ .secondary {
121
+ background: transparent;
122
+ color: #8f857a;
123
+ border-color: #2c2026;
124
+ }
125
+
126
+ .banner {
127
+ padding: 8px 10px;
128
+ background: #140d11;
129
+ border: 1px solid #f0b27a;
130
+ color: #d8cfc4;
131
+ font: 12px/1.4 ui-monospace, monospace;
132
+ overflow-wrap: anywhere;
133
+ }
134
+
135
+ .banner:focus-visible {
136
+ outline: 2px solid #f0b27a;
137
+ outline-offset: 2px;
138
+ }
139
+
140
+ .marker {
141
+ color: #f0b27a;
142
+ }
143
+
144
+ /* The dev-only stale notice overlays the viewport top out of flow: a fixed
145
+ element is removed from normal flow, so the stage, the status line and
146
+ the controls keep the exact offsets they have without the notice — an
147
+ in-flow notice once pushed the primary control below the fold on a
148
+ 1280x800 laptop. The message wraps instead of truncating or overflowing.
149
+ The release page has no dev channel and never renders the element. */
150
+
151
+ /* The notice's height cap is geometry, not a device class: the notice may
152
+ grow down to the canvas's central third — the middle band of the picture,
153
+ where a subject sits — and no further, at every width. The stage is
154
+ width: 100% / max-width: 1280px / aspect-ratio: 16/9, so a third of the
155
+ canvas is min(100vw, 1280px) * 9/16 / 3 = min(100vw, 1280px) * 0.1875
156
+ tall. Two measured corrections make that expression honest (Chromium
157
+ 153, viewport height 800, the 430–1280 sweep of 2026-10-28 recorded in
158
+ .agent/control/stale-cap-verify.md; canvas.top measured 17 at every
159
+ width — 16px main padding + 1px stage border-top):
160
+
161
+ - the canvas starts 17px down, which the expression does not know;
162
+ - main's 32px horizontal padding and the stage's 2px borders make the
163
+ real canvas height (min(100vw, 1312px) - 34px) * 0.5625, so the
164
+ expression over-counts a third by (34 * 0.5625) / 3 = 6.375px.
165
+
166
+ The exact allowance is therefore 17 - 6.375 = 10.625px; the largest
167
+ whole pixel under it, 10px, leaves the cap 0.19px (431 and 767px),
168
+ 0.33–0.5px (the other measured widths) and structurally 0.625px below
169
+ the central third at every width up to 1280px. No max-width media query
170
+ owns this: the 767px boundary capped tablet presentations that already
171
+ cleared the third (at 640px the natural six-line notice cleared it by
172
+ 11.6px) and answered a hypothetical longer message instead of the
173
+ measured one — diagnostic length is unbounded, so only the relationship
174
+ between the notice and the canvas bounds it.
175
+
176
+ The cap bounds the notice as a GRID of two rows: the summary's auto row
177
+ and the opened content's minmax(0, 1fr) row. The 1fr track is what makes
178
+ the cap reach the text: a fr row resolves against the clamped container
179
+ while an auto row (or a flex basis) cannot, and the engine's
180
+ ::details-content wrapper — the box that actually hosts the body inside a
181
+ <details> — is the grid item, so it carries min-height: 0 and the body
182
+ fills it under max-height: 100% and scrolls there. The notice itself is
183
+ never the scroller, because a scrollable details measures (Chromium,
184
+ 2026-10-29) as a tab stop BEFORE its own summary while Tab forward from
185
+ the summary left the notice; the tab order is now summary → (open) body
186
+ → out (round 3, finding 1). The one media query that touches the notice
187
+ swaps text only — below the covered range the summary's recovery
188
+ sentence becomes a compact phrase that runs inline with the affordance —
189
+ never geometry; that is a sentence-length decision, not the rejected
190
+ width cap returning.
191
+
192
+ The collapsed summary (block lines: lead, recovery, affordance — see
193
+ devchannel.mjs) keeps its own tighter line-height (1.2 → 14.4px a line)
194
+ and the lead keeps to one introducing word. At 360px and wider it is
195
+ four lines: 4 × 14.4px + 18px chrome = 75.6px against the 77.5px cap at
196
+ 360px, 83.125px at 390px, 85.9375px at 405px — it fits at rest at
197
+ 360/375/390/405 and everywhere wider. Below 360px the compact phrase
198
+ takes over: at 320px the cap (70px) holds exactly three lines, the lead
199
+ alone takes two of them, and the compact phrase plus the affordance
200
+ share the third (36 model characters against the 38-character line), so
201
+ the collapsed summary is 3 × 14.4px + 18px = 61.2px — it fits at rest
202
+ down to 320px and (by the same arithmetic) to roughly 299px. Below that
203
+ floor not even the compact fits; the notice cannot scroll while
204
+ collapsed any more, so overflow: hidden clips at the cap — the geometry
205
+ holds, the sub-floor text is the recorded gap, and the proof, per width,
206
+ is the arithmetic guard in npm/cli/test/stale-cap.test.js.
207
+ 100vw includes a classic scrollbar where one exists, which can move the
208
+ cap a couple of px past the true third there; only the opened, scrolling
209
+ state can ever reach the cap. Fullscreen does not follow this formula:
210
+ #performance:fullscreen .stage is flex: 1 with aspect-ratio: auto, so
211
+ the stage stops being 16:9 and no width-derived expression describes its
212
+ height. The cap keeps its >=1280px value (250px) there — conservative on
213
+ a tall stage, but on a short one it can sit past the stage's true third;
214
+ the collapsed summary is what shows at rest either way. */
215
+ #dev-stale {
216
+ position: fixed;
217
+ top: 0;
218
+ left: 0;
219
+ right: 0;
220
+ z-index: 1;
221
+ margin: 0;
222
+ padding: 8px 12px;
223
+ border: 1px solid #3a2a33;
224
+ background: #140d11;
225
+ color: #f0b27a;
226
+ font: 12px/1.4 ui-monospace, monospace;
227
+ overflow-wrap: anywhere;
228
+ white-space: pre-line;
229
+ max-height: calc(min(100vw, 1280px) * 0.1875 + 10px);
230
+ display: grid;
231
+ grid-template-rows: auto minmax(0, 1fr);
232
+ overflow: hidden;
233
+ }
234
+
235
+ /* The collapsed state is a native <details>: the summary is the keyboard
236
+ affordance on every engine without scripting — the review that reshaped
237
+ this found the previous capped scroll box reachable only where the
238
+ engine happens to make scroll containers tab stops. The UA marker is
239
+ dropped in favour of the summary's last line, which says in words that
240
+ the full failure is one press away (the ::-webkit-details-marker rule
241
+ covers engines whose summary ignores list-style). The focus ring is the
242
+ house 2px amber :focus-visible treatment that buttons, .banner and the
243
+ diagnostics summary already carry, not the UA default.
244
+ The summary's own line-height is deliberately tighter than the notice's
245
+ 1.4: at 360px and wider the collapsed state is four lines (one-word
246
+ lead, twice-wrapped recovery sentence, affordance) and must fit the cap
247
+ at rest — 4 × 14.4px + 18px chrome = 75.6px against the 77.5px cap; at
248
+ 1.4 the same four lines are 85.2px and over the 83.125px cap at 390px,
249
+ which is the defect round 2 fixed. The expanded body keeps the notice's
250
+ 1.4 rhythm. The summary's grid row is auto, so the cap's pressure lands
251
+ on the content row — the element that can scroll. */
252
+ #dev-stale summary {
253
+ display: block;
254
+ list-style: none;
255
+ cursor: pointer;
256
+ line-height: 1.2;
257
+ }
258
+
259
+ #dev-stale summary::-webkit-details-marker {
260
+ display: none;
261
+ }
262
+
263
+ #dev-stale summary:focus-visible {
264
+ outline: 2px solid #f0b27a;
265
+ outline-offset: 2px;
266
+ }
267
+
268
+ /* Round 3, finding 2 — the compact recovery phrase, the one place a width
269
+ query is allowed to touch the notice, because it chooses a sentence, not
270
+ a box. At 360px and wider the full reassurance fits (four summary lines,
271
+ 75.6px, against caps of 77.5px at 360 and up). Below that the cap
272
+ shrinks 0.1875px per pixel until the full sentence no longer fits, so
273
+ the summary swaps it for a phrase that runs inline with the affordance
274
+ on one line — at 320px the cap (70px) holds exactly three 14.4px lines,
275
+ the lead alone takes two, and "— last good view; the full failure …"
276
+ (36 model characters against a 38-character line) carries the
277
+ reassurance AND the affordance on the third. The full sentence stays in
278
+ the opened body verbatim, and the display:none half is out of the
279
+ accessibility tree, so the summary's name matches what is painted.
280
+ The swap boundary is 359px — the widest pixel the full-sentence proof
281
+ (360 and up) does not cover; the compact phrase is proven down to 320px
282
+ and (by the guard's arithmetic) to roughly 299px. */
283
+ #dev-stale .stale-recovery-compact {
284
+ display: none;
285
+ }
286
+
287
+ @media (max-width: 359px) {
288
+ #dev-stale .stale-recovery {
289
+ display: none;
290
+ }
291
+
292
+ #dev-stale .stale-recovery-compact,
293
+ #dev-stale .stale-more {
294
+ display: inline;
295
+ }
296
+ }
297
+
298
+ #dev-stale summary span {
299
+ display: block;
300
+ }
301
+
302
+ #dev-stale .stale-more {
303
+ color: #8f857a;
304
+ }
305
+
306
+ /* The expanded body: the whole failure verbatim, the built newlines painted
307
+ by the inherited pre-line, separated from the summary by the diagnostics
308
+ rhythm — and the notice's ONLY scroller (round 3, finding 1). Inside a
309
+ <details> the engine hosts the non-summary children in its own
310
+ ::details-content wrapper — THAT box is the second grid row, so it must
311
+ carry min-height: 0 or the row can never shrink below the body's
312
+ min-content and the notice clips instead of the body scrolling. The body
313
+ then fills the row under max-height: 100% and scrolls there, and
314
+ tabindex="0" (set as markup by devchannel.mjs) makes that scroller a tab
315
+ stop after the summary — so the summary is the notice's first and only
316
+ stop while collapsed, and summary → body → out once open. The closed
317
+ disclosure does not render the body, so it is not a stop until it can be
318
+ read. */
319
+ #dev-stale::details-content {
320
+ min-height: 0;
321
+ }
322
+
323
+ #dev-stale .stale-body {
324
+ margin: 8px 0 0;
325
+ max-height: 100%;
326
+ overflow-y: auto;
327
+ }
328
+
329
+ /* The scroller's own ring: the same house 2px amber :focus-visible
330
+ treatment as the summary and the buttons, on the element that actually
331
+ scrolls — the opened body. */
332
+ #dev-stale .stale-body:focus-visible {
333
+ outline: 2px solid #f0b27a;
334
+ outline-offset: 2px;
335
+ }
336
+
337
+ /* Round 3, finding 3 — when the opened body scrolls, say so. A scroller at
338
+ rest shows a partial last line, and at 320px the round-2 reading showed
339
+ exactly that reading as the end of the message ("successful edit" cut to
340
+ a sliver, no scrollbar painted). The builder measures scrollHeight
341
+ against clientHeight when the disclosure opens (the native toggle event)
342
+ and on every delivery, and wears the class; the cue is then a sticky
343
+ ::after inside the scroller: pinned to the scrollport's bottom edge —
344
+ opaque, in the notice's panel colour with the diagnostics separator — so
345
+ it covers the partial line while text follows, and it settles after the
346
+ last line once the end is reached. No hover anywhere: a cue that needs a
347
+ pointer hides the problem from touch and from the keyboard. */
348
+ #dev-stale .stale-body.scrolls-more::after {
349
+ content: '…';
350
+ display: block;
351
+ position: sticky;
352
+ bottom: 0;
353
+ color: #8f857a;
354
+ text-align: right;
355
+ background: #140d11;
356
+ border-top: 1px solid #2c2026;
357
+ }
358
+
359
+ .diagnostics {
360
+ padding-top: 8px;
361
+ border-top: 1px solid #2c2026;
362
+ color: #8f857a;
363
+ font: 12px/1.4 ui-monospace, monospace;
364
+ }
365
+
366
+ .diagnostics summary {
367
+ cursor: pointer;
368
+ }
369
+
370
+ .diagnostics summary:focus-visible {
371
+ outline: 2px solid #f0b27a;
372
+ outline-offset: 2px;
373
+ }
374
+
375
+ #diagnostics-text {
376
+ margin: 8px 0 0;
377
+ color: #d8cfc4;
378
+ white-space: pre-wrap;
379
+ overflow-wrap: anywhere;
380
+ }
381
+
382
+ .diag-line {
383
+ display: block;
384
+ }
385
+
386
+ ::selection {
387
+ background: #f0b27a;
388
+ color: #0a0608;
389
+ }
390
+
391
+ [hidden] {
392
+ display: none !important;
393
+ }
@@ -0,0 +1,76 @@
1
+ // The release entry. It statically imports the portable core, the prepared
2
+ // pngine viewer, the playback-only pacer runtime and the hello run core, then
3
+ // points the shared page implementation at the exported files relative to
4
+ // this module. The third-party playback runtime arrives as bare
5
+ // specifiers — 'pngine/viewer', '@mr_hugo/pacer/rt', '@mr_hugo/pacer/route' — which the
6
+ // bundler resolves against the toolchain's pinned artifacts (the vendored
7
+ // bundle in contributor mode, the installed packages otherwise), so this
8
+ // file never carries a path out of the package. The authoring compiler
9
+ // (animader.wasm/piece.js), pacer's text compile driver, the dev profile
10
+ // and every server-mount path are absent from this graph by construction;
11
+ // esbuild bundles this file and its static imports into the single shipped
12
+ // app.mjs.
13
+ //
14
+ // `import.meta.url` is app.mjs at the export root, so every URL below stays
15
+ // correct when the whole export is served from a nested path.
16
+
17
+ import { createPlayerController, audioDurationNote } from './controller.mjs';
18
+ import { boot, reportBootFailure } from './boot.mjs';
19
+ import { loadPackedShow, ShowError } from '../lib/score.mjs';
20
+ import { createShow } from '../lib/show.mjs';
21
+ import * as soundtrackData from '../lib/soundtrack.mjs';
22
+ import { createWebAudioPort } from '../adapters/webaudio.mjs';
23
+ import { pngine, draw, setUniform, getUniforms, restartSettled } from 'pngine/viewer';
24
+ import { PacerRt } from '@mr_hugo/pacer/rt';
25
+ import { routeWrites, toUniforms } from '@mr_hugo/pacer/route';
26
+ import { createPlayer } from '../hello/run.js';
27
+
28
+ const root = new URL('./', import.meta.url);
29
+
30
+ const releaseProfile = {
31
+ name: 'release',
32
+ remedy: 'rebuild the export with node demo/export.mjs so the prepared viewer carries restartSettled',
33
+ documents: {
34
+ runtimeModule: './app.mjs',
35
+ config: './config.json',
36
+ payload: 'assets/show.png',
37
+ manifest: 'manifest.json',
38
+ },
39
+ loadController: async () => ({ createPlayerController, audioDurationNote }),
40
+ loadModules: async () => ({
41
+ score: { loadPackedShow, ShowError },
42
+ show: { createShow },
43
+ soundtrack: soundtrackData,
44
+ webaudio: { createWebAudioPort },
45
+ pngine: { pngine, draw, setUniform, getUniforms, restartSettled },
46
+ pacerRt: { PacerRt },
47
+ route: { routeWrites, toUniforms },
48
+ run: { createPlayer },
49
+ }),
50
+ configUrl: new URL('./config.json', root),
51
+ configManifestPath: 'config.json',
52
+ manifestUrl: new URL('./manifest.json', root),
53
+ assets: [
54
+ { key: 'payload', url: new URL('./assets/show.png', root), kind: 'bytes', manifestPath: 'assets/show.png' },
55
+ { key: 'score', url: new URL('./assets/score.pacb', root), kind: 'bytes', manifestPath: 'assets/score.pacb' },
56
+ { key: 'pacerRtWasm', url: new URL('./pacer-rt.wasm', root), kind: 'bytes', manifestPath: 'pacer-rt.wasm' },
57
+ ],
58
+ // The audio asset follows the validated config selection so a release can
59
+ // ship any recognized name; the loader appends it after config validation.
60
+ soundtrackAsset: (name) => ({
61
+ key: 'soundtrack',
62
+ url: new URL(`./assets/${name}`, root),
63
+ kind: 'bytes',
64
+ manifestPath: `assets/${name}`,
65
+ }),
66
+ loadScore: ({ modules, assets, reflection }) => modules.score.loadPackedShow({
67
+ document: { name: 'score.pacb', bytes: assets.score.bytes },
68
+ dep: {
69
+ pacerRtWasm: assets.pacerRtWasm.bytes,
70
+ reflection,
71
+ },
72
+ factories: { loadPacerRt: modules.pacerRt.PacerRt.load },
73
+ }),
74
+ };
75
+
76
+ boot(releaseProfile).catch(reportBootFailure);