@1agh/maude 0.28.1 → 0.29.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 (339) hide show
  1. package/README.md +2 -2
  2. package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -1
  3. package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +285 -5
  4. package/{plugins/design/dev-server → apps/studio}/annotations-layer.tsx +1206 -119
  5. package/{plugins/design/dev-server → apps/studio}/api.ts +486 -32
  6. package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
  7. package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
  9. package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
  10. package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
  11. package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
  12. package/apps/studio/bin/read-annotations.mjs +438 -0
  13. package/apps/studio/bin/read-annotations.sh +11 -0
  14. package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
  15. package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
  16. package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
  17. package/apps/studio/bin/to-lottie-verify.html +68 -0
  18. package/apps/studio/bin/to-lottie.sh +128 -0
  19. package/{plugins/design/dev-server → apps/studio}/build.ts +5 -3
  20. package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +1 -1
  21. package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
  22. package/apps/studio/canvas-create.ts +104 -0
  23. package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +46 -12
  24. package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +60 -1
  25. package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
  26. package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
  27. package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
  28. package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +56 -41
  29. package/{plugins/design/dev-server → apps/studio}/client/app.jsx +1713 -530
  30. package/apps/studio/client/canvas-url.js +81 -0
  31. package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
  32. package/{plugins/design/dev-server → apps/studio}/client/index.html +2 -1
  33. package/apps/studio/client/styles/1-tokens-maude.css +296 -0
  34. package/apps/studio/client/styles/3-shell-maude.css +459 -0
  35. package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
  36. package/apps/studio/client/styles/4-components-maude.css +74 -0
  37. package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +357 -34
  38. package/apps/studio/client/styles/5-maude-overrides.css +123 -0
  39. package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
  40. package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
  41. package/apps/studio/client/tour/overlay.jsx +191 -0
  42. package/apps/studio/client/tour/usage-tour.js +35 -0
  43. package/apps/studio/client/whats-new-seen.js +52 -0
  44. package/apps/studio/client/whats-new.jsx +250 -0
  45. package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +1 -1
  46. package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
  47. package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +1 -1
  48. package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +25 -6
  49. package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +1 -1
  50. package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +4 -7
  51. package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +1 -1
  52. package/apps/studio/dist/client.bundle.js +22825 -0
  53. package/apps/studio/dist/comment-mount.js +1960 -0
  54. package/apps/studio/dist/styles.css +5695 -0
  55. package/{plugins/design/dev-server → apps/studio}/dom-selection.ts +75 -4
  56. package/apps/studio/draw/animate.ts +333 -0
  57. package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
  58. package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
  59. package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
  60. package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
  61. package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
  62. package/apps/studio/draw/morph.ts +204 -0
  63. package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
  64. package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
  65. package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
  66. package/apps/studio/draw/serialize-animate.ts +324 -0
  67. package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
  68. package/apps/studio/draw/test/animate.test.ts +172 -0
  69. package/apps/studio/draw/test/morph.test.ts +107 -0
  70. package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
  71. package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
  72. package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
  73. package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +13 -1
  74. package/{plugins/design/dev-server → apps/studio}/http.ts +102 -1
  75. package/{plugins/design/dev-server → apps/studio}/input-router.tsx +8 -1
  76. package/{plugins/design/dev-server → apps/studio}/inspect.ts +20 -3
  77. package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
  78. package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +1 -1
  79. package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
  80. package/{plugins/design/dev-server → apps/studio}/server.ts +18 -0
  81. package/{plugins/design/dev-server → apps/studio}/sync/index.ts +1 -1
  82. package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
  83. package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
  84. package/apps/studio/test/annotations-text-format.test.ts +396 -0
  85. package/apps/studio/test/asset-api.test.ts +149 -0
  86. package/apps/studio/test/canvas-create-api.test.ts +361 -0
  87. package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
  88. package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
  89. package/apps/studio/test/canvas-media-drop.test.ts +158 -0
  90. package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +17 -1
  91. package/apps/studio/test/canvas-url.test.ts +99 -0
  92. package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
  93. package/apps/studio/test/motion-sample.test.ts +35 -0
  94. package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
  95. package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
  96. package/apps/studio/test/read-annotations.test.ts +409 -0
  97. package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
  98. package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
  99. package/apps/studio/test/tour-overlay.test.tsx +56 -0
  100. package/{plugins/design/dev-server → apps/studio}/test/use-annotation-resize.test.ts +43 -0
  101. package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +4 -1
  102. package/apps/studio/test/whats-new.test.ts +101 -0
  103. package/apps/studio/text-imports.d.ts +9 -0
  104. package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +4 -69
  105. package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -1
  106. package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
  107. package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +1 -1
  108. package/{plugins/design/dev-server → apps/studio}/use-annotation-resize.tsx +40 -7
  109. package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +1 -1
  110. package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
  111. package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
  112. package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +1 -1
  113. package/apps/studio/use-canvas-media-drop.tsx +343 -0
  114. package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +1 -1
  115. package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
  116. package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
  117. package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +9 -1
  118. package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
  119. package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +3 -1
  120. package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
  121. package/apps/studio/whats-new.json +139 -0
  122. package/apps/studio/whats-new.schema.json +70 -0
  123. package/apps/studio/whats-new.ts +87 -0
  124. package/cli/bin/maude.mjs +10 -0
  125. package/cli/commands/design.mjs +15 -7
  126. package/cli/commands/design.test.mjs +36 -0
  127. package/cli/commands/help.mjs +8 -2
  128. package/cli/commands/hub.mjs +3 -3
  129. package/cli/commands/hub.test.mjs +1 -1
  130. package/cli/commands/scenario-report.mjs +2 -9
  131. package/cli/lib/flow-design-integration.test.mjs +1 -1
  132. package/cli/lib/gitignore-block.mjs +1 -0
  133. package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
  134. package/cli/lib/preflight.mjs +1 -1
  135. package/package.json +15 -15
  136. package/plugins/design/templates/brief-board.tsx.template +61 -0
  137. package/plugins/design/templates/canvas.tsx.template +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
  139. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  140. package/plugins/flow/.claude-plugin/config.schema.json +16 -0
  141. package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
  142. package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
  143. package/plugins/design/dev-server/dist/styles.css +0 -2710
  144. /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
  145. /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
  146. /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
  147. /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
  148. /package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +0 -0
  149. /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
  150. /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
  151. /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
  152. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
  153. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
  154. /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
  155. /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
  156. /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
  157. /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
  158. /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
  159. /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
  160. /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
  161. /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
  162. /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
  163. /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
  164. /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
  165. /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
  166. /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
  167. /package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +0 -0
  168. /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
  169. /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
  170. /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
  171. /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
  172. /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
  173. /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
  174. /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
  175. /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
  176. /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
  177. /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
  178. /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
  179. /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
  180. /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
  181. /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
  182. /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
  183. /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
  184. /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
  185. /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
  186. /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
  187. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
  188. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
  189. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
  190. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
  191. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
  192. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
  193. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
  194. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
  195. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
  196. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
  197. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
  198. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
  199. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
  200. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
  201. /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
  202. /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
  203. /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
  204. /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
  205. /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
  206. /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
  207. /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
  208. /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
  209. /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
  210. /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
  211. /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
  212. /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
  213. /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
  214. /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
  215. /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
  216. /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
  217. /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
  218. /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
  219. /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
  220. /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
  221. /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
  222. /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
  223. /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
  224. /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
  225. /package/{plugins/design/dev-server → apps/studio}/history.ts +0 -0
  226. /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
  227. /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
  228. /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
  229. /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
  230. /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
  231. /package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +0 -0
  232. /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
  233. /package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +0 -0
  234. /package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +0 -0
  235. /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
  236. /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
  237. /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
  238. /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
  239. /package/{plugins/design/dev-server → apps/studio}/sync/migrate-seed.ts +0 -0
  240. /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
  241. /package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +0 -0
  242. /package/{plugins/design/dev-server → apps/studio}/sync/status.ts +0 -0
  243. /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
  244. /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
  245. /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
  246. /package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +0 -0
  247. /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
  248. /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
  249. /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
  250. /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
  251. /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
  252. /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
  253. /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
  254. /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
  255. /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
  256. /package/{plugins/design/dev-server → apps/studio}/test/canvas-edit.test.ts +0 -0
  257. /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
  258. /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
  259. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
  260. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
  261. /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
  262. /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
  263. /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
  264. /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
  265. /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
  266. /package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -0
  267. /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
  268. /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
  269. /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
  270. /package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +0 -0
  271. /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
  272. /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
  273. /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
  274. /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
  275. /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
  276. /package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +0 -0
  277. /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
  278. /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
  279. /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
  280. /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
  281. /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
  282. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
  283. /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
  284. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
  285. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
  286. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
  287. /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
  288. /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
  289. /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
  290. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
  291. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
  292. /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
  293. /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
  294. /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
  295. /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
  296. /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
  297. /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
  298. /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
  299. /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
  300. /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
  301. /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
  302. /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
  303. /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
  304. /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
  305. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
  306. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
  307. /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
  308. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
  309. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-migrate.test.ts +0 -0
  310. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
  311. /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
  312. /package/{plugins/design/dev-server → apps/studio}/test/sync-agent.test.ts +0 -0
  313. /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
  314. /package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +0 -0
  315. /package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +0 -0
  316. /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
  317. /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
  318. /package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -0
  319. /package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -0
  320. /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
  321. /package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +0 -0
  322. /package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +0 -0
  323. /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
  324. /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
  325. /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
  326. /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
  327. /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
  328. /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
  329. /package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -0
  330. /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
  331. /package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
  332. /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
  333. /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
  334. /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
  335. /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
  336. /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
  337. /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
  338. /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
  339. /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file annotations-layer.tsx — FigJam-style annotation overlay
3
- * @scope plugins/design/dev-server/annotations-layer.tsx
3
+ * @scope apps/studio/annotations-layer.tsx
4
4
  * @purpose Portal-rendered draw layer. Strokes live in world coords and
5
5
  * render INSIDE `.dc-world` via `createPortal`, so CSS `zoom` +
6
6
  * `translate` on the world move them in lockstep with artboards
