@theme-registry/refract 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/dist/_createTheme-255ff48e.esm.js +2 -0
  3. package/dist/_createTheme-255ff48e.esm.js.map +1 -0
  4. package/dist/_createTheme-41c01033.cjs.js +2 -0
  5. package/dist/_createTheme-41c01033.cjs.js.map +1 -0
  6. package/dist/_errors-be768b25.cjs.js +2 -0
  7. package/dist/_errors-be768b25.cjs.js.map +1 -0
  8. package/dist/_errors-d75a5c74.esm.js +2 -0
  9. package/dist/_errors-d75a5c74.esm.js.map +1 -0
  10. package/dist/adapter-kit.cjs.js +2 -0
  11. package/dist/adapter-kit.cjs.js.map +1 -0
  12. package/dist/adapter-kit.d.ts +88 -0
  13. package/dist/adapter-kit.esm.js +2 -0
  14. package/dist/adapter-kit.esm.js.map +1 -0
  15. package/dist/build/auditCommand.d.ts +19 -0
  16. package/dist/build/buildCommand.d.ts +23 -0
  17. package/dist/build/cli.d.ts +2 -0
  18. package/dist/build/config.d.ts +58 -0
  19. package/dist/build/diff.d.ts +56 -0
  20. package/dist/build/diffCommand.d.ts +29 -0
  21. package/dist/build/emit.d.ts +20 -0
  22. package/dist/build/emitTheme.d.ts +39 -0
  23. package/dist/build/guide.d.ts +34 -0
  24. package/dist/build/importCommand.d.ts +42 -0
  25. package/dist/build/index.d.ts +33 -0
  26. package/dist/build/init.d.ts +32 -0
  27. package/dist/build/paths.d.ts +42 -0
  28. package/dist/build/skillsCommand.d.ts +53 -0
  29. package/dist/build/tokensCommand.d.ts +17 -0
  30. package/dist/build/vendor.d.ts +39 -0
  31. package/dist/build.cjs.js +2 -0
  32. package/dist/build.cjs.js.map +1 -0
  33. package/dist/build.esm.js +2 -0
  34. package/dist/build.esm.js.map +1 -0
  35. package/dist/cli.js +8250 -0
  36. package/dist/cli.js.map +1 -0
  37. package/dist/color-math.cjs.js +2 -0
  38. package/dist/color-math.cjs.js.map +1 -0
  39. package/dist/color-math.esm.js +2 -0
  40. package/dist/color-math.esm.js.map +1 -0
  41. package/dist/core/ThemeAdapter.d.ts +234 -0
  42. package/dist/core/createTheme.d.ts +96 -0
  43. package/dist/core/cssProperties.d.ts +8 -0
  44. package/dist/core/defineAdapter.d.ts +25 -0
  45. package/dist/core/derive/crossProperty.d.ts +31 -0
  46. package/dist/core/derive/index.d.ts +11 -0
  47. package/dist/core/derive/resolveTokens.d.ts +27 -0
  48. package/dist/core/errors.d.ts +24 -0
  49. package/dist/core/index.d.ts +16 -0
  50. package/dist/core/media/containers.d.ts +20 -0
  51. package/dist/core/media/defaults.d.ts +3 -0
  52. package/dist/core/media/descriptors.d.ts +12 -0
  53. package/dist/core/media/index.d.ts +11 -0
  54. package/dist/core/media/queries.d.ts +20 -0
  55. package/dist/core/model/buildModel.d.ts +116 -0
  56. package/dist/core/model/index.d.ts +14 -0
  57. package/dist/core/model/interpreted.d.ts +37 -0
  58. package/dist/core/model/mergeRuleSet.d.ts +68 -0
  59. package/dist/core/model/model.d.ts +260 -0
  60. package/dist/core/noopAdapter.d.ts +26 -0
  61. package/dist/core/normalize/index.d.ts +15 -0
  62. package/dist/core/normalize/properties.d.ts +20 -0
  63. package/dist/core/normalize/recipeResolver.d.ts +19 -0
  64. package/dist/core/normalize/recipes.d.ts +38 -0
  65. package/dist/core/normalize/responsive.d.ts +40 -0
  66. package/dist/core/normalize/types.d.ts +261 -0
  67. package/dist/core/rawTheme.d.ts +53 -0
  68. package/dist/core/ref.d.ts +13 -0
  69. package/dist/core/subsystem.d.ts +115 -0
  70. package/dist/core/types.d.ts +31 -0
  71. package/dist/core/units.d.ts +93 -0
  72. package/dist/dtcg/export.d.ts +48 -0
  73. package/dist/dtcg/import.d.ts +43 -0
  74. package/dist/dtcg/index.d.ts +21 -0
  75. package/dist/dtcg/parse.d.ts +6 -0
  76. package/dist/dtcg/types.d.ts +69 -0
  77. package/dist/dtcg.cjs.js +2 -0
  78. package/dist/dtcg.cjs.js.map +1 -0
  79. package/dist/dtcg.esm.js +2 -0
  80. package/dist/dtcg.esm.js.map +1 -0
  81. package/dist/index.cjs.js +2 -0
  82. package/dist/index.cjs.js.map +1 -0
  83. package/dist/index.d.ts +27 -0
  84. package/dist/index.esm.js +2 -0
  85. package/dist/index.esm.js.map +1 -0
  86. package/dist/subsystems/animation/index.d.ts +13 -0
  87. package/dist/subsystems/animation/recipes.d.ts +20 -0
  88. package/dist/subsystems/animation/types.d.ts +52 -0
  89. package/dist/subsystems/borders/index.d.ts +10 -0
  90. package/dist/subsystems/borders/recipes.d.ts +29 -0
  91. package/dist/subsystems/borders/types.d.ts +50 -0
  92. package/dist/subsystems/colors/audit.d.ts +67 -0
  93. package/dist/subsystems/colors/colorInput.d.ts +17 -0
  94. package/dist/subsystems/colors/index.d.ts +12 -0
  95. package/dist/subsystems/colors/keywords.d.ts +9 -0
  96. package/dist/subsystems/colors/normalize.d.ts +26 -0
  97. package/dist/subsystems/colors/recipes.d.ts +21 -0
  98. package/dist/subsystems/colors/types.d.ts +98 -0
  99. package/dist/subsystems/colors/utils.d.ts +104 -0
  100. package/dist/subsystems/components/index.d.ts +13 -0
  101. package/dist/subsystems/components/recipes.d.ts +22 -0
  102. package/dist/subsystems/components/types.d.ts +46 -0
  103. package/dist/subsystems/effects/index.d.ts +9 -0
  104. package/dist/subsystems/effects/recipes.d.ts +24 -0
  105. package/dist/subsystems/effects/types.d.ts +63 -0
  106. package/dist/subsystems/effects/utils.d.ts +57 -0
  107. package/dist/subsystems/globals/index.d.ts +21 -0
  108. package/dist/subsystems/globals/presets.d.ts +33 -0
  109. package/dist/subsystems/globals/types.d.ts +48 -0
  110. package/dist/subsystems/index.d.ts +21 -0
  111. package/dist/subsystems/layout/index.d.ts +14 -0
  112. package/dist/subsystems/layout/normalize.d.ts +35 -0
  113. package/dist/subsystems/layout/recipes.d.ts +16 -0
  114. package/dist/subsystems/layout/structural.d.ts +37 -0
  115. package/dist/subsystems/layout/types.d.ts +154 -0
  116. package/dist/subsystems/typography/index.d.ts +9 -0
  117. package/dist/subsystems/typography/normalize.d.ts +17 -0
  118. package/dist/subsystems/typography/recipes.d.ts +21 -0
  119. package/dist/subsystems/typography/types.d.ts +49 -0
  120. package/package.json +83 -0
  121. package/skills/adapter-scaffold/SKILL.md +85 -0
  122. package/skills/adapter-usage/SKILL.md +75 -0
  123. package/skills/build-config/SKILL.md +77 -0
  124. package/skills/colors/SKILL.md +134 -0
  125. package/skills/consuming-the-output/SKILL.md +62 -0
  126. package/skills/dtcg-import/SKILL.md +76 -0
  127. package/skills/layout/SKILL.md +65 -0
  128. package/skills/overrides-and-child-themes/SKILL.md +70 -0
  129. package/skills/recipes-and-composition/SKILL.md +110 -0
  130. package/skills/theme-authoring/SKILL.md +107 -0
  131. package/skills/theme-foundations/SKILL.md +127 -0
  132. package/skills/troubleshooting/SKILL.md +97 -0
  133. package/skills/typography/SKILL.md +66 -0
  134. package/skills/visual-effects/SKILL.md +96 -0
  135. package/src/subsystems/colors/utils.ts +299 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Pure colour math for palette step + variant synthesis.
