@ankhorage/zora 0.12.0 → 0.12.1

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/internal/color/harmony.d.ts +12 -0
  3. package/dist/internal/color/harmony.d.ts.map +1 -0
  4. package/dist/internal/color/harmony.js +69 -0
  5. package/dist/internal/color/harmony.js.map +1 -0
  6. package/dist/internal/color/hue.d.ts +3 -0
  7. package/dist/internal/color/hue.d.ts.map +1 -0
  8. package/dist/internal/color/hue.js +7 -0
  9. package/dist/internal/color/hue.js.map +1 -0
  10. package/dist/internal/color/index.d.ts +3 -0
  11. package/dist/internal/color/index.d.ts.map +1 -1
  12. package/dist/internal/color/index.js +3 -0
  13. package/dist/internal/color/index.js.map +1 -1
  14. package/dist/internal/color/oklch.d.ts.map +1 -1
  15. package/dist/internal/color/oklch.js +1 -4
  16. package/dist/internal/color/oklch.js.map +1 -1
  17. package/dist/internal/color/scales.d.ts +10 -0
  18. package/dist/internal/color/scales.d.ts.map +1 -0
  19. package/dist/internal/color/scales.js +110 -0
  20. package/dist/internal/color/scales.js.map +1 -0
  21. package/dist/internal/color/types.d.ts +4 -0
  22. package/dist/internal/color/types.d.ts.map +1 -1
  23. package/dist/internal/color/types.js +3 -1
  24. package/dist/internal/color/types.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/internal/color/harmony.test.ts +145 -0
  27. package/src/internal/color/harmony.ts +96 -0
  28. package/src/internal/color/hue.test.ts +28 -0
  29. package/src/internal/color/hue.ts +7 -0
  30. package/src/internal/color/index.ts +13 -0
  31. package/src/internal/color/oklch.ts +1 -5
  32. package/src/internal/color/scales.test.ts +151 -0
  33. package/src/internal/color/scales.ts +145 -0
  34. package/src/internal/color/types.ts +10 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 35c4046: Adds internal harmony hue-slot computation for future ZORA theme generation work.
8
+
3
9
  ## 0.12.0
4
10
 
5
11
  ### Minor Changes
@@ -0,0 +1,12 @@
1
+ import type { ZoraColorHarmony, ZoraHexColor } from '../../theme/types';
2
+ export type ZoraHarmonySlotId = 'base' | 'a' | 'b' | 'c';
3
+ export interface ZoraHarmonySlot {
4
+ id: ZoraHarmonySlotId;
5
+ hue: number;
6
+ }
7
+ export interface ZoraComputedHarmony {
8
+ kind: ZoraColorHarmony;
9
+ orderedSlots: readonly ZoraHarmonySlot[];
10
+ }
11
+ export declare function computeZoraHarmony(seed: ZoraHexColor, harmony: ZoraColorHarmony): ZoraComputedHarmony;
12
+ //# sourceMappingURL=harmony.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harmony.d.ts","sourceRoot":"","sources":["../../../src/internal/color/harmony.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAIxE,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAEzD,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,iBAAiB,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,gBAAgB,CAAC;IACvB,YAAY,EAAE,SAAS,eAAe,EAAE,CAAC;CAC1C;AAmBD,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,YAAY,EAClB,OAAO,EAAE,gBAAgB,GACxB,mBAAmB,CA2DrB"}
@@ -0,0 +1,69 @@
1
+ import { normalizeHueDegrees, rotateHueDegrees } from './hue';
2
+ import { parseHexToOklch } from './oklch';
3
+ const DEFAULT_HARMONY_HUE_DEGREES = 260;
4
+ const MIN_SEED_CHROMA_FOR_HARMONY_HUE = 0.03;
5
+ function createSlot(id, hue) {
6
+ return { id, hue: normalizeHueDegrees(hue) };
7
+ }
8
+ function resolveSeedHueDegrees(seed) {
9
+ const parsed = parseHexToOklch(seed);
10
+ if (!Number.isFinite(parsed.h) || parsed.c < MIN_SEED_CHROMA_FOR_HARMONY_HUE) {
11
+ return DEFAULT_HARMONY_HUE_DEGREES;
12
+ }
13
+ return normalizeHueDegrees(parsed.h);
14
+ }
15
+ export function computeZoraHarmony(seed, harmony) {
16
+ const baseHue = resolveSeedHueDegrees(seed);
17
+ if (harmony === 'monochromatic') {
18
+ return {
19
+ kind: harmony,
20
+ orderedSlots: [createSlot('base', baseHue)],
21
+ };
22
+ }
23
+ if (harmony === 'complementary') {
24
+ return {
25
+ kind: harmony,
26
+ orderedSlots: [createSlot('base', baseHue), createSlot('a', rotateHueDegrees(baseHue, 180))],
27
+ };
28
+ }
29
+ if (harmony === 'analogous') {
30
+ return {
31
+ kind: harmony,
32
+ orderedSlots: [
33
+ createSlot('base', baseHue),
34
+ createSlot('a', rotateHueDegrees(baseHue, -30)),
35
+ createSlot('b', rotateHueDegrees(baseHue, 30)),
36
+ ],
37
+ };
38
+ }
39
+ if (harmony === 'splitComplementary') {
40
+ return {
41
+ kind: harmony,
42
+ orderedSlots: [
43
+ createSlot('base', baseHue),
44
+ createSlot('a', rotateHueDegrees(baseHue, 150)),
45
+ createSlot('b', rotateHueDegrees(baseHue, 210)),
46
+ ],
47
+ };
48
+ }
49
+ if (harmony === 'triadic') {
50
+ return {
51
+ kind: harmony,
52
+ orderedSlots: [
53
+ createSlot('base', baseHue),
54
+ createSlot('a', rotateHueDegrees(baseHue, 120)),
55
+ createSlot('b', rotateHueDegrees(baseHue, 240)),
56
+ ],
57
+ };
58
+ }
59
+ return {
60
+ kind: harmony,
61
+ orderedSlots: [
62
+ createSlot('base', baseHue),
63
+ createSlot('a', rotateHueDegrees(baseHue, 90)),
64
+ createSlot('b', rotateHueDegrees(baseHue, 180)),
65
+ createSlot('c', rotateHueDegrees(baseHue, 270)),
66
+ ],
67
+ };
68
+ }
69
+ //# sourceMappingURL=harmony.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"harmony.js","sourceRoot":"","sources":["../../../src/internal/color/harmony.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ZoraColorHarmony, ZoraHexColor } from '../../theme/types';\nimport { normalizeHueDegrees, rotateHueDegrees } from './hue';\nimport { parseHexToOklch } from './oklch';\n\nexport type ZoraHarmonySlotId = 'base' | 'a' | 'b' | 'c';\n\nexport interface ZoraHarmonySlot {\n id: ZoraHarmonySlotId;\n hue: number;\n}\n\nexport interface ZoraComputedHarmony {\n kind: ZoraColorHarmony;\n orderedSlots: readonly ZoraHarmonySlot[];\n}\n\nconst DEFAULT_HARMONY_HUE_DEGREES = 260;\nconst MIN_SEED_CHROMA_FOR_HARMONY_HUE = 0.03;\n\nfunction createSlot(id: ZoraHarmonySlotId, hue: number): ZoraHarmonySlot {\n return { id, hue: normalizeHueDegrees(hue) };\n}\n\nfunction resolveSeedHueDegrees(seed: ZoraHexColor): number {\n const parsed = parseHexToOklch(seed);\n\n if (!Number.isFinite(parsed.h) || parsed.c < MIN_SEED_CHROMA_FOR_HARMONY_HUE) {\n return DEFAULT_HARMONY_HUE_DEGREES;\n }\n\n return normalizeHueDegrees(parsed.h);\n}\n\nexport function computeZoraHarmony(\n seed: ZoraHexColor,\n harmony: ZoraColorHarmony,\n): ZoraComputedHarmony {\n const baseHue = resolveSeedHueDegrees(seed);\n\n if (harmony === 'monochromatic') {\n return {\n kind: harmony,\n orderedSlots: [createSlot('base', baseHue)],\n };\n }\n\n if (harmony === 'complementary') {\n return {\n kind: harmony,\n orderedSlots: [createSlot('base', baseHue), createSlot('a', rotateHueDegrees(baseHue, 180))],\n };\n }\n\n if (harmony === 'analogous') {\n return {\n kind: harmony,\n orderedSlots: [\n createSlot('base', baseHue),\n createSlot('a', rotateHueDegrees(baseHue, -30)),\n createSlot('b', rotateHueDegrees(baseHue, 30)),\n ],\n };\n }\n\n if (harmony === 'splitComplementary') {\n return {\n kind: harmony,\n orderedSlots: [\n createSlot('base', baseHue),\n createSlot('a', rotateHueDegrees(baseHue, 150)),\n createSlot('b', rotateHueDegrees(baseHue, 210)),\n ],\n };\n }\n\n if (harmony === 'triadic') {\n return {\n kind: harmony,\n orderedSlots: [\n createSlot('base', baseHue),\n createSlot('a', rotateHueDegrees(baseHue, 120)),\n createSlot('b', rotateHueDegrees(baseHue, 240)),\n ],\n };\n }\n\n return {\n kind: harmony,\n orderedSlots: [\n createSlot('base', baseHue),\n createSlot('a', rotateHueDegrees(baseHue, 90)),\n createSlot('b', rotateHueDegrees(baseHue, 180)),\n createSlot('c', rotateHueDegrees(baseHue, 270)),\n ],\n };\n}\n"]}
@@ -0,0 +1,3 @@
1
+ export declare function normalizeHueDegrees(hue: number): number;
2
+ export declare function rotateHueDegrees(hue: number, delta: number): number;
3
+ //# sourceMappingURL=hue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hue.d.ts","sourceRoot":"","sources":["../../../src/internal/color/hue.ts"],"names":[],"mappings":"AAAA,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE"}
@@ -0,0 +1,7 @@
1
+ export function normalizeHueDegrees(hue) {
2
+ return ((hue % 360) + 360) % 360;
3
+ }
4
+ export function rotateHueDegrees(hue, delta) {
5
+ return normalizeHueDegrees(hue + delta);
6
+ }
7
+ //# sourceMappingURL=hue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hue.js","sourceRoot":"","sources":["../../../src/internal/color/hue.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,OAAO,CAAC,CAAC,GAAG,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,KAAa;IACzD,OAAO,mBAAmB,CAAC,GAAG,GAAG,KAAK,CAAC,CAAC;AAC1C,CAAC","sourcesContent":["export function normalizeHueDegrees(hue: number): number {\n return ((hue % 360) + 360) % 360;\n}\n\nexport function rotateHueDegrees(hue: number, delta: number): number {\n return normalizeHueDegrees(hue + delta);\n}\n"]}
@@ -1,3 +1,6 @@
1
+ export { computeZoraHarmony, type ZoraComputedHarmony, type ZoraHarmonySlot, type ZoraHarmonySlotId, } from './harmony';
1
2
  export { parseHexToOklch } from './oklch';
