@liebstoeckel/engine 0.3.5

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 (51) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +82 -0
  3. package/package.json +70 -0
  4. package/src/CaptureView.tsx +96 -0
  5. package/src/CodeMagic.tsx +76 -0
  6. package/src/Deck.tsx +286 -0
  7. package/src/DeckChrome.tsx +240 -0
  8. package/src/HelpOverlay.tsx +156 -0
  9. package/src/MobileHint.tsx +71 -0
  10. package/src/PersistentLayer.tsx +168 -0
  11. package/src/Present.tsx +113 -0
  12. package/src/PresenterView.tsx +454 -0
  13. package/src/PrintView.tsx +151 -0
  14. package/src/QrOverlay.tsx +133 -0
  15. package/src/Stage.tsx +82 -0
  16. package/src/Thumb.tsx +36 -0
  17. package/src/build/buildDeck.ts +321 -0
  18. package/src/build/capture-protocol.ts +55 -0
  19. package/src/build/licenses.ts +336 -0
  20. package/src/build/mdx-plugin.ts +30 -0
  21. package/src/build/source-attr.ts +4 -0
  22. package/src/build/source-package.ts +210 -0
  23. package/src/build/thumbnails.ts +49 -0
  24. package/src/build/visx-esm-plugin.ts +42 -0
  25. package/src/code/diff.ts +61 -0
  26. package/src/code/macro.ts +24 -0
  27. package/src/code/tokenize.ts +72 -0
  28. package/src/code/types.ts +24 -0
  29. package/src/delivery.ts +32 -0
  30. package/src/index.ts +55 -0
  31. package/src/live/Plugin.tsx +160 -0
  32. package/src/live/PluginBoundary.tsx +34 -0
  33. package/src/live/breakout.tsx +235 -0
  34. package/src/live/connect.ts +149 -0
  35. package/src/live/deckIndex.ts +77 -0
  36. package/src/live/detect.ts +17 -0
  37. package/src/live/globalChrome.tsx +185 -0
  38. package/src/live/globals.ts +15 -0
  39. package/src/live/index.ts +7 -0
  40. package/src/live/participant.ts +41 -0
  41. package/src/live/presenterPanel.tsx +281 -0
  42. package/src/live/ui.ts +8 -0
  43. package/src/mobile.ts +59 -0
  44. package/src/nav.ts +149 -0
  45. package/src/slides.ts +19 -0
  46. package/src/source.ts +9 -0
  47. package/src/steps.tsx +117 -0
  48. package/src/thumbnails.ts +31 -0
  49. package/src/transitions.ts +88 -0
  50. package/src/useCoarsePointer.ts +17 -0
  51. package/src/useDeckSync.ts +85 -0
