@pixel-point/toolcraft 0.0.8 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -1,4 +1,4 @@
1
- import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
1
+ import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
2
  import { FileDrop, Panel } from "@repo/ui";
3
3
  import {
4
4
  DEFAULT_COLOR_FORMAT_MODE,
@@ -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";
@@ -53,18 +54,6 @@ function createSchema() {
53
54
  sections: [
54
55
  {
55
56
  controls: {
56
- canvasHeight: {
57
- defaultValue: 180,
58
- label: "Canvas height",
59
- target: "canvas.size.height",
60
- type: "text",
61
- },
62
- canvasWidth: {
63
- defaultValue: 320,
64
- label: "Canvas width",
65
- target: "canvas.size.width",
66
- type: "text",
67
- },
68
57
  prompt: {
69
58
  defaultValue: "Initial prompt",
70
59
  description: "Describe the generated result.",
@@ -108,13 +97,6 @@ function createSchema() {
108
97
  type: "switch",
109
98
  },
110
99
  },
111
- layoutGroups: [
112
- {
113
- columns: 2,
114
- controls: ["canvasWidth", "canvasHeight"],
115
- layout: "inline",
116
- },
117
- ],
118
100
  title: "Basic",
119
101
  },
120
102
  {
@@ -251,7 +233,19 @@ function StateProbe() {
251
233
  <span data-testid="media-ids">
252
234
  {state.mediaAssets.map((asset) => asset.id).join(",")}
253
235
  </span>
236
+ <span data-testid="media-sizes">
237
+ {state.mediaAssets
238
+ .map((asset) =>
239
+ asset.size ? `${asset.size.width}x${asset.size.height}` : "none",
240
+ )
241
+ .join(",")}
242
+ </span>
243
+ <span data-testid="media-transforms">
244
+ {JSON.stringify(state.mediaAssets.map((asset) => asset.transform ?? null))}
245
+ </span>
254
246
  <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
247
+ <span data-testid="timeline-panel-extended">{String(state.panels.timeline.extended)}</span>
248
+ <span data-testid="timeline-panel-hidden">{String(state.panels.timeline.hidden)}</span>
255
249
  <span data-testid="timeline-keyframes">
256
250
  {JSON.stringify(state.timeline.keyframeGroups)}
257
251
  </span>
@@ -387,7 +381,43 @@ describe("ControlsPanel", () => {
387
381
  expect(inlineGroup?.textContent).toContain("Snap Y");
388
382
  });
389
383
 
390
- it("keeps wrapped local action buttons left aligned below their label", () => {
384
+ it("commits palette family and shade changes to runtime state immediately", () => {
385
+ const schema = defineToolcraft({
386
+ canvas: { enabled: false },
387
+ panels: {
388
+ controls: {
389
+ sections: [
390
+ {
391
+ controls: {
392
+ inkPalette: {
393
+ defaultValue: { family: "Slate", shade: "900" },
394
+ label: "Palette",
395
+ target: "ink.palette",
396
+ type: "palette",
397
+ },
398
+ },
399
+ title: "Ink",
400
+ },
401
+ ],
402
+ title: "Controls",
403
+ },
404
+ },
405
+ });
406
+
407
+ renderControlsPanelWithSchema(schema);
408
+
409
+ fireEvent.click(screen.getByRole("button", { name: "Primary family Red" }));
410
+ let values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
411
+
412
+ expect(values["ink.palette"]).toEqual({ family: "Red", shade: "900" });
413
+
414
+ fireEvent.click(screen.getByRole("button", { name: "Primary shade 500" }));
415
+ values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
416
+
417
+ expect(values["ink.palette"]).toEqual({ family: "Red", shade: "500" });
418
+ });
419
+
420
+ it("renders local action buttons as a two-column grid below their label", () => {
391
421
  const schema = defineToolcraft({
392
422
  canvas: { enabled: false },
393
423
  panels: {
@@ -421,10 +451,53 @@ describe("ControlsPanel", () => {
421
451
  '[data-slot="actions-control-buttons"]',
422
452
  );
423
453
 
424
- expect(actionField?.className).toContain("justify-between");
425
- expect(actionField?.className).toContain("flex-wrap");
426
- expect(actionButtons?.className).toContain("justify-start");
454
+ expect(actionField?.getAttribute("data-orientation")).toBe("vertical");
455
+ expect(actionField?.className).not.toContain("justify-between");
456
+ expect(actionButtons?.className).toContain("grid");
457
+ expect(actionButtons?.className).toContain("w-full");
458
+ expect(actionButtons?.className).toContain("grid-cols-2");
459
+ expect(actionButtons?.className).not.toContain("justify-start");
427
460
  expect(actionButtons?.className).not.toContain("ml-auto");
461
+ for (const label of ["Rect", "Circle", "Tri", "Delete"]) {
462
+ expect(screen.getByRole("button", { name: label }).className).toContain(
463
+ "w-full",
464
+ );
465
+ }
466
+ });
467
+
468
+ it("keeps a single local action button at half row width", () => {
469
+ const schema = defineToolcraft({
470
+ canvas: { enabled: false },
471
+ panels: {
472
+ controls: {
473
+ sections: [
474
+ {
475
+ controls: {
476
+ paletteActions: {
477
+ actions: [{ label: "Randomize", value: "palette.randomize" }],
478
+ defaultValue: null,
479
+ label: "Actions",
480
+ target: "palette.actions",
481
+ type: "actions",
482
+ },
483
+ },
484
+ title: "Palette",
485
+ },
486
+ ],
487
+ title: "Controls",
488
+ },
489
+ },
490
+ });
491
+ const { container } = renderControlsPanelWithSchema(schema);
492
+ const actionButtons = container.querySelector<HTMLElement>(
493
+ '[data-slot="actions-control-buttons"]',
494
+ );
495
+ const button = screen.getByRole("button", { name: "Randomize" });
496
+
497
+ expect(actionButtons?.getAttribute("data-actions-count")).toBe("1");
498
+ expect(actionButtons?.className).toContain("w-1/2");
499
+ expect(actionButtons?.className).toContain("grid-cols-1");
500
+ expect(button.className).toContain("w-full");
428
501
  });
429
502
 
430
503
  it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
@@ -474,6 +547,7 @@ describe("ControlsPanel", () => {
474
547
  expect(screen.getByText("Include")).toBeTruthy();
475
548
  expect(screen.getByRole("switch")).toBeTruthy();
476
549
  expect(toggleParameterGroup).toBeTruthy();
550
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
477
551
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
478
552
  "repeat(2, minmax(0, 1fr))",
479
553
  );
@@ -616,13 +690,100 @@ describe("ControlsPanel", () => {
616
690
  );
617
691
 
618
692
  expect(screen.getByText("Loop")).toBeTruthy();
619
- expect(screen.getByText("Duration")).toBeTruthy();
693
+ expect(screen.queryByText("Duration")).toBeNull();
620
694
  expect(toggleParameterGroup).toBeTruthy();
695
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
621
696
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
622
697
  "repeat(2, minmax(0, 1fr))",
623
698
  );
624
699
  });
625
700
 
701
+ it("keeps segmented controls full-width even when an inline row is requested", () => {
702
+ const schema = defineToolcraft({
703
+ canvas: { enabled: false },
704
+ panels: {
705
+ controls: {
706
+ sections: [
707
+ {
708
+ controls: {
709
+ includeText: {
710
+ defaultValue: true,
711
+ label: "Include",
712
+ target: "text.enabled",
713
+ type: "switch",
714
+ },
715
+ dragTarget: {
716
+ defaultValue: "glass",
717
+ label: "Drag",
718
+ options: [
719
+ { label: "Glass", value: "glass" },
720
+ { label: "Text", value: "text" },
721
+ ],
722
+ target: "text.dragTarget",
723
+ type: "segmented",
724
+ },
725
+ },
726
+ layoutGroups: [
727
+ {
728
+ columns: 2,
729
+ controls: ["includeText", "dragTarget"],
730
+ layout: "inline",
731
+ },
732
+ ],
733
+ title: "Glass Text",
734
+ },
735
+ ],
736
+ title: "Controls",
737
+ },
738
+ },
739
+ });
740
+
741
+ const { container } = renderControlsPanelWithSchema(schema);
742
+
743
+ expect(screen.getByText("Include")).toBeTruthy();
744
+ expect(screen.getByText("Drag")).toBeTruthy();
745
+ expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
746
+ expect(container.querySelector('[data-slot="toggle-group"]')).toBeTruthy();
747
+ });
748
+
749
+ it("renders standalone select controls stacked full-width instead of compact side-label rows", () => {
750
+ const schema = defineToolcraft({
751
+ canvas: { enabled: false },
752
+ panels: {
753
+ controls: {
754
+ sections: [
755
+ {
756
+ controls: {
757
+ shape: {
758
+ defaultValue: "pill",
759
+ label: "Shape",
760
+ options: [
761
+ { label: "Pill", value: "pill" },
762
+ { label: "Circle", value: "circle" },
763
+ ],
764
+ target: "glass.shape",
765
+ type: "select",
766
+ },
767
+ },
768
+ title: "Glass",
769
+ },
770
+ ],
771
+ title: "Controls",
772
+ },
773
+ },
774
+ });
775
+
776
+ renderControlsPanelWithSchema(schema);
777
+ const trigger = screen.getByRole("combobox");
778
+ const field = trigger.closest<HTMLElement>('[data-slot="field"]');
779
+
780
+ expect(screen.getByText("Shape")).toBeTruthy();
781
+ expect(trigger.textContent).toContain("Pill");
782
+ expect(field?.dataset.orientation).toBe("vertical");
783
+ expect(field?.className).toContain("gap-2");
784
+ expect(trigger.closest("div")?.className).toContain("w-full");
785
+ });
786
+
626
787
  it("renders compact select pairs inline with stacked full-width fields", () => {
627
788
  const schema = defineToolcraft({
628
789
  canvas: { enabled: false },
@@ -677,6 +838,7 @@ describe("ControlsPanel", () => {
677
838
  );
678
839
 
679
840
  expect(inlineGroup).toBeTruthy();
841
+ expect(inlineGroup?.className).toContain("gap-x-2.5");
680
842
  expect(screen.getByText("Format")).toBeTruthy();
681
843
  expect(screen.getByText("Resolution")).toBeTruthy();
682
844
  expect(triggers).toHaveLength(2);
@@ -1417,16 +1579,399 @@ describe("ControlsPanel", () => {
1417
1579
  },
1418
1580
  });
1419
1581
 
1420
- const { container } = renderControlsPanelWithSchema(schema);
1582
+ const { container } = renderControlsPanelWithSchema(schema);
1583
+
1584
+ expect(
1585
+ container
1586
+ .querySelector('[aria-label="Position X/Y pad"]')
1587
+ ?.getAttribute("data-vector-pad-shape"),
1588
+ ).toBe("square");
1589
+ });
1590
+
1591
+ it("keeps compact vector pads when the panel has multiple vector controls", () => {
1592
+ const schema = defineToolcraft({
1593
+ canvas: { enabled: true },
1594
+ panels: {
1595
+ controls: {
1596
+ sections: [
1597
+ {
1598
+ controls: {
1599
+ origin: {
1600
+ defaultValue: { x: "0.00", y: "0.00" },
1601
+ label: "Origin",
1602
+ target: "geometry.origin",
1603
+ type: "vector",
1604
+ },
1605
+ target: {
1606
+ defaultValue: { x: "0.50", y: "-0.50" },
1607
+ label: "Target",
1608
+ target: "geometry.target",
1609
+ type: "vector",
1610
+ },
1611
+ },
1612
+ title: "Geometry",
1613
+ },
1614
+ ],
1615
+ title: "Controls",
1616
+ },
1617
+ },
1618
+ });
1619
+
1620
+ const { container } = renderControlsPanelWithSchema(schema);
1621
+
1622
+ expect(
1623
+ [...container.querySelectorAll("[data-vector-pad-shape]")].map((pad) =>
1624
+ pad.getAttribute("data-vector-pad-shape"),
1625
+ ),
1626
+ ).toEqual(["compact", "compact"]);
1627
+ });
1628
+
1629
+ it("passes supported vector pad variants from schema to the vector pad", () => {
1630
+ const schema = defineToolcraft({
1631
+ canvas: { enabled: true },
1632
+ panels: {
1633
+ controls: {
1634
+ sections: [
1635
+ {
1636
+ controls: {
1637
+ whiteBalance: {
1638
+ defaultValue: { x: "0.00", y: "0.00" },
1639
+ label: "White Balance",
1640
+ target: "color.whiteBalance",
1641
+ type: "vector",
1642
+ variant: "whiteBalance",
1643
+ },
1644
+ colorBalance: {
1645
+ defaultValue: { x: "0.00", y: "0.00" },
1646
+ label: "Color Balance",
1647
+ target: "color.balance",
1648
+ type: "vector",
1649
+ variant: "colorBalance",
1650
+ },
1651
+ chromaOffset: {
1652
+ defaultValue: { x: "0.00", y: "0.00" },
1653
+ label: "Chroma Offset",
1654
+ target: "effect.chromaOffset",
1655
+ type: "vector",
1656
+ variant: "chromaOffset",
1657
+ },
1658
+ toneBias: {
1659
+ defaultValue: { x: "0.00", y: "0.00" },
1660
+ label: "Tone Bias",
1661
+ target: "tone.bias",
1662
+ type: "vector",
1663
+ variant: "toneBias",
1664
+ },
1665
+ unknown: {
1666
+ defaultValue: { x: "0.00", y: "0.00" },
1667
+ label: "Unknown",
1668
+ target: "effect.unknown",
1669
+ type: "vector",
1670
+ variant: "unknown",
1671
+ },
1672
+ },
1673
+ title: "Color",
1674
+ },
1675
+ ],
1676
+ title: "Controls",
1677
+ },
1678
+ },
1679
+ });
1680
+
1681
+ const { container } = renderControlsPanelWithSchema(schema);
1682
+
1683
+ expect(
1684
+ [...container.querySelectorAll("[data-vector-pad-variant]")].map((pad) =>
1685
+ pad.getAttribute("data-vector-pad-variant"),
1686
+ ),
1687
+ ).toEqual([
1688
+ "whiteBalance",
1689
+ "colorBalance",
1690
+ "chromaOffset",
1691
+ "toneBias",
1692
+ "default",
1693
+ ]);
1694
+ expect(
1695
+ [...container.querySelectorAll("[data-vector-pad-coordinate-mode]")].map((pad) =>
1696
+ pad.getAttribute("data-vector-pad-coordinate-mode"),
1697
+ ),
1698
+ ).toEqual([
1699
+ "cartesian",
1700
+ "cartesian",
1701
+ "cartesian",
1702
+ "cartesian",
1703
+ "screen",
1704
+ ]);
1705
+ });
1706
+
1707
+ it("maps default spatial vector pad movement to screen coordinates", () => {
1708
+ const schema = defineToolcraft({
1709
+ canvas: { enabled: true },
1710
+ panels: {
1711
+ controls: {
1712
+ sections: [
1713
+ {
1714
+ controls: {
1715
+ position: {
1716
+ defaultValue: { x: "0.00", y: "0.00" },
1717
+ label: "Position",
1718
+ target: "geometry.position",
1719
+ type: "vector",
1720
+ },
1721
+ },
1722
+ title: "Geometry",
1723
+ },
1724
+ ],
1725
+ title: "Controls",
1726
+ },
1727
+ },
1728
+ });
1729
+
1730
+ renderControlsPanelWithSchema(schema);
1731
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1732
+
1733
+ expect(pad.getAttribute("data-vector-pad-coordinate-mode")).toBe("screen");
1734
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1735
+ bottom: 100,
1736
+ height: 100,
1737
+ left: 0,
1738
+ right: 100,
1739
+ toJSON: () => undefined,
1740
+ top: 0,
1741
+ width: 100,
1742
+ x: 0,
1743
+ y: 0,
1744
+ } as DOMRect);
1745
+ Object.defineProperty(pad, "setPointerCapture", {
1746
+ configurable: true,
1747
+ value: vi.fn(),
1748
+ });
1749
+
1750
+ fireEvent.pointerDown(pad, {
1751
+ buttons: 1,
1752
+ clientX: 0,
1753
+ clientY: 0,
1754
+ pointerId: 1,
1755
+ });
1756
+
1757
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1758
+ expect(values["geometry.position"]).toEqual({ x: "-1.00", y: "-1.00" });
1759
+ });
1760
+
1761
+ it("resets a vector pad to its default value on double click", () => {
1762
+ const schema = defineToolcraft({
1763
+ canvas: { enabled: true },
1764
+ panels: {
1765
+ controls: {
1766
+ sections: [
1767
+ {
1768
+ controls: {
1769
+ position: {
1770
+ defaultValue: { x: "0.48", y: "-0.34" },
1771
+ label: "Position",
1772
+ target: "geometry.position",
1773
+ type: "vector",
1774
+ },
1775
+ },
1776
+ title: "Geometry",
1777
+ },
1778
+ ],
1779
+ title: "Controls",
1780
+ },
1781
+ },
1782
+ });
1783
+
1784
+ renderControlsPanelWithSchema(schema);
1785
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1786
+
1787
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1788
+ bottom: 100,
1789
+ height: 100,
1790
+ left: 0,
1791
+ right: 100,
1792
+ toJSON: () => undefined,
1793
+ top: 0,
1794
+ width: 100,
1795
+ x: 0,
1796
+ y: 0,
1797
+ } as DOMRect);
1798
+ Object.defineProperty(pad, "setPointerCapture", {
1799
+ configurable: true,
1800
+ value: vi.fn(),
1801
+ });
1802
+
1803
+ fireEvent.pointerDown(pad, {
1804
+ buttons: 1,
1805
+ clientX: 0,
1806
+ clientY: 0,
1807
+ pointerId: 1,
1808
+ });
1809
+ expect(
1810
+ JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")[
1811
+ "geometry.position"
1812
+ ],
1813
+ ).toEqual({ x: "-1.00", y: "-1.00" });
1814
+
1815
+ fireEvent.doubleClick(pad);
1816
+
1817
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1818
+ expect(values["geometry.position"]).toEqual({ x: "0.48", y: "-0.34" });
1819
+ expect(screen.getByText("0.48, -0.34")).toBeTruthy();
1820
+ });
1821
+
1822
+ it("locks vector pad movement to the dominant axis while shift is held", () => {
1823
+ const schema = defineToolcraft({
1824
+ canvas: { enabled: true },
1825
+ panels: {
1826
+ controls: {
1827
+ sections: [
1828
+ {
1829
+ controls: {
1830
+ position: {
1831
+ defaultValue: { x: "0.00", y: "0.00" },
1832
+ label: "Position",
1833
+ target: "geometry.position",
1834
+ type: "vector",
1835
+ },
1836
+ },
1837
+ title: "Geometry",
1838
+ },
1839
+ ],
1840
+ title: "Controls",
1841
+ },
1842
+ },
1843
+ });
1844
+
1845
+ renderControlsPanelWithSchema(schema);
1846
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1847
+
1848
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1849
+ bottom: 100,
1850
+ height: 100,
1851
+ left: 0,
1852
+ right: 100,
1853
+ toJSON: () => undefined,
1854
+ top: 0,
1855
+ width: 100,
1856
+ x: 0,
1857
+ y: 0,
1858
+ } as DOMRect);
1859
+ Object.defineProperty(pad, "setPointerCapture", {
1860
+ configurable: true,
1861
+ value: vi.fn(),
1862
+ });
1863
+
1864
+ fireEvent.pointerDown(pad, {
1865
+ buttons: 1,
1866
+ clientX: 50,
1867
+ clientY: 50,
1868
+ pointerId: 1,
1869
+ });
1870
+ fireEvent.pointerMove(pad, {
1871
+ buttons: 1,
1872
+ clientX: 90,
1873
+ clientY: 70,
1874
+ pointerId: 1,
1875
+ shiftKey: true,
1876
+ });
1877
+
1878
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1879
+ expect(values["geometry.position"]).toEqual({ x: "0.80", y: "0.00" });
1880
+ });
1881
+
1882
+ it("locks vector pad movement vertically when shift drag is y-dominant", () => {
1883
+ const schema = defineToolcraft({
1884
+ canvas: { enabled: true },
1885
+ panels: {
1886
+ controls: {
1887
+ sections: [
1888
+ {
1889
+ controls: {
1890
+ position: {
1891
+ defaultValue: { x: "0.00", y: "0.00" },
1892
+ label: "Position",
1893
+ target: "geometry.position",
1894
+ type: "vector",
1895
+ },
1896
+ },
1897
+ title: "Geometry",
1898
+ },
1899
+ ],
1900
+ title: "Controls",
1901
+ },
1902
+ },
1903
+ });
1904
+
1905
+ renderControlsPanelWithSchema(schema);
1906
+ const pad = screen.getByRole("button", { name: "Position X/Y pad" });
1907
+
1908
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
1909
+ bottom: 100,
1910
+ height: 100,
1911
+ left: 0,
1912
+ right: 100,
1913
+ toJSON: () => undefined,
1914
+ top: 0,
1915
+ width: 100,
1916
+ x: 0,
1917
+ y: 0,
1918
+ } as DOMRect);
1919
+ Object.defineProperty(pad, "setPointerCapture", {
1920
+ configurable: true,
1921
+ value: vi.fn(),
1922
+ });
1923
+
1924
+ fireEvent.pointerDown(pad, {
1925
+ buttons: 1,
1926
+ clientX: 50,
1927
+ clientY: 50,
1928
+ pointerId: 1,
1929
+ });
1930
+ fireEvent.pointerMove(pad, {
1931
+ buttons: 1,
1932
+ clientX: 70,
1933
+ clientY: 90,
1934
+ pointerId: 1,
1935
+ shiftKey: true,
1936
+ });
1937
+
1938
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
1939
+ expect(values["geometry.position"]).toEqual({ x: "0.00", y: "0.80" });
1940
+ });
1941
+
1942
+ it("renders vector pad values as compact rounded coordinates", () => {
1943
+ const schema = defineToolcraft({
1944
+ canvas: { enabled: true },
1945
+ panels: {
1946
+ controls: {
1947
+ sections: [
1948
+ {
1949
+ controls: {
1950
+ center: {
1951
+ defaultValue: {
1952
+ x: "-0.07070312499999998",
1953
+ y: "-0.00392795138",
1954
+ },
1955
+ label: "Center",
1956
+ target: "geometry.center",
1957
+ type: "vector",
1958
+ },
1959
+ },
1960
+ title: "Geometry",
1961
+ },
1962
+ ],
1963
+ title: "Controls",
1964
+ },
1965
+ },
1966
+ });
1967
+
1968
+ renderControlsPanelWithSchema(schema);
1421
1969
 
1422
- expect(
1423
- container
1424
- .querySelector('[aria-label="Position X/Y pad"]')
1425
- ?.getAttribute("data-vector-pad-shape"),
1426
- ).toBe("square");
1970
+ expect(screen.getByText("-0.07, 0.00")).toBeTruthy();
1971
+ expect(screen.queryByText("-0.07070312499999998, -0.00392795138")).toBeNull();
1427
1972
  });
1428
1973
 
1429
- it("keeps compact vector pads when the panel has multiple vector controls", () => {
1974
+ it("prevents native text selection while dragging a vector pad", () => {
1430
1975
  const schema = defineToolcraft({
1431
1976
  canvas: { enabled: true },
1432
1977
  panels: {
@@ -1434,16 +1979,10 @@ describe("ControlsPanel", () => {
1434
1979
  sections: [
1435
1980
  {
1436
1981
  controls: {
1437
- origin: {
1982
+ center: {
1438
1983
  defaultValue: { x: "0.00", y: "0.00" },
1439
- label: "Origin",
1440
- target: "geometry.origin",
1441
- type: "vector",
1442
- },
1443
- target: {
1444
- defaultValue: { x: "0.50", y: "-0.50" },
1445
- label: "Target",
1446
- target: "geometry.target",
1984
+ label: "Center",
1985
+ target: "geometry.center",
1447
1986
  type: "vector",
1448
1987
  },
1449
1988
  },
@@ -1455,16 +1994,39 @@ describe("ControlsPanel", () => {
1455
1994
  },
1456
1995
  });
1457
1996
 
1458
- const { container } = renderControlsPanelWithSchema(schema);
1997
+ renderControlsPanelWithSchema(schema);
1998
+ const pad = screen.getByRole("button", { name: "Center X/Y pad" });
1459
1999
 
1460
- expect(
1461
- [...container.querySelectorAll("[data-vector-pad-shape]")].map((pad) =>
1462
- pad.getAttribute("data-vector-pad-shape"),
1463
- ),
1464
- ).toEqual(["compact", "compact"]);
2000
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
2001
+ bottom: 100,
2002
+ height: 100,
2003
+ left: 0,
2004
+ right: 100,
2005
+ toJSON: () => undefined,
2006
+ top: 0,
2007
+ width: 100,
2008
+ x: 0,
2009
+ y: 0,
2010
+ } as DOMRect);
2011
+ Object.defineProperty(pad, "setPointerCapture", {
2012
+ configurable: true,
2013
+ value: vi.fn(),
2014
+ });
2015
+
2016
+ const pointerDown = createEvent.pointerDown(pad, {
2017
+ buttons: 1,
2018
+ clientX: 50,
2019
+ clientY: 50,
2020
+ pointerId: 1,
2021
+ });
2022
+
2023
+ fireEvent(pad, pointerDown);
2024
+
2025
+ expect(pointerDown.defaultPrevented).toBe(true);
2026
+ expect(pad.className).toContain("select-none");
1465
2027
  });
1466
2028
 
1467
- it("passes supported vector pad variants from schema to the vector pad", () => {
2029
+ it("allows cartesian vector pads when a product intentionally uses y-up math", () => {
1468
2030
  const schema = defineToolcraft({
1469
2031
  canvas: { enabled: true },
1470
2032
  panels: {
@@ -1472,43 +2034,15 @@ describe("ControlsPanel", () => {
1472
2034
  sections: [
1473
2035
  {
1474
2036
  controls: {
1475
- whiteBalance: {
1476
- defaultValue: { x: "0.00", y: "0.00" },
1477
- label: "White Balance",
1478
- target: "color.whiteBalance",
1479
- type: "vector",
1480
- variant: "whiteBalance",
1481
- },
1482
- colorBalance: {
1483
- defaultValue: { x: "0.00", y: "0.00" },
1484
- label: "Color Balance",
1485
- target: "color.balance",
1486
- type: "vector",
1487
- variant: "colorBalance",
1488
- },
1489
- chromaOffset: {
1490
- defaultValue: { x: "0.00", y: "0.00" },
1491
- label: "Chroma Offset",
1492
- target: "effect.chromaOffset",
1493
- type: "vector",
1494
- variant: "chromaOffset",
1495
- },
1496
- toneBias: {
1497
- defaultValue: { x: "0.00", y: "0.00" },
1498
- label: "Tone Bias",
1499
- target: "tone.bias",
1500
- type: "vector",
1501
- variant: "toneBias",
1502
- },
1503
- unknown: {
2037
+ vector: {
2038
+ coordinateMode: "cartesian",
1504
2039
  defaultValue: { x: "0.00", y: "0.00" },
1505
- label: "Unknown",
1506
- target: "effect.unknown",
2040
+ label: "Vector",
2041
+ target: "math.vector",
1507
2042
  type: "vector",
1508
- variant: "unknown",
1509
2043
  },
1510
2044
  },
1511
- title: "Color",
2045
+ title: "Math",
1512
2046
  },
1513
2047
  ],
1514
2048
  title: "Controls",
@@ -1516,19 +2050,35 @@ describe("ControlsPanel", () => {
1516
2050
  },
1517
2051
  });
1518
2052
 
1519
- const { container } = renderControlsPanelWithSchema(schema);
2053
+ renderControlsPanelWithSchema(schema);
2054
+ const pad = screen.getByRole("button", { name: "Vector X/Y pad" });
1520
2055
 
1521
- expect(
1522
- [...container.querySelectorAll("[data-vector-pad-variant]")].map((pad) =>
1523
- pad.getAttribute("data-vector-pad-variant"),
1524
- ),
1525
- ).toEqual([
1526
- "whiteBalance",
1527
- "colorBalance",
1528
- "chromaOffset",
1529
- "toneBias",
1530
- "default",
1531
- ]);
2056
+ expect(pad.getAttribute("data-vector-pad-coordinate-mode")).toBe("cartesian");
2057
+ vi.spyOn(pad, "getBoundingClientRect").mockReturnValue({
2058
+ bottom: 100,
2059
+ height: 100,
2060
+ left: 0,
2061
+ right: 100,
2062
+ toJSON: () => undefined,
2063
+ top: 0,
2064
+ width: 100,
2065
+ x: 0,
2066
+ y: 0,
2067
+ } as DOMRect);
2068
+ Object.defineProperty(pad, "setPointerCapture", {
2069
+ configurable: true,
2070
+ value: vi.fn(),
2071
+ });
2072
+
2073
+ fireEvent.pointerDown(pad, {
2074
+ buttons: 1,
2075
+ clientX: 0,
2076
+ clientY: 0,
2077
+ pointerId: 1,
2078
+ });
2079
+
2080
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
2081
+ expect(values["math.vector"]).toEqual({ x: "-1.00", y: "1.00" });
1532
2082
  });
1533
2083
 
1534
2084
  it("keeps explanatory label text in the native title instead of the visible label", () => {
@@ -1758,7 +2308,6 @@ describe("ControlsPanel", () => {
1758
2308
  step: 0.1,
1759
2309
  target: "animation.speed",
1760
2310
  type: "slider",
1761
- unit: "x",
1762
2311
  },
1763
2312
  },
1764
2313
  layoutGroups: [
@@ -1785,7 +2334,7 @@ describe("ControlsPanel", () => {
1785
2334
  expect(fpsSlider).toBeTruthy();
1786
2335
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1787
2336
  expect(container.textContent).toContain("17 fps");
1788
- expect(container.textContent).toContain("3.7x");
2337
+ expect(container.textContent).toContain("3.7");
1789
2338
  });
1790
2339
 
1791
2340
  it("passes schema disabled state into slider controls", () => {
@@ -2233,9 +2782,12 @@ describe("ControlsPanel", () => {
2233
2782
 
2234
2783
  for (const [draftValue, expectedValue, expectedLabel] of [
2235
2784
  ["0/1", '"shape.range":[0,1]', "0% – 1%"],
2785
+ ["1-6", '"shape.range":[1,6]', "1% – 6%"],
2236
2786
  ["2-3", '"shape.range":[2,3]', "2% – 3%"],
2237
2787
  ["4 - 5", '"shape.range":[4,5]', "4% – 5%"],
2238
2788
  ["6–7", '"shape.range":[6,7]', "6% – 7%"],
2789
+ ["7—8", '"shape.range":[7,8]', "7% – 8%"],
2790
+ ["8−9", '"shape.range":[8,9]', "8% – 9%"],
2239
2791
  ] as const) {
2240
2792
  fireEvent.click(screen.getByRole("button", { name: "Edit Range value" }));
2241
2793
  const editor = screen.getByRole("textbox", { name: "Range value" });
@@ -2374,7 +2926,7 @@ describe("ControlsPanel", () => {
2374
2926
  },
2375
2927
  });
2376
2928
 
2377
- renderControlsPanelWithSchema(schema, undefined, {
2929
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2378
2930
  layers: [
2379
2931
  {
2380
2932
  id: "layer-1",
@@ -2399,9 +2951,66 @@ describe("ControlsPanel", () => {
2399
2951
  });
2400
2952
 
2401
2953
  expect(screen.getByRole("img", { name: "material.svg" })).toBeTruthy();
2954
+ const transformActions = container.querySelector(
2955
+ '[data-slot="actions-control"][aria-label="Image transforms"]',
2956
+ );
2957
+ const transformActionButtons = transformActions?.querySelector(
2958
+ '[data-slot="actions-control-buttons"]',
2959
+ );
2960
+
2961
+ expect(transformActions).toBeTruthy();
2962
+ expect(transformActions?.parentElement?.style.gap).toBe("6px");
2963
+ expect(transformActionButtons?.className).toContain("grid-cols-3");
2964
+ expect(transformActionButtons?.getAttribute("data-actions-columns")).toBe("3");
2965
+ expect(screen.queryByRole("button", { name: "90° Left" })).toBeNull();
2966
+
2967
+ for (const [name, visibleLabel] of [
2968
+ ["90° Right", "90°"],
2969
+ ["Flip horizontal", "Flip H"],
2970
+ ["Flip vertical", "Flip V"],
2971
+ ] as const) {
2972
+ const button = screen.getByRole("button", { name });
2973
+
2974
+ expect(button).toBeTruthy();
2975
+ expect(button.textContent).toContain(visibleLabel);
2976
+ }
2402
2977
  expect(
2403
2978
  screen.queryByText("Click to upload an image"),
2404
2979
  ).toBeNull();
2980
+ const initialPreviewFrameStyle = container
2981
+ .querySelector('[data-slot="file-upload-preview-frame"]')
2982
+ ?.getAttribute("style");
2983
+
2984
+ expect(initialPreviewFrameStyle).toBe(
2985
+ "aspect-ratio: 96 / 64; max-height: 196px; width: 100%;",
2986
+ );
2987
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
2988
+ "transform: translate(-50%, -50%);",
2989
+ );
2990
+
2991
+ fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
2992
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
2993
+ '[{"rotationDeg":90}]',
2994
+ );
2995
+ expect(
2996
+ container.querySelector('[data-slot="file-upload-preview-frame"]')?.getAttribute("style"),
2997
+ ).toBe(initialPreviewFrameStyle);
2998
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
2999
+ "transform: translate(-50%, -50%) rotate(90deg); width: 66.6667%;",
3000
+ );
3001
+
3002
+ fireEvent.click(screen.getByRole("button", { name: "Flip horizontal" }));
3003
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3004
+ '[{"rotationDeg":90,"flipHorizontal":true}]',
3005
+ );
3006
+ expect(screen.getByRole("img", { name: "material.svg" }).getAttribute("style")).toBe(
3007
+ "transform: translate(-50%, -50%) rotate(90deg) scale(-1, 1); width: 66.6667%;",
3008
+ );
3009
+
3010
+ fireEvent.click(screen.getByRole("button", { name: "Flip vertical" }));
3011
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3012
+ '[{"rotationDeg":90,"flipHorizontal":true,"flipVertical":true}]',
3013
+ );
2405
3014
 
2406
3015
  fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
2407
3016
 
@@ -2410,6 +3019,113 @@ describe("ControlsPanel", () => {
2410
3019
  expect(screen.getByText("Click to upload an image")).toBeTruthy();
2411
3020
  });
2412
3021
 
3022
+ it("renders schema default media as a removable fileDrop attachment", () => {
3023
+ const schema = defineToolcraft({
3024
+ canvas: { enabled: true, upload: true },
3025
+ media: {
3026
+ defaultAssets: [
3027
+ {
3028
+ dataUrl:
3029
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='64' viewBox='0 0 96 64'%3E%3Crect width='96' height='64' fill='%23777'/%3E%3C/svg%3E",
3030
+ fileName: "default-background.svg",
3031
+ id: "default-background",
3032
+ layerId: "default-background-layer",
3033
+ mimeType: "image/svg+xml",
3034
+ size: { height: 64, unit: "px", width: 96 },
3035
+ sourceTarget: "input.source",
3036
+ },
3037
+ ],
3038
+ },
3039
+ panels: {
3040
+ controls: {
3041
+ sections: [
3042
+ {
3043
+ controls: {
3044
+ source: {
3045
+ accept: "PNG, SVG",
3046
+ label: "Image",
3047
+ target: "input.source",
3048
+ type: "fileDrop",
3049
+ },
3050
+ },
3051
+ layout: "standalone",
3052
+ title: "Input",
3053
+ },
3054
+ ],
3055
+ title: "Generation Controls",
3056
+ },
3057
+ },
3058
+ });
3059
+
3060
+ renderControlsPanelWithSchema(schema);
3061
+
3062
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3063
+ expect(screen.getByTestId("media-ids").textContent).toBe("default-background");
3064
+
3065
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
3066
+
3067
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
3068
+ expect(screen.queryByRole("img", { name: "default-background.svg" })).toBeNull();
3069
+ expect(screen.getByText("Click to upload an image")).toBeTruthy();
3070
+
3071
+ fireEvent.click(screen.getByRole("button", { name: "Reset Input section" }));
3072
+
3073
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3074
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3075
+
3076
+ fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
3077
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
3078
+
3079
+ expect(screen.getByRole("img", { name: "default-background.svg" })).toBeTruthy();
3080
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3081
+ });
3082
+
3083
+ it("keeps the current editable canvas size when uploading an image through fileDrop", async () => {
3084
+ const schema = defineToolcraft({
3085
+ canvas: {
3086
+ enabled: true,
3087
+ size: { height: 720, unit: "px", width: 1280 },
3088
+ upload: true,
3089
+ },
3090
+ panels: {
3091
+ controls: {
3092
+ sections: [
3093
+ {
3094
+ controls: {
3095
+ source: {
3096
+ label: "Image",
3097
+ target: "input.source",
3098
+ type: "fileDrop",
3099
+ },
3100
+ },
3101
+ layout: "standalone",
3102
+ title: "Input",
3103
+ },
3104
+ ],
3105
+ title: "Generation Controls",
3106
+ },
3107
+ },
3108
+ });
3109
+ const { container } = renderControlsPanelWithSchema(schema);
3110
+ const input = container.querySelector<HTMLInputElement>('input[type="file"]');
3111
+
3112
+ expect(input).toBeTruthy();
3113
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1280,720");
3114
+
3115
+ fireEvent.change(input as HTMLInputElement, {
3116
+ target: {
3117
+ files: [new File(["image"], "wide.png", { type: "image/png" })],
3118
+ },
3119
+ });
3120
+
3121
+ await waitFor(() => {
3122
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3123
+ });
3124
+
3125
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1280,720");
3126
+ expect(screen.getByTestId("media-sizes").textContent).toBe("1280x720");
3127
+ });
3128
+
2413
3129
  it("removes single-layer file uploads from the canvas and returns the file control to empty", () => {
2414
3130
  const schema = defineToolcraft({
2415
3131
  canvas: { enabled: true, upload: true },
@@ -2544,6 +3260,7 @@ describe("ControlsPanel", () => {
2544
3260
  expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2545
3261
  expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2546
3262
  expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2");
3263
+ expect(screen.queryByRole("button", { name: "90° Left" })).toBeNull();
2547
3264
 
2548
3265
  const firstPreview = screen
2549
3266
  .getByRole("img", { name: "one.svg" })
@@ -2560,6 +3277,15 @@ describe("ControlsPanel", () => {
2560
3277
  expect(firstPreview.getAttribute("tabindex")).toBe("0");
2561
3278
  expect(firstPreview.className).toContain("cursor-grab");
2562
3279
 
3280
+ fireEvent.click(firstPreview);
3281
+ expect(firstPreview.getAttribute("data-selected")).toBe("true");
3282
+ expect(screen.getByRole("button", { name: "90° Right" })).toBeTruthy();
3283
+
3284
+ fireEvent.click(screen.getByRole("button", { name: "90° Right" }));
3285
+ expect(screen.getByTestId("media-transforms").textContent).toBe(
3286
+ '[{"rotationDeg":90},null]',
3287
+ );
3288
+
2563
3289
  fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2564
3290
 
2565
3291
  expect(screen.getByTestId("media-count").textContent).toBe("1");
@@ -2944,6 +3670,35 @@ describe("ControlsPanel", () => {
2944
3670
  });
2945
3671
  });
2946
3672
 
3673
+ it("applies color picker slider changes while dragging", async () => {
3674
+ const onChange = vi.fn();
3675
+
3676
+ render(<StyleGuideColorPicker value="#C1FF00" onChange={onChange} />);
3677
+
3678
+ const hueSlider = screen.getByRole("slider", { name: "Color hue" });
3679
+ Object.defineProperty(hueSlider, "getBoundingClientRect", {
3680
+ configurable: true,
3681
+ value: () => ({
3682
+ bottom: 18,
3683
+ height: 18,
3684
+ left: 0,
3685
+ right: 360,
3686
+ top: 0,
3687
+ width: 360,
3688
+ x: 0,
3689
+ y: 0,
3690
+ toJSON: () => ({}),
3691
+ }),
3692
+ });
3693
+
3694
+ fireEvent.pointerDown(hueSlider, { clientX: 0 });
3695
+ fireEvent.pointerMove(window, { clientX: 180 });
3696
+
3697
+ await waitFor(() => {
3698
+ expect(onChange).toHaveBeenLastCalledWith("#00ffff");
3699
+ });
3700
+ });
3701
+
2947
3702
  it("keeps two color opacity controls stacked even when an inline row is requested", () => {
2948
3703
  const schema = defineToolcraft({
2949
3704
  canvas: { enabled: true },
@@ -3416,19 +4171,17 @@ describe("ControlsPanel", () => {
3416
4171
  expect(gradientControlItem?.className).toContain(
3417
4172
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3418
4173
  );
3419
- expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe(
3420
- "both",
3421
- );
4174
+ expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe("top");
3422
4175
  expect(fontControlItem?.className).toContain(
3423
4176
  "has-data-[control-section-divider=compound]:pt-[18px]",
3424
4177
  );
3425
- expect(fontControlItem?.className).toContain(
4178
+ expect(fontControlItem?.className).not.toContain(
3426
4179
  "has-data-[control-section-divider=compound]:pb-[18px]",
3427
4180
  );
3428
4181
  expect(fontControlItem?.className).toContain(
3429
4182
  "has-data-[control-section-divider=compound]:before:inset-x-3",
3430
4183
  );
3431
- expect(fontControlItem?.className).toContain(
4184
+ expect(fontControlItem?.className).not.toContain(
3432
4185
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3433
4186
  );
3434
4187
  });
@@ -3502,7 +4255,10 @@ describe("ControlsPanel", () => {
3502
4255
  fireEvent.change(promptInput, { target: { value: "Updated prompt" } });
3503
4256
  expect(screen.getByTestId("prompt-value").textContent).toBe("Updated prompt");
3504
4257
 
3505
- fireEvent.click(screen.getByRole("switch"));
4258
+ const enabledField = screen.getByText("Enabled").closest('[role="group"]');
4259
+ const enabledSwitch = enabledField?.querySelector<HTMLElement>('[role="switch"]');
4260
+
4261
+ fireEvent.click(enabledSwitch as HTMLElement);
3506
4262
  expect(screen.getByTestId("enabled-value").textContent).toBe("false");
3507
4263
 
3508
4264
  fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
@@ -3948,10 +4704,58 @@ describe("ControlsPanel", () => {
3948
4704
  const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3949
4705
 
3950
4706
  expect(screen.getByText("Resolution scale")).toBeTruthy();
3951
- expect(screen.getByText("2x")).toBeTruthy();
4707
+ expect(screen.getByText("2")).toBeTruthy();
3952
4708
  expect(values["canvas.renderScale"]).toBe(2);
3953
4709
  });
3954
4710
 
4711
+ it("renders timeline extended mode as runtime panel state instead of product values", () => {
4712
+ const schema = defineToolcraft({
4713
+ canvas: {
4714
+ enabled: true,
4715
+ renderScale: true,
4716
+ size: { height: 1080, unit: "px", width: 1920 },
4717
+ sizing: { mode: "editable-output" },
4718
+ },
4719
+ panels: {
4720
+ controls: {
4721
+ sections: [],
4722
+ title: "Controls",
4723
+ },
4724
+ timeline: { mode: "playback" },
4725
+ },
4726
+ });
4727
+
4728
+ renderControlsPanelWithSchema(schema, undefined, {
4729
+ timeline: { currentTimeSeconds: 2, expanded: true, isPlaying: true },
4730
+ });
4731
+
4732
+ const setupText = screen.getByText("Timeline").closest("section")?.textContent ?? "";
4733
+
4734
+ expect(setupText.indexOf("Resolution scale")).toBeLessThan(setupText.indexOf("Timeline"));
4735
+ const timelineField = screen.getByText("Timeline").closest('[role="group"]');
4736
+ const timelineSwitch = timelineField?.querySelector<HTMLElement>('[role="switch"]');
4737
+
4738
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("false");
4739
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("undefined");
4740
+ expect(screen.getByTestId("timeline-panel-hidden").textContent).toBe("undefined");
4741
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
4742
+
4743
+ fireEvent.click(timelineSwitch as HTMLElement);
4744
+
4745
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
4746
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("true");
4747
+ expect(screen.getByTestId("timeline-panel-hidden").textContent).toBe("undefined");
4748
+ expect(screen.getByTestId("timeline-expanded").textContent).toBe("true");
4749
+ expect(JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")).not.toHaveProperty(
4750
+ "panels.timeline.extended",
4751
+ );
4752
+
4753
+ fireEvent.click(screen.getByRole("button", { name: "Reset controls" }));
4754
+
4755
+ expect(timelineSwitch?.getAttribute("aria-checked")).toBe("true");
4756
+ expect(screen.getByTestId("timeline-panel-extended").textContent).toBe("true");
4757
+ });
4758
+
3955
4759
  it("applies empty text input values while typing and reset restores defaults", () => {
3956
4760
  renderControlsPanel();
3957
4761
 
@@ -4188,7 +4992,7 @@ describe("ControlsPanel", () => {
4188
4992
  expect(handledActions).toEqual(["download:Generation Controls"]);
4189
4993
  });
4190
4994
 
4191
- it("renders runtime settings transfer as a body section instead of sticky footer actions", () => {
4995
+ it("renders runtime settings transfer as a headerless body section instead of sticky footer actions", () => {
4192
4996
  const schema = defineToolcraft({
4193
4997
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
4194
4998
  panels: {
@@ -4252,10 +5056,12 @@ describe("ControlsPanel", () => {
4252
5056
  ).not.toContain("Setup");
4253
5057
  expect(firstSection?.querySelector('[data-slot="panel-title"]')).toBeNull();
4254
5058
  expect(firstSection?.querySelector("[data-control-section-collapse-button]")).toBeNull();
5059
+ expect(firstSection?.querySelector('[data-slot="control-section-header"]')).toBeNull();
5060
+ expect(firstSection?.querySelector('[data-slot="panel-section-collapsible-body"]')).toBeNull();
5061
+ expect(firstSection?.getAttribute("data-collapsed")).toBeNull();
4255
5062
  expect(firstSection?.className).toContain("py-0");
4256
- expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("py-3");
4257
- expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pt-2");
4258
- expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pb-5");
5063
+ expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("pt-2");
5064
+ expect(firstSection?.querySelector("[data-control-list]")?.className).toContain("pb-6");
4259
5065
  expect(firstInlineGroup?.textContent).toContain("Canvas width");
4260
5066
  expect(firstInlineGroup?.textContent).toContain("Canvas height");
4261
5067
  expect(firstSection?.textContent).toContain("Aspect ratio");
@@ -4497,10 +5303,12 @@ describe("ControlsPanel", () => {
4497
5303
  const stickyFooter = container.querySelector(
4498
5304
  '[data-slot="toolcraft-panel-sticky-actions"]',
4499
5305
  );
5306
+ const exportPngButton = screen.getByRole("button", { name: "Export PNG" });
4500
5307
 
4501
5308
  expect(stickyFooter?.getAttribute("data-sticky-footer-active")).toBeNull();
5309
+ expect(exportPngButton.querySelector('[data-icon-name="upload-simple"]')).toBeTruthy();
4502
5310
 
4503
- fireEvent.click(screen.getByRole("button", { name: "Export PNG" }));
5311
+ fireEvent.click(exportPngButton);
4504
5312
 
4505
5313
  expect(onPanelAction).toHaveBeenCalledTimes(1);
4506
5314
  await waitFor(() => {