@agent-native/core 0.136.4 → 0.137.0

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 (170) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
  5. package/corpus/templates/clips/app/components/import-menu.tsx +109 -0
  6. package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
  7. package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
  8. package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
  9. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
  10. package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
  11. package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
  13. package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
  14. package/corpus/templates/clips/desktop/src/app.tsx +81 -30
  15. package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
  16. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
  17. package/corpus/templates/clips/server/plugins/auth.ts +9 -0
  18. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
  19. package/corpus/templates/content/actions/_database-utils.ts +6 -0
  20. package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
  22. package/corpus/templates/content/actions/configure-document-property.ts +113 -32
  23. package/corpus/templates/content/actions/delete-content-database.ts +5 -7
  24. package/corpus/templates/content/actions/delete-document-property.ts +109 -62
  25. package/corpus/templates/content/actions/delete-document.ts +178 -36
  26. package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
  27. package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
  28. package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
  29. package/corpus/templates/content/actions/remove-database-items.ts +13 -0
  30. package/corpus/templates/content/actions/set-document-property.ts +69 -1
  31. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
  32. package/corpus/templates/content/parity/matrix.md +1 -1
  33. package/corpus/templates/content/parity/matrix.ts +1 -0
  34. package/corpus/templates/content/server/db/schema.ts +27 -0
  35. package/corpus/templates/content/server/plugins/db.ts +24 -0
  36. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
  37. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
  38. package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
  39. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
  40. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
  41. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
  42. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
  43. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
  44. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
  45. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  46. package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
  47. package/corpus/templates/design/app/i18n-data.ts +8 -0
  48. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
  49. package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
  50. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
  51. package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
  52. package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
  53. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
  54. package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
  55. package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
  56. package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
  57. package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
  58. package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
  59. package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
  60. package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
  61. package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
  62. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
  63. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
  64. package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
  65. package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
  66. package/corpus/templates/factory/.env.example +17 -0
  67. package/corpus/templates/factory/AGENTS.md +27 -17
  68. package/corpus/templates/factory/README.md +19 -9
  69. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
  70. package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
  71. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
  72. package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
  73. package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
  74. package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
  75. package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
  76. package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
  77. package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
  78. package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
  79. package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
  80. package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
  81. package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
  82. package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
  83. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
  84. package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
  85. package/corpus/templates/factory/app/routes/factory.tsx +462 -9
  86. package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
  87. package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
  88. package/corpus/templates/factory/netlify.toml +2 -2
  89. package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
  90. package/corpus/templates/factory/server/connectors/slack.ts +133 -5
  91. package/corpus/templates/factory/server/db/schema.ts +6 -0
  92. package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
  93. package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
  94. package/corpus/templates/factory/server/plugins/auth.ts +1 -0
  95. package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
  96. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
  97. package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
  98. package/corpus/templates/factory/server/triage/contracts.ts +14 -1
  99. package/corpus/templates/factory/server/triage/github-client.ts +404 -0
  100. package/corpus/templates/factory/server/triage/metadata.ts +44 -0
  101. package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
  102. package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
  103. package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
  104. package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
  105. package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
  106. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
  107. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
  108. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
  109. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
  110. package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
  111. package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
  112. package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
  113. package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
  114. package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
  115. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
  116. package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
  117. package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
  118. package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
  119. package/corpus/templates/plan/shared/plan-content.ts +17 -2
  120. package/dist/cli/skills-content/canvas.d.ts +3 -3
  121. package/dist/cli/skills-content/canvas.js +16 -5
  122. package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
  123. package/dist/cli/skills-content/visual-plan-skill.js +27 -0
  124. package/dist/cli/skills-content/wireframe.d.ts +2 -2
  125. package/dist/cli/skills-content/wireframe.js +7 -0
  126. package/dist/client/AgentPanel.js +23 -8
  127. package/dist/client/api-path.d.ts +6 -0
  128. package/dist/client/api-path.js +55 -0
  129. package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
  130. package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
  131. package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
  132. package/dist/client/onboarding/first-run-enabled.js +9 -0
  133. package/dist/client/onboarding/index.d.ts +2 -1
  134. package/dist/client/onboarding/index.js +1 -0
  135. package/dist/client/onboarding/use-onboarding.d.ts +6 -1
  136. package/dist/client/onboarding/use-onboarding.js +32 -1
  137. package/dist/client/route-state.js +10 -0
  138. package/dist/client/settings/SecretsSection.js +32 -8
  139. package/dist/collab/struct-routes.d.ts +1 -1
  140. package/dist/connections/catalog.d.ts +4 -4
  141. package/dist/connections/catalog.js +3 -3
  142. package/dist/db/index.d.ts +1 -1
  143. package/dist/db/index.js +1 -1
  144. package/dist/localization/default-messages.d.ts +5 -0
  145. package/dist/localization/default-messages.js +5 -0
  146. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  147. package/dist/observability/routes.d.ts +3 -3
  148. package/dist/onboarding/app-profile.d.ts +4 -0
  149. package/dist/onboarding/app-profile.js +378 -0
  150. package/dist/onboarding/index.d.ts +2 -1
  151. package/dist/onboarding/index.js +1 -0
  152. package/dist/onboarding/plugin.d.ts +2 -0
  153. package/dist/onboarding/plugin.js +21 -1
  154. package/dist/onboarding/types.d.ts +19 -0
  155. package/dist/progress/routes.d.ts +1 -1
  156. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  157. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  158. package/dist/secrets/routes.d.ts +2 -2
  159. package/dist/secrets/routes.js +41 -17
  160. package/dist/server/auth.js +21 -1
  161. package/dist/server/better-auth-instance.d.ts +3 -2
  162. package/dist/server/better-auth-instance.js +9 -7
  163. package/dist/server/email-markdown.js +3 -8
  164. package/dist/server/transcribe-voice.d.ts +1 -1
  165. package/dist/shared/first-run-onboarding.d.ts +3 -0
  166. package/dist/shared/first-run-onboarding.js +3 -0
  167. package/dist/triggers/index.d.ts +3 -0
  168. package/dist/triggers/index.js +5 -0
  169. package/docs/content/cross-app-sso.mdx +26 -0
  170. package/package.json +1 -1
