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 CHANGED
@@ -99,10 +99,11 @@ const runtime = mount(part, {
99
99
  createWorker,
100
100
  elements: {
101
101
  viewer, controls, // canvas host + param-panel host
102
+ rail, // full-height resizable/collapsible controls rail
102
103
  status: { status, busy, phase }, // status chrome
103
104
  tabs, // view-tab segmented control
104
105
  exports: { stl, step, threeMf }, // export buttons
105
- chrome: { pause, reframe, theme }, // viewer buttons
106
+ chrome: { pause, reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
106
107
  },
107
108
  onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
108
109
  onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
@@ -112,9 +113,20 @@ runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU
112
113
  ```
113
114
 
114
115
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
115
- `#status`/`#busy`/`#phase`, `#part`, `#download`/`#download-step`/`#download-3mf`,
116
- `#pause`/`#reframe`/`#theme`), so a classic host page needs no changes. The viewer
117
- sizes from its container via ResizeObserver no window coupling.
116
+ `#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
117
+ `#download`/`#download-step`/`#download-3mf`,
118
+ `#pause`/`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no
119
+ changes. The viewer sizes from its container via ResizeObserver — no window
120
+ coupling.
121
+
122
+ `rail` is the full-height controls rail introduced by the resizable-panel
123
+ layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
124
+ `chrome.railToggle` is its optional collapse/restore button. Both are optional
125
+ — a host that lays out the framework itself (no rail markup) gets a no-op.
126
+ The rail's resize seam is positioned against `rail.parentElement` by default,
127
+ so **the rail must be a direct child of the positioned `.pf-shell`** unless
128
+ the host also supplies `elements.shell` to point at the real containing block
129
+ (e.g. when a wrapper div sits between them, as is common in a React layout).
118
130
 
119
131
  `onPick` arms click-to-select permanently: `label` is the feature label (falling
120
132
  back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
package/bin/cli.js CHANGED
@@ -15,9 +15,10 @@ import { verify } from "../src/testing/verify.js";
15
15
  import { renderViews } from "../src/testing/render.js";
16
16
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
17
17
  import { matchPattern } from "../src/testing/error-patterns.js";
18
+ import { lintPart } from "../src/lint.js";
18
19
 
19
20
  const die = (msg) => { console.error(msg); process.exit(1); };
20
- const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
21
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
21
22
 
22
23
  // Crash contract (issue #27): with --json, a thrown error becomes structured
23
24
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -59,16 +60,57 @@ async function loadPart(partPath, usage) {
59
60
  const bootKernel = (part) => (detectBackend(part) === "occt" ? bootOcctKernel() : bootManifoldKernel());
60
61
 
61
62
  const commands = {
63
+ async lint(args) {
64
+ const usage = "usage: partforge lint <part-module> [--params <json>] [--json] [--out <file>] [--strict]";
65
+ const { values: flags, positionals: [partPath] } = parse(args, {
66
+ params: { type: "string" },
67
+ json: { type: "boolean" },
68
+ out: { type: "string" },
69
+ strict: { type: "boolean" },
70
+ }, usage);
71
+ try {
72
+ const part = await loadPart(partPath, usage);
73
+ const params = flags.params ? JSON.parse(flags.params) : undefined;
74
+ const report = lintPart(part, { params });
75
+ if (!flags.json) printLint(report);
76
+ if (flags.out) {
77
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
78
+ writeFileSync(flags.out, JSON.stringify(report, null, 2));
79
+ console.log(`\nwrote ${flags.out}`);
80
+ }
81
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
82
+ process.exit(report.ok && (!flags.strict || report.warnings.length === 0) ? 0 : 1);
83
+ } catch (e) {
84
+ crash("lint", e, !!flags.json);
85
+ }
86
+ },
87
+
62
88
  async measure(args) {
63
- const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--json] [--out <file>]";
89
+ const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--no-lint] [--json] [--out <file>]";
64
90
  const { values: flags, positionals: [partPath, view] } = parse(args, {
65
91
  process: { type: "string" },
66
92
  "no-verify": { type: "boolean" },
93
+ "no-lint": { type: "boolean" },
67
94
  json: { type: "boolean" },
68
95
  out: { type: "string" },
69
96
  }, usage);
70
97
  try {
71
98
  const part = await loadPart(partPath, usage);
99
+ // Error-tier lint before the kernel boots: a statically broken part fails in
100
+ // milliseconds with a precise message rather than after a WASM boot and a
101
+ // downstream error that doesn't name the cause. Warnings never gate measure.
102
+ if (!flags["no-lint"]) {
103
+ const lint = lintPart(part);
104
+ if (!lint.ok) {
105
+ if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
106
+ else printLint(lint);
107
+ if (flags.out) {
108
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
109
+ writeFileSync(flags.out, JSON.stringify({ ok: false, lint }, null, 2));
110
+ }
111
+ process.exit(1);
112
+ }
113
+ }
72
114
  const kernel = await bootKernel(part);
73
115
  const report = measure(kernel, part, view);
74
116
  printMeasure(report);
@@ -172,6 +214,19 @@ function printVerify(v) {
172
214
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
173
215
  }
174
216
 
217
+ function printLint(r) {
218
+ const all = [...r.errors, ...r.warnings];
219
+ if (all.length === 0) { console.log("lint: clean"); return; }
220
+ console.log("lint:");
221
+ for (const f of all) {
222
+ console.log(` ${f.severity === "error" ? "✗" : "⚠"} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
223
+ console.log(` ${f.message}`);
224
+ console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
225
+ }
226
+ const e = r.errors.length, w = r.warnings.length;
227
+ console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
228
+ }
229
+
175
230
  const [, , cmd, ...args] = process.argv;
