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.
- package/docs/AUTHORING-PARTS.md +455 -75
- package/docs/ERROR-PATTERNS.md +24 -0
- package/package.json +1 -1
- package/src/framework/animation-controls.js +6 -2
- package/src/framework/app.css +103 -4
- package/src/framework/chrome.css +20 -0
- package/src/framework/controls.js +14 -363
- package/src/framework/lint/rules-animations.js +13 -12
- package/src/framework/lint/rules-schema.js +366 -39
- package/src/framework/mount.js +8 -1
- package/src/framework/oracle/cases.js +17 -6
- package/src/framework/panel/author.js +88 -0
- package/src/framework/panel/info.js +76 -0
- package/src/framework/panel/legacy.js +146 -0
- package/src/framework/panel/model.js +84 -0
- package/src/framework/panel/panel-state.js +70 -0
- package/src/framework/panel/render.js +289 -0
- package/src/framework/panel/widget-specs.js +94 -0
- package/src/framework/panel/widgets/checkbox.js +37 -0
- package/src/framework/panel/widgets/index.js +16 -0
- package/src/framework/panel/widgets/numeric.js +136 -0
- package/src/framework/panel/widgets/readout.js +31 -0
- package/src/framework/panel/widgets/select.js +71 -0
- package/src/framework/panel/widgets/text.js +33 -0
- package/src/parts/bracket.js +5 -5
- package/src/parts/planter.js +23 -17
- package/types/part.d.ts +114 -4
|
@@ -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
|
+
}
|