@agent-native/core 0.80.9 → 0.80.10
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 +7 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
- package/corpus/templates/design/actions/apply-design-state.ts +200 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +242 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +72 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +237 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +421 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
- package/corpus/templates/design/app/i18n-data.ts +269 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/helpers.ts +10 -4
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1016 -71
- package/corpus/templates/design/shared/component-model.ts +239 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/motion-compiler.ts +278 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +37 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- 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 +7 -7
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { buildDeepLink } from "@agent-native/core/server";
|
|
3
|
+
import { assertAccess } from "@agent-native/core/sharing";
|
|
4
|
+
import { eq } from "drizzle-orm";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
8
|
+
import "../server/db/index.js"; // ensure registerShareableResource runs
|
|
9
|
+
import {
|
|
10
|
+
INLINE_DEFAULT_CAPABILITIES,
|
|
11
|
+
hasCapability,
|
|
12
|
+
} from "../shared/design-source-capabilities.js";
|
|
13
|
+
import { resolveTweaksToCssVars } from "../shared/resolve-tweaks.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Token-edit patch schema (mirrors preview-design-token-edit)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const tokenEditSchema = z.object({
|
|
20
|
+
/** The CSS custom property to update, e.g. "--primary-color". */
|
|
21
|
+
cssVar: z
|
|
22
|
+
.string()
|
|
23
|
+
.startsWith("--")
|
|
24
|
+
// The token name is spliced raw into a `:root { … }` rule, so constrain it
|
|
25
|
+
// to a safe CSS custom-property ident (leading "--" plus ident chars only).
|
|
26
|
+
.regex(
|
|
27
|
+
/^--[-_a-zA-Z0-9]+$/,
|
|
28
|
+
"cssVar must be a valid CSS custom property name (-- followed by letters, digits, hyphens, or underscores).",
|
|
29
|
+
)
|
|
30
|
+
.describe("CSS custom property to edit"),
|
|
31
|
+
/** New value string, e.g. "#3B82F6" or "0.75rem". */
|
|
32
|
+
value: z
|
|
33
|
+
.string()
|
|
34
|
+
// The value is spliced raw into the `:root { … }` block; reject characters
|
|
35
|
+
// that could terminate the rule (`}`) or break out of a `<style>` (`<`).
|
|
36
|
+
.refine((v) => !/[}<]/.test(v), 'Token value may not contain "}" or "<".')
|
|
37
|
+
.describe("New value for the token"),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Editor deep link so external agents can surface "Open design". */
|
|
41
|
+
function designDeepLink(designId: string): string {
|
|
42
|
+
return buildDeepLink({
|
|
43
|
+
app: "design",
|
|
44
|
+
view: "editor",
|
|
45
|
+
params: { designId },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Action — persist token edit through the Tweaks loop (Tier-A)
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export default defineAction({
|
|
54
|
+
description:
|
|
55
|
+
"Persist one or more design token edits for a design. " +
|
|
56
|
+
"Tier-A (inline/Alpine): routes the edit through the same mechanism as " +
|
|
57
|
+
"apply-tweaks — merges the value into designs.data.tweakSelections so the " +
|
|
58
|
+
"tuned token survives reload and is visible in the Tokens panel. " +
|
|
59
|
+
"Source file write-back (Tier-B, real apps) is gated behind a capability " +
|
|
60
|
+
"check and currently returns a 'planned' advisory — no file is written.",
|
|
61
|
+
schema: z.object({
|
|
62
|
+
designId: z.string().describe("Design project ID"),
|
|
63
|
+
edits: z
|
|
64
|
+
.array(tokenEditSchema)
|
|
65
|
+
.min(1)
|
|
66
|
+
.describe("One or more { cssVar, value } edits to persist"),
|
|
67
|
+
sourceRef: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe(
|
|
71
|
+
"Opaque source connection ref for real-app (localhost/fusion) " +
|
|
72
|
+
"write-back. Omit for inline designs.",
|
|
73
|
+
),
|
|
74
|
+
}),
|
|
75
|
+
run: async ({ designId, edits, sourceRef }) => {
|
|
76
|
+
await assertAccess("design", designId, "editor");
|
|
77
|
+
|
|
78
|
+
// ------------------------------------------------------------------
|
|
79
|
+
// Capability check — real-app write-back (writeTokens) is gated
|
|
80
|
+
// ------------------------------------------------------------------
|
|
81
|
+
// TODO(fusion): When the bridge proves writeTokens capability, pass the
|
|
82
|
+
// resolved capabilities from the active source connection here instead of
|
|
83
|
+
// falling back to INLINE_DEFAULT_CAPABILITIES.
|
|
84
|
+
const caps = INLINE_DEFAULT_CAPABILITIES;
|
|
85
|
+
const canWriteTokens = hasCapability(caps, "writeTokens");
|
|
86
|
+
// sourceRef will be used once real write-back is implemented
|
|
87
|
+
void sourceRef;
|
|
88
|
+
|
|
89
|
+
const db = getDb();
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------------
|
|
93
|
+
// Load current design data
|
|
94
|
+
// ------------------------------------------------------------------
|
|
95
|
+
const [existingDesign] = await db
|
|
96
|
+
.select({ data: schema.designs.data })
|
|
97
|
+
.from(schema.designs)
|
|
98
|
+
.where(eq(schema.designs.id, designId));
|
|
99
|
+
|
|
100
|
+
let prevData: Record<string, unknown> = {};
|
|
101
|
+
if (existingDesign?.data) {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(existingDesign.data);
|
|
104
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
105
|
+
prevData = parsed;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Stale/invalid JSON — start fresh but never drop the column.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type TweakDef = Parameters<typeof resolveTweaksToCssVars>[0][number];
|
|
113
|
+
const tweaks: TweakDef[] = Array.isArray(prevData.tweaks)
|
|
114
|
+
? (prevData.tweaks as TweakDef[])
|
|
115
|
+
: [];
|
|
116
|
+
|
|
117
|
+
// Map cssVar -> tweakId for known tweaks
|
|
118
|
+
const cssVarToTweakId = new Map<string, string>();
|
|
119
|
+
for (const t of tweaks) {
|
|
120
|
+
if (t.cssVar) cssVarToTweakId.set(t.cssVar, t.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const prevSelections: Record<string, string | number | boolean> =
|
|
124
|
+
prevData.tweakSelections &&
|
|
125
|
+
typeof prevData.tweakSelections === "object" &&
|
|
126
|
+
!Array.isArray(prevData.tweakSelections)
|
|
127
|
+
? (prevData.tweakSelections as Record<
|
|
128
|
+
string,
|
|
129
|
+
string | number | boolean
|
|
130
|
+
>)
|
|
131
|
+
: {};
|
|
132
|
+
|
|
133
|
+
// ------------------------------------------------------------------
|
|
134
|
+
// Merge edits into tweakSelections
|
|
135
|
+
// ------------------------------------------------------------------
|
|
136
|
+
// For each edit:
|
|
137
|
+
// - If the cssVar maps to a known tweak, persist by tweakId (the standard
|
|
138
|
+
// Tweaks loop key) so apply-tweaks, get-design-snapshot, and the bridge
|
|
139
|
+
// script all pick it up consistently.
|
|
140
|
+
// - If the cssVar does NOT map to a known tweak, we synthesise a minimal
|
|
141
|
+
// tweak entry and persist via the raw cssVar key so the bridge script
|
|
142
|
+
// (`TWEAK_BRIDGE_SCRIPT`) still applies it.
|
|
143
|
+
const newSelections = { ...prevSelections };
|
|
144
|
+
|
|
145
|
+
for (const { cssVar, value } of edits) {
|
|
146
|
+
const tweakId = cssVarToTweakId.get(cssVar);
|
|
147
|
+
if (tweakId) {
|
|
148
|
+
newSelections[tweakId] = value;
|
|
149
|
+
} else {
|
|
150
|
+
// Fallback: persist using the cssVar as the key — the bridge script
|
|
151
|
+
// calls documentElement.style.setProperty with the key directly, so
|
|
152
|
+
// any key starting with "--" is applied as-is.
|
|
153
|
+
newSelections[cssVar] = value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
// Persist — same atomic write as apply-tweaks
|
|
159
|
+
// ------------------------------------------------------------------
|
|
160
|
+
const mergedData: Record<string, unknown> = {
|
|
161
|
+
...prevData,
|
|
162
|
+
tweakSelections: newSelections,
|
|
163
|
+
tweaksAppliedAt: now,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await db
|
|
167
|
+
.update(schema.designs)
|
|
168
|
+
.set({ data: JSON.stringify(mergedData), updatedAt: now })
|
|
169
|
+
.where(eq(schema.designs.id, designId));
|
|
170
|
+
|
|
171
|
+
// Resolve to full CSS var map for the response (so the caller can send a
|
|
172
|
+
// tweak-values postMessage without a separate round-trip)
|
|
173
|
+
const resolvedCssVars = resolveTweaksToCssVars(tweaks, newSelections);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
designId,
|
|
177
|
+
persisted: true,
|
|
178
|
+
/**
|
|
179
|
+
* Advisory when real-app source write-back is not yet available.
|
|
180
|
+
* `null` means the edit was fully applied via the Tweaks loop.
|
|
181
|
+
*/
|
|
182
|
+
writeTokensAdvisory: canWriteTokens
|
|
183
|
+
? null
|
|
184
|
+
: "Token source write-back is not yet available for this source tier. " +
|
|
185
|
+
"The value is persisted in the design's tweakSelections and reflected " +
|
|
186
|
+
"live via the CSS-var bridge. Connect Builder to unlock real write-back.",
|
|
187
|
+
/** Resolved CSS var map — pass directly to the tweak-values postMessage. */
|
|
188
|
+
resolvedCssVars,
|
|
189
|
+
deepLink: designDeepLink(designId),
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
link: ({ result }) => {
|
|
193
|
+
if (!result || typeof result !== "object") return null;
|
|
194
|
+
const designId = (result as { designId?: string }).designId;
|
|
195
|
+
if (!designId) return null;
|
|
196
|
+
return {
|
|
197
|
+
url: designDeepLink(designId),
|
|
198
|
+
label: "Open design",
|
|
199
|
+
view: "editor",
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
});
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apply-motion-edit — ATOMIC motion timeline write (§6.3).
|
|
3
|
+
*
|
|
4
|
+
* One action does all of:
|
|
5
|
+
* 1. Validate the timeline against the design's source capabilities.
|
|
6
|
+
* 2. Persist the `motion_timeline` row (insert or update).
|
|
7
|
+
* 3. Compile the tracks into deterministic CSS.
|
|
8
|
+
* 4. Inject/replace the managed `<style data-agent-native-motion>` block inside
|
|
9
|
+
* the design's HTML content, using the same persist path as apply-visual-edit
|
|
10
|
+
* (Yjs/collab + SQL, via agentEnterDocument / applyText / seedFromText).
|
|
11
|
+
* 5. Update `compiledHash` on the row to guard against drift.
|
|
12
|
+
* 6. Return a diff summary (bytes before/after, track count, hash).
|
|
13
|
+
*
|
|
14
|
+
* Never writes unless all steps succeed. Scrubbing/preview is handled by the
|
|
15
|
+
* separate `motion-preview` postMessage path on the frontend — this action is
|
|
16
|
+
* the deliberate "Write to CSS" commit step.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineAction } from "@agent-native/core";
|
|
20
|
+
import {
|
|
21
|
+
agentEnterDocument,
|
|
22
|
+
agentLeaveDocument,
|
|
23
|
+
applyText,
|
|
24
|
+
getText,
|
|
25
|
+
hasCollabState,
|
|
26
|
+
seedFromText,
|
|
27
|
+
} from "@agent-native/core/collab";
|
|
28
|
+
import {
|
|
29
|
+
getRequestOrgId,
|
|
30
|
+
getRequestUserEmail,
|
|
31
|
+
} from "@agent-native/core/server/request-context";
|
|
32
|
+
import { accessFilter, assertAccess } from "@agent-native/core/sharing";
|
|
33
|
+
import { and, eq } from "drizzle-orm";
|
|
34
|
+
import { nanoid } from "nanoid";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
|
|
37
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
38
|
+
import "../server/db/index.js"; // ensure registerShareableResource runs
|
|
39
|
+
import { compile } from "../shared/motion-compiler.js";
|
|
40
|
+
import type { MotionTrack } from "../shared/motion-timeline.js";
|
|
41
|
+
|
|
42
|
+
// ─── Zod schemas ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const keyframeSchema = z.object({
|
|
45
|
+
t: z
|
|
46
|
+
.number()
|
|
47
|
+
.min(0)
|
|
48
|
+
.max(1)
|
|
49
|
+
.describe("Normalised time in [0, 1] where 0 = 0% and 1 = 100%."),
|
|
50
|
+
value: z.string().describe("CSS property value at this keyframe."),
|
|
51
|
+
ease: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe(
|
|
55
|
+
'Per-keyframe easing, e.g. "ease-out" or "cubic-bezier(0.4,0,0.2,1)".',
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const trackSchema = z.object({
|
|
60
|
+
targetNodeId: z
|
|
61
|
+
.string()
|
|
62
|
+
.describe(
|
|
63
|
+
"data-agent-native-node-id of the target DOM element. " +
|
|
64
|
+
"Must be stamped on the element (ensureCodeLayerNodeIdsInHtml).",
|
|
65
|
+
),
|
|
66
|
+
property: z
|
|
67
|
+
.string()
|
|
68
|
+
.describe('CSS property to animate, e.g. "opacity" or "transform".'),
|
|
69
|
+
keyframes: z
|
|
70
|
+
.array(keyframeSchema)
|
|
71
|
+
.min(1)
|
|
72
|
+
.describe("At least one keyframe is required per track."),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const MOTION_STYLE_OPEN = "<style data-agent-native-motion>";
|
|
78
|
+
const MOTION_STYLE_CLOSE = "</style>";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bounded, case-insensitive matcher for any HTML `</style>` end tag
|
|
82
|
+
* (`</style >`, `</STYLE>`, …). Used to locate the close of the managed block
|
|
83
|
+
* without a naive `indexOf("</style>")` that would miss case/whitespace
|
|
84
|
+
* variants — and to detect any `</style` sequence smuggled into compiled CSS.
|
|
85
|
+
*/
|
|
86
|
+
const STYLE_CLOSE_RE = /<\s*\/\s*style\b[^>]*>/i;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reject `<`, `>`, and any `</style` sequence in a CSS token before it is
|
|
90
|
+
* compiled into the managed `<style>` block. Keyframe `value` / `ease` come
|
|
91
|
+
* straight from the caller and are interpolated raw into the CSS, so an
|
|
92
|
+
* unescaped `</style>` (or angle bracket) would break out of the style block
|
|
93
|
+
* and inject arbitrary markup. Returns the original string when safe; throws on
|
|
94
|
+
* a breakout attempt so the whole atomic write fails loudly rather than
|
|
95
|
+
* persisting a poisoned block.
|
|
96
|
+
*/
|
|
97
|
+
function assertSafeCssToken(value: string, field: string): string {
|
|
98
|
+
if (/[<>]/.test(value) || /<\s*\/\s*style/i.test(value)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Invalid ${field}: "<", ">", and "</style" are not allowed in motion CSS values.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Validate that a CSS property name is a safe CSS identifier.
|
|
108
|
+
*
|
|
109
|
+
* Accepts standard and vendor-prefixed property names (e.g. "opacity",
|
|
110
|
+
* "transform", "-webkit-transform") and nothing else. Rejects any string
|
|
111
|
+
* containing `:`, `;`, `{`, `}`, `<`, `/`, whitespace, or other characters
|
|
112
|
+
* that could break out of a CSS declaration or `<style>` block context when
|
|
113
|
+
* the property is interpolated as `${property}: ${value};` inside
|
|
114
|
+
* `@keyframes`.
|
|
115
|
+
*
|
|
116
|
+
* Uses the strict CSS ident regex `^-?[a-zA-Z][a-zA-Z0-9-]*$` which covers
|
|
117
|
+
* every real animatable CSS property while blocking injection payloads.
|
|
118
|
+
*/
|
|
119
|
+
function assertSafeCssProperty(property: string, field: string): string {
|
|
120
|
+
if (!/^-?[a-zA-Z][a-zA-Z0-9-]*$/.test(property)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Invalid ${field}: "${property}" is not a valid CSS property identifier. ` +
|
|
123
|
+
"Only ASCII letters, digits, hyphens, and an optional leading hyphen are allowed.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return property;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Inject or replace the managed `<style data-agent-native-motion>` block in
|
|
131
|
+
* the HTML content. Inserts before `</head>` when not already present.
|
|
132
|
+
*/
|
|
133
|
+
function injectMotionStyle(html: string, css: string): string {
|
|
134
|
+
const open = MOTION_STYLE_OPEN;
|
|
135
|
+
const close = MOTION_STYLE_CLOSE;
|
|
136
|
+
const openIdx = html.indexOf(open);
|
|
137
|
+
|
|
138
|
+
if (openIdx !== -1) {
|
|
139
|
+
// Find the matching closing tag after the open tag using a bounded regex
|
|
140
|
+
// (tolerates `</style >`, `</STYLE>`, etc.) instead of a literal indexOf.
|
|
141
|
+
const after = html.slice(openIdx + open.length);
|
|
142
|
+
const closeMatch = STYLE_CLOSE_RE.exec(after);
|
|
143
|
+
if (closeMatch) {
|
|
144
|
+
const closeIdx = openIdx + open.length + closeMatch.index;
|
|
145
|
+
// Replace the existing block.
|
|
146
|
+
return (
|
|
147
|
+
html.slice(0, openIdx) + open + "\n" + css + "\n" + html.slice(closeIdx)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Not found — insert before </head> or, if there is no <head>, at the top.
|
|
153
|
+
const headClose = html.lastIndexOf("</head>");
|
|
154
|
+
const block = `${open}\n${css}\n${close}`;
|
|
155
|
+
if (headClose !== -1) {
|
|
156
|
+
return html.slice(0, headClose) + block + "\n" + html.slice(headClose);
|
|
157
|
+
}
|
|
158
|
+
return block + "\n" + html;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function liveFileContent(
|
|
162
|
+
fileId: string,
|
|
163
|
+
storedContent: string,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
try {
|
|
166
|
+
if (await hasCollabState(fileId)) {
|
|
167
|
+
const live = await getText(fileId, "content");
|
|
168
|
+
if (typeof live === "string") return live;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Best-effort; SQL is the fallback.
|
|
172
|
+
}
|
|
173
|
+
return storedContent;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function persistFileContent(
|
|
177
|
+
fileId: string,
|
|
178
|
+
designId: string,
|
|
179
|
+
content: string,
|
|
180
|
+
now: string,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const db = getDb();
|
|
183
|
+
agentEnterDocument(fileId);
|
|
184
|
+
try {
|
|
185
|
+
await db
|
|
186
|
+
.update(schema.designFiles)
|
|
187
|
+
.set({ content, updatedAt: now })
|
|
188
|
+
.where(eq(schema.designFiles.id, fileId));
|
|
189
|
+
|
|
190
|
+
if (await hasCollabState(fileId)) {
|
|
191
|
+
await applyText(fileId, content, "content", "agent");
|
|
192
|
+
} else {
|
|
193
|
+
await seedFromText(fileId, content);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// guard:allow-unscoped — the action's run() asserts editor access via
|
|
197
|
+
// assertAccess("design", designId, "editor") before this helper is
|
|
198
|
+
// invoked; this only touches the addressed design row's updatedAt.
|
|
199
|
+
await db
|
|
200
|
+
.update(schema.designs)
|
|
201
|
+
.set({ updatedAt: now })
|
|
202
|
+
.where(eq(schema.designs.id, designId));
|
|
203
|
+
} finally {
|
|
204
|
+
agentLeaveDocument(fileId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Action ───────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
export default defineAction({
|
|
211
|
+
description:
|
|
212
|
+
"Atomically write a motion timeline to a design. " +
|
|
213
|
+
"Persists the motion_timeline row, compiles tracks to CSS, injects the " +
|
|
214
|
+
"managed <style data-agent-native-motion> block into the design's HTML, " +
|
|
215
|
+
"and updates compiledHash — all in one atomic step. " +
|
|
216
|
+
"This is the 'Write to CSS' commit path; preview/scrubbing uses the " +
|
|
217
|
+
"motion-preview postMessage bridge, NOT this action.",
|
|
218
|
+
schema: z.object({
|
|
219
|
+
designId: z.string().describe("Design project ID."),
|
|
220
|
+
fileId: z
|
|
221
|
+
.string()
|
|
222
|
+
.optional()
|
|
223
|
+
.describe(
|
|
224
|
+
"Target design_files.id. Defaults to the design's primary index.html " +
|
|
225
|
+
"when omitted. Required for multi-file designs.",
|
|
226
|
+
),
|
|
227
|
+
timelineId: z
|
|
228
|
+
.string()
|
|
229
|
+
.optional()
|
|
230
|
+
.describe(
|
|
231
|
+
"Existing motion_timeline.id to update. Omit to create a new timeline.",
|
|
232
|
+
),
|
|
233
|
+
sourceRef: z
|
|
234
|
+
.string()
|
|
235
|
+
.optional()
|
|
236
|
+
.describe(
|
|
237
|
+
"Opaque source ref (fileId for inline, routeId for real apps). " +
|
|
238
|
+
"Stored on the timeline row for scoping.",
|
|
239
|
+
),
|
|
240
|
+
tracks: z
|
|
241
|
+
.preprocess(
|
|
242
|
+
(v) => (typeof v === "string" ? JSON.parse(v) : v),
|
|
243
|
+
z.array(trackSchema).min(1),
|
|
244
|
+
)
|
|
245
|
+
.describe(
|
|
246
|
+
"Animation tracks. Each track targets one DOM element by " +
|
|
247
|
+
"data-agent-native-node-id and animates one CSS property.",
|
|
248
|
+
),
|
|
249
|
+
durationMs: z
|
|
250
|
+
.number()
|
|
251
|
+
.int()
|
|
252
|
+
.positive()
|
|
253
|
+
.default(300)
|
|
254
|
+
.describe("Total animation duration in milliseconds."),
|
|
255
|
+
defaultEase: z
|
|
256
|
+
.string()
|
|
257
|
+
.default("ease")
|
|
258
|
+
.describe(
|
|
259
|
+
"Default easing applied to keyframe intervals that omit ease. " +
|
|
260
|
+
'E.g. "ease", "ease-in-out", "cubic-bezier(0.4,0,0.2,1)".',
|
|
261
|
+
),
|
|
262
|
+
label: z
|
|
263
|
+
.string()
|
|
264
|
+
.optional()
|
|
265
|
+
.describe("Optional human-readable label for the timeline."),
|
|
266
|
+
includeContent: z
|
|
267
|
+
.boolean()
|
|
268
|
+
.optional()
|
|
269
|
+
.default(false)
|
|
270
|
+
.describe("Include the full patched HTML in the response (large)."),
|
|
271
|
+
}),
|
|
272
|
+
run: async ({
|
|
273
|
+
designId,
|
|
274
|
+
fileId: fileIdInput,
|
|
275
|
+
timelineId,
|
|
276
|
+
sourceRef,
|
|
277
|
+
tracks,
|
|
278
|
+
durationMs,
|
|
279
|
+
defaultEase,
|
|
280
|
+
includeContent,
|
|
281
|
+
}) => {
|
|
282
|
+
await assertAccess("design", designId, "editor");
|
|
283
|
+
|
|
284
|
+
const db = getDb();
|
|
285
|
+
const now = new Date().toISOString();
|
|
286
|
+
|
|
287
|
+
// ── 1. Resolve the target design file ──────────────────────────────────
|
|
288
|
+
const conditions = [
|
|
289
|
+
accessFilter(schema.designs, schema.designShares),
|
|
290
|
+
eq(schema.designFiles.designId, designId),
|
|
291
|
+
];
|
|
292
|
+
if (fileIdInput) {
|
|
293
|
+
conditions.push(eq(schema.designFiles.id, fileIdInput));
|
|
294
|
+
} else {
|
|
295
|
+
conditions.push(eq(schema.designFiles.filename, "index.html"));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const [file] = await db
|
|
299
|
+
.select({
|
|
300
|
+
id: schema.designFiles.id,
|
|
301
|
+
designId: schema.designFiles.designId,
|
|
302
|
+
filename: schema.designFiles.filename,
|
|
303
|
+
content: schema.designFiles.content,
|
|
304
|
+
})
|
|
305
|
+
.from(schema.designFiles)
|
|
306
|
+
.innerJoin(
|
|
307
|
+
schema.designs,
|
|
308
|
+
eq(schema.designFiles.designId, schema.designs.id),
|
|
309
|
+
)
|
|
310
|
+
.where(and(...conditions))
|
|
311
|
+
.limit(1);
|
|
312
|
+
|
|
313
|
+
if (!file) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
fileIdInput
|
|
316
|
+
? `Design file not found: ${fileIdInput}`
|
|
317
|
+
: `No index.html found for design: ${designId}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const fileId = file.id;
|
|
322
|
+
const currentContent = await liveFileContent(fileId, file.content ?? "");
|
|
323
|
+
|
|
324
|
+
// ── 2. Compile tracks → CSS ─────────────────────────────────────────────
|
|
325
|
+
const typedTracks = tracks as MotionTrack[];
|
|
326
|
+
|
|
327
|
+
// Reject CSS-injection vectors in caller-supplied track properties,
|
|
328
|
+
// keyframe values, and easing strings before they are compiled into the
|
|
329
|
+
// managed <style> block.
|
|
330
|
+
for (const track of typedTracks) {
|
|
331
|
+
assertSafeCssProperty(track.property, "track.property");
|
|
332
|
+
for (const kf of track.keyframes) {
|
|
333
|
+
assertSafeCssToken(kf.value, "keyframe value");
|
|
334
|
+
if (kf.ease !== undefined) {
|
|
335
|
+
assertSafeCssToken(kf.ease, "keyframe ease");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
assertSafeCssToken(defaultEase, "defaultEase");
|
|
340
|
+
|
|
341
|
+
const { css, hash } = compile({
|
|
342
|
+
id: timelineId ?? "",
|
|
343
|
+
designId,
|
|
344
|
+
sourceRef: sourceRef ?? null,
|
|
345
|
+
filePath: null,
|
|
346
|
+
tracks: typedTracks,
|
|
347
|
+
durationMs,
|
|
348
|
+
defaultEase,
|
|
349
|
+
compiledHash: null,
|
|
350
|
+
createdAt: now,
|
|
351
|
+
updatedAt: now,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── 3. Inject the managed CSS block into the HTML ───────────────────────
|
|
355
|
+
const patchedContent = injectMotionStyle(currentContent, css);
|
|
356
|
+
const bytesBefore = currentContent.length;
|
|
357
|
+
const bytesAfter = patchedContent.length;
|
|
358
|
+
|
|
359
|
+
// ── 4. Pre-flight the motion_timeline row write ─────────────────────────
|
|
360
|
+
// Resolve everything that can fail (existence + ownership) BEFORE touching
|
|
361
|
+
// content, so we never persist HTML for a row that can't be written.
|
|
362
|
+
const tracksJson = JSON.stringify(typedTracks);
|
|
363
|
+
const resolvedTimelineId = timelineId ?? nanoid();
|
|
364
|
+
|
|
365
|
+
let insertOwnerEmail: string | null = null;
|
|
366
|
+
let insertOrgId: string | null = null;
|
|
367
|
+
|
|
368
|
+
if (timelineId) {
|
|
369
|
+
// Update existing row — verify it belongs to this design.
|
|
370
|
+
const [existing] = await db
|
|
371
|
+
.select({ id: schema.motionTimeline.id })
|
|
372
|
+
.from(schema.motionTimeline)
|
|
373
|
+
.where(
|
|
374
|
+
and(
|
|
375
|
+
eq(schema.motionTimeline.id, timelineId),
|
|
376
|
+
eq(schema.motionTimeline.designId, designId),
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
.limit(1);
|
|
380
|
+
|
|
381
|
+
if (!existing) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`motion_timeline not found for this design: ${timelineId}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Insert new row — derive ownership from the request context (same
|
|
388
|
+
// pattern as create-design-state and other create actions).
|
|
389
|
+
insertOwnerEmail = getRequestUserEmail() ?? null;
|
|
390
|
+
if (!insertOwnerEmail) throw new Error("no authenticated user");
|
|
391
|
+
insertOrgId = getRequestOrgId() ?? null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── 5. Persist the motion_timeline row FIRST (atomic SQL portion) ───────
|
|
395
|
+
// The timeline row is written before the HTML so that a failure in the
|
|
396
|
+
// HTML/collab write step cannot leave the design content mutated without a
|
|
397
|
+
// corresponding row. The reverse (HTML first) was a false atomicity
|
|
398
|
+
// guarantee: if the row write failed after the HTML write, the managed
|
|
399
|
+
// <style> block would be permanently out of sync with the DB state.
|
|
400
|
+
await db.transaction(async (tx) => {
|
|
401
|
+
if (timelineId) {
|
|
402
|
+
await tx
|
|
403
|
+
.update(schema.motionTimeline)
|
|
404
|
+
.set({
|
|
405
|
+
tracks: tracksJson,
|
|
406
|
+
durationMs,
|
|
407
|
+
defaultEase,
|
|
408
|
+
compiledHash: hash,
|
|
409
|
+
sourceRef: sourceRef ?? null,
|
|
410
|
+
updatedAt: now,
|
|
411
|
+
})
|
|
412
|
+
.where(eq(schema.motionTimeline.id, timelineId));
|
|
413
|
+
} else {
|
|
414
|
+
await tx.insert(schema.motionTimeline).values({
|
|
415
|
+
id: resolvedTimelineId,
|
|
416
|
+
designId,
|
|
417
|
+
sourceRef: sourceRef ?? null,
|
|
418
|
+
filePath: null,
|
|
419
|
+
tracks: tracksJson,
|
|
420
|
+
durationMs,
|
|
421
|
+
defaultEase,
|
|
422
|
+
compiledHash: hash,
|
|
423
|
+
ownerEmail: insertOwnerEmail as string,
|
|
424
|
+
orgId: insertOrgId,
|
|
425
|
+
createdAt: now,
|
|
426
|
+
updatedAt: now,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── 6. Persist the patched HTML content SECOND (Yjs/collab + SQL) ───────
|
|
432
|
+
// Written after the row so a collab/SQL failure here leaves the timeline row
|
|
433
|
+
// accurate (correct tracks + hash) and the stale HTML can be recompiled on
|
|
434
|
+
// the next apply-motion-edit call via compiledHash drift detection.
|
|
435
|
+
await persistFileContent(fileId, designId, patchedContent, now);
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
timelineId: resolvedTimelineId,
|
|
439
|
+
designId,
|
|
440
|
+
fileId,
|
|
441
|
+
trackCount: typedTracks.length,
|
|
442
|
+
compiledHash: hash,
|
|
443
|
+
bytesBefore,
|
|
444
|
+
bytesAfter,
|
|
445
|
+
bytesDelta: bytesAfter - bytesBefore,
|
|
446
|
+
persisted: true,
|
|
447
|
+
patchedContent: includeContent ? patchedContent : undefined,
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
});
|