@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
@@ -255,6 +255,34 @@ function ProgressBar({ progress }) {
255
255
  );
256
256
  }
257
257
 
258
+ // Per-job anchor for the ETA: the FIRST progress sample the client sees. For a
259
+ // video export that's the first frame-step frame — after the (progress-less)
260
+ // renderMediaOnWeb attempt — so the rate is measured over real capture work and
261
+ // isn't skewed by that pre-roll. Keyed by job id; cleared when the job ends.
262
+ const etaAnchors = new Map();
263
+
264
+ /** Rough "~N min left" from the rate since the first observed progress sample.
265
+ * Recomputes on every progress update (frame-step emits ~1–2/s), so it ticks
266
+ * down live without a timer. Returns null until there's enough delta to trust. */
267
+ function etaLabel(job) {
268
+ const cur = job.progress?.current;
269
+ const total = job.progress?.total;
270
+ if (!cur || !total || cur >= total) {
271
+ etaAnchors.delete(job.id);
272
+ return null;
273
+ }
274
+ let anchor = etaAnchors.get(job.id);
275
+ if (!anchor || cur < anchor.c) {
276
+ anchor = { t: Date.now(), c: cur };
277
+ etaAnchors.set(job.id, anchor);
278
+ }
279
+ const doneSinceAnchor = cur - anchor.c;
280
+ if (doneSinceAnchor < 3) return null; // need a few frames to estimate a rate
281
+ const remainingMs = ((Date.now() - anchor.t) / doneSinceAnchor) * (total - cur);
282
+ if (remainingMs < 45_000) return '<1 min left';
283
+ return `~${Math.round(remainingMs / 60_000)} min left`;
284
+ }
285
+
258
286
  function StatusPill({ job }) {
259
287
  const statusText =
260
288
  job.status === 'queued'
@@ -266,7 +294,13 @@ function StatusPill({ job }) {
266
294
  : job.status === 'done'
267
295
  ? 'Ready'
268
296
  : 'Failed';
269
- return <span className={`st-export-pill st-export-pill--${job.status}`}>{statusText}</span>;
297
+ const eta = job.status === 'running' ? etaLabel(job) : null;
298
+ return (
299
+ <span className={`st-export-pill st-export-pill--${job.status}`}>
300
+ {statusText}
301
+ {eta ? ` · ${eta}` : ''}
302
+ </span>
303
+ );
270
304
  }
271
305
 
272
306
  export function ExportBadge({ center }) {
@@ -35,6 +35,10 @@ export const signOut = () => invoke('github_sign_out');
35
35
  export const isSignedIn = () => invoke('github_is_signed_in');
36
36
  export const openVerification = () =>
37
37
  invoke('github_open_verification', { url: 'https://github.com/login/device' });
38
+ /** Open a github.com URL (PR link, repo page) in the OS browser. Native only; the
39
+ * Rust side host-locks it to github.com. Throws on an older desktop build without
40
+ * the command — callers fall back to copy-to-clipboard. */
41
+ export const openGitHubUrl = (url) => invoke('open_github_url', { url });
38
42
  /** Show the device code as soon as the shell has it. Returns an unlisten promise. */
39
43
  export const onDeviceCode = (cb) => listen('github://device-code', cb);
40
44
  /** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */
@@ -364,6 +364,14 @@ export default function GitPanel({
364
364
  getLatest: true,
365
365
  }
366
366
  );
367
+ else if (kind === 'publish' && /protected branch|GH006/i.test(res.error || ''))
368
+ // A protected default branch rejects a direct Publish — point at the PR flow
369
+ // (DDR-162): the way to add work to a protected branch is a draft + a pull request.
370
+ setBanner({
371
+ variant: 'warn',
372
+ title: 'This branch is protected',
373
+ text: 'It needs a pull request. Switch to a draft, then use “Add to Shared version” to open one.',
374
+ });
367
375
  else setBanner({ variant: 'error', text: res.error || 'Something went wrong.' });
368
376
  return res;
369
377
  } finally {
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { useEffect, useRef, useState } from 'react';
21
21
 
22
- import { appRecentProjects, isNativeApp, openLocalProject, pickDirectory } from '../github.js';
22
+ import { appRecentProjects, isNativeApp, openGitHubUrl, openLocalProject, pickDirectory } from '../github.js';
23
23
 
24
24
  const SHARED = new Set(['main', 'master']);
25
25
 
@@ -43,6 +43,10 @@ function Icon({ name, size = 16, className }) {
43
43
  // refresh drafts — a circular arrow.
44
44
  refresh: (<><path d="M12.5 8a4.5 4.5 0 1 1-1.3-3.2" /><polyline points="12.8 2.5 12.8 5 10.3 5" /></>),
45
45
  spinner: <path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8" />,
46
+ // "get latest" — pull the shared version's new commits down.
47
+ download: (<><line x1="8" y1="2.5" x2="8" y2="10.5" /><polyline points="5 7.5 8 10.5 11 7.5" /><line x1="3.5" y1="13.2" x2="12.5" y2="13.2" /></>),
48
+ // dismiss (×) — used by the "continue on <draft>" resume nudge.
49
+ close: (<><line x1="4" y1="4" x2="12" y2="12" /><line x1="12" y1="4" x2="4" y2="12" /></>),
46
50
  }[name];
47
51
  return (
48
52
  <svg className={className} width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
@@ -93,7 +97,7 @@ async function postJson(url, body, opts = {}) {
93
97
  }
94
98
  }
95
99
 
96
- export default function RepoBranchSwitcher({ project, liveBranch }) {
100
+ export default function RepoBranchSwitcher({ project, liveBranch, remoteSync, onGetLatest }) {
97
101
  const native = isNativeApp();
98
102
  const [status, setStatus] = useState(null); // { repo, branch }
99
103
  const [branches, setBranches] = useState([]);
@@ -103,6 +107,34 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
103
107
  const [draftName, setDraftName] = useState('');
104
108
  const [foldConfirm, setFoldConfirm] = useState(false);
105
109
  const [folding, setFolding] = useState('');
110
+ // After a GitHub-remote fold: { url, number } (PR opened) or { message } (draft
111
+ // pushed but no PR). Keeps the confirm sheet open to show the result (DDR-162).
112
+ const [prResult, setPrResult] = useState(null);
113
+ const [prCopied, setPrCopied] = useState(false);
114
+ // Get-latest (pull) busy state for the dock nudge.
115
+ const [pulling, setPulling] = useState(false);
116
+ // "Continue on <draft>?" nudge — dismissed for this session once the user opts out.
117
+ const [resumeDismissed, setResumeDismissed] = useState(false);
118
+
119
+ // Open the PR in the OS browser. A WKWebView anchor can't reach the default browser,
120
+ // so native goes through the Tauri opener (github.com host-locked in Rust); an older
121
+ // desktop build without that command falls back to copy-to-clipboard. Web just opens.
122
+ async function reviewPr(url) {
123
+ if (!isNativeApp()) {
124
+ window.open(url, '_blank', 'noopener,noreferrer');
125
+ return;
126
+ }
127
+ try {
128
+ await openGitHubUrl(url);
129
+ } catch {
130
+ try {
131
+ await navigator.clipboard.writeText(url);
132
+ setPrCopied(true);
133
+ } catch {
134
+ /* clipboard blocked — the URL is shown selectable in the sheet */
135
+ }
136
+ }
137
+ }
106
138
  const [busy, setBusy] = useState(false);
107
139
  const [switching, setSwitching] = useState('');
108
140
  const [err, setErr] = useState('');
@@ -248,17 +280,43 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
248
280
  else { setErr(r.json?.error || 'Could not create the draft.'); setBusy(false); }
249
281
  }
250
282
 
251
- // "Add this draft to the Shared version" (Task 7) merge + publish, then remove.
283
+ // "Get latest" from the dock nudge pull the shared version's new commits. Reuses
284
+ // the app's pull handler (onGetLatest = gitGetLatest): on success it re-probes so the
285
+ // nudge clears; a content conflict opens the visual resolver (handled in app.jsx).
286
+ async function getLatest() {
287
+ if (pulling || !onGetLatest) return;
288
+ setPulling(true);
289
+ setErr('');
290
+ const r = await onGetLatest();
291
+ setPulling(false);
292
+ if (r && !r.ok && !r.conflict)
293
+ setErr(r.status === 401 || r.authRequired ? 'Sign in with GitHub to get the latest.' : r.error || 'Could not get the latest.');
294
+ }
295
+
296
+ // "Add this draft to the Shared version" (Task 7 / DDR-162). On a GitHub remote this
297
+ // opens a pull request (the merge happens on GitHub, post-review) — surface the PR
298
+ // link and keep the sheet open. A local project merges directly (reload). A draft that
299
+ // pushed but couldn't open a PR (no sign-in / non-github) shows a heads-up.
252
300
  async function foldDraft() {
253
- setFoldConfirm(false);
254
301
  setFolding(branch);
255
302
  setErr('');
256
- const r = await postJson('/_api/git/fold', { name: branch });
257
- if (r.ok && r.json?.ok) window.location.reload();
258
- else {
259
- setErr(r.status === 401 ? `Sign in with GitHub to merge into ${sharedName}.` : r.json?.error || 'Could not merge the branch.');
303
+ const r = await postJson('/_api/git/fold', { name: branch }, { timeoutMs: 45000 });
304
+ const j = r.json;
305
+ if (r.ok && j?.ok && j.prUrl) {
306
+ setPrResult({ url: j.prUrl, number: j.prNumber, repo: j.repo });
307
+ setFolding('');
308
+ reloadBranches(); // the draft still exists (the PR references it) — keep the list
309
+ return;
310
+ }
311
+ if (r.ok && j?.ok && j.prUnavailable) {
312
+ setPrResult({ message: j.error || 'Draft published, but the pull request could not be opened.' });
260
313
  setFolding('');
314
+ reloadBranches();
315
+ return;
261
316
  }
317
+ if (r.ok && j?.ok) { window.location.reload(); return; } // local merge (no remote)
318
+ setErr(r.status === 401 ? `Sign in with GitHub to add “${branch}” to ${sharedName}.` : j?.error || 'Could not add the branch.');
319
+ setFolding('');
262
320
  }
263
321
 
264
322
  async function switchRepo(path) {
@@ -282,6 +340,16 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
282
340
 
283
341
  const slug = slugify(draftName);
284
342
  const currentDraft = onShared ? null : branches.find((b) => b.current);
343
+ // On the Shared version with a draft around? Offer to jump back to the most
344
+ // recently-worked local draft. Maude shows whatever branch HEAD is, so after a
345
+ // merge / Get-latest you land on the Shared version — this saves the manual switch
346
+ // back to the draft you were iterating on. Dismissible for the session.
347
+ const resumeDraft =
348
+ onShared && !resumeDismissed && !switching && !folding
349
+ ? [...branches]
350
+ .filter((b) => !SHARED.has(b.name) && b.where !== 'remote')
351
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0]
352
+ : null;
285
353
 
286
354
  // Search-miss (DDR-133, Task 4): a typed name that matches no LOCAL or already-
287
355
  // fetched remote branch offers an explicit remote search (the bounded fetch),
@@ -316,6 +384,23 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
316
384
 
317
385
  return (
318
386
  <div className="rb-dock-wrap">
387
+ {remoteSync?.remoteAhead && !switching && !folding && (
388
+ <button type="button" data-testid="switcher-get-latest" className="btn btn--primary rb-getlatest" onClick={getLatest} disabled={pulling} title={`Get the latest ${sharedName}`}>
389
+ <Icon name={pulling ? 'spinner' : 'download'} size={14} className={pulling ? 'rb-spin' : ''} />
390
+ <span className="rb-getlatest-tx">{pulling ? 'Getting the latest…' : 'Get latest'}</span>
391
+ {!pulling && remoteSync.behind > 0 && <span className="rb-getlatest-sub">{remoteSync.behind} new on {sharedName}</span>}
392
+ </button>
393
+ )}
394
+ {resumeDraft && (
395
+ <div className="rb-resume" data-testid="switcher-resume">
396
+ <button type="button" className="rb-resume-go" onClick={() => switchDraft(resumeDraft.name)} title={`Switch to ${resumeDraft.name}`}>
397
+ <span className="rb-resume-icon"><Icon name="draft" size={13} /></span>
398
+ <span className="rb-resume-tx">Continue on <b>{resumeDraft.name}</b></span>
399
+ <Icon name="chevron-right" size={12} className="rb-resume-arrow" />
400
+ </button>
401
+ <button type="button" className="rb-resume-x" data-testid="switcher-resume-dismiss" aria-label="Dismiss" onClick={() => setResumeDismissed(true)}><Icon name="close" size={12} /></button>
402
+ </div>
403
+ )}
319
404
  <div className="rb-dock" ref={rootRef}>
320
405
  {open && (
321
406
  <div className="rb-pop rb-pop--up" id="rb-switch-pop" role="menu" aria-label="Switch project or version" data-testid="repo-switcher-popup">
@@ -376,7 +461,7 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
376
461
  </span>
377
462
  <Icon name="check" size={14} className="rb-pop-check" />
378
463
  </button>
379
- <button type="button" data-testid="switcher-merge" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setFoldConfirm(true); }}>
464
+ <button type="button" data-testid="switcher-merge" className="rb-fold" role="menuitem" onClick={() => { setOpen(false); setErr(''); setPrResult(null); setPrCopied(false); setFoldConfirm(true); }}>
380
465
  <span className="rb-fold-icon"><Icon name="arrow-up-to-line" size={15} /></span>
381
466
  <span className="rb-fold-tx">
382
467
  <span className="rb-fold-title">Merge this branch → {sharedName}</span>
@@ -459,19 +544,46 @@ export default function RepoBranchSwitcher({ project, liveBranch }) {
459
544
  {err && !open && !newDraft && !switching && <div className="rb-switcher-err" role="alert">{err}</div>}
460
545
  </div>
461
546
 
462
- {/* Merge-to-default confirm — the one modal in this surface. No 3-way merge UI. */}
547
+ {/* Add-to-default confirm — the one modal in this surface. No 3-way merge UI.
548
+ After a GitHub-remote fold it flips to a pull-request result view (DDR-162). */}
463
549
  {foldConfirm && (
464
- <div className="rb-scrim" role="presentation" onClick={() => setFoldConfirm(false)}>
465
- <div className="rb-sheet" role="dialog" aria-modal="true" aria-labelledby="rb-sheet-title" aria-describedby="rb-sheet-body" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') setFoldConfirm(false); }}>
466
- <span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
467
- <h2 className="rb-sheet-title" id="rb-sheet-title">Merge this branch → {sharedName}</h2>
468
- <p className="rb-sheet-body" id="rb-sheet-body">Everything in <b>“{currentDraft?.name || branch}”</b> becomes part of <b>{sharedName}</b> — the default branch everyone shares.</p>
469
- <p className="rb-sheet-meta">Your work is now part of {sharedName}, kept in History. Teammates pick it up the next time they Get latest, and the empty branch is cleared away — nothing is lost.</p>
470
- {err && <p className="rb-newdraft-err">{err}</p>}
471
- <div className="rb-sheet-actions">
472
- <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
473
- <button type="button" data-testid="switcher-merge-confirm" className="btn btn--primary" onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> Merge → {sharedName}</button>
474
- </div>
550
+ <div className="rb-scrim" role="presentation" onClick={() => { setFoldConfirm(false); setPrResult(null); setPrCopied(false); }}>
551
+ <div className="rb-sheet" role="dialog" aria-modal="true" aria-labelledby="rb-sheet-title" aria-describedby="rb-sheet-body" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Escape') { setFoldConfirm(false); setPrResult(null); setPrCopied(false); } }}>
552
+ {prResult ? (
553
+ prResult.url ? (
554
+ <>
555
+ <span className="rb-sheet-icon"><Icon name="share" size={20} /></span>
556
+ <h2 className="rb-sheet-title" id="rb-sheet-title">Pull request opened{prResult.number ? ` #${prResult.number}` : ''}</h2>
557
+ <p className="rb-sheet-body" id="rb-sheet-body">Your draft <b>“{branch}”</b> is up as a pull request into <b>{sharedName}</b>{prResult.repo ? <> of <b>{prResult.repo}</b></> : null}. Review and merge it on GitHub — that keeps your team's review rules.</p>
558
+ <p className="rb-sheet-meta rb-pr-url">{prResult.url}</p>
559
+ <div className="rb-sheet-actions">
560
+ <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setPrResult(null); setPrCopied(false); }}>Close</button>
561
+ <button type="button" className="btn btn--primary" data-testid="switcher-pr-link" onClick={() => reviewPr(prResult.url)}><Icon name="share" size={15} /> {prCopied ? 'Link copied — paste in your browser' : 'Review on GitHub'}</button>
562
+ </div>
563
+ </>
564
+ ) : (
565
+ <>
566
+ <span className="rb-sheet-icon"><Icon name="share" size={20} /></span>
567
+ <h2 className="rb-sheet-title" id="rb-sheet-title">Draft published</h2>
568
+ <p className="rb-sheet-body" id="rb-sheet-body">{prResult.message}</p>
569
+ <div className="rb-sheet-actions">
570
+ <button type="button" data-testid="switcher-pr-dismiss" className="btn btn--primary" onClick={() => { setFoldConfirm(false); setPrResult(null); setPrCopied(false); }}>Got it</button>
571
+ </div>
572
+ </>
573
+ )
574
+ ) : (
575
+ <>
576
+ <span className="rb-sheet-icon"><Icon name="arrow-up-to-line" size={20} /></span>
577
+ <h2 className="rb-sheet-title" id="rb-sheet-title">Add this branch to {sharedName}</h2>
578
+ <p className="rb-sheet-body" id="rb-sheet-body">Propose everything in <b>“{currentDraft?.name || branch}”</b> to <b>{sharedName}</b> — the default branch everyone shares.</p>
579
+ <p className="rb-sheet-meta">On a shared GitHub project this opens a pull request for review; a local project merges directly. Nothing is lost — your work is kept in History.</p>
580
+ {err && <p className="rb-newdraft-err">{err}</p>}
581
+ <div className="rb-sheet-actions">
582
+ <button type="button" className="btn btn--ghost" onClick={() => { setFoldConfirm(false); setErr(''); }}>Cancel</button>
583
+ <button type="button" data-testid="switcher-merge-confirm" className="btn btn--primary" disabled={!!folding} onClick={foldDraft}><Icon name="arrow-up-to-line" size={15} /> {folding ? 'Adding…' : `Add to ${sharedName}`}</button>
584
+ </div>
585
+ </>
586
+ )}
475
587
  </div>
476
588
  </div>
477
589
  )}
@@ -0,0 +1,160 @@
1
+ // StickerPicker.jsx — feature-whiteboard-annotation-improvements, Phase 4.
2
+ //
3
+ // Searchable, pack-grouped picker for MAUDE's bundled whiteboard sticker packs
4
+ // (GET /_api/stickers — main-origin only, DDR-054/DDR-088; mirrors AssetPicker's
5
+ // shell/scrim treatment in app.jsx). Triggered from the canvas-side tool
6
+ // palette's Stickers button via `postMessage({dgn:'open-sticker-picker'})`;
7
+ // `onPick(sticker)` hands the caller `{ file, keywords, url }` — app.jsx
8
+ // re-uploads the picked PNG's bytes through the existing /_api/asset lane
9
+ // (content-addressed, canvas-origin-allowlisted) and relays the resulting
10
+ // project path down to the canvas, exactly like the replace-annotation-media
11
+ // flow already does for AssetPicker.
12
+
13
+ import { useEffect, useMemo, useState } from 'react';
14
+
15
+ function StIcon({ name, size = 15 }) {
16
+ if (name === 'x') {
17
+ return (
18
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
19
+ <path
20
+ d="M4 4l8 8M12 4l-8 8"
21
+ stroke="currentColor"
22
+ strokeWidth="1.6"
23
+ strokeLinecap="round"
24
+ />
25
+ </svg>
26
+ );
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export default function StickerPicker({ onPick, onClose }) {
32
+ const [packs, setPacks] = useState(null); // null = loading
33
+ const [query, setQuery] = useState('');
34
+
35
+ useEffect(() => {
36
+ let alive = true;
37
+ fetch('/_api/stickers')
38
+ .then((r) => r.json())
39
+ .then((j) => alive && setPacks(j.ok ? j.packs : []))
40
+ .catch(() => alive && setPacks([]));
41
+ return () => {
42
+ alive = false;
43
+ };
44
+ }, []);
45
+
46
+ useEffect(() => {
47
+ const onKey = (e) => {
48
+ if (e.key === 'Escape') {
49
+ e.stopPropagation();
50
+ onClose();
51
+ }
52
+ };
53
+ window.addEventListener('keydown', onKey, true);
54
+ return () => window.removeEventListener('keydown', onKey, true);
55
+ }, [onClose]);
56
+
57
+ const filteredPacks = useMemo(() => {
58
+ if (!packs) return [];
59
+ const q = query.trim().toLowerCase();
60
+ if (!q) return packs;
61
+ return packs
62
+ .map((pack) => ({
63
+ ...pack,
64
+ stickers: pack.stickers.filter(
65
+ (s) =>
66
+ s.file.toLowerCase().includes(q) ||
67
+ (s.keywords || []).some((k) => k.toLowerCase().includes(q))
68
+ ),
69
+ }))
70
+ .filter((pack) => pack.stickers.length > 0);
71
+ }, [packs, query]);
72
+
73
+ const totalShown = filteredPacks.reduce((n, p) => n + p.stickers.length, 0);
74
+
75
+ return (
76
+ <div
77
+ className="st-scrim"
78
+ role="presentation"
79
+ onMouseDown={(e) => {
80
+ if (e.target === e.currentTarget) onClose();
81
+ }}
82
+ >
83
+ <div
84
+ className="st-dialog st-sticker-picker"
85
+ role="dialog"
86
+ aria-modal="true"
87
+ aria-label="Stickers"
88
+ >
89
+ <div className="st-dialog-hd">
90
+ <span className="st-dialog-title">Stickers</span>
91
+ <button type="button" className="st-iconbtn" aria-label="Close" onClick={onClose}>
92
+ <StIcon name="x" size={15} />
93
+ </button>
94
+ </div>
95
+ <div className="st-dialog-bd">
96
+ <input
97
+ className="input"
98
+ type="text"
99
+ value={query}
100
+ onChange={(e) => setQuery(e.target.value)}
101
+ placeholder="Search stickers…"
102
+ aria-label="Search stickers"
103
+ autoFocus
104
+ />
105
+ <div className="st-sp-body">
106
+ {packs == null ? (
107
+ <div className="st-sp-empty">Loading…</div>
108
+ ) : totalShown === 0 ? (
109
+ <div className="st-sp-empty">
110
+ {query.trim() ? `No stickers match "${query.trim()}"` : 'No stickers bundled'}
111
+ </div>
112
+ ) : (
113
+ filteredPacks.map((pack) => (
114
+ <div key={pack.slug}>
115
+ <div className="st-sp-pack-hd">
116
+ <span className="st-sp-pack-name">{pack.name}</span>
117
+ {pack.author && <span className="st-sp-pack-author">by {pack.author}</span>}
118
+ </div>
119
+ <div className="st-sp-grid">
120
+ {pack.stickers.map((s) => (
121
+ <button
122
+ type="button"
123
+ key={s.file}
124
+ className="st-sp-cell"
125
+ title={s.keywords?.[0] || s.file}
126
+ onClick={() => onPick(s)}
127
+ >
128
+ <img className="st-sp-thumb" src={s.url} alt={s.keywords?.[0] || s.file} loading="lazy" />
129
+ </button>
130
+ ))}
131
+ </div>
132
+ </div>
133
+ ))
134
+ )}
135
+ </div>
136
+ </div>
137
+ {packs && packs.length > 0 && (
138
+ <div className="st-sp-ft">
139
+ {packs.map((pack) => (
140
+ <span key={pack.slug}>
141
+ {pack.name}
142
+ {pack.author ? ` — ${pack.author}` : ''}
143
+ {pack.attributionUrl ? (
144
+ <>
145
+ {' '}
146
+ (
147
+ <a href={pack.attributionUrl} target="_blank" rel="noreferrer">
148
+ source
149
+ </a>
150
+ )
151
+ </>
152
+ ) : null}
153
+ </span>
154
+ ))}
155
+ </div>
156
+ )}
157
+ </div>
158
+ </div>
159
+ );
160
+ }
@@ -112,7 +112,7 @@ export default function TimelinePanel({
112
112
  total = 0,
113
113
  frame = 0,
114
114
  playing = false,
115
- loop = true,
115
+ loop = false,
116
116
  onSeek,
117
117
  onPlay,
118
118
  onPause,
@@ -145,6 +145,17 @@ export default function TimelinePanel({
145
145
  const clamped = clamp(Math.round(frame), 0, totalFrames - 1);
146
146
  const trackRef = useRef(null);
147
147
  const draggingRef = useRef(false);
148
+ const rootRef = useRef(null);
149
+ // Focus the dock the moment it opens. The transport shortcuts (Space play,
150
+ // arrow frame-step) live on the SHELL window, so they only fire when focus is
151
+ // in the shell — but opening the Timeline via ⌘⇧T while focus sat in the
152
+ // canvas iframe (the common case after any text/element interaction) used to
153
+ // leave focus stuck in the iframe, so Space/arrows silently did nothing.
154
+ // Pulling focus here on mount (mount === open, the panel is conditionally
155
+ // rendered) hands the transport keys a shell focus target immediately.
156
+ useEffect(() => {
157
+ rootRef.current?.focus();
158
+ }, []);
148
159
  // Drag-to-retime a sequence's duration: { index, startX, startDur, rowW, curDur }.
149
160
  const [retimeDrag, setRetimeDrag] = useState(null);
150
161
  // Drag the block body to move a clip's `from`: { index, startX, startFrom, rowW, curFrom }.
@@ -337,8 +348,10 @@ export default function TimelinePanel({
337
348
 
338
349
  return (
339
350
  <aside
351
+ ref={rootRef}
352
+ tabIndex={-1}
340
353
  className={`tl-panel${resizing ? ' is-resizing' : ''}${dropActive ? ' is-drop' : ''}`}
341
- style={height ? { height } : undefined}
354
+ style={{ ...(height ? { height } : {}), outline: 'none' }}
342
355
  onDragOver={(e) => {
343
356
  if (!onDropMedia || !hasFiles(e.dataTransfer)) return;
344
357
  e.preventDefault();