@@ -9,7 +9,7 @@ This generated matrix tracks whether high-value Content UI operations use the sa
9
9
  | database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - |
10
10
  | database.private-preview-drafts | database | Persist and reconcile a user's private database-page preview draft | action-backed | `get-preview-document-draft`, `update-preview-document-draft` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-documents.ts` | A user's private preview draft is read, saved, conflict-checked, or deleted without changing the shared database page until the normal save flow applies it. | These per-user editor-state actions are intentionally hidden from agent tools because preview drafts are a private UI recovery mechanism. | - | P1 | covered | `actions/preview-document-draft.db.test.ts` | - | - |
11
11
  | database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - |
12
- | database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - |
12
+ | database.rows | database | Add, duplicate, move, open, edit, and remove database rows | action-backed | `add-database-item`, `upsert-database-item-by-key`, `remove-database-items`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row memberships and ordering are created, duplicated, moved, edited, and removed without deleting the backing page. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - |
13
13
  | database.table-query-page | database | Query one constrained page while retaining database metadata | action-backed | `query-content-database-items` | `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | - | This UI-only bounded projection is intentionally hidden with agentTool: false; agents use get-content-database for the complete database contract. | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `app/hooks/use-content-database.test.ts` | - | - |
14
14
  | editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - |
15
15
  | editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - |
@@ -323,6 +323,7 @@ export const parityMatrix: ParityRow[] = [
323
323
  status: "action-backed",
324
324
  actions: [
325
325
  "add-database-item",
326
+ "upsert-database-item-by-key",
326
327
  "remove-database-items",
327
328
  "duplicate-database-items",
328
329
  "duplicate-database-item",
@@ -287,6 +287,33 @@ export const contentDatabaseItems = table(
287
287
  ],
288
288
  );
289
289
 
290
+ // Opt-in stable-key claims are the durable concurrency fence for the generic
291
+ // database-row upsert action. They intentionally do not constrain ordinary
292
+ // property editing or change add-database-item behavior.
293
+ export const contentDatabaseItemKeyClaims = table(
294
+ "content_database_item_key_claims",
295
+ {
296
+ id: text("id").primaryKey(),
297
+ ownerEmail: text("owner_email").notNull().default("local@localhost"),
298
+ orgId: text("org_id"),
299
+ databaseId: text("database_id").notNull(),
300
+ propertyId: text("property_id").notNull(),
301
+ keyValueJson: text("key_value_json").notNull(),
302
+ itemId: text("item_id").notNull(),
303
+ documentId: text("document_id").notNull(),
304
+ createdAt: text("created_at").notNull().default(now()),
305
+ updatedAt: text("updated_at").notNull().default(now()),
306
+ },
307
+ (claim) => [
308
+ uniqueIndex(
309
+ "content_database_item_key_claims_database_property_value_unique",
310
+ ).on(claim.databaseId, claim.propertyId, claim.keyValueJson),
311
+ uniqueIndex(
312
+ "content_database_item_key_claims_database_property_document_unique",
313
+ ).on(claim.databaseId, claim.propertyId, claim.documentId),
314
+ ],
315
+ );
316
+
290
317
  export const contentDatabaseBodyHydrationQueue = table(
291
318
  "content_database_body_hydration_queue",
292
319
  {
@@ -908,6 +908,30 @@ const runContentMigrations = runMigrations(
908
908
  WHERE legacy_database_trash.document_id = documents.id
909
909
  )`,
