@conductor-by-89soone/tokens 0.6.1 → 0.7.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/dist/index.d.ts CHANGED
@@ -90,4 +90,42 @@ interface TokenGroup {
90
90
  readonly order: number;
91
91
  }
92
92
 
93
- export { CDT_PREFIX, PACKAGE_NAME, SEVERITY_ICONS, STATUS_ICONS, type SeverityIconName, type StatusIconName, type TokenDefinition, type TokenGroup, type TokenTier, type TokenUsage };
93
+ /**
94
+ * WCAG 2.1 contrast ratio (1–21) of `foreground` drawn on `background`.
95
+ *
96
+ * Accepts `#rgb`, `#rrggbb`, `#rrggbbaa`, `rgb()`/`rgba()` and `hsl()`/`hsla()`, alone or as the single
97
+ * colour inside a compound value such as a box-shadow. A translucent
98
+ * foreground is composited over the background first; the background must be opaque, because what
99
+ * it sits on is unknown here. Throws on anything else rather than returning a ratio that could pass.
100
+ */
101
+ declare function contrastRatio(foreground: string, background: string): number;
102
+
103
+ /**
104
+ * OKLab mixing for derived state colours (CR-050).
105
+ *
106
+ * The stylesheet used to tint tone buttons with `color-mix(in srgb, …)` at render time. Mixing in
107
+ * gamma-encoded sRGB darkens and desaturates the midpoints, and a render-time mix is invisible to
108
+ * `checkContrast`. These tints are now computed here, at build time, in OKLab — the Cartesian form
109
+ * of OKLCH, so lightness is perceptually even — and emitted as plain hex. No browser has to support
110
+ * `oklch()` or relative colour syntax, and every derived background can sit in a contrast pair.
111
+ *
112
+ * OKLab rather than polar OKLCH: the surfaces are near-neutral, so their hue is noise, and an OKLCH
113
+ * mix would swing the tint's hue along that noise (a red mixed toward a blue-grey surface drifts
114
+ * purple). Mixing a and b linearly keeps the tone's hue and walks its chroma down evenly.
115
+ *
116
+ * Refs: CR-050 FR-THM-004 FR-TOK-001
117
+ */
118
+ /** An OKLab (L, a, b) triple, or an sRGB triple in [0, 1] internally. */
119
+ type OklabColor = readonly [number, number, number];
120
+ /** sRGB → OKLab (Björn Ottosson's reference matrices). */
121
+ declare function toOklab(hex: string): OklabColor;
122
+ /** OKLab → sRGB hex, clipping each out-of-gamut channel. */
123
+ declare function fromOklab([lightness, a, b]: OklabColor): string;
124
+ /** `weight` of `foreground` over `background`, mixed in OKLab. 0 → background, 1 → foreground. */
125
+ declare function mixOklab(foreground: string, background: string, weight: number): string;
126
+ /** The two tones whose state tints are derived (`tone.accent.*`, `tone.danger.*`). */
127
+ type ToneName = "accent" | "danger";
128
+ /** `tone.<tone>.<state>` → hex for one theme, from that theme's tone colours and raised surface. */
129
+ declare function toneValues(tones: Readonly<Record<ToneName, string>>, surfaceRaised: string): Readonly<Record<string, string>>;
130
+
131
+ export { CDT_PREFIX, type OklabColor, PACKAGE_NAME, SEVERITY_ICONS, STATUS_ICONS, type SeverityIconName, type StatusIconName, type TokenDefinition, type TokenGroup, type TokenTier, type TokenUsage, type ToneName, contrastRatio, fromOklab, mixOklab, toOklab, toneValues };
package/dist/index.js CHANGED
@@ -476,4 +476,174 @@ var SEVERITY_ICONS = {
476
476
  blocked: "shield-x"
477
477
  };
478
478
 