2
3
  export { resolveModePrimaryColor } from './primary';
4
+ export { createZoraColorScale, type CreateZoraColorScaleOptions, createZoraNeutralScale, createZoraPrimaryScale, } from './scales';
5
+ export { ZORA_COLOR_SCALE_STEPS, type ZoraColorScale, type ZoraColorScaleStep } from './types';
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/internal/color/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACL,oBAAoB,EACpB,KAAK,2BAA2B,EAChC,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,sBAAsB,EAAE,KAAK,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,SAAS,CAAC"}
@@ -1,3 +1,6 @@
1
+ export { computeZoraHarmony, } from './harmony';
1
2
  export { parseHexToOklch } from './oklch';
2
3
  export { resolveModePrimaryColor } from './primary';
4
+ export { createZoraColorScale, createZoraNeutralScale, createZoraPrimaryScale, } from './scales';
5
+ export { ZORA_COLOR_SCALE_STEPS } from './types';
3
6
  //# sourceMappingURL=index.js.map
@@ -1 +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"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/internal/color/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,GAInB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,EACL,oBAAoB,EAEpB,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,sBAAsB,EAAgD,MAAM,SAAS,CAAC","sourcesContent":["export {\n computeZoraHarmony,\n type ZoraComputedHarmony,\n type ZoraHarmonySlot,\n type ZoraHarmonySlotId,\n} from './harmony';\nexport { parseHexToOklch } from './oklch';\nexport { resolveModePrimaryColor } from './primary';\nexport {\n createZoraColorScale,\n type CreateZoraColorScaleOptions,\n createZoraNeutralScale,\n createZoraPrimaryScale,\n} from './scales';\nexport { ZORA_COLOR_SCALE_STEPS, type ZoraColorScale, type ZoraColorScaleStep } from './types';\n"]}
@@ -1 +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"}
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;AAEtD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAS9C,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"}
@@ -1,10 +1,7 @@
1
1
  import { converter, formatHex, parse, toGamut } from 'culori';
2
+ import { normalizeHueDegrees } from './hue';
2
3
  const toOklch = converter('oklch');
3
4
  const gamutMapToSrgb = toGamut('rgb', 'oklch');
