@pixel-point/toolcraft 0.0.7 → 0.0.9

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 (62) hide show
  1. package/package.json +1 -1
  2. package/src/generate.mjs +34 -5
  3. package/src/generate.test.mjs +12 -0
  4. package/src/package-json.mjs +15 -0
  5. package/src/package-json.test.mjs +14 -1
  6. package/templates/runtime/contracts/component-contracts.test.ts +185 -23
  7. package/templates/runtime/contracts/component-contracts.ts +91 -36
  8. package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
  9. package/templates/runtime/contracts/decision-contracts.ts +4 -4
  10. package/templates/runtime/export/export.test.ts +31 -0
  11. package/templates/runtime/export/export.ts +41 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +77 -1
  13. package/templates/runtime/react/canvas-shell.tsx +178 -23
  14. package/templates/runtime/react/control-conditions.ts +166 -0
  15. package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
  16. package/templates/runtime/react/controls-panel.test.tsx +774 -17
  17. package/templates/runtime/react/controls-panel.tsx +155 -8
  18. package/templates/runtime/react/media-file.ts +19 -0
  19. package/templates/runtime/schema/define-toolcraft.test.ts +46 -1
  20. package/templates/runtime/schema/define-toolcraft.ts +29 -3
  21. package/templates/runtime/schema/types.ts +7 -0
  22. package/templates/runtime/state/reducer.test.ts +304 -0
  23. package/templates/runtime/state/reducer.ts +148 -9
  24. package/templates/runtime/state/types.ts +10 -2
  25. package/templates/runtime/testing/performance.test.ts +1424 -56
  26. package/templates/runtime/testing/performance.ts +710 -43
  27. package/templates/starter/AGENTS.md +10 -9
  28. package/templates/starter/docs/toolcraft/README.md +1 -1
  29. package/templates/starter/docs/toolcraft/acceptance-testing.md +16 -8
  30. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -7
  31. package/templates/starter/docs/toolcraft/component-rules.md +46 -16
  32. package/templates/starter/docs/toolcraft/custom-controls.md +8 -4
  33. package/templates/starter/docs/toolcraft/performance.md +54 -6
  34. package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
  35. package/templates/starter/docs/toolcraft/schema-reference.md +48 -19
  36. package/templates/starter/docs/toolcraft/workflow.md +2 -2
  37. package/templates/starter/e2e/app-performance.spec.ts +136 -3
  38. package/templates/starter/e2e/performance-helpers.ts +197 -0
  39. package/templates/starter/gitignore +1 -0
  40. package/templates/starter/package.json +5 -0
  41. package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
  42. package/templates/starter/scripts/toolcraft-port.mjs +102 -0
  43. package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
  44. package/templates/starter/src/app/starter-acceptance.test.ts +1288 -105
  45. package/templates/starter/src/app/starter-acceptance.ts +740 -24
  46. package/templates/starter/src/app/starter-performance.test.ts +66 -5
  47. package/templates/ui/components/control-layout/index.tsx +8 -3
  48. package/templates/ui/components/controls/actions/actions-control.tsx +10 -4
  49. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  50. package/templates/ui/components/controls/color/index.ts +4 -1
  51. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  52. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  53. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
  54. package/templates/ui/components/controls/file-drop/index.ts +1 -1
  55. package/templates/ui/components/controls/index.ts +3 -0
  56. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  57. package/templates/ui/components/controls/select/select-control.tsx +9 -4
  58. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  59. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  60. package/templates/ui/components/controls/vector/index.ts +1 -0
  61. package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
  62. package/templates/ui/components/panel/panel-section.tsx +82 -6
@@ -17,6 +17,14 @@ export type VectorControlValue = {
17
17
  y: string;
18
18
  };
19
19
 
20
+ type VectorPadAxisLock = "x" | "y";
21
+
22
+ type VectorPadDragStart = {
23
+ clientX: number;
24
+ clientY: number;
25
+ value: VectorControlValue;
26
+ };
27
+
20
28
  export type VectorPadVariant =
21
29
  | "default"
22
30
  | "whiteBalance"
@@ -24,10 +32,13 @@ export type VectorPadVariant =
24
32
  | "chromaOffset"
25
33
  | "toneBias";
26
34
 
