@pixel-point/toolcraft 0.0.8 → 0.0.9

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/generate.mjs +34 -5
  3. package/src/generate.test.mjs +12 -0
  4. package/src/package-json.mjs +15 -0
  5. package/src/package-json.test.mjs +14 -1
  6. package/templates/runtime/contracts/component-contracts.test.ts +104 -14
  7. package/templates/runtime/contracts/component-contracts.ts +54 -22
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  10. package/templates/runtime/react/controls-panel.test.tsx +374 -13
  11. package/templates/runtime/react/controls-panel.tsx +65 -4
  12. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  13. package/templates/runtime/schema/define-toolcraft.ts +25 -1
  14. package/templates/runtime/schema/types.ts +3 -0
  15. package/templates/runtime/testing/performance.test.ts +134 -0
  16. package/templates/runtime/testing/performance.ts +34 -4
  17. package/templates/starter/AGENTS.md +4 -4
  18. package/templates/starter/docs/toolcraft/README.md +1 -1
  19. package/templates/starter/docs/toolcraft/acceptance-testing.md +5 -3
  20. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -5
  21. package/templates/starter/docs/toolcraft/component-rules.md +32 -11
  22. package/templates/starter/docs/toolcraft/performance.md +7 -1
  23. package/templates/starter/docs/toolcraft/schema-reference.md +43 -14
  24. package/templates/starter/gitignore +1 -0
  25. package/templates/starter/package.json +2 -0
  26. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  27. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  28. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  29. package/templates/starter/src/app/starter-acceptance.test.ts +739 -66
  30. package/templates/starter/src/app/starter-acceptance.ts +471 -12
  31. package/templates/ui/components/control-layout/index.tsx +8 -3
  32. package/templates/ui/components/controls/actions/actions-control.tsx +11 -3
  33. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  34. package/templates/ui/components/controls/color/index.ts +4 -1
  35. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  36. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  37. package/templates/ui/components/controls/index.ts +2 -0
  38. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  39. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  40. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  41. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  42. package/templates/ui/components/controls/vector/index.ts +1 -0
  43. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  44. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -49,6 +49,8 @@ getToolcraftPerformanceStressValue(appPerformance, "scenario-id")
49
49
 
50
50
  For workload sliders, use `dragToolcraftSliderToPerformanceStressValue(page, label, appPerformance, "scenario-id")` so the test applies the exact numeric value through the real slider min/max range. Do not divide a stress value by the slider max, type a separate short value, or hardcode a ratio in the Playwright test. If a test uses a toy value while `app-performance.ts` claims a heavy fixture, `pnpm verify:perf` must fail.
51
51
 
52
+ Slider and range slider performance scenarios must preserve live product feedback. The browser test should drag the real thumb and verify the canvas/product output changes during drag. If that drag misses budget, keep the live control semantics and optimize the renderer path first: cache expensive inputs, update uniforms or stable buffers, coalesce preview work to `requestAnimationFrame`, cancel stale async renders, move work off React renders, reuse GPU resources, or change renderer strategy. Do not pass performance by making the slider update only after pointer release or Apply.
53
+
52
54
  When a scenario declares `workloadFixture`, apply it first with `getToolcraftPerformanceWorkloadValue` or `applyToolcraftPerformanceWorkloadFixture`, then apply `stressFixture`, then measure. A control-drag scenario that only sets its own slider value while leaving the source media, text, item count, render scale, or dense scene at defaults is invalid.
53
55
 
54
56
  For combined worst cases, put every relevant independent baseline value in `workloadFixture.value` for control scenarios, such as `{ sourceMedia: { width: 3840, height: 2160 }, renderScale: 2 }`, and put the tested control value in `stressFixture.value`. For preview, zoom, drag, animation, or export scenarios that stress the entire state instead of one control, use `stressFixture.value`, such as `{ detail: 96, scale: 0.6, renderScale: 2 }`. Testing workload controls one-by-one is not enough when the product exposes combinations that multiply render cost.
@@ -91,11 +93,13 @@ Custom renderers should:
91
93
  - initialize contexts, programs, shaders, pipelines, textures, and large buffers once;
92
94
  - update uniforms or stable buffers when controls change;
93
95
  - cache decoded media;
94
- - debounce, coalesce, or defer heavy preview work;
96
+ - coalesce high-frequency preview work and split lightweight live feedback from heavier refinement when needed;
95
97
  - cancel stale async renders;
96
98
  - avoid re-decoding media on every control change;
97
99
  - cancel scheduled frames during cleanup.
98
100
 
