@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.
- package/dist/src/contrast.d.ts +40 -0
- package/dist/src/doctor.d.ts +17 -0
- package/package.json +2 -2
- package/src/config.js +1 -0
- package/src/contrast.js +176 -0
- package/src/convert.js +5 -0
- package/src/doctor.js +99 -3
- package/src/init.js +4 -0
- package/src/upgrade.js +3 -0
|
@@ -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
|
+
}>;
|
package/dist/src/doctor.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
package/src/contrast.js
ADDED
|
@@ -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
|
}),
|