@kitlangton/knobs 0.4.0-next.16 → 0.4.0-next.17

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 CHANGED
@@ -458,7 +458,7 @@ Tune them with tokens on the panel or any ancestor:
458
458
 
459
459
  With more than one thing on the page to label (two panels, a panel beside a `knobs/ui` tree,
460
460
  timeline or split, or the host's own `data-hint` buttons), `s` reaches all of them: pressed in a
461
- panel or anywhere outside a text field, every visible control in every panel (rows, tabs, toolbar
461
+ panel or a piece, every visible control in every panel (rows, tabs, toolbar
462
462
  buttons, header buttons), each tree row on screen, a timeline's Play, markers and playhead, the
463
463
  splits' header actions, collapse buttons and rails, and the host's elements gets a code. Typing one
464
464
  focuses its control (a row) or presses it (a button, a marker, a tree row); `Backspace` takes a
@@ -470,7 +470,9 @@ then the pieces, then the host's; down each column, left to right; home row firs
470
470
  a second letter, home row first, so `a` then `s` is the first panel's second control. A group of
471
471
  one is just its letter; spare letters split a big group so its second letters stay on the home row.
472
472
  With only one panel on the page, nothing changes: `s` works while focus is in it, with codes from
473
- its labels.
473
+ its labels. On the page itself (focus on its body, a game's canvas, your content in a split's
474
+ pane) `s` stays the page's, so a game's WASD keeps its S. It starts hint mode there only when the
475
+ host asks: it registers targets of its own (`hints.register`) or marks elements `data-hint`.
474
476
 
475
477
  ```ts
476
478
  import { hints } from "knobs/panel" // or "knobs/ui"
@@ -641,9 +643,13 @@ Without Solid: `remote.update(descriptors, values, { land })` and `mountPanel(re
641
643
  the row. They switch both ways as the width changes; `display: "menu" | "segmented"` fixes one.
642
644
  On a menu row, ←/→ step through the options, and Enter or Space opens the menu (↑↓, a letter,
643
645
  Enter, Esc).
644
- - **Keys stay scoped.** An inline panel hears keys only while focus is inside it. Keys it handles
645
- are claimed (`preventDefault`) and stopped there, and everything else reaches the page. Esc hands
646
- focus back. `keys` remaps (`{ hint: "f" }`) or turns off (`{ random: false }`) any command; a
646
+ - **Keys stay scoped.** A panel, inline or floating, hears keys only while focus is inside it. Keys
647
+ it handles are claimed (`preventDefault`) and stopped there, and everything else reaches the page.
648
+ A press on the page outside it hands focus back to the page and leaves the panel open, even where
649
+ the page cancels its presses so focus can't move (a game's canvas): play with the panel open, and
650
+ click back into it to tune. A floating panel opens with focus at its own level, so one Esc closes
651
+ it and ↓ goes in to the rows. Esc hands focus back (a floating panel's Esc at its own level closes
652
+ it), and the page's toggle key (`` ` ``) shows and hides it from anywhere. `keys` remaps (`{ hint: "f" }`) or turns off (`{ random: false }`) any command; a
647
653
  command that's off loses its header button too, and the `?` popover and row menu list only what's
648
654
  on (see **Header** for picking buttons). `vim: false` drops j/k, `edit: false` leaves digits
649
655
  and Enter to the page, `bigStep` sets ⇧'s multiplier and `wrap: false` stops ↑/↓ at the ends.
@@ -748,7 +754,10 @@ mountPanel(station, column, {
748
754
  `"help"` to keep it (`buttons: ["random", "reset", "copy", "undo", "redo", "help", "close"]`); a
749
755
  `buttons` list that already names `"help"`, as the Lab's does, behaves as before.
750
756
  The buttons sit in fixed slots, left to right Undo · Redo, Reset, Copy, Random (then `?` and
751
- the close X): a list picks which show, not their order. A button left out takes no slot; Undo,
757
+ the close X): a list picks which show, not their order. A die (the header's, or a group's) shows
758
+ only where Random could change something; a store or group of switches, actions and
759
+ `random: false` fields has none, and locks only disable it. Copy falls back to the browser's copy
760
+ command where the clipboard API isn't there (a page served over plain http). A button left out takes no slot; Undo,
752
761
  Redo and Reset keep theirs while they're away (`data-shown="false"`, `disabled`, out of the
753
762
  tab order and the accessibility tree).
754
763
  - **On a surface.** An inline panel in a split's pane (or inside anything with the class
@@ -775,7 +784,9 @@ mountPanel(station, column, {
775
784
  always did. Where scroll-driven animations aren't supported, the header doesn't pin.
776
785
  - **Folding groups.** A field's `folded: true` (or `groupFolded` on the mount) makes its group
777
786
  foldable, starting folded. Its header folds and opens it with a click, Enter or Space; it's a ↑↓
778
- stop of its own, ← folds and → opens. `panel.fold(group, folded?)` does it from code. On a
787
+ stop of its own, ← folds and → opens. `panel.fold(group, folded?)` does it from code. Group
788
+ and sub-heading folds the user chose come back on the next visit, in the store's storage (with
789
+ `session: false` or `persist: "none"`, they don't), over `folded` and `groupFolded`. On a
779
790
  [full page](#full-page) the group's tile folds to its header, the cards below follow, and the
780
791
  filter opens it while it runs. Folded, the header sums the card up on its right, quietly and
781
792
  live: a one-row card's value ("CSS orb"), otherwise its matching group preset (or "Custom"),
@@ -785,6 +796,10 @@ mountPanel(station, column, {
785
796
  - **Card descriptions.** A group's `groupDescription` (on any of its fields; the first wins) or the
786
797
  store's `groupDescriptions` puts one muted line under its card's title, wrapping under the whole
787
798
  header. Search matches it.
799
+ - **Row descriptions.** A field's `description` (`knob(260, { description: "How fast the square
800
+ moves" })`, or on a descriptor) is a tooltip over its row: it shows after the tooltips' usual
801
+ pause over the row, or at once on keyboard focus, and goes with a press. Screen readers hear it
802
+ as the control's description (`aria-describedby`), and search matches it.
788
803
  - **What's active.** `onActive: (at) => …` hears which card or row the pointer or keyboard focus is
789
804
  on: `{ group }` for a card's header or body, `{ group, key }` for a row, `null` for nothing
790
805
  (pinned cards and group preset strips count). Whichever moved last wins; it's called at most once
@@ -979,6 +994,7 @@ A panel inside an app should look like the app's own controls. Four things get i
979
994
  | `--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 |
980
995
  | `--k-font-weight`, `--k-label-weight`, `--k-title-weight` | `400`, `500`, `600` | Body, row labels, and titles, group headings and the chosen chip |
981
996
  | `--k-radius`, `--k-panel-radius` | `8px` by density, radius + `4px` | Corners. `0` squares everything, the pill shapes (switches, handles, dots) included |
997
+ | `--k-radius-frame` | radius + padding + border | A panel's own outer corners, concentric with the cards, preset strip and lens inside it |
982
998
  | `--k-thumb` | `#fff` | Switch thumbs and strip handles |
983
999
  | `--k-lift` | `1` | `0` drops the small shadows under pills, thumbs and handles |
984
1000
  | `--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) |
@@ -1341,7 +1357,7 @@ compare(viewer, { before, after, timeline: clock })
1341
1357
  compareStrip(strip, { before, after, timeline: clock })
1342
1358
  ```
1343
1359
 
1344
- **`compare(container, options)`** is a before/after viewer for videos and images (by extension, or `kind`). It offers two modes, side by side (the default `mode`: a split screen down the middle, each side filling its half) and a wipe; `modes: ["side", "wipe", "difference"]` adds a difference (changes above `threshold` in one accent, `--k-compare-diff`), and one mode hides the bar. The modes sit in a segmented control above the stage, the selection pill sliding to the one picked. It draws in 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 pill on a dark scrim in the corner shows how far, with a reset (`0`, a double-click, `Escape`), which glides back. Side ↔ Wipe is Kit Motion's Comparison fuse: the two sides glide together on one spring (.3 s) and fuse at one side's size in the middle, so the stage never changes size; Before is cut only where they overlap, with a feather that swells mid-transition and a blurred strip over the seam, the wipe's handle fades in with it, and the labels fold to their names and ride either side of the seam (Wipe → Side runs it back, and either way it can be turned around midway). 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 one of its own: a player's thin progress line under the stage (`variant: "line"`, the same as `player()`'s), its markers (a walkthrough's chapters) segments with a small gap between; there's no clock or frame readout anywhere, only the chapter and time under the pointer over the line. Its row starts with a minimal play/pause button; a click on the pictures (not a wipe, pan or pinch) and `Space` play and pause too, with no glyph flashing over them. Two stills get no button and no line. `seek(t, { glide: true })` is a chapter's jump: the videos land at once and the playhead catches up. Loop (`L`, the loop icon) sets a loop range around the playhead on the timeline, whose ends drag, and playback stays inside it. The wipe's line carries a 16×28 grip on the same scrim in its middle that follows the pointer's height only while dragged and settles back when let go; dragging is 1:1, the cursor hides and the line and the grip's marks brighten, and a press elsewhere on the stage brings the line there on a quick catch-up (.15 s). Before and After ride either side of the seam while wiping, each fading as its side runs out of room. The wipe is a slider too (`←`/`→`, `⇧` 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`–`3` pick the offered modes in order and `S` `W` (`D`, when offered) by name, `Z` held shows the loupe (or `loupe: true`), `L` loops, `=`/`-`/`0` zoom. It reports `onMode`, `onWipe` and `onView({ zoom, loop })`; the handle has `mode`, `modes`, `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.
1360
+ **`compare(container, options)`** is a before/after viewer for videos and images (by extension, or `kind`). It offers two modes, side by side (the default `mode`: a split screen down the middle, each side filling its half) and a wipe; `modes: ["side", "wipe", "difference"]` adds a difference (changes above `threshold` in one accent, `--k-compare-diff`), and one mode hides the bar. The modes sit in a segmented control above the stage, the selection pill sliding to the one picked. It draws in 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 pill on a dark scrim in the corner shows how far, with a reset (`0`, a double-click, `Escape`), which glides back. Side ↔ Wipe is Kit Motion's Comparison fuse: the two sides glide together on one spring (.3 s) and fuse at one side's size in the middle, so the stage never changes size; Before is cut only where they overlap, with a feather that swells mid-transition and a blurred strip over the seam, the wipe's handle fades in with it, and the labels fold to their names and ride either side of the seam (Wipe → Side runs it back, and either way it can be turned around midway). 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 shows the frame under the hand 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 cache shared by every viewer of the same source: a grid of small frames first, then every frame as a small JPEG proxy (960 px, at most 48 MB a source, every 2nd or 4th frame past that), and mid-drag the proxy of the frame under the pointer decodes in a few ms, with the one the hand is heading for; the videos wait and seek exactly when it lets go. `player()` does the same over its video once the pointer is over it. 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 one of its own: a player's thin progress line under the stage (`variant: "line"`, the same as `player()`'s), its markers (a walkthrough's chapters) segments with a small gap between; there's no clock or frame readout anywhere, only the chapter and time under the pointer over the line. Its row starts with a minimal play/pause button; a click on the pictures (not a wipe, pan or pinch) and `Space` play and pause too, with no glyph flashing over them. Two stills get no button and no line. `seek(t, { glide: true })` is a chapter's jump: the videos land at once and the playhead catches up. Loop (`L`, the loop icon) sets a loop range around the playhead on the timeline, whose ends drag, and playback stays inside it. The wipe's line carries a 16×28 grip on the same scrim in its middle that follows the pointer's height only while dragged and settles back when let go; dragging is 1:1, the cursor hides and the line and the grip's marks brighten, and a press elsewhere on the stage brings the line there on a quick catch-up (.15 s). Before and After ride either side of the seam while wiping, each fading as its side runs out of room. The wipe is a slider too (`←`/`→`, `⇧` 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`–`3` pick the offered modes in order and `S` `W` (`D`, when offered) by name, `Z` held shows the loupe (or `loupe: true`), `L` loops, `=`/`-`/`0` zoom. It reports `onMode`, `onWipe` and `onView({ zoom, loop })`; the handle has `mode`, `modes`, `wipe`, `zoom`, `loop`, `time`, `playing`, `duration`, `renderer`, `timeline`, `update`, `seek`, `seekBy`, `play`, `pause`, `toggle`, `step`, `setZoom`, `setLoop`, `focus` and `dispose`. **`compareStrip(container, { before, after, timeline })`** is a frame strip 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`.
1345
1361
 
1346
1362
  **`player(container, { src, chapters? })`** is a plain video in the same look: the picture in the compare's frame, then a row under it: a minimal play/pause button (the filled pair, a 28px target, a quiet fill on hover) and one thin progress line (2px, the tertiary text colour filled in text colour, a segment per chapter `{ label, start }`, thicker under the pointer). The button, a click on the picture, or `Space` once it's focused (a click focuses it) plays and pauses, with nothing flashing over the picture; click or drag the line to seek, 1:1, and a click away from where it is glides there while the video lands at once. The chapter and time under the pointer float over the line; `←`/`→` seek 5 s (`⇧` 10 s), and a pinch, `⌘`/ctrl-wheel or `=`/`-` zoom the line as a lane's. The picture comes in with its first frame (a 4px blur and a fade). It takes `poster`, `captions` (WebVTT), `loop` (true), `muted` (true), `aspect` and `fps`, reports `onPlay(playing, time)` and `onSeek(time)` (a seek that landed, paused), and the handle has `video`, `timeline`, `time`, `playing`, `duration`, `play`, `pause`, `toggle`, `seek(t, { glide })`, `seekBy`, `focus` and `dispose`. Under it is `timeline(…, { variant: "line" })`, which you can bind to anything else with a clock.
1347
1363
 
@@ -1512,7 +1528,7 @@ A section's meta line leads with its **kicker** in text colour: `"PR 2 of 5"` by
1512
1528
 
1513
1529
  By default the page only shows: no progress, notes or Copy feedback, no key hints, no statuses (a section's or an update's) and no counts, and a step's `options` sit side by side under their labels. `review: true` brings Kit's review tools back: the progress in the rail and its state dots, statuses, Copy feedback, picking between options (`1`–`9`) and the note bar. With review on, notes come from a launcher in the column's corner (`noteBar: "collapsed"`, the default) that opens into the bar on a click, `n`, a recording or ink, and folds back when it's empty; `"docked"` keeps the bar there, `"hidden"` drops it (`w.send()` still works). Typed, spoken or inked, each note carries what was on show (a video's time and chapter, a compare's loop and zoom, a diff's file) and a short log of what led up to it; `onFeedback`, `w.feedback` and `w.markdown()` hand it back. The page keeps the bar's height clear at its end and as its scroller's `scroll-padding-bottom`, so `j` / `k`, `go()` and the last step never land under it, and full window stops above it. `j` / `k` move between steps (`Home` / `End` to the first and last), `←` / `→` seek, `f` goes full window, `n` writes a note, `1`–`9` pick.
1514
1530
 
1515
- Media is a bare URL (a video or picture), or `compare`, `video`, `image`, `diff`, `code`, `terminal`, `metric`, `callout`, `text`, `facts`, `decision`, `history`, `stack`, `table`, or `mount` (your own, `(el) => cleanup`; the one that isn't JSON). A step's media sit one under another (a group's room apart); `columns: 2` sets them side by side where there's room. Every picture, video and compare stage (and a slot still loading) takes the same frame: the panel radius and a quiet fill, no ring. A diff, code and a terminal sit in one plain well, drawn by its hairline alone (no fill), the panel radius and room inside: a box is a fill or a ring, never both. Nothing in a walkthrough is glass. A metric, a callout, facts, a decision, a table, a stack and a history sit on the page. A slot's `label` reads as written (sentence case), small and faint above it.
1531
+ Media is a bare URL (a video or picture), or `compare`, `video`, `image`, `diff`, `code`, `terminal`, `metric`, `callout`, `text`, `facts`, `decision`, `history`, `stack`, `table`, or `mount` (your own, `(el) => cleanup`; the one that isn't JSON). A step's media sit one under another (a group's room apart); `columns: 2` sets them side by side where there's room. Every picture, video, compare stage, code block and terminal (and a slot still loading) takes the same frame: the panel radius and a quiet fill, no ring. A diff sits in one plain well, drawn by its hairline alone (no fill), the panel radius and room inside: a box is a fill or a ring, never both. Nothing in a walkthrough is glass. A metric, a callout, facts, a decision, a table, a stack and a history sit on the page. Any slot's `label` (a metric's name, facts' `title`) is one line above it, the same for every kind: as written (sentence case), small and faint, with no rule between it and the media; a history's `label` is only its accessible name.
1516
1532
 
1517
1533
  **Keep it scannable.** A step's title is its claim, in about ten words; its caption is one sentence; everything else goes in `more` (Markdown, blank lines between paragraphs), folded under the caption behind a small **More** toggle whose height springs open on the state spring while the words come into focus (a fade with a small blur, from the top). Sections take `more` under their summary, and the page under its own. Media that mounts after the first paint (near the view, or a picture still loading) comes in the same way; nothing moves on load.
1518
1534
 
@@ -1644,6 +1660,8 @@ Changing a token glides: colours, lengths, weights, glass and glow move on the p
1644
1660
 
1645
1661
  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.
1646
1662
 
1663
+ **Moving it.** Drag a floating panel by its header (the title or the empty space beside the buttons): it follows the pointer 1:1, shows the closed hand only while it's carried, and stays where it's let go. The spot is kept with the store's values (`persist: "all"`), by the nearer side: a panel left on the right half keeps its distance from the right edge as the window changes width. It always shows inside the window, and on a phone (420px wide or less) it takes the width less an 8px gutter and scrolls inside the height. An inline panel (`mount`, `mountPanel`) belongs to its host's layout and doesn't move: lifting it out would leave its container's clipping, stacking and token overrides behind. For a panel you can move, open a floating one: `controls.attach(el)` puts a chip on `el`, and `createPanel(controls, anchor, close)` from `knobs/panel` opens one beside `anchor` from your own button (a debug bar's, say), with the same drag and kept spot.
1664
+
1647
1665
  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.
1648
1666
 
1649
1667
  ### Reacting to Random, Reset and presets
@@ -1694,6 +1712,7 @@ The page roles (**Space**: `--k-space-inside` 4 · `sibling` 8 · `title` 12 ·
1694
1712
  |---|---|---|---|---|
1695
1713
  | `--k-radius` | input | `8px` | `8px` | Control corner radius. 0 squares everything, including capsules. Scales with density. |
1696
1714
  | `--k-radius-panel` | semantic | `calc(var(--k-radius) + var(--k-unit))` | `calc(var(--k-radius) + var(--k-unit))` | Panel and card radius, concentric with controls. Derived: `calc(var(--k-radius) + var(--k-unit))`. |
1715
+ | `--k-radius-frame` | semantic | `calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))` | `calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))` | A tuning panel's outer corners: its cards' radius plus its padding and border, so the cards, the preset strip and the focus lens inside run concentric. Squares when --k-radius is 0. Derived: `calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))`. |
1697
1716
  | `--k-round` | semantic | `calc(var(--k-radius) * 999)` | `calc(var(--k-radius) * 999)` | Capsule radius that squares when --k-radius is 0. Use instead of 999px. Derived: `calc(var(--k-radius) * 999)`. |
1698
1717
 
1699
1718
  ### Size
@@ -482,6 +482,7 @@ function mutatePoints(points, range, random, share) {
482
482
  // src/core/schema.ts
483
483
  var META_KEYS = [
484
484
  "label",
485
+ "description",
485
486
  "group",
486
487
  "groupLabel",
487
488
  "section",
@@ -10,7 +10,7 @@ import {
10
10
  DEFAULT_LINEAR,
11
11
  motionCss,
12
12
  storageKey
13
- } from "./chunk-v2g91mrm.js";
13
+ } from "./chunk-2g0znytq.js";
14
14
 
15
15
  // src/core/theme.ts
16
16
  var property = (k) => k.startsWith("--") ? k : `--k-${k}`;
@@ -1272,6 +1272,14 @@ var theme = { from: "theme" }, css = (s) => ({ css: s }), value = (s) => ({ valu
1272
1272
  doc: "Panel and card radius, concentric with controls.",
1273
1273
  deprecated: ["--k-panel-radius", "--ku-panel-radius"]
1274
1274
  },
1275
+ {
1276
+ name: "radius-frame",
1277
+ tier: "semantic",
1278
+ type: "length",
1279
+ group: "Shape",
1280
+ source: css("calc(var(--k-radius) + min(var(--k-radius) * 999, var(--_k-pad) + 1px))"),
1281
+ doc: "A tuning panel's outer corners: its cards' radius plus its padding and border, so the cards, the preset strip and the focus lens inside run concentric. Squares when --k-radius is 0."
1282
+ },
1275
1283
  {
1276
1284
  name: "round",
1277
1285
  tier: "semantic",
@@ -2294,7 +2302,16 @@ var COLOR_TOKENS = [
2294
2302
  "rim",
2295
2303
  "border-subtle",
2296
2304
  "bg-page"
2297
- ], LENGTH_TOKENS = ["pad", "inset", "row", "gap", "radius", "radius-panel", "font-size"], NUMBER_TOKENS = ["weight-label", "blur", "glow", "glass", "glass-tone"], ANIMATED = [...COLOR_TOKENS, ...LENGTH_TOKENS, ...NUMBER_TOKENS, "width"], TOKEN_TRANSITIONS = ANIMATED.map((t) => `--kt-${t} var(--k-token-t)`).join(","), source = (t) => t === "blur" ? "--k-blur" : `--_k-${t}`, register = (names, syntax, initial) => names.map((n) => `@property --kt-${n}{syntax:"${syntax}";inherits:false;initial-value:${initial}}`).join(`
2305
+ ], LENGTH_TOKENS = [
2306
+ "pad",
2307
+ "inset",
2308
+ "row",
2309
+ "gap",
2310
+ "radius",
2311
+ "radius-panel",
2312
+ "radius-frame",
2313
+ "font-size"
2314
+ ], NUMBER_TOKENS = ["weight-label", "blur", "glow", "glass", "glass-tone"], ANIMATED = [...COLOR_TOKENS, ...LENGTH_TOKENS, ...NUMBER_TOKENS, "width"], TOKEN_TRANSITIONS = ANIMATED.map((t) => `--kt-${t} var(--k-token-t)`).join(","), source = (t) => t === "blur" ? "--k-blur" : `--_k-${t}`, register = (names, syntax, initial) => names.map((n) => `@property --kt-${n}{syntax:"${syntax}";inherits:false;initial-value:${initial}}`).join(`
2298
2315
  `);
2299
2316
  var DRIVERS = `${register(COLOR_TOKENS, "<color>", "transparent")}
2300
2317
  ${register(LENGTH_TOKENS, "<length>", "0px")}
@@ -2426,13 +2443,15 @@ ${GRAD_PROPS}
2426
2443
  catches its top. The light rides on the edge (a shadow raised by its width, so only its top
2427
2444
  shows), not inside the card's 1px border, where it read as a second edge. */
2428
2445
  .knobs-panel{-webkit-user-select:none;user-select:none;transition:${TOKEN_TRANSITIONS},--k-armed ${ARMED_T};position:fixed;z-index:2147480001;width:var(--_k-width);max-width:calc(100vw - 16px);max-height:calc(100dvh - 16px);overflow:auto;padding:var(--_k-pad);
2429
- background:color-mix(in srgb,var(--_k-bg) calc(100% - var(--_k-glass) * 20%),transparent);border:1px solid transparent;border-radius:var(--_k-radius-panel);box-shadow:var(--_k-shadow-overlay),0 calc(var(--k-rim-width) * -1) 0 var(--_k-rim),0 0 0 var(--k-rim-width) var(--ka-edge),var(--ka-glow);scrollbar-width:thin;
2446
+ background:color-mix(in srgb,var(--_k-bg) calc(100% - var(--_k-glass) * 20%),transparent);border:1px solid transparent;border-radius:var(--_k-radius-frame);box-shadow:var(--_k-shadow-overlay),0 calc(var(--k-rim-width) * -1) 0 var(--_k-rim),0 0 0 var(--k-rim-width) var(--ka-edge),var(--ka-glow);scrollbar-width:thin;
2430
2447
  /* Armed (focus inside: keys come here), the edge takes the accent and a faint glow lifts it. */
2431
2448
  --ka-edge:color-mix(in srgb,var(--_k-accent) calc(var(--k-armed) * var(--k-armed-edge) * 100%),var(--_k-border-subtle));
2432
2449
  --ka-glow:0 0 calc(var(--k-armed-glow) * 1px) color-mix(in srgb,var(--_k-accent) calc(var(--k-armed) * var(--k-armed-glow-a) * var(--k-chrome,1) * 100%),transparent);
2433
2450
  --k-backdrop:blur(calc(var(--_k-glass) * 24px)) saturate(calc(1 + var(--_k-glass) * .8)) brightness(calc(1 + (var(--_k-glass-tone) - 1) * var(--_k-glass)));
2434
2451
  -webkit-backdrop-filter:var(--k-backdrop);backdrop-filter:var(--k-backdrop);
2435
2452
  animation:knobs-in .16s var(--k-ease);}
2453
+ /* On a phone a floating panel takes the width, less a gutter, and scrolls inside the height. */
2454
+ @media(max-width:420px){.knobs-panel:not(.knobs-inline){width:calc(100vw - 16px)}}
2436
2455
  .knobs-panel[data-restored]{animation:none}
2437
2456
  /* Opening from its chip, the panel's chrome rides a separate shape (see morph.ts) while the
2438
2457
  content, laid out at full size, is clipped to it and revealed top first. */
@@ -2466,8 +2485,9 @@ ${GRAD_PROPS}
2466
2485
  :is(.knobs-panel,.knobs-menu) :is(:focus,:focus-visible):where(:not(.knobs-accessory *)){outline:none}
2467
2486
 
2468
2487
  /* Header */
2469
- .knobs-panel header{position:relative;display:flex;align-items:center;justify-content:space-between;gap:6px;cursor:grab;touch-action:none;padding:0 var(--_k-inset) var(--k-section-gap);min-height:var(--k-icon-btn)}
2470
- .knobs-panel.knobs-inline header,.knobs-panel.knobs-inline header:active{cursor:default}
2488
+ /* A floating panel's header carries it; the hand shows only while it's carrying (data-dragging). */
2489
+ .knobs-panel header{position:relative;display:flex;align-items:center;justify-content:space-between;gap:6px;touch-action:none;padding:0 var(--_k-inset) var(--k-section-gap);min-height:var(--k-icon-btn)}
2490
+ .knobs-panel[data-dragging]>header,.knobs-panel[data-dragging]>header *{cursor:grabbing}
2471
2491
  /* The title row stays pinned while the rows scroll under it, in the panel (it scrolls once it's
2472
2492
  bounded) or, with .knobs-pin-ancestor (which also makes the panel overflow:visible), in the
2473
2493
  nearest scroller around it. Once rows are under it, and only then, it frosts (backdrop blur over
@@ -2482,7 +2502,7 @@ ${GRAD_PROPS}
2482
2502
  @supports (animation-timeline:scroll()){
2483
2503
  .knobs-panel:not(.knobs-page){scroll-timeline:--knobs-self block}
2484
2504
  .knobs-panel.knobs-pin-ancestor:not(.knobs-page){overflow:visible;view-timeline:--knobs-view block}
2485
- .knobs-panel:not(.knobs-page)>header{position:sticky;top:0;z-index:4;border-radius:max(0px,calc(var(--_k-radius-panel) - 1px)) max(0px,calc(var(--_k-radius-panel) - 1px)) 0 0;
2505
+ .knobs-panel:not(.knobs-page)>header{position:sticky;top:0;z-index:4;border-radius:max(0px,calc(var(--_k-radius-frame) - 1px)) max(0px,calc(var(--_k-radius-frame) - 1px)) 0 0;
2486
2506
  --k-head-on:max(var(--k-head-s),var(--k-head-v));--k-head-bg:color-mix(in srgb,var(--_k-bg) 88%,transparent);
2487
2507
  background:color-mix(in srgb,var(--k-head-bg) calc(var(--k-head-on) * 100%),transparent);
2488
2508
  animation:knobs-head-s linear both;animation-timeline:--knobs-self;animation-range:0px 24px}
@@ -2492,7 +2512,6 @@ ${GRAD_PROPS}
2492
2512
  .knobs-panel[data-gpu-active="true"]:not(.knobs-page)>header{background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important}
2493
2513
  .knobs-panel[data-gpu-active="true"]:not(.knobs-page)>header::after{display:none}
2494
2514
  }
2495
- .knobs-panel header:active{cursor:grabbing}
2496
2515
  .knobs-panel header strong{flex:1;min-width:0;font-weight:var(--_k-weight-title);font-size:var(--_k-text-title);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:opacity .2s var(--k-ease),color ${ARMED_T};
2497
2516
  color:color-mix(in srgb,var(--_k-fg) calc(var(--k-title-rest) * 100%),var(--_k-fg-2))}
2498
2517
  /* At rest a panel's title is a little quiet; armed (keys come here) it's at full strength. */
@@ -4118,11 +4137,22 @@ function createRegistry(doc) {
4118
4137
  return;
4119
4138
  if (e.metaKey || e.ctrlKey || e.altKey || e.key !== PAGE_HINT_KEY)
4120
4139
  return;
4121
- if (isTyping(e.target) || !wide())
4140
+ if (isTyping(e.target) || !wide(e.target))
4122
4141
  return;
4123
4142
  if (registry.start())
4124
4143
  e.preventDefault();
4125
- }, wide = () => listening && (sources.length > 1 || [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel"))), relisten = () => {
4144
+ }, hostHints = () => [...doc.querySelectorAll("[data-hint]")].some((el) => !el.closest(".knobs-panel")), wide = (target) => {
4145
+ if (!listening)
4146
+ return !1;
4147
+ if (sources.some((s) => s.listen && s.rank >= 2) || hostHints())
4148
+ return !0;
4149
+ if (sources.length < 2)
4150
+ return !1;
4151
+ if (target === void 0)
4152
+ return !0;
4153
+ let piece = target?.closest?.(".knobs-ui,.knobs-pane-body");
4154
+ return Boolean(piece?.classList.contains("knobs-ui"));
4155
+ }, relisten = () => {
4126
4156
  let next = sources.some((s) => s.listen);
4127
4157
  if (next === listening)
4128
4158
  return;