omnieye-theme-studio 1.0.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.
Files changed (54) hide show
  1. package/.claude/launch.json +11 -0
  2. package/.github/workflows/ci.yml +88 -0
  3. package/.github/workflows/promote.yml +48 -0
  4. package/Dockerfile +20 -0
  5. package/README.md +52 -0
  6. package/dist/assets/index-B0UK7Lt_.js +200 -0
  7. package/dist/index.html +18 -0
  8. package/index.html +18 -0
  9. package/nginx.conf +15 -0
  10. package/package.json +26 -0
  11. package/src/App.tsx +79 -0
  12. package/src/chrome/Footer.tsx +21 -0
  13. package/src/chrome/IconRail.tsx +49 -0
  14. package/src/chrome/StudioSubHeader.tsx +14 -0
  15. package/src/chrome/TopBar.tsx +35 -0
  16. package/src/main.tsx +23 -0
  17. package/src/panels/CardsPanel.tsx +75 -0
  18. package/src/panels/ColourPanel.tsx +175 -0
  19. package/src/panels/LoginPanel.tsx +62 -0
  20. package/src/panels/NotificationsPanel.tsx +37 -0
  21. package/src/panels/SurfacePanel.tsx +66 -0
  22. package/src/panels/TablesPanel.tsx +57 -0
  23. package/src/panels/ThemeManagement.tsx +178 -0
  24. package/src/panels/TypographyPanel.tsx +154 -0
  25. package/src/preview/CamerasPreview.tsx +140 -0
  26. package/src/preview/ComponentsPreview.tsx +146 -0
  27. package/src/preview/DashboardPreview.tsx +138 -0
  28. package/src/preview/LoginPreview.tsx +111 -0
  29. package/src/preview/OperationsPreview.tsx +103 -0
  30. package/src/preview/OrdersPreview.tsx +108 -0
  31. package/src/preview/PreviewPane.tsx +111 -0
  32. package/src/preview/ReportsPreview.tsx +140 -0
  33. package/src/preview/ToastLayer.tsx +34 -0
  34. package/src/preview/useFitCount.ts +68 -0
  35. package/src/shared/ChipGroup.tsx +52 -0
  36. package/src/shared/CropModal.tsx +73 -0
  37. package/src/shared/InfoBadge.tsx +13 -0
  38. package/src/shared/SectionAccordion.tsx +89 -0
  39. package/src/shared/StudioSlider.tsx +103 -0
  40. package/src/shared/SwatchPicker.tsx +44 -0
  41. package/src/shared/TabStrip.tsx +51 -0
  42. package/src/store/useStudioUi.ts +47 -0
  43. package/src/store/useThemeStore.ts +103 -0
  44. package/src/studio/StudioRail.tsx +167 -0
  45. package/src/theme/buildMuiTheme.ts +53 -0
  46. package/src/theme/color.ts +68 -0
  47. package/src/theme/presets.ts +17 -0
  48. package/src/theme/settings.ts +158 -0
  49. package/src/theme/studioTokens.ts +24 -0
  50. package/src/theme/tokens.ts +243 -0
  51. package/src/theme/useTokens.ts +10 -0
  52. package/tsconfig.json +23 -0
  53. package/tsconfig.tsbuildinfo +1 -0
  54. package/vite.config.ts +7 -0
