@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.
- package/README.md +42 -9
- package/package.json +2 -1
- package/src/cli.mjs +4 -2
- package/src/cli.test.mjs +5 -2
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +33 -2
- package/templates/runtime/contracts/component-contracts.test.ts +175 -36
- package/templates/runtime/contracts/component-contracts.ts +90 -45
- 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 +478 -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 +41 -8
- 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 +58 -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 +57 -4
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -2
- 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 +130 -8
- 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 +19 -5
- 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,54 @@ function sourceUsesDirectStorageApi(): boolean {
|
|
|
308
317
|
}
|
|
309
318
|
|
|
310
319
|
describe("Toolcraft template app performance coverage", () => {
|
|
311
|
-
it("publishes
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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 }) => {
|