@open-slide/core 0.0.11 → 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.
- package/dist/{build-DHiRlpjn.js → build-aiY_8kwE.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-CVqRAagl.js} +592 -63
- package/dist/design-CROQh0AA.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-R2we2iaF.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CU4zSyGp.js} +2 -1
- package/dist/sync-3oqN1WyK.js +139 -0
- package/dist/sync-B4eLo2H6.js +3 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +2 -1
- package/package.json +2 -1
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +81 -0
- package/skills/create-theme/SKILL.md +194 -0
- package/skills/slide-authoring/SKILL.md +288 -0
- package/src/app/{App.tsx → app.tsx} +8 -6
- package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
- package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +121 -0
- package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
- package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
- package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
- package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
- package/src/app/components/inspector/save-bar.tsx +47 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +78 -0
- package/src/app/components/panel/save-card.tsx +139 -0
- package/src/app/components/pdf-progress-toast.tsx +25 -0
- package/src/app/components/player.tsx +341 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +204 -0
- package/src/app/components/present/help-overlay.tsx +56 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +40 -0
- package/src/app/components/present/overview-grid.tsx +184 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +44 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +71 -0
- package/src/app/components/present/use-touch-swipe.ts +63 -0
- package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
- package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
- package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
- package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
- package/src/app/components/style-panel/design-provider.tsx +139 -0
- package/src/app/components/style-panel/style-panel.tsx +326 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +57 -0
- package/src/app/components/thumbnail-rail.tsx +151 -0
- package/src/app/components/ui/button.tsx +51 -19
- package/src/app/components/ui/card.tsx +1 -1
- package/src/app/components/ui/dialog.tsx +25 -9
- package/src/app/components/ui/dropdown-menu.tsx +29 -12
- package/src/app/components/ui/input.tsx +13 -9
- package/src/app/components/ui/popover.tsx +5 -2
- package/src/app/components/ui/progress.tsx +2 -2
- package/src/app/components/ui/select.tsx +11 -5
- package/src/app/components/ui/separator.tsx +1 -1
- package/src/app/components/ui/slider.tsx +4 -4
- package/src/app/components/ui/sonner.tsx +11 -1
- package/src/app/components/ui/tabs.tsx +6 -6
- package/src/app/components/ui/textarea.tsx +11 -7
- package/src/app/components/ui/toggle-group.tsx +2 -2
- package/src/app/components/ui/toggle.tsx +6 -6
- package/src/app/components/ui/tooltip.tsx +5 -2
- package/src/app/lib/export-html.ts +10 -1
- package/src/app/lib/export-pdf.ts +7 -0
- package/src/app/lib/folders.ts +1 -1
- package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
- package/src/app/lib/sdk.ts +5 -0
- package/src/app/lib/slides.ts +1 -1
- package/src/app/lib/utils.ts +1 -1
- package/src/app/main.tsx +5 -2
- package/src/app/routes/{Home.tsx → home.tsx} +266 -97
- package/src/app/routes/presenter.tsx +400 -0
- package/src/app/routes/slide.tsx +519 -0
- package/src/app/styles.css +338 -67
- package/src/app/components/PdfProgressToast.tsx +0 -23
- package/src/app/components/Player.tsx +0 -100
- package/src/app/components/ThumbnailRail.tsx +0 -68
- package/src/app/components/inspector/SaveBar.tsx +0 -77
- package/src/app/routes/Slide.tsx +0 -478
- /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
- /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
- /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 './
|
|
5
|
-
import { PRESET_COLORS } from './
|
|
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-[
|
|
50
|
-
<div className="px-
|
|
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-
|
|
65
|
-
|
|
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
|
-
{
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { cn } from '@/lib/utils';
|
|
3
|
-
import {
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|