@fibery/ui-kit 1.40.3 → 1.41.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 (174) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/eslint.config.mjs +16 -0
  3. package/package.json +11 -13
  4. package/src/a11y-color.test.ts +162 -0
  5. package/src/a11y-color.ts +13 -20
  6. package/src/actions-menu/actions-menu-item.tsx +9 -12
  7. package/src/actions-menu/context-actions-menu.tsx +8 -3
  8. package/src/ai/model.tsx +0 -1
  9. package/src/ai/temperature.tsx +0 -1
  10. package/src/animated-height-container.tsx +3 -3
  11. package/src/antd/ant-modal.tsx +10 -5
  12. package/src/antd/index.tsx +3 -0
  13. package/src/antd/input.tsx +0 -1
  14. package/src/antd/styles.ts +15 -15
  15. package/src/app-icon-with-fallback.tsx +6 -6
  16. package/src/app-icon-wrapper.tsx +2 -3
  17. package/src/app-icon.tsx +4 -4
  18. package/src/avatar.tsx +3 -3
  19. package/src/button/base-button.tsx +0 -1
  20. package/src/button/button.tsx +1 -0
  21. package/src/button/make-button-colors.ts +5 -13
  22. package/src/checkbox.tsx +2 -2
  23. package/src/collapsible-section.tsx +11 -1
  24. package/src/color-picker/ColorPickerOrLoader.js +2 -2
  25. package/src/color-utils.test.ts +307 -0
  26. package/src/color-utils.ts +215 -0
  27. package/src/command-menu/index.tsx +1 -0
  28. package/src/comment.tsx +3 -2
  29. package/src/context-menu/index.tsx +12 -7
  30. package/src/date-picker/contexts.ts +2 -2
  31. package/src/date-picker/date-range-picker.tsx +3 -4
  32. package/src/date-picker/single-date-picker.tsx +147 -119
  33. package/src/date-picker/types.ts +4 -3
  34. package/src/day-select/iso-week-day-select.tsx +2 -2
  35. package/src/day-select/week-day-select.tsx +2 -2
  36. package/src/delayed.tsx +0 -1
  37. package/src/design-system/alpha.test.ts +59 -0
  38. package/src/design-system/alpha.ts +41 -0
  39. package/src/design-system/animation.ts +10 -0
  40. package/src/design-system/colors-css.test.ts +39 -0
  41. package/src/design-system/colors-css.ts +25 -0
  42. package/src/design-system/colors-js.test.ts +232 -0
  43. package/src/design-system/colors-js.ts +107 -0
  44. package/src/design-system/colors.test.ts +74 -0
  45. package/src/design-system/colors.ts +759 -0
  46. package/src/design-system/date.ts +3 -0
  47. package/src/design-system/layout.ts +106 -0
  48. package/src/design-system/theme.test.ts +94 -0
  49. package/src/design-system/theme.ts +93 -0
  50. package/src/design-system/typography.ts +179 -0
  51. package/src/design-system/vars.test.ts +1679 -0
  52. package/src/design-system/vars.ts +52 -0
  53. package/src/design-system.test.ts +45 -22
  54. package/src/design-system.ts +25 -1339
  55. package/src/dropdown-menu/index.tsx +21 -16
  56. package/src/emoji-picker/app-icon-picker.tsx +5 -5
  57. package/src/emoji-picker/emoji-picker-content-with-color.tsx +4 -4
  58. package/src/emoji-picker/icon-emoji-picker.tsx +3 -3
  59. package/src/favorites-icon.tsx +1 -1
  60. package/src/fibermoji-placeholder.tsx +3 -3
  61. package/src/field-container.tsx +11 -2
  62. package/src/file-item/file-icon.tsx +169 -0
  63. package/src/file-item/file-menu-items.tsx +68 -0
  64. package/src/file-item/file-preview-actions.tsx +38 -0
  65. package/src/file-item/file-title.tsx +48 -0
  66. package/src/file-item/types.ts +27 -0
  67. package/src/file-item/use-register-in-image-gallery.tsx +70 -0
  68. package/src/file-item-2.tsx +32 -348
  69. package/src/file-item.tsx +6 -3
  70. package/src/hue-shift.test.ts +91 -0
  71. package/src/icons/ast/ChatBubble.ts +8 -0
  72. package/src/icons/ast/ChatFloat.ts +8 -0
  73. package/src/icons/ast/ChatSidebar.ts +8 -0
  74. package/src/icons/ast/FileCounter.ts +8 -0
  75. package/src/icons/ast/FileMultiple.ts +8 -0
  76. package/src/icons/ast/FileOther.ts +8 -0
  77. package/src/icons/ast/NoBorder.ts +8 -0
  78. package/src/icons/ast/NoFill.ts +8 -0
  79. package/src/icons/ast/ValueEdit.ts +8 -0
  80. package/src/icons/ast/index.tsx +9 -0
  81. package/src/icons/react/ChatBubble.tsx +13 -0
  82. package/src/icons/react/ChatFloat.tsx +13 -0
  83. package/src/icons/react/ChatSidebar.tsx +13 -0
  84. package/src/icons/react/FileCounter.tsx +13 -0
  85. package/src/icons/react/FileMultiple.tsx +13 -0
  86. package/src/icons/react/FileOther.tsx +13 -0
  87. package/src/icons/react/NoBorder.tsx +13 -0
  88. package/src/icons/react/NoFill.tsx +13 -0
  89. package/src/icons/react/ValueEdit.tsx +13 -0
  90. package/src/icons/react/index.tsx +9 -0
  91. package/src/icons/svg/chat-bubble.svg +4 -0
  92. package/src/icons/svg/chat-float.svg +4 -0
  93. package/src/icons/svg/chat-sidebar.svg +4 -0
  94. package/src/icons/svg/file-counter.svg +3 -0
  95. package/src/icons/svg/file-multiple.svg +3 -0
  96. package/src/icons/svg/file-other.svg +3 -0
  97. package/src/icons/svg/no-border.svg +17 -0
  98. package/src/icons/svg/no-fill.svg +4 -0
  99. package/src/icons/svg/value-edit.svg +3 -0
  100. package/src/images-gallery/images-gallery.tsx +8 -6
  101. package/src/images-gallery/slide-buttons.tsx +4 -11
  102. package/src/is-iOS.ts +0 -1
  103. package/src/is-in-popup.ts +2 -1
  104. package/src/lists/actions-menu-row-surface.tsx +7 -7
  105. package/src/loading-sausage.tsx +2 -2
  106. package/src/media-query-utils.ts +1 -2
  107. package/src/mobile-keyboard-aware-popup.tsx +6 -5
  108. package/src/modal-menu/modal-menu-content.tsx +2 -2
  109. package/src/number-input/decimal.js +9 -7
  110. package/src/number-input/{number-inline-input-with-autosize.tsx → number-input-inline-with-autosize.tsx} +20 -41
  111. package/src/number-input/number-input-inline.tsx +118 -0
  112. package/src/number-input/{index.test.js → number-input.test.tsx} +38 -43
  113. package/src/number-input/number-input.tsx +63 -121
  114. package/src/number-input/types.ts +19 -0
  115. package/src/number-input/utils.ts +61 -0
  116. package/src/online-users.tsx +2 -2
  117. package/src/palette-generator.test.ts +309 -0
  118. package/src/palette-generator.ts +160 -0
  119. package/src/palettes/_.ts +72 -0
  120. package/src/palettes/slate-arch.colors-snapshot.test.ts +1689 -0
  121. package/src/palettes/slate-arch.ts +188 -0
  122. package/src/palettes/slate-user.colors-snapshot.test.ts +1689 -0
  123. package/src/palettes/slate-user.ts +187 -0
  124. package/src/palettes/warm-arch.colors-snapshot.test.ts +1689 -0
  125. package/src/palettes/warm-arch.ts +222 -0
  126. package/src/palettes/warm-user.colors-snapshot.test.ts +1689 -0
  127. package/src/palettes/warm-user.ts +222 -0
  128. package/src/platform.ts +0 -3
  129. package/src/popover/get-element-ref.ts +28 -0
  130. package/src/popover/index.tsx +236 -339
  131. package/src/popover/{mobile-popup-context.tsx → mobile-popover-context.tsx} +7 -7
  132. package/src/popover/mobile-popover.tsx +169 -0
  133. package/src/popover/modifiers.tsx +2 -2
  134. package/src/popover/popup-stack-context.tsx +8 -9
  135. package/src/progress.tsx +2 -2
  136. package/src/reactions/reaction-button.tsx +12 -6
  137. package/src/root-theme-provider.test.tsx +411 -0
  138. package/src/scale-generator.ts +356 -0
  139. package/src/select/components/menu-list-virtualized.tsx +12 -25
  140. package/src/select/components/menu.tsx +12 -2
  141. package/src/select/index.tsx +6 -5
  142. package/src/select/select.tsx +38 -39
  143. package/src/select/styles.ts +0 -1
  144. package/src/select/util.ts +1 -1
  145. package/src/static-palettes.ts +356 -0
  146. package/src/thematic-color-picker.tsx +266 -0
  147. package/src/thematic-constants.tsx +27 -0
  148. package/src/thematic-controls.tsx +144 -0
  149. package/src/thematic-scales.tsx +122 -0
  150. package/src/thematic-state.ts +370 -0
  151. package/src/thematic.tsx +386 -0
  152. package/src/theme-provider.test.tsx +820 -0
  153. package/src/theme-provider.tsx +158 -92
  154. package/src/theme-settings.ts +67 -12
  155. package/src/theme-styles.ts +58 -6
  156. package/src/toast/toast-action.tsx +1 -1
  157. package/src/toast/toast.tsx +1 -2
  158. package/src/toggle-on-off.tsx +2 -2
  159. package/src/toggle.tsx +7 -8
  160. package/src/tooltip.tsx +14 -10
  161. package/src/type-badge.tsx +7 -14
  162. package/src/unit/styles.ts +2 -25
  163. package/src/unit/unit-with-tooltip.tsx +3 -2
  164. package/src/use-is-phone.tsx +7 -2
  165. package/src/use-long-press.tsx +2 -2
  166. package/src/use-on-screen-keyboard-data.tsx +2 -2
  167. package/src/with-data.tsx +4 -3
  168. package/src/workflow-progress-icon.tsx +2 -2
  169. package/.eslintignore +0 -3
  170. package/.eslintrc +0 -14
  171. package/src/__mocks__/createInlineTheme.js +0 -3
  172. package/src/create-inline-theme.ts +0 -66
  173. package/src/number-input/index.js +0 -191
  174. package/src/palette.ts +0 -237
