@rokkit/core 1.0.4 → 1.1.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Abstract base class for color space adapters.
3
+ */
4
+ export class ColorSpace {
5
+ /**
6
+ * Factory method to create a ColorSpace adapter by name.
7
+ * @param {string} space - 'rgb', 'hsl', or 'oklch'
8
+ * @returns {ColorSpace}
9
+ */
10
+ static create(space: string): ColorSpace;
11
+ /** @returns {string} */
12
+ get name(): string;
13
+ /** @returns {string} CSS color-mix interpolation space */
14
+ get mixSpace(): string;
15
+ /** @returns {string} CSS function name */
16
+ get fn(): string;
17
+ /**
18
+ * Convert a hex color to bare channels in this space.
19
+ * @param {string} hex - #rrggbb
20
+ * @returns {string}
21
+ */
22
+ fromHex(_hex: any): string;
23
+ /**
24
+ * Wrap any input into a complete CSS color value.
25
+ * - hex → convert and wrap
26
+ * - bare channels → wrap in fn()
27
+ * - already wrapped → pass through
28
+ * - non-string / null / undefined → pass through
29
+ * - named colors → pass through
30
+ * @param {*} value
31
+ * @returns {*}
32
+ */
33
+ wrap(_value: any): any;
34
+ /**
35
+ * Produce a color-mix expression for theme CSS variables with alpha support.
36
+ * @param {string} varName - CSS variable name (e.g. '--color-primary-500')
37
+ * @returns {string}
38
+ */
39
+ themeColor(varName: string): string;
40
+ }
41
+ export class RgbColorSpace extends ColorSpace {
42
+ fromHex(hex: any): string;
43
+ wrap(value: any): any;
44
+ }
45
+ export class HslColorSpace extends ColorSpace {
46
+ fromHex(hex: any): string;
47
+ wrap(value: any): any;
48
+ }
49
+ export class OklchColorSpace extends ColorSpace {
50
+ fromHex(hex: any): string;
51
+ wrap(value: any): any;
52
+ }
@@ -54,6 +54,7 @@ export namespace DEFAULT_KEYMAP {
54
54
  }
