@pixel-point/toolcraft 0.0.3 → 0.0.6

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 (47) hide show
  1. package/package.json +1 -1
  2. package/scripts/prepare-pack.mjs +5 -0
  3. package/src/generate.mjs +13 -0
  4. package/src/generate.test.mjs +6 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +86 -8
  6. package/templates/runtime/contracts/component-contracts.ts +36 -8
  7. package/templates/runtime/contracts/decision-contracts.ts +2 -2
  8. package/templates/runtime/export/export.test.ts +65 -0
  9. package/templates/runtime/export/export.ts +54 -1
  10. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  11. package/templates/runtime/react/controls-panel.test.tsx +323 -6
  12. package/templates/runtime/react/controls-panel.tsx +349 -24
  13. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  14. package/templates/runtime/react/settings-transfer.ts +28 -2
  15. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  16. package/templates/runtime/react/timeline-panel.tsx +98 -10
  17. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  18. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  19. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  20. package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
  21. package/templates/runtime/schema/define-toolcraft.ts +197 -6
  22. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  23. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  24. package/templates/runtime/schema/runtime-targets.ts +6 -0
  25. package/templates/runtime/schema/types.ts +23 -1
  26. package/templates/runtime/state/canvas-zoom.ts +1 -1
  27. package/templates/runtime/state/create-template-state.test.ts +9 -3
  28. package/templates/runtime/state/reducer.test.ts +135 -2
  29. package/templates/runtime/state/reducer.ts +236 -12
  30. package/templates/runtime/state/types.ts +1 -0
  31. package/templates/starter/AGENTS.md +6 -4
  32. package/templates/starter/docs/toolcraft/README.md +1 -1
  33. package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
  34. package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
  35. package/templates/starter/docs/toolcraft/component-rules.md +24 -5
  36. package/templates/starter/docs/toolcraft/performance.md +5 -0
  37. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  38. package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
  39. package/templates/starter/gitignore +36 -0
  40. package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
  41. package/templates/starter/src/app/starter-acceptance.ts +357 -4
  42. package/templates/ui/components/control-layout/index.tsx +4 -4
  43. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
  44. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
  45. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  46. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  47. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
@@ -226,7 +226,7 @@ describe("CanvasShell", () => {
226
226
  await waitFor(() => {
227
227
  expect(screen.getByTestId("canvas-offset").textContent).toBe("-8,12");
228
228
  });
229
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
229
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
230
230
  });
231
231
 
