partforge 0.47.0 → 0.47.1

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.
@@ -551,6 +551,12 @@ advanced: [
551
551
  ],
552
552
  ```
553
553
 
554
+ **Collapsing.** Each section is a disclosure. A panel with **three or fewer
555
+ sections opens every section and every Advanced fold on load**; beyond that they
556
+ all start closed, because the rail is a fixed-height column and a long part
557
+ otherwise scrolls forever. Set `collapsed: true` or `collapsed: false` on a
558
+ section to override the rule in either direction.
559
+
554
560
  ---
555
561
 
556
562
  ## Designing the control panel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.47.0",
3
+ "version": "0.47.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -197,12 +197,16 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
197
197
  bubbleWidth = chapterBubble.offsetWidth;
198
198
  }
199
199
  // Stage-relative, because the bubble lives on the stage rather than in the
200
- // wrap: track the point along the timeline, then lift clear of the bar.
200
+ // wrap: track the point along the timeline, then lift clear of the bar
201
+ // the BAR's top, not the wrap's. The touch layout wraps the bar into rows
202
+ // with the timeline on the lower one, so "above the wrap" would sit the
203
+ // bubble on the chooser row. checkTransportTargets pins this.
201
204
  const wrapRect = scrubWrap.getBoundingClientRect();
205
+ const barRect = bar.getBoundingClientRect();
202
206
  const stageRect = container.getBoundingClientRect();
203
207
  chapterBubble.style.left =
204
208
  `${wrapRect.left - stageRect.left + clampBubbleX(f, wrapRect.width, bubbleWidth)}px`;
205
- chapterBubble.style.bottom = `${stageRect.bottom - wrapRect.top + 8}px`;
209
+ chapterBubble.style.bottom = `${stageRect.bottom - barRect.top + 8}px`;
206
210
  chapterBubble.classList.add("pf-show");
207
211
  clearTimeout(bubbleFadeTimer);
208
212
  bubbleFadeTimer = 0;
@@ -64,10 +64,19 @@ canvas { display: block; }
64
64
  .section:not(.section-hidden) ~ .section:not(.section-hidden) {
65
65
  border-top: 1px solid var(--pf-border);
66
66
  }
67
+ .sec-header { display: flex; align-items: center; gap: 4px; }
67
68
  .sec-title {
69
+ flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 8px;
70
+ width: 100%; margin: 0 0 9px; padding: 0; border: 0; background: transparent; cursor: pointer;
71
+ text-align: left;
68
72
  font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
69
- letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2); margin-bottom: 9px;
73
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--pf-muted-2);
70
74
  }
75
+ .sec-title:hover { color: var(--pf-text-2); }
76
+ .sec-title .chev { display: inline-block; transition: transform 0.15s ease; }
77
+ .sec-title .chev::before { content: "▾"; }
78
+ .sec-title[aria-expanded="false"] .chev { transform: rotate(-90deg); }
79
+ .sec-body.hidden { display: none; }
71
80
  select.preset {
72
81
  width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
73
82
  border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px;
@@ -159,11 +168,20 @@ button.action:disabled { opacity: .5; cursor: default; }
159
168
 
160
169
  /* keyboard focus ring shared across the panel's interactive controls */
161
170
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
162
- button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
171
+ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible {
163
172
  outline: none;
164
173
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
165
174
  }
166
175
 
176
+ /* `when` disabling. Distinct from .irrelevant (relevance dimming) on purpose:
177
+ two mechanisms that look identical make the panel impossible to reason about. */
178
+ .disabled { opacity: 0.5; pointer-events: none; }
179
+
180
+ /* The wrapper a titled inner group renders into. Conditions hide the wrapper;
181
+ the disclosure hides `.adv` inside it. Unused until `when` becomes authorable
182
+ in phase 5, but the class exists from Task 7 and must have a rule. */
183
+ .adv-wrap.hidden { display: none; }
184
+
167
185
  /* part tabs (placement: .pf-float-tabs; legacy markup keeps the old float) */
168
186
  #topbar:not(.pf-float-tabs) { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 15; }
169
187
  #topbar .seg {
@@ -288,6 +306,59 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
288
306
  .pf-anim-bar { min-height: 40px; }
289
307
  }
290
308
 
309
+ /* ---- transport bar, touch layout ----------------------------------------
310
+ The rules above size the BAR; these size the things you press inside it. At
311
+ a 13px glyph in 2px/4px of padding, play, reset and the ‹ › pagers measure
312
+ ~20x20, with 8px between neighbours — mouse targets. Measured on a 390px
313
+ stage: a tap 12px off the pause button's centre, ordinary finger error,
314
+ landed on the bar's background and did nothing; a little further landed on
315
+ a pager, which SWITCHES ANIMATION. The scrubber fared worse, flex-shrinking
316
+ toward nothing.
317
+
318
+ So give every control the 44px minimum, and accept the rows that costs: at
319
+ 44px per target they do not fit a 320-390px stage on one line.
320
+
321
+ Both query conditions matter. The width half is the framework's own narrow
322
+ layout (chrome.css's 720px breakpoint, measured on the STAGE — partforge in
323
+ an iframe sized to a phone-width card gets it too); the pointer half catches
324
+ a phone in landscape, wider than 720px and still all thumbs.
325
+
326
+ The bar stays a single flat flex line that WRAPS, in source order — no
327
+ `order` overrides, deliberately: a reordered flex row splits visual order
328
+ from DOM order, and with it from focus order, so a keyboard on a narrow
329
+ window would tab in spatial zigzags. Wrapping in source order keeps
330
+ reading, tab and touch order one sequence, and keeps the pagers bracketing
331
+ the bar's ends like the wide layout:
332
+
333
+ ‹ [ animation v ] (i) ▶ <- wraps where the width runs out
334
+ [===·===·==o===] ↺ ›
335
+
336
+ The timeline takes whatever width its row has left and drops to a wider
337
+ row of its own below ~360px. scripts/check-app.mjs's checkTransportTargets
338
+ pins the result. */
339
+ @media (max-width: 719px), (pointer: coarse) {
340
+ .pf-anim-bar {
341
+ flex-wrap: wrap; justify-content: center;
342
+ column-gap: 4px; row-gap: 2px; padding: 4px 8px;
343
+ }
344
+ .pf-anim-bar button {
345
+ min-width: 44px; min-height: 44px; padding: 0;
346
+ display: inline-flex; align-items: center; justify-content: center;
347
+ }
348
+ .pf-anim-title, .pf-anim-pick { min-width: 0; }
349
+ /* The picker is a target too, not a caption — it opens the animation list. */
350
+ .pf-anim-pick { max-width: 100%; min-height: 44px; padding: 3px 10px; }
351
+ /* The 200px basis makes the timeline BID for real room: at a phone width it
352
+ loses the bid, wraps, and inherits most of a row; 120px is the floor it
353
+ can be squeezed to when sharing one. */
354
+ .pf-anim-scrub-wrap { flex: 1 1 200px; min-width: 120px; width: auto; min-height: 44px; }
355
+ /* The INPUT is the thing a finger has to land on — height on the wrap alone
356
+ leaves an ~18px native strip as the real target, with dead slack around
357
+ it. The track paints centered, so this is invisible; only the hit area
358
+ grows. */
359
+ .pf-anim-scrub { min-height: 44px; }
360
+ }
361
+
291
362
  /* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
292
363
  (bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
293
364
  top-left floating #panel card. Legacy markup still floats #viewbar top-right
@@ -250,6 +250,26 @@
250
250
  .pf-anim-bar { bottom: 64px; }
251
251
  }
252
252
 
253
+ /* Placement half of app.css's touch layout — same query, so the two must be
254
+ edited together. That layout stacks the bar into rows, and a shrink-to-fit
255
+ absolutely-positioned box sizes itself to its widest single item (195px,
256
+ measured at 390px) rather than to the room available, wrapping every row far
257
+ earlier than it needs to. State the width outright.
258
+
259
+ The lift off the viewbar comes along for a landscape phone, which is wider
260
+ than 719px and so misses the block above. It also keeps this clear of the
261
+ viewbar CLAMP in animation-controls.js: with the bands no longer
262
+ intersecting, applyPlacement() returns before touching left/transform, so
263
+ the stacked bar stays centred instead of being slid sideways by a rule that
264
+ was reasoning about a single-row bar.
265
+
266
+ The cap is for a tablet — a coarse pointer on a wide stage — where an
267
+ unbounded stacked bar would span 700px+ and strand the reset button across a
268
+ mostly empty row. A phone is narrower than the cap and still fills its width. */
269
+ @media (max-width: 719px), (pointer: coarse) {
270
+ .pf-anim-bar { width: calc(100% - 24px); max-width: 520px; bottom: 64px; }
271
+ }
272
+
253
273
 
254
274
 
255
275
  /* ---- reduced motion -----------------------------------------------------
@@ -1,364 +1,15 @@
1
- // Builds the sectioned control panel from a part's `parameters` schema.
1
+ // The control panel's public entry point. The implementation lives in panel/:
2
2
  //
3
- // Most sections show a preset picker (below the title) plus an expandable
4
- // "Advanced" block of sliders. The "features" section instead puts, under
5
- // Advanced, a checkbox per feature followed by its own controls — ticking one
6
- // enables it and reveals those controls right below it.
7
- // All controls mutate the shared `params` object and call onDirty() on change.
8
-
9
- import { renderMarkdown } from "./markdown.js";
10
-
11
- // Short numeric string without float noise (4 dp max) for the value box.
12
- const numStr = (v) => String(Math.round(v * 1e4) / 1e4);
13
-
14
- // --- relevance (dim controls / hide sections that don't affect on-screen parts) ---
15
- // `relevant` is a Set of param keys, or any non-Set value (e.g. RELEVANT_ALL) → show all.
16
- function applyRelevance(relevant, controls, sections) {
17
- const showAll = !(relevant instanceof Set);
18
- for (const { key, el: node } of controls) {
19
- const irrelevant = !showAll && !relevant.has(key);
20
- node.classList.toggle("irrelevant", irrelevant);
21
- if (irrelevant) node.title = "Doesn't affect the parts in the current view";
22
- else node.removeAttribute("title");
23
- }
24
- for (const { el: node, keys } of sections) {
25
- const anyRelevant = showAll || [...keys].some((k) => relevant.has(k));
26
- node.classList.toggle("section-hidden", !anyRelevant);
27
- }
28
- }
29
-
30
- // --- visibility (hidden controls/sections) --------------------------------
31
- export const visibleAdvanced = (sec) => (sec.advanced ?? []).filter((d) => !d.hidden);
32
- export const visibleFeatures = (sec) => (sec.features ?? []).filter((f) => !f.hidden);
33
- // Standalone toggle checkboxes a preset section can show (outside the Advanced fold),
34
- // e.g. preview switches. Each: { key, label, on?, description?, hidden? }.
35
- export const visibleToggles = (sec) => (sec.toggles ?? []).filter((t) => !t.hidden);
36
- export function sectionRenders(sec) {
37
- if (sec.hidden) return false;
38
- if (sec.features) return visibleFeatures(sec).length > 0;
39
- const hasPresets = sec.presets && Object.keys(sec.presets).length > 0;
40
- return !!hasPresets || visibleAdvanced(sec).length > 0 || visibleToggles(sec).length > 0;
41
- }
42
-
43
- // Parse a typed value → clamped to [min, max], or null if not a finite number.
44
- export function clampToRange(raw, min, max) {
45
- const v = parseFloat(raw);
46
- if (!Number.isFinite(v)) return null;
47
- return Math.min(max, Math.max(min, v));
48
- }
49
-
50
- // --- info glyph + per-panel popover -----------------------------------------
51
- // Popover top edge: below the glyph when it fits, flipped above when the
52
- // viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
53
- // sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
54
- // reports zero layout metrics, so the flip can't be exercised via the DOM.
55
- export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
56
- const below = glyphBottom + 6;
57
- if (below + popHeight <= viewportHeight - 8) return below;
58
- return Math.max(8, glyphTop - 6 - popHeight);
59
- }
60
-
61
- // One popover element per panel, shared by all its glyphs (only one open at a
62
- // time). Document-level dismiss listeners are registered per panel and removed
63
- // by panel.dispose().
64
- export function createInfoPopover() {
65
- const pop = el("div", "popover");
66
- pop.hidden = true;
67
- document.body.append(pop);
68
- let owner = null; // the glyph whose description is showing
69
-
70
- function close() {
71
- if (pop.hidden) return;
72
- pop.hidden = true;
73
- if (owner) { owner.setAttribute("aria-expanded", "false"); owner = null; }
74
- }
75
- const onDocClick = (e) => {
76
- if (!pop.hidden && !pop.contains(e.target) && !e.target.closest?.(".info")) close();
77
- };
78
- const onDocKeydown = (e) => { if (e.key === "Escape") close(); };
79
- document.addEventListener("click", onDocClick);
80
- document.addEventListener("keydown", onDocKeydown);
81
-
82
- return {
83
- toggle(glyph, description) {
84
- if (owner === glyph) { close(); return; } // toggle off
85
- close();
86
- pop.innerHTML = renderMarkdown(description);
87
- pop.hidden = false;
88
- owner = glyph;
89
- glyph.setAttribute("aria-expanded", "true");
90
- const r = glyph.getBoundingClientRect();
91
- pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
92
- pop.style.left = `${Math.max(8, r.left - 8)}px`;
93
- },
94
- dispose() {
95
- document.removeEventListener("click", onDocClick);
96
- document.removeEventListener("keydown", onDocKeydown);
97
- pop.remove();
98
- },
99
- };
100
- }
101
-
102
- // Append a focusable ⓘ glyph to `container` that toggles the panel's shared
103
- // popover with `description` (Markdown). No-op when description is empty.
104
- export function attachInfo(container, description, info) {
105
- if (typeof description !== "string" || !description.trim()) return;
106
- const glyph = document.createElement("button");
107
- glyph.type = "button";
108
- glyph.className = "info";
109
- glyph.textContent = "ⓘ";
110
- glyph.setAttribute("aria-label", "More info");
111
- glyph.setAttribute("aria-expanded", "false");
112
- glyph.addEventListener("click", (e) => { e.stopPropagation(); info.toggle(glyph, description); });
113
- container.append(glyph);
114
- }
115
-
116
- function el(tag, className, text) {
117
- const node = document.createElement(tag);
118
- if (className) node.className = className;
119
- if (text != null) node.textContent = text;
120
- return node;
121
- }
122
-
123
- // One parameter control bound to params[def.key]. `def.control`:
124
- // "slider" (default) — range slider + an editable number box (drag OR type)
125
- // "number" — number box only (no slider)
126
- // "text" — single-line text field
127
- // "textarea" — multiline text field
128
- // The box accepts exact values (finer than `step`); typed values clamp to
129
- // [min, max] on commit (blur/Enter). Returns { wrap, sync }.
130
- function makeSlider(def, params, onChange, info) {
131
- const numeric = def.control === "number";
132
- const wrap = el("div", "slider");
133
- const row = el("div", "row");
134
- const label = el("label", "", def.label);
135
- attachInfo(label, def.description, info);
136
- row.append(label);
137
-
138
- // editable value box (+ optional unit suffix)
139
- const val = el("div", "val");
140
- const box = document.createElement("input");
141
- box.type = "number";
142
- box.className = "num";
143
- box.min = def.min; box.max = def.max; box.step = def.step;
144
- box.value = numStr(params[def.key]);
145
- val.append(box);
146
- if (def.unit) val.append(el("span", "unit", def.unit));
147
- row.append(val);
148
- wrap.append(row);
149
-
150
- let slider = null;
151
- if (!numeric) {
152
- slider = document.createElement("input");
153
- slider.type = "range";
154
- slider.min = def.min; slider.max = def.max; slider.step = def.step;
155
- slider.value = params[def.key];
156
- slider.addEventListener("input", () => {
157
- params[def.key] = +slider.value;
158
- box.value = numStr(+slider.value);
159
- onChange?.();
160
- });
161
- wrap.append(slider);
162
- }
163
-
164
- // live preview while typing (unclamped); clamp + reformat on commit (blur/Enter)
165
- box.addEventListener("input", () => {
166
- const v = parseFloat(box.value);
167
- if (!Number.isFinite(v)) return;
168
- params[def.key] = v;
169
- if (slider) slider.value = v;
170
- onChange?.();
171
- });
172
- box.addEventListener("change", () => {
173
- const v = clampToRange(box.value, def.min, def.max);
174
- if (v == null) { box.value = numStr(params[def.key]); return; } // revert invalid input
175
- params[def.key] = v;
176
- box.value = numStr(v);
177
- if (slider) slider.value = v;
178
- onChange?.();
179
- });
180
-
181
- const sync = () => {
182
- box.value = numStr(params[def.key]);
183
- if (slider) slider.value = params[def.key];
184
- };
185
- return { wrap, sync };
186
- }
187
-
188
- function makeTextControl(def, params, onChange, info) {
189
- const multiline = def.control === "textarea";
190
- const wrap = el("div", "slider");
191
- const row = el("div", "row");
192
- const label = el("label", "", def.label);
193
- attachInfo(label, def.description, info);
194
- row.append(label);
195
- wrap.append(row);
196
-
197
- const field = document.createElement(multiline ? "textarea" : "input");
198
- if (!multiline) field.type = "text";
199
- field.className = "text-input";
200
- field.value = String(params[def.key] ?? "");
201
- field.addEventListener("input", () => {
202
- params[def.key] = field.value;
203
- onChange?.();
204
- });
205
- wrap.append(field);
206
-
207
- const sync = () => { field.value = String(params[def.key] ?? ""); };
208
- return { wrap, sync };
209
- }
210
-
211
- const makeParameterControl = (def, params, onChange, info) =>
212
- def.control === "text" || def.control === "textarea"
213
- ? makeTextControl(def, params, onChange, info)
214
- : makeSlider(def, params, onChange, info);
215
-
216
- // A collapsible "Advanced ▾" block. Returns { adv, toggle }.
217
- function advancedBlock() {
218
- const adv = el("div", "adv hidden");
219
- const toggle = el("button", "adv-toggle", "Advanced ▾");
220
- toggle.addEventListener("click", () => {
221
- const hidden = adv.classList.toggle("hidden");
222
- toggle.textContent = hidden ? "Advanced ▾" : "Advanced ▴";
223
- });
224
- return { adv, toggle };
225
- }
226
-
227
- export function buildControls(root, parameters, params, onDirty) {
228
- const info = createInfoPopover();
229
- const controls = []; // { key, el } per control element
230
- const sections = []; // { el, keys:Set } per rendered section
231
- const syncFns = []; // { key, sync } for every widget that can re-read params
232
- for (const sec of parameters) {
233
- if (!sectionRenders(sec)) continue;
234
- const section = el("div", "section");
235
- const title = el("div", "sec-title", sec.title);
236
- attachInfo(title, sec.description, info);
237
- section.append(title);
238
- const keys = new Set();
239
- const register = (key, node, sync) => {
240
- controls.push({ key, el: node });
241
- keys.add(key);
242
- if (sync) syncFns.push({ key, sync });
243
- };
244
- if (sec.features) buildFeatureSection(section, sec, params, onDirty, register, info);
245
- else buildPresetSection(section, sec, params, onDirty, register, info);
246
- root.append(section);
247
- sections.push({ el: section, keys });
248
- }
249
- return {
250
- applyRelevance: (relevant) => applyRelevance(relevant, controls, sections),
251
- // Re-read params into the widgets — all of them, or just `keys`. The
252
- // programmatic twin of a user edit (setParams); never fires onDirty.
253
- syncValues: (keys) => {
254
- const only = keys && new Set(keys);
255
- for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
256
- },
257
- dispose: () => { info.dispose(); root.replaceChildren(); },
258
- };
259
- }
260
-
261
- function buildPresetSection(section, sec, params, onDirty, register, info) {
262
- // preset picker, below the title, full width (omitted when the section has no presets)
263
- let preset = null;
264
- const presetNames = sec.presets ? Object.keys(sec.presets) : [];
265
- if (presetNames.length) {
266
- preset = document.createElement("select");
267
- preset.className = "preset";
268
- for (const name of [...presetNames, "Custom"]) {
269
- const o = document.createElement("option");
270
- o.value = name; o.textContent = name; preset.append(o);
271
- }
272
- preset.value = presetNames[0];
273
- section.append(preset);
274
- }
275
-
276
- // standalone toggle checkboxes (e.g. preview switches), shown below the preset and
277
- // outside the Advanced fold so they stay visible. Independent of the preset selector.
278
- for (const t of visibleToggles(sec)) {
279
- const row = el("label", "feat");
280
- const box = document.createElement("input");
281
- box.type = "checkbox";
282
- box.checked = params[t.key] > 0;
283
- const lbl = el("span", "", t.label);
284
- attachInfo(lbl, t.description, info);
285
- row.append(box, lbl);
286
- box.addEventListener("change", () => { params[t.key] = box.checked ? (t.on ?? 1) : 0; onDirty?.(); });
287
- register(t.key, row, () => { box.checked = params[t.key] > 0; });
288
- section.append(row);
289
- }
290
-
291
- const advanced = visibleAdvanced(sec);
292
- const syncs = {};
293
- if (advanced.length) {
294
- const { adv, toggle } = advancedBlock();
295
- for (const def of advanced) {
296
- const s = makeParameterControl(def, params, () => { if (preset) preset.value = "Custom"; onDirty?.(); }, info);
297
- adv.append(s.wrap);
298
- syncs[def.key] = s.sync; // raw: preset APPLICATION must not mark itself Custom
299
- // A programmatic edit diverges from the preset exactly as a user edit does,
300
- // so the picker falls back to Custom — leaving a stale preset name selected
301
- // would also make it unre-appliable (no change event for the current option).
302
- register(def.key, s.wrap, () => { s.sync(); if (preset) preset.value = "Custom"; });
303
- }
304
- section.append(toggle, adv);
305
- }
306
-
307
- // applying a preset overwrites its keys and refreshes this section's sliders
308
- if (preset) {
309
- preset.addEventListener("change", () => {
310
- const bundle = sec.presets[preset.value];
311
- if (!bundle) return; // "Custom"
312
- Object.assign(params, bundle);
313
- for (const key in syncs) if (key in params) syncs[key]();
314
- onDirty?.();
315
- });
316
- }
317
- }
318
-
319
- function buildFeatureSection(section, sec, params, onDirty, register, info) {
320
- // Everything lives under Advanced: each feature is a checkbox followed by its
321
- // own controls, which appear directly below it when the box is checked.
322
- const { adv, toggle } = advancedBlock();
323
- section.append(toggle, adv);
324
-
325
- for (const feat of visibleFeatures(sec)) {
326
- const checkRow = el("label", "feat");
327
- const box = document.createElement("input");
328
- box.type = "checkbox";
329
- box.checked = params[feat.key] > 0;
330
- const featLabel = el("span", "", feat.label);
331
- attachInfo(featLabel, feat.description, info);
332
- checkRow.append(box, featLabel);
333
- // `group` is created just below — the sync only ever runs after this
334
- // function returns, so the closure is safely bound by then.
335
- register(feat.key, checkRow, () => {
336
- box.checked = params[feat.key] > 0;
337
- group.classList.toggle("hidden", !box.checked);
338
- });
339
-
340
- const group = el("div", "feat-group");
341
- const syncs = [];
342
- for (const def of feat.sliders.filter((d) => !d.hidden)) {
343
- const s = makeParameterControl(def, params, onDirty, info);
344
- group.append(s.wrap);
345
- syncs.push(s.sync);
346
- register(def.key, s.wrap, s.sync);
347
- }
348
- group.classList.toggle("hidden", !box.checked);
349
-
350
- box.addEventListener("change", () => {
351
- if (box.checked) {
352
- if (!(params[feat.key] > 0)) params[feat.key] = feat.on; // enable
353
- syncs.forEach((s) => s());
354
- group.classList.remove("hidden");
355
- } else {
356
- params[feat.key] = 0; // disable
357
- group.classList.add("hidden");
358
- }
359
- onDirty?.();
360
- });
361
-
362
- adv.append(checkRow, group); // checkbox, then its controls right below
363
- }
364
- }
3
+ // panel/legacy.js the original advanced/toggles/features shapes
4
+ // panel/model.js canonical nodes -> render tree, plus conditions
5
+ // panel/widget-specs.js the control-type registry (shared with partforge/lint)
6
+ // panel/panel-state.js one pure pass for visibility/disabling/dimming
7
+ // panel/widgets/ one DOM factory per type
8
+ // panel/render.js the DOM binder
9
+ //
10
+ // This file stays at its path because animation-controls.js imports the popover
11
+ // helpers from it, and because it is the documented import site.
12
+ export { buildControls } from "./panel/render.js";
13
+ export { popoverTop, createInfoPopover, attachInfo } from "./panel/info.js";
14
+ export { clampToRange } from "./panel/widgets/numeric.js";
15
+ export { visibleAdvanced, visibleFeatures, visibleToggles, sectionRenders } from "./panel/legacy.js";
@@ -9,6 +9,8 @@ import { EASINGS } from "../animation.js";
9
9
  import { CANONICAL_VIEWS } from "../view-angles.js";
10
10
  import { probeSubPartPose } from "../pose-probe-core.js";
11
11
  import { resolveDerived } from "../derive.js";
12
+ import { desugar } from "../panel/legacy.js";
13
+ import { controlNodes } from "../panel/model.js";
12
14
 
13
15
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
14
16
 
@@ -22,20 +24,19 @@ const animEntries = (part) =>
22
24
  // rule checks its own slice). A bare-tracks animation is one anonymous step.
23
25
  const rawSteps = (a) => (Array.isArray(a.steps) ? a.steps.filter(isPlainObject) : [{ ...a, label: null }]);
24
26
 
25
- // The control descriptor ranges, for value-in-range checks. Mirrors
26
- // rules-schema.js's collectDescriptors walk (not shared: each group owns its
27
- // own walk by design see lint/index.js header).
27
+ // The control descriptor ranges, for value-in-range checks. Walks the shared
28
+ // panel model rather than re-implementing the schema walk before that model
29
+ // existed this duplicated rules-schema.js's collectDescriptors by design, and
30
+ // the two could drift.
31
+ //
32
+ // desugar() is used directly, WITHOUT buildTree(): hidden controls must stay in,
33
+ // because an animation may legitimately drive a parameter with no visible UI.
28
34
  function paramRanges(part) {
29
35
  const ranges = new Map();
30
- const secs = Array.isArray(part?.parameters) ? part.parameters : [];
31
- const add = (d) => {
32
- if (d && typeof d.key === "string" && !ranges.has(d.key)) ranges.set(d.key, { min: d.min, max: d.max });
33
- };
34
- for (const sec of secs) {
35
- for (const d of Array.isArray(sec?.advanced) ? sec.advanced : []) add(d);
36
- for (const f of Array.isArray(sec?.features) ? sec.features : []) {
37
- for (const s of Array.isArray(f?.sliders) ? f.sliders : []) add(s);
38
- }
36
+ for (const c of controlNodes(desugar(part?.parameters))) {
37
+ // A checkbox has no range; only numeric controls constrain a keyframe.
38
+ if (c.type === "checkbox") continue;
39
+ if (typeof c.key === "string" && !ranges.has(c.key)) ranges.set(c.key, { min: c.min, max: c.max });
39
40
  }
40
41
  return ranges;
41
42
  }
@@ -4,11 +4,11 @@
4
4
  // resolve against `defaults`, which produce a control that silently does nothing.
5
5
  import { err, warn } from "./finding.js";
6
6
  import { suggest } from "../geometry/op-options.js";
7
+ import { fieldsFor } from "../panel/widget-specs.js";
8
+ import { sectionRenders } from "../panel/legacy.js";
7
9
 
8
- // Fields controls.js reads on a slider/number descriptor.
9
- const CONTROL_FIELDS = ["key", "label", "unit", "min", "max", "step", "control", "hidden", "description"];
10
+ // Legacy container descriptors aren't widget types, so they keep explicit lists.
10
11
  const FEATURE_FIELDS = ["key", "label", "on", "sliders", "hidden", "description"];
11
- const TOGGLE_FIELDS = ["key", "label", "on", "hidden", "description"];
12
12
 
13
13
  const sections = (part) => (Array.isArray(part?.parameters) ? part.parameters : []);
14
14
  const arr = (x) => (Array.isArray(x) ? x : []);
@@ -21,7 +21,7 @@ function collectDescriptors(part) {
21
21
  const out = [];
22
22
  sections(part).forEach((sec, si) => {
23
23
  arr(sec?.advanced).forEach((d, i) => {
24
- if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: CONTROL_FIELDS });
24
+ if (d) out.push({ d, path: `parameters[${si}].advanced[${i}]`, fields: fieldsFor("slider") });
25
25
  });
26
26
  arr(sec?.features).forEach((f, i) => {
27
27
  if (!f) return;
@@ -31,11 +31,11 @@ function collectDescriptors(part) {
31
31
  // recognise the demo.js flange_d pattern below: a slider sharing its key
32
32
  // with the feature is not an independent parameter, it's the feature's own
33
33
  // magnitude, and `defaults[key] === 0` there means "off", not "out of range".
34
- if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: CONTROL_FIELDS, featureKey: f.key });
34
+ if (s) out.push({ d: s, path: `parameters[${si}].features[${i}].sliders[${j}]`, fields: fieldsFor("slider"), featureKey: f.key });
35
35
  });
36
36
  });
37
37
  arr(sec?.toggles).forEach((t, i) => {
38
- if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: TOGGLE_FIELDS });
38
+ if (t) out.push({ d: t, path: `parameters[${si}].toggles[${i}]`, fields: fieldsFor("checkbox") });
39
39
  });
40
40
  });
41
41
  return out;
@@ -43,21 +43,10 @@ function collectDescriptors(part) {
43
43
 
44
44
  const defaultKeys = (part) => new Set(Object.keys(part?.defaults ?? {}));
45
45
 
46
- // Mirrors src/framework/controls.js's own visibility predicates (visibleFeatures /
47
- // sectionRenders, controls.js:32,36-41) NOT imported, because controls.js pulls in
48
- // `marked`/`dompurify` (via markdown.js) for its description popovers, which would
49
- // break partforge/lint's zero-bare-dependency purity guarantee (test/lint-purity.test.js).
50
- // `features-requires-sliders` must not flag a `feat.sliders.filter(...)` the panel
51
- // will never reach: controls.js only iterates `visibleFeatures(sec)` (skipping any
52
- // feature marked `hidden: true`), and only builds a section at all when
53
- // `sectionRenders(sec)` is true.
54
- const visibleFeatures = (sec) => arr(sec?.features).filter((f) => f && !f.hidden);
55
- function sectionRenders(sec) {
56
- if (sec?.hidden) return false;
57
- if (sec?.features) return visibleFeatures(sec).length > 0;
58
- const hasPresets = sec?.presets && Object.keys(sec.presets).length > 0;
59
- return !!hasPresets || arr(sec?.advanced).some((d) => d && !d.hidden) || arr(sec?.toggles).some((t) => t && !t.hidden);
60
- }
46
+ // These used to be hand-copied from controls.js, because importing it would have
47
+ // dragged `marked`/`dompurify` into partforge/lint and broken its zero-dependency
48
+ // guarantee. panel/legacy.js imports nothing, so lint can share the real
49
+ // implementation and the two can no longer drift.
61
50
 
62
51
  export const SCHEMA_RULES = [
63
52
  {