okchroma 0.5.0 → 0.6.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/README.md CHANGED
@@ -41,7 +41,7 @@ const css = brandCss('acme', 'Acme', theme.themed, theme.secondary?.scale ?? nul
41
41
  // and data-theme="dark" on it for dark mode
42
42
  ```
43
43
 
44
- The Figma tree comes from `themeToFigma`. Signatures, the full input, and an end-to-end example:
44
+ The same values as one object per mode come from `themeTokens`, and the state layer as named rows from `interactionCss` and `interactionTokens` (`docs/agents.md` lists the rows). The Figma tree comes from `themeToFigma`. Signatures, the full input, and an end-to-end example:
45
45
  [Install and API](https://egerrity.github.io/okchroma/#/docs/install).
46
46
 
47
47
  ## Run from source
@@ -24,7 +24,12 @@ __export(index_exports, {
24
24
  DEFAULT_LINK_HEX: () => DEFAULT_LINK_HEX,
25
25
  DISABLED_OPACITY: () => DISABLED_OPACITY,
26
26
  EXT_KEY: () => EXT_KEY,
27
+ INTERACTION_FAMILIES: () => INTERACTION_FAMILIES,
28
+ INTERACTION_LADDER: () => INTERACTION_LADDER,
29
+ INTERACTION_POLE_FAMILY: () => INTERACTION_POLE_FAMILY,
30
+ INTERACTION_ROWS: () => INTERACTION_ROWS,
27
31
  INTERACTION_RUNGS: () => INTERACTION_RUNGS,
32
+ INTERACTION_STATES: () => INTERACTION_STATES,
28
33
  MODE_SPECS: () => MODE_SPECS,
29
34
  OFFSET_ALPHAS: () => OFFSET_ALPHAS,
30
35
  OPACITY_RUNGS: () => OPACITY_RUNGS,
@@ -60,6 +65,11 @@ __export(index_exports, {
60
65
  generateNeutralScale: () => generateNeutralScale,
61
66
  generateScale: () => generateScale,
62
67
  generateSubtleSecondary: () => generateSubtleSecondary,
68
+ interactionCss: () => interactionCss,
69
+ interactionRows: () => interactionRows,
70
+ interactionTintName: () => interactionTintName,
71
+ interactionTokens: () => interactionTokens,
72
+ interactionVarName: () => interactionVarName,
63
73
  neutralCss: () => neutralCss,
64
74
  neutralTintHue: () => neutralTintHue,
65
75
  opacityLeafName: () => opacityLeafName,
@@ -67,19 +77,24 @@ __export(index_exports, {
67
77
  opacityVarName: () => opacityVarName,
68
78
  pageStopFor: () => pageStopFor,
69
79
  parseToken: () => parseToken,
80
+ readEmission: () => readEmission,
70
81
  resolveBrand: () => resolveBrand,
71
82
  resolveDtcgRamp: () => resolveDtcgRamp,
72
83
  resolveLinkInverseTrio: () => resolveLinkInverseTrio,
73
84
  resolveLinkTrio: () => resolveLinkTrio,
85
+ resolveReferences: () => resolveReferences,
74
86
  resolveTheme: () => resolveTheme,
75
87
  signalScalesFor: () => signalScalesFor,
76
88
  signalsCss: () => signalsCss,
77
89
  stopHex: () => stopHex,
78
90
  stopTokenName: () => stopTokenName,
79
91
  stopsToVars: () => stopsToVars,
92
+ systemCss: () => systemCss,
80
93
  themeToFigma: () => themeToFigma,
94
+ themeTokens: () => themeTokens,
81
95
  toHex: () => toHex2,
82
- tokenOrder: () => tokenOrder
96
+ tokenOrder: () => tokenOrder,
97
+ tokensFromEmission: () => tokensFromEmission
83
98
  });
84
99
  module.exports = __toCommonJS(index_exports);
85
100
 
@@ -3828,6 +3843,229 @@ function brandCss(slug, displayName, r, secondary, noteSuffix = "", neutralLevel
3828
3843
  ].join("\n");
3829
3844
  }
3830
3845
 
3846
+ // src/engine/tokensRender.ts
3847
+ var DARK_MARK = '[data-theme="dark"]';
3848
+ function readEmission(css) {
3849
+ const light = /* @__PURE__ */ new Map();
3850
+ const dark = /* @__PURE__ */ new Map();
3851
+ const order = [];
3852
+ let depth = 0;
3853
+ let skipUntilDepth = -1;
3854
+ let inDark = false;
3855
+ let inBlock = false;
3856
+ for (const line of css.split("\n")) {
3857
+ const t = line.trim();
3858
+ if (t.endsWith("{")) {
3859
+ if (t.startsWith("@")) {
3860
+ if (skipUntilDepth < 0) skipUntilDepth = depth;
3861
+ depth++;
3862
+ continue;
3863
+ }
3864
+ depth++;
3865
+ if (skipUntilDepth < 0) {
3866
+ inBlock = true;
3867
+ inDark = t.includes(DARK_MARK);
3868
+ }
3869
+ continue;
3870
+ }
3871
+ if (t === "}") {
3872
+ depth--;
3873
+ if (skipUntilDepth >= 0 && depth === skipUntilDepth) skipUntilDepth = -1;
3874
+ inBlock = false;
3875
+ continue;
3876
+ }
3877
+ if (skipUntilDepth >= 0 || !inBlock || !t.startsWith("--")) continue;
3878
+ const colon = t.indexOf(":");
3879
+ if (colon === -1) continue;
3880
+ const name = t.slice(2, colon).trim();
3881
+ const value = t.slice(colon + 1).replace(/;$/, "").trim();
3882
+ const target = inDark ? dark : light;
3883
+ if (!light.has(name) && !dark.has(name)) order.push(name);
3884
+ target.set(name, value);
3885
+ }
3886
+ return { light, dark, order };
3887
+ }
3888
+ var VAR_REF = /var\(--([a-z0-9-]+)(?:\s*,\s*([^)]*))?\)/g;
3889
+ function resolveReferences(own, shared) {
3890
+ const out = {};
3891
+ const lookup = (name) => own.get(name) ?? shared.get(name);
3892
+ const resolve = (value, depth) => depth > 12 ? value : value.replace(VAR_REF, (_, ref, fallback) => {
3893
+ const target = lookup(ref);
3894
+ if (target !== void 0) return resolve(target, depth + 1);
3895
+ return fallback !== void 0 ? resolve(fallback.trim(), depth + 1) : `var(--${ref})`;
3896
+ });
3897
+ for (const name of /* @__PURE__ */ new Set([...own.keys(), ...shared.keys()])) out[name] = resolve(lookup(name), 0);
3898
+ return out;
3899
+ }
3900
+ function systemCss() {
3901
+ const planeRef = (stop) => stop === PAPER_0 ? `var(--${PAPER_0})` : `var(--${CSS_FAMILY.neutral}-${stop})`;
3902
+ const pad = (r) => String(r).padStart(2, "0");
3903
+ const lines = (mode) => [
3904
+ ...Object.entries(SURFACE_PLANE_LAW).map(([leaf, stops]) => ` --surface-${leaf.split("/").pop()}: ${planeRef(stops[mode])};`),
3905
+ ...Object.keys(SHADOW_ALPHAS).map(Number).map((r) => ` --shadow-${pad(r)}: rgba(0, 0, 0, ${SHADOW_ALPHAS[r][mode]});`),
3906
+ ` --scrim: rgba(0, 0, 0, ${SCRIM_ALPHA});`,
3907
+ ` --disabled-opacity: ${DISABLED_OPACITY};`
3908
+ ];
3909
+ return [":root {", ...lines("light"), "}", `:root${DARK_MARK}, ${DARK_MARK} {`, ...lines("dark"), "}"].join("\n");
3910
+ }
3911
+ function tokensFromEmission(slug, css) {
3912
+ const { light, dark, order } = readEmission(css);
3913
+ const resolvedLight = resolveReferences(light, /* @__PURE__ */ new Map());
3914
+ const resolvedDark = resolveReferences(dark, light);
3915
+ const invariant = order.filter((n) => !dark.has(n) || light.has(n) && light.get(n) === dark.get(n));
3916
+ const raw = { light: Object.fromEntries(light), dark: Object.fromEntries(dark) };
3917
+ return { slug, names: order, light: resolvedLight, dark: resolvedDark, invariant, raw };
3918
+ }
3919
+ function themeTokens(input) {
3920
+ const css = [
3921
+ signalsCss(input.contrastProfile),
3922
+ systemCss(),
3923
+ brandCss(
3924
+ input.slug,
3925
+ input.displayName ?? input.slug,
3926
+ input.brand,
3927
+ input.secondary ?? null,
3928
+ "",
3929
+ input.neutralLevel ?? "default",
3930
+ input.contrastProfile,
3931
+ input.secondaryStyle,
3932
+ input.ctaEscape,
3933
+ input.linkHex ?? null,
3934
+ input.ctaBorder ?? true,
3935
+ input.neutralH
3936
+ )
3937
+ ].join("\n");
3938
+ return tokensFromEmission(input.slug, css);
3939
+ }
3940
+
3941
+ // src/engine/interaction.ts
3942
+ var INTERACTION_POLE_FAMILY = { strong: "neutral-strong", inverse: "neutral-inverse" };
3943
+ var INTERACTION_FAMILIES = [
3944
+ CSS_FAMILY.neutral,
3945
+ CSS_FAMILY.brandPrimary,
3946
+ CSS_FAMILY.brandSecondary,
3947
+ ...SIGNALS2.map((s) => s.emitName),
3948
+ INTERACTION_POLE_FAMILY.strong,
3949
+ INTERACTION_POLE_FAMILY.inverse
3950
+ ];
3951
+ var INTERACTION_STATES = ["enabled", "hover", "pressed", "selected"];
3952
+ var INTERACTION_LADDER = {
3953
+ subtle: { enabled: 8, hover: 12, pressed: 16, selected: 24 },
3954
+ hint: { enabled: null, hover: 8, pressed: 12, selected: 16 }
3955
+ };
3956
+ var INTERACTION_ROWS = [
3957
+ "fg",
3958
+ "fg-strong",
3959
+ "fg-on-hint",
3960
+ "solid-bg-enabled",
3961
+ "solid-bg-hover",
3962
+ "solid-bg-pressed",
3963
+ "solid-border",
3964
+ "solid-fg",
3965
+ "subtle-bg-enabled",
3966
+ "subtle-bg-hover",
3967
+ "subtle-bg-pressed",
3968
+ "subtle-bg-selected",
3969
+ "hint-bg-enabled",
3970
+ "hint-bg-hover",
3971
+ "hint-bg-pressed",
3972
+ "hint-bg-selected"
3973
+ ];
3974
+ var interactionVarName = (family, row) => `--${family}-${row}`;
3975
+ var isPole = (family) => family === INTERACTION_POLE_FAMILY.strong || family === INTERACTION_POLE_FAMILY.inverse;
3976
+ var hexChannels = (hex) => {
3977
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
3978
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
3979
+ };
3980
+ function interactionTintName(family) {
3981
+ if (family === INTERACTION_POLE_FAMILY.strong) return PEN_100;
3982
+ if (family === INTERACTION_POLE_FAMILY.inverse) return PAPER_0;
3983
+ return `${family}-${stopTokenName(8)}`;
3984
+ }
3985
+ function interactionRows(family, mode, tokens) {
3986
+ const neutral = CSS_FAMILY.neutral;
3987
+ const v2 = (name) => `var(--${name})`;
3988
+ const pen = (stop) => `${family}-${stopTokenName(stop)}`;
3989
+ const tintHex = tokens[mode][interactionTintName(family)];
3990
+ const ch = tintHex ? hexChannels(tintHex) : null;
3991
+ if (!ch) throw new Error(`interactionRows: no sRGB hex for ${interactionTintName(family)} in ${mode}`);
3992
+ const at = (rung) => rung === null ? v2(TRANSPARENT_VAR.slice(2)) : `rgba(${ch[0]}, ${ch[1]}, ${ch[2]}, ${OPACITY_RUNGS[rung]})`;
3993
+ const text = isPole(family) ? [["fg", v2(interactionTintName(family))], ["fg-strong", v2(interactionTintName(family))], ["fg-on-hint", v2(interactionTintName(family))]] : [["fg", v2(pen(10))], ["fg-strong", v2(pen(11))], ["fg-on-hint", v2(pen(9))]];
3994
+ const solid = family === INTERACTION_POLE_FAMILY.strong ? [
3995
+ ["solid-bg-enabled", v2(PEN_100)],
3996
+ ["solid-bg-hover", v2(`${neutral}-${stopTokenName(11)}`)],
3997
+ ["solid-bg-pressed", v2(`${neutral}-${stopTokenName(10)}`)],
3998
+ ["solid-border", v2(TRANSPARENT_VAR.slice(2))],
3999
+ ["solid-fg", v2(PAPER_0)]
4000
+ ] : family === INTERACTION_POLE_FAMILY.inverse ? [
4001
+ ["solid-bg-enabled", v2(PAPER_0)],
4002
+ ["solid-bg-hover", v2(`${neutral}-${stopTokenName(2)}`)],
4003
+ ["solid-bg-pressed", v2(`${neutral}-${stopTokenName(3)}`)],
4004
+ ["solid-border", v2(TRANSPARENT_VAR.slice(2))],
4005
+ ["solid-fg", v2(PEN_100)]
4006
+ ] : [
4007
+ ["solid-bg-enabled", v2(`${family}-${STAMP_FILL}`)],
4008
+ ["solid-bg-hover", v2(`${family}-${STAMP_FILL_HOVER}`)],
4009
+ ["solid-bg-pressed", v2(`${family}-${STAMP_FILL_PRESSED}`)],
4010
+ ["solid-border", v2(`${family}-${STAMP_EDGE}`)],
4011
+ ["solid-fg", v2(`${family}-${STAMP_ON}`)]
4012
+ ];
4013
+ const ladder = (tier) => INTERACTION_STATES.map((state) => [`${tier}-bg-${state}`, at(INTERACTION_LADDER[tier][state])]);
4014
+ return [...text, ...solid, ...ladder("subtle"), ...ladder("hint")];
4015
+ }
4016
+ var rowVariesByMode = (row) => /^(subtle|hint)-bg-/.test(row) && row !== "hint-bg-enabled";
4017
+ function interactionCss(slug, tokens) {
4018
+ const scope = `[data-brand="${slug}"]`;
4019
+ const light = [];
4020
+ const dark = [];
4021
+ for (const family of INTERACTION_FAMILIES) {
4022
+ for (const [row, value] of interactionRows(family, "light", tokens)) light.push(` ${interactionVarName(family, row)}: ${value};`);
4023
+ for (const [row, value] of interactionRows(family, "dark", tokens)) if (rowVariesByMode(row)) dark.push(` ${interactionVarName(family, row)}: ${value};`);
4024
+ }
4025
+ const scoped = INTERACTION_FAMILIES.flatMap((family) => [
4026
+ `${scope} [data-family="${family}"], ${scope}[data-family="${family}"] {`,
4027
+ ...INTERACTION_ROWS.map((row) => ` --${row}: var(${interactionVarName(family, row)});`),
4028
+ `}`
4029
+ ]);
4030
+ return [
4031
+ `/* Interaction register: solid, subtle and hint tiers per family, the translucent rows at the engine's opacity rungs */`,
4032
+ `${scope} {`,
4033
+ ...light,
4034
+ `}`,
4035
+ `${scope}[data-theme="dark"] {`,
4036
+ ...dark,
4037
+ `}`,
4038
+ ...scoped
4039
+ ].join("\n");
4040
+ }
4041
+ function interactionTokens(tokens) {
4042
+ const add = (mode) => {
4043
+ const m = /* @__PURE__ */ new Map();
4044
+ for (const family of INTERACTION_FAMILIES)
4045
+ for (const [row, value] of interactionRows(family, mode, tokens)) m.set(interactionVarName(family, row).slice(2), value);
4046
+ return m;
4047
+ };
4048
+ const rawLight = add("light");
4049
+ const rawDark = add("dark");
4050
+ const baseLight = new Map(Object.entries(tokens.light));
4051
+ const baseDark = new Map(Object.entries(tokens.dark));
4052
+ const light = resolveReferences(rawLight, baseLight);
4053
+ const dark = resolveReferences(rawDark, baseDark);
4054
+ const names = [...tokens.names, ...rawLight.keys()];
4055
+ const invariant = [...tokens.invariant, ...[...rawLight.keys()].filter((n) => rawLight.get(n) === rawDark.get(n))];
4056
+ return {
4057
+ slug: tokens.slug,
4058
+ names,
4059
+ light: { ...tokens.light, ...Object.fromEntries([...rawLight.keys()].map((n) => [n, light[n]])) },
4060
+ dark: { ...tokens.dark, ...Object.fromEntries([...rawDark.keys()].map((n) => [n, dark[n]])) },
4061
+ invariant,
4062
+ raw: {
4063
+ light: { ...tokens.raw.light, ...Object.fromEntries(rawLight) },
4064
+ dark: { ...tokens.raw.dark, ...Object.fromEntries(rawDark) }
4065
+ }
4066
+ };
4067
+ }
4068
+
3831
4069
  // src/engine/figmaRender.ts
3832
4070
  var clamp01 = (v2) => Math.min(1, Math.max(0, v2));
3833
4071
  var TRANSPARENT_TOKEN = {
@@ -3995,7 +4233,7 @@ function themeToFigma(r, input) {
3995
4233
  }
3996
4234
  putLeaf(g2, SYSTEM_LEAF.ALPHA.TRANSPARENT, pole(true, 0));
3997
4235
  for (const rung of Object.keys(OPACITY_RUNGS).map(Number))
3998
- putLeaf(g2, opacityTokenPath(rung), { $type: "number", $value: OPACITY_RUNGS[rung] });
4236
+ putLeaf(g2, opacityTokenPath(rung), { $type: "number", $value: rung });
3999
4237
  putLeaf(g2, SYSTEM_LEAF.ALPHA.INK, pole(mode === "dark", SOFT_ON_CTA_ALPHA[mode]));
4000
4238
  putLeaf(g2, SYSTEM_LEAF.ALPHA.AWAY_FROM_BG_06, OFFSET_TOKEN(6, mode));
4001
4239
  putLeaf(g2, SYSTEM_LEAF.ALPHA.AWAY_FROM_BG_08, OFFSET_TOKEN(8, mode));
@@ -3743,6 +3743,229 @@ function brandCss(slug, displayName, r, secondary, noteSuffix = "", neutralLevel
3743
3743
  ].join("\n");
3744
3744
  }
3745
3745
 
3746
+ // src/engine/tokensRender.ts
3747
+ var DARK_MARK = '[data-theme="dark"]';
3748
+ function readEmission(css) {
3749
+ const light = /* @__PURE__ */ new Map();
3750
+ const dark = /* @__PURE__ */ new Map();
3751
+ const order = [];
3752
+ let depth = 0;
3753
+ let skipUntilDepth = -1;
3754
+ let inDark = false;
3755
+ let inBlock = false;
3756
+ for (const line of css.split("\n")) {
3757
+ const t = line.trim();
3758
+ if (t.endsWith("{")) {
3759
+ if (t.startsWith("@")) {
3760
+ if (skipUntilDepth < 0) skipUntilDepth = depth;
3761
+ depth++;
3762
+ continue;
3763
+ }
3764
+ depth++;
3765
+ if (skipUntilDepth < 0) {
3766
+ inBlock = true;
3767
+ inDark = t.includes(DARK_MARK);
3768
+ }
3769
+ continue;
3770
+ }
3771
+ if (t === "}") {
3772
+ depth--;
3773
+ if (skipUntilDepth >= 0 && depth === skipUntilDepth) skipUntilDepth = -1;
3774
+ inBlock = false;
3775
+ continue;
3776
+ }
3777
+ if (skipUntilDepth >= 0 || !inBlock || !t.startsWith("--")) continue;
3778
+ const colon = t.indexOf(":");
3779
+ if (colon === -1) continue;
3780
+ const name = t.slice(2, colon).trim();
3781
+ const value = t.slice(colon + 1).replace(/;$/, "").trim();
3782
+ const target = inDark ? dark : light;
3783
+ if (!light.has(name) && !dark.has(name)) order.push(name);
3784
+ target.set(name, value);
3785
+ }
3786
+ return { light, dark, order };
3787
+ }
3788
+ var VAR_REF = /var\(--([a-z0-9-]+)(?:\s*,\s*([^)]*))?\)/g;
3789
+ function resolveReferences(own, shared) {
3790
+ const out = {};
3791
+ const lookup = (name) => own.get(name) ?? shared.get(name);
3792
+ const resolve = (value, depth) => depth > 12 ? value : value.replace(VAR_REF, (_, ref, fallback) => {
3793
+ const target = lookup(ref);
3794
+ if (target !== void 0) return resolve(target, depth + 1);
3795
+ return fallback !== void 0 ? resolve(fallback.trim(), depth + 1) : `var(--${ref})`;
3796
+ });
3797
+ for (const name of /* @__PURE__ */ new Set([...own.keys(), ...shared.keys()])) out[name] = resolve(lookup(name), 0);
3798
+ return out;
3799
+ }
3800
+ function systemCss() {
3801
+ const planeRef = (stop) => stop === PAPER_0 ? `var(--${PAPER_0})` : `var(--${CSS_FAMILY.neutral}-${stop})`;
3802
+ const pad = (r) => String(r).padStart(2, "0");
3803
+ const lines = (mode) => [
3804
+ ...Object.entries(SURFACE_PLANE_LAW).map(([leaf, stops]) => ` --surface-${leaf.split("/").pop()}: ${planeRef(stops[mode])};`),
3805
+ ...Object.keys(SHADOW_ALPHAS).map(Number).map((r) => ` --shadow-${pad(r)}: rgba(0, 0, 0, ${SHADOW_ALPHAS[r][mode]});`),
3806
+ ` --scrim: rgba(0, 0, 0, ${SCRIM_ALPHA});`,
3807
+ ` --disabled-opacity: ${DISABLED_OPACITY};`
3808
+ ];
3809
+ return [":root {", ...lines("light"), "}", `:root${DARK_MARK}, ${DARK_MARK} {`, ...lines("dark"), "}"].join("\n");
3810
+ }
3811
+ function tokensFromEmission(slug, css) {
3812
+ const { light, dark, order } = readEmission(css);
3813
+ const resolvedLight = resolveReferences(light, /* @__PURE__ */ new Map());
3814
+ const resolvedDark = resolveReferences(dark, light);
3815
+ const invariant = order.filter((n) => !dark.has(n) || light.has(n) && light.get(n) === dark.get(n));
3816
+ const raw = { light: Object.fromEntries(light), dark: Object.fromEntries(dark) };
3817
+ return { slug, names: order, light: resolvedLight, dark: resolvedDark, invariant, raw };
3818
+ }
3819
+ function themeTokens(input) {
3820
+ const css = [
3821
+ signalsCss(input.contrastProfile),
3822
+ systemCss(),
3823
+ brandCss(
3824
+ input.slug,
3825
+ input.displayName ?? input.slug,
3826
+ input.brand,
3827
+ input.secondary ?? null,
3828
+ "",
3829
+ input.neutralLevel ?? "default",
3830
+ input.contrastProfile,
3831
+ input.secondaryStyle,
3832
+ input.ctaEscape,
3833
+ input.linkHex ?? null,
3834
+ input.ctaBorder ?? true,
3835
+ input.neutralH
3836
+ )
3837
+ ].join("\n");
3838
+ return tokensFromEmission(input.slug, css);
3839
+ }
3840
+
3841
+ // src/engine/interaction.ts
3842
+ var INTERACTION_POLE_FAMILY = { strong: "neutral-strong", inverse: "neutral-inverse" };
3843
+ var INTERACTION_FAMILIES = [
3844
+ CSS_FAMILY.neutral,
3845
+ CSS_FAMILY.brandPrimary,
3846
+ CSS_FAMILY.brandSecondary,
3847
+ ...SIGNALS2.map((s) => s.emitName),
3848
+ INTERACTION_POLE_FAMILY.strong,
3849
+ INTERACTION_POLE_FAMILY.inverse
3850
+ ];
3851
+ var INTERACTION_STATES = ["enabled", "hover", "pressed", "selected"];
3852
+ var INTERACTION_LADDER = {
3853
+ subtle: { enabled: 8, hover: 12, pressed: 16, selected: 24 },
3854
+ hint: { enabled: null, hover: 8, pressed: 12, selected: 16 }
3855
+ };
3856
+ var INTERACTION_ROWS = [
3857
+ "fg",
3858
+ "fg-strong",
3859
+ "fg-on-hint",
3860
+ "solid-bg-enabled",
3861
+ "solid-bg-hover",
3862
+ "solid-bg-pressed",
3863
+ "solid-border",
3864
+ "solid-fg",
3865
+ "subtle-bg-enabled",
3866
+ "subtle-bg-hover",
3867
+ "subtle-bg-pressed",
3868
+ "subtle-bg-selected",
3869
+ "hint-bg-enabled",
3870
+ "hint-bg-hover",
3871
+ "hint-bg-pressed",
3872
+ "hint-bg-selected"
3873
+ ];
3874
+ var interactionVarName = (family, row) => `--${family}-${row}`;
3875
+ var isPole = (family) => family === INTERACTION_POLE_FAMILY.strong || family === INTERACTION_POLE_FAMILY.inverse;
3876
+ var hexChannels = (hex) => {
3877
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
3878
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
3879
+ };
3880
+ function interactionTintName(family) {
3881
+ if (family === INTERACTION_POLE_FAMILY.strong) return PEN_100;
3882
+ if (family === INTERACTION_POLE_FAMILY.inverse) return PAPER_0;
3883
+ return `${family}-${stopTokenName(8)}`;
3884
+ }
3885
+ function interactionRows(family, mode, tokens) {
3886
+ const neutral = CSS_FAMILY.neutral;
3887
+ const v2 = (name) => `var(--${name})`;
3888
+ const pen = (stop) => `${family}-${stopTokenName(stop)}`;
3889
+ const tintHex = tokens[mode][interactionTintName(family)];
3890
+ const ch = tintHex ? hexChannels(tintHex) : null;
3891
+ if (!ch) throw new Error(`interactionRows: no sRGB hex for ${interactionTintName(family)} in ${mode}`);
3892
+ const at = (rung) => rung === null ? v2(TRANSPARENT_VAR.slice(2)) : `rgba(${ch[0]}, ${ch[1]}, ${ch[2]}, ${OPACITY_RUNGS[rung]})`;
3893
+ const text = isPole(family) ? [["fg", v2(interactionTintName(family))], ["fg-strong", v2(interactionTintName(family))], ["fg-on-hint", v2(interactionTintName(family))]] : [["fg", v2(pen(10))], ["fg-strong", v2(pen(11))], ["fg-on-hint", v2(pen(9))]];
3894
+ const solid = family === INTERACTION_POLE_FAMILY.strong ? [
3895
+ ["solid-bg-enabled", v2(PEN_100)],
3896
+ ["solid-bg-hover", v2(`${neutral}-${stopTokenName(11)}`)],
3897
+ ["solid-bg-pressed", v2(`${neutral}-${stopTokenName(10)}`)],
3898
+ ["solid-border", v2(TRANSPARENT_VAR.slice(2))],
3899
+ ["solid-fg", v2(PAPER_0)]
3900
+ ] : family === INTERACTION_POLE_FAMILY.inverse ? [
3901
+ ["solid-bg-enabled", v2(PAPER_0)],
3902
+ ["solid-bg-hover", v2(`${neutral}-${stopTokenName(2)}`)],
3903
+ ["solid-bg-pressed", v2(`${neutral}-${stopTokenName(3)}`)],
3904
+ ["solid-border", v2(TRANSPARENT_VAR.slice(2))],
3905
+ ["solid-fg", v2(PEN_100)]
3906
+ ] : [
3907
+ ["solid-bg-enabled", v2(`${family}-${STAMP_FILL}`)],
3908
+ ["solid-bg-hover", v2(`${family}-${STAMP_FILL_HOVER}`)],
3909
+ ["solid-bg-pressed", v2(`${family}-${STAMP_FILL_PRESSED}`)],
3910
+ ["solid-border", v2(`${family}-${STAMP_EDGE}`)],
3911
+ ["solid-fg", v2(`${family}-${STAMP_ON}`)]
3912
+ ];
3913
+ const ladder = (tier) => INTERACTION_STATES.map((state) => [`${tier}-bg-${state}`, at(INTERACTION_LADDER[tier][state])]);
3914
+ return [...text, ...solid, ...ladder("subtle"), ...ladder("hint")];
3915
+ }
3916
+ var rowVariesByMode = (row) => /^(subtle|hint)-bg-/.test(row) && row !== "hint-bg-enabled";
3917
+ function interactionCss(slug, tokens) {
3918
+ const scope = `[data-brand="${slug}"]`;
3919
+ const light = [];
3920
+ const dark = [];
3921
+ for (const family of INTERACTION_FAMILIES) {
3922
+ for (const [row, value] of interactionRows(family, "light", tokens)) light.push(` ${interactionVarName(family, row)}: ${value};`);
3923
+ for (const [row, value] of interactionRows(family, "dark", tokens)) if (rowVariesByMode(row)) dark.push(` ${interactionVarName(family, row)}: ${value};`);
3924
+ }
3925
+ const scoped = INTERACTION_FAMILIES.flatMap((family) => [
3926
+ `${scope} [data-family="${family}"], ${scope}[data-family="${family}"] {`,
3927
+ ...INTERACTION_ROWS.map((row) => ` --${row}: var(${interactionVarName(family, row)});`),
3928
+ `}`
3929
+ ]);
3930
+ return [
3931
+ `/* Interaction register: solid, subtle and hint tiers per family, the translucent rows at the engine's opacity rungs */`,
3932
+ `${scope} {`,
3933
+ ...light,
3934
+ `}`,
3935
+ `${scope}[data-theme="dark"] {`,
3936
+ ...dark,
3937
+ `}`,
3938
+ ...scoped
3939
+ ].join("\n");
3940
+ }
3941
+ function interactionTokens(tokens) {
3942
+ const add = (mode) => {
3943
+ const m = /* @__PURE__ */ new Map();
3944
+ for (const family of INTERACTION_FAMILIES)
3945
+ for (const [row, value] of interactionRows(family, mode, tokens)) m.set(interactionVarName(family, row).slice(2), value);
3946
+ return m;
3947
+ };
3948
+ const rawLight = add("light");
3949
+ const rawDark = add("dark");
3950
+ const baseLight = new Map(Object.entries(tokens.light));
3951
+ const baseDark = new Map(Object.entries(tokens.dark));
3952
+ const light = resolveReferences(rawLight, baseLight);
3953
+ const dark = resolveReferences(rawDark, baseDark);
3954
+ const names = [...tokens.names, ...rawLight.keys()];
3955
+ const invariant = [...tokens.invariant, ...[...rawLight.keys()].filter((n) => rawLight.get(n) === rawDark.get(n))];
3956
+ return {
3957
+ slug: tokens.slug,
3958
+ names,
3959
+ light: { ...tokens.light, ...Object.fromEntries([...rawLight.keys()].map((n) => [n, light[n]])) },
3960
+ dark: { ...tokens.dark, ...Object.fromEntries([...rawDark.keys()].map((n) => [n, dark[n]])) },
3961
+ invariant,
3962
+ raw: {
3963
+ light: { ...tokens.raw.light, ...Object.fromEntries(rawLight) },
3964
+ dark: { ...tokens.raw.dark, ...Object.fromEntries(rawDark) }
3965
+ }
3966
+ };
3967
+ }
3968
+
3746
3969
  // src/engine/figmaRender.ts
3747
3970
  var clamp01 = (v2) => Math.min(1, Math.max(0, v2));
3748
3971
  var TRANSPARENT_TOKEN = {
@@ -3910,7 +4133,7 @@ function themeToFigma(r, input) {
3910
4133
  }
3911
4134
  putLeaf(g2, SYSTEM_LEAF.ALPHA.TRANSPARENT, pole(true, 0));
3912
4135
  for (const rung of Object.keys(OPACITY_RUNGS).map(Number))
3913
- putLeaf(g2, opacityTokenPath(rung), { $type: "number", $value: OPACITY_RUNGS[rung] });
4136
+ putLeaf(g2, opacityTokenPath(rung), { $type: "number", $value: rung });
3914
4137
  putLeaf(g2, SYSTEM_LEAF.ALPHA.INK, pole(mode === "dark", SOFT_ON_CTA_ALPHA[mode]));
3915
4138
  putLeaf(g2, SYSTEM_LEAF.ALPHA.AWAY_FROM_BG_06, OFFSET_TOKEN(6, mode));
3916
4139
  putLeaf(g2, SYSTEM_LEAF.ALPHA.AWAY_FROM_BG_08, OFFSET_TOKEN(8, mode));
@@ -4000,7 +4223,12 @@ export {
4000
4223
  DEFAULT_LINK_HEX,
4001
4224
  DISABLED_OPACITY,
4002
4225
  EXT_KEY,
4226
+ INTERACTION_FAMILIES,
4227
+ INTERACTION_LADDER,
4228
+ INTERACTION_POLE_FAMILY,
4229
+ INTERACTION_ROWS,
4003
4230
  INTERACTION_RUNGS,
4231
+ INTERACTION_STATES,
4004
4232
  MODE_SPECS,
4005
4233
  OFFSET_ALPHAS,
4006
4234
  OPACITY_RUNGS,
@@ -4036,6 +4264,11 @@ export {
4036
4264
  generateNeutralScale,
4037
4265
  generateScale,
4038
4266
  generateSubtleSecondary,
4267
+ interactionCss,
4268
+ interactionRows,
4269
+ interactionTintName,
4270
+ interactionTokens,
4271
+ interactionVarName,
4039
4272
  neutralCss,
4040
4273
  neutralTintHue,
4041
4274
  opacityLeafName,
@@ -4043,17 +4276,22 @@ export {
4043
4276
  opacityVarName,
4044
4277
  pageStopFor,
4045
4278
  parseToken,
4279
+ readEmission,
4046
4280
  resolveBrand,
4047
4281
  resolveDtcgRamp,
4048
4282
  resolveLinkInverseTrio,
4049
4283
  resolveLinkTrio,
4284
+ resolveReferences,
4050
4285
  resolveTheme,
4051
4286
  signalScalesFor,
4052
4287
  signalsCss,
4053
4288
  stopHex,
4054
4289
  stopTokenName,
4055
4290
  stopsToVars,
4291
+ systemCss,
4056
4292
  themeToFigma,
4293
+ themeTokens,
4057
4294
  toHex2 as toHex,
4058
- tokenOrder
4295
+ tokenOrder,
4296
+ tokensFromEmission
4059
4297
  };
@@ -0,0 +1,35 @@
1
+ import { type OpacityRung } from './cssRender';
2
+ import { type ThemeTokens, type TokenMode } from './tokensRender';
3
+ export declare const INTERACTION_POLE_FAMILY: {
4
+ readonly strong: "neutral-strong";
5
+ readonly inverse: "neutral-inverse";
6
+ };
7
+ export type InteractionPoleFamily = (typeof INTERACTION_POLE_FAMILY)[keyof typeof INTERACTION_POLE_FAMILY];
8
+ /** every family the register emits, color families first, the two pole families last */
9
+ export declare const INTERACTION_FAMILIES: readonly string[];
10
+ export type InteractionState = 'enabled' | 'hover' | 'pressed' | 'selected';
11
+ export declare const INTERACTION_STATES: readonly InteractionState[];
12
+ /** the rung each translucent tier takes per state; null is the transparent rest */
13
+ export declare const INTERACTION_LADDER: Record<'subtle' | 'hint', Record<InteractionState, OpacityRung | null>>;
14
+ export declare const INTERACTION_ROWS: readonly ["fg", "fg-strong", "fg-on-hint", "solid-bg-enabled", "solid-bg-hover", "solid-bg-pressed", "solid-border", "solid-fg", "subtle-bg-enabled", "subtle-bg-hover", "subtle-bg-pressed", "subtle-bg-selected", "hint-bg-enabled", "hint-bg-hover", "hint-bg-pressed", "hint-bg-selected"];
15
+ export type InteractionRow = (typeof INTERACTION_ROWS)[number];
16
+ /** the CSS custom property a family's row lives under */
17
+ export declare const interactionVarName: (family: string, row: InteractionRow) => string;
18
+ /**
19
+ * The name whose channels a family's translucent rows ride: the family's
20
+ * highlighter-26, or the pole for the pole families.
21
+ */
22
+ export declare function interactionTintName(family: string): string;
23
+ /** One family's rows for one mode as [name, value] pairs, aliases as var() and rungs as rgba. */
24
+ export declare function interactionRows(family: string, mode: TokenMode, tokens: ThemeTokens): Array<[InteractionRow, string]>;
25
+ /**
26
+ * The register as CSS for one brand: every family's rows under the brand scope, the
27
+ * rung rows per mode, then one scope block per family that maps the family-agnostic
28
+ * names (`--solid-bg-enabled`, `--fg`) onto that family, so a component reads one set
29
+ * of names and `data-family` picks the family, the way a mode does on a Figma instance.
30
+ */
31
+ export declare function interactionCss(slug: string, tokens: ThemeTokens): string;
32
+ /** The base tokens plus the register's rows, resolved to literals per mode. */
33
+ export declare function interactionTokens(tokens: ThemeTokens): ThemeTokens;
34
+ /** every rung the register uses is one of the engine's interaction rungs */
35
+ export declare const interactionLadderIsLegal: () => boolean;
@@ -0,0 +1,51 @@
1
+ import type { GeneratedScale, NeutralLevel, ContrastProfile } from './colorEngine';
2
+ import type { ResolvedBrand, SecondaryStyle } from './resolve';
3
+ export type TokenMode = 'light' | 'dark';
4
+ export interface ThemeTokens {
5
+ slug: string;
6
+ /** every emitted name, in emission order */
7
+ names: string[];
8
+ /** resolved literal values per mode; mode-invariant names appear in both */
9
+ light: Record<string, string>;
10
+ dark: Record<string, string>;
11
+ /** names whose value does not depend on the mode */
12
+ invariant: string[];
13
+ /** the declarations as emitted, var() references intact, per block */
14
+ raw: {
15
+ light: Record<string, string>;
16
+ dark: Record<string, string>;
17
+ };
18
+ }
19
+ export interface ThemeTokensInput {
20
+ slug: string;
21
+ displayName?: string;
22
+ /** the resolved brand; for a resolved theme pass `theme.themed` */
23
+ brand: ResolvedBrand;
24
+ secondary?: GeneratedScale | null;
25
+ neutralLevel?: NeutralLevel;
26
+ contrastProfile?: ContrastProfile;
27
+ secondaryStyle?: SecondaryStyle;
28
+ ctaEscape?: boolean;
29
+ linkHex?: string | null;
30
+ ctaBorder?: boolean;
31
+ neutralH?: number;
32
+ }
33
+ /**
34
+ * Reads the engine's CSS emission into one declaration map per mode. Only the
35
+ * emitters' own grammar is understood: a line ending in `{` opens a block, a line
36
+ * that is `}` closes one, `--name: value;` lines declare. At-rule groups (the
37
+ * display-p3 re-emission) are skipped whole.
38
+ */
39
+ export declare function readEmission(css: string): {
40
+ light: Map<string, string>;
41
+ dark: Map<string, string>;
42
+ order: string[];
43
+ };
44
+ /** Resolves var() references against `own`, falling back to `shared` for names declared once. */
45
+ export declare function resolveReferences(own: Map<string, string>, shared: Map<string, string>): Record<string, string>;
46
+ /** The system rows tokens/semantic.css states as static aliases, as engine-table projections. */
47
+ export declare function systemCss(): string;
48
+ /** Builds a ThemeTokens object from an emission's declaration maps. */
49
+ export declare function tokensFromEmission(slug: string, css: string): ThemeTokens;
50
+ /** The whole page's token set for one brand: signals, system rows, and the brand block. */
51
+ export declare function themeTokens(input: ThemeTokensInput): ThemeTokens;
@@ -2,6 +2,8 @@ export { generateScale, generateNeutralScale, generateSubtleSecondary, neutralTi
2
2
  export { resolveBrand, resolveTheme, resolveLinkTrio, resolveLinkInverseTrio, DEFAULT_LINK_HEX, SIGNAL_SCALES, signalScalesFor, SECONDARY_DISTINCT_DELTA_E, SUBTLE_TINT_MULT, SUBTLE_PASTEL_K, OUTLINE_HOVER_ALPHA, type ResolvedBrand, type ResolvedTheme, type ResolvedSecondary, type SecondaryLevel, type SecondaryStyle, type SignalOverride, } from './engine/resolve';
3
3
  export { brandCss, neutralCss, signalsCss, stopsToVars, toHex, stopHex, OFFSET_ALPHAS, SHADOW_ALPHAS, SCRIM_ALPHA, OPACITY_RUNGS, INTERACTION_RUNGS, opacityLeafName, opacityVarName, opacityTokenPath, type OpacityRung, DISABLED_OPACITY, type ShadowRung, ctaNeedsBorder, ctaBorderRung, pageStopFor, } from './engine/cssRender';
4
4
  export { CSS_FAMILY } from './engine/tokenDescriptions';
5
+ export { themeTokens, tokensFromEmission, readEmission, resolveReferences, systemCss, type ThemeTokens, type ThemeTokensInput, type TokenMode, } from './engine/tokensRender';
6
+ export { interactionCss, interactionTokens, interactionRows, interactionVarName, interactionTintName, INTERACTION_FAMILIES, INTERACTION_POLE_FAMILY, INTERACTION_ROWS, INTERACTION_STATES, INTERACTION_LADDER, type InteractionRow, type InteractionState, type InteractionPoleFamily, } from './engine/interaction';
5
7
  export { themeToFigma, type FigmaGroup, type FigmaColorToken, type FigmaNumberToken, type FigmaLeaf, type ThemeInput, } from './engine/figmaRender';
6
8
  export { emitDtcgRamp, resolveDtcgRamp, parseToken, EXT_KEY, RESOLVER_ID, type DtcgRampGroup, type DtcgRequirementToken, type DtcgSeedToken, type DtcgColorValue, } from './engine/requirements/dtcg';
7
9
  export { MODE_SPECS, type ModeSpec } from './engine/requirements/spec';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okchroma",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "description": "Color token engine: one brand hex in, a full WCAG-enforced light+dark token system out (CSS variables, DTCG JSON, Figma variables)",
6
6
  "repository": {
@@ -54,7 +54,8 @@
54
54
  "name-lint": "esbuild scripts/name-lint.ts --bundle --platform=node --outfile=dist/name-lint.js && node dist/name-lint.js",
55
55
  "typecheck": "tsc --noEmit",
56
56
  "typecheck:ext": "tsc --noEmit -p plugin-ext",
57
- "docs:lint": "esbuild scripts/docs-lint.ts --bundle --platform=node --outfile=dist/docs-lint.js && node dist/docs-lint.js"
57
+ "docs:lint": "esbuild scripts/docs-lint.ts --bundle --platform=node --outfile=dist/docs-lint.js && node dist/docs-lint.js",
58
+ "audit:tokens": "esbuild scripts/tokens-audit.ts --bundle --platform=node --outfile=dist/tokens-audit.js && node dist/tokens-audit.js"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/node": "^25.9.2",