@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/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@nebutra/design-tokens",
3
- "version": "0.1.2",
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,2 @@
1
+ export declare function pathToCssVarName(token: { path: readonly string[] }): string | null;
2
+ export declare function multiThemeName(token: { path: readonly string[] }): string | null;
@@ -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
+ }