@agent-native/core 0.80.6 → 0.80.8
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +70 -27
- package/corpus/core/src/client/analytics.ts +3 -1
- package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
- package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
- package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +34 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
- package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
- package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
- package/corpus/templates/design/AGENTS.md +4 -2
- package/corpus/templates/design/actions/edit-design.ts +6 -3
- package/corpus/templates/design/actions/generate-screens.ts +5 -3
- package/corpus/templates/design/actions/update-design.ts +47 -10
- package/corpus/templates/design/actions/update-file.ts +33 -8
- package/corpus/templates/design/actions/view-screen.ts +14 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
- package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
- package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
- package/corpus/templates/design/e2e/README.md +1 -0
- package/corpus/templates/design/e2e/global-setup.ts +13 -1
- package/corpus/templates/design/playwright.config.ts +10 -1
- package/corpus/templates/design/shared/color-utils.ts +159 -0
- package/corpus/templates/design/shared/generation-session.ts +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +54 -24
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +2 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
- package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/client/extensions/EmbeddedExtension.js +34 -3
- package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +5 -5
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useT } from "@agent-native/core/client";
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_ASSIGNED_REGION_GAP,
|
|
4
|
+
DEFAULT_ASSIGNED_REGION_HEIGHT,
|
|
5
|
+
DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
|
|
6
|
+
DEFAULT_ASSIGNED_REGION_WIDTH,
|
|
3
7
|
DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
|
|
4
8
|
appendPolylinePoint,
|
|
5
9
|
computeMoveSnap,
|
|
@@ -155,6 +159,7 @@ interface MultiScreenCanvasProps {
|
|
|
155
159
|
) => ReactNode;
|
|
156
160
|
selectAllRequest?: number;
|
|
157
161
|
clearSelectionRequest?: number;
|
|
162
|
+
onSelectionChange?: (selectedIds: string[]) => void;
|
|
158
163
|
}
|
|
159
164
|
|
|
160
165
|
/**
|
|
@@ -413,6 +418,7 @@ export function MultiScreenCanvas({
|
|
|
413
418
|
renderScreenContent,
|
|
414
419
|
selectAllRequest,
|
|
415
420
|
clearSelectionRequest,
|
|
421
|
+
onSelectionChange,
|
|
416
422
|
}: MultiScreenCanvasProps) {
|
|
417
423
|
const t = useT();
|
|
418
424
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -543,8 +549,14 @@ export function MultiScreenCanvas({
|
|
|
543
549
|
frameGeometryRef.current = frameGeometry;
|
|
544
550
|
}, [frameGeometry]);
|
|
545
551
|
|
|
552
|
+
const onSelectionChangeRef = useRef(onSelectionChange);
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
555
|
+
}, [onSelectionChange]);
|
|
556
|
+
|
|
546
557
|
useEffect(() => {
|
|
547
558
|
selectedIdsRef.current = selectedIds;
|
|
559
|
+
onSelectionChangeRef.current?.(selectedIds);
|
|
548
560
|
}, [selectedIds]);
|
|
549
561
|
|
|
550
562
|
useEffect(() => {
|
|
@@ -704,17 +716,20 @@ export function MultiScreenCanvas({
|
|
|
704
716
|
(point: Point) =>
|
|
705
717
|
getCurrentFrameEntries()
|
|
706
718
|
.map((entry, index) => ({ ...entry, index }))
|
|
707
|
-
.filter((entry) =>
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
719
|
+
.filter((entry) => {
|
|
720
|
+
const bounds = {
|
|
721
|
+
left: entry.geometry.x,
|
|
722
|
+
top: entry.geometry.y,
|
|
723
|
+
right: entry.geometry.x + entry.geometry.width,
|
|
724
|
+
bottom: entry.geometry.y + entry.geometry.height,
|
|
725
|
+
};
|
|
726
|
+
const local = rotatePointAroundCenter(
|
|
715
727
|
point,
|
|
716
|
-
|
|
717
|
-
|
|
728
|
+
getFrameCenter(entry.geometry),
|
|
729
|
+
entry.geometry.rotation ?? 0,
|
|
730
|
+
);
|
|
731
|
+
return rectContainsPoint(bounds, local);
|
|
732
|
+
})
|
|
718
733
|
.sort(
|
|
719
734
|
(a, b) =>
|
|
720
735
|
(b.geometry.z ?? 0) - (a.geometry.z ?? 0) || b.index - a.index,
|
|
@@ -912,12 +927,22 @@ export function MultiScreenCanvas({
|
|
|
912
927
|
|
|
913
928
|
const hitIds = getCurrentFrameEntries()
|
|
914
929
|
.filter((entry) =>
|
|
915
|
-
|
|
930
|
+
rotatedRectIntersects(
|
|
931
|
+
rect,
|
|
932
|
+
getSelectableBounds(entry.geometry),
|
|
933
|
+
getFrameCenter(entry.geometry),
|
|
934
|
+
entry.geometry.rotation ?? 0,
|
|
935
|
+
),
|
|
916
936
|
)
|
|
917
937
|
.map((entry) => entry.id);
|
|
918
938
|
const hitDraftIds = getCurrentDraftEntries()
|
|
919
939
|
.filter((entry) =>
|
|
920
|
-
|
|
940
|
+
rotatedRectIntersects(
|
|
941
|
+
rect,
|
|
942
|
+
getSelectableBounds(entry.geometry),
|
|
943
|
+
getFrameCenter(entry.geometry),
|
|
944
|
+
entry.geometry.rotation ?? 0,
|
|
945
|
+
),
|
|
921
946
|
)
|
|
922
947
|
.map((entry) => entry.id);
|
|
923
948
|
updateSelectedIds(() =>
|
|
@@ -963,17 +988,21 @@ export function MultiScreenCanvas({
|
|
|
963
988
|
if (preferred) return preferred;
|
|
964
989
|
|
|
965
990
|
return entries
|
|
966
|
-
.filter(({ geometry }) =>
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
991
|
+
.filter(({ geometry }) => {
|
|
992
|
+
const bounds = {
|
|
993
|
+
left: geometry.x,
|
|
994
|
+
top: geometry.y,
|
|
995
|
+
right: geometry.x + geometry.width,
|
|
996
|
+
bottom: geometry.y + geometry.height,
|
|
997
|
+
};
|
|
998
|
+
const draftCenter = getFrameCenter(draft.geometry);
|
|
999
|
+
const local = rotatePointAroundCenter(
|
|
1000
|
+
draftCenter,
|
|
1001
|
+
getFrameCenter(geometry),
|
|
1002
|
+
geometry.rotation ?? 0,
|
|
1003
|
+
);
|
|
1004
|
+
return rectContainsPoint(bounds, local);
|
|
1005
|
+
})
|
|
977
1006
|
.sort((a, b) => (b.geometry.z ?? 0) - (a.geometry.z ?? 0))[0];
|
|
978
1007
|
},
|
|
979
1008
|
[getCurrentFrameEntries],
|
|
@@ -1499,16 +1528,6 @@ export function MultiScreenCanvas({
|
|
|
1499
1528
|
);
|
|
1500
1529
|
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
1501
1530
|
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
1502
|
-
} else {
|
|
1503
|
-
// No draft landed in a frame — restore each moved draft to its
|
|
1504
|
-
// original position so the move is atomic (commit or revert).
|
|
1505
|
-
updateDraftPrimitives((current) =>
|
|
1506
|
-
current.map((draft) => {
|
|
1507
|
-
if (!state.targetIds.includes(draft.id)) return draft;
|
|
1508
|
-
const origin = state.originDrafts[draft.id];
|
|
1509
|
-
return origin ?? draft;
|
|
1510
|
-
}),
|
|
1511
|
-
);
|
|
1512
1531
|
}
|
|
1513
1532
|
}
|
|
1514
1533
|
finishDrag();
|
|
@@ -1679,6 +1698,11 @@ export function MultiScreenCanvas({
|
|
|
1679
1698
|
if (e.button !== 0 || e.shiftKey) return;
|
|
1680
1699
|
e.preventDefault();
|
|
1681
1700
|
e.stopPropagation();
|
|
1701
|
+
// Frame mousedowns stop propagation, so they never reach handleMouseDown.
|
|
1702
|
+
// Clear stale suppression here too so a prior gesture can't swallow this
|
|
1703
|
+
// frame's selecting click. This gesture re-arms suppression on mouse-up
|
|
1704
|
+
// only if it actually moves.
|
|
1705
|
+
suppressNextPick.current = false;
|
|
1682
1706
|
|
|
1683
1707
|
const currentSelectedIds = selectedIdsRef.current;
|
|
1684
1708
|
const targetIds = currentSelectedIds.includes(id)
|
|
@@ -2161,6 +2185,10 @@ export function MultiScreenCanvas({
|
|
|
2161
2185
|
|
|
2162
2186
|
const handleMouseDown = useCallback(
|
|
2163
2187
|
(e: React.MouseEvent) => {
|
|
2188
|
+
// Clear any stale pick-suppression left over from a prior resize/rotate/move
|
|
2189
|
+
// gesture that never received its trailing frame click — otherwise it would
|
|
2190
|
+
// silently swallow this unrelated interaction.
|
|
2191
|
+
suppressNextPick.current = false;
|
|
2164
2192
|
const target = e.target as HTMLElement;
|
|
2165
2193
|
const onFrame = !!target.closest("[data-frame-shell]");
|
|
2166
2194
|
const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
|
|
@@ -3080,6 +3108,10 @@ function Screen({
|
|
|
3080
3108
|
onMouseDown={(e) => {
|
|
3081
3109
|
if (e.button !== 0) return;
|
|
3082
3110
|
if (penActive || creationToolActive) return;
|
|
3111
|
+
if (e.altKey) {
|
|
3112
|
+
onStartDuplicateGesture(screen, display, e);
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3083
3115
|
if (e.shiftKey) {
|
|
3084
3116
|
e.stopPropagation();
|
|
3085
3117
|
onPick(screen.id, e);
|
|
@@ -3444,6 +3476,11 @@ interface BoundsRect {
|
|
|
3444
3476
|
}
|
|
3445
3477
|
|
|
3446
3478
|
function getInitialFrameGeometry(index: number): FrameGeometry {
|
|
3479
|
+
// Seed default frames with the actual screen dimensions and the 3-column grid
|
|
3480
|
+
// the overview centering math (which uses SCREEN_WIDTH/SCREEN_GAP) expects, so
|
|
3481
|
+
// a design without persisted geometry opens centered. (The larger
|
|
3482
|
+
// assigned-region grid is only for the agent's generation planning, not the
|
|
3483
|
+
// editor's default placement.)
|
|
3447
3484
|
const column = index % 3;
|
|
3448
3485
|
const row = Math.floor(index / 3);
|
|
3449
3486
|
return {
|
|
@@ -3571,6 +3608,84 @@ function rectContainsPoint(bounds: BoundsRect, point: Point) {
|
|
|
3571
3608
|
);
|
|
3572
3609
|
}
|
|
3573
3610
|
|
|
3611
|
+
/** Rotate `point` into the local (unrotated) space of a frame whose center is
|
|
3612
|
+
* `center` and whose CSS transform is `rotate(degrees deg)`. Passing the
|
|
3613
|
+
* inverse rotation maps world-space coordinates into the frame's local space
|
|
3614
|
+
* so unrotated bounds tests remain correct. */
|
|
3615
|
+
function rotatePointAroundCenter(
|
|
3616
|
+
point: Point,
|
|
3617
|
+
center: Point,
|
|
3618
|
+
degrees: number,
|
|
3619
|
+
): Point {
|
|
3620
|
+
if (!degrees) return point;
|
|
3621
|
+
const rad = (-degrees * Math.PI) / 180; // inverse rotation
|
|
3622
|
+
const dx = point.x - center.x;
|
|
3623
|
+
const dy = point.y - center.y;
|
|
3624
|
+
const cos = Math.cos(rad);
|
|
3625
|
+
const sin = Math.sin(rad);
|
|
3626
|
+
return {
|
|
3627
|
+
x: center.x + dx * cos - dy * sin,
|
|
3628
|
+
y: center.y + dx * sin + dy * cos,
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
/** Test whether a marquee rect (in canvas space) intersects an OBB described by
|
|
3633
|
+
* `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
|
|
3634
|
+
* We map the four marquee corners into the frame's local space and check
|
|
3635
|
+
* whether any corner is inside the bounds; we also check the reverse (any
|
|
3636
|
+
* frame corner inside the marquee) to handle the case where the marquee is
|
|
3637
|
+
* entirely contained within the rotated frame. */
|
|
3638
|
+
function rotatedRectIntersects(
|
|
3639
|
+
rect: MarqueeRect,
|
|
3640
|
+
bounds: BoundsRect,
|
|
3641
|
+
center: Point,
|
|
3642
|
+
degrees: number,
|
|
3643
|
+
): boolean {
|
|
3644
|
+
if (!degrees) {
|
|
3645
|
+
return rectIntersects(rect, bounds);
|
|
3646
|
+
}
|
|
3647
|
+
// Four corners of the marquee rect in canvas space.
|
|
3648
|
+
const marqueeCorners: Point[] = [
|
|
3649
|
+
{ x: rect.x, y: rect.y },
|
|
3650
|
+
{ x: rect.x + rect.width, y: rect.y },
|
|
3651
|
+
{ x: rect.x + rect.width, y: rect.y + rect.height },
|
|
3652
|
+
{ x: rect.x, y: rect.y + rect.height },
|
|
3653
|
+
];
|
|
3654
|
+
// Test: any marquee corner inside the unrotated frame bounds?
|
|
3655
|
+
for (const corner of marqueeCorners) {
|
|
3656
|
+
const local = rotatePointAroundCenter(corner, center, degrees);
|
|
3657
|
+
if (rectContainsPoint(bounds, local)) return true;
|
|
3658
|
+
}
|
|
3659
|
+
// Four corners of the unrotated frame in canvas space (rotate them outward).
|
|
3660
|
+
const frameCorners: Point[] = [
|
|
3661
|
+
{ x: bounds.left, y: bounds.top },
|
|
3662
|
+
{ x: bounds.right, y: bounds.top },
|
|
3663
|
+
{ x: bounds.right, y: bounds.bottom },
|
|
3664
|
+
{ x: bounds.left, y: bounds.bottom },
|
|
3665
|
+
];
|
|
3666
|
+
const rad = (degrees * Math.PI) / 180;
|
|
3667
|
+
const cos = Math.cos(rad);
|
|
3668
|
+
const sin = Math.sin(rad);
|
|
3669
|
+
const marqueeRight = rect.x + rect.width;
|
|
3670
|
+
const marqueeBottom = rect.y + rect.height;
|
|
3671
|
+
// Test: any rotated frame corner inside the marquee rect?
|
|
3672
|
+
for (const fc of frameCorners) {
|
|
3673
|
+
const dx = fc.x - center.x;
|
|
3674
|
+
const dy = fc.y - center.y;
|
|
3675
|
+
const wx = center.x + dx * cos - dy * sin;
|
|
3676
|
+
const wy = center.y + dx * sin + dy * cos;
|
|
3677
|
+
if (
|
|
3678
|
+
wx >= rect.x &&
|
|
3679
|
+
wx <= marqueeRight &&
|
|
3680
|
+
wy >= rect.y &&
|
|
3681
|
+
wy <= marqueeBottom
|
|
3682
|
+
) {
|
|
3683
|
+
return true;
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
return false;
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3574
3689
|
function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
|
|
3575
3690
|
return (
|
|
3576
3691
|
a.x === b.x &&
|
|
@@ -60,15 +60,17 @@ export function QuestionFlow({
|
|
|
60
60
|
setAnswers((prev) => ({ ...prev, [id]: value }));
|
|
61
61
|
}, []);
|
|
62
62
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
const isAnswered = (q: GuidedQuestion) => {
|
|
64
|
+
const v = answers[q.id];
|
|
65
|
+
if (q.type === "freeform" && typeof v === "string")
|
|
66
|
+
return v.trim().length > 0;
|
|
67
|
+
return hasGuidedAnswer(v);
|
|
68
|
+
};
|
|
69
|
+
const answeredCount = guidedQuestions.filter(isAnswered).length;
|
|
66
70
|
const requiredQuestions = guidedQuestions.filter(
|
|
67
71
|
(question) => question.required,
|
|
68
72
|
);
|
|
69
|
-
const requiredAnswered = requiredQuestions.filter(
|
|
70
|
-
hasGuidedAnswer(answers[question.id]),
|
|
71
|
-
).length;
|
|
73
|
+
const requiredAnswered = requiredQuestions.filter(isAnswered).length;
|
|
72
74
|
const allRequiredAnswered = requiredAnswered === requiredQuestions.length;
|
|
73
75
|
const progress =
|
|
74
76
|
guidedQuestions.length === 0
|
|
@@ -554,11 +556,9 @@ function SliderQuestion({
|
|
|
554
556
|
const current =
|
|
555
557
|
typeof value === "number" ? value : Math.round((min + max) / 2);
|
|
556
558
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
}, []);
|
|
559
|
+
// Do not auto-fill on mount: a required slider must be explicitly moved by
|
|
560
|
+
// the user before it counts as answered. `current` already provides a
|
|
561
|
+
// display-only midpoint fallback for the rendered slider position.
|
|
562
562
|
|
|
563
563
|
return (
|
|
564
564
|
<div className="rounded-md border border-border bg-muted/25 px-3 py-3">
|
|
@@ -285,19 +285,33 @@ function TweakControl({
|
|
|
285
285
|
|
|
286
286
|
{tweak.type === "slider" && (
|
|
287
287
|
<div className="flex h-6 items-center gap-1.5">
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
288
|
+
{(() => {
|
|
289
|
+
const sliderMin = tweak.min ?? 0;
|
|
290
|
+
const sliderMax = tweak.max ?? 100;
|
|
291
|
+
const rawNum = typeof value === "number" ? value : Number(value);
|
|
292
|
+
const sliderValue = Number.isFinite(rawNum)
|
|
293
|
+
? Math.min(sliderMax, Math.max(sliderMin, rawNum))
|
|
294
|
+
: sliderMin;
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<Slider
|
|
298
|
+
min={sliderMin}
|
|
299
|
+
max={sliderMax}
|
|
300
|
+
step={tweak.step ?? 1}
|
|
301
|
+
value={[sliderValue]}
|
|
302
|
+
onValueChange={([v]) => onChange(v)}
|
|
303
|
+
className="flex-1"
|
|
304
|
+
/>
|
|
305
|
+
<span className="min-w-[2ch] text-right text-[11px] tabular-nums text-muted-foreground">
|
|
306
|
+
{sliderValue}
|
|
307
|
+
{tweak.unit ??
|
|
308
|
+
(tweak.cssVar?.toLowerCase().includes("radius")
|
|
309
|
+
? "px"
|
|
310
|
+
: "")}
|
|
311
|
+
</span>
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
})()}
|
|
301
315
|
</div>
|
|
302
316
|
)}
|
|
303
317
|
</div>
|
|
@@ -1090,13 +1090,6 @@ export function SizingField({
|
|
|
1090
1090
|
}: SizingFieldProps) {
|
|
1091
1091
|
const labels = { ...DEFAULT_AUTO_LAYOUT_LABELS, ...labelOverrides };
|
|
1092
1092
|
const isWidth = sizingAxis === "horizontal";
|
|
1093
|
-
// Local pending state for a newly-opened constraint editor. Holds the kind
|
|
1094
|
-
// and seed value so the sub-row is visible before the user has confirmed a
|
|
1095
|
-
// value. We only commit to onMinMaxChange when the user types or scrubs.
|
|
1096
|
-
const [pending, setPending] = useState<{
|
|
1097
|
-
kind: "min" | "max";
|
|
1098
|
-
seed: number;
|
|
1099
|
-
} | null>(null);
|
|
1100
1093
|
|
|
1101
1094
|
const minValue = minMax?.min ?? null;
|
|
1102
1095
|
const maxValue = minMax?.max ?? null;
|
|
@@ -1116,12 +1109,11 @@ export function SizingField({
|
|
|
1116
1109
|
const maxLabel = isWidth ? labels.maxWidth : labels.maxHeight;
|
|
1117
1110
|
|
|
1118
1111
|
const openEditor = (kind: "min" | "max") => {
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
// scrubs/types in the ConstraintSubRow.
|
|
1112
|
+
// Commit immediately so the shown row always reflects real state and
|
|
1113
|
+
// persists across selection changes, remounts, and parent re-renders.
|
|
1122
1114
|
const seed = Math.max(0, Math.round(resolvedSize ?? 0));
|
|
1123
1115
|
const seedValue = kind === "min" ? seed : seed || 1;
|
|
1124
|
-
|
|
1116
|
+
onMinMaxChange?.(sizingAxis, kind, seedValue);
|
|
1125
1117
|
};
|
|
1126
1118
|
|
|
1127
1119
|
return (
|
|
@@ -1236,19 +1228,6 @@ export function SizingField({
|
|
|
1236
1228
|
onMinMaxChange?.(sizingAxis, "min", null);
|
|
1237
1229
|
}}
|
|
1238
1230
|
/>
|
|
1239
|
-
) : pending?.kind === "min" ? (
|
|
1240
|
-
// Pending (uncommitted) min row — shown before the user confirms a value.
|
|
1241
|
-
<ConstraintSubRow
|
|
1242
|
-
label={minLabel}
|
|
1243
|
-
value={pending.seed}
|
|
1244
|
-
disabled={disabled}
|
|
1245
|
-
removeLabel={labels.removeConstraint}
|
|
1246
|
-
onChange={(next) => {
|
|
1247
|
-
setPending(null);
|
|
1248
|
-
onMinMaxChange?.(sizingAxis, "min", next);
|
|
1249
|
-
}}
|
|
1250
|
-
onRemove={() => setPending(null)}
|
|
1251
|
-
/>
|
|
1252
1231
|
) : null}
|
|
1253
1232
|
{hasMax ? (
|
|
1254
1233
|
<ConstraintSubRow
|
|
@@ -1261,19 +1240,6 @@ export function SizingField({
|
|
|
1261
1240
|
onMinMaxChange?.(sizingAxis, "max", null);
|
|
1262
1241
|
}}
|
|
1263
1242
|
/>
|
|
1264
|
-
) : pending?.kind === "max" ? (
|
|
1265
|
-
// Pending (uncommitted) max row — shown before the user confirms a value.
|
|
1266
|
-
<ConstraintSubRow
|
|
1267
|
-
label={maxLabel}
|
|
1268
|
-
value={pending.seed}
|
|
1269
|
-
disabled={disabled}
|
|
1270
|
-
removeLabel={labels.removeConstraint}
|
|
1271
|
-
onChange={(next) => {
|
|
1272
|
-
setPending(null);
|
|
1273
|
-
onMinMaxChange?.(sizingAxis, "max", next);
|
|
1274
|
-
}}
|
|
1275
|
-
onRemove={() => setPending(null)}
|
|
1276
|
-
/>
|
|
1277
1243
|
) : null}
|
|
1278
1244
|
</div>
|
|
1279
1245
|
);
|
|
@@ -889,7 +889,17 @@ export function DesignColorPicker({
|
|
|
889
889
|
setPicking(true);
|
|
890
890
|
try {
|
|
891
891
|
const result = await new EyeDropperCtor().open();
|
|
892
|
-
if (result.sRGBHex)
|
|
892
|
+
if (result.sRGBHex) {
|
|
893
|
+
if (activeGradient) {
|
|
894
|
+
// In gradient mode, route to the selected stop (preserving its alpha)
|
|
895
|
+
// like every other color edit — don't replace the whole gradient with
|
|
896
|
+
// a solid hex.
|
|
897
|
+
const parsed = parseCssColor(result.sRGBHex);
|
|
898
|
+
if (parsed) emitStopColor({ ...parsed, a: fieldColor.a });
|
|
899
|
+
} else {
|
|
900
|
+
onChange(result.sRGBHex);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
893
903
|
} catch {
|
|
894
904
|
// Browser cancels are expected; keep the current color.
|
|
895
905
|
} finally {
|
|
@@ -1845,8 +1855,14 @@ export function inferPaintType(
|
|
|
1845
1855
|
if (
|
|
1846
1856
|
lower.startsWith("radial-gradient") ||
|
|
1847
1857
|
lower.startsWith("repeating-radial-gradient")
|
|
1848
|
-
)
|
|
1858
|
+
) {
|
|
1859
|
+
// Diamond fills serialize as a radial gradient (either "closest-corner"
|
|
1860
|
+
// from EditPanel or "ellipse closest-side" from GradientEditor). Recognize
|
|
1861
|
+
// both so a diamond doesn't silently become a plain radial on reselect.
|
|
1862
|
+
if (/closest-corner/.test(lower) || /ellipse\s+closest-side/.test(lower))
|
|
1863
|
+
return "diamond";
|
|
1849
1864
|
return "radial";
|
|
1865
|
+
}
|
|
1850
1866
|
if (
|
|
1851
1867
|
lower.startsWith("conic-gradient") ||
|
|
1852
1868
|
lower.startsWith("repeating-conic-gradient")
|
|
@@ -57,6 +57,11 @@ const DEFAULT_LABELS: ExportSettingsPanelLabels = {
|
|
|
57
57
|
|
|
58
58
|
const DEFAULT_FORMATS: ExportFormat[] = ["png", "jpg", "svg", "pdf", "webp"];
|
|
59
59
|
|
|
60
|
+
// Export scale bounds — must match the clamp in DesignEditor's PNG/SVG export
|
|
61
|
+
// handlers so the panel never promises a range the exporter won't render.
|
|
62
|
+
const MIN_EXPORT_SCALE = 0.1;
|
|
63
|
+
const MAX_EXPORT_SCALE = 4;
|
|
64
|
+
|
|
60
65
|
/** Preset scale options shown in the scale dropdown — matches the design editor's presets */
|
|
61
66
|
const SCALE_PRESETS: { label: string; value: ExportScale }[] = [
|
|
62
67
|
{ label: "0.5x", value: "0.5" },
|
|
@@ -297,13 +302,20 @@ function ExportRow({
|
|
|
297
302
|
type="number"
|
|
298
303
|
value={row.scale}
|
|
299
304
|
disabled={isDisabled}
|
|
300
|
-
|
|
301
|
-
|
|
305
|
+
// Bounds match the exporter's real clamp ([0.1, 4]); see
|
|
306
|
+
// DesignEditor handleDownload* . Previously the field accepted up to
|
|
307
|
+
// 100x but the exporter silently clamped to 4x with no feedback.
|
|
308
|
+
min={MIN_EXPORT_SCALE}
|
|
309
|
+
max={MAX_EXPORT_SCALE}
|
|
302
310
|
step={0.5}
|
|
303
311
|
onChange={(e) => {
|
|
304
312
|
const parsed = parseFloat(e.target.value);
|
|
305
313
|
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
306
|
-
|
|
314
|
+
const clamped = Math.min(
|
|
315
|
+
MAX_EXPORT_SCALE,
|
|
316
|
+
Math.max(MIN_EXPORT_SCALE, parsed),
|
|
317
|
+
);
|
|
318
|
+
onPatchRow(row.id, { scale: clamped });
|
|
307
319
|
}
|
|
308
320
|
}}
|
|
309
321
|
onBlur={() => {
|
|
@@ -167,10 +167,12 @@ export function parseGradientCss(
|
|
|
167
167
|
}
|
|
168
168
|
} else if (fn === "radial") {
|
|
169
169
|
kind =
|
|
170
|
-
/ellipse\s+closest-side/i.test(first) ||
|
|
170
|
+
/ellipse\s+closest-side/i.test(first) ||
|
|
171
|
+
/closest-corner/i.test(first) ||
|
|
172
|
+
fallbackKind === "diamond"
|
|
171
173
|
? "diamond"
|
|
172
174
|
: "radial";
|
|
173
|
-
if (/circle|ellipse|at\s
|
|
175
|
+
if (/circle|ellipse|at\s|closest-/i.test(first)) stopStart = 1;
|
|
174
176
|
} else if (fn === "conic") {
|
|
175
177
|
kind = "angular";
|
|
176
178
|
const angleMatch = first.match(ANGLE_RE);
|
|
@@ -34,6 +34,16 @@ const FIT_MODES: Array<{ mode: ImageFitMode; label: string }> = [
|
|
|
34
34
|
|
|
35
35
|
// ─── CSS serialization ─────────────────────────────────────────────────────────
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Escape a URL for embedding inside a double-quoted CSS url("...") token.
|
|
39
|
+
* Only `"` and `\` are CSS-significant inside a double-quoted string; newlines
|
|
40
|
+
* must also be stripped. Everything else (parens, commas, single-quotes, etc.)
|
|
41
|
+
* is safe inside double quotes, so we leave it alone to keep the URL intact.
|
|
42
|
+
*/
|
|
43
|
+
function escapeForQuotedUrl(url: string): string {
|
|
44
|
+
return url.replace(/\\/g, "\\\\").replace(/"/g, "%22").replace(/\r?\n/g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
const CHECKER_A = "#d4d4d4";
|
|
38
48
|
const CHECKERBOARD_IMAGE = `linear-gradient(45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(-45deg, ${CHECKER_A} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${CHECKER_A} 75%), linear-gradient(-45deg, transparent 75%, ${CHECKER_A} 75%)`;
|
|
39
49
|
const FIT_MARKER_RE =
|
|
@@ -55,7 +65,7 @@ function imageFitMarker(fit: ImageFitMode): string {
|
|
|
55
65
|
export function imageFillToCss(value: ImageFillValue): string {
|
|
56
66
|
const url = value.url.trim();
|
|
57
67
|
if (!url) return "transparent";
|
|
58
|
-
const safeUrl = url
|
|
68
|
+
const safeUrl = escapeForQuotedUrl(url);
|
|
59
69
|
const image = `url("${safeUrl}")`;
|
|
60
70
|
switch (value.fit) {
|
|
61
71
|
case "fit":
|
|
@@ -70,13 +80,17 @@ export function imageFillToCss(value: ImageFillValue): string {
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
|
|
83
|
+
// Matches url() in three forms:
|
|
84
|
+
// group 1 — double-quoted: url("...anything...")
|
|
85
|
+
// group 2 — single-quoted: url('...anything...')
|
|
86
|
+
// group 3 — unquoted: url(...no-parens-or-quotes...)
|
|
87
|
+
const URL_RE = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"]*?))\s*\)/i;
|
|
74
88
|
|
|
75
89
|
/** Extract the URL + fit mode from a CSS background value, if present. */
|
|
76
90
|
export function parseImageFillCss(value: string): ImageFillValue | null {
|
|
77
91
|
const match = value.match(URL_RE);
|
|
78
92
|
if (!match) return null;
|
|
79
|
-
const url = match[2];
|
|
93
|
+
const url = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
80
94
|
const marker = value.match(FIT_MARKER_RE)?.[1] as ImageFitMode | undefined;
|
|
81
95
|
if (marker) return { url, fit: marker };
|
|
82
96
|
// Heuristic fallback when no marker comment is present (e.g. CSS pasted from
|
|
@@ -141,7 +155,7 @@ export function ImageFillControls({
|
|
|
141
155
|
className="relative h-24 w-full overflow-hidden rounded-md border border-border/60"
|
|
142
156
|
style={{
|
|
143
157
|
backgroundImage: value.url
|
|
144
|
-
? `url("${value.url.trim()
|
|
158
|
+
? `url("${escapeForQuotedUrl(value.url.trim())}")`
|
|
145
159
|
: CHECKERBOARD_IMAGE,
|
|
146
160
|
backgroundSize: value.url
|
|
147
161
|
? value.fit === "fit"
|
|
@@ -58,10 +58,16 @@ export function formatScrubValue(
|
|
|
58
58
|
options: Pick<ScrubExpressionOptions, "precision" | "unit"> = {},
|
|
59
59
|
): string {
|
|
60
60
|
const normalized = normalizeScrubNumber(value, options);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
let numeric: string;
|
|
62
|
+
if (Number.isFinite(options.precision) && options.precision! >= 0) {
|
|
63
|
+
const fixed = normalized.toFixed(options.precision);
|
|
64
|
+
// Only strip trailing zeros when there's a fractional part. Stripping a bare
|
|
65
|
+
// integer (precision 0, e.g. "100") would mangle it to "1" because the regex
|
|
66
|
+
// eats the integer's own trailing zeros.
|
|
67
|
+
numeric = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
|
|
68
|
+
} else {
|
|
69
|
+
numeric = String(normalized);
|
|
70
|
+
}
|
|
65
71
|
return `${numeric}${options.unit ?? ""}`;
|
|
66
72
|
}
|
|
67
73
|
|