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

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
@@ -3335,6 +3335,13 @@ ${GRAD_PROPS}
3335
3335
  .knobs-group-head .knobs-group-die{width:20px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:var(--_k-round);color:var(--_k-fg-3)}
3336
3336
  .knobs-group-head .knobs-group-die[disabled]{opacity:var(--k-disabled-opacity);cursor:default}
3337
3337
  .knobs-panel .knobs-group-head button:where(:not(.knobs-group-reset)){transition:background var(--k-hover-t),color var(--k-hover-t),opacity .2s,transform .12s}
3338
+ /* A panel card's header: its title, a folded card's summary and its buttons on one line, and its
3339
+ description on the next. The title takes its room first; the summary gets what's left. */
3340
+ .knobs-panel:not(.knobs-page) .knobs-group-head{flex-wrap:wrap;row-gap:0}
3341
+ .knobs-panel:not(.knobs-page) .knobs-group-head>.knobs-group-label{flex:1000 1 0;max-width:max-content;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
3342
+ .knobs-group-head>.knobs-group-desc{order:1;flex:0 0 100%;padding:0;line-height:16px;white-space:normal;overflow:visible;text-wrap:pretty}
3343
+ .knobs-group-head>.knobs-group-summary{flex:1 1 0;max-width:max-content;margin-left:auto;font-size:var(--_k-text-meta);text-transform:none}
3344
+ .knobs-group-head[data-folded]>.knobs-group-summary:not(:empty){display:block}
3338
3345
 
3339
3346
  /* Grouped cards: rows share one surface, split by hairlines that stay put whatever a row is doing */
3340
3347
  .knobs-card{position:relative;margin-bottom:var(--k-section-gap);border-radius:var(--_k-radius);background:var(--_k-fill);overflow:hidden}
@@ -4041,7 +4048,7 @@ ${POINTER_ROW} .knobs-slider.knobs-modified:not(:hover,.knobs-active) .knobs-val
4041
4048
  .knobs-group-summary{display:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--_k-fg-3);font-size:var(--_k-text-control);font-weight:500;letter-spacing:0;font-variant-numeric:tabular-nums}
4042
4049
  .knobs-tile[data-folded]>.knobs-group-head .knobs-group-summary:not(:empty){display:block}
4043
4050
  .knobs-tile[data-folded]>.knobs-group-head .knobs-group-ctl{display:none}
4044
- .knobs-tile[data-fold-motion]>.knobs-group-head :is(.knobs-group-summary,.knobs-group-ctl){animation:knobs-fade .2s var(--k-ease) both}
4051
+ .knobs-tile[data-fold-motion]>.knobs-group-head :is(.knobs-group-summary,.knobs-group-ctl),.knobs-group-head[data-fold-motion]>.knobs-group-summary{animation:knobs-fade .2s var(--k-ease) both}
4045
4052
  /* A highlight (page.highlight): the rest dims under a veil of the page's colour (--k-page-dim at
4046
4053
  full), and what's highlighted on its own gets a dot in the gutter by its title. The veil and
4047
4054
  dot are written each frame on the panel spring (see page.ts). */
@@ -11521,6 +11528,44 @@ class GpuOverlayManager {
11521
11528
  this.clipCache.set(el, { key, text, width, clipped });
11522
11529
  return clipped;
11523
11530
  }
11531
+ lineCache = new WeakMap;
11532
+ linesOf(el, width) {
11533
+ const text = el.textContent ?? "";
11534
+ const hit = this.lineCache.get(el);
11535
+ if (hit && hit.text === text && hit.width === width)
11536
+ return hit.lines;
11537
+ const lines = [];
11538
+ const node = el.firstChild;
11539
+ if (node?.nodeType === Node.TEXT_NODE) {
11540
+ const top = el.getBoundingClientRect().top;
11541
+ const range = el.ownerDocument.createRange();
11542
+ let line;
11543
+ const flush = () => {
11544
+ if (line)
11545
+ lines.push({
11546
+ text: text.slice(line.from, line.to),
11547
+ y: (line.top + line.bottom) / 2 - top
11548
+ });
11549
+ };
11550
+ for (const word of text.matchAll(/\S+/g)) {
11551
+ const from = word.index;
11552
+ const to = from + word[0].length;
11553
+ range.setStart(node, from);
11554
+ range.setEnd(node, to);
11555
+ const r = range.getBoundingClientRect();
11556
+ if (line && Math.abs(r.top - line.top) < 1) {
11557
+ line.to = to;
11558
+ line.bottom = Math.max(line.bottom, r.bottom);
11559
+ } else {
11560
+ flush();
11561
+ line = { from, to, top: r.top, bottom: r.bottom };
11562
+ }
11563
+ }
11564
+ flush();
11565
+ }
11566
+ this.lineCache.set(el, { text, width, lines });
11567
+ return lines;
11568
+ }
11524
11569
  curReduced = false;
11525
11570
  curLocks;
11526
11571
  curSelected = 0;
