@pixel-point/toolcraft 0.0.8 → 0.0.11

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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -1,10 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { toolcraftRuntimeCommandTypes } from "../state/types";
4
- import {
5
- defineToolcraft,
6
- getToolcraftSettingsTransferEligibility,
7
- } from "./define-toolcraft";
4
+ import { defineToolcraft } from "./define-toolcraft";
8
5
  import {
9
6
  toolcraftReservedTargets,
10
7
  toolcraftRuntimeOwnedTargets,
@@ -27,17 +24,8 @@ describe("defineToolcraft", () => {
27
24
  );
28
25
  }
29
26
 
30
- function createSections(count: number) {
31
- return Array.from({ length: count }, (_, index) => ({
32
- controls: {
33
- opacity: {
34
- defaultValue: index,
35
- target: `section${index}.opacity`,
36
- type: "slider",
37
- },
38
- },
39
- title: `Section ${index + 1}`,
40
- }));
27
+ function getProductSections(app: ReturnType<typeof defineToolcraft>) {
28
+ return app.panels.controls?.sections.filter((section) => section.title !== "Setup") ?? [];
41
29
  }
42
30
 
43
31
  it("defaults enabled canvas to draggable with canvas toolbar features", () => {
@@ -53,6 +41,30 @@ describe("defineToolcraft", () => {
53
41
  expect(app.toolbar).toEqual({ history: true, radar: true, theme: true, zoom: true });
54
42
  });
55
43
 
44
+ it("defaults upload canvases to editable output unless intrinsic media is explicit", () => {
45
+ const app = defineToolcraft({
46
+ canvas: { enabled: true, upload: true },
47
+ panels: {},
48
+ });
49
+
50
+ expect(app.canvas.size).toEqual({ height: 1080, unit: "px", width: 1920 });
51
+ expect(app.canvas.sizeSource).toBe("runtime-default");
52
+ expect(app.canvas.sizing).toEqual({ mode: "editable-output" });
53
+ expect(app.assembly.capabilities).toContain("canvas.editableSize");
54
+ expect(app.assembly.commands).toContain("canvas.setSize");
55
+ });
56
+
57
+ it("preserves explicit intrinsic media sizing for source-native upload apps", () => {
58
+ const app = defineToolcraft({
59
+ canvas: { enabled: true, sizing: { mode: "intrinsic-media" }, upload: true },
60
+ panels: {},
61
+ });
62
+
63
+ expect(app.canvas.sizing).toEqual({ mode: "intrinsic-media" });
64
+ expect(app.assembly.capabilities).not.toContain("canvas.editableSize");
65
+ expect(app.assembly.commands).not.toContain("canvas.setSize");
66
+ });
67
+
56
68
  it("disables app state persistence by default", () => {
57
69
  const app = defineToolcraft({
58
70
  canvas: { enabled: true },
@@ -82,7 +94,7 @@ describe("defineToolcraft", () => {
82
94
  });
83
95
  });
84
96
 
85
- it("keeps settings transfer disabled for small auto schemas", () => {
97
+ it("keeps settings transfer visible for small auto schemas", () => {
86
98
  const app = defineToolcraft({
87
99
  canvas: { enabled: true, size: { height: 320, unit: "px", width: 320 } },
88
100
  panels: {
@@ -107,11 +119,15 @@ describe("defineToolcraft", () => {
107
119
 
108
120
  expect(app.settingsTransfer).toMatchObject({
109
121
  appId: "mini-app",
110
- enabled: false,
122
+ enabled: true,
111
123
  fileName: "mini-app-settings.json",
112
124
  mode: "auto",
113
125
  });
114
126
  expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
127
+ expect(app.panels.controls?.sections[0]?.controls.settingsTransfer).toMatchObject({
128
+ target: "runtime.settingsTransfer",
129
+ type: "settingsTransfer",
130
+ });
115
131
  expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
116
132
  target: "canvas.aspectRatio",
117
133
  type: "aspectRatio",
@@ -119,124 +135,7 @@ describe("defineToolcraft", () => {
119
135
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
120
136
  });
121
137
 
122
- it("reports settings transfer eligibility from control count, sections, and weighted score", () => {
123
- const small = getToolcraftSettingsTransferEligibility({
124
- panels: {
125
- controls: {
126
- sections: [
127
- {
128
- controls: createSliderControls(1),
129
- title: "Small",
130
- },
131
- ],
132
- title: "Small App",
133
- },
134
- },
135
- });
136
- const manyControls = getToolcraftSettingsTransferEligibility({
137
- panels: {
138
- controls: {
139
- sections: [
140
- {
141
- controls: createSliderControls(12),
142
- title: "Many Controls",
143
- },
144
- ],
145
- title: "Many Controls App",
146
- },
147
- },
148
- });
149
- const manySections = getToolcraftSettingsTransferEligibility({
150
- panels: {
151
- controls: {
152
- sections: createSections(5),
153
- title: "Many Sections App",
154
- },
155
- },
156
- });
157
- const heavyControls = getToolcraftSettingsTransferEligibility({
158
- panels: {
159
- controls: {
160
- sections: [
161
- {
162
- controls: Object.fromEntries(
163
- Array.from({ length: 6 }, (_, index) => [
164
- `gradient${index}`,
165
- {
166
- label: `Gradient ${index + 1}`,
167
- target: `gradient${index}`,
168
- type: "gradient",
169
- },
170
- ]),
171
- ),
172
- title: "Gradients",
173
- },
174
- ],
175
- title: "Heavy Controls App",
176
- },
177
- },
178
- });
179
- const timelineLayersScore = getToolcraftSettingsTransferEligibility({
180
- panels: {
181
- controls: {
182
- sections: [
183
- {
184
- controls: Object.fromEntries(
185
- Array.from({ length: 5 }, (_, index) => [
186
- `gradient${index}`,
187
- {
188
- label: `Gradient ${index + 1}`,
189
- target: `gradient${index}`,
190
- type: "gradient",
191
- },
192
- ]),
193
- ),
194
- title: "Gradients",
195
- },
196
- ],
197
- title: "Timeline Layers App",
198
- },
199
- layers: true,
200
- timeline: { mode: "playback" },
201
- },
202
- });
203
-
204
- expect(small).toEqual({
205
- controlCount: 1,
206
- eligible: false,
207
- reasons: [],
208
- score: 1,
209
- sectionCount: 1,
210
- });
211
- expect(manyControls).toMatchObject({
212
- controlCount: 12,
213
- eligible: true,
214
- reasons: ["control-count"],
215
- sectionCount: 1,
216
- });
217
- expect(manySections).toMatchObject({
218
- controlCount: 5,
219
- eligible: true,
220
- reasons: ["section-count"],
221
- sectionCount: 5,
222
- });
223
- expect(heavyControls).toMatchObject({
224
- controlCount: 6,
225
- eligible: true,
226
- reasons: ["score"],
227
- score: 18,
228
- sectionCount: 1,
229
- });
230
- expect(timelineLayersScore).toMatchObject({
231
- controlCount: 5,
232
- eligible: true,
233
- reasons: ["score"],
234
- score: 19,
235
- sectionCount: 1,
236
- });
237
- });
238
-
239
- it("ignores runtime canvas setup controls in settings transfer eligibility", () => {
138
+ it("keeps runtime setup controls outside product sections", () => {
240
139
  const app = defineToolcraft({
241
140
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
242
141
  panels: {
@@ -253,16 +152,16 @@ describe("defineToolcraft", () => {
253
152
  settingsTransfer: false,
254
153
  });
255
154
 
155
+ expect(app.settingsTransfer.enabled).toBe(true);
156
+ expect(app.panels.controls?.sections[0]?.controls.settingsTransfer).toMatchObject({
157
+ target: "runtime.settingsTransfer",
158
+ type: "settingsTransfer",
159
+ });
256
160
  expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toBeTruthy();
257
161
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
258
162
  expect(app.panels.controls?.sections[0]?.controls.canvasHeight).toBeTruthy();
259
- expect(getToolcraftSettingsTransferEligibility({ panels: app.panels })).toEqual({
260
- controlCount: 10,
261
- eligible: false,
262
- reasons: [],
263
- score: 10,
264
- sectionCount: 1,
265
- });
163
+ expect(getProductSections(app)).toHaveLength(1);
164
+ expect(getProductSections(app)[0]?.title).toBe("Product Controls");
266
165
  });
267
166
 
268
167
  it("injects settings transfer as the first section for complex auto schemas", () => {
@@ -335,6 +234,7 @@ describe("defineToolcraft", () => {
335
234
  "canvasAspectRatio",
336
235
  "canvasWidth",
337
236
  "canvasHeight",
237
+ "timelineExtended",
338
238
  ]);
339
239
  expect(runtimeSettingsSection?.controls.canvasAspectRatio).toMatchObject({
340
240
  defaultValue: {
@@ -362,7 +262,7 @@ describe("defineToolcraft", () => {
362
262
  expect(firstProductSection?.title).toBe("Text");
363
263
  });
364
264
 
365
- it("respects explicit settings transfer overrides", () => {
265
+ it("uses explicit settings transfer naming while keeping the setup controls mandatory", () => {
366
266
  const forced = defineToolcraft({
367
267
  canvas: { enabled: false },
368
268
  panels: {
@@ -410,10 +310,13 @@ describe("defineToolcraft", () => {
410
310
  expect(forced.panels.controls?.sections[0]?.controls.settingsTransfer?.type).toBe(
411
311
  "settingsTransfer",
412
312
  );
413
- expect(disabled.settingsTransfer.enabled).toBe(false);
414
- expect(
415
- disabled.panels.controls?.sections.some((section) => section.controls.settingsTransfer),
416
- ).toBe(false);
313
+ expect(disabled.settingsTransfer).toMatchObject({
314
+ enabled: true,
315
+ mode: false,
316
+ });
317
+ expect(disabled.panels.controls?.sections[0]?.controls.settingsTransfer?.type).toBe(
318
+ "settingsTransfer",
319
+ );
417
320
  });
418
321
 
419
322
  it("preserves conditional visibility on sections and controls", () => {
@@ -445,7 +348,7 @@ describe("defineToolcraft", () => {
445
348
  },
446
349
  });
447
350
 
448
- const [section] = app.panels.controls?.sections ?? [];
351
+ const [section] = getProductSections(app);
449
352
 
450
353
  expect(section?.visibleWhen).toEqual({
451
354
  equals: "co-brand",
@@ -540,6 +443,7 @@ describe("defineToolcraft", () => {
540
443
  "media.delete",
541
444
  "media.import",
542
445
  "media.reorder",
446
+ "media.transform",
543
447
  "layers.reorder",
544
448
  "timeline.setCurrentTime",
545
449
  "canvas.center",
@@ -548,7 +452,11 @@ describe("defineToolcraft", () => {
548
452
  );
549
453
  expect(app.assembly.surfaces.panels.layers?.defaultPlacement).toBe("left");
550
454
  expect(app.assembly.surfaces.panels.timeline?.snapEdges).toEqual(["top", "bottom"]);
551
- expect(app.panels.timeline).toEqual({ enabled: true, mode: "keyframes" });
455
+ expect(app.panels.timeline).toEqual({
456
+ defaultDurationSeconds: 8,
457
+ enabled: true,
458
+ mode: "keyframes",
459
+ });
552
460
  expect(app.assembly.surfaces.panels.toolbar.enabled).toBe(true);
553
461
  });
554
462
 
@@ -560,7 +468,11 @@ describe("defineToolcraft", () => {
560
468
  },
561
469
  });
562
470
 
563
- expect(app.panels.timeline).toEqual({ enabled: true, mode: "playback" });
471
+ expect(app.panels.timeline).toEqual({
472
+ defaultDurationSeconds: 8,
473
+ enabled: true,
474
+ mode: "playback",
475
+ });
564
476
  expect(app.assembly.components).toContain("timelinePanel");
565
477
  expect(app.assembly.capabilities).toEqual(
566
478
  expect.arrayContaining([
@@ -584,6 +496,30 @@ describe("defineToolcraft", () => {
584
496
  expect(app.assembly.commands).not.toContain("timeline.moveKeyframe");
585
497
  });
586
498
 
499
+ it("uses schema timeline default duration as the animation loop duration", () => {
500
+ const app = defineToolcraft({
501
+ canvas: { enabled: true },
502
+ panels: {
503
+ timeline: {
504
+ defaultDurationSeconds: 12,
505
+ mode: "playback",
506
+ },
507
+ },
508
+ });
509
+ const clamped = defineToolcraft({
510
+ canvas: { enabled: true },
511
+ panels: {
512
+ timeline: {
513
+ defaultDurationSeconds: 120,
514
+ mode: "playback",
515
+ },
516
+ },
517
+ });
518
+
519
+ expect(app.panels.timeline?.defaultDurationSeconds).toBe(12);
520
+ expect(clamped.panels.timeline?.defaultDurationSeconds).toBe(60);
521
+ });
522
+
587
523
  it("only publishes commands supported by the runtime command bus", () => {
588
524
  const app = defineToolcraft({
589
525
  canvas: { enabled: true, upload: true },
@@ -617,12 +553,18 @@ describe("defineToolcraft", () => {
617
553
  "canvas.renderScale",
618
554
  "canvas.size.width",
619
555
  "canvas.size.height",
556
+ "runtime.settingsTransfer",
557
+ "panels.timeline.extended",
558
+ "panels.timeline.visible",
620
559
  ]);
621
560
  expect(toolcraftReservedTargets).toEqual([
622
561
  "canvas.aspectRatio",
623
562
  "canvas.renderScale",
624
563
  "canvas.size.width",
625
564
  "canvas.size.height",
565
+ "runtime.settingsTransfer",
566
+ "panels.timeline.extended",
567
+ "panels.timeline.visible",
626
568
  "selectedLayer.opacity",
627
569
  "selectedLayer.visible",
628
570
  ]);
@@ -630,6 +572,9 @@ describe("defineToolcraft", () => {
630
572
  expect(getToolcraftCanvasSizeTargetDimension("canvas.size.height")).toBe("height");
631
573
  expect(getToolcraftCanvasSizeTargetDimension("generation.prompt")).toBeNull();
632
574
  expect(isToolcraftRuntimeOwnedTarget("canvas.aspectRatio")).toBe(true);
575
+ expect(isToolcraftRuntimeOwnedTarget("runtime.settingsTransfer")).toBe(true);
576
+ expect(isToolcraftRuntimeOwnedTarget("panels.timeline.extended")).toBe(true);
577
+ expect(isToolcraftRuntimeOwnedTarget("panels.timeline.visible")).toBe(true);
633
578
  expect(isToolcraftReservedTarget("selectedLayer.opacity")).toBe(true);
634
579
  expect(isToolcraftReservedTarget("generation.prompt")).toBe(false);
635
580
  });
@@ -823,6 +768,7 @@ describe("defineToolcraft", () => {
823
768
  });
824
769
  expect(app.assembly.capabilities).toContain("canvas.renderScale");
825
770
  expect(Object.keys(setupSection?.controls ?? {})).toEqual([
771
+ "settingsTransfer",
826
772
  "canvasAspectRatio",
827
773
  "canvasWidth",
828
774
  "canvasHeight",
@@ -837,11 +783,107 @@ describe("defineToolcraft", () => {
837
783
  step: 0.25,
838
784
  target: "canvas.renderScale",
839
785
  type: "slider",
840
- unit: "x",
841
786
  variant: "discrete",
842
787
  });
843
788
  });
844
789
 
790
+ it("appends timeline visibility to the bottom of setup controls when timeline is enabled", () => {
791
+ const app = defineToolcraft({
792
+ canvas: {
793
+ enabled: true,
794
+ renderScale: true,
795
+ size: { height: 900, unit: "px", width: 1440 },
796
+ sizing: { mode: "editable-output" },
797
+ },
798
+ panels: {
799
+ controls: {
800
+ sections: [],
801
+ title: "Controls",
802
+ },
803
+ timeline: { mode: "playback" },
804
+ },
805
+ });
806
+
807
+ const setupSection = app.panels.controls?.sections[0];
808
+
809
+ expect(Object.keys(setupSection?.controls ?? {})).toEqual([
810
+ "settingsTransfer",
811
+ "canvasAspectRatio",
812
+ "canvasWidth",
813
+ "canvasHeight",
814
+ "canvasRenderScale",
815
+ "timelineExtended",
816
+ ]);
817
+ expect(setupSection?.controls.timelineExtended).toMatchObject({
818
+ defaultValue: false,
819
+ description:
820
+ "Shows the extended runtime timeline with scrubber, duration, loop, and keyframe controls; compact mode keeps only Play visible.",
821
+ label: "Timeline",
822
+ target: "panels.timeline.extended",
823
+ type: "switch",
824
+ });
825
+ });
826
+
827
+ it("does not let app-authored runtime targets suppress mandatory setup controls", () => {
828
+ const app = defineToolcraft({
829
+ canvas: {
830
+ enabled: true,
831
+ renderScale: true,
832
+ size: { height: 1080, unit: "px", width: 1920 },
833
+ sizing: { mode: "editable-output" },
834
+ },
835
+ panels: {
836
+ controls: {
837
+ sections: [
838
+ {
839
+ controls: {
840
+ manualWidth: {
841
+ defaultValue: 1280,
842
+ label: "Width",
843
+ target: "canvas.size.width",
844
+ type: "text",
845
+ },
846
+ manualRenderScale: {
847
+ defaultValue: 1,
848
+ label: "Scale",
849
+ max: 2,
850
+ min: 1,
851
+ target: "canvas.renderScale",
852
+ type: "slider",
853
+ },
854
+ manualTimeline: {
855
+ defaultValue: true,
856
+ label: "Timeline",
857
+ target: "panels.timeline.extended",
858
+ type: "switch",
859
+ },
860
+ },
861
+ title: "Manual Runtime Duplicates",
862
+ },
863
+ ],
864
+ title: "Controls",
865
+ },
866
+ timeline: { mode: "playback" },
867
+ },
868
+ });
869
+
870
+ const [setupSection, duplicateSection] = app.panels.controls?.sections ?? [];
871
+
872
+ expect(Object.keys(setupSection?.controls ?? {})).toEqual([
873
+ "settingsTransfer",
874
+ "canvasAspectRatio",
875
+ "canvasWidth",
876
+ "canvasHeight",
877
+ "canvasRenderScale",
878
+ "timelineExtended",
879
+ ]);
880
+ expect(duplicateSection?.controls.manualWidth?.target).toBe("canvas.size.width");
881
+ expect(duplicateSection?.controls.manualRenderScale?.target).toBe("canvas.renderScale");
882
+ expect(duplicateSection?.controls.manualTimeline?.target).toBe(
883
+ "panels.timeline.extended",
884
+ );
885
+ });
886
+
845
887
  it("can prepend render scale without editable output sizing", () => {
846
888
  const app = defineToolcraft({
847
889
  canvas: {
@@ -874,6 +916,7 @@ describe("defineToolcraft", () => {
874
916
  const intrinsicApp = defineToolcraft({
875
917
  canvas: {
876
918
  enabled: true,
919
+ sizing: { mode: "intrinsic-media" },
877
920
  upload: true,
878
921
  },
879
922
  panels: {
@@ -904,10 +947,14 @@ describe("defineToolcraft", () => {
904
947
 
905
948
  expect(intrinsicApp.canvas.sizing).toEqual({ mode: "intrinsic-media" });
906
949
  expect(fixedApp.canvas.sizing).toEqual({ mode: "fixed-output" });
907
- expect(intrinsicApp.panels.controls?.sections[0]?.title).toBe("Generation");
908
- expect(fixedApp.panels.controls?.sections[0]?.title).toBe("Generation");
950
+ expect(intrinsicApp.panels.controls?.sections[0]?.title).toBe("Setup");
951
+ expect(fixedApp.panels.controls?.sections[0]?.title).toBe("Setup");
952
+ expect(intrinsicApp.panels.controls?.sections[1]?.title).toBe("Generation");
953
+ expect(fixedApp.panels.controls?.sections.at(-1)?.title).toBe("Generation");
909
954
  expect(intrinsicApp.panels.controls?.sections[0]?.controls.canvasWidth).toBeUndefined();
910
955
  expect(fixedApp.panels.controls?.sections[0]?.controls.canvasWidth).toBeUndefined();
956
+ expect(intrinsicApp.panels.controls?.sections[0]?.controls.settingsTransfer).toBeTruthy();
957
+ expect(fixedApp.panels.controls?.sections[0]?.controls.settingsTransfer).toBeTruthy();
911
958
  });
912
959
 
913
960
  it("adds runtime layout groups only for compact numeric text pairs", () => {
@@ -971,7 +1018,7 @@ describe("defineToolcraft", () => {
971
1018
  },
972
1019
  });
973
1020
 
974
- const outputSection = app.panels.controls?.sections[0];
1021
+ const [outputSection] = getProductSections(app);
975
1022
 
976
1023
  expect(outputSection?.layoutGroups).toEqual([
977
1024
  {
@@ -1011,7 +1058,7 @@ describe("defineToolcraft", () => {
1011
1058
  },
1012
1059
  });
1013
1060
 
1014
- const maskSection = app.panels.controls?.sections[0];
1061
+ const [maskSection] = getProductSections(app);
1015
1062
 
1016
1063
  expect(maskSection?.layoutGroups).toEqual([
1017
1064
  {
@@ -1122,7 +1169,7 @@ describe("defineToolcraft", () => {
1122
1169
  },
1123
1170
  });
1124
1171
 
1125
- const [formatPairSection, outputSection] = app.panels.controls?.sections ?? [];
1172
+ const [formatPairSection, outputSection] = getProductSections(app);
1126
1173
 
1127
1174
  expect(formatPairSection?.layoutGroups).toEqual([
1128
1175
  {
@@ -1169,7 +1216,7 @@ describe("defineToolcraft", () => {
1169
1216
  },
1170
1217
  });
1171
1218
 
1172
- const [basicSection, anchorSection] = app.panels.controls?.sections ?? [];
1219
+ const [basicSection, anchorSection] = getProductSections(app);
1173
1220
 
1174
1221
  expect(Object.keys(basicSection?.controls ?? {})).toEqual(["opacity", "threshold"]);
1175
1222
  expect(basicSection?.title).toBe("Basic");
@@ -1212,8 +1259,8 @@ describe("defineToolcraft", () => {
1212
1259
  },
1213
1260
  });
1214
1261
 
1215
- expect(app.panels.controls?.sections[0]?.layout).toBe("standalone");
1216
- expect(app.panels.controls?.sections[0]?.title).toBe("Palette");
1262
+ expect(getProductSections(app)[0]?.layout).toBe("standalone");
1263
+ expect(getProductSections(app)[0]?.title).toBe("Palette");
1217
1264
  });
1218
1265
 
1219
1266
  it("adds semantic implicit titles to standalone color sections", () => {
@@ -1245,8 +1292,8 @@ describe("defineToolcraft", () => {
1245
1292
  },
1246
1293
  });
1247
1294
 
1248
- expect(app.panels.controls?.sections[0]?.layout).toBe("standalone");
1249
- expect(app.panels.controls?.sections[0]?.title).toBe("Fill & Stroke");
1295
+ expect(getProductSections(app)[0]?.layout).toBe("standalone");
1296
+ expect(getProductSections(app)[0]?.title).toBe("Fill & Stroke");
1250
1297
 
1251
1298
  const inferredLayoutApp = defineToolcraft({
1252
1299
  canvas: { enabled: true },
@@ -1269,7 +1316,7 @@ describe("defineToolcraft", () => {
1269
1316
  },
1270
1317
  });
1271
1318
 
1272
- expect(inferredLayoutApp.panels.controls?.sections[0]?.title).toBe("Fill");
1319
+ expect(getProductSections(inferredLayoutApp)[0]?.title).toBe("Fill");
1273
1320
  });
1274
1321
 
1275
1322
  it("replaces generic color section titles with a semantic fallback", () => {
@@ -1345,9 +1392,9 @@ describe("defineToolcraft", () => {
1345
1392
  },
1346
1393
  });
1347
1394
 
1348
- expect(genericColorApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1349
- expect(genericColorsApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1350
- expect(explicitGenericTitleApp.panels.controls?.sections[0]?.title).toBe("Appearance");
1395
+ expect(getProductSections(genericColorApp)[0]?.title).toBe("Appearance");
1396
+ expect(getProductSections(genericColorsApp)[0]?.title).toBe("Appearance");
1397
+ expect(getProductSections(explicitGenericTitleApp)[0]?.title).toBe("Appearance");
1351
1398
  });
1352
1399
 
1353
1400
  it("keeps color controls inside semantic mixed sections", () => {
@@ -1388,15 +1435,64 @@ describe("defineToolcraft", () => {
1388
1435
  },
1389
1436
  });
1390
1437
 
1391
- expect(app.panels.controls?.sections).toHaveLength(1);
1392
- expect(app.panels.controls?.sections[0]?.title).toBe("Square 1 (Right)");
1393
- expect(Object.keys(app.panels.controls?.sections[0]?.controls ?? {})).toEqual([
1438
+ const productSections = getProductSections(app);
1439
+
1440
+ expect(productSections).toHaveLength(1);
1441
+ expect(productSections[0]?.title).toBe("Square 1 (Right)");
1442
+ expect(Object.keys(productSections[0]?.controls ?? {})).toEqual([
1394
1443
  "connections",
1395
1444
  "hoverRadius",
1396
1445
  "color",
1397
1446
  ]);
1398
1447
  });
1399
1448
 
1449
+ it("keeps mode-gated standalone controls inside their semantic section", () => {
1450
+ const app = defineToolcraft({
1451
+ canvas: { enabled: true },
1452
+ panels: {
1453
+ controls: {
1454
+ sections: [
1455
+ {
1456
+ controls: {
1457
+ sourceMode: {
1458
+ defaultValue: "preset",
1459
+ label: "Source",
1460
+ options: [
1461
+ { label: "Preset", value: "preset" },
1462
+ { label: "Image", value: "image" },
1463
+ ],
1464
+ target: "source.mode",
1465
+ type: "segmented",
1466
+ },
1467
+ sourceUpload: {
1468
+ accept: "image/*",
1469
+ assetKind: "image",
1470
+ defaultValue: null,
1471
+ label: "Image",
1472
+ target: "source.upload",
1473
+ type: "fileDrop",
1474
+ visibleWhen: { equals: "image", target: "source.mode" },
1475
+ },
1476
+ },
1477
+ title: "Source",
1478
+ },
1479
+ ],
1480
+ title: "Controls",
1481
+ },
1482
+ },
1483
+ });
1484
+
1485
+ const productSections = getProductSections(app);
1486
+
1487
+ expect(productSections).toHaveLength(1);
1488
+ expect(productSections[0]?.title).toBe("Source");
1489
+ expect(productSections[0]?.layout).toBeUndefined();
1490
+ expect(Object.keys(productSections[0]?.controls ?? {})).toEqual([
1491
+ "sourceMode",
1492
+ "sourceUpload",
1493
+ ]);
1494
+ });
1495
+
1400
1496
  it("preserves controls panel action sections and render metadata", () => {
1401
1497
  const app = defineToolcraft({
1402
1498
  canvas: { enabled: true },
@@ -1449,7 +1545,7 @@ describe("defineToolcraft", () => {
1449
1545
  },
1450
1546
  });
1451
1547
 
1452
- const [slidersSection, actionsSection] = app.panels.controls?.sections ?? [];
1548
+ const [slidersSection, actionsSection] = getProductSections(app);
1453
1549
 
1454
1550
  expect(slidersSection?.controls.opacity?.markerCount).toBe(101);
1455
1551
  expect(slidersSection?.controls.opacity?.variant).toBe("discrete");
@@ -1519,7 +1615,7 @@ describe("defineToolcraft", () => {
1519
1615
  },
1520
1616
  });
1521
1617
 
1522
- const controls = app.panels.controls?.sections[0]?.controls;
1618
+ const controls = getProductSections(app)[0]?.controls;
1523
1619
 
1524
1620
  expect(controls?.speed?.variant).toBeUndefined();
1525
1621
  expect(controls?.speed?.markerCount).toBe(6);