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.
@@ -453,104 +453,357 @@ even at animation rates (see `runtime.setParams`).
453
453
 
454
454
  ## Parameters: the control-panel schema
455
455
 
456
- `parameters` is an **array of sections**; the framework builds the panel from it and
457
- binds each control to a key in `defaults`. Two section kinds:
456
+ `src/parts/planter.js`'s "Body" section is a live in-repo example of the full node
457
+ shape a preset, headline sliders, and a nested `"Wall"` group holding a
458
+ `recommended` band and an `innerDia` readout. `src/parts/bracket.js`'s "Shape ops"
459
+ section shows a `"radio"` control, and mixes it with two sections left on the legacy
460
+ shape — proof the two coexist in one part.
458
461
 
459
- **Preset + controls section:**
462
+ `parameters` is an **array of sections**. Each section is a node with a `controls`
463
+ array, and **authored order is render order** — what you write top-to-bottom is what
464
+ the user reads top-to-bottom:
460
465
 
461
466
  ```js
462
467
  {
463
- id: "body",
468
+ id: "body", // optional; also the node id (see "Ids" below)
464
469
  title: "Body",
465
- presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } }, // name → param overrides
466
- advanced: [ // controls revealed under "Advanced"
467
- { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
468
- { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
469
- { key: "title", label: "Title", control: "text" },
470
- { key: "label", label: "Label", control: "textarea" },
471
- ],
470
+ description: "...", // CommonMark, behind the section's glyph
471
+ collapsed: "auto", // true | false | "auto" (default)
472
+ when: { ... }, // optional condition see "Conditions" below
473
+ controls: [ /* entries, in render order */ ],
472
474
  }
473
475
  ```
474
476
 
475
- Numeric slider/feature controls show an **editable number box** beside them drag the
476
- slider or type an exact value (finer than `step` is allowed; typed values clamp to
477
- `[min, max]`). Optional `control` per parameter chooses the input:
477
+ Every entry in `controls` is one of four things, told apart by its `type`:
478
478
 
479
- - omit it (or use `"slider"`) for a slider + number box;
480
- - `"number"` for a number box only (handy for precise or wide-range values);
481
- - `"text"` for a single-line string field;
482
- - `"textarea"` for a multiline string field whose line breaks are preserved.
479
+ - a **control** bound to one key in `defaults` (`type` defaults to `"slider"`,
480
+ so a plain `{ key, label, min, max, step }` is a slider);
481
+ - **`type: "group"`** a nested container with its own `controls` array;
482
+ - **`type: "preset"`** a picker that writes a bundle of parameters at once;
483
+ - **`type: "readout"`** — a read-only display of a `derive()` output.
483
484
 
484
- Text fields update `params` live on every edit, so the existing rebuild loop previews
485
- the new string immediately. Give every text key a string value in `defaults`; empty
486
- strings are valid control values, while the part's build function decides whether its
487
- geometry supports them. Editing any control in a preset section selects `Custom`, and
488
- choosing a preset updates both numeric and text fields.
485
+ Groups nest, but **two levels is the limit** a section plus one fold inside it is
486
+ as deep as a 300 px rail stays readable, and `partforge lint` warns (`group-depth`)
487
+ past that. Flatten by promoting the inner group to its own section.
489
488
 
490
- **Feature-toggle section** (checkbox enables a feature + reveals its sliders; `0` = off):
489
+ A complete section, exercising most of the model:
491
490
 
