@pixel-point/toolcraft 0.0.6 → 0.0.8

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/generate.test.mjs +1 -1
  3. package/templates/runtime/contracts/component-contracts.test.ts +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -45,7 +45,7 @@ export: {
45
45
  - `appearance.background` or `scene.background` as a `color` control;
46
46
  - `export.includeBackground` as a boolean/options control.
47
47
 
48
- PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, resolution, state, render })`, where `background`, `includeBackground`, and `resolution` come from runtime state. `includeBackground` controls only PNG alpha; live preview, workspace canvas backing, and video export keep the product background. For every app with `Export PNG`, `resolution` comes from `export.image.resolution`: `2k`, `4k`, and `8k` render actual 2048/4096/8192px long-edge PNGs. `current` or omitted resolution falls back to retina sizing. Video export always includes the product background, uses `getToolcraftRetinaExportSize`, and must prove exported metadata duration matches the runtime timeline duration.
48
+ PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, resolution, state, render })`, where `background`, `includeBackground`, and `resolution` come from runtime state. Live preview renderers should call `shouldIncludeToolcraftPreviewBackground(state)` and hide only the product-rendered background when it returns false; do not hide or replace the Toolcraft canvas shell/backing. For every app with `Export PNG`, `resolution` comes from `export.image.resolution`: `2k`, `4k`, and `8k` render actual 2048/4096/8192px long-edge PNGs. `current` or omitted resolution falls back to retina sizing. Video export always includes the product background, uses `getToolcraftRetinaExportSize`, and must prove exported metadata duration matches the runtime timeline duration.
49
49
 
50
50
  Every app with `Export PNG` exposes a separate `Image Export` controls section. For still-output apps it sits directly above sticky footer actions. For animated apps with both `Export PNG` and `Export Video`, it sits immediately before `Video Export`:
51
51
 
@@ -140,7 +140,7 @@ Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`,
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 a full `pnpm verify:perf` checkpoint. Performance fixes must preserve the selected scale and keep the canvas responsive while dragging sliders or other high-frequency controls at that 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 `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.
144
144
 
145
145
  ## Panels
146
146
 
@@ -161,12 +161,13 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
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` |
164
+ | `collectionActions` | Add/remove buttons for repeatable product entities and their runtime-backed item controls | `defaultValue: []`, `itemControl`, `itemDefaultValue`, `itemLabel`, `minItems`, `recommendedMaxItems`, `hardMaxItems`, `target` |
164
165
  | `code` | Multiline textarea | `defaultValue`, `target`, `label` |
165
166
  | `color` | Hex color picker | `defaultValue: { hex }`, `target`, `label` |
166
167
  | `colorOpacity` | Hex color picker plus opacity percent input | `defaultValue: { hex, opacity }`, `target`, `label` |
167
168
  | `curves` | RGB or single curve editor | `defaultValue`, `target`, `variant: "single"`, `interpolation: "smooth" \| "monotone"` |
168
- | `fileDrop` | Upload/drop input | `accept`, `multiple`, `target` |
169
- | `fontPicker` | Font preview select with popup, category search, weight, size, text case, text color/opacity, letter spacing, and line height; product text must consume `fontId`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `textCase`, `color`, and `opacity` | `defaultValue: { fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, opacity }`, `target` |
169
+ | `fileDrop` | Upload/drop input; `assetKind: "image"` owns image previews and `assetKind: "file"` owns sortable arbitrary file lists | `assetKind`, `accept`, `multiple`, `defaultValue`, `target` |
170
+ | `fontPicker` | Font preview select with popup, category search, weight, size, text case, text color/opacity, letter spacing, and line height; product text must consume `fontId`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `textCase`, `color`, and `opacity`; default text color is `#FFFFFF` at `100` opacity | `defaultValue: { fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, opacity }`, `target` |
170
171
  | `gradient` | Gradient editor | `defaultValue: { angle, gradientType, stops }`, `target` |
