@kitlangton/knobs 0.2.0 → 0.4.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -19
- package/dist/dev/{chunk-gezhc24g.js → chunk-7xgkerd2.js} +14 -5
- package/dist/dev/{chunk-693jd1gj.js → chunk-pmkvb9ep.js} +1544 -160
- package/dist/dev/{chunk-5cr85vv6.js → chunk-stbcgz1y.js} +7924 -7931
- package/dist/dev/{chunk-4km87zfx.js → chunk-t68smqkc.js} +18 -7
- package/dist/dev/index.js +187 -31
- package/dist/index.js +197 -33
- package/dist/panel.js +20342 -18461
- package/dist/types/core/keys.d.ts +2 -0
- package/dist/types/core/remote.d.ts +2 -0
- package/dist/types/core/schema.d.ts +5 -0
- package/dist/types/core/store.d.ts +29 -2
- package/dist/types/core/unlock.d.ts +77 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/panel/attach.d.ts +2 -0
- package/dist/types/panel/crumbs.d.ts +19 -0
- package/dist/types/panel/editors.d.ts +9 -0
- package/dist/types/panel/feel.d.ts +103 -0
- package/dist/types/panel/give.d.ts +34 -1
- package/dist/types/panel/gpu/color.d.ts +4 -4
- package/dist/types/panel/gpu/renderer.d.ts +32 -0
- package/dist/types/panel/gpu/shader.wgsl.d.ts +1 -1
- package/dist/types/panel/index.d.ts +2 -1
- package/dist/types/panel/inspector.d.ts +1 -1
- package/dist/types/panel/lens.d.ts +113 -0
- package/dist/types/panel/motion.d.ts +2 -0
- package/dist/types/panel/page-hints.d.ts +2 -1
- package/dist/types/panel/prefs.d.ts +2 -2
- package/dist/types/panel/presence.d.ts +20 -0
- package/dist/types/panel/scope.d.ts +29 -0
- package/dist/types/panel/slider.d.ts +21 -4
- package/dist/types/panel/tab-transition.d.ts +15 -0
- package/dist/types/panel/tabs.d.ts +1 -1
- package/dist/types/panel/toggle.d.ts +27 -0
- package/dist/types/ui/compare-css.d.ts +1 -0
- package/dist/types/ui/compare-frames.d.ts +41 -0
- package/dist/types/ui/compare-gpu.d.ts +58 -0
- package/dist/types/ui/compare-model.d.ts +169 -0
- package/dist/types/ui/compare-mp4.d.ts +29 -0
- package/dist/types/ui/compare-strip.d.ts +28 -0
- package/dist/types/ui/compare.d.ts +103 -0
- package/dist/types/ui/diff-css.d.ts +1 -0
- package/dist/types/ui/diff-model.d.ts +128 -0
- package/dist/types/ui/diff-scroll.d.ts +29 -0
- package/dist/types/ui/diff.d.ts +158 -0
- package/dist/types/ui/index.d.ts +9 -1
- package/dist/types/ui/look.d.ts +1 -1
- package/dist/types/ui/react.d.ts +66 -0
- package/dist/types/ui/scrollbar.d.ts +12 -0
- package/dist/types/ui/solid.d.ts +52 -0
- package/dist/types/ui/split-model.d.ts +4 -3
- package/dist/types/ui/split.d.ts +8 -6
- package/dist/types/ui/timeline.d.ts +35 -2
- package/dist/types/ui/tree.d.ts +7 -2
- package/dist/types/ui/walkthrough-css.d.ts +1 -0
- package/dist/types/ui/walkthrough-media.d.ts +29 -0
- package/dist/types/ui/walkthrough-model.d.ts +447 -0
- package/dist/types/ui/walkthrough.d.ts +63 -0
- package/dist/ui-react.js +16486 -8140
- package/dist/ui-solid.js +16030 -7775
- package/dist/ui.js +17510 -9506
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@ Embeddable, keyboard-first tuning controls for React, Solid, and plain DOM.
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
Each panel's header carries **Undo · Redo · Reset · Copy · Random** as icon buttons, quiet until the pointer or keyboard is in the panel. Random holds the corner with Copy beside it; they're always there. Undo shows while there's history, Redo while there's something to redo and Reset while it has somewhere to go (back to the active preset, else the defaults), each in a slot of its own that waits empty meanwhile, so they bloom in and out in place and nothing else in the header ever moves. Copy turns into a check for a moment once it has copied, and Reset's ↺ turns once each time it resets before it leaves (a press mid-turn adds another; with reduced motion it blinks instead). Hover washes come in at once and fade out. A button flashes when its command runs, from a click or its key alike (`u`, `⇧U`, `⇧R`, `⇧C`, `R`, `⌥R`, and a group card's `R` / `⇧R` on that group's die and reset); a click presses a header button without leaving focus on it. The header has no `?` button: `?` opens the shortcuts, as the title's tooltip says (`header: { buttons: [..., "help"] }` brings the button back, past Random with the close X). 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 in its header's corner, with a reset icon beside it that shows (in its own slot, as the header's does) while the group differs from its preset or defaults. **Lock** a row (
|
|
7
|
+
Each panel's header carries **Undo · Redo · Reset · Copy · Random** as icon buttons, quiet until the pointer or keyboard is in the panel. Random holds the corner with Copy beside it; they're always there. Undo shows while there's history, Redo while there's something to redo and Reset while it has somewhere to go (back to the active preset, else the defaults), each in a slot of its own that waits empty meanwhile, so they bloom in and out in place and nothing else in the header ever moves. Copy turns into a check for a moment once it has copied, and Reset's ↺ turns once each time it resets before it leaves (a press mid-turn adds another; with reduced motion it blinks instead). Hover washes come in at once and fade out. A button flashes when its command runs, from a click or its key alike (`u`, `⇧U`, `⇧R`, `⇧C`, `R`, `⌥R`, and a group card's `R` / `⇧R` on that group's die and reset); a click presses a header button without leaving focus on it. The header has no `?` button: `?` opens the shortcuts, as the title's tooltip says (`header: { buttons: [..., "help"] }` brings the button back, past Random with the close X). 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 in its header's corner, with a reset icon beside it that shows (in its own slot, as the header's does) while the group differs from its preset or defaults. **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 and lets you type its name, `F2`, `Enter` or a double-click renames a saved one, `⌫` on the strip deletes one, and the `?` popover exports/imports them as JSON. The preset you applied last is the **base**: Reset goes back to it, and Random explores around it (see [Presets](#presets-names-and-the-base)).
|
|
8
8
|
|
|
9
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
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
|
|
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, detents that hold a drag for a few px at the default (a faint notch rising from the bottom edge), the active preset's value (one hanging from the top) and the middle (and zero), letting go once you push through, stepped sliders (ten steps or fewer) that resist around their stop and snap to the next past halfway, precision by distance (move the pointer up or down off the row while dragging and it slows to ½, ¼ and ⅛ in smooth steps, a fine ruler showing near the handle; come back and it speeds up without the value jumping), `⌥`-drag for 1/10 speed on top of that, a row that stretches like rubber past either end (its far end anchored, a little thinner as it lengthens, its text still) and springs back on release, 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 drag or 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
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -338,9 +338,9 @@ Panels and switchers are keyboard-first:
|
|
|
338
338
|
| Key | Action |
|
|
339
339
|
| --- | --- |
|
|
340
340
|
| `↑` / `↓` or `j` / `k` | Move between presets, rows and custom actions (`←`/`→` move along the custom actions); a spring's two sliders are two stops |
|
|
341
|
-
| `←` / `→`
|
|
341
|
+
| `←` / `→` | 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 |
|
|
342
342
|
| `Enter` / `Space` | Open a colour, point, easing, gradient or curve row's editor; `↓` then walks into it (strips, chips, handles, pad) |
|
|
343
|
-
| `Esc` | Climb one level: fold an open editor back to its row, then from a row to its card, from a card to the panel, then out (a floating panel closes). Inline edits, the search field and the tree's type-ahead take `Esc` first |
|
|
343
|
+
| `Esc` | Climb one level: fold an open editor back to its row, then from a row to its card, from a card to the panel, then out (a floating panel closes). The focus lens grows out to each level as you go, and the header's `esc ↑ Shape` says where the next press goes (see [Where keys go](#where-keys-go)). Inline edits, the search field and the tree's type-ahead take `Esc` first |
|
|
344
344
|
| Arrows on a pad or curve handle | Move the puck or handle (`⇧` ×10, `⌥` finest); `j` / `k` or `Tab` move on |
|
|
345
345
|
| 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) |
|
|
346
346
|
| `[` / `]` | Cycle presets and saved variants from anywhere in the panel; with no preset strip, cycle tabs |
|
|
@@ -350,16 +350,17 @@ Panels and switchers are keyboard-first:
|
|
|
350
350
|
| `Home` / `End` | Jump slider to `min` or `max` |
|
|
351
351
|
| `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 |
|
|
352
352
|
| `Backspace` / `Delete` | Reset focused row (or the selection) to its default (or double-click row/label) |
|
|
353
|
-
|
|
|
354
|
-
|
|
|
353
|
+
| Drag a slider | Follows the pointer. Off the row it slows in smooth steps (×1 within 24 px, ×½ around 60, ×¼ around 110, ×⅛ beyond 146; ranges, dials, vector cells and colour strips too), a fine ruler fading in near the handle a level per halving; the value never jumps as the rate changes. It sticks for a few px at the default, the active preset's value and the middle (and zero), with a tick (a sound with `sound: true`) and a small settle of the handle, and lets go once pushed through; slowed, the detents weaken so a fine move can sit beside one. `detents: false` on a knob or the mount turns them off. Past `min` or `max` the row stretches like rubber from the end it's anchored at (14 px at most, never past the panel's glass, with one tick) while the value stays at the limit, and springs back on release; range ends, colour strips and pads stretch at their limits too |
|
|
354
|
+
| `⌥` + drag | Fine-tune at 1/10 speed, on top of the slowing off the row |
|
|
355
|
+
| 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 (a mouse dragged sideways on the folded head does too, a step every 24 px; under 4 px it's still a click that opens the row), and on a colour row's head it turns the hue. At a limit everything gives a little, as under a drag (a slider's fill 14 px at most, other controls 6, with one tick), and springs back when you let go; a slider's pan sticks at the same detents as its drag. One undo entry per gesture; momentum is otherwise ignored; vertical scrolling passes through. A `⇧` + wheel notch steps once |
|
|
355
356
|
| `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)) |
|
|
356
357
|
| `Tab` / `⇧Tab` | Next/previous control, wrapping inside floating panels |
|
|
357
358
|
| `f` | Focus first row |
|
|
358
359
|
| `r` / `⇧R` | Randomize / reset, scoped to focus: the selection, else the focused row, else the focused card (its group), else every field. With an active preset, Random lands near it and Reset goes back to it, then to the defaults (see [Presets](#presets-names-and-the-base)). Locked fields, switches and `random: false` keep their values on Random, not on Reset. The header's die and reset icon always act on everything. A reset icon shows while its reset has somewhere to go (the header's: anything; a group's: that group), and ⇧R on a group's card turns that group's ↺ as a click would |
|
|
359
360
|
| `⌥R` | Mutate, scoped the same way: 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) |
|
|
360
361
|
| `g` / `⌥G` | Randomize / mutate the focused row's whole group from any row in it (also the die beside each group's reset icon; `⌥`-click mutates) |
|
|
361
|
-
|
|
|
362
|
-
| `⇧`-click / `⇧↑` `⇧↓` | Select a range of rows (`⌘`-click adds or removes one); `r`, `⌥R`, `⌫`,
|
|
362
|
+
| `L` | Lock or unlock the focused row, the selection, or a focused group heading's whole group, from Random and Mutate (right-click a group heading for its Lock, Randomize and Reset) |
|
|
363
|
+
| `⇧`-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 |
|
|
363
364
|
| Right-click / `⇧F10` / `ContextMenu` | Menu for the row or selection: Lock from Random, Randomize, Reset, Copy; `↑↓`, a letter, `Enter`, `Esc` |
|
|
364
365
|
| Hold `\` | Compare: show the values from before the last change while held (the title dims); nothing is recorded |
|
|
365
366
|
| `⇧C` | Copy the current values (or the selected fields) as code |
|
|
@@ -367,8 +368,49 @@ Panels and switchers are keyboard-first:
|
|
|
367
368
|
| `F2` / `Enter` on the strip, double-click a chip | Rename a saved preset |
|
|
368
369
|
| `u` / `z` | Undo (`⇧u` / `⇧z` redo); slider drags coalesce into a single history entry |
|
|
369
370
|
| `?` | Toggle keyboard shortcuts popover (the title's tooltip mentions it) |
|
|
371
|
+
| `` ` `` | Show or hide every panel and chip on the page, from anywhere outside a text field (see [Show, hide and unlock](#show-hide-and-unlock)) |
|
|
370
372
|
| `Esc` | Close the shortcuts popover; on a focused floating panel (nothing deeper focused), close it and restore focus to its chip |
|
|
371
|
-
| Click a card's empty area or heading | Focus the card (
|
|
373
|
+
| Click a card's empty area or heading | Focus the card (the lens wraps it); `↑` / `↓` go to its first or last row, and `r`, `⌥R` and `⇧R` act on its group |
|
|
374
|
+
|
|
375
|
+
### Where keys go
|
|
376
|
+
|
|
377
|
+
Keys act on the level focus is at: an open editor's part, a row, its card, or the whole panel.
|
|
378
|
+
Three things show which, in both renderers, on floating and inline panels and on a page:
|
|
379
|
+
|
|
380
|
+
- **The focus lens.** One shape, a soft wash with a hairline edge, marks that level and morphs
|
|
381
|
+
straight from where it is to the next (position, size and corners on a quick spring with no
|
|
382
|
+
bounce), never through another level. At rest it fits its target exactly: a row's own shape in
|
|
383
|
+
its card (square, but for the card's corners it reaches), a card's rounded rect. Going in, it
|
|
384
|
+
shrinks onto the row; `Esc` grows it out to the card, then to the panel; `Esc` or `Tab` out of
|
|
385
|
+
the panel grows it to the panel's edge as it fades (a click elsewhere just fades it). A row to
|
|
386
|
+
the next glides. It replaces the per-row and per-card washes. A row's lens shows while you use
|
|
387
|
+
keys (as the old focus wash did); a card's also after a click on it. A press waits for its click
|
|
388
|
+
to finish, so a click on a heading goes straight to the card. With reduced motion it fades out
|
|
389
|
+
where it was and in where it lands.
|
|
390
|
+
- **The armed panel.** While a panel holds the keys (focus is inside it; hovering isn't enough),
|
|
391
|
+
its hairline edge brightens a touch toward the accent and its title is at full strength. At rest
|
|
392
|
+
the title is a little quieter, so on a page with several panels the one listening is plain.
|
|
393
|
+
Focus leaving (or `Esc` out of the panel, or the window going to the background) disarms it.
|
|
394
|
+
- **The scope line.** Beside the title, a quiet trail says where keys go: `Swarm › Shape ›
|
|
395
|
+
Cohesion`, the deepest crumb bright and its parents dim. Crumbs glide to their places as focus
|
|
396
|
+
moves, and one that changes fades out as the new one fades in. For a moment after a key below
|
|
397
|
+
the panel's level, a faint `esc ↑ Shape` follows it, then fades. Clicking a crumb takes focus to
|
|
398
|
+
its level; clicking the title takes it to the panel. When it doesn't fit, the first crumbs fold
|
|
399
|
+
into `…`. `crumbs: false` on the mount drops the line (the lens and the armed frame stay).
|
|
400
|
+
|
|
401
|
+
Tune them with tokens on the panel or any ancestor:
|
|
402
|
+
|
|
403
|
+
| Token | Default | What it does |
|
|
404
|
+
| --- | --- | --- |
|
|
405
|
+
| `--k-lens-ms` | `180` | The lens's morph (ms), a spring with no bounce |
|
|
406
|
+
| `--k-lens-lead` | `1` | How much quicker its size settles than its position (`1`: together) |
|
|
407
|
+
| `--k-lens-wash`, `--k-lens-line` | `.9`, `.65` | Its wash (a share of `--k-surface-hi`) and hairline edge (of `--k-line-hi`) |
|
|
408
|
+
| `--k-lens-panel-wash` | `.35` | The wash at the panel's level, where it covers everything (a share of the wash) |
|
|
409
|
+
| `--k-lens-inset`, `--k-lens-panel-inset` | `0px`, `0px` | How far inside a row, and inside the panel's edge, it sits |
|
|
410
|
+
| `--k-armed-edge` | `.1` | How much accent an armed panel's hairline edge takes |
|
|
411
|
+
| `--k-armed-glow`, `--k-armed-glow-a` | `0`, `0` | An optional glow around it: blur (px) and the accent's strength |
|
|
412
|
+
| `--k-title-rest` | `.6` | A resting panel's title, between `--k-label` (0) and `--k-text` (1) |
|
|
413
|
+
| `--k-esc-hint-ms` | `1800` | How long `esc ↑ …` stays after the last key |
|
|
372
414
|
|
|
373
415
|
### Hint mode across the page
|
|
374
416
|
|
|
@@ -443,7 +485,7 @@ defineControls("tune", schema, {
|
|
|
443
485
|
knob(44, { min: 28, max: 72, group: "drawer.spacing", groupLabel: "Spacing", section: "drawer", sectionLabel: "Session List" })
|
|
444
486
|
```
|
|
445
487
|
|
|
446
|
-
`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.
|
|
488
|
+
`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; with an active preset whose value differs, a second notch hangs from the top edge at that value, and either brightens while a drag is held on it; changed choices and switches show a small dot.
|
|
447
489
|
|
|
448
490
|
### Remote / Socket-Driven Controls (`createRemoteControls`)
|
|
449
491
|
|
|
@@ -561,12 +603,12 @@ Without Solid: `remote.update(descriptors, values, { land })` and `mountPanel(re
|
|
|
561
603
|
are claimed (`preventDefault`) and stopped there, and everything else reaches the page. Esc hands
|
|
562
604
|
focus back. `keys` remaps (`{ hint: "f" }`) or turns off (`{ random: false }`) any command; a
|
|
563
605
|
command that's off loses its header button too, and the `?` popover and row menu list only what's
|
|
564
|
-
on (see **Header** for picking buttons). `vim: false` drops j/k
|
|
606
|
+
on (see **Header** for picking buttons). `vim: false` drops j/k, `edit: false` leaves digits
|
|
565
607
|
and Enter to the page, `bigStep` sets ⇧'s multiplier and `wrap: false` stops ↑/↓ at the ends.
|
|
566
608
|
- **Marks.** `marks: false` (on the store or the mount) hides modified dots, readout marks, group
|
|
567
609
|
resets and Reset, for values that belong to someone else. A command that's off takes its marks
|
|
568
610
|
too: with `reset: false` rows show nothing to reset, and group resets follow `resetAll`.
|
|
569
|
-
- **Haptics.** On phones, a stepped slider crossing a stop,
|
|
611
|
+
- **Haptics.** On phones, a stepped slider crossing a stop, a detent catching a drag, a
|
|
570
612
|
choice, preset or seed changing mid-pan, a pan meeting a limit and a switch flipping each give a
|
|
571
613
|
light tick: `navigator.vibrate` on
|
|
572
614
|
Android; on iPhone, Safari's switch tick (iOS 17.4–26.4 for every tick; from iOS 26.5, which only
|
|
@@ -751,7 +793,8 @@ defineControls("settings", schema, { ship: () => import("knobs/panel") })
|
|
|
751
793
|
|
|
752
794
|
A shipped store stays live in production (`enabled` defaults to true) and mounts from the module you
|
|
753
795
|
name, which your bundler splits into its own chunk. The core never imports the panel itself.
|
|
754
|
-
Stores without `ship` are unchanged.
|
|
796
|
+
Stores without `ship` are unchanged. To tune dev-only stores on a deployed page without shipping
|
|
797
|
+
them to visitors, see [`unlock`](#show-hide-and-unlock).
|
|
755
798
|
|
|
756
799
|
In production its chips stay hidden until someone presses **⌥⇧D**, which turns knobs mode on and off
|
|
757
800
|
for the tab (remembered in `sessionStorage`, with a brief "knobs on" / "knobs off"); then they show on
|
|
@@ -810,6 +853,7 @@ A panel inside an app should look like the app's own controls. Four things get i
|
|
|
810
853
|
| `--k-radius`, `--k-panel-radius` | `8px` by density, radius + `4px` | Corners. `0` squares everything, the pill shapes (switches, handles, dots) included |
|
|
811
854
|
| `--k-thumb` | `#fff` | Switch thumbs and strip handles |
|
|
812
855
|
| `--k-lift` | `1` | `0` drops the small shadows under pills, thumbs and handles |
|
|
856
|
+
| `--k-shadow-strength` | `.48` (light: `.16`) | How dark the WebGPU renderer draws the panel's drop shadow. Set it beside `--k-shadow` when you change that, so both renderers match (a host blending a dark look into a light one moves both together) |
|
|
813
857
|
| `--k-frame` | `card` | `flat`: no card inside the host's own card (see below) |
|
|
814
858
|
| `--k-disabled-opacity` | `.32` | How far a disabled row dims |
|
|
815
859
|
| `--k-warn`, `--k-critical` | `#ff9f0a`, `#ff453a` (light: `#c56a00`, `#e0352b`) | Readouts' warning and critical colours (meters past `warn` / `critical`, log levels), and a destructive confirm |
|
|
@@ -999,7 +1043,7 @@ On a page, the keyboard spans every card:
|
|
|
999
1043
|
| `/` | 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 |
|
|
1000
1044
|
| `s` | Hints for what's on screen, custom cards included |
|
|
1001
1045
|
|
|
1002
|
-
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` and `⇧R` scoped to focus, `g`, `Esc` climbing from row to card to page, focusing a card by its empty area or heading,
|
|
1046
|
+
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` and `⇧R` scoped to focus, `g`, `Esc` climbing from row to card to page, focusing a card by its empty area or heading, `L`, selection, the row menu, hold `\`) works as in a panel.
|
|
1003
1047
|
|
|
1004
1048
|
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.
|
|
1005
1049
|
|
|
@@ -1050,7 +1094,8 @@ Scrolling pane bodies, trees and the command palette's list fade their content o
|
|
|
1050
1094
|
|
|
1051
1095
|
**`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:
|
|
1052
1096
|
|
|
1053
|
-
- `icon` and `detail` for a glyph and dimmed text, or `render` for your own row content
|
|
1097
|
+
- `icon` and `detail` for a glyph and dimmed text (or nodes of your own), or `render` for your own row content
|
|
1098
|
+
- `glide: true` to show the selected row as one highlight that glides to the next row picked (on the rows' .3 s spring) instead of each row lighting in place; several selected rows light in place
|
|
1054
1099
|
- `toggles` for row accessories such as `visible` and `locked`, which show at rest only in their unusual state
|
|
1055
1100
|
- `filter` for a query or a test; matching rows show with their ancestors, the match marked
|
|
1056
1101
|
- `selection`, `expanded` and `rowHeight` (`--k-row` by default)
|
|
@@ -1136,7 +1181,34 @@ const clock = timeline(dock, {
|
|
|
1136
1181
|
})
|
|
1137
1182
|
```
|
|
1138
1183
|
|
|
1139
|
-
**`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.
|
|
1184
|
+
**`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; `arrows: { step: 0.5, shift: 2 }` makes them seconds, the playhead gliding there) and `,`/`.` always do; `seek(t, { glide: true })` glides the playhead too, the time landing at once. 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`, `duration`, `fps`, `markers`, `ranges`, `view`, `seek`, `play`, `pause`, `toggle`, `update`, `fit`, `zoom`, `focus` and `dispose`. Pieces bound to a timeline hear it with `listen({ onSeek, onPlay, onView, onScrub, onRanges })` (it returns a stop), and `drive(clock)` makes playback follow another clock, such as a video's `currentTime`, so the two never drift. `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.
|
|
1185
|
+
|
|
1186
|
+
### Compare
|
|
1187
|
+
|
|
1188
|
+
```ts
|
|
1189
|
+
import { compare, compareStrip, timeline } from "knobs/ui"
|
|
1190
|
+
|
|
1191
|
+
// All in one: the viewer and a timeline of its own.
|
|
1192
|
+
const review = compare(el, {
|
|
1193
|
+
before: { src: "/runs/41.mp4" },
|
|
1194
|
+
after: { src: "/runs/42.mp4" },
|
|
1195
|
+
mode: "side", // the default; "wipe" | "difference" | "flicker"
|
|
1196
|
+
timeline: true,
|
|
1197
|
+
onView: ({ zoom, loop }) => log(zoom, loop), // { u, v, w, h }, { start, end } | null
|
|
1198
|
+
})
|
|
1199
|
+
review.seekBy(0.5)
|
|
1200
|
+
review.setLoop(true) // around the playhead; drag its ends on the timeline
|
|
1201
|
+
|
|
1202
|
+
// A long page of them: each loads near the view and lets go well off it.
|
|
1203
|
+
for (const step of steps) compare(step.el, { before: step.before, after: step.after, timeline: true })
|
|
1204
|
+
|
|
1205
|
+
// Or composed: the app places the pieces and binds them to one timeline.
|
|
1206
|
+
const clock = timeline(dock, { duration: 6, fps: 30 })
|
|
1207
|
+
compare(viewer, { before, after, timeline: clock })
|
|
1208
|
+
compareStrip(strip, { before, after, timeline: clock })
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
**`compare(container, options)`** is a before/after viewer for videos and images (by extension, or `kind`). Side by side (the default `mode`: a split screen down the middle, each side filling its half), a wipe, a difference (changes above `threshold` in one accent, `--k-compare-diff`) or a flicker, drawn by one WebGPU pass that copies a frame in only when one arrives, and draws only when something changed; where there's no WebGPU, two `<video>`s with a clip-path wipe stand in. Pinch, `⌘`/ctrl-wheel or double-click zooms into either side and both zoom to the same part (the wheel and a drag pan it, both 1:1); a small glass pill in the corner shows how far, with Reset (`0`, a double-click, `Escape`), which glides back. Overlaid, the sides are letterboxed, never cropped. Both videos run from one clock: paused, scrubbed or stepped, each seeks to the frame's midpoint, coalesced while a seek is in flight; playing, After leads, Before's rate leans by up to 25% to stay on it (it seeks past 90 ms), and a bound timeline follows After's time, so only one clock ever moves it (`Space` resumes from the frame showing). Scrubbing answers at once even on raw screen recordings with a keyframe every 8–18 s: frames decoded in the background (WebCodecs where the file is a plain MP4, else a hidden `<video>`) sit in a small cache shared by every viewer and strip of the same source, and the nearest shows while the exact seek lands (mid-drag, far from any cached frame, `fastSeek` answers first; letting go seeks exactly). Encoding with a keyframe every 15 frames makes every seek exact and near instant: `COMPARE_FFMPEG` holds the command (`ffmpeg -i in.mov -vf fps=30 -fps_mode cfr -c:v libx264 -crf 18 -g 15 … out.mp4`). Sources load only near the view and are let go 3 s after leaving it (`lazy: false` keeps them), every viewer shares one GPU device, and in a container with no height of its own it takes the height its sides need, half its width in their shape, so the split has no margins (`aspect` before they load); with a height of its own (a card going full-window, say), the pair fits as a whole and refits as it resizes, keeping the zoom. On a card (`.knobs-surface`, a split's pane) or marked `data-nested`, its stage drops its fill, so one surface shows. Bind an existing `timeline()` (`timeline: clock`), or pass `timeline: true` for its own. Loop (`L`) sets a loop range around the playhead on the timeline, whose ends drag, and playback stays inside it. The wipe follows the hand 1:1 and is a slider (`←`/`→`, `⇧` 10%); a mode change glides. Keys when focused: `Space` plays, `←`/`→` seek half a second (`⇧` two), the playhead gliding there while the videos land at once, `,`/`.` step a frame, `1`–`4` or `S` `W` `D` pick a mode, `X` held flicks to Before, `Z` held shows the loupe (or `loupe: true`), `L` loops, `=`/`-`/`0` zoom. It reports `onMode`, `onWipe` and `onView({ zoom, loop })`; the handle has `mode`, `wipe`, `zoom`, `loop`, `time`, `playing`, `duration`, `renderer`, `timeline`, `strip`, `update`, `seek`, `seekBy`, `play`, `pause`, `toggle`, `step`, `setZoom`, `setLoop`, `focus` and `dispose`. A frame strip is off by default (`strip: true` puts one over the timeline); **`compareStrip(container, { before, after, timeline })`** is the same piece on its own: it draws Before over After along the timeline's view from the same frame cache, decoded only while it's on screen, with a bar for how much each column differs; a press scrubs. `Compare` and `CompareStrip` are in `knobs/ui/react` and `knobs/ui/solid`. The Lab's View switch shows one on its scene timeline.
|
|
1140
1212
|
|
|
1141
1213
|
### Activity
|
|
1142
1214
|
|
|
@@ -1198,6 +1270,35 @@ import { KnobsPanel } from "knobs/solid"
|
|
|
1198
1270
|
|
|
1199
1271
|
The parameters are inset by the picker (`--k-picker-pad`, `0 8px 8px`), whatever a panel's own pad. They are real knobs panels: `body(option, element)` mounts whatever an option needs into `element` the first time it opens and returns a cleanup, and it stays mounted while closed, so a panel keeps its state. The picker is plain DOM chrome in `knobs/ui` and the panels come from `knobs/panel`, so `knobs/ui` stays free of the panel, and the parameters draw with the DOM or the WebGPU renderer as any panel does (nothing around them makes a stacking context at rest). `OptionPicker` in `knobs/ui/solid` and `knobs/ui/react` renders `children(option)` there instead, in the component's context. The handle has `set(options)`, `select(id)`, `selected`, `choose(id)`, `chosen`, `focus()` and `destroy()`. A `select(id)` (or a `selected` or `chosen`) for an option that isn't there yet is kept, and the `set()` that brings it picks it; a later `select` replaces it.
|
|
1200
1272
|
|
|
1273
|
+
### Code diff
|
|
1274
|
+
|
|
1275
|
+
```ts
|
|
1276
|
+
import { codeDiff, diffReview } from "knobs/ui"
|
|
1277
|
+
|
|
1278
|
+
// A patch's files in a tree beside the diff, in a split.
|
|
1279
|
+
const review = diffReview(container, { ...look, id: "review", patch: gitShowOutput })
|
|
1280
|
+
|
|
1281
|
+
// Or the diff alone, as a card: one file or many, from a patch or before and after text.
|
|
1282
|
+
const diff = codeDiff(el, {
|
|
1283
|
+
...look,
|
|
1284
|
+
files: [{ name: "src/spring.ts", before, after }],
|
|
1285
|
+
layout: "unified", // "split" by default
|
|
1286
|
+
height: "auto", // as tall as the file's diff, up to maxHeight; "fill" by default
|
|
1287
|
+
onFile: (name) => setFile(name),
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
// Many down a long page (a walkthrough): each holds its height, draws when near the viewport.
|
|
1291
|
+
for (const step of steps) codeDiff(step.el, { ...look, patch: step.patch, height: "auto" })
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
**`codeDiff(container, options)`** shows a code diff in a knobs card: a header with the file's path, its `+12 −3`, ‹ 2 of 5 › when there are several files (`nav: false` hides it), a wrap toggle and Split | Unified, over the diff. The diff itself is drawn by [`@pierre/diffs`](https://diffs.com) (Shiki highlighting, word-level changes, hunks that expand when whole files are known), an optional peer dependency: install it to use diffs (`npm i @pierre/diffs`). It loads on first use, so `knobs/ui` doesn't carry it. Its colours come from the pieces' tokens, so dark, light, presets and `restyle()` carry into it. `--k-diff-add`, `--k-diff-del` and `--k-diff-bg` tune it, and `syntax: { dark, light }` picks Shiki themes for the code. It takes `patch` (`git diff` or `git show` output, one file or many) or `files: [{ name, before, after, lang? }]`, plus `file`, `layout`, `wrap`, `onFile`, `onLayout`, `onWrap` and `onLines(lines, file)` (dragging over line numbers selects lines). Changing the file, the layout or the wrap fades the new view in over the old in place (.3 s in, .24 s out, a 3px blur while it fades; a .16 s fade under reduced motion). With `height: "fill"` the card fills its container and each file keeps its scroll. With `height: "auto"` it's a self-contained piece for a long page: it's as tall as the file's diff up to `maxHeight` (640, or 70% of the window when that's less) and scrolls inside past that, springing to each file's height (.4 s, no bounce). It holds the height the diff will take from the first frame, counted from the patch's rows before the renderer has loaded, so nothing below it jumps. It also draws only within a screen of the viewport (of the scroller it sits in) and lets go of its views four screens away, keeping its height; `lazy: false` draws it at once, and `lazy: true` does this with `height: "fill"`. Pass `viewed` (names) or `onViewed(viewed, file)` for a Viewed tick in the header. `stack: true` puts every file down one scroller, as GitHub's Files changed does: each under its own header (status, path, counts and their bar, Viewed) that sticks to the top until the next file's pushes it off: flush (square, no card around each file, a gap between files), opaque in the code's colour, with a hairline under it only while it's stuck. The card's header then sums the change, ‹ › and `show()` glide to a file (.4 s), the file on show follows the scroll, and each file draws only near the view.
|
|
1295
|
+
|
|
1296
|
+
Its scrollers fade their content out at an edge with more beyond it (a mask: at the top once scrolled, below a stuck header, and at the bottom while there's more below, `--k-diff-fade`, 20px), easing in and out on the .25 s spring. Its scrollbars are thin ones drawn over the code that take no room and stay hidden until the pointer is over the diff, or while you scroll or drag: `overlayScrollbar` up and down, and a bar per code column across, kept at the bottom of what shows of the column however far down a long file you are (the renderer's own, inside its shadow root, are hidden). One inset runs all round, `--k-diff-inset` (8px, the renderer's own gap): the header's controls sit it in from the top and right, the path, the separators and the line numbers it in from the left, and the code ends it above the bottom. Inner corners (separators, the header's controls) are the outer corner less that distance, so they're concentric with the card. On another surface the diff takes no inset across, so its header and code line up with the surface's own content (a walkthrough's caption), and its corners are measured from the surface's. The header's hairline shows only once the code has scrolled under it. The handle has `files`, `file`, `layout`, `wrap`, `viewed`, `show(name)`, `step(±1)`, `update({ patch?, files?, file?, layout?, wrap?, viewed?, height?, maxHeight? })`, `ready` (once drawn) and `dispose()`. `update({ height: "fill" | "auto" })` switches in place, keeping the views, their scroll and the sticky headers; the new height isn't sprung, so a container whose size is moving (a walkthrough card going full window) is followed, not fought.
|
|
1297
|
+
|
|
1298
|
+
**One surface.** Cards never nest. Whatever draws a card carries `.knobs-surface` (a walkthrough's step; put it on your own cards too), and a split's pane counts as one; a piece inside a surface draws no card of its own, so a diff in a step, a pane or your card shows no second fill or ring, and a `diffReview` inside one parts its panes on a hairline instead of drawing them as cards. The diff's code keeps an opaque fill under its sticky line numbers, the colour showing behind it, so it still matches.
|
|
1299
|
+
|
|
1300
|
+
**`diffReview(container, options)`** pairs it with a file list in one card. The list is a drawer at the card's left that springs open and shut between 0 and 256px (`listSize`; .4 s, no bounce), from a button at the start of the diff's header or `⌘B` / `Ctrl+B` while you're working in the review; the diff reflows as it moves, the list slides out from under it, and with an `id` it stays as you left it (`listOpen: false` starts it shut). The list is built on `tree()` and laid out as GitHub's, Graphite's and VS Code's are: a filter field on top (every word must be in the path: `ui diff`; `Esc` clears it, `↓` or `Enter` goes to the list), then a compact tree (28px rows, `--k-diff-row`) with folders folded into paths (`src/ui` is one row when `src` holds nothing else). Each file shows its change as a coloured letter (M amber, A green, D red and struck through, R blue; `--k-diff-mod` and `--k-diff-move` tune the two new ones), its `+12 −3` and a five-square bar of the share added and removed, and a tick to mark it viewed (on a folder, everything in it). Viewed files dim, the list's title counts them (`2/5`), and with an `id` they're remembered until their change moves on (each is saved with a fingerprint of its diff). The file on show has one highlight that glides to the next (the tree's new `glide` option, on the rows' .3 s spring). Picking a file shows it. The handle has the pieces, `tree` and `diff`, plus `listOpen`, `toggleList(open?)`, `viewed`, `setViewed(names, on)`, `filter(query)` and `update()`; `viewed` and `onViewed(viewed, file)` seed and report the marks, and `search: false` drops the field. To place the tree and the diff yourself, `diffFiles({ patch })` lists the files with their counts in tree order, `diffTree(files)` makes the tree's items, and `diffCounts(file, doc, { bar })` and `diffStatus(file)` give a row's counts and letter (a tree's `detail` and `icon`). `CodeDiff` and `DiffReview` in `knobs/ui/react` and `knobs/ui/solid` follow `patch`, `files`, `file`, `layout`, `wrap`, `viewed`, `height` and `maxHeight`. The demo is `examples/lab-diff.html`.
|
|
1301
|
+
|
|
1201
1302
|
### Ideas grid (bento)
|
|
1202
1303
|
|
|
1203
1304
|
```ts
|
|
@@ -1299,6 +1400,78 @@ A reset that went back to the active preset rather than the defaults carries `ba
|
|
|
1299
1400
|
|
|
1300
1401
|
`patch` holds only the fields that changed. The die page (`examples/die.ts`) rolls its demo dice this way.
|
|
1301
1402
|
|
|
1403
|
+
## Show, hide and unlock
|
|
1404
|
+
|
|
1405
|
+
**Backtick.** `` ` `` shows and hides every panel and chip on the page at once, the way a page's
|
|
1406
|
+
dev tools come and go together. It works anywhere outside a text field, and never with ⌘, ⌃ or ⌥
|
|
1407
|
+
held. One key for the whole page, not just the focused panel: you hide them to see the page, and a
|
|
1408
|
+
panel you can't see can't have focus to aim at. If any panel is showing the key hides them all;
|
|
1409
|
+
otherwise it shows them all. Panels mounted later (scrolled into view) follow the last press.
|
|
1410
|
+
|
|
1411
|
+
```ts
|
|
1412
|
+
hero.mount(column) // ` hides and shows it with the page's other panels
|
|
1413
|
+
hero.mount(column, { toggleKey: false }) // always shown
|
|
1414
|
+
defineControls("hero", schema, { toggleKey: "F8" }) // its panels and chips listen for F8
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
- **Remembered per store**, in its `storage`, so a reload puts the page back as you left it, with
|
|
1418
|
+
no animation. Stores with `storage: null` start shown.
|
|
1419
|
+
- **Hidden is hidden.** The panel takes no space and is `hidden` and `inert`: no Tab stop, no hint
|
|
1420
|
+
code, no click, and the WebGPU overlay stops drawing it. Chips hide too, hint mode stops, and
|
|
1421
|
+
focus that was in a panel goes back there when it shows again.
|
|
1422
|
+
- **Motion.** Leaving, a panel fades with a little blur (4 px at most) and scale (.96 at least) on a
|
|
1423
|
+
.25 s spring with no bounce; arriving is the same in reverse on .32 s. An inline panel also folds
|
|
1424
|
+
its space shut, its content gone before the last of the height, so the page below glides up.
|
|
1425
|
+
Pressing again mid-way turns it around from where it is. Both renderers draw the same; reduced
|
|
1426
|
+
motion only fades.
|
|
1427
|
+
- **Off by default** for shipped stores (`ship`, product UI) and full pages (`mountPage`, the whole
|
|
1428
|
+
tool); give them a `toggleKey` to opt in. The `?` popover lists the key.
|
|
1429
|
+
- Your own dev tools can join with ``toggleLayer(document).register({ id, key: "`", storage,
|
|
1430
|
+
root, show })`` from `knobs/panel`, or keep their own handler: knobs doesn't claim the key, so a
|
|
1431
|
+
page's backtick listener still runs and both come and go together.
|
|
1432
|
+
|
|
1433
|
+
**A secret unlock for a live site.** A production build locks every store to its constants and
|
|
1434
|
+
leaves the panel out (see below). To tune a deployed page anyway, let the deploy be unlocked:
|
|
1435
|
+
|
|
1436
|
+
```ts
|
|
1437
|
+
import { defineControls, knob, unlock } from "knobs"
|
|
1438
|
+
|
|
1439
|
+
const hero = defineControls("hero", { radius: knob(12, { min: 0, max: 40, step: 1 }) })
|
|
1440
|
+
hero.mount(column) // nothing for visitors: hero.get() is the shipped constants
|
|
1441
|
+
|
|
1442
|
+
unlock({ panel: () => import("knobs/panel") })
|
|
1443
|
+
```
|
|
1444
|
+
|
|
1445
|
+
Visitors download no panel code and see the constants. You unlock it by typing **knobs** anywhere
|
|
1446
|
+
outside a text field, opening the page with **`?knobs`**, or tapping the **top-right corner three
|
|
1447
|
+
times** quickly (on a phone). Then the panel chunk loads, panels and chips appear, values come from
|
|
1448
|
+
this browser's storage, and a brief "knobs unlocked" confirms it. It's remembered in localStorage
|
|
1449
|
+
until you lock it again: the same secret again, `?knobs=off`, or the handle's `lock()`. Locking takes
|
|
1450
|
+
the panels down and puts the constants back, which is also how to check what visitors see.
|
|
1451
|
+
|
|
1452
|
+
```ts
|
|
1453
|
+
import { KONAMI, unlock } from "knobs"
|
|
1454
|
+
|
|
1455
|
+
const gate = unlock({
|
|
1456
|
+
panel: () => import("knobs/panel"),
|
|
1457
|
+
sequence: KONAMI, // a word ("knobs"), key names (↑↑↓↓←→←→BA), or false
|
|
1458
|
+
query: "tune", // ?tune unlocks, ?tune=off locks; false for none
|
|
1459
|
+
corner: "top-left", // three taps there; false for none
|
|
1460
|
+
persist: false, // this page view only
|
|
1461
|
+
})
|
|
1462
|
+
gate.unlocked // also gate.unlock(), gate.lock(), gate.dispose()
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
It's a secret, not access control: anyone who finds it can tune the page in their own browser, and
|
|
1466
|
+
nothing they change reaches anyone else. In development `unlock` changes nothing, since stores are
|
|
1467
|
+
live there already. Once unlocked, the backtick hides and shows panels as usual.
|
|
1468
|
+
|
|
1469
|
+
How others do it: Leva and Tweakpane have a `hidden` prop; dat.gui hides every GUI on `H` (lil-gui
|
|
1470
|
+
dropped it); Theatre.js toggles its studio with ⌥\\; Stripe's Workbench opens with `~`; Vercel's
|
|
1471
|
+
toolbar hides with ⌘. and only shows to signed-in team members. A typed word, a query flag kept in
|
|
1472
|
+
localStorage and a corner gesture for phones cover a keyboard, a link and a touch screen without an
|
|
1473
|
+
account.
|
|
1474
|
+
|
|
1302
1475
|
## Explore in development. Ship constants.
|
|
1303
1476
|
|
|
1304
1477
|
```ts
|
|
@@ -1306,7 +1479,7 @@ splat.locked // frozen schema defaults
|
|
|
1306
1479
|
splat.get() // live while enabled; the exact locked object otherwise
|
|
1307
1480
|
```
|
|
1308
1481
|
|
|
1309
|
-
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.
|
|
1482
|
+
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. The one way back is a page that calls [`unlock`](#show-hide-and-unlock) and someone who knows its secret; the panel then loads from the module `unlock` names.
|
|
1310
1483
|
|
|
1311
1484
|
## API Summary
|
|
1312
1485
|
|
|
@@ -1314,7 +1487,7 @@ The package has two core exports selected through the standard `development` con
|
|
|
1314
1487
|
|
|
1315
1488
|
| Function | Behavior |
|
|
1316
1489
|
| --- | --- |
|
|
1317
|
-
| `knob(value, { min, max, step?, unit?, shortcut?, label?, group?, groupLabel?, section?, sectionLabel? })` | Numeric slider control with rolling readout and inline editor. |
|
|
1490
|
+
| `knob(value, { min, max, step?, unit?, decimals?, detents?, shortcut?, label?, group?, groupLabel?, section?, sectionLabel? })` | Numeric slider control with rolling readout and inline editor. `detents: false` lets drags glide through the default, the base and the middle. |
|
|
1318
1491
|
| `choice(value, ["a", "b"], { label?, group?, shortcut? }?)` | Nonempty string choices with literal union inference. |
|
|
1319
1492
|
| `toggle(value, { label?, group?, shortcut?, random? }?)` | Boolean switch control. Out of Random and Mutate unless `random: true`. |
|
|
1320
1493
|
| `spring({ duration?, bounce? }?, meta?)` | SwiftUI-style spring: two sliders beside its live curve. |
|
|
@@ -1335,10 +1508,11 @@ The package has two core exports selected through the standard `development` con
|
|
|
1335
1508
|
| `fromDescriptors(descriptors)` | Converts runtime `ControlDescriptor[]` into a `Schema`. |
|
|
1336
1509
|
| `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. |
|
|
1337
1510
|
| `fromSettings(groups, home?)` | A hosted prototype's settings sections as descriptors and values. |
|
|
1511
|
+
| `unlock({ panel, sequence?, query?, corner?, persist? })` / `KONAMI` | Lets a production deploy be unlocked by a typed secret, `?knobs` or three corner taps; returns `{ unlocked, unlock(), lock(), dispose() }`. See [Show, hide and unlock](#show-hide-and-unlock). |
|
|
1338
1512
|
| `action(label, { run, shortcut?, global?, rebind? })` / `flatSpring({ duration, bounce }, meta)` | A button row, with a shortcut its key cap can rebind; two flat number fields drawn as one spring card. `actionShortcut(field)` reads an action's shortcut as `{ key, scope }`. |
|
|
1339
1513
|
| Field meta `random?`, `disabled?`, `readOnly?`, `folded?`, `tab?`, `subsection?` | On any field or descriptor: `random: false` stays out of Random and Mutate (switches, seeds and key combinations start out; `random: true` brings them in); `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. |
|
|
1340
1514
|
| 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. |
|
|
1341
|
-
| `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`, `sound`. The handle adds `panel.fold(group, folded?)`, `panel.tab(id?)`, `panel.search(query?, chips?)` (returns `{ query, chips }`) and `panel.updateAccessories()`. |
|
|
1515
|
+
| `mountPanel(controls, container, options?)` (`knobs/panel`) | An inline panel. Beside theme, density, renderer and keys: `preset`, `header` (`false` or `{ title?, buttons? }`), `crumbs` (`false` drops the scope line), `session`, `groupFolded`, `subsectionFolded`, `tabs`, `tab`, `onTab`, `search`, `groupRandom(group, mode)`, `rowAccessory(key)`, `marks`, `haptics`, `detents`, `sound`. The handle adds `panel.fold(group, folded?)`, `panel.tab(id?)`, `panel.search(query?, chips?)` (returns `{ query, chips }`) and `panel.updateAccessories()`. |
|
|
1342
1516
|
| `sound` (`knobs/panel`) | The page's sound effects (see **Sound**): `enabled`, `enable(on?)`, `volume`, `setVolume(v)`, `theme`, `setTheme("glass" \| "mech")`, `play(cue \| layers, detail?)`, `output()`, `subscribe(listener)`. Panels play only with `sound` on their mount. |
|
|
1343
1517
|
| `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. |
|
|
1344
1518
|
| `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[] }`. |
|
|
@@ -1376,6 +1550,7 @@ The package has two core exports selected through the standard `development` con
|
|
|
1376
1550
|
- `controls.variants.list()` / `.save(name?)` / `.load(id, { coalesce? })` / `.rename(id, name)` / `.remove(id)` / `.export()` / `.import(json)`
|
|
1377
1551
|
- `controls.attach(element, chip?)` — floating chip + anchored panel; `chip` (`{ label?, reveal?, shortcut? }`) overrides the store's `chip` option
|
|
1378
1552
|
- `controls.mount(container, options?)` — inline panel
|
|
1553
|
+
- `controls.toggleKey` — the key that shows and hides its panels and chips (`` "`" `` by default, `false` for shipped stores); `toggleKey` on the mount overrides it
|
|
1379
1554
|
|
|
1380
1555
|
### Workspace pieces (`knobs/ui`)
|
|
1381
1556
|
|
|
@@ -1383,6 +1558,8 @@ The package has two core exports selected through the standard `development` con
|
|
|
1383
1558
|
| --- | --- |
|
|
1384
1559
|
| `split(container, { panes, direction?, id?, storage?, onLayout?, preset?, theme? })` | Resizable panes and docks that collapse to rails or headers; layouts saved by `id`. |
|
|
1385
1560
|
| `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. |
|
|
1561
|
+
| `codeDiff(container, { patch? \| files?, file?, layout?, wrap?, height?, onFile?, onLines?, … })` | A code diff in a card (needs `@pierre/diffs`); files and layouts fade in place. |
|
|
1562
|
+
| `diffReview(container, { patch? \| files?, id?, … })` | The changed files (a filter, viewed ticks) in a tree beside the diff, in a split. |
|
|
1386
1563
|
| `readout(field, { preset?, theme?, label? })` | A readout on its own: `{ element, push(value), value, dispose() }`. |
|
|
1387
1564
|
| `popover(anchor, content, { placement?, offset?, role?, label?, focus?, trap?, closeOnOutside?, onClose? })` | A floating layer that flips and shifts; `{ element, open, reposition(), close() }`. |
|
|
1388
1565
|
| `tooltip(el, text \| { text, shortcut?, placement?, delay? })` / `tooltips(doc)` | Delayed tooltips with instant hand-off and shortcut chips; `data-tip` attributes. |
|
|
@@ -1391,6 +1568,7 @@ The package has two core exports selected through the standard `development` con
|
|
|
1391
1568
|
| `toast(message, { tone?, linger?, action?, within? })` | A status pill at the bottom centre. |
|
|
1392
1569
|
| `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. |
|
|
1393
1570
|
| `timeline(container, { duration, time?, fps?, units?, markers?, ranges?, snap?, onSeek?, … })` | A zoomable ruler and playhead with markers, loop ranges and playback. |
|
|
1571
|
+
| `compare(container, { before, after, mode?, wipe?, timeline?, onMode?, onWipe?, onView?, … })` / `compareStrip(container, { before, after, timeline })` | A before/after viewer for videos and images (a split screen zoomed together, wipe, difference, flicker) on one clock, with a loop; a frame strip with difference bars, on its own. |
|
|
1394
1572
|
| `activity(container, { events, selected?, onSelect?, onActivate?, onToggle?, onPlay?, newest?, follow?, keys?, icons?, tints? })` | A rail of events on one continuous line, with groups that open in place; `{ set, add, select, expand, focus, destroy }`. |
|
|
1395
1573
|
| `optionPicker(container, { options, selected?, chosen?, onSelect?, onChoose?, body?, digits? })` | Options 1–n on a gliding card, the selected one's parameters (a panel) open under it. |
|
|
1396
1574
|
| `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. |
|
|
@@ -248,6 +248,17 @@ function comboParts2(combo, apple = isApple()) {
|
|
|
248
248
|
function comboLabel2(combo, apple = isApple()) {
|
|
249
249
|
return comboParts2(combo, apple).join(apple ? "" : "+");
|
|
250
250
|
}
|
|
251
|
+
function isTypingTarget(target) {
|
|
252
|
+
let el = target;
|
|
253
|
+
if (!el || typeof el.tagName !== "string")
|
|
254
|
+
return !1;
|
|
255
|
+
let tag = el.tagName;
|
|
256
|
+
if (tag === "SELECT" || el.isContentEditable)
|
|
257
|
+
return !0;
|
|
258
|
+
if (tag === "TEXTAREA")
|
|
259
|
+
return !el.readOnly;
|
|
260
|
+
return tag === "INPUT" && !el.readOnly && !["range", "checkbox", "radio", "button", "submit", "reset", "color"].includes(el.type);
|
|
261
|
+
}
|
|
251
262
|
|
|
252
263
|
// src/core/ramp.ts
|
|
253
264
|
var MAX_STOPS = 32, MAX_POINTS = 64, GRADIENT_SAMPLES = 64, CURVE_SAMPLES = 129, clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)), finite = (v) => typeof v === "number" && Number.isFinite(v), round4 = (v) => Math.round(v * 1e4) / 1e4 || 0;
|
|
@@ -531,11 +542,9 @@ function withMeta(base, meta) {
|
|
|
531
542
|
return checkShortcut(meta?.shortcut), Object.freeze({ ...base, ...pickMeta(meta) });
|
|
532
543
|
}
|
|
533
544
|
function knob2(value, options) {
|
|
534
|
-
|
|
535
|
-
if (![value, options.min, options.max, step].every(Number.isFinite) || options.max < options.min || step <= 0)
|
|
545
|
+
if (!Number.isFinite(value))
|
|
536
546
|
throw Error("A knob needs finite bounds and a positive step.");
|
|
537
|
-
|
|
538
|
-
throw Error("A knob's decimals must be a whole number.");
|
|
547
|
+
let step = bounds(options, "A knob");
|
|
539
548
|
checkShortcut(options.shortcut);
|
|
540
549
|
let field = { ...options, kind: "knob", value, step };
|
|
541
550
|
return Object.freeze({ ...field, value: normalizeField(field, value) });
|
|
@@ -1129,4 +1138,4 @@ function urlFlag2(name) {
|
|
|
1129
1138
|
}
|
|
1130
1139
|
var storageKey = (id, part) => `knobs:${encodeURIComponent(id)}:${part}:v1`;
|
|
1131
1140
|
|
|
1132
|
-
export { __preload, __chunks, maxChroma, oklchToRgb, parseColor2, fitColor, formatColor, colorHex2, EASINGS2, EASING_Y, easingName, bezierPoint2, bezierCss2, normalizeCombo2, letterOf2, comboFromEvent2, heldMods, matchesCombo2, comboParts2, comboLabel2, GRADIENT_SAMPLES, sampleGradient2, gradientSamples, gradientCss2, stopColorAt, sampleCurve2, curveSamples, CURVE_SHAPES2, inRange, sameRamp, randomStops, mutateStops, randomPoints, mutatePoints, pickMeta, isReadout2, hasValue2, SPRING2, valueKeys, knob2, choice2, action2, flatSpring2, toggle2, spring2, color2, point2, easing2, vec22, vec32, range2, angle2, seed2, keybind2, gradient2, curve2, toolbarShortcut2, actionShortcut2, toolbar2, watch2, sparkline2, graph2, meter2, table2, log2, wrapAngle2, normalizeField, normalize, isRecord, same, equal, Spring, springMotion2, springResponse, springEasing, springCss2, RESHAPE, RESET_KEYS, REFRESHES, readoutState, pushReadout, READOUT, memoryStorage2, browserStorage, urlFlag2, storageKey };
|
|
1141
|
+
export { __preload, __chunks, maxChroma, oklchToRgb, parseColor2, fitColor, formatColor, colorHex2, EASINGS2, EASING_Y, easingName, bezierPoint2, bezierCss2, normalizeCombo2, letterOf2, comboFromEvent2, heldMods, matchesCombo2, comboParts2, comboLabel2, isTypingTarget, GRADIENT_SAMPLES, sampleGradient2, gradientSamples, gradientCss2, stopColorAt, sampleCurve2, curveSamples, CURVE_SHAPES2, inRange, sameRamp, randomStops, mutateStops, randomPoints, mutatePoints, pickMeta, isReadout2, hasValue2, SPRING2, valueKeys, knob2, choice2, action2, flatSpring2, toggle2, spring2, color2, point2, easing2, vec22, vec32, range2, angle2, seed2, keybind2, gradient2, curve2, toolbarShortcut2, actionShortcut2, toolbar2, watch2, sparkline2, graph2, meter2, table2, log2, wrapAngle2, normalizeField, normalize, isRecord, same, equal, Spring, springMotion2, springResponse, springEasing, springCss2, RESHAPE, RESET_KEYS, REFRESHES, readoutState, pushReadout, READOUT, memoryStorage2, browserStorage, urlFlag2, storageKey };
|