@1agh/maude 0.41.0 → 0.43.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 (260) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -63
  6. package/apps/studio/bin/_html-playwright.mjs +26 -4
  7. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  8. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  9. package/apps/studio/bin/_png-playwright.mjs +15 -2
  10. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  11. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/_video-playwright.mjs +141 -71
  15. package/apps/studio/bin/ingest-footage.sh +27 -0
  16. package/apps/studio/bin/photo-adjust.sh +163 -0
  17. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  18. package/apps/studio/bin/probe-footage.sh +28 -0
  19. package/apps/studio/bin/read-annotations.mjs +125 -2
  20. package/apps/studio/bin/screenshot.sh +53 -4
  21. package/apps/studio/canvas-edit.ts +459 -10
  22. package/apps/studio/canvas-lib.tsx +523 -5
  23. package/apps/studio/canvas-pipeline.ts +65 -7
  24. package/apps/studio/canvas-shell.tsx +156 -16
  25. package/apps/studio/client/app.jsx +476 -88
  26. package/apps/studio/client/export-center.jsx +460 -0
  27. package/apps/studio/client/github.js +4 -0
  28. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  29. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  30. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  31. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  32. package/apps/studio/client/photo-knobs.jsx +437 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +59 -0
  34. package/apps/studio/client/styles/4-components.css +150 -0
  35. package/apps/studio/commands/edit-source-command.ts +31 -4
  36. package/apps/studio/config.schema.json +2 -2
  37. package/apps/studio/context-menu.tsx +31 -6
  38. package/apps/studio/dist/client.bundle.js +5561 -19
  39. package/apps/studio/dist/comment-mount.js +2 -2
  40. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  41. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  42. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/dom-selection.ts +25 -0
  45. package/apps/studio/export-dialog.tsx +18 -25
  46. package/apps/studio/exporters/_runtime.ts +104 -0
  47. package/apps/studio/exporters/html.ts +12 -20
  48. package/apps/studio/exporters/index.ts +14 -2
  49. package/apps/studio/exporters/jobs.ts +376 -0
  50. package/apps/studio/exporters/pdf.ts +16 -20
  51. package/apps/studio/exporters/png.ts +12 -20
  52. package/apps/studio/exporters/pptx.ts +22 -23
  53. package/apps/studio/exporters/scope.ts +1 -0
  54. package/apps/studio/exporters/svg.ts +14 -22
  55. package/apps/studio/exporters/video.ts +37 -19
  56. package/apps/studio/footage/schema.test.ts +195 -0
  57. package/apps/studio/footage/schema.ts +484 -0
  58. package/apps/studio/footage-store.ts +235 -0
  59. package/apps/studio/fs-watch.ts +10 -0
  60. package/apps/studio/git/endpoints.ts +68 -7
  61. package/apps/studio/git/service.ts +198 -20
  62. package/apps/studio/github/service.ts +67 -0
  63. package/apps/studio/handoff.ts +3 -2
  64. package/apps/studio/http.ts +318 -64
  65. package/apps/studio/input-router.tsx +10 -0
  66. package/apps/studio/inspect.ts +53 -1
  67. package/apps/studio/paths.ts +10 -0
  68. package/apps/studio/photo/filters.ts +285 -0
  69. package/apps/studio/photo/pipeline.ts +607 -0
  70. package/apps/studio/photo/schema.ts +367 -0
  71. package/apps/studio/photo-store.ts +154 -0
  72. package/apps/studio/runtime-bundle.ts +29 -6
  73. package/apps/studio/server.ts +8 -2
  74. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  75. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  81. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  82. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  83. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  84. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  85. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  86. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  87. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  88. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  89. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  90. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  91. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  92. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  93. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  94. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  95. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  96. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  97. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  98. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  99. package/apps/studio/stickers/life-style/best.png +0 -0
  100. package/apps/studio/stickers/life-style/come-on.png +0 -0
  101. package/apps/studio/stickers/life-style/cut.png +0 -0
  102. package/apps/studio/stickers/life-style/detox.png +0 -0
  103. package/apps/studio/stickers/life-style/just.png +0 -0
  104. package/apps/studio/stickers/life-style/manifest.json +68 -0
  105. package/apps/studio/stickers/life-style/mirror.png +0 -0
  106. package/apps/studio/stickers/life-style/nice.png +0 -0
  107. package/apps/studio/stickers/life-style/objects.png +0 -0
  108. package/apps/studio/stickers/life-style/ok.png +0 -0
  109. package/apps/studio/stickers/life-style/queen.png +0 -0
  110. package/apps/studio/stickers/life-style/star.png +0 -0
  111. package/apps/studio/stickers/life-style/sun.png +0 -0
  112. package/apps/studio/stickers/life-style/water.png +0 -0
  113. package/apps/studio/stickers/life-style/yeah.png +0 -0
  114. package/apps/studio/stickers/life-style/you-can.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  118. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  119. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  120. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  121. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  122. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  123. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  124. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  125. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  126. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  127. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  128. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  129. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  130. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  131. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  132. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  133. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  134. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  135. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  136. package/apps/studio/stickers/project-status/group-100.png +0 -0
  137. package/apps/studio/stickers/project-status/group-101.png +0 -0
  138. package/apps/studio/stickers/project-status/group-102.png +0 -0
  139. package/apps/studio/stickers/project-status/group-103.png +0 -0
  140. package/apps/studio/stickers/project-status/group-104.png +0 -0
  141. package/apps/studio/stickers/project-status/group-105.png +0 -0
  142. package/apps/studio/stickers/project-status/group-106.png +0 -0
  143. package/apps/studio/stickers/project-status/group-107.png +0 -0
  144. package/apps/studio/stickers/project-status/group-108.png +0 -0
  145. package/apps/studio/stickers/project-status/group-109.png +0 -0
  146. package/apps/studio/stickers/project-status/group-110.png +0 -0
  147. package/apps/studio/stickers/project-status/group-111.png +0 -0
  148. package/apps/studio/stickers/project-status/group-112.png +0 -0
  149. package/apps/studio/stickers/project-status/group-113.png +0 -0
  150. package/apps/studio/stickers/project-status/group-114.png +0 -0
  151. package/apps/studio/stickers/project-status/group-115.png +0 -0
  152. package/apps/studio/stickers/project-status/group-117.png +0 -0
  153. package/apps/studio/stickers/project-status/group-118.png +0 -0
  154. package/apps/studio/stickers/project-status/group-119.png +0 -0
  155. package/apps/studio/stickers/project-status/group-120.png +0 -0
  156. package/apps/studio/stickers/project-status/group-121.png +0 -0
  157. package/apps/studio/stickers/project-status/group-122.png +0 -0
  158. package/apps/studio/stickers/project-status/group-41.png +0 -0
  159. package/apps/studio/stickers/project-status/group-42.png +0 -0
  160. package/apps/studio/stickers/project-status/group-43.png +0 -0
  161. package/apps/studio/stickers/project-status/group-44.png +0 -0
  162. package/apps/studio/stickers/project-status/group-45.png +0 -0
  163. package/apps/studio/stickers/project-status/group-46.png +0 -0
  164. package/apps/studio/stickers/project-status/group-47.png +0 -0
  165. package/apps/studio/stickers/project-status/group-48.png +0 -0
  166. package/apps/studio/stickers/project-status/group-49.png +0 -0
  167. package/apps/studio/stickers/project-status/group-50.png +0 -0
  168. package/apps/studio/stickers/project-status/group-51.png +0 -0
  169. package/apps/studio/stickers/project-status/group-52.png +0 -0
  170. package/apps/studio/stickers/project-status/group-53.png +0 -0
  171. package/apps/studio/stickers/project-status/group-54.png +0 -0
  172. package/apps/studio/stickers/project-status/group-55.png +0 -0
  173. package/apps/studio/stickers/project-status/group-56.png +0 -0
  174. package/apps/studio/stickers/project-status/group-57.png +0 -0
  175. package/apps/studio/stickers/project-status/group-58.png +0 -0
  176. package/apps/studio/stickers/project-status/group-59.png +0 -0
  177. package/apps/studio/stickers/project-status/group-60.png +0 -0
  178. package/apps/studio/stickers/project-status/group-61.png +0 -0
  179. package/apps/studio/stickers/project-status/group-62.png +0 -0
  180. package/apps/studio/stickers/project-status/group-63.png +0 -0
  181. package/apps/studio/stickers/project-status/group-64.png +0 -0
  182. package/apps/studio/stickers/project-status/group-65.png +0 -0
  183. package/apps/studio/stickers/project-status/group-66.png +0 -0
  184. package/apps/studio/stickers/project-status/group-67.png +0 -0
  185. package/apps/studio/stickers/project-status/group-68.png +0 -0
  186. package/apps/studio/stickers/project-status/group-69.png +0 -0
  187. package/apps/studio/stickers/project-status/group-70.png +0 -0
  188. package/apps/studio/stickers/project-status/group-71.png +0 -0
  189. package/apps/studio/stickers/project-status/group-72.png +0 -0
  190. package/apps/studio/stickers/project-status/group-73.png +0 -0
  191. package/apps/studio/stickers/project-status/group-74.png +0 -0
  192. package/apps/studio/stickers/project-status/group-75.png +0 -0
  193. package/apps/studio/stickers/project-status/group-76.png +0 -0
  194. package/apps/studio/stickers/project-status/group-77.png +0 -0
  195. package/apps/studio/stickers/project-status/group-78.png +0 -0
  196. package/apps/studio/stickers/project-status/group-79.png +0 -0
  197. package/apps/studio/stickers/project-status/group-80.png +0 -0
  198. package/apps/studio/stickers/project-status/group-81.png +0 -0
  199. package/apps/studio/stickers/project-status/group-82.png +0 -0
  200. package/apps/studio/stickers/project-status/group-83.png +0 -0
  201. package/apps/studio/stickers/project-status/group-84.png +0 -0
  202. package/apps/studio/stickers/project-status/group-85.png +0 -0
  203. package/apps/studio/stickers/project-status/group-86.png +0 -0
  204. package/apps/studio/stickers/project-status/group-87.png +0 -0
  205. package/apps/studio/stickers/project-status/group-88.png +0 -0
  206. package/apps/studio/stickers/project-status/group-89.png +0 -0
  207. package/apps/studio/stickers/project-status/group-90.png +0 -0
  208. package/apps/studio/stickers/project-status/group-91.png +0 -0
  209. package/apps/studio/stickers/project-status/group-92.png +0 -0
  210. package/apps/studio/stickers/project-status/group-93.png +0 -0
  211. package/apps/studio/stickers/project-status/group-94.png +0 -0
  212. package/apps/studio/stickers/project-status/group-96.png +0 -0
  213. package/apps/studio/stickers/project-status/group-97.png +0 -0
  214. package/apps/studio/stickers/project-status/group-98.png +0 -0
  215. package/apps/studio/stickers/project-status/group-99.png +0 -0
  216. package/apps/studio/stickers/project-status/manifest.json +328 -0
  217. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  218. package/apps/studio/test/acp-commands.test.ts +2 -1
  219. package/apps/studio/test/asset-api.test.ts +3 -3
  220. package/apps/studio/test/canvas-edit.test.ts +17 -6
  221. package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
  222. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  223. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  224. package/apps/studio/test/edit-source-command.test.ts +23 -0
  225. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  226. package/apps/studio/test/export-center.test.tsx +287 -0
  227. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  228. package/apps/studio/test/exporters/history.test.ts +32 -3
  229. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  230. package/apps/studio/test/footage-store.test.ts +100 -0
  231. package/apps/studio/test/git-branches.test.ts +97 -0
  232. package/apps/studio/test/github-api.test.ts +56 -1
  233. package/apps/studio/test/input-router.test.ts +33 -0
  234. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  235. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  236. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  237. package/apps/studio/test/photo-filters.test.ts +149 -0
  238. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  239. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  240. package/apps/studio/test/read-annotations.test.ts +164 -0
  241. package/apps/studio/test/shell-importmap.test.ts +49 -0
  242. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  243. package/apps/studio/test/timeline-parse.test.ts +57 -0
  244. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  245. package/apps/studio/test/undo-stack.test.ts +33 -0
  246. package/apps/studio/test/video-asset.test.ts +5 -5
  247. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  248. package/apps/studio/text-caret.ts +239 -0
  249. package/apps/studio/tool-palette.tsx +43 -20
  250. package/apps/studio/undo-stack.ts +94 -2
  251. package/apps/studio/use-annotation-resize.tsx +4 -4
  252. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  253. package/apps/studio/use-selection-set.tsx +21 -0
  254. package/apps/studio/video-comp.tsx +48 -7
  255. package/apps/studio/whats-new.json +70 -0
  256. package/apps/studio/ws.ts +6 -0
  257. package/cli/commands/design.mjs +34 -3
  258. package/cli/lib/gitignore-block.mjs +2 -0
  259. package/package.json +10 -10
  260. package/plugins/design/templates/_shell.html +1 -0
