@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
@@ -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;
@@ -513,6 +513,28 @@
513
513
  .st-ap-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
514
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; }
515
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); }
516
538
  .st-cp-replace { width: 100%; justify-content: center; }
517
539
  /* Media "Replace…" — a full-width button, NOT inside the 3-col .st-cp-row grid
518
540
  (which would drop it into the 10px provenance-dot column and clip it). */
@@ -1887,6 +1909,25 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1887
1909
  box-shadow: 0 -10px 18px -12px color-mix(in oklab, var(--bg-0) 80%, transparent);
1888
1910
  }
1889
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; }
1890
1931
  /* The compact one-line trigger — "📁 <project> · ◐ <version> ⌃" */
1891
1932
  .rb-trigger {
1892
1933
  display: flex; align-items: center; gap: var(--space-2);
@@ -1983,6 +2024,7 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
1983
2024
  .rb-sheet-body { margin: 0; color: var(--fg-1); font-size: var(--type-md); line-height: var(--lh-md); }
1984
2025
  .rb-sheet-body b { color: var(--fg-0); font-weight: 600; }
1985
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; }
1986
2028
  .rb-sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
1987
2029
  @media (prefers-reduced-motion: reduce) {
1988
2030
  .rb-spin { animation-duration: 1ms; }
@@ -46,6 +46,14 @@ export interface EditSourcePayload {
46
46
  before: string | null;
47
47
  /** Value after the edit. `null` = the edit removed the prop/attr (the reset path). */
48
48
  after: string | null;
49
+ /**
50
+ * `op:'text'` only — which rendered instance this edit targeted (index among
51
+ * same-cd-id DOM nodes). Needed to re-apply a `{variable}` text edit through
52
+ * undo/redo: the engine traces the string back to the right `.map()` item or
53
+ * component-prop usage, and that resolution needs the occurrence. Undefined
54
+ * for literal text (the engine ignores it there) and for css/attr.
55
+ */
56
+ occurrence?: number;
49
57
  }
50
58
 
51
59
  /**
@@ -60,6 +68,14 @@ export type EditSourceApplyFn = (apply: {
60
68
  id: string;
61
69
  key: string;
62
70
  value: string | null;
71
+ /**
72
+ * `op:'text'` re-application only — the value CURRENTLY on disk (the side we
73
+ * replace FROM: `before` on redo, `after` on undo), so a `{variable}` edit
74
+ * can be traced to the right source string. Plus the occurrence. Ignored by
75
+ * css/attr and by literal-text edits.
76
+ */
77
+ from?: string | null;
78
+ occurrence?: number;
63
79
  }) => void | Promise<void>;
64
80
 
65
81
  export const EDIT_SOURCE_KIND = 'edit-source';
