variantkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ 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
+ }