@nebutra/design-tokens 0.1.2 → 2.0.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 +1 -1
- package/build/css/dark.css +65 -49
- package/build/css/light.css +62 -46
- package/build/css/styles.generated.css +477 -115
- package/build/css/theme-inline.css +86 -15
- package/build/tailwind/dark.preset.cjs +64 -36
- package/build/tailwind/light.preset.cjs +62 -34
- package/build/ts/dark.d.ts +79 -9
- package/build/ts/dark.js +75 -49
- package/build/ts/light.d.ts +76 -12
- package/build/ts/light.js +72 -46
- package/package.json +18 -1
- package/scripts/css-var-name.d.mts +2 -0
- package/scripts/css-var-name.mjs +98 -0
- package/scripts/derive-border-tier.d.mts +36 -0
- package/scripts/derive-border-tier.mjs +346 -0
- package/style-dictionary.config.mjs +175 -89
- package/tokens/core.json +33 -18
- package/tokens/semantic.json +0 -12
- package/tokens/themes/dark.json +594 -109
- package/tokens/themes/light.json +535 -103
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/design-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "W3C DTCG ($value/$type) tokens — SSOT, Style Dictionary 4 pipeline. Coexists with @nebutra/tokens; not a replacement.",
|
|
5
5
|
"private": false,
|
|
6
|
+
"nebutra": {
|
|
7
|
+
"status": "foundation",
|
|
8
|
+
"graph": "core",
|
|
9
|
+
"productionReady": false
|
|
10
|
+
},
|
|
6
11
|
"license": "MIT",
|
|
7
12
|
"type": "module",
|
|
8
13
|
"main": "./build/ts/index.js",
|
|
@@ -16,6 +21,14 @@
|
|
|
16
21
|
"./tokens/semantic.json": "./tokens/semantic.json",
|
|
17
22
|
"./tokens/themes/light.json": "./tokens/themes/light.json",
|
|
18
23
|
"./tokens/themes/dark.json": "./tokens/themes/dark.json",
|
|
24
|
+
"./scripts/css-var-name.mjs": {
|
|
25
|
+
"types": "./scripts/css-var-name.d.mts",
|
|
26
|
+
"default": "./scripts/css-var-name.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./scripts/derive-border-tier.mjs": {
|
|
29
|
+
"types": "./scripts/derive-border-tier.d.mts",
|
|
30
|
+
"default": "./scripts/derive-border-tier.mjs"
|
|
31
|
+
},
|
|
19
32
|
"./themes": {
|
|
20
33
|
"types": "./themes.ts",
|
|
21
34
|
"import": "./themes.ts"
|
|
@@ -25,6 +38,10 @@
|
|
|
25
38
|
"files": [
|
|
26
39
|
"tokens",
|
|
27
40
|
"build",
|
|
41
|
+
"scripts/css-var-name.mjs",
|
|
42
|
+
"scripts/css-var-name.d.mts",
|
|
43
|
+
"scripts/derive-border-tier.mjs",
|
|
44
|
+
"scripts/derive-border-tier.d.mts",
|
|
28
45
|
"themes.ts",
|
|
29
46
|
"style-dictionary.config.mjs"
|
|
30
47
|
],
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* css-var-name.mjs
|
|
3
|
+
*
|
|
4
|
+
* The DTCG-path → CSS-custom-property namer, extracted so it has exactly ONE
|
|
5
|
+
* definition in the repository.
|
|
6
|
+
*
|
|
7
|
+
* `style-dictionary.config.mjs` uses it to emit `build/css/*.css` (which is
|
|
8
|
+
* checked against the runtime SSOT `@nebutra/tokens/styles.css` by
|
|
9
|
+
* `verify:parity`). The design site (`apps/design`) uses the same function to
|
|
10
|
+
* label every token it renders with the variable a consumer actually writes.
|
|
11
|
+
*
|
|
12
|
+
* It lives here rather than in the config because a second copy in the site
|
|
13
|
+
* would be a second source of truth for token names — the drift the site exists
|
|
14
|
+
* to make impossible. A token renamed in the pipeline must rename on the page
|
|
15
|
+
* in the same commit, with no edit to the site.
|
|
16
|
+
*
|
|
17
|
+
* Returning `null` means "this token does not become a CSS variable": either it
|
|
18
|
+
* is a build-time-only primitive (`color.white`) or it is emitted by a
|
|
19
|
+
* post-processing step under a different name (`transition.default`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Maps a DTCG path to the CSS variable name the pipeline emits, without the
|
|
24
|
+
* leading `--`.
|
|
25
|
+
*
|
|
26
|
+
* @param {{ path: string[] }} token a Style Dictionary token, or any `{ path }`
|
|
27
|
+
* @returns {string | null} variable name without `--`, or null if not emitted
|
|
28
|
+
*/
|
|
29
|
+
export function pathToCssVarName(token) {
|
|
30
|
+
const path = token.path;
|
|
31
|
+
const [head, ...rest] = path;
|
|
32
|
+
|
|
33
|
+
// Drop primitive `color.` prefix → keep child name.
|
|
34
|
+
if (head === "color") {
|
|
35
|
+
if (rest[0] === "white" || rest[0] === "black") return null;
|
|
36
|
+
if (rest[0] === "tertiary-purple") return "brand-tertiary";
|
|
37
|
+
return rest.join("-");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (head === "size") return rest.join("-");
|
|
41
|
+
if (head === "duration") return `duration-${rest.join("-")}`;
|
|
42
|
+
if (head === "easing") return `ease-${rest.join("-")}`;
|
|
43
|
+
if (head === "fontFamily") return `font-${rest.join("-")}`;
|
|
44
|
+
|
|
45
|
+
if (head === "brand") {
|
|
46
|
+
if (rest[0] === "gradient") {
|
|
47
|
+
const sub = rest.slice(1).join("-");
|
|
48
|
+
return sub === "primary" ? "brand-gradient" : `brand-gradient-${sub}`;
|
|
49
|
+
}
|
|
50
|
+
return `brand-${rest.join("-")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (head === "status") return `status-${rest.join("-")}`;
|
|
54
|
+
|
|
55
|
+
if (head === "container") return `container-${rest.join("-")}`;
|
|
56
|
+
if (head === "radius") {
|
|
57
|
+
return rest[0] === "default" ? "radius" : `radius-${rest.join("-")}`;
|
|
58
|
+
}
|
|
59
|
+
if (head === "transition") {
|
|
60
|
+
if (rest[0] === "shorthand") return "transition";
|
|
61
|
+
if (rest[0] === "default") return null; // composite — emitted via post-process
|
|
62
|
+
return `transition-${rest.join("-")}`;
|
|
63
|
+
}
|
|
64
|
+
if (head === "focusRing") {
|
|
65
|
+
if (rest[0] === "default") return "focus-ring";
|
|
66
|
+
return `focus-ring-${rest.join("-")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (head === "scale") return rest.join("-");
|
|
70
|
+
if (head === "shadcn") return rest.join("-");
|
|
71
|
+
if (head === "ds") return `ds-${rest.join("-")}`;
|
|
72
|
+
if (head === "elevation") return `elevation-${rest.join("-")}`;
|
|
73
|
+
|
|
74
|
+
if (head === "theme") return null;
|
|
75
|
+
|
|
76
|
+
if (head === "shadow") return `shadow-${rest.join("-")}`;
|
|
77
|
+
|
|
78
|
+
return path.join("-");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* CSS variable namer for theme token trees scoped to a `[data-theme]` selector.
|
|
83
|
+
* Emits Tailwind v4 `@theme`-compatible names: color.primary → --color-primary.
|
|
84
|
+
*
|
|
85
|
+
* @param {{ path: string[] }} token
|
|
86
|
+
* @returns {string | null}
|
|
87
|
+
*/
|
|
88
|
+
export function multiThemeName(token) {
|
|
89
|
+
const path = token.path;
|
|
90
|
+
const [head, ...rest] = path;
|
|
91
|
+
if (head === "theme") return null;
|
|
92
|
+
if (head === "color") return `color-${rest.join("-")}`;
|
|
93
|
+
if (head === "radius") return `radius-${rest.join("-")}`;
|
|
94
|
+
if (head === "fontFamily") return `font-${rest.join("-")}`;
|
|
95
|
+
if (head === "shadow") return `shadow-${rest.join("-")}`;
|
|
96
|
+
if (head === "transition") return `transition-${rest.join("-")}`;
|
|
97
|
+
return path.join("-");
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Oklab {
|
|
2
|
+
L: number;
|
|
3
|
+
a: number;
|
|
4
|
+
b: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export declare function hexToRgb(hex: string): [number, number, number] | null;
|
|
8
|
+
export declare function rgbToHex(rgb: readonly [number, number, number]): string;
|
|
9
|
+
export declare function hexToOklab(hex: string): Oklab;
|
|
10
|
+
export declare function oklabToHex(lab: Oklab): string;
|
|
11
|
+
export declare function oklabL(hex: string): number;
|
|
12
|
+
export declare function relativeLuminance(hex: string): number;
|
|
13
|
+
export declare function contrastRatio(hexA: string, hexB: string): number;
|
|
14
|
+
|
|
15
|
+
export declare const BORDER_TIER_T: Record<string, number>;
|
|
16
|
+
export declare const SOLID_ANCHOR_MIN_SPAN: number;
|
|
17
|
+
export declare const LADDER_MIN_JND: number;
|
|
18
|
+
|
|
19
|
+
export interface BorderTierDerivation {
|
|
20
|
+
name: string;
|
|
21
|
+
hex: string;
|
|
22
|
+
meta: {
|
|
23
|
+
from: string;
|
|
24
|
+
anchorStep: string;
|
|
25
|
+
anchor: string;
|
|
26
|
+
t: number;
|
|
27
|
+
bright: boolean;
|
|
28
|
+
achromatic: boolean;
|
|
29
|
+
L: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Mutates the passed merged DTCG tree in place; returns the derivation log. */
|
|
34
|
+
export declare function deriveBorderTier(tokens: unknown): BorderTierDerivation[];
|
|
35
|
+
|
|
36
|
+
export declare function assertLadder(scaleName: string, values: Record<string, string>): string[];
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* derive-border-tier.mjs
|
|
3
|
+
*
|
|
4
|
+
* Generates functional scale steps 6/7/8 (the BORDER tier) of every 12-step
|
|
5
|
+
* scale, instead of aliasing them to the same primitives as steps 3/4/5 (the
|
|
6
|
+
* component-BACKGROUND tier).
|
|
7
|
+
*
|
|
8
|
+
* ── Why this exists ──────────────────────────────────────────────────────────
|
|
9
|
+
* tokens/core.json ships 11-stop primitive palettes (50…950). The functional
|
|
10
|
+
* layer needs 12 steps. The original author closed the 1-step shortfall by
|
|
11
|
+
* pointing 6/7/8 at the same primitives as 3/4/5, which made the border tier
|
|
12
|
+
* pixel-identical to the background tier. "Separate with a tonal shift instead
|
|
13
|
+
* of a border" and "draw a border" then rendered the same colour, so neither
|
|
14
|
+
* could be expressed.
|
|
15
|
+
*
|
|
16
|
+
* Adding hand-picked intermediate primitives to core.json would not survive:
|
|
17
|
+
* `scripts/brand-apply.ts` rewrites color.nebutra-{blue,cyan,neutral} wholesale
|
|
18
|
+
* from DEFAULT_BRAND.colors on every `brand:apply`, over a fixed 11-stop step
|
|
19
|
+
* list. Any extra stop would be silently dropped and the duplication would come
|
|
20
|
+
* back. So the tier is DERIVED at build time and is correct by construction for
|
|
21
|
+
* any brand palette, before and after a rebrand.
|
|
22
|
+
*
|
|
23
|
+
* ── The rule ─────────────────────────────────────────────────────────────────
|
|
24
|
+
* Steps 6/7/8 are placed at t = 0.25, 0.50, 0.75 of an OKLab interpolation from
|
|
25
|
+
* step 5 (background-active) to a tier anchor A. Equal quarters means steps
|
|
26
|
+
* 5→6→7→8→A form a uniform perceptual ladder: every adjacent pair is separated
|
|
27
|
+
* by the same ΔL, so no pair can collapse and the ladder cannot invert.
|
|
28
|
+
*
|
|
29
|
+
* The anchor A is step 9 (the solid fill) — the tier below the border tier —
|
|
30
|
+
* whenever the scale actually descends (light) or ascends (dark) into it:
|
|
31
|
+
*
|
|
32
|
+
* |L(9) − L(5)| >= 0.06 → A = step 9 ("solid-anchored")
|
|
33
|
+
* otherwise → A = step 10 ("bright-scale fallback")
|
|
34
|
+
*
|
|
35
|
+
* The 0.06 threshold is ~4× the ~0.015 OKLab-L just-noticeable difference for
|
|
36
|
+
* large flat areas: it is the smallest span that can hold three visibly
|
|
37
|
+
* separated interior steps. Bright scales (Radix calls these "bright"; yellow,
|
|
38
|
+
* lime, mint, and our cyan) have a step 9 whose lightness is barely below step
|
|
39
|
+
* 5 — the accent is a luminous solid, not the dark end of the ramp. Light cyan:
|
|
40
|
+
* L(5)=0.8709, L(9)=0.8541, a span of 0.0168, ~1 JND for the whole tier. There
|
|
41
|
+
* is no room to place three separated steps inside it, so the tier continues
|
|
42
|
+
* along the ramp toward step 10 instead.
|
|
43
|
+
*
|
|
44
|
+
* BE EXPLICIT ABOUT WHAT THIS COSTS. Steps 6/7/8 then land BELOW step 9
|
|
45
|
+
* (light cyan: 0.8388 / 0.8086 / 0.7759 against L(9)=0.8541), so the 1…12
|
|
46
|
+
* ladder gains an upward step at 8→9 of +0.078 that was not there before —
|
|
47
|
+
* previously the collapse sat at 5→6 (+0.048) instead. The break is moved and
|
|
48
|
+
* enlarged, not removed. It cannot be removed while step 5 and step 9 are both
|
|
49
|
+
* fixed: any tier that fits strictly between them is sub-JND and re-collapses.
|
|
50
|
+
* Step 9 sitting off the ramp is the standard shape for a bright accent scale,
|
|
51
|
+
* but on this palette it is a consequence of the derivation, not a pre-existing
|
|
52
|
+
* property, and the ladder assertion below exempts step 9 for exactly that
|
|
53
|
+
* reason. The honest summary is: five of six scales are strictly monotonic over
|
|
54
|
+
* 1…12; light cyan is monotonic over 1…8 and over 9…12, with step 9 off-ramp.
|
|
55
|
+
*
|
|
56
|
+
* ── Chroma policy ────────────────────────────────────────────────────────────
|
|
57
|
+
* The derived step keeps the hue and chroma produced by the interpolation, so
|
|
58
|
+
* the border tier stays in the same hue family as the background tier it sits
|
|
59
|
+
* on — EXCEPT where the theme opts out with `chroma: 0`.
|
|
60
|
+
*
|
|
61
|
+
* The dark neutral border tier opts out. core.json documents `nebutra-gray`
|
|
62
|
+
* as "zero hue, zero saturation … used where blue undertone clashes — primarily
|
|
63
|
+
* dark-mode borders, dividers, and elevation hairlines". That intent is
|
|
64
|
+
* deliberate and is preserved: dark --neutral-6/7/8 stay achromatic. What was
|
|
65
|
+
* broken there was not the hue, it was the lightness — the greys were pulled
|
|
66
|
+
* from nebutra-gray-900/800/700, which are DARKER than the slate background tier
|
|
67
|
+
* they must sit above, so the "border" was a hole rather than a hairline. The
|
|
68
|
+
* derivation keeps chroma 0 and fixes L.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
// ── OKLab ⇄ sRGB ────────────────────────────────────────────────────────────
|
|
72
|
+
// Björn Ottosson's OKLab (https://bottosson.github.io/posts/oklab/). Same matrices
|
|
73
|
+
// as hexToOklch() in style-dictionary.config.mjs, plus the inverse.
|
|
74
|
+
|
|
75
|
+
const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
76
|
+
const linearToSrgb = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
77
|
+
|
|
78
|
+
/** "#rrggbb" → [r, g, b] in 0…1 sRGB. */
|
|
79
|
+
export function hexToRgb(hex) {
|
|
80
|
+
const match = /^#?([0-9a-f]{6})$/iu.exec(String(hex).trim());
|
|
81
|
+
if (!match) return null;
|
|
82
|
+
const v = match[1];
|
|
83
|
+
return [
|
|
84
|
+
Number.parseInt(v.slice(0, 2), 16) / 255,
|
|
85
|
+
Number.parseInt(v.slice(2, 4), 16) / 255,
|
|
86
|
+
Number.parseInt(v.slice(4, 6), 16) / 255,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
91
|
+
|
|
92
|
+
export function rgbToHex([r, g, b]) {
|
|
93
|
+
const channel = (c) =>
|
|
94
|
+
Math.round(clamp01(c) * 255)
|
|
95
|
+
.toString(16)
|
|
96
|
+
.padStart(2, "0");
|
|
97
|
+
return `#${channel(r)}${channel(g)}${channel(b)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "#rrggbb" → { L, a, b } in OKLab. */
|
|
101
|
+
export function hexToOklab(hex) {
|
|
102
|
+
const rgb = hexToRgb(hex);
|
|
103
|
+
if (!rgb) throw new Error(`derive-border-tier: not a #rrggbb hex: ${hex}`);
|
|
104
|
+
const [r, g, b] = rgb.map(srgbToLinear);
|
|
105
|
+
|
|
106
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
107
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
108
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
109
|
+
|
|
110
|
+
const l_ = Math.cbrt(l);
|
|
111
|
+
const m_ = Math.cbrt(m);
|
|
112
|
+
const s_ = Math.cbrt(s);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
L: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
116
|
+
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
117
|
+
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** { L, a, b } in OKLab → "#rrggbb" (gamut-clipped by channel clamp). */
|
|
122
|
+
export function oklabToHex({ L, a, b }) {
|
|
123
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
124
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
125
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
126
|
+
|
|
127
|
+
const l = l_ ** 3;
|
|
128
|
+
const m = m_ ** 3;
|
|
129
|
+
const s = s_ ** 3;
|
|
130
|
+
|
|
131
|
+
return rgbToHex(
|
|
132
|
+
[
|
|
133
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
134
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
135
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
136
|
+
].map(linearToSrgb),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** OKLab lightness of a hex, for reporting and for the ladder assertion. */
|
|
141
|
+
export const oklabL = (hex) => hexToOklab(hex).L;
|
|
142
|
+
|
|
143
|
+
/** WCAG 2.x relative luminance + contrast ratio, for reporting. */
|
|
144
|
+
export function relativeLuminance(hex) {
|
|
145
|
+
const [r, g, b] = hexToRgb(hex).map(srgbToLinear);
|
|
146
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function contrastRatio(hexA, hexB) {
|
|
150
|
+
const a = relativeLuminance(hexA);
|
|
151
|
+
const b = relativeLuminance(hexB);
|
|
152
|
+
const [hi, lo] = a > b ? [a, b] : [b, a];
|
|
153
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Derivation ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Interpolation positions for steps 6, 7, 8 across the 5 → anchor span. */
|
|
159
|
+
export const BORDER_TIER_T = { 6: 0.25, 7: 0.5, 8: 0.75 };
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Minimum |ΔL| between step 5 and step 9 for step 9 to be usable as the anchor.
|
|
163
|
+
* ~4× the large-area OKLab-L JND (≈0.015): the smallest span that can hold
|
|
164
|
+
* three visibly separated interior steps.
|
|
165
|
+
*/
|
|
166
|
+
export const SOLID_ANCHOR_MIN_SPAN = 0.06;
|
|
167
|
+
|
|
168
|
+
const REF_RE = /^\{([^}]+)\}$/u;
|
|
169
|
+
|
|
170
|
+
/** Follow `{a.b.c}` references through the merged DTCG tree to a literal value. */
|
|
171
|
+
function resolveValue(tokens, value, seen = new Set()) {
|
|
172
|
+
let current = value;
|
|
173
|
+
while (typeof current === "string" && REF_RE.test(current)) {
|
|
174
|
+
const pathKey = REF_RE.exec(current)[1];
|
|
175
|
+
if (seen.has(pathKey)) {
|
|
176
|
+
throw new Error(`derive-border-tier: circular reference at {${pathKey}}`);
|
|
177
|
+
}
|
|
178
|
+
seen.add(pathKey);
|
|
179
|
+
let node = tokens;
|
|
180
|
+
for (const segment of pathKey.split(".")) {
|
|
181
|
+
node = node?.[segment];
|
|
182
|
+
if (node === undefined) {
|
|
183
|
+
throw new Error(`derive-border-tier: unresolvable reference {${pathKey}}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
current = node.$value ?? node.value;
|
|
187
|
+
}
|
|
188
|
+
return current;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Replace the `$value` of every scale step carrying
|
|
193
|
+
* `$extensions["com.nebutra.derive"]` with a computed OKLab value.
|
|
194
|
+
*
|
|
195
|
+
* Mutation is confined to the freshly-parsed token object Style Dictionary
|
|
196
|
+
* hands us; nothing on disk is touched.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} tokens merged DTCG tree (core + semantic + one theme)
|
|
199
|
+
* @returns {Array<{name: string, hex: string, meta: object}>} derivation log
|
|
200
|
+
*/
|
|
201
|
+
export function deriveBorderTier(tokens) {
|
|
202
|
+
const log = [];
|
|
203
|
+
const scales = tokens?.scale;
|
|
204
|
+
if (!scales) return log;
|
|
205
|
+
|
|
206
|
+
for (const [scaleName, scale] of Object.entries(scales)) {
|
|
207
|
+
if (scaleName.startsWith("$")) continue;
|
|
208
|
+
|
|
209
|
+
const literal = (step) => {
|
|
210
|
+
const raw = scale?.[step]?.$value;
|
|
211
|
+
if (raw === undefined) {
|
|
212
|
+
throw new Error(`derive-border-tier: scale.${scaleName} has no step ${step}`);
|
|
213
|
+
}
|
|
214
|
+
return resolveValue(tokens, raw);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const derivedSteps = Object.keys(BORDER_TIER_T).filter(
|
|
218
|
+
(step) => scale?.[step]?.$extensions?.["com.nebutra.derive"] !== undefined,
|
|
219
|
+
);
|
|
220
|
+
if (derivedSteps.length === 0) continue;
|
|
221
|
+
|
|
222
|
+
const from = literal("5");
|
|
223
|
+
const solid = literal("9");
|
|
224
|
+
const span = Math.abs(oklabL(solid) - oklabL(from));
|
|
225
|
+
const bright = span < SOLID_ANCHOR_MIN_SPAN;
|
|
226
|
+
const anchorStep = bright ? "10" : "9";
|
|
227
|
+
const anchor = literal(anchorStep);
|
|
228
|
+
|
|
229
|
+
const fromLab = hexToOklab(from);
|
|
230
|
+
const anchorLab = hexToOklab(anchor);
|
|
231
|
+
|
|
232
|
+
for (const step of derivedSteps) {
|
|
233
|
+
const options = scale[step].$extensions["com.nebutra.derive"];
|
|
234
|
+
const t = BORDER_TIER_T[step];
|
|
235
|
+
const achromatic = options?.chroma === 0;
|
|
236
|
+
|
|
237
|
+
const mixed = {
|
|
238
|
+
L: fromLab.L + t * (anchorLab.L - fromLab.L),
|
|
239
|
+
a: achromatic ? 0 : fromLab.a + t * (anchorLab.a - fromLab.a),
|
|
240
|
+
b: achromatic ? 0 : fromLab.b + t * (anchorLab.b - fromLab.b),
|
|
241
|
+
};
|
|
242
|
+
const hex = oklabToHex(mixed);
|
|
243
|
+
|
|
244
|
+
scale[step].$value = hex;
|
|
245
|
+
log.push({
|
|
246
|
+
name: `${scaleName}-${step}`,
|
|
247
|
+
hex,
|
|
248
|
+
meta: {
|
|
249
|
+
from,
|
|
250
|
+
anchorStep,
|
|
251
|
+
anchor,
|
|
252
|
+
t,
|
|
253
|
+
bright,
|
|
254
|
+
achromatic,
|
|
255
|
+
L: mixed.L,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return log;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build-time invariant. Runs on the resolved literal values of a finished
|
|
266
|
+
* scale and fails the build if the defect this module exists to fix has
|
|
267
|
+
* reappeared — e.g. if the preprocessor stopped being registered and the
|
|
268
|
+
* placeholder `$value` aliases leaked through.
|
|
269
|
+
*
|
|
270
|
+
* Checks:
|
|
271
|
+
* 1. no two of steps 1…12 share a value;
|
|
272
|
+
* 2. steps 1→8 (the ramp: app/component backgrounds + the border tier) are
|
|
273
|
+
* strictly monotonic in OKLab L;
|
|
274
|
+
* 3. steps 9→12 (the solid + text tiers) are strictly monotonic in the same
|
|
275
|
+
* direction;
|
|
276
|
+
* 4. every adjacent pair inside 5…8 is separated by at least one JND, so the
|
|
277
|
+
* border tier is visibly off the background tier (5↔6) and its own three
|
|
278
|
+
* states stay distinguishable (6↔7, 7↔8).
|
|
279
|
+
*
|
|
280
|
+
* Step 9 is checked against 10/11/12 but NOT against step 8: on a bright scale
|
|
281
|
+
* the solid accent legitimately sits off the ramp (see the header note on light
|
|
282
|
+
* cyan). Segments 1…8 and 9…12 are each verified; the 8→9 seam between them is
|
|
283
|
+
* deliberately not, and that exemption is the one place this invariant is
|
|
284
|
+
* weaker than "monotonic over 1…12".
|
|
285
|
+
*/
|
|
286
|
+
export const LADDER_MIN_JND = 0.015;
|
|
287
|
+
|
|
288
|
+
function checkMonotonic(problems, scaleName, label, values, stepNames, direction) {
|
|
289
|
+
const present = stepNames.filter((s) => values[s]);
|
|
290
|
+
for (let i = 1; i < present.length; i += 1) {
|
|
291
|
+
const previous = oklabL(values[present[i - 1]]);
|
|
292
|
+
const current = oklabL(values[present[i]]);
|
|
293
|
+
if (Math.sign(current - previous) !== direction) {
|
|
294
|
+
problems.push(
|
|
295
|
+
`${scaleName} ${label} is not monotonic at step ${present[i]}: ` +
|
|
296
|
+
`L(${present[i - 1]})=${previous.toFixed(4)} → L(${present[i]})=${current.toFixed(4)}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function assertLadder(scaleName, values) {
|
|
303
|
+
const problems = [];
|
|
304
|
+
const steps = Object.keys(values).sort((a, b) => Number(a) - Number(b));
|
|
305
|
+
|
|
306
|
+
const seen = new Map();
|
|
307
|
+
for (const step of steps) {
|
|
308
|
+
const hex = values[step].toLowerCase();
|
|
309
|
+
if (seen.has(hex)) {
|
|
310
|
+
problems.push(`${scaleName}-${step} duplicates ${scaleName}-${seen.get(hex)} (${hex})`);
|
|
311
|
+
} else {
|
|
312
|
+
seen.set(hex, step);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const ramp = ["1", "2", "3", "4", "5", "6", "7", "8"].filter((s) => values[s]);
|
|
317
|
+
if (ramp.length < 2) return problems;
|
|
318
|
+
|
|
319
|
+
const direction = Math.sign(oklabL(values[ramp.at(-1)]) - oklabL(values[ramp[0]]));
|
|
320
|
+
checkMonotonic(problems, scaleName, "ramp", values, ramp, direction);
|
|
321
|
+
checkMonotonic(
|
|
322
|
+
problems,
|
|
323
|
+
scaleName,
|
|
324
|
+
"solid/text tier",
|
|
325
|
+
values,
|
|
326
|
+
["9", "10", "11", "12"],
|
|
327
|
+
direction,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
for (const [a, b] of [
|
|
331
|
+
["5", "6"],
|
|
332
|
+
["6", "7"],
|
|
333
|
+
["7", "8"],
|
|
334
|
+
]) {
|
|
335
|
+
if (!(values[a] && values[b])) continue;
|
|
336
|
+
const gap = Math.abs(oklabL(values[b]) - oklabL(values[a]));
|
|
337
|
+
if (gap < LADDER_MIN_JND) {
|
|
338
|
+
problems.push(
|
|
339
|
+
`${scaleName} steps ${a}↔${b} are only ${gap.toFixed(4)} apart in OKLab L ` +
|
|
340
|
+
`(minimum ${LADDER_MIN_JND}); the border tier is not visibly separated`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return problems;
|
|
346
|
+
}
|