@rokkit/core 1.1.17 → 1.1.18

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.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Relative luminance (Y, 0–1) of a palette value in the given color space.
3
+ * Accepts hex (`#rrggbb`), bare RGB (`"r,g,b"` / `"r g b"`), and bare OKLCH
4
+ * (`"L C H"`). Returns null when the value can't be parsed — callers should
5
+ * treat null as "unknown" and fall back to a safe default.
6
+ *
7
+ * @param {string} value
8
+ * @param {string} [space] — 'rgb' | 'oklch' | 'hsl'; disambiguates bare channels
9
+ * @returns {number | null}
10
+ */
11
+ export function relativeLuminance(value: string, space?: string): number | null;
1
12
  /**
2
13
  * Abstract base class for color space adapters.
3
14
  */
@@ -9,8 +9,8 @@ export type ZSlot = 'z0' | 'z1' | 'z2' | 'z3' | 'z4' | 'z5' | 'z6' | 'z7' | 'z8'
9
9
  export declare const Z_SLOTS: ZSlot[];
10
10
  /**
11
11
  * Maps each named token to the palette shade index that backs it.
12
- * 'derived' indicates the token is computed from another palette (e.g., on-primary
13
- * picks the paper shade of the surface palette by default for white-on-primary).
12
+ * 'derived' indicates the token is computed (e.g., on-primary is the auto
13
+ * on-color — near-black or near-white — picked by the primary fill's luminance).
14
14
  *
15
15
  * paper: 50 (lightest surface tone, canvas)
16
16
  * paper-soft: 100 (card background)
@@ -21,7 +21,7 @@ export declare const Z_SLOTS: ZSlot[];
21
21
  * ink-soft: 500 (placeholder)
22
22
  * ink-faint: 300 (disabled)
23
23
  * primary: 500 (CTA fill)
24
- * on-primary: derived — surface.50 default (white-on-primary)
24
+ * on-primary: derived — auto on-color (near-black/white) for primary fill
25
25
  * accent / status: 500 (solid)
26
26
  * *-soft: 100 (tinted-bg companion)
27
27
  * focus-ring: 500 of accent
package/dist/theme.d.ts CHANGED
@@ -38,13 +38,20 @@ export declare function semanticShortcuts(name: any): (string[] | (RegExp | (([,
38
38
  /**
39
39
  * Generates "on-color" text shortcuts for readable text on colored backgrounds.
40
40
  *
41
- * - `text-on-{name}` → high contrast text for use on z5+ backgrounds (always light text)
42
- * - `text-on-{name}-muted` → slightly muted but still readable on z5+ backgrounds
41
+ * The on-color is whichever of near-black / near-white best contrasts with the
42
+ * role's z5 fill (the principle: a 500-shade fill takes black OR white text —
43
+ * never a mid tint, which fails AA on bright fills like vermillion). The caller
44
+ * passes the resolved on-color hex (see `Theme.#onColorHex`); we emit it as an
45
+ * arbitrary-value utility so it stays a single CSS color, opacity-composable.
43
46
  *
44
- * @param {string} name - Color name (e.g., 'primary', 'surface')
47
+ * - `text-on-{name}` → readable text on a z5+ fill
48
+ * - `text-on-{name}-muted` → same on-color (kept for back-compat callers)
49
+ *
50
+ * @param {string} name - Color name (e.g., 'primary', 'accent', 'danger')
51
+ * @param {string} [onColor='#fafafa'] - resolved on-color hex for this role
45
52
  * @returns {Array} Array of shortcut definitions
46
53
  */
47
- export declare function contrastShortcuts(name: any): (RegExp | (([, end]: [any, any]) => string))[][];
54
+ export declare function contrastShortcuts(name: any, onColor?: string): (RegExp | (([, end]: [any, any]) => string))[][];
48
55
  /**
49
56
  * Theme class for managing color palettes, mappings, and semantic shortcuts.
50
57
  */
@@ -120,8 +127,8 @@ export declare class Theme {
120
127
  * Used in extended mode where the preset emits the full --color-{role}-{shade}
121
128
  * palette; named tokens become thin syntactic aliases over the palette.
122
129
  *
123
- * `on-primary` aliases to `var(--color-surface-50)` (derived: paper of surface,
124
- * matching the core-mode resolution).
130
+ * `on-primary` resolves to the auto on-color for the primary fill (near-black
131
+ * or near-white), matching the core-mode derivation.
125
132
  */
126
133
  getZAliasesForExtended(): Record<string, string>;
127
134
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/core",
3
- "version": "1.1.17",
3
+ "version": "1.1.18",
4
4
  "description": "Contains core utility functions and classes that can be used in various components.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,6 +46,6 @@
46
46
  "ramda": "^0.32.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@rokkit/icons": "1.1.17"
49
+ "@rokkit/icons": "1.1.18"
50
50
  }