@@ -23,17 +23,19 @@ import { ReadinessDialog } from './panels/ReadinessList.jsx';
23
23
  import TimelinePanel from './panels/TimelinePanel.jsx';
24
24
  import { parseCompTimeline } from './panels/timeline-parse.js';
25
25
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
26
+ import StickerPicker from './panels/StickerPicker.jsx';
27
+ import { PhotoKnobs } from './photo-knobs.jsx';
26
28
  import {
27
29
  appIsFirstRun,
28
30
  isNativeApp,
29
31
  onUpdateReady,
30
32
  pickMediaFile,
31
33
  restartToUpdate,
32
- saveExport,
33
34
  } from './github.js';
34
35
  import { COLLAB_TOUR } from './tour/collab-tour.js';
35
36
  import { TourOverlay } from './tour/overlay.jsx';
36
37
  import { USAGE_TOUR } from './tour/usage-tour.js';
38
+ import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
37
39
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
38
40
 
39
41
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
@@ -45,6 +47,8 @@ const THEME_STORE = 'mdcc-theme';
45
47
  const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
46
48
  const SECTIONS_STORE = 'mdcc-sections-expanded';
47
49
  const SIDEBAR_STORE = 'mdcc-sidebar-open';
50
+ const MINIMAP_STORE = 'mdcc-minimap-visible';
51
+ const ZOOMCTL_STORE = 'mdcc-zoomctl-visible';
48
52
  const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
49
53
  // Bun's `define` substitutes this at build time (see build.ts); falls back when
50
54
  // the bundle is consumed in a context that hasn't run the build.