232
232
  it("zooms the canvas world from a trackpad pinch around the pointer", async () => {
@@ -243,7 +243,7 @@ describe("CanvasShell", () => {
243
243
  const world = canvas.querySelector("[data-toolcraft-canvas-world]") as HTMLElement;
244
244
  mockCanvasRect(canvas);
245
245
 
246
- expect(world.style.transform).toBe("translate(-50%, -50%) translate(0px, 0px) scale(0.7)");
246
+ expect(world.style.transform).toBe("translate(-50%, -50%) translate(0px, 0px) scale(1)");
247
247
 
248
248
  const wheelEvent = new WheelEvent("wheel", {
249
249
  bubbles: true,
@@ -257,10 +257,10 @@ describe("CanvasShell", () => {
257
257
  expect(canvas.dispatchEvent(wheelEvent)).toBe(false);
258
258
  expect(wheelEvent.defaultPrevented).toBe(true);
259
259
  await waitFor(() => {
260
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("120");
260
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("150");
261
261
  });
262
- expect(world.style.transform).toContain("translate(-142.857142857142");
263
- expect(world.style.transform).toContain("scale(1.2)");
262
+ expect(world.style.transform).toContain("translate(-100px");
263
+ expect(world.style.transform).toContain("scale(1.5)");
264
264
  });
265
265
 
266
266
  it("prevents panel pinch gestures without moving the canvas viewport", async () => {
@@ -294,7 +294,7 @@ describe("CanvasShell", () => {
294
294
 
295
295
  expect(overlay.dispatchEvent(wheelEvent)).toBe(false);
296
296
  expect(wheelEvent.defaultPrevented).toBe(true);
297
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
297
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
298
298
  expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0");
299
299
  expect(overlay.closest("[data-toolcraft-canvas-world]")).toBeNull();
300
300
 
@@ -306,7 +306,7 @@ describe("CanvasShell", () => {
306
306
 
307
307
  expect(overlay.dispatchEvent(scrollEvent)).toBe(true);
308
308
  expect(scrollEvent.defaultPrevented).toBe(false);
309
- expect(screen.getByTestId("canvas-zoom").textContent).toBe("70");
309
+ expect(screen.getByTestId("canvas-zoom").textContent).toBe("100");
310
310
  expect(screen.getByTestId("canvas-offset").textContent).toBe("0,0");
311
311
  });
312
312
 
@@ -384,7 +384,7 @@ describe("ControlsPanel", () => {
384
384
  expect(inlineGroup?.textContent).toContain("Snap Y");
385
385
  });
386
386
 
387
- it("renders hidden-label toggle plus parameter rows inline without duplicating section labels", () => {
387
+ it("renders the managed background toggle plus unlabeled color row as equal-width inline columns", () => {
388
388
  const schema = defineToolcraft({
389
389
  canvas: { enabled: false },
390
390
  panels: {
@@ -396,7 +396,7 @@ describe("ControlsPanel", () => {
396
396
  defaultValue: true,
397
397
  description:
398
398
  "Controls PNG background transparency while preview and video keep the background.",
399
- label: false,
399
+ label: "Include",
400
400
  target: "export.includeBackground",
401
401
  type: "switch",
402
402
  },
@@ -428,12 +428,110 @@ describe("ControlsPanel", () => {
428
428
  );
429
429
 
430
430
  expect(screen.getAllByText("Background")).toHaveLength(1);
431
- expect(screen.getByRole("switch", { name: "includeBackground" })).toBeTruthy();
431
+ expect(screen.getByText("Include")).toBeTruthy();
432
+ expect(screen.getByRole("switch")).toBeTruthy();
432
433
  expect(toggleParameterGroup).toBeTruthy();
433
- expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
434
+ expect(toggleParameterGroup?.getAttribute("style")).toContain(
435
+ "repeat(2, minmax(0, 1fr))",
436
+ );
434
437
  });
435
438
 
436
- it("renders short visible toggle plus parameter rows as compact toggle-parameter rows", () => {
439
+ it("suppresses obvious help icons in sequential color sections", () => {
440
+ const schema = defineToolcraft({
441
+ canvas: { enabled: false },
442
+ panels: {
443
+ controls: {
444
+ sections: [
445
+ {
446
+ controls: {
447
+ color1: {
448
+ defaultValue: { hex: "#DFFF1A" },
449
+ description: "Sets the first bead color.",
450
+ label: "Color 1",
451
+ target: "beads.color1",
452
+ type: "color",
453
+ },
454
+ color2: {
455
+ defaultValue: { hex: "#8CFF3A" },
456
+ description: "Sets the second bead color.",
457
+ label: "Color 2",
458
+ target: "beads.color2",
459
+ type: "color",
460
+ },
461
+ color3: {
462
+ defaultValue: { hex: "#F4FF5A" },
463
+ description: "Sets the third bead color.",
464
+ label: "Color 3",
465
+ target: "beads.color3",
466
+ type: "color",
467
+ },
468
+ color4: {
469
+ defaultValue: { hex: "#B8FF2E" },
470
+ description: "Sets the fourth bead color.",
471
+ label: "Color 4",
472
+ target: "beads.color4",
473
+ type: "color",
474
+ },
475
+ color5: {
476
+ defaultValue: { hex: "#ECFF68" },
477
+ description: "Sets the fifth bead color.",
478
+ label: "Color 5",
479
+ target: "beads.color5",
480
+ type: "color",
481
+ },
482
+ colorSpread: {
483
+ defaultValue: 34,
484
+ description:
485
+ "Controls how often beads use colors 2-5 instead of Color 1.",
486
+ label: "Spread",
487
+ max: 100,
488
+ min: 0,
489
+ target: "beads.colorSpread",
490
+ type: "slider",
491
+ unit: "%",
492
+ },
493
+ },
494
+ layoutGroups: [
495
+ {
496
+ columns: 2,
497
+ controls: ["color1", "color2"],
498
+ layout: "inline",
499
+ },
500
+ {
501
+ columns: 2,
502
+ controls: ["color3", "color4"],
503
+ layout: "inline",
504
+ },
505
+ ],
506
+ title: "Bead Colors",
507
+ },
508
+ {
509
+ controls: {
510
+ includeBackground: {
511
+ defaultValue: true,
512
+ description:
513
+ "Controls PNG background transparency while preview and video keep the background.",
514
+ label: "Include",
515
+ target: "export.includeBackground",
516
+ type: "switch",
517
+ },
518
+ },
519
+ title: "Background",
520
+ },
521
+ ],
522
+ title: "Controls",
523
+ },
524
+ },
525
+ });
526
+
527
+ renderControlsPanelWithSchema(schema);
528
+
529
+ expect(screen.queryByRole("button", { name: "Color 5 help" })).toBeNull();
530
+ expect(screen.queryByRole("button", { name: "Spread help" })).toBeNull();
531
+ expect(screen.getByRole("button", { name: "Include help" })).toBeTruthy();
532
+ });
533
+
534
+ it("renders short visible toggle plus parameter rows as equal-width toggle-parameter rows", () => {
437
535
  const schema = defineToolcraft({
438
536
  canvas: { enabled: false },
439
537
  panels: {
@@ -477,7 +575,9 @@ describe("ControlsPanel", () => {
477
575
  expect(screen.getByText("Loop")).toBeTruthy();
478
576
  expect(screen.getByText("Duration")).toBeTruthy();
479
577
  expect(toggleParameterGroup).toBeTruthy();
480
- expect(toggleParameterGroup?.getAttribute("style")).toContain("auto minmax(0, 1fr)");
578
+ expect(toggleParameterGroup?.getAttribute("style")).toContain(
579
+ "repeat(2, minmax(0, 1fr))",
580
+ );
481
581
  });
482
582
 
483
583
  it("renders compact select pairs inline with stacked full-width fields", () => {
@@ -1529,6 +1629,46 @@ describe("ControlsPanel", () => {
1529
1629
  container.querySelector('[data-slot="slider"][data-variant="discrete"]'),
1530
1630
  ).toBeNull();
1531
1631
  expect(container.querySelectorAll('[data-slot="slider-marker"]')).toHaveLength(0);
1632
+ expect(container.textContent).toContain("118 cols/s");
1633
+ });
1634
+
1635
+ it("keeps textual slider value labels non-editable", () => {
1636
+ const schema = defineToolcraft({
1637
+ canvas: { enabled: true },
1638
+ panels: {
1639
+ controls: {
1640
+ sections: [
1641
+ {
1642
+ controls: {
1643
+ letterSpacing: {
1644
+ defaultValue: 2,
1645
+ label: "Letter spacing",
1646
+ max: 4,
1647
+ min: 0,
1648
+ step: 1,
1649
+ target: "text.letterSpacing",
1650
+ type: "slider",
1651
+ valueLabel: "Normal",
1652
+ },
1653
+ },
1654
+ title: "Text",
1655
+ },
1656
+ ],
1657
+ title: "Generation Controls",
1658
+ },
1659
+ },
1660
+ });
1661
+
1662
+ renderControlsPanelWithSchema(schema);
1663
+
1664
+ const valueLabel = screen
1665
+ .getAllByText("Normal")
1666
+ .find((node) => node.getAttribute("aria-hidden") !== "true");
1667
+
1668
+ expect(screen.queryByRole("button", { name: "Edit Letter spacing value" })).toBeNull();
1669
+ expect(valueLabel).toBeTruthy();
1670
+ expect(valueLabel?.className).toContain("cursor-default");
1671
+ expect(valueLabel?.className).not.toContain("cursor-text");
1532
1672
  });
1533
1673
 
1534
1674
  it("keeps schema sliders stacked even when a layout group asks for an inline row", () => {
@@ -1584,6 +1724,8 @@ describe("ControlsPanel", () => {
1584
1724
  expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
1585
1725
  expect(fpsSlider).toBeTruthy();
1586
1726
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1727
+ expect(container.textContent).toContain("17 fps");
1728
+ expect(container.textContent).toContain("3.7x");
1587
1729
  });
1588
1730
 
1589
1731
  it("passes schema disabled state into slider controls", () => {
@@ -2278,6 +2420,76 @@ describe("ControlsPanel", () => {
2278
2420
  expect(screen.getByTestId("media-count").textContent).toBe("0");
2279
2421
  });
2280
2422
 
2423
+ it("renders multi-image file uploads as an add-last thumbnail grid", () => {
2424
+ const schema = defineToolcraft({
2425
+ canvas: { enabled: true, upload: true },
2426
+ panels: {
2427
+ controls: {
2428
+ sections: [
2429
+ {
2430
+ controls: {
2431
+ source: {
2432
+ accept: "PNG, SVG",
2433
+ label: "Images",
2434
+ multiple: true,
2435
+ target: "input.sources",
2436
+ type: "fileDrop",
2437
+ },
2438
+ },
2439
+ layout: "standalone",
2440
+ title: "Input",
2441
+ },
2442
+ ],
2443
+ title: "Generation Controls",
2444
+ },
2445
+ },
2446
+ });
2447
+
2448
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2449
+ layers: [
2450
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2451
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2452
+ ],
2453
+ mediaAssets: [
2454
+ {
2455
+ dataUrl:
2456
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23777'/%3E%3C/svg%3E",
2457
+ fileName: "one.svg",
2458
+ id: "media-1",
2459
+ layerId: "layer-1",
2460
+ mimeType: "image/svg+xml",
2461
+ position: { x: 0, y: 0 },
2462
+ size: { height: 96, unit: "px", width: 96 },
2463
+ },
2464
+ {
2465
+ dataUrl:
2466
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 96 96'%3E%3Crect width='96' height='96' fill='%23999'/%3E%3C/svg%3E",
2467
+ fileName: "two.svg",
2468
+ id: "media-2",
2469
+ layerId: "layer-2",
2470
+ mimeType: "image/svg+xml",
2471
+ position: { x: 0, y: 0 },
2472
+ size: { height: 96, unit: "px", width: 96 },
2473
+ },
2474
+ ],
2475
+ selectedLayerId: "layer-2",
2476
+ });
2477
+
2478
+ expect(screen.getByRole("button", { name: "Add image files" })).toBeTruthy();
2479
+ const previewGrid = container.querySelector('[data-slot="file-upload-preview-grid"]');
2480
+ expect(previewGrid?.className).toContain("grid-cols-4");
2481
+ expect(previewGrid?.lastElementChild?.getAttribute("data-slot")).toBe(
2482
+ "file-upload-add-preview",
2483
+ );
2484
+ expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2485
+ expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2486
+
2487
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2488
+
2489
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2490
+ expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2491
+ });
2492
+
2281
2493
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2282
2494
  const schema = defineToolcraft({
2283
2495
  canvas: { enabled: true, upload: true },
@@ -3085,6 +3297,104 @@ describe("ControlsPanel", () => {
3085
3297
  expect(screen.getByTestId("canvas-size").textContent).toBe("320,180");
3086
3298
  });
3087
3299
 
3300
+ it("renders editable-output aspect ratio controls with the current canvas preset", () => {
3301
+ const schema = defineToolcraft({
3302
+ canvas: {
3303
+ enabled: true,
3304
+ size: { height: 1080, unit: "px", width: 1440 },
3305
+ sizing: { mode: "editable-output" },
3306
+ },
3307
+ panels: {
3308
+ controls: {
3309
+ sections: [],
3310
+ title: "Controls",
3311
+ },
3312
+ },
3313
+ });
3314
+ const { container } = renderControlsPanelWithSchema(schema);
3315
+
3316
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1440,1080");
3317
+
3318
+ const aspectRatioControl = container.querySelector<HTMLElement>(
3319
+ '[data-slot="canvas-aspect-ratio-control"]',
3320
+ );
3321
+
3322
+ expect(screen.getByText("Aspect ratio")).toBeTruthy();
3323
+ expect(aspectRatioControl?.textContent).toContain("4:3");
3324
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3325
+
3326
+ expect(values["canvas.aspectRatio"]).toEqual({
3327
+ height: 3,
3328
+ mode: "preset",
3329
+ value: "4:3",
3330
+ width: 4,
3331
+ });
3332
+ expect(values["canvas.size.width"]).toBe(1440);
3333
+ expect(values["canvas.size.height"]).toBe(1080);
3334
+ });
3335
+
3336
+ it("switches editable-output aspect ratio to custom after manual size edits", () => {
3337
+ const schema = defineToolcraft({
3338
+ canvas: {
3339
+ enabled: true,
3340
+ size: { height: 1080, unit: "px", width: 1920 },
3341
+ sizing: { mode: "editable-output" },
3342
+ },
3343
+ panels: {
3344
+ controls: {
3345
+ sections: [],
3346
+ title: "Controls",
3347
+ },
3348
+ },
3349
+ });
3350
+
3351
+ renderControlsPanelWithSchema(schema);
3352
+
3353
+ const widthInput = screen.getByDisplayValue("1920") as HTMLInputElement;
3354
+
3355
+ fireEvent.change(widthInput, { target: { value: "1600" } });
3356
+ fireEvent.blur(widthInput);
3357
+
3358
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1600,1080");
3359
+ expect(screen.getAllByText("Custom...").length).toBeGreaterThan(0);
3360
+ expect(screen.getByDisplayValue("40")).toBeTruthy();
3361
+ expect(screen.getByDisplayValue("27")).toBeTruthy();
3362
+ expect(JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")).toMatchObject({
3363
+ "canvas.aspectRatio": {
3364
+ height: 27,
3365
+ mode: "custom",
3366
+ value: "40:27",
3367
+ width: 40,
3368
+ },
3369
+ "canvas.size.height": 1080,
3370
+ "canvas.size.width": 1600,
3371
+ });
3372
+ });
3373
+
3374
+ it("renders raster render scale as the last technical setup control", () => {
3375
+ const schema = defineToolcraft({
3376
+ canvas: {
3377
+ enabled: true,
3378
+ renderScale: true,
3379
+ size: { height: 1080, unit: "px", width: 1920 },
3380
+ sizing: { mode: "editable-output" },
3381
+ },
3382
+ panels: {
3383
+ controls: {
3384
+ sections: [],
3385
+ title: "Controls",
3386
+ },
3387
+ },
3388
+ });
3389
+ renderControlsPanelWithSchema(schema);
3390
+
3391
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3392
+
3393
+ expect(screen.getByText("Resolution scale")).toBeTruthy();
3394
+ expect(screen.getByText("2x")).toBeTruthy();
3395
+ expect(values["canvas.renderScale"]).toBe(2);
3396
+ });
3397
+
3088
3398
  it("applies empty text input values while typing and reset restores defaults", () => {
3089
3399
  renderControlsPanel();
3090
3400
 
@@ -3391,11 +3701,18 @@ describe("ControlsPanel", () => {
3391
3701
  expect(firstSection?.querySelector("[data-control-list]")?.className).not.toContain("pb-5");
3392
3702
  expect(firstInlineGroup?.textContent).toContain("Canvas width");
3393
3703
  expect(firstInlineGroup?.textContent).toContain("Canvas height");
3704
+ expect(firstSection?.textContent).toContain("Aspect ratio");
3394
3705
  expect(firstSection?.textContent).toContain("Canvas width");
3395
3706
  expect(firstSection?.textContent).toContain("Canvas height");
3396
3707
  expect(firstSection?.textContent).toContain("Export Settings");
3397
3708
  expect(firstSection?.textContent).toContain("Import Settings");
3398
3709
  expect((firstSection?.textContent ?? "").indexOf("Export Settings")).toBeLessThan(
3710
+ (firstSection?.textContent ?? "").indexOf("Import Settings"),
3711
+ );
3712
+ expect((firstSection?.textContent ?? "").indexOf("Import Settings")).toBeLessThan(
3713
+ (firstSection?.textContent ?? "").indexOf("Aspect ratio"),
3714
+ );
3715
+ expect((firstSection?.textContent ?? "").indexOf("Aspect ratio")).toBeLessThan(
3399
3716
  (firstSection?.textContent ?? "").indexOf("Canvas width"),
3400
3717
  );
3401
3718
  expect(scrollContent?.textContent).toContain("Import Settings");