@lemonriver/core 0.1.1 → 0.2.0

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 (82) hide show
  1. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  2. package/dist/cli/bin.js +4 -4
  3. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  4. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  5. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +4 -3
  8. package/dist/locale/index.d.ts +5 -2
  9. package/dist/locale/index.js +2 -2
  10. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  11. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  12. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  13. package/dist/vite/index.d.ts +1 -1
  14. package/dist/vite/index.js +1 -1
  15. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  16. package/package.json +13 -6
  17. package/skills/apply-comments/SKILL.md +10 -6
  18. package/skills/create-slide/SKILL.md +2 -0
  19. package/skills/current-slide/SKILL.md +1 -0
  20. package/skills/slide-authoring/SKILL.md +31 -1
  21. package/src/agent/prompt.ts +141 -0
  22. package/src/agent/provider.ts +123 -0
  23. package/src/agent/types.ts +151 -0
  24. package/src/app/app.tsx +2 -0
  25. package/src/app/assets/open-slide.svg +39 -0
  26. package/src/app/components/agent/agent-dock.tsx +35 -0
  27. package/src/app/components/agent/agent-markdown.tsx +45 -0
  28. package/src/app/components/agent/agent-panel.tsx +692 -0
  29. package/src/app/components/agent/agent-presence.tsx +74 -0
  30. package/src/app/components/agent/composer.tsx +120 -0
  31. package/src/app/components/agent/connect-view.tsx +184 -0
  32. package/src/app/components/agent/history-view.tsx +135 -0
  33. package/src/app/components/agent/provider-logo.tsx +34 -0
  34. package/src/app/components/agent/transcript.tsx +609 -0
  35. package/src/app/components/board/board-canvas.tsx +143 -12
  36. package/src/app/components/board/board.css +51 -0
  37. package/src/app/components/board/layout.test.ts +53 -1
  38. package/src/app/components/board/layout.ts +50 -2
  39. package/src/app/components/board/page-node.tsx +287 -13
  40. package/src/app/components/board/use-board-cards.ts +210 -0
  41. package/src/app/components/board/use-page-order.ts +17 -9
  42. package/src/app/components/command/slide-command-menu.tsx +10 -0
  43. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  44. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  45. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  46. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  47. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  48. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  49. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  50. package/src/app/components/panel/panel-shell.tsx +2 -2
  51. package/src/app/components/panel/resize-handle.tsx +129 -0
  52. package/src/app/components/player.tsx +0 -1
  53. package/src/app/components/preview-step-host.tsx +16 -0
  54. package/src/app/components/sidebar/sidebar.tsx +2 -2
  55. package/src/app/components/style-panel/style-panel.tsx +3 -2
  56. package/src/app/components/thumbnail-rail.tsx +76 -1
  57. package/src/app/favicon.ico +0 -0
  58. package/src/app/favicon.svg +39 -0
  59. package/src/app/index.html +2 -1
  60. package/src/app/lib/agent/use-agent.ts +543 -0
  61. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  62. package/src/app/lib/inspector/use-comments.ts +32 -3
  63. package/src/app/lib/locale-store.ts +6 -1
  64. package/src/app/lib/print-ready.ts +9 -6
  65. package/src/app/lib/use-auto-grow.ts +15 -0
  66. package/src/app/lib/utils.ts +4 -0
  67. package/src/app/routes/board.tsx +48 -11
  68. package/src/app/routes/home.tsx +43 -7
  69. package/src/app/routes/presenter.tsx +1 -16
  70. package/src/app/routes/preview.tsx +169 -0
  71. package/src/app/routes/slide.tsx +221 -173
  72. package/src/app/styles.css +234 -80
  73. package/src/editing/placeholder-id.ts +1 -0
  74. package/src/locale/en.ts +127 -5
  75. package/src/locale/es.ts +692 -0
  76. package/src/locale/index.ts +1 -0
  77. package/src/locale/ja.ts +120 -6
  78. package/src/locale/types.ts +132 -5
  79. package/src/locale/zh-cn.ts +117 -5
  80. package/src/locale/zh-tw.ts +117 -5
  81. package/src/app/assets/open-slide.png +0 -0
  82. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -11,27 +11,30 @@ export async function waitForFonts(): Promise<void> {
11
11
  await document.fonts.ready;
12
12
  }
