@kitlangton/knobs 0.4.0-next.12 → 0.4.0-next.13

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
@@ -369,7 +369,7 @@ The preset you apply last (a chip, `[` / `]`, a pan, `controls.preset(id)`, `var
369
369
  - **The `?` popover says so:** "Random around Storm / Back to Storm", then "Reset all to defaults" once you're there.
370
370
  - **Undo covers it:** applying a preset, each reset and each roll is one undo step, and undoing restores the active preset with the values. The active preset is kept with the values in storage.
371
371
 
372
- `controls.activePreset()` returns `{ kind: "preset" | "variant", id }` or null, `controls.activeGroupPreset(group)` the group's index or -1, `controls.baseValues()` what Reset returns to first, and `controls.canReset(keys?)` whether a reset would change anything. A reset back to a preset reaches `onChange` with `source: "reset"` and `base: true`; remote controls send its values with `onSet` (nulls only for a reset to the defaults), or call `onPreset` again for a group whose peer applies its own presets.
372
+ `controls.activePreset(scope?)` returns `{ kind: "preset" | "variant", id }` or null (a tab's or group's with `scope`, see `scopedPresets` under [Tabs](#tabs-sub-headings-and-search)), `controls.activeGroupPreset(group)` the group's index or -1, `controls.baseValues()` what Reset returns to first, and `controls.canReset(keys?)` whether a reset would change anything. A reset back to a preset reaches `onChange` with `source: "reset"` and `base: true`; remote controls send its values with `onSet` (nulls only for a reset to the defaults), or call `onPreset` again for a group whose peer applies its own presets.
373
373
 
374
374
  ## Keyboard
375
375
 
@@ -431,14 +431,14 @@ Three things show which, in both renderers, on floating and inline panels and on
431
431
  its hairline edge brightens a touch toward the accent and its title is at full strength. At rest
432
432
  the title is a little quieter, so on a page with several panels the one listening is plain.
433
433
  Focus leaving (or `Esc` out of the panel, or the window going to the background) disarms it.
434
- - **The scope line.** Beside the title, a quiet trail says where keys go: `Swarm › Shape ›
435
- Cohesion`, the deepest crumb bright and its parents dim. Crumbs glide to their places as focus
436
- moves, and one that changes fades out as the new one fades in. For a moment after a key below
434
+ - **The scope line** (opt-in: `crumbs: true` on the mount). Beside the title, a quiet trail says
435
+ where keys go: `Swarm › Shape › Cohesion`, the deepest crumb bright and its parents dim. Crumbs
436
+ glide to their places as focus moves, and one that changes fades out as the new one fades in. For a moment after a key below
437
437
  the panel's level, a faint `esc ↑ Shape` follows it, then fades. Clicking a crumb takes focus to
438
438
  its level; clicking the title takes it to the panel. The line lives in the room between the
439
439
  title and the header's buttons: when it doesn't fit, the first crumbs drop off, and without room
440
440
  for the deepest it stays empty, so it never runs under the buttons and the title never moves.
441
- `crumbs: false` on the mount drops the line (the lens and the armed frame stay).
441
+ Without it the header is the title and its buttons; the lens and the armed frame show either way.
442
442
 
443
443
  Tune them with tokens on the panel or any ancestor:
444
444
 
@@ -834,7 +834,21 @@ panel.tab("emitter") // shows a tab; panel.tab() answers the one showing
834
834
  preset strip, then the tab's rows. `controls.isPinned(key)` says whether a key is pinned.
835
835
  A pinned `variants: true` choice is an ordinary choice with a row: pinned wins, and its options
836
836
  make no presets. A tab whose rows no preset moves shows no preset strip; a variants choice moves
837
- every tab (or its own, if it names one).
837
+ its own tab (or every tab, if it has none).
838
+ - **Presets of a tab's own.** `scopedPresets` gives a tab (or a group) presets that belong to it:
839
+ `{ Sidebars: { snappy, smooth, calm } }`. Its strip shows only on that tab, and a tab without any
840
+ shows none. A scoped preset moves only its tab's (or group's) keys, never pinned ones or another
841
+ tab's, so each tab is its own experiment: it has its own active preset (Reset's base and
842
+ Random's centre for its keys), its own modified mark on the chip and its own saved variants
843
+ (`controls.scopes.Sidebars.variants`, kept apart in storage). A preset switch is one undo step.
844
+ A `variants: true` choice on a tab adds its options to that tab's presets. Keys belong to their
845
+ tab's scope first, then their group's. Plain `presets` then cover only the keys no scope holds;
846
+ leave them out and only the scoped strips show. One strip shows per tab: the tab's own, else the
847
+ first group scope on it. In code, `controls.preset(id, { scope })`, `activePreset(scope)` and
848
+ `matchingPreset(scope)` take the tab or group, and `controls.scopeOf(key)` says whose presets
849
+ move a key (`""` for plain `presets`, undefined when pinned). An active preset saved while the
850
+ presets were panel-wide comes back on the scope that now has one by that id, and variants saved
851
+ then start out in each scope they move.
838
852
 
839
853
  ```ts
840
854
  const motion = defineControls(
@@ -845,7 +859,12 @@ panel.tab("emitter") // shows a tab; panel.tab() answers the one showing
845
859
  sidebars: spring({ duration: 0.3, bounce: 0 }, { group: "Sidebars", tab: "Sidebars" }),
846
860
  hover: spring({ duration: 0.14, bounce: 0 }, { group: "Experiments", tab: "Experiments" }),
847
861
  },
848
- { pinned: ["Global"], defaultLabel: "Shipped", presets: { snappy, smooth, calm } },
862
+ {
863
+ pinned: ["Global"],
864
+ defaultLabel: "Shipped",
865
+ // Only the Sidebars tab has a strip, and its presets set only its springs.
866
+ scopedPresets: { Sidebars: { snappy, smooth, calm } },
867
+ },
849
868
  )