@@ -72,16 +88,27 @@ export interface EditSourceCommandInit {
72
88
 
73
89
  export function createEditSourceCommand(init: EditSourceCommandInit): EditCommand {
74
90
  const { payload, applyFn } = init;
75
- const apply = (value: string | null) =>
76
- applyFn({ op: payload.op, canvas: payload.canvas, id: payload.id, key: payload.key, value });
91
+ // `from` = the value on disk BEFORE this re-application (what a `{variable}`
92
+ // text resolver matches against). do() writes `after` over `before`; undo()
93
+ // writes `before` over `after`.
94
+ const apply = (value: string | null, from: string | null) =>
95
+ applyFn({
96
+ op: payload.op,
97
+ canvas: payload.canvas,
98
+ id: payload.id,
99
+ key: payload.key,
100
+ value,
101
+ from,
102
+ occurrence: payload.occurrence,
103
+ });
77
104
  return {
78
105
  kind: EDIT_SOURCE_KIND,
79
106
  label: init.label ?? defaultLabel(payload),
80
107
  async do() {
81
- await apply(payload.after);
108
+ await apply(payload.after, payload.before);
82
109
  },
83
110
  async undo() {
84
- await apply(payload.before);
111
+ await apply(payload.before, payload.after);
85
112
  },
86
113
  };
87
114
  }
@@ -54,7 +54,12 @@ export interface MenuItem {
54
54
  /** Right-aligned shortcut hint (e.g. `⌘C`, `⌫`). */
55
55
  shortcut?: string;
56
56
  destructive?: boolean;
57
- disabled?: boolean;
57
+ /**
58
+ * A FUNCTION form is resolved per-click against `target` — same rationale as
59
+ * the function-form `submenu` below (e.g. "Open Timeline" only enabled when
60
+ * THIS right-clicked artboard actually contains a video sequence).
61
+ */
62
+ disabled?: boolean | ((target: ContextTarget) => boolean);
58
63
  /**
59
64
  * Optional nested flyout (e.g. `Theme ▸ DS default / Light / Dark / Follow
60
65
  * chrome`). When present the row opens a submenu on hover / ArrowRight /
@@ -70,6 +75,13 @@ export interface MenuItem {
70
75
  submenu?: MenuItem[] | ((target: ContextTarget) => MenuItem[]);
71
76
  /** Hover/title hint shown when the item is `disabled` (a11y affordance). */
72
77
  disabledHint?: string;
78
+ /**
79
+ * Fully REMOVE the item for a target (vs. `disabled`, which greys it in place).
80
+ * Resolved per-click. Use when an entry is meaningless — not merely
81
+ * unavailable — for the right-clicked element (e.g. "Edit Photo…" only makes
82
+ * sense on an `<img>`, so it shouldn't appear at all on a `<div>`).
83
+ */
84
+ hidden?: boolean | ((target: ContextTarget) => boolean);
73
85
  onSelect: (target: ContextTarget) => void;
74
86
  }
75
87
 
@@ -371,9 +383,19 @@ export function ContextMenuView({
371
383
  };
372
384
  }, [target.clientX, target.clientY, onClose, pos.x, pos.y]);
373
385
 
386
+ // Resolve per-target `hidden` and drop now-empty sections (so a section that
387
+ // held only a hidden item doesn't leave a dangling separator).
388
+ const visibleSections = sections
389
+ .map((section) =>
390
+ section.filter((item) => {
391
+ const h = typeof item.hidden === 'function' ? item.hidden(target) : item.hidden;
392
+ return !h;
393
+ })
394
+ )
395
+ .filter((section) => section.length > 0);
374
396
  return (
375
397
  <div ref={ref} className="dc-context-menu" role="menu" style={{ left: pos.x, top: pos.y }}>
376
- {sections.map((section, si) => {
398
+ {visibleSections.map((section, si) => {
377
399
  const sectionKey = section.map((i) => i.id).join('|') || `s${si}`;
378
400
  return (
379
401
  <div key={sectionKey} role="group">
@@ -411,17 +433,20 @@ function MenuItemRow({
411
433
  // Resolve a function-form submenu against THIS click's target (e.g.
412
434
  // "Select layer" computing candidates from `target.clientX/clientY`).
413
435
  const submenuItems = typeof item.submenu === 'function' ? item.submenu(target) : item.submenu;
436
+ // Same per-click resolution for a function-form `disabled` (e.g. "Open
437
+ // Timeline" only enabled on an artboard that actually contains video).
438
+ const disabled = typeof item.disabled === 'function' ? item.disabled(target) : item.disabled;
414
439
 
415
440
  if (!submenuItems || submenuItems.length === 0) {
416
441
  return (
417
442
  <button
418
443
  type="button"
419
444
  role="menuitem"
420
- disabled={item.disabled}
421
- title={item.disabled ? item.disabledHint : undefined}
445
+ disabled={disabled}
446
+ title={disabled ? item.disabledHint : undefined}
422
447
  className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
423
448
  onClick={() => {
424
- if (item.disabled) return;
449
+ if (disabled) return;
425
450
  item.onSelect(target);
426
451
  onClose();
427
452
  }}
@@ -464,7 +489,7 @@ function MenuItemRow({
464
489
  role="menuitem"
465
490
  aria-haspopup="menu"
466
491
  aria-expanded={subOpen}
467
- disabled={item.disabled}
492
+ disabled={disabled}
468
493
  className="dc-menu-item"
469
494
  onClick={() => (subOpen ? setSubOpen(false) : open())}
470
495
  onKeyDown={(e) => {