4
- function normalizeHueDegrees(hue) {
5
- const normalized = ((hue % 360) + 360) % 360;
6
- return normalized;
7
- }
8
5
  function isSixDigitHexColor(value) {
9
6
  return /^#[0-9a-fA-F]{6}$/.test(value);
10
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"oklch.js","sourceRoot":"","sources":["../../../src/internal/color/oklch.ts"],"names":[],"mappings":"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","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"]}
1
+ {"version":3,"file":"oklch.js","sourceRoot":"","sources":["../../../src/internal/color/oklch.ts"],"names":[],"mappings":"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","sourcesContent":["import { converter, formatHex, parse, toGamut } from 'culori';\n\nimport type { ZoraHexColor } from '../../theme/types';\nimport { normalizeHueDegrees } from './hue';\nimport type { ZoraOklchColor } from './types';\n\nconst toOklch = converter('oklch');\nconst gamutMapToSrgb = toGamut('rgb', 'oklch');\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,10 @@
1
+ import type { ZoraHexColor } from '../../theme/types';
2
+ import { type ZoraColorScale } from './types';
3
+ export interface CreateZoraColorScaleOptions {
4
+ seed: ZoraHexColor;
5
+ role?: 'primary' | 'neutral';
6
+ }
7
+ export declare function createZoraColorScale(options: CreateZoraColorScaleOptions): ZoraColorScale;
8
+ export declare function createZoraPrimaryScale(seed: ZoraHexColor): ZoraColorScale;
9
+ export declare function createZoraNeutralScale(seed?: ZoraHexColor): ZoraColorScale;
10
+ //# sourceMappingURL=scales.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scales.d.ts","sourceRoot":"","sources":["../../../src/internal/color/scales.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEtD,OAAO,EAAE,KAAK,cAAc,EAA2B,MAAM,SAAS,CAAC;AAEvE,MAAM,WAAW,2BAA2B;IAC1C,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;CAC9B;AA4HD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,2BAA2B,GAAG,cAAc,CAKzF;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,YAAY,GAAG,cAAc,CAEzE;AAED,wBAAgB,sBAAsB,CAAC,IAAI,GAAE,YAAwB,GAAG,cAAc,CAErF"}
@@ -0,0 +1,110 @@
1
+ import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
2
+ import {} from './types';
3
+ const PRIMARY_LIGHTNESS_BY_STEP = {
4
+ 50: 0.97,
5
+ 100: 0.93,
6
+ 200: 0.86,
7
+ 300: 0.78,
8
+ 400: 0.68,
9
+ 500: 0.58,
10
+ 600: 0.5,
11
+ 700: 0.42,
12
+ 800: 0.34,
13
+ 900: 0.27,
14
+ 950: 0.2,
15
+ };
16
+ const NEUTRAL_LIGHTNESS_BY_STEP = {
17
+ 50: 0.98,
18
+ 100: 0.95,
19
+ 200: 0.89,
20
+ 300: 0.8,
21
+ 400: 0.68,
22
+ 500: 0.55,
23
+ 600: 0.44,
24
+ 700: 0.34,
25
+ 800: 0.25,
26
+ 900: 0.18,
27
+ 950: 0.12,
28
+ };
29
+ const PRIMARY_CHROMA_MULTIPLIER_BY_STEP = {
30
+ 50: 0.2,
31
+ 100: 0.3,
32
+ 200: 0.45,
33
+ 300: 0.7,
34
+ 400: 0.95,
35
+ 500: 1,
36
+ 600: 0.95,
37
+ 700: 0.85,
38
+ 800: 0.65,
39
+ 900: 0.45,
40
+ 950: 0.3,
41
+ };
42
+ const MAX_PRIMARY_SCALE_CHROMA = 0.2;
43
+ const MIN_PRIMARY_SCALE_CHROMA = 0.04;
44
+ const NEUTRAL_CHROMA = 0.012;
45
+ const DEFAULT_NEUTRAL_HUE_DEGREES = 260;
46
+ function clampNumber(value, min, max) {
47
+ return Math.max(min, Math.min(value, max));
48
+ }
49
+ function resolvePrimaryScaleChroma(seedChroma, step) {
50
+ const cappedSeedChroma = clampNumber(seedChroma, 0, MAX_PRIMARY_SCALE_CHROMA);
51
+ const multiplier = PRIMARY_CHROMA_MULTIPLIER_BY_STEP[step];
52
+ const scaled = cappedSeedChroma * multiplier;
53
+ const bounded = clampNumber(scaled, 0, MAX_PRIMARY_SCALE_CHROMA);
54
+ const shouldEnforceMin = step >= 300 && step <= 700 && seedChroma >= MIN_PRIMARY_SCALE_CHROMA;
55
+ return shouldEnforceMin ? Math.max(bounded, MIN_PRIMARY_SCALE_CHROMA) : bounded;
56
+ }
57
+ function createScaleEntries(options) {
58
+ const seed = parseHexToOklch(options.seed);
59
+ if (options.role === 'neutral') {
60
+ const hue = typeof seed.h === 'number' ? seed.h : DEFAULT_NEUTRAL_HUE_DEGREES;
61
+ return createScaleFromRamp({
62
+ hue,
63
+ chroma: NEUTRAL_CHROMA,
64
+ lightnessByStep: NEUTRAL_LIGHTNESS_BY_STEP,
65
+ });
66
+ }
67
+ return createScaleFromRamp({
68
+ hue: seed.h,
69
+ chromaByStep: (step) => resolvePrimaryScaleChroma(seed.c, step),
70
+ lightnessByStep: PRIMARY_LIGHTNESS_BY_STEP,
71
+ });
72
+ }
73
+ function createScaleColor(options, step) {
74
+ const lightness = options.lightnessByStep[step];
75
+ const chroma = typeof options.chromaByStep === 'function' ? options.chromaByStep(step) : (options.chroma ?? 0);
76
+ const clamped = clampOklchToGamut({
77
+ l: clampNumber(lightness, 0, 1),
78
+ c: clampNumber(chroma, 0, 1),
79
+ h: options.hue,
80
+ });
81
+ return formatOklchAsHex(clamped);
82
+ }
83
+ function createScaleFromRamp(options) {
84
+ return {
85
+ 50: createScaleColor(options, 50),
86
+ 100: createScaleColor(options, 100),
87
+ 200: createScaleColor(options, 200),
88
+ 300: createScaleColor(options, 300),
89
+ 400: createScaleColor(options, 400),
90
+ 500: createScaleColor(options, 500),
91
+ 600: createScaleColor(options, 600),
92
+ 700: createScaleColor(options, 700),
93
+ 800: createScaleColor(options, 800),
94
+ 900: createScaleColor(options, 900),
95
+ 950: createScaleColor(options, 950),
96
+ };
97
+ }
98
+ export function createZoraColorScale(options) {
99
+ return createScaleEntries({
100
+ seed: options.seed,
101
+ role: options.role,
102
+ });
103
+ }
104
+ export function createZoraPrimaryScale(seed) {
105
+ return createZoraColorScale({ seed, role: 'primary' });
106
+ }
107
+ export function createZoraNeutralScale(seed = '#94a3b8') {
108
+ return createZoraColorScale({ seed, role: 'neutral' });
109
+ }
110
+ //# sourceMappingURL=scales.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scales.js","sourceRoot":"","sources":["../../../src/internal/color/scales.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ZoraHexColor } from '../../theme/types';\nimport { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';\nimport { type ZoraColorScale, type ZoraColorScaleStep } from './types';\n\nexport interface CreateZoraColorScaleOptions {\n seed: ZoraHexColor;\n role?: 'primary' | 'neutral';\n}\n\nconst PRIMARY_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {\n 50: 0.97,\n 100: 0.93,\n 200: 0.86,\n 300: 0.78,\n 400: 0.68,\n 500: 0.58,\n 600: 0.5,\n 700: 0.42,\n 800: 0.34,\n 900: 0.27,\n 950: 0.2,\n};\n\nconst NEUTRAL_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {\n 50: 0.98,\n 100: 0.95,\n 200: 0.89,\n 300: 0.8,\n 400: 0.68,\n 500: 0.55,\n 600: 0.44,\n 700: 0.34,\n 800: 0.25,\n 900: 0.18,\n 950: 0.12,\n};\n\nconst PRIMARY_CHROMA_MULTIPLIER_BY_STEP: Record<ZoraColorScaleStep, number> = {\n 50: 0.2,\n 100: 0.3,\n 200: 0.45,\n 300: 0.7,\n 400: 0.95,\n 500: 1,\n 600: 0.95,\n 700: 0.85,\n 800: 0.65,\n 900: 0.45,\n 950: 0.3,\n};\n\nconst MAX_PRIMARY_SCALE_CHROMA = 0.2;\nconst MIN_PRIMARY_SCALE_CHROMA = 0.04;\nconst NEUTRAL_CHROMA = 0.012;\nconst DEFAULT_NEUTRAL_HUE_DEGREES = 260;\n\nfunction clampNumber(value: number, min: number, max: number): number {\n return Math.max(min, Math.min(value, max));\n}\n\nfunction resolvePrimaryScaleChroma(seedChroma: number, step: ZoraColorScaleStep): number {\n const cappedSeedChroma = clampNumber(seedChroma, 0, MAX_PRIMARY_SCALE_CHROMA);\n const multiplier = PRIMARY_CHROMA_MULTIPLIER_BY_STEP[step];\n const scaled = cappedSeedChroma * multiplier;\n\n const bounded = clampNumber(scaled, 0, MAX_PRIMARY_SCALE_CHROMA);\n const shouldEnforceMin = step >= 300 && step <= 700 && seedChroma >= MIN_PRIMARY_SCALE_CHROMA;\n\n return shouldEnforceMin ? Math.max(bounded, MIN_PRIMARY_SCALE_CHROMA) : bounded;\n}\n\nfunction createScaleEntries(options: CreateZoraColorScaleOptions): ZoraColorScale {\n const seed = parseHexToOklch(options.seed);\n\n if (options.role === 'neutral') {\n const hue = typeof seed.h === 'number' ? seed.h : DEFAULT_NEUTRAL_HUE_DEGREES;\n\n return createScaleFromRamp({\n hue,\n chroma: NEUTRAL_CHROMA,\n lightnessByStep: NEUTRAL_LIGHTNESS_BY_STEP,\n });\n }\n\n return createScaleFromRamp({\n hue: seed.h,\n chromaByStep: (step) => resolvePrimaryScaleChroma(seed.c, step),\n lightnessByStep: PRIMARY_LIGHTNESS_BY_STEP,\n });\n}\n\ninterface CreateScaleFromRampOptions {\n hue: number;\n chroma?: number;\n chromaByStep?: (step: ZoraColorScaleStep) => number;\n lightnessByStep: Record<ZoraColorScaleStep, number>;\n}\n\nfunction createScaleColor(\n options: CreateScaleFromRampOptions,\n step: ZoraColorScaleStep,\n): ZoraHexColor {\n const lightness = options.lightnessByStep[step];\n const chroma =\n typeof options.chromaByStep === 'function' ? options.chromaByStep(step) : (options.chroma ?? 0);\n\n const clamped = clampOklchToGamut({\n l: clampNumber(lightness, 0, 1),\n c: clampNumber(chroma, 0, 1),\n h: options.hue,\n });\n\n return formatOklchAsHex(clamped);\n}\n\nfunction createScaleFromRamp(options: CreateScaleFromRampOptions): ZoraColorScale {\n return {\n 50: createScaleColor(options, 50),\n 100: createScaleColor(options, 100),\n 200: createScaleColor(options, 200),\n 300: createScaleColor(options, 300),\n 400: createScaleColor(options, 400),\n 500: createScaleColor(options, 500),\n 600: createScaleColor(options, 600),\n 700: createScaleColor(options, 700),\n 800: createScaleColor(options, 800),\n 900: createScaleColor(options, 900),\n 950: createScaleColor(options, 950),\n };\n}\n\nexport function createZoraColorScale(options: CreateZoraColorScaleOptions): ZoraColorScale {\n return createScaleEntries({\n seed: options.seed,\n role: options.role,\n });\n}\n\nexport function createZoraPrimaryScale(seed: ZoraHexColor): ZoraColorScale {\n return createZoraColorScale({ seed, role: 'primary' });\n}\n\nexport function createZoraNeutralScale(seed: ZoraHexColor = '#94a3b8'): ZoraColorScale {\n return createZoraColorScale({ seed, role: 'neutral' });\n}\n"]}
@@ -1,6 +1,10 @@
1
+ import type { ZoraHexColor } from '../../theme/types';
1
2
  export interface ZoraOklchColor {
2
3
  l: number;
3
4
  c: number;
4
5
  h: number;
5
6
  }
7
+ export declare const ZORA_COLOR_SCALE_STEPS: readonly [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
8
+ export type ZoraColorScaleStep = (typeof ZORA_COLOR_SCALE_STEPS)[number];
9
+ export type ZoraColorScale = Record<ZoraColorScaleStep, ZoraHexColor>;
6
10
  //# sourceMappingURL=types.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/internal/color/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEtD,MAAM,WAAW,cAAc;IAC7B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,eAAO,MAAM,sBAAsB,iEAEzB,CAAC;AAEX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,kBAAkB,EAAE,YAAY,CAAC,CAAC"}
@@ -1,2 +1,4 @@
1
- export {};
1
+ export const ZORA_COLOR_SCALE_STEPS = [
2
+ 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
3
+ ];
2
4
  //# sourceMappingURL=types.js.map
@@ -1 +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
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/internal/color/types.ts"],"names":[],"mappings":"AAQA,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG;CAC5C,CAAC","sourcesContent":["import type { ZoraHexColor } from '../../theme/types';\n\nexport interface ZoraOklchColor {\n l: number;\n c: number;\n h: number;\n}\n\nexport const ZORA_COLOR_SCALE_STEPS = [\n 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,\n] as const;\n\nexport type ZoraColorScaleStep = (typeof ZORA_COLOR_SCALE_STEPS)[number];\n\nexport type ZoraColorScale = Record<ZoraColorScaleStep, ZoraHexColor>;\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1",
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": {
@@ -0,0 +1,145 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { ZoraHexColor } from '../../theme/types';
4
+ import { normalizeHueDegrees } from './hue';
5
+ import {
6
+ computeZoraHarmony,
7
+ parseHexToOklch,
8
+ type ZoraComputedHarmony,
9
+ type ZoraHarmonySlot,
10
+ type ZoraHarmonySlotId,
11
+ } from './index';
12
+
13
+ function hueDeltaDegrees(a: number, b: number): number {
14
+ const raw = Math.abs(a - b) % 360;
15
+ return Math.min(raw, 360 - raw);
16
+ }
17
+
18
+ function expectNormalizedHue(hue: number) {
19
+ expect(hue).toBeGreaterThanOrEqual(0);
20
+ expect(hue).toBeLessThan(360);
21
+ }
22
+
23
+ function getSlotIds(slots: readonly ZoraHarmonySlot[]): ZoraHarmonySlotId[] {
24
+ return slots.map((slot) => slot.id);
25
+ }
26
+
27
+ describe('computeZoraHarmony', () => {
28
+ test('monochromatic returns 1 slot', () => {
29
+ const seed: ZoraHexColor = '#0f766e';
30
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'monochromatic');
31
+
32
+ expect(harmony.orderedSlots).toHaveLength(1);
33
+ expect(harmony.orderedSlots[0].id).toBe('base');
34
+ });
35
+
36
+ test('complementary returns 2 slots', () => {
37
+ const seed: ZoraHexColor = '#0f766e';
38
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'complementary');
39
+
40
+ expect(harmony.orderedSlots).toHaveLength(2);
41
+ expect(harmony.orderedSlots[0].id).toBe('base');
42
+ expect(harmony.orderedSlots[1].id).toBe('a');
43
+ });
44
+
45
+ test('analogous returns 3 slots', () => {
46
+ const seed: ZoraHexColor = '#0f766e';
47
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'analogous');
48
+
49
+ expect(harmony.orderedSlots).toHaveLength(3);
50
+ expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
51
+ });
52
+
53
+ test('splitComplementary returns 3 slots', () => {
54
+ const seed: ZoraHexColor = '#0f766e';
55
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'splitComplementary');
56
+
57
+ expect(harmony.orderedSlots).toHaveLength(3);
58
+ expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
59
+ });
60
+
61
+ test('triadic returns 3 slots', () => {
62
+ const seed: ZoraHexColor = '#0f766e';
63
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'triadic');
64
+
65
+ expect(harmony.orderedSlots).toHaveLength(3);
66
+ expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
67
+ });
68
+
69
+ test('tetradic returns 4 slots', () => {
70
+ const seed: ZoraHexColor = '#0f766e';
71
+ const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'tetradic');
72
+
73
+ expect(harmony.orderedSlots).toHaveLength(4);
74
+ expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b', 'c']);
75
+ });
76
+
77
+ test('every computed hue is normalized to [0, 360)', () => {
78
+ const seed: ZoraHexColor = '#0f766e';
79
+ const harmonies = [
80
+ 'monochromatic',
81
+ 'complementary',
82
+ 'analogous',
83
+ 'splitComplementary',
84
+ 'triadic',
85
+ 'tetradic',
86
+ ] as const;
87
+
88
+ for (const kind of harmonies) {
89
+ const computed = computeZoraHarmony(seed, kind);
90
+ for (const slot of computed.orderedSlots) {
91
+ expectNormalizedHue(slot.hue);
92
+ }
93
+ }
94
+ });
95
+
96
+ test('base slot approximately matches parsed seed hue for a non-neutral seed', () => {
97
+ const seed: ZoraHexColor = '#ff00ff';
98
+ const seedOklch = parseHexToOklch(seed);
99
+
100
+ const computed = computeZoraHarmony(seed, 'complementary');
101
+ const [base] = computed.orderedSlots;
102
+
103
+ expect(base).toBeDefined();
104
+ if (!base) {
105
+ throw new Error('Expected a base harmony slot.');
106
+ }
107
+
108
+ expect(base.id).toBe('base');
109
+ expect(hueDeltaDegrees(seedOklch.h, base.hue)).toBeLessThan(0.5);
110
+ });
111
+
112
+ test('neutral/low-chroma seed uses a stable fallback hue', () => {
113
+ const seed: ZoraHexColor = '#808080';
114
+ const computed = computeZoraHarmony(seed, 'monochromatic');
115
+
116
+ expect(computed.orderedSlots).toHaveLength(1);
117
+ expect(computed.orderedSlots[0].hue).toBe(260);
118
+ });
119
+
120
+ test('is deterministic for the same seed/harmony', () => {
121
+ const seed: ZoraHexColor = '#0f766e';
122
+
123
+ expect(computeZoraHarmony(seed, 'triadic')).toEqual(computeZoraHarmony(seed, 'triadic'));
124
+ });
125
+
126
+ test('complementary slots are based on base hue + 180', () => {
127
+ const seed: ZoraHexColor = '#0f766e';
128
+ const computed = computeZoraHarmony(seed, 'complementary');
129
+ const [base, a] = computed.orderedSlots;
130
+
131
+ expect(base).toBeDefined();
132
+ expect(a).toBeDefined();
133
+ if (!base || !a) {
134
+ throw new Error('Expected complementary harmony slots.');
135
+ }
136
+
137
+ expect(hueDeltaDegrees(a.hue, normalizeHueDegrees(base.hue + 180))).toBeLessThan(0.0001);
138
+ });
139
+
140
+ test('throws on invalid seed in test/development', () => {
141
+ const invalid: ZoraHexColor = '#nope';
142
+
143
+ expect(() => computeZoraHarmony(invalid, 'triadic')).toThrow();
144
+ });
145
+ });
@@ -0,0 +1,96 @@
1
+ import type { ZoraColorHarmony, ZoraHexColor } from '../../theme/types';
2
+ import { normalizeHueDegrees, rotateHueDegrees } from './hue';
3
+ import { parseHexToOklch } from './oklch';
4
+
5
+ export type ZoraHarmonySlotId = 'base' | 'a' | 'b' | 'c';
6
+
7
+ export interface ZoraHarmonySlot {
8
+ id: ZoraHarmonySlotId;
9
+ hue: number;
10
+ }
11
+
12
+ export interface ZoraComputedHarmony {
13
+ kind: ZoraColorHarmony;
14
+ orderedSlots: readonly ZoraHarmonySlot[];
15
+ }
16
+
17
+ const DEFAULT_HARMONY_HUE_DEGREES = 260;
18
+ const MIN_SEED_CHROMA_FOR_HARMONY_HUE = 0.03;
19
+
20
+ function createSlot(id: ZoraHarmonySlotId, hue: number): ZoraHarmonySlot {
21
+ return { id, hue: normalizeHueDegrees(hue) };
22
+ }
23
+
24
+ function resolveSeedHueDegrees(seed: ZoraHexColor): number {
25
+ const parsed = parseHexToOklch(seed);
26
+
27
+ if (!Number.isFinite(parsed.h) || parsed.c < MIN_SEED_CHROMA_FOR_HARMONY_HUE) {
28
+ return DEFAULT_HARMONY_HUE_DEGREES;
29
+ }
30
+
31
+ return normalizeHueDegrees(parsed.h);
32
+ }
33
+
34
+ export function computeZoraHarmony(
35
+ seed: ZoraHexColor,
36
+ harmony: ZoraColorHarmony,
37
+ ): ZoraComputedHarmony {
38
+ const baseHue = resolveSeedHueDegrees(seed);
39
+
40
+ if (harmony === 'monochromatic') {
41
+ return {
42
+ kind: harmony,
43
+ orderedSlots: [createSlot('base', baseHue)],
44
+ };
45
+ }
46
+
47
+ if (harmony === 'complementary') {
48
+ return {
49
+ kind: harmony,
50
+ orderedSlots: [createSlot('base', baseHue), createSlot('a', rotateHueDegrees(baseHue, 180))],
51
+ };
52
+ }
53
+
54
+ if (harmony === 'analogous') {
55
+ return {
56
+ kind: harmony,
57
+ orderedSlots: [
58
+ createSlot('base', baseHue),
59
+ createSlot('a', rotateHueDegrees(baseHue, -30)),
60
+ createSlot('b', rotateHueDegrees(baseHue, 30)),
61
+ ],
62
+ };
63
+ }
64
+
65
+ if (harmony === 'splitComplementary') {
66
+ return {
67
+ kind: harmony,
68
+ orderedSlots: [
69
+ createSlot('base', baseHue),
70
+ createSlot('a', rotateHueDegrees(baseHue, 150)),
71
+ createSlot('b', rotateHueDegrees(baseHue, 210)),
72
+ ],
73
+ };
74
+ }
75
+
76
+ if (harmony === 'triadic') {
77
+ return {
78
+ kind: harmony,
79
+ orderedSlots: [
80
+ createSlot('base', baseHue),
81
+ createSlot('a', rotateHueDegrees(baseHue, 120)),
82
+ createSlot('b', rotateHueDegrees(baseHue, 240)),
83
+ ],
84
+ };
85
+ }
86
+
87
+ return {
88
+ kind: harmony,
89
+ orderedSlots: [
90
+ createSlot('base', baseHue),
91
+ createSlot('a', rotateHueDegrees(baseHue, 90)),
92
+ createSlot('b', rotateHueDegrees(baseHue, 180)),
93
+ createSlot('c', rotateHueDegrees(baseHue, 270)),
94
+ ],
95
+ };
96
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { normalizeHueDegrees, rotateHueDegrees } from './hue';
4
+
5
+ describe('normalizeHueDegrees', () => {
6
+ test('normalizes negative hues into [0, 360)', () => {
7
+ expect(normalizeHueDegrees(-30)).toBe(330);
8
+ expect(normalizeHueDegrees(-390)).toBe(330);
9
+ });
10
+
11
+ test('normalizes hues >= 360 into [0, 360)', () => {
12
+ expect(normalizeHueDegrees(390)).toBe(30);
13
+ expect(normalizeHueDegrees(720)).toBe(0);
14
+ });
15
+
16
+ test('preserves in-range hues', () => {
17
+ expect(normalizeHueDegrees(0)).toBe(0);
18
+ expect(normalizeHueDegrees(12.5)).toBe(12.5);
19
+ expect(normalizeHueDegrees(359.99)).toBe(359.99);
20
+ });
21
+ });
22
+
23
+ describe('rotateHueDegrees', () => {
24
+ test('rotates and normalizes', () => {
25
+ expect(rotateHueDegrees(350, 30)).toBe(20);
26
+ expect(rotateHueDegrees(10, -30)).toBe(340);
27
+ });
28
+ });
@@ -0,0 +1,7 @@
1
+ export function normalizeHueDegrees(hue: number): number {
2
+ return ((hue % 360) + 360) % 360;
3
+ }
4
+
5
+ export function rotateHueDegrees(hue: number, delta: number): number {
6
+ return normalizeHueDegrees(hue + delta);
7
+ }
@@ -1,2 +1,15 @@
1
+ export {
2
+ computeZoraHarmony,
3
+ type ZoraComputedHarmony,
4
+ type ZoraHarmonySlot,
5
+ type ZoraHarmonySlotId,
6
+ } from './harmony';
1
7
  export { parseHexToOklch } from './oklch';
2
8
  export { resolveModePrimaryColor } from './primary';
9
+ export {
10
+ createZoraColorScale,
11
+ type CreateZoraColorScaleOptions,
12
+ createZoraNeutralScale,
13
+ createZoraPrimaryScale,
14
+ } from './scales';
15
+ export { ZORA_COLOR_SCALE_STEPS, type ZoraColorScale, type ZoraColorScaleStep } from './types';
@@ -1,16 +1,12 @@
1
1
  import { converter, formatHex, parse, toGamut } from 'culori';
2
2
 
3
3
  import type { ZoraHexColor } from '../../theme/types';
4
+ import { normalizeHueDegrees } from './hue';
4
5
  import type { ZoraOklchColor } from './types';
5
6
 
6
7
  const toOklch = converter('oklch');
7
8
  const gamutMapToSrgb = toGamut('rgb', 'oklch');
8
9
 
9
- function normalizeHueDegrees(hue: number): number {
10
- const normalized = ((hue % 360) + 360) % 360;
11
- return normalized;
12
- }
13
-
14
10
  function isSixDigitHexColor(value: string): value is ZoraHexColor {
15
11
  return /^#[0-9a-fA-F]{6}$/.test(value);
16
12
  }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type { ZoraHexColor } from '../../theme/types';
4
+ import {
5
+ createZoraColorScale,
6
+ type CreateZoraColorScaleOptions,
7
+ createZoraNeutralScale,
8
+ createZoraPrimaryScale,
9
+ parseHexToOklch,
10
+ ZORA_COLOR_SCALE_STEPS,
11
+ type ZoraColorScale,
12
+ type ZoraColorScaleStep,
13
+ } from './index';
14
+
15
+ function isSixDigitLowercaseHexColor(value: string): value is ZoraHexColor {
16
+ return /^#[0-9a-f]{6}$/.test(value);
17
+ }
18
+
19
+ function hueDeltaDegrees(a: number, b: number): number {
20
+ const raw = Math.abs(a - b) % 360;
21
+ return Math.min(raw, 360 - raw);
22
+ }
23
+
24
+ function getStepValues(scale: ZoraColorScale): { step: ZoraColorScaleStep; hex: ZoraHexColor }[] {
25
+ return ZORA_COLOR_SCALE_STEPS.map((step) => ({ step, hex: scale[step] }));
26
+ }
27
+
28
+ function assertScaleKeys(scale: ZoraColorScale) {
29
+ const keys = Object.keys(scale)
30
+ .map((key) => Number(key))
31
+ .sort((a, b) => a - b);
32
+
33
+ expect(keys).toEqual([...ZORA_COLOR_SCALE_STEPS]);
34
+ }
35
+
36
+ function assertDecreasingLightness(scale: ZoraColorScale) {
37
+ const lightness = getStepValues(scale).map(({ hex }) => parseHexToOklch(hex).l);
38
+
39
+ for (let index = 0; index < lightness.length - 1; index += 1) {
40
+ expect(lightness[index]).toBeGreaterThan(lightness[index + 1]);
41
+ }
42
+ }
43
+
44
+ describe('createZoraPrimaryScale', () => {
45
+ test('matches createZoraColorScale({ role: primary })', () => {
46
+ const seed: ZoraHexColor = '#0f766e';
47
+ const options: CreateZoraColorScaleOptions = { seed, role: 'primary' };
48
+
49
+ expect(createZoraPrimaryScale(seed)).toEqual(createZoraColorScale(options));
50
+ });
51
+
52
+ test('returns all 50–950 keys and valid lowercase 6-digit hex values', () => {
53
+ const seed: ZoraHexColor = '#0f766e';
54
+ const scale = createZoraPrimaryScale(seed);
55
+
56
+ assertScaleKeys(scale);
57
+
58
+ for (const { hex } of getStepValues(scale)) {
59
+ expect(isSixDigitLowercaseHexColor(hex)).toBe(true);
60
+ }
61
+ });
62
+
63
+ test('is deterministic for the same seed', () => {
64
+ const seed: ZoraHexColor = '#0f766e';
65
+
66
+ expect(createZoraPrimaryScale(seed)).toEqual(createZoraPrimaryScale(seed));
67
+ });
68
+
69
+ test('lightness decreases from 50 to 950', () => {
70
+ const seed: ZoraHexColor = '#0f766e';
71
+ const scale = createZoraPrimaryScale(seed);
72
+
73
+ assertDecreasingLightness(scale);
74
+ });
75
+
76
+ test('preserves hue approximately for mid steps', () => {
77
+ const seed: ZoraHexColor = '#0f766e';
78
+ const seedOklch = parseHexToOklch(seed);
79
+ const scale = createZoraPrimaryScale(seed);
80
+
81
+ const midSteps: ZoraColorScaleStep[] = [400, 500, 600, 700];
82
+ for (const step of midSteps) {
83
+ const oklch = parseHexToOklch(scale[step]);
84
+ expect(hueDeltaDegrees(seedOklch.h, oklch.h)).toBeLessThan(20);
85
+ }
86
+ });
87
+
88
+ test('mid steps have higher chroma than extremes for a saturated seed', () => {
89
+ const seed: ZoraHexColor = '#ff00ff';
90
+ const scale = createZoraPrimaryScale(seed);
91
+
92
+ const chroma50 = parseHexToOklch(scale[50]).c;
93
+ const chroma950 = parseHexToOklch(scale[950]).c;
94
+ const chroma500 = parseHexToOklch(scale[500]).c;
95
+ const chroma600 = parseHexToOklch(scale[600]).c;
96
+
97
+ const extremes = Math.max(chroma50, chroma950);
98
+ const mid = Math.max(chroma500, chroma600);
99
+
100
+ expect(mid).toBeGreaterThan(extremes);
101
+ });
102
+
103
+ test('bounds saturated inputs to stay in gamut', () => {
104
+ const seed: ZoraHexColor = '#ff00ff';
105
+ const scale = createZoraPrimaryScale(seed);
106
+
107
+ for (const { hex } of getStepValues(scale)) {
108
+ expect(isSixDigitLowercaseHexColor(hex)).toBe(true);
109
+ expect(() => parseHexToOklch(hex)).not.toThrow();
110
+ }
111
+ });
112
+
113
+ test('throws on invalid seed in test/development', () => {
114
+ const invalid: ZoraHexColor = '#nope';
115
+
116
+ expect(() => createZoraPrimaryScale(invalid)).toThrow();
117
+ });
118
+ });
119
+
120
+ describe('createZoraNeutralScale', () => {
121
+ test('works with and without a seed', () => {
122
+ const seeded = createZoraNeutralScale('#0f766e');
123
+ const unseeded = createZoraNeutralScale();
124
+
125
+ assertScaleKeys(seeded);
126
+ assertScaleKeys(unseeded);
127
+ });
128
+
129
+ test('returns low-chroma neutrals', () => {
130
+ const scale = createZoraNeutralScale('#0f766e');
131
+
132
+ for (const { hex } of getStepValues(scale)) {
133
+ const oklch = parseHexToOklch(hex);
134
+ expect(oklch.c).toBeLessThanOrEqual(0.03);
135
+ }
136
+ });
137
+
138
+ test('lightness decreases from 50 to 950', () => {
139
+ const scale = createZoraNeutralScale('#0f766e');
140
+
141
+ assertDecreasingLightness(scale);
142
+ });
143
+
144
+ test('avoids pure black/white in the generated steps', () => {
145
+ const scale = createZoraNeutralScale('#0f766e');
146
+ const values = getStepValues(scale).map(({ hex }) => hex);
147
+
148
+ expect(values).not.toContain('#000000');
149
+ expect(values).not.toContain('#ffffff');
150
+ });
151
+ });
@@ -0,0 +1,145 @@
1
+ import type { ZoraHexColor } from '../../theme/types';
2
+ import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
3
+ import { type ZoraColorScale, type ZoraColorScaleStep } from './types';
4
+
5
+ export interface CreateZoraColorScaleOptions {
6
+ seed: ZoraHexColor;
7
+ role?: 'primary' | 'neutral';
8
+ }
9
+
10
+ const PRIMARY_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {
11
+ 50: 0.97,
12
+ 100: 0.93,
13
+ 200: 0.86,
14
+ 300: 0.78,
15
+ 400: 0.68,
16
+ 500: 0.58,
17
+ 600: 0.5,
18
+ 700: 0.42,
19
+ 800: 0.34,
20
+ 900: 0.27,
21
+ 950: 0.2,
22
+ };
23
+
24
+ const NEUTRAL_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {
25
+ 50: 0.98,
26
+ 100: 0.95,
27
+ 200: 0.89,
28
+ 300: 0.8,
29
+ 400: 0.68,
30
+ 500: 0.55,
31
+ 600: 0.44,
32
+ 700: 0.34,
33
+ 800: 0.25,
34
+ 900: 0.18,
35
+ 950: 0.12,
36
+ };
37
+
38
+ const PRIMARY_CHROMA_MULTIPLIER_BY_STEP: Record<ZoraColorScaleStep, number> = {
39
+ 50: 0.2,
40
+ 100: 0.3,
41
+ 200: 0.45,
42
+ 300: 0.7,
43
+ 400: 0.95,
44
+ 500: 1,
45
+ 600: 0.95,
46
+ 700: 0.85,
47
+ 800: 0.65,
48
+ 900: 0.45,
49
+ 950: 0.3,
50
+ };
51
+
52
+ const MAX_PRIMARY_SCALE_CHROMA = 0.2;
53
+ const MIN_PRIMARY_SCALE_CHROMA = 0.04;
54
+ const NEUTRAL_CHROMA = 0.012;
55
+ const DEFAULT_NEUTRAL_HUE_DEGREES = 260;
56
+
57
+ function clampNumber(value: number, min: number, max: number): number {
58
+ return Math.max(min, Math.min(value, max));
59
+ }
60
+
61
+ function resolvePrimaryScaleChroma(seedChroma: number, step: ZoraColorScaleStep): number {
62
+ const cappedSeedChroma = clampNumber(seedChroma, 0, MAX_PRIMARY_SCALE_CHROMA);
63
+ const multiplier = PRIMARY_CHROMA_MULTIPLIER_BY_STEP[step];
64
+ const scaled = cappedSeedChroma * multiplier;
65
+
66
+ const bounded = clampNumber(scaled, 0, MAX_PRIMARY_SCALE_CHROMA);
67
+ const shouldEnforceMin = step >= 300 && step <= 700 && seedChroma >= MIN_PRIMARY_SCALE_CHROMA;
68
+
69
+ return shouldEnforceMin ? Math.max(bounded, MIN_PRIMARY_SCALE_CHROMA) : bounded;
70
+ }
71
+
72
+ function createScaleEntries(options: CreateZoraColorScaleOptions): ZoraColorScale {
73
+ const seed = parseHexToOklch(options.seed);
74
+
75
+ if (options.role === 'neutral') {
76
+ const hue = typeof seed.h === 'number' ? seed.h : DEFAULT_NEUTRAL_HUE_DEGREES;
77
+
78
+ return createScaleFromRamp({
79
+ hue,
80
+ chroma: NEUTRAL_CHROMA,
81
+ lightnessByStep: NEUTRAL_LIGHTNESS_BY_STEP,
82
+ });
83
+ }
84
+
85
+ return createScaleFromRamp({
86
+ hue: seed.h,
87
+ chromaByStep: (step) => resolvePrimaryScaleChroma(seed.c, step),
88
+ lightnessByStep: PRIMARY_LIGHTNESS_BY_STEP,
89
+ });
90
+ }
91
+
92
+ interface CreateScaleFromRampOptions {
93
+ hue: number;
94
+ chroma?: number;
95
+ chromaByStep?: (step: ZoraColorScaleStep) => number;
96
+ lightnessByStep: Record<ZoraColorScaleStep, number>;
97
+ }
98
+
99
+ function createScaleColor(
100
+ options: CreateScaleFromRampOptions,
101
+ step: ZoraColorScaleStep,
102
+ ): ZoraHexColor {
103
+ const lightness = options.lightnessByStep[step];
104
+ const chroma =
105
+ typeof options.chromaByStep === 'function' ? options.chromaByStep(step) : (options.chroma ?? 0);
106
+
107
+ const clamped = clampOklchToGamut({
108
+ l: clampNumber(lightness, 0, 1),
109
+ c: clampNumber(chroma, 0, 1),
110
+ h: options.hue,
111
+ });
112
+
113
+ return formatOklchAsHex(clamped);
114
+ }
115
+
116
+ function createScaleFromRamp(options: CreateScaleFromRampOptions): ZoraColorScale {
117
+ return {
118
+ 50: createScaleColor(options, 50),
119
+ 100: createScaleColor(options, 100),
120
+ 200: createScaleColor(options, 200),
121
+ 300: createScaleColor(options, 300),
122
+ 400: createScaleColor(options, 400),
123
+ 500: createScaleColor(options, 500),
124
+ 600: createScaleColor(options, 600),
125
+ 700: createScaleColor(options, 700),
126
+ 800: createScaleColor(options, 800),
127
+ 900: createScaleColor(options, 900),
128
+ 950: createScaleColor(options, 950),
129
+ };
130
+ }
131
+
132
+ export function createZoraColorScale(options: CreateZoraColorScaleOptions): ZoraColorScale {
133
+ return createScaleEntries({
134
+ seed: options.seed,
135
+ role: options.role,
136
+ });
137
+ }
138
+
139
+ export function createZoraPrimaryScale(seed: ZoraHexColor): ZoraColorScale {
140
+ return createZoraColorScale({ seed, role: 'primary' });
141
+ }
142
+
143
+ export function createZoraNeutralScale(seed: ZoraHexColor = '#94a3b8'): ZoraColorScale {
144
+ return createZoraColorScale({ seed, role: 'neutral' });
145
+ }
@@ -1,5 +1,15 @@
1
+ import type { ZoraHexColor } from '../../theme/types';
2
+
1
3
  export interface ZoraOklchColor {
2
4
  l: number;
3
5
  c: number;
4
6
  h: number;
5
7
  }
8
+
9
+ export const ZORA_COLOR_SCALE_STEPS = [
10
+ 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
11
+ ] as const;
12
+
13
+ export type ZoraColorScaleStep = (typeof ZORA_COLOR_SCALE_STEPS)[number];
14
+
15
+ export type ZoraColorScale = Record<ZoraColorScaleStep, ZoraHexColor>;