@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,76 @@
1
+ import { useMemo, type CSSProperties } from "react";
2
+ import { AnimatePresence, motion } from "motion/react";
3
+ import { keySteps } from "./code/diff";
4
+ import type { TokenizedStep } from "./code/types";
5
+ import { useRevealState } from "./steps";
6
+
7
+ // Shiki FontStyle bitfield → CSS.
8
+ function styleOf(color?: string, fontStyle?: number): CSSProperties {
9
+ return {
10
+ color,
11
+ fontStyle: fontStyle && fontStyle & 1 ? "italic" : undefined,
12
+ fontWeight: fontStyle && fontStyle & 2 ? 700 : undefined,
13
+ textDecoration: fontStyle && fontStyle & 4 ? "underline" : undefined,
14
+ };
15
+ }
16
+
17
+ /** Animated code that morphs between states. Each state is pre-tokenized at build
18
+ * time (see the `code` macro). Consecutive states are line-diffed so unchanged
19
+ * lines keep identity and FLIP to their new position while edits fade in/out. The
20
+ * deck's reveal steps drive which state shows (via `useRevealState`); in a static
21
+ * render (thumbnail/standalone) it shows the final state. */
22
+ export function CodeMagic({
23
+ steps,
24
+ title,
25
+ lang,
26
+ }: {
27
+ steps: TokenizedStep[];
28
+ title?: string;
29
+ lang?: string;
30
+ }) {
31
+ const keyed = useMemo(() => keySteps(steps), [steps]);
32
+ const state = useRevealState(steps.length);
33
+ const active = keyed[Math.max(0, Math.min(keyed.length - 1, state))] ?? { lines: [] };
34
+ const language = lang ?? steps[0]?.lang;
35
+
36
+ return (
37
+ <div className="pi-code" data-code>
38
+ <div className="pi-code-bar">
39
+ <span className="pi-code-dots" aria-hidden>
40
+ <i />
41
+ <i />
42
+ <i />
43
+ </span>
44
+ {title && <span className="pi-code-title">{title}</span>}
45
+ {language && <span className="pi-code-lang">{language}</span>}
46
+ </div>
47
+ <pre className="pi-codemagic">
48
+ <code>
49
+ <AnimatePresence mode="popLayout" initial={false}>
50
+ {active.lines.map((line) => (
51
+ <motion.div
52
+ key={line.key}
53
+ layout
54
+ className="pi-code-line"
55
+ initial={{ opacity: 0, x: -10 }}
56
+ animate={{ opacity: 1, x: 0 }}
57
+ exit={{ opacity: 0, x: 10 }}
58
+ transition={{ type: "spring", stiffness: 320, damping: 34 }}
59
+ >
60
+ {line.tokens.length === 0 ? (
61
+ <span>{" "}</span>
62
+ ) : (
63
+ line.tokens.map((tok, i) => (
64
+ <span key={i} style={styleOf(tok.color, tok.fontStyle)}>
65
+ {tok.content}
66
+ </span>
67
+ ))
68
+ )}
69
+ </motion.div>
70
+ ))}
71
+ </AnimatePresence>
72
+ </code>
73
+ </pre>
74
+ </div>
75
+ );
76
+ }
package/src/Deck.tsx ADDED
@@ -0,0 +1,286 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
3
+ import { MDXProvider } from "@mdx-js/react";
4
+ import { mdxComponents } from "@liebstoeckel/components";
5
+ import * as Y from "yjs";
6
+ import type { PluginDef } from "@liebstoeckel/plugin-sdk";
7
+ import { useDeckNav, useTouchNav } from "./nav";
8
+ import { PortraitHint } from "./MobileHint";
9
+ import { useDeckSync } from "./useDeckSync";
10
+ import { useLive } from "./live/Plugin";
11
+ import { useLiveDeck } from "./live/deckIndex";
12
+ import { PersistentProvider, PersistentLayer, type PersistentItem } from "./PersistentLayer";
13
+ import { ScaledStage, SlideFrame } from "./Stage";
14
+ import { DeckThumb } from "./Thumb";
15
+ import { readThumbnails } from "./thumbnails";
16
+ import { hasEmbeddedSource } from "./source";
17
+ import { HelpOverlay } from "./HelpOverlay";
18
+ import { QrOverlay } from "./QrOverlay";
19
+ import { PluginOverlays } from "./live/globalChrome";
20
+ import { DeckChrome } from "./DeckChrome";
21
+ import { StepsProvider } from "./steps";
22
+ import { accumulateDigits, toggleFullscreen } from "./delivery";
23
+ import { normalizeSlides, type SlideInput } from "./slides";
24
+ import { resolveTransition, mobileTransitionsDisabled, type SlideDirection, type SlideTransition } from "./transitions";
25
+ import type { Theme } from "@liebstoeckel/theme";
26
+ import { useCoarsePointer } from "./useCoarsePointer";
27
+
28
+ export type DeckProps = {
29
+ slides: SlideInput[];
30
+ persistent?: PersistentItem[];
31
+ brands?: string[];
32
+ title?: string;
33
+ /** Deck-wide default slide transition. A slide can override it with its own
34
+ * `export const transition`. Defaults to a light `"fade"`. */
35
+ transition?: SlideTransition;
36
+ /** Deck-defined brand themes. Each is injected as a `[data-brand]` CSS block, so a
37
+ * deck can ship its own brand(s) without editing the theme package, reference
38
+ * them by `name` in `brands`. (Built-in brand names work via the theme styles
39
+ * with no entry here.) */
40
+ brandThemes?: Theme[];
41
+ /** Allow slide transitions on mobile (coarse-pointer) devices. Off by default, * transitions are dropped there for snappier, jank-free navigation. Set true to
42
+ * opt back in. */
43
+ mobileTransitions?: boolean;
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ plugins?: PluginDef<any>[];
46
+ };
47
+
48
+ function openPresenter() {
49
+ // preserve the query (incl. ?t=<token>) so a live presenter window authenticates
50
+ const url = location.origin + location.pathname + location.search + "#presenter";
51
+ // window.open can throw (relay sandbox without allow-popups) or return null (a
52
+ // popup blocker), never let that bubble up as an uncaught DOMException.
53
+ try {
54
+ const w = window.open(url, "liebstoeckel-presenter", "width=1366,height=860");
55
+ if (!w) console.warn("[liebstoeckel] presenter pop-out was blocked (popup blocker or sandbox).");
56
+ } catch (err) {
57
+ console.warn("[liebstoeckel] presenter pop-out is unavailable in this context:", err);
58
+ }
59
+ }
60
+
61
+ export function Deck({ slides, persistent = [], brands = ["default"], transition: deckTransition, mobileTransitions }: DeckProps) {
62
+ const norm = useMemo(() => normalizeSlides(slides), [slides]);
63
+ const count = norm.length;
64
+ // Pre-rendered overview thumbnails (build-time), if the deck embedded them.
65
+ const thumbs = useMemo(() => readThumbnails(), []);
66
+
67
+ // Index/step source: shared Yjs doc in a live session (viewers follow), else
68
+ // BroadcastChannel across local windows. Both hooks run; we select one.
69
+ const liveCtx = useLive();
70
+ const fallbackDoc = useMemo(() => new Y.Doc(), []);
71
+ const sync = useDeckSync(count);
72
+ const liveDeck = useLiveDeck(liveCtx?.doc ?? fallbackDoc, count, liveCtx?.role !== "viewer");
73
+ const isLive = !!liveCtx?.live;
74
+ const role = isLive ? liveCtx?.role : undefined;
75
+ // A live viewer follows the presenter, overview + the presenter pop-out are
76
+ // presenter-only (they'd reach the confidence monitor / drive nav). Standalone
77
+ // (no live role) drives its own deck, so canDrive is true (ADR 0070).
78
+ const canDrive = role !== "viewer";
79
+ const ctrl = isLive ? liveDeck : sync;
80
+ const { index, step, total } = ctrl;
81
+
82
+ // Gate total-reporting by the current slide so the *exiting* slide (during the
83
+ // AnimatePresence overlap) can't clobber the entering slide's step count.
84
+ const indexRef = useRef(index);
85
+ indexRef.current = index;
86
+ const onTotal = useCallback(
87
+ (slideIndex: number, n: number) => {
88
+ if (slideIndex === indexRef.current) ctrl.setTotal(n);
89
+ },
90
+ [ctrl],
91
+ );
92
+
93
+ const [brandIdx, setBrandIdx] = useState(0);
94
+ const [help, setHelp] = useState(false);
95
+ // Static for the loaded document; resolve once for the help overlay's eject hint.
96
+ const ejectable = useMemo(() => hasEmbeddedSource(), []);
97
+ const [blurred, setBlurred] = useState(false);
98
+ const [overview, setOverview] = useState(false);
99
+ const [qr, setQr] = useState(false);
100
+ const [jump, setJump] = useState("");
101
+ const brand = brands[brandIdx % brands.length];
102
+
103
+ useEffect(() => {
104
+ document.body.dataset.brand = brand;
105
+ }, [brand]);
106
+
107
+ useEffect(() => {
108
+ const onCtx = (e: MouseEvent) => {
109
+ e.preventDefault();
110
+ setHelp((v) => !v);
111
+ };
112
+ window.addEventListener("contextmenu", onCtx);
113
+ return () => window.removeEventListener("contextmenu", onCtx);
114
+ }, []);
115
+
116
+ const onDigit = useCallback(
117
+ (key: string) => {
118
+ const r = accumulateDigits(jump, key);
119
+ setJump(r.buffer);
120
+ if (r.commit != null) ctrl.setIndex(r.commit);
121
+ },
122
+ [jump, ctrl],
123
+ );
124
+
125
+ useDeckNav({
126
+ count,
127
+ setIndex: ctrl.setIndex,
128
+ onNext: ctrl.next,
129
+ onPrev: ctrl.prev,
130
+ onToggleBrand: brands.length > 1 ? () => setBrandIdx((n) => n + 1) : undefined,
131
+ onOpenPresenter: canDrive ? openPresenter : undefined,
132
+ onToggleHelp: () => setHelp((v) => !v),
133
+ onFullscreen: () => void toggleFullscreen(document.documentElement),
134
+ onBlur: () => setBlurred((v) => !v),
135
+ onOverview: canDrive ? () => setOverview((v) => !v) : undefined,
136
+ onQr: () => setQr((v) => !v),
137
+ onDigit,
138
+ });
139
+
140
+ // Touch nav for everyone who drives their own deck (standalone + presenter); a
141
+ // live viewer follows the presenter, so it isn't bound for them.
142
+ useTouchNav({ enabled: role !== "viewer", onNext: ctrl.next, onPrev: ctrl.prev });
143
+
144
+ const Current = norm[index]?.Component ?? (() => null);
145
+
146
+ // Slide transition: per-slide `transition` export wins over the deck default
147
+ // (which defaults to "fade"). `direction` mirrors directional presets on
148
+ // back-nav; reduced-motion collapses everything to a tiny opacity fade.
149
+ const reduceMotion = useReducedMotion();
150
+ const coarse = useCoarsePointer();
151
+ const prevIndexRef = useRef(index);
152
+ const direction: SlideDirection = index >= prevIndexRef.current ? 1 : -1;
153
+ useEffect(() => {
154
+ prevIndexRef.current = index;
155
+ }, [index]);
156
+ // Mobile (coarse pointer) drops transitions by default, opt back in with
157
+ // `mobileTransitions`. Otherwise: per-slide override → deck default → "fade".
158
+ const requested = mobileTransitionsDisabled(coarse, mobileTransitions)
159
+ ? "none"
160
+ : (norm[index]?.transition ?? deckTransition);
161
+ const slideTransition = resolveTransition(requested, !!reduceMotion);
162
+
163
+ return (
164
+ <MDXProvider components={mdxComponents}>
165
+ <PersistentProvider>
166
+ {/* 100dvh (dynamic viewport), NOT 100vh: on mobile the browser's address
167
+ bar makes 100vh taller than the visible area, which would push the slide
168
+ bottom + the viewport-pinned chrome below the fold. */}
169
+ <div className="relative h-dvh w-screen overflow-hidden bg-bg">
170
+ <ScaledStage className="absolute inset-0">
171
+ <div data-deck-root className="absolute inset-0">
172
+ <AnimatePresence custom={direction}>
173
+ <motion.div
174
+ key={index}
175
+ custom={direction}
176
+ className="absolute inset-0"
177
+ variants={slideTransition.variants}
178
+ initial="enter"
179
+ animate="center"
180
+ exit="exit"
181
+ transition={slideTransition.transition}
182
+ >
183
+ <SlideFrame>
184
+ <StepsProvider step={step} slideIndex={index} onTotal={onTotal}>
185
+ <Current />
186
+ </StepsProvider>
187
+ </SlideFrame>
188
+ </motion.div>
189
+ </AnimatePresence>
190
+
191
+ <PersistentLayer items={persistent} currentIndex={index} />
192
+
193
+ {/* deck-wide plugin overlays (e.g. reactions floaters), over the
194
+ slide, below chrome; non-interactive (live only, see ADR 0021) */}
195
+ <PluginOverlays />
196
+
197
+ {/* jump-to-number buffer */}
198
+ <AnimatePresence>
199
+ {jump && (
200
+ <motion.div
201
+ initial={{ opacity: 0, scale: 0.9 }}
202
+ animate={{ opacity: 1, scale: 1 }}
203
+ exit={{ opacity: 0 }}
204
+ className="absolute left-1/2 top-8 -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
205
+ >
206
+ → {jump}
207
+ </motion.div>
208
+ )}
209
+ </AnimatePresence>
210
+
211
+ <HelpOverlay open={help} onClose={() => setHelp(false)} showBrand={brands.length > 1} role={role} ejectable={ejectable} />
212
+ <PortraitHint />
213
+ </div>
214
+
215
+ {/* blur-screen overlay */}
216
+ <AnimatePresence>
217
+ {blurred && (
218
+ <motion.div
219
+ className="absolute inset-0 flex items-center justify-center backdrop-blur-2xl"
220
+ style={{ background: "color-mix(in srgb, var(--brand-bg) 55%, transparent)" }}
221
+ initial={{ opacity: 0 }}
222
+ animate={{ opacity: 1 }}
223
+ exit={{ opacity: 0 }}
224
+ onClick={() => setBlurred(false)}
225
+ >
226
+ <svg width="84" height="84" viewBox="0 0 24 24" fill="none" stroke="var(--brand-muted)" strokeWidth="1.4" opacity="0.5">
227
+ <path d="M2 12s3.5-6 10-6 10 6 10 6" strokeLinecap="round" />
228
+ <path d="M2 12s3.5 4 10 4 10-4 10-4" strokeLinecap="round" />
229
+ </svg>
230
+ </motion.div>
231
+ )}
232
+ </AnimatePresence>
233
+ </ScaledStage>
234
+
235
+ {/* QR + overview render OUTSIDE the scaled canvas (device scale) so they're
236
+ full-size on a phone, the touch ⋮ menu opens them. */}
237
+ <QrOverlay open={qr} url={liveCtx?.viewerUrl} onClose={() => setQr(false)} />
238
+ <AnimatePresence>
239
+ {overview && (
240
+ <motion.div
241
+ className="absolute inset-0 z-40 overflow-auto bg-bg/95 p-[4%] backdrop-blur-xl"
242
+ initial={{ opacity: 0 }}
243
+ animate={{ opacity: 1 }}
244
+ exit={{ opacity: 0 }}
245
+ >
246
+ <div className="mb-6 font-mono text-sm uppercase tracking-[0.3em] text-muted">Overview · tap or type a number</div>
247
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 lg:gap-5">
248
+ {norm.map((s, i) => (
249
+ <button
250
+ key={i}
251
+ onClick={() => {
252
+ ctrl.setIndex(i);
253
+ setOverview(false);
254
+ }}
255
+ className={`relative aspect-video overflow-hidden rounded-xl border text-left transition ${
256
+ i === index ? "border-primary" : "border-border hover:border-text"
257
+ }`}
258
+ >
259
+ <DeckThumb Component={s.Component} src={thumbs?.get(i)} alt={`Slide ${i + 1}`} />
260
+ <span className="absolute bottom-1 right-2 font-mono text-xs text-muted">{i + 1}</span>
261
+ </button>
262
+ ))}
263
+ </div>
264
+ </motion.div>
265
+ )}
266
+ </AnimatePresence>
267
+
268
+ {/* chrome at DEVICE scale, pinned to the real viewport (outside the scaled
269
+ canvas) so the help + plugin buttons stay tappable on a phone. On touch
270
+ the help affordance becomes a ⋮ action menu (fullscreen, overview, …). */}
271
+ <DeckChrome
272
+ index={index}
273
+ count={count}
274
+ isLive={isLive}
275
+ role={role}
276
+ canDrive={canDrive}
277
+ viewerUrl={liveCtx?.viewerUrl}
278
+ onHelp={() => setHelp(true)}
279
+ onOverview={() => setOverview((v) => !v)}
280
+ onQr={() => setQr((v) => !v)}
281
+ />
282
+ </div>
283
+ </PersistentProvider>
284
+ </MDXProvider>
285
+ );
286
+ }
@@ -0,0 +1,240 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import { AnimatePresence } from "motion/react";
3
+ import { usePluginChrome } from "./live/globalChrome";
4
+ import { BreakoutSheet } from "./live/breakout";
5
+ import { useCoarsePointer } from "./useCoarsePointer";
6
+ import { toggleFullscreen } from "./delivery";
7
+
8
+ function useIsFullscreen(): boolean {
9
+ const [fs, setFs] = useState(false);
10
+ useEffect(() => {
11
+ const h = () => setFs(typeof document !== "undefined" && !!document.fullscreenElement);
12
+ h();
13
+ document.addEventListener("fullscreenchange", h);
14
+ return () => document.removeEventListener("fullscreenchange", h);
15
+ }, []);
16
+ return fs;
17
+ }
18
+
19
+ const ICON = {
20
+ more: (
21
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
22
+ <circle cx="12" cy="5" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="12" cy="19" r="1.8" />
23
+ </svg>
24
+ ),
25
+ enterFs: (
26
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
27
+ <path d="M4 9V5a1 1 0 0 1 1-1h4M20 9V5a1 1 0 0 0-1-1h-4M4 15v4a1 1 0 0 0 1 1h4M20 15v4a1 1 0 0 1-1 1h-4" />
28
+ </svg>
29
+ ),
30
+ exitFs: (
31
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
32
+ <path d="M9 4v3a2 2 0 0 1-2 2H4M15 4v3a2 2 0 0 0 2 2h3M9 20v-3a2 2 0 0 0-2-2H4M15 20v-3a2 2 0 0 1 2-2h3" />
33
+ </svg>
34
+ ),
35
+ grid: (
36
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
37
+ <rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
38
+ <rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
39
+ </svg>
40
+ ),
41
+ share: (
42
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
43
+ <rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
44
+ <path d="M14 3h7v7M3 14h4v4M10 14h0M14 10h0" />
45
+ </svg>
46
+ ),
47
+ theme: (
48
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
49
+ <path d="M12 3a9 9 0 1 0 0 18 2.5 2.5 0 0 0 0-5h-1a2 2 0 0 1 0-4h3a4 4 0 0 0 4-4 5 5 0 0 0-9-3Z" />
50
+ </svg>
51
+ ),
52
+ notes: (
53
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
54
+ <rect x="3" y="4" width="18" height="14" rx="1.5" />
55
+ <path d="M7 9h10M7 13h6" />
56
+ </svg>
57
+ ),
58
+ } as const;
59
+
60
+ interface MenuAction {
61
+ key: string;
62
+ label: string;
63
+ icon: ReactNode;
64
+ onClick: () => void;
65
+ }
66
+
67
+ function MenuRow({ action, onDone }: { action: MenuAction; onDone: () => void }) {
68
+ return (
69
+ <button
70
+ onClick={() => {
71
+ action.onClick();
72
+ onDone();
73
+ }}
74
+ style={{
75
+ appearance: "none",
76
+ cursor: "pointer",
77
+ display: "flex",
78
+ alignItems: "center",
79
+ gap: "0.9rem",
80
+ width: "100%",
81
+ padding: "0.95rem 0.5rem",
82
+ border: "none",
83
+ borderTop: "1px solid color-mix(in srgb, var(--brand-text, #e9e6d7) 8%, transparent)",
84
+ background: "transparent",
85
+ color: "var(--brand-text, #e9e6d7)",
86
+ fontFamily: "var(--brand-font-body, sans-serif)",
87
+ fontSize: "1.05rem",
88
+ textAlign: "left",
89
+ }}
90
+ >
91
+ <span style={{ color: "var(--brand-muted, #9da28c)", display: "flex" }}>{action.icon}</span>
92
+ {action.label}
93
+ </button>
94
+ );
95
+ }
96
+
97
+ /** Touch action menu: a `⋮` button opening a sheet of tappable deck actions. */
98
+ function DeckMenu({ actions }: { actions: MenuAction[] }) {
99
+ const [open, setOpen] = useState(false);
100
+ return (
101
+ <>
102
+ <button
103
+ onClick={() => setOpen(true)}
104
+ aria-label="Menu"
105
+ className="flex h-7 w-7 items-center justify-center rounded-full border border-border text-muted/70 transition hover:border-text hover:text-text"
106
+ >
107
+ {ICON.more}
108
+ </button>
109
+ <AnimatePresence>
110
+ {open && (
111
+ <BreakoutSheet label="Menu" onClose={() => setOpen(false)}>
112
+ <div style={{ display: "flex", flexDirection: "column" }}>
113
+ {actions.map((a) => (
114
+ <MenuRow key={a.key} action={a} onDone={() => setOpen(false)} />
115
+ ))}
116
+ </div>
117
+ </BreakoutSheet>
118
+ )}
119
+ </AnimatePresence>
120
+ </>
121
+ );
122
+ }
123
+
124
+ /** Deck chrome rendered at DEVICE scale, pinned to the real viewport, outside the
125
+ * scaled 1280×720 canvas, so the buttons stay tappable on a phone. Carries the
126
+ * progress bar, the slide counter, the help/menu affordance, and any plugin-
127
+ * registered global controls (ADR 0021). On a coarse pointer the rail is upsized
128
+ * and the keyboard-shortcut help is swapped for a tappable `⋮` action menu. */
129
+ export function DeckChrome({
130
+ index,
131
+ count,
132
+ isLive,
133
+ role,
134
+ canDrive,
135
+ viewerUrl,
136
+ onHelp,
137
+ onOverview,
138
+ onQr,
139
+ }: {
140
+ index: number;
141
+ count: number;
142
+ isLive: boolean;
143
+ role: string | undefined;
144
+ canDrive: boolean;
145
+ viewerUrl: string | undefined;
146
+ onHelp: () => void;
147
+ onOverview: () => void;
148
+ onQr: () => void;
149
+ }) {
150
+ const coarse = useCoarsePointer();
151
+ const fs = useIsFullscreen();
152
+ const inset = "max(env(safe-area-inset-left), 1rem)";
153
+ // plugin global controls: pinned ones sit in the rail; the rest overflow into the ⋮
154
+ // menu on touch so the rail can't run off-screen (ADR 0038). Panels are hosted here.
155
+ const { rail: pluginRail, menuActions: pluginMenu, panels: pluginPanels } = usePluginChrome();
156
+
157
+ // Switch this window to the presenter view (ADR 0027), the touch counterpart to
158
+ // the desktop `P` pop-out. `Present` selects the view by the #presenter hash at
159
+ // mount, so set the hash and reload.
160
+ const openPresenterView = () => {
161
+ if (typeof location === "undefined") return;
162
+ location.hash = "presenter";
163
+ location.reload();
164
+ };
165
+
166
+ // Note: no "Cycle theme" here, brand cycling stays the desktop `t` shortcut
167
+ // (a niche preview/showcase affordance), not a touch action.
168
+ const actions: MenuAction[] = [
169
+ {
170
+ key: "fullscreen",
171
+ label: fs ? "Exit fullscreen" : "Fullscreen",
172
+ icon: fs ? ICON.exitFs : ICON.enterFs,
173
+ onClick: () => void toggleFullscreen(document.documentElement),
174
+ },
175
+ ...(canDrive ? [{ key: "overview", label: "Overview", icon: ICON.grid, onClick: onOverview }] : []),
176
+ // Presenter view (notes-first confidence monitor), drivers only, never viewers.
177
+ ...(canDrive ? [{ key: "presenter", label: "Presenter view", icon: ICON.notes, onClick: openPresenterView }] : []),
178
+ ...(isLive && viewerUrl ? [{ key: "share", label: "Share / QR", icon: ICON.share, onClick: onQr }] : []),
179
+ ...pluginMenu, // overflowed (unpinned) plugin controls, touch only
180
+ ];
181
+
182
+ return (
183
+ <>
184
+ {/* progress + counter pinned to the viewport bottom */}
185
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 h-[3px] bg-border/40">
186
+ <div
187
+ className="h-full bg-primary transition-[width] duration-500 ease-out"
188
+ style={{ width: `${((index + 1) / count) * 100}%` }}
189
+ />
190
+ </div>
191
+ <div
192
+ className="pointer-events-none absolute z-30 font-mono text-sm tracking-wide text-muted"
193
+ style={{ bottom: "calc(0.9rem + env(safe-area-inset-bottom))", right: "max(env(safe-area-inset-right), 1.25rem)" }}
194
+ >
195
+ {String(index + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
196
+ </div>
197
+
198
+ {/* chrome rail: help (desktop) or ⋮ menu (touch) + plugin global controls */}
199
+ <div
200
+ className="absolute z-30 flex items-center gap-2"
201
+ style={{
202
+ bottom: "calc(0.75rem + env(safe-area-inset-bottom))",
203
+ left: inset,
204
+ transform: coarse ? "scale(1.6)" : "none",
205
+ transformOrigin: "bottom left",
206
+ }}
207
+ >
208
+ {coarse ? (
209
+ <DeckMenu actions={actions} />
210
+ ) : (
211
+ <button
212
+ onClick={onHelp}
213
+ title={isLive ? `${role} · shortcuts (? or right-click)` : "Shortcuts (? or right-click)"}
214
+ className={`flex h-7 w-7 items-center justify-center rounded-full border transition ${
215
+ isLive
216
+ ? "border-accent/50 text-accent opacity-80 hover:opacity-100"
217
+ : "border-border text-muted/50 opacity-60 hover:border-text hover:text-text hover:opacity-100"
218
+ }`}
219
+ >
220
+ {!isLive ? (
221
+ <span className="font-mono text-xs">?</span>
222
+ ) : role === "viewer" ? (
223
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
224
+ <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
225
+ <circle cx="12" cy="12" r="3" />
226
+ </svg>
227
+ ) : (
228
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
229
+ <rect x="3" y="4" width="18" height="13" rx="1.5" />
230
+ <path d="M12 17v3M8.5 20h7" />
231
+ </svg>
232
+ )}
233
+ </button>
234
+ )}
235
+ {pluginRail}
236
+ </div>
237
+ {pluginPanels}
238
+ </>
239
+ );
240
+ }