@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.
- package/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +28 -0
- package/templates/runtime/contracts/component-contracts.test.ts +148 -34
- package/templates/runtime/contracts/component-contracts.ts +79 -38
- package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
- package/templates/runtime/contracts/decision-contracts.ts +19 -6
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +474 -27
- package/templates/runtime/react/controls-panel.tsx +71 -26
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
- package/templates/runtime/schema/define-toolcraft.ts +117 -247
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +41 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +657 -2
- package/templates/runtime/testing/performance.ts +789 -49
- package/templates/starter/AGENTS.md +22 -16
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
- package/templates/starter/docs/toolcraft/component-rules.md +49 -37
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +30 -10
- package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +1 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
- package/templates/starter/scripts/toolcraft-port.mjs +178 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
- package/templates/starter/src/app/starter-acceptance.ts +978 -81
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +2 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +4 -26
- package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
- 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
|
|
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
|
|
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
|
|
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: "
|
|
10
|
+
expect(starterSchema.canvas.sizing).toEqual({ mode: "editable-output" });
|
|
11
11
|
expect(starterSchema.canvas.upload).toBe(true);
|
|
12
|
-
expect(starterSchema.panels.controls).
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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 }) => {
|