@rokkit/cli 1.3.1 → 1.3.2

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,40 @@
1
+ /**
2
+ * Parse a colour value into OKLCH components [L, C, H].
3
+ * Accepts a palette ref ("kami.400"), a bare "L C H" string ("0.985 0.005 85"),
4
+ * or an `oklch(L C H)` function. Returns null for anything else (e.g. hex/rgb).
5
+ * @param {string} value
6
+ * @param {Record<string, Record<string|number,string>>} palettes
7
+ * @returns {[number, number, number] | null}
8
+ */
9
+ export function parseColor(value: string, palettes?: Record<string, Record<string | number, string>>): [number, number, number] | null;
10
+ /**
11
+ * OKLCH → linear sRGB (the space WCAG relative luminance is defined in).
12
+ * @param {[number, number, number]} oklch
13
+ * @returns {[number, number, number]} linear sRGB, each clamped to [0,1]
14
+ */
15
+ export function oklchToLinearSrgb([L, C, H]: [number, number, number]): [number, number, number];
16
+ /**
17
+ * WCAG relative luminance from OKLCH components.
18
+ * @param {[number, number, number]} oklch
19
+ * @returns {number}
20
+ */
21
+ export function relativeLuminance(oklch: [number, number, number]): number;
22
+ /**
23
+ * WCAG contrast ratio between two OKLCH colours.
24
+ * @param {[number, number, number]} a
25
+ * @param {[number, number, number]} b
26
+ * @returns {number}
27
+ */
28
+ export function contrastRatio(a: [number, number, number], b: [number, number, number]): number;
29
+ /**
30
+ * Verify text/border contrast tokens from the parsed config, in light + dark.
31
+ * @param {Record<string, any> | null} config
32
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
33
+ */
34
+ export function checkContrastTokens(config: Record<string, any> | null): Array<{
35
+ id: string;
36
+ label: string;
37
+ status: "warn";
38
+ fixable: false;
39
+ fix: string;
40
+ }>;
@@ -32,6 +32,23 @@ export function validateConfigShape(config: Record<string, unknown> | null): Arr
32
32
  fixable: false;
33
33
  fix: string;
34
34
  }>;
