@pixel-point/toolcraft 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +1 -1
  2. package/src/generate.mjs +34 -5
  3. package/src/generate.test.mjs +12 -0
  4. package/src/package-json.mjs +15 -0
  5. package/src/package-json.test.mjs +14 -1
  6. package/templates/runtime/contracts/component-contracts.test.ts +185 -23
  7. package/templates/runtime/contracts/component-contracts.ts +91 -36
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +4 -4
  10. package/templates/runtime/export/export.test.ts +31 -0
  11. package/templates/runtime/export/export.ts +41 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  13. package/templates/runtime/react/canvas-shell.tsx +178 -23
  14. package/templates/runtime/react/control-conditions.ts +166 -0
  15. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  16. package/templates/runtime/react/controls-panel.test.tsx +774 -17
  17. package/templates/runtime/react/controls-panel.tsx +155 -8
  18. package/templates/runtime/react/media-file.ts +19 -0
  19. package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
  20. package/templates/runtime/schema/define-toolcraft.ts +29 -3
  21. package/templates/runtime/schema/types.ts +7 -0
  22. package/templates/runtime/state/reducer.test.ts +304 -0
  23. package/templates/runtime/state/reducer.ts +148 -9
  24. package/templates/runtime/state/types.ts +10 -2
  25. package/templates/runtime/testing/performance.test.ts +1424 -56
  26. package/templates/runtime/testing/performance.ts +710 -43
  27. package/templates/starter/AGENTS.md +10 -9
  28. package/templates/starter/docs/toolcraft/README.md +1 -1
  29. package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
  30. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
  31. package/templates/starter/docs/toolcraft/component-rules.md +46 -16
  32. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  33. package/templates/starter/docs/toolcraft/performance.md +54 -6
  34. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  35. package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
  36. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  37. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  38. package/templates/starter/e2e/performance-helpers.ts +197 -0
  39. package/templates/starter/gitignore +1 -0
  40. package/templates/starter/package.json +5 -0
  41. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  42. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  43. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  44. package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
  45. package/templates/starter/src/app/starter-acceptance.ts +740 -24
  46. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  47. package/templates/ui/components/control-layout/index.tsx +8 -3
  48. package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
  49. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  50. package/templates/ui/components/controls/color/index.ts +4 -1
  51. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  52. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  53. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  54. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  55. package/templates/ui/components/controls/index.ts +3 -0
  56. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  57. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  58. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  59. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  60. package/templates/ui/components/controls/vector/index.ts +1 -0
  61. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  62. package/templates/ui/components/panel/panel-section.tsx +82 -6
@@ -7,6 +7,7 @@ import {
7
7
  getColorSurfaceStyle,
8
8
  getColorSurfaceThumbPosition,
9
9
  getSurfaceHsvColor,
10
+ StyleGuideColorPicker,
10
11
  } from "@repo/ui/controls";
11
12
  import * as React from "react";
