@pixel-point/toolcraft 0.0.9 → 0.0.11

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 (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. 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,36 @@ 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 a sequential Playwright browser performance fallback", () => {
312
321
  const packageJson = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")) as {
313
322
  scripts?: Record<string, string>;
314
323
  };
315
324
 
316
325
  expect(
317
326
  packageJson.scripts?.["test:browser:perf"],
318
- "Generated apps must expose a sequential browser performance gate so perf budgets are measured without parallel e2e noise.",
327
+ "Generated apps must expose a sequential Playwright fallback so perf budgets can be measured when no agent browser is available.",
319
328
  ).toBe(
320
329
  'playwright install chromium && playwright test e2e/app-performance.spec.ts --workers=1 && playwright test --grep "browser perf:" --workers=1 --pass-with-no-tests',
321
330
  );
322
331
  expect(packageJson.scripts?.["verify:quick"]).toBe("pnpm ai:check && pnpm test");
323
332
  expect(packageJson.scripts?.["verify:ui"]).toBe("pnpm test:browser");
324
333
  expect(packageJson.scripts?.["verify:perf"]).toBe("pnpm test:browser:perf");
334
+ expect(packageJson.scripts?.["verify:perf:playwright"]).toBe(
335
+ "pnpm test:browser:perf",
336
+ );
325
337
  expect(packageJson.scripts?.["verify:final"]).toBe(
326
338
  "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser",
327
339
  );
328
340
  });
329
341
 
342
+ it("declares agent browser as the preferred performance runner", () => {
343
+ expect(starterPerformance.browserCheckPolicy).toEqual({
344
+ fallbackRunner: "playwright",
345
+ fallbackWhen: ["agent-browser-unavailable", "ci"],
346
+ preferredRunner: "agent-browser",
347
+ });
348
+ });
349
+
330
350
  it("requires valid performance coverage for declared workload scenarios", () => {
331
351
  expect(validateToolcraftPerformanceCoverage(starterSchema, starterPerformance)).toEqual([]);
332
352
  });
@@ -510,7 +530,7 @@ describe("Toolcraft template app performance coverage", () => {
510
530
  }),
511
531
  ).toEqual(
512
532
  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.',
533
+ '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
534
  ]),
515
535
  );
516
536
  });
@@ -710,6 +730,13 @@ describe("Toolcraft template app performance coverage", () => {
710
730
  ).toBe(true);
711
731
  });
712
732
 
733
+ it("requires render scale scenarios to prove backing canvas pixels in browser performance tests", () => {
734
+ expect(
735
+ browserPerfContractRequiresRenderScaleBackingPixels(),
736
+ "Browser performance contract must require renderScale scenarios to assert backing canvas pixels, not only state or labels.",
737
+ ).toBe(true);
738
+ });
739
+
713
740
  it("requires procedural pixel-loop renderers to use a GPU strategy", () => {
714
741
  if (!sourceUsesCpuPixelLoop()) {
715
742
  return;
@@ -822,7 +849,7 @@ describe("Toolcraft template app performance coverage", () => {
822
849
  }
823
850
  });
824
851
 
825
- it("requires each browser performance scenario to point at a Playwright test", () => {
852
+ it("requires each browser performance scenario to point at a fallback Playwright test", () => {
826
853
  const browserTestSources = readBrowserTestSources();
827
854
 
828
855
  for (const scenario of starterPerformance.scenarios) {
@@ -832,7 +859,7 @@ describe("Toolcraft template app performance coverage", () => {
832
859
 
833
860
  expect(
834
861
  browserTestSources,
835
- `${scenario.id} must be backed by a Playwright test named "${scenario.browserTestName}".`,
862
+ `${scenario.id} must be backed by a fallback Playwright test named "${scenario.browserTestName}".`,
836
863
  ).toContain(scenario.browserTestName);
837
864
  }
838
865
  });
@@ -1262,8 +1289,6 @@ describe("Toolcraft template app performance coverage", () => {
1262
1289
  }),
1263
1290
  ).toEqual(
1264
1291
  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
1292
  "shader.gradient must have a performance scenario because every visible control can affect app responsiveness.",
1268
1293
  "shader.mode must have a performance scenario because every visible control can affect app responsiveness.",
1269
1294
  ]),
@@ -1418,6 +1443,85 @@ describe("Toolcraft template app performance coverage", () => {
1418
1443
  );
1419
1444
  });
1420
1445
 
1446
+ it("does not let load-profile smooth targets bypass required interaction budgets", () => {
1447
+ const schema = defineToolcraft({
1448
+ canvas: { enabled: true },
1449
+ panels: {
1450
+ controls: {
1451
+ sections: [
1452
+ {
1453
+ controls: {
1454
+ density: {
1455
+ defaultValue: 4,
1456
+ label: "Density",
1457
+ max: 12,
1458
+ min: 1,
1459
+ performanceReason: "Density changes rendered output size.",
1460
+ performanceRole: "workload",
1461
+ target: "render.density",
1462
+ type: "slider",
1463
+ },
1464
+ },
1465
+ title: "Render",
1466
+ },
1467
+ ],
1468
+ title: "Controls",
1469
+ },
1470
+ },
1471
+ });
1472
+
1473
+ expect(
1474
+ validateToolcraftPerformanceCoverage(schema, {
1475
+ rendererStrategy: "none",
1476
+ rendererWorkload: "none",
1477
+ scenarios: [
1478
+ {
1479
+ automated: true,
1480
+ automatedTestName: "perf: density drag stays responsive",
1481
+ browser: true,
1482
+ browserTestName: "browser perf: density drag stays responsive",
1483
+ budget: { maxFrameGapMs: 80 },
1484
+ controlLabel: "Density",
1485
+ expectedObservable: "Dragging Density updates product output without blocking the UI.",
1486
+ fixture: "density smooth target fixture",
1487
+ id: "density-drag",
1488
+ interaction: "control-drag",
1489
+ stressFixture: {
1490
+ kind: "max-value",
1491
+ loadProfile: {
1492
+ degradationStepPercent: 10,
1493
+ evidence: [
1494
+ {
1495
+ attemptedTarget: 12,
1496
+ decision: "Keep 12 as experimental and guarantee smoothness through 11.",
1497
+ measuredResult: "At density 12, maxFrameGapMs 148 exceeded the 80ms budget.",
1498
+ optimizationAttempted:
1499
+ "Cached expensive render inputs and coalesced preview work to requestAnimationFrame.",
1500
+ result: "failed",
1501
+ scenarioId: "density-drag",
1502
+ },
1503
+ ],
1504
+ hardLimit: 12,
1505
+ metric: "numeric-max",
1506
+ smoothTarget: 11,
1507
+ smoothTargetRatio: 0.9,
1508
+ target: "render.density",
1509
+ userFacingRange: "experimental-above-smooth",
1510
+ },
1511
+ reason: "Density 11 is the measured smooth target after hard-limit testing.",
1512
+ value: 11,
1513
+ },
1514
+ target: "render.density",
1515
+ values: { default: 4, max: 12, min: 1 },
1516
+ workload: true,
1517
+ },
1518
+ ],
1519
+ usesCustomRenderer: false,
1520
+ workloadTargets: ["render.density"],
1521
+ }),
1522
+ ).toContain("density-drag control-drag scenario must declare maxInteractionMs.");
1523
+ });
1524
+
1421
1525
  it("requires real browser interaction metadata for control performance scenarios", () => {
1422
1526
  expect(
1423
1527
  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 }) => {