@fibery/ui-kit 1.40.4 → 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 (141) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/eslint.config.mjs +16 -0
  3. package/package.json +8 -7
  4. package/src/a11y-color.test.ts +5 -24
  5. package/src/actions-menu/actions-menu-item.tsx +6 -9
  6. package/src/actions-menu/context-actions-menu.tsx +8 -3
  7. package/src/ai/model.tsx +0 -1
  8. package/src/ai/temperature.tsx +0 -1
  9. package/src/animated-height-container.tsx +3 -3
  10. package/src/antd/index.tsx +3 -0
  11. package/src/antd/input.tsx +0 -1
  12. package/src/antd/styles.ts +9 -9
  13. package/src/app-icon-with-fallback.tsx +5 -5
  14. package/src/app-icon-wrapper.tsx +2 -3
  15. package/src/app-icon.tsx +3 -3
  16. package/src/avatar.tsx +3 -3
  17. package/src/button/base-button.tsx +0 -1
  18. package/src/button/make-button-colors.ts +2 -2
  19. package/src/checkbox.tsx +1 -1
  20. package/src/collapsible-section.tsx +11 -1
  21. package/src/color-utils.test.ts +15 -25
  22. package/src/color-utils.ts +43 -8
  23. package/src/command-menu/index.tsx +1 -0
  24. package/src/context-menu/index.tsx +5 -5
  25. package/src/date-picker/contexts.ts +2 -2
  26. package/src/date-picker/date-range-picker.tsx +3 -4
  27. package/src/date-picker/single-date-picker.tsx +147 -119
  28. package/src/date-picker/types.ts +4 -3
  29. package/src/day-select/iso-week-day-select.tsx +2 -2
  30. package/src/day-select/week-day-select.tsx +2 -2
  31. package/src/delayed.tsx +0 -1
  32. package/src/design-system/alpha.test.ts +59 -0
  33. package/src/design-system/alpha.ts +41 -0
  34. package/src/design-system/animation.ts +10 -0
  35. package/src/design-system/colors-css.test.ts +39 -0
  36. package/src/design-system/colors-css.ts +25 -0
  37. package/src/design-system/colors-js.test.ts +232 -0
  38. package/src/design-system/colors-js.ts +107 -0
  39. package/src/design-system/colors.test.ts +74 -0
  40. package/src/{design-system.colors.ts → design-system/colors.ts} +133 -134
  41. package/src/design-system/date.ts +3 -0
  42. package/src/design-system/layout.ts +106 -0
  43. package/src/design-system/theme.test.ts +94 -0
  44. package/src/design-system/theme.ts +93 -0
  45. package/src/design-system/typography.ts +179 -0
  46. package/src/design-system/vars.test.ts +1679 -0
  47. package/src/design-system/vars.ts +52 -0
  48. package/src/design-system.test.ts +46 -303
  49. package/src/design-system.ts +25 -545
  50. package/src/dropdown-menu/index.tsx +5 -5
  51. package/src/emoji-picker/icon-emoji-picker.tsx +1 -1
  52. package/src/fibermoji-placeholder.tsx +3 -3
  53. package/src/field-container.tsx +11 -2
  54. package/src/file-item/file-icon.tsx +3 -3
  55. package/src/file-item-2.tsx +4 -10
  56. package/src/file-item.tsx +0 -1
  57. package/src/icons/ast/ChatBubble.ts +8 -0
  58. package/src/icons/ast/ChatFloat.ts +8 -0
  59. package/src/icons/ast/ChatSidebar.ts +8 -0
  60. package/src/icons/ast/FileOther.ts +8 -0
  61. package/src/icons/ast/NoBorder.ts +8 -0
  62. package/src/icons/ast/NoFill.ts +8 -0
  63. package/src/icons/ast/index.tsx +6 -0
  64. package/src/icons/react/ChatBubble.tsx +13 -0
  65. package/src/icons/react/ChatFloat.tsx +13 -0
  66. package/src/icons/react/ChatSidebar.tsx +13 -0
  67. package/src/icons/react/FileOther.tsx +13 -0
  68. package/src/icons/react/NoBorder.tsx +13 -0
  69. package/src/icons/react/NoFill.tsx +13 -0
  70. package/src/icons/react/index.tsx +6 -0
  71. package/src/icons/svg/chat-bubble.svg +4 -0
  72. package/src/icons/svg/chat-float.svg +4 -0
  73. package/src/icons/svg/chat-sidebar.svg +4 -0
  74. package/src/icons/svg/file-other.svg +3 -0
  75. package/src/icons/svg/no-border.svg +17 -0
  76. package/src/icons/svg/no-fill.svg +4 -0
  77. package/src/images-gallery/images-gallery.tsx +8 -6
  78. package/src/images-gallery/slide-buttons.tsx +2 -2
  79. package/src/is-iOS.ts +0 -1
  80. package/src/is-in-popup.ts +2 -1
  81. package/src/lists/actions-menu-row-surface.tsx +4 -4
  82. package/src/loading-sausage.tsx +2 -2
  83. package/src/media-query-utils.ts +1 -2
  84. package/src/mobile-keyboard-aware-popup.tsx +2 -2
  85. package/src/modal-menu/modal-menu-content.tsx +2 -2
  86. package/src/number-input/decimal.js +9 -7
  87. package/src/number-input/{number-inline-input-with-autosize.tsx → number-input-inline-with-autosize.tsx} +20 -41
  88. package/src/number-input/number-input-inline.tsx +118 -0
  89. package/src/number-input/{index.test.js → number-input.test.tsx} +38 -43
  90. package/src/number-input/number-input.tsx +63 -121
  91. package/src/number-input/types.ts +19 -0
  92. package/src/number-input/utils.ts +61 -0
  93. package/src/online-users.tsx +2 -2
  94. package/src/palette-generator.test.ts +24 -281
  95. package/src/palette-generator.ts +39 -45
  96. package/src/palettes/_.ts +72 -0
  97. package/src/palettes/slate-arch.colors-snapshot.test.ts +1689 -0
  98. package/src/palettes/slate-arch.ts +188 -0
  99. package/src/palettes/slate-user.colors-snapshot.test.ts +1689 -0
  100. package/src/palettes/slate-user.ts +187 -0
  101. package/src/palettes/warm-arch.colors-snapshot.test.ts +1689 -0
  102. package/src/palettes/warm-arch.ts +222 -0
  103. package/src/palettes/warm-user.colors-snapshot.test.ts +1689 -0
  104. package/src/palettes/warm-user.ts +222 -0
  105. package/src/popover/get-element-ref.ts +28 -0
  106. package/src/popover/index.tsx +232 -333
  107. package/src/popover/{mobile-popup-context.tsx → mobile-popover-context.tsx} +7 -7
  108. package/src/popover/mobile-popover.tsx +169 -0
  109. package/src/popover/modifiers.tsx +2 -2
  110. package/src/popover/popup-stack-context.tsx +8 -9
  111. package/src/root-theme-provider.test.tsx +114 -19
  112. package/src/scale-generator.ts +31 -22
  113. package/src/select/components/menu-list-virtualized.tsx +5 -3
  114. package/src/select/index.tsx +6 -5
  115. package/src/select/select.tsx +36 -38
  116. package/src/select/util.ts +1 -1
  117. package/src/static-palettes.ts +221 -11
  118. package/src/thematic-controls.tsx +6 -6
  119. package/src/thematic-scales.tsx +15 -15
  120. package/src/thematic-state.ts +57 -20
  121. package/src/thematic.tsx +36 -32
  122. package/src/theme-provider.test.tsx +31 -19
  123. package/src/theme-provider.tsx +41 -38
  124. package/src/theme-settings.ts +66 -11
  125. package/src/theme-styles.ts +53 -8
  126. package/src/toast/toast.tsx +1 -2
  127. package/src/toggle.tsx +2 -2
  128. package/src/tooltip.tsx +4 -3
  129. package/src/type-badge.tsx +4 -11
  130. package/src/unit/styles.ts +0 -23
  131. package/src/use-is-phone.tsx +7 -2
  132. package/src/use-on-screen-keyboard-data.tsx +2 -2
  133. package/src/with-data.tsx +4 -3
  134. package/src/workflow-progress-icon.tsx +2 -2
  135. package/.eslintignore +0 -3
  136. package/.eslintrc +0 -14
  137. package/src/__mocks__/createInlineTheme.js +0 -3
  138. package/src/__snapshots__/design-system.test.ts.snap +0 -7265
  139. package/src/create-inline-theme.ts +0 -67
  140. package/src/number-input/index.js +0 -191
  141. package/src/palette.ts +0 -253
