@spatika/react 1.4.0 → 1.5.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 (93) hide show
  1. package/AGENTS.md +6 -3
  2. package/README.md +1 -1
  3. package/dist/composites/EventCalendar.js +3 -3
  4. package/dist/composites/GlassCard.d.ts +1 -1
  5. package/dist/composites/RichTextEditor.d.ts +1 -4
  6. package/dist/composites/RichTextEditor.d.ts.map +1 -1
  7. package/dist/composites/RichTextEditor.js +12 -19
  8. package/dist/composites/SurfaceCard.d.ts +1 -1
  9. package/dist/composites/Tag.d.ts +2 -2
  10. package/dist/index.d.ts +8 -22
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -11
  13. package/dist/lib/breakpoints.d.ts +17 -0
  14. package/dist/lib/breakpoints.d.ts.map +1 -0
  15. package/dist/lib/breakpoints.js +23 -0
  16. package/dist/lib/create-theme.d.ts +162 -0
  17. package/dist/lib/create-theme.d.ts.map +1 -0
  18. package/dist/lib/create-theme.js +138 -0
  19. package/dist/lib/scheduler.js +2 -2
  20. package/dist/lib/themes.d.ts +17 -7
  21. package/dist/lib/themes.d.ts.map +1 -1
  22. package/dist/lib/themes.js +22 -8
  23. package/dist/lib/use-media-query.d.ts +9 -0
  24. package/dist/lib/use-media-query.d.ts.map +1 -1
  25. package/dist/lib/use-media-query.js +28 -1
  26. package/dist/primitives/Alert.d.ts +1 -1
  27. package/dist/primitives/Badge.d.ts +2 -2
  28. package/dist/primitives/Button.d.ts +2 -2
  29. package/dist/primitives/Container.d.ts +2 -2
  30. package/dist/primitives/Fab.d.ts +4 -4
  31. package/dist/primitives/Grid.d.ts +2 -1
  32. package/dist/primitives/Grid.d.ts.map +1 -1
  33. package/dist/primitives/Grid.js +10 -2
  34. package/dist/primitives/IconButton.d.ts +2 -2
  35. package/dist/primitives/ToggleButton.d.ts +1 -1
  36. package/dist/theme/SpatikaThemeProvider.d.ts +4 -5
  37. package/dist/theme/SpatikaThemeProvider.d.ts.map +1 -1
  38. package/dist/theme/SpatikaThemeProvider.js +28 -13
  39. package/package.json +4 -2
  40. package/skills/spatika-ui/SKILL.md +25 -6
  41. package/src/composites/EventCalendar.test.tsx +293 -0
  42. package/src/composites/EventCalendar.tsx +3 -3
  43. package/src/composites/RichTextEditor.test.tsx +51 -0
  44. package/src/composites/RichTextEditor.tsx +20 -84
  45. package/src/composites/chart-ui.test.tsx +21 -0
  46. package/src/composites/scheduler.test.tsx +0 -258
  47. package/src/index.test.ts +6 -0
  48. package/src/index.ts +138 -72
  49. package/src/lib/breakpoints.test.ts +29 -0
  50. package/src/lib/breakpoints.ts +28 -0
  51. package/src/lib/create-theme.test.ts +79 -0
  52. package/src/lib/create-theme.ts +249 -0
  53. package/src/lib/scheduler.test.ts +10 -0
  54. package/src/lib/scheduler.ts +2 -2
  55. package/src/lib/themes.test.ts +17 -0
  56. package/src/lib/themes.ts +43 -12
  57. package/src/lib/use-media-query.test.tsx +80 -0
  58. package/src/lib/use-media-query.ts +35 -1
  59. package/src/primitives/Grid.test.tsx +55 -0
  60. package/src/primitives/Grid.tsx +13 -3
  61. package/src/primitives/mui-more.test.tsx +17 -0
  62. package/src/theme/SpatikaThemeProvider.test.tsx +38 -0
  63. package/src/theme/SpatikaThemeProvider.tsx +43 -15
  64. package/src/composites/ChartDataGrid.test.tsx +0 -57
  65. package/src/composites/ChartDataGrid.tsx +0 -196
  66. package/src/composites/MapChart.test.tsx +0 -39
  67. package/src/composites/MapChart.tsx +0 -130
  68. package/src/composites/ScatterWebGL.test.tsx +0 -25
  69. package/src/composites/ScatterWebGL.tsx +0 -79
  70. package/src/composites/cartesian-charts.test.tsx +0 -136
  71. package/src/composites/cartesian-charts.tsx +0 -1355
  72. package/src/composites/chart-composition.test.tsx +0 -68
  73. package/src/composites/chart-composition.tsx +0 -1039
  74. package/src/composites/chart-interaction.ts +0 -136
  75. package/src/composites/chart-ui.tsx +0 -336
  76. package/src/composites/charts-3d.test.tsx +0 -25
  77. package/src/composites/charts-3d.tsx +0 -318
  78. package/src/composites/charts.test.tsx +0 -254
  79. package/src/composites/flow-charts.test.tsx +0 -98
  80. package/src/composites/flow-charts.tsx +0 -641
  81. package/src/composites/radial-charts.test.tsx +0 -89
  82. package/src/composites/radial-charts.tsx +0 -643
  83. package/src/lib/chart-export.test.ts +0 -20
  84. package/src/lib/chart-export.ts +0 -99
  85. package/src/lib/charts-platform.test.ts +0 -93
  86. package/src/lib/charts.test.ts +0 -132
  87. package/src/lib/charts.ts +0 -1021
  88. package/src/lib/geo.test.ts +0 -42
  89. package/src/lib/geo.ts +0 -138
  90. package/src/lib/iso.test.ts +0 -22
  91. package/src/lib/iso.ts +0 -102
  92. package/src/lib/webgl-scatter.test.ts +0 -36
  93. package/src/lib/webgl-scatter.ts +0 -230
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ createTheme,
4
+ injectCustomThemeStyles,
5
+ PALETTE_CSS_VARS,
6
+ themeToCss,
7
+ } from "./create-theme";
8
+
9
+ describe("createTheme", () => {
10
+ afterEach(() => {
11
+ document.getElementById("spk-custom-themes")?.remove();
12
+ });
13
+
14
+ it("overlays palette, glass, and shape onto a built-in theme", () => {
15
+ const theme = createTheme({
16
+ id: "brand",
17
+ label: "Brand",
18
+ extends: "neelam",
19
+ palette: { primary: "#22c55e", primaryForeground: "#052e16", chart1: "#4ade80" },
20
+ glass: { blur: "12px" },
21
+ shape: { radius: "0.75rem" },
22
+ typography: { fontFamily: '"Source Sans 3", sans-serif' },
23
+ vars: { "--shadow-soft": "none" },
24
+ });
25
+
26
+ expect(theme.id).toBe("brand");
27
+ expect(theme.label).toBe("Brand");
28
+ expect(theme.extends).toBe("neelam");
29
+ expect(theme.colorScheme).toBe("dark");
30
+ expect(theme.vars["--primary"]).toBe("#22c55e");
31
+ expect(theme.vars["--primary-foreground"]).toBe("#052e16");
32
+ expect(theme.vars["--chart-1"]).toBe("#4ade80");
33
+ expect(theme.vars["--glass-blur"]).toBe("12px");
34
+ expect(theme.vars["--radius"]).toBe("0.75rem");
35
+ expect(theme.vars["--font-sans"]).toBe('"Source Sans 3", sans-serif');
36
+ expect(theme.vars["--shadow-soft"]).toBe("none");
37
+ });
38
+
39
+ it("defaults to a light Mukta base", () => {
40
+ const theme = createTheme({ id: "lilac", palette: { primary: "#7c3aed" } });
41
+ expect(theme.extends).toBe("mukta");
42
+ expect(theme.colorScheme).toBe("light");
43
+ expect(theme.label).toBe("lilac");
44
+ });
45
+
46
+ it("rejects empty and built-in ids", () => {
47
+ expect(() => createTheme({ id: " " })).toThrow(/required/);
48
+ expect(() => createTheme({ id: "neelam" })).toThrow(/built-in/);
49
+ });
50
+
51
+ it("serializes an attribute selector stylesheet", () => {
52
+ const theme = createTheme({
53
+ id: "brand",
54
+ palette: { primary: "#7c3aed" },
55
+ typography: { fontFamily: "Georgia, serif" },
56
+ });
57
+ const css = themeToCss(theme);
58
+ expect(css).toContain('[data-spk-theme="brand"]');
59
+ expect(css).toContain("--primary: #7c3aed;");
60
+ expect(css).toContain("font-family: var(--font-sans);");
61
+ });
62
+
63
+ it("injects and replaces a document stylesheet", () => {
64
+ const brand = createTheme({ id: "brand", palette: { primary: "#7c3aed" } });
65
+ injectCustomThemeStyles([brand]);
66
+ const el = document.getElementById("spk-custom-themes");
67
+ expect(el?.tagName).toBe("STYLE");
68
+ expect(el?.textContent).toContain("--primary: #7c3aed;");
69
+
70
+ injectCustomThemeStyles([]);
71
+ expect(document.getElementById("spk-custom-themes")).toBeNull();
72
+ });
73
+
74
+ it("maps every palette key to a CSS variable", () => {
75
+ expect(PALETTE_CSS_VARS.primary).toBe("--primary");
76
+ expect(PALETTE_CSS_VARS.mutedForeground).toBe("--muted-foreground");
77
+ expect(Object.keys(PALETTE_CSS_VARS).length).toBeGreaterThan(20);
78
+ });
79
+ });
@@ -0,0 +1,249 @@
1
+ import { isDarkTheme, isThemeId, type BuiltinThemeId, type ThemeId } from "./themes";
2
+
3
+ /** Color tokens that map 1:1 onto Spatika CSS variables. */
4
+ export type ThemePalette = {
5
+ background?: string;
6
+ foreground?: string;
7
+ card?: string;
8
+ cardForeground?: string;
9
+ popover?: string;
10
+ popoverForeground?: string;
11
+ primary?: string;
12
+ primaryForeground?: string;
13
+ secondary?: string;
14
+ secondaryForeground?: string;
15
+ muted?: string;
16
+ mutedForeground?: string;
17
+ accent?: string;
18
+ accentForeground?: string;
19
+ destructive?: string;
20
+ destructiveForeground?: string;
21
+ border?: string;
22
+ input?: string;
23
+ inputBackground?: string;
24
+ switchBackground?: string;
25
+ ring?: string;
26
+ sidebar?: string;
27
+ sidebarForeground?: string;
28
+ sidebarPrimary?: string;
29
+ sidebarPrimaryForeground?: string;
30
+ sidebarAccent?: string;
31
+ sidebarAccentForeground?: string;
32
+ sidebarBorder?: string;
33
+ sidebarRing?: string;
34
+ accentInfo?: string;
35
+ accentInfoBg?: string;
36
+ accentHighlight?: string;
37
+ accentHighlightBg?: string;
38
+ accentWarm?: string;
39
+ accentWarmBg?: string;
40
+ accentDangerBg?: string;
41
+ chart1?: string;
42
+ chart2?: string;
43
+ chart3?: string;
44
+ chart4?: string;
45
+ chart5?: string;
46
+ };
47
+
48
+ export type ThemeGlass = {
49
+ bg?: string;
50
+ panelBg?: string;
51
+ mutedBg?: string;
52
+ headerBg?: string;
53
+ border?: string;
54
+ blur?: string;
55
+ headerBlur?: string;
56
+ menuBlur?: string;
57
+ saturate?: string;
58
+ shadow?: string;
59
+ highlight?: string;
60
+ overlayBg?: string;
61
+ overlayBlur?: string;
62
+ };
63
+
64
+ export type ThemeShape = {
65
+ radius?: string;
66
+ radiusCard?: string;
67
+ radiusControl?: string;
68
+ radiusPill?: string;
69
+ };
70
+
71
+ export type ThemeTypography = {
72
+ fontFamily?: string;
73
+ };
74
+
75
+ export type CreateThemeOptions = {
76
+ /** Unique id written to `data-spk-theme`. Must not collide with a built-in id. */
77
+ id: string;
78
+ label?: string;
79
+ /** Built-in theme whose CSS variables are inherited, then overlaid. */
80
+ extends?: BuiltinThemeId;
81
+ colorScheme?: "light" | "dark";
82
+ palette?: ThemePalette;
83
+ glass?: ThemeGlass;
84
+ shape?: ThemeShape;
85
+ typography?: ThemeTypography;
86
+ /** Escape hatch: raw CSS custom properties, e.g. `{ "--shadow-soft": "none" }`. */
87
+ vars?: Record<string, string>;
88
+ };
89
+
90
+ export type SpatikaTheme = {
91
+ id: string;
92
+ label: string;
93
+ extends: BuiltinThemeId;
94
+ colorScheme: "light" | "dark";
95
+ vars: Record<string, string>;
96
+ };
97
+
98
+ export const PALETTE_CSS_VARS = {
99
+ background: "--background",
100
+ foreground: "--foreground",
101
+ card: "--card",
102
+ cardForeground: "--card-foreground",
103
+ popover: "--popover",
104
+ popoverForeground: "--popover-foreground",
105
+ primary: "--primary",
106
+ primaryForeground: "--primary-foreground",
107
+ secondary: "--secondary",
108
+ secondaryForeground: "--secondary-foreground",
109
+ muted: "--muted",
110
+ mutedForeground: "--muted-foreground",
111
+ accent: "--accent",
112
+ accentForeground: "--accent-foreground",
113
+ destructive: "--destructive",
114
+ destructiveForeground: "--destructive-foreground",
115
+ border: "--border",
116
+ input: "--input",
117
+ inputBackground: "--input-background",
118
+ switchBackground: "--switch-background",
119
+ ring: "--ring",
120
+ sidebar: "--sidebar",
121
+ sidebarForeground: "--sidebar-foreground",
122
+ sidebarPrimary: "--sidebar-primary",
123
+ sidebarPrimaryForeground: "--sidebar-primary-foreground",
124
+ sidebarAccent: "--sidebar-accent",
125
+ sidebarAccentForeground: "--sidebar-accent-foreground",
126
+ sidebarBorder: "--sidebar-border",
127
+ sidebarRing: "--sidebar-ring",
128
+ accentInfo: "--accent-info",
129
+ accentInfoBg: "--accent-info-bg",
130
+ accentHighlight: "--accent-highlight",
131
+ accentHighlightBg: "--accent-highlight-bg",
132
+ accentWarm: "--accent-warm",
133
+ accentWarmBg: "--accent-warm-bg",
134
+ accentDangerBg: "--accent-danger-bg",
135
+ chart1: "--chart-1",
136
+ chart2: "--chart-2",
137
+ chart3: "--chart-3",
138
+ chart4: "--chart-4",
139
+ chart5: "--chart-5",
140
+ } as const satisfies Record<keyof ThemePalette, string>;
141
+
142
+ export const GLASS_CSS_VARS = {
143
+ bg: "--glass-bg",
144
+ panelBg: "--glass-panel-bg",
145
+ mutedBg: "--glass-muted-bg",
146
+ headerBg: "--glass-header-bg",
147
+ border: "--glass-border",
148
+ blur: "--glass-blur",
149
+ headerBlur: "--glass-header-blur",
150
+ menuBlur: "--glass-menu-blur",
151
+ saturate: "--glass-saturate",
152
+ shadow: "--glass-shadow",
153
+ highlight: "--glass-highlight",
154
+ overlayBg: "--modal-overlay-bg",
155
+ overlayBlur: "--modal-overlay-blur",
156
+ } as const satisfies Record<keyof ThemeGlass, string>;
157
+
158
+ export const SHAPE_CSS_VARS = {
159
+ radius: "--radius",
160
+ radiusCard: "--radius-card",
161
+ radiusControl: "--radius-control",
162
+ radiusPill: "--radius-pill",
163
+ } as const satisfies Record<keyof ThemeShape, string>;
164
+
165
+ const CUSTOM_THEME_STYLE_ID = "spk-custom-themes";
166
+
167
+ function assignMappedVars<T extends Record<string, string>>(
168
+ vars: Record<string, string>,
169
+ source: object | undefined,
170
+ map: T,
171
+ ) {
172
+ if (!source) return;
173
+ for (const [key, cssVar] of Object.entries(map)) {
174
+ const value = (source as Record<string, string | undefined>)[key];
175
+ if (typeof value === "string" && value.length > 0) vars[cssVar] = value;
176
+ }
177
+ }
178
+
179
+ /** Build a custom theme that overlays CSS variables on a built-in Spatika theme. */
180
+ export function createTheme(options: CreateThemeOptions): SpatikaTheme {
181
+ const id = options.id.trim();
182
+ if (!id) {
183
+ throw new Error("createTheme: `id` is required.");
184
+ }
185
+ if (isThemeId(id)) {
186
+ throw new Error(`createTheme: "${id}" is a built-in theme. Choose a different id.`);
187
+ }
188
+
189
+ const base: BuiltinThemeId = options.extends ?? "mukta";
190
+ const colorScheme = options.colorScheme ?? (isDarkTheme(base) ? "dark" : "light");
191
+ const vars: Record<string, string> = {};
192
+
193
+ assignMappedVars(vars, options.palette, PALETTE_CSS_VARS);
194
+ assignMappedVars(vars, options.glass, GLASS_CSS_VARS);
195
+ assignMappedVars(vars, options.shape, SHAPE_CSS_VARS);
196
+
197
+ if (options.typography?.fontFamily) {
198
+ vars["--font-sans"] = options.typography.fontFamily;
199
+ }
200
+ if (options.vars) {
201
+ for (const [key, value] of Object.entries(options.vars)) {
202
+ if (value) vars[key] = value;
203
+ }
204
+ }
205
+
206
+ return {
207
+ id,
208
+ label: options.label?.trim() || id,
209
+ extends: base,
210
+ colorScheme,
211
+ vars,
212
+ };
213
+ }
214
+
215
+ /** Serialize a custom theme as an attribute selector rule. */
216
+ export function themeToCss(theme: SpatikaTheme): string {
217
+ const entries = Object.entries(theme.vars);
218
+ if (!entries.length) {
219
+ return `[data-spk-theme="${theme.id}"] {\n}\n`;
220
+ }
221
+ const decls = entries.map(([key, value]) => ` ${key}: ${value};`).join("\n");
222
+ const font = theme.vars["--font-sans"]
223
+ ? `\n font-family: var(--font-sans);`
224
+ : "";
225
+ return `[data-spk-theme="${theme.id}"] {\n${decls}${font}\n}\n`;
226
+ }
227
+
228
+ /** Inject (or replace) a single stylesheet for every registered custom theme. */
229
+ export function injectCustomThemeStyles(themes: readonly SpatikaTheme[]): void {
230
+ if (typeof document === "undefined") return;
231
+ let el = document.getElementById(CUSTOM_THEME_STYLE_ID) as HTMLStyleElement | null;
232
+ if (!themes.length) {
233
+ el?.remove();
234
+ return;
235
+ }
236
+ if (!el) {
237
+ el = document.createElement("style");
238
+ el.id = CUSTOM_THEME_STYLE_ID;
239
+ document.head.appendChild(el);
240
+ }
241
+ el.textContent = themes.map(themeToCss).join("\n");
242
+ }
243
+
244
+ export function findCustomTheme(
245
+ theme: ThemeId,
246
+ customThemes: readonly SpatikaTheme[] | undefined,
247
+ ): SpatikaTheme | undefined {
248
+ return customThemes?.find((item) => item.id === theme);
249
+ }
@@ -3,6 +3,7 @@ import {
3
3
  addDays,
4
4
  calendarVisibleRange,
5
5
  CALENDAR_AGENDA_DAYS,
6
+ eventColorStyle,
6
7
  formatDateRange,
7
8
  getMonthGrid,
8
9
  getTimelineRange,
@@ -213,3 +214,12 @@ describe("scheduler formatting", () => {
213
214
  expect(lastInstant(event).getDate()).toBe(3);
214
215
  });
215
216
  });