@@ -24,6 +24,7 @@
24
24
  import {
25
25
  type CSSProperties,
26
26
  createContext,
27
+ type KeyboardEvent as ReactKeyboardEvent,
27
28
  type PointerEvent as ReactPointerEvent,
28
29
  useCallback,
29
30
  useContext,
@@ -49,6 +50,14 @@ import { crossedDragThreshold, type Tool } from './input-router.tsx';
49
50
  import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
50
51
  import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
51
52
  import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
53
+ import {
54
+ isHttpUrl,
55
+ linkDomain,
56
+ prettifyUrl,
57
+ showCanvasToast,
58
+ uploadAsset,
59
+ useCanvasMediaDrop,
60
+ } from './use-canvas-media-drop.tsx';
52
61
  import { useCollab } from './use-collab.tsx';
53
62
  import { useSelectionSetOptional } from './use-selection-set.tsx';
54
63
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
@@ -80,6 +89,13 @@ export interface PenStroke {
80
89
  color: string;
81
90
  width: number;
82
91
  points: WorldPoint[];
92
+ /**
93
+ * Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase /
94
+ * hit-test / translate logic; it just renders wide + translucent with
95
+ * `mix-blend-mode:multiply` (overlaps darken) and carries a translucent
96
+ * marker colour. Absent / false = a normal solid pen (back-compat).
97
+ */
98
+ highlighter?: boolean;
83
99
  }
84
100
  export interface RectStroke {
85
101
  id: string;
@@ -93,6 +109,8 @@ export interface RectStroke {
93
109
  fill?: string | null;
94
110
  /** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
95
111
  cornerRadius?: number;
112
+ /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
113
+ dashed?: boolean;
96
114
  }
97
115
  export interface EllipseStroke {
98
116
  id: string;
@@ -104,6 +122,8 @@ export interface EllipseStroke {
104
122
  rx: number;
105
123
  ry: number;
106
124
  fill?: string | null;
125
+ /** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
126
+ dashed?: boolean;
107
127
  }
108
128
  /**
109
129
  * Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
@@ -163,6 +183,12 @@ export interface TextStroke {
163
183
  bold?: boolean;
164
184
  /** Phase 24 — strikethrough. Absent / false = none (back-compat). */
165
185
  strike?: boolean;
186
+ /** Italic style (item 4b). Absent / false = upright (back-compat). */
187
+ italic?: boolean;
188
+ /** Underline (item 4b). Combined with strike into one text-decoration. */
189
+ underline?: boolean;
190
+ /** List style (item 4c). Markers are render-only — never stored in `text`. */
191
+ listType?: ListType;
166
192
  /**
167
193
  * Phase 24 — horizontal alignment. Absent default differs by kind: anchored
168
194
  * text = 'center' (legacy, byte-identical), standalone = 'left'.
@@ -186,9 +212,59 @@ export interface StickyStroke {
186
212
  bold?: boolean;
187
213
  /** Phase 24 — strikethrough body. Absent / false = none. */
188
214
  strike?: boolean;
215
+ /** Italic body (item 4b). Absent / false = upright. */
216
+ italic?: boolean;
217
+ /** Underline body (item 4b). Combined with strike into one text-decoration. */
218
+ underline?: boolean;
219
+ /** List style (item 4c). Markers are render-only — never stored in `text`. */
220
+ listType?: ListType;
189
221
  /** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
190
222
  align?: TextAlign;
191
223
  }
224
+ /**
225
+ * Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves
226
+ * and resizes like any annotation. `href` is ALWAYS a relative
227
+ * `assets/<sha8>.<ext>` path (never a data: URL — keeps the persisted SVG under
228
+ * its 1 MB cap and matches the sanitizer's `<image>` href allowlist). The live
229
+ * canvas may briefly render an optimistic `blob:` href before the upload swaps
230
+ * it to the content-addressed path; only the `assets/…` form is ever persisted.
231
+ */
232
+ export interface ImageStroke {
233
+ id: string;
234
+ tool: 'image';
235
+ x: number;
236
+ y: number;
237
+ w: number;
238
+ h: number;
239
+ href: string;
240
+ /**
241
+ * Alt text. Persisted in `data-alt` and emitted as `aria-label` on the
242
+ * `<image>`, so it travels with the exported / saved SVG (where AT reads it).
243
+ * NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden`
244
+ * (editor chrome — AT shouldn't be flooded by decorative strokes), so the live
245
+ * in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''.
246
+ */
247
+ alt?: string;
248
+ }
249
+ /**
250
+ * Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO
251
+ * server fetch and NO external favicon (the dev-server stays zero-egress —
252
+ * DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text
253
+ * when present, else the prettified URL; `domain` is `new URL(url).hostname`.
254
+ * Persists as an allowlisted `<g>` (rect + vector glyph + two `<text>` runs) —
255
+ * the click-to-open handler reads `data-url`, no `<a href>` is ever stored.
256
+ */
257
+ export interface LinkStroke {
258
+ id: string;
259
+ tool: 'link';
260
+ x: number;
261
+ y: number;
262
+ w: number;
263
+ h: number;
264
+ url: string;
265
+ title: string;
266
+ domain: string;
267
+ }
192
268
  export type Stroke =
193
269
  | PenStroke
194
270
  | RectStroke
@@ -196,7 +272,9 @@ export type Stroke =
196
272
  | PolygonStroke
197
273
  | ArrowStroke
198
274
  | TextStroke
199
- | StickyStroke;
275
+ | StickyStroke
276
+ | ImageStroke
277
+ | LinkStroke;
200
278
 
201
279
  /**
202
280
  * Phase 21 — what the inline editor is currently bound to. `anchored` edits
@@ -235,6 +313,45 @@ type PaletteColor = (typeof STROKE_PALETTE)[number];
235
313
  // other hues stay one click away. (Stickies keep their warm-paper default —
236
314
  // DEFAULT_STICKY_COLOR — they're paper, not ink.)
237
315
  const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
316
+ // Annotation polish — the LIVE default ink follows the canvas theme so a
317
+ // freshly-armed pen/shape/arrow/text reads true on dark canvases (the
318
+ // `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f`
319
+ // ink slot; dark → a light ink that reads on dark. This is the live draw
320
+ // default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip
321
+ // determinism + back-compat), and stored strokes keep their literal hex
322
+ // (FigJam parity — no retroactive recolour).
323
+ const DEFAULT_INK_DARK = '#ededed';
324
+ export function resolveDefaultInk(theme: string): string {
325
+ return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR;
326
+ }
327
+
328
+ /**
329
+ * The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
330
+ * 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
331
+ * themes only artboard palettes — canvas-shell.tsx) is what flips the canvas
332
+ * BACKGROUND the annotation ink sits on, so the theme-aware default ink follows
333
+ * it. Re-resolves on `data-maude-theme` mutation (the dark/light toggle posts it
334
+ * into the iframe after mount, so we re-read once on mount too).
335
+ */
336
+ function readChromeTheme(): 'light' | 'dark' {
337
+ if (typeof document === 'undefined') return 'dark';
338
+ return document.documentElement.dataset.maudeTheme === 'light' ? 'light' : 'dark';
339
+ }
340
+ function useCanvasChromeTheme(): 'light' | 'dark' {
341
+ const [theme, setTheme] = useState<'light' | 'dark'>(readChromeTheme);
342
+ useEffect(() => {
343
+ if (typeof document === 'undefined') return;
344
+ const sync = () => setTheme(readChromeTheme());
345
+ sync(); // catch a value stamped between the initializer and this effect
346
+ const obs = new MutationObserver(sync);
347
+ obs.observe(document.documentElement, {
348
+ attributes: true,
349
+ attributeFilter: ['data-maude-theme'],
350
+ });
351
+ return () => obs.disconnect();
352
+ }, []);
353
+ return theme;
354
+ }
238
355
 
239
356
  // Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
240
357
  // pale blue wash under a saturated stroke, exactly like FigJam shapes.
@@ -250,6 +367,24 @@ export const FILL_PALETTE = [
250
367
  '#e7e7e7', // ink tint
251
368
  ] as const;
252
369
 
370
+ // Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas.
371
+ const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7'
372
+ const NEUTRAL_FILL_DARK = '#2a2a2a';
373
+ /**
374
+ * Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape
375
+ * tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated
376
+ * outline over a pale wash of the same hue); the ink slot / themed-dark ink /
377
+ * any unknown hex maps to a neutral wash. "No fill" stays one click away (the
378
+ * chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored
379
+ * shapes keep their literal fill — only NEW shapes pick up this default.
380
+ */
381
+ export function defaultFillFor(color: string, theme: string): string {
382
+ const idx = STROKE_PALETTE.indexOf(color as PaletteColor);
383
+ // Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral.
384
+ if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx];
385
+ return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT;
386
+ }
387
+
253
388
  const STROKE_WIDTH_THIN = 3;
254
389
  const STROKE_WIDTH_THICK = 6;
255
390
  type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
@@ -277,6 +412,22 @@ export const STICKY_PALETTE = [
277
412
  ] as const;
278
413
  const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
279
414
  const STICKY_CORNER_RADIUS = 8;
415
+
416
+ // Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex
417
+ // (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`.
418
+ // Yellow is the default; green / pink / blue follow. Wholly separate from the
419
+ // ink PALETTE — the highlighter draws a soft wash, not a saturated line.
420
+ export const HIGHLIGHTER_PALETTE = [
421
+ '#ffe24d80', // yellow (default)
422
+ '#7ce8a080', // green
423
+ '#ff9ed180', // pink
424
+ '#7ec5ff80', // blue
425
+ ] as const;
426
+ const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0];
427
+ // Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick),
428
+ // all wider than the pen. Default medium.
429
+ const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const;
430
+ const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1];
280
431
  // Phase 24 — stickies are 1:1; the default tap size is a square.
281
432
  const STICKY_DEFAULT_W = 200;
282
433
  const STICKY_DEFAULT_H = 200;
@@ -285,6 +436,42 @@ const STICKY_MIN_SIZE = 40;
285
436
  // tap point (FigJam parity: click commits, drag sizes). Square aspect.
286
437
  const SHAPE_DEFAULT_SIZE = 120;
287
438
 
439
+ // Phase 23 — image + link media strokes.
440
+ /** Below this side an image stroke is discarded as an accidental micro-drop. */
441
+ const IMAGE_MIN_SIZE = 16;
442
+ /** Longest side a freshly dropped/pasted image is scaled down to (world px). */
443
+ export const IMAGE_MAX_DROP_SIDE = 480;
444
+ const LINK_DEFAULT_W = 260;
445
+ const LINK_DEFAULT_H = 76;
446
+ const LINK_CARD_FILL = '#ffffff';
447
+ const LINK_CARD_STROKE = '#d4d4d8';
448
+ const LINK_DOMAIN_FILL = '#71717a';
449
+ const LINK_TITLE_FILL = '#18181b';
450
+ const LINK_GLYPH_STROKE = '#52525b';
451
+ // Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the
452
+ // serialized nested-<svg> glyph AND the StrokeNode render so re-serialize stays
453
+ // byte-stable. The parser ignores the glyph entirely (it reads data-* + the
454
+ // <rect> geometry), so render/serialize only need to agree visually.
455
+ const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
456
+ const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
457
+
458
+ /** Card text positions, derived purely from the bbox (idempotent round-trip). */
459
+ function linkCardLayout(x: number, y: number, w: number, h: number) {
460
+ const textX = x + 48;
461
+ return {
462
+ glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 },
463
+ textX,
464
+ domain: { y: y + h / 2 - 14, fontSize: 11 },
465
+ title: { y: y + h / 2, fontSize: 13 },
466
+ textMaxChars: Math.max(8, Math.floor((w - 60) / 7)),
467
+ };
468
+ }
469
+
470
+ /** Clamp a link title to the card's character budget (pure → byte-stable). */
471
+ function clampLinkTitle(title: string, maxChars: number): string {
472
+ return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title;
473
+ }
474
+
288
475
  // ─────────────────────────────────────────────────────────────────────────────
289
476
  // Pure helpers — exported for unit tests.
290
477
 
@@ -296,6 +483,19 @@ function esc(s: string): string {
296
483
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
297
484
  }
298
485
 
