@agent-native/core 0.84.9 → 0.84.12

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 (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +43 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/run-store.ts +29 -6
  5. package/corpus/core/src/cli/skills.ts +6 -3
  6. package/corpus/core/src/client/agent-chat-adapter.ts +31 -11
  7. package/corpus/core/src/client/require-session.tsx +20 -1
  8. package/corpus/core/src/client/sharing/ShareButton.tsx +9 -3
  9. package/corpus/core/src/collab/storage.ts +40 -5
  10. package/corpus/core/src/server/auth.ts +6 -0
  11. package/corpus/templates/assets/.agents/skills/logo-composite/SKILL.md +7 -6
  12. package/corpus/templates/assets/AGENTS.md +15 -0
  13. package/corpus/templates/assets/actions/_helpers.ts +3 -1
  14. package/corpus/templates/assets/actions/create-generation-preset.ts +12 -1
  15. package/corpus/templates/assets/actions/generate-asset.ts +5 -1
  16. package/corpus/templates/assets/actions/generate-image-batch.ts +6 -1
  17. package/corpus/templates/assets/actions/generate-image.ts +18 -11
  18. package/corpus/templates/assets/actions/open-asset-picker.ts +4 -2
  19. package/corpus/templates/assets/actions/update-generation-preset.ts +16 -3
  20. package/corpus/templates/assets/app/i18n/zh-TW.ts +3 -0
  21. package/corpus/templates/assets/app/i18n-data.ts +30 -0
  22. package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +42 -7
  23. package/corpus/templates/assets/app/routes/library.tsx +4 -2
  24. package/corpus/templates/assets/changelog/2026-07-01-generation-presets-can-now-composite-your-brand-s-canonical-.md +6 -0
  25. package/corpus/templates/assets/changelog/2026-07-01-tagging-a-preset-now-briefs-the-agent-on-that-preset-s-aesth.md +6 -0
  26. package/corpus/templates/assets/server/lib/preset-chat-context.ts +118 -0
  27. package/corpus/templates/assets/server/plugins/agent-chat.ts +5 -0
  28. package/corpus/templates/assets/shared/api.ts +1 -0
  29. package/corpus/templates/design/actions/export-html.ts +4 -2
  30. package/corpus/templates/design/actions/export-pdf.ts +3 -1
  31. package/corpus/templates/design/actions/export-svg.ts +4 -2
  32. package/corpus/templates/design/actions/export-zip.ts +10 -3
  33. package/corpus/templates/design/actions/get-design-snapshot.ts +41 -4
  34. package/corpus/templates/design/actions/present-design-variants.ts +21 -6
  35. package/corpus/templates/design/actions/update-file.ts +68 -17
  36. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +3 -2
  37. package/corpus/templates/design/app/components/design/EditPanel.tsx +35 -3
  38. package/corpus/templates/design/app/components/design/MotionDock.tsx +46 -11
  39. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +80 -30
  40. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +60 -10
  41. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +2 -0
  42. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +4 -0
  43. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  44. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +2 -2
  45. package/corpus/templates/design/app/i18n-data.ts +1 -1
  46. package/corpus/templates/design/app/pages/DesignEditor.tsx +493 -109
  47. package/corpus/templates/design/app/pages/VisualEdit.tsx +4 -16
  48. package/corpus/templates/design/changelog/2026-07-01-improved-variant-pick-follow-ups-so-selected-directions-cont.md +6 -0
  49. package/corpus/templates/design/e2e/helpers.ts +2 -2
  50. package/dist/agent/run-store.d.ts.map +1 -1
  51. package/dist/agent/run-store.js +28 -6
  52. package/dist/agent/run-store.js.map +1 -1
  53. package/dist/cli/skills.d.ts.map +1 -1
  54. package/dist/cli/skills.js +6 -3
  55. package/dist/cli/skills.js.map +1 -1
  56. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  57. package/dist/client/agent-chat-adapter.js +26 -11
  58. package/dist/client/agent-chat-adapter.js.map +1 -1
  59. package/dist/client/require-session.d.ts +14 -0
  60. package/dist/client/require-session.d.ts.map +1 -1
  61. package/dist/client/require-session.js +19 -1
  62. package/dist/client/require-session.js.map +1 -1
  63. package/dist/client/sharing/ShareButton.d.ts +4 -2
  64. package/dist/client/sharing/ShareButton.d.ts.map +1 -1
  65. package/dist/client/sharing/ShareButton.js +3 -1
  66. package/dist/client/sharing/ShareButton.js.map +1 -1
  67. package/dist/collab/routes.d.ts +1 -1
  68. package/dist/collab/storage.d.ts.map +1 -1
  69. package/dist/collab/storage.js +31 -5
  70. package/dist/collab/storage.js.map +1 -1
  71. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  72. package/dist/notifications/routes.d.ts +2 -2
  73. package/dist/observability/routes.d.ts +6 -6
  74. package/dist/resources/handlers.d.ts +2 -2
  75. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  76. package/dist/server/auth.d.ts.map +1 -1
  77. package/dist/server/auth.js +7 -0
  78. package/dist/server/auth.js.map +1 -1
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -40,7 +40,17 @@ const FALLBACK_INSTRUCTIONS =
40
40
  "The generated directions have been saved as normal screens on the Design " +
41
41
  "overview board. The chat shows one button per screen. Ask the user to pick " +
42
42
  "a screen by name if the inline buttons are not available; after they pick, " +
43
- "delete the other variant screens and continue from the kept screen.";
43
+ "delete the other variant screens, call get-design-snapshot with fileId for " +
44
+ "the kept screen, and continue from that one file in a bounded pass.";
45
+
46
+ const VARIANT_PICK_SUBMIT_MESSAGE =
47
+ "Use this design direction. Delete the other variant screens, call " +
48
+ "get-design-snapshot with the selected option's fileId (or filename if " +
49
+ "needed) for the kept file, then continue from that current HTML in a " +
50
+ "bounded single-file pass. Prefer edit-design for refinements; if " +
51
+ "generate-design is needed, update only the kept file compactly. Do not " +
52
+ "generate a full multi-screen rewrite or resend a huge payload. Stop after " +
53
+ "the first successful save.";
44
54
 
45
55
  const variantSchema = z.object({
46
56
  id: z.string().min(1).describe("Stable variant id, e.g. 'minimal-focus'"),
@@ -410,7 +420,11 @@ export default defineAction({
410
420
  "Provide 2-5 variants (3 is the sweet spot). Use this for design " +
411
421
  "exploration before follow-up refinement. After the user's choice, keep " +
412
422
  "the chosen screen, delete the other generated variant screens, and " +
413
- "continue from the kept screen. For complex apps, make each variant a " +
423
+ "call get-design-snapshot with fileId for the kept screen before " +
424
+ "continuing from that single file in a bounded pass. Prefer edit-design " +
425
+ "for refinements; if generate-design is needed, update only the kept file " +
426
+ "compactly and stop after the first successful save. For complex apps, " +
427
+ "make each variant a " +
414
428
  "compact representative screen; pass concise labels/descriptions/features " +
415
429
  "and omit content when full HTML would be too large. Design will render " +
416
430
  "compact screens from the direction data. Expand the chosen direction " +
@@ -563,9 +577,9 @@ export default defineAction({
563
577
  await writeAppStateForCurrentTab("guided-questions", {
564
578
  title: prompt ?? "Pick a direction",
565
579
  description:
566
- "All options are on the board. Choose the one to keep and I will continue from it.",
580
+ "All options are on the board. Choose one to keep; I will delete the others, read only the kept screen, and continue from that single file in a bounded pass.",
567
581
  submitLabel: "Use selected direction",
568
- submitMessage: "Use this design direction.",
582
+ submitMessage: VARIANT_PICK_SUBMIT_MESSAGE,
569
583
  skipLabel: "Show another set",
570
584
  skipMessage: "None of these directions are right.",
571
585
  questions: [
@@ -590,7 +604,8 @@ export default defineAction({
590
604
  label: screen.label || optionName(index),
591
605
  value:
592
606
  `Keep "${screen.label}" (${screen.filename}, file id ${screen.id}) ` +
593
- `from variant set ${variantSetId}. Delete the other variant screens: ${otherScreens}.`,
607
+ `from variant set ${variantSetId}. Delete the other variant screens: ${otherScreens}. ` +
608
+ `Then call get-design-snapshot with designId ${designId} and fileId ${screen.id} (filename ${screen.filename}) and continue from that current HTML in a bounded single-file pass. Prefer edit-design for refinements; if generate-design is needed, update only this kept file compactly. Do not generate a full multi-screen rewrite or resend a huge payload. Stop after the first successful save.`,
594
609
  };
595
610
  }),
596
611
  },
@@ -608,7 +623,7 @@ export default defineAction({
608
623
  embed: true,
609
624
  fallbackInstructions: FALLBACK_INSTRUCTIONS,
610
625
  nextRequiredAction:
611
- "Wait for the user to pick a screen in chat. Then delete the unchosen variant screens with delete-file and continue from the chosen screen.",
626
+ "Wait for the user to pick a screen in chat. Then delete the unchosen variant screens with delete-file, call get-design-snapshot with fileId for the chosen screen, and continue from that single file in a bounded pass. Prefer edit-design; if generate-design is needed, update only the kept file compactly and stop after the first successful save.",
612
627
  };
613
628
  },
614
629
  link: ({ result }) => {
@@ -4,12 +4,24 @@ import {
4
4
  applyText,
5
5
  seedFromText,
6
6
  } from "@agent-native/core/collab";
7
+ import { isPostgres } from "@agent-native/core/db";
7
8
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
8
- import { and, eq } from "drizzle-orm";
9
+ import { and, eq, sql } from "drizzle-orm";
9
10
  import { z } from "zod";
10
11
 
11
12
  import { getDb, schema } from "../server/db/index.js";
12
13
 
14
+ function rowsAffected(result: unknown): number | undefined {
15
+ const candidate = result as {
16
+ rowsAffected?: unknown;
17
+ rowCount?: unknown;
18
+ changes?: unknown;
19
+ } | null;
20
+ const value =
21
+ candidate?.rowsAffected ?? candidate?.rowCount ?? candidate?.changes;
22
+ return typeof value === "number" ? value : undefined;
23
+ }
24
+
13
25
  export default defineAction({
14
26
  description:
15
27
  "Update an existing file in a design project. " +
@@ -70,13 +82,19 @@ export default defineAction({
70
82
 
71
83
  await assertAccess("design", file.designId, "editor");
72
84
 
73
- // Reject a rename that would collide with an existing filename in the same
74
- // design. The collision check and the write run in one transaction so they
75
- // can't be interleaved by a concurrent rename. (A DB-level UNIQUE index on
76
- // (designId, filename) would be the strongest guarantee but is a non-additive
77
- // schema change on existing data, so it's deferred.)
78
- await db.transaction(async (tx) => {
79
- if (filename !== undefined) {
85
+ const updates: Record<string, unknown> = { updatedAt: now };
86
+ if (content !== undefined) updates.content = content;
87
+ if (filename !== undefined) updates.filename = filename;
88
+ if (fileType !== undefined) updates.fileType = fileType;
89
+
90
+ if (filename !== undefined && isPostgres()) {
91
+ await db.transaction(async (tx) => {
92
+ // Postgres evaluates concurrent NOT EXISTS updates under MVCC, so a
93
+ // guarded UPDATE alone can still race. Serialize design-file renames in
94
+ // this rare path without using SQLite's fragile async savepoint wrapper.
95
+ await (
96
+ tx as unknown as { execute: (query: unknown) => Promise<unknown> }
97
+ ).execute(sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`);
80
98
  const [collision] = await tx
81
99
  .select({ id: schema.designFiles.id })
82
100
  .from(schema.designFiles)
@@ -92,18 +110,51 @@ export default defineAction({
92
110
  `File "${filename}" already exists in design ${file.designId}`,
93
111
  );
94
112
  }
95
- }
113
+ await tx
114
+ .update(schema.designFiles)
115
+ .set(updates)
116
+ .where(eq(schema.designFiles.id, id));
117
+ });
118
+ } else {
119
+ // Reject colliding SQLite renames as part of the write. SQLite's local
120
+ // async transaction wrapper can fail under concurrent editor/collab writes,
121
+ // so keep this to one guarded UPDATE instead of a SELECT-then-UPDATE window.
122
+ const updateWhere =
123
+ filename === undefined
124
+ ? eq(schema.designFiles.id, id)
125
+ : and(
126
+ eq(schema.designFiles.id, id),
127
+ sql`NOT EXISTS (
128
+ SELECT 1 FROM design_files AS sibling
129
+ WHERE sibling.design_id = ${file.designId}
130
+ AND sibling.filename = ${filename}
131
+ AND sibling.id <> ${id}
132
+ )`,
133
+ );
96
134
 
97
- const updates: Record<string, unknown> = { updatedAt: now };
98
- if (content !== undefined) updates.content = content;
99
- if (filename !== undefined) updates.filename = filename;
100
- if (fileType !== undefined) updates.fileType = fileType;
101
-
102
- await tx
135
+ const updateResult = await db
103
136
  .update(schema.designFiles)
104
137
  .set(updates)
105
- .where(eq(schema.designFiles.id, id));
106
- });
138
+ .where(updateWhere);
139
+
140
+ if (filename !== undefined && rowsAffected(updateResult) === 0) {
141
+ const [collision] = await db
142
+ .select({ id: schema.designFiles.id })
143
+ .from(schema.designFiles)
144
+ .where(
145
+ and(
146
+ eq(schema.designFiles.designId, file.designId),
147
+ eq(schema.designFiles.filename, filename),
148
+ ),
149
+ )
150
+ .limit(1);
151
+ if (collision && collision.id !== id) {
152
+ throw new Error(
153
+ `File "${filename}" already exists in design ${file.designId}`,
154
+ );
155
+ }
156
+ }
157
+ }
107
158
 
108
159
  // Push content through the collab layer so live editors see the change
109
160
  if (content !== undefined && syncCollab) {
@@ -1811,14 +1811,15 @@ export function DesignCanvas({
1811
1811
  : "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
1812
1812
  }
1813
1813
  data-design-preview-iframe
1814
- data-screen-iframe-id={screenId ?? undefined}
1814
+ data-screen-iframe-id={
1815
+ boardSurface ? undefined : (screenId ?? undefined)
1816
+ }
1815
1817
  data-design-source-type={
1816
1818
  sourceType ??
1817
1819
  (externalPreviewUrl
1818
1820
  ? "localhost" // inferred — content is a URL
1819
1821
  : "inline")
1820
1822
  }
1821
- allowTransparency={transparentBackground || undefined}
1822
1823
  className="block h-full w-full border-0 bg-transparent"
1823
1824
  style={{
1824
1825
  background: iframeBackgroundColor,
@@ -138,7 +138,7 @@ import type { StatesPanelProps } from "./StatesPanel";
138
138
  import { TweaksPanelContent } from "./TweaksPanel";
139
139
  import type { ElementInfo } from "./types";
140
140
 
141
- export type InspectorTab = "design" | "tweaks";
141
+ export type InspectorTab = "design" | "tweaks" | "extensions";
142
142
 
143
143
  const MIXED_VALUE = "Mixed";
144
144
 
@@ -216,6 +216,7 @@ interface EditPanelProps {
216
216
  tweakValues?: Record<string, string | number | boolean>;
217
217
  onTweakChange?: (id: string, value: string | number | boolean) => void;
218
218
  onRequestTweaks?: (anchor: HTMLElement) => void;
219
+ extensionsPanel?: ReactNode;
219
220
  onStyleChange: (property: string, value: string) => void;
220
221
  onStylesChange?: (styles: Record<string, string>) => void;
221
222
  onExport?: (settings: ExportSettingsValue[]) => void;
@@ -283,6 +284,8 @@ interface EditPanelProps {
283
284
  defaultComponentName?: string;
284
285
  /** Code-inspection data for the "Inspect code" popover. */
285
286
  inspectCode?: InspectCodeData;
287
+ /** Optional compact AI edit controls for selected/local source elements. */
288
+ aiActions?: ReactNode;
286
289
  }
287
290
 
288
291
  /**
@@ -2960,10 +2963,12 @@ function InspectorTabsHeader({
2960
2963
  activeTab,
2961
2964
  onActiveTabChange,
2962
2965
  trailing,
2966
+ showExtensions,
2963
2967
  }: {
2964
2968
  activeTab: InspectorTab;
2965
2969
  onActiveTabChange: (tab: InspectorTab) => void;
2966
2970
  trailing?: ReactNode;
2971
+ showExtensions?: boolean;
2967
2972
  }) {
2968
2973
  const t = useT();
2969
2974
 
@@ -2986,6 +2991,14 @@ function InspectorTabsHeader({
2986
2991
  >
2987
2992
  {t("designEditor.tweaks")}
2988
2993
  </TabsTrigger>
2994
+ {showExtensions ? (
2995
+ <TabsTrigger
2996
+ value="extensions"
2997
+ className="h-6 rounded-md px-1.5 !text-[11px] font-semibold text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-[var(--design-editor-panel-raised-bg)] data-[state=active]:text-foreground data-[state=active]:shadow-none"
2998
+ >
2999
+ {"Extensions" /* i18n-ignore design inspector tab */}
3000
+ </TabsTrigger>
3001
+ ) : null}
2989
3002
  </TabsList>
2990
3003
  </Tabs>
2991
3004
  {trailing ? <div className="shrink-0">{trailing}</div> : null}
@@ -3282,7 +3295,7 @@ function CornerRadiusControl({
3282
3295
  value={radius}
3283
3296
  onChange={commitRadius}
3284
3297
  min={0}
3285
- precision={1}
3298
+ precision={0}
3286
3299
  />
3287
3300
  <Tooltip>
3288
3301
  <TooltipTrigger asChild>
@@ -3308,6 +3321,7 @@ function CornerRadiusControl({
3308
3321
  <>
3309
3322
  <AppearanceScrubField
3310
3323
  label={t("editPanel.labels.topLeft")}
3324
+ ariaLabel="Top left"
3311
3325
  icon={IconRadiusTopLeft}
3312
3326
  value={corners.topLeft}
3313
3327
  onChange={(value) =>
@@ -3321,6 +3335,7 @@ function CornerRadiusControl({
3321
3335
  />
3322
3336
  <AppearanceScrubField
3323
3337
  label={t("editPanel.labels.topRight")}
3338
+ ariaLabel="Top right"
3324
3339
  icon={IconRadiusTopRight}
3325
3340
  value={corners.topRight}
3326
3341
  onChange={(value) =>
@@ -3335,6 +3350,7 @@ function CornerRadiusControl({
3335
3350
  <span aria-hidden="true" />
3336
3351
  <AppearanceScrubField
3337
3352
  label={t("editPanel.labels.bottomLeft")}
3353
+ ariaLabel="Bottom left"
3338
3354
  icon={IconRadiusBottomLeft}
3339
3355
  value={corners.bottomLeft}
3340
3356
  onChange={(value) =>
@@ -3348,6 +3364,7 @@ function CornerRadiusControl({
3348
3364
  />
3349
3365
  <AppearanceScrubField
3350
3366
  label={t("editPanel.labels.bottomRight")}
3367
+ ariaLabel="Bottom right"
3351
3368
  icon={IconRadiusBottomRight}
3352
3369
  value={corners.bottomRight}
3353
3370
  onChange={(value) =>
@@ -3368,6 +3385,7 @@ function CornerRadiusControl({
3368
3385
 
3369
3386
  function AppearanceScrubField({
3370
3387
  label,
3388
+ ariaLabel,
3371
3389
  icon,
3372
3390
  value,
3373
3391
  onChange,
@@ -3379,6 +3397,7 @@ function AppearanceScrubField({
3379
3397
  precision,
3380
3398
  }: {
3381
3399
  label: string;
3400
+ ariaLabel?: string;
3382
3401
  icon: (props: { className?: string }) => ReactNode;
3383
3402
  value: number;
3384
3403
  onChange: (value: number) => void;
@@ -3392,7 +3411,7 @@ function AppearanceScrubField({
3392
3411
  return (
3393
3412
  <ScrubInput
3394
3413
  label={label}
3395
- ariaLabel={label}
3414
+ ariaLabel={ariaLabel ?? label}
3396
3415
  icon={icon}
3397
3416
  value={value}
3398
3417
  onChange={onChange}
@@ -6842,6 +6861,7 @@ export function EditPanel({
6842
6861
  tweakValues = {},
6843
6862
  onTweakChange,
6844
6863
  onRequestTweaks,
6864
+ extensionsPanel,
6845
6865
  onStyleChange,
6846
6866
  onStylesChange,
6847
6867
  onExport,
@@ -6858,6 +6878,7 @@ export function EditPanel({
6858
6878
  selectedElementAlreadyComponent = false,
6859
6879
  defaultComponentName = "Component",
6860
6880
  inspectCode,
6881
+ aiActions,
6861
6882
  }: EditPanelProps) {
6862
6883
  const t = useT();
6863
6884
  const [createComponentOpen, setCreateComponentOpen] = useState(false);
@@ -6950,6 +6971,7 @@ export function EditPanel({
6950
6971
  activeTab={activeTab}
6951
6972
  onActiveTabChange={handleActiveTabChange}
6952
6973
  trailing={headerTrailing}
6974
+ showExtensions={!!extensionsPanel}
6953
6975
  />
6954
6976
 
6955
6977
  {activeTab === "design" ? (
@@ -7058,6 +7080,12 @@ export function EditPanel({
7058
7080
  />
7059
7081
  )}
7060
7082
 
7083
+ {aiActions ? (
7084
+ <div className="border-b border-[var(--design-editor-control-border)] px-2 py-1.5">
7085
+ {aiActions}
7086
+ </div>
7087
+ ) : null}
7088
+
7061
7089
  {!inspectorElement && (
7062
7090
  <PageProperties
7063
7091
  styles={pageStyles}
@@ -7228,6 +7256,10 @@ export function EditPanel({
7228
7256
  />
7229
7257
  </div>
7230
7258
  </div>
7259
+ ) : activeTab === "extensions" && extensionsPanel ? (
7260
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
7261
+ {extensionsPanel}
7262
+ </div>
7231
7263
  ) : null}
7232
7264
  </div>
7233
7265
  );
@@ -117,6 +117,14 @@ export interface MotionDockProps {
117
117
  canvasIframeRef?: React.RefObject<HTMLIFrameElement | null>;
118
118
  /** Whether the parent autosave mutation is in flight. */
119
119
  applying?: boolean;
120
+ /** Controlled auto-keyframe state. */
121
+ autoKeyframe?: boolean;
122
+ /** Called when the auto-keyframe toggle changes. */
123
+ onAutoKeyframeChange?: (enabled: boolean) => void;
124
+ /** Controlled playhead position, normalized to [0, 1]. */
125
+ playhead?: number;
126
+ /** Called whenever the playhead moves. */
127
+ onPlayheadChange?: (t: number) => void;
120
128
  /**
121
129
  * The currently-selected canvas element, if any. Required to create the FIRST
122
130
  * track for a layer: the picker animates this node's
@@ -139,6 +147,10 @@ export function MotionDock({
139
147
  onDurationChange,
140
148
  canvasIframeRef,
141
149
  applying = false,
150
+ autoKeyframe: autoKeyframeProp,
151
+ onAutoKeyframeChange,
152
+ playhead: playheadProp,
153
+ onPlayheadChange,
142
154
  selectedTarget = null,
143
155
  }: MotionDockProps) {
144
156
  // Controlled / uncontrolled open state.
@@ -153,13 +165,36 @@ export function MotionDock({
153
165
  );
154
166
 
155
167
  // Playhead position: normalised [0, 1].
156
- const [playhead, setPlayhead] = useState(0);
168
+ const [playhead, setPlayhead] = useState(playheadProp ?? 0);
157
169
  const [playing, setPlaying] = useState(false);
158
170
  const playRafRef = useRef<number | null>(null);
159
171
  const playStartRef = useRef<{ wallMs: number; startT: number } | null>(null);
160
-
161
- // Auto-keyframe mode: clicking the canvas at a time scrubs without writing.
162
- const [autoKeyframe, setAutoKeyframe] = useState(false);
172
+ useEffect(() => {
173
+ if (playheadProp === undefined) return;
174
+ setPlayhead(Math.max(0, Math.min(1, playheadProp)));
175
+ }, [playheadProp]);
176
+
177
+ // Auto-keyframe mode: inspector/style edits create keyframes at the playhead.
178
+ const [autoKeyframeInternal, setAutoKeyframeInternal] = useState(false);
179
+ const autoKeyframe = autoKeyframeProp ?? autoKeyframeInternal;
180
+ const setAutoKeyframe = useCallback(
181
+ (next: boolean | ((current: boolean) => boolean)) => {
182
+ const resolved =
183
+ typeof next === "function"
184
+ ? (next as (current: boolean) => boolean)(autoKeyframe)
185
+ : next;
186
+ setAutoKeyframeInternal(resolved);
187
+ onAutoKeyframeChange?.(resolved);
188
+ },
189
+ [autoKeyframe, onAutoKeyframeChange],
190
+ );
191
+ const setPlayheadValue = useCallback(
192
+ (next: number) => {
193
+ setPlayhead(next);
194
+ onPlayheadChange?.(next);
195
+ },
196
+ [onPlayheadChange],
197
+ );
163
198
 
164
199
  // Dock height (resizable via the top drag handle).
165
200
  const [dockHeight, setDockHeight] = useState(DEFAULT_DOCK_HEIGHT);
@@ -235,7 +270,7 @@ export function MotionDock({
235
270
  if (!playStartRef.current) return;
236
271
  const elapsed = now - playStartRef.current.wallMs;
237
272
  const t = Math.min(1, playStartRef.current.startT + elapsed / durationMs);
238
- setPlayhead(t);
273
+ setPlayheadValue(t);
239
274
  sendPreview(t);
240
275
  if (t < 1) {
241
276
  playRafRef.current = requestAnimationFrame(tick);
@@ -244,7 +279,7 @@ export function MotionDock({
244
279
  }
245
280
  };
246
281
  playRafRef.current = requestAnimationFrame(tick);
247
- }, [durationMs, playhead, sendPreview, stopPlayback]);
282
+ }, [durationMs, playhead, sendPreview, setPlayheadValue, stopPlayback]);
248
283
 
249
284
  useEffect(() => {
250
285
  return () => {
@@ -263,10 +298,10 @@ export function MotionDock({
263
298
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
264
299
  const rect = trackAreaRef.current.getBoundingClientRect();
265
300
  const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
266
- setPlayhead(t);
301
+ setPlayheadValue(t);
267
302
  sendPreview(t);
268
303
  },
269
- [sendPreview, stopPlayback],
304
+ [sendPreview, setPlayheadValue, stopPlayback],
270
305
  );
271
306
 
272
307
  const handleRulerPointerMove = useCallback(
@@ -274,10 +309,10 @@ export function MotionDock({
274
309
  if (!isDraggingPlayhead.current || !trackAreaRef.current) return;
275
310
  const rect = trackAreaRef.current.getBoundingClientRect();
276
311
  const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
277
- setPlayhead(t);
312
+ setPlayheadValue(t);
278
313
  sendPreview(t);
279
314
  },
280
- [sendPreview],
315
+ [sendPreview, setPlayheadValue],
281
316
  );
282
317
 
283
318
  const handleRulerPointerUp = useCallback(() => {
@@ -493,7 +528,7 @@ export function MotionDock({
493
528
  className="size-6 shrink-0"
494
529
  onClick={() => {
495
530
  stopPlayback();
496
- setPlayhead(0);
531
+ setPlayheadValue(0);
497
532
  sendPreview(0);
498
533
  }}
499
534
  aria-label="Reset playhead"