@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,400 @@
1
+ import { MinusIcon, PlusIcon } from "@phosphor-icons/react";
2
+ import * as React from "react";
3
+ import {
4
+ Button,
5
+ Field,
6
+ InputGroup,
7
+ InputGroupAddon,
8
+ InputGroupInput,
9
+ InputGroupText,
10
+ } from "../../primitives";
11
+ import { selectedItemSurfaceClassName } from "../../primitives/selection-state";
12
+ import { cn } from "../../../lib/utils";
13
+
14
+ import {
15
+ formatStopPosition,
16
+ maxGradientStops,
17
+ minGradientStops,
18
+ normalizeStopOpacity,
19
+ parseStopOpacity,
20
+ parseStopPosition,
21
+ type IndexedGradientStop,
22
+ } from "./gradient-control-utils";
23
+ import { ColorValueControl } from "../color";
24
+ import { ControlFieldLabel } from "../../control-layout";
25
+
26
+ function GradientStopsHeader({
27
+ canAdd,
28
+ onAdd,
29
+ }: {
30
+ canAdd: boolean;
31
+ onAdd: () => void;
32
+ }): React.JSX.Element {
33
+ return (
34
+ <div className="flex items-center justify-between gap-2">
35
+ <ControlFieldLabel>Stops</ControlFieldLabel>
36
+ <Button
37
+ aria-label="Add gradient stop"
38
+ disabled={!canAdd}
39
+ onClick={onAdd}
40
+ size="icon-sm"
41
+ type="button"
42
+ variant="ghost"
43
+ >
44
+ <PlusIcon />
45
+ </Button>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ function GradientStopColorControls({
51
+ onColorChange,
52
+ onOpacityChange,
53
+ stop,
54
+ stopLabel,
55
+ stopName,
56
+ }: {
57
+ onColorChange: (nextColor: string) => void;
58
+ onOpacityChange: (nextOpacity: number) => void;
59
+ stop: IndexedGradientStop;
60
+ stopLabel: string;
61
+ stopName: string;
62
+ }): React.JSX.Element {
63
+ return (
64
+ <ColorValueControl
65
+ className="min-w-0"
66
+ color={stop.color}
67
+ inputName={`${stopName}-color`}
68
+ label={stopLabel}
69
+ nativeInputName={`${stopName}-native-color`}
70
+ onColorChange={onColorChange}
71
+ >
72
+ <GradientStopOpacityInput
73
+ onOpacityChange={onOpacityChange}
74
+ stop={stop}
75
+ stopLabel={stopLabel}
76
+ stopName={stopName}
77
+ />
78
+ </ColorValueControl>
79
+ );
80
+ }
81
+
82
+ function GradientStopOpacityInput({
83
+ onOpacityChange,
84
+ stop,
85
+ stopLabel,
86
+ stopName,
87
+ }: {
88
+ onOpacityChange: (nextOpacity: number) => void;
89
+ stop: IndexedGradientStop;
90
+ stopLabel: string;
91
+ stopName: string;
92
+ }): React.JSX.Element {
93
+ const committedOpacity = String(parseStopOpacity(stop.opacity));
94
+ const [draftOpacity, setDraftOpacity] = React.useState(committedOpacity);
95
+
96
+ React.useEffect(() => {
97
+ setDraftOpacity(committedOpacity);
98
+ }, [committedOpacity]);
99
+
100
+ function commitOpacity(nextDraft = draftOpacity): void {
101
+ const trimmedDraft = nextDraft.trim();
102
+
103
+ if (trimmedDraft === "" || !Number.isFinite(Number.parseFloat(trimmedDraft))) {
104
+ setDraftOpacity(committedOpacity);
105
+ return;
106
+ }
107
+
108
+ const nextOpacity = normalizeStopOpacity(trimmedDraft);
109
+
110
+ setDraftOpacity(String(nextOpacity));
111
+
112
+ if (nextOpacity !== parseStopOpacity(stop.opacity)) {
113
+ onOpacityChange(nextOpacity);
114
+ }
115
+ }
116
+
117
+ return (
118
+ <InputGroup className="w-14 flex-none rounded-l-none [&:not(:focus-within):hover]:!border-[color:color-mix(in_oklab,var(--border)_20%,transparent)] [&:not(:focus-within):hover]:text-[color:var(--foreground)]">
119
+ <InputGroupInput
120
+ aria-label={`${stopLabel} opacity`}
121
+ autoComplete="off"
122
+ className="pl-[5px] pr-1 text-right font-mono"
123
+ inputMode="numeric"
124
+ name={`${stopName}-opacity`}
125
+ onBlur={() => commitOpacity()}
126
+ onChange={(event) => setDraftOpacity(event.target.value)}
127
+ onKeyDown={(event) => {
128
+ if (event.key === "Enter") {
129
+ event.preventDefault();
130
+ commitOpacity(event.currentTarget.value);
131
+ event.currentTarget.blur();
132
+ return;
133
+ }
134
+
135
+ if (event.key === "Escape") {
136
+ event.preventDefault();
137
+ setDraftOpacity(committedOpacity);
138
+ event.currentTarget.blur();
139
+ }
140
+ }}
141
+ type="text"
142
+ value={draftOpacity}
143
+ />
144
+ <InputGroupAddon align="inline-end" className="pr-1.5 pl-0">
145
+ <InputGroupText className="group-hover/input-group:text-[color:var(--foreground)]">
146
+ %
147
+ </InputGroupText>
148
+ </InputGroupAddon>
149
+ </InputGroup>
150
+ );
151
+ }
152
+
153
+ function getStopPositionInputValue(position: string): string {
154
+ return String(Math.round(parseStopPosition(position) * 100));
155
+ }
156
+
157
+ function getNormalizedStopPositionFromInputValue(value: string): string | null {
158
+ const parsedValue = Number.parseFloat(value);
159
+
160
+ if (!Number.isFinite(parsedValue)) {
161
+ return null;
162
+ }
163
+
164
+ return formatStopPosition(parsedValue / 100);
165
+ }
166
+
167
+ function GradientStopPositionInput({
168
+ onPositionChange,
169
+ stop,
170
+ stopLabel,
171
+ stopName,
172
+ }: {
173
+ onPositionChange: (nextPosition: string) => void;
174
+ stop: IndexedGradientStop;
175
+ stopLabel: string;
176
+ stopName: string;
177
+ }): React.JSX.Element {
178
+ const committedPosition = formatStopPosition(parseStopPosition(stop.position));
179
+ const committedInputValue = getStopPositionInputValue(stop.position);
180
+ const [draftValue, setDraftValue] = React.useState(committedInputValue);
181
+ const skipNextBlurCommitRef = React.useRef(false);
182
+
183
+ React.useEffect(() => {
184
+ setDraftValue(committedInputValue);
185
+ }, [committedInputValue]);
186
+
187
+ function commitPosition(nextValue = draftValue): void {
188
+ const nextPosition = getNormalizedStopPositionFromInputValue(nextValue);
189
+
190
+ if (nextPosition === null) {
191
+ setDraftValue(committedInputValue);
192
+ return;
193
+ }
194
+
195
+ setDraftValue(getStopPositionInputValue(nextPosition));
196
+
197
+ if (nextPosition !== committedPosition) {
198
+ onPositionChange(nextPosition);
199
+ }
200
+ }
201
+
202
+ return (
203
+ <InputGroup className="min-w-0">
204
+ <InputGroupInput
205
+ aria-label={`${stopLabel} position`}
206
+ autoComplete="off"
207
+ className="pl-[5px] pr-1 text-left font-mono"
208
+ inputMode="numeric"
209
+ name={`${stopName}-position`}
210
+ onBlur={() => {
211
+ if (skipNextBlurCommitRef.current) {
212
+ skipNextBlurCommitRef.current = false;
213
+ return;
214
+ }
215
+
216
+ commitPosition();
217
+ }}
218
+ onChange={(event) => setDraftValue(event.target.value)}
219
+ onKeyDown={(event) => {
220
+ if (event.key === "Enter") {
221
+ event.preventDefault();
222
+ commitPosition(event.currentTarget.value);
223
+ skipNextBlurCommitRef.current = true;
224
+ event.currentTarget.blur();
225
+ }
226
+
227
+ if (event.key === "Escape") {
228
+ event.preventDefault();
229
+ setDraftValue(committedInputValue);
230
+ skipNextBlurCommitRef.current = true;
231
+ event.currentTarget.blur();
232
+ }
233
+ }}
234
+ type="text"
235
+ value={draftValue}
236
+ />
237
+ <InputGroupAddon align="inline-end" className="pr-1.5 pl-0">
238
+ <InputGroupText className="group-hover/input-group:text-[color:var(--foreground)]">
239
+ %
240
+ </InputGroupText>
241
+ </InputGroupAddon>
242
+ </InputGroup>
243
+ );
244
+ }
245
+
246
+ function GradientStopRowSelectionSurface({
247
+ isSelected,
248
+ }: {
249
+ isSelected: boolean;
250
+ }): React.JSX.Element {
251
+ return (
252
+ <div
253
+ aria-hidden="true"
254
+ className={cn(
255
+ "pointer-events-none absolute -inset-[5px] rounded-[7px] opacity-0 data-[selected=true]:opacity-100",
256
+ selectedItemSurfaceClassName,
257
+ )}
258
+ data-selected={isSelected}
259
+ data-slot="gradient-stop-row-surface"
260
+ />
261
+ );
262
+ }
263
+
264
+ function GradientStopRow({
265
+ canRemove,
266
+ isSelected,
267
+ onColorChange,
268
+ onOpacityChange,
269
+ onPositionChange,
270
+ onRemove,
271
+ onSelect,
272
+ stop,
273
+ }: {
274
+ canRemove: boolean;
275
+ isSelected: boolean;
276
+ onColorChange: (nextColor: string) => void;
277
+ onOpacityChange: (nextOpacity: number) => void;
278
+ onPositionChange: (nextPosition: string) => void;
279
+ onRemove: () => void;
280
+ onSelect: () => void;
281
+ stop: IndexedGradientStop;
282
+ }): React.JSX.Element {
283
+ const stopLabel = `Stop ${stop.originalIndex + 1}`;
284
+ const stopName = `gradient-stop-${stop.originalIndex + 1}`;
285
+
286
+ function selectAndChangeColor(nextColor: string): void {
287
+ onSelect();
288
+ onColorChange(nextColor);
289
+ }
290
+
291
+ function selectAndChangeOpacity(nextOpacity: number): void {
292
+ onSelect();
293
+ onOpacityChange(nextOpacity);
294
+ }
295
+
296
+ function selectAndChangePosition(nextPosition: string): void {
297
+ onSelect();
298
+ onPositionChange(nextPosition);
299
+ }
300
+
301
+ return (
302
+ <Field
303
+ className="min-w-0 rounded-none p-0"
304
+ data-selected={isSelected}
305
+ onFocusCapture={onSelect}
306
+ onPointerDownCapture={onSelect}
307
+ orientation="horizontal"
308
+ >
309
+ <div className="min-w-0 flex-1 py-1" data-slot="gradient-stop-row-gutter">
310
+ <div className="relative min-w-0" data-slot="gradient-stop-row-content">
311
+ <GradientStopRowSelectionSurface isSelected={isSelected} />
312
+ <div
313
+ className="relative grid min-w-0 grid-cols-[3.5rem_minmax(0,1fr)_1.5rem] items-center gap-[6px]"
314
+ data-slot="gradient-stop-row-grid"
315
+ >
316
+ <GradientStopPositionInput
317
+ onPositionChange={selectAndChangePosition}
318
+ stop={stop}
319
+ stopLabel={stopLabel}
320
+ stopName={stopName}
321
+ />
322
+ <GradientStopColorControls
323
+ onColorChange={selectAndChangeColor}
324
+ onOpacityChange={selectAndChangeOpacity}
325
+ stop={stop}
326
+ stopLabel={stopLabel}
327
+ stopName={stopName}
328
+ />
329
+ <Button
330
+ aria-label={`Remove ${stopLabel.toLowerCase()}`}
331
+ disabled={!canRemove}
332
+ onClick={onRemove}
333
+ size="icon-sm"
334
+ type="button"
335
+ variant="ghost"
336
+ >
337
+ <MinusIcon />
338
+ </Button>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ </Field>
343
+ );
344
+ }
345
+
346
+ export function GradientStopsList({
347
+ onAdd,
348
+ onColorChange,
349
+ onOpacityChange,
350
+ onPositionChange,
351
+ onRemove,
352
+ onSelect,
353
+ selectedIndex,
354
+ stops,
355
+ }: {
356
+ onAdd: () => void;
357
+ onColorChange: (index: number, nextColor: string) => void;
358
+ onOpacityChange: (index: number, nextOpacity: number) => void;
359
+ onPositionChange: (index: number, nextPosition: string) => void;
360
+ onRemove: (index: number) => void;
361
+ onSelect: (index: number) => void;
362
+ selectedIndex: number | null;
363
+ stops: readonly IndexedGradientStop[];
364
+ }): React.JSX.Element {
365
+ const canAdd = stops.length < maxGradientStops;
366
+ const canRemove = stops.length > minGradientStops;
367
+
368
+ return (
369
+ <div
370
+ className="flex min-w-0 flex-col gap-1"
371
+ data-slot="gradient-stops-list"
372
+ >
373
+ <GradientStopsHeader canAdd={canAdd} onAdd={onAdd} />
374
+ <div
375
+ className="flex min-w-0 flex-col gap-[2px]"
376
+ data-slot="gradient-stops-list-rows"
377
+ >
378
+ {stops.map((stop) => (
379
+ <GradientStopRow
380
+ canRemove={canRemove}
381
+ isSelected={selectedIndex === stop.originalIndex}
382
+ key={stop.originalIndex}
383
+ onColorChange={(nextColor) =>
384
+ onColorChange(stop.originalIndex, nextColor)
385
+ }
386
+ onOpacityChange={(nextOpacity) =>
387
+ onOpacityChange(stop.originalIndex, nextOpacity)
388
+ }
389
+ onPositionChange={(nextPosition) =>
390
+ onPositionChange(stop.originalIndex, nextPosition)
391
+ }
392
+ onRemove={() => onRemove(stop.originalIndex)}
393
+ onSelect={() => onSelect(stop.originalIndex)}
394
+ stop={stop}
395
+ />
396
+ ))}
397
+ </div>
398
+ </div>
399
+ );
400
+ }
@@ -0,0 +1,152 @@
1
+ import {
2
+ InputGroup,
3
+ InputGroupAddon,
4
+ InputGroupInput,
5
+ InputGroupText,
6
+ Field,
7
+ } from "../../primitives";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectGroup,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "../../primitives";
16
+ import { useRef, useState } from "react";
17
+
18
+ import type { GradientType } from "../control-types";
19
+ import { ControlFieldLabel } from "../../control-layout";
20
+ import {
21
+ gradientTypeOptions,
22
+ normalizeGradientAngle,
23
+ } from "./gradient-control-utils";
24
+
25
+ function GradientTypeSelect({
26
+ onTypeChange,
27
+ type,
28
+ }: {
29
+ onTypeChange: (nextType: GradientType) => void;
30
+ type: GradientType;
31
+ }): React.JSX.Element {
32
+ const selectedOption =
33
+ gradientTypeOptions.find((option) => option.value === type) ??
34
+ gradientTypeOptions[0];
35
+
36
+ return (
37
+ <Select
38
+ items={gradientTypeOptions}
39
+ onValueChange={(nextValue) => onTypeChange(nextValue as GradientType)}
40
+ value={type}
41
+ >
42
+ <SelectTrigger className="w-full justify-between rounded-lg">
43
+ <SelectValue>{() => selectedOption.label}</SelectValue>
44
+ </SelectTrigger>
45
+ <SelectContent align="start" alignItemWithTrigger={false}>
46
+ <SelectGroup>
47
+ {gradientTypeOptions.map((option) => (
48
+ <SelectItem key={option.value} value={option.value}>
49
+ {option.label}
50
+ </SelectItem>
51
+ ))}
52
+ </SelectGroup>
53
+ </SelectContent>
54
+ </Select>
55
+ );
56
+ }
57
+
58
+ export function GradientToolbar({
59
+ angle,
60
+ name,
61
+ onAngleChange,
62
+ onTypeChange,
63
+ type,
64
+ }: {
65
+ angle: number;
66
+ name: string;
67
+ onAngleChange: (nextAngle: number) => void;
68
+ onTypeChange: (nextType: GradientType) => void;
69
+ type: GradientType;
70
+ }): React.JSX.Element {
71
+ const [angleDraft, setAngleDraft] = useState<string | null>(null);
72
+ const angleBeforeEditRef = useRef(angle);
73
+ const displayedAngle = angleDraft ?? String(angle);
74
+
75
+ function handleAngleFocus(): void {
76
+ angleBeforeEditRef.current = angle;
77
+ setAngleDraft(String(angle));
78
+ }
79
+
80
+ function handleAngleChange(event: React.ChangeEvent<HTMLInputElement>): void {
81
+ setAngleDraft(event.target.value);
82
+ }
83
+
84
+ function handleAngleBlur(): void {
85
+ const nextDraft = angleDraft?.trim() ?? "";
86
+
87
+ if (nextDraft === "" || !Number.isFinite(Number.parseFloat(nextDraft))) {
88
+ onAngleChange(angleBeforeEditRef.current);
89
+ setAngleDraft(null);
90
+ return;
91
+ }
92
+
93
+ const nextAngle = normalizeGradientAngle(nextDraft);
94
+
95
+ if (nextAngle !== angle) {
96
+ onAngleChange(nextAngle);
97
+ }
98
+
99
+ setAngleDraft(null);
100
+ }
101
+
102
+ return (
103
+ <div
104
+ className="grid min-w-0 gap-x-2.5 gap-y-3"
105
+ data-slot="gradient-toolbar-grid"
106
+ style={{ gridTemplateColumns: "repeat(2, minmax(0, 1fr))" }}
107
+ >
108
+ <Field className="h-fit min-w-0 gap-2" orientation="vertical">
109
+ <ControlFieldLabel>{name}</ControlFieldLabel>
110
+ <div className="min-w-0 w-full">
111
+ <GradientTypeSelect onTypeChange={onTypeChange} type={type} />
112
+ </div>
113
+ </Field>
114
+ <Field className="h-fit min-w-0 gap-2" orientation="vertical">
115
+ <ControlFieldLabel>Angle</ControlFieldLabel>
116
+ <div className="min-w-0 w-full">
117
+ <InputGroup>
118
+ <InputGroupInput
119
+ aria-label="Gradient angle"
120
+ autoComplete="off"
121
+ className="text-left font-mono"
122
+ inputMode="numeric"
123
+ name="gradient-angle"
124
+ onBlur={handleAngleBlur}
125
+ onChange={handleAngleChange}
126
+ onFocus={handleAngleFocus}
127
+ onKeyDown={(event) => {
128
+ if (event.key === "Enter") {
129
+ event.preventDefault();
130
+ handleAngleBlur();
131
+ event.currentTarget.blur();
132
+ return;
133
+ }
134
+
135
+ if (event.key === "Escape") {
136
+ event.preventDefault();
137
+ setAngleDraft(String(angle));
138
+ event.currentTarget.blur();
139
+ }
140
+ }}
141
+ type="text"
142
+ value={displayedAngle}
143
+ />
144
+ <InputGroupAddon align="inline-end" className="pr-1.5 pl-0">
145
+ <InputGroupText>°</InputGroupText>
146
+ </InputGroupAddon>
147
+ </InputGroup>
148
+ </div>
149
+ </Field>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,4 @@
1
+ "use client";
2
+
3
+ export { GradientControl } from "./gradient-control";
4
+ export type { GradientControlProps } from "./gradient-control";
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { ControlFieldLabel } from "../../control-layout";
6
+ import { Field } from "../../primitives";
7
+ import { cn } from "../../../lib/utils";
8
+
9
+ type ImagePickerColumns = 2 | 3 | 4;
10
+
11
+ export type ImagePickerItem = {
12
+ alt?: string;
13
+ src: string;
14
+ value: string;
15
+ };
16
+
17
+ export type ImagePickerControlProps = {
18
+ items?: readonly ImagePickerItem[];
19
+ name?: string;
20
+ value?: string;
21
+ onValueChange?: (value: string) => void;
22
+ };
23
+
24
+ const defaultImageSrc =
25
+ 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220"%3E%3C/svg%3E';
26
+
27
+ const defaultImagePickerItems = [
28
+ { alt: "Image 1", src: defaultImageSrc, value: "image-1" },
29
+ { alt: "Image 2", src: defaultImageSrc, value: "image-2" },
30
+ { alt: "Image 3", src: defaultImageSrc, value: "image-3" },
31
+ { alt: "Image 4", src: defaultImageSrc, value: "image-4" },
32
+ { alt: "Image 5", src: defaultImageSrc, value: "image-5" },
33
+ { alt: "Image 6", src: defaultImageSrc, value: "image-6" },
34
+ { alt: "Image 7", src: defaultImageSrc, value: "image-7" },
35
+ { alt: "Image 8", src: defaultImageSrc, value: "image-8" },
36
+ ] satisfies readonly ImagePickerItem[];
37
+
38
+ const columnClassNames: Record<ImagePickerColumns, string> = {
39
+ 2: "grid-cols-2",
40
+ 3: "grid-cols-3",
41
+ 4: "grid-cols-4",
42
+ };
43
+
44
+ function getInitialValue(
45
+ items: readonly ImagePickerItem[],
46
+ value: string | undefined,
47
+ ): string {
48
+ return value ?? items[0]?.value ?? "";
49
+ }
50
+
51
+ function getImagePickerColumns(itemCount: number): ImagePickerColumns {
52
+ if (itemCount === 2) {
53
+ return 2;
54
+ }
55
+
56
+ if (itemCount === 3 || itemCount === 6) {
57
+ return 3;
58
+ }
59
+
60
+ return 4;
61
+ }
62
+
63
+ export function ImagePickerControl({
64
+ items = defaultImagePickerItems,
65
+ name = "Image",
66
+ value,
67
+ onValueChange,
68
+ }: ImagePickerControlProps): React.JSX.Element {
69
+ const [currentValue, setCurrentValue] = React.useState(() =>
70
+ getInitialValue(items, value),
71
+ );
72
+
73
+ React.useEffect(() => {
74
+ if (value !== undefined) {
75
+ setCurrentValue(value);
76
+ return;
77
+ }
78
+
79
+ setCurrentValue((previousValue) =>
80
+ items.some((item) => item.value === previousValue)
81
+ ? previousValue
82
+ : (items[0]?.value ?? ""),
83
+ );
84
+ }, [items, value]);
85
+
86
+ function updateValue(nextValue: string): void {
87
+ setCurrentValue(nextValue);
88
+ onValueChange?.(nextValue);
89
+ }
90
+
91
+ const columns = getImagePickerColumns(items.length);
92
+
93
+ return (
94
+ <Field className="min-w-0 !gap-[10px]">
95
+ <ControlFieldLabel>{name}</ControlFieldLabel>
96
+ <div
97
+ className={cn(
98
+ "grid min-w-0 gap-[10px] overflow-visible",
99
+ columnClassNames[columns],
100
+ )}
101
+ >
102
+ {items.map((item) => {
103
+ const isSelected = item.value === currentValue;
104
+
105
+ return (
106
+ <button
107
+ aria-label={item.alt ?? item.value}
108
+ aria-pressed={isSelected}
109
+ className={cn(
110
+ "group/image-picker-item relative min-w-0 cursor-pointer rounded-lg border border-[color:color-mix(in_oklab,var(--border)_10%,transparent)] bg-[color:var(--muted)] p-0 outline-none transition-[border-color,background-color,box-shadow] duration-150 ease-out",
111
+ "hover:border-[color:color-mix(in_oklab,var(--border)_22%,transparent)] hover:bg-[color:var(--muted)]",
112
+ "focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_oklab,var(--ring)_30%,transparent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--background)]",
113
+ "data-[selected=true]:outline data-[selected=true]:outline-[1px] data-[selected=true]:outline-offset-2 data-[selected=true]:outline-[color:var(--border)] data-[selected=true]:[outline-style:solid]",
114
+ )}
115
+ data-selected={isSelected}
116
+ key={item.value}
117
+ onClick={() => updateValue(item.value)}
118
+ type="button"
119
+ >
120
+ <span
121
+ className={cn(
122
+ "relative block w-full overflow-hidden rounded-[inherit] bg-[color:var(--muted)]",
123
+ "aspect-[4/3]",
124
+ )}
125
+ >
126
+ <img
127
+ alt=""
128
+ className="h-full w-full object-cover"
129
+ draggable={false}
130
+ src={item.src}
131
+ />
132
+ </span>
133
+ </button>
134
+ );
135
+ })}
136
+ </div>
137
+ </Field>
138
+ );
139
+ }
@@ -0,0 +1,7 @@
1
+ "use client";
2
+
3
+ export { ImagePickerControl } from "./image-picker-control";
4
+ export type {
5
+ ImagePickerControlProps,
6
+ ImagePickerItem,
7
+ } from "./image-picker-control";