55
55
  export namespace DEFAULT_THEME_MAPPING {
56
56
  let surface: string;
57
+ let ink: string;
57
58
  let primary: string;
58
59
  let secondary: string;
59
60
  let tertiary: string;
@@ -77,5 +78,6 @@ export namespace TONE_MAP {
77
78
  let z9: number;
78
79
  let z10: number;
79
80
  }
81
+ export const INVERTED_ROLES: Set<string>;
80
82
  export const DEFAULT_STATE_ICONS: any;
81
83
  export { defaultColors, syntaxColors, shades, defaultPalette } from "./colors/index";
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  export * from "./types.js";
2
2
  export * from "./constants.js";
3
+ export * from "./named-tokens.ts";
3
4
  export * from "./utils.js";
4
5
  export * from "./nested.js";
5
6
  export * from "./string.js";
6
7
  export * from "./theme.js";
8
+ export { ColorSpace } from "./color-space.js";
7
9
  export { FieldMapper } from "./field-mapper.js";
8
10
  export { createEmitter } from "./events.js";
9
11
  export { getLineTypes } from "./connector.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The 24 canonical named tokens that constitute the "core" emit vocabulary.
3
+ * Order is significant for tests and for predictable CSS-var output ordering.
4
+ */
5
+ export declare const NAMED_TOKENS: readonly ["paper", "paper-soft", "paper-mute", "paper-edge", "ink", "ink-mute", "ink-soft", "ink-faint", "primary", "on-primary", "accent", "accent-soft", "success", "success-soft", "warning", "warning-soft", "danger", "danger-soft", "error", "error-soft", "info", "info-soft", "focus-ring", "shadow-tint"];
6
+ export type NamedToken = (typeof NAMED_TOKENS)[number];
7
+ export type SkinRole = 'surface' | 'ink' | 'primary' | 'accent' | 'success' | 'warning' | 'danger' | 'error' | 'info';
8
+ export type ZSlot = 'z0' | 'z1' | 'z2' | 'z3' | 'z4' | 'z5' | 'z6' | 'z7' | 'z8' | 'z9' | 'z10';
9
+ export declare const Z_SLOTS: ZSlot[];
10
+ /**
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).
14
+ *
15
+ * paper: 50 (lightest surface tone, canvas)
16
+ * paper-soft: 100 (card background)
17
+ * paper-mute: 200 (subdued panel)
18
+ * paper-edge: 400 (hairline border tone)
19
+ * ink: 900 (primary text)
20
+ * ink-mute: 700 (secondary text)
21
+ * ink-soft: 500 (placeholder)
22
+ * ink-faint: 300 (disabled)
23
+ * primary: 500 (CTA fill)
24
+ * on-primary: derived — surface.50 default (white-on-primary)
25
+ * accent / status: 500 (solid)
26
+ * *-soft: 100 (tinted-bg companion)
27
+ * focus-ring: 500 of accent
28
+ * shadow-tint: 900 of ink
29
+ */
30
+ export declare const NAMED_TOKEN_SHADE_MAP: Record<NamedToken, number | 'derived'>;
31
+ /**
32
+ * Maps each named token to the skin role whose palette it draws from.
33
+ */
34
+ export declare const NAMED_TOKEN_ROLE_MAP: Record<NamedToken, SkinRole>;
35
+ /**
36
+ * Surface z-slot → named slot. Used to emit back-compat aliases like
37
+ * `--color-surface-z1: var(--paper-soft);` in core mode.
38
+ *
39
+ * z2/z3 collapse to paper-mute, z5/z6 to ink-soft, z7/z8 to ink-mute,
40
+ * z9/z10 to ink. This collapse is the contract: 4 surface tones in core mode.
41
+ */
42
+ export declare const Z_COLLAPSE_MAP_SURFACE: Record<ZSlot, NamedToken>;
43
+ /**
44
+ * Ink role uses the inverted z-scale (z0 = darkest = primary text).
45
+ * In core mode, the same 4+4 named ladder is reused; this table just inverts.
46
+ */
47
+ export declare const Z_COLLAPSE_MAP_INK: Record<ZSlot, NamedToken>;
48
+ /**
49
+ * Returns true when the role has a `-soft` companion in the named vocabulary.
50
+ * Used to decide whether the role's tinted-background slot collapses to
51
+ * `var(--{role}-soft)` or to `var(--{role})`.
52
+ */
53
+ export declare function hasSoftCompanion(role: string): boolean;
54
+ export declare function shadeForNamedToken(name: string): number | 'derived' | undefined;
55
+ export declare function roleForNamedToken(name: string): SkinRole | undefined;
package/dist/theme.d.ts CHANGED
@@ -1,23 +1,24 @@
1
1
  /**
2
- * Generate shades for a color using css varuable
2
+ * Generate shades for a color using css variable and a ColorSpace adapter.
3
3
  *
4
4
  * @param {string} name
5
- * @param {string} modifier
6
- * @returns
5
+ * @param {string|import('./color-space').ColorSpace} space - color space name or adapter instance
6
+ * @returns {Record<string|number, string>}
7
7
  */
8
- export declare function shadesOf(name: any, modifier?: string): {
9
- DEFAULT: any;
8
+ export declare function shadesOf(name: any, space?: string): {
9
+ DEFAULT: string;
10
10
  };
11
11
  /**
12
12
  * Constructs and returns the light and dark theme variants based on provided color mapping and color definitions.
13
13
  *
14
14
  * @param {Object} [mapping=DEFAULT_THEME_MAPPING] - An object mapping variant names to color property names.
15
15
  * @param {Object} [colors=defaultColors] - The object containing default color definitions.
16
- * @param {import('./utils').ColorSpace} [colorSpace] - Color space for CSS variable values.
16
+ * @param {string} [colorSpace] - Color space name for CSS variable values.
17
17
  * @returns {Array<Array>} An array containing two arrays, one for the light theme variant and another for the dark theme.
18
18
  */
19
19
  export declare function themeRules(mapping: {
20
20
  surface: string;
21
+ ink: string;
21
22
  primary: string;
22
23
  secondary: string;
23
24
  tertiary: string;
@@ -51,12 +52,13 @@ export declare class Theme {
51
52
  #private;
52
53
  /**
53
54
  *
54
- * @param {import('./types.js').ColorTheme & { colorSpace?: import('./utils').ColorSpace }} param0
55
+ * @param {import('./types.js').ColorTheme & { colorSpace?: string }} param0
55
56
  */
56
57
  constructor({ colors, mapping, colorSpace }?: {
57
58
  colors?: any;
58
59
  mapping?: {
59
60
  surface: string;
61
+ ink: string;
60
62
  primary: string;
61
63
  secondary: string;
62
64
  tertiary: string;
@@ -78,6 +80,60 @@ export declare class Theme {
78
80
  mapVariant(color: any, variant: any): {};
79
81
  getColorRules(mapping?: null): {};
80
82
  getPalette(mapping?: null): {};
83
+ /**
84
+ * Returns the named-token layer as a map of `--{name}: <resolved value>` entries.
85
+ *
86
+ * Palette values are inlined — there is no `var(--color-{role}-{shade})` indirection.
87
+ *
88
+ * `on-primary` is derived: it picks the paper shade (50) of the surface palette so the
89
+ * default contrast pair is "primary fill + white-ish text." Skins that need a different
90
+ * pair should override via custom tokens.
91
+ *
92
+ * `*-soft` variants resolve to shade 100 of their role's palette.
93
+ * `shadow-tint` resolves to shade 900 of the ink palette.
94
+ *
95
+ * @param {'light' | 'dark'} _mode — accepted for symmetry with future
96
+ * dark-aware derivations. Today the Theme is constructed with a mode-resolved
97
+ * mapping, so this parameter is informational.
98
+ * @param perRoleModes — optional map of role → 'core' | 'extended'. When a token's
99
+ * role is 'extended', the value is a `var(--color-{role}-{shade})` alias instead
100
+ * of the inlined palette value. Defaults to 'core' for all unspecified roles
101
+ * (and for the whole map when undefined).
102
+ */
103
+ getNamedTokens(_mode?: string, perRoleModes?: Record<string, 'core' | 'extended'>): Record<string, string>;
104
+ /**
105
+ * Returns z-alias CSS-var assignments for back-compat in core mode.
106
+ * `--color-{role}-z{n}` → `var(--{namedSlot})`.
107
+ *
108
+ * - surface: uses Z_COLLAPSE_MAP_SURFACE (z2/z3 → paper-mute, z9/z10 → ink, etc.)
109
+ * - ink: uses Z_COLLAPSE_MAP_INK (inverted scale)
110
+ * - primary/accent/status: tint-vs-solid 2-state collapse.
111
+ * z0–z2 → role-soft (if it exists) else role; z3+ → role.
112
+ *
113
+ * This is the back-compat layer: existing `@apply bg-surface-z3` etc. resolves
114
+ * through these aliases until consumers migrate to the named vocabulary.
115
+ */
116
+ /** Accepts SkinRole and any custom skin roles beyond the SkinRole literal union. */
117
+ getZAliasesForCore(role: string): Record<string, string>;
118
+ /**
119
+ * Returns named tokens as CSS-var aliases pointing at the palette layer.
120
+ * Used in extended mode where the preset emits the full --color-{role}-{shade}
121
+ * palette; named tokens become thin syntactic aliases over the palette.
122
+ *
123
+ * `on-primary` aliases to `var(--color-surface-50)` (derived: paper of surface,
124
+ * matching the core-mode resolution).
125
+ */
126
+ getZAliasesForExtended(): Record<string, string>;
127
+ /**
128
+ * Emit the full --color-{role}-{shade} palette CSS vars for a single role.
129
+ * Mirrors what getPalette does for all roles, but scoped to one.
130
+ */
131
+ getPaletteForRole(role: string): Record<string, string>;
132
+ /**
133
+ * Emit z-aliases for one role in extended mode — they point at the palette vars,
134
+ * using TONE_MAP for surface/non-inverted roles and the inverted map for ink.
135
+ */
136
+ getZAliasesForRoleExtended(role: string): Record<string, string>;
81
137
  getShortcuts(name: any): (string[] | (RegExp | (([, variant, end]: [any, any, any]) => string))[] | (RegExp | (([, end]: [any, any]) => string))[])[];
82
138
  /**
83
139
  * Generates CSS custom property declarations for the semantic z0–z10 tone scale.
package/dist/utils.d.ts CHANGED
@@ -101,40 +101,16 @@ export function hex2oklch(hex: string): string;
101
101
  */
102
102
  export function oklch2hex(L: number, C: number, H: number): string;
103
103
  /**
104
- * Convert hex string to HSL components: `{H} {S}% {L}%`
105
- * @param {string} hex - 6-digit hex color (with or without #)
106
- * @returns {string} space-separated HSL components for CSS `hsl(H S% L% / alpha)`
107
- */
108
- export function hex2hsl(hex: string): string;
109
- /** @typedef {'rgb' | 'hsl' | 'oklch'} ColorSpace */
110
- /**
111
- * Convert a hex color to the component format for a given color space.
112
- * - rgb: "r,g,b" → for `rgba(var(--x), alpha)`
113
- * - hsl: "H S% L%" → for `hsl(var(--x) / alpha)`
114
- * - oklch: "L C H" → for `oklch(var(--x) / alpha)`
104
+ * Detects whether an icon value is a CSS class or a literal character
105
+ * (kanji, emoji, single letter, etc.).
115
106
  *
116
- * @param {string} hex - 6-digit hex color (#rrggbb)
117
- * @param {ColorSpace} space
118
- * @returns {string}
119
- */
120
- export function hexToComponents(hex: string, space: ColorSpace): string;
121
- /**
122
- * Convert a CSS color value to components for use in CSS variables.
123
- *
124
- * - Hex (#rrggbb): converted to space-appropriate components via hexToComponents.
125
- * - oklch(...) strings: inner components extracted (e.g. "0.58 0.15 35") so the
126
- * value is compatible with the oklch(var(--x) / alpha) wrapper pattern.
127
- * - All other formats: returned as-is (no opacity-modifier support).
107
+ * Anything more than one grapheme is treated as a class — including bare
108
+ * semantic names like `file-svelte` which UnoCSS expands to the configured
109
+ * icon collection via `iconShortcuts(DEFAULT_ICONS, …)`. Single characters
110
+ * (kanji, emoji, single letters) render as literal text.
128
111
  *
129
- * @param {unknown} value
130
- * @param {ColorSpace} [space]
131
- * @returns {unknown}
132
- */
133
- export function colorToRgb(value: unknown, space?: ColorSpace): unknown;
134
- /**
135
- * Detects whether an icon value is a CSS class (i-*) or literal text (kanji, emoji, etc.)
136
112
  * @param {string | null | undefined} icon
137
- * @returns {boolean} true if icon is a CSS class string
113
+ * @returns {boolean} true if icon should render as a CSS class
138
114
  */
139
115
  export function isIconClass(icon: string | null | undefined): boolean;
140
116
  /**
@@ -144,4 +120,3 @@ export function isIconClass(icon: string | null | undefined): boolean;
144
120
  * @returns {string|null} - Returns the original string if it's an image URL or image data, otherwise null
145
121
  */
146
122
  export function getImage(str: string): string | null;
147
- export type ColorSpace = "rgb" | "hsl" | "oklch";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/core",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
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.0.4"
49
+ "@rokkit/icons": "1.1.0"
50
50
  }
51
51
  }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * ColorSpace adapter strategy pattern.
3
+ *
4
+ * Each adapter knows how to convert hex colors to its own representation,
5
+ * wrap bare channels into a complete CSS color function, and produce
6
+ * `color-mix()` expressions for theme variables with alpha support.
7
+ */
8
+
9
+ // ── Private math helpers (copied from utils.js — not imported) ──────────
10
+
11
+ /**
12
+ * Convert sRGB component (0-255) to linear RGB.
13
+ * @param {number} c - sRGB value 0-255
14
+ * @returns {number}
15
+ */
16
+ function srgbToLinear(c) {
17
+ const s = c / 255
18
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
19
+ }
20
+
21
+ // Linear sRGB → XYZ D65
22
+ const SRGB_TO_XYZ = [
23
+ [0.4123907993, 0.3575843394, 0.1804807884],
24
+ [0.2126390059, 0.7151686788, 0.0721923154],
25
+ [0.0193308187, 0.1191947798, 0.9505321522]
26
+ ]
27
+ // XYZ D65 → LMS
28
+ const XYZ_TO_LMS = [
29
+ [0.8189330101, 0.3618667424, -0.1288597137],
30
+ [0.0329845436, 0.9293118715, 0.0361456387],
31
+ [0.0482003018, 0.2643662691, 0.633851707]
32
+ ]
33
+ // LMS^(1/3) → OKLab
34
+ const LMS3_TO_OKLAB = [
35
+ [0.2104542553, 0.793617785, -0.0040720468],
36
+ [1.9779984951, -2.428592205, 0.4505937099],
37
+ [0.0259040371, 0.7827717662, -0.808675766]
38
+ ]
39
+
40
+ /** @param {number[][]} m @param {number[]} v */
41
+ function matMul(m, v) {
42
+ return m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2])
43
+ }
44
+
45
+ // ── Hex parsing helper ──────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Parse a hex string (#rrggbb) into [r, g, b] integers.
49
+ * @param {string} hex
50
+ * @returns {number[]}
51
+ */
52
+ function parseHex(hex) {
53
+ return hex.match(/\w\w/g).map((x) => parseInt(x, 16))
54
+ }
55
+
56
+ // ── Hex → HSL conversion ───────────────────────────────────────────────
57
+
58
+ /**
59
+ * Convert hex to HSL bare channels: "H S% L%"
60
+ * @param {string} hex
61
+ * @returns {string}
62
+ */
63
+ function hexToHsl(hex) {
64
+ const [r, g, b] = parseHex(hex)
65
+ const rn = r / 255
66
+ const gn = g / 255
67
+ const bn = b / 255
68
+ const max = Math.max(rn, gn, bn)
69
+ const min = Math.min(rn, gn, bn)
70
+ const l = (max + min) / 2
71
+
72
+ if (max === min) return `0 0% ${Math.round(l * 100)}%`
73
+
74
+ const d = max - min
75
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
76
+
77
+ let h = 0
78
+ if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6
79
+ else if (max === gn) h = ((bn - rn) / d + 2) / 6
80
+ else h = ((rn - gn) / d + 4) / 6
81
+
82
+ return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
83
+ }
84
+
85
+ // ── Hex → OKLCH conversion ─────────────────────────────────────────────
86
+
87
+ /**
88
+ * Convert hex to OKLCH bare channels: "L C H"
89
+ * @param {string} hex
90
+ * @returns {string}
91
+ */
92
+ function hexToOklch(hex) {
93
+ const [r, g, b] = parseHex(hex)
94
+ const lin = [srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)]
95
+
96
+ const xyz = matMul(SRGB_TO_XYZ, lin)
97
+ const lms = matMul(XYZ_TO_LMS, xyz)
98
+ const lms3 = [Math.cbrt(lms[0]), Math.cbrt(lms[1]), Math.cbrt(lms[2])]
99
+ const [L, a, bk] = matMul(LMS3_TO_OKLAB, lms3)
100
+
101
+ const C = Math.sqrt(a * a + bk * bk)
102
+ let H = (Math.atan2(bk, a) * 180) / Math.PI
103
+ if (H < 0) H += 360
104
+
105
+ const Lr = Math.round(L * 1000000) / 1000000
106
+ const Cr = Math.round(C * 1000000) / 1000000
107
+ const Hr = Math.round(H * 10000) / 10000
108
+
109
+ return `${Lr} ${Cr} ${Hr}`
110
+ }
111
+
112
+ // ── Hex regex ───────────────────────────────────────────────────────────
113
+
114
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/
115
+
116
+ // ── Base class ──────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Abstract base class for color space adapters.
120
+ */
121
+ export class ColorSpace {
122
+ /** @returns {string} */
123
+ get name() {
124
+ throw new Error('Subclass must implement name')
125
+ }
126
+
127
+ /** @returns {string} CSS color-mix interpolation space */
128
+ get mixSpace() {
129
+ throw new Error('Subclass must implement mixSpace')
130
+ }
131
+
132
+ /** @returns {string} CSS function name */
133
+ get fn() {
134
+ throw new Error('Subclass must implement fn')
135
+ }
136
+
137
+ /**
138
+ * Convert a hex color to bare channels in this space.
139
+ * @param {string} hex - #rrggbb
140
+ * @returns {string}
141
+ */
142
+ fromHex(_hex) {
143
+ throw new Error('Subclass must implement fromHex')
144
+ }
145
+
146
+ /**
147
+ * Wrap any input into a complete CSS color value.
148
+ * - hex → convert and wrap
149
+ * - bare channels → wrap in fn()
150
+ * - already wrapped → pass through
151
+ * - non-string / null / undefined → pass through
152
+ * - named colors → pass through
153
+ * @param {*} value
154
+ * @returns {*}
155
+ */
156
+ wrap(_value) {
157
+ throw new Error('Subclass must implement wrap')
158
+ }
159
+
160
+ /**
161
+ * Produce a color-mix expression for theme CSS variables with alpha support.
162
+ * @param {string} varName - CSS variable name (e.g. '--color-primary-500')
163
+ * @returns {string}
164
+ */
165
+ themeColor(varName) {
166
+ return `color-mix(in ${this.mixSpace}, var(${varName}) calc(<alpha-value> * 100%), transparent)`
167
+ }
168
+
169
+ /**
170
+ * Factory method to create a ColorSpace adapter by name.
171
+ * @param {string} space - 'rgb', 'hsl', or 'oklch'
172
+ * @returns {ColorSpace}
173
+ */
174
+ static create(space) {
175
+ switch (space) {
176
+ case 'rgb':
177
+ return new RgbColorSpace()
178
+ case 'hsl':
179
+ return new HslColorSpace()
180
+ case 'oklch':
181
+ return new OklchColorSpace()
182
+ default:
183
+ throw new Error(`Unknown color space: ${space}`)
184
+ }
185
+ }
186
+ }
187
+
188
+ // ── RGB adapter ─────────────────────────────────────────────────────────
189
+
190
+ export class RgbColorSpace extends ColorSpace {
191
+ get name() {
192
+ return 'rgb'
193
+ }
194
+
195
+ get mixSpace() {
196
+ return 'srgb'
197
+ }
198
+
199
+ get fn() {
200
+ return 'rgb'
201
+ }
202
+
203
+ fromHex(hex) {
204
+ const [r, g, b] = parseHex(hex)
205
+ return `${r}, ${g}, ${b}`
206
+ }
207
+
208
+ wrap(value) {
209
+ if (typeof value !== 'string') return value
210
+
211
+ // Already wrapped
212
+ if (value.startsWith('rgb(') || value.startsWith('rgba(')) return value
213
+
214
+ // Hex
215
+ if (HEX_RE.test(value)) return `rgb(${this.fromHex(value)})`
216
+
217
+ // Bare channels: comma-separated or space-separated digits
218
+ // Comma-separated: "255,0,0" or "255, 0, 0"
219
+ if (/^\d+\s*,\s*\d+\s*,\s*\d+$/.test(value)) {
220
+ const parts = value.split(',').map((s) => s.trim())
221
+ return `rgb(${parts.join(', ')})`
222
+ }
223
+ // Space-separated: "255 0 0"
224
+ if (/^\d+\s+\d+\s+\d+$/.test(value)) {
225
+ const parts = value.trim().split(/\s+/)
226
+ return `rgb(${parts.join(', ')})`
227
+ }
228
+
229
+ // Named color or other — pass through
230
+ return value
231
+ }
232
+ }
233
+
234
+ // ── HSL adapter ─────────────────────────────────────────────────────────
235
+
236
+ export class HslColorSpace extends ColorSpace {
237
+ get name() {
238
+ return 'hsl'
239
+ }
240
+
241
+ get mixSpace() {
242
+ return 'srgb'
243
+ }
244
+
245
+ get fn() {
246
+ return 'hsl'
247
+ }
248
+
249
+ fromHex(hex) {
250
+ return hexToHsl(hex)
251
+ }
252
+
253
+ wrap(value) {
254
+ if (typeof value !== 'string') return value
255
+
256
+ // Already wrapped
257
+ if (value.startsWith('hsl(') || value.startsWith('hsla(')) return value
258
+
259
+ // Hex
260
+ if (HEX_RE.test(value)) return `hsl(${this.fromHex(value)})`
261
+
262
+ // Bare channels: "H S% L%" pattern (number, space, number%, space, number%)
263
+ if (/^\d+\s+\d+%\s+\d+%$/.test(value)) {
264
+ return `hsl(${value})`
265
+ }
266
+
267
+ // Named color or other — pass through
268
+ return value
269
+ }
270
+ }
271
+
272
+ // ── OKLCH adapter ───────────────────────────────────────────────────────
273
+
274
+ export class OklchColorSpace extends ColorSpace {
275
+ get name() {
276
+ return 'oklch'
277
+ }
278
+
279
+ get mixSpace() {
280
+ return 'oklch'
281
+ }
282
+
283
+ get fn() {
284
+ return 'oklch'
285
+ }
286
+
287
+ fromHex(hex) {
288
+ return hexToOklch(hex)
289
+ }
290
+
291
+ wrap(value) {
292
+ if (typeof value !== 'string') return value
293
+
294
+ // Already wrapped
295
+ if (value.startsWith('oklch(')) return value
296
+
297
+ // Hex
298
+ if (HEX_RE.test(value)) return `oklch(${this.fromHex(value)})`
299
+
300
+ // Bare channels: three space-separated numbers (may contain decimals)
301
+ if (/^[\d.]+\s+[\d.]+\s+[\d.]+$/.test(value)) {
302
+ return `oklch(${value})`
303
+ }
304
+
305
+ // Named color or other — pass through
306
+ return value
307
+ }
308
+ }
package/src/constants.js CHANGED
@@ -79,6 +79,7 @@ export const DEFAULT_ICONS = [
79
79
  'action-close',
80
80
  'action-copy',
81
81
  'action-copysuccess',
82
+ 'action-download',
82
83
  'action-check',
83
84
  'action-pin',
84
85
  'action-save',
@@ -87,6 +88,20 @@ export const DEFAULT_ICONS = [
87
88
  'node-closed',
88
89
  'folder-opened',
89
90
  'folder-closed',
91
+ 'doc-default',
92
+ 'doc-svelte',
93
+ 'doc-js',
94
+ 'doc-ts',
95
+ 'doc-css',
96
+ 'doc-html',
97
+ 'doc-json',
98
+ 'doc-md',
99
+ 'doc-text',
100
+ 'doc-pdf',
101
+ 'doc-archive',
102
+ 'media-image',
103
+ 'media-video',
104
+ 'media-audio',
90
105
  'selector-opened',
91
106
  'selector-closed',
92
107
  'menu-opened',
@@ -133,7 +148,9 @@ export const DEFAULT_ICONS = [
133
148
  'align-vertical-bottom',
134
149
  'align-vertical-middle',
135
150
  'view-code',
136
- 'view-chart'
151
+ 'view-chart',
152
+ 'view-preview',
153
+ 'view-off'
137
154
  ]
138
155
 
139
156
  export const DEFAULT_OPTIONS = {
@@ -154,6 +171,7 @@ export const DEFAULT_KEYMAP = {
154
171
 
155
172
  export const DEFAULT_THEME_MAPPING = {
156
173
  surface: 'slate',
174
+ ink: 'slate',
157
175
  primary: 'orange',
158
176
  secondary: 'pink',
159
177
  tertiary: 'violet',
@@ -178,6 +196,9 @@ export const TONE_MAP = {
178
196
  z9: 900,
179
197
  z10: 950
180
198
  }
199
+
200
+ export const INVERTED_ROLES = new Set(['ink'])
201
+
181
202
  /**
182
203
  * Splits an icon name into its group and key components.
183
204
  * @param {string} name - The icon name to split.
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from './types.js'
3
3
  // skipcq: JS-E1004 - Needed for exposing all constants
4
4
  export * from './constants.js'
5
+ export * from './named-tokens.ts'
5
6
  // skipcq: JS-E1004 - Needed for exposing all functions
6
7
  export * from './utils.js'
7
8
  // skipcq: JS-E1004 - Needed for exposing all functions
@@ -10,6 +11,7 @@ export * from './nested.js'
10
11
  export * from './string.js'
11
12
  // skipcq: JS-E1004 - Needed for exposing all functions
12
13
  export * from './theme.js'
14
+ export { ColorSpace } from './color-space.js'
13
15
  export { FieldMapper } from './field-mapper.js'
14
16
  export { getItemAtIndex, getIndexForItem } from './mapped-items.js'
15
17
  export { createEmitter } from './events.js'
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The 24 canonical named tokens that constitute the "core" emit vocabulary.
3
+ * Order is significant for tests and for predictable CSS-var output ordering.
4
+ */
5
+ export const NAMED_TOKENS = [
6
+ 'paper', 'paper-soft', 'paper-mute', 'paper-edge',
7
+ 'ink', 'ink-mute', 'ink-soft', 'ink-faint',
8
+ 'primary', 'on-primary',
9
+ 'accent', 'accent-soft',
10
+ 'success', 'success-soft',
11
+ 'warning', 'warning-soft',
12
+ 'danger', 'danger-soft',
13
+ 'error', 'error-soft',
14
+ 'info', 'info-soft',
15
+ 'focus-ring', 'shadow-tint'
16
+ ] as const
17
+
18
+ export type NamedToken = (typeof NAMED_TOKENS)[number]
19
+
20
+ export type SkinRole =
21
+ | 'surface' | 'ink' | 'primary' | 'accent'
22
+ | 'success' | 'warning' | 'danger' | 'error' | 'info'
23
+
24
+ export type ZSlot = 'z0' | 'z1' | 'z2' | 'z3' | 'z4' | 'z5' | 'z6' | 'z7' | 'z8' | 'z9' | 'z10'
25
+
26
+ export const Z_SLOTS: ZSlot[] = ['z0', 'z1', 'z2', 'z3', 'z4', 'z5', 'z6', 'z7', 'z8', 'z9', 'z10']
27
+
28
+ /**
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).
32
+ *
33
+ * paper: 50 (lightest surface tone, canvas)
34
+ * paper-soft: 100 (card background)
35
+ * paper-mute: 200 (subdued panel)
36
+ * paper-edge: 400 (hairline border tone)
37
+ * ink: 900 (primary text)
38
+ * ink-mute: 700 (secondary text)
39
+ * ink-soft: 500 (placeholder)
40
+ * ink-faint: 300 (disabled)
41
+ * primary: 500 (CTA fill)
42
+ * on-primary: derived — surface.50 default (white-on-primary)
43
+ * accent / status: 500 (solid)
44
+ * *-soft: 100 (tinted-bg companion)
45
+ * focus-ring: 500 of accent
46
+ * shadow-tint: 900 of ink
47
+ */
48
+ export const NAMED_TOKEN_SHADE_MAP: Record<NamedToken, number | 'derived'> = {
49
+ paper: 50,
50
+ 'paper-soft': 100,
51
+ 'paper-mute': 200,
52
+ 'paper-edge': 400,
53
+ ink: 900,
54
+ 'ink-mute': 700,
55
+ 'ink-soft': 500,
56
+ 'ink-faint': 300,
57
+ primary: 500,
58
+ 'on-primary': 'derived',
59
+ accent: 500,
60
+ 'accent-soft': 100,
61
+ success: 500,
62
+ 'success-soft': 100,
63
+ warning: 500,
64
+ 'warning-soft': 100,
65
+ danger: 500,
66
+ 'danger-soft': 100,
67
+ error: 500,
68
+ 'error-soft': 100,
69
+ info: 500,
70
+ 'info-soft': 100,
71
+ 'focus-ring': 500,
72
+ 'shadow-tint': 900
73
+ }
74
+
75
+ /**
76
+ * Maps each named token to the skin role whose palette it draws from.
77
+ */
78
+ export const NAMED_TOKEN_ROLE_MAP: Record<NamedToken, SkinRole> = {
79
+ paper: 'surface',
80
+ 'paper-soft': 'surface',
81
+ 'paper-mute': 'surface',
82
+ 'paper-edge': 'surface',
83
+ ink: 'ink',
84
+ 'ink-mute': 'ink',
85
+ 'ink-soft': 'ink',
86
+ 'ink-faint': 'ink',
87
+ primary: 'primary',
88
+ 'on-primary': 'primary',
89
+ accent: 'accent',
90
+ 'accent-soft': 'accent',
91
+ success: 'success',
92
+ 'success-soft': 'success',
93
+ warning: 'warning',
94
+ 'warning-soft': 'warning',
95
+ danger: 'danger',
96
+ 'danger-soft': 'danger',
97
+ error: 'error',
98
+ 'error-soft': 'error',
99
+ info: 'info',
100
+ 'info-soft': 'info',
101
+ 'focus-ring': 'accent',
102
+ 'shadow-tint': 'ink'
103
+ }
104
+
105
+ /**
106
+ * Surface z-slot → named slot. Used to emit back-compat aliases like
107
+ * `--color-surface-z1: var(--paper-soft);` in core mode.
108
+ *
109
+ * z2/z3 collapse to paper-mute, z5/z6 to ink-soft, z7/z8 to ink-mute,
110
+ * z9/z10 to ink. This collapse is the contract: 4 surface tones in core mode.
111
+ */
112
+ export const Z_COLLAPSE_MAP_SURFACE: Record<ZSlot, NamedToken> = {
113
+ z0: 'paper',
114
+ z1: 'paper-soft',
115
+ z2: 'paper-mute',
116
+ z3: 'paper-mute',
117
+ z4: 'paper-edge',
118
+ z5: 'ink-soft',
119
+ z6: 'ink-soft',
120
+ z7: 'ink-mute',
121
+ z8: 'ink-mute',
122
+ z9: 'ink',
123
+ z10: 'ink'
124
+ }
125
+
126
+ /**
127
+ * Ink role uses the inverted z-scale (z0 = darkest = primary text).
128
+ * In core mode, the same 4+4 named ladder is reused; this table just inverts.
129
+ */
130
+ export const Z_COLLAPSE_MAP_INK: Record<ZSlot, NamedToken> = {
131
+ z0: 'ink',
132
+ z1: 'ink-mute',
133
+ z2: 'ink-mute',
134
+ z3: 'ink-soft',
135
+ z4: 'ink-soft',
136
+ z5: 'paper-edge',
137
+ z6: 'paper-edge',
138
+ z7: 'paper-mute',
139
+ z8: 'paper-mute',
140
+ z9: 'paper-soft',
141
+ z10: 'paper'
142
+ }
143
+
144
+ const SOFT_TOKENS: Set<string> = new Set(NAMED_TOKENS.filter((t) => t.endsWith('-soft')))
145
+
146
+ /**
147
+ * Returns true when the role has a `-soft` companion in the named vocabulary.
148
+ * Used to decide whether the role's tinted-background slot collapses to
149
+ * `var(--{role}-soft)` or to `var(--{role})`.
150
+ */
151
+ export function hasSoftCompanion(role: string): boolean {
152
+ return SOFT_TOKENS.has(`${role}-soft`)
153
+ }
154
+
155
+ export function shadeForNamedToken(name: string): number | 'derived' | undefined {
156
+ return NAMED_TOKEN_SHADE_MAP[name as NamedToken]
157
+ }
158
+
159
+ export function roleForNamedToken(name: string): SkinRole | undefined {
160
+ return NAMED_TOKEN_ROLE_MAP[name as NamedToken]
161
+ }
package/src/theme.ts CHANGED
@@ -1,44 +1,35 @@
1
1
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2
2
  // @ts-nocheck
3
- import { DEFAULT_THEME_MAPPING, defaultColors, TONE_MAP } from './constants'
3
+ import { DEFAULT_THEME_MAPPING, defaultColors, TONE_MAP, INVERTED_ROLES } from './constants'
4
4
  import { shades } from './colors/index'
5
- import { colorToRgb } from './utils'
6
-
7
- const modifiers = {
8
- hsl: (value) => `hsl(${value} / <alpha-value>)`,
9
- rgb: (value) => `rgb(${value} / <alpha-value>)`,
10
- oklch: (value) => `oklch(${value} / <alpha-value>)`,
11
- none: (value) => value
12
- }
13
-
14
- /**
15
- * CSS function wrappers for each color space, used by mapVariant().
16
- * rgb uses legacy rgba() comma syntax for broad compatibility.
17
- * hsl/oklch use modern space-separated syntax with / for alpha.
18
- */
19
- const COLOR_SPACE_WRAPPERS = {
20
- rgb: (varRef) => `rgba(${varRef},<alpha-value>)`,
21
- hsl: (varRef) => `hsl(${varRef} / <alpha-value>)`,
22
- oklch: (varRef) => `oklch(${varRef} / <alpha-value>)`
23
- }
5
+ import { ColorSpace } from './color-space'
6
+ import {
7
+ NAMED_TOKENS,
8
+ NAMED_TOKEN_SHADE_MAP,
9
+ NAMED_TOKEN_ROLE_MAP,
10
+ Z_COLLAPSE_MAP_SURFACE,
11
+ Z_COLLAPSE_MAP_INK,
12
+ Z_SLOTS,
13
+ hasSoftCompanion
14
+ } from './named-tokens'
24
15
 
25
16
  /**
26
- * Generate shades for a color using css varuable
17
+ * Generate shades for a color using css variable and a ColorSpace adapter.
27
18
  *
28
19
  * @param {string} name
29
- * @param {string} modifier
30
- * @returns
20
+ * @param {string|import('./color-space').ColorSpace} space - color space name or adapter instance
21
+ * @returns {Record<string|number, string>}
31
22
  */
32
- export function shadesOf(name, modifier = 'none') {
33
- const fn = modifier in modifiers ? modifiers[modifier] : modifiers.none
23
+ export function shadesOf(name, space = 'rgb') {
24
+ const adapter = typeof space === 'string' ? ColorSpace.create(space) : space
34
25
 
35
26
  return shades.reduce(
36
27
  (result, shade) => ({
37
28
  ...result,
38
- [shade]: fn(`var(--color-${name}-${shade})`)
29
+ [shade]: adapter.themeColor(`--color-${name}-${shade}`)
39
30
  }),
40
31
  {
41
- DEFAULT: fn(`var(--color-${name}-500)`)
32
+ DEFAULT: adapter.themeColor(`--color-${name}-500`)
42
33
  }
43
34
  )
44
35
  }
@@ -49,14 +40,14 @@ export function shadesOf(name, modifier = 'none') {
49
40
  * @param {string} variant - The name of the variant to generate rules for.
50
41
  * @param {Object} colors - The object containing color definitions.
51
42
  * @param {Object} mapping - An object that maps variant names to color property names.
52
- * @param {import('./utils').ColorSpace} [colorSpace] - Color space for component format.
43
+ * @param {import('./color-space').ColorSpace} adapter - ColorSpace adapter instance.
53
44
  * @returns {import('./types').ShadeMappings} An array containing the color rules for both light and dark modes.
54
45
  */
55
- function generateColorRules(variant, colors, mapping, colorSpace) {
46
+ function generateColorRules(variant, colors, mapping, adapter) {
56
47
  return ['DEFAULT', ...shades].flatMap((shade) => [
57
48
  {
58
49
  key: shade === 'DEFAULT' ? `--color-${variant}` : `--color-${variant}-${shade}`,
59
- value: colorToRgb(colors[mapping[variant]][`${shade}`], colorSpace)
50
+ value: adapter.wrap(colors[mapping[variant]][`${shade}`])
60
51
  }
61
52
  ])
62
53
  }
@@ -66,15 +57,16 @@ function generateColorRules(variant, colors, mapping, colorSpace) {
66
57
  *
67
58
  * @param {Object} [mapping=DEFAULT_THEME_MAPPING] - An object mapping variant names to color property names.
68
59
  * @param {Object} [colors=defaultColors] - The object containing default color definitions.
69
- * @param {import('./utils').ColorSpace} [colorSpace] - Color space for CSS variable values.
60
+ * @param {string} [colorSpace] - Color space name for CSS variable values.
70
61
  * @returns {Array<Array>} An array containing two arrays, one for the light theme variant and another for the dark theme.
71
62
  */
72
63
  export function themeRules(mapping = DEFAULT_THEME_MAPPING, colors = defaultColors, colorSpace) {
73
64
  mapping = { ...DEFAULT_THEME_MAPPING, ...mapping }
74
65
  colors = { ...defaultColors, ...colors }
66
+ const adapter = ColorSpace.create(colorSpace || 'rgb')
75
67
  const variants = Object.keys(mapping)
76
68
  const rules = variants
77
- .flatMap((variant) => generateColorRules(variant, colors, mapping, colorSpace))
69
+ .flatMap((variant) => generateColorRules(variant, colors, mapping, adapter))
78
70
  .reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {})
79
71
 
80
72
  return rules
@@ -126,9 +118,11 @@ function toneShortcuts({ prefix, name, toneName, lightValue, darkValue }) {
126
118
  * @returns {Array} Array of shortcut definitions
127
119
  */
128
120
  export function semanticShortcuts(name) {
121
+ const inverted = INVERTED_ROLES.has(name)
129
122
  const shortcuts = []
130
- for (const [toneName, lightValue] of Object.entries(TONE_MAP)) {
131
- const darkValue = 1000 - lightValue
123
+ for (const [toneName, toneValue] of Object.entries(TONE_MAP)) {
124
+ const lightValue = inverted ? 1000 - toneValue : toneValue
125
+ const darkValue = inverted ? toneValue : 1000 - toneValue
132
126
  for (const prefix of SEMANTIC_PREFIXES) {
133
127
  shortcuts.push(...toneShortcuts({ prefix, name, toneName, lightValue, darkValue }))
134
128
  }
@@ -163,6 +157,7 @@ export function contrastShortcuts(name) {
163
157
  * If a semantic color is null, it inherits from another semantic color.
164
158
  */
165
159
  const COLOR_FALLBACKS = {
160
+ ink: 'surface',
166
161
  tertiary: 'primary',
167
162
  secondary: 'primary',
168
163
  accent: 'primary',
@@ -190,16 +185,16 @@ function resolveColors(mapping) {
190
185
  export class Theme {
191
186
  #colors
192
187
  #mapping
193
- #colorSpace
188
+ #adapter
194
189
 
195
190
  /**
196
191
  *
197
- * @param {import('./types.js').ColorTheme & { colorSpace?: import('./utils').ColorSpace }} param0
192
+ * @param {import('./types.js').ColorTheme & { colorSpace?: string }} param0
198
193
  */
199
194
  constructor({ colors = defaultColors, mapping = DEFAULT_THEME_MAPPING, colorSpace = 'rgb' } = {}) {
200
195
  this.#colors = { ...defaultColors, ...colors }
201
196
  this.#mapping = resolveColors({ ...DEFAULT_THEME_MAPPING, ...mapping })
202
- this.#colorSpace = colorSpace
197
+ this.#adapter = ColorSpace.create(colorSpace)
203
198
  }
204
199
 
205
200
  get colors() {
@@ -217,21 +212,20 @@ export class Theme {
217
212
  }
218
213
 
219
214
  get colorSpace() {
220
- return this.#colorSpace
215
+ return this.#adapter.name
221
216
  }
222
217
  set colorSpace(colorSpace) {
223
- this.#colorSpace = colorSpace
218
+ this.#adapter = ColorSpace.create(colorSpace)
224
219
  }
225
220
 
226
221
  mapVariant(color, variant) {
227
- const wrap = COLOR_SPACE_WRAPPERS[this.#colorSpace] || COLOR_SPACE_WRAPPERS.rgb
228
222
  return Object.keys(color).reduce(
229
223
  (acc, key) => ({
230
224
  ...acc,
231
225
  [key]:
232
226
  key === 'DEFAULT'
233
- ? wrap(`var(--color-${variant})`)
234
- : wrap(`var(--color-${variant}-${key})`)
227
+ ? this.#adapter.themeColor(`--color-${variant}`)
228
+ : this.#adapter.themeColor(`--color-${variant}-${key}`)
235
229
  }),
236
230
  {}
237
231
  )
@@ -253,11 +247,196 @@ export class Theme {
253
247
  const useColors = { ...defaultColors, ...this.#colors }
254
248
  const variants = Object.keys(useMapping)
255
249
  const rules = variants
256
- .flatMap((variant) => generateColorRules(variant, useColors, useMapping, this.#colorSpace))
250
+ .flatMap((variant) => generateColorRules(variant, useColors, useMapping, this.#adapter))
257
251
  .reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {})
258
252
  return rules
259
253
  }
260
254
 
255
+ /**
256
+ * Returns the named-token layer as a map of `--{name}: <resolved value>` entries.
257
+ *
258
+ * Palette values are inlined — there is no `var(--color-{role}-{shade})` indirection.
259
+ *
260
+ * `on-primary` is derived: it picks the paper shade (50) of the surface palette so the
261
+ * default contrast pair is "primary fill + white-ish text." Skins that need a different
262
+ * pair should override via custom tokens.
263
+ *
264
+ * `*-soft` variants resolve to shade 100 of their role's palette.
265
+ * `shadow-tint` resolves to shade 900 of the ink palette.
266
+ *
267
+ * @param {'light' | 'dark'} _mode — accepted for symmetry with future
268
+ * dark-aware derivations. Today the Theme is constructed with a mode-resolved
269
+ * mapping, so this parameter is informational.
270
+ * @param perRoleModes — optional map of role → 'core' | 'extended'. When a token's
271
+ * role is 'extended', the value is a `var(--color-{role}-{shade})` alias instead
272
+ * of the inlined palette value. Defaults to 'core' for all unspecified roles
273
+ * (and for the whole map when undefined).
274
+ */
275
+ getNamedTokens(_mode = 'light', perRoleModes?: Record<string, 'core' | 'extended'>) {
276
+ const colors = { ...defaultColors, ...this.#colors }
277
+ const result: Record<string, string> = {}
278
+ for (const name of NAMED_TOKENS) {
279
+ const value = this.#resolveNamedToken(name, colors, perRoleModes)
280
+ if (value !== undefined) result[`--${name}`] = value
281
+ }
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Resolves a single named token to its CSS value, respecting per-role mode.
287
+ * Returns undefined when the role's palette is missing.
288
+ */
289
+ #resolveNamedToken(name: string, colors: Record<string, Record<string, string>>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
290
+ const role = NAMED_TOKEN_ROLE_MAP[name]
291
+ const shadeOrDerived = NAMED_TOKEN_SHADE_MAP[name]
292
+ const paletteName = this.#mapping[role]
293
+ if (!paletteName || !colors[paletteName]) return undefined
294
+ if (shadeOrDerived === 'derived') return this.#resolveDerivedToken(name, colors, perRoleModes)
295
+ return this.#resolveShadeToken(role, shadeOrDerived as number, colors[paletteName], perRoleModes)
296
+ }
297
+
298
+ /**
299
+ * Resolves a shade-based named token value in either core (inline) or extended (alias) mode.
300
+ */
301
+ #resolveShadeToken(role: string, shade: number, palette: Record<string, string>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
302
+ const roleMode = perRoleModes?.[role] ?? 'core'
303
+ if (roleMode === 'extended') return `var(--color-${role}-${shade})`
304
+ const raw = palette[String(shade)]
305
+ return raw !== undefined ? this.#adapter.wrap(raw) : undefined
306
+ }
307
+
308
+ /**
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.
311
+ */
312
+ #resolveDerivedToken(name: string, colors: Record<string, Record<string, string>>, perRoleModes?: Record<string, 'core' | 'extended'>): string | undefined {
313
+ 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'])
322
+ }
323
+ return undefined
324
+ }
325
+
326
+ /**
327
+ * Generate z-aliases for surface or ink role using the provided map.
328
+ */
329
+ #getZAliasesFromMap(role: 'surface' | 'ink', map: Record<ZSlot, NamedToken>): Record<string, string> {
330
+ const result: Record<string, string> = {}
331
+ // Bare `--color-{role}` alias — points at the role's primary named
332
+ // slot. Lets preset-wind3 color utilities (bg-surface, from-surface,
333
+ // to-surface, etc.) resolve in core mode without forcing the full
334
+ // palette emit. Surface → paper, Ink → ink.
335
+ result[`--color-${role}`] = role === 'surface' ? 'var(--paper)' : 'var(--ink)'
336
+ for (const z of Z_SLOTS) {
337
+ result[`--color-${role}-${z}`] = `var(--${map[z]})`
338
+ }
339
+ return result
340
+ }
341
+
342
+ /**
343
+ * Generate z-aliases for other roles using tint-vs-solid 2-state collapse.
344
+ */
345
+ #getZAliasesOther(role: string): Record<string, string> {
346
+ const result: Record<string, string> = {}
347
+ const hasSoft = hasSoftCompanion(role)
348
+ const softTarget = hasSoft ? `${role}-soft` : role
349
+ // Bare `--color-{role}` alias points at the named token so
350
+ // preset-wind3 color utilities (from-{role}, to-{role}, divide-{role},
351
+ // etc.) resolve in core mode — they read `--color-{role}` and we'd
352
+ // otherwise leave it undefined since core mode skips the palette emit.
353
+ result[`--color-${role}`] = `var(--${role})`
354
+ for (const z of Z_SLOTS) {
355
+ const zNum = parseInt(z.slice(1), 10)
356
+ const target = zNum <= 2 ? softTarget : role
357
+ result[`--color-${role}-${z}`] = `var(--${target})`
358
+ }
359
+ return result
360
+ }
361
+
362
+ /**
363
+ * Returns z-alias CSS-var assignments for back-compat in core mode.
364
+ * `--color-{role}-z{n}` → `var(--{namedSlot})`.
365
+ *
366
+ * - surface: uses Z_COLLAPSE_MAP_SURFACE (z2/z3 → paper-mute, z9/z10 → ink, etc.)
367
+ * - ink: uses Z_COLLAPSE_MAP_INK (inverted scale)
368
+ * - primary/accent/status: tint-vs-solid 2-state collapse.
369
+ * z0–z2 → role-soft (if it exists) else role; z3+ → role.
370
+ *
371
+ * This is the back-compat layer: existing `@apply bg-surface-z3` etc. resolves
372
+ * through these aliases until consumers migrate to the named vocabulary.
373
+ */
374
+ /** Accepts SkinRole and any custom skin roles beyond the SkinRole literal union. */
375
+ getZAliasesForCore(role: string): Record<string, string> {
376
+ if (role === 'surface') {
377
+ return this.#getZAliasesFromMap('surface', Z_COLLAPSE_MAP_SURFACE)
378
+ }
379
+ if (role === 'ink') {
380
+ return this.#getZAliasesFromMap('ink', Z_COLLAPSE_MAP_INK)
381
+ }
382
+ return this.#getZAliasesOther(role)
383
+ }
384
+
385
+ /**
386
+ * Returns named tokens as CSS-var aliases pointing at the palette layer.
387
+ * Used in extended mode where the preset emits the full --color-{role}-{shade}
388
+ * palette; named tokens become thin syntactic aliases over the palette.
389
+ *
390
+ * `on-primary` aliases to `var(--color-surface-50)` (derived: paper of surface,
391
+ * matching the core-mode resolution).
392
+ */
393
+ getZAliasesForExtended(): Record<string, string> {
394
+ const result: Record<string, string> = {}
395
+ for (const name of NAMED_TOKENS) {
396
+ const role = NAMED_TOKEN_ROLE_MAP[name]
397
+ const shadeOrDerived = NAMED_TOKEN_SHADE_MAP[name]
398
+ if (shadeOrDerived === 'derived') {
399
+ // on-primary → surface.50
400
+ result[`--${name}`] = `var(--color-surface-50)`
401
+ continue
402
+ }
403
+ result[`--${name}`] = `var(--color-${role}-${shadeOrDerived})`
404
+ }
405
+ return result
406
+ }
407
+
408
+ /**
409
+ * Emit the full --color-{role}-{shade} palette CSS vars for a single role.
410
+ * Mirrors what getPalette does for all roles, but scoped to one.
411
+ */
412
+ getPaletteForRole(role: string): Record<string, string> {
413
+ const colors = { ...defaultColors, ...this.#colors }
414
+ const paletteName = this.#mapping[role]
415
+ if (!paletteName || !colors[paletteName]) return {}
416
+ const palette = colors[paletteName]
417
+ const result: Record<string, string> = {}
418
+ for (const shade of shades) {
419
+ if (palette[shade] !== undefined) {
420
+ result[`--color-${role}-${shade}`] = this.#adapter.wrap(palette[shade])
421
+ }
422
+ }
423
+ return result
424
+ }
425
+
426
+ /**
427
+ * Emit z-aliases for one role in extended mode — they point at the palette vars,
428
+ * using TONE_MAP for surface/non-inverted roles and the inverted map for ink.
429
+ */
430
+ getZAliasesForRoleExtended(role: string): Record<string, string> {
431
+ const result: Record<string, string> = {}
432
+ const isInverted = INVERTED_ROLES.has(role)
433
+ for (const [zone, light] of Object.entries(TONE_MAP)) {
434
+ const shade = isInverted ? 1000 - light : light
435
+ result[`--color-${role}-${zone}`] = `var(--color-${role}-${shade})`
436
+ }
437
+ return result
438
+ }
439
+
261
440
  getShortcuts(name) {
262
441
  return [...semanticShortcuts(name), ...contrastShortcuts(name)]
263
442
  }
@@ -276,15 +455,17 @@ export class Theme {
276
455
  const names = Object.keys(this.#mapping)
277
456
 
278
457
  const lightLines = names.flatMap((name) =>
279
- Object.entries(TONE_MAP).map(
280
- ([zone, light]) => ` --color-${name}-${zone}: var(--color-${name}-${light});`
281
- )
458
+ Object.entries(TONE_MAP).map(([zone, light]) => {
459
+ const value = INVERTED_ROLES.has(name) ? 1000 - light : light
460
+ return ` --color-${name}-${zone}: var(--color-${name}-${value});`
461
+ })
282
462
  )
283
463
 
284
464
  const darkLines = names.flatMap((name) =>
285
465
  Object.entries(TONE_MAP).map(([zone, light]) => {
286
466
  const dark = 1000 - light
287
- return ` --color-${name}-${zone}: var(--color-${name}-${dark});`
467
+ const value = INVERTED_ROLES.has(name) ? light : dark
468
+ return ` --color-${name}-${zone}: var(--color-${name}-${value});`
288
469
  })
289
470
  )
290
471
 
package/src/utils.js CHANGED
@@ -318,93 +318,22 @@ export function oklch2hex(L, C, H) {
318
318
  }
319
319
 
320
320
  /**
321
- * Convert hex string to HSL components: `{H} {S}% {L}%`
322
- * @param {string} hex - 6-digit hex color (with or without #)
323
- * @returns {string} space-separated HSL components for CSS `hsl(H S% L% / alpha)`
324
- */
325
- export function hex2hsl(hex) {
326
- const [r, g, b] = hex.match(/\w\w/g).map((x) => parseInt(x, 16))
327
-
328
- const rn = r / 255
329
- const gn = g / 255
330
- const bn = b / 255
331
- const max = Math.max(rn, gn, bn)
332
- const min = Math.min(rn, gn, bn)
333
- const l = (max + min) / 2
334
-
335
- if (max === min) return `0 0% ${Math.round(l * 100)}%`
336
-
337
- const d = max - min
338
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
339
-
340
- let h = 0
341
- if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6
342
- else if (max === gn) h = ((bn - rn) / d + 2) / 6
343
- else h = ((rn - gn) / d + 4) / 6
344
-
345
- const Hr = Math.round(h * 360)
346
- const Sr = Math.round(s * 100)
347
- const Lr = Math.round(l * 100)
348
-
349
- return `${Hr} ${Sr}% ${Lr}%`
350
- }
351
-
352
- /** @typedef {'rgb' | 'hsl' | 'oklch'} ColorSpace */
353
-
354
- /**
355
- * Convert a hex color to the component format for a given color space.
356
- * - rgb: "r,g,b" → for `rgba(var(--x), alpha)`
357
- * - hsl: "H S% L%" → for `hsl(var(--x) / alpha)`
358
- * - oklch: "L C H" → for `oklch(var(--x) / alpha)`
321
+ * Detects whether an icon value is a CSS class or a literal character
322
+ * (kanji, emoji, single letter, etc.).
359
323
  *
360
- * @param {string} hex - 6-digit hex color (#rrggbb)
361
- * @param {ColorSpace} space
362
- * @returns {string}
363
- */
364
- export function hexToComponents(hex, space) {
365
- switch (space) {
366
- case 'hsl':
367
- return hex2hsl(hex)
368
- case 'oklch':
369
- return hex2oklch(hex)
370
- case 'rgb':
371
- default:
372
- return hex2rgb(hex)
373
- }
374
- }
375
-
376
- /**
377
- * Convert a CSS color value to components for use in CSS variables.
378
- *
379
- * - Hex (#rrggbb): converted to space-appropriate components via hexToComponents.
380
- * - oklch(...) strings: inner components extracted (e.g. "0.58 0.15 35") so the
381
- * value is compatible with the oklch(var(--x) / alpha) wrapper pattern.
382
- * - All other formats: returned as-is (no opacity-modifier support).
324
+ * Anything more than one grapheme is treated as a class — including bare
325
+ * semantic names like `file-svelte` which UnoCSS expands to the configured
326
+ * icon collection via `iconShortcuts(DEFAULT_ICONS, …)`. Single characters
327
+ * (kanji, emoji, single letters) render as literal text.
383
328
  *
384
- * @param {unknown} value
385
- * @param {ColorSpace} [space]
386
- * @returns {unknown}
387
- */
388
- export function colorToRgb(value, space) {
389
- if (typeof value !== 'string') return value
390
- if (/^#[0-9a-fA-F]{6}$/.test(value)) {
391
- return space ? hexToComponents(value, space) : hex2rgb(value)
392
- }
393
- // oklch(...) — extract inner components so the var can be used in oklch(var(--x) / alpha)
394
- const oklchMatch = value.match(/^oklch\(\s*([^)]+?)\s*\)$/)
395
- if (oklchMatch) return oklchMatch[1]
396
- return value
397
- }
398
-
399
-
400
- /**
401
- * Detects whether an icon value is a CSS class (i-*) or literal text (kanji, emoji, etc.)
402
329
  * @param {string | null | undefined} icon
403
- * @returns {boolean} true if icon is a CSS class string
330
+ * @returns {boolean} true if icon should render as a CSS class
404
331
  */
405
332
  export function isIconClass(icon) {
406
333
  if (!icon || typeof icon !== 'string') return false
407
- return icon.startsWith('i-')
334
+ // Spread to count graphemes, not UTF-16 code units, so multi-codepoint
335
+ // emoji (e.g. 👨‍👩‍👧‍👦) and single CJK characters don't get misread.
336
+ return [...icon].length > 1
408
337
  }
409
338
 
410
339
  /**