850
869
  motion.mount(el, { title: "Knobs", tabs: ["Sidebars", "Experiments"] })
851
870
  ```
@@ -2007,7 +2026,7 @@ The package has two core exports selected through the standard `development` con
2007
2026
  | `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 }`. |
2008
2027
  | 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. |
2009
2028
  | 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. |
2010
- | `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()`. |
2029
+ | `mountPanel(controls, container, options?)` (`knobs/panel`) | An inline panel. Beside theme, density, renderer and keys: `preset`, `header` (`false` or `{ title?, buttons? }`), `crumbs` (`true` adds 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()`. |
2011
2030
  | `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. |
2012
2031
  | `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. |
2013
2032
  | `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[] }`. |
@@ -2035,10 +2054,11 @@ The package has two core exports selected through the standard `development` con
2035
2054
  - `controls.randomize(keys?)` / `controls.mutate(keys?)`: skip locked fields; one undo step each
2036
2055
  - `controls.lock(keys, locked = true)` / `controls.isLocked(key)`: locks are saved with the values (remote controls keep them in memory)
2037
2056
  - `controls.compare(hold)` / `controls.comparing`: while held, `get()` returns the previous undo entry; no history, no storage
2038
- - `controls.preset(id, { coalesce? })` / `controls.matchingPreset()`
2039
- - `controls.activePreset()` / `controls.activeGroupPreset(group)` / `controls.baseValues()`: the base Reset returns to and Random explores around
2057
+ - `controls.preset(id, { coalesce?, scope? })` / `controls.matchingPreset(scope?)`
2058
+ - `controls.activePreset(scope?)` / `controls.activeGroupPreset(group)` / `controls.baseValues()`: the base Reset returns to and Random explores around
2059
+ - `controls.scopes` / `controls.scopeOf(key)`: the tabs and groups with presets of their own (`scopedPresets`), each `{ id, keys, presets, variants }`, and whose presets move a key
2040
2060
  - `controls.groupPresets` / `controls.groupPreset(group, index, { coalesce? })` / `controls.matchingGroupPreset(group)` / `controls.groupPresetDisplay`