35
+ /**
36
+ * Advisory lint: scan src/ for surface/border tokens used as text colour.
37
+ * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
38
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
39
+ */
40
+ export function checkTextTokenUsage(fs: {
41
+ list?: (dir: string) => string[];
42
+ exists: (p: string) => boolean;
43
+ read: (p: string) => string;
44
+ resolve: (p: string) => string;
45
+ }): Array<{
46
+ id: string;
47
+ label: string;
48
+ status: "warn";
49
+ fixable: false;
50
+ fix: string;
51
+ }>;
35
52
  /**
36
53
  * Interactive doctor command — validates project setup.
37
54
  * @param {{ fix?: boolean }} [opts]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/cli",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Command line utilities for icon bundling and conversion.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@mdi/svg": "^7.4.47",
30
- "@rokkit/unocss": "1.3.1",
30
+ "@rokkit/unocss": "1.3.2",
31
31
  "sade": "^1.8.1",
32
32
  "@iconify/tools": "5.0.3",
33
33
  "@iconify/types": "^2.0.0",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Static contrast checks for `rokkit doctor`.
3
+ *
4
+ * Computes real WCAG contrast (not just OKLCH lightness diff) from the project's
5
+ * configured palettes, in BOTH light and dark modes, and verifies:
6
+ * - `ink` and `ink-mute` (primary + secondary text) clear AA 4.5 on `paper`,
7
+ * - the text ramp is monotonic: ink > ink-mute > ink-soft > ink-faint,
8
+ * - `paper-edge` (the hairline border tone) is actually visible against `paper`
9
+ * (catches the "near-black etched edge invisible in dark" class of bug).
10
+ *
11
+ * All findings are advisory (`warn`) — a project's palette is the author's call;
12
+ * the doctor surfaces problems, it doesn't block.
13
+ */
14
+
15
+ const PALETTE_REF = /^([a-z][\w-]*)\.(\d{2,3})$/i
16
+
17
+ /**
18
+ * Parse a colour value into OKLCH components [L, C, H].
19
+ * Accepts a palette ref ("kami.400"), a bare "L C H" string ("0.985 0.005 85"),
20
+ * or an `oklch(L C H)` function. Returns null for anything else (e.g. hex/rgb).
21
+ * @param {string} value
22
+ * @param {Record<string, Record<string|number,string>>} palettes
23
+ * @returns {[number, number, number] | null}
24
+ */
25
+ export function parseColor(value, palettes = {}) {
26
+ if (typeof value !== 'string') return null
27
+ let raw = value.trim()
28
+ const ref = raw.match(PALETTE_REF)
29
+ if (ref) {
30
+ const shade = palettes[ref[1]]?.[ref[2]]
31
+ if (!shade) return null
32
+ raw = String(shade).trim()
33
+ }
34
+ const fn = raw.match(/^oklch\(\s*([^)]+)\)$/i)
35
+ if (fn) raw = fn[1].trim()
36
+ const parts = raw.split(/[\s,/]+/).filter(Boolean).map(Number)
37
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return null
38
+ return [parts[0], parts[1], parts[2]]
39
+ }
40
+
41
+ /**
42
+ * OKLCH → linear sRGB (the space WCAG relative luminance is defined in).
43
+ * @param {[number, number, number]} oklch
44
+ * @returns {[number, number, number]} linear sRGB, each clamped to [0,1]
45
+ */
46
+ export function oklchToLinearSrgb([L, C, H]) {
47
+ const hr = (H * Math.PI) / 180
48
+ const a = C * Math.cos(hr)
49
+ const b = C * Math.sin(hr)
50
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b
51
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b
52
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b
53
+ const l = l_ ** 3
54
+ const m = m_ ** 3
55
+ const s = s_ ** 3
56
+ const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
57
+ const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
58
+ const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
59
+ const clamp = (v) => Math.max(0, Math.min(1, v))
60
+ return [clamp(r), clamp(g), clamp(bl)]
61
+ }
62
+
63
+ /**
64
+ * WCAG relative luminance from OKLCH components.
65
+ * @param {[number, number, number]} oklch
66
+ * @returns {number}
67
+ */
68
+ export function relativeLuminance(oklch) {
69
+ const [r, g, b] = oklchToLinearSrgb(oklch)
70
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
71
+ }
72
+
73
+ /**
74
+ * WCAG contrast ratio between two OKLCH colours.
75
+ * @param {[number, number, number]} a
76
+ * @param {[number, number, number]} b
77
+ * @returns {number}
78
+ */
79
+ export function contrastRatio(a, b) {
80
+ const la = relativeLuminance(a)
81
+ const lb = relativeLuminance(b)
82
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
83
+ }
84
+
85
+ /** Pick a skin role's palette name for a mode (handles `{light,dark}` duals). */
86
+ function roleName(skin, role, mode) {
87
+ const v = skin?.[role]
88
+ if (v && typeof v === 'object' && !Array.isArray(v)) return mode === 'dark' ? v.dark : v.light
89
+ return v
90
+ }
91
+
92
+ /**
93
+ * Resolve a named token's OKLCH for a mode: config `overrides` win, else the
94
+ * role palette at the token's shade.
95
+ */
96
+ function resolveToken(config, skin, role, shade, name, mode) {
97
+ const ov = config.overrides?.[name]
98
+ if (ov !== undefined) {
99
+ const v = ov && typeof ov === 'object' && !Array.isArray(ov) ? (mode === 'dark' ? ov.dark : ov.light) : ov
100
+ return parseColor(v, config.palettes)
101
+ }
102
+ const pal = config.palettes?.[roleName(skin, role, mode)]
103
+ return pal ? parseColor(String(pal[shade]), config.palettes) : null
104
+ }
105
+
106
+ const AA = 4.5
107
+
108
+ /**
109
+ * Verify text/border contrast tokens from the parsed config, in light + dark.
110
+ * @param {Record<string, any> | null} config
111
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
112
+ */
113
+ export function checkContrastTokens(config) {
114
+ if (!config?.palettes) return []
115
+ const skin = config.skins?.default ?? config.skin ?? config.colors
116
+ if (!skin || !skin.ink || !skin.surface) return []
117
+
118
+ const findings = []
119
+ for (const mode of ['light', 'dark']) {
120
+ const paper = resolveToken(config, skin, 'surface', 50, 'paper', mode)
121
+ if (!paper) continue
122
+ const ramp = [
123
+ ['ink', 900],
124
+ ['ink-mute', 700],
125
+ ['ink-soft', 500],
126
+ ['ink-faint', 300]
127
+ ].map(([name, shade]) => ({ name, c: (() => {
128
+ const tok = resolveToken(config, skin, 'ink', shade, name, mode)
129
+ return tok ? contrastRatio(tok, paper) : null
130
+ })() }))
131
+
132
+ // AA gate on the readable text tones.
133
+ for (const { name, c } of ramp) {
134
+ if ((name === 'ink' || name === 'ink-mute') && c !== null && c < AA) {
135
+ findings.push({
136
+ id: `contrast-${name}-${mode}`,
137
+ label: `${name} on paper clears AA in ${mode}`,
138
+ status: 'warn',
139
+ fixable: false,
140
+ fix: `${name} vs paper is ${c.toFixed(2)}:1 in ${mode} (need ≥ ${AA}). It backs ${name === 'ink' ? 'primary' : 'secondary'} text — darken ${name} (or lighten paper) in this mode.`
141
+ })
142
+ }
143
+ }
144
+
145
+ // Monotonic ramp: ink > ink-mute > ink-soft > ink-faint.
146
+ const seq = ramp.filter((r) => r.c !== null)
147
+ for (let i = 1; i < seq.length; i++) {
148
+ if (seq[i].c > seq[i - 1].c + 0.01) {
149
+ findings.push({
150
+ id: `contrast-ramp-${mode}`,
151
+ label: `ink ramp is monotonic in ${mode}`,
152
+ status: 'warn',
153
+ fixable: false,
154
+ fix: `${seq[i].name} (${seq[i].c.toFixed(2)}:1) has MORE contrast than ${seq[i - 1].name} (${seq[i - 1].c.toFixed(2)}:1) in ${mode} — the ramp ink > ink-mute > ink-soft > ink-faint is inverted/compressed.`
155
+ })
156
+ break
157
+ }
158
+ }
159
+
160
+ // Border visibility: paper-edge must be distinguishable from paper.
161
+ const edge = resolveToken(config, skin, 'surface', 400, 'paper-edge', mode)
162
+ if (edge) {
163
+ const ec = contrastRatio(edge, paper)
164
+ if (ec < 1.18) {
165
+ findings.push({
166
+ id: `contrast-paper-edge-${mode}`,
167
+ label: `paper-edge is visible against paper in ${mode}`,
168
+ status: 'warn',
169
+ fixable: false,
170
+ fix: `paper-edge vs paper is only ${ec.toFixed(2)}:1 in ${mode} — the hairline border is effectively invisible. Pick a paper-edge value with more lightness separation from the canvas in this mode.`
171
+ })
172
+ }
173
+ }
174
+ }
175
+ return findings
176
+ }
package/src/doctor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable no-console */
2
- import { existsSync, readFileSync, writeFileSync } from 'fs'
3
- import { resolve } from 'path'
2
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs'
3
+ import { resolve, join } from 'path'
4
4
  import { loadConfig as loadParsedConfig } from './config.js'
