@rokkit/core 1.8.0 → 1.8.1

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/src/oklch.js ADDED
@@ -0,0 +1,124 @@
1
+ /** Colour conversion: hex ↔ `r,g,b` and hex ↔ OKLCH (CSS Color Level 4, via XYZ D65). */
2
+
3
+ /**
4
+ * convert hex string to `{r},{g},{b}`
5
+ * @param {string} hex
6
+ * @return {string}
7
+ */
8
+ export function hex2rgb(hex) {
9
+ const [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16))
10
+ return `${r},${g},${b}`
11
+ }
12
+
13
+ /**
14
+ * Convert sRGB component (0-255) to linear RGB.
15
+ * @param {number} c - sRGB value 0-255
16
+ * @returns {number}
17
+ */
18
+ function srgbToLinear(c) {
19
+ const s = c / 255
20
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
21
+ }
22
+
23
+ /**
24
+ * Convert linear RGB to sRGB component (0-255).
25
+ * @param {number} c - linear value 0-1
26
+ * @returns {number}
27
+ */
28
+ function linearToSrgb(c) {
29
+ const s = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
30
+ return Math.round(Math.max(0, Math.min(255, s * 255)))
31
+ }
32
+
33
+ // CSS Color Level 4 two-step matrices (via XYZ D65) for round-trip fidelity.
34
+ // Linear sRGB → XYZ D65
35
+ const SRGB_TO_XYZ = [
36
+ [0.4123907993, 0.3575843394, 0.1804807884],
37
+ [0.2126390059, 0.7151686788, 0.0721923154],
38
+ [0.0193308187, 0.1191947798, 0.9505321522]
39
+ ]
40
+ // XYZ D65 → LMS
41
+ const XYZ_TO_LMS = [
42
+ [0.8189330101, 0.3618667424, -0.1288597137],
43
+ [0.0329845436, 0.9293118715, 0.0361456387],
44
+ [0.0482003018, 0.2643662691, 0.633851707]
45
+ ]
46
+ // LMS^(1/3) → OKLab
47
+ const LMS3_TO_OKLAB = [
48
+ [0.2104542553, 0.793617785, -0.0040720468],
49
+ [1.9779984951, -2.428592205, 0.4505937099],
50
+ [0.0259040371, 0.7827717662, -0.808675766]
51
+ ]
52
+ // OKLab → LMS^(1/3)
53
+ const OKLAB_TO_LMS3 = [
54
+ [1.0, 0.3963377774, 0.2158037573],
55
+ [1.0, -0.1055613458, -0.0638541728],
56
+ [1.0, -0.0894841775, -1.291485548]
57
+ ]
58
+ // LMS → XYZ D65
59
+ const LMS_TO_XYZ = [
60
+ [1.2270138511, -0.5577999807, 0.281256149],
61
+ [-0.0405801784, 1.1122568696, -0.0716766787],
62
+ [-0.0763812845, -0.4214819784, 1.5861632204]
63
+ ]
64
+ // XYZ D65 → linear sRGB
65
+ const XYZ_TO_SRGB = [
66
+ [3.2409699419, -1.5373831776, -0.4986107603],
67
+ [-0.9692436363, 1.8759675015, 0.0415550574],
68
+ [0.0556300797, -0.2039769589, 1.0569715142]
69
+ ]
70
+
71
+ /** @param {number[][]} m @param {number[]} v */
72
+ function matMul(m, v) {
73
+ return m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2])
74
+ }
75
+
76
+ /**
77
+ * Convert hex string to OKLCH components: `{L} {C} {H}`
78
+ * Uses CSS Color Level 4 two-step conversion via XYZ D65 for precision.
79
+ * @param {string} hex - 6-digit hex color (with or without #)
80
+ * @returns {string} space-separated OKLCH components for CSS `oklch(L C H / alpha)`
81
+ */
82
+ export function hex2oklch(hex) {
83
+ const [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16))
84
+ const lin = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]
85
+
86
+ const xyz = matMul(SRGB_TO_XYZ, lin)
87
+ const lms = matMul(XYZ_TO_LMS, xyz)
88
+ const lms3 = [Math.cbrt(lms[0]), Math.cbrt(lms[1]), Math.cbrt(lms[2])]
89
+ const [L, a, bk] = matMul(LMS3_TO_OKLAB, lms3)
90
+
91
+ const C = Math.sqrt(a * a + bk * bk)
92
+ let H = (Math.atan2(bk, a) * 180) / Math.PI
93
+ if (H < 0) H += 360
94
+
95
+ const Lr = Math.round(L * 1000000) / 1000000
96
+ const Cr = Math.round(C * 1000000) / 1000000
97
+ const Hr = Math.round(H * 10000) / 10000
98
+
99
+ return `${Lr} ${Cr} ${Hr}`
100
+ }
101
+
102
+ /**
103
+ * Convert OKLCH components back to hex string.
104
+ * Uses CSS Color Level 4 two-step conversion via XYZ D65 for precision.
105
+ * @param {number} L - Lightness 0-1
106
+ * @param {number} C - Chroma >= 0
107
+ * @param {number} H - Hue 0-360 degrees
108
+ * @returns {string} hex color string (#rrggbb)
109
+ */
110
+ export function oklch2hex(L, C, H) {
111
+ const hRad = (H * Math.PI) / 180
112
+ const lab = [L, C * Math.cos(hRad), C * Math.sin(hRad)]
113
+
114
+ const lms3 = matMul(OKLAB_TO_LMS3, lab)
115
+ const lms = [lms3[0] ** 3, lms3[1] ** 3, lms3[2] ** 3]
116
+ const xyz = matMul(LMS_TO_XYZ, lms)
117
+ const lin = matMul(XYZ_TO_SRGB, xyz)
118
+
119
+ const toHex = (n) => {
120
+ const hex = linearToSrgb(n).toString(16)
121
+ return hex.length === 1 ? `0${hex}` : hex
122
+ }
123
+ return `#${toHex(lin[0])}${toHex(lin[1])}${toHex(lin[2])}`
124
+ }
@@ -0,0 +1,45 @@
1
+ /** Snippet lookup for data-driven rendering. */
2
+ import { has } from 'ramda'
3
+ import { ITEM_SNIPPET } from './constants'
4
+
5
+ /**
6
+ * Get snippet function from an object
7
+ * @param {Object} obj
8
+ * @param {string} key
9
+ * @param {null|Function} defaultSnippet
10
+ * @returns {Function|undefined}
11
+ */
12
+ export function getSnippet(obj, key, defaultSnippet = null) {
13
+ if (has(key, obj) && typeof obj[key] === 'function') {
14
+ return obj[key]
15
+ }
16
+ return defaultSnippet
17
+ }
18
+
19
+ /**
20
+ * @param {unknown} value
21
+ * @returns {Function|null}
22
+ */
23
+ function asSnippet(value) {
24
+ return typeof value === 'function' ? value : null
25
+ }
26
+
27
+ /**
28
+ * Resolve which snippet to render for a proxy item.
29
+ *
30
+ * Reads the proxy's `snippet` field (via proxy.get('snippet'), honoring fields.snippet)
31
+ * for a per-item named override first (e.g. item.snippet = 'highlighted').
32
+ * Falls back to the component-level fallback snippet name (e.g. 'itemContent' / 'groupContent').
33
+ * Returns null if neither is found.
34
+ *
35
+ * @param {Record<string, unknown>} snippets - snippets passed to the component
36
+ * @param {{ get: (key: string) => unknown }} proxy - a ProxyItem; its snippet field is read via proxy.get('snippet')
37
+ * @param {string} [fallback] - fallback snippet name; defaults to ITEM_SNIPPET ('itemContent')
38
+ * @returns {Function | null}
39
+ */
40
+ export function resolveSnippet(snippets, proxy, fallback = ITEM_SNIPPET) {
41
+ // `snippet` is a field-mapped prop like icon/href — read it via the proxy's
42
+ // field accessor (honors fields.snippet), not as a direct property.
43
+ const name = proxy?.get('snippet')
44
+ return asSnippet(name && snippets[name]) || asSnippet(snippets[fallback])
45
+ }
package/src/theme.ts CHANGED
@@ -1,5 +1,3 @@
1
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2
- // @ts-nocheck
3
1
  import { DEFAULT_THEME_MAPPING, defaultColors } from './constants'
