@pixel-point/toolcraft 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/generate.test.mjs +1 -1
  3. package/templates/runtime/contracts/component-contracts.test.ts +123 -15
  4. package/templates/runtime/contracts/component-contracts.ts +93 -19
  5. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  6. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  7. package/templates/runtime/export/export.test.ts +31 -0
  8. package/templates/runtime/export/export.ts +41 -0
  9. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  10. package/templates/runtime/react/canvas-shell.tsx +178 -23
  11. package/templates/runtime/react/control-conditions.ts +166 -0
  12. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  13. package/templates/runtime/react/controls-panel.test.tsx +590 -9
  14. package/templates/runtime/react/controls-panel.tsx +472 -10
  15. package/templates/runtime/react/media-file.ts +19 -0
  16. package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
  17. package/templates/runtime/schema/define-toolcraft.ts +11 -3
  18. package/templates/runtime/schema/types.ts +27 -0
  19. package/templates/runtime/state/reducer.test.ts +357 -0
  20. package/templates/runtime/state/reducer.ts +195 -9
  21. package/templates/runtime/state/types.ts +12 -2
  22. package/templates/runtime/testing/performance.test.ts +1282 -48
  23. package/templates/runtime/testing/performance.ts +676 -39
  24. package/templates/starter/AGENTS.md +13 -12
  25. package/templates/starter/docs/toolcraft/README.md +4 -3
  26. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
  27. package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
  28. package/templates/starter/docs/toolcraft/component-rules.md +20 -7
  29. package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
  30. package/templates/starter/docs/toolcraft/performance.md +51 -13
  31. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  32. package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
  33. package/templates/starter/docs/toolcraft/workflow.md +7 -10
  34. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  35. package/templates/starter/e2e/performance-helpers.ts +197 -0
  36. package/templates/starter/package.json +4 -1
  37. package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
  38. package/templates/starter/src/app/starter-acceptance.ts +269 -12
  39. package/templates/starter/src/app/starter-performance.test.ts +67 -6
  40. package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  44. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  45. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
  46. package/templates/ui/components/controls/index.ts +9 -0
  47. package/templates/ui/components/panel/panel-section.tsx +53 -1
  48. package/templates/ui/index.ts +1 -0
