dialkit 1.2.1 → 1.4.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.
Files changed (88) hide show
  1. package/README.md +653 -6
  2. package/dist/dropdown-position.d.ts +15 -0
  3. package/dist/dropdown-position.js +22 -0
  4. package/dist/dropdown-position.js.map +1 -0
  5. package/dist/icons.d.ts +4 -1
  6. package/dist/icons.js +13 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/index.cjs +3796 -768
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +315 -10
  11. package/dist/index.d.ts +315 -10
  12. package/dist/index.js +3733 -711
  13. package/dist/index.js.map +1 -1
  14. package/dist/panel-drag.d.ts +19 -0
  15. package/dist/panel-drag.js +72 -0
  16. package/dist/panel-drag.js.map +1 -0
  17. package/dist/solid/index.d.ts +264 -5
  18. package/dist/solid/index.js +5277 -1632
  19. package/dist/solid/index.js.map +1 -1
  20. package/dist/store/index.cjs +329 -57
  21. package/dist/store/index.cjs.map +1 -1
  22. package/dist/store/index.d.cts +43 -4
  23. package/dist/store/index.d.ts +43 -4
  24. package/dist/store/index.js +321 -56
  25. package/dist/store/index.js.map +1 -1
  26. package/dist/styles.css +809 -7
  27. package/dist/svelte/components/ControlRenderer.svelte +5 -2
  28. package/dist/svelte/components/ControlRenderer.svelte.d.ts +2 -0
  29. package/dist/svelte/components/ControlRenderer.svelte.d.ts.map +1 -1
  30. package/dist/svelte/components/DialRoot.svelte +218 -16
  31. package/dist/svelte/components/DialRoot.svelte.d.ts +1 -0
  32. package/dist/svelte/components/DialRoot.svelte.d.ts.map +1 -1
  33. package/dist/svelte/components/Folder.svelte +24 -13
  34. package/dist/svelte/components/Folder.svelte.d.ts +1 -0
  35. package/dist/svelte/components/Folder.svelte.d.ts.map +1 -1
  36. package/dist/svelte/components/Panel.svelte +104 -71
  37. package/dist/svelte/components/Panel.svelte.d.ts +4 -0
  38. package/dist/svelte/components/Panel.svelte.d.ts.map +1 -1
  39. package/dist/svelte/components/PresetManager.svelte +5 -5
  40. package/dist/svelte/components/PresetManager.svelte.d.ts.map +1 -1
  41. package/dist/svelte/components/SelectControl.svelte +6 -14
  42. package/dist/svelte/components/SelectControl.svelte.d.ts.map +1 -1
  43. package/dist/svelte/components/Timeline/ClipPopover.svelte +206 -0
  44. package/dist/svelte/components/Timeline/ClipPopover.svelte.d.ts +26 -0
  45. package/dist/svelte/components/Timeline/ClipPopover.svelte.d.ts.map +1 -0
  46. package/dist/svelte/components/Timeline/DialTimeline.svelte +76 -0
  47. package/dist/svelte/components/Timeline/DialTimeline.svelte.d.ts +13 -0
  48. package/dist/svelte/components/Timeline/DialTimeline.svelte.d.ts.map +1 -0
  49. package/dist/svelte/components/Timeline/TimelineClip.svelte +233 -0
  50. package/dist/svelte/components/Timeline/TimelineClip.svelte.d.ts +24 -0
  51. package/dist/svelte/components/Timeline/TimelineClip.svelte.d.ts.map +1 -0
  52. package/dist/svelte/components/Timeline/TimelineSection.svelte +756 -0
  53. package/dist/svelte/components/Timeline/TimelineSection.svelte.d.ts +12 -0
  54. package/dist/svelte/components/Timeline/TimelineSection.svelte.d.ts.map +1 -0
  55. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte +25 -0
  56. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte.d.ts +4 -0
  57. package/dist/svelte/components/Timeline/TimelineToggleButton.svelte.d.ts.map +1 -0
  58. package/dist/svelte/components/TransitionControl.svelte +26 -11
  59. package/dist/svelte/components/TransitionControl.svelte.d.ts +9 -0
  60. package/dist/svelte/components/TransitionControl.svelte.d.ts.map +1 -1
  61. package/dist/svelte/createDialKit.svelte.d.ts +11 -1
  62. package/dist/svelte/createDialKit.svelte.d.ts.map +1 -1
  63. package/dist/svelte/createDialKit.svelte.js +61 -34
  64. package/dist/svelte/createDialTimeline.svelte.d.ts +4 -0
  65. package/dist/svelte/createDialTimeline.svelte.d.ts.map +1 -0
  66. package/dist/svelte/createDialTimeline.svelte.js +73 -0
  67. package/dist/svelte/index.d.ts +7 -3
  68. package/dist/svelte/index.d.ts.map +1 -1
  69. package/dist/svelte/index.js +4 -1
  70. package/dist/svelte/theme-css.d.ts +1 -1
  71. package/dist/svelte/theme-css.d.ts.map +1 -1
  72. package/dist/svelte/theme-css.js +809 -7
  73. package/dist/timeline/index.cjs +1288 -0
  74. package/dist/timeline/index.cjs.map +1 -0
  75. package/dist/timeline/index.d.cts +443 -0
  76. package/dist/timeline/index.d.ts +443 -0
  77. package/dist/timeline/index.js +1233 -0
  78. package/dist/timeline/index.js.map +1 -0
  79. package/dist/vue/index.d.ts +325 -12
  80. package/dist/vue/index.js +3226 -280
  81. package/dist/vue/index.js.map +1 -1
  82. package/package.json +23 -13
  83. package/dist/solid/index.cjs +0 -3079
  84. package/dist/solid/index.cjs.map +0 -1
  85. package/dist/solid/index.d.cts +0 -258
  86. package/dist/vue/index.cjs +0 -3056
  87. package/dist/vue/index.cjs.map +0 -1
  88. package/dist/vue/index.d.cts +0 -675
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # dialkit v1.2.0
1
+ # dialkit v1.4.0
2
2
 
