@fibery/ui-kit 1.40.3 → 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.
- package/CHANGELOG.md +8 -0
- package/package.json +4 -7
- package/src/__snapshots__/design-system.test.ts.snap +7265 -0
- package/src/a11y-color.test.ts +181 -0
- package/src/a11y-color.ts +13 -20
- package/src/actions-menu/actions-menu-item.tsx +7 -7
- package/src/antd/ant-modal.tsx +10 -5
- package/src/antd/styles.ts +6 -6
- package/src/app-icon-with-fallback.tsx +2 -2
- package/src/app-icon.tsx +2 -2
- package/src/button/button.tsx +1 -0
- package/src/button/make-button-colors.ts +5 -13
- package/src/checkbox.tsx +2 -2
- package/src/color-picker/ColorPickerOrLoader.js +2 -2
- package/src/color-utils.test.ts +317 -0
- package/src/color-utils.ts +180 -0
- package/src/comment.tsx +3 -2
- package/src/context-menu/index.tsx +12 -7
- package/src/create-inline-theme.ts +9 -8
- package/src/design-system.colors.ts +760 -0
- package/src/design-system.test.ts +287 -7
- package/src/design-system.ts +146 -940
- package/src/dropdown-menu/index.tsx +21 -16
- package/src/emoji-picker/app-icon-picker.tsx +5 -5
- package/src/emoji-picker/emoji-picker-content-with-color.tsx +4 -4
- package/src/emoji-picker/icon-emoji-picker.tsx +2 -2
- package/src/favorites-icon.tsx +1 -1
- package/src/file-item/file-icon.tsx +169 -0
- package/src/file-item/file-menu-items.tsx +68 -0
- package/src/file-item/file-preview-actions.tsx +38 -0
- package/src/file-item/file-title.tsx +48 -0
- package/src/file-item/types.ts +27 -0
- package/src/file-item/use-register-in-image-gallery.tsx +70 -0
- package/src/file-item-2.tsx +35 -345
- package/src/file-item.tsx +6 -2
- package/src/hue-shift.test.ts +91 -0
- package/src/icons/ast/FileCounter.ts +8 -0
- package/src/icons/ast/FileMultiple.ts +8 -0
- package/src/icons/ast/ValueEdit.ts +8 -0
- package/src/icons/ast/index.tsx +3 -0
- package/src/icons/react/FileCounter.tsx +13 -0
- package/src/icons/react/FileMultiple.tsx +13 -0
- package/src/icons/react/ValueEdit.tsx +13 -0
- package/src/icons/react/index.tsx +3 -0
- package/src/icons/svg/file-counter.svg +3 -0
- package/src/icons/svg/file-multiple.svg +3 -0
- package/src/icons/svg/value-edit.svg +3 -0
- package/src/images-gallery/slide-buttons.tsx +4 -11
- package/src/lists/actions-menu-row-surface.tsx +5 -5
- package/src/mobile-keyboard-aware-popup.tsx +4 -3
- package/src/palette-generator.test.ts +566 -0
- package/src/palette-generator.ts +166 -0
- package/src/palette.ts +71 -55
- package/src/platform.ts +0 -3
- package/src/popover/index.tsx +13 -15
- package/src/progress.tsx +2 -2
- package/src/reactions/reaction-button.tsx +12 -6
- package/src/root-theme-provider.test.tsx +316 -0
- package/src/scale-generator.ts +347 -0
- package/src/select/components/menu-list-virtualized.tsx +7 -22
- package/src/select/components/menu.tsx +12 -2
- package/src/select/select.tsx +2 -1
- package/src/select/styles.ts +0 -1
- package/src/static-palettes.ts +146 -0
- package/src/thematic-color-picker.tsx +266 -0
- package/src/thematic-constants.tsx +27 -0
- package/src/thematic-controls.tsx +144 -0
- package/src/thematic-scales.tsx +122 -0
- package/src/thematic-state.ts +333 -0
- package/src/thematic.tsx +382 -0
- package/src/theme-provider.test.tsx +808 -0
- package/src/theme-provider.tsx +132 -69
- package/src/theme-settings.ts +1 -1
- package/src/theme-styles.ts +12 -5
- package/src/toast/toast-action.tsx +1 -1
- package/src/toggle-on-off.tsx +2 -2
- package/src/toggle.tsx +5 -6
- package/src/tooltip.tsx +13 -10
- package/src/type-badge.tsx +3 -3
- package/src/unit/styles.ts +2 -2
- package/src/unit/unit-with-tooltip.tsx +3 -2
- 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
|
+
}
|