@open-slide/core 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
  2. package/dist/cli/bin.js +43 -4
  3. package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
  4. package/dist/design-CROQh0AA.js +35 -0
  5. package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
  6. package/dist/index.d.ts +55 -4
  7. package/dist/index.js +110 -1
  8. package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
  9. package/dist/sync-3oqN1WyK.js +139 -0
  10. package/dist/sync-B4eLo2H6.js +3 -0
  11. package/dist/vite/index.d.ts +1 -1
  12. package/dist/vite/index.js +2 -1
  13. package/package.json +2 -1
  14. package/skills/apply-comments/SKILL.md +83 -0
  15. package/skills/create-slide/SKILL.md +81 -0
  16. package/skills/create-theme/SKILL.md +194 -0
  17. package/skills/slide-authoring/SKILL.md +288 -0
  18. package/src/app/{App.tsx → app.tsx} +8 -6
  19. package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
  20. package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
  21. package/src/app/components/history-provider.tsx +120 -0
  22. package/src/app/components/image-placeholder.tsx +121 -0
  23. package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
  24. package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
  25. package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
  26. package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
  27. package/src/app/components/inspector/save-bar.tsx +47 -0
  28. package/src/app/components/panel/panel-fields.tsx +60 -0
  29. package/src/app/components/panel/panel-shell.tsx +78 -0
  30. package/src/app/components/panel/save-card.tsx +139 -0
  31. package/src/app/components/pdf-progress-toast.tsx +25 -0
  32. package/src/app/components/player.tsx +341 -0
  33. package/src/app/components/present/blackout-overlay.tsx +18 -0
  34. package/src/app/components/present/control-bar.tsx +204 -0
  35. package/src/app/components/present/help-overlay.tsx +56 -0
  36. package/src/app/components/present/jump-input.tsx +74 -0
  37. package/src/app/components/present/laser-pointer.tsx +40 -0
  38. package/src/app/components/present/overview-grid.tsx +184 -0
  39. package/src/app/components/present/progress-bar.tsx +26 -0
  40. package/src/app/components/present/use-idle.ts +44 -0
  41. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  42. package/src/app/components/present/use-presenter-channel.ts +71 -0
  43. package/src/app/components/present/use-touch-swipe.ts +63 -0
  44. package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
  45. package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
  46. package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
  47. package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
  48. package/src/app/components/style-panel/design-provider.tsx +139 -0
  49. package/src/app/components/style-panel/style-panel.tsx +326 -0
  50. package/src/app/components/style-panel/use-design.ts +112 -0
  51. package/src/app/components/theme-toggle.tsx +57 -0
  52. package/src/app/components/thumbnail-rail.tsx +151 -0
  53. package/src/app/components/ui/button.tsx +51 -19
  54. package/src/app/components/ui/card.tsx +1 -1
  55. package/src/app/components/ui/dialog.tsx +25 -9
  56. package/src/app/components/ui/dropdown-menu.tsx +29 -12
  57. package/src/app/components/ui/input.tsx +13 -9
  58. package/src/app/components/ui/popover.tsx +5 -2
  59. package/src/app/components/ui/progress.tsx +2 -2
  60. package/src/app/components/ui/select.tsx +11 -5
  61. package/src/app/components/ui/separator.tsx +1 -1
  62. package/src/app/components/ui/slider.tsx +4 -4
  63. package/src/app/components/ui/sonner.tsx +11 -1
  64. package/src/app/components/ui/tabs.tsx +6 -6
  65. package/src/app/components/ui/textarea.tsx +11 -7
  66. package/src/app/components/ui/toggle-group.tsx +2 -2
  67. package/src/app/components/ui/toggle.tsx +6 -6
  68. package/src/app/components/ui/tooltip.tsx +5 -2
  69. package/src/app/lib/export-html.ts +10 -1
  70. package/src/app/lib/export-pdf.ts +7 -0
  71. package/src/app/lib/folders.ts +1 -1
  72. package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
  73. package/src/app/lib/sdk.ts +5 -0
  74. package/src/app/lib/slides.ts +1 -1
  75. package/src/app/lib/utils.ts +1 -1
  76. package/src/app/main.tsx +5 -2
  77. package/src/app/routes/{Home.tsx → home.tsx} +266 -97
  78. package/src/app/routes/presenter.tsx +400 -0
  79. package/src/app/routes/slide.tsx +519 -0
  80. package/src/app/styles.css +338 -67
  81. package/src/app/components/PdfProgressToast.tsx +0 -23
  82. package/src/app/components/Player.tsx +0 -100
  83. package/src/app/components/ThumbnailRail.tsx +0 -68
  84. package/src/app/components/inspector/SaveBar.tsx +0 -77
  85. package/src/app/routes/Slide.tsx +0 -478
  86. /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
  87. /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
  88. /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { Plus } from 'lucide-react';
