@rokkit/cli 1.4.0 → 1.4.1

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/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
- 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)
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
- const v = skin?.[role]
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 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
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.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
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/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
- const shapeChecks = validateConfigShape(parsed)
34
- /* v8 ignore next 8 -- loadConfig uses dynamic file:// import which always fails in
35
- JSDOM; validateConfigShape(null) returns [] so this branch is unreachable in tests */
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
- const config = {
228
- palettes: ZEN_SUMI_PALETTES,
229
- colorSpace: 'oklch',
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 ? { 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
- palette,
269
- customColors,
270
- icons,
271
- iconPath,
272
- iconStyle,
273
- themes,
274
- defaultTheme,
275
- switcher,
276
- includeChart,
277
- chartColors,
278
- chartShades,
279
- storageKey
280
- }) {
281
- if (palette === 'zen-sumi') {
282
- return generateZenSumiConfig({ themes, defaultTheme, switcher, includeChart, chartColors, chartShades, storageKey })
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
- const config = {
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 ? { 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
- // Detect SvelteKit project
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
- /* 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 */
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