@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,61 @@
1
+ import type { CodeToken, KeyedStep, TokenizedStep } from "./types";
2
+
3
+ const lineText = (tokens: CodeToken[]): string => tokens.map((t) => t.content).join("");
4
+
5
+ /** For each line in `cur`, the index of the line it matches in `prev` (or null).
6
+ * Standard LCS over line text, biased to keep the longest common run aligned so
7
+ * unchanged lines around an edit keep their identity. */
8
+ export function matchLines(prev: string[], cur: string[]): (number | null)[] {
9
+ const m = prev.length;
10
+ const n = cur.length;
11
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
12
+ for (let i = m - 1; i >= 0; i--) {
13
+ for (let j = n - 1; j >= 0; j--) {
14
+ dp[i]![j] = prev[i] === cur[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
15
+ }
16
+ }
17
+ const match = new Array<number | null>(n).fill(null);
18
+ let i = 0;
19
+ let j = 0;
20
+ while (i < m && j < n) {
21
+ if (prev[i] === cur[j]) {
22
+ match[j] = i;
23
+ i++;
24
+ j++;
25
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
26
+ i++;
27
+ } else {
28
+ j++;
29
+ }
30
+ }
31
+ return match;
32
+ }
33
+
34
+ /** Assign every line in every step a key that is stable for the "same" line across
35
+ * consecutive steps (matched by LCS, chained). Lines that persist keep their key
36
+ * → Motion morphs their position; new keys enter, dropped keys exit. */
37
+ export function keySteps(steps: TokenizedStep[]): KeyedStep[] {
38
+ const out: KeyedStep[] = [];
39
+ let counter = 0;
40
+ let prevKeys: string[] = [];
41
+ let prevText: string[] = [];
42
+
43
+ for (let s = 0; s < steps.length; s++) {
44
+ const lines = steps[s]!.lines;
45
+ const texts = lines.map(lineText);
46
+ let keys: string[];
47
+ if (s === 0) {
48
+ keys = texts.map(() => `l${counter++}`);
49
+ } else {
50
+ const match = matchLines(prevText, texts);
51
+ keys = texts.map((_, idx) => {
52
+ const p = match[idx];
53
+ return p != null ? prevKeys[p]! : `l${counter++}`;
54
+ });
55
+ }
56
+ out.push({ lines: lines.map((tokens, idx) => ({ key: keys[idx]!, tokens })) });
57
+ prevKeys = keys;
58
+ prevText = texts;
59
+ }
60
+ return out;
61
+ }
@@ -0,0 +1,24 @@
1
+ // Bun macro entrypoint. Import it with `with { type: "macro" }` from a deck so the
2
+ // code is tokenized at BUILD time and the result inlined as a literal, no Shiki,
3
+ // grammars, or WASM ship to the browser. Pass string literals so the bundler can
4
+ // evaluate the call statically.
5
+ //
6
+ // import { codeStory } from "@liebstoeckel/engine/code" with { type: "macro" };
7
+ // <CodeMagic steps={codeStory([{ code: `const a = 1`, lang: "ts" }, …])} />
8
+ import { tokenizeStep } from "./tokenize";
9
+ import type { TokenizedStep } from "./types";
10
+
11
+ export interface CodeInput {
12
+ code: string;
13
+ lang?: string;
14
+ }
15
+
16
+ /** Tokenize a sequence of code states for <CodeMagic>. */
17
+ export async function codeStory(steps: CodeInput[]): Promise<TokenizedStep[]> {
18
+ return Promise.all(steps.map((s) => tokenizeStep(s.code, s.lang)));
19
+ }
20
+
21
+ /** Tokenize a single code state (a one-step story). */
22
+ export async function code(src: string, lang = "ts"): Promise<TokenizedStep> {
23
+ return tokenizeStep(src, lang);
24
+ }
@@ -0,0 +1,72 @@
1
+ // BUILD-TIME ONLY. Tokenizes code with Shiki using its css-variables theme, so
2
+ // every token's color is a `var(--shiki-token-*)` string that the theme binds to
3
+ // the active brand at runtime. Imported only from the `code` macro (stripped from
4
+ // the browser bundle) and from build tooling, never from the deck runtime.
5
+ import {
6
+ createCssVariablesTheme,
7
+ createHighlighter,
8
+ type BundledLanguage,
9
+ type Highlighter,
10
+ type SpecialLanguage,
11
+ } from "shiki";
12
+ import type { CodeToken, TokenizedStep } from "./types";
13
+
14
+ const THEME = createCssVariablesTheme({ name: "brand", variablePrefix: "--shiki-", fontStyle: true });
15
+
16
+ // A pragmatic default language set; build-time cost only.
17
+ const LANGS = [
18
+ "typescript",
19
+ "tsx",
20
+ "javascript",
21
+ "jsx",
22
+ "json",
23
+ "bash",
24
+ "html",
25
+ "css",
26
+ "python",
27
+ "rust",
28
+ "go",
29
+ "sql",
30
+ "yaml",
31
+ "markdown",
32
+ "diff",
33
+ ] as const;
34
+
35
+ let hl: Promise<Highlighter> | null = null;
36
+ function highlighter(): Promise<Highlighter> {
37
+ if (!hl) hl = createHighlighter({ themes: [THEME], langs: LANGS as unknown as string[] });
38
+ return hl;
39
+ }
40
+
41
+ function aliasLang(lang: string): string {
42
+ const l = lang.toLowerCase();
43
+ return (
44
+ {
45
+ ts: "typescript",
46
+ js: "javascript",
47
+ mjs: "javascript",
48
+ sh: "bash",
49
+ shell: "bash",
50
+ zsh: "bash",
51
+ md: "markdown",
52
+ yml: "yaml",
53
+ py: "python",
54
+ rs: "rust",
55
+ }[l] ?? l
56
+ );
57
+ }
58
+
59
+ /** Tokenize one code state. Unknown languages fall back to plain text. */
60
+ export async function tokenizeStep(code: string, lang = "ts"): Promise<TokenizedStep> {
61
+ const h = await highlighter();
62
+ const resolved = aliasLang(lang);
63
+ const useLang = (h.getLoadedLanguages().includes(resolved) ? resolved : "text") as
64
+ | BundledLanguage
65
+ | SpecialLanguage;
66
+ const src = code.replace(/\n+$/, "");
67
+ const { tokens } = h.codeToTokens(src, { lang: useLang, theme: "brand" });
68
+ const lines: CodeToken[][] = tokens.map((line) =>
69
+ line.map((t) => ({ content: t.content, color: t.color, fontStyle: t.fontStyle })),
70
+ );
71
+ return { lang: resolved, lines };
72
+ }
@@ -0,0 +1,24 @@
1
+ // Build-time tokenized code, shaped to be macro-serializable (plain JSON).
2
+ // `fontStyle` is Shiki's FontStyle bitfield: 1=italic, 2=bold, 4=underline.
3
+
4
+ export interface CodeToken {
5
+ content: string;
6
+ color?: string;
7
+ fontStyle?: number;
8
+ }
9
+
10
+ /** One code state: lines of colored tokens, produced by Shiki at build time. */
11
+ export interface TokenizedStep {
12
+ lang: string;
13
+ lines: CodeToken[][];
14
+ }
15
+
16
+ export interface KeyedLine {
17
+ /** stable across steps for the "same" line, so it FLIP-animates between states */
18
+ key: string;
19
+ tokens: CodeToken[];
20
+ }
21
+
22
+ export interface KeyedStep {
23
+ lines: KeyedLine[];
24
+ }
@@ -0,0 +1,32 @@
1
+ // Pure helpers for live-delivery controls (fullscreen, numeric jump, step nav).
2
+
3
+ export const clampIndex = (n: number, count: number) => Math.min(Math.max(n, 0), Math.max(count - 1, 0));
4
+
5
+ export function fullscreenAction(isFullscreen: boolean): "enter" | "exit" {
6
+ return isFullscreen ? "exit" : "enter";
7
+ }
8
+
9
+ export async function toggleFullscreen(el: Element): Promise<void> {
10
+ if (typeof document === "undefined") return;
11
+ if (document.fullscreenElement) await document.exitFullscreen();
12
+ else await (el as HTMLElement).requestFullscreen?.();
13
+ }
14
+
15
+ /** Accumulate digit keys into a buffer; Enter commits a 1-based slide number,
16
+ * Escape clears. Returns the next buffer and a committed (0-based) index | null. */
17
+ export function accumulateDigits(buffer: string, key: string): { buffer: string; commit: number | null } {
18
+ if (/^[0-9]$/.test(key)) return { buffer: (buffer + key).slice(0, 3), commit: null };
19
+ if (key === "Enter" && buffer) return { buffer: "", commit: parseInt(buffer, 10) - 1 };
20
+ if (key === "Escape") return { buffer: "", commit: null };
21
+ return { buffer, commit: null };
22
+ }
23
+
24
+ /** Advance within a slide's steps; once past the last step, signal a slide change. */
25
+ export function stepForward(step: number, total: number): { step: number; advanceSlide: boolean } {
26
+ return step < total ? { step: step + 1, advanceSlide: false } : { step: 0, advanceSlide: true };
27
+ }
28
+
29
+ /** Retreat a step; at the first step, signal a slide change. */
30
+ export function stepBack(step: number): { step: number; retreatSlide: boolean } {
31
+ return step > 0 ? { step: step - 1, retreatSlide: false } : { step: 0, retreatSlide: true };
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ export { Present } from "./Present";
2
+ export { Deck, type DeckProps } from "./Deck";
3
+ export { PresenterView } from "./PresenterView";
4
+ export { ScaledStage, SlideFrame, STAGE_W, STAGE_H } from "./Stage";
5
+ export { HelpOverlay } from "./HelpOverlay";
6
+ export { useDeckSync } from "./useDeckSync";
7
+ export { useDeckNav } from "./nav";
8
+ export { normalizeSlides, type SlideInput } from "./slides";
9
+ export {
10
+ resolveTransition,
11
+ TRANSITIONS,
12
+ DEFAULT_TRANSITION,
13
+ type SlideTransition,
14
+ type SlideTransitionName,
15
+ type SlideTransitionSpec,
16
+ type SlideDirection,
17
+ } from "./transitions";
18
+ export { CaptureView } from "./CaptureView";
19
+ export { PrintView } from "./PrintView";
20
+ export { readThumbnails, type ThumbnailSet } from "./thumbnails";
21
+ export { hasEmbeddedSource } from "./source";
22
+ export { Step, StepsProvider, useRevealState } from "./steps";
23
+ export { CodeMagic } from "./CodeMagic";
24
+ export type { TokenizedStep, CodeToken } from "./code/types";
25
+ export {
26
+ fullscreenAction,
27
+ toggleFullscreen,
28
+ accumulateDigits,
29
+ stepForward,
30
+ stepBack,
31
+ clampIndex,
32
+ } from "./delivery";
33
+ export {
34
+ PersistentProvider,
35
+ PersistentLayer,
36
+ Slot,
37
+ type PersistentItem,
38
+ } from "./PersistentLayer";
39
+ export { Magic, Atmosphere } from "@liebstoeckel/components";
40
+ export {
41
+ Plugin,
42
+ LiveProvider,
43
+ useLive,
44
+ detectLive,
45
+ getParticipantId,
46
+ connectLive,
47
+ getDeckIndex,
48
+ setDeckIndex,
49
+ useLiveDeck,
50
+ mergeUi,
51
+ type LiveInfo,
52
+ type LiveConnection,
53
+ type LiveContextValue,
54
+ type DeckController,
55
+ } from "./live";
@@ -0,0 +1,160 @@
1
+ import { createContext, useContext, useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
2
+ import { AnimatePresence, LayoutGroup } from "motion/react";
3
+ import type * as Y from "yjs";
4
+ import { pluginState, registerPluginInstance, type ClientProps, type PluginDef, type Role, type ThemeTokens } from "@liebstoeckel/plugin-sdk";
5
+ import { mergeUi } from "./ui";
6
+ import { GlowTap, BreakoutSheet, useBreakoutEligible } from "./breakout";
7
+ import { PluginBoundary } from "./PluginBoundary";
8
+
9
+ export interface LiveContextValue {
10
+ live: boolean;
11
+ role: Role;
12
+ participant: string;
13
+ doc: Y.Doc;
14
+ theme: ThemeTokens;
15
+ /** read-only follow-along link, for the in-deck QR (live only) */
16
+ viewerUrl?: string;
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ plugins: Record<string, PluginDef<any>>;
19
+ }
20
+
21
+ const LiveCtx = createContext<LiveContextValue | null>(null);
22
+ export const useLive = (): LiveContextValue | null => useContext(LiveCtx);
23
+
24
+ export function LiveProvider({ value, children }: { value: LiveContextValue; children: ReactNode }) {
25
+ return <LiveCtx.Provider value={value}>{children}</LiveCtx.Provider>;
26
+ }
27
+
28
+ /** Subscribe to a plugin's slice of the shared doc and assemble its `ClientProps`.
29
+ * The single piece of plumbing behind `<Plugin>`, the global surfaces, and the
30
+ * presenter console, so every surface reads the same live state identically. */
31
+ export function usePluginProps(
32
+ ctx: LiveContextValue,
33
+ id: string,
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ def: PluginDef<any>,
36
+ props: Record<string, unknown> = {},
37
+ instance = "",
38
+ ): ClientProps<unknown> {
39
+ const state = useMemo(() => pluginState(ctx.doc, id, def.state, instance), [ctx.doc, id, def, instance]);
40
+ const [snap, setSnap] = useState<unknown>(() => state.snapshot());
41
+ useEffect(() => {
42
+ setSnap(state.snapshot());
43
+ return state.subscribe(setSnap);
44
+ }, [state]);
45
+ return {
46
+ doc: ctx.doc,
47
+ state,
48
+ snapshot: snap,
49
+ role: ctx.role,
50
+ live: ctx.live,
51
+ participantId: ctx.participant,
52
+ theme: ctx.theme,
53
+ ui: mergeUi({}, {}),
54
+ props,
55
+ instance,
56
+ };
57
+ }
58
+
59
+ /** Place a plugin in a slide. Renders the plugin's `Slide` when a server is
60
+ * connected, else its `fallback`. Subscribes to the shared state snapshot. */
61
+ export function Plugin({
62
+ id,
63
+ instance = "",
64
+ title,
65
+ props = {},
66
+ components = {},
67
+ }: {
68
+ id: string;
69
+ /** instance discriminator (ADR 0033); omit for the default slice. Two placements with
70
+ * the same (id, instance) share state, that's how you intentionally mirror one. */
71
+ instance?: string;
72
+ /** optional human label for this instance, used in the presenter tabs to tell
73
+ * sibling instances apart (a plugin's `presenter.title(snapshot)` takes precedence). */
74
+ title?: string;
75
+ props?: Record<string, unknown>;
76
+ components?: Record<string, ComponentType<Record<string, unknown>>>;
77
+ }) {
78
+ const ctx = useContext(LiveCtx);
79
+ const def = ctx?.plugins[id];
80
+ const state = useMemo(
81
+ () => (ctx && def ? pluginState(ctx.doc, id, def.state, instance) : null),
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ [ctx?.doc, id, def, instance],
84
+ );
85
+ const [snap, setSnap] = useState<unknown>(() => state?.snapshot());
86
+ const [open, setOpen] = useState(false);
87
+ // hooks run unconditionally (before any early return)
88
+ const interactive = def?.client.interactive !== false;
89
+ const eligible = useBreakoutEligible(interactive);
90
+
91
+ useEffect(() => {
92
+ if (!state) return;
93
+ setSnap(state.snapshot());
94
+ return state.subscribe(setSnap);
95
+ }, [state]);
96
+
97
+ // Register this instance so the presenter console / server can discover it (ADR 0033).
98
+ useEffect(() => {
99
+ if (ctx?.live && def) registerPluginInstance(ctx.doc, id, instance, { title });
100
+ }, [ctx?.live, ctx?.doc, def, id, instance, title]);
101
+
102
+ // Inside a <Present> but the id isn't in its `plugins={[…]}` — a very common authoring
103
+ // slip. Without this the component silently renders nothing (no fallback, no error),
104
+ // leaving a baffling blank slide. Warn so the mistake explains itself.
105
+ useEffect(() => {
106
+ if (ctx && !def) {
107
+ console.warn(
108
+ `[liebstoeckel] <Plugin id="${id}"> is not registered — add its plugin to ` +
109
+ `<Present plugins={[…]}> or it renders nothing.`,
110
+ );
111
+ }
112
+ }, [ctx, def, id]);
113
+
114
+ if (!ctx || !def || !state) return null;
115
+
116
+ if (!ctx.live) {
117
+ const Fb = def.client.fallback;
118
+ return Fb ? <Fb snapshot={snap as never} props={props} /> : null;
119
+ }
120
+
121
+ const Slide = def.client.Slide;
122
+ // Isolate each surface's Motion layout tree: a plugin's `layoutId` (e.g. Q&A's ranked
123
+ // rows) is also rendered by other live surfaces of the same plugin, the global panel,
124
+ // the presenter console, the mobile breakout. Sharing one `layoutId` across them makes
125
+ // Motion morph one into another (rows "disappear" from the slide). A per-surface
126
+ // LayoutGroup namespaces the ids so intra-surface animation still works but they never
127
+ // collide across surfaces (the ADR 0026 hazard, generalised).
128
+ const slide = (key: string) => (
129
+ <LayoutGroup key={key} id={`plugin:${id}:${instance}:${key}`}>
130
+ <PluginBoundary resetKey={snap}>
131
+ <Slide
132
+ doc={ctx.doc}
133
+ state={state}
134
+ snapshot={snap as never}
135
+ role={ctx.role}
136
+ live={ctx.live}
137
+ participantId={ctx.participant}
138
+ theme={ctx.theme}
139
+ ui={mergeUi({}, components)}
140
+ props={props}
141
+ instance={instance}
142
+ />
143
+ </PluginBoundary>
144
+ </LayoutGroup>
145
+ );
146
+
147
+ // Desktop / fine pointer: inline, as today.
148
+ if (!eligible) return slide("inline");
149
+
150
+ // Touch + shrunk stage: a tappable glowing preview (non-interactive) that opens
151
+ // the same plugin full-size in a sheet outside the scaled canvas.
152
+ return (
153
+ <>
154
+ <GlowTap label={id} onOpen={() => setOpen(true)}>
155
+ {slide("preview")}
156
+ </GlowTap>
157
+ <AnimatePresence>{open && <BreakoutSheet label={id} onClose={() => setOpen(false)}>{slide("sheet")}</BreakoutSheet>}</AnimatePresence>
158
+ </>
159
+ );
160
+ }
@@ -0,0 +1,34 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+
3
+ // Contain a single plugin surface's render failure. A live deck shares its state with
4
+ // an untrusted audience, so a malformed remote value could otherwise throw during render
5
+ // (e.g. React refusing a non-string child) and unmount the *whole* deck — a deck-wide
6
+ // overlay that throws white-screens the presentation for everyone. State is sanitized
7
+ // upstream so this should never fire, but it's the last line of defense: a failing plugin
8
+ // degrades to its fallback (nothing by default) and the rest of the deck keeps rendering.
9
+ // It auto-recovers when `resetKey` changes (e.g. the offending state entry is replaced).
10
+ export class PluginBoundary extends Component<
11
+ { children: ReactNode; fallback?: ReactNode; resetKey?: unknown },
12
+ { failed: boolean }
13
+ > {
14
+ state = { failed: false };
15
+
16
+ static getDerivedStateFromError(): { failed: boolean } {
17
+ return { failed: true };
18
+ }
19
+
20
+ componentDidUpdate(prev: Readonly<{ resetKey?: unknown }>) {
21
+ if (this.state.failed && prev.resetKey !== this.props.resetKey) {
22
+ this.setState({ failed: false });
23
+ }
24
+ }
25
+
26
+ componentDidCatch(err: Error, info: ErrorInfo) {
27
+ // eslint-disable-next-line no-console
28
+ console.error("[liebstoeckel] plugin render error (contained):", err, info.componentStack);
29
+ }
30
+
31
+ render() {
32
+ return this.state.failed ? (this.props.fallback ?? null) : this.props.children;
33
+ }
34
+ }