2041
- - `controls.groupLabel(id)` / `controls.sectionLabel(id)` / `controls.presetLabel(id)`: display labels (`groupLabels`, `sectionLabels`, `presetLabels`, `defaultLabel`, or the fields' `groupLabel` / `sectionLabel`)
2061
+ - `controls.groupLabel(id)` / `controls.sectionLabel(id)` / `controls.presetLabel(id, scope?)`: display labels (`groupLabels`, `sectionLabels`, `presetLabels`, `defaultLabel`, or the fields' `groupLabel` / `sectionLabel`)
2042
2062
  - `controls.groupDescription(group): string | undefined` / `controls.sectionDescription(section): string | undefined`: `groupDescriptions` / `sectionDescriptions`, else the fields' `groupDescription` / `sectionDescription`
2043
2063
  - `controls.groupFolded` / `controls.sectionFolded`: how groups and a page's sections start, as given to `defineControls` or `createRemoteControls` (`groupFolded?: Record<string, boolean>`, `sectionFolded?: SectionFolded`); mounts read them under their own
2044
2064
  - `controls.code("ts" | "json" | "jsx", values?)`
@@ -6,7 +6,7 @@ import {
6
6
  writeSession,
7
7
  injectStyles,
8
8
  toggleLayer
9
- } from "./chunk-nwfm1bfj.js";
9
+ } from "./chunk-pdf3zy95.js";
10
10
 
11
11
  // src/panel/attach.ts
12
12
  function attach(controls, element, options = {}) {
@@ -82,7 +82,7 @@ function attach(controls, element, options = {}) {
82
82
  return;
83
83
  loading = !0;
84
84
  try {
85
- let { createPanel } = await import("./chunk-9qtvxa6k.js");
85
+ let { createPanel } = await import("./chunk-mbhy4jg5.js");
86
86
  if (disposed || !controls.enabled || panel)
87
87
  return;
88
88
  if (panel = createPanel(controls, element, close, { restored, chip }), remember)
@@ -119,7 +119,7 @@ import {
119
119
  createHintController,
120
120
  pageHints,
121
121
  toggleLayer
122
- } from "./chunk-nwfm1bfj.js";
122
+ } from "./chunk-pdf3zy95.js";
123
123
 
124
124
  // src/panel/die.ts
125
125
  var ROUND = 16 * Math.sqrt(5), DIE = { half: 64 + ROUND, round: ROUND, pip: 12 }, PIPS = {
@@ -960,8 +960,8 @@ function panOptions(el, o) {
960
960
 
961
961
  // src/panel/presets.ts
962
962
  var DRIFTED_PILL = 0.5;
963
- function activeEntryId(controls) {
964
- let ref = controls.activePreset();
963
+ function activeEntryId(controls, scope = "") {
964
+ let ref = controls.activePreset(scope);
965
965
  return ref ? `${ref.kind === "preset" ? "p" : "v"}:${ref.id}` : null;
966
966
  }
967
967
  function resolveActivePresetEntry(entries, currentValues, lastAppliedId, baseId = null) {
@@ -1012,7 +1012,7 @@ function nextPresetIndex(count, activeIndex, direction) {
1012
1012
  return direction > 0 ? 0 : count - 1;
1013
1013
  return ((activeIndex + direction) % count + count) % count;
1014
1014
  }
1015
- function createPresetStrip(doc, controls, showVariants, run, onAfterChange, presetLabel = (id) => controls.presetLabel(id), relevant) {
1015
+ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, presetLabel = (id, scope) => controls.presetLabel(id, scope), relevant, scopeOf = () => "") {
1016
1016
  let wake = () => onAfterChange?.(), el = (tag, cls, text) => {
1017
1017
  let node = doc.createElement(tag);
1018
1018
  if (cls)
@@ -1029,31 +1029,34 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1029
1029
  saveButton.type = "button", saveButton.tabIndex = -1, saveButton.setAttribute("aria-label", "Save as variant"), saveButton.setAttribute("aria-keyshortcuts", "Shift+S"), saveButton.dataset.hint = "Save", saveButton.onclick = () => run(save);
1030
1030
  if (track.append(pill), root.append(track), saveButton)
1031
1031
  root.append(saveButton);
1032
- let pillEdges = new Edges, pillY = new Spring(0, 28, 0.86), pillAlpha = new Spring(1, 26, 0.9), entries = [], buttons = [], signature = "", lastVariantsRef = null, lastAppliedId = null, activeState = { activeId: "p:default", activeIndex: 0, isCustom: !1 }, prevActiveId = "", prevActiveIndex = -2, prevIsCustom = !1, prevDrifted = !1, initializedPill = !1, slideUntil = 0, display = "auto", chipMin = 0, unpinned = (values) => {
1032
+ let pillEdges = new Edges, pillY = new Spring(0, 28, 0.86), pillAlpha = new Spring(1, 26, 0.9), entries = [], buttons = [], signature = "", lastVariantsRef = null, lastAppliedId = null, activeState = { activeId: "p:default", activeIndex: 0, isCustom: !1 }, prevActiveId = "", prevActiveIndex = -2, prevIsCustom = !1, prevDrifted = !1, initializedPill = !1, slideUntil = 0, display = "auto", chipMin = 0, scope = null, at = () => scope ?? "", source = () => scope === null ? void 0 : scope === "" ? { presets: controls.presets, variants: controls.variants } : controls.scopes[scope], unpinned = (values) => {
1033
1033
  let out;
1034
1034
  for (let k of Object.keys(values))
1035
- if (controls.isPinned(k) && !same(values[k], controls.locked[k]))
1035
+ if (controls.scopeOf(k) !== at() && !same(values[k], controls.locked[k]))
1036
1036
  out ??= { ...values }, out[k] = controls.locked[k];
1037
1037
  return out ?? values;
1038
1038
  };
1039
1039
  function buildEntries() {
1040
- let presetEntries = Object.entries(controls.presets).map(([id, presetValues]) => ({
1040
+ let from = source();
1041
+ if (!from)
1042
+ return [];
1043
+ let where = at(), presetEntries = Object.entries(from.presets).map(([id, presetValues]) => ({
1041
1044
  id: `p:${id}`,
1042
1045
  kind: "preset",
1043
1046
  rawId: id,
1044
- label: presetLabel(id),
1047
+ label: presetLabel(id, where),
1045
1048
  values: presetValues,
1046
- apply: (coalesce) => controls.preset(id, { coalesce })
1049
+ apply: (coalesce) => controls.preset(id, { coalesce, scope: where })
1047
1050
  }));
1048
1051
  if (!showVariants)
1049
1052
  return presetEntries;
1050
- let variantEntries = controls.variants.list().map((v) => ({
1053
+ let variantEntries = from.variants.list().map((v) => ({
1051
1054
  id: `v:${v.id}`,
1052
1055
  kind: "variant",
1053
1056
  rawId: v.id,
1054
1057
  label: v.name ?? v.id.slice(0, 8),
1055
1058
  values: unpinned(v.values),
1056
- apply: (coalesce) => controls.variants.load(v.id, { coalesce })
1059
+ apply: (coalesce) => from.variants.load(v.id, { coalesce })
1057
1060
  }));
1058
1061
  return [...presetEntries, ...variantEntries];
1059
1062
  }
@@ -1087,7 +1090,7 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1087
1090
  if (pill.style.left = `${left}px`, pill.style.right = `${box.trackWidth - right}px`, pill.style.top = `${top}px`, pill.style.height = `${box.height}px`, pill.style.opacity = activeState.drifted ? String(DRIFTED_PILL) : "1", pill.style.scale = "", first)
1088
1091
  doc.defaultView?.requestAnimationFrame(() => pill.style.transition = "");
1089
1092
  }
1090
- let resolve = () => resolveActivePresetEntry(entries, unpinned(controls.get()), lastAppliedId, activeEntryId(controls)), wanted = (list) => (list.length > 1 || showVariants) && (relevant?.(list) ?? !0);
1093
+ let resolve = () => resolveActivePresetEntry(entries, unpinned(controls.get()), lastAppliedId, activeEntryId(controls, at())), wanted = (list) => scope !== null && (list.length > 1 || showVariants) && (relevant?.(list) ?? !0);
1091
1094
  function syncShown() {
1092
1095
  let show = wanted(entries);
1093
1096
  if (root.hidden === show && root.dataset.presence === void 0)
@@ -1130,13 +1133,16 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1130
1133
  }
1131
1134
  function update() {
1132
1135
  run(() => {
1133
- let variantsRef = showVariants ? controls.variants.list() : null;
1134
- if (signature !== "" && variantsRef === lastVariantsRef) {
1136
+ let was = scope;
1137
+ if (scope = scopeOf(), scope !== was)
1138
+ lastAppliedId = null;
1139
+ let variantsRef = showVariants ? source()?.variants.list() ?? null : null;
1140
+ if (signature !== "" && scope === was && variantsRef === lastVariantsRef) {
1135
1141
  syncShown(), syncSelection(!1);
1136
1142
  return;
1137
1143
  }
1138
1144
  lastVariantsRef = variantsRef;
1139
- let nextEntries = buildEntries(), nextSig = JSON.stringify(nextEntries.map((e) => [e.id, e.label]));
1145
+ let nextEntries = buildEntries(), nextSig = JSON.stringify([scope, nextEntries.map((e) => [e.id, e.label])]);
1140
1146
  if (entries = nextEntries, syncShown(), nextSig !== signature)
1141
1147
  signature = nextSig, entries = nextEntries, buttons = entries.map((entry, idx) => {
1142
1148
  let b = el("button", "knobs-preset-chip", entry.label);
@@ -1175,11 +1181,14 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1175
1181
  }
1176
1182
  function save() {
1177
1183
  finishName(!0);
1178
- let used = new Set(controls.variants.list().map((v) => v.name)), n = 1;
1184
+ let variants = source()?.variants;
1185
+ if (!variants)
1186
+ return "";
1187
+ let used = new Set(variants.list().map((v) => v.name)), n = 1;
1179
1188
  while (used.has(`Variant ${n}`))
1180
1189
  n++;
1181
- let variant = controls.variants.save(`Variant ${n}`);
1182
- return controls.variants.load(variant.id), lastAppliedId = `v:${variant.id}`, update(), startName(entries.findIndex((e) => e.id === `v:${variant.id}`), !0), onAfterChange?.(), `Saved “Variant ${n}”. Type a name, or Enter to keep it.`;
1190
+ let variant = variants.save(`Variant ${n}`);
1191
+ return variants.load(variant.id), lastAppliedId = `v:${variant.id}`, update(), startName(entries.findIndex((e) => e.id === `v:${variant.id}`), !0), onAfterChange?.(), `Saved “Variant ${n}”. Type a name, or Enter to keep it.`;
1183
1192
  }
1184
1193
  let naming = null;
1185
1194
  function placeName() {
@@ -1223,7 +1232,7 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1223
1232
  focusQuietly();
1224
1233
  let message;
1225
1234
  if (keep && entry?.kind === "variant" && name && name !== was)
1226
- controls.variants.rename(entry.rawId, name), update(), message = `Named “${name}”.`;
1235
+ source()?.variants.rename(entry.rawId, name), update(), message = `Named “${name}”.`;
1227
1236
  return onAfterChange?.(), message;
1228
1237
  }
1229
1238
  function rename() {
@@ -1239,7 +1248,7 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
1239
1248
  let entry = entries[activeState.activeIndex];
1240
1249
  if (!entry || entry.kind !== "variant")
1241
1250
  return;
1242
- return controls.variants.remove(entry.rawId), lastAppliedId = null, update(), onAfterChange?.(), `Deleted “${entry.label}”.`;
1251
+ return source()?.variants.remove(entry.rawId), lastAppliedId = null, update(), onAfterChange?.(), `Deleted “${entry.label}”.`;
1243
1252
  }
1244
1253
  let scrubbing = !1, scrubCommitted = !1, chipIndexAt = (clientX, clientY) => {
1245
1254
  let best = -1, bestDist = 1 / 0;
@@ -2971,7 +2980,8 @@ fn hash21(q: vec2f) -> f32 {
2971
2980
 
2972
2981
  // kind 0: Panel card surface + elevation shadow + border + specular rim. With a hairline width
2973
2982
  // (extra.z, whole device px) the edge is that hairline just outside the card, like CSS's
2974
- // 0 0 0 w spread shadow, and the rim is CSS's inset 0 w 0; otherwise a 1px border and a 1px rim.
2983
+ // 0 0 0 w spread shadow, and the rim lights its top, like CSS's 0 -w 0 (one edge, not two);
2984
+ // otherwise a 1px border and a 1px rim inside it.
2975
2985
  fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
2976
2986
  let r = p.look.x;
2977
2987
  let dist = roundedBox(local, size, r);
@@ -2980,6 +2990,8 @@ fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
2980
2990
  var out = vec4f(0.0);
2981
2991
  if (hair > 0.0 && dist > -1.0) {
2982
2992
  out = premul(p.color2) * clamp(covSd(dist - hair) - bodyCov, 0.0, 1.0);
2993
+ let raised = covSd(roundedBox(local + vec2f(0.0, hair), size, r));
2994
+ out = over(out, premul(p.aux2) * clamp(raised - bodyCov, 0.0, 1.0));
2983
2995
  }
2984
2996
 
2985
2997
  // Floating drop shadow when glowOrBlur (look.z) > 0; like box-shadow, never under the card.
@@ -2993,13 +3005,16 @@ fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
2993
3005
 
2994
3006
  var card = premul(p.color) * bodyCov;
2995
3007
 
2996
- // The rim, built like CSS's inset 0 w 0: inside the border, minus that shape moved down w.
3008
+ // Without a hairline, the rim is CSS's inset 0 1px 0: inside the border, minus that shape
3009
+ // moved down.
2997
3010
  let inner = local - vec2f(1.0);
2998
3011
  let innerSize = size - vec2f(2.0);
2999
3012
  let w = select(1.0, hair, hair > 0.0);
3000
3013
  let innerCov = covSd(roundedBox(inner, innerSize, r - 1.0));
3001
- let rimCov = innerCov * (1.0 - covSd(roundedBox(inner - vec2f(0.0, w), innerSize, r - 1.0)));
3002
- card = over(card, premul(p.aux2) * rimCov);
3014
+ if (hair <= 0.0) {
3015
+ let rimCov = innerCov * (1.0 - covSd(roundedBox(inner - vec2f(0.0, w), innerSize, r - 1.0)));
3016
+ card = over(card, premul(p.aux2) * rimCov);
3017
+ }
3003
3018
 
3004
3019
  // A morphing card's specular ring (extra.y), w wide inside the border like CSS's inset 0 0 0 w.
3005
3020
  if (p.extra.y > 0.001) {
@@ -15047,7 +15062,7 @@ function buildPanel(controls, host, options) {
15047
15062
  }
15048
15063
  popover.append(popGrid);
15049
15064
  let baseAt = (scope, from) => {
15050
- let group = scope === "row" ? rows[rowAt(from)]?.group : scope === "group" ? cardOf(from)?.dataset.group ?? void 0 : void 0, keys = scope === "row" ? rows[rowAt(from)]?.keys ?? [] : scope === "group" ? cardKeys(cardOf(from)) : scope === "selection" ? selectionKeys() ?? [] : void 0, index = group === void 0 ? -1 : controls.activeGroupPreset(group), ref = controls.activePreset(), name = index >= 0 ? controls.groupPresets[group]?.[index]?.name : ref?.kind === "preset" ? presetLabel(ref.id) : ref ? presetStrip.getEntries().find((e) => e.id === `v:${ref.id}`)?.label : void 0;
15065
+ let group = scope === "row" ? rows[rowAt(from)]?.group : scope === "group" ? cardOf(from)?.dataset.group ?? void 0 : void 0, keys = scope === "row" ? rows[rowAt(from)]?.keys ?? [] : scope === "group" ? cardKeys(cardOf(from)) : scope === "selection" ? selectionKeys() ?? [] : void 0, index = group === void 0 ? -1 : controls.activeGroupPreset(group), where = (keys?.length ? controls.scopeOf(keys[0]) : stripScope()) ?? null, ref = where === null ? null : controls.activePreset(where), name = index >= 0 ? controls.groupPresets[group]?.[index]?.name : ref?.kind === "preset" ? presetLabel(ref.id, where) : ref ? (where === "" ? controls.variants : controls.scopes[where]?.variants)?.list().find((v) => v.id === ref.id)?.name : void 0;
15051
15066
  if (!name)
15052
15067
  return null;
15053
15068
  let off = keys ? keys.some((k) => controls.isModified(k)) : controls.isModified();
@@ -15141,7 +15156,7 @@ function buildPanel(controls, host, options) {
15141
15156
  if (helpButton?.hidden)
15142
15157
  titleEl.title = `${titleEl.textContent ? `${titleEl.textContent}
15143
15158
  ` : ""}Press ${show("help")} for keyboard shortcuts`;
15144
- let presetLabel = (id) => options.presetLabels?.[id] ?? (id === "default" ? options.defaultLabel : void 0) ?? controls.presetLabel(id), presetStrip = createPresetStrip(doc, controls, showVariants, run, () => wakeGpu(), presetLabel, (entries) => presetsTouchTab(entries)), tabStrip = createTabStrip(doc, (id, animate) => selectTab(id, animate)), activeTab, searchOptions = typeof options.search === "object" ? options.search : {}, chipTests = /* @__PURE__ */ new Map, search = options.search && !page ? createSearch(doc, {
15159
+ let presetLabel = (id, scope = "") => options.presetLabels?.[id] ?? (id === "default" ? options.defaultLabel : void 0) ?? controls.presetLabel(id, scope), presetStrip = createPresetStrip(doc, controls, showVariants, run, () => wakeGpu(), presetLabel, (entries) => presetsTouchTab(entries), () => stripScope()), tabStrip = createTabStrip(doc, (id, animate) => selectTab(id, animate)), activeTab, searchOptions = typeof options.search === "object" ? options.search : {}, chipTests = /* @__PURE__ */ new Map, search = options.search && !page ? createSearch(doc, {
15145
15160
  placeholder: searchOptions.placeholder ?? "Search",
15146
15161
  chips: (searchOptions.chips ?? ["modified"]).map((chip) => {
15147
15162
  if (chip === "modified")
@@ -16192,11 +16207,11 @@ function buildPanel(controls, host, options) {
16192
16207
  return list;
16193
16208
  };
16194
16209
  function presetsTouchTab(entries) {
16195
- if (page || activeTab === void 0)
16210
+ if (page || activeTab === void 0 || stripScope() !== "")
16196
16211
  return !0;
16197
16212
  let keys = rows.flatMap((r) => {
16198
16213
  let tab = tabOf(r);
16199
- return !pinnedRow(r) && (tab === void 0 || tab === activeTab) ? r.keys : [];
16214
+ return !pinnedRow(r) && (tab === void 0 || tab === activeTab) ? r.keys.filter((k) => controls.scopeOf(k) === "") : [];
16200
16215
  }), v = variantsOf(controls.schema, controls.isPinned);
16201
16216
  if (v) {
16202
16217
  let tab = v.field.tab ?? (v.field.group !== void 0 ? groupTab(v.field.group) : void 0);
@@ -16205,6 +16220,20 @@ function buildPanel(controls, host, options) {
16205
16220
  }
16206
16221
  return presetsTouch(entries, keys, controls.locked);
16207
16222
  }
16223
+ function stripScope() {
16224
+ let named = Object.keys(controls.scopes), panelWide = named.length === 0 || Object.keys(controls.presets).length > 1;
16225
+ if (page)
16226
+ return panelWide ? "" : null;
16227
+ if (activeTab === void 0)
16228
+ return panelWide ? "" : named[0] ?? null;
16229
+ if (named.includes(activeTab))
16230
+ return activeTab;
16231
+ return named.find((id) => rows.some((r) => r.group === id && tabOf(r) === activeTab)) ?? (panelWide ? "" : null);
16232
+ }
16233
+ let stripVariants = () => {
16234
+ let scope = stripScope();
16235
+ return (scope ? controls.scopes[scope]?.variants : void 0) ?? controls.variants;
16236
+ };
16208
16237
  function syncTabs() {
16209
16238
  let list = tabList(), has = (id) => id !== void 0 && list.some((t) => t.id === id);
16210
16239
  if (!has(activeTab))
@@ -16218,7 +16247,7 @@ function buildPanel(controls, host, options) {
16218
16247
  writeSession(controls.id, { tab: id });
16219
16248
  tabStrip.select(id, animate);
16220
16249
  let focused = doc.activeElement;
16221
- if (applyVisibility(animate ? "tab" : "instant"), focused && root.contains(focused) && focused.closest("[hidden],[data-leaving]"))
16250
+ if (presetStrip.update(), applyVisibility(animate ? "tab" : "instant"), focused && root.contains(focused) && focused.closest("[hidden],[data-leaving]"))
16222
16251
  tabStrip.focus({ preventScroll: !0, focusVisible: !1 });
16223
16252
  options.onTab?.(id), wakeGpu();
16224
16253
  }
@@ -16431,7 +16460,7 @@ function buildPanel(controls, host, options) {
16431
16460
  }, text = el("textarea");
16432
16461
  text.hidden = !0, text.name = "variant-json", text.spellcheck = !1, text.setAttribute("aria-label", "Import or copy text");
16433
16462
  let apply = button("Apply import", () => {
16434
- controls.variants.import(text.value), say("Presets imported."), reveal(text, !1), reveal(apply, !1), positioned?.constrain();
16463
+ stripVariants().import(text.value), say("Presets imported."), reveal(text, !1), reveal(apply, !1), positioned?.constrain();
16435
16464
  });
16436
16465
  apply.className = "knobs-apply-btn", apply.setAttribute("aria-label", "Import JSON"), apply.hidden = !0;
16437
16466
  let copy = async (value, inPlace = !0) => {
@@ -16456,7 +16485,7 @@ function buildPanel(controls, host, options) {
16456
16485
  }
16457
16486
  if (showVariants && showFooter) {
16458
16487
  let transfer = el("div");
16459
- transfer.className = "knobs-transfer", transfer.append(button("Export presets", () => void copy(controls.variants.export(), !1)), button("Import presets", () => {
16488
+ transfer.className = "knobs-transfer", transfer.append(button("Export presets", () => void copy(stripVariants().export(), !1)), button("Import presets", () => {
16460
16489
  reveal(text, !0), reveal(apply, !0), text.value = "", positioned?.constrain(), text.focus();
16461
16490
  })), popover.append(transfer);
16462
16491
  }
@@ -16524,6 +16553,7 @@ function buildPanel(controls, host, options) {
16524
16553
  }
16525
16554
  let shownBase = "", baseSignature = () => JSON.stringify([
16526
16555
  controls.activePreset(),
16556
+ Object.keys(controls.scopes).map((s) => controls.activePreset(s)),
16527
16557
  Object.keys(controls.groupPresets).map((g) => controls.activeGroupPreset(g))
16528
16558
  ]);
16529
16559
  function update() {
@@ -17145,7 +17175,7 @@ function buildPanel(controls, host, options) {
17145
17175
  row: rows[rowAt(active)]?.display,
17146
17176
  editor: t.level === "editor" ? partName(t.el, active) : void 0
17147
17177
  });
17148
- }, crumbs = options.crumbs === !1 || header.hidden || !titleEl.isConnected ? void 0 : createCrumbs({
17178
+ }, crumbs = options.crumbs !== !0 || header.hidden || !titleEl.isConnected ? void 0 : createCrumbs({
17149
17179
  header,
17150
17180
  title: titleEl,
17151
17181
  reduced: () => reducedMotion(),
@@ -17163,7 +17193,21 @@ function buildPanel(controls, host, options) {
17163
17193
  return;
17164
17194
  let t = lensTarget(to);
17165
17195
  jumpedTo = t && t !== "out" ? t.el : null, to.focus(quiet), queueScope();
17166
- }, titleDown;
17196
+ };
17197
+ root.addEventListener("mousedown", (event) => {
17198
+ if (event.button !== 0 || event.shiftKey || event.metaKey || event.ctrlKey)
17199
+ return;
17200
+ let target = event.target instanceof Element ? event.target : null, r = rows[rowAt(target)];
17201
+ if (!target || !r || r.leaving || r.row.closest("[hidden]"))
17202
+ return;
17203
+ let own = target.closest("button,input,select,textarea,a[href],[tabindex],[contenteditable]");
17204
+ if (own && r.row.contains(own))
17205
+ return;
17206
+ event.preventDefault(), r.input.focus({ preventScroll: !0, focusVisible: !1 });
17207
+ let t = lensTarget(doc.activeElement);
17208
+ jumpedTo = t && t !== "out" ? t.el : null, queueScope();
17209
+ }, !0);
17210
+ let titleDown;
17167
17211
  titleEl.addEventListener("pointerdown", (event) => {
17168
17212
  titleDown = [event.clientX, event.clientY];
17169
17213
  }), titleEl.addEventListener("click", (event) => {
@@ -2396,7 +2396,7 @@ ${GRAD_PROPS}
2396
2396
 
2397
2397
  /* The chip: a round cog button, or a capsule with a label beside the cog. */
2398
2398
  .knobs-chip{position:fixed;z-index:2147480000;display:inline-flex;align-items:center;justify-content:center;gap:5px;width:26px;height:26px;padding:0;border-radius:999px;cursor:pointer;background:var(--_k-bg);border:1px solid transparent;
2399
- color:var(--_k-fg-2);font-size:var(--_k-text-control);line-height:1;letter-spacing:.01em;white-space:nowrap;--k-chip-edge:var(--_k-border-subtle);box-shadow:0 0 0 var(--k-rim-width) var(--k-chip-edge),0 4px 16px rgba(0,0,0,calc(.35 * var(--_k-shadow-k,1))),inset 0 var(--k-rim-width) 0 var(--_k-rim);transition:color .15s,border-color .15s,transform .2s var(--k-ease),opacity .2s,scale .12s var(--k-ease),${TOKEN_TRANSITIONS};}
2399
+ color:var(--_k-fg-2);font-size:var(--_k-text-control);line-height:1;letter-spacing:.01em;white-space:nowrap;--k-chip-edge:var(--_k-border-subtle);box-shadow:0 calc(var(--k-rim-width) * -1) 0 var(--_k-rim),0 0 0 var(--k-rim-width) var(--k-chip-edge),0 4px 16px rgba(0,0,0,calc(.35 * var(--_k-shadow-k,1)));transition:color .15s,border-color .15s,transform .2s var(--k-ease),opacity .2s,scale .12s var(--k-ease),${TOKEN_TRANSITIONS};}
2400
2400
  .knobs-chip[data-label]{width:auto;padding:0 10px 0 7px}
2401
2401
  .knobs-chip .knobs-icon{display:block;flex:none;width:15px;height:15px}
2402
2402
  .knobs-chip:hover{color:var(--_k-fg);--k-chip-edge:var(--_k-border-strong)}
@@ -2423,9 +2423,10 @@ ${GRAD_PROPS}
2423
2423
  /* Panel material: --k-glass (0 opaque … 1 clearest) thins the surface over a blurred, saturated
2424
2424
  backdrop, toned toward the theme (--k-glass-tone dims it under dark panels) so text keeps its
2425
2425
  contrast. Its edge is a hairline (--k-edge, --k-rim-width wide) and a hairline of light (--k-rim)
2426
- catches its top. */
2426
+ catches its top. The light rides on the edge (a shadow raised by its width, so only its top
2427
+ shows), not inside the card's 1px border, where it read as a second edge. */
2427
2428
  .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);
2428
- 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 0 0 var(--k-rim-width) var(--ka-edge),inset 0 var(--k-rim-width) 0 var(--_k-rim),var(--ka-glow);scrollbar-width:thin;
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;
2429
2430
  /* Armed (focus inside: keys come here), the edge takes the accent and a faint glow lifts it. */
2430
2431
  --ka-edge:color-mix(in srgb,var(--_k-accent) calc(var(--k-armed) * var(--k-armed-edge) * 100%),var(--_k-border-subtle));
2431
2432
  --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);
@@ -2438,7 +2439,7 @@ ${GRAD_PROPS}
2438
2439
  .knobs-panel[data-morphing]{background:transparent;border-color:transparent;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;animation:none}
2439
2440
  .knobs-morph{position:fixed;left:0;top:0;z-index:2147480001;pointer-events:none;box-sizing:border-box;--p:0;--spec:0;
2440
2441
  background:color-mix(in srgb,var(--_k-bg) calc(100% - var(--_k-glass) * 20% * var(--p)),transparent);border:1px solid transparent;
2441
- box-shadow:0 0 0 var(--k-rim-width) var(--_k-border-subtle),inset 0 0 0 var(--k-rim-width) rgb(255 255 255/var(--spec)),inset 0 var(--k-rim-width) 0 color-mix(in srgb,var(--_k-rim) calc(var(--p) * 100%),transparent);
2442
+ box-shadow:0 calc(var(--k-rim-width) * -1) 0 color-mix(in srgb,var(--_k-rim) calc(var(--p) * 100%),transparent),0 0 0 var(--k-rim-width) var(--_k-border-subtle),inset 0 0 0 var(--k-rim-width) rgb(255 255 255/var(--spec));
2442
2443
  --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)));
2443
2444
  -webkit-backdrop-filter:var(--k-backdrop);backdrop-filter:var(--k-backdrop)}
2444
2445
  .knobs-morph::before,.knobs-morph::after{content:"";position:absolute;inset:-1px;border-radius:inherit;pointer-events:none}
@@ -2446,7 +2447,7 @@ ${GRAD_PROPS}
2446
2447
  .knobs-morph::after{box-shadow:var(--_k-shadow-overlay);opacity:var(--p)}
2447
2448
  /* Meanwhile the chip is just its label, fading on the shape. */
2448
2449
  .knobs-chip.knobs-chip[data-morph]{visibility:visible;pointer-events:auto;transform:none;transition:none;background:transparent;border-color:transparent;box-shadow:none}
2449
- .knobs-panel.knobs-inline{position:relative;z-index:auto;left:auto!important;top:auto!important;max-width:100%;max-height:none;box-shadow:0 0 0 var(--k-rim-width) color-mix(in srgb,var(--ka-edge) calc(var(--k-chrome) * 100%),transparent),inset 0 var(--k-rim-width) 0 color-mix(in srgb,var(--_k-rim) calc(var(--k-chrome) * 100%),transparent),var(--ka-glow);animation:none;
2450
+ .knobs-panel.knobs-inline{position:relative;z-index:auto;left:auto!important;top:auto!important;max-width:100%;max-height:none;box-shadow:0 calc(var(--k-rim-width) * -1) 0 color-mix(in srgb,var(--_k-rim) calc(var(--k-chrome) * 100%),transparent),0 0 0 var(--k-rim-width) color-mix(in srgb,var(--ka-edge) calc(var(--k-chrome) * 100%),transparent),var(--ka-glow);animation:none;
2450
2451
  /* --k-chrome 0: an embedded panel sheds its card (background, border, rim) for the host's column,
2451
2452
  border width too, so its rows line up with the column's edges. */
2452
2453
  border-width:calc(var(--k-chrome) * 1px);