486
+ /**
487
+ * Phase 23 — attribute-safe escape: `esc()` plus `>`. The legacy text/sticky
488
+ * paths only put user text in element CONTENT (where a bare `>` is harmless),
489
+ * so `esc()` never escaped it; but the media strokes carry user text (pasted
490
+ * link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt
491
+ * / href). A bare `>` there would prematurely close the tag and confuse the
492
+ * `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media
493
+ * attribute value; element CONTENT keeps plain `esc()`.
494
+ */
495
+ function escAttr(s: string): string {
496
+ return esc(s).replace(/>/g, '&gt;');
497
+ }
498
+
299
499
  export function penPathD(points: readonly WorldPoint[]): string {
300
500
  if (points.length === 0) return '';
301
501
  const [first, ...rest] = points as readonly WorldPoint[];
@@ -305,6 +505,202 @@ export function penPathD(points: readonly WorldPoint[]): string {
305
505
  return d;
306
506
  }
307
507
 
508
+ // ── Multi-line text (item 4a) ────────────────────────────────────────────────
509
+ // SVG <text> ignores `\n`, so multi-line annotation text must render as one
510
+ // <tspan> per line. The serialized + live forms share this geometry; single-
511
+ // line text keeps the legacy single-run form (no tspan) so the canary holds.
512
+
513
+ /** Line-height multiplier for multi-line annotation text. */
514
+ const TEXT_LINE_HEIGHT = 1.25;
515
+
516
+ /** Split a text body into its display lines. */
517
+ export function splitTextLines(text: string): string[] {
518
+ return text.split('\n');
519
+ }
520
+
521
+ /** Annotation polish (item 4c) — list style for text + sticky bodies. */
522
+ export type ListType = 'bullet' | 'number';
523
+
524
+ /**
525
+ * Render-time list marker prefix for one line. Markers are PRESENTATION ONLY —
526
+ * never stored in `text` (DDR) — so the stored string stays clean and
527
+ * contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `.
528
+ */
529
+ function listPrefixedLine(line: string, index: number, list?: ListType): string {
530
+ if (!list) return line;
531
+ return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`;
532
+ }
533
+
534
+ /** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */
535
+ function stripListPrefix(line: string, index: number, list?: ListType): string {
536
+ if (!list) return line;
537
+ const marker = list === 'bullet' ? '• ' : `${index + 1}. `;
538
+ return line.startsWith(marker) ? line.slice(marker.length) : line;
539
+ }
540
+
541
+ /** Prefix every line of a body with its list marker (for the editor display). */
542
+ export function listPrefixedBody(text: string, list?: ListType): string {
543
+ if (!list) return text;
544
+ return splitTextLines(text)
545
+ .map((line, i) => listPrefixedLine(line, i, list))
546
+ .join('\n');
547
+ }
548
+
549
+ /**
550
+ * Strip list markers off editor `innerText` on commit (item 4c). Generic — a
551
+ * `•` bullet OR any leading `N. ` number is removed once per line, regardless of
552
+ * the index the user actually typed, so re-numbering while editing round-trips
553
+ * cleanly (the stored text stays marker-free; the read view re-derives markers).
554
+ */
555
+ export function stripEditorMarkers(text: string, list?: ListType): string {
556
+ if (!list) return text;
557
+ const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
558
+ return splitTextLines(text)
559
+ .map((line) => line.replace(re, ''))
560
+ .join('\n');
561
+ }
562
+
563
+ /**
564
+ * Combined `text-decoration` SVG attribute for strike + underline (item 4b).
565
+ * Strike-only stays `text-decoration="line-through"` (byte-identical to the
566
+ * legacy Phase-24 form); both → `line-through underline`; neither → empty.
567
+ */
568
+ function textDecoAttr(strike?: boolean, underline?: boolean): string {
569
+ const vals: string[] = [];
570
+ if (strike) vals.push('line-through');
571
+ if (underline) vals.push('underline');
572
+ return vals.length ? ` text-decoration="${vals.join(' ')}"` : '';
573
+ }
574
+
575
+ /** CSS `text-decoration` value for the live render (strike + underline). */
576
+ function textDecoCss(strike?: boolean, underline?: boolean): string | undefined {
577
+ const vals: string[] = [];
578
+ if (strike) vals.push('line-through');
579
+ if (underline) vals.push('underline');
580
+ return vals.length ? vals.join(' ') : undefined;
581
+ }
582
+
583
+ /**
584
+ * Inline text formatting carried by an editor through commit (item 4b/4d
585
+ * unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the
586
+ * stroke. `strike` rides along unchanged (no shortcut; toolbar-only).
587
+ */
588
+ export interface EditorFmt {
589
+ bold?: boolean;
590
+ italic?: boolean;
591
+ underline?: boolean;
592
+ strike?: boolean;
593
+ }
594
+ /** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the
595
+ * serialize-only-when-set invariant + byte-identical canary hold. */
596
+ function normFmt(fmt?: EditorFmt): EditorFmt {
597
+ return {
598
+ bold: fmt?.bold || undefined,
599
+ italic: fmt?.italic || undefined,
600
+ underline: fmt?.underline || undefined,
601
+ strike: fmt?.strike || undefined,
602
+ };
603
+ }
604
+ /** True when a stroke's existing formatting already matches `fmt` (so a pure
605
+ * identity edit can short-circuit without a redundant undo record). */
606
+ function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
607
+ if (!fmt) return true;
608
+ return (
609
+ !!s.bold === !!fmt.bold &&
610
+ !!s.italic === !!fmt.italic &&
611
+ !!s.underline === !!fmt.underline &&
612
+ !!s.strike === !!fmt.strike
613
+ );
614
+ }
615
+
616
+ /**
617
+ * Shared inline-formatting state for the three text editors (sticky / anchored /
618
+ * standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
619
+ * bold / italic / underline WHILE editing (preventing the browser's native
620
+ * execCommand, which would inject markup the model can't read), preview live via
621
+ * `style`, and commit on the stroke via `fmtRef`. `strike` rides along unchanged
622
+ * (toolbar-only — no universal shortcut). One hook = identical behaviour across
623
+ * all three editors.
624
+ */
625
+ function useEditorFormat(initial: EditorFmt): {
626
+ fmtRef: { current: EditorFmt };
627
+ style: CSSProperties;
628
+ onFormatKey: (e: ReactKeyboardEvent) => boolean;
629
+ } {
630
+ const [bold, setBold] = useState(!!initial.bold);
631
+ const [italic, setItalic] = useState(!!initial.italic);
632
+ const [underline, setUnderline] = useState(!!initial.underline);
633
+ const strike = !!initial.strike;
634
+ const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike });
635
+ fmtRef.current = { bold, italic, underline, strike };
636
+ const style: CSSProperties = {
637
+ fontWeight: bold ? 700 : undefined,
638
+ fontStyle: italic ? 'italic' : undefined,
639
+ textDecoration: textDecoCss(strike, underline),
640
+ };
641
+ const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
642
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
643
+ const k = e.key.toLowerCase();
644
+ if (k === 'b') {
645
+ e.preventDefault();
646
+ setBold((v) => !v);
647
+ return true;
648
+ }
649
+ if (k === 'i') {
650
+ e.preventDefault();
651
+ setItalic((v) => !v);
652
+ return true;
653
+ }
654
+ if (k === 'u') {
655
+ e.preventDefault();
656
+ setUnderline((v) => !v);
657
+ return true;
658
+ }
659
+ return false;
660
+ }, []);
661
+ return { fmtRef, style, onFormatKey };
662
+ }
663
+
664
+ /**
665
+ * Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored
666
+ * (hanging) or is lifted half the block height when vertically centred
667
+ * (anchored-in-host); every later line advances one line-height.
668
+ */
669
+ function textLineDy(i: number, fontSize: number, lineCount: number, centered: boolean): number {
670
+ const lh = fontSize * TEXT_LINE_HEIGHT;
671
+ if (i > 0) return lh;
672
+ return centered ? (-(lineCount - 1) / 2) * lh : 0;
673
+ }
674
+
675
+ /**
676
+ * Inner content for a serialized `<text>` stroke: the legacy single esc'd run
677
+ * when there's no newline (byte-identical, canary-safe), else one `<tspan>` per
678
+ * line. `x` is set on each tspan for standalone text (resets the line origin);
679
+ * anchored text omits it (the persisted form carries no absolute position —
680
+ * geometry is resolved against the host at render time). A `list` prefix
681
+ * (bullet / number) is prepended per line at render time only (DDR — never
682
+ * stored in `text`).
683
+ */
684
+ function textInnerSvg(
685
+ text: string,
686
+ fontSize: number,
687
+ centered: boolean,
688
+ x: number | undefined,
689
+ list?: ListType
690
+ ): string {
691
+ if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list));
692
+ const lines = splitTextLines(text);
693
+ const xAttr = x != null ? ` x="${x}"` : '';
694
+ return lines
695
+ .map(
696
+ (line, i) =>
697
+ `<tspan${xAttr} dy="${textLineDy(i, fontSize, lines.length, centered)}">${esc(
698
+ listPrefixedLine(line, i, list)
699
+ )}</tspan>`
700
+ )
701
+ .join('');
702
+ }
703
+
308
704
  // Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
309
705
  // Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
310
706
  // and the byte-identical canary keep working.
@@ -359,6 +755,28 @@ export function polygonPoints(
359
755
  .join(' ');
360
756
  }
361
757
 
758
+ /**
759
+ * Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r`
760
+ * and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The
761
+ * radius is clamped to half the smaller side so it never self-overlaps. Used by
762
+ * `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`)
763
+ * stays a plain `<rect>` (DDR — zero canary / sanitizer / parse impact).
764
+ */
765
+ export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string {
766
+ const rr = Math.max(0, Math.min(r, w / 2, h / 2));
767
+ return [
768
+ `M${x + rr} ${y}`,
769
+ `L${x + w - rr} ${y}`,
770
+ `Q${x + w} ${y} ${x + w} ${y + rr}`,
771
+ `L${x + w} ${y + h}`, // sharp bottom-right
772
+ `L${x + rr} ${y + h}`,
773
+ `Q${x} ${y + h} ${x} ${y + h - rr}`,
774
+ `L${x} ${y + rr}`,
775
+ `Q${x} ${y} ${x + rr} ${y}`,
776
+ 'Z',
777
+ ].join(' ');
778
+ }
779
+
362
780
  /** Even-odd ray-cast point-in-polygon test. */
363
781
  function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
364
782
  let inside = false;
@@ -398,10 +816,14 @@ function strokeToSvgEl(s: Stroke): string {
398
816
  // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
399
817
  // standalone text (no anchorId) writes its own world x/y and omits
400
818
  // data-anchor-id (so the parser routes it back to the standalone branch).
401
- // Phase 24 — bold/strike/align serialize ONLY for non-default values, so a
402
- // legacy text node stays byte-identical (weight/deco/alignAttr are empty).
819
+ // bold/italic/strike/underline/align/list serialize ONLY for non-default
820
+ // values, so a legacy text node stays byte-identical (every added fragment
821
+ // is empty). Multi-line text emits one <tspan> per line (item 4a); a
822
+ // single-line unstyled run stays the legacy single esc'd text.
403
823
  const weight = s.bold ? ' font-weight="700"' : '';
404
- const deco = s.strike ? ' text-decoration="line-through"' : '';
824
+ const italic = s.italic ? ' font-style="italic"' : '';
825
+ const deco = textDecoAttr(s.strike, s.underline);
826
+ const listAttr = s.listType ? ` data-list="${s.listType}"` : '';
405
827
  if (s.anchorId != null && s.anchorId !== '') {
406
828
  const align = s.align ?? 'center'; // anchored default = centre (legacy)
407
829
  const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
@@ -410,8 +832,12 @@ function strokeToSvgEl(s: Stroke): string {
410
832
  s.anchorId
411
833
  )}" data-font-size="${s.fontSize}" fill="${esc(
412
834
  s.color
413
- )}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${esc(
414
- s.text
835
+ )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${textInnerSvg(
836
+ s.text,
837
+ s.fontSize,
838
+ true,
839
+ undefined,
840
+ s.listType
415
841
  )}</text>`;
416
842
  }
417
843
  const tx = s.x ?? 0;
@@ -423,8 +849,12 @@ function strokeToSvgEl(s: Stroke): string {
423
849
  s.fontSize
424
850
  }" fill="${esc(
425
851
  s.color
426
- )}"${weight}${deco} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${esc(
427
- s.text
852
+ )}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${textInnerSvg(
853
+ s.text,
854
+ s.fontSize,
855
+ false,
856
+ tx,
857
+ s.listType
428
858
  )}</text>`;
429
859
  }
430
860
  if (s.tool === 'sticky') {
@@ -435,14 +865,18 @@ function strokeToSvgEl(s: Stroke): string {
435
865
  const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
436
866
  const w = Math.max(0, s.w);
437
867
  const h = Math.max(0, s.h);
438
- // Phase 24 — bold/strike/align on the <g> data-attrs, emitted ONLY for
439
- // non-default values (sticky default align = left) so Phase-21 stickies
440
- // serialize byte-identically.
868
+ // bold/italic/strike/underline/align/list on the <g> data-attrs, emitted
869
+ // ONLY for non-default values (sticky default align = left) so Phase-21
870
+ // stickies serialize byte-identically. The body <text> stays raw text —
871
+ // list markers are render-only (item 4c), never persisted.
441
872
  const align = s.align ?? 'left';
442
873
  const styleAttrs =
443
874
  (s.bold ? ' data-bold="1"' : '') +
875
+ (s.italic ? ' data-italic="1"' : '') +
444
876
  (s.strike ? ' data-strike="1"' : '') +
445
- (align !== 'left' ? ` data-align="${align}"` : '');
877
+ (s.underline ? ' data-underline="1"' : '') +
878
+ (align !== 'left' ? ` data-align="${align}"` : '') +
879
+ (s.listType ? ` data-list="${s.listType}"` : '');
446
880
  return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
447
881
  s.fontSize
448
882
  }" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
@@ -453,9 +887,52 @@ function strokeToSvgEl(s: Stroke): string {
453
887
  s.fontSize
454
888
  }" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
455
889
  }
890
+ if (s.tool === 'image') {
891
+ // Phase 23 — `href` is ALWAYS a relative assets/<sha8>.<ext> path (asserted
892
+ // on create + re-validated by the sanitizer's <image> href allowlist). Alt
893
+ // text persists in `data-alt` + is emitted as `aria-label` for the exported
894
+ // SVG (the live annotation root is aria-hidden — see ImageStroke.alt).
895
+ const nx = Math.min(s.x, s.x + s.w);
896
+ const ny = Math.min(s.y, s.y + s.h);
897
+ const nw = Math.abs(s.w);
898
+ const nh = Math.abs(s.h);
899
+ const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : '';
900
+ return `<image data-id="${esc(s.id)}" data-tool="image" x="${nx}" y="${ny}" width="${nw}" height="${nh}" href="${escAttr(
901
+ s.href
902
+ )}" preserveAspectRatio="xMidYMid meet"${altAttr}/>`;
903
+ }
904
+ if (s.tool === 'link') {
905
+ // Phase 23 — client-only preview chip. data-url/title/domain are the
906
+ // round-trip source of truth; the inner rect/glyph/text are the inert,
907
+ // sanitizer-safe visual (no <a href> persisted — click-to-open reads
908
+ // data-url client-side and validates http(s) before window.open).
909
+ const nx = Math.min(s.x, s.x + s.w);
910
+ const ny = Math.min(s.y, s.y + s.h);
911
+ const nw = Math.abs(s.w);
912
+ const nh = Math.abs(s.h);
913
+ const lay = linkCardLayout(nx, ny, nw, nh);
914
+ const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
915
+ return (
916
+ `<g data-id="${esc(s.id)}" data-tool="link" data-url="${escAttr(s.url)}" data-title="${escAttr(
917
+ s.title
918
+ )}" data-domain="${escAttr(s.domain)}">` +
919
+ `<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
920
+ `<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="none" stroke="${LINK_GLYPH_STROKE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${LINK_GLYPH_D1}"/><path d="${LINK_GLYPH_D2}"/></svg>` +
921
+ `<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
922
+ s.domain
923
+ )}</text>` +
924
+ `<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
925
+ shownTitle
926
+ )}</text>` +
927
+ `</g>`
928
+ );
929
+ }
456
930
  const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
457
931
  if (s.tool === 'pen') {
458
- return `<path ${common} fill="none" d="${penPathD(s.points)}" pointer-events="stroke"/>`;
932
+ // Highlighter (item 8) — data-highlighter ONLY when true so a normal pen
933
+ // stays byte-identical (canary).
934
+ const hl = s.highlighter ? ' data-highlighter="1"' : '';
935
+ return `<path ${common} fill="none" d="${penPathD(s.points)}"${hl} pointer-events="stroke"/>`;
459
936
  }
460
937
  if (s.tool === 'rect') {
461
938
  const fill = s.fill ? esc(s.fill) : 'none';
@@ -463,17 +940,23 @@ function strokeToSvgEl(s: Stroke): string {
463
940
  // sharp-corner rects serialize byte-identically (Task 10 canary).
464
941
  const r = s.cornerRadius ?? 0;
465
942
  const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
943
+ // Dashed outline (item 7) — emitted ONLY when true (mirror polygon/arrow)
944
+ // so a legacy solid rect stays byte-identical.
945
+ const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
946
+ const dashAttr = s.dashed ? ' data-dash="1"' : '';
466
947
  return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
467
948
  0,
468
949
  s.w
469
- )}" height="${Math.max(0, s.h)}"${round}/>`;
950
+ )}" height="${Math.max(0, s.h)}"${round}${dash}${dashAttr}/>`;
470
951
  }
