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,73 @@
1
+ import { useRef, useState, useEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Box, Typography, Slider } from '@mui/material';
4
+
5
+ // Crop modal — 1:1 with the HTML app's "Adjust brand image": drag + zoom, bright keep-frame,
6
+ // dimmed cropped-out area, canvas export at the target aspect (CH=1000).
7
+ export interface CropModalProps {
8
+ src: string;
9
+ aspect: number; // keep-frame aspect (w/h)
10
+ onCancel: () => void;
11
+ onConfirm: (dataUrl: string) => void;
12
+ }
13
+
14
+ export function CropModal({ src, aspect, onCancel, onConfirm }: CropModalProps) {
15
+ const [zoom, setZoom] = useState(1);
16
+ const [pos, setPos] = useState({ x: 0, y: 0 });
17
+ const frameRef = useRef<HTMLDivElement>(null);
18
+ const imgRef = useRef<HTMLImageElement>(null);
19
+ const keepRef = useRef<HTMLDivElement>(null);
20
+ const drag = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null);
21
+
22
+ useEffect(() => {
23
+ const move = (ev: PointerEvent) => {
24
+ if (!drag.current) return;
25
+ setPos({ x: drag.current.ox + (ev.clientX - drag.current.sx), y: drag.current.oy + (ev.clientY - drag.current.sy) });
26
+ };
27
+ const up = () => { drag.current = null; };
28
+ document.addEventListener('pointermove', move);
29
+ document.addEventListener('pointerup', up);
30
+ return () => { document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); };
31
+ }, []);
32
+
33
+ const startDrag = (e: React.PointerEvent) => { e.preventDefault(); drag.current = { sx: e.clientX, sy: e.clientY, ox: pos.x, oy: pos.y }; };
34
+
35
+ const confirm = () => {
36
+ const frame = frameRef.current, img = imgRef.current, keep = keepRef.current;
37
+ if (!frame || !img) { onConfirm(src); return; }
38
+ const fr = (keep || frame).getBoundingClientRect();
39
+ const CH = 1000, CW = Math.round(CH * aspect), sc = CW / fr.width;
40
+ const cv = document.createElement('canvas');
41
+ cv.width = CW; cv.height = CH;
42
+ const ctx = cv.getContext('2d');
43
+ if (!ctx) { onConfirm(src); return; }
44
+ const ir = img.getBoundingClientRect();
45
+ const dx = (ir.left - fr.left) * sc, dy = (ir.top - fr.top) * sc, dw = ir.width * sc, dh = ir.height * sc;
46
+ try { ctx.drawImage(img, dx, dy, dw, dh); onConfirm(cv.toDataURL('image/png')); }
47
+ catch { onConfirm(src); }
48
+ };
49
+
50
+ // Portal to <body> so the fixed overlay escapes the open accordion's transform (which otherwise
51
+ // becomes the containing block for position:fixed and clips the modal / its buttons).
52
+ return createPortal(
53
+ <Box sx={{ position: 'fixed', inset: 0, zIndex: 1400, bgcolor: 'rgba(15,23,42,.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 2.25, overflow: 'auto' }}>
54
+ <Box sx={{ width: '100%', maxWidth: 320, bgcolor: 'background.paper', border: '1px solid', borderColor: 'divider', borderRadius: 3, boxShadow: '0 20px 50px rgba(15,23,42,.4)', p: 2 }}>
55
+ <Typography sx={{ fontSize: 14, fontWeight: 800, mb: 0.5 }}>Adjust brand image</Typography>
56
+ <Typography sx={{ fontSize: 11.5, color: 'text.secondary', mb: 1.5, lineHeight: 1.45 }}>Drag and zoom to fit your image inside the bright frame. Anything in the dimmed area is cropped out.</Typography>
57
+ <Box ref={frameRef} onPointerDown={startDrag} sx={{ position: 'relative', width: '100%', aspectRatio: '1.55', borderRadius: 2.5, overflow: 'hidden', bgcolor: '#e9edf2', cursor: 'grab', touchAction: 'none', display: 'grid', placeItems: 'center', '&:active': { cursor: 'grabbing' } }}>
58
+ <img ref={imgRef} src={src} alt="" draggable={false} style={{ position: 'absolute', left: '50%', top: '50%', transform: `translate(-50%,-50%) translate(${pos.x}px,${pos.y}px) scale(${zoom})`, transformOrigin: 'center', maxWidth: '100%', maxHeight: '100%', width: 'auto', height: 'auto', userSelect: 'none', pointerEvents: 'none' }} />
59
+ <Box ref={keepRef} sx={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', height: '72%', aspectRatio: String(aspect), maxWidth: '90%', boxShadow: '0 0 0 9999px rgba(10,15,25,.62)', border: '2px solid #fff', borderRadius: '3px', pointerEvents: 'none' }} />
60
+ </Box>
61
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, my: 1.5 }}>
62
+ <Typography sx={{ fontSize: 12, fontWeight: 700, color: 'text.secondary' }}>Zoom</Typography>
63
+ <Slider size="small" min={0.3} max={4} step={0.02} value={zoom} onChange={(_, v) => setZoom(v as number)} />
64
+ </Box>
65
+ <Box sx={{ display: 'flex', gap: 1 }}>
66
+ <Box component="button" onClick={onCancel} sx={{ flex: 1, p: 1.25, borderRadius: 2.5, border: '1px solid', borderColor: 'divider', bgcolor: 'action.hover', color: 'text.secondary', fontSize: 12.5, fontWeight: 700, cursor: 'pointer' }}>Cancel</Box>
67
+ <Box component="button" onClick={confirm} sx={{ flex: 1, p: 1.25, borderRadius: 2.5, border: 'none', bgcolor: 'primary.main', color: 'primary.contrastText', fontSize: 12.5, fontWeight: 700, cursor: 'pointer' }}>Confirm</Box>
68
+ </Box>
69
+ </Box>
70
+ </Box>,
71
+ document.body,
72
+ );
73
+ }
@@ -0,0 +1,13 @@
1
+ import { Box, Tooltip } from '@mui/material';
2
+ import { useThemeStore } from '../store/useThemeStore';
3
+ import { studioTokens } from '../theme/studioTokens';
4
+
5
+ // Small circled-i info badge — neutral chevron colour from studioTokens (theme-independent).
6
+ export function InfoBadge({ text }: { text: string }) {
7
+ const sk = studioTokens(useThemeStore((s) => s.settings.mode));
8
+ return (
9
+ <Tooltip title={text} arrow>
10
+ <Box component="span" sx={{ width: 14, height: 14, borderRadius: '50%', border: `1.2px solid ${sk.chevron}`, color: sk.chevron, fontSize: 9, fontWeight: 800, fontStyle: 'italic', fontFamily: 'Georgia, serif', display: 'inline-grid', placeItems: 'center', cursor: 'help', flexShrink: 0 }}>i</Box>
11
+ </Tooltip>
12
+ );
13
+ }
@@ -0,0 +1,89 @@
1
+ import { Box, Collapse } from '@mui/material';
2
+ import type { ReactNode } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { studioTokens } from '../theme/studioTokens';
5
+ import { buildTokens } from '../theme/tokens';
6
+ import { palette, rgbaOf } from '../theme/color';
7
+
8
+ // Collapsible studio section — ported 1:1 from Theme Manager.dc.html (rowChip):
9
+ // • OPEN → wrapper tinted with the edited theme's primary (0.10 light / 0.22 dark),
10
+ // title recoloured to the accent, chevron turns primary, whole row lifts translateY(-2px).
11
+ // • DIMMED → when ANY section is open, the others drop to opacity 0.1 (the "transparency behaviour").
12
+ // • Icon tile stays a neutral studio surface in BOTH states (never filled with primary).
13
+ export interface SectionAccordionProps {
14
+ icon: ReactNode;
15
+ title: string;
16
+ info?: string;
17
+ open: boolean;
18
+ dimmed?: boolean;
19
+ onToggle: () => void;
20
+ onPreviewMenu?: () => void;
21
+ children: ReactNode;
22
+ }
23
+
24
+ export function SectionAccordion({ icon, title, info, open, dimmed, onToggle, onPreviewMenu, children }: SectionAccordionProps) {
25
+ const settings = useThemeStore((s) => s.settings);
26
+ const dark = settings.mode === 'dark';
27
+ const primary = settings.primaryHex || '#1565c0';
28
+ const sk = studioTokens(settings.mode);
29
+ const pal = palette(primary);
30
+ const T = buildTokens(settings);
31
+
32
+ const wrapBg = open ? (dark ? rgbaOf(primary, 0.22) : rgbaOf(primary, 0.10)) : 'transparent';
33
+ const titleColor = open ? (dark ? pal[200] : pal[700]) : sk.text;
34
+ const chevColor = open ? primary : sk.chevron;
35
+
36
+ return (
37
+ <Box
38
+ sx={{
39
+ background: wrapBg,
40
+ opacity: dimmed ? 0.1 : 1,
41
+ transform: open ? 'translateY(-2px)' : 'none',
42
+ transition: 'opacity .22s ease, transform .22s ease',
43
+ borderBottom: `1px solid ${sk.divider}`,
44
+ overflow: 'hidden',
45
+ }}
46
+ >
47
+ <Box
48
+ component="button"
49
+ onClick={onToggle}
50
+ sx={{
51
+ width: '100%', display: 'flex', alignItems: 'center', gap: '9px', p: '7px 11px',
52
+ background: 'transparent', border: 'none', textAlign: 'left', cursor: 'pointer', fontFamily: 'inherit',
53
+ }}
54
+ >
55
+ <Box sx={{ width: 32, height: 32, borderRadius: '10px', background: sk.iconTile, color: sk.body, display: 'grid', placeItems: 'center', flexShrink: 0, position: 'relative' }}>
56
+ {icon}
57
+ </Box>
58
+ <Box sx={{ flex: 1, minWidth: 0 }}>
59
+ <Box sx={{ fontSize: `${T.tH5}px`, fontWeight: T.wH5, fontFamily: `${T.headFam},sans-serif`, lineHeight: 1.25, color: titleColor, letterSpacing: '-.2px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
60
+ {title}
61
+ </Box>
62
+ </Box>
63
+ {onPreviewMenu && (
64
+ <Box
65
+ component="span"
66
+ title="Change preview screen"
67
+ onClick={(e) => { e.stopPropagation(); onPreviewMenu(); }}
68
+ sx={{ width: 16, height: 16, mr: '6px', flexShrink: 0, cursor: 'pointer', display: 'grid', placeItems: 'center', color: sk.chevron, transition: 'filter .15s ease, transform .1s ease', '&:hover': { filter: 'brightness(1.15)', transform: 'scale(1.12)' } }}
69
+ >
70
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg>
71
+ </Box>
72
+ )}
73
+ {info && (
74
+ <Box component="span" title={info} sx={{ width: 15, height: 15, borderRadius: '50%', border: `1.2px solid ${sk.chevron}`, color: sk.chevron, fontSize: '9px', fontWeight: 800, fontStyle: 'italic', fontFamily: 'Georgia, serif', display: 'grid', placeItems: 'center', mr: '7px', flexShrink: 0, cursor: 'help' }}>i</Box>
75
+ )}
76
+ <Box sx={{ width: 20, height: 20, borderRadius: '6px', display: 'grid', placeItems: 'center', fontSize: '9px', color: chevColor, flexShrink: 0, transition: 'background .18s, border-color .18s' }}>
77
+ <Box component="span" sx={{ display: 'block', lineHeight: 1, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>▼</Box>
78
+ </Box>
79
+ </Box>
80
+ <Collapse in={open} unmountOnExit timeout={280}>
81
+ {/* Panel body — a plain block wrapper (padding + top divider), matching every HTML panel
82
+ except Colour, which supplies its own flex/gap-11 wrapper internally. */}
83
+ <Box sx={{ p: '2px 13px 12px', borderTop: `1px solid ${sk.divider}` }}>
84
+ {children}
85
+ </Box>
86
+ </Collapse>
87
+ </Box>
88
+ );
89
+ }
@@ -0,0 +1,103 @@
1
+ import { Box, Typography, InputBase, Tooltip } from '@mui/material';
2
+ import { useState, useEffect } from 'react';
3
+ import type { ReactNode } from 'react';
4
+ import { useThemeStore } from '../store/useThemeStore';
5
+ import { studioTokens } from '../theme/studioTokens';
6
+ import { useTokens } from '../theme/useTokens';
7
+
8
+ // Slider with inline pencil-edit + validation, ported from the HTML dc-inline pattern.
9
+ export interface StudioSliderProps {
10
+ label: string;
11
+ info?: string;
12
+ value: number; // 0 may mean "auto" upstream; caller passes the effective value
13
+ effective: number; // shown value (never 0-as-blank)
14
+ min: number;
15
+ max: number;
16
+ step?: number;
17
+ unit?: string;
18
+ autoLabel?: string; // shown when value===0
19
+ softMin?: number; // warn (not block) below
20
+ softMax?: number; // warn (not block) above
21
+ extra?: ReactNode; // extra control after the value pill (e.g. the H1 scale-ratio gear)
22
+ onChange: (v: number) => void;
23
+ }
24
+
25
+ export function StudioSlider(props: StudioSliderProps) {
26
+ const { label, info, value, effective, min, max, step = 1, unit = '', autoLabel, softMin, softMax, extra, onChange } = props;
27
+ const sk = studioTokens(useThemeStore((s) => s.settings.mode));
28
+ const T = useTokens();
29
+ const bodyFamCss = `${T.bodyFam},sans-serif`;
30
+ const [editing, setEditing] = useState(false);
31
+ const [draft, setDraft] = useState(String(effective));
32
+ const [warn, setWarn] = useState('');
33
+
34
+ useEffect(() => { if (!editing) setDraft(String(effective)); }, [effective, editing]);
35
+
36
+ const commit = (raw: string) => {
37
+ const n = parseFloat(raw);
38
+ if (isNaN(n)) { setWarn('Enter a number'); return; }
39
+ if (softMin != null && n < softMin) setWarn(`Below ${softMin}${unit} may break the UI`);
40
+ else if (softMax != null && n > softMax) setWarn(`Above ${softMax}${unit} may break the UI`);
41
+ else setWarn('');
42
+ onChange(n);
43
+ };
44
+
45
+ const shown = value === 0 && autoLabel ? autoLabel : `${effective}${unit}`;
46
+
47
+ return (
48
+ <Box sx={{ mb: 1.5 }}>
49
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: '8px' }}>
50
+ <Typography sx={{ fontSize: `${T.tBodySm}px`, fontWeight: T.bwEmph, fontFamily: bodyFamCss, lineHeight: T.bodyLH, color: sk.label2, display: 'flex', alignItems: 'center', gap: '5px' }}>
51
+ {label}
52
+ {info && (
53
+ <Tooltip title={info} arrow>
54
+ <Box component="span" sx={{ width: 13, height: 13, borderRadius: '50%', border: `1.1px solid ${sk.chevron}`, color: sk.chevron, fontSize: 8, fontWeight: 800, fontStyle: 'italic', fontFamily: 'Georgia, serif', display: 'inline-grid', placeItems: 'center', cursor: 'help' }}>i</Box>
55
+ </Tooltip>
56
+ )}
57
+ </Typography>
58
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: '5px', flexShrink: 0 }}>
59
+ {editing ? (
60
+ <InputBase
61
+ autoFocus
62
+ value={draft}
63
+ onChange={(e) => setDraft(e.target.value)}
64
+ onBlur={() => { commit(draft); setEditing(false); }}
65
+ onKeyDown={(e) => { if (e.key === 'Enter') { commit(draft); setEditing(false); } if (e.key === 'Escape') setEditing(false); }}
66
+ sx={{ width: 56, fontSize: `${T.tBodySm}px`, fontWeight: 700, fontFamily: bodyFamCss, border: '1.5px solid', borderColor: 'primary.main', borderRadius: '6px', px: '6px', py: '3px', '& input': { p: 0, textAlign: 'center' } }}
67
+ />
68
+ ) : (
69
+ // Value pill — the HTML dc-inline summary: value + pencil edit icon (click to edit inline).
70
+ <Box
71
+ component="button"
72
+ onClick={() => { setDraft(String(effective)); setEditing(true); }}
73
+ sx={{ display: 'inline-flex', alignItems: 'center', gap: '5px', fontFamily: bodyFamCss, fontSize: `${T.tBodySm}px`, fontWeight: 800, color: sk.body, bgcolor: 'rgba(130,140,155,0.14)', border: '1px solid rgba(130,140,155,0.28)', borderRadius: '6px', px: '7px', py: '2px', cursor: 'pointer', lineHeight: 1.3, '&:hover': { bgcolor: 'rgba(130,140,155,0.24)' } }}
74
+ >
75
+ {shown}
76
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block', flexShrink: 0 }}>
77
+ <path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
78
+ </svg>
79
+ </Box>
80
+ )}
81
+ {extra}
82
+ </Box>
83
+ </Box>
84
+ {/* Native styled range — 1:1 with the HTML: 4px #dde3ea track, 18px white thumb (no fill). */}
85
+ <Box
86
+ component="input"
87
+ type="range"
88
+ min={min}
89
+ max={max}
90
+ step={step}
91
+ value={effective}
92
+ onChange={(e) => { setWarn(''); onChange(parseFloat((e.target as HTMLInputElement).value)); }}
93
+ sx={{
94
+ WebkitAppearance: 'none', appearance: 'none', width: '100%', height: '4px', m: 0, p: 0, display: 'block',
95
+ borderRadius: '2px', outline: 'none', cursor: 'pointer', background: '#dde3ea', accentColor: 'primary.main',
96
+ '&::-webkit-slider-thumb': { WebkitAppearance: 'none', width: 18, height: 18, borderRadius: '50%', background: '#fff', cursor: 'pointer', boxShadow: '0 1px 4px rgba(0,0,0,.25),0 0 0 1px rgba(0,0,0,.05)' },
97
+ '&::-moz-range-thumb': { width: 18, height: 18, border: 'none', borderRadius: '50%', background: '#fff', cursor: 'pointer', boxShadow: '0 1px 4px rgba(0,0,0,.25),0 0 0 1px rgba(0,0,0,.05)' },
98
+ }}
99
+ />
100
+ {warn && <Typography variant="caption" sx={{ color: 'warning.main', fontSize: 10 }}>{warn}</Typography>}
101
+ </Box>
102
+ );
103
+ }
@@ -0,0 +1,44 @@
1
+ import { Box, Typography, Tooltip, Popover } from '@mui/material';
2
+ import { useState } from 'react';
3
+ import { useThemeStore } from '../store/useThemeStore';
4
+ import { studioTokens } from '../theme/studioTokens';
5
+
6
+ // Swatch colour picker: label + color well opening a native <input type=color>.
7
+ export interface SwatchPickerProps {
8
+ label: string;
9
+ info?: string;
10
+ value: string; // '' = using derived default
11
+ fallback: string; // shown when value is empty
12
+ onChange: (hex: string) => void;
13
+ onReset?: () => void;
14
+ }
15
+
16
+ export function SwatchPicker({ label, info, value, fallback, onChange, onReset }: SwatchPickerProps) {
17
+ const sk = studioTokens(useThemeStore((s) => s.settings.mode));
18
+ const [anchor, setAnchor] = useState<HTMLElement | null>(null);
19
+ const shown = value || fallback;
20
+ return (
21
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
22
+ <Box
23
+ onClick={(e) => setAnchor(e.currentTarget)}
24
+ sx={{ width: 22, height: 22, borderRadius: 1.5, border: `1px solid ${sk.insetBd}`, bgcolor: shown, cursor: 'pointer', flexShrink: 0 }}
25
+ />
26
+ <Typography variant="caption" sx={{ flex: 1, color: sk.body, display: 'flex', alignItems: 'center', gap: 0.5 }}>
27
+ {label}
28
+ {info && (
29
+ <Tooltip title={info} arrow>
30
+ <Box component="span" sx={{ width: 13, height: 13, borderRadius: '50%', border: `1.1px solid ${sk.chevron}`, color: sk.chevron, fontSize: 8, fontWeight: 800, fontStyle: 'italic', fontFamily: 'Georgia, serif', display: 'inline-grid', placeItems: 'center', cursor: 'help' }}>i</Box>
31
+ </Tooltip>
32
+ )}
33
+ </Typography>
34
+ {value && onReset && (
35
+ <Box component="span" onClick={onReset} title="Reset to theme default" sx={{ fontSize: 13, color: sk.muted, cursor: 'pointer' }}>↺</Box>
36
+ )}
37
+ <Popover open={!!anchor} anchorEl={anchor} onClose={() => setAnchor(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}>
38
+ <Box sx={{ p: 1 }}>
39
+ <input type="color" value={shown} onChange={(e) => onChange(e.target.value)} style={{ width: 160, height: 40, border: 'none', background: 'none' }} />
40
+ </Box>
41
+ </Popover>
42
+ </Box>
43
+ );
44
+ }
@@ -0,0 +1,51 @@
1
+ import { Box, Button, Tooltip } from '@mui/material';
2
+ import { useThemeStore } from '../store/useThemeStore';
3
+ import { rgbaOf } from '../theme/color';
4
+ import { useTokens } from '../theme/useTokens';
5
+
6
+ // Studio tab strip — tokens ported verbatim from Theme Manager.dc.html (colorTabs):
7
+ // active = highlight-tint fill + 3px primary underline, neutral text, opacity 1, weight body+200
8
+ // inactive= transparent, opacity .3, neutral body text, 2px transparent underline; hover lifts+tints.
9
+ export interface TabItem { key: string; label: string; info?: string; }
10
+ export interface TabStripProps { tabs: TabItem[]; active: string; onChange: (key: string) => void; }
11
+
12
+ export function TabStrip({ tabs, active, onChange }: TabStripProps) {
13
+ const mode = useThemeStore((s) => s.settings.mode);
14
+ const primary = useThemeStore((s) => s.settings.primaryHex) || '#1565c0';
15
+ const highlight = useThemeStore((s) => s.settings.highlightHex) || '#ffd400';
16
+ const bodyWeight = useThemeStore((s) => s.settings.bodyWeight) || 400;
17
+ const dark = mode === 'dark';
18
+ const bwEmph = Math.min(900, bodyWeight + 200);
19
+ const T = useTokens();
20
+ const activeTxt = dark ? '#e6edf3' : '#1c2127';
21
+ const inactiveTxt = dark ? '#cdd4de' : '#5a6573';
22
+
23
+ return (
24
+ <Box sx={{ display: 'flex', gap: '2px', mb: '2px', px: '2px', borderBottom: '1.5px solid rgba(120,130,145,0.5)' }}>
25
+ {tabs.map((t) => {
26
+ const on = t.key === active;
27
+ const btn = (
28
+ <Button
29
+ key={t.key}
30
+ onClick={() => onChange(t.key)}
31
+ disableRipple
32
+ sx={{
33
+ flex: 1, minWidth: 0, px: '4px', pt: '9px', pb: '8px', borderRadius: '6px 6px 0 0', mb: '-1px',
34
+ fontSize: `${T.tBodySm}px`, fontFamily: `${T.bodyFam},sans-serif`, fontWeight: on ? bwEmph : bodyWeight, letterSpacing: '.2px', textTransform: 'none',
35
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
36
+ color: on ? activeTxt : inactiveTxt,
37
+ opacity: on ? 1 : 0.3,
38
+ bgcolor: on ? rgbaOf(highlight, dark ? 0.26 : 0.18) : 'transparent',
39
+ borderBottom: on ? `3px solid ${primary}` : '2px solid transparent',
40
+ transition: 'color .16s ease, background .16s ease, border-color .16s ease, opacity .16s ease',
41
+ '&:hover': on ? { bgcolor: rgbaOf(highlight, dark ? 0.26 : 0.18) } : { opacity: 1, bgcolor: 'rgba(150,160,175,0.35)', borderRadius: '8px', boxShadow: '0 6px 18px rgba(0,0,0,0.35)', transform: 'translateY(-2px)' },
42
+ }}
43
+ >
44
+ {t.label}
45
+ </Button>
46
+ );
47
+ return t.info ? <Tooltip key={t.key} title={t.info} arrow>{btn}</Tooltip> : btn;
48
+ })}
49
+ </Box>
50
+ );
51
+ }
@@ -0,0 +1,47 @@
1
+ import { create } from 'zustand';
2
+
3
+ // Preview screens, in the HTML app's order. label = header text, icon key = IconRail glyph.
4
+ export const SCREENS = [
5
+ { key: 'dashboard', label: 'Dashboard' },
6
+ { key: 'orders', label: 'Orders' },
7
+ { key: 'reports', label: 'Reports' },
8
+ { key: 'cameras', label: 'Cameras' },
9
+ { key: 'operations', label: 'Operations' },
10
+ { key: 'components', label: 'Components' },
11
+ { key: 'login', label: 'Login' },
12
+ ] as const;
13
+
14
+ export type ScreenKey = (typeof SCREENS)[number]['key'];
15
+
16
+ // The "Change preview screen" popover lists only these 6 (matches the HTML app's PVT).
17
+ export const PREVIEW_TABS: { key: ScreenKey; label: string }[] = [
18
+ { key: 'dashboard', label: 'Dashboard' },
19
+ { key: 'operations', label: 'Operations' },
20
+ { key: 'cameras', label: 'Cameras' },
21
+ { key: 'reports', label: 'Reports' },
22
+ { key: 'orders', label: 'Orders' },
23
+ { key: 'components', label: 'Components' },
24
+ ];
25
+
26
+ // Transient studio UI state (not part of the saved theme / dirty snapshot).
27
+ interface StudioUi {
28
+ openSection: string; // which rail section is expanded ('' = none)
29
+ setOpenSection: (id: string) => void;
30
+ previewScreen: ScreenKey;
31
+ setPreviewScreen: (k: ScreenKey) => void;
32
+ previewMenuOpen: boolean;
33
+ setPreviewMenuOpen: (v: boolean) => void;
34
+ studioOpen: boolean;
35
+ setStudioOpen: (v: boolean) => void;
36
+ }
37
+
38
+ export const useStudioUi = create<StudioUi>((set) => ({
39
+ openSection: '',
40
+ setOpenSection: (id) => set({ openSection: id }),
41
+ previewScreen: 'dashboard',
42
+ setPreviewScreen: (k) => set({ previewScreen: k }),
43
+ previewMenuOpen: false,
44
+ setPreviewMenuOpen: (v) => set({ previewMenuOpen: v }),
45
+ studioOpen: true,
46
+ setStudioOpen: (v) => set({ studioOpen: v }),
47
+ }));
@@ -0,0 +1,103 @@
1
+ import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
+ import { DEFAULT_SETTINGS, type ThemeSettings } from '../theme/settings';
4
+
5
+ export interface SavedTheme {
6
+ id: string;
7
+ name: string;
8
+ snap: ThemeSettings;
9
+ }
10
+
11
+ interface ThemeStore {
12
+ settings: ThemeSettings;
13
+ savedThemes: SavedTheme[];
14
+ baseline: string;
15
+ set: (patch: Partial<ThemeSettings>) => void;
16
+ applyPreset: (id: string, snap: Partial<ThemeSettings>) => void;
17
+ reset: () => void;
18
+ captureBaseline: () => void;
19
+ restoreBaseline: () => void;
20
+ isDirty: () => boolean;
21
+ saveTheme: (name: string) => void;
22
+ applySaved: (t: SavedTheme) => void;
23
+ renameSaved: (id: string, name: string) => void;
24
+ deleteSaved: (id: string) => void;
25
+ importTheme: (name: string, snap: ThemeSettings) => SavedTheme;
26
+ }
27
+
28
+ // Keys excluded from the dirty snapshot (transient / non-theme).
29
+ const snapshot = (s: ThemeSettings) => JSON.stringify(s);
30
+
31
+ export const useThemeStore = create<ThemeStore>()(
32
+ persist(
33
+ (setState, get) => ({
34
+ settings: { ...DEFAULT_SETTINGS },
35
+ savedThemes: [],
36
+ baseline: snapshot(DEFAULT_SETTINGS),
37
+
38
+ set: (patch) =>
39
+ setState((st) => ({ settings: { ...st.settings, ...patch, presetId: 'custom' } })),
40
+
41
+ applyPreset: (id, snap) =>
42
+ setState((st) => {
43
+ const next = { ...st.settings, ...snap, presetId: id };
44
+ return { settings: next, baseline: snapshot(next) };
45
+ }),
46
+
47
+ reset: () =>
48
+ setState(() => ({ settings: { ...DEFAULT_SETTINGS }, baseline: snapshot(DEFAULT_SETTINGS) })),
49
+
50
+ captureBaseline: () => setState((st) => ({ baseline: snapshot(st.settings) })),
51
+
52
+ restoreBaseline: () =>
53
+ setState((st) => {
54
+ try { return { settings: JSON.parse(st.baseline) as ThemeSettings }; }
55
+ catch { return {}; }
56
+ }),
57
+
58
+ isDirty: () => snapshot(get().settings) !== get().baseline,
59
+
60
+ saveTheme: (name) =>
61
+ setState((st) => {
62
+ const id = 'cust' + Date.now();
63
+ const snap = { ...st.settings };
64
+ return {
65
+ savedThemes: [...st.savedThemes, { id, name, snap }],
66
+ settings: { ...st.settings, presetId: id },
67
+ baseline: snapshot({ ...st.settings, presetId: id }),
68
+ };
69
+ }),
70
+
71
+ applySaved: (t) =>
72
+ setState(() => {
73
+ const next = { ...t.snap, presetId: t.id };
74
+ return { settings: next, baseline: snapshot(next) };
75
+ }),
76
+
77
+ renameSaved: (id, name) =>
78
+ setState((st) => ({
79
+ savedThemes: st.savedThemes.map((x) => (x.id === id ? { ...x, name } : x)),
80
+ })),
81
+
82
+ deleteSaved: (id) =>
83
+ setState((st) => ({ savedThemes: st.savedThemes.filter((x) => x.id !== id) })),
84
+
85
+ importTheme: (name, snap) => {
86
+ const existing = get().savedThemes.map((x) => x.name.toLowerCase());
87
+ let nm = name, i = 2;
88
+ while (existing.includes(nm.toLowerCase())) nm = `${name} ${i++}`;
89
+ const item: SavedTheme = { id: 'imp' + Date.now(), name: nm, snap };
90
+ setState((st) => {
91
+ const next = { ...snap, presetId: item.id };
92
+ return {
93
+ savedThemes: [...st.savedThemes, item],
94
+ settings: next,
95
+ baseline: snapshot(next),
96
+ };
97
+ });
98
+ return item;
99
+ },
100
+ }),
101
+ { name: 'omnieye-theme-studio' }
102
+ )
103
+ );