@pixel-point/toolcraft 0.0.17 → 0.0.18

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 (196) hide show
  1. package/package.json +1 -1
  2. package/src/generate-test-doc-assertions.mjs +58 -68
  3. package/src/generate-test-doc-assertions.test.mjs +43 -0
  4. package/src/generate-test-doc-policy-assertions.mjs +92 -0
  5. package/src/generate-test-file-assertions.mjs +26 -75
  6. package/src/generate-test-product-iteration.test.mjs +17 -0
  7. package/src/generate-test-runtime-boundary-assertions.mjs +158 -0
  8. package/src/generate-test-standalone-assertions.mjs +10 -0
  9. package/templates/runtime/contracts/component-contracts.choices.test.ts +47 -3
  10. package/templates/runtime/contracts/component-contracts.choices.ts +48 -3
  11. package/templates/runtime/contracts/component-contracts.inputs.test.ts +6 -6
  12. package/templates/runtime/contracts/component-contracts.inputs.ts +6 -6
  13. package/templates/runtime/contracts/component-contracts.media-custom.test.ts +8 -2
  14. package/templates/runtime/contracts/component-contracts.media-custom.ts +8 -2
  15. package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +4 -4
  16. package/templates/runtime/contracts/component-contracts.runtime-labels.test.ts +7 -1
  17. package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +3 -0
  18. package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +3 -0
  19. package/templates/runtime/contracts/component-contracts.runtime.ts +12 -8
  20. package/templates/runtime/contracts/component-contracts.test.ts +2 -0
  21. package/templates/runtime/contracts/decision-contracts.test.ts +18 -0
  22. package/templates/runtime/contracts/decision-contracts.ts +10 -10
  23. package/templates/runtime/export/export-background.ts +0 -19
  24. package/templates/runtime/export/export.test.ts +45 -42
  25. package/templates/runtime/export/index.ts +44 -16
  26. package/templates/runtime/index.ts +2 -0
  27. package/templates/runtime/model-import/model-import-limits.ts +15 -4
  28. package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +11 -1
  29. package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +29 -0
  30. package/templates/runtime/react/app-shell/toolcraft-app.tsx +18 -8
  31. package/templates/runtime/react/canvas/canvas-shell-rendering.test.tsx +76 -0
  32. package/templates/runtime/react/canvas/canvas-shell-test-utils.tsx +13 -1
  33. package/templates/runtime/react/canvas/canvas-shell.tsx +16 -1
  34. package/templates/runtime/react/canvas/product-scene-surface.test.tsx +195 -0
  35. package/templates/runtime/react/canvas/product-scene-surface.tsx +155 -0
  36. package/templates/runtime/react/canvas/runtime-scene-export.ts +1 -1
  37. package/templates/runtime/react/controls-panel/__tests__/control-conditions.test.ts +61 -0
  38. package/templates/runtime/react/controls-panel/__tests__/controls-panel.collection-actions.test.tsx +89 -0
  39. package/templates/runtime/react/controls-panel/__tests__/controls-panel.control-visibility.test.tsx +40 -19
  40. package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +1 -1
  41. package/templates/runtime/react/controls-panel/__tests__/controls-panel.source-collection.test.tsx +62 -0
  42. package/templates/runtime/react/controls-panel/__tests__/controls-panel.source-color-bank.test.tsx +67 -0
  43. package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +2 -4
  44. package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +10 -4
  45. package/templates/runtime/react/controls-panel/conditions/control-conditions.ts +32 -119
  46. package/templates/runtime/react/controls-panel/layout/controls-panel-control-group.tsx +5 -2
  47. package/templates/runtime/react/controls-panel/layout/controls-panel-layout.tsx +10 -8
  48. package/templates/runtime/react/controls-panel/layout/controls-panel-section.tsx +3 -3
  49. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-item-fields.test.tsx +63 -0
  50. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-item-fields.tsx +138 -0
  51. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-items.tsx +104 -0
  52. package/templates/runtime/react/controls-panel/renderers/controls-panel-collection-renderer.tsx +115 -236
  53. package/templates/runtime/react/controls-panel/renderers/controls-panel-file-drop-item-controls.test.tsx +95 -0
  54. package/templates/runtime/react/controls-panel/renderers/controls-panel-file-drop-item-controls.tsx +82 -0
  55. package/templates/runtime/react/controls-panel/renderers/controls-panel-media-renderer.test.tsx +73 -1
  56. package/templates/runtime/react/controls-panel/renderers/controls-panel-media-renderer.tsx +50 -1
  57. package/templates/runtime/react/controls-panel/renderers/controls-panel-renderer-registry.test.ts +1 -1
  58. package/templates/runtime/react/controls-panel/renderers/controls-panel-renderer-registry.ts +2 -1
  59. package/templates/runtime/react/controls-panel/values/controls-panel-collection-values.ts +38 -6
  60. package/templates/runtime/react/controls-panel/values/controls-panel-value-labels.ts +1 -0
  61. package/templates/runtime/react/index.ts +4 -0
  62. package/templates/runtime/react/model-rendering/model-export.ts +1 -1
  63. package/templates/runtime/react/orientation-gizmo/orientation-gizmo-selection.ts +5 -5
  64. package/templates/runtime/react/orientation-gizmo/use-toolcraft-orientation-control-selection.ts +2 -2
  65. package/templates/runtime/scene/scene-bounds.test.ts +23 -0
  66. package/templates/runtime/scene/scene-bounds.ts +31 -17
  67. package/templates/runtime/schema/app-capabilities.test.ts +4 -0
  68. package/templates/runtime/schema/collection-item-controls.ts +29 -0
  69. package/templates/runtime/schema/control-applicability.test.ts +127 -0
  70. package/templates/runtime/schema/control-applicability.ts +433 -0
  71. package/templates/runtime/schema/control-schema-normalization.ts +177 -11
  72. package/templates/runtime/schema/controls-panel-actions.ts +2 -2
  73. package/templates/runtime/schema/controls-panel-section-layout.ts +14 -9
  74. package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +59 -0
  75. package/templates/runtime/schema/define-toolcraft.collection-actions-item-controls.test.ts +127 -0
  76. package/templates/runtime/schema/define-toolcraft.control-sections.test.ts +10 -4
  77. package/templates/runtime/schema/define-toolcraft.file-drop-item-controls.test.ts +98 -0
  78. package/templates/runtime/schema/define-toolcraft.file-drop-normalization.test.ts +27 -0
  79. package/templates/runtime/schema/define-toolcraft.segmented-control-fit.test.ts +20 -2
  80. package/templates/runtime/schema/define-toolcraft.setup-canvas.test.ts +16 -3
  81. package/templates/runtime/schema/define-toolcraft.source-collection.test.ts +79 -0
  82. package/templates/runtime/schema/runtime-setup-section.ts +24 -5
  83. package/templates/runtime/schema/types.ts +68 -13
  84. package/templates/starter/AGENTS.md +12 -10
  85. package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -31
  86. package/templates/starter/docs/toolcraft/agent-worklog.md +15 -0
  87. package/templates/starter/docs/toolcraft/assembly-workflow.md +10 -3
  88. package/templates/starter/docs/toolcraft/component-rules.md +8 -6
  89. package/templates/starter/docs/toolcraft/core/control-selection.md +9 -3
  90. package/templates/starter/docs/toolcraft/core/layout.md +7 -7
  91. package/templates/starter/docs/toolcraft/core/media-upload.md +2 -0
  92. package/templates/starter/docs/toolcraft/core/runtime-boundary.md +13 -2
  93. package/templates/starter/docs/toolcraft/core/setup-export.md +33 -7
  94. package/templates/starter/docs/toolcraft/custom-controls.md +6 -4
  95. package/templates/starter/docs/toolcraft/decision-contract.md +3 -1
  96. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  97. package/templates/starter/docs/toolcraft/schema-reference.md +21 -19
  98. package/templates/starter/e2e/app-browser-control-applicability-requirements.spec.ts +163 -0
  99. package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +7 -2
  100. package/templates/starter/e2e/app-browser-orientation-evidence.spec.ts +174 -21
  101. package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +1 -33
  102. package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +61 -15
  103. package/templates/starter/e2e/{browser-conditional-output-evidence-helpers.ts → browser-background-output-evidence.ts} +0 -68
  104. package/templates/starter/e2e/browser-control-applicability-evidence.ts +114 -0
  105. package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +20 -11
  106. package/templates/starter/e2e/browser-orientation-gizmo-evidence-helpers.ts +4 -0
  107. package/templates/starter/e2e/browser-orientation-gizmo-live-preconditions.ts +86 -0
  108. package/templates/starter/e2e/browser-runtime-applicability-requirements.ts +84 -0
  109. package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +9 -2
  110. package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +29 -26
  111. package/templates/starter/e2e/browser-semantic-evidence-test-helpers.ts +25 -0
  112. package/templates/starter/scripts/toolcraft-contract-manifest.json +1 -0
  113. package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +85 -115
  114. package/templates/starter/scripts/toolcraft-product-boundary-module-policy.mjs +101 -0
  115. package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +0 -72
  116. package/templates/starter/scripts/toolcraft-product-control-bindings.mjs +182 -0
  117. package/templates/starter/scripts/toolcraft-product-control-boundary.mjs +146 -0
  118. package/templates/starter/scripts/toolcraft-product-control-boundary.test.mjs +186 -0
  119. package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +6 -17
  120. package/templates/starter/scripts/toolcraft-product-export-boundary.test.mjs +196 -0
  121. package/templates/starter/scripts/toolcraft-typescript-source-evidence-structure.test.mjs +20 -0
  122. package/templates/starter/scripts/toolcraft-typescript-source-evidence.mjs +1 -0
  123. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +2 -0
  124. package/templates/starter/src/app/acceptance/artifact-export-intent.ts +130 -0
  125. package/templates/starter/src/app/acceptance/conditions.ts +1 -1
  126. package/templates/starter/src/app/acceptance/control-acceptance-context.ts +2 -2
  127. package/templates/starter/src/app/acceptance/control-acceptance-coverage.ts +6 -20
  128. package/templates/starter/src/app/acceptance/control-acceptance-kind-rules.ts +24 -5
  129. package/templates/starter/src/app/acceptance/control-acceptance-policy.ts +2 -13
  130. package/templates/starter/src/app/acceptance/control-applicability-cases.test.ts +321 -0
  131. package/templates/starter/src/app/acceptance/control-applicability-cases.ts +290 -0
  132. package/templates/starter/src/app/acceptance/control-applicability.ts +332 -0
  133. package/templates/starter/src/app/acceptance/control-layout-dependency-rules.ts +20 -9
  134. package/templates/starter/src/app/acceptance/control-layout-entity-rules.ts +4 -6
  135. package/templates/starter/src/app/acceptance/control-layout-model.ts +4 -4
  136. package/templates/starter/src/app/acceptance/control-layout-section-rules.ts +3 -10
  137. package/templates/starter/src/app/acceptance/control-parts.ts +8 -0
  138. package/templates/starter/src/app/acceptance/control-section-entity-cohesion.test.ts +215 -0
  139. package/templates/starter/src/app/acceptance/control-section-entity-cohesion.ts +132 -0
  140. package/templates/starter/src/app/acceptance/control-section-inventory.test.ts +87 -0
  141. package/templates/starter/src/app/acceptance/control-section-inventory.ts +74 -44
  142. package/templates/starter/src/app/acceptance/custom-controls.ts +11 -3
  143. package/templates/starter/src/app/acceptance/inline-layout.ts +64 -9
  144. package/templates/starter/src/app/acceptance/media-upload.ts +5 -1
  145. package/templates/starter/src/app/acceptance/orientation-gizmo.ts +18 -11
  146. package/templates/starter/src/app/acceptance/output-background-rules.ts +2 -2
  147. package/templates/starter/src/app/acceptance/output-export-layout-rules.ts +18 -10
  148. package/templates/starter/src/app/acceptance/output-export-model.ts +54 -11
  149. package/templates/starter/src/app/acceptance/output-export.ts +22 -10
  150. package/templates/starter/src/app/acceptance/output-video-export-rules.ts +99 -0
  151. package/templates/starter/src/app/acceptance/types.ts +23 -8
  152. package/templates/starter/src/app/acceptance/validate-coverage.ts +19 -2
  153. package/templates/starter/src/app/starter-acceptance.artifact-export-intent.test.ts +239 -0
  154. package/templates/starter/src/app/starter-acceptance.background-export.test.ts +33 -0
  155. package/templates/starter/src/app/starter-acceptance.canvas-sizing.test.ts +16 -1
  156. package/templates/starter/src/app/starter-acceptance.compound-part-coverage.test.ts +65 -0
  157. package/templates/starter/src/app/starter-acceptance.contract-fixtures.test.ts +41 -0
  158. package/templates/starter/src/app/starter-acceptance.contract-fixtures.ts +112 -16
  159. package/templates/starter/src/app/starter-acceptance.control-applicability.test.ts +224 -0
  160. package/templates/starter/src/app/starter-acceptance.control-order.test.ts +58 -3
  161. package/templates/starter/src/app/starter-acceptance.control-state.test.ts +0 -111
  162. package/templates/starter/src/app/starter-acceptance.custom-control-collections.test.ts +3 -1
  163. package/templates/starter/src/app/starter-acceptance.custom-control-valid.test.ts +1 -1
  164. package/templates/starter/src/app/starter-acceptance.export-test-utils.ts +22 -0
  165. package/templates/starter/src/app/{starter-acceptance.image-video-export-settings.test.ts → starter-acceptance.image-export-settings.test.ts} +37 -9
  166. package/templates/starter/src/app/starter-acceptance.interaction-ownership.test.ts +12 -0
  167. package/templates/starter/src/app/starter-acceptance.media-upload.fixtures.ts +11 -1
  168. package/templates/starter/src/app/starter-acceptance.media-upload.test.ts +28 -0
  169. package/templates/starter/src/app/starter-acceptance.output-export-intent.test.ts +384 -0
  170. package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +4 -0
  171. package/templates/starter/src/app/starter-acceptance.product-output-export.test.ts +112 -105
  172. package/templates/starter/src/app/starter-acceptance.product-readiness.test.ts +12 -0
  173. package/templates/starter/src/app/starter-acceptance.section-cohesion.test.ts +1 -1
  174. package/templates/starter/src/app/starter-acceptance.section-dependencies.test.ts +35 -24
  175. package/templates/starter/src/app/starter-acceptance.section-size.test.ts +72 -0
  176. package/templates/starter/src/app/starter-acceptance.ts +11 -2
  177. package/templates/starter/src/app/starter-acceptance.video-export-settings.test.ts +336 -0
  178. package/templates/starter/src/app/starter-acceptance.view-interaction.test.ts +8 -0
  179. package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -2
  180. package/templates/starter/src/routes/index.tsx +1 -0
  181. package/templates/ui/components/composites/tabs.tsx +2 -2
  182. package/templates/ui/components/controls/collection-actions/collection-item-groups.test.tsx +38 -0
  183. package/templates/ui/components/controls/collection-actions/collection-item-groups.tsx +36 -0
  184. package/templates/ui/components/controls/collection-actions/index.ts +2 -0
  185. package/templates/ui/components/controls/file-drop/file-drop-collection-control.tsx +137 -0
  186. package/templates/ui/components/controls/file-drop/file-drop-control.test.tsx +189 -0
  187. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +81 -88
  188. package/templates/ui/components/controls/file-drop/file-drop-presentation.tsx +131 -9
  189. package/templates/ui/components/controls/file-drop/file-drop-types.ts +36 -22
  190. package/templates/ui/components/controls/file-drop/index.ts +1 -0
  191. package/templates/ui/components/controls/index.ts +3 -0
  192. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +1 -1
  193. package/templates/ui/components/controls/range-slider/range-slider-value.test.ts +14 -0
  194. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -2
  195. package/templates/starter/src/app/acceptance/condition-exclusivity.test.ts +0 -60
  196. package/templates/starter/src/app/acceptance/condition-exclusivity.ts +0 -221
