variantkit 0.1.0

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,587 @@
1
+ #!/usr/bin/env node
2
+ // variantkit — set up (or check, or fully remove) VariantKit in a project.
3
+ //
4
+ // npx variantkit [init] [targetDir] [flags] set everything up (default command)
5
+ // npx variantkit doctor [targetDir] check the install, print fix-its
6
+ // npx variantkit remove [targetDir] uninstall with zero residue
7
+ //
8
+ // init flags: --dry-run --skip-install --no-mount --no-skill --skill
9
+ // remove flags: --keep-deps --skill (also removes the global skill)
10
+ //
11
+ // init does (idempotent):
12
+ // 1. npm i dialkit motion
13
+ // 1b. ship the panel patch (delightful minimize/expand morph) via patch-package (non-fatal)
14
+ // 2. copy the runtime -> <base>/variantkit/ (buildDecision, configs, schemas/, react/,
15
+ // react.tsx Studio, panel css, vite-plugin)
16
+ // 3. copy AGENT.md -> <target>/AGENT.md (won't clobber an existing one)
17
+ // 4. wire the decision transport (vite plugin / Next API route)
18
+ // 5. mount <DialRoot/> + <VariantBar/> + dialkit styles in the app entry
19
+ // 6. append the VariantKit pointer to CLAUDE.md / AGENTS.md / .cursor rules
20
+ // 7. gitignore .variantkit/ and install the global Claude Code skill
21
+ //
22
+ // Nothing is silent: every action is logged, every skip explains why.
23
+
24
+ import { execSync } from 'node:child_process'
25
+ import {
26
+ existsSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync,
27
+ statSync, rmSync, rmdirSync,
28
+ } from 'node:fs'
29
+ import { dirname, join, resolve, relative } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+ import { homedir } from 'node:os'
32
+
33
+ const SELF = dirname(fileURLToPath(import.meta.url)) // the variantkit/ dir
34
+ const REPO_ROOT = resolve(SELF, '..')
35
+ const argv = process.argv.slice(2)
36
+ const COMMANDS = new Set(['init', 'doctor', 'remove'])
37
+ const command = COMMANDS.has(argv[0]) ? argv[0] : 'init'
38
+ const args = COMMANDS.has(argv[0]) ? argv.slice(1) : argv
39
+ const DRY = args.includes('--dry-run')
40
+ const SKIP_INSTALL = args.includes('--skip-install')
41
+ const NO_MOUNT = args.includes('--no-mount')
42
+ const NO_SKILL = args.includes('--no-skill')
43
+ const FORCE_SKILL = args.includes('--skill')
44
+ const KEEP_DEPS = args.includes('--keep-deps')
45
+ const target = resolve(args.find((a) => !a.startsWith('--')) ?? process.cwd())
46
+
47
+ const log = (m) => console.log(` ${m}`)
48
+ const warn = (m) => console.warn(` ! ${m}`)
49
+ const head = (m) => console.log(`\n${m}`)
50
+ const did = (m) => console.log(` ${DRY ? '[dry] would' : '✓'} ${m}`)
51
+
52
+ function fail(m) {
53
+ console.error(`\nvariantkit ${command} failed: ${m}`)
54
+ process.exit(1)
55
+ }
56
+
57
+ function readJson(p) {
58
+ try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return null }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Project detection — shared by all commands
63
+ // ---------------------------------------------------------------------------
64
+
65
+ function detect() {
66
+ const pkg = readJson(join(target, 'package.json')) ?? {}
67
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
68
+ const framework = deps.next ? 'next' : deps.vite ? 'vite' : deps['@remix-run/react'] ? 'remix' : 'unknown'
69
+ const hasTs = existsSync(join(target, 'tsconfig.json'))
70
+ const srcBase = existsSync(join(target, 'src')) ? join(target, 'src') : target
71
+ const runtimeDir = join(srcBase, 'variantkit')
72
+
73
+ // Next router dirs
74
+ const appDir = ['app', join('src', 'app')].map((d) => join(target, d)).find(existsSync) ?? null
75
+ const pagesDir = ['pages', join('src', 'pages')].map((d) => join(target, d)).find(existsSync) ?? null
76
+
77
+ // Vite entry + config
78
+ const entry = ['main.tsx', 'main.jsx', 'main.ts', 'main.js', 'index.tsx', 'index.jsx']
79
+ .map((f) => join(srcBase, f)).find(existsSync) ?? null
80
+ const viteConfig = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts']
81
+ .map((f) => join(target, f)).find(existsSync) ?? null
82
+ const layout = appDir
83
+ ? ['layout.tsx', 'layout.jsx', 'layout.js'].map((f) => join(appDir, f)).find(existsSync) ?? null
84
+ : null
85
+
86
+ return { pkg, deps, framework, hasTs, srcBase, runtimeDir, appDir, pagesDir, entry, viteConfig, layout }
87
+ }
88
+
89
+ const POINTER = `
90
+ <!-- variantkit -->
91
+ ## VariantKit
92
+ This project uses VariantKit for AI-assisted UI exploration. Read ./AGENT.md before building UI.
93
+ - When asked to build or change UI, offer 2-4 structural variants (a variant set) per AGENT.md,
94
+ not one — wired to a FULL contextual panel (an archetype from variantkit/schemas, AGENT.md §7).
95
+ - On "paramify" / "let me tweak this", wrap the existing component in its archetype panel (§7).
96
+ - Generated variants must pass the deslop rules in AGENT.md (no random mono/italics/eyebrows,
97
+ decorative lines/dots, unmotivated warm accents, oversized radii, em dashes, emoji).
98
+ - On "deslop" / "remove AI slop", run the deslop pass in AGENT.md.
99
+ - On "apply decision" (or at session start), apply pending .variantkit/decisions/*.json per AGENT.md §4.
100
+ - Before generating variants, read .variantkit/TASTE.md if present and bias defaults toward it.
101
+ <!-- /variantkit -->
102
+ `
103
+ const RULES_FILES = ['CLAUDE.md', 'AGENTS.md', join('.cursor', 'rules'), '.cursorrules']
104
+ const RUNTIME_FILES = [
105
+ 'buildDecision.ts', 'configs.ts', 'react.tsx',
106
+ 'dialkit-clean.css', 'dialkit-dark.css', 'motion.css',
107
+ 'vite-plugin.mjs', 'vite-plugin.d.mts',
108
+ ]
109
+ const RUNTIME_DIRS = ['schemas', 'react']
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Codemods (init) — every patch is marker-guarded or pattern-guarded + reversible
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function patchViteConfig(viteConfig, runtimeDir) {
116
+ let src = readFileSync(viteConfig, 'utf8')
117
+ if (src.includes('vite-plugin.mjs') || /variantkit\(\)/.test(src)) {
118
+ log(`${relative(target, viteConfig)}: transport already wired, skipping`)
119
+ return true
120
+ }
121
+ const importPath = './' + relative(target, join(runtimeDir, 'vite-plugin.mjs')).replace(/\\/g, '/')
122
+ if (!/plugins:\s*\[/.test(src)) {
123
+ warn(`${relative(target, viteConfig)}: no plugins array found — add manually:`)
124
+ warn(` import variantkit from '${importPath}' + plugins: [variantkit(), ...]`)
125
+ return false
126
+ }
127
+ const importLine = `import variantkit from '${importPath}'\n`
128
+ const lastImport = src.lastIndexOf('\nimport ')
129
+ const insertAt = lastImport >= 0 ? src.indexOf('\n', lastImport + 1) + 1 : 0
130
+ src = src.slice(0, insertAt) + importLine + src.slice(insertAt)
131
+ src = src.replace(/plugins:\s*\[/, (m) => `${m}variantkit(), `)
132
+ if (!DRY) writeFileSync(viteConfig, src)
133
+ did(`wire vite transport -> ${relative(target, viteConfig)}`)
134
+ return true
135
+ }
136
+
137
+ function mountChrome(file, runtimeDir, kind) {
138
+ let src = readFileSync(file, 'utf8')
139
+ if (src.includes('DialRoot')) {
140
+ log(`${relative(target, file)}: DialRoot already mounted, skipping`)
141
+ return true
142
+ }
143
+ const barPath = relative(dirname(file), join(runtimeDir, 'react', 'VariantBar')).replace(/\\/g, '/')
144
+ const barImport = barPath.startsWith('.') ? barPath : './' + barPath
145
+ const rtRel = relative(dirname(file), runtimeDir).replace(/\\/g, '/')
146
+ const rt = rtRel.startsWith('.') ? rtRel : './' + rtRel
147
+ const imports =
148
+ `import { DialRoot } from 'dialkit'\nimport { VariantBar } from '${barImport}'\n` +
149
+ `import 'dialkit/styles.css'\nimport '${rt}/dialkit-clean.css'\nimport '${rt}/motion.css'\n`
150
+ const CHROME = '<DialRoot /><VariantBar />'
151
+
152
+ let patched
153
+ if (kind === 'next-layout') {
154
+ // <body ...>{children}</body> -> mount as siblings of children
155
+ patched = src.replace(/\{children\}(\s*<\/body>)/, `{children}${CHROME}$1`)
156
+ if (patched === src) patched = src.replace(/\{children\}/, `<>{children}${CHROME}</>`)
157
+ } else {
158
+ // vite entry: <App /> -> fragment with chrome
159
+ patched = src.replace(/<App\s*\/>/, `<><App />${CHROME}</>`)
160
+ }
161
+ if (patched === src) {
162
+ warn(`${relative(target, file)}: could not find a mount point — add manually as siblings of your app:`)
163
+ warn(` ${CHROME} + import 'dialkit/styles.css'`)
164
+ return false
165
+ }
166
+ const lastImport = patched.lastIndexOf('\nimport ')
167
+ const insertAt = lastImport >= 0 ? patched.indexOf('\n', lastImport + 1) + 1 : 0
168
+ patched = patched.slice(0, insertAt) + imports + patched.slice(insertAt)
169
+ if (!DRY) writeFileSync(file, patched)
170
+ did(`mount <DialRoot/> + <VariantBar/> -> ${relative(target, file)}`)
171
+ return true
172
+ }
173
+
174
+ function appendGitignore() {
175
+ const p = join(target, '.gitignore')
176
+ const cur = existsSync(p) ? readFileSync(p, 'utf8') : ''
177
+ if (cur.split('\n').some((l) => l.trim() === '.variantkit/')) {
178
+ log('.gitignore: already ignores .variantkit/, skipping')
179
+ return
180
+ }
181
+ if (!DRY) writeFileSync(p, cur.replace(/\s*$/, '\n') + '.variantkit/\n')
182
+ did('gitignore .variantkit/')
183
+ }
184
+
185
+ function installSkill() {
186
+ const skillSrc = join(SELF, 'skill')
187
+ const skillDest = join(homedir(), '.claude', 'skills', 'variantkit')
188
+ if (!existsSync(skillSrc)) return warn('skill source missing, skipping')
189
+ if (DRY) return did(`copy skill -> ${skillDest}`)
190
+ mkdirSync(dirname(skillDest), { recursive: true })
191
+ cpSync(skillSrc, skillDest, { recursive: true })
192
+ did(`installed global skill -> ${skillDest}`)
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // init
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function init() {
200
+ head(`variantkit init → ${target}${DRY ? ' (dry run)' : ''}`)
201
+ if (!existsSync(target) || !statSync(target).isDirectory()) fail(`not a directory: ${target}`)
202
+ if (!existsSync(join(target, 'package.json'))) {
203
+ warn('no package.json in target — is this a JS/TS project? continuing anyway.')
204
+ }
205
+ const d = detect()
206
+ log(`detected: framework=${d.framework} ts=${d.hasTs} base=${relative(target, d.srcBase) || '.'}`)
207
+ if (!d.hasTs) {
208
+ warn('no tsconfig.json — the runtime ships as TypeScript; Vite and Next compile it as-is,')
209
+ warn('but add a tsconfig for editor types if you can.')
210
+ }
211
+
212
+ // 1. deps
213
+ head('1. dependencies (dialkit, motion)')
214
+ if (SKIP_INSTALL) log('skipped (--skip-install)')
215
+ else if (DRY) did('run: npm i dialkit motion')
216
+ else {
217
+ try {
218
+ execSync('npm i dialkit motion', { cwd: target, stdio: 'inherit' })
219
+ did('installed dialkit + motion')
220
+ } catch (e) {
221
+ fail(`npm install failed: ${e.message}`)
222
+ }
223
+ }
224
+
225
+ // 1b. panel polish — ship the delightful minimize/expand morph as a patch over dialkit's
226
+ // dist (the one thing CSS can't reach: a hardcoded motion spring). patch-package + a
227
+ // postinstall hook keep it applied across reinstalls. Pinned to dialkit 1.2.0; entirely
228
+ // non-fatal — if anything here fails the panel still works, just with the default morph.
229
+ head('1b. panel polish (delightful minimize/expand morph)')
230
+ const patchSrc = join(SELF, 'patches', 'dialkit+1.2.0.patch')
231
+ if (!existsSync(patchSrc)) {
232
+ warn('panel patch missing — skipping (panel works, just the default morph)')
233
+ } else {
234
+ const patchDest = join(target, 'patches', 'dialkit+1.2.0.patch')
235
+ if (!DRY) {
236
+ mkdirSync(dirname(patchDest), { recursive: true })
237
+ copyFileSync(patchSrc, patchDest)
238
+ }
239
+ did(`copy dialkit patch -> ${relative(target, patchDest)}`)
240
+ if (SKIP_INSTALL) {
241
+ log('skipped applying (--skip-install) — run `npx patch-package` to apply the morph')
242
+ } else if (DRY) {
243
+ did('run: add "postinstall":"patch-package", npm i -D patch-package, npx patch-package')
244
+ } else {
245
+ try {
246
+ const pkgPath = join(target, 'package.json')
247
+ if (existsSync(pkgPath)) {
248
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
249
+ pkg.scripts = pkg.scripts || {}
250
+ const cur = pkg.scripts.postinstall
251
+ if (!cur) {
252
+ pkg.scripts.postinstall = 'patch-package'
253
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
254
+ did('add "postinstall": "patch-package" to package.json')
255
+ } else if (cur.includes('patch-package')) {
256
+ log('postinstall already runs patch-package — left as is')
257
+ } else {
258
+ // Don't clobber an existing postinstall — run theirs first, then patch-package.
259
+ pkg.scripts.postinstall = `${cur} && patch-package`
260
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
261
+ warn(`appended patch-package to your existing postinstall (now: "${pkg.scripts.postinstall}") — review it`)
262
+ }
263
+ }
264
+ execSync('npm i -D patch-package', { cwd: target, stdio: 'inherit' })
265
+ execSync('npx patch-package', { cwd: target, stdio: 'inherit' })
266
+ did('applied delightful minimize/expand morph to dialkit')
267
+ } catch (e) {
268
+ warn(`could not apply panel patch (non-fatal): ${e.message}`)
269
+ }
270
+ }
271
+ }
272
+
273
+ // 2. runtime
274
+ head('2. runtime (buildDecision, schemas/, react/, vite-plugin)')
275
+ if (!DRY) mkdirSync(d.runtimeDir, { recursive: true })
276
+ for (const f of RUNTIME_FILES) {
277
+ const src = join(SELF, f)
278
+ if (!existsSync(src)) fail(`missing source: ${src}`)
279
+ if (!DRY) copyFileSync(src, join(d.runtimeDir, f))
280
+ }
281
+ for (const dir of RUNTIME_DIRS) {
282
+ const src = join(SELF, dir)
283
+ if (!existsSync(src)) fail(`missing source: ${src}`)
284
+ if (!DRY) cpSync(src, join(d.runtimeDir, dir), { recursive: true })
285
+ }
286
+ did(`copy runtime -> ${relative(target, d.runtimeDir)}/`)
287
+
288
+ // 3. AGENT.md
289
+ head('3. contract (AGENT.md)')
290
+ const agentSrc = join(REPO_ROOT, 'AGENT.md')
291
+ if (!existsSync(agentSrc)) fail(`missing source: ${agentSrc}`)
292
+ let agentDest = join(target, 'AGENT.md')
293
+ if (existsSync(agentDest) && !readFileSync(agentDest, 'utf8').includes('VariantKit — Agent Contract')) {
294
+ agentDest = join(target, 'AGENT.variantkit.md')
295
+ warn('AGENT.md already exists — writing AGENT.variantkit.md instead (merge by hand)')
296
+ }
297
+ if (!DRY) copyFileSync(agentSrc, agentDest)
298
+ did(`copy AGENT.md -> ${relative(target, agentDest)}`)
299
+ const namingSrc = join(REPO_ROOT, 'NAMING.md')
300
+ const namingDest = join(target, 'NAMING.md')
301
+ if (!existsSync(namingSrc)) {
302
+ warn('NAMING.md source missing — skipping (AGENT.md references it)')
303
+ } else if (existsSync(namingDest) && !readFileSync(namingDest, 'utf8').includes('VariantKit — Vocabulary')) {
304
+ warn('NAMING.md already exists and is not ours — left untouched')
305
+ } else {
306
+ if (!DRY) copyFileSync(namingSrc, namingDest)
307
+ did(`copy NAMING.md -> ${relative(target, namingDest)}`)
308
+ }
309
+
310
+ // 4. decision transport
311
+ head('4. decision transport')
312
+ if (d.framework === 'vite' && d.viteConfig) {
313
+ patchViteConfig(d.viteConfig, d.runtimeDir)
314
+ } else if (d.framework === 'next' && d.appDir) {
315
+ const routeDest = join(d.appDir, 'api', '__variantkit', 'decision', 'route.ts')
316
+ if (existsSync(routeDest)) log('Next route already present, skipping')
317
+ else {
318
+ if (!DRY) mkdirSync(dirname(routeDest), { recursive: true })
319
+ if (!DRY) copyFileSync(join(SELF, 'templates', 'next-route.ts'), routeDest)
320
+ did(`copy Next App Router route -> ${relative(target, routeDest)}`)
321
+ }
322
+ } else if (d.framework === 'next' && d.pagesDir) {
323
+ const routeDest = join(d.pagesDir, 'api', '__variantkit', 'decision.ts')
324
+ if (existsSync(routeDest)) log('Next pages API route already present, skipping')
325
+ else {
326
+ if (!DRY) mkdirSync(dirname(routeDest), { recursive: true })
327
+ if (!DRY) copyFileSync(join(SELF, 'templates', 'next-pages-api.ts'), routeDest)
328
+ did(`copy Next Pages Router route -> ${relative(target, routeDest)}`)
329
+ }
330
+ } else {
331
+ warn(`framework=${d.framework}: no transport wired — finalize falls back to the clipboard.`)
332
+ warn('(vite + Next are supported; see variantkit/vite-plugin.mjs to wire others)')
333
+ }
334
+
335
+ // 5. mount chrome
336
+ head('5. app chrome (<DialRoot/> + <VariantBar/>)')
337
+ if (NO_MOUNT) log('skipped (--no-mount)')
338
+ else if (d.framework === 'next' && d.layout) mountChrome(d.layout, d.runtimeDir, 'next-layout')
339
+ else if (d.entry) mountChrome(d.entry, d.runtimeDir, 'vite-entry')
340
+ else {
341
+ warn('no entry/layout found — mount manually as siblings of your app root:')
342
+ warn(" <DialRoot /> <VariantBar /> + import 'dialkit/styles.css'")
343
+ }
344
+
345
+ // 6. rules pointer
346
+ head('6. agent rules pointer')
347
+ let patched = 0
348
+ for (const rel of RULES_FILES) {
349
+ const p = join(target, rel)
350
+ if (!existsSync(p)) continue
351
+ const cur = readFileSync(p, 'utf8')
352
+ if (cur.includes('<!-- variantkit -->')) {
353
+ log(`${rel}: already has the pointer, skipping`)
354
+ patched++
355
+ continue
356
+ }
357
+ if (!DRY) writeFileSync(p, cur.replace(/\s*$/, '') + '\n' + POINTER)
358
+ did(`append pointer -> ${rel}`)
359
+ patched++
360
+ }
361
+ if (patched === 0) {
362
+ const p = join(target, 'CLAUDE.md')
363
+ if (!DRY) writeFileSync(p, `# Project\n${POINTER}`)
364
+ did('no rules file found — created CLAUDE.md with the pointer')
365
+ }
366
+
367
+ // 7. housekeeping
368
+ head('7. housekeeping')
369
+ appendGitignore()
370
+ const wantSkill = FORCE_SKILL || (!NO_SKILL && existsSync(join(homedir(), '.claude')))
371
+ if (wantSkill) installSkill()
372
+ else log(`global skill skipped (${NO_SKILL ? '--no-skill' : 'no ~/.claude found'}; force with --skill)`)
373
+
374
+ head('done. next:')
375
+ log('run your dev server, then ask your AI for "three takes on <component>".')
376
+ log('switch with the bottom bar (keys 1..9), Compare for side-by-side, Finalize when happy —')
377
+ log('then tell your agent "apply decision". Check the install anytime: npx variantkit doctor')
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // doctor
382
+ // ---------------------------------------------------------------------------
383
+
384
+ function doctor() {
385
+ head(`variantkit doctor → ${target}`)
386
+ const d = detect()
387
+ let fails = 0
388
+ const check = (ok, label, fix) => {
389
+ console.log(` ${ok ? '✓' : '✗'} ${label}${ok ? '' : ` — ${fix}`}`)
390
+ if (!ok) fails++
391
+ }
392
+
393
+ check(!!d.deps.dialkit, 'dialkit dependency', 'npm i dialkit')
394
+ check(!!d.deps.motion, 'motion dependency', 'npm i motion')
395
+ check(existsSync(join(d.runtimeDir, 'buildDecision.ts')), 'runtime: buildDecision.ts', 'npx variantkit init')
396
+ check(existsSync(join(d.runtimeDir, 'schemas', 'archetypes.ts')), 'runtime: schemas/', 'npx variantkit init')
397
+ check(existsSync(join(d.runtimeDir, 'react', 'VariantBar.tsx')), 'runtime: react/', 'npx variantkit init')
398
+ check(existsSync(join(d.runtimeDir, 'configs.ts')), 'runtime: configs.ts', 'npx variantkit init')
399
+ check(existsSync(join(d.runtimeDir, 'react.tsx')), 'runtime: react.tsx (Studio)', 'npx variantkit init')
400
+ check(existsSync(join(d.runtimeDir, 'dialkit-clean.css')), 'runtime: panel css', 'npx variantkit init')
401
+ const agentOk = [join(target, 'AGENT.md'), join(target, 'AGENT.variantkit.md')]
402
+ .some((p) => existsSync(p) && readFileSync(p, 'utf8').includes('VariantKit — Agent Contract'))
403
+ check(agentOk, 'AGENT.md contract', 'npx variantkit init')
404
+
405
+ const mountFile = (d.framework === 'next' ? d.layout : d.entry)
406
+ const mounted = mountFile && readFileSync(mountFile, 'utf8').includes('DialRoot')
407
+ check(!!mounted, '<DialRoot/> + <VariantBar/> mounted', `mount in ${mountFile ? relative(target, mountFile) : 'your app entry'}`)
408
+ const styles = mountFile && readFileSync(mountFile, 'utf8').includes('dialkit/styles.css')
409
+ check(!!styles, 'dialkit styles imported', "import 'dialkit/styles.css' next to the mount")
410
+
411
+ if (d.framework === 'vite') {
412
+ const wired = d.viteConfig && readFileSync(d.viteConfig, 'utf8').includes('variantkit')
413
+ check(!!wired, 'vite decision transport', 'add variantkit() plugin to vite.config')
414
+ } else if (d.framework === 'next') {
415
+ const route = (d.appDir && existsSync(join(d.appDir, 'api', '__variantkit', 'decision', 'route.ts')))
416
+ || (d.pagesDir && existsSync(join(d.pagesDir, 'api', '__variantkit', 'decision.ts')))
417
+ check(!!route, 'Next decision transport route', 'npx variantkit init')
418
+ } else {
419
+ log(`~ framework=${d.framework}: transport check skipped (clipboard fallback applies)`)
420
+ }
421
+
422
+ const pointer = RULES_FILES.some((rel) => {
423
+ const p = join(target, rel)
424
+ return existsSync(p) && readFileSync(p, 'utf8').includes('<!-- variantkit -->')
425
+ })
426
+ check(pointer, 'agent rules pointer', 'npx variantkit init')
427
+ const gi = join(target, '.gitignore')
428
+ check(existsSync(gi) && readFileSync(gi, 'utf8').includes('.variantkit/'), '.variantkit/ gitignored', 'add .variantkit/ to .gitignore')
429
+ const skill = existsSync(join(homedir(), '.claude', 'skills', 'variantkit', 'SKILL.md'))
430
+ check(skill, 'global Claude Code skill', 'npx variantkit init --skill')
431
+
432
+ head(fails === 0 ? 'all good.' : `${fails} issue${fails > 1 ? 's' : ''} found.`)
433
+ process.exit(fails === 0 ? 0 : 1)
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // remove — zero residue, the prune ethos applied to the tool itself
438
+ // ---------------------------------------------------------------------------
439
+
440
+ function remove() {
441
+ head(`variantkit remove → ${target}${DRY ? ' (dry run)' : ''}`)
442
+ const d = detect()
443
+
444
+ // runtime + state
445
+ for (const p of [d.runtimeDir, join(target, '.variantkit')]) {
446
+ if (!existsSync(p)) continue
447
+ if (!DRY) rmSync(p, { recursive: true, force: true })
448
+ did(`delete ${relative(target, p)}/`)
449
+ }
450
+
451
+ // AGENT.md + NAMING.md (only the ones we wrote)
452
+ for (const name of ['AGENT.md', 'AGENT.variantkit.md', 'NAMING.md']) {
453
+ const p = join(target, name)
454
+ const marker = name === 'NAMING.md' ? 'VariantKit — Vocabulary' : 'VariantKit — Agent Contract'
455
+ if (existsSync(p) && readFileSync(p, 'utf8').includes(marker)) {
456
+ if (!DRY) rmSync(p)
457
+ did(`delete ${name}`)
458
+ }
459
+ }
460
+
461
+ // pointer blocks
462
+ for (const rel of RULES_FILES) {
463
+ const p = join(target, rel)
464
+ if (!existsSync(p)) continue
465
+ const cur = readFileSync(p, 'utf8')
466
+ if (!cur.includes('<!-- variantkit -->')) continue
467
+ const next = cur
468
+ .replace(/\n*<!-- variantkit -->[\s\S]*?<!-- \/variantkit -->\n?/g, '')
469
+ .replace(/\s*$/, '\n')
470
+ if (!DRY) {
471
+ if (next.trim() === '# Project') rmSync(p) // we created this file ourselves
472
+ else writeFileSync(p, next)
473
+ }
474
+ did(`strip pointer from ${rel}`)
475
+ }
476
+
477
+ // vite config
478
+ if (d.viteConfig) {
479
+ const cur = readFileSync(d.viteConfig, 'utf8')
480
+ if (cur.includes('variantkit')) {
481
+ const next = cur
482
+ .replace(/import variantkit from '[^']*vite-plugin\.mjs'\n?/, '')
483
+ .replace(/variantkit\(\),\s*/, '')
484
+ .replace(/,?\s*variantkit\(\)/, '')
485
+ if (!DRY) writeFileSync(d.viteConfig, next)
486
+ did(`unwire transport from ${relative(target, d.viteConfig)}`)
487
+ }
488
+ }
489
+
490
+ // Next routes
491
+ for (const p of [
492
+ d.appDir && join(d.appDir, 'api', '__variantkit'),
493
+ d.pagesDir && join(d.pagesDir, 'api', '__variantkit'),
494
+ ].filter(Boolean)) {
495
+ if (!existsSync(p)) continue
496
+ if (!DRY) rmSync(p, { recursive: true, force: true })
497
+ did(`delete ${relative(target, p)}/`)
498
+ }
499
+
500
+ // unmount chrome
501
+ for (const file of [d.entry, d.layout].filter(Boolean)) {
502
+ const cur = readFileSync(file, 'utf8')
503
+ if (!cur.includes('DialRoot')) continue
504
+ const next = cur
505
+ .replace(/import \{ DialRoot \} from 'dialkit'\n?/, '')
506
+ .replace(/import \{ VariantBar \} from '[^']*'\n?/, '')
507
+ .replace(/import 'dialkit\/styles\.css'\n?/, '')
508
+ .replace(/import '[^']*dialkit-clean\.css'\n?/, '')
509
+ .replace(/import '[^']*motion\.css'\n?/, '')
510
+ .replace(/<><App \/><DialRoot \/><VariantBar \/><\/>/, '<App />')
511
+ .replace(/\{children\}<DialRoot \/><VariantBar \/>/, '{children}')
512
+ .replace(/<>\{children\}<DialRoot \/><VariantBar \/><\/>/, '{children}')
513
+ .replace(/<DialRoot \/>\s*<VariantBar \/>\s*/, '')
514
+ if (!DRY) writeFileSync(file, next)
515
+ did(`unmount chrome from ${relative(target, file)}`)
516
+ }
517
+
518
+ // .gitignore line
519
+ const gi = join(target, '.gitignore')
520
+ if (existsSync(gi)) {
521
+ const cur = readFileSync(gi, 'utf8')
522
+ if (cur.includes('.variantkit/')) {
523
+ if (!DRY) writeFileSync(gi, cur.split('\n').filter((l) => l.trim() !== '.variantkit/').join('\n'))
524
+ did('remove .variantkit/ from .gitignore')
525
+ }
526
+ }
527
+
528
+ // panel patch + postinstall hook
529
+ const patchFile = join(target, 'patches', 'dialkit+1.2.0.patch')
530
+ if (existsSync(patchFile)) {
531
+ if (!DRY) {
532
+ rmSync(patchFile)
533
+ try { rmdirSync(join(target, 'patches')) } catch { /* not empty — leave it */ }
534
+ }
535
+ did('delete patches/dialkit+1.2.0.patch')
536
+ }
537
+ const pkgPath = join(target, 'package.json')
538
+ if (existsSync(pkgPath)) {
539
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
540
+ const cur = pkg.scripts?.postinstall
541
+ if (cur === 'patch-package') {
542
+ if (!DRY) {
543
+ delete pkg.scripts.postinstall
544
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
545
+ }
546
+ did('remove "postinstall": "patch-package"')
547
+ } else if (cur?.includes(' && patch-package')) {
548
+ if (!DRY) {
549
+ pkg.scripts.postinstall = cur.replace(' && patch-package', '')
550
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
551
+ }
552
+ did('strip patch-package from postinstall')
553
+ }
554
+ }
555
+
556
+ // deps
557
+ if (KEEP_DEPS) log('keeping dialkit + motion (--keep-deps)')
558
+ else if (DRY) did('run: npm uninstall dialkit motion patch-package')
559
+ else if (d.deps.dialkit || d.deps.motion) {
560
+ try {
561
+ execSync('npm uninstall dialkit motion patch-package', { cwd: target, stdio: 'inherit' })
562
+ did('uninstalled dialkit + motion + patch-package')
563
+ } catch (e) {
564
+ warn(`npm uninstall failed: ${e.message}`)
565
+ }
566
+ }
567
+
568
+ // global skill — only on explicit request (it serves other projects too)
569
+ if (FORCE_SKILL) {
570
+ const skillDest = join(homedir(), '.claude', 'skills', 'variantkit')
571
+ if (existsSync(skillDest)) {
572
+ if (!DRY) rmSync(skillDest, { recursive: true, force: true })
573
+ did(`delete global skill ${skillDest}`)
574
+ }
575
+ } else {
576
+ log('global skill kept (remove with: npx variantkit remove --skill)')
577
+ }
578
+
579
+ head('removed. `git status` should show only deletions and reverted lines.')
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+
584
+ if (command === 'doctor') doctor()
585
+ else if (command === 'remove') remove()
586
+ else init()
587
+
@@ -0,0 +1,77 @@
1
+ /* VariantKit motion — PANEL ONLY. (Principles: emil-design-eng.)
2
+ Only transform/opacity animate. Everything stays under 300ms. Respects reduced-motion.
3
+ Nothing here may style or animate the project's own UI — VariantKit presents the user's
4
+ elements untouched. All selectors are scoped to .dialkit-root / panel chrome. */
5
+ :root {
6
+ --vk-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
7
+ --vk-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
8
+ }
9
+
10
+ /* Press feedback — the panel's own buttons (incl. Finalize). */
11
+ .dialkit-root button {
12
+ transition: transform 140ms var(--vk-ease-out), background-color 160ms var(--vk-ease-out);
13
+ }
14
+ .dialkit-root button:active {
15
+ transform: scale(0.97);
16
+ }
17
+
18
+ /* Finalize button morphs to "✓ Copied" on finalize (text swapped in JS; the button carries
19
+ data-vk-flashing while it shows). A quick scale pop confirms the action — no color is
20
+ imposed, so it works with whatever the project themed the panel button as. */
21
+ .dialkit-root .dialkit-button[data-vk-flashing] {
22
+ animation: vk-finalized-pop 340ms var(--vk-ease-out);
23
+ }
24
+ @keyframes vk-finalized-pop {
25
+ 0% {
26
+ transform: scale(0.96);
27
+ }
28
+ 55% {
29
+ transform: scale(1.025);
30
+ }
31
+ 100% {
32
+ transform: scale(1);
33
+ }
34
+ }
35
+
36
+ /* ── Theme switch (light ↔ dark) — occasional action, so it earns delight ──────────────── */
37
+ /* The panel cross-fades its colors. `.vk-theming` is added only during the switch (~420ms),
38
+ so this transition never slows down normal hover/interaction. */
39
+ .dialkit-root.vk-theming,
40
+ .dialkit-root.vk-theming *:not(.vk-theme-toggle):not(.vk-swap) {
41
+ transition:
42
+ background-color 360ms var(--vk-ease-out),
43
+ color 360ms var(--vk-ease-out),
44
+ border-color 360ms var(--vk-ease-out),
45
+ box-shadow 360ms var(--vk-ease-out) !important;
46
+ }
47
+
48
+ /* The sun/moon icon spins + scales in each time it swaps (fresh node → animation runs once). */
49
+ .vk-theme-toggle .vk-swap {
50
+ animation: vk-icon-swap 420ms var(--vk-ease-out);
51
+ }
52
+ @keyframes vk-icon-swap {
53
+ from {
54
+ opacity: 0;
55
+ transform: rotate(-120deg) scale(0.5);
56
+ }
57
+ to {
58
+ opacity: 1;
59
+ transform: rotate(0) scale(1);
60
+ }
61
+ }
62
+
63
+ @media (prefers-reduced-motion: reduce) {
64
+ .dialkit-root.vk-theming,
65
+ .dialkit-root.vk-theming * {
66
+ transition: background-color 200ms ease, color 200ms ease !important;
67
+ }
68
+ .vk-theme-toggle .vk-swap {
69
+ animation: none;
70
+ }
71
+ .dialkit-root button:active {
72
+ transform: none;
73
+ }
74
+ .dialkit-root .dialkit-button[data-vk-flashing] {
75
+ animation: none;
76
+ }
77
+ }