35
+ export type VectorPadCoordinateMode = "cartesian" | "screen";
36
+
27
37
  export type VectorControlProps = VectorControlValue & {
28
38
  defaultValue?: Partial<VectorControlValue>;
29
39
  name: string;
30
40
  onValueChange?: ControlValueChangeHandler<VectorControlValue>;
41
+ padCoordinateMode?: VectorPadCoordinateMode;
31
42
  padShape?: "compact" | "square";
32
43
  padVariant?: VectorPadVariant;
33
44
  xLabel?: string;
@@ -38,10 +49,20 @@ function clamp(value: number, min = 0, max = 1): number {
38
49
  return Math.min(max, Math.max(min, value));
39
50
  }
40
51
 
41
- function pointFromEvent(event: React.PointerEvent<HTMLElement>): CurvePoint {
52
+ function getDefaultPadCoordinateMode(
53
+ padVariant: VectorPadVariant,
54
+ ): VectorPadCoordinateMode {
55
+ return padVariant === "default" ? "screen" : "cartesian";
56
+ }
57
+
58
+ function pointFromEvent(
59
+ event: React.PointerEvent<HTMLElement>,
60
+ coordinateMode: VectorPadCoordinateMode,
61
+ ): CurvePoint {
42
62
  const rect = event.currentTarget.getBoundingClientRect();
43
63
  const x = clamp((event.clientX - rect.left) / rect.width);
44
- const y = clamp(1 - (event.clientY - rect.top) / rect.height);
64
+ const yRatio = clamp((event.clientY - rect.top) / rect.height);
65
+ const y = coordinateMode === "screen" ? yRatio : 1 - yRatio;
45
66
 
46
67
  return { x, y };
47
68
  }
@@ -54,6 +75,8 @@ type VectorPadStyle = React.CSSProperties & {
54
75
  "--xy-pad-y": string;
55
76
  };
56
77
 
78
+ const vectorPadAxisLockThresholdPx = 2;
79
+
57
80
  const whiteBalancePadBackgroundImage =
58
81
  "linear-gradient(90deg,color-mix(in oklab, #2f7cff 72%, transparent),color-mix(in oklab, var(--foreground) 8%, transparent) 50%,color-mix(in oklab, #ff9a22 78%, transparent)),linear-gradient(180deg,color-mix(in oklab, #d84f9a 68%, transparent),transparent 49%,color-mix(in oklab, #42ba62 72%, transparent)),radial-gradient(circle at 50% 50%,color-mix(in oklab, var(--foreground) 7%, transparent),color-mix(in oklab, var(--background) 14%, transparent) 62%)";
59
82
 
@@ -73,13 +96,20 @@ const vectorPadBackgroundImages: Partial<Record<VectorPadVariant, string>> = {
73
96
  whiteBalance: whiteBalancePadBackgroundImage,
74
97
  };
75
98
 
76
- function getVectorPoint(x: string, y: string): VectorPadStyle {
99
+ function getVectorPoint(
100
+ x: string,
101
+ y: string,
102
+ coordinateMode: VectorPadCoordinateMode,
103
+ ): VectorPadStyle {
77
104
  const parsedX = Number.parseFloat(x);
78
105
  const parsedY = Number.parseFloat(y);
79
106
  const clampedX = Number.isFinite(parsedX) ? clamp(parsedX, -1, 1) : 0;
80
107
  const clampedY = Number.isFinite(parsedY) ? clamp(parsedY, -1, 1) : 0;
81
108
  const xPosition = `${(clampedX + 1) * 50}%`;
82
- const yPosition = `${(1 - (clampedY + 1) / 2) * 100}%`;
109
+ const yPosition =
110
+ coordinateMode === "screen"
111
+ ? `${((clampedY + 1) / 2) * 100}%`
112
+ : `${(1 - (clampedY + 1) / 2) * 100}%`;
83
113
 
84
114
  return {
85
115
  "--xy-pad-display-x":
@@ -279,6 +309,7 @@ function VectorPadHandle({ isDragging }: { isDragging: boolean }): React.JSX.Ele
279
309
  function VectorPadField({
280
310
  name,
281
311
  onValueChange,
312
+ padCoordinateMode,
282
313
  padShape = "compact",
283
314
  padVariant = "default",
284
315
  x,
@@ -287,9 +318,13 @@ function VectorPadField({
287
318
  const [isPointerDragging, setIsPointerDragging] = React.useState(false);
288
319
  const normalizedX = normalizeVectorCoordinate(x);
289
320
  const normalizedY = normalizeVectorCoordinate(y);
290
- const point = getVectorPoint(normalizedX, normalizedY);
321
+ const coordinateMode =
322
+ padCoordinateMode ?? getDefaultPadCoordinateMode(padVariant);
323
+ const point = getVectorPoint(normalizedX, normalizedY, coordinateMode);
291
324
  const valueLabel = getVectorValueLabel(normalizedX, normalizedY);
292
325
  const vectorPadBackgroundImage = vectorPadBackgroundImages[padVariant];
326
+ const axisLockRef = React.useRef<VectorPadAxisLock | null>(null);
327
+ const dragStartRef = React.useRef<VectorPadDragStart | null>(null);
293
328
  const liveHistoryGroupRef = React.useRef<string | null>(null);
294
329
  const updateVector = (
295
330
  nextX: string,
@@ -321,17 +356,51 @@ function VectorPadField({
321
356
  }
322
357
 
323
358
  function updateFromPointer(event: React.PointerEvent<HTMLButtonElement>): void {
324
- const nextPoint = pointFromEvent(event);
359
+ const nextPoint = pointFromEvent(event, coordinateMode);
360
+ const nextValue: VectorControlValue = {
361
+ x: (nextPoint.x * 2 - 1).toFixed(2),
362
+ y: (nextPoint.y * 2 - 1).toFixed(2),
363
+ };
364
+ const dragStart = dragStartRef.current;
365
+
366
+ if (event.shiftKey && dragStart) {
367
+ if (!axisLockRef.current) {
368
+ const deltaX = event.clientX - dragStart.clientX;
369
+ const deltaY = event.clientY - dragStart.clientY;
370
+
371
+ if (Math.hypot(deltaX, deltaY) < vectorPadAxisLockThresholdPx) {
372
+ updateVector(
373
+ dragStart.value.x,
374
+ dragStart.value.y,
375
+ getLiveHistoryMeta(),
376
+ );
377
+ return;
378
+ }
379
+
380
+ axisLockRef.current = Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y";
381
+ }
382
+
383
+ updateVector(
384
+ axisLockRef.current === "x" ? nextValue.x : dragStart.value.x,
385
+ axisLockRef.current === "y" ? nextValue.y : dragStart.value.y,
386
+ getLiveHistoryMeta(),
387
+ );
388
+ return;
389
+ }
390
+
391
+ axisLockRef.current = null;
325
392
 
326
393
  updateVector(
327
- (nextPoint.x * 2 - 1).toFixed(2),
328
- (nextPoint.y * 2 - 1).toFixed(2),
394
+ nextValue.x,
395
+ nextValue.y,
329
396
  getLiveHistoryMeta(),
330
397
  );
331
398
  }
332
399
 
333
400
  function stopPointerDrag(): void {
334
401
  setIsPointerDragging(false);
402
+ axisLockRef.current = null;
403
+ dragStartRef.current = null;
335
404
  liveHistoryGroupRef.current = null;
336
405
  }
337
406
 
@@ -352,12 +421,19 @@ function VectorPadField({
352
421
  "relative w-full cursor-default! touch-none overflow-hidden rounded-[calc(var(--radius)+2px)] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--foreground)_4%,transparent),color-mix(in_oklab,var(--foreground)_1%,transparent))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--foreground)_12%,transparent)] focus-visible:outline-none",
353
422
  padShape === "square" ? "aspect-square" : "h-[142px]",
354
423
  )}
424
+ data-vector-pad-coordinate-mode={coordinateMode}
355
425
  data-vector-pad-shape={padShape}
356
426
  data-vector-pad-variant={padVariant}
357
427
  onLostPointerCapture={stopPointerDrag}
358
428
  onPointerCancel={stopPointerDrag}
359
429
  onPointerDown={(event) => {
360
430
  setIsPointerDragging(true);
431
+ axisLockRef.current = null;
432
+ dragStartRef.current = {
433
+ clientX: event.clientX,
434
+ clientY: event.clientY,
435
+ value: { x: normalizedX, y: normalizedY },
436
+ };
361
437
  event.currentTarget.setPointerCapture(event.pointerId);
362
438
  updateFromPointer(event);
363
439
  }}
@@ -53,11 +53,11 @@ export function PanelSection({
53
53
  {childArray.map((child, index) => (
54
54
  <ControlItem
55
55
  allowCompoundDividers={shouldRenderInnerDividers}
56
- compoundDividerPlacement={
57
- shouldRenderInnerDividers && childArray.length > 1 && index === 0
58
- ? "bottom"
59
- : "both"
60
- }
56
+ compoundDividerPlacement={getCompoundDividerPlacement({
57
+ childCount: childArray.length,
58
+ index,
59
+ shouldRenderInnerDividers,
60
+ })}
61
61
  flush={flush || isActionSection}
62
62
  key={getPanelSectionChildKey(child, index)}
63
63
  >
@@ -100,6 +100,30 @@ export function PanelSection({
100
100
  );
101
101
  }
102
102
 
103
+ function getCompoundDividerPlacement({
104
+ childCount,
105
+ index,
106
+ shouldRenderInnerDividers,
107
+ }: {
108
+ childCount: number;
109
+ index: number;
110
+ shouldRenderInnerDividers: boolean;
111
+ }): "both" | "bottom" | "top" {
112
+ if (!shouldRenderInnerDividers || childCount <= 1) {
113
+ return "both";
114
+ }
115
+
116
+ if (index === 0) {
117
+ return "bottom";
118
+ }
119
+
120
+ if (index === childCount - 1) {
121
+ return "top";
122
+ }
123
+
124
+ return "both";
125
+ }
126
+
103
127
  function PanelSectionCollapsibleBody({
104
128
  collapsed,
105
129
  children,
@@ -109,6 +133,10 @@ function PanelSectionCollapsibleBody({
109
133
  }): React.JSX.Element | null {
110
134
  const [shouldRender, setShouldRender] = React.useState(!collapsed);
111
135
  const [isVisuallyCollapsed, setIsVisuallyCollapsed] = React.useState(collapsed);
136
+ const suppressControlTransitions =
137
+ useInitialPanelSectionControlTransitionSuppression(
138
+ `${collapsed ? "collapsed" : "expanded"}:${shouldRender ? "mounted" : "unmounted"}`,
139
+ );
112
140
 
113
141
  React.useEffect(() => {
114
142
  if (collapsed) {
@@ -155,11 +183,59 @@ function PanelSectionCollapsibleBody({
155
183
  data-slot="panel-section-collapsible-body"
156
184
  onTransitionEnd={handleTransitionEnd}
157
185
  >
158
- <div className="min-h-0 overflow-hidden">{children}</div>
186
+ <div
187
+ className="min-h-0 overflow-hidden"
188
+ data-toolcraft-controls-mounting={
189
+ suppressControlTransitions ? "true" : undefined
190
+ }
191
+ >
192
+ {children}
193
+ </div>
159
194
  </div>
160
195
  );
161
196
  }
162
197
 
198
+ function useInitialPanelSectionControlTransitionSuppression(
199
+ dependency: unknown,
200
+ ): boolean {
201
+ const [suppressionState, setSuppressionState] = React.useState(() => ({
202
+ dependency,
203
+ isSuppressing: true,
204
+ }));
205
+
206
+ React.useEffect(() => {
207
+ setSuppressionState({ dependency, isSuppressing: true });
208
+
209
+ if (typeof window === "undefined") {
210
+ return undefined;
211
+ }
212
+
213
+ if (typeof window.requestAnimationFrame !== "function") {
214
+ const timeout = window.setTimeout(() => {
215
+ setSuppressionState({ dependency, isSuppressing: false });
216
+ }, 0);
217
+
218
+ return () => window.clearTimeout(timeout);
219
+ }
220
+
221
+ let secondFrame = 0;
222
+ const firstFrame = window.requestAnimationFrame(() => {
223
+ secondFrame = window.requestAnimationFrame(() => {
224
+ setSuppressionState({ dependency, isSuppressing: false });
225
+ });
226
+ });
227
+
228
+ return () => {
229
+ window.cancelAnimationFrame(firstFrame);
230
+ window.cancelAnimationFrame(secondFrame);
231
+ };
232
+ }, [dependency]);
233
+
234
+ return (
235
+ suppressionState.dependency !== dependency || suppressionState.isSuppressing
236
+ );
237
+ }
238
+
163
239
  function getPanelSectionChildKey(
164
240
  child: React.ReactNode,
165
241
  index: number,