3
3
  <img src="https://joshpuckett.me/images/dialkit.png" width="100%" />
4
4
 
5
- Real-time parameter tweaking for React, Solid, Svelte, and Vue, created by Josh Puckett.
5
+ Real-time parameter tweaking for React, Solid, Svelte, and Vue, created by Josh Puckett. Now with a scrubbable animation [Timeline](#timeline).
6
6
 
7
7
  To learn more about how I use DialKit, and approach design in general, feel free to check out [Interface Craft](http://interfacecraft.dev/).
8
8
 
@@ -72,6 +72,8 @@ const params = useDialKit(name, config, options?)
72
72
  |-------|------|-------------|
73
73
  | `name` | `string` | Panel title displayed in the UI |
74
74
  | `config` | `DialConfig` | Parameter definitions (see Control Types below) |
75
+ | `options.id` | `string` | Stable logical id for sharing values across remounts/pages |
76
+ | `options.persist` | `DialKitPersistOptions` | Persist values to browser storage |
75
77
  | `options.onAction` | `(path: string) => void` | Callback when action buttons are clicked |
76
78
  | `options.shortcuts` | `Record<string, ShortcutConfig>` | Keyboard shortcuts for controls (see [Keyboard Shortcuts](#keyboard-shortcuts)) |
77
79
 
@@ -79,6 +81,122 @@ Returns a fully typed object matching your config shape with live values. Updati
79
81
 
80
82
  ---
81
83
 
84
+ ## Stable IDs and Persistence
85
+
86
+ By default, a DialKit panel is tied to the lifecycle of the component that calls `useDialKit`. Pass `id` when multiple mounts should reconnect to the same logical panel, and pass `persist: true` when values should survive reloads and browser sessions.
87
+
88
+ ```tsx
89
+ // dials/useOnboardingDials.ts
90
+ import { useDialKit } from 'dialkit';
91
+
92
+ export function useOnboardingDials() {
93
+ return useDialKit('Onboarding', {
94
+ name: { type: 'text', default: 'Avery', placeholder: 'Name' },
95
+ avatarScale: [1, 0.6, 1.6, 0.01],
96
+ accent: { type: 'color', default: '#6C5CE7' },
97
+ }, {
98
+ id: 'onboarding',
99
+ persist: true,
100
+ });
101
+ }
102
+ ```
103
+
104
+ Use that helper anywhere the shared values are needed:
105
+
106
+ ```tsx
107
+ function PageTwo() {
108
+ const onboarding = useOnboardingDials();
109
+
110
+ const page = useDialKit('Page Two', {
111
+ cardRadius: [16, 0, 64],
112
+ });
113
+
114
+ return (
115
+ <Card
116
+ name={onboarding.name}
117
+ radius={page.cardRadius}
118
+ accent={onboarding.accent}
119
+ />
120
+ );
121
+ }
122
+ ```
123
+
124
+ When `PageTwo` is mounted, the single `<DialRoot />` shows both `Onboarding` and `Page Two` as top-level sections. If another page calls `useOnboardingDials()`, DialKit reconnects to the same `id` and keeps the shared values.
125
+
126
+ `persist: true` stores values, presets, and the active preset in `localStorage` using `dialkit:${id}` as the key. Use the object form to customize storage:
127
+
128
+ ```tsx
129
+ useDialKit('Onboarding', config, {
130
+ id: 'onboarding',
131
+ persist: {
132
+ key: 'my-app:onboarding-dials',
133
+ storage: 'sessionStorage',
134
+ presets: false,
135
+ },
136
+ });
137
+ ```
138
+
139
+ The `id` string has no special format; it only needs to be reused wherever you want the same logical panel. Without `id` or `persist`, DialKit behaves exactly as before.
140
+
141
+ ---
142
+
143
+ ## useDialKitController
144
+
145
+ Use the controller API when your app code also needs to update DialKit values, such as reset buttons, URL sync, or app-defined preset buttons.
146
+
147
+ ```tsx
148
+ import { useDialKitController } from 'dialkit';
149
+
150
+ function Card() {
151
+ const dial = useDialKitController('Card', {
152
+ blur: [24, 0, 100],
153
+ scale: 1.2,
154
+ color: '#ff5500',
155
+ visible: true,
156
+ shadow: {
157
+ radius: [16, 0, 64],
158
+ },
159
+ });
160
+
161
+ return (
162
+ <>
163
+ <button onClick={() => dial.setValues({
164
+ blur: 48,
165
+ scale: 1,
166
+ shadow: { radius: 28 },
167
+ })}>
168
+ Apply preset
169
+ </button>
170
+ <button onClick={() => dial.resetValues()}>Reset</button>
171
+
172
+ <div style={{
173
+ filter: `blur(${dial.values.blur}px)`,
174
+ transform: `scale(${dial.values.scale})`,
175
+ color: dial.values.color,
176
+ opacity: dial.values.visible ? 1 : 0,
177
+ borderRadius: dial.values.shadow.radius,
178
+ }}>
179
+ ...
180
+ </div>
181
+ </>
182
+ );
183
+ }
184
+ ```
185
+
186
+ Controller methods:
187
+
188
+ | Method | Description |
189
+ |--------|-------------|
190
+ | `values` | The same live resolved values returned by `useDialKit` |
191
+ | `setValue(path, value)` | Updates one control by dot path, like `'shadow.radius'` |
192
+ | `setValues(values)` | Updates multiple controls with a typed nested partial object |
193
+ | `resetValues()` | Restores the current config defaults and clears the active preset |
194
+ | `getValues()` | Reads the latest resolved values outside render callbacks |
195
+
196
+ Programmatic updates use the same state as panel edits. If a saved preset is active, updates are saved into that preset; otherwise they update the base "Version 1" values. Action controls are triggers, so they are not set by `setValues`.
197
+
198
+ ---
199
+
82
200
  ## Control Types
83
201
 
84
202
  ### Slider
@@ -268,10 +386,49 @@ const values = useDialKit('Controls', {
268
386
  | `position` | `'top-right' \| 'top-left' \| 'bottom-right' \| 'bottom-left'` | `'top-right'` |
269
387
  | `defaultOpen` | `boolean` | `true` |
270
388
  | `mode` | `'popover' \| 'inline'` | `'popover'` |
389
+ | `theme` | `'system' \| 'light' \| 'dark'` | `'system'` |
271
390
  | `productionEnabled` | `boolean` | `false` in production, `true` otherwise |
391
+ | `onOpenChange` | `(open: boolean) => void` | `undefined` |
272
392
 
273
393
  Mount once at your app root. In the default `popover` mode, the panel renders via a portal on `document.body`. It collapses to a small icon button and expands to 280px wide on click.
274
394
 
395
+ ### Multiple panels
396
+
397
+ If multiple `useDialKit` calls are registered under the same root, DialKit renders one shared shell and shows each panel as a collapsible top-level section. No extra API is needed:
398
+
399
+ ```tsx
400
+ function PhotoStack() {
401
+ const photo = useDialKit('Photo Stack', {
402
+ blur: [12, 0, 40],
403
+ scale: [1, 0.5, 2],
404
+ });
405
+
406
+ const stage = useDialKit('Stage', {
407
+ pagePadding: [40, 16, 96],
408
+ background: '#ffffff',
409
+ });
410
+
411
+ return (
412
+ <div style={{ padding: stage.pagePadding, background: stage.background }}>
413
+ <img style={{ filter: `blur(${photo.blur}px)`, transform: `scale(${photo.scale})` }} />
414
+ </div>
415
+ );
416
+ }
417
+ ```
418
+
419
+ The mounted `<DialRoot />` stays the same. With a single registered panel, the panel title and layout stay unchanged. This behavior works the same way in React, Solid, Svelte, and Vue.
420
+
421
+ Use `onOpenChange` when you need to persist whether the floating panel is open or collapsed:
422
+
423
+ ```tsx
424
+ <DialRoot
425
+ defaultOpen={localStorage.getItem('dialkit-open') !== '0'}
426
+ onOpenChange={(open) => {
427
+ localStorage.setItem('dialkit-open', open ? '1' : '0');
428
+ }}
429
+ />
430
+ ```
431
+
275
432
  DialKit is automatically hidden in production builds. To enable it in production, pass `productionEnabled`:
276
433
 
277
434
  ```tsx
@@ -465,6 +622,350 @@ function PhotoStack() {
465
622
 
466
623
  ---
467
624
 
625
+ ## Timeline
626
+
627
+ DialKit Timeline lets you define an animation in code, preview it, and tune its timing, values, and curves in a visual timeline.
628
+
629
+ The animation's structure stays in code. The timeline editor adjusts its properties without changing which clips loop, which clips form sequences, or how your application combines them.
630
+
631
+ Timeline is available in React, Solid, Svelte, and Vue. Every adapter uses the same framework-neutral timeline core and store, while lifecycle and rendering stay native to its framework.
632
+
633
+ ```tsx
634
+ import { useDialTimeline, DialTimeline } from 'dialkit';
635
+ import 'dialkit/styles.css';
636
+
637
+ function Hero() {
638
+ const hero = useDialTimeline(
639
+ 'Hero',
640
+ {
641
+ entrance: {
642
+ at: 0,
643
+ duration: 0.6,
644
+ from: { y: 32, opacity: 0 },
645
+ to: { y: 0, opacity: 1 },
646
+ transition: { type: 'spring', bounce: 0.2 },
647
+ },
648
+ idle: {
649
+ at: 0.8,
650
+ loop: true,
651
+ from: { y: 0 },
652
+ steps: [
653
+ { duration: 1, to: { y: -6 } },
654
+ { duration: 1, to: { y: 0 } },
655
+ ],
656
+ },
657
+ },
658
+ { loop: { from: 0.8 } }
659
+ );
660
+
661
+ const entrance = hero.entrance.current;
662
+ const idle = hero.idle.current;
663
+
664
+ return (
665
+ <>
666
+ <h1 style={{
667
+ opacity: entrance.opacity,
668
+ transform: `translateY(${entrance.y + idle.y}px)`,
669
+ }}>
670
+ Ship the moment.
671
+ </h1>
672
+ <DialTimeline />
673
+ </>
674
+ );
675
+ }
676
+ ```
677
+
678
+ Each named entry is a clip and appears as one row in the timeline. During authoring, bind its `current` values to your UI so the element always matches the playhead, whether the timeline is playing, paused, or being scrubbed. `current` is DialKit's deterministic preview of the configured curve; after tuning, Copy the settings into your app's real animation and remove the timeline hook.
679
+
680
+ The framework entry points expose the same config, values, transport, and `<DialTimeline />` dock:
681
+
682
+ | Framework | Import | Timeline function | Read returned values |
683
+ |-----------|--------|-------------------|----------------------|
684
+ | React | `dialkit` | `useDialTimeline` | `timeline.card.current` |
685
+ | Solid | `dialkit/solid` | `createDialTimeline` | `timeline().card.current` |
686
+ | Svelte 5 | `dialkit/svelte` | `createDialTimeline` | `timeline.card.current` |
687
+ | Vue 3 | `dialkit/vue` | `useDialTimeline` | `timeline.value.card.current` in script; auto-unwrapped in templates |
688
+
689
+ ### Timeline function
690
+
691
+ ```tsx
692
+ const tl = useDialTimeline(name, config, options?)
693
+ // Solid/Svelte: createDialTimeline(name, config, options?)
694
+ ```
695
+
696
+ | Param | Type | Description |
697
+ |-------|------|-------------|
698
+ | `name` | `string` | Timeline title displayed in the dock |
699
+ | `config` | `TimelineConfig` | Clip definitions plus an optional top-level `duration` |
700
+ | `options.id` | `string` | Stable logical id, same semantics as `useDialKit` |
701
+ | `options.persist` | `DialKitPersistOptions` | Persist timing edits to browser storage |
702
+ | `options.autoplay` | `boolean` | Start playing on mount. Default `true` |
703
+ | `options.loop` | `boolean \| { from: number }` | Wrap the playhead when it reaches the end (see [Looping](#looping)) |
704
+
705
+ Clip timing lives in the same store as panel values, so presets, persistence, reset, and Copy all work on timing data with no extra wiring.
706
+
707
+ The returned object combines the transport with one entry per clip:
708
+
709
+ ```tsx
710
+ tl.time // playhead in seconds
711
+ tl.playing // boolean
712
+ tl.duration // timeline length in seconds
713
+ tl.play() // resume (restarts if parked at the end)
714
+ tl.pause()
715
+ tl.replay() // seek to 0 and play
716
+ tl.seek(1.2) // move the playhead (pins the deterministic first-pass state)
717
+
718
+ tl.headline // TimelineClipValues for the "headline" clip
719
+ ```
720
+
721
+ `time`, `playing`, `duration`, `play`, `pause`, `replay`, and `seek` are reserved — a clip with one of those names is skipped with a console warning.
722
+
723
+ ### Clips
724
+
725
+ - Use `from` and `to` for one animation from one state to another.
726
+ - Use `steps` for several states played sequentially.
727
+ - Use `props` when properties need independent timing.
728
+ - Use separate named clips for separate behaviors, even when they affect the same element.
729
+
730
+ DialKit does not inspect your rendered elements or automatically combine animations. Your application decides how the values from different clips are used.
731
+
732
+ Every clip requires `at`, which sets its start time:
733
+
734
+ ```tsx
735
+ card: {
736
+ at: 0.45, // start time in seconds
737
+ duration: 0.7, // bar length in seconds
738
+ from: { y: 44, scale: 0.95, opacity: 0 }, // any DialKit leaf values
739
+ to: { y: 0, scale: 1, opacity: 1 },
740
+ transition: { type: 'spring', bounce: 0.25 },
741
+ }
742
+ ```
743
+
744
+ - **`from` / `to`** accept any normal DialKit leaf values — numbers, hex colors — and become editable controls in the clip's popover. Bare numbers get property-aware slider ranges (`x`/`y` ±100, `rotate` ±180, `scale` 0–2, `opacity` 0–1, and so on), expanded to include your actual endpoints.
745
+ - **`transition`** is a spring or easing config, exactly as in the panel's spring editor. Clips with `from`/`to` and no `transition` animate with a default spring (`{ type: 'spring', bounce: 0.2 }`).
746
+ - **The bar owns the duration.** Time-based springs and easings stretch to the bar: resize the clip and the curve retimes. Physics springs (`stiffness`/`damping`/`mass`) work the other way — the duration is *derived* from their settle time, the bar shows `~0.62s`, and it can't be resized (change the physics instead).
747
+ - **`duration` may be omitted** — it defaults to the easing's duration or the spring's settle time; a `from`/`to` clip with no `transition` gets the default spring's settle time.
748
+
749
+ Config mistakes warn in the console instead of failing silently: an entry missing `at`, conflicting clip shapes (`steps` + `to`, `props` + `from`), or a sequence property with no starting value.
750
+
751
+ A clip with only `at` (and optionally `duration`) is a **marker**: it carries timing state (`started`, `active`, `progress`) but no values. Useful for driving custom effects off `progress`:
752
+
753
+ ```tsx
754
+ shine: { at: 2.7, duration: 0.7 },
755
+
756
+ // later
757
+ <div style={{ transform: `translateX(${-150 + tl.shine.progress * 400}%)` }} />
758
+ ```
759
+
760
+ ### Reading clip state
761
+
762
+ Each clip on the returned object is a `TimelineClipValues`:
763
+
764
+ | Field | Type | Description |
765
+ |-------|------|-------------|
766
+ | `at` | `number` | Clip start in seconds — live, reflects dock edits |
767
+ | `duration` | `number` | Effective duration — the bar length |
768
+ | `loop` | `'off' \| 'repeat'` | Effective loop mode |
769
+ | `started` | `boolean` | Playhead is at or past the clip start |
770
+ | `active` | `boolean` | Playhead is inside the clip (any cycle, for looping clips) |
771
+ | `done` | `boolean` | Playhead is past the clip end (past the timeline end, for looping clips) |
772
+ | `progress` | `number` | 0–1 position within the clip — a sawtooth per cycle for looping clips |
773
+ | `step` | `number` | Index of the leg under the playhead (sequence clips only) |
774
+ | `from` / `to` | `object` | Resolved endpoint values (`to` is the final merged state for sequences) |
775
+ | `animate` | `object` | `to` once the clip has started, `from` before |
776
+ | `transition` | `TransitionConfig` | Motion-ready curve, duration driven by the bar (single-curve clips only) |
777
+ | `css` | `TimelineClipCss` | `transitionDuration` + `transitionTimingFunction` (single-curve clips only) |
778
+ | `current` | `object` | Values interpolated through the clip's curves at the playhead |
779
+
780
+ ### Recommended workflow: preview, copy, replace
781
+
782
+ The intended workflow is:
783
+
784
+ 1. **Author with `current`.** Bind `clip.current` directly to the element while tuning. DialKit deterministically samples the configured spring or easing curve, which makes every intermediate state scrubbable.
785
+ 2. **Copy the tuned settings.** Copy exports the timings, values, and transition parameters you arrived at in the dock.
786
+ 3. **Move those settings into the real app animation.** Apply the copied transition to your normal Motion animation (or your production animation system), then remove `useDialTimeline`/`createDialTimeline` and `<DialTimeline />`. Motion now runs its real spring at runtime.
787
+
788
+ Hiding or removing only `<DialTimeline />` hides the editor UI; it does **not** change how the animation is rendered. As long as the component is reading `clip.current`, DialKit's sampled values are still driving it.
789
+
790
+ The sampler uses the damped-spring equation with the configured stiffness, damping, and mass, plus a Motion-compatible mapping for `visualDuration` and `bounce`. It is designed to closely preview the final motion, but it is not a guarantee of frame-for-frame identity with Motion's runtime implementation.
791
+
792
+ There are three ways to bind a clip while authoring:
793
+
794
+ **1. `current` — recommended for tuning.** Bind styles directly; the element sits at DialKit's sampled state at all times, giving you true scrubbing. DialKit remains the renderer until you replace this binding with your production animation.
795
+
796
+ ```tsx
797
+ <div style={{
798
+ opacity: tl.card.current.opacity,
799
+ transform: `translateY(${tl.card.current.y}px) scale(${tl.card.current.scale})`,
800
+ }} />
801
+ ```
802
+
803
+ **2. `animate` + `transition` — real Motion during authoring.** The clip start flips `animate` from `from` to `to`, so Motion runs the actual curve. The tradeoff is that timeline scrubbing snaps between endpoints instead of showing intermediate states.
804
+
805
+ ```tsx
806
+ <motion.div animate={tl.card.animate} transition={tl.card.transition} />
807
+ ```
808
+
809
+ **3. `animate` + `css` — native CSS during authoring.** Same endpoint flip, using a CSS transition. Easing curves map exactly; springs are approximated with an overshoot bezier, and scrubbing still snaps between endpoints.
810
+
811
+ ```tsx
812
+ <div style={{
813
+ opacity: tl.card.animate.opacity,
814
+ transform: `translateY(${tl.card.animate.y}px)`,
815
+ transitionProperty: 'transform, opacity',
816
+ ...tl.card.css,
817
+ }} />
818
+ ```
819
+
820
+ ### Sequences — `steps`
821
+
822
+ A clip can explicitly chain several sequential legs with a `steps` array. Each array item becomes one segment on the same row; drag a boundary to retime a leg, or click a segment to edit its target values and curve (the first leg's popover also shows the clip's `from`). DialKit does not infer steps from the DOM element consuming the values, from the number of animated properties, or from multiple clips affecting the same element.
823
+
824
+ ```tsx
825
+ path: {
826
+ at: 0,
827
+ from: { x: -70, y: 0 },
828
+ transition: { type: 'easing', duration: 0.8, ease: [0.65, 0, 0.35, 1] },
829
+ steps: [
830
+ { duration: 0.8, to: { x: 0, y: 36 } },
831
+ { duration: 0.8, to: { x: 70, y: 0 } },
832
+ { duration: 0.8, to: { x: -70 } }, // y untouched — holds at 0
833
+ ],
834
+ },
835
+ ```
836
+
837
+ - **The hold rule:** each leg animates only the properties named in its `to`; everything else holds its value from the previous leg. In the example, leg 3 moves `x` while `y` stays where leg 2 left it.
838
+ - Use separate named clips when an element has separate behaviors, such as a one-shot entrance plus an idle animation. They remain separate rows even when the element combines both outputs.
839
+ - **Declare every animated property in `from`.** The sequence's `current` merges all legs from that starting state — a property with no initial value has nothing to hold or interpolate from before its first leg.
840
+ - Each leg takes its own `transition`; legs without one inherit the clip's `transition` (or the default spring).
841
+ - The clip's duration is the sum of its legs — there's no separate duration to edit.
842
+ - `tl.path.step` reports the index of the leg under the playhead.
843
+
844
+ ### Property tracks — `props`
845
+
846
+ When one element's properties need *independent* timing — different durations, different curves, offset phases — give each property a full track:
847
+
848
+ ```tsx
849
+ float: {
850
+ at: 0,
851
+ loop: true,
852
+ props: {
853
+ y: {
854
+ from: -9,
855
+ transition: { type: 'easing', duration: 0.6, ease: [0.45, 0, 0.55, 1] },
856
+ steps: [
857
+ { duration: 0.6, to: 9 },
858
+ { duration: 0.6, to: -9 },
859
+ ],
860
+ },
861
+ scale: {
862
+ from: 0.94,
863
+ delay: 0.12, // starts 120ms behind y — a phase offset
864
+ transition: { type: 'easing', duration: 0.6, ease: [0.8, 0, 0.2, 1] },
865
+ steps: [
866
+ { duration: 0.6, to: 1.06 },
867
+ { duration: 0.6, to: 0.94 },
868
+ ],
869
+ },
870
+ },
871
+ },
872
+ ```
873
+
874
+ Each track is a mini-clip: its own `from`/`to` or `steps`, `duration`, `transition`, and a `delay` offset from the clip's `at`. In the dock, the clip's row is a read-only composite bar — click it to expand one full row per track, where everything is editable and dragging a track's bar adjusts its phase. When the clip loops, each track folds against its own cycle length, so tracks with different periods drift in and out of phase like real oscillators.
875
+
876
+ `props` is mutually exclusive with `from`/`to`/`steps` on the same clip. Track names that collide with clip fields (`at`, `duration`, `loop`, `from`, `to`, `transition`, `delay`, `stepN`) are skipped with a warning. Properties that share timing belong in a plain `from`/`to` or `steps` clip instead.
877
+
878
+ ### Layers — nested groups
879
+
880
+ Nesting clips one level under a key groups them into a collapsible layer in the dock. Purely presentational — values nest the same way:
881
+
882
+ ```tsx
883
+ const tl = useDialTimeline('Compound', {
884
+ circle: {
885
+ path: { at: 0, /* ... */ },
886
+ float: { at: 0, /* ... */ },
887
+ },
888
+ });
889
+
890
+ tl.circle.path.current
891
+ tl.circle.float.current
892
+ ```
893
+
894
+ ### Looping
895
+
896
+ There are two loop controls, and they compose:
897
+
898
+ **Clip loop** — `loop: true` on a clip repeats its cycle from `at` until the timeline ends. The bar is one cycle, so dragging it longer slows the loop. Looping is code-defined rather than editable in the dock. There is no mirror mode — a loop that should return home (a bob, a pulse) is a sequence whose last leg lands back on the starting values, which also puts the loop seam at a natural zero-velocity point.
899
+
900
+ **Timeline loop** — the `loop` option wraps the *playhead*:
901
+
902
+ ```tsx
903
+ useDialTimeline('Hero', config, { loop: true }); // wrap to 0
904
+ useDialTimeline('Hero', config, { loop: { from: 1.4 } }); // wrap to 1.4s
905
+ ```
906
+
907
+ `{ from }` is the intro-then-idle pattern: clips before that time play exactly once, and looping clips inside the region keep cycling with continuous phase — no snap at the wrap. Scrubbing always pins the deterministic first-pass state.
908
+
909
+ **Event-driven timelines** — pass `autoplay: false` and drive the transport from your app. The dock and your code share the same clock: click your real button, watch the playhead run, scrub back, tune, click again.
910
+
911
+ ```tsx
912
+ const toast = useDialTimeline('Toast', config, { autoplay: false });
913
+
914
+ <button onClick={() => toast.replay()}>Save changes</button>
915
+ ```
916
+
917
+ ### Timeline duration
918
+
919
+ The top-level `duration` is the minimum editing window. Omit it and DialKit initially infers an exact fit to the last clip's end. If a live edit—such as switching to a longer physics spring—would move content past that boundary, DialKit extends the timeline automatically. Set `duration` explicitly when you want deliberate slack; authored content is never clipped to fit it.
920
+
921
+ ```tsx
922
+ const tl = useDialTimeline('Hero', {
923
+ duration: 4, // seconds; minimum window, inferred when omitted
924
+ headline: { at: 0.45, /* ... */ },
925
+ });
926
+ ```
927
+
928
+ ### The dock — `<DialTimeline />`
929
+
930
+ Mount once, anywhere. The dock renders fixed to the bottom of the screen via a portal and shows every registered timeline as a section, mirroring how `<DialRoot />` collects panels. It renders nothing until a timeline registers, and like `DialRoot` it's hidden in production builds.
931
+
932
+ ```tsx
933
+ <DialTimeline theme="dark" />
934
+ ```
935
+
936
+ | Prop | Type | Default |
937
+ |------|------|---------|
938
+ | `theme` | `'system' \| 'light' \| 'dark'` | `'system'` |
939
+ | `defaultVisible` | `boolean` | `true` |
940
+ | `visible` | `boolean` | uncontrolled |
941
+ | `onVisibilityChange` | `(visible: boolean) => void` | `undefined` |
942
+ | `defaultOpen` | `boolean` | `true` |
943
+ | `productionEnabled` | `boolean` | `false` in production, `true` otherwise |
944
+
945
+ Each section's toolbar has **Play/Pause**, **Add Version**, a version selector, **Copy**, and a collapse chevron. The collapsed toolbar stays playable and keeps its full-range overview scrubber, so you can inspect the animation without opening the detailed tracks.
946
+
947
+ When `DialRoot` and `DialTimeline` are mounted together, the panel header gets a timeline icon that hides or shows the entire dock. Visibility never changes playback, and showing the dock restores each section's previous collapsed or expanded state. Use `visible` and `onVisibilityChange` when the host app needs to control or persist this state.
948
+
949
+ In the grid:
950
+
951
+ - **Scrub the full timeline** by dragging the compact toolbar overview — playback pauses while you drag and resumes on release.
952
+ - **Zoom the timescale** by holding Option/Alt while dragging left or right on the expanded seconds ruler. The gesture zooms around the point where it began. Hold Shift and drag to reset to the full view; dragging without a modifier seeks directly.
953
+ - **Drag a clip** to move it in time; **drag its edges** to resize (the transition retimes with it).
954
+ - **Drag a segment boundary** on a sequence to resize the leg on its left; the overall clip grows or shrinks with it.
955
+ - **Click a clip** to open its popover — the same DialKit controls (including values, color pickers, and the spring/easing curve editor) scoped to that clip's values. Time-based transitions expose their duration there, and track popovers expose track delay. Click a segment to edit one leg.
956
+ - **Click a props clip's composite bar** to expand its tracks into full editable rows; drag a track's bar to phase-shift it.
957
+ - Physics-spring clips show `~` before their derived duration and resize via their popover physics instead of their edges.
958
+
959
+ Clip names are labels only. Editors open from clips and segments in the timeline.
960
+
961
+ **Copy** exports the current timing values as an agent-ready instruction — tuned `at`s, durations, curves, and values, normalized so defaults and editor-only fields don't add noise. Physics springs include their effective settled duration. The instruction tells the agent to keep `clip.current` during authoring and add a `TODO(production)` comment beside the hook. That breadcrumb explains that the sampled bindings must eventually be replaced with real Motion animations before `useDialTimeline` and `<DialTimeline />` are removed; hiding the dock alone leaves `clip.current` rendering in place.
962
+
963
+ `DialRoot` and `DialTimeline` are independent: use both together (panels for styling, timeline for timing), or the dock alone. Timeline-backed panels don't appear in `DialRoot`.
964
+
965
+ `formatClock(seconds, tenths?)` is exported for rendering `mm:ss` / `mm:ss.t` readouts in your own UI.
966
+
967
+ ---
968
+
468
969
  ## Solid
469
970
 
470
971
  DialKit also works with Solid. Import from `dialkit/solid` instead of `dialkit` — the API mirrors the React version, with `createDialKit` replacing `useDialKit` and `DialRoot` as a Solid component.
@@ -513,7 +1014,44 @@ function Card() {
513
1014
  }
514
1015
  ```
515
1016
 
516
- `createDialKit` returns an accessor — call `params()` to read the current values. All control types, config shapes, and panel features (presets, copy, folders) work identically to the React version.
1017
+ `createDialKit` returns an accessor — call `params()` to read the current values. All control types, config shapes, and panel features (presets, copy, folders, and `DialRoot` props like `onOpenChange`) work identically to the React version.
1018
+
1019
+ Solid timelines use the same accessor shape:
1020
+
1021
+ ```tsx
1022
+ import { createDialTimeline, DialTimeline } from 'dialkit/solid';
1023
+
1024
+ function Hero() {
1025
+ const timeline = createDialTimeline('Hero', {
1026
+ entrance: {
1027
+ at: 0,
1028
+ duration: 0.6,
1029
+ from: { y: 32, opacity: 0 },
1030
+ to: { y: 0, opacity: 1 },
1031
+ },
1032
+ });
1033
+
1034
+ return <>
1035
+ <h1 style={{ opacity: timeline().entrance.current.opacity }}>Ship it.</h1>
1036
+ <DialTimeline />
1037
+ </>;
1038
+ }
1039
+ ```
1040
+
1041
+ Use `createDialKitController` when Solid code needs to update values:
1042
+
1043
+ ```tsx
1044
+ import { createDialKitController } from 'dialkit/solid';
1045
+
1046
+ const dial = createDialKitController('Card', {
1047
+ blur: [24, 0, 100],
1048
+ shadow: { radius: [16, 0, 64] },
1049
+ });
1050
+
1051
+ dial.setValues({ blur: 48, shadow: { radius: 28 } });
1052
+ dial.resetValues();
1053
+ dial.values().blur;
1054
+ ```
517
1055
 
518
1056
  ---
519
1057
 
@@ -554,7 +1092,50 @@ npm install dialkit
554
1092
  </div>
555
1093
  ```
556
1094
 
557
- `createDialKit` returns a reactive object — access values directly (e.g. `params.blur`). Styles are injected automatically by `DialRoot` (no CSS import needed). Cleanup is automatic when the component unmounts. All control types, presets, folders, and transitions match the React/Solid entries.
1095
+ `createDialKit` returns a reactive object — access values directly (e.g. `params.blur`). Styles are injected automatically by `DialRoot` (no CSS import needed). Cleanup is automatic when the component unmounts. All control types, presets, folders, transitions, and `DialRoot` props like `onOpenChange` match the React/Solid entries.
1096
+
1097
+ Svelte timelines are reactive objects too:
1098
+
1099
+ ```svelte
1100
+ <script>
1101
+ import { createDialTimeline, DialTimeline } from 'dialkit/svelte';
1102
+
1103
+ const timeline = createDialTimeline('Hero', {
1104
+ entrance: {
1105
+ at: 0,
1106
+ duration: 0.6,
1107
+ from: { y: 32, opacity: 0 },
1108
+ to: { y: 0, opacity: 1 }
1109
+ }
1110
+ });
1111
+ </script>
1112
+
1113
+ <h1 style:opacity={timeline.entrance.current.opacity}>Ship it.</h1>
1114
+ <DialTimeline />
1115
+ ```
1116
+
1117
+ Use `createDialKitController` when Svelte code needs to update values:
1118
+
1119
+ ```svelte
1120
+ <script>
1121
+ import { createDialKitController } from 'dialkit/svelte';
1122
+
1123
+ const dial = createDialKitController('Card', {
1124
+ blur: [24, 0, 100],
1125
+ shadow: { radius: [16, 0, 64] }
1126
+ });
1127
+
1128
+ const params = dial.values;
1129
+ </script>
1130
+
1131
+ <button onclick={() => dial.setValues({ blur: 48, shadow: { radius: 28 } })}>
1132
+ Apply preset
1133
+ </button>
1134
+
1135
+ <div style:filter={`blur(${params.blur}px)`} style:border-radius={`${params.shadow.radius}px`}>
1136
+ ...
1137
+ </div>
1138
+ ```
558
1139
 
559
1140
  ---
560
1141
 
@@ -586,7 +1167,7 @@ import Card from './Card.vue';
586
1167
 
587
1168
  <template>
588
1169
  <Card />
589
- <DialRoot />
1170
+ <DialRoot @open-change="(open) => localStorage.setItem('dialkit-open', open ? '1' : '0')" />
590
1171
  </template>
591
1172
  ```
592
1173
 
@@ -617,6 +1198,47 @@ const params = useDialKit('Card', {
617
1198
 
618
1199
  `useDialKit` returns a reactive object. All control types, presets, folders, keyboard shortcuts, and transitions work identically to the other frameworks.
619
1200
 
1201
+ Vue timelines return a computed ref, which templates unwrap automatically:
1202
+
1203
+ ```vue
1204
+ <script setup>
1205
+ import { useDialTimeline, DialTimeline } from 'dialkit/vue';
1206
+
1207
+ const timeline = useDialTimeline('Hero', {
1208
+ entrance: {
1209
+ at: 0,
1210
+ duration: 0.6,
1211
+ from: { y: 32, opacity: 0 },
1212
+ to: { y: 0, opacity: 1 },
1213
+ },
1214
+ });
1215
+ </script>
1216
+
1217
+ <template>
1218
+ <h1 :style="{ opacity: timeline.entrance.current.opacity }">Ship it.</h1>
1219
+ <DialTimeline />
1220
+ </template>
1221
+ ```
1222
+
1223
+ Use `useDialKitController` when Vue code needs to update values:
1224
+
1225
+ ```vue
1226
+ <script setup>
1227
+ import { useDialKitController } from 'dialkit/vue';
1228
+
1229
+ const { values, setValues, resetValues } = useDialKitController('Card', {
1230
+ blur: [24, 0, 100],
1231
+ shadow: { radius: [16, 0, 64] },
1232
+ });
1233
+ </script>
1234
+
1235
+ <template>
1236
+ <button @click="setValues({ blur: 48, shadow: { radius: 28 } })">Apply preset</button>
1237
+ <button @click="resetValues()">Reset</button>
1238
+ <div :style="{ filter: `blur(${values.blur}px)`, borderRadius: `${values.shadow.radius}px` }" />
1239
+ </template>
1240
+ ```
1241
+
620
1242
  ---
621
1243
 
622
1244
  ## Types
@@ -626,6 +1248,8 @@ All config and value types are exported:
626
1248
  ```tsx
627
1249
  import type {
628
1250
  SpringConfig,
1251
+ EasingConfig,
1252
+ TransitionConfig,
629
1253
  ActionConfig,
630
1254
  SelectConfig,
631
1255
  ColorConfig,
@@ -634,6 +1258,8 @@ import type {
634
1258
  ShortcutMode,
635
1259
  DialConfig,
636
1260
  DialValue,
1261
+ DialKitController,
1262
+ DialKitValueUpdates,
637
1263
  ResolvedValues,
638
1264
  ControlMeta,
639
1265
  PanelConfig,
@@ -641,7 +1267,28 @@ import type {
641
1267
  } from 'dialkit';
642
1268
  ```
643
1269
 
644
- Return values are fully typed: `params.blur` infers as `number`, `params.color` as `string`, `params.spring` as `SpringConfig`, `params.shadow` as a nested object, etc.
1270
+ Timeline types are exported as well:
1271
+
1272
+ ```tsx
1273
+ import type {
1274
+ TimelineConfig,
1275
+ TimelineClipConfig,
1276
+ TimelineClipValues,
1277
+ TimelineClipCss,
1278
+ TimelineClipLoop,
1279
+ TimelineGroupConfig,
1280
+ TimelineGroupValues,
1281
+ TimelinePropConfig,
1282
+ TimelinePropStepConfig,
1283
+ TimelineStepConfig,
1284
+ TimelineStepValues,
1285
+ DialTimelineValues,
1286
+ UseDialTimelineOptions,
1287
+ DialTimelineProps,
1288
+ } from 'dialkit';
1289
+ ```
1290
+
1291
+ Return values are fully typed: `params.blur` infers as `number`, `params.color` as `string`, `params.spring` as `SpringConfig`, `params.shadow` as a nested object, etc. Timeline values are typed from the config shape too — `tl.card.current.y` infers as `number`, and `step` only exists on sequence clips.
645
1292
 
646
1293
  ---
647
1294