@agent-native/core 0.85.6 → 0.85.7
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 +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +33 -1
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +2 -2
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -54,7 +54,6 @@ export function compile(timeline: MotionTimeline): CompileResult {
|
|
|
54
54
|
return { css, hash: djb2(css) };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const animNames: string[] = [];
|
|
58
57
|
const kfBlocks: string[] = [];
|
|
59
58
|
const rulesByTarget = new Map<
|
|
60
59
|
string,
|
|
@@ -77,12 +76,15 @@ export function compile(timeline: MotionTimeline): CompileResult {
|
|
|
77
76
|
if (!keyframes || keyframes.length === 0) continue;
|
|
78
77
|
assertSafeMotionCssProperty(property, "track.property");
|
|
79
78
|
|
|
79
|
+
// Sort ONCE per track: the editor may hand us keyframes in drag order, and
|
|
80
|
+
// both the stop list and the element-rule ease must read time order.
|
|
81
|
+
const sortedKeyframes = [...keyframes].sort((a, b) => a.t - b.t);
|
|
82
|
+
|
|
80
83
|
const name = animationName(targetNodeId, property);
|
|
81
|
-
|
|
82
|
-
kfBlocks.push(keyframesBlock(name, property, keyframes, defaultEase));
|
|
84
|
+
kfBlocks.push(keyframesBlock(name, property, sortedKeyframes, defaultEase));
|
|
83
85
|
|
|
84
86
|
const dur = formatDuration(durationMs);
|
|
85
|
-
const ease =
|
|
87
|
+
const ease = sortedKeyframes[0]?.ease ?? defaultEase;
|
|
86
88
|
assertSafeMotionCssToken(ease, "track ease");
|
|
87
89
|
const targetRule = rulesByTarget.get(targetNodeId) ?? {
|
|
88
90
|
names: [],
|
|
@@ -97,21 +99,24 @@ export function compile(timeline: MotionTimeline): CompileResult {
|
|
|
97
99
|
rulesByTarget.set(targetNodeId, targetRule);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const css = [...kfBlocks, ...ruleBlocks, reducedMotionBlock(animNames)].join(
|
|
113
|
-
"\n\n",
|
|
102
|
+
const sortedTargets = [...rulesByTarget.entries()].sort(([a], [b]) =>
|
|
103
|
+
a.localeCompare(b),
|
|
104
|
+
);
|
|
105
|
+
const ruleBlocks = sortedTargets.map(
|
|
106
|
+
([targetNodeId, rule]) =>
|
|
107
|
+
`[data-agent-native-node-id="${escAttr(targetNodeId)}"] {\n` +
|
|
108
|
+
` animation-name: ${rule.names.join(", ")};\n` +
|
|
109
|
+
` animation-duration: ${rule.durations.join(", ")};\n` +
|
|
110
|
+
` animation-timing-function: ${rule.timings.join(", ")};\n` +
|
|
111
|
+
` animation-fill-mode: ${rule.fillModes.join(", ")};\n` +
|
|
112
|
+
`}`,
|
|
114
113
|
);
|
|
114
|
+
|
|
115
|
+
const css = [
|
|
116
|
+
...kfBlocks,
|
|
117
|
+
...ruleBlocks,
|
|
118
|
+
reducedMotionBlock(sortedTargets.map(([targetNodeId]) => targetNodeId)),
|
|
119
|
+
].join("\n\n");
|
|
115
120
|
return { css, hash: djb2(css) };
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -201,6 +206,24 @@ export function hashCss(css: string): string {
|
|
|
201
206
|
return djb2(css);
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Parse the first `animation-duration` declaration in a managed motion CSS
|
|
211
|
+
* body and return it in milliseconds, or `null` when absent/unparsable. Used
|
|
212
|
+
* by CSS-recovery so a recovered timeline keeps the compiled duration instead
|
|
213
|
+
* of inventing a default that the next save would then persist.
|
|
214
|
+
*/
|
|
215
|
+
export function parseFirstAnimationDurationMs(css: string): number | null {
|
|
216
|
+
const m = /animation-duration\s*:\s*([^;]+)/.exec(css);
|
|
217
|
+
if (!m) return null;
|
|
218
|
+
const first = m[1].split(",")[0].trim();
|
|
219
|
+
const value = /^([\d.]+)(ms|s)$/.exec(first);
|
|
220
|
+
if (!value) return null;
|
|
221
|
+
const n = parseFloat(value[1]);
|
|
222
|
+
if (!Number.isFinite(n)) return null;
|
|
223
|
+
const ms = value[2] === "ms" ? n : n * 1000;
|
|
224
|
+
return ms > 0 ? Math.round(ms) : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
204
227
|
/**
|
|
205
228
|
* Reject caller-supplied CSS declaration values before interpolation into the
|
|
206
229
|
* managed motion stylesheet. Motion values still allow useful CSS functions
|
|
@@ -248,13 +271,18 @@ const CSS_TOKEN_CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
|
248
271
|
|
|
249
272
|
/**
|
|
250
273
|
* Build a deterministic CSS animation name from a node id and CSS property.
|
|
251
|
-
* Non-ident characters are replaced with `_
|
|
274
|
+
* Non-ident characters are replaced with `_`; when sanitisation changed the
|
|
275
|
+
* node id, a short hash of the RAW id is appended so distinct ids that
|
|
276
|
+
* sanitise identically (e.g. "a:b" vs "a_b") never collide.
|
|
252
277
|
*
|
|
253
|
-
* Format: `an-motion-<nodeId
|
|
278
|
+
* Format: `an-motion-<nodeId>[_<hash>]--<property>`
|
|
254
279
|
*/
|
|
255
280
|
function animationName(nodeId: string, property: string): string {
|
|
256
281
|
const safe = (s: string) => s.replace(/[^a-zA-Z0-9-]/g, "_");
|
|
257
|
-
|
|
282
|
+
const safeNode = safe(nodeId);
|
|
283
|
+
const suffix =
|
|
284
|
+
safeNode === nodeId ? "" : `_${djb2Num(nodeId).toString(36).slice(-4)}`;
|
|
285
|
+
return `an-motion-${safeNode}${suffix}--${safe(property)}`;
|
|
258
286
|
}
|
|
259
287
|
|
|
260
288
|
/** Reverse `animationName` — returns `null` when the name doesn't match. */
|
|
@@ -299,17 +327,21 @@ function keyframesBlock(
|
|
|
299
327
|
/**
|
|
300
328
|
* Build the `@media (prefers-reduced-motion: reduce)` block.
|
|
301
329
|
* Always emitted so managed blocks are easily identified by parsers.
|
|
330
|
+
*
|
|
331
|
+
* Selects ONLY the node ids that carry compiled motion rules — a blanket
|
|
332
|
+
* `[data-agent-native-node-id]` selector would disable every animation on
|
|
333
|
+
* every stamped node, including ones this compiler does not manage.
|
|
302
334
|
*/
|
|
303
|
-
function reducedMotionBlock(
|
|
304
|
-
if (
|
|
335
|
+
function reducedMotionBlock(targetNodeIds: string[]): string {
|
|
336
|
+
if (targetNodeIds.length === 0) {
|
|
305
337
|
return `@media (prefers-reduced-motion: reduce) {\n /* no animations */\n}`;
|
|
306
338
|
}
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
const selector = targetNodeIds
|
|
340
|
+
.map((id) => `[data-agent-native-node-id="${escAttr(id)}"]`)
|
|
341
|
+
.join(",\n ");
|
|
309
342
|
return (
|
|
310
343
|
`@media (prefers-reduced-motion: reduce) {\n` +
|
|
311
|
-
` ${selector}
|
|
312
|
-
` [data-agent-native-node-id] {\n` +
|
|
344
|
+
` ${selector} {\n` +
|
|
313
345
|
` animation: none !important;\n` +
|
|
314
346
|
` }\n` +
|
|
315
347
|
`}`
|
|
@@ -322,11 +354,18 @@ function formatDuration(ms: number): string {
|
|
|
322
354
|
return `${s}s`;
|
|
323
355
|
}
|
|
324
356
|
|
|
325
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* Format a normalised time `t ∈ [0, 1]` as a CSS percentage string.
|
|
359
|
+
* Interior stops are clamped away from 0% / 100% so a stop at e.g.
|
|
360
|
+
* t = 0.99997 never rounds onto a real t = 1 stop (duplicate keyframe
|
|
361
|
+
* selectors silently drop one of the two values).
|
|
362
|
+
*/
|
|
326
363
|
function formatPercent(t: number): string {
|
|
327
364
|
if (t <= 0) return "0%";
|
|
328
365
|
if (t >= 1) return "100%";
|
|
329
366
|
const pct = Math.round(t * 10000) / 100;
|
|
367
|
+
if (pct >= 100) return "99.99%";
|
|
368
|
+
if (pct <= 0) return "0.01%";
|
|
330
369
|
return `${pct}%`;
|
|
331
370
|
}
|
|
332
371
|
|
|
@@ -415,13 +454,18 @@ function escapeRegExp(value: string): string {
|
|
|
415
454
|
|
|
416
455
|
/**
|
|
417
456
|
* djb2 string hash — deterministic, no crypto dependency.
|
|
418
|
-
* Returns a 32-bit unsigned integer
|
|
457
|
+
* Returns a 32-bit unsigned integer.
|
|
419
458
|
*/
|
|
420
|
-
function
|
|
459
|
+
function djb2Num(str: string): number {
|
|
421
460
|
let hash = 5381;
|
|
422
461
|
for (let i = 0; i < str.length; i++) {
|
|
423
462
|
// eslint-disable-next-line no-bitwise
|
|
424
463
|
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
|
|
425
464
|
}
|
|
426
|
-
return hash
|
|
465
|
+
return hash;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** {@link djb2Num} as a decimal string (stored `compiled_hash` format). */
|
|
469
|
+
function djb2(str: string): string {
|
|
470
|
+
return djb2Num(str).toString(10);
|
|
427
471
|
}
|
|
@@ -191,3 +191,338 @@ export function hasTrackFor(
|
|
|
191
191
|
(t) => t.targetNodeId === targetNodeId && t.property === property,
|
|
192
192
|
);
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
// ─── Keyframe helpers (pure; shared by MotionDock + tests) ────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Two keyframes closer together than this (normalised time) are treated as the
|
|
199
|
+
* same stop: upserts replace instead of accumulating invisible duplicates.
|
|
200
|
+
*/
|
|
201
|
+
export const MOTION_KEYFRAME_TIME_EPSILON = 0.002;
|
|
202
|
+
|
|
203
|
+
/** Return a copy of `keyframes` sorted ascending by `t` (stable). */
|
|
204
|
+
export function sortMotionKeyframes(
|
|
205
|
+
keyframes: MotionKeyframe[],
|
|
206
|
+
): MotionKeyframe[] {
|
|
207
|
+
return [...keyframes].sort((a, b) => a.t - b.t);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Insert `keyframe` into `keyframes`, replacing any existing keyframe whose
|
|
212
|
+
* `t` is within `epsilon` of the new keyframe's time (so repeated adds at the
|
|
213
|
+
* same playhead position never accumulate invisible duplicates). Returns a new
|
|
214
|
+
* sorted array; the input is not mutated.
|
|
215
|
+
*/
|
|
216
|
+
export function upsertMotionKeyframeAtTime(
|
|
217
|
+
keyframes: MotionKeyframe[],
|
|
218
|
+
keyframe: MotionKeyframe,
|
|
219
|
+
epsilon: number = MOTION_KEYFRAME_TIME_EPSILON,
|
|
220
|
+
): MotionKeyframe[] {
|
|
221
|
+
const withoutCurrentTime = keyframes.filter(
|
|
222
|
+
(existing) => Math.abs(existing.t - keyframe.t) > epsilon,
|
|
223
|
+
);
|
|
224
|
+
return sortMotionKeyframes([...withoutCurrentTime, keyframe]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Easing evaluation ────────────────────────────────────────────────────────
|
|
228
|
+
//
|
|
229
|
+
// Mirrors CSS timing-function semantics closely enough for scrub preview and
|
|
230
|
+
// value sampling. The canvas preview bridge carries its own dependency-free
|
|
231
|
+
// copy of this algorithm (it cannot import modules); keep the two in sync.
|
|
232
|
+
|
|
233
|
+
const EASE_KEYWORD_BEZIERS: Record<string, [number, number, number, number]> = {
|
|
234
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
235
|
+
"ease-in": [0.42, 0, 1, 1],
|
|
236
|
+
"ease-out": [0, 0, 0.58, 1],
|
|
237
|
+
"ease-in-out": [0.42, 0, 0.58, 1],
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Evaluate y for a CSS cubic-bezier timing function at progress x ∈ [0, 1]. */
|
|
241
|
+
function cubicBezierY(
|
|
242
|
+
x1: number,
|
|
243
|
+
y1: number,
|
|
244
|
+
x2: number,
|
|
245
|
+
y2: number,
|
|
246
|
+
x: number,
|
|
247
|
+
): number {
|
|
248
|
+
if (x <= 0) return 0;
|
|
249
|
+
if (x >= 1) return 1;
|
|
250
|
+
const cx = 3 * x1;
|
|
251
|
+
const bx = 3 * (x2 - x1) - cx;
|
|
252
|
+
const ax = 1 - cx - bx;
|
|
253
|
+
const cy = 3 * y1;
|
|
254
|
+
const by = 3 * (y2 - y1) - cy;
|
|
255
|
+
const ay = 1 - cy - by;
|
|
256
|
+
const sampleX = (u: number) => ((ax * u + bx) * u + cx) * u;
|
|
257
|
+
const sampleY = (u: number) => ((ay * u + by) * u + cy) * u;
|
|
258
|
+
const sampleDX = (u: number) => (3 * ax * u + 2 * bx) * u + cx;
|
|
259
|
+
|
|
260
|
+
// Newton-Raphson, falling back to bisection when the derivative flattens.
|
|
261
|
+
let u = x;
|
|
262
|
+
for (let i = 0; i < 8; i++) {
|
|
263
|
+
const err = sampleX(u) - x;
|
|
264
|
+
if (Math.abs(err) < 1e-6) return sampleY(u);
|
|
265
|
+
const d = sampleDX(u);
|
|
266
|
+
if (Math.abs(d) < 1e-6) break;
|
|
267
|
+
u = Math.min(1, Math.max(0, u - err / d));
|
|
268
|
+
}
|
|
269
|
+
let lo = 0;
|
|
270
|
+
let hi = 1;
|
|
271
|
+
u = x;
|
|
272
|
+
while (hi - lo > 1e-6) {
|
|
273
|
+
u = (lo + hi) / 2;
|
|
274
|
+
if (sampleX(u) < x) lo = u;
|
|
275
|
+
else hi = u;
|
|
276
|
+
}
|
|
277
|
+
return sampleY(u);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Evaluate a {@link MotionEase} timing function at linear progress `x ∈ [0, 1]`.
|
|
282
|
+
*
|
|
283
|
+
* Supports the CSS keywords (`linear`, `ease*`, `step-start`, `step-end`),
|
|
284
|
+
* `cubic-bezier(...)` (including overshoot control points, which is how the
|
|
285
|
+
* dock's "Spring" preset is expressed), `steps(n, position)`, and a `spring`
|
|
286
|
+
* keyword approximated by an overshoot bezier. Unknown values fall back to
|
|
287
|
+
* linear. The result may leave [0, 1] for overshoot beziers by design.
|
|
288
|
+
*/
|
|
289
|
+
export function evaluateMotionEase(
|
|
290
|
+
ease: MotionEase | undefined,
|
|
291
|
+
x: number,
|
|
292
|
+
): number {
|
|
293
|
+
const clamped = x <= 0 ? 0 : x >= 1 ? 1 : x;
|
|
294
|
+
const raw = String(ease ?? "ease")
|
|
295
|
+
.trim()
|
|
296
|
+
.toLowerCase();
|
|
297
|
+
if (raw === "linear") return clamped;
|
|
298
|
+
if (raw === "step-start") return clamped > 0 ? 1 : 0;
|
|
299
|
+
if (raw === "step-end") return clamped >= 1 ? 1 : 0;
|
|
300
|
+
|
|
301
|
+
const keyword = EASE_KEYWORD_BEZIERS[raw];
|
|
302
|
+
if (keyword) {
|
|
303
|
+
return cubicBezierY(
|
|
304
|
+
keyword[0],
|
|
305
|
+
keyword[1],
|
|
306
|
+
keyword[2],
|
|
307
|
+
keyword[3],
|
|
308
|
+
clamped,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const bezier = /^cubic-bezier\(([^)]+)\)$/.exec(raw);
|
|
313
|
+
if (bezier) {
|
|
314
|
+
const parts = bezier[1].split(",").map((part) => parseFloat(part));
|
|
315
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
316
|
+
// x control points must stay in [0, 1] (CSS requirement); y may overshoot.
|
|
317
|
+
const x1 = Math.min(1, Math.max(0, parts[0]));
|
|
318
|
+
const x2 = Math.min(1, Math.max(0, parts[2]));
|
|
319
|
+
return cubicBezierY(x1, parts[1], x2, parts[3], clamped);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const steps = /^steps\(([^)]+)\)$/.exec(raw);
|
|
324
|
+
if (steps) {
|
|
325
|
+
const args = steps[1].split(",").map((part) => part.trim());
|
|
326
|
+
const count = parseInt(args[0], 10);
|
|
327
|
+
if (Number.isFinite(count) && count > 0) {
|
|
328
|
+
if (clamped >= 1) return 1;
|
|
329
|
+
const jumpStart = args[1] === "start" || args[1] === "jump-start";
|
|
330
|
+
return Math.min(
|
|
331
|
+
1,
|
|
332
|
+
(Math.floor(clamped * count) + (jumpStart ? 1 : 0)) / count,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Spring approximation: a gentle overshoot bezier.
|
|
338
|
+
if (raw.startsWith("spring")) {
|
|
339
|
+
return cubicBezierY(0.34, 1.56, 0.64, 1, clamped);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return clamped;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Value sampling ───────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
type MotionValueSegment =
|
|
348
|
+
| { kind: "lit"; text: string }
|
|
349
|
+
| { kind: "num"; value: number; unit: string };
|
|
350
|
+
|
|
351
|
+
function formatSampledNumber(n: number): string {
|
|
352
|
+
if (!Number.isFinite(n)) return "0";
|
|
353
|
+
return (Math.round(n * 10000) / 10000).toString();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Parse a hex / rgb(a) CSS color into [r, g, b, a], or null. */
|
|
357
|
+
function parseSimpleColor(
|
|
358
|
+
value: string,
|
|
359
|
+
): [number, number, number, number] | null {
|
|
360
|
+
const s = value.trim();
|
|
361
|
+
const hex = /^#([0-9a-fA-F]{3,8})$/.exec(s);
|
|
362
|
+
if (hex) {
|
|
363
|
+
const h = hex[1];
|
|
364
|
+
if (h.length === 3 || h.length === 4) {
|
|
365
|
+
return [
|
|
366
|
+
parseInt(h[0] + h[0], 16),
|
|
367
|
+
parseInt(h[1] + h[1], 16),
|
|
368
|
+
parseInt(h[2] + h[2], 16),
|
|
369
|
+
h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
|
370
|
+
];
|
|
371
|
+
}
|
|
372
|
+
if (h.length === 6 || h.length === 8) {
|
|
373
|
+
return [
|
|
374
|
+
parseInt(h.slice(0, 2), 16),
|
|
375
|
+
parseInt(h.slice(2, 4), 16),
|
|
376
|
+
parseInt(h.slice(4, 6), 16),
|
|
377
|
+
h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const rgb = /^rgba?\(([^)]+)\)$/i.exec(s);
|
|
383
|
+
if (rgb) {
|
|
384
|
+
const parts = rgb[1].split(/[\s,/]+/).filter((p) => p.length > 0);
|
|
385
|
+
if (parts.length >= 3) {
|
|
386
|
+
const alpha = parts[3];
|
|
387
|
+
return [
|
|
388
|
+
parseFloat(parts[0]),
|
|
389
|
+
parseFloat(parts[1]),
|
|
390
|
+
parseFloat(parts[2]),
|
|
391
|
+
alpha === undefined
|
|
392
|
+
? 1
|
|
393
|
+
: alpha.endsWith("%")
|
|
394
|
+
? parseFloat(alpha) / 100
|
|
395
|
+
: parseFloat(alpha),
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function formatSampledColor(c: [number, number, number, number]): string {
|
|
403
|
+
const clamp255 = (x: number) => Math.round(Math.min(255, Math.max(0, x)));
|
|
404
|
+
const a = Math.min(1, Math.max(0, c[3]));
|
|
405
|
+
if (a >= 1) {
|
|
406
|
+
return `rgb(${clamp255(c[0])}, ${clamp255(c[1])}, ${clamp255(c[2])})`;
|
|
407
|
+
}
|
|
408
|
+
return `rgba(${clamp255(c[0])}, ${clamp255(c[1])}, ${clamp255(c[2])}, ${Math.round(a * 1000) / 1000})`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function tokenizeMotionValue(value: string): MotionValueSegment[] {
|
|
412
|
+
const segs: MotionValueSegment[] = [];
|
|
413
|
+
const numRe = /^[+-]?(?:\d+\.?\d*|\.\d+)/;
|
|
414
|
+
let i = 0;
|
|
415
|
+
let litStart = 0;
|
|
416
|
+
while (i < value.length) {
|
|
417
|
+
// Skip numbers glued to an identifier (translate3d, matrix3d, …) or a hex
|
|
418
|
+
// color literal — those belong to the literal chunk, not a numeric arg.
|
|
419
|
+
const prev = i > 0 ? value[i - 1] : "";
|
|
420
|
+
if (!/[a-zA-Z#]/.test(prev)) {
|
|
421
|
+
const nm = numRe.exec(value.slice(i));
|
|
422
|
+
if (nm) {
|
|
423
|
+
const um = /^[a-z%]+/i.exec(value.slice(i + nm[0].length));
|
|
424
|
+
const unit = um ? um[0] : "";
|
|
425
|
+
if (i > litStart) {
|
|
426
|
+
segs.push({ kind: "lit", text: value.slice(litStart, i) });
|
|
427
|
+
}
|
|
428
|
+
segs.push({ kind: "num", value: parseFloat(nm[0]), unit });
|
|
429
|
+
i += nm[0].length + unit.length;
|
|
430
|
+
litStart = i;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
if (value.length > litStart) {
|
|
437
|
+
segs.push({ kind: "lit", text: value.slice(litStart) });
|
|
438
|
+
}
|
|
439
|
+
return segs;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function segmentShape(segs: MotionValueSegment[]): string {
|
|
443
|
+
return segs
|
|
444
|
+
.map((seg) =>
|
|
445
|
+
seg.kind === "lit" ? `L${seg.text}` : `N${seg.unit || "<none>"}`,
|
|
446
|
+
)
|
|
447
|
+
.join(" ");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Linearly interpolate two CSS values at `ratio`. Handles matching numeric
|
|
452
|
+
* skeletons (`translateY(16px)` → `translateY(0px)`) and simple colors
|
|
453
|
+
* (hex / rgb(a)); anything non-interpolable holds the `from` value.
|
|
454
|
+
* `ratio` may leave [0, 1] for overshoot easing.
|
|
455
|
+
*/
|
|
456
|
+
export function lerpMotionValues(
|
|
457
|
+
from: string,
|
|
458
|
+
to: string,
|
|
459
|
+
ratio: number,
|
|
460
|
+
): string {
|
|
461
|
+
if (from === to) return from;
|
|
462
|
+
const colorFrom = parseSimpleColor(from);
|
|
463
|
+
const colorTo = parseSimpleColor(to);
|
|
464
|
+
if (colorFrom && colorTo) {
|
|
465
|
+
return formatSampledColor([
|
|
466
|
+
colorFrom[0] + (colorTo[0] - colorFrom[0]) * ratio,
|
|
467
|
+
colorFrom[1] + (colorTo[1] - colorFrom[1]) * ratio,
|
|
468
|
+
colorFrom[2] + (colorTo[2] - colorFrom[2]) * ratio,
|
|
469
|
+
colorFrom[3] + (colorTo[3] - colorFrom[3]) * ratio,
|
|
470
|
+
]);
|
|
471
|
+
}
|
|
472
|
+
const fromSegs = tokenizeMotionValue(from);
|
|
473
|
+
const toSegs = tokenizeMotionValue(to);
|
|
474
|
+
if (
|
|
475
|
+
fromSegs.length === toSegs.length &&
|
|
476
|
+
segmentShape(fromSegs) === segmentShape(toSegs)
|
|
477
|
+
) {
|
|
478
|
+
let out = "";
|
|
479
|
+
let interpolated = false;
|
|
480
|
+
for (let i = 0; i < fromSegs.length; i++) {
|
|
481
|
+
const a = fromSegs[i];
|
|
482
|
+
const b = toSegs[i];
|
|
483
|
+
if (a.kind === "lit") {
|
|
484
|
+
out += a.text;
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (b.kind !== "num") return from;
|
|
488
|
+
interpolated = true;
|
|
489
|
+
out +=
|
|
490
|
+
formatSampledNumber(a.value + (b.value - a.value) * ratio) +
|
|
491
|
+
(a.unit || b.unit);
|
|
492
|
+
}
|
|
493
|
+
if (interpolated) return out;
|
|
494
|
+
}
|
|
495
|
+
return from;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Sample the value of a keyframe list at normalised time `t ∈ [0, 1]`,
|
|
500
|
+
* honouring per-keyframe easing (each keyframe's `ease` shapes the interval to
|
|
501
|
+
* the NEXT keyframe, standard CSS semantics). Used by the dock's
|
|
502
|
+
* "add keyframe at playhead" so new keyframes seed with the value currently
|
|
503
|
+
* shown by the preview instead of a hardcoded placeholder.
|
|
504
|
+
*/
|
|
505
|
+
export function sampleMotionKeyframesAt(
|
|
506
|
+
keyframes: MotionKeyframe[],
|
|
507
|
+
t: number,
|
|
508
|
+
defaultEase?: MotionEase,
|
|
509
|
+
): string {
|
|
510
|
+
if (keyframes.length === 0) return "";
|
|
511
|
+
const sorted = sortMotionKeyframes(keyframes);
|
|
512
|
+
if (sorted.length === 1) return sorted[0].value;
|
|
513
|
+
const clamped = Math.min(1, Math.max(0, t));
|
|
514
|
+
if (clamped <= sorted[0].t) return sorted[0].value;
|
|
515
|
+
const last = sorted[sorted.length - 1];
|
|
516
|
+
if (clamped >= last.t) return last.value;
|
|
517
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
518
|
+
const prev = sorted[i];
|
|
519
|
+
const next = sorted[i + 1];
|
|
520
|
+
if (clamped < prev.t || clamped > next.t) continue;
|
|
521
|
+
const span = next.t - prev.t;
|
|
522
|
+
if (span <= 0) return prev.value;
|
|
523
|
+
const ratio = (clamped - prev.t) / span;
|
|
524
|
+
const eased = evaluateMotionEase(prev.ease ?? defaultEase, ratio);
|
|
525
|
+
return lerpMotionValues(prev.value, next.value, eased);
|
|
526
|
+
}
|
|
527
|
+
return last.value;
|
|
528
|
+
}
|