@open-slide/core 1.0.4 → 1.0.5

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 (46) hide show
  1. package/dist/{build-DqfKmw9h.js → build-CoON6kTb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-CN7J0RDO.js → config-Bxtztw-H.js} +373 -221
  4. package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
  5. package/dist/{dev-jWxtWHAG.js → dev-IezNC17X.js} +1 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/locale/index.d.ts +24 -0
  8. package/dist/locale/index.js +1189 -0
  9. package/dist/{preview-CSA05Gfm.js → preview-BwYjtENY.js} +1 -1
  10. package/dist/types-BVvl_xup.d.ts +314 -0
  11. package/dist/vite/index.d.ts +2 -1
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +7 -1
  14. package/src/app/app.tsx +6 -2
  15. package/src/app/components/asset-view.tsx +87 -64
  16. package/src/app/components/click-nav-zones.tsx +4 -2
  17. package/src/app/components/inspector/comment-widget.tsx +9 -7
  18. package/src/app/components/inspector/inspector-panel.tsx +68 -39
  19. package/src/app/components/inspector/inspector-provider.tsx +185 -58
  20. package/src/app/components/inspector/save-bar.tsx +6 -2
  21. package/src/app/components/panel/save-card.tsx +12 -9
  22. package/src/app/components/pdf-progress-toast.tsx +11 -4
  23. package/src/app/components/present/control-bar.tsx +17 -10
  24. package/src/app/components/present/help-overlay.tsx +18 -17
  25. package/src/app/components/present/overview-grid.tsx +6 -4
  26. package/src/app/components/sidebar/folder-item.tsx +16 -7
  27. package/src/app/components/sidebar/icon-picker.tsx +4 -2
  28. package/src/app/components/sidebar/sidebar.tsx +87 -25
  29. package/src/app/components/style-panel/style-panel.tsx +26 -18
  30. package/src/app/components/theme-toggle.tsx +7 -5
  31. package/src/app/components/thumbnail-rail.tsx +4 -2
  32. package/src/app/favicon.ico +0 -0
  33. package/src/app/lib/inspector/use-editor.ts +9 -7
  34. package/src/app/lib/use-locale.ts +20 -0
  35. package/src/app/routes/home.tsx +90 -45
  36. package/src/app/routes/presenter.tsx +45 -25
  37. package/src/app/routes/slide.tsx +37 -24
  38. package/src/app/styles.css +28 -0
  39. package/src/app/virtual.d.ts +4 -0
  40. package/src/locale/en.ts +303 -0
  41. package/src/locale/format.ts +12 -0
  42. package/src/locale/index.ts +6 -0
  43. package/src/locale/ja.ts +307 -0
  44. package/src/locale/types.ts +323 -0
  45. package/src/locale/zh-cn.ts +303 -0
  46. package/src/locale/zh-tw.ts +303 -0
@@ -1,18 +1,5 @@
1
1
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
2
-
3
- const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
4
- { keys: ['→', '↓', 'Space', 'PgDn'], label: 'Next slide' },
5
- { keys: ['←', '↑', 'PgUp'], label: 'Previous slide' },
6
- { keys: ['Home', 'End'], label: 'First / last slide' },
7
- { keys: ['1–9', 'Enter'], label: 'Jump to slide' },
8
- { keys: ['O'], label: 'Slide overview' },
9
- { keys: ['B'], label: 'Black screen' },
10
- { keys: ['W'], label: 'White screen' },
11
- { keys: ['L'], label: 'Laser pointer' },
12
- { keys: ['P'], label: 'Open Presenter View' },
13
- { keys: ['?', 'H'], label: 'Toggle this help' },
14
- { keys: ['Esc'], label: 'Close overlay / exit' },
15
- ];
2
+ import { useLocale } from '@/lib/use-locale';
16
3
 