171
172
  | `imagePicker` | Image choice grid | `items`, `defaultValue`, `target` |
172
173
  | `palette` | Constrained design-token palette picker for family + shade | `defaultValue: { family, shade }`, `target` |
@@ -184,6 +185,8 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
184
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
187
 
188
+ 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
+
187
190
  ## Control Selection Inventory
188
191
 
189
192
  Before writing schema controls, map product needs to built-ins by value model, not visual similarity.
@@ -235,6 +238,8 @@ Section expand/collapse uses the standard runtime height/opacity animation. Do n
235
238
 
236
239
  Ordinary section collapsed/expanded state persists as a per-app runtime UI preference. It is not undo/redo state, not settings import/export state, and `Reset controls` must not clear it. Runtime technical `Setup` / settings sections and sticky footer `Export` sections are not collapsible.
237
240
 
241
+ Ordinary section headers expose the runtime section reset action before the collapse button. It dispatches `controls.resetTargets` and restores only that section's control targets to their schema `defaultValue`.
242
+
238
243
  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.
239
244
 
240
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.
@@ -249,7 +254,7 @@ If a target prefix has to be split across sections, the spec must name the workf
249
254
 
250
255
  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`.
251
256
 
252
- 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 controls show visible field labels in mixed sections that contain any non-color control. Omit visible color labels only in color-only sections. Mixed inline rows require visible labels on every field, except the required Background row where the color control uses `label: false` because the section title owns the context. Color fields in other mixed rows must not be unlabeled. 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`.
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`.
253
258
 
254
259
  `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.
255
260
 
@@ -261,7 +266,7 @@ Order controls by decision flow inside each section:
261
266
  - `mode`: mode, type, filter, blend, style, and preset selectors;
262
267
  - `primary`, `spatial`, `color`: core product parameters;
263
268
  - `strength`: intensity, opacity, scale, depth;
264
- - `detail`: grain, noise, blur, density, radius, quality;
269
+ - `detail`: noise, texture, blur, density, radius, quality;
265
270
  - `advanced`: secondary tuning;
266
271
  - `action`: footer actions.
267
272
 
@@ -19,7 +19,7 @@ Do not edit implementation files until this preflight is complete.
19
19
 
20
20
  ## Task Routing
21
21
 
22
- Use the smallest reading set that covers the changed surface.
22
+ Use the smallest reading set that covers the changed surface. If a task touches multiple surfaces, combine every matching row instead of picking only the closest one.
23
23
 
24
24
  | Task type | Read before editing |
25
25
  | --- | --- |
@@ -29,7 +29,7 @@ Use the smallest reading set that covers the changed surface.
29
29
  | Renderer, canvas output, visual technique | `renderer-technique.md`, `performance.md`, `acceptance-testing.md` |
30
30
  | Timeline, keyframes, animation transport | `decision-contract.md`, `component-rules.md`, `acceptance-testing.md`, `performance.md` |
31
31
  | Layers | `decision-contract.md`, `component-rules.md`, `acceptance-testing.md` |
32
- | Export, copy, media, background | `schema-reference.md`, `component-rules.md`, `acceptance-testing.md` |
32
+ | Export, copy, media, background | `schema-reference.md`, `component-rules.md`, `acceptance-testing.md`, `performance.md` |
33
33
  | Broken control, visual mismatch, failed build, export bug, performance issue | `decision-contract.md`, the relevant component/runtime doc, and the failing test/log first |
34
34
  | Figma implementation | Use Figma MCP/design context, then `assembly-workflow.md` and the relevant component docs |
35
35
 
@@ -73,15 +73,12 @@ Choose the tier from `AGENTS.md` before editing. Use the tier to decide checks.
73
73
 
74
74
  - Tier 0-1: targeted docs/typecheck/unit plus focused browser when visual.
75
75
  - Tier 2: `pnpm verify:quick` plus relevant browser acceptance.
