@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
@@ -1,10 +1,29 @@
1
1
  import { getToolcraftSegmentedStaticFitError } from "../contracts/segmented-control-fit";
2
2
  import { normalizeToolcraftModelFileDrop } from "../model-import/model-import-limits";
3
+ import { isToolcraftCollectionItemControlType } from "./collection-item-controls";
4
+ import { resolveToolcraftControlApplicability } from "./control-applicability";
3
5
  import type {
4
6
  ResolvedToolcraftControlSchema,
7
+ ToolcraftModelFileDropSchema,
8
+ ToolcraftNonModelControlSchema,
9
+ ToolcraftResolvedControlApplicabilitySchema,
5
10
  ToolcraftControlSchema,
6
11
  } from "./types";
7
12
 
13
+ type ControlWithResolvedApplicability =
14
+ | (Omit<
15
+ ToolcraftNonModelControlSchema,
16
+ "applicability" | "visibleWhen"
17
+ > & {
18
+ applicability: ToolcraftResolvedControlApplicabilitySchema;
19
+ })
20
+ | (Omit<
21
+ ToolcraftModelFileDropSchema,
22
+ "applicability" | "visibleWhen"
23
+ > & {
24
+ applicability: ToolcraftResolvedControlApplicabilitySchema;
25
+ });
26
+
8
27
  const modelOnlyControlFieldNames = [
9
28
  "modelFormats",
10
29
  "modelLimits",
@@ -82,6 +101,117 @@ function assertFileDropHardMaxItems(control: ToolcraftControlSchema): void {
82
101
  }
83
102
  }
84
103
 