176
231
  if (!commands[cmd]) die(USAGE);
177
232
  await commands[cmd](args);
@@ -30,8 +30,9 @@ OCCT-only fillet/chamfer/shell ops.
30
30
  `http://localhost:5173/<your-part>.html`.
31
31
 
32
32
  That's the whole loop. The chrome (panel, tabs, viewer, export buttons) is shared —
33
- your HTML is ~30 lines of structural markup and carries no CSS (the framework
34
- supplies it via `framework/app.css`, imported by `mount`).
33
+ your HTML is structural markup only and carries no CSS (the framework supplies it via
34
+ `framework/app.css`, imported by `mount`). See "Wiring a part into a runnable app"
35
+ below for what that markup must contain.
35
36
 
36
37
  ---
37
38
 
@@ -390,6 +391,28 @@ choosing a preset updates both numeric and text fields.
390
391
  Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
391
392
  everything below.
392
393
 
394
+ **Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
395
+ `toggles` array to a preset section — shown below the preset picker, outside the
396
+ Advanced fold, so it stays visible:
397
+
398
+ ```js
399
+ {
400
+ id: "shape",
401
+ title: "Shape ops",
402
+ toggles: [
403
+ { key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
404
+ description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
405
+ ],
406
+ }
407
+ ```
408
+
409
+ Each entry is `{ key, label, on?, hidden?, description? }`: checked sets `key` to `on`
410
+ (default `1`); unchecked sets it to `0`. This is the correct home for a bare boolean —
411
+ a `features` entry *requires* a `sliders` array (the panel reads `feat.sliders.filter(...)`
412
+ unguarded and throws if it's missing), so a feature with nothing to reveal belongs in
413
+ `toggles` instead. `src/parts/bracket.js`'s `clip` toggle (shown above) is the worked
414
+ example.
415
+
393
416
  **Control metadata (optional — on any control def, feature, or section):**
394
417
 
395
418
  - `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
@@ -666,37 +689,121 @@ stylesheet). `mount` looks up these element IDs:
666
689
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
667
690
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
668
691
  | `#viewbar` with `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
669
-
670
- Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
671
- (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
692
+ | `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
693
+ | `#rail-toggle` | optional collapses/restores the rail; resolved the same way as `#pause`/`#theme` |
694
+
695
+ Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
696
+ workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
697
+ `"occt"` for STEP — handled for you).
698
+
699
+ **The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
700
+ `class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
701
+ (`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
702
+ (`#topbar`, `#viewbar`, `#busy`) as absolutely-positioned siblings of the canvas, not
703
+ page-level overlays. `#panel` (`class="pf-rail"`) is a full-height rail docked to the
704
+ right edge, split into three children — `.pf-rail-head` / `.pf-rail-body` /
705
+ `.pf-rail-foot` — of which head and foot are flex-fixed and only the body scrolls: put
706
+ your heading in the head and the download row in the foot so the export buttons never
707
+ scroll out of reach. The rail's drag/collapse seam is created by `rail.js` itself; don't
708
+ add markup for it. This isn't decorative — get the head/body/foot split wrong and either
709
+ the export buttons scroll away or a tall parameter list pushes them off-screen. See
710
+ `docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md` for why the rail is
711
+ shaped this way (resize/collapse behavior, breakpoints, the design rationale).
712
+
713
+ **Keyboard (the seam is `role="separator"`, focusable, `tabIndex=0`):**
714
+
715
+ | Key | Action |
716
+ |---|---|
717
+ | ← | widen the rail 16px (64px with Shift). No-op while collapsed. |
718
+ | → | narrow the rail 16px (64px with Shift), clamped at the 240px minimum — never collapses. No-op while collapsed. |
719
+ | Home | jump to the 240px minimum, animated. Reopens even while collapsed. |
720
+ | End | jump to the clamped maximum (half the shell, capped at 560px), animated. Reopens even while collapsed. |
721
+ | Enter / Space | toggle collapse — collapses if open; reopens at the remembered width if collapsed. |
722
+ | double-click (on the seam) | reset to the 288px default, animated, and opens if collapsed. |
723
+
724
+ Arrow keys move the **separator**, not the pane — standard `role="separator"`
725
+ semantics, and why ← *widens* a right-hand rail. `Cmd`/`Ctrl`/`Alt` held with an
726
+ arrow key passes through untouched (those are OS/browser-reserved combos, e.g.
727
+ back navigation or window-switching); `Shift` alone still applies the larger
728
+ step. Collapsed, the two arrow keys are deliberate no-ops rather than a reopen
729
+ gesture — reopening would otherwise silently discard the remembered width and
730
+ clamp to the minimum, and "press an arrow, get narrower" reads backwards for a
731
+ rail that's already shut. Home/End and Enter/Space/double-click are exempt from
732
+ that rule and always reopen, since jumping to an explicit width or toggling is
733
+ an unambiguous, deliberate gesture either way. A held arrow-key repeat
734
+ suppresses the 150ms width transition for the whole repeat window (not just one
735
+ keydown), matching what happens during a drag.
736
+
737
+ Legacy id-only markup (predating this class scheme) still renders: `app.css` keeps
738
+ `:not(.pf-*)` fallbacks (`#app:not(.pf-stage)`, `#panel:not(.pf-rail)`, and
739
+ placement-only ones for `#topbar`/`#viewbar`) that reproduce the old floating-card
740
+ look — `:not()` rather than a plain id rule because an id selector outranks a class. New
741
+ apps should still use the classed markup above; the fallback exists for pages that
742
+ predate it, not as a second supported style.
743
+
744
+ A host that builds its own DOM instead of using `mount`'s markup (e.g. an editor
745
+ embedding the viewer/rail inside a larger UI) can adopt the same layout by importing
746
+ **`partforge/chrome.css`** directly — it's deliberately class-based and id-free for that
747
+ reason. It expects `partforge/tokens.css` to already be loaded for its `--pf-*` custom
748
+ properties, and expects the host to size `.pf-shell` itself (`mount`'s own `app.css`,
749
+ which `@import`s both, does both of these for you already).
672
750
 