3
+ *
4
+ * Lightness / hue / chroma work runs in **OKLCH** (a perceptual space) via Björn Ottosson's
5
+ * public-domain matrices — parse → sRGB → linear → OKLab → OKLCH → apply → gamut-map → linear →
6
+ * sRGB → quantize → serialize — so equal lightness steps look even and one lightness reads the same
7
+ * across hues. hex / `rgba()` strings are only touched at the boundary (`parseColor` in,
8
+ * `serializeColor` out). `lighten` / `darken` shift OKLCH lightness by ΔL points; `setL` places an
9
+ * absolute lightness; `rotateHue` / `complement` turn the hue; `alpha` sets opacity. All are
10
+ * string-in / string-out so the same fns run at normalize time (baking the cached value) and later
11
+ * in the derivation registry — AND are vendored to consumers (see `build/vendor.ts`) so a value
12
+ * computed live in the app matches the emitted CSS variable. Kept dependency-free for that vendoring.
13
+ */
14
+
15
+ /** An `[r, g, b]` channel triple, each `0–255`. */
16
+ export type RGBTuple = readonly [number, number, number];
17
+
18
+ /** An `[r, g, b, a]` quad — channels `0–255`, alpha `0–1`. */
19
+ export type RGBATuple = readonly [number, number, number, number];
20
+
21
+ /** A colour authored/worked as a tuple — opaque `[r,g,b]` or translucent `[r,g,b,a]`. */
22
+ export type ColorTuple = RGBTuple | RGBATuple;
23
+
24
+ const HEX_LENGTHS = new Set([3, 6]);
25
+
26
+ const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
27
+ const clampChannel = (value: number): number => clamp(value, 0, 255);
28
+ const clampAlpha = (value: number): number => clamp(value, 0, 1);
29
+ const clampPercent = (percent: number): number => clamp(percent, 0, 100);
30
+
31
+ /** Round to `dp` decimal places (tidy CSS output — `71.8`, not `71.79999`). */
32
+ const roundTo = (value: number, dp: number): number => {
33
+ const f = 10 ** dp;
34
+ return Math.round(value * f) / f;
35
+ };
36
+
37
+ /** Round an alpha to 3 decimals so serialized `rgba()` stays tidy (`0.4`, not `0.4000001`). */
38
+ const roundAlpha = (a: number): number => Math.round(clampAlpha(a) * 1000) / 1000;
39
+
40
+ /** Parse a 3- or 6-digit hex string (with or without `#`) into an `[r, g, b]` tuple. */
41
+ export const convertHexToRGB = (hex: string): RGBTuple => {
42
+ const sanitized = hex.replace(/^\s*#|\s*$/g, "");
43
+
44
+ if (!HEX_LENGTHS.has(sanitized.length)) {
45
+ // Plain Error on purpose: this module is vendored import-free (build/vendor.ts) so it can be
46
+ // transpiled standalone into consumer bundles — it cannot import RefractError. Its parse errors are
47
+ // re-thrown as REFRACT_E_COLOR_INPUT by colors/normalize.ts, which is the coded authoring surface.
48
+ throw new Error(`Unsupported hex length: "${sanitized}". Use 3 or 6 digits.`);
49
+ }
50
+
51
+ const normalized = sanitized.length === 3 ? sanitized.replace(/(.)/g, "$1$1") : sanitized;
52
+
53
+ return [
54
+ parseInt(normalized.substring(0, 2), 16),
55
+ parseInt(normalized.substring(2, 4), 16),
56
+ parseInt(normalized.substring(4, 6), 16),
57
+ ] as RGBTuple;
58
+ };
59
+
60
+ /** Serialize an `[r, g, b]` tuple back to a `#rrggbb` string, clamping each channel to `0–255`. */
61
+ export const convertRgbToHex = (rgb: RGBTuple): string => {
62
+ if (rgb.length !== 3) {
63
+ throw new Error(`Expected RGB tuple of length 3, received ${rgb.length}`);
64
+ }
65
+
66
+ const hex = rgb
67
+ .map(value => {
68
+ const next = clampChannel(Math.round(value)).toString(16);
69
+ return next.length === 1 ? `0${next}` : next;
70
+ })
71
+ .join("");
72
+
73
+ return `#${hex}`;
74
+ };
75
+
76
+ const RGB_FN = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i;
77
+
78
+ /**
79
+ * Parse any canonical colour string this subsystem produces — a 3/6-digit hex **or** an
80
+ * `rgb()` / `rgba()` string — into `{ rgb, a }` (alpha defaults to `1`). This is the boundary
81
+ * gate; a value that isn't parseable rgb (e.g. a bare CSS keyword or `var(--…)`) throws, so
82
+ * callers can surface a friendly "this colour can't be derived from" error.
83
+ */
84
+ export const parseColor = (value: string): { rgb: RGBTuple; a: number } => {
85
+ const trimmed = value.trim();
86
+
87
+ const fn = RGB_FN.exec(trimmed);
88
+ if (fn) {
89
+ return {
90
+ rgb: [Number(fn[1]), Number(fn[2]), Number(fn[3])] as RGBTuple,
91
+ a: fn[4] === undefined ? 1 : roundAlpha(Number(fn[4])),
92
+ };
93
+ }
94
+
95
+ return { rgb: convertHexToRGB(trimmed), a: 1 };
96
+ };
97
+
98
+ /**
99
+ * Serialize `[r, g, b]` (+ optional alpha) to the canonical CSS string — always `rgb(r, g, b)`
100
+ * when fully opaque, else `rgba(r, g, b, a)`. The single serialization point for the subsystem, so
101
+ * the Model (and every adapter + `resolveToken`) always carries one canonical `rgb()/rgba()` form
102
+ * per colour — never hex. (`convertRgbToHex` remains for the vendored consumer helper + input parsing.)
103
+ */
104
+ export const serializeColor = (rgb: RGBTuple, a = 1): string => {
105
+ const alpha = roundAlpha(a);
106
+ const [r, g, b] = rgb;
107
+ const R = clampChannel(Math.round(r));
108
+ const G = clampChannel(Math.round(g));
109
+ const B = clampChannel(Math.round(b));
110
+ return alpha >= 1 ? `rgb(${R}, ${G}, ${B})` : `rgba(${R}, ${G}, ${B}, ${alpha})`;
111
+ };
112
+
113
+ const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
114
+
115
+ /** Whether a string is a 3- or 6-digit `#`-prefixed hex colour. */
116
+ export const isHexColor = (value: string): boolean => HEX_RE.test(value.trim());
117
+
118
+
119
+ // ─── OKLCH synthesis (Björn Ottosson public-domain matrices) ──────────────────────────────────
120
+ // Lightness / hue / chroma adjustments happen here, in a perceptual space, so equal ΔL steps look
121
+ // even and one lightness reads consistently across hues. `L` is carried on a 0–100 scale (matching
122
+ // the authoring dials); OKLab's native `L` is 0–1. sRGB channels are 0–255 only at the boundary.
123
+
124
+ /** An OKLCH colour — perceptual lightness `L` (0–100), chroma `C` (≥ 0), hue `h` (degrees, 0–360). */
125
+ export type OKLCH = { L: number; C: number; h: number };
126
+
127
+ const DEG = 180 / Math.PI;
128
+ const GAMUT_EPS = 1e-6;
129
+ const GAMUT_ITERATIONS = 24;
130
+
131
+ /** sRGB channel (0–1) → linear-light (0–1). */
132
+ const srgbToLinear = (c: number): number =>
133
+ c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
134
+
135
+ /** Linear-light channel (0–1) → sRGB (0–1). */
136
+ const linearToSrgb = (c: number): number =>
137
+ c >= 0.0031308 ? 1.055 * Math.pow(c, 1 / 2.4) - 0.055 : 12.92 * c;
138
+
139
+ /** Linear-sRGB `[r, g, b]` (0–1) → OKLab `{ L: 0–1, a, b }`. */
140
+ const linearRgbToOklab = (
141
+ lr: number,
142
+ lg: number,
143
+ lb: number,
144
+ ): { L: number; a: number; b: number } => {
145
+ const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
146
+ const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
147
+ const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
148
+ const l_ = Math.cbrt(l);
149
+ const m_ = Math.cbrt(m);
150
+ const s_ = Math.cbrt(s);
151
+ return {
152
+ L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
153
+ a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
154
+ b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
155
+ };
156
+ };
157
+
158
+ /** OKLab `{ L: 0–1, a, b }` → linear-sRGB `[r, g, b]` (may fall outside 0–1 when out of gamut). */
159
+ const oklabToLinearRgb = (L: number, a: number, b: number): [number, number, number] => {
160
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
161
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
162
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
163
+ const l = l_ * l_ * l_;
164
+ const m = m_ * m_ * m_;
165
+ const s = s_ * s_ * s_;
166
+ return [
167
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
168
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
169
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
170
+ ];
171
+ };
172
+
173
+ /** Convert an sRGB `[r, g, b]` (0–255) triple to OKLCH (`L` on a 0–100 scale). */
174
+ export const rgbToOklch = (rgb: RGBTuple): OKLCH => {
175
+ const [r, g, b] = rgb;
176
+ const lab = linearRgbToOklab(srgbToLinear(r / 255), srgbToLinear(g / 255), srgbToLinear(b / 255));
177
+ const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
178
+ let h = Math.atan2(lab.b, lab.a) * DEG;
179
+ if (h < 0) h += 360;
180
+ return { L: lab.L * 100, C, h };
181
+ };
182
+
183
+ /**
184
+ * Convert OKLCH back to an sRGB `[r, g, b]` triple (**unquantized** 0–255 floats). Holds `L` and
185
+ * `h`; when the requested chroma is outside sRGB it binary-searches `C` downward — fixed iterations,
186
+ * no data-dependent early exit, so build- and runtime-computed values agree (§20.8) — until the
187
+ * linear result is in `[0, 1]`. Lightness and hue are never adjusted, so a synthesized ramp keeps
188
+ * its hue and even lightness spacing; only saturation gives way at the gamut boundary. The floats
189
+ * are quantized exactly once, later, by `serializeColor`, so the 0–255 channel clamp never fires.
190
+ */
191
+ export const oklchToRgb = (color: OKLCH): RGBTuple => {
192
+ const L = clamp(color.L, 0, 100) / 100;
193
+ const hr = color.h / DEG;
194
+ const cosh = Math.cos(hr);
195
+ const sinh = Math.sin(hr);
196
+
197
+ const linearAt = (c: number): [number, number, number] => oklabToLinearRgb(L, c * cosh, c * sinh);
198
+ const inGamut = (lin: [number, number, number]): boolean =>
199
+ lin.every(v => v >= -GAMUT_EPS && v <= 1 + GAMUT_EPS);
200
+
201
+ let lin = linearAt(color.C);
202
+ if (!inGamut(lin)) {
203
+ // `C = 0` (a neutral grey at this L) is always in gamut, so `lo` starts from a valid floor.
204
+ let lo = 0;
205
+ let hi = color.C;
206
+ for (let i = 0; i < GAMUT_ITERATIONS; i++) {
207
+ const mid = (lo + hi) / 2;
208
+ if (inGamut(linearAt(mid))) lo = mid;
209
+ else hi = mid;
210
+ }
211
+ lin = linearAt(lo);
212
+ }
213
+
214
+ return [
215
+ linearToSrgb(clamp(lin[0], 0, 1)) * 255,
216
+ linearToSrgb(clamp(lin[1], 0, 1)) * 255,
217
+ linearToSrgb(clamp(lin[2], 0, 1)) * 255,
218
+ ] as RGBTuple;
219
+ };
220
+
221
+ /** Run a colour string through OKLCH, apply `transform`, and re-serialize (alpha preserved). */
222
+ const withOklch = (value: string, transform: (lch: OKLCH) => OKLCH): string => {
223
+ const { rgb, a } = parseColor(value);
224
+ return serializeColor(oklchToRgb(transform(rgbToOklch(rgb))), a);
225
+ };
226
+
227
+ /** Lighten a colour by `delta` OKLCH lightness points (0–100 scale); hue, chroma, alpha preserved. */
228
+ export const lighten = (value: string, delta: number): string =>
229
+ withOklch(value, lch => ({ ...lch, L: clamp(lch.L + delta, 0, 100) }));
230
+
231
+ /** Darken a colour by `delta` OKLCH lightness points (0–100 scale); hue, chroma, alpha preserved. */
232
+ export const darken = (value: string, delta: number): string =>
233
+ withOklch(value, lch => ({ ...lch, L: clamp(lch.L - delta, 0, 100) }));
234
+
235
+ /** Set a colour's absolute OKLCH lightness to `L` (0–100); hue, chroma, alpha preserved. */
236
+ export const setL = (value: string, L: number): string =>
237
+ withOklch(value, lch => ({ ...lch, L: clamp(L, 0, 100) }));
238
+
239
+ /** Rotate a colour's OKLCH hue by `deg` (signed) around the perceptual wheel; L, C, alpha preserved. */
240
+ export const rotateHue = (value: string, deg: number): string =>
241
+ withOklch(value, lch => ({ ...lch, h: (((lch.h + deg) % 360) + 360) % 360 }));
242
+
243
+ /** The perceptual complement — a 180° hue rotation at the same lightness and chroma. */
244
+ export const complement = (value: string): string => rotateHue(value, 180);
245
+
246
+ /**
247
+ * An `adjust` dial set — any subset. `l` = **absolute** OKLCH lightness target (0–100, raw L, the
248
+ * opposite direction from a numeric-`steps` label); `c` = chroma **multiplier** (`1` keep, `0` grey,
249
+ * `>1` more saturated where the gamut allows); `h` = **signed** hue rotation in degrees. Numbers
250
+ * only — keeps the vendored math string-free.
251
+ */
252
+ export type AdjustDials = { l?: number; c?: number; h?: number };
253
+
254
+ /**
255
+ * One-shot OKLCH placement — set an absolute lightness, scale chroma, and/or rotate hue in a single
256
+ * derivation (each dial optional; an omitted dial leaves that component untouched). Alpha preserved.
257
+ */
258
+ export const adjust = (value: string, dials: AdjustDials): string =>
259
+ withOklch(value, lch => ({
260
+ L: dials.l === undefined ? lch.L : clamp(dials.l, 0, 100),
261
+ C: dials.c === undefined ? lch.C : Math.max(0, lch.C * dials.c),
262
+ h: dials.h === undefined ? lch.h : (((lch.h + dials.h) % 360) + 360) % 360,
263
+ }));
264
+
265
+ /**
266
+ * Convert any canonical colour string (the subsystem's `rgb()` / `rgba()` form, or a hex string)
267
+ * to hex — 6-digit `#rrggbb` when opaque, 8-digit `#rrggbbaa` when translucent. Used by the DTCG
268
+ * exporter, whose `color` token type is conventionally hex (every other output stays `rgb()`).
269
+ */
270
+ export const toHexColor = (value: string): string => {
271
+ const { rgb, a } = parseColor(value);
272
+ const hex = convertRgbToHex(rgb);
273
+ if (a >= 1) return hex;
274
+ const aa = clampChannel(Math.round(a * 255)).toString(16).padStart(2, "0");
275
+ return `${hex}${aa}`;
276
+ };
277
+
278
+ /**
279
+ * Convert any canonical colour string (the subsystem's `rgb()` / `rgba()` form, or a hex string) to
280
+ * a CSS Color 4 `oklch(L% C H)` string — `oklch(L% C H / a)` when translucent. `L` is a percentage
281
+ * (0–100), `C` the absolute chroma, `H` in degrees, derived from the stored (quantized) rgb. Used by
282
+ * the CSS adapter's `colorFormat: "oklch"` output; the colour is identical to the `rgb()` form.
283
+ */
284
+ export const toOklchColor = (value: string): string => {
285
+ const { rgb, a } = parseColor(value);
286
+ const { L, C, h } = rgbToOklch(rgb);
287
+ const body = `${roundTo(L, 2)}% ${roundTo(C, 4)} ${roundTo(h, 2)}`;
288
+ return a >= 1 ? `oklch(${body})` : `oklch(${body} / ${roundAlpha(a)})`;
289
+ };
290
+
291
+ /**
292
+ * Set a colour's opacity to `percent` (0–100) — an **absolute** alpha, not a relative fade
293
+ * (`alpha(x, 40)` ⇒ 40% opaque). The rgb channels are untouched; only the alpha channel is
294
+ * replaced. Result serializes to `rgba(…)` (or hex when `percent` is 100).
295
+ */
296
+ export const alpha = (value: string, percent: number): string => {
297
+ const { rgb } = parseColor(value);
298
+ return serializeColor(rgb, clampPercent(percent) / 100);
299
+ };