@open-slide/core 0.0.11 → 0.0.12

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 (88) hide show
  1. package/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
  4. package/dist/design-CROQh0AA.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
  6. package/dist/index.d.ts +55 -4
  7. package/dist/index.js +110 -1
  8. package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
  9. package/dist/sync-3oqN1WyK.js +139 -0
  10. package/dist/sync-B4eLo2H6.js +3 -0
  11. package/dist/vite/index.d.ts +1 -1
  12. package/dist/vite/index.js +2 -1
  13. package/package.json +2 -1
  14. package/skills/apply-comments/SKILL.md +83 -0
  15. package/skills/create-slide/SKILL.md +81 -0
  16. package/skills/create-theme/SKILL.md +194 -0
  17. package/skills/slide-authoring/SKILL.md +288 -0
  18. package/src/app/{App.tsx → app.tsx} +8 -6
  19. package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
  20. package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
  21. package/src/app/components/history-provider.tsx +120 -0
  22. package/src/app/components/image-placeholder.tsx +121 -0
  23. package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
  24. package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
  25. package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
  26. package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
  27. package/src/app/components/inspector/save-bar.tsx +47 -0
  28. package/src/app/components/panel/panel-fields.tsx +60 -0
  29. package/src/app/components/panel/panel-shell.tsx +78 -0
  30. package/src/app/components/panel/save-card.tsx +139 -0
  31. package/src/app/components/pdf-progress-toast.tsx +25 -0
  32. package/src/app/components/player.tsx +341 -0
  33. package/src/app/components/present/blackout-overlay.tsx +18 -0
  34. package/src/app/components/present/control-bar.tsx +204 -0
  35. package/src/app/components/present/help-overlay.tsx +56 -0
  36. package/src/app/components/present/jump-input.tsx +74 -0
  37. package/src/app/components/present/laser-pointer.tsx +40 -0
  38. package/src/app/components/present/overview-grid.tsx +184 -0
  39. package/src/app/components/present/progress-bar.tsx +26 -0
  40. package/src/app/components/present/use-idle.ts +44 -0
  41. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  42. package/src/app/components/present/use-presenter-channel.ts +71 -0
  43. package/src/app/components/present/use-touch-swipe.ts +63 -0
  44. package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
  45. package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
  46. package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
  47. package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
  48. package/src/app/components/style-panel/design-provider.tsx +139 -0
  49. package/src/app/components/style-panel/style-panel.tsx +326 -0
  50. package/src/app/components/style-panel/use-design.ts +112 -0
  51. package/src/app/components/theme-toggle.tsx +57 -0
  52. package/src/app/components/thumbnail-rail.tsx +151 -0
  53. package/src/app/components/ui/button.tsx +51 -19
  54. package/src/app/components/ui/card.tsx +1 -1
  55. package/src/app/components/ui/dialog.tsx +25 -9
  56. package/src/app/components/ui/dropdown-menu.tsx +29 -12
  57. package/src/app/components/ui/input.tsx +13 -9
  58. package/src/app/components/ui/popover.tsx +5 -2
  59. package/src/app/components/ui/progress.tsx +2 -2
  60. package/src/app/components/ui/select.tsx +11 -5
  61. package/src/app/components/ui/separator.tsx +1 -1
  62. package/src/app/components/ui/slider.tsx +4 -4
  63. package/src/app/components/ui/sonner.tsx +11 -1
  64. package/src/app/components/ui/tabs.tsx +6 -6
  65. package/src/app/components/ui/textarea.tsx +11 -7
  66. package/src/app/components/ui/toggle-group.tsx +2 -2
  67. package/src/app/components/ui/toggle.tsx +6 -6
  68. package/src/app/components/ui/tooltip.tsx +5 -2
  69. package/src/app/lib/export-html.ts +10 -1
  70. package/src/app/lib/export-pdf.ts +7 -0
  71. package/src/app/lib/folders.ts +1 -1
  72. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  73. package/src/app/lib/sdk.ts +5 -0
  74. package/src/app/lib/slides.ts +1 -1
  75. package/src/app/lib/utils.ts +1 -1
  76. package/src/app/main.tsx +5 -2
  77. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  78. package/src/app/routes/presenter.tsx +400 -0
  79. package/src/app/routes/slide.tsx +519 -0
  80. package/src/app/styles.css +338 -67
  81. package/src/app/components/PdfProgressToast.tsx +0 -23
  82. package/src/app/components/Player.tsx +0 -100
  83. package/src/app/components/ThumbnailRail.tsx +0 -68
  84. package/src/app/components/inspector/SaveBar.tsx +0 -77
  85. package/src/app/routes/Slide.tsx +0 -478
  86. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  87. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  88. /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
