@pixel-point/toolcraft 0.0.4 → 0.0.7

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 (49) 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 +7 -1
  5. package/templates/runtime/contracts/component-contracts.test.ts +96 -3
  6. package/templates/runtime/contracts/component-contracts.ts +75 -3
  7. package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
  8. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  9. package/templates/runtime/react/canvas-shell.test.tsx +7 -7
  10. package/templates/runtime/react/controls-panel.test.tsx +455 -1
  11. package/templates/runtime/react/controls-panel.tsx +515 -30
  12. package/templates/runtime/react/settings-transfer.test.ts +3 -3
  13. package/templates/runtime/react/timeline-panel.test.tsx +69 -0
  14. package/templates/runtime/react/timeline-panel.tsx +98 -10
  15. package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
  16. package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
  17. package/templates/runtime/schema/define-toolcraft.test.ts +78 -1
  18. package/templates/runtime/schema/define-toolcraft.ts +145 -6
  19. package/templates/runtime/schema/runtime-targets.ts +1 -0
  20. package/templates/runtime/schema/types.ts +46 -1
  21. package/templates/runtime/state/canvas-zoom.ts +1 -1
  22. package/templates/runtime/state/create-template-state.test.ts +6 -6
  23. package/templates/runtime/state/reducer.test.ts +139 -8
  24. package/templates/runtime/state/reducer.ts +88 -22
  25. package/templates/runtime/state/types.ts +3 -0
  26. package/templates/starter/AGENTS.md +8 -8
  27. package/templates/starter/docs/toolcraft/README.md +4 -3
  28. package/templates/starter/docs/toolcraft/acceptance-testing.md +6 -2
  29. package/templates/starter/docs/toolcraft/assembly-workflow.md +8 -6
  30. package/templates/starter/docs/toolcraft/component-rules.md +17 -1
  31. package/templates/starter/docs/toolcraft/custom-controls.md +2 -2
  32. package/templates/starter/docs/toolcraft/performance.md +8 -7
  33. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  34. package/templates/starter/docs/toolcraft/schema-reference.md +15 -4
  35. package/templates/starter/docs/toolcraft/workflow.md +5 -8
  36. package/templates/starter/gitignore +36 -0
  37. package/templates/starter/package.json +1 -1
  38. package/templates/starter/src/app/starter-acceptance.test.ts +55 -0
  39. package/templates/starter/src/app/starter-acceptance.ts +67 -1
  40. package/templates/starter/src/app/starter-performance.test.ts +1 -1
  41. package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
  42. package/templates/ui/components/controls/collection-actions/index.ts +4 -0
  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 -6
  45. package/templates/ui/components/controls/index.ts +8 -0
  46. package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
  47. package/templates/ui/components/controls/slider/slider-value.ts +48 -5
  48. package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -1
  49. package/templates/ui/index.ts +1 -0
@@ -436,6 +436,101 @@ describe("ControlsPanel", () => {
436
436
  );
437
437
  });
438
438
 
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
+
439
534
  it("renders short visible toggle plus parameter rows as equal-width toggle-parameter rows", () => {
440
535
  const schema = defineToolcraft({
441
536
  canvas: { enabled: false },
@@ -1176,6 +1271,24 @@ describe("ControlsPanel", () => {
1176
1271
  });
1177
1272
  });
1178
1273
 
1274
+ fireEvent.change(fontSizeInput, {
1275
+ target: { value: "768" },
1276
+ });
1277
+ fireEvent.blur(fontSizeInput);
1278
+
1279
+ await waitFor(() => {
1280
+ expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1281
+ color: "#FFFFFF",
1282
+ fontId: "roboto",
1283
+ fontSize: 768,
1284
+ fontWeight: "400",
1285
+ letterSpacing: "wider",
1286
+ lineHeight: "relaxed",
1287
+ opacity: 100,
1288
+ textCase: "uppercase",
1289
+ });
1290
+ });
1291
+
1179
1292
  const colorHexInput = screen.getByLabelText("Color hex") as HTMLInputElement;