492
491
  ```js
493
- {
494
- id: "flange",
495
- title: "Flange",
496
- features: [
497
- { label: "Base flange", key: "flange_d", on: 16, // checked → set key to `on`; unchecked → 0
498
- sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
499
- ],
500
- }
492
+ defaults: { profile: "round", facets: 6, dia: 80, wall: 2, feet: 0 },
493
+ derive: (p) => ({ innerDia: p.dia - 2 * p.wall }),
494
+
495
+ parameters: [
496
+ {
497
+ id: "body",
498
+ title: "Body",
499
+ description: "Silhouette and size of the vessel.",
500
+ controls: [
501
+ { type: "preset", presets: {
502
+ "Pen cup": { dia: 80, wall: 2, profile: "round" },
503
+ Vase: { dia: 120, wall: 2.4, profile: "faceted", facets: 8 },
504
+ } },
505
+
506
+ { key: "profile", type: "radio", label: "Profile",
507
+ options: [{ value: "round", label: "Round" }, { value: "faceted", label: "Faceted" }],
508
+ description: "**Round** revolves the silhouette; **faceted** prisms it." },
509
+
510
+ { key: "facets", type: "slider", label: "Facets", min: 3, max: 12, step: 1,
511
+ when: { profile: "faceted" }, // only shown on a faceted profile
512
+ description: "Sides of the prism. 6–8 reads as faceted without looking coarse." },
513
+
514
+ { key: "dia", type: "slider", label: "Diameter", unit: "mm", min: 30, max: 150, step: 1,
515
+ description: "Outer diameter at the widest point; 60–100 mm suits a pen cup." },
516
+
517
+ { type: "group", title: "Wall", collapsed: "auto", controls: [
518
+ { key: "wall", type: "slider", label: "Thickness", unit: "mm",
519
+ min: 0.8, max: 4, step: 0.1, recommended: [1.2, 4],
520
+ description: "Wall thickness. Under 1.2 mm an FDM print gets fragile." },
521
+
522
+ { type: "readout", label: "Inner diameter", derivedKey: "innerDia", unit: "mm",
523
+ description: "Diameter minus both walls — the space something actually has to fit into." },
524
+
525
+ { key: "feet", type: "checkbox", label: "Raised feet", on: 3,
526
+ description: "Lift the base on four 3 mm feet so it drains and de-moulds cleanly." },
527
+ ] },
528
+ ],
529
+ },
530
+ ]
501
531
  ```
502
532
 
503
- Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
504
- everything below.
533
+ Every control `key` must exist in `defaults`, or the control is silently dead
534
+ `control-key-not-in-defaults` is an error for exactly that reason.
535
+
536
+ **Ids.** A section, a group and a preset may carry an `id`; the renderer keys its
537
+ element, state and disclosure maps on ids, so they must be unique across the whole
538
+ panel (`duplicate-node-id`). A **control** entry's `id` is ignored — controls get
539
+ positional ids — and lint reports it as an unknown field. Leave `id` off unless you
540
+ need a stable handle.
541
+
542
+ ### Control types
543
+
544
+ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` and
545
+ `whenFalse`. Beyond those:
546
+
547
+ | `type` | Renders as | Extra fields |
548
+ |---|---|---|
549
+ | `"slider"` (default) | a range track plus an editable number box | `unit`, `min`, `max`, `step`, and the refinements below |
550
+ | `"number"` | the number box alone — for precise or very wide ranges | `unit`, `min`, `max`, `step`, `recommended` (see below) |
551
+ | `"text"` | a single-line string field | — |
552
+ | `"textarea"` | a multiline string field; line breaks are preserved | — |
553
+ | `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
554
+ | `"select"` | a dropdown | `options` |
555
+ | `"radio"` | a segmented button row | `options` |
556
+
557
+ Numeric controls always show the number box: drag the slider *or* type an exact
558
+ value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
559
+ Text fields write `params` on every keystroke, so the rebuild loop previews the new
560
+ string immediately; give every text key a string default (empty strings are valid,
561
+ and the build decides whether its geometry tolerates one).
562
+
563
+ **`options`** (select/radio) takes either the shorthand `["round", "faceted"]` —
564
+ each entry is both value and label — or the long form
565
+ `[{ value, label, description? }]`. Values may be strings or numbers, and
566
+ `defaults[key]` **must be one of them** (`select-default-not-in-options`; watch
567
+ types, `12` is not `"12"`). An option's `description` surfaces as a hover tooltip
568
+ on that one option, not as a ⓘ popover.
569
+
570
+ **`"readout"` is not a control.** It has no `key`, never writes `params`, and can
571
+ never be a preset target. It displays one output of `derive()`, named by
572
+ `derivedKey`, refreshed on every parameter change; `unit` is appended to numeric
573
+ values. A `derivedKey` no `derive` group returns shows an em-dash forever, which
574
+ `readout-unknown-derived-key` warns about. Readouts are how a panel closes the loop
575
+ on design intent — show the user the clearance, the inner diameter, the resulting
576
+ wall — without adding a parameter nobody should edit.
577
+
578
+ **A `group`** takes `type`, `id`, `title`, `collapsed`, `bare`, `controls`,
579
+ `hidden`, `when` and `whenFalse`. It deliberately takes **no `description`**: the
580
+ fold's title is itself a button, and there is nowhere to hang an ⓘ glyph beside it.
581
+ Put the explanation on the section or on the controls inside. `bare: true` drops the
582
+ title and the disclosure entirely, leaving an indented block — useful for a run of
583
+ controls that appear and disappear together under one `when`.
584
+
585
+ ### Control metadata
505
586
 
