@open-slide/core 1.2.0 → 1.4.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 (56) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
  4. package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
  5. package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
  6. package/dist/en-7GU-DHbJ.js +361 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +136 -342
  11. package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/slide-authoring/SKILL.md +21 -2
  20. package/src/app/app.tsx +13 -1
  21. package/src/app/components/asset-view.tsx +37 -22
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  24. package/src/app/components/inspector/inspector-panel.tsx +370 -30
  25. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  26. package/src/app/components/player.tsx +25 -5
  27. package/src/app/components/present/control-bar.tsx +12 -0
  28. package/src/app/components/sidebar/folder-item.tsx +27 -5
  29. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  30. package/src/app/components/sidebar/sidebar.tsx +20 -0
  31. package/src/app/components/themes/theme-detail.tsx +300 -0
  32. package/src/app/components/themes/themes-gallery.tsx +146 -0
  33. package/src/app/components/thumbnail-rail.tsx +17 -5
  34. package/src/app/lib/assets.ts +55 -2
  35. package/src/app/lib/export-pdf.ts +6 -0
  36. package/src/app/lib/inspector/use-editor.ts +9 -1
  37. package/src/app/lib/sdk.ts +1 -0
  38. package/src/app/lib/slides.ts +17 -1
  39. package/src/app/lib/themes.ts +22 -0
  40. package/src/app/lib/use-agent-socket.ts +18 -0
  41. package/src/app/lib/use-slide-module.ts +48 -0
  42. package/src/app/routes/assets.tsx +9 -0
  43. package/src/app/routes/home-shell.tsx +194 -0
  44. package/src/app/routes/home.tsx +89 -207
  45. package/src/app/routes/presenter.tsx +2 -20
  46. package/src/app/routes/slide.tsx +217 -54
  47. package/src/app/routes/themes.tsx +34 -0
  48. package/src/app/virtual.d.ts +20 -0
  49. package/src/locale/en.ts +49 -7
  50. package/src/locale/ja.ts +50 -7
  51. package/src/locale/types.ts +44 -2
  52. package/src/locale/zh-cn.ts +49 -8
  53. package/src/locale/zh-tw.ts +49 -8
  54. package/dist/sync-B4eLo2H6.js +0 -3
  55. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  56. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -32,6 +32,12 @@ type Props = {
32
32
  allowExit?: boolean;
33
33
  controls?: boolean;
34
34
  slideId?: string;
35
+ /**
36
+ * When true, the Player enters the browser Fullscreen API on mount.
37
+ * When false, it renders as a window-sized overlay (viewport-filling)
38
+ * without entering fullscreen. Defaults to true for back-compat.
39
+ */
40
+ fullscreen?: boolean;
35
41
  };
36
42
 
