@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
@@ -24,17 +24,21 @@ import {
24
24
  DropdownMenuTrigger,
25
25
  } from '@/components/ui/dropdown-menu';
26
26
  import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
27
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
27
28
  import { useFolders } from '@/lib/folders';
28
- import { useLocale } from '@/lib/use-locale';
29
+ import { useAgentSocketConnected } from '@/lib/use-agent-socket';
30
+ import { format, useLocale } from '@/lib/use-locale';
29
31
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
30
32
  import { cn } from '@/lib/utils';
31
33
  import { ClickNavZones } from '../components/click-nav-zones';
34
+ import { NotesDrawer } from '../components/notes-drawer';
32
35
  import { PdfProgressToast } from '../components/pdf-progress-toast';
33
36
  import { Player } from '../components/player';
34
37
  import { SlideCanvas } from '../components/slide-canvas';
35
- import { ThumbnailRail } from '../components/thumbnail-rail';
38
+ import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail';
36
39
  import { exportSlideAsHtml } from '../lib/export-html';
37
40
  import { exportSlideAsPdf } from '../lib/export-pdf';
41
+ import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes';
38
42
  import type { SlideModule } from '../lib/sdk';
39
43
  import { loadSlide } from '../lib/slides';
40
44
 
@@ -78,6 +82,33 @@ export function Slide() {
78
82
  const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
79
83
  const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
80
84
 
85
+ useEffect(() => {
86
+ if (!import.meta.hot) return;
87
+ if (!slideId || !slide || pageCount === 0) return;
88
+ import.meta.hot.send('open-slide:current', {
89
+ slideId,
90
+ pageIndex: index,
91
+ totalPages: pageCount,
92
+ slideTitle: slide.meta?.title ?? slideId,
93
+ view,
94
+ });
95
+ }, [slideId, index, pageCount, slide, view]);
96
+
97
+ const goTo = useCallback(
98
+ (i: number) => {
99
+ const clamped = Math.max(0, Math.min(pageCount - 1, i));
100
+ setSearchParams(
101
+ (prev) => {
102
+ const next = new URLSearchParams(prev);
103
+ next.set('p', String(clamped + 1));
104
+ return next;
105
+ },
106
+ { replace: true },
107
+ );
108
+ },
109
+ [pageCount, setSearchParams],
110
+ );
111
+
81
112
  const reorderPage = useCallback(
82
113
  async (from: number, to: number) => {
83
114
  if (from === to) return;
@@ -91,21 +122,14 @@ export function Slide() {
91
122
  const [movedIdx] = order.splice(from, 1);
92
123
  order.splice(to, 0, movedIdx);
93
124
 
125
+ remapNotesSessionCacheAfterReorder(slideId, order);
126
+
94
127
  // Keep the user looking at the same page they were on before the drag.
95
128
  let nextIndex = index;
96
129
  if (index === from) nextIndex = to;
97
130
  else if (from < index && to >= index) nextIndex = index - 1;
98
131
  else if (from > index && to <= index) nextIndex = index + 1;
99
- if (nextIndex !== index) {
100
- setSearchParams(
101
- (prev) => {
102
- const params = new URLSearchParams(prev);
103
- params.set('p', String(nextIndex + 1));
104
- return params;
105
- },
106
- { replace: true },
107
- );
108
- }
132
+ if (nextIndex !== index) goTo(nextIndex);
109
133
 
110
134
  try {
111
135
  const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/reorder`, {
@@ -119,25 +143,81 @@ export function Slide() {
119
143
  }
120
144
  } catch (err) {
121
145
  setPages(before);
146
+ const inverse = order.map((_, i) => order.indexOf(i));
147
+ remapNotesSessionCacheAfterReorder(slideId, inverse);
122
148
  toast.error(`Reorder failed: ${String((err as Error).message ?? err)}`);
123
149
  }
124
150
  },
125
- [pages, index, slideId, setSearchParams],
151
+ [pages, index, slideId, goTo],
126
152
  );
127
153
 
128
- const goTo = useCallback(
129
- (i: number) => {
130
- const clamped = Math.max(0, Math.min(pageCount - 1, i));
131
- setSearchParams(
132
- (prev) => {
133
- const next = new URLSearchParams(prev);
134
- next.set('p', String(clamped + 1));
135
- return next;
136
- },
137
- { replace: true },
138
- );
154
+ const duplicatePage = useCallback(
155
+ async (i: number) => {
156
+ const before = pages;
157
+ if (i < 0 || i >= before.length) return;
158
+ const nextPages = [...before];
159
+ nextPages.splice(i + 1, 0, before[i]);
160
+ setPages(nextPages);
161
+ if (index > i) goTo(index + 1);
162
+
163
+ try {
164
+ const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}/duplicate`, {
165
+ method: 'POST',
166
+ });
167
+ if (!res.ok) {
168
+ const detail = await res.json().catch(() => ({ error: res.statusText }));
169
+ throw new Error(detail.error ?? `HTTP ${res.status}`);
170
+ }
171
+ toast.success(format(t.thumbnailRail.toastDuplicated, { n: i + 1 }));
172
+ } catch (err) {
173
+ setPages(before);
174
+ toast.error(
175
+ `${t.thumbnailRail.toastDuplicateFailed}: ${String((err as Error).message ?? err)}`,
176
+ );
177
+ }
139
178
  },
140
- [pageCount, setSearchParams],
179
+ [pages, index, slideId, goTo, t.thumbnailRail],
180
+ );
181
+
182
+ const deletePage = useCallback(
183
+ async (i: number) => {
184
+ const before = pages;
185
+ if (i < 0 || i >= before.length || before.length <= 1) return;
186
+ const nextPages = before.slice(0, i).concat(before.slice(i + 1));
187
+ setPages(nextPages);
188
+ if (index >= i && index > 0) {
189
+ const target = index === i ? Math.min(index, nextPages.length - 1) : index - 1;
190
+ goTo(target);
191
+ }
192
+
193
+ try {
194
+ const res = await fetch(`/__slides/${encodeURIComponent(slideId)}/pages/${i}`, {
195
+ method: 'DELETE',
196
+ });
197
+ if (!res.ok) {
198
+ const detail = await res.json().catch(() => ({ error: res.statusText }));
199
+ throw new Error(detail.error ?? `HTTP ${res.status}`);
200
+ }
201
+ toast.success(format(t.thumbnailRail.toastDeleted, { n: i + 1 }));
202
+ } catch (err) {
203
+ setPages(before);
204
+ toast.error(
205
+ `${t.thumbnailRail.toastDeleteFailed}: ${String((err as Error).message ?? err)}`,
206
+ );
207
+ }
208
+ },
209
+ [pages, index, slideId, goTo, t.thumbnailRail],
210
+ );
211
+
212
+ const thumbnailActions = useMemo<ThumbnailActions | undefined>(
213
+ () =>
214
+ import.meta.env.DEV
215
+ ? {
216
+ onDuplicate: duplicatePage,
217
+ onDelete: deletePage,
218
+ }
219
+ : undefined,
220
+ [duplicatePage, deletePage],
141
221
  );
142
222
 
143
223
  useEffect(() => {
@@ -255,6 +335,7 @@ export function Slide() {
255
335
  return (
256
336
  <HistoryProvider>
257
337
  <InspectorProvider slideId={slideId}>
338
+ <SelectionReporter />
258
339
  <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
259
340
  {/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
260
341
  <header className="relative flex h-12 shrink-0 items-center justify-between border-b border-hairline bg-sidebar/85 px-2 backdrop-blur-md md:px-3">
@@ -288,6 +369,7 @@ export function Slide() {
288
369
  </TabsList>
289
370
  </Tabs>
290
371
  )}
372
+ {import.meta.env.DEV && <AgentConnectedBadge />}
291
373
  </div>
292
374
 
293
375
  {/* Centered title — the rail and mobile pill carry the page count. */}
@@ -400,57 +482,68 @@ export function Slide() {
400
482
  </div>
401
483
  ) : (
402
484
  <DesignProvider slideId={slideId}>
403
- <div className="flex min-h-0 flex-1 flex-col md:flex-row">
404
- <div className="hidden w-[16.5rem] shrink-0 md:block">
405
- <ThumbnailRail
485
+ <div className="flex min-h-0 flex-1 flex-col">
486
+ <div className="flex min-h-0 flex-1 flex-col md:flex-row">
487
+ <ResizableRail
406
488
  pages={pages}
407
489
  design={slide.design}
408
490
  current={index}
409
491
  onSelect={goTo}
410
492
  onReorder={import.meta.env.DEV ? reorderPage : undefined}
493
+ actions={thumbnailActions}
411
494
  />
412
- </div>
413
- <main
414
- ref={slideViewportRef}
415
- data-inspector-root
416
- className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
417
- >
418
- <SlideWheelNavigation
419
- targetRef={slideViewportRef}
420
- onPrev={() => goTo(index - 1)}
421
- onNext={() => goTo(index + 1)}
422
- canPrev={index > 0}
423
- canNext={index < pageCount - 1}
424
- />
425
- <SlideCanvas design={slide.design}>
426
- <CurrentPage />
427
- </SlideCanvas>
428
- <ClickNavZones
429
- onPrev={() => goTo(index - 1)}
430
- onNext={() => goTo(index + 1)}
431
- canPrev={index > 0}
432
- canNext={index < pageCount - 1}
433
- />
434
- <InspectOverlay />
435
- <SaveBar />
436
- {import.meta.env.DEV && <CommentWidget />}
437
- </main>
438
- {/* Mobile-only horizontal rail. Sits below the canvas and
495
+ <main
496
+ ref={slideViewportRef}
497
+ data-inspector-root
498
+ data-slide-id={slideId}
499
+ className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
500
+ >
501
+ <SlideWheelNavigation
502
+ targetRef={slideViewportRef}
503
+ onPrev={() => goTo(index - 1)}
504
+ onNext={() => goTo(index + 1)}
505
+ canPrev={index > 0}
506
+ canNext={index < pageCount - 1}
507
+ />
508
+ <SlideCanvas design={slide.design}>
509
+ <CurrentPage />
510
+ </SlideCanvas>
511
+ <ClickNavZones
512
+ onPrev={() => goTo(index - 1)}
513
+ onNext={() => goTo(index + 1)}
514
+ canPrev={index > 0}
515
+ canNext={index < pageCount - 1}
516
+ />
517
+ <InspectOverlay />
518
+ <SaveBar />
519
+ {import.meta.env.DEV && <CommentWidget />}
520
+ </main>
521
+ {/* Mobile-only horizontal rail. Sits below the canvas and
439
522
  pads its bottom for the iOS home indicator / Safari URL bar. */}
440
- <div
441
- className="shrink-0 border-t border-hairline md:hidden"
442
- style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
443
- >
444
- <ThumbnailRail
445
- pages={pages}
446
- design={slide.design}
447
- current={index}
448
- onSelect={goTo}
449
- orientation="horizontal"
450
- />
523
+ <div
524
+ className="shrink-0 border-t border-hairline md:hidden"
525
+ style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
526
+ >
527
+ <ThumbnailRail
528
+ pages={pages}
529
+ design={slide.design}
530
+ current={index}
531
+ onSelect={goTo}
532
+ orientation="horizontal"
533
+ actions={thumbnailActions}
534
+ />
535
+ </div>
536
+ <InspectorPanel />
537
+ <DesignPanel open={designOpen} onClose={() => setDesignOpen(false)} />
451
538
  </div>
452
- <InspectorPanel />
453
- <DesignPanel open={designOpen} onClose={() => setDesignOpen(false)} />
539
+ {import.meta.env.DEV && (
540
+ <NotesDrawer
541
+ slideId={slideId}
542
+ index={index}
543
+ total={pageCount}
544
+ initial={slide.notes?.[index]}
545
+ />
546
+ )}
454
547
  </div>
455
548
  </DesignProvider>
456
549
  )}
@@ -460,6 +553,178 @@ export function Slide() {
460
553
  );
461
554
  }
462
555
 
556
+ const RAIL_WIDTH_STORAGE_KEY = 'open-slide:thumbnail-rail-width';
557
+ const DEFAULT_RAIL_WIDTH = 264;
558
+ const MIN_RAIL_WIDTH = 200;
559
+ const MAX_RAIL_WIDTH = 480;
560
+
561
+ function readStoredRailWidth(): number {
562
+ if (typeof window === 'undefined') return DEFAULT_RAIL_WIDTH;
563
+ const raw = window.localStorage.getItem(RAIL_WIDTH_STORAGE_KEY);
564
+ const parsed = raw == null ? Number.NaN : Number(raw);
565
+ if (!Number.isFinite(parsed)) return DEFAULT_RAIL_WIDTH;
566
+ return Math.min(MAX_RAIL_WIDTH, Math.max(MIN_RAIL_WIDTH, parsed));
567
+ }
568
+
569
+ function ResizableRail(props: {
570
+ pages: SlideModule['default'];
571
+ design?: SlideModule['design'];
572
+ current: number;
573
+ onSelect: (i: number) => void;
574
+ onReorder?: (from: number, to: number) => void;
575
+ actions?: ThumbnailActions;
576
+ }) {
577
+ const t = useLocale();
578
+ const [width, setWidth] = useState<number>(readStoredRailWidth);
579
+ const [resizing, setResizing] = useState(false);
580
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
581
+
582
+ useEffect(() => {
583
+ if (typeof window === 'undefined') return;
584
+ window.localStorage.setItem(RAIL_WIDTH_STORAGE_KEY, String(width));
585
+ }, [width]);
586
+
587
+ useEffect(() => {
588
+ if (!resizing) return;
589
+ const prev = {
590
+ cursor: document.body.style.cursor,
591
+ userSelect: document.body.style.userSelect,
592
+ };
593
+ document.body.style.cursor = 'col-resize';
594
+ document.body.style.userSelect = 'none';
595
+ return () => {
596
+ document.body.style.cursor = prev.cursor;
597
+ document.body.style.userSelect = prev.userSelect;
598
+ };
599
+ }, [resizing]);
600
+
601
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
602
+ e.preventDefault();
603
+ e.currentTarget.setPointerCapture(e.pointerId);
604
+ dragRef.current = { startX: e.clientX, startWidth: width };
605
+ setResizing(true);
606
+ };
607
+
608
+ const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
609
+ if (!dragRef.current) return;
610
+ const delta = e.clientX - dragRef.current.startX;
611
+ const next = Math.min(
612
+ MAX_RAIL_WIDTH,
613
+ Math.max(MIN_RAIL_WIDTH, dragRef.current.startWidth + delta),
614
+ );
615
+ setWidth(next);
616
+ };
617
+
618
+ const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
619
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
620
+ e.currentTarget.releasePointerCapture(e.pointerId);
621
+ }
622
+ dragRef.current = null;
623
+ setResizing(false);
624
+ };
625
+
626
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
627
+ const step = e.shiftKey ? 32 : 8;
628
+ if (e.key === 'ArrowLeft') {
629
+ e.preventDefault();
630
+ e.stopPropagation();
631
+ setWidth((w) => Math.max(MIN_RAIL_WIDTH, w - step));
632
+ } else if (e.key === 'ArrowRight') {
633
+ e.preventDefault();
634
+ e.stopPropagation();
635
+ setWidth((w) => Math.min(MAX_RAIL_WIDTH, w + step));
636
+ } else if (e.key === 'Home') {
637
+ e.preventDefault();
638
+ e.stopPropagation();
639
+ setWidth(DEFAULT_RAIL_WIDTH);
640
+ }
641
+ };
642
+
643
+ return (
644
+ <div className="relative hidden shrink-0 md:block" style={{ width }}>
645
+ <ThumbnailRail width={width} {...props} />
646
+ {/* biome-ignore lint/a11y/useSemanticElements: focusable resize handle (splitter pattern), not a static <hr> */}
647
+ <div
648
+ role="separator"
649
+ aria-orientation="vertical"
650
+ aria-label={t.thumbnailRail.resizeRail}
651
+ aria-valuenow={width}
652
+ aria-valuemin={MIN_RAIL_WIDTH}
653
+ aria-valuemax={MAX_RAIL_WIDTH}
654
+ tabIndex={0}
655
+ onPointerDown={onPointerDown}
656
+ onPointerMove={onPointerMove}
657
+ onPointerUp={onPointerUp}
658
+ onPointerCancel={onPointerUp}
659
+ onKeyDown={onKeyDown}
660
+ onDoubleClick={() => setWidth(DEFAULT_RAIL_WIDTH)}
661
+ className={cn(
662
+ 'group/resize absolute inset-y-0 right-0 z-20 w-1.5 translate-x-1/2 cursor-col-resize touch-none outline-none',
663
+ 'focus-visible:bg-brand/20',
664
+ )}
665
+ >
666
+ <span
667
+ aria-hidden
668
+ className={cn(
669
+ 'pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-brand opacity-0 transition-opacity',
670
+ 'group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100',
671
+ resizing && 'opacity-100',
672
+ )}
673
+ />
674
+ </div>
675
+ </div>
676
+ );
677
+ }
678
+
679
+ function AgentConnectedBadge() {
680
+ const t = useLocale();
681
+ const connected = useAgentSocketConnected();
682
+ return (
683
+ <TooltipProvider delayDuration={200}>
684
+ <Tooltip>
685
+ <TooltipTrigger asChild>
686
+ <button
687
+ type="button"
688
+ className="ml-1 flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-0.5 text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
689
+ >
690
+ <span aria-hidden className="relative flex size-1.5 items-center justify-center">
691
+ {connected ? (
692
+ <>
693
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
694
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
695
+ </>
696
+ ) : (
697
+ <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
698
+ )}
699
+ </span>
700
+ {connected ? t.slide.agentConnected : t.slide.agentDisconnected}
701
+ </button>
702
+ </TooltipTrigger>
703
+ <TooltipContent side="bottom" align="start" className="max-w-[280px] leading-relaxed">
704
+ {connected ? t.slide.agentConnectedTooltip : t.slide.agentDisconnectedTooltip}
705
+ </TooltipContent>
706
+ </Tooltip>
707
+ </TooltipProvider>
708
+ );
709
+ }
710
+
711
+ function SelectionReporter() {
712
+ const { selected } = useInspector();
713
+ useEffect(() => {
714
+ if (!import.meta.hot) return;
715
+ const selection = selected
716
+ ? {
717
+ line: selected.line,
718
+ column: selected.column,
719
+ tagName: selected.anchor.tagName.toLowerCase(),
720
+ text: (selected.anchor.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120),
721
+ }
722
+ : null;
723
+ import.meta.hot.send('open-slide:current', { selection });
724
+ }, [selected]);
725
+ return null;
726
+ }
727
+
463
728
  function SlideWheelNavigation({
464
729
  targetRef,
465
730
  onPrev,
@@ -0,0 +1,34 @@
1
+ import { useNavigate, useParams } from 'react-router-dom';
2
+ import { useLocale } from '@/lib/use-locale';
3
+ import { FolderIconChip } from '../components/sidebar/folder-item';
4
+ import { ThemeDetail } from '../components/themes/theme-detail';
5
+ import { ThemesGallery } from '../components/themes/themes-gallery';
6
+ import { themes as themeRegistry } from '../lib/themes';
7
+
8
+ export function ThemesGalleryPage() {
9
+ const navigate = useNavigate();
10
+ const t = useLocale();
11
+ return (
12
+ <>
13
+ <header className="mb-8 md:mb-12">
14
+ <div className="flex flex-wrap items-center gap-3">
15
+ <FolderIconChip icon={{ type: 'emoji', value: '🎨' }} className="size-7 text-2xl" />
16
+ <h1 className="font-heading text-[32px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[44px]">
17
+ {t.themes.title}
18
+ </h1>
19
+ <span className="folio ml-1 self-end pb-2">
20
+ {themeRegistry.length.toString().padStart(2, '0')}
21
+ </span>
22
+ </div>
23
+ </header>
24
+ <ThemesGallery onOpen={(id) => navigate(`/themes/${encodeURIComponent(id)}`)} />
25
+ </>
26
+ );
27
+ }
28
+
29
+ export function ThemeDetailPage() {
30
+ const { themeId } = useParams<{ themeId: string }>();
31
+ const navigate = useNavigate();
32
+ if (!themeId) return null;
33
+ return <ThemeDetail themeId={themeId} onBack={() => navigate('/themes')} />;
34
+ }
@@ -1,6 +1,7 @@
1
1
  declare module 'virtual:open-slide/slides' {
2
2
  import type { SlideModule } from './lib/sdk';
3
3
  export const slideIds: string[];
4
+ export const slideThemes: Record<string, string>;
4
5
  export function loadSlide(id: string): Promise<SlideModule>;
5
6
  }
6
7
 
@@ -26,3 +27,22 @@ declare module 'virtual:open-slide/folders' {
26
27
  const manifest: FoldersManifest;
27
28
  export default manifest;
28
29
  }
30
+
31
+ declare module 'virtual:open-slide/themes' {
32
+ import type { DesignSystem } from './lib/design';
33
+ import type { Page } from './lib/sdk';
34
+
35
+ export type ThemeMeta = {
36
+ id: string;
37
+ name: string;
38
+ description: string;
39
+ body: string;
40
+ hasDemo: boolean;
41
+ };
42
+
43
+ export const themes: ThemeMeta[];
44
+ export function loadThemeDemo(id: string): Promise<{
45
+ default: Page[];
46
+ design?: DesignSystem;
47
+ }>;
48
+ }
package/src/locale/en.ts CHANGED
@@ -38,6 +38,7 @@ export const en: Locale = {
38
38
  home: {
39
39
  appTitle: 'open-slide',
40
40
  draft: 'Draft',
41
+ themes: 'Themes',
41
42
  folders: 'Folders',
42
43
  newFolder: 'New folder',
43
44
  folderName: 'Folder name',
@@ -51,9 +52,8 @@ export const en: Locale = {
51
52
  nothingMatchesPrefix: 'Nothing matches ',
52
53
  nothingMatchesSuffix: ' in this folder.',
53
54
  noSlidesYet: 'No slides yet',
54
- createSlideHintPrefix: 'Create ',
55
- createSlideHintMid: ' that ',
56
- createSlideHintSuffix: '.',
55
+ createSlideHintPrefix: 'Run ',
56
+ createSlideHintSuffix: ' in your agent to scaffold one.',
57
57
  folderEmptyTitle: '{name} is empty',
58
58
  folderEmptyHint: 'Drag a slide from Draft into this folder in the sidebar.',
59
59
  slideActions: 'Slide actions',
@@ -83,6 +83,12 @@ export const en: Locale = {
83
83
  slide: {
84
84
  home: 'Home',
85
85
  backToHome: 'Back to home',
86
+ agentConnected: 'Agent connected',
87
+ agentConnectedTooltip:
88
+ 'The dev server is publishing your current slide and inspector selection to your agent. Ask "this slide" or "this element" in chat and it will resolve. Disappears in production builds.',
89
+ agentDisconnected: 'Agent disconnected',
90
+ agentDisconnectedTooltip:
91
+ 'Lost connection to the dev server, so your agent can no longer see the current slide or inspector selection. Restart the dev server to restore the connection.',
86
92
  download: 'Download',
87
93
  exportAsHtml: 'Export as HTML',
88
94
  exportAsPdf: 'Export as PDF',
@@ -156,6 +162,12 @@ export const en: Locale = {
156
162
  inspector: {
157
163
  inspect: 'Inspect',
158
164
  deselect: 'Deselect',
165
+ agentWatching: 'Agent is watching',
166
+ agentWatchingTooltip:
167
+ 'Your agent already sees the selected element via the dev server — just ask it in chat. Leave comments here only when you want to queue a few before asking.',
168
+ agentNotWatching: 'Agent not watching',
169
+ agentNotWatchingTooltip:
170
+ 'Lost connection to the dev server, so your agent can no longer see the selected element. Restart the dev server to restore the connection.',
159
171
  contentSection: 'Content',
160
172
  typographySection: 'Typography',
161
173
  colorSection: 'Color',
@@ -192,10 +204,10 @@ export const en: Locale = {
192
204
  cropFitContain: 'Fit',
193
205
  cropApply: 'Apply',
194
206
  cropResetAria: 'Reset crop',
195
- noteForAgent: 'Note for the agent',
196
- noteAgentPlaceholder: 'Describe a change for the agent…',
197
- noteShortcutHint: '⌘↵ to send',
198
- addNote: 'Add note',
207
+ leaveComment: 'Leave a comment',
208
+ commentPlaceholder: 'Describe a change for the agent…',
209
+ commentShortcutHint: '⌘↵ to add',
210
+ addComment: 'Add comment',
199
211
  unsavedChanges: {
200
212
  one: '{count} unsaved change',
201
213
  other: '{count} unsaved changes',
@@ -288,6 +300,14 @@ export const en: Locale = {
288
300
  thumbnailRail: {
289
301
  pages: 'Pages',
290
302
  goToPageAria: 'Go to page {n}',
303
+ duplicatePage: 'Duplicate',
304
+ deletePage: 'Delete',
305
+ pageActionsAria: 'Page {n} actions',
306
+ toastDuplicated: 'Duplicated page {n}',
307
+ toastDeleted: 'Deleted page {n}',
308
+ toastDuplicateFailed: 'Could not duplicate page',
309
+ toastDeleteFailed: 'Could not delete page',
310
+ resizeRail: 'Resize thumbnail rail',
291
311
  },
292
312
 
293
313
  pdfToast: {
@@ -309,4 +329,38 @@ export const en: Locale = {
309
329
  prevAria: 'Previous page',
310
330
  nextAria: 'Next page',
311
331
  },
332
+
333
+ imagePlaceholder: {
334
+ dropOverlay: 'Drop image to use here',
335
+ uploading: 'Uploading…',
336
+ uploadFailed: "Couldn't upload image",
337
+ },
338
+
339
+ notesDrawer: {
340
+ toggle: 'Notes',
341
+ pageLabel: 'page {n}/{total}',
342
+ placeholder: 'Write speaker notes for this slide…',
343
+ statusSaving: 'Saving…',
344
+ statusSaved: 'Saved',
345
+ statusError: 'Save failed: {msg}',
346
+ },
347
+
348
+ themes: {
349
+ title: 'Themes',
350
+ noThemesTitle: 'No themes yet',
351
+ noThemesHintPrefix: 'Run ',
352
+ noThemesHintSuffix: ' to author one — a markdown file under themes/ plus a sibling demo slide.',
353
+ noDemoYet: 'No demo yet',
354
+ noDemoHintPrefix: 'Re-run ',
355
+ noDemoHintSuffix: ' for this theme to generate a preview slide.',
356
+ backToGallery: 'Back to themes',
357
+ pageOf: 'page {n}/{total}',
358
+ nextPageAria: 'Next page',
359
+ prevPageAria: 'Previous page',
360
+ openThemeAria: 'Open theme {name}',
361
+ usedBy: 'Slides using this theme',
362
+ usedByEmpty: 'No slides use this theme yet.',
363
+ expandPromptAria: 'Expand prompt',
364
+ collapsePromptAria: 'Collapse prompt',
365
+ },
312
366
  };