@pixel-point/toolcraft 0.0.8 → 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 (44) 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 +104 -14
  7. package/templates/runtime/contracts/component-contracts.ts +54 -22
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  10. package/templates/runtime/react/controls-panel.test.tsx +374 -13
  11. package/templates/runtime/react/controls-panel.tsx +65 -4
  12. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  13. package/templates/runtime/schema/define-toolcraft.ts +25 -1
  14. package/templates/runtime/schema/types.ts +3 -0
  15. package/templates/runtime/testing/performance.test.ts +134 -0
  16. package/templates/runtime/testing/performance.ts +34 -4
  17. package/templates/starter/AGENTS.md +4 -4
  18. package/templates/starter/docs/toolcraft/README.md +1 -1
  19. package/templates/starter/docs/toolcraft/acceptance-testing.md +5 -3
  20. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -5
  21. package/templates/starter/docs/toolcraft/component-rules.md +32 -11
  22. package/templates/starter/docs/toolcraft/performance.md +7 -1
  23. package/templates/starter/docs/toolcraft/schema-reference.md +43 -14
  24. package/templates/starter/gitignore +1 -0
  25. package/templates/starter/package.json +2 -0
  26. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  27. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  28. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  29. package/templates/starter/src/app/starter-acceptance.test.ts +739 -66
  30. package/templates/starter/src/app/starter-acceptance.ts +471 -12
  31. package/templates/ui/components/control-layout/index.tsx +8 -3
  32. package/templates/ui/components/controls/actions/actions-control.tsx +11 -3
  33. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  34. package/templates/ui/components/controls/color/index.ts +4 -1
  35. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  36. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  37. package/templates/ui/components/controls/index.ts +2 -0
  38. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  39. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  40. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  41. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  42. package/templates/ui/components/controls/vector/index.ts +1 -0
  43. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  44. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -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";
@@ -387,7 +388,7 @@ describe("ControlsPanel", () => {
387
388
  expect(inlineGroup?.textContent).toContain("Snap Y");
388
389
  });
389
390
 