910
910
  },
911
+ {
912
+ version: 78,
913
+ name: "content-database-item-stable-key-claims",
914
+ sql: `CREATE TABLE IF NOT EXISTS content_database_item_key_claims (
915
+ id TEXT PRIMARY KEY,
916
+ owner_email TEXT NOT NULL DEFAULT 'local@localhost',
917
+ org_id TEXT,
918
+ database_id TEXT NOT NULL,
919
+ property_id TEXT NOT NULL,
920
+ key_value_json TEXT NOT NULL,
921
+ item_id TEXT NOT NULL,
922
+ document_id TEXT NOT NULL,
923
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
924
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
925
+ );
926
+ CREATE UNIQUE INDEX IF NOT EXISTS content_database_item_key_claims_database_property_value_unique ON content_database_item_key_claims (database_id, property_id, key_value_json);
927
+ CREATE INDEX IF NOT EXISTS content_database_item_key_claims_item_idx ON content_database_item_key_claims (item_id);
928
+ CREATE INDEX IF NOT EXISTS content_database_item_key_claims_document_idx ON content_database_item_key_claims (document_id)`,
929
+ },
930
+ {
931
+ version: 79,
932
+ name: "content-database-item-stable-key-single-active-claim",
933
+ sql: `CREATE UNIQUE INDEX IF NOT EXISTS content_database_item_key_claims_database_property_document_unique ON content_database_item_key_claims (database_id, property_id, document_id)`,
934
+ },
911
935
  ],
912
936
  { table: "content_migrations" },
913
937
  );
