partforge 0.88.0 → 0.89.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.88.0",
3
+ "version": "0.89.0",
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",
@@ -278,6 +278,7 @@ button.action:disabled { opacity: .5; cursor: default; }
278
278
  /* keyboard focus ring shared across the panel's interactive controls */
279
279
  .seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
280
280
  button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible, #viewbar button:focus-visible,
281
+ .pf-view-select:focus-visible,
281
282
  .pf-viewcube-toggle:focus-visible, .pf-float-rail-toggle:focus-visible, .pf-sketch-toolbar button:focus-visible {
282
283
  outline: none;
283
284
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
@@ -299,14 +300,65 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
299
300
  border-radius: var(--pf-radius-pill);
300
301
  box-shadow: var(--pf-shadow-float);
301
302
  }
302
- /* nowrap: `.seg button` is `flex: 1` (equal-width columns), and a part with
303
- two views where one label is long (e.g. import-demo's "Reference overlay")
304
- would otherwise wrap onto a second line, making this pill taller than the
305
- single fixed height debug-overlay.js positions itself below (see its
306
- comment) — the overlay would then overlap the now-two-row tabs. Letting the
307
- label force the column wider instead keeps the pill single-row regardless
308
- of label length. */
309
- #topbar .seg button { min-width: 70px; padding: 7px 10px; white-space: nowrap; }
303
+ /* flex: the panel's `.seg button` is `flex: 1 1 0%` — equal-width columns,
304
+ which is right for a radio group in the rail and wrong here. With a zero
305
+ basis every tab gets the SAME width, so the widest label does not get to
306
+ size its own column: import-demo's two tabs came out 106px each while
307
+ "Reference overlay" needs ~127, and the text simply drew outside the pill's
308
+ rounded background (measured in Chromium: scrollWidth 238 against
309
+ clientWidth 221). Sizing each tab to its own content is what the nowrap note
310
+ below always claimed happened, and is also what makes the pill's natural
311
+ width honest — view-tabs.js's collapse measures exactly this.
312
+
313
+ nowrap: with content-sized columns a long label would otherwise wrap onto a
314
+ second line, making this pill taller than the single fixed height
315
+ debug-overlay.js positions itself below (see its comment) — the overlay
316
+ would then overlap the now-two-row tabs. Letting the label force the column
317
+ wider instead keeps the pill single-row regardless of label length, and
318
+ hands the overflow to the collapse rather than to a second row.
319
+
320
+ view-tabs.js ALSO sets nowrap inline on every button it generates, and that is
321
+ the copy that actually protects the rule: a host restyling the pill from
322
+ scratch (partforge-cloud's `#viewer #part button`, with no `.seg` class)
323
+ never sees this selector, and long labels wrapped there for exactly that
324
+ reason. This one still covers hand-written markup on partforge's own pages. */
325
+ #topbar .seg button { flex: 0 0 auto; min-width: 70px; padding: 7px 10px; white-space: nowrap; }
326
+
327
+ /* The tabs' narrow-stage fallback. When view-tabs.js measures the segmented
328
+ pill as wider than the stage's top-centre slot it flips `data-pf-tabs` to
329
+ "menu", and this dropdown stands in for the buttons.
330
+
331
+ Ungated on `.seg` / `#topbar` for the same reason #viewbar's chrome below is:
332
+ partforge-cloud dresses the pill with its own `#viewer #part` rules and
333
+ neither of those selectors reaches it — and it is the host that most needs
334
+ this, since its stage is a column beside an editor rather than a full window.
335
+
336
+ 27px tall, which is what the buttons it replaces measure (11px mono + 7px of
337
+ padding top and bottom). Matched rather than approximated so the pill's own
338
+ height does not change as it swaps — it sits at the stage's top centre, where
339
+ a 1px jump on every resize past the threshold would read as a twitch — and so
340
+ the collapsed pill stays under the fixed height debug-overlay.js parks itself
341
+ below. Capped in width so a long label ellipsizes rather than pushing the
342
+ pill back out to the width that collapsed it in the first place. */
343
+ .pf-view-select {
344
+ box-sizing: border-box;
345
+ height: 27px; max-width: min(220px, 100%);
346
+ padding: 0 24px 0 9px;
347
+ border: 0; border-radius: var(--pf-radius-control);
348
+ background: transparent; color: var(--pf-text-2);
349
+ font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
350
+ cursor: pointer; appearance: none; text-overflow: ellipsis;
351
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='7'%3E%3Cpolygon points='0,0 8,0 4,7' fill='%238b8b94'/%3E%3C/svg%3E");
352
+ background-repeat: no-repeat;
353
+ background-position: right 9px center;
354
+ }
355
+ /* `hidden` is the switch view-tabs.js flips between the two representations,
356
+ and a host that gives tab buttons a `display` of their own would beat the
357
+ UA's [hidden] rule — the trap `#viewbar button[hidden]` below already guards
358
+ against. Said again here against the layout state, which lives on the
359
+ element itself and so holds whatever a host does to the pill around it. */
360
+ [data-pf-tabs="menu"] > button[data-part] { display: none; }
361
+ [data-pf-tabs="segmented"] > .pf-view-select { display: none; }
310
362
 