12
13
  import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -248,6 +249,9 @@ function StateProbe() {
248
249
  {state.canvas.size.width},{state.canvas.size.height}
249
250
  </span>
250
251
  <span data-testid="media-count">{state.mediaAssets.length}</span>
252
+ <span data-testid="media-ids">
253
+ {state.mediaAssets.map((asset) => asset.id).join(",")}
254
+ </span>
251
255
  <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
252
256
  <span data-testid="timeline-keyframes">
253
257
  {JSON.stringify(state.timeline.keyframeGroups)}
@@ -384,6 +388,89 @@ describe("ControlsPanel", () => {
384
388
  expect(inlineGroup?.textContent).toContain("Snap Y");
385
389
  });
386
390
 
391
+ it("renders local action buttons as a two-column grid below their label", () => {
392
+ const schema = defineToolcraft({
393
+ canvas: { enabled: false },
394
+ panels: {
395
+ controls: {
396
+ sections: [
397
+ {
398
+ controls: {
399
+ masks: {
400
+ actions: [
401
+ { label: "Rect", value: "mask.add.rectangle" },
402
+ { label: "Circle", value: "mask.add.circle" },
403
+ { label: "Tri", value: "mask.add.triangle" },
404
+ { label: "Delete", value: "mask.delete" },
405
+ ],
406
+ defaultValue: [],
407
+ label: "Masks",
408
+ target: "composition.masks",
409
+ type: "actions",
410
+ },
411
+ },
412
+ title: "Composition",
413
+ },
414
+ ],
415
+ title: "Controls",
416
+ },
417
+ },
418
+ });
419
+ const { container } = renderControlsPanelWithSchema(schema);
420
+ const actionField = container.querySelector('[data-slot="actions-control"]');
421
+ const actionButtons = container.querySelector(
422
+ '[data-slot="actions-control-buttons"]',
423
+ );
424
+
425
+ expect(actionField?.getAttribute("data-orientation")).toBe("vertical");
426
+ expect(actionField?.className).not.toContain("justify-between");
427
+ expect(actionButtons?.className).toContain("grid");
428
+ expect(actionButtons?.className).toContain("w-full");
429
+ expect(actionButtons?.className).toContain("grid-cols-2");
430
+ expect(actionButtons?.className).not.toContain("justify-start");
431
+ expect(actionButtons?.className).not.toContain("ml-auto");
432
+ for (const label of ["Rect", "Circle", "Tri", "Delete"]) {
433
+ expect(screen.getByRole("button", { name: label }).className).toContain(
434
+ "w-full",
435
+ );
436
+ }
437
+ });
438
+
439
+ it("keeps a single local action button at half row width", () => {
440
+ const schema = defineToolcraft({
441
+ canvas: { enabled: false },
442
+ panels: {
443
+ controls: {
444
+ sections: [
445
+ {
446
+ controls: {
447
+ paletteActions: {
448
+ actions: [{ label: "Randomize", value: "palette.randomize" }],
449
+ defaultValue: null,
450
+ label: "Actions",
451
+ target: "palette.actions",
452
+ type: "actions",
453
+ },
454
+ },
455
+ title: "Palette",
456
+ },
457
+ ],
458
+ title: "Controls",
459
+ },
460
+ },
461
+ });
462
+ const { container } = renderControlsPanelWithSchema(schema);
463
+ const actionButtons = container.querySelector<HTMLElement>(
464
+ '[data-slot="actions-control-buttons"]',
465
+ );
466
+ const button = screen.getByRole("button", { name: "Randomize" });
467
+
468
+ expect(actionButtons?.getAttribute("data-actions-count")).toBe("1");
469
+ expect(actionButtons?.className).toContain("w-1/2");
470
+ expect(actionButtons?.className).toContain("grid-cols-1");
471
+ expect(button.className).toContain("w-full");
472
+ });
473
+
387
474
  it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
388
475
  const schema = defineToolcraft({
389
476
  canvas: { enabled: false },
@@ -431,6 +518,7 @@ describe("ControlsPanel", () => {
431
518
  expect(screen.getByText("Include")).toBeTruthy();
432
519
  expect(screen.getByRole("switch")).toBeTruthy();
433
520
  expect(toggleParameterGroup).toBeTruthy();
521
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
434
522
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
435
523
  "repeat(2, minmax(0, 1fr))",
436
524
  );
@@ -448,35 +536,35 @@ describe("ControlsPanel", () => {
448
536
  defaultValue: { hex: "#DFFF1A" },
449
537
  description: "Sets the first bead color.",
450
538
  label: "Color 1",
451
- target: "beads.color1",
539
+ target: "palette.accent1",
452
540
  type: "color",
453
541
  },
454
542
  color2: {
455
543
  defaultValue: { hex: "#8CFF3A" },
456
544
  description: "Sets the second bead color.",
457
545
  label: "Color 2",
458
- target: "beads.color2",
546
+ target: "palette.accent2",
459
547
  type: "color",
460
548
  },
461
549
  color3: {
462
550
  defaultValue: { hex: "#F4FF5A" },
463
551
  description: "Sets the third bead color.",
464
552
  label: "Color 3",
465
- target: "beads.color3",
553
+ target: "palette.accent3",
466
554
  type: "color",
467
555
  },
468
556
  color4: {
469
557
  defaultValue: { hex: "#B8FF2E" },
470
558
  description: "Sets the fourth bead color.",
471
559
  label: "Color 4",
472
- target: "beads.color4",
560
+ target: "palette.accent4",
473
561
  type: "color",
474
562
  },
475
563
  color5: {
476
564
  defaultValue: { hex: "#ECFF68" },
477
565
  description: "Sets the fifth bead color.",
478
566
  label: "Color 5",
479
- target: "beads.color5",
567
+ target: "palette.accent5",
480
568
  type: "color",
481
569
  },
482
570
  colorSpread: {
@@ -486,7 +574,7 @@ describe("ControlsPanel", () => {
486
574
  label: "Spread",
487
575
  max: 100,
488
576
  min: 0,
489
- target: "beads.colorSpread",
577
+ target: "palette.spread",
490
578
  type: "slider",
491
579
  unit: "%",
492
580
  },
@@ -503,7 +591,7 @@ describe("ControlsPanel", () => {
503
591
  layout: "inline",
504
592
  },
505
593
  ],
506
- title: "Bead Colors",
594
+ title: "Accent Shades",
507
595
  },
508
596
  {
509
597
  controls: {
@@ -573,13 +661,62 @@ describe("ControlsPanel", () => {
573
661
  );
574
662
 
575
663
  expect(screen.getByText("Loop")).toBeTruthy();
576
- expect(screen.getByText("Duration")).toBeTruthy();
664
+ expect(screen.queryByText("Duration")).toBeNull();
577
665
  expect(toggleParameterGroup).toBeTruthy();
666
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
578
667
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
579
668
  "repeat(2, minmax(0, 1fr))",
580
669
  );
581
670
  });
582
671
 
672
+ it("keeps segmented controls full-width even when an inline row is requested", () => {
673
+ const schema = defineToolcraft({
674
+ canvas: { enabled: false },
675
+ panels: {
676
+ controls: {
677
+ sections: [
678
+ {
679
+ controls: {
680
+ includeText: {
681
+ defaultValue: true,
682
+ label: "Include",
683
+ target: "text.enabled",
684
+ type: "switch",
685
+ },
686
+ dragTarget: {
687
+ defaultValue: "glass",
688
+ label: "Drag",
689
+ options: [
690
+ { label: "Glass", value: "glass" },
691
+ { label: "Text", value: "text" },
692
+ ],
693
+ target: "text.dragTarget",
694
+ type: "segmented",
695
+ },
696
+ },
697
+ layoutGroups: [
698
+ {
699
+ columns: 2,
700
+ controls: ["includeText", "dragTarget"],
701
+ layout: "inline",
702
+ },
703
+ ],
704
+ title: "Glass Text",
705
+ },
706
+ ],
707
+ title: "Controls",
708
+ },
709
+ },
710
+ });
711
+
712
+ const { container } = renderControlsPanelWithSchema(schema);
713
+
714
+ expect(screen.getByText("Include")).toBeTruthy();
715
+ expect(screen.getByText("Drag")).toBeTruthy();
716
+ expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
717
+ expect(container.querySelector('[data-slot="toggle-group"]')).toBeTruthy();
718
+ });
719
+
583
720
  it("renders compact select pairs inline with stacked full-width fields", () => {
584
721
  const schema = defineToolcraft({
585
722
  canvas: { enabled: false },
@@ -634,6 +771,7 @@ describe("ControlsPanel", () => {
634
771
  );
635
772
 
636
773
  expect(inlineGroup).toBeTruthy();
774
+ expect(inlineGroup?.className).toContain("gap-x-2.5");
637
775
  expect(screen.getByText("Format")).toBeTruthy();
638
776
  expect(screen.getByText("Resolution")).toBeTruthy();
639
777
  expect(triggers).toHaveLength(2);
@@ -1102,7 +1240,6 @@ describe("ControlsPanel", () => {
1102
1240
  controls: {
1103
1241
  font: {
1104
1242
  defaultValue: {
1105
- color: "#FFFFFF",
1106
1243
  fontId: "inter",
1107
1244
  fontSize: 16,
1108
1245
  fontWeight: "400",
@@ -1487,6 +1624,246 @@ describe("ControlsPanel", () => {
1487
1624
  "toneBias",
1488
1625
  "default",
1489
1626
  ]);
1627
+ expect(
1628
+ [...container.querySelectorAll("[data-vector-pad-coordinate-mode]")].map((pad) =>
1629
+ pad.getAttribute("data-vector-pad-coordinate-mode"),
1630
+ ),
1631
+ ).toEqual([
1632
+ "cartesian",
1633
+ "cartesian",
1634
+ "cartesian",
1635
+ "cartesian",
1636
+ "screen",
1637
+ ]);
1638
+ });
1639
+
1640
+ it("maps default spatial vector pad movement to screen coordinates", () => {
1641
+ const schema = defineToolcraft({
1642
+ canvas: { enabled: true },
1643
+ panels: {
1644
+ controls: {
1645
+ sections: [
1646
+ {
1647
+ controls: {
1648
+ position: {
1649
+ defaultValue: { x: "0.00", y: "0.00" },
1650
+ label: "Position",
1651
+ target: "geometry.position",
1652
+ type: "vector",
1653
+ },
1654
+ },
1655
+ title: "Geometry",
1656
+ },
1657
+ ],
1658
+ title: "Controls",
1659
+ },
1660
+ },
1661
+ });
1662
+
1663
+ renderControlsPanelWithSchema(schema);
1664
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1665
+
1666
+ expect(pad.getAttribute("data-vector-pad-coordinate-mode")).toBe("screen");
1667
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1668
+ bottom: 100,
1669
+ height: 100,
1670
+ left: 0,
1671
+ right: 100,
1672
+ toJSON: () => undefined,
1673
+ top: 0,
1674
+ width: 100,
1675
+ x: 0,
1676
+ y: 0,
1677
+ } as DOMRect);
1678
+ Object.defineProperty(pad, "setPointerCapture", {
1679
+ configurable: true,
1680
+ value: vi.fn(),
1681
+ });
1682
+
1683
+ fireEvent.pointerDown(pad, {
1684
+ buttons: 1,
1685
+ clientX: 0,
1686
+ clientY: 0,
1687
+ pointerId: 1,
1688
+ });
1689
+
1690
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1691
+ expect(values["geometry.position"]).toEqual({ x: "-1.00", y: "-1.00" });
1692
+ });
1693
+
1694
+ it("locks vector pad movement to the dominant axis while shift is held", () => {
1695
+ const schema = defineToolcraft({
1696
+ canvas: { enabled: true },
1697
+ panels: {
1698
+ controls: {
1699
+ sections: [
1700
+ {
1701
+ controls: {
1702
+ position: {
1703
+ defaultValue: { x: "0.00", y: "0.00" },
1704
+ label: "Position",
1705
+ target: "geometry.position",
1706
+ type: "vector",
1707
+ },
1708
+ },
1709
+ title: "Geometry",
1710
+ },
1711
+ ],
1712
+ title: "Controls",
1713
+ },
1714
+ },
1715
+ });
1716
+
1717
+ renderControlsPanelWithSchema(schema);
1718
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1719
+
1720
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1721
+ bottom: 100,
1722
+ height: 100,
1723
+ left: 0,
1724
+ right: 100,
1725
+ toJSON: () => undefined,
1726
+ top: 0,
1727
+ width: 100,
1728
+ x: 0,
1729
+ y: 0,
1730
+ } as DOMRect);
1731
+ Object.defineProperty(pad, "setPointerCapture", {
1732
+ configurable: true,
1733
+ value: vi.fn(),
1734
+ });
1735
+
1736
+ fireEvent.pointerDown(pad, {
1737
+ buttons: 1,
1738
+ clientX: 50,
1739
+ clientY: 50,
1740
+ pointerId: 1,
1741
+ });
1742
+ fireEvent.pointerMove(pad, {
1743
+ buttons: 1,
1744
+ clientX: 90,
1745
+ clientY: 70,
1746
+ pointerId: 1,
1747
+ shiftKey: true,
1748
+ });
1749
+
1750
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1751
+ expect(values["geometry.position"]).toEqual({ x: "0.80", y: "0.00" });
1752
+ });
1753
+
1754
+ it("locks vector pad movement vertically when shift drag is y-dominant", () => {
1755
+ const schema = defineToolcraft({
1756
+ canvas: { enabled: true },
1757
+ panels: {
1758
+ controls: {
1759
+ sections: [
1760
+ {
1761
+ controls: {
1762
+ position: {
1763
+ defaultValue: { x: "0.00", y: "0.00" },
1764
+ label: "Position",
1765
+ target: "geometry.position",
1766
+ type: "vector",
1767
+ },
1768
+ },
1769
+ title: "Geometry",
1770
+ },
1771
+ ],
1772
+ title: "Controls",
1773
+ },
1774
+ },
1775
+ });
1776
+
1777
+ renderControlsPanelWithSchema(schema);
1778
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1779
+
1780
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1781
+ bottom: 100,
1782
+ height: 100,
1783
+ left: 0,
1784
+ right: 100,
1785
+ toJSON: () => undefined,
1786
+ top: 0,
1787
+ width: 100,
1788
+ x: 0,
1789
+ y: 0,
1790
+ } as DOMRect);
1791
+ Object.defineProperty(pad, "setPointerCapture", {
1792
+ configurable: true,
1793
+ value: vi.fn(),
1794
+ });
1795
+
1796
+ fireEvent.pointerDown(pad, {
1797
+ buttons: 1,
1798
+ clientX: 50,
1799
+ clientY: 50,
1800
+ pointerId: 1,
1801
+ });
1802
+ fireEvent.pointerMove(pad, {
1803
+ buttons: 1,
1804
+ clientX: 70,
1805
+ clientY: 90,
1806
+ pointerId: 1,
1807
+ shiftKey: true,
1808
+ });
1809
+
1810
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1811
+ expect(values["geometry.position"]).toEqual({ x: "0.00", y: "0.80" });
1812
+ });
1813
+
1814
+ it("allows cartesian vector pads when a product intentionally uses y-up math", () => {
1815
+ const schema = defineToolcraft({
1816
+ canvas: { enabled: true },
1817
+ panels: {
1818
+ controls: {
1819
+ sections: [
1820
+ {
1821
+ controls: {
1822
+ vector: {
1823
+ coordinateMode: "cartesian",
1824
+ defaultValue: { x: "0.00", y: "0.00" },
1825
+ label: "Vector",
1826
+ target: "math.vector",
1827
+ type: "vector",
1828
+ },
1829
+ },
1830
+ title: "Math",
1831
+ },
1832
+ ],
1833
+ title: "Controls",
1834
+ },
1835
+ },
1836
+ });
1837
+
1838
+ renderControlsPanelWithSchema(schema);
1839
+ const pad = screen.getByRole("button", { name: "Vector X/Y pad" });
1840
+
1841
+ expect(pad.getAttribute("data-vector-pad-coordinate-mode")).toBe("cartesian");
1842
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1843
+ bottom: 100,
1844
+ height: 100,
1845
+ left: 0,
1846
+ right: 100,
1847
+ toJSON: () => undefined,
1848
+ top: 0,
1849
+ width: 100,
1850
+ x: 0,
1851
+ y: 0,
1852
+ } as DOMRect);
1853
+ Object.defineProperty(pad, "setPointerCapture", {
1854
+ configurable: true,
1855
+ value: vi.fn(),
1856
+ });
1857
+
1858
+ fireEvent.pointerDown(pad, {
1859
+ buttons: 1,
1860
+ clientX: 0,
1861
+ clientY: 0,
1862
+ pointerId: 1,
1863
+ });
1864
+
1865
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1866
+ expect(values["math.vector"]).toEqual({ x: "-1.00", y: "1.00" });
1490
1867
  });
1491
1868
 
1492
1869
  it("keeps explanatory label text in the native title instead of the visible label", () => {
@@ -1716,7 +2093,6 @@ describe("ControlsPanel", () => {
1716
2093
  step: 0.1,
1717
2094
  target: "animation.speed",
1718
2095
  type: "slider",
1719
- unit: "x",
1720
2096
  },
1721
2097
  },
1722
2098
  layoutGroups: [
@@ -1743,7 +2119,7 @@ describe("ControlsPanel", () => {
1743
2119
  expect(fpsSlider).toBeTruthy();
1744
2120
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1745
2121
  expect(container.textContent).toContain("17 fps");
1746
- expect(container.textContent).toContain("3.7x");
2122
+ expect(container.textContent).toContain("3.7");
1747
2123
  });
1748
2124
 
1749
2125
  it("passes schema disabled state into slider controls", () => {
@@ -2501,6 +2877,22 @@ describe("ControlsPanel", () => {
2501
2877
  );
2502
2878
  expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2503
2879
  expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2880
+ expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2");
2881
+
2882
+ const firstPreview = screen
2883
+ .getByRole("img", { name: "one.svg" })
2884
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2885
+ const secondPreview = screen
2886
+ .getByRole("img", { name: "two.svg" })
2887
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2888
+
2889
+ if (!firstPreview || !secondPreview) {
2890
+ throw new Error("Expected multi-image previews to render");
2891
+ }
2892
+
2893
+ expect(firstPreview.getAttribute("role")).toBe("button");
2894
+ expect(firstPreview.getAttribute("tabindex")).toBe("0");
2895
+ expect(firstPreview.className).toContain("cursor-grab");
2504
2896
 
2505
2897
  fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2506
2898
 
@@ -2508,6 +2900,118 @@ describe("ControlsPanel", () => {
2508
2900
  expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2509
2901
  });
2510
2902
 
2903
+ it("renders arbitrary file uploads as a sortable file list", () => {
2904
+ const schema = defineToolcraft({
2905
+ canvas: { enabled: true, upload: true },
2906
+ panels: {
2907
+ controls: {
2908
+ sections: [
2909
+ {
2910
+ controls: {
2911
+ source: {
2912
+ assetKind: "file",
2913
+ label: "Files",
2914
+ multiple: true,
2915
+ target: "input.files",
2916
+ type: "fileDrop",
2917
+ },
2918
+ },
2919
+ layout: "standalone",
2920
+ title: "Input",
2921
+ },
2922
+ ],
2923
+ title: "Generation Controls",
2924
+ },
2925
+ },
2926
+ });
2927
+
2928
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2929
+ layers: [
2930
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2931
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2932
+ ],
2933
+ mediaAssets: [
2934
+ {
2935
+ assetKind: "file",
2936
+ dataUrl: "data:text/plain;base64,b25l",
2937
+ fileName: "one.txt",
2938
+ id: "media-1",
2939
+ layerId: "layer-1",
2940
+ mimeType: "text/plain",
2941
+ position: { x: 0, y: 0 },
2942
+ sourceTarget: "input.files",
2943
+ },
2944
+ {
2945
+ assetKind: "file",
2946
+ dataUrl: "data:application/json;base64,e30=",
2947
+ fileName: "two.json",
2948
+ id: "media-2",
2949
+ layerId: "layer-2",
2950
+ mimeType: "application/json",
2951
+ position: { x: 0, y: 0 },
2952
+ sourceTarget: "input.files",
2953
+ },
2954
+ ],
2955
+ selectedLayerId: "layer-2",
2956
+ });
2957
+
2958
+ const fileList = container.querySelector('[data-slot="file-upload-file-list"]');
2959
+ const fileRows = container.querySelectorAll('[data-slot="file-upload-file-item"]');
2960
+ const fileDividers = container.querySelectorAll('[data-slot="file-upload-file-divider"]');
2961
+
2962
+ expect(fileList).toBeTruthy();
2963
+ expect(fileRows).toHaveLength(2);
2964
+ expect(fileDividers).toHaveLength(2);
2965
+ expect(screen.getByText("one.txt")).toBeTruthy();
2966
+ expect(screen.getByText("two.json")).toBeTruthy();
2967
+ expect(screen.getByText("one.txt").className).toContain("text-xs");
2968
+ expect(screen.getByText("one.txt").className).not.toContain("truncate");
2969
+ expect(screen.getByText("one.txt").className).toContain("mask-image");
2970
+ expect(screen.getByRole("button", { name: "Remove one.txt" })).toBeTruthy();
2971
+ expect(screen.getByRole("button", { name: "Add a new file" })).toBeTruthy();
2972
+ expect(fileRows[0]?.className).not.toContain("border-b");
2973
+ expect(fileRows[0]?.className).toContain("hover:bg");
2974
+ expect(fileDividers[0]?.className).toContain("var(--border)_5%");
2975
+ expect(fileRows[0]?.getAttribute("role")).toBe("button");
2976
+ expect(fileRows[0]?.getAttribute("tabindex")).toBe("0");
2977
+
2978
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.json" }));
2979
+
2980
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2981
+ expect(screen.queryByText("two.json")).toBeNull();
2982
+ });
2983
+
2984
+ it("renders the arbitrary file uploader empty state", () => {
2985
+ const schema = defineToolcraft({
2986
+ canvas: { enabled: true, upload: true },
2987
+ panels: {
2988
+ controls: {
2989
+ sections: [
2990
+ {
2991
+ controls: {
2992
+ source: {
2993
+ assetKind: "file",
2994
+ label: "Files",
2995
+ target: "input.files",
2996
+ type: "fileDrop",
2997
+ },
2998
+ },
2999
+ layout: "standalone",
3000
+ title: "Input",
3001
+ },
3002
+ ],
3003
+ title: "Generation Controls",
3004
+ },
3005
+ },
3006
+ });
3007
+
3008
+ renderControlsPanelWithSchema(schema);
3009
+
3010
+ expect(screen.getByText("Click to upload a file")).toBeTruthy();
3011
+ expect(screen.getByText("or drag it onto the canvas")).toBeTruthy();
3012
+ expect(screen.queryByText("Click to upload an image")).toBeNull();
3013
+ });
3014
+
2511
3015
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2512
3016
  const schema = defineToolcraft({
2513
3017
  canvas: { enabled: true, upload: true },
@@ -2574,6 +3078,65 @@ describe("ControlsPanel", () => {
2574
3078
  expect(colorControlList?.children).toHaveLength(1);
2575
3079
  });
2576
3080
 
3081
+ it("keeps an odd trailing plain color half-width in a color bank", () => {
3082
+ const schema = defineToolcraft({
3083
+ canvas: { enabled: true },
3084
+ panels: {
3085
+ controls: {
3086
+ sections: [
3087
+ {
3088
+ controls: {
3089
+ accent1: {
3090
+ defaultValue: { hex: "#9CE6FF" },
3091
+ label: "Accent 1",
3092
+ target: "accent.shade1",
3093
+ type: "color",
3094
+ },
3095
+ accent2: {
3096
+ defaultValue: { hex: "#FF7A90" },
3097
+ label: "Accent 2",
3098
+ target: "accent.shade2",
3099
+ type: "color",
3100
+ },
3101
+ accent3: {
3102
+ defaultValue: { hex: "#FFD166" },
3103
+ label: "Accent 3",
3104
+ target: "accent.shade3",
3105
+ type: "color",
3106
+ },
3107
+ accent4: {
3108
+ defaultValue: { hex: "#8BF38B" },
3109
+ label: "Accent 4",
3110
+ target: "accent.shade4",
3111
+ type: "color",
3112
+ },
3113
+ accent5: {
3114
+ defaultValue: { hex: "#BDA8FF" },
3115
+ label: "Accent 5",
3116
+ target: "accent.shade5",
3117
+ type: "color",
3118
+ },
3119
+ },
3120
+ title: "Accent Shades",
3121
+ },
3122
+ ],
3123
+ title: "Generation Controls",
3124
+ },
3125
+ },
3126
+ });
3127
+
3128
+ const { container } = renderControlsPanelWithSchema(schema);
3129
+ const pairedColorRows = container.querySelectorAll('[data-slot="color-control-grid"]');
3130
+ const trailingColorButtonGroup = screen
3131
+ .getByLabelText("Accent 5 hex")
3132
+ .closest('[data-slot="button-group"]');
3133
+ const trailingColorWidthContainer = trailingColorButtonGroup?.parentElement;
3134
+
3135
+ expect(pairedColorRows).toHaveLength(2);
3136
+ expect(trailingColorWidthContainer?.className).toContain("w-1/2");
3137
+ expect(trailingColorWidthContainer?.className).not.toContain("w-full");
3138
+ });
3139
+
2577
3140
  it("omits visible color field labels only inside color-only sections", () => {
2578
3141
  const schema = defineToolcraft({
2579
3142
  canvas: { enabled: true },
@@ -2715,6 +3278,35 @@ describe("ControlsPanel", () => {
2715
3278
  });
2716
3279
  });
2717
3280
 
3281
+ it("applies color picker slider changes while dragging", async () => {
3282
+ const onChange = vi.fn();
3283
+
3284
+ render(<StyleGuideColorPicker value="#C1FF00" onChange={onChange} />);
3285
+
3286
+ const hueSlider = screen.getByRole("slider", { name: "Color hue" });
3287
+ Object.defineProperty(hueSlider, "getBoundingClientRect", {
3288
+ configurable: true,
3289
+ value: () => ({
3290
+ bottom: 18,
3291
+ height: 18,
3292
+ left: 0,
3293
+ right: 360,
3294
+ top: 0,
3295
+ width: 360,
3296
+ x: 0,
3297
+ y: 0,
3298
+ toJSON: () => ({}),
3299
+ }),
3300
+ });
3301
+
3302
+ fireEvent.pointerDown(hueSlider, { clientX: 0 });
3303
+ fireEvent.pointerMove(window, { clientX: 180 });
3304
+
3305
+ await waitFor(() => {
3306
+ expect(onChange).toHaveBeenLastCalledWith("#00ffff");
3307
+ });
3308
+ });
3309
+
2718
3310
  it("keeps two color opacity controls stacked even when an inline row is requested", () => {
2719
3311
  const schema = defineToolcraft({
2720
3312
  canvas: { enabled: true },
@@ -3187,19 +3779,17 @@ describe("ControlsPanel", () => {
3187
3779
  expect(gradientControlItem?.className).toContain(
3188
3780
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3189
3781
  );
3190
- expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe(
3191
- "both",
3192
- );
3782
+ expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe("top");
3193
3783
  expect(fontControlItem?.className).toContain(
3194
3784
  "has-data-[control-section-divider=compound]:pt-[18px]",
3195
3785
  );
3196
- expect(fontControlItem?.className).toContain(
3786
+ expect(fontControlItem?.className).not.toContain(
3197
3787
  "has-data-[control-section-divider=compound]:pb-[18px]",
3198
3788
  );
3199
3789
  expect(fontControlItem?.className).toContain(
3200
3790
  "has-data-[control-section-divider=compound]:before:inset-x-3",
3201
3791
  );
3202
- expect(fontControlItem?.className).toContain(
3792
+ expect(fontControlItem?.className).not.toContain(
3203
3793
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3204
3794
  );
3205
3795
  });
@@ -3341,6 +3931,54 @@ describe("ControlsPanel", () => {
3341
3931
  expect(container.querySelector('[data-control-section-reset-button]')).toBeTruthy();
3342
3932
  });
3343
3933
 
3934
+ it("clears file upload previews from the selected section reset action", () => {
3935
+ const schema = defineToolcraft({
3936
+ canvas: { enabled: true, upload: true },
3937
+ panels: {
3938
+ controls: {
3939
+ sections: [
3940
+ {
3941
+ controls: {
3942
+ source: {
3943
+ defaultValue: null,
3944
+ label: "Image",
3945
+ target: "media.source",
3946
+ type: "fileDrop",
3947
+ },
3948
+ },
3949
+ title: "Source Image",
3950
+ },
3951
+ ],
3952
+ title: "Controls",
3953
+ },
3954
+ },
3955
+ });
3956
+
3957
+ renderControlsPanelWithSchema(schema, {}, {
3958
+ mediaAssets: [
3959
+ {
3960
+ dataUrl:
3961
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E",
3962
+ fileName: "source.svg",
3963
+ id: "media-1",
3964
+ layerId: "layer-1",
3965
+ mimeType: "image/svg+xml",
3966
+ position: { x: 0, y: 0 },
3967
+ size: { height: 300, unit: "px", width: 400 },
3968
+ sourceTarget: "media.source",
3969
+ },
3970
+ ],
3971
+ });
3972
+
3973
+ expect(screen.getByRole("img", { name: "source.svg" })).toBeTruthy();
3974
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3975
+
3976
+ fireEvent.click(screen.getByRole("button", { name: "Reset Source Image section" }));
3977
+
3978
+ expect(screen.queryByRole("img", { name: "source.svg" })).toBeNull();
3979
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
3980
+ });
3981
+
3344
3982
  it("renders collection actions as add/remove controls for runtime item arrays", () => {
3345
3983
  const schema = defineToolcraft({
3346
3984
  canvas: { enabled: false },
@@ -3449,6 +4087,101 @@ describe("ControlsPanel", () => {
3449
4087
  expect(screen.getByDisplayValue("New label")).toBeTruthy();
3450
4088
  });
3451
4089
 
4090
+ it("keeps a collection font picker color popover open while editing its color surface", async () => {
4091
+ const schema = defineToolcraft({
4092
+ canvas: { enabled: false },
4093
+ panels: {
4094
+ controls: {
4095
+ sections: [
4096
+ {
4097
+ controls: {
4098
+ textStyles: {
4099
+ defaultValue: [
4100
+ {
4101
+ color: "#FFFFFF",
4102
+ fontId: "inter",
4103
+ fontSize: 16,
4104
+ fontWeight: "400",
4105
+ letterSpacing: "normal",
4106
+ lineHeight: "normal",
4107
+ opacity: 100,
4108
+ textCase: "original",
4109
+ },
4110
+ ],
4111
+ itemControl: {
4112
+ defaultValue: {
4113
+ color: "#FFFFFF",
4114
+ fontId: "inter",
4115
+ fontSize: 16,
4116
+ fontWeight: "400",
4117
+ letterSpacing: "normal",
4118
+ lineHeight: "normal",
4119
+ opacity: 100,
4120
+ textCase: "original",
4121
+ },
4122
+ label: "Font",
4123
+ type: "fontPicker",
4124
+ },
4125
+ label: "Text styles",
4126
+ minItems: 1,
4127
+ target: "text.styles",
4128
+ type: "collectionActions",
4129
+ },
4130
+ },
4131
+ title: "Typography",
4132
+ },
4133
+ ],
4134
+ title: "Controls",
4135
+ },
4136
+ },
4137
+ });
4138
+
4139
+ const { container } = renderControlsPanelWithSchema(schema);
4140
+
4141
+ fireEvent.click(screen.getByRole("button", { name: "Pick Color" }));
4142
+
4143
+ const surface = await waitFor(() => {
4144
+ const nextSurface = container.ownerDocument.body.querySelector<HTMLElement>(
4145
+ '[data-slot="style-guide-color-surface"]',
4146
+ );
4147
+ if (!nextSurface) {
4148
+ throw new Error("Expected color surface to be open.");
4149
+ }
4150
+ return nextSurface;
4151
+ });
4152
+
4153
+ const surfaceRect = {
4154
+ bottom: 100,
4155
+ height: 100,
4156
+ left: 0,
4157
+ right: 100,
4158
+ toJSON: () => ({}),
4159
+ top: 0,
4160
+ width: 100,
4161
+ x: 0,
4162
+ y: 0,
4163
+ } as DOMRect;
4164
+ Object.defineProperty(surface, "getBoundingClientRect", {
4165
+ configurable: true,
4166
+ value: () => surfaceRect,
4167
+ });
4168
+
4169
+ fireEvent.pointerDown(surface, {
4170
+ buttons: 1,
4171
+ clientX: 72,
4172
+ clientY: 28,
4173
+ pointerId: 1,
4174
+ });
4175
+
4176
+ expect(
4177
+ container.ownerDocument.body.querySelector('[data-slot="style-guide-color-surface"]'),
4178
+ ).toBeTruthy();
4179
+
4180
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
4181
+
4182
+ expect(values["text.styles"][0].color).not.toBe("#FFFFFF");
4183
+ });
4184
+
3452
4185
  it("commits canvas size controls to runtime state on blur or Enter", () => {
3453
4186
  const { container } = renderControlsPanel();
3454
4187
 
@@ -3576,7 +4309,7 @@ describe("ControlsPanel", () => {
3576
4309
  const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3577
4310
 
3578
4311
  expect(screen.getByText("Resolution scale")).toBeTruthy();
3579
- expect(screen.getByText("2x")).toBeTruthy();
4312
+ expect(screen.getByText("2")).toBeTruthy();
3580
4313
  expect(values["canvas.renderScale"]).toBe(2);
3581
4314
  });
3582
4315
 
@@ -4017,6 +4750,30 @@ describe("ControlsPanel", () => {
4017
4750
  expect(container.querySelector('[data-slot="control-section-header"]')).toBeTruthy();
4018
4751
  });
4019
4752
 
4753
+ it("suppresses child control transitions when a section re-expands", () => {
4754
+ const schema = createSchema();
4755
+ renderControlsPanelWithSchema(schema);
4756
+ const basicTitle = screen.getByText("Basic");
4757
+ const header = basicTitle.closest<HTMLElement>('[data-slot="control-section-header"]');
4758
+ const section = basicTitle.closest("section");
4759
+ const initialBody = section?.querySelector<HTMLElement>(
4760
+ '[data-slot="panel-section-collapsible-body"]',
4761
+ );
4762
+
4763
+ fireEvent.click(header!);
4764
+ fireEvent.transitionEnd(initialBody!);
4765
+ fireEvent.click(header!);
4766
+
4767
+ const expandedBody = section?.querySelector<HTMLElement>(
4768
+ '[data-slot="panel-section-collapsible-body"]',
4769
+ );
4770
+ const innerControls = expandedBody?.firstElementChild as HTMLElement | null;
4771
+
4772
+ expect(expandedBody?.className).toContain("transition-[grid-template-rows,opacity]");
4773
+ expect(expandedBody?.className).toContain("grid-rows-[1fr]");
4774
+ expect(innerControls?.getAttribute("data-toolcraft-controls-mounting")).toBe("true");
4775
+ });
4776
+
4020
4777
  it("persists ordinary section collapse state as a UI preference across remounts", () => {
4021
4778
  const schema = createSchema();
4022
4779
  const { unmount } = renderControlsPanelWithSchema(schema);