@pixel-point/toolcraft 0.0.8 → 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 (88) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +42 -5
  4. package/src/generate.test.mjs +40 -0
  5. package/src/package-json.mjs +15 -0
  6. package/src/package-json.test.mjs +14 -1
  7. package/templates/runtime/contracts/component-contracts.test.ts +251 -47
  8. package/templates/runtime/contracts/component-contracts.ts +130 -57
  9. package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
  10. package/templates/runtime/contracts/decision-contracts.ts +22 -9
  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 +916 -108
  17. package/templates/runtime/react/controls-panel.tsx +136 -30
  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 +266 -170
  27. package/templates/runtime/schema/define-toolcraft.ts +140 -246
  28. package/templates/runtime/schema/runtime-targets.ts +21 -0
  29. package/templates/runtime/schema/types.ts +44 -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 +810 -21
  41. package/templates/runtime/testing/performance.ts +823 -53
  42. package/templates/starter/AGENTS.md +24 -18
  43. package/templates/starter/docs/toolcraft/README.md +8 -4
  44. package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
  45. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  46. package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
  47. package/templates/starter/docs/toolcraft/component-rules.md +75 -42
  48. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  49. package/templates/starter/docs/toolcraft/performance.md +37 -11
  50. package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
  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 +55 -2
  54. package/templates/starter/e2e/performance-helpers.ts +45 -0
  55. package/templates/starter/gitignore +1 -0
  56. package/templates/starter/index.html +1 -0
  57. package/templates/starter/package.json +3 -0
  58. package/templates/starter/playwright.config.ts +1 -1
  59. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  60. package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
  61. package/templates/starter/scripts/toolcraft-port.mjs +280 -0
  62. package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
  63. package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
  64. package/templates/starter/src/app/starter-acceptance.ts +1453 -97
  65. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  66. package/templates/starter/src/app/starter-performance.ts +5 -0
  67. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  68. package/templates/starter/src/app/starter-schema.ts +6 -2
  69. package/templates/starter/vite.config.ts +58 -2
  70. package/templates/ui/components/control-layout/index.tsx +8 -3
  71. package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
  72. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
  73. package/templates/ui/components/controls/color/index.ts +4 -1
  74. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  75. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
  76. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
  77. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  78. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  79. package/templates/ui/components/controls/index.ts +4 -0
  80. package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
  81. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  82. package/templates/ui/components/controls/select/select-control.tsx +8 -25
  83. package/templates/ui/components/controls/slider/slider-value.ts +0 -1
  84. package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
  85. package/templates/ui/components/controls/vector/index.ts +1 -0
  86. package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
  87. package/templates/ui/components/panel/panel-actions.tsx +1 -1
  88. package/templates/ui/components/panel/panel-section.tsx +29 -5
