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,32 @@
|
|
|
1
|
+
diff --git a/node_modules/dialkit/dist/index.cjs b/node_modules/dialkit/dist/index.cjs
|
|
2
|
+
index d9f4837..4bf4e6d 100644
|
|
3
|
+
--- a/node_modules/dialkit/dist/index.cjs
|
|
4
|
+
+++ b/node_modules/dialkit/dist/index.cjs
|
|
5
|
+
@@ -1006,8 +1006,9 @@ function Folder({ title, children, defaultOpen = true, isRoot = false, inline =
|
|
6
|
+
style: panelStyle,
|
|
7
|
+
onClick: !isOpen ? handleToggle : void 0,
|
|
8
|
+
"data-collapsed": isCollapsed,
|
|
9
|
+
- whileTap: !isOpen ? { scale: 0.9 } : void 0,
|
|
10
|
+
- transition: { type: "spring", visualDuration: 0.15, bounce: 0.3 },
|
|
11
|
+
+ whileHover: !isOpen ? { scale: 1.06, boxShadow: "0 10px 28px rgba(0,0,0,0.34)" } : void 0,
|
|
12
|
+
+ whileTap: !isOpen ? { scale: 0.88 } : void 0,
|
|
13
|
+
+ transition: isOpen ? { type: "spring", visualDuration: 0.44, bounce: 0.26 } : { type: "spring", visualDuration: 0.3, bounce: 0.14 },
|
|
14
|
+
children: folderContent
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
diff --git a/node_modules/dialkit/dist/index.js b/node_modules/dialkit/dist/index.js
|
|
18
|
+
index f8d8baf..7a0e7b3 100644
|
|
19
|
+
--- a/node_modules/dialkit/dist/index.js
|
|
20
|
+
+++ b/node_modules/dialkit/dist/index.js
|
|
21
|
+
@@ -966,8 +966,9 @@ function Folder({ title, children, defaultOpen = true, isRoot = false, inline =
|
|
22
|
+
style: panelStyle,
|
|
23
|
+
onClick: !isOpen ? handleToggle : void 0,
|
|
24
|
+
"data-collapsed": isCollapsed,
|
|
25
|
+
- whileTap: !isOpen ? { scale: 0.9 } : void 0,
|
|
26
|
+
- transition: { type: "spring", visualDuration: 0.15, bounce: 0.3 },
|
|
27
|
+
+ whileHover: !isOpen ? { scale: 1.06, boxShadow: "0 10px 28px rgba(0,0,0,0.34)" } : void 0,
|
|
28
|
+
+ whileTap: !isOpen ? { scale: 0.88 } : void 0,
|
|
29
|
+
+ transition: isOpen ? { type: "spring", visualDuration: 0.44, bounce: 0.26 } : { type: "spring", visualDuration: 0.3, bounce: 0.14 },
|
|
30
|
+
children: folderContent
|
|
31
|
+
}
|
|
32
|
+
);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// VariantBar — VariantKit's own chrome: a slim bottom-center bar with variant tabs,
|
|
3
|
+
// compare toggle, and Finalize. Mount once next to <DialRoot/>. Dev-only (hidden in
|
|
4
|
+
// production builds).
|
|
5
|
+
//
|
|
6
|
+
// Zero wiring: it discovers variant sets straight from DialKit's documented store —
|
|
7
|
+
// a `variant` select paired with a `finalize` action, either at a panel's top level
|
|
8
|
+
// (the classic shell) or inside a folder (the Studio's one-folder-per-element layout).
|
|
9
|
+
// Tabs drive DialStore.updateValue; Finalize fires the shell's own finalize action via
|
|
10
|
+
// DialStore.triggerAction, so the decision path is identical to clicking it in the panel.
|
|
11
|
+
//
|
|
12
|
+
// Keys: 1..9 switch variants of the active set (ignored while typing).
|
|
13
|
+
|
|
14
|
+
import { useEffect, useReducer, useState, type CSSProperties } from 'react'
|
|
15
|
+
import { DialStore } from 'dialkit'
|
|
16
|
+
import { vkStore, IS_PROD } from './vkStore'
|
|
17
|
+
|
|
18
|
+
type Option = { value: string; label: string }
|
|
19
|
+
|
|
20
|
+
interface VariantSet {
|
|
21
|
+
panelId: string
|
|
22
|
+
name: string
|
|
23
|
+
options: Option[]
|
|
24
|
+
active: string
|
|
25
|
+
variantPath: string
|
|
26
|
+
finalizePath: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ControlNode = {
|
|
30
|
+
type: string
|
|
31
|
+
path: string
|
|
32
|
+
label: string
|
|
33
|
+
children?: ControlNode[]
|
|
34
|
+
options?: (string | { value: string; label: string })[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setFrom(panelId: string, name: string, scope: ControlNode[], prefix: string, values: Record<string, unknown>): VariantSet | null {
|
|
38
|
+
const variantPath = prefix ? `${prefix}.variant` : 'variant'
|
|
39
|
+
const finalizePath = prefix ? `${prefix}.finalize` : 'finalize'
|
|
40
|
+
const variant = scope.find((c) => c.path === variantPath && c.type === 'select')
|
|
41
|
+
const finalize = scope.find((c) => c.path === finalizePath && c.type === 'action')
|
|
42
|
+
if (!variant || !finalize || !variant.options) return null
|
|
43
|
+
const options = variant.options.map((o) =>
|
|
44
|
+
typeof o === 'string' ? { value: o, label: o } : { value: o.value, label: o.label },
|
|
45
|
+
)
|
|
46
|
+
return { panelId, name, options, active: String(values[variantPath] ?? ''), variantPath, finalizePath }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findVariantSets(): VariantSet[] {
|
|
50
|
+
const sets: VariantSet[] = []
|
|
51
|
+
for (const panel of DialStore.getPanels()) {
|
|
52
|
+
const controls = panel.controls as unknown as ControlNode[]
|
|
53
|
+
const root = setFrom(panel.id, panel.name, controls, '', panel.values)
|
|
54
|
+
if (root) sets.push(root)
|
|
55
|
+
// Studio layout: one folder per element, each with its own variant+finalize.
|
|
56
|
+
for (const c of controls) {
|
|
57
|
+
if (c.type !== 'folder' || !c.children) continue
|
|
58
|
+
const folder = setFrom(panel.id, c.label, c.children, c.path, panel.values)
|
|
59
|
+
if (folder) sets.push(folder)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return sets
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTyping(target: EventTarget | null): boolean {
|
|
66
|
+
const el = target as HTMLElement | null
|
|
67
|
+
if (!el) return false
|
|
68
|
+
const tag = el.tagName
|
|
69
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const font = "system-ui, -apple-system, 'Segoe UI', sans-serif"
|
|
73
|
+
|
|
74
|
+
const barStyle: CSSProperties = {
|
|
75
|
+
position: 'fixed',
|
|
76
|
+
bottom: 16,
|
|
77
|
+
left: '50%',
|
|
78
|
+
transform: 'translateX(-50%)',
|
|
79
|
+
zIndex: 99998,
|
|
80
|
+
display: 'flex',
|
|
81
|
+
alignItems: 'center',
|
|
82
|
+
gap: 4,
|
|
83
|
+
padding: 4,
|
|
84
|
+
borderRadius: 12,
|
|
85
|
+
background: 'rgba(20, 20, 22, 0.92)',
|
|
86
|
+
backdropFilter: 'blur(12px)',
|
|
87
|
+
WebkitBackdropFilter: 'blur(12px)',
|
|
88
|
+
boxShadow: '0 12px 32px rgba(0,0,0,0.32), 0 0 0 1px rgba(255,255,255,0.08)',
|
|
89
|
+
fontFamily: font,
|
|
90
|
+
fontSize: 12,
|
|
91
|
+
fontVariantNumeric: 'tabular-nums',
|
|
92
|
+
WebkitFontSmoothing: 'antialiased',
|
|
93
|
+
color: '#d4d4d8',
|
|
94
|
+
userSelect: 'none',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const tabStyle = (active: boolean): CSSProperties => ({
|
|
98
|
+
display: 'flex',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
gap: 6,
|
|
101
|
+
padding: '6px 10px',
|
|
102
|
+
borderRadius: 8, // concentric: bar 12 - padding 4
|
|
103
|
+
border: 'none',
|
|
104
|
+
cursor: 'pointer',
|
|
105
|
+
fontFamily: font,
|
|
106
|
+
fontSize: 12,
|
|
107
|
+
fontWeight: 500,
|
|
108
|
+
lineHeight: 1,
|
|
109
|
+
background: active ? 'rgba(255,255,255,0.14)' : 'transparent',
|
|
110
|
+
color: active ? '#fafafa' : '#a1a1aa',
|
|
111
|
+
transition: 'background-color 0.15s ease, color 0.15s ease',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const keyHintStyle: CSSProperties = {
|
|
115
|
+
fontSize: 10,
|
|
116
|
+
opacity: 0.55,
|
|
117
|
+
fontVariantNumeric: 'tabular-nums',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const dividerStyle: CSSProperties = {
|
|
121
|
+
width: 1,
|
|
122
|
+
height: 16,
|
|
123
|
+
background: 'rgba(255,255,255,0.12)',
|
|
124
|
+
margin: '0 2px',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function VariantBar() {
|
|
128
|
+
const [, force] = useReducer((c: number) => c + 1, 0)
|
|
129
|
+
const [setIndex, setSetIndex] = useState(0)
|
|
130
|
+
|
|
131
|
+
// Re-render on panel add/remove and on value changes of every variant panel.
|
|
132
|
+
useEffect(() => DialStore.subscribeGlobal(force), [])
|
|
133
|
+
const sets = findVariantSets()
|
|
134
|
+
const panelKey = sets.map((s) => s.panelId + ':' + s.variantPath).join('|')
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const ids = Array.from(new Set(sets.map((s) => s.panelId)))
|
|
137
|
+
const unsubs = ids.map((id) => DialStore.subscribe(id, force))
|
|
138
|
+
return () => unsubs.forEach((u) => u())
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [panelKey])
|
|
141
|
+
|
|
142
|
+
const current = sets[Math.min(setIndex, Math.max(sets.length - 1, 0))]
|
|
143
|
+
|
|
144
|
+
// Keyboard: 1..9 switch variants of the active set.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!current) return
|
|
147
|
+
const onKey = (e: KeyboardEvent) => {
|
|
148
|
+
if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return
|
|
149
|
+
const n = Number(e.key)
|
|
150
|
+
if (!Number.isInteger(n) || n < 1 || n > current.options.length) return
|
|
151
|
+
DialStore.updateValue(current.panelId, current.variantPath, current.options[n - 1].value)
|
|
152
|
+
}
|
|
153
|
+
window.addEventListener('keydown', onKey)
|
|
154
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
155
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
156
|
+
}, [current?.panelId, current?.options.length])
|
|
157
|
+
|
|
158
|
+
if (IS_PROD || !current) return null
|
|
159
|
+
|
|
160
|
+
const comparing = vkStore.isCompare(current.name)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div style={barStyle}>
|
|
164
|
+
{sets.length > 1 && (
|
|
165
|
+
<button
|
|
166
|
+
style={{ ...tabStyle(false), fontWeight: 600, color: '#fafafa' }}
|
|
167
|
+
title="Switch variant set"
|
|
168
|
+
onClick={() => setSetIndex((i) => (i + 1) % sets.length)}
|
|
169
|
+
>
|
|
170
|
+
{current.name} ▾
|
|
171
|
+
</button>
|
|
172
|
+
)}
|
|
173
|
+
{sets.length > 1 && <div style={dividerStyle} />}
|
|
174
|
+
{current.options.map((o, i) => (
|
|
175
|
+
<button
|
|
176
|
+
key={o.value}
|
|
177
|
+
style={tabStyle(o.value === current.active)}
|
|
178
|
+
onClick={() => DialStore.updateValue(current.panelId, current.variantPath, o.value)}
|
|
179
|
+
>
|
|
180
|
+
<span style={keyHintStyle}>{i + 1}</span>
|
|
181
|
+
{o.label}
|
|
182
|
+
</button>
|
|
183
|
+
))}
|
|
184
|
+
<div style={dividerStyle} />
|
|
185
|
+
<button
|
|
186
|
+
style={tabStyle(comparing)}
|
|
187
|
+
title="Compare all variants side by side"
|
|
188
|
+
onClick={() => vkStore.toggleCompare(current.name)}
|
|
189
|
+
>
|
|
190
|
+
Compare
|
|
191
|
+
</button>
|
|
192
|
+
<button
|
|
193
|
+
style={{
|
|
194
|
+
...tabStyle(false),
|
|
195
|
+
background: '#fafafa',
|
|
196
|
+
color: '#18181b',
|
|
197
|
+
fontWeight: 600,
|
|
198
|
+
}}
|
|
199
|
+
title="Finalize this variant — writes the decision for your agent"
|
|
200
|
+
onClick={() => DialStore.triggerAction(current.panelId, current.finalizePath)}
|
|
201
|
+
>
|
|
202
|
+
Finalize
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default VariantBar
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
// VariantStage — renders the active variant normally, or ALL variants in a live
|
|
3
|
+
// side-by-side grid when compare mode is on (toggled from VariantBar). Every cell
|
|
4
|
+
// reacts to panel tweaks in real time; clicking a cell selects that variant.
|
|
5
|
+
//
|
|
6
|
+
// The shell renders it in place of the bare <Active/>:
|
|
7
|
+
//
|
|
8
|
+
// <VariantStage name="PricingCard" registry={registry} active={String(v.variant)}
|
|
9
|
+
// props={variantProps} />
|
|
10
|
+
//
|
|
11
|
+
// Lives only in the shell, so it prunes away with the rest of the wiring.
|
|
12
|
+
|
|
13
|
+
import { useEffect, useReducer, type ComponentType, type CSSProperties } from 'react'
|
|
14
|
+
import { DialStore } from 'dialkit'
|
|
15
|
+
import { vkStore, IS_PROD } from './vkStore'
|
|
16
|
+
|
|
17
|
+
const font = "system-ui, -apple-system, 'Segoe UI', sans-serif"
|
|
18
|
+
|
|
19
|
+
const gridStyle: CSSProperties = {
|
|
20
|
+
display: 'grid',
|
|
21
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
|
22
|
+
gap: 24,
|
|
23
|
+
alignItems: 'start',
|
|
24
|
+
width: '100%',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cellStyle = (active: boolean): CSSProperties => ({
|
|
28
|
+
display: 'flex',
|
|
29
|
+
flexDirection: 'column',
|
|
30
|
+
gap: 10,
|
|
31
|
+
padding: 12,
|
|
32
|
+
borderRadius: 14,
|
|
33
|
+
cursor: 'pointer',
|
|
34
|
+
outline: active ? '2px solid #18181b' : '1px solid rgba(0,0,0,0.08)',
|
|
35
|
+
outlineOffset: 2,
|
|
36
|
+
transition: 'outline-color 0.15s ease',
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const captionStyle = (active: boolean): CSSProperties => ({
|
|
40
|
+
fontFamily: font,
|
|
41
|
+
fontSize: 12,
|
|
42
|
+
fontWeight: active ? 600 : 500,
|
|
43
|
+
color: active ? '#18181b' : '#71717a',
|
|
44
|
+
WebkitFontSmoothing: 'antialiased',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function selectVariant(name: string, key: string): void {
|
|
48
|
+
const panel = DialStore.getPanels().find(
|
|
49
|
+
(p) => p.name === name && p.controls.some((c) => c.path === 'variant' && c.type === 'select'),
|
|
50
|
+
)
|
|
51
|
+
if (panel) DialStore.updateValue(panel.id, 'variant', key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function VariantStage<P extends object>({
|
|
55
|
+
name,
|
|
56
|
+
registry,
|
|
57
|
+
active,
|
|
58
|
+
props,
|
|
59
|
+
}: {
|
|
60
|
+
name: string
|
|
61
|
+
registry: Record<string, { component: ComponentType<P>; label: string }>
|
|
62
|
+
active: string
|
|
63
|
+
props: P
|
|
64
|
+
}) {
|
|
65
|
+
const [, force] = useReducer((c: number) => c + 1, 0)
|
|
66
|
+
useEffect(() => vkStore.subscribe(force), [])
|
|
67
|
+
|
|
68
|
+
const entries = Object.entries(registry)
|
|
69
|
+
const comparing = !IS_PROD && vkStore.isCompare(name)
|
|
70
|
+
|
|
71
|
+
if (!comparing) {
|
|
72
|
+
const Active = registry[active]?.component
|
|
73
|
+
return Active ? <Active {...props} /> : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div style={gridStyle}>
|
|
78
|
+
{entries.map(([key, { component, label }]) => {
|
|
79
|
+
const C = component
|
|
80
|
+
const isActive = key === active
|
|
81
|
+
return (
|
|
82
|
+
<div key={key} style={cellStyle(isActive)} onClick={() => selectVariant(name, key)}>
|
|
83
|
+
<span style={captionStyle(isActive)}>{label}</span>
|
|
84
|
+
<C {...props} />
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default VariantStage
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Tiny module-level store shared by VariantBar and VariantStage (same import instance
|
|
2
|
+
// inside the project). Tracks which variant sets are in compare mode.
|
|
3
|
+
|
|
4
|
+
type Listener = () => void
|
|
5
|
+
|
|
6
|
+
const compare = new Set<string>()
|
|
7
|
+
const listeners = new Set<Listener>()
|
|
8
|
+
|
|
9
|
+
function emit(): void {
|
|
10
|
+
for (const l of listeners) l()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const vkStore = {
|
|
14
|
+
isCompare(name: string): boolean {
|
|
15
|
+
return compare.has(name)
|
|
16
|
+
},
|
|
17
|
+
toggleCompare(name: string): void {
|
|
18
|
+
if (compare.has(name)) compare.delete(name)
|
|
19
|
+
else compare.add(name)
|
|
20
|
+
emit()
|
|
21
|
+
},
|
|
22
|
+
subscribe(l: Listener): () => void {
|
|
23
|
+
listeners.add(l)
|
|
24
|
+
return () => listeners.delete(l)
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Production guard shared by the dev chrome. Vite/Next statically replace
|
|
29
|
+
// process.env.NODE_ENV; plain browsers without `process` land in the catch.
|
|
30
|
+
export const IS_PROD: boolean = (() => {
|
|
31
|
+
try {
|
|
32
|
+
// @ts-ignore -- `process` may be untyped in browser-only projects; bundlers
|
|
33
|
+
// statically replace process.env.NODE_ENV so the prod check still inlines.
|
|
34
|
+
return process.env.NODE_ENV === 'production'
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
})()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// VariantKit React helper — one reusable component instead of hand-writing a studio per
|
|
2
|
+
// project. Folds N elements into ONE DialKit panel (a folder each), routes each element's
|
|
3
|
+
// finalize, and optionally focuses the folder of the element you hover (panel-side only —
|
|
4
|
+
// nothing is ever drawn over the project's UI).
|
|
5
|
+
//
|
|
6
|
+
// import { Studio } from './variantkit/react'
|
|
7
|
+
// <Studio elements={[
|
|
8
|
+
// { name: 'Hero', keys: ['centered','split','minimal'],
|
|
9
|
+
// controls: { headline: 'Ship faster', align: { type: 'select', options: ['left','center'], default: 'left' } },
|
|
10
|
+
// render: (variant, v) => <Hero .../> },
|
|
11
|
+
// ]} focusOnHover />
|
|
12
|
+
//
|
|
13
|
+
// `controls` is authored per element by whoever builds it — any controls DialKit supports
|
|
14
|
+
// (slider, select, toggle, color, text, spring, nested folders…), with defaults taken from
|
|
15
|
+
// the project's own design system. VariantKit only adds the variant select (when there are
|
|
16
|
+
// 2+ variants) and the finalize action.
|
|
17
|
+
//
|
|
18
|
+
// Requires <DialRoot/> mounted once in the app root (DialKit), plus dialkit/styles.css and
|
|
19
|
+
// (recommended) ./dialkit-clean.css.
|
|
20
|
+
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
|
21
|
+
import { motion, MotionConfig } from 'motion/react'
|
|
22
|
+
import { useDialKit } from 'dialkit'
|
|
23
|
+
import { panelConfig, defaultsOf, regOf, type PanelConfig } from './configs'
|
|
24
|
+
import { buildDecision, submitDecision, type ParamValue } from './buildDecision'
|
|
25
|
+
|
|
26
|
+
export interface ElementDef {
|
|
27
|
+
/** Component name — becomes the folder title and the decision's component. */
|
|
28
|
+
name: string
|
|
29
|
+
/** Variant keys. A single key renders no variant dropdown — just the controls. */
|
|
30
|
+
keys: string[]
|
|
31
|
+
/**
|
|
32
|
+
* The element's own controls — contextual, authored for THIS element (any DialKit control:
|
|
33
|
+
* slider, select, boolean toggle, color, text, spring, nested folder groups…). VariantKit
|
|
34
|
+
* adds `variant` + `finalize` around them; it never decides what these are.
|
|
35
|
+
*/
|
|
36
|
+
controls?: PanelConfig
|
|
37
|
+
/** Render the active variant from its resolved values. */
|
|
38
|
+
render: (variant: string, values: Record<string, ParamValue>) => ReactNode
|
|
39
|
+
/** Optional full config override (replaces the assembled variant+controls+finalize). */
|
|
40
|
+
config?: PanelConfig
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StudioProps {
|
|
44
|
+
elements: ElementDef[]
|
|
45
|
+
/** Panel title. */
|
|
46
|
+
name?: string
|
|
47
|
+
/** Expand the panel folder of the element you hover (panel-side only; no overlay on the UI). */
|
|
48
|
+
focusOnHover?: boolean
|
|
49
|
+
/** Called after an element is finalized (decision already submitted/copied). */
|
|
50
|
+
onFinalize?: (decision: ReturnType<typeof buildDecision>) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const cfgFor = (e: ElementDef): PanelConfig =>
|
|
54
|
+
e.config ?? panelConfig(e.controls ?? {}, e.keys, { component: e.name })
|
|
55
|
+
|
|
56
|
+
// Match DialKit's humanized folder title ("Pricing Card") back to an element name.
|
|
57
|
+
const norm = (s: string) => s.replace(/\s+/g, '').toLowerCase()
|
|
58
|
+
|
|
59
|
+
function focusFolder(name: string | null) {
|
|
60
|
+
if (!name) return
|
|
61
|
+
// NEVER touch the root folder — clicking its header collapses the whole panel. Only the
|
|
62
|
+
// per-element folders (the ones with a title) are toggled.
|
|
63
|
+
document.querySelectorAll('.dialkit-folder:not(.dialkit-folder-root)').forEach((f) => {
|
|
64
|
+
const title = f.querySelector('.dialkit-folder-title')?.textContent?.trim()
|
|
65
|
+
if (!title) return
|
|
66
|
+
const header = f.querySelector<HTMLElement>('.dialkit-folder-header')
|
|
67
|
+
if (!header) return
|
|
68
|
+
const expanded = !!f.querySelector('.dialkit-folder-content')
|
|
69
|
+
const shouldExpand = norm(title) === norm(name)
|
|
70
|
+
if (shouldExpand && !expanded) header.click()
|
|
71
|
+
else if (!shouldExpand && expanded) header.click()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Studio({ elements, name = 'VariantKit', focusOnHover, onFinalize }: StudioProps) {
|
|
76
|
+
const [focused, setFocused] = useState<string | null>(null)
|
|
77
|
+
const elsRef = useRef(elements)
|
|
78
|
+
elsRef.current = elements
|
|
79
|
+
|
|
80
|
+
// One combined config: a folder per element, first open and the rest collapsed. The
|
|
81
|
+
// finalize button's "✓ Copied" feedback is handled inside copyDecision (panel-side, no
|
|
82
|
+
// overlay), so the label stays a plain "Finalize <name>" here.
|
|
83
|
+
const combined: Record<string, unknown> = {}
|
|
84
|
+
elements.forEach((e, i) => {
|
|
85
|
+
const base = cfgFor(e)
|
|
86
|
+
const finalize = { ...(base.finalize as object), label: `Finalize ${e.name}` }
|
|
87
|
+
combined[e.name] = { ...base, finalize, _collapsed: i !== 0 }
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const all = useDialKit(name, combined as never, {
|
|
91
|
+
onAction: (path: string) => {
|
|
92
|
+
const elName = path.split('.')[0]
|
|
93
|
+
const e = elsRef.current.find((x) => x.name === elName)
|
|
94
|
+
if (!e) return
|
|
95
|
+
const slice = (all as Record<string, Record<string, ParamValue>>)[elName]
|
|
96
|
+
const decision = buildDecision(elName, slice, defaultsOf(cfgFor(e)), regOf(e.keys))
|
|
97
|
+
// Dev transport first ("✓ Saved" -> .variantkit/decisions/), clipboard fallback ("✓ Copied").
|
|
98
|
+
submitDecision(decision)
|
|
99
|
+
onFinalize?.(decision)
|
|
100
|
+
},
|
|
101
|
+
}) as Record<string, Record<string, ParamValue>>
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (focusOnHover) focusFolder(focused)
|
|
105
|
+
}, [focused, focusOnHover])
|
|
106
|
+
|
|
107
|
+
// Render the elements as-is — VariantKit adds no layout, spacing, alignment, rings, or
|
|
108
|
+
// badges around the project's UI. The host page owns presentation entirely.
|
|
109
|
+
return (
|
|
110
|
+
<MotionConfig reducedMotion="user">
|
|
111
|
+
{elements.map((e) => {
|
|
112
|
+
const slice = all[e.name]
|
|
113
|
+
if (!slice) return null
|
|
114
|
+
const variant = e.keys.length > 1 ? String(slice.variant) : e.keys[0]
|
|
115
|
+
return (
|
|
116
|
+
<section
|
|
117
|
+
key={e.name}
|
|
118
|
+
className="vk-section"
|
|
119
|
+
onMouseEnter={focusOnHover ? () => setFocused(e.name) : undefined}
|
|
120
|
+
style={{ display: 'contents' }}
|
|
121
|
+
>
|
|
122
|
+
{/* Variant switch is frequent → keep the settle tiny and fast. (This div is the
|
|
123
|
+
only wrapper box VariantKit adds; it carries zero styling of its own.) */}
|
|
124
|
+
<motion.div
|
|
125
|
+
key={variant}
|
|
126
|
+
initial={{ opacity: 0 }}
|
|
127
|
+
animate={{ opacity: 1 }}
|
|
128
|
+
transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}
|
|
129
|
+
>
|
|
130
|
+
{e.render(variant, slice) as ReactElement}
|
|
131
|
+
</motion.div>
|
|
132
|
+
</section>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
</MotionConfig>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Panel theme toggle ──────────────────────────────────────────────────────────────────
|
|
140
|
+
// DialKit ships light only; this manages the panel's light/dark theme AND injects a small
|
|
141
|
+
// sun/moon toggle into the panel header (DialKit has no slot for it, so we append one).
|
|
142
|
+
// Requires dialkit-dark.css imported. Returns { theme, setTheme } if you also want a custom UI.
|
|
143
|
+
|
|
144
|
+
const MOON =
|
|
145
|
+
'<svg class="vk-swap" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
|
|
146
|
+
const SUN =
|
|
147
|
+
'<svg class="vk-swap" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
|
148
|
+
|
|
149
|
+
export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
150
|
+
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
|
151
|
+
try {
|
|
152
|
+
const s = localStorage.getItem('vk-theme')
|
|
153
|
+
if (s === 'light' || s === 'dark') return s
|
|
154
|
+
} catch {
|
|
155
|
+
/* no storage */
|
|
156
|
+
}
|
|
157
|
+
return initial
|
|
158
|
+
})
|
|
159
|
+
const themeRef = useRef(theme)
|
|
160
|
+
themeRef.current = theme
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const flip = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
|
|
164
|
+
|
|
165
|
+
// The actual DOM work. Guarded two ways so it can NEVER cause a runaway observer loop:
|
|
166
|
+
// (1) it only writes when something actually differs, (2) the observer is paused around
|
|
167
|
+
// these writes (see below) so our own mutations don't retrigger it.
|
|
168
|
+
const sync = () => {
|
|
169
|
+
document.querySelectorAll('.dialkit-root').forEach((el) => {
|
|
170
|
+
if (el.getAttribute('data-theme') !== themeRef.current) el.setAttribute('data-theme', themeRef.current)
|
|
171
|
+
})
|
|
172
|
+
document.querySelectorAll<HTMLElement>('.dialkit-panel-header').forEach((hdr) => {
|
|
173
|
+
const titleRow = hdr.querySelector<HTMLElement>('.dialkit-folder-header-top') ?? hdr
|
|
174
|
+
// Keep exactly one toggle (hot-reload can leave a stale node behind).
|
|
175
|
+
const existing = titleRow.querySelectorAll<HTMLButtonElement>('.vk-theme-toggle')
|
|
176
|
+
for (let i = 1; i < existing.length; i++) existing[i].remove()
|
|
177
|
+
let btn = existing[0] ?? null
|
|
178
|
+
if (!btn) {
|
|
179
|
+
btn = document.createElement('button')
|
|
180
|
+
// Absolute, just left of DialKit's settings icon (which is absolute at right:12).
|
|
181
|
+
btn.className = 'vk-theme-toggle'
|
|
182
|
+
btn.type = 'button'
|
|
183
|
+
btn.setAttribute('aria-label', 'Toggle panel theme')
|
|
184
|
+
const stop = (e: Event) => e.stopPropagation()
|
|
185
|
+
btn.addEventListener('pointerdown', stop)
|
|
186
|
+
btn.addEventListener('mousedown', stop)
|
|
187
|
+
btn.addEventListener('click', (e) => {
|
|
188
|
+
e.stopPropagation()
|
|
189
|
+
flip()
|
|
190
|
+
})
|
|
191
|
+
titleRow.appendChild(btn)
|
|
192
|
+
}
|
|
193
|
+
if (btn.dataset.vkTheme !== themeRef.current) {
|
|
194
|
+
btn.dataset.vkTheme = themeRef.current
|
|
195
|
+
btn.innerHTML = themeRef.current === 'dark' ? SUN : MOON // fresh <svg class="vk-swap"> animates in
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Coalesce mutation bursts into one sync per frame, and pause the observer while WE write,
|
|
201
|
+
// so our own DOM changes can't re-trigger it. Belt and suspenders against the freeze.
|
|
202
|
+
let frame = 0
|
|
203
|
+
const mo = new MutationObserver(() => {
|
|
204
|
+
if (frame) return
|
|
205
|
+
frame = requestAnimationFrame(() => {
|
|
206
|
+
frame = 0
|
|
207
|
+
mo.disconnect()
|
|
208
|
+
sync()
|
|
209
|
+
mo.observe(document.body, { childList: true, subtree: true })
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Delightful theme switch: add `.vk-theming` so the panel cross-fades its colors (the
|
|
214
|
+
// class scopes a transition that only exists during the switch — see motion.css), then
|
|
215
|
+
// flip the value via sync(). First mount sets the same value, so nothing animates.
|
|
216
|
+
const panels = document.querySelectorAll('.dialkit-root')
|
|
217
|
+
panels.forEach((p) => p.classList.add('vk-theming'))
|
|
218
|
+
sync()
|
|
219
|
+
const settle = setTimeout(() => panels.forEach((p) => p.classList.remove('vk-theming')), 420)
|
|
220
|
+
mo.observe(document.body, { childList: true, subtree: true })
|
|
221
|
+
try {
|
|
222
|
+
localStorage.setItem('vk-theme', theme)
|
|
223
|
+
} catch {
|
|
224
|
+
/* no storage */
|
|
225
|
+
}
|
|
226
|
+
return () => {
|
|
227
|
+
if (frame) cancelAnimationFrame(frame)
|
|
228
|
+
clearTimeout(settle)
|
|
229
|
+
mo.disconnect()
|
|
230
|
+
}
|
|
231
|
+
}, [theme])
|
|
232
|
+
|
|
233
|
+
return { theme, setTheme }
|
|
234
|
+
}
|