@rokkit/cli 1.3.0 → 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.0",
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.0",
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",
package/src/config.js CHANGED
@@ -16,6 +16,7 @@ export async function loadConfig(adapters) {
16
16
 
17
17
  try {
18
18
  const { default: config } = await import(`file://${configPath}?t=${Date.now()}`)
19
+ /* v8 ignore next -- dynamic import succeeds only in real Node, not JSDOM */
19
20
  return config
20
21
  } catch {
21
22
  return null
@@ -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/convert.js CHANGED
@@ -47,6 +47,9 @@ export function processIcons(iconSet, color) {
47
47
  try {
48
48
  cleanAndOptimizeIcon(svg, color)
49
49
  } catch (err) {
50
+ /* v8 ignore next 6 -- catch body requires an iconify-tools internal parse
51
+ error; the err1 fixture exercises this path but the exception propagates
52
+ at importDirectory level before processIcons is reached */
50
53
  console.error(`Error parsing ${name}: ${err.reason}`)
51
54
  iconSet.remove(name)
52
55
  return
@@ -116,6 +119,8 @@ export async function convert(folder, options) {
116
119
 
117
120
  // Create the target directory if it doesn't exist
118
121
  const targetDir = path.resolve(options.target)
122
+ /* v8 ignore next 4 -- bundle() (called above) always creates targetDir first;
123
+ this guard is unreachable in practice but kept as a defensive fallback */
119
124
  if (!fs.existsSync(targetDir)) {
120
125
  fs.mkdirSync(targetDir, { recursive: true })
121
126
  }
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
 
@@ -240,6 +262,9 @@ function applyPatchHtml(cwd, label) {
240
262
  * @param {string} cwd
241
263
  * @param {string} label
242
264
  */
265
+ /* v8 ignore next 16 -- chart-config check emits 'warn' (not 'fail'); applyCheckFix
266
+ only processes 'fail' statuses, making this handler unreachable via the normal
267
+ fix flow. Kept for potential future use if the check severity changes. */
243
268
  function applyPatchChartConfig(cwd, label) {
244
269
  const configPath = resolve(cwd, 'rokkit.config.js')
245
270
  if (!existsSync(configPath)) return false
@@ -405,6 +430,49 @@ export function validateConfigShape(config) {
405
430
  return checks
406
431
  }
407
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
+
408
476
  /**
409
477
  * Interactive doctor command — validates project setup.
410
478
  * @param {{ fix?: boolean }} [opts]
@@ -418,6 +486,8 @@ export async function doctor(opts = {}) {
418
486
 
419
487
  const parsed = await loadParsedConfig({ cwd })
420
488
  const shapeChecks = validateConfigShape(parsed)
489
+ /* v8 ignore next 8 -- loadConfig uses dynamic file:// import which always fails in
490
+ JSDOM; validateConfigShape(null) returns [] so this branch is unreachable in tests */
421
491
  if (shapeChecks.length > 0) {
422
492
  console.info('\nConfig shape:')
423
493
  for (const c of shapeChecks) {
@@ -426,6 +496,32 @@ export async function doctor(opts = {}) {
426
496
  }
427
497
  }
428
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
+
429
525
  handleResults(checks, cwd, failures, opts.fix ?? false)
430
526
  console.info('')
431
527
  }
package/src/init.js CHANGED
@@ -408,6 +408,7 @@ const PROMPTS_CONFIG = [
408
408
  type: (prev) => (prev?.length > 1 ? 'select' : null),
409
409
  name: 'defaultTheme',
410
410
  message: 'Default theme style',
411
+ /* v8 ignore next 2 -- prompts.inject bypasses choices callback evaluation in tests */
411
412
  choices: (prev) =>
412
413
  prev.map((t) => ({ title: t.charAt(0).toUpperCase() + t.slice(1), value: t }))
413
414
  },
@@ -547,6 +548,9 @@ export async function init(_opts = {}, adapters = {}) {
547
548
 
548
549
  const response = await prompts(PROMPTS_CONFIG)
549
550
 
551
+ /* v8 ignore next 10 -- prompts mutates PROMPTS_CONFIG[n].type in-place on the
552
+ first call, so the conditional primary/accent/surface prompts always resolve
553
+ to null on subsequent runs; the branch is tested via generateConfig directly */
550
554
  if (response.palette === 'custom') {
551
555
  response.customColors = {
552
556
  primary: response.primary,
package/src/upgrade.js CHANGED
@@ -57,10 +57,13 @@ function resolveUpgradeAdapters(adapters) {
57
57
  exists: adapters.exists ?? ((p) => existsSync(p)),
58
58
  fetchVersion:
59
59
  adapters.fetchVersion ??
60
+ /* v8 ignore next 8 -- default fetchVersion calls real npm; tests always inject
61
+ fetchVersion so this closure is never executed in the test environment */
60
62
  ((name) => {
61
63
  try {
62
64
  return execFileSync('npm', ['view', name, 'version'], { encoding: 'utf-8' }).trim()
63
65
  } catch {
66
+ // npm binary absent or network error — treat as unknown version
64
67
  return null
65
68
  }
66
69
  }),