@@ -17,6 +17,7 @@ import {
17
17
  } from "./use-canvas-drop-import";
18
18
  import { useCanvasViewportInteractions } from "./use-canvas-viewport-interactions";
19
19
  import { useToolcraftCanvasFrame } from "./use-toolcraft-canvas-frame";
20
+ import { ToolcraftProductSceneSurface } from "./product-scene-surface";
20
21
  import { useToolcraftStore } from "../app-shell/toolcraft-store-context";
21
22
  import { useToolcraftSourceAssetCoordinator } from "../app-shell/toolcraft-source-asset-context";
22
23
  import { useToolcraftCommittedSelector } from "../app-shell/toolcraft-selectors";
@@ -32,6 +33,7 @@ const directCanvasModelOperationTargets = [DIRECT_CANVAS_OPERATION_TARGET] as co
32
33
 
33
34
  export type CanvasShellProps = {
34
35
  children?: React.ReactNode;
36
+ infiniteCanvasContent?: React.ReactNode;
35
37
  renderDefaultMedia?: boolean;
36
38
  };
37
39
 
@@ -58,6 +60,7 @@ const selectSelectedLayerId = (state: ToolcraftState) => state.selectedLayerId;
58
60
 
59
61
  export function CanvasShell({
60
62
  children,
63
+ infiniteCanvasContent,
61
64
  renderDefaultMedia = true,
62
65
  }: CanvasShellProps): React.JSX.Element {
63
66
  const dispatch = useToolcraftDispatch();
@@ -170,6 +173,14 @@ export function CanvasShell({
170
173
  : undefined
171
174
  }
172
175
  >
176
+ {canvasFrame.kind === "infinite" && infiniteCanvasContent ? (
177
+ <div
178
+ className="pointer-events-none absolute inset-0 overflow-hidden"
179
+ data-toolcraft-infinite-canvas-content=""
180
+ >
181
+ {infiniteCanvasContent}
182
+ </div>
183
+ ) : null}
173
184
  <CanvasViewportWorld>
174
185
  {renderEditableCanvas ? (
175
186
  <CanvasSceneSurface frame={canvasFrame}>
@@ -193,7 +204,11 @@ export function CanvasShell({
193
204
  : null}
194
205
  {children ? (
195
206
  <CanvasSceneSlot frame={canvasFrame}>
196
- {children}
207
+ <ToolcraftProductSceneSurface
208
+ frame={canvasFrame}
209
+ >
210
+ {children}
211
+ </ToolcraftProductSceneSurface>
197
212
  </CanvasSceneSlot>
198
213
  ) : null}
199
214
  </CanvasSceneSurface>
@@ -0,0 +1,195 @@
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { defineToolcraft } from "../../schema/define-toolcraft";
5
+ import type { ToolcraftCanvasFrame } from "../../state/canvas-frame";
6
+ import { ToolcraftRoot } from "../app-shell/toolcraft-root";
7
+ import { useToolcraftDispatch } from "../app-shell/use-toolcraft";
8
+ import {
9
+ ToolcraftProductSceneBoundsBoundary,
10
+ ToolcraftProductSceneSurface,
11
+ useToolcraftProductSceneFrame,
12
+ } from "./product-scene-surface";
13
+
14
+ const schema = defineToolcraft({
15
+ canvas: {
16
+ enabled: true,
17
+ size: { height: 200, unit: "px", width: 300 },
18
+ sizing: { mode: "editable-output" },
19
+ },
20
+ panels: {},
21
+ persistence: { storage: "none" },
22
+ });
23
+
24
+ afterEach(() => {
25
+ cleanup();
26
+ });
27
+
28
+ function FrameProbe(): React.JSX.Element {
29
+ const frame = useToolcraftProductSceneFrame();
30
+ return <output data-testid="frame">{JSON.stringify(frame)}</output>;
31
+ }
32
+
33
+ function renderSurface(
34
+ frame: ToolcraftCanvasFrame,
35
+ boundsProvider = vi.fn(() => [
36
+ { height: 50, width: 80, x: -40, y: -25 },
37
+ { height: 20, width: 30, x: 30, y: 15 },
38
+ ]),
39
+ ) {
40
+ return {
41
+ boundsProvider,
42
+ ...render(
43
+ <ToolcraftRoot
44
+ initialState={{
45
+ canvas: {
46
+ mode: frame.kind,
47
+ offset: { x: 0, y: 0 },
48
+ size: { height: 200, unit: "px", width: 300 },
49
+ zoom: 100,
50
+ },
51
+ }}
52
+ schema={schema}
53
+ >
54
+ <ToolcraftProductSceneBoundsBoundary boundsProvider={boundsProvider}>
55
+ <ToolcraftProductSceneSurface frame={frame}>
56
+ <FrameProbe />
57
+ </ToolcraftProductSceneSurface>
58
+ </ToolcraftProductSceneBoundsBoundary>
59
+ </ToolcraftRoot>,
60
+ ),
61
+ };
62
+ }
63
+
64
+ describe("ToolcraftProductSceneSurface", () => {
65
+ it("keeps the finite frame canonical without invoking product bounds", () => {
66
+ const { boundsProvider, container } = renderSurface({
67
+ kind: "finite",
68
+ size: { height: 200, unit: "px", width: 300 },
69
+ });
70
+
71
+ expect(boundsProvider).not.toHaveBeenCalled();
72
+ expect(screen.getByTestId("frame").textContent).toBe(
73
+ JSON.stringify({
74
+ kind: "finite",
75
+ rect: { height: 200, width: 300, x: 0, y: 0 },
76
+ }),
77
+ );
78
+ expect(
79
+ container.querySelector("[data-toolcraft-product-scene]"),
80
+ ).toBeNull();
81
+ });
82
+
83
+ it("positions one infinite product surface from the exact provider union", () => {
84
+ const { boundsProvider, container } = renderSurface({ kind: "infinite" });
85
+ const productScene = container.querySelector<HTMLElement>(
86
+ "[data-toolcraft-product-scene]",
87
+ );
88
+
89
+ expect(boundsProvider).toHaveBeenCalledTimes(1);
90
+ expect(productScene?.dataset.toolcraftProductSceneStatus).toBe("ready");
91
+ expect(productScene?.style.cssText).toContain("height: 60px");
92
+ expect(productScene?.style.cssText).toContain("left: -40px");
93
+ expect(productScene?.style.cssText).toContain("top: -25px");
94
+ expect(productScene?.style.cssText).toContain("width: 100px");
95
+ expect(screen.getByTestId("frame").textContent).toBe(
96
+ JSON.stringify({
97
+ kind: "infinite",
98
+ rect: { height: 60, width: 100, x: -40, y: -25 },
99
+ }),
100
+ );
101
+ });
102
+
103
+ it("keeps hook consumers stable when state changes without changing bounds", () => {
104
+ const renderProbe = vi.fn();
105
+
106
+ function StableFrameProbe(): React.JSX.Element {
107
+ renderProbe();
108
+ const dispatch = useToolcraftDispatch();
109
+ useToolcraftProductSceneFrame();
110
+ return (
111
+ <button
112
+ onClick={() =>
113
+ dispatch({ delta: { x: 12, y: -8 }, type: "canvas.panBy" })
114
+ }
115
+ type="button"
116
+ >
117
+ Pan
118
+ </button>
119
+ );
120
+ }
121
+
122
+ const boundsProvider = vi.fn(() => [
123
+ { height: 60, width: 100, x: -40, y: -25 },
124
+ ]);
125
+ render(
126
+ <ToolcraftRoot
127
+ initialState={{
128
+ canvas: {
129
+ mode: "infinite",
130
+ offset: { x: 0, y: 0 },
131
+ size: { height: 200, unit: "px", width: 300 },
132
+ zoom: 100,
133
+ },
134
+ }}
135
+ schema={schema}
136
+ >
137
+ <ToolcraftProductSceneBoundsBoundary boundsProvider={boundsProvider}>
138
+ <ToolcraftProductSceneSurface frame={{ kind: "infinite" }}>
139
+ <StableFrameProbe />
140
+ </ToolcraftProductSceneSurface>
141
+ </ToolcraftProductSceneBoundsBoundary>
142
+ </ToolcraftRoot>,
143
+ );
144
+
145
+ expect(renderProbe).toHaveBeenCalledTimes(1);
146
+ fireEvent.click(screen.getByRole("button", { name: "Pan" }));
147
+ expect(boundsProvider).toHaveBeenCalledTimes(2);
148
+ expect(renderProbe).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it.each([
152
+ {
153
+ expectedKind: "empty",
154
+ provider: () => [],
155
+ },
156
+ {
157
+ expectedKind: "unavailable",
158
+ provider: () => [
159
+ { height: 20, width: Number.NaN, x: 0, y: 0 },
160
+ ],
161
+ },
162
+ {
163
+ expectedKind: "unavailable",
164
+ provider: () => {
165
+ throw new Error("broken provider");
166
+ },
167
+ },
168
+ ])(
169
+ "reports $expectedKind without falling back to finite geometry",
170
+ ({ expectedKind, provider }) => {
171
+ const { container } = renderSurface(
172
+ { kind: "infinite" },
173
+ vi.fn(provider),
174
+ );
175
+ const productScene = container.querySelector<HTMLElement>(
176
+ "[data-toolcraft-product-scene]",
177
+ );
178
+
179
+ expect(productScene?.dataset.toolcraftProductSceneStatus).toBe(
180
+ expectedKind,
181
+ );
182
+ expect(productScene?.style.width).toBe("");
183
+ expect(productScene?.style.height).toBe("");
184
+ expect(screen.getByTestId("frame").textContent).toBe(
185
+ JSON.stringify({ kind: expectedKind, rect: null }),
186
+ );
187
+ },
188
+ );
189
+
190
+ it("fails directly when the frame hook escapes the product scene", () => {
191
+ expect(() => render(<FrameProbe />)).toThrow(
192
+ "useToolcraftProductSceneFrame must be used inside Toolcraft product scene content",
193
+ );
194
+ });
195
+ });
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import {
6
+ resolveToolcraftProductSceneBounds,
7
+ type ToolcraftProductSceneBoundsProvider,
8
+ type ToolcraftSceneRect,
9
+ } from "../../scene";
10
+ import type { ToolcraftCanvasFrame } from "../../state/canvas-frame";
11
+ import type { ToolcraftState } from "../../state/types";
12
+ import { useToolcraftCommittedSelector } from "../app-shell/toolcraft-selectors";
13
+
14
+ export type ToolcraftProductSceneFrame =
15
+ | Readonly<{ kind: "finite" | "infinite"; rect: ToolcraftSceneRect }>
16
+ | Readonly<{ kind: "empty" | "unavailable"; rect: null }>;
17
+
18
+ const ProductSceneFrameContext =
19
+ React.createContext<ToolcraftProductSceneFrame | null>(null);
20
+ const ProductSceneBoundsContext =
21
+ React.createContext<ToolcraftProductSceneBoundsProvider | undefined>(
22
+ undefined,
23
+ );
24
+
25
+ function resolveInfiniteProductFrame(
26
+ boundsProvider: ToolcraftProductSceneBoundsProvider | undefined,
27
+ state: ToolcraftState,
28
+ ): ToolcraftProductSceneFrame {
29
+ if (!boundsProvider) {
30
+ return { kind: "unavailable", rect: null };
31
+ }
32
+
33
+ try {
34
+ const result = resolveToolcraftProductSceneBounds(
35
+ boundsProvider({ state }),
36
+ );
37
+ return result.ok
38
+ ? { kind: "infinite", rect: result.bounds }
39
+ : {
40
+ kind:
41
+ result.code === "empty-scene" ? "empty" : "unavailable",
42
+ rect: null,
43
+ };
44
+ } catch {
45
+ return { kind: "unavailable", rect: null };
46
+ }
47
+ }
48
+
49
+ function productSceneFramesEqual(
50
+ previous: ToolcraftProductSceneFrame,
51
+ next: ToolcraftProductSceneFrame,
52
+ ): boolean {
53
+ if (previous.kind !== next.kind) return false;
54
+ if (previous.rect === null || next.rect === null) return true;
55
+
56
+ return (
57
+ Object.is(previous.rect.height, next.rect.height) &&
58
+ Object.is(previous.rect.width, next.rect.width) &&
59
+ Object.is(previous.rect.x, next.rect.x) &&
60
+ Object.is(previous.rect.y, next.rect.y)
61
+ );
62
+ }
63
+
64
+ export function useToolcraftProductSceneFrame(): ToolcraftProductSceneFrame {
65
+ const frame = React.useContext(ProductSceneFrameContext);
66
+ if (!frame) {
67
+ throw new Error(
68
+ "useToolcraftProductSceneFrame must be used inside Toolcraft product scene content.",
69
+ );
70
+ }
71
+ return frame;
72
+ }
73
+
74
+ export function ToolcraftProductSceneBoundsBoundary({
75
+ boundsProvider,
76
+ children,
77
+ }: Readonly<{
78
+ boundsProvider?: ToolcraftProductSceneBoundsProvider;
79
+ children: React.ReactNode;
80
+ }>): React.JSX.Element {
81
+ return (
82
+ <ProductSceneBoundsContext.Provider value={boundsProvider}>
83
+ {children}
84
+ </ProductSceneBoundsContext.Provider>
85
+ );
86
+ }
87
+
88
+ export function ToolcraftProductSceneSurface({
89
+ children,
90
+ frame,
91
+ }: Readonly<{
92
+ children: React.ReactNode;
93
+ frame: ToolcraftCanvasFrame;
94
+ }>): React.JSX.Element {
95
+ if (frame.kind === "finite") {
96
+ const productFrame: ToolcraftProductSceneFrame = {
97
+ kind: "finite",
98
+ rect: {
99
+ height: frame.size.height,
100
+ width: frame.size.width,
101
+ x: 0,
102
+ y: 0,
103
+ },
104
+ };
105
+
106
+ return (
107
+ <ProductSceneFrameContext.Provider value={productFrame}>
108
+ {children}
109
+ </ProductSceneFrameContext.Provider>
110
+ );
111
+ }
112
+
113
+ return <InfiniteProductSceneSurface>{children}</InfiniteProductSceneSurface>;
114
+ }
115
+
116
+ function InfiniteProductSceneSurface({
117
+ children,
118
+ }: Readonly<{ children: React.ReactNode }>): React.JSX.Element {
119
+ const boundsProvider = React.useContext(ProductSceneBoundsContext);
120
+ const selectProductFrame = React.useCallback(
121
+ (state: ToolcraftState) =>
122
+ resolveInfiniteProductFrame(boundsProvider, state),
123
+ [boundsProvider],
124
+ );
125
+ const productFrame = useToolcraftCommittedSelector(
126
+ selectProductFrame,
127
+ productSceneFramesEqual,
128
+ );
129
+ const readyRect =
130
+ productFrame.kind === "infinite" ? productFrame.rect : null;
131
+
132
+ return (
133
+ <ProductSceneFrameContext.Provider value={productFrame}>
134
+ <div
135
+ className={readyRect ? "absolute" : "contents"}
136
+ data-toolcraft-product-scene=""
137
+ data-toolcraft-product-scene-status={
138
+ readyRect ? "ready" : productFrame.kind
139
+ }
140
+ style={
141
+ readyRect
142
+ ? {
143
+ height: readyRect.height,
144
+ left: readyRect.x,
145
+ top: readyRect.y,
146
+ width: readyRect.width,
147
+ }
148
+ : undefined
149
+ }
150
+ >
151
+ {children}
152
+ </div>
153
+ </ProductSceneFrameContext.Provider>
154
+ );
155
+ }
@@ -1,4 +1,4 @@
1
- import type { ToolcraftExportFrame } from "../../export";
1
+ import type { ToolcraftExportFrame } from "../../export/export-frame";
2
2
  import type { ToolcraftRuntimeSceneVisibility } from "../../scene";
3
3
  import type { ToolcraftCanvasFrame } from "../../state/canvas-frame";
4
4
  import type { ToolcraftImageAsset, ToolcraftState } from "../../state/types";
@@ -9,6 +9,67 @@ import {
9
9
  } from "../conditions/control-conditions";
10
10
 
11
11
  describe("Toolcraft control conditions", () => {
12
+ it("requires every applicability predicate to match", () => {
13
+ const schema = defineToolcraft({
14
+ canvas: { enabled: true },
15
+ panels: {
16
+ controls: {
17
+ sections: [
18
+ {
19
+ controls: {
20
+ sides: {
21
+ applicability: {
22
+ all: [
23
+ { equals: "create", target: "source.mode" },
24
+ {
25
+ oneOf: ["polygon", "star"],
26
+ target: "shape.kind",
27
+ },
28
+ ],
29
+ mode: "conditional",
30
+ },
31
+ target: "shape.sides",
32
+ type: "slider",
33
+ },
34
+ },
35
+ title: "Shape",
36
+ },
37
+ ],
38
+ title: "Controls",
39
+ },
40
+ },
41
+ });
42
+ const control = schema.panels.controls?.sections.find(
43
+ (section) => section.title === "Shape",
44
+ )?.controls.sides;
45
+
46
+ expect(control).toBeTruthy();
47
+ expect(
48
+ isToolcraftControlVisible(
49
+ createToolcraftState(schema, {
50
+ values: { "shape.kind": "star", "source.mode": "upload" },
51
+ }),
52
+ control!,
53
+ ),
54
+ ).toBe(false);
55
+ expect(
56
+ isToolcraftControlVisible(
57
+ createToolcraftState(schema, {
58
+ values: { "shape.kind": "heart", "source.mode": "create" },
59
+ }),
60
+ control!,
61
+ ),
62
+ ).toBe(false);
63
+ expect(
64
+ isToolcraftControlVisible(
65
+ createToolcraftState(schema, {
66
+ values: { "shape.kind": "star", "source.mode": "create" },
67
+ }),
68
+ control!,
69
+ ),
70
+ ).toBe(true);
71
+ });
72
+
12
73
  it("resolves timeline runtime targets through the canonical condition helpers", () => {
13
74
  const schema = defineToolcraft({
14
75
  canvas: { enabled: true, renderScale: true, sizing: { mode: "editable-output" } },
@@ -54,6 +54,9 @@ describe("ControlsPanel collection actions", () => {
54
54
  expect(collectionHeader?.lastElementChild?.contains(addButton)).toBe(true);
55
55
  expect(colorGrid).toBeTruthy();
56
56
  expect(colorGrid?.children).toHaveLength(2);
57
+ expect(
58
+ container.querySelector('[data-slot="collection-item-groups"]'),
59
+ ).toBeNull();
57
60
  expect(screen.queryByText("Color 1")).toBeNull();
58
61
  expect(screen.queryByText("Color 2")).toBeNull();
59
62
 
@@ -77,6 +80,92 @@ describe("ControlsPanel collection actions", () => {
77
80
  expect((removeButton as HTMLButtonElement).disabled).toBe(true);
78
81
  });
79
82
 
83
+ it("adds, edits, separates, and removes complete logical item records", () => {
84
+ const schema = defineToolcraft({
85
+ canvas: { enabled: false },
86
+ panels: {
87
+ controls: {
88
+ sections: [
89
+ {
90
+ controls: {
91
+ layers: {
92
+ defaultValue: [
93
+ {
94
+ invert: true,
95
+ rendererToken: "keep",
96
+ strength: 0.4,
97
+ },
98
+ ],
99
+ itemControls: {
100
+ strength: {
101
+ defaultValue: 0.5,
102
+ label: "Strength",
103
+ max: 1,
104
+ min: 0,
105
+ step: 0.1,
106
+ type: "slider",
107
+ },
108
+ invert: {
109
+ defaultValue: false,
110
+ label: "Invert",
111
+ type: "switch",
112
+ },
113
+ },
114
+ itemLabel: "Layer",
115
+ minItems: 1,
116
+ target: "surface.layers",
117
+ type: "collectionActions",
118
+ },
119
+ },
120
+ title: "Surface Layers",
121
+ },
122
+ ],
123
+ title: "Controls",
124
+ },
125
+ },
126
+ });
127
+ const { container } = renderControlsPanelWithSchema(schema);
128
+
129
+ expect(
130
+ container.querySelectorAll('[data-slot="collection-item-group"]'),
131
+ ).toHaveLength(1);
132
+ expect(screen.queryByText(/Item 1/u)).toBeNull();
133
+
134
+ fireEvent.click(screen.getByRole("button", { name: "Add Layer" }));
135
+ let values = JSON.parse(
136
+ screen.getByTestId("values-json").textContent ?? "{}",
137
+ );
138
+ expect(values["surface.layers"]).toEqual([
139
+ { invert: true, rendererToken: "keep", strength: 0.4 },
140
+ { invert: false, strength: 0.5 },
141
+ ]);
142
+
143
+ const groups = container.querySelectorAll(
144
+ '[data-slot="collection-item-group"]',
145
+ );
146
+ expect(groups).toHaveLength(2);
147
+ expect(groups[0]?.className).not.toContain("border-t");
148
+ expect(groups[1]?.className).toContain("border-t");
149
+
150
+ fireEvent.click(
151
+ screen.getAllByRole("button", { name: "Edit Strength value" })[0]!,
152
+ );
153
+ const editor = screen.getByRole("textbox", { name: "Strength value" });
154
+ editor.textContent = "0.7";
155
+ fireEvent.keyDown(editor, { key: "Enter" });
156
+ values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
157
+ expect(values["surface.layers"][0]).toEqual({
158
+ invert: true,
159
+ rendererToken: "keep",
160
+ strength: 0.7,
161
+ });
162
+
163
+ fireEvent.click(screen.getByRole("button", { name: "Remove Layer" }));
164
+ expect(
165
+ container.querySelectorAll('[data-slot="collection-item-group"]'),
166
+ ).toHaveLength(1);
167
+ });
168
+
80
169
  it("renders collection item controls beyond color lists", () => {
81
170
  const schema = defineToolcraft({
82
171
  canvas: { enabled: false },