104
+ function assertFileDropVariant(control: ToolcraftControlSchema): void {
105
+ if (
106
+ control.type !== "fileDrop" ||
107
+ control.variant !== "collection-actions"
108
+ ) {
109
+ return;
110
+ }
111
+
112
+ if (control.assetKind !== "file" || control.multiple !== true) {
113
+ throw new Error(
114
+ 'Toolcraft fileDrop validation [filedrop-collection-actions]: variant "collection-actions" requires assetKind: "file" with multiple: true.',
115
+ );
116
+ }
117
+ }
118
+
119
+ type ItemControlsOwner = Readonly<{
120
+ minimumFields: number;
121
+ name: "collectionActions" | "fileDrop";
122
+ }>;
123
+
124
+ function getItemControlsOwner(
125
+ control: ToolcraftControlSchema,
126
+ ): ItemControlsOwner | null {
127
+ if (control.type === "collectionActions") {
128
+ return { minimumFields: 2, name: "collectionActions" };
129
+ }
130
+
131
+ if (
132
+ control.type === "fileDrop" &&
133
+ control.variant === "collection-actions" &&
134
+ control.assetKind === "file" &&
135
+ control.multiple === true
136
+ ) {
137
+ return { minimumFields: 1, name: "fileDrop" };
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ function isPlainRecord(value: unknown): boolean {
144
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
145
+ return false;
146
+ }
147
+
148
+ const prototype = Object.getPrototypeOf(value);
149
+ return prototype === Object.prototype || prototype === null;
150
+ }
151
+
152
+ function assertItemControls(control: ToolcraftControlSchema): void {
153
+ if (!control.itemControls) {
154
+ return;
155
+ }
156
+
157
+ const owner = getItemControlsOwner(control);
158
+
159
+ if (!owner) {
160
+ throw new Error(
161
+ `Toolcraft control validation [collection-item-controls] for target "${control.target}": itemControls require collectionActions or a multiple file-kind collection-actions fileDrop.`,
162
+ );
163
+ }
164
+
165
+ if (control.itemControl) {
166
+ throw new Error(
167
+ `Toolcraft control validation [collection-item-template] for target "${control.target}": itemControl and itemControls are mutually exclusive.`,
168
+ );
169
+ }
170
+
171
+ if (Object.prototype.hasOwnProperty.call(control, "itemDefaultValue")) {
172
+ throw new Error(
173
+ `Toolcraft control validation [collection-item-default] for target "${control.target}": itemDefaultValue cannot be used with itemControls; field defaults own the new record.`,
174
+ );
175
+ }
176
+
177
+ const entries = Object.entries(control.itemControls);
178
+ if (entries.length < owner.minimumFields) {
179
+ throw new Error(
180
+ `Toolcraft control validation [collection-item-controls] for target "${control.target}": ${owner.name} itemControls require at least ${owner.minimumFields === 1 ? "one" : "two"} built-in field${owner.minimumFields === 1 ? "" : "s"}.`,
181
+ );
182
+ }
183
+
184
+ for (const [id, itemControl] of entries) {
185
+ if (!id.trim() || !isToolcraftCollectionItemControlType(itemControl.type)) {
186
+ throw new Error(
187
+ `Toolcraft control validation [collection-item-controls] for target "${control.target}" itemControls.${id}: unsupported built-in field.`,
188
+ );
189
+ }
190
+ if (!Object.prototype.hasOwnProperty.call(itemControl, "defaultValue")) {
191
+ throw new Error(
192
+ `Toolcraft control validation [collection-item-controls] for target "${control.target}" itemControls.${id}: defaultValue is required.`,
193
+ );
194
+ }
195
+
196
+ const fitError = getToolcraftSegmentedStaticFitError(itemControl);
197
+ if (fitError) {
198
+ throw new Error(
199
+ `Toolcraft control validation [segmented-control-fit] for target "${control.target}" itemControls.${id}: ${fitError}`,
200
+ );
201
+ }
202
+ }
203
+
204
+ if (
205
+ owner.name === "collectionActions" &&
206
+ Array.isArray(control.defaultValue) &&
207
+ control.defaultValue.some((item) => !isPlainRecord(item))
208
+ ) {
209
+ throw new Error(
210
+ `Toolcraft control validation [collection-item-value] for target "${control.target}": every initial compound item must be a plain record.`,
211
+ );
212
+ }
213
+ }
214
+
85
215
  function assertSegmentedControlFit(control: ToolcraftControlSchema): void {
86
216
  const fitError = getToolcraftSegmentedStaticFitError(control);
87
217
 
@@ -92,13 +222,29 @@ function assertSegmentedControlFit(control: ToolcraftControlSchema): void {
92
222
  }
93
223
  }
94
224
 
95
- function assertCollectionItemSegmentedControlFit(
96
- control: ToolcraftControlSchema,
97
- ): void {
98
- if (control.type !== "collectionActions" || !control.itemControl) {
225
+ function assertCollectionItemControl(control: ToolcraftControlSchema): void {
226
+ if (
227
+ control.type !== "collectionActions" &&
228
+ control.type !== "sourceCollection"
229
+ ) {
230
+ return;
231
+ }
232
+
233
+ if (!control.itemControl) {
234
+ if (control.type === "sourceCollection") {
235
+ throw new Error(
236
+ `Toolcraft control validation [source-collection-item-control] for target "${control.target}": itemControl is required.`,
237
+ );
238
+ }
99
239
  return;
100
240
  }
101
241
 
242
+ if (!isToolcraftCollectionItemControlType(control.itemControl.type)) {
243
+ throw new Error(
244
+ `Toolcraft control validation [collection-item-control] for target "${control.target}": unsupported collection item control "${String(control.itemControl.type)}".`,
245
+ );
246
+ }
247
+
102
248
  const fitError = getToolcraftSegmentedStaticFitError(control.itemControl);
103
249
 
104
250
  if (fitError) {
@@ -108,11 +254,15 @@ function assertCollectionItemSegmentedControlFit(
108
254
  }
109
255
  }
110
256
 
111
- function isSliderLikeControl(control: ToolcraftControlSchema): boolean {
257
+ function isSliderLikeControl(
258
+ control: ControlWithResolvedApplicability,
259
+ ): boolean {
112
260
  return control.type === "slider" || control.type === "rangeSlider";
113
261
  }
114
262
 
115
- function getStepMarkerCount(control: ToolcraftControlSchema): number | undefined {
263
+ function getStepMarkerCount(
264
+ control: ControlWithResolvedApplicability,
265
+ ): number | undefined {
116
266
  if (
117
267
  typeof control.step !== "number" ||
118
268
  typeof control.min !== "number" ||
@@ -137,8 +287,8 @@ function getStepMarkerCount(control: ToolcraftControlSchema): number | undefined
137
287
  }
138
288
 
139
289
  function normalizeSliderControlSchema(
140
- control: ToolcraftControlSchema,
141
- ): ToolcraftControlSchema {
290
+ control: ControlWithResolvedApplicability,
291
+ ): ControlWithResolvedApplicability {
142
292
  if (
143
293
  !isSliderLikeControl(control) ||
144
294
  typeof control.step !== "number" ||
@@ -155,7 +305,7 @@ function normalizeSliderControlSchema(
155
305
  }
156
306
 
157
307
  function normalizeFileDropControlSchema(
158
- control: ToolcraftControlSchema,
308
+ control: ControlWithResolvedApplicability,
159
309
  ): ResolvedToolcraftControlSchema {
160
310
  if (control.assetKind === "model") {
161
311
  return normalizeToolcraftModelFileDrop(control);
@@ -180,10 +330,26 @@ function normalizeControlSchema(
180
330
  assertRequiredControlStringField(controlSnapshot, "target");
181
331
  assertControlRuntimeBoundary(controlSnapshot);
182
332
  assertFileDropHardMaxItems(controlSnapshot);
333
+ assertFileDropVariant(controlSnapshot);
334
+ assertItemControls(controlSnapshot);
183
335
  assertSegmentedControlFit(controlSnapshot);
184
- assertCollectionItemSegmentedControlFit(controlSnapshot);
336
+ assertCollectionItemControl(controlSnapshot);
337
+
338
+ const {
339
+ applicability: authoredApplicability,
340
+ visibleWhen,
341
+ ...controlWithoutApplicability
342
+ } = controlSnapshot;
343
+ const applicability = resolveToolcraftControlApplicability({
344
+ applicability: authoredApplicability,
345
+ visibleWhen,
346
+ });
347
+
185
348
  return normalizeFileDropControlSchema(
186
- normalizeSliderControlSchema(controlSnapshot),
349
+ normalizeSliderControlSchema({
350
+ ...controlWithoutApplicability,
351
+ applicability,
352
+ }),
187
353
  );
188
354
  }
189
355
 
@@ -7,15 +7,15 @@ import {
7
7
  import type {
8
8
  ResolvedToolcraftControlSchema,
9
9
  ResolvedToolcraftControlSectionSchema,
10
+ ResolvedToolcraftNonModelControlSchema,
10
11
  ToolcraftControlSchema,
11
- ToolcraftNonModelControlSchema,
12
12
  } from "./types";
13
13
 
14
14
  type ToolcraftControlActionSchema = NonNullable<
15
15
  ToolcraftControlSchema["actions"]
16
16
  >[number];
17
17
 
18
- type ToolcraftPanelActionsControl = ToolcraftNonModelControlSchema & {
18
+ type ToolcraftPanelActionsControl = ResolvedToolcraftNonModelControlSchema & {
19
19
  type: "panelActions";
20
20
  };
21
21
 
@@ -9,14 +9,16 @@ import {
9
9
  registerToolcraftInternalControlSection,
10
10
  } from "./controls-panel-section-id";
11
11
  import { withImplicitStandaloneSectionTitle } from "./controls-panel-section-titles";
12
+ import {
13
+ getToolcraftApplicabilityTargets,
14
+ } from "./control-applicability";
12
15
  import type {
13
16
  ResolvedToolcraftControlSchema,
14
17
  ResolvedToolcraftControlSectionSchema,
15
- ToolcraftControlSchema,
16
18
  } from "./types";
17
19
 
18
20
  function getControlDefaultSectionLayout(
19
- control: ToolcraftControlSchema,
21
+ control: ResolvedToolcraftControlSchema,
20
22
  ): "grouped" | "standalone" {
21
23
  const contract = (
22
24
  TOOLCRAFT_COMPONENT_CONTRACTS as Record<
@@ -31,12 +33,15 @@ function getControlDefaultSectionLayout(
31
33
  }
32
34
 
33
35
  function isControlGatedBySameSectionControl(
34
- control: ToolcraftControlSchema,
35
- entries: readonly [string, ToolcraftControlSchema][],
36
+ control: ResolvedToolcraftControlSchema,
37
+ entries: readonly [string, ResolvedToolcraftControlSchema][],
36
38
  ): boolean {
37
- const gateTargets = [control.visibleWhen?.target, control.disabledWhen?.target].filter(
38
- (target): target is string => Boolean(target),
39
- );
39
+ const gateTargets = [
40
+ ...getToolcraftApplicabilityTargets(
41
+ control.applicability,
42
+ ),
43
+ ...(control.disabledWhen ? [control.disabledWhen.target] : []),
44
+ ];
40
45
 
41
46
  if (gateTargets.length === 0) {
42
47
  return false;
@@ -49,8 +54,8 @@ function isControlGatedBySameSectionControl(
49
54
  }
50
55
 
51
56
  function getControlSectionLayout(
52
- control: ToolcraftControlSchema,
53
- entries: readonly [string, ToolcraftControlSchema][],
57
+ control: ResolvedToolcraftControlSchema,
58
+ entries: readonly [string, ResolvedToolcraftControlSchema][],
54
59
  ): "grouped" | "standalone" {
55
60
  if (isControlGatedBySameSectionControl(control, entries)) {
56
61
  return "grouped";
@@ -13,6 +13,65 @@ const opacityControl = {
13
13
  } as const;
14
14
 
15
15
  describe("Toolcraft control section identity roundtrips", () => {
16
+ it("preserves explicit, legacy, and implicit applicability origins", () => {
17
+ const resolved = defineToolcraft({
18
+ canvas: { enabled: true },
19
+ panels: {
20
+ controls: {
21
+ sections: [
22
+ {
23
+ controls: {
24
+ explicit: {
25
+ applicability: { mode: "always" },
26
+ target: "appearance.explicit",
27
+ type: "switch",
28
+ },
29
+ implicit: {
30
+ target: "appearance.implicit",
31
+ type: "switch",
32
+ },
33
+ legacy: {
34
+ target: "appearance.legacy",
35
+ type: "slider",
36
+ visibleWhen: {
37
+ equals: true,
38
+ target: "appearance.explicit",
39
+ },
40
+ },
41
+ },
42
+ id: "appearance",
43
+ title: "Appearance",
44
+ },
45
+ ],
46
+ title: "Controls",
47
+ },
48
+ },
49
+ });
50
+ const roundTripped = defineToolcraft(
51
+ resolved as unknown as ToolcraftAppSchema,
52
+ );
53
+ const readApplicability = (schema: typeof resolved) =>
54
+ Object.fromEntries(
55
+ schema.panels.controls?.sections
56
+ .filter((section) => section.id.startsWith("appearance"))
57
+ .flatMap((section) => Object.entries(section.controls))
58
+ .map(([id, control]) => [id, control.applicability]) ?? [],
59
+ );
60
+
61
+ expect(readApplicability(resolved)).toEqual({
62
+ explicit: { mode: "always", origin: "explicit" },
63
+ implicit: { mode: "always", origin: "implicit" },
64
+ legacy: {
65
+ all: [{ equals: true, target: "appearance.explicit" }],
66
+ mode: "conditional",
67
+ origin: "legacy",
68
+ },
69
+ });
70
+ expect(readApplicability(roundTripped)).toEqual(
71
+ readApplicability(resolved),
72
+ );
73
+ });
74
+
16
75
  it("round-trips resolved legacy and runtime section ids without duplicating runtime sections", () => {
17
76
  const resolved = defineToolcraft({
18
77
  canvas: { enabled: true },
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "./define-toolcraft";
4
+ import type { ToolcraftControlSchema } from "./types";
5
+
6
+ function createSchema(control: ToolcraftControlSchema) {
7
+ return {
8
+ canvas: { enabled: false },
9
+ panels: {
10
+ controls: {
11
+ sections: [{ controls: { layers: control }, title: "Layers" }],
12
+ title: "Controls",
13
+ },
14
+ },
15
+ };
16
+ }
17
+
18
+ function compoundCollection(
19
+ overrides: Record<string, unknown> = {},
20
+ ): ToolcraftControlSchema {
21
+ return {
22
+ defaultValue: [{ invert: false, strength: 0.4 }],
23
+ itemControls: {
24
+ strength: {
25
+ defaultValue: 0.5,
26
+ label: "Strength",
27
+ max: 1,
28
+ min: 0,
29
+ step: 0.01,
30
+ type: "slider",
31
+ },
32
+ invert: {
33
+ defaultValue: false,
34
+ label: "Invert",
35
+ type: "switch",
36
+ },
37
+ },
38
+ itemLabel: "Layer",
39
+ minItems: 1,
40
+ target: "surface.layers",
41
+ type: "collectionActions",
42
+ ...overrides,
43
+ } as ToolcraftControlSchema;
44
+ }
45
+
46
+ describe("defineToolcraft compound collectionActions items", () => {
47
+ it("accepts one logical record made from several built-in fields", () => {
48
+ const app = defineToolcraft(createSchema(compoundCollection()));
49
+ const control = app.panels.controls?.sections.find(
50
+ (section) => section.title === "Layers",
51
+ )?.controls.layers;
52
+
53
+ expect(Object.keys(control?.itemControls ?? {})).toEqual([
54
+ "strength",
55
+ "invert",
56
+ ]);
57
+ });
58
+
59
+ it("rejects competing scalar and record templates", () => {
60
+ expect(() =>
61
+ defineToolcraft(
62
+ createSchema(
63
+ compoundCollection({
64
+ itemControl: { defaultValue: 0, type: "slider" },
65
+ }),
66
+ ),
67
+ ),
68
+ ).toThrow(/collection-item-template.*itemControl.*itemControls/u);
69
+ });
70
+
71
+ it("requires at least two fields in a compound collection item", () => {
72
+ expect(() =>
73
+ defineToolcraft(
74
+ createSchema(
75
+ compoundCollection({
76
+ itemControls: {
77
+ strength: { defaultValue: 0.5, type: "slider" },
78
+ },
79
+ }),
80
+ ),
81
+ ),
82
+ ).toThrow(/collection-item-controls.*at least two/u);
83
+ });
84
+
85
+ it("requires one default source per compound field", () => {
86
+ expect(() =>
87
+ defineToolcraft(
88
+ createSchema(
89
+ compoundCollection({
90
+ itemControls: {
91
+ invert: { defaultValue: false, type: "switch" },
92
+ strength: { type: "slider" },
93
+ },
94
+ }),
95
+ ),
96
+ ),
97
+ ).toThrow(/itemControls\.strength.*defaultValue/u);
98
+ });
99
+
100
+ it("rejects a competing itemDefaultValue", () => {
101
+ expect(() =>
102
+ defineToolcraft(
103
+ createSchema(
104
+ compoundCollection({
105
+ itemDefaultValue: { invert: true, strength: 1 },
106
+ }),
107
+ ),
108
+ ),
109
+ ).toThrow(/collection-item-default.*itemDefaultValue/u);
110
+ });
111
+
112
+ it("rejects non-record initial compound items", () => {
113
+ expect(() =>
114
+ defineToolcraft(
115
+ createSchema(compoundCollection({ defaultValue: [new Date()] })),
116
+ ),
117
+ ).toThrow(/collection-item-value.*plain record/u);
118
+ });
119
+
120
+ it("keeps sourceCollection single-field only", () => {
121
+ expect(() =>
122
+ defineToolcraft(
123
+ createSchema(compoundCollection({ type: "sourceCollection" })),
124
+ ),
125
+ ).toThrow(/collection-item-controls.*collectionActions.*fileDrop/u);
126
+ });
127
+ });
@@ -4,7 +4,7 @@ import { defineToolcraft } from "./define-toolcraft";
4
4
  import { getProductSections } from "./define-toolcraft-test-utils";
5
5
 
6
6
  describe("defineToolcraft control section semantics", () => {
7
- it("preserves conditional visibility on sections and controls", () => {
7
+ it("preserves section visibility and resolves legacy control visibility", () => {
8
8
  const app = defineToolcraft({
9
9
  canvas: { enabled: true },
10
10
  panels: {
@@ -38,9 +38,15 @@ describe("defineToolcraft control section semantics", () => {
38
38
  equals: "co-brand",
39
39
  target: "cover.templateId",
40
40
  });
41
- expect(section?.controls.partnerLogo?.visibleWhen).toEqual({
42
- equals: "logo",
43
- target: "coBrand.identityMode",
41
+ expect(section?.controls.partnerLogo?.applicability).toEqual({
42
+ all: [
43
+ {
44
+ equals: "logo",
45
+ target: "coBrand.identityMode",
46
+ },
47
+ ],
48
+ mode: "conditional",
49
+ origin: "legacy",
44
50
  });
45
51
  });
46
52
 
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "./define-toolcraft";
4
+ import type { ToolcraftControlSchema } from "./types";
5
+
6
+ function createAppSchema(control: ToolcraftControlSchema) {
7
+ return {
8
+ canvas: { enabled: false },
9
+ panels: {
10
+ controls: {
11
+ sections: [
12
+ {
13
+ controls: { bumpMaps: control },
14
+ title: "Bump Maps",
15
+ },
16
+ ],
17
+ title: "Controls",
18
+ },
19
+ },
20
+ };
21
+ }
22
+
23
+ function createFileDrop(
24
+ overrides: Record<string, unknown> = {},
25
+ ): ToolcraftControlSchema {
26
+ return {
27
+ assetKind: "file",
28
+ defaultValue: [],
29
+ itemControls: {
30
+ depth: { defaultValue: 0.2, max: 1, min: 0, type: "slider" },
31
+ levels: {
32
+ defaultValue: [0, 1],
33
+ max: 1,
34
+ min: 0,
35
+ step: 0.01,
36
+ type: "rangeSlider",
37
+ },
38
+ invert: { defaultValue: false, type: "switch" },
39
+ offset: {
40
+ coordinateMode: "cartesian",
41
+ defaultValue: { x: "0", y: "0" },
42
+ type: "vector",
43
+ },
44
+ },
45
+ multiple: true,
46
+ target: "media.bumpMaps",
47
+ type: "fileDrop",
48
+ variant: "collection-actions",
49
+ ...overrides,
50
+ } as ToolcraftControlSchema;
51
+ }
52
+
53
+ describe("defineToolcraft fileDrop item controls", () => {
54
+ it("accepts built-in settings owned by each attached file", () => {
55
+ const app = defineToolcraft(createAppSchema(createFileDrop()));
56
+ const control = app.panels.controls?.sections.find(
57
+ (section) => section.title === "Bump Maps",
58
+ )?.controls.bumpMaps;
59
+
60
+ expect(control?.itemControls?.depth?.defaultValue).toBe(0.2);
61
+ expect(control?.itemControls?.levels?.type).toBe("rangeSlider");
62
+ expect(control?.itemControls?.offset?.type).toBe("vector");
63
+ });
64
+
65
+ it("requires the file collection-actions variant", () => {
66
+ expect(() =>
67
+ defineToolcraft(
68
+ createAppSchema(createFileDrop({ variant: undefined })),
69
+ ),
70
+ ).toThrow(/collection-item-controls.*collectionActions.*fileDrop/u);
71
+ });
72
+
73
+ it("requires every nested setting to declare a reset default", () => {
74
+ expect(() =>
75
+ defineToolcraft(
76
+ createAppSchema(
77
+ createFileDrop({ itemControls: { depth: { type: "slider" } } }),
78
+ ),
79
+ ),
80
+ ).toThrow(/itemControls\.depth.*defaultValue/u);
81
+ });
82
+
83
+ it("rejects an empty nested settings declaration", () => {
84
+ expect(() =>
85
+ defineToolcraft(createAppSchema(createFileDrop({ itemControls: {} }))),
86
+ ).toThrow(/itemControls require at least one built-in field/u);
87
+ });
88
+
89
+ it("rejects itemDefaultValue beside per-file field defaults", () => {
90
+ expect(() =>
91
+ defineToolcraft(
92
+ createAppSchema(
93
+ createFileDrop({ itemDefaultValue: { depth: 1 } }),
94
+ ),
95
+ ),
96
+ ).toThrow(/collection-item-default.*itemDefaultValue/u);
97
+ });
98
+ });
@@ -90,6 +90,33 @@ describe("defineToolcraft fileDrop normalization", () => {
90
90
  expect(getSourceControl(app)?.hardMaxItems).toBe(0);
91
91
  });
92
92
 
93
+ it("accepts collection actions for a multiple file-kind fileDrop", () => {
94
+ const app = defineToolcraft(
95
+ createFileDropSchema({
96
+ assetKind: "file",
97
+ multiple: true,
98
+ variant: "collection-actions",
99
+ }),
100
+ );
101
+
102
+ expect(getSourceControl(app)?.variant).toBe("collection-actions");
103
+ });
104
+
105
+ it.each([
106
+ ["a single fileDrop", { assetKind: "file" }],
107
+ ["an image fileDrop", { assetKind: "image", multiple: true }],
108
+ ["a model fileDrop", { assetKind: "model", multiple: false }],
109
+ ])("rejects collection actions on %s", (_label, fields) => {
110
+ expect(() =>
111
+ defineToolcraft(
112
+ createFileDropSchema({
113
+ ...fields,
114
+ variant: "collection-actions",
115
+ }),
116
+ ),
117
+ ).toThrow(/\[filedrop-collection-actions\].*assetKind: "file".*multiple: true/u);
118
+ });
119
+
93
120
  it("accepts model fileDrops", () => {
94
121
  const createModelApp = () =>
95
122
  defineToolcraft(createFileDropSchema({ assetKind: "model" }));