variantkit 0.2.0 → 0.3.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/package.json +1 -1
- package/variantkit/configs.ts +26 -0
- package/variantkit/dialkit-clean.css +119 -11
- package/variantkit/motion.css +33 -0
- package/variantkit/patches/dialkit+1.2.0.patch +54 -6
- package/variantkit/react.tsx +208 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "variantkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The configuration panel for AI-built UI: your agent generates variants with a full contextual control panel, you tweak and finalize, the losers get pruned from the codebase. Built on DialKit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/variantkit/configs.ts
CHANGED
|
@@ -71,3 +71,29 @@ export function defaultsOf(cfg: PanelConfig): Record<string, number | string | b
|
|
|
71
71
|
export function regOf(variantKeys: string[]): Record<string, true> {
|
|
72
72
|
return Object.fromEntries(variantKeys.map((k) => [k, true]))
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
// Flatten a (possibly nested) config into the path→default map DialKit addresses values by:
|
|
76
|
+
// dot-joined raw keys (`headingSize`, `Pricing Card.accent`). Used by the header Reset button
|
|
77
|
+
// to push every control — INCLUDING the variant — back to where it started. Folders are plain
|
|
78
|
+
// objects without a `type`; `_`-prefixed keys (e.g. `_collapsed`) and actions are skipped.
|
|
79
|
+
export function flatDefaults(cfg: PanelConfig, prefix = ''): Record<string, number | string | boolean> {
|
|
80
|
+
const out: Record<string, number | string | boolean> = {}
|
|
81
|
+
for (const [key, c] of Object.entries(cfg)) {
|
|
82
|
+
if (key.startsWith('_') || c == null) continue
|
|
83
|
+
const path = prefix ? `${prefix}.${key}` : key
|
|
84
|
+
if (typeof c === 'number' || typeof c === 'string' || typeof c === 'boolean') {
|
|
85
|
+
out[path] = c
|
|
86
|
+
} else if (Array.isArray(c)) {
|
|
87
|
+
out[path] = c[0] as number
|
|
88
|
+
} else if (typeof c === 'object') {
|
|
89
|
+
const o = c as { type?: string; default?: unknown }
|
|
90
|
+
if (o.type === 'action') continue
|
|
91
|
+
if (o.type) {
|
|
92
|
+
if (o.default !== undefined) out[path] = o.default as number | string | boolean
|
|
93
|
+
} else {
|
|
94
|
+
Object.assign(out, flatDefaults(c as PanelConfig, path)) // nested folder of controls
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
@@ -13,13 +13,34 @@
|
|
|
13
13
|
bubble sets its own uniform 12px, so this only affects the open panel. */
|
|
14
14
|
.dialkit-root .dialkit-panel-inner:not([data-collapsed='true']) {
|
|
15
15
|
padding-bottom: 12px;
|
|
16
|
+
/* DialKit sets an inline pixel `height` from its own content measurement — but it under-measures
|
|
17
|
+
by ~14px (our bottom padding + the wrapped variant pills aren't counted), so the last control
|
|
18
|
+
(Finalize) gets clipped on EVERY window size, not just short ones. Force `height: auto` so the
|
|
19
|
+
panel hugs its real content, then cap to the viewport and scroll if a short window demands it.
|
|
20
|
+
Scoped to the expanded panel so DialKit's collapse animation (which drives `data-collapsed`)
|
|
21
|
+
is left untouched. */
|
|
22
|
+
height: auto !important;
|
|
23
|
+
max-height: calc(100vh - 28px);
|
|
24
|
+
overflow-y: auto;
|
|
25
|
+
overscroll-behavior: contain;
|
|
26
|
+
scrollbar-width: thin;
|
|
27
|
+
scrollbar-color: var(--dial-surface-hover) transparent;
|
|
28
|
+
}
|
|
29
|
+
.dialkit-root .dialkit-panel-inner:not([data-collapsed='true'])::-webkit-scrollbar {
|
|
30
|
+
width: 8px;
|
|
31
|
+
}
|
|
32
|
+
.dialkit-root .dialkit-panel-inner:not([data-collapsed='true'])::-webkit-scrollbar-thumb {
|
|
33
|
+
background: var(--dial-surface-hover);
|
|
34
|
+
border-radius: 999px;
|
|
35
|
+
border: 2px solid transparent;
|
|
36
|
+
background-clip: padding-box;
|
|
16
37
|
}
|
|
17
38
|
.dialkit-root .dialkit-panel-header {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
margin-bottom:
|
|
39
|
+
/* A quiet divider + breathing room separates the header (title + chrome) from the controls,
|
|
40
|
+
so the panel reads as "identity up top, then the body". */
|
|
41
|
+
border-bottom: 1px solid var(--dial-border) !important;
|
|
42
|
+
padding-bottom: 12px !important;
|
|
43
|
+
margin-bottom: 12px !important;
|
|
23
44
|
}
|
|
24
45
|
.dialkit-root .dialkit-panel-header .dialkit-folder-header-top {
|
|
25
46
|
padding-bottom: 0 !important;
|
|
@@ -41,8 +62,10 @@
|
|
|
41
62
|
align-items: stretch;
|
|
42
63
|
}
|
|
43
64
|
.dialkit-root .dialkit-vk-pills .dialkit-select-label {
|
|
44
|
-
/*
|
|
45
|
-
|
|
65
|
+
/* Drop the "Variant" caption — the segmented pills are the first thing under the title and are
|
|
66
|
+
self-evidently the take selector. A lone label above them just read as an orphaned control
|
|
67
|
+
row. The panel now opens straight into the pills, like a tab bar. */
|
|
68
|
+
display: none;
|
|
46
69
|
}
|
|
47
70
|
.dialkit-root .dialkit-vk-pills-track {
|
|
48
71
|
display: flex;
|
|
@@ -95,11 +118,94 @@
|
|
|
95
118
|
color: var(--dial-glass-bg);
|
|
96
119
|
}
|
|
97
120
|
|
|
98
|
-
/*
|
|
121
|
+
/* ── Panel icon: collapse glyph (expanded) + brand mark (collapsed) ───────────────────────
|
|
122
|
+
DialKit's `.dialkit-panel-icon` sits top-right; clicking it (via the header's toggle) collapses
|
|
123
|
+
the panel. We paint it with a CSS mask in `currentColor` (themes itself, survives React
|
|
124
|
+
re-renders): a contract/minimize glyph while EXPANDED — that's its real job, the panel's
|
|
125
|
+
collapse affordance — and VariantKit's brand mark on the COLLAPSED bubble, where the panel
|
|
126
|
+
needs a face (two rounded cards, one solid, echoing the variant pills). */
|
|
127
|
+
.dialkit-root .dialkit-panel-icon > * {
|
|
128
|
+
display: none; /* hide DialKit's own dial paths */
|
|
129
|
+
}
|
|
130
|
+
.dialkit-root .dialkit-panel-icon {
|
|
131
|
+
background-color: currentColor;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
-webkit-mask-repeat: no-repeat;
|
|
134
|
+
mask-repeat: no-repeat;
|
|
135
|
+
-webkit-mask-position: center;
|
|
136
|
+
mask-position: center;
|
|
137
|
+
-webkit-mask-size: contain;
|
|
138
|
+
mask-size: contain;
|
|
139
|
+
}
|
|
140
|
+
.dialkit-root .dialkit-panel-inner:not([data-collapsed='true']) .dialkit-panel-icon {
|
|
141
|
+
-webkit-mask-image: var(--vk-collapse-mark);
|
|
142
|
+
mask-image: var(--vk-collapse-mark);
|
|
143
|
+
}
|
|
144
|
+
.dialkit-root .dialkit-panel-inner[data-collapsed='true'] .dialkit-panel-icon {
|
|
145
|
+
-webkit-mask-image: var(--vk-brand-mark);
|
|
146
|
+
mask-image: var(--vk-brand-mark);
|
|
147
|
+
}
|
|
148
|
+
.dialkit-root {
|
|
149
|
+
--vk-brand-mark: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Crect%20x='2.2'%20y='4.4'%20width='5.2'%20height='7.2'%20rx='1.7'%20fill='black'/%3E%3Crect%20x='8.6'%20y='4.4'%20width='5.2'%20height='7.2'%20rx='1.7'%20fill='none'%20stroke='black'%20stroke-width='1.3'%20opacity='.5'/%3E%3C/svg%3E");
|
|
150
|
+
--vk-collapse-mark: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Cpath%20d='M12.8%203.2%20L9%207%20M9%204.7%20L9%207%20L11.3%207'%20fill='none'%20stroke='black'%20stroke-width='1.5'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3Cpath%20d='M3.2%2012.8%20L7%209%20M7%2011.3%20L7%209%20L4.7%209'%20fill='none'%20stroke='black'%20stroke-width='1.5'%20stroke-linecap='round'%20stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── Element actions: Shuffle + Reset, beside the element name ────────────────────────────
|
|
154
|
+
Two quiet icon buttons sit right after the title, in the same row — they act on the ELEMENT,
|
|
155
|
+
so they read as the element's own controls. Panel chrome (theme + collapse) stays on the far
|
|
156
|
+
right; the title row reserves space for it so the two groups never collide. */
|
|
157
|
+
.dialkit-root .dialkit-panel-header .dialkit-folder-title-row {
|
|
158
|
+
display: flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
gap: 3px;
|
|
161
|
+
padding-right: 76px; /* clear the theme toggle (right 40) + collapse icon (right 12) */
|
|
162
|
+
}
|
|
163
|
+
.dialkit-root .dialkit-panel-header .dialkit-folder-title-row .dialkit-folder-title {
|
|
164
|
+
flex: 0 1 auto;
|
|
165
|
+
min-width: 0;
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
text-overflow: ellipsis;
|
|
168
|
+
white-space: nowrap;
|
|
169
|
+
}
|
|
170
|
+
.dialkit-root .vk-actions {
|
|
171
|
+
display: inline-flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
gap: 1px;
|
|
174
|
+
margin-left: 4px;
|
|
175
|
+
flex: 0 0 auto;
|
|
176
|
+
}
|
|
177
|
+
.dialkit-root .vk-action-btn {
|
|
178
|
+
width: 24px;
|
|
179
|
+
height: 24px;
|
|
180
|
+
display: inline-grid;
|
|
181
|
+
place-items: center;
|
|
182
|
+
border: none;
|
|
183
|
+
background: transparent;
|
|
184
|
+
border-radius: 6px;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
color: var(--dial-text-secondary);
|
|
187
|
+
transition:
|
|
188
|
+
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
|
189
|
+
color 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
190
|
+
}
|
|
191
|
+
.dialkit-root .vk-action-btn:hover {
|
|
192
|
+
background: var(--dial-surface-hover);
|
|
193
|
+
color: var(--dial-text-primary);
|
|
194
|
+
}
|
|
195
|
+
.dialkit-root .vk-action-btn:active {
|
|
196
|
+
transform: scale(0.9);
|
|
197
|
+
}
|
|
198
|
+
.dialkit-root .vk-action-btn svg {
|
|
199
|
+
display: block;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── Header chrome: theme toggle ─────────────────────────────────────────────────────────
|
|
203
|
+
Panel chrome on the right — the theme toggle, just left of the collapse/brand icon. */
|
|
99
204
|
.dialkit-root .vk-theme-toggle {
|
|
100
205
|
position: absolute;
|
|
101
206
|
top: 6px;
|
|
102
|
-
right: 40px;
|
|
207
|
+
right: 40px; /* just left of the collapse icon (DialKit's panel-icon at right ~12) */
|
|
208
|
+
z-index: 2;
|
|
103
209
|
width: 26px;
|
|
104
210
|
height: 26px;
|
|
105
211
|
display: inline-grid;
|
|
@@ -109,11 +215,13 @@
|
|
|
109
215
|
border-radius: 7px;
|
|
110
216
|
cursor: pointer;
|
|
111
217
|
color: var(--dial-text-secondary);
|
|
112
|
-
|
|
113
|
-
|
|
218
|
+
transition:
|
|
219
|
+
background-color 140ms cubic-bezier(0.23, 1, 0.32, 1),
|
|
220
|
+
color 140ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
114
221
|
}
|
|
115
222
|
.dialkit-root .vk-theme-toggle:hover {
|
|
116
223
|
background: var(--dial-surface-hover);
|
|
224
|
+
color: var(--dial-text-primary);
|
|
117
225
|
}
|
|
118
226
|
.dialkit-root .vk-theme-toggle:active {
|
|
119
227
|
transform: scale(0.92);
|
package/variantkit/motion.css
CHANGED
|
@@ -60,6 +60,35 @@
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/* ── Header actions feedback ─────────────────────────────────────────────────────────────
|
|
64
|
+
Reset spins its arrow once counter-clockwise; Shuffle gives its icon a quick tumble. Both
|
|
65
|
+
are keyed off a data attribute the JS sets for ~480ms, so each click animates fresh. */
|
|
66
|
+
.dialkit-root .vk-reset[data-spinning] svg {
|
|
67
|
+
animation: vk-reset-spin 480ms var(--vk-ease-out);
|
|
68
|
+
}
|
|
69
|
+
@keyframes vk-reset-spin {
|
|
70
|
+
from {
|
|
71
|
+
transform: rotate(0);
|
|
72
|
+
}
|
|
73
|
+
to {
|
|
74
|
+
transform: rotate(-360deg);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
.dialkit-root .vk-shuffle[data-shuffling] svg {
|
|
78
|
+
animation: vk-shuffle-tumble 420ms var(--vk-ease-out);
|
|
79
|
+
}
|
|
80
|
+
@keyframes vk-shuffle-tumble {
|
|
81
|
+
0% {
|
|
82
|
+
transform: rotate(0) scale(1);
|
|
83
|
+
}
|
|
84
|
+
45% {
|
|
85
|
+
transform: rotate(16deg) scale(0.86);
|
|
86
|
+
}
|
|
87
|
+
100% {
|
|
88
|
+
transform: rotate(0) scale(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
@media (prefers-reduced-motion: reduce) {
|
|
64
93
|
.dialkit-root.vk-theming,
|
|
65
94
|
.dialkit-root.vk-theming * {
|
|
@@ -68,6 +97,10 @@
|
|
|
68
97
|
.vk-theme-toggle .vk-swap {
|
|
69
98
|
animation: none;
|
|
70
99
|
}
|
|
100
|
+
.dialkit-root .vk-reset[data-spinning] svg,
|
|
101
|
+
.dialkit-root .vk-shuffle[data-shuffling] svg {
|
|
102
|
+
animation: none;
|
|
103
|
+
}
|
|
71
104
|
.dialkit-root button:active {
|
|
72
105
|
transform: none;
|
|
73
106
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
diff --git a/node_modules/dialkit/dist/index.cjs b/node_modules/dialkit/dist/index.cjs
|
|
2
|
-
index d9f4837..
|
|
2
|
+
index d9f4837..ce0432f 100644
|
|
3
3
|
--- a/node_modules/dialkit/dist/index.cjs
|
|
4
4
|
+++ b/node_modules/dialkit/dist/index.cjs
|
|
5
5
|
@@ -337,7 +337,7 @@ var DialStoreClass = class {
|
|
@@ -23,7 +23,23 @@ index d9f4837..385c7dc 100644
|
|
|
23
23
|
children: folderContent
|
|
24
24
|
}
|
|
25
25
|
);
|
|
26
|
-
@@ -
|
|
26
|
+
@@ -1034,6 +1035,7 @@ function Slider({
|
|
27
|
+
shortcut,
|
|
28
|
+
shortcutActive
|
|
29
|
+
}) {
|
|
30
|
+
+ if (typeof value !== "number") value = typeof min === "number" ? min : 0;
|
|
31
|
+
const wrapperRef = (0, import_react5.useRef)(null);
|
|
32
|
+
const trackRef = (0, import_react5.useRef)(null);
|
|
33
|
+
const inputRef = (0, import_react5.useRef)(null);
|
|
34
|
+
@@ -1825,6 +1827,7 @@ function EaseTextInput({ ease, onChange }) {
|
|
35
|
+
// src/components/TextControl.tsx
|
|
36
|
+
var import_jsx_runtime10 = require("react/jsx-runtime");
|
|
37
|
+
function TextControl({ label, value, onChange, placeholder }) {
|
|
38
|
+
+ if (typeof value !== "string") value = "";
|
|
39
|
+
return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("div", { className: "dialkit-text-control", children: [
|
|
40
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)("label", { className: "dialkit-text-label", children: label }),
|
|
41
|
+
/* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
|
|
42
|
+
@@ -1853,6 +1856,15 @@ function normalizeOptions(options) {
|
|
27
43
|
(opt) => typeof opt === "string" ? { value: opt, label: toTitleCase(opt) } : opt
|
|
28
44
|
);
|
|
29
45
|
}
|
|
@@ -39,7 +55,15 @@ index d9f4837..385c7dc 100644
|
|
|
39
55
|
function SelectControl({ label, value, options, onChange }) {
|
|
40
56
|
const [isOpen, setIsOpen] = (0, import_react10.useState)(false);
|
|
41
57
|
const triggerRef = (0, import_react10.useRef)(null);
|
|
42
|
-
@@ -
|
|
58
|
+
@@ -1966,6 +1978,7 @@ var import_react12 = require("react");
|
|
59
|
+
var import_jsx_runtime12 = require("react/jsx-runtime");
|
|
60
|
+
var HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
61
|
+
function ColorControl({ label, value, onChange }) {
|
|
62
|
+
+ if (typeof value !== "string") value = "#000000";
|
|
63
|
+
const [isEditing, setIsEditing] = (0, import_react12.useState)(false);
|
|
64
|
+
const [editValue, setEditValue] = (0, import_react12.useState)(value);
|
|
65
|
+
const colorInputRef = (0, import_react12.useRef)(null);
|
|
66
|
+
@@ -2266,7 +2279,7 @@ Apply these values as the new defaults in the useDialKit call.`;
|
|
43
67
|
);
|
|
44
68
|
case "select":
|
|
45
69
|
return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
|
|
@@ -49,7 +73,7 @@ index d9f4837..385c7dc 100644
|
|
|
49
73
|
label: control.label,
|
|
50
74
|
value,
|
|
51
75
|
diff --git a/node_modules/dialkit/dist/index.js b/node_modules/dialkit/dist/index.js
|
|
52
|
-
index f8d8baf..
|
|
76
|
+
index f8d8baf..47a7e4d 100644
|
|
53
77
|
--- a/node_modules/dialkit/dist/index.js
|
|
54
78
|
+++ b/node_modules/dialkit/dist/index.js
|
|
55
79
|
@@ -297,7 +297,7 @@ var DialStoreClass = class {
|
|
@@ -73,7 +97,23 @@ index f8d8baf..a58300a 100644
|
|
|
73
97
|
children: folderContent
|
|
74
98
|
}
|
|
75
99
|
);
|
|
76
|
-
@@ -
|
|
100
|
+
@@ -994,6 +995,7 @@ function Slider({
|
|
101
|
+
shortcut,
|
|
102
|
+
shortcutActive
|
|
103
|
+
}) {
|
|
104
|
+
+ if (typeof value !== "number") value = typeof min === "number" ? min : 0;
|
|
105
|
+
const wrapperRef = useRef4(null);
|
|
106
|
+
const trackRef = useRef4(null);
|
|
107
|
+
const inputRef = useRef4(null);
|
|
108
|
+
@@ -1785,6 +1787,7 @@ function EaseTextInput({ ease, onChange }) {
|
|
109
|
+
// src/components/TextControl.tsx
|
|
110
|
+
import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
111
|
+
function TextControl({ label, value, onChange, placeholder }) {
|
|
112
|
+
+ if (typeof value !== "string") value = "";
|
|
113
|
+
return /* @__PURE__ */ jsxs9("div", { className: "dialkit-text-control", children: [
|
|
114
|
+
/* @__PURE__ */ jsx10("label", { className: "dialkit-text-label", children: label }),
|
|
115
|
+
/* @__PURE__ */ jsx10(
|
|
116
|
+
@@ -1813,6 +1816,15 @@ function normalizeOptions(options) {
|
|
77
117
|
(opt) => typeof opt === "string" ? { value: opt, label: toTitleCase(opt) } : opt
|
|
78
118
|
);
|
|
79
119
|
}
|
|
@@ -89,7 +129,15 @@ index f8d8baf..a58300a 100644
|
|
|
89
129
|
function SelectControl({ label, value, options, onChange }) {
|
|
90
130
|
const [isOpen, setIsOpen] = useState6(false);
|
|
91
131
|
const triggerRef = useRef8(null);
|
|
92
|
-
@@ -
|
|
132
|
+
@@ -1926,6 +1938,7 @@ import { useState as useState7, useRef as useRef9, useEffect as useEffect6 } fro
|
|
133
|
+
import { jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
134
|
+
var HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
135
|
+
function ColorControl({ label, value, onChange }) {
|
|
136
|
+
+ if (typeof value !== "string") value = "#000000";
|
|
137
|
+
const [isEditing, setIsEditing] = useState7(false);
|
|
138
|
+
const [editValue, setEditValue] = useState7(value);
|
|
139
|
+
const colorInputRef = useRef9(null);
|
|
140
|
+
@@ -2226,7 +2239,7 @@ Apply these values as the new defaults in the useDialKit call.`;
|
|
93
141
|
);
|
|
94
142
|
case "select":
|
|
95
143
|
return /* @__PURE__ */ jsx14(
|
package/variantkit/react.tsx
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
// (recommended) ./dialkit-clean.css.
|
|
20
20
|
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
|
21
21
|
import { motion, MotionConfig } from 'motion/react'
|
|
22
|
-
import { useDialKit } from 'dialkit'
|
|
23
|
-
import { panelConfig, defaultsOf, regOf, type PanelConfig } from './configs'
|
|
22
|
+
import { useDialKit, DialStore } from 'dialkit'
|
|
23
|
+
import { panelConfig, defaultsOf, regOf, flatDefaults, type PanelConfig } from './configs'
|
|
24
24
|
import { buildDecision, submitDecision, type ParamValue } from './buildDecision'
|
|
25
25
|
|
|
26
26
|
export interface ElementDef {
|
|
@@ -32,8 +32,13 @@ export interface ElementDef {
|
|
|
32
32
|
* The element's own controls — contextual, authored for THIS element (any DialKit control:
|
|
33
33
|
* slider, select, boolean toggle, color, text, spring, nested folder groups…). VariantKit
|
|
34
34
|
* adds `variant` + `finalize` around them; it never decides what these are.
|
|
35
|
+
*
|
|
36
|
+
* Pass a plain object for controls shared by every variant, OR a function of the active variant
|
|
37
|
+
* to make the control set DEPEND on the variant — different takes often need different knobs (a
|
|
38
|
+
* "split" hero has a media control a "minimal" one doesn't; a "vinyl" player has spin speed).
|
|
39
|
+
* Switch the variant and the panel swaps its controls; shared keys keep their values.
|
|
35
40
|
*/
|
|
36
|
-
controls?: PanelConfig
|
|
41
|
+
controls?: PanelConfig | ((variant: string) => PanelConfig)
|
|
37
42
|
/** Render the active variant from its resolved values. */
|
|
38
43
|
render: (variant: string, values: Record<string, ParamValue>) => ReactNode
|
|
39
44
|
/** Optional full config override (replaces the assembled variant+controls+finalize). */
|
|
@@ -50,9 +55,6 @@ export interface StudioProps {
|
|
|
50
55
|
onFinalize?: (decision: ReturnType<typeof buildDecision>) => void
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
const cfgFor = (e: ElementDef): PanelConfig =>
|
|
54
|
-
e.config ?? panelConfig(e.controls ?? {}, e.keys, { component: e.name })
|
|
55
|
-
|
|
56
58
|
// Match DialKit's humanized folder title ("Pricing Card") back to an element name.
|
|
57
59
|
const norm = (s: string) => s.replace(/\s+/g, '').toLowerCase()
|
|
58
60
|
|
|
@@ -77,23 +79,41 @@ export function Studio({ elements, name = 'VariantKit', focusOnHover, onFinalize
|
|
|
77
79
|
const elsRef = useRef(elements)
|
|
78
80
|
elsRef.current = elements
|
|
79
81
|
|
|
82
|
+
const single = elements.length === 1
|
|
83
|
+
|
|
84
|
+
// Each element's active variant — seeds the variant-specific control resolution below. Seeded
|
|
85
|
+
// with the first key, then synced from the live panel after each render (see the effect). When
|
|
86
|
+
// a variant changes, this updates → the config rebuilds → the panel swaps that variant's
|
|
87
|
+
// controls. (Studio is remounted per element set, so this reseeds correctly.)
|
|
88
|
+
const [variants, setVariants] = useState<Record<string, string>>(() =>
|
|
89
|
+
Object.fromEntries(elements.map((e) => [e.name, e.keys[0]])),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// Resolve an element's controls for its active variant: a function `controls` is called with the
|
|
93
|
+
// variant, a plain object is shared across all variants.
|
|
94
|
+
const controlsOf = (e: ElementDef): PanelConfig => {
|
|
95
|
+
const v = variants[e.name] ?? e.keys[0]
|
|
96
|
+
return typeof e.controls === 'function' ? e.controls(v) : e.controls ?? {}
|
|
97
|
+
}
|
|
98
|
+
const cfgForE = (e: ElementDef): PanelConfig =>
|
|
99
|
+
e.config ?? panelConfig(controlsOf(e), e.keys, { component: e.name })
|
|
100
|
+
|
|
80
101
|
// Build the combined config. With ONE element, a per-element folder is pure redundancy —
|
|
81
102
|
// the panel title already names it, so a "Button" folder under "Button Lab" just adds a
|
|
82
103
|
// confusing second hierarchy level. So flatten: the lone element's controls sit at the panel
|
|
83
104
|
// root. With 2+ elements, give each its own folder (first open, rest collapsed) — that's
|
|
84
105
|
// where the grouping earns its keep. The finalize button's "✓ Copied" feedback is handled
|
|
85
106
|
// inside copyDecision (panel-side), so the label stays a plain "Finalize <name>".
|
|
86
|
-
const single = elements.length === 1
|
|
87
107
|
const combined: Record<string, unknown> = {}
|
|
88
108
|
if (single) {
|
|
89
109
|
const e = elements[0]
|
|
90
|
-
const base =
|
|
110
|
+
const base = cfgForE(e)
|
|
91
111
|
Object.assign(combined, base, {
|
|
92
112
|
finalize: { ...(base.finalize as object), label: `Finalize ${e.name}` },
|
|
93
113
|
})
|
|
94
114
|
} else {
|
|
95
115
|
elements.forEach((e, i) => {
|
|
96
|
-
const base =
|
|
116
|
+
const base = cfgForE(e)
|
|
97
117
|
const finalize = { ...(base.finalize as object), label: `Finalize ${e.name}` }
|
|
98
118
|
combined[e.name] = { ...base, finalize, _collapsed: i !== 0 }
|
|
99
119
|
})
|
|
@@ -109,19 +129,43 @@ export function Studio({ elements, name = 'VariantKit', focusOnHover, onFinalize
|
|
|
109
129
|
string,
|
|
110
130
|
ParamValue
|
|
111
131
|
>
|
|
112
|
-
const decision = buildDecision(e.name, slice, defaultsOf(
|
|
132
|
+
const decision = buildDecision(e.name, slice, defaultsOf(cfgForE(e)), regOf(e.keys))
|
|
113
133
|
// Dev transport first ("✓ Saved" -> .variantkit/decisions/), clipboard fallback ("✓ Copied").
|
|
114
134
|
submitDecision(decision)
|
|
115
135
|
onFinalize?.(decision)
|
|
116
136
|
},
|
|
117
137
|
}) as Record<string, Record<string, ParamValue>>
|
|
118
138
|
|
|
139
|
+
// Keep `variants` in step with the live panel so variant-specific controls swap in when you
|
|
140
|
+
// pick a different take. Runs after every commit; setVariants no-ops when nothing changed, so
|
|
141
|
+
// it settles in one extra render and never loops.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
setVariants((prev) => {
|
|
144
|
+
let changed = false
|
|
145
|
+
const next = { ...prev }
|
|
146
|
+
for (const e of elsRef.current) {
|
|
147
|
+
if (e.keys.length < 2) continue
|
|
148
|
+
const live = single
|
|
149
|
+
? (all as unknown as Record<string, ParamValue>).variant
|
|
150
|
+
: (all as Record<string, Record<string, ParamValue>>)[e.name]?.variant
|
|
151
|
+
if (live != null && String(live) !== prev[e.name]) {
|
|
152
|
+
next[e.name] = String(live)
|
|
153
|
+
changed = true
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return changed ? next : prev
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
119
160
|
useEffect(() => {
|
|
120
161
|
// Focus-on-hover only applies to the multi-element layout (it toggles per-element folders).
|
|
121
162
|
// With a single flattened element there are no element folders to focus.
|
|
122
163
|
if (focusOnHover && !single) focusFolder(focused)
|
|
123
164
|
}, [focused, focusOnHover, single])
|
|
124
165
|
|
|
166
|
+
// Shuffle (randomize every knob — and the take) + Reset (back to defaults), in the header.
|
|
167
|
+
usePanelActions(name, combined as PanelConfig)
|
|
168
|
+
|
|
125
169
|
// Render the elements as-is — VariantKit adds no layout, spacing, alignment, rings, or
|
|
126
170
|
// badges around the project's UI. The host page owns presentation entirely.
|
|
127
171
|
return (
|
|
@@ -188,6 +232,8 @@ export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
|
188
232
|
if (el.getAttribute('data-theme') !== themeRef.current) el.setAttribute('data-theme', themeRef.current)
|
|
189
233
|
})
|
|
190
234
|
document.querySelectorAll<HTMLElement>('.dialkit-panel-header').forEach((hdr) => {
|
|
235
|
+
// Theme is PANEL chrome, so it lives in the header on the right (CSS pins it just left of
|
|
236
|
+
// the collapse icon) — deliberately apart from the element actions (shuffle/reset, left).
|
|
191
237
|
const titleRow = hdr.querySelector<HTMLElement>('.dialkit-folder-header-top') ?? hdr
|
|
192
238
|
// Keep exactly one toggle (hot-reload can leave a stale node behind).
|
|
193
239
|
const existing = titleRow.querySelectorAll<HTMLButtonElement>('.vk-theme-toggle')
|
|
@@ -195,7 +241,6 @@ export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
|
195
241
|
let btn = existing[0] ?? null
|
|
196
242
|
if (!btn) {
|
|
197
243
|
btn = document.createElement('button')
|
|
198
|
-
// Absolute, just left of DialKit's settings icon (which is absolute at right:12).
|
|
199
244
|
btn.className = 'vk-theme-toggle'
|
|
200
245
|
btn.type = 'button'
|
|
201
246
|
btn.setAttribute('aria-label', 'Toggle panel theme')
|
|
@@ -215,18 +260,20 @@ export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
|
215
260
|
})
|
|
216
261
|
}
|
|
217
262
|
|
|
218
|
-
// Coalesce mutation bursts into one sync per frame
|
|
219
|
-
//
|
|
263
|
+
// Coalesce mutation bursts into one sync per frame. sync() is idempotent (it only writes when
|
|
264
|
+
// a value actually differs and only appends the toggle when it's missing), so we stay
|
|
265
|
+
// connected rather than disconnecting around our own writes — the same reason as the action
|
|
266
|
+
// cluster: disconnecting opened a window where the panel-swap mutation landed unobserved and
|
|
267
|
+
// the toggle never got (re)appended into the freshly-built cluster.
|
|
220
268
|
let frame = 0
|
|
221
|
-
const
|
|
269
|
+
const schedule = () => {
|
|
222
270
|
if (frame) return
|
|
223
271
|
frame = requestAnimationFrame(() => {
|
|
224
272
|
frame = 0
|
|
225
|
-
mo.disconnect()
|
|
226
273
|
sync()
|
|
227
|
-
mo.observe(document.body, { childList: true, subtree: true })
|
|
228
274
|
})
|
|
229
|
-
}
|
|
275
|
+
}
|
|
276
|
+
const mo = new MutationObserver(schedule)
|
|
230
277
|
|
|
231
278
|
// Delightful theme switch: add `.vk-theming` so the panel cross-fades its colors (the
|
|
232
279
|
// class scopes a transition that only exists during the switch — see motion.css), then
|
|
@@ -234,6 +281,7 @@ export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
|
234
281
|
const panels = document.querySelectorAll('.dialkit-root')
|
|
235
282
|
panels.forEach((p) => p.classList.add('vk-theming'))
|
|
236
283
|
sync()
|
|
284
|
+
schedule() // catch the action cluster if it commits a frame after this effect
|
|
237
285
|
const settle = setTimeout(() => panels.forEach((p) => p.classList.remove('vk-theming')), 420)
|
|
238
286
|
mo.observe(document.body, { childList: true, subtree: true })
|
|
239
287
|
try {
|
|
@@ -250,3 +298,146 @@ export function useDialkitTheme(initial: 'light' | 'dark' = 'light') {
|
|
|
250
298
|
|
|
251
299
|
return { theme, setTheme }
|
|
252
300
|
}
|
|
301
|
+
|
|
302
|
+
// ── Element actions: Shuffle + Reset ──────────────────────────────────────────────────────
|
|
303
|
+
// Two icon buttons injected right after the element name in the header title row — they act on
|
|
304
|
+
// the ELEMENT (randomize / restore its controls), so they read as the element's own controls
|
|
305
|
+
// (panel chrome — theme + collapse — stays on the far right). They drive the live panel through
|
|
306
|
+
// DialKit's store, so every control and the variant pills update in place, as if you'd dragged
|
|
307
|
+
// them.
|
|
308
|
+
// Shuffle → a fresh random valid value for every slider/select/toggle (and the variant), so
|
|
309
|
+
// "surprise me" lands a whole new combination. Color/text/spring are left alone —
|
|
310
|
+
// random hex or copy is noise, not exploration.
|
|
311
|
+
// Reset → every control (incl. the variant) back to its authored default.
|
|
312
|
+
|
|
313
|
+
const SHUFFLE =
|
|
314
|
+
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>'
|
|
315
|
+
const RESET =
|
|
316
|
+
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
|
|
317
|
+
|
|
318
|
+
// A DialKit control's metadata (the shape we read from the live store). Loosely typed — we only
|
|
319
|
+
// touch the fields we need and ignore the rest of DialKit's union.
|
|
320
|
+
interface ControlMeta {
|
|
321
|
+
type: string
|
|
322
|
+
path: string
|
|
323
|
+
min?: number
|
|
324
|
+
max?: number
|
|
325
|
+
step?: number
|
|
326
|
+
options?: (string | { value: string; label: string })[]
|
|
327
|
+
children?: ControlMeta[]
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A random *valid* value for a single control, or undefined to leave it untouched. Sliders snap
|
|
331
|
+
// to their step; selects/variant pick an option; toggles flip a coin. Anything else is skipped.
|
|
332
|
+
function randomValue(meta: ControlMeta): number | string | boolean | undefined {
|
|
333
|
+
if (meta.type === 'slider') {
|
|
334
|
+
const min = meta.min ?? 0
|
|
335
|
+
const max = meta.max ?? 1
|
|
336
|
+
const step = meta.step ?? (Number.isInteger(min) && Number.isInteger(max) ? 1 : (max - min) / 100 || 1)
|
|
337
|
+
const steps = Math.max(1, Math.round((max - min) / step))
|
|
338
|
+
return +(min + Math.floor(Math.random() * (steps + 1)) * step).toFixed(4)
|
|
339
|
+
}
|
|
340
|
+
if (meta.type === 'toggle') return Math.random() < 0.5
|
|
341
|
+
if (meta.type === 'select') {
|
|
342
|
+
const opts = (meta.options ?? []).map((o) => (typeof o === 'string' ? o : o.value))
|
|
343
|
+
return opts.length ? opts[Math.floor(Math.random() * opts.length)] : undefined
|
|
344
|
+
}
|
|
345
|
+
return undefined // color / text / spring / transition / action / folder — left as-is
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function usePanelActions(panelName: string, config: PanelConfig) {
|
|
349
|
+
const cfgRef = useRef(config)
|
|
350
|
+
cfgRef.current = config
|
|
351
|
+
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
const panelId = () => DialStore.getPanels().find((p) => p.name === panelName)?.id
|
|
354
|
+
|
|
355
|
+
const reset = () => {
|
|
356
|
+
const id = panelId()
|
|
357
|
+
if (!id) return
|
|
358
|
+
for (const [path, value] of Object.entries(flatDefaults(cfgRef.current))) {
|
|
359
|
+
DialStore.updateValue(id, path, value as never)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const shuffle = () => {
|
|
364
|
+
const id = panelId()
|
|
365
|
+
if (!id) return
|
|
366
|
+
const panel = DialStore.getPanel(id)
|
|
367
|
+
if (!panel) return
|
|
368
|
+
const walk = (controls: ControlMeta[]) => {
|
|
369
|
+
for (const c of controls) {
|
|
370
|
+
if (c.children?.length) walk(c.children)
|
|
371
|
+
const v = randomValue(c)
|
|
372
|
+
if (v !== undefined) DialStore.updateValue(id, c.path, v as never)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
walk(panel.controls as unknown as ControlMeta[])
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Build an icon button once; the spin/pop feedback comes from a data attribute the CSS keys
|
|
379
|
+
// off. `label` is the accessible/tooltip description (the button itself is icon-only).
|
|
380
|
+
const make = (cls: string, label: string, svg: string, onClick: () => void, flash: string) => {
|
|
381
|
+
const btn = document.createElement('button')
|
|
382
|
+
btn.type = 'button'
|
|
383
|
+
btn.className = `vk-action-btn ${cls}`
|
|
384
|
+
btn.setAttribute('aria-label', label)
|
|
385
|
+
btn.title = label
|
|
386
|
+
btn.innerHTML = svg
|
|
387
|
+
const stop = (e: Event) => e.stopPropagation()
|
|
388
|
+
btn.addEventListener('pointerdown', stop)
|
|
389
|
+
btn.addEventListener('mousedown', stop)
|
|
390
|
+
btn.addEventListener('click', (e) => {
|
|
391
|
+
e.stopPropagation()
|
|
392
|
+
onClick()
|
|
393
|
+
btn.setAttribute(flash, '')
|
|
394
|
+
setTimeout(() => btn.removeAttribute(flash), 480)
|
|
395
|
+
})
|
|
396
|
+
return btn
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const sync = () => {
|
|
400
|
+
// Ensure the panel header's title row (the element name, only present when expanded) carries
|
|
401
|
+
// exactly one shuffle/reset pair, appended after the title — robust against React re-renders.
|
|
402
|
+
// Then sweep any orphaned pairs (panel collapsed, element swapped, hot-reload).
|
|
403
|
+
const valid = new Set<Element>()
|
|
404
|
+
document.querySelectorAll<HTMLElement>('.dialkit-panel-header .dialkit-folder-title-row').forEach((row) => {
|
|
405
|
+
let bar = row.querySelector<HTMLElement>(':scope > .vk-actions')
|
|
406
|
+
if (!bar) {
|
|
407
|
+
bar = document.createElement('div')
|
|
408
|
+
bar.className = 'vk-actions'
|
|
409
|
+
bar.appendChild(make('vk-shuffle', 'Shuffle all controls', SHUFFLE, shuffle, 'data-shuffling'))
|
|
410
|
+
bar.appendChild(make('vk-reset', 'Reset to defaults', RESET, reset, 'data-spinning'))
|
|
411
|
+
row.appendChild(bar)
|
|
412
|
+
}
|
|
413
|
+
valid.add(bar)
|
|
414
|
+
})
|
|
415
|
+
document.querySelectorAll('.vk-actions').forEach((b) => {
|
|
416
|
+
if (!valid.has(b)) b.remove()
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Frame-coalesced sync on any DOM change. sync() is idempotent — it injects the cluster only
|
|
421
|
+
// when it's missing and bails when it's already there — so we DON'T disconnect the observer
|
|
422
|
+
// around our own writes: our injection fires one more (no-op) sync and then goes quiet. The
|
|
423
|
+
// old disconnect/reconnect dance opened a window where the panel-swap mutation (on project
|
|
424
|
+
// switch) landed unobserved, leaving the cluster un-injected. Staying connected closes it.
|
|
425
|
+
let frame = 0
|
|
426
|
+
const schedule = () => {
|
|
427
|
+
if (frame) return
|
|
428
|
+
frame = requestAnimationFrame(() => {
|
|
429
|
+
frame = 0
|
|
430
|
+
sync()
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
const mo = new MutationObserver(schedule)
|
|
434
|
+
sync()
|
|
435
|
+
schedule() // catch panel DOM that commits the frame after mount
|
|
436
|
+
mo.observe(document.body, { childList: true, subtree: true })
|
|
437
|
+
return () => {
|
|
438
|
+
if (frame) cancelAnimationFrame(frame)
|
|
439
|
+
mo.disconnect()
|
|
440
|
+
document.querySelectorAll('.vk-actions').forEach((el) => el.remove())
|
|
441
|
+
}
|
|
442
|
+
}, [panelName])
|
|
443
|
+
}
|