76
- - Tier 3: `pnpm verify:quick`, targeted browser acceptance, and relevant `pnpm verify:perf` scenarios.
77
- - Tier 4: `pnpm verify:final`, then start `pnpm dev` for the local URL.
76
+ - Tier 3: `pnpm verify:quick`, targeted browser acceptance, and targeted performance scenarios only for touched workload/viewport/export paths.
77
+ - Tier 4: `pnpm verify:final`; add `pnpm verify:perf` only for the first working app version or explicit performance complaints, then start `pnpm dev` for the local URL.
78
78
 
79
- Run a full performance checkpoint with `pnpm verify:perf` when:
79
+ Run a full performance checkpoint with `pnpm verify:perf` only when:
80
80
 
81
81
  - the first working version of the app exists;
82
- - renderer, canvas, animation, export, timeline, or layers change;
83
- - a bug that previously broke functionality is fixed;
84
- - a performance optimization lands;
85
- - the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
82
+ - the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance.
86
83
 
87
- Fast feature loops may defer full performance only when none of these checkpoint triggers apply. Record the deferred check and reason in the worklog.
84
+ Fast feature loops after the first working version do not run the full performance suite by default. Renderer, canvas, animation, export, timeline, layers, `canvas.renderScale`, bug fixes, and performance-sensitive controls still need targeted functional/browser checks first, plus targeted performance scenarios only when they directly exercise the touched path. Record any skipped full performance run and reason in the worklog.
@@ -110,12 +110,49 @@ function getScenarioBudgetAssertionPattern(scenarioId: string): RegExp {
110
110
 
111
111
  function getScenarioStressFixturePattern(scenarioId: string): RegExp {
112
112
  return new RegExp(
113
- `getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
113
+ `(?:getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)|applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
114
114
  scenarioId,
115
115
  )}\\1`,
116
116
  );
117
117
  }
118
118
 
119
+ function getScenarioWorkloadFixturePattern(scenarioId: string): RegExp {
120
+ return new RegExp(
121
+ `(?:getToolcraftPerformanceWorkloadValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
122
+ scenarioId,
123
+ )}\\1`,
124
+ );
125
+ }
126
+
127
+ function getScenarioCustomStressFixturePattern(scenarioId: string): RegExp {
128
+ return new RegExp(
129
+ `applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
130
+ scenarioId,
131
+ )}\\1`,
132
+ );
133
+ }
134
+
135
+ function getScenarioCustomWorkloadFixturePattern(scenarioId: string): RegExp {
136
+ return new RegExp(
137
+ `applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
138
+ scenarioId,
139
+ )}\\1`,
140
+ );
141
+ }
142
+
143
+ function getScenarioSliderStressValuePattern(scenarioId: string): RegExp {
144
+ return new RegExp(
145
+ `dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
146
+ scenarioId,
147
+ )}\\1`,
148
+ );
149
+ }
150
+
151
+ function getFirstMatchIndex(source: string, pattern: RegExp): number {
152
+ const match = pattern.exec(source);
153
+ return match?.index ?? -1;
154
+ }
155
+
119
156
  test("browser performance matrix points at real Playwright tests", () => {
120
157
  const browserTestSources = readSiblingBrowserTestSources();
121
158
 
@@ -154,15 +191,71 @@ test("browser performance tests use real Toolcraft interactions", () => {
154
191
  continue;
155
192
  }
156
193
 
157
- if (scenario.workload) {
194
+ if (scenario.workload || scenario.stress === true || scenario.stressFixture) {
158
195
  expect(
159
196
  scenario.stressFixture,
160
- `${scenario.id} workload scenario must declare stressFixture so browser tests cannot use toy inputs.`,
197
+ `${scenario.id} stress scenario must declare stressFixture so browser tests cannot use toy inputs.`,
161
198
  ).toBeDefined();
162
199
  expect(
163
200
  browserTestSource,
164
201
  `${scenario.id} must read the declared stress fixture with getToolcraftPerformanceStressValue(appPerformance, "${scenario.id}") before measuring performance.`,
165
202
  ).toMatch(getScenarioStressFixturePattern(scenario.id));
203
+
204
+ if (
205
+ scenario.stressFixture?.kind === "custom" &&
206
+ typeof scenario.stressFixture.value === "object" &&
207
+ scenario.stressFixture.value !== null &&
208
+ !Array.isArray(scenario.stressFixture.value)
209
+ ) {
210
+ expect(
211
+ browserTestSource,
212
+ `${scenario.id} custom stress fixture must be applied with applyToolcraftPerformanceStressFixture so every heavy-state key is mapped through the real UI before measurement.`,
213
+ ).toMatch(getScenarioCustomStressFixturePattern(scenario.id));
214
+ }
215
+ }
216
+
217
+ if (scenario.workloadFixture) {
218
+ const workloadFixtureIndex = getFirstMatchIndex(
219
+ browserTestSource,
220
+ getScenarioWorkloadFixturePattern(scenario.id),
221
+ );
222
+ const stressFixtureIndex = getFirstMatchIndex(
223
+ browserTestSource,
224
+ getScenarioStressFixturePattern(scenario.id),
225
+ );
226
+ const budgetAssertionIndex = getFirstMatchIndex(
227
+ browserTestSource,
228
+ getScenarioBudgetAssertionPattern(scenario.id),
229
+ );
230
+
231
+ expect(
232
+ browserTestSource,
233
+ `${scenario.id} must apply the declared workloadFixture with getToolcraftPerformanceWorkloadValue(appPerformance, "${scenario.id}") or applyToolcraftPerformanceWorkloadFixture before measuring performance.`,
234
+ ).toMatch(getScenarioWorkloadFixturePattern(scenario.id));
235
+ expect(
236
+ workloadFixtureIndex,
237
+ `${scenario.id} must apply workloadFixture before stressFixture so the measured control runs inside the heavy app baseline.`,
238
+ ).toBeGreaterThanOrEqual(0);
239
+ expect(
240
+ stressFixtureIndex,
241
+ `${scenario.id} must read/apply stressFixture after workloadFixture.`,
242
+ ).toBeGreaterThan(workloadFixtureIndex);
243
+ expect(
244
+ budgetAssertionIndex,
245
+ `${scenario.id} must assert budget only after workloadFixture is applied.`,
246
+ ).toBeGreaterThan(workloadFixtureIndex);
247
+
248
+ if (
249
+ scenario.workloadFixture.kind === "custom" &&
250
+ typeof scenario.workloadFixture.value === "object" &&
251
+ scenario.workloadFixture.value !== null &&
252
+ !Array.isArray(scenario.workloadFixture.value)
253
+ ) {
254
+ expect(
255
+ browserTestSource,
256
+ `${scenario.id} custom workload fixture must be applied with applyToolcraftPerformanceWorkloadFixture so every baseline-state key is mapped through the real UI before measurement.`,
257
+ ).toMatch(getScenarioCustomWorkloadFixturePattern(scenario.id));
258
+ }
166
259
  }
167
260
 
168
261
  if (scenario.interaction === "control-drag") {
@@ -190,6 +283,13 @@ test("browser performance tests use real Toolcraft interactions", () => {
190
283
  `${scenario.id} drags discrete slider "${scenario.controlLabel}" and must verify Toolcraft marker-budget behavior plus smooth drag.`,
191
284
  ).toMatch(/expectToolcraftDiscreteSliderDragSmoothness\s*\(/);
192
285
  }
286
+
287
+ if (scenario.workload && scenario.controlLabel) {
288
+ expect(
289
+ browserTestSource,
290
+ `${scenario.id} workload slider drags must apply the exact stressFixture.value with dragToolcraftSliderToPerformanceStressValue instead of hand-dividing by max or typing a ratio.`,
291
+ ).toMatch(getScenarioSliderStressValuePattern(scenario.id));
292
+ }
193
293
  }
194
294
 
195
295
  if (scenario.interaction === "control-change") {
@@ -284,6 +384,39 @@ test("browser performance tests use real Toolcraft interactions", () => {
284
384
  ).not.toMatch(/canvas\.setOffset|canvas\.panBy|canvas\.zoom|Zoom in|Zoom out|Center canvas/);
285
385
  }
286
386
 
387
+ if (scenario.interaction === "timeline-playback") {
388
+ expect(
389
+ browserTestSource,
390
+ `${scenario.id} must use the real timeline playback UI while measuring frames.`,
391
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Play playback|Pause playback|timeline-playback|data-slot=["']timeline-playback)/);
392
+ expect(
393
+ browserTestSource,
394
+ `${scenario.id} must not satisfy timeline playback performance by dispatching timeline state directly.`,
395
+ ).not.toMatch(/timeline\.setCurrentTime|timeline\.setPlaying|timeline\.play|timeline\.pause/);
396
+ }
397
+
398
+ if (scenario.interaction === "timeline-scrub") {
399
+ expect(
400
+ browserTestSource,
401
+ `${scenario.id} must scrub the real timeline UI while measuring interaction responsiveness.`,
402
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Playback position|timeline-playback-handle|data-slot=["']timeline-playback-handle|page\.mouse\.(?:down|move|up))/);
403
+ expect(
404
+ browserTestSource,
405
+ `${scenario.id} must not satisfy timeline scrub performance by setting timeline time directly.`,
406
+ ).not.toMatch(/timeline\.setCurrentTime|canvas\.setCurrentTime/);
407
+ }
408
+
409
+ if (scenario.interaction === "mask-drag") {
410
+ expect(
411
+ browserTestSource,
412
+ `${scenario.id} must measure a real canvas handle/mask drag.`,
413
+ ).toMatch(/measureToolcraftInteraction\s*\([\s\S]*dragCanvasHandle\s*\(/);
414
+ expect(
415
+ browserTestSource,
416
+ `${scenario.id} must not fake mask drag coverage with direct runtime state mutation.`,
417
+ ).not.toMatch(/mask\.(?:set|update|move)|canvas\.setOffset|canvas\.panBy/);
418
+ }
419
+
287
420
  if (scenario.interaction === "viewport-zoom-stress") {
288
421
  expect(
289
422
  browserTestSource,
@@ -21,6 +21,26 @@ export type ToolcraftInteractionOptions = {
21
21
  settleMs?: number;
22
22
  };
23
23
 
24
+ export type ToolcraftStressFixtureApplyContext = {
25
+ config: ToolcraftPerformanceConfig;
26
+ fixture: Record<string, unknown>;
27
+ key: string;
28
+ page: Page;
29
+ scenarioId: string;
30
+ };
31
+
32
+ export type ToolcraftStressFixtureAppliers = Record<
33
+ string,
34
+ (
35
+ value: unknown,
36
+ context: ToolcraftStressFixtureApplyContext,
37
+ ) => Promise<void> | void
38
+ >;
39
+
40
+ function isToolcraftStressFixtureObject(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
24
44
  export async function startToolcraftFrameProbe(
25
45
  page: Page,
26
46
  ): Promise<() => Promise<ToolcraftFrameProbeResult>> {
@@ -371,6 +391,161 @@ export async function dragToolcraftSliderByLabel(
371
391
  await page.mouse.up();
372
392
  }
373
393
 
394
+ export async function dragToolcraftSliderToValue(
395
+ page: Page,
396
+ label: string,
397
+ value: number,
398
+ ): Promise<void> {
399
+ const field = await getToolcraftFieldByLabel(page, label);
400
+ const slider = field.locator('[data-slot="slider"], [role="slider"]').first();
401
+
402
+ await expect(slider, `Toolcraft slider "${label}" should be visible`).toBeVisible();
403
+
404
+ const range = await slider.evaluate((element) => {
405
+ const htmlElement = element as HTMLElement;
406
+ const min = Number(
407
+ htmlElement.getAttribute("aria-valuemin") ??
408
+ (htmlElement as HTMLInputElement).min ??
409
+ "0",
410
+ );
411
+ const max = Number(
412
+ htmlElement.getAttribute("aria-valuemax") ??
413
+ (htmlElement as HTMLInputElement).max ??
414
+ "100",
415
+ );
416
+
417
+ return {
418
+ max: Number.isFinite(max) ? max : 100,
419
+ min: Number.isFinite(min) ? min : 0,
420
+ };
421
+ });
422
+ const denominator = range.max - range.min;
423
+ const ratio = denominator === 0 ? 0 : (value - range.min) / denominator;
424
+
425
+ await dragToolcraftSliderByLabel(page, label, Math.min(1, Math.max(0, ratio)));
426
+ }
427
+
428
+ export async function dragToolcraftSliderToPerformanceStressValue(
429
+ page: Page,
430
+ label: string,
431
+ config: ToolcraftPerformanceConfig,
432
+ scenarioId: string,
433
+ ): Promise<void> {
434
+ const value = getToolcraftPerformanceStressValue(config, scenarioId);
435
+
436
+ if (typeof value !== "number" || !Number.isFinite(value)) {
437
+ throw new Error(
438
+ `Toolcraft performance scenario "${scenarioId}" must provide a numeric stressFixture.value for slider "${label}".`,
439
+ );
440
+ }
441
+
442
+ await dragToolcraftSliderToValue(page, label, value);
443
+ }
444
+
445
+ export async function applyToolcraftPerformanceStressFixture(
446
+ page: Page,
447
+ config: ToolcraftPerformanceConfig,
448
+ scenarioId: string,
449
+ appliers: ToolcraftStressFixtureAppliers,
450
+ ): Promise<Record<string, unknown>> {
451
+ const fixture = getToolcraftPerformanceStressValue(config, scenarioId);
452
+
453
+ if (!isToolcraftStressFixtureObject(fixture)) {
454
+ throw new Error(
455
+ `Toolcraft performance scenario "${scenarioId}" must provide an object stressFixture.value for combined fixture application.`,
456
+ );
457
+ }
458
+
459
+ const fixtureKeys = Object.keys(fixture);
460
+ if (fixtureKeys.length === 0) {
461
+ throw new Error(
462
+ `Toolcraft performance scenario "${scenarioId}" stressFixture.value must contain at least one key.`,
463
+ );
464
+ }
465
+
466
+ const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
467
+ if (missingKeys.length > 0) {
468
+ throw new Error(
469
+ `Toolcraft performance scenario "${scenarioId}" is missing fixture appliers for: ${missingKeys.join(
470
+ ", ",
471
+ )}.`,
472
+ );
473
+ }
474
+
475
+ const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
476
+ if (extraKeys.length > 0) {
477
+ throw new Error(
478
+ `Toolcraft performance scenario "${scenarioId}" declares fixture appliers not present in stressFixture.value: ${extraKeys.join(
479
+ ", ",
480
+ )}.`,
481
+ );
482
+ }
483
+
484
+ for (const key of fixtureKeys) {
485
+ await appliers[key]!(fixture[key], {
486
+ config,
487
+ fixture,
488
+ key,
489
+ page,
490
+ scenarioId,
491
+ });
492
+ }
493
+
494
+ return fixture;
495
+ }
496
+
497
+ export async function applyToolcraftPerformanceWorkloadFixture(
498
+ page: Page,
499
+ config: ToolcraftPerformanceConfig,
500
+ scenarioId: string,
501
+ appliers: ToolcraftStressFixtureAppliers,
502
+ ): Promise<Record<string, unknown>> {
503
+ const fixture = getToolcraftPerformanceWorkloadValue(config, scenarioId);
504
+
505
+ if (!isToolcraftStressFixtureObject(fixture)) {
506
+ throw new Error(
507
+ `Toolcraft performance scenario "${scenarioId}" must provide an object workloadFixture.value for baseline fixture application.`,
508
+ );
509
+ }
510
+
511
+ const fixtureKeys = Object.keys(fixture);
512
+ if (fixtureKeys.length === 0) {
513
+ throw new Error(
514
+ `Toolcraft performance scenario "${scenarioId}" workloadFixture.value must contain at least one key.`,
515
+ );
516
+ }
517
+
518
+ const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
519
+ if (missingKeys.length > 0) {
520
+ throw new Error(
521
+ `Toolcraft performance scenario "${scenarioId}" is missing workload fixture appliers for: ${missingKeys.join(
522
+ ", ",
523
+ )}.`,
524
+ );
525
+ }
526
+
527
+ const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
528
+ if (extraKeys.length > 0) {
529
+ throw new Error(
530
+ `Toolcraft performance scenario "${scenarioId}" declares workload fixture appliers not present in workloadFixture.value: ${extraKeys.join(
531
+ ", ",
532
+ )}.`,
533
+ );
534
+ }
535
+
536
+ for (const key of fixtureKeys) {
537
+ await appliers[key]!(fixture[key], {
538
+ config,
539
+ fixture,
540
+ key,
541
+ page,
542
+ scenarioId,
543
+ });
544
+ }
545
+
546
+ return fixture;
547
+ }
548
+
374
549
  export async function dragToolcraftCanvasViewport(
375
550
  page: Page,
376
551
  delta: { x: number; y: number } = { x: 96, y: -64 },
@@ -553,6 +728,28 @@ export function getToolcraftPerformanceStressValue<TValue = unknown>(
553
728
  return scenario.stressFixture.value as TValue;
554
729
  }
555
730
 
731
+ export function getToolcraftPerformanceWorkloadValue<TValue = unknown>(
732
+ config: ToolcraftPerformanceConfig,
733
+ scenarioId: string,
734
+ ): TValue {
735
+ const scenario = config.scenarios.find((item) => item.id === scenarioId);
736
+
737
+ if (!scenario) {
738
+ throw new Error(`Toolcraft performance scenario "${scenarioId}" was not found.`);
739
+ }
740
+
741
+ if (
742
+ !scenario.workloadFixture ||
743
+ !Object.prototype.hasOwnProperty.call(scenario.workloadFixture, "value")
744
+ ) {
745
+ throw new Error(
746
+ `Toolcraft performance scenario "${scenarioId}" does not declare workloadFixture.value.`,
747
+ );
748
+ }
749
+
750
+ return scenario.workloadFixture.value as TValue;
751
+ }
752
+
556
753
  export function expectToolcraftScenarioPerformanceBudget(
557
754
  result: ToolcraftPerformanceBudgetResult,
558
755
  config: ToolcraftPerformanceConfig,
@@ -16,10 +16,13 @@
16
16
  "verify:quick": "pnpm ai:check && pnpm test",
17
17
  "verify:ui": "pnpm test:browser",
18
18
  "verify:perf": "pnpm test:browser:perf",
19
- "verify:final": "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser && pnpm test:browser:perf"
19
+ "verify:final": "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser"
20
20
  },
21
21
  "dependencies": {
22
22
  "@base-ui/react": "^1.4.1",
23
+ "@dnd-kit/core": "^6.3.1",
24
+ "@dnd-kit/sortable": "^10.0.0",
25
+ "@dnd-kit/utilities": "^3.2.2",
23
26
  "@fontsource-variable/inter": "^5.2.8",
24
27
  "@phosphor-icons/react": "^2.1.10",
25
28
  "@repo/toolcraft-runtime": "workspace:*",