@multiplatform.one/theme 6.0.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 (67) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE +201 -0
  3. package/package.json +85 -0
  4. package/src/audit/baselines/home-login.json +171 -0
  5. package/src/audit/baselines/home-login.png +0 -0
  6. package/src/audit/baselines/home-pokemon.json +355 -0
  7. package/src/audit/baselines/home-pokemon.png +0 -0
  8. package/src/audit/baselines/home.json +194 -0
  9. package/src/audit/baselines/home.png +0 -0
  10. package/src/audit/baselines/pokemon.json +251 -0
  11. package/src/audit/baselines/pokemon.png +0 -0
  12. package/src/audit/constraintAudit.ts +874 -0
  13. package/src/audit/diffReport.spec.ts +191 -0
  14. package/src/audit/diffReport.ts +344 -0
  15. package/src/audit/index.ts +10 -0
  16. package/src/font.ts +49 -0
  17. package/src/fonts/createFontLoader.ts +15 -0
  18. package/src/fonts/createFontLoader.web.ts +9 -0
  19. package/src/fonts/index.ts +2 -0
  20. package/src/fonts/types.ts +13 -0
  21. package/src/index.ts +3 -0
  22. package/src/theme/Intent.tsx +30 -0
  23. package/src/theme/Preset.tsx +93 -0
  24. package/src/theme/PresetContext.ts +27 -0
  25. package/src/theme/Tint.tsx +44 -0
  26. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +69 -0
  27. package/src/theme/animations/css.ts +25 -0
  28. package/src/theme/animations/index.ts +1 -0
  29. package/src/theme/animations/index.web.ts +1 -0
  30. package/src/theme/animations/reactNative.ts +54 -0
  31. package/src/theme/colorRules.spec.ts +195 -0
  32. package/src/theme/colorRules.ts +166 -0
  33. package/src/theme/cookies.spec.ts +185 -0
  34. package/src/theme/cookies.ts +209 -0
  35. package/src/theme/createDefaultThemeConfig.ts +86 -0
  36. package/src/theme/createThemes.ts +286 -0
  37. package/src/theme/devtools/ColorLineVisualizer.tsx +261 -0
  38. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +1657 -0
  39. package/src/theme/devtools/buildSnapshot.spec.ts +113 -0
  40. package/src/theme/devtools/buildSnapshot.ts +103 -0
  41. package/src/theme/devtools/knobIcons.tsx +217 -0
  42. package/src/theme/devtools/vizConstants.spec.ts +58 -0
  43. package/src/theme/devtools/vizConstants.ts +28 -0
  44. package/src/theme/index.ts +21 -0
  45. package/src/theme/intent.spec.tsx +276 -0
  46. package/src/theme/intents.ts +22 -0
  47. package/src/theme/knobs.ts +147 -0
  48. package/src/theme/pokemonScreen.spec.ts +109 -0
  49. package/src/theme/preset.spec.tsx +242 -0
  50. package/src/theme/preset.types.ts +22 -0
  51. package/src/theme/presets.spec.ts +252 -0
  52. package/src/theme/presets.ts +49 -0
  53. package/src/theme/recipes.ts +69 -0
  54. package/src/theme/resolveKnobs.spec.ts +571 -0
  55. package/src/theme/resolveKnobs.ts +226 -0
  56. package/src/theme/shared.spec.ts +84 -0
  57. package/src/theme/shared.tsx +131 -0
  58. package/src/theme/theme.native.tsx +65 -0
  59. package/src/theme/theme.tsx +190 -0
  60. package/src/theme/tint.spec.tsx +179 -0
  61. package/src/theme/useColorScale.native.ts +7 -0
  62. package/src/theme/useColorScale.ts +29 -0
  63. package/src/theme/useResolvedKnobs.ts +121 -0
  64. package/src/theme/useTheme.native.ts +49 -0
  65. package/src/theme/useTheme.ts +213 -0
  66. package/tsconfig.json +11 -0
  67. package/vitest.config.mjs +9 -0
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildSnapshot, type SnapshotInput } from "./buildSnapshot";
3
+
4
+ function makeInput(overrides: Partial<SnapshotInput> = {}): SnapshotInput {
5
+ return {
6
+ knobValues: {},
7
+ lightColors: [],
8
+ darkColors: [],
9
+ ...overrides,
10
+ };
11
+ }
12
+
13
+ describe("buildSnapshot", () => {
14
+ it("generates a markdown string with a title", () => {
15
+ const result = buildSnapshot(makeInput());
16
+ expect(result).toContain("# Theme Snapshot");
17
+ });
18
+
19
+ it("includes the generated timestamp", () => {
20
+ const result = buildSnapshot(makeInput());
21
+ expect(result).toContain("Generated:");
22
+ });
23
+
24
+ it("includes configuration section", () => {
25
+ const result = buildSnapshot(
26
+ makeInput({
27
+ activePreset: "dark-mode",
28
+ schemeSetting: "dark",
29
+ schemeValue: "dark",
30
+ currentColor: "blue",
31
+ }),
32
+ );
33
+ expect(result).toContain("## Configuration");
34
+ expect(result).toContain("dark-mode");
35
+ expect(result).toContain("blue");
36
+ });
37
+
38
+ it("shows (none / custom) when no preset", () => {
39
+ const result = buildSnapshot(makeInput());
40
+ expect(result).toContain("(none / custom)");
41
+ });
42
+
43
+ it("includes knobs table", () => {
44
+ const result = buildSnapshot(
45
+ makeInput({
46
+ knobValues: { fillStyle: "outlined", borderRadius: "large" },
47
+ }),
48
+ );
49
+ expect(result).toContain("## Knobs");
50
+ expect(result).toContain("| fillStyle | outlined |");
51
+ expect(result).toContain("| borderRadius | large |");
52
+ });
53
+
54
+ it("includes state knob overrides when present", () => {
55
+ const result = buildSnapshot(
56
+ makeInput({
57
+ knobValues: { "hover.elevation": "medium" },
58
+ }),
59
+ );
60
+ expect(result).toContain("| hover.elevation | medium |");
61
+ });
62
+
63
+ it("includes semantic groups table", () => {
64
+ const result = buildSnapshot(makeInput());
65
+ expect(result).toContain("## Semantic Groups");
66
+ expect(result).toContain("Background");
67
+ expect(result).toContain("Text");
68
+ });
69
+
70
+ it("includes light and dark color tables when provided", () => {
71
+ const result = buildSnapshot(
72
+ makeInput({
73
+ lightColors: [
74
+ "#fff",
75
+ "#eee",
76
+ "#ddd",
77
+ "#ccc",
78
+ "#bbb",
79
+ "#aaa",
80
+ "#999",
81
+ "#888",
82
+ "#777",
83
+ "#666",
84
+ "#555",
85
+ "#444",
86
+ ],
87
+ darkColors: [
88
+ "#111",
89
+ "#222",
90
+ "#333",
91
+ "#444",
92
+ "#555",
93
+ "#666",
94
+ "#777",
95
+ "#888",
96
+ "#999",
97
+ "#aaa",
98
+ "#bbb",
99
+ "#ccc",
100
+ ],
101
+ }),
102
+ );
103
+ expect(result).toContain("### light mode");
104
+ expect(result).toContain("### dark mode");
105
+ expect(result).toContain("$color1");
106
+ expect(result).toContain("$color12");
107
+ });
108
+
109
+ it("shows placeholder when no colors available", () => {
110
+ const result = buildSnapshot(makeInput());
111
+ expect(result).toContain("_(no theme colors available)_");
112
+ });
113
+ });
@@ -0,0 +1,103 @@
1
+ import { semanticGroups } from "../colorRules";
2
+
3
+ const knobKeys = [
4
+ "fillStyle",
5
+ "borderRadius",
6
+ "borderWidth",
7
+ "elevation",
8
+ "space",
9
+ "textAccent",
10
+ "headingFont",
11
+ "bodyFont",
12
+ "fontWeight",
13
+ "animation",
14
+ ];
15
+
16
+ const stateKnobKeys = ["elevation", "borderWidth", "borderRadius"] as const;
17
+ const stateNames = ["hover", "press", "focus", "focusVisible"] as const;
18
+
19
+ export interface SnapshotInput {
20
+ knobValues: Record<string, string | undefined>;
21
+ activePreset?: string;
22
+ activeProfile?: string;
23
+ schemeSetting?: string;
24
+ schemeValue?: string;
25
+ currentColor?: string;
26
+ lightColors: string[];
27
+ darkColors: string[];
28
+ }
29
+
30
+ export function buildSnapshot(input: SnapshotInput): string {
31
+ const {
32
+ knobValues,
33
+ activePreset,
34
+ activeProfile,
35
+ schemeSetting,
36
+ schemeValue,
37
+ currentColor,
38
+ lightColors,
39
+ darkColors,
40
+ } = input;
41
+ const lines: string[] = [];
42
+ const w = (s: string) => lines.push(s);
43
+
44
+ w("# Theme Snapshot");
45
+ w(`Generated: ${new Date().toISOString()}`);
46
+ w("");
47
+
48
+ // ── Configuration ──────────────────────────────────────────
49
+ w("## Configuration");
50
+ w(`- **Preset**: ${activePreset || "(none / custom)"}`);
51
+ if (activeProfile) w(`- **Profile**: ${activeProfile}`);
52
+ if (schemeSetting) w(`- **Scheme setting**: ${schemeSetting}`);
53
+ if (schemeValue) w(`- **Resolved scheme**: ${schemeValue}`);
54
+ w(`- **Theme color**: ${currentColor || "(none)"}`);
55
+ w("");
56
+
57
+ // ── Knobs ──────────────────────────────────────────────────
58
+ w("## Knobs");
59
+ w("| Knob | Value |");
60
+ w("|------|-------|");
61
+ for (const key of knobKeys) {
62
+ w(`| ${key} | ${knobValues[key] || "(default)"} |`);
63
+ }
64
+ for (const state of stateNames) {
65
+ for (const key of stateKnobKeys) {
66
+ const val = knobValues[`${state}.${key}`];
67
+ if (val) w(`| ${state}.${key} | ${val} |`);
68
+ }
69
+ }
70
+ w("");
71
+
72
+ // ── Semantic Groups ──────────────────────────────────────────
73
+ w("## Semantic Groups");
74
+ w("");
75
+ w("| Group | Steps |");
76
+ w("|-------|-------|");
77
+ for (const g of semanticGroups) {
78
+ w(`| ${g.label} | ${g.from}–${g.to} |`);
79
+ }
80
+ w("");
81
+
82
+ // ── Actual theme colors ($color1–$color12) ─────────────────
83
+ w("## Theme Colors ($color1–$color12)");
84
+ w("");
85
+ for (const [label, colors] of [
86
+ ["light", lightColors],
87
+ ["dark", darkColors],
88
+ ] as const) {
89
+ w(`### ${label} mode`);
90
+ if (colors.length > 0) {
91
+ w("| Token | Hex |");
92
+ w("|-------|-----|");
93
+ for (let i = 0; i < Math.min(12, colors.length); i++) {
94
+ w(`| $color${i + 1} | ${colors[i] || "(empty)"} |`);
95
+ }
96
+ } else {
97
+ w("_(no theme colors available)_");
98
+ }
99
+ w("");
100
+ }
101
+
102
+ return lines.join("\n");
103
+ }
@@ -0,0 +1,217 @@
1
+ import React, { type ReactNode } from "react";
2
+
3
+ /**
4
+ * Shared icon renderers for theme knob controls.
5
+ *
6
+ * Each icon function accepts a pre-resolved foreground `color` string so
7
+ * that both the TanStack Devtools panel and the Storybook addon can use
8
+ * the same rendering logic with their own color schemes.
9
+ *
10
+ * TanStack: `ic(active, isDark)` → theme-adaptive foreground
11
+ * Storybook: `ic(active)` → purple accent for active, gray for inactive
12
+ */
13
+
14
+ export function fillIcon(value: string, color: string): ReactNode {
15
+ if (value === "filled") {
16
+ return <div style={{ width: 14, height: 14, background: color, borderRadius: 2 }} />;
17
+ }
18
+ return <div style={{ width: 14, height: 14, border: `1.5px solid ${color}`, borderRadius: 2 }} />;
19
+ }
20
+
21
+ export function spaceIcon(value: string, color: string): ReactNode {
22
+ const size = ({ small: 8, medium: 12, large: 16 } as Record<string, number>)[value] ?? 12;
23
+ return <div style={{ width: size, height: size, background: color, borderRadius: 2 }} />;
24
+ }
25
+
26
+ export function sizeIcon(value: string, color: string): ReactNode {
27
+ const height = ({ small: 8, medium: 11, large: 14 } as Record<string, number>)[value] ?? 11;
28
+ return <div style={{ width: 4, height: height, background: color, borderRadius: 1 }} />;
29
+ }
30
+
31
+ export function borderRadiusIcon(value: string, color: string): ReactNode {
32
+ const radius =
33
+ ({ none: 0, small: 2, medium: 4, large: 7, full: 10 } as Record<string, number>)[value] ?? 0;
34
+ return (
35
+ <div style={{ width: 14, height: 14, border: `1.5px solid ${color}`, borderRadius: radius }} />
36
+ );
37
+ }
38
+
39
+ export function borderWidthIcon(value: string, color: string): ReactNode {
40
+ if (value === "none") {
41
+ return (
42
+ <div style={{ width: 14, height: 14, background: color, borderRadius: 2, opacity: 0.35 }} />
43
+ );
44
+ }
45
+ const width = ({ small: 1, medium: 1.5, large: 2 } as Record<string, number>)[value] ?? 1;
46
+ return (
47
+ <div style={{ width: 14, height: 14, border: `${width}px solid ${color}`, borderRadius: 2 }} />
48
+ );
49
+ }
50
+
51
+ export function elevationIcon(value: string, color: string, _isDark: boolean): ReactNode {
52
+ const shadowColor = "rgba(128,128,128,0.55)";
53
+ const offset = ({ none: 0, small: 1, medium: 2, large: 3 } as Record<string, number>)[value] ?? 0;
54
+ const shadowSize =
55
+ ({ none: 0, small: 10, medium: 11, large: 12 } as Record<string, number>)[value] ?? 0;
56
+ const squareSize = 10;
57
+ const total = squareSize + offset * 2 + 1;
58
+
59
+ if (offset === 0) {
60
+ return (
61
+ <div
62
+ style={{
63
+ width: squareSize,
64
+ height: squareSize,
65
+ border: `1px solid ${color}`,
66
+ borderRadius: 1,
67
+ }}
68
+ />
69
+ );
70
+ }
71
+
72
+ return (
73
+ <div
74
+ style={{
75
+ position: "relative",
76
+ width: total,
77
+ height: total,
78
+ }}
79
+ >
80
+ <div
81
+ style={{
82
+ position: "absolute",
83
+ bottom: 0,
84
+ right: 0,
85
+ width: shadowSize,
86
+ height: shadowSize,
87
+ background: shadowColor,
88
+ borderRadius: 1,
89
+ }}
90
+ />
91
+ <div
92
+ style={{
93
+ position: "absolute",
94
+ top: 0,
95
+ left: 0,
96
+ width: squareSize,
97
+ height: squareSize,
98
+ border: `1px solid ${color}`,
99
+ borderRadius: 1,
100
+ }}
101
+ />
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export function textAccentIcon(value: string, color: string): ReactNode {
107
+ const weight = value === "low" ? 300 : value === "medium" ? 500 : 700;
108
+ const opacity = value === "low" ? 0.35 : value === "medium" ? 0.7 : 1;
109
+ return (
110
+ <span
111
+ style={{
112
+ fontSize: 12,
113
+ fontWeight: weight,
114
+ color: color,
115
+ opacity,
116
+ fontFamily: "system-ui, sans-serif",
117
+ lineHeight: 1,
118
+ }}
119
+ >
120
+ Aa
121
+ </span>
122
+ );
123
+ }
124
+
125
+ const fontCategoryStyles: Record<
126
+ string,
127
+ { family: string; label: string; letterSpacing?: string; style?: string }
128
+ > = {
129
+ "sans-serif": {
130
+ family: "-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
131
+ label: "Aa",
132
+ },
133
+ serif: { family: "Georgia, 'Times New Roman', Times, serif", label: "Aa" },
134
+ mono: { family: "'SF Mono', 'Cascadia Code', Menlo, monospace", label: "Aa" },
135
+ slab: { family: "'Rockwell', 'Courier New', serif", label: "Aa" },
136
+ rounded: { family: "'Nunito', 'Varela Round', system-ui, sans-serif", label: "Aa" },
137
+ condensed: { family: "'Arial Narrow', sans-serif", label: "Aa", letterSpacing: "-0.03em" },
138
+ cursive: { family: "'Segoe Script', cursive", label: "Aa", style: "italic" },
139
+ handwriting: { family: "'Comic Sans MS', 'Caveat', cursive", label: "Aa" },
140
+ pixel: { family: "'Courier New', monospace", label: "Aa", letterSpacing: "0.05em" },
141
+ blackletter: { family: "'Old English Text MT', serif", label: "Aa" },
142
+ geometric: { family: "'Century Gothic', 'Futura', sans-serif", label: "Aa" },
143
+ };
144
+
145
+ export function headingFontIcon(value: string, color: string): ReactNode {
146
+ const s = fontCategoryStyles[value] ?? { family: "inherit", label: "Aa" };
147
+ return (
148
+ <span
149
+ style={{
150
+ fontSize: 13,
151
+ fontWeight: 700,
152
+ color: color,
153
+ fontFamily: s.family,
154
+ fontStyle: s.style,
155
+ lineHeight: 1,
156
+ letterSpacing: s.letterSpacing,
157
+ }}
158
+ >
159
+ {s.label}
160
+ </span>
161
+ );
162
+ }
163
+
164
+ export function bodyFontIcon(value: string, color: string): ReactNode {
165
+ const s = fontCategoryStyles[value] ?? { family: "inherit", label: "Aa" };
166
+ return (
167
+ <span
168
+ style={{
169
+ fontSize: 13,
170
+ fontWeight: 400,
171
+ color: color,
172
+ fontFamily: s.family,
173
+ fontStyle: s.style,
174
+ lineHeight: 1,
175
+ letterSpacing: s.letterSpacing,
176
+ }}
177
+ >
178
+ {s.label}
179
+ </span>
180
+ );
181
+ }
182
+
183
+ export function fontWeightIcon(value: string, color: string): ReactNode {
184
+ const weight = ({ light: 300, regular: 400, bold: 700 } as Record<string, number>)[value] ?? 400;
185
+ return (
186
+ <span
187
+ style={{
188
+ fontSize: 12,
189
+ fontWeight: weight,
190
+ color: color,
191
+ fontFamily: "system-ui, sans-serif",
192
+ lineHeight: 1,
193
+ }}
194
+ >
195
+ W
196
+ </span>
197
+ );
198
+ }
199
+
200
+ export function modeIcon(value: "system" | "light" | "dark", color: string): ReactNode {
201
+ const base = { width: 14, height: 14, borderRadius: "50%" } as const;
202
+ if (value === "system") {
203
+ return (
204
+ <div
205
+ style={{
206
+ ...base,
207
+ border: `1.5px solid ${color}`,
208
+ background: `linear-gradient(90deg, ${color} 50%, transparent 50%)`,
209
+ }}
210
+ />
211
+ );
212
+ }
213
+ if (value === "light") {
214
+ return <div style={{ ...base, border: `1.5px solid ${color}` }} />;
215
+ }
216
+ return <div style={{ ...base, background: color }} />;
217
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fallbackGray, stepToPx, cellSize } from "./vizConstants";
3
+
4
+ describe("fallbackGray", () => {
5
+ it("returns lighter gray for step 1", () => {
6
+ const rgb = fallbackGray(1);
7
+ expect(rgb).toBe("rgb(230,230,230)");
8
+ });
9
+
10
+ it("returns darker gray for step 12", () => {
11
+ const rgb = fallbackGray(12);
12
+ expect(rgb).toBe("rgb(40,40,40)");
13
+ });
14
+
15
+ it("returns mid-gray for step 6", () => {
16
+ const rgb = fallbackGray(6);
17
+ const match = rgb.match(/rgb\((\d+),(\d+),(\d+)\)/);
18
+ expect(match).not.toBeNull();
19
+ const value = Number(match![1]);
20
+ expect(value).toBeGreaterThan(100);
21
+ expect(value).toBeLessThan(200);
22
+ });
23
+
24
+ it("produces monotonically decreasing gray values from step 1 to 12", () => {
25
+ const values = Array.from({ length: 12 }, (_, i) => {
26
+ const match = fallbackGray(i + 1).match(/(\d+)/);
27
+ return Number(match![1]);
28
+ });
29
+ for (let i = 1; i < values.length; i++) {
30
+ expect(values[i]).toBeLessThanOrEqual(values[i - 1]);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe("stepToPx", () => {
36
+ it("maps step 1 to half a cell", () => {
37
+ expect(stepToPx(1)).toBe(cellSize / 2);
38
+ });
39
+
40
+ it("maps step 2 to 1.5 cells", () => {
41
+ expect(stepToPx(2)).toBe(cellSize + cellSize / 2);
42
+ });
43
+
44
+ it("maps step 12 correctly", () => {
45
+ expect(stepToPx(12)).toBe(11 * cellSize + cellSize / 2);
46
+ });
47
+
48
+ it("increases by cellSize per step", () => {
49
+ expect(stepToPx(3) - stepToPx(2)).toBe(cellSize);
50
+ expect(stepToPx(7) - stepToPx(6)).toBe(cellSize);
51
+ });
52
+ });
53
+
54
+ describe("cellSize", () => {
55
+ it("is 32", () => {
56
+ expect(cellSize).toBe(32);
57
+ });
58
+ });
@@ -0,0 +1,28 @@
1
+ export function luminance(rgb: string): number {
2
+ const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
3
+ if (!canvas) return 0.5;
4
+ const context = canvas.getContext("2d");
5
+ if (!context) return 0.5;
6
+ canvas.width = 1;
7
+ canvas.height = 1;
8
+ context.fillStyle = rgb;
9
+ context.fillRect(0, 0, 1, 1);
10
+ const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
11
+ return (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
12
+ }
13
+
14
+ export function contrastTextFor(bgColor: string): string {
15
+ return luminance(bgColor) > 0.5 ? "#111" : "#f0f0f0";
16
+ }
17
+
18
+ export function fallbackGray(step: number): string {
19
+ const normalizedStep = (step - 1) / 11;
20
+ const grayValue = Math.round(230 - normalizedStep * 190);
21
+ return `rgb(${grayValue},${grayValue},${grayValue})`;
22
+ }
23
+
24
+ export const cellSize = 32;
25
+
26
+ export function stepToPx(step: number): number {
27
+ return (step - 1) * cellSize + cellSize / 2;
28
+ }
@@ -0,0 +1,21 @@
1
+ export * from "./animations/index";
2
+ export * from "./colorRules";
3
+ export * from "./cookies";
4
+ export * from "./createDefaultThemeConfig";
5
+ export * from "./createThemes";
6
+ export * from "./devtools/ThemeDevtoolsPanel";
7
+ export * from "./Intent";
8
+ export * from "./intents";
9
+ export * from "./knobs";
10
+ export * from "./Preset";
11
+ export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
12
+ export * from "./PresetContext";
13
+ export * from "./presets";
14
+ export * from "./recipes";
15
+ export * from "./resolveKnobs";
16
+ export * from "./shared";
17
+
18
+ export * from "./theme";
19
+ export * from "./Tint";
20
+ export * from "./useResolvedKnobs";
21
+ export * from "./useTheme";