@@ -248,6 +248,9 @@ function StateProbe() {
248
248
  {state.canvas.size.width},{state.canvas.size.height}
249
249
  </span>
250
250
  <span data-testid="media-count">{state.mediaAssets.length}</span>
251
+ <span data-testid="media-ids">
252
+ {state.mediaAssets.map((asset) => asset.id).join(",")}
253
+ </span>
251
254
  <span data-testid="timeline-expanded">{String(state.timeline.expanded)}</span>
252
255
  <span data-testid="timeline-keyframes">
253
256
  {JSON.stringify(state.timeline.keyframeGroups)}
@@ -384,6 +387,46 @@ describe("ControlsPanel", () => {
384
387
  expect(inlineGroup?.textContent).toContain("Snap Y");
385
388
  });
386
389
 
390
+ it("keeps wrapped local action buttons left aligned below their label", () => {
391
+ const schema = defineToolcraft({
392
+ canvas: { enabled: false },
393
+ panels: {
394
+ controls: {
395
+ sections: [
396
+ {
397
+ controls: {
398
+ masks: {
399
+ actions: [
400
+ { label: "Rect", value: "mask.add.rectangle" },
401
+ { label: "Circle", value: "mask.add.circle" },
402
+ { label: "Tri", value: "mask.add.triangle" },
403
+ { label: "Delete", value: "mask.delete" },
404
+ ],
405
+ defaultValue: [],
406
+ label: "Masks",
407
+ target: "composition.masks",
408
+ type: "actions",
409
+ },
410
+ },
411
+ title: "Composition",
412
+ },
413
+ ],
414
+ title: "Controls",
415
+ },
416
+ },
417
+ });
418
+ const { container } = renderControlsPanelWithSchema(schema);
419
+ const actionField = container.querySelector('[data-slot="actions-control"]');
420
+ const actionButtons = container.querySelector(
421
+ '[data-slot="actions-control-buttons"]',
422
+ );
423
+
424
+ expect(actionField?.className).toContain("justify-between");
425
+ expect(actionField?.className).toContain("flex-wrap");
426
+ expect(actionButtons?.className).toContain("justify-start");
427
+ expect(actionButtons?.className).not.toContain("ml-auto");
428
+ });
429
+
387
430
  it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
388
431
  const schema = defineToolcraft({
389
432
  canvas: { enabled: false },
@@ -448,35 +491,35 @@ describe("ControlsPanel", () => {
448
491
  defaultValue: { hex: "#DFFF1A" },
449
492
  description: "Sets the first bead color.",
450
493
  label: "Color 1",
451
- target: "beads.color1",
494
+ target: "palette.accent1",
452
495
  type: "color",
453
496
  },
454
497
  color2: {
455
498
  defaultValue: { hex: "#8CFF3A" },
456
499
  description: "Sets the second bead color.",
457
500
  label: "Color 2",
458
- target: "beads.color2",
501
+ target: "palette.accent2",
459
502
  type: "color",
460
503
  },
461
504
  color3: {
462
505
  defaultValue: { hex: "#F4FF5A" },
463
506
  description: "Sets the third bead color.",
464
507
  label: "Color 3",
465
- target: "beads.color3",
508
+ target: "palette.accent3",
466
509
  type: "color",
467
510
  },
468
511
  color4: {
469
512
  defaultValue: { hex: "#B8FF2E" },
470
513
  description: "Sets the fourth bead color.",
471
514
  label: "Color 4",
472
- target: "beads.color4",
515
+ target: "palette.accent4",
473
516
  type: "color",
474
517
  },
475
518
  color5: {
476
519
  defaultValue: { hex: "#ECFF68" },
477
520
  description: "Sets the fifth bead color.",
478
521
  label: "Color 5",
479
- target: "beads.color5",
522
+ target: "palette.accent5",
480
523
  type: "color",
481
524
  },
482
525
  colorSpread: {
@@ -486,7 +529,7 @@ describe("ControlsPanel", () => {
486
529
  label: "Spread",
487
530
  max: 100,
488
531
  min: 0,
489
- target: "beads.colorSpread",
532
+ target: "palette.spread",
490
533
  type: "slider",
491
534
  unit: "%",
492
535
  },
@@ -503,7 +546,7 @@ describe("ControlsPanel", () => {
503
546
  layout: "inline",
504
547
  },
505
548
  ],
506
- title: "Bead Colors",
549
+ title: "Accent Shades",
507
550
  },
508
551
  {
509
552
  controls: {
@@ -1102,7 +1145,6 @@ describe("ControlsPanel", () => {
1102
1145
  controls: {
1103
1146
  font: {
1104
1147
  defaultValue: {
1105
- color: "#FFFFFF",
1106
1148
  fontId: "inter",
1107
1149
  fontSize: 16,
1108
1150
  fontWeight: "400",
@@ -1271,6 +1313,24 @@ describe("ControlsPanel", () => {
1271
1313
  });
1272
1314
  });
1273
1315
 
1316
+ fireEvent.change(fontSizeInput, {
1317
+ target: { value: "768" },
1318
+ });
1319
+ fireEvent.blur(fontSizeInput);
1320
+
1321
+ await waitFor(() => {
1322
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1323
+ color: "#FFFFFF",
1324
+ fontId: "roboto",
1325
+ fontSize: 768,
1326
+ fontWeight: "400",
1327
+ letterSpacing: "wider",
1328
+ lineHeight: "relaxed",
1329
+ opacity: 100,
1330
+ textCase: "uppercase",
1331
+ });
1332
+ });
1333
+
1274
1334
  const colorHexInput = screen.getByLabelText("Color hex") as HTMLInputElement;
1275
1335
  fireEvent.change(colorHexInput, {
1276
1336
  target: { value: "#C1FF00" },
@@ -1286,7 +1346,7 @@ describe("ControlsPanel", () => {
1286
1346
  expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1287
1347
  color: "#C1FF00",
1288
1348
  fontId: "roboto",
1289
- fontSize: 24,
1349
+ fontSize: 768,
1290
1350
  fontWeight: "400",
1291
1351
  letterSpacing: "wider",
1292
1352
  lineHeight: "relaxed",
@@ -2483,6 +2543,22 @@ describe("ControlsPanel", () => {
2483
2543
  );
2484
2544
  expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2485
2545
  expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2546
+ expect(screen.getByTestId("media-ids").textContent).toBe("media-1,media-2");
2547
+
2548
+ const firstPreview = screen
2549
+ .getByRole("img", { name: "one.svg" })
2550
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2551
+ const secondPreview = screen
2552
+ .getByRole("img", { name: "two.svg" })
2553
+ .closest<HTMLElement>('[data-slot="file-upload-preview-item"]');
2554
+
2555
+ if (!firstPreview || !secondPreview) {
2556
+ throw new Error("Expected multi-image previews to render");
2557
+ }
2558
+
2559
+ expect(firstPreview.getAttribute("role")).toBe("button");
2560
+ expect(firstPreview.getAttribute("tabindex")).toBe("0");
2561
+ expect(firstPreview.className).toContain("cursor-grab");
2486
2562
 
2487
2563
  fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2488
2564
 
@@ -2490,6 +2566,118 @@ describe("ControlsPanel", () => {
2490
2566
  expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2491
2567
  });
2492
2568
 
2569
+ it("renders arbitrary file uploads as a sortable file list", () => {
2570
+ const schema = defineToolcraft({
2571
+ canvas: { enabled: true, upload: true },
2572
+ panels: {
2573
+ controls: {
2574
+ sections: [
2575
+ {
2576
+ controls: {
2577
+ source: {
2578
+ assetKind: "file",
2579
+ label: "Files",
2580
+ multiple: true,
2581
+ target: "input.files",
2582
+ type: "fileDrop",
2583
+ },
2584
+ },
2585
+ layout: "standalone",
2586
+ title: "Input",
2587
+ },
2588
+ ],
2589
+ title: "Generation Controls",
2590
+ },
2591
+ },
2592
+ });
2593
+
2594
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2595
+ layers: [
2596
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2597
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2598
+ ],
2599
+ mediaAssets: [
2600
+ {
2601
+ assetKind: "file",
2602
+ dataUrl: "data:text/plain;base64,b25l",
2603
+ fileName: "one.txt",
2604
+ id: "media-1",
2605
+ layerId: "layer-1",
2606
+ mimeType: "text/plain",
2607
+ position: { x: 0, y: 0 },
2608
+ sourceTarget: "input.files",
2609
+ },
2610
+ {
2611
+ assetKind: "file",
2612
+ dataUrl: "data:application/json;base64,e30=",
2613
+ fileName: "two.json",
2614
+ id: "media-2",
2615
+ layerId: "layer-2",
2616
+ mimeType: "application/json",
2617
+ position: { x: 0, y: 0 },
2618
+ sourceTarget: "input.files",
2619
+ },
2620
+ ],
2621
+ selectedLayerId: "layer-2",
2622
+ });
2623
+
2624
+ const fileList = container.querySelector('[data-slot="file-upload-file-list"]');
2625
+ const fileRows = container.querySelectorAll('[data-slot="file-upload-file-item"]');
2626
+ const fileDividers = container.querySelectorAll('[data-slot="file-upload-file-divider"]');
2627
+
2628
+ expect(fileList).toBeTruthy();
2629
+ expect(fileRows).toHaveLength(2);
2630
+ expect(fileDividers).toHaveLength(2);
2631
+ expect(screen.getByText("one.txt")).toBeTruthy();
2632
+ expect(screen.getByText("two.json")).toBeTruthy();
2633
+ expect(screen.getByText("one.txt").className).toContain("text-xs");
2634
+ expect(screen.getByText("one.txt").className).not.toContain("truncate");
2635
+ expect(screen.getByText("one.txt").className).toContain("mask-image");
2636
+ expect(screen.getByRole("button", { name: "Remove one.txt" })).toBeTruthy();
2637
+ expect(screen.getByRole("button", { name: "Add a new file" })).toBeTruthy();
2638
+ expect(fileRows[0]?.className).not.toContain("border-b");
2639
+ expect(fileRows[0]?.className).toContain("hover:bg");
2640
+ expect(fileDividers[0]?.className).toContain("var(--border)_5%");
2641
+ expect(fileRows[0]?.getAttribute("role")).toBe("button");
2642
+ expect(fileRows[0]?.getAttribute("tabindex")).toBe("0");
2643
+
2644
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.json" }));
2645
+
2646
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2647
+ expect(screen.queryByText("two.json")).toBeNull();
2648
+ });
2649
+
2650
+ it("renders the arbitrary file uploader empty state", () => {
2651
+ const schema = defineToolcraft({
2652
+ canvas: { enabled: true, upload: true },
2653
+ panels: {
2654
+ controls: {
2655
+ sections: [
2656
+ {
2657
+ controls: {
2658
+ source: {
2659
+ assetKind: "file",
2660
+ label: "Files",
2661
+ target: "input.files",
2662
+ type: "fileDrop",
2663
+ },
2664
+ },
2665
+ layout: "standalone",
2666
+ title: "Input",
2667
+ },
2668
+ ],
2669
+ title: "Generation Controls",
2670
+ },
2671
+ },
2672
+ });
2673
+
2674
+ renderControlsPanelWithSchema(schema);
2675
+
2676
+ expect(screen.getByText("Click to upload a file")).toBeTruthy();
2677
+ expect(screen.getByText("or drag it onto the canvas")).toBeTruthy();
2678
+ expect(screen.queryByText("Click to upload an image")).toBeNull();
2679
+ });
2680
+
2493
2681
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2494
2682
  const schema = defineToolcraft({
2495
2683
  canvas: { enabled: true, upload: true },
@@ -2556,6 +2744,65 @@ describe("ControlsPanel", () => {
2556
2744
  expect(colorControlList?.children).toHaveLength(1);
2557
2745
  });
2558
2746
 
2747
+ it("keeps an odd trailing plain color half-width in a color bank", () => {
2748
+ const schema = defineToolcraft({
2749
+ canvas: { enabled: true },
2750
+ panels: {
2751
+ controls: {
2752
+ sections: [
2753
+ {
2754
+ controls: {
2755
+ accent1: {
2756
+ defaultValue: { hex: "#9CE6FF" },
2757
+ label: "Accent 1",
2758
+ target: "accent.shade1",
2759
+ type: "color",
2760
+ },
2761
+ accent2: {
2762
+ defaultValue: { hex: "#FF7A90" },
2763
+ label: "Accent 2",
2764
+ target: "accent.shade2",
2765
+ type: "color",
2766
+ },
2767
+ accent3: {
2768
+ defaultValue: { hex: "#FFD166" },
2769
+ label: "Accent 3",
2770
+ target: "accent.shade3",
2771
+ type: "color",
2772
+ },
2773
+ accent4: {
2774
+ defaultValue: { hex: "#8BF38B" },
2775
+ label: "Accent 4",
2776
+ target: "accent.shade4",
2777
+ type: "color",
2778
+ },
2779
+ accent5: {
2780
+ defaultValue: { hex: "#BDA8FF" },
2781
+ label: "Accent 5",
2782
+ target: "accent.shade5",
2783
+ type: "color",
2784
+ },
2785
+ },
2786
+ title: "Accent Shades",
2787
+ },
2788
+ ],
2789
+ title: "Generation Controls",
2790
+ },
2791
+ },
2792
+ });
2793
+
2794
+ const { container } = renderControlsPanelWithSchema(schema);
2795
+ const pairedColorRows = container.querySelectorAll('[data-slot="color-control-grid"]');
2796
+ const trailingColorButtonGroup = screen
2797
+ .getByLabelText("Accent 5 hex")
2798
+ .closest('[data-slot="button-group"]');
2799
+ const trailingColorWidthContainer = trailingColorButtonGroup?.parentElement;
2800
+
2801
+ expect(pairedColorRows).toHaveLength(2);
2802
+ expect(trailingColorWidthContainer?.className).toContain("w-1/2");
2803
+ expect(trailingColorWidthContainer?.className).not.toContain("w-full");
2804
+ });
2805
+
2559
2806
  it("omits visible color field labels only inside color-only sections", () => {
2560
2807
  const schema = defineToolcraft({
2561
2808
  canvas: { enabled: true },
@@ -3264,6 +3511,316 @@ describe("ControlsPanel", () => {
3264
3511
  expect(screen.getByTestId("enabled-value").textContent).toBe("true");
3265
3512
  });
3266
3513
 
3514
+ it("resets only controls from the selected section header action", () => {
3515
+ const schema = defineToolcraft({
3516
+ canvas: { enabled: false },
3517
+ panels: {
3518
+ controls: {
3519
+ sections: [
3520
+ {
3521
+ controls: {
3522
+ toneName: {
3523
+ defaultValue: "Soft",
3524
+ label: "Tone name",
3525
+ target: "tone.name",
3526
+ type: "text",
3527
+ },
3528
+ },
3529
+ title: "Tone",
3530
+ },
3531
+ {
3532
+ controls: {
3533
+ prompt: {
3534
+ defaultValue: "Poster",
3535
+ label: "Prompt",
3536
+ target: "generation.prompt",
3537
+ type: "text",
3538
+ },
3539
+ },
3540
+ title: "Output",
3541
+ },
3542
+ ],
3543
+ title: "Controls",
3544
+ },
3545
+ },
3546
+ });
3547
+ const { container } = renderControlsPanelWithSchema(schema);
3548
+
3549
+ fireEvent.change(screen.getByDisplayValue("Soft"), {
3550
+ target: { value: "Sharp" },
3551
+ });
3552
+ fireEvent.change(screen.getByDisplayValue("Poster"), {
3553
+ target: { value: "Banner" },
3554
+ });
3555
+
3556
+ const toneHeader = screen
3557
+ .getByText("Tone")
3558
+ .closest<HTMLElement>('[data-slot="control-section-header"]');
3559
+ const headerButtons = toneHeader?.querySelectorAll("button");
3560
+
3561
+ expect(headerButtons?.[0]?.getAttribute("data-control-section-reset-button")).toBe("");
3562
+ expect(headerButtons?.[1]?.getAttribute("data-control-section-collapse-button")).toBe("");
3563
+
3564
+ fireEvent.click(screen.getByRole("button", { name: "Reset Tone section" }));
3565
+
3566
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3567
+
3568
+ expect(values["tone.name"]).toBe("Soft");
3569
+ expect(values["generation.prompt"]).toBe("Banner");
3570
+ expect(container.querySelector('[data-control-section-reset-button]')).toBeTruthy();
3571
+ });
3572
+
3573
+ it("clears file upload previews from the selected section reset action", () => {
3574
+ const schema = defineToolcraft({
3575
+ canvas: { enabled: true, upload: true },
3576
+ panels: {
3577
+ controls: {
3578
+ sections: [
3579
+ {
3580
+ controls: {
3581
+ source: {
3582
+ defaultValue: null,
3583
+ label: "Image",
3584
+ target: "media.source",
3585
+ type: "fileDrop",
3586
+ },
3587
+ },
3588
+ title: "Source Image",
3589
+ },
3590
+ ],
3591
+ title: "Controls",
3592
+ },
3593
+ },
3594
+ });
3595
+
3596
+ renderControlsPanelWithSchema(schema, {}, {
3597
+ mediaAssets: [
3598
+ {
3599
+ dataUrl:
3600
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E",
3601
+ fileName: "source.svg",
3602
+ id: "media-1",
3603
+ layerId: "layer-1",
3604
+ mimeType: "image/svg+xml",
3605
+ position: { x: 0, y: 0 },
3606
+ size: { height: 300, unit: "px", width: 400 },
3607
+ sourceTarget: "media.source",
3608
+ },
3609
+ ],
3610
+ });
3611
+
3612
+ expect(screen.getByRole("img", { name: "source.svg" })).toBeTruthy();
3613
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
3614
+
3615
+ fireEvent.click(screen.getByRole("button", { name: "Reset Source Image section" }));
3616
+
3617
+ expect(screen.queryByRole("img", { name: "source.svg" })).toBeNull();
3618
+ expect(screen.getByTestId("media-count").textContent).toBe("0");
3619
+ });
3620
+
3621
+ it("renders collection actions as add/remove controls for runtime item arrays", () => {
3622
+ const schema = defineToolcraft({
3623
+ canvas: { enabled: false },
3624
+ panels: {
3625
+ controls: {
3626
+ sections: [
3627
+ {
3628
+ controls: {
3629
+ colors: {
3630
+ defaultValue: [{ hex: "#DFFF1A" }, { hex: "#8CFF3A" }],
3631
+ itemControl: {
3632
+ defaultValue: { hex: "#C1FF00" },
3633
+ label: "Color",
3634
+ type: "color",
3635
+ },
3636
+ label: "Colors",
3637
+ minItems: 1,
3638
+ recommendedMaxItems: 2,
3639
+ target: "bead.colors",
3640
+ type: "collectionActions",
3641
+ },
3642
+ },
3643
+ title: "Bead Colors",
3644
+ },
3645
+ ],
3646
+ title: "Controls",
3647
+ },
3648
+ },
3649
+ });
3650
+
3651
+ const { container } = renderControlsPanelWithSchema(schema);
3652
+
3653
+ const addButton = screen.getByRole("button", { name: "Add Color" });
3654
+ const removeButton = screen.getByRole("button", { name: "Remove Color" });
3655
+ const collectionHeader = container.querySelector(
3656
+ '[data-slot="collection-actions-control-header"]',
3657
+ );
3658
+ const colorGrid = container.querySelector(
3659
+ '[data-slot="collection-actions-items-grid"]',
3660
+ );
3661
+
3662
+ expect(collectionHeader?.textContent).toContain("Colors");
3663
+ expect(collectionHeader?.lastElementChild?.contains(removeButton)).toBe(true);
3664
+ expect(collectionHeader?.lastElementChild?.contains(addButton)).toBe(true);
3665
+ expect(colorGrid).toBeTruthy();
3666
+ expect(colorGrid?.children).toHaveLength(2);
3667
+ expect(screen.queryByText("Color 1")).toBeNull();
3668
+ expect(screen.queryByText("Color 2")).toBeNull();
3669
+
3670
+ fireEvent.click(addButton);
3671
+ fireEvent.click(addButton);
3672
+
3673
+ let values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3674
+
3675
+ expect(values["bead.colors"]).toHaveLength(4);
3676
+ expect(screen.queryByText("Color 4")).toBeNull();
3677
+ expect(colorGrid?.children).toHaveLength(4);
3678
+ expect((addButton as HTMLButtonElement).disabled).toBe(false);
3679
+
3680
+ fireEvent.click(removeButton);
3681
+ fireEvent.click(removeButton);
3682
+ fireEvent.click(removeButton);
3683
+
3684
+ values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3685
+
3686
+ expect(values["bead.colors"]).toHaveLength(1);
3687
+ expect((removeButton as HTMLButtonElement).disabled).toBe(true);
3688
+ });
3689
+
3690
+ it("renders collection item controls beyond color lists", () => {
3691
+ const schema = defineToolcraft({
3692
+ canvas: { enabled: false },
3693
+ panels: {
3694
+ controls: {
3695
+ sections: [
3696
+ {
3697
+ controls: {
3698
+ labels: {
3699
+ defaultValue: ["Alpha"],
3700
+ itemControl: {
3701
+ defaultValue: "New label",
3702
+ label: "Label",
3703
+ type: "text",
3704
+ },
3705
+ minItems: 1,
3706
+ target: "glyph.labels",
3707
+ type: "collectionActions",
3708
+ },
3709
+ },
3710
+ title: "Glyph Labels",
3711
+ },
3712
+ ],
3713
+ title: "Controls",
3714
+ },
3715
+ },
3716
+ });
3717
+
3718
+ renderControlsPanelWithSchema(schema);
3719
+
3720
+ fireEvent.click(screen.getByRole("button", { name: "Add Label" }));
3721
+
3722
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3723
+
3724
+ expect(values["glyph.labels"]).toEqual(["Alpha", "New label"]);
3725
+ expect(screen.getByDisplayValue("Alpha")).toBeTruthy();
3726
+ expect(screen.getByDisplayValue("New label")).toBeTruthy();
3727
+ });
3728
+
3729
+ it("keeps a collection font picker color popover open while editing its color surface", async () => {
3730
+ const schema = defineToolcraft({
3731
+ canvas: { enabled: false },
3732
+ panels: {
3733
+ controls: {
3734
+ sections: [
3735
+ {
3736
+ controls: {
3737
+ textStyles: {
3738
+ defaultValue: [
3739
+ {
3740
+ color: "#FFFFFF",
3741
+ fontId: "inter",
3742
+ fontSize: 16,
3743
+ fontWeight: "400",
3744
+ letterSpacing: "normal",
3745
+ lineHeight: "normal",
3746
+ opacity: 100,
3747
+ textCase: "original",
3748
+ },
3749
+ ],
3750
+ itemControl: {
3751
+ defaultValue: {
3752
+ color: "#FFFFFF",
3753
+ fontId: "inter",
3754
+ fontSize: 16,
3755
+ fontWeight: "400",
3756
+ letterSpacing: "normal",
3757
+ lineHeight: "normal",
3758
+ opacity: 100,
3759
+ textCase: "original",
3760
+ },
3761
+ label: "Font",
3762
+ type: "fontPicker",
3763
+ },
3764
+ label: "Text styles",
3765
+ minItems: 1,
3766
+ target: "text.styles",
3767
+ type: "collectionActions",
3768
+ },
3769
+ },
3770
+ title: "Typography",
3771
+ },
3772
+ ],
3773
+ title: "Controls",
3774
+ },
3775
+ },
3776
+ });
3777
+
3778
+ const { container } = renderControlsPanelWithSchema(schema);
3779
+
3780
+ fireEvent.click(screen.getByRole("button", { name: "Pick Color" }));
3781
+
3782
+ const surface = await waitFor(() => {
3783
+ const nextSurface = container.ownerDocument.body.querySelector<HTMLElement>(
3784
+ '[data-slot="style-guide-color-surface"]',
3785
+ );
3786
+ if (!nextSurface) {
3787
+ throw new Error("Expected color surface to be open.");
3788
+ }
3789
+ return nextSurface;
3790
+ });
3791
+
3792
+ const surfaceRect = {
3793
+ bottom: 100,
3794
+ height: 100,
3795
+ left: 0,
3796
+ right: 100,
3797
+ toJSON: () => ({}),
3798
+ top: 0,
3799
+ width: 100,
3800
+ x: 0,
3801
+ y: 0,
3802
+ } as DOMRect;
3803
+ Object.defineProperty(surface, "getBoundingClientRect", {
3804
+ configurable: true,
3805
+ value: () => surfaceRect,
3806
+ });
3807
+
3808
+ fireEvent.pointerDown(surface, {
3809
+ buttons: 1,
3810
+ clientX: 72,
3811
+ clientY: 28,
3812
+ pointerId: 1,
3813
+ });
3814
+
3815
+ expect(
3816
+ container.ownerDocument.body.querySelector('[data-slot="style-guide-color-surface"]'),
3817
+ ).toBeTruthy();
3818
+
3819
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3820
+
3821
+ expect(values["text.styles"][0].color).not.toBe("#FFFFFF");
3822
+ });
3823
+
3267
3824
  it("commits canvas size controls to runtime state on blur or Enter", () => {
3268
3825
  const { container } = renderControlsPanel();
3269
3826
 
@@ -3832,6 +4389,30 @@ describe("ControlsPanel", () => {
3832
4389
  expect(container.querySelector('[data-slot="control-section-header"]')).toBeTruthy();
3833
4390
  });
3834
4391
 
4392
+ it("suppresses child control transitions when a section re-expands", () => {
4393
+ const schema = createSchema();
4394
+ renderControlsPanelWithSchema(schema);
4395
+ const basicTitle = screen.getByText("Basic");
4396
+ const header = basicTitle.closest<HTMLElement>('[data-slot="control-section-header"]');
4397
+ const section = basicTitle.closest("section");
4398
+ const initialBody = section?.querySelector<HTMLElement>(
4399
+ '[data-slot="panel-section-collapsible-body"]',
4400
+ );
4401
+
4402
+ fireEvent.click(header!);
4403
+ fireEvent.transitionEnd(initialBody!);
4404
+ fireEvent.click(header!);
4405
+
4406
+ const expandedBody = section?.querySelector<HTMLElement>(
4407
+ '[data-slot="panel-section-collapsible-body"]',
4408
+ );
4409
+ const innerControls = expandedBody?.firstElementChild as HTMLElement | null;
4410
+
4411
+ expect(expandedBody?.className).toContain("transition-[grid-template-rows,opacity]");
4412
+ expect(expandedBody?.className).toContain("grid-rows-[1fr]");
4413
+ expect(innerControls?.getAttribute("data-toolcraft-controls-mounting")).toBe("true");
4414
+ });
4415
+
3835
4416
  it("persists ordinary section collapse state as a UI preference across remounts", () => {
3836
4417
  const schema = createSchema();
3837
4418
  const { unmount } = renderControlsPanelWithSchema(schema);