@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/dist/panel.js CHANGED
@@ -3099,7 +3099,7 @@ ${GRAD_PROPS}
3099
3099
 
3100
3100
  /* The chip: a round cog button, or a capsule with a label beside the cog. */
3101
3101
  .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;
3102
- 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};}
3102
+ 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};}
3103
3103
  .knobs-chip[data-label]{width:auto;padding:0 10px 0 7px}
3104
3104
  .knobs-chip .knobs-icon{display:block;flex:none;width:15px;height:15px}
3105
3105
  .knobs-chip:hover{color:var(--_k-fg);--k-chip-edge:var(--_k-border-strong)}
@@ -3126,9 +3126,10 @@ ${GRAD_PROPS}
3126
3126
  /* Panel material: --k-glass (0 opaque … 1 clearest) thins the surface over a blurred, saturated
3127
3127
  backdrop, toned toward the theme (--k-glass-tone dims it under dark panels) so text keeps its
3128
3128
  contrast. Its edge is a hairline (--k-edge, --k-rim-width wide) and a hairline of light (--k-rim)
3129
- catches its top. */
3129
+ catches its top. The light rides on the edge (a shadow raised by its width, so only its top
3130
+ shows), not inside the card's 1px border, where it read as a second edge. */
3130
3131
  .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);
3131
- 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;
3132
+ 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;
3132
3133
  /* Armed (focus inside: keys come here), the edge takes the accent and a faint glow lifts it. */
3133
3134
  --ka-edge:color-mix(in srgb,var(--_k-accent) calc(var(--k-armed) * var(--k-armed-edge) * 100%),var(--_k-border-subtle));
3134
3135
  --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);
@@ -3141,7 +3142,7 @@ ${GRAD_PROPS}
3141
3142
  .knobs-panel[data-morphing]{background:transparent;border-color:transparent;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;animation:none}
3142
3143
  .knobs-morph{position:fixed;left:0;top:0;z-index:2147480001;pointer-events:none;box-sizing:border-box;--p:0;--spec:0;
3143
3144
  background:color-mix(in srgb,var(--_k-bg) calc(100% - var(--_k-glass) * 20% * var(--p)),transparent);border:1px solid transparent;
3144
- 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);
3145
+ 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));
3145
3146
  --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)));
3146
3147
  -webkit-backdrop-filter:var(--k-backdrop);backdrop-filter:var(--k-backdrop)}
3147
3148
  .knobs-morph::before,.knobs-morph::after{content:"";position:absolute;inset:-1px;border-radius:inherit;pointer-events:none}
@@ -3149,7 +3150,7 @@ ${GRAD_PROPS}
3149
3150
  .knobs-morph::after{box-shadow:var(--_k-shadow-overlay);opacity:var(--p)}
3150
3151
  /* Meanwhile the chip is just its label, fading on the shape. */
3151
3152
  .knobs-chip.knobs-chip[data-morph]{visibility:visible;pointer-events:auto;transform:none;transition:none;background:transparent;border-color:transparent;box-shadow:none}
3152
- .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;
3153
+ .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;
3153
3154
  /* --k-chrome 0: an embedded panel sheds its card (background, border, rim) for the host's column,
3154
3155
  border width too, so its rows line up with the column's edges. */
3155
3156
  border-width:calc(var(--k-chrome) * 1px);
@@ -6955,8 +6956,8 @@ var init_swipe = __esm(() => {
6955
6956
  });
6956
6957
 
6957
6958
  // src/panel/presets.ts