479
- export { CDT_PREFIX, PACKAGE_NAME, SEVERITY_ICONS, STATUS_ICONS, breakpoints, tokens };
479
+ // src/contrast/color.ts
480
+ var HEX_SOURCE = "#([0-9a-f]{3,8})\\b";
481
+ var FUNCTIONAL_SOURCE = "\\b(rgba?|hsla?)\\(\\s*([^()]*)\\)";
482
+ var clamp = (value, low, high) => Math.min(Math.max(value, low), high);
483
+ function fromHex(digits) {
484
+ const byte = (pair) => parseInt(pair.length === 1 ? pair + pair : pair, 16);
485
+ if (digits.length === 3 || digits.length === 4) {
486
+ const [r, g, b, a] = [...digits];
487
+ return { r: byte(r), g: byte(g), b: byte(b), alpha: a === void 0 ? 1 : byte(a) / 255 };
488
+ }
489
+ if (digits.length === 6 || digits.length === 8) {
490
+ const at = (index) => digits.slice(index, index + 2);
491
+ return {
492
+ r: byte(at(0)),
493
+ g: byte(at(2)),
494
+ b: byte(at(4)),
495
+ alpha: digits.length === 8 ? byte(at(6)) / 255 : 1
496
+ };
497
+ }
498
+ return void 0;
499
+ }
500
+ function argumentsOf(body) {
501
+ return body.replace(/\//g, " ").split(/[\s,]+/).filter((part) => part !== "");
502
+ }
503
+ function toAlpha(raw) {
504
+ if (raw === void 0) return 1;
505
+ const value = raw.endsWith("%") ? Number(raw.slice(0, -1)) / 100 : Number(raw);
506
+ return Number.isFinite(value) ? clamp(value, 0, 1) : 1;
507
+ }
508
+ function fromRgb(body) {
509
+ const parts = argumentsOf(body);
510
+ if (parts.length < 3) return void 0;
511
+ const channel = (raw) => {
512
+ const value = raw.endsWith("%") ? Number(raw.slice(0, -1)) / 100 * 255 : Number(raw);
513
+ return Number.isFinite(value) ? clamp(Math.round(value), 0, 255) : Number.NaN;
514
+ };
515
+ const r = channel(parts[0]);
516
+ const g = channel(parts[1]);
517
+ const b = channel(parts[2]);
518
+ if ([r, g, b].some(Number.isNaN)) return void 0;
519
+ return { r, g, b, alpha: toAlpha(parts[3]) };
520
+ }
521
+ function fromHsl(body) {
522
+ const parts = argumentsOf(body);
523
+ if (parts.length < 3) return void 0;
524
+ const hue = Number(parts[0].replace(/deg$/i, ""));
525
+ const saturation = Number(parts[1].replace(/%$/, "")) / 100;
526
+ const lightness = Number(parts[2].replace(/%$/, "")) / 100;
527
+ if (![hue, saturation, lightness].every((value) => Number.isFinite(value))) return void 0;
528
+ const chroma = (1 - Math.abs(2 * clamp(lightness, 0, 1) - 1)) * clamp(saturation, 0, 1);
529
+ const sextant = (hue % 360 + 360) % 360 / 60;
530
+ const secondary = chroma * (1 - Math.abs(sextant % 2 - 1));
531
+ const lift = clamp(lightness, 0, 1) - chroma / 2;
532
+ const wheel = [
533
+ [chroma, secondary, 0],
534
+ [secondary, chroma, 0],
535
+ [0, chroma, secondary],
536
+ [0, secondary, chroma],
537
+ [secondary, 0, chroma],
538
+ [chroma, 0, secondary]
539
+ ];
540
+ const [r, g, b] = wheel[Math.floor(sextant) % 6];
541
+ const channel = (raw) => clamp(Math.round((raw + lift) * 255), 0, 255);
542
+ return { r: channel(r), g: channel(g), b: channel(b), alpha: toAlpha(parts[3]) };
543
+ }
544
+ function parseFunctional(name, body) {
545
+ return name.toLowerCase().startsWith("hsl") ? fromHsl(body) : fromRgb(body);
546
+ }
547
+ function findColors(value) {
548
+ const found = [];
549
+ for (const match of value.matchAll(new RegExp(FUNCTIONAL_SOURCE, "gi"))) {
550
+ const parsed = parseFunctional(match[1], match[2]);
551
+ if (parsed) found.push(parsed);
552
+ }
553
+ for (const match of value.matchAll(new RegExp(HEX_SOURCE, "gi"))) {
554
+ const parsed = fromHex(match[1]);
555
+ if (parsed) found.push(parsed);
556
+ }
557
+ return found;
558
+ }
559
+ function linearise(channel8) {
560
+ const channel = channel8 / 255;
561
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
562
+ }
563
+ function relativeLuminance({ r, g, b }) {
564
+ return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
565
+ }
566
+ function contrastRatio(one, other) {
567
+ const first = relativeLuminance(one);
568
+ const second = relativeLuminance(other);
569
+ const lighter = Math.max(first, second);
570
+ const darker = Math.min(first, second);
571
+ return (lighter + 0.05) / (darker + 0.05);
572
+ }
573
+ function compositeOver(foreground, background) {
574
+ if (foreground.alpha >= 1) return foreground;
575
+ const mix = (front, back) => Math.round(front * foreground.alpha + back * (1 - foreground.alpha));
576
+ return {
577
+ r: mix(foreground.r, background.r),
578
+ g: mix(foreground.g, background.g),
579
+ b: mix(foreground.b, background.b),
580
+ alpha: 1
581
+ };
582
+ }
583
+
584
+ // src/measure.ts
585
+ function parse(role, value) {
586
+ const colours = findColors(value);
587
+ if (colours.length !== 1) throw new Error(`contrastRatio: ${role} "${value}" must hold exactly one #hex, rgb() or hsl() colour, found ${colours.length}`);
588
+ return colours[0];
589
+ }
590
+ function contrastRatio2(foreground, background) {
591
+ const back = parse("background", background);
592
+ if (back.alpha < 1) throw new Error(`contrastRatio: background "${background}" is translucent; composite it onto its surface first`);
593
+ return contrastRatio(compositeOver(parse("foreground", foreground), back), back);
594
+ }
595
+
596
+ // src/oklab.ts
597
+ var HEX = /^#([0-9a-f]{6})$/i;
598
+ function parseHex(hex) {
599
+ const match = HEX.exec(hex);
600
+ if (match === null) throw new Error(`oklab: expected #rrggbb, got "${hex}"`);
601
+ const digits = match[1];
602
+ return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16) / 255);
603
+ }
604
+ var toLinear = (channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
605
+ var toGamma = (channel) => channel <= 31308e-7 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055;
606
+ var clamp2 = (channel) => Math.min(1, Math.max(0, channel));
607
+ function toOklab(hex) {
608
+ const [r, g, b] = parseHex(hex).map(toLinear);
609
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
610
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
611
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
612
+ return [
613
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
614
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
615
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
616
+ ];
617
+ }
618
+ function fromOklab([lightness, a, b]) {
619
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
620
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
621
+ const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
622
+ const linear = [
623
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
624
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
625
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
626
+ ];
627
+ return `#${linear.map((channel) => Math.round(clamp2(toGamma(clamp2(channel))) * 255).toString(16).padStart(2, "0")).join("")}`;
628
+ }
629
+ function mixOklab(foreground, background, weight) {
630
+ if (!(weight >= 0 && weight <= 1)) throw new Error(`oklab: weight must be within [0, 1], got ${weight}`);
631
+ const front = toOklab(foreground);
632
+ const back = toOklab(background);
633
+ return fromOklab(front.map((channel, index) => channel * weight + back[index] * (1 - weight)));
634
+ }
635
+ var TONE_WEIGHTS = {
636
+ background: 0.04,
637
+ border: 0.14,
638
+ backgroundHover: 0.12,
639
+ borderHover: 0.45
640
+ };
641
+ function toneValues(tones, surfaceRaised) {
642
+ const out = {};
643
+ for (const tone of Object.keys(tones)) {
644
+ for (const [state, weight] of Object.entries(TONE_WEIGHTS)) out[`tone.${tone}.${state}`] = mixOklab(tones[tone], surfaceRaised, weight);
645
+ }
646
+ return out;
647
+ }
648
+
649
+ export { CDT_PREFIX, PACKAGE_NAME, SEVERITY_ICONS, STATUS_ICONS, breakpoints, contrastRatio2 as contrastRatio, fromOklab, mixOklab, toOklab, tokens, toneValues };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conductor-by-89soone/tokens",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Theme-aware design tokens and validation CLIs for Conductor.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://89sooner.github.io/design-system/",