@@ -10,10 +10,10 @@ import {
10
10
  type ThematicPrefs,
11
11
  type ThemePalette,
12
12
  } from "./palette-generator";
13
- import {defaultPalette} from "./theme-styles";
14
13
  import {getThemePreference, setThemePreference, type ThemePreference} from "./theme-settings";
15
14
  import {StaticPalettes} from "./static-palettes";
16
15
  import {defaultPrefs, HIGHLIGHT_COLOR, nextThemePref} from "./thematic-constants";
16
+ import {useToast} from "./toast/toast-provider";
17
17
 
18
18
  interface StoredState {
19
19
  prefs: ThematicPrefs | null;
@@ -21,8 +21,7 @@ interface StoredState {
21
21
  customPalette: Partial<ThemePalette> | null;
22
22
  }
23
23
 
24
- export type ThematicExport = {
25
- scales: Partial<Omit<ThemePalette, "key">>;
24
+ export type ThematicExport = Partial<Omit<ThemePalette, "key">> & {
26
25
  meta?: {
27
26
  prefs: ThematicPrefs | null;
28
27
  };
@@ -30,10 +29,25 @@ export type ThematicExport = {
30
29
 
31
30
  const STORAGE_KEY = "thematic-palette";
32
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
+
33
39
  function loadFromStorage(): StoredState | null {
34
40
  try {
35
41
  const raw = localStorage.getItem(STORAGE_KEY);
36
- return raw ? JSON.parse(raw) : null;
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;
37
51
  } catch {
38
52
  return null;
39
53
  }
@@ -50,8 +64,9 @@ function saveToStorage(data: StoredState) {
50
64
  const stored = loadFromStorage();
51
65
 
52
66
  export function useThematicState() {
53
- const {setPalette} = useThematic();
67
+ const {palette: providerPalette, setPalette} = useThematic();
54
68
  const themeMode = useThemeMode();
69
+ const toast = useToast();
55
70
 
56
71
  const [themePref, setThemePref] = useState<ThemePreference>(getThemePreference);
57
72
  const isAuto = themePref === "auto";
@@ -103,7 +118,7 @@ export function useThematicState() {
103
118
  baseCurvesLightChanged || baseCurvesDarkChanged || accentCurvesLightChanged || accentCurvesDarkChanged;
104
119
 
105
120
  const palette = useMemo(() => {
106
- const base = staticPalette ? staticPalette.make() : prefs ? makeThematicPalette(prefs) : defaultPalette;
121
+ const base = staticPalette ? staticPalette.make() : prefs ? makeThematicPalette(prefs) : providerPalette;
107
122
  let result = customPalette ? {...base, ...customPalette} : base;
108
123
 
109
124
  if (baseCurvesLightChanged) {
@@ -166,6 +181,7 @@ export function useThematicState() {
166
181
  prefsPresent,
167
182
  staticPalette,
168
183
  customPalette,
184
+ providerPalette,
169
185
  baseCurvesLight,
170
186
  baseCurvesDark,
171
187
  accentCurvesLight,
@@ -185,10 +201,14 @@ export function useThematicState() {
185
201
  key: `${palette.key}-highlight-${highlight.scale}-${highlight.index}`,
186
202
  [highlight.scale]: scale as unknown as ColorScale,
187
203
  });
204
+ } else if (prefs !== null || staticPalette !== null || customPalette !== null || anyCurvesChanged) {
205
+ if (palette.key !== providerPalette.key) {
206
+ setPalette(palette);
207
+ }
188
208
  } else {
189
- setPalette(palette);
209
+ setPalette(null);
190
210
  }
191
- }, [palette, highlight, setPalette]);
211
+ }, [palette, providerPalette, highlight, prefs, staticPalette, customPalette, anyCurvesChanged, setPalette]);
192
212
 
193
213
  const update = (partial: Partial<ThematicPrefs>) => {
194
214
  setStaticPaletteIndex(null);
@@ -209,7 +229,12 @@ export function useThematicState() {
209
229
 
210
230
  const cycleStaticPalettes = () => {
211
231
  setPrefs(null);
212
- setStaticPaletteIndex((prev) => (prev === null ? 0 : (prev + 1) % StaticPalettes.length));
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
+ });
213
238
  };
214
239
 
215
240
  const cycleTheme = () => {
@@ -226,13 +251,17 @@ export function useThematicState() {
226
251
  setBaseCurvesDark(DEFAULT_DARK_CURVES);
227
252
  setAccentCurvesLight(DEFAULT_LIGHT_CURVES);
228
253
  setAccentCurvesDark(DEFAULT_DARK_CURVES);
254
+ saveToStorage({prefs: null, staticPaletteIndex: null, customPalette: null});
229
255
  };
230
256
 
231
257
  const updateScaleColor = useCallback(
232
258
  (scaleName: keyof ThemePalette, index: number, color: string) => {
233
259
  setCustomPalette((prev) => {
234
- const currentScale = prev?.[scaleName] ?? palette[scaleName];
235
- const mutableScale = [...currentScale] as unknown as string[];
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[];
236
265
  mutableScale[index] = color;
237
266
  return {...prev, [scaleName]: mutableScale as unknown as ColorScale};
238
267
  });
@@ -262,28 +291,36 @@ export function useThematicState() {
262
291
  const setAccentCurves = editingDark ? setAccentCurvesDark : setAccentCurvesLight;
263
292
 
264
293
  const exportState = useCallback((): ThematicExport => {
265
- const keys: (keyof Omit<ThemePalette, "key">)[] = ["white", "black"];
294
+ const keys: (keyof Omit<ThemePalette, "key">)[] = ["white", "whiteA", "black", "blackA"];
266
295
  if (showLight) {
267
296
  keys.push("base", "accent", "red", "teal", "yellow");
268
297
  }
269
298
  if (showDark) {
270
299
  keys.push("baseDark", "accentDark", "redDark", "tealDark", "yellowDark");
271
300
  }
272
- const scales: Partial<Omit<ThemePalette, "key">> = {};
301
+ const result: ThematicExport = {};
273
302
  for (const k of keys) {
274
- scales[k] = palette[k];
303
+ (result as Record<string, unknown>)[k] = palette[k];
275
304
  }
276
305
  const bothModes = showLight && showDark;
277
- return {scales, ...(bothModes ? {meta: {prefs}} : undefined)};
306
+ if (bothModes) {
307
+ result.meta = {prefs};
308
+ }
309
+ return result;
278
310
  }, [palette, showLight, showDark, prefs]);
279
311
 
280
312
  const importState = useCallback((data: ThematicExport) => {
281
- if (data.meta) {
282
- setPrefs(data.meta.prefs);
313
+ const {meta, ...scales} = data;
314
+ if (!isValidPalette(scales)) {
315
+ alert("Outdated palette format");
316
+ return;
317
+ }
318
+ if (meta) {
319
+ setPrefs(meta.prefs);
283
320
  setStaticPaletteIndex(null);
284
321
  }
285
- const hasLight = "base" in data.scales;
286
- const hasDark = "baseDark" in data.scales;
322
+ const hasLight = "base" in scales;
323
+ const hasDark = "baseDark" in scales;
287
324
  if (hasLight && !hasDark) {
288
325
  setThemePreference("light");
289
326
  setThemePref("light");
@@ -295,7 +332,7 @@ export function useThematicState() {
295
332
  (prev) =>
296
333
  ({
297
334
  ...prev,
298
- ...data.scales,
335
+ ...scales,
299
336
  key: `import-${Date.now()}`,
300
337
  } as Partial<ThemePalette>)
301
338
  );
package/src/thematic.tsx CHANGED
@@ -2,9 +2,7 @@ import {css} from "@linaria/core";
2
2
  import {createPortal} from "react-dom";
3
3
  import {border, space, themeVars} from "./design-system";
4
4
  import {ThemeProvider} from "./theme-provider";
5
- import {OKLCH, produceColor} from "./color-utils";
6
- import type {ColorScale} from "./palette-generator";
7
- import type {ThematicExport} from "./thematic-state";
5
+ import {toOKLCH, toRGB} from "./color-utils";
8
6
  import {useThematicState} from "./thematic-state";
9
7
  import {IconButton} from "./button/icon-button";
10
8
  import Refresh from "./icons/react/Refresh";
@@ -17,7 +15,8 @@ import FileDownload from "./icons/react/FileDownload";
17
15
  import FileUpload from "./icons/react/FileUpload";
18
16
  import {themeIcons} from "./thematic-constants";
19
17
  import {ArrayEditor, ArraySlider, controlRowCss, Slider} from "./thematic-controls";
20
- import {ScalePair, ScaleRow, sectionSeparatorCss} from "./thematic-scales";
18
+ import {scaleCss, ScalePair, ScaleRow, sectionLabelCss, sectionSeparatorCss} from "./thematic-scales";
19
+ import {SwatchWithPicker} from "./thematic-color-picker";
21
20
 
22
21
  const panelCss = css`
23
22
  position: fixed;
@@ -37,18 +36,6 @@ const buttonsCss = css`
37
36
  margin-top: ${space.s12}px;
38
37
  `;
39
38
 
40
- function toOklchScale(scale: ColorScale): ColorScale {
41
- return scale.map((c) => produceColor(c, (x) => x, OKLCH)) as unknown as ColorScale;
42
- }
43
-
44
- function toOklchExport(data: ThematicExport): ThematicExport {
45
- const scales = {...data.scales};
46
- for (const [k, v] of Object.entries(scales)) {
47
- (scales as Record<string, ColorScale>)[k] = toOklchScale(v);
48
- }
49
- return {...data, scales};
50
- }
51
-
52
39
  export function Thematic() {
53
40
  const {
54
41
  palette,
@@ -80,18 +67,17 @@ export function Thematic() {
80
67
  importState,
81
68
  } = useThematicState();
82
69
 
83
- const handleExport = (e: React.MouseEvent) => {
70
+ const handleExport = () => {
84
71
  const data = exportState();
85
- const output = e.altKey ? toOklchExport(data) : data;
86
- const blob = new Blob([JSON.stringify(output, null, 2)], {type: "application/json"});
72
+ const blob = new Blob([JSON.stringify(data, null, 2)], {type: "application/json"});
87
73
  const url = URL.createObjectURL(blob);
88
74
  const a = document.createElement("a");
89
75
  a.href = url;
90
- const hasLight = "base" in data.scales;
91
- const hasDark = "baseDark" in data.scales;
76
+ const hasLight = "base" in data;
77
+ const hasDark = "baseDark" in data;
92
78
  const modes = [hasLight && "light", hasDark && "dark"].filter(Boolean).join("&");
93
79
  const now = new Date().toISOString().replaceAll(/\D+/g, "");
94
- a.download = `palette-${modes}${e.altKey ? "-oklch" : ""}-${now}.json`;
80
+ a.download = `palette-${modes}-${now}.json`;
95
81
  a.click();
96
82
  URL.revokeObjectURL(url);
97
83
  };
@@ -273,11 +259,31 @@ export function Thematic() {
273
259
 
274
260
  <div className={sectionSeparatorCss} />
275
261
 
262
+ <div className={sectionLabelCss}>lights & shades</div>
263
+ <div className={scaleCss}>
264
+ <div style={{border: "1px solid #888", borderRadius: 5}}>
265
+ <SwatchWithPicker
266
+ color={palette.white}
267
+ title={`white\n${toRGB(palette.white)}\n${toOKLCH(palette.white)}`}
268
+ onChange={(c) => updateScaleColor("white", 0, c)}
269
+ isAltPressed={isAltPressed}
270
+ />
271
+ </div>
272
+ <div style={{border: "1px solid #888", borderRadius: 5}}>
273
+ <SwatchWithPicker
274
+ color={palette.black}
275
+ title={`black\n${toRGB(palette.black)}\n${toOKLCH(palette.black)}`}
276
+ onChange={(c) => updateScaleColor("black", 0, c)}
277
+ isAltPressed={isAltPressed}
278
+ />
279
+ </div>
280
+ </div>
281
+
276
282
  <ScaleRow
277
- name="white"
278
- scale={palette.white}
279
- onColorChange={(i, c) => updateScaleColor("white", i, c)}
280
- onHighlight={highlightScale("white")}
283
+ name="whiteA"
284
+ scale={palette.whiteA}
285
+ onColorChange={(i, c) => updateScaleColor("whiteA", i, c)}
286
+ onHighlight={highlightScale("whiteA")}
281
287
  onHighlightEnd={clearHighlight}
282
288
  isAltPressed={isAltPressed}
283
289
  />
@@ -298,13 +304,11 @@ export function Thematic() {
298
304
  onChange={(v) => update({whiteChroma: v})}
299
305
  />
300
306
 
301
- <div className={sectionSeparatorCss} />
302
-
303
307
  <ScaleRow
304
- name="black"
305
- scale={palette.black}
306
- onColorChange={(i, c) => updateScaleColor("black", i, c)}
307
- onHighlight={highlightScale("black")}
308
+ name="blackA"
309
+ scale={palette.blackA}
310
+ onColorChange={(i, c) => updateScaleColor("blackA", i, c)}
311
+ onHighlight={highlightScale("blackA")}
308
312
  onHighlightEnd={clearHighlight}
309
313
  isAltPressed={isAltPressed}
310
314
  />
@@ -11,12 +11,9 @@ import {
11
11
  useThemeStyles,
12
12
  } from "./theme-provider";
13
13
  import {defaultDarkTheme, defaultLightTheme, defaultLightThemeStyles} from "./theme-styles";
14
- import {createInlineTheme, getThemeColors} from "./design-system";
14
+ import {createInlineStyles, getThemeColors, themeVars} from "./design-system";
15
15
  import {makeDefaultPalette} from "./palette-generator";
16
16
 
17
- // Use real createInlineTheme, not the mock
18
- jest.unmock("./create-inline-theme");
19
-
20
17
  const palette = makeDefaultPalette();
21
18
 
22
19
  function ThemeDisplay({testId = "theme-display"}: {testId?: string}) {
@@ -79,8 +76,8 @@ describe("ThemeProvider", () => {
79
76
  const darkTheme = getThemeColors(null, "dark", palette);
80
77
 
81
78
  // Compare full inline themes to verify they differ
82
- const lightStyles = createInlineTheme(lightTheme, []);
83
- const darkStyles = createInlineTheme(darkTheme, []);
79
+ const lightStyles = createInlineStyles(lightTheme);
80
+ const darkStyles = createInlineStyles(darkTheme);
84
81
 
85
82
  // Count how many properties differ between light and dark
86
83
  let diffCount = 0;
@@ -117,8 +114,8 @@ describe("ThemeProvider", () => {
117
114
  const theme3 = getThemeColors(null, "light", palette); // back to light
118
115
 
119
116
  // Verify themes differ
120
- const styles1 = createInlineTheme(theme1, []);
121
- const styles2 = createInlineTheme(theme2, []);
117
+ const styles1 = createInlineStyles(theme1);
118
+ const styles2 = createInlineStyles(theme2);
122
119
 
123
120
  let diffCount = 0;
124
121
  for (const key in styles2) {
@@ -199,8 +196,8 @@ describe("ThemeProvider", () => {
199
196
  const lightTheme = getThemeColors(null, "light", palette);
200
197
  const darkTheme = getThemeColors(null, "dark", palette);
201
198
 
202
- const lightStyles = createInlineTheme(lightTheme, []);
203
- const darkStyles = createInlineTheme(darkTheme, []);
199
+ const lightStyles = createInlineStyles(lightTheme);
200
+ const darkStyles = createInlineStyles(darkTheme);
204
201
 
205
202
  let diffCount = 0;
206
203
  for (const key in darkStyles) {
@@ -309,6 +306,21 @@ describe("ThemeProvider", () => {
309
306
  // Theme should be generated with the custom color
310
307
  expect(screen.getByTestId("theme-display")).toBeInTheDocument();
311
308
  });
309
+
310
+ it("color={var(--...)} is a no-op (same theme as parent)", () => {
311
+ const {container} = render(
312
+ <ThemeProvider mode="light">
313
+ <ThemeProvider color={themeVars.accentTextColor}>
314
+ <ThemeDisplay />
315
+ </ThemeProvider>
316
+ </ThemeProvider>
317
+ );
318
+
319
+ const divs = Array.from(container.querySelectorAll("div")).filter((d) => !d.hasAttribute("data-testid"));
320
+ const innerDiv = divs[divs.length - 1];
321
+ const style = innerDiv?.getAttribute("style") || "";
322
+ expect(style).toBe("");
323
+ });
312
324
  });
313
325
 
314
326
  describe("style optimization verification", () => {
@@ -344,8 +356,8 @@ describe("ThemeProvider", () => {
344
356
  const lightTheme = getThemeColors(null, "light", palette);
345
357
  const darkTheme = getThemeColors(null, "dark", palette);
346
358
 
347
- const lightStyles = createInlineTheme(lightTheme, []);
348
- const darkStyles = createInlineTheme(darkTheme, []);
359
+ const lightStyles = createInlineStyles(lightTheme);
360
+ const darkStyles = createInlineStyles(darkTheme);
349
361
 
350
362
  // Calculate light→dark diff
351
363
  const lightToDarkDiff: Record<string, string> = {};
@@ -371,11 +383,11 @@ describe("ThemeProvider", () => {
371
383
  const lightTheme = getThemeColors(null, "light", palette);
372
384
  const darkTheme = getThemeColors(null, "dark", palette);
373
385
 
374
- const fullStyles = createInlineTheme(darkTheme, []);
386
+ const fullStyles = createInlineStyles(darkTheme);
375
387
  const fullCount = Object.keys(fullStyles).length;
376
388
 
377
389
  // Calculate diff
378
- const lightStyles = createInlineTheme(lightTheme, []);
390
+ const lightStyles = createInlineStyles(lightTheme);
379
391
  let diffCount = 0;
380
392
  for (const key in fullStyles) {
381
393
  if (fullStyles[key] !== lightStyles[key]) {
@@ -666,8 +678,8 @@ describe("ThemeProvider", () => {
666
678
  const lightTheme = getThemeColors(null, "light", palette);
667
679
  const darkTheme = getThemeColors(null, "dark", palette);
668
680
 
669
- const lightStyles = createInlineTheme(lightTheme, []);
670
- const darkStyles = createInlineTheme(darkTheme, []);
681
+ const lightStyles = createInlineStyles(lightTheme);
682
+ const darkStyles = createInlineStyles(darkTheme);
671
683
  let expectedDiffCount = 0;
672
684
  for (const key in darkStyles) {
673
685
  if (darkStyles[key] !== lightStyles[key]) {
@@ -710,8 +722,8 @@ describe("ThemeProvider", () => {
710
722
  const lightTheme = getThemeColors(null, "light", palette);
711
723
  const darkTheme = getThemeColors(null, "dark", palette);
712
724
 
713
- const lightStyles = createInlineTheme(lightTheme, []);
714
- const darkStyles = createInlineTheme(darkTheme, []);
725
+ const lightStyles = createInlineStyles(lightTheme);
726
+ const darkStyles = createInlineStyles(darkTheme);
715
727
  let expectedDiffCount = 0;
716
728
  for (const key in darkStyles) {
717
729
  if (darkStyles[key] !== lightStyles[key]) {
@@ -765,7 +777,7 @@ describe("ThemeProvider", () => {
765
777
 
766
778
  function StylesCheck() {
767
779
  const styles = useThemeStyles();
768
- const expected = createInlineTheme(darkTheme, []);
780
+ const expected = createInlineStyles(darkTheme);
769
781
  const match = Object.keys(expected).every((k) => styles[k] === expected[k]);
770
782
  return <div data-testid="styles-match" data-match={String(match)} />;
771
783
  }
@@ -1,15 +1,15 @@
1
1
  import invariant from "invariant";
2
2
  import {
3
- defaultDarkTheme,
4
- defaultDarkThemeStyles,
3
+ chooseGlobalStyles,
5
4
  defaultLightTheme,
6
5
  defaultLightThemeStyles,
7
6
  defaultPalette,
8
- themeStyles,
7
+ selectGlobalTheme,
9
8
  } from "./theme-styles";
10
9
  import {layoutStyles} from "./layout-styles";
11
10
  import {
12
11
  createContext,
12
+ forwardRef,
13
13
  PropsWithChildren,
14
14
  ReactNode,
15
15
  useContext,
@@ -18,8 +18,8 @@ import {
18
18
  useMemo,
19
19
  useState,
20
20
  } from "react";
21
- import {createInlineTheme, getThemeColors, rejectNoKeys, ThemeColors} from "./design-system";
22
- import {ThemePalette} from "./palette-generator";
21
+ import {createInlineStyles, getThemeColors, ThemeColors, ThemeStyles} from "./design-system";
22
+ import {selectPalette, ThemePalette} from "./palette-generator";
23
23
  import {
24
24
  subscribeOnThemeMenuPreferenceChange,
25
25
  subscribeOnThemeModeChange,
@@ -27,9 +27,9 @@ import {
27
27
  ThemeMenuPreference,
28
28
  ThemeMode,
29
29
  ThemePreference,
30
+ useWarmPreference,
30
31
  } from "./theme-settings";
31
32
  import {css} from "@linaria/core";
32
- import {ThemeStyles} from "./create-inline-theme";
33
33
 
34
34
  const ThemePreferenceContext = createContext<ThemePreference | null>(null);
35
35
 
@@ -67,9 +67,13 @@ export function useThemeStyles(): ThemeStyles {
67
67
 
68
68
  const ThemePaletteContext = createContext<ThemePalette>(defaultPalette);
69
69
 
70
+ export function useThemePalette(): ThemePalette {
71
+ return useContext(ThemePaletteContext);
72
+ }
73
+
70
74
  type ThematicContextValue = {
71
75
  palette: ThemePalette;
72
- setPalette: (palette: ThemePalette) => void;
76
+ setPalette: (palette: ThemePalette | null) => void;
73
77
  };
74
78
 
75
79
  const ThematicContext = createContext<ThematicContextValue>({
@@ -101,7 +105,7 @@ export function ThemeProvider({
101
105
  const theme = themeOverride ?? getThemeColors(color, mode, palette);
102
106
 
103
107
  const parentStyles = useContext(portal ? RootThemeStylesContext : ThemeStylesContext);
104
- const styles = createInlineTheme(theme, rejectNoKeys);
108
+ const styles = createInlineStyles(theme);
105
109
  const stylesDiff = useMemo(() => {
106
110
  if (styles === parentStyles) {
107
111
  return undefined;
@@ -131,6 +135,20 @@ export function ThemeProvider({
131
135
  );
132
136
  }
133
137
 
138
+ /**
139
+ * Wrapper for ThemeProvider that absorbs Radix Portal's `asChild` ref/props.
140
+ * Use as direct child of Radix Portal components (MenuPortal, TooltipPortal, etc.)
141
+ */
142
+ export const RadixPortalThemeProvider = forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<"div">>(
143
+ function PortalThemeProvider({children, ...rest}, ref) {
144
+ return (
145
+ <div ref={ref} {...rest}>
146
+ <ThemeProvider portal>{children}</ThemeProvider>
147
+ </div>
148
+ );
149
+ }
150
+ );
151
+
134
152
  export function MenuThemeProvider({children}: {children: ReactNode}) {
135
153
  const themeMode = useThemeMode();
136
154
  // light2 = always dark menu
@@ -142,31 +160,41 @@ export function RootThemeProvider({
142
160
  initialThemeMode,
143
161
  initialPreference,
144
162
  initialMenuPreference,
163
+ isArchitect = false,
145
164
  children,
146
165
  }: {
147
166
  initialThemeMode: ThemeMode;
148
167
  initialPreference: ThemePreference;
149
168
  initialMenuPreference: ThemeMenuPreference;
169
+ isArchitect?: boolean;
150
170
  children: ReactNode;
151
171
  }): JSX.Element {
152
172
  const [themeMode, setThemeMode] = useState<ThemeMode>(initialThemeMode);
153
173
  useEffect(() => subscribeOnThemeModeChange(setThemeMode), []);
154
174
 
155
- const [palette, setPalette] = useState<ThemePalette>(defaultPalette);
175
+ const [warm] = useWarmPreference();
176
+ useEffect(() => chooseGlobalStyles(themeMode, warm), [themeMode, warm]);
177
+
178
+ const [paletteOverride, setPaletteOverride] = useState<ThemePalette | null>(null);
179
+
180
+ const palette = useMemo(() => {
181
+ return paletteOverride ?? selectPalette({warmLook: warm, isArchitect});
182
+ }, [paletteOverride, warm, isArchitect]);
156
183
 
157
184
  let res = <ThemeProvider theme={getThemeColors(null, themeMode, palette)}>{children}</ThemeProvider>;
158
185
 
159
186
  // Set base context to what global CSS has, so ThemeProvider diffs against it
160
- const cssThemeStyles = themeMode === "dark" ? defaultDarkThemeStyles : defaultLightThemeStyles;
187
+ const [cssThemeColors, cssThemeStyles] = selectGlobalTheme(warm, themeMode === "dark");
161
188
  res = <ThemeStylesContext.Provider value={cssThemeStyles}>{res}</ThemeStylesContext.Provider>;
162
189
  res = <RootThemeStylesContext.Provider value={cssThemeStyles}>{res}</RootThemeStylesContext.Provider>;
163
-
164
- const cssThemeColors = themeMode === "dark" ? defaultDarkTheme : defaultLightTheme;
165
190
  res = <ThemeColorsContext.Provider value={cssThemeColors}>{res}</ThemeColorsContext.Provider>;
166
191
 
167
192
  res = <ThemePaletteContext.Provider value={palette}>{res}</ThemePaletteContext.Provider>;
168
193
 
169
- const thematicContextValue = useMemo<ThematicContextValue>(() => ({palette, setPalette}), [palette]);
194
+ const thematicContextValue = useMemo<ThematicContextValue>(
195
+ () => ({palette, setPalette: setPaletteOverride}),
196
+ [palette, setPaletteOverride]
197
+ );
170
198
  res = <ThematicContext.Provider value={thematicContextValue}>{res}</ThematicContext.Provider>;
171
199
 
172
200
  const [themePreference, setThemePreference] = useState<ThemePreference>(initialPreference);
@@ -185,30 +213,5 @@ export function RootThemeProvider({
185
213
  };
186
214
  }, []);
187
215
 
188
- useEffect(() => {
189
- const darkThemeClassName = "dark-theme";
190
- const lightThemeAndDarkMenuClassName = "light-theme-and-dark-menu";
191
- const meta = document.querySelector('meta[name="color-scheme"]');
192
- let colorScheme = "light";
193
- const documentElement = document.documentElement;
194
- documentElement.classList.remove(darkThemeClassName);
195
- documentElement.classList.remove(lightThemeAndDarkMenuClassName);
196
- documentElement.classList.add(themeStyles);
197
- if (themeMode === "dark") {
198
- documentElement.classList.add(darkThemeClassName);
199
- colorScheme = "dark";
200
- } else if (themeMode === "light2") {
201
- documentElement.classList.add(lightThemeAndDarkMenuClassName);
202
- }
203
-
204
- documentElement.style.colorScheme = colorScheme;
205
- meta?.setAttribute("content", colorScheme);
206
-
207
- return () => {
208
- documentElement.classList.remove(darkThemeClassName);
209
- documentElement.classList.remove(lightThemeAndDarkMenuClassName);
210
- };
211
- }, [themeMode]);
212
-
213
216
  return res;
214
217
  }
@@ -1,3 +1,4 @@
1
+ import {useEffect, useState} from "react";
1
2
  import {getMediaQueryList, subscribeOnChange as subscribeOnMediaQueryChange} from "./media-query-utils";
2
3
 
3
4
  const listenersThemeMode = new Map();
@@ -10,6 +11,7 @@ const themeMenuPreferenceKey = "theme-menu-preference";
10
11
  export type ThemePreference = "dark" | "light" | "auto";
11
12
  export type ThemeMenuPreference = "dark" | "auto";
12
13
 
14
+ export const themeModes = ["dark", "light", "light2"] as const;
13
15
  export type ThemeMode = "dark" | "light" | "light2";
14
16
 
15
17
  export function getThemePreference(): ThemePreference {
@@ -56,22 +58,18 @@ function getDarkMediaQuery() {
56
58
  }
57
59
 
58
60
  export function getThemeMode(): ThemeMode {
59
- const preference = getThemePreference();
60
- const menuPreference = getThemeMenuPreference();
61
-
62
- if (preference === "auto") {
63
- const matches = getDarkMediaQuery().matches;
64
- if (matches) {
61
+ const item = getThemePreference();
62
+ const menu = getThemeMenuPreference();
63
+ if (item === "auto") {
64
+ if (getDarkMediaQuery().matches) {
65
65
  return "dark";
66
- }
67
- if (menuPreference === "dark") {
66
+ } else if (menu === "dark") {
68
67
  return "light2";
69
68
  }
70
69
  return "light";
71
- } else if (preference === "dark") {
70
+ } else if (item === "dark") {
72
71
  return "dark";
73
- }
74
- if (menuPreference === "dark") {
72
+ } else if (menu === "dark") {
75
73
  return "light2";
76
74
  }
77
75
  return "light";
@@ -171,3 +169,60 @@ export function subscribeOnThemeModeChange(cb: (themeMode: ThemeMode) => void):
171
169
  window.removeEventListener("storage", storageListener);
172
170
  };
173
171
  }
172
+
173
+ const warmKey = "warm";
174
+ const warmListeners = new Set<(warm: boolean) => void>();
175
+
176
+ export function hasWarmPreference(): boolean {
177
+ try {
178
+ return localStorage.getItem(warmKey) !== null;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ export function loadWarmPreference(): boolean {
185
+ try {
186
+ return localStorage.getItem(warmKey) === "true";
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ export function storeWarmPreference(warm: boolean) {
193
+ try {
194
+ localStorage.setItem(warmKey, JSON.stringify(warm));
195
+ } catch {
196
+ // ignore
197
+ }
198
+ warmListeners.forEach((cb) => cb(warm));
199
+ }
200
+
201
+ export function subscribeOnWarmPreferenceChange(cb: (warm: boolean) => void): () => void {
202
+ warmListeners.add(cb);
203
+ let prev = loadWarmPreference();
204
+ const storageListener = () => {
205
+ const next = loadWarmPreference();
206
+ if (prev !== next) {
207
+ prev = next;
208
+ cb(next);
209
+ }
210
+ };
211
+ window.addEventListener("storage", storageListener);
212
+ return () => {
213
+ warmListeners.delete(cb);
214
+ window.removeEventListener("storage", storageListener);
215
+ };
216
+ }
217
+
218
+ export function useWarmPreference() {
219
+ const [warm, setWarm] = useState(loadWarmPreference);
220
+ useEffect(() => subscribeOnWarmPreferenceChange(setWarm), []);
221
+ return [
222
+ warm,
223
+ (warm: boolean) => {
224
+ setWarm(warm);
225
+ storeWarmPreference(warm);
226
+ },
227
+ ] as const;
228
+ }