6958
- function activeEntryId(controls) {
6959
- const ref = controls.activePreset();
6959
+ function activeEntryId(controls, scope = "") {
6960
+ const ref = controls.activePreset(scope);
6960
6961
  return ref ? `${ref.kind === "preset" ? "p" : "v"}:${ref.id}` : null;
6961
6962
  }
6962
6963
  function resolveActivePresetEntry(entries, currentValues, lastAppliedId, baseId = null) {
@@ -7009,7 +7010,7 @@ function nextPresetIndex(count, activeIndex, direction) {
7009
7010
  return direction > 0 ? 0 : count - 1;
7010
7011
  return ((activeIndex + direction) % count + count) % count;
7011
7012
  }
7012
- function createPresetStrip(doc, controls, showVariants, run, onAfterChange, presetLabel = (id) => controls.presetLabel(id), relevant) {
7013
+ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, presetLabel = (id, scope) => controls.presetLabel(id, scope), relevant, scopeOf = () => "") {
7013
7014
  const wake = () => onAfterChange?.();
7014
7015
  const el = (tag, cls, text) => {
7015
7016
  const node = doc.createElement(tag);
@@ -7057,34 +7058,41 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7057
7058
  let slideUntil = 0;
7058
7059
  let display = "auto";
7059
7060
  let chipMin = 0;
7061
+ let scope = null;
7062
+ const at = () => scope ?? "";
7063
+ const source = () => scope === null ? undefined : scope === "" ? { presets: controls.presets, variants: controls.variants } : controls.scopes[scope];
7060
7064
  const unpinned = (values) => {
7061
7065
  let out;
7062
7066
  for (const k of Object.keys(values))
7063
- if (controls.isPinned(k) && !same2(values[k], controls.locked[k])) {
7067
+ if (controls.scopeOf(k) !== at() && !same2(values[k], controls.locked[k])) {
7064
7068
  out ??= { ...values };
7065
7069
  out[k] = controls.locked[k];
7066
7070
  }
7067
7071
  return out ?? values;
7068
7072
  };
7069
7073
  function buildEntries() {
7070
- const presetEntries = Object.entries(controls.presets).map(([id, presetValues]) => ({
7074
+ const from = source();
7075
+ if (!from)
7076
+ return [];
7077
+ const where = at();
7078
+ const presetEntries = Object.entries(from.presets).map(([id, presetValues]) => ({
7071
7079
  id: `p:${id}`,
7072
7080
  kind: "preset",
7073
7081
  rawId: id,
7074
- label: presetLabel(id),
7082
+ label: presetLabel(id, where),
7075
7083
  values: presetValues,
7076
- apply: (coalesce) => controls.preset(id, { coalesce })
7084
+ apply: (coalesce) => controls.preset(id, { coalesce, scope: where })
7077
7085
  }));
7078
7086
  if (!showVariants)
7079
7087
  return presetEntries;
7080
- const variants = controls.variants.list();
7088
+ const variants = from.variants.list();
7081
7089
  const variantEntries = variants.map((v) => ({
7082
7090
  id: `v:${v.id}`,
7083
7091
  kind: "variant",
7084
7092
  rawId: v.id,
7085
7093
  label: v.name ?? v.id.slice(0, 8),
7086
7094
  values: unpinned(v.values),
7087
- apply: (coalesce) => controls.variants.load(v.id, { coalesce })
7095
+ apply: (coalesce) => from.variants.load(v.id, { coalesce })
7088
7096
  }));
7089
7097
  return [...presetEntries, ...variantEntries];
7090
7098
  }
@@ -7135,8 +7143,8 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7135
7143
  if (first)
7136
7144
  doc.defaultView?.requestAnimationFrame(() => pill.style.transition = "");
7137
7145
  }
7138
- const resolve = () => resolveActivePresetEntry(entries, unpinned(controls.get()), lastAppliedId, activeEntryId(controls));
7139
- const wanted = (list) => (list.length > 1 || showVariants) && (relevant?.(list) ?? true);
7146
+ const resolve = () => resolveActivePresetEntry(entries, unpinned(controls.get()), lastAppliedId, activeEntryId(controls, at()));
7147
+ const wanted = (list) => scope !== null && (list.length > 1 || showVariants) && (relevant?.(list) ?? true);
7140
7148
  function syncShown() {
7141
7149
  const show = wanted(entries);
7142
7150
  if (root.hidden === show && root.dataset.presence === undefined)
@@ -7196,15 +7204,19 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7196
7204
  }
7197
7205
  function update() {
7198
7206
  run(() => {
7199
- const variantsRef = showVariants ? controls.variants.list() : null;
7200
- if (signature !== "" && variantsRef === lastVariantsRef) {
7207
+ const was = scope;
7208
+ scope = scopeOf();
7209
+ if (scope !== was)
7210
+ lastAppliedId = null;
7211
+ const variantsRef = showVariants ? source()?.variants.list() ?? null : null;
7212
+ if (signature !== "" && scope === was && variantsRef === lastVariantsRef) {
7201
7213
  syncShown();
7202
7214
  syncSelection(false);
7203
7215
  return;
7204
7216
  }
7205
7217
  lastVariantsRef = variantsRef;
7206
7218
  const nextEntries = buildEntries();
7207
- const nextSig = JSON.stringify(nextEntries.map((e) => [e.id, e.label]));
7219
+ const nextSig = JSON.stringify([scope, nextEntries.map((e) => [e.id, e.label])]);
7208
7220
  entries = nextEntries;
7209
7221
  syncShown();
7210
7222
  if (nextSig !== signature) {
@@ -7269,12 +7281,15 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7269
7281
  }
7270
7282
  function save() {
7271
7283
  finishName(true);
7272
- const used = new Set(controls.variants.list().map((v) => v.name));
7284
+ const variants = source()?.variants;
7285
+ if (!variants)
7286
+ return "";
7287
+ const used = new Set(variants.list().map((v) => v.name));
7273
7288
  let n = 1;
7274
7289
  while (used.has(`Variant ${n}`))
7275
7290
  n++;
7276
- const variant = controls.variants.save(`Variant ${n}`);
7277
- controls.variants.load(variant.id);
7291
+ const variant = variants.save(`Variant ${n}`);
7292
+ variants.load(variant.id);
7278
7293
  lastAppliedId = `v:${variant.id}`;
7279
7294
  update();
7280
7295
  startName(entries.findIndex((e) => e.id === `v:${variant.id}`), true);
@@ -7349,7 +7364,7 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7349
7364
  focusQuietly();
7350
7365
  let message;
7351
7366
  if (keep && entry?.kind === "variant" && name && name !== was) {
7352
- controls.variants.rename(entry.rawId, name);
7367
+ source()?.variants.rename(entry.rawId, name);
7353
7368
  update();
7354
7369
  message = `Named “${name}”.`;
7355
7370
  }
@@ -7370,7 +7385,7 @@ function createPresetStrip(doc, controls, showVariants, run, onAfterChange, pres
7370
7385
  const entry = entries[activeState.activeIndex];
7371
7386
  if (!entry || entry.kind !== "variant")
7372
7387
  return;
7373
- controls.variants.remove(entry.rawId);
7388
+ source()?.variants.remove(entry.rawId);
7374
7389
  lastAppliedId = null;
7375
7390
  update();
7376
7391
  onAfterChange?.();
@@ -9459,7 +9474,8 @@ fn hash21(q: vec2f) -> f32 {
9459
9474
 
9460
9475
  // kind 0: Panel card surface + elevation shadow + border + specular rim. With a hairline width
9461
9476
  // (extra.z, whole device px) the edge is that hairline just outside the card, like CSS's
9462
- // 0 0 0 w spread shadow, and the rim is CSS's inset 0 w 0; otherwise a 1px border and a 1px rim.
9477
+ // 0 0 0 w spread shadow, and the rim lights its top, like CSS's 0 -w 0 (one edge, not two);
9478
+ // otherwise a 1px border and a 1px rim inside it.
9463
9479
  fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
9464
9480
  let r = p.look.x;
9465
9481
  let dist = roundedBox(local, size, r);
@@ -9468,6 +9484,8 @@ fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
9468
9484
  var out = vec4f(0.0);
9469
9485
  if (hair > 0.0 && dist > -1.0) {
9470
9486
  out = premul(p.color2) * clamp(covSd(dist - hair) - bodyCov, 0.0, 1.0);
9487
+ let raised = covSd(roundedBox(local + vec2f(0.0, hair), size, r));
9488
+ out = over(out, premul(p.aux2) * clamp(raised - bodyCov, 0.0, 1.0));
9471
9489
  }
9472
9490
 
9473
9491
  // Floating drop shadow when glowOrBlur (look.z) > 0; like box-shadow, never under the card.
@@ -9481,13 +9499,16 @@ fn shadePanel(p: Prim, local: vec2f, size: vec2f) -> vec4f {
9481
9499
 
9482
9500
  var card = premul(p.color) * bodyCov;
9483
9501
 
9484
- // The rim, built like CSS's inset 0 w 0: inside the border, minus that shape moved down w.
9502
+ // Without a hairline, the rim is CSS's inset 0 1px 0: inside the border, minus that shape
9503
+ // moved down.
9485
9504
  let inner = local - vec2f(1.0);
9486
9505
  let innerSize = size - vec2f(2.0);
9487
9506
  let w = select(1.0, hair, hair > 0.0);
9488
9507
  let innerCov = covSd(roundedBox(inner, innerSize, r - 1.0));
9489
- let rimCov = innerCov * (1.0 - covSd(roundedBox(inner - vec2f(0.0, w), innerSize, r - 1.0)));
9490
- card = over(card, premul(p.aux2) * rimCov);
9508
+ if (hair <= 0.0) {
9509
+ let rimCov = innerCov * (1.0 - covSd(roundedBox(inner - vec2f(0.0, w), innerSize, r - 1.0)));
9510
+ card = over(card, premul(p.aux2) * rimCov);
9511
+ }
9491
9512
 
9492
9513
  // A morphing card's specular ring (extra.y), w wide inside the border like CSS's inset 0 0 0 w.
9493
9514
  if (p.extra.y > 0.001) {
@@ -26046,8 +26067,9 @@ function buildPanel(controls, host, options) {
26046
26067
  const group = scope === "row" ? rows[rowAt(from)]?.group : scope === "group" ? cardOf(from)?.dataset.group ?? undefined : undefined;
26047
26068
  const keys = scope === "row" ? rows[rowAt(from)]?.keys ?? [] : scope === "group" ? cardKeys(cardOf(from)) : scope === "selection" ? selectionKeys() ?? [] : undefined;
26048
26069
  const index = group === undefined ? -1 : controls.activeGroupPreset(group);
26049
- const ref = controls.activePreset();
26050
- const 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 : undefined;
26070
+ const where = (keys?.length ? controls.scopeOf(keys[0]) : stripScope()) ?? null;
26071
+ const ref = where === null ? null : controls.activePreset(where);
26072
+ const 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 : undefined;
26051
26073
  if (!name)
26052
26074
  return null;
26053
26075
  const off = keys ? keys.some((k) => controls.isModified(k)) : controls.isModified();
@@ -26200,8 +26222,8 @@ function buildPanel(controls, host, options) {
26200
26222
  if (helpButton?.hidden)
26201
26223
  titleEl.title = `${titleEl.textContent ? `${titleEl.textContent}
26202
26224
  ` : ""}Press ${show("help")} for keyboard shortcuts`;
26203
- const presetLabel = (id) => options.presetLabels?.[id] ?? (id === "default" ? options.defaultLabel : undefined) ?? controls.presetLabel(id);
26204
- const presetStrip = createPresetStrip(doc, controls, showVariants, run, () => wakeGpu(), presetLabel, (entries) => presetsTouchTab(entries));
26225
+ const presetLabel = (id, scope = "") => options.presetLabels?.[id] ?? (id === "default" ? options.defaultLabel : undefined) ?? controls.presetLabel(id, scope);
26226
+ const presetStrip = createPresetStrip(doc, controls, showVariants, run, () => wakeGpu(), presetLabel, (entries) => presetsTouchTab(entries), () => stripScope());
26205
26227
  const tabStrip = createTabStrip(doc, (id, animate) => selectTab(id, animate));
26206
26228
  let activeTab;
26207
26229
  const searchOptions = typeof options.search === "object" ? options.search : {};
@@ -27606,11 +27628,11 @@ function buildPanel(controls, host, options) {
27606
27628
  return list;
27607
27629
  };
27608
27630
  function presetsTouchTab(entries) {
27609
- if (page || activeTab === undefined)
27631
+ if (page || activeTab === undefined || stripScope() !== "")
27610
27632
  return true;
27611
27633
  const keys = rows.flatMap((r) => {
27612
27634
  const tab = tabOf(r);
27613
- return !pinnedRow(r) && (tab === undefined || tab === activeTab) ? r.keys : [];
27635
+ return !pinnedRow(r) && (tab === undefined || tab === activeTab) ? r.keys.filter((k) => controls.scopeOf(k) === "") : [];
27614
27636
  });
27615
27637
  const v = variantsOf(controls.schema, controls.isPinned);
27616
27638
  if (v) {
@@ -27620,6 +27642,22 @@ function buildPanel(controls, host, options) {
27620
27642
  }
27621
27643
  return presetsTouch(entries, keys, controls.locked);
27622
27644
  }
27645
+ function stripScope() {
27646
+ const named = Object.keys(controls.scopes);
27647
+ const panelWide = named.length === 0 || Object.keys(controls.presets).length > 1;
27648
+ if (page)
27649
+ return panelWide ? "" : null;
27650
+ if (activeTab === undefined)
27651
+ return panelWide ? "" : named[0] ?? null;
27652
+ if (named.includes(activeTab))
27653
+ return activeTab;
27654
+ const group = named.find((id) => rows.some((r) => r.group === id && tabOf(r) === activeTab));
27655
+ return group ?? (panelWide ? "" : null);
27656
+ }
27657
+ const stripVariants = () => {
27658
+ const scope = stripScope();
27659
+ return (scope ? controls.scopes[scope]?.variants : undefined) ?? controls.variants;
27660
+ };
27623
27661
  function syncTabs() {
27624
27662
  const list = tabList();
27625
27663
  const has = (id) => id !== undefined && list.some((t) => t.id === id);
@@ -27636,6 +27674,7 @@ function buildPanel(controls, host, options) {
27636
27674
  writeSession(controls.id, { tab: id });
27637
27675
  tabStrip.select(id, animate);
27638
27676
  const focused = doc.activeElement;
27677
+ presetStrip.update();
27639
27678
  applyVisibility(animate ? "tab" : "instant");
27640
27679
  if (focused && root.contains(focused) && focused.closest("[hidden],[data-leaving]"))
27641
27680
  tabStrip.focus({ preventScroll: true, focusVisible: false });
@@ -27911,7 +27950,7 @@ function buildPanel(controls, host, options) {
27911
27950
  text.spellcheck = false;
27912
27951
  text.setAttribute("aria-label", "Import or copy text");
27913
27952
  const apply = button("Apply import", () => {
27914
- controls.variants.import(text.value);
27953
+ stripVariants().import(text.value);
27915
27954
  say("Presets imported.");
27916
27955
  reveal(text, false);
27917
27956
  reveal(apply, false);
@@ -27957,7 +27996,7 @@ function buildPanel(controls, host, options) {
27957
27996
  if (showVariants && showFooter) {
27958
27997
  const transfer = el("div");
27959
27998
  transfer.className = "knobs-transfer";
27960
- transfer.append(button("Export presets", () => void copy(controls.variants.export(), false)), button("Import presets", () => {
27999
+ transfer.append(button("Export presets", () => void copy(stripVariants().export(), false)), button("Import presets", () => {
27961
28000
  reveal(text, true);
27962
28001
  reveal(apply, true);
27963
28002
  text.value = "";
@@ -28053,6 +28092,7 @@ function buildPanel(controls, host, options) {
28053
28092
  let shownBase = "";
28054
28093
  const baseSignature = () => JSON.stringify([
28055
28094
  controls.activePreset(),
28095
+ Object.keys(controls.scopes).map((s) => controls.activePreset(s)),
28056
28096
  Object.keys(controls.groupPresets).map((g) => controls.activeGroupPreset(g))
28057
28097
  ]);
28058
28098
  function update() {
@@ -28834,7 +28874,7 @@ function buildPanel(controls, host, options) {
28834
28874
  editor: t.level === "editor" ? partName(t.el, active) : undefined
28835
28875
  });
28836
28876
  };
28837
- const crumbs = options.crumbs === false || header.hidden || !titleEl.isConnected ? undefined : createCrumbs({
28877
+ const crumbs = options.crumbs !== true || header.hidden || !titleEl.isConnected ? undefined : createCrumbs({
28838
28878
  header,
28839
28879
  title: titleEl,
28840
28880
  reduced: () => reducedMotion(),
@@ -28858,6 +28898,22 @@ function buildPanel(controls, host, options) {
28858
28898
  to.focus(quiet);
28859
28899
  queueScope();
28860
28900
  };
28901
+ root.addEventListener("mousedown", (event) => {
28902
+ if (event.button !== 0 || event.shiftKey || event.metaKey || event.ctrlKey)
28903
+ return;
28904
+ const target = event.target instanceof Element ? event.target : null;
28905
+ const r = rows[rowAt(target)];
28906
+ if (!target || !r || r.leaving || r.row.closest("[hidden]"))
28907
+ return;
28908
+ const own = target.closest("button,input,select,textarea,a[href],[tabindex],[contenteditable]");
28909
+ if (own && r.row.contains(own))
28910
+ return;
28911
+ event.preventDefault();
28912
+ r.input.focus({ preventScroll: true, focusVisible: false });
28913
+ const t = lensTarget(doc.activeElement);
28914
+ jumpedTo = t && t !== "out" ? t.el : null;
28915
+ queueScope();
28916
+ }, true);
28861
28917
  let titleDown;
28862
28918
  titleEl.addEventListener("pointerdown", (event) => {
28863
28919
  titleDown = [event.clientX, event.clientY];
@@ -136,6 +136,8 @@ export interface RemoteControlsOptions {
136
136
  readonly nullDefaults?: boolean;
137
137
  readonly values?: State;
138
138
  readonly presets?: Readonly<Record<string, State>>;
139
+ /** Presets that belong to one tab or group (see `ControlsOptions.scopedPresets`). */
140
+ readonly scopedPresets?: Readonly<Record<string, Readonly<Record<string, State>>>>;
139
141
  /** Groups pinned above the tab strip, outside the presets (see `ControlsOptions.pinned`). */
140
142
  readonly pinned?: readonly string[];
141
143
  /** Numbered presets per group, e.g. the phone's preset sets (see `ControlsOptions.groupPresets`). */
@@ -50,6 +50,19 @@ export interface PresetRef {
50
50
  readonly kind: "preset" | "variant";
51
51
  readonly id: string;
52
52
  }
53
+ /**
54
+ * A preset strip that belongs to one tab or group (see `ControlsOptions.scopedPresets`): its
55
+ * presets and saved variants move only its own keys, and it keeps its own active preset.
56
+ */
57
+ export interface PresetScope<V = State> {
58
+ /** The tab or group it's named for. */
59
+ readonly id: string;
60
+ /** The keys it moves: the tab's or group's, pinned ones aside. */
61
+ readonly keys: readonly string[];
62
+ /** By id, `default` first; keys outside the scope sit at their defaults. */
63
+ readonly presets: Readonly<Record<string, V>>;
64
+ readonly variants: Variants<V>;
65
+ }
53
66
  export interface ControlAction<V = State> {
54
67
  readonly id?: string;
55
68
  readonly label: string;
@@ -169,8 +182,9 @@ export interface MountPanelOptions extends LabelOptions {
169
182
  */
170
183
  readonly header?: boolean | HeaderOptions;
171
184
  /**
172
- * `false` drops the scope line beside the title (`Shape › Cohesion`, where keys go, and a faint
173
- * `esc ↑ Shape` for where Esc goes). The focus lens and the armed frame stay.
185
+ * `true` shows a scope line beside the title (`Shape › Cohesion`, where keys go, and a faint
186
+ * `esc ↑ Shape` for where Esc goes). Off by default; the focus lens and the armed frame show
187
+ * either way.
174
188
  */
175
189
  readonly crumbs?: boolean;
176
190
  /**
@@ -355,6 +369,14 @@ export interface ControlsOptions<S extends Schema> extends LabelOptions {
355
369
  /** An undo stack the host owns; undo and redo call it (see `HostHistory`). */
356
370
  history?: HostHistory;
357
371
  presets?: Readonly<Record<string, Partial<Values<S>>>>;
372
+ /**
373
+ * Presets that belong to one tab or group, keyed by its id: `{ Sidebars: { snappy: {…} } }`.
374
+ * Each shows its own strip on that tab (a tab without any shows none), moves only that tab's
375
+ * or group's keys, and has its own active preset, modified mark and saved variants. A variants
376
+ * choice on a tab adds its options to that tab's. `presets` then covers only the keys no scope
377
+ * holds; leave it out and only the scoped strips show.
378
+ */
379
+ scopedPresets?: Readonly<Record<string, Readonly<Record<string, Partial<Values<S>>>>>>;
358
380
  /**
359
381
  * Groups pinned to the top of a panel, under the header and above the tab strip, on every tab
360
382
  * (as fields' `pinned: true` pins theirs). Presets and saved variants leave them alone: applying
@@ -408,6 +430,10 @@ export interface Controls<S extends Schema = Schema> {
408
430
  readonly persist: PersistMode;
409
431
  readonly presets: Readonly<Record<string, Values<S>>>;
410
432
  readonly variants: Variants<Values<S>>;
433
+ /** The tabs and groups with presets of their own, by id (see `ControlsOptions.scopedPresets`). */
434
+ readonly scopes: Readonly<Record<string, PresetScope<Values<S>>>>;
435
+ /** The scope `key`'s presets come from: a tab or group's id, `""` for `presets`, or undefined when pinned. */
436
+ scopeOf(key: string): string | undefined;
411
437
  readonly actions: readonly ControlAction<Values<S>>[];
412
438
  /** Its panels' theme, as given (see `ControlsOptions.theme`). */
413
439
  readonly theme: Theme | ThemeMode | undefined;
@@ -437,8 +463,8 @@ export interface Controls<S extends Schema = Schema> {
437
463
  readonly groupFolded: Readonly<Record<string, boolean>> | undefined;
438
464
  /** How a page's sections start (`sectionFolded`), as given. */
439
465
  readonly sectionFolded: SectionFolded | undefined;
440
- /** A preset's display label (`presetLabels`, `defaultLabel`), else its id. */
441
- presetLabel(id: string): string;
466
+ /** A preset's display label (`presetLabels`, `defaultLabel`), else its id; `scope` as in `preset`. */
467
+ presetLabel(id: string, scope?: string): string;
442
468
  /** Whether `key` is pinned (its group is, see `ControlsOptions.pinned`), outside the presets. */
443
469
  isPinned(key: string): boolean;
444
470
  readonly error: string | null;
@@ -555,17 +581,21 @@ export interface Controls<S extends Schema = Schema> {
555
581
  readonly comparing: boolean;
556
582
  /**
557
583
  * Applies a preset and makes it the active one (`"default"` clears it); one undo step, or
558
- * folded into the last with `coalesce` (a pan across the strip).
584
+ * folded into the last with `coalesce` (a pan across the strip). With `scope`, one of that tab
585
+ * or group's presets, which moves only its keys.
559
586
  */
560
587
  preset(id: string, options?: {
561
588
  coalesce?: boolean;
589
+ scope?: string;
562
590
  }): void;
563
- matchingPreset(): string;
591
+ /** The preset the current values match (in `scope`, over its keys), else `"custom"`. */
592
+ matchingPreset(scope?: string): string;
564
593
  /**
565
594
  * The preset (or saved variant) last applied, which Reset returns to and Random explores
566
- * around, or null. Kept with the values; forgotten by a reset to the defaults.
595
+ * around, or null. Kept with the values; forgotten by a reset to the defaults. With `scope`,
596
+ * that tab or group's.
567
597
  */
568
- activePreset(): PresetRef | null;
598
+ activePreset(scope?: string): PresetRef | null;
569
599
  /** The group preset last applied to `group` (0-based), or -1. */
570
600
  activeGroupPreset(group: string): number;
571
601
  /** The values Reset returns to first: the active presets over the defaults. */
@@ -22,4 +22,6 @@ export interface Variants<V> {
22
22
  export(): string;
23
23
  import(json: string): void;
24
24
  }
25
- export declare function createVariants<S extends Schema>(schema: () => S, id: string, key: string, storage: () => StoragePort | null, get: () => Values<S>, set: (v: Values<S>, id: string, coalesce: boolean) => void, notify: () => void, enabled: () => boolean): Variants<Values<S>>;
25
+ export declare function createVariants<S extends Schema>(schema: () => S, id: string, key: string, storage: () => StoragePort | null, get: () => Values<S>, set: (v: Values<S>, id: string, coalesce: boolean) => void, notify: () => void, enabled: () => boolean,
26
+ /** What the list starts as while storage holds none yet (it's kept once something's saved). */
27
+ seed?: () => readonly Variant<Values<S>>[]): Variants<Values<S>>;
@@ -19,8 +19,11 @@ export interface ActivePresetState {
19
19
  }
20
20
  /** How strong the selection pill stays on the active preset once the values drift off it. */
21
21
  export declare const DRIFTED_PILL = 0.5;
22
- /** The strip's id for the store's active preset (`p:` a preset, `v:` a saved variant), if any. */
23
- export declare function activeEntryId(controls: Controls<any>): string | null;
22
+ /**
23
+ * The strip's id for the store's active preset (`p:` a preset, `v:` a saved variant), if any;
24
+ * `scope` as in `Controls.preset`.
25
+ */
26
+ export declare function activeEntryId(controls: Controls<any>, scope?: string): string | null;
24
27
  /**
25
28
  * Resolves which preset or variant is currently active.
26
29
  * The store's active preset (`baseId`) holds the selection however far the values drift from it.
@@ -75,6 +78,11 @@ export interface PresetStrip {
75
78
  }
76
79
  export declare function createPresetStrip<S extends Schema>(doc: Document, controls: Controls<S>, showVariants: boolean, run: (action: () => unknown) => void, onAfterChange?: () => void,
77
80
  /** What a preset's chip says (its id stays its identity); `controls.presetLabel` by default. */
78
- presetLabel?: (id: string) => string,
81
+ presetLabel?: (id: string, scope: string) => string,
79
82
  /** Whether these entries matter where the strip is shown (a tab they don't touch hides it). */
80
- relevant?: (entries: readonly PresetEntry<any>[]) => boolean): PresetStrip;
83
+ relevant?: (entries: readonly PresetEntry<any>[]) => boolean,
84
+ /**
85
+ * Whose presets it shows: a tab or group's (see `ControlsOptions.scopedPresets`), `""` for the
86
+ * panel's own, or null for none (the strip hides).
87
+ */
88
+ scopeOf?: () => string | null): PresetStrip;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/knobs",
3
- "version": "0.4.0-next.12",
3
+ "version": "0.4.0-next.13",
4
4
  "description": "Per-component tuning controls. Explore in development, ship constants.",
5
5
  "type": "module",
6
6
  "license": "MIT",