@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,437 @@
1
+ // photo-knobs.jsx — the Inspector "Photo" tab body (feature-photo-editor, Task 15).
2
+ //
3
+ // A self-contained editor for a PhotoEdit sidecar. Unlike CssKnobs (whose write
4
+ // path is /_api/edit-css keyed on a source `data-cd-id`), PhotoKnobs writes the
5
+ // non-destructive `assets/<sha8>.photo.json` sidecar through /_api/photo-edit —
6
+ // so it works for BOTH an artboard `<img>` and an annotation ImageStroke (both
7
+ // carry an `assets/<sha8>.<ext>` source), neither of which CssKnobs can express.
8
+ //
9
+ // Runs on the MAIN origin (the studio shell), so inline styles are fine
10
+ // (style-src 'self' 'unsafe-inline'). Renders the controls; the live pixel
11
+ // preview happens in the canvas iframe's <PhotoLayer>, driven by `onEdit`
12
+ // (app.jsx broadcasts the updated edit down to the canvas). Reuses the shell's
13
+ // HSV `ColorPicker` when injected; falls back to a native color input.
14
+ //
15
+ // Sections + ranges mirror photo/schema.ts exactly:
16
+ // Adjustments (brightness/contrast/saturation/exposure −1..1, hue −180..180,
17
+ // sepia/grayscale/invert 0..1) · Duotone · Grain · Pattern · Mask · Background.
18
+
19
+ import { useCallback, useEffect, useRef, useState } from 'react';
20
+
21
+ const PATTERN_TYPES = ['dots', 'grid', 'lines', 'diagonal', 'crosshatch'];
22
+ const PATTERN_BLENDS = ['normal', 'multiply', 'screen', 'overlay', 'soft-light'];
23
+ const MASK_PRESETS = ['none', 'vignette', 'radial-reveal', 'edge-fade'];
24
+
25
+ const ADJUSTMENTS = [
26
+ { key: 'brightness', label: 'Brightness', min: -1, max: 1 },
27
+ { key: 'contrast', label: 'Contrast', min: -1, max: 1 },
28
+ { key: 'saturation', label: 'Saturation', min: -1, max: 1 },
29
+ { key: 'exposure', label: 'Exposure', min: -1, max: 1 },
30
+ { key: 'hue', label: 'Hue', min: -180, max: 180, step: 1, unit: '°' },
31
+ { key: 'sepia', label: 'Sepia', min: 0, max: 1 },
32
+ { key: 'grayscale', label: 'Grayscale', min: 0, max: 1 },
33
+ { key: 'invert', label: 'Invert', min: 0, max: 1 },
34
+ ];
35
+
36
+ // Deep-clone a small plain edit object (structuredClone is available in the shell).
37
+ const clone = (o) => (o ? JSON.parse(JSON.stringify(o)) : {});
38
+
39
+ /** Prune empty sub-objects so a neutral edit serializes back to `{}`. */
40
+ function prune(edit) {
41
+ const out = {};
42
+ for (const [k, v] of Object.entries(edit)) {
43
+ if (v == null) continue;
44
+ if (typeof v === 'object' && !Array.isArray(v)) {
45
+ const inner = {};
46
+ for (const [ik, iv] of Object.entries(v)) if (iv != null && iv !== '') inner[ik] = iv;
47
+ if (Object.keys(inner).length) out[k] = inner;
48
+ } else {
49
+ out[k] = v;
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // ── small inline-styled primitives ──────────────────────────────────────────
56
+
57
+ // Theme tokens — the REAL shell tokens (client/styles/1-tokens.css), defined
58
+ // for both `:root`/`[data-theme="light"]` and `[data-theme="dark"]`. Earlier
59
+ // this used invented `--st-*` names (--st-fg/--st-border/--st-btn-bg/--st-mono)
60
+ // that were never actually declared anywhere in the stylesheet, so every rule
61
+ // silently fell back to its hardcoded dark-mode default — the panel looked
62
+ // fine in dark mode purely by coincidence and was low-contrast/washed out in
63
+ // light mode.
64
+ const S = {
65
+ body: { padding: '10px 12px', fontSize: 12, color: 'var(--fg-0)', overflowY: 'auto' },
66
+ sec: { marginBottom: 14 },
67
+ secHead: {
68
+ display: 'flex',
69
+ alignItems: 'center',
70
+ justifyContent: 'space-between',
71
+ fontSize: 11,
72
+ textTransform: 'uppercase',
73
+ letterSpacing: '.06em',
74
+ color: 'var(--fg-2)',
75
+ margin: '4px 0 8px',
76
+ },
77
+ row: { display: 'flex', alignItems: 'center', gap: 8, margin: '5px 0' },
78
+ label: { flex: '0 0 78px', fontSize: 11, color: 'var(--fg-1)' },
79
+ range: { flex: 1, minWidth: 0 },
80
+ num: {
81
+ flex: '0 0 42px',
82
+ textAlign: 'right',
83
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
84
+ fontSize: 11,
85
+ color: 'var(--fg-2)',
86
+ },
87
+ btn: {
88
+ font: 'inherit',
89
+ fontSize: 11,
90
+ padding: '5px 10px',
91
+ borderRadius: 6,
92
+ border: '1px solid var(--border-default)',
93
+ background: 'var(--bg-3)',
94
+ color: 'var(--fg-0)',
95
+ cursor: 'pointer',
96
+ },
97
+ reset: {
98
+ font: 'inherit',
99
+ fontSize: 10,
100
+ padding: '1px 6px',
101
+ borderRadius: 4,
102
+ border: '1px solid transparent',
103
+ background: 'transparent',
104
+ color: 'var(--fg-2)',
105
+ cursor: 'pointer',
106
+ },
107
+ select: {
108
+ font: 'inherit',
109
+ fontSize: 11,
110
+ padding: '3px 6px',
111
+ borderRadius: 5,
112
+ border: '1px solid var(--border-default)',
113
+ background: 'var(--bg-3)',
114
+ color: 'var(--fg-0)',
115
+ },
116
+ };
117
+
118
+ function Section({ title, right, children }) {
119
+ return (
120
+ <section style={S.sec}>
121
+ <div style={S.secHead}>
122
+ <span>{title}</span>
123
+ {right}
124
+ </div>
125
+ {children}
126
+ </section>
127
+ );
128
+ }
129
+
130
+ function Slider({ label, value, min, max, step = 0.01, unit = '', onChange, onCommit }) {
131
+ const v = value ?? (min < 0 ? 0 : min);
132
+ return (
133
+ <div style={S.row}>
134
+ <span style={S.label}>{label}</span>
135
+ <input
136
+ type="range"
137
+ style={S.range}
138
+ min={min}
139
+ max={max}
140
+ step={step}
141
+ value={v}
142
+ onChange={(e) => onChange(Number(e.target.value))}
143
+ onPointerUp={onCommit}
144
+ onKeyUp={onCommit}
145
+ aria-label={label}
146
+ />
147
+ <span style={S.num}>
148
+ {step >= 1 ? Math.round(v) : v.toFixed(2)}
149
+ {unit}
150
+ </span>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function Toggle({ checked, onChange, label }) {
156
+ return (
157
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, cursor: 'pointer' }}>
158
+ <input type="checkbox" checked={!!checked} onChange={(e) => onChange(e.target.checked)} />
159
+ {label}
160
+ </label>
161
+ );
162
+ }
163
+
164
+ function ColorSwatch({ value, fallback, ColorPicker, onApply }) {
165
+ const hex = value || fallback;
166
+ if (ColorPicker) return <ColorPicker seed={hex} onApply={onApply} />;
167
+ return (
168
+ <input
169
+ type="color"
170
+ value={hex}
171
+ onChange={(e) => onApply(e.target.value)}
172
+ style={{ width: 28, height: 20, padding: 0, border: 'none', background: 'none' }}
173
+ aria-label="color"
174
+ />
175
+ );
176
+ }
177
+
178
+ // ── the panel ────────────────────────────────────────────────────────────────
179
+
180
+ /**
181
+ * @param {object} p
182
+ * @param {string} p.asset `assets/<sha8>.<ext>` source path.
183
+ * @param {object} [p.initialEdit] Seed PhotoEdit (else fetched on mount).
184
+ * @param {Function} [p.ColorPicker] Injected HSV picker component (optional).
185
+ * @param {Function} [p.onEdit] (edit) => void — live-preview broadcast.
186
+ * @param {Function} [p.onRemoveBackground] async (asset) => { maskAsset } | null.
187
+ * @param {Function} [p.onRecordEdit] (before, after) => void — undo integration.
188
+ */
189
+ export function PhotoKnobs({ asset, initialEdit, ColorPicker, onEdit, onRemoveBackground, onRecordEdit }) {
190
+ const [edit, setEditState] = useState(() => clone(initialEdit));
191
+ const [loading, setLoading] = useState(!initialEdit);
192
+ const [saveState, setSaveState] = useState('idle'); // idle | saving | saved | error
193
+ const [bgBusy, setBgBusy] = useState(false);
194
+ const putTimer = useRef(null);
195
+ const editRef = useRef(edit);
196
+ editRef.current = edit;
197
+
198
+ // Hydrate from the sidecar unless seeded.
199
+ useEffect(() => {
200
+ if (initialEdit || !asset) return;
201
+ let dead = false;
202
+ setLoading(true);
203
+ fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
204
+ .then((r) => (r.ok ? r.json() : {}))
205
+ .then((j) => {
206
+ if (dead) return;
207
+ setEditState(j && typeof j === 'object' ? j : {});
208
+ setLoading(false);
209
+ })
210
+ .catch(() => !dead && setLoading(false));
211
+ return () => {
212
+ dead = true;
213
+ };
214
+ }, [asset, initialEdit]);
215
+
216
+ const put = useCallback(
217
+ (next) => {
218
+ clearTimeout(putTimer.current);
219
+ putTimer.current = setTimeout(async () => {
220
+ setSaveState('saving');
221
+ try {
222
+ const res = await fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`, {
223
+ method: 'PUT',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify(prune(next)),
226
+ });
227
+ setSaveState(res.ok ? 'saved' : 'error');
228
+ } catch {
229
+ setSaveState('error');
230
+ }
231
+ }, 160);
232
+ },
233
+ [asset]
234
+ );
235
+
236
+ // Apply a mutation, push the optimistic edit to the live preview, persist
237
+ // (debounced), and record undo (before → after).
238
+ const mutate = useCallback(
239
+ (fn, { commit = false } = {}) => {
240
+ const before = editRef.current;
241
+ const next = clone(before);
242
+ fn(next);
243
+ setEditState(next);
244
+ onEdit?.(next);
245
+ put(next);
246
+ if (commit) onRecordEdit?.(before, next);
247
+ },
248
+ [onEdit, put, onRecordEdit]
249
+ );
250
+
251
+ const setAdj = (key, value, commit) =>
252
+ mutate((e) => {
253
+ e.adjustments = e.adjustments || {};
254
+ if (value === 0 || value == null) delete e.adjustments[key];
255
+ else e.adjustments[key] = value;
256
+ if (Object.keys(e.adjustments).length === 0) delete e.adjustments;
257
+ }, { commit });
258
+
259
+ const setSection = (section, patch, commit) =>
260
+ mutate((e) => {
261
+ e[section] = { ...(e[section] || {}), ...patch };
262
+ }, { commit });
263
+
264
+ const resetAdjustments = () => mutate((e) => delete e.adjustments, { commit: true });
265
+
266
+ if (loading) return <div style={{ ...S.body, opacity: 0.6 }}>Loading photo edit…</div>;
267
+
268
+ const adj = edit.adjustments || {};
269
+ const duo = edit.duotone || {};
270
+ const grain = edit.grain || {};
271
+ const pat = edit.pattern || {};
272
+ const mask = edit.mask || {};
273
+ const bg = edit.backgroundRemoved || {};
274
+
275
+ return (
276
+ <div style={S.body} data-testid="photo-knobs">
277
+ <div style={{ ...S.secHead, marginTop: 0, color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0 }}>
278
+ <span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: 10 }}>{asset}</span>
279
+ <span style={{ fontSize: 10, color: saveState === 'error' ? 'var(--status-error)' : undefined }}>
280
+ {saveState === 'saving' ? 'saving…' : saveState === 'error' ? '⚠ save failed' : saveState === 'saved' ? 'saved' : ''}
281
+ </span>
282
+ </div>
283
+
284
+ <Section
285
+ title="Adjustments"
286
+ right={
287
+ <button type="button" style={S.reset} onClick={resetAdjustments} title="Reset adjustments">
288
+ reset
289
+ </button>
290
+ }
291
+ >
292
+ {ADJUSTMENTS.map((a) => (
293
+ <Slider
294
+ key={a.key}
295
+ label={a.label}
296
+ value={adj[a.key]}
297
+ min={a.min}
298
+ max={a.max}
299
+ step={a.step ?? 0.01}
300
+ unit={a.unit ?? ''}
301
+ onChange={(v) => setAdj(a.key, v)}
302
+ onCommit={() => setAdj(a.key, adj[a.key] ?? 0, true)}
303
+ />
304
+ ))}
305
+ </Section>
306
+
307
+ <Section
308
+ title="Duotone"
309
+ right={<Toggle checked={duo.enabled} onChange={(v) => setSection('duotone', { enabled: v }, true)} label="on" />}
310
+ >
311
+ {duo.enabled && (
312
+ <>
313
+ <div style={S.row}>
314
+ <span style={S.label}>Shadow</span>
315
+ <ColorSwatch value={duo.colorA} fallback="#111111" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorA: hex }, true)} />
316
+ <span style={S.label}>Highlight</span>
317
+ <ColorSwatch value={duo.colorB} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('duotone', { colorB: hex }, true)} />
318
+ </div>
319
+ <Slider label="Intensity" value={duo.intensity ?? 1} min={0} max={1} onChange={(v) => setSection('duotone', { intensity: v })} onCommit={() => setSection('duotone', { intensity: duo.intensity ?? 1 }, true)} />
320
+ </>
321
+ )}
322
+ </Section>
323
+
324
+ <Section
325
+ title="Grain"
326
+ right={<Toggle checked={grain.enabled} onChange={(v) => setSection('grain', { enabled: v }, true)} label="on" />}
327
+ >
328
+ {grain.enabled && (
329
+ <>
330
+ <Slider label="Amount" value={grain.amount ?? 0.4} min={0} max={1} onChange={(v) => setSection('grain', { amount: v })} onCommit={() => setSection('grain', { amount: grain.amount ?? 0.4 }, true)} />
331
+ <Slider label="Size" value={grain.size ?? 1} min={1} max={8} step={0.5} onChange={(v) => setSection('grain', { size: v })} onCommit={() => setSection('grain', { size: grain.size ?? 1 }, true)} />
332
+ </>
333
+ )}
334
+ </Section>
335
+
336
+ <Section
337
+ title="Pattern"
338
+ right={<Toggle checked={pat.enabled} onChange={(v) => setSection('pattern', { enabled: v }, true)} label="on" />}
339
+ >
340
+ {pat.enabled && (
341
+ <>
342
+ <div style={S.row}>
343
+ <span style={S.label}>Type</span>
344
+ <select style={S.select} value={pat.type || 'dots'} onChange={(e) => setSection('pattern', { type: e.target.value }, true)}>
345
+ {PATTERN_TYPES.map((t) => (
346
+ <option key={t} value={t}>
347
+ {t}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ <span style={S.label}>Blend</span>
352
+ <select style={S.select} value={pat.blend || 'normal'} onChange={(e) => setSection('pattern', { blend: e.target.value }, true)}>
353
+ {PATTERN_BLENDS.map((b) => (
354
+ <option key={b} value={b}>
355
+ {b}
356
+ </option>
357
+ ))}
358
+ </select>
359
+ </div>
360
+ <div style={S.row}>
361
+ <span style={S.label}>Color</span>
362
+ <ColorSwatch value={pat.color} fallback="#ffffff" ColorPicker={ColorPicker} onApply={(hex) => setSection('pattern', { color: hex }, true)} />
363
+ <span style={{ ...S.num, flex: 1, textAlign: 'left', color: 'var(--fg-3)' }}>tip: dark + multiply</span>
364
+ </div>
365
+ <Slider label="Scale" value={pat.scale ?? 1} min={0.25} max={4} step={0.25} onChange={(v) => setSection('pattern', { scale: v })} onCommit={() => setSection('pattern', { scale: pat.scale ?? 1 }, true)} />
366
+ <Slider label="Opacity" value={pat.opacity ?? 0.5} min={0} max={1} onChange={(v) => setSection('pattern', { opacity: v })} onCommit={() => setSection('pattern', { opacity: pat.opacity ?? 0.5 }, true)} />
367
+ </>
368
+ )}
369
+ </Section>
370
+
371
+ <Section title="Mask">
372
+ <div style={S.row}>
373
+ <span style={S.label}>Preset</span>
374
+ <select style={S.select} value={mask.preset || 'none'} onChange={(e) => setSection('mask', { preset: e.target.value }, true)}>
375
+ {MASK_PRESETS.map((m) => (
376
+ <option key={m} value={m}>
377
+ {m}
378
+ </option>
379
+ ))}
380
+ </select>
381
+ </div>
382
+ {mask.preset && mask.preset !== 'none' && (
383
+ <Slider label="Strength" value={mask.strength ?? 0.6} min={0} max={1} onChange={(v) => setSection('mask', { strength: v })} onCommit={() => setSection('mask', { strength: mask.strength ?? 0.6 }, true)} />
384
+ )}
385
+ </Section>
386
+
387
+ <Section title="Background">
388
+ <div style={{ ...S.row, gap: 10 }}>
389
+ {bg.maskAsset ? (
390
+ <>
391
+ <Toggle checked={bg.enabled} onChange={(v) => setSection('backgroundRemoved', { enabled: v }, true)} label="applied" />
392
+ <button
393
+ type="button"
394
+ style={{ ...S.reset, opacity: bgBusy ? 0.6 : 1 }}
395
+ disabled={bgBusy || !onRemoveBackground}
396
+ title="Run background removal again"
397
+ onClick={async () => {
398
+ if (!onRemoveBackground) return;
399
+ setBgBusy(true);
400
+ try {
401
+ const res = await onRemoveBackground(asset);
402
+ if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
403
+ } finally {
404
+ setBgBusy(false);
405
+ }
406
+ }}
407
+ >
408
+ {bgBusy ? 'removing…' : 'redo'}
409
+ </button>
410
+ </>
411
+ ) : (
412
+ <button
413
+ type="button"
414
+ style={{ ...S.btn, opacity: bgBusy ? 0.6 : 1 }}
415
+ disabled={bgBusy || !onRemoveBackground}
416
+ data-testid="photo-remove-bg"
417
+ onClick={async () => {
418
+ if (!onRemoveBackground) return;
419
+ setBgBusy(true);
420
+ try {
421
+ const res = await onRemoveBackground(asset);
422
+ if (res?.maskAsset) setSection('backgroundRemoved', { enabled: true, maskAsset: res.maskAsset }, true);
423
+ } finally {
424
+ setBgBusy(false);
425
+ }
426
+ }}
427
+ >
428
+ {bgBusy ? 'Removing…' : '✦ Remove Background'}
429
+ </button>
430
+ )}
431
+ </div>
432
+ </Section>
433
+ </div>
434
+ );
435
+ }
436
+
437
+ export default PhotoKnobs;
@@ -86,6 +86,23 @@
86
86
  border-radius: var(--radius-pill); background: var(--accent); border: 1.5px solid var(--bg-1);
87
87
  }
88
88
 
89
+ /* feature-background-export-notification-center — menubar Exports button.
90
+ Mirrors .st-whatsnew's attribute-driven state (data-busy instead of
91
+ data-unseen — running/queued exports, not an unread count). */
92
+ .st-exports {
93
+ position: relative; appearance: none; border: 0; background: transparent; cursor: pointer;
94
+ width: 26px; height: 26px; display: grid; place-items: center; border-radius: var(--radius-sm);
95
+ color: var(--fg-1); font-size: 13px;
96
+ }
97
+ .st-exports:hover { background: var(--bg-3); color: var(--fg-0); }
98
+ .st-exports[data-busy="true"] { color: var(--accent); }
99
+ .st-exports__count {
100
+ position: absolute; top: -2px; right: -2px; min-width: 14px; height: 14px; padding: 0 3px;
101
+ display: grid; place-items: center; border-radius: var(--radius-pill);
102
+ background: var(--accent); color: var(--accent-fg); font-family: var(--font-mono);
103
+ font-size: 9px; font-weight: 700; line-height: 1; border: 1.5px solid var(--bg-1);
104
+ }
105
+
89
106
  /* ─── Menubar dropdown ────────────────────────────────────────────────────── */
90
107
  .st-dropdown {
91
108
  position: absolute; top: 38px; min-width: 230px;
@@ -496,6 +513,28 @@
496
513
  .st-ap-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
497
514
  .st-ap-thumb { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-xs); background: var(--bg-4, var(--bg-3)); display: block; }
498
515
  .st-ap-name { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
516
+
517
+ /* ─── StickerPicker (feature-whiteboard-annotation-improvements Phase 4) ──── */
518
+ .st-sticker-picker { width: 640px; }
519
+ .st-sticker-picker .input {
520
+ font-family: var(--font-body); font-size: var(--type-sm); color: var(--fg-0);
521
+ background: var(--bg-3); border: 1px solid var(--border-default); border-radius: var(--radius-sm);
522
+ padding: var(--space-3) var(--space-4); width: 100%;
523
+ transition: border-color var(--dur-soft) var(--ease-out), box-shadow var(--dur-soft) var(--ease-out);
524
+ }
525
+ .st-sticker-picker .input::placeholder { color: var(--fg-3); }
526
+ .st-sticker-picker .input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-tint); }
527
+ .st-sp-body { max-height: 56vh; overflow-y: auto; padding: 2px; display: flex; flex-direction: column; gap: var(--space-5); }
528
+ .st-sp-pack-hd { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 2px; }
529
+ .st-sp-pack-name { font-family: var(--font-mono); font-size: var(--type-xs); font-weight: 600; color: var(--fg-1); text-transform: uppercase; letter-spacing: var(--tracking-wide); }
530
+ .st-sp-pack-author { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-3); }
531
+ .st-sp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); gap: var(--space-2); }
532
+ .st-sp-cell { appearance: none; cursor: pointer; display: flex; flex-direction: column; align-items: center; padding: 6px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-2); transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
533
+ .st-sp-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
534
+ .st-sp-thumb { width: 100%; aspect-ratio: 1; object-fit: contain; display: block; }
535
+ .st-sp-empty { color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); padding: var(--space-6) 0; text-align: center; }
536
+ .st-sp-ft { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-3) var(--space-5); border-top: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
537
+ .st-sp-ft a { color: var(--fg-2); }
499
538
  .st-cp-replace { width: 100%; justify-content: center; }
500
539
  /* Media "Replace…" — a full-width button, NOT inside the 3-col .st-cp-row grid
501
540
  (which would drop it into the 10px provenance-dot column and clip it). */
@@ -1870,6 +1909,25 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1870
1909
  box-shadow: 0 -10px 18px -12px color-mix(in oklab, var(--bg-0) 80%, transparent);
1871
1910
  }
1872
1911
  .rb-dock { position: relative; }
1912
+ /* Proactive "remote has updates" nudge — a full-width primary button above the dock
1913
+ trigger, shown when the tracking remote is ahead (status.remoteAhead). */
1914
+ .rb-getlatest { width: 100%; justify-content: flex-start; gap: var(--space-2); animation: rb-getlatest-in var(--dur-soft, 160ms) var(--ease-out, ease-out); }
1915
+ .rb-getlatest-tx { flex: 0 0 auto; }
1916
+ .rb-getlatest-sub { margin-left: auto; font-weight: 500; opacity: 0.9; font-size: var(--type-xs); white-space: nowrap; }
1917
+ @keyframes rb-getlatest-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
1918
+ @media (prefers-reduced-motion: reduce) { .rb-getlatest, .rb-resume { animation: none; } }
1919
+ /* "Continue on <draft>" resume nudge — a subtle secondary row (not the primary blue),
1920
+ shown above the dock trigger when you're on the Shared version but have a draft. */
1921
+ .rb-resume { display: flex; align-items: center; gap: var(--space-1); width: 100%; animation: rb-getlatest-in var(--dur-soft, 160ms) var(--ease-out, ease-out); }
1922
+ .rb-resume-go { flex: 1 1 auto; display: flex; align-items: center; gap: var(--space-2); min-width: 0; text-align: left; padding: var(--space-2) var(--space-3); border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-2); color: var(--fg-1); cursor: pointer; font-size: var(--type-sm); transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
1923
+ .rb-resume-go:hover { border-color: var(--border-strong); background: var(--bg-3); }
1924
+ .rb-resume-go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1925
+ .rb-resume-icon { color: var(--status-warn); flex: 0 0 auto; display: grid; place-items: center; }
1926
+ .rb-resume-tx { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1927
+ .rb-resume-arrow { color: var(--fg-3); flex: 0 0 auto; }
1928
+ .rb-resume-x { flex: 0 0 auto; display: grid; place-items: center; width: 28px; height: 28px; border: none; border-radius: var(--radius-md); background: transparent; color: var(--fg-3); cursor: pointer; }
1929
+ .rb-resume-x:hover { background: var(--bg-3); color: var(--fg-1); }
1930
+ .rb-resume-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1873
1931
  /* The compact one-line trigger — "📁 <project> · ◐ <version> ⌃" */
1874
1932
  .rb-trigger {
1875
1933
  display: flex; align-items: center; gap: var(--space-2);
@@ -1966,6 +2024,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1966
2024
  .rb-sheet-body { margin: 0; color: var(--fg-1); font-size: var(--type-md); line-height: var(--lh-md); }
1967
2025
  .rb-sheet-body b { color: var(--fg-0); font-weight: 600; }
1968
2026
  .rb-sheet-meta { margin: 0; color: var(--fg-2); font-size: var(--type-sm); line-height: var(--lh-md); }
2027
+ .rb-pr-url { font-family: var(--font-mono, ui-monospace, monospace); font-size: var(--type-xs); word-break: break-all; user-select: all; opacity: 0.8; }
1969
2028
  .rb-sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
1970
2029
  @media (prefers-reduced-motion: reduce) {
1971
2030
  .rb-spin { animation-duration: 1ms; }
@@ -1306,6 +1306,156 @@
1306
1306
  }
1307
1307
  }
1308
1308
 
1309
+ /* ───────── Export notification center — feature-background-export-notification-center ─────────
1310
+ Panel shell reuses .help-modal-backdrop (above); list/item/progress are new
1311
+ — no prior job-row or progress-bar component existed in the client. */
1312
+ .st-export-panel {
1313
+ width: min(560px, 92vw);
1314
+ max-height: 80vh;
1315
+ display: flex;
1316
+ flex-direction: column;
1317
+ background: var(--u-bg-1);
1318
+ color: var(--u-fg-0);
1319
+ border: 1px solid var(--u-bg-3);
1320
+ border-radius: var(--radius-md);
1321
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.3);
1322
+ overflow: hidden;
1323
+ }
1324
+ .st-export-panel__body {
1325
+ overflow-y: auto;
1326
+ padding: 8px 14px 16px;
1327
+ }
1328
+ .st-export-list {
1329
+ list-style: none;
1330
+ margin: 0;
1331
+ padding: 0;
1332
+ }
1333
+ .st-export-item {
1334
+ padding: 12px 0;
1335
+ border-bottom: 1px solid var(--u-bg-3);
1336
+ }
1337
+ .st-export-item:last-child {
1338
+ border-bottom: none;
1339
+ }
1340
+ .st-export-item__hd {
1341
+ display: flex;
1342
+ align-items: center;
1343
+ justify-content: space-between;
1344
+ gap: 8px;
1345
+ margin-bottom: 6px;
1346
+ }
1347
+ .st-export-item__label {
1348
+ font-weight: 600;
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+ .st-export-item__ft {
1353
+ display: flex;
1354
+ align-items: center;
1355
+ justify-content: space-between;
1356
+ gap: 8px;
1357
+ margin-top: 6px;
1358
+ }
1359
+ .st-export-item__filename {
1360
+ color: var(--u-fg-2);
1361
+ font-size: 12px;
1362
+ font-family: var(--font-mono, monospace);
1363
+ overflow: hidden;
1364
+ text-overflow: ellipsis;
1365
+ white-space: nowrap;
1366
+ }
1367
+ .st-export-item__error {
1368
+ color: var(--u-status-error);
1369
+ font-size: 12px;
1370
+ }
1371
+ .st-export-pill {
1372
+ text-transform: uppercase;
1373
+ font-size: 9px;
1374
+ font-weight: 700;
1375
+ letter-spacing: 0.04em;
1376
+ padding: 2px 6px;
1377
+ border-radius: var(--radius-pill, 999px);
1378
+ white-space: nowrap;
1379
+ }
1380
+ .st-export-pill--queued {
1381
+ background: var(--u-bg-3);
1382
+ color: var(--u-fg-2);
1383
+ }
1384
+ .st-export-pill--running {
1385
+ background: var(--u-accent-bg);
1386
+ color: var(--u-accent);
1387
+ }
1388
+ .st-export-pill--done {
1389
+ background: color-mix(in oklab, var(--u-status-success) 18%, transparent);
1390
+ color: var(--u-status-success);
1391
+ }
1392
+ .st-export-pill--failed {
1393
+ background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
1394
+ color: var(--u-status-error);
1395
+ }
1396
+ .st-export-progress {
1397
+ position: relative;
1398
+ height: 4px;
1399
+ border-radius: var(--radius-pill, 999px);
1400
+ background: var(--u-bg-3);
1401
+ overflow: hidden;
1402
+ }
1403
+ .st-export-progress-bar {
1404
+ height: 100%;
1405
+ background: var(--u-accent);
1406
+ transition: width 0.2s ease-out;
1407
+ }
1408
+ .st-export-progress--indeterminate::after {
1409
+ content: '';
1410
+ position: absolute;
1411
+ inset: 0;
1412
+ width: 40%;
1413
+ background: var(--u-accent);
1414
+ border-radius: var(--radius-pill, 999px);
1415
+ animation: st-export-indeterminate 1.2s ease-in-out infinite;
1416
+ }
1417
+ @keyframes st-export-indeterminate {
1418
+ 0% {
1419
+ transform: translateX(-100%);
1420
+ }
1421
+ 100% {
1422
+ transform: translateX(350%);
1423
+ }
1424
+ }
1425
+ @media (prefers-reduced-motion: reduce) {
1426
+ .st-export-progress--indeterminate::after {
1427
+ animation-duration: 1ms;
1428
+ }
1429
+ }
1430
+ .st-toast .st-export-progress {
1431
+ margin-top: var(--space-1, 4px);
1432
+ }
1433
+ /* Busy state for the Save…/Download button — the bytes fetch + (native) the
1434
+ OS save-dialog handoff can take several seconds on a large export; without
1435
+ this the button just goes silent after the click. */
1436
+ .st-btn-spin {
1437
+ display: inline-block;
1438
+ width: 11px;
1439
+ height: 11px;
1440
+ margin-right: 5px;
1441
+ vertical-align: -1px;
1442
+ border: 1.5px solid currentColor;
1443
+ border-top-color: transparent;
1444
+ border-radius: 50%;
1445
+ opacity: 0.75;
1446
+ animation: st-btn-spin 0.7s linear infinite;
1447
+ }
1448
+ @keyframes st-btn-spin {
1449
+ to {
1450
+ transform: rotate(360deg);
1451
+ }
1452
+ }
1453
+ @media (prefers-reduced-motion: reduce) {
1454
+ .st-btn-spin {
1455
+ animation-duration: 1ms;
1456
+ }
1457
+ }
1458
+
1309
1459
  /* ───────── Guided tour (overlay) — feature-in-app-whats-new-tour Phase 3 ───────── */
1310
1460
  .mdcc-tour {
1311
1461
  position: fixed;