partforge 0.26.1 → 0.28.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/README.md +16 -4
- package/bin/cli.js +57 -2
- package/docs/AUTHORING-PARTS.md +205 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +6 -2
- package/skills/partforge/SKILL.md +7 -1
- package/src/app-bracket.js +5 -0
- package/src/app-demo.js +5 -0
- package/src/app-faceted-vase.js +5 -0
- package/src/app-filleted-box.js +5 -0
- package/src/app-hull-sweep.js +5 -0
- package/src/app-nameplate.js +5 -0
- package/src/app-planter.js +5 -0
- package/src/app-text-smoke.js +5 -0
- package/src/framework/app.css +85 -39
- package/src/framework/chrome.css +180 -0
- package/src/framework/debug-overlay.js +16 -1
- package/src/framework/geometry/op-options.js +3 -1
- package/src/framework/geometry/probe.js +122 -23
- package/src/framework/lint/finding.js +20 -0
- package/src/framework/lint/index.js +114 -0
- package/src/framework/lint/rules-build.js +84 -0
- package/src/framework/lint/rules-schema.js +209 -0
- package/src/framework/lint/rules-shape.js +86 -0
- package/src/framework/lint/rules-verify.js +288 -0
- package/src/framework/mount.js +14 -0
- package/src/framework/rail-state.js +73 -0
- package/src/framework/rail.js +321 -0
- package/src/framework/tokens.css +14 -1
- package/src/framework/verify-metrics.js +51 -0
- package/src/framework/worker.js +9 -0
- package/src/lint.js +3 -0
- package/src/testing/verify.js +4 -45
package/src/framework/app.css
CHANGED
|
@@ -6,42 +6,63 @@
|
|
|
6
6
|
accent, shadcn-shaped radii/shadows/focus rings — plain CSS on --pf-*.
|
|
7
7
|
See docs/AUTHORING-PARTS.md. */
|
|
8
8
|
@import "./tokens.css"; /* palette + light overrides; also exported standalone as partforge/tokens.css */
|
|
9
|
+
@import "./chrome.css"; /* reusable layout; also exported as partforge/chrome.css */
|
|
9
10
|
|
|
10
11
|
* { box-sizing: border-box; }
|
|
11
12
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
12
|
-
font: 13px/1.4 -
|
|
13
|
-
|
|
13
|
+
font: 13px/1.4 var(--pf-sans); }
|
|
14
|
+
/* Legacy fallback: a page whose markup predates .pf-stage keeps the old
|
|
15
|
+
full-window viewer. :not() rather than a plain #app rule because an id would
|
|
16
|
+
outrank .pf-stage's positioning and win the cascade. */
|
|
17
|
+
#app:not(.pf-stage) { position: fixed; inset: 0; background: var(--pf-bg); }
|
|
14
18
|
canvas { display: block; }
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
/* Legacy fallback: the pre-rail floating card, for markup without .pf-rail. */
|
|
21
|
+
#panel:not(.pf-rail) {
|
|
17
22
|
position: fixed; top: 12px; left: 12px; width: 256px;
|
|
18
23
|
max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
|
|
19
24
|
background: var(--pf-surface); border: 1px solid var(--pf-border); border-radius: 16px;
|
|
20
25
|
padding: 14px; color: var(--pf-text);
|
|
21
|
-
|
|
22
|
-
panes read as siblings (was the smaller shadow-md). */
|
|
23
|
-
box-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);
|
|
26
|
+
box-shadow: var(--pf-shadow-float);
|
|
24
27
|
}
|
|
28
|
+
/* Legacy fallback only: pre-rail markup has a bare <h1>/.sub directly inside
|
|
29
|
+
#panel, with no .pf-rail-head wrapper, so chrome.css's classed
|
|
30
|
+
.pf-rail-head h1/.sub rule (the single source of truth for that typography)
|
|
31
|
+
can't reach it — chrome.css is deliberately id-free (see test/tokens.test.js)
|
|
32
|
+
so this id-scoped restatement has to live here instead. Classed markup never
|
|
33
|
+
hits this rule (its h1 is inside .pf-rail-head, already styled by
|
|
34
|
+
chrome.css); keep the two in sync if the typography ever changes. */
|
|
25
35
|
#panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em; }
|
|
26
36
|
#panel .sub {
|
|
27
37
|
font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
|
|
28
38
|
letter-spacing: 0.04em; text-transform: uppercase;
|
|
29
|
-
margin: 0 0
|
|
39
|
+
margin: 2px 0 0;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
.seg { display: flex; gap: 2px; margin-bottom: 12px;
|
|
33
|
-
background: var(--pf-surface-2); border-radius:
|
|
43
|
+
background: var(--pf-surface-2); border-radius: var(--pf-radius-control); padding: 3px; }
|
|
34
44
|
.seg button {
|
|
35
|
-
flex: 1; padding: 6px 0; border: 0; border-radius:
|
|
45
|
+
flex: 1; padding: 6px 0; border: 0; border-radius: var(--pf-radius-control);
|
|
36
46
|
background: transparent; color: var(--pf-muted); cursor: pointer;
|
|
37
47
|
font-family: var(--pf-mono); font-size: 11px; letter-spacing: 0.02em;
|
|
38
48
|
}
|
|
39
49
|
.seg button:hover { color: var(--pf-text-2); }
|
|
40
50
|
.seg button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
41
51
|
|
|
52
|
+
/* Full-bleed rows: the divider spans the rail's whole width while the content
|
|
53
|
+
sits at the rail's own padding, so each slider gains the ~22px the old box
|
|
54
|
+
border + padding used to take from both sides. */
|
|
42
55
|
.section {
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
padding: 11px var(--pf-rail-pad);
|
|
57
|
+
}
|
|
58
|
+
/* Divider BETWEEN visible sections. `~` walks all preceding siblings, and a
|
|
59
|
+
relevance-hidden section (.section-hidden, display:none) fails the :not(),
|
|
60
|
+
so the topmost VISIBLE section never draws a hairline under the rail header.
|
|
61
|
+
Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
62
|
+
matches DOM position rather than visibility, and leaves a stray divider
|
|
63
|
+
floating at the top whenever applyRelevance hides the first section. */
|
|
64
|
+
.section:not(.section-hidden) ~ .section:not(.section-hidden) {
|
|
65
|
+
border-top: 1px solid var(--pf-border);
|
|
45
66
|
}
|
|
46
67
|
.sec-title {
|
|
47
68
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
@@ -49,13 +70,13 @@ canvas { display: block; }
|
|
|
49
70
|
}
|
|
50
71
|
select.preset {
|
|
51
72
|
width: 100%; background: var(--pf-input-bg); color: var(--pf-text-2);
|
|
52
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
73
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px;
|
|
53
74
|
font-family: var(--pf-mono); font-size: 11px;
|
|
54
75
|
}
|
|
55
76
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
56
77
|
color: var(--pf-text-2); cursor: pointer; }
|
|
57
78
|
.feat input { cursor: pointer; accent-color: var(--pf-accent); }
|
|
58
|
-
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left:
|
|
79
|
+
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 1px solid var(--pf-border); }
|
|
59
80
|
.feat-group.hidden { display: none; }
|
|
60
81
|
.adv-toggle {
|
|
61
82
|
margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 6px;
|
|
@@ -74,7 +95,7 @@ select.preset {
|
|
|
74
95
|
.row .num {
|
|
75
96
|
width: 54px; text-align: right; font-family: var(--pf-mono); font-size: 12px; font-variant-numeric: tabular-nums;
|
|
76
97
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
77
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
98
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 3px 6px;
|
|
78
99
|
}
|
|
79
100
|
.row .num:focus { outline: none; border-color: var(--pf-accent);
|
|
80
101
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
@@ -84,7 +105,7 @@ select.preset {
|
|
|
84
105
|
.text-input {
|
|
85
106
|
display: block; width: 100%; font: 12px/1.4 var(--pf-mono);
|
|
86
107
|
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
87
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
108
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
|
|
88
109
|
}
|
|
89
110
|
textarea.text-input { min-height: 64px; resize: vertical; }
|
|
90
111
|
.text-input:focus { outline: none; border-color: var(--pf-accent);
|
|
@@ -97,12 +118,12 @@ input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; backgro
|
|
|
97
118
|
input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--pf-accent); }
|
|
98
119
|
input[type="range"]::-webkit-slider-thumb {
|
|
99
120
|
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
|
|
100
|
-
border-radius: 50%; background: var(--pf-accent); border: 2px solid var(--pf-surface
|
|
121
|
+
border-radius: 50%; background: var(--pf-accent); border: 2px solid var(--pf-surface); box-shadow: 0 0 0 1px var(--pf-accent);
|
|
101
122
|
transition: box-shadow .12s ease;
|
|
102
123
|
}
|
|
103
124
|
input[type="range"]::-moz-range-thumb {
|
|
104
125
|
width: 14px; height: 14px; border-radius: 50%; background: var(--pf-accent);
|
|
105
|
-
border: 2px solid var(--pf-surface
|
|
126
|
+
border: 2px solid var(--pf-surface); box-shadow: 0 0 0 1px var(--pf-accent);
|
|
106
127
|
}
|
|
107
128
|
input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
108
129
|
input[type="range"]:focus-visible { outline: none; }
|
|
@@ -110,7 +131,7 @@ input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px
|
|
|
110
131
|
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
|
|
111
132
|
|
|
112
133
|
button.action {
|
|
113
|
-
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius:
|
|
134
|
+
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: var(--pf-radius-control);
|
|
114
135
|
background: var(--pf-accent); color: var(--pf-on-accent); font-weight: 500; cursor: pointer;
|
|
115
136
|
}
|
|
116
137
|
button.action:hover:not(:disabled) { background: color-mix(in oklab, var(--pf-accent) 90%, black); }
|
|
@@ -125,7 +146,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
125
146
|
}
|
|
126
147
|
.dl-row { display: flex; gap: 6px; }
|
|
127
148
|
.dl-row button {
|
|
128
|
-
flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius:
|
|
149
|
+
flex: 1; padding: 8px 0; border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control);
|
|
129
150
|
background: transparent; color: var(--pf-text-2);
|
|
130
151
|
font-family: var(--pf-mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
|
|
131
152
|
}
|
|
@@ -143,28 +164,28 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
143
164
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
144
165
|
}
|
|
145
166
|
|
|
146
|
-
/* part tabs
|
|
147
|
-
#topbar {
|
|
148
|
-
position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
|
|
149
|
-
z-index: 15;
|
|
150
|
-
}
|
|
167
|
+
/* part tabs (placement: .pf-float-tabs; legacy markup keeps the old float) */
|
|
168
|
+
#topbar:not(.pf-float-tabs) { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 15; }
|
|
151
169
|
#topbar .seg {
|
|
152
170
|
margin: 0; padding: 4px; background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
153
|
-
border-radius:
|
|
154
|
-
box-shadow:
|
|
171
|
+
border-radius: var(--pf-radius-pill);
|
|
172
|
+
box-shadow: var(--pf-shadow-float);
|
|
155
173
|
}
|
|
156
174
|
#topbar .seg button { min-width: 70px; padding: 7px 10px; }
|
|
157
175
|
|
|
158
|
-
/* viewer controls
|
|
176
|
+
/* viewer controls. APPEARANCE is ungated: partforge-cloud re-anchors #viewbar's
|
|
177
|
+
position in sandbox.css but inherits this pill chrome, so gating it on a class
|
|
178
|
+
the cloud never sets would strip the editor's viewbar. PLACEMENT comes from
|
|
179
|
+
.pf-float-viewbar; the :not() keeps legacy markup's old top-right float. */
|
|
159
180
|
#viewbar {
|
|
160
|
-
position: fixed; top: 12px; right: 12px; z-index: 15;
|
|
161
181
|
display: flex; gap: 4px; padding: 4px;
|
|
162
182
|
background: var(--pf-surface); border: 1px solid var(--pf-border);
|
|
163
|
-
border-radius:
|
|
164
|
-
box-shadow:
|
|
183
|
+
border-radius: var(--pf-radius-pill);
|
|
184
|
+
box-shadow: var(--pf-shadow-float);
|
|
165
185
|
}
|
|
186
|
+
#viewbar:not(.pf-float-viewbar) { position: fixed; top: 12px; right: 12px; z-index: 15; }
|
|
166
187
|
#viewbar button {
|
|
167
|
-
width: 34px; height: 34px; border: 0; border-radius:
|
|
188
|
+
width: 34px; height: 34px; border: 0; border-radius: var(--pf-radius-control);
|
|
168
189
|
background: transparent; color: var(--pf-muted-2); cursor: pointer;
|
|
169
190
|
font-size: 15px; line-height: 1;
|
|
170
191
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -177,16 +198,36 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
177
198
|
#viewbar button:hover { color: var(--pf-text); background: var(--pf-surface-2); }
|
|
178
199
|
#viewbar button.on { background: var(--pf-accent); color: var(--pf-on-accent); }
|
|
179
200
|
|
|
201
|
+
/* At very narrow widths the full-size pill (5 icon buttons + cutaway's
|
|
202
|
+
Flip/Reset, ~314px) is wider than a small phone's viewport minus the
|
|
203
|
+
stage's 12px margins, and .pf-shell { overflow: hidden } clips the excess
|
|
204
|
+
off its left edge (caught by scripts/check-app.mjs's containment check).
|
|
205
|
+
Shrink the pill instead of letting it wrap — a two-row pill would need its
|
|
206
|
+
own anchoring rework, while shrinking is a pure appearance change. */
|
|
207
|
+
@media (max-width: 360px) {
|
|
208
|
+
#viewbar { gap: 3px; }
|
|
209
|
+
#viewbar button { width: 30px; height: 30px; font-size: 13px; }
|
|
210
|
+
#viewbar .pf-cutaway-actions { gap: 3px; }
|
|
211
|
+
#viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
|
|
215
|
+
(bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
|
|
216
|
+
top-left floating #panel card. Legacy markup still floats #viewbar top-right
|
|
217
|
+
and #panel top-left (both `position: fixed`), which collide once the window
|
|
218
|
+
gets narrow enough — restore the old narrow-width rule that moves #viewbar
|
|
219
|
+
to bottom-centre and gives #panel room, scoped with :not(.pf-*) so it can't
|
|
220
|
+
affect the new layout. */
|
|
180
221
|
@media (max-width: 680px) {
|
|
181
|
-
#panel { max-height: calc(100vh - 80px); }
|
|
182
|
-
#viewbar {
|
|
222
|
+
#panel:not(.pf-rail) { max-height: calc(100vh - 80px); }
|
|
223
|
+
#viewbar:not(.pf-float-viewbar) {
|
|
183
224
|
top: auto; right: auto; bottom: 12px; left: 50%;
|
|
184
225
|
transform: translateX(-50%);
|
|
185
226
|
}
|
|
186
227
|
}
|
|
187
228
|
|
|
188
229
|
#busy {
|
|
189
|
-
position:
|
|
230
|
+
position: absolute; inset: 0; z-index: 20; pointer-events: none;
|
|
190
231
|
display: none; flex-direction: column; align-items: center;
|
|
191
232
|
justify-content: center; gap: 14px;
|
|
192
233
|
}
|
|
@@ -211,8 +252,8 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
211
252
|
.popover {
|
|
212
253
|
position: fixed; z-index: 50; max-width: 280px; max-height: 50vh; overflow: auto;
|
|
213
254
|
background: var(--pf-surface); color: var(--pf-text); border: 1px solid var(--pf-border);
|
|
214
|
-
border-radius:
|
|
215
|
-
box-shadow:
|
|
255
|
+
border-radius: var(--pf-radius-pill); padding: 10px 12px;
|
|
256
|
+
box-shadow: var(--pf-shadow-float);
|
|
216
257
|
font-size: 12px; line-height: 1.5;
|
|
217
258
|
}
|
|
218
259
|
.popover[hidden] { display: none; }
|
|
@@ -246,12 +287,12 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
246
287
|
/* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
|
|
247
288
|
laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
|
|
248
289
|
#pf-pick-banner {
|
|
249
|
-
position: fixed; top: 78px; left: 50
|
|
290
|
+
position: fixed; top: 78px; left: calc(50% - var(--pf-rail-w) / 2); transform: translateX(-50%);
|
|
250
291
|
z-index: 30; max-width: min(56ch, calc(100vw - 24px));
|
|
251
292
|
padding: 10px 36px 10px 12px;
|
|
252
293
|
background: var(--pf-surface); color: var(--pf-text);
|
|
253
|
-
border: 1px solid var(--pf-border); border-radius:
|
|
254
|
-
box-shadow:
|
|
294
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-pill);
|
|
295
|
+
box-shadow: var(--pf-shadow-float);
|
|
255
296
|
font-size: 12px; line-height: 1.45;
|
|
256
297
|
animation: pf-pick-in .18s ease;
|
|
257
298
|
}
|
|
@@ -264,6 +305,11 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
|
|
|
264
305
|
#pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
265
306
|
#pf-pick-banner .pf-pick-label { color: var(--pf-muted); font-size: 11px; }
|
|
266
307
|
#pf-pick-banner .pf-pick-prompt { color: var(--pf-text-strong); font-weight: 600; }
|
|
308
|
+
/* Below the rail's breakpoint the rail stacks under the viewer, so the stage is
|
|
309
|
+
full width again and the banner centres on the window. */
|
|
310
|
+
@media (max-width: 719px) {
|
|
311
|
+
#pf-pick-banner { left: 50%; }
|
|
312
|
+
}
|
|
267
313
|
#pf-pick-close {
|
|
268
314
|
position: absolute; top: 6px; right: 6px; appearance: none;
|
|
269
315
|
width: 20px; height: 20px; padding: 0;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* Reusable chrome LAYOUT for a partforge app: the shell, the viewer column
|
|
2
|
+
("stage"), the full-height controls rail, its resize seam, and the two
|
|
3
|
+
floating pill groups. This file owns the shell's layout and its structural
|
|
4
|
+
surfaces — the rail's/head's/foot's background, border, shadow, and text
|
|
5
|
+
color, and the seam pill's background — all expressed through overridable
|
|
6
|
+
--pf-* tokens. Appearance of the controls INSIDE the rail (the panel
|
|
7
|
+
widgets themselves) stays in app.css.
|
|
8
|
+
|
|
9
|
+
Class-based on purpose. partforge-cloud's sandbox builds its own DOM
|
|
10
|
+
(#viewer / #pfc-controls) and could never reuse an id-keyed sheet, so the
|
|
11
|
+
layout is expressed as .pf-* classes and exported standalone as
|
|
12
|
+
"partforge/chrome.css". app.css keeps :not(.pf-*) fallbacks so legacy
|
|
13
|
+
id-only markup renders its previous floating look untouched.
|
|
14
|
+
|
|
15
|
+
Prerequisite: this sheet consumes --pf-* custom properties (--pf-rail-w,
|
|
16
|
+
--pf-rail-pad, --pf-border, --pf-surface, --pf-bg, --pf-text, --pf-muted,
|
|
17
|
+
--pf-shadow-rail) but does not import them — a standalone consumer must
|
|
18
|
+
also load "partforge/tokens.css" (kept separate so the two stay
|
|
19
|
+
independently composable). The host is also responsible for giving
|
|
20
|
+
.pf-shell a height (e.g. `height: 100%` on an ancestor chain rooted at
|
|
21
|
+
`html, body`, or `position: absolute; inset: 0`); this sheet does not size
|
|
22
|
+
the shell itself.
|
|
23
|
+
|
|
24
|
+
See docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md. */
|
|
25
|
+
|
|
26
|
+
/* ---- shell: viewer column + rail, side by side --------------------------- */
|
|
27
|
+
.pf-shell {
|
|
28
|
+
display: flex;
|
|
29
|
+
/* containing block for the absolutely-positioned seam */
|
|
30
|
+
position: relative;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ---- stage: the viewer column, which owns its floating chrome ------------
|
|
35
|
+
min-width: 0 lets the column shrink past the canvas's intrinsic width, so
|
|
36
|
+
dragging the rail wider actually narrows the viewer instead of overflowing. */
|
|
37
|
+
.pf-stage {
|
|
38
|
+
flex: 1;
|
|
39
|
+
position: relative;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
background: var(--pf-bg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ---- rail: a full-height right edge, set back from the viewer ------------
|
|
45
|
+
Square-cornered: it is an edge, not a card. The shadow is INSET on its left
|
|
46
|
+
side — the viewer casts onto the rail, which is what makes the rail read as
|
|
47
|
+
set back. An outer shadow would read as floating above the viewer. */
|
|
48
|
+
.pf-rail {
|
|
49
|
+
flex: none;
|
|
50
|
+
width: var(--pf-rail-w);
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
min-height: 0;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
background: var(--pf-surface);
|
|
56
|
+
border-left: 1px solid var(--pf-border);
|
|
57
|
+
box-shadow: var(--pf-shadow-rail);
|
|
58
|
+
color: var(--pf-text);
|
|
59
|
+
/* Discrete changes (toggle, Home/End, double-click) animate; a drag never
|
|
60
|
+
does — an animated width fights the pointer and costs an extra WebGL
|
|
61
|
+
buffer reallocation every frame. */
|
|
62
|
+
transition: width .15s ease;
|
|
63
|
+
}
|
|
64
|
+
.pf-rail[inert] { border-left-width: 0; }
|
|
65
|
+
|
|
66
|
+
/* Head and foot are flex-fixed rather than sticky, so the scroll container is
|
|
67
|
+
exactly .pf-rail-body. On a full-height rail the export buttons must never
|
|
68
|
+
scroll out of reach. */
|
|
69
|
+
.pf-rail-head, .pf-rail-foot { flex: none; }
|
|
70
|
+
.pf-rail-head { padding: 12px var(--pf-rail-pad); border-bottom: 1px solid var(--pf-border); }
|
|
71
|
+
.pf-rail-foot { padding: 12px var(--pf-rail-pad); border-top: 1px solid var(--pf-border); }
|
|
72
|
+
.pf-rail-body {
|
|
73
|
+
flex: 1;
|
|
74
|
+
min-height: 0;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
overscroll-behavior: contain;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Rail-head title/subtitle typography — class-only (see this file's header:
|
|
80
|
+
no host-supplied id can be assumed), so a host that adopts this sheet with
|
|
81
|
+
its own ids (e.g. partforge-cloud) gets a correctly-shaped rail head with
|
|
82
|
+
correctly-styled text, not just a correctly-shaped empty box. Legacy
|
|
83
|
+
id-only markup (a bare <h1>/.sub with no .pf-rail-head wrapper) can't be
|
|
84
|
+
reached from here without an id selector, so app.css restates the same
|
|
85
|
+
two rules scoped to #panel for that one case — see the comment there. */
|
|
86
|
+
.pf-rail-head h1 {
|
|
87
|
+
font-size: 14px; margin: 0 0 2px; color: var(--pf-text-strong); letter-spacing: -0.01em;
|
|
88
|
+
}
|
|
89
|
+
.pf-rail-head .sub {
|
|
90
|
+
font-family: var(--pf-mono); color: var(--pf-muted); font-size: 10px;
|
|
91
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
92
|
+
margin: 2px 0 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- resize seam --------------------------------------------------------
|
|
96
|
+
An OVERLAY, not a flex item. partforge-cloud's seam is a real 12px column
|
|
97
|
+
because its card is inset from the window with a gutter to live in; this rail
|
|
98
|
+
is flush against the viewer behind a hairline, so a flex item would open a
|
|
99
|
+
visible stripe of page background.
|
|
100
|
+
|
|
101
|
+
max(0px, …) is what parks the seam flush at the window edge when the rail is
|
|
102
|
+
collapsed, so a fresh drag can pull it back out. The element is created by
|
|
103
|
+
rail.js — no host markup declares it. */
|
|
104
|
+
.pf-rail-seam {
|
|
105
|
+
position: absolute;
|
|
106
|
+
top: 0; bottom: 0;
|
|
107
|
+
right: max(0px, calc(var(--pf-rail-w) - 6px));
|
|
108
|
+
z-index: 20;
|
|
109
|
+
width: 12px;
|
|
110
|
+
display: flex; align-items: center; justify-content: center;
|
|
111
|
+
touch-action: none;
|
|
112
|
+
cursor: ew-resize;
|
|
113
|
+
}
|
|
114
|
+
/* Collapsed, the only legal direction is left. */
|
|
115
|
+
.pf-rail-seam[data-collapsed] { cursor: w-resize; }
|
|
116
|
+
.pf-rail-seam:focus-visible { outline: none; }
|
|
117
|
+
/* Invisible at rest; the affordance is a short centred pill that appears only
|
|
118
|
+
on hover, keyboard focus, or during a drag. */
|
|
119
|
+
.pf-rail-seam > span {
|
|
120
|
+
pointer-events: none;
|
|
121
|
+
width: 3px; height: 100px; border-radius: 999px;
|
|
122
|
+
background: transparent;
|
|
123
|
+
transition: background-color .12s ease;
|
|
124
|
+
/* Nudges only the pill 5px off the divider hairline it otherwise sits flush
|
|
125
|
+
against — the seam's own 12px hit target stays centred on the boundary
|
|
126
|
+
(that line is what a user aims at to drag) and must NOT move. */
|
|
127
|
+
transform: translateX(-5px);
|
|
128
|
+
}
|
|
129
|
+
.pf-rail-seam:hover > span,
|
|
130
|
+
[data-pf-dragging] .pf-rail-seam > span { background: var(--pf-muted); }
|
|
131
|
+
/* Keyboard focus must read as distinct from hover/drag, not just present —
|
|
132
|
+
the accent pill, not the muted one, is the only thing that says "focus
|
|
133
|
+
landed here" for a keyboard user. */
|
|
134
|
+
.pf-rail-seam:focus-visible > span { background: var(--pf-accent); }
|
|
135
|
+
|
|
136
|
+
/* While dragging, the cursor must stay correct even when the pointer is out
|
|
137
|
+
over the viewer, and the viewer must not react to it. Pointer capture keeps
|
|
138
|
+
the events coming; these two rules are the second belt. */
|
|
139
|
+
[data-pf-dragging] { cursor: ew-resize; user-select: none; }
|
|
140
|
+
[data-pf-dragging] .pf-stage { pointer-events: none; }
|
|
141
|
+
[data-pf-dragging] .pf-rail,
|
|
142
|
+
[data-pf-key-resizing] .pf-rail { transition: none; }
|
|
143
|
+
|
|
144
|
+
/* ---- floating chrome: PLACEMENT ONLY, absolute within the stage ----------
|
|
145
|
+
Deliberately no appearance here. partforge-cloud's sandbox.css re-anchors
|
|
146
|
+
#viewbar's position with its own rules but inherits the pill's chrome
|
|
147
|
+
(background/border/radius/shadow) from app.css, so that chrome must live in
|
|
148
|
+
app.css ungated — not be duplicated into a class the cloud never sets. This
|
|
149
|
+
file owns where things sit; app.css owns what they look like. */
|
|
150
|
+
.pf-float-tabs, .pf-float-viewbar { position: absolute; z-index: 15; }
|
|
151
|
+
.pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
|
|
152
|
+
.pf-float-viewbar { bottom: 12px; right: 12px; }
|
|
153
|
+
|
|
154
|
+
/* ---- stacked layout: no room for a rail beside the viewer ----------------
|
|
155
|
+
The seam is hidden and resize is absent at this width (rail.js also refuses
|
|
156
|
+
to start a drag); the #rail-toggle still collapses and restores. */
|
|
157
|
+
@media (max-width: 719px) {
|
|
158
|
+
.pf-shell { flex-direction: column; }
|
|
159
|
+
.pf-rail {
|
|
160
|
+
width: auto; height: 45vh;
|
|
161
|
+
border-left: 0; border-top: 1px solid var(--pf-border);
|
|
162
|
+
box-shadow: none;
|
|
163
|
+
}
|
|
164
|
+
.pf-rail-seam { display: none; }
|
|
165
|
+
/* Collapsed, --pf-rail-w has no effect here (width is auto, height is 45vh),
|
|
166
|
+
so hide the rail outright — otherwise the toggle's first tap leaves a
|
|
167
|
+
full-size rail on screen that is inert and unresponsive. */
|
|
168
|
+
.pf-rail[inert] { display: none; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ---- reduced motion -----------------------------------------------------
|
|
172
|
+
Collapsing the rail slides 288px of layout across the screen — the first
|
|
173
|
+
layout-scale animation in the framework, and the kind of movement a
|
|
174
|
+
vestibular-sensitive user actually feels. Honour the preference: the rail
|
|
175
|
+
still collapses and still resizes, it just arrives instead of travelling.
|
|
176
|
+
Scoped to what this layout introduces; the pre-existing busy spinner is a
|
|
177
|
+
state indicator and is left alone. */
|
|
178
|
+
@media (prefers-reduced-motion: reduce) {
|
|
179
|
+
.pf-rail, .pf-rail-seam > span { transition: none; }
|
|
180
|
+
}
|
|
@@ -5,8 +5,23 @@
|
|
|
5
5
|
export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
|
|
6
6
|
const box = document.createElement("div");
|
|
7
7
|
box.id = "pf-debug";
|
|
8
|
+
// Left edge, below the top tab row: #viewbar is bottom-right and
|
|
9
|
+
// #pf-pick/#pf-pick-toast are bottom-left, so the top-left corner has no
|
|
10
|
+
// OTHER fixed element competing for it — but #topbar's tabs are centred in
|
|
11
|
+
// the stage, and the stage starts flush at the window's left edge, so a top
|
|
12
|
+
// offset of 12px (matching the tabs') is not actually free of them: at
|
|
13
|
+
// narrow/medium widths this ~240px-wide box reaches past the stage's
|
|
14
|
+
// horizontal centre and touches the tabs no matter which top corner it
|
|
15
|
+
// anchors to (measured — see .superpowers/sdd/debug-overlay-fix.md).
|
|
16
|
+
// #topbar's pill is a fixed 38px tall regardless of viewport or label width
|
|
17
|
+
// (12px top + 38px), so clearing it VERTICALLY is what's actually
|
|
18
|
+
// width-independent: sit below the tabs instead of racing them
|
|
19
|
+
// horizontally. That also drops any dependency on --pf-rail-w, so no media
|
|
20
|
+
// query is needed for the stacked layout below 720px.
|
|
21
|
+
// Positioned inline (not in chrome.css) because this box is dev-only chrome
|
|
22
|
+
// specific to partforge itself, never exported for a host to skin or reuse.
|
|
8
23
|
Object.assign(box.style, {
|
|
9
|
-
position: "fixed",
|
|
24
|
+
position: "fixed", top: "58px", left: "12px", zIndex: "9999",
|
|
10
25
|
font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
|
|
11
26
|
color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
|
|
12
27
|
lineHeight: "1.5", whiteSpace: "pre",
|
|
@@ -28,7 +28,9 @@ function editDistance(a, b) {
|
|
|
28
28
|
// Prefix match first so long-form names hit their short key (radius→r,
|
|
29
29
|
// height→h, diameter→d), then edit distance ≤ 2 for plain typos. A digit
|
|
30
30
|
// suffix is peeled and re-attached so radius1 hints r1, not r.
|
|
31
|
-
|
|
31
|
+
// Exported so partforge/lint's `unknown-control-field` rule reuses this exact
|
|
32
|
+
// suggester rather than carrying a second copy of the edit-distance logic.
|
|
33
|
+
export function suggest(key, valid) {
|
|
32
34
|
const lk = key.toLowerCase();
|
|
33
35
|
const m = /^([a-z]+)(\d+)$/.exec(lk);
|
|
34
36
|
if (m) for (const v of valid) if (m[1].startsWith(v.toLowerCase()) && valid.includes(v + m[2])) return v + m[2];
|
|
@@ -1,22 +1,44 @@
|
|
|
1
|
-
// Geometry-free
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
1
|
+
// Geometry-free build execution. Two consumers share one Proxy implementation:
|
|
2
|
+
//
|
|
3
|
+
// • createProbeKernel() — records op NAMES so detectBackend() can route a part to
|
|
4
|
+
// OCCT when it uses fillet/chamfer/shell.
|
|
5
|
+
// • createValidatingProbe() — additionally checks op names against the kernel
|
|
6
|
+
// contract's op lists and routes options-form calls through the same op-options
|
|
7
|
+
// normalizers the real backends use, so partforge/lint can catch a bad call in
|
|
8
|
+
// microseconds instead of after a WASM boot.
|
|
9
|
+
//
|
|
10
|
+
// Catch-all proxies (rather than a hand-listed allowlist) mean new kernel/solid
|
|
11
|
+
// methods never have to be mirrored here — the probe can't drift out of sync with
|
|
12
|
+
// the real backends. (That drift previously broke the panel's relevance dimming
|
|
13
|
+
// when the build-step vocabulary was added but not taught to the probe.) The
|
|
14
|
+
// validating probe DOES need an allowlist, so it takes one from kernel.js's op
|
|
15
|
+
// lists, which test/kernel-contract.test.js pins to both backend implementations.
|
|
16
|
+
import {
|
|
17
|
+
OCCT_ONLY_OPS, KERNEL_OPS, KERNEL_OPTIONAL_OPS,
|
|
18
|
+
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
|
|
19
|
+
} from "./kernel.js";
|
|
20
|
+
import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
|
|
6
21
|
import { resolveDerived } from "../derive.js";
|
|
7
22
|
|
|
8
23
|
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
25
|
+
// The probe returns ONE chainable handle for every non-query op, so it cannot tell a
|
|
26
|
+
// Solid from a Shape2D — k.box() and k.shape2d() yield the same object. The solid-scope
|
|
27
|
+
// allowlist is therefore the union of all three surfaces: deliberately permissive, so
|
|
28
|
+
// it never false-positives on an error-severity rule.
|
|
29
|
+
const KERNEL_ALLOWED = new Set([...KERNEL_OPS, ...KERNEL_OPTIONAL_OPS]);
|
|
30
|
+
const SOLID_ALLOWED = new Set([...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS]);
|
|
31
|
+
|
|
32
|
+
export const MAX_PROBE_OPS = 100000;
|
|
33
|
+
|
|
34
|
+
// Thrown to unwind a runaway build. Never escapes runValidatingProbe.
|
|
35
|
+
export class ProbeRunawayError extends Error {
|
|
36
|
+
constructor(message) { super(message); this.name = "ProbeRunawayError"; }
|
|
37
|
+
}
|
|
13
38
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// methods never have to be mirrored here — the probe can't drift out of sync with the
|
|
18
|
-
// real backends. (That drift previously broke the panel's relevance dimming/hiding when
|
|
19
|
-
// the build-step vocabulary was added but not taught to the probe.)
|
|
39
|
+
// Shared proxy construction. `onCall(scope, op, args)` observes every op; queries
|
|
40
|
+
// return realistic dummy values the build may read.
|
|
41
|
+
function makeProbe(onCall) {
|
|
20
42
|
const solidQueries = {
|
|
21
43
|
boundingBox: () => ({ min: [0, 0, 0], max: [1, 1, 1], center: [0.5, 0.5, 0.5], size: [1, 1, 1] }),
|
|
22
44
|
volume: () => 1,
|
|
@@ -29,24 +51,101 @@ export function createProbeKernel() {
|
|
|
29
51
|
cleanup: () => {},
|
|
30
52
|
};
|
|
31
53
|
|
|
32
|
-
// `ignore` keeps the proxy from masquerading as a thenable/internal
|
|
33
|
-
// `then` (so it's never await-unwrapped),
|
|
34
|
-
// undefined rather than a chainable op.
|
|
35
|
-
|
|
54
|
+
// `ignore` keeps the proxy from masquerading as a thenable/internal/serializable
|
|
55
|
+
// handle: symbols, `then` (so it's never await-unwrapped), `_`-prefixed internals,
|
|
56
|
+
// and `toJSON` all resolve to undefined rather than a chainable op. `toJSON` matters
|
|
57
|
+
// because a handle nested inside an options object (the normal calling convention,
|
|
58
|
+
// e.g. `k.extrude({ profile: someShape, h: 5 })`) isn't caught by the `describe()`
|
|
59
|
+
// identity check below — that only sees the top-level options object, not the
|
|
60
|
+
// nested handle — so `JSON.stringify` walks into it and probes for `toJSON` per the
|
|
61
|
+
// spec. Without this, that probe would be recorded as a real op and then flagged as
|
|
62
|
+
// an unknown one.
|
|
63
|
+
const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
|
|
36
64
|
|
|
37
|
-
|
|
65
|
+
// A query (boundingBox, volume, toMesh, …) must count against `onCall`'s ceiling
|
|
66
|
+
// exactly like any other op — returning `queries[key]` directly here used to let
|
|
67
|
+
// every query bypass the counter entirely, so a query-only loop (`for(;;)
|
|
68
|
+
// s.volume()`) never tripped MAX_PROBE_OPS and hung forever. Wrap it the same
|
|
69
|
+
// way as the chaining branch below: observe the call, then run the real query.
|
|
70
|
+
const opProxy = (queries, scope) => new Proxy({}, {
|
|
38
71
|
get(_t, key) {
|
|
39
72
|
if (ignore(key)) return undefined;
|
|
40
|
-
if (key in queries) return queries[key];
|
|
41
|
-
return (...
|
|
73
|
+
if (key in queries) return (...args) => { onCall(scope, key, args); return queries[key](...args); };
|
|
74
|
+
return (...args) => { onCall(scope, key, args); return proxy; };
|
|
42
75
|
},
|
|
43
76
|
});
|
|
44
77
|
|
|
45
|
-
const proxy = opProxy(solidQueries); // a solid handle: every op chains back to itself
|
|
46
|
-
const kernel = opProxy(kernelQueries); // factory ops (cylinder/box/prism/…) return a solid
|
|
78
|
+
const proxy = opProxy(solidQueries, "solid"); // a solid handle: every op chains back to itself
|
|
79
|
+
const kernel = opProxy(kernelQueries, "kernel"); // factory ops (cylinder/box/prism/…) return a solid
|
|
80
|
+
return { kernel, proxy };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createProbeKernel() {
|
|
84
|
+
const used = new Set();
|
|
85
|
+
const { kernel } = makeProbe((_scope, key) => used.add(key));
|
|
47
86
|
return { kernel, used };
|
|
48
87
|
}
|
|
49
88
|
|
|
89
|
+
export function createValidatingProbe({ maxOps = MAX_PROBE_OPS } = {}) {
|
|
90
|
+
const calls = [];
|
|
91
|
+
const issues = [];
|
|
92
|
+
const used = new Set();
|
|
93
|
+
let count = 0;
|
|
94
|
+
let solidProxy = null;
|
|
95
|
+
|
|
96
|
+
// Args are recorded as strings so two probe runs can be compared for determinism.
|
|
97
|
+
// The chainable handle is a single shared object, so identity is enough to spot it —
|
|
98
|
+
// and checking identity FIRST matters, because JSON.stringify would trip its traps.
|
|
99
|
+
const describe = (a) => {
|
|
100
|
+
if (a === solidProxy) return "<solid>";
|
|
101
|
+
if (typeof a === "function") return "<fn>";
|
|
102
|
+
try { return JSON.stringify(a) ?? String(a); } catch { return "<unserializable>"; }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const onCall = (scope, op, args) => {
|
|
106
|
+
if (++count > maxOps) throw new ProbeRunawayError(`build exceeded ${maxOps} kernel operations`);
|
|
107
|
+
used.add(op);
|
|
108
|
+
const allowed = scope === "kernel" ? KERNEL_ALLOWED : SOLID_ALLOWED;
|
|
109
|
+
if (!allowed.has(op)) issues.push({ kind: "unknown-op", scope, op });
|
|
110
|
+
// Validate ONLY the options form — the normative rule (KERNEL-CONTRACT.md
|
|
111
|
+
// "Calling convention") is that a call is options form when it receives exactly
|
|
112
|
+
// one plain-object argument. Legacy positional calls have no options contract to
|
|
113
|
+
// check against. We run `toArgs` (key + required validation) but never the spec's
|
|
114
|
+
// separate `check` hook: `check` inspects real geometry (revolve's calls
|
|
115
|
+
// boundingBox() on its profile), which is meaningless against a proxy.
|
|
116
|
+
const specs = scope === "kernel" ? KERNEL_OP_SPECS : SOLID_OP_SPECS;
|
|
117
|
+
if (specs[op] && args.length === 1 && isPlainOptions(args[0])) {
|
|
118
|
+
try { specs[op].toArgs(args[0]); }
|
|
119
|
+
catch (e) { issues.push({ kind: "invalid-options", scope, op, message: e?.message || String(e) }); }
|
|
120
|
+
}
|
|
121
|
+
calls.push({ scope, op, args: args.map(describe) });
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const { kernel, proxy } = makeProbe(onCall);
|
|
125
|
+
solidProxy = proxy;
|
|
126
|
+
return { kernel, calls, issues, used };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Execute every sub-part's build() against a validating probe.
|
|
131
|
+
* Never throws: a build error becomes an entry in `throws`, a runaway sets `runaway`.
|
|
132
|
+
*/
|
|
133
|
+
export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {}) {
|
|
134
|
+
const probe = createValidatingProbe({ maxOps });
|
|
135
|
+
const throws = [];
|
|
136
|
+
let runaway = false;
|
|
137
|
+
for (const [name, sp] of Object.entries(part?.parts ?? {})) {
|
|
138
|
+
if (typeof sp?.build !== "function") continue; // no-buildable-parts already reports this
|
|
139
|
+
try {
|
|
140
|
+
sp.build(probe.kernel, p, d);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (e instanceof ProbeRunawayError) { runaway = true; break; }
|
|
143
|
+
throws.push({ subpart: name, message: e?.message || String(e) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { calls: probe.calls, issues: probe.issues, used: probe.used, throws, runaway };
|
|
147
|
+
}
|
|
148
|
+
|
|
50
149
|
export function detectBackend(part, params = {}) {
|
|
51
150
|
if (part.meta?.backend) return part.meta.backend;
|
|
52
151
|
const p = { ...part.defaults, ...params };
|