@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,93 @@
1
+ import { type PropsWithChildren, useContext, useMemo } from "react";
2
+ import { Theme as TamaguiTheme } from "tamagui";
3
+ import type { ThemeName } from "@tamagui/web";
4
+ import { PresetContext, type PresetContextValue } from "./PresetContext";
5
+ import { defaultKnobs, type Knobs } from "./knobs";
6
+ import type { Preset as PresetType } from "./preset.types";
7
+ import { getPreset } from "./shared";
8
+
9
+ export interface PresetProps extends PropsWithChildren {
10
+ /** Named preset to apply. Looked up from the preset registry. */
11
+ preset?: string;
12
+ /** Tamagui theme name (e.g. "blue", "red"). Wraps children in <Theme />. */
13
+ theme?: ThemeName;
14
+ /** Partial knob overrides merged on top of the preset's knobs. */
15
+ overrides?: Partial<Knobs>;
16
+ /** When true (default), inherits from parent Preset. When false, replaces entirely. */
17
+ cascade?: boolean;
18
+ }
19
+
20
+ export function Preset({
21
+ children,
22
+ preset: presetName,
23
+ theme,
24
+ overrides,
25
+ cascade = true,
26
+ }: PresetProps) {
27
+ const parentCtx = useContext(PresetContext);
28
+
29
+ const ctxValue = useMemo((): PresetContextValue => {
30
+ // 1. Determine base preset
31
+ let base: PresetType;
32
+
33
+ if (cascade && parentCtx) {
34
+ // Start from parent's preset
35
+ base = { ...parentCtx.preset };
36
+ // If a preset name is given, overlay it
37
+ if (presetName) {
38
+ const namedPreset = getPreset(presetName);
39
+ if (namedPreset) {
40
+ base = { ...namedPreset };
41
+ }
42
+ }
43
+ // Carry parent overrides forward, then layer ours
44
+ const mergedOverrides: Partial<Knobs> = {
45
+ ...parentCtx.overrides,
46
+ ...overrides,
47
+ };
48
+ return {
49
+ preset: {
50
+ ...base,
51
+ knobs: { ...base.knobs, ...parentCtx.overrides, ...overrides },
52
+ },
53
+ overrides: Object.keys(mergedOverrides).length > 0 ? mergedOverrides : undefined,
54
+ };
55
+ }
56
+
57
+ // cascade=false or no parent — start fresh
58
+ const freshDefault: PresetType = {
59
+ theme: "" as ThemeName,
60
+ knobs: { ...defaultKnobs },
61
+ intents: {},
62
+ tints: [],
63
+ };
64
+
65
+ if (presetName) {
66
+ base = getPreset(presetName) ?? freshDefault;
67
+ } else if (!cascade) {
68
+ // Explicitly non-cascading: always start from defaults
69
+ base = freshDefault;
70
+ } else {
71
+ // No parent context exists: use defaults
72
+ base = freshDefault;
73
+ }
74
+
75
+ if (overrides) {
76
+ return {
77
+ preset: { ...base, knobs: { ...base.knobs, ...overrides } },
78
+ overrides,
79
+ };
80
+ }
81
+
82
+ return { preset: base };
83
+ }, [cascade, parentCtx, presetName, overrides]);
84
+
85
+ let element = <PresetContext.Provider value={ctxValue}>{children}</PresetContext.Provider>;
86
+
87
+ // Wrap with Tamagui <Theme /> when theme is specified
88
+ if (theme) {
89
+ element = <TamaguiTheme name={theme}>{element}</TamaguiTheme>;
90
+ }
91
+
92
+ return element;
93
+ }
@@ -0,0 +1,27 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { Knobs } from "./knobs";
3
+ import type { Preset } from "./preset.types";
4
+ import type { ThemeName } from "@tamagui/web";
5
+
6
+ export interface PresetContextValue {
7
+ preset: Preset;
8
+ overrides?: Partial<Knobs>;
9
+ }
10
+
11
+ export const PresetContext = createContext<PresetContextValue | null>(null);
12
+
13
+ /**
14
+ * Read the nearest Preset context. Returns null if no <Preset> ancestor exists.
15
+ */
16
+ export function usePresetContext(): PresetContextValue | null {
17
+ return useContext(PresetContext);
18
+ }
19
+
20
+ /**
21
+ * Read the tints array from the nearest Preset context.
22
+ * Returns an empty array if no <Preset> ancestor exists.
23
+ */
24
+ export function usePresetTints(): ThemeName[] {
25
+ const ctx = useContext(PresetContext);
26
+ return ctx?.preset.tints ?? [];
27
+ }
@@ -0,0 +1,44 @@
1
+ import { type PropsWithChildren, createContext, useContext } from "react";
2
+ import { Theme } from "tamagui";
3
+ import { usePresetTints } from "./PresetContext";
4
+
5
+ const TintDepthContext = createContext(0);
6
+
7
+ export interface TintProps extends PropsWithChildren {
8
+ /** Extra offset beyond the normal +1 nesting increment. Default 0. */
9
+ alt?: number;
10
+ /** Disable tinting (renders children without a Theme wrapper). */
11
+ disable?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Depth-based tint component. Each nesting level increments the depth
16
+ * and selects a tint from the preset's tints array via modulo.
17
+ *
18
+ * Applies a tint sub-theme based on nesting depth.
19
+ */
20
+ export function Tint({ children, alt = 0, disable, ...props }: TintProps) {
21
+ const parentDepth = useContext(TintDepthContext);
22
+ const depth = parentDepth + 1 + alt;
23
+ const tints = usePresetTints();
24
+ const tint = tints.length > 0 ? tints[depth % tints.length] : undefined;
25
+
26
+ return (
27
+ <TintDepthContext.Provider value={depth}>
28
+ {disable || !tint ? (
29
+ children
30
+ ) : (
31
+ <Theme name={tint} {...props}>
32
+ {children}
33
+ </Theme>
34
+ )}
35
+ </TintDepthContext.Provider>
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Read the current tint depth from context.
41
+ */
42
+ export function useTintDepth(): number {
43
+ return useContext(TintDepthContext);
44
+ }
@@ -0,0 +1,69 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`resolveKnobs > matches snapshot for defaultKnobs 1`] = `
4
+ {
5
+ "control": {
6
+ "focusKnobProps": undefined,
7
+ "focusVisibleKnobProps": undefined,
8
+ "hoverKnobProps": undefined,
9
+ "pressKnobProps": undefined,
10
+ },
11
+ "elevation": {
12
+ "focusKnobProps": undefined,
13
+ "focusVisibleKnobProps": undefined,
14
+ "hoverKnobProps": undefined,
15
+ "pressKnobProps": undefined,
16
+ },
17
+ "knobProps": {
18
+ "body": {
19
+ "fontFamily": "$body",
20
+ "fontWeight": "400",
21
+ },
22
+ "borderRadius": {
23
+ "borderRadius": "$4",
24
+ "borderWidth": 0.5,
25
+ },
26
+ "borderRadiusOuter": {
27
+ "borderRadius": "$3",
28
+ },
29
+ "elevation": undefined,
30
+ "gap": {
31
+ "gap": "$3",
32
+ },
33
+ "gapLg": {
34
+ "gap": "$4",
35
+ },
36
+ "heading": {
37
+ "fontFamily": "$heading",
38
+ "fontWeight": "400",
39
+ },
40
+ "inputBackground": "$color6",
41
+ "inputSurface": {
42
+ "borderWidth": 0.5,
43
+ },
44
+ "outlined": false,
45
+ "panelPadding": {
46
+ "padding": "$3",
47
+ },
48
+ "pointy": false,
49
+ "size": "medium",
50
+ "sizeToken": "$4",
51
+ "space": "medium",
52
+ "surface": {
53
+ "borderWidth": 0.5,
54
+ },
55
+ "textAccent": "high",
56
+ "textAccentColor": "$color",
57
+ "textWeight": {
58
+ "fontWeight": "400",
59
+ },
60
+ "transition": "quick",
61
+ },
62
+ "text": {
63
+ "focusKnobProps": undefined,
64
+ "focusVisibleKnobProps": undefined,
65
+ "hoverKnobProps": undefined,
66
+ "pressKnobProps": undefined,
67
+ },
68
+ }
69
+ `;
@@ -0,0 +1,25 @@
1
+ import { createAnimations } from "@tamagui/animations-css";
2
+
3
+ export const animationConfig = {
4
+ bouncy: "ease-in 200ms",
5
+ lazy: "ease-in 600ms",
6
+ slow: "ease-in 500ms",
7
+ medium: "ease-in-out 250ms",
8
+ quick: "ease-in 100ms",
9
+ tooltip: "ease-in 400ms",
10
+ snappy: "ease-out 80ms",
11
+ gentle: "ease-in-out 450ms",
12
+ } as const;
13
+
14
+ export type AnimationName = keyof typeof animationConfig;
15
+ export const animationNames = Object.keys(animationConfig) as AnimationName[];
16
+ export const animations = createAnimations(animationConfig);
17
+
18
+ function parseDurationMs(timing: string): number {
19
+ const match = timing.match(/(\d+)\s*ms/);
20
+ return match ? Number.parseInt(match[1], 10) : 200;
21
+ }
22
+
23
+ export const animationDurations: Record<AnimationName, number> = Object.fromEntries(
24
+ Object.entries(animationConfig).map(([k, v]) => [k, parseDurationMs(v)]),
25
+ ) as Record<AnimationName, number>;
@@ -0,0 +1 @@
1
+ export * from "./reactNative";
@@ -0,0 +1 @@
1
+ export * from "./css";
@@ -0,0 +1,54 @@
1
+ import { createAnimations } from "@tamagui/animations-react-native";
2
+
3
+ const animationConfig = {
4
+ bouncy: {
5
+ type: "spring",
6
+ damping: 9,
7
+ mass: 0.9,
8
+ stiffness: 150,
9
+ },
10
+ lazy: {
11
+ type: "spring",
12
+ damping: 18,
13
+ stiffness: 50,
14
+ },
15
+ slow: {
16
+ type: "spring",
17
+ damping: 15,
18
+ stiffness: 40,
19
+ },
20
+ medium: {
21
+ type: "spring",
22
+ damping: 15,
23
+ mass: 1,
24
+ stiffness: 120,
25
+ },
26
+ quick: {
27
+ type: "spring",
28
+ damping: 20,
29
+ mass: 1.2,
30
+ stiffness: 250,
31
+ },
32
+ tooltip: {
33
+ type: "spring",
34
+ damping: 10,
35
+ mass: 0.9,
36
+ stiffness: 100,
37
+ },
38
+ snappy: {
39
+ type: "spring",
40
+ damping: 28,
41
+ mass: 0.8,
42
+ stiffness: 350,
43
+ },
44
+ gentle: {
45
+ type: "spring",
46
+ damping: 12,
47
+ mass: 1.4,
48
+ stiffness: 60,
49
+ },
50
+ } as const;
51
+
52
+ export type AnimationName = keyof typeof animationConfig;
53
+ export const animationNames = Object.keys(animationConfig) as AnimationName[];
54
+ export const animations = createAnimations(animationConfig);
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ normalizeToHex,
4
+ relativeLuminance,
5
+ contrastRatio,
6
+ findReadableStep,
7
+ normalizeColorScale,
8
+ $color,
9
+ semanticGroups,
10
+ } from "./colorRules";
11
+
12
+ describe("normalizeToHex", () => {
13
+ it('normalizes "white" to #ffffff', () => {
14
+ expect(normalizeToHex("white")).toBe("#ffffff");
15
+ });
16
+
17
+ it('normalizes "black" to #000000', () => {
18
+ expect(normalizeToHex("black")).toBe("#000000");
19
+ });
20
+
21
+ it("normalizes shorthand hex #fff", () => {
22
+ expect(normalizeToHex("#fff")).toBe("#ffffff");
23
+ });
24
+
25
+ it("normalizes shorthand hex #abc", () => {
26
+ expect(normalizeToHex("#abc")).toBe("#aabbcc");
27
+ });
28
+
29
+ it("passes through 6-digit hex", () => {
30
+ expect(normalizeToHex("#1a2b3c")).toBe("#1a2b3c");
31
+ });
32
+
33
+ it("handles #000 shorthand", () => {
34
+ expect(normalizeToHex("#000")).toBe("#000000");
35
+ });
36
+
37
+ it("handles #FFF case-insensitive", () => {
38
+ expect(normalizeToHex("#FFF")).toBe("#ffffff");
39
+ });
40
+
41
+ it("normalizes HSL values", () => {
42
+ const result = normalizeToHex("hsl(0, 100%, 50%)");
43
+ expect(result).toBe("#ff0000");
44
+ });
45
+
46
+ it("normalizes HSL blue", () => {
47
+ const result = normalizeToHex("hsl(240, 100%, 50%)");
48
+ expect(result).toBe("#0000ff");
49
+ });
50
+
51
+ it("normalizes HSL green", () => {
52
+ const result = normalizeToHex("hsl(120, 100%, 50%)");
53
+ expect(result).toBe("#00ff00");
54
+ });
55
+
56
+ it("returns null for unrecognized color strings", () => {
57
+ expect(normalizeToHex("rgb(255, 0, 0)")).toBeNull();
58
+ expect(normalizeToHex("transparent")).toBeNull();
59
+ expect(normalizeToHex("potato")).toBeNull();
60
+ });
61
+
62
+ it("handles whitespace", () => {
63
+ expect(normalizeToHex(" #abc ")).toBe("#aabbcc");
64
+ });
65
+ });
66
+
67
+ describe("relativeLuminance", () => {
68
+ it("returns 1 for white", () => {
69
+ expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 4);
70
+ });
71
+
72
+ it("returns 0 for black", () => {
73
+ expect(relativeLuminance("#000000")).toBeCloseTo(0, 4);
74
+ });
75
+
76
+ it("returns intermediate value for mid-gray", () => {
77
+ const lum = relativeLuminance("#808080");
78
+ expect(lum).toBeGreaterThan(0.1);
79
+ expect(lum).toBeLessThan(0.5);
80
+ });
81
+ });
82
+
83
+ describe("contrastRatio", () => {
84
+ it("returns 21 for black vs white", () => {
85
+ const lBlack = relativeLuminance("#000000");
86
+ const lWhite = relativeLuminance("#ffffff");
87
+ expect(contrastRatio(lBlack, lWhite)).toBeCloseTo(21, 0);
88
+ });
89
+
90
+ it("returns 1 for identical luminances", () => {
91
+ const lum = relativeLuminance("#808080");
92
+ expect(contrastRatio(lum, lum)).toBeCloseTo(1, 4);
93
+ });
94
+
95
+ it("is commutative (order doesn't matter)", () => {
96
+ const l1 = relativeLuminance("#ff0000");
97
+ const l2 = relativeLuminance("#0000ff");
98
+ expect(contrastRatio(l1, l2)).toBeCloseTo(contrastRatio(l2, l1), 4);
99
+ });
100
+
101
+ it("is always >= 1", () => {
102
+ const l1 = relativeLuminance("#123456");
103
+ const l2 = relativeLuminance("#654321");
104
+ expect(contrastRatio(l1, l2)).toBeGreaterThanOrEqual(1);
105
+ });
106
+ });
107
+
108
+ describe("findReadableStep", () => {
109
+ const lightScale = [
110
+ "#fafafa",
111
+ "#f5f5f5",
112
+ "#eeeeee",
113
+ "#e0e0e0",
114
+ "#bdbdbd",
115
+ "#9e9e9e",
116
+ "#757575",
117
+ "#616161",
118
+ "#424242",
119
+ "#333333",
120
+ "#212121",
121
+ "#111111",
122
+ ];
123
+
124
+ it("returns startStep if it already meets contrast", () => {
125
+ const step = findReadableStep(lightScale, 1, 12, 1);
126
+ expect(step).toBe(12);
127
+ });
128
+
129
+ it("searches in preferred direction when startStep is too low contrast", () => {
130
+ const step = findReadableStep(lightScale, 1, 3, 1);
131
+ expect(step).toBeGreaterThan(3);
132
+ });
133
+
134
+ it("falls back to highest-contrast step if nothing meets threshold", () => {
135
+ const flatScale = Array(12).fill("#808080");
136
+ const step = findReadableStep(flatScale, 1, 6, 1);
137
+ expect(step).toBe(1);
138
+ });
139
+ });
140
+
141
+ describe("normalizeColorScale", () => {
142
+ it("normalizes a valid 12-color hex scale", () => {
143
+ const input = Array(12).fill("#abc");
144
+ const result = normalizeColorScale(input);
145
+ expect(result).toHaveLength(12);
146
+ expect(result![0]).toBe("#aabbcc");
147
+ });
148
+
149
+ it("returns null for wrong length", () => {
150
+ expect(normalizeColorScale(["#000", "#fff"])).toBeNull();
151
+ });
152
+
153
+ it("returns null if any color fails to parse", () => {
154
+ const input = Array(12).fill("#abc");
155
+ input[5] = "not-a-color";
156
+ expect(normalizeColorScale(input)).toBeNull();
157
+ });
158
+
159
+ it("normalizes mixed formats", () => {
160
+ const input = [
161
+ "white",
162
+ "#000",
163
+ "#112233",
164
+ "#abc",
165
+ "black",
166
+ "#fff",
167
+ "#123",
168
+ "#456",
169
+ "#789",
170
+ "#aaa",
171
+ "#bbb",
172
+ "#ccc",
173
+ ];
174
+ const result = normalizeColorScale(input);
175
+ expect(result).not.toBeNull();
176
+ expect(result![0]).toBe("#ffffff");
177
+ expect(result![1]).toBe("#000000");
178
+ });
179
+ });
180
+
181
+ describe("$color", () => {
182
+ it("returns $color{step} token string", () => {
183
+ expect($color(1)).toBe("$color1");
184
+ expect($color(12)).toBe("$color12");
185
+ expect($color(5)).toBe("$color5");
186
+ });
187
+ });
188
+
189
+ describe("semanticGroups", () => {
190
+ it("has 5 groups covering steps 1-12", () => {
191
+ expect(semanticGroups).toHaveLength(5);
192
+ expect(semanticGroups[0].from).toBe(1);
193
+ expect(semanticGroups[semanticGroups.length - 1].to).toBe(12);
194
+ });
195
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Color Rules — reference constants and utilities for the 12-step color scale.
3
+ *
4
+ * The 12 Radix/Tamagui steps divide into semantic groups:
5
+ * 1–2 Background
6
+ * 3–5 UI Background
7
+ * 6–8 Border
8
+ * 9–10 Primary
9
+ * 11–12 Text
10
+ *
11
+ * Tamagui's templates handle all color-to-component mapping automatically.
12
+ * This module provides:
13
+ * - Semantic group definitions for the debugger
14
+ * - Luminance/contrast utilities for accessibility checks
15
+ * - Component color ramp config (button vs input)
16
+ */
17
+
18
+ // ── Luminance / contrast utilities ────────────────────────────
19
+
20
+ function parseChannel(hex: string, offset: number): number {
21
+ return Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
22
+ }
23
+
24
+ function linearize(c: number): number {
25
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
26
+ }
27
+
28
+ /** Parse a CSS color string to a 6-digit hex (handles hex, hsl, named "white"/"black"). */
29
+ export function normalizeToHex(raw: string): string | null {
30
+ const s = raw.trim().toLowerCase();
31
+ if (s === "white" || s === "#fff" || s === "#ffffff") return "#ffffff";
32
+ if (s === "black" || s === "#000" || s === "#000000") return "#000000";
33
+ if (s.startsWith("#") && (s.length === 7 || s.length === 4)) {
34
+ if (s.length === 4) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
35
+ return s;
36
+ }
37
+ const hslMatch = s.match(/hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)/);
38
+ if (hslMatch) {
39
+ const h = Number(hslMatch[1]);
40
+ const sat = Number(hslMatch[2]) / 100;
41
+ const l = Number(hslMatch[3]) / 100;
42
+ const a = sat * Math.min(l, 1 - l);
43
+ const f = (n: number) => {
44
+ const k = (n + h / 30) % 12;
45
+ const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
46
+ return Math.round(c * 255)
47
+ .toString(16)
48
+ .padStart(2, "0");
49
+ };
50
+ return `#${f(0)}${f(8)}${f(4)}`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /** WCAG 2.x relative luminance from a 6-digit hex string. */
56
+ export function relativeLuminance(hex: string): number {
57
+ return (
58
+ 0.2126 * linearize(parseChannel(hex, 1)) +
59
+ 0.7152 * linearize(parseChannel(hex, 3)) +
60
+ 0.0722 * linearize(parseChannel(hex, 5))
61
+ );
62
+ }
63
+
64
+ /** WCAG contrast ratio (always >= 1). */
65
+ export function contrastRatio(l1: number, l2: number): number {
66
+ const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
67
+ return (lighter + 0.05) / (darker + 0.05);
68
+ }
69
+
70
+ export const minContrastRatio = 3;
71
+
72
+ /**
73
+ * Given a 12-color scale (hex strings), a background step, and a starting
74
+ * text step, scan towards `preferredDir` for the first step whose contrast
75
+ * ratio meets minContrastRatio. Falls back to the opposite direction,
76
+ * then to the step with the highest contrast if nothing passes.
77
+ */
78
+ export function findReadableStep(
79
+ scale: string[],
80
+ bgStep: number,
81
+ startStep: number,
82
+ preferredDir: 1 | -1,
83
+ ): number {
84
+ const bgLum = relativeLuminance(scale[bgStep - 1]);
85
+
86
+ if (contrastRatio(bgLum, relativeLuminance(scale[startStep - 1])) >= minContrastRatio) {
87
+ return startStep;
88
+ }
89
+
90
+ for (let s = startStep + preferredDir; s >= 1 && s <= 12; s += preferredDir) {
91
+ if (contrastRatio(bgLum, relativeLuminance(scale[s - 1])) >= minContrastRatio) return s;
92
+ }
93
+
94
+ for (let s = startStep - preferredDir; s >= 1 && s <= 12; s -= preferredDir) {
95
+ if (contrastRatio(bgLum, relativeLuminance(scale[s - 1])) >= minContrastRatio) return s;
96
+ }
97
+
98
+ const l1 = relativeLuminance(scale[0]);
99
+ const l12 = relativeLuminance(scale[11]);
100
+ return contrastRatio(bgLum, l1) >= contrastRatio(bgLum, l12) ? 1 : 12;
101
+ }
102
+
103
+ // ── Component color ramps ─────────────────────────────────────
104
+
105
+ export type ColorStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
106
+
107
+ export interface ColorRamp {
108
+ base: ColorStep;
109
+ hover: ColorStep;
110
+ /** press for buttons, active/checked for inputs */
111
+ active: ColorStep;
112
+ }
113
+
114
+ export interface ComponentColorConfig {
115
+ bg: ColorRamp;
116
+ border: ColorRamp;
117
+ }
118
+
119
+ export interface ButtonColorConfig extends ComponentColorConfig {
120
+ textLow: ColorRamp;
121
+ }
122
+
123
+ export const buttonColors: ButtonColorConfig = {
124
+ bg: { base: 5, hover: 4, active: 6 },
125
+ border: { base: 7, hover: 6, active: 8 },
126
+ textLow: { base: 7, hover: 6, active: 10 },
127
+ };
128
+
129
+ export const inputColors: ComponentColorConfig = {
130
+ bg: { base: 4, hover: 3, active: 5 },
131
+ border: { base: 7, hover: 6, active: 8 },
132
+ };
133
+
134
+ export function $color(step: ColorStep) {
135
+ return `$color${step}` as const;
136
+ }
137
+
138
+ // ── Semantic groups ───────────────────────────────────────────
139
+
140
+ export interface SemanticGroup {
141
+ key: string;
142
+ label: string;
143
+ from: number;
144
+ to: number;
145
+ color: string;
146
+ }
147
+
148
+ export const semanticGroups: SemanticGroup[] = [
149
+ { key: "background", label: "Background", from: 1, to: 2, color: "#6ec" },
150
+ { key: "ui", label: "UI Background", from: 3, to: 5, color: "#b89af5" },
151
+ { key: "border", label: "Border", from: 6, to: 8, color: "#ea8" },
152
+ { key: "primary", label: "Primary", from: 9, to: 10, color: "#f66" },
153
+ { key: "text", label: "Text", from: 11, to: 12, color: "#5af" },
154
+ ];
155
+
156
+ /** Normalise a raw color scale into 6-digit hex strings. Returns null when parsing fails. */
157
+ export function normalizeColorScale(raw: string[]): string[] | null {
158
+ if (raw.length !== 12) return null;
159
+ const out: string[] = [];
160
+ for (const c of raw) {
161
+ const hex = normalizeToHex(c);
162
+ if (!hex) return null;
163
+ out.push(hex);
164
+ }
165
+ return out;
166
+ }