506
- A feature's `on` is **required and must be greater than 0** it is the real value the
507
- parameter takes when the box is ticked (a diameter, a count), and the panel reads
508
- `> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
509
- reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
510
- the exception: its `on` is just a flag and defaults to 1.
587
+ - `description` a CommonMark string shown in a click-open **ⓘ** popover beside the
588
+ label. Supports **bold/italic**, lists, `code`, links and images (for diagrams);
589
+ links open in a new tab and the rendered HTML is sanitized. Write one for every
590
+ control see "A description for every control" below.
591
+ - `hidden: true` omits the node from the panel. Its `key` must still exist in
592
+ `defaults` and still drives the geometry: this is *no UI*, not *no parameter*. Use
593
+ it for internal constants the end user shouldn't edit. A group left with no visible
594
+ children doesn't render at all, and neither does an empty section.
595
+
596
+ ### Slider refinements
597
+
598
+ Three optional fields shape how a numeric track behaves. All three are worth reaching
599
+ for when a raw linear slider misrepresents the parameter.
600
+
601
+ - **`scale: "log"`** — the thumb travels geometrically, so a 0.1–100 range gives each
602
+ decade equal width instead of burying everything below 10 in the first pixel. The
603
+ number box stays linear and exact, so typing `0.5` still works. `min` **must be
604
+ greater than 0** (`log(0)` is `-Infinity` and the mapping breaks); lint reports
605
+ `log-scale-needs-positive-min`.
606
+ - **`ticks: [...]`** — marked values on the track (a native `datalist`). Every tick
607
+ must sit inside `[min, max]`. Add **`snap: true`** to quantize *slider drags* to the
608
+ nearest tick; the number box stays free, so an off-tick value is always still
609
+ typeable. Use it for stock sizes: M3/M4/M5, 3 mm / 6 mm plate.
610
+ - **`recommended: [lo, hi]`** — tints that span of the track and puts a warning border
611
+ on the number box when the value sits outside it. This is the **visual companion to
612
+ the DFM checks**: the band is where the process the part targets is comfortable
613
+ (minimum wall, nozzle multiples, sane clearances), and `verify`'s `minWall` /
614
+ process checks are the same judgement enforced at measure time. It is advisory —
615
+ outside values remain selectable, because a user who knows their printer should not
616
+ be blocked by a default profile.
617
+
618
+ `ticks`, `snap` and `recommended` render on a **linear track only**; combined with
619
+ `scale: "log"` they are ignored, and `slider-refinement-invalid` warns. On a
620
+ `"number"` control there is no track at all: `recommended` still tints the box on an
621
+ out-of-band value, while `scale`, `ticks` and `snap` do nothing.
622
+
623
+ ### Conditions: `when` and `whenFalse`
624
+
625
+ `when` is valid on **any** node — a control, a group, a preset, a readout, or a
626
+ section itself. It is a plain data condition evaluated against raw parameters:
627
+
628
+ ```js
629
+ when: { profile: "faceted" } // equality
630
+ when: { wall: { gte: 1.2 } } // gt | gte | lt | lte | ne
631
+ when: { style: { in: ["cup", "vase"] } } // membership
632
+ when: { drain: { gt: 0 }, mode: "planter" } // multiple keys are ANDed
633
+ when: { allOf: [{ drain: { gt: 0 } }, { mode: "planter" }] }
634
+ when: { anyOf: [{ style: "cup" }, { style: "vase" }] }
635
+ when: { not: { style: "plain" } }
636
+ ```
637
+
638
+ The operators are `gt`, `gte`, `lt`, `lte`, `ne` and `in`; the combinators are
639
+ `allOf`, `anyOf` and `not`. Two rules make conditions statically checkable, and both
640
+ are enforced as **errors** because either failure is silent at runtime:
641
+
642
+ - **Raw parameter keys only.** A `when` reads keys from `defaults`, never derived
643
+ values — that is what lets lint check every referenced key against `defaults`
644
+ (`when-key-not-in-defaults`), which no predicate function could support. Readouts
645
+ reach derived values through their own `derivedKey`, so there is never any doubt
646
+ which namespace a name is in.
647
+ - **Known operators only.** `evalWhen` treats an unrecognised operator as false, so a
648
+ typo would hide the node forever; `when-unknown-operator` catches it first.
649
+
650
+ A malformed condition evaluates to `false` rather than throwing — a control that
651
+ hides is better than a panel that crashes.
652
+
653
+ When the condition is false the node is **removed from the layout**, taking its
654
+ subtree with it if it is a group. Set **`whenFalse: "disable"`** to grey it in place
655
+ instead, for the case where the user should see that an option exists but needs
656
+ something else switched on first. Disabling propagates through the whole subtree and
657
+ sets real `disabled` attributes, so a disabled control cannot be focused or dragged.
658
+
659
+ **`when` is not relevance dimming.** The panel also dims controls automatically, and
660
+ the two are different mechanisms that must stay visually distinct:
661
+
662
+ | | Relevance dimming | `when` |
663
+ |---|---|---|
664
+ | Answers | "does the geometry on screen actually read this parameter?" | "did the author say this applies right now?" |
665
+ | Comes from | probing the build — automatic, nothing to write | your `when` condition |
666
+ | Looks like | faded but fully usable, with a "doesn't affect the parts in the current view" tooltip | gone from the layout, or genuinely disabled |
667
+
668
+ A control can be relevant but conditioned away, or conditioned in but irrelevant.
669
+ Both recompute on the same tick as any parameter change. Don't reach for `when` to
670
+ reproduce dimming — you'd be hand-maintaining something the framework already knows.
671
+
672
+ ### Collapsing
673
+
674
+ Every section and every titled group is a disclosure, controlled by `collapsed`:
675
+ `true` (start closed), `false` (start open), or `"auto"` (the default). `"auto"`
676
+ defers to one rule:
677
+
678
+ > A panel with **three or fewer visible top-level sections** opens every `"auto"`
679
+ > section and fold on load. Beyond that, they all start closed.
511
680
 
512
- **Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
513
- `toggles` array to a preset section shown below the preset picker, outside the
514
- Advanced fold, so it stays visible:
681
+ The rail is a fixed-height column, and a long part otherwise scrolls forever; three
682
+ sections is a panel the user can take in at a glance. The count is over the sections
683
+ in the built tree `hidden: true` sections and sections left with nothing in them are
684
+ gone before counting, but a section that a false `when` or relevance merely hides from
685
+ view still counts, since it can come back without rebuilding the panel.
686
+ Only the **first** render applies it — after that the user's own clicks own the folds,
687
+ and a slider drag never snaps a section they opened back shut. A `bare: true` group
688
+ has no disclosure at all and is never collapsed.
689
+
690
+ ### Presets
691
+
692
+ A preset picker is a node like any other:
693
+
694
+ ```js
695
+ { type: "preset", label: "Size", presets: {
696
+ M3: { od: 8, bore: 3.4, h: 10 },
697
+ M5: { od: 12, bore: 5.4, h: 16 },
698
+ } }
699
+ ```
700
+
701
+ Each key of `presets` is a name, each value a bundle of parameter overrides (every
702
+ key of which must exist in `defaults` — `preset-key-not-in-defaults`). The picker
703
+ lists the names plus **Custom**, and opens on the first name. Choosing a preset
704
+ assigns its overrides over `params` and refreshes that section's controls; editing
705
+ any control in the section afterwards drops the picker to **Custom**.
706
+
707
+ Because it is a node, a picker can sit **anywhere** in `controls` — among the
708
+ controls it affects, not necessarily at the top — and a section may carry more than
709
+ one. Note that Custom-marking tracks the section's **first** picker only, so if two
710
+ pickers in one section both need to show divergence, give each its own section.
711
+
712
+ **Preset names are global to the part**, not to the section: `verify()` expands one
713
+ case per preset name (so every preset gets measured), and a repeated name throws
714
+ there. `duplicate-preset-name` catches it at lint time instead.
715
+
716
+ ### Legacy section shapes (still supported)
717
+
718
+ Everything above is what a **new part should write**. The original array-based shapes
719
+ predate the node model, still work exactly as they always did, and are not going
720
+ away — most of the in-repo parts are deliberately left on them as live proof.
721
+ `desugar()` normalizes them into the very same nodes, so the **runtime** is uniform:
722
+ one renderer, one state pass, one set of lint walkers, whichever shape you wrote.
723
+
724
+ The **authorable surface is not** uniform, and deliberately so — the legacy
725
+ descriptors are frozen at the fields they always had. `when`, `whenFalse` and
726
+ `collapsed`, and the `"checkbox"`, `"select"`, `"radio"` and `"readout"` types, exist
727
+ in the `controls` shape **only**. Written on a legacy descriptor they are dropped and
728
+ reported as `unknown-control-field`; a legacy section's `collapsed` is ignored
729
+ silently. Reach for any of them and you are writing a `controls` section.
730
+
731
+ | Legacy | Normalizes to |
732
+ |---|---|
733
+ | `presets: {...}` | a `{ type: "preset" }` node, first child of the section |
734
+ | `toggles: [{ key, label, on }]` | `"checkbox"` controls placed directly in the section, after the picker and before the Advanced fold |
735
+ | `advanced: [...]` | a nested group titled **Advanced**, `collapsed: "auto"` |
736
+ | `features: [{ key, on, sliders }]` | per feature: a `"checkbox"`, followed by a `bare` group of its sliders carrying `when: { [key]: { gt: 0 } }` — both inside the Advanced group |
737
+ | `control: "number"` | `type: "number"` |
738
+ | `hidden: true` | kept through desugaring (lint needs it), dropped when the render tree is built |
739
+
740
+ **Preset + controls section** — a picker, standalone toggles, and an Advanced fold:
515
741
 
516
742
  ```js
