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.
- package/AGENT.md +428 -0
- package/LICENSE +21 -0
- package/NAMING.md +42 -0
- package/README.md +111 -0
- package/package.json +36 -0
- package/variantkit/README.md +32 -0
- package/variantkit/buildDecision.ts +264 -0
- package/variantkit/configs.ts +70 -0
- package/variantkit/dialkit-clean.css +46 -0
- package/variantkit/dialkit-dark.css +27 -0
- package/variantkit/init.mjs +587 -0
- package/variantkit/motion.css +77 -0
- package/variantkit/patches/dialkit+1.2.0.patch +32 -0
- package/variantkit/react/VariantBar.tsx +208 -0
- package/variantkit/react/VariantStage.tsx +92 -0
- package/variantkit/react/vkStore.ts +38 -0
- package/variantkit/react.tsx +234 -0
- package/variantkit/schemas/archetypes.ts +216 -0
- package/variantkit/schemas/sections.ts +151 -0
- package/variantkit/skill/SKILL.md +223 -0
- package/variantkit/templates/next-pages-api.ts +33 -0
- package/variantkit/templates/next-route.ts +33 -0
- package/variantkit/vite-plugin.d.mts +8 -0
- package/variantkit/vite-plugin.mjs +55 -0
|
@@ -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
|
+
}
|