51
51
  }
@@ -42,6 +42,73 @@ function matMul(m, v) {
42
42
  return m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2])
43
43
  }
44
44
 
45
+ // Inverse OKLab matrices (for OKLCH → XYZ, to recover relative luminance Y).
46
+ const OKLAB_TO_LMS3 = [
47
+ [1, 0.3963377774, 0.2158037573],
48
+ [1, -0.1055613458, -0.0638541728],
49
+ [1, -0.0894841775, -1.291485548]
50
+ ]
51
+ const LMS_TO_XYZ = [
52
+ [1.2270138511, -0.5577999807, 0.281256149],
53
+ [-0.0405801784, 1.1122568696, -0.0716766787],
54
+ [-0.0763812845, -0.4214819784, 1.5861632204]
55
+ ]
56
+
57
+ /**
58
+ * Relative luminance (Y, 0–1 per WCAG) of an sRGB triplet.
59
+ * @param {number} r @param {number} g @param {number} b — 0–255
60
+ * @returns {number}
61
+ */
62
+ function rgbLuminance(r, g, b) {
63
+ const lin = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]
64
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
65
+ }
66
+
67
+ /**
68
+ * Relative luminance (Y) of an OKLCH color. Y is the XYZ Y component, so we
69
+ * only need OKLab → LMS → XYZ — no full sRGB round-trip.
70
+ * @param {number} L @param {number} C @param {number} H — H in degrees
71
+ * @returns {number}
72
+ */
73
+ function oklchLuminance(L, C, H) {
74
+ const hr = (H * Math.PI) / 180
75
+ const lab = [L, C * Math.cos(hr), C * Math.sin(hr)]
76
+ const lms = matMul(OKLAB_TO_LMS3, lab).map((v) => v ** 3)
77
+ return matMul(LMS_TO_XYZ, lms)[1]
78
+ }
79
+
80
+ /**
81
+ * Relative luminance (Y, 0–1) of a palette value in the given color space.
82
+ * Accepts hex (`#rrggbb`), bare RGB (`"r,g,b"` / `"r g b"`), and bare OKLCH
83
+ * (`"L C H"`). Returns null when the value can't be parsed — callers should
84
+ * treat null as "unknown" and fall back to a safe default.
85
+ *
86
+ * @param {string} value
87
+ * @param {string} [space] — 'rgb' | 'oklch' | 'hsl'; disambiguates bare channels
88
+ * @returns {number | null}
89
+ */
90
+ export function relativeLuminance(value, space = 'rgb') {
91
+ if (typeof value !== 'string') return null
92
+ const v = value.trim()
93
+ if (HEX_RE.test(v)) {
94
+ const [r, g, b] = parseHex(v)
95
+ return rgbLuminance(r, g, b)
96
+ }
97
+ if (space === 'oklch' && /^[\d.]+\s+[\d.]+\s+[\d.]+$/.test(v)) {
98
+ const [L, C, H] = v.split(/\s+/).map(Number)
99
+ return oklchLuminance(L, C, H)
100
+ }
101
+ if (/^\d+\s*,\s*\d+\s*,\s*\d+$/.test(v)) {
102
+ const [r, g, b] = v.split(',').map((s) => Number(s.trim()))
103
+ return rgbLuminance(r, g, b)
104
+ }
105
+ if (/^\d+\s+\d+\s+\d+$/.test(v)) {
106
+ const [r, g, b] = v.trim().split(/\s+/).map(Number)
107
+ return rgbLuminance(r, g, b)
108
+ }
109
+ return null
110
+ }
111
+
45
112
  // ── Hex parsing helper ──────────────────────────────────────────────────
46
113
 
