@open-slide/core 1.0.6 → 1.1.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.
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
15
15
  import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
16
16
  import { type Edit, type EditOp, type EditResult, useEditor } from '@/lib/inspector/use-editor';
17
17
  import { useLocale } from '@/lib/use-locale';
18
+ import { ImageCropDialog } from './image-crop-dialog';
18
19
 
19
20
  export type SelectedTarget = {
20
21
  line: number;
@@ -69,6 +70,7 @@ type InspectorCtx = {
69
70
  commitEdits: () => Promise<void>;
70
71
  cancelEdits: () => void;
71
72
  committing: boolean;
73
+ openCrop: (anchor: HTMLImageElement) => void;
72
74
  };
73
75
 
74
76
  const Ctx = createContext<InspectorCtx | null>(null);
@@ -90,6 +92,16 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
90
92
  const instanceCounterRef = useRef(0);
91
93
  const [pendingCount, setPendingCount] = useState(0);
92
94
  const [committing, setCommitting] = useState(false);
95
+ const [cropTarget, setCropTarget] = useState<{
96
+ line: number;
97
+ column: number;
98
+ anchor: HTMLImageElement;
99
+ src: string;
100
+ targetWidth: number;
101
+ targetHeight: number;
102
+ initialFit: 'cover' | 'contain';
103
+ initialPosition: { x: number; y: number };
104
+ } | null>(null);
93
105
  const t = useLocale();
94
106
 
95
107
  const ensureInstanceId = useCallback((el: HTMLElement): string => {
@@ -529,6 +541,26 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
529
541
  setSelected(null);
530
542
  }, []);
531
543
 
544
+ const openCrop = useCallback((anchor: HTMLImageElement) => {
545
+ const loc = anchor.dataset.slideLoc;
546
+ if (!loc) return;
547
+ const [lineStr, columnStr] = loc.split(':');
548
+ const line = Number(lineStr);
549
+ const column = Number(columnStr);
550
+ if (!Number.isFinite(line) || !Number.isFinite(column)) return;
551
+ const cs = window.getComputedStyle(anchor);
552
+ setCropTarget({
553
+ line,
554
+ column,
555
+ anchor,
556
+ src: anchor.currentSrc || anchor.src,
557
+ targetWidth: anchor.offsetWidth || anchor.getBoundingClientRect().width,
558
+ targetHeight: anchor.offsetHeight || anchor.getBoundingClientRect().height,
559
+ initialFit: cs.objectFit === 'contain' ? 'contain' : 'cover',
560
+ initialPosition: parseObjectPosition(cs.objectPosition),
561
+ });
562
+ }, []);
563
+
532
564
  const value = useMemo<InspectorCtx>(
533
565
  () => ({
534
566
  slideId,
@@ -549,6 +581,7 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
549
581
  commitEdits,
550
582
  cancelEdits,
551
583
  committing,
584
+ openCrop,
552
585
  }),
553
586
  [
554
587
  slideId,
@@ -568,10 +601,59 @@ export function InspectorProvider({ slideId, children }: { slideId: string; chil
568
601
  commitEdits,
569
602
  cancelEdits,
570
603
  committing,
604
+ openCrop,
571
605
  ],
572
606
  );
573
607
 
574
- return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
608
+ return (
609
+ <Ctx.Provider value={value}>
610
+ {children}
611
+ {cropTarget && (
612
+ <ImageCropDialog
613
+ src={cropTarget.src}
614
+ targetWidth={cropTarget.targetWidth}
615
+ targetHeight={cropTarget.targetHeight}
616
+ initialFit={cropTarget.initialFit}
617
+ initialPosition={cropTarget.initialPosition}
618
+ onClose={() => setCropTarget(null)}
619
+ onApply={(result) => {
620
+ const { line, column, anchor } = cropTarget;
621
+ if (anchor.isConnected) {
622
+ bufferOps(line, column, anchor, [
623
+ { kind: 'set-style', key: 'objectFit', value: result.fit },
624
+ {
625
+ kind: 'set-style',
626
+ key: 'objectPosition',
627
+ value: `${round2(result.x)}% ${round2(result.y)}%`,
628
+ },
629
+ ]);
630
+ }
631
+ setCropTarget(null);
632
+ }}
633
+ />
634
+ )}
635
+ </Ctx.Provider>
636
+ );
637
+ }
638
+
639
+ function round2(n: number): number {
640
+ return Math.round(n * 100) / 100;
641
+ }
642
+
643
+ function parseObjectPosition(value: string): { x: number; y: number } {
644
+ const parts = value.trim().split(/\s+/);
645
+ const xRaw = parts[0] ?? '50%';
646
+ const yRaw = parts[1] ?? xRaw;
647
+ return { x: parsePercent(xRaw, 50), y: parsePercent(yRaw, 50) };
648
+ }
649
+
650
+ function parsePercent(s: string, fallback: number): number {
651
+ if (s === 'center') return 50;
652
+ if (s === 'left' || s === 'top') return 0;
653
+ if (s === 'right' || s === 'bottom') return 100;
654
+ const m = s.match(/(-?\d+(?:\.\d+)?)%/);
655
+ if (m?.[1]) return Number(m[1]);
656
+ return fallback;
575
657
  }
576
658
 
577
659
  export function InspectToggleButton() {
@@ -58,6 +58,7 @@ export function Player({
58
58
  const [helpOpen, setHelpOpen] = useState(false);
59
59
  const [blackout, setBlackout] = useState<'black' | 'white' | null>(null);
60
60
  const [laser, setLaser] = useState(false);
61
+ const [keyboardDriven, setKeyboardDriven] = useState(false);
61
62
  const [startedAt] = useState(() => Date.now());
62
63
 
63
64
  const goPrev = useCallback(() => {
@@ -181,19 +182,23 @@ export function Player({
181
182
 
182
183
  if (isNext) {
183
184
  e.preventDefault();
185
+ setKeyboardDriven(true);
184
186
  goNext();
185
187
  return;
186
188
  }
187
189
  if (isPrev) {
188
190
  e.preventDefault();
191
+ setKeyboardDriven(true);
189
192
  goPrev();
190
193
  return;
191
194
  }
192
195
  if (e.key === 'Home') {
196
+ setKeyboardDriven(true);
193
197
  onIndexChange(0);
194
198
  return;
195
199
  }
196
200
  if (e.key === 'End') {
201
+ setKeyboardDriven(true);
197
202
  onIndexChange(pages.length - 1);
198
203
  return;
199
204
  }
@@ -246,7 +251,16 @@ export function Player({
246
251
  const pointerNearBottom = usePointerNearBottom(BAR_HOTZONE_PX, controls && !overlayActive);
247
252
  const chromeVisible = pointerNearBottom || overlayActive;
248
253
  const idle = useIdle(IDLE_HIDE_MS, controls && !overlayActive);
249
- const hideCursor = controls && (laser || (idle && !overlayActive && !pointerNearBottom));
254
+
255
+ useEffect(() => {
256
+ if (!keyboardDriven) return;
257
+ const clear = () => setKeyboardDriven(false);
258
+ window.addEventListener('mousemove', clear, { passive: true });
259
+ return () => window.removeEventListener('mousemove', clear);
260
+ }, [keyboardDriven]);
261
+
262
+ const hideCursor =
263
+ controls && (laser || keyboardDriven || (idle && !overlayActive && !pointerNearBottom));
250
264
 
251
265
  const PageComp = pages[index];
252
266
 
@@ -1,11 +1,15 @@
1
1
  import { useEffect, useState } from 'react';
2
2
 
3
3
  /**
4
- * Reports whether the user has been idle (no pointer / key / touch input)
5
- * for at least `delayMs`. Resets on any input event. The hook starts in
4
+ * Reports whether the user has been idle (no pointer / touch input) for at
5
+ * least `delayMs`. Resets on any pointer-related event. The hook starts in
6
6
  * the non-idle state so freshly-mounted UI is visible while the user
7
7
  * orients themselves.
8
8
  *
9
+ * Keyboard input is intentionally excluded — during a talk the presenter
10
+ * drives slides with arrow keys, and we want the cursor to stay hidden
11
+ * while they do.
12
+ *
9
13
  * Pass `enabled = false` to short-circuit (useful when the player is
10
14
  * paused on an overlay and we don't want to hide chrome behind it).
11
15
  */
@@ -27,14 +31,12 @@ export function useIdle(delayMs: number, enabled = true) {
27
31
  const opts = { passive: true } as const;
28
32
  window.addEventListener('mousemove', reset, opts);
29
33
  window.addEventListener('mousedown', reset, opts);
30
- window.addEventListener('keydown', reset);
31
34
  window.addEventListener('touchstart', reset, opts);
32
35
  window.addEventListener('wheel', reset, opts);
33
36
  return () => {
34
37
  if (timer) clearTimeout(timer);
35
38
  window.removeEventListener('mousemove', reset);
36
39
  window.removeEventListener('mousedown', reset);
37
- window.removeEventListener('keydown', reset);
38
40
  window.removeEventListener('touchstart', reset);
39
41
  window.removeEventListener('wheel', reset);
40
42
  };
@@ -11,6 +11,7 @@ import {
11
11
  import { toast } from 'sonner';
12
12
  import { useHistory } from '@/components/history-provider';
13
13
  import { type DesignSystem, defaultDesign, designToCssVars } from '../../lib/design';
14
+ import { shuffleDesign } from '../../lib/design-presets';
14
15
  import { useDesign as useDesignFetch } from './use-design';
15
16
 
16
17
  type DesignCtx = {
@@ -26,6 +27,7 @@ type DesignCtx = {
26
27
  commit: () => Promise<void>;
27
28
  discard: () => void;
28
29
  resetToDefaults: () => void;
30
+ shuffle: () => void;
29
31
  };
30
32
 
31
33
  const Ctx = createContext<DesignCtx | null>(null);
@@ -98,6 +100,16 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
98
100
  });
99
101
  }, [history]);
100
102
 
103
+ const shuffle = useCallback(() => {
104
+ const prev = draftRef.current;
105
+ const next = clone(shuffleDesign(prev));
106
+ setDraft(next);
107
+ history.record({
108
+ undo: () => setDraft(prev),
109
+ redo: () => setDraft(next),
110
+ });
111
+ }, [history]);
112
+
101
113
  // SlideCanvas emits its design vars inline on the canvas root, so a draft
102
114
  // overlay must use `!important` to outrank those inline styles.
103
115
  const previewCss = useMemo(() => {
@@ -121,6 +133,7 @@ export function DesignProvider({ slideId, children }: { slideId: string; childre
121
133
  commit,
122
134
  discard,
123
135
  resetToDefaults,
136
+ shuffle,
124
137
  };
125
138
 
126
139
  return (
@@ -1,4 +1,4 @@
1
- import { Palette, X } from 'lucide-react';
1
+ import { Palette, Shuffle, 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';
@@ -28,7 +28,7 @@ type DesignPanelProps = {
28
28
  };
29
29
 
30
30
  export function DesignPanel({ open, onClose }: DesignPanelProps) {
31
- const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
31
+ const { draft, exists, warning, loaded, dirty, update, shuffle } = useDesignPanelState();
32
32
  const { mounted, animVisible } = usePanelMount(open);
33
33
  const t = useLocale();
34
34
 
@@ -60,15 +60,27 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
60
60
  />
61
61
  )}
62
62
  </div>
63
- <Button
64
- variant="ghost"
65
- size="icon-sm"
66
- className="text-muted-foreground hover:text-foreground"
67
- onClick={onClose}
68
- aria-label={t.stylePanel.closePanelAria}
69
- >
70
- <X className="size-3.5" />
71
- </Button>
63
+ <div className="flex items-center gap-0.5">
64
+ <Button
65
+ variant="ghost"
66
+ size="icon-sm"
67
+ className="text-muted-foreground hover:text-foreground"
68
+ onClick={shuffle}
69
+ aria-label={t.stylePanel.shuffleAria}
70
+ title={t.stylePanel.shuffleTitle}
71
+ >
72
+ <Shuffle className="size-3.5" />
73
+ </Button>
74
+ <Button
75
+ variant="ghost"
76
+ size="icon-sm"
77
+ className="text-muted-foreground hover:text-foreground"
78
+ onClick={onClose}
79
+ aria-label={t.stylePanel.closePanelAria}
80
+ >
81
+ <X className="size-3.5" />
82
+ </Button>
83
+ </div>
72
84
  </>
73
85
  }
74
86
  banner={
@@ -1,3 +1,19 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ } from '@dnd-kit/core';
10
+ import {
11
+ SortableContext,
12
+ sortableKeyboardCoordinates,
13
+ useSortable,
14
+ verticalListSortingStrategy,
15
+ } from '@dnd-kit/sortable';
16
+ import { CSS } from '@dnd-kit/utilities';
1
17
  import { useEffect, useRef } from 'react';
2
18
  import { ScrollArea } from '@/components/ui/scroll-area';
3
19
  import { format, useLocale } from '@/lib/use-locale';
@@ -14,6 +30,7 @@ type Props = {
14
30
  design?: DesignSystem;
15
31
  current: number;
16
32
  onSelect: (index: number) => void;
33
+ onReorder?: (from: number, to: number) => void;
17
34
  orientation?: Orientation;
18
35
  };
19
36
 
@@ -25,6 +42,7 @@ export function ThumbnailRail({
25
42
  design,
26
43
  current,
27
44
  onSelect,
45
+ onReorder,
28
46
  orientation = 'vertical',
29
47
  }: Props) {
30
48
  const activeRef = useRef<HTMLButtonElement | null>(null);
@@ -93,61 +111,210 @@ export function ThumbnailRail({
93
111
 
94
112
  const scale = VERTICAL_THUMB_WIDTH / CANVAS_WIDTH;
95
113
  const height = CANVAS_HEIGHT * scale;
114
+
115
+ const renderThumb = (PageComp: Page, i: number) => {
116
+ const active = i === current;
117
+ const inner = (
118
+ <ThumbContents
119
+ index={i}
120
+ active={active}
121
+ page={PageComp}
122
+ design={design}
123
+ scale={scale}
124
+ height={height}
125
+ />
126
+ );
127
+
128
+ if (onReorder) {
129
+ return (
130
+ <SortableThumb
131
+ key={i}
132
+ index={i}
133
+ active={active}
134
+ activeRef={active ? activeRef : undefined}
135
+ onSelect={() => onSelect(i)}
136
+ ariaLabel={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
137
+ >
138
+ {inner}
139
+ </SortableThumb>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <button
145
+ key={i}
146
+ type="button"
147
+ ref={active ? activeRef : undefined}
148
+ onClick={() => onSelect(i)}
149
+ aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
150
+ aria-current={active ? 'true' : undefined}
151
+ className={thumbButtonClass(active)}
152
+ >
153
+ {inner}
154
+ </button>
155
+ );
156
+ };
157
+
158
+ const list = (
159
+ <aside className="flex flex-col gap-2 px-3 py-3">
160
+ <div className="flex items-baseline justify-between px-1 pb-1">
161
+ <span className="eyebrow">{t.thumbnailRail.pages}</span>
162
+ <span className="folio">{pages.length.toString().padStart(2, '0')}</span>
163
+ </div>
164
+ {pages.map(renderThumb)}
165
+ </aside>
166
+ );
167
+
168
+ if (!onReorder) {
169
+ return <ScrollArea className="h-full border-r border-hairline bg-sidebar">{list}</ScrollArea>;
170
+ }
171
+
96
172
  return (
97
173
  <ScrollArea className="h-full border-r border-hairline bg-sidebar">
98
- <aside className="flex flex-col gap-2 px-3 py-3">
99
- <div className="flex items-baseline justify-between px-1 pb-1">
100
- <span className="eyebrow">{t.thumbnailRail.pages}</span>
101
- <span className="folio">{pages.length.toString().padStart(2, '0')}</span>
102
- </div>
103
- {pages.map((PageComp, i) => {
104
- const active = i === current;
105
- return (
106
- <button
107
- // biome-ignore lint/suspicious/noArrayIndexKey: pages list is render-stable
108
- key={i}
109
- type="button"
110
- ref={active ? activeRef : undefined}
111
- onClick={() => onSelect(i)}
112
- aria-label={`Go to page ${i + 1}`}
113
- aria-current={active ? 'true' : undefined}
114
- className={cn(
115
- 'group/thumb flex items-start gap-2.5 rounded-[6px] p-1.5 text-left motion-safe:transition-colors',
116
- 'hover:bg-muted/60',
117
- active && 'bg-muted',
118
- )}
119
- >
120
- <span
121
- className={cn(
122
- 'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
123
- active ? 'text-brand' : 'text-muted-foreground/70',
124
- )}
125
- >
126
- {(i + 1).toString().padStart(2, '0')}
127
- </span>
128
- <div
129
- className={cn(
130
- 'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-all',
131
- active
132
- ? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
133
- : 'border-hairline group-hover/thumb:border-foreground/25',
134
- )}
135
- style={{ width: VERTICAL_THUMB_WIDTH, height }}
136
- >
137
- <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
138
- <PageComp />
139
- </SlideCanvas>
140
- {active && (
141
- <span
142
- aria-hidden
143
- className="pointer-events-none absolute inset-y-0 left-0 w-[2px] bg-brand"
144
- />
145
- )}
146
- </div>
147
- </button>
148
- );
149
- })}
150
- </aside>
174
+ <SortableRail pages={pages} onReorder={onReorder}>
175
+ {list}
176
+ </SortableRail>
151
177
  </ScrollArea>
152
178
  );
153
179
  }
180
+
181
+ function thumbButtonClass(active: boolean): string {
182
+ return cn(
183
+ 'group/thumb flex w-full items-start gap-2.5 rounded-[6px] p-1.5 text-left motion-safe:transition-colors',
184
+ 'hover:bg-muted/60',
185
+ active && 'bg-muted',
186
+ );
187
+ }
188
+
189
+ function ThumbContents({
190
+ index,
191
+ active,
192
+ page: PageComp,
193
+ design,
194
+ scale,
195
+ height,
196
+ }: {
197
+ index: number;
198
+ active: boolean;
199
+ page: Page;
200
+ design?: DesignSystem;
201
+ scale: number;
202
+ height: number;
203
+ }) {
204
+ return (
205
+ <>
206
+ <span
207
+ className={cn(
208
+ 'mt-1.5 w-7 shrink-0 text-right font-mono text-[10px] font-medium tracking-[0.06em] tabular-nums uppercase',
209
+ active ? 'text-brand' : 'text-muted-foreground/70',
210
+ )}
211
+ >
212
+ {(index + 1).toString().padStart(2, '0')}
213
+ </span>
214
+ <div
215
+ className={cn(
216
+ 'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-all',
217
+ active
218
+ ? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
219
+ : 'border-hairline group-hover/thumb:border-foreground/25',
220
+ )}
221
+ style={{ width: VERTICAL_THUMB_WIDTH, height }}
222
+ >
223
+ <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
224
+ <PageComp />
225
+ </SlideCanvas>
226
+ {active && (
227
+ <span
228
+ aria-hidden
229
+ className="pointer-events-none absolute inset-y-0 left-0 w-[2px] bg-brand"
230
+ />
231
+ )}
232
+ </div>
233
+ </>
234
+ );
235
+ }
236
+
237
+ function SortableRail({
238
+ pages,
239
+ onReorder,
240
+ children,
241
+ }: {
242
+ pages: Page[];
243
+ onReorder: (from: number, to: number) => void;
244
+ children: React.ReactNode;
245
+ }) {
246
+ const sensors = useSensors(
247
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
248
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
249
+ );
250
+
251
+ const items = pages.map((_, i) => i + 1);
252
+
253
+ const handleDragEnd = (event: DragEndEvent) => {
254
+ const { active, over } = event;
255
+ if (!over || active.id === over.id) return;
256
+ const from = (active.id as number) - 1;
257
+ const to = (over.id as number) - 1;
258
+ if (from < 0 || to < 0 || from === to) return;
259
+ onReorder(from, to);
260
+ };
261
+
262
+ return (
263
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
264
+ <SortableContext items={items} strategy={verticalListSortingStrategy}>
265
+ {children}
266
+ </SortableContext>
267
+ </DndContext>
268
+ );
269
+ }
270
+
271
+ function SortableThumb({
272
+ index,
273
+ active,
274
+ activeRef,
275
+ onSelect,
276
+ ariaLabel,
277
+ children,
278
+ }: {
279
+ index: number;
280
+ active: boolean;
281
+ activeRef: React.MutableRefObject<HTMLButtonElement | null> | undefined;
282
+ onSelect: () => void;
283
+ ariaLabel: string;
284
+ children: React.ReactNode;
285
+ }) {
286
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
287
+ id: index + 1,
288
+ });
289
+
290
+ const setRef = (node: HTMLButtonElement | null) => {
291
+ setNodeRef(node);
292
+ if (activeRef) activeRef.current = node;
293
+ };
294
+
295
+ const yOnlyTransform = transform ? { ...transform, x: 0 } : transform;
296
+
297
+ return (
298
+ <button
299
+ ref={setRef}
300
+ type="button"
301
+ onClick={onSelect}
302
+ aria-label={ariaLabel}
303
+ aria-current={active ? 'true' : undefined}
304
+ style={{
305
+ transform: CSS.Transform.toString(yOnlyTransform),
306
+ transition,
307
+ touchAction: 'none',
308
+ }}
309
+ className={cn(
310
+ thumbButtonClass(active),
311
+ 'cursor-grab active:cursor-grabbing',
312
+ isDragging && 'z-10 opacity-60 shadow-edge ring-1 ring-brand',
313
+ )}
314
+ {...attributes}
315
+ {...listeners}
316
+ >
317
+ {children}
318
+ </button>
319
+ );
320
+ }
@@ -0,0 +1,94 @@
1
+ import { type DesignSystem, defaultDesign } from './design';
2
+
3
+ const SANS_SYSTEM = '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif';
4
+ const SANS_INTER = '"Inter", system-ui, sans-serif';
5
+ const SANS_HELV = '"Helvetica Neue", Helvetica, Arial, sans-serif';
6
+ const SERIF_GEORGIA = 'Georgia, "Times New Roman", serif';
7
+ const SERIF_TIMES = '"Times New Roman", Times, serif';
8
+ const MONO_SF = '"SF Mono", "JetBrains Mono", Menlo, monospace';
9
+
10
+ export const designPresets: DesignSystem[] = [
11
+ defaultDesign,
12
+ {
13
+ palette: { bg: '#0f1115', text: '#f5f3ee', accent: '#7cc4ff' },
14
+ fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
15
+ typeScale: { hero: 192, body: 32 },
16
+ radius: 6,
17
+ },
18
+ {
19
+ palette: { bg: '#eef1f4', text: '#1c2733', accent: '#ff6a5b' },
20
+ fonts: { display: SANS_HELV, body: SANS_SYSTEM },
21
+ typeScale: { hero: 156, body: 30 },
22
+ radius: 8,
23
+ },
24
+ {
25
+ palette: { bg: '#fdf6e3', text: '#073642', accent: '#b58900' },
26
+ fonts: { display: SERIF_GEORGIA, body: SANS_INTER },
27
+ typeScale: { hero: 144, body: 28 },
28
+ radius: 14,
29
+ },
30
+ {
31
+ palette: { bg: '#ede2cc', text: '#3a2a1a', accent: '#2f6e3a' },
32
+ fonts: { display: SERIF_TIMES, body: SERIF_GEORGIA },
33
+ typeScale: { hero: 168, body: 32 },
34
+ radius: 4,
35
+ },
36
+ {
37
+ palette: { bg: '#ffffff', text: '#0a0a0a', accent: '#e11d48' },
38
+ fonts: { display: SANS_HELV, body: SANS_HELV },
39
+ typeScale: { hero: 200, body: 28 },
40
+ radius: 0,
41
+ },
42
+ {
43
+ palette: { bg: '#fde9d9', text: '#3a1f3d', accent: '#f97316' },
44
+ fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
45
+ typeScale: { hero: 184, body: 36 },
46
+ radius: 24,
47
+ },
48
+ {
49
+ palette: { bg: '#e9f5ee', text: '#0f3324', accent: '#ec4899' },
50
+ fonts: { display: SANS_INTER, body: SANS_INTER },
51
+ typeScale: { hero: 160, body: 32 },
52
+ radius: 16,
53
+ },
54
+ {
55
+ palette: { bg: '#0a0a0a', text: '#f3edd9', accent: '#eab308' },
56
+ fonts: { display: SERIF_GEORGIA, body: SANS_HELV },
57
+ typeScale: { hero: 200, body: 32 },
58
+ radius: 2,
59
+ },
60
+ {
61
+ palette: { bg: '#ece2f5', text: '#2a1c4a', accent: '#facc15' },
62
+ fonts: { display: SERIF_GEORGIA, body: SANS_SYSTEM },
63
+ typeScale: { hero: 168, body: 34 },
64
+ radius: 20,
65
+ },
66
+ {
67
+ palette: { bg: '#101418', text: '#a7f3d0', accent: '#fbbf24' },
68
+ fonts: { display: MONO_SF, body: MONO_SF },
69
+ typeScale: { hero: 144, body: 24 },
70
+ radius: 4,
71
+ },
72
+ {
73
+ palette: { bg: '#fafafa', text: '#0a0a0a', accent: '#facc15' },
74
+ fonts: { display: SANS_HELV, body: SANS_HELV },
75
+ typeScale: { hero: 220, body: 32 },
76
+ radius: 0,
77
+ },
78
+ ];
79
+
80
+ function pickRandom(): DesignSystem {
81
+ const idx = Math.floor(Math.random() * designPresets.length);
82
+ return designPresets[idx] ?? defaultDesign;
83
+ }
84
+
85
+ export function shuffleDesign(current?: DesignSystem | null): DesignSystem {
86
+ if (designPresets.length === 0) return defaultDesign;
87
+ if (designPresets.length === 1) return designPresets[0] ?? defaultDesign;
88
+ const currentJson = current ? JSON.stringify(current) : null;
89
+ for (let i = 0; i < 8; i++) {
90
+ const pick = pickRandom();
91
+ if (JSON.stringify(pick) !== currentJson) return pick;
92
+ }
93
+ return pickRandom();
94
+ }