@kitlangton/knobs 0.0.0-stage → 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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1270 -2
  3. package/dist/dev/chunk-e13znr7c.js +155 -0
  4. package/dist/dev/chunk-kdewv0mt.js +1334 -0
  5. package/dist/dev/chunk-ktsn42p7.js +15668 -0
  6. package/dist/dev/chunk-qtt698hp.js +1118 -0
  7. package/dist/dev/index.js +1389 -0
  8. package/dist/index.js +2367 -0
  9. package/dist/panel.js +23635 -0
  10. package/dist/react.js +326 -0
  11. package/dist/solid.js +336 -0
  12. package/dist/themes.js +235 -0
  13. package/dist/types/core/adapter.d.ts +21 -0
  14. package/dist/types/core/code.d.ts +3 -0
  15. package/dist/types/core/color.d.ts +27 -0
  16. package/dist/types/core/easing.d.ts +21 -0
  17. package/dist/types/core/internal.d.ts +18 -0
  18. package/dist/types/core/keys.d.ts +21 -0
  19. package/dist/types/core/mutate.d.ts +21 -0
  20. package/dist/types/core/ramp.d.ts +150 -0
  21. package/dist/types/core/random.d.ts +2 -0
  22. package/dist/types/core/readout.d.ts +73 -0
  23. package/dist/types/core/remote.d.ts +258 -0
  24. package/dist/types/core/schema.d.ts +526 -0
  25. package/dist/types/core/spring.d.ts +38 -0
  26. package/dist/types/core/storage.d.ts +8 -0
  27. package/dist/types/core/store.d.ts +474 -0
  28. package/dist/types/core/theme.d.ts +27 -0
  29. package/dist/types/core/variants.d.ts +17 -0
  30. package/dist/types/index.d.ts +13 -0
  31. package/dist/types/panel/attach.d.ts +5 -0
  32. package/dist/types/panel/choice.d.ts +56 -0
  33. package/dist/types/panel/die.d.ts +121 -0
  34. package/dist/types/panel/editors.d.ts +159 -0
  35. package/dist/types/panel/folds.d.ts +14 -0
  36. package/dist/types/panel/give.d.ts +73 -0
  37. package/dist/types/panel/gpu/atlas.d.ts +40 -0
  38. package/dist/types/panel/gpu/color.d.ts +128 -0
  39. package/dist/types/panel/gpu/die.wgsl.d.ts +1 -0
  40. package/dist/types/panel/gpu/renderer.d.ts +712 -0
  41. package/dist/types/panel/gpu/shader.wgsl.d.ts +2 -0
  42. package/dist/types/panel/haptics.d.ts +8 -0
  43. package/dist/types/panel/has.d.ts +14 -0
  44. package/dist/types/panel/hints.d.ts +45 -0
  45. package/dist/types/panel/icons.d.ts +30 -0
  46. package/dist/types/panel/index.d.ts +10 -0
  47. package/dist/types/panel/inspector.d.ts +20 -0
  48. package/dist/types/panel/measure.d.ts +6 -0
  49. package/dist/types/panel/menu.d.ts +38 -0
  50. package/dist/types/panel/morph.d.ts +38 -0
  51. package/dist/types/panel/motion.d.ts +77 -0
  52. package/dist/types/panel/page-hints.d.ts +82 -0
  53. package/dist/types/panel/page.d.ts +423 -0
  54. package/dist/types/panel/panel.d.ts +43 -0
  55. package/dist/types/panel/position.d.ts +5 -0
  56. package/dist/types/panel/prefs.d.ts +28 -0
  57. package/dist/types/panel/presets.d.ts +48 -0
  58. package/dist/types/panel/ramps.d.ts +14 -0
  59. package/dist/types/panel/readout-row.d.ts +21 -0
  60. package/dist/types/panel/reveal.d.ts +15 -0
  61. package/dist/types/panel/rolling-css.d.ts +2 -0
  62. package/dist/types/panel/search.d.ts +36 -0
  63. package/dist/types/panel/seg.d.ts +15 -0
  64. package/dist/types/panel/session.d.ts +21 -0
  65. package/dist/types/panel/slider.d.ts +92 -0
  66. package/dist/types/panel/smear.d.ts +7 -0
  67. package/dist/types/panel/spin.d.ts +13 -0
  68. package/dist/types/panel/styles.d.ts +11 -0
  69. package/dist/types/panel/swipe.d.ts +114 -0
  70. package/dist/types/panel/switcher.d.ts +20 -0
  71. package/dist/types/panel/tabs.d.ts +31 -0
  72. package/dist/types/panel/thumb.d.ts +36 -0
  73. package/dist/types/react/Knobs.d.ts +15 -0
  74. package/dist/types/react/VariantSwitcher.d.ts +10 -0
  75. package/dist/types/react/index.d.ts +3 -0
  76. package/dist/types/react/useControls.d.ts +11 -0
  77. package/dist/types/solid/index.d.ts +50 -0
  78. package/dist/types/themes/index.d.ts +1 -0
  79. package/dist/types/ui/bento-css.d.ts +1 -0
  80. package/dist/types/ui/bento-model.d.ts +96 -0
  81. package/dist/types/ui/bento.d.ts +77 -0
  82. package/dist/types/ui/commands.d.ts +11 -0
  83. package/dist/types/ui/confirm.d.ts +13 -0
  84. package/dist/types/ui/index.d.ts +20 -0
  85. package/dist/types/ui/layer.d.ts +70 -0
  86. package/dist/types/ui/look.d.ts +53 -0
  87. package/dist/types/ui/menu.d.ts +63 -0
  88. package/dist/types/ui/overlay-css.d.ts +1 -0
  89. package/dist/types/ui/overlay-model.d.ts +87 -0
  90. package/dist/types/ui/palette.d.ts +38 -0
  91. package/dist/types/ui/popover.d.ts +35 -0
  92. package/dist/types/ui/react.d.ts +45 -0
  93. package/dist/types/ui/readout-css.d.ts +1 -0
  94. package/dist/types/ui/readout-model.d.ts +137 -0
  95. package/dist/types/ui/readouts.d.ts +85 -0
  96. package/dist/types/ui/rgba.d.ts +6 -0
  97. package/dist/types/ui/solid.d.ts +40 -0
  98. package/dist/types/ui/split-model.d.ts +81 -0
  99. package/dist/types/ui/split.d.ts +75 -0
  100. package/dist/types/ui/timeline-model.d.ts +83 -0
  101. package/dist/types/ui/timeline.d.ts +76 -0
  102. package/dist/types/ui/toast.d.ts +19 -0
  103. package/dist/types/ui/tooltip.d.ts +19 -0
  104. package/dist/types/ui/tree-model.d.ts +125 -0
  105. package/dist/types/ui/tree.d.ts +95 -0
  106. package/dist/ui-react.js +6558 -0
  107. package/dist/ui-solid.js +6511 -0
  108. package/dist/ui.js +9238 -0
  109. package/package.json +99 -4