@@ -181,142 +181,150 @@ export function BreakpointDeviceControl({
181
181
  return (
182
182
  <div
183
183
  data-breakpoint-device-control
184
- className={cn(
185
- "flex shrink-0 items-center gap-0.5 rounded-md bg-[var(--design-editor-control-bg)] p-0.5",
186
- className,
187
- )}
184
+ className={cn("flex shrink-0 items-center gap-1", className)}
188
185
  >
189
- {/* Base segment the primary/widest editing context. Icon-only with
186
+ {/* Only the selectable segments share the tinted, inset group. "+" adds a
187
+ breakpoint rather than targeting one, so it must stay outside this
188
+ surface — inside it, it reads as one more segment to select. */}
189
+ <div className="flex items-center gap-0.5 rounded-md bg-[var(--design-editor-control-bg)] p-0.5">
190
+ {/* Base segment — the primary/widest editing context. Icon-only with
190
191
  the label in the tooltip: this control shares one cramped
191
192
  inspector-header row with the collaborators menu and play/share
192
193
  actions (~300px total), so every segment stays as narrow as it
193
194
  can. */}
194
- <button
195
- type="button"
196
- className={segmentClass(baseActive)}
197
- aria-pressed={baseActive}
198
- aria-label={t("designEditor.breakpointBar.base")}
199
- onClick={() => onSelect(undefined)}
200
- title={
201
- baseWidthPx != null
202
- ? `${t("designEditor.breakpointBar.base")} · ${Math.round(baseWidthPx)}px`
203
- : t("designEditor.breakpointBar.base")
204
- }
205
- >
206
- <IconViewportWide className="size-3.5" />
207
- </button>
195
+ <button
196
+ type="button"
197
+ className={segmentClass(baseActive)}
198
+ aria-pressed={baseActive}
199
+ aria-label={t("designEditor.breakpointBar.base")}
200
+ onClick={() => onSelect(undefined)}
201
+ title={
202
+ baseWidthPx != null
203
+ ? `${t("designEditor.breakpointBar.base")} · ${Math.round(baseWidthPx)}px`
204
+ : t("designEditor.breakpointBar.base")
205
+ }
206
+ >
207
+ <IconViewportWide className="size-3.5" />
208
+ </button>
208
209
 
209
- {/* One segment per breakpoint, widest → narrowest. Clicking always
210
+ {/* One segment per breakpoint, widest → narrowest. Clicking always
210
211
  SELECTS (never toggles off) — returning to Base is the Base
211
212
  segment, clicking the base frame/empty canvas, or Escape (the
212
213
  Framer click-to-target model). */}
213
- {ordered.map((breakpoint) => {
214
- const active = activeWidthPx === breakpoint.widthPx;
215
- const menuOpen = menuOpenFor === breakpoint.id;
216
- const showMenuAffordance = Boolean(
217
- canEdit && (onRemove || onChangeWidth) && (active || menuOpen),
218
- );
219
- return (
220
- <div key={breakpoint.id} className="relative flex items-center">
221
- <button
222
- type="button"
223
- className={segmentClass(active)}
224
- aria-pressed={active}
225
- onClick={() => onSelect(breakpoint.widthPx)}
226
- title={`${breakpoint.label} · ${breakpoint.widthPx}px`}
227
- >
228
- {/* ITEM 8a — device icon (by width bucket) + width number.
214
+ {ordered.map((breakpoint) => {
215
+ const active = activeWidthPx === breakpoint.widthPx;
216
+ const menuOpen = menuOpenFor === breakpoint.id;
217
+ const showMenuAffordance = Boolean(
218
+ canEdit && (onRemove || onChangeWidth) && (active || menuOpen),
219
+ );
220
+ return (
221
+ <div key={breakpoint.id} className="relative flex items-center">
222
+ <button
223
+ type="button"
224
+ className={segmentClass(active)}
225
+ aria-pressed={active}
226
+ onClick={() => onSelect(breakpoint.widthPx)}
227
+ title={`${breakpoint.label} · ${breakpoint.widthPx}px`}
228
+ >
229
+ {/* ITEM 8a — device icon (by width bucket) + width number.
229
230
  Kept compact (size-3, one notch smaller than Base's
230
231
  size-3.5) so the segment still fits this ~300px
231
232
  inspector-header row next to play/share; the full label
232
233
  stays in the tooltip. */}
233
- <DeviceIcon widthPx={breakpoint.widthPx} />
234
- <span>{breakpoint.widthPx}</span>
235
- </button>
236
- {/* "…" — per-breakpoint options (Change width / Remove). Shown
234
+ <DeviceIcon widthPx={breakpoint.widthPx} />
235
+ <span>{breakpoint.widthPx}</span>
236
+ </button>
237
+ {/* "…" — per-breakpoint options (Change width / Remove). Shown
237
238
  for the ACTIVE segment (and while its menu is open) so idle
238
239
  segments stay clean; replaces the old hover-"X" that both
239
240
  shifted layout and, being buried in a floating bar, was easy
240
241
  to miss entirely. */}
241
- {showMenuAffordance ? (
242
- <DropdownMenu
243
- open={menuOpen}
244
- onOpenChange={(open) => {
245
- setMenuOpenFor(open ? breakpoint.id : null);
246
- setWidthDraft(open ? String(breakpoint.widthPx) : "");
247
- }}
248
- >
249
- <DropdownMenuTrigger asChild>
250
- <button
251
- type="button"
252
- aria-label={t("designEditor.breakpointBar.options")}
253
- className="flex h-6 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[5px] text-muted-foreground hover:text-foreground"
254
- onClick={(event) => event.stopPropagation()}
255
- >
256
- <IconDots className="size-3" />
257
- </button>
258
- </DropdownMenuTrigger>
259
- <DropdownMenuContent
260
- align="end"
261
- className="design-editor-app-menu-content w-52 rounded-lg bg-[var(--design-editor-panel-bg)] p-1"
242
+ {showMenuAffordance ? (
243
+ <DropdownMenu
244
+ open={menuOpen}
245
+ onOpenChange={(open) => {
246
+ setMenuOpenFor(open ? breakpoint.id : null);
247
+ setWidthDraft(open ? String(breakpoint.widthPx) : "");
248
+ }}
262
249
  >
263
- {/* Change width — inline input row; Enter commits.
250
+ <DropdownMenuTrigger asChild>
251
+ <button
252
+ type="button"
253
+ aria-label={t("designEditor.breakpointBar.options")}
254
+ className="flex h-6 w-4 shrink-0 cursor-pointer items-center justify-center rounded-[5px] text-muted-foreground hover:text-foreground"
255
+ onClick={(event) => event.stopPropagation()}
256
+ >
257
+ <IconDots className="size-3" />
258
+ </button>
259
+ </DropdownMenuTrigger>
260
+ <DropdownMenuContent
261
+ align="end"
262
+ className="design-editor-app-menu-content w-52 rounded-lg bg-[var(--design-editor-panel-bg)] p-1"
263
+ >
264
+ {/* Change width — inline input row; Enter commits.
264
265
  Key events stay local (stopPropagation) so the menu's
265
266
  typeahead/arrow navigation doesn't steal keystrokes
266
267
  from the input. */}
267
- {onChangeWidth ? (
268
- <div className="flex items-center gap-1.5 px-1.5 py-1">
269
- <span className="shrink-0 !text-[11px] text-muted-foreground">
270
- {t("designEditor.breakpointBar.changeWidth")}
271
- </span>
272
- <Input
273
- type="number"
274
- min={320}
275
- max={3840}
276
- value={widthDraft}
277
- autoFocus
278
- onChange={(event) => setWidthDraft(event.target.value)}
279
- onKeyDown={(event) => {
280
- event.stopPropagation();
281
- if (event.key !== "Enter") return;
282
- event.preventDefault();
283
- const widthPx = parseBreakpointWidthInput(
284
- widthDraft,
285
- existingWidths.filter(
286
- (width) => width !== breakpoint.widthPx,
287
- ),
288
- );
289
- setMenuOpenFor(null);
290
- if (
291
- widthPx !== null &&
292
- widthPx !== breakpoint.widthPx
293
- ) {
294
- onChangeWidth(breakpoint.id, widthPx);
268
+ {onChangeWidth ? (
269
+ <div className="flex items-center gap-1.5 px-1.5 py-1">
270
+ <span className="shrink-0 !text-[11px] text-muted-foreground">
271
+ {t("designEditor.breakpointBar.changeWidth")}
272
+ </span>
273
+ <Input
274
+ type="number"
275
+ min={320}
276
+ max={3840}
277
+ value={widthDraft}
278
+ autoFocus
279
+ onChange={(event) =>
280
+ setWidthDraft(event.target.value)
295
281
  }
282
+ onKeyDown={(event) => {
283
+ event.stopPropagation();
284
+ if (event.key !== "Enter") return;
285
+ event.preventDefault();
286
+ const widthPx = parseBreakpointWidthInput(
287
+ widthDraft,
288
+ existingWidths.filter(
289
+ (width) => width !== breakpoint.widthPx,
290
+ ),
291
+ );
292
+ setMenuOpenFor(null);
293
+ if (
294
+ widthPx !== null &&
295
+ widthPx !== breakpoint.widthPx
296
+ ) {
297
+ onChangeWidth(breakpoint.id, widthPx);
298
+ }
299
+ }}
300
+ className="h-6 px-1.5 !text-[11px] tabular-nums"
301
+ aria-label={t(
302
+ "designEditor.breakpointBar.changeWidth",
303
+ )}
304
+ />
305
+ </div>
306
+ ) : null}
307
+ {onChangeWidth && onRemove ? (
308
+ <DropdownMenuSeparator />
309
+ ) : null}
310
+ {onRemove ? (
311
+ <DropdownMenuItem
312
+ className="h-7 px-2 py-0 !text-[12px] text-destructive focus:text-destructive"
313
+ onSelect={() => {
314
+ setMenuOpenFor(null);
315
+ onRemove(breakpoint.id);
296
316
  }}
297
- className="h-6 px-1.5 !text-[11px] tabular-nums"
298
- aria-label={t("designEditor.breakpointBar.changeWidth")}
299
- />
300
- </div>
301
- ) : null}
302
- {onChangeWidth && onRemove ? <DropdownMenuSeparator /> : null}
303
- {onRemove ? (
304
- <DropdownMenuItem
305
- className="h-7 px-2 py-0 !text-[12px] text-destructive focus:text-destructive"
306
- onSelect={() => {
307
- setMenuOpenFor(null);
308
- onRemove(breakpoint.id);
309
- }}
310
- >
311
- {t("designEditor.breakpointBar.remove")}
312
- </DropdownMenuItem>
313
- ) : null}
314
- </DropdownMenuContent>
315
- </DropdownMenu>
316
- ) : null}
317
- </div>
318
- );
319
- })}
317
+ >
318
+ {t("designEditor.breakpointBar.remove")}
319
+ </DropdownMenuItem>
320
+ ) : null}
321
+ </DropdownMenuContent>
322
+ </DropdownMenu>
323
+ ) : null}
324
+ </div>
325
+ );
326
+ })}
327
+ </div>
320
328
 
321
329
  {/* "+" — Framer default widths or a custom width. */}
322
330
  {canEdit && onAdd ? (
@@ -325,7 +333,7 @@ export function BreakpointDeviceControl({
325
333
  <Button
326
334
  variant="ghost"
327
335
  size="icon"
328
- className="size-6 cursor-pointer rounded-[5px] text-muted-foreground hover:text-foreground"
336
+ className="size-6 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:bg-[var(--design-editor-control-bg)] hover:text-foreground"
329
337
  title={t("designEditor.breakpointBar.addBreakpoint")}
330
338
  >
331
339
  <IconPlus className="size-3.5" />
@@ -2,7 +2,6 @@ import {
2
2
  IconComponents,
3
3
  IconFrame,
4
4
  IconPhoto,
5
- IconTypography,
6
5
  IconVector,
7
6
  } from "@tabler/icons-react";
8
7
  import {
@@ -29,6 +28,7 @@ import {
29
28
  } from "@/components/ui/context-menu";
30
29
  import { cn } from "@/lib/utils";
31
30
 
31
+ import { IconText } from "./inspector/design-icons";
32
32
  import type { CanvasLayerHitCandidate } from "./types";
33
33
 
34
34
  // LIVE-VERIFIED (real Figma, UI3) canvas context menus:
@@ -1177,7 +1177,7 @@ function CanvasLayerCandidateItem({
1177
1177
  const Icon = candidate.info.componentName
1178
1178
  ? IconComponents
1179
1179
  : /^(h[1-6]|p|span|label|input|textarea)$/.test(tag)
1180
- ? IconTypography
1180
+ ? IconText
1181
1181
  : /^(img|picture|video)$/.test(tag)
1182
1182
  ? IconPhoto
1183
1183
  : /^(svg|path|circle|ellipse|polygon|line)$/.test(tag)
@@ -84,7 +84,6 @@ import {
84
84
  IconRotate3d,
85
85
  IconShadow,
86
86
  IconSquare,
87
- IconTypography,
88
87
  IconUnlink,
89
88
  IconVector,
90
89
  IconWaveSine,
@@ -224,7 +223,6 @@ import {
224
223
  InspectorSegment,
225
224
  RowDragHandle,
226
225
  SectionIconButton,
227
- SectionIconToggle,
228
226
  useRowDragReorder,
229
227
  } from "./edit-panel/inspector-controls";
230
228
  import {
@@ -331,7 +329,7 @@ import {
331
329
  type MotionKeyframeCssProperty,
332
330
  type ScrubInputChangeMeta,
333
331
  } from "./inspector";
334
- import { IconLayoutSettings } from "./inspector/design-icons";
332
+ import { IconLayoutSettings, IconText } from "./inspector/design-icons";
335
333
  import type { DesignPaintType } from "./inspector/DesignColorPicker";
336
334
  import {
337
335
  GlslShaderEffectSection,
@@ -1156,7 +1154,7 @@ function CodeInspectPanel({
1156
1154
  function elementTypeIcon(element: ElementInfo) {
1157
1155
  if (elementIsComponentSelection(element)) return IconComponents;
1158
1156
  const tag = normalizedElementTagName(element.tagName);
1159
- if (TEXT_TAGS.has(tag)) return IconTypography;
1157
+ if (TEXT_TAGS.has(tag)) return IconText;
1160
1158
  if (tag === "img" || tag === "video" || tag === "picture") return IconPhoto;
1161
1159
  if (tag === "svg" || tag === "path") return IconVector;
1162
1160
  if (tag === "button" || tag === "a") return IconComponents;
@@ -1573,9 +1571,19 @@ function ExportPreview({
1573
1571
  }) {
1574
1572
  const t = useT();
1575
1573
  const rect = element?.boundingRect;
1576
- const width = rect?.width ?? 0;
1577
- const height = rect?.height ?? 0;
1578
- const aspect = width > 0 && height > 0 ? width / height : 1;
1574
+ // Selection sources disagree on whether boundingRect is populated (layer-tree
1575
+ // picks in overview report nothing), so the rendered bitmap — which exists
1576
+ // only once a real capture succeeded is the honest size to caption with.
1577
+ const [rendered, setRendered] = useState<{
1578
+ width: number;
1579
+ height: number;
1580
+ } | null>(null);
1581
+ const width = rendered?.width ?? rect?.width ?? null;
1582
+ const height = rendered?.height ?? rect?.height ?? null;
1583
+ const aspect =
1584
+ width != null && height != null && width > 0 && height > 0
1585
+ ? width / height
1586
+ : 1;
1579
1587
  const [state, setState] = useState<
1580
1588
  | { status: "idle" }
1581
1589
  | { status: "loading" }
@@ -1591,6 +1599,7 @@ function ExportPreview({
1591
1599
  let cancelled = false;
1592
1600
  let objectUrl: string | null = null;
1593
1601
  setState({ status: "loading" });
1602
+ setRendered(null);
1594
1603
  void onRender()
1595
1604
  .then((blob) => {
1596
1605
  if (cancelled) return;
@@ -1624,6 +1633,12 @@ function ExportPreview({
1624
1633
  style={{
1625
1634
  imageRendering: "auto",
1626
1635
  }}
1636
+ onLoad={(event) =>
1637
+ setRendered({
1638
+ width: event.currentTarget.naturalWidth,
1639
+ height: event.currentTarget.naturalHeight,
1640
+ })
1641
+ }
1627
1642
  />
1628
1643
  ) : (
1629
1644
  <div className="flex size-full items-center justify-center rounded border border-[var(--design-editor-control-border)] bg-[var(--design-editor-panel-bg)] px-2 text-center !text-[10px] text-muted-foreground">
@@ -1633,9 +1648,43 @@ function ExportPreview({
1633
1648
  </div>
1634
1649
  )}
1635
1650
  </div>
1636
- <p className="mt-2 text-center text-[10px] tabular-nums text-muted-foreground">
1637
- {Math.round(width)} × {Math.round(height)}
1638
- </p>
1651
+ {width != null && height != null ? (
1652
+ <p className="mt-2 text-center text-[10px] tabular-nums text-muted-foreground">
1653
+ {Math.round(width)} × {Math.round(height)}
1654
+ </p>
1655
+ ) : null}
1656
+ </div>
1657
+ );
1658
+ }
1659
+
1660
+ /** Named, collapsed-by-default "Preview" row. An icon-only toggle in the
1661
+ * section header reads as "export an image", not "reveal what will export". */
1662
+ function ExportPreviewDisclosure({
1663
+ element,
1664
+ onRender,
1665
+ }: {
1666
+ element: ElementInfo | null;
1667
+ onRender?: () => Promise<Blob>;
1668
+ }) {
1669
+ const [open, setOpen] = useState(false);
1670
+ const label = "Preview"; // i18n-ignore design inspector label
1671
+
1672
+ return (
1673
+ <div>
1674
+ <button
1675
+ type="button"
1676
+ onClick={() => setOpen((shown) => !shown)}
1677
+ aria-expanded={open}
1678
+ className="flex cursor-pointer items-center gap-1 !text-[11px] font-medium text-muted-foreground hover:text-foreground"
1679
+ >
1680
+ {open ? (
1681
+ <IconChevronDown className="size-3 shrink-0" />
1682
+ ) : (
1683
+ <IconChevronRight className="size-3 shrink-0 rtl:-scale-x-100" />
1684
+ )}
1685
+ {label}
1686
+ </button>
1687
+ {open ? <ExportPreview element={element} onRender={onRender} /> : null}
1639
1688
  </div>
1640
1689
  );
1641
1690
  }
@@ -1800,7 +1849,6 @@ export const EditPanel = memo(function EditPanel({
1800
1849
  const [exportSettings, setExportSettings] = useState<ExportSettingsValue>(
1801
1850
  DEFAULT_EXPORT_SETTINGS,
1802
1851
  );
1803
- const [showExportPreview, setShowExportPreview] = useState(false);
1804
1852
  // Element interaction-state selector (Default / Hover / Focus / …). Owned
1805
1853
  // here (not lifted to the parent) per the mission contract — DesignEditor
1806
1854
  // only needs to react to changes via onInteractionStateChange, it doesn't
@@ -1927,7 +1975,6 @@ export const EditPanel = memo(function EditPanel({
1927
1975
 
1928
1976
  useEffect(() => {
1929
1977
  setExportSettings(DEFAULT_EXPORT_SETTINGS);
1930
- setShowExportPreview(false);
1931
1978
  }, [selectedElementKey]);
1932
1979
 
1933
1980
  useEffect(() => {
@@ -2368,22 +2415,7 @@ export const EditPanel = memo(function EditPanel({
2368
2415
  </>
2369
2416
  )}
2370
2417
  {onExport ? (
2371
- <PanelSection
2372
- title={t("editPanel.sections.export")}
2373
- actions={
2374
- <SectionIconToggle
2375
- label={
2376
- showExportPreview
2377
- ? "Hide preview" /* i18n-ignore design inspector action */
2378
- : "Show preview" /* i18n-ignore design inspector action */
2379
- }
2380
- active={showExportPreview}
2381
- onClick={() => setShowExportPreview((shown) => !shown)}
2382
- >
2383
- <IconPhoto className="size-3.5" />
2384
- </SectionIconToggle>
2385
- }
2386
- >
2418
+ <PanelSection title={t("editPanel.sections.export")}>
2387
2419
  <ExportSettingsPanel
2388
2420
  key={selectedElementKey}
2389
2421
  value={exportSettings}
@@ -2402,12 +2434,14 @@ export const EditPanel = memo(function EditPanel({
2402
2434
  }
2403
2435
  onExport={onExport}
2404
2436
  />
2405
- {showExportPreview ? (
2406
- <ExportPreview
2407
- element={inspectorElement}
2408
- onRender={onRenderExportPreview}
2409
- />
2410
- ) : null}
2437
+ {/* Distinct from the settings panel's key: two siblings
2438
+ sharing one key makes React duplicate the panel on every
2439
+ re-render, so the section grows an Export block per tick. */}
2440
+ <ExportPreviewDisclosure
2441
+ key={`${selectedElementKey}:preview`}
2442
+ element={inspectorElement}
2443
+ onRender={onRenderExportPreview}
2444
+ />
2411
2445
  </PanelSection>
2412
2446
  ) : null}
2413
2447