673
751
  `#cutaway` is optional viewer chrome. When present, it toggles an interactive
674
752
  section plane whose exposed faces are hatched; changing views resets it. Cutaway
675
753
  is viewer-only and never changes STL, STEP, or 3MF exports. Hosts that omit the
676
754
  button get no cutaway UI.
677
755
 
678
- Programmatic hosts can provide the same optional control without relying on an
679
- ID by passing it beside the other chrome references:
756
+ Programmatic hosts can provide the same optional controls, including the rail toggle,
757
+ without relying on an ID by passing them beside the other chrome references — and can
758
+ pass the rail itself as `elements.rail` instead of relying on `#panel`:
680
759
 
681
760
  ```js
682
761
  mount(part, {
683
762
  createWorker,
684
763
  elements: {
764
+ rail,
685
765
  chrome: {
686
766
  pause,
687
767
  reframe,
688
768
  cutaway,
689
769
  theme,
770
+ railToggle,
690
771
  },
691
772
  },
692
773
  });
693
774
  ```
694
775
 
776
+ `rail`/`chrome.railToggle` are both optional; a host with no rail markup gets a
777
+ no-op (the resize/collapse behavior below simply doesn't attach). **Constraint:**
778
+ the rail element must be a direct child of the positioned `.pf-shell` — the
779
+ resize seam is created and positioned against `rail.parentElement` by default,
780
+ so an extra wrapper div between them (common in a React layout) puts the seam
781
+ against the wrong ancestor and silently breaks `[data-pf-dragging] .pf-stage`.
782
+ A host that can't make the rail a direct child of `.pf-shell` must also pass
783
+ `elements.shell` pointing at the real positioned ancestor:
784
+
785
+ ```js
786
+ mount(part, {
787
+ createWorker,
788
+ elements: { rail, shell, chrome: { railToggle } },
789
+ });
790
+ ```
791
+
695
792
  > Production deploy compiles only the pages listed in `build.rollupOptions.input`
696
793
  > (currently the landing gallery + the demo part pages). Other root `*.html` files are
697
794
  > **dev-only** (Vite serves any root HTML in `npm run dev`) unless added there. To also
698
795
  > ship one, add it to `build.rollupOptions.input` in `vite.config.js`.
699
796
 
797
+ **Styling hooks:** the rail/stage layout and palette are both plain `--pf-*` custom
798
+ properties from `partforge/tokens.css`, overridable on `:root` (or
799
+ `:root[data-theme="light"]`) without touching `chrome.css`. Layout/shape tokens added
800
+ alongside the rail: `--pf-sans`, `--pf-rail-w`, `--pf-rail-pad`, `--pf-radius-control`,
801
+ `--pf-radius-pill`, `--pf-shadow-float`, `--pf-shadow-rail`. The dev demos self-host
802
+ Geist and Geist Mono (`@fontsource-variable/geist(-mono)`, a `devDependency`, imported
803
+ from each `app-<part>.js` — see `src/app-demo.js`) so a standalone forge looks like the
804
+ finished product; the published library ships no font files, and a consumer that loads
805
+ none falls through `--pf-sans`/`--pf-mono` to system stacks by design.
806
+
700
807
  ### Developing against a local (linked) partforge
701
808
 
702
809
  A normal `npm install partforge` needs no extra config. But if you `npm link` a local
@@ -785,6 +892,83 @@ The `measure` function is also exported for vitest (boot a Manifold kernel as in
785
892
  expect(r.subparts[0].holes).toBe(1); // e.g. expects one bore
786
893
  });
