@rokkit/cli 1.4.0 → 1.4.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/agents.d.ts +2 -2
- package/dist/src/init.d.ts +402 -17
- package/dist/src/skills.d.ts +1 -1
- package/dist/src/validate.d.ts +8 -27
- package/package.json +6 -5
- package/src/agents.js +73 -40
- package/src/contrast.js +141 -82
- package/src/convert.js +4 -2
- package/src/doctor.js +29 -36
- package/src/init.js +126 -87
- package/src/skills.js +51 -25
- package/src/validate.js +59 -41
package/src/contrast.js
CHANGED
|
@@ -24,20 +24,38 @@ const PALETTE_REF = /^([a-z][\w-]*)\.(\d{2,3})$/i
|
|
|
24
24
|
*/
|
|
25
25
|
export function parseColor(value, palettes = {}) {
|
|
26
26
|
if (typeof value !== 'string') return null
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
|
27
|
+
const raw = resolvePaletteRef(value.trim(), palettes)
|
|
28
|
+
if (raw === null) return null
|
|
29
|
+
const parts = stripColorFn(raw).split(/[\s,/]+/).filter(Boolean).map(Number)
|
|
37
30
|
if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) return null
|
|
38
31
|
return [parts[0], parts[1], parts[2]]
|
|
39
32
|
}
|
|
40
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Follow a `"kami.400"` palette reference to its value. Non-references pass
|
|
36
|
+
* through unchanged; a reference pointing at a missing shade returns null,
|
|
37
|
+
* which the caller treats as unparseable.
|
|
38
|
+
* @param {string} raw
|
|
39
|
+
* @param {Record<string, Record<string|number,string>>} palettes
|
|
40
|
+
* @returns {string | null}
|
|
41
|
+
*/
|
|
42
|
+
function resolvePaletteRef(raw, palettes) {
|
|
43
|
+
const ref = raw.match(PALETTE_REF)
|
|
44
|
+
if (!ref) return raw
|
|
45
|
+
const shade = palettes[ref[1]]?.[ref[2]]
|
|
46
|
+
return shade ? String(shade).trim() : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Unwrap `oklch(L C H)` to its bare channels; anything else passes through.
|
|
51
|
+
* @param {string} raw
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function stripColorFn(raw) {
|
|
55
|
+
const fn = raw.match(/^oklch\(\s*([^)]+)\)$/i)
|
|
56
|
+
return fn ? fn[1].trim() : raw
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/**
|
|
42
60
|
* OKLCH → linear sRGB (the space WCAG relative luminance is defined in).
|
|
43
61
|
* @param {[number, number, number]} oklch
|
|
@@ -82,28 +100,127 @@ export function contrastRatio(a, b) {
|
|
|
82
100
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
|
|
83
101
|
}
|
|
84
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Unwrap a `{ light, dark }` dual to the value for `mode`; scalars pass through.
|
|
105
|
+
* Both skin roles and config overrides accept the dual form, so this is the one
|
|
106
|
+
* place that knows the shape.
|
|
107
|
+
* @param {unknown} value
|
|
108
|
+
* @param {string} mode
|
|
109
|
+
*/
|
|
110
|
+
function pickMode(value, mode) {
|
|
111
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
112
|
+
return mode === 'dark' ? value.dark : value.light
|
|
113
|
+
}
|
|
114
|
+
return value
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
/** Pick a skin role's palette name for a mode (handles `{light,dark}` duals). */
|
|
86
118
|
function roleName(skin, role, mode) {
|
|
87
|
-
|
|
88
|
-
if (v && typeof v === 'object' && !Array.isArray(v)) return mode === 'dark' ? v.dark : v.light
|
|
89
|
-
return v
|
|
119
|
+
return pickMode(skin?.[role], mode)
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
/**
|
|
93
123
|
* Resolve a named token's OKLCH for a mode: config `overrides` win, else the
|
|
94
124
|
* role palette at the token's shade.
|
|
125
|
+
* @param {{ config: Record<string, any>, skin: Record<string, any>, role: string,
|
|
126
|
+
* shade: number, name: string, mode: string }} args
|
|
95
127
|
*/
|
|
96
|
-
function resolveToken(config, skin, role, shade, name, mode) {
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
const pal = config.palettes?.[roleName(skin, role, mode)]
|
|
103
|
-
return pal ? parseColor(String(pal[shade]), config.palettes) : null
|
|
128
|
+
function resolveToken({ config, skin, role, shade, name, mode }) {
|
|
129
|
+
const override = config.overrides?.[name]
|
|
130
|
+
if (override !== undefined) return parseColor(pickMode(override, mode), config.palettes)
|
|
131
|
+
const palette = config.palettes?.[roleName(skin, role, mode)]
|
|
132
|
+
return palette ? parseColor(String(palette[shade]), config.palettes) : null
|
|
104
133
|
}
|
|
105
134
|
|
|
106
135
|
const AA = 4.5
|
|
136
|
+
/** Below this ratio a hairline border is effectively invisible against the canvas. */
|
|
137
|
+
const EDGE_MIN = 1.18
|
|
138
|
+
/** Float slack, so noise in the ramp doesn't read as a real inversion. */
|
|
139
|
+
const RAMP_SLACK = 0.01
|
|
140
|
+
/** The ink ramp, darkest first — the order the monotonic check asserts. */
|
|
141
|
+
const INK_RAMP = [
|
|
142
|
+
['ink', 900],
|
|
143
|
+
['ink-mute', 700],
|
|
144
|
+
['ink-soft', 500],
|
|
145
|
+
['ink-faint', 300]
|
|
146
|
+
]
|
|
147
|
+
/** The two ink tones that carry readable text, and so must clear AA. */
|
|
148
|
+
const READABLE = new Set(['ink', 'ink-mute'])
|
|
149
|
+
|
|
150
|
+
/** The skin block, under whichever of the three accepted config keys holds it. */
|
|
151
|
+
function skinOf(config) {
|
|
152
|
+
return config.skins?.default ?? config.skin ?? config.colors
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Each ink tone's contrast against paper, in ramp order; null where unresolvable. */
|
|
156
|
+
function inkRamp(config, skin, paper, mode) {
|
|
157
|
+
return INK_RAMP.map(([name, shade]) => {
|
|
158
|
+
const token = resolveToken({ config, skin, role: 'ink', shade, name, mode })
|
|
159
|
+
return { name, c: token ? contrastRatio(token, paper) : null }
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** AA gate on the tones that actually carry text. */
|
|
164
|
+
function checkReadableText(ramp, mode) {
|
|
165
|
+
return ramp
|
|
166
|
+
.filter(({ name, c }) => READABLE.has(name) && c !== null && c < AA)
|
|
167
|
+
.map(({ name, c }) => ({
|
|
168
|
+
id: `contrast-${name}-${mode}`,
|
|
169
|
+
label: `${name} on paper clears AA in ${mode}`,
|
|
170
|
+
status: 'warn',
|
|
171
|
+
fixable: false,
|
|
172
|
+
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.`
|
|
173
|
+
}))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ink > ink-mute > ink-soft > ink-faint. Reports the FIRST inversion only —
|
|
178
|
+
* once the ramp is out of order every later pair is suspect too, and four
|
|
179
|
+
* findings for one mistake is noise.
|
|
180
|
+
*/
|
|
181
|
+
function checkRampMonotonic(ramp, mode) {
|
|
182
|
+
const seq = ramp.filter((r) => r.c !== null)
|
|
183
|
+
const broken = seq.findIndex((r, i) => i > 0 && r.c > seq[i - 1].c + RAMP_SLACK)
|
|
184
|
+
if (broken < 0) return []
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
id: `contrast-ramp-${mode}`,
|
|
188
|
+
label: `ink ramp is monotonic in ${mode}`,
|
|
189
|
+
status: 'warn',
|
|
190
|
+
fixable: false,
|
|
191
|
+
fix: `${seq[broken].name} (${seq[broken].c.toFixed(2)}:1) has MORE contrast than ${seq[broken - 1].name} (${seq[broken - 1].c.toFixed(2)}:1) in ${mode} — the ramp ink > ink-mute > ink-soft > ink-faint is inverted/compressed.`
|
|
192
|
+
}
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** paper-edge must be distinguishable from paper, or the hairline border vanishes. */
|
|
197
|
+
function checkEdgeVisible(config, skin, paper, mode) {
|
|
198
|
+
const edge = resolveToken({ config, skin, role: 'surface', shade: 400, name: 'paper-edge', mode })
|
|
199
|
+
if (!edge) return []
|
|
200
|
+
const ratio = contrastRatio(edge, paper)
|
|
201
|
+
if (ratio >= EDGE_MIN) return []
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
id: `contrast-paper-edge-${mode}`,
|
|
205
|
+
label: `paper-edge is visible against paper in ${mode}`,
|
|
206
|
+
status: 'warn',
|
|
207
|
+
fixable: false,
|
|
208
|
+
fix: `paper-edge vs paper is only ${ratio.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.`
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** All three checks for one mode. */
|
|
214
|
+
function checksForMode(config, skin, mode) {
|
|
215
|
+
const paper = resolveToken({ config, skin, role: 'surface', shade: 50, name: 'paper', mode })
|
|
216
|
+
if (!paper) return []
|
|
217
|
+
const ramp = inkRamp(config, skin, paper, mode)
|
|
218
|
+
return [
|
|
219
|
+
...checkReadableText(ramp, mode),
|
|
220
|
+
...checkRampMonotonic(ramp, mode),
|
|
221
|
+
...checkEdgeVisible(config, skin, paper, mode)
|
|
222
|
+
]
|
|
223
|
+
}
|
|
107
224
|
|
|
108
225
|
/**
|
|
109
226
|
* Verify text/border contrast tokens from the parsed config, in light + dark.
|
|
@@ -112,65 +229,7 @@ const AA = 4.5
|
|
|
112
229
|
*/
|
|
113
230
|
export function checkContrastTokens(config) {
|
|
114
231
|
if (!config?.palettes) return []
|
|
115
|
-
const skin = config
|
|
116
|
-
if (!skin
|
|
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
|
|
232
|
+
const skin = skinOf(config)
|
|
233
|
+
if (!skin?.ink || !skin.surface) return []
|
|
234
|
+
return ['light', 'dark'].flatMap((mode) => checksForMode(config, skin, mode))
|
|
176
235
|
}
|
package/src/convert.js
CHANGED
|
@@ -47,12 +47,14 @@ export function processIcons(iconSet, color) {
|
|
|
47
47
|
try {
|
|
48
48
|
cleanAndOptimizeIcon(svg, color)
|
|
49
49
|
} catch (err) {
|
|
50
|
-
/* v8 ignore
|
|
50
|
+
/* v8 ignore start -- catch body requires an iconify-tools internal parse
|
|
51
51
|
error; the err1 fixture exercises this path but the exception propagates
|
|
52
|
-
at importDirectory level before processIcons is reached
|
|
52
|
+
at importDirectory level before processIcons is reached. Uses start/stop
|
|
53
|
+
because `ignore next N` no longer fires under vitest 4's AST remapping. */
|
|
53
54
|
console.error(`Error parsing ${name}: ${err.reason}`)
|
|
54
55
|
iconSet.remove(name)
|
|
55
56
|
return
|
|
57
|
+
/* v8 ignore stop */
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
iconSet.fromSVG(name, svg)
|
package/src/doctor.js
CHANGED
|
@@ -30,43 +30,36 @@ export async function doctor(opts = {}) {
|
|
|
30
30
|
const failures = printChecks(checks)
|
|
31
31
|
|
|
32
32
|
const parsed = await loadParsedConfig({ cwd })
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (shapeChecks.length > 0) {
|
|
37
|
-
console.info('\nConfig shape:')
|
|
38
|
-
for (const c of shapeChecks) {
|
|
39
|
-
console.info(` WARN ${c.label}`)
|
|
40
|
-
console.info(` ${c.fix}`)
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const contrastChecks = checkContrastTokens(parsed)
|
|
45
|
-
/* v8 ignore start -- loadConfig uses a dynamic file:// import which always fails in
|
|
46
|
-
JSDOM, so parsed is null and contrastChecks is [] → this print block is unreachable
|
|
47
|
-
in tests. The check logic itself is covered directly in contrast.spec.js. */
|
|
48
|
-
if (contrastChecks.length > 0) {
|
|
49
|
-
console.info('\nContrast (WCAG AA · light + dark):')
|
|
50
|
-
for (const c of contrastChecks) {
|
|
51
|
-
console.info(` WARN ${c.label}`)
|
|
52
|
-
console.info(` ${c.fix}`)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
/* v8 ignore stop */
|
|
56
|
-
|
|
57
|
-
const usageChecks = checkTextTokenUsage(createFsAdapter(cwd))
|
|
58
|
-
/* v8 ignore start -- this print block depends on the real filesystem walk finding
|
|
59
|
-
a misused token in cwd; in the JSDOM doctor() test the temp project is clean, so
|
|
60
|
-
usageChecks is [] and this is unreachable. checkTextTokenUsage is covered directly. */
|
|
61
|
-
if (usageChecks.length > 0) {
|
|
62
|
-
console.info('\nToken usage:')
|
|
63
|
-
for (const c of usageChecks) {
|
|
64
|
-
console.info(` WARN ${c.label}`)
|
|
65
|
-
console.info(` ${c.fix}`)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
/* v8 ignore stop */
|
|
33
|
+
printAdvisories('Config shape', validateConfigShape(parsed))
|
|
34
|
+
printAdvisories('Contrast (WCAG AA · light + dark)', checkContrastTokens(parsed))
|
|
35
|
+
printAdvisories('Token usage', checkTextTokenUsage(createFsAdapter(cwd)))
|
|
69
36
|
|
|
70
37
|
handleResults(checks, cwd, failures, opts.fix ?? false)
|
|
71
38
|
console.info('')
|
|
72
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Print one titled advisory section, or nothing at all when there is nothing to
|
|
43
|
+
* report. Replaces three identical if/heading/loop blocks — and, with them,
|
|
44
|
+
* three separate coverage-ignore comments saying the same thing.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} title
|
|
47
|
+
* @param {Array<{ label: string, fix: string }>} findings
|
|
48
|
+
*/
|
|
49
|
+
/* v8 ignore start -- every caller is empty under test: loadConfig uses a dynamic
|
|
50
|
+
file:// import that always fails in JSDOM (so parsed is null and both config-derived
|
|
51
|
+
lists are []), and the doctor() test's temp project is clean (so the usage walk finds
|
|
52
|
+
nothing), which means this never gets past its own guard. The check logic itself is
|
|
53
|
+
covered directly in contrast.spec.js and via validateConfigShape /
|
|
54
|
+
checkTextTokenUsage. The ignore wraps the WHOLE function rather than a counted line
|
|
55
|
+
range: with an early return the function's tail is unreached too, and a `next N` count
|
|
56
|
+
silently drifts the moment a line is added. */
|
|
57
|
+
function printAdvisories(title, findings) {
|
|
58
|
+
if (findings.length === 0) return
|
|
59
|
+
console.info(`\n${title}:`)
|
|
60
|
+
for (const c of findings) {
|
|
61
|
+
console.info(` WARN ${c.label}`)
|
|
62
|
+
console.info(` ${c.fix}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/* v8 ignore stop */
|
package/src/init.js
CHANGED
|
@@ -222,36 +222,59 @@ export function generateChartConfig({ chartColors, chartShades }) {
|
|
|
222
222
|
* storageKey?: string }} [opts]
|
|
223
223
|
* @returns {Record<string, unknown>}
|
|
224
224
|
*/
|
|
225
|
+
/**
|
|
226
|
+
* The zen-sumi design decisions themselves — fixed, so they live outside the
|
|
227
|
+
* function rather than being rebuilt per call alongside the user's answers.
|
|
228
|
+
*/
|
|
229
|
+
const ZEN_SUMI_BASE = {
|
|
230
|
+
palettes: ZEN_SUMI_PALETTES,
|
|
231
|
+
colorSpace: 'oklch',
|
|
232
|
+
tokens: 'core',
|
|
233
|
+
skin: {
|
|
234
|
+
surface: { light: 'kami', dark: 'sumi' },
|
|
235
|
+
ink: { light: 'kami', dark: 'sumi' },
|
|
236
|
+
primary: 'shu',
|
|
237
|
+
accent: 'shu',
|
|
238
|
+
success: 'hisui',
|
|
239
|
+
warning: 'kohaku',
|
|
240
|
+
danger: 'shu',
|
|
241
|
+
error: 'shu',
|
|
242
|
+
info: 'kohaku'
|
|
243
|
+
},
|
|
244
|
+
shape: { radius: 'soft' },
|
|
245
|
+
typography: {
|
|
246
|
+
display: "'Fraunces', 'Iowan Old Style', Georgia, serif",
|
|
247
|
+
sans: "'Inter', system-ui, -apple-system, sans-serif",
|
|
248
|
+
mono: "'JetBrains Mono', 'SF Mono', Menlo, monospace"
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `{ storageKey }` when one was derived, else nothing to spread. */
|
|
253
|
+
const storageSection = (storageKey) => (storageKey ? { storageKey } : {})
|
|
254
|
+
|
|
255
|
+
/** `{ chart }` when charts were requested, else nothing to spread. */
|
|
256
|
+
function chartSection(includeChart, chartColors, chartShades) {
|
|
257
|
+
return includeChart ? { chart: generateChartConfig({ chartColors, chartShades }) } : {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `{ icons }` when a style or a custom path was chosen, else nothing to spread. */
|
|
261
|
+
function iconSection(icons, iconPath, iconStyle) {
|
|
262
|
+
const config = {}
|
|
263
|
+
if (iconStyle) config.style = iconStyle
|
|
264
|
+
if (icons === 'custom' && iconPath) config.custom = iconPath
|
|
265
|
+
return Object.keys(config).length > 0 ? { icons: config } : {}
|
|
266
|
+
}
|
|
267
|
+
|
|
225
268
|
export function generateZenSumiConfig(opts = {}) {
|
|
226
269
|
const { themes, defaultTheme, switcher, includeChart, chartColors, chartShades, storageKey } = opts
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
tokens: 'core',
|
|
231
|
-
skin: {
|
|
232
|
-
surface: { light: 'kami', dark: 'sumi' },
|
|
233
|
-
ink: { light: 'kami', dark: 'sumi' },
|
|
234
|
-
primary: 'shu',
|
|
235
|
-
accent: 'shu',
|
|
236
|
-
success: 'hisui',
|
|
237
|
-
warning: 'kohaku',
|
|
238
|
-
danger: 'shu',
|
|
239
|
-
error: 'shu',
|
|
240
|
-
info: 'kohaku'
|
|
241
|
-
},
|
|
242
|
-
shape: { radius: 'soft' },
|
|
243
|
-
typography: {
|
|
244
|
-
display: "'Fraunces', 'Iowan Old Style', Georgia, serif",
|
|
245
|
-
sans: "'Inter', system-ui, -apple-system, sans-serif",
|
|
246
|
-
mono: "'JetBrains Mono', 'SF Mono', Menlo, monospace"
|
|
247
|
-
},
|
|
248
|
-
themes: themes && themes.length ? themes : ['rokkit', 'zen-sumi'],
|
|
270
|
+
return {
|
|
271
|
+
...ZEN_SUMI_BASE,
|
|
272
|
+
themes: themes?.length ? themes : ['rokkit', 'zen-sumi'],
|
|
249
273
|
defaultTheme: defaultTheme || 'zen-sumi',
|
|
250
274
|
switcher: switcher || 'full',
|
|
251
|
-
...(storageKey
|
|
275
|
+
...storageSection(storageKey),
|
|
276
|
+
...chartSection(includeChart, chartColors, chartShades)
|
|
252
277
|
}
|
|
253
|
-
if (includeChart) config.chart = generateChartConfig({ chartColors, chartShades })
|
|
254
|
-
return config
|
|
255
278
|
}
|
|
256
279
|
|
|
257
280
|
/**
|
|
@@ -264,25 +287,26 @@ export function generateZenSumiConfig(opts = {}) {
|
|
|
264
287
|
* storageKey?: string }} opts
|
|
265
288
|
* @returns {Record<string, unknown>}
|
|
266
289
|
*/
|
|
267
|
-
export function generateConfig({
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
290
|
+
export function generateConfig(opts) {
|
|
291
|
+
const {
|
|
292
|
+
palette,
|
|
293
|
+
customColors,
|
|
294
|
+
icons,
|
|
295
|
+
iconPath,
|
|
296
|
+
iconStyle,
|
|
297
|
+
themes,
|
|
298
|
+
defaultTheme,
|
|
299
|
+
switcher,
|
|
300
|
+
includeChart,
|
|
301
|
+
chartColors,
|
|
302
|
+
chartShades,
|
|
303
|
+
storageKey
|
|
304
|
+
} = opts
|
|
305
|
+
// Forwarding the whole `opts` is safe: generateZenSumiConfig destructures only
|
|
306
|
+
// the keys it needs and ignores the palette/icon answers.
|
|
307
|
+
if (palette === 'zen-sumi') return generateZenSumiConfig(opts)
|
|
284
308
|
|
|
285
|
-
|
|
309
|
+
return {
|
|
286
310
|
skin: resolveSkin(palette, customColors),
|
|
287
311
|
colorSpace: 'rgb',
|
|
288
312
|
tokens: 'core',
|
|
@@ -290,19 +314,10 @@ export function generateConfig({
|
|
|
290
314
|
themes,
|
|
291
315
|
defaultTheme: defaultTheme || themes[0],
|
|
292
316
|
switcher,
|
|
293
|
-
...(storageKey
|
|
317
|
+
...storageSection(storageKey),
|
|
318
|
+
...iconSection(icons, iconPath, iconStyle),
|
|
319
|
+
...chartSection(includeChart, chartColors, chartShades)
|
|
294
320
|
}
|
|
295
|
-
|
|
296
|
-
const iconConfig = {}
|
|
297
|
-
if (iconStyle) iconConfig.style = iconStyle
|
|
298
|
-
if (icons === 'custom' && iconPath) iconConfig.custom = iconPath
|
|
299
|
-
if (Object.keys(iconConfig).length > 0) config.icons = iconConfig
|
|
300
|
-
|
|
301
|
-
if (includeChart) {
|
|
302
|
-
config.chart = generateChartConfig({ chartColors, chartShades })
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return config
|
|
306
321
|
}
|
|
307
322
|
|
|
308
323
|
/**
|
|
@@ -520,52 +535,76 @@ function writeAppHtml(cwd, initScript, storageKey) {
|
|
|
520
535
|
console.info(' Patched app.html — added theme init script')
|
|
521
536
|
}
|
|
522
537
|
|
|
538
|
+
/**
|
|
539
|
+
* When this doesn't look like a SvelteKit project, warn and ask. Returns false
|
|
540
|
+
* to abort the whole command.
|
|
541
|
+
* @param {string} cwd
|
|
542
|
+
* @returns {Promise<boolean>}
|
|
543
|
+
*/
|
|
544
|
+
async function confirmSvelteKit(cwd) {
|
|
545
|
+
if (detectSvelteKit(cwd)) return true
|
|
546
|
+
console.warn('No svelte.config.js found — this may not be a SvelteKit project.')
|
|
547
|
+
const { proceed } = await prompts({
|
|
548
|
+
type: 'confirm',
|
|
549
|
+
name: 'proceed',
|
|
550
|
+
message: 'Continue anyway?',
|
|
551
|
+
initial: false
|
|
552
|
+
})
|
|
553
|
+
return Boolean(proceed)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The host package's name, or undefined when there's no readable package.json —
|
|
558
|
+
* the storage key is derived from it, and a missing name is not an error.
|
|
559
|
+
* @param {string} cwd
|
|
560
|
+
* @returns {string | undefined}
|
|
561
|
+
*/
|
|
562
|
+
function readAppName(cwd) {
|
|
563
|
+
try {
|
|
564
|
+
return JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf-8')).name
|
|
565
|
+
} catch {
|
|
566
|
+
return undefined
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Fold the three separate custom-colour answers into the single `customColors`
|
|
572
|
+
* shape `generateConfig` expects. Mutates `response` in place, like the rest of
|
|
573
|
+
* the prompt post-processing.
|
|
574
|
+
* @param {Record<string, unknown>} response
|
|
575
|
+
*/
|
|
576
|
+
/* v8 ignore start -- prompts mutates PROMPTS_CONFIG[n].type in-place on the first call,
|
|
577
|
+
so the conditional primary/accent/surface prompts always resolve to null on subsequent
|
|
578
|
+
runs and this never gets past its own guard; the behaviour is tested via generateConfig
|
|
579
|
+
directly. Wrapping the whole function rather than counting lines: with an early return
|
|
580
|
+
the tail is unreached too, and a `next N` count drifts as soon as a line moves. */
|
|
581
|
+
function applyCustomColors(response) {
|
|
582
|
+
if (response.palette !== 'custom') return
|
|
583
|
+
response.customColors = {
|
|
584
|
+
primary: response.primary,
|
|
585
|
+
accent: response.accent,
|
|
586
|
+
surface: response.surface
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/* v8 ignore stop */
|
|
590
|
+
|
|
523
591
|
/**
|
|
524
592
|
* Interactive init command — prompts the user, writes config files.
|
|
525
593
|
* @param {Record<string, unknown>} [_opts]
|
|
526
594
|
* @param {{ runInstall?: (bin: string, args: string[]) => void }} [adapters] — injectable for testing
|
|
527
595
|
*/
|
|
528
|
-
// eslint-disable-next-line max-lines-per-function
|
|
529
596
|
export async function init(_opts = {}, adapters = {}) {
|
|
530
597
|
const cwd = process.cwd()
|
|
531
598
|
|
|
532
|
-
|
|
533
|
-
if (!detectSvelteKit(cwd)) {
|
|
534
|
-
console.warn('No svelte.config.js found — this may not be a SvelteKit project.')
|
|
535
|
-
const { proceed } = await prompts({
|
|
536
|
-
type: 'confirm',
|
|
537
|
-
name: 'proceed',
|
|
538
|
-
message: 'Continue anyway?',
|
|
539
|
-
initial: false
|
|
540
|
-
})
|
|
541
|
-
if (!proceed) return
|
|
542
|
-
}
|
|
599
|
+
if (!(await confirmSvelteKit(cwd))) return
|
|
543
600
|
|
|
544
601
|
console.info('Rokkit Init — Setting up your SvelteKit project\n')
|
|
545
602
|
|
|
546
|
-
// Install packages
|
|
547
603
|
installPackages(cwd, adapters)
|
|
548
604
|
|
|
549
605
|
const response = await prompts(PROMPTS_CONFIG)
|
|
550
|
-
|
|
551
|
-
|
|
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 */
|
|
554
|
-
if (response.palette === 'custom') {
|
|
555
|
-
response.customColors = {
|
|
556
|
-
primary: response.primary,
|
|
557
|
-
accent: response.accent,
|
|
558
|
-
surface: response.surface
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
let appName
|
|
563
|
-
try {
|
|
564
|
-
appName = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf-8')).name
|
|
565
|
-
} catch {
|
|
566
|
-
appName = undefined
|
|
567
|
-
}
|
|
568
|
-
response.storageKey = storageKeyFromName(appName)
|
|
606
|
+
applyCustomColors(response)
|
|
607
|
+
response.storageKey = storageKeyFromName(readAppName(cwd))
|
|
569
608
|
|
|
570
609
|
const config = generateConfig(response)
|
|
571
610
|
|