@pixel-point/toolcraft 0.0.9 → 0.0.12

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 (77) hide show
  1. package/README.md +42 -9
  2. package/package.json +2 -1
  3. package/src/cli.mjs +4 -2
  4. package/src/cli.test.mjs +5 -2
  5. package/src/generate.mjs +14 -6
  6. package/src/generate.test.mjs +33 -2
  7. package/templates/runtime/contracts/component-contracts.test.ts +175 -36
  8. package/templates/runtime/contracts/component-contracts.ts +90 -45
  9. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  11. package/templates/runtime/export/export.test.ts +63 -0
  12. package/templates/runtime/export/export.ts +55 -0
  13. package/templates/runtime/index.ts +1 -0
  14. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  15. package/templates/runtime/react/canvas-shell.tsx +31 -9
  16. package/templates/runtime/react/controls-panel.test.tsx +478 -27
  17. package/templates/runtime/react/controls-panel.tsx +71 -26
  18. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  19. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  20. package/templates/runtime/react/settings-transfer.ts +6 -1
  21. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  22. package/templates/runtime/react/timeline-panel.tsx +44 -7
  23. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  24. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  25. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  26. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  27. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +41 -0
  30. package/templates/runtime/state/create-template-state.test.ts +156 -0
  31. package/templates/runtime/state/create-template-state.ts +38 -8
  32. package/templates/runtime/state/media-defaults.ts +105 -0
  33. package/templates/runtime/state/persistence.test.ts +58 -0
  34. package/templates/runtime/state/persistence.ts +105 -1
  35. package/templates/runtime/state/reducer.test.ts +280 -4
  36. package/templates/runtime/state/reducer.ts +195 -9
  37. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  38. package/templates/runtime/state/timeline-loop.ts +35 -0
  39. package/templates/runtime/state/types.ts +27 -0
  40. package/templates/runtime/testing/performance.test.ts +657 -2
  41. package/templates/runtime/testing/performance.ts +789 -49
  42. package/templates/starter/AGENTS.md +22 -16
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +58 -37
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +30 -10
  50. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  51. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  52. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  53. package/templates/starter/e2e/app-performance.spec.ts +57 -4
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/index.html +1 -0
  56. package/templates/starter/package.json +3 -2
  57. package/templates/starter/playwright.config.ts +1 -1
  58. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  59. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  60. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  61. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  62. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  63. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  64. package/templates/starter/src/app/starter-performance.test.ts +130 -8
  65. package/templates/starter/src/app/starter-performance.ts +5 -0
  66. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  67. package/templates/starter/src/app/starter-schema.ts +6 -2
  68. package/templates/starter/vite.config.ts +58 -2
  69. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  70. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  71. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  72. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  73. package/templates/ui/components/controls/index.ts +2 -0
  74. package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
  75. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  76. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  77. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -295,6 +295,15 @@ function browserTestsAssertNativePreviewResolution(): boolean {
295
295
  );
296
296
  }
297
297
 
