@textcortex/slidewise 1.3.0 → 1.5.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.
@@ -22,6 +22,12 @@ import { HostProvider } from "./HostContext";
22
22
  import { IconProvider, type SlidewiseIcons } from "./IconContext";
23
23
  import { ReadOnlyProvider } from "./ReadOnlyContext";
24
24
  import { DirtyProvider } from "./DirtyContext";
25
+ import { LabelsProvider, type SlidewiseLabels } from "./LabelsContext";
26
+ import {
27
+ SurfacesProvider,
28
+ surfacesToCssVars,
29
+ type SlidewiseSurfaces,
30
+ } from "./SurfacesContext";
25
31
 
26
32
  export interface SlidewiseRootProps {
27
33
  /**
@@ -43,6 +49,18 @@ export interface SlidewiseRootProps {
43
49
  * "Undo"/"Redo" button enabled state without polling `canUndo()`/`canRedo()`.
44
50
  */
45
51
  onHistoryChange?: (state: HistoryState) => void;
52
+ /** Fires when the active slide changes (user click, programmatic goToSlide). */
53
+ onActiveSlideChange?: (slideId: string) => void;
54
+ /** Fires when the selected element ids change. */
55
+ onSelectionChange?: (selection: SelectionSnapshot) => void;
56
+ /** Fires when the canvas zoom level changes. */
57
+ onZoomChange?: (scale: number) => void;
58
+ /** Fires immediately before the host's `onSave` is invoked. */
59
+ onSaveStart?: () => void;
60
+ /** Fires after the host's `onSave` resolves successfully. */
61
+ onSaveSuccess?: () => void;
62
+ /** Fires when the host's `onSave` throws. The error still propagates. */
63
+ onSaveError?: (err: Error) => void;
46
64
  /**
47
65
  * Hide editing affordances (save / undo / redo) and disable canvas
48
66
  * mutations. Use this when the host viewer doesn't have write access.
@@ -60,6 +78,22 @@ export interface SlidewiseRootProps {
60
78
  * the bundled lucide icons.
61
79
  */
62
80
  icons?: SlidewiseIcons;
81
+ /**
82
+ * User-visible string overrides for i18n. Pass any subset; missing
83
+ * entries fall back to English defaults. Pairs with `icons` for full
84
+ * locale customization.
85
+ */
86
+ labels?: SlidewiseLabels;
87
+ /**
88
+ * Per-surface background overrides. Equivalent to setting the
89
+ * `--slidewise-bg-*` CSS variables on the root, but as a typed prop so
90
+ * hosts can drive theming from JS without writing CSS:
91
+ *
92
+ * ```tsx
93
+ * <Slidewise.Root surfaces={{ app: "#0b0d10", rail: "#1c1c22" }}>
94
+ * ```
95
+ */
96
+ surfaces?: SlidewiseSurfaces;
63
97
  /** Extra class names appended to the root. */
64
98
  className?: string;
65
99
  /** Inline style applied to the root. */
@@ -74,6 +108,12 @@ export interface HistoryState {
74
108
  redoSize: number;
75
109
  }
76
110
 
111
+ /** Snapshot of the current selection, scoped to the active slide. */
112
+ export interface SelectionSnapshot {
113
+ slideId: string;
114
+ elementIds: string[];
115
+ }
116
+
77
117
  export interface SlidewiseRootHandle {
78
118
  play(): void;
79
119
  stop(): void;
@@ -92,6 +132,44 @@ export interface SlidewiseRootHandle {
92
132
  * window handles typical typing/drag bursts.
93
133
  */
94
134
  endCoalesce(): void;
135
+
136
+ // ---- Navigation ----
137
+ /** Switch the active slide. No-op when `slideId` is not in the deck. */
138
+ goToSlide(slideId: string): void;
139
+ /** Advance to the slide after the current one. No-op past the last slide. */
140
+ nextSlide(): void;
141
+ /** Step back to the slide before the current one. No-op past the first. */
142
+ prevSlide(): void;
143
+
144
+ // ---- Zoom ----
145
+ /** Zoom out by one step (×0.8), clamped to the editor's min zoom. */
146
+ zoomOut(): void;
147
+ /** Zoom in by one step (×1.25), clamped to the editor's max zoom. */
148
+ zoomIn(): void;
149
+ /** Set the absolute zoom (1 = 100%); clamped to [0.1, 4]. */
150
+ setZoom(scale: number): void;
151
+
152
+ // ---- Slide CRUD ----
153
+ /**
154
+ * Insert a blank slide after `afterId`, or at the end if `afterId` is
155
+ * omitted. Returns the new slide's id. The new slide becomes active.
156
+ */
157
+ addSlide(afterId?: string): string;
158
+ /**
159
+ * Insert a copy of `slideId` immediately after it. Returns the new
160
+ * slide's id, or `null` if `slideId` wasn't found. The copy becomes
161
+ * active.
162
+ */
163
+ duplicateSlide(slideId: string): string | null;
164
+ /**
165
+ * Delete a slide. No-op when the deck would be left with zero slides.
166
+ */
167
+ deleteSlide(slideId: string): void;
168
+
169
+ // ---- Selection ----
170
+ /** Current selection snapshot (slide id + selected element ids). */
171
+ getSelection(): SelectionSnapshot;
172
+
95
173
  getDeck(): Deck;
96
174
  isDirty(): boolean;
97
175
  resetDirty(): void;
@@ -124,11 +202,19 @@ function RootInner({
124
202
  onExport,
125
203
  onDirtyChange,
126
204
  onHistoryChange: props_onHistoryChange,
205
+ onActiveSlideChange,
206
+ onSelectionChange,
207
+ onZoomChange,
208
+ onSaveStart,
209
+ onSaveSuccess,
210
+ onSaveError,
127
211
  readOnly = false,
128
212
  theme,
129
213
  initialSlideId,
130
214
  fontFamily,
131
215
  icons,
216
+ labels,
217
+ surfaces,
132
218
  className,
133
219
  style,
134
220
  children,
@@ -145,6 +231,12 @@ function RootInner({
145
231
  const onSaveRef = useRef(onSave);
146
232
  const onExportRef = useRef(onExport);
147
233
  const onHistoryChangeRef = useRef(props_onHistoryChange);
234
+ const onActiveSlideChangeRef = useRef(onActiveSlideChange);
235
+ const onSelectionChangeRef = useRef(onSelectionChange);
236
+ const onZoomChangeRef = useRef(onZoomChange);
237
+ const onSaveStartRef = useRef(onSaveStart);
238
+ const onSaveSuccessRef = useRef(onSaveSuccess);
239
+ const onSaveErrorRef = useRef(onSaveError);
148
240
 
149
241
  useEffect(() => {
150
242
  onChangeRef.current = onChange;
@@ -152,7 +244,25 @@ function RootInner({
152
244
  onSaveRef.current = onSave;
153
245
  onExportRef.current = onExport;
154
246
  onHistoryChangeRef.current = props_onHistoryChange;
155
- }, [onChange, onDirtyChange, onSave, onExport, props_onHistoryChange]);
247
+ onActiveSlideChangeRef.current = onActiveSlideChange;
248
+ onSelectionChangeRef.current = onSelectionChange;
249
+ onZoomChangeRef.current = onZoomChange;
250
+ onSaveStartRef.current = onSaveStart;
251
+ onSaveSuccessRef.current = onSaveSuccess;
252
+ onSaveErrorRef.current = onSaveError;
253
+ }, [
254
+ onChange,
255
+ onDirtyChange,
256
+ onSave,
257
+ onExport,
258
+ props_onHistoryChange,
259
+ onActiveSlideChange,
260
+ onSelectionChange,
261
+ onZoomChange,
262
+ onSaveStart,
263
+ onSaveSuccess,
264
+ onSaveError,
265
+ ]);
156
266
 
157
267
  useEffect(() => {
158
268
  if (theme) {
@@ -207,6 +317,29 @@ function RootInner({
207
317
  redoSize: nextFut,
208
318
  });
209
319
  }
320
+
321
+ // Active slide changes (click in rail, programmatic goToSlide).
322
+ if (state.currentSlideId !== prev.currentSlideId) {
323
+ onActiveSlideChangeRef.current?.(state.currentSlideId);
324
+ }
325
+
326
+ // Selection — shallow compare ids since the array is rebuilt on
327
+ // every selectElement call. Same slideId + same ids = no emit.
328
+ if (
329
+ state.selectedIds !== prev.selectedIds &&
330
+ !shallowEqualIds(state.selectedIds, prev.selectedIds)
331
+ ) {
332
+ onSelectionChangeRef.current?.({
333
+ slideId: state.currentSlideId,
334
+ elementIds: state.selectedIds,
335
+ });
336
+ }
337
+
338
+ // Zoom.
339
+ if (state.zoom !== prev.zoom) {
340
+ onZoomChangeRef.current?.(state.zoom);
341
+ }
342
+
210
343
  if (state.deck === prev.deck) return;
211
344
  onChangeRef.current?.(state.deck);
212
345
  const nextDirty = state.deck !== savedDeckRef.current;
@@ -239,12 +372,54 @@ function RootInner({
239
372
  return { undo: s.history.length, redo: s.future.length };
240
373
  },
241
374
  endCoalesce: () => store.getState().endCoalesce(),
375
+
376
+ goToSlide: (slideId: string) => {
377
+ const s = store.getState();
378
+ if (s.deck.slides.some((sl) => sl.id === slideId)) {
379
+ s.selectSlide(slideId);
380
+ }
381
+ },
382
+ nextSlide: () => {
383
+ const s = store.getState();
384
+ const idx = s.deck.slides.findIndex(
385
+ (sl) => sl.id === s.currentSlideId
386
+ );
387
+ const next = s.deck.slides[idx + 1];
388
+ if (next) s.selectSlide(next.id);
389
+ },
390
+ prevSlide: () => {
391
+ const s = store.getState();
392
+ const idx = s.deck.slides.findIndex(
393
+ (sl) => sl.id === s.currentSlideId
394
+ );
395
+ const prev = s.deck.slides[idx - 1];
396
+ if (prev) s.selectSlide(prev.id);
397
+ },
398
+
399
+ zoomIn: () => store.getState().zoomIn(),
400
+ zoomOut: () => store.getState().zoomOut(),
401
+ setZoom: (scale: number) => store.getState().setZoom(scale),
402
+
403
+ addSlide: (afterId?: string) => store.getState().addSlide(afterId),
404
+ duplicateSlide: (slideId: string) =>
405
+ store.getState().duplicateSlide(slideId),
406
+ deleteSlide: (slideId: string) => store.getState().deleteSlide(slideId),
407
+
408
+ getSelection: (): SelectionSnapshot => {
409
+ const s = store.getState();
410
+ return {
411
+ slideId: s.currentSlideId,
412
+ elementIds: [...s.selectedIds],
413
+ };
414
+ },
415
+
242
416
  getDeck: () => store.getState().deck,
243
417
  isDirty: () => dirtyRef.current,
244
418
  resetDirty: () => {
245
419
  savedDeckRef.current = store.getState().deck;
246
420
  if (dirtyRef.current) {
247
421
  dirtyRef.current = false;
422
+ setDirty(false);
248
423
  onDirtyChangeRef.current?.(false);
249
424
  }
250
425
  },
@@ -252,33 +427,58 @@ function RootInner({
252
427
  [store]
253
428
  );
254
429
 
255
- // Wrap host save with dirty-flag reset so any TopBar.Save / imperative save
256
- // path that funnels through here clears the dirty state on success.
430
+ // Wrap host save with:
431
+ // - onSaveStart / onSaveSuccess / onSaveError lifecycle hooks
432
+ // - dirty-flag reset on success
433
+ // The error still propagates so TopBar.Save's local "Saving…" → "idle"
434
+ // transition kicks in correctly.
257
435
  const wrappedSave = onSave
258
436
  ? async (d: Deck) => {
259
- await onSaveRef.current!(d);
260
- savedDeckRef.current = d;
261
- if (dirtyRef.current) {
262
- dirtyRef.current = false;
263
- onDirtyChangeRef.current?.(false);
437
+ onSaveStartRef.current?.();
438
+ try {
439
+ await onSaveRef.current!(d);
440
+ savedDeckRef.current = d;
441
+ if (dirtyRef.current) {
442
+ dirtyRef.current = false;
443
+ setDirty(false);
444
+ onDirtyChangeRef.current?.(false);
445
+ }
446
+ onSaveSuccessRef.current?.();
447
+ } catch (err) {
448
+ onSaveErrorRef.current?.(
449
+ err instanceof Error ? err : new Error(String(err))
450
+ );
451
+ throw err;
264
452
  }
265
453
  }
266
454
  : undefined;
267
455
 
456
+ // Merge host's `style` prop with the surface overrides so a host can
457
+ // pass both without one clobbering the other. Surfaces win on conflict
458
+ // because they're the more specific theming intent.
459
+ const surfaceVars = surfacesToCssVars(surfaces);
460
+ const mergedStyle: CSSProperties | undefined = surfaceVars
461
+ ? { ...style, ...(surfaceVars as CSSProperties) }
462
+ : style;
463
+
268
464
  return (
269
465
  <ReadOnlyProvider readOnly={readOnly}>
270
466
  <IconProvider icons={icons ?? {}}>
271
- <DirtyProvider dirty={dirty}>
272
- <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
273
- <RootShell
274
- fontFamily={fontFamily}
275
- className={className}
276
- style={style}
277
- >
278
- {children}
279
- </RootShell>
280
- </HostProvider>
281
- </DirtyProvider>
467
+ <LabelsProvider labels={labels}>
468
+ <SurfacesProvider surfaces={surfaces}>
469
+ <DirtyProvider dirty={dirty}>
470
+ <HostProvider callbacks={{ onSave: wrappedSave, onExport }}>
471
+ <RootShell
472
+ fontFamily={fontFamily}
473
+ className={className}
474
+ style={mergedStyle}
475
+ >
476
+ {children}
477
+ </RootShell>
478
+ </HostProvider>
479
+ </DirtyProvider>
480
+ </SurfacesProvider>
481
+ </LabelsProvider>
282
482
  </IconProvider>
283
483
  </ReadOnlyProvider>
284
484
  );
@@ -330,3 +530,12 @@ function RootShell({
330
530
  </div>
331
531
  );
332
532
  }
533
+
534
+ function shallowEqualIds(a: readonly string[], b: readonly string[]): boolean {
535
+ if (a === b) return true;
536
+ if (a.length !== b.length) return false;
537
+ for (let i = 0; i < a.length; i++) {
538
+ if (a[i] !== b[i]) return false;
539
+ }
540
+ return true;
541
+ }
@@ -0,0 +1,128 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+
3
+ /**
4
+ * Per-surface background overrides. Each key maps to a CSS variable that the
5
+ * library's stylesheet reads. Values can be any valid CSS background
6
+ * (color, gradient, var() reference, etc.). Equivalent to setting the
7
+ * `--slidewise-bg-*` variables manually — provided as a typed prop so
8
+ * hosts can drive theming from JS.
9
+ *
10
+ * Missing keys fall back to the library's defaults; supply only what you
11
+ * want to override.
12
+ */
13
+ export interface SlidewiseSurfaces {
14
+ /** Outermost app shell. Maps to `--slidewise-bg-app`. */
15
+ app?: string;
16
+ /** Top bar surface. `--slidewise-bg-topbar`. */
17
+ topbar?: string;
18
+ /** Slide rail container. `--slidewise-bg-rail`. */
19
+ rail?: string;
20
+ /** Inactive slide rail item. `--slidewise-bg-rail-item`. */
21
+ railItem?: string;
22
+ /** Active/selected slide rail item. `--slidewise-bg-rail-item-active`. */
23
+ railItemActive?: string;
24
+ /** Canvas frame (around the slide). `--slidewise-bg-canvas-frame`. */
25
+ canvasFrame?: string;
26
+ /** Canvas backdrop gradient start. `--slidewise-bg-canvas-from`. */
27
+ canvasFrom?: string;
28
+ /** Canvas backdrop gradient end. `--slidewise-bg-canvas-to`. */
29
+ canvasTo?: string;
30
+ /** Floating bottom toolbar. `--slidewise-bg-bottom-toolbar`. */
31
+ bottomToolbar?: string;
32
+ /** Right panel surface. `--slidewise-bg-right-panel`. */
33
+ rightPanel?: string;
34
+ /** Popover/menu surface. `--slidewise-bg-menu`. */
35
+ menu?: string;
36
+ /** Tooltip surface. `--slidewise-bg-tooltip`. */
37
+ tooltip?: string;
38
+ /** Popover surface. `--slidewise-bg-popover`. */
39
+ popover?: string;
40
+ /** Dialog surface. `--slidewise-bg-dialog`. */
41
+ dialog?: string;
42
+ /** Slide paper. `--slidewise-bg-slide`. */
43
+ slide?: string;
44
+ /** Selection overlay tint. `--slidewise-bg-selection`. */
45
+ selection?: string;
46
+ /** Hover state. `--slidewise-bg-hover`. */
47
+ hover?: string;
48
+ /** Active/pressed state. `--slidewise-bg-active`. */
49
+ active?: string;
50
+ /** Form input. `--slidewise-bg-input`. */
51
+ input?: string;
52
+ /** Chrome button. `--slidewise-bg-button`. */
53
+ button?: string;
54
+ /** Chrome button on hover. `--slidewise-bg-button-hover`. */
55
+ buttonHover?: string;
56
+ /** Smart pill. `--slidewise-bg-pill`. */
57
+ pill?: string;
58
+ /** Unsaved-changes badge. `--slidewise-bg-unsaved-badge`. */
59
+ unsavedBadge?: string;
60
+ }
61
+
62
+ const KEY_TO_VAR: Record<keyof SlidewiseSurfaces, string> = {
63
+ app: "--slidewise-bg-app",
64
+ topbar: "--slidewise-bg-topbar",
65
+ rail: "--slidewise-bg-rail",
66
+ railItem: "--slidewise-bg-rail-item",
67
+ railItemActive: "--slidewise-bg-rail-item-active",
68
+ canvasFrame: "--slidewise-bg-canvas-frame",
69
+ canvasFrom: "--slidewise-bg-canvas-from",
70
+ canvasTo: "--slidewise-bg-canvas-to",
71
+ bottomToolbar: "--slidewise-bg-bottom-toolbar",
72
+ rightPanel: "--slidewise-bg-right-panel",
73
+ menu: "--slidewise-bg-menu",
74
+ tooltip: "--slidewise-bg-tooltip",
75
+ popover: "--slidewise-bg-popover",
76
+ dialog: "--slidewise-bg-dialog",
77
+ slide: "--slidewise-bg-slide",
78
+ selection: "--slidewise-bg-selection",
79
+ hover: "--slidewise-bg-hover",
80
+ active: "--slidewise-bg-active",
81
+ input: "--slidewise-bg-input",
82
+ button: "--slidewise-bg-button",
83
+ buttonHover: "--slidewise-bg-button-hover",
84
+ pill: "--slidewise-bg-pill",
85
+ unsavedBadge: "--slidewise-bg-unsaved-badge",
86
+ };
87
+
88
+ /**
89
+ * Convert a `SlidewiseSurfaces` map into an object suitable for spreading
90
+ * into a React `style` prop. Returns `null` when no overrides are present
91
+ * so the consumer doesn't allocate a fresh style object every render.
92
+ */
93
+ export function surfacesToCssVars(
94
+ surfaces: SlidewiseSurfaces | undefined
95
+ ): Record<string, string> | null {
96
+ if (!surfaces) return null;
97
+ const entries: [string, string][] = [];
98
+ for (const key of Object.keys(KEY_TO_VAR) as (keyof SlidewiseSurfaces)[]) {
99
+ const value = surfaces[key];
100
+ if (value !== undefined) entries.push([KEY_TO_VAR[key], value]);
101
+ }
102
+ if (entries.length === 0) return null;
103
+ return Object.fromEntries(entries);
104
+ }
105
+
106
+ const SurfacesContext = createContext<SlidewiseSurfaces | null>(null);
107
+
108
+ export function SurfacesProvider({
109
+ surfaces,
110
+ children,
111
+ }: {
112
+ surfaces: SlidewiseSurfaces | undefined;
113
+ children: ReactNode;
114
+ }) {
115
+ const value = useMemo(() => surfaces ?? null, [surfaces]);
116
+ return (
117
+ <SurfacesContext.Provider value={value}>{children}</SurfacesContext.Provider>
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Read the surface override map. Mostly used by `<Slidewise.Root>` itself,
123
+ * but exposed so host-rendered surfaces (e.g. a custom panel) can apply
124
+ * the same tokens for visual consistency.
125
+ */
126
+ export function useSurfaces(): SlidewiseSurfaces | null {
127
+ return useContext(SurfacesContext);
128
+ }
@@ -37,6 +37,7 @@ export {
37
37
  type SlidewiseRootProps,
38
38
  type SlidewiseRootHandle,
39
39
  type HistoryState,
40
+ type SelectionSnapshot,
40
41
  } from "./SlidewiseRoot";
41
42
  export {
42
43
  SlideRail,
@@ -73,6 +74,19 @@ export {
73
74
  } from "./IconContext";
74
75
  export { ReadOnlyProvider, useReadOnly } from "./ReadOnlyContext";
75
76
  export { DirtyProvider, useDirty } from "./DirtyContext";
77
+ export {
78
+ LabelsProvider,
79
+ useLabels,
80
+ DEFAULT_LABELS,
81
+ type SlidewiseLabels,
82
+ type ResolvedLabels,
83
+ } from "./LabelsContext";
84
+ export {
85
+ SurfacesProvider,
86
+ useSurfaces,
87
+ surfacesToCssVars,
88
+ type SlidewiseSurfaces,
89
+ } from "./SurfacesContext";
76
90
 
77
91
  /**
78
92
  * Store hooks. Use these from host components anywhere under
@@ -68,7 +68,7 @@ export function RightPanel({
68
68
  width,
69
69
  flexShrink: 0,
70
70
  height: "100%",
71
- background: "var(--rail-bg)",
71
+ background: "var(--slidewise-bg-right-panel, var(--rail-bg))",
72
72
  borderLeft: "1px solid var(--border)",
73
73
  boxShadow: "var(--rail-shadow)",
74
74
  overflow: "auto",
@@ -3,6 +3,7 @@ import { Download } from "lucide-react";
3
3
  import { useEditorStore } from "@/lib/StoreProvider";
4
4
  import { useHostCallbacks } from "../HostContext";
5
5
  import { useIcons } from "../IconContext";
6
+ import { useLabels } from "../LabelsContext";
6
7
  import { primaryBtnStyle, primaryHoverHandlers } from "./styles";
7
8
 
8
9
  /**
@@ -24,13 +25,15 @@ export interface TopBarExportProps {
24
25
  export function Export({
25
26
  className,
26
27
  style,
27
- ariaLabel = "Export",
28
- label = "Export",
28
+ ariaLabel,
29
+ label,
29
30
  children,
30
31
  }: TopBarExportProps = {}) {
31
32
  const store = useEditorStore();
32
33
  const { onExport: onExportHost } = useHostCallbacks();
33
34
  const icons = useIcons();
35
+ const labels = useLabels();
36
+ const resolved = label ?? labels.export;
34
37
 
35
38
  const onClick = () => {
36
39
  const deck = store.getState().deck;
@@ -53,13 +56,13 @@ export function Export({
53
56
  <button
54
57
  type="button"
55
58
  className={className}
56
- aria-label={ariaLabel}
59
+ aria-label={ariaLabel ?? resolved}
57
60
  onClick={onClick}
58
61
  style={{ ...primaryBtnStyle(), ...style }}
59
62
  {...primaryHoverHandlers()}
60
63
  >
61
64
  {children ?? icons.export ?? <Download size={14} />}
62
- {label}
65
+ {resolved}
63
66
  </button>
64
67
  );
65
68
  }
@@ -2,6 +2,7 @@ import type { CSSProperties, ReactNode } from "react";
2
2
  import { Play as PlayIcon } from "lucide-react";
3
3
  import { useEditor } from "@/lib/StoreProvider";
4
4
  import { useIcons } from "../IconContext";
5
+ import { useLabels } from "../LabelsContext";
5
6
  import { chromeBtnStyle, hoverHandlers } from "./styles";
6
7
 
7
8
  /**
@@ -19,24 +20,26 @@ export interface TopBarPlayProps {
19
20
  export function Play({
20
21
  className,
21
22
  style,
22
- ariaLabel = "Play",
23
- label = "Play",
23
+ ariaLabel,
24
+ label,
24
25
  children,
25
26
  }: TopBarPlayProps = {}) {
26
27
  const play = useEditor((s) => s.play);
27
28
  const icons = useIcons();
29
+ const labels = useLabels();
30
+ const resolved = label ?? labels.play;
28
31
 
29
32
  return (
30
33
  <button
31
34
  type="button"
32
35
  className={className}
33
- aria-label={ariaLabel}
36
+ aria-label={ariaLabel ?? resolved}
34
37
  onClick={play}
35
38
  style={{ ...chromeBtnStyle(), ...style }}
36
39
  {...hoverHandlers()}
37
40
  >
38
41
  {children ?? icons.play ?? <PlayIcon size={14} />}
39
- {label}
42
+ {resolved}
40
43
  </button>
41
44
  );
42
45
  }
@@ -3,6 +3,7 @@ import { Redo2 } from "lucide-react";
3
3
  import { useEditor } from "@/lib/StoreProvider";
4
4
  import { useIcons } from "../IconContext";
5
5
  import { useReadOnly } from "../ReadOnlyContext";
6
+ import { useLabels } from "../LabelsContext";
6
7
  import { iconBtnStyle, hoverHandlers } from "./styles";
7
8
 
8
9
  /**
@@ -19,21 +20,23 @@ export interface TopBarRedoProps {
19
20
  export function Redo({
20
21
  className,
21
22
  style,
22
- ariaLabel = "Redo",
23
+ ariaLabel,
23
24
  children,
24
25
  }: TopBarRedoProps = {}) {
25
26
  const redo = useEditor((s) => s.redo);
26
27
  const canRedo = useEditor((s) => s.future.length > 0);
27
28
  const icons = useIcons();
28
29
  const readOnly = useReadOnly();
30
+ const labels = useLabels();
31
+ const resolvedAria = ariaLabel ?? labels.redo;
29
32
  if (readOnly) return null;
30
33
 
31
34
  return (
32
35
  <button
33
36
  type="button"
34
37
  className={className}
35
- title={ariaLabel}
36
- aria-label={ariaLabel}
38
+ title={resolvedAria}
39
+ aria-label={resolvedAria}
37
40
  disabled={!canRedo}
38
41
  onClick={redo}
39
42
  style={{
@@ -35,7 +35,8 @@ export function Root({
35
35
  alignItems: "center",
36
36
  padding: "0 14px",
37
37
  gap: 10,
38
- background: "var(--slidewise-bar-bg, var(--app-bg))",
38
+ background:
39
+ "var(--slidewise-bg-topbar, var(--slidewise-bar-bg, var(--app-bg)))",
39
40
  borderBottom: "1px solid var(--border)",
40
41
  boxShadow: "var(--topbar-shadow)",
41
42
  fontFamily: "Inter, system-ui, sans-serif",
@@ -4,6 +4,7 @@ import { useEditorStore } from "@/lib/StoreProvider";
4
4
  import { useHostCallbacks } from "../HostContext";
5
5
  import { useIcons } from "../IconContext";
6
6
  import { useReadOnly } from "../ReadOnlyContext";
7
+ import { useLabels } from "../LabelsContext";
7
8
  import { chromeBtnStyle, hoverHandlers } from "./styles";
8
9
 
9
10
  /**
@@ -27,7 +28,7 @@ export interface TopBarSaveProps {
27
28
  export function Save({
28
29
  className,
29
30
  style,
30
- ariaLabel = "Save",
31
+ ariaLabel,
31
32
  labels,
32
33
  children,
33
34
  }: TopBarSaveProps = {}) {
@@ -35,6 +36,7 @@ export function Save({
35
36
  const { onSave: onSaveHost } = useHostCallbacks();
36
37
  const icons = useIcons();
37
38
  const readOnly = useReadOnly();
39
+ const ctxLabels = useLabels();
38
40
  const [phase, setPhase] = useState<"idle" | "saving" | "saved">("idle");
39
41
 
40
42
  if (readOnly) return null;
@@ -59,16 +61,16 @@ export function Save({
59
61
 
60
62
  const text =
61
63
  phase === "saving"
62
- ? (labels?.saving ?? "Saving…")
64
+ ? (labels?.saving ?? ctxLabels.save.saving)
63
65
  : phase === "saved"
64
- ? (labels?.saved ?? "Saved")
65
- : (labels?.idle ?? "Save");
66
+ ? (labels?.saved ?? ctxLabels.save.saved)
67
+ : (labels?.idle ?? ctxLabels.save.idle);
66
68
 
67
69
  return (
68
70
  <button
69
71
  type="button"
70
72
  className={className}
71
- aria-label={ariaLabel}
73
+ aria-label={ariaLabel ?? ctxLabels.save.idle}
72
74
  onClick={onClick}
73
75
  style={{ ...chromeBtnStyle(), ...style }}
74
76
  {...hoverHandlers()}