47
114
  /**
@@ -27,8 +27,8 @@ export const Z_SLOTS: ZSlot[] = ['z0', 'z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7',
27
27
 
28
28
  /**
29
29
  * Maps each named token to the palette shade index that backs it.
30
- * 'derived' indicates the token is computed from another palette (e.g., on-primary
31
- * picks the paper shade of the surface palette by default for white-on-primary).
30
+ * 'derived' indicates the token is computed (e.g., on-primary is the auto
31
+ * on-color — near-black or near-white — picked by the primary fill's luminance).
32
32
  *
33
33
  * paper: 50 (lightest surface tone, canvas)
34
34
  * paper-soft: 100 (card background)
@@ -39,7 +39,7 @@ export const Z_SLOTS: ZSlot[] = ['z0', 'z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7',
39
39
  * ink-soft: 500 (placeholder)
40
40
  * ink-faint: 300 (disabled)
41
41
  * primary: 500 (CTA fill)
42
- * on-primary: derived — surface.50 default (white-on-primary)
42
+ * on-primary: derived — auto on-color (near-black/white) for primary fill
43
43
  * accent / status: 500 (solid)
44
44
  * *-soft: 100 (tinted-bg companion)
45
45
  * focus-ring: 500 of accent
package/src/theme.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // @ts-nocheck
3
3
  import { DEFAULT_THEME_MAPPING, defaultColors, TONE_MAP, INVERTED_ROLES } from './constants'
4
4
  import { shades } from './colors/index'
5
- import { ColorSpace } from './color-space'
5
+ import { ColorSpace, relativeLuminance } from './color-space'
6
6
  import {
7
7
  NAMED_TOKENS,
8
8
  NAMED_TOKEN_SHADE_MAP,
@@ -133,25 +133,42 @@ export function semanticShortcuts(name) {
133
133
  /**
134
134
  * Generates "on-color" text shortcuts for readable text on colored backgrounds.
135
135
  *
136
- * - `text-on-{name}` → high contrast text for use on z5+ backgrounds (always light text)
137
- * - `text-on-{name}-muted` → slightly muted but still readable on z5+ backgrounds
136
+ * The on-color is whichever of near-black / near-white best contrasts with the
137
+ * role's z5 fill (the principle: a 500-shade fill takes black OR white text —
138
+ * never a mid tint, which fails AA on bright fills like vermillion). The caller
139
+ * passes the resolved on-color hex (see `Theme.#onColorHex`); we emit it as an
140
+ * arbitrary-value utility so it stays a single CSS color, opacity-composable.
138
141
  *
139
- * @param {string} name - Color name (e.g., 'primary', 'surface')
142
+ * - `text-on-{name}` → readable text on a z5+ fill
143
+ * - `text-on-{name}-muted` → same on-color (kept for back-compat callers)
144
+ *
145
+ * @param {string} name - Color name (e.g., 'primary', 'accent', 'danger')
146
+ * @param {string} [onColor='#fafafa'] - resolved on-color hex for this role
140
147
  * @returns {Array} Array of shortcut definitions
141
148
  */
142
- export function contrastShortcuts(name) {
149
+ export function contrastShortcuts(name, onColor = '#fafafa') {
143
150
  return [
144
- [
145
- new RegExp(`^text-on-${name}(\\/\\d+)?$`),
146
- ([, end]) => `text-${name}-50${end || ''} dark:text-${name}-50${end || ''}`
147
- ],
148
- [
149
- new RegExp(`^text-on-${name}-muted(\\/\\d+)?$`),
150
- ([, end]) => `text-${name}-100${end || ''} dark:text-${name}-200${end || ''}`
151
- ]
151
+ [new RegExp(`^text-on-${name}(\\/\\d+)?$`), ([, end]) => `text-[${onColor}]${end || ''}`],
152
+ [new RegExp(`^text-on-${name}-muted(\\/\\d+)?$`), ([, end]) => `text-[${onColor}]${end || ''}`]
152
153
  ]
153
154
  }
154
155
 
156
+ /**
157
+ * Near-black / near-white on-color endpoints. Not pure #000/#fff — a hair of
158
+ * lift reads softer on a saturated fill while still clearing AA (near-black is
159
+ * dark enough that even a mid-bright vermillion 500 reaches ~5:1).
160
+ */
161
+ const ON_COLOR_DARK = '#161616'
162
+ const ON_COLOR_LIGHT = '#fafafa'
163
+ /**
164
+ * Fill luminance where ON_COLOR_DARK and ON_COLOR_LIGHT give equal WCAG contrast:
165
+ * solve (Yf+0.05)² = (Y_dark+0.05)(Y_light+0.05) with Y_dark≈0.0074, Y_light≈0.956
166
+ * → Yf ≈ 0.190. Fills at/above this take dark text; below take light text. (Truly
167
+ * mid-luminance fills near this point clear neither pair at AA — that's the
168
+ * palette author's choice to make, per "pick a 500 where black or white works".)
169
+ */
170
+ const ON_COLOR_Y_CROSSOVER = 0.19
171
+
155
172
  /**
156
173
  * Fallback chain for nullable color mappings.
157
174
  * If a semantic color is null, it inherits from another semantic color.
@@ -306,23 +323,32 @@ export class Theme {
306
323
  }
307
324
 
308
325
  /**
309
- * Resolves a 'derived' named token. Today only `on-primary` is derived — it picks
310
- * shade 50 from the surface palette for the default white-on-primary contrast pair.
326
+ * Resolves a 'derived' named token. Today only `on-primary` is derived — it's
327
+ * the auto on-color (near-black or near-white) for the primary fill, so the
328
+ * default pair always clears AA whether primary-500 is bright (→ black) or
329
+ * dark (→ white). Skins still override `on-primary` for a bespoke pair.
311
330
  */
312
- #resolveDerivedToken(name: string, colors: Record<string, Record<string, string>>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
331
+ #resolveDerivedToken(name: string, colors: Record<string, Record<string, string>>, _perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
313
332
  if (name === 'on-primary') {
314
- const surfacePaletteName = this.#mapping['surface']
315
- const surfacePalette = colors[surfacePaletteName]
316
- if (!surfacePalette?.['50']) return undefined
317
- const surfaceMode = perRoleModes?.['surface'] ?? 'core'
318
- if (surfaceMode === 'extended') {
319
- return `var(--color-surface-50)`
320
- }
321
- return this.#adapter.wrap(surfacePalette['50'])
333
+ return this.#adapter.wrap(this.#onColorHex('primary', colors))
322
334
  }
323
335
  return undefined
324
336
  }
