@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,235 @@
1
+ import { createContext, useContext, useEffect, useState, type CSSProperties, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { motion } from "motion/react";
4
+ import { StageScaleContext } from "../Stage";
5
+ import { useCoarsePointer } from "../useCoarsePointer";
6
+ import { breakoutEligible } from "../mobile";
7
+
8
+ /** Set false to suppress the touch breakout for plugins in a subtree (e.g. the
9
+ * presenter's non-interactive slide preview). */
10
+ export const BreakoutAllowedContext = createContext(true);
11
+
12
+ /** Whether a plugin should offer tap-to-expand instead of inline interaction. */
13
+ export function useBreakoutEligible(interactive: boolean): boolean {
14
+ const scale = useContext(StageScaleContext);
15
+ const allowed = useContext(BreakoutAllowedContext);
16
+ const coarse = useCoarsePointer();
17
+ return breakoutEligible({ allowed, coarse, scale, interactive });
18
+ }
19
+
20
+ /** A pulsing brand-accent ring + "tap to interact" pill around a (non-interactive)
21
+ * plugin preview. Tapping anywhere opens the full-size breakout. */
22
+ export function GlowTap({ label, onOpen, children }: { label: string; onOpen: () => void; children: ReactNode }) {
23
+ return (
24
+ <div
25
+ role="button"
26
+ tabIndex={0}
27
+ aria-label={`${label}, tap to interact`}
28
+ data-pi-no-nav
29
+ onClick={onOpen}
30
+ onKeyDown={(e) => {
31
+ if (e.key === "Enter" || e.key === " ") {
32
+ e.preventDefault();
33
+ onOpen();
34
+ }
35
+ }}
36
+ style={{ position: "relative", cursor: "pointer", borderRadius: "1.1rem", display: "inline-block", maxWidth: "100%" }}
37
+ >
38
+ <motion.span
39
+ aria-hidden
40
+ style={{
41
+ position: "absolute",
42
+ inset: "-3px",
43
+ borderRadius: "1.2rem",
44
+ border: "1px solid var(--brand-accent, #e0c580)",
45
+ boxShadow: "0 0 26px -4px var(--brand-accent, #e0c580)",
46
+ pointerEvents: "none",
47
+ }}
48
+ animate={{ opacity: [0.45, 1, 0.45] }}
49
+ transition={{ duration: 2.2, repeat: Infinity, ease: "easeInOut" }}
50
+ />
51
+ <div style={{ pointerEvents: "none" }}>{children}</div>
52
+ <span
53
+ style={{
54
+ position: "absolute",
55
+ bottom: "-0.7rem",
56
+ left: "50%",
57
+ transform: "translateX(-50%)",
58
+ whiteSpace: "nowrap",
59
+ padding: "0.25rem 0.7rem",
60
+ borderRadius: "999px",
61
+ background: "var(--brand-accent, #e0c580)",
62
+ color: "var(--brand-on-primary, #10140e)",
63
+ fontFamily: "var(--brand-font-mono, monospace)",
64
+ fontSize: "0.7rem",
65
+ letterSpacing: "0.12em",
66
+ textTransform: "uppercase",
67
+ fontWeight: 600,
68
+ pointerEvents: "none",
69
+ }}
70
+ >
71
+ tap to interact ↗
72
+ </span>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ /** True on a short viewport (landscape phone / small window), switch the breakout
78
+ * from a full-width bottom sheet to a centered, height-capped card so its content
79
+ * doesn't overflow off the bottom. */
80
+ function useShortViewport(): boolean {
81
+ const [short, setShort] = useState(false);
82
+ useEffect(() => {
83
+ if (typeof matchMedia !== "function") return;
84
+ const mq = matchMedia("(max-height: 600px)");
85
+ const update = () => setShort(mq.matches);
86
+ update();
87
+ mq.addEventListener?.("change", update);
88
+ return () => mq.removeEventListener?.("change", update);
89
+ }, []);
90
+ return short;
91
+ }
92
+
93
+ /** Track the **visual viewport**, the region NOT covered by the on-screen keyboard.
94
+ * On mobile the keyboard overlays the layout viewport (`100vh`/`inset:0` don't shrink),
95
+ * so a bottom-anchored sheet ends up behind it. Pinning to `visualViewport` keeps the
96
+ * sheet in the visible area. Returns null when unsupported (desktop / SSR) → caller
97
+ * falls back to full-viewport. */
98
+ function useVisualViewport(): { height: number; offsetTop: number } | null {
99
+ const [vp, setVp] = useState<{ height: number; offsetTop: number } | null>(null);
100
+ useEffect(() => {
101
+ const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
102
+ if (!vv) return;
103
+ const update = () => setVp({ height: vv.height, offsetTop: vv.offsetTop });
104
+ update();
105
+ vv.addEventListener("resize", update);
106
+ vv.addEventListener("scroll", update);
107
+ return () => {
108
+ vv.removeEventListener("resize", update);
109
+ vv.removeEventListener("scroll", update);
110
+ };
111
+ }, []);
112
+ return vp;
113
+ }
114
+
115
+ /** A full-size breakout portaled OUTSIDE the scaled stage, so the plugin's real
116
+ * controls render at device scale with proper tap targets. Same Yjs state. A
117
+ * bottom sheet on tall viewports; a centered, scrollable card on short/landscape
118
+ * ones so the content never runs off-screen. */
119
+ export function BreakoutSheet({ label, onClose, children }: { label: string; onClose: () => void; children: ReactNode }) {
120
+ const short = useShortViewport();
121
+ const vp = useVisualViewport();
122
+ useEffect(() => {
123
+ const onKey = (e: KeyboardEvent) => {
124
+ if (e.key === "Escape") onClose();
125
+ };
126
+ window.addEventListener("keydown", onKey);
127
+ const prev = document.body.style.overflow;
128
+ document.body.style.overflow = "hidden";
129
+ return () => {
130
+ window.removeEventListener("keydown", onKey);
131
+ document.body.style.overflow = prev;
132
+ };
133
+ }, [onClose]);
134
+
135
+ if (typeof document === "undefined") return null;
136
+
137
+ return createPortal(
138
+ <motion.div
139
+ onClick={onClose}
140
+ initial={{ opacity: 0 }}
141
+ animate={{ opacity: 1 }}
142
+ exit={{ opacity: 0 }}
143
+ style={{
144
+ // pin to the visual viewport so the on-screen keyboard can't bury the sheet
145
+ // (it overlays the layout viewport; `inset:0` would sit behind it). ADR 0037.
146
+ position: "fixed",
147
+ left: 0,
148
+ right: 0,
149
+ top: vp ? vp.offsetTop : 0,
150
+ height: vp ? vp.height : "100%",
151
+ zIndex: 9999,
152
+ display: "flex",
153
+ flexDirection: "column",
154
+ justifyContent: short ? "center" : "flex-end",
155
+ alignItems: "center",
156
+ padding: short ? "max(env(safe-area-inset-top), 0.75rem) 0.75rem" : 0,
157
+ background: "color-mix(in srgb, var(--brand-bg, #10140e) 60%, transparent)",
158
+ backdropFilter: "blur(6px)",
159
+ }}
160
+ >
161
+ <motion.div
162
+ data-pi-breakout
163
+ onClick={(e) => e.stopPropagation()}
164
+ initial={{ y: short ? 24 : "100%", opacity: short ? 0 : 1 }}
165
+ animate={{ y: 0, opacity: 1 }}
166
+ exit={{ y: short ? 24 : "100%", opacity: short ? 0 : 1 }}
167
+ transition={{ type: "spring", stiffness: 320, damping: 36 }}
168
+ style={{
169
+ width: "100%",
170
+ maxWidth: short ? "560px" : "720px",
171
+ marginInline: "auto",
172
+ maxHeight: short ? "100%" : "min(88vh, 100%)",
173
+ overflow: "auto",
174
+ padding: short
175
+ ? "0 1.1rem 1.1rem"
176
+ : "0 1.25rem calc(1.5rem + env(safe-area-inset-bottom))",
177
+ borderRadius: short ? "1.2rem" : "1.4rem 1.4rem 0 0",
178
+ background: "var(--brand-surface, #1a2014)",
179
+ border: short
180
+ ? "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))"
181
+ : "none",
182
+ borderTop: "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))",
183
+ boxShadow: "0 -24px 60px -20px rgba(0,0,0,0.7)",
184
+ }}
185
+ >
186
+ {/* sticky header so the close button stays reachable while the body scrolls */}
187
+ <div
188
+ style={{
189
+ position: "sticky",
190
+ top: 0,
191
+ zIndex: 1,
192
+ display: "flex",
193
+ alignItems: "center",
194
+ justifyContent: "space-between",
195
+ padding: "1.1rem 0 0.9rem",
196
+ marginBottom: "0.4rem",
197
+ background: "var(--brand-surface, #1a2014)",
198
+ }}
199
+ >
200
+ <span
201
+ style={{
202
+ fontFamily: "var(--brand-font-mono, monospace)",
203
+ fontSize: "0.7rem",
204
+ letterSpacing: "0.22em",
205
+ textTransform: "uppercase",
206
+ color: "var(--brand-muted, #9da28c)",
207
+ }}
208
+ >
209
+ {label}
210
+ </span>
211
+ <button
212
+ onClick={onClose}
213
+ aria-label="close"
214
+ style={{
215
+ appearance: "none",
216
+ cursor: "pointer",
217
+ border: "none",
218
+ background: "transparent",
219
+ color: "var(--brand-muted, #9da28c)",
220
+ fontSize: "1.2rem",
221
+ lineHeight: 1,
222
+ padding: "0.25rem 0.5rem",
223
+ }}
224
+ >
225
+
226
+ </button>
227
+ </div>
228
+ {/* shrink the plugin's content a touch on short viewports so it fits with
229
+ less scrolling (zoom reflows, unlike transform) */}
230
+ <div style={short ? ({ zoom: 0.85 } as CSSProperties) : undefined}>{children}</div>
231
+ </motion.div>
232
+ </motion.div>,
233
+ document.body,
234
+ );
235
+ }
@@ -0,0 +1,149 @@
1
+ import * as Y from "yjs";
2
+ import type { LiveInfo } from "./detect";
3
+
4
+ export interface LiveConnection {
5
+ doc: Y.Doc;
6
+ onStatus(cb: (connected: boolean) => void): void;
7
+ close(): void;
8
+ }
9
+
10
+ export interface ConnectOptions {
11
+ WS?: typeof WebSocket;
12
+ /** base reconnect delay (ms); backs off exponentially, capped */
13
+ reconnectBaseMs?: number;
14
+ reconnectMaxMs?: number;
15
+ /** force-reconnect if no frame (incl. server keepalives) arrives within this
16
+ * window, detects half-open sockets the browser won't `close`. 0 = disabled. */
17
+ staleMs?: number;
18
+ /** After this many consecutive failed (re)connect attempts the session is likely
19
+ * *gone* (re-provisioned to a new relay session) rather than a transient blip, so
20
+ * retrying the same URL will 403 forever (ADR 0071 §5 / ticket 0018). Fire
21
+ * `onUnrecoverable` once to recover via the stable link. 0 = never (default). */
22
+ reloadAfterAttempts?: number;
23
+ /** Recovery action when the session looks gone. Default: reload the page (which
24
+ * re-resolves the stable `/live/:slug` → the new relay session) where possible. */
25
+ onUnrecoverable?: () => void;
26
+ }
27
+
28
+ /** Connect a Yjs doc to the live server over WebSocket, with auto-reconnect and a
29
+ * liveness watchdog. On (re)open it pushes local state up and the server replies
30
+ * with the full session state; thereafter updates flow both ways. Survives
31
+ * network blips and silently-dead (half-open) connections. */
32
+ export function connectLive(info: LiveInfo, participant: string, opts: ConnectOptions = {}): LiveConnection {
33
+ const WS = opts.WS ?? WebSocket;
34
+ const baseMs = opts.reconnectBaseMs ?? 1000;
35
+ const maxMs = opts.reconnectMaxMs ?? 15000;
36
+ const staleMs = opts.staleMs ?? 60000;
37
+ const reloadAfter = opts.reloadAfterAttempts ?? 0;
38
+ const onUnrecoverable =
39
+ opts.onUnrecoverable ??
40
+ (() => {
41
+ if (typeof location !== "undefined") location.reload();
42
+ });
43
+ let escalated = false;
44
+ const doc = new Y.Doc();
45
+ const sep = info.ws.includes("?") ? "&" : "?";
46
+ const url = `${info.ws}${sep}p=${encodeURIComponent(participant)}`;
47
+ const statusCbs: Array<(c: boolean) => void> = [];
48
+ const emit = (c: boolean) => statusCbs.forEach((cb) => cb(c));
49
+
50
+ let ws: WebSocket | null = null;
51
+ let closed = false;
52
+ let attempt = 0;
53
+ let timer: ReturnType<typeof setTimeout> | undefined;
54
+ let lastMsgAt = Date.now();
55
+ let watchdog: ReturnType<typeof setInterval> | undefined;
56
+
57
+ const onUpdate = (update: Uint8Array, origin: unknown) => {
58
+ if (origin !== "remote" && ws && ws.readyState === ws.OPEN) ws.send(new Uint8Array(update));
59
+ };
60
+ doc.on("update", onUpdate);
61
+
62
+ const schedule = () => {
63
+ if (closed) return;
64
+ // Persistent failure → the session is likely gone (re-provisioned). Stop hammering
65
+ // the dead URL and escalate to stable-link recovery exactly once (ticket 0018).
66
+ if (reloadAfter > 0 && attempt >= reloadAfter && !escalated) {
67
+ escalated = true;
68
+ onUnrecoverable();
69
+ return;
70
+ }
71
+ const delay = Math.min(baseMs * 2 ** attempt, maxMs);
72
+ attempt++;
73
+ timer = setTimeout(open, delay);
74
+ };
75
+
76
+ // watchdog: if the socket is OPEN but we've heard nothing within staleMs, the
77
+ // connection is likely half-open, drop it so `close` triggers a reconnect.
78
+ if (staleMs > 0) {
79
+ const period = Math.min(Math.max(Math.floor(staleMs / 3), 20), 30000);
80
+ watchdog = setInterval(() => {
81
+ if (closed || !ws || ws.readyState !== ws.OPEN) return;
82
+ if (Date.now() - lastMsgAt > staleMs) {
83
+ try {
84
+ ws.close();
85
+ } catch {
86
+ /* close handler reconnects */
87
+ }
88
+ }
89
+ }, period);
90
+ (watchdog as { unref?: () => void }).unref?.();
91
+ }
92
+
93
+ function open() {
94
+ if (closed) return;
95
+ const sock = new WS(url);
96
+ ws = sock;
97
+ sock.binaryType = "arraybuffer";
98
+ sock.addEventListener("open", () => {
99
+ attempt = 0;
100
+ lastMsgAt = Date.now();
101
+ try {
102
+ sock.send(new Uint8Array(Y.encodeStateAsUpdate(doc)));
103
+ } catch {
104
+ /* ignore */
105
+ }
106
+ emit(true);
107
+ });
108
+ sock.addEventListener("message", (e: MessageEvent) => {
109
+ lastMsgAt = Date.now(); // any frame (update or keepalive) proves liveness
110
+ try {
111
+ Y.applyUpdate(doc, new Uint8Array(e.data as ArrayBuffer), "remote");
112
+ } catch {
113
+ /* ignore malformed frame */
114
+ }
115
+ });
116
+ sock.addEventListener("close", () => {
117
+ emit(false);
118
+ schedule();
119
+ });
120
+ sock.addEventListener("error", () => {
121
+ try {
122
+ sock.close();
123
+ } catch {
124
+ /* the close handler will reconnect */
125
+ }
126
+ });
127
+ }
128
+
129
+ open();
130
+
131
+ return {
132
+ doc,
133
+ onStatus(cb) {
134
+ statusCbs.push(cb);
135
+ },
136
+ close() {
137
+ closed = true;
138
+ if (timer) clearTimeout(timer);
139
+ if (watchdog) clearInterval(watchdog);
140
+ doc.off("update", onUpdate);
141
+ try {
142
+ ws?.close();
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ doc.destroy();
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,77 @@
1
+ import * as Y from "yjs";
2
+ import { useEffect, useState } from "react";
3
+ import { stepForward, stepBack } from "../delivery";
4
+
5
+ const clampN = (n: number, count: number) => Math.min(Math.max(n, 0), Math.max(count - 1, 0));
6
+
7
+ export const getDeckIndex = (doc: Y.Doc): number => (doc.getMap("deck").get("index") as number) ?? 0;
8
+ export const setDeckIndex = (doc: Y.Doc, n: number): void => {
9
+ doc.getMap("deck").set("index", n);
10
+ };
11
+
12
+ export interface DeckController {
13
+ index: number;
14
+ step: number;
15
+ total: number;
16
+ canDrive: boolean;
17
+ setIndex(u: number | ((n: number) => number)): void;
18
+ setStep(step: number): void;
19
+ setTotal(total: number): void;
20
+ next(): void;
21
+ prev(): void;
22
+ }
23
+
24
+ /** Deck nav state backed by the shared doc: viewers follow, only `canDrive` (the
25
+ * presenter role) writes. Carries index + step + total so reveals follow too. */
26
+ export function useLiveDeck(doc: Y.Doc, count: number, canDrive: boolean): DeckController {
27
+ const map = doc.getMap<number>("deck");
28
+ const read = () => ({
29
+ index: (map.get("index") as number) ?? 0,
30
+ step: (map.get("step") as number) ?? 0,
31
+ total: (map.get("total") as number) ?? 0,
32
+ });
33
+ const [s, setS] = useState(read);
34
+ useEffect(() => {
35
+ const handler = () => setS(read());
36
+ map.observe(handler);
37
+ handler();
38
+ return () => map.unobserve(handler);
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [doc]);
41
+
42
+ const setIndexTo = (n: number) =>
43
+ doc.transact(() => {
44
+ map.set("index", clampN(n, count));
45
+ map.set("step", 0);
46
+ });
47
+
48
+ return {
49
+ ...s,
50
+ canDrive,
51
+ setIndex(u) {
52
+ if (!canDrive) return;
53
+ setIndexTo(typeof u === "function" ? u(read().index) : u);
54
+ },
55
+ setStep(step) {
56
+ if (canDrive) map.set("step", step);
57
+ },
58
+ setTotal(total) {
59
+ if (canDrive && read().total !== total) map.set("total", total);
60
+ },
61
+ // read freshest doc state so rapid presses don't act on a stale step
62
+ next() {
63
+ if (!canDrive) return;
64
+ const cur = read();
65
+ const r = stepForward(cur.step, cur.total);
66
+ if (r.advanceSlide) setIndexTo(cur.index + 1);
67
+ else map.set("step", r.step);
68
+ },
69
+ prev() {
70
+ if (!canDrive) return;
71
+ const cur = read();
72
+ const r = stepBack(cur.step);
73
+ if (r.retreatSlide) setIndexTo(cur.index - 1);
74
+ else map.set("step", r.step);
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,17 @@
1
+ import type { Role } from "@liebstoeckel/plugin-sdk";
2
+
3
+ export interface LiveInfo {
4
+ ws: string;
5
+ session: string;
6
+ role: Role;
7
+ token: string;
8
+ participant?: string;
9
+ /** read-only follow-along link, for the in-deck QR */
10
+ viewer?: string;
11
+ }
12
+
13
+ /** Read the bootstrap the live server injects. Absent → standalone (.html). */
14
+ export function detectLive(): LiveInfo | null {
15
+ const g = globalThis as { __LIEBSTOECKEL_LIVE__?: LiveInfo };
16
+ return g.__LIEBSTOECKEL_LIVE__ ?? null;
17
+ }
@@ -0,0 +1,185 @@
1
+ import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { AnimatePresence, LayoutGroup, motion } from "motion/react";
4
+ import { ChromeButton } from "@liebstoeckel/plugin-ui";
5
+ import { type ClientProps, type GlobalProps, type PluginDef } from "@liebstoeckel/plugin-sdk";
6
+ import { useLive, usePluginProps, type LiveContextValue } from "./Plugin";
7
+ import { PluginBoundary } from "./PluginBoundary";
8
+ import { BreakoutSheet } from "./breakout";
9
+ import { useCoarsePointer } from "../useCoarsePointer";
10
+ import { globalPlugins } from "./globals";
11
+
12
+ /** A row in the touch `⋮` menu (matches DeckChrome's MenuAction). */
13
+ export interface PluginMenuAction {
14
+ key: string;
15
+ label: string;
16
+ icon: ReactNode;
17
+ onClick: () => void;
18
+ }
19
+
20
+ interface PanelController {
21
+ open: boolean;
22
+ toggle: () => void;
23
+ close: () => void;
24
+ }
25
+
26
+ /** A lightweight popover for a global plugin Panel, portaled to device scale and
27
+ * anchored just above the chrome rail (bottom-left), with a transparent outside-
28
+ * click catcher and NO backdrop blur/dim. Unlike the focus-stealing `BreakoutSheet`
29
+ * (used for full interactive breakouts like Q&A), a quick, frequent action such as
30
+ * reacting shouldn't blur the whole deck behind it (ADR 0023). */
31
+ function ChromePopover({ onClose, children }: { onClose: () => void; children: ReactNode }) {
32
+ useEffect(() => {
33
+ const onKey = (e: KeyboardEvent) => {
34
+ if (e.key === "Escape") onClose();
35
+ };
36
+ window.addEventListener("keydown", onKey);
37
+ return () => window.removeEventListener("keydown", onKey);
38
+ }, [onClose]);
39
+
40
+ if (typeof document === "undefined") return null;
41
+
42
+ return createPortal(
43
+ <>
44
+ {/* transparent catcher: outside-tap closes, but the deck stays fully visible */}
45
+ <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 9998, background: "transparent" }} />
46
+ <motion.div
47
+ data-pi-panel
48
+ onClick={(e) => e.stopPropagation()}
49
+ initial={{ opacity: 0, y: 10, scale: 0.98 }}
50
+ animate={{ opacity: 1, y: 0, scale: 1 }}
51
+ exit={{ opacity: 0, y: 10, scale: 0.98 }}
52
+ transition={{ type: "spring", stiffness: 420, damping: 32 }}
53
+ style={{
54
+ position: "fixed",
55
+ zIndex: 9999,
56
+ left: "max(env(safe-area-inset-left), 1rem)",
57
+ bottom: "calc(3.6rem + env(safe-area-inset-bottom))",
58
+ maxWidth: "min(22rem, calc(100vw - 2rem))",
59
+ padding: "1rem 1.15rem 1.15rem",
60
+ borderRadius: "1rem",
61
+ background: "var(--brand-surface, #11141b)",
62
+ border: "1px solid var(--brand-border, #222734)",
63
+ boxShadow: "0 18px 50px -18px rgba(0,0,0,0.7)",
64
+ }}
65
+ >
66
+ {children}
67
+ </motion.div>
68
+ </>,
69
+ document.body,
70
+ );
71
+ }
72
+
73
+ /** One plugin's ambient overlay (e.g. reactions floaters), inside the shared
74
+ * `pointer-events:none` layer. */
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ function OverlayItem({ ctx, id, def }: { ctx: LiveContextValue; id: string; def: PluginDef<any> }) {
77
+ const props = usePluginProps(ctx, id, def);
78
+ const Overlay = def.client.global?.Overlay as ComponentType<ClientProps<unknown>> | undefined;
79
+ // This overlay is mounted deck-wide on every slide: if it throws, it would unmount the
80
+ // whole deck. Contain it so a bad audience-written value can't white-screen everyone.
81
+ return Overlay ? (
82
+ <PluginBoundary resetKey={props.snapshot}>
83
+ <Overlay {...props} />
84
+ </PluginBoundary>
85
+ ) : null;
86
+ }
87
+
88
+ /** Deck-wide overlay layer. Mounted as a child of `[data-deck-root]` so its
89
+ * contents are positioned over the slide; non-interactive by default. */
90
+ export function PluginOverlays() {
91
+ const ctx = useLive();
92
+ if (!ctx?.live) return null;
93
+ const entries = globalPlugins(ctx.plugins).filter((e) => e.def.client.global?.Overlay);
94
+ if (entries.length === 0) return null;
95
+ return (
96
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
97
+ {entries.map(({ id, def }) => (
98
+ <OverlayItem key={id} ctx={ctx} id={id} def={def} />
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /** A plugin's rail trigger: its custom `Control` if it has one, else a `ChromeButton`
105
+ * built from `icon` + `label`. Toggles the shared panel open-state. */
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ function RailTrigger({ ctx, id, def, panel }: { ctx: LiveContextValue; id: string; def: PluginDef<any>; panel: PanelController }) {
108
+ const base = usePluginProps(ctx, id, def);
109
+ const g = def.client.global!;
110
+ const Control = g.Control as ComponentType<GlobalProps<unknown>> | undefined;
111
+ if (Control) return <Control {...base} panel={panel} />;
112
+ return (
113
+ <ChromeButton onClick={panel.toggle} active={panel.open} title={g.label} ariaLabel={g.label}>
114
+ {g.icon}
115
+ </ChromeButton>
116
+ );
117
+ }
118
+
119
+ /** A plugin's panel, hosted centrally so it survives the `⋮` sheet closing and so its
120
+ * open-state can be driven from either the rail or a menu row. A `"sheet"` panel opens
121
+ * full-viewport on touch (keyboard-friendly, ADR 0037); otherwise a popover. */
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ function PanelHost({ ctx, id, def, open, onClose, coarse }: { ctx: LiveContextValue; id: string; def: PluginDef<any>; open: boolean; onClose: () => void; coarse: boolean }) {
124
+ const base = usePluginProps(ctx, id, def);
125
+ const Panel = def.client.global?.Panel as ComponentType<GlobalProps<unknown>> | undefined;
126
+ const gprops: GlobalProps<unknown> = { ...base, panel: { open, toggle: onClose, close: onClose } };
127
+ const sheet = def.client.global?.panelMode === "sheet" && coarse;
128
+ return (
129
+ <AnimatePresence>
130
+ {open &&
131
+ Panel &&
132
+ (sheet ? (
133
+ <BreakoutSheet label={def.client.global?.label ?? id} onClose={onClose}>
134
+ <LayoutGroup id={`plugin-panel:${id}`}>
135
+ <PluginBoundary resetKey={base.snapshot}>
136
+ <Panel {...gprops} />
137
+ </PluginBoundary>
138
+ </LayoutGroup>
139
+ </BreakoutSheet>
140
+ ) : (
141
+ <ChromePopover onClose={onClose}>
142
+ <LayoutGroup id={`plugin-panel:${id}`}>
143
+ <PluginBoundary resetKey={base.snapshot}>
144
+ <Panel {...gprops} />
145
+ </PluginBoundary>
146
+ </LayoutGroup>
147
+ </ChromePopover>
148
+ ))}
149
+ </AnimatePresence>
150
+ );
151
+ }
152
+
153
+ /** Wires the global plugin controls into the chrome (ADR 0038). Returns the rail
154
+ * triggers (pinned + custom on touch, all on desktop), the `⋮` menu rows for the rest
155
+ * (touch only, the rail can't overflow), and the centrally-hosted panels. One panel
156
+ * open at a time. */
157
+ export function usePluginChrome(): { rail: ReactNode; menuActions: PluginMenuAction[]; panels: ReactNode } {
158
+ const ctx = useLive();
159
+ const coarse = useCoarsePointer();
160
+ const [openId, setOpenId] = useState<string | null>(null);
161
+ if (!ctx?.live) return { rail: null, menuActions: [], panels: null };
162
+
163
+ const entries = globalPlugins(ctx.plugins).filter((e) => e.def.client.global?.Panel || e.def.client.global?.Control);
164
+ // desktop: everything inline. touch: pinned (or a custom Control) inline, the rest → ⋮.
165
+ const inRail = (def: (typeof entries)[number]["def"]) => !coarse || !!def.client.global?.pinned || !!def.client.global?.Control;
166
+ const ctrl = (id: string): PanelController => ({
167
+ open: openId === id,
168
+ toggle: () => setOpenId((v) => (v === id ? null : id)),
169
+ close: () => setOpenId(null),
170
+ });
171
+
172
+ const rail = entries
173
+ .filter((e) => inRail(e.def))
174
+ .map(({ id, def }) => <RailTrigger key={id} ctx={ctx} id={id} def={def} panel={ctrl(id)} />);
175
+
176
+ const menuActions: PluginMenuAction[] = entries
177
+ .filter((e) => !inRail(e.def))
178
+ .map(({ id, def }) => ({ key: `plugin:${id}`, label: def.client.global?.label ?? id, icon: def.client.global?.icon ?? null, onClick: () => setOpenId(id) }));
179
+
180
+ const panels = entries.map(({ id, def }) => (
181
+ <PanelHost key={id} ctx={ctx} id={id} def={def} open={openId === id} onClose={() => setOpenId(null)} coarse={coarse} />
182
+ ));
183
+
184
+ return { rail, menuActions, panels };
185
+ }
@@ -0,0 +1,15 @@
1
+ import type { PluginDef } from "@liebstoeckel/plugin-sdk";
2
+
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ type Registry = Record<string, PluginDef<any>>;
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ export type GlobalEntry = { id: string; def: PluginDef<any> };
7
+
8
+ /** The registered plugins that expose a deck-wide `client.global` namespace, in
9
+ * registration order (the order the deck listed them in `plugins={[…]}`). The
10
+ * engine mounts one set of global surfaces per entry. See ADR 0021. */
11
+ export function globalPlugins(registry: Registry): GlobalEntry[] {
12
+ return Object.entries(registry)
13
+ .filter(([, def]) => def.client.global)
14
+ .map(([id, def]) => ({ id, def }));
15
+ }
@@ -0,0 +1,7 @@
1
+ export { detectLive, type LiveInfo } from "./detect";
2
+ export { getParticipantId } from "./participant";
3
+ export { connectLive, type LiveConnection } from "./connect";
4
+ export { getDeckIndex, setDeckIndex, useLiveDeck, type DeckController } from "./deckIndex";
5
+ export { mergeUi } from "./ui";
6
+ export { Plugin, LiveProvider, useLive, type LiveContextValue } from "./Plugin";
7
+ export { BreakoutAllowedContext } from "./breakout";