partforge 0.47.0 → 0.48.0

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.
@@ -0,0 +1,76 @@
1
+ // The ⓘ glyph and its per-panel popover. Shared by the control panel and the
2
+ // animation transport bar (animation-controls.js), which is why it is its own
3
+ // module rather than living inside the panel renderer.
4
+ import { renderMarkdown } from "../markdown.js";
5
+
6
+ function el(tag, className, text) {
7
+ const node = document.createElement(tag);
8
+ if (className) node.className = className;
9
+ if (text != null) node.textContent = text;
10
+ return node;
11
+ }
12
+
13
+ // Popover top edge: below the glyph when it fits, flipped above when the
14
+ // viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
15
+ // sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
16
+ // reports zero layout metrics, so the flip can't be exercised via the DOM.
17
+ export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
18
+ const below = glyphBottom + 6;
19
+ if (below + popHeight <= viewportHeight - 8) return below;
20
+ return Math.max(8, glyphTop - 6 - popHeight);
21
+ }
22
+
23
+ // One popover element per panel, shared by all its glyphs (only one open at a
24
+ // time). Document-level dismiss listeners are registered per panel and removed
25
+ // by panel.dispose().
26
+ export function createInfoPopover() {
27
+ const pop = el("div", "popover");
28
+ pop.hidden = true;
29
+ document.body.append(pop);
30
+ let owner = null; // the glyph whose description is showing
31
+
32
+ function close() {
33
+ if (pop.hidden) return;
34
+ pop.hidden = true;
35
+ if (owner) { owner.setAttribute("aria-expanded", "false"); owner = null; }
36
+ }
37
+ const onDocClick = (e) => {
38
+ if (!pop.hidden && !pop.contains(e.target) && !e.target.closest?.(".info")) close();
39
+ };
40
+ const onDocKeydown = (e) => { if (e.key === "Escape") close(); };
41
+ document.addEventListener("click", onDocClick);
42
+ document.addEventListener("keydown", onDocKeydown);
43
+
44
+ return {
45
+ toggle(glyph, description) {
46
+ if (owner === glyph) { close(); return; } // toggle off
47
+ close();
48
+ pop.innerHTML = renderMarkdown(description);
49
+ pop.hidden = false;
50
+ owner = glyph;
51
+ glyph.setAttribute("aria-expanded", "true");
52
+ const r = glyph.getBoundingClientRect();
53
+ pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
54
+ pop.style.left = `${Math.max(8, r.left - 8)}px`;
55
+ },
56
+ dispose() {
57
+ document.removeEventListener("click", onDocClick);
58
+ document.removeEventListener("keydown", onDocKeydown);
59
+ pop.remove();
60
+ },
61
+ };
62
+ }
63
+
64
+ // Append a focusable ⓘ glyph to `container` that toggles the panel's shared
65
+ // popover with `description` (Markdown). No-op when description is empty.
66
+ export function attachInfo(container, description, info) {
67
+ if (typeof description !== "string" || !description.trim()) return;
68
+ const glyph = document.createElement("button");
69
+ glyph.type = "button";
70
+ glyph.className = "info";
71
+ glyph.textContent = "ⓘ";
72
+ glyph.setAttribute("aria-label", "More info");
73
+ glyph.setAttribute("aria-expanded", "false");
74
+ glyph.addEventListener("click", (e) => { e.stopPropagation(); info.toggle(glyph, description); });
75
+ container.append(glyph);
76
+ }
@@ -0,0 +1,146 @@
1
+ // Everything this project knows about the ORIGINAL parameter-schema shapes —
2
+ // `advanced`, `toggles`, `features`, and the `control:` field — lives here and
3
+ // nowhere else. Those shapes still work and still ship; when they are eventually
4
+ // retired, this is one file to delete rather than an archaeology dig through the
5
+ // model.
6
+ //
7
+ // Imports author.js, on purpose: partforge/lint consumes desugar() and
8
+ // test/lint-purity.test.js asserts lint's whole import closure has zero bare
9
+ // dependencies.
10
+
11
+ import { authoredSection } from "./author.js";
12
+
13
+ const arr = (x) => (Array.isArray(x) ? x : []);
14
+
15
+ // --- the legacy visibility predicates (unchanged behavior) ------------------
16
+ // Guarded per-entry (`x && !x.hidden`) and per-section (`sec?.`) because a null
17
+ // entry or a missing section is anticipated malformed input here: lint's
18
+ // collectDescriptors walks the very same arrays with the same per-entry guard,
19
+ // and it must be able to walk a broken part in order to report on it, rather
20
+ // than have the walk itself throw.
21
+ export const visibleAdvanced = (sec) => arr(sec?.advanced).filter((d) => d && !d.hidden);
22
+ export const visibleFeatures = (sec) => arr(sec?.features).filter((f) => f && !f.hidden);
23
+ export const visibleToggles = (sec) => arr(sec?.toggles).filter((t) => t && !t.hidden);
24
+
25
+ export function sectionRenders(sec) {
26
+ if (sec?.hidden) return false;
27
+ if (sec?.features) return visibleFeatures(sec).length > 0;
28
+ const hasPresets = sec?.presets && Object.keys(sec.presets).length > 0;
29
+ return !!hasPresets || visibleAdvanced(sec).length > 0 || visibleToggles(sec).length > 0;
30
+ }
31
+
32
+ // --- desugaring -------------------------------------------------------------
33
+
34
+ // One legacy control descriptor -> a control node. `marksCustom` records the
35
+ // legacy split at controls.js:302 vs :346 — a preset-section control drops its
36
+ // picker to "Custom" when synced externally, a feature's own slider does not.
37
+ const toControl = (d, marksCustom) => ({
38
+ kind: "control",
39
+ key: d.key,
40
+ type: d.control ?? "slider",
41
+ label: d.label,
42
+ description: d.description,
43
+ unit: d.unit,
44
+ min: d.min,
45
+ max: d.max,
46
+ step: d.step,
47
+ hidden: !!d.hidden,
48
+ marksCustom,
49
+ });
50
+
51
+ const toCheckbox = (d, { preserveOn, on }) => ({
52
+ kind: "control",
53
+ key: d.key,
54
+ type: "checkbox",
55
+ label: d.label,
56
+ description: d.description,
57
+ on,
58
+ preserveOn,
59
+ hidden: !!d.hidden,
60
+ });
61
+
62
+ // A legacy feature -> [checkbox, bare group of its sliders gated on the checkbox].
63
+ // This is the whole point of the exercise: a feature stops being a special
64
+ // renderer path and becomes an ordinary conditional group.
65
+ function featureNodes(f) {
66
+ const box = toCheckbox(f, { preserveOn: true, on: f.on });
67
+ const group = {
68
+ kind: "group",
69
+ bare: true,
70
+ hidden: !!f.hidden,
71
+ when: { [f.key]: { gt: 0 } },
72
+ children: arr(f.sliders).filter(Boolean).map((s) => toControl(s, false)),
73
+ };
74
+ return [box, group];
75
+ }
76
+
77
+ export function desugar(parameters) {
78
+ return arr(parameters).map((sec) => {
79
+ // The NEW shape: children live in `controls`. author.js owns it entirely;
80
+ // when both `controls` and legacy arrays appear (a lint error,
81
+ // mixed-section-shape), `controls` wins — same winner-takes-all routing the
82
+ // features branch below applies to the legacy shapes.
83
+ if (Array.isArray(sec?.controls)) return authoredSection(sec);
84
+
85
+ const children = [];
86
+
87
+ // controls.js:180 routes any section with a truthy `features` field
88
+ // exclusively to buildFeatureSection, which never reads `presets`,
89
+ // `toggles`, or `advanced` — so a features section desugars to ONLY its
90
+ // Advanced group of feature nodes, matching that legacy routing exactly.
91
+ if (sec?.features) {
92
+ const advChildren = [];
93
+ for (const f of arr(sec.features)) {
94
+ if (f) advChildren.push(...featureNodes(f));
95
+ }
96
+ if (advChildren.length) {
97
+ children.push({ kind: "group", title: "Advanced", collapsed: "auto",
98
+ legacyAdvanced: true, children: advChildren }); // read by nothing yet; phase 4's migration tooling will key on it
99
+ }
100
+ return {
101
+ kind: "group",
102
+ id: sec?.id,
103
+ title: sec?.title,
104
+ description: sec?.description,
105
+ collapsed: "auto",
106
+ hidden: !!sec?.hidden,
107
+ children,
108
+ };
109
+ }
110
+
111
+ // The picker is a node like everything else, placed first — which is exactly
112
+ // where controls.js:264-274 rendered it, so an existing part is unchanged
113
+ // while a new-style part can position one anywhere in `controls`.
114
+ const presetNames = sec?.presets ? Object.keys(sec.presets) : [];
115
+ if (presetNames.length) {
116
+ children.push({ kind: "preset", presets: sec.presets, hidden: false });
117
+ }
118
+
119
+ // Toggles sit directly in the section, before the Advanced fold, exactly as
120
+ // controls.js:278-289 rendered them.
121
+ for (const t of arr(sec?.toggles)) {
122
+ if (t) children.push(toCheckbox(t, { preserveOn: false, on: t.on ?? 1 }));
123
+ }
124
+
125
+ // Everything else lands inside an "Advanced" group. `collapsed: "auto"` is
126
+ // what later hands these folds to the small-panel auto-open rule.
127
+ const advChildren = [];
128
+ for (const d of arr(sec?.advanced)) {
129
+ if (d) advChildren.push(toControl(d, true));
130
+ }
131
+ if (advChildren.length) {
132
+ children.push({ kind: "group", title: "Advanced", collapsed: "auto",
133
+ legacyAdvanced: true, children: advChildren });
134
+ }
135
+
136
+ return {
137
+ kind: "group",
138
+ id: sec?.id,
139
+ title: sec?.title,
140
+ description: sec?.description,
141
+ collapsed: "auto",
142
+ hidden: !!sec?.hidden,
143
+ children,
144
+ };
145
+ });
146
+ }
@@ -0,0 +1,84 @@
1
+ // The pure panel model: canonical nodes in, render tree out, plus the condition
2
+ // evaluator. No DOM, no dependencies — partforge/lint imports this and
3
+ // test/lint-purity.test.js requires its whole closure to be dependency-free.
4
+
5
+ // The operator table IS the grammar. evalWhen dispatches through it and (from
6
+ // phase 5) `when-unknown-operator` builds its did-you-mean list from its keys,
7
+ // so adding an operator can never leave lint behind.
8
+ export const WHEN_OPS = {
9
+ gt: (a, b) => a > b,
10
+ gte: (a, b) => a >= b,
11
+ lt: (a, b) => a < b,
12
+ lte: (a, b) => a <= b,
13
+ ne: (a, b) => a !== b,
14
+ in: (a, b) => Array.isArray(b) && b.includes(a),
15
+ };
16
+
17
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
18
+
19
+ // A condition against raw params. Never throws: a malformed condition reads as
20
+ // false, because a panel that crashes is worse than a control that hides.
21
+ export function evalWhen(cond, params) {
22
+ if (cond == null) return true;
23
+ if (!isPlainObject(cond)) return false;
24
+ for (const [key, want] of Object.entries(cond)) {
25
+ if (key === "allOf") {
26
+ if (!Array.isArray(want) || !want.every((c) => evalWhen(c, params))) return false;
27
+ } else if (key === "anyOf") {
28
+ if (!Array.isArray(want) || !want.some((c) => evalWhen(c, params))) return false;
29
+ } else if (key === "not") {
30
+ if (evalWhen(want, params)) return false;
31
+ } else if (isPlainObject(want)) {
32
+ const entries = Object.entries(want);
33
+ if (entries.length === 0) return false;
34
+ for (const [op, operand] of entries) {
35
+ const fn = WHEN_OPS[op];
36
+ if (!fn || !fn(params[key], operand)) return false;
37
+ }
38
+ } else if (params[key] !== want) {
39
+ return false;
40
+ }
41
+ }
42
+ return true;
43
+ }
44
+
45
+ // --- tree building ----------------------------------------------------------
46
+
47
+ // A group earns its place if anything survived inside it. Simpler than the
48
+ // predicate it replaces (controls.js:36-41), which needed a "has presets but no
49
+ // controls" special case — now a preset-only section just has one child.
50
+ const renders = (node) => node.kind !== "group" || node.children.length > 0;
51
+
52
+ // Drop hidden nodes and groups left empty, and stamp a stable id on everything.
53
+ // Ids are positional, so they are stable across rebuilds of the same schema; an
54
+ // authored `id` replaces the last segment.
55
+ function assign(nodes, prefix) {
56
+ const out = [];
57
+ nodes.forEach((node, i) => {
58
+ if (node.hidden) return;
59
+ const id = node.id ?? (prefix ? `${prefix}/${i}` : String(i));
60
+ if (node.kind !== "group") {
61
+ out.push({ ...node, id });
62
+ return;
63
+ }
64
+ const built = { ...node, id, children: assign(node.children ?? [], id) };
65
+ if (renders(built)) out.push(built);
66
+ });
67
+ return out;
68
+ }
69
+
70
+ export const buildTree = (canonical) => assign(canonical ?? [], "");
71
+
72
+ // Depth-first flat walk of the control leaves. Used by the renderer, by lint's
73
+ // range checks, and by anything that needs "every parameter this panel binds".
74
+ export function controlNodes(tree) {
75
+ const out = [];
76
+ const walk = (nodes) => {
77
+ for (const n of nodes ?? []) {
78
+ if (n.kind === "group") walk(n.children);
79
+ else if (n.kind === "control") out.push(n);
80
+ }
81
+ };
82
+ walk(tree);
83
+ return out;
84
+ }
@@ -0,0 +1,70 @@
1
+ // Every cross-cutting decision the panel makes about a node, in one pure pass.
2
+ // The renderer does nothing but apply the result.
3
+ //
4
+ // This is the rail-state.js / rail.js split applied to the panel: the tangled
5
+ // part isn't drawing controls, it's deciding which are visible, which are
6
+ // disabled and which are dimmed — three mechanisms acting on the same nodes.
7
+ // Computing them together, without a DOM, is what makes their interaction
8
+ // testable and keeps render.js small.
9
+ //
10
+ // The two mechanisms are deliberately independent and MUST stay visually
11
+ // distinct (see the spec): `when` hides or disables, relevance only dims.
12
+ import { evalWhen, controlNodes } from "./model.js";
13
+
14
+ // A panel with a handful of sections should present itself fully, not make the
15
+ // user click three times to see it. Beyond this many, collapsing wins: the rail
16
+ // is a fixed-height column and an eight-section part scrolls forever.
17
+ //
18
+ // Counting SECTIONS rather than controls is deliberate — an author can predict
19
+ // it at a glance, which matters because the rule shapes what their panel looks
20
+ // like on first load.
21
+ export const AUTO_OPEN_MAX_SECTIONS = 3;
22
+
23
+ const resolveOpen = (node, autoOpen) => {
24
+ if (node.bare) return true; // no disclosure to open
25
+ if (node.collapsed === true) return false;
26
+ if (node.collapsed === false) return true;
27
+ return autoOpen; // "auto" or unset
28
+ };
29
+
30
+ export function computeState(tree, { params, relevant }) {
31
+ const state = new Map();
32
+ const showAll = !(relevant instanceof Set);
33
+ const autoOpen = tree.length <= AUTO_OPEN_MAX_SECTIONS;
34
+
35
+ const walk = (nodes, parentVisible, isTop, parentDisabled) => {
36
+ for (const node of nodes) {
37
+ const passes = evalWhen(node.when, params);
38
+ const hideOnFail = node.whenFalse !== "disable";
39
+ const visible = parentVisible && (passes || !hideOnFail);
40
+ // Disabled propagates like visible does: a disabled group disables its
41
+ // whole subtree in the state itself, so render.js never has to walk
42
+ // descendants (and get bitten by their sibling ordering) to apply it.
43
+ const disabled = parentDisabled || (!passes && !hideOnFail);
44
+
45
+ if (node.kind === "group") {
46
+ // A group is dimmed when nothing inside it is relevant — the
47
+ // .section-hidden behavior, generalized from sections to any group.
48
+ const keys = controlNodes([node]).map((c) => c.key);
49
+ const dimmed = !showAll && !keys.some((k) => relevant.has(k));
50
+ // Only a TOP-LEVEL group gets `.section-hidden` (display:none). An inner
51
+ // group merely dims, because collapsing an inner group out of the layout
52
+ // on a relevance change makes the panel jump under the user's cursor.
53
+ // Groups: only dimmedSection is consumed by the renderer today; inner-group dimmed is informational (controls dim individually).
54
+ state.set(node.id, { visible, disabled, dimmed, dimmedSection: dimmed && isTop, open: resolveOpen(node, autoOpen) });
55
+ walk(node.children, visible, false, disabled);
56
+ } else {
57
+ state.set(node.id, {
58
+ visible, disabled, dimmedSection: false, open: true,
59
+ // Relevance is computed over parameter keys, so only a control can be
60
+ // irrelevant. A preset node has no key; the legacy renderer never
61
+ // dims the picker, and neither do we.
62
+ dimmed: node.kind === "control" && !showAll && !relevant.has(node.key),
63
+ });
64
+ }
65
+ }
66
+ };
67
+
68
+ walk(tree, true, true, false);
69
+ return state;
70
+ }
@@ -0,0 +1,289 @@
1
+ // The DOM binder. Builds elements from the render tree, then applies whatever
2
+ // panel-state.js decided. Everything about WHAT to show lives in the model and
3
+ // state modules; this file only knows how to put it on screen.
4
+ import { desugar } from "./legacy.js";
5
+ import { buildTree, controlNodes } from "./model.js";
6
+ import { computeState } from "./panel-state.js";
7
+ import { WIDGET_FACTORIES } from "./widgets/index.js";
8
+ import { makeReadout } from "./widgets/readout.js";
9
+ import { createInfoPopover, attachInfo } from "./info.js";
10
+
11
+ function el(tag, className, text) {
12
+ const node = document.createElement(tag);
13
+ if (className) node.className = className;
14
+ if (text != null) node.textContent = text;
15
+ return node;
16
+ }
17
+
18
+ // Map every node id (groups and leaves alike) to its node, for the reveal
19
+ // re-sync in applyState below.
20
+ function indexNodes(nodes, map) {
21
+ for (const node of nodes ?? []) {
22
+ map.set(node.id, node);
23
+ if (node.kind === "group") indexNodes(node.children, map);
24
+ }
25
+ }
26
+
27
+ export function buildControls(root, parameters, params, onDirty) {
28
+ const info = createInfoPopover();
29
+ const tree = buildTree(desugar(parameters));
30
+
31
+ const nodeEls = new Map(); // id -> the element whose visibility we toggle
32
+ const displayUpdates = new Map(); // id -> a display widget's update(derived)
33
+ const groupIds = new Set(); // ids that are group/section wrappers, never controls
34
+ const syncFns = []; // { key, sync } for every widget
35
+ const rawSyncs = new Map(); // sectionId -> [{ key, sync }] for preset application
36
+ const widgetSyncs = new Map(); // id -> the RAW widget sync (no markCustom)
37
+ const nodeById = new Map(); // id -> node, for the reveal re-sync
38
+ const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
39
+ const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
40
+ // Containers that own a disclosure: sections, and titled inner groups (the
41
+ // legacy "Advanced" fold). `label` is set only for the inner groups, whose
42
+ // button text carries the ▾/▴ instead of a chevron span.
43
+ const disclosures = new Map(); // id -> { body, button, label }
44
+ indexNodes(tree, nodeById);
45
+ let relevant = null;
46
+
47
+ // Re-apply state after any change that could flip a condition. This is what
48
+ // reproduces the legacy feature behavior generically: ticking a feature's
49
+ // checkbox now simply makes its group's condition true. It also re-syncs any
50
+ // subtree that just went from hidden to visible, so a just-revealed slider
51
+ // shows the freshly-written value instead of a stale one (legacy
52
+ // controls.js ran `syncs.forEach((s) => s())` on a feature tick).
53
+ // The FIRST computed state decides which sections/folds start open, per the
54
+ // auto-open rule and any explicit `collapsed`. After that, the user's own
55
+ // clicks own it — re-running this on every param change would otherwise
56
+ // snap a section the user opened shut on the next slider drag.
57
+ let openApplied = false;
58
+ const applyOpenState = (state) => {
59
+ if (openApplied) return;
60
+ openApplied = true;
61
+ for (const [id, d] of disclosures) {
62
+ const open = state.get(id)?.open ?? true;
63
+ d.body.classList.toggle("hidden", !open);
64
+ d.button.setAttribute("aria-expanded", String(open));
65
+ if (d.label) d.button.textContent = open ? `${d.label} ▴` : `${d.label} ▾`;
66
+ }
67
+ };
68
+
69
+ const applyState = () => {
70
+ const state = computeState(tree, { params, relevant });
71
+ applyOpenState(state);
72
+ for (const [id, node] of nodeEls) {
73
+ const s = state.get(id);
74
+ if (!s) continue;
75
+ const isGroup = groupIds.has(id);
76
+ node.classList.toggle("hidden", !s.visible);
77
+ node.classList.toggle("section-hidden", !!s.dimmedSection);
78
+ node.classList.toggle("irrelevant", !isGroup && s.dimmed && !s.dimmedSection);
79
+ node.classList.toggle("disabled", s.disabled);
80
+ // Only a control leaf's own inputs get `.disabled` written. `disabled`
81
+ // already propagates through the whole subtree in computeState, so a
82
+ // group never needs to (and must not) walk its descendants here — doing
83
+ // so made the outcome depend on `nodeEls` insertion order, which differs
84
+ // between a bare group/section (registers before its children) and a
85
+ // titled group (registers after, via its wrapper). Skipping the query
86
+ // entirely when `disabled` hasn't changed also keeps this off the hot
87
+ // path (applyState runs on every slider drag and relevance update).
88
+ if (!isGroup && lastDisabled.get(id) !== s.disabled) {
89
+ if (node.matches?.("input, select, textarea")) node.disabled = s.disabled;
90
+ // `.seg button` is in the list for the radio widget, whose options are
91
+ // <button>s: without it a disabled radio stayed keyboard-focusable and
92
+ // only LOOKED disabled. Scoped to `.seg` rather than every button so a
93
+ // disabled control's ⓘ glyph stays reachable — the popover is where the
94
+ // author explains what has to be enabled first, which is exactly what
95
+ // the reader wants here. (Group nodes skip this branch entirely, so the
96
+ // section and fold disclosure buttons are never touched either way.)
97
+ for (const input of node.querySelectorAll("input, select, textarea, .seg button")) {
98
+ input.disabled = s.disabled;
99
+ }
100
+ lastDisabled.set(id, s.disabled);
101
+ }
102
+ if (!isGroup && s.dimmed && !s.dimmedSection) node.title = "Doesn't affect the parts in the current view";
103
+ else node.removeAttribute("title");
104
+
105
+ const wasVisible = lastVisible.get(id);
106
+ if (wasVisible === false && s.visible) {
107
+ for (const c of controlNodes([nodeById.get(id)])) widgetSyncs.get(c.id)?.();
108
+ }
109
+ lastVisible.set(id, s.visible);
110
+ }
111
+ };
112
+
113
+ const onEdit = () => { applyState(); onDirty?.(); };
114
+
115
+ // --- build ---------------------------------------------------------------
116
+ //
117
+ // TWO DIFFERENT THINGS USE `.hidden`, and conflating them is a real bug:
118
+ // conditions hide a node, and a disclosure closes a fold. `applyState` runs on
119
+ // every param change (mount.js:510), so if it toggled `.hidden` on the fold
120
+ // body, every slider drag would re-open a fold the user had closed.
121
+ //
122
+ // So: a titled group gets a WRAPPER. Conditions toggle the wrapper; the
123
+ // disclosure toggles the body inside it. A bare group has no fold, so it keeps
124
+ // the legacy `.feat-group.hidden` markup exactly and conditions own it.
125
+ function renderGroup(node, container, sectionCtx) {
126
+ groupIds.add(node.id);
127
+
128
+ if (node.bare) {
129
+ const box = el("div", "feat-group");
130
+ nodeEls.set(node.id, box);
131
+ for (const child of node.children) renderNode(child, box, sectionCtx);
132
+ container.append(box);
133
+ return;
134
+ }
135
+
136
+ const wrap = el("div", "adv-wrap");
137
+ const body = el("div", "adv hidden"); // starts closed — legacy parity
138
+ body.id = `pf-fold-${node.id.replaceAll("/", "-")}`;
139
+ const toggle = el("button", "adv-toggle", `${node.title} ▾`);
140
+ toggle.setAttribute("aria-controls", body.id);
141
+ toggle.addEventListener("click", () => {
142
+ const nowHidden = body.classList.toggle("hidden");
143
+ toggle.textContent = nowHidden ? `${node.title} ▾` : `${node.title} ▴`;
144
+ toggle.setAttribute("aria-expanded", String(!nowHidden));
145
+ });
146
+ for (const child of node.children) renderNode(child, body, sectionCtx);
147
+ wrap.append(toggle, body);
148
+ nodeEls.set(node.id, wrap); // conditions act on the wrapper
149
+ disclosures.set(node.id, { body, button: toggle, label: node.title });
150
+ container.append(wrap);
151
+ }
152
+
153
+ // The preset picker. Applying a preset overwrites its keys and refreshes the
154
+ // section's controls through their RAW syncs — a preset application must not
155
+ // mark itself Custom (controls.test.js:366).
156
+ function renderPreset(node, container, sectionCtx) {
157
+ if (node.label) {
158
+ const row = el("div", "row");
159
+ row.append(el("label", "", node.label));
160
+ container.append(row);
161
+ }
162
+ const names = Object.keys(node.presets);
163
+ const select = document.createElement("select");
164
+ select.className = "preset";
165
+ for (const name of [...names, "Custom"]) {
166
+ const o = document.createElement("option");
167
+ o.value = name; o.textContent = name; select.append(o);
168
+ }
169
+ select.value = names[0];
170
+ select.addEventListener("change", () => {
171
+ const bundle = node.presets[select.value];
172
+ if (!bundle) return; // "Custom"
173
+ Object.assign(params, bundle);
174
+ for (const { key, sync } of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
175
+ onEdit();
176
+ });
177
+ // The section's controls need a handle on the picker to drop it to Custom
178
+ // when one of them is edited. First picker in the section wins.
179
+ if (sectionCtx && !sectionCtx.preset) sectionCtx.preset = select;
180
+ nodeEls.set(node.id, select);
181
+ container.append(select);
182
+ }
183
+
184
+ function renderNode(node, container, sectionCtx) {
185
+ if (node.kind === "group") { renderGroup(node, container, sectionCtx); return; }
186
+ if (node.kind === "preset") { renderPreset(node, container, sectionCtx); return; }
187
+ if (node.kind === "display") {
188
+ const widget = makeReadout(node, { info });
189
+ nodeEls.set(node.id, widget.el);
190
+ displayUpdates.set(node.id, widget.update);
191
+ container.append(widget.el);
192
+ return;
193
+ }
194
+
195
+ const factory = WIDGET_FACTORIES[node.type];
196
+ if (!factory) return; // unknown type: lint reports it; the panel skips it
197
+
198
+ // Editing a control in a preset section diverges from the preset, so the
199
+ // picker falls back to Custom (controls.js:296). A feature's own slider and a
200
+ // toggle do NOT — `marksCustom` is false for them.
201
+ const markCustom = () => {
202
+ if (node.marksCustom && sectionCtx?.preset) sectionCtx.preset.value = "Custom";
203
+ };
204
+ const widget = factory(node, params, {
205
+ onChange: () => { markCustom(); onEdit(); },
206
+ info,
207
+ });
208
+ nodeEls.set(node.id, widget.el);
209
+ widgetSyncs.set(node.id, widget.sync);
210
+ container.append(widget.el);
211
+
212
+ // The raw sync is what a PRESET application uses — it must not mark itself
213
+ // Custom (controls.test.js:366). The registered sync is what an external
214
+ // syncValues() uses, and for a preset-section control it does drop the
215
+ // picker to Custom (controls.test.js:350), because a programmatic edit
216
+ // diverges from the preset exactly as a user edit does.
217
+ if (sectionCtx) rawSyncs.get(sectionCtx.id).push({ key: node.key, sync: widget.sync });
218
+ syncFns.push({
219
+ key: node.key,
220
+ sync: () => { widget.sync(); markCustom(); },
221
+ });
222
+ }
223
+
224
+ for (const section of tree) {
225
+ groupIds.add(section.id);
226
+ const secEl = el("div", "section");
227
+ nodeEls.set(section.id, secEl);
228
+
229
+ const header = el("div", "sec-header");
230
+ const title = el("button", "sec-title");
231
+ title.type = "button";
232
+ // The chev span carries NO text — its glyph comes from CSS (::before) —
233
+ // because sectionByTitle-style lookups match `.sec-title` by exact
234
+ // textContent === title (controls.test.js:210), and a text chevron here
235
+ // would break that match.
236
+ title.append(el("span", "sec-name", section.title ?? ""), el("span", "chev"));
237
+ header.append(title);
238
+ // The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
239
+ // <button>, and a button nested in a button is invalid HTML that never
240
+ // receives clicks.
241
+ attachInfo(header, section.description, info);
242
+ secEl.append(header);
243
+
244
+ const body = el("div", "sec-body");
245
+ body.id = `pf-sec-${section.id.replaceAll("/", "-")}`;
246
+ title.setAttribute("aria-controls", body.id);
247
+ secEl.append(body);
248
+
249
+ title.addEventListener("click", () => {
250
+ const nowHidden = body.classList.toggle("hidden");
251
+ title.setAttribute("aria-expanded", String(!nowHidden));
252
+ });
253
+ disclosures.set(section.id, { body, button: title, label: null });
254
+
255
+ // `preset` is filled in when a preset node renders. Controls read it late, so
256
+ // one appearing after them in the children array still works.
257
+ const ctx = { id: section.id, preset: null };
258
+ rawSyncs.set(section.id, []);
259
+
260
+ for (const child of section.children) renderNode(child, body, ctx);
261
+ root.append(secEl);
262
+ }
263
+
264
+ applyState();
265
+
266
+ // The single entry point for a param change: relevance dims/undims controls,
267
+ // derived pushes fresh values into every readout. Either argument may be
268
+ // omitted (mount.js's initial call, or a syncValues-only path) — only what's
269
+ // passed updates. `applyRelevance` is the old name, kept as a thin delegate
270
+ // so existing callers (and mount.test.js) don't have to change.
271
+ const refresh = ({ relevant: nextRelevant, derived } = {}) => {
272
+ if (nextRelevant !== undefined) relevant = nextRelevant;
273
+ if (derived !== undefined) {
274
+ for (const update of displayUpdates.values()) update(derived);
275
+ }
276
+ applyState();
277
+ };
278
+
279
+ return {
280
+ refresh,
281
+ applyRelevance: (next) => refresh({ relevant: next }),
282
+ syncValues: (keys) => {
283
+ const only = keys && new Set(keys);
284
+ for (const { key, sync } of syncFns) if (!only || only.has(key)) sync();
285
+ applyState();
286
+ },
287
+ dispose: () => { info.dispose(); root.replaceChildren(); },
288
+ };
289
+ }