4
2
  import { shades } from './colors/index'
5
3
  import { ColorSpace, relativeLuminance } from './color-space'
@@ -8,6 +6,23 @@ import {
8
6
  NAMED_TOKEN_SHADE_MAP,
9
7
  NAMED_TOKEN_ROLE_MAP
10
8
  } from './named-tokens'
9
+ import type { NamedToken } from './named-tokens'
10
+
11
+ /** One palette: shade (`'50'`…`'950'`, `'DEFAULT'`) → colour value in the adapter's notation. */
12
+ type Palette = Record<string, string>
13
+ /** Palette name → palette. */
14
+ type Colors = Record<string, Palette>
15
+ /** Role (`primary`, `surface`…) → palette name. */
16
+ type Mapping = Record<string, string>
17
+ type Adapter = ReturnType<typeof ColorSpace.create>
18
+
19
+ /**
20
+ * The default colours seen as palettes only. preset-mini's map also holds keyword entries
21
+ * (`inherit`, `current`, `transparent`…) that are plain strings, but a Theme only reaches a
22
+ * colour through a role mapping, and roles map to palette names — so the keyword entries are
23
+ * never indexed by shade here.
24
+ */
25
+ const DEFAULT_COLORS = defaultColors as unknown as Colors
11
26
 
12
27
  /**
13
28
  * Generate shades for a color using css variable and a ColorSpace adapter.
@@ -16,10 +31,10 @@ import {
16
31
  * @param {string|import('./color-space').ColorSpace} space - color space name or adapter instance
17
32
  * @returns {Record<string|number, string>}
18
33
  */