@@ -1079,7 +1083,10 @@ function ExportDialog({
1079
1083
  if (activeArtboardId) options.artboardId = activeArtboardId;
1080
1084
  if (selection?.selector) options.selection = selection;
1081
1085
  try {
1082
- const r = await fetch('/_api/export', {
1086
+ // feature-background-export-notification-center enqueue and close
1087
+ // immediately; the menubar notification center owns status, progress,
1088
+ // and completion (download / native Save…) from here on.
1089
+ const r = await fetch('/_api/export-jobs', {
1083
1090
  method: 'POST',
1084
1091
  headers: { 'content-type': 'application/json' },
1085
1092
  body: JSON.stringify({ format: card.format, scope, options }),
@@ -1089,38 +1096,11 @@ function ExportDialog({
1089
1096
  setBusy(false);
1090
1097
  return;
1091
1098
  }
1092
- const disp = r.headers.get('Content-Disposition') || '';
1093
- const fn = /filename="([^"]+)"/.exec(disp);
1094
- const filename = (fn && fn[1]) || `export.${card.format}`;
1095
- const blob = await r.blob();
1096
- if (isNativeApp()) {
1097
- // Native: WKWebView swallows the `<a download>` blob (the file lands in
1098
- // an unknown default with no prompt). Route through a real OS save dialog
1099
- // so the export OFFERS a location. Cancel → soft no-op.
1100
- const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
1101
- const savedPath = await saveExport(filename, bytes);
1102
- if (savedPath) {
1103
- setStatus({ ok: true, msg: `Saved to ${savedPath}` });
1104
- loadRecent();
1105
- } else {
1106
- setStatus({ ok: true, msg: 'Save cancelled' });
1107
- }
1108
- } else {
1109
- const url = URL.createObjectURL(blob);
1110
- const a = document.createElement('a');
1111
- a.href = url;
1112
- a.download = filename;
1113
- document.body.appendChild(a);
1114
- a.click();
1115
- a.remove();
1116
- URL.revokeObjectURL(url);
1117
- setStatus({ ok: true, msg: `Exported ${filename}` });
1118
- loadRecent();
1119
- }
1099
+ onClose();
1120
1100
  } catch (err) {
1121
1101
  setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
1102
+ setBusy(false);
1122
1103
  }
1123
- setBusy(false);
1124
1104
  }
1125
1105
 
1126
1106
  return (
@@ -1672,6 +1652,8 @@ function Sidebar({
1672
1652
  dirtyByPath,
1673
1653
  project,
1674
1654
  gitBranch,
1655
+ remoteSync,
1656
+ onGetLatest,
1675
1657
  }) {
1676
1658
  const filteredGroups = useMemo(() => {
1677
1659
  if (!search) return groups;
@@ -1913,7 +1895,7 @@ function Sidebar({
1913
1895
  {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
1914
1896
  opens UPWARD, sitting directly above the GitHub identity avatar so the two
1915
1897
  form one bottom dock. Renders nothing until the project is a git repo. */}
1916
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} />
1898
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
1917
1899
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
1918
1900
  sign in, connected account + New/Pull/Share, sign out. Self-contained
1919
1901
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -2451,12 +2433,9 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
2451
2433
  <span className="st-dd-lead">
2452
2434
  <span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
2453
2435
  <span>{p.label}</span>
2436
+ {p.phase ? <span className="st-dd-phase">{p.phase}</span> : null}
2454
2437
  </span>
2455
- {p.phase ? (
2456
- <span className="st-dd-phase">{p.phase}</span>
2457
- ) : p.shortcut ? (
2458
- <Kbd>{p.shortcut}</Kbd>
2459
- ) : null}
2438
+ {p.shortcut ? <Kbd>{p.shortcut}</Kbd> : null}
2460
2439
  </button>
2461
2440
  ))}
2462
2441
  <div className="st-dd-sep" />
@@ -2680,6 +2659,7 @@ function Menubar({
2680
2659
  onOpenWhatsNew,
2681
2660
  onOpenReadiness,
2682
2661
  whatsNewCount,
2662
+ exportCenter,
2683
2663
  artboardCount = 0,
2684
2664
  presence = null,
2685
2665
  inspectorOpen,
@@ -2763,6 +2743,7 @@ function Menubar({
2763
2743
  {
2764
2744
  id: 'timeline',
2765
2745
  label: 'Timeline',
2746
+ shortcut: '⌘ ⇧ T',
2766
2747
  phase: hasComps ? 'video' : undefined,
2767
2748
  checked: timelineOpen,
2768
2749
  disabled: false,
@@ -2993,6 +2974,7 @@ function Menubar({
2993
2974
  <StIcon name="sparkle" size={15} />
2994
2975
  </button>
2995
2976
  )}
2977
+ {exportCenter && <ExportBadge center={exportCenter} />}
2996
2978
  <button
2997
2979
  type="button"
2998
2980
  className="st-whatsnew"
@@ -3981,6 +3963,12 @@ function cssSplitUnit(v) {
3981
3963
  // is for the SAME element we already hold locally, preserve those fields instead
3982
3964
  // of clobbering them to empty (else the server round-trip wipes the custom-CSS /
3983
3965
  // custom-attr rows + computed readout right after selection).
3966
+ // feature-photo-editor — `photoKind`/`photoAsset` (dom-selection.ts) are the SAME
3967
+ // class of client-only DOM-derived field and belong in this list for the same
3968
+ // reason: without it, every server-pushed `selected`/`snapshot` restore (a canvas
3969
+ // switch, a reconnect, the persisted `_active.json` on boot — none of which carry
3970
+ // these fields) silently drops the Inspector's Photo tab until the next fresh
3971
+ // click re-derives it live — the "tab is there, then it's gone" flicker.
3984
3972
  function mergeSelClientFields(incoming, prev) {
3985
3973
  if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
3986
3974
  if (!incoming.id || incoming.id !== prev.id) return incoming;
@@ -3990,6 +3978,8 @@ function mergeSelClientFields(incoming, prev) {
3990
3978
  computed: incoming.computed ?? prev.computed,
3991
3979
  customStyles: incoming.customStyles ?? prev.customStyles,
3992
3980
  attrs: incoming.attrs ?? prev.attrs,
3981
+ photoKind: incoming.photoKind ?? prev.photoKind,
3982
+ photoAsset: incoming.photoAsset ?? prev.photoAsset,
3993
3983
  };
3994
3984
  }
3995
3985
 
@@ -6094,6 +6084,31 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
6094
6084
  );
6095
6085
  }
6096
6086
 
6087
+ // feature-photo-editor (Task 13/14) — derive the content-addressed photo asset a
6088
+ // DOM selection points at (an artboard `<img src="assets/<sha8>.<ext>">`). Prefers
6089
+ // the `photoAsset` the resolver stamped (dom-selection.ts), falling back to a scan
6090
+ // of the selection's src/html for a selection that reached the panel via a code
6091
+ // path predating that field. Returns null for a non-photo element.
6092
+ const PHOTO_ASSET_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
6093
+ function photoAssetOfSelection(el) {
6094
+ if (!el || Array.isArray(el)) return null;
6095
+ if (el.photoAsset) return el.photoAsset;
6096
+ if ((el.tag || '').toLowerCase() !== 'img') return null;
6097
+ const html = el.html || '';
6098
+ // Once an edit is baked, the element's LIVE src is a `data:` URL (canvas-lib's
6099
+ // PhotoPreviewBridge swaps it in directly) — it never matches PHOTO_ASSET_RE,
6100
+ // so a REPLAYED/serialized selection (a WS resync, or the persisted
6101
+ // `_active.json` restored on boot) that arrives without the dedicated
6102
+ // `photoAsset` field would otherwise permanently lose the Photo tab for any
6103
+ // already-edited photo. The element still carries the `data-photo-asset` tag
6104
+ // the bridge stamped, and that DOES survive into an outerHTML snapshot — check
6105
+ // it before falling back to the plain assets/<sha8> src scan.
6106
+ const tagged = /data-photo-asset="([^"]+)"/.exec(html);
6107
+ if (tagged) return tagged[1];
6108
+ const m = PHOTO_ASSET_RE.exec(`${el.attrs?.src || ''} ${html}`);
6109
+ return m ? m[0] : null;
6110
+ }
6111
+
6097
6112
  function InspectorPanel({
6098
6113
  selected,
6099
6114
  onClose,
@@ -6114,6 +6129,16 @@ function InspectorPanel({
6114
6129
  onTabChange,
6115
6130
  width,
6116
6131
  resizing,
6132
+ // feature-photo-editor (Task 13/15) — the Photo tab + its live-preview /
6133
+ // bg-removal / undo channels. `photoSel` is the annotation-image target
6134
+ // (threaded up separately since the annotation model has no DOM selection);
6135
+ // an artboard `<img>` target is derived from `selected` instead.
6136
+ photoSel,
6137
+ photoRev,
6138
+ onPhotoEdit,
6139
+ onPhotoRemoveBackground,
6140
+ onPhotoRecordEdit,
6141
+ onPhotoUndoRedo,
6117
6142
  }) {
6118
6143
  // Tab is controllable from the parent (the guided tour drives it to 'css' /
6119
6144
  // 'layers' so a spotlight step lands on a real row) but falls back to local
@@ -6412,10 +6437,41 @@ function InspectorPanel({
6412
6437
  };
6413
6438
  // `selected` may be a single element, an array (multi-select), or null.
6414
6439
  const el = Array.isArray(selected) ? selected[0] : selected;
6440
+ // feature-photo-editor (Task 13) — resolve the Photo-tab target. Priority: an
6441
+ // annotation-image threaded up (`photoSel`, no DOM selection) → a Photo-ONLY
6442
+ // panel; else a content-addressed artboard `<img>` selection → Photo alongside
6443
+ // the normal tabs.
6444
+ const photoTarget = photoSel
6445
+ ? { asset: photoSel.asset, kind: 'annotation-image' }
6446
+ : (() => {
6447
+ const a = photoAssetOfSelection(el);
6448
+ return a ? { asset: a, kind: 'artboard-img' } : null;
6449
+ })();
6450
+ const photoOnly = photoTarget?.kind === 'annotation-image';
6451
+ // Cmd+Z / Cmd+Shift+Z (or Cmd+Y) while focus is still inside a Photo-tab
6452
+ // slider — mirrors `onKnobKeyDown` above for the same reason: the window-
6453
+ // level shortcut listener bails out whenever `document.activeElement` is an
6454
+ // `<input>` (native text-undo would otherwise win), so a commit-then-Cmd+Z
6455
+ // with the slider still focused needs its own forwarder here.
6456
+ const onPhotoKnobKeyDown = (e) => {
6457
+ if (!(e.metaKey || e.ctrlKey) || e.altKey) return;
6458
+ const k = e.key.toLowerCase();
6459
+ if (k === 'z') {
6460
+ e.preventDefault();
6461
+ onPhotoUndoRedo?.(e.shiftKey ? 'redo' : 'undo');
6462
+ } else if (k === 'y') {
6463
+ e.preventDefault();
6464
+ onPhotoUndoRedo?.('redo');
6465
+ }
6466
+ };
6467
+ // An annotation-image has no element tabs; force Photo. Otherwise honor the
6468
+ // requested tab, but drop off a stale 'photo' tab when the new selection isn't
6469
+ // photo-eligible (so switching from a photo to a normal element lands sanely).
6470
+ const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
6415
6471
  const tabBtn = (id, label, icon) => (
6416
6472
  <button
6417
6473
  type="button"
6418
- className={'st-rp-tab' + (tab === id ? ' is-active' : '')}
6474
+ className={'st-rp-tab' + (effTab === id ? ' is-active' : '')}
6419
6475
  onClick={() => setTab(id)}
6420
6476
  >
6421
6477
  <StIcon name={icon} size={14} />
@@ -6431,9 +6487,16 @@ function InspectorPanel({
6431
6487
  data-tour="inspector"
6432
6488
  >
6433
6489
  <div className="st-rp-tabs" data-tour="inspector-tabs">
6434
- {tabBtn('inspect', 'Inspect', 'sliders')}
6435
- {tabBtn('layers', 'Layers', 'layers')}
6436
- {tabBtn('css', 'CSS', 'code')}
6490
+ {photoOnly ? (
6491
+ tabBtn('photo', 'Photo', 'image')
6492
+ ) : (
6493
+ <>
6494
+ {tabBtn('inspect', 'Inspect', 'sliders')}
6495
+ {tabBtn('layers', 'Layers', 'layers')}
6496
+ {tabBtn('css', 'CSS', 'code')}
6497
+ {photoTarget ? tabBtn('photo', 'Photo', 'image') : null}
6498
+ </>
6499
+ )}
6437
6500
  <button
6438
6501
  type="button"
6439
6502
  className="st-iconbtn"
@@ -6469,7 +6532,18 @@ function InspectorPanel({
6469
6532
  </div>
6470
6533
  ) : null}
6471
6534
  <div className="st-rp-body">
6472
- {!el ? (
6535
+ {effTab === 'photo' && photoTarget ? (
6536
+ <div onKeyDown={onPhotoKnobKeyDown}>
6537
+ <PhotoKnobs
6538
+ key={`${photoTarget.asset}:${photoRev ?? 0}`}
6539
+ asset={photoTarget.asset}
6540
+ ColorPicker={ColorPicker}
6541
+ onEdit={(edit) => onPhotoEdit?.(photoTarget.asset, edit)}
6542
+ onRemoveBackground={onPhotoRemoveBackground}
6543
+ onRecordEdit={(before, after) => onPhotoRecordEdit?.(photoTarget.asset, before, after)}
6544
+ />
6545
+ </div>
6546
+ ) : !el ? (
6473
6547
  <div className="st-rp-empty">
6474
6548
  {/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
6475
6549
  kbd would stack as stretched flex items. */}
@@ -6477,7 +6551,7 @@ function InspectorPanel({
6477
6551
  Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
6478
6552
  </p>
6479
6553
  </div>
6480
- ) : tab === 'inspect' ? (
6554
+ ) : effTab === 'inspect' ? (
6481
6555
  <>
6482
6556
  <div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
6483
6557
  <div className="st-insp-row">
@@ -6526,7 +6600,7 @@ function InspectorPanel({
6526
6600
  ) : null}
6527
6601
  <InspectComputed el={el} />
6528
6602
  </>
6529
- ) : tab === 'layers' ? (
6603
+ ) : effTab === 'layers' ? (
6530
6604
  <>
6531
6605
  <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
6532
6606
  {layersTree?.nodes?.length ? (
@@ -6877,7 +6951,9 @@ function App() {
6877
6951
  const [activeComps, setActiveComps] = useState([]);
6878
6952
  const [timelineFrame, setTimelineFrame] = useState(0);
6879
6953
  const [timelinePlaying, setTimelinePlaying] = useState(false);
6880
- const [timelineLoop, setTimelineLoop] = useState(true);
6954
+ // Default off — looping is an explicit opt-in per session, not a surprise
6955
+ // once the user presses Play (rca/issue-video-artboard-loop-defaults-on).
6956
+ const [timelineLoop, setTimelineLoop] = useState(false);
6881
6957
  const [timelineMuted, setTimelineMuted] = useState(false);
6882
6958
  const [timelineVolume, setTimelineVolume] = useState(1);
6883
6959
  const [timelineHeight, setTimelineHeight] = useState(216);
@@ -6900,6 +6976,15 @@ function App() {
6900
6976
  useEffect(() => {
6901
6977
  timelineCompIdRef.current = timelineCompId;
6902
6978
  }, [timelineCompId]);
6979
+ // rca/issue-video-artboard-frame-reset-on-edit — last known playhead, read
6980
+ // from the `timeline-comps` handler below (which fires on every comp
6981
+ // (re)mount, incl. a ⌘R hard iframe reload) to re-seed the fresh Player
6982
+ // instance. A ref, not state, so the handler always sees the latest value
6983
+ // without pulling `timelineFrame` into that big message-listener's deps.
6984
+ const timelineFrameRef = useRef(0);
6985
+ useEffect(() => {
6986
+ timelineFrameRef.current = timelineFrame;
6987
+ }, [timelineFrame]);
6903
6988
  // The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
6904
6989
  // /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
6905
6990
  // rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
@@ -6947,6 +7032,17 @@ function App() {
6947
7032
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
6948
7033
  // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
6949
7034
  const [inspectorTab, setInspectorTab] = useState('inspect');
7035
+ // feature-photo-editor (Task 13) — the annotation-image Photo-tab target,
7036
+ // threaded up from the annotation layer's "Edit Photo…" (the annotation model
7037
+ // has no DOM selection to ride, unlike an artboard `<img>`). `{ asset, strokeId }`
7038
+ // or null. Cleared whenever a normal DOM selection arrives so the two never
7039
+ // fight over the panel. The shell-side photo undo stack lives alongside it.
7040
+ const [photoSel, setPhotoSel] = useState(null);
7041
+ const photoUndoRef = useRef({ undo: [], redo: [] });
7042
+ // Bumped by the Cmd+Z/Cmd+Shift+Z photo-undo handler below so the mounted
7043
+ // PhotoKnobs (keyed on `asset:photoRev`) remounts and re-fetches the sidecar
7044
+ // it just PUT, instead of drifting from its own stale local `edit` state.
7045
+ const [photoRev, setPhotoRev] = useState(0);
6950
7046
  // feature-element-editing-robustness Stage C — auto-open the Inspector on the
6951
7047
  // CSS tab when a fresh single selection arrives AND no right panel is already
6952
7048
  // open. Preference-backed (default ON); disable it in the View menu. Refs keep
@@ -7049,6 +7145,7 @@ function App() {
7049
7145
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
7050
7146
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
7051
7147
  const whatsNew = useWhatsNew(MDCC_VERSION);
7148
+ const exportCenter = useExportCenter();
7052
7149
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
7053
7150
  // "welcome" project on first launch; we ask it whether this is a first run and, if
7054
7151
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -7134,8 +7231,8 @@ function App() {
7134
7231
  // persistent prefs broadcast to every open canvas iframe; presentMode is a
7135
7232
  // non-destructive "hide ALL chrome + shell, artboards only" overlay with an
7136
7233
  // Esc / floating-pill escape hatch back to the chrome.
7137
- const [minimapVisible, setMinimapVisible] = useState(true);
7138
- const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
7234
+ const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
7235
+ const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
7139
7236
  const [presentMode, setPresentMode] = useState(false);
7140
7237
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
7141
7238
  // so the top bar no longer mirrors it.)
@@ -7166,6 +7263,96 @@ function App() {
7166
7263
  [activePath]
7167
7264
  );
7168
7265
 
7266
+ // ── feature-photo-editor — the Photo tab's three channels ─────────────────
7267
+ // (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
7268
+ // canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
7269
+ // into the matching `<img>`/`<image>` element's src/href (iteration 2 — see
7270
+ // the bridge's own header comment for why it's a direct swap, not an
7271
+ // overlay). (2) undo: a shell-side stack (photo edits are sidecar writes,
7272
+ // not the canvas-side source-edit stack). (3) background removal: the
7273
+ // client-side @imgly ML flow (Task 12).
7274
+ const onPhotoEdit = useCallback(
7275
+ (asset, edit) => postToActiveCanvas({ dgn: 'photo-preview', asset, edit }),
7276
+ [postToActiveCanvas]
7277
+ );
7278
+ const onPhotoRecordEdit = useCallback((asset, before, after) => {
7279
+ photoUndoRef.current.undo.push({ asset, before, after });
7280
+ photoUndoRef.current.redo.length = 0;
7281
+ }, []);
7282
+ // Pops the shell-side photo-undo stack, re-persists the reverted edit through
7283
+ // the same PUT route PhotoKnobs itself uses, re-broadcasts it to the live
7284
+ // preview, and bumps `photoRev` so the mounted PhotoKnobs (keyed on
7285
+ // `asset:photoRev`) remounts and re-fetches instead of drifting from its own
7286
+ // stale local state. Returns false (does nothing) when the requested stack is
7287
+ // empty, so callers can fall through to the canvas's own undo stack.
7288
+ const performPhotoUndo = useCallback(
7289
+ (redo) => {
7290
+ const stack = redo ? photoUndoRef.current.redo : photoUndoRef.current.undo;
7291
+ if (!stack.length) return false;
7292
+ const entry = stack.pop();
7293
+ (redo ? photoUndoRef.current.undo : photoUndoRef.current.redo).push(entry);
7294
+ const edit = (redo ? entry.after : entry.before) || {};
7295
+ onPhotoEdit(entry.asset, edit);
7296
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(entry.asset)}`, {
7297
+ method: 'PUT',
7298
+ headers: { 'Content-Type': 'application/json' },
7299
+ body: JSON.stringify(edit),
7300
+ }).catch(() => {});
7301
+ setPhotoRev((v) => v + 1);
7302
+ return true;
7303
+ },
7304
+ [onPhotoEdit]
7305
+ );
7306
+ // Task 12 — magic background removal, entirely client-side (WASM/WebGPU via
7307
+ // @imgly/background-removal — NEVER the -node native-addon variant, DDR-070).
7308
+ // The lib is DYNAMICALLY imported so a session that never removes a background
7309
+ // pays zero bundle cost (the lazy-bundle guarantee). Fetches the source bytes,
7310
+ // runs the matte, uploads it content-addressed through the SAME /_api/asset lane
7311
+ // drag-drop uses, and returns the new `assets/<sha8>.png` for PhotoEdit.
7312
+ const onPhotoRemoveBackground = useCallback(
7313
+ async (asset) => {
7314
+ // Scan/shimmer reveal on the live photo while the ML pass runs, the same
7315
+ // "something is actively happening here" language as the agent-edit
7316
+ // artboard rim (artboard-activity-overlay.tsx) — but applied directly to
7317
+ // the photo element itself (a `data-photo-busy` attribute + injected CSS
7318
+ // mask sweep, inspect.ts) rather than a floating tracked overlay. A
7319
+ // floating decoy is exactly the architecture DDR-161's addendum tore out
7320
+ // for the live-edit preview (z-index/resize/hit-testing bugs); an
7321
+ // attribute toggle on the real element sidesteps that whole class.
7322
+ postToActiveCanvas({ dgn: 'photo-busy', asset, busy: true });
7323
+ try {
7324
+ const srcRes = await fetch(`/${asset.replace(/^\/+/, '')}`);
7325
+ if (!srcRes.ok) return null;
7326
+ const srcBlob = await srcRes.blob();
7327
+ const { removeBackground } = await import('@imgly/background-removal');
7328
+ // Inference is 100% CLIENT-SIDE (WASM/WebGPU) — the user's pixels NEVER
7329
+ // leave the browser. Only the public model weights (~40 MB, identical for
7330
+ // everyone) are fetched from IMG.LY's default host on first use; they're
7331
+ // too large to bundle in the npm tarball (`resources.json` ships empty).
7332
+ // Self-hosting those weights off the dev server for offline / air-gapped
7333
+ // parity is the flagged Task-11 follow-up (a one-time download step) — until
7334
+ // then the default host provides them, with no pixel-privacy exposure.
7335
+ const matte = await removeBackground(srcBlob);
7336
+ const up = await fetch('/_api/asset', {
7337
+ method: 'POST',
7338
+ headers: { 'content-type': matte.type || 'image/png' },
7339
+ body: matte,
7340
+ });
7341
+ const j = await up.json().catch(() => ({}));
7342
+ if (up.ok && j.path) return { maskAsset: j.path };
7343
+ return null;
7344
+ } catch (err) {
7345
+ console.error('[photo] background removal failed', err);
7346
+ return null;
7347
+ } finally {
7348
+ // Always clears, success or failure — an errored pass must not leave
7349
+ // the shimmer stuck on the photo forever.
7350
+ postToActiveCanvas({ dgn: 'photo-busy', asset, busy: false });
7351
+ }
7352
+ },
7353
+ [postToActiveCanvas]
7354
+ );
7355
+
7169
7356
  // DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
7170
7357
  // with no drop target used to make the browser NAVIGATE AWAY to the file
7171
7358
  // (file:// replaces the app). Document-level guard: always cancel the default
@@ -7554,6 +7741,16 @@ function App() {
7554
7741
  localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
7555
7742
  } catch {}
7556
7743
  }, [sectionsExpanded]);
7744
+ useEffect(() => {
7745
+ try {
7746
+ localStorage.setItem(MINIMAP_STORE, minimapVisible ? '1' : '0');
7747
+ } catch {}
7748
+ }, [minimapVisible]);
7749
+ useEffect(() => {
7750
+ try {
7751
+ localStorage.setItem(ZOOMCTL_STORE, zoomCtlVisible ? '1' : '0');
7752
+ } catch {}
7753
+ }, [zoomCtlVisible]);
7557
7754
 
7558
7755
  const toggleSection = useCallback((label, defaultOpen) => {
7559
7756
  setSectionsExpanded((prev) => {
@@ -7696,6 +7893,10 @@ function App() {
7696
7893
  );
7697
7894
  } catch {}
7698
7895
  }
7896
+ } else if (m.type === 'export:job' && m.payload) {
7897
+ // feature-background-export-notification-center — full-snapshot
7898
+ // job state on every queued/running/progress/done/failed change.
7899
+ exportCenter.upsert(m.payload);
7699
7900
  } else if (m.type === 'sync:status' && m.payload) {
7700
7901
  // Phase 9 Task 8 — hub connection state for the offline banner.
7701
7902
  setSyncStatus(m.payload);
@@ -7880,8 +8081,11 @@ function App() {
7880
8081
  useEffect(() => {
7881
8082
  if (gitStatus?.repo === false) return; // solo / non-git project — no remote
7882
8083
  refreshRemoteSync();
7883
- if (!changesOpen) return; // only keep polling while the panel is visible
7884
- const id = setInterval(refreshRemoteSync, 60000);
8084
+ // Poll in the background too not only while Changes is open — so the dock's
8085
+ // "Get latest" nudge surfaces a teammate's publish proactively. Slower cadence
8086
+ // when the panel is closed to stay network-polite (the ahead/behind probe is
8087
+ // TTL-cached server-side, so a missed tick is cheap to re-issue).
8088
+ const id = setInterval(refreshRemoteSync, changesOpen ? 60000 : 120000);
7885
8089
  return () => clearInterval(id);
7886
8090
  }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
7887
8091
 
@@ -8262,7 +8466,22 @@ function App() {
8262
8466
  // canvas must not be able to plant a selection with no user gesture. The
8263
8467
  // user can only click the visible active canvas, so a non-active source is
8264
8468
  // never a legitimate selection.
8265
- if (m.dgn === 'select' || m.dgn === 'select-set' || m.dgn === 'clear-select') {
8469
+ //
8470
+ // The SAME gate now covers the privileged SOURCE-WRITE relays — `edit-text`
8471
+ // (inline text commit) and `apply-edit` (undo/redo re-application). Both
8472
+ // ride into a main-origin `.tsx` rewrite; a write path is strictly more
8473
+ // dangerous than a selection, yet was previously ungated (ethical-hacker
8474
+ // F-B, DDR-160 follow-up). A background/synced untrusted canvas (DDR-054)
8475
+ // must not drive a gestureless write into another canvas — and inline edits
8476
+ // + Cmd+Z always originate in the ACTIVE canvas the user is looking at, so
8477
+ // a non-active source is never legitimate here either.
8478
+ if (
8479
+ m.dgn === 'select' ||
8480
+ m.dgn === 'select-set' ||
8481
+ m.dgn === 'clear-select' ||
8482
+ m.dgn === 'edit-text' ||
8483
+ m.dgn === 'apply-edit'
8484
+ ) {
8266
8485
  const activeWin =
8267
8486
  activePath && activePath !== SYSTEM_TAB
8268
8487
  ? iframesRef.current.get(activePath)?.contentWindow
@@ -8270,10 +8489,12 @@ function App() {
8270
8489
  if (e.source !== activeWin) return;
8271
8490
  }
8272
8491
  if (m.dgn === 'select' && m.selection) {
8492
+ setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
8273
8493
  wsSend({ type: 'select', selection: m.selection });
8274
8494
  setSelected(m.selection);
8275
8495
  maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
8276
8496
  } else if (m.dgn === 'select-set') {
8497
+ setPhotoSel(null);
8277
8498
  // Canvas multi-select. Payload shape:
8278
8499
  // null → empty selection
8279
8500
  // Selection → length-1 (back-compat with legacy single-element shape)
@@ -8297,6 +8518,7 @@ function App() {
8297
8518
  maybeAutoOpenInspectorOnSelect(payload); // Stage C
8298
8519
  }
8299
8520
  } else if (m.dgn === 'clear-select') {
8521
+ setPhotoSel(null);
8300
8522
  wsSend({ type: 'clear-select' });
8301
8523
  setSelected(null);
8302
8524
  } else if (m.dgn === 'edit-text' && m.id) {
@@ -8318,7 +8540,15 @@ function App() {
8318
8540
  fetch('/_api/edit-text', {
8319
8541
  method: 'POST',
8320
8542
  headers: { 'content-type': 'application/json' },
8321
- body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
8543
+ body: JSON.stringify({
8544
+ canvas: m.file,
8545
+ id: m.id,
8546
+ text: m.text ?? '',
8547
+ // Context for editing `{variable}` text — which rendered instance +
8548
+ // its pre-edit text, so the engine targets the right source string.
8549
+ ...(typeof m.occurrence === 'number' ? { occurrence: m.occurrence } : {}),
8550
+ ...(typeof m.before === 'string' ? { before: m.before } : {}),
8551
+ }),
8322
8552
  })
8323
8553
  .then((r) => r.json().catch(() => ({})))
8324
8554
  .then((j) => {
@@ -8351,6 +8581,11 @@ function App() {
8351
8581
  } else {
8352
8582
  url = '/_api/edit-text';
8353
8583
  body = { canvas: m.canvas, id: m.id, text: value ?? '' };
8584
+ // Undo/redo of a `{variable}` text edit needs to re-target the source
8585
+ // string: the occurrence + the value currently on disk (`from` — the
8586
+ // side we're replacing FROM). Harmless for literal text.
8587
+ if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
8588
+ if (typeof m.from === 'string') body.before = m.from;
8354
8589
  }
8355
8590
  editApplyChainRef.current = editApplyChainRef.current
8356
8591
  .catch(() => {})
@@ -8527,38 +8762,54 @@ function App() {
8527
8762
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8528
8763
  if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
8529
8764
  } else if (m.dgn === 'insert-request') {
8530
- // Stage I3 — insert a synthesized div/text/image relative to `refId`.
8765
+ // Stage I3 — insert a synthesized div/text/image relative to `refId`,
8766
+ // OR — empty-artboard fallback (tool-palette "+ Element" on a fresh
8767
+ // artboard with no elements yet) — as a child of `artboardId`. Exactly
8768
+ // one of the two must be present; the artboardId variant only makes
8769
+ // sense inside-start/inside-end (no sibling to be before/after).
8531
8770
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8771
+ const hasRefId = typeof m.refId === 'string';
8772
+ const hasArtboardId = typeof m.artboardId === 'string';
8773
+ const okPosition =
8774
+ m.position === 'before' ||
8775
+ m.position === 'after' ||
8776
+ m.position === 'inside-start' ||
8777
+ m.position === 'inside-end';
8532
8778
  const okShape =
8533
- typeof m.refId === 'string' &&
8779
+ hasRefId !== hasArtboardId &&
8534
8780
  (m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
8535
- (m.position === 'before' ||
8536
- m.position === 'after' ||
8537
- m.position === 'inside-start' ||
8538
- m.position === 'inside-end');
8781
+ okPosition &&
8782
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8539
8783
  if (e.source === activeWin && okShape) {
8540
8784
  insertElementShellRef.current?.(m.refId, m.position, m.kind, {
8785
+ artboardId: hasArtboardId ? m.artboardId : undefined,
8541
8786
  src: typeof m.src === 'string' ? m.src : undefined,
8542
8787
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8543
8788
  });
8544
8789
  }
8545
8790
  } else if (m.dgn === 'insert-image-request') {
8546
- // Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
8547
- // needs a contained asset src, so the shell opens the AssetPicker; the
8548
- // pick then drives insertElementShell(kind:'image'). Confused-deputy
8549
- // gated + pinned to the active canvas like the other request verbs.
8791
+ // Stage F/I3 — "Insert ▸ Image" from the canvas context menu, or the
8792
+ // tool-palette's empty-artboard fallback (`artboardId` in place of
8793
+ // `refId`). An image needs a contained asset src, so the shell opens
8794
+ // the AssetPicker; the pick then drives insertElementShell(kind:'image').
8795
+ // Confused-deputy gated + pinned to the active canvas like the other
8796
+ // request verbs.
8550
8797
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8798
+ const hasRefId = typeof m.refId === 'string';
8799
+ const hasArtboardId = typeof m.artboardId === 'string';
8551
8800
  const okShape =
8552
- typeof m.refId === 'string' &&
8801
+ hasRefId !== hasArtboardId &&
8553
8802
  (m.position === 'before' ||
8554
8803
  m.position === 'after' ||
8555
8804
  m.position === 'inside-start' ||
8556
- m.position === 'inside-end');
8805
+ m.position === 'inside-end') &&
8806
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8557
8807
  if (e.source === activeWin && okShape) {
8558
8808
  openAssetPickerRef.current?.({
8559
8809
  purpose: 'insert-image',
8560
8810
  canvas: activePath,
8561
8811
  refId: m.refId,
8812
+ artboardId: hasArtboardId ? m.artboardId : undefined,
8562
8813
  position: m.position,
8563
8814
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8564
8815
  });
@@ -8590,6 +8841,27 @@ function App() {
8590
8841
  before: typeof m.before === 'string' ? m.before : null,
8591
8842
  });
8592
8843
  }
8844
+ } else if (m.dgn === 'edit-annotation-photo-request') {
8845
+ // feature-photo-editor (Task 17) — "Edit Photo…" on an annotation
8846
+ // ImageStroke. The annotation model has no data-cd-id / DOM selection, so
8847
+ // it can't ride the normal select path; instead the canvas posts its
8848
+ // `assets/<sha8>.<ext>` href up and the shell opens the Photo-only tab on
8849
+ // it. Same confused-deputy gate (DDR-054) + active-canvas pin as F3.
8850
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8851
+ const asset = PHOTO_ASSET_RE.exec(typeof m.asset === 'string' ? m.asset : '')?.[0] || null;
8852
+ if (e.source === activeWin && typeof m.id === 'string' && asset) {
8853
+ setPhotoSel({ asset, strokeId: m.id });
8854
+ openRightPanel('inspector');
8855
+ setInspectorTab('photo');
8856
+ }
8857
+ } else if (m.dgn === 'open-sticker-picker') {
8858
+ // Phase 4 (whiteboard-improvements) — the toolbar's Stickers button.
8859
+ // Confused-deputy gated + pinned to the active canvas like the other
8860
+ // request verbs above.
8861
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8862
+ if (e.source === activeWin) {
8863
+ openStickerPickerRef.current?.({ canvas: activePath });
8864
+ }
8593
8865
  } else if (m.dgn === 'insert-artboard-request') {
8594
8866
  // Stage I4 — insert a new empty artboard from a screen-size preset.
8595
8867
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
@@ -8626,7 +8898,12 @@ function App() {
8626
8898
  }
8627
8899
  } else if (m.dgn === 'open-inspector') {
8628
8900
  // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
8901
+ // feature-photo-editor (Task 16) — an optional `tab` lands the panel on a
8902
+ // specific tab (the element "Edit Photo…" entry passes `tab: 'photo'`; the
8903
+ // img is already selected via select-set, so the Photo tab derives its
8904
+ // target from that selection's `photoAsset`).
8629
8905
  openRightPanel('inspector');
8906
+ if (typeof m.tab === 'string') setInspectorTab(m.tab);
8630
8907
  } else if (m.dgn === 'present-enter') {
8631
8908
  // Canvas tool-palette "Presentation mode" button — Present Mode is a
8632
8909
  // shell-level state (hides the menubar / sidebar / panels), so the
@@ -8720,6 +8997,23 @@ function App() {
8720
8997
  height: Math.max(1, Math.round(Number(c.height) || 0)),
8721
8998
  }));
8722
8999
  setActiveComps(safe);
9000
+ // rca/issue-video-artboard-frame-reset-on-edit — the SAME comp
9001
+ // re-announcing means its Player just remounted (an edit-triggered
9002
+ // HMR remount, or a ⌘R hard iframe reload that wiped video-comp.tsx's
9003
+ // own module-scope frame mirror too). The shell's `timelineFrame`
9004
+ // survives either way (this component doesn't remount) — push it back
9005
+ // down so the fresh Player opens where the Timeline left off, instead
9006
+ // of the poster-frame default. Skip on a genuinely new comp/canvas
9007
+ // (no prior id, or frame still at its post-switch 0).
9008
+ const prevCompId = timelineCompIdRef.current;
9009
+ const stillPresent = prevCompId && safe.some((c) => c.id === prevCompId);
9010
+ if (stillPresent && timelineFrameRef.current > 0) {
9011
+ postToActiveCanvas({
9012
+ dgn: 'timeline-seek',
9013
+ frame: timelineFrameRef.current,
9014
+ id: prevCompId,
9015
+ });
9016
+ }
8723
9017
  } else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
8724
9018
  // Live playhead mirror from the Player (preview scrub/playback).
8725
9019
  const activeWin =
@@ -8731,6 +9025,17 @@ function App() {
8731
9025
  // artboard's Player must not fight it for the readout (multi-comp canvas).
8732
9026
  if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
8733
9027
  if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
9028
+ } else if (m.dgn === 'timeline-ended') {
9029
+ // The Player stopped itself at the last frame (loop off) — resync the
9030
+ // shell's Play/Pause button, which otherwise has no way to learn
9031
+ // playback ended on its own (rca/issue-video-artboard-loop-defaults-on).
9032
+ const activeWin =
9033
+ activePath && activePath !== SYSTEM_TAB
9034
+ ? iframesRef.current.get(activePath)?.contentWindow
9035
+ : null;
9036
+ if (e.source !== activeWin) return;
9037
+ if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
9038
+ setTimelinePlaying(false);
8734
9039
  } else if (m.dgn === 'toggle-palette') {
8735
9040
  // ⌘K pressed while focus was inside the canvas iframe — the injected
8736
9041
  // inspector forwards the chord here since the iframe's keydown never
@@ -8743,6 +9048,8 @@ function App() {
8743
9048
  else if (m.id === 'inspector') toggleRightPanel('inspector');
8744
9049
  else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
8745
9050
  else if (m.id === 'comments') toggleRightPanel('comments');
9051
+ else if (m.id === 'changes') toggleRightPanel('changes');
9052
+ else if (m.id === 'timeline') toggleTimeline();
8746
9053
  else if (m.id === 'export') setExportDialog({ mode: 'export' });
8747
9054
  else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
8748
9055
  } else if (m.dgn === 'open-export') {
@@ -8753,6 +9060,11 @@ function App() {
8753
9060
  mode: 'export',
8754
9061
  scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
8755
9062
  });
9063
+ } else if (m.dgn === 'open-timeline-request') {
9064
+ // Artboard-chrome context menu's "Open Timeline" (video-comp artboards
9065
+ // only). Scope the Timeline to the right-clicked artboard, then open it.
9066
+ if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
9067
+ setTimelineOpen(true);
8756
9068
  } else if (m.dgn === 'loaded' && m.file) {
8757
9069
  // iframe finished loading — drop the compile skeleton, push current
8758
9070
  // comments + carry over focused pin if any
@@ -8854,7 +9166,12 @@ function App() {
8854
9166
  } catch {}
8855
9167
  };
8856
9168
  try {
8857
- const r = await fetch('/_api/export', {
9169
+ // feature-background-export-notification-center enqueue and reply
9170
+ // with the job id immediately; the notification center (which
9171
+ // already owns the WS connection) is the single place status/
9172
+ // progress/completion live from here, regardless of which dialog
9173
+ // created the job.
9174
+ const r = await fetch('/_api/export-jobs', {
8858
9175
  method: 'POST',
8859
9176
  headers: { 'content-type': 'application/json' },
8860
9177
  body: JSON.stringify(payload),
@@ -8863,28 +9180,8 @@ function App() {
8863
9180
  reply({ ok: false, error: (await r.text()) || String(r.status) });
8864
9181
  return;
8865
9182
  }
8866
- const disp = r.headers.get('Content-Disposition') || '';
8867
- const fn = /filename="([^"]+)"/.exec(disp);
8868
- const filename = (fn && fn[1]) || 'export';
8869
- const blob = await r.blob();
8870
- if (isNativeApp()) {
8871
- // Native: route the bridged in-canvas export through the OS save dialog
8872
- // too (WKWebView swallows the `<a download>` blob). Mirror the shell
8873
- // ExportDialog's doExport native branch.
8874
- const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
8875
- const savedPath = await saveExport(filename, bytes);
8876
- reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
8877
- return;
8878
- }
8879
- const url = URL.createObjectURL(blob);
8880
- const a = document.createElement('a');
8881
- a.href = url;
8882
- a.download = filename;
8883
- document.body.appendChild(a);
8884
- a.click();
8885
- a.remove();
8886
- URL.revokeObjectURL(url);
8887
- reply({ ok: true, filename });
9183
+ const { jobId } = await r.json();
9184
+ reply({ ok: true, jobId });
8888
9185
  } catch (err) {
8889
9186
  reply({ ok: false, error: err && err.message ? err.message : String(err) });
8890
9187
  }
@@ -8917,6 +9214,7 @@ function App() {
8917
9214
  zoomCtlVisible,
8918
9215
  broadcastChrome,
8919
9216
  activePath,
9217
+ postToActiveCanvas,
8920
9218
  ]);
8921
9219
 
8922
9220
  // Tell the active canvas iframe to drop any persistent selection (canvas
@@ -9301,6 +9599,7 @@ function App() {
9301
9599
  '/_api/insert-element',
9302
9600
  {
9303
9601
  refId,
9602
+ artboardId: typeof opts.artboardId === 'string' ? opts.artboardId : undefined,
9304
9603
  position,
9305
9604
  kind,
9306
9605
  src: typeof opts.src === 'string' ? opts.src : undefined,
@@ -9506,8 +9805,11 @@ function App() {
9506
9805
  t.tagName === 'SELECT' ||
9507
9806
  t.isContentEditable);
9508
9807
  if (editable) return;
9509
- if (!activePath || activePath === SYSTEM_TAB) return;
9808
+ // Suppress the WKWebView back-nav unconditionally once focus isn't editable —
9809
+ // the activePath/SYSTEM_TAB check below is app logic, not default-action gating,
9810
+ // so it must not gate preventDefault (that gap caused the desktop "Starting…" hang).
9510
9811
  e.preventDefault();
9812
+ if (!activePath || activePath === SYSTEM_TAB) return;
9511
9813
  const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
9512
9814
  if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
9513
9815
  };
@@ -9523,6 +9825,16 @@ function App() {
9523
9825
  useEffect(() => {
9524
9826
  openAssetPickerRef.current = (req) => setAssetPickerReq(req);
9525
9827
  }, []);
9828
+
9829
+ // Phase 4 (whiteboard-improvements) — { canvas: activePath } when open, null
9830
+ // when closed. Mirrors assetPickerReq's ref-indirection (the message
9831
+ // listener below is set up with a minimal dep array, so it reaches a FRESH
9832
+ // opener via a ref rather than needing setStickerPickerReq in its own deps).
9833
+ const [stickerPickerReq, setStickerPickerReq] = useState(null);
9834
+ const openStickerPickerRef = useRef(null);
9835
+ useEffect(() => {
9836
+ openStickerPickerRef.current = (req) => setStickerPickerReq(req);
9837
+ }, []);
9526
9838
  const onAssetPicked = useCallback(
9527
9839
  (pickedPath) => {
9528
9840
  const req = assetPickerReq;
@@ -9538,6 +9850,7 @@ function App() {
9538
9850
  }
9539
9851
  if (req.purpose === 'insert-image') {
9540
9852
  insertElementShell(req.refId, req.position || 'after', 'image', {
9853
+ artboardId: req.artboardId,
9541
9854
  src: pickedPath,
9542
9855
  refIndex: req.refIndex,
9543
9856
  });
@@ -9583,6 +9896,47 @@ function App() {
9583
9896
  },
9584
9897
  [assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
9585
9898
  );
9899
+
9900
+ // Phase 4 (whiteboard-improvements) — a bundled sticker has no project asset
9901
+ // path yet (it lives in MAUDE's own STICKERS_DIR, main-origin-only per
9902
+ // DDR-054), so re-upload its bytes through the SAME /_api/asset lane every
9903
+ // other image source uses (content-addressed, canvas-origin-allowlisted) —
9904
+ // then relay the resulting project-relative path down to the canvas exactly
9905
+ // like replace-annotation-media above (the annotation model owns its own
9906
+ // strokes; the shell performs no stroke write of its own).
9907
+ const onStickerPicked = useCallback(
9908
+ async (sticker) => {
9909
+ const req = stickerPickerReq;
9910
+ setStickerPickerReq(null);
9911
+ if (!req || !sticker?.url) return;
9912
+ // G3 security (DDR-152) — same re-check as onAssetPicked: the request
9913
+ // captured the active canvas when the picker opened; abort rather than
9914
+ // insert into whatever canvas happens to be active now.
9915
+ if (req.canvas && req.canvas !== activePath) {
9916
+ console.warn('[sticker-picker] active canvas changed since request — aborting');
9917
+ return;
9918
+ }
9919
+ try {
9920
+ const blob = await fetch(sticker.url).then((r) =>
9921
+ r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))
9922
+ );
9923
+ const res = await fetch('/_api/asset', {
9924
+ method: 'POST',
9925
+ headers: { 'content-type': blob.type || 'image/png' },
9926
+ body: blob,
9927
+ });
9928
+ const j = await res.json().catch(() => ({}));
9929
+ if (!res.ok || !j.path) {
9930
+ console.warn('[sticker-picker]', j.error || `upload failed (HTTP ${res.status})`);
9931
+ return;
9932
+ }
9933
+ postToActiveCanvas({ dgn: 'insert-sticker', path: j.path });
9934
+ } catch {
9935
+ console.warn('[sticker-picker] could not load or upload that sticker');
9936
+ }
9937
+ },
9938
+ [stickerPickerReq, activePath, postToActiveCanvas]
9939
+ );
9586
9940
  // Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
9587
9941
  // the element's current src as the undo before-value (captured from the
9588
9942
  // Selection's `attrs.src`, not the resolved URL).
@@ -9686,8 +10040,19 @@ function App() {
9686
10040
  ) {
9687
10041
  return; // the timeline shortcuts effect claims it
9688
10042
  }
9689
- e.preventDefault();
9690
10043
  const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
10044
+ // feature-photo-editor — photo edits are sidecar writes (`/_api/photo-edit`),
10045
+ // invisible to the canvas's own source-edit undo stack, so they need
10046
+ // their own owner here. Claims the keypress only while the Photo tab
10047
+ // is the one on screen AND it actually has something to undo/redo —
10048
+ // otherwise falls through to the canvas stack as before. (Focus still
10049
+ // inside a Photo-tab slider is handled separately by
10050
+ // `onPhotoKnobKeyDown`, mirroring `onKnobKeyDown` for CSS knobs.)
10051
+ if (inspectorTab === 'photo' && performPhotoUndo(redo)) {
10052
+ e.preventDefault();
10053
+ return;
10054
+ }
10055
+ e.preventDefault();
9691
10056
  postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
9692
10057
  return;
9693
10058
  }
@@ -9745,6 +10110,12 @@ function App() {
9745
10110
  setExportDialog({ mode: 'handoff' });
9746
10111
  return;
9747
10112
  }
10113
+ // Cmd+Shift+T — toggle the Timeline (video-comp scrub) dock.
10114
+ if (meta && e.shiftKey && (e.key === 't' || e.key === 'T')) {
10115
+ e.preventDefault();
10116
+ toggleTimeline();
10117
+ return;
10118
+ }
9748
10119
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
9749
10120
  // Canvas comment-drop is the `C` tool letter (press C in the canvas,
9750
10121
  // then click the element) or right-click "Add comment". Cmd+C now
@@ -9860,6 +10231,9 @@ function App() {
9860
10231
  clearActiveCanvasSelection,
9861
10232
  presentMode,
9862
10233
  exitPresent,
10234
+ toggleTimeline,
10235
+ inspectorTab,
10236
+ performPhotoUndo,
9863
10237
  ]);
9864
10238
 
9865
10239
  const registerIframe = useCallback((path, el) => {
@@ -10011,6 +10385,7 @@ function App() {
10011
10385
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
10012
10386
  <SyncBanner status={syncStatus} />
10013
10387
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
10388
+ {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
10014
10389
  {gitLifecycle && (
10015
10390
  <div role="status" aria-live="polite" className="st-banner st-banner--info">
10016
10391
  <span className="st-banner-dot" aria-hidden="true" />
@@ -10064,6 +10439,7 @@ function App() {
10064
10439
  onOpenReadiness={() => setReadinessOpen(true)}
10065
10440
  onOpenWhatsNew={whatsNew.openPanel}
10066
10441
  whatsNewCount={whatsNew.unseen.length}
10442
+ exportCenter={exportCenter}
10067
10443
  artboardCount={activeArtboards}
10068
10444
  inspectorOpen={inspectorOpen}
10069
10445
  inspectorTab={inspectorTab}
@@ -10150,6 +10526,8 @@ function App() {
10150
10526
  dirtyByPath={dirtyByPath}
10151
10527
  project={project}
10152
10528
  gitBranch={gitStatus?.branch}
10529
+ remoteSync={remoteSync}
10530
+ onGetLatest={gitGetLatest}
10153
10531
  />
10154
10532
  {sidebarOpen && (
10155
10533
  <PanelGrip
@@ -10232,6 +10610,12 @@ function App() {
10232
10610
  onResizeArtboard={resizeArtboardShell}
10233
10611
  editScope={editScope}
10234
10612
  onUndoRedo={(dir) => postToActiveCanvas({ dgn: dir })}
10613
+ photoSel={photoSel}
10614
+ photoRev={photoRev}
10615
+ onPhotoEdit={onPhotoEdit}
10616
+ onPhotoRemoveBackground={onPhotoRemoveBackground}
10617
+ onPhotoRecordEdit={onPhotoRecordEdit}
10618
+ onPhotoUndoRedo={(dir) => performPhotoUndo(dir === 'redo')}
10235
10619
  layersTree={layersTree}
10236
10620
  canvasFile={activePath}
10237
10621
  onSelectLayer={(n) =>
@@ -10695,6 +11079,9 @@ function App() {
10695
11079
  onClose={() => setAssetPickerReq(null)}
10696
11080
  />
10697
11081
  )}
11082
+ {stickerPickerReq && (
11083
+ <StickerPicker onPick={onStickerPicked} onClose={() => setStickerPickerReq(null)} />
11084
+ )}
10698
11085
  {diffTarget && (
10699
11086
  <DiffView
10700
11087
  target={diffTarget}
@@ -10730,6 +11117,7 @@ function App() {
10730
11117
  }}
10731
11118
  />
10732
11119
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
11120
+ <ExportPanel center={exportCenter} />
10733
11121
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
10734
11122
  {usageNudge && !tourSteps && !collabNudge && (
10735
11123
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">