@@ -0,0 +1,37 @@
1
+ import { Box, FormControlLabel, Switch } from '@mui/material';
2
+ import { useState } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { TabStrip } from '../shared/TabStrip';
5
+ import { ChipGroup } from '../shared/ChipGroup';
6
+ import { StudioSlider } from '../shared/StudioSlider';
7
+
8
+ export function NotificationsPanel() {
9
+ const [tab, setTab] = useState('placement');
10
+ const s = useThemeStore((st) => st.settings);
11
+ const set = useThemeStore((st) => st.set);
12
+ return (
13
+ <>
14
+ <TabStrip active={tab} onChange={setTab} tabs={[
15
+ { key: 'placement', label: 'Placement', info: 'Where toasts appear.' },
16
+ { key: 'behaviour', label: 'Behaviour', info: 'Width & auto-dismiss.' },
17
+ ]} />
18
+ <Box sx={{ p: '12px 2px 2px' }}>
19
+ {tab === 'placement' && (
20
+ <ChipGroup columns={2} value={s.notifPosition} onChange={(v) => set({ notifPosition: v as any })}
21
+ options={[
22
+ { key: 'top-center', label: 'Top Center' }, { key: 'top-right', label: 'Top Right' },
23
+ { key: 'bottom-left', label: 'Bottom Left' }, { key: 'bottom-right', label: 'Bottom Right' },
24
+ ]} />
25
+ )}
26
+ {tab === 'behaviour' && (
27
+ <>
28
+ <StudioSlider label="Toast width" value={s.toastW} effective={s.toastW || 252} min={200} max={500} unit="px" softMax={500} onChange={(v) => set({ toastW: v })} />
29
+ <FormControlLabel sx={{ mt: 0.5 }} control={<Switch checked={s.notifBadge} onChange={(e) => set({ notifBadge: e.target.checked })} />} label="Show unread badge" />
30
+ <FormControlLabel sx={{ mt: 0.5, display: 'flex' }} control={<Switch checked={s.notifAutoDismiss} onChange={(e) => set({ notifAutoDismiss: e.target.checked })} />} label="Auto-dismiss toasts" />
31
+ {s.notifAutoDismiss && <StudioSlider label="Dismiss after" value={s.autoDismissSec} effective={s.autoDismissSec || 5} min={5} max={30} unit="s" onChange={(v) => set({ autoDismissSec: v })} />}
32
+ </>
33
+ )}
34
+ </Box>
35
+ </>
36
+ );
37
+ }
@@ -0,0 +1,66 @@
1
+ import { Box, Typography, Switch } from '@mui/material';
2
+ import { useState } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { TabStrip } from '../shared/TabStrip';
5
+ import { ChipGroup } from '../shared/ChipGroup';
6
+ import { StudioSlider } from '../shared/StudioSlider';
7
+ import { studioTokens } from '../theme/studioTokens';
8
+
9
+ const DENS_F: Record<string, number> = { compact: 0.82, default: 1, comfortable: 1.16 };
10
+
11
+ // Surface & Shape — 1:1 with the open preview.
12
+ // Shape: Corner Radius (chips) · Exact radius · Density (chips) · Exact density
13
+ // Effects: Card-to-card gap · Container padding · Edge gap · Uppercase button labels · Status Chips
14
+ export function SurfacePanel() {
15
+ const [tab, setTab] = useState('shape');
16
+ const s = useThemeStore((st) => st.settings);
17
+ const set = useThemeStore((st) => st.set);
18
+ const sk = studioTokens(s.mode);
19
+
20
+ const densEff = s.densityScale > 0 ? s.densityScale : Math.round((DENS_F[s.density] ?? 1) * 100);
21
+ const gapEff = s.cardGapPx > 0 ? s.cardGapPx : Math.round(16 * (DENS_F[s.density] ?? 1));
22
+ const padEff = s.containerPadPx > 0 ? s.containerPadPx : Math.round(10 * (DENS_F[s.density] ?? 1));
23
+
24
+ return (
25
+ <>
26
+ <TabStrip active={tab} onChange={setTab} tabs={[
27
+ { key: 'shape', label: 'Shape', info: 'Corner radius & layout density.' },
28
+ { key: 'effects', label: 'Effects', info: 'Spacing, button case & status chips.' },
29
+ ]} />
30
+ <Box sx={{ p: '12px 2px 2px' }}>
31
+ {tab === 'shape' && (
32
+ <>
33
+ <Typography variant="caption" sx={{ fontWeight: 700, color: sk.label2, textTransform: 'uppercase', letterSpacing: '.6px', display: 'block', mb: 0.75 }}>Corner Radius</Typography>
34
+ <ChipGroup value={s.radiusPreset} onChange={(v) => set({ radiusPreset: v as 'sharp' | 'rounded' | 'round', radiusVal: v === 'sharp' ? 0 : v === 'round' ? 18 : 9 })}
35
+ options={[{ key: 'sharp', label: 'Sharp' }, { key: 'rounded', label: 'Rounded' }, { key: 'round', label: 'Round' }]} />
36
+ <Box sx={{ mt: 1.25 }}>
37
+ <StudioSlider label="Exact radius" value={s.radiusVal} effective={s.radiusVal} min={0} max={28} unit="px" onChange={(v) => set({ radiusVal: v, radiusPreset: 'rounded' })} />
38
+ </Box>
39
+ <Typography variant="caption" sx={{ fontWeight: 700, color: sk.label2, textTransform: 'uppercase', letterSpacing: '.6px', display: 'block', mb: 0.75, mt: 0.5 }}>Density</Typography>
40
+ <ChipGroup value={s.densityScale > 0 ? '' : s.density} onChange={(v) => set({ density: v as 'compact' | 'default' | 'comfortable', densityScale: 0 })}
41
+ options={[{ key: 'compact', label: 'Compact' }, { key: 'default', label: 'Default' }, { key: 'comfortable', label: 'Comfortable' }]} />
42
+ <Box sx={{ mt: 1.25 }}>
43
+ <StudioSlider label="Exact density" info="Fine-tune spacing scale (%)." value={s.densityScale} effective={densEff} min={60} max={140} unit="%" onChange={(v) => set({ densityScale: v })} />
44
+ </Box>
45
+ </>
46
+ )}
47
+ {tab === 'effects' && (
48
+ <>
49
+ <StudioSlider label="Card-to-card gap" value={s.cardGapPx} effective={gapEff} min={0} max={40} step={2} unit="px" onChange={(v) => set({ cardGapPx: v })} />
50
+ <StudioSlider label="Container padding" value={s.containerPadPx} effective={padEff} min={0} max={40} step={2} unit="px" onChange={(v) => set({ containerPadPx: v })} />
51
+ <StudioSlider label="Edge gap" info="Space between the preview frame and the outer app card." value={s.edgePadPx} effective={s.edgePadPx} min={0} max={40} step={2} unit="px" autoLabel="0px (flush)" onChange={(v) => set({ edgePadPx: v })} />
52
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
53
+ <Typography variant="caption" sx={{ fontWeight: 700, color: sk.label2 }}>Uppercase button labels</Typography>
54
+ <Switch size="small" checked={s.btnCase === 'upper'} onChange={(e) => set({ btnCase: e.target.checked ? 'upper' : 'none' })} />
55
+ </Box>
56
+ <Box sx={{ mt: 1.25 }}>
57
+ <Typography variant="caption" sx={{ fontWeight: 700, color: sk.label2, textTransform: 'uppercase', letterSpacing: '.6px', display: 'block', mb: 0.75 }}>Status Chips</Typography>
58
+ <ChipGroup columns={2} value={s.statusBold ? 'bold' : 'soft'} onChange={(v) => set({ statusBold: v === 'bold' })}
59
+ options={[{ key: 'soft', label: 'Soft' }, { key: 'bold', label: 'Bold' }]} />
60
+ </Box>
61
+ </>
62
+ )}
63
+ </Box>
64
+ </>
65
+ );
66
+ }
@@ -0,0 +1,57 @@
1
+ import { Box, FormControlLabel, Switch } from '@mui/material';
2
+ import { useState } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { TabStrip } from '../shared/TabStrip';
5
+ import { ChipGroup } from '../shared/ChipGroup';
6
+ import { StudioSlider } from '../shared/StudioSlider';
7
+
8
+ export function TablesPanel() {
9
+ const [tab, setTab] = useState('rows');
10
+ const s = useThemeStore((st) => st.settings);
11
+ const set = useThemeStore((st) => st.set);
12
+ return (
13
+ <>
14
+ <TabStrip active={tab} onChange={setTab} tabs={[
15
+ { key: 'rows', label: 'Rows', info: 'Row density & dividers.' },
16
+ { key: 'cols', label: 'Columns', info: 'Column density & dividers.' },
17
+ { key: 'cells', label: 'Cells', info: 'Cell text & vertical alignment.' },
18
+ { key: 'styling', label: 'Styling', info: 'Header background & row striping.' },
19
+ ]} />
20
+ <Box sx={{ p: '12px 2px 2px' }}>
21
+ {tab === 'rows' && (
22
+ <>
23
+ <ChipGroup value={s.tableDensity} onChange={(v) => set({ tableDensity: v as any })}
24
+ options={[{ key: 'compact', label: 'Compact' }, { key: 'default', label: 'Default' }, { key: 'comfortable', label: 'Comfortable' }]} />
25
+ <FormControlLabel sx={{ mt: 1 }} control={<Switch checked={s.tableDividers} onChange={(e) => set({ tableDividers: e.target.checked })} />} label="Row dividers" />
26
+ {s.tableDividers && <StudioSlider label="Divider width" value={s.tableRowDivW} effective={s.tableRowDivW} min={1} max={4} unit="px" onChange={(v) => set({ tableRowDivW: v })} />}
27
+ </>
28
+ )}
29
+ {tab === 'cols' && (
30
+ <>
31
+ <ChipGroup value={s.tableColDensity} onChange={(v) => set({ tableColDensity: v as any })}
32
+ options={[{ key: 'compact', label: 'Compact' }, { key: 'default', label: 'Default' }, { key: 'comfortable', label: 'Comfortable' }]} />
33
+ <FormControlLabel sx={{ mt: 1 }} control={<Switch checked={s.tableColDividers} onChange={(e) => set({ tableColDividers: e.target.checked })} />} label="Column dividers" />
34
+ {s.tableColDividers && <StudioSlider label="Divider width" value={s.tableColDivW} effective={s.tableColDivW} min={1} max={4} unit="px" onChange={(v) => set({ tableColDivW: v })} />}
35
+ </>
36
+ )}
37
+ {tab === 'cells' && (
38
+ <>
39
+ <ChipGroup value={s.tableCellAlignH} onChange={(v) => set({ tableCellAlignH: v as any })}
40
+ options={[{ key: 'left', label: 'Left' }, { key: 'center', label: 'Center' }, { key: 'right', label: 'Right' }]} />
41
+ <Box sx={{ mt: 1 }}>
42
+ <ChipGroup value={s.tableCellAlignV} onChange={(v) => set({ tableCellAlignV: v as any })}
43
+ options={[{ key: 'top', label: 'Top' }, { key: 'middle', label: 'Middle' }, { key: 'bottom', label: 'Bottom' }]} />
44
+ </Box>
45
+ </>
46
+ )}
47
+ {tab === 'styling' && (
48
+ <>
49
+ <ChipGroup value={s.tableHeaderStyle} onChange={(v) => set({ tableHeaderStyle: v as any })}
50
+ options={[{ key: 'plain', label: 'Plain' }, { key: 'tinted', label: 'Tinted' }, { key: 'accent', label: 'Accent' }]} />
51
+ <FormControlLabel sx={{ mt: 1 }} control={<Switch checked={s.tableStripe} onChange={(e) => set({ tableStripe: e.target.checked })} />} label="Striped rows" />
52
+ </>
53
+ )}
54
+ </Box>
55
+ </>
56
+ );
57
+ }
@@ -0,0 +1,178 @@
1
+ import { Box, Typography, Button, Menu, MenuItem, Dialog, DialogTitle, DialogContent, DialogActions, TextField } from '@mui/material';
2
+ import { useState } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { TabStrip } from '../shared/TabStrip';
5
+ import { PRESETS } from '../theme/presets';
6
+ import { studioTokens } from '../theme/studioTokens';
7
+ import { rgbaOf } from '../theme/color';
8
+
9
+ export function ThemeManagement() {
10
+ const [tab, setTab] = useState('presets');
11
+ const s = useThemeStore((st) => st.settings);
12
+ const dark = s.mode === 'dark';
13
+ const sk = studioTokens(s.mode);
14
+ const highlight = s.highlightHex || '#ffd400';
15
+ const cardActiveBg = rgbaOf(highlight, dark ? 0.30 : 0.18);
16
+ const cardActiveName = dark ? '#e6edf3' : '#1c2127';
17
+ const cardIdleName = dark ? 'rgba(255,255,255,0.82)' : '#2c333d';
18
+ const saved = useThemeStore((st) => st.savedThemes);
19
+ const applyPreset = useThemeStore((st) => st.applyPreset);
20
+ const applySaved = useThemeStore((st) => st.applySaved);
21
+ const saveTheme = useThemeStore((st) => st.saveTheme);
22
+ const renameSaved = useThemeStore((st) => st.renameSaved);
23
+ const deleteSaved = useThemeStore((st) => st.deleteSaved);
24
+ const importTheme = useThemeStore((st) => st.importTheme);
25
+ const set = useThemeStore((st) => st.set);
26
+ const isDirty = useThemeStore((st) => st.isDirty);
27
+
28
+ const downloadTheme = (name: string, snap: object) => {
29
+ const blob = new Blob([JSON.stringify({ name, snap }, null, 2)], { type: 'application/json' });
30
+ const url = URL.createObjectURL(blob);
31
+ const a = document.createElement('a');
32
+ a.href = url; a.download = `${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.theme.json`;
33
+ a.click(); URL.revokeObjectURL(url);
34
+ };
35
+ const onImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
36
+ const f = e.target.files?.[0];
37
+ if (!f) return;
38
+ const rd = new FileReader();
39
+ rd.onload = () => {
40
+ try {
41
+ const parsed = JSON.parse(rd.result as string);
42
+ const snap = parsed.snap ?? parsed;
43
+ importTheme(parsed.name || 'Imported Theme', snap);
44
+ } catch { /* ignore bad file */ }
45
+ };
46
+ rd.readAsText(f);
47
+ e.target.value = '';
48
+ };
49
+
50
+ const [menu, setMenu] = useState<{ el: HTMLElement; id: string } | null>(null);
51
+ const [saveOpen, setSaveOpen] = useState(false);
52
+ const [saveName, setSaveName] = useState('');
53
+ const [rename, setRename] = useState<{ id: string; name: string } | null>(null);
54
+ const [del, setDel] = useState<{ id: string; name: string } | null>(null);
55
+
56
+ const activeName = PRESETS.find((p) => p.id === s.presetId)?.name || saved.find((z) => z.id === s.presetId)?.name || 'Custom Theme';
57
+
58
+ const nameErr = (n: string, ignoreId?: string) => {
59
+ const t = n.trim();
60
+ if (!t) return 'Name required';
61
+ if (saved.some((x) => x.name.toLowerCase() === t.toLowerCase() && x.id !== ignoreId)) return 'Name already exists';
62
+ return '';
63
+ };
64
+
65
+ return (
66
+ <>
67
+ <TabStrip active={tab} onChange={setTab} tabs={[
68
+ { key: 'presets', label: 'Presets', info: 'Ready-made colour themes.' },
69
+ { key: 'saved', label: 'My Themes', info: 'Your saved custom themes.' },
70
+ { key: 'mode', label: 'Mode', info: 'Light / Dark.' },
71
+ ]} />
72
+ <Box sx={{ p: '12px 2px 2px' }}>
73
+
74
+ {tab === 'presets' && (
75
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '9px' }}>
76
+ {PRESETS.map((p) => {
77
+ const active = s.presetId === p.id;
78
+ return (
79
+ <Box key={p.id} onClick={() => applyPreset(p.id, p.snap)} title={`${p.name} — applies this colour palette`}
80
+ sx={{ display: 'flex', alignItems: 'center', gap: '9px', p: '9px 10px', borderRadius: '11px', cursor: 'pointer', textAlign: 'left',
81
+ border: `1px solid ${active ? highlight : (dark ? '#363d48' : '#e3e8ee')}`,
82
+ bgcolor: active ? cardActiveBg : (dark ? '#262b34' : '#ffffff'), transition: 'all .14s ease' }}>
83
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
84
+ <Box sx={{ width: 20, height: 11, borderRadius: '3px', bgcolor: p.snap.primaryHex }} />
85
+ <Box sx={{ width: 20, height: 7, borderRadius: '2px', bgcolor: p.snap.secondaryHex }} />
86
+ </Box>
87
+ <Box component="span" sx={{ fontSize: '11px', fontWeight: 600, color: active ? cardActiveName : cardIdleName, lineHeight: 1.3 }}>{p.name}</Box>
88
+ </Box>
89
+ );
90
+ })}
91
+ </Box>
92
+ )}
93
+
94
+ {tab === 'saved' && (
95
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
96
+ {saved.length === 0 && <Typography variant="caption" sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}>No saved themes yet.</Typography>}
97
+ <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 0.9 }}>
98
+ {saved.map((z) => {
99
+ const active = s.presetId === z.id;
100
+ return (
101
+ <Box key={z.id} onClick={() => applySaved(z)} title="Apply this saved theme"
102
+ sx={{ display: 'flex', flexDirection: 'column', gap: 0.9, p: '9px 10px', borderRadius: '11px', cursor: 'pointer',
103
+ border: `1px solid ${active ? highlight : (dark ? '#363d48' : '#e3e8ee')}`, bgcolor: active ? cardActiveBg : (dark ? '#262b34' : '#ffffff'), transition: 'all .14s ease' }}>
104
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
105
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
106
+ <Box sx={{ width: 20, height: 11, borderRadius: '3px', bgcolor: z.snap.primaryHex }} />
107
+ <Box sx={{ width: 20, height: 7, borderRadius: '2px', bgcolor: z.snap.secondaryHex }} />
108
+ </Box>
109
+ <Box component="span" sx={{ flex: 1, minWidth: 0, fontSize: '11px', fontWeight: 600, color: active ? cardActiveName : cardIdleName, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{z.name}</Box>
110
+ </Box>
111
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
112
+ <Box component="span" onClick={(e) => { e.stopPropagation(); setMenu({ el: e.currentTarget, id: z.id }); }} sx={{ cursor: 'pointer', color: sk.muted, px: 0.5 }}>⋮</Box>
113
+ </Box>
114
+ </Box>
115
+ );
116
+ })}
117
+ </Box>
118
+ <Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
119
+ <Button size="small" variant="outlined" component="label" sx={{ flex: 1, textTransform: 'none' }}>↑ Import
120
+ <input hidden type="file" accept=".json,application/json" onChange={onImportFile} />
121
+ </Button>
122
+ <Button size="small" variant="outlined" onClick={() => downloadTheme(activeName, s)} sx={{ flex: 1, textTransform: 'none' }}>⤓ Download</Button>
123
+ <Button size="small" variant="contained" disabled={!isDirty()} onClick={() => { setSaveName(''); setSaveOpen(true); }} sx={{ flex: 1, textTransform: 'none' }}>+ Save</Button>
124
+ </Box>
125
+ </Box>
126
+ )}
127
+
128
+ {tab === 'mode' && (
129
+ <Box sx={{ display: 'flex', gap: 1 }}>
130
+ {(['light', 'dark'] as const).map((m) => (
131
+ <Button key={m} onClick={() => set({ mode: m })} variant={s.mode === m ? 'contained' : 'outlined'} sx={{ flex: 1, textTransform: 'capitalize' }}>{m}</Button>
132
+ ))}
133
+ </Box>
134
+ )}
135
+ </Box>
136
+
137
+ <Menu open={!!menu} anchorEl={menu?.el} onClose={() => setMenu(null)}>
138
+ <MenuItem onClick={() => { const z = saved.find((x) => x.id === menu!.id)!; applySaved(z); setMenu(null); }}>✏️ Edit</MenuItem>
139
+ <MenuItem onClick={() => { const z = saved.find((x) => x.id === menu!.id)!; setRename({ id: z.id, name: z.name }); setMenu(null); }}>✎ Rename</MenuItem>
140
+ <MenuItem onClick={() => { const z = saved.find((x) => x.id === menu!.id)!; setDel({ id: z.id, name: z.name }); setMenu(null); }}>🗑 Delete</MenuItem>
141
+ <MenuItem onClick={() => { const z = saved.find((x) => x.id === menu!.id)!; downloadTheme(z.name, z.snap); setMenu(null); }}>⤓ Download</MenuItem>
142
+ </Menu>
143
+
144
+ <Dialog open={saveOpen} onClose={() => setSaveOpen(false)}>
145
+ <DialogTitle>Save theme</DialogTitle>
146
+ <DialogContent>
147
+ <TextField autoFocus fullWidth size="small" label="Theme name" value={saveName} onChange={(e) => setSaveName(e.target.value)} error={!!nameErr(saveName)} helperText={nameErr(saveName)} sx={{ mt: 1 }} />
148
+ </DialogContent>
149
+ <DialogActions>
150
+ <Button onClick={() => setSaveOpen(false)}>Cancel</Button>
151
+ <Button variant="contained" disabled={!!nameErr(saveName)} onClick={() => { saveTheme(saveName.trim()); setSaveOpen(false); }}>Save</Button>
152
+ </DialogActions>
153
+ </Dialog>
154
+
155
+ <Dialog open={!!rename} onClose={() => setRename(null)}>
156
+ <DialogTitle>Rename theme</DialogTitle>
157
+ <DialogContent>
158
+ <TextField autoFocus fullWidth size="small" label="New name" value={rename?.name ?? ''} onChange={(e) => setRename((r) => r && { ...r, name: e.target.value })} error={!!(rename && nameErr(rename.name, rename.id))} helperText={rename ? nameErr(rename.name, rename.id) : ''} sx={{ mt: 1 }} />
159
+ </DialogContent>
160
+ <DialogActions>
161
+ <Button onClick={() => setRename(null)}>Cancel</Button>
162
+ <Button variant="contained" disabled={!!(rename && nameErr(rename.name, rename.id))} onClick={() => { renameSaved(rename!.id, rename!.name.trim()); setRename(null); }}>Rename</Button>
163
+ </DialogActions>
164
+ </Dialog>
165
+
166
+ <Dialog open={!!del} onClose={() => setDel(null)}>
167
+ <DialogTitle>Delete theme</DialogTitle>
168
+ <DialogContent>
169
+ <Typography variant="body2">Delete “{del?.name}”? This cannot be undone.</Typography>
170
+ </DialogContent>
171
+ <DialogActions>
172
+ <Button onClick={() => setDel(null)}>Cancel</Button>
173
+ <Button color="error" variant="contained" onClick={() => { deleteSaved(del!.id); setDel(null); }}>Delete</Button>
174
+ </DialogActions>
175
+ </Dialog>
176
+ </>
177
+ );
178
+ }
@@ -0,0 +1,154 @@
1
+ import { Box, InputBase } from '@mui/material';
2
+ import { useState, useEffect } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { TabStrip } from '../shared/TabStrip';
5
+ import { StudioSlider } from '../shared/StudioSlider';
6
+ import { studioTokens } from '../theme/studioTokens';
7
+ import { palette, rgbaOf } from '../theme/color';
8
+
9
+ // Body-font chips carry a descriptive sub-label and render in their own typeface (1:1 with HTML fontChips).
10
+ const FONTS = [
11
+ { key: 'Roboto', label: 'Roboto', sub: 'Material' },
12
+ { key: 'Inter', label: 'Inter', sub: 'Enterprise' },
13
+ { key: 'DM Sans', label: 'DM Sans', sub: 'Modern' },
14
+ { key: 'Manrope', label: 'Manrope', sub: 'Geometric' },
15
+ { key: 'Plus Jakarta Sans', label: 'Jakarta', sub: 'Friendly' },
16
+ { key: 'Nunito Sans', label: 'Nunito', sub: 'Rounded' },
17
+ ];
18
+ const HEAD_FONTS = [
19
+ { key: 'Figtree', label: 'Google Sans' }, { key: 'Roboto', label: 'Roboto' }, { key: 'Inter', label: 'Inter' },
20
+ { key: 'Arial', label: 'Arial' }, { key: 'Manrope', label: 'Manrope' }, { key: 'Plus Jakarta Sans', label: 'Jakarta' },
21
+ ];
22
+
23
+ // Font chip — 1:1 with the HTML: a single font-name label rendered in its own typeface,
24
+ // no descriptive sub-label. (`sub` is accepted but intentionally not rendered.)
25
+ function FontChip({ label, fam, active, onClick }: { label: string; sub?: string; fam: string; active: boolean; onClick: () => void }) {
26
+ const mode = useThemeStore((s) => s.settings.mode);
27
+ const primary = useThemeStore((s) => s.settings.primaryHex) || '#1565c0';
28
+ const highlight = useThemeStore((s) => s.settings.highlightHex) || '#ffd400';
29
+ const dark = mode === 'dark';
30
+ const pal = palette(primary);
31
+ const onBg = rgbaOf(highlight, dark ? 0.30 : 0.18);
32
+ return (
33
+ <Box onClick={onClick} sx={{ py: '17px', px: '4px', borderRadius: '10px', textAlign: 'center', cursor: 'pointer', border: `1px solid ${active ? (dark ? pal[400] : primary) : (dark ? '#363d48' : '#e3e8ee')}`, bgcolor: active ? onBg : (dark ? '#262b34' : '#ffffff'), transition: 'all .14s ease', '&:hover': active ? {} : { borderColor: dark ? '#4a5361' : '#c9d0d9' } }}>
34
+ <Box sx={{ fontFamily: `${fam}, sans-serif`, fontSize: 13, fontWeight: 700, lineHeight: 1, color: active ? (dark ? pal[200] : pal[700]) : (dark ? '#aeb6c0' : '#5a6573') }}>{label}</Box>
35
+ </Box>
36
+ );
37
+ }
38
+
39
+ function UploadFont({ label, current, onLoad }: { label: string; current: string; onLoad: (name: string, url: string) => void }) {
40
+ const sk = studioTokens(useThemeStore((s) => s.settings.mode));
41
+ const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
42
+ const file = e.target.files?.[0];
43
+ if (!file) return;
44
+ const name = file.name.replace(/\.[^.]+$/, '');
45
+ const url = URL.createObjectURL(file);
46
+ const style = document.createElement('style');
47
+ style.textContent = `@font-face{font-family:'${name}';src:url('${url}');font-display:swap;}`;
48
+ document.head.appendChild(style);
49
+ onLoad(name, url);
50
+ };
51
+ return (
52
+ <Box sx={{ mt: 1.5 }}>
53
+ <Box component="label" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.75, bgcolor: sk.inset, border: `1.5px dashed ${sk.insetBd}`, borderRadius: '9px', p: 1.5, cursor: 'pointer', color: sk.body, fontSize: 11.5, fontWeight: 600, textAlign: 'center' }}>
54
+ ⬆ {label}<Box component="span" sx={{ color: sk.muted }}>.woff2 / .ttf / .otf</Box>
55
+ <input type="file" accept=".woff2,.woff,.ttf,.otf" onChange={onFile} style={{ display: 'none' }} />
56
+ </Box>
57
+ {current && (
58
+ <Box sx={{ mt: 0.9, display: 'flex', alignItems: 'center', gap: 0.9, bgcolor: 'success.main', borderRadius: 2, px: 1.4, py: 0.9, color: 'success.contrastText' }}>
59
+ <Box component="span" sx={{ fontWeight: 900, fontSize: 12 }}>✓</Box>
60
+ <Box component="span" sx={{ fontSize: 11.5 }}>Loaded: <b>{current}</b></Box>
61
+ </Box>
62
+ )}
63
+ </Box>
64
+ );
65
+ }
66
+
67
+ // Scale-ratio gear (H1 size row) — 1:1 with the HTML: a gear pill that opens an inline number
68
+ // input editing the modular scale ratio (scaleRatioCustom). Ratio presets: compact 1.15, default
69
+ // 1.25, airy 1.414.
70
+ const RATIO_PRESET: Record<string, number> = { compact: 1.15, default: 1.25, airy: 1.414 };
71
+ function RatioGear() {
72
+ const s = useThemeStore((st) => st.settings);
73
+ const set = useThemeStore((st) => st.set);
74
+ const sk = studioTokens(s.mode);
75
+ const [editing, setEditing] = useState(false);
76
+ const ratioEff = s.scaleRatioCustom > 0 ? s.scaleRatioCustom : (RATIO_PRESET[s.scaleRatio] ?? 1.25);
77
+ const [draft, setDraft] = useState(ratioEff.toFixed(2));
78
+ useEffect(() => { if (!editing) setDraft(ratioEff.toFixed(2)); }, [ratioEff, editing]);
79
+ const commit = () => { const n = parseFloat(draft); if (!isNaN(n)) set({ scaleRatioCustom: n }); setEditing(false); };
80
+ if (editing) {
81
+ return (
82
+ <InputBase autoFocus value={draft}
83
+ onChange={(e) => setDraft(e.target.value)}
84
+ onBlur={commit}
85
+ onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') setEditing(false); }}
86
+ sx={{ width: 56, fontSize: '11.5px', fontWeight: 700, border: '1.5px solid', borderColor: 'primary.main', borderRadius: '6px', px: '6px', py: '3px', '& input': { p: 0, textAlign: 'center' } }} />
87
+ );
88
+ }
89
+ return (
90
+ <Box component="button" title="Configure scale ratio — each heading is the one above ÷ this value"
91
+ onClick={() => { setDraft(ratioEff.toFixed(2)); setEditing(true); }}
92
+ sx={{ display: 'inline-grid', placeItems: 'center', color: sk.body, bgcolor: 'rgba(130,140,155,0.14)', border: '1px solid rgba(130,140,155,0.28)', borderRadius: '6px', px: '6px', py: '3px', cursor: 'pointer', '&:hover': { bgcolor: 'rgba(130,140,155,0.24)' } }}>
93
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
94
+ <circle cx="12" cy="12" r="3" />
95
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
96
+ </svg>
97
+ </Box>
98
+ );
99
+ }
100
+
101
+ export function TypographyPanel() {
102
+ const [tab, setTab] = useState('bodyFont');
103
+ const s = useThemeStore((st) => st.settings);
104
+ const set = useThemeStore((st) => st.set);
105
+
106
+ return (
107
+ <>
108
+ <TabStrip
109
+ active={tab}
110
+ onChange={setTab}
111
+ tabs={[
112
+ { key: 'bodyFont', label: 'Body Font', info: 'Typeface used for body / paragraph text (headings use the Heading Font)' },
113
+ { key: 'headFont', label: 'Heading Font', info: 'Choose a separate typeface for headings (or match the body font)' },
114
+ { key: 'headText', label: 'Heading Text', info: 'Fine-tune size & weight per heading level (H1–H4)' },
115
+ { key: 'bodyText', label: 'Body Text', info: 'Adjust base font size and line height for body copy' },
116
+ ]}
117
+ />
118
+ <Box sx={{ p: '12px 2px 2px' }}>
119
+ {tab === 'bodyFont' && (
120
+ <>
121
+ <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '10px' }}>
122
+ {FONTS.map((f) => <FontChip key={f.key} label={f.label} sub={f.sub} fam={f.key} active={s.fontFamily === f.key} onClick={() => set({ fontFamily: f.key })} />)}
123
+ {s.customFont && <FontChip label={s.customFont} sub="Custom" fam={s.customFont} active={s.fontFamily === s.customFont} onClick={() => set({ fontFamily: s.customFont })} />}
124
+ </Box>
125
+ <UploadFont label="Upload custom font" current={s.customFont} onLoad={(name) => set({ customFont: name, fontFamily: name })} />
126
+ </>
127
+ )}
128
+ {tab === 'headFont' && (
129
+ <>
130
+ <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '10px' }}>
131
+ {HEAD_FONTS.map((f) => <FontChip key={f.key} label={f.label} fam={f.key} active={s.headingFont === f.key} onClick={() => set({ headingFont: f.key })} />)}
132
+ {s.customHeadFont && <FontChip label={s.customHeadFont} sub="Custom" fam={s.customHeadFont} active={s.headingFont === s.customHeadFont} onClick={() => set({ headingFont: s.customHeadFont })} />}
133
+ </Box>
134
+ <UploadFont label="Upload heading font" current={s.customHeadFont} onLoad={(name) => set({ customHeadFont: name, headingFont: name })} />
135
+ </>
136
+ )}
137
+ {tab === 'headText' && (
138
+ <>
139
+ <StudioSlider label="H1 size" info="All heading levels scale from H1 by a modular ratio (configurable via the gear icon)." value={s.h1Size} effective={s.h1Size || 20} min={18} max={56} unit="px" extra={<RatioGear />} onChange={(v) => set({ h1Size: v })} />
140
+ <StudioSlider label="Weight (all headings)" value={s.headingWeight} effective={s.headingWeight} min={300} max={900} step={100} onChange={(v) => set({ headingWeight: v })} />
141
+ <StudioSlider label="Line height (all headings)" value={s.headingLH} effective={s.headingLH || 1.2} min={0.9} max={2} step={0.05} autoLabel="Auto (1.20)" onChange={(v) => set({ headingLH: v })} />
142
+ </>
143
+ )}
144
+ {tab === 'bodyText' && (
145
+ <>
146
+ <StudioSlider label="Base font size" info="Root body text size." value={s.baseFontPx} effective={s.baseFontPx || 12} min={9} max={20} unit="px" softMin={9} onChange={(v) => set({ baseFontPx: v })} />
147
+ <StudioSlider label="Body weight" value={s.bodyWeight} effective={s.bodyWeight || 400} min={300} max={900} step={100} autoLabel="Auto (400)" onChange={(v) => set({ bodyWeight: v })} />
148
+ <StudioSlider label="Body line height" value={s.bodyLineHeight} effective={s.bodyLineHeight || 1.5} min={1.2} max={2} step={0.05} autoLabel="Auto (1.50)" onChange={(v) => set({ bodyLineHeight: v })} />
149
+ </>
150
+ )}
151
+ </Box>
152
+ </>
153
+ );
154
+ }