37
43
  export function Player({
@@ -43,8 +49,9 @@ export function Player({
43
49
  allowExit = true,
44
50
  controls = false,
45
51
  slideId,
52
+ fullscreen = true,
46
53
  }: Props) {
47
- const rootRef = useRef<HTMLDivElement>(null);
54
+ const rootRef = useRef<HTMLDivElement | null>(null);
48
55
  // Mirrored as state so descendants portaling *into* the player subtree
49
56
  // (tooltips, popovers — the body is outside the fullscreen tree) re-render
50
57
  // once the node mounts.
@@ -60,6 +67,12 @@ export function Player({
60
67
  const [laser, setLaser] = useState(false);
61
68
  const [keyboardDriven, setKeyboardDriven] = useState(false);
62
69
  const [startedAt] = useState(() => Date.now());
70
+ const [windowed, setWindowed] = useState(!fullscreen);
71
+ // Mirror windowed into a ref so the fullscreenchange listener can read the
72
+ // latest value without re-binding — exits from window mode must not call
73
+ // onExit, but exits initiated by the browser (Esc in fullscreen) must.
74
+ const windowedRef = useRef(windowed);
75
+ windowedRef.current = windowed;
63
76
 
64
77
  const canPrev = index > 0;
65
78
  const canNext = index < pages.length - 1;
@@ -90,6 +103,7 @@ export function Player({
90
103
  });
91
104
 
92
105
  useEffect(() => {
106
+ if (windowed) return;
93
107
  const el = rootRef.current;
94
108
  if (!el) return;
95
109
  if (document.fullscreenElement !== el) {
@@ -98,17 +112,21 @@ export function Player({
98
112
  return () => {
99
113
  if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
100
114
  };
101
- }, []);
115
+ }, [windowed]);
102
116
 
103
117
  useEffect(() => {
104
118
  if (!allowExit) return;
105
119
  const onFsChange = () => {
106
- if (!document.fullscreenElement) onExit();
120
+ if (!document.fullscreenElement && !windowedRef.current) onExit();
107
121
  };
108
122
  document.addEventListener('fullscreenchange', onFsChange);
109
123
  return () => document.removeEventListener('fullscreenchange', onFsChange);
110
124
  }, [onExit, allowExit]);
111
125
 
126
+ const toggleFullscreen = useCallback(() => {
127
+ setWindowed((w) => !w);
128
+ }, []);
129
+
112
130
  // Player is the source of truth: it re-publishes state on every change
113
131
  // and answers `request-state` pings so newly opened presenter windows
114
132
  // hydrate immediately.
@@ -271,7 +289,7 @@ export function Player({
271
289
  <div
272
290
  ref={setRoot}
273
291
  className={cn(
274
- 'relative flex h-dvh w-screen items-center justify-center bg-black',
292
+ 'fixed inset-0 flex items-center justify-center overflow-hidden bg-black',
275
293
  controls && 'select-none',
276
294
  controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
277
295
  )}
@@ -310,12 +328,14 @@ export function Player({
310
328
  blackout={blackout}
311
329
  laser={laser}
312
330
  allowExit={allowExit}
331
+ windowed={windowed}
313
332
  onPrev={goPrev}
314
333
  onNext={goNext}
315
334
  onOverview={() => setOverviewOpen(true)}
316
335
  onBlackout={(mode) => setBlackout((c) => (c === mode ? null : mode))}
317
336
  onLaser={() => setLaser((v) => !v)}
318
337
  onPresenter={() => slideId && openPresenterWindow(slideId)}
338
+ onToggleFullscreen={toggleFullscreen}
319
339
  onHelp={() => setHelpOpen(true)}
320
340
  onExit={onExit}
321
341
  />
@@ -334,7 +354,7 @@ export function Player({
334
354
  );
335
355
  }
336
356
 
337
- function openPresenterWindow(slideId: string) {
357
+ export function openPresenterWindow(slideId: string) {
338
358
  if (typeof window === 'undefined') return;
339
359
  const url = `/s/${encodeURIComponent(slideId)}/presenter`;
340
360
  window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
@@ -5,6 +5,8 @@ import {
5
5
  Grid2x2,
6
6
  Keyboard,
7
7
  LogOut,
8
+ Maximize,
9
+ Minimize,
8
10
  MonitorSpeaker,
9
11
  Square,
10
12
  Sun,
@@ -24,12 +26,14 @@ type Props = {
24
26
  blackout: 'black' | 'white' | null;
25
27
  laser: boolean;
26
28
  allowExit: boolean;
29
+ windowed: boolean;
27
30
  onPrev: () => void;
28
31
  onNext: () => void;
29
32
  onOverview: () => void;
30
33
  onBlackout: (mode: 'black' | 'white') => void;
31
34
  onLaser: () => void;
32
35
  onPresenter: () => void;
36
+ onToggleFullscreen: () => void;
33
37
  onHelp: () => void;
34
38
  onExit: () => void;
35
39
  /**
@@ -48,12 +52,14 @@ export function PresentControlBar({
48
52
  blackout,
49
53
  laser,
50
54
  allowExit,
55
+ windowed,
51
56
  onPrev,
52
57
  onNext,
53
58
  onOverview,
54
59
  onBlackout,
55
60
  onLaser,
56
61
  onPresenter,
62
+ onToggleFullscreen,
57
63
  onHelp,
58
64
  onExit,
59
65
  tooltipContainer,
@@ -123,6 +129,12 @@ export function PresentControlBar({
123
129
  <BarButton label={t.present.presenterAria} onClick={onPresenter}>
124
130
  <MonitorSpeaker className="size-4" />
125
131
  </BarButton>
132
+ <BarButton
133
+ label={windowed ? t.present.enterFullscreenAria : t.present.exitFullscreenAria}
134
+ onClick={onToggleFullscreen}
135
+ >
136
+ {windowed ? <Maximize className="size-4" /> : <Minimize className="size-4" />}
137
+ </BarButton>
126
138
  <BarButton label={t.present.helpAria} onClick={onHelp}>
127
139
  <Keyboard className="size-4" />
128
140
  </BarButton>
@@ -67,6 +67,12 @@ type Row =
67
67
  }
68
68
  | {
69
69
  kind: 'draft';
70
+ }
71
+ | {
72
+ kind: 'themes';
73
+ }
74
+ | {
75
+ kind: 'assets';
70
76
  };
71
77
 
72
78
  export function FolderItem({
@@ -89,7 +95,9 @@ export function FolderItem({
89
95
  const slideDragActive = useSlideDragActive();
90
96
  const t = useLocale();
91
97
 
92
- const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
98
+ const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets';
99
+ const isSlideDrag = (e: React.DragEvent) =>
100
+ acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
93
101
  const handleDragEnter = (e: React.DragEvent) => {
94
102
  if (!isSlideDrag(e)) return;
95
103
  dragDepth.current += 1;
@@ -106,6 +114,7 @@ export function FolderItem({
106
114
  if (dragDepth.current === 0) setDragOver(false);
107
115
  };
108
116
  const handleDrop = (e: React.DragEvent) => {
117
+ if (!acceptsSlideDrop) return;
109
118
  const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
110
119
  dragDepth.current = 0;
111
120
  setDragOver(false);
@@ -114,9 +123,22 @@ export function FolderItem({
114
123
  onDropSlide(slideId);
115
124
  };
116
125
 
117
- const icon =
118
- row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
119
- const label = row.kind === 'draft' ? t.home.draft : row.folder.name;
126
+ const icon: FolderIcon =
127
+ row.kind === 'draft'
128
+ ? { type: 'emoji', value: '📝' }
129
+ : row.kind === 'themes'
130
+ ? { type: 'emoji', value: '🎨' }
131
+ : row.kind === 'assets'
132
+ ? { type: 'emoji', value: '🗂️' }
133
+ : row.folder.icon;
134
+ const label =
135
+ row.kind === 'draft'
136
+ ? t.home.draft
137
+ : row.kind === 'themes'
138
+ ? t.home.themes
139
+ : row.kind === 'assets'
140
+ ? t.home.assets
141
+ : row.folder.name;
120
142
 
121
143
  const commitRename = () => {
122
144
  if (row.kind !== 'folder') return;
@@ -133,7 +155,7 @@ export function FolderItem({
133
155
  selected
134
156
  ? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
135
157
  : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
136
- slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
158
+ slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
137
159
  dragOver &&
138
160
  'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
139
161
  )}
@@ -0,0 +1,34 @@
1
+ import { cn } from '@/lib/utils';
2
+ import type { FolderIcon } from '../../lib/sdk';
3
+ import { FolderIconChip } from './folder-item';
4
+
5
+ export function MobileFolderPill({
6
+ icon,
7
+ label,
8
+ count,
9
+ active,
10
+ onClick,
11
+ }: {
12
+ icon: FolderIcon;
13
+ label: string;
14
+ count: number;
15
+ active: boolean;
16
+ onClick: () => void;
17
+ }) {
18
+ return (
19
+ <button
20
+ type="button"
21
+ onClick={onClick}
22
+ className={cn(
23
+ 'flex shrink-0 items-center gap-1.5 rounded-[5px] border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
24
+ active
25
+ ? 'border-foreground/40 bg-foreground text-background'
26
+ : 'border-border bg-card text-muted-foreground hover:text-foreground',
27
+ )}
28
+ >
29
+ <FolderIconChip icon={icon} className="size-3.5 text-sm" />
30
+ <span className="max-w-[8rem] truncate">{label}</span>
31
+ <span className="folio nums">{count.toString().padStart(2, '0')}</span>
32
+ </button>
33
+ );
34
+ }
@@ -9,10 +9,14 @@ import { FolderIconChip, FolderItem } from './folder-item';
9
9
  import { IconPicker, PRESET_COLORS } from './icon-picker';
10
10
 
11
11
  export const DRAFT_ID = 'draft';
12
+ export const THEMES_ID = '__themes__';
13
+ export const ASSETS_ID = '__assets__';
12
14
 
13
15
  export function Sidebar({
14
16
  folders,
15
17
  countFor,
18
+ themesCount,
19
+ assetsCount,
16
20
  selectedId,
17
21
  onSelect,
18
22
  onCreate,
@@ -24,6 +28,8 @@ export function Sidebar({
24
28
  }: {
25
29
  folders: Folder[];
26
30
  countFor: (folderId: string | null) => number;
31
+ themesCount: number;
32
+ assetsCount: number;
27
33
  selectedId: string;
28
34
  onSelect: (id: string) => void;
29
35
  onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
@@ -111,6 +117,20 @@ export function Sidebar({
111
117
  onSelect={() => onSelect(DRAFT_ID)}
112
118
  onDropSlide={onDropToDraft}
113
119
  />
120
+ <FolderItem
121
+ row={{ kind: 'themes' }}
122
+ count={themesCount}
123
+ selected={selectedId === THEMES_ID}
124
+ onSelect={() => onSelect(THEMES_ID)}
125
+ onDropSlide={() => {}}
126
+ />
127
+ <FolderItem
128
+ row={{ kind: 'assets' }}
129
+ count={assetsCount}
130
+ selected={selectedId === ASSETS_ID}
131
+ onSelect={() => onSelect(ASSETS_ID)}
132
+ onDropSlide={() => {}}
133
+ />
114
134
  </div>
115
135
 
116
136
  <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
@@ -0,0 +1,300 @@
1
+ import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
2
+ import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Button } from '@/components/ui/button';
5
+ import { format, useLocale } from '@/lib/use-locale';
6
+ import { cn } from '@/lib/utils';
7
+ import type { SlideModule } from '../../lib/sdk';
8
+ import { loadSlide, slidesByTheme } from '../../lib/slides';
9
+ import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
10
+ import { SlideCanvas } from '../slide-canvas';
11
+
12
+ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () => void }) {
13
+ const t = useLocale();
14
+ const theme = useMemo(() => themes.find((th) => th.id === themeId), [themeId]);
15
+ const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
16
+ const [pageIndex, setPageIndex] = useState(0);
17
+
18
+ useEffect(() => {
19
+ setPageIndex(0);
20
+ setDemo(null);
21
+ if (!theme?.hasDemo) return;
22
+ let cancelled = false;
23
+ loadThemeDemo(theme.id)
24
+ .then((mod) => {
25
+ if (!cancelled) setDemo(mod);
26
+ })
27
+ .catch(() => {});
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, [theme]);
32
+
33
+ const pages = demo?.default ?? [];
34
+ const totalPages = pages.length;
35
+ const usedBySlideIds = useMemo(() => (theme ? slidesByTheme(theme.id) : []), [theme]);
36
+
37
+ const promptRef = useRef<HTMLPreElement>(null);
38
+ const [promptExpanded, setPromptExpanded] = useState(false);
39
+ const [promptOverflows, setPromptOverflows] = useState(false);
40
+
41
+ const themeBody = theme?.body;
42
+ useEffect(() => {
43
+ setPromptExpanded(false);
44
+ const el = promptRef.current;
45
+ if (!el || !themeBody) return;
46
+ setPromptOverflows(el.scrollHeight > PROMPT_COLLAPSED_PX + 8);
47
+ }, [themeBody]);
48
+
49
+ useEffect(() => {
50
+ if (totalPages <= 1) return;
51
+ const onKey = (e: KeyboardEvent) => {
52
+ const tag = (e.target as HTMLElement | null)?.tagName;
53
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
54
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
55
+ setPageIndex((i) => Math.min(totalPages - 1, i + 1));
56
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
57
+ setPageIndex((i) => Math.max(0, i - 1));
58
+ }
59
+ };
60
+ window.addEventListener('keydown', onKey);
61
+ return () => window.removeEventListener('keydown', onKey);
62
+ }, [totalPages]);
63
+
64
+ if (!theme) {
65
+ return (
66
+ <div className="px-8 py-12">
67
+ <Button variant="ghost" size="sm" onClick={onBack}>
68
+ <ChevronLeft className="size-4" />
69
+ {t.themes.backToGallery}
70
+ </Button>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const Current = pages[pageIndex];
76
+
77
+ return (
78
+ <div className="flex flex-col gap-6 md:gap-8">
79
+ <div className="flex items-center gap-3">
80
+ <Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
81
+ <ChevronLeft className="size-4" />
82
+ {t.themes.backToGallery}
83
+ </Button>
84
+ </div>
85
+
86
+ <header className="flex flex-wrap items-baseline gap-3">
87
+ <h2 className="font-heading text-[26px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[32px]">
88
+ {theme.name}
89
+ </h2>
90
+ {theme.description ? (
91
+ <p className="basis-full text-[13px] leading-relaxed text-muted-foreground">
92
+ {theme.description}
93
+ </p>
94
+ ) : null}
95
+ </header>
96
+
97
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:gap-8">
98
+ <div className="flex min-w-0 flex-col gap-6">
99
+ <div className="flex flex-col gap-3">
100
+ <div className="relative aspect-video overflow-hidden rounded-[8px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04]">
101
+ {!theme.hasDemo ? (
102
+ <NoDemoLargeState />
103
+ ) : !demo ? (
104
+ <div className="grid h-full w-full place-items-center text-[11px] tracking-[0.16em] uppercase text-muted-foreground/60">
105
+ {t.common.loading}
106
+ </div>
107
+ ) : Current ? (
108
+ <SlideCanvas flat freezeMotion design={demo.design}>
109
+ <Current />
110
+ </SlideCanvas>
111
+ ) : null}
112
+ </div>
113
+
114
+ {totalPages > 1 ? (
115
+ <div className="flex items-center justify-between gap-2">
116
+ <button
117
+ type="button"
118
+ aria-label={t.themes.prevPageAria}
119
+ disabled={pageIndex === 0}
120
+ onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
121
+ className="flex size-8 items-center justify-center rounded-[6px] border border-border bg-card text-foreground transition-colors hover:bg-muted disabled:opacity-40"
122
+ >
123
+ <ChevronLeft className="size-4" />
124
+ </button>
125
+ <span className="folio">
126
+ {format(t.themes.pageOf, { n: pageIndex + 1, total: totalPages })}
127
+ </span>
128
+ <button
129
+ type="button"
130
+ aria-label={t.themes.nextPageAria}
131
+ disabled={pageIndex === totalPages - 1}
132
+ onClick={() => setPageIndex((i) => Math.min(totalPages - 1, i + 1))}
133
+ className="flex size-8 items-center justify-center rounded-[6px] border border-border bg-card text-foreground transition-colors hover:bg-muted disabled:opacity-40"
134
+ >
135
+ <ChevronRight className="size-4" />
136
+ </button>
137
+ </div>
138
+ ) : null}
139
+ </div>
140
+
141
+ <div className="relative">
142
+ <pre
143
+ ref={promptRef}
144
+ style={
145
+ promptOverflows && !promptExpanded ? { maxHeight: PROMPT_COLLAPSED_PX } : undefined
146
+ }
147
+ className={cn(
148
+ 'w-full rounded-[8px] border border-hairline bg-card p-4 font-mono text-[11.5px] leading-relaxed text-foreground/90',
149
+ promptOverflows && !promptExpanded ? 'overflow-hidden' : 'overflow-auto',
150
+ )}
151
+ >
152
+ {renderBodyWithSwatches(theme.body)}
153
+ </pre>
154
+ {promptOverflows && !promptExpanded ? (
155
+ <button
156
+ type="button"
157
+ aria-label={t.themes.expandPromptAria}
158
+ onClick={() => setPromptExpanded(true)}
159
+ className="absolute inset-x-0 bottom-0 flex h-24 items-end justify-center rounded-b-[8px] bg-gradient-to-t from-card via-card/85 to-transparent pb-3 text-muted-foreground transition-colors hover:text-foreground"
160
+ >
161
+ <ChevronDown className="size-4" />
162
+ </button>
163
+ ) : null}
164
+ {promptOverflows && promptExpanded ? (
165
+ <div className="mt-2 flex justify-center">
166
+ <button
167
+ type="button"
168
+ aria-label={t.themes.collapsePromptAria}
169
+ onClick={() => setPromptExpanded(false)}
170
+ className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
171
+ >
172
+ <ChevronDown className="size-4 rotate-180" />
173
+ </button>
174
+ </div>
175
+ ) : null}
176
+ </div>
177
+ </div>
178
+
179
+ <aside className="flex min-w-0 flex-col gap-4">
180
+ <div className="flex flex-wrap items-baseline gap-3">
181
+ <span className="eyebrow">{t.themes.usedBy}</span>
182
+ {usedBySlideIds.length > 0 ? (
183
+ <span className="folio">{usedBySlideIds.length.toString().padStart(2, '0')}</span>
184
+ ) : null}
185
+ </div>
186
+ {usedBySlideIds.length === 0 ? (
187
+ <p className="text-[12.5px] leading-relaxed text-muted-foreground">
188
+ {t.themes.usedByEmpty}
189
+ </p>
190
+ ) : (
191
+ <ul className="flex flex-col gap-5">
192
+ {usedBySlideIds.map((id) => (
193
+ <li key={id}>
194
+ <ThemeSlideCard id={id} />
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ </aside>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ function ThemeSlideCard({ id }: { id: string }) {
206
+ const t = useLocale();
207
+ const [slide, setSlide] = useState<SlideModule | null>(null);
208
+
209
+ useEffect(() => {
210
+ let cancelled = false;
211
+ loadSlide(id)
212
+ .then((mod) => {
213
+ if (!cancelled) setSlide(mod);
214
+ })
215
+ .catch(() => {});
216
+ return () => {
217
+ cancelled = true;
218
+ };
219
+ }, [id]);
220
+
221
+ const FirstPage = slide?.default[0];
222
+ const displayTitle = slide?.meta?.title ?? id;
223
+
224
+ return (
225
+ <Link to={`/s/${id}`} className="group block focus-visible:outline-none">
226
+ <div className="relative aspect-video overflow-hidden rounded-[6px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04] group-hover:shadow-floating group-hover:ring-foreground/20 motion-safe:transition-[box-shadow,--tw-ring-color] motion-safe:duration-200">
227
+ {FirstPage ? (
228
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
229
+ <SlideCanvas flat freezeMotion design={slide?.design}>
230
+ <FirstPage />
231
+ </SlideCanvas>
232
+ </div>
233
+ ) : (
234
+ <div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
235
+ {t.common.loading}
236
+ </div>
237
+ )}
238
+ </div>
239
+ <div className="mt-2.5">
240
+ <h3 className="min-w-0 truncate font-heading text-[13px] font-medium tracking-tight">
241
+ {displayTitle}
242
+ </h3>
243
+ <p className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground/80">{id}</p>
244
+ </div>
245
+ </Link>
246
+ );
247
+ }
248
+
249
+ const PROMPT_COLLAPSED_PX = 320;
250
+
251
+ const HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
252
+
253
+ function renderBodyWithSwatches(body: string): ReactNode[] {
254
+ const out: ReactNode[] = [];
255
+ let lastIndex = 0;
256
+ let match: RegExpExecArray | null = HEX_RE.exec(body);
257
+ let key = 0;
258
+ while (match !== null) {
259
+ if (match.index > lastIndex) {
260
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex, match.index)}</Fragment>);
261
+ }
262
+ const hex = match[0];
263
+ out.push(
264
+ <span
265
+ key={`s${key}`}
266
+ aria-hidden
267
+ className="mr-[0.25em] -translate-y-[0.1em] inline-block size-[0.85em] rounded-[2px] align-middle ring-1 ring-foreground/15"
268
+ style={{ background: hex }}
269
+ />,
270
+ );
271
+ out.push(<Fragment key={`h${key}`}>{hex}</Fragment>);
272
+ lastIndex = match.index + hex.length;
273
+ key += 1;
274
+ match = HEX_RE.exec(body);
275
+ }
276
+ if (lastIndex < body.length) {
277
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex)}</Fragment>);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function NoDemoLargeState() {
283
+ const t = useLocale();
284
+ return (
285
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-8 text-center">
286
+ <div className="max-w-sm">
287
+ <p className="font-heading text-[15px] font-semibold tracking-tight">
288
+ {t.themes.noDemoYet}
289
+ </p>
290
+ <p className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
291
+ {t.themes.noDemoHintPrefix}
292
+ <code className="rounded-[4px] bg-card px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
293
+ /create-theme
294
+ </code>
295
+ {t.themes.noDemoHintSuffix}
296
+ </p>
297
+ </div>
298
+ </div>
299
+ );
300
+ }