471
952
  if (s.tool === 'ellipse') {
472
953
  const fill = s.fill ? esc(s.fill) : 'none';
954
+ const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
955
+ const dashAttr = s.dashed ? ' data-dash="1"' : '';
473
956
  return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
474
957
  0,
475
958
  s.rx
476
- )}" ry="${Math.max(0, s.ry)}"/>`;
959
+ )}" ry="${Math.max(0, s.ry)}"${dash}${dashAttr}/>`;
477
960
  }
478
961
  if (s.tool === 'polygon') {
479
962
  // Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
@@ -606,7 +1089,12 @@ export function svgToStrokes(svgText: string): Stroke[] {
606
1089
  if (tool === 'pen') {
607
1090
  const d = el.getAttribute('d') || '';
608
1091
  const points = parsePathD(d);
609
- if (points.length) out.push({ id, tool: 'pen', color, width, points });
1092
+ if (points.length) {
1093
+ const pen: PenStroke = { id, tool: 'pen', color, width, points };
1094
+ // Highlighter flag; absent ⇒ undefined so a normal pen round-trips.
1095
+ if (el.getAttribute('data-highlighter') === '1') pen.highlighter = true;
1096
+ out.push(pen);
1097
+ }
610
1098
  continue;
611
1099
  }
612
1100
  if (tool === 'sticky') {
@@ -636,13 +1124,17 @@ export function svgToStrokes(svgText: string): Stroke[] {
636
1124
  fontSize,
637
1125
  cornerRadius,
638
1126
  };
639
- // Phase 24 — style attrs; absent ⇒ defaults (normal / left), left unset.
1127
+ // Style attrs; absent ⇒ defaults (normal / left), left unset.
640
1128
  if (el.getAttribute('data-bold') === '1') sticky.bold = true;
1129
+ if (el.getAttribute('data-italic') === '1') sticky.italic = true;
641
1130
  if (el.getAttribute('data-strike') === '1') sticky.strike = true;
1131
+ if (el.getAttribute('data-underline') === '1') sticky.underline = true;
642
1132
  const sticAlign = el.getAttribute('data-align');
643
1133
  if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
644
1134
  sticky.align = sticAlign;
645
1135
  }
1136
+ const sticList = el.getAttribute('data-list');
1137
+ if (sticList === 'bullet' || sticList === 'number') sticky.listType = sticList;
646
1138
  out.push(sticky);
647
1139
  continue;
648
1140
  }
@@ -654,7 +1146,11 @@ export function svgToStrokes(svgText: string): Stroke[] {
654
1146
  const fill = parseFill(el.getAttribute('fill'));
655
1147
  // Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
656
1148
  const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
657
- out.push({ id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius });
1149
+ const rect: RectStroke = { id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius };
1150
+ // Dashed (item 7); absent ⇒ undefined so a solid rect round-trips.
1151
+ const rectDash = el.getAttribute('data-dash');
1152
+ if (rectDash === '1' || rectDash === 'true') rect.dashed = true;
1153
+ out.push(rect);
658
1154
  continue;
659
1155
  }
660
1156
  if (tool === 'ellipse') {
@@ -663,7 +1159,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
663
1159
  const rx = Number.parseFloat(el.getAttribute('rx') || '0');
664
1160
  const ry = Number.parseFloat(el.getAttribute('ry') || '0');
665
1161
  const fill = parseFill(el.getAttribute('fill'));
666
- out.push({ id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill });
1162
+ const ell: EllipseStroke = { id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill };
1163
+ const ellDash = el.getAttribute('data-dash');
1164
+ if (ellDash === '1' || ellDash === 'true') ell.dashed = true;
1165
+ out.push(ell);
667
1166
  continue;
668
1167
  }
669
1168
  if (tool === 'polygon') {
@@ -728,18 +1227,66 @@ export function svgToStrokes(svgText: string): Stroke[] {
728
1227
  }
729
1228
  continue;
730
1229
  }
1230
+ if (tool === 'image') {
1231
+ // Phase 23 — geometry off the element; href is whatever survived the
1232
+ // sanitizer (a valid assets/<sha8>.<ext> path, or '' if it was stripped
1233
+ // — an external/data:/`..` href is dropped server-side, so a poisoned
1234
+ // SVG round-trips to an inert empty-href stroke that fetches nothing).
1235
+ const x = Number.parseFloat(el.getAttribute('x') || '0');
1236
+ const y = Number.parseFloat(el.getAttribute('y') || '0');
1237
+ const w = Number.parseFloat(el.getAttribute('width') || '0');
1238
+ const h = Number.parseFloat(el.getAttribute('height') || '0');
1239
+ const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
1240
+ const img: ImageStroke = { id, tool: 'image', x, y, w, h, href };
1241
+ const alt = el.getAttribute('data-alt');
1242
+ if (alt) img.alt = alt;
1243
+ out.push(img);
1244
+ continue;
1245
+ }
1246
+ if (tool === 'link') {
1247
+ // Phase 23 — data-* are the source of truth; geometry off the <rect>
1248
+ // child (mirrors sticky). Defensive: missing title ⇒ domain.
1249
+ const rectEl = el.querySelector('rect');
1250
+ const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
1251
+ const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
1252
+ const w = Number.parseFloat(rectEl?.getAttribute('width') || String(LINK_DEFAULT_W));
1253
+ const h = Number.parseFloat(rectEl?.getAttribute('height') || String(LINK_DEFAULT_H));
1254
+ const url = el.getAttribute('data-url') || '';
1255
+ const domain = el.getAttribute('data-domain') || '';
1256
+ const title = el.getAttribute('data-title') || domain || url;
1257
+ out.push({ id, tool: 'link', x, y, w, h, url, title, domain });
1258
+ continue;
1259
+ }
731
1260
  if (tool === 'text') {
732
1261
  const rawAnchor = el.getAttribute('data-anchor-id');
733
1262
  const fontSize =
734
1263
  Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
735
1264
  DEFAULT_FONT_SIZE;
736
1265
  const inkColor = el.getAttribute('fill') || color;
737
- const body = (el.textContent || '').trim();
738
- // Phase 24 bold / strike / align. `data-align` is the round-trip
739
- // source of truth (text-anchor is derived from it). Absent ⇒ default
740
- // (normal / per-kind align), left unset so legacy nodes round-trip.
1266
+ // List style (item 4c) — read FIRST so per-line markers can be stripped
1267
+ // off the parsed text (markers are render-only; never stored).
1268
+ const listRaw = el.getAttribute('data-list');
1269
+ const listType: ListType | undefined =
1270
+ listRaw === 'bullet' || listRaw === 'number' ? listRaw : undefined;
1271
+ // Multi-line text (item 4a) — one <tspan> per line. Recover `\n` by
1272
+ // joining tspan text content (markers stripped); a legacy single-run
1273
+ // <text> has no tspans → read its trimmed textContent.
1274
+ const tspans = el.querySelectorAll('tspan');
1275
+ const body =
1276
+ tspans.length > 0
1277
+ ? Array.from(tspans)
1278
+ .map((t, i) => stripListPrefix(t.textContent ?? '', i, listType))
1279
+ .join('\n')
1280
+ : stripListPrefix((el.textContent || '').trim(), 0, listType);
1281
+ // bold / italic / strike / underline / align. `data-align` is the
1282
+ // round-trip source of truth (text-anchor is derived from it). Absent ⇒
1283
+ // default (normal / per-kind align), left unset so legacy nodes
1284
+ // round-trip.
741
1285
  const isBold = el.getAttribute('font-weight') === '700';
742
- const isStrike = (el.getAttribute('text-decoration') || '').includes('line-through');
1286
+ const isItalic = el.getAttribute('font-style') === 'italic';
1287
+ const decoAttr = el.getAttribute('text-decoration') || '';
1288
+ const isStrike = decoAttr.includes('line-through');
1289
+ const isUnderline = decoAttr.includes('underline');
743
1290
  const da = el.getAttribute('data-align');
744
1291
  const align: TextAlign | undefined =
745
1292
  da === 'left' || da === 'center' || da === 'right' ? da : undefined;
@@ -756,7 +1303,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
756
1303
  y: Number.parseFloat(el.getAttribute('y') || '0'),
757
1304
  };
758
1305
  if (isBold) t.bold = true;
1306
+ if (isItalic) t.italic = true;
759
1307
  if (isStrike) t.strike = true;
1308
+ if (isUnderline) t.underline = true;
1309
+ if (listType) t.listType = listType;
760
1310
  if (align) t.align = align;
761
1311
  out.push(t);
762
1312
  continue;
@@ -770,7 +1320,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
770
1320
  anchorId: rawAnchor,
771
1321
  };
772
1322
  if (isBold) t.bold = true;
1323
+ if (isItalic) t.italic = true;
773
1324
  if (isStrike) t.strike = true;
1325
+ if (isUnderline) t.underline = true;
1326
+ if (listType) t.listType = listType;
774
1327
  if (align) t.align = align;
775
1328
  out.push(t);
776
1329
  }
@@ -811,8 +1364,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
811
1364
  );
812
1365
  }
813
1366
  const t = Math.max(tol, 'width' in s ? s.width : 2);
814
- if (s.tool === 'sticky') {
815
- // Sticky is a solid paper card — filled-rect hit anywhere inside.
1367
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1368
+ // Sticky / image / link are solid cards — filled-rect hit anywhere inside.
816
1369
  const xMin = Math.min(s.x, s.x + s.w);
817
1370
  const xMax = Math.max(s.x, s.x + s.w);
818
1371
  const yMin = Math.min(s.y, s.y + s.h);
@@ -1002,6 +1555,9 @@ function isStrokeMeaningful(s: Stroke): boolean {
1002
1555
  // Sticky below a readable floor is discarded like a 2×2 rect.
1003
1556
  if (s.tool === 'sticky')
1004
1557
  return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
1558
+ // Phase 23 — an image needs real extent; a link needs a non-empty URL.
1559
+ if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
1560
+ if (s.tool === 'link') return s.url.trim().length > 0;
1005
1561
  return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
1006
1562
  }
1007
1563
 
@@ -1042,7 +1598,8 @@ export function strokeBBox(
1042
1598
  h: Math.abs(s.y2 - s.y1),
1043
1599
  };
1044
1600
  }
1045
- if (s.tool === 'sticky') {
1601
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1602
+ // Phase 23 — image + link are rect-shaped media, same bbox as a sticky card.
1046
1603
  return {
1047
1604
  x: Math.min(s.x, s.x + s.w),
1048
1605
  y: Math.min(s.y, s.y + s.h),
@@ -1059,11 +1616,18 @@ export function strokeBBox(
1059
1616
  }
1060
1617
  const tx = s.x ?? 0;
1061
1618
  const ty = s.y ?? 0;
1619
+ // Multi-line text (item 4a) — the bbox spans the longest line (width) and all
1620
+ // lines (height) so the selection halo / hit-test / eraser cover the whole
1621
+ // block, not just line one. List markers widen each line by 2–3 chars.
1622
+ const lines = splitTextLines(s.text);
1623
+ const markerPad = s.listType ? 3 : 0;
1624
+ const longest = lines.reduce((m, l) => Math.max(m, l.length + markerPad), 0);
1062
1625
  return {
1063
1626
  x: tx,
1064
1627
  y: ty,
1065
- w: Math.max(8, s.text.length * s.fontSize * 0.55),
1066
- h: s.fontSize * 1.2,
1628
+ w: Math.max(8, longest * s.fontSize * 0.55),
1629
+ // Single-line keeps the legacy 1.2 height; multi-line grows by line count.
1630
+ h: lines.length <= 1 ? s.fontSize * 1.2 : lines.length * s.fontSize * TEXT_LINE_HEIGHT,
1067
1631
  };
1068
1632
  }
1069
1633
 
@@ -1092,6 +1656,38 @@ function deriveFile(): string | undefined {
1092
1656
  }
1093
1657
  }
1094
1658
 
1659
+ /**
1660
+ * Phase 23 — the served designRoot prefix for the current canvas document. An
1661
+ * image stroke persists its href as a RELATIVE `assets/<sha8>.<ext>` path (the
1662
+ * shape the sanitizer allowlists), but a bare relative href resolves against
1663
+ * `/_canvas-shell.html` → `/assets/…`, which the canvas-origin gate 403s. The
1664
+ * real served path is `/<designRel>/assets/…`, so the render must prefix it.
1665
+ */
1666
+ function canvasDesignRel(): string {
1667
+ if (typeof window === 'undefined') return '.design';
1668
+ try {
1669
+ const p = window.location.pathname;
1670
+ if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
1671
+ const qs = new URLSearchParams(window.location.search);
1672
+ return (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
1673
+ }
1674
+ // Direct `/<designRel>/<group>/<canvas>.tsx` route → the first path segment.
1675
+ const seg = decodeURIComponent(p).replace(/^\/+/, '').split('/')[0];
1676
+ return seg || '.design';
1677
+ } catch {
1678
+ return '.design';
1679
+ }
1680
+ }
1681
+
1682
+ /**
1683
+ * Resolve an image stroke href for the browser. The persisted `assets/<name>`
1684
+ * form is rewritten to the served `/<designRel>/assets/<name>` path; an
1685
+ * optimistic `blob:`/`data:` preview href (pre-upload) is used verbatim.
1686
+ */
1687
+ function resolveAssetHref(href: string): string {
1688
+ return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
1689
+ }
1690
+
1095
1691
  // ─────────────────────────────────────────────────────────────────────────────
