@1agh/maude 0.42.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 (236) 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 -22
  6. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  7. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  8. package/apps/studio/bin/_video-playwright.mjs +141 -71
  9. package/apps/studio/bin/ingest-footage.sh +27 -0
  10. package/apps/studio/bin/photo-adjust.sh +163 -0
  11. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  12. package/apps/studio/bin/probe-footage.sh +28 -0
  13. package/apps/studio/bin/read-annotations.mjs +125 -2
  14. package/apps/studio/canvas-edit.ts +459 -10
  15. package/apps/studio/canvas-lib.tsx +523 -5
  16. package/apps/studio/canvas-pipeline.ts +65 -7
  17. package/apps/studio/canvas-shell.tsx +156 -16
  18. package/apps/studio/client/app.jsx +451 -34
  19. package/apps/studio/client/export-center.jsx +35 -1
  20. package/apps/studio/client/github.js +4 -0
  21. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  23. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  24. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  25. package/apps/studio/client/photo-knobs.jsx +437 -0
  26. package/apps/studio/client/styles/3-shell-maude.css +42 -0
  27. package/apps/studio/commands/edit-source-command.ts +31 -4
  28. package/apps/studio/context-menu.tsx +31 -6
  29. package/apps/studio/dist/client.bundle.js +5561 -19
  30. package/apps/studio/dist/comment-mount.js +2 -2
  31. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  32. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  33. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  34. package/apps/studio/dist/styles.css +1 -1
  35. package/apps/studio/dom-selection.ts +25 -0
  36. package/apps/studio/exporters/jobs.ts +47 -5
  37. package/apps/studio/exporters/video.ts +24 -4
  38. package/apps/studio/footage/schema.test.ts +195 -0
  39. package/apps/studio/footage/schema.ts +484 -0
  40. package/apps/studio/footage-store.ts +235 -0
  41. package/apps/studio/fs-watch.ts +10 -0
  42. package/apps/studio/git/endpoints.ts +68 -7
  43. package/apps/studio/git/service.ts +196 -20
  44. package/apps/studio/github/service.ts +67 -0
  45. package/apps/studio/handoff.ts +3 -2
  46. package/apps/studio/http.ts +173 -14
  47. package/apps/studio/input-router.tsx +10 -0
  48. package/apps/studio/inspect.ts +53 -1
  49. package/apps/studio/paths.ts +10 -0
  50. package/apps/studio/photo/filters.ts +285 -0
  51. package/apps/studio/photo/pipeline.ts +607 -0
  52. package/apps/studio/photo/schema.ts +367 -0
  53. package/apps/studio/photo-store.ts +154 -0
  54. package/apps/studio/runtime-bundle.ts +29 -6
  55. package/apps/studio/server.ts +5 -1
  56. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  57. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  58. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  59. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  60. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  61. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  62. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  63. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  64. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  65. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  66. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  67. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  68. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  69. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  70. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  71. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  72. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  73. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  74. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  75. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  81. package/apps/studio/stickers/life-style/best.png +0 -0
  82. package/apps/studio/stickers/life-style/come-on.png +0 -0
  83. package/apps/studio/stickers/life-style/cut.png +0 -0
  84. package/apps/studio/stickers/life-style/detox.png +0 -0
  85. package/apps/studio/stickers/life-style/just.png +0 -0
  86. package/apps/studio/stickers/life-style/manifest.json +68 -0
  87. package/apps/studio/stickers/life-style/mirror.png +0 -0
  88. package/apps/studio/stickers/life-style/nice.png +0 -0
  89. package/apps/studio/stickers/life-style/objects.png +0 -0
  90. package/apps/studio/stickers/life-style/ok.png +0 -0
  91. package/apps/studio/stickers/life-style/queen.png +0 -0
  92. package/apps/studio/stickers/life-style/star.png +0 -0
  93. package/apps/studio/stickers/life-style/sun.png +0 -0
  94. package/apps/studio/stickers/life-style/water.png +0 -0
  95. package/apps/studio/stickers/life-style/yeah.png +0 -0
  96. package/apps/studio/stickers/life-style/you-can.png +0 -0
  97. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  98. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  99. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  100. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  101. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  102. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  103. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  104. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  105. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  106. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  107. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  108. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  109. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  110. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  111. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  112. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  113. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  114. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  118. package/apps/studio/stickers/project-status/group-100.png +0 -0
  119. package/apps/studio/stickers/project-status/group-101.png +0 -0
  120. package/apps/studio/stickers/project-status/group-102.png +0 -0
  121. package/apps/studio/stickers/project-status/group-103.png +0 -0
  122. package/apps/studio/stickers/project-status/group-104.png +0 -0
  123. package/apps/studio/stickers/project-status/group-105.png +0 -0
  124. package/apps/studio/stickers/project-status/group-106.png +0 -0
  125. package/apps/studio/stickers/project-status/group-107.png +0 -0
  126. package/apps/studio/stickers/project-status/group-108.png +0 -0
  127. package/apps/studio/stickers/project-status/group-109.png +0 -0
  128. package/apps/studio/stickers/project-status/group-110.png +0 -0
  129. package/apps/studio/stickers/project-status/group-111.png +0 -0
  130. package/apps/studio/stickers/project-status/group-112.png +0 -0
  131. package/apps/studio/stickers/project-status/group-113.png +0 -0
  132. package/apps/studio/stickers/project-status/group-114.png +0 -0
  133. package/apps/studio/stickers/project-status/group-115.png +0 -0
  134. package/apps/studio/stickers/project-status/group-117.png +0 -0
  135. package/apps/studio/stickers/project-status/group-118.png +0 -0
  136. package/apps/studio/stickers/project-status/group-119.png +0 -0
  137. package/apps/studio/stickers/project-status/group-120.png +0 -0
  138. package/apps/studio/stickers/project-status/group-121.png +0 -0
  139. package/apps/studio/stickers/project-status/group-122.png +0 -0
  140. package/apps/studio/stickers/project-status/group-41.png +0 -0
  141. package/apps/studio/stickers/project-status/group-42.png +0 -0
  142. package/apps/studio/stickers/project-status/group-43.png +0 -0
  143. package/apps/studio/stickers/project-status/group-44.png +0 -0
  144. package/apps/studio/stickers/project-status/group-45.png +0 -0
  145. package/apps/studio/stickers/project-status/group-46.png +0 -0
  146. package/apps/studio/stickers/project-status/group-47.png +0 -0
  147. package/apps/studio/stickers/project-status/group-48.png +0 -0
  148. package/apps/studio/stickers/project-status/group-49.png +0 -0
  149. package/apps/studio/stickers/project-status/group-50.png +0 -0
  150. package/apps/studio/stickers/project-status/group-51.png +0 -0
  151. package/apps/studio/stickers/project-status/group-52.png +0 -0
  152. package/apps/studio/stickers/project-status/group-53.png +0 -0
  153. package/apps/studio/stickers/project-status/group-54.png +0 -0
  154. package/apps/studio/stickers/project-status/group-55.png +0 -0
  155. package/apps/studio/stickers/project-status/group-56.png +0 -0
  156. package/apps/studio/stickers/project-status/group-57.png +0 -0
  157. package/apps/studio/stickers/project-status/group-58.png +0 -0
  158. package/apps/studio/stickers/project-status/group-59.png +0 -0
  159. package/apps/studio/stickers/project-status/group-60.png +0 -0
  160. package/apps/studio/stickers/project-status/group-61.png +0 -0
  161. package/apps/studio/stickers/project-status/group-62.png +0 -0
  162. package/apps/studio/stickers/project-status/group-63.png +0 -0
  163. package/apps/studio/stickers/project-status/group-64.png +0 -0
  164. package/apps/studio/stickers/project-status/group-65.png +0 -0
  165. package/apps/studio/stickers/project-status/group-66.png +0 -0
  166. package/apps/studio/stickers/project-status/group-67.png +0 -0
  167. package/apps/studio/stickers/project-status/group-68.png +0 -0
  168. package/apps/studio/stickers/project-status/group-69.png +0 -0
  169. package/apps/studio/stickers/project-status/group-70.png +0 -0
  170. package/apps/studio/stickers/project-status/group-71.png +0 -0
  171. package/apps/studio/stickers/project-status/group-72.png +0 -0
  172. package/apps/studio/stickers/project-status/group-73.png +0 -0
  173. package/apps/studio/stickers/project-status/group-74.png +0 -0
  174. package/apps/studio/stickers/project-status/group-75.png +0 -0
  175. package/apps/studio/stickers/project-status/group-76.png +0 -0
  176. package/apps/studio/stickers/project-status/group-77.png +0 -0
  177. package/apps/studio/stickers/project-status/group-78.png +0 -0
  178. package/apps/studio/stickers/project-status/group-79.png +0 -0
  179. package/apps/studio/stickers/project-status/group-80.png +0 -0
  180. package/apps/studio/stickers/project-status/group-81.png +0 -0
  181. package/apps/studio/stickers/project-status/group-82.png +0 -0
  182. package/apps/studio/stickers/project-status/group-83.png +0 -0
  183. package/apps/studio/stickers/project-status/group-84.png +0 -0
  184. package/apps/studio/stickers/project-status/group-85.png +0 -0
  185. package/apps/studio/stickers/project-status/group-86.png +0 -0
  186. package/apps/studio/stickers/project-status/group-87.png +0 -0
  187. package/apps/studio/stickers/project-status/group-88.png +0 -0
  188. package/apps/studio/stickers/project-status/group-89.png +0 -0
  189. package/apps/studio/stickers/project-status/group-90.png +0 -0
  190. package/apps/studio/stickers/project-status/group-91.png +0 -0
  191. package/apps/studio/stickers/project-status/group-92.png +0 -0
  192. package/apps/studio/stickers/project-status/group-93.png +0 -0
  193. package/apps/studio/stickers/project-status/group-94.png +0 -0
  194. package/apps/studio/stickers/project-status/group-96.png +0 -0
  195. package/apps/studio/stickers/project-status/group-97.png +0 -0
  196. package/apps/studio/stickers/project-status/group-98.png +0 -0
  197. package/apps/studio/stickers/project-status/group-99.png +0 -0
  198. package/apps/studio/stickers/project-status/manifest.json +328 -0
  199. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  200. package/apps/studio/test/acp-commands.test.ts +2 -1
  201. package/apps/studio/test/asset-api.test.ts +3 -3
  202. package/apps/studio/test/canvas-edit.test.ts +17 -6
  203. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  204. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  205. package/apps/studio/test/edit-source-command.test.ts +23 -0
  206. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  207. package/apps/studio/test/footage-store.test.ts +100 -0
  208. package/apps/studio/test/git-branches.test.ts +97 -0
  209. package/apps/studio/test/github-api.test.ts +56 -1
  210. package/apps/studio/test/input-router.test.ts +33 -0
  211. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  212. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  213. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  214. package/apps/studio/test/photo-filters.test.ts +149 -0
  215. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  216. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  217. package/apps/studio/test/read-annotations.test.ts +164 -0
  218. package/apps/studio/test/shell-importmap.test.ts +49 -0
  219. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  220. package/apps/studio/test/timeline-parse.test.ts +57 -0
  221. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  222. package/apps/studio/test/undo-stack.test.ts +33 -0
  223. package/apps/studio/test/video-asset.test.ts +5 -5
  224. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  225. package/apps/studio/text-caret.ts +239 -0
  226. package/apps/studio/tool-palette.tsx +43 -20
  227. package/apps/studio/undo-stack.ts +94 -2
  228. package/apps/studio/use-annotation-resize.tsx +4 -4
  229. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  230. package/apps/studio/use-selection-set.tsx +21 -0
  231. package/apps/studio/video-comp.tsx +48 -7
  232. package/apps/studio/whats-new.json +53 -0
  233. package/cli/commands/design.mjs +34 -3
  234. package/cli/lib/gitignore-block.mjs +1 -0
  235. package/package.json +10 -10
  236. package/plugins/design/templates/_shell.html +1 -0