101
+ Coalescing may reduce redundant renders during high-frequency slider drags, but it must not make the slider feel deferred or leave the canvas unchanged until release.
102
+
99
103
  Custom renderers must declare `rendererPipeline` in `src/app/app-performance.ts`. This is the machine-checkable Render Pipeline Inventory:
100
104
 
101
105
  - every render pass has an `id`, `kind`, `runsOn`, `output`, `quality`, `inputs`, and `invalidatedBy`;
@@ -131,6 +135,8 @@ Use real interactions for:
131
135
  - `viewport-zoom-stress` for detail-heavy or animated custom renderers;
132
136
  - `viewport-stability`.
133
137
 
138
+ For `slider` and `rangeSlider`, the required performance scenario is `control-drag`. A `control-change` scenario can cover selects, inputs, toggles, and other non-drag controls, but it does not prove live canvas feedback or drag smoothness for sliders.
139
+
134
140
  Animated custom renderers also need `animation-viewport-drag`. Animation-only frame sampling and viewport-only stability are not enough: the browser test must sample frames while physically dragging or panning the canvas viewport. If SVG/DOM cannot pass that combined budget, choose a different renderer strategy from evidence instead of loosening the budget.
135
141
 
136
142
  Detail-heavy or animated custom renderers also need `viewport-zoom-stress`. This test must apply the combined worst-case stress fixture first, then use the real toolbar zoom controls while sampling frame gaps and long tasks. Do not satisfy it by calling `canvas.zoom`, mutating runtime state directly, checking only the final zoom value, or zooming a default/lightweight output.
@@ -129,18 +129,18 @@ Use `MediaRecorder.isTypeSupported(...)` or an explicit encoder/transcoder capab
129
129
  Choose sizing from product context:
130
130
 
131
131
  - `intrinsic-media`: a single uploaded or generated source defines `canvas.size`.
132
- - `editable-output`: exportable output where users should edit width and height.
133
- - `fixed-output`: product-defined output size that users must not edit.
132
+ - `editable-output`: product/export output where users always see aspect ratio, width, and height.
133
+ - `fixed-output`: non-product/internal output size that users must not edit.
134
134
 
135
- For product output, export, copy, download, shader rendering, procedural rendering, or no single intrinsic source image, use `editable-output` unless the product explicitly needs `fixed-output`.
135
+ For product output, export, copy, download, shader rendering, procedural rendering, reference clones, or no single intrinsic source image, use `editable-output`.
136
136
 
137
- A prompt-provided base/default size is only the initial `canvas.size`. It must not remove the runtime Aspect ratio, Canvas width, and Canvas height controls. Aspect presets use canonical output sizes (`16:9` is `1920x1080`; the other presets are derived around a 1080px short edge or matching portrait long edge). When no explicit product size is provided, runtime defaults to `16:9` / `1920x1080`; choose another preset only when the product meaning calls for it. Use `fixed-output` only when the reference or product explicitly locks dimensions, and add runtime acceptance with `canvasSizingCoverage: "fixed-output-size"`.
137
+ A prompt-provided, reference, fixed-format, or base/default size is only the initial `canvas.size`. It must not remove the runtime Aspect ratio, Canvas width, and Canvas height controls. Aspect presets use canonical output sizes (`16:9` is `1920x1080`; the other presets are derived around a 1080px short edge or matching portrait long edge). When no explicit product size is provided, runtime defaults to `16:9` / `1920x1080`; choose another preset only when the product meaning calls for it. Generated product/export apps do not use `fixed-output` to preserve a reference baseline; fixed dimensions stay visible as editable defaults.
138
138
 