@@ -0,0 +1,519 @@
1
+ import config from 'virtual:open-slide/config';
2
+ import { ChevronLeft, Download, FileCode2, FileText, Loader2, Pencil, Play } from 'lucide-react';
3
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Link, useParams, useSearchParams } from 'react-router-dom';
5
+ import { toast } from 'sonner';
6
+ import { AssetView } from '@/components/asset-view';
7
+ import { HistoryProvider } from '@/components/history-provider';
8
+ import { CommentWidget } from '@/components/inspector/comment-widget';
9
+ import { InspectOverlay } from '@/components/inspector/inspect-overlay';
10
+ import { InspectorPanel } from '@/components/inspector/inspector-panel';
11
+ import {
12
+ InspectorProvider,
13
+ InspectToggleButton,
14
+ useInspector,
15
+ } from '@/components/inspector/inspector-provider';
16
+ import { SaveBar } from '@/components/inspector/save-bar';
17
+ import { DesignProvider } from '@/components/style-panel/design-provider';
18
+ import { DesignPanel, DesignToggleButton } from '@/components/style-panel/style-panel';
19
+ import { Button, buttonVariants } from '@/components/ui/button';
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuItem,
24
+ DropdownMenuTrigger,
25
+ } from '@/components/ui/dropdown-menu';
26
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
27
+ import { useFolders } from '@/lib/folders';
28
+ import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
29
+ import { cn } from '@/lib/utils';
30
+ import { ClickNavZones } from '../components/click-nav-zones';
31
+ import { PdfProgressToast } from '../components/pdf-progress-toast';
32
+ import { Player } from '../components/player';
33
+ import { SlideCanvas } from '../components/slide-canvas';
34
+ import { ThumbnailRail } from '../components/thumbnail-rail';
35
+ import { exportSlideAsHtml } from '../lib/export-html';
36
+ import { exportSlideAsPdf } from '../lib/export-pdf';
37
+ import type { SlideModule } from '../lib/sdk';
38
+ import { loadSlide } from '../lib/slides';
39
+
40
+ const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build;
41
+
42
+ export function Slide() {
43
+ const { slideId = '' } = useParams();
44
+ const [searchParams, setSearchParams] = useSearchParams();
45
+ const [slide, setSlide] = useState<SlideModule | null>(null);
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [playing, setPlaying] = useState(false);
48
+ const [exporting, setExporting] = useState(false);
49
+ const [designOpen, setDesignOpen] = useState(false);
50
+ const { renameSlide } = useFolders();
51
+ const slideViewportRef = useRef<HTMLElement>(null);
52
+
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ setSlide(null);
56
+ setError(null);
57
+ loadSlide(slideId)
58
+ .then((mod) => {
59
+ if (!cancelled) setSlide(mod);
60
+ })
61
+ .catch((e) => {
62
+ if (!cancelled) setError(String(e?.message ?? e));
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [slideId]);
68
+
69
+ const pages = useMemo(() => slide?.default ?? [], [slide]);
70
+ const pageCount = pages.length;
71
+ const rawIndex = Number(searchParams.get('p') ?? '1') - 1;
72
+ const index = Number.isFinite(rawIndex) ? Math.max(0, Math.min(pageCount - 1, rawIndex)) : 0;
73
+ const view = searchParams.get('view') === 'assets' ? 'assets' : 'slides';
74
+
75
+ const goTo = useCallback(
76
+ (i: number) => {
77
+ const clamped = Math.max(0, Math.min(pageCount - 1, i));
78
+ setSearchParams(
79
+ (prev) => {
80
+ const next = new URLSearchParams(prev);
81
+ next.set('p', String(clamped + 1));
82
+ return next;
83
+ },
84
+ { replace: true },
85
+ );
86
+ },
87
+ [pageCount, setSearchParams],
88
+ );
89
+
90
+ useEffect(() => {
91
+ if (playing) return;
92
+ const onKey = (e: KeyboardEvent) => {
93
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
94
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'PageDown') {
95
+ e.preventDefault();
96
+ goTo(index + 1);
97
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
98
+ e.preventDefault();
99
+ goTo(index - 1);
100
+ } else if (e.key === 'f' || e.key === 'F') {
101
+ setPlaying(true);
102
+ }
103
+ };
104
+ window.addEventListener('keydown', onKey);
105
+ return () => window.removeEventListener('keydown', onKey);
106
+ }, [index, goTo, playing]);
107
+
108
+ if (error) {
109
+ return (
110
+ <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
111
+ {showSlideBrowser && (
112
+ <Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
113
+ ← Home
114
+ </Link>
115
+ )}
116
+ <span className="mt-6 block eyebrow text-destructive/80">Load failed</span>
117
+ <h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
118
+ Failed to load slide
119
+ </h2>
120
+ <pre className="mt-4 overflow-auto rounded-[6px] border border-border bg-card p-4 text-[11.5px] leading-relaxed whitespace-pre-wrap shadow-edge">
121
+ {error}
122
+ </pre>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ if (!slide) {
128
+ return (
129
+ <div className="mx-auto max-w-3xl px-8 py-16 text-[12.5px] text-muted-foreground">
130
+ <span className="eyebrow">Loading</span>
131
+ <p className="mt-2 font-mono">{slideId}</p>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ if (pageCount === 0) {
137
+ return (
138
+ <div className="mx-auto max-w-3xl px-8 py-16 text-muted-foreground">
139
+ {showSlideBrowser && (
140
+ <Link to="/" className="text-[12px] font-medium text-foreground/70 hover:text-foreground">
141
+ ← Home
142
+ </Link>
143
+ )}
144
+ <span className="mt-6 block eyebrow">Empty</span>
145
+ <h2 className="mt-2 font-heading text-xl font-semibold tracking-tight text-foreground">
146
+ Nothing to show.
147
+ </h2>
148
+ <p className="mt-3 text-[13px] leading-relaxed">
149
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
150
+ slides/{slideId}/index.tsx
151
+ </code>{' '}
152
+ must{' '}
153
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px]">
154
+ export default
155
+ </code>{' '}
156
+ a non-empty array of components.
157
+ </p>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ if (!showSlideUi) {
163
+ return (
164
+ <Player
165
+ pages={pages}
166
+ design={slide.design}
167
+ index={index}
168
+ onIndexChange={goTo}
169
+ onExit={() => {}}
170
+ allowExit={false}
171
+ />
172
+ );
173
+ }
174
+
175
+ if (playing) {
176
+ return (
177
+ <Player
178
+ pages={pages}
179
+ design={slide.design}
180
+ index={index}
181
+ onIndexChange={goTo}
182
+ onExit={() => setPlaying(false)}
183
+ controls
184
+ slideId={slideId}
185
+ />
186
+ );
187
+ }
188
+
189
+ const CurrentPage = pages[index];
190
+ const title = slide.meta?.title ?? slideId;
191
+
192
+ return (
193
+ <HistoryProvider>
194
+ <InspectorProvider slideId={slideId}>
195
+ <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
196
+ {/* Editorial toolbar — three zones, hairline separators, mono-folio center */}
197
+ <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">
198
+ <div className="flex items-center gap-1.5 md:gap-2">
199
+ {showSlideBrowser && (
200
+ <Button asChild variant="ghost" size="icon-sm" title="Home">
201
+ <Link to="/" aria-label="Back to home">
202
+ <ChevronLeft className="size-4" />
203
+ </Link>
204
+ </Button>
205
+ )}
206
+ <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
207
+ {import.meta.env.DEV && (
208
+ <Tabs
209
+ value={view}
210
+ onValueChange={(next) => {
211
+ setSearchParams(
212
+ (prev) => {
213
+ const params = new URLSearchParams(prev);
214
+ if (next === 'assets') params.set('view', 'assets');
215
+ else params.delete('view');
216
+ return params;
217
+ },
218
+ { replace: true },
219
+ );
220
+ }}
221
+ >
222
+ <TabsList>
223
+ <TabsTrigger value="slides">Slides</TabsTrigger>
224
+ <TabsTrigger value="assets">Assets</TabsTrigger>
225
+ </TabsList>
226
+ </Tabs>
227
+ )}
228
+ </div>
229
+
230
+ {/* Centered title — the rail and mobile pill carry the page count. */}
231
+ <div className="pointer-events-none absolute inset-x-0 top-1/2 flex -translate-y-1/2 justify-center px-2">
232
+ <div className="pointer-events-auto min-w-0 max-w-[min(34rem,calc(100vw-22rem))]">
233
+ <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
234
+ </div>
235
+ </div>
236
+
237
+ <div className="flex items-center gap-1">
238
+ {view === 'slides' && allowHtmlDownload && (
239
+ <DropdownMenu>
240
+ <DropdownMenuTrigger
241
+ type="button"
242
+ disabled={exporting}
243
+ aria-label="Download"
244
+ title="Download"
245
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
246
+ >
247
+ {exporting ? (
248
+ <Loader2 className="size-4 animate-spin" />
249
+ ) : (
250
+ <Download className="size-4" />
251
+ )}
252
+ </DropdownMenuTrigger>
253
+ <DropdownMenuContent align="end" className="min-w-[200px]">
254
+ <DropdownMenuItem
255
+ disabled={exporting}
256
+ onSelect={async () => {
257
+ if (!slide || exporting) return;
258
+ setExporting(true);
259
+ try {
260
+ await exportSlideAsHtml(slide, slideId);
261
+ } catch (err) {
262
+ console.error('[open-slide] export failed', err);
263
+ } finally {
264
+ setExporting(false);
265
+ }
266
+ }}
267
+ >
268
+ <FileCode2 />
269
+ Export as HTML
270
+ </DropdownMenuItem>
271
+ <DropdownMenuItem
272
+ disabled={exporting}
273
+ onSelect={async () => {
274
+ if (!slide || exporting) return;
275
+ setExporting(true);
276
+ const toastId = `pdf-export-${slideId}`;
277
+ toast.custom(
278
+ () => (
279
+ <PdfProgressToast
280
+ progress={{
281
+ phase: 'processing',
282
+ current: 0,
283
+ total: pages.length,
284
+ percent: 0,
285
+ }}
286
+ />
287
+ ),
288
+ { id: toastId, duration: Infinity },
289
+ );
290
+ try {
291
+ await exportSlideAsPdf(slide, slideId, (p) => {
292
+ toast.custom(() => <PdfProgressToast progress={p} />, {
293
+ id: toastId,
294
+ duration: Infinity,
295
+ });
296
+ });
297
+ } catch (err) {
298
+ console.error('[open-slide] pdf export failed', err);
299
+ toast.error('PDF export failed', { id: toastId, duration: 4000 });
300
+ } finally {
301
+ setExporting(false);
302
+ toast.dismiss(toastId);
303
+ }
304
+ }}
305
+ >
306
+ <FileText />
307
+ Export as PDF
308
+ </DropdownMenuItem>
309
+ </DropdownMenuContent>
310
+ </DropdownMenu>
311
+ )}
312
+ {view === 'slides' && (
313
+ <DesignToggleButton active={designOpen} onToggle={() => setDesignOpen((v) => !v)} />
314
+ )}
315
+ {view === 'slides' && <InspectToggleButton />}
316
+ <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
317
+ {view === 'slides' && (
318
+ <Button
319
+ size="sm"
320
+ variant="brand"
321
+ onClick={() => setPlaying(true)}
322
+ className="px-2.5 md:px-3"
323
+ >
324
+ <Play className="size-3.5 fill-current" />
325
+ <span className="hidden md:inline">Present</span>
326
+ <kbd className="ml-1 hidden rounded-[3px] bg-brand-foreground/15 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
327
+ F
328
+ </kbd>
329
+ </Button>
330
+ )}
331
+ </div>
332
+ </header>
333
+
334
+ {view === 'assets' ? (
335
+ <div className="min-h-0 flex-1">
336
+ <AssetView slideId={slideId} />
337
+ </div>
338
+ ) : (
339
+ <DesignProvider slideId={slideId}>
340
+ <div className="flex min-h-0 flex-1 flex-col md:flex-row">
341
+ <div className="hidden w-[16.5rem] shrink-0 md:block">
342
+ <ThumbnailRail
343
+ pages={pages}
344
+ design={slide.design}
345
+ current={index}
346
+ onSelect={goTo}
347
+ />
348
+ </div>
349
+ <main
350
+ ref={slideViewportRef}
351
+ data-inspector-root
352
+ className="paper relative min-h-0 min-w-0 flex-1 bg-canvas p-2 md:p-10"
353
+ >
354
+ <SlideWheelNavigation
355
+ targetRef={slideViewportRef}
356
+ onPrev={() => goTo(index - 1)}
357
+ onNext={() => goTo(index + 1)}
358
+ canPrev={index > 0}
359
+ canNext={index < pageCount - 1}
360
+ />
361
+ <SlideCanvas design={slide.design}>
362
+ <CurrentPage />
363
+ </SlideCanvas>
364
+ <ClickNavZones
365
+ onPrev={() => goTo(index - 1)}
366
+ onNext={() => goTo(index + 1)}
367
+ canPrev={index > 0}
368
+ canNext={index < pageCount - 1}
369
+ />
370
+ <InspectOverlay />
371
+ <SaveBar />
372
+ {import.meta.env.DEV && <CommentWidget />}
373
+ </main>
374
+ {/* Mobile-only horizontal rail. Sits below the canvas and
375
+ pads its bottom for the iOS home indicator / Safari URL bar. */}
376
+ <div
377
+ className="shrink-0 border-t border-hairline md:hidden"
378
+ style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
379
+ >
380
+ <ThumbnailRail
381
+ pages={pages}
382
+ design={slide.design}
383
+ current={index}
384
+ onSelect={goTo}
385
+ orientation="horizontal"
386
+ />
387
+ </div>
388
+ <InspectorPanel />
389
+ <DesignPanel open={designOpen} onClose={() => setDesignOpen(false)} />
390
+ </div>
391
+ </DesignProvider>
392
+ )}
393
+ </div>
394
+ </InspectorProvider>
395
+ </HistoryProvider>
396
+ );
397
+ }
398
+
399
+ function SlideWheelNavigation({
400
+ targetRef,
401
+ onPrev,
402
+ onNext,
403
+ canPrev,
404
+ canNext,
405
+ }: {
406
+ targetRef: RefObject<HTMLElement>;
407
+ onPrev: () => void;
408
+ onNext: () => void;
409
+ canPrev: boolean;
410
+ canNext: boolean;
411
+ }) {
412
+ const { active } = useInspector();
413
+
414
+ useWheelPageNavigation({
415
+ ref: targetRef,
416
+ enabled: !active,
417
+ canPrev,
418
+ canNext,
419
+ onPrev,
420
+ onNext,
421
+ });
422
+
423
+ return null;
424
+ }
425
+
426
+ function InlineTitleEditor({
427
+ title,
428
+ onSubmit,
429
+ }: {
430
+ title: string;
431
+ onSubmit: (name: string) => Promise<void> | void;
432
+ }) {
433
+ const [editing, setEditing] = useState(false);
434
+ const [value, setValue] = useState(title);
435
+ const [saving, setSaving] = useState(false);
436
+ const inputRef = useRef<HTMLInputElement | null>(null);
437
+
438
+ useEffect(() => {
439
+ if (!editing) setValue(title);
440
+ }, [title, editing]);
441
+
442
+ useEffect(() => {
443
+ if (editing) {
444
+ queueMicrotask(() => {
445
+ inputRef.current?.focus();
446
+ inputRef.current?.select();
447
+ });
448
+ }
449
+ }, [editing]);
450
+
451
+ const commit = async () => {
452
+ const trimmed = value.trim();
453
+ if (!trimmed || trimmed === title) {
454
+ setValue(title);
455
+ setEditing(false);
456
+ return;
457
+ }
458
+ setSaving(true);
459
+ try {
460
+ await onSubmit(trimmed);
461
+ setEditing(false);
462
+ } finally {
463
+ setSaving(false);
464
+ }
465
+ };
466
+
467
+ const cancel = () => {
468
+ setValue(title);
469
+ setEditing(false);
470
+ };
471
+
472
+ if (editing) {
473
+ return (
474
+ <div className="flex flex-1 items-center justify-center">
475
+ <input
476
+ ref={inputRef}
477
+ value={value}
478
+ disabled={saving}
479
+ onChange={(e) => setValue(e.target.value)}
480
+ onBlur={() => {
481
+ if (!saving) commit();
482
+ }}
483
+ onKeyDown={(e) => {
484
+ if (e.key === 'Enter') {
485
+ e.preventDefault();
486
+ commit();
487
+ } else if (e.key === 'Escape') {
488
+ e.preventDefault();
489
+ cancel();
490
+ }
491
+ }}
492
+ maxLength={80}
493
+ className="min-w-0 max-w-[min(34rem,90%)] rounded-[5px] border border-foreground/30 bg-card px-2 py-0.5 text-center font-heading text-[13px] font-medium tracking-tight outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
494
+ />
495
+ </div>
496
+ );
497
+ }
498
+
499
+ return (
500
+ <div className="group/title flex min-w-0 items-baseline justify-center gap-1.5">
501
+ <h1 className="truncate font-heading text-[13.5px] font-semibold tracking-[-0.01em]">
502
+ {title}
503
+ </h1>
504
+ {import.meta.env.DEV && (
505
+ <button
506
+ type="button"
507
+ onClick={() => setEditing(true)}
508
+ aria-label="Rename slide"
509
+ className={cn(
510
+ 'flex size-5 shrink-0 items-center justify-center rounded-[4px] text-muted-foreground transition-opacity hover:bg-muted hover:text-foreground',
511
+ 'opacity-0 group-hover/title:opacity-100 focus-visible:opacity-100',
512
+ )}
513
+ >
514
+ <Pencil className="size-3" />
515
+ </button>
516
+ )}
517
+ </div>
518
+ );
519
+ }