390
- it("keeps wrapped local action buttons left aligned below their label", () => {
391
+ it("renders local action buttons as a two-column grid below their label", () => {
391
392
  const schema = defineToolcraft({
392
393
  canvas: { enabled: false },
393
394
  panels: {
@@ -421,10 +422,53 @@ describe("ControlsPanel", () => {
421
422
  '[data-slot="actions-control-buttons"]',
422
423
  );
423
424
 
424
- expect(actionField?.className).toContain("justify-between");
425
- expect(actionField?.className).toContain("flex-wrap");
426
- expect(actionButtons?.className).toContain("justify-start");
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");
427
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");
428
472
  });
429
473
 
430
474
  it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
@@ -474,6 +518,7 @@ describe("ControlsPanel", () => {
474
518
  expect(screen.getByText("Include")).toBeTruthy();
475
519
  expect(screen.getByRole("switch")).toBeTruthy();
476
520
  expect(toggleParameterGroup).toBeTruthy();
521
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
477
522
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
478
523
  "repeat(2, minmax(0, 1fr))",
479
524
  );
@@ -616,13 +661,62 @@ describe("ControlsPanel", () => {
616
661
  );
617
662
 
618
663
  expect(screen.getByText("Loop")).toBeTruthy();
619
- expect(screen.getByText("Duration")).toBeTruthy();
664
+ expect(screen.queryByText("Duration")).toBeNull();
620
665
  expect(toggleParameterGroup).toBeTruthy();
666
+ expect(toggleParameterGroup?.className).toContain("gap-x-2.5");
621
667
  expect(toggleParameterGroup?.getAttribute("style")).toContain(
622
668
  "repeat(2, minmax(0, 1fr))",
623
669
  );
624
670
  });
625
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
+
626
720
  it("renders compact select pairs inline with stacked full-width fields", () => {
627
721
  const schema = defineToolcraft({
628
722
  canvas: { enabled: false },
@@ -677,6 +771,7 @@ describe("ControlsPanel", () => {
677
771
  );
678
772
 
679
773
  expect(inlineGroup).toBeTruthy();
774
+ expect(inlineGroup?.className).toContain("gap-x-2.5");
680
775
  expect(screen.getByText("Format")).toBeTruthy();
681
776
  expect(screen.getByText("Resolution")).toBeTruthy();
682
777
  expect(triggers).toHaveLength(2);
@@ -1529,6 +1624,246 @@ describe("ControlsPanel", () => {
1529
1624
  "toneBias",
1530
1625
  "default",
1531
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" });
1532
1867
  });
1533
1868
 
1534
1869
  it("keeps explanatory label text in the native title instead of the visible label", () => {
@@ -1758,7 +2093,6 @@ describe("ControlsPanel", () => {
1758
2093
  step: 0.1,
1759
2094
  target: "animation.speed",
1760
2095
  type: "slider",
1761
- unit: "x",
1762
2096
  },
1763
2097
  },
1764
2098
  layoutGroups: [
@@ -1785,7 +2119,7 @@ describe("ControlsPanel", () => {
1785
2119
  expect(fpsSlider).toBeTruthy();
1786
2120
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1787
2121
  expect(container.textContent).toContain("17 fps");
1788
- expect(container.textContent).toContain("3.7x");
2122
+ expect(container.textContent).toContain("3.7");
1789
2123
  });
1790
2124
 
1791
2125
  it("passes schema disabled state into slider controls", () => {
@@ -2944,6 +3278,35 @@ describe("ControlsPanel", () => {
2944
3278
  });
2945
3279
  });
2946
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
+
2947
3310
  it("keeps two color opacity controls stacked even when an inline row is requested", () => {
2948
3311
  const schema = defineToolcraft({
2949
3312
  canvas: { enabled: true },
@@ -3416,19 +3779,17 @@ describe("ControlsPanel", () => {
3416
3779
  expect(gradientControlItem?.className).toContain(
3417
3780
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3418
3781
  );
3419
- expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe(
3420
- "both",
3421
- );
3782
+ expect(fontControlItem?.dataset.controlItemCompoundDividerPlacement).toBe("top");
3422
3783
  expect(fontControlItem?.className).toContain(
3423
3784
  "has-data-[control-section-divider=compound]:pt-[18px]",
3424
3785
  );
3425
- expect(fontControlItem?.className).toContain(
3786
+ expect(fontControlItem?.className).not.toContain(
3426
3787
  "has-data-[control-section-divider=compound]:pb-[18px]",
3427
3788
  );
3428
3789
  expect(fontControlItem?.className).toContain(
3429
3790
  "has-data-[control-section-divider=compound]:before:inset-x-3",
3430
3791
  );
3431
- expect(fontControlItem?.className).toContain(
3792
+ expect(fontControlItem?.className).not.toContain(
3432
3793
  "has-data-[control-section-divider=compound]:after:inset-x-3",
3433
3794
  );
3434
3795
  });
@@ -3948,7 +4309,7 @@ describe("ControlsPanel", () => {
3948
4309
  const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3949
4310
 
3950
4311
  expect(screen.getByText("Resolution scale")).toBeTruthy();
3951
- expect(screen.getByText("2x")).toBeTruthy();
4312
+ expect(screen.getByText("2")).toBeTruthy();
3952
4313
  expect(values["canvas.renderScale"]).toBe(2);
3953
4314
  });
3954
4315
 
@@ -47,6 +47,7 @@ import {
47
47
  type GradientStop,
48
48
  type GradientType,
49
49
  type ImagePickerItem,
50
+ type VectorPadCoordinateMode,
50
51
  type VectorPadVariant,
51
52
  } from "@repo/ui";
52
53
 
@@ -421,6 +422,17 @@ function asVectorPadVariant(value: string | undefined): VectorPadVariant {
421
422
  return "default";
422
423
  }
423
424
 
425
+ function asVectorPadCoordinateMode(
426
+ value: string | undefined,
427
+ padVariant: VectorPadVariant,
428
+ ): VectorPadCoordinateMode {
429
+ if (value === "cartesian" || value === "screen") {
430
+ return value;
431
+ }
432
+
433
+ return padVariant === "default" ? "screen" : "cartesian";
434
+ }
435
+
424
436
  function asCurveInterpolation(value: string | undefined): CurveInterpolation | undefined {
425
437
  return value === "monotone" || value === "smooth" ? value : undefined;
426
438
  }
@@ -1154,6 +1166,15 @@ function hasSliderControl(
1154
1166
  );
1155
1167
  }
1156
1168
 
1169
+ function hasSegmentedControl(
1170
+ controlsById: Record<string, ToolcraftControlSchema>,
1171
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
1172
+ ): boolean {
1173
+ return layoutGroup.controls.some(
1174
+ (controlId) => controlsById[controlId]?.type === "segmented",
1175
+ );
1176
+ }
1177
+
1157
1178
  function hasSectionedCompoundControl(
1158
1179
  controlsById: Record<string, ToolcraftControlSchema>,
1159
1180
  layoutGroup: ToolcraftControlLayoutGroupSchema,
@@ -1214,6 +1235,22 @@ function isToggleParameterLayoutGroup(
1214
1235
  return booleanControlCount === 1 && parameterControlCount === 1;
1215
1236
  }
1216
1237
 
1238
+ function shouldHideToggleParameterControlLabel({
1239
+ control,
1240
+ controlsById,
1241
+ layoutGroup,
1242
+ }: {
1243
+ control: ToolcraftControlSchema;
1244
+ controlsById: Record<string, ToolcraftControlSchema>;
1245
+ layoutGroup: ToolcraftControlLayoutGroupSchema | undefined;
1246
+ }): boolean {
1247
+ return Boolean(
1248
+ layoutGroup &&
1249
+ isToggleParameterLayoutGroup(controlsById, layoutGroup) &&
1250
+ !isBooleanControl(control),
1251
+ );
1252
+ }
1253
+
1217
1254
  function getControlTargetEntityKey(control: ToolcraftControlSchema): string | null {
1218
1255
  const parts = control.target.split(".");
1219
1256
 
@@ -1409,6 +1446,7 @@ function getInlineLayoutGroupByControlId({
1409
1446
  if (
1410
1447
  hasColorOpacityControl(controlsById, layoutGroup) ||
1411
1448
  hasSliderControl(controlsById, layoutGroup) ||
1449
+ hasSegmentedControl(controlsById, layoutGroup) ||
1412
1450
  hasSectionedCompoundControl(controlsById, layoutGroup) ||
1413
1451
  hasUnsafeInlineSwitchLabels(controlsById, layoutGroup)
1414
1452
  ) {
@@ -1893,6 +1931,10 @@ export function ControlsPanel({
1893
1931
  providerKey: string;
1894
1932
  sectionTitle: string | undefined;
1895
1933
  }): React.ReactNode {
1934
+ if (control.label === false) {
1935
+ return children;
1936
+ }
1937
+
1896
1938
  const help = getControlHelpText({ control, label, sectionTitle });
1897
1939
 
1898
1940
  if (!help) {
@@ -2404,13 +2446,23 @@ export function ControlsPanel({
2404
2446
  }
2405
2447
 
2406
2448
  const [id, rawControl] = group.entry;
2407
- const isInInlineLayoutGroup = inlineLayoutGroupByControlId.has(id);
2449
+ const inlineLayoutGroup = inlineLayoutGroupByControlId.get(id);
2450
+ const isInInlineLayoutGroup = Boolean(inlineLayoutGroup);
2408
2451
  const disabled = isControlDisabled(rawControl);
2409
- const control =
2452
+ const resolvedControl =
2410
2453
  disabled === Boolean(rawControl.disabled)
2411
2454
  ? rawControl
2412
2455
  : { ...rawControl, disabled };
2413
- const name = getControlName(id, control.label);
2456
+ const shouldHideLabel = shouldHideToggleParameterControlLabel({
2457
+ control: resolvedControl,
2458
+ controlsById: visibleControls,
2459
+ layoutGroup: inlineLayoutGroup,
2460
+ });
2461
+ const control =
2462
+ shouldHideLabel && resolvedControl.label !== false
2463
+ ? { ...resolvedControl, label: false }
2464
+ : resolvedControl;
2465
+ const name = getControlName(id, resolvedControl.label);
2414
2466
  const value = getControlValue(control);
2415
2467
  const usesHeaderKeyframeAction = control.target === headerKeyframeTarget;
2416
2468
  const commitWithLabel =
@@ -2522,6 +2574,7 @@ export function ControlsPanel({
2522
2574
  key={id}
2523
2575
  name={name}
2524
2576
  onValueChange={commit}
2577
+ showLabel={control.label !== false}
2525
2578
  value={asString(value)}
2526
2579
  />
2527
2580
  ),
@@ -2863,6 +2916,7 @@ export function ControlsPanel({
2863
2916
  key={id}
2864
2917
  name={name}
2865
2918
  onValueChange={commit}
2919
+ showLabel={control.label !== false}
2866
2920
  start={rangeValue.start}
2867
2921
  />
2868
2922
  ),
@@ -2929,6 +2983,7 @@ export function ControlsPanel({
2929
2983
  name={name}
2930
2984
  onValueChange={commit}
2931
2985
  options={control.options ?? []}
2986
+ showLabel={control.label !== false}
2932
2987
  value={asString(value, control.options?.[0]?.value ?? "")}
2933
2988
  />
2934
2989
  );
@@ -3008,6 +3063,7 @@ export function ControlsPanel({
3008
3063
  key={id}
3009
3064
  name={name}
3010
3065
  onValueChange={commit}
3066
+ showLabel={control.label !== false}
3011
3067
  value={asString(value)}
3012
3068
  />
3013
3069
  ),
@@ -3020,6 +3076,7 @@ export function ControlsPanel({
3020
3076
 
3021
3077
  case "vector": {
3022
3078
  const vectorValue = asVectorValue(value);
3079
+ const padVariant = asVectorPadVariant(control.variant);
3023
3080
 
3024
3081
  return withKeyframeLabelAction({
3025
3082
  children: (
@@ -3028,8 +3085,12 @@ export function ControlsPanel({
3028
3085
  key={id}
3029
3086
  name={name}
3030
3087
  onValueChange={commit}
3088
+ padCoordinateMode={asVectorPadCoordinateMode(
3089
+ control.coordinateMode,
3090
+ padVariant,
3091
+ )}
3031
3092
  padShape={vectorPadShape}
3032
- padVariant={asVectorPadVariant(control.variant)}
3093
+ padVariant={padVariant}
3033
3094
  x={vectorValue.x}
3034
3095
  xLabel={control.xLabel}
3035
3096
  y={vectorValue.y}
@@ -837,7 +837,6 @@ describe("defineToolcraft", () => {
837
837
  step: 0.25,
838
838
  target: "canvas.renderScale",
839
839
  type: "slider",
840
- unit: "x",
841
840
  variant: "discrete",
842
841
  });
843
842
  });
@@ -1397,6 +1396,51 @@ describe("defineToolcraft", () => {
1397
1396
  ]);
1398
1397
  });
1399
1398
 
1399
+ it("keeps mode-gated standalone controls inside their semantic section", () => {
1400
+ const app = defineToolcraft({
1401
+ canvas: { enabled: true },
1402
+ panels: {
1403
+ controls: {
1404
+ sections: [
1405
+ {
1406
+ controls: {
1407
+ sourceMode: {
1408
+ defaultValue: "preset",
1409
+ label: "Source",
1410
+ options: [
1411
+ { label: "Preset", value: "preset" },
1412
+ { label: "Image", value: "image" },
1413
+ ],
1414
+ target: "source.mode",
1415
+ type: "segmented",
1416
+ },
1417
+ sourceUpload: {
1418
+ accept: "image/*",
1419
+ assetKind: "image",
1420
+ defaultValue: null,
1421
+ label: "Image",
1422
+ target: "source.upload",
1423
+ type: "fileDrop",
1424
+ visibleWhen: { equals: "image", target: "source.mode" },
1425
+ },
1426
+ },
1427
+ title: "Source",
1428
+ },
1429
+ ],
1430
+ title: "Controls",
1431
+ },
1432
+ },
1433
+ });
1434
+
1435
+ expect(app.panels.controls?.sections).toHaveLength(1);
1436
+ expect(app.panels.controls?.sections[0]?.title).toBe("Source");
1437
+ expect(app.panels.controls?.sections[0]?.layout).toBeUndefined();
1438
+ expect(Object.keys(app.panels.controls?.sections[0]?.controls ?? {})).toEqual([
1439
+ "sourceMode",
1440
+ "sourceUpload",
1441
+ ]);
1442
+ });
1443
+
1400
1444
  it("preserves controls panel action sections and render metadata", () => {
1401
1445
  const app = defineToolcraft({
1402
1446
  canvas: { enabled: true },