stagecraft 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.
Files changed (133) hide show
  1. package/AGENT.md +792 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -0
  4. package/bin/cli.js +51 -0
  5. package/bin/export.js +137 -0
  6. package/bin/init.js +52 -0
  7. package/bin/lib/edit-ops.js +405 -0
  8. package/bin/serve.js +278 -0
  9. package/dist/stagecraft.bundle.css +4443 -0
  10. package/dist/stagecraft.bundle.js +7621 -0
  11. package/dist/themes/brand.bundle.css +5262 -0
  12. package/dist/themes/neon.bundle.css +5289 -0
  13. package/dist/themes/paper.bundle.css +5276 -0
  14. package/dist/themes/phosphor.bundle.css +4443 -0
  15. package/dist/themes/shopware.bundle.css +5850 -0
  16. package/examples/closing-card.js +74 -0
  17. package/examples/orchestration-graph.js +156 -0
  18. package/examples/terminal-log.js +109 -0
  19. package/examples/token-stream.js +96 -0
  20. package/examples/whoami.js +90 -0
  21. package/package.json +41 -0
  22. package/src/components/activity-list.js +75 -0
  23. package/src/components/agenda.js +79 -0
  24. package/src/components/bar-chart.js +162 -0
  25. package/src/components/before-after.js +135 -0
  26. package/src/components/bento.js +73 -0
  27. package/src/components/big-number.js +87 -0
  28. package/src/components/callout.js +75 -0
  29. package/src/components/checklist.js +81 -0
  30. package/src/components/code-block.js +141 -0
  31. package/src/components/code-diff.js +98 -0
  32. package/src/components/compare.js +85 -0
  33. package/src/components/counter.js +80 -0
  34. package/src/components/cta.js +69 -0
  35. package/src/components/cycle.js +146 -0
  36. package/src/components/definition.js +96 -0
  37. package/src/components/donut-chart.js +179 -0
  38. package/src/components/full-image.js +82 -0
  39. package/src/components/funnel.js +111 -0
  40. package/src/components/gauge.js +147 -0
  41. package/src/components/heatmap.js +141 -0
  42. package/src/components/image-grid.js +80 -0
  43. package/src/components/image-text.js +96 -0
  44. package/src/components/kinetic-text.js +72 -0
  45. package/src/components/kpi.js +106 -0
  46. package/src/components/line-chart.js +215 -0
  47. package/src/components/manifesto.js +104 -0
  48. package/src/components/marquee.js +63 -0
  49. package/src/components/matrix2x2.js +151 -0
  50. package/src/components/pillars.js +80 -0
  51. package/src/components/pricing.js +90 -0
  52. package/src/components/process-flow.js +133 -0
  53. package/src/components/progress.js +136 -0
  54. package/src/components/punchline.js +82 -0
  55. package/src/components/pyramid.js +107 -0
  56. package/src/components/qanda.js +60 -0
  57. package/src/components/quote.js +70 -0
  58. package/src/components/roadmap.js +130 -0
  59. package/src/components/section-card.js +45 -0
  60. package/src/components/shift-arrow.js +41 -0
  61. package/src/components/spark-line.js +147 -0
  62. package/src/components/spotlight.js +85 -0
  63. package/src/components/statement.js +106 -0
  64. package/src/components/stats.js +91 -0
  65. package/src/components/steps.js +83 -0
  66. package/src/components/swot.js +110 -0
  67. package/src/components/team-grid.js +87 -0
  68. package/src/components/testimonial.js +99 -0
  69. package/src/components/timeline.js +91 -0
  70. package/src/components/tip.js +63 -0
  71. package/src/components/venn.js +198 -0
  72. package/src/edit-mode.js +1256 -0
  73. package/src/engine.js +823 -0
  74. package/src/helpers.js +169 -0
  75. package/src/transitions.js +101 -0
  76. package/starter/index.html +40 -0
  77. package/starter/slides/00-title.js +12 -0
  78. package/starter/stagecraft.config.js +8 -0
  79. package/themes/brand/base.css +4 -0
  80. package/themes/brand/components-business.css +173 -0
  81. package/themes/brand/components-chart.css +65 -0
  82. package/themes/brand/components-content.css +126 -0
  83. package/themes/brand/components-data.css +162 -0
  84. package/themes/brand/components-diagram.css +115 -0
  85. package/themes/brand/components-layout.css +112 -0
  86. package/themes/brand/components.css +46 -0
  87. package/themes/brand/manifest.json +20 -0
  88. package/themes/brand/tokens.css +20 -0
  89. package/themes/brand/transitions.css +4 -0
  90. package/themes/neon/base.css +10 -0
  91. package/themes/neon/components-business.css +189 -0
  92. package/themes/neon/components-chart.css +70 -0
  93. package/themes/neon/components-content.css +112 -0
  94. package/themes/neon/components-data.css +160 -0
  95. package/themes/neon/components-diagram.css +109 -0
  96. package/themes/neon/components-layout.css +87 -0
  97. package/themes/neon/components.css +87 -0
  98. package/themes/neon/manifest.json +21 -0
  99. package/themes/neon/tokens.css +17 -0
  100. package/themes/neon/transitions.css +13 -0
  101. package/themes/paper/base.css +9 -0
  102. package/themes/paper/components-business.css +196 -0
  103. package/themes/paper/components-chart.css +74 -0
  104. package/themes/paper/components-content.css +108 -0
  105. package/themes/paper/components-data.css +168 -0
  106. package/themes/paper/components-diagram.css +89 -0
  107. package/themes/paper/components-layout.css +105 -0
  108. package/themes/paper/components.css +60 -0
  109. package/themes/paper/manifest.json +10 -0
  110. package/themes/paper/tokens.css +21 -0
  111. package/themes/paper/transitions.css +11 -0
  112. package/themes/phosphor/base.css +511 -0
  113. package/themes/phosphor/components-business.css +818 -0
  114. package/themes/phosphor/components-chart.css +415 -0
  115. package/themes/phosphor/components-content.css +530 -0
  116. package/themes/phosphor/components-data.css +824 -0
  117. package/themes/phosphor/components-diagram.css +427 -0
  118. package/themes/phosphor/components-layout.css +450 -0
  119. package/themes/phosphor/components.css +223 -0
  120. package/themes/phosphor/manifest.json +11 -0
  121. package/themes/phosphor/tokens.css +17 -0
  122. package/themes/phosphor/transitions.css +213 -0
  123. package/themes/shopware/base.css +94 -0
  124. package/themes/shopware/components-business.css +344 -0
  125. package/themes/shopware/components-chart.css +121 -0
  126. package/themes/shopware/components-content.css +169 -0
  127. package/themes/shopware/components-data.css +219 -0
  128. package/themes/shopware/components-diagram.css +129 -0
  129. package/themes/shopware/components-layout.css +166 -0
  130. package/themes/shopware/components.css +83 -0
  131. package/themes/shopware/manifest.json +21 -0
  132. package/themes/shopware/tokens.css +68 -0
  133. package/themes/shopware/transitions.css +22 -0