1180
1293
  fireEvent.change(colorHexInput, {
1181
1294
  target: { value: "#C1FF00" },
@@ -1191,7 +1304,7 @@ describe("ControlsPanel", () => {
1191
1304
  expect(JSON.parse(screen.getByTestId("font-value").textContent ?? "{}")).toEqual({
1192
1305
  color: "#C1FF00",
1193
1306
  fontId: "roboto",
1194
- fontSize: 24,
1307
+ fontSize: 768,
1195
1308
  fontWeight: "400",
1196
1309
  letterSpacing: "wider",
1197
1310
  lineHeight: "relaxed",
@@ -1534,6 +1647,46 @@ describe("ControlsPanel", () => {
1534
1647
  container.querySelector('[data-slot="slider"][data-variant="discrete"]'),
1535
1648
  ).toBeNull();
1536
1649
  expect(container.querySelectorAll('[data-slot="slider-marker"]')).toHaveLength(0);
1650
+ expect(container.textContent).toContain("118 cols/s");
1651
+ });
1652
+
1653
+ it("keeps textual slider value labels non-editable", () => {
1654
+ const schema = defineToolcraft({
1655
+ canvas: { enabled: true },
1656
+ panels: {
1657
+ controls: {
1658
+ sections: [
1659
+ {
1660
+ controls: {
1661
+ letterSpacing: {
1662
+ defaultValue: 2,
1663
+ label: "Letter spacing",
1664
+ max: 4,
1665
+ min: 0,
1666
+ step: 1,
1667
+ target: "text.letterSpacing",
1668
+ type: "slider",
1669
+ valueLabel: "Normal",
1670
+ },
1671
+ },
1672
+ title: "Text",
1673
+ },
1674
+ ],
1675
+ title: "Generation Controls",
1676
+ },
1677
+ },
1678
+ });
1679
+
1680
+ renderControlsPanelWithSchema(schema);
1681
+
1682
+ const valueLabel = screen
1683
+ .getAllByText("Normal")
1684
+ .find((node) => node.getAttribute("aria-hidden") !== "true");
1685
+
1686
+ expect(screen.queryByRole("button", { name: "Edit Letter spacing value" })).toBeNull();
1687
+ expect(valueLabel).toBeTruthy();
1688
+ expect(valueLabel?.className).toContain("cursor-default");
1689
+ expect(valueLabel?.className).not.toContain("cursor-text");
1537
1690
  });
1538
1691
 
1539
1692
  it("keeps schema sliders stacked even when a layout group asks for an inline row", () => {
@@ -1589,6 +1742,8 @@ describe("ControlsPanel", () => {
1589
1742
  expect(container.querySelector('[data-control-layout="inline"]')).toBeNull();
1590
1743
  expect(fpsSlider).toBeTruthy();
1591
1744
  expect(container.querySelectorAll('[data-slot="slider-marker"]').length).toBeGreaterThan(0);
1745
+ expect(container.textContent).toContain("17 fps");
1746
+ expect(container.textContent).toContain("3.7x");
1592
1747
  });
1593
1748
 
1594
1749
  it("passes schema disabled state into slider controls", () => {
@@ -2283,6 +2438,76 @@ describe("ControlsPanel", () => {
2283
2438
  expect(screen.getByTestId("media-count").textContent).toBe("0");
2284
2439
  });
2285
2440
 
2441
+ it("renders multi-image file uploads as an add-last thumbnail grid", () => {
2442
+ const schema = defineToolcraft({
2443
+ canvas: { enabled: true, upload: true },
2444
+ panels: {
2445
+ controls: {
2446
+ sections: [
2447
+ {
2448
+ controls: {
2449
+ source: {
2450
+ accept: "PNG, SVG",
2451
+ label: "Images",
2452
+ multiple: true,
2453
+ target: "input.sources",
2454
+ type: "fileDrop",
2455
+ },
2456
+ },
2457
+ layout: "standalone",
2458
+ title: "Input",
2459
+ },
2460
+ ],
2461
+ title: "Generation Controls",
2462
+ },
2463
+ },
2464
+ });
2465
+
2466
+ const { container } = renderControlsPanelWithSchema(schema, undefined, {
2467
+ layers: [
2468
+ { id: "layer-1", kind: "layer", name: "one", visible: true },
2469
+ { id: "layer-2", kind: "layer", name: "two", visible: true },
2470
+ ],
2471
+ mediaAssets: [
2472
+ {
2473
+ dataUrl:
2474
+ "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",
2475
+ fileName: "one.svg",
2476
+ id: "media-1",
2477
+ layerId: "layer-1",
2478
+ mimeType: "image/svg+xml",
2479
+ position: { x: 0, y: 0 },
2480
+ size: { height: 96, unit: "px", width: 96 },
2481
+ },
2482
+ {
2483
+ dataUrl:
2484
+ "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",
2485
+ fileName: "two.svg",
2486
+ id: "media-2",
2487
+ layerId: "layer-2",
2488
+ mimeType: "image/svg+xml",
2489
+ position: { x: 0, y: 0 },
2490
+ size: { height: 96, unit: "px", width: 96 },
2491
+ },
2492
+ ],
2493
+ selectedLayerId: "layer-2",
2494
+ });
2495
+
2496
+ expect(screen.getByRole("button", { name: "Add image files" })).toBeTruthy();
2497
+ const previewGrid = container.querySelector('[data-slot="file-upload-preview-grid"]');
2498
+ expect(previewGrid?.className).toContain("grid-cols-4");
2499
+ expect(previewGrid?.lastElementChild?.getAttribute("data-slot")).toBe(
2500
+ "file-upload-add-preview",
2501
+ );
2502
+ expect(screen.getByRole("img", { name: "one.svg" })).toBeTruthy();
2503
+ expect(screen.getByRole("img", { name: "two.svg" })).toBeTruthy();
2504
+
2505
+ fireEvent.click(screen.getByRole("button", { name: "Remove two.svg" }));
2506
+
2507
+ expect(screen.getByTestId("media-count").textContent).toBe("1");
2508
+ expect(screen.queryByRole("img", { name: "two.svg" })).toBeNull();
2509
+ });
2510
+
2286
2511
  it("keeps file upload deletion in the layers panel for multi-layer apps", () => {
2287
2512
  const schema = defineToolcraft({
2288
2513
  canvas: { enabled: true, upload: true },
@@ -3057,6 +3282,173 @@ describe("ControlsPanel", () => {
3057
3282
  expect(screen.getByTestId("enabled-value").textContent).toBe("true");
3058
3283
  });
3059
3284
 
3285
+ it("resets only controls from the selected section header action", () => {
3286
+ const schema = defineToolcraft({
3287
+ canvas: { enabled: false },
3288
+ panels: {
3289
+ controls: {
3290
+ sections: [
3291
+ {
3292
+ controls: {
3293
+ toneName: {
3294
+ defaultValue: "Soft",
3295
+ label: "Tone name",
3296
+ target: "tone.name",
3297
+ type: "text",
3298
+ },
3299
+ },
3300
+ title: "Tone",
3301
+ },
3302
+ {
3303
+ controls: {
3304
+ prompt: {
3305
+ defaultValue: "Poster",
3306
+ label: "Prompt",
3307
+ target: "generation.prompt",
3308
+ type: "text",
3309
+ },
3310
+ },
3311
+ title: "Output",
3312
+ },
3313
+ ],
3314
+ title: "Controls",
3315
+ },
3316
+ },
3317
+ });
3318
+ const { container } = renderControlsPanelWithSchema(schema);
3319
+
3320
+ fireEvent.change(screen.getByDisplayValue("Soft"), {
3321
+ target: { value: "Sharp" },
3322
+ });
3323
+ fireEvent.change(screen.getByDisplayValue("Poster"), {
3324
+ target: { value: "Banner" },
3325
+ });
3326
+
3327
+ const toneHeader = screen
3328
+ .getByText("Tone")
3329
+ .closest<HTMLElement>('[data-slot="control-section-header"]');
3330
+ const headerButtons = toneHeader?.querySelectorAll("button");
3331
+
3332
+ expect(headerButtons?.[0]?.getAttribute("data-control-section-reset-button")).toBe("");
3333
+ expect(headerButtons?.[1]?.getAttribute("data-control-section-collapse-button")).toBe("");
3334
+
3335
+ fireEvent.click(screen.getByRole("button", { name: "Reset Tone section" }));
3336
+
3337
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3338
+
3339
+ expect(values["tone.name"]).toBe("Soft");
3340
+ expect(values["generation.prompt"]).toBe("Banner");
3341
+ expect(container.querySelector('[data-control-section-reset-button]')).toBeTruthy();
3342
+ });
3343
+
3344
+ it("renders collection actions as add/remove controls for runtime item arrays", () => {
3345
+ const schema = defineToolcraft({
3346
+ canvas: { enabled: false },
3347
+ panels: {
3348
+ controls: {
3349
+ sections: [
3350
+ {
3351
+ controls: {
3352
+ colors: {
3353
+ defaultValue: [{ hex: "#DFFF1A" }, { hex: "#8CFF3A" }],
3354
+ itemControl: {
3355
+ defaultValue: { hex: "#C1FF00" },
3356
+ label: "Color",
3357
+ type: "color",
3358
+ },
3359
+ label: "Colors",
3360
+ minItems: 1,
3361
+ recommendedMaxItems: 2,
3362
+ target: "bead.colors",
3363
+ type: "collectionActions",
3364
+ },
3365
+ },
3366
+ title: "Bead Colors",
3367
+ },
3368
+ ],
3369
+ title: "Controls",
3370
+ },
3371
+ },
3372
+ });
3373
+
3374
+ const { container } = renderControlsPanelWithSchema(schema);
3375
+
3376
+ const addButton = screen.getByRole("button", { name: "Add Color" });
3377
+ const removeButton = screen.getByRole("button", { name: "Remove Color" });
3378
+ const collectionHeader = container.querySelector(
3379
+ '[data-slot="collection-actions-control-header"]',
3380
+ );
3381
+ const colorGrid = container.querySelector(
3382
+ '[data-slot="collection-actions-items-grid"]',
3383
+ );
3384
+
3385
+ expect(collectionHeader?.textContent).toContain("Colors");
3386
+ expect(collectionHeader?.lastElementChild?.contains(removeButton)).toBe(true);
3387
+ expect(collectionHeader?.lastElementChild?.contains(addButton)).toBe(true);
3388
+ expect(colorGrid).toBeTruthy();
3389
+ expect(colorGrid?.children).toHaveLength(2);
3390
+ expect(screen.queryByText("Color 1")).toBeNull();
3391
+ expect(screen.queryByText("Color 2")).toBeNull();
3392
+
3393
+ fireEvent.click(addButton);
3394
+ fireEvent.click(addButton);
3395
+
3396
+ let values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3397
+
3398
+ expect(values["bead.colors"]).toHaveLength(4);
3399
+ expect(screen.queryByText("Color 4")).toBeNull();
3400
+ expect(colorGrid?.children).toHaveLength(4);
3401
+ expect((addButton as HTMLButtonElement).disabled).toBe(false);
3402
+
3403
+ fireEvent.click(removeButton);
3404
+ fireEvent.click(removeButton);
3405
+ fireEvent.click(removeButton);
3406
+
3407
+ values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3408
+
3409
+ expect(values["bead.colors"]).toHaveLength(1);
3410
+ expect((removeButton as HTMLButtonElement).disabled).toBe(true);
3411
+ });
3412
+
3413
+ it("renders collection item controls beyond color lists", () => {
3414
+ const schema = defineToolcraft({
3415
+ canvas: { enabled: false },
3416
+ panels: {
3417
+ controls: {
3418
+ sections: [
3419
+ {
3420
+ controls: {
3421
+ labels: {
3422
+ defaultValue: ["Alpha"],
3423
+ itemControl: {
3424
+ defaultValue: "New label",
3425
+ label: "Label",
3426
+ type: "text",
3427
+ },
3428
+ minItems: 1,
3429
+ target: "glyph.labels",
3430
+ type: "collectionActions",
3431
+ },
3432
+ },
3433
+ title: "Glyph Labels",
3434
+ },
3435
+ ],
3436
+ title: "Controls",
3437
+ },
3438
+ },
3439
+ });
3440
+
3441
+ renderControlsPanelWithSchema(schema);
3442
+
3443
+ fireEvent.click(screen.getByRole("button", { name: "Add Label" }));
3444
+
3445
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3446
+
3447
+ expect(values["glyph.labels"]).toEqual(["Alpha", "New label"]);
3448
+ expect(screen.getByDisplayValue("Alpha")).toBeTruthy();
3449
+ expect(screen.getByDisplayValue("New label")).toBeTruthy();
3450
+ });
3451
+
3060
3452
  it("commits canvas size controls to runtime state on blur or Enter", () => {
3061
3453
  const { container } = renderControlsPanel();
3062
3454
 
@@ -3126,6 +3518,68 @@ describe("ControlsPanel", () => {
3126
3518
  expect(values["canvas.size.height"]).toBe(1080);
3127
3519
  });
3128
3520
 
3521
+ it("switches editable-output aspect ratio to custom after manual size edits", () => {
3522
+ const schema = defineToolcraft({
3523
+ canvas: {
3524
+ enabled: true,
3525
+ size: { height: 1080, unit: "px", width: 1920 },
3526
+ sizing: { mode: "editable-output" },
3527
+ },
3528
+ panels: {
3529
+ controls: {
3530
+ sections: [],
3531
+ title: "Controls",
3532
+ },
3533
+ },
3534
+ });
3535
+
3536
+ renderControlsPanelWithSchema(schema);
3537
+
3538
+ const widthInput = screen.getByDisplayValue("1920") as HTMLInputElement;
3539
+
3540
+ fireEvent.change(widthInput, { target: { value: "1600" } });
3541
+ fireEvent.blur(widthInput);
3542
+
3543
+ expect(screen.getByTestId("canvas-size").textContent).toBe("1600,1080");
3544
+ expect(screen.getAllByText("Custom...").length).toBeGreaterThan(0);
3545
+ expect(screen.getByDisplayValue("40")).toBeTruthy();
3546
+ expect(screen.getByDisplayValue("27")).toBeTruthy();
3547
+ expect(JSON.parse(screen.getByTestId("values-json").textContent ?? "{}")).toMatchObject({
3548
+ "canvas.aspectRatio": {
3549
+ height: 27,
3550
+ mode: "custom",
3551
+ value: "40:27",
3552
+ width: 40,
3553
+ },
3554
+ "canvas.size.height": 1080,
3555
+ "canvas.size.width": 1600,
3556
+ });
3557
+ });
3558
+
3559
+ it("renders raster render scale as the last technical setup control", () => {
3560
+ const schema = defineToolcraft({
3561
+ canvas: {
3562
+ enabled: true,
3563
+ renderScale: true,
3564
+ size: { height: 1080, unit: "px", width: 1920 },
3565
+ sizing: { mode: "editable-output" },
3566
+ },
3567
+ panels: {
3568
+ controls: {
3569
+ sections: [],
3570
+ title: "Controls",
3571
+ },
3572
+ },
3573
+ });
3574
+ renderControlsPanelWithSchema(schema);
3575
+
3576
+ const values = JSON.parse(screen.getByTestId("values-json").textContent ?? "{}");
3577
+
3578
+ expect(screen.getByText("Resolution scale")).toBeTruthy();
3579
+ expect(screen.getByText("2x")).toBeTruthy();
3580
+ expect(values["canvas.renderScale"]).toBe(2);
3581
+ });
3582
+
3129
3583
  it("applies empty text input values while typing and reset restores defaults", () => {
3130
3584
  renderControlsPanel();
3131
3585