@pixel-point/toolcraft 0.0.8 → 0.0.9
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/package.json +1 -1
- package/src/generate.mjs +34 -5
- package/src/generate.test.mjs +12 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +104 -14
- package/templates/runtime/contracts/component-contracts.ts +54 -22
- package/templates/runtime/contracts/decision-contracts.test.ts +5 -0
- package/templates/runtime/contracts/decision-contracts.ts +3 -3
- package/templates/runtime/react/controls-panel.test.tsx +374 -13
- package/templates/runtime/react/controls-panel.tsx +65 -4
- package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
- package/templates/runtime/schema/define-toolcraft.ts +25 -1
- package/templates/runtime/schema/types.ts +3 -0
- package/templates/runtime/testing/performance.test.ts +134 -0
- package/templates/runtime/testing/performance.ts +34 -4
- package/templates/starter/AGENTS.md +4 -4
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +5 -3
- package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -5
- package/templates/starter/docs/toolcraft/component-rules.md +32 -11
- package/templates/starter/docs/toolcraft/performance.md +7 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +43 -14
- package/templates/starter/gitignore +1 -0
- package/templates/starter/package.json +2 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +39 -4
- package/templates/starter/scripts/toolcraft-port.mjs +102 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +60 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +739 -66
- package/templates/starter/src/app/starter-acceptance.ts +471 -12
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +11 -3
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/index.ts +2 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/select/select-control.tsx +9 -4
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +84 -8
- package/templates/ui/components/panel/panel-section.tsx +29 -5
|
@@ -147,6 +147,7 @@ export type ToolcraftCustomControlCoverage =
|
|
|
147
147
|
const builtInToolcraftControlTypeValues = [
|
|
148
148
|
"actions",
|
|
149
149
|
"anchorGrid",
|
|
150
|
+
"aspectRatio",
|
|
150
151
|
"channelMixer",
|
|
151
152
|
"checkbox",
|
|
152
153
|
"code",
|
|
@@ -172,6 +173,8 @@ const builtInToolcraftControlTypeValues = [
|
|
|
172
173
|
] as const;
|
|
173
174
|
const settingsTransferOptOutReasonPattern =
|
|
174
175
|
/\b(ephemeral|temporary|one-off|not portable|session-only)\b/i;
|
|
176
|
+
const insufficientFixedCanvasSizingReasonPattern =
|
|
177
|
+
/\b(no|without|missing|lacks?|does\s+not\s+(?:have|expose)|did\s+not\s+(?:have|expose)|has\s+no|had\s+no)\b[^.]{0,80}\b(?:size|dimension|canvas|output)\b[^.]{0,80}\b(?:editor|controls?|settings?|picker|input|ui)\b|\breference\s+app\b[^.]{0,120}\b(?:no|without|missing|lacks?|does\s+not\s+(?:have|expose)|did\s+not\s+(?:have|expose)|has\s+no|had\s+no)\b[^.]{0,80}\b(?:size|dimension|canvas|output)\b/i;
|
|
175
178
|
|
|
176
179
|
export type ToolcraftBuiltInControlType =
|
|
177
180
|
(typeof builtInToolcraftControlTypeValues)[number];
|
|
@@ -264,6 +267,15 @@ export type ToolcraftControlOrderItem = {
|
|
|
264
267
|
type: string;
|
|
265
268
|
};
|
|
266
269
|
|
|
270
|
+
export type ToolcraftControlSectionInventoryEntry = {
|
|
271
|
+
entity?: string;
|
|
272
|
+
groupingReason: string;
|
|
273
|
+
splitReason?: string;
|
|
274
|
+
targets: readonly string[];
|
|
275
|
+
title: string;
|
|
276
|
+
workflowStage?: string;
|
|
277
|
+
};
|
|
278
|
+
|
|
267
279
|
export const starterTransferMode: ToolcraftTransferMode = {
|
|
268
280
|
animationIntent: { mode: "none" },
|
|
269
281
|
mode: "new-toolcraft-app",
|
|
@@ -277,10 +289,16 @@ export const starterProductReadiness: ToolcraftProductReadiness = {
|
|
|
277
289
|
|
|
278
290
|
export const starterAcceptance: readonly ToolcraftComponentAcceptance[] = [];
|
|
279
291
|
|
|
292
|
+
export const starterControlSectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [];
|
|
293
|
+
|
|
280
294
|
function getActionValue(action: ToolcraftActionSchema | string): string {
|
|
281
295
|
return typeof action === "string" ? action : action.value;
|
|
282
296
|
}
|
|
283
297
|
|
|
298
|
+
function getActionLabelText(action: ToolcraftActionSchema | string): string {
|
|
299
|
+
return typeof action === "string" ? action : (action.label ?? action.value);
|
|
300
|
+
}
|
|
301
|
+
|
|
284
302
|
function getActionSearchText(action: ToolcraftActionSchema | string): string {
|
|
285
303
|
return typeof action === "string" ? action : `${action.label} ${action.value} ${action.command ?? ""}`;
|
|
286
304
|
}
|
|
@@ -873,6 +891,64 @@ function hasVisibleControlLabel(control: ToolcraftControlSchema): boolean {
|
|
|
873
891
|
return typeof control.label === "string" && control.label.trim().length > 0;
|
|
874
892
|
}
|
|
875
893
|
|
|
894
|
+
const shortSingleLineTextMaxLength = 80;
|
|
895
|
+
const longTextControlEvidencePattern =
|
|
896
|
+
/\b(multiline|multi-line|multi\s+line|long\s+(?:text|content|prompt|instructions?)|paragraph|body\s+(?:copy|text)|rich\s+text|textarea|instructions?|script|code|json|css|shader|template|markdown|csv|xml|yaml|html|svg|list|lines|dataset|structured|source\s+text)\b/i;
|
|
897
|
+
const shortSingleLineTextIntentPattern =
|
|
898
|
+
/\b(button|label|title|name|caption|headline|cta|badge|token|word|phrase|single-line|one-line|short\s+text)\b/i;
|
|
899
|
+
|
|
900
|
+
function getControlDefaultString(control: ToolcraftControlSchema): string | undefined {
|
|
901
|
+
return typeof control.defaultValue === "string" ? control.defaultValue : undefined;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getTextValueControlIntentText(
|
|
905
|
+
controlId: string,
|
|
906
|
+
control: ToolcraftControlSchema,
|
|
907
|
+
): string {
|
|
908
|
+
return [
|
|
909
|
+
controlId,
|
|
910
|
+
control.target,
|
|
911
|
+
getControlLabelText(control),
|
|
912
|
+
control.description ?? "",
|
|
913
|
+
]
|
|
914
|
+
.join(" ")
|
|
915
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function getCodeTextareaControlError({
|
|
919
|
+
control,
|
|
920
|
+
controlId,
|
|
921
|
+
label,
|
|
922
|
+
}: {
|
|
923
|
+
control: ToolcraftControlSchema;
|
|
924
|
+
controlId: string;
|
|
925
|
+
label: string;
|
|
926
|
+
}): string | undefined {
|
|
927
|
+
if (control.type !== "code") {
|
|
928
|
+
return undefined;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const defaultValue = getControlDefaultString(control);
|
|
932
|
+
const intentText = getTextValueControlIntentText(controlId, control);
|
|
933
|
+
const hasLongTextEvidence =
|
|
934
|
+
longTextControlEvidencePattern.test(intentText) ||
|
|
935
|
+
(defaultValue !== undefined &&
|
|
936
|
+
(defaultValue.length > shortSingleLineTextMaxLength ||
|
|
937
|
+
/[\r\n]/.test(defaultValue)));
|
|
938
|
+
const looksLikeShortSingleLineText =
|
|
939
|
+
shortSingleLineTextIntentPattern.test(intentText) ||
|
|
940
|
+
(defaultValue !== undefined &&
|
|
941
|
+
defaultValue.trim().length > 0 &&
|
|
942
|
+
defaultValue.trim().length <= shortSingleLineTextMaxLength &&
|
|
943
|
+
!/[\r\n]/.test(defaultValue));
|
|
944
|
+
|
|
945
|
+
if (!looksLikeShortSingleLineText || hasLongTextEvidence) {
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return `${label} uses CodeTextarea for short single-line text. Use type "text" for button labels, canvas labels, titles, captions, names, badges, tokens, and other short one-line content. Reserve type "code" for long, multiline, or structured values, and document that reason in description when the default is short.`;
|
|
950
|
+
}
|
|
951
|
+
|
|
876
952
|
const singleCurveSemanticPattern =
|
|
877
953
|
/\b(acceleration|accel|bend|easing|ease|response|depth|mask|opacity|alpha|motion|velocity|threshold|falloff|remap|remapping)\b|speed\s+profile|mapping\s+curve|curve\s+mapping/i;
|
|
878
954
|
const rgbCurveSemanticPattern =
|
|
@@ -931,6 +1007,33 @@ function getToggleControlLabelError(
|
|
|
931
1007
|
return undefined;
|
|
932
1008
|
}
|
|
933
1009
|
|
|
1010
|
+
function getSingleActionsControlLabelError(
|
|
1011
|
+
control: ToolcraftControlSchema,
|
|
1012
|
+
): string | undefined {
|
|
1013
|
+
if (control.type !== "actions") {
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const actions = getControlActions(control);
|
|
1018
|
+
|
|
1019
|
+
if (actions.length !== 1) {
|
|
1020
|
+
return undefined;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const label = getControlLabelText(control).trim();
|
|
1024
|
+
const actionLabel = getActionLabelText(actions[0] as ToolcraftActionSchema | string).trim();
|
|
1025
|
+
|
|
1026
|
+
if (
|
|
1027
|
+
label &&
|
|
1028
|
+
actionLabel &&
|
|
1029
|
+
normalizeToolcraftSemanticText(label) === normalizeToolcraftSemanticText(actionLabel)
|
|
1030
|
+
) {
|
|
1031
|
+
return `single Actions control label "${label}" duplicates its only button label "${actionLabel}". Keep the button as the command and use a short context label such as "Ink wash", "Palette action", or "Current layer".`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
934
1037
|
const maxInlineSwitchLabelLength = 16;
|
|
935
1038
|
const maxInlineSwitchLabelWordCount = 2;
|
|
936
1039
|
|
|
@@ -1319,7 +1422,7 @@ function isModeSelectorControl(
|
|
|
1319
1422
|
return false;
|
|
1320
1423
|
}
|
|
1321
1424
|
|
|
1322
|
-
return /mode|type|filter|
|
|
1425
|
+
return /mode|type|filter|style|preset|variant/i.test(
|
|
1323
1426
|
`${controlId} ${control.target} ${getControlLabelText(control)}`,
|
|
1324
1427
|
);
|
|
1325
1428
|
}
|
|
@@ -1445,7 +1548,7 @@ const maxHardControlsPerSection = 10;
|
|
|
1445
1548
|
|
|
1446
1549
|
const controlSemanticClusterPatterns: ReadonlyArray<readonly [string, RegExp]> = [
|
|
1447
1550
|
["input", /\b(upload|source|prompt|content|text|phrase|copy|message|file|media|image)\b/i],
|
|
1448
|
-
["mode", /\b(mode|type|preset|style|variant|
|
|
1551
|
+
["mode", /\b(mode|type|preset|style|variant|filter|layout|format|quality)\b/i],
|
|
1449
1552
|
["motion", /\b(animation|speed|velocity|accel|acceleration|correlation|duration|timing|loop|phase|fps|rate)\b/i],
|
|
1450
1553
|
["geometry", /\b(width|height|size|scale|position|offset|anchor|origin|target|radius|distance|spread|bend|curve|curves|path|shape|grid|gap)\b/i],
|
|
1451
1554
|
["density", /\b(fill|density|amount|count|ratio|word|words|letter|letters|particle|particles|layer|layers|island|islands)\b/i],
|
|
@@ -1683,6 +1786,73 @@ function getToolcraftControlSemanticCluster(
|
|
|
1683
1786
|
return inferToolcraftControlOrderRole(controlId, control);
|
|
1684
1787
|
}
|
|
1685
1788
|
|
|
1789
|
+
function normalizeToolcraftConditionValue(value: unknown): string | null {
|
|
1790
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
1791
|
+
const normalized = normalizeToolcraftSemanticText(String(value));
|
|
1792
|
+
return normalized || null;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function getToolcraftConditionBranchTexts(
|
|
1799
|
+
condition: ToolcraftControlConditionSchema,
|
|
1800
|
+
gateControl: ToolcraftControlSchema,
|
|
1801
|
+
): string[] {
|
|
1802
|
+
const rawValues =
|
|
1803
|
+
"equals" in condition
|
|
1804
|
+
? [condition.equals]
|
|
1805
|
+
: Array.isArray(condition.oneOf)
|
|
1806
|
+
? [...condition.oneOf]
|
|
1807
|
+
: [];
|
|
1808
|
+
const normalizedValues = rawValues
|
|
1809
|
+
.map(normalizeToolcraftConditionValue)
|
|
1810
|
+
.filter((value): value is string => Boolean(value));
|
|
1811
|
+
const optionLabels = (gateControl.options ?? [])
|
|
1812
|
+
.filter((option) =>
|
|
1813
|
+
normalizedValues.includes(normalizeToolcraftSemanticText(option.value)),
|
|
1814
|
+
)
|
|
1815
|
+
.map((option) => normalizeToolcraftSemanticText(option.label))
|
|
1816
|
+
.filter(Boolean);
|
|
1817
|
+
|
|
1818
|
+
return [...new Set([...normalizedValues, ...optionLabels])];
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function sectionTitleLooksLikeDependencyBranch({
|
|
1822
|
+
condition,
|
|
1823
|
+
gateControl,
|
|
1824
|
+
sectionTitle,
|
|
1825
|
+
}: {
|
|
1826
|
+
condition: ToolcraftControlConditionSchema;
|
|
1827
|
+
gateControl: ToolcraftControlSchema;
|
|
1828
|
+
sectionTitle: string | undefined;
|
|
1829
|
+
}): boolean {
|
|
1830
|
+
const sectionText = normalizeToolcraftSemanticText(sectionTitle);
|
|
1831
|
+
|
|
1832
|
+
if (!sectionText) {
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
return getToolcraftConditionBranchTexts(condition, gateControl).some(
|
|
1837
|
+
(branchText) =>
|
|
1838
|
+
branchText.length > 2 &&
|
|
1839
|
+
(sectionText === branchText ||
|
|
1840
|
+
sectionText.includes(branchText) ||
|
|
1841
|
+
branchText.includes(sectionText)),
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function isToolcraftProductSectionControl(control: ToolcraftControlSchema): boolean {
|
|
1846
|
+
return (
|
|
1847
|
+
control.type !== "panelActions" &&
|
|
1848
|
+
control.type !== "settingsTransfer" &&
|
|
1849
|
+
control.target !== "canvas.aspectRatio" &&
|
|
1850
|
+
control.target !== "canvas.renderScale" &&
|
|
1851
|
+
control.target !== "canvas.size.width" &&
|
|
1852
|
+
control.target !== "canvas.size.height"
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1686
1856
|
function getToolcraftGenericControlLabelError({
|
|
1687
1857
|
control,
|
|
1688
1858
|
controlId,
|
|
@@ -1887,8 +2057,187 @@ function getToolcraftColorBankLabelErrors({
|
|
|
1887
2057
|
return errors;
|
|
1888
2058
|
}
|
|
1889
2059
|
|
|
2060
|
+
function getToolcraftSectionInventoryByTitle(
|
|
2061
|
+
sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
|
|
2062
|
+
): Map<string, ToolcraftControlSectionInventoryEntry> {
|
|
2063
|
+
return new Map(
|
|
2064
|
+
sectionInventory.map((entry) => [entry.title.trim(), entry]),
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function hasToolcraftInventorySplitEvidence({
|
|
2069
|
+
sectionInventoryByTitle,
|
|
2070
|
+
sections,
|
|
2071
|
+
}: {
|
|
2072
|
+
sectionInventoryByTitle: ReadonlyMap<string, ToolcraftControlSectionInventoryEntry>;
|
|
2073
|
+
sections: ReadonlySet<string>;
|
|
2074
|
+
}): boolean {
|
|
2075
|
+
const entries = [...sections].map((sectionLabel) =>
|
|
2076
|
+
sectionInventoryByTitle.get(sectionLabel),
|
|
2077
|
+
);
|
|
2078
|
+
|
|
2079
|
+
return (
|
|
2080
|
+
entries.length === sections.size &&
|
|
2081
|
+
entries.every(
|
|
2082
|
+
(entry) =>
|
|
2083
|
+
entry &&
|
|
2084
|
+
(entry.workflowStage?.trim().length ?? 0) > 0 &&
|
|
2085
|
+
(entry.splitReason?.trim().length ?? 0) >= 12,
|
|
2086
|
+
)
|
|
2087
|
+
);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function getToolcraftControlSectionInventoryErrors(
|
|
2091
|
+
schema: ResolvedToolcraftAppSchema,
|
|
2092
|
+
sectionInventory: readonly ToolcraftControlSectionInventoryEntry[],
|
|
2093
|
+
): string[] {
|
|
2094
|
+
const errors: string[] = [];
|
|
2095
|
+
|
|
2096
|
+
if (sectionInventory.length === 0) {
|
|
2097
|
+
return errors;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
const schemaSections = (schema.panels.controls?.sections ?? []).flatMap(
|
|
2101
|
+
(section, sectionIndex) => {
|
|
2102
|
+
const targets = Object.values(section.controls)
|
|
2103
|
+
.filter(isToolcraftProductSectionControl)
|
|
2104
|
+
.map((control) => control.target);
|
|
2105
|
+
|
|
2106
|
+
if (targets.length === 0) {
|
|
2107
|
+
return [];
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
return [
|
|
2111
|
+
{
|
|
2112
|
+
label: getToolcraftSectionLabel(section.title, sectionIndex),
|
|
2113
|
+
targets,
|
|
2114
|
+
},
|
|
2115
|
+
];
|
|
2116
|
+
},
|
|
2117
|
+
);
|
|
2118
|
+
const schemaSectionTitles = new Set(schemaSections.map((section) => section.label));
|
|
2119
|
+
const schemaTargetToSection = new Map<string, string>();
|
|
2120
|
+
|
|
2121
|
+
for (const section of schemaSections) {
|
|
2122
|
+
for (const target of section.targets) {
|
|
2123
|
+
schemaTargetToSection.set(target, section.label);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
const inventoryTitleCounts = new Map<string, number>();
|
|
2128
|
+
const inventoryTargetToSection = new Map<string, string>();
|
|
2129
|
+
|
|
2130
|
+
for (const entry of sectionInventory) {
|
|
2131
|
+
const title = entry.title.trim();
|
|
2132
|
+
const entity = entry.entity?.trim() ?? "";
|
|
2133
|
+
const workflowStage = entry.workflowStage?.trim() ?? "";
|
|
2134
|
+
const groupingReason = entry.groupingReason.trim();
|
|
2135
|
+
const splitReason = entry.splitReason?.trim() ?? "";
|
|
2136
|
+
|
|
2137
|
+
if (!title) {
|
|
2138
|
+
errors.push(
|
|
2139
|
+
"Control Section Inventory contains an entry without a section title.",
|
|
2140
|
+
);
|
|
2141
|
+
continue;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
inventoryTitleCounts.set(title, (inventoryTitleCounts.get(title) ?? 0) + 1);
|
|
2145
|
+
|
|
2146
|
+
if (!schemaSectionTitles.has(title)) {
|
|
2147
|
+
errors.push(
|
|
2148
|
+
`Control Section Inventory references "${title}", but no rendered product controls section uses that title.`,
|
|
2149
|
+
);
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
if (!entity && !workflowStage) {
|
|
2153
|
+
errors.push(
|
|
2154
|
+
`Control Section Inventory entry "${title}" must declare entity or workflowStage so grouping is based on product meaning, not UI layout.`,
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
if (groupingReason.length < 12) {
|
|
2159
|
+
errors.push(
|
|
2160
|
+
`Control Section Inventory entry "${title}" must include a concrete groupingReason explaining why these controls belong together.`,
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (splitReason && splitReason.length < 12) {
|
|
2165
|
+
errors.push(
|
|
2166
|
+
`Control Section Inventory entry "${title}" splitReason is too vague. Explain the product workflow split or omit splitReason.`,
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
if (entry.targets.length === 0) {
|
|
2171
|
+
errors.push(
|
|
2172
|
+
`Control Section Inventory entry "${title}" must list the product control targets rendered in that section.`,
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
for (const target of entry.targets) {
|
|
2177
|
+
const schemaSection = schemaTargetToSection.get(target);
|
|
2178
|
+
|
|
2179
|
+
if (!schemaSection) {
|
|
2180
|
+
errors.push(
|
|
2181
|
+
`Control Section Inventory entry "${title}" lists target "${target}", but that target is not rendered by any product controls section.`,
|
|
2182
|
+
);
|
|
2183
|
+
continue;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (schemaSection !== title) {
|
|
2187
|
+
errors.push(
|
|
2188
|
+
`Control Section Inventory entry "${title}" lists target "${target}", but the schema renders it in "${schemaSection}". Update the schema grouping or the inventory.`,
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const existingSection = inventoryTargetToSection.get(target);
|
|
2193
|
+
|
|
2194
|
+
if (existingSection && existingSection !== title) {
|
|
2195
|
+
errors.push(
|
|
2196
|
+
`Control Section Inventory lists target "${target}" in both "${existingSection}" and "${title}". Each product control target belongs to exactly one section inventory entry.`,
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
inventoryTargetToSection.set(target, title);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
for (const [title, count] of inventoryTitleCounts) {
|
|
2205
|
+
if (count > 1) {
|
|
2206
|
+
errors.push(
|
|
2207
|
+
`Control Section Inventory repeats section "${title}" ${count} times. Section inventory entries must be unique.`,
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
for (const section of schemaSections) {
|
|
2213
|
+
const entry = sectionInventory.find(
|
|
2214
|
+
(inventoryEntry) => inventoryEntry.title.trim() === section.label,
|
|
2215
|
+
);
|
|
2216
|
+
|
|
2217
|
+
if (!entry) {
|
|
2218
|
+
errors.push(
|
|
2219
|
+
`Control Section Inventory is missing product section "${section.label}". Add its entity/workflow stage, targets, and groupingReason.`,
|
|
2220
|
+
);
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const inventoryTargets = new Set(entry.targets);
|
|
2225
|
+
|
|
2226
|
+
for (const target of section.targets) {
|
|
2227
|
+
if (!inventoryTargets.has(target)) {
|
|
2228
|
+
errors.push(
|
|
2229
|
+
`Control Section Inventory entry "${section.label}" is missing rendered target "${target}". The inventory must cover every product control in the section.`,
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
return errors;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
1890
2238
|
function getToolcraftControlSectionGroupingErrors(
|
|
1891
2239
|
schema: ResolvedToolcraftAppSchema,
|
|
2240
|
+
sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = [],
|
|
1892
2241
|
): string[] {
|
|
1893
2242
|
const errors: string[] = [];
|
|
1894
2243
|
const visibleControls: Array<{
|
|
@@ -1896,11 +2245,23 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
1896
2245
|
controlId: string;
|
|
1897
2246
|
loosePrefix: string | null;
|
|
1898
2247
|
sectionLabel: string;
|
|
2248
|
+
sectionTitle: string | undefined;
|
|
1899
2249
|
}> = [];
|
|
1900
2250
|
const strictPrefixSections = new Map<string, Set<string>>();
|
|
1901
2251
|
const loosePrefixSections = new Map<string, Set<string>>();
|
|
1902
2252
|
const colorSectionLoosePrefixes = new Map<string, string>();
|
|
2253
|
+
const sectionInventoryByTitle = getToolcraftSectionInventoryByTitle(sectionInventory);
|
|
1903
2254
|
const sectionTitleCounts = new Map<string, { count: number; label: string }>();
|
|
2255
|
+
const controlsByTarget = new Map<
|
|
2256
|
+
string,
|
|
2257
|
+
{
|
|
2258
|
+
control: ToolcraftControlSchema;
|
|
2259
|
+
controlId: string;
|
|
2260
|
+
loosePrefix: string | null;
|
|
2261
|
+
sectionLabel: string;
|
|
2262
|
+
sectionTitle: string | undefined;
|
|
2263
|
+
}
|
|
2264
|
+
>();
|
|
1904
2265
|
|
|
1905
2266
|
for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
|
|
1906
2267
|
const sectionTitle = section.title?.trim();
|
|
@@ -1945,11 +2306,8 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
1945
2306
|
.map(([, control]) => getToolcraftLooseTargetPrefix(control.target))
|
|
1946
2307
|
.filter((prefix): prefix is string => Boolean(prefix)),
|
|
1947
2308
|
);
|
|
1948
|
-
const productControls = controls.filter(
|
|
1949
|
-
(
|
|
1950
|
-
control.type !== "settingsTransfer" &&
|
|
1951
|
-
control.target !== "canvas.size.width" &&
|
|
1952
|
-
control.target !== "canvas.size.height",
|
|
2309
|
+
const productControls = controls.filter(([, control]) =>
|
|
2310
|
+
isToolcraftProductSectionControl(control),
|
|
1953
2311
|
);
|
|
1954
2312
|
const semanticClusters = new Set(
|
|
1955
2313
|
productControls.map(([controlId, control]) =>
|
|
@@ -2015,6 +2373,14 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
2015
2373
|
controlId,
|
|
2016
2374
|
loosePrefix,
|
|
2017
2375
|
sectionLabel,
|
|
2376
|
+
sectionTitle,
|
|
2377
|
+
});
|
|
2378
|
+
controlsByTarget.set(control.target, {
|
|
2379
|
+
control,
|
|
2380
|
+
controlId,
|
|
2381
|
+
loosePrefix,
|
|
2382
|
+
sectionLabel,
|
|
2383
|
+
sectionTitle,
|
|
2018
2384
|
});
|
|
2019
2385
|
|
|
2020
2386
|
if (strictPrefix) {
|
|
@@ -2048,6 +2414,42 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
2048
2414
|
}
|
|
2049
2415
|
}
|
|
2050
2416
|
|
|
2417
|
+
for (const item of visibleControls) {
|
|
2418
|
+
for (const [conditionName, condition] of [
|
|
2419
|
+
["visibleWhen", item.control.visibleWhen],
|
|
2420
|
+
["disabledWhen", item.control.disabledWhen],
|
|
2421
|
+
] as const) {
|
|
2422
|
+
if (!condition || !item.loosePrefix) {
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
const gateControl = controlsByTarget.get(condition.target);
|
|
2427
|
+
|
|
2428
|
+
if (!gateControl || gateControl.sectionLabel === item.sectionLabel) {
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const sharesTargetEntity =
|
|
2433
|
+
gateControl.loosePrefix !== null &&
|
|
2434
|
+
gateControl.loosePrefix === item.loosePrefix;
|
|
2435
|
+
const looksLikeBranchSection =
|
|
2436
|
+
conditionName === "visibleWhen" &&
|
|
2437
|
+
sectionTitleLooksLikeDependencyBranch({
|
|
2438
|
+
condition,
|
|
2439
|
+
gateControl: gateControl.control,
|
|
2440
|
+
sectionTitle: item.sectionTitle,
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
if (!sharesTargetEntity && !looksLikeBranchSection) {
|
|
2444
|
+
continue;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
errors.push(
|
|
2448
|
+
`${item.sectionLabel} / ${item.controlId} is gated by ${conditionName} target "${condition.target}" in ${gateControl.sectionLabel}, but it belongs to the same dependency group. Keep selectors and their dependent controls in one semantic section when they describe one product entity or branch; use visibleWhen/disabledWhen inside that section instead of splitting branch controls into their own section.`,
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2051
2453
|
const fontPickerControls = visibleControls.filter(
|
|
2052
2454
|
(item) => item.control.type === "fontPicker" && item.loosePrefix,
|
|
2053
2455
|
);
|
|
@@ -2080,9 +2482,12 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
2080
2482
|
}
|
|
2081
2483
|
|
|
2082
2484
|
for (const [prefix, sections] of strictPrefixSections) {
|
|
2083
|
-
if (
|
|
2485
|
+
if (
|
|
2486
|
+
sections.size > 1 &&
|
|
2487
|
+
!hasToolcraftInventorySplitEvidence({ sectionInventoryByTitle, sections })
|
|
2488
|
+
) {
|
|
2084
2489
|
errors.push(
|
|
2085
|
-
`Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the
|
|
2490
|
+
`Controls for product entity "${prefix}" are split across sections: ${[...sections].join(", ")}. Keep controls for the same product entity in one semantic section unless the Control Section Inventory declares workflowStage and splitReason for every split section.`,
|
|
2086
2491
|
);
|
|
2087
2492
|
}
|
|
2088
2493
|
}
|
|
@@ -2151,6 +2556,7 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2151
2556
|
schema: ResolvedToolcraftAppSchema = starterSchema,
|
|
2152
2557
|
acceptance: readonly ToolcraftComponentAcceptance[] = starterAcceptance,
|
|
2153
2558
|
transferMode: ToolcraftTransferMode = starterTransferMode,
|
|
2559
|
+
sectionInventory: readonly ToolcraftControlSectionInventoryEntry[] = starterControlSectionInventory,
|
|
2154
2560
|
): string[] {
|
|
2155
2561
|
const errors: string[] = [];
|
|
2156
2562
|
const controls = collectToolcraftVisibleControls(schema);
|
|
@@ -2181,7 +2587,8 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2181
2587
|
]);
|
|
2182
2588
|
|
|
2183
2589
|
errors.push(...getToolcraftControlOrderErrors(schema));
|
|
2184
|
-
errors.push(...getToolcraftControlSectionGroupingErrors(schema));
|
|
2590
|
+
errors.push(...getToolcraftControlSectionGroupingErrors(schema, sectionInventory));
|
|
2591
|
+
errors.push(...getToolcraftControlSectionInventoryErrors(schema, sectionInventory));
|
|
2185
2592
|
|
|
2186
2593
|
for (const [sectionIndex, section] of (schema.panels.controls?.sections ?? []).entries()) {
|
|
2187
2594
|
const sectionLabel = getToolcraftSectionLabel(section.title, sectionIndex);
|
|
@@ -2201,6 +2608,16 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2201
2608
|
);
|
|
2202
2609
|
}
|
|
2203
2610
|
|
|
2611
|
+
const segmentedIds = layoutGroup.controls.filter(
|
|
2612
|
+
(controlId) => section.controls[controlId]?.type === "segmented",
|
|
2613
|
+
);
|
|
2614
|
+
|
|
2615
|
+
if (segmentedIds.length > 0) {
|
|
2616
|
+
errors.push(
|
|
2617
|
+
`${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes segmented control ${segmentedIds.join(", ")}. Segmented is full-width and must not share a two-column or half-width row; use Select when a finite choice must fit beside another control.`,
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2204
2621
|
const switchEntries = layoutGroup.controls
|
|
2205
2622
|
.map((controlId) => [controlId, section.controls[controlId]] as const)
|
|
2206
2623
|
.filter(
|
|
@@ -2242,6 +2659,16 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2242
2659
|
`${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" includes toggle label ${unsafeBooleanLabels.map(([controlId, control]) => `${controlId} "${getInlineSwitchLabelText(controlId, control)}"`).join(", ")} that is too long for a compact toggle-plus-parameter row. Keep the toggle label short, such as "Include" inside Background, or stack the controls.`,
|
|
2243
2660
|
);
|
|
2244
2661
|
}
|
|
2662
|
+
|
|
2663
|
+
const visibleParameterLabels = parameterEntries.filter(
|
|
2664
|
+
([, control]) => control.label !== false,
|
|
2665
|
+
);
|
|
2666
|
+
|
|
2667
|
+
if (visibleParameterLabels.length > 0) {
|
|
2668
|
+
errors.push(
|
|
2669
|
+
`${sectionLabel} layoutGroups inline row "${layoutGroup.controls.join(", ")}" pairs a toggle with parameter labels ${visibleParameterLabels.map(([controlId, control]) => `${controlId} "${getControlLabelText(control)}"`).join(", ")}. In toggle-plus-parameter rows, the non-toggle parameter must use label false; if that label is needed, stack the controls instead.`,
|
|
2670
|
+
);
|
|
2671
|
+
}
|
|
2245
2672
|
}
|
|
2246
2673
|
}
|
|
2247
2674
|
|
|
@@ -2803,6 +3230,12 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2803
3230
|
}
|
|
2804
3231
|
|
|
2805
3232
|
if (schema.canvas.sizing.mode === "fixed-output") {
|
|
3233
|
+
if (schemaHasPngExportPanelAction(schema) || schemaHasVideoExportPanelAction(schema)) {
|
|
3234
|
+
errors.push(
|
|
3235
|
+
'Product/output apps with export actions must use canvas.sizing mode "editable-output" so Aspect ratio, Canvas width, and Canvas height are always available. Put reference, fixed-format, or user-requested dimensions in canvas.size as the initial value instead of hiding size controls with "fixed-output".',
|
|
3236
|
+
);
|
|
3237
|
+
}
|
|
3238
|
+
|
|
2806
3239
|
const fixedCanvasSizingEntry = acceptance.find(
|
|
2807
3240
|
(entry) =>
|
|
2808
3241
|
entry.kind === "runtime" &&
|
|
@@ -2811,14 +3244,20 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2811
3244
|
|
|
2812
3245
|
if (!fixedCanvasSizingEntry) {
|
|
2813
3246
|
errors.push(
|
|
2814
|
-
'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable.
|
|
3247
|
+
'canvas.sizing mode "fixed-output" requires a runtime acceptance entry with canvasSizingCoverage "fixed-output-size" explaining why width and height are intentionally non-editable. Product/output apps must use "editable-output"; user-provided, reference, fixed-format, or base/default sizes belong in canvas.size as editable initial values.',
|
|
2815
3248
|
);
|
|
2816
3249
|
} else {
|
|
2817
3250
|
const evidenceText = getAcceptanceEvidenceText(fixedCanvasSizingEntry);
|
|
2818
3251
|
|
|
2819
3252
|
if (!/(fixed|locked|non-editable|not user-editable|must not edit|reference-defined|product-defined)/i.test(evidenceText)) {
|
|
2820
3253
|
errors.push(
|
|
2821
|
-
`${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the
|
|
3254
|
+
`${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" must explain why the output dimensions are intentionally fixed for a non-product/internal fixture, not merely initialized from a default size.`,
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
if (insufficientFixedCanvasSizingReasonPattern.test(evidenceText)) {
|
|
3259
|
+
errors.push(
|
|
3260
|
+
`${fixedCanvasSizingEntry.id} canvasSizingCoverage "fixed-output-size" cannot be justified by the reference or previous app lacking a size editor. Exportable product apps must use editable-output; put reference, product-defined, or fixed-format dimensions in canvas.size as initial values instead of hiding size controls.`,
|
|
2822
3261
|
);
|
|
2823
3262
|
}
|
|
2824
3263
|
|
|
@@ -2938,11 +3377,25 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
2938
3377
|
const isCustomControl = isCustomToolcraftControl(control);
|
|
2939
3378
|
const isSelectedLayerTarget = control.target.startsWith("selectedLayer.");
|
|
2940
3379
|
const toggleLabelError = getToggleControlLabelError(control, sectionTitle);
|
|
3380
|
+
const singleActionsLabelError = getSingleActionsControlLabelError(control);
|
|
3381
|
+
const codeTextareaError = getCodeTextareaControlError({
|
|
3382
|
+
control,
|
|
3383
|
+
controlId,
|
|
3384
|
+
label,
|
|
3385
|
+
});
|
|
2941
3386
|
|
|
2942
3387
|
if (toggleLabelError) {
|
|
2943
3388
|
errors.push(`${label} ${toggleLabelError}`);
|
|
2944
3389
|
}
|
|
2945
3390
|
|
|
3391
|
+
if (singleActionsLabelError) {
|
|
3392
|
+
errors.push(`${label} ${singleActionsLabelError}`);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
if (codeTextareaError) {
|
|
3396
|
+
errors.push(codeTextareaError);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
2946
3399
|
if (
|
|
2947
3400
|
control.type === "rangeSlider" &&
|
|
2948
3401
|
Array.isArray(control.defaultValue) &&
|
|
@@ -3143,6 +3596,12 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
3143
3596
|
if (isSliderLikeControl(control)) {
|
|
3144
3597
|
const expectedMarkerCount = getStepMarkerCount(control);
|
|
3145
3598
|
|
|
3599
|
+
if (control.unit === "x") {
|
|
3600
|
+
errors.push(
|
|
3601
|
+
`${label} uses unit "x", but Toolcraft slider values do not use x suffixes. Omit unit for scale, multiplier, intensity, opacity, strength, depth, and shader amount values unless a real measurement unit applies.`,
|
|
3602
|
+
);
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3146
3605
|
if (
|
|
3147
3606
|
control.variant === "discrete" &&
|
|
3148
3607
|
expectedMarkerCount &&
|