package/README.md CHANGED
@@ -1,3 +1,1271 @@
1
- # Temporary Holding Version
1
+ # knobs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Embeddable, keyboard-first tuning controls for React, Solid, and plain DOM.
4
+
5
+ Give a component a few knobs. Hover it to reveal a tiny cog and open a floating panel, or mount an inline panel directly into a sidebar or tuning dashboard. Explore a seed, a spring curve, a spacing, or a layout variant. Save the good variations. Copy constants back into your source and ship zero tuning UI in production.
6
+
7
+ Each panel's header carries **Random · Reset · Copy · Undo/Redo** as icon buttons, quiet until the pointer or keyboard is in the panel. Undo and redo fold in once there's history and away when there's none; Copy turns into a check for a moment once it has copied, and Reset's ↺ turns once each time it resets (a press mid-turn adds another; with reduced motion it blinks instead). The header has no `?` button: `?` opens the shortcuts, as the title's tooltip says (`header: { buttons: [..., "help"] }` brings the button back). It sits above a preset strip and inset-grouped cards of rows. Random is a die that tumbles when it rolls; `⌥`-click it (or press `⌥R`) to **mutate** instead: small changes around the current values, with a quarter turn of the die. Each group has its own quieter die beside its reset icon. **Lock** a row (`⇧L`, or the row's context menu) to keep it out of Random and Mutate; a small lock sits just left of its value. Hold **`\`** to see the values from before your last change, in the panel and your app alike. Shipped presets and your saved variants live together in that strip: `+` (or `⇧S`) saves the current values as a new chip, `⌫` on the strip deletes a saved one, and the `?` popover exports/imports them as JSON.
8
+
9
+ Beyond sliders, choices and switches there are four richer fields: a SwiftUI-style **spring** (duration and bounce beside its live step-response curve), an **OKLCH colour** (swatch and hex, with hue, chroma and lightness strips that paint their real gradients), a 2D **point** pad, and a cubic-bezier **easing** editor with named curves.
10
+
11
+ Zero runtime dependencies in core. Pure DOM panels with `@kitlangton/rolling-number` readouts and DialKit-style sliders: a flat accent-tinted fill that stops exactly at its ends, a hairline handle centred on its edge that fades where it would cross the text, a faint notch at the default that pulls drags onto it, stepped sliders (ten steps or fewer) that resist around their stop and snap to the next past halfway, `⌥`-drag for 1/10 speed, and a sideways trackpad pan that moves the value with your fingers like a drag (a `⇧` + wheel notch steps once); the same pan drags the selection pill across the preset strip or a choice, through as many options as you travel, flips a switch from anywhere on its row, scrubs a seed and steps a ramp's presets, and the momentum after you lift is ignored, so it stops where your fingers did. At an end, a pan gives a little like a rubber band instead of stopping dead. Switching presets ripples the changed rows top to bottom; selection pills and switch thumbs stretch toward where they're going and settle; the panel is a frosted glass surface over your work. A reload, Vite's included, keeps floating panels open, editors expanded and focus where it was, per tab. Thin adapters for React (`knobs/react`) and Solid (`knobs/solid`), plus a remote/descriptor mode for driving values over a WebSocket.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ bun add knobs@npm:@kitlangton/knobs
17
+ ```
18
+
19
+ The package is published as `@kitlangton/knobs`; installing it under the alias `knobs` keeps
20
+ every import in this README (`knobs`, `knobs/react`, `knobs/panel`, `knobs/ui`…) as written.
21
+ React and Solid are optional peers, needed only for `knobs/react`, `knobs/ui/react`,
22
+ `knobs/solid` and `knobs/ui/solid`.
23
+
24
+ ## 30 seconds
25
+
26
+ ```sh
27
+ bun install
28
+ bun run build
29
+ ```
30
+
31
+ ### Plain DOM
32
+
33
+ ```ts
34
+ import { defineControls, knob, choice, toggle } from "knobs"
35
+
36
+ const splat = defineControls("splat", {
37
+ seed: knob(22, { min: 1, max: 99, step: 1, shortcut: "s" }),
38
+ count: knob(98, { min: 0, max: 300, step: 1, shortcut: "c" }),
39
+ size: knob(7, { min: 1, max: 30, step: 0.5, unit: "px" }),
40
+ flavor: choice("inked", ["inked", "clean"]),
41
+ grain: toggle(true),
42
+ }, {
43
+ enabled: import.meta.env.DEV,
44
+ presets: { subtle: { count: 40, size: 4 } },
45
+ })
46
+
47
+ // Floating hover chip + anchored panel (it grows out of the chip and collapses back into it):
48
+ const detach = splat.attach(document.querySelector<HTMLElement>("#splat")!)
49
+
50
+ // Or mount an inline panel inside a container:
51
+ const unmount = splat.mount(document.querySelector<HTMLElement>("#sidebar")!, {
52
+ theme: "auto",
53
+ density: "compact",
54
+ })
55
+
56
+ const unsubscribe = splat.subscribe(() => render(splat.get()))
57
+ render(splat.get())
58
+ // On teardown: detach(); unmount(); unsubscribe()
59
+ ```
60
+
61
+ ### React (`knobs/react`)
62
+
63
+ Define controls at module scope so renders do not recreate the store.
64
+
65
+ ```tsx
66
+ import { Knobs, KnobsPanel, useControls, VariantSwitcher } from "knobs/react"
67
+
68
+ function Splatter() {
69
+ const values = useControls(splat)
70
+ // values.count: number; values.flavor: "inked" | "clean"
71
+ return (
72
+ <Knobs controls={splat}>
73
+ <section>
74
+ <MySplatter {...values} />
75
+ <VariantSwitcher controls={splat} field="flavor">
76
+ <span>{values.flavor}</span>
77
+ </VariantSwitcher>
78
+ </section>
79
+ </Knobs>
80
+ )
81
+ }
82
+
83
+ // Or mount an inline panel anywhere in the tree:
84
+ function Sidebar() {
85
+ return <KnobsPanel controls={splat} theme="light" density="compact" variants={false} />
86
+ }
87
+ ```
88
+
89
+ `Knobs` adds no wrapper DOM: its single child must be a native HTML element or a component that forwards its ref to one. Existing refs are composed. For explicit attachment, use `const ref = useKnobs(splat)` and put `ref` on your element.
90
+
91
+ ### Solid (`knobs/solid`)
92
+
93
+ ```tsx
94
+ import { Knobs, KnobsPanel, useControls, useKnobs, VariantSwitcher } from "knobs/solid"
95
+
96
+ function Splatter() {
97
+ const values = useControls(splat)
98
+ // values() is a reactive Accessor<Values<typeof splat.schema>>
99
+ return (
100
+ <Knobs controls={splat}>
101
+ <section>
102
+ <MySplatter count={values().count} flavor={values().flavor} />
103
+ <VariantSwitcher controls={splat} field="flavor">
104
+ <span>{values().flavor}</span>
105
+ </VariantSwitcher>
106
+ </section>
107
+ </Knobs>
108
+ )
109
+ }
110
+ ```
111
+
112
+ `useKnobs(splat)` works as a Solid `ref={useKnobs(splat)}` callback, and `knobs` works as a `use:knobs={splat}` directive.
113
+
114
+ ### Springs, colours, points and easings
115
+
116
+ ```ts
117
+ import { color, defineControls, easing, point, spring, springCss, bezierCss } from "knobs"
118
+
119
+ const motion = defineControls("motion", {
120
+ travel: spring({ duration: 0.55, bounce: 0.3 }), // { duration, bounce }
121
+ curve: easing("ease-out"), // [x1, y1, x2, y2]
122
+ target: point({ x: 0.5, y: 0 }, { min: -1, max: 1, step: 0.01 }), // { x, y }
123
+ tint: color("#ef774b"), // "oklch(0.7 0.16 40)"
124
+ })
125
+
126
+ const v = motion.get()
127
+ const { easing: spring, duration } = springCss(v.travel) // a CSS linear() twin of the spring
128
+ el.style.transition = `transform ${duration}ms ${spring}, opacity 300ms ${bezierCss(v.curve)}`
129
+ el.style.background = v.tint // OKLCH strings work as-is in CSS
130
+ ```
131
+
132
+ - **`spring(value?, meta?)`**: `{ duration, bounce }` like SwiftUI's `.spring(duration:bounce:)` (ω = 2π / duration; ζ = 1 − bounce, or 1 / (1 + bounce) below zero). Duration runs 0.05–2 s and bounce −0.5–1, both in 0.01 steps. The row is two slider stops beside the step response over a fixed 1.2 s window, drawn with the panel's own spring integrator; a dot runs the curve in real time after each change. `springCss(value)` gives the same motion as a CSS `linear()` easing and duration; `springMotion(value)` gives ω and ζ.
133
+ - **`color(value, meta?)`**: takes `#rgb`, `#rrggbb`, `oklch(L C H)` or `{ l, c, h }` and stores a canonical `oklch(L C H)` string (L 0–1), so values serialise, compare and drop into CSS directly. Colours always fit sRGB: chroma is trimmed until they do, keeping lightness and hue. Every hex comes back exactly: `colorHex(color("#0000ff").value)` is `"#0000ff"`. The chroma strip spans 0 to the sRGB gamut edge at the current hue and lightness, so scrubbing hue or lightness keeps the thumb's relative vividness while the readout shows the OKLCH chroma. `colorHex(value)` and `parseColor(value)` convert.
134
+ - **`point(value, { min, max, step? })`**: `{ x, y }`; bounds and step take a number or `{ x, y }`. y grows downward, as in CSS and UIKit. The pad marks the default with a ring; a drag is one undo step, and `⌥` drags at 1/10 speed.
135
+ - **`easing(value, meta?)`**: `[x1, y1, x2, y2]` or a name: `ease`, `ease-in`/`in`, `ease-out`/`out`, `ease-in-out`/`in-out`, `linear`, `smooth`, `snappy` or `back`. x stays within 0–1 and y within −0.6–1.6. The row shows a mini curve; the editor has two handles and the named curves as chips. `bezierCss(value)` writes the CSS.
136
+
137
+ ### Gradients and curves
138
+
139
+ ```ts
140
+ import { curve, defineControls, gradient, sampleCurve, sampleGradient } from "knobs"
141
+
142
+ const schema = {
143
+ smoke: gradient(
144
+ [
145
+ { at: 0, color: "#fff2c4" },
146
+ { at: 0.3, color: "#7a2e14" },
147
+ { at: 1, color: "#2a2a2c" },
148
+ ],
149
+ {
150
+ label: "Smoke over life",
151
+ presets: { Steam: [{ at: 0, color: "#fff" }, { at: 1, color: "#6b7480" }] },
152
+ },
153
+ ), // [{ at, color: "oklch(…)" }]
154
+ falloff: curve("inverse²", { label: "Light falloff" }), // [{ x, y }]
155
+ gain: curve([{ x: 0, y: 0 }, { x: 1, y: 2 }], { range: [0, 2], interp: "linear" }),
156
+ }
157
+ const fx = defineControls("fx", schema)
158
+
159
+ const v = fx.get()
160
+ puff.style.background = sampleGradient(v.smoke, age) // an oklch() string
161
+ const [r, g, b] = sampleGradient(v.smoke, age, { as: "linear" }) // for a shader
162
+ const alpha = sampleCurve(v.falloff, distance / radius, schema.falloff) // the field's interp
163
+ ```
164
+
165
+ - **`gradient(stops, { space?, presets?, ...meta })`**: a colour ramp, `[{ at, color }]`. `at` runs
166
+ 0–1; colours take what `color()` does and are stored as `oklch()` strings. Stops stay sorted
167
+ (2–32 of them). `space: "oklab"` (the default) blends perceptually; `"srgb"` blends the gamma
168
+ channels as CSS does. The row shows the ramp with a tick per stop. Open, it's a wide strip with
169
+ a pin per stop: drag a pin (it may pass its neighbours), pull it off the strip to delete it
170
+ (never below two), or click the strip to add a stop in the colour already there. The selected
171
+ stop's colour is edited in the hue, chroma and lightness strips below, and `presets` show as
172
+ chips.
173
+ - **`curve(points | shape, { range?, interp?, shapes?, presets?, ...meta })`**: a 1D curve,
174
+ `[{ x, y }]`, with x across 0–1 and y within `range` (0–1 by default). The first and last points
175
+ stay at x 0 and 1; a curve holds 2–64 points. `interp` is `"smooth"` (a monotone cubic, so it
176
+ never overshoots between points), `"linear"` or `"step"`. The row shows the shape's name, if it
177
+ is one, and a mini curve. Open, it's a graph with a grid: drag points (`⇧` snaps to the grid),
178
+ press on the curve or double-click empty space to add one, drag one out of the graph (or press
179
+ Delete) to remove it. A crosshair follows the pointer and reads out `x → y`. The built-in
180
+ shapes `linear`, `ease-out`, `inverse²`, `smoothstep` and `bell` (`CURVE_SHAPES`, mapped onto
181
+ the range) show as chips beside any `presets`; `shapes: false` hides them. `curve("bell")`
182
+ starts from one.
183
+ - **`sampleGradient(stops, t, { space?, as? })`** returns the colour at `t` as an `oklch()` string,
184
+ or `as: "hex"`, `"rgb"` (gamma sRGB 0–1) or `"linear"` (linear-light, for shaders).
185
+ `gradientCss(stops, { space?, direction? })` writes a CSS `linear-gradient()`.
186
+ **`sampleCurve(points, x, { interp? })`** returns y at `x`. Pass the field as the options, as
187
+ above, so the blend or interpolation matches the editor's.
188
+
189
+ Keys, with the strip or graph focused: `←`/`→` move the selected stop or point (`⇧` ×10, `⌥`
190
+ finest on a curve), and `↑`/`↓` move a point's y. `Tab` / `⇧Tab` step through the stops or points,
191
+ then move on past the ends, and `[` / `]` cycle through them. `n` adds one halfway to the next,
192
+ and `Delete` removes the selected one. On a gradient, `Enter` edits the stop's colour and `Esc`
193
+ comes back. `←`/`→` on a closed row or on the chips cycle the presets. A drag is one undo step.
194
+
195
+ Presets change smoothly: both ramps are sampled at fixed points (64 colours, 129 curve points)
196
+ and the samples blend on the panel's spring, so a strip shifts colour and a curve bends into its
197
+ new shape while the pins and points glide. The WebGPU renderer draws the same samples. Random
198
+ keeps a gradient's stop count and walks its hue along a lightness ramp. On a curve, Random picks
199
+ a built-in shape and jitters it. Mutate nudges colours as it does a `color` and moves stops and
200
+ points without letting them pass each other.
201
+
202
+ ### Inspector fields: vectors, ranges, angles, seeds, key combinations and toolbars
203
+
204
+ ```ts
205
+ import { angle, defineControls, keybind, range, seed, toolbar, vec2, vec3 } from "knobs"
206
+
207
+ const node = defineControls("node", {
208
+ tools: toolbar([
209
+ { id: "play", label: "Play", icon: "play", shortcut: "Space" },
210
+ { id: "retrigger", label: "Retrigger", icon: "retrigger", shortcut: "R", global: true },
211
+ { id: "frame", label: "Frame", icon: "frame", shortcut: { key: "F", scope: "none" } }, // a chip only
212
+ ]),
213
+ position: vec3({ x: 0, y: 1.2, z: -4 }, { min: -50, max: 50, step: 0.1 }), // { x, y, z }
214
+ ground: vec2({ x: 0, y: 0 }, { min: -50, max: 50, axes: ["x", "z"] }), // { x, y }, shown as X and Z
215
+ scale: vec3({ x: 1, y: 1, z: 1 }, { min: 0.01, max: 10, step: 0.01, linked: true }),
216
+ pivot: vec2({ x: 0.5, y: 0 }, { min: 0, max: 1, step: 0.01 }), // { x, y }
217
+ visible: range([4, 40], { min: 0, max: 100, step: 0.5, unit: "m" }), // [low, high]
218
+ yaw: angle(90), // degrees
219
+ seed: seed(1234), // a whole number
220
+ fire: keybind("Space"), // "Meta+Shift+K", "" for none
221
+ }, { onAction: (id) => run(id) })
222
+ ```
223
+
224
+ - **`vec2(value, { min, max, step?, unit?, decimals? })` / `vec3(…)`**: inline x, y (and z) number
225
+ cells. Each cell is a ↑↓ stop: drag it sideways or press ←/→ to scrub (`⇧` ×10, `⌥` finest),
226
+ Enter or a digit to type. `link: true` adds a link button; while linked (`linked: true` starts
227
+ that way) changing one axis scales the others with it. `axes: ["x", "z"]` names the cells (their
228
+ letters and accessible names, "Ground z") for a ground-plane pair; the value keeps its `x` and `y`.
229
+ - **`range([low, high], { min, max, step?, unit?, decimals? })`**: one slider with two thumbs that
230
+ can't cross. The nearer thumb follows the pointer; a press well inside the band moves the whole
231
+ band. Each thumb is a stop: ←/→ move it, Enter types it. A thumb that shows never sits on the
232
+ label or readout: their text fades for a few pixels either side of it.
233
+ - **`angle(value, { min?, max?, step?, wrap? })`**: degrees on a small dial, 0° up and clockwise.
234
+ 0–360 by default, where `wrap` (on for a full turn) carries 359° on to 0°. Drag the hand round
235
+ (`⇧` snaps to 15°) or scrub the readout; ←/→ turn it, Enter types it.
236
+ - **`seed(value, { min?, max? })`**: a whole number with a die. Clicking the die or pressing Space
237
+ rolls a new one; ←/→ step it and Enter types it. Its digits roll to a new value; a rolled seed's
238
+ spin upward for as long as the die tumbles. Random and Mutate leave seeds alone unless the field
239
+ says `random: true`.
240
+ - **`keybind(value?)`**: a key combination such as `"Meta+Shift+K"` (stored canonically, `""` for
241
+ none), drawn as key caps. Enter or a click starts listening; the next combination is the value,
242
+ Esc cancels and ⌫ clears. Not rolled by Random or Mutate. Letters follow the user's layout
243
+ (Dvorak's K records and matches as K), with ⌥ combinations read through the browser's layout
244
+ map; `comboFromEvent`, `matchesCombo` and `letterOf` do the same for a host's own keys.
245
+ - **`toolbar(items)`**: a wrapping row of buttons with an icon (`play`, `pause`, `frame`,
246
+ `retrigger`, `clear`, `target`, `camera`, `sparkle`, … or SVG path data on a 256 grid), a label
247
+ and a shortcut chip. A press calls the item's `run` and `onAction(id)`; `controls.action(key, id)`
248
+ does it from code. Shortcuts work while the panel has focus, and anywhere on the page with
249
+ `global: true` (not while typing). A panel's shortcuts win over its own single-key commands.
250
+ `shortcut: { key, scope }` says where one works: `"panel"` (the default), `"page"` (as `global`)
251
+ or `"none"`, a chip that only shows a key the host binds itself (a canvas's `F`), so the panel's
252
+ own `f` and `g` still work beside it. The row holds no value; ←/→ move along it and Enter or
253
+ Space presses.
254
+
255
+ Remote descriptors use the same names: `kind: "vec2" | "vec3" | "range" | "angle" | "seed" |
256
+ "keybind"` with `default`, `min`, `max`, `step`, `unit`, `decimals`, `link`, `linked` and `wrap`, and
257
+ `{ kind: "toolbar", items: [{ key, label, icon?, shortcut?, global?, disabled? }] }`; vectors take
258
+ `axes`. Each kind takes
259
+ `disabled`, `readOnly` and mixed values like the rest ("—" in place of the value).
260
+
261
+ Spring, point, easing, gradient and curve values compare by their contents everywhere (`isModified`, presets, undo, variants), and unchanged ones keep their identity across updates so memoized React children skip them. Random values stay in range people use (springs 0.2–0.9 s with bounce up to 0.45, mid-light colours of moderate chroma, any point, a named curve).
262
+
263
+ ### Readouts: live values, sparklines, graphs, meters, tables and logs
264
+
265
+ Readout rows show what a running tool is doing, beside the controls that change it. They hold no value: nothing goes into the store's values, undo, presets or storage. Push to them with `controls.watch(key, value)`, as often as every frame.
266
+
267
+ ```ts
268
+ import { defineControls, graph, log, meter, sparkline, table, watch } from "knobs"
269
+
270
+ const live = defineControls("live", {
271
+ frame: graph({
272
+ label: "Frame time",
273
+ series: [{ id: "cpu", label: "CPU", area: true }, { id: "gpu", label: "GPU" }],
274
+ unit: "ms",
275
+ min: 0,
276
+ thresholds: [{ value: 16.7, label: "60 fps" }],
277
+ }),
278
+ fps: watch({ label: "FPS", decimals: 0 }),
279
+ drops: sparkline({ label: "Drops", min: 0 }),
280
+ memory: meter({ label: "GPU memory", max: 512, unit: "MB", warn: 384, critical: 470 }),
281
+ passes: table({ label: "Passes", columns: ["Pass", "ms", "Draws"] }),
282
+ events: log({ label: "Events", lines: 8 }),
283
+ })
284
+
285
+ // Each frame:
286
+ live.watch("frame", { cpu: 5.2, gpu: 9.1 })
287
+ live.watch("fps", 60)
288
+ live.watch("drops", 1428)
289
+ live.watch("memory", 383) // null: indeterminate
290
+ live.watch("passes", [["Shadow", 0.42, 12], ["Rain", 1.1, 16]])
291
+ live.watch("events", { level: "warn", text: "Gutter drip over budget" })
292
+ ```
293
+
294
+ - **`watch`**: a number or text, with `unit`, `decimals` or `format(n)`.
295
+ - **`sparkline`**: a number per sample, or an array that replaces the history (`history`, 60 by default). Hovered or focused, it reads the history's min–max.
296
+ - **`graph`**: a taller row with several series (`{ [id]: number }` per sample; a series left out breaks its line), `area` fills, `thresholds` with labels, and a crosshair that follows the pointer or `←`/`→` (`⇧` by ten). The legend reads the sample under it. The title keeps its words: in a narrow column the legend drops its units, then wraps under the title.
297
+ - **`meter`**: a slim bar out of `max`, or `segments` cells. From `warn` it takes `--k-warn`, from `critical` `--k-critical`, and it doesn't flicker between them when the value hovers at a threshold. `null` sweeps it as indeterminate (`aria-busy`).
298
+ - **`table`**: rows of cells, or a key-value record. Numbers sit right in tabular figures.
299
+ - **`log`**: a string, `{ text, level?, time? }` or an array of them. Level chips (All, Warn, Error, with counts), a filter that marks its match (`f` or `Enter` from the log), follow-tail that lets go when you scroll up and offers a "3 new ↓" pill, and Copy (`⌘C`) of the lines shown. Only the lines in view are in the DOM. Narrow, the filter shrinks and then the tools wrap under the title.
300
+
301
+ `controls.watched(key)` returns the latest value; `null` clears a readout. A readout can also poll: `get: () => value` is read each frame while its row is on screen (pushing is the usual way, and lets the page sleep).
302
+
303
+ Readouts cost nothing at rest. They draw on one shared clock, only while on screen and only when something arrived, so a paused feed lets the page, and the GPU overlay, sleep. Graphics are canvases redrawn from a list of drawing ops (the GPU overlay draws the same ops). Text changes at most `hz` times a second (4 by default), in boxes that only grow, so nothing reflows as numbers change. Scales snap to round steps (1, 2 or 5 × 10ⁿ) and ease there on a spring, in log space, so a spike rescales smoothly. The store keeps each readout's history, so a panel mounted later starts with it. On their own, outside a panel: `readout(sparkline({ unit: "ms" }), { preset })` from `knobs/ui` returns `{ element, push(value), value, dispose() }`.
304
+
305
+ ## Keyboard
306
+
307
+ Panels and switchers are keyboard-first:
308
+
309
+ | Key | Action |
310
+ | --- | --- |
311
+ | `↑` / `↓` or `j` / `k` | Move between presets, rows and custom actions (`←`/`→` move along the custom actions); a spring's two sliders are two stops |
312
+ | `←` / `→` or `h` / `l` | Adjust slider, cycle choice, or flip toggle (`⇧` multiplies numeric step by `×10`); on a colour row turn the hue, on a point row move x, on an easing row cycle the named curves |
313
+ | `Enter` / `Space` | Open a colour, point, easing, gradient or curve row's editor; `↓` then walks into it (strips, chips, handles, pad) |
314
+ | `Esc` | Fold an open editor and return to its row (before closing the panel) |
315
+ | Arrows on a pad or curve handle | Move the puck or handle (`⇧` ×10, `⌥` finest); `j` / `k` or `Tab` move on |
316
+ | On a gradient strip or curve graph | Arrows move the selected stop or point; `Tab` / `⇧Tab` and `[` / `]` pick another; `n` adds one, `Delete` removes it; `Enter` edits a stop's colour (`Esc` returns) |
317
+ | `[` / `]` | Cycle presets and saved variants from anywhere in the panel; with no preset strip, cycle tabs |
318
+ | `⌥1`–`⌥9` | Show tab 1–9 |
319
+ | `/` | Focus the search field (`Esc` clears it, then hands focus back; `↓` or `Enter` go to the first row left) |
320
+ | `Home` / `End` | Jump slider to `min` or `max` |
321
+ | `0`–`9`, `.`, `-` or `Enter` | Edit numeric value inline: the readout's own digits, in place, with the unit after them. `↑` `↓` step the text (`⇧` by the big step), `Enter` commits (the readout rolls from what was typed to the nearest step in range), `Esc` cancels |
322
+ | `Backspace` / `Delete` | Reset focused row (or the selection) to its default (or double-click row/label) |
323
+ | `⌥` + drag | Fine-tune a slider at 1/10 speed |
324
+ | Pan sideways (two fingers) | Every control that holds a value answers it, in the active state a drag gives it. Sliders, colour strips, pads, easing handles, vector cells, ranges and dials move 1:1 with the fingers; over the preset strip or a choice, the pill slides through the options, and a choice menu steps them. Anywhere on a switch's row, its thumb follows the fingers and the switch flips past the middle (a flick goes its way). A seed scrubs a step every 12 px, its die rolling along, and a flick carries on a few steps. Over a gradient strip or a curve graph it moves the stop or point under the pointer; on a gradient, curve or easing row's head it steps through the presets, and on a colour row's head it turns the hue. At a limit everything gives a little (6 px at most, with one tick) and springs back when you let go. One undo entry per gesture; momentum is otherwise ignored; vertical scrolling passes through. A `⇧` + wheel notch steps once |
325
+ | `s` | Jump: badges every row (one code per row, compound ones included), preset and button with its shortest unique prefix; type it to jump (presets apply). With other panels or `knobs/ui` pieces on the page, it labels all of them (see [Hint mode across the page](#hint-mode-across-the-page)) |
326
+ | `Tab` / `⇧Tab` | Next/previous control, wrapping inside floating panels |
327
+ | `f` | Focus first row |
328
+ | `r` / `⇧R` | Randomize all fields / reset all fields to defaults (locked fields keep their values on Random, not on Reset) |
329
+ | `⌥R` | Mutate: nudge every unlocked value a little (numbers and points 8–12% of their range, colours a few degrees of hue and a touch of chroma and lightness, springs and curves slightly; choices and switches change about 15% of the time) |
330
+ | `g` / `⌥G` | Randomize / mutate the focused row's group (also the die beside each group's reset icon; `⌥`-click mutates) |
331
+ | `⇧L` | Lock or unlock the focused row (or the selection) from Random and Mutate |
332
+ | `⇧`-click / `⇧↑` `⇧↓` | Select a range of rows (`⌘`-click adds or removes one); `r`, `⌥R`, `⌫`, `⇧L` and `⇧C` then act on the selection; `Esc` or a plain click clears it |
333
+ | Right-click / `⇧F10` / `ContextMenu` | Menu for the row or selection: Lock from Random, Randomize, Reset, Copy; `↑↓`, a letter, `Enter`, `Esc` |
334
+ | Hold `\` | Compare: show the values from before the last change while held (the title dims); nothing is recorded |
335
+ | `⇧C` | Copy the current values (or the selected fields) as code |
336
+ | `⇧S` | Save the current values as a new preset chip |
337
+ | `u` / `z` | Undo (`⇧u` / `⇧z` redo); slider drags coalesce into a single history entry |
338
+ | `?` | Toggle keyboard shortcuts popover (the title's tooltip mentions it) |
339
+ | `Esc` | Close shortcuts popover or floating panel and restore focus to its chip |
340
+
341
+ ### Hint mode across the page
342
+
343
+ With more than one thing on the page to label (two panels, a panel beside a `knobs/ui` tree,
344
+ timeline or split, or the host's own `data-hint` buttons), `s` reaches all of them: pressed in a
345
+ panel or anywhere outside a text field, every visible control in every panel (rows, tabs, toolbar
346
+ buttons, header buttons), each tree row on screen, a timeline's Play, markers and playhead, the
347
+ splits' header actions, collapse buttons and rails, and the host's elements gets a code. Typing one
348
+ focuses its control (a row) or presses it (a button, a marker, a tree row); `Backspace` takes a
349
+ letter back, `Enter` takes the first match, `Esc`, a click or a scroll cancels. Only what shows is
350
+ labelled: rows on other tabs, in folded groups or scrolled out of their pane aren't.
351
+
352
+ Codes are one or two letters, grouped: each panel or piece takes a letter of its own (panels first,
353
+ then the pieces, then the host's; down each column, left to right; home row first) and its controls
354
+ a second letter, home row first, so `a` then `s` is the first panel's second control. A group of
355
+ one is just its letter; spare letters split a big group so its second letters stay on the home row.
356
+ With only one panel on the page, nothing changes: `s` works while focus is in it, with codes from
357
+ its labels.
358
+
359
+ ```ts
360
+ import { hints } from "knobs/panel" // or "knobs/ui"
361
+
362
+ // The host's own targets: elements, or { el, label?, labelEl?, activate? }.
363
+ const off = hints.register(toolbar, () => toolbar.querySelectorAll("button"))
364
+ ```
365
+
366
+ ```html
367
+ <!-- Or no code: a data-hint (its value labels it; empty uses the aria-label or text). -->
368
+ <nav data-hint-group>
369
+ <button data-hint>Edit</button>
370
+ <button data-hint="Play">▶ Play</button>
371
+ </nav>
372
+ ```
373
+
374
+ A `data-hint` element is pressed (a click) unless it's a field or focusable without being a button,
375
+ which takes focus. `data-hint-group` keeps a container's elements under one letter. Panels opt out
376
+ with `keys: { hint: false }`, or keep `s` to themselves with `hints: "panel"` on the mount (the
377
+ default is `"page"`); `tree`, `timeline` and `split` take `hints: false`. A tree leaves `s` to hint
378
+ mode while the page has it (type-ahead keeps every other letter). `hints.start()` and `hints.stop()`
379
+ drive it from a host's own key.
380
+
381
+ In `VariantSwitcher` (React, Solid, or `attachVariantSwitcher` from `knobs/panel`): hover or focus, then press **`1`–`9`** or **`←`/`→`**; `Enter`/`Space` cycles when focused. Nested switchers share a single hover owner, and text inputs keep their own keys.
382
+
383
+ ## Groups, Labels, Actions & Remote Mode
384
+
385
+ Fields accept optional `label`, `group`, and `shortcut` metadata:
386
+
387
+ ```ts
388
+ const motion = defineControls("motion", {
389
+ duration: knob(0.4, { min: 0.1, max: 1, step: 0.05, unit: "s", label: "Duration", group: "Spring", shortcut: "d" }),
390
+ bounce: knob(0.25, { min: 0, max: 0.7, step: 0.05, label: "Bounce", group: "Spring", shortcut: "b" }),
391
+ chrome: choice("glass", ["glass", "solid"], { label: "Chrome", group: "Surface" }),
392
+ }, {
393
+ actions: [
394
+ { label: "Copy JSON", run: (v, ctl) => navigator.clipboard.writeText(ctl.code("json")).then(() => "Copied JSON.") },
395
+ ],
396
+ })
397
+ ```
398
+
399
+ When fields specify `group`, each group's rows share one rounded card under a section header.
400
+
401
+ **Labels apart from ids.** A group's string is its identity (group presets, `resetGroup`, `onReset({ group })`, `page.highlight({ groups })`, `swift(group)`), and by default also its title. When the ids are machine-shaped (`drawer.spacing`, `composer.spacing`) give them labels; same-labelled groups stay separate cards:
402
+
403
+ ```ts
404
+ defineControls("tune", schema, {
405
+ groupLabels: { "drawer.spacing": "Spacing", "composer.spacing": "Spacing" }, // by group id, verbatim
406
+ sectionLabels: { drawer: "Session List" }, // by section id (pages)
407
+ presetLabels: { snappy: "Snappy" }, // by preset id
408
+ defaultLabel: "Native", // shorthand for presetLabels.default: the baseline's chip
409
+ })
410
+ // or per field / descriptor (the first one that gives a label wins):
411
+ knob(44, { min: 28, max: 72, group: "drawer.spacing", groupLabel: "Spacing", section: "drawer", sectionLabel: "Session List" })
412
+ ```
413
+
414
+ `createRemoteControls` takes the same options (and `groupLabel` / `sectionLabel` on descriptors), and `mountPanel` / `mountPage` take them too, over the store's. Everything that shows a name uses the label: card headers in both renderers, aria names, hint codes, the preset strip, its hint badges. The filter matches labels and ids alike. The preset id stays `default` for `[` / `]`, storage and `matchingPreset()`; there's no second "default" chip. Stores answer `groupLabel(id)`, `sectionLabel(id)` and `presetLabel(id)`. Group presets already have names, so `presetLabels` doesn't apply to them. A small reset icon appears beside the header while anything in that group differs from its defaults, next to a die that randomizes just that group (`⌥`-click mutates it). A slider's default is a short notch rising from its bottom edge while the row is hovered, focused or dragged, brighter once the value moves off it; changed choices and switches show a small dot.
415
+
416
+ ### Remote / Socket-Driven Controls (`createRemoteControls`)
417
+
418
+ When values live on another device or process (such as the OpenCode iOS app paired with a Mac over WebSocket), build controls from runtime descriptors with `createRemoteControls`:
419
+
420
+ ```ts
421
+ import { createRemoteControls, type ControlDescriptor } from "knobs"
422
+ import { mountPanel } from "knobs/panel"
423
+
424
+ const descriptors: ControlDescriptor[] = [
425
+ { key: "drawer.spring.duration", label: "Duration", kind: "number", swiftType: "Double", default: 0.35, min: 0.1, max: 1, step: 0.05, unit: "s", group: "Spring" },
426
+ { key: "drawer.spring.bounce", label: "Bounce", kind: "number", swiftType: "Double", default: 0.15, min: 0, max: 0.6, step: 0.05, group: "Spring" },
427
+ ]
428
+
429
+ const remote = createRemoteControls("iphone", descriptors, {
430
+ values: phone.values,
431
+ onSet: (key, value) => ws.send(JSON.stringify({ type: "set", key, value })),
432
+ onReset: ({ key, group }) =>
433
+ ws.send(JSON.stringify(key ? { type: "set", key, value: null } : { type: "resetAll", group: group ?? null })),
434
+ actions: [
435
+ {
436
+ label: "Copy as Swift",
437
+ run: () => navigator.clipboard.writeText(remote.swift()).then(() => "Copied Swift."),
438
+ },
439
+ ],
440
+ })
441
+
442
+ // Apply incoming socket updates without echoing them back to onSet or polluting undo history:
443
+ ws.onmessage = (e) => {
444
+ const msg = JSON.parse(e.data)
445
+ if (msg.type === "state") remote.sync(msg.state.phones[0].values, { replace: true })
446
+ }
447
+
448
+ mountPanel(remote, document.querySelector("#groups")!, { variants: false })
449
+ ```
450
+
451
+ A change of several keys at once (a page or card preset, Native, Random, Mutate, resetting a selection, or undoing and redoing any of them) can go out as one message: give `onSetBatch` and it gets the change's entries in one call instead of an `onSet` per key. Each entry is what `onSet` would have received, with `null` again meaning back to the default. A one-key change still calls `onSet`, or `onSetBatch` with one entry when there's no `onSet`. Resetting everything or a card still reaches only `onReset`, and a card preset only `onPreset` when given. Echo holds stay per key: each key in a batch holds against stale state until the peer reports the value sent for it.
452
+
453
+ ```ts
454
+ onSetBatch: (changes) => ws.send(JSON.stringify({ type: "setMany", changes })),
455
+ ```
456
+
457
+ Descriptors also take `kind: "spring"` (default `{ duration, bounce }`), `"color"` (any string `color()` accepts), `"point"` (default `{ x, y }`, with `min`/`max`/`step` applying to both axes), `"easing"` (default `[x1, y1, x2, y2]`), `"gradient"` (default `[{ at, color }]`, with `space` and `presets`) and `"curve"` (default `[{ x, y }]`, with `range`, `interp`, `shapes` and `presets`). `onSet` then sends those values as they are (objects, strings and arrays), or `null` when a key returns to its default. `remote.swift()` emits matching Swift:
458
+
459
+ ```swift
460
+ TunableSpring("drawer.spring", "Drawer", duration: 0.45, bounce: 0.25) // was duration: 0.4, bounce: 0.25
461
+ Tunable<Color>("composer.tint", "Tint", default: Color(red: 0.2393, green: 0.5451, blue: 0.9999)) // #3d8bff · was Color(red: 0.0392, green: 0.5176, blue: 1)
462
+ Tunable<CGPoint>("composer.anchor", "Anchor", default: CGPoint(x: 0.5, y: 0.75)) // was CGPoint(x: 0.5, y: 1)
463
+ Tunable<UnitCurve>("drawer.fade", "Fade curve", default: .bezier(startControlPoint: UnitPoint(x: 0.32, y: 0.72), endControlPoint: UnitPoint(x: 0, y: 1))) // was .bezier(...)
464
+ ```
465
+
466
+ Colours leave as sRGB components (the OKLCH value is already inside sRGB). Points are `CGPoint`s and easings SwiftUI `UnitCurve.bezier` values. Gradients become `Gradient(stops: [.init(color: Color(…), location: …)])` and curves `[CGPoint]`. Paired `*.duration` / `*.bounce` number descriptors still fold into one `TunableSpring`.
467
+
468
+ **Migrating an app's Tune page:** the Swift side needs `Tunable` to support `Color`, `CGPoint` and `UnitCurve` (decoding the socket's `"oklch(L C H)"`/hex string, `{ x, y }` and `[x1, y1, x2, y2]`), and a `TunableSpring` that reads `{ duration, bounce }` from one key rather than two. Until then, keep sending a spring as paired `.duration`/`.bounce` number descriptors; the panel and codegen handle both. Nothing in other repositories changes until you opt in.
469
+
470
+ See [`docs/migration.md`](./docs/migration.md) for the OpenCode iOS Tune Page and the Problems rewrite, and [Embedding in an app](#embedding-in-an-app) below.
471
+
472
+ ## Embedding in an app
473
+
474
+ Panels can be part of a product, not just a dev tool: an app's parameter column, a prototype's
475
+ settings, a settings sheet. The app owns the values; the panel shows them and reports intents.
476
+
477
+ ```tsx
478
+ import { createRemoteControls, type ControlDescriptor } from "knobs"
479
+ import { KnobsPanel, useModified } from "knobs/solid"
480
+ import { problemsPreset } from "knobs/themes"
481
+
482
+ const params = createRemoteControls("parameters", [], {
483
+ ship: () => import("knobs/panel"), // live in production builds too (see Shipping)
484
+ nullDefaults: false, // onSet sends values as they are
485
+ onSet: (key, value) => save(key, value),
486
+ onReset: ({ key, group }) => clear(key ?? group), // {} resets everything
487
+ onAction: (key) => run(key),
488
+ })
489
+ const modified = useModified(params) // a "modified" dot beside your own heading
490
+
491
+ <KnobsPanel
492
+ controls={params}
493
+ descriptors={descriptors()} // a new array swaps rows in place; the same array only syncs values
494
+ values={values()} // your state, optimistic edits and server echoes included
495
+ land={switchingItem()} // another item arrived: values land without springing
496
+ header={false}
497
+ preset={problemsPreset}
498
+ keys={{ random: false, undo: false, vim: false, edit: false, bigStep: 5, wrap: false }}
499
+ onReady={(panel) => (focusPanel = () => panel.focus())}
500
+ />
501
+ ```
502
+
503
+ Without Solid: `remote.update(descriptors, values, { land })` and `mountPanel(remote, el, options)`.
504
+
505
+ - **Descriptor swaps.** `remote.update(descriptors, values, { land? })` keeps the panel. Rows whose
506
+ key persists keep their DOM and spring to the new value (a new range retunes in place); new rows
507
+ reveal and missing ones fold, in the cascade's turn. Undo history belongs to one descriptor set:
508
+ it starts over when the descriptors change shape or `land` is set.
509
+ - **One rule for incoming values.** A key is *held* while it's in hand (dragged, or open in the
510
+ inline editor) and, on remote controls, for 600 ms after it last changed here (`echo`). `sync`
511
+ and `update` leave held keys alone; when the hold ends, the newest value that arrived lands.
512
+ `controls.hold(key, on)` holds keys for controls of your own.
513
+ - **Flat springs.** `{ key: "spring", kind: "spring", bind: { duration: "d", bounce: "b" } }`
514
+ draws two flat number keys as one spring card. Their own number descriptors, if present, give
515
+ ranges and labels. `onSet` reports `"d"` and `"b"` separately; values stay flat. Locally:
516
+ `defineControls("m", { ...flatSpring({ duration: 0.45, bounce: 0.3 }, { label: "Spring" }) })`.
517
+ - **Actions and settings.** `{ kind: "action" }` (or `action("Replay")` locally) is a button row
518
+ that calls `onAction(key)`. Choices take `{ id, label }` options. `fromSettings(groups, home)` maps
519
+ a hosted prototype's settings sections (choices, switches, buttons, titled sections becoming
520
+ groups) to descriptors and values, with what the prototype first reported as each default.
521
+ - **Choices** are segmented while their labels fit with room to spare and there are at most four,
522
+ and a drop-down menu otherwise. The row's label keeps its words and the control gives way: a
523
+ menu's value truncates (its whole text shows on hover) down to a few letters, and segments that
524
+ would squeeze the label become a menu. Only then does the label give way, and never below half
525
+ the row. They switch both ways as the width changes; `display: "menu" | "segmented"` fixes one.
526
+ On a menu row, ←/→ step through the options, and Enter or Space opens the menu (↑↓, a letter,
527
+ Enter, Esc).
528
+ - **Keys stay scoped.** An inline panel hears keys only while focus is inside it. Keys it handles
529
+ are claimed (`preventDefault`) and stopped there, and everything else reaches the page. Esc hands
530
+ focus back. `keys` remaps (`{ hint: "f" }`) or turns off (`{ random: false }`) any command; a
531
+ command that's off loses its header button too, and the `?` popover and row menu list only what's
532
+ on (see **Header** for picking buttons). `vim: false` drops j/k/h/l, `edit: false` leaves digits
533
+ and Enter to the page, `bigStep` sets ⇧'s multiplier and `wrap: false` stops ↑/↓ at the ends.
534
+ - **Marks.** `marks: false` (on the store or the mount) hides modified dots, readout marks, group
535
+ resets and Reset, for values that belong to someone else. A command that's off takes its marks
536
+ too: with `reset: false` rows show nothing to reset, and group resets follow `resetAll`.
537
+ - **Haptics.** On phones, a stepped slider crossing a stop, the default's notch catching a drag, a
538
+ choice, preset or seed changing mid-pan, a pan meeting a limit and a switch flipping each give a
539
+ light tick: `navigator.vibrate` on
540
+ Android; on iPhone, Safari's switch tick (iOS 17.4–26.4 for every tick; from iOS 26.5, which only
541
+ plays it for a tap, switches alone). `haptics: false` on the mount turns them off, and so does
542
+ reduced motion.
543
+ - **Reading values.** `useValue(controls, key)` and `useModified(controls, key?)` change only when
544
+ their answer does; a panel update touches only the rows that changed.
545
+
546
+ ### Embedding options
547
+
548
+ An editor that already has its own state, undo stack and chrome (the Arcade Lab is the model:
549
+ `examples/lab.html`) hands the panel those instead of working around it:
550
+
551
+ ```ts
552
+ const station = createRemoteControls("lab-station", descriptors, {
553
+ values: settings,
554
+ nullDefaults: false,
555
+ echo: 0, // local state: no late echoes to wait out
556
+ random: seeded(tuning.seed), // Random and Mutate draw from this, not Math.random
557
+ tunable: (key) => !key.endsWith("Seed"), // beside locks and each field's `random: false`
558
+ history: editorHistory, // { undo, redo, canUndo, canRedo, subscribe? }
559
+ persist: "variants", // keep saved variants, never the values
560
+ onSet: (key, value, meta) => write(key, value, meta),
561
+ onGesture: ({ key, phase }) => (phase === "start" ? editor.begin(key) : editor.end(key)),
562
+ })
563
+ mountPanel(station, column, {
564
+ preset: labPreset,
565
+ header: { title: false, buttons: ["random", "reset", "undo", "redo", "help"] },
566
+ session: false,
567
+ groupFolded: { fireball: true },
568
+ rowAccessory: (key) => pinButton(key),
569
+ })
570
+ ```
571
+
572
+ - **Seeded Random.** `random` is the `[0, 1)` source Random and Mutate draw from. A roll draws
573
+ only for the keys it may change, in schema order, so the same seed and the same rows give the same
574
+ values. `tunable(key, values)` and a field's `random: false` keep keys out as a lock does (seeds,
575
+ structural pickers); `controls.canRoll(key)` says whether a key is in.
576
+ - **Host history.** With `history`, the panel's Undo and Redo buttons and keys call the host's
577
+ stack, and the buttons follow its `canUndo()` and `canRedo()` (re-read when `subscribe` fires).
578
+ knobs' own entries are then only for compare (hold `\`), which previews the last of them. The
579
+ preview and its release reach `onSet` / `onChange` with `source: "compare"`: apply them, but
580
+ don't record them as steps of the host's own history.
581
+ - **Gestures.** `onGesture({ key, phase })` (on `defineControls` and `createRemoteControls`) says
582
+ when a key goes into the hand (`"start"`) and comes out (`"end"`): a press or drag on its row,
583
+ wherever the pointer is let go; an inline edit; a run of `←`/`→` (or Page Up/Down), until the
584
+ keys have been up for 300 ms. Every change between them is one gesture, so a host opens an undo
585
+ entry at `start` and closes it at `end` (`examples/lab.ts` does). `controls.hold(key, on)`
586
+ reports the same.
587
+ - **Disabled, read-only and mixed.** A field's `disabled` dims its row and `readOnly` keeps it at
588
+ full strength; either takes no edits from the pointer or keys, and Random, Mutate and Reset skip
589
+ it. Each takes a boolean or `(values) => boolean`, re-checked on every change, so a row follows
590
+ the value it depends on. A function that reads state elsewhere (another store, the host's
591
+ settings) is read again when you call `controls.refresh()`, which re-checks the flags and asks
592
+ `rowAccessory` again without new values. Focus still reaches a gated row (to read it). Mixed keys (a
593
+ multi-selection whose items differ) read "—" until edited: `sync(values, { mixed })`,
594
+ `update(descriptors, values, { mixed })` or `controls.setMixed(keys, on?)`. The first edit of a
595
+ mixed key reaches `onSet` even when the value doesn't change, so it applies to every selected item.
596
+ - **Persistence.** `persist: "all"` (the default) keeps values, locks, saved variants and a floating
597
+ panel's position; `"variants"` keeps only saved variants, for values the host owns; `"none"`
598
+ keeps nothing. `session: false` on the mount stops the per-tab session too, so a rebuilt panel
599
+ doesn't take focus back to its last row or reopen editors and groups (on while there's storage).
600
+ - **Header.** `header: false` drops it; `{ title?: false, buttons?: [...] }` keeps it and picks what
601
+ shows (`"random" | "reset" | "copy" | "undo" | "redo" | "help" | "close"`). A hidden button keeps
602
+ its key; a command that's off (`keys`) loses both. **Without a `buttons` list, every button shows
603
+ except `help`** (the `?` button): `?` still opens the shortcuts, and the title's tooltip says so.
604
+ This is a change of default: before, `?` had a header button unless `buttons` left it out. List
605
+ `"help"` to keep it (`buttons: ["random", "reset", "copy", "undo", "redo", "help", "close"]`); a
606
+ `buttons` list that already names `"help"`, as the Lab's does, behaves as before.
607
+ - **Folding groups.** A field's `folded: true` (or `groupFolded` on the mount) makes its group
608
+ foldable, starting folded. Its header folds and opens it with a click, Enter or Space; it's a ↑↓
609
+ stop of its own, ← folds and → opens. `panel.fold(group, folded?)` does it from code. On a
610
+ [full page](#full-page) the group's tile folds to its header, the cards below follow, and the
611
+ filter opens it while it runs.
612
+ - **Group Random by the host.** `groupRandom: (group, mode) => …` runs first for a group's die, `g`
613
+ and `⌥G` (`mode` is `"random"` or `"mutate"`). Return `true` when the host rolled the group (its
614
+ own seeded "Randomize <group>"): the die tumbles and knobs leaves the values alone. Return
615
+ anything else and knobs rolls it. With `groupRandom` set, group dice stay enabled.
616
+ - **Row accessories.** `rowAccessory(key)` puts the host's node just after each row's label (a pin,
617
+ a spawn-time marker); slider ticks and the label make room for it. On a choice row it takes its
618
+ room from the value, never from the label: where none is left (a label too long for the
619
+ column) it lies over the label's end, which fades from under it while the row is hovered or
620
+ focused. The node stays the host's (style it, listen on it, its clicks never reach the row);
621
+ `panel.updateAccessories()` (or `controls.refresh()`, for every panel of the store) asks again.
622
+ With the WebGPU renderer it stays in the DOM, above the overlay.
623
+ - **Long menus.** A menu of more than eight options (not numbered presets) opens with a search
624
+ field: typing filters, ↑↓ and Enter choose, Esc closes. The row's value truncates with its whole
625
+ text as a tooltip, and the label keeps its words.
626
+
627
+ ### Tabs, sub-headings and search
628
+
629
+ A tool's inspector usually has more than fits in one column. Fields name a `tab` and a
630
+ `subsection`; the mount adds a tab strip and a search field:
631
+
632
+ ```ts
633
+ const node = defineControls("node", {
634
+ position: vec3({ x: 0, y: 0, z: 0 }, { min: -50, max: 50, group: "xform", tab: "transform" }),
635
+ pivot: vec2({ x: 0.5, y: 0 }, { min: 0, max: 1, group: "xform", subsection: "Pivot & bounds" }),
636
+ seed: seed(1234, { group: "emitter", tab: "emitter" }),
637
+ drag: knob(0.2, { min: 0, max: 1, group: "emitter", subsection: "Forces" }),
638
+ })
639
+ const panel = mountPanel(node, column, {
640
+ tabs: [{ id: "transform", label: "Transform" }, "emitter"], // order and labels
641
+ tab: "transform", // first shown (else the last one shown, else the first)
642
+ onTab: (id) => save(id),
643
+ subsectionFolded: { Forces: true },
644
+ search: { placeholder: "Search", chips: ["modified"] },
645
+ })
646
+ panel.tab("emitter") // shows a tab; panel.tab() answers the one showing
647
+ ```
648
+
649
+ - **Tabs.** A row shows on its field's `tab`, or else the first `tab` its group names; rows with
650
+ none show on every tab. The strip's underline slides to the chosen tab and the rows cross: what
651
+ leaves folds away as what arrives unfolds, a moment behind. `⌥1`–`⌥9` pick a tab, and `[` / `]`
652
+ cycle tabs when there's no preset strip. The tab last shown is remembered for these controls (and
653
+ in the session).
654
+ - **Sub-headings.** Fields with the same `subsection` in a group sit under a flat heading inside the
655
+ group's card, split off by a hairline (never a card inside a card). They're gathered: a group's
656
+ rows without a sub-heading come first, then each sub-heading's rows together, in the order the
657
+ sub-headings first appear, wherever their fields sit in the schema. The heading is a ↑↓ stop: a
658
+ click, Enter or Space folds it, ← folds and → opens. `subsectionFolded` starts some folded; the
659
+ session remembers the rest.
660
+ - **Search.** `search: true` (or `{ placeholder, chips }`) puts a field under the header. Typing
661
+ filters rows by label, key, group or sub-heading across every tab, opening folded groups and
662
+ sub-headings while it runs; matches show in the accent colour. Chips narrow it further:
663
+ `"modified"` keeps rows off their defaults and counts them, and a chip of your own is
664
+ `{ id, label, count?, test(key, controls) }`. `/` focuses the field. `panel.search(query?, chips?)`
665
+ searches from code, as typing and clicking would (`chips` lists exactly which are on), and
666
+ returns `{ query, chips }`; `panel.search()` only reads it.
667
+
668
+ ### Shipping
669
+
670
+ Stores are dev tools by default: a production build (the package's default export condition)
671
+ locks them to their defaults, mounts nothing and leaves the panel out of the bundle. `ship` makes a
672
+ store product UI:
673
+
674
+ ```ts
675
+ createRemoteControls("parameters", descriptors, { ship: () => import("knobs/panel") })
676
+ defineControls("settings", schema, { ship: () => import("knobs/panel") })
677
+ ```
678
+
679
+ A shipped store stays live in production (`enabled` defaults to true) and mounts from the module you
680
+ name, which your bundler splits into its own chunk. The core never imports the panel itself.
681
+ Stores without `ship` are unchanged.
682
+
683
+ In production its chips stay hidden until someone presses **⌥⇧D**, which turns knobs mode on and off
684
+ for the tab (remembered in `sessionStorage`, with a brief "knobs on" / "knobs off"); then they show on
685
+ hover as in development. The key works anywhere on the page except while typing in a field. `chip`
686
+ sets when chips show, the key and a label beside the cog, per store or per `attach`:
687
+
688
+ ```ts
689
+ defineControls("hero", schema, {
690
+ ship: () => import("knobs/panel"),
691
+ // reveal: "hover" (dev default) | "shortcut" (production default) | "always"
692
+ chip: { reveal: "shortcut", shortcut: "Alt+Shift+K", label: "knobs" },
693
+ })
694
+ hero.attach(element, { reveal: "always" })
695
+ ```
696
+
697
+ ### Styling and tokens
698
+
699
+ Theme with `--k-*` tokens on the panel or any ancestor, or ship a set as a preset:
700
+ `mountPanel(c, el, { preset })` with a `TokenPreset` (`{ name, dark, light?, css? }`), or
701
+ `presetCss(preset)` in your own stylesheet with `data-preset` on the panel. `problemsPreset` (from
702
+ `knobs/themes`) is the Problems viewer's parameter column. Every token it sets works on its own:
703
+
704
+ | Token | Default | What it does |
705
+ | --- | --- | --- |
706
+ | `--k-row-gap` | `0px` | Above 0, each row is its own bar, this far apart; a spring stacks its curve over its sliders |
707
+ | `--k-hover-in` / `--k-hover-out` | `150` / `150` | Hover washes in and out (ms) |
708
+ | `--k-chrome` | `1` | `0` drops an inline panel's card: background, border and rim |
709
+ | `--k-fill-base`, `--k-fill-rest-mix`, `--k-fill-hover-mix`, `--k-fill-active-mix` | surface-hi, 20%, rest, 30% | The fill: accent mixed into the base at rest, hovered and dragged |
710
+ | `--k-handle`, `--k-handle-rest`, `--k-handle-inset` | accent, `0`, `0px` | The handle's colour, its opacity at rest, and how far inside the fill's edge it sits; it fades where it passes under the label or readout |
711
+ | `--k-step-resist` | `0.25` | How far a stepped slider's fill follows the pointer off its stop while dragged: `0` snaps, `1` follows freely |
712
+ | `--k-motion-blur` | `0` | Up to `1`: moving fill edges and selection pills smear by their speed |
713
+ | `--k-range-blur` | `.5` | Up to `1`: a range band's gliding ends each smear by their own speed (`0` is off) |
714
+ | `--k-value-font`, `--k-unit-gap`, `--k-unit-color` | font, `.28em`, dim | The readout's face, and the unit's spacing and colour (`currentColor` matches the value) |
715
+ | `--k-readout-mark` | `0` | `1`: a modified value takes `--k-modified` behind a small dot |
716
+ | `--k-group-transform`, `--k-group-size`, `--k-group-color`, `--k-group-tracking`, `--k-group-inset` | uppercase, text-cap, dim, `.04em`, inset | Group headings |
717
+ | `--k-group-divider`, `--k-group-divider-gap` | transparent, `0px` | A hairline above every group but the first, and the room under it |
718
+ | `--k-curve-inset`, `--k-curve-guide`, `--k-curve-dot` | `6px`, `solid`, `1` | The spring curve's side inset; `dashed` draws one dashed line at rest instead of two; `0` hides the running dot |
719
+
720
+ Stable class hooks: `.knobs-panel` (with `data-preset`, `data-theme`, `data-density`,
721
+ `data-rows`, `data-landing`, `data-frame`), `.knobs-card`, `.knobs-group-head`, `.knobs-row`
722
+ and `[data-field="<key>"]` on every row (sliders, springs and editors too), `.knobs-slider`,
723
+ `.knobs-spring`, `.knobs-seg`, `.knobs-select`, `.knobs-action`, `.knobs-modified`,
724
+ `.knobs-mixed`, `[data-disabled]` and `[data-readonly]`. Both renderers draw every token above.
725
+
726
+ ### Matching your app
727
+
728
+ A panel inside an app should look like the app's own controls. Four things get it there.
729
+
730
+ **Tokens.** Beside colours and sizes, these carry a host's type and shapes:
731
+
732
+ | Token | Default | What it does |
733
+ | --- | --- | --- |
734
+ | `--k-font`, `--k-mono`, `--k-font-size` | system, mono, `12px` by density | Faces and size; `--k-value-font: var(--k-mono)` puts readouts in mono |
735
+ | `--k-font-weight`, `--k-label-weight`, `--k-title-weight` | `400`, `500`, `600` | Body, row labels, and titles, group headings and the chosen chip |
736
+ | `--k-radius`, `--k-panel-radius` | `8px` by density, radius + `4px` | Corners. `0` squares everything, the pill shapes (switches, handles, dots) included |
737
+ | `--k-thumb` | `#fff` | Switch thumbs and strip handles |
738
+ | `--k-lift` | `1` | `0` drops the small shadows under pills, thumbs and handles |
739
+ | `--k-frame` | `card` | `flat`: no card inside the host's own card (see below) |
740
+ | `--k-disabled-opacity` | `.32` | How far a disabled row dims |
741
+ | `--k-warn`, `--k-critical` | `#ff9f0a`, `#ff453a` (light: `#c56a00`, `#e0352b`) | Readouts' warning and critical colours (meters past `warn` / `critical`, log levels), and a destructive confirm |
742
+ | `--k-control-h` | row − 2 × unit | Height of the controls inside a row: segments, menus, switches, number cells, key caps, toolbar buttons |
743
+ | `--k-value-size` | font size − `.5px` | Size of every readout (sliders, cells, ranges, dials, seeds, editor summaries) |
744
+ | `--k-button-bg`, `--k-button-line`, `--k-button-hover-bg`, `--k-button-hover-line` | each button's own | Fill and outline of custom actions, Export/Import, toolbar buttons, action rows and preset chips, at rest and hovered. With a fill or outline set, an action row is a sized button in its row and the presets strip a row of buttons (`data-buttons` on the panel) |
745
+ | `--k-button-h`, `--k-button-font-size`, `--k-button-radius` | each button's own | Their height, label size and corners |
746
+ | `--k-button-color` | each button's own | Their label colour at rest (toolbar labels at full strength, say); hovered they take `--k-text` |
747
+
748
+ **Frame.** `--k-frame: flat` is for a panel set into the host's own column or card. Group cards
749
+ lose their surface and corners, rows are split by hairlines, and groups by `--k-group-divider` over
750
+ their heading. An inline flat panel also drops its own card (`--k-chrome: 0`), its border and its
751
+ padding, so nothing draws a box inside the host's box and its rows meet the column's edges exactly,
752
+ in both renderers. `examples/rows.html` shows a column in knobs' look, the Lab's and the Lab's with
753
+ button tokens, by the DOM and the GPU. The panel reports it as `data-frame="flat"`.
754
+
755
+ **Presets.** `labPreset` (from `knobs/themes`) is the Arcade Lab's column: its greys and blue
756
+ accent, 11.5px type at 26px rows, square corners, the flat frame, mono readouts, and no glass, glow
757
+ or lift. Start from it, or spread it and change a few tokens:
758
+
759
+ ```ts
760
+ import { labPreset } from "knobs/themes"
761
+ mountPanel(controls, column, { preset: { ...labPreset, dark: { ...labPreset.dark, accent: "#e0a030" } } })
762
+ ```
763
+
764
+ **Reset.** A host's element rules (`button { padding }`, `span { letter-spacing }`,
765
+ `*:focus { outline }`) don't reach panels or their menus. A scoped reset sits above element rules
766
+ and below every knobs rule, so there's nothing to undo in your stylesheet, and focus never draws an
767
+ outline. Your `rowAccessory` nodes are exempt and keep your styles. `examples/host.html` mounts
768
+ panels under a deliberately hostile stylesheet; the browser suite checks that nothing moves when it
769
+ turns off.
770
+
771
+ ## Full page
772
+
773
+ When there are more controls than a panel holds (a tuning page with a dozen groups), `mountPage` lays them out as a page: a sticky glass header with the title, the presets strip and **Random · Reset · Copy · Undo/Redo**, then **sections** of cards: every group is a card, and each section is a column of them under its heading. As many columns fit as `columnWidth` allows. A long section takes two or more adjacent columns (its cards read down one and on to the next, and the extra columns line up under its heading); when the sections outnumber the columns, short ones stack, in order, with their headings. The split keeps the tallest column as short as it can be. Columns are independent stacks: a card that changes height moves only the cards below it in its own column, never another column.
774
+
775
+ Everything moves rather than jumps. A card whose content changes (an editor opening, rows the filter hides, rows added or removed, a custom card swapping its content) eases to its new height and the cards below it glide. The columns re-balance only when the window gains or loses a column (after a brief pause while it's being resized), when cards come or go, when a section folds or opens, and when the filter changes, and then every card glides from where it was to its new place, interruptibly. With reduced motion, layout changes snap.
776
+
777
+ ```ts
778
+ import { createRemoteControls } from "knobs"
779
+ import { mountPage } from "knobs/panel"
780
+
781
+ const tune = createRemoteControls("iphone-tune", descriptors, {
782
+ values: phone.values,
783
+ // Numbered presets per group: 1, 2, 3… in the card's header. Keys a preset leaves out are at
784
+ // their defaults, so { name: "Today", values: {} } is the shipped look.
785
+ groupPresets: {
786
+ Rhythm: [
787
+ { name: "Today", values: {} },
788
+ { name: "Compact", values: { "transcript.row.flowHalf": 6, "transcript.row.turnHalf": 9 } },
789
+ ],
790
+ },
791
+ onSet: (key, value) => send({ type: "set", key, value }),
792
+ // The peer applies its own preset (and sends back its state) instead of getting a set per key.
793
+ onPreset: ({ group, index }) => send({ type: "preset", key: presetSetKey[group], index }),
794
+ })
795
+
796
+ const page = mountPage(tune, document.querySelector("#app")!, {
797
+ title: "iPhone Tune",
798
+ sections: ["Playground", "Session List", "Transcript"], // order; others follow as they appear
799
+ columnWidth: 300, // the narrowest a column gets (px)
800
+ copy: () => tune.swift(), // what Copy and ⇧C put on the clipboard
801
+ })
802
+ ```
803
+
804
+ Fields and descriptors take a `section` beside their `group` (`knob(44, { min: 28, max: 72, group: "Spacing", section: "Session List" })`); panels ignore it. `defineControls` takes `groupPresets` and `onPreset` too, and every store has `groupPresets`, `groupPreset(group, index, { coalesce? })` (one undo step, `source: "preset"` with its `group`) and `matchingGroupPreset(group)`.
805
+
806
+ **Card presets by name.** A card's presets sit in its header by name ("Native · Tight · Airy") as segments, with the same gliding pill as the preset strip (pans drag it, one undo step per gesture). When the names don't fit beside the title, they become a compact menu showing the selected name (a quiet "Custom" when nothing matches), its items numbered. The title wins the room: the presets give way first. `1`–`9` apply the focused card's presets either way, and hint mode (`s`) offers the focused card's presets as "1 Native", "2 Tight"…. `groupPresetDisplay` chooses (on the store, remote options or `mountPage`): `"auto"` (names while they fit, else the menu; the default), `"names"`, `"menu"`, or `"numbers"` for the compact 1, 2, 3 chips, where hovering a number shows its preset's name in place of the title and applying one by key shows it briefly.
807
+
808
+ **Card titles.** A card's title drops its section's name when it repeats it ahead of a separator (· • : / | › » or a dash): "Transcript · Arrivals" under the Transcript section reads "Arrivals". A `groupLabels` entry (or `groupLabel`) is used verbatim instead; custom cards' titles are shortened the same way. Card headers are compact: as tall as their 20 px controls, a gap and a half above the rows (`--k-head-gap`), tighter still in the compact density.
809
+
810
+ **Custom cards** sit in the same grid, in the same material, for anything that isn't a field: a playground switcher, a list of motion jumps, a video player. Small primitives come with them, each built from the panel's own rows; `set` shows a value that arrived from elsewhere without calling `onChange`:
811
+
812
+ ```ts
813
+ const playground = page.card({ title: "Playground & Slow Motion", section: "Playground" })
814
+ const screen = playground.choice({
815
+ options: [{ value: "transcript", label: "Transcript" }, { value: "composer", label: "Composer" }],
816
+ value: "transcript",
817
+ onChange: (v) => send({ type: "playground", playground: v }),
818
+ }) // no label: the segments span the card
819
+ const speed = playground.choice({
820
+ label: "Speed",
821
+ options: ["0.1", "0.25", "0.5", "1", "2"].map((v) => ({ value: v, label: `${v}×` })),
822
+ value: "1",
823
+ onChange: (v) => send({ type: "slowmo", scale: Number(v) }),
824
+ })
825
+ playground.actions([{ label: "Live arrival", run: () => replay("liveArrival") }])
826
+ playground.toggle({ label: "Reduce motion", value: false, onChange: (on) => setReduceMotion(on) })
827
+
828
+ const watch = page.card({ title: "Motion Watch", section: "Playground" })
829
+ watch.item({ label: "Layout jump · toolRow", detail: "14.2 pt · f212", onSelect: () => inspect(jump) })
830
+ watch.body.append(myPlayerElement) // or pass `element`; `clear()` empties it, `remove()` drops the card
831
+ speed.set("0.25") // the phone changed it
832
+ ```
833
+
834
+ **Collapsible cards.** Pass `collapsed` (`true` folded, `false` open) and a card's header toggles it, by a click or `Enter` where it's focused (`j` / `k` reach the header): the body fades as the card's height springs down to its header and the cards below in its column glide, interruptibly; other columns stay put. While folded the header shows `summary` on its right. `card.collapse(bool)` folds or opens it from code, `card.collapsed` says which, and `card.summary(text)` updates the line. `description` puts one muted line under the title. What the user last chose comes back after a reload in the same tab (sessionStorage, with the panel's other per-tab state), over `collapsed`.
835
+
836
+ ```ts
837
+ const connection = page.card({ title: "Connection", section: "Connection", collapsed: false, summary: "Waiting for iPhone…" })
838
+ connection.body.append(qrCode)
839
+ // Once the phone has paired and signed in:
840
+ connection.summary(`iPhone · ${phone.version} · connected`)
841
+ connection.collapse(true)
842
+ ```
843
+
844
+ Content that changes over time animates if you update it in place. `card.list()` keeps rows by key: `set` updates the rows that stay, folds new ones open and removed ones shut (rows replaced by others cross-fade while the card eases to its new height). `actions(...).set` swaps buttons the same way, keyed by label, and the row folds away while it's empty. `card.show(element, visible)` folds anything in the card open or shut.
845
+
846
+ ```ts
847
+ const jumps = watch.list()
848
+ jumps.set(phone.jumps.map((j) => ({ key: j.id, label: j.title, detail: j.detail, onSelect: () => replay(j.id) })))
849
+ const scenarios = playground.actions([])
850
+ scenarios.set(screen === "composer" ? [{ label: "Call", run: call }, { label: "Dictation", run: dictate }] : [])
851
+ ```
852
+
853
+ **What's on screen.** `page.highlight({ groups?, sections? })` marks what's showing elsewhere, such as the phone's current screen. Highlighted cards and sections keep full strength. A section highlighted as a whole gets a small dot by its heading, and a card highlighted on its own gets one by its title. Everything else dims a little under a veil of the page's colour (`--k-page-dim`, 0.4). Dimmed cards stay readable and fully interactive, and one you focus comes back up while you work in it. Changes glide on the panel spring (`--k-spring`) and can be interrupted midway. `null` clears it. `groups` takes group ids or custom card titles, and `sections` takes section ids. With `{ reveal: true }`, the first highlighted card (or its section's heading) glides into view when it's off screen. It won't do that while you're dragging, or if you scrolled, pressed a key or touched the page in the last two seconds. `Tab` from the header lands on the first highlighted card. Both renderers draw the veil and the dot the same way.
854
+
855
+ ```ts
856
+ // The tune server's Context { id, groups, label? }: the phone's groups are the page's sections.
857
+ page.highlight({ sections: phone.context.groups.map((g) => SECTIONS[g] ?? title(g)) }, { reveal: true, expand: true })
858
+ ```
859
+
860
+ With `{ expand: true }`, the highlight also opens the folded sections and cards it names. What it opened folds again once a later highlight (any, `null` included) moves away from it, unless you worked in it meanwhile (a click or a key inside, or folding it yourself makes it yours). Opening and refolding this way isn't remembered.
861
+
862
+ #### Folding sections, summaries and the jump strip
863
+
864
+ A page with many sections reads better folded. `sectionFolded` starts sections folded to their headings: `true` for all of them, or by section id (`{ Transcript: true }`). It's taken from the mount, then the store or remote options, then a field's `sectionFolded: true` (the first field of a section that says wins). A section that says either way can fold, and its heading toggles it by a click, or `Enter` / `Space` where it's focused. Its cards fold away where they are and the columns re-balance with a folded section counted as its heading alone; everything glides and can be interrupted. `page.foldSection(section, folded?, { remember?, reveal? })` and `page.fold(group, folded?, { remember?, reveal? })` do it from code (they toggle without `folded`), and `page.sectionFolded(section)` says which. While the filter (`/`) runs, it opens the folded sections and cards it matches without touching their folds; when it clears, everything is back as it was folded.
865
+
866
+ Folded things say what's in them. A folded group card trades its presets, reset and die for "Compact · 7 changed" (the matching group preset, or "Custom", then its changed rows; each part only when it applies). A folded section's heading reads "2 changed · 3 cards". `groupSummary(group, info)` and `sectionSummary(section, info)` on the mount replace either (`info` is a `FoldSummaryInfo`: `{ preset, changed, keys }`; return `null` for nothing). Group cards fold as in a panel, by a field's or descriptor's `folded`, or `groupFolded` on the mount, `defineControls` or `createRemoteControls` (both take `sectionFolded` too).
867
+
868
+ Descriptions add one muted line under a title: `groupDescriptions` and `sectionDescriptions` by id (on the store, remote options or the mount), or `groupDescription` / `sectionDescription` on any field or descriptor (the first that gives one wins). `page.card({ description })` does the same for a custom card. The filter matches descriptions too.
869
+
870
+ What the user folds and opens (sections and group cards) is remembered under `knobs:<id>:folds:v1` (`storageKey(id, "folds")`) as `{ sections: Record<string, boolean>, groups: Record<string, boolean> }`, and wins over `sectionFolded` and `groupFolded`. Only the user's folds and those made with `remember` are written; folds for context (`remember: false`, a highlight's `expand`) and the filter's never are. By default they go to the store's own storage, or nowhere with `persist: "none"`. `foldStorage` on the mount always wins and keeps folds only, never values, locks or variants, so a remote store on `storage: null` (the phone's values always win) can still remember them; `null` keeps none.
871
+
872
+ ```ts
873
+ const tune = createRemoteControls("iphone-tune", descriptors, { storage: null, /* … */ })
874
+ mountPage(tune, app, { foldStorage: localStorage })
875
+ ```
876
+
877
+ Code chooses what counts as the user's:
878
+
879
+ | Call | Remembered |
880
+ | --- | --- |
881
+ | `page.fold(group, folded?)`, `page.foldSection(section, folded?)` | Yes: they act for the user (a button of yours). Pass `{ remember: false }` for context. |
882
+ | `page.reveal({ section } \| { group })` | No: it's for context (what's on the phone's screen). It opens the section (and the group's card), glides it under the header, and `activeSection()` reports it until the user scrolls. Pass `{ remember: true }` for a jump the user asked for, such as a click in your own navigation. |
883
+ | `page.highlight(target, { expand: true })` | No, and it folds again once the context moves on (see above). |
884
+ | A click or key on a heading or card header, a jump-strip chip, `⌥↑` / `⌥↓` | Yes. |
885
+
886
+ `{ reveal: true }` on `fold` and `foldSection` glides the card or heading into view as well.
887
+
888
+ **Your own navigation.** A sidebar of your own reads the page instead of drawing a strip in its header:
889
+
890
+ ```ts
891
+ page.sections()
892
+ // [{ id: "Transcript", label: "Transcript", description?: "…", folded: true, changed: 8,
893
+ // groups: [{ id: "transcript.arrivals", label: "Arrivals", folded: false, changed: 1, preset: "Custom" }, …] }, …]
894
+ const stop = page.onSections((sections) => renderSidebar(sections)) // now, then after each change
895
+ page.onActiveSection((id) => markActive(id)) // as the page scrolls
896
+ page.activeSection() // "Transcript"
897
+ sidebarLink.onclick = () => page.reveal({ section: "Transcript" }, { remember: true })
898
+ ```
899
+
900
+ `sections()` lists the sections showing, in reading order (a running filter leaves out those with nothing matching), each with its group cards; custom cards aren't listed. `folded` is the section's or card's own state, which the filter's opening doesn't change. `changed` counts rows that differ from their defaults, and `preset` is the matching group preset's name, "Custom" when none matches, or `null` without presets. `onSections` calls its listener once with the current list, then after anything it reports changes (a fold, a value, a label), at most once per microtask, and returns an unsubscribe. Its second argument says what a fold came from: `"user"` (a click or key on a heading or header), `"code"` (`fold`, `foldSection`, `reveal`, a highlight's `expand`), `"search"` (the filter showing its matches; `folded` keeps the saved state) or `"restore"` (the filter cleared); it's `null` for the first call and for changes that aren't folds. `activeSection()` is the section filling most of the window below the header (after a `reveal` or a jump, that section until the user scrolls); `onActiveSection` calls its listener when it changes.
901
+
902
+ `nav: true` adds a strip of section chips to the sticky header. The chip for the section that fills most of the screen is underlined as you scroll, and each chip counts its section's changed rows. A chip glides the page to its section and opens it if it's folded (that's remembered); `⌥↑` / `⌥↓` do the same for the section before or after. Hint mode reaches the chips and the folding headings.
903
+
904
+ ```ts
905
+ const page = mountPage(tune, app, {
906
+ sectionFolded: { "Session List": true, Transcript: true, Composer: true },
907
+ foldStorage: localStorage, // folds outlive the tab
908
+ nav: true,
909
+ sectionDescriptions: { Playground: "What the phone shows, how fast it moves" },
910
+ groupSummary: (group, { preset, changed }) => (changed ? `${changed} changed` : preset),
911
+ })
912
+ page.foldSection("Composer", false)
913
+ page.highlight({ sections: ["Transcript"] }, { expand: true }) // opens Transcript until the phone moves on
914
+ ```
915
+
916
+ On a page, the keyboard spans every card:
917
+
918
+ | Key | Action |
919
+ | --- | --- |
920
+ | `↑` / `↓` or `j` / `k` | Every row and custom control, down each column and on to the next |
921
+ | `Tab` / `⇧Tab` | Next / previous group (the whole card glides into view where it fits); from the header, the first highlighted card |
922
+ | `⌥↑` / `⌥↓` | Glide to the previous / next section, opening it if it's folded; focus goes with it |
923
+ | `1`–`9` | Apply the focused group's preset, by its number (in groups without them, digits still edit numbers; `Enter` always does) |
924
+ | `Enter` / `Space` on a section heading or a foldable card's header | Fold or open it (on a section heading or group card, `←` folds and `→` opens too) |
925
+ | `/` | Filter rows by label, group or section, live; rows fold shut, cards with nothing left fold away and the rest glide into re-balanced columns; `Esc` clears |
926
+ | `s` | Hints for what's on screen, custom cards included |
927
+
928
+ Focus that lands off screen (or under the header) glides into view, retargeting from where the last glide was headed so held keys don't fall behind. The rest (`[` `]`, `r`, `g`, `⇧L`, selection, the row menu, hold `\`) works as in a panel.
929
+
930
+ Both renderers draw the page. With `?gpu`, the overlay draws the header's contents, section headings, cards and rows, clipped to below the sticky header, and follows every frame of a glide or fold; the page's background, the header's glass (a real `backdrop-filter`) and custom cards stay in the DOM under the transparent canvas, so they look the same in both. Scrolling and layout motion wake the overlay and it sleeps once the page is still. The demo is `examples/tune.html`: the OpenCode iOS tune page on a simulated iPhone that speaks the tune server's protocol.
931
+
932
+ ## Workspaces (`knobs/ui`)
933
+
934
+ `knobs/ui` has the pieces a tool's workspace is built from: resizable split panes and docks, and an outliner tree. They are plain DOM and framework-neutral, use the same tokens as panels (`--k-*`, `--k-control-h`, a `preset` such as `labPreset`), and work on their own or next to panels. A panel can mount inside a pane.
935
+
936
+ ```ts
937
+ import { labPreset } from "knobs/themes"
938
+ import { applyMove, split, tree } from "knobs/ui"
939
+ import { mountPanel } from "knobs/panel"
940
+
941
+ const look = { preset: labPreset, theme: "dark" as const }
942
+ const main = split(workspace, {
943
+ ...look,
944
+ id: "workspace", // saves sizes and collapsed panes
945
+ panes: [
946
+ { id: "outliner", size: 240, min: 180, max: 460, collapsible: true, rail: "Scene" },
947
+ { id: "view", min: 360 },
948
+ { id: "inspector", size: 300, min: 248, collapsible: true, title: "Inspector" },
949
+ ],
950
+ })
951
+ // Splits nest: a viewport over a dock that folds to its header.
952
+ const center = split(main.pane("view").body, {
953
+ ...look,
954
+ id: "center",
955
+ direction: "column",
956
+ panes: [{ id: "stage" }, { id: "dock", size: 300, collapsible: true, title: "Dock" }],
957
+ })
958
+ mountPanel(controls, main.pane("inspector").body)
959
+
960
+ let scene = [{ id: "lights", label: "Lights", children: [{ id: "sun", label: "Sun" }] }]
961
+ const outline = tree(main.pane("outliner").body, {
962
+ ...look,
963
+ items: scene,
964
+ label: "Scene",
965
+ onSelect: (ids) => pick(ids),
966
+ onMove: (move) => outline.update((scene = applyMove(scene, move))),
967
+ onRename: (id, label) => rename(id, label),
968
+ })
969
+ ```
970
+
971
+ **`split(container, options)`** lays panes along a `"row"` (default) or a `"column"`, divided by hairlines with a wider grip (`--k-ui-hit`, 9px). Panes with a `size` keep it in pixels; the rest share what's left. Dragging a divider resizes, and each pane slows softly into its `min` and `max` rather than stopping dead. Drag a collapsible pane 40px past its min and it collapses. Double-click a divider to reset it. A focused divider takes `←`/`→` (or `↑`/`↓`; `⇧` for bigger steps), `Home`/`End`, and `Enter` or `Space` to collapse. Collapsing springs the pane down to a slim rail (`--k-ui-rail`), or in a column to its header. The content keeps its open size and slides out by the divider, with no reflow, and a click on the rail opens it again. A collapse can be turned back halfway. With an `id`, the layout is saved (localStorage unless you pass `storage`; `null` saves nothing) and restored before the first paint. Panes take `title`, `count`, `actions` (icon buttons in the header), `rail` (the rail's label), `scroll` and `element` (content to move into the body). The handle has `pane(id)` (`body`, `header`, `size`, `collapsed`, `collapse()`, `resize()`, `title()`), `layout()`, `collapse(id, collapsed?)`, `resize(id, px)`, `reset(id?)` and `dispose()`.
972
+
973
+ Every change of layout, drag frames and springs included, calls `onLayout(layout)`, fires `knobs-pane-resize` on each pane element and fires `knobs-layout` on the document. The WebGPU renderer listens for `knobs-layout`, so panels in panes stay drawn in place through a drag or a collapse. A panel that a collapse clips away entirely isn't drawn at all. Panes don't create stacking contexts, so a panel's search and editors still sit above the overlay.
974
+
975
+ **`tree(container, options)`** is an outliner. Rows are virtualised, so 10,000 of them scroll at full speed with about 35 in the DOM. When a branch opens, its children slide out from under it as one block with the rows below, like a drawer, and closing folds them back. The tree takes items with `id`, `label` and `children` (or `getId`, `getLabel` and `getChildren` for your own shape; an array, even empty, makes a parent that takes drops). Options:
976
+
977
+ - `icon` and `detail` for a glyph and dimmed text, or `render` for your own row content
978
+ - `toggles` for row accessories such as `visible` and `locked`, which show at rest only in their unusual state
979
+ - `filter` for a query or a test; matching rows show with their ancestors, the match marked
980
+ - `selection`, `expanded` and `rowHeight` (`--k-row` by default)
981
+ - `typeahead`: letters jump to a matching label (on by default). `false` leaves every letter to the page, and `(key) => boolean` decides which ones start a jump, so a host's single-key shortcuts (`F` to frame, `G` for the grid) reach the window while the outliner has focus; letters typed right after a jump carry on its run
982
+
983
+ | Key | Action |
984
+ | --- | --- |
985
+ | `↑` / `↓`, `Home` / `End` | Move; type letters to jump to a label (see `typeahead`) |
986
+ | `←` / `→` | Close or go up / open or go down |
987
+ | `⇧` click or `⇧↑↓`, `⌘` click, `⌘A` | Extend, toggle, select all |
988
+ | `Space` | Select the active row (`⌘Space` toggles it) |
989
+ | `Enter` / `F2` | Rename in place (with `onRename`); otherwise `Enter` calls `onActivate` |
990
+ | `⌥↑` / `⌥↓` | Move the selection past its neighbour |
991
+
992
+ Dragging rows shows a line where they'll land, or a wash on the row they'll go into, and scrolls near the edges. Drags and `⌥↑↓` report `onMove({ ids, parent, index })`, where `index` is counted after the moved rows are taken out. Apply the move to your items (`applyMove(items, move)` does it for plain `{ id, children }` items, or `applyMove(items, access, move)` for your own shape) and call `update(items)`. Rows that stay glide to their new places. `canDrop(move)` can refuse a drop. The handle also has `select`, `expand`, `filter`, `reveal`, `focus`, `rename`, `rows()` and `refresh()`.
993
+
994
+ **Two frames.** By default the pieces wear knobs' own look, in dark and light: every leaf pane is a rounded card on the page (`--k-panel-radius`, the panel's surface and hairline rim) half a gap inside its slot, so panes sit `--k-ui-gap` (6px) apart with a small capsule grip in each gap that shows on hover and lengthens in the accent while dragged; the outliner's rows are inset washes with concentric corners; overlays are glass at the panel's radius. A preset or host with `--k-frame: flat` (`labPreset`) puts panes edge to edge on hairlines instead, square, as a dense editor's columns. A pane that holds another split is only its slot, so cards never nest. `--k-ui-gap`, `--k-ui-pane` (a card's fill), `--k-ui-row-x` (the rows' inset) and `--k-ui-divider` tune either frame.
995
+
996
+ **`restyle(target, { preset?, theme?, density? })`** changes the look of what's already on screen: every piece and panel inside `target` (the document by default) takes the preset (none for knobs' own), theme and, for panels, density. Panels' tokens glide to the new values, splits and trees measure again, readouts redraw in the new colours, and on a document, overlays opened afterwards take it too. The Lab's top bar switches between knobs' look and the Lab's this way (`?look=lab` opens on the Lab's, `?theme=light` on knobs' light).
997
+
998
+ The active row and the selection show as soft washes in the accent, with no outlines. You can tune them with `--k-ui-select`, `--k-ui-select-focus`, `--k-ui-wash`, `--k-ui-wash-hi`, `--k-ui-indent` (14px) and `--k-ui-divider` (the hairline's colour, `--k-line` by default). The model behind both pieces is exported too (`solveSplit`, `dragDivider`, `softClamp`, `parseLayout`, `flatten`, `navigate`, `select`, `typeahead`, `dropTarget`, `validMove`, `stepMove`, `virtualWindow`), for hosts that draw their own.
999
+
1000
+ React and Solid wrappers come from `knobs/ui/react` and `knobs/ui/solid`:
1001
+
1002
+ ```tsx
1003
+ import { Split, Tree } from "knobs/ui/react" // or "knobs/ui/solid"
1004
+
1005
+ <Split id="workspace" panes={panes} onReady={(split) => (handle = split)}>
1006
+ <Tree items={items} onSelect={(ids) => setPicked(ids)} onMove={(m) => setItems(applyMove(items, m))} />
1007
+ <Viewport />
1008
+ </Split>
1009
+ ```
1010
+
1011
+ `Split` puts each child in its pane, in order; the React one portals them, so context and state carry through. `Tree` updates in place when `items`, `selection` or `filter` change, and always calls the latest callbacks. The Lab demo (`examples/lab.html`, `?rows=10000` for a big scene) is a full workspace: an outliner, a viewport over a dock with a Live tab of readouts fed by a fake viewport (`?paused` stops the feed, `?sparks=20` adds sparklines), the inspector column, a `⌘K` palette, tooltips, and a confirm before deleting from the outliner.
1012
+
1013
+ ### Overlays: popovers, tooltips, the command palette, confirms and toasts
1014
+
1015
+ `knobs/ui` also has the floating layers a workspace needs. They sit in one fixed layer above the page and the GPU overlay, come in on a spring (scale and blur from where they open, interruptible both ways; opacity only under reduced motion), and use the same tokens. `overlayLook({ preset, theme })` sets the look they all take; each also takes its own `look`.
1016
+
1017
+ ```ts
1018
+ import { commandPalette, commandsFrom, confirm, overlayLook, popover, toast, tooltip, tooltips } from "knobs/ui"
1019
+
1020
+ overlayLook({ preset: labPreset, theme: "dark" })
1021
+
1022
+ const menu = popover(button, list, { placement: "bottom-end", role: "menu", label: "Games" })
1023
+ tooltip(pause, { text: "Pause the live feed", shortcut: "Alt+P" })
1024
+ tooltips(document) // and any element with data-tip="…" (data-tip-shortcut, data-tip-placement)
1025
+
1026
+ commandPalette({
1027
+ commands: () => [
1028
+ { label: "Pause live feed", section: "Live", shortcut: "Alt+P", run: () => feed.pause() },
1029
+ ...commandsFrom(controls, { title: "Weather", reveal: (key) => focusRow(key) }),
1030
+ ],
1031
+ })
1032
+
1033
+ if (await confirm({ title: "Delete “Barrier”?", confirm: "Delete", destructive: true })) remove()
1034
+ toast("Deleted Barrier", { action: { label: "Undo", run: undo } })
1035
+ ```
1036
+
1037
+ - **`popover(anchor, content, options?)`** places `content` beside an element, a rect or a point. `placement` is `top`, `bottom`, `left` or `right`, each with `-start` or `-end`; it flips when its side is short and the other has more room, then shifts to stay 8px inside the view, and follows its anchor through scrolls and resizes. It grows from the anchor's edge. It takes focus (`focus: "first" | "layer" | "none"`) and gives it back when it closes; Esc, a click outside or focus leaving closes it (`closeOnOutside: false` keeps it), and `trap: true` keeps Tab inside. The handle has `element`, `open`, `reposition()` and `close()` (a promise that resolves once it's gone).
1038
+ - **`tooltip(el, content, options?)`** shows after a 500ms pause over the element, or on keyboard focus (not a click's). While one is up, or just after, moving to a neighbour shows its tip at once, with no entrance. A press hides it until the pointer leaves; Esc hides it. `shortcut` shows as key chips (`⌘K` on Apple platforms, `Ctrl+K` elsewhere) and sets `aria-keyshortcuts`; the target gets `aria-describedby` while it shows. Content can be a function, read each time it shows. Split panes use these for their buttons and rails.
1039
+ - **`commandPalette({ commands, onRun?, hotkey?, recents?, placeholder? })`** opens on `⌘K` (`hotkey`, `"Mod+K"` by default; `false` for none). With no query it shows recent commands (remembered in localStorage under `recents`; `false` for none), then each `section`. Typing ranks commands by a fuzzy match: letters in order, best at the start, at word starts and in runs. The matched letters light up in the accent while the rest of the label dims, and letters strewn thinly across words don't count. A command answers to its `keywords` and section too. `↑`/`↓` move a highlight that glides (`PgUp`/`PgDn`, `⌘↑`/`⌘↓`), disabled commands are passed over, `Enter` runs, `Esc` or a click outside closes. A `run` that returns a string (or a promise of one) shows it as a toast; a throw shows as an error toast. The handle has `open(query?)`, `close()`, `toggle()`, `isOpen` and `dispose()`.
1040
+ - **`commandsFrom(controls, { title?, fields?, reveal? })`** lists a store as commands: its actions and toolbar buttons (with their shortcuts), presets and group presets (the current one marked), toggles ("Turn on …"), one command per choice option, "Reset …" for modified fields, and, with `reveal(key)`, every other field (its current value as the hint) to go to.
1041
+ - **`confirm({ title, body?, confirm?, cancel?, destructive? })`** returns `Promise<boolean>`. It's modal over a scrim, keeps Tab inside, and Esc or a click outside cancels. `destructive` puts the button in `--k-critical` and focus on Cancel; otherwise focus starts on the confirming button.
1042
+ - **`toast(message, { tone?, linger?, action?, within? })`** shows a pill at the bottom centre for 3.2s (6s for `tone: "error"`). A new message replaces the one showing, and hovering keeps it. Pages show their status (Copied, errors) this way.
1043
+ - **`menu(anchor, items, options?)`** opens a menu beside a button, a rect or a point. Items take `label`, `icon`, `shortcut` (chips only; it binds nothing), `hint`, `disabled`, `checked` (with `radio`, a dot), `destructive` (in `--k-critical`) and `items` for a submenu (or a function read when it opens); `"-"` is a separator and `null` or `false` leaves an item out. It grows from where it was asked for on a snappy spring and softens out shorter, and either can be turned back halfway. A submenu opens on a short hover and stays open while the pointer heads for it (a safe triangle). Keys: `↑`/`↓`, `Home`/`End`, a letter, `Enter` or `Space`, `→` and `←` for submenus, `Esc`. Each item's `run` and `onSelect(item)` are called; `onClose(reason)` says why it went. The handle has `element`, `open` and `close()`.
1044
+ - **`contextMenu(target, items, options?)`** opens one at the pointer on a right-click, or at the focused element on the menu key or `⇧F10`. `items` may be a function of the event, read each time (`null` leaves the browser's own menu). A right-click while one is open moves it. The handle has `open(at?)`, `current` and `dispose()`.
1045
+
1046
+ The pure parts are exported for hosts that draw their own: `place({ anchor, size, view, placement })`, `fuzzy(query, text)` (score and matched indices), `rank(items, query)` and `marks(text, indices)`.
1047
+
1048
+ ### Timeline
1049
+
1050
+ ```ts
1051
+ import { timeline } from "knobs/ui"
1052
+
1053
+ const clock = timeline(dock, {
1054
+ ...look,
1055
+ duration: 12, // seconds
1056
+ fps: 30,
1057
+ markers: [{ id: "boom", time: 4, label: "Boom" }],
1058
+ onSeek: (time, source) => scene.seek(time),
1059
+ onCross: (marker) => marker.id === "boom" && retrigger(),
1060
+ })
1061
+ ```
1062
+
1063
+ **`timeline(container, options)`** is a scrubber for time-based work. The ruler counts in seconds or frames (`units`) with ticks that thin out as it zooms (`⌘` wheel, a pinch, `+`/`-`, `0` fits) and pans with the wheel or a trackpad. The playhead follows the hand 1:1, pulls softly toward markers and lands on frames (`snap`). Markers can be added (`M`), dragged, renamed (`Enter`) and removed, and `[`/`]` jump between them; `I` and `O` set a loop range. `Space` plays on one rAF clock that sleeps when nothing moves, and `←`/`→` step a frame (`⇧` ten). A right-click on the ruler, a marker or a range opens a context menu. It reports `onSeek(time, source)` (`"scrub" | "key" | "marker" | "play" | "code"`), `onPlay`, `onMarkers`, `onRanges` and `onCross(marker)`. The handle has `time`, `playing`, `markers`, `ranges`, `view`, `seek`, `play`, `pause`, `toggle`, `update`, `fit`, `zoom`, `focus` and `dispose`. `Timeline` in `knobs/ui/react` and `knobs/ui/solid` follows its props. The Lab's scene clock and Boom retrigger run from one under the viewport.
1064
+
1065
+ ### Ideas grid (bento)
1066
+
1067
+ ```ts
1068
+ import { bento } from "knobs/ui"
1069
+
1070
+ const ideas = bento(container, {
1071
+ items: [
1072
+ { id: "a", src: "a.jpg", srcset: "a-640.jpg 640w, a.jpg 1600w", label: "Light table" },
1073
+ { id: "b", aspect: 4 / 3, render: (el) => mountPrototype(el), label: "Chip → panel" },
1074
+ ],
1075
+ onPick: (ids) => save(ids),
1076
+ })
1077
+ ```
1078
+
1079
+ **`bento(container, options)`** lays out ideas (images, live canvases, prototypes) for someone to look at and pick from. There's no chrome: tiles sit on the page with hairline gutters (`gap`, 2px), no cards, borders or shadows, and a label shows as type on hover. Items take `id`, `src` (with `srcset` and `alt`) or `render(el)` (return a cleanup), `aspect` (width / height; images without one take their natural size, read from the smallest `srcset` candidate before the first paint), `label`, `group` and `hero`.
1080
+
1081
+ The layout shows as much as it can without cropping. Up to 24 items lay out on one screen when every tile stays at least a sixth of the view tall: as justified rows, at the row count that covers the most, or with a hero (one tile large beside or above the rest) when an item asks for it or the first item as a hero shows 4% more. Larger sets, groups and narrow views scroll in justified rows near a third of the view tall (`rowHeight`). A row only looks at the items from its own start, so adding or removing an item never moves the rows before it. Tiles keep their own shape, except that the last row (or a block a little narrower than the view) may crop by up to `tolerance` (8%) to fill the width. Tiles that change place glide there on the calm spring (.4 s, no bounce), new ones come in, and removed ones soften out (scale .96, 3px blur) while the rest close the gap. Only the tiles near the view are in the page (about 30 of 500), and the frame loop sleeps when nothing moves.
1082
+
1083
+ Click or `Enter` zooms a tile to fill the view, aspect-fit and centred: one continuous spring (.45 s, no bounce; .38 s back) from its place in the grid, while the grid fades under it. The tile blurs with its speed, up to `blur` (2.5px), and is sharp at rest. `Esc`, a click, `Enter` or a pinch out returns it to its exact place, scrolling the grid under it first if you moved on. While zoomed, `←`/`→` or a two-finger swipe slide to the neighbour (.3 s) and a zoomed prototype takes the pointer. `⌘`-scroll or a pinch zooms by hand: the tile follows the fingers 1:1 and settles in or out by where and how fast you let go. Under reduced motion, everything fades instead of travelling.
1084
+
1085
+ | Key | Action |
1086
+ | --- | --- |
1087
+ | `←` `→` `↑` `↓` | Move the focus wash by geometry (zoomed: `←`/`→` go to the neighbour) |
1088
+ | `Enter` / `Esc` | Zoom in / out |
1089
+ | `Space`, `⇧`/`⌘` click | Pick or unpick (a dot in the accent); `onPick(ids)` |
1090
+ | `1`–`9`, `Home` / `End` | Jump to an item |
1091
+
1092
+ The handle has `update(items)`, `zoom(id | null)`, `zoomed`, `active`, `focus(id?)`, `picks`, `pick(ids)`, `plan`, `moving`, `stats()` (the frame loop's script time) and `dispose()`; `onZoom` and `onActive` report changes. It takes the pieces' tokens and `restyle`, in dark and light; tiles round at `--k-ui-bento-radius` (3px), square under `--k-frame: flat`; `--k-ui-bento-bg` and `--k-ui-scrim` set the page under the tiles and the zoom. `planBento(aspects, { width, height, … })` is the layout on its own, with `bentoNeighbour`, `bentoWindow` and `fitRect`. `Bento` in `knobs/ui/react` and `knobs/ui/solid` follows `items` and `picks`. The demo is `examples/ideas.html` (`?n=500` for a big set, `?groups`, `?hero=3`, `?perf` for a frame-time readout, `?bench` to scroll and zoom on its own and report).
1093
+
1094
+ ## Theming, Density & WebGPU Renderer
1095
+
1096
+ Panels support `theme: "dark" | "light" | "auto"`, `density: "compact" | "default" | "comfortable"`, and `renderer: "gpu" | "dom" | "auto"` (or `?gpu` in the URL), configured on `defineControls`, `mountPanel`, or `<KnobsPanel />`. The header title defaults to the controls id, written as words (`"synth-voice"` reads "Synth voice"); pass `title` to override it or `title: false` to hide it.
1097
+
1098
+ When `renderer: "gpu"` (or `?gpu`) is active, all floating and inline panels share a single page-level WebGPU overlay canvas and `GPUDevice` with 480 Hz spring physics, velocity-driven directional motion blur, native-rasterized glyph atlas text, and per-column rolling digit smear, while keeping an invisible semantic DOM mirror for keyboard navigation and screen readers. The DOM renderer's transitions run on the same springs (sampled into CSS `linear()` easings), so both renderers move alike. The canvas can't sample the page, so each glass panel gets a `backdrop-filter` element just under the canvas; a panel lying over another panel firms up instead. When WebGPU is unavailable, panels fall back to the DOM/CSS renderer automatically.
1099
+
1100
+ The overlay is one canvas at the top of the page, so a panel's DOM overlays (search, inline editors) can only stay above it if they aren't trapped in a stacking context. An inline panel inside an ancestor that creates one (a `transform`, `filter`, `opacity` below 1, a `z-index` on a positioned or flex item, `contain`, `isolation`, …) therefore draws with the DOM renderer, which looks the same, and is marked `data-gpu-trapped`. To keep such a panel on the GPU, take the stacking context off its ancestors. `knobs/ui` panes don't create any.
1101
+
1102
+ Override CSS variables on `:root` or `.knobs-panel`:
1103
+
1104
+ ```css
1105
+ :root {
1106
+ --k-bg: #1c1c1e;
1107
+ --k-surface: rgba(255, 255, 255, 0.055);
1108
+ --k-surface-hi: rgba(255, 255, 255, 0.11);
1109
+ --k-line: rgba(255, 255, 255, 0.1);
1110
+ --k-text: rgba(255, 255, 255, 0.92);
1111
+ --k-label: rgba(255, 255, 255, 0.64);
1112
+ --k-accent: #fff;
1113
+ --k-modified: #ff9f0a;
1114
+ --k-width: 288px;
1115
+ --k-pad: 10px;
1116
+ --k-inset: 10px;
1117
+ --k-row: 32px;
1118
+ --k-gap: 4px;
1119
+ --k-radius: 8px;
1120
+ --k-panel-radius: 12px;
1121
+ --k-font-size: 12px;
1122
+ --k-label-weight: 500;
1123
+ --k-blur: 1; /* motion strength: readout blur and pill stretch */
1124
+ --k-motion-blur: 0; /* up to 1: moving fill edges and pills smear by their speed */
1125
+ --k-range-blur: .5; /* up to 1: a range band's gliding ends smear by their speed */
1126
+ --k-step-resist: 0.25; /* a stepped slider's pull off its stop (0 snaps, 1 follows) */
1127
+ --k-glow: 0.5;
1128
+ --k-roll: 420; /* readout roll (ms) */
1129
+ --k-drag-roll: 0; /* roll while dragging (ms; 0 snaps) */
1130
+ --k-cascade: 12; /* preset ripple between rows (ms; 0 moves them together) */
1131
+ --k-glass: 0.6; /* 0 is opaque; up to 1 thins the panel over a blurred backdrop */
1132
+ --k-glass-tone: 0.5; /* backdrop brightness under full glass (light theme: 1.08) */
1133
+ --k-spring: 0.24 0.16; /* duration and bounce values settle on (GPU); pair with: */
1134
+ --k-fill-ease: linear(...); /* springCss(value).easing, for CSS transitions */
1135
+ --k-fill-ms: 300; /* springCss(value).duration */
1136
+ --k-reveal-ease: cubic-bezier(.2, .8, .2, 1); /* how editors and messages open */
1137
+ --k-rim: rgba(255, 255, 255, 0.09);
1138
+ }
1139
+ ```
1140
+
1141
+ Changing a token glides: colours, lengths, weights, glass and glow move on the panel spring (`--k-fill-ease` over `--k-fill-ms`), so a theme switch or a preset of tokens animates in both renderers. While a control is dragged they follow it step for step.
1142
+
1143
+ A floating panel opens out of its chip like liquid glass. The press squeezes the cog (to 0.94, easing in over 60 ms); then each edge of the glass rides its own spring from the chip to the panel. The edges facing the way it travels lead on a stiff spring (as stiff as their axis carries the travel) and the others trail on a softer one, so the glass stretches toward the panel, overshoots a little and settles in under 300 ms, its corners going from the cog's circle to the panel's radius. The content follows a beat behind: blurred 8 px, faded and at 97%, revealed top first, landing exactly crisp. Closing runs it backwards and faster: the content blurs out first, then the glass pulls into the cog with its far edges trailing (about 200 ms). Esc, the close button or the chip itself folds it back; toggling mid-way reverses from wherever the shape is, keeping its velocity. The WebGPU overlay blurs, fades and scales the content in its shader, so both renderers match. A reload brings an open panel back in place, with no morph.
1144
+
1145
+ Reduced motion (`prefers-reduced-motion`) turns off token glides, the cascade, the pop, edge blur, haptics, the spring dot, the editors' height reveals and the chip's squeeze, and panels simply fade in and out, with no stretch or blur.
1146
+
1147
+ ### Reacting to Random, Reset and presets
1148
+
1149
+ `onChange` says where each change came from, so a page can answer the panel's own commands, such as replaying an animation when Random rolls:
1150
+
1151
+ ```ts
1152
+ const die = defineControls("die", schema, {
1153
+ onChange(patch, values, { source }) {
1154
+ // source: "local" | "randomize" | "mutate" | "reset" | "preset" | "variant" | "undo" | "redo"
1155
+ // | "remote" | "compare" (holding \ previews earlier values, and their return)
1156
+ if (source === "randomize") rollDice()
1157
+ },
1158
+ })
1159
+ ```
1160
+
1161
+ `patch` holds only the fields that changed. The die page (`examples/die.ts`) rolls its demo dice this way.
1162
+
1163
+ ## Explore in development. Ship constants.
1164
+
1165
+ ```ts
1166
+ splat.locked // frozen schema defaults
1167
+ splat.get() // live while enabled; the exact locked object otherwise
1168
+ ```
1169
+
1170
+ The package has two core exports selected through the standard `development` condition. Vite selects it automatically in dev. The default export is production-safe: **disabled unconditionally, even if `enabled: true` is passed**. `attach()` and `mount()` are no-ops, and their panel imports are erased from that entry.
1171
+
1172
+ ## API Summary
1173
+
1174
+ ### Fields & Descriptors
1175
+
1176
+ | Function | Behavior |
1177
+ | --- | --- |
1178
+ | `knob(value, { min, max, step?, unit?, shortcut?, label?, group?, groupLabel?, section?, sectionLabel? })` | Numeric slider control with rolling readout and inline editor. |
1179
+ | `choice(value, ["a", "b"], { label?, group?, shortcut? }?)` | Nonempty string choices with literal union inference. |
1180
+ | `toggle(value, { label?, group?, shortcut? }?)` | Boolean switch control. |
1181
+ | `spring({ duration?, bounce? }?, meta?)` | SwiftUI-style spring: two sliders beside its live curve. |
1182
+ | `color("#hex" \| "oklch(L C H)" \| { l, c, h }, meta?)` | OKLCH colour kept inside sRGB, as an `oklch()` string; swatch, hex and hue/chroma/lightness strips. |
1183
+ | `point({ x, y }, { min, max, step?, ...meta })` | 2D pad; bounds and step take a number or `{ x, y }`. |
1184
+ | `easing([x1, y1, x2, y2] \| name, meta?)` | Cubic bezier with a mini curve, a handle editor and named curves. |
1185
+ | `gradient([{ at, color }], { space?, presets?, ...meta })` | A colour ramp of 2–32 stops, blended in `oklab` (default) or `srgb`; a strip of draggable stops with the colour editor for the selected one. |
1186
+ | `curve([{ x, y }] \| shape, { range?, interp?, shapes?, presets?, ...meta })` | A 1D curve of 2–64 points (ends pinned to x 0 and 1), `smooth` (monotone cubic), `linear` or `step`; a graph of draggable points with shape chips. |
1187
+ | `sampleGradient(stops, t, { space?, as? })` / `gradientCss(stops, { space?, direction? })` | A gradient's colour at `t` (`oklch()`, or `as: "hex" \| "rgb" \| "linear"`); a CSS `linear-gradient()`. |
1188
+ | `sampleCurve(points, x, { interp? })` / `CURVE_SHAPES` | A curve's y at `x`; the built-in shapes (`linear`, `ease-out`, `inverse²`, `smoothstep`, `bell`) over 0–1. |
1189
+ | `vec2({ x, y }, { min, max, step?, link?, linked?, axes? })` / `vec3({ x, y, z }, …)` | Inline number cells, optionally linked for uniform scaling; `axes: ["x", "z"]` names them. |
1190
+ | `range([low, high], { min, max, step?, unit? })` | One slider with two thumbs. |
1191
+ | `angle(degrees, { min?, max?, step?, wrap? })` | A dial, 0° up and clockwise; 0–360 wrapping by default. |
1192
+ | `seed(value, { min?, max? })` / `keybind(combo?)` | A whole number with a die; a captured key combination. Both stay out of Random and Mutate by default. |
1193
+ | `toolbar([{ id?, label, icon?, shortcut?, global?, disabled?, run? }])` | A row of buttons with shortcut chips; presses call `onAction(id)`. `shortcut` is a key, or `{ key, scope: "page" \| "panel" \| "none" }`; `toolbarShortcut(item)` reads either. |
1194
+ | `watch` / `sparkline` / `graph` / `meter` / `table` / `log` (`{ label?, unit?, decimals?, format?, hz?, get?, … }`) | Readout rows that hold no value; push with `controls.watch(key, value)`. See [Readouts](#readouts-live-values-sparklines-graphs-meters-tables-and-logs). |
1195
+ | `springCss(spring)` / `springMotion(spring)` / `bezierCss(curve)` / `colorHex(color)` | Turn values into CSS, ω/ζ, or hex. |
1196
+ | `fromDescriptors(descriptors)` | Converts runtime `ControlDescriptor[]` into a `Schema`. |
1197
+ | `createRemoteControls(id, descriptors, options)` | Creates a remote-synced `Controls` store with `.swift(group?)` codegen and `.update(descriptors, values, { land })`; incoming values skip keys in hand or changed here in the last 600 ms, and the newest lands when the hold ends. |
1198
+ | `fromSettings(groups, home?)` | A hosted prototype's settings sections as descriptors and values. |
1199
+ | `action(label, { run })` / `flatSpring({ duration, bounce }, meta)` | A button row; two flat number fields drawn as one spring card. |
1200
+ | Field meta `random?`, `disabled?`, `readOnly?`, `folded?`, `tab?`, `subsection?` | On any field or descriptor: `random: false` stays out of Random and Mutate; `disabled` / `readOnly` (boolean or `(values) => boolean`) gate the row; `folded` makes its group foldable, starting folded; `tab` puts the row on a tab; `subsection` puts it under a sub-heading in its group. |
1201
+ | Field meta `groupDescription?: string`, `sectionDescription?: string`, `sectionFolded?: boolean` | On any field or descriptor, for pages (the first field of a group or section that gives one wins): a muted line under the card's title or the section's heading; `sectionFolded` makes the section foldable, starting folded. |
1202
+ | `mountPanel(controls, container, options?)` (`knobs/panel`) | An inline panel. Beside theme, density, renderer and keys: `preset`, `header` (`false` or `{ title?, buttons? }`), `session`, `groupFolded`, `subsectionFolded`, `tabs`, `tab`, `onTab`, `search`, `groupRandom(group, mode)`, `rowAccessory(key)`, `marks`, `haptics`. The handle adds `panel.fold(group, folded?)`, `panel.tab(id?)`, `panel.search(query?, chips?)` (returns `{ query, chips }`) and `panel.updateAccessories()`. |
1203
+ | `mountPage(controls, container, options?)` (`knobs/panel`) | A full page of group cards in sections under a sticky header; `page.card({ title?, section?, element?, collapsed?, summary?, description? })` adds custom cards (`card.collapse(collapsed?)`, `card.collapsed`, `card.summary(content \| null)`), and `page.highlight({ groups?, sections? } \| null, { reveal?, expand? })` marks what's on screen. Options take `groupLabels`, `sectionLabels`, `groupDescriptions`, `sectionDescriptions`, `presetLabels`, `defaultLabel`, `groupPresetDisplay` and `groupFolded` over the store's. |
1204
+ | `MountPageOptions` folds | `sectionFolded?: SectionFolded` (`boolean \| Record<string, boolean>`), `foldStorage?: StoragePort \| null`, `nav?: boolean`, `groupSummary?(group, info: FoldSummaryInfo): string \| Node \| null`, `sectionSummary?(section, info: FoldSummaryInfo): string \| Node \| null`; `FoldSummaryInfo` is `{ preset: string \| null, changed: number, keys: readonly string[] }`. |
1205
+ | `page.fold(group: string, folded?: boolean, options?: PageFoldOptions): void` / `page.foldSection(section: string, folded?: boolean, options?: PageFoldOptions): void` / `page.sectionFolded(section: string): boolean` | Fold a group's card or a section or open it (toggles without `folded`), and ask which a section is. `PageFoldOptions` is `{ remember?: boolean, reveal?: boolean }`; `remember` defaults to `true`. |
1206
+ | `page.reveal(target: { section: string } \| { group: string }, options?: { remember?: boolean }): void` | Open a section (or a group's card and its section) and glide it into view; `remember` defaults to `false`. |
1207
+ | `page.sections(): readonly PageSectionInfo[]` / `page.onSections(listener: (sections: readonly PageSectionInfo[]) => void): () => void` | `PageSectionInfo` is `{ id, label, description?, folded, changed, groups: PageGroupInfo[] }`; `PageGroupInfo` is `{ id, label, folded, changed, preset: string \| null }`. |
1208
+ | `page.activeSection(): string \| undefined` / `page.onActiveSection(listener: (section: string \| undefined) => void): () => void` | The section in view as the page scrolls. |
1209
+
1210
+ ### Store (`Controls<S>`)
1211
+
1212
+ - `controls.get()` / `controls.getServerSnapshot()` / `controls.locked`
1213
+ - `controls.set(patch, { coalesce? })`
1214
+ - `controls.sync(values, { replace?, land?, mixed? })` — apply external state without recording undo or emitting a local change; held keys keep theirs
1215
+ - Options `random`, `tunable(key, values)`, `history` (a `HostHistory`), `persist` (`"all" | "variants" | "none"`) and `onGesture({ key, phase: "start" | "end" })`; see [Embedding options](#embedding-options)
1216
+ - `controls.refresh()` — re-check function `disabled` / `readOnly` flags and row accessories without new values
1217
+ - `controls.canRoll(key)` / `controls.isDisabled(key)` / `controls.isReadOnly(key)` / `controls.persist`
1218
+ - `controls.setMixed(keys, on?)` / `controls.isMixed(key)`
1219
+ - `controls.hold(key, on?)` / `controls.isHeld(key)` / `controls.landing`
1220
+ - `controls.action(key, item?)` — press an action row, or a toolbar's item
1221
+ - `controls.watch(key, value)` / `controls.watched(key)` — push to a readout row (`null` clears it); the latest value
1222
+ - `controls.reset(keys?)` / `controls.resetKey(key)` / `controls.resetGroup(group)`
1223
+ - `controls.isModified(key?)` / `controls.modified()`
1224
+ - `controls.undo()` / `controls.redo()` / `controls.canUndo` / `controls.canRedo`
1225
+ - `controls.randomize(keys?)` / `controls.mutate(keys?)`: skip locked fields; one undo step each
1226
+ - `controls.lock(keys, locked = true)` / `controls.isLocked(key)`: locks are saved with the values (remote controls keep them in memory)
1227
+ - `controls.compare(hold)` / `controls.comparing`: while held, `get()` returns the previous undo entry; no history, no storage
1228
+ - `controls.preset(id)` / `controls.matchingPreset()`
1229
+ - `controls.groupPresets` / `controls.groupPreset(group, index, { coalesce? })` / `controls.matchingGroupPreset(group)` / `controls.groupPresetDisplay`
1230
+ - `controls.groupLabel(id)` / `controls.sectionLabel(id)` / `controls.presetLabel(id)`: display labels (`groupLabels`, `sectionLabels`, `presetLabels`, `defaultLabel`, or the fields' `groupLabel` / `sectionLabel`)
1231
+ - `controls.groupDescription(group): string | undefined` / `controls.sectionDescription(section): string | undefined`: `groupDescriptions` / `sectionDescriptions`, else the fields' `groupDescription` / `sectionDescription`
1232
+ - `controls.groupFolded` / `controls.sectionFolded`: how groups and a page's sections start, as given to `defineControls` or `createRemoteControls` (`groupFolded?: Record<string, boolean>`, `sectionFolded?: SectionFolded`); mounts read them under their own
1233
+ - `controls.code("ts" | "json" | "jsx", values?)`
1234
+ - `controls.variants.list()` / `.save(name?)` / `.load(id)` / `.remove(id)` / `.export()` / `.import(json)`
1235
+ - `controls.attach(element, chip?)` — floating chip + anchored panel; `chip` (`{ label?, reveal?, shortcut? }`) overrides the store's `chip` option
1236
+ - `controls.mount(container, options?)` — inline panel
1237
+
1238
+ ### Workspace pieces (`knobs/ui`)
1239
+
1240
+ | Function | Behavior |
1241
+ | --- | --- |
1242
+ | `split(container, { panes, direction?, id?, storage?, onLayout?, preset?, theme? })` | Resizable panes and docks that collapse to rails or headers; layouts saved by `id`. |
1243
+ | `tree(container, { items, onSelect?, onMove?, onRename?, toggles?, filter?, typeahead?, … })` | A virtualised outliner with keyboard moves, drag reorder and rename; `typeahead: false \| (key) => boolean` leaves letters to the page. |
1244
+ | `readout(field, { preset?, theme?, label? })` | A readout on its own: `{ element, push(value), value, dispose() }`. |
1245
+ | `popover(anchor, content, { placement?, offset?, role?, label?, focus?, trap?, closeOnOutside?, onClose? })` | A floating layer that flips and shifts; `{ element, open, reposition(), close() }`. |
1246
+ | `tooltip(el, text \| { text, shortcut?, placement?, delay? })` / `tooltips(doc)` | Delayed tooltips with instant hand-off and shortcut chips; `data-tip` attributes. |
1247
+ | `commandPalette({ commands, onRun?, hotkey?, recents?, placeholder? })` / `commandsFrom(controls, options?)` | `⌘K` fuzzy command search; a store's actions, presets and fields as commands. |
1248
+ | `confirm({ title, body?, confirm?, cancel?, destructive? })` | `Promise<boolean>`, modal, focus kept inside. |
1249
+ | `toast(message, { tone?, linger?, action?, within? })` | A status pill at the bottom centre. |
1250
+ | `menu(anchor, items, { placement?, label?, onSelect?, onClose? })` / `contextMenu(target, items, options?)` | Menus with icons, shortcut chips, checks, radios and submenus; a right-click or menu-key menu. |
1251
+ | `timeline(container, { duration, time?, fps?, units?, markers?, ranges?, snap?, onSeek?, … })` | A zoomable ruler and playhead with markers, loop ranges and playback. |
1252
+ | `bento(container, { items, gap?, tolerance?, rowHeight?, fit?, blur?, picks?, onPick?, onZoom?, onActive? })` | An ideas grid: a bento of images and live content that zooms an item to fill the view; picks with Space. |
1253
+ | `planBento(aspects, { width, height, gap?, tolerance?, rowHeight?, groups?, hero? })` | The bento's layout on its own: rects, mode, coverage and crop. |
1254
+ | `overlayLook({ preset?, theme? })` | The look every overlay takes. |
1255
+ | `hints.register(root, () => targets, { listen?, rank? })` / `data-hint` | Joins the page's hint mode (`s`); `hints.start()`, `hints.stop()`. |
1256
+ | `restyle(target, { preset?, theme?, density? })` | Re-themes the pieces and panels already on screen, live; on a document, later overlays too. |
1257
+ | `place` / `fuzzy` / `rank` / `marks` | The overlays' pure model. |
1258
+
1259
+ ## Develop
1260
+
1261
+ ```sh
1262
+ bun install
1263
+ bun run build
1264
+ bun run typecheck
1265
+ bun run test # Vitest unit suite
1266
+ bun run example # Vite dev server with live HMR against src/
1267
+ bun run test:browser # Production leak check + Playwright browser suite
1268
+ bun run format
1269
+ ```
1270
+
1271
+ MIT © 2026 Kit Langton.