787
894
 
895
+ ## Linting
896
+
897
+ `partforge lint` statically validates a PartDefinition without booting a geometry
898
+ kernel. It runs in milliseconds and catches the authoring mistakes that otherwise
899
+ surface only at runtime — or, worse, not at all.
900
+
901
+ ```bash
902
+ npx partforge lint src/parts/<part>.js [--params '{"h":40}'] [--json] [--out f] [--strict]
903
+ ```
904
+
905
+ Exit 0 when clean, 1 when any **error** finding is present; `--strict` also fails on
906
+ warnings. `partforge measure` runs the error tier automatically before booting a
907
+ kernel — pass `--no-lint` to skip it.
908
+
909
+ The same check is available programmatically and in the browser:
910
+
911
+ ```js
912
+ import { lintPart } from "partforge/lint";
913
+ const { ok, errors, warnings } = lintPart(part, { params });
914
+ ```
915
+
916
+ `partforge/lint` has **zero runtime dependencies** and never imports a geometry
917
+ kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
918
+ iframe, and Deno. A worker also answers `{ type: "lint", params }` with
919
+ `{ type: "lint-report", report }` without booting its kernel.
920
+
921
+ **Findings** carry the same guarantees as verify's checks — a self-contained `hint`
922
+ on every one, and a stable `pattern` id where an ERROR-PATTERNS.md entry applies:
923
+
924
+ ```js
925
+ { rule: "features-requires-sliders", severity: "error",
926
+ message: "section \"flange\" feature 0 has no `sliders` array",
927
+ hint: "A `features` entry must carry a `sliders` array …",
928
+ path: "parameters[1].features[0]", pattern: "features-missing-sliders" }
929
+ ```
930
+
931
+ `path` is a JS accessor path rooted at the PartDefinition — `parameters[1].features[0]`,
932
+ `defaults.bore`, `parts.spacer.views[0]`, `parameters[0].presets["M3"].od`. Findings
933
+ about the definition as a whole use `""`.
934
+
935
+ **Severity.** A finding is an `error` when the part is *provably broken* — it cannot
936
+ behave as authored — whether or not that shows up as a thrown exception. Some error
937
+ findings do correspond to a runtime throw (`build-throws`, `verify-expect-throws`),
938
+ but others catch **silent** wrongness: `missing-meta-title`, `part-view-unknown`,
939
+ `control-key-not-in-defaults`, `preset-key-not-in-defaults`, and
940
+ `verify-unknown-subpart` all fire on parts that build, measure, and verify cleanly —
941
+ a dead control that's silently unreachable, a view that renders nothing, or a
942
+ `verify` expectation that's silently dropped so its gate never runs. That's still an
943
+ error: the part doesn't do what its author wrote, the failure is just quiet instead
944
+ of loud. Everything speculative or stylistic — lossy but not broken — is a `warning`
945
+ and never blocks anything. Because `measure` runs the error tier as a gate (see
946
+ below), a part with one of these silent defects now exits non-zero where it
947
+ previously didn't; that's the fix working as intended, not a regression.
948
+
949
+ ### Rule catalog
950
+
951
+ **Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
952
+ `missing-views`, `part-view-unknown` (all errors); `view-unused` (warning).
953
+
954
+ **Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
955
+ `preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
956
+ `unknown-control-field`, `duplicate-control-key`, `default-not-exposed` (warnings).
957
+
958
+ **Kernel API**, found by executing `build()` against a geometry-free probe —
959
+ `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
960
+ `derive-throws`, `manifold-backend-uses-occt-op`, `build-runaway` (errors);
961
+ `nondeterministic-build` (warning, from diffing two probe runs).
962
+
963
+ **Verify block** — `verify-unknown-metric`, `verify-unknown-subpart`,
964
+ `verify-bad-expr`, `verify-bad-pair-check`, `verify-unknown-process`,
965
+ `verify-expect-throws` (all errors). Note `_view` also accepts the pair-wise
966
+ `contacts` / `clearance` keys, which are not scalar view metrics; they are
967
+ validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
968
+
969
+ A rule that itself throws yields an `internal-rule-error` **warning** and the run
970
+ continues: `lintPart` never throws and never blocks a part because of a linter bug.
971
+
788
972
  ### The diagnostics contract (for agents)
