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,32 @@
1
+ # variantkit/ — what gets installed
2
+
3
+ This directory is the distributable. `init.mjs` copies the runtime into a target project
4
+ and wires everything; see the [repo README](../README.md) for positioning and
5
+ [docs/quickstart.md](../docs/quickstart.md) for usage.
6
+
7
+ ```sh
8
+ npx variantkit [init|doctor|remove] [targetDir] [flags]
9
+ ```
10
+
11
+ - `buildDecision.ts` — pure core: dot-path flatten (schema 2), override diff (taste
12
+ signal), prune list, `submitDecision` (dev transport, "✓ Saved") with clipboard fallback
13
+ ("✓ Copied"), `defaultsFromConfig`
14
+ - `configs.ts` — panel assembly helpers (`panelConfig`, `defaultsOf`, `regOf`); VariantKit
15
+ adds only the variant select + finalize around YOUR controls
16
+ - `schemas/sections.ts` — composable control sections + token resolvers (SHADOWS, FONT_STACKS)
17
+ - `schemas/archetypes.ts` — 11 per-element checklists of design axes (see
18
+ [docs/archetypes.md](../docs/archetypes.md)) — adapt + seed, never paste
19
+ - `react.tsx` — `Studio` (N elements → one panel, folder each, finalize routing,
20
+ focus-on-hover) + `useDialkitTheme` (panel dark mode with header toggle)
21
+ - `react/VariantBar.tsx` — bottom bar: variant tabs, keys 1-9, Compare, Finalize;
22
+ auto-discovers sets (shell or Studio layout) from DialKit's documented store
23
+ - `react/VariantStage.tsx` — live side-by-side compare grid for the classic shell
24
+ - `dialkit-clean.css` / `dialkit-dark.css` / `motion.css` — panel chrome polish (hide
25
+ redundant copy button, dark palette, micro-motion; never touches project UI)
26
+ - `patches/dialkit+1.2.0.patch` — delightful panel minimize/expand morph (patch-package)
27
+ - `vite-plugin.mjs` (+ `.d.mts`) — dev-only decision transport for Vite
28
+ - `templates/` — Next.js App/Pages Router transport routes
29
+ - `skill/SKILL.md` — global Claude Code skill (installed to `~/.claude/skills/variantkit`)
30
+ - `../AGENT.md` — the agent contract: scaffold convention, authoring rules, decision
31
+ schema 2, prune + self-check, §7 completeness bar, §6 deslop, §8 taste memory
32
+ - `../NAMING.md` — the vocabulary (one word per concept)
@@ -0,0 +1,264 @@
1
+ // VariantKit core — pure, no React imports. Builds the finalize decision (winner +
2
+ // override diff + prune list) and ships it to your agent so it can prune the variant
3
+ // set down to the winner. See AGENT.md for the contract.
4
+ //
5
+ // live values (nested folders ok) + defaults
6
+ // │
7
+ // ▼
8
+ // buildDecision ── finalized (winner key)
9
+ // ├── values (dot-path flattened final params to inline)
10
+ // ├── overridesFromDefault (only-changed; the taste signal)
11
+ // └── prune (every registry key except the winner)
12
+ //
13
+ // Decision schema 2: values/overrides are dot-path flattened ("surface.radius": 12)
14
+ // so folder-grouped configurations produce flat, inlineable decisions.
15
+
16
+ export type ParamValue = number | string | boolean
17
+
18
+ // A control-output object (spring, easing) — carries a string `type` and is treated
19
+ // as a single leaf value. Plain objects WITHOUT `type` are folders and get flattened.
20
+ export interface ControlObject {
21
+ type: string
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export type LeafValue = ParamValue | ControlObject
26
+ // Loose on purpose: accepts both resolved DialKit values and raw config objects
27
+ // (defaultsFromConfig walks configs, which contain slider tuples, selects, actions).
28
+ export type NestedValues = Record<string, unknown>
29
+
30
+ export interface Override {
31
+ from: LeafValue
32
+ to: LeafValue
33
+ }
34
+
35
+ export interface Decision {
36
+ schema: 2
37
+ component: string
38
+ finalized: string
39
+ values: Record<string, LeafValue>
40
+ overridesFromDefault: Record<string, Override>
41
+ prune: string[]
42
+ note: string
43
+ status: 'pending'
44
+ timestamp: string
45
+ }
46
+
47
+ // Keys present in the DialKit values object that are NOT design params.
48
+ const RESERVED = new Set(['variant', 'finalize'])
49
+
50
+ const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
51
+
52
+ function normalizeHex(s: string): string {
53
+ let h = s.toLowerCase()
54
+ if (h.length === 4) h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]
55
+ return h
56
+ }
57
+
58
+ function isControlObject(v: unknown): v is ControlObject {
59
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && typeof (v as ControlObject).type === 'string'
60
+ }
61
+
62
+ function isFolder(v: unknown): v is NestedValues {
63
+ return typeof v === 'object' && v !== null && !Array.isArray(v) && !isControlObject(v)
64
+ }
65
+
66
+ // Equality that treats #FFF and #ffffff as the same color. Control objects (spring,
67
+ // easing) compare on the keys present in `a` (the default) so library-filled extras
68
+ // in the live value don't read as user overrides. Everything else strict.
69
+ export function valuesEqual(a: LeafValue, b: LeafValue): boolean {
70
+ if (typeof a === 'string' && typeof b === 'string' && HEX.test(a) && HEX.test(b)) {
71
+ return normalizeHex(a) === normalizeHex(b)
72
+ }
73
+ if (isControlObject(a) && isControlObject(b)) {
74
+ for (const k of Object.keys(a)) {
75
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false
76
+ }
77
+ return true
78
+ }
79
+ return a === b
80
+ }
81
+
82
+ // Flatten nested folder values to dot-paths. Skips reserved keys (variant/finalize at
83
+ // any depth — folder-per-element configs nest them), config-only keys (_collapsed),
84
+ // and DialKit internals (__mode).
85
+ export function flattenValues(nested: NestedValues, prefix = ''): Record<string, LeafValue> {
86
+ const out: Record<string, LeafValue> = {}
87
+ for (const k of Object.keys(nested)) {
88
+ if (k.startsWith('_')) continue
89
+ if (k.endsWith('__mode')) continue
90
+ if (RESERVED.has(k)) continue
91
+ const v = nested[k]
92
+ if (v === undefined) continue
93
+ const path = prefix ? `${prefix}.${k}` : k
94
+ if (isFolder(v)) {
95
+ Object.assign(out, flattenValues(v as NestedValues, path))
96
+ } else {
97
+ out[path] = v as LeafValue
98
+ }
99
+ }
100
+ return out
101
+ }
102
+
103
+ // Derive the frozen defaults from a DialKit config object — the reference the
104
+ // override diff is measured against. Walks folders; one default per control:
105
+ // number/string/boolean -> itself [def, min, max, step?] -> def
106
+ // select -> default ?? first option color/text -> default
107
+ // spring/easing -> the config object action -> skipped (no value)
108
+ export function defaultsFromConfig(config: NestedValues): NestedValues {
109
+ const out: NestedValues = {}
110
+ for (const k of Object.keys(config)) {
111
+ if (k.startsWith('_')) continue
112
+ if (RESERVED.has(k)) continue
113
+ const v = config[k]
114
+ if (v === undefined) continue
115
+ if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
116
+ out[k] = v
117
+ } else if (Array.isArray(v)) {
118
+ out[k] = v[0] as ParamValue
119
+ } else if (isControlObject(v)) {
120
+ if (v.type === 'action') continue
121
+ if (v.type === 'select') {
122
+ const opts = (v as { options?: unknown[] }).options ?? []
123
+ const first = opts[0]
124
+ const firstValue = typeof first === 'object' && first !== null ? (first as { value: string }).value : (first as string)
125
+ out[k] = ((v as { default?: string }).default ?? firstValue) as ParamValue
126
+ } else if (v.type === 'color' || v.type === 'text') {
127
+ out[k] = ((v as { default?: string }).default ?? '') as ParamValue
128
+ } else {
129
+ out[k] = v // spring / easing / unknown control: the object IS the default
130
+ }
131
+ } else if (isFolder(v)) {
132
+ out[k] = defaultsFromConfig(v as NestedValues)
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ export function buildDecision(
139
+ component: string,
140
+ liveValues: NestedValues,
141
+ defaults: NestedValues,
142
+ registry: Record<string, unknown>,
143
+ opts?: { now?: string; note?: string },
144
+ ): Decision {
145
+ // No `variant` in the live values means a single-variant set (no dropdown was rendered):
146
+ // the winner is the registry's only key.
147
+ const finalized = liveValues.variant !== undefined ? String(liveValues.variant) : (Object.keys(registry)[0] ?? '')
148
+
149
+ const values = flattenValues(liveValues)
150
+ const flatDefaults = flattenValues(defaults)
151
+
152
+ const overridesFromDefault: Record<string, Override> = {}
153
+ for (const k of Object.keys(values)) {
154
+ if (!(k in flatDefaults)) continue
155
+ if (!valuesEqual(flatDefaults[k], values[k])) {
156
+ overridesFromDefault[k] = { from: flatDefaults[k], to: values[k] }
157
+ }
158
+ }
159
+
160
+ const prune = Object.keys(registry).filter((k) => k !== finalized)
161
+
162
+ return {
163
+ schema: 2,
164
+ component,
165
+ finalized,
166
+ values,
167
+ overridesFromDefault,
168
+ prune,
169
+ note: opts?.note ?? '',
170
+ status: 'pending',
171
+ timestamp: opts?.now ?? new Date().toISOString(),
172
+ }
173
+ }
174
+
175
+ // Briefly morph the element's Finalize button (e.g. to "✓ Copied"), in the PANEL only —
176
+ // never an overlay on the app UI. Runs from copyDecision/submitDecision so EVERY finalize
177
+ // gets feedback, however it was wired. DialKit renders an action as
178
+ // `<button class="dialkit-button">Finalize X</button>` and does not re-render on an action
179
+ // (no value changed), so a direct text swap sticks until we revert it.
180
+ function flashFinalized(component: string, text: string): void {
181
+ if (typeof document === 'undefined') return
182
+ const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('.dialkit-root .dialkit-button'))
183
+ // Prefer the exact "Finalize <component>" button; fall back to the sole action button.
184
+ let btn = buttons.find((b) => b.textContent?.trim() === `Finalize ${component}`)
185
+ if (!btn && buttons.length === 1) btn = buttons[0]
186
+ if (!btn || btn.dataset.vkFlashing) return
187
+ const original = btn.textContent ?? ''
188
+ btn.dataset.vkFlashing = '1'
189
+ btn.textContent = text
190
+ setTimeout(() => {
191
+ if (btn!.dataset.vkFlashing) {
192
+ btn!.textContent = original
193
+ delete btn!.dataset.vkFlashing
194
+ }
195
+ }, 1500)
196
+ }
197
+
198
+ // Ship the decision to the dev server (vite plugin / Next route) so it lands in
199
+ // .variantkit/decisions/ and the agent can apply it with zero copy-paste. Falls back to
200
+ // the clipboard when no transport is running. NEVER fails silently.
201
+ const TRANSPORT_ENDPOINTS = ['/__variantkit/decision', '/api/__variantkit/decision']
202
+
203
+ export async function submitDecision(decision: Decision): Promise<void> {
204
+ if (typeof fetch !== 'undefined') {
205
+ for (const endpoint of TRANSPORT_ENDPOINTS) {
206
+ try {
207
+ const res = await fetch(endpoint, {
208
+ method: 'POST',
209
+ headers: { 'content-type': 'application/json' },
210
+ body: JSON.stringify(decision),
211
+ })
212
+ if (res.ok) {
213
+ flashFinalized(decision.component, '✓ Saved')
214
+ console.info(`[variantkit] decision saved to .variantkit/decisions/${decision.component}.json — tell your agent: "apply decision".`)
215
+ return
216
+ }
217
+ } catch {
218
+ // endpoint not running — try the next, then the clipboard
219
+ }
220
+ }
221
+ }
222
+ await copyDecision(decision)
223
+ }
224
+
225
+ // Copy the decision JSON to the clipboard. NEVER fail silently: if the Clipboard API is
226
+ // unavailable (insecure context, old webview), fall back to execCommand, then to a log.
227
+ // Also flashes the Finalize button to "✓ Copied" (panel-side feedback, no toast/overlay).
228
+ export async function copyDecision(decision: Decision): Promise<void> {
229
+ flashFinalized(decision.component, '✓ Copied')
230
+ const json = JSON.stringify(decision, null, 2)
231
+ try {
232
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
233
+ await navigator.clipboard.writeText(json)
234
+ console.info('[variantkit] decision copied. Paste it to your agent to prune.')
235
+ return
236
+ }
237
+ throw new Error('clipboard API unavailable')
238
+ } catch {
239
+ fallbackCopy(json)
240
+ }
241
+ }
242
+
243
+ function fallbackCopy(json: string): void {
244
+ if (typeof document === 'undefined') {
245
+ console.warn('[variantkit] no clipboard + no DOM. Decision:\n' + json)
246
+ return
247
+ }
248
+ const ta = document.createElement('textarea')
249
+ ta.value = json
250
+ ta.style.position = 'fixed'
251
+ ta.style.opacity = '0'
252
+ document.body.appendChild(ta)
253
+ ta.focus()
254
+ ta.select()
255
+ let ok = false
256
+ try {
257
+ ok = document.execCommand('copy')
258
+ } catch {
259
+ ok = false
260
+ }
261
+ document.body.removeChild(ta)
262
+ if (ok) console.info('[variantkit] decision copied via fallback. Paste it to your agent.')
263
+ else console.warn('[variantkit] could not copy automatically. Decision:\n' + json)
264
+ }
@@ -0,0 +1,70 @@
1
+ // VariantKit — panel assembly helpers. VariantKit does NOT decide what the controls are.
2
+ //
3
+ // The agent building the project authors the controls per element — contextual, specific,
4
+ // derived from that element's actual design axes and the project's design system. Any control
5
+ // DialKit supports is fair game: slider, select, toggle (boolean), color, text, spring /
6
+ // transition, nested folder groups. There is no preset menu and no VariantKit default value:
7
+ // every default comes from the project (its tokens, or the element's current values).
8
+ //
9
+ // VariantKit's only additions are structural:
10
+ // - a `variant` select (only when there are 2+ variants)
11
+ // - a `finalize` action
12
+ //
13
+ // Usage in a component's index.tsx (built on DialKit):
14
+ // const cfg = panelConfig(
15
+ // { tone: { type: 'select', options: ['quiet','bold'], default: 'quiet' }, // yours,
16
+ // accent: tokens.brand, compact: false }, // per element
17
+ // ['solid', 'outline', 'ghost'],
18
+ // { component: 'Button' },
19
+ // )
20
+ // const v = useDialKit('Button', cfg, { onAction: () =>
21
+ // copyDecision(buildDecision('Button', v, defaultsOf(cfg), regOf(['solid','outline','ghost']))) })
22
+
23
+ // Loose config shape — these are DialKit config objects; we don't import DialKit's types here
24
+ // so the helpers stay framework-light. `any` keeps the result assignable to DialKit's own
25
+ // config type at the useDialKit call site without coupling this file to DialKit.
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ export type Control = any
28
+ export type PanelConfig = Record<string, Control>
29
+
30
+ const RESERVED = new Set(['variant', 'finalize'])
31
+
32
+ // Assemble the full DialKit config for a variant set: [variant select +] the element's own
33
+ // controls + a finalize action. With a single variant key, no dropdown is added — the panel
34
+ // is just the element's controls + finalize.
35
+ export function panelConfig(
36
+ controls: PanelConfig,
37
+ variantKeys: string[],
38
+ opts?: { finalizeLabel?: string; component?: string },
39
+ ): PanelConfig {
40
+ return {
41
+ ...(variantKeys.length > 1
42
+ ? { variant: { type: 'select', options: variantKeys, default: variantKeys[0] } }
43
+ : {}),
44
+ ...controls,
45
+ finalize: { type: 'action', label: opts?.finalizeLabel ?? `Finalize ${opts?.component ?? ''}`.trim() },
46
+ }
47
+ }
48
+
49
+ // Resolve the frozen default value of each control — feeds buildDecision's `defaults`.
50
+ export function defaultsOf(cfg: PanelConfig): Record<string, number | string | boolean> {
51
+ const out: Record<string, number | string | boolean> = {}
52
+ for (const [key, c] of Object.entries(cfg)) {
53
+ if (RESERVED.has(key)) continue
54
+ if (typeof c === 'number' || typeof c === 'string' || typeof c === 'boolean') {
55
+ out[key] = c
56
+ } else if (Array.isArray(c)) {
57
+ out[key] = c[0] as number
58
+ } else if (c && typeof c === 'object') {
59
+ const o = c as { type?: string; default?: unknown }
60
+ if (o.type === 'action') continue
61
+ if (o.default !== undefined) out[key] = o.default as number | string | boolean
62
+ }
63
+ }
64
+ return out
65
+ }
66
+
67
+ // Convenience: registry object from variant keys, for buildDecision's prune computation.
68
+ export function regOf(variantKeys: string[]): Record<string, true> {
69
+ return Object.fromEntries(variantKeys.map((k) => [k, true]))
70
+ }
@@ -0,0 +1,46 @@
1
+ /* VariantKit panel cleanup for DialKit.
2
+ - Hide only the "Copy parameters" clipboard button (redundant with Finalize). The preset
3
+ toolbar STAYS — it's VariantKit's snapshot mechanism (save a tuned variant, switch, then
4
+ finalize the active one). See AGENT.md "Snapshots".
5
+ - Remove folder/header dividers so the panel reads on spacing alone, like DialKit's own UI. */
6
+ .dialkit-root [title='Copy parameters'] {
7
+ display: none !important;
8
+ }
9
+
10
+ /* DialKit ships the expanded panel with zero bottom padding (10px 12px 0 12px), so the last
11
+ control — usually Finalize — sits flush against the edge. Match the sides. The collapsed
12
+ bubble sets its own uniform 12px, so this only affects the open panel. */
13
+ .dialkit-root .dialkit-panel-inner:not([data-collapsed='true']) {
14
+ padding-bottom: 12px;
15
+ }
16
+ .dialkit-root .dialkit-panel-header {
17
+ border-bottom: none !important;
18
+ }
19
+ .dialkit-root .dialkit-folder {
20
+ border-top: none !important;
21
+ border-bottom: none !important;
22
+ }
23
+
24
+ /* The injected panel theme toggle — placed just left of DialKit's settings icon (right:12). */
25
+ .dialkit-root .vk-theme-toggle {
26
+ position: absolute;
27
+ top: 6px;
28
+ right: 40px;
29
+ width: 26px;
30
+ height: 26px;
31
+ display: inline-grid;
32
+ place-items: center;
33
+ border: none;
34
+ background: transparent;
35
+ border-radius: 7px;
36
+ cursor: pointer;
37
+ color: var(--dial-text-secondary);
38
+ z-index: 2;
39
+ transition: background-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
40
+ }
41
+ .dialkit-root .vk-theme-toggle:hover {
42
+ background: var(--dial-surface-hover);
43
+ }
44
+ .dialkit-root .vk-theme-toggle:active {
45
+ transform: scale(0.92);
46
+ }
@@ -0,0 +1,27 @@
1
+ /* Dark theme for DialKit's panel — cool, near-black, crisp (matches DialKit's own dark mode).
2
+ DialKit ships a light palette only and reads CSS vars on .dialkit-root[data-theme]; this
3
+ supplies the dark set. Set data-theme="dark" on .dialkit-root to activate. */
4
+ .dialkit-root[data-theme='dark'] {
5
+ --dial-glass-bg: #0c0c0d;
6
+ --dial-dropdown-bg: #1b1b1e;
7
+
8
+ --dial-surface: rgba(255, 255, 255, 0.055);
9
+ --dial-surface-hover: rgba(255, 255, 255, 0.09);
10
+ --dial-surface-active: rgba(255, 255, 255, 0.14);
11
+ --dial-surface-subtle: rgba(255, 255, 255, 0.04);
12
+
13
+ --dial-text-root: #fafafa;
14
+ --dial-text-section: rgba(255, 255, 255, 0.92);
15
+ --dial-text-label: rgba(255, 255, 255, 0.62);
16
+ --dial-text-focus: #ffffff;
17
+ --dial-text-primary: rgba(255, 255, 255, 0.95);
18
+ --dial-text-secondary: rgba(255, 255, 255, 0.55);
19
+ --dial-text-tertiary: rgba(255, 255, 255, 0.34);
20
+
21
+ --dial-border: rgba(255, 255, 255, 0.08);
22
+ --dial-border-hover: rgba(255, 255, 255, 0.16);
23
+
24
+ --dial-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);
25
+ --dial-shadow-collapsed: 0 4px 16px rgba(0, 0, 0, 0.5);
26
+ --dial-shadow-dropdown: 0 12px 32px rgba(0, 0, 0, 0.55);
27
+ }