325
337
 
338
+ /**
339
+ * Picks the readable on-color (near-black / near-white hex) for a role's z5
340
+ * fill by its relative luminance. Bright fills (vermillion, teal, amber) take
341
+ * dark text; dark fills (violet, indigo) take light text. Falls back to light
342
+ * when the fill can't be measured (matches the historical default).
343
+ */
344
+ #onColorHex(role: string, colors: Record<string, Record<string, string>>): string {
345
+ const palette = colors[this.#mapping[role]]
346
+ const fill = palette?.['500']
347
+ const y = fill !== undefined ? relativeLuminance(fill, this.#adapter.name) : null
348
+ if (y === null) return ON_COLOR_LIGHT
349
+ return y >= ON_COLOR_Y_CROSSOVER ? ON_COLOR_DARK : ON_COLOR_LIGHT
350
+ }
351
+
326
352
  /**
327
353
  * Generate z-aliases for surface or ink role using the provided map.
328
354
  */
@@ -387,17 +413,18 @@ export class Theme {
387
413
  * Used in extended mode where the preset emits the full --color-{role}-{shade}
388
414
  * palette; named tokens become thin syntactic aliases over the palette.
389
415
  *
390
- * `on-primary` aliases to `var(--color-surface-50)` (derived: paper of surface,
391
- * matching the core-mode resolution).
416
+ * `on-primary` resolves to the auto on-color for the primary fill (near-black
417
+ * or near-white), matching the core-mode derivation.
392
418
  */
393
419
  getZAliasesForExtended(): Record<string, string> {
420
+ const colors = { ...defaultColors, ...this.#colors }
394
421
  const result: Record<string, string> = {}
395
422
  for (const name of NAMED_TOKENS) {
396
423
  const role = NAMED_TOKEN_ROLE_MAP[name]
397
424
  const shadeOrDerived = NAMED_TOKEN_SHADE_MAP[name]
398
425
  if (shadeOrDerived === 'derived') {
399
- // on-primary → surface.50
400
- result[`--${name}`] = `var(--color-surface-50)`
426
+ // on-primary → auto on-color (black/white) for the primary fill
427
+ result[`--${name}`] = this.#adapter.wrap(this.#onColorHex('primary', colors))
401
428
  continue
402
429
  }
403
430
  result[`--${name}`] = `var(--color-${role}-${shadeOrDerived})`
@@ -438,7 +465,8 @@ export class Theme {
438
465
  }
439
466
 
440
467
  getShortcuts(name) {
441
- return [...semanticShortcuts(name), ...contrastShortcuts(name)]
468
+ const colors = { ...defaultColors, ...this.#colors }
469
+ return [...semanticShortcuts(name), ...contrastShortcuts(name, this.#onColorHex(name, colors))]
442
470
  }
443
471
 
444
472
  /**