1096
1692
  // Styles
1097
1693
 
@@ -1303,7 +1899,8 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
1303
1899
  if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
1304
1900
  if (s.tool === 'arrow')
1305
1901
  return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
1306
- if (s.tool === 'sticky') return { ...s, x: s.x + dx, y: s.y + dy };
1902
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link')
1903
+ return { ...s, x: s.x + dx, y: s.y + dy };
1307
1904
  // text — anchored inherits its host's bbox (moves with the host); standalone
1308
1905
  // (Phase 21) carries its own world (x, y) and translates directly.
1309
1906
  if (s.anchorId != null && s.anchorId !== '') return s;
@@ -1338,6 +1935,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
1338
1935
  export function AnnotationsLayer() {
1339
1936
  ensureAnnotStyles();
1340
1937
  const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
1938
+ const theme = useCanvasChromeTheme();
1341
1939
  const controller = useViewportControllerContext();
1342
1940
  const vp = controller?.viewport ?? null;
1343
1941
  const worldRef = useWorldRefContext();
@@ -1346,12 +1944,42 @@ export function AnnotationsLayer() {
1346
1944
 
1347
1945
  const [strokes, setStrokesState] = useState<Stroke[]>([]);
1348
1946
  const [drawing, setDrawing] = useState<Stroke | null>(null);
1349
- const [color, setColor] = useState<string>(DEFAULT_COLOR);
1350
- const [fill, setFill] = useState<string | null>(null);
1947
+ // Theme-aware live default ink (items 3/5/6). Initialized from the current
1948
+ // theme; tracked-untouched until the user picks a swatch, after which it
1949
+ // sticks (colorTouchedRef). The themed-default effect lives below.
1950
+ const [color, setColorState] = useState<string>(() => resolveDefaultInk(theme));
1951
+ const colorTouchedRef = useRef(false);
1952
+ const setColor = useCallback((c: string) => {
1953
+ colorTouchedRef.current = true;
1954
+ setColorState(c);
1955
+ }, []);
1956
+ // While the user hasn't picked an ink swatch yet, follow the theme — so a
1957
+ // dark canvas arms a light-reading default and a light canvas a dark one.
1958
+ useEffect(() => {
1959
+ if (!colorTouchedRef.current) setColorState(resolveDefaultInk(theme));
1960
+ }, [theme]);
1961
+ // Shape default fill (item 2). A freshly-armed Shape tool gets a fill (not
1962
+ // outline-only); the index-paired tint of the active ink. Untouched until the
1963
+ // user picks a fill swatch (incl. "No fill"), after which it sticks.
1964
+ const [fill, setFillState] = useState<string | null>(null);
1965
+ const fillTouchedRef = useRef(false);
1966
+ const setFill = useCallback((f: string | null) => {
1967
+ fillTouchedRef.current = true;
1968
+ setFillState(f);
1969
+ }, []);
1970
+ // While the Shape tool is armed and the fill is untouched, follow the ink
1971
+ // (and theme) so a square/circle/diamond lands with a paired-tint wash. Once
1972
+ // the user picks a fill swatch (or "No fill"), this stops.
1973
+ useEffect(() => {
1974
+ if (tool === 'shape' && !fillTouchedRef.current) setFillState(defaultFillFor(color, theme));
1975
+ }, [tool, color, theme]);
1351
1976
  const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
1352
1977
  // Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
1353
1978
  // context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
1354
1979
  const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
1980
+ // Annotation polish (item 8) — draw-time highlighter marker hue + nib width.
1981
+ const [highlighterColor, setHighlighterColor] = useState<string>(DEFAULT_HIGHLIGHTER_COLOR);
1982
+ const [highlighterWidth, setHighlighterWidth] = useState<number>(DEFAULT_HIGHLIGHTER_WIDTH);
1355
1983
  // Phase 21 — a standalone-text caret waiting for its first keystroke. No
1356
1984
  // stroke exists yet (mirrors anchored text: the stroke is born on commit,
1357
1985
  // so an abandoned empty caret leaves nothing behind / no undo record).
@@ -1393,7 +2021,12 @@ export function AnnotationsLayer() {
1393
2021
  strokesRef.current = strokes;
1394
2022
 
1395
2023
  const isDraw =
1396
- tool === 'pen' || tool === 'shape' || tool === 'arrow' || tool === 'sticky' || tool === 'text';
2024
+ tool === 'pen' ||
2025
+ tool === 'highlighter' ||
2026
+ tool === 'shape' ||
2027
+ tool === 'arrow' ||
2028
+ tool === 'sticky' ||
2029
+ tool === 'text';
1397
2030
  const isErase = tool === 'eraser';
1398
2031
  const isActive = isDraw || isErase;
1399
2032
  // T20 / Phase 24 — every shape primitive carries stroke weight (FigJam ships
@@ -1622,6 +2255,95 @@ export function AnnotationsLayer() {
1622
2255
  [vp]
1623
2256
  );
1624
2257
 
2258
+ // ── Phase 23 — media intake (drag-drop / paste / file-picker / link input) ──
2259
+ // Image: optimistic blob: preview stroke (LOCAL only — never persisted) →
2260
+ // POST /_api/asset → swap href to assets/… and commit (one undo record) →
2261
+ // revoke the blob URL. On failure the optimistic stroke is removed + a toast.
2262
+ const createImageFromFile = useCallback(
2263
+ (file: File, world: [number, number]) => {
2264
+ if (typeof window === 'undefined' || !file.type.startsWith('image/')) return;
2265
+ const blobUrl = URL.createObjectURL(file);
2266
+ const probe = new Image();
2267
+ probe.onload = () => {
2268
+ const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
2269
+ const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
2270
+ const longest = Math.max(natW, natH) || 1;
2271
+ const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
2272
+ const w = Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale));
2273
+ const h = Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale));
2274
+ const id = rid();
2275
+ const optimistic: ImageStroke = {
2276
+ id,
2277
+ tool: 'image',
2278
+ x: world[0] - w / 2,
2279
+ y: world[1] - h / 2,
2280
+ w,
2281
+ h,
2282
+ href: blobUrl,
2283
+ };
2284
+ // Local-only insert — the blob: href must NOT reach the server (it's
2285
+ // ephemeral + would be stripped by the sanitizer); we commit only the
2286
+ // assets/… form once the upload lands.
2287
+ setStrokesState([...strokesRef.current, optimistic]);
2288
+ void uploadAsset(file).then((res) => {
2289
+ const cur = strokesRef.current;
2290
+ if ('path' in res) {
2291
+ const after = cur.map((s) => (s.id === id ? ({ ...s, href: res.path } as Stroke) : s));
2292
+ const beforeForUndo = cur.filter((s) => s.id !== id);
2293
+ commitStrokes(beforeForUndo, after, 'add image');
2294
+ annotSel?.replace([id]);
2295
+ } else {
2296
+ setStrokesState(cur.filter((s) => s.id !== id));
2297
+ showCanvasToast(`Image upload failed — ${res.error}`);
2298
+ }
2299
+ URL.revokeObjectURL(blobUrl);
2300
+ });
2301
+ };
2302
+ probe.onerror = () => {
2303
+ URL.revokeObjectURL(blobUrl);
2304
+ showCanvasToast('Could not read that image file');
2305
+ };
2306
+ probe.src = blobUrl;
2307
+ },
2308
+ [commitStrokes, annotSel]
2309
+ );
2310
+
2311
+ // Link: client-only preview chip — no upload, no server fetch. Only http(s)
2312
+ // URLs are accepted (the hook already gates, re-checked here defensively).
2313
+ const createLink = useCallback(
2314
+ (url: string, title: string, world: [number, number]) => {
2315
+ if (!isHttpUrl(url)) return;
2316
+ const w = LINK_DEFAULT_W;
2317
+ const h = LINK_DEFAULT_H;
2318
+ const id = rid();
2319
+ const link: LinkStroke = {
2320
+ id,
2321
+ tool: 'link',
2322
+ x: world[0] - w / 2,
2323
+ y: world[1] - h / 2,
2324
+ w,
2325
+ h,
2326
+ url,
2327
+ title: (title || prettifyUrl(url)).slice(0, 300),
2328
+ domain: linkDomain(url),
2329
+ };
2330
+ const before = strokesRef.current;
2331
+ commitStrokes(before, [...before, link], 'add link');
2332
+ annotSel?.replace([id]);
2333
+ },
2334
+ [commitStrokes, annotSel]
2335
+ );
2336
+
2337
+ const mediaCallbacks = useMemo(
2338
+ () => ({ onImage: createImageFromFile, onLink: createLink }),
2339
+ [createImageFromFile, createLink]
2340
+ );
2341
+ // Media intake is paste/drop only (per product steer — no toolbar buttons):
2342
+ // drop an image / URL or Cmd+V a clipboard image / link straight onto the
2343
+ // canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
2344
+ // hold the commit/undo sink + screenToWorld.
2345
+ useCanvasMediaDrop({ enabled: visible, screenToWorld, callbacks: mediaCallbacks });
2346
+
1625
2347
  const eraseAt = useCallback(
1626
2348
  (wx: number, wy: number) => {
1627
2349
  const zoom = vp?.zoom || 1;
@@ -1672,6 +2394,17 @@ export function AnnotationsLayer() {
1672
2394
  const activeFill = supportsFill ? fill : null;
1673
2395
  if (tool === 'pen') {
1674
2396
  setDrawing({ id, tool: 'pen', color, width, points: [[wx, wy]] });
2397
+ } else if (tool === 'highlighter') {
2398
+ // Highlighter (item 8) — a pen stroke with a wide width, a translucent
2399
+ // marker hue, and the highlighter flag (renders multiply at draw time).
2400
+ setDrawing({
2401
+ id,
2402
+ tool: 'pen',
2403
+ color: highlighterColor,
2404
+ width: highlighterWidth,
2405
+ points: [[wx, wy]],
2406
+ highlighter: true,
2407
+ });
1675
2408
  } else if (tool === 'shape') {
1676
2409
  // Phase 24 — the single Shape tool maps its kind onto a stroke type:
1677
2410
  // circle → ellipse; square/rounded → rect (cornerRadius 0 / 8);
@@ -1760,6 +2493,8 @@ export function AnnotationsLayer() {
1760
2493
  fill,
1761
2494
  thickness,
1762
2495
  stickyColor,
2496
+ highlighterColor,
2497
+ highlighterWidth,
1763
2498
  supportsThickness,
1764
2499
  supportsFill,
1765
2500
  isActive,
@@ -1862,7 +2597,9 @@ export function AnnotationsLayer() {
1862
2597
  // mode is locked on this tool. Sticky lets the user draw many shapes in a
1863
2598
  // row (canonical pattern: tldraw double-click to lock). Eraser stays
1864
2599
  // armed by default — that tool is destructive, not constructive.
1865
- const toolJustUsed = cur.tool;
2600
+ // Map a highlighter pen (a 'pen' stroke with the flag) back to the
2601
+ // 'highlighter' tool id so its sticky-lock check matches the active tool.
2602
+ const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
1866
2603
  if (toolJustUsed !== 'eraser') {
1867
2604
  const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
1868
2605
  if (!stickyOnThis) setTool('move');
@@ -1993,7 +2730,9 @@ export function AnnotationsLayer() {
1993
2730
  t === 'polygon' ||
1994
2731
  t === 'arrow' ||
1995
2732
  t === 'text' ||
1996
- t === 'sticky')
2733
+ t === 'sticky' ||
2734
+ t === 'image' ||
2735
+ t === 'link')
1997
2736
  ) {
1998
2737
  return id;
1999
2738
  }
@@ -2012,21 +2751,18 @@ export function AnnotationsLayer() {
2012
2751
  const target = e.target as Element | null;
2013
2752
  if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
2014
2753
  const strokeId = findStrokeId(target);
2015
- // When pointerdown lands inside an artboard but not on a stroke, the
2016
- // gesture belongs to artboard-drag / element-marquee — not the
2017
- // annotation marquee. Bailing here keeps the annotation marquee from
2018
- // racing the artboard drag (post-Wave-3 user grievance G5).
2019
- if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
2020
2754
  const [wx, wy] = screenToWorld(e.clientX, e.clientY);
2021
2755
  const startClientX = e.clientX;
2022
2756
  const startClientY = e.clientY;
2023
2757
 
2024
- // Stroke hit select + start drag-translate of the group ─────────
2758
+ // Stroke hit OR multi-selection hull hit start a group drag. Clicking a
2759
+ // selected stroke, OR anywhere inside the current selection's bounding box
2760
+ // (FigJam parity), moves the whole group. Without the hull case, grabbing
2761
+ // the empty space BETWEEN selected strokes fell through to the marquee
2762
+ // branch below and DESELECTED them.
2763
+ let ids: string[] | null = null;
2025
2764
  if (strokeId) {
2026
- e.preventDefault();
2027
- e.stopImmediatePropagation();
2028
2765
  elementSel?.clear();
2029
- let ids: string[];
2030
2766
  if (e.shiftKey) {
2031
2767
  annotSel.add(strokeId);
2032
2768
  ids = annotSel.contains(strokeId)
@@ -2038,6 +2774,28 @@ export function AnnotationsLayer() {
2038
2774
  annotSel.replace(strokeId);
2039
2775
  ids = [strokeId];
2040
2776
  }
2777
+ } else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
2778
+ // Hull hit-test — union bbox of the currently-selected strokes.
2779
+ let hx1 = Infinity;
2780
+ let hy1 = Infinity;
2781
+ let hx2 = -Infinity;
2782
+ let hy2 = -Infinity;
2783
+ for (const s of strokesStoreRef.current.strokes) {
2784
+ if (!annotSel.contains(s.id)) continue;
2785
+ const bb = strokeBBox(s);
2786
+ if (!bb) continue;
2787
+ hx1 = Math.min(hx1, bb.x);
2788
+ hy1 = Math.min(hy1, bb.y);
2789
+ hx2 = Math.max(hx2, bb.x + bb.w);
2790
+ hy2 = Math.max(hy2, bb.y + bb.h);
2791
+ }
2792
+ if (hx2 >= hx1 && wx >= hx1 && wx <= hx2 && wy >= hy1 && wy <= hy2) {
2793
+ ids = annotSel.selectedIds;
2794
+ }
2795
+ }
2796
+ if (ids && ids.length) {
2797
+ e.preventDefault();
2798
+ e.stopImmediatePropagation();
2041
2799
  // Capture a snapshot of all strokes at drag start. Every pointermove
2042
2800
  // re-translates FROM the snapshot using the cumulative cursor delta
2043
2801
  // (NOT a delta-from-last-frame mutation), so dragging back to origin
@@ -2090,6 +2848,13 @@ export function AnnotationsLayer() {
2090
2848
  return;
2091
2849
  }
2092
2850
 
2851
+ // Not a stroke / hull-group drag. When pointerdown lands inside an
2852
+ // artboard the gesture belongs to artboard-drag / element-marquee — not
2853
+ // the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
2854
+ // group-drag decision so a multi-selection hull-drag still wins even when
2855
+ // the strokes sit over an artboard.
2856
+ if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
2857
+
2093
2858
  // Empty world — start a drag-select gesture. A bare click without
2094
2859
  // moving clears annotation selection (post-Wave-3 feedback: click-to-
2095
2860
  // deselect is back; Esc also still works).
@@ -2185,7 +2950,7 @@ export function AnnotationsLayer() {
2185
2950
  }, [tool]);
2186
2951
 
2187
2952
  const commitText = useCallback(
2188
- (anchorId: string, text: string) => {
2953
+ (anchorId: string, text: string, fmt?: EditorFmt) => {
2189
2954
  const trimmed = text.trim();
2190
2955
  const prev = strokesRef.current;
2191
2956
  const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
@@ -2198,8 +2963,10 @@ export function AnnotationsLayer() {
2198
2963
  next = prev.filter((s) => s.id !== existing.id);
2199
2964
  label = 'delete text';
2200
2965
  } else if (existing) {
2201
- if (existing.text === trimmed) return; // identity edit
2202
- next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
2966
+ if (existing.text === trimmed && fmtEqual(existing, fmt)) return; // identity edit
2967
+ next = prev.map((s) =>
2968
+ s.id === existing.id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s
2969
+ );
2203
2970
  } else {
2204
2971
  next = [
2205
2972
  ...prev,
@@ -2210,6 +2977,7 @@ export function AnnotationsLayer() {
2210
2977
  fontSize: DEFAULT_FONT_SIZE,
2211
2978
  text: trimmed,
2212
2979
  anchorId,
2980
+ ...normFmt(fmt),
2213
2981
  } as TextStroke,
2214
2982
  ];
2215
2983
  label = 'add text';
@@ -2222,13 +2990,13 @@ export function AnnotationsLayer() {
2222
2990
  // Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
2223
2991
  // no trim) and the card persists even when blank, so this only updates text.
2224
2992
  const commitStickyText = useCallback(
2225
- (id: string, text: string) => {
2993
+ (id: string, text: string, fmt?: EditorFmt) => {
2226
2994
  const prev = strokesRef.current;
2227
2995
  const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
2228
2996
  | StickyStroke
2229
2997
  | undefined;
2230
- if (!existing || existing.text === text) return;
2231
- const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
2998
+ if (!existing || (existing.text === text && fmtEqual(existing, fmt))) return;
2999
+ const next = prev.map((s) => (s.id === id ? { ...existing, text, ...normFmt(fmt) } : s));
2232
3000
  commitStrokes(prev, next, 'edit sticky');
2233
3001
  },
2234
3002
  [commitStrokes]
@@ -2237,7 +3005,7 @@ export function AnnotationsLayer() {
2237
3005
  // Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
2238
3006
  // (same rule as anchored text).
2239
3007
  const commitStandaloneText = useCallback(
2240
- (id: string, text: string) => {
3008
+ (id: string, text: string, fmt?: EditorFmt) => {
2241
3009
  const trimmed = text.trim();
2242
3010
  const prev = strokesRef.current;
2243
3011
  const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
@@ -2250,10 +3018,10 @@ export function AnnotationsLayer() {
2250
3018
  );
2251
3019
  return;
2252
3020
  }
2253
- if (existing.text === trimmed) return;
3021
+ if (existing.text === trimmed && fmtEqual(existing, fmt)) return;
2254
3022
  commitStrokes(
2255
3023
  prev,
2256
- prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
3024
+ prev.map((s) => (s.id === id ? { ...existing, text: trimmed, ...normFmt(fmt) } : s)),
2257
3025
  'edit text'
2258
3026
  );
2259
3027
  },
@@ -2263,14 +3031,23 @@ export function AnnotationsLayer() {
2263
3031
  // Phase 21 — born-on-commit standalone text (from the text-tool caret). An
2264
3032
  // empty caret persists nothing — ONE undo record only when real text lands.
2265
3033
  const createStandaloneText = useCallback(
2266
- (x: number, y: number, text: string) => {
3034
+ (x: number, y: number, text: string, fmt?: EditorFmt) => {
2267
3035
  const trimmed = text.trim();
2268
3036
  if (trimmed.length === 0) return;
2269
3037
  const prev = strokesRef.current;
2270
3038
  const id = rid();
2271
3039
  const next: Stroke[] = [
2272
3040
  ...prev,
2273
- { id, tool: 'text', color, fontSize: DEFAULT_FONT_SIZE, text: trimmed, x, y },
3041
+ {
3042
+ id,
3043
+ tool: 'text',
3044
+ color,
3045
+ fontSize: DEFAULT_FONT_SIZE,
3046
+ text: trimmed,
3047
+ x,
3048
+ y,
3049
+ ...normFmt(fmt),
3050
+ },
2274
3051
  ];
2275
3052
  commitStrokes(prev, next, 'add text');
2276
3053
  if (annotSel) annotSel.replace(id);
@@ -2298,15 +3075,15 @@ export function AnnotationsLayer() {
2298
3075
  editingTargetRef.current = editingTarget;
2299
3076
 
2300
3077
  const commitEditing = useCallback(
2301
- (text: string) => {
3078
+ (text: string, fmt?: EditorFmt) => {
2302
3079
  const target = editingTargetRef.current;
2303
3080
  setEditingId(null);
2304
3081
  setPendingText(null);
2305
3082
  if (!target) return;
2306
- if (target.kind === 'anchored') commitText(target.anchorId, text);
2307
- else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text);
2308
- else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text);
2309
- else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text);
3083
+ if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
3084
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
3085
+ else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
3086
+ else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
2310
3087
  },
2311
3088
  [commitText, commitStickyText, commitStandaloneText, createStandaloneText]
2312
3089
  );
@@ -2388,6 +3165,7 @@ export function AnnotationsLayer() {
2388
3165
  marquee={marquee}
2389
3166
  ghost={ghostPreview}
2390
3167
  editingTarget={editingTarget}
3168
+ inkColor={color}
2391
3169
  onCommitEdit={commitEditing}
2392
3170
  onCancelEdit={cancelEditing}
2393
3171
  />
@@ -2397,10 +3175,15 @@ export function AnnotationsLayer() {
2397
3175
  {isActive ? (
2398
3176
  <AnnotationsChrome
2399
3177
  tool={tool}
3178
+ theme={theme}
2400
3179
  color={color}
2401
3180
  setColor={setColor}
2402
3181
  stickyColor={stickyColor}
2403
3182
  setStickyColor={setStickyColor}
3183
+ highlighterColor={highlighterColor}
3184
+ setHighlighterColor={setHighlighterColor}
3185
+ highlighterWidth={highlighterWidth}
3186
+ setHighlighterWidth={setHighlighterWidth}
2404
3187
  supportsFill={supportsFill}
2405
3188
  fill={fill}
2406
3189
  setFill={setFill}
@@ -2505,6 +3288,7 @@ function AnnotationsSvg({
2505
3288
  marquee,
2506
3289
  ghost,
2507
3290
  editingTarget,
3291
+ inkColor,
2508
3292
  onCommitEdit,
2509
3293
  onCancelEdit,
2510
3294
  }: {
@@ -2516,7 +3300,9 @@ function AnnotationsSvg({
2516
3300
  marquee: { ax: number; ay: number; bx: number; by: number } | null;
2517
3301
  ghost: GhostDescriptor | null;
2518
3302
  editingTarget: EditingTarget;
2519
- onCommitEdit: (text: string) => void;
3303
+ /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
3304
+ inkColor: string;
3305
+ onCommitEdit: (text: string, fmt?: EditorFmt) => void;
2520
3306
  onCancelEdit: () => void;
2521
3307
  }) {
2522
3308
  const [, force] = useState({});
@@ -2578,7 +3364,7 @@ function AnnotationsSvg({
2578
3364
  anchorId={editingTarget.anchorId}
2579
3365
  host={editingTarget.host}
2580
3366
  existing={anchoredExisting}
2581
- onCommit={(_anchorId, text) => onCommitEdit(text)}
3367
+ onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
2582
3368
  onCancel={onCancelEdit}
2583
3369
  />
2584
3370
  ) : null}
@@ -2597,8 +3383,11 @@ function AnnotationsSvg({
2597
3383
  color={editingTarget.text.color}
2598
3384
  initialText={editingTarget.text.text}
2599
3385
  bold={editingTarget.text.bold}
3386
+ italic={editingTarget.text.italic}
2600
3387
  strike={editingTarget.text.strike}
3388
+ underline={editingTarget.text.underline}
2601
3389
  align={editingTarget.text.align ?? 'left'}
3390
+ listType={editingTarget.text.listType}
2602
3391
  onCommit={onCommitEdit}
2603
3392
  onCancel={onCancelEdit}
2604
3393
  />
@@ -2608,7 +3397,7 @@ function AnnotationsSvg({
2608
3397
  x={editingTarget.x}
2609
3398
  y={editingTarget.y}
2610
3399
  fontSize={DEFAULT_FONT_SIZE}
2611
- color={DEFAULT_COLOR}
3400
+ color={inkColor}
2612
3401
  initialText=""
2613
3402
  onCommit={onCommitEdit}
2614
3403
  onCancel={onCancelEdit}
@@ -2629,13 +3418,26 @@ function TextEditor({
2629
3418
  anchorId: string;
2630
3419
  host: RectStroke | EllipseStroke | null;
2631
3420
  existing: TextStroke | undefined;
2632
- onCommit: (anchorId: string, text: string) => void;
3421
+ onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
2633
3422
  onCancel: () => void;
2634
3423
  }) {
2635
3424
  const ref = useRef<HTMLDivElement | null>(null);
2636
- const initial = existing?.text ?? '';
3425
+ // Show list markers WHILE editing so the read↔edit swap doesn't flicker
3426
+ // (item 4c) — stripped back to raw text on commit.
3427
+ const initial = listPrefixedBody(existing?.text ?? '', existing?.listType);
2637
3428
  const initialRef = useRef(initial);
2638
3429
  initialRef.current = initial;
3430
+ // Cmd/Ctrl+B/I/U formatting while editing (item 4d).
3431
+ const {
3432
+ fmtRef,
3433
+ style: fmtStyle,
3434
+ onFormatKey,
3435
+ } = useEditorFormat({
3436
+ bold: existing?.bold,
3437
+ italic: existing?.italic,
3438
+ underline: existing?.underline,
3439
+ strike: existing?.strike,
3440
+ });
2639
3441
 
2640
3442
  useEffect(() => {
2641
3443
  const el = ref.current;
@@ -2662,11 +3464,15 @@ function TextEditor({
2662
3464
  const el = ref.current;
2663
3465
  if (!el) return;
2664
3466
  if (el.contains(e.target as Node)) return;
2665
- onCommit(anchorId, el.innerText || '');
3467
+ onCommit(
3468
+ anchorId,
3469
+ stripEditorMarkers(el.innerText || '', existing?.listType),
3470
+ fmtRef.current
3471
+ );
2666
3472
  };
2667
3473
  document.addEventListener('pointerdown', onDown, true);
2668
3474
  return () => document.removeEventListener('pointerdown', onDown, true);
2669
- }, [anchorId, onCommit]);
3475
+ }, [anchorId, onCommit, existing?.listType, fmtRef]);
2670
3476
 
2671
3477
  if (!host) return null;
2672
3478
  const bbox = strokeBBox(host);
@@ -2686,24 +3492,29 @@ function TextEditor({
2686
3492
  style={{
2687
3493
  width: '100%',
2688
3494
  height: '100%',
3495
+ // Column flex (NOT row) so contentEditable line breaks stack
3496
+ // vertically; justify-center keeps the block vertically centred in
3497
+ // the host. The pre-Task-5 row-flex laid lines out side-by-side
3498
+ // (item 4a — the mangled multi-line look).
2689
3499
  display: 'flex',
2690
- alignItems: 'center',
2691
- justifyContent:
2692
- align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
3500
+ flexDirection: 'column',
3501
+ justifyContent: 'center',
3502
+ alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
2693
3503
  padding: '0 8px',
2694
3504
  boxSizing: 'border-box',
2695
3505
  textAlign: align,
3506
+ whiteSpace: 'pre-wrap',
2696
3507
  color: existing?.color ?? '#1a1a1a',
2697
3508
  fontSize: `${fontSize}px`,
2698
3509
  fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
2699
- fontWeight: existing?.bold ? 700 : undefined,
2700
- textDecoration: existing?.strike ? 'line-through' : undefined,
3510
+ ...fmtStyle,
2701
3511
  lineHeight: 1.25,
2702
3512
  outline: 'none',
2703
3513
  background: 'transparent',
2704
3514
  cursor: 'text',
2705
3515
  }}
2706
3516
  onKeyDown={(e) => {
3517
+ if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
2707
3518
  if (e.key === 'Escape') {
2708
3519
  e.preventDefault();
2709
3520
  onCancel();
@@ -2712,7 +3523,11 @@ function TextEditor({
2712
3523
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
2713
3524
  e.preventDefault();
2714
3525
  const el = ref.current;
2715
- onCommit(anchorId, el?.innerText || '');
3526
+ onCommit(
3527
+ anchorId,
3528
+ stripEditorMarkers(el?.innerText || '', existing?.listType),
3529
+ fmtRef.current
3530
+ );
2716
3531
  }
2717
3532
  }}
2718
3533
  >
@@ -2731,13 +3546,30 @@ function StickyEditor({
2731
3546
  onCancel,
2732
3547
  }: {
2733
3548
  sticky: StickyStroke;
2734
- onCommit: (text: string) => void;
3549
+ onCommit: (text: string, fmt?: EditorFmt) => void;
2735
3550
  onCancel: () => void;
2736
3551
  }) {
2737
3552
  // A flex-centered contentEditable (NOT a textarea) so the edit view matches
2738
3553
  // the committed `.dc-sticky-body` exactly — text stays centered, no jump on
2739
- // commit. Multi-line: Enter inserts a line break; Esc cancels; blur commits.
3554
+ // commit. Multi-line: Enter inserts a line break; Esc cancels; blur / Cmd+Enter
3555
+ // commit; Cmd/Ctrl+B/I/U format (unified with the other editors — item 4d).
2740
3556
  const ref = useRef<HTMLDivElement | null>(null);
3557
+ const doneRef = useRef(false);
3558
+ const {
3559
+ fmtRef,
3560
+ style: fmtStyle,
3561
+ onFormatKey,
3562
+ } = useEditorFormat({
3563
+ bold: sticky.bold,
3564
+ italic: sticky.italic,
3565
+ underline: sticky.underline,
3566
+ strike: sticky.strike,
3567
+ });
3568
+ const commit = () => {
3569
+ if (doneRef.current) return;
3570
+ doneRef.current = true;
3571
+ onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
3572
+ };
2741
3573
  useEffect(() => {
2742
3574
  const el = ref.current;
2743
3575
  if (!el) return;
@@ -2767,16 +3599,25 @@ function StickyEditor({
2767
3599
  contentEditable
2768
3600
  suppressContentEditableWarning
2769
3601
  aria-label="Edit sticky note text"
2770
- style={{ ...stickyBodyStyle(sticky), outline: 'none', cursor: 'text' }}
2771
- onBlur={() => onCommit(ref.current?.innerText ?? '')}
3602
+ style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
3603
+ onBlur={commit}
2772
3604
  onKeyDown={(e) => {
3605
+ if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
2773
3606
  if (e.key === 'Escape') {
2774
3607
  e.preventDefault();
3608
+ doneRef.current = true; // suppress the unmount blur-commit
2775
3609
  onCancel();
3610
+ return;
3611
+ }
3612
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
3613
+ e.preventDefault();
3614
+ commit();
2776
3615
  }
2777
3616
  }}
2778
3617
  >
2779
- {sticky.text}
3618
+ {/* Show the list markers while editing (item 4c) so the read↔edit swap
3619
+ doesn't flicker; stripped back to raw text on commit. */}
3620
+ {stickyBodyText(sticky)}
2780
3621
  </div>
2781
3622
  </foreignObject>
2782
3623
  );
@@ -2791,8 +3632,11 @@ function StandaloneTextEditor({
2791
3632
  color,
2792
3633
  initialText,
2793
3634
  bold,
3635
+ italic,
2794
3636
  strike,
3637
+ underline,
2795
3638
  align,
3639
+ listType,
2796
3640
  onCommit,
2797
3641
  onCancel,
2798
3642
  }: {
@@ -2802,12 +3646,38 @@ function StandaloneTextEditor({
2802
3646
  color: string;
2803
3647
  initialText: string;
2804
3648
  bold?: boolean;
3649
+ italic?: boolean;
2805
3650
  strike?: boolean;
3651
+ underline?: boolean;
2806
3652
  align?: TextAlign;
2807
- onCommit: (text: string) => void;
3653
+ listType?: ListType;
3654
+ onCommit: (text: string, fmt?: EditorFmt) => void;
2808
3655
  onCancel: () => void;
2809
3656
  }) {
2810
3657
  const ref = useRef<HTMLDivElement | null>(null);
3658
+ // Cmd/Ctrl+B/I/U formatting while editing (item 4d).
3659
+ const {
3660
+ fmtRef,
3661
+ style: fmtStyle,
3662
+ onFormatKey,
3663
+ } = useEditorFormat({
3664
+ bold,
3665
+ italic,
3666
+ underline,
3667
+ strike,
3668
+ });
3669
+ // Single-fire commit guard — outside-click + blur can both fire in one tick;
3670
+ // without this the text would commit twice (two undo records). Markers shown
3671
+ // while editing (item 4c) are stripped back to raw text here on commit.
3672
+ const doneRef = useRef(false);
3673
+ const commitOnce = useCallback(
3674
+ (text: string) => {
3675
+ if (doneRef.current) return;
3676
+ doneRef.current = true;
3677
+ onCommit(stripEditorMarkers(text, listType), fmtRef.current);
3678
+ },
3679
+ [onCommit, listType, fmtRef]
3680
+ );
2811
3681
  useEffect(() => {
2812
3682
  const el = ref.current;
2813
3683
  if (!el) return;
@@ -2831,13 +3701,16 @@ function StandaloneTextEditor({
2831
3701
  const el = ref.current;
2832
3702
  if (!el) return;
2833
3703
  if (el.contains(e.target as Node)) return;
2834
- onCommit(el.innerText || '');
3704
+ commitOnce(el.innerText || '');
2835
3705
  };
2836
3706
  document.addEventListener('pointerdown', onDown, true);
2837
3707
  return () => document.removeEventListener('pointerdown', onDown, true);
2838
- }, [onCommit]);
3708
+ }, [commitOnce]);
2839
3709
  return (
2840
- <foreignObject x={x} y={y} width={600} height={Math.max(24, fontSize * 1.6)}>
3710
+ // Generous box so multi-line text isn't clipped while typing (item 4a). The
3711
+ // empty area is transparent + pointer-pass-through (the SVG root is
3712
+ // pointer-events:none), so outside-click still commits.
3713
+ <foreignObject x={x} y={y} width={640} height={480}>
2841
3714
  <div
2842
3715
  xmlns="http://www.w3.org/1999/xhtml"
2843
3716
  ref={ref}
@@ -2848,32 +3721,38 @@ function StandaloneTextEditor({
2848
3721
  style={{
2849
3722
  display: 'inline-block',
2850
3723
  minWidth: '8px',
2851
- whiteSpace: 'pre',
3724
+ // pre-wrap so Enter inserts a real newline (multi-line text), not a
3725
+ // commit; long lines also wrap within the box.
3726
+ whiteSpace: 'pre-wrap',
2852
3727
  padding: '0 2px',
2853
3728
  color,
2854
3729
  fontSize: `${fontSize}px`,
2855
3730
  fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
2856
- fontWeight: bold ? 700 : undefined,
2857
- textDecoration: strike ? 'line-through' : undefined,
3731
+ ...fmtStyle,
2858
3732
  textAlign: align ?? 'left',
2859
- lineHeight: 1.2,
3733
+ lineHeight: TEXT_LINE_HEIGHT,
2860
3734
  outline: 'none',
2861
3735
  background: 'transparent',
2862
3736
  cursor: 'text',
2863
3737
  }}
3738
+ onBlur={() => commitOnce(ref.current?.innerText || '')}
2864
3739
  onKeyDown={(e) => {
3740
+ if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
2865
3741
  if (e.key === 'Escape') {
2866
3742
  e.preventDefault();
3743
+ // Mark done so the unmount blur that follows doesn't commit.
3744
+ doneRef.current = true;
2867
3745
  onCancel();
2868
3746
  return;
2869
3747
  }
2870
- if (e.key === 'Enter' && !e.shiftKey) {
3748
+ // Cmd/Ctrl+Enter commits; plain Enter inserts a newline (item 4a).
3749
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
2871
3750
  e.preventDefault();
2872
- onCommit(ref.current?.innerText || '');
3751
+ commitOnce(ref.current?.innerText || '');
2873
3752
  }
2874
3753
  }}
2875
3754
  >
2876
- {initialText}
3755
+ {listPrefixedBody(initialText, listType)}
2877
3756
  </div>
2878
3757
  </foreignObject>
2879
3758
  );
@@ -3002,12 +3881,46 @@ function stickyBodyStyle(s: StickyStroke): CSSProperties {
3002
3881
  return {
3003
3882
  fontSize: `${s.fontSize}px`,
3004
3883
  fontWeight: s.bold ? 700 : undefined,
3005
- textDecoration: s.strike ? 'line-through' : undefined,
3884
+ fontStyle: s.italic ? 'italic' : undefined,
3885
+ textDecoration: textDecoCss(s.strike, s.underline),
3006
3886
  textAlign: align,
3007
3887
  justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
3008
3888
  };
3009
3889
  }
3010
3890
 
3891
+ /** Sticky body content: raw text for a plain card, else per-line with list
3892
+ * markers prepended (item 4c — markers are render-only). */
3893
+ function stickyBodyText(s: StickyStroke): string {
3894
+ if (!s.listType) return s.text;
3895
+ return splitTextLines(s.text)
3896
+ .map((line, i) => listPrefixedLine(line, i, s.listType))
3897
+ .join('\n');
3898
+ }
3899
+
3900
+ /**
3901
+ * Render the inner content of a `<text>` stroke: a single string for single-
3902
+ * line unstyled text (item 4a parity with the legacy form), else one `<tspan>`
3903
+ * per line with list markers prepended (item 4c). `tx` is the per-line origin;
3904
+ * `centered` lifts the block half its height for vertically-centred anchored
3905
+ * text. Mirrors `textInnerSvg` so the live + persisted geometry agree.
3906
+ */
3907
+ function renderTextLines(
3908
+ text: string,
3909
+ fontSize: number,
3910
+ tx: number,
3911
+ centered: boolean,
3912
+ list?: ListType
3913
+ ) {
3914
+ if (!list && !text.includes('\n')) return text;
3915
+ const lines = splitTextLines(text);
3916
+ return lines.map((line, i) => (
3917
+ // biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + immutable per render
3918
+ <tspan key={i} x={tx} dy={textLineDy(i, fontSize, lines.length, centered)}>
3919
+ {listPrefixedLine(line, i, list)}
3920
+ </tspan>
3921
+ ));
3922
+ }
3923
+
3011
3924
  /**
3012
3925
  * Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
3013
3926
  * `pointer-events:none`, never added to `strokes`, so it can't be selected,
@@ -3091,12 +4004,14 @@ function StrokeNode({
3091
4004
  const strokeHit = interactive ? 'stroke' : ('none' as const);
3092
4005
  if (stroke.tool === 'text') {
3093
4006
  // Anchored text renders centered in its host; standalone (Phase 21) renders
3094
- // top-left-anchored at its own world (x, y).
3095
- // Phase 24 — bold / strike / align applied to the rendered <text>.
4007
+ // top-left-anchored at its own world (x, y). bold / italic / strike /
4008
+ // underline applied to the rendered <text>; multi-line + list markers via
4009
+ // renderTextLines (one <tspan> per line).
3096
4010
  const textStyle = {
3097
4011
  fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
3098
4012
  fontWeight: stroke.bold ? 700 : undefined,
3099
- textDecoration: stroke.strike ? 'line-through' : undefined,
4013
+ fontStyle: stroke.italic ? 'italic' : undefined,
4014
+ textDecoration: textDecoCss(stroke.strike, stroke.underline),
3100
4015
  } as const;
3101
4016
  if (stroke.anchorId != null && stroke.anchorId !== '') {
3102
4017
  const host = anchorsById.get(stroke.anchorId);
@@ -3126,18 +4041,19 @@ function StrokeNode({
3126
4041
  dominantBaseline="middle"
3127
4042
  style={textStyle}
3128
4043
  >
3129
- {stroke.text}
4044
+ {renderTextLines(stroke.text, stroke.fontSize, tx, true, stroke.listType)}
3130
4045
  </text>
3131
4046
  );
3132
4047
  }
3133
4048
  const align = stroke.align ?? 'left';
3134
4049
  const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
4050
+ const tx = stroke.x ?? 0;
3135
4051
  return (
3136
4052
  <text
3137
4053
  data-id={stroke.id}
3138
4054
  data-tool="text"
3139
4055
  data-font-size={stroke.fontSize}
3140
- x={stroke.x ?? 0}
4056
+ x={tx}
3141
4057
  y={stroke.y ?? 0}
3142
4058
  fill={stroke.color}
3143
4059
  fontSize={stroke.fontSize}
@@ -3146,7 +4062,7 @@ function StrokeNode({
3146
4062
  pointerEvents={interactive ? 'visiblePainted' : 'none'}
3147
4063
  style={textStyle}
3148
4064
  >
3149
- {stroke.text}
4065
+ {renderTextLines(stroke.text, stroke.fontSize, tx, false, stroke.listType)}
3150
4066
  </text>
3151
4067
  );
3152
4068
  }
@@ -3159,14 +4075,11 @@ function StrokeNode({
3159
4075
  return (
3160
4076
  <g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
3161
4077
  {/* Paper card: soft drop shadow + hairline edge so it reads as a
3162
- lifted sticky, not a flat colored box (FigJam-style). */}
3163
- <rect
3164
- x={x}
3165
- y={y}
3166
- width={w}
3167
- height={h}
3168
- rx={r}
3169
- ry={r}
4078
+ lifted sticky, not a flat colored box (FigJam-style). The body is a
4079
+ path with a SHARP bottom-right corner (item 1) — TL/TR/BL rounded.
4080
+ The persisted form stays a <rect> (DDR), so this is render-only. */}
4081
+ <path
4082
+ d={stickyCornerPath(x, y, w, h, r)}
3170
4083
  fill={stroke.color}
3171
4084
  stroke="rgba(0,0,0,0.05)"
3172
4085
  strokeWidth={1}
@@ -3180,13 +4093,105 @@ function StrokeNode({
3180
4093
  className="dc-sticky-body"
3181
4094
  style={stickyBodyStyle(stroke)}
3182
4095
  >
3183
- {stroke.text}
4096
+ {stickyBodyText(stroke)}
3184
4097
  </div>
3185
4098
  </foreignObject>
3186
4099
  )}
3187
4100
  </g>
3188
4101
  );
3189
4102
  }
4103
+ if (stroke.tool === 'image') {
4104
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4105
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4106
+ const w = Math.abs(stroke.w);
4107
+ const h = Math.abs(stroke.h);
4108
+ return (
4109
+ <image
4110
+ data-id={stroke.id}
4111
+ data-tool="image"
4112
+ x={x}
4113
+ y={y}
4114
+ width={w}
4115
+ height={h}
4116
+ href={resolveAssetHref(stroke.href)}
4117
+ preserveAspectRatio="xMidYMid meet"
4118
+ aria-label={stroke.alt || undefined}
4119
+ pointerEvents={hitMode}
4120
+ />
4121
+ );
4122
+ }
4123
+ if (stroke.tool === 'link') {
4124
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4125
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4126
+ const w = Math.abs(stroke.w);
4127
+ const h = Math.abs(stroke.h);
4128
+ const lay = linkCardLayout(x, y, w, h);
4129
+ const shownTitle = clampLinkTitle(stroke.title, lay.textMaxChars);
4130
+ const textFont = {
4131
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4132
+ } as const;
4133
+ return (
4134
+ <g
4135
+ data-id={stroke.id}
4136
+ data-tool="link"
4137
+ data-url={stroke.url}
4138
+ data-title={stroke.title}
4139
+ data-domain={stroke.domain}
4140
+ pointerEvents={hitMode}
4141
+ >
4142
+ <rect
4143
+ x={x}
4144
+ y={y}
4145
+ width={w}
4146
+ height={h}
4147
+ rx={8}
4148
+ ry={8}
4149
+ fill={LINK_CARD_FILL}
4150
+ stroke={LINK_CARD_STROKE}
4151
+ strokeWidth={1}
4152
+ vectorEffect="non-scaling-stroke"
4153
+ filter="url(#dc-sticky-shadow)"
4154
+ />
4155
+ <svg
4156
+ x={lay.glyph.x}
4157
+ y={lay.glyph.y}
4158
+ width={lay.glyph.size}
4159
+ height={lay.glyph.size}
4160
+ viewBox="0 0 24 24"
4161
+ fill="none"
4162
+ stroke={LINK_GLYPH_STROKE}
4163
+ strokeWidth={2}
4164
+ strokeLinecap="round"
4165
+ strokeLinejoin="round"
4166
+ aria-hidden="true"
4167
+ >
4168
+ <path d={LINK_GLYPH_D1} />
4169
+ <path d={LINK_GLYPH_D2} />
4170
+ </svg>
4171
+ <text
4172
+ x={lay.textX}
4173
+ y={lay.domain.y}
4174
+ fontSize={lay.domain.fontSize}
4175
+ fill={LINK_DOMAIN_FILL}
4176
+ dominantBaseline="hanging"
4177
+ style={textFont}
4178
+ >
4179
+ {stroke.domain}
4180
+ </text>
4181
+ <text
4182
+ x={lay.textX}
4183
+ y={lay.title.y}
4184
+ fontSize={lay.title.fontSize}
4185
+ fill={LINK_TITLE_FILL}
4186
+ fontWeight={600}
4187
+ dominantBaseline="hanging"
4188
+ style={textFont}
4189
+ >
4190
+ {shownTitle}
4191
+ </text>
4192
+ </g>
4193
+ );
4194
+ }
3190
4195
  const common = {
3191
4196
  'data-id': stroke.id,
3192
4197
  'data-tool': stroke.tool,
@@ -3197,7 +4202,17 @@ function StrokeNode({
3197
4202
  vectorEffect: 'non-scaling-stroke' as const,
3198
4203
  };
3199
4204
  if (stroke.tool === 'pen') {
3200
- return <path {...common} fill="none" d={penPathD(stroke.points)} pointerEvents={strokeHit} />;
4205
+ // Highlighter (item 8) overlaps darken via multiply; the translucent hue
4206
+ // lives in `stroke.color`, the wide nib in `stroke.width`.
4207
+ return (
4208
+ <path
4209
+ {...common}
4210
+ fill="none"
4211
+ d={penPathD(stroke.points)}
4212
+ style={stroke.highlighter ? { mixBlendMode: 'multiply' } : undefined}
4213
+ pointerEvents={strokeHit}
4214
+ />
4215
+ );
3201
4216
  }
3202
4217
  if (stroke.tool === 'rect') {
3203
4218
  const x = Math.min(stroke.x, stroke.x + stroke.w);
@@ -3213,6 +4228,7 @@ function StrokeNode({
3213
4228
  height={Math.abs(stroke.h)}
3214
4229
  rx={r}
3215
4230
  ry={r}
4231
+ strokeDasharray={stroke.dashed ? '6 4' : undefined}
3216
4232
  pointerEvents={hitMode}
3217
4233
  />
3218
4234
  );
@@ -3226,6 +4242,7 @@ function StrokeNode({
3226
4242
  cy={stroke.cy}
3227
4243
  rx={Math.max(0, stroke.rx)}
3228
4244
  ry={Math.max(0, stroke.ry)}
4245
+ strokeDasharray={stroke.dashed ? '6 4' : undefined}
3229
4246
  pointerEvents={hitMode}
3230
4247
  />
3231
4248
  );
@@ -3284,10 +4301,15 @@ function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
3284
4301
 
3285
4302
  function AnnotationsChrome({
3286
4303
  tool,
4304
+ theme,
3287
4305
  color,
3288
4306
  setColor,
3289
4307
  stickyColor,
3290
4308
  setStickyColor,
4309
+ highlighterColor,
4310
+ setHighlighterColor,
4311
+ highlighterWidth,
4312
+ setHighlighterWidth,
3291
4313
  supportsFill,
3292
4314
  fill,
3293
4315
  setFill,
@@ -3296,10 +4318,17 @@ function AnnotationsChrome({
3296
4318
  setThickness,
3297
4319
  }: {
3298
4320
  tool: Tool;
4321
+ /** Canvas theme — the ink swatch (slot 8) renders the themed default so the
4322
+ * active default reads true on dark canvases. */
4323
+ theme: string;
3299
4324
  color: string;
3300
4325
  setColor: (c: string) => void;
3301
4326
  stickyColor: string;
3302
4327
  setStickyColor: (c: string) => void;
4328
+ highlighterColor: string;
4329
+ setHighlighterColor: (c: string) => void;
4330
+ highlighterWidth: number;
4331
+ setHighlighterWidth: (w: number) => void;
3303
4332
  supportsFill: boolean;
3304
4333
  fill: string | null;
3305
4334
  setFill: (f: string | null) => void;
@@ -3329,21 +4358,79 @@ function AnnotationsChrome({
3329
4358
  </div>
3330
4359
  );
3331
4360
  }
4361
+ // Highlighter picks a translucent marker hue (item 8) — its own palette,
4362
+ // mirroring sticky. Each swatch previews on a dark chip via its alpha.
4363
+ if (tool === 'highlighter') {
4364
+ return (
4365
+ <div className="dc-annot-chrome" role="toolbar" aria-label="Highlighter tools">
4366
+ <div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter color">
4367
+ {HIGHLIGHTER_PALETTE.map((c) => (
4368
+ <button
4369
+ key={c}
4370
+ type="button"
4371
+ className="dc-annot-sw"
4372
+ aria-pressed={c === highlighterColor}
4373
+ aria-label={`Highlighter color ${c}`}
4374
+ title={`Highlighter color ${c}`}
4375
+ style={{ background: c }}
4376
+ onClick={() => setHighlighterColor(c)}
4377
+ />
4378
+ ))}
4379
+ </div>
4380
+ <div className="dc-annot-sep" />
4381
+ {/* Nib width — three filled dots of increasing size (item 8). */}
4382
+ <div className="dc-annot-swatches" role="radiogroup" aria-label="Highlighter width">
4383
+ {HIGHLIGHTER_WIDTHS.map((w) => {
4384
+ const dot = Math.round(6 + (w / 28) * 8); // 8–14 px preview dot
4385
+ return (
4386
+ <button
4387
+ key={w}
4388
+ type="button"
4389
+ className="dc-annot-ibtn"
4390
+ aria-pressed={w === highlighterWidth}
4391
+ aria-label={`Highlighter width ${w}`}
4392
+ title={`Width ${w}px`}
4393
+ onClick={() => setHighlighterWidth(w)}
4394
+ >
4395
+ <span
4396
+ aria-hidden="true"
4397
+ style={{
4398
+ width: dot,
4399
+ height: dot,
4400
+ borderRadius: '50%',
4401
+ background: 'currentColor',
4402
+ display: 'inline-block',
4403
+ }}
4404
+ />
4405
+ </button>
4406
+ );
4407
+ })}
4408
+ </div>
4409
+ </div>
4410
+ );
4411
+ }
3332
4412
  return (
3333
4413
  <div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
3334
4414
  <div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
3335
- {STROKE_PALETTE.map((c) => (
3336
- <button
3337
- key={c}
3338
- type="button"
3339
- className="dc-annot-sw"
3340
- aria-pressed={c === color}
3341
- aria-label={`Color ${c}`}
3342
- title={`Color ${c}`}
3343
- style={{ background: c }}
3344
- onClick={() => setColor(c)}
3345
- />
3346
- ))}
4415
+ {STROKE_PALETTE.map((base, i) => {
4416
+ // The last slot is the default ink — render it themed so the active
4417
+ // default reads true on dark canvases (white-ish ink) without
4418
+ // touching the other hues. Key by the immutable base hex so a theme
4419
+ // flip recolours the swatch in place rather than remounting.
4420
+ const c = i === STROKE_PALETTE.length - 1 ? resolveDefaultInk(theme) : base;
4421
+ return (
4422
+ <button
4423
+ key={base}
4424
+ type="button"
4425
+ className="dc-annot-sw"
4426
+ aria-pressed={c === color}
4427
+ aria-label={`Color ${c}`}
4428
+ title={`Color ${c}`}
4429
+ style={{ background: c }}
4430
+ onClick={() => setColor(c)}
4431
+ />
4432
+ );
4433
+ })}
3347
4434
  </div>
3348
4435
  {supportsFill ? (
3349
4436
  <>