@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,48 @@
1
+ import { useHistory } from '@/components/history-provider';
2
+ import { SaveCard } from '@/components/panel/save-card';
3
+ import { useDesignPanelState } from '@/components/style-panel/design-provider';
4
+ import { format, plural, useLocale } from '@/lib/use-locale';
5
+ import { useInspector } from './inspector-provider';
6
+
7
+ export function SaveBar() {
8
+ const insp = useInspector();
9
+ const design = useDesignPanelState();
10
+ const history = useHistory();
11
+ const t = useLocale();
12
+
13
+ const inspectorCount = insp.pendingCount;
14
+ const designCount = design.dirty ? 1 : 0;
15
+ const total = inspectorCount + designCount;
16
+
17
+ const dirty = total > 0;
18
+ const committing = insp.committing || design.committing;
19
+
20
+ const onSave = async () => {
21
+ const tasks: Promise<void>[] = [];
22
+ if (inspectorCount > 0) tasks.push(Promise.resolve(insp.commitEdits()));
23
+ if (designCount > 0) tasks.push(Promise.resolve(design.commit()));
24
+ // Each provider surfaces its own errors via toast; swallow here so
25
+ // one failure doesn't reject the combined save.
26
+ await Promise.all(tasks).catch(() => {});
27
+ };
28
+
29
+ const onDiscard = () => {
30
+ if (inspectorCount > 0) insp.cancelEdits();
31
+ if (designCount > 0) design.discard();
32
+ };
33
+
34
+ return (
35
+ <SaveCard
36
+ uiAttr="inspector"
37
+ dirty={dirty}
38
+ committing={committing}
39
+ onSave={onSave}
40
+ onDiscard={onDiscard}
41
+ unsavedLabel={format(plural(total, t.inspector.unsavedChanges), { count: total })}
42
+ onUndo={history.undo}
43
+ onRedo={history.redo}
44
+ canUndo={history.canUndo}
45
+ canRedo={history.canRedo}
46
+ />
47
+ );
48
+ }
@@ -0,0 +1,39 @@
1
+ import { Languages } from 'lucide-react';
2
+ import { buttonVariants } from '@/components/ui/button';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from '@/components/ui/dropdown-menu';
9
+ import { LOCALE_OPTIONS, setLocale } from '@/lib/locale-store';
10
+ import { useLocale } from '@/lib/use-locale';
11
+ import { cn } from '@/lib/utils';
12
+
13
+ export function LanguageToggle() {
14
+ const t = useLocale();
15
+
16
+ return (
17
+ <DropdownMenu>
18
+ <DropdownMenuTrigger
19
+ type="button"
20
+ aria-label={t.languageToggle.toggleAria}
21
+ title={t.languageToggle.title}
22
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
23
+ >
24
+ <Languages className="size-3.5" />
25
+ </DropdownMenuTrigger>
26
+ <DropdownMenuContent align="end" className="min-w-[140px]">
27
+ {LOCALE_OPTIONS.map((option) => (
28
+ <DropdownMenuItem
29
+ key={option.id}
30
+ onSelect={() => setLocale(option.id)}
31
+ data-active={t.id === option.id}
32
+ >
33
+ {option.label}
34
+ </DropdownMenuItem>
35
+ ))}
36
+ </DropdownMenuContent>
37
+ </DropdownMenu>
38
+ );
39
+ }
@@ -0,0 +1,120 @@
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-aippt: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
+ // Escape during IME composition dismisses the candidate
101
+ // list; it must not blur the textarea.
102
+ if (e.nativeEvent.isComposing) return;
103
+ e.preventDefault();
104
+ textareaRef.current?.blur();
105
+ } else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
106
+ e.preventDefault();
107
+ void flush();
108
+ }
109
+ }}
110
+ placeholder={t.notesDrawer.placeholder}
111
+ rows={6}
112
+ spellCheck
113
+ 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"
114
+ />
115
+ </div>
116
+ </div>
117
+ )}
118
+ </aside>
119
+ );
120
+ }
@@ -0,0 +1,363 @@
1
+ import { ListOrdered, type LucideIcon, Sparkles, X } from 'lucide-react';
2
+ import { type Ref, useEffect, useRef, useState } from 'react';
3
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
+ import { format, useLocale } from '@/lib/use-locale';
5
+ import { cn } from '@/lib/utils';
6
+ import type { DesignSystem } from '../lib/design';
7
+ import { SlidePageProvider } from '../lib/page-context';
8
+ import type { Page } from '../lib/sdk';
9
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
10
+ import type { SlideTransition } from '../lib/transition';
11
+ import { SlideCanvas } from './slide-canvas';
12
+
13
+ const THUMB_W = 320;
14
+ const THUMB_H = (THUMB_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
15
+
16
+ export type OverviewVariant = 'present' | 'editor';
17
+
18
+ type Props = {
19
+ pages: Page[];
20
+ design?: DesignSystem;
21
+ open: boolean;
22
+ current: number;
23
+ onClose: () => void;
24
+ onSelect: (index: number) => void;
25
+ variant?: OverviewVariant;
26
+ moduleTransition?: SlideTransition;
27
+ tooltipContainer?: HTMLElement | null;
28
+ };
29
+
30
+ export function OverviewGrid({
31
+ pages,
32
+ design,
33
+ open,
34
+ current,
35
+ onClose,
36
+ onSelect,
37
+ variant = 'present',
38
+ moduleTransition,
39
+ tooltipContainer,
40
+ }: Props) {
41
+ const [focused, setFocused] = useState(current);
42
+ const gridRef = useRef<HTMLDivElement>(null);
43
+ const focusedRef = useRef<HTMLButtonElement | null>(null);
44
+ const t = useLocale();
45
+
46
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
47
+ useEffect(() => {
48
+ if (open) setFocused(current);
49
+ }, [open]);
50
+
51
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `focused` swaps which button holds the ref; we must re-run to focus the new node
52
+ useEffect(() => {
53
+ if (!open) return;
54
+ focusedRef.current?.focus();
55
+ focusedRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
56
+ }, [focused, open]);
57
+
58
+ useEffect(() => {
59
+ if (!open) return;
60
+ const onKey = (e: KeyboardEvent) => {
61
+ if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
62
+ const cols = computeCols(gridRef.current);
63
+ if (e.key === 'ArrowRight') {
64
+ e.preventDefault();
65
+ e.stopPropagation();
66
+ setFocused((i) => Math.min(pages.length - 1, i + 1));
67
+ } else if (e.key === 'ArrowLeft') {
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ setFocused((i) => Math.max(0, i - 1));
71
+ } else if (e.key === 'ArrowDown') {
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ setFocused((i) => Math.min(pages.length - 1, i + cols));
75
+ } else if (e.key === 'ArrowUp') {
76
+ e.preventDefault();
77
+ e.stopPropagation();
78
+ setFocused((i) => Math.max(0, i - cols));
79
+ } else if (e.key === 'Home') {
80
+ e.preventDefault();
81
+ e.stopPropagation();
82
+ setFocused(0);
83
+ } else if (e.key === 'End') {
84
+ e.preventDefault();
85
+ e.stopPropagation();
86
+ setFocused(pages.length - 1);
87
+ } else if (e.key === 'Enter') {
88
+ e.preventDefault();
89
+ e.stopPropagation();
90
+ onSelect(focused);
91
+ onClose();
92
+ } else if (e.key === 'Escape') {
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ onClose();
96
+ }
97
+ };
98
+ window.addEventListener('keydown', onKey, true);
99
+ return () => window.removeEventListener('keydown', onKey, true);
100
+ }, [open, pages.length, focused, onClose, onSelect]);
101
+
102
+ if (!open) return null;
103
+
104
+ const styles = variant === 'present' ? presentStyles : editorStyles;
105
+
106
+ return (
107
+ <div
108
+ role="dialog"
109
+ aria-modal="true"
110
+ aria-label={t.present.overviewDialogAria}
111
+ className={cn('absolute inset-0 z-50 flex flex-col backdrop-blur-sm', styles.surface)}
112
+ >
113
+ <div className="flex shrink-0 items-center justify-between px-8 pt-6 pb-3">
114
+ <span className={cn('eyebrow', styles.eyebrow)}>{t.present.overviewEyebrow}</span>
115
+ <div className="flex items-center gap-3">
116
+ <span className={cn('font-mono text-[11px] tabular-nums', styles.eyebrow)}>
117
+ {(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
118
+ </span>
119
+ <button
120
+ type="button"
121
+ onClick={onClose}
122
+ aria-label={t.common.close}
123
+ className={cn(
124
+ 'flex size-6 items-center justify-center rounded-[4px] outline-none transition-colors',
125
+ styles.closeButton,
126
+ )}
127
+ >
128
+ <X className="size-3.5" />
129
+ </button>
130
+ </div>
131
+ </div>
132
+ <div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
133
+ <TooltipProvider delayDuration={200}>
134
+ <div
135
+ className="grid justify-center gap-5"
136
+ style={{
137
+ gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
138
+ }}
139
+ >
140
+ {pages.map((PageComp, i) => {
141
+ const isFocused = i === focused;
142
+ const isCurrent = i === current;
143
+ return (
144
+ <OverviewThumb
145
+ // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
146
+ key={i}
147
+ buttonRef={isFocused ? focusedRef : undefined}
148
+ page={PageComp}
149
+ index={i}
150
+ total={pages.length}
151
+ design={design}
152
+ isFocused={isFocused}
153
+ isCurrent={isCurrent}
154
+ styles={styles}
155
+ moduleTransition={moduleTransition}
156
+ tooltipContainer={tooltipContainer}
157
+ onFocus={() => setFocused(i)}
158
+ onSelect={() => {
159
+ onSelect(i);
160
+ onClose();
161
+ }}
162
+ />
163
+ );
164
+ })}
165
+ </div>
166
+ </TooltipProvider>
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function OverviewThumb({
173
+ page: PageComp,
174
+ index,
175
+ total,
176
+ design,
177
+ isFocused,
178
+ isCurrent,
179
+ styles,
180
+ moduleTransition,
181
+ tooltipContainer,
182
+ onFocus,
183
+ onSelect,
184
+ buttonRef,
185
+ }: {
186
+ page: Page;
187
+ index: number;
188
+ total: number;
189
+ design?: DesignSystem;
190
+ isFocused: boolean;
191
+ isCurrent: boolean;
192
+ styles: OverviewStyles;
193
+ moduleTransition?: SlideTransition;
194
+ tooltipContainer?: HTMLElement | null;
195
+ onFocus: () => void;
196
+ onSelect: () => void;
197
+ buttonRef?: Ref<HTMLButtonElement>;
198
+ }) {
199
+ const t = useLocale();
200
+ const boxRef = useRef<HTMLDivElement | null>(null);
201
+ const [hasSteps, setHasSteps] = useState(false);
202
+ const hasTransition = Boolean(PageComp.transition ?? moduleTransition);
203
+
204
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-detect when the page at this slot changes
205
+ useEffect(() => {
206
+ setHasSteps(boxRef.current?.querySelector('[data-osd-step]') != null);
207
+ }, [PageComp]);
208
+
209
+ return (
210
+ <button
211
+ ref={buttonRef}
212
+ type="button"
213
+ onClick={onSelect}
214
+ onMouseEnter={onFocus}
215
+ onFocus={onFocus}
216
+ aria-label={format(t.present.overviewGoToAria, { n: index + 1 })}
217
+ aria-current={isCurrent ? 'true' : undefined}
218
+ className={cn(
219
+ 'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
220
+ isFocused ? styles.thumbFocused : styles.thumbHover,
221
+ )}
222
+ >
223
+ <div
224
+ ref={boxRef}
225
+ className={cn(
226
+ 'relative w-full overflow-hidden rounded-[4px] ring-1 transition-shadow',
227
+ styles.thumbSurface,
228
+ isFocused ? 'ring-2 ring-[var(--brand,#ef4444)]' : styles.thumbRing,
229
+ )}
230
+ style={{ height: THUMB_H }}
231
+ >
232
+ <SlideCanvas
233
+ scale={THUMB_W / CANVAS_WIDTH}
234
+ center={false}
235
+ flat
236
+ freezeMotion
237
+ design={design}
238
+ >
239
+ <SlidePageProvider index={index} total={total}>
240
+ <PageComp />
241
+ </SlidePageProvider>
242
+ </SlideCanvas>
243
+ {isCurrent && (
244
+ <span
245
+ aria-hidden
246
+ className="pointer-events-none absolute top-1.5 right-1.5 rounded-[3px] bg-[var(--brand,#ef4444)] px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.06em] uppercase text-white"
247
+ >
248
+ {t.present.nowBadge}
249
+ </span>
250
+ )}
251
+ </div>
252
+ <div className="flex h-4 w-full items-center justify-between gap-2">
253
+ <span
254
+ className={cn(
255
+ 'font-mono text-[10.5px] tracking-[0.08em] tabular-nums uppercase',
256
+ isFocused || isCurrent ? styles.labelActive : styles.labelMuted,
257
+ )}
258
+ >
259
+ {(index + 1).toString().padStart(2, '0')}
260
+ </span>
261
+ {(hasTransition || hasSteps) && (
262
+ <span className="flex items-center gap-1">
263
+ {hasTransition && (
264
+ <OverviewIndicator
265
+ icon={Sparkles}
266
+ label={t.thumbnailRail.transitionIndicator}
267
+ className={styles.indicator}
268
+ tooltipContainer={tooltipContainer}
269
+ />
270
+ )}
271
+ {hasSteps && (
272
+ <OverviewIndicator
273
+ icon={ListOrdered}
274
+ label={t.thumbnailRail.stepsIndicator}
275
+ className={styles.indicator}
276
+ tooltipContainer={tooltipContainer}
277
+ />
278
+ )}
279
+ </span>
280
+ )}
281
+ </div>
282
+ </button>
283
+ );
284
+ }
285
+
286
+ function OverviewIndicator({
287
+ icon: Icon,
288
+ label,
289
+ className,
290
+ tooltipContainer,
291
+ }: {
292
+ icon: LucideIcon;
293
+ label: string;
294
+ className: string;
295
+ tooltipContainer?: HTMLElement | null;
296
+ }) {
297
+ return (
298
+ <Tooltip>
299
+ <TooltipTrigger asChild>
300
+ <span
301
+ role="img"
302
+ aria-label={label}
303
+ className={cn('flex size-4 items-center justify-center', className)}
304
+ >
305
+ <Icon className="size-3.5" strokeWidth={1.9} />
306
+ </span>
307
+ </TooltipTrigger>
308
+ <TooltipContent side="top" sideOffset={6} container={tooltipContainer ?? undefined}>
309
+ {label}
310
+ </TooltipContent>
311
+ </Tooltip>
312
+ );
313
+ }
314
+
315
+ type OverviewStyles = {
316
+ surface: string;
317
+ eyebrow: string;
318
+ thumbFocused: string;
319
+ thumbHover: string;
320
+ thumbSurface: string;
321
+ thumbRing: string;
322
+ labelActive: string;
323
+ labelMuted: string;
324
+ indicator: string;
325
+ closeButton: string;
326
+ };
327
+
328
+ const presentStyles = {
329
+ surface: 'bg-black/95',
330
+ eyebrow: 'text-white/55',
331
+ thumbFocused: 'bg-white/10',
332
+ thumbHover: 'hover:bg-white/5',
333
+ thumbSurface: 'bg-black',
334
+ thumbRing: 'ring-white/10',
335
+ labelActive: 'text-white/85',
336
+ labelMuted: 'text-white/45',
337
+ indicator: 'text-white/45 transition-colors group-hover/thumb:text-white/75',
338
+ closeButton:
339
+ 'text-white/55 hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/40',
340
+ } as const;
341
+
342
+ const editorStyles = {
343
+ surface: 'bg-background/95',
344
+ eyebrow: 'text-muted-foreground',
345
+ thumbFocused: 'bg-muted',
346
+ thumbHover: 'hover:bg-muted/60',
347
+ thumbSurface: 'bg-card',
348
+ thumbRing: 'ring-hairline',
349
+ labelActive: 'text-foreground',
350
+ labelMuted: 'text-muted-foreground/60',
351
+ indicator: 'text-muted-foreground/60 transition-colors group-hover/thumb:text-muted-foreground',
352
+ closeButton:
353
+ 'text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
354
+ } as const;
355
+
356
+ function computeCols(grid: HTMLDivElement | null) {
357
+ if (!grid) return 4;
358
+ const inner = grid.firstElementChild as HTMLElement | null;
359
+ if (!inner) return 4;
360
+ const cs = getComputedStyle(inner);
361
+ const cols = cs.gridTemplateColumns.split(' ').filter(Boolean).length;
362
+ return Math.max(1, cols);
363
+ }
@@ -0,0 +1,60 @@
1
+ import { Label } from '@/components/ui/label';
2
+
3
+ export function Section({ title, children }: { title: string; children: React.ReactNode }) {
4
+ return (
5
+ <section className="px-3.5 py-3.5">
6
+ <div className="mb-2.5 flex items-center gap-2">
7
+ <span className="eyebrow">{title}</span>
8
+ <span aria-hidden className="h-px flex-1 bg-hairline" />
9
+ </div>
10
+ <div className="flex flex-col gap-2.5">{children}</div>
11
+ </section>
12
+ );
13
+ }
14
+
15
+ export function Field({ label, children }: { label: string; children: React.ReactNode }) {
16
+ return (
17
+ <div className="grid grid-cols-[68px_1fr] items-center gap-3">
18
+ <Label className="text-[11px] font-normal text-muted-foreground">{label}</Label>
19
+ <div className="flex min-w-0 items-center gap-1.5">{children}</div>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ export function NumberField({
25
+ value,
26
+ onChange,
27
+ min,
28
+ max,
29
+ step = 1,
30
+ suffix,
31
+ }: {
32
+ value: number;
33
+ onChange: (n: number) => void;
34
+ min?: number;
35
+ max?: number;
36
+ step?: number;
37
+ suffix?: string;
38
+ }) {
39
+ return (
40
+ <div className="flex h-7 shrink-0 items-center rounded-[5px] border border-border bg-background pr-1.5 transition-colors focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/30">
41
+ <input
42
+ type="number"
43
+ value={value}
44
+ onChange={(e) => {
45
+ const n = Number(e.target.value);
46
+ if (Number.isFinite(n)) onChange(n);
47
+ }}
48
+ min={min}
49
+ max={max}
50
+ step={step}
51
+ className="nums h-full w-12 bg-transparent px-2 text-right font-mono text-[11px] outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
52
+ />
53
+ {suffix && (
54
+ <span className="font-mono text-[9.5px] uppercase tracking-[0.06em] text-muted-foreground/80">
55
+ {suffix}
56
+ </span>
57
+ )}
58
+ </div>
59
+ );
60
+ }