@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,316 @@
1
+ import {act, render, screen} from "@testing-library/react";
2
+ import {
3
+ RootThemeProvider,
4
+ ThemeProvider,
5
+ useThematic,
6
+ useTheme,
7
+ useThemeMenuPreference,
8
+ useThemeMode,
9
+ useThemePreference,
10
+ } from "./theme-provider";
11
+ import {makeDefaultPalette, ThemePalette} from "./palette-generator";
12
+ import {defaultDarkTheme, defaultLightTheme} from "./theme-styles";
13
+ import {createInlineTheme, getThemeColors} from "./design-system";
14
+
15
+ jest.unmock("./create-inline-theme");
16
+
17
+ const palette = makeDefaultPalette();
18
+
19
+ function ThemeDisplay({testId = "theme-display"}: {testId?: string}) {
20
+ const theme = useTheme();
21
+ const mode = useThemeMode();
22
+ return (
23
+ <div data-testid={testId} data-mode={mode} data-primary={theme.primary}>
24
+ {theme.key}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ function PreferenceDisplay() {
30
+ const preference = useThemePreference();
31
+ const menuPreference = useThemeMenuPreference();
32
+ return (
33
+ <div data-testid="preferences" data-preference={preference} data-menu-preference={menuPreference}>
34
+ {preference}/{menuPreference}
35
+ </div>
36
+ );
37
+ }
38
+
39
+ function ThematicDisplay() {
40
+ const {palette, setPalette} = useThematic();
41
+ return (
42
+ <div data-testid="thematic" data-has-palette={Boolean(palette)} data-has-setter={typeof setPalette === "function"}>
43
+ thematic
44
+ </div>
45
+ );
46
+ }
47
+
48
+ describe("RootThemeProvider", () => {
49
+ beforeEach(() => {
50
+ document.documentElement.className = "";
51
+ document.documentElement.style.colorScheme = "";
52
+ });
53
+
54
+ describe("context provision", () => {
55
+ it("provides theme via useTheme", () => {
56
+ render(
57
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
58
+ <ThemeDisplay />
59
+ </RootThemeProvider>
60
+ );
61
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "light");
62
+ });
63
+
64
+ it("provides dark theme when initialThemeMode is dark", () => {
65
+ render(
66
+ <RootThemeProvider initialThemeMode="dark" initialPreference="dark" initialMenuPreference="dark">
67
+ <ThemeDisplay />
68
+ </RootThemeProvider>
69
+ );
70
+ expect(screen.getByTestId("theme-display")).toHaveAttribute("data-mode", "dark");
71
+ });
72
+
73
+ it("provides themePreference via useThemePreference", () => {
74
+ render(
75
+ <RootThemeProvider initialThemeMode="light" initialPreference="auto" initialMenuPreference="auto">
76
+ <PreferenceDisplay />
77
+ </RootThemeProvider>
78
+ );
79
+ expect(screen.getByTestId("preferences")).toHaveAttribute("data-preference", "auto");
80
+ });
81
+
82
+ it("provides themeMenuPreference via useThemeMenuPreference", () => {
83
+ render(
84
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="dark">
85
+ <PreferenceDisplay />
86
+ </RootThemeProvider>
87
+ );
88
+ expect(screen.getByTestId("preferences")).toHaveAttribute("data-menu-preference", "dark");
89
+ });
90
+
91
+ it("provides thematic context with palette and setPalette", () => {
92
+ render(
93
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
94
+ <ThematicDisplay />
95
+ </RootThemeProvider>
96
+ );
97
+ const el = screen.getByTestId("thematic");
98
+ expect(el).toHaveAttribute("data-has-palette", "true");
99
+ expect(el).toHaveAttribute("data-has-setter", "true");
100
+ });
101
+ });
102
+
103
+ describe("base context for diff optimization", () => {
104
+ it("sets light base context in light mode", () => {
105
+ const {container} = render(
106
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
107
+ <ThemeProvider theme={defaultLightTheme}>
108
+ <span data-testid="inner">inner</span>
109
+ </ThemeProvider>
110
+ </RootThemeProvider>
111
+ );
112
+
113
+ // Inner ThemeProvider with same light theme should have empty diff
114
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
115
+ // The innermost div (from nested ThemeProvider) should have no styles
116
+ const innerMostDiv = divs[divs.length - 1];
117
+ const style = innerMostDiv?.getAttribute("style") || "";
118
+ expect(style).toBe("");
119
+ });
120
+
121
+ it("sets dark base context in dark mode", () => {
122
+ const {container} = render(
123
+ <RootThemeProvider initialThemeMode="dark" initialPreference="dark" initialMenuPreference="dark">
124
+ <ThemeProvider theme={defaultDarkTheme}>
125
+ <span data-testid="inner">inner</span>
126
+ </ThemeProvider>
127
+ </RootThemeProvider>
128
+ );
129
+
130
+ // Inner ThemeProvider with same dark theme should have empty diff
131
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
132
+ const innerMostDiv = divs[divs.length - 1];
133
+ const style = innerMostDiv?.getAttribute("style") || "";
134
+ expect(style).toBe("");
135
+ });
136
+
137
+ it("nested light ThemeProvider in dark RootThemeProvider produces diff", () => {
138
+ const {container} = render(
139
+ <RootThemeProvider initialThemeMode="dark" initialPreference="dark" initialMenuPreference="dark">
140
+ <ThemeProvider theme={defaultLightTheme}>
141
+ <span data-testid="inner">inner</span>
142
+ </ThemeProvider>
143
+ </RootThemeProvider>
144
+ );
145
+
146
+ // Inner ThemeProvider with light theme should have diff (dark->light)
147
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
148
+ const innerMostDiv = divs[divs.length - 1];
149
+ const style = innerMostDiv?.getAttribute("style") || "";
150
+ expect(style.length).toBeGreaterThan(0);
151
+ });
152
+ });
153
+
154
+ describe("document element classes", () => {
155
+ it("adds dark-theme class in dark mode", () => {
156
+ render(
157
+ <RootThemeProvider initialThemeMode="dark" initialPreference="dark" initialMenuPreference="dark">
158
+ <div>content</div>
159
+ </RootThemeProvider>
160
+ );
161
+ expect(document.documentElement.classList.contains("dark-theme")).toBe(true);
162
+ });
163
+
164
+ it("does not add dark-theme class in light mode", () => {
165
+ render(
166
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
167
+ <div>content</div>
168
+ </RootThemeProvider>
169
+ );
170
+ expect(document.documentElement.classList.contains("dark-theme")).toBe(false);
171
+ });
172
+
173
+ it("adds light-theme-and-dark-menu class in light2 mode", () => {
174
+ render(
175
+ <RootThemeProvider initialThemeMode="light2" initialPreference="light" initialMenuPreference="dark">
176
+ <div>content</div>
177
+ </RootThemeProvider>
178
+ );
179
+ expect(document.documentElement.classList.contains("light-theme-and-dark-menu")).toBe(true);
180
+ });
181
+
182
+ it("sets colorScheme to dark in dark mode", () => {
183
+ render(
184
+ <RootThemeProvider initialThemeMode="dark" initialPreference="dark" initialMenuPreference="dark">
185
+ <div>content</div>
186
+ </RootThemeProvider>
187
+ );
188
+ expect(document.documentElement.style.colorScheme).toBe("dark");
189
+ });
190
+
191
+ it("sets colorScheme to light in light mode", () => {
192
+ render(
193
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
194
+ <div>content</div>
195
+ </RootThemeProvider>
196
+ );
197
+ expect(document.documentElement.style.colorScheme).toBe("light");
198
+ });
199
+ });
200
+
201
+ describe("diff optimization integrity", () => {
202
+ it("RootThemeProvider internal ThemeProvider only sets diff from base", () => {
203
+ const lightStyles = createInlineTheme(defaultLightTheme, []);
204
+ const lightTheme = getThemeColors(null, "light", palette);
205
+ const actualStyles = createInlineTheme(lightTheme, []);
206
+
207
+ // Count expected diff (theme from getThemeColors vs defaultLightTheme)
208
+ let expectedDiffCount = 0;
209
+ for (const key in actualStyles) {
210
+ if (actualStyles[key] !== lightStyles[key]) {
211
+ expectedDiffCount++;
212
+ }
213
+ }
214
+
215
+ const {container} = render(
216
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
217
+ <span>content</span>
218
+ </RootThemeProvider>
219
+ );
220
+
221
+ // Get the ThemeProvider's div style
222
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
223
+ if (divs.length > 0) {
224
+ const style = divs[0].getAttribute("style") || "";
225
+ const actualCount = style.split(";").filter((s) => s.includes(":")).length;
226
+ // Actual diff should match expected diff count (or be close)
227
+ expect(actualCount).toBeLessThanOrEqual(expectedDiffCount + 5);
228
+ }
229
+ });
230
+
231
+ it("deeply nested ThemeProviders accumulate correct diffs", () => {
232
+ const {container} = render(
233
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
234
+ <ThemeProvider mode="dark">
235
+ <ThemeProvider mode="light">
236
+ <span data-testid="deepest">deep</span>
237
+ </ThemeProvider>
238
+ </ThemeProvider>
239
+ </RootThemeProvider>
240
+ );
241
+
242
+ // Each transition (light->dark, dark->light) should produce diffs
243
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => d.hasAttribute("style"));
244
+ // Should have multiple styled divs (root's ThemeProvider + nested ones)
245
+ expect(divs.length).toBeGreaterThanOrEqual(2);
246
+ });
247
+ });
248
+
249
+ describe("setPalette", () => {
250
+ it("setPalette updates theme colors", () => {
251
+ let capturedSetPalette: (p: ThemePalette) => void = () => {};
252
+
253
+ function ThemeCapture() {
254
+ const {setPalette} = useThematic();
255
+ const theme = useTheme();
256
+ capturedSetPalette = setPalette;
257
+ return <div data-testid="capture" data-card-bg={theme.cardBg} />;
258
+ }
259
+
260
+ render(
261
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
262
+ <ThemeCapture />
263
+ </RootThemeProvider>
264
+ );
265
+
266
+ const initialCardBg = screen.getByTestId("capture").getAttribute("data-card-bg");
267
+
268
+ // cardBg in light mode = white[0], so modify white scale
269
+ const newPalette = makeDefaultPalette();
270
+ newPalette.key = "test-palette"; // must change key - getThemeColors is memoized by key
271
+ newPalette.white = {...newPalette.white, 0: "oklch(0.9 0.05 200)"};
272
+
273
+ act(() => capturedSetPalette(newPalette));
274
+
275
+ const updatedCardBg = screen.getByTestId("capture").getAttribute("data-card-bg");
276
+ expect(updatedCardBg).toBe("oklch(0.9 0.05 200)");
277
+ expect(updatedCardBg).not.toBe(initialCardBg);
278
+ });
279
+
280
+ it("nested ThemeProvider inherits updated palette", () => {
281
+ let capturedSetPalette: (p: ThemePalette) => void = () => {};
282
+
283
+ function PaletteControl() {
284
+ const {setPalette} = useThematic();
285
+ capturedSetPalette = setPalette;
286
+ return null;
287
+ }
288
+
289
+ function NestedTheme() {
290
+ const theme = useTheme();
291
+ return <div data-testid="nested" data-card-bg={theme.cardBg} />;
292
+ }
293
+
294
+ render(
295
+ <RootThemeProvider initialThemeMode="light" initialPreference="light" initialMenuPreference="auto">
296
+ <PaletteControl />
297
+ <ThemeProvider mode="light">
298
+ <NestedTheme />
299
+ </ThemeProvider>
300
+ </RootThemeProvider>
301
+ );
302
+
303
+ const initialCardBg = screen.getByTestId("nested").getAttribute("data-card-bg");
304
+
305
+ const newPalette = makeDefaultPalette();
306
+ newPalette.key = "test-palette-2";
307
+ newPalette.white = {...newPalette.white, 0: "oklch(0.8 0.02 100)"};
308
+
309
+ act(() => capturedSetPalette(newPalette));
310
+
311
+ const updatedCardBg = screen.getByTestId("nested").getAttribute("data-card-bg");
312
+ expect(updatedCardBg).toBe("oklch(0.8 0.02 100)");
313
+ expect(updatedCardBg).not.toBe(initialCardBg);
314
+ });
315
+ });
316
+ });
@@ -0,0 +1,347 @@
1
+ import {fromOKLCH} from "./color-utils";
2
+ import type {ColorScale} from "./palette-generator";
3
+
4
+ export type BasedScale12<T> = readonly [
5
+ base00: T,
6
+ step01: T,
7
+ step02: T,
8
+ step03: T,
9
+ step04: T,
10
+ step05: T,
11
+ step06: T,
12
+ step07: T,
13
+ step08: T,
14
+ step09: T,
15
+ step10: T,
16
+ step11: T,
17
+ step12: T
18
+ ];
19
+
20
+ export type Scale12<T> = readonly [
21
+ step01: T,
22
+ step02: T,
23
+ step03: T,
24
+ step04: T,
25
+ step05: T,
26
+ step06: T,
27
+ step07: T,
28
+ step08: T,
29
+ step09: T,
30
+ step10: T,
31
+ step11: T,
32
+ step12: T
33
+ ];
34
+
35
+ export interface ScaleCurves {
36
+ lightness: Scale12<number>; // 0-100 scale
37
+ chroma: Scale12<number>; // multipliers relative to peakChroma
38
+ }
39
+
40
+ export interface ScaleConfig {
41
+ hue: number;
42
+ chroma: number;
43
+ mode: "light" | "dark";
44
+ curves?: ScaleCurves;
45
+ /** Adjusts lightness contrast. >1 = more contrast, <1 = less. Default 1. */
46
+ contrast?: number;
47
+ /** Override step 0 (base) color. Defaults to white. */
48
+ base?: string;
49
+ }
50
+
51
+ // Based on indigo scale (default accent) from palette.ts
52
+ export const DEFAULT_LIGHT_CURVES: ScaleCurves = {
53
+ lightness: [99.069, 98.983, 96.966, 95.012, 92.145, 88.367, 82.757, 76.14, 58.48, 54.173, 49.642, 26.071],
54
+ chroma: [0.018, 0.027, 0.08, 0.126, 0.19, 0.27, 0.384, 0.529, 0.934, 1.0, 0.878, 0.44],
55
+ };
56
+
57
+ export const DEFAULT_DARK_CURVES: ScaleCurves = {
58
+ lightness: [20.512, 22.974, 27.284, 29.742, 32.452, 35.981, 41.256, 47.972, 57.658, 62.928, 76.225, 95.899],
59
+ chroma: [0.119, 0.226, 0.357, 0.444, 0.475, 0.517, 0.72, 0.952, 1.0, 0.921, 0.601, 0.111],
60
+ };
61
+
62
+ // Curves extracted from current palette.ts
63
+ const SLATE_LIGHT_CURVES: ScaleCurves = {
64
+ lightness: [99.241, 97.719, 96.955, 94.656, 93.115, 91.568, 89.235, 82.937, 69.959, 57.217, 48.318, 20.441],
65
+ chroma: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
66
+ };
67
+
68
+ const SLATE_DARK_CURVES: ScaleCurves = {
69
+ lightness: [20.441, 22.667, 25.908, 28.012, 30.078, 32.109, 36.079, 43.714, 54.586, 59.818, 70.787, 94.656],
70
+ chroma: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
71
+ };
72
+
73
+ const INDIGO_LIGHT_CURVES: ScaleCurves = {
74
+ lightness: [99.069, 98.983, 96.966, 95.012, 92.145, 88.367, 82.757, 76.14, 58.48, 54.173, 49.642, 26.071],
75
+ chroma: [0.018, 0.027, 0.08, 0.126, 0.19, 0.27, 0.384, 0.529, 0.934, 1.0, 0.878, 0.44],
76
+ };
77
+
78
+ const INDIGO_DARK_CURVES: ScaleCurves = {
79
+ lightness: [20.512, 22.974, 27.284, 29.742, 32.452, 35.981, 41.256, 47.972, 57.658, 62.928, 76.225, 95.899],
80
+ chroma: [0.119, 0.226, 0.357, 0.444, 0.475, 0.517, 0.72, 0.952, 1.0, 0.921, 0.601, 0.111],
81
+ };
82
+
83
+ const RED_LIGHT_CURVES: ScaleCurves = {
84
+ lightness: [98.9, 98.9, 96.6, 94.4, 91.4, 87.5, 81.8, 74.5, 62.5, 59.7, 55.7, 24.9],
85
+ chroma: [0.027, 0.027, 0.084, 0.138, 0.207, 0.289, 0.403, 0.562, 0.975, 0.985, 1.0, 0.307],
86
+ };
87
+
88
+ const RED_DARK_CURVES: ScaleCurves = {
89
+ lightness: [20.6, 22.2, 25.9, 28.7, 31.5, 35.2, 40.8, 48.1, 62.5, 66.2, 70.5, 95.7],
90
+ chroma: [0.101, 0.178, 0.288, 0.369, 0.441, 0.54, 0.687, 0.872, 1.0, 0.992, 0.967, 0.104],
91
+ };
92
+
93
+ const TEAL_LIGHT_CURVES: ScaleCurves = {
94
+ lightness: [99.4, 98.4, 96.7, 94.3, 91.2, 86.8, 80.5, 71.8, 65, 61.7, 51.8, 29],
95
+ chroma: [0.03, 0.094, 0.174, 0.255, 0.337, 0.449, 0.6, 0.859, 1.0, 0.953, 0.799, 0.353],
96
+ };
97
+
98
+ const TEAL_DARK_CURVES: ScaleCurves = {
99
+ lightness: [18.9, 22, 25.2, 28.4, 31.6, 34.7, 40.7, 48.5, 65, 68.8, 74.4, 96.5],
100
+ chroma: [0.163, 0.269, 0.31, 0.354, 0.4, 0.44, 0.528, 0.636, 0.864, 0.913, 1.0, 0.215],
101
+ };
102
+
103
+ const YELLOW_LIGHT_CURVES: ScaleCurves = {
104
+ lightness: [99.15, 98.666, 98.103, 97.119, 95.34, 92.061, 86.841, 81.273, 88.224, 85.516, 54.771, 28.162],
105
+ chroma: [0.04, 0.16, 0.299, 0.419, 0.539, 0.621, 0.692, 0.911, 1.0, 0.962, 0.624, 0.231],
106
+ };
107
+
108
+ const YELLOW_DARK_CURVES: ScaleCurves = {
109
+ lightness: [18.975, 22.715, 25.994, 27.672, 31.467, 35.323, 40.577, 48.421, 88.224, 93.814, 82.593, 97.801],
110
+ chroma: [0.213, 0.255, 0.291, 0.31, 0.353, 0.398, 0.444, 0.546, 1.0, 0.898, 0.926, 0.289],
111
+ };
112
+
113
+ // ============ PRESET CONFIGS ============
114
+ export const SCALE_PRESETS = {
115
+ slate: {
116
+ hue: 0,
117
+ chroma: 0,
118
+ lightCurves: SLATE_LIGHT_CURVES,
119
+ darkCurves: SLATE_DARK_CURVES,
120
+ },
121
+ indigo: {
122
+ hue: 264,
123
+ chroma: 0.1618, // peak chroma at step 10 light
124
+ lightCurves: INDIGO_LIGHT_CURVES,
125
+ darkCurves: INDIGO_DARK_CURVES,
126
+ },
127
+ red: {
128
+ hue: 19,
129
+ chroma: 0.1978, // peak chroma at step 11 light
130
+ lightCurves: RED_LIGHT_CURVES,
131
+ darkCurves: RED_DARK_CURVES,
132
+ },
133
+ teal: {
134
+ hue: 183,
135
+ chroma: 0.1137, // peak chroma at step 9 light
136
+ lightCurves: TEAL_LIGHT_CURVES,
137
+ darkCurves: TEAL_DARK_CURVES,
138
+ },
139
+ yellow: {
140
+ hue: 100, // OKLCH yellow hue
141
+ chroma: 0.19, // peak chroma at step 9 light
142
+ lightCurves: YELLOW_LIGHT_CURVES,
143
+ darkCurves: YELLOW_DARK_CURVES,
144
+ },
145
+ } as const;
146
+
147
+ /**
148
+ * Generate a 12-step color scale from hue + peak chroma.
149
+ * Returns ColorScale (13 elements: base + 12 steps)
150
+ */
151
+ function applyContrast(L: number, contrast: number): number {
152
+ // Scale lightness away from midpoint (50)
153
+ const adjusted = 50 + (L - 50) * contrast;
154
+ return Math.max(0, Math.min(100, adjusted));
155
+ }
156
+
157
+ export function generateColorScale(config: ScaleConfig): ColorScale {
158
+ const {hue, chroma, mode, curves, contrast = 1, base = "hsl(0, 0%, 100%)"} = config;
159
+
160
+ const defaultCurves = mode === "light" ? DEFAULT_LIGHT_CURVES : DEFAULT_DARK_CURVES;
161
+ const {lightness, chroma: chromaCurve} = curves ?? defaultCurves;
162
+
163
+ const steps = lightness.map((L, i) => {
164
+ const adjustedL = applyContrast(L, contrast);
165
+ const C = chroma * chromaCurve[i];
166
+ return fromOKLCH(adjustedL / 100, C, hue);
167
+ });
168
+
169
+ return [base, ...steps] as unknown as ColorScale;
170
+ }
171
+
172
+ /**
173
+ * Generate a scale using a named preset
174
+ */
175
+ export function generatePresetScale(name: keyof typeof SCALE_PRESETS, mode: "light" | "dark"): ColorScale {
176
+ const preset = SCALE_PRESETS[name];
177
+ const curves = "lightCurves" in preset ? (mode === "light" ? preset.lightCurves : preset.darkCurves) : undefined;
178
+ return generateColorScale({
179
+ hue: preset.hue,
180
+ chroma: preset.chroma,
181
+ mode,
182
+ curves,
183
+ });
184
+ }
185
+
186
+ const GRAY_CURVES: ScaleCurves = {
187
+ // Based on slate (default base) from palette.ts
188
+ lightness: SLATE_LIGHT_CURVES.lightness,
189
+ chroma: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
190
+ };
191
+
192
+ const GRAY_DARK_CURVES: ScaleCurves = {
193
+ lightness: SLATE_DARK_CURVES.lightness,
194
+ chroma: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
195
+ };
196
+
197
+ /**
198
+ * Generate a gray scale with optional subtle hue tint
199
+ */
200
+ export function generateGrayScale(config: {
201
+ hue?: number;
202
+ chroma?: number;
203
+ mode: "light" | "dark";
204
+ contrast?: number;
205
+ base?: string;
206
+ }): ColorScale {
207
+ const {hue = 0, chroma = 0, mode, contrast, base} = config;
208
+ const curves = mode === "light" ? GRAY_CURVES : GRAY_DARK_CURVES;
209
+ const defaultBase = mode === "dark" ? "hsl(0, 0%, 5%)" : undefined;
210
+ return generateColorScale({hue, chroma, mode, contrast, curves, base: base ?? defaultBase});
211
+ }
212
+
213
+ // ============ HUE INTERPOLATION ============
214
+
215
+ interface HueReference {
216
+ hue: number;
217
+ chroma: number;
218
+ lightCurves: ScaleCurves;
219
+ darkCurves: ScaleCurves;
220
+ }
221
+
222
+ // Reference scales sorted by hue for interpolation
223
+ const HUE_REFERENCES: HueReference[] = [
224
+ {hue: 19, chroma: 0.1978, lightCurves: RED_LIGHT_CURVES, darkCurves: RED_DARK_CURVES},
225
+ {hue: 183, chroma: 0.1137, lightCurves: TEAL_LIGHT_CURVES, darkCurves: TEAL_DARK_CURVES},
226
+ {hue: 264, chroma: 0.1618, lightCurves: INDIGO_LIGHT_CURVES, darkCurves: INDIGO_DARK_CURVES},
227
+ ].sort((a, b) => a.hue - b.hue);
228
+
229
+ function lerp(a: number, b: number, t: number): number {
230
+ return a + (b - a) * t;
231
+ }
232
+
233
+ function lerpArray<T extends readonly number[]>(a: T, b: T, t: number): {[K in keyof T]: number} {
234
+ return a.map((v, i) => lerp(v, b[i], t)) as {[K in keyof T]: number};
235
+ }
236
+
237
+ function normalizeHue(h: number): number {
238
+ return ((h % 360) + 360) % 360;
239
+ }
240
+
241
+ /**
242
+ * Find bracketing references and interpolation factor for a target hue
243
+ */
244
+ function findHueBracket(targetHue: number): {before: HueReference; after: HueReference; t: number} {
245
+ const h = normalizeHue(targetHue);
246
+ const refs = HUE_REFERENCES;
247
+
248
+ // Find which two references bracket the target hue
249
+ let beforeIdx = refs.length - 1;
250
+ let afterIdx = 0;
251
+
252
+ for (let i = 0; i < refs.length; i++) {
253
+ if (refs[i].hue <= h) {
254
+ beforeIdx = i;
255
+ afterIdx = (i + 1) % refs.length;
256
+ }
257
+ }
258
+
259
+ const before = refs[beforeIdx];
260
+ const after = refs[afterIdx];
261
+
262
+ // Calculate interpolation factor
263
+ const beforeHue = before.hue;
264
+ let afterHue = after.hue;
265
+
266
+ // Handle wrap-around (e.g., from indigo 264 to red 19)
267
+ if (afterHue <= beforeHue) {
268
+ afterHue += 360;
269
+ }
270
+
271
+ let targetH = h;
272
+ if (targetH < beforeHue) {
273
+ targetH += 360;
274
+ }
275
+
276
+ const range = afterHue - beforeHue;
277
+ const t = range > 0 ? (targetH - beforeHue) / range : 0;
278
+
279
+ return {before, after, t: Math.max(0, Math.min(1, t))};
280
+ }
281
+
282
+ /**
283
+ * Interpolate curves between two references
284
+ */
285
+ function interpolateCurves(before: ScaleCurves, after: ScaleCurves, t: number): ScaleCurves {
286
+ return {
287
+ lightness: lerpArray(before.lightness, after.lightness, t),
288
+ chroma: lerpArray(before.chroma, after.chroma, t),
289
+ };
290
+ }
291
+
292
+ export interface InterpolatedScaleConfig {
293
+ /** Target hue (0-360). Curves are interpolated from nearby reference scales. */
294
+ hue: number;
295
+ /** Optional chroma override. If not provided, interpolated from references. */
296
+ chroma?: number;
297
+ /** Light or dark mode */
298
+ mode: "light" | "dark";
299
+ }
300
+
301
+ /**
302
+ * Generate a scale for any hue by interpolating curves from reference scales.
303
+ * This produces smooth, natural-looking palettes for any color.
304
+ */
305
+ export function generateInterpolatedScale(config: InterpolatedScaleConfig): ColorScale {
306
+ const {hue, chroma: chromaOverride, mode} = config;
307
+
308
+ const {before, after, t} = findHueBracket(hue);
309
+
310
+ // Interpolate curves
311
+ const beforeCurves = mode === "light" ? before.lightCurves : before.darkCurves;
312
+ const afterCurves = mode === "light" ? after.lightCurves : after.darkCurves;
313
+ const curves = interpolateCurves(beforeCurves, afterCurves, t);
314
+
315
+ // Interpolate chroma if not overridden
316
+ const chroma = chromaOverride ?? lerp(before.chroma, after.chroma, t);
317
+
318
+ return generateColorScale({hue, chroma, mode, curves});
319
+ }
320
+
321
+ export function paletteFromHue(hue: number, mode: "light" | "dark"): ColorScale {
322
+ return generateInterpolatedScale({hue, mode});
323
+ }
324
+
325
+ export function paletteFromHueBoth(hue: number): {light: ColorScale; dark: ColorScale} {
326
+ return {
327
+ light: generateInterpolatedScale({hue, mode: "light"}),
328
+ dark: generateInterpolatedScale({hue, mode: "dark"}),
329
+ };
330
+ }
331
+
332
+ const WHITE_ALPHAS: BasedScale12<number> = [1, 0, 0.01, 0.03, 0.06, 0.09, 0.12, 0.18, 0.25, 0.39, 0.45, 0.59, 0.92];
333
+ const BLACK_ALPHAS: BasedScale12<number> = [1, 0.01, 0.03, 0.05, 0.07, 0.09, 0.11, 0.14, 0.22, 0.44, 0.48, 0.57, 0.91];
334
+
335
+ function alphaScale(L: number, C: number, H: number, alphas: BasedScale12<number>): ColorScale {
336
+ return alphas.map((a) => fromOKLCH(L, C, H, a)) as {length: 13; [i: number]: string} as ColorScale;
337
+ }
338
+
339
+ export function generateWhiteScale(hue: number, chroma: number, luminosity = 1, chromaMul = 1): ColorScale {
340
+ const c = chroma * 0.1 * chromaMul;
341
+ return alphaScale((1 - chroma * 0.1) * luminosity, c, hue, WHITE_ALPHAS);
342
+ }
343
+
344
+ export function generateBlackScale(hue: number, chroma: number, luminosity = 1, chromaMul = 1): ColorScale {
345
+ const c = chroma * 0.1 * chromaMul;
346
+ return alphaScale(chroma * 0.1 * 4 * luminosity, c, hue, BLACK_ALPHAS);
347
+ }