@@ -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)),
@@ -90,7 +90,7 @@ export function ControlInlineGroup({
90
90
  {...props}
91
91
  className={cn(
92
92
  "grid min-w-0",
93
- kind === "slider" ? "gap-4" : "gap-2",
93
+ kind === "slider" ? "gap-4" : "gap-x-2.5 gap-y-2",
94
94
  kind === "toggleParameter" && "items-end",
95
95
  className,
96
96
  )}
@@ -229,10 +229,13 @@ export function ControlItem({
229
229
  }: {
230
230
  allowCompoundDividers?: boolean;
231
231
  children: React.ReactNode;
232
- compoundDividerPlacement?: "both" | "bottom";
232
+ compoundDividerPlacement?: "both" | "bottom" | "top";
233
233
  flush?: boolean;
234
234
  }): React.JSX.Element {
235
- const showCompoundTopDivider = compoundDividerPlacement === "both";
235
+ const showCompoundTopDivider =
236
+ compoundDividerPlacement === "both" || compoundDividerPlacement === "top";
237
+ const showCompoundBottomDivider =
238
+ compoundDividerPlacement === "both" || compoundDividerPlacement === "bottom";
236
239
 
237
240
  return (
238
241
  <div
@@ -240,11 +243,13 @@ export function ControlItem({
240
243
  "min-w-0",
241
244
  !flush && "px-3",
242
245
  allowCompoundDividers &&
246
+ showCompoundBottomDivider &&
243
247
  "has-data-[control-section-divider=compound]:relative has-data-[control-section-divider=compound]:pb-[18px] has-data-[control-section-divider=compound]:after:absolute has-data-[control-section-divider=compound]:after:bottom-0 has-data-[control-section-divider=compound]:after:h-px has-data-[control-section-divider=compound]:after:bg-[color:color-mix(in_oklab,var(--border)_8%,transparent)]",
244
248
  allowCompoundDividers &&
245
249
  showCompoundTopDivider &&
246
250
  "has-data-[control-section-divider=compound]:pt-[18px] has-data-[control-section-divider=compound]:before:absolute has-data-[control-section-divider=compound]:before:top-0 has-data-[control-section-divider=compound]:before:h-px has-data-[control-section-divider=compound]:before:bg-[color:color-mix(in_oklab,var(--border)_8%,transparent)]",
247
251
  allowCompoundDividers &&
252
+ showCompoundBottomDivider &&
248
253
  (flush
249
254
  ? "has-data-[control-section-divider=compound]:after:inset-x-0"
250
255
  : "has-data-[control-section-divider=compound]:after:inset-x-3"),
@@ -2,18 +2,23 @@
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";
16
20
  import { ControlFieldLabel } from "../../control-layout";
21
+ import { cn } from "../../../lib/utils";
17
22
 
18
23
  export type ActionControlIconName =
19
24
  | "check"
@@ -22,8 +27,12 @@ export type ActionControlIconName =
22
27
  | "download-simple"
23
28
  | "eraser"
24
29
  | "export"
30
+ | "flip-horizontal"
31
+ | "flip-vertical"
32
+ | "rotate-cw"
25
33
  | "rotate-ccw"
26
34
  | "shuffle"
35
+ | "upload-simple"
27
36
  | "wand-sparkles";
28
37
 
29
38
  const actionIconComponents = {
@@ -33,12 +42,17 @@ const actionIconComponents = {
33
42
  "download-simple": DownloadSimpleIcon,
34
43
  eraser: EraserIcon,
35
44
  export: ExportIcon,
45
+ "flip-horizontal": FlipHorizontalIcon,
46
+ "flip-vertical": FlipVerticalIcon,
47
+ "rotate-cw": ArrowClockwiseIcon,
36
48
  "rotate-ccw": ArrowCounterClockwiseIcon,
37
49
  shuffle: ShuffleIcon,
50
+ "upload-simple": UploadSimpleIcon,
38
51
  "wand-sparkles": MagicWandIcon,
39
52
  } as const;
40
53
 
41
54
  export type ActionControlObjectOption = {
55
+ ariaLabel?: string;
42
56
  children?: ReactNode;
43
57
  label?: string;
44
58
  value: string;
@@ -51,6 +65,12 @@ function getActionValue(action: ActionControlOption): string {
51
65
  return typeof action === "string" ? action : action.value;
52
66
  }
53
67
 
68
+ function getActionLabel(action: ActionControlOption): string {
69
+ return typeof action === "string"
70
+ ? action
71
+ : (action.ariaLabel ?? action.label ?? action.value);
72
+ }
73
+
54
74
  function getActionContent(action: ActionControlOption): ReactNode {
55
75
  if (typeof action === "string") {
56
76
  return action;
@@ -77,6 +97,10 @@ function getActionAriaLabel(
77
97
  action: ActionControlOption,
78
98
  content: ReactNode,
79
99
  ): string | undefined {
100
+ if (typeof action !== "string" && action.ariaLabel) {
101
+ return action.ariaLabel;
102
+ }
103
+
80
104
  if (typeof content === "string") {
81
105
  return undefined;
82
106
  }
@@ -86,23 +110,41 @@ function getActionAriaLabel(
86
110
 
87
111
  export type ActionsControlProps = {
88
112
  actions: readonly ActionControlOption[];
113
+ buttonColumns?: 1 | 2 | 3 | 4;
89
114
  name: string;
90
115
  onAction?: (value: string) => void;
116
+ showActionLabels?: boolean;
91
117
  showLabel?: boolean;
92
118
  };
93
119
 
94
120
  export function ActionsControl({
95
121
  actions,
122
+ buttonColumns,
96
123
  name,
97
124
  onAction,
125
+ showActionLabels = true,
98
126
  showLabel = true,
99
127
  }: ActionsControlProps): React.JSX.Element {
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";
141
+
100
142
  return (
101
143
  <Field
102
144
  aria-label={showLabel ? undefined : name}
103
- className="h-fit min-w-0 flex-wrap items-center justify-between gap-y-2!"
145
+ className="h-fit min-w-0 gap-2"
104
146
  data-slot="actions-control"
105
- orientation="horizontal"
147
+ orientation="vertical"
106
148
  >
107
149
  {showLabel ? (
108
150
  <ControlFieldLabel className="min-w-0" textClassName="min-w-0 truncate">
@@ -110,24 +152,33 @@ export function ActionsControl({
110
152
  </ControlFieldLabel>
111
153
  ) : null}
112
154
  <div
113
- className="flex max-w-full shrink-0 flex-wrap items-center justify-start gap-1.5"
155
+ className={cn(
156
+ "grid max-w-full gap-1.5",
157
+ columnClass,
158
+ )}
159
+ data-actions-count={actions.length}
160
+ data-actions-columns={buttonColumns ?? (hasSingleAction ? 1 : 2)}
114
161
  data-slot="actions-control-buttons"
115
162
  >
116
163
  {actions.map((action) => {
117
164
  const actionContent = getActionContent(action);
165
+ const actionLabel = getActionLabel(action);
118
166
  const actionValue = getActionValue(action);
119
167
 
120
168
  return (
121
169
  <Button
122
- aria-label={getActionAriaLabel(action, actionContent)}
170
+ aria-label={
171
+ showActionLabels ? getActionAriaLabel(action, actionContent) : actionLabel
172
+ }
123
173
  key={actionValue}
124
174
  onClick={() => onAction?.(actionValue)}
175
+ className="w-full"
125
176
  size="sm"
126
177
  type="button"
127
178
  variant="outline"
128
179
  >
129
180
  {getActionIcon(action)}
130
- {actionContent}
181
+ {showActionLabels ? actionContent : null}
131
182
  </Button>
132
183
  );
133
184
  })}
@@ -14,12 +14,14 @@ export type CodeTextareaControlProps = {
14
14
  defaultValue?: string;
15
15
  name: string;
16
16
  onValueChange?: ControlValueChangeHandler<string>;
17
+ showLabel?: boolean;
17
18
  value: string;
18
19
  };
19
20
 
20
21
  export function CodeTextareaControl({
21
22
  name,
22
23
  onValueChange,
24
+ showLabel = true,
23
25
  value,
24
26
  }: CodeTextareaControlProps): React.JSX.Element {
25
27
  const [currentValue, setCurrentValue] = React.useState(value);
@@ -61,9 +63,11 @@ export function CodeTextareaControl({
61
63
 
62
64
  return (
63
65
  <Field className="min-w-0 gap-2">
64
- <div className="flex items-center">
65
- <ControlFieldLabel>{name}</ControlFieldLabel>
66
- </div>
66
+ {showLabel ? (
67
+ <div className="flex items-center">
68
+ <ControlFieldLabel>{name}</ControlFieldLabel>
69
+ </div>
70
+ ) : null}
67
71
  <Textarea
68
72
  aria-label={name}
69
73
  className="max-h-[calc(12lh+12px)] min-h-[84px] overflow-y-auto font-mono"
@@ -18,7 +18,10 @@ export {
18
18
  type ColorFormatMode,
19
19
  type ColorSurfaceModel,
20
20
  } from "./style-guide-color-picker-channel-utils";
21
- export { DEFAULT_COLOR_FORMAT_MODE } from "./style-guide-color-picker";
21
+ export {
22
+ DEFAULT_COLOR_FORMAT_MODE,
23
+ StyleGuideColorPicker,
24
+ } from "./style-guide-color-picker";
22
25
  export {
23
26
  getColorSurfaceSliderConfig,
24
27
  getColorSurfaceStyle,