2
2
  import { useState } from 'react';
3
+ import { ThemeToggle } from '@/components/theme-toggle';
3
4
  import type { Folder, FolderIcon } from '@/lib/sdk';
4
- import { FolderItem } from './FolderItem';
5
- import { PRESET_COLORS } from './IconPicker';
5
+ import { FolderItem } from './folder-item';
6
+ import { PRESET_COLORS } from './icon-picker';
6
7
 
7
8
  export const DRAFT_ID = 'draft';
8
9
 
@@ -46,9 +47,10 @@ export function Sidebar({
46
47
  };
47
48
 
48
49
  return (
49
- <aside className="flex h-full w-[17rem] shrink-0 flex-col border-r bg-card/40">
50
- <div className="px-5 pt-6 pb-3">
50
+ <aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
51
+ <div className="flex items-center justify-between px-4 pt-5 pb-4">
51
52
  <h1 className="font-heading text-lg font-bold tracking-tight">open-slide</h1>
53
+ <ThemeToggle />
52
54
  </div>
53
55
 
54
56
  <div className="px-2">
@@ -61,8 +63,10 @@ export function Sidebar({
61
63
  />
62
64
  </div>
63
65
 
64
- <div className="mt-4 px-4 pb-1 text-xs font-medium tracking-wide text-muted-foreground/70">
65
- FOLDERS
66
+ <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
67
+ <span className="eyebrow">Folders</span>
68
+ <span className="h-px flex-1 bg-hairline" aria-hidden />
69
+ <span className="folio">{folders.length.toString().padStart(2, '0')}</span>
66
70
  </div>
67
71
 
68
72
  <div className="flex-1 overflow-y-auto px-2 pb-2">
@@ -83,34 +87,36 @@ export function Sidebar({
83
87
  />
84
88
  ))}
85
89
 
86
- {creating ? (
87
- <div className="mt-1 flex items-center gap-2 rounded-md border border-dashed bg-background px-2 py-1.5">
88
- <input
89
- value={newName}
90
- onChange={(e) => setNewName(e.target.value)}
91
- onBlur={commitCreate}
92
- onKeyDown={(e) => {
93
- if (e.key === 'Enter') commitCreate();
94
- if (e.key === 'Escape') {
95
- setCreating(false);
96
- setNewName('');
97
- }
98
- }}
99
- placeholder="Folder name"
100
- maxLength={40}
101
- className="min-w-0 flex-1 bg-transparent text-sm outline-none"
102
- />
103
- </div>
104
- ) : (
105
- <button
106
- type="button"
107
- onClick={() => setCreating(true)}
108
- className="mt-1 flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border/70 px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-muted/40 hover:text-foreground"
109
- >
110
- <Plus className="size-3.5" />
111
- Add folder
112
- </button>
113
- )}
90
+ {import.meta.env.DEV &&
91
+ (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 />
94
+ <input
95
+ value={newName}
96
+ onChange={(e) => setNewName(e.target.value)}
97
+ onBlur={commitCreate}
98
+ onKeyDown={(e) => {
99
+ if (e.key === 'Enter') commitCreate();
100
+ if (e.key === 'Escape') {
101
+ setCreating(false);
102
+ setNewName('');
103
+ }
104
+ }}
105
+ placeholder="Folder name"
106
+ maxLength={40}
107
+ className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground/60"
108
+ />
109
+ </div>
110
+ ) : (
111
+ <button
112
+ type="button"
113
+ onClick={() => setCreating(true)}
114
+ 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
+ >
116
+ <Plus className="size-3.5" />
117
+ <span>New folder</span>
118
+ </button>
119
+ ))}
114
120
  </div>
115
121
  </aside>
116
122
  );
@@ -1,6 +1,7 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
2
2
  import { cn } from '@/lib/utils';
3
- import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../lib/sdk';
3
+ import { type DesignSystem, designToCssVars } from '../../design';
4
+ import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
4
5
 
5
6
  type Props = {
6
7
  children: ReactNode;
@@ -10,10 +11,27 @@ type Props = {
10
11
  center?: boolean;
11
12
  /** Flat mode: no rounded corners or drop shadow. */
12
13
  flat?: boolean;
14
+ /** Freeze descendant animations and transitions, useful for thumbnail previews. */
15
+ freezeMotion?: boolean;
13
16
  className?: string;
17
+ /**
18
+ * Per-slide design tokens. When set, the matching CSS custom properties
19
+ * are emitted on the canvas root so descendants can use `var(--osd-X)`
20
+ * regardless of which surface (editor, player, thumbnail, export) is
21
+ * rendering them.
22
+ */
23
+ design?: DesignSystem;
14
24
  };
15
25
 
16
- export function SlideCanvas({ children, scale, center = true, flat = false, className }: Props) {
26
+ export function SlideCanvas({
27
+ children,
28
+ scale,
29
+ center = true,
30
+ flat = false,
31
+ freezeMotion = false,
32
+ className,
33
+ design,
34
+ }: Props) {
17
35
  const containerRef = useRef<HTMLDivElement>(null);
18
36
  const [fitScale, setFitScale] = useState(1);
19
37
 
@@ -39,7 +57,9 @@ export function SlideCanvas({ children, scale, center = true, flat = false, clas
39
57
  <div
40
58
  className={cn(
41
59
  'overflow-hidden bg-white text-black',
42
- !flat && 'rounded-md shadow-xl ring-1 ring-black/5',
60
+ // Inset shadow keeps the 1px edge inside the canvas box so it
61
+ // can't be clipped by the parent's overflow-hidden.
62
+ !flat && 'rounded-[6px] shadow-[inset_0_0_0_1px_oklch(0_0_0/0.08)]',
43
63
  )}
44
64
  style={{
45
65
  width: scaledW,
@@ -55,12 +75,17 @@ export function SlideCanvas({ children, scale, center = true, flat = false, clas
55
75
  }}
56
76
  >
57
77
  <div
58
- style={{
59
- width: CANVAS_WIDTH,
60
- height: CANVAS_HEIGHT,
61
- transform: `scale(${s})`,
62
- transformOrigin: 'top left',
63
- }}
78
+ data-osd-canvas
79
+ data-osd-freeze-motion={freezeMotion ? '' : undefined}
80
+ style={
81
+ {
82
+ width: CANVAS_WIDTH,
83
+ height: CANVAS_HEIGHT,
84
+ transform: `scale(${s})`,
85
+ transformOrigin: 'top left',
86
+ ...(design ? designToCssVars(design) : {}),
87
+ } as CSSProperties
88
+ }
64
89
  >
65
90
  {children}
66
91
  </div>
@@ -0,0 +1,139 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { toast } from 'sonner';
12
+ import { useHistory } from '@/components/history-provider';
13
+ import { type DesignSystem, defaultDesign, designToCssVars } from '../../../design';
14
+ import { useDesign as useDesignFetch } from './use-design';
15
+
16
+ type DesignCtx = {
17
+ slideId: string;
18
+ loaded: boolean;
19
+ exists: boolean;
20
+ warning: string | null;
21
+ design: DesignSystem | null;
22
+ draft: DesignSystem | null;
23
+ dirty: boolean;
24
+ committing: boolean;
25
+ update: (mut: (next: DesignSystem) => void, coalesceKey?: string) => void;
26
+ commit: () => Promise<void>;
27
+ discard: () => void;
28
+ resetToDefaults: () => void;
29
+ };
30
+
31
+ const Ctx = createContext<DesignCtx | null>(null);
32
+
33
+ export function useDesignPanelState(): DesignCtx {
34
+ const v = useContext(Ctx);
35
+ if (!v) throw new Error('useDesignPanelState must be used inside <DesignProvider>');
36
+ return v;
37
+ }
38
+
39
+ function clone<T>(d: T): T {
40
+ return JSON.parse(JSON.stringify(d)) as T;
41
+ }
42
+
43
+ export function DesignProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
44
+ const { design, exists, warning, loaded, save } = useDesignFetch(slideId);
45
+ const [draft, setDraft] = useState<DesignSystem | null>(null);
46
+ const [committing, setCommitting] = useState(false);
47
+ const history = useHistory();
48
+ const draftRef = useRef<DesignSystem | null>(null);
49
+ draftRef.current = draft;
50
+
51
+ // Re-seed draft whenever the saved design changes (slide switch, post-save HMR).
52
+ useEffect(() => {
53
+ if (design) setDraft(clone(design));
54
+ }, [design]);
55
+
56
+ const dirty = useMemo(() => {
57
+ if (!draft || !design) return false;
58
+ return JSON.stringify(draft) !== JSON.stringify(design);
59
+ }, [draft, design]);
60
+
61
+ const update = useCallback(
62
+ (mut: (d: DesignSystem) => void, coalesceKey?: string) => {
63
+ const prev = draftRef.current;
64
+ if (!prev) return;
65
+ const next = clone(prev);
66
+ mut(next);
67
+ setDraft(next);
68
+ history.record({
69
+ coalesceKey,
70
+ undo: () => setDraft(prev),
71
+ redo: () => setDraft(next),
72
+ });
73
+ },
74
+ [history],
75
+ );
76
+
77
+ const commit = useCallback(async () => {
78
+ if (!draft) return;
79
+ setCommitting(true);
80
+ const r = await save(draft);
81
+ setCommitting(false);
82
+ if (!r.ok) toast.error(r.error ?? 'Failed to save');
83
+ history.clear();
84
+ }, [draft, save, history]);
85
+
86
+ const discard = useCallback(() => {
87
+ if (design) setDraft(clone(design));
88
+ history.clear();
89
+ }, [design, history]);
90
+
91
+ const resetToDefaults = useCallback(() => {
92
+ const prev = draftRef.current;
93
+ const next = clone(defaultDesign);
94
+ setDraft(next);
95
+ history.record({
96
+ coalesceKey: 'design:reset',
97
+ undo: () => setDraft(prev),
98
+ redo: () => setDraft(next),
99
+ });
100
+ }, [history]);
101
+
102
+ // Live-preview overlay: rendered only while there are unsaved changes so the
103
+ // canvas reflects the draft instantly, before any file write. SlideCanvas
104
+ // emits its own CSS variables inline on the canvas root (so home thumbnails,
105
+ // player, and exports work without any extra plumbing). Inline styles win
106
+ // against external rules, so the overlay must use `!important` to override.
107
+ const previewCss = useMemo(() => {
108
+ if (!dirty || !draft) return '';
109
+ const lines = Object.entries(designToCssVars(draft))
110
+ .map(([k, v]) => ` ${k}: ${v} !important;`)
111
+ .join('\n');
112
+ return `[data-osd-canvas] {\n${lines}\n}`;
113
+ }, [dirty, draft]);
114
+
115
+ const value: DesignCtx = {
116
+ slideId,
117
+ loaded,
118
+ exists,
119
+ warning,
120
+ design,
121
+ draft,
122
+ dirty,
123
+ committing,
124
+ update,
125
+ commit,
126
+ discard,
127
+ resetToDefaults,
128
+ };
129
+
130
+ return (
131
+ <Ctx.Provider value={value}>
132
+ {previewCss && (
133
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local css from draft state.
134
+ <style dangerouslySetInnerHTML={{ __html: previewCss }} />
135
+ )}
136
+ {children}
137
+ </Ctx.Provider>
138
+ );
139
+ }
@@ -0,0 +1,326 @@
1
+ import { Palette, X } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
+ import { Field, NumberField, Section } from '@/components/panel/panel-fields';
4
+ import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
5
+ import { Button } from '../ui/button';
6
+ import { Input } from '../ui/input';
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
8
+ import { Separator } from '../ui/separator';
9
+ import { Slider } from '../ui/slider';
10
+ import { useDesignPanelState } from './design-provider';
11
+
12
+ const FONT_PRESETS: Array<{ label: string; value: string }> = [
13
+ {
14
+ label: 'System sans',
15
+ value: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
16
+ },
17
+ { label: 'Inter', value: '"Inter", system-ui, sans-serif' },
18
+ { label: 'Helvetica', value: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
19
+ { label: 'Georgia', value: 'Georgia, "Times New Roman", serif' },
20
+ { label: 'Times', value: '"Times New Roman", Times, serif' },
21
+ { label: 'SF Mono', value: '"SF Mono", "JetBrains Mono", Menlo, monospace' },
22
+ ];
23
+
24
+ type DesignPanelProps = {
25
+ open: boolean;
26
+ onClose: () => void;
27
+ };
28
+
29
+ export function DesignPanel({ open, onClose }: DesignPanelProps) {
30
+ const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
31
+ const { mounted, animVisible } = usePanelMount(open);
32
+
33
+ if (!loaded) return null;
34
+ if (!mounted) return null;
35
+ if (!draft) return null;
36
+
37
+ return (
38
+ <PanelShell
39
+ uiAttr="design"
40
+ animVisible={animVisible}
41
+ header={
42
+ <>
43
+ <div className="flex min-w-0 items-center gap-2">
44
+ <Palette className="size-3.5 text-muted-foreground" />
45
+ <span className="font-heading text-[12px] font-semibold tracking-tight">
46
+ Design tokens
47
+ </span>
48
+ {!exists && (
49
+ <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
51
+ </span>
52
+ )}
53
+ {dirty && (
54
+ <span className="size-1.5 rounded-full bg-brand" title="Unsaved" aria-hidden />
55
+ )}
56
+ </div>
57
+ <Button
58
+ variant="ghost"
59
+ size="icon-sm"
60
+ className="text-muted-foreground hover:text-foreground"
61
+ onClick={onClose}
62
+ aria-label="Close design panel"
63
+ >
64
+ <X className="size-3.5" />
65
+ </Button>
66
+ </>
67
+ }
68
+ banner={
69
+ warning && (
70
+ <div className="flex gap-2 border-b border-hairline bg-[oklch(0.97_0.04_85)] px-3 py-2 text-[11px] leading-relaxed text-[oklch(0.35_0.08_45)] dark:bg-[oklch(0.25_0.04_60)] dark:text-[oklch(0.85_0.08_85)]">
71
+ <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-brand" />
72
+ <span>{warning}</span>
73
+ </div>
74
+ )
75
+ }
76
+ >
77
+ <Section title="Colors">
78
+ <ColorField
79
+ label="Background"
80
+ value={draft.palette.bg}
81
+ onChange={(v) =>
82
+ update((d) => {
83
+ d.palette.bg = v;
84
+ }, 'design:palette.bg')
85
+ }
86
+ />
87
+ <ColorField
88
+ label="Text"
89
+ value={draft.palette.text}
90
+ onChange={(v) =>
91
+ update((d) => {
92
+ d.palette.text = v;
93
+ }, 'design:palette.text')
94
+ }
95
+ />
96
+ <ColorField
97
+ label="Accent"
98
+ value={draft.palette.accent}
99
+ onChange={(v) =>
100
+ update((d) => {
101
+ d.palette.accent = v;
102
+ }, 'design:palette.accent')
103
+ }
104
+ />
105
+ </Section>
106
+
107
+ <Separator />
108
+
109
+ <Section title="Typography">
110
+ <FontField
111
+ label="Display"
112
+ value={draft.fonts.display}
113
+ onChange={(v) =>
114
+ update((d) => {
115
+ d.fonts.display = v;
116
+ }, 'design:fonts.display')
117
+ }
118
+ />
119
+ <FontField
120
+ label="Body"
121
+ value={draft.fonts.body}
122
+ onChange={(v) =>
123
+ update((d) => {
124
+ d.fonts.body = v;
125
+ }, 'design:fonts.body')
126
+ }
127
+ />
128
+ <SliderField
129
+ label="Hero"
130
+ value={draft.typeScale.hero}
131
+ min={48}
132
+ max={240}
133
+ step={2}
134
+ suffix="px"
135
+ onChange={(n) =>
136
+ update((d) => {
137
+ d.typeScale.hero = n;
138
+ }, 'design:typeScale.hero')
139
+ }
140
+ />
141
+ <SliderField
142
+ label="Body"
143
+ value={draft.typeScale.body}
144
+ min={16}
145
+ max={72}
146
+ step={1}
147
+ suffix="px"
148
+ onChange={(n) =>
149
+ update((d) => {
150
+ d.typeScale.body = n;
151
+ }, 'design:typeScale.body')
152
+ }
153
+ />
154
+ </Section>
155
+
156
+ <Separator />
157
+
158
+ <Section title="Shape">
159
+ <SliderField
160
+ label="Radius"
161
+ value={draft.radius.md}
162
+ min={0}
163
+ max={80}
164
+ step={1}
165
+ suffix="px"
166
+ onChange={(n) =>
167
+ update((d) => {
168
+ d.radius.md = n;
169
+ }, 'design:radius.md')
170
+ }
171
+ />
172
+ </Section>
173
+ </PanelShell>
174
+ );
175
+ }
176
+
177
+ export function DesignToggleButton({
178
+ active,
179
+ onToggle,
180
+ }: {
181
+ active: boolean;
182
+ onToggle: () => void;
183
+ }) {
184
+ if (import.meta.env.PROD) return null;
185
+ return (
186
+ <Button
187
+ size="sm"
188
+ variant={active ? 'default' : 'ghost'}
189
+ onClick={onToggle}
190
+ data-design-ui
191
+ title="Design tokens"
192
+ >
193
+ <Palette className="size-3.5" />
194
+ <span className="hidden md:inline">Design</span>
195
+ </Button>
196
+ );
197
+ }
198
+
199
+ function ColorField({
200
+ label,
201
+ value,
202
+ onChange,
203
+ }: {
204
+ label: string;
205
+ value: string;
206
+ onChange: (v: string) => void;
207
+ }) {
208
+ const [hexDraft, setHexDraft] = useState(value);
209
+ useEffect(() => setHexDraft(value), [value]);
210
+
211
+ return (
212
+ <Field label={label}>
213
+ <label className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-background shadow-xs">
214
+ <span className="size-5 rounded-sm" style={{ backgroundColor: value }} />
215
+ <input
216
+ type="color"
217
+ value={normalizeHex(value)}
218
+ onChange={(e) => onChange(e.target.value)}
219
+ className="absolute inset-0 cursor-pointer opacity-0"
220
+ />
221
+ </label>
222
+ <Input
223
+ type="text"
224
+ value={hexDraft}
225
+ onChange={(e) => {
226
+ const v = e.target.value;
227
+ setHexDraft(v);
228
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v);
229
+ }}
230
+ onBlur={() => {
231
+ if (!/^#[0-9a-fA-F]{6}$/.test(hexDraft)) setHexDraft(value);
232
+ }}
233
+ className="h-8 flex-1 font-mono text-[11px] uppercase"
234
+ spellCheck={false}
235
+ />
236
+ </Field>
237
+ );
238
+ }
239
+
240
+ function FontField({
241
+ label,
242
+ value,
243
+ onChange,
244
+ }: {
245
+ label: string;
246
+ value: string;
247
+ onChange: (v: string) => void;
248
+ }) {
249
+ const matched = FONT_PRESETS.find((p) => p.value === value);
250
+ return (
251
+ <Field label={label}>
252
+ <Select
253
+ value={matched ? matched.value : '__custom__'}
254
+ onValueChange={(v) => {
255
+ if (v !== '__custom__') onChange(v);
256
+ }}
257
+ >
258
+ <SelectTrigger size="sm" className="h-8 flex-1 text-xs">
259
+ <SelectValue />
260
+ </SelectTrigger>
261
+ <SelectContent>
262
+ {FONT_PRESETS.map((p) => (
263
+ <SelectItem key={p.label} value={p.value} className="text-xs">
264
+ {p.label}
265
+ </SelectItem>
266
+ ))}
267
+ {!matched && (
268
+ <SelectItem value="__custom__" className="text-xs">
269
+ Custom…
270
+ </SelectItem>
271
+ )}
272
+ </SelectContent>
273
+ </Select>
274
+ </Field>
275
+ );
276
+ }
277
+
278
+ function SliderField({
279
+ label,
280
+ value,
281
+ min,
282
+ max,
283
+ step = 1,
284
+ suffix,
285
+ onChange,
286
+ }: {
287
+ label: string;
288
+ value: number;
289
+ min: number;
290
+ max: number;
291
+ step?: number;
292
+ suffix?: string;
293
+ onChange: (n: number) => void;
294
+ }) {
295
+ return (
296
+ <Field label={label}>
297
+ <Slider
298
+ min={min}
299
+ max={max}
300
+ step={step}
301
+ value={[value]}
302
+ onValueChange={([v]) => onChange(v ?? value)}
303
+ className="flex-1"
304
+ />
305
+ <NumberField
306
+ value={value}
307
+ onChange={onChange}
308
+ min={min}
309
+ max={max}
310
+ step={step}
311
+ suffix={suffix}
312
+ />
313
+ </Field>
314
+ );
315
+ }
316
+
317
+ function normalizeHex(value: string): string {
318
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) return value;
319
+ if (/^#[0-9a-fA-F]{3}$/.test(value)) {
320
+ const r = value[1];
321
+ const g = value[2];
322
+ const b = value[3];
323
+ return `#${r}${r}${g}${g}${b}${b}`;
324
+ }
325
+ return '#000000';
326
+ }