298
+ function browserPerfContractRequiresRenderScaleBackingPixels(): boolean {
299
+ const browserTestSources = readBrowserTestSources();
300
+
301
+ return (
302
+ /scenarioUsesRenderScaleFixture/.test(browserTestSources) &&
303
+ /expectToolcraftCanvasBackingPixelsForRenderScale/.test(browserTestSources)
304
+ );
305
+ }
306
+
298
307
  function sourceUsesAnimationFrameWithoutCleanup(): boolean {
299
308
  const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
300
309
 
@@ -308,25 +317,54 @@ function sourceUsesDirectStorageApi(): boolean {
308
317
  }
309
318
 
310
319
  describe("Toolcraft template app performance coverage", () => {
311
- it("publishes a sequential browser performance gate", () => {
320
+ it("publishes separate browser acceptance and performance fallback gates", () => {
312
321
  const packageJson = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")) as {
313
322
  scripts?: Record<string, string>;
314
323
  };
315
324
 
325
+ expect(
326
+ packageJson.scripts?.["test:browser"],
327
+ "Generated apps must keep full performance scenarios out of the default browser acceptance gate.",
328
+ ).toBe('playwright install chromium && playwright test --grep-invert "browser perf:"');
316
329
  expect(
317
330
  packageJson.scripts?.["test:browser:perf"],
318
- "Generated apps must expose a sequential browser performance gate so perf budgets are measured without parallel e2e noise.",
331
+ "Generated apps must expose a sequential Playwright fallback so perf budgets can be measured when no agent browser is available.",
319
332
  ).toBe(
320
- 'playwright install chromium && playwright test e2e/app-performance.spec.ts --workers=1 && playwright test --grep "browser perf:" --workers=1 --pass-with-no-tests',
333
+ 'playwright install chromium && playwright test --grep "browser perf:" --workers=1 --pass-with-no-tests',
321
334
  );
322
335
  expect(packageJson.scripts?.["verify:quick"]).toBe("pnpm ai:check && pnpm test");
323
336
  expect(packageJson.scripts?.["verify:ui"]).toBe("pnpm test:browser");
324
337
  expect(packageJson.scripts?.["verify:perf"]).toBe("pnpm test:browser:perf");
338
+ expect(packageJson.scripts?.["verify:perf:playwright"]).toBe(
339
+ "pnpm test:browser:perf",
340
+ );
325
341
  expect(packageJson.scripts?.["verify:final"]).toBe(
326
342
  "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser",
327
343
  );
328
344
  });
329
345
 
346
+ it("keeps the Playwright performance audit inside the browser perf tag", () => {
347
+ const source = readFileSync(join(e2eDir, "app-performance.spec.ts"), "utf8");
348
+ const testNames = [...source.matchAll(/test\(\s*(["'`])([^"'`]+)\1/g)].map(
349
+ (match) => match[2],
350
+ );
351
+
352
+ expect(source).not.toContain("TOOLCRAFT_PERF_CHECK");
353
+ expect(testNames.length).toBeGreaterThan(0);
354
+ expect(
355
+ testNames.every((name) => name.includes("browser perf:")),
356
+ `app-performance.spec.ts tests must all be tagged for the dedicated perf checkpoint: ${testNames.join(", ")}`,
357
+ ).toBe(true);
358
+ });
359
+
360
+ it("declares agent browser as the preferred performance runner", () => {
361
+ expect(starterPerformance.browserCheckPolicy).toEqual({
362
+ fallbackRunner: "playwright",
363
+ fallbackWhen: ["agent-browser-unavailable", "ci"],
364
+ preferredRunner: "agent-browser",
365
+ });
366
+ });
367
+
330
368
  it("requires valid performance coverage for declared workload scenarios", () => {
331
369
  expect(validateToolcraftPerformanceCoverage(starterSchema, starterPerformance)).toEqual([]);
332
370
  });
@@ -510,7 +548,7 @@ describe("Toolcraft template app performance coverage", () => {
510
548
  }),
511
549
  ).toEqual(
512
550
  expect.arrayContaining([
513
- 'rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "canvas-2d". Keeping a CPU renderer requires measured WebGL/WebGPU evidence in rendererTechnique.whyNotAlternativeStrategies or performanceRisks.',
551
+ 'rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "canvas-2d". Keeping a CPU renderer requires rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison.',
514
552
  ]),
515
553
  );
516
554
  });
@@ -710,6 +748,13 @@ describe("Toolcraft template app performance coverage", () => {
710
748
  ).toBe(true);
711
749
  });
712
750
 
751
+ it("requires render scale scenarios to prove backing canvas pixels in browser performance tests", () => {
752
+ expect(
753
+ browserPerfContractRequiresRenderScaleBackingPixels(),
754
+ "Browser performance contract must require renderScale scenarios to assert backing canvas pixels, not only state or labels.",
755
+ ).toBe(true);
756
+ });
757
+
713
758
  it("requires procedural pixel-loop renderers to use a GPU strategy", () => {
714
759
  if (!sourceUsesCpuPixelLoop()) {
715
760
  return;
@@ -822,7 +867,7 @@ describe("Toolcraft template app performance coverage", () => {
822
867
  }
823
868
  });
824
869
 
825
- it("requires each browser performance scenario to point at a Playwright test", () => {
870
+ it("requires each browser performance scenario to point at a fallback Playwright test", () => {
826
871
  const browserTestSources = readBrowserTestSources();
827
872
 
828
873
  for (const scenario of starterPerformance.scenarios) {
@@ -832,7 +877,7 @@ describe("Toolcraft template app performance coverage", () => {
832
877
 
833
878
  expect(
834
879
  browserTestSources,
835
- `${scenario.id} must be backed by a Playwright test named "${scenario.browserTestName}".`,
880
+ `${scenario.id} must be backed by a fallback Playwright test named "${scenario.browserTestName}".`,
836
881
  ).toContain(scenario.browserTestName);
837
882
  }
838
883
  });
@@ -1262,8 +1307,6 @@ describe("Toolcraft template app performance coverage", () => {
1262
1307
  }),
1263
1308
  ).toEqual(
1264
1309
  expect.arrayContaining([
1265
- "canvas.size.width must have a performance scenario because every visible control can affect app responsiveness.",
1266
- "canvas.size.height must have a performance scenario because every visible control can affect app responsiveness.",
1267
1310
  "shader.gradient must have a performance scenario because every visible control can affect app responsiveness.",
1268
1311
  "shader.mode must have a performance scenario because every visible control can affect app responsiveness.",
1269
1312
  ]),
@@ -1418,6 +1461,85 @@ describe("Toolcraft template app performance coverage", () => {
1418
1461
  );
1419
1462
  });
1420
1463
 
1464
+ it("does not let load-profile smooth targets bypass required interaction budgets", () => {
1465
+ const schema = defineToolcraft({
1466
+ canvas: { enabled: true },
1467
+ panels: {
1468
+ controls: {
1469
+ sections: [
1470
+ {
1471
+ controls: {
1472
+ density: {
1473
+ defaultValue: 4,
1474
+ label: "Density",
1475
+ max: 12,
1476
+ min: 1,
1477
+ performanceReason: "Density changes rendered output size.",
1478
+ performanceRole: "workload",
1479
+ target: "render.density",
1480
+ type: "slider",
1481
+ },
1482
+ },
1483
+ title: "Render",
1484
+ },
1485
+ ],
1486
+ title: "Controls",
1487
+ },
1488
+ },
1489
+ });
1490
+
1491
+ expect(
1492
+ validateToolcraftPerformanceCoverage(schema, {
1493
+ rendererStrategy: "none",
1494
+ rendererWorkload: "none",
1495
+ scenarios: [
1496
+ {
1497
+ automated: true,
1498
+ automatedTestName: "perf: density drag stays responsive",
1499
+ browser: true,
1500
+ browserTestName: "browser perf: density drag stays responsive",
1501
+ budget: { maxFrameGapMs: 80 },
1502
+ controlLabel: "Density",
1503
+ expectedObservable: "Dragging Density updates product output without blocking the UI.",
1504
+ fixture: "density smooth target fixture",
1505
+ id: "density-drag",
1506
+ interaction: "control-drag",
1507
+ stressFixture: {
1508
+ kind: "max-value",
1509
+ loadProfile: {
1510
+ degradationStepPercent: 10,
1511
+ evidence: [
1512
+ {
1513
+ attemptedTarget: 12,
1514
+ decision: "Keep 12 as experimental and guarantee smoothness through 11.",
1515
+ measuredResult: "At density 12, maxFrameGapMs 148 exceeded the 80ms budget.",
1516
+ optimizationAttempted:
1517
+ "Cached expensive render inputs and coalesced preview work to requestAnimationFrame.",
1518
+ result: "failed",
1519
+ scenarioId: "density-drag",
1520
+ },
1521
+ ],
1522
+ hardLimit: 12,
1523
+ metric: "numeric-max",
1524
+ smoothTarget: 11,
1525
+ smoothTargetRatio: 0.9,
1526
+ target: "render.density",
1527
+ userFacingRange: "experimental-above-smooth",
1528
+ },
1529
+ reason: "Density 11 is the measured smooth target after hard-limit testing.",
1530
+ value: 11,
1531
+ },
1532
+ target: "render.density",
1533
+ values: { default: 4, max: 12, min: 1 },
1534
+ workload: true,
1535
+ },
1536
+ ],
1537
+ usesCustomRenderer: false,
1538
+ workloadTargets: ["render.density"],
1539
+ }),
1540
+ ).toContain("density-drag control-drag scenario must declare maxInteractionMs.");
1541
+ });
1542
+
1421
1543
  it("requires real browser interaction metadata for control performance scenarios", () => {
1422
1544
  expect(
1423
1545
  validateToolcraftPerformanceCoverage(starterSchema, {
@@ -4,6 +4,11 @@ import {
4
4
  } from "@repo/toolcraft-runtime";
5
5
 
6
6
  export const starterPerformance: ToolcraftPerformanceConfig = defineToolcraftPerformance({
7
+ browserCheckPolicy: {
8
+ fallbackRunner: "playwright",
9
+ fallbackWhen: ["agent-browser-unavailable", "ci"],
10
+ preferredRunner: "agent-browser",
11
+ },
7
12
  rendererStrategy: "none",
8
13
  rendererWorkload: "none",
9
14
  scenarios: [],
@@ -7,9 +7,25 @@ describe("starterSchema", () => {
7
7
  it("publishes the base Toolcraft template app contract for AI assembly", () => {
8
8
  expect(starterSchema.canvas.draggable).toBe(true);
9
9
  expect(starterSchema.canvas.enabled).toBe(true);
10
- expect(starterSchema.canvas.sizing).toEqual({ mode: "intrinsic-media" });
10
+ expect(starterSchema.canvas.sizing).toEqual({ mode: "editable-output" });
11
11
  expect(starterSchema.canvas.upload).toBe(true);
12
- expect(starterSchema.panels.controls).toBeUndefined();
12
+ expect(starterSchema.panels.controls?.sections[0]?.title).toBe("Setup");
13
+ expect(starterSchema.panels.controls?.sections[0]?.controls.settingsTransfer).toMatchObject({
14
+ target: "runtime.settingsTransfer",
15
+ type: "settingsTransfer",
16
+ });
17
+ expect(starterSchema.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
18
+ target: "canvas.aspectRatio",
19
+ type: "aspectRatio",
20
+ });
21
+ expect(starterSchema.panels.controls?.sections[0]?.controls.canvasWidth).toMatchObject({
22
+ target: "canvas.size.width",
23
+ type: "text",
24
+ });
25
+ expect(starterSchema.panels.controls?.sections[0]?.controls.canvasHeight).toMatchObject({
26
+ target: "canvas.size.height",
27
+ type: "text",
28
+ });
13
29
  expect(starterSchema.panels.layers).toBeUndefined();
14
30
  expect(starterSchema.panels.timeline).toBeUndefined();
15
31
  expect(starterSchema.toolbar).toEqual({
@@ -20,38 +36,47 @@ describe("starterSchema", () => {
20
36
  });
21
37
  expect(starterSchema.assembly.components).toEqual([
22
38
  "canvas",
39
+ "controlsPanel",
23
40
  "toolbar",
24
41
  ]);
25
42
  expect(starterSchema.assembly.capabilities).toEqual(
26
43
  expect.arrayContaining([
27
44
  "canvas.draggable",
45
+ "canvas.editableSize",
28
46
  "canvas.upload",
47
+ "controls.defaults",
48
+ "controls.panel",
29
49
  "toolbar.history",
30
50
  "toolbar.radar",
31
51
  "toolbar.theme",
32
52
  "toolbar.zoom",
33
53
  ]),
34
54
  );
35
- expect(starterSchema.assembly.capabilities).not.toContain("controls.defaults");
36
55
  expect(starterSchema.assembly.capabilities).not.toContain("timeline.playback");
37
56
  expect(starterSchema.assembly.capabilities).not.toContain("timeline.keyframes");
38
57
  expect(starterSchema.assembly.commands).toEqual(
39
58
  expect.arrayContaining([
40
59
  "canvas.center",
60
+ "canvas.setSize",
41
61
  "canvas.setViewport",
42
62
  "canvas.zoomIn",
63
+ "controls.reset",
64
+ "controls.setValue",
43
65
  "history.undo",
44
66
  "media.delete",
45
67
  "media.import",
46
68
  ]),
47
69
  );
48
- expect(starterSchema.assembly.commands).not.toContain("controls.reset");
49
- expect(starterSchema.assembly.commands).not.toContain("controls.setValue");
50
70
  expect(starterSchema.assembly.commands).not.toContain("timeline.setCurrentTime");
51
71
  });
52
72
 
53
- it("starts without product-specific panels or controls", () => {
54
- expect(starterSchema.panels.controls).toBeUndefined();
73
+ it("starts with runtime setup but without product-specific panels or controls", () => {
74
+ const productSections =
75
+ starterSchema.panels.controls?.sections.filter((section) => section.title !== "Setup") ??
76
+ [];
77
+
78
+ expect(starterSchema.panels.controls?.sections[0]?.title).toBe("Setup");
79
+ expect(productSections).toEqual([]);
55
80
  expect(starterSchema.panels.layers).toBeUndefined();
56
81
  expect(starterSchema.panels.timeline).toBeUndefined();
57
82
  });
@@ -3,10 +3,14 @@ import { defineToolcraft } from "@repo/toolcraft-runtime";
3
3
  export const starterSchema = defineToolcraft({
4
4
  canvas: {
5
5
  enabled: true,
6
- sizing: { mode: "intrinsic-media" },
7
6
  upload: true,
8
7
  },
9
- panels: {},
8
+ panels: {
9
+ controls: {
10
+ sections: [],
11
+ title: "Controls",
12
+ },
13
+ },
10
14
  toolbar: {
11
15
  history: true,
12
16
  radar: true,
@@ -1,10 +1,66 @@
1
1
  import tailwindcss from "@tailwindcss/vite";
2
2
  import react from "@vitejs/plugin-react";
3
+ import fs from "node:fs/promises";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import path from "node:path";
3
6
  import { fileURLToPath } from "node:url";
4
- import { defineConfig } from "vite";
7
+ import { defineConfig, type Plugin } from "vite";
8
+
9
+ const rootDir = fileURLToPath(new URL(".", import.meta.url));
10
+ const toolcraftServerIdentityPath = "/.toolcraft/server-identity.json";
11
+
12
+ function readHtmlAppTitle(source: string): string | null {
13
+ const appTitleMeta = source
14
+ .match(/<meta\b[^>]*>/gi)
15
+ ?.find((tag) => tag.match(/\bname\s*=\s*["']toolcraft-app-title["']/i));
16
+
17
+ return appTitleMeta?.match(/\bcontent\s*=\s*["']([^"']*)["']/i)?.[1] ?? null;
18
+ }
19
+
20
+ async function createToolcraftServerIdentity() {
21
+ const indexSource = await fs.readFile(path.join(rootDir, "index.html"), "utf8");
22
+
23
+ return {
24
+ appTitle: readHtmlAppTitle(indexSource),
25
+ root: await fs.realpath(rootDir),
26
+ };
27
+ }
28
+
29
+ function toolcraftServerIdentityPlugin(): Plugin {
30
+ function handleIdentityRequest(
31
+ request: IncomingMessage,
32
+ response: ServerResponse,
33
+ next: (error?: unknown) => void,
34
+ ) {
35
+ const requestUrl = new URL(request.url ?? "/", "http://localhost");
36
+
37
+ if (requestUrl.pathname !== toolcraftServerIdentityPath) {
38
+ next();
39
+ return;
40
+ }
41
+
42
+ createToolcraftServerIdentity()
43
+ .then((identity) => {
44
+ response.setHeader("content-type", "application/json; charset=utf-8");
45
+ response.setHeader("cache-control", "no-store");
46
+ response.end(JSON.stringify(identity));
47
+ })
48
+ .catch(next);
49
+ }
50
+
51
+ return {
52
+ name: "toolcraft-server-identity",
53
+ configurePreviewServer(server) {
54
+ server.middlewares.use(handleIdentityRequest);
55
+ },
56
+ configureServer(server) {
57
+ server.middlewares.use(handleIdentityRequest);
58
+ },
59
+ };
60
+ }
5
61
 
6
62
  export default defineConfig({
7
- plugins: [tailwindcss(), react()],
63
+ plugins: [toolcraftServerIdentityPlugin(), tailwindcss(), react()],
8
64
  resolve: {
9
65
  alias: {
10
66
  "@": fileURLToPath(new URL("./src", import.meta.url)),
@@ -2,14 +2,18 @@
2
2
 
3
3
  import type { ReactElement, ReactNode } from "react";
4
4
  import {
5
+ ArrowClockwiseIcon,
5
6
  ArrowCounterClockwiseIcon,
6
7
  CheckIcon,
7
8
  CopySimpleIcon,
8
9
  DownloadSimpleIcon,
9
10
  EraserIcon,
10
11
  ExportIcon,
12
+ FlipHorizontalIcon,
13
+ FlipVerticalIcon,
11
14
  MagicWandIcon,
12
15
  ShuffleIcon,
16
+ UploadSimpleIcon,
13
17
  } from "@phosphor-icons/react";
14
18
 
15
19
  import { Button, Field } from "../../primitives";
@@ -23,8 +27,12 @@ export type ActionControlIconName =
23
27
  | "download-simple"
24
28
  | "eraser"
25
29
  | "export"
30
+ | "flip-horizontal"
31
+ | "flip-vertical"
32
+ | "rotate-cw"
26
33
  | "rotate-ccw"
27
34
  | "shuffle"
35
+ | "upload-simple"
28
36
  | "wand-sparkles";
29
37
 
30
38
  const actionIconComponents = {
@@ -34,12 +42,17 @@ const actionIconComponents = {
34
42
  "download-simple": DownloadSimpleIcon,
35
43
  eraser: EraserIcon,
36
44
  export: ExportIcon,
45
+ "flip-horizontal": FlipHorizontalIcon,
46
+ "flip-vertical": FlipVerticalIcon,
47
+ "rotate-cw": ArrowClockwiseIcon,
37
48
  "rotate-ccw": ArrowCounterClockwiseIcon,
38
49
  shuffle: ShuffleIcon,
50
+ "upload-simple": UploadSimpleIcon,
39
51
  "wand-sparkles": MagicWandIcon,
40
52
  } as const;
41
53
 
42
54
  export type ActionControlObjectOption = {
55
+ ariaLabel?: string;
43
56
  children?: ReactNode;
44
57
  label?: string;
45
58
  value: string;
@@ -52,6 +65,12 @@ function getActionValue(action: ActionControlOption): string {
52
65
  return typeof action === "string" ? action : action.value;
53
66
  }
54
67
 
68
+ function getActionLabel(action: ActionControlOption): string {
69
+ return typeof action === "string"
70
+ ? action
71
+ : (action.ariaLabel ?? action.label ?? action.value);
72
+ }
73
+
55
74
  function getActionContent(action: ActionControlOption): ReactNode {
56
75
  if (typeof action === "string") {
57
76
  return action;
@@ -78,6 +97,10 @@ function getActionAriaLabel(
78
97
  action: ActionControlOption,
79
98
  content: ReactNode,
80
99
  ): string | undefined {
100
+ if (typeof action !== "string" && action.ariaLabel) {
101
+ return action.ariaLabel;
102
+ }
103
+
81
104
  if (typeof content === "string") {
82
105
  return undefined;
83
106
  }
@@ -87,18 +110,34 @@ function getActionAriaLabel(
87
110
 
88
111
  export type ActionsControlProps = {
89
112
  actions: readonly ActionControlOption[];
113
+ buttonColumns?: 1 | 2 | 3 | 4;
90
114
  name: string;
91
115
  onAction?: (value: string) => void;
116
+ showActionLabels?: boolean;
92
117
  showLabel?: boolean;
93
118
  };
94
119
 
95
120
  export function ActionsControl({
96
121
  actions,
122
+ buttonColumns,
97
123
  name,
98
124
  onAction,
125
+ showActionLabels = true,
99
126
  showLabel = true,
100
127
  }: ActionsControlProps): React.JSX.Element {
101
128
  const hasSingleAction = actions.length === 1;
129
+ const columnClass =
130
+ buttonColumns === 4
131
+ ? "w-full grid-cols-4"
132
+ : buttonColumns === 3
133
+ ? "w-full grid-cols-3"
134
+ : buttonColumns === 2
135
+ ? "w-full grid-cols-2"
136
+ : buttonColumns === 1
137
+ ? "w-full grid-cols-1"
138
+ : hasSingleAction
139
+ ? "w-1/2 grid-cols-1"
140
+ : "w-full grid-cols-2";
102
141
 
103
142
  return (
104
143
  <Field
@@ -115,18 +154,22 @@ export function ActionsControl({
115
154
  <div
116
155
  className={cn(
117
156
  "grid max-w-full gap-1.5",
118
- hasSingleAction ? "w-1/2 grid-cols-1" : "w-full grid-cols-2",
157
+ columnClass,
119
158
  )}
120
159
  data-actions-count={actions.length}
160
+ data-actions-columns={buttonColumns ?? (hasSingleAction ? 1 : 2)}
121
161
  data-slot="actions-control-buttons"
122
162
  >
123
163
  {actions.map((action) => {
124
164
  const actionContent = getActionContent(action);
165
+ const actionLabel = getActionLabel(action);
125
166
  const actionValue = getActionValue(action);
126
167
 
127
168
  return (
128
169
  <Button
129
- aria-label={getActionAriaLabel(action, actionContent)}
170
+ aria-label={
171
+ showActionLabels ? getActionAriaLabel(action, actionContent) : actionLabel
172
+ }
130
173
  key={actionValue}
131
174
  onClick={() => onAction?.(actionValue)}
132
175
  className="w-full"
@@ -135,7 +178,7 @@ export function ActionsControl({
135
178
  variant="outline"
136
179
  >
137
180
  {getActionIcon(action)}
138
- {actionContent}
181
+ {showActionLabels ? actionContent : null}
139
182
  </Button>
140
183
  );
141
184
  })}
@@ -3,6 +3,10 @@
3
3
  import * as React from "react";
4
4
 
5
5
  import { cn } from "../../../lib/utils";
6
+ import {
7
+ createControlHistoryGroupId,
8
+ type ControlChangeMeta,
9
+ } from "../control-types";
6
10
  import {
7
11
  PALETTE_SHADE_STEPS,
8
12
  STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS,
@@ -21,7 +25,7 @@ export {
21
25
  };
22
26
  export type { PaletteColorFamily, PaletteControlValue, PaletteShadeStep };
23
27
 
24
- export type PaletteControlChangeMeta = {
28
+ export type PaletteControlChangeMeta = ControlChangeMeta & {
25
29
  stage: "live" | "commit";
26
30
  hex: string;
27
31
  };
@@ -91,6 +95,7 @@ export function PaletteControl({
91
95
  const pendingPersistRef = React.useRef<PaletteControlValue | null>(null);
92
96
  const clickCommitTimeoutRef = React.useRef<number | null>(null);
93
97
  const persistTimeoutRef = React.useRef<number | null>(null);
98
+ const liveHistoryGroupRef = React.useRef<string | null>(null);
94
99
  const isInteractingRef = React.useRef(false);
95
100
  const shadeTrackRef = React.useRef<HTMLDivElement | null>(null);
96
101
  const onValueChangeRef = React.useRef(onValueChange);
@@ -148,12 +153,34 @@ export function PaletteControl({
148
153
  persistTimeoutRef.current = null;
149
154
  }, []);
150
155
 
156
+ const getLiveHistoryMeta = React.useCallback((): ControlChangeMeta => {
157
+ liveHistoryGroupRef.current ??= createControlHistoryGroupId("palette");
158
+
159
+ return {
160
+ history: "merge",
161
+ historyGroup: liveHistoryGroupRef.current,
162
+ };
163
+ }, []);
164
+
165
+ const finishLiveHistoryGroup = React.useCallback(() => {
166
+ liveHistoryGroupRef.current = null;
167
+ }, []);
168
+
151
169
  const emitChange = React.useCallback(
152
170
  (nextValue: PaletteControlValue, stage: PaletteControlChangeMeta["stage"]) => {
153
171
  const hex = getPaletteHex(nextValue);
154
- onValueChangeRef.current?.(nextValue, { stage, hex });
172
+ const historyMeta =
173
+ stage === "live" || liveHistoryGroupRef.current
174
+ ? getLiveHistoryMeta()
175
+ : undefined;
176
+
177
+ onValueChangeRef.current?.(nextValue, {
178
+ ...historyMeta,
179
+ stage,
180
+ hex,
181
+ });
155
182
  },
156
- [],
183
+ [getLiveHistoryMeta],
157
184
  );
158
185
 
159
186
  const syncOptimisticValue = React.useCallback((nextValue: PaletteControlValue) => {
@@ -174,6 +201,7 @@ export function PaletteControl({
174
201
  if (options?.immediate) {
175
202
  pendingPersistRef.current = null;
176
203
  onCommitRef.current?.(pendingPersist, getPaletteHex(pendingPersist));
204
+ finishLiveHistoryGroup();
177
205
  setInteractionState(false);
178
206
  return true;
179
207
  }
@@ -184,17 +212,19 @@ export function PaletteControl({
184
212
  pendingPersistRef.current = null;
185
213
 
186
214
  if (!nextPersist) {
215
+ finishLiveHistoryGroup();
187
216
  setInteractionState(false);
188
217
  return;
189
218
  }
190
219
 
191
220
  onCommitRef.current?.(nextPersist, getPaletteHex(nextPersist));
221
+ finishLiveHistoryGroup();
192
222
  setInteractionState(false);
193
223
  }, PERSIST_SETTLE_MS);
194
224
 
195
225
  return true;
196
226
  },
197
- [clearPersistTimeout, setInteractionState],
227
+ [clearPersistTimeout, finishLiveHistoryGroup, setInteractionState],
198
228
  );
199
229
 
200
230
  const flushPendingCommit = React.useCallback((options?: { persistImmediately?: boolean }) => {