517
743
  {
518
- id: "shape",
519
- title: "Shape ops",
744
+ id: "body",
745
+ title: "Body",
746
+ presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
520
747
  toggles: [
521
748
  { key: "clip", label: "Clip arms to a disc (intersect)", on: 1,
522
- description: "**Intersect** the cross with a circle so the four arm tips are rounded off to a common radius." },
749
+ description: "**Intersect** the cross with a circle so the arm tips round off." },
750
+ ],
751
+ advanced: [ // controls revealed under "Advanced"
752
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
753
+ { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
754
+ { key: "title", label: "Title", control: "text" },
523
755
  ],
524
756
  }
525
757
  ```
526
758
 
527
- Each entry is `{ key, label, on?, hidden?, description? }`: checked sets `key` to `on`
528
- (default `1`); unchecked sets it to `0`. This is the correct home for a bare boolean —
529
- a `features` entry *requires* a `sliders` array (the panel reads `feat.sliders.filter(...)`
530
- unguarded and throws if it's missing), so a feature with nothing to reveal belongs in
531
- `toggles` instead. `src/parts/bracket.js`'s `clip` toggle (shown above) is the worked
532
- example.
759
+ `control` is the legacy spelling of `type` and takes `"slider"` (the default),
760
+ `"number"`, `"text"` or `"textarea"` the newer `"checkbox"`, `"select"`, `"radio"`
761
+ and `"readout"` types exist only in the `controls` shape. A `toggles` entry is
762
+ `{ key, label, on?, hidden?, description? }`: checked writes `on` (default `1`),
763
+ unchecked writes `0`. It is the right home for a bare boolean in this shape —
764
+ `src/parts/hull-sweep.js`'s `wrap` toggle is the in-repo example.
533
765
 
534
- **Control metadata (optional on any control def, feature, or section):**
535
-
536
- - `description` — a CommonMark string shown in a click-open **ⓘ** popover beside the
537
- label. Supports **bold/italic**, lists, `code`, links, and images (for diagrams);
538
- links open in a new tab and the rendered HTML is sanitized. Write one for every
539
- control — see "A description for every control" below.
540
- - `hidden: true` — omits the control/feature/section from the panel. Its `key` must still
541
- exist in `defaults` and still drives the geometry: use it for internal constants the
542
- end user shouldn't edit (it is *no UI*, not *no parameter*). A section left with no
543
- presets and no visible controls doesn't render at all.
766
+ **Feature-toggle section**a checkbox that enables a feature *and* reveals its own
767
+ sliders (`0` = off):
544
768
 
545
769
  ```js
