@fibery/ui-kit 1.40.2 → 1.40.4

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 (87) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +4 -7
  3. package/src/__snapshots__/design-system.test.ts.snap +7265 -0
  4. package/src/a11y-color.test.ts +181 -0
  5. package/src/a11y-color.ts +13 -20
  6. package/src/actions-menu/actions-menu-item.tsx +7 -7
  7. package/src/antd/ant-modal.tsx +10 -5
  8. package/src/antd/styles.ts +6 -6
  9. package/src/app-icon-with-fallback.tsx +2 -2
  10. package/src/app-icon.tsx +2 -2
  11. package/src/button/button.tsx +1 -0
  12. package/src/button/make-button-colors.ts +5 -13
  13. package/src/checkbox.tsx +2 -2
  14. package/src/color-picker/ColorPickerOrLoader.js +2 -2
  15. package/src/color-utils.test.ts +317 -0
  16. package/src/color-utils.ts +180 -0
  17. package/src/comment.tsx +3 -2
  18. package/src/context-menu/index.tsx +12 -7
  19. package/src/create-inline-theme.ts +9 -8
  20. package/src/design-system.colors.ts +760 -0
  21. package/src/design-system.test.ts +287 -7
  22. package/src/design-system.ts +146 -940
  23. package/src/dropdown-menu/index.tsx +21 -16
  24. package/src/emoji-picker/app-icon-picker.tsx +5 -5
  25. package/src/emoji-picker/emoji-picker-content-with-color.tsx +4 -4
  26. package/src/emoji-picker/icon-emoji-picker.tsx +2 -2
  27. package/src/favorites-icon.tsx +1 -1
  28. package/src/file-item/file-icon.tsx +169 -0
  29. package/src/file-item/file-menu-items.tsx +68 -0
  30. package/src/file-item/file-preview-actions.tsx +38 -0
  31. package/src/file-item/file-title.tsx +48 -0
  32. package/src/file-item/types.ts +27 -0
  33. package/src/file-item/use-register-in-image-gallery.tsx +70 -0
  34. package/src/file-item-2.tsx +35 -345
  35. package/src/file-item.tsx +6 -2
  36. package/src/hue-shift.test.ts +91 -0
  37. package/src/icons/Icon.tsx +1 -0
  38. package/src/icons/ast/FileCounter.ts +8 -0
  39. package/src/icons/ast/FileMultiple.ts +8 -0
  40. package/src/icons/ast/Print.ts +8 -0
  41. package/src/icons/ast/ValueEdit.ts +8 -0
  42. package/src/icons/ast/index.tsx +4 -0
  43. package/src/icons/react/Dividers.tsx +1 -1
  44. package/src/icons/react/FileCounter.tsx +13 -0
  45. package/src/icons/react/FileMultiple.tsx +13 -0
  46. package/src/icons/react/Print.tsx +13 -0
  47. package/src/icons/react/ValueEdit.tsx +13 -0
  48. package/src/icons/react/index.tsx +4 -0
  49. package/src/icons/svg/file-counter.svg +3 -0
  50. package/src/icons/svg/file-multiple.svg +3 -0
  51. package/src/icons/svg/print.svg +3 -0
  52. package/src/icons/svg/value-edit.svg +3 -0
  53. package/src/images-gallery/slide-buttons.tsx +4 -11
  54. package/src/lists/actions-menu-row-surface.tsx +5 -5
  55. package/src/mobile-keyboard-aware-popup.tsx +4 -3
  56. package/src/palette-generator.test.ts +566 -0
  57. package/src/palette-generator.ts +166 -0
  58. package/src/palette.ts +71 -55
  59. package/src/platform.ts +0 -3
  60. package/src/popover/index.tsx +20 -28
  61. package/src/progress.tsx +2 -2
  62. package/src/reactions/reaction-button.tsx +12 -6
  63. package/src/root-theme-provider.test.tsx +316 -0
  64. package/src/scale-generator.ts +347 -0
  65. package/src/select/components/menu-list-virtualized.tsx +7 -22
  66. package/src/select/components/menu.tsx +12 -2
  67. package/src/select/select.tsx +2 -1
  68. package/src/select/styles.ts +0 -1
  69. package/src/static-palettes.ts +146 -0
  70. package/src/thematic-color-picker.tsx +266 -0
  71. package/src/thematic-constants.tsx +27 -0
  72. package/src/thematic-controls.tsx +144 -0
  73. package/src/thematic-scales.tsx +122 -0
  74. package/src/thematic-state.ts +333 -0
  75. package/src/thematic.tsx +382 -0
  76. package/src/theme-provider.test.tsx +808 -0
  77. package/src/theme-provider.tsx +132 -69
  78. package/src/theme-settings.ts +1 -1
  79. package/src/theme-styles.ts +12 -5
  80. package/src/toast/toast-action.tsx +1 -1
  81. package/src/toggle-on-off.tsx +2 -2
  82. package/src/toggle.tsx +5 -6
  83. package/src/tooltip.tsx +13 -10
  84. package/src/type-badge.tsx +3 -3
  85. package/src/unit/styles.ts +2 -2
  86. package/src/unit/unit-with-tooltip.tsx +3 -2
  87. package/src/use-long-press.tsx +2 -2