package/src/engine.js ADDED
@@ -0,0 +1,823 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stagecraft — Engine (Layer 0).
5
+ *
6
+ * The runtime: slide registry, navigation, step model, storyboard,
7
+ * transitions, deck loader, edit-mode WebSocket hook.
8
+ *
9
+ * Loads via plain <script> in index.html. Exposes `Stage.*` globals.
10
+ */
11
+
12
+ (function (root) {
13
+ const Stage = root.Stage = root.Stage || {};
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Slide registry
17
+ // ---------------------------------------------------------------------------
18
+ Stage.slides = Stage.slides || [];
19
+
20
+ // Stage.register(slide [, meta])
21
+ // slide — { section, title, render, init?, replay?, steps?, onStep?, transition? }
22
+ // meta — optional: { notes, ... } merged onto the slide.
23
+ // This is the home for fields that belong to the *deck* rather than
24
+ // the *component* — most importantly `notes` (speaker notes shown
25
+ // in presenter view).
26
+ Stage.register = function (slide, meta) {
27
+ if (!slide || typeof slide.render !== 'function') {
28
+ throw new Error('Stage.register: slide must have a render(el) function');
29
+ }
30
+ if (meta && typeof meta === 'object') Object.assign(slide, meta);
31
+ Stage.slides.push(slide);
32
+ };
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Transition registry
36
+ // Built-in transitions live in src/transitions.js. Themes may override or
37
+ // register new ones via Stage.registerTransition.
38
+ // ---------------------------------------------------------------------------
39
+ Stage.transitions = Stage.transitions || {};
40
+
41
+ Stage.registerTransition = function (name, config) {
42
+ Stage.transitions[name] = config;
43
+ };
44
+
45
+ function applyTransition(el, name, phase /* 'enter' | 'exit' */) {
46
+ const t = Stage.transitions[name] || Stage.transitions.fade;
47
+ if (!t) return;
48
+ if (phase === 'enter') t.enter?.(el);
49
+ else t.exit?.(el);
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Deck loader — Stage.deck({ theme, slides: [{src, transition?}, ...] })
54
+ // Sets the theme, fetches each slide script in order, starts the runtime.
55
+ // ---------------------------------------------------------------------------
56
+ Stage.deck = function (config) {
57
+ Stage._config = config;
58
+ if (config.theme) document.documentElement.setAttribute('data-theme', config.theme);
59
+ // Slides list with transitions; engine reads .transition on enter.
60
+ Stage._manifestSlides = config.slides || [];
61
+
62
+ const sources = (config.slides || []).map(s => s.src);
63
+ loadScripts(sources).then(() => {
64
+ // After all slide scripts loaded, each has called Stage.register().
65
+ // Pair manifest transitions with registered slides by order.
66
+ Stage.slides.forEach((slide, i) => {
67
+ const m = Stage._manifestSlides[i];
68
+ if (m) slide.transition = m.transition || slide.transition || 'fade';
69
+ });
70
+ initRuntime();
71
+ });
72
+ };
73
+
74
+ function loadScripts(srcs) {
75
+ return srcs.reduce((p, src) => p.then(() => loadScript(src)), Promise.resolve());
76
+ }
77
+
78
+ function loadScript(src) {
79
+ return new Promise((resolve, reject) => {
80
+ const s = document.createElement('script');
81
+ s.src = src;
82
+ s.async = false;
83
+ s.onload = () => resolve();
84
+ s.onerror = () => reject(new Error('Failed to load ' + src));
85
+ document.head.appendChild(s);
86
+ });
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Runtime — navigation, step model, storyboard, edit-mode hook
91
+ // ---------------------------------------------------------------------------
92
+ let stage, welcome, uiTitle, curSec, dotsEl, hint;
93
+ let current = -1;
94
+ let currentStep = 0;
95
+ let activeCleanup = null;
96
+ let hintTimer = null;
97
+ let editMode = false; // user-toggleable: are edit affordances visible?
98
+ let serverAvailable = false; // is the dev server actually reachable?
99
+ let ws = null;
100
+ let presenterMode = false; // this window is rendering the presenter view
101
+ let bc = null; // BroadcastChannel for window sync
102
+ let suppressBroadcast = false; // re-entrancy guard for sync
103
+ let presenterEls = null; // { currentPane, nextPane, notesPane, timer, clock }
104
+ let talkStartTime = null; // for the elapsed timer
105
+
106
+ function initRuntime() {
107
+ if (Stage._inited) return;
108
+ Stage._inited = true;
109
+
110
+ // Are we the presenter window? Detected at init from ?mode=presenter.
111
+ const params = new URLSearchParams(location.search);
112
+ presenterMode = params.get('mode') === 'presenter';
113
+ if (presenterMode) {
114
+ document.body.classList.add('presenter-mode');
115
+ buildPresenterChrome();
116
+ } else {
117
+ buildChrome();
118
+ }
119
+
120
+ bindKeyboard();
121
+ bindMouse();
122
+ bindHash();
123
+ bindResize();
124
+ tryConnectEditServer();
125
+ setupBroadcastChannel();
126
+
127
+ const initial = parseHash();
128
+ if (initial !== null) {
129
+ go(initial);
130
+ showHint();
131
+ }
132
+
133
+ // If the storyboard was open before a reload (e.g. drag-drop triggered
134
+ // a manifest write → reload), restore it. We wait briefly so the
135
+ // current slide has rendered and the edit-mode WS has had a chance
136
+ // to attach (so connectors + drag handles are wired up).
137
+ try {
138
+ if (sessionStorage.getItem('stagecraft:overview') === '1') {
139
+ if (initial === null) go(0); // need a current slide so storyboard has context
140
+ const restore = () => {
141
+ if (overviewActive) return;
142
+ openOverview();
143
+ };
144
+ // Two-stage: try fast (edit mode already on), then again after WS
145
+ // has had time to upgrade.
146
+ setTimeout(restore, 50);
147
+ setTimeout(restore, 400);
148
+ }
149
+ } catch (e) {}
150
+ }
151
+
152
+ function buildChrome() {
153
+ stage = document.getElementById('stage');
154
+ if (!stage) {
155
+ stage = document.createElement('main');
156
+ stage.id = 'stage';
157
+ document.body.appendChild(stage);
158
+ }
159
+
160
+ welcome = document.getElementById('welcome');
161
+ uiTitle = document.getElementById('uiTitle');
162
+ curSec = document.getElementById('curSec');
163
+ dotsEl = document.getElementById('uiDots');
164
+ hint = document.getElementById('uiHint');
165
+
166
+ // build progress dots from unique section numbers
167
+ if (dotsEl) {
168
+ const sections = [...new Set(Stage.slides.map(s => s.section).filter(Boolean))].sort((a, b) => a - b);
169
+ const totalEl = document.querySelector('.ui-counter .total');
170
+ if (totalEl) totalEl.textContent = String(sections.length).padStart(2, '0');
171
+ sections.forEach(sec => {
172
+ const d = document.createElement('div');
173
+ d.className = 'dot';
174
+ d.dataset.sec = sec;
175
+ dotsEl.appendChild(d);
176
+ });
177
+ }
178
+
179
+ if (welcome) {
180
+ welcome.addEventListener('click', () => start(), { once: true });
181
+ }
182
+ }
183
+
184
+ function start() {
185
+ if (current === -1) go(0);
186
+ showHint();
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Presenter view chrome
191
+ // ---------------------------------------------------------------------------
192
+ function buildPresenterChrome() {
193
+ // Remove any normal-mode chrome the HTML happens to have inlined.
194
+ document.querySelectorAll('.welcome, .ui').forEach(n => n.remove());
195
+ const existingStage = document.getElementById('stage');
196
+ if (existingStage) existingStage.remove();
197
+
198
+ document.title = 'Stagecraft Presenter';
199
+ document.body.innerHTML = `
200
+ <div class="presenter-shell">
201
+ <div class="presenter-top">
202
+ <div class="presenter-pane presenter-current" id="presenterCurrent">
203
+ <div class="presenter-pane-label">NOW · slide <span id="presenterCurrentIdx">00</span></div>
204
+ <div class="presenter-pane-stage" id="presenterCurrentStage"></div>
205
+ </div>
206
+ <div class="presenter-pane presenter-next" id="presenterNext">
207
+ <div class="presenter-pane-label">NEXT</div>
208
+ <div class="presenter-pane-stage" id="presenterNextStage"></div>
209
+ </div>
210
+ </div>
211
+ <div class="presenter-meta">
212
+ <div class="presenter-timer" id="presenterTimer">00:00</div>
213
+ <div class="presenter-clock" id="presenterClock">--:--</div>
214
+ <button class="presenter-timer-reset" id="presenterTimerReset" title="Reset elapsed timer">↻ reset</button>
215
+ </div>
216
+ <div class="presenter-notes" id="presenterNotes">
217
+ <div class="presenter-notes-label">SPEAKER NOTES</div>
218
+ <div class="presenter-notes-body" id="presenterNotesBody"></div>
219
+ </div>
220
+ </div>
221
+ `;
222
+
223
+ presenterEls = {
224
+ currentStage: document.getElementById('presenterCurrentStage'),
225
+ currentIdx: document.getElementById('presenterCurrentIdx'),
226
+ nextStage: document.getElementById('presenterNextStage'),
227
+ notesBody: document.getElementById('presenterNotesBody'),
228
+ timer: document.getElementById('presenterTimer'),
229
+ clock: document.getElementById('presenterClock')
230
+ };
231
+
232
+ // Engine's "stage" reference now points to the current-slide container.
233
+ // The pane-stage is the inner scaler; we render slides into a wrapper
234
+ // inside the pane that lets us position child .slide elements normally.
235
+ stage = presenterEls.currentStage;
236
+ stage.classList.add('presenter-stage');
237
+
238
+ startTalkTimer();
239
+ document.getElementById('presenterTimerReset')?.addEventListener('click', () => {
240
+ talkStartTime = Date.now();
241
+ updatePresenterMeta();
242
+ });
243
+
244
+ document.body.classList.add('armed');
245
+ }
246
+
247
+ function startTalkTimer() {
248
+ talkStartTime = Date.now();
249
+ updatePresenterMeta();
250
+ setInterval(updatePresenterMeta, 1000);
251
+ }
252
+
253
+ function updatePresenterMeta() {
254
+ if (!presenterEls) return;
255
+ const elapsed = Date.now() - (talkStartTime || Date.now());
256
+ const total = Math.floor(elapsed / 1000);
257
+ const h = Math.floor(total / 3600);
258
+ const m = Math.floor((total % 3600) / 60);
259
+ const s = total % 60;
260
+ const pad = n => String(n).padStart(2, '0');
261
+ presenterEls.timer.textContent = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
262
+ const now = new Date();
263
+ presenterEls.clock.textContent = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
264
+ }
265
+
266
+ function renderPresenterNext(idx) {
267
+ if (!presenterEls) return;
268
+ presenterEls.nextStage.innerHTML = '';
269
+ const nextIdx = idx + 1;
270
+ if (nextIdx >= Stage.slides.length) {
271
+ presenterEls.nextStage.innerHTML = '<div class="presenter-end">— end of deck —</div>';
272
+ return;
273
+ }
274
+ const next = Stage.slides[nextIdx];
275
+ const el = document.createElement('div');
276
+ el.className = 'slide current';
277
+ try { next.render(el); } catch (e) { console.warn('next render', e); }
278
+ presenterEls.nextStage.appendChild(el);
279
+ }
280
+
281
+ function renderPresenterNotes(slide) {
282
+ if (!presenterEls) return;
283
+ const notes = (slide && slide.notes) || '';
284
+ presenterEls.notesBody.textContent = notes || '(no notes for this slide)';
285
+ presenterEls.notesBody.classList.toggle('empty', !notes);
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // BroadcastChannel — sync nav events across presenter + presentation windows
290
+ // ---------------------------------------------------------------------------
291
+ function setupBroadcastChannel() {
292
+ if (typeof BroadcastChannel === 'undefined') return;
293
+ bc = new BroadcastChannel('stagecraft:nav');
294
+ bc.addEventListener('message', (e) => {
295
+ const msg = e.data;
296
+ if (!msg || typeof msg !== 'object') return;
297
+ suppressBroadcast = true;
298
+ try {
299
+ switch (msg.type) {
300
+ case 'go': go(msg.idx); break;
301
+ case 'step': applyRemoteStep(msg.step); break;
302
+ case 'replay': replay(); break;
303
+ case 'overview': msg.open ? openOverview() : closeOverview(); break;
304
+ }
305
+ } finally {
306
+ suppressBroadcast = false;
307
+ }
308
+ });
309
+ }
310
+
311
+ function broadcast(msg) {
312
+ if (!bc) return;
313
+ if (suppressBroadcast) return;
314
+ try { bc.postMessage(msg); } catch (e) { /* ignore */ }
315
+ }
316
+
317
+ function applyRemoteStep(step) {
318
+ const slide = Stage.slides[current];
319
+ if (!slide || !slide.steps) return;
320
+ currentStep = Math.max(0, Math.min(slide.steps - 1, step));
321
+ const el = stage.querySelector('.slide.current');
322
+ if (el && slide.onStep) {
323
+ try { slide.onStep(el, currentStep); } catch (e) { /* ignore */ }
324
+ }
325
+ }
326
+
327
+ // Toggle edit-mode affordances on/off without disconnecting the server.
328
+ // Lets the user present cleanly even with the dev server running.
329
+ function toggleEditMode() {
330
+ if (!serverAvailable) {
331
+ // No server — nothing to toggle. Show a brief hint.
332
+ showHint();
333
+ return;
334
+ }
335
+ editMode = !editMode;
336
+ document.body.classList.toggle('edit-mode', editMode);
337
+ // Re-fire decoration on the current slide (so pin markers + hover affordances
338
+ // appear / disappear without a full reload).
339
+ const el = stage && stage.querySelector('.slide.current');
340
+ const slide = Stage.slides[current];
341
+ if (slide && el) {
342
+ if (editMode) {
343
+ if (Stage._editUI?.onSlideRendered) {
344
+ try { Stage._editUI.onSlideRendered(slide, current, el); } catch (e) {}
345
+ }
346
+ } else {
347
+ // Strip any present-mode pin markers + lingering hover outlines.
348
+ el.querySelectorAll('.pin-marker').forEach(n => n.remove());
349
+ document.querySelectorAll('.hover-outline, .note-overlay, .transition-picker, .add-slide-dialog').forEach(n => n.remove());
350
+ // Close storyboard too — it's an edit-mode-flavored view.
351
+ if (overviewActive) closeOverview();
352
+ }
353
+ }
354
+ console.log('[stagecraft] edit mode', editMode ? 'ON' : 'OFF');
355
+ }
356
+
357
+ function openPresenterWindow() {
358
+ const url = new URL(location.href);
359
+ url.searchParams.set('mode', 'presenter');
360
+ // Preserve hash so presenter opens on the current slide
361
+ window.open(url.toString(), 'stagecraft-presenter',
362
+ 'width=1200,height=800,toolbar=no,menubar=no,location=no');
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Slide navigation
367
+ // ---------------------------------------------------------------------------
368
+ function go(idx) {
369
+ if (idx < 0 || idx >= Stage.slides.length) return;
370
+ if (activeCleanup) { try { activeCleanup(); } catch (e) { console.warn(e); } activeCleanup = null; }
371
+
372
+ const old = stage.querySelector('.slide.current');
373
+ const oldSlide = Stage.slides[current];
374
+ if (old && oldSlide) {
375
+ old.classList.remove('current');
376
+ old.classList.add('exiting');
377
+ applyTransition(old, oldSlide.transition || 'fade', 'exit');
378
+ setTimeout(() => old.remove(), 700);
379
+ }
380
+
381
+ const slide = Stage.slides[idx];
382
+ const el = document.createElement('div');
383
+ el.className = 'slide';
384
+ el.dataset.idx = idx;
385
+ el.dataset.transition = slide.transition || 'fade';
386
+ try { slide.render(el); } catch (e) { console.error('render error', e); }
387
+ stage.appendChild(el);
388
+
389
+ // Assign data-stage-key after render for edit mode
390
+ if (Stage.assignStageKeys) Stage.assignStageKeys(el);
391
+
392
+ // Force reflow before adding .current so transitions run
393
+ void el.offsetHeight;
394
+ el.classList.add('current');
395
+ applyTransition(el, slide.transition || 'fade', 'enter');
396
+
397
+ current = idx;
398
+ currentStep = 0;
399
+ updateChrome(slide);
400
+ syncHash(idx);
401
+
402
+ // Presenter view extras
403
+ if (presenterMode && presenterEls) {
404
+ if (presenterEls.currentIdx) presenterEls.currentIdx.textContent = String(idx).padStart(2, '0');
405
+ renderPresenterNext(idx);
406
+ renderPresenterNotes(slide);
407
+ }
408
+
409
+ // Broadcast nav to the other window (if any), unless this go() was
410
+ // itself triggered by a remote message.
411
+ broadcast({ type: 'go', idx });
412
+
413
+ setTimeout(() => {
414
+ if (current !== idx) return;
415
+ try {
416
+ activeCleanup = slide.init ? slide.init(el) : null;
417
+ if (slide.steps && slide.onStep) {
418
+ try { slide.onStep(el, 0); } catch (e) { console.error('onStep error', e); }
419
+ }
420
+ } catch (e) { console.error('init error', e); }
421
+ // Let edit-mode decorate the freshly rendered slide (pin markers, etc.)
422
+ if (editMode && Stage._editUI?.onSlideRendered) {
423
+ try { Stage._editUI.onSlideRendered(slide, idx, el); } catch (e) { /* ignore */ }
424
+ }
425
+ }, 80);
426
+
427
+ if (welcome && !welcome.classList.contains('hidden')) {
428
+ welcome.classList.add('hidden');
429
+ document.body.classList.add('armed');
430
+ setTimeout(() => welcome.remove(), 600);
431
+ }
432
+ }
433
+
434
+ function updateChrome(slide) {
435
+ if (uiTitle) uiTitle.textContent = slide.title || '';
436
+ const sec = slide.section;
437
+ if (curSec) curSec.textContent = String(sec).padStart(2, '0');
438
+ if (dotsEl) {
439
+ const dots = dotsEl.querySelectorAll('.dot');
440
+ dots.forEach(d => {
441
+ const s = Number(d.dataset.sec);
442
+ d.classList.remove('active', 'past');
443
+ if (s === sec) d.classList.add('active');
444
+ else if (s < sec) d.classList.add('past');
445
+ });
446
+ }
447
+ }
448
+
449
+ function next() {
450
+ const slide = Stage.slides[current];
451
+ if (slide && slide.steps && currentStep < slide.steps - 1) {
452
+ currentStep++;
453
+ const el = stage.querySelector('.slide.current');
454
+ if (el && slide.onStep) {
455
+ try { slide.onStep(el, currentStep); } catch (e) { console.error('onStep error', e); }
456
+ }
457
+ broadcast({ type: 'step', step: currentStep });
458
+ return;
459
+ }
460
+ if (current < Stage.slides.length - 1) go(current + 1);
461
+ }
462
+
463
+ function prev() {
464
+ const slide = Stage.slides[current];
465
+ if (slide && slide.steps && currentStep > 0) {
466
+ currentStep--;
467
+ const el = stage.querySelector('.slide.current');
468
+ if (el && slide.onStep) {
469
+ try { slide.onStep(el, currentStep); } catch (e) { console.error('onStep error', e); }
470
+ }
471
+ broadcast({ type: 'step', step: currentStep });
472
+ return;
473
+ }
474
+ if (current > 0) go(current - 1);
475
+ }
476
+
477
+ function jumpToSection(secNum) {
478
+ const idx = Stage.slides.findIndex(s => s.section === secNum);
479
+ if (idx >= 0) go(idx);
480
+ }
481
+
482
+ function replay() {
483
+ const el = stage.querySelector('.slide.current');
484
+ const slide = Stage.slides[current];
485
+ if (!el || !slide) return;
486
+ if (activeCleanup) { try { activeCleanup(); } catch (e) {} activeCleanup = null; }
487
+ const fn = slide.replay || slide.init;
488
+ if (fn) {
489
+ try {
490
+ activeCleanup = fn(el) || null;
491
+ currentStep = 0;
492
+ if (slide.steps && slide.onStep) slide.onStep(el, 0);
493
+ } catch (e) { console.error('replay error', e); }
494
+ }
495
+ broadcast({ type: 'replay' });
496
+ }
497
+
498
+ function toggleFullscreen() {
499
+ if (!document.fullscreenElement) document.documentElement.requestFullscreen?.();
500
+ else document.exitFullscreen?.();
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // Storyboard
505
+ // ---------------------------------------------------------------------------
506
+ let overviewActive = false;
507
+ let overviewCleanups = [];
508
+
509
+ function openOverview() {
510
+ if (overviewActive) return;
511
+ overviewActive = true;
512
+ // Persist state across reloads (e.g. when a drag-drop triggers a manifest
513
+ // reload, we want the storyboard to come back open).
514
+ try { sessionStorage.setItem('stagecraft:overview', '1'); } catch (e) {}
515
+
516
+ const ov = document.createElement('div');
517
+ ov.id = 'overview';
518
+ ov.className = 'overview';
519
+ const sectionCount = new Set(Stage.slides.map(s => s.section).filter(Boolean)).size;
520
+
521
+ ov.innerHTML = `
522
+ <div class="overview-header">
523
+ <div class="left"><strong>Storyboard</strong>&nbsp;&nbsp;·&nbsp;&nbsp;${Stage.slides.length} slides&nbsp;&nbsp;·&nbsp;&nbsp;${sectionCount} sections${editMode ? '&nbsp;&nbsp;·&nbsp;&nbsp;<span class="accent">EDIT MODE</span>' : ''}</div>
524
+ <div class="right"><span class="accent">click</span> to jump&nbsp;&nbsp;·&nbsp;&nbsp;<span class="accent">S</span> or <span class="accent">Esc</span> to close</div>
525
+ </div>
526
+ <div class="overview-grid" id="overviewGrid"></div>
527
+ `;
528
+ const grid = ov.querySelector('#overviewGrid');
529
+
530
+ Stage.slides.forEach((slide, i) => {
531
+ const tile = document.createElement('div');
532
+ tile.className = 'tile';
533
+ if (i === current) tile.classList.add('current');
534
+ tile.dataset.idx = i;
535
+ if (editMode) tile.setAttribute('draggable', 'true');
536
+
537
+ const scaler = document.createElement('div');
538
+ scaler.className = 'tile-scaler';
539
+ const slideEl = document.createElement('div');
540
+ slideEl.className = 'slide current';
541
+ try { slide.render(slideEl); } catch (e) { console.warn('tile render', e); }
542
+ scaler.appendChild(slideEl);
543
+ tile.appendChild(scaler);
544
+
545
+ const num = document.createElement('div');
546
+ num.className = 'tile-num';
547
+ num.textContent = String(i).padStart(2, '0');
548
+ tile.appendChild(num);
549
+
550
+ const label = document.createElement('div');
551
+ label.className = 'tile-label';
552
+ label.textContent = slide.title || '';
553
+ tile.appendChild(label);
554
+
555
+ // Edit-mode affordances per tile
556
+ if (editMode && Stage._editUI) {
557
+ Stage._editUI.decorateTile?.(tile, slide, i);
558
+ }
559
+
560
+ tile.addEventListener('click', (e) => {
561
+ if (e.target.closest('.tile-edit-ui')) return; // ignore clicks on edit UI
562
+ // Suppress the synthetic click that fires after a drag-drop sequence.
563
+ if (Stage._editUI?.justFinishedDrag?.()) return;
564
+ closeOverview();
565
+ go(i);
566
+ });
567
+
568
+ grid.appendChild(tile);
569
+
570
+ // run slide init for storyboard preview
571
+ if (slide.init) {
572
+ setTimeout(() => {
573
+ if (!overviewActive) return;
574
+ try {
575
+ const cleanup = slide.init(slideEl);
576
+ if (cleanup) overviewCleanups.push(cleanup);
577
+ } catch (e) { console.warn('tile init', e); }
578
+ }, 80);
579
+ }
580
+ });
581
+
582
+ document.body.appendChild(ov);
583
+
584
+ scaleTiles();
585
+ // Edit-mode decorations (connectors, transition icons) need geometry, so
586
+ // they run AFTER scaleTiles. Re-run on every resize.
587
+ if (editMode && Stage._editUI) Stage._editUI.afterOverviewBuilt?.(ov);
588
+
589
+ requestAnimationFrame(() => ov.classList.add('in'));
590
+ requestAnimationFrame(() => {
591
+ const cur = ov.querySelector('.tile.current');
592
+ if (cur) cur.scrollIntoView({ block: 'center', behavior: 'instant' });
593
+ });
594
+ }
595
+
596
+ function scaleTiles() {
597
+ const ov = document.getElementById('overview');
598
+ if (!ov) return;
599
+ const vw = window.innerWidth;
600
+ const vh = window.innerHeight;
601
+ const aspect = vh / vw;
602
+ ov.querySelectorAll('.tile').forEach(tile => {
603
+ const tw = tile.clientWidth;
604
+ const th = tw * aspect;
605
+ tile.style.height = th + 'px';
606
+ const scaler = tile.querySelector('.tile-scaler');
607
+ if (scaler) {
608
+ scaler.style.width = vw + 'px';
609
+ scaler.style.height = vh + 'px';
610
+ scaler.style.transform = `scale(${tw / vw})`;
611
+ }
612
+ });
613
+ }
614
+
615
+ function closeOverview() {
616
+ if (!overviewActive) return;
617
+ overviewActive = false;
618
+ try { sessionStorage.removeItem('stagecraft:overview'); } catch (e) {}
619
+ overviewCleanups.forEach(c => { try { c(); } catch (e) {} });
620
+ overviewCleanups = [];
621
+ const ov = document.getElementById('overview');
622
+ if (ov) {
623
+ ov.classList.remove('in');
624
+ setTimeout(() => ov.remove(), 280);
625
+ }
626
+ }
627
+
628
+ function toggleOverview() {
629
+ if (overviewActive) closeOverview();
630
+ else openOverview();
631
+ }
632
+
633
+ // ---------------------------------------------------------------------------
634
+ // Keyboard / mouse / touch
635
+ // ---------------------------------------------------------------------------
636
+ function bindKeyboard() {
637
+ window.addEventListener('keydown', (e) => {
638
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
639
+ // Don't capture keys while user is editing text in edit mode
640
+ if (e.target && e.target.isContentEditable) return;
641
+ if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) return;
642
+
643
+ if (overviewActive) {
644
+ if (e.key === 's' || e.key === 'S' || e.key === 'Escape') {
645
+ e.preventDefault();
646
+ closeOverview();
647
+ }
648
+ return;
649
+ }
650
+
651
+ switch (e.key) {
652
+ case 'ArrowRight':
653
+ case 'PageDown':
654
+ case ' ':
655
+ case 'Enter':
656
+ e.preventDefault(); next(); break;
657
+ case 'ArrowLeft':
658
+ case 'PageUp':
659
+ case 'Backspace':
660
+ e.preventDefault(); prev(); break;
661
+ case 'f': case 'F':
662
+ e.preventDefault(); toggleFullscreen(); break;
663
+ case 'r': case 'R':
664
+ e.preventDefault(); replay(); break;
665
+ case 's': case 'S':
666
+ e.preventDefault(); toggleOverview(); break;
667
+ case 'p': case 'P':
668
+ if (presenterMode) break;
669
+ e.preventDefault(); openPresenterWindow(); break;
670
+ case 'e': case 'E':
671
+ e.preventDefault(); toggleEditMode(); break;
672
+ case '?': case 'h': case 'H':
673
+ showHint(); break;
674
+ default:
675
+ if (/^[1-9]$/.test(e.key)) {
676
+ e.preventDefault();
677
+ jumpToSection(parseInt(e.key, 10));
678
+ }
679
+ }
680
+ });
681
+ }
682
+
683
+ function bindMouse() {
684
+ let touchStartX = null;
685
+ window.addEventListener('touchstart', (e) => {
686
+ touchStartX = e.touches[0].clientX;
687
+ }, { passive: true });
688
+ window.addEventListener('touchend', (e) => {
689
+ if (touchStartX == null) return;
690
+ const dx = e.changedTouches[0].clientX - touchStartX;
691
+ // Swipe always navigates. Tap only navigates outside edit mode.
692
+ if (Math.abs(dx) > 50) {
693
+ if (dx < 0) next(); else prev();
694
+ } else if (!editMode) {
695
+ next();
696
+ }
697
+ touchStartX = null;
698
+ });
699
+
700
+ window.addEventListener('click', (e) => {
701
+ if (overviewActive) return;
702
+ if (e.target.closest('#overview')) return;
703
+ if (e.target.closest('.qr-frame')) return;
704
+ // In edit mode, the slide surface is for editing — never advance
705
+ // on a free-space click. Navigation happens via keyboard (←/→/Space),
706
+ // swipe, or the storyboard. This is essential so single-click of a
707
+ // potential double-click target doesn't skip the slide.
708
+ if (editMode) return;
709
+ if (current === -1) { go(0); return; }
710
+ const w = window.innerWidth;
711
+ if (e.clientX < w / 3) prev(); else next();
712
+ });
713
+ }
714
+
715
+ function bindHash() {
716
+ window.addEventListener('hashchange', () => {
717
+ const idx = parseHash();
718
+ if (idx !== null && idx !== current) go(idx);
719
+ });
720
+ }
721
+
722
+ function bindResize() {
723
+ window.addEventListener('resize', () => {
724
+ if (!overviewActive) return;
725
+ scaleTiles();
726
+ const ov = document.getElementById('overview');
727
+ if (editMode && Stage._editUI && ov) Stage._editUI.afterOverviewBuilt?.(ov);
728
+ });
729
+ }
730
+
731
+ function parseHash() {
732
+ const m = location.hash.match(/^#(\d+)$/);
733
+ if (!m) return null;
734
+ const n = parseInt(m[1], 10);
735
+ return (n >= 0 && n < Stage.slides.length) ? n : null;
736
+ }
737
+
738
+ function syncHash(idx) {
739
+ const target = '#' + idx;
740
+ if (location.hash !== target) {
741
+ try { history.replaceState(null, '', target); }
742
+ catch (e) { location.hash = target; }
743
+ }
744
+ }
745
+
746
+ function showHint() {
747
+ if (!hint) return;
748
+ hint.classList.add('visible');
749
+ clearTimeout(hintTimer);
750
+ hintTimer = setTimeout(() => hint.classList.remove('visible'), 2500);
751
+ }
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // Edit-mode WebSocket hook
755
+ // ---------------------------------------------------------------------------
756
+ function tryConnectEditServer() {
757
+ try {
758
+ ws = new WebSocket(`ws://${location.hostname || 'localhost'}:${location.port || 3000}/stagecraft`);
759
+ ws.addEventListener('open', () => {
760
+ serverAvailable = true;
761
+ // Default to edit-mode-on when the server is reachable. The user can
762
+ // toggle this off with `E` to present cleanly while the server stays
763
+ // running.
764
+ editMode = true;
765
+ document.body.classList.add('edit-mode');
766
+ console.log('[stagecraft] edit mode ON — connected to dev server');
767
+ if (Stage._editUI) Stage._editUI.activate(ws);
768
+ });
769
+ ws.addEventListener('message', handleServerMessage);
770
+ ws.addEventListener('error', () => { /* silent */ });
771
+ ws.addEventListener('close', () => {
772
+ if (serverAvailable) {
773
+ serverAvailable = false;
774
+ editMode = false;
775
+ document.body.classList.remove('edit-mode');
776
+ console.log('[stagecraft] edit mode OFF — server disconnected');
777
+ }
778
+ });
779
+ } catch (e) {
780
+ // No server — silent
781
+ }
782
+ }
783
+
784
+ function handleServerMessage(ev) {
785
+ let msg;
786
+ try { msg = JSON.parse(ev.data); } catch (e) { return; }
787
+ if (msg.type === 'reload') {
788
+ handleReload(msg);
789
+ } else if (msg.type === 'reload-all') {
790
+ location.reload();
791
+ }
792
+ }
793
+
794
+ function handleReload(msg) {
795
+ const target = msg.target;
796
+ if (target === 'theme-css') {
797
+ // Reload stylesheets in place
798
+ document.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
799
+ const u = new URL(link.href);
800
+ u.searchParams.set('_r', Date.now());
801
+ link.href = u.toString();
802
+ });
803
+ return;
804
+ }
805
+ if (target === 'manifest' || target === 'slide' || target === 'theme-js') {
806
+ // Reload the whole page; preserve current slide via hash.
807
+ location.reload();
808
+ }
809
+ }
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Expose internal API for edit-mode UI module
813
+ // ---------------------------------------------------------------------------
814
+ Stage._engine = {
815
+ go, next, prev, replay, toggleOverview, openOverview, closeOverview,
816
+ currentIndex: () => current,
817
+ currentStep: () => currentStep,
818
+ isEditMode: () => editMode,
819
+ getWs: () => ws,
820
+ getStageEl: () => stage,
821
+ };
822
+
823
+ })(typeof window !== 'undefined' ? window : globalThis);