19
- export function shadesOf(name, space = 'rgb') {
34
+ export function shadesOf(name: string, space: string | Adapter = 'rgb'): Record<string, string> {
20
35
  const adapter = typeof space === 'string' ? ColorSpace.create(space) : space
21
36
 
22
- return shades.reduce(
37
+ return shades.reduce<Record<string, string>>(
23
38
  (result, shade) => ({
24
39
  ...result,
25
40
  [shade]: adapter.themeColor(`--color-${name}-${shade}`)
@@ -39,7 +54,7 @@ export function shadesOf(name, space = 'rgb') {
39
54
  * @param {import('./color-space').ColorSpace} adapter - ColorSpace adapter instance.
40
55
  * @returns {import('./types').ShadeMappings} An array containing the color rules for both light and dark modes.
41
56
  */
42
- function generateColorRules(variant, colors, mapping, adapter) {
57
+ function generateColorRules(variant: string, colors: Colors, mapping: Mapping, adapter: Adapter) {
43
58
  return ['DEFAULT', ...shades].flatMap((shade) => [
44
59
  {
45
60
  key: shade === 'DEFAULT' ? `--color-${variant}` : `--color-${variant}-${shade}`,
@@ -56,14 +71,18 @@ function generateColorRules(variant, colors, mapping, adapter) {
56
71
  * @param {string} [colorSpace] - Color space name for CSS variable values.
57
72
  * @returns {Array<Array>} An array containing two arrays, one for the light theme variant and another for the dark theme.
58
73
  */
59
- export function themeRules(mapping = DEFAULT_THEME_MAPPING, colors = defaultColors, colorSpace) {
74
+ export function themeRules(
75
+ mapping: Mapping = DEFAULT_THEME_MAPPING,
76
+ colors: Colors = DEFAULT_COLORS,
77
+ colorSpace?: string
78
+ ): Record<string, string> {
60
79
  mapping = { ...DEFAULT_THEME_MAPPING, ...mapping }
61
- colors = { ...defaultColors, ...colors }
80
+ colors = { ...(DEFAULT_COLORS), ...colors }
62
81
  const adapter = ColorSpace.create(colorSpace || 'rgb')
63
82
  const variants = Object.keys(mapping)
64
83
  const rules = variants
65
84
  .flatMap((variant) => generateColorRules(variant, colors, mapping, adapter))
66
- .reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {})
85
+ .reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
67
86
 
68
87
  return rules
69
88
  }
@@ -84,10 +103,13 @@ export function themeRules(mapping = DEFAULT_THEME_MAPPING, colors = defaultColo
84
103
  * @param {string} [onColor='#fafafa'] - resolved on-color hex for this role
85
104
  * @returns {Array} Array of shortcut definitions
86
105
  */
87
- export function contrastShortcuts(name, onColor = '#fafafa') {
106
+ export function contrastShortcuts(name: string, onColor = '#fafafa') {
88
107
  return [
89
- [new RegExp(`^text-on-${name}(\\/\\d+)?$`), ([, end]) => `text-[${onColor}]${end || ''}`],
90
- [new RegExp(`^text-on-${name}-muted(\\/\\d+)?$`), ([, end]) => `text-[${onColor}]${end || ''}`]
108
+ [new RegExp(`^text-on-${name}(\\/\\d+)?$`), ([, end]: string[]) => `text-[${onColor}]${end || ''}`],
109
+ [
110
+ new RegExp(`^text-on-${name}-muted(\\/\\d+)?$`),
111
+ ([, end]: string[]) => `text-[${onColor}]${end || ''}`
112
+ ]
91
113
  ]
92
114
  }
93
115
 
@@ -128,7 +150,7 @@ export function pickOnColor(y: number | null): string {
128
150
  * Fallback chain for nullable color mappings.
129
151
  * If a semantic color is null, it inherits from another semantic color.
130
152
  */
131
- const COLOR_FALLBACKS = {
153
+ const COLOR_FALLBACKS: Record<string, keyof typeof DEFAULT_THEME_MAPPING> = {
132
154
  ink: 'surface',
133
155
  tertiary: 'primary',
134
156
  secondary: 'primary',
@@ -141,30 +163,34 @@ const COLOR_FALLBACKS = {
141
163
  * @param {Record<string, string | null>} mapping
142
164
  * @returns {Record<string, string>}
143
165
  */
144
- function resolveColors(mapping) {
166
+ function resolveColors(mapping: Record<string, string | null | undefined>): Mapping {
145
167
  const resolved = { ...mapping }
146
168
  for (const [key, fallbackKey] of Object.entries(COLOR_FALLBACKS)) {
147
169
  if (resolved[key] === null || resolved[key] === undefined) {
148
170
  resolved[key] = resolved[fallbackKey] ?? DEFAULT_THEME_MAPPING[fallbackKey]
149
171
  }
150
172
  }
151
- return resolved
173
+ return resolved as Mapping
152
174
  }
153
175
 
154
176
  /**
155
177
  * Theme class for managing color palettes, mappings, and semantic shortcuts.
156
178
  */
157
179
  export class Theme {
158
- #colors
159
- #mapping
160
- #adapter
180
+ #colors: Colors
181
+ #mapping: Mapping
182
+ #adapter: Adapter
161
183
 
162
184
  /**
163
185
  *
164
186
  * @param {import('./types.js').ColorTheme & { colorSpace?: string }} param0
165
187
  */
166
- constructor({ colors = defaultColors, mapping = DEFAULT_THEME_MAPPING, colorSpace = 'rgb' } = {}) {
167
- this.#colors = { ...defaultColors, ...colors }
188
+ constructor({
189
+ colors = DEFAULT_COLORS,
190
+ mapping = DEFAULT_THEME_MAPPING as Record<string, string | null>,
191
+ colorSpace = 'rgb'
192
+ }: { colors?: Colors; mapping?: Record<string, string | null>; colorSpace?: string } = {}) {
193
+ this.#colors = { ...(DEFAULT_COLORS), ...colors }
168
194
  this.#mapping = resolveColors({ ...DEFAULT_THEME_MAPPING, ...mapping })
169
195
  this.#adapter = ColorSpace.create(colorSpace)
170
196
  }
@@ -172,26 +198,26 @@ export class Theme {
172
198
  get colors() {
173
199
  return this.#colors
174
200
  }
175
- set colors(colors) {
201
+ set colors(colors: Colors) {
176
202
  this.#colors = { ...colors }
177
203
  }
178
204
 
179
205
  get mapping() {
180
206
  return this.#mapping
181
207
  }
182
- set mapping(mapping) {
208
+ set mapping(mapping: Record<string, string | null>) {
183
209
  this.#mapping = resolveColors({ ...mapping })
184
210
  }
185
211
 
186
212
  get colorSpace() {
187
213
  return this.#adapter.name
188
214
  }
189
- set colorSpace(colorSpace) {
215
+ set colorSpace(colorSpace: string) {
190
216
  this.#adapter = ColorSpace.create(colorSpace)
191
217
  }
192
218
 
193
- mapVariant(color, variant) {
194
- return Object.keys(color).reduce(
219
+ mapVariant(color: Palette, variant: string): Record<string, string> {
220
+ return Object.keys(color).reduce<Record<string, string>>(
195
221
  (acc, key) => ({
196
222
  ...acc,
197
223
  [key]:
@@ -203,9 +229,9 @@ export class Theme {
203
229
  )
204
230
  }
205
231
 
206
- getColorRules(mapping = null) {
232
+ getColorRules(mapping: Mapping | null = null): Record<string, Record<string, string>> {
207
233
  const variants = Object.entries({ ...this.#mapping, ...mapping })
208
- return variants.reduce(
234
+ return variants.reduce<Record<string, Record<string, string>>>(
209
235
  (acc, [variant, key]) => ({
210
236
  ...acc,
211
237
  [variant]: this.mapVariant(this.#colors[key], variant)
@@ -214,13 +240,13 @@ export class Theme {
214
240
  )
215
241
  }
216
242
 
217
- getPalette(mapping = null) {
243
+ getPalette(mapping: Mapping | null = null): Record<string, string> {
218
244
  const useMapping = { ...this.#mapping, ...mapping }
219
- const useColors = { ...defaultColors, ...this.#colors }
245
+ const useColors = { ...(DEFAULT_COLORS), ...this.#colors }
220
246
  const variants = Object.keys(useMapping)
221
247
  const rules = variants
222
248
  .flatMap((variant) => generateColorRules(variant, useColors, useMapping, this.#adapter))
223
- .reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {})
249
+ .reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
224
250
  return rules
225
251
  }
226
252
 
@@ -245,7 +271,7 @@ export class Theme {
245
271
  * (and for the whole map when undefined).
246
272
  */
247
273
  getNamedTokens(_mode = 'light', perRoleModes?: Record<string, 'core' | 'extended'>) {
248
- const colors = { ...defaultColors, ...this.#colors }
274
+ const colors: Colors = { ...(DEFAULT_COLORS), ...this.#colors }
249
275
  const result: Record<string, string> = {}
250
276
  for (const name of NAMED_TOKENS) {
251
277
  const value = this.#resolveNamedToken(name, colors, perRoleModes)
@@ -258,7 +284,7 @@ export class Theme {
258
284
  * Resolves a single named token to its CSS value, respecting per-role mode.
259
285
  * Returns undefined when the role's palette is missing.
260
286
  */
261
- #resolveNamedToken(name: string, colors: Record<string, Record<string, string>>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
287
+ #resolveNamedToken(name: NamedToken, colors: Record<string, Record<string, string>>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
262
288
  const role = NAMED_TOKEN_ROLE_MAP[name]
263
289
  const shadeOrDerived = NAMED_TOKEN_SHADE_MAP[name]
264
290
  const paletteName = this.#mapping[role]
@@ -330,7 +356,7 @@ export class Theme {
330
356
  * or near-white), matching the core-mode derivation.
331
357
  */
332
358
  getZAliasesForExtended(): Record<string, string> {
333
- const colors = { ...defaultColors, ...this.#colors }
359
+ const colors: Colors = { ...(DEFAULT_COLORS), ...this.#colors }
334
360
  const result: Record<string, string> = {}
335
361
  for (const name of NAMED_TOKENS) {
336
362
  const role = NAMED_TOKEN_ROLE_MAP[name]
@@ -350,7 +376,7 @@ export class Theme {
350
376
  * Mirrors what getPalette does for all roles, but scoped to one.
351
377
  */
352
378
  getPaletteForRole(role: string): Record<string, string> {
353
- const colors = { ...defaultColors, ...this.#colors }
379
+ const colors: Colors = { ...(DEFAULT_COLORS), ...this.#colors }
354
380
  const paletteName = this.#mapping[role]
355
381
  if (!paletteName || !colors[paletteName]) return {}
356
382
  const palette = colors[paletteName]
@@ -363,8 +389,8 @@ export class Theme {
363
389
  return result
364
390
  }
365
391
 
366
- getShortcuts(name) {
367
- const colors = { ...defaultColors, ...this.#colors }
392
+ getShortcuts(name: string) {
393
+ const colors: Colors = { ...(DEFAULT_COLORS), ...this.#colors }
368
394
  return contrastShortcuts(name, this.#onColorHex(name, colors))
369
395
  }
370
396