@open-slide/core 1.1.0 → 1.3.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 (55) hide show
  1. package/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
  4. package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
  5. package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
  6. package/dist/en-CDKzoZvf.js +351 -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 +166 -326
  11. package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
  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/current-slide/SKILL.md +110 -0
  20. package/skills/slide-authoring/SKILL.md +59 -1
  21. package/src/app/app.tsx +11 -1
  22. package/src/app/components/asset-view.tsx +1 -13
  23. package/src/app/components/image-placeholder.tsx +123 -1
  24. package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
  25. package/src/app/components/inspector/inspector-panel.tsx +163 -19
  26. package/src/app/components/inspector/inspector-provider.tsx +60 -7
  27. package/src/app/components/notes-drawer.tsx +117 -0
  28. package/src/app/components/player.tsx +11 -7
  29. package/src/app/components/present/overview-grid.tsx +2 -2
  30. package/src/app/components/sidebar/folder-item.tsx +16 -5
  31. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  32. package/src/app/components/sidebar/sidebar.tsx +10 -0
  33. package/src/app/components/themes/theme-detail.tsx +300 -0
  34. package/src/app/components/themes/themes-gallery.tsx +146 -0
  35. package/src/app/components/thumbnail-rail.tsx +136 -29
  36. package/src/app/components/ui/context-menu.tsx +237 -0
  37. package/src/app/lib/assets.ts +55 -2
  38. package/src/app/lib/inspector/use-notes.ts +134 -0
  39. package/src/app/lib/sdk.ts +1 -0
  40. package/src/app/lib/slides.ts +10 -1
  41. package/src/app/lib/themes.ts +22 -0
  42. package/src/app/lib/use-agent-socket.ts +18 -0
  43. package/src/app/routes/home-shell.tsx +173 -0
  44. package/src/app/routes/home.tsx +108 -204
  45. package/src/app/routes/slide.tsx +333 -68
  46. package/src/app/routes/themes.tsx +34 -0
  47. package/src/app/virtual.d.ts +20 -0
  48. package/src/locale/en.ts +61 -7
  49. package/src/locale/ja.ts +62 -7
  50. package/src/locale/types.ts +62 -5
  51. package/src/locale/zh-cn.ts +61 -7
  52. package/src/locale/zh-tw.ts +61 -7
  53. package/dist/sync-B4eLo2H6.js +0 -3
  54. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  55. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -0,0 +1,117 @@
