@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,156 @@
1
+ import { useEffect } from "react";
2
+ import { AnimatePresence, motion } from "motion/react";
3
+ import type { Role } from "@liebstoeckel/plugin-sdk";
4
+
5
+ type Shortcut = { keys: string[]; label: string; presenterOnly?: boolean };
6
+
7
+ /** Guide for recovering + editing a built deck (linked from the eject footer). */
8
+ const EDIT_DOCS_URL = "https://docs.liebstoeckel.app/guides/editing-a-built-deck/";
9
+
10
+ function Kbd({ children, dim }: { children: string; dim?: boolean }) {
11
+ return (
12
+ <kbd
13
+ className={`inline-flex min-w-[1.9rem] items-center justify-center rounded-md border px-2 py-1 font-mono text-sm shadow-sm ${
14
+ dim ? "border-border/50 bg-bg/40 text-muted" : "border-border bg-bg text-text"
15
+ }`}
16
+ >
17
+ {children}
18
+ </kbd>
19
+ );
20
+ }
21
+
22
+ export function HelpOverlay({
23
+ open,
24
+ onClose,
25
+ showBrand,
26
+ role,
27
+ ejectable,
28
+ }: {
29
+ open: boolean;
30
+ onClose: () => void;
31
+ showBrand?: boolean;
32
+ /** live role; undefined = standalone (everything enabled) */
33
+ role?: Role;
34
+ /** this `.html` carries its own recoverable source → show the "edit this deck" hint */
35
+ ejectable?: boolean;
36
+ }) {
37
+ useEffect(() => {
38
+ if (!open) return;
39
+ const onKey = (e: KeyboardEvent) => {
40
+ if (e.key === "Escape") onClose();
41
+ };
42
+ window.addEventListener("keydown", onKey);
43
+ return () => window.removeEventListener("keydown", onKey);
44
+ }, [open, onClose]);
45
+
46
+ const isViewer = role === "viewer";
47
+
48
+ const shortcuts: Shortcut[] = [
49
+ { keys: ["→", "Space"], label: "Next / reveal step", presenterOnly: true },
50
+ { keys: ["←"], label: "Previous / hide step", presenterOnly: true },
51
+ { keys: ["Home", "End"], label: "First / last slide", presenterOnly: true },
52
+ { keys: ["0-9", "↵"], label: "Jump to slide", presenterOnly: true },
53
+ { keys: ["O"], label: "Overview grid", presenterOnly: true },
54
+ { keys: ["F"], label: "Fullscreen" },
55
+ { keys: ["B"], label: "Blur screen" },
56
+ { keys: ["Q"], label: "Show join QR" },
57
+ { keys: ["P"], label: "Open presenter view", presenterOnly: true },
58
+ ...(showBrand ? [{ keys: ["T"], label: "Switch brand" }] : []),
59
+ { keys: ["?"], label: "Toggle this help" },
60
+ { keys: ["Esc"], label: "Close" },
61
+ ];
62
+
63
+ return (
64
+ <AnimatePresence>
65
+ {open && (
66
+ <motion.div
67
+ className="absolute inset-0 z-50 flex items-center justify-center"
68
+ initial={{ opacity: 0 }}
69
+ animate={{ opacity: 1 }}
70
+ exit={{ opacity: 0 }}
71
+ transition={{ duration: 0.18 }}
72
+ >
73
+ <div
74
+ className="absolute inset-0 bg-bg/70 backdrop-blur-md"
75
+ onClick={onClose}
76
+ onContextMenu={(e) => {
77
+ e.preventDefault();
78
+ onClose();
79
+ }}
80
+ />
81
+ <motion.div
82
+ initial={{ opacity: 0, scale: 0.96, y: 10 }}
83
+ animate={{ opacity: 1, scale: 1, y: 0 }}
84
+ exit={{ opacity: 0, scale: 0.97, y: 8 }}
85
+ transition={{ type: "spring", stiffness: 260, damping: 24 }}
86
+ className="relative w-[440px] rounded-2xl border border-border bg-surface/90 p-7 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
87
+ >
88
+ <div className="mb-5 flex items-baseline justify-between">
89
+ <span className="font-heading text-2xl font-semibold text-text">Shortcuts</span>
90
+ {role ? (
91
+ <span className="font-mono text-[11px] uppercase tracking-[0.25em] text-accent">
92
+ ● live · {role}
93
+ </span>
94
+ ) : (
95
+ <span className="font-mono text-[11px] uppercase tracking-[0.3em] text-muted">keyboard</span>
96
+ )}
97
+ </div>
98
+ {isViewer && (
99
+ <p className="mb-4 font-body text-sm text-muted">
100
+ You're following as a <span className="text-text">viewer</span>, the presenter drives
101
+ navigation. You can still interact (e.g. vote).
102
+ </p>
103
+ )}
104
+ <ul className="space-y-3">
105
+ {shortcuts.map((s) => {
106
+ const disabled = isViewer && s.presenterOnly;
107
+ return (
108
+ <li
109
+ key={s.label}
110
+ className={`flex items-center justify-between gap-4 ${disabled ? "opacity-40" : ""}`}
111
+ title={disabled ? "Presenter only" : undefined}
112
+ >
113
+ <span className="font-body text-lg text-text/85">
114
+ {s.label}
115
+ {disabled && <span className="ml-2 font-mono text-[10px] uppercase tracking-wider text-muted">presenter</span>}
116
+ </span>
117
+ <span className="flex shrink-0 items-center gap-1.5">
118
+ {s.keys.map((k, i) => (
119
+ <span key={k} className="flex items-center gap-1.5">
120
+ {i > 0 && <span className="font-mono text-xs text-muted">/</span>}
121
+ <Kbd dim={disabled}>{k}</Kbd>
122
+ </span>
123
+ ))}
124
+ </span>
125
+ </li>
126
+ );
127
+ })}
128
+ </ul>
129
+ {ejectable && (
130
+ <div className="mt-6 border-t border-border pt-4">
131
+ <p className="font-body text-sm text-text/80">
132
+ This deck embeds its own source, recover it with{" "}
133
+ <code className="rounded bg-bg/60 px-1.5 py-0.5 font-mono text-[13px] text-text">
134
+ liebstoeckel eject
135
+ </code>{" "}
136
+ to edit it by hand or with an agent.{" "}
137
+ <a
138
+ href={EDIT_DOCS_URL}
139
+ target="_blank"
140
+ rel="noreferrer"
141
+ className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
142
+ >
143
+ Learn how →
144
+ </a>
145
+ </p>
146
+ </div>
147
+ )}
148
+ <div className="mt-6 border-t border-border pt-4 text-center font-mono text-[11px] uppercase tracking-[0.25em] text-muted">
149
+ right-click anywhere to toggle
150
+ </div>
151
+ </motion.div>
152
+ </motion.div>
153
+ )}
154
+ </AnimatePresence>
155
+ );
156
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { AnimatePresence, motion } from "motion/react";
4
+
5
+ /** A brief, dismissable "rotate for a bigger view" toast shown on a portrait phone
6
+ * (coarse pointer). Portaled to <body> so it renders at device scale, not inside
7
+ * the scaled canvas. Auto-hides; tap to dismiss. */
8
+ export function PortraitHint() {
9
+ const [portrait, setPortrait] = useState(false);
10
+ const [dismissed, setDismissed] = useState(false);
11
+
12
+ useEffect(() => {
13
+ if (typeof matchMedia !== "function") return;
14
+ const coarse = matchMedia("(pointer: coarse)");
15
+ const orient = matchMedia("(orientation: portrait)");
16
+ const update = () => setPortrait(coarse.matches && orient.matches);
17
+ update();
18
+ coarse.addEventListener?.("change", update);
19
+ orient.addEventListener?.("change", update);
20
+ return () => {
21
+ coarse.removeEventListener?.("change", update);
22
+ orient.removeEventListener?.("change", update);
23
+ };
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ if (!portrait) return;
28
+ const t = setTimeout(() => setDismissed(true), 6000);
29
+ return () => clearTimeout(t);
30
+ }, [portrait]);
31
+
32
+ if (typeof document === "undefined") return null;
33
+ const show = portrait && !dismissed;
34
+
35
+ return createPortal(
36
+ <AnimatePresence>
37
+ {show && (
38
+ <motion.button
39
+ onClick={() => setDismissed(true)}
40
+ initial={{ opacity: 0, y: 12 }}
41
+ animate={{ opacity: 1, y: 0 }}
42
+ exit={{ opacity: 0, y: 12 }}
43
+ style={{
44
+ position: "fixed",
45
+ left: "50%",
46
+ bottom: "calc(1.25rem + env(safe-area-inset-bottom))",
47
+ transform: "translateX(-50%)",
48
+ zIndex: 9998,
49
+ display: "flex",
50
+ alignItems: "center",
51
+ gap: "0.5rem",
52
+ padding: "0.55rem 0.95rem",
53
+ borderRadius: "999px",
54
+ border: "1px solid var(--brand-border, color-mix(in srgb, var(--brand-text, #e9e6d7) 14%, transparent))",
55
+ background: "color-mix(in srgb, var(--brand-surface, #1a2014) 88%, transparent)",
56
+ color: "var(--brand-muted, #9da28c)",
57
+ fontFamily: "var(--brand-font-mono, monospace)",
58
+ fontSize: "0.72rem",
59
+ letterSpacing: "0.04em",
60
+ backdropFilter: "blur(8px)",
61
+ cursor: "pointer",
62
+ }}
63
+ >
64
+ <span aria-hidden style={{ fontSize: "0.9rem" }}>⤾</span>
65
+ Rotate for a bigger view
66
+ </motion.button>
67
+ )}
68
+ </AnimatePresence>,
69
+ document.body,
70
+ );
71
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { motion } from "motion/react";
11
+ import { StageScaleContext } from "./Stage";
12
+ import { SlideIndexContext } from "./steps";
13
+
14
+ export type PersistentItem = { id: string; render: () => ReactNode };
15
+ type Rect = { top: number; left: number; width: number; height: number };
16
+
17
+ // Per element id, the measured slot rect for each slide index that mounts one.
18
+ type SlotMap = Record<string, Record<number, Rect>>;
19
+
20
+ type Ctx = {
21
+ set: (id: string, slide: number, rect: Rect) => void;
22
+ clear: (id: string, slide: number) => void;
23
+ slots: SlotMap;
24
+ };
25
+ const PersistentCtx = createContext<Ctx | null>(null);
26
+ const usePersistent = () => {
27
+ const c = useContext(PersistentCtx);
28
+ if (!c) throw new Error("Slot/PersistentLayer must be used inside <PersistentProvider>");
29
+ return c;
30
+ };
31
+
32
+ const same = (a: Rect | undefined, b: Rect) =>
33
+ a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
34
+
35
+ /** Holds each persistent element's slot rect **per slide index**. Visibility is
36
+ * decided by the layer from the *current* slide index (not by ref-counting mounted
37
+ * slots), so when navigating to a slide that has no slot, the element hides at the
38
+ * index change, in step with the transition, instead of lingering until the old
39
+ * slide finishes its exit. When the incoming slide also has a slot, its rect is
40
+ * present for the new index and the element travels to it. */
41
+ export function PersistentProvider({ children }: { children: ReactNode }) {
42
+ const [slots, setSlots] = useState<SlotMap>({});
43
+
44
+ const set = useCallback((id: string, slide: number, rect: Rect) => {
45
+ setSlots((p) => {
46
+ if (same(p[id]?.[slide], rect)) return p;
47
+ return { ...p, [id]: { ...(p[id] ?? {}), [slide]: rect } };
48
+ });
49
+ }, []);
50
+
51
+ const clear = useCallback((id: string, slide: number) => {
52
+ setSlots((p) => {
53
+ if (!p[id] || !(slide in p[id]!)) return p;
54
+ const next = { ...p[id] };
55
+ delete next[slide];
56
+ return { ...p, [id]: next };
57
+ });
58
+ }, []);
59
+
60
+ return <PersistentCtx.Provider value={{ set, clear, slots }}>{children}</PersistentCtx.Provider>;
61
+ }
62
+
63
+ /** Reserves space inside a slide and reports its box (per slide index) to the
64
+ * persistent layer. Drop the same `id` on multiple slides to make the element
65
+ * travel between them. */
66
+ export function Slot({ id, className }: { id: string; className?: string }) {
67
+ const { set, clear } = usePersistent();
68
+ const slide = useContext(SlideIndexContext);
69
+ const scale = useContext(StageScaleContext);
70
+ const ref = useRef<HTMLDivElement>(null);
71
+
72
+ useLayoutEffect(() => {
73
+ const el = ref.current!;
74
+ const root = (el.closest("[data-deck-root]") as HTMLElement) ?? document.body;
75
+ // getBoundingClientRect is post-transform (device px), but the persistent layer
76
+ // positions inside the stage's LOGICAL 1280×720 space, divide the stage scale
77
+ // back out, else the element is mis-placed whenever the stage isn't at 1:1.
78
+ const s = scale || 1;
79
+ const measure = (): Rect => {
80
+ const r = el.getBoundingClientRect();
81
+ const base = root.getBoundingClientRect();
82
+ return { top: (r.top - base.top) / s, left: (r.left - base.left) / s, width: r.width / s, height: r.height / s };
83
+ };
84
+ set(id, slide, measure());
85
+ const ro = new ResizeObserver(() => set(id, slide, measure()));
86
+ ro.observe(el);
87
+ ro.observe(root);
88
+ const onResize = () => set(id, slide, measure());
89
+ window.addEventListener("resize", onResize);
90
+ return () => {
91
+ ro.disconnect();
92
+ window.removeEventListener("resize", onResize);
93
+ clear(id, slide);
94
+ };
95
+ }, [id, slide, set, clear, scale]);
96
+
97
+ return <div ref={ref} data-slot={id} className={className} />;
98
+ }
99
+
100
+ const SPRING = { type: "spring", stiffness: 200, damping: 26 } as const;
101
+ // Appear: snap the box into place (no positional tween) and only fade opacity in,
102
+ // so the element doesn't fly in from a previous slot's position or the origin.
103
+ const APPEAR = {
104
+ top: { duration: 0 },
105
+ left: { duration: 0 },
106
+ width: { duration: 0 },
107
+ height: { duration: 0 },
108
+ opacity: SPRING,
109
+ } as const;
110
+
111
+ /** Renders each persistent element ONCE (never unmounts → internal state kept),
112
+ * positioning it onto the slot on the **current** slide. Animates the box props
113
+ * (top/left/width/height) directly rather than Motion `layout` FLIP, whose
114
+ * projection mis-positions under the stage's scaled ancestor. Coordinates are the
115
+ * slot's LOGICAL rect. When the current slide has no slot the element fades out in
116
+ * place. The positional spring runs only for consecutive slot→slot travel; an
117
+ * appearance (first show, or coming from a slot-less slide) snaps + fades in.
118
+ * `currentIndex` is omitted only for standalone use (then any slot shows). */
119
+ export function PersistentLayer({ items, currentIndex }: { items: PersistentItem[]; currentIndex?: number }) {
120
+ const { slots } = usePersistent();
121
+ const last = useRef<Record<string, Rect>>({});
122
+
123
+ // Track the index we navigated FROM. Updated in render but ONLY when the index
124
+ // actually changes, so the transient re-renders that land the incoming slot's
125
+ // rect (same currentIndex) keep reporting the real previous slide. Robust against
126
+ // effect-flush timing, unlike a useEffect-updated ref.
127
+ const nav = useRef<{ current?: number; from?: number }>({ current: currentIndex, from: currentIndex });
128
+ if (nav.current.current !== currentIndex) {
129
+ nav.current = { current: currentIndex, from: nav.current.current };
130
+ }
131
+ const cameFrom = nav.current.from;
132
+
133
+ const resolved = items.map((it) => {
134
+ const perSlide = slots[it.id];
135
+ const active = perSlide ? (currentIndex != null ? perSlide[currentIndex] : Object.values(perSlide)[0]) : undefined;
136
+ if (active) last.current[it.id] = active;
137
+ const r = active ?? last.current[it.id];
138
+ const show = !!active;
139
+ // Travel only if the slide we came FROM also had this slot; otherwise (first
140
+ // show, or from a slot-less slide) snap + fade in instead of flying across.
141
+ const cameFromSlot = perSlide != null && cameFrom != null && perSlide[cameFrom] != null;
142
+ const appearing = show && !cameFromSlot;
143
+ return { it, r, show, appearing };
144
+ });
145
+
146
+ return (
147
+ <div className="pointer-events-none absolute inset-0">
148
+ {resolved.map(({ it, r, show, appearing }) => (
149
+ <motion.div
150
+ key={it.id}
151
+ data-persistent={it.id}
152
+ initial={false}
153
+ animate={{
154
+ top: r?.top ?? 0,
155
+ left: r?.left ?? 0,
156
+ width: r?.width ?? 0,
157
+ height: r?.height ?? 0,
158
+ opacity: show ? 1 : 0,
159
+ }}
160
+ transition={appearing ? APPEAR : SPRING}
161
+ style={{ position: "absolute", pointerEvents: show ? "auto" : "none" }}
162
+ >
163
+ {it.render()}
164
+ </motion.div>
165
+ ))}
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,113 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import * as Y from "yjs";
3
+ import { useTheme } from "@liebstoeckel/plugin-ui";
4
+ import { themeToCss, type Theme } from "@liebstoeckel/theme";
5
+ import { registerPluginInstance, type PluginDef } from "@liebstoeckel/plugin-sdk";
6
+ import { Deck, type DeckProps } from "./Deck";
7
+ import { PresenterView } from "./PresenterView";
8
+ import { CaptureView } from "./CaptureView";
9
+ import { PrintView } from "./PrintView";
10
+ import { LiveProvider, type LiveContextValue } from "./live/Plugin";
11
+ import { detectLive } from "./live/detect";
12
+ import { connectLive } from "./live/connect";
13
+ import { getParticipantId } from "./live/participant";
14
+ import { captureRequest, printRequest } from "./build/capture-protocol";
15
+
16
+ /** Concatenate deck-defined brand themes into one CSS string of `[data-brand]`
17
+ * blocks (empty when none). Pure, unit-testable without a DOM. */
18
+ export function brandThemesCss(themes?: Theme[]): string {
19
+ return (themes ?? []).map(themeToCss).join("\n");
20
+ }
21
+
22
+ /** Whether to render the presenter confidence monitor: the `#presenter` hash asks
23
+ * for it, but a live **viewer** must never reach it (it exposes speaker notes and
24
+ * the plugin presenter consoles). Standalone (`role === undefined`) keeps the
25
+ * hash-gate as the presenter mechanism (ADR 0027/0070). Pure, DOM-free. */
26
+ export function presenterViewRequested(hash: string, role?: string): boolean {
27
+ return hash.includes("presenter") && role !== "viewer";
28
+ }
29
+
30
+ // Single entry point for a presentation. Detects a live server (the bootstrap the
31
+ // server injects), connects the shared Yjs doc, and provides plugin context. Falls
32
+ // back to the standalone deck (BroadcastChannel presenter view via the P key).
33
+ export function Present(props: DeckProps) {
34
+ // Build-time thumbnail capture short-circuits everything else: no live connect,
35
+ // no nav, no presenter, just a motionless slide for the headless screenshotter.
36
+ // Gate the live connection on it (hooks still run unconditionally).
37
+ const [capture] = useState(() => captureRequest());
38
+ const [print] = useState(() => printRequest());
39
+ const info = useMemo(() => (capture || print ? null : detectLive()), [capture, print]);
40
+ const participant = useMemo(() => getParticipantId(), []);
41
+ const theme = useTheme();
42
+ const registry = useMemo(
43
+ () =>
44
+ Object.fromEntries((props.plugins ?? []).map((p) => [p.id, p])) as Record<
45
+ string,
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ PluginDef<any>
48
+ >,
49
+ [props.plugins],
50
+ );
51
+ const conn = useMemo(() => (info ? connectLive(info, participant) : null), [info, participant]);
52
+ const doc = useMemo(() => conn?.doc ?? new Y.Doc(), [conn]);
53
+
54
+ // A plugin with global surfaces + a presenter console (e.g. Q&A) can be used without an
55
+ // on-slide placement, so register its default instance in the doc index, otherwise the
56
+ // presenter console, which discovers instances from placements (ADR 0033), wouldn't find
57
+ // it. Runs in both the Deck and the presenter window, so it doesn't depend on a viewer
58
+ // being connected. (ADR 0036)
59
+ useEffect(() => {
60
+ if (!info) return;
61
+ for (const [id, def] of Object.entries(registry)) {
62
+ if (def.client.presenter && def.client.global) registerPluginInstance(doc, id, "");
63
+ }
64
+ }, [info, registry, doc]);
65
+
66
+ const value: LiveContextValue = {
67
+ live: !!info,
68
+ role: info?.role ?? "presenter",
69
+ participant,
70
+ doc,
71
+ theme,
72
+ viewerUrl: info?.viewer,
73
+ plugins: registry,
74
+ };
75
+
76
+ // The presenter confidence monitor is selected by the #presenter hash, but in a
77
+ // live session a *viewer* must never reach it (it leaks speaker notes + presenter
78
+ // consoles). Standalone (no live role) keeps the hash-gate (ADR 0027/0070).
79
+ const [isPresenterWindow] = useState(
80
+ () => typeof location !== "undefined" && presenterViewRequested(location.hash, info?.role),
81
+ );
82
+
83
+ // Deck-defined brands → an injected `[data-brand]` stylesheet (so a deck can ship
84
+ // its own brand without editing the theme package). Rendered in every view.
85
+ const css = brandThemesCss(props.brandThemes);
86
+ const brandStyle = css ? <style data-brand-themes>{css}</style> : null;
87
+
88
+ if (capture)
89
+ return (
90
+ <>
91
+ {brandStyle}
92
+ <CaptureView {...props} />
93
+ </>
94
+ );
95
+
96
+ if (print)
97
+ return (
98
+ <>
99
+ {brandStyle}
100
+ <PrintView {...props} />
101
+ </>
102
+ );
103
+
104
+ // The confidence monitor (#presenter) works standalone (BroadcastChannel) and
105
+ // live (shared Yjs doc), it reads the same controller as the audience Deck.
106
+ const view = isPresenterWindow ? <PresenterView {...props} /> : <Deck {...props} />;
107
+ return (
108
+ <LiveProvider value={value}>
109
+ {brandStyle}
110
+ {view}
111
+ </LiveProvider>
112
+ );
113
+ }