139
- Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are mandatory only for `editable-output` sizing. They do not depend on `settingsTransfer`: when settings transfer is off, the runtime prepends a technical `Setup` canvas size section without a visible heading; when settings transfer is on, the controls merge into the first technical `Setup` runtime settings section without a visible heading. Do not hand-build a duplicate size selector.
139
+ Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`, `Canvas width`, and `Canvas height` controls are mandatory through `editable-output` sizing for product/export apps. They do not depend on `settingsTransfer`: when settings transfer is off, the runtime prepends a technical `Setup` canvas size section without a visible heading; when settings transfer is on, the controls merge into the first technical `Setup` runtime settings section without a visible heading. Do not hand-build a duplicate size selector.
140
140
 
141
141
  When the user manually edits `Canvas width` or `Canvas height`, the runtime treats that as an exact custom output size. It keeps the typed dimension, keeps the other dimension unchanged, switches `Aspect ratio` to `Custom`, and shows the reduced current ratio in the custom ratio inputs. Only selecting an aspect preset may resize both dimensions from a canonical preset.
142
142
 
143
- For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` after canvas sizing in the first technical `Setup` section. The slider ranges from `1x` to `2x`, defaults to `2x`, and changes the renderer backing pixels without changing the visible CSS size or product output dimensions. Adding or enabling this slider requires targeted browser evidence that the canvas stays responsive while dragging sliders or other high-frequency controls at the selected scale. Full `pnpm verify:perf` is required only for the first working app version or explicit performance complaints. Performance fixes must preserve the selected scale. Diagnose whether lag comes from renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, stale async renders, or animation scheduling before reducing quality. Do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp `canvas.renderScale` below the user's chosen value. Do not enable it for DOM/SVG/vector-native previews; use native vector rendering for those.
143
+ For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` after canvas sizing in the first technical `Setup` section. The slider ranges from `1` to `2`, defaults to `2`, and changes the renderer backing pixels without changing the visible CSS size or product output dimensions. Adding or enabling this slider requires targeted browser evidence that the canvas stays responsive while dragging sliders or other high-frequency controls at the selected scale. Full `pnpm verify:perf` is required only for the first working app version or explicit performance complaints. Performance fixes must preserve the selected scale. Diagnose whether lag comes from renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, stale async renders, or animation scheduling before reducing quality. Do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp `canvas.renderScale` below the user's chosen value. Do not enable it for DOM/SVG/vector-native previews; use native vector rendering for those.
144
144
 
145
145
  ## Panels
146
146
 
@@ -157,7 +157,7 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
157
157
 
158
158
  | `type` | Renders | Key fields |
159
159
  | --- | --- | --- |
160
- | `actions` | Inline local action buttons for the current section or nearby entity | `actions`, `target`, `label` |
160
+ | `actions` | Local action buttons for the current section or nearby entity, rendered below the label in a two-column grid | `actions`, `target`, `label` |
161
161
  | `anchorGrid` | Anchor picker | `defaultValue`, `target` |
162
162
  | `channelMixer` | RGB-only channel matrix mixer with R/G/B tabs and Red/Green/Blue source sliders | `defaultValue`, `target`, `label` |
163
163
  | `checkbox` | Checkbox field | `defaultValue`, `target`, `label` |
@@ -179,14 +179,20 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
179
179
  | `slider` | Single-value slider | `defaultValue`, `min`, `max`, `step`, `unit`, `variant` |
180
180
  | `switch` | Binary switch | `defaultValue`, `target`, `label` |
181
181
  | `text` | Single-line input | `defaultValue`, `target`, `label`, `commitMode` |
182
- | `vector` | X/Y vector pad and fields | `defaultValue: { x, y }`, `xLabel`, `yLabel`, `variant` |
182
+ | `vector` | X/Y vector pad and fields | `defaultValue: { x, y }`, `xLabel`, `yLabel`, `variant`, `coordinateMode` |
183
183
 
184
- `text` defaults to `commitMode: "content"` and applies while typing for real content such as prompts, names, titles, tokens, and short text. Use `commitMode: "setting"` for text inputs that edit settings such as font size, numeric-like style values, dimensions, ids, or configuration fields; setting text commits on blur or Enter. Canvas width and Canvas height always commit on blur or Enter. `code` / `CodeTextarea` is a content editor, applies while typing, and is capped at 12 visible lines. Long content scrolls inside the textarea instead of making the controls panel taller.
184
+ `text` defaults to `commitMode: "content"` and applies while typing for short real content such as button labels, canvas labels, names, titles, captions, tokens, compact prompts, and other one-line text. Use `commitMode: "setting"` for text inputs that edit settings such as font size, numeric-like style values, dimensions, ids, or configuration fields; setting text commits on blur or Enter. Canvas width and Canvas height always commit on blur or Enter. `code` / `CodeTextarea` is a content editor for long, multiline, or structured values, applies while typing, and is capped at 12 visible lines. Long content scrolls inside the textarea instead of making the controls panel taller. Do not use `code` for short single-line button/canvas text unless `description` explicitly proves the field is intended for long or structured input.
185
185
 
186
- For `slider` and `rangeSlider`, `unit` is a measurement or scale suffix, not the entity being counted. Use units such as `%`, `px`, `°`, `x`, `s`, `ms`, `fps`, `rows`, or `cols` only when they clarify the number. Do not add repeated nouns such as `letters`, `shapes`, `words`, `symbols`, `items`, `particles`, or `layers` when the label or section already names that entity. If the value needs a noun, rename the label or section. Word or acronym units, when truly needed, render with a space (`5 cols`, `17 fps`); compact symbol/CSS units stay tight (`70%`, `24px`).
186
+ For `slider` and `rangeSlider`, `unit` is a real measurement suffix, not the entity being counted and not a generic multiplier. Use units such as `%`, `px`, `°`, `s`, `ms`, `fps`, `rows`, or `cols` only when they clarify the number. Do not use `unit: "x"`; scale, multiplier, intensity, opacity, strength, depth, and shader amount values display plain numbers unless a real measurement unit applies. Do not add repeated nouns such as `letters`, `shapes`, `words`, `symbols`, `items`, `particles`, or `layers` when the label or section already names that entity. If the value needs a noun, rename the label or section. Word or acronym units, when truly needed, render with a space (`5 cols`, `17 fps`); compact symbol/CSS units stay tight (`70%`, `24px`).
187
+
188
+ `slider` and `rangeSlider` are live controls. Dragging must update runtime state and product output while the drag is in progress, not only on pointer release, blur, Apply, or a final commit. Treat a non-live slider as a broken product mapping unless an extreme measured performance ceiling is documented; even then, keep immediate lightweight canvas feedback and refine the heavy output after coalescing/caching.
189
+
190
+ For `vector`, the default/spatial variant uses `coordinateMode: "screen"` by default: dragging left/up lowers `x` and `y`, so canvas objects move left/up without renderer-side Y inversion. Use `coordinateMode: "cartesian"` only for intentional mathematical Y-up coordinates. Color variants keep their color-axis semantics by default.
187
191
 
188
192
  Use `collectionActions` when the product owns a growable/shrinkable item list. `minItems` protects the smallest valid output, `recommendedMaxItems` is only a design recommendation, and `hardMaxItems` is valid only for a real product or technical limit. Adding/removing items must update the runtime array and the renderer/export must consume that same array. Do not pair a count slider with hidden fixed item controls when the user needs to add or remove actual entities. The collection label is on the left and remove/add buttons stay on the right. Homogeneous repeated items do not show visible per-item labels when the collection label already names the group. `itemControl.type` supports normal item built-ins such as `color`, `colorOpacity`, `text`, `select`, `segmented`, `slider`, `switch`, `checkbox`, `rangeInput`, and `fontPicker`; item controls still follow normal density rules, so plain colors use equal 50% columns when they fit. Use `fontPicker` as the item control when each repeated item is a text style or typography entity; do not split its font, weight, size, case, color/opacity, letter spacing, or line height into sibling collection fields.
189
193
 
194
+ Segmented controls are full-width compact choices. Do not place `segmented` in inline half-width rows beside Switch, Color, Select, or another control; use `select` when a finite choice must occupy a half-width column.
195
+
190
196
  ## Control Selection Inventory
191
197
 
192
198
  Before writing schema controls, map product needs to built-ins by value model, not visual similarity.
@@ -221,15 +227,38 @@ If the user asks for product animation and does not explicitly say it is decorat
221
227
 
222
228
  ## Control Section Inventory
223
229
 
224
- Before editing `panels.controls.sections`, write a short inventory in the spec or plan:
230
+ Before editing `panels.controls.sections`, define and export `starterControlSectionInventory` beside `starterAcceptance` in `src/app/starter-acceptance.ts`. This is the machine-checkable version of the section plan.
225
231
 
226
232
  - section title;
227
233
  - product entity or workflow stage;
228
234
  - included schema targets;
229
235
  - reason these controls belong together or reason for a real workflow split.
230
236
 
237
+ ```ts
238
+ export const starterControlSectionInventory = [
239
+ {
240
+ entity: "Text block",
241
+ groupingReason:
242
+ "These controls edit the text content, typography, and visible text fill together.",
243
+ targets: ["text.content", "text.font"],
244
+ title: "Text",
245
+ },
246
+ {
247
+ entity: "Object shape",
248
+ groupingReason: "Structure controls tune the physical footprint of the object.",
249
+ splitReason:
250
+ "Structure and density are separate workflow stages in this editor.",
251
+ targets: ["object.shape.size"],
252
+ title: "Shape Structure",
253
+ workflowStage: "structure",
254
+ },
255
+ ] as const;
256
+ ```
257
+
231
258
  Group controls by product meaning, not by component type. Do not create sections named `Controls`, `Settings`, `Options`, `Sliders`, `Inputs`, `Buttons`, `Color`, or `Colors`.
232
259
 
260
+ The inventory must match the rendered schema: every product control target in every product section appears exactly once, and every inventory target renders in the section named by `title`. Runtime technical `Setup` controls, sticky footer `Export` actions, `settingsTransfer`, and runtime canvas sizing controls do not need inventory entries. If one target entity is split across sections, every split section must declare `workflowStage` and a concrete `splitReason`; otherwise the validator treats the split as accidental section drift.
261
+
233
262
  Every app-authored controls-panel body section must have a short meaningful visible title. Runtime-created setup/settings sections use the technical title `Setup` but render without a visible heading; sticky footer action sections use the technical title `Export` but render without a visible heading.
234
263
 
235
264
  Every visible section title renders through the standard 36px collapsible header row with vertically centered text and the runtime collapse icon. Do not hand-build section headers in generated apps.
@@ -242,7 +271,7 @@ Ordinary section headers expose the runtime section reset action before the coll
242
271
 
243
272
  Ordinary controls-panel body sections use 8px top spacing and 24px bottom spacing for their control content. Runtime technical `Setup` / settings sections use 12px top and bottom spacing to match side padding. Sticky footer action sections keep their dedicated spacing.
244
273
 
245
- Large built-in compound controls inside mixed sections render content-width internal dividers with 18px between each rendered divider and the control content. If a compound control is the first item in that section, render only its bottom internal divider and remove the top internal padding. If a section contains exactly one control, whether simple or compound, only the parent section dividers render. Single `curves` are not compound for dividers; RGB `curves` are compound.
274
+ Large built-in compound controls inside mixed sections render content-width internal dividers with 18px between each rendered divider and the control content. If a compound control is the first item in that section, render only its bottom internal divider and remove the top internal padding. If it is the last item in that section, render only its top internal divider and remove the bottom internal padding. If a section contains exactly one control, whether simple or compound, only the parent section dividers render. Single `curves` are not compound for dividers; RGB `curves` are compound.
246
275
 
247
276
  Controls for the same product entity stay in the same section. For example, `squares.right.connections`, `squares.right.hoverRadius`, and `squares.right.color` belong in `Square 1 (Right)` with `Color` as the field label. A standalone color section is only valid when the color is the whole product entity, such as `Background`, `Accent`, `Connector`, or `Brand`.
248
277
 
@@ -254,7 +283,7 @@ If a target prefix has to be split across sections, the spec must name the workf
254
283
 
255
284
  Switch and checkbox labels name the setting context only. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead. If the nearest section title already names the context, do not duplicate it as the visible toggle label. Use a short contextual label such as `Include` or, only for icon-only visual toggles, `label: false` with the product meaning in `target` and `description`.
256
285
 
257
- Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color labels are semantic, not automatic: decide once for the whole color group, omit per-item labels such as `Color 1` for palette variation banks like `Accent Shades` or `Bead Colors`, and do not mix labeled and unlabeled items inside that bank. Sibling controls like `Spread` or `Randomness` do not force item labels; keep visible labels only when colors edit distinct roles such as `Fill`, `Stroke`, `Background`, `Connector`, or `Object`. Related plain color banks render two per row, and an odd trailing plain color remains half-width instead of stretching to a full row. Mixed inline rows require visible labels on every field except the required Background row and palette variation color banks whose group/section label already names the bank. Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when both visible labels fit without truncation; the runtime auto-pairs safe adjacent toggles by target entity, and schemas should stack them only when either label is too long. A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and both controls edit the same entity; shorten the toggle label when the section title already supplies context. Toggle plus parameter rows are equal-width two-column rows: each control occupies one half, never intrinsic toggle width plus remaining space. The required Background row uses `Include` plus unlabeled background color. Schema `slider` and `rangeSlider` controls always stay stacked at full width; the only built-in exception is the paired letter-spacing and line-height footer sliders inside `fontPicker`. Do not place sibling controls for case, color, opacity, size, weight, letter spacing, or line height when the same text entity already uses `fontPicker`.
286
+ Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color labels are semantic, not automatic: decide once for the whole color group, omit per-item labels such as `Color 1` for palette variation banks like `Accent Shades` or `Bead Colors`, and do not mix labeled and unlabeled items inside that bank. Sibling controls like `Spread` or `Randomness` do not force item labels; keep visible labels only when colors edit distinct roles such as `Fill`, `Stroke`, `Background`, `Connector`, or `Object`. Related plain color banks render two per row, and an odd trailing plain color remains half-width instead of stretching to a full row. Mixed inline rows usually require visible labels on every field, except toggle-plus-parameter rows, the required Background row, and palette variation color banks whose group/section label already names the bank. All 50/50 inline rows use the same horizontal column gap as paired `select` controls; do not create a wider or narrower gap for toggle-plus-parameter rows. Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when both visible labels fit without truncation; the runtime auto-pairs safe adjacent toggles by target entity, and schemas should stack them only when either label is too long. A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and both controls edit the same entity; the non-toggle parameter uses `label: false`, and if that label is needed, stack the controls instead. Toggle plus parameter rows are equal-width two-column rows: each control occupies one half, never intrinsic toggle width plus remaining space. The required Background row uses `Include` plus unlabeled background color. Schema `slider` and `rangeSlider` controls always stay stacked at full width; the only built-in exception is the paired letter-spacing and line-height footer sliders inside `fontPicker`. Do not place sibling controls for case, color, opacity, size, weight, letter spacing, or line height when the same text entity already uses `fontPicker`.
258
287
 
259
288
  `rangeSlider` is always a full-width two-thumb control. Do not include it in `layoutGroups`. Its `defaultValue` must start with different lower and upper values, such as `[20, 80]`, so the two handles do not collapse into one apparent slider. Manual range labels accept built-in separators such as slash, hyphen, spaces, and dashes.
260
289
 
@@ -309,6 +338,6 @@ After adding, removing, or reorganizing controls, sections, timeline, or layers,
309
338
 
310
339
  When settings transfer is enabled and the canvas uses `editable-output` sizing, the first technical `Setup` runtime section is mandatory and contains `Export Settings`, `Import Settings`, `Aspect ratio`, `Canvas width`, `Canvas height`, and optional `Resolution scale` in that order. Do not split these into separate sections or recreate them manually.
311
340
 
312
- A settings-transfer section with only `Export Settings` and `Import Settings` means the canvas is not `editable-output` or the app already declares its own `canvas.size.width` / `canvas.size.height` controls. For product-output apps, treat that as a schema decision to review.
341
+ A settings-transfer section with only `Export Settings` and `Import Settings` means the canvas is not `editable-output` or the app already declares its own `canvas.size.width` / `canvas.size.height` controls. For product-output apps, treat that as a schema error to fix, not as a layout variant.
313
342
 
314
343
  Do not hand-write `settings-transfer.ts`, hidden file inputs, route handlers, or `panelActions` for settings import/export. Sticky footer `panelActions` remain product delivery only.
@@ -19,6 +19,7 @@ playwright-report
19
19
  .env
20
20
  .env.*
21
21
  !.env.example
22
+ .toolcraft
22
23
 
23
24
  # Logs
24
25
  logs
@@ -6,8 +6,10 @@
6
6
  "scripts": {
7
7
  "ai:check": "node scripts/check-ai-skills.mjs",
8
8
  "dev": "node scripts/run-vite-on-free-port.mjs dev",
9
+ "dev:restart": "node scripts/run-vite-on-free-port.mjs dev --toolcraft-restart",
9
10
  "build": "tsc -p tsconfig.json --noEmit && vite build",
10
11
  "preview": "node scripts/run-vite-on-free-port.mjs preview",
12
+ "preview:restart": "node scripts/run-vite-on-free-port.mjs preview --toolcraft-restart",
11
13
  "docs:check": "node scripts/check-toolcraft-docs.mjs",
12
14
  "test": "node scripts/check-toolcraft-docs.mjs && node scripts/check-toolcraft-integrity.mjs && node --test scripts/*.test.mjs && vitest run src --passWithNoTests",
13
15
  "test:browser": "playwright install chromium && playwright test",
@@ -2,21 +2,56 @@
2
2
 
3
3
  import { spawn } from "node:child_process";
4
4
 
5
- import { findAvailablePort, readPreferredPort } from "./toolcraft-port.mjs";
5
+ import {
6
+ findAvailablePort,
7
+ isPortAvailableOnLoopback,
8
+ killListeningProcessesOnPort,
9
+ readPreferredPort,
10
+ readSavedToolcraftPort,
11
+ waitForPortAvailable,
12
+ writeSavedToolcraftPort,
13
+ } from "./toolcraft-port.mjs";
6
14
 
7
15
  const viteCommand = process.argv[2] ?? "dev";
8
- const passthroughArgs = process.argv.slice(3).filter((arg) => arg !== "--");
16
+ const restartArg = "--toolcraft-restart";
17
+ const rawPassthroughArgs = process.argv.slice(3).filter((arg) => arg !== "--");
18
+ const restartSamePort =
19
+ process.env.TOOLCRAFT_RESTART === "1" || rawPassthroughArgs.includes(restartArg);
20
+ const passthroughArgs = rawPassthroughArgs.filter((arg) => arg !== restartArg);
9
21
  const preferredPort = readPreferredPort([
10
22
  "TOOLCRAFT_DEV_PORT",
11
23
  "TOOLCRAFT_PORT",
12
24
  "PORT",
13
25
  ]);
14
- const port = await findAvailablePort(preferredPort);
26
+ const savedPort = restartSamePort ? await readSavedToolcraftPort() : null;
27
+ const port = savedPort ?? (await findAvailablePort(preferredPort));
15
28
 
16
- if (port !== preferredPort) {
29
+ if (savedPort) {
30
+ console.log(`[toolcraft] Restart mode: reusing port ${savedPort}.`);
31
+
32
+ if (!(await isPortAvailableOnLoopback(savedPort))) {
33
+ console.log(`[toolcraft] Port ${savedPort} is busy; stopping the existing listener first.`);
34
+ await killListeningProcessesOnPort(savedPort);
35
+
36
+ if (!(await waitForPortAvailable(savedPort))) {
37
+ console.log(`[toolcraft] Port ${savedPort} is still busy; forcing listener shutdown.`);
38
+ await killListeningProcessesOnPort(savedPort, { signal: "SIGKILL" });
39
+
40
+ if (!(await waitForPortAvailable(savedPort, { timeoutMs: 1_000 }))) {
41
+ throw new Error(`Port ${savedPort} is still busy after forced restart cleanup.`);
42
+ }
43
+ }
44
+ }
45
+ } else if (restartSamePort) {
46
+ console.log("[toolcraft] Restart mode requested, but no saved port exists yet.");
47
+ }
48
+
49
+ if (!savedPort && port !== preferredPort) {
17
50
  console.log(`[toolcraft] Port ${preferredPort} is busy; using ${port} instead.`);
18
51
  }
19
52
 
53
+ await writeSavedToolcraftPort(port);
54
+
20
55
  const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
21
56
  const child = spawn(pnpmCommand, ["exec", "vite", viteCommand, "--port", String(port), ...passthroughArgs], {
22
57
  env: {
@@ -1,7 +1,14 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
1
3
  import net from "node:net";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
2
6
 
3
7
  export const DEFAULT_TOOLCRAFT_PORT = 3002;
4
8
  const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
9
+ const execFileAsync = promisify(execFile);
10
+ const PORT_STATE_DIR = ".toolcraft";
11
+ const PORT_STATE_FILE = "server-port.json";
5
12
 
6
13
  export function readPreferredPort(names, fallback = DEFAULT_TOOLCRAFT_PORT, env = process.env) {
7
14
  for (const name of names) {
@@ -52,3 +59,98 @@ export async function findAvailablePort(startPort = DEFAULT_TOOLCRAFT_PORT) {
52
59
 
53
60
  throw new Error(`No free port found at or above ${startPort}.`);
54
61
  }
62
+
63
+ function isValidPort(port) {
64
+ return Number.isInteger(port) && port > 0 && port <= 65_535;
65
+ }
66
+
67
+ export function getToolcraftPortStatePath(cwd = process.cwd()) {
68
+ return path.join(cwd, PORT_STATE_DIR, PORT_STATE_FILE);
69
+ }
70
+
71
+ export async function readSavedToolcraftPort(cwd = process.cwd()) {
72
+ try {
73
+ const source = await fs.readFile(getToolcraftPortStatePath(cwd), "utf8");
74
+ const state = JSON.parse(source);
75
+
76
+ return isValidPort(state.port) ? state.port : null;
77
+ } catch (error) {
78
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) {
79
+ return null;
80
+ }
81
+
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ export async function writeSavedToolcraftPort(port, cwd = process.cwd()) {
87
+ if (!isValidPort(port)) {
88
+ throw new Error(`Cannot save invalid Toolcraft port: ${port}.`);
89
+ }
90
+
91
+ const statePath = getToolcraftPortStatePath(cwd);
92
+ await fs.mkdir(path.dirname(statePath), { recursive: true });
93
+ await fs.writeFile(
94
+ statePath,
95
+ `${JSON.stringify({ port, updatedAt: new Date().toISOString() }, null, 2)}\n`,
96
+ );
97
+ }
98
+
99
+ export async function getListeningProcessIds(port, execFileImpl = execFileAsync) {
100
+ if (!isValidPort(port)) {
101
+ return [];
102
+ }
103
+
104
+ try {
105
+ const { stdout } =
106
+ process.platform === "win32"
107
+ ? await execFileImpl("powershell", [
108
+ "-NoProfile",
109
+ "-Command",
110
+ `Get-NetTCPConnection -LocalPort ${port} -State Listen | Select-Object -ExpandProperty OwningProcess -Unique`,
111
+ ])
112
+ : await execFileImpl("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]);
113
+
114
+ return [...new Set(String(stdout).split(/\s+/).map(Number).filter(Number.isInteger))].filter(
115
+ (pid) => pid > 0 && pid !== process.pid,
116
+ );
117
+ } catch {
118
+ return [];
119
+ }
120
+ }
121
+
122
+ export async function killListeningProcessesOnPort(port, options = {}) {
123
+ const {
124
+ execFileImpl = execFileAsync,
125
+ killProcess = process.kill,
126
+ signal = "SIGTERM",
127
+ } = options;
128
+ const pids = await getListeningProcessIds(port, execFileImpl);
129
+
130
+ for (const pid of pids) {
131
+ try {
132
+ killProcess(pid, signal);
133
+ } catch (error) {
134
+ if (error?.code !== "ESRCH") {
135
+ throw error;
136
+ }
137
+ }
138
+ }
139
+
140
+ return pids;
141
+ }
142
+
143
+ export async function waitForPortAvailable(port, options = {}) {
144
+ const { intervalMs = 100, timeoutMs = 3_000 } = options;
145
+ const deadline = Date.now() + timeoutMs;
146
+
147
+ while (Date.now() <= deadline) {
148
+ if (await isPortAvailableOnLoopback(port)) {
149
+ return true;
150
+ }
151
+
152
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
153
+ }
154
+
155
+ return false;
156
+ }
@@ -1,8 +1,17 @@
1
1
  import assert from "node:assert/strict";
2
+ import fs from "node:fs/promises";
2
3
  import net from "node:net";
4
+ import os from "node:os";
5
+ import path from "node:path";
3
6
  import test from "node:test";
4
7
 
5
- import { findAvailablePort } from "./toolcraft-port.mjs";
8
+ import {
9
+ findAvailablePort,
10
+ getListeningProcessIds,
11
+ killListeningProcessesOnPort,
12
+ readSavedToolcraftPort,
13
+ writeSavedToolcraftPort,
14
+ } from "./toolcraft-port.mjs";
6
15
 
7
16
  function listen(host) {
8
17
  return new Promise((resolve, reject) => {
@@ -71,3 +80,53 @@ test("findAvailablePort skips ports occupied on IPv4 localhost", async (t) => {
71
80
  "A port occupied on 127.0.0.1 must be treated as unavailable for localhost URLs.",
72
81
  );
73
82
  });
83
+
84
+ test("saved Toolcraft port round-trips through local project state", async (t) => {
85
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "toolcraft-port-"));
86
+ t.after(() => fs.rm(cwd, { force: true, recursive: true }));
87
+
88
+ assert.equal(await readSavedToolcraftPort(cwd), null);
89
+
90
+ await writeSavedToolcraftPort(4123, cwd);
91
+
92
+ assert.equal(await readSavedToolcraftPort(cwd), 4123);
93
+ });
94
+
95
+ test("getListeningProcessIds parses unique listening process ids", async () => {
96
+ const pids = await getListeningProcessIds(4123, async () => ({
97
+ stderr: "",
98
+ stdout: "123\n456\n123\nnot-a-pid\n",
99
+ }));
100
+
101
+ assert.deepEqual(pids, [123, 456]);
102
+ });
103
+
104
+ test("killListeningProcessesOnPort targets listeners found on the port", async () => {
105
+ const killed = [];
106
+ const pids = await killListeningProcessesOnPort(4123, {
107
+ execFileImpl: async () => ({ stderr: "", stdout: "123\n456\n" }),
108
+ killProcess: (pid, signal) => {
109
+ killed.push({ pid, signal });
110
+ },
111
+ });
112
+
113
+ assert.deepEqual(pids, [123, 456]);
114
+ assert.deepEqual(killed, [
115
+ { pid: 123, signal: "SIGTERM" },
116
+ { pid: 456, signal: "SIGTERM" },
117
+ ]);
118
+ });
119
+
120
+ test("killListeningProcessesOnPort supports forced restart cleanup", async () => {
121
+ const killed = [];
122
+ const pids = await killListeningProcessesOnPort(4123, {
123
+ execFileImpl: async () => ({ stderr: "", stdout: "789\n" }),
124
+ killProcess: (pid, signal) => {
125
+ killed.push({ pid, signal });
126
+ },
127
+ signal: "SIGKILL",
128
+ });
129
+
130
+ assert.deepEqual(pids, [789]);
131
+ assert.deepEqual(killed, [{ pid: 789, signal: "SIGKILL" }]);
132
+ });