@ankhorage/zora 0.11.0 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7279fe4: Derives light and dark primary colors from a single ZORA theme seed using an internal OKLCH color boundary.
8
+
3
9
  ## 0.11.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -90,6 +90,22 @@ export function App({ appTheme }: { appTheme: ZoraTheme }) {
90
90
  }
91
91
  ```
92
92
 
93
+ ZORA themes use a single seed `primaryColor`. ZORA derives mode-specific primary
94
+ colors for light and dark mode internally.
95
+
96
+ ```tsx
97
+ <ZoraProvider
98
+ theme={{
99
+ id: 'studio',
100
+ primaryColor: '#0f766e',
101
+ harmony: 'analogous',
102
+ tone: 'jewel',
103
+ }}
104
+ >
105
+ <App />
106
+ </ZoraProvider>
107
+ ```
108
+
93
109
  `mode` and `themeId` are available on public ZORA components through `ZoraBaseProps`.
94
110
  Use component props for local component/subtree overrides.
95
111
 
@@ -0,0 +1,3 @@
1
+ export { parseHexToOklch } from './oklch';
2
+ export { resolveModePrimaryColor } from './primary';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/internal/color/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { parseHexToOklch } from './oklch';
2
+ export { resolveModePrimaryColor } from './primary';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/internal/color/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC","sourcesContent":["export { parseHexToOklch } from './oklch';\nexport { resolveModePrimaryColor } from './primary';\n"]}
@@ -0,0 +1,6 @@
1
+ import type { ZoraHexColor } from '../../theme/types';
2
+ import type { ZoraOklchColor } from './types';
3
+ export declare function parseHexToOklch(hex: ZoraHexColor): ZoraOklchColor;
4
+ export declare function formatOklchAsHex(color: ZoraOklchColor): ZoraHexColor;
5
+ export declare function clampOklchToGamut(color: ZoraOklchColor): ZoraOklchColor;
6
+ //# sourceMappingURL=oklch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oklch.d.ts","sourceRoot":"","sources":["../../../src/internal/color/oklch.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAc9C,wBAAgB,eAAe,CAAC,GAAG,EAAE,YAAY,GAAG,cAAc,CAoBjE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,YAAY,CAcpE;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,CAavE"}
@@ -0,0 +1,53 @@
1
+ import { converter, formatHex, parse, toGamut } from 'culori';
2
+ const toOklch = converter('oklch');
3
+ const gamutMapToSrgb = toGamut('rgb', 'oklch');
4
+ function normalizeHueDegrees(hue) {
5
+ const normalized = ((hue % 360) + 360) % 360;
6
+ return normalized;
7
+ }
8
+ function isSixDigitHexColor(value) {
9
+ return /^#[0-9a-fA-F]{6}$/.test(value);
10
+ }
11
+ export function parseHexToOklch(hex) {
12
+ if (!isSixDigitHexColor(hex)) {
13
+ throw new Error(`Expected a 6-digit hex color like '#0f766e', got '${String(hex)}'.`);
14
+ }
15
+ const parsed = parse(hex);
16
+ if (!parsed) {
17
+ throw new Error(`Unable to parse hex color '${String(hex)}'.`);
18
+ }
19
+ const oklch = toOklch(parsed);
20
+ if (typeof oklch.l !== 'number' || typeof oklch.c !== 'number') {
21
+ throw new Error(`Unable to convert hex color '${String(hex)}' to OKLCH.`);
22
+ }
23
+ return {
24
+ l: oklch.l,
25
+ c: oklch.c,
26
+ h: normalizeHueDegrees(typeof oklch.h === 'number' ? oklch.h : 0),
27
+ };
28
+ }
29
+ export function formatOklchAsHex(color) {
30
+ const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
31
+ const hex = formatHex(mapped);
32
+ if (!hex || !isSixDigitHexColor(hex)) {
33
+ throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
34
+ }
35
+ const normalized = hex.toLowerCase();
36
+ if (!isSixDigitHexColor(normalized)) {
37
+ throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
38
+ }
39
+ return normalized;
40
+ }
41
+ export function clampOklchToGamut(color) {
42
+ const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
43
+ const clamped = toOklch(mapped);
44
+ if (typeof clamped.l !== 'number' || typeof clamped.c !== 'number') {
45
+ throw new Error('Unable to clamp OKLCH color to sRGB gamut.');
46
+ }
47
+ return {
48
+ l: clamped.l,
49
+ c: clamped.c,
50
+ h: normalizeHueDegrees(typeof clamped.h === 'number' ? clamped.h : 0),
51
+ };
52
+ }
53
+ //# sourceMappingURL=oklch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oklch.js","sourceRoot":"","sources":["../../../src/internal/color/oklch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAK9D,MAAM,OAAO,GAAG,SAAS,CAAC,OAAO,CAAC,CAAC;AACnC,MAAM,cAAc,GAAG,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;AAE/C,SAAS,mBAAmB,CAAC,GAAW;IACtC,MAAM,UAAU,GAAG,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC;IAC7C,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa;IACvC,OAAO,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,GAAiB;IAC/C,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,qDAAqD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACxF,CAAC;IAED,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,8BAA8B,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC9B,IAAI,OAAO,KAAK,CAAC,CAAC,KAAK,QAAQ,IAAI,OAAO,KAAK,CAAC,CAAC,KAAK,QAAQ,EAAE,CAAC;QAC/D,MAAM,IAAI,KAAK,CAAC,gCAAgC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC5E,CAAC;IAED,OAAO;QACL,CAAC,EAAE,KAAK,CAAC,CAAC;QACV,CAAC,EAAE,KAAK,CAAC,CAAC;QACV,CAAC,EAAE,mBAAmB,CAAC,OAAO,KAAK,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KAClE,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAqB;IACpD,MAAM,MAAM,GAAG,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;IACrF,MAAM,GAAG,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;IAE9B,IAAI,CAAC,GAAG,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IAED,MAAM,UAAU,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IACrC,IAAI,CAAC,kBAAkB,CAAC,UAAU,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,sDAAsD,CAAC,CAAC;IAC1E,CAAC;IAED,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,KAAqB;IACrD,MAAM,MAAM,GAAG,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;IACrF,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAEhC,IAAI,OAAO,OAAO,CAAC,CAAC,KAAK,QAAQ,IAAI,OAAO,OAAO,CAAC,CAAC,KAAK,QAAQ,EAAE,CAAC;QACnE,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IAED,OAAO;QACL,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,CAAC,EAAE,OAAO,CAAC,CAAC;QACZ,CAAC,EAAE,mBAAmB,CAAC,OAAO,OAAO,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;KACtE,CAAC;AACJ,CAAC","sourcesContent":["import { converter, formatHex, parse, toGamut } from 'culori';\n\nimport type { ZoraHexColor } from '../../theme/types';\nimport type { ZoraOklchColor } from './types';\n\nconst toOklch = converter('oklch');\nconst gamutMapToSrgb = toGamut('rgb', 'oklch');\n\nfunction normalizeHueDegrees(hue: number): number {\n const normalized = ((hue % 360) + 360) % 360;\n return normalized;\n}\n\nfunction isSixDigitHexColor(value: string): value is ZoraHexColor {\n return /^#[0-9a-fA-F]{6}$/.test(value);\n}\n\nexport function parseHexToOklch(hex: ZoraHexColor): ZoraOklchColor {\n if (!isSixDigitHexColor(hex)) {\n throw new Error(`Expected a 6-digit hex color like '#0f766e', got '${String(hex)}'.`);\n }\n\n const parsed = parse(hex);\n if (!parsed) {\n throw new Error(`Unable to parse hex color '${String(hex)}'.`);\n }\n\n const oklch = toOklch(parsed);\n if (typeof oklch.l !== 'number' || typeof oklch.c !== 'number') {\n throw new Error(`Unable to convert hex color '${String(hex)}' to OKLCH.`);\n }\n\n return {\n l: oklch.l,\n c: oklch.c,\n h: normalizeHueDegrees(typeof oklch.h === 'number' ? oklch.h : 0),\n };\n}\n\nexport function formatOklchAsHex(color: ZoraOklchColor): ZoraHexColor {\n const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });\n const hex = formatHex(mapped);\n\n if (!hex || !isSixDigitHexColor(hex)) {\n throw new Error('Unable to format OKLCH color as a 6-digit hex value.');\n }\n\n const normalized = hex.toLowerCase();\n if (!isSixDigitHexColor(normalized)) {\n throw new Error('Unable to format OKLCH color as a 6-digit hex value.');\n }\n\n return normalized;\n}\n\nexport function clampOklchToGamut(color: ZoraOklchColor): ZoraOklchColor {\n const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });\n const clamped = toOklch(mapped);\n\n if (typeof clamped.l !== 'number' || typeof clamped.c !== 'number') {\n throw new Error('Unable to clamp OKLCH color to sRGB gamut.');\n }\n\n return {\n l: clamped.l,\n c: clamped.c,\n h: normalizeHueDegrees(typeof clamped.h === 'number' ? clamped.h : 0),\n };\n}\n"]}
@@ -0,0 +1,3 @@
1
+ import type { ZoraHexColor, ZoraThemeMode } from '../../theme/types';
2
+ export declare function resolveModePrimaryColor(primaryColor: ZoraHexColor, mode: ZoraThemeMode): ZoraHexColor;
3
+ //# sourceMappingURL=primary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"primary.d.ts","sourceRoot":"","sources":["../../../src/internal/color/primary.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AA2BrE,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,aAAa,GAClB,YAAY,CAiCd"}
@@ -0,0 +1,44 @@
1
+ import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
2
+ const FALLBACK_PRIMARY_COLOR = '#0f766e';
3
+ const LIGHT_PRIMARY_LIGHTNESS_TARGET = 0.52;
4
+ const DARK_PRIMARY_LIGHTNESS_TARGET = 0.72;
5
+ const MIN_PRIMARY_CHROMA = 0.04;
6
+ const MAX_LIGHT_PRIMARY_CHROMA = 0.18;
7
+ const MAX_DARK_PRIMARY_CHROMA = 0.2;
8
+ const LIGHTNESS_BLEND = 0.85;
9
+ function clampNumber(value, min, max) {
10
+ return Math.max(min, Math.min(value, max));
11
+ }
12
+ function resolveModePrimaryTargetLightness(mode) {
13
+ return mode === 'dark' ? DARK_PRIMARY_LIGHTNESS_TARGET : LIGHT_PRIMARY_LIGHTNESS_TARGET;
14
+ }
15
+ function resolveModePrimaryMaxChroma(mode) {
16
+ return mode === 'dark' ? MAX_DARK_PRIMARY_CHROMA : MAX_LIGHT_PRIMARY_CHROMA;
17
+ }
18
+ export function resolveModePrimaryColor(primaryColor, mode) {
19
+ let seed;
20
+ try {
21
+ seed = parseHexToOklch(primaryColor);
22
+ }
23
+ catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ if (process.env.NODE_ENV === 'production') {
26
+ console.warn(`Invalid ZORA primaryColor '${primaryColor}'. Falling back. ${message}`);
27
+ return resolveModePrimaryColor(FALLBACK_PRIMARY_COLOR, mode);
28
+ }
29
+ throw error instanceof Error ? error : new Error(message);
30
+ }
31
+ const targetLightness = resolveModePrimaryTargetLightness(mode);
32
+ const maxChroma = resolveModePrimaryMaxChroma(mode);
33
+ const blendedLightness = seed.l + (targetLightness - seed.l) * LIGHTNESS_BLEND;
34
+ const boundedLightness = clampNumber(blendedLightness, 0.12, 0.92);
35
+ const cappedChroma = clampNumber(seed.c, 0, maxChroma);
36
+ const boundedChroma = seed.c < MIN_PRIMARY_CHROMA ? cappedChroma : Math.max(cappedChroma, MIN_PRIMARY_CHROMA);
37
+ const derived = clampOklchToGamut({
38
+ l: boundedLightness,
39
+ c: boundedChroma,
40
+ h: seed.h,
41
+ });
42
+ return formatOklchAsHex(derived);
43
+ }
44
+ //# sourceMappingURL=primary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"primary.js","sourceRoot":"","sources":["../../../src/internal/color/primary.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG/E,MAAM,sBAAsB,GAAiB,SAAS,CAAC;AAEvD,MAAM,8BAA8B,GAAG,IAAI,CAAC;AAC5C,MAAM,6BAA6B,GAAG,IAAI,CAAC;AAE3C,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAChC,MAAM,wBAAwB,GAAG,IAAI,CAAC;AACtC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAEpC,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B,SAAS,WAAW,CAAC,KAAa,EAAE,GAAW,EAAE,GAAW;IAC1D,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,SAAS,iCAAiC,CAAC,IAAmB;IAC5D,OAAO,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,6BAA6B,CAAC,CAAC,CAAC,8BAA8B,CAAC;AAC1F,CAAC;AAED,SAAS,2BAA2B,CAAC,IAAmB;IACtD,OAAO,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,wBAAwB,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,YAA0B,EAC1B,IAAmB;IAEnB,IAAI,IAAoB,CAAC;IAEzB,IAAI,CAAC;QACH,IAAI,GAAG,eAAe,CAAC,YAAY,CAAC,CAAC;IACvC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAEvE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;YAC1C,OAAO,CAAC,IAAI,CAAC,8BAA8B,YAAY,oBAAoB,OAAO,EAAE,CAAC,CAAC;YACtF,OAAO,uBAAuB,CAAC,sBAAsB,EAAE,IAAI,CAAC,CAAC;QAC/D,CAAC;QAED,MAAM,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;IAC5D,CAAC;IAED,MAAM,eAAe,GAAG,iCAAiC,CAAC,IAAI,CAAC,CAAC;IAChE,MAAM,SAAS,GAAG,2BAA2B,CAAC,IAAI,CAAC,CAAC;IAEpD,MAAM,gBAAgB,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,eAAe,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC;IAC/E,MAAM,gBAAgB,GAAG,WAAW,CAAC,gBAAgB,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;IAEnE,MAAM,YAAY,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC,CAAC;IACvD,MAAM,aAAa,GACjB,IAAI,CAAC,CAAC,GAAG,kBAAkB,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,EAAE,kBAAkB,CAAC,CAAC;IAE1F,MAAM,OAAO,GAAG,iBAAiB,CAAC;QAChC,CAAC,EAAE,gBAAgB;QACnB,CAAC,EAAE,aAAa;QAChB,CAAC,EAAE,IAAI,CAAC,CAAC;KACV,CAAC,CAAC;IAEH,OAAO,gBAAgB,CAAC,OAAO,CAAC,CAAC;AACnC,CAAC","sourcesContent":["import type { ZoraHexColor, ZoraThemeMode } from '../../theme/types';\nimport { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';\nimport type { ZoraOklchColor } from './types';\n\nconst FALLBACK_PRIMARY_COLOR: ZoraHexColor = '#0f766e';\n\nconst LIGHT_PRIMARY_LIGHTNESS_TARGET = 0.52;\nconst DARK_PRIMARY_LIGHTNESS_TARGET = 0.72;\n\nconst MIN_PRIMARY_CHROMA = 0.04;\nconst MAX_LIGHT_PRIMARY_CHROMA = 0.18;\nconst MAX_DARK_PRIMARY_CHROMA = 0.2;\n\nconst LIGHTNESS_BLEND = 0.85;\n\nfunction clampNumber(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(value, max));\n}\n\nfunction resolveModePrimaryTargetLightness(mode: ZoraThemeMode): number {\n return mode === 'dark' ? DARK_PRIMARY_LIGHTNESS_TARGET : LIGHT_PRIMARY_LIGHTNESS_TARGET;\n}\n\nfunction resolveModePrimaryMaxChroma(mode: ZoraThemeMode): number {\n return mode === 'dark' ? MAX_DARK_PRIMARY_CHROMA : MAX_LIGHT_PRIMARY_CHROMA;\n}\n\nexport function resolveModePrimaryColor(\n primaryColor: ZoraHexColor,\n mode: ZoraThemeMode,\n): ZoraHexColor {\n let seed: ZoraOklchColor;\n\n try {\n seed = parseHexToOklch(primaryColor);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n\n if (process.env.NODE_ENV === 'production') {\n console.warn(`Invalid ZORA primaryColor '${primaryColor}'. Falling back. ${message}`);\n return resolveModePrimaryColor(FALLBACK_PRIMARY_COLOR, mode);\n }\n\n throw error instanceof Error ? error : new Error(message);\n }\n\n const targetLightness = resolveModePrimaryTargetLightness(mode);\n const maxChroma = resolveModePrimaryMaxChroma(mode);\n\n const blendedLightness = seed.l + (targetLightness - seed.l) * LIGHTNESS_BLEND;\n const boundedLightness = clampNumber(blendedLightness, 0.12, 0.92);\n\n const cappedChroma = clampNumber(seed.c, 0, maxChroma);\n const boundedChroma =\n seed.c < MIN_PRIMARY_CHROMA ? cappedChroma : Math.max(cappedChroma, MIN_PRIMARY_CHROMA);\n\n const derived = clampOklchToGamut({\n l: boundedLightness,\n c: boundedChroma,\n h: seed.h,\n });\n\n return formatOklchAsHex(derived);\n}\n"]}
@@ -0,0 +1,6 @@
1
+ export interface ZoraOklchColor {
2
+ l: number;
3
+ c: number;
4
+ h: number;
5
+ }
6
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/internal/color/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/internal/color/types.ts"],"names":[],"mappings":"","sourcesContent":["export interface ZoraOklchColor {\n l: number;\n c: number;\n h: number;\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"createZoraThemeConfig.d.ts","sourceRoot":"","sources":["../../src/theme/createZoraThemeConfig.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAgB,SAAS,EAAiB,MAAM,SAAS,CAAC;AAStE,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,SAA4B,GAAG,WAAW,CAetF"}
1
+ {"version":3,"file":"createZoraThemeConfig.d.ts","sourceRoot":"","sources":["../../src/theme/createZoraThemeConfig.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAGzC,wBAAgB,qBAAqB,CAAC,KAAK,GAAE,SAA4B,GAAG,WAAW,CAetF"}
@@ -1,9 +1,5 @@
1
+ import { resolveModePrimaryColor } from '../internal/color';
1
2
  import { zoraDefaultTheme } from './zoraDefaultTheme';
2
- function resolveModePrimaryColor(primaryColor, _mode) {
3
- // Intentionally conservative in Plan 1: mode-specific primary derivation
4
- // (OKLCH/lightness scale, etc.) comes in later theme-engine work.
5
- return primaryColor;
6
- }
7
3
  export function createZoraThemeConfig(theme = zoraDefaultTheme) {
8
4
  return {
9
5
  id: theme.id,
@@ -1 +1 @@
1
- {"version":3,"file":"createZoraThemeConfig.js","sourceRoot":"","sources":["../../src/theme/createZoraThemeConfig.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEtD,SAAS,uBAAuB,CAAC,YAA0B,EAAE,KAAoB;IAC/E,yEAAyE;IACzE,kEAAkE;IAClE,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,QAAmB,gBAAgB;IACvE,OAAO;QACL,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,EAAE;QAC5B,KAAK,EAAE;YACL,YAAY,EAAE,uBAAuB,CAAC,KAAK,CAAC,YAAY,EAAE,OAAO,CAAC;YAClE,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,UAAU,EAAE,KAAK,CAAC,IAAI;SACvB;QACD,IAAI,EAAE;YACJ,YAAY,EAAE,uBAAuB,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;YACjE,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,UAAU,EAAE,KAAK,CAAC,IAAI;SACvB;KACF,CAAC;AACJ,CAAC","sourcesContent":["import type { ThemeConfig } from '@ankhorage/surface';\n\nimport type { ZoraHexColor, ZoraTheme, ZoraThemeMode } from './types';\nimport { zoraDefaultTheme } from './zoraDefaultTheme';\n\nfunction resolveModePrimaryColor(primaryColor: ZoraHexColor, _mode: ZoraThemeMode): ZoraHexColor {\n // Intentionally conservative in Plan 1: mode-specific primary derivation\n // (OKLCH/lightness scale, etc.) comes in later theme-engine work.\n return primaryColor;\n}\n\nexport function createZoraThemeConfig(theme: ZoraTheme = zoraDefaultTheme): ThemeConfig {\n return {\n id: theme.id,\n name: theme.name ?? theme.id,\n light: {\n primaryColor: resolveModePrimaryColor(theme.primaryColor, 'light'),\n harmony: theme.harmony,\n systemTone: theme.tone,\n },\n dark: {\n primaryColor: resolveModePrimaryColor(theme.primaryColor, 'dark'),\n harmony: theme.harmony,\n systemTone: theme.tone,\n },\n };\n}\n"]}
1
+ {"version":3,"file":"createZoraThemeConfig.js","sourceRoot":"","sources":["../../src/theme/createZoraThemeConfig.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEtD,MAAM,UAAU,qBAAqB,CAAC,QAAmB,gBAAgB;IACvE,OAAO;QACL,EAAE,EAAE,KAAK,CAAC,EAAE;QACZ,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,EAAE;QAC5B,KAAK,EAAE;YACL,YAAY,EAAE,uBAAuB,CAAC,KAAK,CAAC,YAAY,EAAE,OAAO,CAAC;YAClE,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,UAAU,EAAE,KAAK,CAAC,IAAI;SACvB;QACD,IAAI,EAAE;YACJ,YAAY,EAAE,uBAAuB,CAAC,KAAK,CAAC,YAAY,EAAE,MAAM,CAAC;YACjE,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,UAAU,EAAE,KAAK,CAAC,IAAI;SACvB;KACF,CAAC;AACJ,CAAC","sourcesContent":["import type { ThemeConfig } from '@ankhorage/surface';\n\nimport { resolveModePrimaryColor } from '../internal/color';\nimport type { ZoraTheme } from './types';\nimport { zoraDefaultTheme } from './zoraDefaultTheme';\n\nexport function createZoraThemeConfig(theme: ZoraTheme = zoraDefaultTheme): ThemeConfig {\n return {\n id: theme.id,\n name: theme.name ?? theme.id,\n light: {\n primaryColor: resolveModePrimaryColor(theme.primaryColor, 'light'),\n harmony: theme.harmony,\n systemTone: theme.tone,\n },\n dark: {\n primaryColor: resolveModePrimaryColor(theme.primaryColor, 'dark'),\n harmony: theme.harmony,\n systemTone: theme.tone,\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.12.0",
5
5
  "description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
6
6
  "homepage": "https://github.com/ankhorage/zora#readme",
7
7
  "bugs": {
@@ -43,7 +43,8 @@
43
43
  }
44
44
  },
45
45
  "dependencies": {
46
- "@ankhorage/surface": "^0.1.12"
46
+ "@ankhorage/surface": "^0.1.12",
47
+ "culori": "^4.0.2"
47
48
  },
48
49
  "files": [
49
50
  "dist",
@@ -84,6 +85,7 @@
84
85
  "@changesets/cli": "^2.31.0",
85
86
  "@expo/vector-icons": "^15.1.1",
86
87
  "@react-native-picker/picker": "^2.11.4",
88
+ "@types/culori": "^4.0.1",
87
89
  "@types/bun": "^1.3.13",
88
90
  "@types/node": "^25.6.0",
89
91
  "@types/react": "^19.2.14",
@@ -0,0 +1,2 @@
1
+ export { parseHexToOklch } from './oklch';
2
+ export { resolveModePrimaryColor } from './primary';
@@ -0,0 +1,69 @@
1
+ import { converter, formatHex, parse, toGamut } from 'culori';
2
+
3
+ import type { ZoraHexColor } from '../../theme/types';
4
+ import type { ZoraOklchColor } from './types';
5
+
6
+ const toOklch = converter('oklch');
7
+ const gamutMapToSrgb = toGamut('rgb', 'oklch');
8
+
9
+ function normalizeHueDegrees(hue: number): number {
10
+ const normalized = ((hue % 360) + 360) % 360;
11
+ return normalized;
12
+ }
13
+
14
+ function isSixDigitHexColor(value: string): value is ZoraHexColor {
15
+ return /^#[0-9a-fA-F]{6}$/.test(value);
16
+ }
17
+
18
+ export function parseHexToOklch(hex: ZoraHexColor): ZoraOklchColor {
19
+ if (!isSixDigitHexColor(hex)) {
20
+ throw new Error(`Expected a 6-digit hex color like '#0f766e', got '${String(hex)}'.`);
21
+ }
22
+
23
+ const parsed = parse(hex);
24
+ if (!parsed) {
25
+ throw new Error(`Unable to parse hex color '${String(hex)}'.`);
26
+ }
27
+
28
+ const oklch = toOklch(parsed);
29
+ if (typeof oklch.l !== 'number' || typeof oklch.c !== 'number') {
30
+ throw new Error(`Unable to convert hex color '${String(hex)}' to OKLCH.`);
31
+ }
32
+
33
+ return {
34
+ l: oklch.l,
35
+ c: oklch.c,
36
+ h: normalizeHueDegrees(typeof oklch.h === 'number' ? oklch.h : 0),
37
+ };
38
+ }
39
+
40
+ export function formatOklchAsHex(color: ZoraOklchColor): ZoraHexColor {
41
+ const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
42
+ const hex = formatHex(mapped);
43
+
44
+ if (!hex || !isSixDigitHexColor(hex)) {
45
+ throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
46
+ }
47
+
48
+ const normalized = hex.toLowerCase();
49
+ if (!isSixDigitHexColor(normalized)) {
50
+ throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
51
+ }
52
+
53
+ return normalized;
54
+ }
55
+
56
+ export function clampOklchToGamut(color: ZoraOklchColor): ZoraOklchColor {
57
+ const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
58
+ const clamped = toOklch(mapped);
59
+
60
+ if (typeof clamped.l !== 'number' || typeof clamped.c !== 'number') {
61
+ throw new Error('Unable to clamp OKLCH color to sRGB gamut.');
62
+ }
63
+
64
+ return {
65
+ l: clamped.l,
66
+ c: clamped.c,
67
+ h: normalizeHueDegrees(typeof clamped.h === 'number' ? clamped.h : 0),
68
+ };
69
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { ZoraHexColor } from '../../theme/types';
4
+ import { parseHexToOklch } from './oklch';
5
+ import { resolveModePrimaryColor } from './primary';
6
+
7
+ function isSixDigitHexColor(value: string): value is ZoraHexColor {
8
+ return /^#[0-9a-f]{6}$/.test(value);
9
+ }
10
+
11
+ function hueDeltaDegrees(a: number, b: number): number {
12
+ const raw = Math.abs(a - b) % 360;
13
+ return Math.min(raw, 360 - raw);
14
+ }
15
+
16
+ describe('resolveModePrimaryColor', () => {
17
+ test('returns valid hex for light and dark mode', () => {
18
+ const seed: ZoraHexColor = '#0f766e';
19
+
20
+ const light = resolveModePrimaryColor(seed, 'light');
21
+ const dark = resolveModePrimaryColor(seed, 'dark');
22
+
23
+ expect(isSixDigitHexColor(light)).toBe(true);
24
+ expect(isSixDigitHexColor(dark)).toBe(true);
25
+ });
26
+
27
+ test('derives different colors for light vs dark for a typical saturated seed', () => {
28
+ const seed: ZoraHexColor = '#0f766e';
29
+
30
+ const light = resolveModePrimaryColor(seed, 'light');
31
+ const dark = resolveModePrimaryColor(seed, 'dark');
32
+
33
+ expect(light).not.toBe(dark);
34
+ });
35
+
36
+ test('dark derived primary has higher OKLCH lightness than the light derived primary', () => {
37
+ const seed: ZoraHexColor = '#0f766e';
38
+
39
+ const light = resolveModePrimaryColor(seed, 'light');
40
+ const dark = resolveModePrimaryColor(seed, 'dark');
41
+
42
+ const lightOklch = parseHexToOklch(light);
43
+ const darkOklch = parseHexToOklch(dark);
44
+
45
+ expect(darkOklch.l).toBeGreaterThan(lightOklch.l);
46
+ });
47
+
48
+ test('preserves hue approximately for a non-neutral seed', () => {
49
+ const seed: ZoraHexColor = '#0f766e';
50
+ const seedOklch = parseHexToOklch(seed);
51
+
52
+ const light = resolveModePrimaryColor(seed, 'light');
53
+ const dark = resolveModePrimaryColor(seed, 'dark');
54
+
55
+ const lightOklch = parseHexToOklch(light);
56
+ const darkOklch = parseHexToOklch(dark);
57
+
58
+ expect(hueDeltaDegrees(seedOklch.h, lightOklch.h)).toBeLessThan(20);
59
+ expect(hueDeltaDegrees(seedOklch.h, darkOklch.h)).toBeLessThan(20);
60
+ });
61
+
62
+ test('bounds chroma', () => {
63
+ const seed: ZoraHexColor = '#ff00ff';
64
+
65
+ const light = resolveModePrimaryColor(seed, 'light');
66
+ const dark = resolveModePrimaryColor(seed, 'dark');
67
+
68
+ const lightOklch = parseHexToOklch(light);
69
+ const darkOklch = parseHexToOklch(dark);
70
+
71
+ expect(lightOklch.c).toBeLessThanOrEqual(0.18 + 0.01);
72
+ expect(darkOklch.c).toBeLessThanOrEqual(0.2 + 0.01);
73
+ });
74
+
75
+ test('handles low-chroma colors without crashing', () => {
76
+ const seed: ZoraHexColor = '#808080';
77
+
78
+ const light = resolveModePrimaryColor(seed, 'light');
79
+ const dark = resolveModePrimaryColor(seed, 'dark');
80
+
81
+ expect(isSixDigitHexColor(light)).toBe(true);
82
+ expect(isSixDigitHexColor(dark)).toBe(true);
83
+ });
84
+
85
+ test('throws on invalid hex in test/development', () => {
86
+ const invalid: ZoraHexColor = '#nope';
87
+
88
+ expect(() => resolveModePrimaryColor(invalid, 'light')).toThrow();
89
+ });
90
+
91
+ test('falls back in production when input is invalid', () => {
92
+ const invalid: ZoraHexColor = '#nope';
93
+
94
+ const originalEnv = process.env.NODE_ENV;
95
+ process.env.NODE_ENV = 'production';
96
+
97
+ try {
98
+ const resolved = resolveModePrimaryColor(invalid, 'light');
99
+ expect(resolved).not.toBe('#0f766e');
100
+ expect(resolved).toBe(resolveModePrimaryColor('#0f766e', 'light'));
101
+ } finally {
102
+ process.env.NODE_ENV = originalEnv;
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,64 @@
1
+ import type { ZoraHexColor, ZoraThemeMode } from '../../theme/types';
2
+ import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
3
+ import type { ZoraOklchColor } from './types';
4
+
5
+ const FALLBACK_PRIMARY_COLOR: ZoraHexColor = '#0f766e';
6
+
7
+ const LIGHT_PRIMARY_LIGHTNESS_TARGET = 0.52;
8
+ const DARK_PRIMARY_LIGHTNESS_TARGET = 0.72;
9
+
10
+ const MIN_PRIMARY_CHROMA = 0.04;
11
+ const MAX_LIGHT_PRIMARY_CHROMA = 0.18;
12
+ const MAX_DARK_PRIMARY_CHROMA = 0.2;
13
+
14
+ const LIGHTNESS_BLEND = 0.85;
15
+
16
+ function clampNumber(value: number, min: number, max: number): number {
17
+ return Math.max(min, Math.min(value, max));
18
+ }
19
+
20
+ function resolveModePrimaryTargetLightness(mode: ZoraThemeMode): number {
21
+ return mode === 'dark' ? DARK_PRIMARY_LIGHTNESS_TARGET : LIGHT_PRIMARY_LIGHTNESS_TARGET;
22
+ }
23
+
24
+ function resolveModePrimaryMaxChroma(mode: ZoraThemeMode): number {
25
+ return mode === 'dark' ? MAX_DARK_PRIMARY_CHROMA : MAX_LIGHT_PRIMARY_CHROMA;
26
+ }
27
+
28
+ export function resolveModePrimaryColor(
29
+ primaryColor: ZoraHexColor,
30
+ mode: ZoraThemeMode,
31
+ ): ZoraHexColor {
32
+ let seed: ZoraOklchColor;
33
+
34
+ try {
35
+ seed = parseHexToOklch(primaryColor);
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+
39
+ if (process.env.NODE_ENV === 'production') {
40
+ console.warn(`Invalid ZORA primaryColor '${primaryColor}'. Falling back. ${message}`);
41
+ return resolveModePrimaryColor(FALLBACK_PRIMARY_COLOR, mode);
42
+ }
43
+
44
+ throw error instanceof Error ? error : new Error(message);
45
+ }
46
+
47
+ const targetLightness = resolveModePrimaryTargetLightness(mode);
48
+ const maxChroma = resolveModePrimaryMaxChroma(mode);
49
+
50
+ const blendedLightness = seed.l + (targetLightness - seed.l) * LIGHTNESS_BLEND;
51
+ const boundedLightness = clampNumber(blendedLightness, 0.12, 0.92);
52
+
53
+ const cappedChroma = clampNumber(seed.c, 0, maxChroma);
54
+ const boundedChroma =
55
+ seed.c < MIN_PRIMARY_CHROMA ? cappedChroma : Math.max(cappedChroma, MIN_PRIMARY_CHROMA);
56
+
57
+ const derived = clampOklchToGamut({
58
+ l: boundedLightness,
59
+ c: boundedChroma,
60
+ h: seed.h,
61
+ });
62
+
63
+ return formatOklchAsHex(derived);
64
+ }
@@ -0,0 +1,5 @@
1
+ export interface ZoraOklchColor {
2
+ l: number;
3
+ c: number;
4
+ h: number;
5
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
+ import { parseHexToOklch } from '../internal/color';
3
4
  import { createZoraThemeConfig } from './createZoraThemeConfig';
5
+ import type { ZoraHexColor } from './types';
6
+
7
+ function isSixDigitHexColor(value: string): value is ZoraHexColor {
8
+ return /^#[0-9a-f]{6}$/.test(value);
9
+ }
4
10
 
5
11
  describe('createZoraThemeConfig', () => {
6
12
  test('converts the default theme seed into a surface config', () => {
@@ -8,12 +14,18 @@ describe('createZoraThemeConfig', () => {
8
14
 
9
15
  expect(themeConfig.id).toBe('zora');
10
16
  expect(themeConfig.name).toBe('ZORA');
11
- expect(themeConfig.light.primaryColor).toBe('#0f766e');
17
+ expect(isSixDigitHexColor(themeConfig.light.primaryColor)).toBe(true);
12
18
  expect(themeConfig.light.harmony).toBe('analogous');
13
19
  expect(themeConfig.light.systemTone).toBe('jewel');
14
- expect(themeConfig.dark.primaryColor).toBe('#0f766e');
20
+ expect(isSixDigitHexColor(themeConfig.dark.primaryColor)).toBe(true);
15
21
  expect(themeConfig.dark.harmony).toBe('analogous');
16
22
  expect(themeConfig.dark.systemTone).toBe('jewel');
23
+
24
+ expect(themeConfig.light.primaryColor).not.toBe(themeConfig.dark.primaryColor);
25
+
26
+ const lightOklch = parseHexToOklch(themeConfig.light.primaryColor);
27
+ const darkOklch = parseHexToOklch(themeConfig.dark.primaryColor);
28
+ expect(darkOklch.l).toBeGreaterThan(lightOklch.l);
17
29
  });
18
30
 
19
31
  test('falls back to id when name is omitted', () => {
@@ -26,11 +38,13 @@ describe('createZoraThemeConfig', () => {
26
38
 
27
39
  expect(themeConfig.id).toBe('studio');
28
40
  expect(themeConfig.name).toBe('studio');
29
- expect(themeConfig.light.primaryColor).toBe('#0f766e');
41
+ expect(isSixDigitHexColor(themeConfig.light.primaryColor)).toBe(true);
30
42
  expect(themeConfig.light.harmony).toBe('analogous');
31
43
  expect(themeConfig.light.systemTone).toBe('jewel');
32
- expect(themeConfig.dark.primaryColor).toBe('#0f766e');
44
+ expect(isSixDigitHexColor(themeConfig.dark.primaryColor)).toBe(true);
33
45
  expect(themeConfig.dark.harmony).toBe('analogous');
34
46
  expect(themeConfig.dark.systemTone).toBe('jewel');
47
+
48
+ expect(themeConfig.light.primaryColor).not.toBe(themeConfig.dark.primaryColor);
35
49
  });
36
50
  });
@@ -1,14 +1,9 @@
1
1
  import type { ThemeConfig } from '@ankhorage/surface';
2
2
 
3
- import type { ZoraHexColor, ZoraTheme, ZoraThemeMode } from './types';
3
+ import { resolveModePrimaryColor } from '../internal/color';
4
+ import type { ZoraTheme } from './types';
4
5
  import { zoraDefaultTheme } from './zoraDefaultTheme';
5
6
 
6
- function resolveModePrimaryColor(primaryColor: ZoraHexColor, _mode: ZoraThemeMode): ZoraHexColor {
7
- // Intentionally conservative in Plan 1: mode-specific primary derivation
8
- // (OKLCH/lightness scale, etc.) comes in later theme-engine work.
9
- return primaryColor;
10
- }
11
-
12
7
  export function createZoraThemeConfig(theme: ZoraTheme = zoraDefaultTheme): ThemeConfig {
13
8
  return {
14
9
  id: theme.id,