@pixel-point/toolcraft 0.0.3 → 0.0.6
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/scripts/prepare-pack.mjs +5 -0
- package/src/generate.mjs +13 -0
- package/src/generate.test.mjs +6 -0
- package/templates/runtime/contracts/component-contracts.test.ts +86 -8
- package/templates/runtime/contracts/component-contracts.ts +36 -8
- package/templates/runtime/contracts/decision-contracts.ts +2 -2
- package/templates/runtime/export/export.test.ts +65 -0
- package/templates/runtime/export/export.ts +54 -1
- package/templates/runtime/react/canvas-shell.test.tsx +7 -7
- package/templates/runtime/react/controls-panel.test.tsx +323 -6
- package/templates/runtime/react/controls-panel.tsx +349 -24
- package/templates/runtime/react/settings-transfer.test.ts +6 -0
- package/templates/runtime/react/settings-transfer.ts +28 -2
- package/templates/runtime/react/timeline-panel.test.tsx +69 -0
- package/templates/runtime/react/timeline-panel.tsx +98 -10
- package/templates/runtime/react/toolbar-panel.test.tsx +6 -6
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +2 -2
- package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +122 -2
- package/templates/runtime/schema/define-toolcraft.ts +197 -6
- package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
- package/templates/runtime/schema/keyframe-capability.ts +2 -2
- package/templates/runtime/schema/runtime-targets.ts +6 -0
- package/templates/runtime/schema/types.ts +23 -1
- package/templates/runtime/state/canvas-zoom.ts +1 -1
- package/templates/runtime/state/create-template-state.test.ts +9 -3
- package/templates/runtime/state/reducer.test.ts +135 -2
- package/templates/runtime/state/reducer.ts +236 -12
- package/templates/runtime/state/types.ts +1 -0
- package/templates/starter/AGENTS.md +6 -4
- package/templates/starter/docs/toolcraft/README.md +1 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +4 -2
- package/templates/starter/docs/toolcraft/assembly-workflow.md +13 -4
- package/templates/starter/docs/toolcraft/component-rules.md +24 -5
- package/templates/starter/docs/toolcraft/performance.md +5 -0
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +53 -9
- package/templates/starter/gitignore +36 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +678 -21
- package/templates/starter/src/app/starter-acceptance.ts +357 -4
- package/templates/ui/components/control-layout/index.tsx +4 -4
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +101 -18
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +4 -1
- package/templates/ui/components/controls/slider/slider-value.ts +48 -5
- package/templates/ui/components/primitives/editable-slider-value-label.tsx +6 -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
|
|
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;
|
|
@@ -1458,14 +1561,33 @@ function getToolcraftControlDescriptionError({
|
|
|
1458
1561
|
control,
|
|
1459
1562
|
controlId,
|
|
1460
1563
|
sectionLabel,
|
|
1564
|
+
sectionTitle,
|
|
1461
1565
|
}: {
|
|
1462
1566
|
control: ToolcraftControlSchema;
|
|
1463
1567
|
controlId: string;
|
|
1464
1568
|
sectionLabel: string;
|
|
1569
|
+
sectionTitle: string | undefined;
|
|
1465
1570
|
}): string | undefined {
|
|
1466
1571
|
const description = control.description?.trim();
|
|
1467
1572
|
|
|
1468
|
-
if (!description
|
|
1573
|
+
if (!description) {
|
|
1574
|
+
return undefined;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const label = getControlLabelText(control).trim();
|
|
1578
|
+
|
|
1579
|
+
if (
|
|
1580
|
+
isToolcraftObviousColorSectionControlDescription({
|
|
1581
|
+
control,
|
|
1582
|
+
description,
|
|
1583
|
+
label,
|
|
1584
|
+
sectionTitle,
|
|
1585
|
+
})
|
|
1586
|
+
) {
|
|
1587
|
+
return `${sectionLabel} / ${controlId} description adds a help icon to an obvious color-section control. Omit control.description when the section title and visible label already explain the setting.`;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (control.type !== "fontPicker") {
|
|
1469
1591
|
return undefined;
|
|
1470
1592
|
}
|
|
1471
1593
|
|
|
@@ -1480,6 +1602,52 @@ function getToolcraftControlDescriptionError({
|
|
|
1480
1602
|
return `${sectionLabel} / ${controlId} description repeats FontPicker-owned fields (${repeatedParts.join(", ")}). FontPicker help must explain only non-obvious product behavior; use section titles and visible field labels for font family, weight, size, case, color, opacity, letter spacing, and line height, or omit description.`;
|
|
1481
1603
|
}
|
|
1482
1604
|
|
|
1605
|
+
function isToolcraftColorSectionTitle(sectionTitle: string | undefined): boolean {
|
|
1606
|
+
return /\b(colou?rs?|palette|palettes)\b/i.test(sectionTitle ?? "");
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function isToolcraftSequentialColorLabel(label: string): boolean {
|
|
1610
|
+
return /^colou?r\s+\d+$/i.test(label.trim());
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function isToolcraftSimplePaletteDistributionLabel(label: string): boolean {
|
|
1614
|
+
return /^(spread|mix|distribution)$/i.test(label.trim());
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function isToolcraftGenericControlHelpDescription(description: string): boolean {
|
|
1618
|
+
return /^(adjusts?|changes?|chooses?|controls?|defines?|selects?|sets?|updates?)\b/i.test(
|
|
1619
|
+
description.trim(),
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function isToolcraftObviousColorSectionControlDescription({
|
|
1624
|
+
control,
|
|
1625
|
+
description,
|
|
1626
|
+
label,
|
|
1627
|
+
sectionTitle,
|
|
1628
|
+
}: {
|
|
1629
|
+
control: ToolcraftControlSchema;
|
|
1630
|
+
description: string;
|
|
1631
|
+
label: string;
|
|
1632
|
+
sectionTitle: string | undefined;
|
|
1633
|
+
}): boolean {
|
|
1634
|
+
if (!isToolcraftColorSectionTitle(sectionTitle)) {
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
if (
|
|
1639
|
+
(control.type === "color" || control.type === "colorOpacity") &&
|
|
1640
|
+
isToolcraftSequentialColorLabel(label)
|
|
1641
|
+
) {
|
|
1642
|
+
return true;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return (
|
|
1646
|
+
isToolcraftSimplePaletteDistributionLabel(label) &&
|
|
1647
|
+
isToolcraftGenericControlHelpDescription(description)
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1483
1651
|
function getToolcraftControlSectionGroupingErrors(
|
|
1484
1652
|
schema: ResolvedToolcraftAppSchema,
|
|
1485
1653
|
): string[] {
|
|
@@ -1588,6 +1756,7 @@ function getToolcraftControlSectionGroupingErrors(
|
|
|
1588
1756
|
control,
|
|
1589
1757
|
controlId,
|
|
1590
1758
|
sectionLabel,
|
|
1759
|
+
sectionTitle,
|
|
1591
1760
|
});
|
|
1592
1761
|
|
|
1593
1762
|
if (descriptionError) {
|
|
@@ -1823,7 +1992,7 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
1823
1992
|
|
|
1824
1993
|
if (unsafeBooleanLabels.length > 0) {
|
|
1825
1994
|
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,
|
|
1995
|
+
`${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
1996
|
);
|
|
1828
1997
|
}
|
|
1829
1998
|
}
|
|
@@ -1861,15 +2030,199 @@ export function validateToolcraftAcceptanceCoverage(
|
|
|
1861
2030
|
}
|
|
1862
2031
|
|
|
1863
2032
|
if (schemaHasPngExportPanelAction(schema)) {
|
|
2033
|
+
const backgroundSection = getSchemaControlsSectionByTitle(schema, "Background");
|
|
2034
|
+
const backgroundSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Background");
|
|
2035
|
+
const panelActionsSectionIndex = getFirstPanelActionsSectionIndex(schema);
|
|
2036
|
+
const imageExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Image Export");
|
|
2037
|
+
const videoExportSectionIndex = getSchemaControlsSectionIndexByTitle(schema, "Video Export");
|
|
2038
|
+
const hasVideoExportAction = schemaHasVideoExportPanelAction(schema);
|
|
2039
|
+
const expectedOutputSettingsIndex =
|
|
2040
|
+
imageExportSectionIndex >= 0 ? imageExportSectionIndex : videoExportSectionIndex;
|
|
2041
|
+
const finalExportSettingsIndex = hasVideoExportAction
|
|
2042
|
+
? videoExportSectionIndex
|
|
2043
|
+
: imageExportSectionIndex;
|
|
2044
|
+
const includeBackgroundEntry = getSectionControlEntryByTarget(
|
|
2045
|
+
backgroundSection,
|
|
2046
|
+
"export.includeBackground",
|
|
2047
|
+
);
|
|
2048
|
+
const backgroundColorEntry = getOutputBackgroundColorEntry(backgroundSection);
|
|
2049
|
+
const imageExportSection = getSchemaControlsSectionByTitle(schema, "Image Export");
|
|
2050
|
+
const imageFormatEntry = getSectionControlEntryByTarget(
|
|
2051
|
+
imageExportSection,
|
|
2052
|
+
"export.image.format",
|
|
2053
|
+
);
|
|
2054
|
+
const imageResolutionEntry = getSectionControlEntryByTarget(
|
|
2055
|
+
imageExportSection,
|
|
2056
|
+
"export.image.resolution",
|
|
2057
|
+
);
|
|
2058
|
+
const imageFormatControl = imageFormatEntry?.[1];
|
|
2059
|
+
const imageResolutionControl = imageResolutionEntry?.[1];
|
|
2060
|
+
const imageFormatOptionValues =
|
|
2061
|
+
imageFormatControl?.options?.map((option) => option.value.toLowerCase()) ?? [];
|
|
2062
|
+
const imageResolutionOptionValues =
|
|
2063
|
+
imageResolutionControl?.options?.map((option) => option.value.toLowerCase()) ?? [];
|
|
2064
|
+
|
|
2065
|
+
if (!backgroundSection) {
|
|
2066
|
+
errors.push(
|
|
2067
|
+
'Product apps with Export PNG must expose a separate controls section titled "Background" directly before the first export settings section.',
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
if (
|
|
2072
|
+
backgroundSectionIndex >= 0 &&
|
|
2073
|
+
expectedOutputSettingsIndex >= 0 &&
|
|
2074
|
+
backgroundSectionIndex !== expectedOutputSettingsIndex - 1
|
|
2075
|
+
) {
|
|
2076
|
+
errors.push(
|
|
2077
|
+
'The "Background" controls section must sit directly before the first export settings section: Image Export when PNG export exists, otherwise Video Export.',
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
if (
|
|
2082
|
+
finalExportSettingsIndex >= 0 &&
|
|
2083
|
+
panelActionsSectionIndex >= 0 &&
|
|
2084
|
+
finalExportSettingsIndex !== panelActionsSectionIndex - 1
|
|
2085
|
+
) {
|
|
2086
|
+
errors.push(
|
|
2087
|
+
'Export settings must sit directly above sticky footer actions: Image Export for still apps, or Video Export after Image Export for animated apps.',
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
if (
|
|
2092
|
+
hasVideoExportAction &&
|
|
2093
|
+
imageExportSectionIndex >= 0 &&
|
|
2094
|
+
videoExportSectionIndex >= 0 &&
|
|
2095
|
+
imageExportSectionIndex !== videoExportSectionIndex - 1
|
|
2096
|
+
) {
|
|
2097
|
+
errors.push(
|
|
2098
|
+
'Animated apps with both Export PNG and Export Video must place Image Export immediately before Video Export.',
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1864
2102
|
if (!schemaHasOutputBackgroundColorControl(controls)) {
|
|
1865
2103
|
errors.push(
|
|
1866
2104
|
"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
2105
|
);
|
|
1868
2106
|
}
|
|
1869
2107
|
|
|
2108
|
+
if (!backgroundColorEntry) {
|
|
2109
|
+
errors.push(
|
|
2110
|
+
'The "Background" section must contain the renderer-owned background color control, such as appearance.background or scene.background.',
|
|
2111
|
+
);
|
|
2112
|
+
} else {
|
|
2113
|
+
const [, backgroundColorControl] = backgroundColorEntry;
|
|
2114
|
+
|
|
2115
|
+
if (backgroundColorControl.label !== false) {
|
|
2116
|
+
errors.push(
|
|
2117
|
+
'The background color control inside the required "Background" section must use label false; the section title already supplies the visible context.',
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
1870
2122
|
if (!schemaHasOutputBackgroundToggleControl(controls)) {
|
|
1871
2123
|
errors.push(
|
|
1872
|
-
|
|
2124
|
+
'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.',
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
if (!includeBackgroundEntry) {
|
|
2129
|
+
errors.push(
|
|
2130
|
+
'The "Background" section must contain export.includeBackground as the Include switch.',
|
|
2131
|
+
);
|
|
2132
|
+
} else {
|
|
2133
|
+
const [, includeBackgroundControl] = includeBackgroundEntry;
|
|
2134
|
+
|
|
2135
|
+
if (includeBackgroundControl.type !== "switch") {
|
|
2136
|
+
errors.push('export.includeBackground must be a Switch control labeled "Include".');
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
if (getControlLabelText(includeBackgroundControl) !== "Include") {
|
|
2140
|
+
errors.push(
|
|
2141
|
+
'export.includeBackground must use the short visible label "Include"; the Background section title already supplies the rest of the context.',
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
if (
|
|
2147
|
+
!sectionHasEqualWidthOutputBackgroundRow(
|
|
2148
|
+
backgroundSection,
|
|
2149
|
+
includeBackgroundEntry?.[0],
|
|
2150
|
+
backgroundColorEntry?.[0],
|
|
2151
|
+
)
|
|
2152
|
+
) {
|
|
2153
|
+
errors.push(
|
|
2154
|
+
'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.',
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
if (!imageExportSection) {
|
|
2159
|
+
errors.push(
|
|
2160
|
+
'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.',
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (!imageFormatControl) {
|
|
2165
|
+
errors.push(
|
|
2166
|
+
'The separate "Image Export" section must include a format control with target "export.image.format".',
|
|
2167
|
+
);
|
|
2168
|
+
} else {
|
|
2169
|
+
if (imageFormatControl.type !== "select") {
|
|
2170
|
+
errors.push(
|
|
2171
|
+
'Image Export format must be a Select control so it matches the Video Export settings structure.',
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (!imageFormatOptionValues.includes("png") || !imageFormatOptionValues.includes("jpg")) {
|
|
2176
|
+
errors.push('Image Export format options must include "png" and "jpg".');
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
if (imageFormatControl.defaultValue !== "png") {
|
|
2180
|
+
errors.push('Image Export format must default to "png".');
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (!imageResolutionControl) {
|
|
2185
|
+
errors.push(
|
|
2186
|
+
'The separate "Image Export" section must include a resolution control with target "export.image.resolution".',
|
|
2187
|
+
);
|
|
2188
|
+
} else {
|
|
2189
|
+
if (imageResolutionControl.type !== "select") {
|
|
2190
|
+
errors.push(
|
|
2191
|
+
'Image Export resolution must be a Select control so it matches the Video Export settings structure.',
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
if (
|
|
2196
|
+
!imageResolutionOptionValues.includes("2k") ||
|
|
2197
|
+
!imageResolutionOptionValues.includes("4k") ||
|
|
2198
|
+
!imageResolutionOptionValues.includes("8k")
|
|
2199
|
+
) {
|
|
2200
|
+
errors.push(
|
|
2201
|
+
'Image Export resolution options must include "2k", "4k", and "8k".',
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
if (imageResolutionControl.defaultValue !== "4k") {
|
|
2206
|
+
errors.push('Image Export resolution must default to "4k".');
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const imageFormatControlId = imageFormatEntry?.[0];
|
|
2211
|
+
const imageResolutionControlId = imageResolutionEntry?.[0];
|
|
2212
|
+
const imageExportHasInlinePair =
|
|
2213
|
+
imageExportSection === undefined ||
|
|
2214
|
+
imageFormatControlId === undefined ||
|
|
2215
|
+
imageResolutionControlId === undefined
|
|
2216
|
+
? false
|
|
2217
|
+
: sectionHasInlineLayoutGroupForPair(
|
|
2218
|
+
imageExportSection,
|
|
2219
|
+
imageFormatControlId,
|
|
2220
|
+
imageResolutionControlId,
|
|
2221
|
+
);
|
|
2222
|
+
|
|
2223
|
+
if (!imageExportHasInlinePair) {
|
|
2224
|
+
errors.push(
|
|
2225
|
+
"Image Export format and resolution must render as one compact two-column inline row, matching Video Export settings.",
|
|
1873
2226
|
);
|
|
1874
2227
|
}
|
|
1875
2228
|
}
|
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
const gridTemplateColumns = `repeat(${Math.max(
|
|
84
|
+
1,
|
|
85
|
+
Math.floor(columns),
|
|
86
|
+
)}, minmax(0, 1fr))`;
|
|
87
87
|
|
|
88
88
|
return (
|
|
89
89
|
<div
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { CloudArrowUpIcon, XIcon } from "@phosphor-icons/react";
|
|
4
|
+
import { CloudArrowUpIcon, PlusIcon, XIcon } from "@phosphor-icons/react";
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../../lib/utils";
|
|
7
7
|
import { Button, Field } from "../../primitives";
|
|
8
8
|
|
|
9
9
|
export type FileDropPreview = {
|
|
10
10
|
alt?: string;
|
|
11
|
+
id?: string;
|
|
11
12
|
size?: {
|
|
12
13
|
height: number;
|
|
13
14
|
width: number;
|
|
@@ -17,9 +18,13 @@ export type FileDropPreview = {
|
|
|
17
18
|
|
|
18
19
|
export type FileDropControlProps = {
|
|
19
20
|
accept: string;
|
|
21
|
+
multiple?: boolean;
|
|
20
22
|
onClear?: () => void;
|
|
21
23
|
onFileSelect?: (file: File) => void;
|
|
24
|
+
onFilesSelect?: (files: File[]) => void;
|
|
25
|
+
onPreviewRemove?: (preview: FileDropPreview, index: number) => void;
|
|
22
26
|
preview?: FileDropPreview;
|
|
27
|
+
previews?: readonly FileDropPreview[];
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
function isDragLeavingCurrentTarget(event: React.DragEvent<HTMLElement>): boolean {
|
|
@@ -73,20 +78,38 @@ function getPreviewImageStyle(size: FileDropPreview["size"]): React.CSSPropertie
|
|
|
73
78
|
|
|
74
79
|
export function FileDropControl({
|
|
75
80
|
accept,
|
|
81
|
+
multiple = false,
|
|
76
82
|
onClear,
|
|
77
83
|
onFileSelect,
|
|
84
|
+
onFilesSelect,
|
|
85
|
+
onPreviewRemove,
|
|
78
86
|
preview,
|
|
87
|
+
previews,
|
|
79
88
|
}: FileDropControlProps): React.JSX.Element {
|
|
80
89
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
81
90
|
const [dragOver, setDragOver] = React.useState(false);
|
|
82
|
-
const
|
|
91
|
+
const previewItems = previews ?? (preview ? [preview] : []);
|
|
92
|
+
const hasPreview = previewItems.some((item) => Boolean(item.src));
|
|
93
|
+
const shouldRenderPreviewGrid = multiple && previewItems.length > 1;
|
|
83
94
|
|
|
84
|
-
function
|
|
85
|
-
|
|
95
|
+
function handleFiles(fileList: FileList | readonly File[] | undefined): void {
|
|
96
|
+
const files = Array.from(fileList ?? []);
|
|
97
|
+
|
|
98
|
+
if (files.length === 0) {
|
|
86
99
|
return;
|
|
87
100
|
}
|
|
88
101
|
|
|
89
|
-
|
|
102
|
+
if (multiple) {
|
|
103
|
+
if (onFilesSelect) {
|
|
104
|
+
onFilesSelect(files);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
files.forEach((file) => onFileSelect?.(file));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onFileSelect?.(files[0]);
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
function openFileDialog(): void {
|
|
@@ -109,15 +132,24 @@ export function FileDropControl({
|
|
|
109
132
|
aria-hidden="true"
|
|
110
133
|
className="hidden"
|
|
111
134
|
onChange={(event) => {
|
|
112
|
-
|
|
135
|
+
handleFiles(event.currentTarget.files ?? undefined);
|
|
113
136
|
event.currentTarget.value = "";
|
|
114
137
|
}}
|
|
138
|
+
multiple={multiple}
|
|
115
139
|
ref={inputRef}
|
|
116
140
|
tabIndex={-1}
|
|
117
141
|
type="file"
|
|
118
142
|
/>
|
|
119
143
|
<div
|
|
120
|
-
aria-label={
|
|
144
|
+
aria-label={
|
|
145
|
+
hasPreview
|
|
146
|
+
? multiple
|
|
147
|
+
? "Drop image files"
|
|
148
|
+
: "Replace image file"
|
|
149
|
+
: multiple
|
|
150
|
+
? "Browse image files"
|
|
151
|
+
: "Browse image file"
|
|
152
|
+
}
|
|
121
153
|
className={cn(
|
|
122
154
|
"group/file-upload relative flex min-h-16 w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-[color:color-mix(in_oklab,var(--border)_18%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_3%,transparent)] text-center shadow-none transition-[background-color,border-color,box-shadow] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)] data-[drag-over=true]:border-[color:color-mix(in_oklab,var(--link)_28%,transparent)] data-[drag-over=true]:bg-[color:color-mix(in_oklab,var(--link)_13%,transparent)] data-[drag-over=true]:shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--background)]",
|
|
123
155
|
hasPreview ? "overflow-hidden p-2" : "px-3 py-3",
|
|
@@ -140,23 +172,74 @@ export function FileDropControl({
|
|
|
140
172
|
onDrop={(event) => {
|
|
141
173
|
event.preventDefault();
|
|
142
174
|
setDragOver(false);
|
|
143
|
-
|
|
175
|
+
handleFiles(event.dataTransfer?.files ?? undefined);
|
|
144
176
|
}}
|
|
145
177
|
onKeyDown={handleDropTargetKeyDown}
|
|
146
178
|
role="button"
|
|
147
179
|
tabIndex={0}
|
|
148
180
|
>
|
|
149
|
-
{
|
|
181
|
+
{shouldRenderPreviewGrid ? (
|
|
182
|
+
<div
|
|
183
|
+
className="grid w-full grid-cols-4 gap-2"
|
|
184
|
+
data-slot="file-upload-preview-grid"
|
|
185
|
+
>
|
|
186
|
+
{previewItems.map((item, index) => (
|
|
187
|
+
<div
|
|
188
|
+
className="relative aspect-square min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)-4px)] bg-[color:color-mix(in_oklab,var(--foreground)_6%,transparent)]"
|
|
189
|
+
data-slot="file-upload-preview-item"
|
|
190
|
+
key={item.id ?? `${item.src}-${index}`}
|
|
191
|
+
>
|
|
192
|
+
<img
|
|
193
|
+
alt={item.alt ?? ""}
|
|
194
|
+
className="size-full object-cover"
|
|
195
|
+
draggable={false}
|
|
196
|
+
height={item.size?.height}
|
|
197
|
+
src={item.src}
|
|
198
|
+
width={item.size?.width}
|
|
199
|
+
/>
|
|
200
|
+
{onPreviewRemove ? (
|
|
201
|
+
<Button
|
|
202
|
+
aria-label={`Remove ${item.alt ?? "image"}`}
|
|
203
|
+
className="absolute top-1 right-1"
|
|
204
|
+
onClick={(event) => {
|
|
205
|
+
event.stopPropagation();
|
|
206
|
+
onPreviewRemove(item, index);
|
|
207
|
+
}}
|
|
208
|
+
size="icon-sm"
|
|
209
|
+
type="button"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
>
|
|
212
|
+
<XIcon className="drop-shadow-[0_2px_1px_color-mix(in_oklab,var(--background)_80%,transparent)]" />
|
|
213
|
+
</Button>
|
|
214
|
+
) : null}
|
|
215
|
+
</div>
|
|
216
|
+
))}
|
|
217
|
+
<button
|
|
218
|
+
aria-label="Add image files"
|
|
219
|
+
className="flex aspect-square min-w-0 items-center justify-center rounded-[calc(var(--radius-lg)-4px)] border border-[color:color-mix(in_oklab,var(--border)_5%,transparent)] bg-[color:color-mix(in_oklab,var(--foreground)_4%,transparent)] text-[color:color-mix(in_oklab,var(--foreground)_65%,transparent)] transition-[background-color,border-color,color] duration-150 ease-out hover:border-[color:color-mix(in_oklab,var(--border)_35%,transparent)] hover:bg-[color:color-mix(in_oklab,var(--foreground)_7%,transparent)] hover:text-[color:var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ring)]"
|
|
220
|
+
data-slot="file-upload-add-preview"
|
|
221
|
+
onClick={(event) => {
|
|
222
|
+
event.stopPropagation();
|
|
223
|
+
openFileDialog();
|
|
224
|
+
}}
|
|
225
|
+
type="button"
|
|
226
|
+
>
|
|
227
|
+
<PlusIcon className="size-4" weight="regular" />
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
) : hasPreview ? (
|
|
150
231
|
<>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
232
|
+
{previewItems[0] ? (
|
|
233
|
+
<img
|
|
234
|
+
alt={previewItems[0].alt ?? ""}
|
|
235
|
+
className="block h-auto max-h-[196px] max-w-full rounded-[calc(var(--radius-lg)-4px)] object-contain"
|
|
236
|
+
draggable={false}
|
|
237
|
+
height={previewItems[0].size?.height}
|
|
238
|
+
src={previewItems[0].src}
|
|
239
|
+
style={getPreviewImageStyle(previewItems[0].size)}
|
|
240
|
+
width={previewItems[0].size?.width}
|
|
241
|
+
/>
|
|
242
|
+
) : null}
|
|
160
243
|
{onClear ? (
|
|
161
244
|
<Button
|
|
162
245
|
aria-label="Remove image"
|
|
@@ -127,7 +127,7 @@ const textCaseOptions: Array<{
|
|
|
127
127
|
label: string;
|
|
128
128
|
value: FontPickerTextCasePreset;
|
|
129
129
|
}> = [
|
|
130
|
-
{ label: "
|
|
130
|
+
{ label: "As typed", value: "original" },
|
|
131
131
|
{ label: "Uppercase", value: "uppercase" },
|
|
132
132
|
{ label: "Lowercase", value: "lowercase" },
|
|
133
133
|
{ label: "Capitalize", value: "capitalize" },
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { applySliderValueLabelUnit } from "../slider/slider-value";
|
|
2
|
+
|
|
1
3
|
export function formatRangeSliderValue(
|
|
2
4
|
value: readonly number[],
|
|
3
5
|
unit?: string,
|
|
4
6
|
): string {
|
|
5
|
-
const formatValue = (item: number): string =>
|
|
7
|
+
const formatValue = (item: number): string =>
|
|
8
|
+
applySliderValueLabelUnit(String(Math.round(item)), unit);
|
|
6
9
|
|
|
7
10
|
if (value.length >= 2 && value[0] === value[1]) {
|
|
8
11
|
return formatValue(value[0] ?? 0);
|