17
4
  type Props = {
18
5
  open: boolean;
@@ -23,15 +10,29 @@ type Props = {
23
10
  };
24
11
 
25
12
  export function PresentHelpOverlay({ open, onOpenChange, container }: Props) {
13
+ const t = useLocale();
14
+ const shortcuts: Array<{ keys: string[]; label: string }> = [
15
+ { keys: ['→', '↓', 'Space', 'PgDn'], label: t.present.shortcutNext },
16
+ { keys: ['←', '↑', 'PgUp'], label: t.present.shortcutPrev },
17
+ { keys: ['Home', 'End'], label: t.present.shortcutFirstLast },
18
+ { keys: ['1–9', 'Enter'], label: t.present.shortcutJump },
19
+ { keys: ['O'], label: t.present.shortcutOverview },
20
+ { keys: ['B'], label: t.present.shortcutBlack },
21
+ { keys: ['W'], label: t.present.shortcutWhite },
22
+ { keys: ['L'], label: t.present.shortcutLaser },
23
+ { keys: ['P'], label: t.present.shortcutPresenter },
24
+ { keys: ['?', 'H'], label: t.present.shortcutToggleHelp },
25
+ { keys: ['Esc'], label: t.present.shortcutCloseExit },
26
+ ];
26
27
  return (
27
28
  <Dialog open={open} onOpenChange={onOpenChange}>
28
29
  <DialogContent container={container ?? undefined} className="max-w-lg sm:max-w-lg">
29
30
  <DialogHeader>
30
- <span className="eyebrow">Present mode</span>
31
- <DialogTitle>Keyboard shortcuts</DialogTitle>
31
+ <span className="eyebrow">{t.present.helpEyebrow}</span>
32
+ <DialogTitle>{t.present.helpTitle}</DialogTitle>
32
33
  </DialogHeader>
33
34
  <div className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
34
- {SHORTCUTS.map((row) => (
35
+ {shortcuts.map((row) => (
35
36
  <div
36
37
  key={row.label}
37
38
  className="flex items-center justify-between gap-3 border-b border-hairline py-1.5 last:border-0"
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
+ import { format, useLocale } from '@/lib/use-locale';
2
3
  import { cn } from '@/lib/utils';
3
4
  import type { DesignSystem } from '../../lib/design';
4
5
  import type { Page } from '../../lib/sdk';
@@ -26,6 +27,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
26
27
  const [focused, setFocused] = useState(current);
27
28
  const gridRef = useRef<HTMLDivElement>(null);
28
29
  const focusedRef = useRef<HTMLButtonElement | null>(null);
30
+ const t = useLocale();
29
31
 
30
32
  // biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
31
33
  useEffect(() => {
@@ -88,11 +90,11 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
88
90
  <div
89
91
  role="dialog"
90
92
  aria-modal="true"
91
- aria-label="Slide overview"
93
+ aria-label={t.present.overviewDialogAria}
92
94
  className="absolute inset-0 z-50 flex flex-col bg-black/95 backdrop-blur-sm"
93
95
  >
94
96
  <div className="flex shrink-0 items-baseline justify-between px-8 pt-6 pb-3">
95
- <span className="eyebrow text-white/55">Overview</span>
97
+ <span className="eyebrow text-white/55">{t.present.overviewEyebrow}</span>
96
98
  <span className="font-mono text-[11px] text-white/55 tabular-nums">
97
99
  {(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
98
100
  </span>
@@ -118,7 +120,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
118
120
  onClose();
119
121
  }}
120
122
  onMouseEnter={() => setFocused(i)}
121
- aria-label={`Go to slide ${i + 1}`}
123
+ aria-label={format(t.present.overviewGoToAria, { n: i + 1 })}
122
124
  aria-current={isCurrent ? 'true' : undefined}
123
125
  className={cn(
124
126
  'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
@@ -146,7 +148,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
146
148
  aria-hidden
147
149
  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"
148
150
  >
149
- Now
151
+ {t.present.nowBadge}
150
152
  </span>
151
153
  )}
152
154
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  } from '@/components/ui/dropdown-menu';
9
9
  import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
10
10
  import type { Folder, FolderIcon } from '@/lib/sdk';
11
+ import { useLocale } from '@/lib/use-locale';
11
12
  import { cn } from '@/lib/utils';
12
13
  import { IconPicker } from './icon-picker';
13
14
 
@@ -86,6 +87,7 @@ export function FolderItem({
86
87
  const dragDepth = useRef(0);
87
88
  const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
88
89
  const slideDragActive = useSlideDragActive();
90
+ const t = useLocale();
89
91
 
90
92
  const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
91
93
  const handleDragEnter = (e: React.DragEvent) => {
@@ -114,7 +116,7 @@ export function FolderItem({
114
116
 
115
117
  const icon =
116
118
  row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
117
- const label = row.kind === 'draft' ? 'Draft' : row.folder.name;
119
+ const label = row.kind === 'draft' ? t.home.draft : row.folder.name;
118
120
 
119
121
  const commitRename = () => {
120
122
  if (row.kind !== 'folder') return;
@@ -148,7 +150,7 @@ export function FolderItem({
148
150
  <button
149
151
  type="button"
150
152
  className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
151
- aria-label="Change icon"
153
+ aria-label={t.home.changeIcon}
152
154
  onClick={(e) => e.stopPropagation()}
153
155
  >
154
156
  <FolderIconChip icon={icon} />
@@ -185,7 +187,14 @@ export function FolderItem({
185
187
  </button>
186
188
  )}
187
189
 
188
- <span className={cn('folio shrink-0', count === 0 && 'opacity-0 group-hover:opacity-100')}>
190
+ <span
191
+ className={cn(
192
+ 'folio ml-auto shrink-0 transition-opacity',
193
+ row.kind === 'folder' &&
194
+ import.meta.env.DEV &&
195
+ 'group-hover:opacity-0 group-has-[[aria-expanded=true]]:opacity-0',
196
+ )}
197
+ >
189
198
  {count.toString().padStart(2, '0')}
190
199
  </span>
191
200
 
@@ -195,8 +204,8 @@ export function FolderItem({
195
204
  <button
196
205
  type="button"
197
206
  onClick={(e) => e.stopPropagation()}
198
- className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
199
- aria-label="Folder actions"
207
+ className="absolute right-2 top-1/2 size-5 -translate-y-1/2 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
208
+ aria-label={t.home.folderActions}
200
209
  >
201
210
  <MoreHorizontal className="mx-auto size-3.5" />
202
211
  </button>
@@ -209,11 +218,11 @@ export function FolderItem({
209
218
  }}
210
219
  >
211
220
  <Pencil />
212
- Rename
221
+ {t.common.rename}
213
222
  </DropdownMenuItem>
214
223
  <DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
215
224
  <Trash2 />
216
- Delete
225
+ {t.common.delete}
217
226
  </DropdownMenuItem>
218
227
  </DropdownMenuContent>
219
228
  </DropdownMenu>
@@ -1,6 +1,7 @@
1
1
  import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
2
2
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3
3
  import type { FolderIcon } from '@/lib/sdk';
4
+ import { useLocale } from '@/lib/use-locale';
4
5
 
5
6
  // Editorial palette — restrained warm/earth tones, no shadcn defaults
6
7
  // (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
@@ -23,11 +24,12 @@ export function IconPicker({
23
24
  value: FolderIcon;
24
25
  onChange: (icon: FolderIcon) => void;
25
26
  }) {
27
+ const t = useLocale();
26
28
  return (
27
29
  <Tabs defaultValue={value.type} className="w-[320px]">
28
30
  <TabsList className="w-full">
29
- <TabsTrigger value="emoji">Emoji</TabsTrigger>
30
- <TabsTrigger value="color">Color</TabsTrigger>
31
+ <TabsTrigger value="emoji">{t.home.iconEmojiTab}</TabsTrigger>
32
+ <TabsTrigger value="color">{t.home.iconColorTab}</TabsTrigger>
31
33
  </TabsList>
32
34
 
33
35
  <TabsContent value="emoji">
@@ -1,9 +1,12 @@
1
1
  import { Plus } from 'lucide-react';
2
- import { useState } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { toast } from 'sonner';
3
4
  import { ThemeToggle } from '@/components/theme-toggle';
5
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
4
6
  import type { Folder, FolderIcon } from '@/lib/sdk';
5
- import { FolderItem } from './folder-item';
6
- import { PRESET_COLORS } from './icon-picker';
7
+ import { format, useLocale } from '@/lib/use-locale';
8
+ import { FolderIconChip, FolderItem } from './folder-item';
9
+ import { IconPicker, PRESET_COLORS } from './icon-picker';
7
10
 
8
11
  export const DRAFT_ID = 'draft';
9
12
 
@@ -32,25 +35,72 @@ export function Sidebar({
32
35
  }) {
33
36
  const [creating, setCreating] = useState(false);
34
37
  const [newName, setNewName] = useState('');
38
+ const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
39
+ type: 'color',
40
+ value: PRESET_COLORS[0],
41
+ }));
42
+ const [iconOpen, setIconOpen] = useState(false);
43
+ const inputRef = useRef<HTMLInputElement>(null);
44
+ const t = useLocale();
35
45
 
36
- const commitCreate = () => {
37
- const trimmed = newName.trim();
38
- if (!trimmed) {
39
- setCreating(false);
40
- setNewName('');
41
- return;
42
- }
46
+ const startCreating = () => {
43
47
  const color = PRESET_COLORS[folders.length % PRESET_COLORS.length];
44
- onCreate(trimmed, { type: 'color', value: color });
48
+ setNewIcon({ type: 'color', value: color });
45
49
  setNewName('');
50
+ setCreating(true);
51
+ };
52
+
53
+ useEffect(() => {
54
+ if (creating) inputRef.current?.focus();
55
+ }, [creating]);
56
+
57
+ const stateRef = useRef({ name: newName, icon: newIcon, iconOpen });
58
+ stateRef.current = { name: newName, icon: newIcon, iconOpen };
59
+
60
+ const exitCreate = () => {
46
61
  setCreating(false);
62
+ setNewName('');
63
+ setIconOpen(false);
47
64
  };
48
65
 
66
+ const commitCreate = async () => {
67
+ const trimmed = stateRef.current.name.trim();
68
+ const icon = stateRef.current.icon;
69
+ if (!trimmed) {
70
+ exitCreate();
71
+ return;
72
+ }
73
+ exitCreate();
74
+ try {
75
+ const folder = await onCreate(trimmed, icon);
76
+ toast.success(format(t.home.toastFolderCreated, { name: folder?.name ?? trimmed }));
77
+ } catch {
78
+ toast.error(t.home.toastFolderCreateFailed);
79
+ }
80
+ };
81
+
82
+ // biome-ignore lint/correctness/useExhaustiveDependencies: commitCreate reads latest state via stateRef
83
+ useEffect(() => {
84
+ if (!creating) return;
85
+ const onDown = (e: MouseEvent) => {
86
+ if (stateRef.current.iconOpen) return;
87
+ const target = e.target as HTMLElement | null;
88
+ if (!target) return;
89
+ if (target.closest('[data-folder-create]')) return;
90
+ if (target.closest('[data-radix-popper-content-wrapper]')) return;
91
+ commitCreate();
92
+ };
93
+ document.addEventListener('mousedown', onDown);
94
+ return () => document.removeEventListener('mousedown', onDown);
95
+ }, [creating]);
96
+
49
97
  return (
50
98
  <aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
51
99
  <div className="flex items-center justify-between px-4 pt-5 pb-4">
52
- <h1 className="font-heading text-lg font-bold tracking-tight">open-slide</h1>
53
- <ThemeToggle />
100
+ <h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
101
+ <div className="-mr-1.5">
102
+ <ThemeToggle />
103
+ </div>
54
104
  </div>
55
105
 
56
106
  <div className="px-2">
@@ -64,9 +114,8 @@ export function Sidebar({
64
114
  </div>
65
115
 
66
116
  <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
67
- <span className="eyebrow">Folders</span>
117
+ <span className="eyebrow">{t.home.folders}</span>
68
118
  <span className="h-px flex-1 bg-hairline" aria-hidden />
69
- <span className="folio">{folders.length.toString().padStart(2, '0')}</span>
70
119
  </div>
71
120
 
72
121
  <div className="flex-1 overflow-y-auto px-2 pb-2">
@@ -89,20 +138,33 @@ export function Sidebar({
89
138
 
90
139
  {import.meta.env.DEV &&
91
140
  (creating ? (
92
- <div className="mt-1 flex items-center gap-2 rounded-[5px] border border-dashed border-foreground/30 bg-card px-2 py-1.5">
93
- <span className="size-2 shrink-0 rounded-[2px] bg-brand" aria-hidden />
141
+ <div
142
+ data-folder-create
143
+ className="mt-1 flex items-center gap-2.5 rounded-[5px] border border-dashed border-foreground/30 bg-card px-2 py-[5px]"
144
+ >
145
+ <Popover open={iconOpen} onOpenChange={setIconOpen}>
146
+ <PopoverTrigger asChild>
147
+ <button
148
+ type="button"
149
+ className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
150
+ aria-label={t.home.pickIcon}
151
+ >
152
+ <FolderIconChip icon={newIcon} />
153
+ </button>
154
+ </PopoverTrigger>
155
+ <PopoverContent side="right" align="start" className="w-auto p-2">
156
+ <IconPicker value={newIcon} onChange={setNewIcon} />
157
+ </PopoverContent>
158
+ </Popover>
94
159
  <input
160
+ ref={inputRef}
95
161
  value={newName}
96
162
  onChange={(e) => setNewName(e.target.value)}
97
- onBlur={commitCreate}
98
163
  onKeyDown={(e) => {
99
164
  if (e.key === 'Enter') commitCreate();
100
- if (e.key === 'Escape') {
101
- setCreating(false);
102
- setNewName('');
103
- }
165
+ if (e.key === 'Escape') exitCreate();
104
166
  }}
105
- placeholder="Folder name"
167
+ placeholder={t.home.folderName}
106
168
  maxLength={40}
107
169
  className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground/60"
108
170
  />
@@ -110,11 +172,11 @@ export function Sidebar({
110
172
  ) : (
111
173
  <button
112
174
  type="button"
113
- onClick={() => setCreating(true)}
175
+ onClick={startCreating}
114
176
  className="mt-1 flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
115
177
  >
116
178
  <Plus className="size-3.5" />
117
- <span>New folder</span>
179
+ <span>{t.home.newFolder}</span>
118
180
  </button>
119
181
  ))}
120
182
  </div>
@@ -2,6 +2,7 @@ import { Palette, X } from 'lucide-react';
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Field, NumberField, Section } from '@/components/panel/panel-fields';
4
4
  import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
5
+ import { useLocale } from '@/lib/use-locale';
5
6
  import { Button } from '../ui/button';
6
7
  import { Input } from '../ui/input';
7
8
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
@@ -29,6 +30,7 @@ type DesignPanelProps = {
29
30
  export function DesignPanel({ open, onClose }: DesignPanelProps) {
30
31
  const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
31
32
  const { mounted, animVisible } = usePanelMount(open);
33
+ const t = useLocale();
32
34
 
33
35
  if (!loaded) return null;
34
36
  if (!mounted) return null;
@@ -43,15 +45,19 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
43
45
  <div className="flex min-w-0 items-center gap-2">
44
46
  <Palette className="size-3.5 text-muted-foreground" />
45
47
  <span className="font-heading text-[12px] font-semibold tracking-tight">
46
- Design tokens
48
+ {t.stylePanel.designTokens}
47
49
  </span>
48
50
  {!exists && (
49
51
  <span className="rounded-[3px] border border-hairline bg-muted/60 px-1.5 py-px font-mono text-[9.5px] uppercase tracking-[0.08em] text-muted-foreground">
50
- draft
52
+ {t.stylePanel.draftBadge}
51
53
  </span>
52
54
  )}
53
55
  {dirty && (
54
- <span className="size-1.5 rounded-full bg-brand" title="Unsaved" aria-hidden />
56
+ <span
57
+ className="size-1.5 rounded-full bg-brand"
58
+ title={t.stylePanel.unsavedTitle}
59
+ aria-hidden
60
+ />
55
61
  )}
56
62
  </div>
57
63
  <Button
@@ -59,7 +65,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
59
65
  size="icon-sm"
60
66
  className="text-muted-foreground hover:text-foreground"
61
67
  onClick={onClose}
62
- aria-label="Close design panel"
68
+ aria-label={t.stylePanel.closePanelAria}
63
69
  >
64
70
  <X className="size-3.5" />
65
71
  </Button>
@@ -74,9 +80,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
74
80
  )
75
81
  }
76
82
  >
77
- <Section title="Colors">
83
+ <Section title={t.stylePanel.colorsSection}>
78
84
  <ColorField
79
- label="Background"
85
+ label={t.stylePanel.backgroundLabel}
80
86
  value={draft.palette.bg}
81
87
  onChange={(v) =>
82
88
  update((d) => {
@@ -85,7 +91,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
85
91
  }
86
92
  />
87
93
  <ColorField
88
- label="Text"
94
+ label={t.stylePanel.textLabel}
89
95
  value={draft.palette.text}
90
96
  onChange={(v) =>
91
97
  update((d) => {
@@ -94,7 +100,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
94
100
  }
95
101
  />
96
102
  <ColorField
97
- label="Accent"
103
+ label={t.stylePanel.accentLabel}
98
104
  value={draft.palette.accent}
99
105
  onChange={(v) =>
100
106
  update((d) => {
@@ -106,9 +112,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
106
112
 
107
113
  <Separator />
108
114
 
109
- <Section title="Typography">
115
+ <Section title={t.stylePanel.typographySection}>
110
116
  <FontField
111
- label="Display"
117
+ label={t.stylePanel.displayFontLabel}
112
118
  value={draft.fonts.display}
113
119
  onChange={(v) =>
114
120
  update((d) => {
@@ -117,7 +123,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
117
123
  }
118
124
  />
119
125
  <FontField
120
- label="Body"
126
+ label={t.stylePanel.bodyFontLabel}
121
127
  value={draft.fonts.body}
122
128
  onChange={(v) =>
123
129
  update((d) => {
@@ -126,7 +132,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
126
132
  }
127
133
  />
128
134
  <SliderField
129
- label="Hero"
135
+ label={t.stylePanel.heroLabel}
130
136
  value={draft.typeScale.hero}
131
137
  min={48}
132
138
  max={240}
@@ -139,7 +145,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
139
145
  }
140
146
  />
141
147
  <SliderField
142
- label="Body"
148
+ label={t.stylePanel.bodyLabel}
143
149
  value={draft.typeScale.body}
144
150
  min={16}
145
151
  max={72}
@@ -155,9 +161,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
155
161
 
156
162
  <Separator />
157
163
 
158
- <Section title="Shape">
164
+ <Section title={t.stylePanel.shapeSection}>
159
165
  <SliderField
160
- label="Radius"
166
+ label={t.stylePanel.radiusLabel}
161
167
  value={draft.radius}
162
168
  min={0}
163
169
  max={80}
@@ -181,6 +187,7 @@ export function DesignToggleButton({
181
187
  active: boolean;
182
188
  onToggle: () => void;
183
189
  }) {
190
+ const t = useLocale();
184
191
  if (import.meta.env.PROD) return null;
185
192
  return (
186
193
  <Button
@@ -188,10 +195,10 @@ export function DesignToggleButton({
188
195
  variant={active ? 'default' : 'ghost'}
189
196
  onClick={onToggle}
190
197
  data-design-ui
191
- title="Design tokens"
198
+ title={t.stylePanel.designToggleTitle}
192
199
  >
193
200
  <Palette className="size-3.5" />
194
- <span className="hidden md:inline">Design</span>
201
+ <span className="hidden md:inline">{t.stylePanel.designToggle}</span>
195
202
  </Button>
196
203
  );
197
204
  }
@@ -247,6 +254,7 @@ function FontField({
247
254
  onChange: (v: string) => void;
248
255
  }) {
249
256
  const matched = FONT_PRESETS.find((p) => p.value === value);
257
+ const tFont = useLocale();
250
258
  return (
251
259
  <Field label={label}>
252
260
  <Select
@@ -266,7 +274,7 @@ function FontField({
266
274
  ))}
267
275
  {!matched && (
268
276
  <SelectItem value="__custom__" className="text-xs">
269
- Custom…
277
+ {tFont.stylePanel.fontPresetCustom}
270
278
  </SelectItem>
271
279
  )}
272
280
  </SelectContent>
@@ -8,11 +8,13 @@ import {
8
8
  DropdownMenuItem,
9
9
  DropdownMenuTrigger,
10
10
  } from '@/components/ui/dropdown-menu';
11
+ import { useLocale } from '@/lib/use-locale';
11
12
  import { cn } from '@/lib/utils';
12
13
 
13
14
  export function ThemeToggle() {
14
15
  const { theme, setTheme } = useTheme();
15
16
  const [mounted, setMounted] = useState(false);
17
+ const t = useLocale();
16
18
 
17
19
  useEffect(() => {
18
20
  setMounted(true);
@@ -22,8 +24,8 @@ export function ThemeToggle() {
22
24
  <DropdownMenu>
23
25
  <DropdownMenuTrigger
24
26
  type="button"
25
- aria-label="Toggle theme"
26
- title="Theme"
27
+ aria-label={t.themeToggle.toggleAria}
28
+ title={t.themeToggle.title}
27
29
  className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative')}
28
30
  >
29
31
  <Sun className="size-3.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
@@ -35,21 +37,21 @@ export function ThemeToggle() {
35
37
  data-active={mounted && theme === 'light'}
36
38
  >
37
39
  <Sun />
38
- Light
40
+ {t.themeToggle.light}
39
41
  </DropdownMenuItem>
40
42
  <DropdownMenuItem
41
43
  onSelect={() => setTheme('dark')}
42
44
  data-active={mounted && theme === 'dark'}
43
45
  >
44
46
  <Moon />
45
- Dark
47
+ {t.themeToggle.dark}
46
48
  </DropdownMenuItem>
47
49
  <DropdownMenuItem
48
50
  onSelect={() => setTheme('system')}
49
51
  data-active={mounted && theme === 'system'}
50
52
  >
51
53
  <Monitor />
52
- System
54
+ {t.themeToggle.system}
53
55
  </DropdownMenuItem>
54
56
  </DropdownMenuContent>
55
57
  </DropdownMenu>
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { ScrollArea } from '@/components/ui/scroll-area';
3
+ import { format, useLocale } from '@/lib/use-locale';
3
4
  import { cn } from '@/lib/utils';
4
5
  import type { DesignSystem } from '../lib/design';
5
6
  import type { Page } from '../lib/sdk';
@@ -27,6 +28,7 @@ export function ThumbnailRail({
27
28
  orientation = 'vertical',
28
29
  }: Props) {
29
30
  const activeRef = useRef<HTMLButtonElement | null>(null);
31
+ const t = useLocale();
30
32
 
31
33
  // biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
32
34
  useEffect(() => {
@@ -55,7 +57,7 @@ export function ThumbnailRail({
55
57
  type="button"
56
58
  ref={active ? activeRef : undefined}
57
59
  onClick={() => onSelect(i)}
58
- aria-label={`Go to page ${i + 1}`}
60
+ aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
59
61
  aria-current={active ? 'true' : undefined}
60
62
  className={cn('group/thumb relative flex shrink-0 flex-col items-center gap-1.5')}
61
63
  >
@@ -95,7 +97,7 @@ export function ThumbnailRail({
95
97
  <ScrollArea className="h-full border-r border-hairline bg-sidebar">
96
98
  <aside className="flex flex-col gap-2 px-3 py-3">
97
99
  <div className="flex items-baseline justify-between px-1 pb-1">
98
- <span className="eyebrow">Pages</span>
100
+ <span className="eyebrow">{t.thumbnailRail.pages}</span>
99
101
  <span className="folio">{pages.length.toString().padStart(2, '0')}</span>
100
102
  </div>
101
103
  {pages.map((PageComp, i) => {
Binary file
@@ -2,12 +2,14 @@ import { useCallback } from 'react';
2
2
 
3
3
  export type EditOp =
4
4
  | { kind: 'set-style'; key: string; value: string | null }
5
- | { kind: 'set-text'; value: string }
5
+ | { kind: 'set-text'; value: string; prevText?: string }
6
6
  | { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
7
7
  | { kind: 'replace-placeholder-with-image'; assetPath: string };
8
8
 
9
9
  export type Edit = { line: number; column: number; ops: EditOp[] };
10
10
 
11
+ export type EditResult = { ok: boolean; error?: string };
12
+
11
13
  export class NoOpEditError extends Error {
12
14
  constructor() {
13
15
  super(
@@ -37,9 +39,11 @@ export function useEditor(slideId: string) {
37
39
  );
38
40
 
39
41
  // Batch many element edits into one file write and one HMR tick.
42
+ // Returns one result per input edit so callers can keep failed
43
+ // edits buffered while clearing the ones that landed.
40
44
  const applyEdits = useCallback(
41
- async (edits: Edit[]) => {
42
- if (edits.length === 0) return;
45
+ async (edits: Edit[]): Promise<EditResult[]> => {
46
+ if (edits.length === 0) return [];
43
47
  const res = await fetch('/__edit/batch', {
44
48
  method: 'POST',
45
49
  headers: { 'content-type': 'application/json' },
@@ -47,14 +51,12 @@ export function useEditor(slideId: string) {
47
51
  });
48
52
  const body = (await res.json().catch(() => ({}))) as {
49
53
  error?: string;
50
- changed?: boolean;
51
- results?: Array<{ ok: boolean; error?: string }>;
54
+ results?: EditResult[];
52
55
  };
53
56
  if (!res.ok) {
54
57
  throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
55
58
  }
56
- const failed = body.results?.find((r) => !r.ok);
57
- if (failed?.error) throw new Error(failed.error);
59
+ return body.results ?? [];
58
60
  },
59
61
  [slideId],
60
62
  );