@pixel-point/toolcraft 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +28 -7
  3. package/templates/runtime/contracts/component-contracts.ts +14 -7
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +65 -0
  6. package/templates/runtime/export/export.ts +54 -1
  7. package/templates/runtime/react/controls-panel.test.tsx +54 -6
  8. package/templates/runtime/react/controls-panel.tsx +216 -0
  9. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  10. package/templates/runtime/react/settings-transfer.ts +28 -2
  11. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  12. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  13. package/templates/runtime/schema/define-toolcraft.ts +60 -2
  14. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  15. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  16. package/templates/runtime/schema/runtime-targets.ts +5 -0
  17. package/templates/runtime/state/create-template-state.test.ts +6 -0
  18. package/templates/runtime/state/reducer.test.ts +55 -0
  19. package/templates/runtime/state/reducer.ts +214 -9
  20. package/templates/starter/AGENTS.md +5 -3
  21. package/templates/starter/docs/toolcraft/acceptance-testing.md +3 -1
  22. package/templates/starter/docs/toolcraft/assembly-workflow.md +10 -3
  23. package/templates/starter/docs/toolcraft/component-rules.md +12 -5
  24. package/templates/starter/docs/toolcraft/schema-reference.md +45 -7
  25. package/templates/starter/src/app/starter-acceptance.test.ts +623 -21
  26. package/templates/starter/src/app/starter-acceptance.ts +290 -3
  27. package/templates/ui/components/control-layout/index.tsx +4 -4
  28. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
@@ -766,7 +766,7 @@ function getToggleControlLabelError(
766
766
  normalizeToolcraftSemanticText(label) ===
767
767
  normalizeToolcraftSemanticText(sectionTitle)
768
768
  ) {
769
- return `toggle label "${label}" duplicates section title "${sectionTitle}". Use label false for a visual-only toggle or rename the toggle to a more specific setting.`;
769
+ return `toggle label "${label}" duplicates section title "${sectionTitle}". Use a shorter contextual label such as "Include" or rename the toggle to a more specific setting.`;
770
770
  }
771
771
 
772
772
  return undefined;
@@ -893,6 +893,15 @@ function actionLooksLikePngExport(action: ToolcraftActionSchema | string): boole
893
893
  );
894
894
  }
895
895
 