546
- advanced: [
547
- { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
548
- description: "Barrel OD. Keep it larger than the bore so a wall remains. See the [guide](https://example.com)." },
549
- { key: "wall_seg", min: 8, max: 256, step: 1, hidden: true, // internal constant; no UI, still in defaults
550
- description: "Facet count fixed by the design." },
551
- ],
770
+ {
771
+ id: "flange",
772
+ title: "Flange",
773
+ features: [
774
+ { label: "Base flange", key: "flange_d", on: 16,
775
+ sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
776
+ ],
777
+ }
552
778
  ```
553
779
 
780
+ A feature's `on` is **required and must be greater than 0** — it is the real value the
781
+ parameter takes when the box is ticked (a diameter, a count), and the panel reads
782
+ `> 0` as "enabled", so there is nothing sensible to fall back to
783
+ (`features-requires-on`). `sliders` is required too (`features-requires-sliders`) — it
784
+ is what the checkbox reveals, and a feature with nothing to reveal belongs in
785
+ `toggles` instead. A section carrying `features` renders *only* its features — its
786
+ `presets`, `toggles` and `advanced` are ignored. `src/parts/demo.js`'s `flange` is the
787
+ in-repo example (`planter.js` has a second one).
788
+
789
+ Three behaviours differ between the shapes, and they are frozen that way on purpose:
790
+
791
+ - A legacy **feature** checkbox restores the magnitude the user had dialled in when
792
+ re-ticked; an authored `"checkbox"` always writes `on`. The node-model way to get a
793
+ feature is a checkbox plus a group gated on `when: { key: { gt: 0 } }` — which is
794
+ exactly what `features` desugars to.
795
+ - **Every** control in a `controls` section marks the section's picker Custom when
796
+ edited. In the legacy shapes, feature sliders and toggles do not.
797
+ - Collapse state is not authorable here, per the surface note above: a legacy section
798
+ and the "Advanced" fold it desugars to are both always `"auto"`, so they follow the
799
+ three-section rule and nothing else.
800
+
801
+ **A section is one shape or the other.** Mixing `controls` with `advanced`,
802
+ `toggles`, `features` or `presets` is the error `mixed-section-shape` — the render
803
+ order of the mixture would be arbitrary. (`controls` wins if you do it anyway.) A
804
+ single *part* may mix freely, one shape per section, so migration can go section by
805
+ section.
806
+
554
807
  ---
555
808
 
556
809
  ## Designing the control panel
@@ -605,12 +858,65 @@ coherently:
605
858
  Tier the controls so the default view is uncluttered:
606
859
 
607
860
  1. **Presets** for the common cases — the first thing most users pick.
608
- 2. A **few primary sliders** for the dimensions users change most.
609
- 3. **`Advanced`** (the collapsible block) for the rest.
610
- 4. **`hidden`** for internal constants the end user shouldn't edit.
611
-
612
- Aim for a panel with a few visible controls that still exposes the full design when
613
- someone opens Advanced.
861
+ 2. A **few primary controls** for the dimensions users change most, sitting loose in
862
+ the section.
863
+ 3. **A nested group** (`{ type: "group", title: "...", collapsed: "auto" }`) for the
864
+ rest — one per idea, titled for what it is (`Wall`, `Lid`, `Mounting`), not
865
+ "Advanced". Two levels is the ceiling.
866
+ 4. **`hidden: true`** for internal constants the end user shouldn't edit.
867
+
868
+ Keep a section to **12 visible controls or fewer** — past that `section-too-many-controls`
869
+ warns, because more than a dozen in one column reads as a wall rather than a set of
870
+ choices. If a section is over budget, the fix is almost always that two ideas are
871
+ sharing it: split the section, or hide internals (`hidden: true`) — grouping
872
+ organizes but does not reduce the count.
873
+
874
+ Aim for a panel whose first screen is a handful of controls, and whose full design is
875
+ one click away in a fold.
876
+
877
+ ### Choosing a control
878
+
879
+ The type carries meaning, so pick the one that matches the parameter rather than
880
+ defaulting everything to a slider:
881
+
882
+ - **A continuous dimension** → `"slider"`. Add `recommended` when there's a
883
+ manufacturable band, `ticks` + `snap` when real-world stock sizes exist, and
884
+ `scale: "log"` when the range spans decades.
885
+ - **A precise or very wide number** (a count, a tolerance, a coordinate) →
886
+ `"number"`, so the user types rather than hunts.
887
+ - **A discrete choice** → `"select"` when the values are a list, or `"radio"` when
888
+ there are **2–4** of them and seeing all the options at once is part of the
889
+ decision. Never fake either one with a slider over magic integers.
890
+ - **A boolean** → `"checkbox"`. Ticked writes `on`, cleared writes `0`; there is no
891
+ reason for a two-position slider to exist.
892
+ - **A computed value the user should see but not set** → `"readout"`. It costs no
893
+ parameter and answers the "so what did that do?" question in place.
894
+
895
+ Then gate what doesn't always apply. A control that is meaningless in the current mode
896
+ should carry a **`when`** rather than sit there inert — hide it by default, or use
897
+ `whenFalse: "disable"` when its existence is itself the information ("Lid hinge:
898
+ enable a lid first"). Conditions are also the cheapest way to keep a section under
899
+ budget: three mode-specific controls that are never all relevant at once cost the
900
+ reader one.
901
+
902
+ Finally, **every control gets a `description`** — see below.
903
+
904
+ ### Ordering and naming
905
+
906
+ Authored order is render order, so spend it deliberately: put the control a user
907
+ reaches for first at the **top** — usually the primary dimension the presets don't
908
+ settle — and order the rest by how a user thinks about the part, not by the order
909
+ the build consumes them. A user scans the rail top-to-bottom once; the control they
910
+ need should sit where that scan expects it, with fine-tuning below it and
911
+ housekeeping last.
912
+
913
+ Labels are for reading, not for the build: a short noun phrase (**"Wall
914
+ thickness"**, **"Bolt hole ø"**), with units in `unit:` rather than in the label
915
+ text, and never a parameter key or build-internal jargon — `flange_d` is a key,
916
+ "Flange diameter" is a label. Every label must make sense on its own with its
917
+ neighbours folded away; if a label only reads correctly next to another control
918
+ ("Diameter" … "Diameter" in two groups), rename until each stands alone or regroup
919
+ until they are one idea.
614
920
 
615
921
  ### A description for every control
616
922
 
@@ -638,6 +944,9 @@ parameters change. You don't wire this up; it's automatic. To get the most from
638
944
  on-screen part shows dimmed, which is a useful signal that it's vestigial or
639
945
  misplaced.
640
946
 
947
+ This is a separate mechanism from `when`, and stays visually distinct from it on
948
+ purpose — see "Conditions: `when` and `whenFalse`" above for the split.
949
+
641
950
  ---
642
951
 
643
952
  ## Profiles & patterns
@@ -1130,9 +1439,80 @@ previously didn't; that's the fix working as intended, not a regression.
1130
1439
  `default-view-ambiguous` (warnings).
1131
1440
 
1132
1441
  **Parameter schema** — `features-requires-sliders`, `features-requires-on`,
1133
- `control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
1442
+ `control-key-not-in-defaults`, `preset-key-not-in-defaults`, `mixed-section-shape`,
1443
+ `duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
1444
+ `select-default-not-in-options`, `log-scale-needs-positive-min`,
1445
+ `when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type` (errors);
1134
1446
  `slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
1135
- `default-not-exposed` (warnings).
1447
+ `default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
1448
+ `group-depth`, `section-too-many-controls` (warnings).
1449
+
1450
+ `mixed-section-shape` fires when a section mixes the new `controls` array with
1451
+ a legacy field (`advanced`, `toggles`, `features`, `presets`) — the two shapes
1452
+ can't coexist, since mixing them would make the render order arbitrary. Move
1453
+ the legacy entries into `controls` (a toggle becomes a checkbox control,
1454
+ `advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
1455
+ nodes), or drop `controls` and stay legacy.
1456
+ `duplicate-preset-name` fires when the same preset name is declared twice
1457
+ (legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
1458
+ are global to the part, and `verify()` expands one case per name and throws on
1459
+ a repeat, a worse place to find out. Rename one of them.
1460
+ `duplicate-node-id` fires when two panel nodes (sections, groups, or controls)
1461
+ share an `id` — the renderer keys its element and state maps on ids, and a
1462
+ collision silently cross-wires the two nodes. Rename one `id`, or drop it to
1463
+ use the positional default.
1464
+ `select-options-missing` fires when a `select`/`radio` control has no
1465
+ `options` array — with none the control renders empty and its parameter can
1466
+ never change.
1467
+ `select-default-not-in-options` fires when `defaults[key]` is not one of a
1468
+ `select`/`radio`'s option values (watch value types — `12` is not `"12"`) —
1469
+ without this the panel opens showing a value the user can never get back to.
1470
+ Add the value to `options`, or change the default.
1471
+ `log-scale-needs-positive-min` fires when a slider/number sets `scale: "log"`
1472
+ without a positive `min` — `log(0)` is `-Infinity` and the thumb-to-value mapping
1473
+ breaks, so raise `min` above 0 or drop `scale`.
1474
+ `when-key-not-in-defaults` and `when-unknown-operator` walk every authored
1475
+ `when` (on a control, a group, a preset, a readout, or a section itself) —
1476
+ `allOf`/`anyOf`/`not` recurse — and check each condition against the two things
1477
+ that make it real: the param key must be one `defaults` actually declares, and
1478
+ each comparison operator (`{ gt: 0 }`, `{ in: [...] }`, …) must be one
1479
+ `evalWhen` recognises. Both are silent failure modes — an unknown key reads
1480
+ `undefined` and an unknown operator is treated as false, so either way the
1481
+ condition is always false and the node never shows — which is why both are
1482
+ errors rather than warnings.
1483
+
1484
+ `unknown-control-type` fires when an authored control's `type` (e.g. a typo
1485
+ like `"sldier"`) isn't one of the recognised widget types — the renderer skips
1486
+ a node with an unrecognised type entirely, so the control silently vanishes
1487
+ from the panel with no other sign anything is wrong. An unrecognised type's
1488
+ field list falls back to the common set (`key`, `type`, `label`, `description`,
1489
+ `hidden`, `when`, `whenFalse`) rather than an empty one, so this error carries
1490
+ the diagnosis instead of every field on the control — even ordinary ones like
1491
+ `label` — separately warning as `unknown-control-field`. This only applies to
1492
+ the authored `controls` shape — a legacy descriptor's `control:` value was
1493
+ never validated and still isn't.
1494
+
1495
+ `readout-unknown-derived-key` checks a `{ type: "readout" }` entry's `derivedKey`
1496
+ against the keys `derive()` actually produces (resolved once against `defaults`)
1497
+ — a readout naming a key no group returns shows an em-dash forever, so it warns
1498
+ rather than errors.
1499
+ `slider-refinement-invalid` covers a slider/number's optional `ticks` (native
1500
+ datalist marks; combine with `snap: true` to quantize slider drags to the
1501
+ nearest tick) and `recommended` (an `[lo, hi]` band tinted on the track, with
1502
+ the value box warning outside it): a tick outside `[min, max]`, a `recommended`
1503
+ that isn't exactly `[lo, hi]` with `lo < hi`, or either of them combined with
1504
+ `scale: "log"` (ticks and the band render on a linear track only) all warn.
1505
+ `group-depth` warns when authored groups nest more than two levels deep — a
1506
+ section plus one inner fold is as deep as a 300px rail can stay readable.
1507
+ Flatten by promoting the innermost group to its own section, or folding its
1508
+ controls into the parent.
1509
+ `section-too-many-controls` warns when a section (authored or legacy, desugared
1510
+ to a common format) shows more than 12 visible controls — the budget is
1511
+ deliberately conservative, revisable against real LLM-authored parts. More than a
1512
+ dozen in one section reads as a wall; split into multiple sections, or hide
1513
+ internals (`hidden: true`). Grouping controls organizes them but does not
1514
+ reduce the count — the check recurses into groups — so a group alone doesn't
1515
+ bring a section back under budget.
1136
1516
 