@@ -0,0 +1,454 @@
1
+ import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
2
+ import * as Y from "yjs";
3
+ import { MDXProvider } from "@mdx-js/react";
4
+ import { mdxComponents } from "@liebstoeckel/components";
5
+ import { useDeckSync } from "./useDeckSync";
6
+ import { useDeckNav, useTouchNav } from "./nav";
7
+ import { useLive } from "./live/Plugin";
8
+ import { PresenterPanel } from "./live/presenterPanel";
9
+ import { BreakoutAllowedContext } from "./live/breakout";
10
+ import { useLiveDeck } from "./live/deckIndex";
11
+ import { ScaledStage, SlideFrame } from "./Stage";
12
+ import { PresenterShare } from "./QrOverlay";
13
+ import { PersistentProvider } from "./PersistentLayer";
14
+ import { useCoarsePointer } from "./useCoarsePointer";
15
+ import { normalizeSlides } from "./slides";
16
+ import type { DeckProps } from "./Deck";
17
+
18
+ function useNow(ms = 1000) {
19
+ const [now, setNow] = useState(() => Date.now());
20
+ useEffect(() => {
21
+ const id = setInterval(() => setNow(Date.now()), ms);
22
+ return () => clearInterval(id);
23
+ }, [ms]);
24
+ return now;
25
+ }
26
+
27
+ type WakeLockNav = Navigator & { wakeLock?: { request(type: "screen"): Promise<{ release(): Promise<void> }> } };
28
+
29
+ /** Hold a screen Wake Lock while `on`, a phone used as a remote shouldn't sleep
30
+ * mid-talk. Best-effort (browser/permission dependent); re-acquires when the tab
31
+ * returns to the foreground (locks drop on hide). */
32
+ function useWakeLock(on: boolean) {
33
+ useEffect(() => {
34
+ const nav = typeof navigator !== "undefined" ? (navigator as WakeLockNav) : undefined;
35
+ if (!on || !nav?.wakeLock) return;
36
+ let sentinel: { release(): Promise<void> } | null = null;
37
+ let cancelled = false;
38
+ const acquire = () =>
39
+ nav
40
+ .wakeLock!.request("screen")
41
+ .then((s) => {
42
+ if (cancelled) void s.release().catch(() => {});
43
+ else sentinel = s;
44
+ })
45
+ .catch(() => {});
46
+ void acquire();
47
+ const onVis = () => {
48
+ if (document.visibilityState === "visible") void acquire();
49
+ };
50
+ document.addEventListener("visibilitychange", onVis);
51
+ return () => {
52
+ cancelled = true;
53
+ document.removeEventListener("visibilitychange", onVis);
54
+ void sentinel?.release().catch(() => {});
55
+ };
56
+ }, [on]);
57
+ }
58
+
59
+ const fmtElapsed = (delta: number) => {
60
+ const s = Math.max(0, Math.floor(delta / 1000));
61
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
62
+ };
63
+
64
+ type TimerCell = { at: number; epoch: number };
65
+
66
+ /** The shared talk-timer start, the **conflict-free** way (ADR 0030): each presenter
67
+ * writes its OWN cell (keyed by `doc.clientID`) so two clients never contend for one
68
+ * key, no LWW tie-break, no timing race. The start is a deterministic reduce over
69
+ * the map: the MIN `at` within the highest `epoch`. Reset bumps the epoch (a higher
70
+ * epoch wins), so it propagates to every device. Each device computes elapsed itself
71
+ * (sub-second clock skew). Only drivers write; standalone → per-window fallback doc. */
72
+ function usePresenterStart(doc: Y.Doc, canWrite: boolean): { startedAt: number; reset: () => void } {
73
+ const map = useMemo(() => doc.getMap("timer") as Y.Map<TimerCell>, [doc]);
74
+ const key = String(doc.clientID);
75
+
76
+ const reduce = useCallback((): { start?: number; epoch: number } => {
77
+ let epoch = -1;
78
+ let start: number | undefined;
79
+ map.forEach((v) => {
80
+ if (!v || typeof v.at !== "number") return;
81
+ const e = typeof v.epoch === "number" ? v.epoch : 0;
82
+ if (e > epoch) {
83
+ epoch = e;
84
+ start = v.at;
85
+ } else if (e === epoch && v.at < (start ?? Infinity)) {
86
+ start = v.at;
87
+ }
88
+ });
89
+ return { start, epoch: Math.max(0, epoch) };
90
+ }, [map]);
91
+
92
+ const [startedAt, setStartedAt] = useState<number | undefined>(() => reduce().start);
93
+
94
+ useEffect(() => {
95
+ const apply = () => setStartedAt(reduce().start);
96
+ map.observe(apply);
97
+ apply();
98
+ // claim our own cell at the current epoch, only we ever write this key
99
+ if (canWrite && !map.has(key)) map.set(key, { at: Date.now(), epoch: reduce().epoch });
100
+ return () => map.unobserve(apply);
101
+ }, [map, key, canWrite, reduce]);
102
+
103
+ const reset = useCallback(() => {
104
+ if (canWrite) map.set(key, { at: Date.now(), epoch: reduce().epoch + 1 });
105
+ }, [map, key, canWrite, reduce]);
106
+
107
+ return { startedAt: startedAt ?? Date.now(), reset };
108
+ }
109
+
110
+ function Label({ children, dot }: { children: ReactNode; dot?: boolean }) {
111
+ return (
112
+ <div className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.3em] text-muted">
113
+ {dot && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary shadow-[0_0_8px_var(--brand-primary)]" />}
114
+ {children}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ // Fills its parent box; ScaledStage letterboxes the 16:9 slide inside. The PARENT
120
+ // decides the size, so previews shrink to fit available height (never push the
121
+ // notes off-screen). Rendered live (not a thumbnail), the presenter's current/next
122
+ // previews are only two slides, so this stays cheap while staying pixel-crisp and
123
+ // reflecting live plugin state, unlike the static build-time thumbnails.
124
+ function Thumb({ Component, interactive = true }: { Component?: ComponentType; interactive?: boolean }) {
125
+ return (
126
+ <div
127
+ className={`relative h-full w-full overflow-hidden rounded-2xl border border-border bg-bg shadow-[0_20px_60px_-20px_rgba(0,0,0,0.8)] ${interactive ? "" : "pointer-events-none"}`}
128
+ >
129
+ <MDXProvider components={mdxComponents}>
130
+ <PersistentProvider>
131
+ <BreakoutAllowedContext.Provider value={false}>
132
+ <ScaledStage className="absolute inset-0">
133
+ <SlideFrame still>{Component ? <Component /> : null}</SlideFrame>
134
+ </ScaledStage>
135
+ </BreakoutAllowedContext.Provider>
136
+ </PersistentProvider>
137
+ </MDXProvider>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ // Prominent step/reveal progress, readable from a distance. Makes it obvious why
143
+ // the slide isn't advancing yet: there are still reveals left on this slide.
144
+ function StepIndicator({ step, total }: { step: number; total: number }) {
145
+ const revealing = step < total;
146
+ const remaining = total - step;
147
+ return (
148
+ <div
149
+ className={`flex shrink-0 flex-col gap-2 rounded-xl border px-4 py-3 transition-colors ${
150
+ revealing ? "border-accent/60 bg-accent/10" : "border-border bg-surface/30"
151
+ }`}
152
+ >
153
+ <div className="flex items-center justify-between gap-3">
154
+ <span
155
+ className={`flex items-center gap-2 font-mono text-sm font-semibold uppercase tracking-[0.18em] ${
156
+ revealing ? "text-accent" : "text-muted"
157
+ }`}
158
+ >
159
+ {revealing && <span className="h-2 w-2 animate-pulse rounded-full bg-accent shadow-[0_0_8px_var(--brand-accent)]" />}
160
+ {revealing ? "Revealing steps" : "All steps shown"}
161
+ </span>
162
+ <span className="font-mono text-2xl font-semibold tabular-nums text-text">
163
+ {step}
164
+ <span className="text-muted">/{total}</span>
165
+ </span>
166
+ </div>
167
+ {/* segmented bar, one segment per step, filled up to the current reveal */}
168
+ <div className="flex gap-1.5">
169
+ {Array.from({ length: total }).map((_, i) => (
170
+ <span
171
+ key={i}
172
+ className={`h-2.5 flex-1 rounded-full transition-colors ${i < step ? "bg-accent" : "bg-border"}`}
173
+ />
174
+ ))}
175
+ </div>
176
+ <span className="font-mono text-xs tracking-wide text-muted">
177
+ {revealing
178
+ ? `${remaining} more ${remaining === 1 ? "reveal" : "reveals"}, then Next advances the slide`
179
+ : "Next advances to the following slide"}
180
+ </span>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ export function PresenterView({ slides, brands = ["default"], title = "liebstoeckel" }: DeckProps) {
186
+ const norm = useMemo(() => normalizeSlides(slides), [slides]);
187
+ // Same controller selection as the Deck: shared doc when live, else BroadcastChannel.
188
+ const liveCtx = useLive();
189
+ const live = !!liveCtx?.live;
190
+ const fallbackDoc = useMemo(() => new Y.Doc(), []);
191
+ const sync = useDeckSync(norm.length);
192
+ const liveDeck = useLiveDeck(liveCtx?.doc ?? fallbackDoc, norm.length, liveCtx?.role !== "viewer");
193
+ const ctrl = live ? liveDeck : sync;
194
+ const { index, step, total, setIndex, next, prev } = ctrl;
195
+
196
+ // Share both links (Q / the header button), live only. The viewer link is
197
+ // injected; the presenter link is this window's own URL minus the #presenter
198
+ // hash (it loaded with ?t=<presenterToken>), scanning it drives from a phone.
199
+ const [share, setShare] = useState(false);
200
+ const presenterUrl = useMemo(
201
+ () => (typeof location !== "undefined" ? location.origin + location.pathname + location.search : undefined),
202
+ [],
203
+ );
204
+ useDeckNav({ count: norm.length, setIndex, onNext: next, onPrev: prev, onQr: live ? () => setShare((v) => !v) : undefined });
205
+ useTouchNav({ enabled: true, onNext: next, onPrev: prev });
206
+ const now = useNow();
207
+ // Talk timer: the START is shared via the doc (conflict-free per-client cells,
208
+ // ADR 0030) so every presenter surface agrees; standalone falls back to the
209
+ // per-window doc. Only drivers write.
210
+ const timerDoc = liveCtx?.doc ?? fallbackDoc;
211
+ const canWriteTimer = !live || liveCtx?.role !== "viewer";
212
+ const { startedAt, reset: resetTimer } = usePresenterStart(timerDoc, canWriteTimer);
213
+
214
+ // Phone presenter (ADR 0027): a notes-first confidence monitor + remote. Keep the
215
+ // screen awake, and offer a way back to the audience deck (drop the #presenter
216
+ // hash → Present re-selects the Deck at mount).
217
+ const coarse = useCoarsePointer();
218
+ useWakeLock(coarse);
219
+ const backToSlides = () => {
220
+ if (typeof location !== "undefined") location.assign(location.pathname + location.search);
221
+ };
222
+
223
+ // Focus mode (ADR 0032): full-bleed the active pane (notes / a plugin console),
224
+ // hiding the slide previews. Nav + timer + the tab strip stay pinned. `z` toggles,
225
+ // `Esc` restores; both guard against firing while typing in a console input.
226
+ const [focused, setFocused] = useState(false);
227
+ const toggleFocus = useCallback(() => setFocused((v) => !v), []);
228
+ useEffect(() => {
229
+ const onKey = (e: KeyboardEvent) => {
230
+ const el = document.activeElement as HTMLElement | null;
231
+ const editable = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable);
232
+ if (e.key === "Escape" && focused) {
233
+ setFocused(false);
234
+ return;
235
+ }
236
+ if (e.key === "z" && !editable) {
237
+ e.preventDefault();
238
+ toggleFocus();
239
+ }
240
+ };
241
+ window.addEventListener("keydown", onKey);
242
+ return () => window.removeEventListener("keydown", onKey);
243
+ }, [focused, toggleFocus]);
244
+
245
+ useEffect(() => {
246
+ document.body.dataset.brand = brands[0];
247
+ }, [brands]);
248
+
249
+ const Current = norm[index]?.Component;
250
+ const Next = norm[index + 1]?.Component;
251
+ const notes = norm[index]?.notes;
252
+ const wall = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
253
+ const elapsed = fmtElapsed(now - startedAt);
254
+ const count = norm.length;
255
+ // Nothing left to advance to: last slide AND all reveals shown (a remaining reveal
256
+ // still makes Next a "Reveal →"). Symmetric for Prev at the very start.
257
+ const atEnd = index >= count - 1 && step >= total;
258
+ const atStart = index <= 0 && step <= 0;
259
+ const noNotes = <span className="text-muted">, no notes for this slide, </span>;
260
+ const shareOverlay = (
261
+ <PresenterShare open={share} viewerUrl={liveCtx?.viewerUrl} presenterUrl={presenterUrl} onClose={() => setShare(false)} />
262
+ );
263
+
264
+ // ── Mobile: notes-first stack (ADR 0027) ───────────────────────────────────
265
+ // Notes dominate; a compact next+reveal peek; big thumb-zone Prev/Next. Vertical
266
+ // scroll moves the notes, horizontal swipe (useTouchNav) changes slides, the two
267
+ // axes don't collide, so notes scroll can't misfire a slide change.
268
+ if (coarse) {
269
+ return (
270
+ <div className="flex h-dvh w-screen flex-col bg-bg font-body text-text">
271
+ {shareOverlay}
272
+ {/* 1 · slim status bar */}
273
+ <div
274
+ className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-2"
275
+ style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
276
+ >
277
+ <button onClick={backToSlides} aria-label="Back to slides" className="font-mono text-xs uppercase tracking-wider text-muted transition active:text-text">
278
+ ‹ slides
279
+ </button>
280
+ <span className="font-mono text-sm tabular-nums text-text">
281
+ {String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
282
+ </span>
283
+ <div className="flex items-center gap-3">
284
+ <button onClick={resetTimer} aria-label="Reset timer" className="font-mono text-sm tabular-nums text-primary">
285
+ {elapsed}
286
+ </button>
287
+ {live && (
288
+ <button onClick={() => setShare((v) => !v)} aria-label="Share session links" className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-muted transition active:text-accent">
289
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
290
+ <rect x="3" y="3" width="7" height="7" rx="1" />
291
+ <rect x="14" y="3" width="7" height="7" rx="1" />
292
+ <rect x="3" y="14" width="7" height="7" rx="1" />
293
+ <path d="M14 14h3M20 14v3M14 20h3M20 20h.01M17 17v.01" />
294
+ </svg>
295
+ </button>
296
+ )}
297
+ </div>
298
+ </div>
299
+
300
+ {/* 2 · dominant region: notes (default) + one tab per plugin console (ADR 0031),
301
+ with a maximize toggle (ADR 0032) */}
302
+ <PresenterPanel variant="mobile" notes={notes ?? noNotes} focused={focused} onToggleFocus={toggleFocus} />
303
+
304
+ {/* 3 · compact next + reveal peek, reclaimed by focus mode */}
305
+ {!focused && (
306
+ <div className="flex shrink-0 items-center gap-3 border-t border-border px-4 py-2">
307
+ <div className="h-12 w-[5.5rem] shrink-0 opacity-80">
308
+ {Next ? <Thumb Component={Next} interactive={false} /> : <div className="h-full w-full rounded-md border border-dashed border-border" />}
309
+ </div>
310
+ <div className="min-w-0 flex-1 font-mono text-[11px]">
311
+ <div className="uppercase tracking-[0.2em] text-muted">{Next ? "next up" : "end of deck"}</div>
312
+ {total > 0 && (
313
+ <div className={step < total ? "text-accent" : "text-muted"}>
314
+ {step < total ? `revealing ${step} / ${total}, Next reveals` : "Next → following slide"}
315
+ </div>
316
+ )}
317
+ </div>
318
+ </div>
319
+ )}
320
+
321
+ {/* 4 · thumb-zone controls */}
322
+ <div
323
+ className="flex shrink-0 items-stretch gap-3 px-4 pt-2"
324
+ style={{ paddingBottom: "calc(0.85rem + env(safe-area-inset-bottom))" }}
325
+ >
326
+ <button
327
+ onClick={prev}
328
+ disabled={atStart}
329
+ aria-label="Previous"
330
+ className="flex-1 rounded-xl border border-border py-4 font-mono text-lg text-muted transition active:border-text active:text-text disabled:opacity-40"
331
+ >
332
+
333
+ </button>
334
+ <button
335
+ onClick={next}
336
+ disabled={atEnd}
337
+ className="flex-[2.4] rounded-xl bg-primary py-4 font-mono text-base font-semibold uppercase tracking-widest text-on-primary transition active:brightness-110 disabled:opacity-40"
338
+ >
339
+ {step < total ? "Reveal →" : "Next →"}
340
+ </button>
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ── Desktop: two-column confidence monitor ──────────────────────────────────
347
+ // Prev/Next, reused in the current column (normal) and pinned under the panel
348
+ // (focus mode) so navigation is never hidden (ADR 0032).
349
+ const navRow = (
350
+ <div className="flex shrink-0 items-center gap-3">
351
+ <button
352
+ onClick={prev}
353
+ disabled={atStart}
354
+ className="flex-1 rounded-xl border border-border py-3 font-mono text-sm uppercase tracking-widest text-muted transition hover:border-text hover:text-text disabled:opacity-40 disabled:hover:border-border disabled:hover:text-muted"
355
+ >
356
+ ← Prev
357
+ </button>
358
+ <button
359
+ onClick={next}
360
+ disabled={atEnd}
361
+ className="flex-[2] rounded-xl bg-primary py-3 font-mono text-sm font-semibold uppercase tracking-widest text-on-primary transition hover:brightness-110 disabled:opacity-40 disabled:hover:brightness-100"
362
+ >
363
+ {step < total ? "Reveal →" : "Next →"}
364
+ </button>
365
+ </div>
366
+ );
367
+ return (
368
+ <div className="flex h-screen w-screen flex-col bg-bg font-body text-text">
369
+ {/* top bar */}
370
+ <header className="flex items-center justify-between border-b border-border px-4 py-3 lg:px-8 lg:py-4">
371
+ <div className="flex items-baseline gap-3">
372
+ <span className="font-heading text-xl font-semibold tracking-tight text-text lg:text-2xl">{title}</span>
373
+ <span className="hidden font-mono text-[11px] uppercase tracking-[0.3em] text-muted sm:inline">presenter</span>
374
+ </div>
375
+ <div className="flex items-center gap-4 lg:gap-8">
376
+ <div className="hidden text-right sm:block">
377
+ <div className="font-mono text-[10px] uppercase tracking-[0.25em] text-muted">clock</div>
378
+ <div className="font-mono text-lg tabular-nums text-text">{wall}</div>
379
+ </div>
380
+ <div className="text-right">
381
+ <div className="font-mono text-[10px] uppercase tracking-[0.25em] text-muted">elapsed</div>
382
+ <div className="font-mono text-2xl font-medium tabular-nums text-primary lg:text-3xl">{fmtElapsed(now - startedAt)}</div>
383
+ </div>
384
+ <button
385
+ onClick={resetTimer}
386
+ className="rounded-lg border border-border px-3 py-2 font-mono text-xs uppercase tracking-wider text-muted transition hover:border-primary hover:text-primary"
387
+ >
388
+ reset
389
+ </button>
390
+ {live && (
391
+ <button
392
+ onClick={() => setShare((v) => !v)}
393
+ title="Share links (Q), viewer + presenter QR"
394
+ aria-label="Share session links"
395
+ className="flex h-9 w-9 items-center justify-center rounded-lg border border-border text-muted transition hover:border-accent hover:text-accent"
396
+ >
397
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
398
+ <rect x="3" y="3" width="7" height="7" rx="1" />
399
+ <rect x="14" y="3" width="7" height="7" rx="1" />
400
+ <rect x="3" y="14" width="7" height="7" rx="1" />
401
+ <path d="M14 14h3M20 14v3M14 20h3M20 20h.01M17 17v.01" />
402
+ </svg>
403
+ </button>
404
+ )}
405
+ </div>
406
+ </header>
407
+
408
+ {shareOverlay}
409
+
410
+ {/* main: flex row; each column is a min-h-0 flex-col so inner regions can
411
+ shrink. Thumbnails get capped/flexible heights; the notes panel always
412
+ keeps a guaranteed, scrollable minimum. */}
413
+ <div className="flex min-h-0 flex-1 flex-col gap-5 p-5 lg:flex-row lg:gap-7 lg:p-7">
414
+ {/* current, hidden in focus mode (it's on the room's screen anyway) */}
415
+ {!focused && (
416
+ <section className="flex min-h-0 min-w-0 flex-col gap-3 lg:flex-[1.55]">
417
+ <Label dot>
418
+ On screen · {String(index + 1).padStart(2, "0")} / {String(norm.length).padStart(2, "0")}
419
+ </Label>
420
+ <div className="h-[30vh] min-h-0 min-w-0 lg:h-auto lg:flex-1">
421
+ <Thumb Component={Current} />
422
+ </div>
423
+ {total > 0 && <StepIndicator step={step} total={total} />}
424
+ {navRow}
425
+ </section>
426
+ )}
427
+
428
+ {/* next + notes (full width in focus mode) */}
429
+ <aside className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
430
+ {/* next preview hidden on phones to give notes the room, and in focus mode.
431
+ The height share (basis) sits on THIS wrapper, a flex child of the aside,
432
+ which has a definite height, so the inner preview can be flex-1 and fill
433
+ it. (A % basis on an auto-height parent doesn't resolve, which collapsed
434
+ the box to its min-height and made the preview render tiny.) */}
435
+ {!focused && (
436
+ <div className="hidden min-h-[120px] shrink basis-[34%] flex-col gap-3 lg:flex">
437
+ <Label>{Next ? "Next up" : "End of deck"}</Label>
438
+ <div className="min-h-0 min-w-0 flex-1 opacity-80">
439
+ {Next ? <Thumb Component={Next} interactive={false} /> : <div className="h-full w-full rounded-2xl border border-dashed border-border" />}
440
+ </div>
441
+ </div>
442
+ )}
443
+
444
+ {/* notes (default tab) + one tab per plugin console (ADR 0031), with the
445
+ maximize toggle (ADR 0032). */}
446
+ <PresenterPanel variant="desktop" notes={notes ?? noNotes} focused={focused} onToggleFocus={toggleFocus} />
447
+ {/* pin nav under the panel when the current column (which normally holds it)
448
+ is hidden by focus mode */}
449
+ {focused && navRow}
450
+ </aside>
451
+ </div>
452
+ </div>
453
+ );
454
+ }
@@ -0,0 +1,151 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import * as Y from "yjs";
3
+ import { MDXProvider } from "@mdx-js/react";
4
+ import { mdxComponents } from "@liebstoeckel/components";
5
+ import { useTheme } from "@liebstoeckel/plugin-ui";
6
+ import type { PluginDef } from "@liebstoeckel/plugin-sdk";
7
+ import { STAGE_H, STAGE_W, SlideFrame } from "./Stage";
8
+ import { PersistentProvider } from "./PersistentLayer";
9
+ import { StepsProvider } from "./steps";
10
+ import { LiveProvider, type LiveContextValue } from "./live/Plugin";
11
+ import { normalizeSlides } from "./slides";
12
+ import type { DeckProps } from "./Deck";
13
+ import {
14
+ PRINT_READY,
15
+ PRINT_SELECT_EVENT,
16
+ SLIDE_COUNT,
17
+ printRequest,
18
+ type PrintSelect,
19
+ } from "./build/capture-protocol";
20
+
21
+ // Past any real slide's step count, so every <Step> is revealed for a complete,
22
+ // final-state page (reveals start at target, <Step> uses initial={false}).
23
+ const ALL_STEPS = 1e6;
24
+
25
+ /** Build-time **print** render (vector PDF export): every selected slide laid out
26
+ * at the native 1280×720 canvas, each forced onto its own print page, so a single
27
+ * headless `page.pdf()` produces a multi-page, text-preserving PDF. No nav, no
28
+ * AnimatePresence, no scaling, final state only. Driven via the print protocol:
29
+ * publishes SLIDE_COUNT, renders the indices it's handed (PRINT_SELECT_EVENT), and
30
+ * echoes the select token into PRINT_READY once that selection has painted.
31
+ *
32
+ * Each slide gets its own PersistentProvider: persistent travel (ADR 0007) is a
33
+ * live-nav concept, so in a static all-slides layout each slide just renders its
34
+ * own elements in final position. */
35
+ export function PrintView({ slides, brands = ["default"], plugins = [] }: DeckProps) {
36
+ const norm = useMemo(() => normalizeSlides(slides), [slides]);
37
+
38
+ // Offline (live:false) plugin context → each <Plugin> renders its fallback, same
39
+ // as opening the standalone .html (mirrors CaptureView).
40
+ const theme = useTheme();
41
+ const doc = useMemo(() => new Y.Doc(), []);
42
+ const liveValue = useMemo<LiveContextValue>(
43
+ () => ({
44
+ live: false,
45
+ role: "presenter",
46
+ participant: "print",
47
+ doc,
48
+ theme,
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
+ plugins: Object.fromEntries(plugins.map((p) => [p.id, p])) as Record<string, PluginDef<any>>,
51
+ }),
52
+ [doc, theme, plugins],
53
+ );
54
+
55
+ // The slides to lay out, and the token to echo once they've painted. The driver
56
+ // sends a PRINT_SELECT once it knows the count; until then, honor a flag-supplied
57
+ // list (or all slides) so a standalone print also works.
58
+ const initial = useMemo(() => printRequest()?.indices, []);
59
+ const [sel, setSel] = useState<PrintSelect>(() => ({
60
+ indices: initial && initial.length ? initial : norm.map((_, i) => i),
61
+ token: 0,
62
+ }));
63
+
64
+ useEffect(() => {
65
+ document.body.dataset.brand = brands[0];
66
+ }, [brands]);
67
+
68
+ // Publish the slide count and follow the driver's selection.
69
+ useEffect(() => {
70
+ const g = globalThis as Record<string, unknown>;
71
+ g[SLIDE_COUNT] = norm.length;
72
+ const onSelect = (e: Event) => {
73
+ const detail = (e as CustomEvent<PrintSelect>).detail;
74
+ if (detail && Array.isArray(detail.indices)) setSel(detail);
75
+ };
76
+ window.addEventListener(PRINT_SELECT_EVENT, onSelect as EventListener);
77
+ return () => window.removeEventListener(PRINT_SELECT_EVENT, onSelect as EventListener);
78
+ }, [norm.length]);
79
+
80
+ const chosen = useMemo(
81
+ () => sel.indices.filter((i) => Number.isInteger(i) && i >= 0 && i < norm.length),
82
+ [sel.indices, norm.length],
83
+ );
84
+
85
+ // Signal "this selection has painted" after two animation frames + the deck's
86
+ // fonts, so the exporter prints a settled document. Reset first so a stale token
87
+ // is never read.
88
+ useEffect(() => {
89
+ const g = globalThis as Record<string, unknown>;
90
+ g[PRINT_READY] = -1;
91
+ let raf1 = 0;
92
+ let raf2 = 0;
93
+ const fonts = (document as unknown as { fonts?: { ready?: Promise<unknown> } }).fonts?.ready;
94
+ Promise.resolve(fonts).then(() => {
95
+ raf1 = requestAnimationFrame(() => {
96
+ raf2 = requestAnimationFrame(() => {
97
+ g[PRINT_READY] = sel.token;
98
+ });
99
+ });
100
+ });
101
+ return () => {
102
+ cancelAnimationFrame(raf1);
103
+ cancelAnimationFrame(raf2);
104
+ };
105
+ }, [sel.token, chosen.length]);
106
+
107
+ return (
108
+ <LiveProvider value={liveValue}>
109
+ <MDXProvider components={mdxComponents}>
110
+ {/* one page per slide; exact-size, clipped, page-broken. white page gutter
111
+ never shows because each block fills the page (margin:0 in page.pdf). */}
112
+ <style>{`@page { size: ${STAGE_W}px ${STAGE_H}px; margin: 0 }
113
+ /* The deck is fullscreen (theme sets html,body{height:100%;overflow:hidden}). For
114
+ print we must let the document GROW so the stacked slides paginate, otherwise it
115
+ clips to one viewport and only slide 1 prints. */
116
+ html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0; padding: 0; background: #fff }
117
+ #root { height: auto !important; overflow: visible !important; position: static !important }
118
+ [data-print-page] { break-inside: avoid }
119
+ /* The film-grain feTurbulence rasterizes to a ~20MB full-page bitmap per slide in
120
+ print, invisible noise, enormous cost. Drop it; charts/gradients stay vector. */
121
+ [data-atmosphere-grain] { display: none !important }`}</style>
122
+ <div data-print-root>
123
+ {chosen.map((idx, n) => {
124
+ const Current = norm[idx]?.Component ?? (() => null);
125
+ return (
126
+ <div
127
+ key={idx}
128
+ data-print-page
129
+ style={{
130
+ position: "relative",
131
+ width: STAGE_W,
132
+ height: STAGE_H,
133
+ overflow: "hidden",
134
+ breakAfter: n < chosen.length - 1 ? "page" : "auto",
135
+ }}
136
+ >
137
+ <PersistentProvider>
138
+ <SlideFrame still>
139
+ <StepsProvider step={ALL_STEPS} slideIndex={idx}>
140
+ <Current />
141
+ </StepsProvider>
142
+ </SlideFrame>
143
+ </PersistentProvider>
144
+ </div>
145
+ );
146
+ })}
147
+ </div>
148
+ </MDXProvider>
149
+ </LiveProvider>
150
+ );
151
+ }