217
+
218
+ describe("eventColorStyle", () => {
219
+ it("uses solid bar fills with light labels for month-spanning events", () => {
220
+ expect(eventColorStyle("rose", "bar")).toEqual({
221
+ backgroundColor: "var(--destructive)",
222
+ color: "#ffffff",
223
+ });
224
+ });
225
+ });
@@ -703,8 +703,8 @@ export function eventColorStyle(
703
703
  if (variant === "dot") return { backgroundColor: color };
704
704
  if (variant === "bar") {
705
705
  return {
706
- backgroundColor: `color-mix(in srgb, ${color} 88%, transparent)`,
707
- color: "var(--primary-foreground)",
706
+ backgroundColor: color,
707
+ color: "#ffffff",
708
708
  };
709
709
  }
710
710
  if (variant === "wash") {
@@ -63,6 +63,17 @@ describe("themes", () => {
63
63
  expect(document.documentElement.classList.contains("sandhya")).toBe(true);
64
64
  });
65
65
 
66
+ it("applies a custom theme id on top of its base", () => {
67
+ applyTheme("brand", undefined, {
68
+ id: "brand",
69
+ extends: "neelam",
70
+ colorScheme: "dark",
71
+ });
72
+ expect(document.documentElement.classList.contains("neelam")).toBe(true);
73
+ expect(document.documentElement.getAttribute("data-spk-theme")).toBe("brand");
74
+ expect(document.documentElement.style.colorScheme).toBe("dark");
75
+ });
76
+
66
77
  it("reads and writes stored themes", () => {
67
78
  expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBeNull();
68
79
  writeStoredTheme(SPATIKA_THEME_STORAGE_KEY, "sandhya");
@@ -70,4 +81,10 @@ describe("themes", () => {
70
81
  writeStoredTheme(false, "neelam");
71
82
  expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBe("sandhya");
72
83
  });
84
+
85
+ it("restores a custom stored theme when it is allowed", () => {
86
+ writeStoredTheme(SPATIKA_THEME_STORAGE_KEY, "brand");
87
+ expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY)).toBeNull();
88
+ expect(readStoredTheme(SPATIKA_THEME_STORAGE_KEY, ["brand"])).toBe("brand");
89
+ });
73
90
  });
