@1agh/maude 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/apps/studio/.ai/cache/_stats.json +7 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +12 -2
  3. package/apps/studio/annotations-layer.tsx +1055 -278
  4. package/apps/studio/annotations-model.ts +58 -0
  5. package/apps/studio/api.ts +173 -63
  6. package/apps/studio/bin/_html-playwright.mjs +26 -4
  7. package/apps/studio/bin/_ingest-footage.mjs +386 -0
  8. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  9. package/apps/studio/bin/_png-playwright.mjs +15 -2
  10. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  11. package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/_video-playwright.mjs +141 -71
  15. package/apps/studio/bin/ingest-footage.sh +27 -0
  16. package/apps/studio/bin/photo-adjust.sh +163 -0
  17. package/apps/studio/bin/photo-bg-remove.sh +160 -0
  18. package/apps/studio/bin/probe-footage.sh +28 -0
  19. package/apps/studio/bin/read-annotations.mjs +125 -2
  20. package/apps/studio/bin/screenshot.sh +53 -4
  21. package/apps/studio/canvas-edit.ts +459 -10
  22. package/apps/studio/canvas-lib.tsx +523 -5
  23. package/apps/studio/canvas-pipeline.ts +65 -7
  24. package/apps/studio/canvas-shell.tsx +156 -16
  25. package/apps/studio/client/app.jsx +476 -88
  26. package/apps/studio/client/export-center.jsx +460 -0
  27. package/apps/studio/client/github.js +4 -0
  28. package/apps/studio/client/panels/GitPanel.jsx +8 -0
  29. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
  30. package/apps/studio/client/panels/StickerPicker.jsx +160 -0
  31. package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
  32. package/apps/studio/client/photo-knobs.jsx +437 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +59 -0
  34. package/apps/studio/client/styles/4-components.css +150 -0
  35. package/apps/studio/commands/edit-source-command.ts +31 -4
  36. package/apps/studio/config.schema.json +2 -2
  37. package/apps/studio/context-menu.tsx +31 -6
  38. package/apps/studio/dist/client.bundle.js +5561 -19
  39. package/apps/studio/dist/comment-mount.js +2 -2
  40. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  41. package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
  42. package/apps/studio/dist/runtime/pixi-js.js +519 -519
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/dom-selection.ts +25 -0
  45. package/apps/studio/export-dialog.tsx +18 -25
  46. package/apps/studio/exporters/_runtime.ts +104 -0
  47. package/apps/studio/exporters/html.ts +12 -20
  48. package/apps/studio/exporters/index.ts +14 -2
  49. package/apps/studio/exporters/jobs.ts +376 -0
  50. package/apps/studio/exporters/pdf.ts +16 -20
  51. package/apps/studio/exporters/png.ts +12 -20
  52. package/apps/studio/exporters/pptx.ts +22 -23
  53. package/apps/studio/exporters/scope.ts +1 -0
  54. package/apps/studio/exporters/svg.ts +14 -22
  55. package/apps/studio/exporters/video.ts +37 -19
  56. package/apps/studio/footage/schema.test.ts +195 -0
  57. package/apps/studio/footage/schema.ts +484 -0
  58. package/apps/studio/footage-store.ts +235 -0
  59. package/apps/studio/fs-watch.ts +10 -0
  60. package/apps/studio/git/endpoints.ts +68 -7
  61. package/apps/studio/git/service.ts +198 -20
  62. package/apps/studio/github/service.ts +67 -0
  63. package/apps/studio/handoff.ts +3 -2
  64. package/apps/studio/http.ts +318 -64
  65. package/apps/studio/input-router.tsx +10 -0
  66. package/apps/studio/inspect.ts +53 -1
  67. package/apps/studio/paths.ts +10 -0
  68. package/apps/studio/photo/filters.ts +285 -0
  69. package/apps/studio/photo/pipeline.ts +607 -0
  70. package/apps/studio/photo/schema.ts +367 -0
  71. package/apps/studio/photo-store.ts +154 -0
  72. package/apps/studio/runtime-bundle.ts +29 -6
  73. package/apps/studio/server.ts +8 -2
  74. package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
  75. package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
  76. package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
  77. package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
  78. package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
  79. package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
  80. package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
  81. package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
  82. package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
  83. package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
  84. package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
  85. package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
  86. package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
  87. package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
  88. package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
  89. package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
  90. package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
  91. package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
  92. package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
  93. package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
  94. package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
  95. package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
  96. package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
  97. package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
  98. package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
  99. package/apps/studio/stickers/life-style/best.png +0 -0
  100. package/apps/studio/stickers/life-style/come-on.png +0 -0
  101. package/apps/studio/stickers/life-style/cut.png +0 -0
  102. package/apps/studio/stickers/life-style/detox.png +0 -0
  103. package/apps/studio/stickers/life-style/just.png +0 -0
  104. package/apps/studio/stickers/life-style/manifest.json +68 -0
  105. package/apps/studio/stickers/life-style/mirror.png +0 -0
  106. package/apps/studio/stickers/life-style/nice.png +0 -0
  107. package/apps/studio/stickers/life-style/objects.png +0 -0
  108. package/apps/studio/stickers/life-style/ok.png +0 -0
  109. package/apps/studio/stickers/life-style/queen.png +0 -0
  110. package/apps/studio/stickers/life-style/star.png +0 -0
  111. package/apps/studio/stickers/life-style/sun.png +0 -0
  112. package/apps/studio/stickers/life-style/water.png +0 -0
  113. package/apps/studio/stickers/life-style/yeah.png +0 -0
  114. package/apps/studio/stickers/life-style/you-can.png +0 -0
  115. package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
  116. package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
  117. package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
  118. package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
  119. package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
  120. package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
  121. package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
  122. package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
  123. package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
  124. package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
  125. package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
  126. package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
  127. package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
  128. package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
  129. package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
  130. package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
  131. package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
  132. package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
  133. package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
  134. package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
  135. package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
  136. package/apps/studio/stickers/project-status/group-100.png +0 -0
  137. package/apps/studio/stickers/project-status/group-101.png +0 -0
  138. package/apps/studio/stickers/project-status/group-102.png +0 -0
  139. package/apps/studio/stickers/project-status/group-103.png +0 -0
  140. package/apps/studio/stickers/project-status/group-104.png +0 -0
  141. package/apps/studio/stickers/project-status/group-105.png +0 -0
  142. package/apps/studio/stickers/project-status/group-106.png +0 -0
  143. package/apps/studio/stickers/project-status/group-107.png +0 -0
  144. package/apps/studio/stickers/project-status/group-108.png +0 -0
  145. package/apps/studio/stickers/project-status/group-109.png +0 -0
  146. package/apps/studio/stickers/project-status/group-110.png +0 -0
  147. package/apps/studio/stickers/project-status/group-111.png +0 -0
  148. package/apps/studio/stickers/project-status/group-112.png +0 -0
  149. package/apps/studio/stickers/project-status/group-113.png +0 -0
  150. package/apps/studio/stickers/project-status/group-114.png +0 -0
  151. package/apps/studio/stickers/project-status/group-115.png +0 -0
  152. package/apps/studio/stickers/project-status/group-117.png +0 -0
  153. package/apps/studio/stickers/project-status/group-118.png +0 -0
  154. package/apps/studio/stickers/project-status/group-119.png +0 -0
  155. package/apps/studio/stickers/project-status/group-120.png +0 -0
  156. package/apps/studio/stickers/project-status/group-121.png +0 -0
  157. package/apps/studio/stickers/project-status/group-122.png +0 -0
  158. package/apps/studio/stickers/project-status/group-41.png +0 -0
  159. package/apps/studio/stickers/project-status/group-42.png +0 -0
  160. package/apps/studio/stickers/project-status/group-43.png +0 -0
  161. package/apps/studio/stickers/project-status/group-44.png +0 -0
  162. package/apps/studio/stickers/project-status/group-45.png +0 -0
  163. package/apps/studio/stickers/project-status/group-46.png +0 -0
  164. package/apps/studio/stickers/project-status/group-47.png +0 -0
  165. package/apps/studio/stickers/project-status/group-48.png +0 -0
  166. package/apps/studio/stickers/project-status/group-49.png +0 -0
  167. package/apps/studio/stickers/project-status/group-50.png +0 -0
  168. package/apps/studio/stickers/project-status/group-51.png +0 -0
  169. package/apps/studio/stickers/project-status/group-52.png +0 -0
  170. package/apps/studio/stickers/project-status/group-53.png +0 -0
  171. package/apps/studio/stickers/project-status/group-54.png +0 -0
  172. package/apps/studio/stickers/project-status/group-55.png +0 -0
  173. package/apps/studio/stickers/project-status/group-56.png +0 -0
  174. package/apps/studio/stickers/project-status/group-57.png +0 -0
  175. package/apps/studio/stickers/project-status/group-58.png +0 -0
  176. package/apps/studio/stickers/project-status/group-59.png +0 -0
  177. package/apps/studio/stickers/project-status/group-60.png +0 -0
  178. package/apps/studio/stickers/project-status/group-61.png +0 -0
  179. package/apps/studio/stickers/project-status/group-62.png +0 -0
  180. package/apps/studio/stickers/project-status/group-63.png +0 -0
  181. package/apps/studio/stickers/project-status/group-64.png +0 -0
  182. package/apps/studio/stickers/project-status/group-65.png +0 -0
  183. package/apps/studio/stickers/project-status/group-66.png +0 -0
  184. package/apps/studio/stickers/project-status/group-67.png +0 -0
  185. package/apps/studio/stickers/project-status/group-68.png +0 -0
  186. package/apps/studio/stickers/project-status/group-69.png +0 -0
  187. package/apps/studio/stickers/project-status/group-70.png +0 -0
  188. package/apps/studio/stickers/project-status/group-71.png +0 -0
  189. package/apps/studio/stickers/project-status/group-72.png +0 -0
  190. package/apps/studio/stickers/project-status/group-73.png +0 -0
  191. package/apps/studio/stickers/project-status/group-74.png +0 -0
  192. package/apps/studio/stickers/project-status/group-75.png +0 -0
  193. package/apps/studio/stickers/project-status/group-76.png +0 -0
  194. package/apps/studio/stickers/project-status/group-77.png +0 -0
  195. package/apps/studio/stickers/project-status/group-78.png +0 -0
  196. package/apps/studio/stickers/project-status/group-79.png +0 -0
  197. package/apps/studio/stickers/project-status/group-80.png +0 -0
  198. package/apps/studio/stickers/project-status/group-81.png +0 -0
  199. package/apps/studio/stickers/project-status/group-82.png +0 -0
  200. package/apps/studio/stickers/project-status/group-83.png +0 -0
  201. package/apps/studio/stickers/project-status/group-84.png +0 -0
  202. package/apps/studio/stickers/project-status/group-85.png +0 -0
  203. package/apps/studio/stickers/project-status/group-86.png +0 -0
  204. package/apps/studio/stickers/project-status/group-87.png +0 -0
  205. package/apps/studio/stickers/project-status/group-88.png +0 -0
  206. package/apps/studio/stickers/project-status/group-89.png +0 -0
  207. package/apps/studio/stickers/project-status/group-90.png +0 -0
  208. package/apps/studio/stickers/project-status/group-91.png +0 -0
  209. package/apps/studio/stickers/project-status/group-92.png +0 -0
  210. package/apps/studio/stickers/project-status/group-93.png +0 -0
  211. package/apps/studio/stickers/project-status/group-94.png +0 -0
  212. package/apps/studio/stickers/project-status/group-96.png +0 -0
  213. package/apps/studio/stickers/project-status/group-97.png +0 -0
  214. package/apps/studio/stickers/project-status/group-98.png +0 -0
  215. package/apps/studio/stickers/project-status/group-99.png +0 -0
  216. package/apps/studio/stickers/project-status/manifest.json +328 -0
  217. package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
  218. package/apps/studio/test/acp-commands.test.ts +2 -1
  219. package/apps/studio/test/asset-api.test.ts +3 -3
  220. package/apps/studio/test/canvas-edit.test.ts +17 -6
  221. package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
  222. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  223. package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
  224. package/apps/studio/test/edit-source-command.test.ts +23 -0
  225. package/apps/studio/test/element-structural-edit.test.ts +46 -0
  226. package/apps/studio/test/export-center.test.tsx +287 -0
  227. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  228. package/apps/studio/test/exporters/history.test.ts +32 -3
  229. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  230. package/apps/studio/test/footage-store.test.ts +100 -0
  231. package/apps/studio/test/git-branches.test.ts +97 -0
  232. package/apps/studio/test/github-api.test.ts +56 -1
  233. package/apps/studio/test/input-router.test.ts +33 -0
  234. package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
  235. package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
  236. package/apps/studio/test/photo-edit-api.test.ts +201 -0
  237. package/apps/studio/test/photo-filters.test.ts +149 -0
  238. package/apps/studio/test/photo-pipeline.test.ts +106 -0
  239. package/apps/studio/test/photo-taxonomy.test.ts +96 -0
  240. package/apps/studio/test/read-annotations.test.ts +164 -0
  241. package/apps/studio/test/shell-importmap.test.ts +49 -0
  242. package/apps/studio/test/text-editability-stamp.test.ts +131 -0
  243. package/apps/studio/test/timeline-parse.test.ts +57 -0
  244. package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
  245. package/apps/studio/test/undo-stack.test.ts +33 -0
  246. package/apps/studio/test/video-asset.test.ts +5 -5
  247. package/apps/studio/test/video-render-bridge.test.ts +23 -1
  248. package/apps/studio/text-caret.ts +239 -0
  249. package/apps/studio/tool-palette.tsx +43 -20
  250. package/apps/studio/undo-stack.ts +94 -2
  251. package/apps/studio/use-annotation-resize.tsx +4 -4
  252. package/apps/studio/use-canvas-media-drop.tsx +38 -1
  253. package/apps/studio/use-selection-set.tsx +21 -0
  254. package/apps/studio/video-comp.tsx +48 -7
  255. package/apps/studio/whats-new.json +70 -0
  256. package/apps/studio/ws.ts +6 -0
  257. package/cli/commands/design.mjs +34 -3
  258. package/cli/lib/gitignore-block.mjs +2 -0
  259. package/package.json +10 -10
  260. package/plugins/design/templates/_shell.html +1 -0