@@ -23,6 +23,8 @@ 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,
@@ -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.
@@ -1648,6 +1652,8 @@ function Sidebar({
1648
1652
  dirtyByPath,
1649
1653
  project,
1650
1654
  gitBranch,
1655
+ remoteSync,
1656
+ onGetLatest,
1651
1657
  }) {
1652
1658
  const filteredGroups = useMemo(() => {
1653
1659
  if (!search) return groups;
@@ -1889,7 +1895,7 @@ function Sidebar({
1889
1895
  {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
1890
1896
  opens UPWARD, sitting directly above the GitHub identity avatar so the two
1891
1897
  form one bottom dock. Renders nothing until the project is a git repo. */}
1892
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} />
1898
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
1893
1899
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
1894
1900
  sign in, connected account + New/Pull/Share, sign out. Self-contained
1895
1901
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -2427,12 +2433,9 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
2427
2433
  <span className="st-dd-lead">
2428
2434
  <span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
2429
2435
  <span>{p.label}</span>
2436
+ {p.phase ? <span className="st-dd-phase">{p.phase}</span> : null}
2430
2437
  </span>
2431
- {p.phase ? (
2432
- <span className="st-dd-phase">{p.phase}</span>
2433
- ) : p.shortcut ? (
2434
- <Kbd>{p.shortcut}</Kbd>
2435
- ) : null}
2438
+ {p.shortcut ? <Kbd>{p.shortcut}</Kbd> : null}
2436
2439
  </button>
2437
2440
  ))}
2438
2441
  <div className="st-dd-sep" />
@@ -2740,6 +2743,7 @@ function Menubar({
2740
2743
  {
2741
2744
  id: 'timeline',
2742
2745
  label: 'Timeline',
2746
+ shortcut: '⌘ ⇧ T',
2743
2747
  phase: hasComps ? 'video' : undefined,
2744
2748
  checked: timelineOpen,
2745
2749
  disabled: false,
@@ -3959,6 +3963,12 @@ function cssSplitUnit(v) {
3959
3963
  // is for the SAME element we already hold locally, preserve those fields instead
3960
3964
  // of clobbering them to empty (else the server round-trip wipes the custom-CSS /
3961
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.
3962
3972
  function mergeSelClientFields(incoming, prev) {
3963
3973
  if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
3964
3974
  if (!incoming.id || incoming.id !== prev.id) return incoming;
@@ -3968,6 +3978,8 @@ function mergeSelClientFields(incoming, prev) {
3968
3978
  computed: incoming.computed ?? prev.computed,
3969
3979
  customStyles: incoming.customStyles ?? prev.customStyles,
3970
3980
  attrs: incoming.attrs ?? prev.attrs,
3981
+ photoKind: incoming.photoKind ?? prev.photoKind,
3982
+ photoAsset: incoming.photoAsset ?? prev.photoAsset,
3971
3983
  };
3972
3984
  }
3973
3985
 
@@ -6072,6 +6084,31 @@ function ArtboardKnobs({ el, onResizeArtboard }) {
6072
6084
  );
6073
6085
  }
6074
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
+
6075
6112
  function InspectorPanel({
6076
6113
  selected,
6077
6114
  onClose,
@@ -6092,6 +6129,16 @@ function InspectorPanel({
6092
6129
  onTabChange,
6093
6130
  width,
6094
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,
6095
6142
  }) {
6096
6143
  // Tab is controllable from the parent (the guided tour drives it to 'css' /
6097
6144
  // 'layers' so a spotlight step lands on a real row) but falls back to local
@@ -6390,10 +6437,41 @@ function InspectorPanel({
6390
6437
  };
6391
6438
  // `selected` may be a single element, an array (multi-select), or null.
6392
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;
6393
6471
  const tabBtn = (id, label, icon) => (
6394
6472
  <button
6395
6473
  type="button"
6396
- className={'st-rp-tab' + (tab === id ? ' is-active' : '')}
6474
+ className={'st-rp-tab' + (effTab === id ? ' is-active' : '')}
6397
6475
  onClick={() => setTab(id)}
6398
6476
  >
6399
6477
  <StIcon name={icon} size={14} />
@@ -6409,9 +6487,16 @@ function InspectorPanel({
6409
6487
  data-tour="inspector"
6410
6488
  >
6411
6489
  <div className="st-rp-tabs" data-tour="inspector-tabs">
6412
- {tabBtn('inspect', 'Inspect', 'sliders')}
6413
- {tabBtn('layers', 'Layers', 'layers')}
6414
- {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
+ )}
6415
6500
  <button
6416
6501
  type="button"
6417
6502
  className="st-iconbtn"
@@ -6447,7 +6532,18 @@ function InspectorPanel({
6447
6532
  </div>
6448
6533
  ) : null}
6449
6534
  <div className="st-rp-body">
6450
- {!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 ? (
6451
6547
  <div className="st-rp-empty">
6452
6548
  {/* <p> wrapper — st-rp-empty is a flex column, bare text nodes +
6453
6549
  kbd would stack as stretched flex items. */}
@@ -6455,7 +6551,7 @@ function InspectorPanel({
6455
6551
  Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
6456
6552
  </p>
6457
6553
  </div>
6458
- ) : tab === 'inspect' ? (
6554
+ ) : effTab === 'inspect' ? (
6459
6555
  <>
6460
6556
  <div className="st-rp-hd">{el.selector || el.tag || 'element'}</div>
6461
6557
  <div className="st-insp-row">
@@ -6504,7 +6600,7 @@ function InspectorPanel({
6504
6600
  ) : null}
6505
6601
  <InspectComputed el={el} />
6506
6602
  </>
6507
- ) : tab === 'layers' ? (
6603
+ ) : effTab === 'layers' ? (
6508
6604
  <>
6509
6605
  <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
6510
6606
  {layersTree?.nodes?.length ? (
@@ -6855,7 +6951,9 @@ function App() {
6855
6951
  const [activeComps, setActiveComps] = useState([]);
6856
6952
  const [timelineFrame, setTimelineFrame] = useState(0);
6857
6953
  const [timelinePlaying, setTimelinePlaying] = useState(false);
6858
- 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);
6859
6957
  const [timelineMuted, setTimelineMuted] = useState(false);
6860
6958
  const [timelineVolume, setTimelineVolume] = useState(1);
6861
6959
  const [timelineHeight, setTimelineHeight] = useState(216);
@@ -6878,6 +6976,15 @@ function App() {
6878
6976
  useEffect(() => {
6879
6977
  timelineCompIdRef.current = timelineCompId;
6880
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]);
6881
6988
  // The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
6882
6989
  // /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
6883
6990
  // rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
@@ -6925,6 +7032,17 @@ function App() {
6925
7032
  // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
6926
7033
  // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
6927
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);
6928
7046
  // feature-element-editing-robustness Stage C — auto-open the Inspector on the
6929
7047
  // CSS tab when a fresh single selection arrives AND no right panel is already
6930
7048
  // open. Preference-backed (default ON); disable it in the View menu. Refs keep
@@ -7113,8 +7231,8 @@ function App() {
7113
7231
  // persistent prefs broadcast to every open canvas iframe; presentMode is a
7114
7232
  // non-destructive "hide ALL chrome + shell, artboards only" overlay with an
7115
7233
  // Esc / floating-pill escape hatch back to the chrome.
7116
- const [minimapVisible, setMinimapVisible] = useState(true);
7117
- const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
7234
+ const [minimapVisible, setMinimapVisible] = useState(() => readBoolStore(MINIMAP_STORE, false));
7235
+ const [zoomCtlVisible, setZoomCtlVisible] = useState(() => readBoolStore(ZOOMCTL_STORE, false));
7118
7236
  const [presentMode, setPresentMode] = useState(false);
7119
7237
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
7120
7238
  // so the top bar no longer mirrors it.)
@@ -7145,6 +7263,96 @@ function App() {
7145
7263
  [activePath]
7146
7264
  );
7147
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
+
7148
7356
  // DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
7149
7357
  // with no drop target used to make the browser NAVIGATE AWAY to the file
7150
7358
  // (file:// replaces the app). Document-level guard: always cancel the default
@@ -7533,6 +7741,16 @@ function App() {
7533
7741
  localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
7534
7742
  } catch {}
7535
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]);
7536
7754
 
7537
7755
  const toggleSection = useCallback((label, defaultOpen) => {
7538
7756
  setSectionsExpanded((prev) => {
@@ -7863,8 +8081,11 @@ function App() {
7863
8081
  useEffect(() => {
7864
8082
  if (gitStatus?.repo === false) return; // solo / non-git project — no remote
7865
8083
  refreshRemoteSync();
7866
- if (!changesOpen) return; // only keep polling while the panel is visible
7867
- 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);
7868
8089
  return () => clearInterval(id);
7869
8090
  }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
7870
8091
 
@@ -8245,7 +8466,22 @@ function App() {
8245
8466
  // canvas must not be able to plant a selection with no user gesture. The
8246
8467
  // user can only click the visible active canvas, so a non-active source is
8247
8468
  // never a legitimate selection.
8248
- 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
+ ) {
8249
8485
  const activeWin =
8250
8486
  activePath && activePath !== SYSTEM_TAB
8251
8487
  ? iframesRef.current.get(activePath)?.contentWindow
@@ -8253,10 +8489,12 @@ function App() {
8253
8489
  if (e.source !== activeWin) return;
8254
8490
  }
8255
8491
  if (m.dgn === 'select' && m.selection) {
8492
+ setPhotoSel(null); // a DOM selection supersedes an annotation-image Photo target
8256
8493
  wsSend({ type: 'select', selection: m.selection });
8257
8494
  setSelected(m.selection);
8258
8495
  maybeAutoOpenInspectorOnSelect(m.selection); // Stage C
8259
8496
  } else if (m.dgn === 'select-set') {
8497
+ setPhotoSel(null);
8260
8498
  // Canvas multi-select. Payload shape:
8261
8499
  // null → empty selection
8262
8500
  // Selection → length-1 (back-compat with legacy single-element shape)
@@ -8280,6 +8518,7 @@ function App() {
8280
8518
  maybeAutoOpenInspectorOnSelect(payload); // Stage C
8281
8519
  }
8282
8520
  } else if (m.dgn === 'clear-select') {
8521
+ setPhotoSel(null);
8283
8522
  wsSend({ type: 'clear-select' });
8284
8523
  setSelected(null);
8285
8524
  } else if (m.dgn === 'edit-text' && m.id) {
@@ -8301,7 +8540,15 @@ function App() {
8301
8540
  fetch('/_api/edit-text', {
8302
8541
  method: 'POST',
8303
8542
  headers: { 'content-type': 'application/json' },
8304
- 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
+ }),
8305
8552
  })
8306
8553
  .then((r) => r.json().catch(() => ({})))
8307
8554
  .then((j) => {
@@ -8334,6 +8581,11 @@ function App() {
8334
8581
  } else {
8335
8582
  url = '/_api/edit-text';
8336
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;
8337
8589
  }
8338
8590
  editApplyChainRef.current = editApplyChainRef.current
8339
8591
  .catch(() => {})
@@ -8510,38 +8762,54 @@ function App() {
8510
8762
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8511
8763
  if (e.source === activeWin && typeof m.id === 'string') pasteStyleRef.current?.(m.id);
8512
8764
  } else if (m.dgn === 'insert-request') {
8513
- // 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).
8514
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';
8515
8778
  const okShape =
8516
- typeof m.refId === 'string' &&
8779
+ hasRefId !== hasArtboardId &&
8517
8780
  (m.kind === 'div' || m.kind === 'text' || m.kind === 'image') &&
8518
- (m.position === 'before' ||
8519
- m.position === 'after' ||
8520
- m.position === 'inside-start' ||
8521
- m.position === 'inside-end');
8781
+ okPosition &&
8782
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8522
8783
  if (e.source === activeWin && okShape) {
8523
8784
  insertElementShellRef.current?.(m.refId, m.position, m.kind, {
8785
+ artboardId: hasArtboardId ? m.artboardId : undefined,
8524
8786
  src: typeof m.src === 'string' ? m.src : undefined,
8525
8787
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8526
8788
  });
8527
8789
  }
8528
8790
  } else if (m.dgn === 'insert-image-request') {
8529
- // Stage F/I3 — "Insert ▸ Image" from the canvas context menu. An image
8530
- // needs a contained asset src, so the shell opens the AssetPicker; the
8531
- // pick then drives insertElementShell(kind:'image'). Confused-deputy
8532
- // 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.
8533
8797
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
8798
+ const hasRefId = typeof m.refId === 'string';
8799
+ const hasArtboardId = typeof m.artboardId === 'string';
8534
8800
  const okShape =
8535
- typeof m.refId === 'string' &&
8801
+ hasRefId !== hasArtboardId &&
8536
8802
  (m.position === 'before' ||
8537
8803
  m.position === 'after' ||
8538
8804
  m.position === 'inside-start' ||
8539
- m.position === 'inside-end');
8805
+ m.position === 'inside-end') &&
8806
+ (!hasArtboardId || m.position === 'inside-start' || m.position === 'inside-end');
8540
8807
  if (e.source === activeWin && okShape) {
8541
8808
  openAssetPickerRef.current?.({
8542
8809
  purpose: 'insert-image',
8543
8810
  canvas: activePath,
8544
8811
  refId: m.refId,
8812
+ artboardId: hasArtboardId ? m.artboardId : undefined,
8545
8813
  position: m.position,
8546
8814
  refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
8547
8815
  });
@@ -8573,6 +8841,27 @@ function App() {
8573
8841
  before: typeof m.before === 'string' ? m.before : null,
8574
8842
  });
8575
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
+ }
8576
8865
  } else if (m.dgn === 'insert-artboard-request') {
8577
8866
  // Stage I4 — insert a new empty artboard from a screen-size preset.
8578
8867
  const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
@@ -8609,7 +8898,12 @@ function App() {
8609
8898
  }
8610
8899
  } else if (m.dgn === 'open-inspector') {
8611
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`).
8612
8905
  openRightPanel('inspector');
8906
+ if (typeof m.tab === 'string') setInspectorTab(m.tab);
8613
8907
  } else if (m.dgn === 'present-enter') {
8614
8908
  // Canvas tool-palette "Presentation mode" button — Present Mode is a
8615
8909
  // shell-level state (hides the menubar / sidebar / panels), so the
@@ -8703,6 +8997,23 @@ function App() {
8703
8997
  height: Math.max(1, Math.round(Number(c.height) || 0)),
8704
8998
  }));
8705
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
+ }
8706
9017
  } else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
8707
9018
  // Live playhead mirror from the Player (preview scrub/playback).
8708
9019
  const activeWin =
@@ -8714,6 +9025,17 @@ function App() {
8714
9025
  // artboard's Player must not fight it for the readout (multi-comp canvas).
8715
9026
  if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
8716
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);
8717
9039
  } else if (m.dgn === 'toggle-palette') {
8718
9040
  // ⌘K pressed while focus was inside the canvas iframe — the injected
8719
9041
  // inspector forwards the chord here since the iframe's keydown never
@@ -8726,6 +9048,8 @@ function App() {
8726
9048
  else if (m.id === 'inspector') toggleRightPanel('inspector');
8727
9049
  else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
8728
9050
  else if (m.id === 'comments') toggleRightPanel('comments');
9051
+ else if (m.id === 'changes') toggleRightPanel('changes');
9052
+ else if (m.id === 'timeline') toggleTimeline();
8729
9053
  else if (m.id === 'export') setExportDialog({ mode: 'export' });
8730
9054
  else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
8731
9055
  } else if (m.dgn === 'open-export') {
@@ -8736,6 +9060,11 @@ function App() {
8736
9060
  mode: 'export',
8737
9061
  scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
8738
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);
8739
9068
  } else if (m.dgn === 'loaded' && m.file) {
8740
9069
  // iframe finished loading — drop the compile skeleton, push current
8741
9070
  // comments + carry over focused pin if any
@@ -8885,6 +9214,7 @@ function App() {
8885
9214
  zoomCtlVisible,
8886
9215
  broadcastChrome,
8887
9216
  activePath,
9217
+ postToActiveCanvas,
8888
9218
  ]);
8889
9219
 
8890
9220
  // Tell the active canvas iframe to drop any persistent selection (canvas
@@ -9269,6 +9599,7 @@ function App() {
9269
9599
  '/_api/insert-element',
9270
9600
  {
9271
9601
  refId,
9602
+ artboardId: typeof opts.artboardId === 'string' ? opts.artboardId : undefined,
9272
9603
  position,
9273
9604
  kind,
9274
9605
  src: typeof opts.src === 'string' ? opts.src : undefined,
@@ -9474,8 +9805,11 @@ function App() {
9474
9805
  t.tagName === 'SELECT' ||
9475
9806
  t.isContentEditable);
9476
9807
  if (editable) return;
9477
- 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).
9478
9811
  e.preventDefault();
9812
+ if (!activePath || activePath === SYSTEM_TAB) return;
9479
9813
  const one = Array.isArray(selected) ? (selected.length === 1 ? selected[0] : null) : selected;
9480
9814
  if (one?.artboardId && !one.id) deleteArtboardShell(one.artboardId);
9481
9815
  };
@@ -9491,6 +9825,16 @@ function App() {
9491
9825
  useEffect(() => {
9492
9826
  openAssetPickerRef.current = (req) => setAssetPickerReq(req);
9493
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
+ }, []);
9494
9838
  const onAssetPicked = useCallback(
9495
9839
  (pickedPath) => {
9496
9840
  const req = assetPickerReq;
@@ -9506,6 +9850,7 @@ function App() {
9506
9850
  }
9507
9851
  if (req.purpose === 'insert-image') {
9508
9852
  insertElementShell(req.refId, req.position || 'after', 'image', {
9853
+ artboardId: req.artboardId,
9509
9854
  src: pickedPath,
9510
9855
  refIndex: req.refIndex,
9511
9856
  });
@@ -9551,6 +9896,47 @@ function App() {
9551
9896
  },
9552
9897
  [assetPickerReq, insertElementShell, activePath, recordSourceEdit, postToActiveCanvas]
9553
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
+ );
9554
9940
  // Media-section "Replace…" (CssKnobs) → open the picker in replace mode with
9555
9941
  // the element's current src as the undo before-value (captured from the
9556
9942
  // Selection's `attrs.src`, not the resolved URL).
@@ -9654,8 +10040,19 @@ function App() {
9654
10040
  ) {
9655
10041
  return; // the timeline shortcuts effect claims it
9656
10042
  }
9657
- e.preventDefault();
9658
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();
9659
10056
  postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
9660
10057
  return;
9661
10058
  }
@@ -9713,6 +10110,12 @@ function App() {
9713
10110
  setExportDialog({ mode: 'handoff' });
9714
10111
  return;
9715
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
+ }
9716
10119
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
9717
10120
  // Canvas comment-drop is the `C` tool letter (press C in the canvas,
9718
10121
  // then click the element) or right-click "Add comment". Cmd+C now
@@ -9828,6 +10231,9 @@ function App() {
9828
10231
  clearActiveCanvasSelection,
9829
10232
  presentMode,
9830
10233
  exitPresent,
10234
+ toggleTimeline,
10235
+ inspectorTab,
10236
+ performPhotoUndo,
9831
10237
  ]);
9832
10238
 
9833
10239
  const registerIframe = useCallback((path, el) => {
@@ -10120,6 +10526,8 @@ function App() {
10120
10526
  dirtyByPath={dirtyByPath}
10121
10527
  project={project}
10122
10528
  gitBranch={gitStatus?.branch}
10529
+ remoteSync={remoteSync}
10530
+ onGetLatest={gitGetLatest}
10123
10531
  />
10124
10532
  {sidebarOpen && (
10125
10533
  <PanelGrip
@@ -10202,6 +10610,12 @@ function App() {
10202
10610
  onResizeArtboard={resizeArtboardShell}
10203
10611
  editScope={editScope}
10204
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')}
10205
10619
  layersTree={layersTree}
10206
10620
  canvasFile={activePath}
10207
10621
  onSelectLayer={(n) =>
@@ -10665,6 +11079,9 @@ function App() {
10665
11079
  onClose={() => setAssetPickerReq(null)}
10666
11080
  />
10667
11081
  )}
11082
+ {stickerPickerReq && (
11083
+ <StickerPicker onPick={onStickerPicked} onClose={() => setStickerPickerReq(null)} />
11084
+ )}
10668
11085
  {diffTarget && (
10669
11086
  <DiffView
10670
11087
  target={diffTarget}