@@ -13320,12 +13365,11 @@ class GpuOverlayManager {
13320
13365
  this.drawText(this.fit(g.labelEl.textContent ?? "", lr.width, px, tokens.titleWeight), lr.left, cy, withAlpha2(tokens.text, 1 - peek), { px, weight: tokens.titleWeight, track: -0.01 });
13321
13366
  if (g.peek && peek > 0.005)
13322
13367
  this.drawText(this.fit(g.peek.textContent ?? "", lr.width, px, tokens.titleWeight), lr.left, cy, withAlpha2(tokens.text, peek), { px, weight: tokens.titleWeight, track: -0.01 });
13323
- } else
13324
- this.drawText(tokens.groupUpper ? (g.labelEl.textContent ?? "").toUpperCase() : g.labelEl.textContent ?? "", lr.left, lr.top + lr.height * 0.5, tokens.groupColor, {
13325
- px: Math.max(9, tokens.groupSize),
13326
- weight: tokens.titleWeight,
13327
- track: tokens.groupTracking
13328
- });
13368
+ } else {
13369
+ const label = tokens.groupUpper ? (g.labelEl.textContent ?? "").toUpperCase() : g.labelEl.textContent ?? "";
13370
+ const px = Math.max(9, tokens.groupSize);
13371
+ this.drawText(this.domClipped(g.labelEl, label, lr.width) ? this.fit(label, lr.width, px, tokens.titleWeight, undefined, tokens.groupTracking, true) : label, lr.left, lr.top + lr.height * 0.5, tokens.groupColor, { px, weight: tokens.titleWeight, track: tokens.groupTracking });
13372
+ }
13329
13373
  if (!g.title && tokens.groupDivider[3] > 0 && g.element.previousElementSibling)