package/src/lib/themes.ts CHANGED
@@ -1,14 +1,20 @@
1
- /** Supported theme identifiers applied as classes on a root element. */
1
+ /** Supported built-in theme identifiers applied as classes on a root element. */
2
2
  export const THEME_IDS = ["mukta", "neelam", "usha", "sandhya"] as const;
3
3
 
4
- export type ThemeId = (typeof THEME_IDS)[number];
4
+ export type BuiltinThemeId = (typeof THEME_IDS)[number];
5
5
 
6
- export const DEFAULT_THEME: ThemeId = "mukta";
6
+ /**
7
+ * Built-in ids plus custom ids from `createTheme`.
8
+ * Custom strings keep autocomplete for Mukta / Neelam / Usha / Sandhya.
9
+ */
10
+ export type ThemeId = BuiltinThemeId | (string & {});
11
+
12
+ export const DEFAULT_THEME: BuiltinThemeId = "mukta";
7
13
 
8
14
  /** Default `localStorage` key used by the docs site and `SpatikaThemeProvider`. */
9
15
  export const SPATIKA_THEME_STORAGE_KEY = "spk-theme";
10
16
 
11
- export const THEME_LABELS: Record<ThemeId, string> = {
17
+ export const THEME_LABELS: Record<BuiltinThemeId, string> = {
12
18
  mukta: "Mukta",
13
19
  neelam: "Neelam",
14
20
  usha: "Usha",
@@ -17,7 +23,7 @@ export const THEME_LABELS: Record<ThemeId, string> = {
17
23
 
18
24
  const THEME_CLASS_LIST = ["neelam", "usha", "sandhya"] as const;
19
25
 
20
- export function isThemeId(value: unknown): value is ThemeId {
26
+ export function isThemeId(value: unknown): value is BuiltinThemeId {
21
27
  return typeof value === "string" && (THEME_IDS as readonly string[]).includes(value);
22
28
  }
23
29
 
@@ -25,12 +31,24 @@ export function isDarkTheme(theme: ThemeId): boolean {
25
31
  return theme === "neelam" || theme === "sandhya";
26
32
  }
27
33
 
34
+ export type ApplyThemeCustom = {
35
+ id: string;
36
+ extends: BuiltinThemeId;
37
+ colorScheme: "light" | "dark";
38
+ };
39
+
28
40
  /** Read a persisted theme. Returns `null` when missing or invalid. */
29
- export function readStoredTheme(storageKey?: string | false): ThemeId | null {
41
+ export function readStoredTheme(
42
+ storageKey?: string | false,
43
+ allowed?: readonly string[],
44
+ ): ThemeId | null {
30
45
  if (!storageKey || typeof window === "undefined") return null;
31
46
  try {
32
47
  const value = window.localStorage.getItem(storageKey);
33
- return isThemeId(value) ? value : null;
48
+ if (!value) return null;
49
+ if (isThemeId(value)) return value;
50
+ if (allowed?.includes(value)) return value;
51
+ return null;
34
52
  } catch {
35
53
  return null;
36
54
  }
@@ -46,14 +64,27 @@ export function writeStoredTheme(storageKey: string | false | undefined, theme:
46
64
  }
47
65
  }
48
66
 
67
+ function resolveBase(theme: ThemeId, custom?: ApplyThemeCustom | null): BuiltinThemeId {
68
+ if (custom) return custom.extends;
69
+ return isThemeId(theme) ? theme : DEFAULT_THEME;
70
+ }
71
+
49
72
  /** Apply a theme class to an element (defaults to `<html>`). */
50
- export function applyTheme(theme: ThemeId, target?: HTMLElement | null) {
73
+ export function applyTheme(
74
+ theme: ThemeId,
75
+ target?: HTMLElement | null,
76
+ custom?: ApplyThemeCustom | null,
77
+ ) {
51
78
  if (typeof document === "undefined") return;
52
79
  const root = target ?? document.documentElement;
80
+ const base = resolveBase(theme, custom);
81
+ const id = custom?.id ?? theme;
82
+
53
83
  root.classList.remove(...THEME_CLASS_LIST);
54
- if (theme !== DEFAULT_THEME) {
55
- root.classList.add(theme);
84
+ if (base !== DEFAULT_THEME) {
85
+ root.classList.add(base);
56
86
  }
57
- root.setAttribute("data-spk-theme", theme);
58
- root.style.colorScheme = isDarkTheme(theme) ? "dark" : "light";
87
+ root.setAttribute("data-spk-theme", id);
88
+ const dark = custom ? custom.colorScheme === "dark" : isDarkTheme(base);
89
+ root.style.colorScheme = dark ? "dark" : "light";
59
90
  }
@@ -0,0 +1,80 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ breakpointDown,
5
+ breakpointUp,
6
+ useBreakpoint,
7
+ useBreakpointDown,
8
+ useBreakpointUp,
9
+ useIsMobile,
10
+ useMediaQuery,
11
+ } from "./use-media-query";
12
+
13
+ function Probe({ query }: { query: string }) {
14
+ const matches = useMediaQuery(query);
15
+ return <span data-testid="mq">{String(matches)}</span>;
16
+ }
17
+
18
+ function BreakpointProbe() {
19
+ const current = useBreakpoint();
20
+ const mdUp = useBreakpointUp("md");
21
+ const mdDown = useBreakpointDown("md");
22
+ const mobile = useIsMobile();
23
+ return (
24
+ <div>
25
+ <span data-testid="bp">{current}</span>
26
+ <span data-testid="up">{String(mdUp)}</span>
27
+ <span data-testid="down">{String(mdDown)}</span>
28
+ <span data-testid="mobile">{String(mobile)}</span>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ function mockMatchMedia(matching: (query: string) => boolean) {
34
+ Object.defineProperty(window, "matchMedia", {
35
+ writable: true,
36
+ value: (query: string) => ({
37
+ matches: matching(query),
38
+ media: query,
39
+ addEventListener: vi.fn(),
40
+ removeEventListener: vi.fn(),
41
+ addListener: vi.fn(),
42
+ removeListener: vi.fn(),
43
+ }),
44
+ });
45
+ }
46
+
47
+ describe("useMediaQuery", () => {
48
+ afterEach(() => {
49
+ cleanup();
50
+ });
51
+
52
+ it("is false until mounted, then follows matchMedia", () => {
53
+ mockMatchMedia((query) => query.includes("900"));
54
+ render(<Probe query="(min-width: 900px)" />);
55
+ expect(screen.getByTestId("mq")).toHaveTextContent("true");
56
+ });
57
+
58
+ it("reports the current breakpoint and aliases", () => {
59
+ mockMatchMedia((query) => {
60
+ if (query === breakpointUp("sm")) return true;
61
+ if (query === breakpointUp("md")) return true;
62
+ if (query === breakpointUp("lg")) return false;
63
+ if (query === breakpointUp("xl")) return false;
64
+ if (query === breakpointDown("md")) return false;
65
+ return false;
66
+ });
67
+ render(<BreakpointProbe />);
68
+ expect(screen.getByTestId("bp")).toHaveTextContent("md");
69
+ expect(screen.getByTestId("up")).toHaveTextContent("true");
70
+ expect(screen.getByTestId("down")).toHaveTextContent("false");
71
+ expect(screen.getByTestId("mobile")).toHaveTextContent("false");
72
+ });
73
+
74
+ it("treats viewports below md as mobile", () => {
75
+ mockMatchMedia((query) => query === breakpointDown("md"));
76
+ render(<BreakpointProbe />);
77
+ expect(screen.getByTestId("bp")).toHaveTextContent("xs");
78
+ expect(screen.getByTestId("mobile")).toHaveTextContent("true");
79
+ });
80
+ });
@@ -1,4 +1,11 @@
1
1
  import { useEffect, useState } from "react";
2
+ import {
3
+ BREAKPOINT_KEYS,
4
+ BREAKPOINTS,
5
+ breakpointDown,
6
+ breakpointUp,
7
+ type Breakpoint,
8
+ } from "./breakpoints";
2
9
 
3
10
  /** Subscribe to a CSS media query. Defaults to `false` until mounted. */
4
11
  export function useMediaQuery(query: string): boolean {
@@ -18,5 +25,32 @@ export function useMediaQuery(query: string): boolean {
18
25
 
19
26
  /** True when the viewport is below the `md` breakpoint (768px). */
20
27
  export function useIsMobile(): boolean {
21
- return useMediaQuery("(max-width: 767px)");
28
+ return useMediaQuery(breakpointDown("md"));
22
29
  }
30
+
31
+ /** True from this breakpoint up (`xs` is always true). */
32
+ export function useBreakpointUp(key: Breakpoint): boolean {
33
+ const matches = useMediaQuery(breakpointUp(key));
34
+ return key === "xs" || matches;
35
+ }
36
+
37
+ /** True strictly below this breakpoint. */
38
+ export function useBreakpointDown(key: Exclude<Breakpoint, "xs">): boolean {
39
+ return useMediaQuery(breakpointDown(key));
40
+ }
41
+
42
+ /** Largest matching breakpoint, starting at `xs`. */
43
+ export function useBreakpoint(): Breakpoint {
44
+ const sm = useMediaQuery(breakpointUp("sm"));
45
+ const md = useMediaQuery(breakpointUp("md"));
46
+ const lg = useMediaQuery(breakpointUp("lg"));
47
+ const xl = useMediaQuery(breakpointUp("xl"));
48
+ if (xl) return "xl";
49
+ if (lg) return "lg";
50
+ if (md) return "md";
51
+ if (sm) return "sm";
52
+ return "xs";
53
+ }
54
+
55
+ export { BREAKPOINTS, BREAKPOINT_KEYS, breakpointDown, breakpointUp };
56
+ export type { Breakpoint };
@@ -0,0 +1,55 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { Box, Grid } from "./Grid";
4
+
5
+ describe("Grid", () => {
6
+ it("applies container layout and breakpoint spans including xl", () => {
7
+ const { container } = render(
8
+ <Grid container spacing={3}>
9
+ <Grid xs={12} sm={6} md={4} lg={3} xl={2}>
10
+ Cell
11
+ </Grid>
12
+ </Grid>,
13
+ );
14
+
15
+ const wrap = container.querySelector("[data-slot='grid'][data-container]");
16
+ expect(wrap).toBeTruthy();
17
+ expect(screen.getByText("Cell")).toBeInTheDocument();
18
+
19
+ const item = container.querySelector("[data-slot='grid'][data-item]");
20
+ expect(item?.className).toContain("col-span-12");
21
+ expect(item?.className).toContain("sm:col-span-6");
22
+ expect(item?.className).toContain("md:col-span-4");
23
+ expect(item?.className).toContain("lg:col-span-3");
24
+ expect(item?.className).toContain("xl:col-span-2");
25
+ });
26
+
27
+ it("skips auto spans and falls back to a full row", () => {
28
+ const { container } = render(
29
+ <Grid container>
30
+ <Grid xs="auto" sm="auto" xl="auto">
31
+ Auto
32
+ </Grid>
33
+ <Grid size="grow">Grow</Grid>
34
+ </Grid>,
35
+ );
36
+
37
+ const items = container.querySelectorAll("[data-slot='grid'][data-item]");
38
+ expect(items[0]?.className).not.toMatch(/\b(sm|md|lg|xl):col-span-/);
39
+ expect(items[0]?.className).toContain("col-span-12");
40
+ expect(items[1]?.className).toContain("col-span-12");
41
+ expect(screen.getByText("Auto")).toBeInTheDocument();
42
+ expect(screen.getByText("Grow")).toBeInTheDocument();
43
+ });
44
+ });
45
+
46
+ describe("Box", () => {
47
+ it("renders as a generic wrapper and can change the element", () => {
48
+ render(
49
+ <Box>
50
+ <Box component="section">Panel</Box>
51
+ </Box>,
52
+ );
53
+ expect(screen.getByText("Panel").tagName).toBe("SECTION");
54
+ });
55
+ });