@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,2327 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { DiamondIcon } from "@phosphor-icons/react";
5
+ import {
6
+ Actions,
7
+ AnchorGrid,
8
+ Button,
9
+ ChannelMixer,
10
+ Checkbox,
11
+ CodeTextarea,
12
+ Color,
13
+ ColorOpacity,
14
+ ControlInlineGroup,
15
+ ControlFieldLabelActionProvider,
16
+ ControlFieldLabelHelpProvider,
17
+ Curves,
18
+ FileDrop,
19
+ FontPicker,
20
+ Gradient,
21
+ ImagePicker,
22
+ Palette,
23
+ Panel,
24
+ PanelActions,
25
+ PanelSection,
26
+ type PanelActionObjectOption,
27
+ RangeInput,
28
+ RangeSlider,
29
+ Segmented,
30
+ Select,
31
+ Slider,
32
+ Switch,
33
+ TextInput,
34
+ Tooltip,
35
+ TooltipContent,
36
+ TooltipTrigger,
37
+ Vector,
38
+ type ChannelMixerValues,
39
+ type ControlChangeMeta,
40
+ type ColorControlInput,
41
+ type ColorControlInputPair,
42
+ type ColorOpacityValue,
43
+ type CurveInterpolation,
44
+ type FontPickerValue,
45
+ type GradientStop,
46
+ type GradientType,
47
+ type ImagePickerItem,
48
+ type VectorPadVariant,
49
+ } from "@repo/ui";
50
+
51
+ import type {
52
+ ToolcraftActionCommand,
53
+ ToolcraftActionSchema,
54
+ ToolcraftControlConditionSchema,
55
+ ToolcraftControlLayoutGroupSchema,
56
+ ToolcraftControlSectionSchema,
57
+ ToolcraftControlSchema,
58
+ ResolvedToolcraftAppSchema,
59
+ } from "../schema/types";
60
+ import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
61
+ import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
62
+ import type {
63
+ ToolcraftCommand,
64
+ ToolcraftPanelState,
65
+ ToolcraftState,
66
+ } from "../state/types";
67
+ import { readImportedImageFile } from "./media-file";
68
+ import { PanelContainer } from "./panel-host";
69
+ import type { PanelPlacement, PanelStateChange } from "./panel-host-types";
70
+ import type { ToolcraftControlRendererMap } from "./control-renderers";
71
+ import {
72
+ downloadToolcraftSettings,
73
+ importToolcraftSettings,
74
+ } from "./settings-transfer";
75
+ import {
76
+ readToolcraftLocalStorageValue,
77
+ removeToolcraftLocalStorageValue,
78
+ } from "./storage-key-migration";
79
+ import { useToolcraft } from "./use-toolcraft";
80
+
81
+ export type ControlsPanelProps = {
82
+ className?: string;
83
+ controlRenderers?: ToolcraftControlRendererMap;
84
+ framed?: boolean;
85
+ onPanelAction?: ToolcraftPanelActionHandler;
86
+ onPanelStateChange?: PanelStateChange;
87
+ panelPlacement?: PanelPlacement;
88
+ panelState?: ToolcraftPanelState;
89
+ };
90
+
91
+ export type ToolcraftPanelActionContext = {
92
+ action: ToolcraftActionSchema;
93
+ dispatch: React.Dispatch<ToolcraftCommand>;
94
+ reportProgress: (progress: number) => void;
95
+ state: ToolcraftState;
96
+ };
97
+
98
+ export type ToolcraftPanelActionHandler = (
99
+ context: ToolcraftPanelActionContext,
100
+ ) => PromiseLike<unknown> | void;
101
+
102
+ type AnyRecord = Record<string, unknown>;
103
+ type ControlEntry = [string, ToolcraftControlSchema];
104
+ type ControlRenderGroup =
105
+ | { entries: readonly ControlEntry[]; kind: "colorGroup" }
106
+ | { entry: ControlEntry; kind: "control" };
107
+ type RenderedControlRenderGroup = {
108
+ ids: readonly string[];
109
+ node: React.ReactNode;
110
+ };
111
+ type FooterActionProgressEntry = {
112
+ id: number;
113
+ progress: number | null;
114
+ };
115
+
116
+ const hiddenDiscreteMarkerCount = 2;
117
+ const controlsPanelSectionCollapseStorageVersion = 1;
118
+
119
+ const sectionedCompoundControlTypes = new Set([
120
+ "channelMixer",
121
+ "fontPicker",
122
+ "gradient",
123
+ "palette",
124
+ ]);
125
+
126
+ const defaultGradientStops = [
127
+ { color: "#FFFFFF", position: "0%" },
128
+ { color: "#7CFF3A", position: "46%" },
129
+ { color: "#111111", position: "100%" },
130
+ ] as const satisfies readonly GradientStop[];
131
+
132
+ const defaultChannelMixerValues = {
133
+ B: { B: 100, G: 0, R: 0 },
134
+ G: { B: 0, G: 100, R: 0 },
135
+ R: { B: 0, G: 0, R: 100 },
136
+ } satisfies ChannelMixerValues;
137
+
138
+ function cn(...classNames: Array<string | false | null | undefined>): string {
139
+ return classNames.filter(Boolean).join(" ");
140
+ }
141
+
142
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
143
+ return (
144
+ typeof value === "object" &&
145
+ value !== null &&
146
+ "then" in value &&
147
+ typeof (value as { then?: unknown }).then === "function"
148
+ );
149
+ }
150
+
151
+ function noopReportProgress(): void {}
152
+
153
+ function clampFooterActionProgress(progress: number): number {
154
+ if (!Number.isFinite(progress)) {
155
+ return 0;
156
+ }
157
+
158
+ return Math.max(0, Math.min(1, progress));
159
+ }
160
+
161
+ function canCreateControlKeyframe(control: ToolcraftControlSchema): boolean {
162
+ return getToolcraftControlKeyframeCapability(control).capable;
163
+ }
164
+
165
+ function shouldRenderCompoundControlSectionDivider(
166
+ control: ToolcraftControlSchema,
167
+ ): boolean {
168
+ if (control.type === "curves") {
169
+ return control.variant !== "single";
170
+ }
171
+
172
+ return sectionedCompoundControlTypes.has(control.type);
173
+ }
174
+
175
+ function withCompoundControlSectionDivider({
176
+ children,
177
+ control,
178
+ }: {
179
+ children: React.ReactNode;
180
+ control: ToolcraftControlSchema;
181
+ }): React.ReactNode {
182
+ if (!shouldRenderCompoundControlSectionDivider(control)) {
183
+ return children;
184
+ }
185
+
186
+ return (
187
+ <div className="contents" data-control-section-divider="compound">
188
+ {children}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function isRecord(value: unknown): value is AnyRecord {
194
+ return typeof value === "object" && value !== null && !Array.isArray(value);
195
+ }
196
+
197
+ function valuesEqual(first: unknown, second: unknown): boolean {
198
+ if (Object.is(first, second)) {
199
+ return true;
200
+ }
201
+
202
+ if (
203
+ (typeof first !== "object" || first === null) &&
204
+ (typeof second !== "object" || second === null)
205
+ ) {
206
+ return false;
207
+ }
208
+
209
+ try {
210
+ return JSON.stringify(first) === JSON.stringify(second);
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ function valuesInclude(value: unknown, options: readonly unknown[]): boolean {
217
+ return options.some((option) => valuesEqual(value, option));
218
+ }
219
+
220
+ function readComparableNumber(value: unknown): number | null {
221
+ if (typeof value === "number" && Number.isFinite(value)) {
222
+ return value;
223
+ }
224
+
225
+ if (typeof value === "string") {
226
+ const numberValue = Number(value.trim());
227
+
228
+ return Number.isFinite(numberValue) ? numberValue : null;
229
+ }
230
+
231
+ return null;
232
+ }
233
+
234
+ function compareConditionNumbers(
235
+ value: unknown,
236
+ expected: number | undefined,
237
+ comparator: (value: number, expected: number) => boolean,
238
+ ): boolean | null {
239
+ if (typeof expected !== "number" || !Number.isFinite(expected)) {
240
+ return null;
241
+ }
242
+
243
+ const numberValue = readComparableNumber(value);
244
+
245
+ return numberValue === null ? false : comparator(numberValue, expected);
246
+ }
247
+
248
+ function asNumber(value: unknown, fallback: number): number {
249
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
250
+ }
251
+
252
+ function asString(value: unknown, fallback = ""): string {
253
+ if (typeof value === "string") {
254
+ return value;
255
+ }
256
+
257
+ return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
258
+ }
259
+
260
+ function asBoolean(value: unknown, fallback = false): boolean {
261
+ return typeof value === "boolean" ? value : fallback;
262
+ }
263
+
264
+ function getPanelActionButtonVariant(
265
+ variant: ToolcraftActionSchema["variant"],
266
+ ): PanelActionObjectOption["variant"] {
267
+ switch (variant) {
268
+ case "destructive":
269
+ case "ghost":
270
+ case "link":
271
+ case "outline":
272
+ case "secondary":
273
+ return variant;
274
+ default:
275
+ return "default";
276
+ }
277
+ }
278
+
279
+ function asNumberArray(value: unknown, fallback: readonly number[]): readonly number[] {
280
+ return Array.isArray(value) && value.every((item) => typeof item === "number")
281
+ ? value
282
+ : fallback;
283
+ }
284
+
285
+ function asRangeInputValue(value: unknown): { end: string; start: string } {
286
+ if (isRecord(value)) {
287
+ return {
288
+ end: asString(value.end, "100%"),
289
+ start: asString(value.start, "0%"),
290
+ };
291
+ }
292
+
293
+ return { end: "100%", start: "0%" };
294
+ }
295
+
296
+ function asVectorValue(value: unknown): { x: string; y: string } {
297
+ if (isRecord(value)) {
298
+ return {
299
+ x: asString(value.x, "0.00"),
300
+ y: asString(value.y, "0.00"),
301
+ };
302
+ }
303
+
304
+ return { x: "0.00", y: "0.00" };
305
+ }
306
+
307
+ function asVectorPadVariant(value: string | undefined): VectorPadVariant {
308
+ if (
309
+ value === "whiteBalance" ||
310
+ value === "colorBalance" ||
311
+ value === "chromaOffset" ||
312
+ value === "toneBias"
313
+ ) {
314
+ return value;
315
+ }
316
+
317
+ return "default";
318
+ }
319
+
320
+ function asCurveInterpolation(value: string | undefined): CurveInterpolation | undefined {
321
+ return value === "monotone" || value === "smooth" ? value : undefined;
322
+ }
323
+
324
+ function formatControlValueLabel(
325
+ control: ToolcraftControlSchema,
326
+ value: unknown,
327
+ ): string {
328
+ if (typeof control.valueLabel === "string") {
329
+ return control.valueLabel;
330
+ }
331
+
332
+ switch (control.type) {
333
+ case "checkbox":
334
+ case "switch":
335
+ return asBoolean(value) ? "On" : "Off";
336
+ case "color":
337
+ return asColorValue(value).hex;
338
+ case "colorOpacity": {
339
+ const colorOpacityValue = asColorOpacityValue(value);
340
+
341
+ return `${colorOpacityValue.hex} ${colorOpacityValue.opacity}%`;
342
+ }
343
+ case "fontPicker":
344
+ return asFontPickerValue(value).fontId;
345
+ case "gradient":
346
+ return `${asGradientValue(value).stops.length} stops`;
347
+ case "imagePicker":
348
+ return (
349
+ control.items?.find((item) => item.value === value)?.alt ??
350
+ asString(value)
351
+ );
352
+ case "palette": {
353
+ if (isRecord(value)) {
354
+ const family = asString(value.family);
355
+ const shade = asString(value.shade);
356
+
357
+ return [family, shade].filter(Boolean).join(" ") || "Palette";
358
+ }
359
+
360
+ return "Palette";
361
+ }
362
+ case "rangeInput": {
363
+ const rangeValue = asRangeInputValue(value);
364
+
365
+ return `${rangeValue.start} – ${rangeValue.end}`;
366
+ }
367
+ case "rangeSlider": {
368
+ const rangeValue = asNumberArray(value, []);
369
+
370
+ return rangeValue.length > 0
371
+ ? rangeValue.map((item) => `${item}${control.unit ?? ""}`).join(" – ")
372
+ : "Range";
373
+ }
374
+ case "select":
375
+ case "segmented":
376
+ return (
377
+ control.options?.find((option) => option.value === value)?.label ??
378
+ asString(value)
379
+ );
380
+ case "slider":
381
+ return `${asNumber(value, asNumber(control.defaultValue, control.min ?? 0))}${
382
+ control.unit ?? ""
383
+ }`;
384
+ case "vector": {
385
+ const vectorValue = asVectorValue(value);
386
+
387
+ return `${vectorValue.x}, ${vectorValue.y}`;
388
+ }
389
+ default:
390
+ return typeof value === "string" || typeof value === "number"
391
+ ? String(value)
392
+ : control.type;
393
+ }
394
+ }
395
+
396
+ function asColorValue(value: unknown): { hex: string } {
397
+ if (isRecord(value)) {
398
+ return { hex: asString(value.hex, "#C1FF00") };
399
+ }
400
+
401
+ return { hex: "#C1FF00" };
402
+ }
403
+
404
+ function asColorOpacityValue(value: unknown): ColorOpacityValue {
405
+ if (isRecord(value)) {
406
+ return {
407
+ hex: asString(value.hex, "#C1FF00"),
408
+ opacity: Math.min(100, Math.max(0, Math.round(asNumber(value.opacity, 100)))),
409
+ };
410
+ }
411
+
412
+ return { hex: "#C1FF00", opacity: 100 };
413
+ }
414
+
415
+ function asGradientType(value: unknown): GradientType {
416
+ return value === "linear" ||
417
+ value === "radial" ||
418
+ value === "angular" ||
419
+ value === "diamond"
420
+ ? value
421
+ : "linear";
422
+ }
423
+
424
+ function asGradientValue(value: unknown): {
425
+ angle: number;
426
+ gradientType: GradientType;
427
+ stops: readonly GradientStop[];
428
+ } {
429
+ if (isRecord(value)) {
430
+ return {
431
+ angle: asNumber(value.angle, 90),
432
+ gradientType: asGradientType(value.gradientType),
433
+ stops: Array.isArray(value.stops)
434
+ ? (value.stops as readonly GradientStop[])
435
+ : defaultGradientStops,
436
+ };
437
+ }
438
+
439
+ return {
440
+ angle: 90,
441
+ gradientType: "linear",
442
+ stops: defaultGradientStops,
443
+ };
444
+ }
445
+
446
+ function asFontPickerValue(value: unknown): FontPickerValue {
447
+ if (typeof value === "string") {
448
+ return {
449
+ color: "#FFFFFF",
450
+ fontId: value,
451
+ fontSize: 16,
452
+ fontWeight: "400",
453
+ letterSpacing: "normal",
454
+ lineHeight: "normal",
455
+ opacity: 100,
456
+ textCase: "original",
457
+ };
458
+ }
459
+
460
+ if (isRecord(value)) {
461
+ return {
462
+ color: asString(value.color, "#FFFFFF"),
463
+ fontId: asString(value.fontId, "inter"),
464
+ fontSize: asNumber(value.fontSize, 16),
465
+ fontWeight: asString(value.fontWeight, "400"),
466
+ letterSpacing:
467
+ value.letterSpacing === "tighter" ||
468
+ value.letterSpacing === "tight" ||
469
+ value.letterSpacing === "normal" ||
470
+ value.letterSpacing === "wide" ||
471
+ value.letterSpacing === "wider" ||
472
+ value.letterSpacing === "widest"
473
+ ? value.letterSpacing
474
+ : "normal",
475
+ lineHeight:
476
+ value.lineHeight === "none" ||
477
+ value.lineHeight === "tight" ||
478
+ value.lineHeight === "snug" ||
479
+ value.lineHeight === "normal" ||
480
+ value.lineHeight === "relaxed" ||
481
+ value.lineHeight === "loose"
482
+ ? value.lineHeight
483
+ : "normal",
484
+ opacity: Math.min(100, Math.max(0, Math.round(asNumber(value.opacity, 100)))),
485
+ textCase:
486
+ value.textCase === "original" ||
487
+ value.textCase === "uppercase" ||
488
+ value.textCase === "lowercase" ||
489
+ value.textCase === "capitalize" ||
490
+ value.textCase === "titleCase"
491
+ ? value.textCase
492
+ : "original",
493
+ };
494
+ }
495
+
496
+ return {
497
+ color: "#FFFFFF",
498
+ fontId: "inter",
499
+ fontSize: 16,
500
+ fontWeight: "400",
501
+ letterSpacing: "normal",
502
+ lineHeight: "normal",
503
+ opacity: 100,
504
+ textCase: "original",
505
+ };
506
+ }
507
+
508
+ function asActionSchemas(
509
+ actions: readonly (ToolcraftActionSchema | string)[] | undefined,
510
+ ): readonly ToolcraftActionSchema[] {
511
+ return (actions ?? []).map((action) =>
512
+ typeof action === "string"
513
+ ? {
514
+ label: action,
515
+ value: action,
516
+ }
517
+ : action,
518
+ );
519
+ }
520
+
521
+ function getActionCommand(action: ToolcraftActionSchema): ToolcraftActionCommand | null {
522
+ if (action.command) {
523
+ return action.command;
524
+ }
525
+
526
+ switch (action.value.toLowerCase()) {
527
+ case "apply":
528
+ return "controls.apply";
529
+ case "reset":
530
+ return "controls.reset";
531
+ default:
532
+ return null;
533
+ }
534
+ }
535
+
536
+ function getActionLabel(action: ToolcraftActionSchema): string {
537
+ return action.label ?? action.value;
538
+ }
539
+
540
+ function getControlName(id: string, label: boolean | string | undefined): string {
541
+ if (typeof label === "string") {
542
+ return label;
543
+ }
544
+
545
+ return id;
546
+ }
547
+
548
+ function ControlKeyframeButton({
549
+ active,
550
+ name,
551
+ onClick,
552
+ }: {
553
+ active: boolean;
554
+ name: string;
555
+ onClick: () => void;
556
+ }): React.JSX.Element {
557
+ const label = active ? `Disable ${name} keyframes` : `Add ${name} keyframe`;
558
+
559
+ return (
560
+ <Tooltip>
561
+ <TooltipTrigger
562
+ render={
563
+ <Button
564
+ aria-label={label}
565
+ aria-pressed={active}
566
+ className={cn(
567
+ "size-4 opacity-100 transition-opacity duration-150 ease-out hover:!bg-transparent active:!bg-transparent aria-pressed:!bg-transparent data-popup-open:!bg-transparent [&_svg:not([class*='size-'])]:!size-2.5 [&_svg:not([class*='size-'])]:!opacity-70 data-[icon-active=true]:[&_svg:not([class*='size-'])]:!opacity-100",
568
+ active &&
569
+ "!text-[color:var(--link)] aria-pressed:!text-[color:var(--link)] data-popup-open:!text-[color:var(--link)] [&_svg]:!text-[color:var(--link)] [&_svg]:!fill-[color:var(--link)]",
570
+ )}
571
+ data-icon-active={active}
572
+ onClick={(event) => {
573
+ event.stopPropagation();
574
+ onClick();
575
+
576
+ if (typeof event.currentTarget.blur === "function") {
577
+ event.currentTarget.blur();
578
+ }
579
+ }}
580
+ size="icon-sm"
581
+ style={active ? { color: "var(--link)" } : undefined}
582
+ type="button"
583
+ variant="ghost-static"
584
+ />
585
+ }
586
+ >
587
+ <DiamondIcon weight={active ? "fill" : "regular"} />
588
+ </TooltipTrigger>
589
+ <TooltipContent side="top">{label}</TooltipContent>
590
+ </Tooltip>
591
+ );
592
+ }
593
+
594
+ function getControlRenderGroups(entries: readonly ControlEntry[]): ControlRenderGroup[] {
595
+ const groups: ControlRenderGroup[] = [];
596
+ let index = 0;
597
+
598
+ while (index < entries.length) {
599
+ const entry = entries[index];
600
+
601
+ if (!entry) {
602
+ index += 1;
603
+ continue;
604
+ }
605
+
606
+ if (entry[1].type !== "color") {
607
+ groups.push({ entry, kind: "control" });
608
+ index += 1;
609
+ continue;
610
+ }
611
+
612
+ const colorEntries: ControlEntry[] = [];
613
+
614
+ while (entries[index]?.[1].type === "color") {
615
+ const colorEntry = entries[index];
616
+
617
+ if (colorEntry) {
618
+ colorEntries.push(colorEntry);
619
+ }
620
+
621
+ index += 1;
622
+ }
623
+
624
+ for (let colorIndex = 0; colorIndex < colorEntries.length; colorIndex += 2) {
625
+ const firstEntry = colorEntries[colorIndex];
626
+ const secondEntry = colorEntries[colorIndex + 1];
627
+
628
+ if (firstEntry && secondEntry) {
629
+ groups.push({ entries: [firstEntry, secondEntry], kind: "colorGroup" });
630
+ } else if (firstEntry) {
631
+ groups.push({ entry: firstEntry, kind: "control" });
632
+ }
633
+ }
634
+ }
635
+
636
+ return groups;
637
+ }
638
+
639
+ function getControlRenderGroupIds(group: ControlRenderGroup): readonly string[] {
640
+ return group.kind === "colorGroup"
641
+ ? group.entries.map(([id]) => id)
642
+ : [group.entry[0]];
643
+ }
644
+
645
+ function countControlsByType(
646
+ sections: readonly ToolcraftControlSectionSchema[],
647
+ type: string,
648
+ ): number {
649
+ return sections.reduce(
650
+ (count, section) =>
651
+ count +
652
+ Object.values(section.controls).filter((control) => control.type === type)
653
+ .length,
654
+ 0,
655
+ );
656
+ }
657
+
658
+ function shouldCommitTextControlOnBlur(control: ToolcraftControlSchema): boolean {
659
+ return (
660
+ control.commitMode === "setting" ||
661
+ Boolean(getToolcraftCanvasSizeTargetDimension(control.target))
662
+ );
663
+ }
664
+
665
+ function isColorFieldControl(control: ToolcraftControlSchema | undefined): boolean {
666
+ return control?.type === "color" || control?.type === "colorOpacity";
667
+ }
668
+
669
+ function isColorOnlySection(entries: readonly ControlEntry[]): boolean {
670
+ return entries.length > 0 && entries.every(([, control]) => isColorFieldControl(control));
671
+ }
672
+
673
+ function getRenderedControlsSectionTitle(
674
+ section: ToolcraftControlSectionSchema,
675
+ ): ToolcraftControlSectionSchema["title"] {
676
+ return shouldHideRuntimeControlsSectionTitle(section) ? undefined : section.title;
677
+ }
678
+
679
+ function shouldHideRuntimeControlsSectionTitle(
680
+ section: ToolcraftControlSectionSchema,
681
+ ): boolean {
682
+ return isRuntimeSetupSection(section) || isStickyFooterActionSection(section);
683
+ }
684
+
685
+ function isRuntimeSetupSection(section: ToolcraftControlSectionSchema): boolean {
686
+ if (section.title !== "Setup") {
687
+ return false;
688
+ }
689
+
690
+ return Object.values(section.controls).some(
691
+ (control) =>
692
+ control.type === "settingsTransfer" ||
693
+ control.target === "canvas.size.width" ||
694
+ control.target === "canvas.size.height",
695
+ );
696
+ }
697
+
698
+ function isStickyFooterActionSection(section: ToolcraftControlSectionSchema): boolean {
699
+ return (
700
+ section.actionGroup !== undefined &&
701
+ Object.values(section.controls).some((control) => control.type === "panelActions")
702
+ );
703
+ }
704
+
705
+ function getControlsPanelSectionCollapseKey({
706
+ entries,
707
+ section,
708
+ sectionIndex,
709
+ }: {
710
+ entries: readonly ControlEntry[];
711
+ section: ToolcraftControlSectionSchema;
712
+ sectionIndex: number;
713
+ }): string {
714
+ const targets = entries.map(([id, control]) => `${id}:${control.target}`).join("|");
715
+
716
+ return `${section.title ?? "section"}:${sectionIndex}:${targets}`;
717
+ }
718
+
719
+ function hashStorageKeyPart(value: string): string {
720
+ let hash = 2166136261;
721
+
722
+ for (let index = 0; index < value.length; index += 1) {
723
+ hash ^= value.charCodeAt(index);
724
+ hash = Math.imul(hash, 16777619);
725
+ }
726
+
727
+ return (hash >>> 0).toString(36);
728
+ }
729
+
730
+ function getControlsPanelSectionCollapseStorageKey(
731
+ schema: ResolvedToolcraftAppSchema,
732
+ ): string | null {
733
+ const controlsPanel = schema.panels.controls;
734
+
735
+ if (!controlsPanel) {
736
+ return null;
737
+ }
738
+
739
+ const appIdentity =
740
+ schema.persistence.storage === "localStorage"
741
+ ? `persistence:${schema.persistence.key}:v${schema.persistence.version}`
742
+ : JSON.stringify({
743
+ sections: controlsPanel.sections.map((section) => ({
744
+ controls: Object.entries(section.controls).map(([id, control]) => ({
745
+ id,
746
+ label: control.label,
747
+ target: control.target,
748
+ type: control.type,
749
+ })),
750
+ title: section.title,
751
+ })),
752
+ title: controlsPanel.title,
753
+ });
754
+
755
+ return `toolcraft:ui:controls-panel-sections:${hashStorageKeyPart(appIdentity)}:v${controlsPanelSectionCollapseStorageVersion}`;
756
+ }
757
+
758
+ function readControlsPanelCollapsedSections(
759
+ storageKey: string | null,
760
+ ): Record<string, boolean> {
761
+ if (!storageKey || typeof window === "undefined") {
762
+ return {};
763
+ }
764
+
765
+ try {
766
+ const rawValue = readToolcraftLocalStorageValue(storageKey);
767
+
768
+ if (!rawValue) {
769
+ return {};
770
+ }
771
+
772
+ const payload: unknown = JSON.parse(rawValue);
773
+
774
+ if (
775
+ !payload ||
776
+ typeof payload !== "object" ||
777
+ !("version" in payload) ||
778
+ payload.version !== controlsPanelSectionCollapseStorageVersion ||
779
+ !("collapsed" in payload) ||
780
+ !Array.isArray(payload.collapsed)
781
+ ) {
782
+ return {};
783
+ }
784
+
785
+ return Object.fromEntries(
786
+ payload.collapsed
787
+ .filter((item): item is string => typeof item === "string" && item.length > 0)
788
+ .map((item) => [item, true]),
789
+ );
790
+ } catch {
791
+ return {};
792
+ }
793
+ }
794
+
795
+ function writeControlsPanelCollapsedSections(
796
+ storageKey: string | null,
797
+ collapsedSectionByKey: Record<string, boolean>,
798
+ ): void {
799
+ if (!storageKey || typeof window === "undefined") {
800
+ return;
801
+ }
802
+
803
+ const collapsed = Object.entries(collapsedSectionByKey)
804
+ .filter(([, collapsedValue]) => collapsedValue)
805
+ .map(([key]) => key);
806
+
807
+ try {
808
+ if (collapsed.length === 0) {
809
+ removeToolcraftLocalStorageValue(storageKey);
810
+ return;
811
+ }
812
+
813
+ window.localStorage.setItem(
814
+ storageKey,
815
+ JSON.stringify({
816
+ collapsed,
817
+ version: controlsPanelSectionCollapseStorageVersion,
818
+ }),
819
+ );
820
+ } catch {
821
+ // UI preferences are best-effort; panel interaction stays authoritative.
822
+ }
823
+ }
824
+
825
+ function shouldShowColorFieldLabel({
826
+ control,
827
+ sectionHasOnlyColorFields,
828
+ }: {
829
+ control: ToolcraftControlSchema;
830
+ sectionHasOnlyColorFields: boolean;
831
+ }): boolean {
832
+ return control.label !== false && !sectionHasOnlyColorFields;
833
+ }
834
+
835
+ function hasColorOpacityControl(
836
+ controlsById: Record<string, ToolcraftControlSchema>,
837
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
838
+ ): boolean {
839
+ return layoutGroup.controls.some(
840
+ (controlId) => controlsById[controlId]?.type === "colorOpacity",
841
+ );
842
+ }
843
+
844
+ function hasSliderControl(
845
+ controlsById: Record<string, ToolcraftControlSchema>,
846
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
847
+ ): boolean {
848
+ return layoutGroup.controls.some(
849
+ (controlId) =>
850
+ controlsById[controlId]?.type === "slider" ||
851
+ controlsById[controlId]?.type === "rangeSlider",
852
+ );
853
+ }
854
+
855
+ function hasSectionedCompoundControl(
856
+ controlsById: Record<string, ToolcraftControlSchema>,
857
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
858
+ ): boolean {
859
+ return layoutGroup.controls.some((controlId) => {
860
+ const control = controlsById[controlId];
861
+
862
+ return control ? shouldRenderCompoundControlSectionDivider(control) : false;
863
+ });
864
+ }
865
+
866
+ const maxInlineSwitchLabelLength = 16;
867
+ const maxInlineSwitchLabelWordCount = 2;
868
+
869
+ function isInlineSwitchLabelSafe(
870
+ controlId: string,
871
+ control: ToolcraftControlSchema,
872
+ ): boolean {
873
+ const label = getControlName(controlId, control.label).trim();
874
+ const wordCount = label.split(/\s+/u).filter(Boolean).length;
875
+
876
+ return label.length <= maxInlineSwitchLabelLength && wordCount <= maxInlineSwitchLabelWordCount;
877
+ }
878
+
879
+ function hasUnsafeInlineSwitchLabels(
880
+ controlsById: Record<string, ToolcraftControlSchema>,
881
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
882
+ ): boolean {
883
+ const switchEntries = layoutGroup.controls
884
+ .map((controlId) => [controlId, controlsById[controlId]] as const)
885
+ .filter(
886
+ (entry): entry is readonly [string, ToolcraftControlSchema] =>
887
+ Boolean(entry[1]) && entry[1].type === "switch",
888
+ );
889
+
890
+ return (
891
+ switchEntries.length > 1 &&
892
+ switchEntries.some(([controlId, control]) => !isInlineSwitchLabelSafe(controlId, control))
893
+ );
894
+ }
895
+
896
+ function isBooleanControl(control: ToolcraftControlSchema | undefined): boolean {
897
+ return control?.type === "checkbox" || control?.type === "switch";
898
+ }
899
+
900
+ function isToggleParameterLayoutGroup(
901
+ controlsById: Record<string, ToolcraftControlSchema>,
902
+ layoutGroup: ToolcraftControlLayoutGroupSchema,
903
+ ): boolean {
904
+ if (layoutGroup.controls.length !== 2) {
905
+ return false;
906
+ }
907
+
908
+ const controls = layoutGroup.controls.map((controlId) => controlsById[controlId]);
909
+ const booleanControlCount = controls.filter((control) => isBooleanControl(control)).length;
910
+ const parameterControlCount = controls.filter((control) => !isBooleanControl(control)).length;
911
+
912
+ return booleanControlCount === 1 && parameterControlCount === 1;
913
+ }
914
+
915
+ function getControlTargetEntityKey(control: ToolcraftControlSchema): string | null {
916
+ const parts = control.target.split(".");
917
+
918
+ if (parts.length < 2) {
919
+ return null;
920
+ }
921
+
922
+ return parts.slice(0, -1).join(".");
923
+ }
924
+
925
+ function shouldAutoInlineBooleanPair(
926
+ controlsById: Record<string, ToolcraftControlSchema>,
927
+ firstId: string,
928
+ secondId: string,
929
+ ): boolean {
930
+ const firstControl = controlsById[firstId];
931
+ const secondControl = controlsById[secondId];
932
+
933
+ if (!isBooleanControl(firstControl) || !isBooleanControl(secondControl)) {
934
+ return false;
935
+ }
936
+
937
+ if (
938
+ !isInlineSwitchLabelSafe(firstId, firstControl) ||
939
+ !isInlineSwitchLabelSafe(secondId, secondControl)
940
+ ) {
941
+ return false;
942
+ }
943
+
944
+ const firstEntityKey = getControlTargetEntityKey(firstControl);
945
+ const secondEntityKey = getControlTargetEntityKey(secondControl);
946
+
947
+ return firstEntityKey !== null && firstEntityKey === secondEntityKey;
948
+ }
949
+
950
+ function getAutoInlineBooleanLayoutGroups({
951
+ controlsById,
952
+ renderedGroups,
953
+ reservedControlIds,
954
+ }: {
955
+ controlsById: Record<string, ToolcraftControlSchema>;
956
+ renderedGroups: readonly RenderedControlRenderGroup[];
957
+ reservedControlIds: ReadonlySet<string>;
958
+ }): ToolcraftControlLayoutGroupSchema[] {
959
+ const layoutGroups: ToolcraftControlLayoutGroupSchema[] = [];
960
+ let index = 0;
961
+
962
+ while (index < renderedGroups.length) {
963
+ const firstIds = renderedGroups[index]?.ids;
964
+ const secondIds = renderedGroups[index + 1]?.ids;
965
+ const firstId = firstIds?.length === 1 ? firstIds[0] : undefined;
966
+ const secondId = secondIds?.length === 1 ? secondIds[0] : undefined;
967
+
968
+ if (
969
+ firstId &&
970
+ secondId &&
971
+ !reservedControlIds.has(firstId) &&
972
+ !reservedControlIds.has(secondId) &&
973
+ shouldAutoInlineBooleanPair(controlsById, firstId, secondId)
974
+ ) {
975
+ layoutGroups.push({
976
+ columns: 2,
977
+ controls: [firstId, secondId],
978
+ layout: "inline",
979
+ });
980
+ index += 2;
981
+ continue;
982
+ }
983
+
984
+ index += 1;
985
+ }
986
+
987
+ return layoutGroups;
988
+ }
989
+
990
+ function getControlMarkerCount(
991
+ control: ToolcraftControlSchema,
992
+ markerLimit?: number,
993
+ ): number | undefined {
994
+ const markerCount = control.markerCount;
995
+
996
+ if (
997
+ markerLimit &&
998
+ control.variant === "discrete" &&
999
+ typeof markerCount === "number" &&
1000
+ markerCount > markerLimit
1001
+ ) {
1002
+ return hiddenDiscreteMarkerCount;
1003
+ }
1004
+
1005
+ return markerCount;
1006
+ }
1007
+
1008
+ function renderControlLayoutGroups({
1009
+ controlsById,
1010
+ layoutGroups,
1011
+ renderedGroups,
1012
+ }: {
1013
+ controlsById: Record<string, ToolcraftControlSchema>;
1014
+ layoutGroups?: readonly ToolcraftControlLayoutGroupSchema[];
1015
+ renderedGroups: readonly RenderedControlRenderGroup[];
1016
+ }): React.ReactNode[] {
1017
+ if (!layoutGroups?.length) {
1018
+ const autoLayoutGroups = getAutoInlineBooleanLayoutGroups({
1019
+ controlsById,
1020
+ renderedGroups,
1021
+ reservedControlIds: new Set(),
1022
+ });
1023
+
1024
+ if (autoLayoutGroups.length === 0) {
1025
+ return renderedGroups.map((group) => group.node);
1026
+ }
1027
+
1028
+ return renderControlLayoutGroups({
1029
+ controlsById,
1030
+ layoutGroups: autoLayoutGroups,
1031
+ renderedGroups,
1032
+ });
1033
+ }
1034
+
1035
+ const reservedControlIds = new Set(layoutGroups.flatMap((group) => group.controls));
1036
+ const resolvedLayoutGroups = [
1037
+ ...layoutGroups,
1038
+ ...getAutoInlineBooleanLayoutGroups({
1039
+ controlsById,
1040
+ renderedGroups,
1041
+ reservedControlIds,
1042
+ }),
1043
+ ];
1044
+ const layoutGroupByControlId = getInlineLayoutGroupByControlId({
1045
+ controlsById,
1046
+ layoutGroups: resolvedLayoutGroups,
1047
+ });
1048
+
1049
+ const nodes: React.ReactNode[] = [];
1050
+
1051
+ for (const renderedGroup of renderedGroups) {
1052
+ const layoutGroup = renderedGroup.ids
1053
+ .map((id) => layoutGroupByControlId.get(id))
1054
+ .find((group): group is ToolcraftControlLayoutGroupSchema => Boolean(group));
1055
+
1056
+ if (!layoutGroup) {
1057
+ nodes.push(renderedGroup.node);
1058
+ continue;
1059
+ }
1060
+
1061
+ const groupedRenderedControls = renderedGroups.filter((candidate) =>
1062
+ candidate.ids.some((id) => layoutGroup.controls.includes(id)),
1063
+ );
1064
+ const firstGroupedControl = groupedRenderedControls[0];
1065
+
1066
+ if (firstGroupedControl !== renderedGroup) {
1067
+ continue;
1068
+ }
1069
+
1070
+ if (groupedRenderedControls.length < 2) {
1071
+ nodes.push(renderedGroup.node);
1072
+ continue;
1073
+ }
1074
+
1075
+ nodes.push(
1076
+ <ControlInlineGroup
1077
+ columns={layoutGroup.columns ?? 2}
1078
+ kind={
1079
+ isToggleParameterLayoutGroup(controlsById, layoutGroup)
1080
+ ? "toggleParameter"
1081
+ : "default"
1082
+ }
1083
+ key={`layout-group-${layoutGroup.controls.join("-")}`}
1084
+ >
1085
+ {groupedRenderedControls.map((group) => group.node)}
1086
+ </ControlInlineGroup>,
1087
+ );
1088
+ }
1089
+
1090
+ return nodes;
1091
+ }
1092
+
1093
+ function getInlineLayoutGroupByControlId({
1094
+ controlsById,
1095
+ layoutGroups,
1096
+ }: {
1097
+ controlsById: Record<string, ToolcraftControlSchema>;
1098
+ layoutGroups?: readonly ToolcraftControlLayoutGroupSchema[];
1099
+ }): Map<string, ToolcraftControlLayoutGroupSchema> {
1100
+ const layoutGroupByControlId = new Map<string, ToolcraftControlLayoutGroupSchema>();
1101
+
1102
+ for (const layoutGroup of layoutGroups ?? []) {
1103
+ if (layoutGroup.layout !== "inline") {
1104
+ continue;
1105
+ }
1106
+
1107
+ if (
1108
+ hasColorOpacityControl(controlsById, layoutGroup) ||
1109
+ hasSliderControl(controlsById, layoutGroup) ||
1110
+ hasSectionedCompoundControl(controlsById, layoutGroup) ||
1111
+ hasUnsafeInlineSwitchLabels(controlsById, layoutGroup)
1112
+ ) {
1113
+ continue;
1114
+ }
1115
+
1116
+ for (const controlId of layoutGroup.controls) {
1117
+ layoutGroupByControlId.set(controlId, layoutGroup);
1118
+ }
1119
+ }
1120
+
1121
+ return layoutGroupByControlId;
1122
+ }
1123
+
1124
+ export function ControlsPanel({
1125
+ className,
1126
+ controlRenderers,
1127
+ framed = true,
1128
+ onPanelAction,
1129
+ onPanelStateChange,
1130
+ panelPlacement,
1131
+ panelState,
1132
+ }: ControlsPanelProps): React.JSX.Element | null {
1133
+ const { dispatch, state } = useToolcraft();
1134
+ const nextFooterActionIdRef = React.useRef(0);
1135
+ const [footerActionProgressEntries, setFooterActionProgressEntries] = React.useState<
1136
+ readonly FooterActionProgressEntry[]
1137
+ >([]);
1138
+ const sectionCollapseStorageKey = React.useMemo(
1139
+ () => getControlsPanelSectionCollapseStorageKey(state.schema),
1140
+ [state.schema],
1141
+ );
1142
+ const [collapsedSectionByKey, setCollapsedSectionByKey] = React.useState<
1143
+ Record<string, boolean>
1144
+ >(() => readControlsPanelCollapsedSections(sectionCollapseStorageKey));
1145
+ const stickyFooterProgress = React.useMemo(() => {
1146
+ for (let index = footerActionProgressEntries.length - 1; index >= 0; index -= 1) {
1147
+ const progress = footerActionProgressEntries[index]?.progress;
1148
+
1149
+ if (typeof progress === "number") {
1150
+ return progress;
1151
+ }
1152
+ }
1153
+
1154
+ return null;
1155
+ }, [footerActionProgressEntries]);
1156
+ const controlsPanel = state.schema.panels.controls;
1157
+ const keyframedControlIds = React.useMemo(
1158
+ () => new Set(state.timeline.keyframeGroups.map((group) => group.controlId)),
1159
+ [state.timeline.keyframeGroups],
1160
+ );
1161
+ const keyframeControlsEnabled = Boolean(
1162
+ state.schema.assembly.capabilities.includes("timeline.keyframes") &&
1163
+ state.timeline.expanded,
1164
+ );
1165
+
1166
+ React.useEffect(() => {
1167
+ setCollapsedSectionByKey(readControlsPanelCollapsedSections(sectionCollapseStorageKey));
1168
+ }, [sectionCollapseStorageKey]);
1169
+
1170
+ if (!controlsPanel) {
1171
+ return null;
1172
+ }
1173
+
1174
+ const resolvedControlsPanel = controlsPanel;
1175
+ const placement = panelPlacement ?? (framed ? "frame" : "surface");
1176
+ const lastHistoryPatch = state.history.undo.at(-1);
1177
+ const controlsResetKey =
1178
+ lastHistoryPatch?.label === "Reset controls" ? state.history.undo.length : 0;
1179
+
1180
+ function dispatchCommand(command: ToolcraftCommand): void {
1181
+ dispatch(command);
1182
+ }
1183
+
1184
+ function setControlValue(
1185
+ target: string,
1186
+ value: unknown,
1187
+ label?: string,
1188
+ meta?: ControlChangeMeta,
1189
+ ): void {
1190
+ dispatchCommand({
1191
+ history: meta?.history,
1192
+ historyGroup: meta?.historyGroup,
1193
+ label,
1194
+ target,
1195
+ type: "controls.setValue",
1196
+ value,
1197
+ });
1198
+ }
1199
+
1200
+ function createFooterActionProgressTracker(): {
1201
+ reportProgress: (progress: number) => void;
1202
+ trackResult: (result: PromiseLike<unknown> | void) => void;
1203
+ } {
1204
+ const id = nextFooterActionIdRef.current;
1205
+ nextFooterActionIdRef.current += 1;
1206
+
1207
+ let latestProgress: number | null = null;
1208
+ let isTracked = false;
1209
+
1210
+ function reportProgress(progress: number): void {
1211
+ latestProgress = clampFooterActionProgress(progress);
1212
+
1213
+ if (!isTracked) {
1214
+ return;
1215
+ }
1216
+
1217
+ setFooterActionProgressEntries((entries) =>
1218
+ entries.map((entry) =>
1219
+ entry.id === id ? { ...entry, progress: latestProgress } : entry,
1220
+ ),
1221
+ );
1222
+ }
1223
+
1224
+ function trackResult(result: PromiseLike<unknown> | void): void {
1225
+ if (!isPromiseLike(result)) {
1226
+ return;
1227
+ }
1228
+
1229
+ isTracked = true;
1230
+ setFooterActionProgressEntries((entries) => [
1231
+ ...entries,
1232
+ { id, progress: latestProgress },
1233
+ ]);
1234
+
1235
+ void Promise.resolve(result)
1236
+ .catch((error: unknown) => {
1237
+ console.error("Toolcraft panel action failed.", error);
1238
+ })
1239
+ .finally(() => {
1240
+ setFooterActionProgressEntries((entries) =>
1241
+ entries.filter((entry) => entry.id !== id),
1242
+ );
1243
+ });
1244
+ }
1245
+
1246
+ return { reportProgress, trackResult };
1247
+ }
1248
+
1249
+ function runAction(
1250
+ action: ToolcraftActionSchema,
1251
+ options: { trackFooterPending?: boolean } = {},
1252
+ ): void {
1253
+ const command = action.command ?? (onPanelAction ? null : getActionCommand(action));
1254
+
1255
+ if (command) {
1256
+ dispatchCommand({ type: command });
1257
+ return;
1258
+ }
1259
+
1260
+ const footerActionProgressTracker = options.trackFooterPending
1261
+ ? createFooterActionProgressTracker()
1262
+ : null;
1263
+ const result = onPanelAction?.({
1264
+ action,
1265
+ dispatch,
1266
+ reportProgress:
1267
+ footerActionProgressTracker?.reportProgress ?? noopReportProgress,
1268
+ state,
1269
+ });
1270
+
1271
+ footerActionProgressTracker?.trackResult(result);
1272
+ }
1273
+
1274
+ function getControlValue(control: ToolcraftControlSchema): unknown {
1275
+ const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(control.target);
1276
+
1277
+ return canvasSizeDimension
1278
+ ? state.canvas.size[canvasSizeDimension]
1279
+ : (state.values[control.target] ?? control.defaultValue);
1280
+ }
1281
+
1282
+ function getControlDefaultValueByTarget(target: string): unknown {
1283
+ for (const section of resolvedControlsPanel.sections) {
1284
+ for (const control of Object.values(section.controls)) {
1285
+ if (control.target === target) {
1286
+ return control.defaultValue;
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ return undefined;
1292
+ }
1293
+
1294
+ function getTargetValue(target: string): unknown {
1295
+ const canvasSizeDimension = getToolcraftCanvasSizeTargetDimension(target);
1296
+
1297
+ return canvasSizeDimension
1298
+ ? state.canvas.size[canvasSizeDimension]
1299
+ : (state.values[target] ?? getControlDefaultValueByTarget(target));
1300
+ }
1301
+
1302
+ function conditionMatches(condition: ToolcraftControlConditionSchema): boolean {
1303
+ const value = getTargetValue(condition.target);
1304
+ const matches: boolean[] = [];
1305
+
1306
+ if ("equals" in condition) {
1307
+ matches.push(valuesEqual(value, condition.equals));
1308
+ }
1309
+
1310
+ if ("notEquals" in condition) {
1311
+ matches.push(!valuesEqual(value, condition.notEquals));
1312
+ }
1313
+
1314
+ if (Array.isArray(condition.oneOf)) {
1315
+ matches.push(valuesInclude(value, condition.oneOf));
1316
+ }
1317
+
1318
+ if (Array.isArray(condition.notOneOf)) {
1319
+ matches.push(!valuesInclude(value, condition.notOneOf));
1320
+ }
1321
+
1322
+ const greaterThan = compareConditionNumbers(
1323
+ value,
1324
+ condition.greaterThan,
1325
+ (numberValue, expected) => numberValue > expected,
1326
+ );
1327
+ const greaterThanOrEqual = compareConditionNumbers(
1328
+ value,
1329
+ condition.greaterThanOrEqual,
1330
+ (numberValue, expected) => numberValue >= expected,
1331
+ );
1332
+ const lessThan = compareConditionNumbers(
1333
+ value,
1334
+ condition.lessThan,
1335
+ (numberValue, expected) => numberValue < expected,
1336
+ );
1337
+ const lessThanOrEqual = compareConditionNumbers(
1338
+ value,
1339
+ condition.lessThanOrEqual,
1340
+ (numberValue, expected) => numberValue <= expected,
1341
+ );
1342
+
1343
+ for (const match of [
1344
+ greaterThan,
1345
+ greaterThanOrEqual,
1346
+ lessThan,
1347
+ lessThanOrEqual,
1348
+ ]) {
1349
+ if (match !== null) {
1350
+ matches.push(match);
1351
+ }
1352
+ }
1353
+
1354
+ return matches.length > 0 && matches.every(Boolean);
1355
+ }
1356
+
1357
+ function isControlDisabled(control: ToolcraftControlSchema): boolean {
1358
+ if (control.disabled) {
1359
+ return true;
1360
+ }
1361
+
1362
+ return control.disabledWhen ? conditionMatches(control.disabledWhen) : false;
1363
+ }
1364
+
1365
+ function isControlVisible(control: ToolcraftControlSchema): boolean {
1366
+ return control.visibleWhen ? conditionMatches(control.visibleWhen) : true;
1367
+ }
1368
+
1369
+ function isSectionVisible(section: ToolcraftControlSectionSchema): boolean {
1370
+ return section.visibleWhen ? conditionMatches(section.visibleWhen) : true;
1371
+ }
1372
+
1373
+ function getVisibleSectionEntries(
1374
+ section: ToolcraftControlSectionSchema,
1375
+ ): ControlEntry[] {
1376
+ return Object.entries(section.controls).filter(([, control]) =>
1377
+ isControlVisible(control),
1378
+ );
1379
+ }
1380
+
1381
+ function getControlsRecord(
1382
+ entries: readonly ControlEntry[],
1383
+ ): Record<string, ToolcraftControlSchema> {
1384
+ return Object.fromEntries(entries);
1385
+ }
1386
+
1387
+ function getSelectedControlKeyframeTime(controlId: string): number | undefined {
1388
+ const selectedKeyframeId = state.timeline.selectedKeyframeId;
1389
+
1390
+ if (!selectedKeyframeId) {
1391
+ return undefined;
1392
+ }
1393
+
1394
+ const selectedKeyframe = state.timeline.keyframeGroups
1395
+ .find((group) => group.controlId === controlId)
1396
+ ?.keyframes.find((keyframe) => keyframe.id === selectedKeyframeId);
1397
+
1398
+ return selectedKeyframe?.timeSeconds;
1399
+ }
1400
+
1401
+ function maybeUpsertControlKeyframe(
1402
+ control: ToolcraftControlSchema,
1403
+ name: string,
1404
+ value: unknown,
1405
+ ): void {
1406
+ if (
1407
+ !keyframeControlsEnabled ||
1408
+ !keyframedControlIds.has(control.target) ||
1409
+ !canCreateControlKeyframe(control)
1410
+ ) {
1411
+ return;
1412
+ }
1413
+
1414
+ dispatchCommand({
1415
+ controlId: control.target,
1416
+ controlLabel: name,
1417
+ timeSeconds: getSelectedControlKeyframeTime(control.target),
1418
+ type: "timeline.upsertControlKeyframe",
1419
+ value,
1420
+ valueLabel: formatControlValueLabel(control, value),
1421
+ });
1422
+ }
1423
+
1424
+ function getKeyframeLabelAction(
1425
+ control: ToolcraftControlSchema,
1426
+ name: string,
1427
+ value: unknown,
1428
+ ): React.ReactNode {
1429
+ if (!keyframeControlsEnabled || !canCreateControlKeyframe(control)) {
1430
+ return null;
1431
+ }
1432
+
1433
+ return (
1434
+ <ControlKeyframeButton
1435
+ active={keyframedControlIds.has(control.target)}
1436
+ name={name}
1437
+ onClick={() => {
1438
+ dispatchCommand({
1439
+ controlId: control.target,
1440
+ controlLabel: name,
1441
+ type: "timeline.toggleControlKeyframes",
1442
+ value,
1443
+ valueLabel: formatControlValueLabel(control, value),
1444
+ });
1445
+ }}
1446
+ />
1447
+ );
1448
+ }
1449
+
1450
+ function withKeyframeLabelAction({
1451
+ children,
1452
+ control,
1453
+ disableAction = false,
1454
+ labelActionName,
1455
+ name,
1456
+ providerKey,
1457
+ value,
1458
+ }: {
1459
+ children: React.ReactNode;
1460
+ control: ToolcraftControlSchema;
1461
+ disableAction?: boolean;
1462
+ labelActionName?: string;
1463
+ name: string;
1464
+ providerKey: string;
1465
+ value: unknown;
1466
+ }): React.ReactNode {
1467
+ if (disableAction) {
1468
+ return children;
1469
+ }
1470
+
1471
+ const actionName = labelActionName ?? name;
1472
+ const action = getKeyframeLabelAction(control, actionName, value);
1473
+
1474
+ if (!action) {
1475
+ return children;
1476
+ }
1477
+
1478
+ return (
1479
+ <ControlFieldLabelActionProvider
1480
+ action={action}
1481
+ key={providerKey}
1482
+ label={actionName}
1483
+ >
1484
+ {children}
1485
+ </ControlFieldLabelActionProvider>
1486
+ );
1487
+ }
1488
+
1489
+ function getControlHelpText(
1490
+ control: ToolcraftControlSchema,
1491
+ ): string | null {
1492
+ const description = control.description?.trim();
1493
+
1494
+ return description || null;
1495
+ }
1496
+
1497
+ function withControlLabelHelp({
1498
+ children,
1499
+ control,
1500
+ label,
1501
+ providerKey,
1502
+ }: {
1503
+ children: React.ReactNode;
1504
+ control: ToolcraftControlSchema;
1505
+ label: string;
1506
+ providerKey: string;
1507
+ }): React.ReactNode {
1508
+ const help = getControlHelpText(control);
1509
+
1510
+ if (!help) {
1511
+ return children;
1512
+ }
1513
+
1514
+ return (
1515
+ <ControlFieldLabelHelpProvider
1516
+ help={help}
1517
+ key={`${providerKey}-help`}
1518
+ label={label}
1519
+ >
1520
+ {children}
1521
+ </ControlFieldLabelHelpProvider>
1522
+ );
1523
+ }
1524
+
1525
+ function getSectionHeaderKeyframeEntry(
1526
+ entries: readonly ControlEntry[],
1527
+ title: React.ReactNode,
1528
+ ): ControlEntry | null {
1529
+ if (typeof title !== "string") {
1530
+ return null;
1531
+ }
1532
+
1533
+ const matchingTitleEntry = entries.find(([id, control]) => {
1534
+ if (control.type === "channelMixer" || control.type === "curves") {
1535
+ return false;
1536
+ }
1537
+
1538
+ const name = getControlName(id, control.label);
1539
+
1540
+ return name === title && canCreateControlKeyframe(control);
1541
+ });
1542
+
1543
+ if (matchingTitleEntry) {
1544
+ return matchingTitleEntry;
1545
+ }
1546
+
1547
+ return null;
1548
+ }
1549
+
1550
+ function getSectionHeaderKeyframeAction(entry: ControlEntry): React.ReactNode {
1551
+ const [id, control] = entry;
1552
+ const name = getControlName(id, control.label);
1553
+
1554
+ return getKeyframeLabelAction(control, name, getControlValue(control));
1555
+ }
1556
+
1557
+ function renderColorGroup(
1558
+ entries: readonly ControlEntry[],
1559
+ headerKeyframeTarget: string | null,
1560
+ sectionHasOnlyColorFields: boolean,
1561
+ ): React.JSX.Element | null {
1562
+ const colorInputs = entries.map(([id, control]) => {
1563
+ const name = getControlName(id, control.label);
1564
+ const value = getControlValue(control);
1565
+ const colorValue = asColorValue(value);
1566
+
1567
+ return {
1568
+ hex: colorValue.hex,
1569
+ name,
1570
+ onValueChange: (nextValue, meta) => {
1571
+ setControlValue(control.target, nextValue, name, meta);
1572
+ maybeUpsertControlKeyframe(control, name, nextValue);
1573
+ },
1574
+ showLabel: shouldShowColorFieldLabel({
1575
+ control,
1576
+ sectionHasOnlyColorFields,
1577
+ }),
1578
+ } satisfies ColorControlInput;
1579
+ });
1580
+ const [firstInput, secondInput] = colorInputs;
1581
+
1582
+ if (!firstInput) {
1583
+ return null;
1584
+ }
1585
+
1586
+ if (!secondInput) {
1587
+ const firstEntry = entries[0];
1588
+ const firstControl = firstEntry?.[1];
1589
+ const firstValue = firstControl ? getControlValue(firstControl) : undefined;
1590
+
1591
+ return firstControl ? (
1592
+ withKeyframeLabelAction({
1593
+ children: (
1594
+ <Color
1595
+ hex={firstInput.hex}
1596
+ key={firstInput.name}
1597
+ name={firstInput.name}
1598
+ onValueChange={firstInput.onValueChange}
1599
+ showLabel={shouldShowColorFieldLabel({
1600
+ control: firstControl,
1601
+ sectionHasOnlyColorFields,
1602
+ })}
1603
+ />
1604
+ ),
1605
+ control: firstControl,
1606
+ disableAction: firstControl.target === headerKeyframeTarget,
1607
+ name: firstInput.name,
1608
+ providerKey: firstInput.name,
1609
+ value: firstValue,
1610
+ }) as React.JSX.Element
1611
+ ) : (
1612
+ <Color
1613
+ hex={firstInput.hex}
1614
+ key={firstInput.name}
1615
+ name={firstInput.name}
1616
+ onValueChange={firstInput.onValueChange}
1617
+ showLabel={firstInput.showLabel}
1618
+ />
1619
+ );
1620
+ }
1621
+
1622
+ return (
1623
+ <Color
1624
+ inputs={[firstInput, secondInput] as ColorControlInputPair}
1625
+ key={`${firstInput.name}-${secondInput.name}`}
1626
+ />
1627
+ );
1628
+ }
1629
+
1630
+ const visibleSections = resolvedControlsPanel.sections
1631
+ .map((section) => ({
1632
+ entries: getVisibleSectionEntries(section),
1633
+ section,
1634
+ }))
1635
+ .filter(({ entries, section }) => isSectionVisible(section) && entries.length > 0);
1636
+ const visibleControlsPanelSections = visibleSections.map(({ entries, section }) => ({
1637
+ ...section,
1638
+ controls: getControlsRecord(entries),
1639
+ }));
1640
+ const vectorControlCount = countControlsByType(visibleControlsPanelSections, "vector");
1641
+ const vectorPadShape = vectorControlCount === 1 ? "square" : "compact";
1642
+
1643
+ const panel = (
1644
+ <Panel
1645
+ className={cn(
1646
+ "shrink-0",
1647
+ placement === "frame" && "max-h-none",
1648
+ className,
1649
+ )}
1650
+ collapsed={panelState?.collapsed}
1651
+ contentTransitionSuppressionKey={
1652
+ keyframeControlsEnabled ? "keyframes" : "plain"
1653
+ }
1654
+ key={controlsResetKey}
1655
+ onCollapsedChange={(collapsed) => onPanelStateChange?.({ collapsed })}
1656
+ onResetControls={() => dispatchCommand({ type: "controls.reset" })}
1657
+ stickyFooterActive={footerActionProgressEntries.length > 0}
1658
+ stickyFooterProgress={stickyFooterProgress}
1659
+ title={resolvedControlsPanel.title}
1660
+ >
1661
+ {visibleSections.map(({ entries, section }, sectionIndex) => {
1662
+ const visibleControls = getControlsRecord(entries);
1663
+ const sectionHasOnlyColorFields = isColorOnlySection(entries);
1664
+ const renderedSectionTitle = getRenderedControlsSectionTitle(section);
1665
+ const sectionSpacing = isRuntimeSetupSection(section) ? "technical" : "default";
1666
+ const sectionCollapseKey = getControlsPanelSectionCollapseKey({
1667
+ entries,
1668
+ section,
1669
+ sectionIndex,
1670
+ });
1671
+ const isSectionCollapsible = renderedSectionTitle !== undefined;
1672
+ const isSectionCollapsed =
1673
+ isSectionCollapsible && collapsedSectionByKey[sectionCollapseKey] === true;
1674
+ const headerKeyframeEntry = getSectionHeaderKeyframeEntry(entries, section.title);
1675
+ const headerKeyframeTarget = headerKeyframeEntry?.[1].target ?? null;
1676
+ const inlineLayoutGroupByControlId = getInlineLayoutGroupByControlId({
1677
+ controlsById: visibleControls,
1678
+ layoutGroups: section.layoutGroups,
1679
+ });
1680
+
1681
+ return (
1682
+ <PanelSection
1683
+ action={
1684
+ headerKeyframeEntry
1685
+ ? getSectionHeaderKeyframeAction(headerKeyframeEntry)
1686
+ : undefined
1687
+ }
1688
+ actionGroup={section.actionGroup}
1689
+ allowCompoundDividers={entries.length > 1}
1690
+ collapsed={isSectionCollapsed}
1691
+ collapsible={isSectionCollapsible}
1692
+ key={`${section.title ?? "section"}-${sectionIndex}`}
1693
+ onCollapsedChange={(collapsed) => {
1694
+ setCollapsedSectionByKey((current) => {
1695
+ const next = {
1696
+ ...current,
1697
+ [sectionCollapseKey]: collapsed,
1698
+ };
1699
+
1700
+ writeControlsPanelCollapsedSections(sectionCollapseStorageKey, next);
1701
+
1702
+ return next;
1703
+ });
1704
+ }}
1705
+ spacing={sectionSpacing}
1706
+ title={renderedSectionTitle}
1707
+ >
1708
+ {renderControlLayoutGroups({
1709
+ controlsById: visibleControls,
1710
+ layoutGroups: section.layoutGroups,
1711
+ renderedGroups: getControlRenderGroups(entries).map((group) => {
1712
+ const ids = getControlRenderGroupIds(group);
1713
+
1714
+ if (group.kind === "colorGroup") {
1715
+ return {
1716
+ ids,
1717
+ node: renderColorGroup(
1718
+ group.entries,
1719
+ headerKeyframeTarget,
1720
+ sectionHasOnlyColorFields,
1721
+ ),
1722
+ };
1723
+ }
1724
+
1725
+ const [id, rawControl] = group.entry;
1726
+ const isInInlineLayoutGroup = inlineLayoutGroupByControlId.has(id);
1727
+ const disabled = isControlDisabled(rawControl);
1728
+ const control =
1729
+ disabled === Boolean(rawControl.disabled)
1730
+ ? rawControl
1731
+ : { ...rawControl, disabled };
1732
+ const name = getControlName(id, control.label);
1733
+ const value = getControlValue(control);
1734
+ const usesHeaderKeyframeAction = control.target === headerKeyframeTarget;
1735
+ const commitWithLabel =
1736
+ (label: string) =>
1737
+ (nextValue: unknown, meta?: ControlChangeMeta): void => {
1738
+ setControlValue(control.target, nextValue, label, meta);
1739
+ maybeUpsertControlKeyframe(control, label, nextValue);
1740
+ };
1741
+ const commit = commitWithLabel(name);
1742
+ const node = (() => {
1743
+ switch (control.type) {
1744
+ case "actions": {
1745
+ const actions = asActionSchemas(control.actions);
1746
+
1747
+ return (
1748
+ <Actions
1749
+ actions={actions.map((action) => ({
1750
+ icon: action.icon,
1751
+ label: action.label,
1752
+ value: action.value,
1753
+ }))}
1754
+ key={id}
1755
+ name={name}
1756
+ onAction={(actionValue) => {
1757
+ const action = actions.find((item) => item.value === actionValue);
1758
+
1759
+ if (action) {
1760
+ runAction(action);
1761
+ }
1762
+ }}
1763
+ showLabel={control.label !== false}
1764
+ />
1765
+ );
1766
+ }
1767
+
1768
+ case "anchorGrid":
1769
+ return withKeyframeLabelAction({
1770
+ children: (
1771
+ <AnchorGrid
1772
+ key={id}
1773
+ name={name}
1774
+ onValueChange={commit}
1775
+ value={
1776
+ asString(value, "center") as React.ComponentProps<
1777
+ typeof AnchorGrid
1778
+ >["value"]
1779
+ }
1780
+ />
1781
+ ),
1782
+ control,
1783
+ disableAction: usesHeaderKeyframeAction,
1784
+ name,
1785
+ providerKey: id,
1786
+ value,
1787
+ });
1788
+
1789
+ case "channelMixer": {
1790
+ const channelMixerName = name;
1791
+
1792
+ return withKeyframeLabelAction({
1793
+ children: (
1794
+ <ChannelMixer
1795
+ key={id}
1796
+ name={channelMixerName}
1797
+ onValueChange={(nextValue) =>
1798
+ commitWithLabel(channelMixerName)(nextValue.values)
1799
+ }
1800
+ values={
1801
+ isRecord(value) ? (value as ChannelMixerValues) : defaultChannelMixerValues
1802
+ }
1803
+ />
1804
+ ),
1805
+ control,
1806
+ disableAction: false,
1807
+ labelActionName: channelMixerName,
1808
+ name,
1809
+ providerKey: id,
1810
+ value,
1811
+ });
1812
+ }
1813
+
1814
+ case "checkbox":
1815
+ return (
1816
+ <Checkbox
1817
+ checked={asBoolean(value)}
1818
+ key={id}
1819
+ name={name}
1820
+ onCheckedChange={commit}
1821
+ showLabel={control.label !== false}
1822
+ />
1823
+ );
1824
+
1825
+ case "code":
1826
+ return withKeyframeLabelAction({
1827
+ children: (
1828
+ <CodeTextarea
1829
+ defaultValue={asString(control.defaultValue, asString(value))}
1830
+ key={id}
1831
+ name={name}
1832
+ onValueChange={commit}
1833
+ value={asString(value)}
1834
+ />
1835
+ ),
1836
+ control,
1837
+ disableAction: usesHeaderKeyframeAction,
1838
+ name,
1839
+ providerKey: id,
1840
+ value,
1841
+ });
1842
+
1843
+ case "color": {
1844
+ const colorValue = asColorValue(value);
1845
+
1846
+ return withKeyframeLabelAction({
1847
+ children: (
1848
+ <Color
1849
+ hex={colorValue.hex}
1850
+ key={id}
1851
+ name={name}
1852
+ onValueChange={commit}
1853
+ showLabel={shouldShowColorFieldLabel({
1854
+ control,
1855
+ sectionHasOnlyColorFields,
1856
+ })}
1857
+ />
1858
+ ),
1859
+ control,
1860
+ disableAction: usesHeaderKeyframeAction,
1861
+ name,
1862
+ providerKey: id,
1863
+ value,
1864
+ });
1865
+ }
1866
+
1867
+ case "colorOpacity": {
1868
+ const colorOpacityValue = asColorOpacityValue(value);
1869
+
1870
+ return withKeyframeLabelAction({
1871
+ children: (
1872
+ <ColorOpacity
1873
+ hex={colorOpacityValue.hex}
1874
+ key={id}
1875
+ name={name}
1876
+ onValueChange={commit}
1877
+ opacity={colorOpacityValue.opacity}
1878
+ showLabel={shouldShowColorFieldLabel({
1879
+ control,
1880
+ sectionHasOnlyColorFields,
1881
+ })}
1882
+ />
1883
+ ),
1884
+ control,
1885
+ disableAction: usesHeaderKeyframeAction,
1886
+ name,
1887
+ providerKey: id,
1888
+ value: colorOpacityValue,
1889
+ });
1890
+ }
1891
+
1892
+ case "curves": {
1893
+ const curvesName = control.label === false ? "Curves" : name;
1894
+
1895
+ return withKeyframeLabelAction({
1896
+ children: (
1897
+ <Curves
1898
+ interpolation={asCurveInterpolation(control.interpolation)}
1899
+ key={id}
1900
+ name={curvesName}
1901
+ onValueChange={commitWithLabel(curvesName)}
1902
+ variant={control.variant === "single" ? "single" : "rgb"}
1903
+ {...(isRecord(value) ? value : {})}
1904
+ />
1905
+ ),
1906
+ control,
1907
+ disableAction: false,
1908
+ labelActionName: curvesName,
1909
+ name: curvesName,
1910
+ providerKey: id,
1911
+ value,
1912
+ });
1913
+ }
1914
+
1915
+ case "fileDrop": {
1916
+ const previewMediaAsset = state.schema.panels.layers
1917
+ ? undefined
1918
+ : state.mediaAssets[0];
1919
+
1920
+ return (
1921
+ <FileDrop
1922
+ accept={control.accept ?? "PNG, JPEG, GIF, SVG, WebP"}
1923
+ key={id}
1924
+ onClear={
1925
+ previewMediaAsset
1926
+ ? () => {
1927
+ dispatchCommand({
1928
+ mediaId: previewMediaAsset.id,
1929
+ type: "media.delete",
1930
+ });
1931
+ }
1932
+ : undefined
1933
+ }
1934
+ onFileSelect={(file) => {
1935
+ void readImportedImageFile(file, state.canvas.size).then((importedImage) => {
1936
+ if (!importedImage) {
1937
+ return;
1938
+ }
1939
+
1940
+ dispatchCommand({
1941
+ asset: {
1942
+ dataUrl: importedImage.dataUrl,
1943
+ fileName: file.name,
1944
+ mimeType: file.type || "image/*",
1945
+ position: { x: 0, y: 0 },
1946
+ size: importedImage.size,
1947
+ },
1948
+ type: "media.import",
1949
+ });
1950
+ });
1951
+ }}
1952
+ preview={
1953
+ previewMediaAsset
1954
+ ? {
1955
+ alt: previewMediaAsset.fileName,
1956
+ size: previewMediaAsset.size,
1957
+ src: previewMediaAsset.dataUrl,
1958
+ }
1959
+ : undefined
1960
+ }
1961
+ />
1962
+ );
1963
+ }
1964
+
1965
+ case "gradient": {
1966
+ const gradientValue = asGradientValue(value);
1967
+
1968
+ return withKeyframeLabelAction({
1969
+ children: (
1970
+ <Gradient
1971
+ angle={gradientValue.angle}
1972
+ gradientType={gradientValue.gradientType}
1973
+ key={id}
1974
+ onValueChange={commit}
1975
+ stops={gradientValue.stops}
1976
+ />
1977
+ ),
1978
+ control,
1979
+ disableAction: usesHeaderKeyframeAction,
1980
+ name,
1981
+ providerKey: id,
1982
+ value,
1983
+ });
1984
+ }
1985
+
1986
+ case "fontPicker": {
1987
+ const fontPickerValue = asFontPickerValue(value);
1988
+
1989
+ return withKeyframeLabelAction({
1990
+ children: (
1991
+ <FontPicker
1992
+ defaultValue={asFontPickerValue(control.defaultValue)}
1993
+ disabled={control.disabled}
1994
+ key={id}
1995
+ name={name}
1996
+ onValueChange={commit}
1997
+ value={fontPickerValue}
1998
+ />
1999
+ ),
2000
+ control,
2001
+ disableAction: usesHeaderKeyframeAction,
2002
+ name,
2003
+ providerKey: id,
2004
+ value: fontPickerValue,
2005
+ });
2006
+ }
2007
+
2008
+ case "imagePicker":
2009
+ return (
2010
+ <ImagePicker
2011
+ items={control.items as readonly ImagePickerItem[] | undefined}
2012
+ key={id}
2013
+ name={name}
2014
+ onValueChange={commit}
2015
+ value={asString(value, control.items?.[0]?.value ?? "")}
2016
+ />
2017
+ );
2018
+
2019
+ case "palette":
2020
+ return withKeyframeLabelAction({
2021
+ children: (
2022
+ <Palette
2023
+ defaultValue={
2024
+ isRecord(control.defaultValue)
2025
+ ? (control.defaultValue as React.ComponentProps<
2026
+ typeof Palette
2027
+ >["defaultValue"])
2028
+ : undefined
2029
+ }
2030
+ key={id}
2031
+ onCommit={(nextValue) => commit(nextValue)}
2032
+ value={
2033
+ isRecord(value)
2034
+ ? (value as React.ComponentProps<typeof Palette>["value"])
2035
+ : undefined
2036
+ }
2037
+ />
2038
+ ),
2039
+ control,
2040
+ disableAction: usesHeaderKeyframeAction,
2041
+ name,
2042
+ providerKey: id,
2043
+ value,
2044
+ });
2045
+
2046
+ case "panelActions": {
2047
+ const actions = asActionSchemas(control.actions);
2048
+
2049
+ return (
2050
+ <PanelActions
2051
+ actions={actions.map((action) => ({
2052
+ icon: action.icon,
2053
+ name: getActionLabel(action),
2054
+ value: action.value,
2055
+ variant: getPanelActionButtonVariant(action.variant),
2056
+ }))}
2057
+ key={id}
2058
+ onAction={(actionValue) => {
2059
+ const action = actions.find((item) => item.value === actionValue);
2060
+
2061
+ if (action) {
2062
+ runAction(action, { trackFooterPending: true });
2063
+ }
2064
+ }}
2065
+ />
2066
+ );
2067
+ }
2068
+
2069
+ case "rangeInput": {
2070
+ const rangeValue = asRangeInputValue(value);
2071
+
2072
+ return withKeyframeLabelAction({
2073
+ children: (
2074
+ <RangeInput
2075
+ defaultValue={asRangeInputValue(control.defaultValue)}
2076
+ end={rangeValue.end}
2077
+ key={id}
2078
+ name={name}
2079
+ onValueChange={commit}
2080
+ start={rangeValue.start}
2081
+ />
2082
+ ),
2083
+ control,
2084
+ disableAction: usesHeaderKeyframeAction,
2085
+ name,
2086
+ providerKey: id,
2087
+ value,
2088
+ });
2089
+ }
2090
+
2091
+ case "rangeSlider":
2092
+ return withKeyframeLabelAction({
2093
+ children: (
2094
+ <RangeSlider
2095
+ baseValue={asNumberArray(control.defaultValue, [])}
2096
+ disabled={control.disabled}
2097
+ markerCount={getControlMarkerCount(control)}
2098
+ max={control.max ?? 100}
2099
+ min={control.min ?? 0}
2100
+ key={id}
2101
+ name={name}
2102
+ onValueChange={commit}
2103
+ step={control.step ?? 0.1}
2104
+ unit={control.unit}
2105
+ value={asNumberArray(value, [control.min ?? 0, control.max ?? 100])}
2106
+ valueLabel={control.valueLabel}
2107
+ variant={
2108
+ control.variant === "discrete" ? "discrete" : "continuous"
2109
+ }
2110
+ />
2111
+ ),
2112
+ control,
2113
+ disableAction: usesHeaderKeyframeAction,
2114
+ name,
2115
+ providerKey: id,
2116
+ value,
2117
+ });
2118
+
2119
+ case "segmented":
2120
+ return withKeyframeLabelAction({
2121
+ children: (
2122
+ <Segmented
2123
+ key={id}
2124
+ name={name}
2125
+ onValueChange={commit}
2126
+ options={control.options ?? []}
2127
+ value={asString(value, control.options?.[0]?.value ?? "")}
2128
+ variant={control.variant === "dots" ? "dots" : "default"}
2129
+ />
2130
+ ),
2131
+ control,
2132
+ disableAction: usesHeaderKeyframeAction,
2133
+ name,
2134
+ providerKey: id,
2135
+ value,
2136
+ });
2137
+
2138
+ case "select":
2139
+ return (
2140
+ <Select
2141
+ key={id}
2142
+ layout={isInInlineLayoutGroup ? "stacked" : undefined}
2143
+ name={name}
2144
+ onValueChange={commit}
2145
+ options={control.options ?? []}
2146
+ value={asString(value, control.options?.[0]?.value ?? "")}
2147
+ />
2148
+ );
2149
+
2150
+ case "settingsTransfer":
2151
+ return (
2152
+ <PanelActions
2153
+ actions={[
2154
+ {
2155
+ icon: "upload-simple",
2156
+ name: "Export Settings",
2157
+ onClick: () => downloadToolcraftSettings(state),
2158
+ variant: "outline",
2159
+ },
2160
+ {
2161
+ icon: "download-simple",
2162
+ name: "Import Settings",
2163
+ onClick: () => {
2164
+ void importToolcraftSettings({ dispatch, state });
2165
+ },
2166
+ variant: "outline",
2167
+ },
2168
+ ]}
2169
+ key={id}
2170
+ columns={2}
2171
+ />
2172
+ );
2173
+
2174
+ case "slider":
2175
+ return withKeyframeLabelAction({
2176
+ children: (
2177
+ <Slider
2178
+ baseValue={asNumber(control.defaultValue, control.min ?? 0)}
2179
+ disabled={control.disabled}
2180
+ key={id}
2181
+ markerCount={getControlMarkerCount(control)}
2182
+ max={control.max ?? 100}
2183
+ min={control.min ?? 0}
2184
+ name={name}
2185
+ onValueChange={commit}
2186
+ step={control.step ?? 1}
2187
+ unit={control.unit}
2188
+ value={asNumber(
2189
+ value,
2190
+ asNumber(control.defaultValue, control.min ?? 0),
2191
+ )}
2192
+ valueLabel={control.valueLabel}
2193
+ variant={
2194
+ control.variant === "discrete" ? "discrete" : "continuous"
2195
+ }
2196
+ />
2197
+ ),
2198
+ control,
2199
+ disableAction: usesHeaderKeyframeAction,
2200
+ name,
2201
+ providerKey: id,
2202
+ value,
2203
+ });
2204
+
2205
+ case "switch":
2206
+ return (
2207
+ <Switch
2208
+ checked={asBoolean(value)}
2209
+ key={id}
2210
+ name={name}
2211
+ onCheckedChange={commit}
2212
+ showLabel={control.label !== false}
2213
+ />
2214
+ );
2215
+
2216
+ case "text":
2217
+ return withKeyframeLabelAction({
2218
+ children: (
2219
+ <TextInput
2220
+ commitOnBlur={shouldCommitTextControlOnBlur(control)}
2221
+ defaultValue={asString(control.defaultValue, asString(value))}
2222
+ key={id}
2223
+ name={name}
2224
+ onValueChange={commit}
2225
+ value={asString(value)}
2226
+ />
2227
+ ),
2228
+ control,
2229
+ disableAction: usesHeaderKeyframeAction,
2230
+ name,
2231
+ providerKey: id,
2232
+ value,
2233
+ });
2234
+
2235
+ case "vector": {
2236
+ const vectorValue = asVectorValue(value);
2237
+
2238
+ return withKeyframeLabelAction({
2239
+ children: (
2240
+ <Vector
2241
+ defaultValue={asVectorValue(control.defaultValue)}
2242
+ key={id}
2243
+ name={name}
2244
+ onValueChange={commit}
2245
+ padShape={vectorPadShape}
2246
+ padVariant={asVectorPadVariant(control.variant)}
2247
+ x={vectorValue.x}
2248
+ xLabel={control.xLabel}
2249
+ y={vectorValue.y}
2250
+ yLabel={control.yLabel}
2251
+ />
2252
+ ),
2253
+ control,
2254
+ disableAction: usesHeaderKeyframeAction,
2255
+ name,
2256
+ providerKey: id,
2257
+ value,
2258
+ });
2259
+ }
2260
+
2261
+ default: {
2262
+ const CustomControl = controlRenderers?.[control.type];
2263
+
2264
+ if (!CustomControl) {
2265
+ return null;
2266
+ }
2267
+
2268
+ return withKeyframeLabelAction({
2269
+ children: (
2270
+ <React.Fragment key={id}>
2271
+ {CustomControl({
2272
+ control,
2273
+ controlId: id,
2274
+ dispatch,
2275
+ keyframeAction: getKeyframeLabelAction(control, name, value),
2276
+ name,
2277
+ setValue: commit,
2278
+ state,
2279
+ value,
2280
+ })}
2281
+ </React.Fragment>
2282
+ ),
2283
+ control,
2284
+ disableAction: usesHeaderKeyframeAction,
2285
+ name,
2286
+ providerKey: id,
2287
+ value,
2288
+ });
2289
+ }
2290
+ }
2291
+ })();
2292
+
2293
+ return {
2294
+ ids,
2295
+ node: withCompoundControlSectionDivider({
2296
+ children: withControlLabelHelp({
2297
+ children: node,
2298
+ control,
2299
+ label: name,
2300
+ providerKey: id,
2301
+ }),
2302
+ control,
2303
+ }),
2304
+ };
2305
+ }),
2306
+ })}
2307
+ </PanelSection>
2308
+ );
2309
+ })}
2310
+ </Panel>
2311
+ );
2312
+
2313
+ if (placement === "surface") {
2314
+ return panel;
2315
+ }
2316
+
2317
+ return (
2318
+ <PanelContainer
2319
+ onPanelStateChange={onPanelStateChange}
2320
+ panelState={panelState}
2321
+ panelType="controls"
2322
+ placement={placement}
2323
+ >
2324
+ {panel}
2325
+ </PanelContainer>
2326
+ );
2327
+ }