13330
13374
  this.pushPrim({
13331
13375
  x: gr.left,
@@ -13340,13 +13384,16 @@ class GpuOverlayManager {
13340
13384
  const cs = getComputedStyle(g.desc);
13341
13385
  const left = r.left + (parseFloat(cs.paddingLeft) || 0);
13342
13386
  const w = r.width - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
13343
- this.drawText(this.fit(g.desc.textContent ?? "", w + 0.5, tokens.textMeta, tokens.labelWeight), left, r.top + r.height * 0.5, tokens.dim, { px: tokens.textMeta, weight: tokens.labelWeight });
13387
+ const lines = cs.whiteSpace.includes("nowrap") ? [{ text: g.desc.textContent ?? "", y: r.height * 0.5 }] : this.linesOf(g.desc, r.width);
13388
+ for (const line of lines)
13389
+ this.drawText(this.fit(line.text, w + 0.5, tokens.textMeta, tokens.labelWeight), left, r.top + line.y, tokens.dim, { px: tokens.textMeta, weight: tokens.labelWeight });
13344
13390
  }
13345
13391
  const fadeIn = (node) => node.parentElement?.closest("[data-fold-motion]") ? Number(getComputedStyle(node).opacity) || 0 : 1;
13346
13392
  if (g.summary && g.summary.getClientRects().length) {
13347
13393
  const r = this.rectOf(g.summary);
13348
13394
  const a = fadeIn(g.summary);
13349
- this.drawText(this.fit(g.summary.textContent ?? "", r.width + 0.5, tokens.textControl, 500), r.left, r.top + r.height * 0.5, withAlpha2(tokens.dim, a), { px: tokens.textControl, weight: 500 });
13395
+ const px = g.title ? tokens.textControl : tokens.textMeta;
13396
+ this.drawText(this.fit(g.summary.textContent ?? "", r.width + 0.5, px, 500), r.left, r.top + r.height * 0.5, withAlpha2(tokens.dim, a), { px, weight: 500 });
13350
13397
  }
13351
13398
  const ctlShown = !g.controls || g.controls.getClientRects().length > 0;
13352
13399
  const headAlpha = this.curAlpha;
@@ -25131,6 +25178,114 @@ var init_lens = __esm(() => {
25131
25178
  };
25132
25179
  });
25133
25180
 
25181
+ // src/panel/active.ts
25182
+ function activeReporter(resolve, report, schedule) {
25183
+ let hover = null;
25184
+ let focus = null;
25185
+ let lead = "pointer";
25186
+ let last = null;
25187
+ let cancel;
25188
+ const flush = () => {
25189
+ cancel = undefined;
25190
+ const [first, second] = lead === "pointer" ? [hover, focus] : [focus, hover];
25191
+ const at = resolve(first) ?? resolve(second);
25192
+ if (same3(at, last))
25193
+ return;
25194
+ last = at;
25195
+ report(at);
25196
+ };
25197
+ const touch = () => {
25198
+ cancel ??= schedule(flush);
25199
+ };
25200
+ return {
25201
+ pointer(target) {
25202
+ hover = target;
25203
+ if (target !== null)
25204
+ lead = "pointer";
25205
+ touch();
25206
+ },
25207
+ focus(target) {
25208
+ focus = target;
25209
+ if (target !== null)
25210
+ lead = "focus";
25211
+ touch();
25212
+ },
25213
+ dispose() {
25214
+ cancel?.();
25215
+ cancel = undefined;
25216
+ hover = focus = null;
25217
+ if (last !== null)
25218
+ report(last = null);
25219
+ }
25220
+ };
25221
+ }
25222
+ function activeEvents(reporter, where) {
25223
+ let inMenu = false;
25224
+ return {
25225
+ over(e) {
25226
+ reporter.pointer(e.target);
25227
+ },
25228
+ move(e) {
25229
+ if (e.movementX || e.movementY)
25230
+ reporter.pointer(e.target);
25231
+ },
25232
+ out(e) {
25233
+ if (where(e.relatedTarget) !== "inside")
25234
+ reporter.pointer(null);
25235
+ },
25236
+ focusIn(e) {
25237
+ const at = where(e.target);
25238
+ inMenu = at === "menu";
25239
+ if (at === "inside")
25240
+ reporter.focus(e.target);
25241
+ else if (at === "outside")
25242
+ reporter.focus(null);
25243
+ },
25244
+ focusOut(e) {
25245
+ if (e.relatedTarget !== null)
25246
+ return;
25247
+ const at = where(e.target);
25248
+ if (at === "inside" || at === "menu" && inMenu) {
25249
+ inMenu = false;
25250
+ reporter.focus(null);
25251
+ }
25252
+ }
25253
+ };
25254
+ }
25255
+ function watchActive(root, resolve, report) {
25256
+ const doc = root.ownerDocument;
25257
+ const win = doc.defaultView;
25258
+ const reporter = activeReporter(resolve, report, (flush) => {
25259
+ const id = win.requestAnimationFrame(flush);
25260
+ return () => win.cancelAnimationFrame(id);
25261
+ });
25262
+ const events = activeEvents(reporter, (node) => node instanceof Node && root.contains(node) ? "inside" : node instanceof Element && node.closest(".knobs-menu") ? "menu" : "outside");
25263
+ const el = (node) => node;
25264
+ const over = (e) => events.over({ target: el(e.target) });
25265
+ const move = (e) => events.move({ target: el(e.target), movementX: e.movementX, movementY: e.movementY });
25266
+ const out = (e) => events.out({ relatedTarget: el(e.relatedTarget) });
25267
+ const focusIn = (e) => events.focusIn({ target: el(e.target) });
25268
+ const focusOut = (e) => events.focusOut({ target: el(e.target), relatedTarget: el(e.relatedTarget) });
25269
+ root.addEventListener("pointerover", over);
25270
+ root.addEventListener("pointermove", move);
25271
+ root.addEventListener("pointerout", out);
25272
+ doc.addEventListener("focusin", focusIn);
25273
+ doc.addEventListener("focusout", focusOut);
25274
+ if (doc.activeElement && root.contains(doc.activeElement))
25275
+ reporter.focus(doc.activeElement);
25276
+ return {
25277
+ dispose() {
25278
+ root.removeEventListener("pointerover", over);
25279
+ root.removeEventListener("pointermove", move);
25280
+ root.removeEventListener("pointerout", out);
25281
+ doc.removeEventListener("focusin", focusIn);
25282
+ doc.removeEventListener("focusout", focusOut);
25283
+ reporter.dispose();
25284
+ }
25285
+ };
25286
+ }
25287
+ var same3 = (a, b) => a === b || a !== null && b !== null && a.group === b.group && a.key === b.key;
25288
+
25134
25289
  // src/panel/has.ts
25135
25290
  function trackHas(root) {
25136
25291
  const doc = root.ownerDocument;
@@ -25879,6 +26034,20 @@ var init_presence = __esm(() => {
25879
26034
  LOOK = ["opacity", "filter", "scale", "transform-origin"];
25880
26035
  });
25881
26036
 
26037
+ // src/panel/summary.ts
26038
+ function panelFoldSummary(rows, preset) {
26039
+ if (rows.length === 1 && rows[0].value)
26040
+ return rows[0].value;
26041
+ const lead = preset ?? rows.find((r) => r.changed && r.value)?.value ?? rows.find((r) => r.choice && r.value)?.value;
26042
+ const changed = rows.filter((r) => r.changed).length;
26043
+ return [lead, changed ? `${changed} changed` : ""].filter(Boolean).join(" · ");
26044
+ }
26045
+ var baseSignature = (controls) => JSON.stringify([
26046
+ controls.activePreset(),
26047
+ Object.keys(controls.scopes).map((s) => controls.activePreset(s)),
26048
+ Object.keys(controls.groupPresets).map((g) => controls.activeGroupPreset(g))
26049
+ ]);
26050
+
25882
26051
  // src/panel/panel.ts
25883
26052
  function readableTitle(id) {
25884
26053
  if (/\s/.test(id))
@@ -26526,19 +26695,21 @@ function buildPanel(controls, host, options) {
26526
26695
  foldTile(card, next && !filterEl.value.trim(), animate);
26527
26696
  else
26528
26697
  applyVisibility(animate ? "reveal" : "instant");
26529
- if (page) {
26530
- const tile = card.parentElement;
26531
- tile.toggleAttribute("data-folded", next);
26698
+ const marked = page ? card.parentElement : head;
26699
+ if (marked) {
26700
+ marked.toggleAttribute("data-folded", next);
26532
26701
  if (animate) {
26533
- tile.setAttribute("data-fold-motion", "");
26534
- clearTimeout(foldMotions.get(tile));
26535
- foldMotions.set(tile, setTimeout(() => tile.removeAttribute("data-fold-motion"), 400));
26702
+ marked.setAttribute("data-fold-motion", "");
26703
+ clearTimeout(foldMotions.get(marked));
26704
+ foldMotions.set(marked, setTimeout(() => marked.removeAttribute("data-fold-motion"), 400));
26536
26705
  }
26706
+ }
26707
+ if (page) {
26537
26708
  if (remember)
26538
26709
  foldsKept?.set("groups", group, next);
26539
- refreshFolds();
26540
26710
  } else if (sessionKept)
26541
26711
  writeSession(controls.id, { groups: { ...readSession(controls.id).groups, [group]: next } });
26712
+ refreshFolds();
26542
26713
  positioned?.constrain();
26543
26714
  wakeGpu();
26544
26715
  }
@@ -26695,12 +26866,11 @@ function buildPanel(controls, host, options) {
26695
26866
  describeGroup(groupName);
26696
26867
  }
26697
26868
  if (foldAt !== undefined) {
26698
- if (page) {
26699
- summary = el("span");
26700
- summary.className = "knobs-group-summary";
26869
+ summary = el("span");
26870
+ summary.className = "knobs-group-summary";
26871
+ if (page)
26701
26872
  gRight.append(summary);
26702
- groupSummaries.set(groupName, summary);
26703
- }
26873
+ groupSummaries.set(groupName, summary);
26704
26874
  caret = caretSvg();
26705
26875
  gRight.append(caret);
26706
26876
  gHead.dataset.foldable = "";
@@ -26710,6 +26880,8 @@ function buildPanel(controls, host, options) {
26710
26880
  const folded = (page ? foldsKept?.get("groups", groupName) : session.groups?.[groupName]) ?? foldAt;
26711
26881
  folds.set(groupName, folded);
26712
26882
  gHead.setAttribute("aria-expanded", String(!folded));
26883
+ if (!page)
26884
+ gHead.toggleAttribute("data-folded", folded);
26713
26885
  gHead.addEventListener("click", (event) => {
26714
26886
  if (event.detail > 1 || event.target.closest("button"))
26715
26887
  return;
@@ -26721,8 +26893,13 @@ function buildPanel(controls, host, options) {
26721
26893
  gTitle.className = "knobs-group-title";
26722
26894
  gTitle.append(gLabel, gPeek);
26723
26895
  gHead.append(gTitle, gRight);
26724
- } else
26725
- gHead.append(gLabel, gRight);
26896
+ } else {
26897
+ const desc = el("span");
26898
+ desc.className = "knobs-group-desc";
26899
+ groupDescs.set(groupName, desc);
26900
+ describeGroup(groupName);
26901
+ gHead.append(gLabel, desc, ...summary ? [summary] : [], gRight);
26902
+ }
26726
26903
  gpuGroups.push({
26727
26904
  element: gHead,
26728
26905
  labelEl: gLabel,
@@ -27530,7 +27707,7 @@ function buildPanel(controls, host, options) {
27530
27707
  under = name;
27531
27708
  nodes.push(r.row);
27532
27709
  }
27533
- const strip = run.group !== undefined && !run.pinned && !stripped.has(run.group) ? groupStripFor(run.group) : undefined;
27710
+ const strip = run.group !== undefined && !stripped.has(run.group) ? groupStripFor(run.group) : undefined;
27534
27711
  if (strip)
27535
27712
  stripped.add(run.group);
27536
27713
  arrange(block.card, strip ? [strip.element, ...nodes] : nodes);
@@ -27540,6 +27717,8 @@ function buildPanel(controls, host, options) {
27540
27717
  motion.push({ node: r.row, show: true });
27541
27718
  }
27542
27719
  }
27720
+ for (const group of groupDescs.keys())
27721
+ describeGroup(group);
27543
27722
  for (const [id, sh] of subheads)
27544
27723
  if (!usedSubheads.has(id)) {
27545
27724
  sh.el.remove();
@@ -27598,8 +27777,7 @@ function buildPanel(controls, host, options) {
27598
27777
  motion.forEach((m, i) => present(m.node, m.show, i * stagger, m.after));
27599
27778
  }
27600
27779
  applyVisibility(animate ? "reveal" : "instant");
27601
- if (page)
27602
- refreshFolds();
27780
+ refreshFolds();
27603
27781
  if (focused && root.contains(focused) && doc.activeElement !== focused && focused.isConnected)
27604
27782
  focused.focus({ preventScroll: true });
27605
27783
  const gone = focused ? leaving.find((r) => r.row.contains(focused)) : undefined;
@@ -27759,7 +27937,7 @@ function buildPanel(controls, host, options) {
27759
27937
  const searching = () => search?.filtering() ?? false;
27760
27938
  const rowMatches = (r) => {
27761
27939
  const query = search.query();
27762
- if (query && !matchesFilter(query, r.display, r.key, r.group, r.group && groupTitle(r.group), r.field.subsection))
27940
+ if (query && !matchesFilter(query, r.display, r.key, r.group, r.group && groupTitle(r.group), r.group && groupDescription(r.group), r.field.subsection))
27763
27941
  return false;
27764
27942
  for (const chip of search.active())
27765
27943
  if (!r.keys.some((k) => chipTests.get(chip)?.(k)))
@@ -28157,11 +28335,6 @@ function buildPanel(controls, host, options) {
28157
28335
  wakeGpu();
28158
28336
  }
28159
28337
  let shownBase = "";
28160
- const baseSignature = () => JSON.stringify([
28161
- controls.activePreset(),
28162
- Object.keys(controls.scopes).map((s) => controls.activePreset(s)),
28163
- Object.keys(controls.groupPresets).map((g) => controls.activeGroupPreset(g))
28164
- ]);
28165
28338
  function update() {
28166
28339
  if (!controls.enabled) {
28167
28340
  close?.();
@@ -28188,6 +28361,7 @@ function buildPanel(controls, host, options) {
28188
28361
  }
28189
28362
  const stagger = initialized ? cascade(changedCount) : 0;
28190
28363
  const moving = initialized ? rows.filter(changed) : rows;
28364
+ const rebasing = baseSignature(controls) !== shownBase;
28191
28365
  updateStrips();
28192
28366
  batchMeasures(() => {
28193
28367
  for (const row of moving) {
@@ -28200,7 +28374,7 @@ function buildPanel(controls, host, options) {
28200
28374
  primeSegPill(chips.seg, chips.buttons);
28201
28375
  showValues(values, moving, stagger, landing);
28202
28376
  });
28203
- if (moving.length || !initialized)
28377
+ if (moving.length || !initialized || rebasing)
28204
28378
  refreshFolds();
28205
28379
  shown = values;
28206
28380
  initialized = true;
@@ -28226,7 +28400,7 @@ function buildPanel(controls, host, options) {
28226
28400
  const fresh = (r) => r.row.dataset.shown === undefined;
28227
28401
  const now = performance.now();
28228
28402
  let turn = 0;
28229
- const based = baseSignature();
28403
+ const based = baseSignature(controls);
28230
28404
  const rebased = based !== shownBase;
28231
28405
  shownBase = based;
28232
28406
  for (const row of moving) {
@@ -28531,15 +28705,49 @@ function buildPanel(controls, host, options) {
28531
28705
  keys: rows.filter((r) => r.section === section).flatMap((r) => r.keys)
28532
28706
  });
28533
28707
  const changedText = (n) => n && marks ? `${n} changed` : "";
28708
+ const valueText = (r, values) => {
28709
+ const f = r.field;
28710
+ const v = r.value(values);
28711
+ const num = (n) => String(Number(n.toFixed(2)));
28712
+ switch (f.kind) {
28713
+ case "choice":
28714
+ return f.labels?.[v] ?? String(v);
28715
+ case "toggle":
28716
+ return v ? "On" : "Off";
28717
+ case "knob":
28718
+ return `${v.toFixed(f.decimals ?? decimals(f.step))}${f.unit ? ` ${f.unit}` : ""}`;
28719
+ case "spring": {
28720
+ const { duration, bounce } = v;
28721
+ return `${num(duration)} s${bounce ? `, bounce ${num(bounce)}` : ""}`;
28722
+ }
28723
+ case "color":
28724
+ return colorHex(v);
28725
+ case "text":
28726
+ return String(v);
28727
+ default:
28728
+ return "";
28729
+ }
28730
+ };
28731
+ const groupSummary = (group) => {
28732
+ const info = groupInfo(group);
28733
+ if (options.groupSummary)
28734
+ return options.groupSummary(group, info);
28735
+ if (page)
28736
+ return [info.preset, changedText(info.changed)].filter(Boolean).join(" · ") || null;
28737
+ const values = controls.get();
28738
+ const own = rows.filter((r) => r.group === group && !r.leaving && r.keys.length);
28739
+ return panelFoldSummary(own.map((r) => ({
28740
+ value: valueText(r, values),
28741
+ changed: marks && r.keys.some((k) => controls.isModified(k)),
28742
+ choice: r.field.kind === "choice"
28743
+ })), info.preset) || null;
28744
+ };
28534
28745
  function refreshFolds() {
28746
+ for (const [group, node] of groupSummaries)
28747
+ if (folds.get(group))
28748
+ putSummary(node, groupSummary(group));
28535
28749
  if (!layout || !page)
28536
28750
  return;
28537
- for (const [group, node] of groupSummaries) {
28538
- if (!folds.get(group))
28539
- continue;
28540
- const info = groupInfo(group);
28541
- putSummary(node, page.groupSummary ? page.groupSummary(group, info) : [info.preset, changedText(info.changed)].filter(Boolean).join(" · ") || null);
28542
- }
28543
28751
  for (const s of layout.sectionList()) {
28544
28752
  const node = s.label?.querySelector(":scope > .knobs-section-summary");
28545
28753
  if (!node || !s.folded)
@@ -30057,6 +30265,16 @@ function buildPanel(controls, host, options) {
30057
30265
  attributes: true,
30058
30266
  attributeFilter: ["data-knobs-renderer", "data-theme", "data-appearance", "class", "style"]
30059
30267
  });
30268
+ const onActive = options.onActive;
30269
+ const active = onActive ? watchActive(root, (node) => {
30270
+ if (!node || !root.contains(node))
30271
+ return null;
30272
+ const r = rows.find((r) => !r.leaving && r.row.contains(node));
30273
+ if (r)
30274
+ return r.group === undefined ? null : { group: r.group, key: r.key };
30275
+ const card = node.closest("[data-group]");
30276
+ return card && root.contains(card) ? { group: card.dataset.group } : null;
30277
+ }, onActive) : undefined;
30060
30278
  const panel = {
30061
30279
  element: root,
30062
30280
  presetStrip,
@@ -30147,6 +30365,7 @@ function buildPanel(controls, host, options) {
30147
30365
  doc.removeEventListener("pointerup", onRelease, true);
30148
30366
  doc.removeEventListener("pointercancel", onRelease, true);
30149
30367
  crumbs?.dispose();
30368
+ active?.dispose();
30150
30369
  clearTimeout(escTimer);
30151
30370
  win?.removeEventListener("blur", queueScope);
30152
30371
  win?.removeEventListener("focus", queueScope);
@@ -15,7 +15,10 @@ export interface FieldMeta {
15
15
  readonly section?: string;
16
16
  /** What the section's heading shows (the first field that gives one wins); `section` by default. */
17
17
  readonly sectionLabel?: string;
18
- /** One muted line under the group's card title on a page (the first field that gives one wins). */
18
+ /**
19
+ * One muted line under the group's card title, in a panel or a page (the first field that gives
20
+ * one wins).
21
+ */
19
22
  readonly groupDescription?: string;
20
23
  /** One muted line under the section's heading on a page (the first field that gives one wins). */
21
24
  readonly sectionDescription?: string;
@@ -96,7 +96,7 @@ export interface LabelOptions {
96
96
  readonly defaultLabel?: string | Readonly<Record<string, string>>;
97
97
  /** How a page card shows its group presets (see `GroupPresetDisplay`). */
98
98
  readonly groupPresetDisplay?: GroupPresetDisplay;
99
- /** By group id: one muted line under its card's title on a page, over fields' `groupDescription`. */
99
+ /** By group id: one muted line under its card's title, over fields' `groupDescription`. */
100
100
  readonly groupDescriptions?: Readonly<Record<string, string>>;
101
101
  /** By section id: one muted line under its heading on a page, over fields' `sectionDescription`. */
102
102
  readonly sectionDescriptions?: Readonly<Record<string, string>>;
@@ -146,6 +146,20 @@ export interface HeaderOptions {
146
146
  */
147
147
  readonly buttons?: readonly HeaderButton[];
148
148
  }
149
+ /** What's active in a panel (see `MountPanelOptions.onActive`): a card, or one of its rows. */
150
+ export interface PanelActive {
151
+ readonly group: string;
152
+ readonly key?: string;
153
+ }
154
+ /** What a folded card's or section's summary is made of (see `MountPanelOptions.groupSummary`). */
155
+ export interface FoldSummaryInfo {
156
+ /** The group preset its values match (its name), "Custom" when none does; null without any. */
157
+ readonly preset: string | null;
158
+ /** How many of its rows differ from their defaults (a spring's two keys count once). */
159
+ readonly changed: number;
160
+ /** Its value keys. */
161
+ readonly keys: readonly string[];
162
+ }
149
163
  export interface MountPanelOptions extends LabelOptions {
150
164
  readonly title?: string | false;
151
165
  /**
@@ -253,6 +267,20 @@ export interface MountPanelOptions extends LabelOptions {
253
267
  readonly sound?: boolean | SoundOptions;
254
268
  /** Called once the panel is mounted, with a handle to focus it (a host's "Tune" key). */
255
269
  readonly onReady?: (panel: PanelHandle) => void;
270
+ /**
271
+ * Called when the card or row under the pointer or focus changes: `{ group }` for a card's
272
+ * header or body, `{ group, key }` for one of its rows, `null` for nothing (and once more when
273
+ * the panel goes). Whichever of pointer and focus moved last wins; at most once a frame, never
274
+ * the same value twice. For outlining the part of the app a card changes.
275
+ */
276
+ readonly onActive?: (at: PanelActive | null) => void;
277
+ /**
278
+ * What a folded group card's header says on its right. In a panel, by default, a one-row card's
279
+ * value ("CSS orb"); otherwise its matching group preset, else its first changed value, else
280
+ * its first choice, then "· N changed". On a page, "<matching preset, or Custom> · N changed".
281
+ * Each part shows only where it applies; `null` shows nothing.
282
+ */
283
+ readonly groupSummary?: (group: string, info: FoldSummaryInfo) => string | Node | null;
256
284
  /** What Copy (and ⇧C) puts on the clipboard; `controls.code()` by default. */
257
285
  readonly copy?: () => string;
258
286
  /**
@@ -389,13 +417,15 @@ export interface ControlsOptions<S extends Schema> extends LabelOptions {
389
417
  * Groups pinned to the top of a panel, under the header and above the tab strip, on every tab
390
418
  * (as fields' `pinned: true` pins theirs). Presets and saved variants leave them alone: applying
391
419
  * one keeps their values, saving one keeps them out, and they're modified against their defaults.
420
+ * A scope keyed by the group's own name (`scopedPresets`) still moves it.
392
421
  */
393
422
  pinned?: readonly string[];
394
423
  /**
395
424
  * Groups presets leave alone, where they are: `{ Motion: false }`. As with pinned groups,
396
425
  * applying a preset or variant keeps their values, saving one leaves them out, and a preset's chip
397
- * doesn't drift when they change; but their rows stay in place. A field says it alone with
398
- * `inPresets: false`.
426
+ * doesn't drift when they change; but their rows stay in place. A scope keyed by the group's own
427
+ * name (`scopedPresets: { Motion: … }`) still moves it: only other scopes' presets leave it
428
+ * alone. A field says it alone with `inPresets: false`, and then no scope moves it.
399
429
  */
400
430
  inPresets?: Readonly<Record<string, boolean>>;
401
431
  /**
@@ -449,7 +479,7 @@ export interface Controls<S extends Schema = Schema> {
449
479
  readonly scopes: Readonly<Record<string, PresetScope<Values<S>>>>;
450
480
  /**
451
481
  * The scope `key`'s presets come from: a tab or group's id, `""` for `presets`, or undefined
452
- * when presets leave it alone (pinned, `inPresets: false`).
482
+ * when presets leave it alone (pinned, `inPresets: false`, without a scope of its group's own).
453
483
  */
454
484
  scopeOf(key: string): string | undefined;
455
485
  readonly actions: readonly ControlAction<Values<S>>[];
@@ -485,7 +515,10 @@ export interface Controls<S extends Schema = Schema> {
485
515
  presetLabel(id: string, scope?: string): string;
486
516
  /** Whether `key` is pinned (its group is, see `ControlsOptions.pinned`), outside the presets. */
487
517
  isPinned(key: string): boolean;
488
- /** Whether presets move `key`: false when it's pinned or says `inPresets: false` (or its group does). */
518
+ /**
519
+ * Whether presets move `key`: false when it's pinned or says `inPresets: false` (or its group
520
+ * does). Its group's own scope may still move it (see `scopeOf`).
521
+ */
489
522
  inPresets(key: string): boolean;
490
523
  readonly error: string | null;
491
524
  get(): Values<S>;
@@ -5,7 +5,7 @@ export { colorHex, parseColor, type Oklch } from "./core/color.js";
5
5
  export { sampleGradient, sampleCurve, gradientCss, CURVE_SHAPES, type GradientStop, type GradientSpace, type CurvePoint, type CurveInterp, type SampleGradientOptions, type SampleCurveOptions, } from "./core/ramp.js";
6
6
  export { bezierCss, bezierPoint, EASINGS, type Bezier, type EasingName } from "./core/easing.js";
7
7
  export { AROUND } from "./core/random.js";
8
- export { defineControls, type ChangeMeta, type GestureEvent, type ChangeSource, type ChipOptions, type ControlAction, type Controls, type ControlsOptions, type DensityMode, type GroupPreset, type GroupPresetDisplay, type PresetRef, type HeaderButton, type HeaderOptions, type HostHistory, type LabelOptions, type MountPanelOptions, type PanelCommand, type PanelHandle, type PanelModule, type PanelKeys, type PersistMode, type SearchChip, type SearchOptions, type SyncOptions, type RendererMode, type RevealMode, type SectionFolded, type SoundOptions, type SoundTheme, type ThemeMode, type Appearance, } from "./core/store.js";
8
+ export { defineControls, type ChangeMeta, type GestureEvent, type ChangeSource, type ChipOptions, type ControlAction, type Controls, type ControlsOptions, type DensityMode, type FoldSummaryInfo, type GroupPreset, type GroupPresetDisplay, type PresetRef, type HeaderButton, type HeaderOptions, type HostHistory, type LabelOptions, type MountPanelOptions, type PanelActive, type PanelCommand, type PanelHandle, type PanelModule, type PanelKeys, type PersistMode, type SearchChip, type SearchOptions, type SyncOptions, type RendererMode, type RevealMode, type SectionFolded, type SoundOptions, type SoundTheme, type ThemeMode, type Appearance, } from "./core/store.js";
9
9
  export { createRemoteControls, descriptorToField, formatSwift, fromDescriptors, fromSettings, type SettingsGroup, type SettingsItem, type ActionDescriptor, type ChoiceOption, type ControlDescriptor, type SpringBindingDescriptor, type ToolbarDescriptor, type ReadoutDescriptor, type ValueDescriptor, type RemoteControls, type RemoteControlsOptions, } from "./core/remote.js";
10
10
  export type { LogEntry, LogLevel, TableRows, WatchValue } from "./core/readout.js";
11
11
  export { memoryStorage, urlFlag, type StoragePort } from "./core/storage.js";
@@ -0,0 +1,53 @@
1
+ import type { PanelActive } from "../core/store.js";
2
+ /**
3
+ * What's active in a panel, from where the pointer is and where focus is, each told as it moves.
4
+ * Whichever last moved onto something leads; while it's on nothing (or off the panel), the
5
+ * other's target stands. `report` hears the result at most once a frame (`schedule`), and never
6
+ * the same value twice in a row.
7
+ */
8
+ export declare function activeReporter<T>(resolve: (target: T | null) => PanelActive | null, report: (at: PanelActive | null) => void, schedule: (flush: () => void) => () => void): {
9
+ pointer(target: T | null): void;
10
+ focus(target: T | null): void;
11
+ /** Stops listening; what was active is no longer, so a last `null` is reported. */
12
+ dispose(): void;
13
+ };
14
+ /** Where an event's node is: in the panel, in one of its menus (on the body), or elsewhere. */
15
+ export type ActiveWhere = "inside" | "menu" | "outside";
16
+ /**
17
+ * The panel's pointer and focus events, told to `reporter` (see `activeReporter`). Focus that
18
+ * moves into one of the panel's menus keeps the row that opened it until it comes back, moves
19
+ * elsewhere, or leaves the menu for nowhere (a blur, the menu closing without handing it back).
20
+ * A pointer that moves (not just one the page scrolled under) takes the lead back from focus.
21
+ */
22
+ export declare function activeEvents<T>(reporter: {
23
+ pointer(target: T | null): void;
24
+ focus(target: T | null): void;
25
+ }, where: (node: T | null) => ActiveWhere): {
26
+ over(e: {
27
+ readonly target: T | null;
28
+ }): void;
29
+ move(e: {
30
+ readonly target: T | null;
31
+ readonly movementX: number;
32
+ readonly movementY: number;
33
+ }): void;
34
+ out(e: {
35
+ readonly relatedTarget: T | null;
36
+ }): void;
37
+ /** On the document: focus arriving anywhere. */
38
+ focusIn(e: {
39
+ readonly target: T | null;
40
+ }): void;
41
+ /** On the document: focus going nowhere (the window losing it, a blur) fires no focusin. */
42
+ focusOut(e: {
43
+ readonly target: T | null;
44
+ readonly relatedTarget: T | null;
45
+ }): void;
46
+ };
47
+ /**
48
+ * Tells `report` what the pointer or focus is on in `root` (see `activeReporter` and
49
+ * `activeEvents`), resolving the element under them with `resolve`. Returns a cleanup.
50
+ */
51
+ export declare function watchActive(root: HTMLElement, resolve: (node: Element | null) => PanelActive | null, report: (at: PanelActive | null) => void): {
52
+ dispose(): void;
53
+ };
@@ -399,9 +399,9 @@ export interface GpuGroupBinding {
399
399
  readonly peek?: HTMLElement;
400
400
  /** On a page: the header's controls (presets, reset, die), hidden while the card is folded. */
401
401
  readonly controls?: HTMLElement;
402
- /** On a page: what a folded card's header says in place of its controls. */
402
+ /** What a folded card's header says (on a page, in place of its controls). */
403
403
  readonly summary?: HTMLElement;
404
- /** On a page: the muted line under the card's title. */
404
+ /** The muted line under the card's title. */
405
405
  readonly desc?: HTMLElement;
406
406
  /** On a page: the group's presets, as named or numbered segments, or a compact menu. */
407
407
  readonly presets?: {
@@ -732,6 +732,12 @@ declare class GpuOverlayManager {
732
732
  private clipCache;
733
733
  /** Whether the DOM lays `el`'s text out wider than its box (so text-overflow shows "…"). */
734
734
  private domClipped;
735
+ private lineCache;
736
+ /**
737
+ * The lines the DOM wraps `el`'s text into, each with its middle's offset from `el`'s top, read
738
+ * back word by word (kept until the text or the width changes).
739
+ */
740
+ private linesOf;
735
741
  private curReduced;
736
742
  private curLocks;
737
743
  /** The current row's selection wash (0…1), which its hover wash gives way to. */
@@ -5,6 +5,7 @@ export { computeHintPrefixes, type HintBadgeState, type HintCandidate, type Hint
5
5
  export { assignPageCodes, hints, pageHints, type HintPainter, type HintSourceOptions, type PageHints, type PageHintTarget, } from "./page-hints.js";
6
6
  export { Spring, VelocityTracker } from "./motion.js";
7
7
  export { createPanel, mountPage, mountPanel, type Panel, type PanelSearch } from "./panel.js";
8
+ export type { PanelActive } from "../core/store.js";
8
9
  export { balancedRuns, type FoldSummaryInfo, type MountPageOptions, type Page, type PageAction, type PageCard, type PageCardOptions, type PageControl, type PageFoldOptions, type PageFoldSource, type PageGroupInfo, type PageHighlight, type PageSectionInfo, } from "./page.js";
9
10
  export { createPresetStrip, nextPresetIndex, resolveActivePresetEntry, type ActivePresetState, type PresetEntry, type PresetStrip, } from "./presets.js";
10
11
  export { sound, type CueDetail, type Sound, type SoundCue, type SoundLayer, type SoundOptions, type SoundTheme, } from "./sound.js";