311
363
  /* viewer controls. APPEARANCE is ungated: partforge-cloud re-anchors #viewbar's
312
364
  position in sandbox.css but inherits this pill chrome, so gating it on a class
@@ -7,10 +7,89 @@ import { loadView, saveView } from "./view-state.js";
7
7
  // hand-wrote. Which tab opens is resolveDefaultView's call, not key order. The
8
8
  // active tab then persists per part for the rest of the browser session, so a
9
9
  // Vite dev reload doesn't throw you back mid-edit.
10
- export function createViewTabs(el, part, { onChange }) {
10
+ //
11
+ // The bar has TWO representations of the same choice, and both exist from the
12
+ // start: the segmented buttons, and a dropdown that takes over when the pill no
13
+ // longer fits the stage's top-centre slot. Which one shows is `data-pf-tabs` on
14
+ // the element, kept current by the measurement below. They are built together
15
+ // and switched together rather than rebuilt on each swap, so a click and a
16
+ // dropdown change are the same commit path and neither can drift from the other.
17
+
18
+ // The slot must clear the pill by this much before the buttons come back. Purely
19
+ // an anti-flap margin: a pill whose natural width lands within a subpixel of the
20
+ // slot would otherwise swap layouts on every ResizeObserver notification.
21
+ const EXPAND_MARGIN = 2;
22
+ // chrome.css's `.pf-float-tabs { top: 12px; left: 50% }` inset — the margin the
23
+ // bar keeps from the stage edge when nothing else competes for the corner.
24
+ const STAGE_MARGIN = 12;
25
+ // Breathing room between the pill's edge and the rail toggle it must not reach.
26
+ const TOGGLE_GAP = 8;
27
+
28
+ // Which representation to show. Pure, and deliberately a function of the pill's
29
+ // NATURAL width rather than its current one — see `natural` below for why that
30
+ // distinction is what stops the swap oscillating.
31
+ //
32
+ // A non-positive measurement is the ABSENCE of a reading, not a claim that
33
+ // nothing fits: happy-dom reports zeros for every box, and so does a real
34
+ // browser before first layout. Segmented is the honest fallback — it is what
35
+ // the bar looked like before this measurement existed.
36
+ export function pickLayout({ natural, available, collapsed }) {
37
+ if (!(natural > 0) || !(available > 0)) return "segmented";
38
+ if (collapsed) return available >= natural + EXPAND_MARGIN ? "segmented" : "menu";
39
+ return natural > available ? "menu" : "segmented";
40
+ }
41
+
42
+ // How much of each stage edge is spoken for. Doubled by the caller because the
43
+ // pill is centre-anchored: the tighter side governs both halves. Derived from
44
+ // the rail toggle's measured box rather than from a second copy of its offsets,
45
+ // the same way animation-controls.js derives the view cube's claim from the
46
+ // viewbar's — retune chrome.css and this follows. The toggle is `[hidden]`
47
+ // below the narrow breakpoint, where the stage margin stands alone.
48
+ function sideClearance(stage, stageRect) {
49
+ const toggle = stage?.querySelector?.(".pf-float-rail-toggle");
50
+ if (!toggle || toggle.hidden || !stageRect) return STAGE_MARGIN;
51
+ const rect = toggle.getBoundingClientRect?.();
52
+ if (!(rect?.width > 0)) return STAGE_MARGIN;
53
+ return Math.max(STAGE_MARGIN, stageRect.right - rect.left + TOGGLE_GAP);
54
+ }
55
+
56
+ // `segWidth` is what the pill measures RIGHT NOW, which is near nothing once the
57
+ // buttons are hidden. Reporting it raw rather than smoothing it here keeps this
58
+ // a plain reading; remembering the last meaningful one is the orchestrator's job.
59
+ function domMeasure(el) {
60
+ return () => {
61
+ const stage = el.closest?.(".pf-stage") ?? el.offsetParent ?? el.ownerDocument?.documentElement;
62
+ const stageRect = stage?.getBoundingClientRect?.() ?? null;
63
+ const stageWidth = stageRect?.width || stage?.clientWidth || 0;
64
+ return {
65
+ segWidth: el.scrollWidth || 0,
66
+ available: stageWidth ? stageWidth - 2 * sideClearance(stage, stageRect) : 0,
67
+ };
68
+ };
69
+ }
70
+
71
+ // Built from the buttons rather than from part.views so a page with hand-written
72
+ // markup gets a working dropdown too — the buttons are the one description of
73
+ // the choice that both paths share. Option text goes through textContent for the
74
+ // same reason the buttons do: view labels are untrusted data.
75
+ function buildSelect(doc, tabs) {
76
+ const select = doc.createElement("select");
77
+ select.className = "pf-view-select";
78
+ select.setAttribute("aria-label", "View");
79
+ for (const btn of tabs) {
80
+ const option = doc.createElement("option");
81
+ option.value = btn.dataset.part;
82
+ option.textContent = btn.textContent;
83
+ select.append(option);
84
+ }
85
+ return select;
86
+ }
87
+
88
+ export function createViewTabs(el, part, { onChange, measure } = {}) {
11
89
  const generated = !!(el && part.views);
12
90
  const partKey = part?.meta?.title ?? "";
13
91
  const resolved = resolveDefaultView(part);
92
+ const doc = el.ownerDocument ?? document;
14
93
  if (generated) {
15
94
  // Built node-by-node with textContent/dataset rather than an innerHTML
16
95
  // template — view keys and labels come from the part, which is untrusted
@@ -20,32 +99,113 @@ export function createViewTabs(el, part, { onChange }) {
20
99
  const btn = document.createElement("button");
21
100
  btn.dataset.part = key;
22
101
  btn.textContent = v?.label ?? key;
102
+ // Inline, not a stylesheet rule, because this one is load-bearing rather
103
+ // than decorative and a host can drop a stylesheet rule by accident.
104
+ // partforge-cloud restyles the pill from scratch (`#viewer #part button`,
105
+ // no `.seg` class), which is exactly how app.css's `white-space: nowrap`
106
+ // stopped applying there and let long labels wrap onto a second line. A
107
+ // wrapping label makes the pill NARROWER, not wider, so the collapse
108
+ // below would never trip — the overflow it is meant to catch would show
109
+ // up as a two-row pill instead. On the element, it is out of reach.
110
+ btn.style.whiteSpace = "nowrap";
23
111
  if (key === resolved) btn.classList.add("on");
24
112
  return btn;
25
113
  }));
26
114
  }
27
115
 
28
- const setActive = (btn) => { for (const b of el.children) b.classList.toggle("on", b === btn); };
116
+ const buttons = () => [...el.querySelectorAll("button[data-part]")];
117
+ const tabs = buttons();
118
+ // Below two tabs there is nothing to choose, and an empty pill must stay
119
+ // empty: partforge-cloud hides the bar with `#viewer #part:empty`, so a part
120
+ // with no views would otherwise gain a one-option dropdown and a visible pill.
121
+ const select = tabs.length >= 2 ? buildSelect(doc, tabs) : null;
122
+ if (select) el.append(select);
123
+
124
+ const setActive = (btn) => {
125
+ for (const b of buttons()) b.classList.toggle("on", b === btn);
126
+ if (select) select.value = btn.dataset.part;
127
+ };
29
128
 
30
129
  // Initial view: the session-saved one if it still matches a tab, else the active
31
130
  // button — the resolved default for a generated bar, or whatever the page's own
32
131
  // markup marked `on` for a hand-written one.
33
132
  const defaultView = el.querySelector("button.on")?.dataset.part ?? el.querySelector("button")?.dataset.part;
34
133
  const saved = loadView(partKey);
35
- const savedBtn = saved ? [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === saved) : null;
134
+ const savedBtn = saved ? tabs.find((b) => b.dataset.part === saved) : null;
36
135
  let view = savedBtn ? saved : defaultView;
37
- if (savedBtn) setActive(savedBtn);
136
+ // Unconditional now (it used to run only for a restored view): the dropdown
137
+ // has to open showing the same tab the buttons do, and for a generated bar
138
+ // the button half of this is the no-op it always was.
139
+ const activeBtn = savedBtn ?? tabs.find((b) => b.dataset.part === view);
140
+ if (activeBtn) setActive(activeBtn);
38
141
 
39
- const onClick = (e) => {
40
- const btn = e.target.closest("button[data-part]");
41
- if (!btn) return;
142
+ const commit = (btn) => {
42
143
  view = btn.dataset.part;
43
144
  saveView(partKey, view);
44
145
  setActive(btn);
45
146
  onChange(view);
46
147
  };
148
+
149
+ const onClick = (e) => {
150
+ const btn = e.target.closest("button[data-part]");
151
+ if (!btn) return;
152
+ commit(btn);
153
+ };
47
154
  el.addEventListener("click", onClick);
48
155
 
156
+ const onSelectChange = () => {
157
+ const btn = buttons().find((b) => b.dataset.part === select.value);
158
+ if (btn) commit(btn);
159
+ };
160
+ select?.addEventListener("change", onSelectChange);
161
+
162
+ // ---- layout: which representation is on screen --------------------------
163
+ let detached = false;
164
+ let mode = null;
165
+ // The pill's width AS IF THE BUTTONS WERE SHOWING — the fixed point the swap
166
+ // turns on, and the same trick animation-controls.js's nominalClusterRect
167
+ // plays for the view cube. Re-reading the live pill while collapsed would
168
+ // measure the dropdown instead, decide the buttons fit, expand, overflow, and
169
+ // collapse again: two frames per cycle, on screen as a flickering bar. This
170
+ // value cannot change while collapsed, so the collapsed state is stable.
171
+ // Caching is exact rather than approximate here because the buttons are
172
+ // content-sized (`min-width` + padding + nowrap, no percentages), so their
173
+ // natural width does not depend on the viewport at all.
174
+ let natural = 0;
175
+ const read = measure ?? domMeasure(el);
176
+
177
+ const setMode = (next) => {
178
+ if (next === mode) return;
179
+ mode = next;
180
+ el.dataset.pfTabs = next;
181
+ const menu = next === "menu";
182
+ for (const b of buttons()) b.hidden = menu;
183
+ if (select) select.hidden = !menu;
184
+ };
185
+
186
+ const relayout = () => {
187
+ if (detached) return;
188
+ if (!select) { setMode("segmented"); return; }
189
+ const { segWidth, available } = read();
190
+ if (mode !== "menu" && segWidth > 0) natural = segWidth;
191
+ setMode(pickLayout({ natural, available, collapsed: mode === "menu" }));
192
+ };
193
+ setMode("segmented"); // measure from the state the cache is only valid in
194
+ relayout();
195
+
196
+ // ResizeObserver is the precise trigger; the resize listener is the belt that
197
+ // also fires where it is absent, mirroring animation-controls.js's placement
198
+ // pair. Both land on the same idempotent relayout, so a doubled notification
199
+ // costs one measurement and no DOM write.
200
+ const win = doc.defaultView ?? globalThis;
201
+ win.addEventListener?.("resize", relayout);
202
+ const stage = el.closest?.(".pf-stage") ?? el.parentElement;
203
+ const observer = typeof win.ResizeObserver === "function" ? new win.ResizeObserver(relayout) : null;
204
+ if (observer && stage) observer.observe(stage);
205
+ // A webfont swap changes every label's width, and so the pill's natural width,
206
+ // after the first layout the measurement above ran against.
207
+ doc.fonts?.ready?.then(relayout).catch(() => {});
208
+
49
209
  return {
50
210
  current: () => view,
51
211
  // Programmatic switch — the click path without the click. Used by an
@@ -53,17 +213,21 @@ export function createViewTabs(el, part, { onChange }) {
53
213
  // Returns false for a name that isn't a tab so callers can validate.
54
214
  select: (name) => {
55
215
  if (name === view) return true; // already active — nothing to do
56
- const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
216
+ const btn = buttons().find((b) => b.dataset.part === name);
57
217
  if (!btn) return false;
58
- view = name;
59
- saveView(partKey, view);
60
- setActive(btn);
61
- onChange(view);
218
+ commit(btn);
62
219
  return true;
63
220
  },
64
221
  detach: () => {
222
+ detached = true;
65
223
  el.removeEventListener("click", onClick);
224
+ select?.removeEventListener("change", onSelectChange);
225
+ win.removeEventListener?.("resize", relayout);
226
+ observer?.disconnect();
227
+ select?.remove(); // ours in both paths — hand-written markup never has one
228
+ delete el.dataset.pfTabs;
66
229
  if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
230
+ else for (const b of buttons()) b.hidden = false; // leave the page's own buttons as we found them
67
231
  },
68
232
  };
69
233
  }