@pixel-point/toolcraft 0.0.7 → 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 (41) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +84 -12
  3. package/templates/runtime/contracts/component-contracts.ts +40 -17
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +31 -0
  6. package/templates/runtime/export/export.ts +41 -0
  7. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  8. package/templates/runtime/react/canvas-shell.tsx +178 -23
  9. package/templates/runtime/react/control-conditions.ts +166 -0
  10. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  11. package/templates/runtime/react/controls-panel.test.tsx +404 -8
  12. package/templates/runtime/react/controls-panel.tsx +90 -4
  13. package/templates/runtime/react/media-file.ts +19 -0
  14. package/templates/runtime/schema/define-toolcraft.test.ts +1 -0
  15. package/templates/runtime/schema/define-toolcraft.ts +4 -2
  16. package/templates/runtime/schema/types.ts +4 -0
  17. package/templates/runtime/state/reducer.test.ts +304 -0
  18. package/templates/runtime/state/reducer.ts +148 -9
  19. package/templates/runtime/state/types.ts +10 -2
  20. package/templates/runtime/testing/performance.test.ts +1282 -48
  21. package/templates/runtime/testing/performance.ts +676 -39
  22. package/templates/starter/AGENTS.md +6 -5
  23. package/templates/starter/docs/toolcraft/acceptance-testing.md +11 -5
  24. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -2
  25. package/templates/starter/docs/toolcraft/component-rules.md +16 -7
  26. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  27. package/templates/starter/docs/toolcraft/performance.md +47 -5
  28. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  29. package/templates/starter/docs/toolcraft/schema-reference.md +6 -6
  30. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  31. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  32. package/templates/starter/e2e/performance-helpers.ts +197 -0
  33. package/templates/starter/package.json +3 -0
  34. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  35. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  36. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  37. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  38. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  39. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  40. package/templates/ui/components/controls/index.ts +1 -0
  41. package/templates/ui/components/panel/panel-section.tsx +53 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixel-point/toolcraft",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "bin": {