@@ -0,0 +1,144 @@
1
+ import {css} from "@linaria/core";
2
+ import {space, themeVars} from "./design-system";
3
+ import type {Scale12} from "./palette-generator";
4
+
5
+ export const controlRowCss = css`
6
+ display: flex;
7
+ align-items: center;
8
+ gap: ${space.s8}px;
9
+ margin: ${space.s4}px 0;
10
+ `;
11
+
12
+ export const controlLabelCss = css`
13
+ width: 50px;
14
+ color: ${themeVars.colorTextSecondary};
15
+ `;
16
+
17
+ export const controlValueCss = css`
18
+ width: 45px;
19
+ text-align: right;
20
+ font-variant-numeric: tabular-nums;
21
+ `;
22
+
23
+ export const sliderCss = css`
24
+ flex: 1;
25
+ accent-color: ${themeVars.primaryBlue};
26
+ `;
27
+
28
+ export const clickableLabelCss = css`
29
+ cursor: pointer;
30
+ &:hover {
31
+ text-decoration: underline;
32
+ }
33
+ `;
34
+
35
+ export function ArrayEditor({
36
+ label,
37
+ values,
38
+ onChange,
39
+ }: {
40
+ label: string;
41
+ values: Scale12<number>;
42
+ onChange?: (v: Scale12<number>) => void;
43
+ }) {
44
+ const edit = onChange
45
+ ? () => {
46
+ const json = JSON.stringify(values);
47
+ const result = prompt(`${label} (12 values):`, json);
48
+ if (result && result !== json) {
49
+ try {
50
+ const parsed = JSON.parse(result);
51
+ if (Array.isArray(parsed) && parsed.length === 12) {
52
+ onChange(parsed as unknown as Scale12<number>);
53
+ } else {
54
+ alert("Need array with 12 values");
55
+ }
56
+ } catch {
57
+ alert("Invalid JSON");
58
+ }
59
+ }
60
+ }
61
+ : undefined;
62
+
63
+ return (
64
+ <span
65
+ className={`${controlLabelCss} ${onChange ? clickableLabelCss : ""}`}
66
+ onClick={edit}
67
+ title={onChange ? "Click to edit array" : undefined}
68
+ >
69
+ {label}
70
+ </span>
71
+ );
72
+ }
73
+
74
+ export function ArraySlider({
75
+ label,
76
+ values,
77
+ onValuesChange,
78
+ sliderValue,
79
+ onSliderChange,
80
+ min,
81
+ max,
82
+ step,
83
+ format = (v) => v.toFixed(3),
84
+ }: {
85
+ label: string;
86
+ values: Scale12<number>;
87
+ onValuesChange?: (v: Scale12<number>) => void;
88
+ sliderValue: number;
89
+ onSliderChange: (v: number) => void;
90
+ min: number;
91
+ max: number;
92
+ step: number;
93
+ format?: (v: number) => string;
94
+ }) {
95
+ return (
96
+ <div className={controlRowCss}>
97
+ <ArrayEditor label={label} values={values} onChange={onValuesChange} />
98
+ <input
99
+ type="range"
100
+ className={sliderCss}
101
+ min={min}
102
+ max={max}
103
+ step={step}
104
+ value={sliderValue}
105
+ onChange={(e) => onSliderChange(parseFloat(e.target.value))}
106
+ />
107
+ <span className={controlValueCss}>{format(sliderValue)}</span>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ export function Slider({
113
+ label,
114
+ value,
115
+ min,
116
+ max,
117
+ step,
118
+ format = (v) => v.toFixed(step < 1 ? 2 : 0),
119
+ onChange,
120
+ }: {
121
+ label: string;
122
+ value: number;
123
+ min: number;
124
+ max: number;
125
+ step: number;
126
+ format?: (v: number) => string;
127
+ onChange: (v: number) => void;
128
+ }) {
129
+ return (
130
+ <div className={controlRowCss}>
131
+ <span className={controlLabelCss}>{label}</span>
132
+ <input
133
+ type="range"
134
+ className={sliderCss}
135
+ min={min}
136
+ max={max}
137
+ step={step}
138
+ value={value}
139
+ onChange={(e) => onChange(parseFloat(e.target.value))}
140
+ />
141
+ <span className={controlValueCss}>{format(value)}</span>
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,122 @@
1
+ import {css} from "@linaria/core";
2
+ import {type ColorScale} from "./palette-generator";
3
+ import {OKLCH, produceColor} from "./color-utils";
4
+ import {space, themeVars} from "./design-system";
5
+ import {SwatchWithPicker} from "./thematic-color-picker";
6
+
7
+ export const scaleCss = css`
8
+ display: flex;
9
+ gap: 2px;
10
+ margin: ${space.s2}px 0;
11
+ `;
12
+
13
+ export const scaleRowContainerCss = css`
14
+ display: flex;
15
+ align-items: center;
16
+ `;
17
+
18
+ export const sectionLabelCss = css`
19
+ font-size: 10px;
20
+ color: ${themeVars.colorTextSecondary};
21
+ margin-bottom: 2px;
22
+ `;
23
+
24
+ export const sectionSeparatorCss = css`
25
+ height: 1px;
26
+ background: ${themeVars.separatorColor};
27
+ margin: ${space.s8}px 0;
28
+ `;
29
+
30
+ function formatTooltip(name: string, color: string, index: number): string {
31
+ const rgb = produceColor(color, []);
32
+ const oklch = produceColor(color, [], OKLCH);
33
+ return `${name}[${index}]\n${rgb}\n${oklch}`;
34
+ }
35
+
36
+ export function ScaleRow({
37
+ name,
38
+ scale,
39
+ onColorChange,
40
+ onHighlight,
41
+ onHighlightEnd,
42
+ isAltPressed,
43
+ }: {
44
+ name: string;
45
+ scale: ColorScale;
46
+ onColorChange?: (index: number, color: string) => void;
47
+ onHighlight?: (index: number) => void;
48
+ onHighlightEnd?: () => void;
49
+ isAltPressed?: boolean;
50
+ }) {
51
+ return (
52
+ <div className={scaleRowContainerCss}>
53
+ <div className={scaleCss}>
54
+ {scale.map((color, i) => (
55
+ <SwatchWithPicker
56
+ key={i}
57
+ color={color}
58
+ title={formatTooltip(name, color, i)}
59
+ onChange={onColorChange ? (c) => onColorChange(i, c) : undefined}
60
+ onHighlight={onHighlight ? () => onHighlight(i) : undefined}
61
+ onHighlightEnd={onHighlightEnd}
62
+ isAltPressed={isAltPressed}
63
+ />
64
+ ))}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ export function ScalePair({
71
+ name,
72
+ label,
73
+ light,
74
+ dark,
75
+ showLight = true,
76
+ showDark = true,
77
+ onLightColorChange,
78
+ onDarkColorChange,
79
+ onLightHighlight,
80
+ onDarkHighlight,
81
+ onHighlightEnd,
82
+ isAltPressed,
83
+ }: {
84
+ name: string;
85
+ label?: string;
86
+ light: ColorScale;
87
+ dark: ColorScale;
88
+ showLight?: boolean;
89
+ showDark?: boolean;
90
+ onLightColorChange?: (index: number, color: string) => void;
91
+ onDarkColorChange?: (index: number, color: string) => void;
92
+ onLightHighlight?: (index: number) => void;
93
+ onDarkHighlight?: (index: number) => void;
94
+ onHighlightEnd?: () => void;
95
+ isAltPressed?: boolean;
96
+ }) {
97
+ return (
98
+ <>
99
+ {label && <div className={sectionLabelCss}>{label}</div>}
100
+ {showLight && (
101
+ <ScaleRow
102
+ name={name}
103
+ scale={light}
104
+ onColorChange={onLightColorChange}
105
+ onHighlight={onLightHighlight}
106
+ onHighlightEnd={onHighlightEnd}
107
+ isAltPressed={isAltPressed}
108
+ />
109
+ )}
110
+ {showDark && (
111
+ <ScaleRow
112
+ name={`${name}Dark`}
113
+ scale={dark}
114
+ onColorChange={onDarkColorChange}
115
+ onHighlight={onDarkHighlight}
116
+ onHighlightEnd={onHighlightEnd}
117
+ isAltPressed={isAltPressed}
118
+ />
119
+ )}
120
+ </>
121
+ );
122
+ }
@@ -0,0 +1,333 @@
1
+ import {useCallback, useEffect, useMemo, useState} from "react";
2
+ import {useThematic, useThemeMode} from "./theme-provider";
3
+ import {
4
+ type ColorScale,
5
+ DEFAULT_DARK_CURVES,
6
+ DEFAULT_LIGHT_CURVES,
7
+ generateColorScale,
8
+ makeThematicPalette,
9
+ type ScaleCurves,
10
+ type ThematicPrefs,
11
+ type ThemePalette,
12
+ } from "./palette-generator";
13
+ import {defaultPalette} from "./theme-styles";
14
+ import {getThemePreference, setThemePreference, type ThemePreference} from "./theme-settings";
15
+ import {StaticPalettes} from "./static-palettes";
16
+ import {defaultPrefs, HIGHLIGHT_COLOR, nextThemePref} from "./thematic-constants";
17
+
18
+ interface StoredState {
19
+ prefs: ThematicPrefs | null;
20
+ staticPaletteIndex: number | null;
21
+ customPalette: Partial<ThemePalette> | null;
22
+ }
23
+
24
+ export type ThematicExport = {
25
+ scales: Partial<Omit<ThemePalette, "key">>;
26
+ meta?: {
27
+ prefs: ThematicPrefs | null;
28
+ };
29
+ };
30
+
31
+ const STORAGE_KEY = "thematic-palette";
32
+
33
+ function loadFromStorage(): StoredState | null {
34
+ try {
35
+ const raw = localStorage.getItem(STORAGE_KEY);
36
+ return raw ? JSON.parse(raw) : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function saveToStorage(data: StoredState) {
43
+ try {
44
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
45
+ } catch {
46
+ // ignore
47
+ }
48
+ }
49
+
50
+ const stored = loadFromStorage();
51
+
52
+ export function useThematicState() {
53
+ const {setPalette} = useThematic();
54
+ const themeMode = useThemeMode();
55
+
56
+ const [themePref, setThemePref] = useState<ThemePreference>(getThemePreference);
57
+ const isAuto = themePref === "auto";
58
+ const isLightMode = themeMode === "light" || themeMode === "light2";
59
+ const showLight = isAuto || isLightMode;
60
+ const showDark = isAuto || !isLightMode;
61
+
62
+ const [collapsed, setCollapsedRaw] = useState(() => localStorage.getItem("thematic-collapsed") !== "false");
63
+ const setCollapsed = useCallback((v: boolean) => {
64
+ setCollapsedRaw(v);
65
+ localStorage.setItem("thematic-collapsed", String(v));
66
+ }, []);
67
+ const [visible, setVisibleRaw] = useState(true);
68
+ const setVisible = useCallback((v: boolean) => {
69
+ setVisibleRaw(v);
70
+ if (!v) {
71
+ localStorage.setItem("thematic-collapsed", "true");
72
+ }
73
+ }, []);
74
+ const [isAltPressed, setIsAltPressed] = useState(false);
75
+ const [prefs, setPrefs] = useState<ThematicPrefs | null>(stored?.prefs ?? null);
76
+ const [staticPaletteIndex, setStaticPaletteIndex] = useState<number | null>(stored?.staticPaletteIndex ?? null);
77
+ const [customPalette, setCustomPalette] = useState<Partial<ThemePalette> | null>(stored?.customPalette ?? null);
78
+ const [baseCurvesLight, setBaseCurvesLight] = useState<ScaleCurves>(DEFAULT_LIGHT_CURVES);
79
+ const [baseCurvesDark, setBaseCurvesDark] = useState<ScaleCurves>(DEFAULT_DARK_CURVES);
80
+ const [accentCurvesLight, setAccentCurvesLight] = useState<ScaleCurves>(DEFAULT_LIGHT_CURVES);
81
+ const [accentCurvesDark, setAccentCurvesDark] = useState<ScaleCurves>(DEFAULT_DARK_CURVES);
82
+ const [highlight, setHighlight] = useState<{scale: keyof ThemePalette; index: number} | null>(null);
83
+
84
+ useEffect(() => {
85
+ const onKeyDown = (e: KeyboardEvent) => e.altKey && setIsAltPressed(true);
86
+ const onKeyUp = (e: KeyboardEvent) => !e.altKey && setIsAltPressed(false);
87
+ window.addEventListener("keydown", onKeyDown);
88
+ window.addEventListener("keyup", onKeyUp);
89
+ return () => {
90
+ window.removeEventListener("keydown", onKeyDown);
91
+ window.removeEventListener("keyup", onKeyUp);
92
+ };
93
+ }, []);
94
+
95
+ const prefsPresent = prefs ?? defaultPrefs;
96
+ const staticPalette = staticPaletteIndex !== null ? StaticPalettes[staticPaletteIndex] : null;
97
+
98
+ const baseCurvesLightChanged = baseCurvesLight !== DEFAULT_LIGHT_CURVES;
99
+ const baseCurvesDarkChanged = baseCurvesDark !== DEFAULT_DARK_CURVES;
100
+ const accentCurvesLightChanged = accentCurvesLight !== DEFAULT_LIGHT_CURVES;
101
+ const accentCurvesDarkChanged = accentCurvesDark !== DEFAULT_DARK_CURVES;
102
+ const anyCurvesChanged =
103
+ baseCurvesLightChanged || baseCurvesDarkChanged || accentCurvesLightChanged || accentCurvesDarkChanged;
104
+
105
+ const palette = useMemo(() => {
106
+ const base = staticPalette ? staticPalette.make() : prefs ? makeThematicPalette(prefs) : defaultPalette;
107
+ let result = customPalette ? {...base, ...customPalette} : base;
108
+
109
+ if (baseCurvesLightChanged) {
110
+ result = {
111
+ ...result,
112
+ base: generateColorScale({
113
+ hue: prefsPresent.baseHue ?? 0,
114
+ chroma: prefsPresent.baseChroma ?? 0,
115
+ mode: "light",
116
+ curves: {lightness: baseCurvesLight.lightness, chroma: baseCurvesLight.chroma},
117
+ contrast: prefsPresent.contrast,
118
+ }),
119
+ };
120
+ }
121
+ if (baseCurvesDarkChanged) {
122
+ result = {
123
+ ...result,
124
+ baseDark: generateColorScale({
125
+ hue: prefsPresent.baseHue ?? 0,
126
+ chroma: prefsPresent.baseChroma ?? 0,
127
+ mode: "dark",
128
+ curves: {lightness: baseCurvesDark.lightness, chroma: baseCurvesDark.chroma},
129
+ contrast: prefsPresent.contrast,
130
+ }),
131
+ };
132
+ }
133
+ if (accentCurvesLightChanged) {
134
+ result = {
135
+ ...result,
136
+ accent: generateColorScale({
137
+ hue: prefsPresent.accentHue,
138
+ chroma: prefsPresent.accentChroma,
139
+ mode: "light",
140
+ curves: {lightness: accentCurvesLight.lightness, chroma: accentCurvesLight.chroma},
141
+ contrast: prefsPresent.contrast,
142
+ }),
143
+ };
144
+ }
145
+ if (accentCurvesDarkChanged) {
146
+ result = {
147
+ ...result,
148
+ accentDark: generateColorScale({
149
+ hue: prefsPresent.accentHue,
150
+ chroma: prefsPresent.accentChroma,
151
+ mode: "dark",
152
+ curves: {lightness: accentCurvesDark.lightness, chroma: accentCurvesDark.chroma},
153
+ contrast: prefsPresent.contrast,
154
+ }),
155
+ };
156
+ }
157
+ if (customPalette) {
158
+ const customKey = Object.entries(customPalette)
159
+ .map(([k, v]) => `${k}:${Array.isArray(v) ? v.join(",") : v}`)
160
+ .join("|");
161
+ result = {...result, key: `${result.key}-custom-${customKey}`};
162
+ }
163
+ return result;
164
+ }, [
165
+ prefs,
166
+ prefsPresent,
167
+ staticPalette,
168
+ customPalette,
169
+ baseCurvesLight,
170
+ baseCurvesDark,
171
+ accentCurvesLight,
172
+ accentCurvesDark,
173
+ baseCurvesLightChanged,
174
+ baseCurvesDarkChanged,
175
+ accentCurvesLightChanged,
176
+ accentCurvesDarkChanged,
177
+ ]);
178
+
179
+ useEffect(() => {
180
+ if (highlight) {
181
+ const scale = [...palette[highlight.scale]] as unknown as string[];
182
+ scale[highlight.index] = HIGHLIGHT_COLOR;
183
+ setPalette({
184
+ ...palette,
185
+ key: `${palette.key}-highlight-${highlight.scale}-${highlight.index}`,
186
+ [highlight.scale]: scale as unknown as ColorScale,
187
+ });
188
+ } else {
189
+ setPalette(palette);
190
+ }
191
+ }, [palette, highlight, setPalette]);
192
+
193
+ const update = (partial: Partial<ThematicPrefs>) => {
194
+ setStaticPaletteIndex(null);
195
+ setCustomPalette(null);
196
+ setPrefs({...prefsPresent, ...partial});
197
+ };
198
+
199
+ const randomize = () => {
200
+ setStaticPaletteIndex(null);
201
+ setPrefs({
202
+ baseHue: Math.random() * 360,
203
+ baseChroma: Math.random() * 0.1,
204
+ accentHue: Math.random() * 360,
205
+ accentChroma: 0.08 + Math.random() * 0.12,
206
+ contrast: 0.85 + Math.random() * 0.3,
207
+ });
208
+ };
209
+
210
+ const cycleStaticPalettes = () => {
211
+ setPrefs(null);
212
+ setStaticPaletteIndex((prev) => (prev === null ? 0 : (prev + 1) % StaticPalettes.length));
213
+ };
214
+
215
+ const cycleTheme = () => {
216
+ const next = isAltPressed ? "auto" : nextThemePref[themePref];
217
+ setThemePreference(next);
218
+ setThemePref(next);
219
+ };
220
+
221
+ const reset = () => {
222
+ setStaticPaletteIndex(null);
223
+ setPrefs(null);
224
+ setCustomPalette(null);
225
+ setBaseCurvesLight(DEFAULT_LIGHT_CURVES);
226
+ setBaseCurvesDark(DEFAULT_DARK_CURVES);
227
+ setAccentCurvesLight(DEFAULT_LIGHT_CURVES);
228
+ setAccentCurvesDark(DEFAULT_DARK_CURVES);
229
+ };
230
+
231
+ const updateScaleColor = useCallback(
232
+ (scaleName: keyof ThemePalette, index: number, color: string) => {
233
+ setCustomPalette((prev) => {
234
+ const currentScale = prev?.[scaleName] ?? palette[scaleName];
235
+ const mutableScale = [...currentScale] as unknown as string[];
236
+ mutableScale[index] = color;
237
+ return {...prev, [scaleName]: mutableScale as unknown as ColorScale};
238
+ });
239
+ },
240
+ [palette]
241
+ );
242
+
243
+ const highlightScale = useCallback(
244
+ (scale: keyof ThemePalette) => (index: number) => {
245
+ setHighlight({scale, index});
246
+ },
247
+ []
248
+ );
249
+
250
+ const clearHighlight = useCallback(() => setHighlight(null), []);
251
+
252
+ const isModified = prefs !== null || staticPaletteIndex !== null || customPalette !== null || anyCurvesChanged;
253
+
254
+ useEffect(() => {
255
+ saveToStorage({prefs, staticPaletteIndex, customPalette});
256
+ }, [prefs, staticPaletteIndex, customPalette]);
257
+
258
+ const editingDark = !isAuto && !isLightMode;
259
+ const baseCurves = editingDark ? baseCurvesDark : baseCurvesLight;
260
+ const setBaseCurves = editingDark ? setBaseCurvesDark : setBaseCurvesLight;
261
+ const accentCurves = editingDark ? accentCurvesDark : accentCurvesLight;
262
+ const setAccentCurves = editingDark ? setAccentCurvesDark : setAccentCurvesLight;
263
+
264
+ const exportState = useCallback((): ThematicExport => {
265
+ const keys: (keyof Omit<ThemePalette, "key">)[] = ["white", "black"];
266
+ if (showLight) {
267
+ keys.push("base", "accent", "red", "teal", "yellow");
268
+ }
269
+ if (showDark) {
270
+ keys.push("baseDark", "accentDark", "redDark", "tealDark", "yellowDark");
271
+ }
272
+ const scales: Partial<Omit<ThemePalette, "key">> = {};
273
+ for (const k of keys) {
274
+ scales[k] = palette[k];
275
+ }
276
+ const bothModes = showLight && showDark;
277
+ return {scales, ...(bothModes ? {meta: {prefs}} : undefined)};
278
+ }, [palette, showLight, showDark, prefs]);
279
+
280
+ const importState = useCallback((data: ThematicExport) => {
281
+ if (data.meta) {
282
+ setPrefs(data.meta.prefs);
283
+ setStaticPaletteIndex(null);
284
+ }
285
+ const hasLight = "base" in data.scales;
286
+ const hasDark = "baseDark" in data.scales;
287
+ if (hasLight && !hasDark) {
288
+ setThemePreference("light");
289
+ setThemePref("light");
290
+ } else if (hasDark && !hasLight) {
291
+ setThemePreference("dark");
292
+ setThemePref("dark");
293
+ }
294
+ setCustomPalette(
295
+ (prev) =>
296
+ ({
297
+ ...prev,
298
+ ...data.scales,
299
+ key: `import-${Date.now()}`,
300
+ } as Partial<ThemePalette>)
301
+ );
302
+ }, []);
303
+
304
+ return {
305
+ palette,
306
+ isAltPressed,
307
+ collapsed,
308
+ setCollapsed,
309
+ visible,
310
+ setVisible,
311
+ themePref,
312
+ isModified,
313
+ showLight,
314
+ showDark,
315
+ isAuto,
316
+ prefsPresent,
317
+ staticPalette,
318
+ baseCurves,
319
+ setBaseCurves,
320
+ accentCurves,
321
+ setAccentCurves,
322
+ update,
323
+ randomize,
324
+ cycleStaticPalettes,
325
+ cycleTheme,
326
+ reset,
327
+ updateScaleColor,
328
+ highlightScale,
329
+ clearHighlight,
330
+ exportState,
331
+ importState,
332
+ };
333
+ }