@heyramzi/ui 1.54.2 → 2.0.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 (361) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +198 -6
  4. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  5. package/src/ambient.d.ts +8 -0
  6. package/src/brand/brand.test.ts +67 -0
  7. package/src/brand/index.ts +137 -0
  8. package/src/canvas/README.md +236 -0
  9. package/src/canvas/card/card-frame.svelte +61 -0
  10. package/src/canvas/card/card.css +39 -0
  11. package/src/canvas/card/resize-handles.svelte +119 -0
  12. package/src/canvas/card/resize.ts +33 -0
  13. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  14. package/src/canvas/chrome/chrome.css +231 -0
  15. package/src/canvas/chrome/lock-badge.svelte +18 -0
  16. package/src/canvas/chrome/side-handles.svelte +30 -0
  17. package/src/canvas/chrome/size-chip.svelte +45 -0
  18. package/src/canvas/chrome/snap-guides.svelte +69 -0
  19. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  20. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  21. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  22. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  23. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  24. package/src/canvas/edges/bend-handle.svelte +76 -0
  25. package/src/canvas/edges/edge-gradient.svelte +30 -0
  26. package/src/canvas/edges/edge-path.test.ts +58 -0
  27. package/src/canvas/edges/edge-path.ts +293 -0
  28. package/src/canvas/edges/gradient-edge.svelte +43 -0
  29. package/src/canvas/editor/context-menu.test.ts +65 -0
  30. package/src/canvas/editor/context-menu.ts +48 -0
  31. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  32. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  33. package/src/canvas/editor/editor-context.ts +62 -0
  34. package/src/canvas/editor/editor-mode.ts +67 -0
  35. package/src/canvas/editor/frame.ts +98 -0
  36. package/src/canvas/editor/hand-context.ts +15 -0
  37. package/src/canvas/editor/history-core.test.ts +239 -0
  38. package/src/canvas/editor/history-core.ts +247 -0
  39. package/src/canvas/editor/keymap.test.ts +161 -0
  40. package/src/canvas/editor/keymap.ts +151 -0
  41. package/src/canvas/editor/layout-actions.test.ts +156 -0
  42. package/src/canvas/editor/layout-actions.ts +219 -0
  43. package/src/canvas/editor/lock.test.ts +68 -0
  44. package/src/canvas/editor/lock.ts +87 -0
  45. package/src/canvas/editor/rebase.test.ts +208 -0
  46. package/src/canvas/editor/rebase.ts +272 -0
  47. package/src/canvas/editor/style.ts +184 -0
  48. package/src/canvas/editor/text-field-context.ts +41 -0
  49. package/src/canvas/editor/z-order.test.ts +75 -0
  50. package/src/canvas/editor/z-order.ts +87 -0
  51. package/src/canvas/headless/alignment.test.ts +59 -0
  52. package/src/canvas/headless/alignment.ts +23 -0
  53. package/src/canvas/headless/break-apart-find.ts +418 -0
  54. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  55. package/src/canvas/headless/break-apart-plan.ts +439 -0
  56. package/src/canvas/headless/break-apart-style.ts +106 -0
  57. package/src/canvas/headless/break-apart.ts +244 -0
  58. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  59. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  60. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  61. package/src/canvas/headless/edge-anchors.ts +143 -0
  62. package/src/canvas/headless/edge-memo.test.ts +58 -0
  63. package/src/canvas/headless/edge-memo.ts +32 -0
  64. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  65. package/src/canvas/headless/edge-serialization.ts +32 -0
  66. package/src/canvas/headless/figure-geometry.ts +293 -0
  67. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  68. package/src/canvas/headless/fit-viewport.ts +137 -0
  69. package/src/canvas/headless/grid.test.ts +34 -0
  70. package/src/canvas/headless/grid.ts +94 -0
  71. package/src/canvas/headless/icon-node.test.ts +23 -0
  72. package/src/canvas/headless/icon-node.ts +66 -0
  73. package/src/canvas/headless/index.ts +9 -0
  74. package/src/canvas/headless/linkify.test.ts +23 -0
  75. package/src/canvas/headless/linkify.ts +25 -0
  76. package/src/canvas/headless/pen-marks.ts +133 -0
  77. package/src/canvas/headless/sketch.test.ts +99 -0
  78. package/src/canvas/headless/sketch.ts +302 -0
  79. package/src/canvas/headless/snap-guides.test.ts +61 -0
  80. package/src/canvas/headless/snap-guides.ts +146 -0
  81. package/src/canvas/headless/svg-figure.test.ts +49 -0
  82. package/src/canvas/headless/svg-figure.ts +176 -0
  83. package/src/canvas/headless/table-layout.test.ts +158 -0
  84. package/src/canvas/headless/table-layout.ts +309 -0
  85. package/src/canvas/index.ts +35 -0
  86. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  87. package/src/canvas/lod/lod-switch.svelte +140 -0
  88. package/src/canvas/lod/render-all.svelte.ts +52 -0
  89. package/src/canvas/ops/arrange.ts +108 -0
  90. package/src/canvas/ops/ops.test.ts +98 -0
  91. package/src/canvas/ops/ops.ts +347 -0
  92. package/src/canvas/pen/PenField.svelte +128 -0
  93. package/src/canvas/pen/PenField.test.ts +87 -0
  94. package/src/canvas/pen/PenOverlay.svelte +172 -0
  95. package/src/canvas/pen/ShapeNode.svelte +64 -0
  96. package/src/canvas/pen/TextNode.svelte +97 -0
  97. package/src/canvas/pen/colors.ts +28 -0
  98. package/src/canvas/pen/eraser.ts +46 -0
  99. package/src/canvas/pen/geometry.ts +148 -0
  100. package/src/canvas/pen/host-field.test.svelte +13 -0
  101. package/src/canvas/pen/index.ts +40 -0
  102. package/src/canvas/pen/ops.ts +16 -0
  103. package/src/canvas/pen/pen-session.ts +177 -0
  104. package/src/canvas/pen/pen.css +149 -0
  105. package/src/canvas/pen/plugin.ts +139 -0
  106. package/src/canvas/pen/shapes.ts +104 -0
  107. package/src/canvas/pen/stroke-path.ts +54 -0
  108. package/src/canvas/pen/types.ts +96 -0
  109. package/src/canvas/pen/ui.ts +26 -0
  110. package/src/canvas/plugin.test.ts +45 -0
  111. package/src/canvas/plugin.ts +159 -0
  112. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  113. package/src/canvas/screen/PartView.svelte +176 -0
  114. package/src/canvas/screen/ScreenBody.svelte +142 -0
  115. package/src/canvas/screen/ScreenCard.svelte +32 -0
  116. package/src/canvas/screen/glyphs/button.svg +1 -0
  117. package/src/canvas/screen/glyphs/close.svg +1 -0
  118. package/src/canvas/screen/glyphs/down.svg +1 -0
  119. package/src/canvas/screen/glyphs/header.svg +1 -0
  120. package/src/canvas/screen/glyphs/hills.svg +1 -0
  121. package/src/canvas/screen/glyphs/image.svg +1 -0
  122. package/src/canvas/screen/glyphs/input.svg +1 -0
  123. package/src/canvas/screen/glyphs/list.svg +1 -0
  124. package/src/canvas/screen/glyphs/modal.svg +1 -0
  125. package/src/canvas/screen/glyphs/plus.svg +1 -0
  126. package/src/canvas/screen/glyphs/up.svg +1 -0
  127. package/src/canvas/screen/glyphs.ts +40 -0
  128. package/src/canvas/screen/labels.ts +41 -0
  129. package/src/canvas/screen/parts.ts +164 -0
  130. package/src/canvas/screen/plugin.ts +67 -0
  131. package/src/canvas/screen/screen.css +593 -0
  132. package/src/canvas/screen/types.ts +47 -0
  133. package/src/canvas/screen/ui.ts +13 -0
  134. package/src/canvas/test-utils.ts +18 -0
  135. package/src/canvas/types.ts +53 -0
  136. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  137. package/src/canvas/ui/FrameLayer.svelte +116 -0
  138. package/src/canvas/ui/PenLayer.svelte +39 -0
  139. package/src/canvas/ui/StylePanel.svelte +268 -0
  140. package/src/canvas/ui/canvas-grid.svelte +156 -0
  141. package/src/canvas/ui/canvas-ui.css +350 -0
  142. package/src/canvas/ui/forward-wheel.ts +29 -0
  143. package/src/canvas/ui/icons.ts +124 -0
  144. package/src/canvas/ui/place-panel.ts +80 -0
  145. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  146. package/src/canvas/ui/shortcut-tip.ts +17 -0
  147. package/src/canvas/ui/tools.ts +146 -0
  148. package/src/charts/README.md +104 -0
  149. package/src/charts/ambient.d.ts +4 -0
  150. package/src/charts/animated-value.css +7 -0
  151. package/src/charts/animated-value.svelte +110 -0
  152. package/src/charts/area-chart.css +29 -0
  153. package/src/charts/area-chart.svelte +204 -0
  154. package/src/charts/bar-chart.css +119 -0
  155. package/src/charts/bar-chart.svelte +275 -0
  156. package/src/charts/charts.css +201 -0
  157. package/src/charts/crosshair.svelte +35 -0
  158. package/src/charts/dashboard/chart.ts +5 -0
  159. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  160. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  161. package/src/charts/dashboard/data-panel.svelte +81 -0
  162. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  163. package/src/charts/dashboard/data-table.svelte +278 -0
  164. package/src/charts/dashboard/data-table.ts +26 -0
  165. package/src/charts/dashboard/index.ts +25 -0
  166. package/src/charts/dashboard/panel-grid.svelte +43 -0
  167. package/src/charts/dashboard/period-filter.svelte +53 -0
  168. package/src/charts/dashboard/rating-chip.svelte +35 -0
  169. package/src/charts/dashboard/stat-band.svelte +47 -0
  170. package/src/charts/dashboard/stat-tile.svelte +176 -0
  171. package/src/charts/dashboard/windows.ts +8 -0
  172. package/src/charts/donut-chart.css +56 -0
  173. package/src/charts/donut-chart.svelte +158 -0
  174. package/src/charts/edge-fade.svelte +43 -0
  175. package/src/charts/edge-fade.ts +16 -0
  176. package/src/charts/figure.css +100 -0
  177. package/src/charts/figure.svelte +122 -0
  178. package/src/charts/gauge-chart.css +94 -0
  179. package/src/charts/gauge-chart.svelte +302 -0
  180. package/src/charts/hover-band.test.ts +30 -0
  181. package/src/charts/hover-band.ts +21 -0
  182. package/src/charts/hover-focus.svelte +85 -0
  183. package/src/charts/index.ts +31 -0
  184. package/src/charts/legend.css +136 -0
  185. package/src/charts/legend.svelte +112 -0
  186. package/src/charts/line-chart.css +26 -0
  187. package/src/charts/line-chart.svelte +256 -0
  188. package/src/charts/motion.ts +19 -0
  189. package/src/charts/number-chart.css +179 -0
  190. package/src/charts/number-chart.svelte +187 -0
  191. package/src/charts/primitives/chart-container.svelte +57 -0
  192. package/src/charts/primitives/chart-style.svelte +36 -0
  193. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  194. package/src/charts/primitives/chart-utils.ts +54 -0
  195. package/src/charts/primitives/index.ts +11 -0
  196. package/src/charts/reference-line.css +18 -0
  197. package/src/charts/reference-line.svelte +31 -0
  198. package/src/charts/reveal.svelte +61 -0
  199. package/src/charts/ring-chart.css +104 -0
  200. package/src/charts/ring-chart.svelte +166 -0
  201. package/src/charts/sparkline.css +45 -0
  202. package/src/charts/sparkline.svelte +141 -0
  203. package/src/charts/stat-card.css +105 -0
  204. package/src/charts/stat-card.svelte +267 -0
  205. package/src/charts/time-series-chart.css +28 -0
  206. package/src/charts/time-series-chart.svelte +179 -0
  207. package/src/charts/trend-pill.css +38 -0
  208. package/src/charts/trend-pill.svelte +48 -0
  209. package/src/components/copy/copy-button.svelte +1 -1
  210. package/src/components/docs/doc-page.svelte +1 -1
  211. package/src/components/docs/on-this-page.svelte +2 -2
  212. package/src/components/list-page/empty-state.svelte +1 -1
  213. package/src/components/list-page/filter-popover.svelte +1 -1
  214. package/src/components/list-page/section-label.svelte +1 -1
  215. package/src/components/segmented/segment.svelte +4 -4
  216. package/src/components/theme/theme-provider.svelte +6 -4
  217. package/src/components/toolbar/index.ts +5 -0
  218. package/src/components/toolbar/toolbar.svelte +168 -0
  219. package/src/components/toolbar/toolbar.test.ts +66 -0
  220. package/src/editor/README.md +58 -0
  221. package/src/editor/ambient.d.ts +1 -0
  222. package/src/editor/block-id.ts +77 -0
  223. package/src/editor/block-ops.test.ts +237 -0
  224. package/src/editor/block-ops.ts +157 -0
  225. package/src/editor/content.css +418 -0
  226. package/src/editor/editor-toolbar.svelte +303 -0
  227. package/src/editor/editor.svelte +462 -0
  228. package/src/editor/extensions.ts +145 -0
  229. package/src/editor/image-toolbar.svelte +123 -0
  230. package/src/editor/index.ts +17 -0
  231. package/src/editor/inline-editor.svelte +261 -0
  232. package/src/editor/markdown-escaping.test.ts +158 -0
  233. package/src/editor/markdown-marks.test.ts +89 -0
  234. package/src/editor/markdown-test-helpers.ts +13 -0
  235. package/src/editor/markdown-youtube.test.ts +93 -0
  236. package/src/editor/markdown.test.ts +250 -0
  237. package/src/editor/markdown.ts +511 -0
  238. package/src/editor/renderer.svelte +92 -0
  239. package/src/editor/serializer.test.ts +133 -0
  240. package/src/editor/serializer.ts +370 -0
  241. package/src/editor/slash-items.ts +115 -0
  242. package/src/editor/slash-menu.svelte +159 -0
  243. package/src/editor/youtube-url.ts +35 -0
  244. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  245. package/src/lib/result.ts +19 -0
  246. package/src/onboarding/README.md +120 -0
  247. package/src/onboarding/define-tours.test.ts +50 -0
  248. package/src/onboarding/define-tours.ts +31 -0
  249. package/src/onboarding/demo.svelte +86 -0
  250. package/src/onboarding/index.ts +12 -0
  251. package/src/onboarding/onboarding-provider.svelte +120 -0
  252. package/src/onboarding/onboarding.svelte.ts +24 -0
  253. package/src/onboarding/pick.test.ts +79 -0
  254. package/src/onboarding/pick.ts +36 -0
  255. package/src/onboarding/stores.test.ts +103 -0
  256. package/src/onboarding/stores.ts +58 -0
  257. package/src/onboarding/tour-card.svelte +70 -0
  258. package/src/onboarding/tour-runner.svelte +197 -0
  259. package/src/onboarding/types.ts +34 -0
  260. package/src/onboarding/wait.ts +29 -0
  261. package/src/shadcn/command/command-input.svelte +1 -1
  262. package/src/slides/Controls.svelte +138 -0
  263. package/src/slides/Deck.svelte +271 -0
  264. package/src/slides/Fragment.svelte +71 -0
  265. package/src/slides/Overview.svelte +113 -0
  266. package/src/slides/Presenter.svelte +292 -0
  267. package/src/slides/README.md +89 -0
  268. package/src/slides/Slide.svelte +179 -0
  269. package/src/slides/SlideList.svelte +19 -0
  270. package/src/slides/ambient.d.ts +1 -0
  271. package/src/slides/bridges.ts +69 -0
  272. package/src/slides/deck-controller.svelte.ts +292 -0
  273. package/src/slides/demo-slides.svelte +358 -0
  274. package/src/slides/demo.svelte +176 -0
  275. package/src/slides/export/capture.ts +113 -0
  276. package/src/slides/export/ffmpeg.ts +120 -0
  277. package/src/slides/export/index.ts +9 -0
  278. package/src/slides/export/launch.ts +10 -0
  279. package/src/slides/export/pdf.ts +152 -0
  280. package/src/slides/export/types.ts +48 -0
  281. package/src/slides/index.ts +19 -0
  282. package/src/slides/labels.ts +48 -0
  283. package/src/slides/layout.ts +51 -0
  284. package/src/slides/model.ts +47 -0
  285. package/src/slides/navigation.ts +100 -0
  286. package/src/slides/theme.css +205 -0
  287. package/src/slides/types.ts +86 -0
  288. package/src/tasks/README.md +312 -0
  289. package/src/tasks/ambient.d.ts +3 -0
  290. package/src/tasks/board-order.ts +137 -0
  291. package/src/tasks/dates.ts +31 -0
  292. package/src/tasks/define-statuses.ts +32 -0
  293. package/src/tasks/demo.svelte +389 -0
  294. package/src/tasks/detail/detail-demo.svelte +186 -0
  295. package/src/tasks/detail/detail-types.ts +54 -0
  296. package/src/tasks/detail/task-chip.svelte +28 -0
  297. package/src/tasks/detail/task-detail.svelte +315 -0
  298. package/src/tasks/detail/task-property.svelte +17 -0
  299. package/src/tasks/detail/task-thread.svelte +258 -0
  300. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  301. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  302. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  303. package/src/tasks/gantt/gantt-header.svelte +71 -0
  304. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  305. package/src/tasks/gantt/gantt.css +22 -0
  306. package/src/tasks/gantt/gantt.test.ts +77 -0
  307. package/src/tasks/gantt/logic.test.ts +163 -0
  308. package/src/tasks/gantt/logic.ts +248 -0
  309. package/src/tasks/inbox/inbox-card.svelte +283 -0
  310. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  311. package/src/tasks/inbox/inbox-logic.ts +55 -0
  312. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  313. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  314. package/src/tasks/inbox/inbox-types.ts +108 -0
  315. package/src/tasks/inbox/inbox.svelte +203 -0
  316. package/src/tasks/inbox/index.ts +23 -0
  317. package/src/tasks/index.ts +85 -0
  318. package/src/tasks/kanban-board.svelte +561 -0
  319. package/src/tasks/kanban-column.svelte +117 -0
  320. package/src/tasks/kanban-drag.ts +38 -0
  321. package/src/tasks/kanban-types.ts +65 -0
  322. package/src/tasks/presets/sales.ts +16 -0
  323. package/src/tasks/record/demo-data.ts +286 -0
  324. package/src/tasks/record/field-section.svelte +53 -0
  325. package/src/tasks/record/fields.ts +142 -0
  326. package/src/tasks/record/format.ts +87 -0
  327. package/src/tasks/record/gutter.ts +2 -0
  328. package/src/tasks/record/index.ts +54 -0
  329. package/src/tasks/record/info-list.svelte +316 -0
  330. package/src/tasks/record/kinds.ts +42 -0
  331. package/src/tasks/record/logic.ts +324 -0
  332. package/src/tasks/record/next-up.svelte +70 -0
  333. package/src/tasks/record/project-list.svelte +133 -0
  334. package/src/tasks/record/record-demo.svelte +324 -0
  335. package/src/tasks/record/record-header.svelte +70 -0
  336. package/src/tasks/record/record-shell.svelte +138 -0
  337. package/src/tasks/record/record-stats.svelte +48 -0
  338. package/src/tasks/record/record-timeline.svelte +231 -0
  339. package/src/tasks/record/record.test.ts +350 -0
  340. package/src/tasks/record/steps-rail.svelte +474 -0
  341. package/src/tasks/record/types.ts +280 -0
  342. package/src/tasks/record/where-we-are.svelte +121 -0
  343. package/src/tasks/reorder.ts +19 -0
  344. package/src/tasks/side-panel-types.ts +16 -0
  345. package/src/tasks/side-panel.svelte +193 -0
  346. package/src/tasks/status-badge.svelte +63 -0
  347. package/src/tasks/status-dot.svelte +228 -0
  348. package/src/tasks/status-icon.svelte +30 -0
  349. package/src/tasks/status-types.ts +40 -0
  350. package/src/tasks/task-gantt.svelte +230 -0
  351. package/src/tasks/task-list.svelte +470 -0
  352. package/src/tasks/task-types.ts +33 -0
  353. package/src/tasks/time/duration.ts +69 -0
  354. package/src/tasks/time/time-chip.svelte +62 -0
  355. package/src/tasks/time/time-dot.svelte +58 -0
  356. package/src/tasks/time/time-ring.svelte +74 -0
  357. package/src/tasks/time/time-tracker.svelte +565 -0
  358. package/src/tasks/time/time-types.ts +74 -0
  359. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  360. package/src/tokens/base.css +5 -5
  361. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,96 @@