1137
1517
  **Kernel API**, found by executing `build()` against a geometry-free probe —
1138
1518
  `unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
@@ -337,6 +337,30 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
337
337
  - **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
338
338
  option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
339
339
 
340
+ ## duplicate-preset-name-throws
341
+
342
+ - **Symptom:** `duplicate preset name across sections:` thrown from verify/measure, naming the repeated preset (e.g. `duplicate preset name across sections: "Compact"`).
343
+ - **Cause:** The same preset name is declared twice — once via the legacy `presets` field, once as a `{ type: "preset" }` node, or twice within either.
344
+ - **Fix:** Rename one of them; `npx partforge lint` reports it statically as `duplicate-preset-name` before verify ever runs. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
345
+
346
+ ## when-condition-never-true
347
+
348
+ - **Symptom:** A control, group, preset, readout, or section with a `when` condition never appears, with no error anywhere.
349
+ - **Cause:** The condition references a key `defaults` doesn't declare (reads `undefined`, which every comparison treats as false) or a typo'd operator (`evalWhen` treats an unrecognized operator as false too).
350
+ - **Fix:** Run `npx partforge lint` — `when-key-not-in-defaults` or `when-unknown-operator` names the offending key or operator. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
351
+
352
+ ## readout-shows-em-dash
353
+
354
+ - **Symptom:** A `{ type: "readout" }` control renders "—" forever, no matter what the other controls are set to.
355
+ - **Cause:** The readout's `derivedKey` names a key that no `derive()` group actually produces.
356
+ - **Fix:** Name a key a `derive` group returns, or add that key to `derive`; `npx partforge lint` warns via `readout-unknown-derived-key`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
357
+
358
+ ## select-default-unreachable
359
+
360
+ - **Symptom:** The panel opens showing a `select`/`radio` value the control can never be set back to by interacting with it.
361
+ - **Cause:** `defaults[key]` is not among the control's `options` values — often a value-type mismatch (`12` is not `"12"`).
362
+ - **Fix:** Add the default to `options`, or change the default to one of the existing options; `npx partforge lint` errors via `select-default-not-in-options`. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Rule catalog".
363
+
340
364
  # Hardware library
341
365
 
342
366
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.47.0",
3
+ "version": "0.48.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",