5
5
  import {
6
6
  generateUnoConfig,
@@ -10,6 +10,7 @@ import {
10
10
  serializeRokkitConfig
11
11
  } from './init.js'
12
12
  import { themeInitScript } from '@rokkit/unocss/hooks'
13
+ import { checkContrastTokens } from './contrast.js'
13
14
 
14
15
  const KNOWN_THEMES = [
15
16
  'rokkit',
@@ -169,6 +170,26 @@ export function runChecks(fs) {
169
170
  ]
170
171
  }
171
172
 
173
+ const SKIP_DIRS = new Set(['node_modules', '.svelte-kit', 'dist', '.git', 'coverage'])
174
+
175
+ /**
176
+ * Recursively list absolute file paths under `dir`, skipping vendored/build dirs.
177
+ * @param {string} dir
178
+ * @returns {string[]}
179
+ */
180
+ function listFiles(dir) {
181
+ const out = []
182
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
183
+ if (entry.isDirectory()) {
184
+ if (SKIP_DIRS.has(entry.name)) continue
185
+ out.push(...listFiles(join(dir, entry.name)))
186
+ } else {
187
+ out.push(join(dir, entry.name))
188
+ }
189
+ }
190
+ return out
191
+ }
192
+
172
193
  /**
173
194
  * Create a real filesystem adapter rooted at cwd.
174
195
  * @param {string} cwd
@@ -177,7 +198,8 @@ function createFsAdapter(cwd) {
177
198
  return {
178
199
  exists: (p) => existsSync(p),
179
200
  read: (p) => readFileSync(p, 'utf-8'),
180
- resolve: (p) => resolve(cwd, p)
201
+ resolve: (p) => resolve(cwd, p),
202
+ list: (dir) => listFiles(dir)
181
203
  }
182
204
  }
183
205
 
@@ -408,6 +430,49 @@ export function validateConfigShape(config) {
408
430
  return checks
409
431
  }
410
432
 
433
+ /**
434
+ * Patterns that flag a surface/border token being used as a text colour.
435
+ * paper-edge / paper-mute are border/surface tones — used as text they're faint
436
+ * in light and invisible in dark. paper / paper-soft are legitimate on-colours.
437
+ */
438
+ const TEXT_MISUSE_PATTERNS = [
439
+ /text-paper-edge\b/,
440
+ /text-paper-mute\b/,
441
+ /color:\s*var\(\s*--paper-edge\s*\)/,
442
+ /color:\s*var\(\s*--paper-mute\s*\)/
443
+ ]
444
+
445
+ /**
446
+ * Advisory lint: scan src/ for surface/border tokens used as text colour.
447
+ * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
448
+ * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
449
+ */
450
+ export function checkTextTokenUsage(fs) {
451
+ if (typeof fs.list !== 'function' || !fs.exists(fs.resolve('src'))) return []
452
+
453
+ const cwd = process.cwd()
454
+ const hits = []
455
+ for (const file of fs.list(fs.resolve('src'))) {
456
+ if (!file.endsWith('.css') && !file.endsWith('.svelte')) continue
457
+ const lines = fs.read(file).split('\n')
458
+ const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
459
+ lines.forEach((line, i) => {
460
+ if (TEXT_MISUSE_PATTERNS.some((re) => re.test(line))) hits.push(`${display}:${i + 1}`)
461
+ })
462
+ }
463
+
464
+ if (hits.length === 0) return []
465
+ return [
466
+ {
467
+ id: 'surface-token-as-text',
468
+ label: 'no surface/border token used as text colour',
469
+ status: 'warn',
470
+ fixable: false,
471
+ fix: `paper-edge / paper-mute are border/surface tokens — never use them for text (faint in light, invisible in dark). Use the ink scale (ink-mute for readable secondary text). Found at:\n ${hits.join('\n ')}`
472
+ }
473
+ ]
474
+ }
475
+
411
476
  /**
412
477
  * Interactive doctor command — validates project setup.
413
478
  * @param {{ fix?: boolean }} [opts]
@@ -431,6 +496,32 @@ export async function doctor(opts = {}) {
431
496
  }
432
497
  }
433
498
 
499
+ const contrastChecks = checkContrastTokens(parsed)
500
+ /* v8 ignore start -- loadConfig uses a dynamic file:// import which always fails in
501
+ JSDOM, so parsed is null and contrastChecks is [] → this print block is unreachable
502
+ in tests. The check logic itself is covered directly in contrast.spec.js. */
503
+ if (contrastChecks.length > 0) {
504
+ console.info('\nContrast (WCAG AA · light + dark):')
505
+ for (const c of contrastChecks) {
506
+ console.info(` WARN ${c.label}`)
507
+ console.info(` ${c.fix}`)
508
+ }
509
+ }
510
+ /* v8 ignore stop */
511
+
512
+ const usageChecks = checkTextTokenUsage(createFsAdapter(cwd))
513
+ /* v8 ignore start -- this print block depends on the real filesystem walk finding
514
+ a misused token in cwd; in the JSDOM doctor() test the temp project is clean, so
515
+ usageChecks is [] and this is unreachable. checkTextTokenUsage is covered directly. */
516
+ if (usageChecks.length > 0) {
517
+ console.info('\nToken usage:')
518
+ for (const c of usageChecks) {
519
+ console.info(` WARN ${c.label}`)
520
+ console.info(` ${c.fix}`)
521
+ }
522
+ }
523
+ /* v8 ignore stop */
524
+
434
525
  handleResults(checks, cwd, failures, opts.fix ?? false)
435
526
  console.info('')
436
527
  }