@@ -0,0 +1,144 @@
1
+ import {css} from "@linaria/core";
2
+ import {space, themeVars} from "./design-system";
3
+ import type {ZeroBasedScale} 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: ZeroBasedScale<number>;
42
+ onChange?: (v: ZeroBasedScale<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 ZeroBasedScale<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: ZeroBasedScale<number>;
87
+ onValuesChange?: (v: ZeroBasedScale<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 {toOKLCH, toRGB} 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
+ return `${name}[${index}]\n${toRGB(color)}\n${toOKLCH(color)}`;
32
+ }
33
+
34
+ export function ScaleRow({
35
+ name,
36
+ scale,
37
+ onColorChange,
38
+ onHighlight,
39
+ onHighlightEnd,
40
+ isAltPressed,
41
+ }: {
42
+ name: string;
43
+ scale: ColorScale;
44
+ onColorChange?: (index: number, color: string) => void;
45
+ onHighlight?: (index: number) => void;
46
+ onHighlightEnd?: () => void;
47
+ isAltPressed?: boolean;
48
+ }) {
49
+ return (
50
+ <div className={scaleRowContainerCss}>
51
+ <div className={scaleCss}>
52
+ {scale.map((color, i) =>
53
+ color === null ? null : (
54
+ <SwatchWithPicker
55
+ key={i}
56
+ color={color}
57
+ title={formatTooltip(name, color, i)}
58
+ onChange={onColorChange ? (c) => onColorChange(i, c) : undefined}
59
+ onHighlight={onHighlight ? () => onHighlight(i) : undefined}
60
+ onHighlightEnd={onHighlightEnd}
61
+ isAltPressed={isAltPressed}
62
+ />
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,370 @@
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 {getThemePreference, setThemePreference, type ThemePreference} from "./theme-settings";
14
+ import {StaticPalettes} from "./static-palettes";
15
+ import {defaultPrefs, HIGHLIGHT_COLOR, nextThemePref} from "./thematic-constants";
16
+ import {useToast} from "./toast/toast-provider";
17
+
18
+ interface StoredState {
19
+ prefs: ThematicPrefs | null;
20
+ staticPaletteIndex: number | null;
21
+ customPalette: Partial<ThemePalette> | null;
22
+ }
23
+
24
+ export type ThematicExport = Partial<Omit<ThemePalette, "key">> & {
25
+ meta?: {
26
+ prefs: ThematicPrefs | null;
27
+ };
28
+ };
29
+
30
+ const STORAGE_KEY = "thematic-palette";
31
+
32
+ function isValidPalette(p: Partial<ThemePalette> | null): boolean {
33
+ if (!p) {
34
+ return true;
35
+ }
36
+ return Array.isArray(p.whiteA) && Array.isArray(p.blackA);
37
+ }
38
+
39
+ function loadFromStorage(): StoredState | null {
40
+ try {
41
+ const raw = localStorage.getItem(STORAGE_KEY);
42
+ if (!raw) {
43
+ return null;
44
+ }
45
+ const parsed = JSON.parse(raw) as StoredState;
46
+ if (!isValidPalette(parsed.customPalette)) {
47
+ localStorage.removeItem(STORAGE_KEY);
48
+ return null;
49
+ }
50
+ return parsed;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function saveToStorage(data: StoredState) {
57
+ try {
58
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
59
+ } catch {
60
+ // ignore
61
+ }
62
+ }
63
+
64
+ const stored = loadFromStorage();
65
+
66
+ export function useThematicState() {
67
+ const {palette: providerPalette, setPalette} = useThematic();
68
+ const themeMode = useThemeMode();
69
+ const toast = useToast();
70
+
71
+ const [themePref, setThemePref] = useState<ThemePreference>(getThemePreference);
72
+ const isAuto = themePref === "auto";
73
+ const isLightMode = themeMode === "light" || themeMode === "light2";
74
+ const showLight = isAuto || isLightMode;
75
+ const showDark = isAuto || !isLightMode;
76
+
77
+ const [collapsed, setCollapsedRaw] = useState(() => localStorage.getItem("thematic-collapsed") !== "false");
78
+ const setCollapsed = useCallback((v: boolean) => {
79
+ setCollapsedRaw(v);
80
+ localStorage.setItem("thematic-collapsed", String(v));
81
+ }, []);
82
+ const [visible, setVisibleRaw] = useState(true);
83
+ const setVisible = useCallback((v: boolean) => {
84
+ setVisibleRaw(v);
85
+ if (!v) {
86
+ localStorage.setItem("thematic-collapsed", "true");
87
+ }
88
+ }, []);
89
+ const [isAltPressed, setIsAltPressed] = useState(false);
90
+ const [prefs, setPrefs] = useState<ThematicPrefs | null>(stored?.prefs ?? null);
91
+ const [staticPaletteIndex, setStaticPaletteIndex] = useState<number | null>(stored?.staticPaletteIndex ?? null);
92
+ const [customPalette, setCustomPalette] = useState<Partial<ThemePalette> | null>(stored?.customPalette ?? null);
93
+ const [baseCurvesLight, setBaseCurvesLight] = useState<ScaleCurves>(DEFAULT_LIGHT_CURVES);
94
+ const [baseCurvesDark, setBaseCurvesDark] = useState<ScaleCurves>(DEFAULT_DARK_CURVES);
95
+ const [accentCurvesLight, setAccentCurvesLight] = useState<ScaleCurves>(DEFAULT_LIGHT_CURVES);
96
+ const [accentCurvesDark, setAccentCurvesDark] = useState<ScaleCurves>(DEFAULT_DARK_CURVES);
97
+ const [highlight, setHighlight] = useState<{scale: keyof ThemePalette; index: number} | null>(null);
98
+
99
+ useEffect(() => {
100
+ const onKeyDown = (e: KeyboardEvent) => e.altKey && setIsAltPressed(true);
101
+ const onKeyUp = (e: KeyboardEvent) => !e.altKey && setIsAltPressed(false);
102
+ window.addEventListener("keydown", onKeyDown);
103
+ window.addEventListener("keyup", onKeyUp);
104
+ return () => {
105
+ window.removeEventListener("keydown", onKeyDown);
106
+ window.removeEventListener("keyup", onKeyUp);
107
+ };
108
+ }, []);
109
+
110
+ const prefsPresent = prefs ?? defaultPrefs;
111
+ const staticPalette = staticPaletteIndex !== null ? StaticPalettes[staticPaletteIndex] : null;
112
+
113
+ const baseCurvesLightChanged = baseCurvesLight !== DEFAULT_LIGHT_CURVES;
114
+ const baseCurvesDarkChanged = baseCurvesDark !== DEFAULT_DARK_CURVES;
115
+ const accentCurvesLightChanged = accentCurvesLight !== DEFAULT_LIGHT_CURVES;
116
+ const accentCurvesDarkChanged = accentCurvesDark !== DEFAULT_DARK_CURVES;
117
+ const anyCurvesChanged =
118
+ baseCurvesLightChanged || baseCurvesDarkChanged || accentCurvesLightChanged || accentCurvesDarkChanged;
119
+
120
+ const palette = useMemo(() => {
121
+ const base = staticPalette ? staticPalette.make() : prefs ? makeThematicPalette(prefs) : providerPalette;
122
+ let result = customPalette ? {...base, ...customPalette} : base;
123
+
124
+ if (baseCurvesLightChanged) {
125
+ result = {
126
+ ...result,
127
+ base: generateColorScale({
128
+ hue: prefsPresent.baseHue ?? 0,
129
+ chroma: prefsPresent.baseChroma ?? 0,
130
+ mode: "light",
131
+ curves: {lightness: baseCurvesLight.lightness, chroma: baseCurvesLight.chroma},
132
+ contrast: prefsPresent.contrast,
133
+ }),
134
+ };
135
+ }
136
+ if (baseCurvesDarkChanged) {
137
+ result = {
138
+ ...result,
139
+ baseDark: generateColorScale({
140
+ hue: prefsPresent.baseHue ?? 0,
141
+ chroma: prefsPresent.baseChroma ?? 0,
142
+ mode: "dark",
143
+ curves: {lightness: baseCurvesDark.lightness, chroma: baseCurvesDark.chroma},
144
+ contrast: prefsPresent.contrast,
145
+ }),
146
+ };
147
+ }
148
+ if (accentCurvesLightChanged) {
149
+ result = {
150
+ ...result,
151
+ accent: generateColorScale({
152
+ hue: prefsPresent.accentHue,
153
+ chroma: prefsPresent.accentChroma,
154
+ mode: "light",
155
+ curves: {lightness: accentCurvesLight.lightness, chroma: accentCurvesLight.chroma},
156
+ contrast: prefsPresent.contrast,
157
+ }),
158
+ };
159
+ }
160
+ if (accentCurvesDarkChanged) {
161
+ result = {
162
+ ...result,
163
+ accentDark: generateColorScale({
164
+ hue: prefsPresent.accentHue,
165
+ chroma: prefsPresent.accentChroma,
166
+ mode: "dark",
167
+ curves: {lightness: accentCurvesDark.lightness, chroma: accentCurvesDark.chroma},
168
+ contrast: prefsPresent.contrast,
169
+ }),
170
+ };
171
+ }
172
+ if (customPalette) {
173
+ const customKey = Object.entries(customPalette)
174
+ .map(([k, v]) => `${k}:${Array.isArray(v) ? v.join(",") : v}`)
175
+ .join("|");
176
+ result = {...result, key: `${result.key}-custom-${customKey}`};
177
+ }
178
+ return result;
179
+ }, [
180
+ prefs,
181
+ prefsPresent,
182
+ staticPalette,
183
+ customPalette,
184
+ providerPalette,
185
+ baseCurvesLight,
186
+ baseCurvesDark,
187
+ accentCurvesLight,
188
+ accentCurvesDark,
189
+ baseCurvesLightChanged,
190
+ baseCurvesDarkChanged,
191
+ accentCurvesLightChanged,
192
+ accentCurvesDarkChanged,
193
+ ]);
194
+
195
+ useEffect(() => {
196
+ if (highlight) {
197
+ const scale = [...palette[highlight.scale]] as unknown as string[];
198
+ scale[highlight.index] = HIGHLIGHT_COLOR;
199
+ setPalette({
200
+ ...palette,
201
+ key: `${palette.key}-highlight-${highlight.scale}-${highlight.index}`,
202
+ [highlight.scale]: scale as unknown as ColorScale,
203
+ });
204
+ } else if (prefs !== null || staticPalette !== null || customPalette !== null || anyCurvesChanged) {
205
+ if (palette.key !== providerPalette.key) {
206
+ setPalette(palette);
207
+ }
208
+ } else {
209
+ setPalette(null);
210
+ }
211
+ }, [palette, providerPalette, highlight, prefs, staticPalette, customPalette, anyCurvesChanged, setPalette]);
212
+
213
+ const update = (partial: Partial<ThematicPrefs>) => {
214
+ setStaticPaletteIndex(null);
215
+ setCustomPalette(null);
216
+ setPrefs({...prefsPresent, ...partial});
217
+ };
218
+
219
+ const randomize = () => {
220
+ setStaticPaletteIndex(null);
221
+ setPrefs({
222
+ baseHue: Math.random() * 360,
223
+ baseChroma: Math.random() * 0.1,
224
+ accentHue: Math.random() * 360,
225
+ accentChroma: 0.08 + Math.random() * 0.12,
226
+ contrast: 0.85 + Math.random() * 0.3,
227
+ });
228
+ };
229
+
230
+ const cycleStaticPalettes = () => {
231
+ setPrefs(null);
232
+ setStaticPaletteIndex((prev) => {
233
+ const next = prev === null ? 0 : (prev + 1) % StaticPalettes.length;
234
+ const key = StaticPalettes[next].make().key;
235
+ toast.info({title: key});
236
+ return next;
237
+ });
238
+ };
239
+
240
+ const cycleTheme = () => {
241
+ const next = isAltPressed ? "auto" : nextThemePref[themePref];
242
+ setThemePreference(next);
243
+ setThemePref(next);
244
+ };
245
+
246
+ const reset = () => {
247
+ setStaticPaletteIndex(null);
248
+ setPrefs(null);
249
+ setCustomPalette(null);
250
+ setBaseCurvesLight(DEFAULT_LIGHT_CURVES);
251
+ setBaseCurvesDark(DEFAULT_DARK_CURVES);
252
+ setAccentCurvesLight(DEFAULT_LIGHT_CURVES);
253
+ setAccentCurvesDark(DEFAULT_DARK_CURVES);
254
+ saveToStorage({prefs: null, staticPaletteIndex: null, customPalette: null});
255
+ };
256
+
257
+ const updateScaleColor = useCallback(
258
+ (scaleName: keyof ThemePalette, index: number, color: string) => {
259
+ setCustomPalette((prev) => {
260
+ const currentValue = prev?.[scaleName] ?? palette[scaleName];
261
+ if (typeof currentValue === "string") {
262
+ return {...prev, [scaleName]: color};
263
+ }
264
+ const mutableScale = [...currentValue] as unknown as string[];
265
+ mutableScale[index] = color;
266
+ return {...prev, [scaleName]: mutableScale as unknown as ColorScale};
267
+ });
268
+ },
269
+ [palette]
270
+ );
271
+
272
+ const highlightScale = useCallback(
273
+ (scale: keyof ThemePalette) => (index: number) => {
274
+ setHighlight({scale, index});
275
+ },
276
+ []
277
+ );
278
+
279
+ const clearHighlight = useCallback(() => setHighlight(null), []);
280
+
281
+ const isModified = prefs !== null || staticPaletteIndex !== null || customPalette !== null || anyCurvesChanged;
282
+
283
+ useEffect(() => {
284
+ saveToStorage({prefs, staticPaletteIndex, customPalette});
285
+ }, [prefs, staticPaletteIndex, customPalette]);
286
+
287
+ const editingDark = !isAuto && !isLightMode;
288
+ const baseCurves = editingDark ? baseCurvesDark : baseCurvesLight;
289
+ const setBaseCurves = editingDark ? setBaseCurvesDark : setBaseCurvesLight;
290
+ const accentCurves = editingDark ? accentCurvesDark : accentCurvesLight;
291
+ const setAccentCurves = editingDark ? setAccentCurvesDark : setAccentCurvesLight;
292
+
293
+ const exportState = useCallback((): ThematicExport => {
294
+ const keys: (keyof Omit<ThemePalette, "key">)[] = ["white", "whiteA", "black", "blackA"];
295
+ if (showLight) {
296
+ keys.push("base", "accent", "red", "teal", "yellow");
297
+ }
298
+ if (showDark) {
299
+ keys.push("baseDark", "accentDark", "redDark", "tealDark", "yellowDark");
300
+ }
301
+ const result: ThematicExport = {};
302
+ for (const k of keys) {
303
+ (result as Record<string, unknown>)[k] = palette[k];
304
+ }
305
+ const bothModes = showLight && showDark;
306
+ if (bothModes) {
307
+ result.meta = {prefs};
308
+ }
309
+ return result;
310
+ }, [palette, showLight, showDark, prefs]);
311
+
312
+ const importState = useCallback((data: ThematicExport) => {
313
+ const {meta, ...scales} = data;
314
+ if (!isValidPalette(scales)) {
315
+ alert("Outdated palette format");
316
+ return;
317
+ }
318
+ if (meta) {
319
+ setPrefs(meta.prefs);
320
+ setStaticPaletteIndex(null);
321
+ }
322
+ const hasLight = "base" in scales;
323
+ const hasDark = "baseDark" in scales;
324
+ if (hasLight && !hasDark) {
325
+ setThemePreference("light");
326
+ setThemePref("light");
327
+ } else if (hasDark && !hasLight) {
328
+ setThemePreference("dark");
329
+ setThemePref("dark");
330
+ }
331
+ setCustomPalette(
332
+ (prev) =>
333
+ ({
334
+ ...prev,
335
+ ...scales,
336
+ key: `import-${Date.now()}`,
337
+ } as Partial<ThemePalette>)
338
+ );
339
+ }, []);
340
+
341
+ return {
342
+ palette,
343
+ isAltPressed,
344
+ collapsed,
345
+ setCollapsed,
346
+ visible,
347
+ setVisible,
348
+ themePref,
349
+ isModified,
350
+ showLight,
351
+ showDark,
352
+ isAuto,
353
+ prefsPresent,
354
+ staticPalette,
355
+ baseCurves,
356
+ setBaseCurves,
357
+ accentCurves,
358
+ setAccentCurves,
359
+ update,
360
+ randomize,
361
+ cycleStaticPalettes,
362
+ cycleTheme,
363
+ reset,
364
+ updateScaleColor,
365
+ highlightScale,
366
+ clearHighlight,
367
+ exportState,
368
+ importState,
369
+ };
370
+ }