@agent-native/core 0.85.5 → 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.
Files changed (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. 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
- animNames.push(name);
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 = keyframes[0]?.ease ?? defaultEase;
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 ruleBlocks = [...rulesByTarget.entries()]
101
- .sort(([a], [b]) => a.localeCompare(b))
102
- .map(
103
- ([targetNodeId, rule]) =>
104
- `[data-agent-native-node-id="${escAttr(targetNodeId)}"] {\n` +
105
- ` animation-name: ${rule.names.join(", ")};\n` +
106
- ` animation-duration: ${rule.durations.join(", ")};\n` +
107
- ` animation-timing-function: ${rule.timings.join(", ")};\n` +
108
- ` animation-fill-mode: ${rule.fillModes.join(", ")};\n` +
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>--<property>`
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
- return `an-motion-${safe(nodeId)}--${safe(property)}`;
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(names: string[]): string {
304
- if (names.length === 0) {
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
- // Disable every named animation on any element that carries it.
308
- const selector = names.map((n) => `[style*="${n}"]`).join(",\n ");
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},\n` +
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
- /** Format a normalised time `t ∈ [0, 1]` as a CSS percentage string. */
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 as a decimal string.
457
+ * Returns a 32-bit unsigned integer.
419
458
  */
420
- function djb2(str: string): string {
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.toString(10);
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
+ }