13
13
 
14
+ // Resolves false when any target was still missing at the deadline.
14
15
  export async function waitForDataWaitfor(
15
16
  root: HTMLElement,
16
17
  timeoutMs = DEFAULT_WAITFOR_TIMEOUT_MS,
17
- ): Promise<void> {
18
+ ): Promise<boolean> {
18
19
  const targets = Array.from(root.querySelectorAll<HTMLElement>('[data-waitfor]'));
19
- if (targets.length === 0) return;
20
+ if (targets.length === 0) return true;
20
21
  const deadline = performance.now() + timeoutMs;
21
- await Promise.all(
22
+ const results = await Promise.all(
22
23
  targets.map(async (el) => {
23
24
  const selector = el.getAttribute('data-waitfor');
24
- if (!selector) return;
25
+ if (!selector) return true;
25
26
  while (performance.now() < deadline) {
26
27
  try {
27
- if (el.querySelector(selector)) return;
28
+ if (el.querySelector(selector)) return true;
28
29
  } catch {
29
- return; // invalid selector — skip rather than hang
30
+ return true; // invalid selector — skip rather than hang
30
31
  }
31
32
  await nextFrame();
32
33
  }
34
+ return false;
33
35
  }),
34
36
  );
37
+ return results.every(Boolean);
35
38
  }
36
39
 
37
40
  export function isFrameAnimationSettled(frame: Element): boolean {
@@ -0,0 +1,15 @@
1
+ import { type RefObject, useLayoutEffect } from 'react';
2
+
3
+ export function useAutoGrow(
4
+ ref: RefObject<HTMLTextAreaElement | null>,
5
+ value: string,
6
+ max: number,
7
+ ): void {
8
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text changes
9
+ useLayoutEffect(() => {
10
+ const node = ref.current;
11
+ if (!node) return;
12
+ node.style.height = '0px';
13
+ node.style.height = `${Math.min(max, Math.max(40, node.scrollHeight))}px`;
14
+ }, [ref, value, max]);
15
+ }
@@ -9,6 +9,10 @@ export function round2(n: number): number {
9
9
  return Math.round(n * 100) / 100;
10
10
  }
11
11
 
12
+ export function clamp(value: number, min: number, max: number): number {
13
+ return Math.min(max, Math.max(min, value));
14
+ }
15
+
12
16
  export function pad2(n: number): string {
13
17
  return n.toString().padStart(2, '0');
14
18
  }
@@ -1,11 +1,12 @@
1
1
  import { ChevronLeft } from 'lucide-react';
2
- import { lazy, Suspense, useCallback } from 'react';
2
+ import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
3
3
  import { useNavigate, useParams } from 'react-router-dom';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { useDocumentTitle } from '@/lib/use-document-title';
6
6
  import { format, useLocale } from '@/lib/use-locale';
7
7
  import { pad2 } from '@/lib/utils';
8
8
  import { columnStarts } from '../../files/board-structure';
9
+ import { useBoardCards } from '../components/board/use-board-cards';
9
10
  import { useBoardColumns } from '../components/board/use-board-columns';
10
11
  import { usePageOrder } from '../components/board/use-page-order';
11
12
  import type { Page } from '../lib/sdk';
@@ -17,6 +18,20 @@ const BoardCanvas = lazy(() => import('../components/board/board-canvas'));
17
18
  // the deck module is still loading.
18
19
  const NO_PAGES: Page[] = [];
19
20
 
21
+ const LINKS_KEY = 'open-slide:board-links-visible';
22
+
23
+ function useLinksVisible() {
24
+ const [visible, setVisible] = useState(() => {
25
+ if (typeof window === 'undefined') return true;
26
+ return window.localStorage.getItem(LINKS_KEY) !== '0';
27
+ });
28
+ useEffect(() => {
29
+ window.localStorage.setItem(LINKS_KEY, visible ? '1' : '0');
30
+ }, [visible]);
31
+ const toggle = useCallback(() => setVisible((v) => !v), []);
32
+ return [visible, toggle] as const;
33
+ }
34
+
20
35
  export function Board() {
21
36
  const { slideId = '' } = useParams();
22
37
  const navigate = useNavigate();
@@ -36,15 +51,24 @@ export function Board() {
36
51
  pages,
37
52
  columnTitle,
38
53
  );
54
+ const { ready, cards, pairs, patch, link, unlink, beginOp } = useBoardCards(
55
+ slideId,
56
+ modulePages,
57
+ pages.length,
58
+ );
59
+ const [showLinks, toggleLinks] = useLinksVisible();
39
60
 
40
- // Order and structure move together: every path that reorders a deck goes
41
- // through here, so the columns can never drift out of step with the pages.
61
+ // Order, structure and the board's own card data move together: every path
62
+ // that changes the deck's pages goes through here, so none of them can drift
63
+ // out of step with the pages.
42
64
  const movePage = useCallback(
43
65
  (from: number, to: number) => {
66
+ if (from === to) return;
67
+ const settle = beginOp({ kind: 'move', from, to });
44
68
  moveBetweenColumns(from, to);
45
- void reorder(from, to);
69
+ void reorder(from, to).then(settle);
46
70
  },
47
- [moveBetweenColumns, reorder],
71
+ [beginOp, moveBetweenColumns, reorder],
48
72
  );
49
73
 
50
74
  const openPage = useCallback(
@@ -56,10 +80,11 @@ export function Board() {
56
80
 
57
81
  const duplicatePage = useCallback(
58
82
  (index: number) => {
83
+ const settle = beginOp({ kind: 'duplicate', index });
59
84
  resizeColumnAt(index, 1);
60
- void duplicate(index);
85
+ void duplicate(index).then(settle);
61
86
  },
62
- [duplicate, resizeColumnAt],
87
+ [beginOp, duplicate, resizeColumnAt],
63
88
  );
64
89
 
65
90
  // A new page lands at the end of the column whose + was clicked.
@@ -69,10 +94,12 @@ export function Board() {
69
94
  const starts = columnStarts(columns);
70
95
  const start = starts[columnIndex];
71
96
  const size = columns[columnIndex].size;
97
+ const at = size > 0 ? start + size : start;
98
+ const settle = beginOp({ kind: 'insert', index: at });
72
99
  growColumn(columnIndex, 1);
73
- void insert(size > 0 ? start + size : start);
100
+ void insert(at).then(settle);
74
101
  },
75
- [columns, growColumn, insert],
102
+ [beginOp, columns, growColumn, insert],
76
103
  );
77
104
 
78
105
  const moveToEnd = useCallback(
@@ -82,10 +109,12 @@ export function Board() {
82
109
 
83
110
  const deletePage = useCallback(
84
111
  (index: number) => {
112
+ if (pages.length <= 1) return;
113
+ const settle = beginOp({ kind: 'delete', index });
85
114
  resizeColumnAt(index, -1);
86
- void remove(index);
115
+ void remove(index).then(settle);
87
116
  },
88
- [remove, resizeColumnAt],
117
+ [beginOp, pages.length, remove, resizeColumnAt],
89
118
  );
90
119
 
91
120
  return (
@@ -123,6 +152,10 @@ export function Board() {
123
152
  slideId={slideId}
124
153
  pages={pages}
125
154
  notes={slide?.notes}
155
+ cards={cards}
156
+ cardsReady={ready}
157
+ links={pairs}
158
+ showLinks={showLinks}
126
159
  columns={columns}
127
160
  design={slide?.design}
128
161
  onOpenPage={openPage}
@@ -132,6 +165,10 @@ export function Board() {
132
165
  onDuplicate={duplicatePage}
133
166
  onDelete={deletePage}
134
167
  onMoveToEnd={moveToEnd}
168
+ onPatchCard={patch}
169
+ onLink={link}
170
+ onUnlink={unlink}
171
+ onToggleLinks={toggleLinks}
135
172
  />
136
173
  </Suspense>
137
174
  )}
@@ -8,6 +8,7 @@ import {
8
8
  MoreHorizontal,
9
9
  Palette,
10
10
  Pencil,
11
+ Plus,
11
12
  Search,
12
13
  Trash2,
13
14
  X,
@@ -30,8 +31,10 @@ import {
30
31
  DropdownMenuItem,
31
32
  DropdownMenuTrigger,
32
33
  } from '@/components/ui/dropdown-menu';
34
+ import { useAgentStatus, useCreateDeck } from '@/lib/agent/use-agent';
33
35
  import { format, useLocale } from '@/lib/use-locale';
34
36
  import { cn, pad2 } from '@/lib/utils';
37
+ import { ProviderLogo } from '../components/agent/provider-logo';
35
38
  import { FolderIconChip, SLIDE_DND_MIME, SystemViewIcon } from '../components/sidebar/folder-item';
36
39
  import { ALL_SLIDES_ID, DRAFT_ID } from '../components/sidebar/sidebar';
37
40
  import { SlideCanvas } from '../components/slide-canvas';
@@ -193,6 +196,7 @@ export function Home() {
193
196
  <div className="ml-auto flex w-full items-center gap-2 md:w-auto">
194
197
  <SortControl value={sortKey} onChange={setSortKey} />
195
198
  <SearchInput value={query} onChange={setQuery} />
199
+ {import.meta.env.DEV && <NewDeckButton />}
196
200
  </div>
197
201
  </div>
198
202
  </header>
@@ -319,6 +323,30 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
319
323
  );
320
324
  }
321
325
 
326
+ function useCreateDeckWithAgent() {
327
+ const { provider } = useAgentStatus();
328
+ const start = useCreateDeck();
329
+ if (!provider) return null;
330
+ return { provider, start };
331
+ }
332
+
333
+ function NewDeckButton() {
334
+ const t = useLocale();
335
+ const agent = useCreateDeckWithAgent();
336
+ if (!agent) return null;
337
+ return (
338
+ <Button
339
+ size="sm"
340
+ className="h-8 shrink-0"
341
+ onClick={agent.start}
342
+ title={format(t.agent.createDeckWith, { provider: agent.provider.name })}
343
+ >
344
+ <Plus />
345
+ {t.agent.createDeck}
346
+ </Button>
347
+ );
348
+ }
349
+
322
350
  function HomeLoading() {
323
351
  const t = useLocale();
324
352
  return (
@@ -360,6 +388,7 @@ function NoResultsState({ query, onClear }: { query: string; onClear: () => void
360
388
 
361
389
  function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
362
390
  const t = useLocale();
391
+ const agent = useCreateDeckWithAgent();
363
392
  const folderEmptyTitle = t.home.folderEmptyTitle.replace(
364
393
  '{name}',
365
394
  folderName ?? t.home.folderEmptyTitle,
@@ -373,13 +402,20 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
373
402
  <p className="mt-4 font-heading text-[14px] font-semibold tracking-tight">
374
403
  {t.home.noSlidesYet}
375
404
  </p>
376
- <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
377
- {t.home.createSlideHintPrefix}
378
- <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
379
- /create-slide
380
- </code>
381
- {t.home.createSlideHintSuffix}
382
- </p>
405
+ {agent ? (
406
+ <Button size="sm" className="mt-4" onClick={agent.start}>
407
+ <ProviderLogo provider={agent.provider.id} />
408
+ {format(t.agent.createDeckWith, { provider: agent.provider.name })}
409
+ </Button>
410
+ ) : (
411
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
412
+ {t.home.createSlideHintPrefix}
413
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
414
+ /create-slide
415
+ </code>
416
+ {t.home.createSlideHintSuffix}
417
+ </p>
418
+ )}
383
419
  </>
384
420
  ) : (
385
421
  <>
@@ -11,7 +11,6 @@ import {
11
11
  } from 'lucide-react';
12
12
  import {
13
13
  type KeyboardEvent as ReactKeyboardEvent,
14
- type ReactNode,
15
14
  useCallback,
16
15
  useEffect,
17
16
  useRef,
@@ -29,12 +28,12 @@ import {
29
28
  type PresenterState,
30
29
  usePresenterChannel,
31
30
  } from '../components/present/use-presenter-channel';
31
+ import { PreviewStepHost } from '../components/preview-step-host';
32
32
  import { SlideCanvas } from '../components/slide-canvas';
33
33
  import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer';
34
34
  import { SlidePageProvider } from '../lib/page-context';
35
35
  import { CANVAS_HEIGHT, CANVAS_WIDTH, type SlideModule } from '../lib/sdk';
36
36
  import { loadSlide, slideIds } from '../lib/slides';
37
- import { type StepController, StepHost } from '../lib/step-context';
38
37
  import { useSlideModule } from '../lib/use-slide-module';
39
38
 
40
39
  export function Presenter() {
@@ -653,20 +652,6 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
653
652
  return <span className="eyebrow">{children}</span>;
654
653
  }
655
654
 
656
- function PreviewStepHost({ revealed, children }: { revealed: number; children: ReactNode }) {
657
- const noopControllerRef = useRef<StepController | null>(null);
658
- return (
659
- <StepHost
660
- isActivePage={false}
661
- entryDirection="jump"
662
- controllerRef={noopControllerRef}
663
- controlledRevealed={revealed}
664
- >
665
- {children}
666
- </StepHost>
667
- );
668
- }
669
-
670
655
  function Clock() {
671
656
  const [now, setNow] = useState(() => new Date());
672
657
  const t = useLocale();
@@ -0,0 +1,169 @@
1
+ import { Component, type ReactNode, useEffect, useRef, useState } from 'react';
2
+ import { useParams, useSearchParams } from 'react-router-dom';
3
+ import { useDocumentTitle } from '@/lib/use-document-title';
4
+ import { PreviewStepHost } from '../components/preview-step-host';
5
+ import { SlideCanvas } from '../components/slide-canvas';
6
+ import { nextPaint, sleep } from '../lib/dom';
7
+ import { SlidePageProvider } from '../lib/page-context';
8
+ import { isFrameAnimationSettled, waitForDataWaitfor, waitForFonts } from '../lib/print-ready';
9
+ import { useSlideModule } from '../lib/use-slide-module';
10
+
11
+ const SETTLE_TIMEOUT_MS = 10_000;
12
+ const POLL_INTERVAL_MS = 50;
13
+
14
+ type Settle = { status: 'loading' } | { status: 'ready' } | { status: 'error'; message: string };
15
+
16
+ function parseIntParam(raw: string | null): number | null {
17
+ if (raw === null || raw.trim() === '') return null;
18
+ const n = Number(raw);
19
+ return Number.isInteger(n) ? n : Number.NaN;
20
+ }
21
+
22
+ function settlesWithin(promise: Promise<unknown>, ms: number): Promise<boolean> {
23
+ return Promise.race([promise.then(() => true), sleep(ms).then(() => false)]);
24
+ }
25
+
26
+ function decodeImages(root: HTMLElement): Promise<unknown> {
27
+ const images = Array.from(root.querySelectorAll('img'));
28
+ return Promise.all(images.map((img) => img.decode().catch(() => {})));
29
+ }
30
+
31
+ type PageErrorBoundaryProps = {
32
+ resetKey: unknown;
33
+ onError: (error: unknown) => void;
34
+ children: ReactNode;
35
+ };
36
+
37
+ class PageErrorBoundary extends Component<PageErrorBoundaryProps, { failed: boolean }> {
38
+ state = { failed: false };
39
+
40
+ static getDerivedStateFromError() {
41
+ return { failed: true };
42
+ }
43
+
44
+ componentDidCatch(error: unknown) {
45
+ this.props.onError(error);
46
+ }
47
+
48
+ componentDidUpdate(prev: PageErrorBoundaryProps) {
49
+ if (this.state.failed && prev.resetKey !== this.props.resetKey) {
50
+ this.setState({ failed: false });
51
+ }
52
+ }
53
+
54
+ render() {
55
+ return this.state.failed ? null : this.props.children;
56
+ }
57
+ }
58
+
59
+ export function Preview() {
60
+ const { slideId = '' } = useParams();
61
+ const [searchParams] = useSearchParams();
62
+ const { slide, error: loadError } = useSlideModule(slideId);
63
+ const frameRef = useRef<HTMLDivElement>(null);
64
+ const [settle, setSettle] = useState<Settle>({ status: 'loading' });
65
+ const [renderError, setRenderError] = useState<{ page: unknown; message: string } | null>(null);
66
+
67
+ const pageParam = parseIntParam(searchParams.get('p'));
68
+ const stepParam = parseIntParam(searchParams.get('step'));
69
+ const pages = slide?.default ?? [];
70
+ const pageNumber = pageParam ?? 1;
71
+ const Page = pages[pageNumber - 1];
72
+
73
+ let inputError = loadError;
74
+ if (!inputError && slide) {
75
+ if (pages.length === 0) inputError = `Slide "${slideId}" has no pages.`;
76
+ else if (!Page) {
77
+ inputError = `Page ${searchParams.get('p')} is out of range (1–${pages.length}).`;
78
+ } else if (stepParam !== null && (Number.isNaN(stepParam) || stepParam < 0)) {
79
+ inputError = `Invalid step "${searchParams.get('step')}" — expected a non-negative integer.`;
80
+ }
81
+ }
82
+
83
+ useDocumentTitle(slide ? `${slide.meta?.title ?? slideId} · ${pageNumber}` : undefined);
84
+
85
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a new step reveals content that has to settle again
86
+ useEffect(() => {
87
+ setSettle({ status: 'loading' });
88
+ if (!Page || inputError) return;
89
+ let cancelled = false;
90
+ (async () => {
91
+ await nextPaint();
92
+ const frame = frameRef.current;
93
+ if (!frame) return;
94
+ const deadline = performance.now() + SETTLE_TIMEOUT_MS;
95
+ const remaining = () => Math.max(0, deadline - performance.now());
96
+ const fontsLoaded = await settlesWithin(waitForFonts(), remaining());
97
+ const targetsFound = await waitForDataWaitfor(frame, remaining());
98
+ const imagesDecoded = await settlesWithin(decodeImages(frame), remaining());
99
+ while (!cancelled && !isFrameAnimationSettled(frame) && remaining() > 0) {
100
+ await sleep(POLL_INTERVAL_MS);
101
+ }
102
+ const animationsSettled = isFrameAnimationSettled(frame);
103
+ await nextPaint();
104
+ if (cancelled) return;
105
+ const pending = [
106
+ !fontsLoaded && 'fonts to load',
107
+ !targetsFound && 'a [data-waitfor] target to appear',
108
+ !imagesDecoded && 'images to decode',
109
+ !animationsSettled && 'animations to finish',
110
+ ].filter(Boolean);
111
+ setSettle(
112
+ pending.length > 0
113
+ ? { status: 'error', message: `Timed out waiting for ${pending.join(', ')}.` }
114
+ : { status: 'ready' },
115
+ );
116
+ })();
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [Page, inputError, stepParam]);
121
+
122
+ const pageError = renderError && renderError.page === Page ? renderError.message : null;
123
+ const error = inputError ?? pageError ?? (settle.status === 'error' ? settle.message : null);
124
+ const state = error ? 'error' : settle.status;
125
+
126
+ return (
127
+ <div
128
+ ref={frameRef}
129
+ data-osd-preview={state}
130
+ data-osd-preview-page={Page ? pageNumber : undefined}
131
+ data-osd-preview-total={slide ? pages.length : undefined}
132
+ className="h-screen w-screen overflow-hidden bg-black"
133
+ >
134
+ {error ? (
135
+ <pre
136
+ data-osd-preview-error
137
+ className="m-0 p-6 font-mono text-[14px] whitespace-pre-wrap text-red-400"
138
+ >
139
+ {error}
140
+ </pre>
141
+ ) : (
142
+ slide &&
143
+ Page && (
144
+ <SlideCanvas flat freezeMotion design={slide.design}>
145
+ <SlidePageProvider index={pageNumber - 1} total={pages.length}>
146
+ <PageErrorBoundary
147
+ resetKey={Page}
148
+ onError={(e) =>
149
+ setRenderError({
150
+ page: Page,
151
+ message: `Page ${pageNumber} threw while rendering:\n${e instanceof Error ? (e.stack ?? e.message) : String(e)}`,
152
+ })
153
+ }
154
+ >
155
+ {stepParam === null ? (
156
+ <Page />
157
+ ) : (
158
+ <PreviewStepHost revealed={stepParam}>
159
+ <Page />
160
+ </PreviewStepHost>
161
+ )}
162
+ </PageErrorBoundary>
163
+ </SlidePageProvider>
164
+ </SlideCanvas>
165
+ )
166
+ )}
167
+ </div>
168
+ );
169
+ }