1
+ // The pen layer's node shapes. A stroke, a text and a shape are ordinary xyflow
2
+ // nodes, so undo, copy and save come from the node diffing the editor already does.
3
+
4
+ export const STROKE_NODE = "stroke";
5
+ export const TEXT_NODE = "text";
6
+ export const SHAPE_NODE = "shape";
7
+
8
+ /** Colours are token names, never hex, so a board drawn in light reads in dark. */
9
+ export const PEN_COLORS = [
10
+ "ink",
11
+ "red",
12
+ "blue",
13
+ "green",
14
+ "amber",
15
+ "violet",
16
+ "indigo",
17
+ "terra",
18
+ ] as const;
19
+ export type PenColor = (typeof PEN_COLORS)[number];
20
+
21
+ export type PenTool =
22
+ | "pen"
23
+ | "highlighter"
24
+ | "eraser"
25
+ | "text"
26
+ | "rectangle"
27
+ | "ellipse"
28
+ | "diamond";
29
+
30
+ export const SHAPE_KINDS = ["rectangle", "ellipse", "diamond"] as const;
31
+ export type ShapeKind = (typeof SHAPE_KINDS)[number];
32
+
33
+ /** x, y, then pressure from 0 to 1 when the device reported one. */
34
+ export type StrokePoint = readonly [number, number, number?];
35
+
36
+ /** A point as it arrives, from a pointer or a file. Anything past x, y and pressure is ignored. */
37
+ export type StrokeSample = StrokePoint | readonly number[];
38
+
39
+ export interface StrokeData extends Record<string, unknown> {
40
+ /** Relative to the node's position, already simplified. */
41
+ points: StrokePoint[];
42
+ color: PenColor;
43
+ /** Brush width in flow units. */
44
+ size: number;
45
+ /** The highlighter draws wide, flat and see-through. */
46
+ tool: "pen" | "highlighter";
47
+ /** False when real pen pressure is in the points. */
48
+ simulatePressure: boolean;
49
+ }
50
+
51
+ export interface TextData extends Record<string, unknown> {
52
+ text: string;
53
+ color: PenColor;
54
+ /** Font size in flow units. */
55
+ fontSize: number;
56
+ /** Bold, or the face's own weight when unset. */
57
+ fontWeight?: "regular" | "bold";
58
+ /** The node's width is a column the words wrap in, as on a board, not the width of the words. */
59
+ fixedWidth?: boolean;
60
+ align?: "left" | "center";
61
+ /** A hand rule under the text, as under a board title. */
62
+ underline?: boolean;
63
+ }
64
+
65
+ export interface ShapeData extends Record<string, unknown> {
66
+ shape: ShapeKind;
67
+ color: PenColor;
68
+ /** Line width in flow units. */
69
+ strokeWidth: number;
70
+ /** A faint wash inside the outline. */
71
+ filled: boolean;
72
+ /** The wash's own colour. Unset, it takes the outline's. */
73
+ fill?: PenColor;
74
+ label: string;
75
+ }
76
+
77
+ /** A node as the pen layer writes it, which is the addNode op's node field. */
78
+ export interface PenNodeInput<D extends Record<string, unknown>> {
79
+ id?: string;
80
+ type: string;
81
+ position: { x: number; y: number };
82
+ data: D;
83
+ width: number;
84
+ height: number;
85
+ }
86
+
87
+ export const PEN_DEFAULTS = {
88
+ pen: { size: 4 },
89
+ highlighter: { size: 20 },
90
+ strokeWidth: 3,
91
+ fontSize: 20,
92
+ color: "ink",
93
+ } as const satisfies { color: PenColor; [key: string]: unknown };
94
+
95
+ /** Highlighter ink is drawn at this opacity so what's under it still shows. */
96
+ export const HIGHLIGHTER_OPACITY = 0.38;
@@ -0,0 +1,26 @@
1
+ // The browser registers `penUiPlugin`, and a server registers `penPlugin`.
2
+ // Never both, because a kind has one owner.
3
+
4
+ import { definePlugin } from "../plugin.js";
5
+ import PenOverlay from "./PenOverlay.svelte";
6
+ import { penPlugin } from "./plugin.js";
7
+ import ShapeNode from "./ShapeNode.svelte";
8
+ import StrokeNode from "../components/shared/svg/StrokeNode.svelte";
9
+ import TextNode from "./TextNode.svelte";
10
+ import { SHAPE_NODE, STROKE_NODE, TEXT_NODE } from "./types.js";
11
+
12
+ export { PenOverlay, ShapeNode, StrokeNode, TextNode };
13
+
14
+ const components = {
15
+ [STROKE_NODE]: StrokeNode,
16
+ [TEXT_NODE]: TextNode,
17
+ [SHAPE_NODE]: ShapeNode,
18
+ };
19
+
20
+ export const penUiPlugin = definePlugin({
21
+ ...penPlugin,
22
+ nodes: (penPlugin.nodes ?? []).map((kind) => ({
23
+ ...kind,
24
+ component: components[kind.type as keyof typeof components],
25
+ })),
26
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { z } from "zod";
3
+ import { createRegistry, definePlugin } from "./plugin.js";
4
+
5
+ const ping = z.object({ kind: z.literal("ping") });
6
+
7
+ const a = definePlugin({
8
+ id: "a",
9
+ nodes: [
10
+ { type: "card", label: "Card", sizing: "content-sized" },
11
+ { type: "frame", label: "Frame", sizing: "layout-owned" },
12
+ ],
13
+ ops: [{ kind: "ping", schema: ping, apply: () => {} }],
14
+ layouts: [
15
+ {
16
+ id: "flat",
17
+ matches: (_d, o) => o.mode === "flat",
18
+ run: (d) => ({ nodes: d.nodes, edges: d.edges }),
19
+ },
20
+ ],
21
+ });
22
+
23
+ describe("createRegistry", () => {
24
+ it("indexes kinds, ops and sizing", () => {
25
+ const registry = createRegistry([a]);
26
+ expect(registry.nodes.get("card")?.label).toBe("Card");
27
+ expect([...registry.contentSizedTypes()]).toEqual(["card"]);
28
+ expect([...registry.layoutOwnedTypes()]).toEqual(["frame"]);
29
+ expect(registry.parseOp({ kind: "ping" })).toEqual({ kind: "ping" });
30
+ expect(() => registry.parseOp({ kind: "pong" })).toThrow(/Unknown op kind/);
31
+ });
32
+
33
+ it("picks the first layout pass that matches", () => {
34
+ const registry = createRegistry([a]);
35
+ expect(registry.pickLayout({ nodes: [], edges: [] }, { mode: "flat" })?.id).toBe("flat");
36
+ expect(registry.pickLayout({ nodes: [], edges: [] }, { mode: "tree" })).toBeNull();
37
+ });
38
+
39
+ it("refuses a node type or op kind two plugins both claim", () => {
40
+ const b = definePlugin({ id: "b", nodes: [{ type: "card", label: "Other" }] });
41
+ expect(() => createRegistry([a, b])).toThrow(/claimed by both "a" and "b"/);
42
+ const c = definePlugin({ id: "c", ops: [{ kind: "ping", schema: ping, apply: () => {} }] });
43
+ expect(() => createRegistry([a, c])).toThrow(/claimed by both/);
44
+ });
45
+ });
@@ -0,0 +1,159 @@
1
+ // How an app adds cards, ops and layouts to the canvas. It imports Svelte types
2
+ // only, so the server can build a registry too.
3
+
4
+ import type { NodeTypes } from "@xyflow/svelte";
5
+ import { z } from "zod";
6
+ import type { CanvasData, CanvasEdge, CanvasNode } from "./types.js";
7
+
8
+ // =========================
9
+ // NODE KINDS
10
+ // =========================
11
+
12
+ export interface NodeKind {
13
+ /** The value of `node.type`. Unique across every plugin. */
14
+ type: string;
15
+ label: string;
16
+ /** The Svelte component xyflow renders for this type. Absent on the server. */
17
+ component?: NodeTypes[string];
18
+ /** Palette placement. Absent means the kind is created by code or an op, never from the palette. */
19
+ palette?: { group: string; section?: string; order: number };
20
+ /** Size a new node gets before any layout or measuring. */
21
+ size?: { width: number; height?: number };
22
+ /** Starting `data` for a new node. */
23
+ createData?: () => Record<string, unknown>;
24
+ /** Parent types this kind may sit in. Empty means top level only. Absent means any. */
25
+ validParents?: readonly string[];
26
+ /** "layout-owned": a layout pass sets the size. "content-sized": the DOM sets the height. */
27
+ sizing?: "layout-owned" | "content-sized";
28
+ }
29
+
30
+ // =========================
31
+ // OPS
32
+ // =========================
33
+
34
+ /** The graph an op list rewrites, one op at a time. Handlers replace `nodes` and `edges`, never mutate them. */
35
+ export interface OpState {
36
+ nodes: Record<string, unknown>[];
37
+ edges: Record<string, unknown>[];
38
+ }
39
+
40
+ /** One op kind. `schema` checks the op's shape, `apply` rewrites the state or throws. */
41
+ export interface OpDefinition<Op extends OpLike = OpLike> {
42
+ kind: Op["kind"];
43
+ schema: z.ZodType<Op>;
44
+ // A method, so a definition for one op shape fits a list of them.
45
+ apply(state: OpState, op: Op, opIndex: number): void;
46
+ }
47
+
48
+ /** An op before its kind's schema has narrowed it: a kind plus whatever fields it carries. */
49
+ export type OpLike = { kind: string; [field: string]: unknown };
50
+
51
+ // =========================
52
+ // LAYOUTS
53
+ // =========================
54
+
55
+ export interface LayoutOptions {
56
+ /** Which pass the caller asks for by name. Absent lets each pass's `matches` decide. */
57
+ mode?: string;
58
+ /** DOM-measured sizes, folded in as layout input. */
59
+ measured?: ReadonlyMap<string, { width?: number; height?: number }>;
60
+ /** A pass reads what it needs from here: sibling order, spacing, anything else the app passes. */
61
+ extra?: Readonly<Record<string, unknown>>;
62
+ }
63
+
64
+ /** A layout pass. The first registered pass whose `matches` answers true runs. */
65
+ export interface LayoutPass {
66
+ id: string;
67
+ matches: (data: CanvasData, options: LayoutOptions) => boolean;
68
+ run: (data: CanvasData, options: LayoutOptions) => { nodes: CanvasNode[]; edges: CanvasEdge[] };
69
+ }
70
+
71
+ // =========================
72
+ // PLUGIN
73
+ // =========================
74
+
75
+ export interface CanvasPlugin {
76
+ /** A name for errors, like "pen" or "billing". Unique. */
77
+ id: string;
78
+ nodes?: readonly NodeKind[];
79
+ ops?: readonly OpDefinition[];
80
+ layouts?: readonly LayoutPass[];
81
+ }
82
+
83
+ /** Identity, so a plugin file gets its types checked where it is written. */
84
+ export function definePlugin(plugin: CanvasPlugin): CanvasPlugin {
85
+ return plugin;
86
+ }
87
+
88
+ // =========================
89
+ // REGISTRY
90
+ // =========================
91
+
92
+ export interface CanvasRegistry {
93
+ readonly plugins: readonly CanvasPlugin[];
94
+ readonly nodes: ReadonlyMap<string, NodeKind>;
95
+ readonly ops: ReadonlyMap<string, OpDefinition>;
96
+ readonly layouts: readonly LayoutPass[];
97
+ /** The `nodeTypes` map xyflow wants: type to component, for kinds that have one. */
98
+ nodeTypes: () => NodeTypes;
99
+ /** Types a layout leaves the height of to the DOM. */
100
+ contentSizedTypes: () => ReadonlySet<string>;
101
+ /** Types a layout sizes from their children. */
102
+ layoutOwnedTypes: () => ReadonlySet<string>;
103
+ /** Validates one op against its kind's schema. Throws a ZodError or an unknown-kind error. */
104
+ parseOp: (input: unknown) => { kind: string };
105
+ /** The first layout pass that matches, or null. */
106
+ pickLayout: (data: CanvasData, options?: LayoutOptions) => LayoutPass | null;
107
+ }
108
+
109
+ /** Collects plugins into one lookup. A node type or op kind two plugins both claim is an error. */
110
+ export function createRegistry(plugins: readonly CanvasPlugin[]): CanvasRegistry {
111
+ const nodes = new Map<string, NodeKind>();
112
+ const ops = new Map<string, OpDefinition>();
113
+ const layouts: LayoutPass[] = [];
114
+ const owner = new Map<string, string>();
115
+
116
+ const claim = (key: string, pluginId: string) => {
117
+ const first = owner.get(key);
118
+ if (first) throw new Error(`"${key}" is claimed by both "${first}" and "${pluginId}"`);
119
+ owner.set(key, pluginId);
120
+ };
121
+
122
+ for (const plugin of plugins) {
123
+ for (const kind of plugin.nodes ?? []) {
124
+ claim(`node:${kind.type}`, plugin.id);
125
+ nodes.set(kind.type, kind);
126
+ }
127
+ for (const op of plugin.ops ?? []) {
128
+ claim(`op:${op.kind}`, plugin.id);
129
+ ops.set(op.kind, op);
130
+ }
131
+ layouts.push(...(plugin.layouts ?? []));
132
+ }
133
+
134
+ const typesWhere = (sizing: NodeKind["sizing"]) =>
135
+ new Set([...nodes.values()].filter((k) => k.sizing === sizing).map((k) => k.type));
136
+
137
+ return {
138
+ plugins,
139
+ nodes,
140
+ ops,
141
+ layouts,
142
+ nodeTypes() {
143
+ const out: NodeTypes = {};
144
+ for (const kind of nodes.values()) if (kind.component) out[kind.type] = kind.component;
145
+ return out;
146
+ },
147
+ contentSizedTypes: () => typesWhere("content-sized"),
148
+ layoutOwnedTypes: () => typesWhere("layout-owned"),
149
+ parseOp(input) {
150
+ const kind = (input as { kind?: unknown } | null)?.kind;
151
+ const def = typeof kind === "string" ? ops.get(kind) : undefined;
152
+ if (!def) throw new Error(`Unknown op kind: ${String(kind)}`);
153
+ return def.schema.parse(input);
154
+ },
155
+ pickLayout(data, options = {}) {
156
+ return layouts.find((pass) => pass.matches(data, options)) ?? null;
157
+ },
158
+ };
159
+ }
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { GLYPHS, type GlyphName } from "./glyphs.js";
6
+
7
+ // =========================
8
+ // TYPES
9
+ // =========================
10
+ interface Props {
11
+ name: GlyphName;
12
+ size?: number;
13
+ class?: string;
14
+ }
15
+
16
+ // =========================
17
+ // PROPS
18
+ // =========================
19
+ let { name, size = 12, class: klass = "" }: Props = $props();
20
+ </script>
21
+
22
+ <!-- =========================== -->
23
+ <!-- MARKUP -->
24
+ <!-- =========================== -->
25
+ <span class="cv-glyph {klass}" style:--glyph={`url("${GLYPHS[name]}")`} style:--size="{size}px" aria-hidden="true"></span>
@@ -0,0 +1,176 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { tick } from "svelte";
6
+ import GlyphIcon from "./GlyphIcon.svelte";
7
+ import { addItem, removeItem, setItem } from "./parts.js";
8
+ import type { ScreenLabels } from "./labels.js";
9
+ import type { ScreenMode, ScreenPart } from "./types.js";
10
+
11
+ // One kit part, drawn as grey boxes or in the board's tokens. Its words are inputs in both.
12
+
13
+ // =========================
14
+ // TYPES
15
+ // =========================
16
+ interface Props {
17
+ part: ScreenPart;
18
+ mode: ScreenMode;
19
+ index: number;
20
+ count: number;
21
+ labels: ScreenLabels;
22
+ readonly?: boolean;
23
+ oncommit: (patch: Partial<Omit<ScreenPart, "id" | "kind">>, live: boolean) => void;
24
+ onmove: (step: -1 | 1) => void;
25
+ onremove: () => void;
26
+ onend: () => void;
27
+ }
28
+
29
+ // =========================
30
+ // PROPS
31
+ // =========================
32
+ let { part, mode, index, count, labels, readonly = false, oncommit, onmove, onremove, onend }: Props = $props();
33
+
34
+ // =========================
35
+ // STATE
36
+ // =========================
37
+ let root = $state<HTMLElement>();
38
+
39
+ // =========================
40
+ // DERIVED
41
+ // =========================
42
+ let items = $derived(part.items ?? []);
43
+
44
+ // =========================
45
+ // FUNCTIONS
46
+ // =========================
47
+ function keep(event: KeyboardEvent) {
48
+ // A key typed into a part's words is the part's, so it never reaches the board's shortcuts.
49
+ event.stopPropagation();
50
+ if (event.key === "Escape") (event.currentTarget as HTMLElement).blur();
51
+ }
52
+
53
+ async function focusItem(at: number) {
54
+ await tick();
55
+ root?.querySelector<HTMLInputElement>(`[data-item="${at}"]`)?.focus();
56
+ }
57
+
58
+ function itemKey(event: KeyboardEvent, at: number) {
59
+ keep(event);
60
+ if (event.key === "Enter") {
61
+ event.preventDefault();
62
+ oncommit({ items: [...items.slice(0, at + 1), "Item", ...items.slice(at + 1)] }, false);
63
+ focusItem(at + 1);
64
+ } else if (event.key === "Backspace" && items[at] === "" && items.length > 1) {
65
+ event.preventDefault();
66
+ oncommit({ items: removeItem(items, at) }, false);
67
+ focusItem(Math.max(0, at - 1));
68
+ }
69
+ }
70
+ </script>
71
+
72
+ <!-- =========================== -->
73
+ <!-- MARKUP -->
74
+ <!-- =========================== -->
75
+ {#snippet words(value: string, aria: string, commit: (next: string, live: boolean) => void, cls = "")}
76
+ <input
77
+ class="cv-field nodrag {cls}"
78
+ {value}
79
+ {readonly}
80
+ aria-label={aria}
81
+ oninput={(event) => commit(event.currentTarget.value, true)}
82
+ onblur={onend}
83
+ onkeydown={keep}
84
+ />
85
+ {/snippet}
86
+
87
+ <div bind:this={root} class="cv-part is-{part.kind}" data-part={part.kind} data-part-id={part.id}>
88
+ {#if part.kind === "header"}
89
+ <div class="cv-header">
90
+ <span class="cv-brand" aria-hidden="true"></span>
91
+ {@render words(part.label, labels.kinds.header, (next, live) => oncommit({ label: next }, live), "cv-header-title")}
92
+ <span class="cv-menu" aria-hidden="true"><i></i><i></i><i></i></span>
93
+ </div>
94
+ {:else if part.kind === "button"}
95
+ <div class="cv-button-row">
96
+ <div class="cv-button is-{part.variant ?? 'primary'}">
97
+ {@render words(part.label, labels.kinds.button, (next, live) => oncommit({ label: next }, live))}
98
+ </div>
99
+ </div>
100
+ {:else if part.kind === "input"}
101
+ <div class="cv-input">
102
+ {@render words(part.label, labels.kinds.input, (next, live) => oncommit({ label: next }, live), "cv-input-label")}
103
+ <div class="cv-input-box" aria-hidden="true"><i></i></div>
104
+ </div>
105
+ {:else if part.kind === "image"}
106
+ <div class="cv-image">
107
+ <div class="cv-image-box" aria-hidden="true">
108
+ <GlyphIcon name="hills" size={60} class="cv-hills" />
109
+ </div>
110
+ {@render words(part.label, labels.kinds.image, (next, live) => oncommit({ label: next }, live), "cv-caption")}
111
+ </div>
112
+ {:else if part.kind === "list"}
113
+ <ul class="cv-list">
114
+ {#each items as item, at (at)}
115
+ <li class="cv-list-row">
116
+ <i aria-hidden="true"></i>
117
+ <input
118
+ class="cv-field nodrag"
119
+ data-item={at}
120
+ value={item}
121
+ {readonly}
122
+ aria-label={labels.kinds.list}
123
+ oninput={(event) => oncommit({ items: setItem(items, at, event.currentTarget.value) }, true)}
124
+ onblur={onend}
125
+ onkeydown={(event) => itemKey(event, at)}
126
+ />
127
+ {#if !readonly && items.length > 1}
128
+ <button type="button" class="cv-mini nodrag" aria-label={labels.removeItem} onclick={() => oncommit({ items: removeItem(items, at) }, false)}>
129
+ <GlyphIcon name="close" size={10} />
130
+ </button>
131
+ {/if}
132
+ </li>
133
+ {/each}
134
+ {#if !readonly}
135
+ <li class="cv-list-row is-add">
136
+ <button type="button" class="cv-add-item nodrag" onclick={() => oncommit({ items: addItem(items) }, false)}>
137
+ <GlyphIcon name="plus" size={10} />
138
+ {labels.addItem}
139
+ </button>
140
+ </li>
141
+ {/if}
142
+ </ul>
143
+ {:else}
144
+ <div class="cv-modal">
145
+ <div class="cv-modal-head">
146
+ {@render words(part.label, labels.kinds.modal, (next, live) => oncommit({ label: next }, live), "cv-modal-title")}
147
+ <GlyphIcon name="close" size={10} class="cv-x" />
148
+ </div>
149
+ {@render words(part.text ?? "", labels.kinds.modal, (next, live) => oncommit({ text: next }, live), "cv-modal-text")}
150
+ </div>
151
+ {/if}
152
+
153
+ {#if !readonly}
154
+ <div class="cv-tools nodrag">
155
+ {#if part.kind === "button"}
156
+ <button
157
+ type="button"
158
+ class="cv-tool is-text"
159
+ aria-pressed={part.variant !== "secondary"}
160
+ onclick={() => oncommit({ variant: part.variant === "secondary" ? "primary" : "secondary" }, false)}
161
+ >
162
+ {part.variant === "secondary" ? labels.secondary : labels.primary}
163
+ </button>
164
+ {/if}
165
+ <button type="button" class="cv-tool" aria-label={labels.moveUp} disabled={index === 0} onclick={() => onmove(-1)}>
166
+ <GlyphIcon name="up" />
167
+ </button>
168
+ <button type="button" class="cv-tool" aria-label={labels.moveDown} disabled={index === count - 1} onclick={() => onmove(1)}>
169
+ <GlyphIcon name="down" />
170
+ </button>
171
+ <button type="button" class="cv-tool" aria-label={labels.remove} onclick={onremove}>
172
+ <GlyphIcon name="close" />
173
+ </button>
174
+ </div>
175
+ {/if}
176
+ </div>
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { getDiagramEditorContext } from "../editor/editor-context.js";
6
+ import { isLocked } from "../editor/lock.js";
7
+ import { DEFAULT_SCREEN_LABELS, type ScreenLabels } from "./labels.js";
8
+ import { addPart, movePart, normalizeScreen, patchPart, removePart } from "./parts.js";
9
+ import GlyphIcon from "./GlyphIcon.svelte";
10
+ import PartView from "./PartView.svelte";
11
+ import { PART_KINDS, SCREEN_MODES, type PartKind, type ScreenMode, type ScreenPart } from "./types.js";
12
+ import "./screen.css";
13
+
14
+ // What sits in a Screen card's frame. Every edit patches the node's `data`, so undo and
15
+ // save come from the editor and nothing here keeps a copy.
16
+
17
+ // =========================
18
+ // TYPES
19
+ // =========================
20
+ interface Props {
21
+ id: string;
22
+ data: Record<string, unknown>;
23
+ /** The host's translations. Missing keys fall back to English. */
24
+ labels?: Partial<ScreenLabels>;
25
+ }
26
+
27
+ // =========================
28
+ // PROPS
29
+ // =========================
30
+ let { id, data, labels: given }: Props = $props();
31
+
32
+ // =========================
33
+ // STATE
34
+ // =========================
35
+ const editor = getDiagramEditorContext();
36
+ let tray = $state(false);
37
+
38
+ // =========================
39
+ // DERIVED
40
+ // =========================
41
+ let screen = $derived(normalizeScreen(data));
42
+ let labels = $derived<ScreenLabels>({
43
+ ...DEFAULT_SCREEN_LABELS,
44
+ ...given,
45
+ modes: { ...DEFAULT_SCREEN_LABELS.modes, ...given?.modes },
46
+ kinds: { ...DEFAULT_SCREEN_LABELS.kinds, ...given?.kinds },
47
+ });
48
+ let readonly = $derived(!editor || isLocked({ data }));
49
+ let showTray = $derived(!readonly && (tray || screen.parts.length === 0));
50
+
51
+ // =========================
52
+ // FUNCTIONS
53
+ // =========================
54
+ function write(patch: Record<string, unknown>, live = false) {
55
+ editor?.patchNodeData(id, patch, live ? { coalesce: true } : undefined);
56
+ }
57
+
58
+ function setMode(mode: ScreenMode) {
59
+ if (mode !== screen.mode) write({ mode });
60
+ }
61
+
62
+ function add(kind: PartKind) {
63
+ write({ parts: addPart(screen.parts, kind) });
64
+ tray = false;
65
+ }
66
+
67
+ function commitPart(part: ScreenPart, change: Partial<Omit<ScreenPart, "id" | "kind">>, live: boolean) {
68
+ write({ parts: patchPart(screen.parts, part.id, change) }, live);
69
+ }
70
+ </script>
71
+
72
+ <!-- =========================== -->
73
+ <!-- MARKUP -->
74
+ <!-- =========================== -->
75
+ <div class="cv-screen" data-mode={screen.mode} data-testid="screen-card">
76
+ <div class="cv-title-bar">
77
+ <input
78
+ class="cv-field cv-name nodrag"
79
+ value={screen.label}
80
+ {readonly}
81
+ placeholder={labels.name}
82
+ aria-label={labels.name}
83
+ oninput={(event) => write({ label: event.currentTarget.value }, true)}
84
+ onblur={() => editor?.endTextEdit()}
85
+ onkeydown={(event) => {
86
+ event.stopPropagation();
87
+ if (event.key === "Escape") event.currentTarget.blur();
88
+ }}
89
+ />
90
+ {#if !readonly}
91
+ <div class="cv-switch nodrag" role="radiogroup" aria-label={labels.mode}>
92
+ {#each SCREEN_MODES as mode (mode)}
93
+ <button
94
+ type="button"
95
+ role="radio"
96
+ class="cv-seg"
97
+ aria-checked={screen.mode === mode}
98
+ data-mode-option={mode}
99
+ onclick={() => setMode(mode)}
100
+ >
101
+ {labels.modes[mode]}
102
+ </button>
103
+ {/each}
104
+ </div>
105
+ {/if}
106
+ </div>
107
+
108
+ <div class="cv-stage">
109
+ {#each screen.parts as part, index (part.id)}
110
+ <PartView
111
+ {part}
112
+ mode={screen.mode}
113
+ {index}
114
+ count={screen.parts.length}
115
+ {labels}
116
+ {readonly}
117
+ oncommit={(change, live) => commitPart(part, change, live)}
118
+ onmove={(step) => write({ parts: movePart(screen.parts, part.id, step) })}
119
+ onremove={() => write({ parts: removePart(screen.parts, part.id) })}
120
+ onend={() => editor?.endTextEdit()}
121
+ />
122
+ {/each}
123
+
124
+ {#if !readonly}
125
+ {#if showTray}
126
+ <div class="cv-tray nodrag" data-testid="screen-tray">
127
+ {#each PART_KINDS as kind (kind)}
128
+ <button type="button" class="cv-tile" data-add={kind} onclick={() => add(kind)}>
129
+ <GlyphIcon name={kind} size={20} />
130
+ <span>{labels.kinds[kind]}</span>
131
+ </button>
132
+ {/each}
133
+ </div>
134
+ {:else}
135
+ <button type="button" class="cv-add nodrag" data-testid="screen-add" onclick={() => (tray = true)}>
136
+ <GlyphIcon name="plus" />
137
+ {labels.addPart}
138
+ </button>
139
+ {/if}
140
+ {/if}
141
+ </div>
142
+ </div>