@@ -224,6 +224,12 @@ describe("Toolcraft template component contracts", () => {
224
224
  expect(contract.aiUsageRules).toContain(
225
225
  "Do not use a custom control to recreate a built-in Slider, RangeSlider, Select, Segmented, Switch, Checkbox, Color, ColorOpacity, Gradient, FontPicker, ImagePicker, FileDrop, TextInput, CodeTextarea, RangeInput, Palette, Actions, CollectionActions, Curves, AnchorGrid, ChannelMixer, Vector, or PanelActions control.",
226
226
  );
227
+ expect(contract.aiUsageRules).toContain(
228
+ "When a custom control owns a growable, removable, selectable, or reorderable runtime item set, its builtInFitCheck must explicitly check collectionActions and actions before choosing custom; this is based on the value model and user workflow, not on entity names such as masks or glyphs.",
229
+ );
230
+ expect(contract.aiUsageRules).toContain(
231
+ "Do not justify custom controls with icons, layout, styling, compactness, or custom buttons alone. The fit check must name the product interaction or value model that built-ins cannot express.",
232
+ );
227
233
  expect(contract.aiUsageRules).toContain(
228
234
  "Custom controls may use Toolcraft primitives for small app-specific chrome, but must not import or render low-level runtime surfaces or duplicate toolbar, timeline, layers, canvas, panel, or built-in control mechanics.",
229
235
  );
@@ -254,6 +260,12 @@ describe("Toolcraft template component contracts", () => {
254
260
  expect(contract.aiUsageRules).toContain(
255
261
  "Do not use Actions for animation transport; Play, Pause, Resume, Restart, and Scrub belong to the top timeline when timeline behavior exists.",
256
262
  );
263
+ expect(contract.decisionCatalog?.layoutConstraints).toContain(
264
+ "When an Actions button group wraps below its label, the wrapped button row starts at the left edge of the control content instead of centering or staying right-aligned.",
265
+ );
266
+ expect(contract.aiUsageRules).toContain(
267
+ "When an Actions button group no longer fits beside its label and wraps to a second row, align the wrapped button row to the left.",
268
+ );
257
269
  expect(contract.aiUsageRules).toContain(
258
270
  'For local reset-like actions, use product-specific values such as "reset-current-layer" or "reset-palette" and handle them through ToolcraftApp onPanelAction; do not use a bare "reset" value unless the action intentionally runs controls.reset.',
259
271
  );
@@ -286,6 +298,10 @@ describe("Toolcraft template component contracts", () => {
286
298
  "recommendedMaxItems is advisory only and must not disable the plus button. Use hardMaxItems only when a real product, algorithm, API, export, or measured performance limit requires it.",
287
299
  );
288
300
  expect(contract.aiUsageRules.join(" ")).toMatch(/TextInput/);
301
+ expect(contract.aiUsageRules.join(" ")).toMatch(/FontPicker/);
302
+ expect(contract.aiUsageRules).toContain(
303
+ "Use FontPicker as the collection item control when each repeated item is a typography/text-style entity; do not split its font, color, opacity, size, case, letter-spacing, or line-height into sibling collection fields.",
304
+ );
289
305
  });
290
306
 
291
307
  it("documents Palette as a constrained design-token color control", () => {
@@ -568,22 +584,31 @@ describe("Toolcraft template component contracts", () => {
568
584
  'Pair renderer-owned output background color with export.includeBackground in one Background section directly before export settings. Use an equal-width inline row with the export.includeBackground Switch labeled "Include" on the left and the background Color parameter with label false on the right; each control occupies one half of the row.',
569
585
  );
570
586
  expect(color.aiUsageRules).toContain(
571
- "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls only PNG alpha; it must not make live preview, workspace canvas backing, or video transparent.",
587
+ "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls live preview product background and PNG alpha; it must not make the Toolcraft canvas shell/backing or video output transparent.",
572
588
  );
573
589
  expect(color.aiUsageRules).toContain(
574
590
  "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.",
575
591
  );
576
592
  expect(color.aiUsageRules).toContain(
577
- 'Mixed inline rows require visible labels on both controls. The required Background row is the only section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Color fields in other mixed rows must not be unlabeled.',
593
+ 'Mixed inline rows require visible labels on both controls. The required Background row is the section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Palette variation color banks are the other exception when the group/section label already names the color bank.',
578
594
  );
579
595
  expect(color.aiUsageRules).toContain(
580
596
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
581
597
  );
582
598
  expect(color.aiUsageRules).toContain(
583
- "Show visible labels for Color and ColorOpacity controls inside mixed sections that contain any non-color controls.",
599
+ "Decide color label visibility from the user's point of view. Omit labels for color banks that only add palette variety, such as Accent Shades, Bead Colors, or palette.accent1..5.",
600
+ );
601
+ expect(color.aiUsageRules).toContain(
602
+ "Make color label visibility a group-level decision: do not mix labeled and unlabeled items inside one semantic color bank.",
584
603
  );
585
604
  expect(color.aiUsageRules).toContain(
586
- "Omit visible color field labels only when the section contains color controls and no other control types.",
605
+ "Keep visible labels when colors edit distinct user-facing entities or roles, such as Fill, Stroke, Background, Connector, Object, or Highlight.",
606
+ );
607
+ expect(color.aiUsageRules).toContain(
608
+ "A color bank can share a section with distribution controls such as Spread, Mix, or Randomness and still keep each color item unlabeled when the section title names the palette context.",
609
+ );
610
+ expect(color.aiUsageRules).toContain(
611
+ "If a multi-color bank has an odd trailing plain Color, keep that last Color at half width; only ColorOpacity or intentionally full-width compound controls occupy a full row.",
587
612
  );
588
613
  expect(gradient.aiUsageRules).toContain(
589
614
  "Gradient is a compound control; acceptance must prove gradient.gradientType, gradient.angle, gradient.stops.position, gradient.stops.color, and gradient.stops.opacity all affect the product output when visible.",
@@ -600,6 +625,9 @@ describe("Toolcraft template component contracts", () => {
600
625
  expect(fontPicker.aiUsageRules).toContain(
601
626
  'Do not recreate FontPicker with a plain Select plus separate sliders; use type: "fontPicker" so the popup mechanics and footer controls stay intact.',
602
627
  );
628
+ expect(fontPicker.aiUsageRules).toContain(
629
+ "FontPicker standard/default text color is #FFFFFF with opacity 100; omit color/opacity or use those values unless the prompt or reference explicitly requires a different initial text color.",
630
+ );
603
631
  expect(fontPicker.aiUsageRules).toContain(
604
632
  "Any product text controlled by FontPicker must render fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, and opacity in preview and export; do not leave typography values as panel-only runtime state.",
605
633
  );
@@ -743,14 +771,20 @@ describe("Toolcraft template component contracts", () => {
743
771
  "Custom renderers must define performance budgets for media import, preview updates, control drags, and export/copy before implementation.",
744
772
  );
745
773
  expect(contract.aiUsageRules).toContain(
746
- "Controls that change renderer workload, such as Char Size, Grid Density, Matrix Scale, Sample Count, Resolution, Blur Radius, Iterations, Particle Count, or Quality, must be tested at min, default, and max values.",
774
+ "Controls that change renderer workload by changing output dimensions, element count, density, sample count, iteration count, blur/filter radius, shader branch cost, media processing, text layout, or export quality must be tested at min, default, and max values.",
747
775
  );
748
776
  expect(contract.aiUsageRules).toContain(
749
- "Hash differs is not enough for workload controls; tests must assert semantic direction, for example smaller Char Size increases glyph/cell density and larger Char Size decreases it.",
777
+ "For workload control scenarios, stressFixture is the tested control value. If the app has an independent heavy baseline such as large media, long text, many items, high render scale, or dense source state, declare workloadFixture and apply it before the measured control interaction.",
778
+ );
779
+ expect(contract.aiUsageRules).toContain(
780
+ "Hash differs is not enough for workload controls; tests must assert semantic direction, such as density increasing item count or size changes reducing/increasing rendered cells.",
750
781
  );
751
782
  expect(contract.aiUsageRules).toContain(
752
783
  "Performance tests must use representative fixtures and the same renderer/export path as the running app, not only tiny 32px fixtures or isolated helper state.",
753
784
  );
785
+ expect(contract.aiUsageRules).toContain(
786
+ 'Media-import workload fixtures and media workload baselines must use fixture kind "media" with numeric width and height at least 1920x1080-equivalent; 640x480 preview fixtures cannot satisfy upload, effect-control, or image-processing performance coverage.',
787
+ );
754
788
  expect(contract.aiUsageRules).toContain(
755
789
  "Slider drags and high-frequency controls must debounce or coalesce preview work, cancel stale async renders, and avoid re-decoding media on every control change.",
756
790
  );
@@ -790,8 +824,19 @@ describe("Toolcraft template component contracts", () => {
790
824
  expect(contract.aiUsageRules).toContain(
791
825
  'productRepresentation "mixed" is valid only when rendererTechnique.layers proves at least two different content families.',
792
826
  );
827
+ expect(contract.aiUsageRules).toContain(
828
+ "Custom renderer apps must declare rendererPipeline with render passes, cache keys, execution location, preview/export quality, and interaction invalidation before implementation.",
829
+ );
830
+ expect(contract.aiUsageRules).toContain(
831
+ "Render Pipeline Inventory must explain which runtime targets invalidate each expensive pass; high-frequency interactions such as animation frames, drag, zoom, pan, timeline playback, and mask movement must not invalidate upstream decode/preprocess/pixel-transform work unless that target truly changes the upstream result.",
832
+ );
833
+ expect(contract.aiUsageRules).toContain(
834
+ "Cache-sensitive render passes such as decode, preprocess, pixel-transform, text-layout, rasterize, and composite must declare cache keys so tests can reject full recomputation on every control change.",
835
+ );
793
836
  expect(contract.aiUsageRules.join("\n")).toMatch(/rendererTechnique/);
794
837
  expect(contract.aiUsageRules.join("\n")).toMatch(/rendererTechnique\.layers/);
838
+ expect(contract.aiUsageRules.join("\n")).toMatch(/rendererPipeline/);
839
+ expect(contract.aiUsageRules.join("\n")).toMatch(/Render Pipeline Inventory/);
795
840
  expect(contract.aiUsageRules.join("\n")).toMatch(/sourceRepresentation/);
796
841
  expect(contract.aiUsageRules.join("\n")).toMatch(/productRepresentation/);
797
842
  expect(contract.aiUsageRules.join("\n")).toMatch(/previewRenderer/);
@@ -809,11 +854,14 @@ describe("Toolcraft template component contracts", () => {
809
854
  "Text-output and vector-output visible previews must preserve native output fidelity. Do not render a low-resolution offscreen canvas or texture and upscale it to the product size.",
810
855
  );
811
856
  expect(contract.aiUsageRules).toContain(
812
- "Pixel-output renderers must use WebGL or WebGPU even when the scene is static.",
857
+ "Pixel-output renderers must treat WebGL/WebGPU as the default candidate even when the scene is static; Canvas 2D is allowed only when measured worst-case evidence shows the CPU path preserves quality and remains responsive.",
813
858
  );
814
859
  expect(contract.aiUsageRules).toContain(
815
860
  "Procedural pixel renderers, shader-like effects, animated mesh gradients, and large exportable previews should use WebGL or WebGPU for pixel work instead of main-thread ImageData loops.",
816
861
  );
862
+ expect(contract.aiUsageRules).toContain(
863
+ "Detail-heavy Canvas 2D pixel/media renderers may stay on CPU only when rendererTechnique records measured stress evidence for rejecting WebGL/WebGPU; if the heavy media stress fails, move pixel work to GPU instead of lowering quality.",
864
+ );
817
865
  expect(contract.aiUsageRules).toContain(
818
866
  "WebGL and WebGPU renderers must initialize contexts, programs, shaders, pipelines, textures, and large buffers once, then update uniforms or stable buffers when controls change.",
819
867
  );
@@ -830,11 +878,14 @@ describe("Toolcraft template component contracts", () => {
830
878
  "Animated preview renderers must suspend or coalesce non-essential animation work while the user drags, pans, pinches, zooms, or centers the canvas viewport, then resume from the correct timeline or autonomous time without changing the user's play/pause state.",
831
879
  );
832
880
  expect(contract.aiUsageRules).toContain(
833
- "If a generated app uses ImageData, getImageData, or putImageData for procedural output, performance validation must fail unless the renderer is converted to GPU rendering or the CPU path is removed.",
881
+ "If a generated app uses ImageData, getImageData, or putImageData for procedural output, performance validation must fail unless rendererTechnique records measured WebGL/WebGPU comparison evidence that the CPU path preserves quality and responsiveness, or the renderer is moved to GPU.",
834
882
  );
835
883
  expect(contract.aiUsageRules).toContain(
836
884
  "Performance matrices must declare rendererStrategy so tests can distinguish none, dom, svg, canvas-2d, webgl, and webgpu renderer paths.",
837
885
  );
886
+ expect(contract.aiUsageRules).toContain(
887
+ "If a renderer cannot meet the budget, first optimize renderer technique, caching, invalidation, scheduling, and critical-path work. Only change exposed product ranges, work units, or controls after recording measured evidence that the requested quality ceiling is impossible; do not silently reduce product quality to pass budgets.",
888
+ );
838
889
  });
839
890
 
840
891
  it("documents reference runtime clone mode as a tested composition contract", () => {
@@ -1156,10 +1207,10 @@ describe("Toolcraft template component contracts", () => {
1156
1207
  'Product-output apps must expose a dedicated "Background" section directly before the first export settings section. With PNG export that first section is Image Export; with video-only export it is Video Export.',
1157
1208
  );
1158
1209
  expect(contract.aiUsageRules).toContain(
1159
- "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
1210
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas for PNG alpha and call shouldIncludeToolcraftPreviewBackground(state) for live preview product background.",
1160
1211
  );
1161
1212
  expect(contract.aiUsageRules).toContain(
1162
- "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently without making live preview, workspace canvas backing, or video transparent.",
1213
+ "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently; turning Include off makes preview product background and PNG alpha transparent without hiding the Toolcraft canvas backing or video background.",
1163
1214
  );
1164
1215
  expect(contract.aiUsageRules).toContain(
1165
1216
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
@@ -1184,15 +1235,33 @@ describe("Toolcraft template component contracts", () => {
1184
1235
  it("documents file upload ownership across single-layer and multi-layer apps", () => {
1185
1236
  const contract = getToolcraftComponentContract("fileDrop");
1186
1237
 
1187
- expect(contract.commands).toEqual(["media.delete", "media.import"]);
1238
+ expect(contract.commands).toEqual(["media.delete", "media.import", "media.reorder"]);
1239
+ expect(contract.aiUsageRules).toContain(
1240
+ 'Use fileDrop with assetKind: "image" for image-only source media and assetKind: "file" for arbitrary uploaded files.',
1241
+ );
1188
1242
  expect(contract.aiUsageRules).toContain(
1189
1243
  "In single-layer apps, the runtime shows the uploaded image as the fileDrop preview and provides the clear action.",
1190
1244
  );
1245
+ expect(contract.aiUsageRules).toContain(
1246
+ "In file mode, the runtime shows uploaded files as a sortable list with paperclip icons, file names, remove buttons, and --border/5 separators.",
1247
+ );
1248
+ expect(contract.aiUsageRules).toContain(
1249
+ "In single-layer apps, global Reset controls and section reset must remove uploaded fileDrop source media and return the fileDrop target to defaultValue.",
1250
+ );
1191
1251
  expect(contract.aiUsageRules).toContain(
1192
1252
  "Use fileDrop with multiple: true when the app needs several uploaded images as one source set; do not build a custom thumbnail uploader for this.",
1193
1253
  );
1194
1254
  expect(contract.aiUsageRules).toContain(
1195
- "When multiple uploaded images are present, the runtime appends media, shows a four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
1255
+ "When multiple uploaded images are present, the runtime appends media, shows a sortable four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
1256
+ );
1257
+ expect(contract.aiUsageRules).toContain(
1258
+ "Canvas drops route to the first visible matching fileDrop target by asset kind: image files prefer image uploaders, non-image files prefer file uploaders, and file uploaders accept images only when no image uploader matches.",
1259
+ );
1260
+ expect(contract.aiUsageRules).toContain(
1261
+ "Dragging thumbnails reorders runtime mediaAssets; preview, export, and renderer mapping must consume that media order instead of maintaining a separate product-only order.",
1262
+ );
1263
+ expect(contract.aiUsageRules).toContain(
1264
+ "Do not create custom upload buttons, file lists, or file sorting for generic source uploads when fileDrop can represent the source set.",
1196
1265
  );
1197
1266
  expect(contract.aiUsageRules).toContain(
1198
1267
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
@@ -1226,6 +1295,9 @@ describe("Toolcraft template component contracts", () => {
1226
1295
  expect(contract.aiUsageRules).toContain(
1227
1296
  "Render multiple related color fields in one section with at most two colors per row.",
1228
1297
  );
1298
+ expect(contract.aiUsageRules).toContain(
1299
+ "If a multi-color bank has an odd trailing plain Color, keep that last Color at half width; only ColorOpacity or intentionally full-width compound controls occupy a full row.",
1300
+ );
1229
1301
  });
1230
1302
 
1231
1303
  it("documents CodeTextarea as generic multiline text input", () => {
@@ -375,6 +375,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
375
375
  layoutConstraints: [
376
376
  "Keep local actions close to the entity they affect.",
377
377
  "Keep action labels short and scoped by the section title; prefer Randomize, Clear, Sort, Normalize, Duplicate, or Reset when the section already names the target.",
378
+ "When an Actions button group wraps below its label, the wrapped button row starts at the left edge of the control content instead of centering or staying right-aligned.",
378
379
  ],
379
380
  requiredAcceptance: [
380
381
  "Prove each action dispatches the intended command or product side effect for the nearby entity only.",
@@ -388,6 +389,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
388
389
  "Do not use Actions for final product delivery actions; use sticky panelActions for Export, Copy, Download, Generate, or Apply.",
389
390
  "Do not use Actions for global reset; the controls panel header owns global reset.",
390
391
  "Do not use Actions for animation transport; Play, Pause, Resume, Restart, and Scrub belong to the top timeline when timeline behavior exists.",
392
+ "When an Actions button group no longer fits beside its label and wraps to a second row, align the wrapped button row to the left.",
391
393
  'For local reset-like actions, use product-specific values such as "reset-current-layer" or "reset-palette" and handle them through ToolcraftApp onPanelAction; do not use a bare "reset" value unless the action intentionally runs controls.reset.',
392
394
  "Acceptance and browser tests must click each Actions button and prove the product output or runtime state for the nearby entity changed.",
393
395
  ],
@@ -435,7 +437,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
435
437
  "Adding or removing collection items must update the runtime target array consumed by the renderer and export; do not add panel-only items.",
436
438
  "Do not model add/remove item behavior with a Slider count when users need to edit the actual items.",
437
439
  "recommendedMaxItems is advisory only and must not disable the plus button. Use hardMaxItems only when a real product, algorithm, API, export, or measured performance limit requires it.",
438
- "CollectionActions item controls use built-in controls whenever possible, such as Color, ColorOpacity, TextInput, Select, Segmented, Slider, Switch, Checkbox, or RangeInput.",
440
+ "CollectionActions item controls use built-in controls whenever possible, such as Color, ColorOpacity, TextInput, Select, Segmented, Slider, Switch, Checkbox, RangeInput, or FontPicker.",
441
+ "Use FontPicker as the collection item control when each repeated item is a typography/text-style entity; do not split its font, color, opacity, size, case, letter-spacing, or line-height into sibling collection fields.",
439
442
  "Do not add visible labels like Color 1, Color 2, Item 1, or Item 2 for homogeneous collection items when the collection label already explains the group.",
440
443
  "Use compact half-width item layout whenever the child control is allowed to fit in a half row; color items without opacity are the default two-column case.",
441
444
  "Acceptance must add and remove items through the browser UI and prove canvas/export output follows the changed collection.",
@@ -501,8 +504,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
501
504
  "Video export browser coverage must load the exported blob metadata and prove video.duration matches the edited runtime timeline duration; blobSize/blobType checks alone are not enough.",
502
505
  "Video export must report frame-based progress through reportProgress during render/encode steps. PNG export should report phase progress for render, blob, and handoff when those phases are asynchronous.",
503
506
  'Product-output apps must expose a dedicated "Background" section directly before the first export settings section. With PNG export that first section is Image Export; with video-only export it is Video Export.',
504
- "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas only for PNG alpha.",
505
- "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently without making live preview, workspace canvas backing, or video transparent.",
507
+ "Product-output apps must pass the includeBackground runtime value to createToolcraftPngExportCanvas for PNG alpha and call shouldIncludeToolcraftPreviewBackground(state) for live preview product background.",
508
+ "PNG export must use createToolcraftPngExportCanvas so background transparency and selected image dimensions or retina fallback are applied consistently; turning Include off makes preview product background and PNG alpha transparent without hiding the Toolcraft canvas backing or video background.",
506
509
  "Video export must keep product background and use getToolcraftRetinaExportSize for retina dimensions.",
507
510
  "Copy PNG can be a secondary action when clipboard output is useful, but copy does not replace export.",
508
511
  "Add Copy PNG as a secondary action only when the prompt/reference includes clipboard output or the product clearly benefits from paste/share workflows.",
@@ -621,7 +624,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
621
624
  ],
622
625
  layoutConstraints: [
623
626
  "Plain Color controls may render two per row only when no opacity is present.",
624
- "Color fields in mixed sections need visible labels.",
627
+ "Odd trailing plain Color controls in a multi-color bank keep the same half-width footprint as paired colors.",
628
+ "Color label visibility depends on user usefulness: palette variation banks omit per-item labels; distinct color roles keep labels.",
625
629
  ],
626
630
  requiredAcceptance: [
627
631
  "Prove the selected color affects product output, preview, or export.",
@@ -633,18 +637,21 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
633
637
  "Keep a color inside a section when it configures the same entity as nearby controls. Example: Square 1 (Right) contains Connections, Hover radius, and Color in one section.",
634
638
  "Use a standalone color section only when the color itself is the whole semantic section; the section title must describe the product role such as Background, Object, Connector, Accent, Gradient, or Brand.",
635
639
  "When color belongs to the same object or effect as nearby controls, keep it inside that section and use a concise field label that is unambiguous in context, such as Color in a Square section or Symbol color in a mixed Style section.",
636
- "Show visible labels for Color and ColorOpacity controls inside mixed sections that contain any non-color controls.",
637
- "Omit visible color field labels only when the section contains color controls and no other control types.",
640
+ "Decide color label visibility from the user's point of view. Omit labels for color banks that only add palette variety, such as Accent Shades, Bead Colors, or palette.accent1..5.",
641
+ "Make color label visibility a group-level decision: do not mix labeled and unlabeled items inside one semantic color bank.",
642
+ "Keep visible labels when colors edit distinct user-facing entities or roles, such as Fill, Stroke, Background, Connector, Object, or Highlight.",
643
+ "A color bank can share a section with distribution controls such as Spread, Mix, or Randomness and still keep each color item unlabeled when the section title names the palette context.",
638
644
  "The standalone default applies only to color-only sections; mixed semantic sections keep color grouped with nearby controls.",
639
645
  "Never use generic Color or Colors as a generated section title. If no meaningful color role exists and the colors are just basic colors, use a neutral section title such as Appearance instead of omitting the title.",
640
646
  "Do not split a grouped object section into a separate generated Color section; if the color role is unclear, ask the user before implementation.",
641
647
  "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.",
642
- 'Mixed inline rows require visible labels on both controls. The required Background row is the only section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Color fields in other mixed rows must not be unlabeled.',
648
+ 'Mixed inline rows require visible labels on both controls. The required Background row is the section-title-owned exception: use the Switch label "Include" and set the background Color control label to false. Palette variation color banks are the other exception when the group/section label already names the color bank.',
643
649
  "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.",
644
650
  "Product-output apps always expose renderer-owned output background color as a schema color target such as appearance.background or scene.background.",
645
651
  'Pair renderer-owned output background color with export.includeBackground in one Background section directly before export settings. Use an equal-width inline row with the export.includeBackground Switch labeled "Include" on the left and the background Color parameter with label false on the right; each control occupies one half of the row.',
646
- "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls only PNG alpha; it must not make live preview, workspace canvas backing, or video transparent.",
652
+ "Preview, PNG export, and video export must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. export.includeBackground controls live preview product background and PNG alpha; it must not make the Toolcraft canvas shell/backing or video output transparent.",
647
653
  "Render multiple related color fields in one section with at most two colors per row.",
654
+ "If a multi-color bank has an odd trailing plain Color, keep that last Color at half width; only ColorOpacity or intentionally full-width compound controls occupy a full row.",
648
655
  ],
649
656
  },
650
657
  gradient: {
@@ -725,6 +732,7 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
725
732
  "FontPicker owns the font preview select, virtualized font popup, category filters, search, preview loading, font-weight select, font-size input, text-case select, text color/opacity control, letter-spacing slider, and line-height slider.",
726
733
  "Do not recreate FontPicker with a plain Select plus separate sliders; use type: \"fontPicker\" so the popup mechanics and footer controls stay intact.",
727
734
  "Use one object value with fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, and opacity. Keep typography renderers wired to all eight parts.",
735
+ "FontPicker standard/default text color is #FFFFFF with opacity 100; omit color/opacity or use those values unless the prompt or reference explicitly requires a different initial text color.",
728
736
  "Any product text controlled by FontPicker must render fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, and opacity in preview and export; do not leave typography values as panel-only runtime state.",
729
737
  "FontPicker is an atomic compound typography control. Do not split any owned typography part into a neighboring schema control for the same product text entity.",
730
738
  "Do not put a help tooltip on FontPicker just to list its owned fields. If the section title and FontPicker labels already make the text target clear, omit description.",
@@ -863,20 +871,27 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
863
871
  ],
864
872
  layoutConstraints: [
865
873
  "FileDrop lives in the controls panel; single-layer apps use its preview and clear behavior.",
866
- "When fileDrop has multiple: true and more than one image is present, the runtime renders a four-column thumbnail grid with the add-more tile last.",
874
+ "When fileDrop has multiple: true and more than one image is present, the runtime renders a sortable four-column thumbnail grid with the add-more tile last.",
875
+ "When fileDrop has assetKind: file, the runtime renders a sortable file list with a paperclip icon, filename, remove button, and row separators using --border/5.",
867
876
  ],
868
877
  requiredAcceptance: [
869
- "Prove file import changes media state and product output; prove clear removes source material.",
878
+ "Prove file import changes media state and product output; prove clear, reorder, and section/global reset update source material.",
870
879
  ],
871
880
  }),
872
881
  aiUsageRules: [
873
882
  "Use fileDrop for source material uploads in the controls panel, not on the canvas.",
883
+ 'Use fileDrop with assetKind: "image" for image-only source media and assetKind: "file" for arbitrary uploaded files.',
874
884
  "In single-layer apps, the runtime shows the uploaded image as the fileDrop preview and provides the clear action.",
885
+ "In file mode, the runtime shows uploaded files as a sortable list with paperclip icons, file names, remove buttons, and --border/5 separators.",
886
+ "In single-layer apps, global Reset controls and section reset must remove uploaded fileDrop source media and return the fileDrop target to defaultValue.",
875
887
  "Use fileDrop with multiple: true when the app needs several uploaded images as one source set; do not build a custom thumbnail uploader for this.",
876
- "When multiple uploaded images are present, the runtime appends media, shows a four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
888
+ "When multiple uploaded images are present, the runtime appends media, shows a sortable four-column preview grid, puts the add-more tile last, and exposes per-image removal.",
889
+ "Canvas drops route to the first visible matching fileDrop target by asset kind: image files prefer image uploaders, non-image files prefer file uploaders, and file uploaders accept images only when no image uploader matches.",
890
+ "Dragging thumbnails reorders runtime mediaAssets; preview, export, and renderer mapping must consume that media order instead of maintaining a separate product-only order.",
891
+ "Do not create custom upload buttons, file lists, or file sorting for generic source uploads when fileDrop can represent the source set.",
877
892
  "In multi-layer apps, deletion and visibility belong to the Layers panel; fileDrop remains an upload target.",
878
893
  ],
879
- commands: ["media.delete", "media.import"],
894
+ commands: ["media.delete", "media.import", "media.reorder"],
880
895
  },
881
896
  imagePicker: {
882
897
  ...control("imagePicker", "ImagePicker", "standalone", "component-owned"),
@@ -978,6 +993,8 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
978
993
  aiUsageRules: [
979
994
  "Use custom controls only for product interactions that built-in controls cannot express.",
980
995
  "Do not use a custom control to recreate a built-in Slider, RangeSlider, Select, Segmented, Switch, Checkbox, Color, ColorOpacity, Gradient, FontPicker, ImagePicker, FileDrop, TextInput, CodeTextarea, RangeInput, Palette, Actions, CollectionActions, Curves, AnchorGrid, ChannelMixer, Vector, or PanelActions control.",
996
+ "When a custom control owns a growable, removable, selectable, or reorderable runtime item set, its builtInFitCheck must explicitly check collectionActions and actions before choosing custom; this is based on the value model and user workflow, not on entity names such as masks or glyphs.",
997
+ "Do not justify custom controls with icons, layout, styling, compactness, or custom buttons alone. The fit check must name the product interaction or value model that built-ins cannot express.",
981
998
  "Custom controls may use Toolcraft primitives for small app-specific chrome, but must not import or render low-level runtime surfaces or duplicate toolbar, timeline, layers, canvas, panel, or built-in control mechanics.",
982
999
  "Custom controls must render the minimum UI needed to understand the value, context, and available actions; avoid decorative metadata and text that repeats what the section, label, or visible item already explains.",
983
1000
  "Every visible custom-control element must justify its space by enabling selection, ordering, preview, removal, upload, editing, or status that affects the product.",
@@ -1101,9 +1118,11 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1101
1118
  performanceAcceptance: {
1102
1119
  aiUsageRules: [
1103
1120
  "Custom renderers must define performance budgets for media import, preview updates, control drags, and export/copy before implementation.",
1104
- "Controls that change renderer workload, such as Char Size, Grid Density, Matrix Scale, Sample Count, Resolution, Blur Radius, Iterations, Particle Count, or Quality, must be tested at min, default, and max values.",
1105
- "Hash differs is not enough for workload controls; tests must assert semantic direction, for example smaller Char Size increases glyph/cell density and larger Char Size decreases it.",
1121
+ "Controls that change renderer workload by changing output dimensions, element count, density, sample count, iteration count, blur/filter radius, shader branch cost, media processing, text layout, or export quality must be tested at min, default, and max values.",
1122
+ "For workload control scenarios, stressFixture is the tested control value. If the app has an independent heavy baseline such as large media, long text, many items, high render scale, or dense source state, declare workloadFixture and apply it before the measured control interaction.",
1123
+ "Hash differs is not enough for workload controls; tests must assert semantic direction, such as density increasing item count or size changes reducing/increasing rendered cells.",
1106
1124
  "Performance tests must use representative fixtures and the same renderer/export path as the running app, not only tiny 32px fixtures or isolated helper state.",
1125
+ "Media-import workload fixtures and media workload baselines must use fixture kind \"media\" with numeric width and height at least 1920x1080-equivalent; 640x480 preview fixtures cannot satisfy upload, effect-control, or image-processing performance coverage.",
1107
1126
  "Expensive renderers must cache decoded media, source pixels, glyph atlases, gradients, and other reusable inputs by media id, canvas size, and stable control keys.",
1108
1127
  "Slider drags and high-frequency controls must debounce or coalesce preview work, cancel stale async renders, and avoid re-decoding media on every control change.",
1109
1128
  "Performance matrices must declare rendererWorkload as none, simple-composition, text-output, vector-output, or pixel-output.",
@@ -1114,6 +1133,9 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1114
1133
  "Renderer specs must include a Renderer Technique Decision Matrix with sourceRepresentation, productRepresentation, previewRenderer, exportRenderer, rendererWorkload, rendererStrategy, whyNotAlternativeStrategies, fidelityRisks, and performanceRisks.",
1115
1134
  "Custom renderer apps must mirror the Renderer Technique Decision Matrix in typed rendererTechnique config so validation can reject contradictory renderer choices.",
1116
1135
  "Custom renderer specs must include a Renderer Layer Inventory and mirror it in typed rendererTechnique.layers so dense raster backgrounds cannot silently rasterize semantic foreground output.",
1136
+ "Custom renderer apps must declare rendererPipeline with render passes, cache keys, execution location, preview/export quality, and interaction invalidation before implementation.",
1137
+ "Render Pipeline Inventory must explain which runtime targets invalidate each expensive pass; high-frequency interactions such as animation frames, drag, zoom, pan, timeline playback, and mask movement must not invalidate upstream decode/preprocess/pixel-transform work unless that target truly changes the upstream result.",
1138
+ "Cache-sensitive render passes such as decode, preprocess, pixel-transform, text-layout, rasterize, and composite must declare cache keys so tests can reject full recomputation on every control change.",
1117
1139
  "Semantic foreground output such as product lines, shapes, icons, text, object bounds, and meaningful markers should use DOM or SVG by default; dense raster backgrounds do not justify rasterizing low-count foreground geometry or text.",
1118
1140
  "Editing handles must be DOM/SVG overlays, excluded from export, and written through runtime state instead of being drawn into the product raster layer.",
1119
1141
  "Product foreground and editing handle renderer layers must declare uiSelector so browser tests can verify the visible layer exists.",
@@ -1122,17 +1144,18 @@ export const TOOLCRAFT_COMPONENT_CONTRACTS = {
1122
1144
  "Do not switch renderer technology just because it seems more modern or faster. Preview and export may use different renderers only when the decision matrix explains why and export/copy remains product-quality.",
1123
1145
  "Choose renderer workload by product fidelity before choosing rendering technology: ASCII, glyph grids, code art, subtitles, typography, or monospace text products are text-output unless the product intentionally rasterizes them into per-pixel effects.",
1124
1146
  "Text-output and vector-output visible previews must preserve native output fidelity. Do not render a low-resolution offscreen canvas or texture and upscale it to the product size.",
1125
- "Pixel-output renderers must use WebGL or WebGPU even when the scene is static.",
1147
+ "Pixel-output renderers must treat WebGL/WebGPU as the default candidate even when the scene is static; Canvas 2D is allowed only when measured worst-case evidence shows the CPU path preserves quality and remains responsive.",
1126
1148
  "Procedural pixel renderers, shader-like effects, animated mesh gradients, and large exportable previews should use WebGL or WebGPU for pixel work instead of main-thread ImageData loops.",
1149
+ "Detail-heavy Canvas 2D pixel/media renderers may stay on CPU only when rendererTechnique records measured stress evidence for rejecting WebGL/WebGPU; if the heavy media stress fails, move pixel work to GPU instead of lowering quality.",
1127
1150
  "WebGL and WebGPU renderers must initialize contexts, programs, shaders, pipelines, textures, and large buffers once, then update uniforms or stable buffers when controls change.",
1128
1151
  "For keyframe or playback renderers, texture upload and media decode must be keyed to source media/resource changes, not to timeline time or evaluated settings. Timeline-only updates must reuse decoded media and existing GPU resources.",
1129
1152
  "Do not create WebGL/WebGPU contexts, shader programs, textures, or requestAnimationFrame loops directly in the React render path.",
1130
1153
  "Animation loops must cancel scheduled frames during cleanup.",
1131
1154
  "Animated preview renderers must suspend or coalesce non-essential animation work while the user drags, pans, pinches, zooms, or centers the canvas viewport, then resume from the correct timeline or autonomous time without changing the user's play/pause state.",
1132
- "If a generated app uses ImageData, getImageData, or putImageData for procedural output, performance validation must fail unless the renderer is converted to GPU rendering or the CPU path is removed.",
1155
+ "If a generated app uses ImageData, getImageData, or putImageData for procedural output, performance validation must fail unless rendererTechnique records measured WebGL/WebGPU comparison evidence that the CPU path preserves quality and responsiveness, or the renderer is moved to GPU.",
1133
1156
  "Performance matrices must declare rendererStrategy so tests can distinguish none, dom, svg, canvas-2d, webgl, and webgpu renderer paths.",
1134
1157
  "Browser verification must interact with the actual UI, exercise worst-case control values, and fail if the app freezes, creates runaway render loops, drops canvas zoom/offset, or misses the performance budget.",
1135
- "If a renderer cannot meet the budget, reduce the work units, clamp the control range, move work off the critical path, or remove the control.",
1158
+ "If a renderer cannot meet the budget, first optimize renderer technique, caching, invalidation, scheduling, and critical-path work. Only change exposed product ranges, work units, or controls after recording measured evidence that the requested quality ceiling is impossible; do not silently reduce product quality to pass budgets.",
1136
1159
  ],
1137
1160
  capabilities: ["performance-budgets", "workload-control-tests"],
1138
1161
  commands: [],
@@ -177,7 +177,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
177
177
  currentConstraint:
178
178
  "Product-output apps expose final output delivery through sticky footer panelActions.",
179
179
  desiredBehavior:
180
- 'Static products include Export PNG plus an "Image Export" section for format and 2K/4K/8K resolution; animated products include Export Video and Export PNG plus "Video Export" settings. Copy can be secondary, but it does not replace export. Product apps expose a required "Background" section directly before export settings, with a Switch labeled "Include" and a background color control with label false in one equal-width row. Standard export helpers own runtime PNG transparency and selected image dimensions or retina fallback, while live preview, workspace canvas backing, and video keep the background.',
180
+ 'Static products include Export PNG plus an "Image Export" section for format and 2K/4K/8K resolution; animated products include Export Video and Export PNG plus "Video Export" settings. Copy can be secondary, but it does not replace export. Product apps expose a required "Background" section directly before export settings, with a Switch labeled "Include" and a background color control with label false in one equal-width row. Standard helpers own runtime PNG transparency, live preview product-background visibility, and selected image dimensions or retina fallback, while Toolcraft canvas backing and video keep the background.',
181
181
  enforcement: ["acceptance-validator", "performance-validator", "browser-helper", "starter-agents"],
182
182
  id: "output-export-required",
183
183
  level: "invariant",
@@ -8,6 +8,7 @@ import {
8
8
  getToolcraftRetinaExportPixelRatio,
9
9
  getToolcraftRetinaExportSize,
10
10
  shouldIncludeToolcraftExportBackground,
11
+ shouldIncludeToolcraftPreviewBackground,
11
12
  } from "./export";
12
13
 
13
14
  function createState(schema = defineToolcraft({ canvas: { enabled: true }, panels: {} })): ToolcraftState {
@@ -89,6 +90,36 @@ describe("Toolcraft export helpers", () => {
89
90
  expect(shouldIncludeToolcraftExportBackground({ format: "video", schema })).toBe(true);
90
91
  });
91
92
 
93
+ it("uses include-background runtime state for live preview background", () => {
94
+ const state = createState();
95
+
96
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(true);
97
+
98
+ state.values["export.includeBackground"] = false;
99
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(false);
100
+
101
+ state.values["export.includeBackground"] = true;
102
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(true);
103
+ });
104
+
105
+ it("keeps video export background independent from preview include state", () => {
106
+ const schema = defineToolcraft({
107
+ canvas: { enabled: true },
108
+ export: {
109
+ png: {
110
+ background: "transparent",
111
+ },
112
+ },
113
+ panels: {},
114
+ });
115
+ const state = createState(schema);
116
+
117
+ state.values["export.includeBackground"] = false;
118
+
119
+ expect(shouldIncludeToolcraftPreviewBackground({ state })).toBe(false);
120
+ expect(shouldIncludeToolcraftExportBackground({ format: "video", schema })).toBe(true);
121
+ });
122
+
92
123
  it("uses at least 2x pixel ratio for retina export", () => {
93
124
  expect(getToolcraftRetinaExportPixelRatio(1)).toBe(2);
94
125
  expect(getToolcraftRetinaExportPixelRatio(1.5)).toBe(2);
@@ -16,6 +16,11 @@ export type ToolcraftExportBackgroundOptions = {
16
16
  schema: ResolvedToolcraftAppSchema;
17
17
  };
18
18
 
19
+ export type ToolcraftPreviewBackgroundOptions = {
20
+ includeBackgroundTarget?: string;
21
+ state: ToolcraftState;
22
+ };
23
+
19
24
  export type ToolcraftExportSizeOptions = {
20
25
  devicePixelRatio?: number;
21
26
  state: ToolcraftState;
@@ -129,6 +134,42 @@ export function shouldIncludeToolcraftExportBackground({
129
134
  return schema.export.png.background !== "transparent";
130
135
  }
131
136
 
137
+ export function shouldIncludeToolcraftPreviewBackground({
138
+ includeBackgroundTarget = "export.includeBackground",
139
+ state,
140
+ }: ToolcraftPreviewBackgroundOptions): boolean {
141
+ const includeBackgroundValue = state.values[includeBackgroundTarget];
142
+
143
+ if (typeof includeBackgroundValue === "boolean") {
144
+ return includeBackgroundValue;
145
+ }
146
+
147
+ if (typeof includeBackgroundValue === "string") {
148
+ const normalizedValue = includeBackgroundValue.trim().toLowerCase();
149
+
150
+ if (
151
+ normalizedValue === "false" ||
152
+ normalizedValue === "off" ||
153
+ normalizedValue === "no" ||
154
+ normalizedValue === "transparent" ||
155
+ normalizedValue === "exclude"
156
+ ) {
157
+ return false;
158
+ }
159
+
160
+ if (
161
+ normalizedValue === "true" ||
162
+ normalizedValue === "on" ||
163
+ normalizedValue === "yes" ||
164
+ normalizedValue === "include"
165
+ ) {
166
+ return true;
167
+ }
168
+ }
169
+
170
+ return true;
171
+ }
172
+
132
173
  export function createToolcraftPngExportCanvas({
133
174
  background = "#000000",
134
175
  canvasFactory = () => document.createElement("canvas"),
@@ -43,7 +43,13 @@ function CanvasStateProbe() {
43
43
  <span data-testid="canvas-zoom">{state.canvas.zoom}</span>
44
44
  <span data-testid="media-count">{state.mediaAssets.length}</span>
45
45
  <span data-testid="media-size">
46
- {state.mediaAssets[0]?.size.width ?? 0},{state.mediaAssets[0]?.size.height ?? 0}
46
+ {state.mediaAssets[0]?.size?.width ?? 0},{state.mediaAssets[0]?.size?.height ?? 0}
47
+ </span>
48
+ <span data-testid="media-targets">
49
+ {state.mediaAssets.map((asset) => asset.sourceTarget ?? "none").join(",")}
50
+ </span>
51
+ <span data-testid="media-kinds">
52
+ {state.mediaAssets.map((asset) => asset.assetKind ?? "image").join(",")}
47
53
  </span>
48
54
  </>
49
55
  );
@@ -376,6 +382,76 @@ describe("CanvasShell", () => {
376
382
  expect(screen.getByRole("button", { name: "Select material.png" })).toBeTruthy();
377
383
  });
378
384
 
385
+ it("routes canvas drops to image and file upload targets by asset kind", async () => {
386
+ const schema = defineToolcraft({
387
+ canvas: {
388
+ enabled: true,
389
+ size: { width: 300, height: 200, unit: "px" },
390
+ upload: true,
391
+ },
392
+ panels: {
393
+ controls: {
394
+ sections: [
395
+ {
396
+ controls: {
397
+ files: {
398
+ assetKind: "file",
399
+ label: "Files",
400
+ multiple: true,
401
+ target: "source.files",
402
+ type: "fileDrop",
403
+ },
404
+ image: {
405
+ label: "Image",
406
+ target: "source.image",
407
+ type: "fileDrop",
408
+ },
409
+ },
410
+ title: "Sources",
411
+ },
412
+ ],
413
+ title: "Controls",
414
+ },
415
+ },
416
+ });
417
+
418
+ render(
419
+ <ToolcraftRoot schema={schema}>
420
+ <div style={{ height: 640, position: "relative", width: 640 }}>
421
+ <CanvasShell />
422
+ <CanvasStateProbe />
423
+ </div>
424
+ </ToolcraftRoot>,
425
+ );
426
+
427
+ const canvas = screen.getByRole("application", { name: "Canvas viewport" });
428
+ const imageFile = new File(["image"], "material.png", { type: "image/png" });
429
+ const textFile = new File(["notes"], "notes.txt", { type: "text/plain" });
430
+
431
+ fireEvent.drop(canvas, {
432
+ clientX: 0,
433
+ clientY: 0,
434
+ dataTransfer: {
435
+ files: [imageFile, textFile],
436
+ },
437
+ });
438
+
439
+ await waitFor(() => {
440
+ expect(screen.getByTestId("media-count").textContent).toBe("2");
441
+ });
442
+
443
+ expect(screen.getByTestId("media-targets").textContent?.split(",").sort()).toEqual([
444
+ "source.files",
445
+ "source.image",
446
+ ]);
447
+ expect(screen.getByTestId("media-kinds").textContent?.split(",").sort()).toEqual([
448
+ "file",
449
+ "image",
450
+ ]);
451
+ expect(screen.getByRole("button", { name: "Select material.png" })).toBeTruthy();
452
+ expect(screen.queryByRole("button", { name: "Select notes.txt" })).toBeNull();
453
+ });
454
+
379
455
  it("hides media when a parent layer group is hidden", () => {
380
456
  render(
381
457
  <ToolcraftRoot