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,216 @@
|
|
|
1
|
+
// VariantKit archetypes — per-element-family CHECKLISTS of design axes, composed from
|
|
2
|
+
// section builders (./sections.ts) plus the element's own controls. They exist so a
|
|
3
|
+
// configuration covers everything that's actually a design decision for an element
|
|
4
|
+
// (layout, surface, typography, color, motion, states, element-specific knobs) instead
|
|
5
|
+
// of 2-3 loose sliders.
|
|
6
|
+
//
|
|
7
|
+
// "VariantKit presents; the project decides" still holds (AGENT.md §0/§7): adapt the
|
|
8
|
+
// set per element, seed every default from the project's rendered values/tokens, drop
|
|
9
|
+
// axes the element doesn't have. The fallback values below are scaffolding for brand-new
|
|
10
|
+
// elements only — on an existing element, an unseeded default is a contract violation.
|
|
11
|
+
//
|
|
12
|
+
// Contract (AGENT.md §7): a non-trivial element gets an archetype panel — ≥4 folders,
|
|
13
|
+
// 12-25 controls — with defaults seeded from the code as rendered:
|
|
14
|
+
//
|
|
15
|
+
// useDialKit('PricingCard', {
|
|
16
|
+
// variant: { type: 'select', options: [...], default: '...' },
|
|
17
|
+
// ...pricingArchetype({ surface: { radius: 18 }, color: { accent: '#1F5E54' } }),
|
|
18
|
+
// finalize: { type: 'action', label: 'Finalize' },
|
|
19
|
+
// })
|
|
20
|
+
//
|
|
21
|
+
// Pure data — safe to import anywhere, prunes away cleanly.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
layoutSection,
|
|
25
|
+
surfaceSection,
|
|
26
|
+
typographySection,
|
|
27
|
+
colorSection,
|
|
28
|
+
motionSection,
|
|
29
|
+
statesSection,
|
|
30
|
+
type SectionConfig,
|
|
31
|
+
type Slider,
|
|
32
|
+
type SelectOption,
|
|
33
|
+
} from './sections'
|
|
34
|
+
|
|
35
|
+
export * from './sections'
|
|
36
|
+
|
|
37
|
+
type Overrides = {
|
|
38
|
+
layout?: Parameters<typeof layoutSection>[0]
|
|
39
|
+
surface?: Parameters<typeof surfaceSection>[0]
|
|
40
|
+
typography?: Parameters<typeof typographySection>[0]
|
|
41
|
+
color?: Parameters<typeof colorSection>[0]
|
|
42
|
+
motion?: Parameters<typeof motionSection>[0]
|
|
43
|
+
states?: Parameters<typeof statesSection>[0]
|
|
44
|
+
/** Extra top-level controls or folders merged into the panel. */
|
|
45
|
+
extra?: SectionConfig
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const select = (options: SelectOption[], def?: string) => ({ type: 'select' as const, options, default: def })
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export function buttonArchetype(o: Overrides & { size?: string; iconPosition?: string; fullWidth?: boolean } = {}) {
|
|
53
|
+
return {
|
|
54
|
+
size: select(['sm', 'md', 'lg'], o.size ?? 'md'),
|
|
55
|
+
iconPosition: select(['none', 'left', 'right'], o.iconPosition ?? 'none'),
|
|
56
|
+
fullWidth: o.fullWidth ?? false,
|
|
57
|
+
surface: surfaceSection({ radius: 8, shadow: 'none', ...o.surface }),
|
|
58
|
+
typography: typographySection({ size: 14, weight: '600', ...o.typography }),
|
|
59
|
+
color: colorSection(o.color),
|
|
60
|
+
states: statesSection(o.states),
|
|
61
|
+
motion: motionSection(o.motion, { collapsed: true }),
|
|
62
|
+
...o.extra,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cardArchetype(o: Overrides & { media?: string; density?: string } = {}) {
|
|
67
|
+
return {
|
|
68
|
+
media: select(['none', 'top', 'left'], o.media ?? 'none'),
|
|
69
|
+
density: select(['compact', 'regular', 'spacious'], o.density ?? 'regular'),
|
|
70
|
+
layout: layoutSection({ padding: 24, gap: 12, ...o.layout }),
|
|
71
|
+
surface: surfaceSection(o.surface),
|
|
72
|
+
typography: typographySection(o.typography, { collapsed: true }),
|
|
73
|
+
color: colorSection(o.color),
|
|
74
|
+
states: statesSection(o.states, { collapsed: true }),
|
|
75
|
+
motion: motionSection(o.motion, { collapsed: true }),
|
|
76
|
+
...o.extra,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function heroArchetype(o: Overrides & { alignment?: string; ctaStyle?: string; minHeight?: number } = {}) {
|
|
81
|
+
return {
|
|
82
|
+
alignment: select(['left', 'center'], o.alignment ?? 'left'),
|
|
83
|
+
ctaStyle: select(['solid', 'outline', 'ghost'], o.ctaStyle ?? 'solid'),
|
|
84
|
+
minHeight: [o.minHeight ?? 480, 240, 960, 10] as Slider,
|
|
85
|
+
layout: layoutSection({ padding: 64, gap: 24, maxWidth: 960, ...o.layout }),
|
|
86
|
+
typography: typographySection({ size: 44, weight: '700', lineHeight: 1.1, tracking: -0.5, ...o.typography }),
|
|
87
|
+
color: colorSection(o.color),
|
|
88
|
+
motion: motionSection(o.motion, { collapsed: true }),
|
|
89
|
+
...o.extra,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function navbarArchetype(o: Overrides & { height?: number; sticky?: boolean; blur?: number; linkGap?: number } = {}) {
|
|
94
|
+
return {
|
|
95
|
+
height: [o.height ?? 56, 40, 96, 1] as Slider,
|
|
96
|
+
sticky: o.sticky ?? true,
|
|
97
|
+
blur: [o.blur ?? 8, 0, 24, 1] as Slider,
|
|
98
|
+
linkGap: [o.linkGap ?? 24, 8, 64, 1] as Slider,
|
|
99
|
+
surface: surfaceSection({ shadow: 'xs', radius: 0, ...o.surface }),
|
|
100
|
+
typography: typographySection({ size: 14, ...o.typography }, { collapsed: true }),
|
|
101
|
+
color: colorSection(o.color),
|
|
102
|
+
motion: motionSection(o.motion, { collapsed: true }),
|
|
103
|
+
...o.extra,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function modalArchetype(o: Overrides & { width?: number; overlayOpacity?: number; backdropBlur?: number } = {}) {
|
|
108
|
+
return {
|
|
109
|
+
width: [o.width ?? 440, 280, 880, 10] as Slider,
|
|
110
|
+
overlayOpacity: [o.overlayOpacity ?? 0.5, 0, 1, 0.05] as Slider,
|
|
111
|
+
backdropBlur: [o.backdropBlur ?? 0, 0, 16, 1] as Slider,
|
|
112
|
+
layout: layoutSection({ padding: 24, gap: 16, ...o.layout }),
|
|
113
|
+
surface: surfaceSection({ radius: 12, shadow: 'xl', ...o.surface }),
|
|
114
|
+
typography: typographySection(o.typography, { collapsed: true }),
|
|
115
|
+
color: colorSection(o.color),
|
|
116
|
+
motion: motionSection(o.motion),
|
|
117
|
+
...o.extra,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function formArchetype(o: Overrides & { labelPosition?: string; fieldGap?: number; inputRadius?: number } = {}) {
|
|
122
|
+
return {
|
|
123
|
+
labelPosition: select(['top', 'left', 'floating'], o.labelPosition ?? 'top'),
|
|
124
|
+
fieldGap: [o.fieldGap ?? 16, 4, 48, 1] as Slider,
|
|
125
|
+
inputRadius: [o.inputRadius ?? 8, 0, 24, 1] as Slider,
|
|
126
|
+
layout: layoutSection({ padding: 0, gap: 16, ...o.layout }, { collapsed: true }),
|
|
127
|
+
surface: surfaceSection(o.surface, { collapsed: true }),
|
|
128
|
+
typography: typographySection({ size: 14, ...o.typography }),
|
|
129
|
+
color: colorSection(o.color),
|
|
130
|
+
states: statesSection(o.states, { collapsed: true }),
|
|
131
|
+
...o.extra,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function tableArchetype(o: Overrides & { density?: string; rowHeight?: number; striped?: boolean; dividers?: string } = {}) {
|
|
136
|
+
return {
|
|
137
|
+
density: select(['compact', 'regular', 'relaxed'], o.density ?? 'regular'),
|
|
138
|
+
rowHeight: [o.rowHeight ?? 44, 28, 72, 1] as Slider,
|
|
139
|
+
striped: o.striped ?? false,
|
|
140
|
+
dividers: select(['none', 'rows', 'all'], o.dividers ?? 'rows'),
|
|
141
|
+
surface: surfaceSection({ shadow: 'none', ...o.surface }),
|
|
142
|
+
typography: typographySection({ size: 13, ...o.typography }),
|
|
143
|
+
color: colorSection(o.color),
|
|
144
|
+
states: statesSection(o.states, { collapsed: true }),
|
|
145
|
+
...o.extra,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function listArchetype(o: Overrides & { marker?: string; itemGap?: number; dense?: boolean } = {}) {
|
|
150
|
+
return {
|
|
151
|
+
marker: select(['none', 'dot', 'check', 'number'], o.marker ?? 'none'),
|
|
152
|
+
itemGap: [o.itemGap ?? 8, 0, 32, 1] as Slider,
|
|
153
|
+
dense: o.dense ?? false,
|
|
154
|
+
layout: layoutSection({ padding: 0, gap: 8, ...o.layout }, { collapsed: true }),
|
|
155
|
+
typography: typographySection(o.typography),
|
|
156
|
+
color: colorSection(o.color),
|
|
157
|
+
states: statesSection(o.states, { collapsed: true }),
|
|
158
|
+
...o.extra,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function badgeArchetype(o: Overrides & { size?: string; pill?: boolean; tone?: string } = {}) {
|
|
163
|
+
return {
|
|
164
|
+
size: select(['sm', 'md'], o.size ?? 'sm'),
|
|
165
|
+
pill: o.pill ?? true,
|
|
166
|
+
tone: select(['neutral', 'accent', 'success', 'warning', 'danger'], o.tone ?? 'neutral'),
|
|
167
|
+
surface: surfaceSection({ radius: 6, shadow: 'none', borderWidth: 1, ...o.surface }),
|
|
168
|
+
typography: typographySection({ size: 12, weight: '500', ...o.typography }),
|
|
169
|
+
color: colorSection(o.color),
|
|
170
|
+
...o.extra,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function pricingArchetype(o: Overrides & { priceSize?: number; featured?: boolean; ctaStyle?: string } = {}) {
|
|
175
|
+
return {
|
|
176
|
+
priceSize: [o.priceSize ?? 36, 20, 64, 1] as Slider,
|
|
177
|
+
featured: o.featured ?? false,
|
|
178
|
+
ctaStyle: select(['solid', 'outline', 'ghost'], o.ctaStyle ?? 'solid'),
|
|
179
|
+
layout: layoutSection({ padding: 28, gap: 16, maxWidth: 380, ...o.layout }),
|
|
180
|
+
surface: surfaceSection(o.surface),
|
|
181
|
+
typography: typographySection(o.typography, { collapsed: true }),
|
|
182
|
+
color: colorSection(o.color),
|
|
183
|
+
states: statesSection(o.states, { collapsed: true }),
|
|
184
|
+
motion: motionSection(o.motion, { collapsed: true }),
|
|
185
|
+
...o.extra,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function sectionArchetype(o: Overrides & { paddingY?: number } = {}) {
|
|
190
|
+
return {
|
|
191
|
+
paddingY: [o.paddingY ?? 80, 0, 240, 4] as Slider,
|
|
192
|
+
layout: layoutSection({ padding: 24, gap: 32, maxWidth: 1120, ...o.layout }),
|
|
193
|
+
surface: surfaceSection({ shadow: 'none', radius: 0, ...o.surface }),
|
|
194
|
+
typography: typographySection({ size: 32, weight: '700', ...o.typography }),
|
|
195
|
+
color: colorSection(o.color),
|
|
196
|
+
...o.extra,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Lookup for agents: archetype key -> builder. Pick the closest archetype; when nothing
|
|
201
|
+
// fits, compose sections directly per AGENT.md §7.
|
|
202
|
+
export const ARCHETYPES = {
|
|
203
|
+
button: buttonArchetype,
|
|
204
|
+
card: cardArchetype,
|
|
205
|
+
hero: heroArchetype,
|
|
206
|
+
navbar: navbarArchetype,
|
|
207
|
+
modal: modalArchetype,
|
|
208
|
+
form: formArchetype,
|
|
209
|
+
table: tableArchetype,
|
|
210
|
+
list: listArchetype,
|
|
211
|
+
badge: badgeArchetype,
|
|
212
|
+
pricing: pricingArchetype,
|
|
213
|
+
section: sectionArchetype,
|
|
214
|
+
} as const
|
|
215
|
+
|
|
216
|
+
export type ArchetypeKey = keyof typeof ARCHETYPES
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// VariantKit section builders — composable chunks of a DialKit config. Each returns a
|
|
2
|
+
// plain config object (a DialKit folder body) covering one design dimension with the
|
|
3
|
+
// controls that dimension actually needs. Archetypes (./archetypes.ts) compose these
|
|
4
|
+
// into full panels so an element's panel feels like its real configuration panel,
|
|
5
|
+
// not 2-3 loose sliders.
|
|
6
|
+
//
|
|
7
|
+
// Pure data — no imports from dialkit/react. Pass current-code values as overrides so
|
|
8
|
+
// the panel opens matching what's rendered:
|
|
9
|
+
//
|
|
10
|
+
// surface: surfaceSection({ radius: 16, bg: '#0A0A0A' })
|
|
11
|
+
//
|
|
12
|
+
// Drop a control you don't need by destructuring:
|
|
13
|
+
//
|
|
14
|
+
// const { maxWidth, ...layout } = layoutSection()
|
|
15
|
+
|
|
16
|
+
export type SelectOption = string | { value: string; label: string }
|
|
17
|
+
export type Select = { type: 'select'; options: SelectOption[]; default?: string }
|
|
18
|
+
export type Color = { type: 'color'; default?: string }
|
|
19
|
+
export type Slider = [number, number, number] | [number, number, number, number]
|
|
20
|
+
export type Spring = { type: 'spring'; visualDuration?: number; bounce?: number; stiffness?: number; damping?: number; mass?: number }
|
|
21
|
+
export type SectionConfig = Record<string, unknown>
|
|
22
|
+
|
|
23
|
+
export interface SectionOpts {
|
|
24
|
+
collapsed?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function section<T extends SectionConfig>(body: T, opts?: SectionOpts): T {
|
|
28
|
+
return (opts?.collapsed ? { _collapsed: true, ...body } : body) as T
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const select = (options: SelectOption[], def?: string): Select => ({ type: 'select', options, default: def })
|
|
32
|
+
const color = (def: string): Color => ({ type: 'color', default: def })
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Token resolvers — selects return tokens; variants map them to CSS with these.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export const SHADOWS: Record<string, string> = {
|
|
39
|
+
none: 'none',
|
|
40
|
+
xs: '0 1px 2px rgba(0,0,0,0.05)',
|
|
41
|
+
sm: '0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)',
|
|
42
|
+
md: '0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04)',
|
|
43
|
+
lg: '0 12px 32px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.05)',
|
|
44
|
+
xl: '0 24px 56px rgba(0,0,0,0.16), 0 8px 16px rgba(0,0,0,0.06)',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FONT_STACKS: Record<string, string> = {
|
|
48
|
+
system: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
|
49
|
+
sans: "'Inter', system-ui, sans-serif",
|
|
50
|
+
serif: "'Georgia', 'Times New Roman', serif",
|
|
51
|
+
mono: "'SF Mono', 'JetBrains Mono', monospace",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SHADOW_LEVELS = Object.keys(SHADOWS)
|
|
55
|
+
export const FONT_FAMILIES = Object.keys(FONT_STACKS)
|
|
56
|
+
export const WEIGHTS = ['400', '500', '600', '700', '800']
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Sections
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export function layoutSection(
|
|
63
|
+
d: Partial<{ padding: number; gap: number; align: 'start' | 'center' | 'end'; direction: 'row' | 'column'; maxWidth: number }> = {},
|
|
64
|
+
opts?: SectionOpts,
|
|
65
|
+
) {
|
|
66
|
+
return section(
|
|
67
|
+
{
|
|
68
|
+
padding: [d.padding ?? 24, 0, 96, 1] as Slider,
|
|
69
|
+
gap: [d.gap ?? 12, 0, 64, 1] as Slider,
|
|
70
|
+
align: select(['start', 'center', 'end'], d.align ?? 'start'),
|
|
71
|
+
direction: select(['column', 'row'], d.direction ?? 'column'),
|
|
72
|
+
maxWidth: [d.maxWidth ?? 480, 240, 1440, 10] as Slider,
|
|
73
|
+
},
|
|
74
|
+
opts,
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function surfaceSection(
|
|
79
|
+
d: Partial<{ bg: string; radius: number; borderWidth: number; borderColor: string; shadow: string }> = {},
|
|
80
|
+
opts?: SectionOpts,
|
|
81
|
+
) {
|
|
82
|
+
return section(
|
|
83
|
+
{
|
|
84
|
+
bg: color(d.bg ?? '#ffffff'),
|
|
85
|
+
radius: [d.radius ?? 12, 0, 32, 1] as Slider,
|
|
86
|
+
borderWidth: [d.borderWidth ?? 1, 0, 4, 1] as Slider,
|
|
87
|
+
borderColor: color(d.borderColor ?? '#e4e4e7'),
|
|
88
|
+
shadow: select(SHADOW_LEVELS, d.shadow ?? 'sm'),
|
|
89
|
+
},
|
|
90
|
+
opts,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function typographySection(
|
|
95
|
+
d: Partial<{ size: number; weight: string; tracking: number; lineHeight: number; family: string }> = {},
|
|
96
|
+
opts?: SectionOpts,
|
|
97
|
+
) {
|
|
98
|
+
return section(
|
|
99
|
+
{
|
|
100
|
+
size: [d.size ?? 16, 10, 48, 1] as Slider,
|
|
101
|
+
weight: select(WEIGHTS, d.weight ?? '500'),
|
|
102
|
+
tracking: [d.tracking ?? 0, -1, 2, 0.05] as Slider,
|
|
103
|
+
lineHeight: [d.lineHeight ?? 1.4, 1, 2, 0.05] as Slider,
|
|
104
|
+
family: select(FONT_FAMILIES, d.family ?? 'system'),
|
|
105
|
+
},
|
|
106
|
+
opts,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function colorSection(
|
|
111
|
+
d: Partial<{ accent: string; fg: string; bg: string; muted: string }> = {},
|
|
112
|
+
opts?: SectionOpts,
|
|
113
|
+
) {
|
|
114
|
+
return section(
|
|
115
|
+
{
|
|
116
|
+
accent: color(d.accent ?? '#18181b'),
|
|
117
|
+
fg: color(d.fg ?? '#18181b'),
|
|
118
|
+
bg: color(d.bg ?? '#ffffff'),
|
|
119
|
+
muted: color(d.muted ?? '#71717a'),
|
|
120
|
+
},
|
|
121
|
+
opts,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function motionSection(
|
|
126
|
+
d: Partial<{ spring: Spring; hoverScale: number; duration: number }> = {},
|
|
127
|
+
opts?: SectionOpts,
|
|
128
|
+
) {
|
|
129
|
+
return section(
|
|
130
|
+
{
|
|
131
|
+
spring: d.spring ?? ({ type: 'spring', visualDuration: 0.3, bounce: 0.15 } as Spring),
|
|
132
|
+
hoverScale: [d.hoverScale ?? 1.02, 1, 1.12, 0.005] as Slider,
|
|
133
|
+
duration: [d.duration ?? 0.25, 0, 1.5, 0.05] as Slider,
|
|
134
|
+
},
|
|
135
|
+
opts,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function statesSection(
|
|
140
|
+
d: Partial<{ hoverBg: string; hoverShadow: string; activeScale: number }> = {},
|
|
141
|
+
opts?: SectionOpts,
|
|
142
|
+
) {
|
|
143
|
+
return section(
|
|
144
|
+
{
|
|
145
|
+
hoverBg: color(d.hoverBg ?? '#fafafa'),
|
|
146
|
+
hoverShadow: select(SHADOW_LEVELS, d.hoverShadow ?? 'md'),
|
|
147
|
+
activeScale: [d.activeScale ?? 0.98, 0.9, 1, 0.005] as Slider,
|
|
148
|
+
},
|
|
149
|
+
opts,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: variantkit
|
|
3
|
+
description: AI-assisted UI exploration. When the user asks to build, design, or change any user-facing UI (a component, screen, section, state, hero, card, layout, or visual treatment), generate 2-4 structural variants instead of one and wire them to a live, FULL configuration panel so they can switch, tweak everything, finalize, and have the losers pruned to one clean component. Also wraps existing components in their own configuration panel on "paramify" / "let me tweak this" / "give me controls", applies pending decisions on "apply decision", and handles "deslop" / "remove the AI slop" requests. Triggers on building/redesigning UI, "give me options/takes/variants", "explore directions", "paramify", "let me tweak", "give me controls", "apply decision", "deslop", "this looks AI-generated".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# VariantKit
|
|
7
|
+
|
|
8
|
+
Make UI exploration cheap and structured: generate several variants the user judges live,
|
|
9
|
+
then prune to the winner. This skill is self-contained — you can scaffold and prune from it
|
|
10
|
+
alone. If the project has an `AGENT.md` with a VariantKit section, that is authoritative; read
|
|
11
|
+
it and prefer it over this summary.
|
|
12
|
+
|
|
13
|
+
## When to use (proactively)
|
|
14
|
+
|
|
15
|
+
Trigger whenever the user asks to build or change user-facing UI and the result is open-ended
|
|
16
|
+
(aesthetic, layout, tone, structure, density). Default to offering 2-4 variants. Skip only for
|
|
17
|
+
mechanical, exactly-specified changes, or when the user says "just one".
|
|
18
|
+
|
|
19
|
+
## Step 1 — make sure the project is set up
|
|
20
|
+
|
|
21
|
+
Check for `dialkit` in the project's deps and a `buildDecision.ts` (usually `src/variantkit/`).
|
|
22
|
+
If missing, set it up from the project root:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx variantkit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
That installs `dialkit motion` + the runtime (`buildDecision`, `configs`, `react` Studio
|
|
29
|
+
helper, `dialkit-clean.css`, `dialkit-dark.css`, `motion.css`), `AGENT.md`, and a rules pointer.
|
|
30
|
+
Ensure the panel host + stylesheets are set up once in the app root (DialRoot is a sibling,
|
|
31
|
+
not a wrapper):
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { DialRoot } from 'dialkit'
|
|
35
|
+
import 'dialkit/styles.css'
|
|
36
|
+
import './variantkit/dialkit-clean.css' // hide redundant copy button + dividers (keeps snapshots)
|
|
37
|
+
import './variantkit/dialkit-dark.css' // optional dark palette; set data-theme="dark" on .dialkit-root
|
|
38
|
+
import './variantkit/motion.css' // stagger, press feedback, easings, reduced-motion
|
|
39
|
+
// render <App /> and <DialRoot /> as siblings
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The installer also wires the decision transport (vite plugin / Next API route), mounts
|
|
43
|
+
`<DialRoot/>` + `<VariantBar/>` (tabs, keys 1..9, live Compare grid, Finalize) and the
|
|
44
|
+
stylesheets automatically. Check or undo anytime: `npx variantkit doctor`
|
|
45
|
+
(15 checks with fix-its) / `npx variantkit remove` (zero-residue).
|
|
46
|
+
|
|
47
|
+
Finalize feedback is in-button (the button morphs to "✓ Saved" via the transport, or
|
|
48
|
+
"✓ Copied" on the clipboard fallback), so no toast is needed. **Snapshots:** the panel's preset toolbar (≡+ / Version) saves a tuned
|
|
49
|
+
variant — use it to keep two tunings and switch between them; Finalize acts on the active one.
|
|
50
|
+
|
|
51
|
+
## The rules that make this work (never violate)
|
|
52
|
+
|
|
53
|
+
**VariantKit presents; the project decides.**
|
|
54
|
+
- **Design comes from the project.** You already know this project's design system, tokens,
|
|
55
|
+
and guidelines — every variant follows them, exactly as a hand-built component would.
|
|
56
|
+
VariantKit ships no colors, radii, fonts, or "house style"; never introduce one.
|
|
57
|
+
- **Controls come from the element.** Author the controls that genuinely matter for tweaking
|
|
58
|
+
THIS element — its real design axes. Any number, any kind of control; there is no standard
|
|
59
|
+
set. Never reuse a control set across unrelated elements.
|
|
60
|
+
- **Defaults come from the code.** Every control default is the element's current/intended
|
|
61
|
+
value from the project (tokens, existing styles) — never an invented literal.
|
|
62
|
+
- **The rendered element stays untouched.** No rings, badges, overlays, or layout imposed on
|
|
63
|
+
the project's UI — the user judges the element exactly as it ships. All feedback lives in
|
|
64
|
+
the panel.
|
|
65
|
+
|
|
66
|
+
If you cannot run the installer (offline), you can still scaffold using the recipe below; add
|
|
67
|
+
`dialkit motion` to deps and copy `buildDecision.ts` from the variantkit repo when possible.
|
|
68
|
+
|
|
69
|
+
## Vocabulary (use these exactly)
|
|
70
|
+
|
|
71
|
+
**Element** = the thing being designed. **Variant** = one structural take on it. **Control** =
|
|
72
|
+
one setting; **Configuration** = the contextual set of controls for an element. **Snapshot** =
|
|
73
|
+
a saved variant+values state. **Finalize** → writes a **Decision** → agent **prunes** losers.
|
|
74
|
+
Full glossary: `NAMING.md`.
|
|
75
|
+
|
|
76
|
+
## Step 2 — scaffold a variant set
|
|
77
|
+
|
|
78
|
+
**Easiest path — the `Studio` helper.** For one OR many elements, prefer it over hand-wiring;
|
|
79
|
+
it gives one panel, a folder per element, finalize routing, and focus-on-hover:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { Studio, type ElementDef } from './variantkit/react'
|
|
83
|
+
|
|
84
|
+
const ELEMENTS: ElementDef[] = [
|
|
85
|
+
{
|
|
86
|
+
name: 'PricingCard',
|
|
87
|
+
keys: ['slab', 'ledger', 'inverse'],
|
|
88
|
+
// Authored for THIS element, defaults from THIS project's tokens — not a fixed menu.
|
|
89
|
+
controls: {
|
|
90
|
+
density: { type: 'select', options: ['compact', 'comfortable'], default: 'comfortable' },
|
|
91
|
+
accent: tokens.brand,
|
|
92
|
+
showAnnualToggle: true,
|
|
93
|
+
},
|
|
94
|
+
render: (variant, v) => <Card variant={variant} {...v} />,
|
|
95
|
+
},
|
|
96
|
+
// add more elements here; each gets its own folder + its own authored controls
|
|
97
|
+
]
|
|
98
|
+
<Studio elements={ELEMENTS} focusOnHover />
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`controls` is whatever fits the element — any DialKit control: number `[default,min,max]` →
|
|
102
|
+
slider, string → text, `#hex` → color, boolean → segmented toggle, `select` → dropdown,
|
|
103
|
+
`spring`/`transition` → motion editor (only for elements that move), nested object → folder
|
|
104
|
+
group. Ask "which axes of this element would the developer tweak before committing?" and
|
|
105
|
+
expose exactly those. For a single element, pass one entry; with one variant key no dropdown
|
|
106
|
+
is shown. `focusOnHover` expands the hovered element's folder — panel-side only, nothing is
|
|
107
|
+
drawn over the rendered element. Mount `<DialRoot/>` once in the app root. Still author the
|
|
108
|
+
variant components file-per-variant (recipe below) so the prune stays a clean delete.
|
|
109
|
+
|
|
110
|
+
### The completeness bar (AGENT.md §7)
|
|
111
|
+
|
|
112
|
+
A panel with 2-3 loose sliders is a failure: during exploration the panel must feel like
|
|
113
|
+
the element's ACTUAL configuration panel. Every design literal a variant renders becomes a
|
|
114
|
+
control (paramify rule). Non-trivial element ⇒ ≥4 folders, 12-25 controls; collapse the
|
|
115
|
+
secondary ones. Use the archetype checklists in `variantkit/schemas/archetypes.ts`
|
|
116
|
+
(button, card, hero, navbar, modal, form, table, list, badge, pricing, section) — they are
|
|
117
|
+
checklists to ADAPT and seed from the project's real values, never sets to paste. Drop a
|
|
118
|
+
control that is a variant's structural identity by destructuring; resolve token selects
|
|
119
|
+
(shadow, font family) to CSS in the shell via `SHADOWS` / `FONT_STACKS`.
|
|
120
|
+
|
|
121
|
+
### Manual wiring (if not using the helper)
|
|
122
|
+
|
|
123
|
+
File-per-variant. Only `index.tsx` wires the tool; each variant is a plain, self-contained
|
|
124
|
+
component. This is what makes the later prune reliable (delete files + one rename).
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
ComponentName/
|
|
128
|
+
index.tsx # the ONLY file importing dialkit / registry / buildDecision
|
|
129
|
+
registry.ts # { key: { component, label } }
|
|
130
|
+
variants/
|
|
131
|
+
<a>.tsx # 2-4 self-contained components, same props, same morph transition
|
|
132
|
+
<b>.tsx
|
|
133
|
+
<c>.tsx
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`index.tsx` — variant = a DialKit `select`, your authored controls, finalize = an `action`.
|
|
137
|
+
(Control names/defaults below are placeholders — derive yours from the element + the
|
|
138
|
+
project's tokens.)
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { useDialKit } from 'dialkit'
|
|
142
|
+
import { registry } from './registry'
|
|
143
|
+
import { buildDecision, submitDecision, type ParamValue } from '../../core/buildDecision'
|
|
144
|
+
|
|
145
|
+
// Defaults = the project's real values (tokens / current styles), never invented literals.
|
|
146
|
+
const DEFAULTS: Record<string, ParamValue> = { density: 'comfortable', accent: tokens.brand }
|
|
147
|
+
|
|
148
|
+
export default function ComponentName(props: { /* real props */ }) {
|
|
149
|
+
const v = useDialKit('ComponentName', {
|
|
150
|
+
variant: { type: 'select', options: ['a', 'b', 'c'], default: 'a' }, // omit when only one variant
|
|
151
|
+
density: { type: 'select', options: ['compact', 'comfortable'], default: DEFAULTS.density },
|
|
152
|
+
accent: DEFAULTS.accent as string,
|
|
153
|
+
finalize: { type: 'action', label: 'Finalize' },
|
|
154
|
+
}, {
|
|
155
|
+
onAction: () => submitDecision(buildDecision('ComponentName', v as Record<string, ParamValue>, DEFAULTS, registry)),
|
|
156
|
+
}) as Record<string, ParamValue>
|
|
157
|
+
|
|
158
|
+
const Active = registry[String(v.variant)].component
|
|
159
|
+
return <Active {...props} density={String(v.density)} accent={v.accent as string} />
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Each variant declares the **same** transition (a local const, duplicated on purpose so it
|
|
164
|
+
stays self-contained), so switching morphs rather than snaps:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const morph = { transition: 'border-radius .25s ease, background-color .25s ease, box-shadow .25s ease, padding .25s ease' }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Every variant must pass the deslop checklist below — generated UI must not look AI-generated.
|
|
171
|
+
|
|
172
|
+
## Step 3 — finalize → prune
|
|
173
|
+
|
|
174
|
+
Finalize ships a `decision.json` (schema 2, dot-path values): `{ schema, component,
|
|
175
|
+
finalized, values, overridesFromDefault, prune[], note, status, timestamp }` — through the
|
|
176
|
+
dev transport into `.variantkit/decisions/<Component>.json`, or to the clipboard when no
|
|
177
|
+
transport is running. On "apply decision" (or at session start) scan
|
|
178
|
+
`.variantkit/decisions/*.json` for `status: "pending"`; a pasted decision applies the same
|
|
179
|
+
way. Prune (mechanical — never move JSX between files):
|
|
180
|
+
|
|
181
|
+
1. Inline `values` (as literals) into the winner file `variants/<finalized>.tsx`.
|
|
182
|
+
2. Rename that file to `index.tsx`, overwriting the shell. Do NOT copy code by hand.
|
|
183
|
+
3. Delete every loser in `prune` + leftover `variants/*`; delete `registry.ts`.
|
|
184
|
+
4. Remove the DialKit wiring. If this was the last variant set, also drop `<DialRoot/>`.
|
|
185
|
+
5. Resolve: append the decision (status "resolved") to `.variantkit/history/log.jsonl`,
|
|
186
|
+
delete the pending decision file.
|
|
187
|
+
|
|
188
|
+
## Paramify an existing component (no variants)
|
|
189
|
+
|
|
190
|
+
On "paramify this" / "let me tweak this" / "give me controls for this": wrap the EXISTING
|
|
191
|
+
component in its full configuration per AGENT.md §7 — adapted archetype + finalize action,
|
|
192
|
+
props fed from panel values; no registry, no variants/. On finalize: inline the chosen
|
|
193
|
+
values as literals and strip the wiring completely.
|
|
194
|
+
|
|
195
|
+
## Taste memory
|
|
196
|
+
|
|
197
|
+
Before scaffolding, read `.variantkit/TASTE.md` if present — seed defaults toward the
|
|
198
|
+
observed preferences, plus one variant that deliberately breaks the pattern. After
|
|
199
|
+
resolving, if `.variantkit/history/log.jsonl` has ≥3 entries, distill/update `TASTE.md`
|
|
200
|
+
per AGENT.md §8 — grounded claims only, every bullet cites ≥2 decisions with real values.
|
|
201
|
+
|
|
202
|
+
Self-check (all must hold): no file imports `dialkit` / `registry` / `buildDecision`;
|
|
203
|
+
`variants/` and `registry.ts` gone; `index.tsx` renders the winner with values inlined; the
|
|
204
|
+
visible output matches the chosen variant.
|
|
205
|
+
|
|
206
|
+
## Deslop — applies to generated UI, never the DialKit panel
|
|
207
|
+
|
|
208
|
+
Slop is decoration without a system: a tell is slop as a one-off, fine as a consistent,
|
|
209
|
+
repeated system. Default to keep when unsure; subtract, never add. On "deslop" / "this looks
|
|
210
|
+
AI-generated", run a pass: scope → scan → judge each → remove only slop → verify in browser →
|
|
211
|
+
report a table. Catalog:
|
|
212
|
+
|
|
213
|
+
1. Random italics on headings/labels → remove; use weight/size for emphasis.
|
|
214
|
+
2. Random mono font for "tech feel" → revert to UI font; numbers use `tabular-nums`.
|
|
215
|
+
3. All-caps + wide-tracking "eyebrow" kickers → delete, or keep at most one system-wide.
|
|
216
|
+
4. Decorative accent/divider stub lines → remove; keep only full structural rules.
|
|
217
|
+
5. Ornamental colored/pulsing dots → remove; keep dots that encode real state.
|
|
218
|
+
6. Unmotivated warm accents (amber/orange/rose) → map to real tokens; reserve for semantics.
|
|
219
|
+
7. Decorative single letters/monograms standing for nothing → remove.
|
|
220
|
+
8. Oversized radii (≥20px) → 8-12px, concentric (inner = outer − padding).
|
|
221
|
+
9. One-sided / gradient highlight borders for flair → uniform 1px or none.
|
|
222
|
+
10. Em dashes in copy → commas, colons, or separate sentences.
|
|
223
|
+
11. Emoji in product UI → remove.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// VariantKit decision transport for Next.js (Pages Router) — dev-only. Installed by
|
|
2
|
+
// `variantkit init` at pages/api/__variantkit/decision.ts. Returns 404 outside development.
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import type { NextApiRequest, NextApiResponse } from 'next'
|
|
7
|
+
|
|
8
|
+
const NAME_OK = /^[A-Za-z0-9_-]+$/
|
|
9
|
+
|
|
10
|
+
export default function handler(req: NextApiRequest, res: NextApiResponse): void {
|
|
11
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
12
|
+
res.status(404).end('not found')
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
if (req.method !== 'POST') {
|
|
16
|
+
res.status(405).end('method not allowed')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
const decision = req.body
|
|
20
|
+
const component = String(decision?.component ?? '')
|
|
21
|
+
if (!NAME_OK.test(component)) {
|
|
22
|
+
res.status(400).end('invalid component name')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
const dir = join(process.cwd(), '.variantkit', 'decisions')
|
|
26
|
+
const historyDir = join(process.cwd(), '.variantkit', 'history')
|
|
27
|
+
mkdirSync(dir, { recursive: true })
|
|
28
|
+
mkdirSync(historyDir, { recursive: true })
|
|
29
|
+
writeFileSync(join(dir, `${component}.json`), JSON.stringify(decision, null, 2) + '\n')
|
|
30
|
+
appendFileSync(join(historyDir, 'log.jsonl'), JSON.stringify(decision) + '\n')
|
|
31
|
+
console.log(`[variantkit] decision saved: .variantkit/decisions/${component}.json`)
|
|
32
|
+
res.status(200).json({ ok: true })
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// VariantKit decision transport for Next.js (App Router) — dev-only. The panel's
|
|
2
|
+
// finalize button POSTs here; the decision lands in .variantkit/decisions/ and the
|
|
3
|
+
// agent applies it per AGENT.md §4. Installed by `variantkit init` at
|
|
4
|
+
// app/api/__variantkit/decision/route.ts. Returns 404 outside development.
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const NAME_OK = /^[A-Za-z0-9_-]+$/
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request): Promise<Response> {
|
|
12
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
13
|
+
return new Response('not found', { status: 404 })
|
|
14
|
+
}
|
|
15
|
+
let decision: { component?: unknown }
|
|
16
|
+
try {
|
|
17
|
+
decision = await req.json()
|
|
18
|
+
} catch {
|
|
19
|
+
return new Response('bad decision payload', { status: 400 })
|
|
20
|
+
}
|
|
21
|
+
const component = String(decision.component ?? '')
|
|
22
|
+
if (!NAME_OK.test(component)) {
|
|
23
|
+
return new Response('invalid component name', { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
const dir = join(process.cwd(), '.variantkit', 'decisions')
|
|
26
|
+
const historyDir = join(process.cwd(), '.variantkit', 'history')
|
|
27
|
+
mkdirSync(dir, { recursive: true })
|
|
28
|
+
mkdirSync(historyDir, { recursive: true })
|
|
29
|
+
writeFileSync(join(dir, `${component}.json`), JSON.stringify(decision, null, 2) + '\n')
|
|
30
|
+
appendFileSync(join(historyDir, 'log.jsonl'), JSON.stringify(decision) + '\n')
|
|
31
|
+
console.log(`[variantkit] decision saved: .variantkit/decisions/${component}.json`)
|
|
32
|
+
return Response.json({ ok: true })
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Types for vite-plugin.mjs — keeps strict vite.config.ts type-checks green without
|
|
2
|
+
// depending on vite's own types being installed.
|
|
3
|
+
declare function variantkit(): {
|
|
4
|
+
name: string
|
|
5
|
+
apply: 'serve'
|
|
6
|
+
configureServer(server: unknown): void
|
|
7
|
+
}
|
|
8
|
+
export default variantkit
|