1
+ import { ChevronDown, ChevronUp, NotebookPen } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { PANEL_TRANSITION_MS, usePanelMount } from '@/components/panel/panel-shell';
4
+ import { useNotes } from '@/lib/inspector/use-notes';
5
+ import { format, useLocale } from '@/lib/use-locale';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ const STORAGE_KEY = 'open-slide:notes-drawer-open';
9
+ const DRAWER_CONTENT_H = 166;
10
+
11
+ type Props = {
12
+ slideId: string;
13
+ index: number;
14
+ total: number;
15
+ initial: string | undefined;
16
+ };
17
+
18
+ export function NotesDrawer({ slideId, index, total, initial }: Props) {
19
+ const t = useLocale();
20
+ const [open, setOpen] = useState(() => {
21
+ if (typeof window === 'undefined') return false;
22
+ return window.localStorage.getItem(STORAGE_KEY) === '1';
23
+ });
24
+ const { value, setValue, status, flush } = useNotes(slideId, index, initial);
25
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
26
+ const { mounted, animVisible } = usePanelMount(open);
27
+
28
+ useEffect(() => {
29
+ if (typeof window === 'undefined') return;
30
+ window.localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
31
+ }, [open]);
32
+
33
+ const statusLabel = (() => {
34
+ switch (status.kind) {
35
+ case 'saving':
36
+ return t.notesDrawer.statusSaving;
37
+ case 'saved':
38
+ return t.notesDrawer.statusSaved;
39
+ case 'error':
40
+ return format(t.notesDrawer.statusError, { msg: status.message });
41
+ default:
42
+ return '';
43
+ }
44
+ })();
45
+
46
+ return (
47
+ <aside
48
+ data-notes-drawer
49
+ className="hidden shrink-0 border-t border-hairline bg-sidebar/85 backdrop-blur md:block"
50
+ >
51
+ <button
52
+ type="button"
53
+ onClick={() => {
54
+ setOpen((o) => {
55
+ if (o) void flush();
56
+ return !o;
57
+ });
58
+ }}
59
+ className="flex h-9 w-full items-center gap-2 px-3 text-[12px] text-foreground/80 hover:bg-muted/40"
60
+ aria-expanded={open}
61
+ >
62
+ <NotebookPen className="size-3.5 text-muted-foreground" />
63
+ <span className="font-medium">{t.notesDrawer.toggle}</span>
64
+ <span className="font-mono text-[11px] text-muted-foreground">
65
+ {format(t.notesDrawer.pageLabel, { n: index + 1, total })}
66
+ </span>
67
+ <span
68
+ className={cn(
69
+ 'ml-auto truncate text-[11px]',
70
+ status.kind === 'error' ? 'text-destructive' : 'text-muted-foreground',
71
+ )}
72
+ aria-live="polite"
73
+ >
74
+ {statusLabel}
75
+ </span>
76
+ {open ? (
77
+ <ChevronDown className="size-3.5 text-muted-foreground" />
78
+ ) : (
79
+ <ChevronUp className="size-3.5 text-muted-foreground" />
80
+ )}
81
+ </button>
82
+ {mounted && (
83
+ <div
84
+ className="overflow-hidden border-t border-hairline transition-[height] ease-out"
85
+ style={{
86
+ height: animVisible ? DRAWER_CONTENT_H : 0,
87
+ transitionDuration: `${PANEL_TRANSITION_MS}ms`,
88
+ }}
89
+ >
90
+ <div className="px-3 py-2">
91
+ <textarea
92
+ ref={textareaRef}
93
+ value={value}
94
+ onChange={(e) => setValue(e.target.value)}
95
+ onBlur={() => {
96
+ void flush();
97
+ }}
98
+ onKeyDown={(e) => {
99
+ if (e.key === 'Escape') {
100
+ e.preventDefault();
101
+ textareaRef.current?.blur();
102
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
103
+ e.preventDefault();
104
+ void flush();
105
+ }
106
+ }}
107
+ placeholder={t.notesDrawer.placeholder}
108
+ rows={6}
109
+ spellCheck
110
+ className="block h-[150px] w-full resize-none rounded-[6px] border border-border bg-card px-3 py-2 text-[13px] leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
111
+ />
112
+ </div>
113
+ </div>
114
+ )}
115
+ </aside>
116
+ );
117
+ }
@@ -61,6 +61,9 @@ export function Player({
61
61
  const [keyboardDriven, setKeyboardDriven] = useState(false);
62
62
  const [startedAt] = useState(() => Date.now());
63
63
 
64
+ const canPrev = index > 0;
65
+ const canNext = index < pages.length - 1;
66
+
64
67
  const goPrev = useCallback(() => {
65
68
  if (index > 0) onIndexChange(index - 1);
66
69
  }, [index, onIndexChange]);
@@ -73,8 +76,8 @@ export function Player({
73
76
  useWheelPageNavigation({
74
77
  ref: rootRef,
75
78
  enabled: !overlayActive,
76
- canPrev: index > 0,
77
- canNext: index < pages.length - 1,
79
+ canPrev,
80
+ canNext,
78
81
  onPrev: goPrev,
79
82
  onNext: goNext,
80
83
  });
@@ -269,7 +272,8 @@ export function Player({
269
272
  ref={setRoot}
270
273
  className={cn(
271
274
  'relative flex h-dvh w-screen items-center justify-center bg-black',
272
- hideCursor && 'cursor-none',
275
+ controls && 'select-none',
276
+ controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
273
277
  )}
274
278
  >
275
279
  <SlideCanvas flat design={design}>
@@ -280,15 +284,15 @@ export function Player({
280
284
  type="button"
281
285
  aria-label="Previous page"
282
286
  onClick={goPrev}
283
- disabled={index === 0}
284
- className="absolute inset-y-0 left-0 z-10 w-[30%]"
287
+ disabled={!canPrev}
288
+ className={cn('absolute inset-y-0 left-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
285
289
  />
286
290
  <button
287
291
  type="button"
288
292
  aria-label="Next page"
289
293
  onClick={goNext}
290
- disabled={index === pages.length - 1}
291
- className="absolute inset-y-0 right-0 z-10 w-[30%]"
294
+ disabled={!canNext}
295
+ className={cn('absolute inset-y-0 right-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
292
296
  />
293
297
 
294
298
  {controls && (
@@ -96,9 +96,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
96
96
  </div>
97
97
  <div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
98
98
  <div
99
- className="grid gap-5"
99
+ className="grid justify-center gap-5"
100
100
  style={{
101
- gridTemplateColumns: `repeat(auto-fill, minmax(${THUMB_W}px, 1fr))`,
101
+ gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
102
102
  }}
103
103
  >
104
104
  {pages.map((PageComp, i) => {
@@ -67,6 +67,9 @@ type Row =
67
67
  }
68
68
  | {
69
69
  kind: 'draft';
70
+ }
71
+ | {
72
+ kind: 'themes';
70
73
  };
71
74
 
72
75
  export function FolderItem({
@@ -89,7 +92,9 @@ export function FolderItem({
89
92
  const slideDragActive = useSlideDragActive();
90
93
  const t = useLocale();
91
94
 
92
- const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
95
+ const acceptsSlideDrop = row.kind !== 'themes';
96
+ const isSlideDrag = (e: React.DragEvent) =>
97
+ acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
93
98
  const handleDragEnter = (e: React.DragEvent) => {
94
99
  if (!isSlideDrag(e)) return;
95
100
  dragDepth.current += 1;
@@ -106,6 +111,7 @@ export function FolderItem({
106
111
  if (dragDepth.current === 0) setDragOver(false);
107
112
  };
108
113
  const handleDrop = (e: React.DragEvent) => {
114
+ if (!acceptsSlideDrop) return;
109
115
  const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
110
116
  dragDepth.current = 0;
111
117
  setDragOver(false);
@@ -114,9 +120,14 @@ export function FolderItem({
114
120
  onDropSlide(slideId);
115
121
  };
116
122
 
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;
123
+ const icon: FolderIcon =
124
+ row.kind === 'draft'
125
+ ? { type: 'emoji', value: '📝' }
126
+ : row.kind === 'themes'
127
+ ? { type: 'emoji', value: '🎨' }
128
+ : row.folder.icon;
129
+ const label =
130
+ row.kind === 'draft' ? t.home.draft : row.kind === 'themes' ? t.home.themes : row.folder.name;
120
131
 
121
132
  const commitRename = () => {
122
133
  if (row.kind !== 'folder') return;
@@ -133,7 +144,7 @@ export function FolderItem({
133
144
  selected
134
145
  ? '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
146
  : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
136
- slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
147
+ slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
137
148
  dragOver &&
138
149
  '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
150
  )}
@@ -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,12 @@ 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__';
12
13
 
13
14
  export function Sidebar({
14
15
  folders,
15
16
  countFor,
17
+ themesCount,
16
18
  selectedId,
17
19
  onSelect,
18
20
  onCreate,
@@ -24,6 +26,7 @@ export function Sidebar({
24
26
  }: {
25
27
  folders: Folder[];
26
28
  countFor: (folderId: string | null) => number;
29
+ themesCount: number;
27
30
  selectedId: string;
28
31
  onSelect: (id: string) => void;
29
32
  onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
@@ -111,6 +114,13 @@ export function Sidebar({
111
114
  onSelect={() => onSelect(DRAFT_ID)}
112
115
  onDropSlide={onDropToDraft}
113
116
  />
117
+ <FolderItem
118
+ row={{ kind: 'themes' }}
119
+ count={themesCount}
120
+ selected={selectedId === THEMES_ID}
121
+ onSelect={() => onSelect(THEMES_ID)}
122
+ onDropSlide={() => {}}
123
+ />
114
124
  </div>
115
125
 
116
126
  <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
+ }