789
973
 
790
974
  `partforge measure <part> --json` / `--out <file>` emits the machine-readable
@@ -817,13 +1001,20 @@ JSON to stdout and exits 1:
817
1001
  ```
818
1002
 
819
1003
  `pattern`/`hint` appear when the message matches an ERROR-PATTERNS.md symptom
820
- string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. Caveat: a throw
821
- *after* measure output has printed (e.g. an unknown metric in `verify.expect`, or
822
- a per-case build crash) appends this JSON after the human lines, so stdout is no
823
- longer pure JSON; prefer `--out` (or parse the trailing JSON object the crash
824
- JSON is pretty-printed across multiple lines) for robust machine parsing. With
825
- `--out` the measure report is written to the file as soon as `measure` succeeds,
826
- so even if a later `verify` throw crashes the run the file is there — it just
1004
+ string. Exit codes: 0 pass, 1 gate failure or crash — unchanged. `measure`'s
1005
+ automatic lint pass (see "Linting" above) now catches most of the defects that
1006
+ used to surface this way statically, before the kernel boots, so they fail with
1007
+ pure JSON up front instead. The caveat narrows but doesn't disappear: lint
1008
+ resolves `verify.expect` once against the part's *defaults*, while `verify()`
1009
+ itself expands every `verify.cases` entry and re-resolves `expect(p, d)` per
1010
+ case so an expectation that only names a bad metric/subpart for a non-default
1011
+ case (see `test/fixtures/unknown-metric-in-case-part.js`) still passes lint
1012
+ clean and then throws at runtime, after measure output has printed. That throw
1013
+ appends crash JSON after the human lines, so stdout is no longer pure JSON;
1014
+ prefer `--out` (or parse the trailing JSON object — the crash JSON is
1015
+ pretty-printed across multiple lines) for robust machine parsing. With `--out`
1016
+ the measure report is written to the file as soon as `measure` succeeds, so
1017
+ even if a later `verify` throw crashes the run the file is there — it just
827
1018
  lacks the `verify` key.
828
1019
 
829
1020
  **Fresh-evidence rule.** A passing report is evidence only for the source, parameters,
@@ -115,6 +115,12 @@ The framework itself rebuilds each sub-part fresh per job and applies `place` on
115
115
  - **Cause:** A `key` used in the `parameters` schema (slider, feature, or preset override) doesn't exist in `defaults` — every key must, including `hidden` ones.
116
116
  - **Fix:** Add the key to `defaults` with a sensible starting value. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
117
117
 
118
+ ## features-missing-sliders
119
+
120
+ - **Symptom:** `Cannot read properties of undefined (reading 'filter')` thrown from the control panel while the app boots, with no geometry ever rendering.
121
+ - **Cause:** A `features` entry in the parameter schema has no `sliders` array — `controls.js` reads `feat.sliders.filter(...)` unguarded. A bare on/off control was put in `features` instead of `toggles`.
122
+ - **Fix:** Move a bare boolean to the section's `toggles` array (`{ key, label, on }`), or give the `features` entry the `sliders` array it requires. `npx partforge lint <part>` catches this statically as `features-requires-sliders`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Parameters: the control-panel schema".
123
+
118
124
  ## dimmed-control-vestigial-param
119
125
 
120
126
  - **Symptom:** A control renders dimmed (but still editable) and changing it does nothing on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.26.1",
3
+ "version": "0.28.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",
@@ -25,9 +25,11 @@
25
25
  ".": "./src/index.js",
26
26
  "./worker": "./src/framework/worker.js",
27
27
  "./geometry": "./src/framework/geometry/polygon.js",
28
+ "./lint": "./src/lint.js",
28
29
  "./derive": "./src/framework/derive.js",
29
30
  "./testing": "./src/testing.js",
30
- "./tokens.css": "./src/framework/tokens.css"
31
+ "./tokens.css": "./src/framework/tokens.css",
32
+ "./chrome.css": "./src/framework/chrome.css"
31
33
  },
32
34
  "bin": {
33
35
  "partforge": "./bin/cli.js"
@@ -53,6 +55,8 @@
53
55
  "three": "^0.184.0"
54
56
  },
55
57
  "devDependencies": {
58
+ "@fontsource-variable/geist": "^5.3.0",
59
+ "@fontsource-variable/geist-mono": "^5.3.0",
56
60
  "happy-dom": "^20.10.6",
57
61
  "playwright": "^1.49.0",
58
62
  "vite": "^8.0.16",
@@ -60,4 +60,10 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
60
60
  ## Related: debugging failures
61
61
 
62
62
  If anything fails while you're editing a part, grep `docs/ERROR-PATTERNS.md` for the
63
- symptom first — its preamble states the full grep-first rule.
63
+ symptom first — its preamble states the full grep-first rule. Before assuming a user's
64
+ click is needed at all, run the static linter — it's instant, needs no live app, and
65
+ catches schema/build mistakes no pick session would explain:
66
+
67
+ ```bash
68
+ partforge lint src/parts/<part>.js
69
+ ```
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import part from "./parts/bracket.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
package/src/app-demo.js CHANGED
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import demoPart from "./parts/demo.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import vasePart from "./parts/faceted-vase.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import part from "./parts/filleted-box.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import part from "./parts/hull-sweep.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import part from "./parts/nameplate.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import planterPart from "./parts/planter.js";
2
7
  import { mount } from "./framework/index.js";
3
8
 
@@ -1,3 +1,8 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
1
6
  import part from "./parts/text-smoke.js";
2
7
  import { mount } from "./framework/index.js";
3
8