896
+ function actionLooksLikeVideoExport(action: ToolcraftActionSchema | string): boolean {
897
+ const text = getActionSearchText(action).replace(/([a-z])([A-Z])/g, "$1 $2");
898
+
899
+ return (
900
+ (/\b(export|download)\b/i.test(text) && /\b(video|mp4|webm|mov)\b/i.test(text)) ||
901
+ /\bexport\.video\b/i.test(text)
902
+ );
903
+ }
904
+
896
905
  function schemaHasPngExportPanelAction(schema: ResolvedToolcraftAppSchema): boolean {
897
906
  return (schema.panels.controls?.sections ?? []).some((section) =>
898
907
  Object.values(section.controls).some(
@@ -903,6 +912,57 @@ function schemaHasPngExportPanelAction(schema: ResolvedToolcraftAppSchema): bool
903
912
  );
904
913
  }
905
914
 
915
+ function schemaHasVideoExportPanelAction(schema: ResolvedToolcraftAppSchema): boolean {
916
+ return (schema.panels.controls?.sections ?? []).some((section) =>
917
+ Object.values(section.controls).some(
918
+ (control) =>
919
+ control.type === "panelActions" &&
920
+ getControlActions(control).some(actionLooksLikeVideoExport),
921
+ ),
922
+ );
923
+ }
924
+
925
+ function getFirstPanelActionsSectionIndex(schema: ResolvedToolcraftAppSchema): number {
926
+ return (schema.panels.controls?.sections ?? []).findIndex((section) =>
927
+ Object.values(section.controls).some((control) => control.type === "panelActions"),
928
+ );
929
+ }
930
+
931
+ function getSchemaControlsSectionByTitle(
932
+ schema: ResolvedToolcraftAppSchema,
933
+ title: string,
934
+ ): NonNullable<ResolvedToolcraftAppSchema["panels"]["controls"]>["sections"][number] | undefined {
935
+ const normalizedTitle = normalizeToolcraftSemanticText(title);
936
+
937
+ return (schema.panels.controls?.sections ?? []).find(
938
+ (section) => normalizeToolcraftSemanticText(section.title) === normalizedTitle,
939
+ );
940
+ }
941
+
942
+ function getSchemaControlsSectionIndexByTitle(
943
+ schema: ResolvedToolcraftAppSchema,
944
+ title: string,
945
+ ): number {
946
+ const normalizedTitle = normalizeToolcraftSemanticText(title);
947
+
948
+ return (schema.panels.controls?.sections ?? []).findIndex(
949
+ (section) => normalizeToolcraftSemanticText(section.title) === normalizedTitle,
950
+ );
951
+ }
952
+
953
+ function getSectionControlEntryByTarget(
954
+ section:
955
+ | NonNullable<ResolvedToolcraftAppSchema["panels"]["controls"]>["sections"][number]
956
+ | undefined,
957
+ target: string,
958
+ ): readonly [string, ToolcraftControlSchema] | undefined {
959
+ if (!section) {
960
+ return undefined;
961
+ }
962
+
963
+ return Object.entries(section.controls).find(([, control]) => control.target === target);
964
+ }
965
+
906
966
  function schemaHasOutputBackgroundColorControl(
907
967
  controls: readonly ToolcraftVisibleControl[],
908
968
  ): boolean {
@@ -942,6 +1002,49 @@ function isOutputBackgroundToggleControl(visibleControl: ToolcraftVisibleControl
942
1002
  );
943
1003
  }
944
1004
 
1005
+ function getOutputBackgroundColorEntry(
1006
+ section:
1007
+ | NonNullable<ResolvedToolcraftAppSchema["panels"]["controls"]>["sections"][number]
1008
+ | undefined,
1009
+ ): readonly [string, ToolcraftControlSchema] | undefined {
1010
+ if (!section) {
1011
+ return undefined;
1012
+ }
1013
+
1014
+ return Object.entries(section.controls).find(([controlId, control]) => {
1015
+ if (control.type !== "color") {
1016
+ return false;
1017
+ }
1018
+
1019
+ return /\b(background|backdrop|scene|canvas)\b/i.test(
1020
+ [section.title, controlId, control.target, getControlLabelText(control)]
1021
+ .join(" ")
1022
+ .replace(/([a-z])([A-Z])/g, "$1 $2"),
1023
+ );
1024
+ });
1025
+ }
1026
+
1027
+ function sectionHasEqualWidthOutputBackgroundRow(
1028
+ section:
1029
+ | NonNullable<ResolvedToolcraftAppSchema["panels"]["controls"]>["sections"][number]
1030
+ | undefined,
1031
+ toggleControlId: string | undefined,
1032
+ colorControlId: string | undefined,
1033
+ ): boolean {
1034
+ if (!section || !toggleControlId || !colorControlId) {
1035
+ return false;
1036
+ }
1037
+
1038
+ return (section.layoutGroups ?? []).some(
1039
+ (layoutGroup) =>
1040
+ layoutGroup.layout === "inline" &&
1041
+ layoutGroup.columns === 2 &&
1042
+ layoutGroup.controls.length === 2 &&
1043
+ layoutGroup.controls[0] === toggleControlId &&
1044
+ layoutGroup.controls[1] === colorControlId,
1045
+ );
1046
+ }
1047
+
945
1048
  const SEGMENTED_CONTROL_MAX_OPTIONS = 4;
946
1049
  const SEGMENTED_CONTROL_MAX_OPTION_LABEL_LENGTH = 9;
947
1050
  const SEGMENTED_CONTROL_MAX_TOTAL_LABEL_LENGTH = 24;
@@ -1823,7 +1926,7 @@ export function validateToolcraftAcceptanceCoverage(
1823
1926
 
1824
1927
  if (unsafeBooleanLabels.length > 0) {
1825
1928
  errors.push(
1826
- `${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, hide it when the section title supplies the context, or stack the controls.`,
1929
+ `${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.`,
1827
1930
  );
1828
1931
  }
1829
1932
  }
@@ -1861,15 +1964,199 @@ export function validateToolcraftAcceptanceCoverage(
1861
1964
  }
1862
1965
 
1863
1966
  if (schemaHasPngExportPanelAction(schema)) {
1967
+ const backgroundSection = getSchemaControlsSectionByTitle(schema, "Background");
1968
+ const backgroundSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Background");
1969
+ const panelActionsSectionIndex = getFirstPanelActionsSectionIndex(schema);
1970
+ const imageExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Image Export");
1971
+ const videoExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Video Export");
1972
+ const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
1973
+ const expectedOutputSettingsIndex =
1974
+ imageExportSectionIndex >= 0 ? imageExportSectionIndex : videoExportSectionIndex;
1975
+ const finalExportSettingsIndex = hasVideoExportAction
1976
+ ? videoExportSectionIndex
1977
+ : imageExportSectionIndex;
1978
+ const includeBackgroundEntry = getSectionControlEntryByTarget(
1979
+ backgroundSection,
1980
+ "export.includeBackground",
1981
+ );
1982
+ const backgroundColorEntry = getOutputBackgroundColorEntry(backgroundSection);
1983
+ const imageExportSection = getSchemaControlsSectionByTitle(schema, "Image Export");
1984
+ const imageFormatEntry = getSectionControlEntryByTarget(
1985
+ imageExportSection,
1986
+ "export.image.format",
1987
+ );
1988
+ const imageResolutionEntry = getSectionControlEntryByTarget(
1989
+ imageExportSection,
1990
+ "export.image.resolution",
1991
+ );
1992
+ const imageFormatControl = imageFormatEntry?.[1];
1993
+ const imageResolutionControl = imageResolutionEntry?.[1];
1994
+ const imageFormatOptionValues =
1995
+ imageFormatControl?.options?.map((option) => option.value.toLowerCase()) ?? [];
1996
+ const imageResolutionOptionValues =
1997
+ imageResolutionControl?.options?.map((option) => option.value.toLowerCase()) ?? [];
1998
+
1999
+ if (!backgroundSection) {
2000
+ errors.push(
2001
+ 'Product apps with Export PNG must expose a separate controls section titled "Background" directly before the first export settings section.',
2002
+ );
2003
+ }
2004
+
2005
+ if (
2006
+ backgroundSectionIndex >= 0 &&
2007
+ expectedOutputSettingsIndex >= 0 &&
2008
+ backgroundSectionIndex !== expectedOutputSettingsIndex - 1
2009
+ ) {
2010
+ errors.push(
2011
+ 'The "Background" controls section must sit directly before the first export settings section: Image Export when PNG export exists, otherwise Video Export.',
2012
+ );
2013
+ }
2014
+
2015
+ if (
2016
+ finalExportSettingsIndex >= 0 &&
2017
+ panelActionsSectionIndex >= 0 &&
2018
+ finalExportSettingsIndex !== panelActionsSectionIndex - 1
2019
+ ) {
2020
+ errors.push(
2021
+ 'Export settings must sit directly above sticky footer actions: Image Export for still apps, or Video Export after Image Export for animated apps.',
2022
+ );
2023
+ }
2024
+
2025
+ if (
2026
+ hasVideoExportAction &&
2027
+ imageExportSectionIndex >= 0 &&
2028
+ videoExportSectionIndex >= 0 &&
2029
+ imageExportSectionIndex !== videoExportSectionIndex - 1
2030
+ ) {
2031
+ errors.push(
2032
+ 'Animated apps with both Export PNG and Export Video must place Image Export immediately before Video Export.',
2033
+ );
2034
+ }
2035
+
1864
2036
  if (!schemaHasOutputBackgroundColorControl(controls)) {
1865
2037
  errors.push(
1866
2038
  "Product apps with Export PNG must expose a user-facing background color control such as appearance.background or scene.background. Preview, PNG export, and video export must read that runtime value instead of hardcoding the product background.",
1867
2039
  );
1868
2040
  }
1869
2041
 
2042
+ if (!backgroundColorEntry) {
2043
+ errors.push(
2044
+ 'The "Background" section must contain the renderer-owned background color control, such as appearance.background or scene.background.',
2045
+ );
2046
+ } else {
2047
+ const [, backgroundColorControl] = backgroundColorEntry;
2048
+
2049
+ if (backgroundColorControl.label !== false) {
2050
+ errors.push(
2051
+ 'The background color control inside the required "Background" section must use label false; the section title already supplies the visible context.',
2052
+ );
2053
+ }
2054
+ }
2055
+
1870
2056
  if (!schemaHasOutputBackgroundToggleControl(controls)) {
1871
2057
  errors.push(
1872
- "Product apps with Export PNG must expose a user-facing Include background / Transparent background control such as export.includeBackground. PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; video export keeps the background.",
2058
+ 'Product apps with Export PNG must expose export.includeBackground inside the required "Background" section as a Switch labeled "Include". PNG export must pass that runtime value to createToolcraftPngExportCanvas includeBackground; video export keeps the background.',
2059
+ );
2060
+ }
2061
+
2062
+ if (!includeBackgroundEntry) {
2063
+ errors.push(
2064
+ 'The "Background" section must contain export.includeBackground as the Include switch.',
2065
+ );
2066
+ } else {
2067
+ const [, includeBackgroundControl] = includeBackgroundEntry;
2068
+
2069
+ if (includeBackgroundControl.type !== "switch") {
2070
+ errors.push('export.includeBackground must be a Switch control labeled "Include".');
2071
+ }
2072
+
2073
+ if (getControlLabelText(includeBackgroundControl) !== "Include") {
2074
+ errors.push(
2075
+ 'export.includeBackground must use the short visible label "Include"; the Background section title already supplies the rest of the context.',
2076
+ );
2077
+ }
2078
+ }
2079
+
2080
+ if (
2081
+ !sectionHasEqualWidthOutputBackgroundRow(
2082
+ backgroundSection,
2083
+ includeBackgroundEntry?.[0],
2084
+ backgroundColorEntry?.[0],
2085
+ )
2086
+ ) {
2087
+ errors.push(
2088
+ 'The "Background" section must render export.includeBackground and the background color in one two-column inline layoutGroup, with Include on the left and the unlabeled background color on the right.',
2089
+ );
2090
+ }
2091
+
2092
+ if (!imageExportSection) {
2093
+ errors.push(
2094
+ 'Apps with Export PNG must expose image export settings in a separate controls section titled "Image Export" directly above sticky footer export actions or directly before "Video Export" when video export also exists.',
2095
+ );
2096
+ }
2097
+
2098
+ if (!imageFormatControl) {
2099
+ errors.push(
2100
+ 'The separate "Image Export" section must include a format control with target "export.image.format".',
2101
+ );
2102
+ } else {
2103
+ if (imageFormatControl.type !== "select") {
2104
+ errors.push(
2105
+ 'Image Export format must be a Select control so it matches the Video Export settings structure.',
2106
+ );
2107
+ }
2108
+
2109
+ if (!imageFormatOptionValues.includes("png") || !imageFormatOptionValues.includes("jpg")) {
2110
+ errors.push('Image Export format options must include "png" and "jpg".');
2111
+ }
2112
+
2113
+ if (imageFormatControl.defaultValue !== "png") {
2114
+ errors.push('Image Export format must default to "png".');
2115
+ }
2116
+ }
2117
+
2118
+ if (!imageResolutionControl) {
2119
+ errors.push(
2120
+ 'The separate "Image Export" section must include a resolution control with target "export.image.resolution".',
2121
+ );
2122
+ } else {
2123
+ if (imageResolutionControl.type !== "select") {
2124
+ errors.push(
2125
+ 'Image Export resolution must be a Select control so it matches the Video Export settings structure.',
2126
+ );
2127
+ }
2128
+
2129
+ if (
2130
+ !imageResolutionOptionValues.includes("2k") ||
2131
+ !imageResolutionOptionValues.includes("4k") ||
2132
+ !imageResolutionOptionValues.includes("8k")
2133
+ ) {
2134
+ errors.push(
2135
+ 'Image Export resolution options must include "2k", "4k", and "8k".',
2136
+ );
2137
+ }
2138
+
2139
+ if (imageResolutionControl.defaultValue !== "4k") {
2140
+ errors.push('Image Export resolution must default to "4k".');
2141
+ }
2142
+ }
2143
+
2144
+ const imageFormatControlId = imageFormatEntry?.[0];
2145
+ const imageResolutionControlId = imageResolutionEntry?.[0];
2146
+ const imageExportHasInlinePair =
2147
+ imageExportSection === undefined ||
2148
+ imageFormatControlId === undefined ||
2149
+ imageResolutionControlId === undefined
2150
+ ? false
2151
+ : sectionHasInlineLayoutGroupForPair(
2152
+ imageExportSection,
2153
+ imageFormatControlId,
2154
+ imageResolutionControlId,
2155
+ );
2156
+
2157
+ if (!imageExportHasInlinePair) {
2158
+ errors.push(
2159
+ "Image Export format and resolution must render as one compact two-column inline row, matching Video Export settings.",
1873
2160
  );
1874
2161
  }
1875
2162
  }
@@ -80,10 +80,10 @@ export function ControlInlineGroup({
80
80
  columns?: number;
81
81
  kind?: "default" | "slider" | "toggleParameter";
82
82
  }): React.JSX.Element {
83
- const gridTemplateColumns =
84
- kind === "toggleParameter"
85
- ? "auto minmax(0, 1fr)"
86
- : `repeat(${Math.max(1, Math.floor(columns))}, minmax(0, 1fr))`;
83
+ const gridTemplateColumns = `repeat(${Math.max(
84
+ 1,
85
+ Math.floor(columns),
86
+ )}, minmax(0, 1fr))`;
87
87
 
88
88
  return (
89
89
  <div
@@ -127,7 +127,7 @@ const textCaseOptions: Array<{
127
127
  label: string;
128
128
  value: FontPickerTextCasePreset;
129
129
  }> = [
130
- { label: "Original", value: "original" },
130
+ { label: "As typed", value: "original" },
131
131
  { label: "Uppercase", value: "uppercase" },
132
132
  { label: "Lowercase", value: "lowercase" },
133
133
  { label: "Capitalize", value: "capitalize" },