@@ -0,0 +1,460 @@
1
+ // export-center.jsx — background export notification center
2
+ // (feature-background-export-notification-center).
3
+ //
4
+ // useExportCenter() → headless handle (hydrate + WS upsert + toast/panel state)
5
+ // <ExportBadge> → menubar entry point (busy pulse + running/queued count)
6
+ // <ExportToast> → completion notice (bottom-left, mirrors WhatsNewToast)
7
+ // <ExportPanel> → job list (reuses the help-modal backdrop)
8
+ //
9
+ // Structurally a near-exact twin of whats-new.jsx, but driven by live WS
10
+ // `export:job` pushes (via `upsert`, called from app.jsx's WS message handler)
11
+ // instead of a static feed. Styling lives in client/styles/3-shell-maude.css
12
+ // (`.st-exports`) and 4-components.css (`.st-export-*`).
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { isNativeApp, saveExport } from './github.js';
16
+
17
+ const FORMAT_LABELS = {
18
+ png: 'PNG',
19
+ pdf: 'PDF',
20
+ svg: 'SVG',
21
+ html: 'HTML',
22
+ pptx: 'PPTX',
23
+ canva: 'Canva',
24
+ zip: 'ZIP',
25
+ mp4: 'MP4',
26
+ webm: 'WebM',
27
+ gif: 'GIF',
28
+ };
29
+ const SCOPE_LABELS = {
30
+ selection: 'Selection',
31
+ artboard: 'Artboard',
32
+ 'canvas-as-separate': 'Canvas → separate',
33
+ 'project-raw': 'Project (raw)',
34
+ };
35
+
36
+ async function fetchJobBytes(id) {
37
+ const r = await fetch(`/_api/export-jobs/download?id=${encodeURIComponent(id)}`);
38
+ if (!r.ok) return null;
39
+ const disp = r.headers.get('Content-Disposition') || '';
40
+ const filename = /filename="([^"]+)"/.exec(disp)?.[1] || null;
41
+ const blob = await r.blob();
42
+ return { blob, filename };
43
+ }
44
+
45
+ async function autoDownloadBlob(id, fallbackName) {
46
+ const got = await fetchJobBytes(id);
47
+ if (!got) return;
48
+ const filename = got.filename || fallbackName || 'export';
49
+ const url = URL.createObjectURL(got.blob);
50
+ const a = document.createElement('a');
51
+ a.href = url;
52
+ a.download = filename;
53
+ document.body.appendChild(a);
54
+ a.click();
55
+ a.remove();
56
+ URL.revokeObjectURL(url);
57
+ }
58
+
59
+ async function saveNative(id, fallbackName) {
60
+ const got = await fetchJobBytes(id);
61
+ if (!got) return null;
62
+ const filename = got.filename || fallbackName || 'export';
63
+ const bytes = Array.from(new Uint8Array(await got.blob.arrayBuffer()));
64
+ return saveExport(filename, bytes);
65
+ }
66
+
67
+ export function useExportCenter() {
68
+ const [jobs, setJobs] = useState(() => new Map());
69
+ const [panelOpen, setPanelOpen] = useState(false);
70
+ const [toastQueue, setToastQueue] = useState([]);
71
+ const [savingIds, setSavingIds] = useState(() => new Set());
72
+ const prevStatusRef = useRef(new Map());
73
+ // Dedup guards (not state — re-rendering on these changing would be
74
+ // pointless; they only gate one-shot side effects).
75
+ const finalizedRef = useRef(new Set()); // auto-download fires at most once per job
76
+ const startToastShownRef = useRef(new Set()); // "Exporting…" toast fires at most once per job
77
+
78
+ useEffect(() => {
79
+ let alive = true;
80
+ fetch('/_api/export-jobs')
81
+ .then((r) => r.json())
82
+ .then((j) => {
83
+ if (!alive) return;
84
+ const m = new Map();
85
+ for (const job of Array.isArray(j?.jobs) ? j.jobs : []) {
86
+ m.set(job.id, job);
87
+ // Hydration is a SNAPSHOT, not a live transition — seed both guards
88
+ // so a page (re)load never replays a toast / auto-download for a
89
+ // job that already reached this status before we opened.
90
+ prevStatusRef.current.set(job.id, job.status);
91
+ startToastShownRef.current.add(job.id);
92
+ }
93
+ setJobs(m);
94
+ })
95
+ .catch(() => {
96
+ /* offline / restart — the badge just stays empty until a push arrives */
97
+ });
98
+ return () => {
99
+ alive = false;
100
+ };
101
+ }, []);
102
+
103
+ const upsert = useCallback((job) => {
104
+ if (!job || typeof job.id !== 'string') return;
105
+ setJobs((prev) => {
106
+ const next = new Map(prev);
107
+ next.set(job.id, job);
108
+ return next;
109
+ });
110
+ }, []);
111
+
112
+ // One toast per job, morphing in place as the job's live status changes
113
+ // (queued/running → done/failed) rather than stacking a separate "started"
114
+ // and "finished" toast. Detects two kinds of transitions:
115
+ // - first time a job is seen active (queued/running) → surfaces it so the
116
+ // user knows it's running in the background right after the dialog closes.
117
+ // - a done/failed transition → surfaces it (or refreshes it in place if
118
+ // it's still the front-of-queue toast from the "started" step above).
119
+ useEffect(() => {
120
+ const prevStatus = prevStatusRef.current;
121
+ const toSurface = [];
122
+ const justFinished = [];
123
+ // Single pass — read `was` for every job BEFORE prevStatus gets mutated
124
+ // below, so both the toast + auto-download decisions see the real
125
+ // "did this change just now" transition, not an already-updated ref.
126
+ for (const job of jobs.values()) {
127
+ const was = prevStatus.get(job.id);
128
+ const isActive = job.status === 'queued' || job.status === 'running';
129
+ const isFinished = job.status === 'done' || job.status === 'failed';
130
+ if (was === undefined && isActive && !startToastShownRef.current.has(job.id)) {
131
+ startToastShownRef.current.add(job.id);
132
+ toSurface.push(job.id);
133
+ } else if (was !== job.status && isFinished) {
134
+ toSurface.push(job.id);
135
+ justFinished.push(job);
136
+ }
137
+ }
138
+ for (const job of jobs.values()) prevStatus.set(job.id, job.status);
139
+
140
+ if (toSurface.length) {
141
+ setToastQueue((q) => {
142
+ const merged = q.filter((id) => !toSurface.includes(id));
143
+ return [...toSurface, ...merged].slice(0, 8);
144
+ });
145
+ }
146
+
147
+ // Native (Tauri) never auto-pops the OS save dialog — it would interrupt
148
+ // whatever the user is doing elsewhere. Always an explicit "Save…" click.
149
+ if (isNativeApp() || !justFinished.length) return;
150
+ for (const job of justFinished) {
151
+ if (job.status !== 'done') continue;
152
+ if (finalizedRef.current.has(job.id)) continue;
153
+ // Focus-gated: avoids duplicate downloads when multiple tabs share one
154
+ // dev-server and both receive the same WS push.
155
+ if (!document.hasFocus()) continue;
156
+ finalizedRef.current.add(job.id);
157
+ void autoDownloadBlob(job.id, job.filename);
158
+ }
159
+ }, [jobs]);
160
+
161
+ const list = useMemo(
162
+ () =>
163
+ Array.from(jobs.values()).sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')),
164
+ [jobs]
165
+ );
166
+ const runningCount = list.filter((j) => j.status === 'running').length;
167
+ const queuedCount = list.filter((j) => j.status === 'queued').length;
168
+
169
+ const dismissToast = useCallback(() => setToastQueue((q) => q.slice(1)), []);
170
+ const openPanel = useCallback(() => setPanelOpen(true), []);
171
+ const closePanel = useCallback(() => setPanelOpen(false), []);
172
+
173
+ const withSaving = useCallback(async (job, fn) => {
174
+ setSavingIds((prev) => new Set(prev).add(job.id));
175
+ try {
176
+ await fn();
177
+ } finally {
178
+ setSavingIds((prev) => {
179
+ const next = new Set(prev);
180
+ next.delete(job.id);
181
+ return next;
182
+ });
183
+ }
184
+ }, []);
185
+
186
+ // Manual "Download" (web, unfocused-tab fallback) / "Save…" (native) — the
187
+ // toast/panel row action. Both mark the job finalized so the focus-gated
188
+ // auto-download effect above doesn't ALSO fire for it later, and both track
189
+ // a busy state (`savingIds`) so the button can show it's working instead of
190
+ // going silent while the bytes fetch + (for native) the OS dialog opens.
191
+ const download = useCallback(
192
+ async (job) => {
193
+ finalizedRef.current.add(job.id);
194
+ await withSaving(job, () => autoDownloadBlob(job.id, job.filename));
195
+ },
196
+ [withSaving]
197
+ );
198
+ const save = useCallback(
199
+ async (job) => {
200
+ finalizedRef.current.add(job.id);
201
+ await withSaving(job, () => saveNative(job.id, job.filename));
202
+ },
203
+ [withSaving]
204
+ );
205
+
206
+ const toastJobId = panelOpen ? null : (toastQueue[0] ?? null);
207
+
208
+ return {
209
+ jobs: list,
210
+ upsert,
211
+ runningCount,
212
+ queuedCount,
213
+ busyCount: runningCount + queuedCount,
214
+ panelOpen,
215
+ openPanel,
216
+ closePanel,
217
+ showToast: !!toastJobId,
218
+ toastJob: toastJobId ? (jobs.get(toastJobId) ?? null) : null,
219
+ dismissToast,
220
+ download,
221
+ save,
222
+ savingIds,
223
+ };
224
+ }
225
+
226
+ function jobLabel(job) {
227
+ const fmt = FORMAT_LABELS[job.format] || String(job.format || '').toUpperCase();
228
+ const scope = SCOPE_LABELS[job.scope] || job.scope;
229
+ return `${fmt} · ${scope}`;
230
+ }
231
+
232
+ // Native "Save…" opens the OS file picker; web "Download" streams a blob
233
+ // through <a download> — distinct verbs, so the busy label stays distinct too
234
+ // (the fetch-bytes step behind both can take several seconds on a large export).
235
+ function saveActionLabel(saving) {
236
+ if (isNativeApp()) return saving ? 'Saving…' : 'Save…';
237
+ return saving ? 'Downloading…' : 'Download';
238
+ }
239
+
240
+ function ProgressBar({ progress }) {
241
+ if (!progress || !progress.total) {
242
+ return <div className="st-export-progress st-export-progress--indeterminate" aria-hidden="true" />;
243
+ }
244
+ const pct = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
245
+ return (
246
+ <div
247
+ className="st-export-progress"
248
+ role="progressbar"
249
+ aria-valuenow={pct}
250
+ aria-valuemin={0}
251
+ aria-valuemax={100}
252
+ >
253
+ <div className="st-export-progress-bar" style={{ width: `${pct}%` }} />
254
+ </div>
255
+ );
256
+ }
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
+
286
+ function StatusPill({ job }) {
287
+ const statusText =
288
+ job.status === 'queued'
289
+ ? 'Queued'
290
+ : job.status === 'running'
291
+ ? job.progress?.total
292
+ ? `${job.progress.current} of ${job.progress.total}`
293
+ : 'Rendering…'
294
+ : job.status === 'done'
295
+ ? 'Ready'
296
+ : 'Failed';
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
+ );
304
+ }
305
+
306
+ export function ExportBadge({ center }) {
307
+ const busy = center.busyCount > 0;
308
+ return (
309
+ <button
310
+ type="button"
311
+ className="st-exports"
312
+ data-tour="exports"
313
+ data-busy={busy ? 'true' : 'false'}
314
+ aria-label={busy ? `Exports — ${center.busyCount} running` : 'Exports'}
315
+ data-tip="Exports"
316
+ onClick={center.openPanel}
317
+ >
318
+ <span aria-hidden="true">⬇</span>
319
+ {busy && (
320
+ <span className="st-exports__count" aria-hidden="true">
321
+ {center.busyCount}
322
+ </span>
323
+ )}
324
+ </button>
325
+ );
326
+ }
327
+
328
+ export function ExportToast({ center }) {
329
+ if (!center.showToast || !center.toastJob) return null;
330
+ const job = center.toastJob;
331
+ const pending = job.status === 'queued' || job.status === 'running';
332
+ const ok = job.status === 'done';
333
+ const saving = center.savingIds.has(job.id);
334
+ const finalize = () => {
335
+ if (isNativeApp()) void center.save(job);
336
+ else void center.download(job);
337
+ };
338
+ return (
339
+ <div className="st-toast" role="status" aria-live="polite">
340
+ <button type="button" className="st-toast-close" aria-label="Dismiss" onClick={center.dismissToast}>
341
+ ×
342
+ </button>
343
+ <div className="st-toast-hd">
344
+ <span aria-hidden="true">{pending ? '⋯' : ok ? '⬇' : '⚠'}</span>
345
+ {pending ? 'Exporting…' : ok ? 'Export ready' : 'Export failed'}
346
+ </div>
347
+ <div className="st-toast-title">{jobLabel(job)}</div>
348
+ {pending ? (
349
+ <>
350
+ <div className="st-toast-txt">Running in the background — you can keep working.</div>
351
+ <ProgressBar progress={job.progress} />
352
+ </>
353
+ ) : (
354
+ <div className="st-toast-txt">
355
+ {ok ? (job.filename ?? 'Ready to download.') : (job.error ?? 'Something went wrong.')}
356
+ </div>
357
+ )}
358
+ {ok && (
359
+ <div className="st-toast-actions">
360
+ <button
361
+ type="button"
362
+ className="btn btn--primary btn--sm"
363
+ onClick={finalize}
364
+ disabled={saving}
365
+ >
366
+ {saving && <span className="st-btn-spin" aria-hidden="true" />}
367
+ {saveActionLabel(saving)}
368
+ </button>
369
+ <button type="button" className="btn btn--ghost btn--sm" onClick={center.dismissToast}>
370
+ Dismiss
371
+ </button>
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ export function ExportPanel({ center }) {
379
+ const { panelOpen, closePanel } = center;
380
+ useEffect(() => {
381
+ if (!panelOpen) return;
382
+ function onKey(e) {
383
+ if (e.key === 'Escape') closePanel();
384
+ }
385
+ window.addEventListener('keydown', onKey);
386
+ return () => window.removeEventListener('keydown', onKey);
387
+ }, [panelOpen, closePanel]);
388
+
389
+ if (!panelOpen) return null;
390
+ return (
391
+ <div
392
+ className="help-modal-backdrop"
393
+ role="presentation"
394
+ onMouseDown={(e) => {
395
+ if (e.target === e.currentTarget) closePanel();
396
+ }}
397
+ >
398
+ <div
399
+ className="st-export-panel"
400
+ role="dialog"
401
+ aria-modal="true"
402
+ aria-labelledby="st-export-panel-title"
403
+ >
404
+ <header className="help-modal-hd">
405
+ <span className="title" id="st-export-panel-title">
406
+ Exports
407
+ </span>
408
+ <button
409
+ type="button"
410
+ className="help-modal-close"
411
+ aria-label="Close (Esc)"
412
+ onClick={closePanel}
413
+ >
414
+ ×
415
+ </button>
416
+ </header>
417
+ <div className="st-export-panel__body">
418
+ {center.jobs.length === 0 ? (
419
+ <p className="mdcc-wn-empty">No exports yet.</p>
420
+ ) : (
421
+ <ul className="st-export-list">
422
+ {center.jobs.map((job) => (
423
+ <li key={job.id} className="st-export-item">
424
+ <div className="st-export-item__hd">
425
+ <span className="st-export-item__label">{jobLabel(job)}</span>
426
+ <StatusPill job={job} />
427
+ </div>
428
+ {(job.status === 'queued' || job.status === 'running') && (
429
+ <ProgressBar progress={job.progress} />
430
+ )}
431
+ {job.status === 'done' && (
432
+ <div className="st-export-item__ft">
433
+ <span className="st-export-item__filename">{job.filename}</span>
434
+ <button
435
+ type="button"
436
+ className="btn btn--ghost btn--sm"
437
+ disabled={center.savingIds.has(job.id)}
438
+ onClick={() => (isNativeApp() ? center.save(job) : center.download(job))}
439
+ >
440
+ {center.savingIds.has(job.id) && (
441
+ <span className="st-btn-spin" aria-hidden="true" />
442
+ )}
443
+ {saveActionLabel(center.savingIds.has(job.id))}
444
+ </button>
445
+ </div>
446
+ )}
447
+ {job.status === 'failed' && (
448
+ <div className="st-export-item__ft">
449
+ <span className="st-export-item__error">{job.error ?? 'Export failed.'}</span>
450
+ </div>
451
+ )}
452
+ </li>
453
+ ))}
454
+ </ul>
455
+ )}
456
+ </div>
457
+ </div>
458
+ </div>
459
+ );
460
+ }
@@ -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 {