@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,176 @@
1
+ // A small drawing on a board. Markup arrives over the API, so it's rebuilt tag by
2
+ // tag from an allowlist. Authoring: see the package README.
3
+
4
+ /** Shapes, text and the local defs they reference. No `a`, `use` or `image`:
5
+ * each one can point somewhere off the page. */
6
+ const ALLOWED_TAGS = new Set([
7
+ "svg",
8
+ "g",
9
+ "defs",
10
+ "title",
11
+ "desc",
12
+ "linearGradient",
13
+ "radialGradient",
14
+ "stop",
15
+ "marker",
16
+ "clipPath",
17
+ "path",
18
+ "rect",
19
+ "circle",
20
+ "ellipse",
21
+ "line",
22
+ "polyline",
23
+ "polygon",
24
+ "text",
25
+ "tspan",
26
+ ]);
27
+
28
+ /** Geometry and paint only. An allowlist and not a denylist, so `onload` and
29
+ * `href` are out by being absent rather than by being remembered. */
30
+ const ALLOWED_ATTRS = new Set([
31
+ "viewBox",
32
+ "width",
33
+ "height",
34
+ "x",
35
+ "y",
36
+ "x1",
37
+ "x2",
38
+ "y1",
39
+ "y2",
40
+ "cx",
41
+ "cy",
42
+ "r",
43
+ "rx",
44
+ "ry",
45
+ "d",
46
+ "points",
47
+ "dx",
48
+ "dy",
49
+ "transform",
50
+ "fill",
51
+ "fill-opacity",
52
+ "fill-rule",
53
+ "stroke",
54
+ "stroke-width",
55
+ "stroke-dasharray",
56
+ "stroke-dashoffset",
57
+ "stroke-linecap",
58
+ "stroke-linejoin",
59
+ "stroke-opacity",
60
+ "opacity",
61
+ "font-size",
62
+ "font-weight",
63
+ "font-style",
64
+ "letter-spacing",
65
+ "text-anchor",
66
+ "dominant-baseline",
67
+ "offset",
68
+ "stop-color",
69
+ "stop-opacity",
70
+ "gradientUnits",
71
+ "gradientTransform",
72
+ "clip-path",
73
+ "clipPathUnits",
74
+ "marker-end",
75
+ "marker-start",
76
+ "markerWidth",
77
+ "markerHeight",
78
+ "refX",
79
+ "refY",
80
+ "orient",
81
+ "id",
82
+ "class",
83
+ "preserveAspectRatio",
84
+ ]);
85
+
86
+ /** A figure is a diagram, not a pasted illustration: past this it is an asset
87
+ * with a URL, and the board holds an image node instead. */
88
+ export const MAX_FIGURE_CHARS = 16000;
89
+
90
+ const TAG = /<\/?([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>])*?)\/?>/g;
91
+ const ATTR = /([a-zA-Z][\w:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
92
+ /** A reference out of the document. `url(#local)` stays; everything else goes. */
93
+ const FOREIGN_URL = /url\(\s*['"]?(?!#)/i;
94
+
95
+ function cleanAttrs(raw: string): string {
96
+ const kept: string[] = [];
97
+ for (const match of raw.matchAll(ATTR)) {
98
+ const name = match[1];
99
+ const value = match[3] ?? match[4] ?? "";
100
+ if (!ALLOWED_ATTRS.has(name)) continue;
101
+ if (FOREIGN_URL.test(value)) continue;
102
+ kept.push(`${name}="${value.replace(/"/g, "&quot;")}"`);
103
+ }
104
+ return kept.length > 0 ? ` ${kept.join(" ")}` : "";
105
+ }
106
+
107
+ /** The figure as it's safe to draw, or null without an `<svg>`. Script, style and foreignObject go with contents; unknown tags drop, keeping text. */
108
+ export function sanitizeFigureSvg(raw: string): string | null {
109
+ if (typeof raw !== "string" || raw.length > MAX_FIGURE_CHARS) return null;
110
+ const stripped = raw
111
+ .replace(/<!--[\s\S]*?-->/g, "")
112
+ .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "")
113
+ .replace(/<\?[\s\S]*?\?>/g, "")
114
+ .replace(/<!DOCTYPE[^>]*>/gi, "")
115
+ .replace(/<(script|style|foreignObject|iframe)\b[\s\S]*?<\/\1\s*>/gi, "");
116
+ if (!/<svg\b/i.test(stripped)) return null;
117
+
118
+ let out = "";
119
+ let cursor = 0;
120
+ for (const match of stripped.matchAll(TAG)) {
121
+ const [tag, name, attrs] = match;
122
+ const start = match.index ?? 0;
123
+ out += stripped.slice(cursor, start);
124
+ cursor = start + tag.length;
125
+ if (!ALLOWED_TAGS.has(name)) continue;
126
+ if (tag.startsWith("</")) {
127
+ out += `</${name}>`;
128
+ continue;
129
+ }
130
+ out += `<${name}${cleanAttrs(attrs)}${tag.endsWith("/>") ? " />" : ">"}`;
131
+ }
132
+ out += stripped.slice(cursor);
133
+ return out.trim();
134
+ }
135
+
136
+ function rootAttr(svg: string, name: string): string | undefined {
137
+ const root = svg.match(/<svg\b([^>]*)>/i);
138
+ if (!root) return undefined;
139
+ const found = root[1].match(new RegExp(`${name}\\s*=\\s*"([^"]*)"`, "i"));
140
+ return found?.[1];
141
+ }
142
+
143
+ /** Height over width, from the viewBox the author drew in. */
144
+ export function figureRatio(svg: string): number | null {
145
+ const box = rootAttr(svg, "viewBox");
146
+ if (box) {
147
+ const [, , w, h] = box
148
+ .trim()
149
+ .split(/[\s,]+/)
150
+ .map(Number);
151
+ if (w > 0 && h > 0) return h / w;
152
+ }
153
+ const w = Number.parseFloat(rootAttr(svg, "width") ?? "");
154
+ const h = Number.parseFloat(rootAttr(svg, "height") ?? "");
155
+ return w > 0 && h > 0 ? h / w : null;
156
+ }
157
+
158
+ /** The box the drawing needs. The fallback is a wide banner, the shape a
159
+ * board figure takes when the author gave no viewBox. */
160
+ export function figureBoxHeight(svg: string, width: number, fallback = 0.4): number {
161
+ return Math.round(width * (figureRatio(svg) ?? fallback));
162
+ }
163
+
164
+ /** The heading over a figure and the line under it, with their margins. These
165
+ * mirror sketch-figure.svelte and move when its type sizes do. */
166
+ const LABEL_H = 27;
167
+ const CAPTION_H = 24;
168
+
169
+ /** The whole node: the drawing, plus whatever text sits around it. */
170
+ export function figureNodeHeight(
171
+ figure: { svg?: string; label?: string; caption?: string; height?: number },
172
+ width: number,
173
+ ): number {
174
+ const art = figure.height ?? figureBoxHeight(figure.svg ?? "", width);
175
+ return art + (figure.label ? LABEL_H : 0) + (figure.caption ? CAPTION_H : 0);
176
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ tableColumnCount,
4
+ tableColumnWeights,
5
+ tableGridTemplate,
6
+ tableHeight,
7
+ tableNumericColumns,
8
+ tableMinWidth,
9
+ tableWeightsAt,
10
+ tableWidth,
11
+ TABLE_COLHEAD_H,
12
+ TABLE_TITLE_H,
13
+ } from "./table-layout.js";
14
+
15
+ const PLANS = {
16
+ headers: ["Plan", "Price", "Included", "Pick it when"],
17
+ rows: [
18
+ {
19
+ cells: [
20
+ "Free",
21
+ "$0",
22
+ "5 projects, memory, connectors, artifacts, Sonnet and Haiku",
23
+ "You are setting up loads 1 and 2",
24
+ ],
25
+ },
26
+ {
27
+ cells: [
28
+ "Pro",
29
+ "$20 a month",
30
+ "Scheduled work, Claude Code, Opus, Slides, Docs, Chrome, Microsoft 365",
31
+ "You want everything in this video",
32
+ ],
33
+ },
34
+ ],
35
+ };
36
+
37
+ describe("tableColumnWeights", () => {
38
+ it("gives the long column more room than the short one", () => {
39
+ const [plan, price, included] = tableColumnWeights(PLANS.headers, PLANS.rows);
40
+ expect(included).toBeGreaterThan(price);
41
+ expect(price).toBeGreaterThan(plan);
42
+ });
43
+
44
+ it("sums to one", () => {
45
+ const total = tableColumnWeights(PLANS.headers, PLANS.rows).reduce((a, b) => a + b, 0);
46
+ expect(total).toBeCloseTo(1, 6);
47
+ });
48
+
49
+ it("reads the author's weights as relative, not absolute", () => {
50
+ const small = tableColumnWeights(PLANS.headers, PLANS.rows, [1, 1, 3, 2]);
51
+ const large = tableColumnWeights(PLANS.headers, PLANS.rows, [10, 10, 30, 20]);
52
+ expect(small).toEqual(large);
53
+ expect(small[2]).toBeCloseTo(3 / 7, 6);
54
+ });
55
+
56
+ it("ignores a weight list that does not cover every column", () => {
57
+ const partial = tableColumnWeights(PLANS.headers, PLANS.rows, [1, 2]);
58
+ expect(partial).toEqual(tableColumnWeights(PLANS.headers, PLANS.rows));
59
+ });
60
+
61
+ it("keeps a one-word column readable instead of hairline thin", () => {
62
+ const weights = tableColumnWeights(["Id", "Detail"], [{ cells: ["a", "a".repeat(200)] }]);
63
+ expect(weights[0]).toBeGreaterThan(0.1);
64
+ });
65
+ });
66
+
67
+ describe("tableGridTemplate", () => {
68
+ it("floors every track at zero so a long cell cannot widen its column", () => {
69
+ expect(tableGridTemplate([0.25, 0.75])).toBe("minmax(0, 0.2500fr) minmax(0, 0.7500fr)");
70
+ });
71
+ });
72
+
73
+ describe("tableNumericColumns", () => {
74
+ it("marks a column of figures and leaves prose alone", () => {
75
+ const rows = [{ cells: ["Free", "$0", "5 projects"] }, { cells: ["Pro", "$20", "everything"] }];
76
+ expect(tableNumericColumns(rows, 3)).toEqual([false, true, false]);
77
+ });
78
+
79
+ it("does not mark a column where one cell carries words", () => {
80
+ const rows = [{ cells: ["$0"] }, { cells: ["$20 a month"] }];
81
+ expect(tableNumericColumns(rows, 1)).toEqual([false]);
82
+ });
83
+ });
84
+
85
+ describe("tableHeight", () => {
86
+ it("keeps every row to one line when the columns are content-sized", () => {
87
+ const wide = tableHeight(PLANS.headers, PLANS.rows, 1320);
88
+ // Title + heads + two single-line rows. Anything taller means a wrap.
89
+ expect(wide).toBeLessThan(TABLE_TITLE_H + TABLE_COLHEAD_H + 2 * 40);
90
+ });
91
+
92
+ it("grows when the same table is squeezed", () => {
93
+ expect(tableHeight(PLANS.headers, PLANS.rows, 420)).toBeGreaterThan(
94
+ tableHeight(PLANS.headers, PLANS.rows, 1320),
95
+ );
96
+ });
97
+
98
+ it("still has a height with no rows", () => {
99
+ expect(tableHeight(PLANS.headers, [], 480)).toBeGreaterThan(80);
100
+ });
101
+ });
102
+
103
+ describe("tableColumnCount", () => {
104
+ it("takes the widest row when it runs past the headers", () => {
105
+ expect(tableColumnCount(["A"], [{ cells: ["a", "b", "c"] }])).toBe(3);
106
+ });
107
+ });
108
+
109
+ describe("tableWidth", () => {
110
+ // The "Has to keep working" table of the Solusign board, stored 1320px wide.
111
+ const KEEP = {
112
+ headers: ["What", "Until"],
113
+ rows: [
114
+ { cells: ["Avoma webhooks, which only reach you through Zapier", "The app takes them in"] },
115
+ { cells: ["Fillout webhooks", "The app takes them in"] },
116
+ { cells: ["The Apps Script email sync", "Always"] },
117
+ { cells: ["Make and Zapier", "It runs in the app instead"] },
118
+ { cells: ["Any Airtable table", "That table is cut over"] },
119
+ ],
120
+ };
121
+
122
+ it("fits two short columns to their content, not to the stored width", () => {
123
+ const width = tableWidth(KEEP.headers, KEEP.rows, 1320);
124
+ expect(width).toBeGreaterThanOrEqual(500);
125
+ expect(width).toBeLessThanOrEqual(560);
126
+ });
127
+
128
+ it("never grows past the width it was asked for", () => {
129
+ expect(tableWidth(KEEP.headers, KEEP.rows, 400)).toBe(400);
130
+ });
131
+
132
+ it("splits by content once the card has room, whatever weights the author wrote", () => {
133
+ const width = tableWidth(KEEP.headers, KEEP.rows, 1320);
134
+ const [what, until] = tableWeightsAt(KEEP.headers, KEEP.rows, width, { explicit: [2, 3] });
135
+ expect(what).toBeGreaterThan(until);
136
+ });
137
+ });
138
+
139
+ describe("tableWidth font", () => {
140
+ const rows = [
141
+ {
142
+ cells: [
143
+ "Avoma webhooks, which only reach you through Zapier",
144
+ "The app takes them in until the cutover is done",
145
+ ],
146
+ },
147
+ ];
148
+
149
+ it("gives the hand font more room than the plain one", () => {
150
+ expect(tableWidth(["What", "Until"], rows, undefined, "hand")).toBeGreaterThan(
151
+ tableWidth(["What", "Until"], rows, undefined, "plain"),
152
+ );
153
+ });
154
+
155
+ it("will not let a drag squeeze a column under its floor", () => {
156
+ expect(tableMinWidth(["What", "Until"], rows)).toBeGreaterThanOrEqual(2 * 84);
157
+ });
158
+ });
@@ -0,0 +1,309 @@
1
+ // Table geometry: column widths, and the height that follows.
2
+ // One owner, because the component, node-sizing and board-doc must agree.
3
+ // Flat numbers stay in metrics.ts.
4
+
5
+ /** 13px Satoshi. Deliberately wide: guessing narrow leaves a gap. */
6
+ const CELL_CHAR_W = 6.7;
7
+ /** px-3 on both sides of a cell. */
8
+ export const TABLE_CELL_PAD_X = 12;
9
+ /** One line of a cell: 13px over leading-[18px]. */
10
+ const ROW_LINE_H = 18;
11
+ /** The hairline between two rows. */
12
+ const ROW_BORDER = 1;
13
+ /** The "no rows configured" placeholder. */
14
+ const TABLE_EMPTY_H = 56;
15
+ /** A section band: 11px caps over a 16px line + py-1.5 + border. */
16
+ const SECTION_ROW_H = 29;
17
+ /** A note under a row: 11px over a 16px line. */
18
+ const NOTE_LINE_H = 16;
19
+ /** Indent of the first cell per depth step. */
20
+ export const TABLE_DEPTH_INDENT = 16;
21
+
22
+ // The table card's own rhythm.
23
+ /** Title row: 24px icon chip + py-3 + border. */
24
+ export const TABLE_TITLE_H = 49;
25
+ /** Column heads: 16px line + py-2 + border. */
26
+ export const TABLE_COLHEAD_H = 33;
27
+ /** py-2.5 top and bottom. */
28
+ const TABLE_ROW_PAD_Y = 20;
29
+ /** The narrowest a table card gets, however little it says. */
30
+ export const TABLE_MIN_W = 360;
31
+ /** The widest it gets, so a 13px line stays under about 75 characters. */
32
+ export const TABLE_MAX_W = 760;
33
+ /** A column left blank to be filled in still gets room to write. */
34
+ const FILL_COL_CH = 32;
35
+
36
+ /** Excalifont runs about 12% wider than Satoshi over the same text. */
37
+ const HAND_FONT_FACTOR = 1.12;
38
+ /** The widest a drag may make a card. */
39
+ export const TABLE_RESIZE_MAX_W = 1600;
40
+
41
+ /** The floor. Under 9 characters a head like "Price" wraps. */
42
+ const MIN_COL_CH = 9;
43
+ /** The ceiling, so one long column cannot eat the card. */
44
+ const MAX_COL_CH = 48;
45
+
46
+ /**
47
+ * One row. `section` is a full-width label from `cells[0]`, `depth` indents the
48
+ * first cell, and `note` is a small italic line under the row.
49
+ */
50
+ export type TableRow = { cells: string[]; section?: boolean; depth?: number; note?: string };
51
+
52
+ export function tableColumnCount(headers: string[], rows: TableRow[]): number {
53
+ return Math.max(
54
+ headers.length,
55
+ rows.reduce((max, row) => Math.max(max, row.cells?.length ?? 0), 0),
56
+ 1,
57
+ );
58
+ }
59
+
60
+ /** Fractions summing to 1. `explicit` is relative, so [1,1,3] and [10,10,30]
61
+ * are one split. Otherwise the content decides. */
62
+ export function tableColumnWeights(
63
+ headers: string[],
64
+ rows: TableRow[],
65
+ explicit?: number[],
66
+ ): number[] {
67
+ const count = tableColumnCount(headers, rows);
68
+
69
+ if (explicit && explicit.length === count && explicit.every((w) => w > 0)) {
70
+ return normalize(explicit);
71
+ }
72
+
73
+ return normalize(naturalColumnChars(headers, rows, count));
74
+ }
75
+
76
+ /** Characters each column wants: its longest line, between the floor and the ceiling. */
77
+ function naturalColumnChars(headers: string[], rows: TableRow[], count: number): number[] {
78
+ return Array.from({ length: count }, (_, i) => {
79
+ const head = headers[i]?.length ?? 0;
80
+ const cells = rows.filter((row) => !row.section).map((row) => row.cells?.[i] ?? "");
81
+ const body = cells.reduce((max, cell) => Math.max(max, cell.length), 0);
82
+ // "...", "…" or nothing: the column is a blank to write in, not content.
83
+ const blank = cells.length > 0 && cells.every(isBlankCell);
84
+ const wants = blank ? Math.max(head, FILL_COL_CH) : Math.max(head, body);
85
+ return Math.min(MAX_COL_CH, Math.max(MIN_COL_CH, wants));
86
+ });
87
+ }
88
+
89
+ /** A placeholder rather than a value: empty, dots, an ellipsis, a dash or a question mark. */
90
+ export function isBlankCell(value: string): boolean {
91
+ return /^[\s.…?\-–—_]*$/.test(value);
92
+ }
93
+
94
+ /** The narrowest a column gets: enough for a head like "Price" not to wrap. */
95
+ export const TABLE_COL_MIN_W = MIN_COL_CH * CELL_CHAR_W + TABLE_CELL_PAD_X * 2;
96
+
97
+ /** `minmax(floor, …fr)`, not a bare `fr`: a long cell must not widen its column,
98
+ * and a thin weight must not squeeze a head below the floor. */
99
+ export function tableGridTemplate(weights: number[], floor = 0): string {
100
+ const min = floor > 0 ? `${floor}px` : "0";
101
+ return weights.map((w) => `minmax(${min}, ${w.toFixed(4)}fr)`).join(" ");
102
+ }
103
+
104
+ /** Pixel widths for weights, with columns under the floor pinned there, as the grid does. */
105
+ export function tableColumnPx(weights: number[], width: number): number[] {
106
+ const out = weights.map(() => 0);
107
+ const pinned = new Set<number>();
108
+ let changed = true;
109
+ while (changed) {
110
+ changed = false;
111
+ const free = weights.reduce((sum, w, i) => sum + (pinned.has(i) ? 0 : w), 0);
112
+ const room = width - pinned.size * TABLE_COL_MIN_W;
113
+ weights.forEach((w, i) => {
114
+ if (pinned.has(i)) return;
115
+ out[i] = free > 0 ? (room * w) / free : 0;
116
+ if (out[i] < TABLE_COL_MIN_W && pinned.size < weights.length - 1) {
117
+ pinned.add(i);
118
+ changed = true;
119
+ }
120
+ });
121
+ }
122
+ return out.map((px, i) => (pinned.has(i) ? TABLE_COL_MIN_W : px));
123
+ }
124
+
125
+ /** How the card is set: the hand font is wider than the plain one. */
126
+ export type TableFont = "plain" | "hand";
127
+
128
+ function charWidth(font: TableFont): number {
129
+ return font === "hand" ? CELL_CHAR_W * HAND_FONT_FACTOR : CELL_CHAR_W;
130
+ }
131
+
132
+ /** The width that fits the content, clamped to TABLE_MIN_W..TABLE_MAX_W and
133
+ * never past `ceiling`. A card the user resized keeps that width instead. */
134
+ export function tableWidth(
135
+ headers: string[],
136
+ rows: TableRow[],
137
+ ceiling = TABLE_MAX_W,
138
+ font: TableFont = "plain",
139
+ ): number {
140
+ const fitted = Math.ceil(naturalWidth(headers, rows, font));
141
+ return Math.min(ceiling, TABLE_MAX_W, Math.max(TABLE_MIN_W, fitted));
142
+ }
143
+
144
+ /** The narrowest a drag may make a card: every column at its floor. */
145
+ export function tableMinWidth(headers: string[], rows: TableRow[]): number {
146
+ return tableColumnCount(headers, rows) * TABLE_COL_MIN_W + 2;
147
+ }
148
+
149
+ /** The width at which every column holds its longest line, up to the per-column cap. */
150
+ function naturalWidth(headers: string[], rows: TableRow[], font: TableFont): number {
151
+ const count = tableColumnCount(headers, rows);
152
+ return naturalColumnChars(headers, rows, count).reduce(
153
+ (sum, ch) => sum + ch * charWidth(font) + TABLE_CELL_PAD_X * 2,
154
+ 0,
155
+ );
156
+ }
157
+
158
+ /** The author's weights and how the card is set, for `tableWeightsAt`. */
159
+ export type TableWeightsOptions = { explicit?: number[]; font?: TableFont };
160
+
161
+ /** Column weights for a card `width` wide. A card with room for the content
162
+ * splits by content; a squeezed one falls back to the author's weights. */
163
+ export function tableWeightsAt(
164
+ headers: string[],
165
+ rows: TableRow[],
166
+ width: number,
167
+ { explicit, font = "plain" }: TableWeightsOptions = {},
168
+ ): number[] {
169
+ if (width >= naturalWidth(headers, rows, font)) return tableColumnWeights(headers, rows);
170
+ return tableColumnWeights(headers, rows, explicit);
171
+ }
172
+
173
+ /** Figure columns get `tabular-nums`. Not right-aligned: a table node is read
174
+ * across, and a right-aligned middle column opens a gutter mid-sentence. */
175
+ export function tableNumericColumns(rows: TableRow[], count: number): boolean[] {
176
+ return Array.from({ length: count }, (_, i) => {
177
+ const values = rows
178
+ .filter((row) => !row.section)
179
+ .map((row) => row.cells?.[i]?.trim() ?? "")
180
+ .filter(Boolean);
181
+ return values.length > 0 && values.every(isFigure);
182
+ });
183
+ }
184
+
185
+ /** A price, a count, a percentage, a multiplier. Not "$20 a month". */
186
+ function isFigure(value: string): boolean {
187
+ return /^[-+]?[$€£]?\s?\d[\d\s.,]*\s?[%x×]?$/.test(value);
188
+ }
189
+
190
+ /** How many lines a cell takes at the width its column gets. */
191
+ function wrappedLines(text: string, columnWidth: number, charW = CELL_CHAR_W): number {
192
+ const inner = Math.max(charW, columnWidth - TABLE_CELL_PAD_X * 2);
193
+ const perLine = Math.max(1, Math.floor(inner / charW));
194
+ return Math.max(1, Math.ceil(text.length / perLine));
195
+ }
196
+
197
+ /** What a user has fixed by hand. Anything left out is the content's call. */
198
+ export type TableSizing = {
199
+ /** The card's width, set by a drag on its edge. */
200
+ userWidth?: number;
201
+ /** px per column. Once set it is the source of truth for the split. */
202
+ columnWidths?: number[];
203
+ /** Min px per row; null leaves the row to its content. */
204
+ rowHeights?: (number | null)[];
205
+ /** The card's min height, set by a drag on its bottom edge. */
206
+ userHeight?: number;
207
+ font?: TableFont;
208
+ };
209
+
210
+ /** What fixes the columns: the user's sizing, and the author's weights as the fallback split. */
211
+ export type TableColumnsOptions = { sizing: TableSizing; weights?: number[] };
212
+
213
+ /** Pixel widths of the columns when the card is `width` wide. */
214
+ export function tableColumnsAt(
215
+ headers: string[],
216
+ rows: TableRow[],
217
+ width: number,
218
+ { sizing, weights }: TableColumnsOptions,
219
+ ): number[] {
220
+ const count = tableColumnCount(headers, rows);
221
+ const inner = Math.max(count * TABLE_COL_MIN_W, width - 2);
222
+ const pinned = sizing.columnWidths;
223
+ if (pinned && pinned.length === count && pinned.every((w) => w > 0)) {
224
+ return tableColumnPx(pinned, inner);
225
+ }
226
+ const split =
227
+ sizing.userWidth != null
228
+ ? tableColumnWeights(headers, rows, weights)
229
+ : tableWeightsAt(headers, rows, width, { explicit: weights, font: sizing.font });
230
+ return tableColumnPx(split, inner);
231
+ }
232
+
233
+ /** The card's width and the pixel width of each column, from what the user fixed. */
234
+ export function tableColumns(
235
+ headers: string[],
236
+ rows: TableRow[],
237
+ sizing: TableSizing = {},
238
+ weights?: number[],
239
+ ): { width: number; columns: number[] } {
240
+ const count = tableColumnCount(headers, rows);
241
+ const pinned = sizing.columnWidths;
242
+ const hasPinned = pinned && pinned.length === count && pinned.every((w) => w > 0);
243
+ const width =
244
+ sizing.userWidth ??
245
+ (hasPinned
246
+ ? Math.round(pinned.reduce((sum, w) => sum + w, 0)) + 2
247
+ : tableWidth(headers, rows, TABLE_MAX_W, sizing.font));
248
+ return { width, columns: tableColumnsAt(headers, rows, width, { sizing, weights }) };
249
+ }
250
+
251
+ /** Each row's height: its content, or the height the user pinned if that is more. */
252
+ export function tableRowHeights(
253
+ rows: TableRow[],
254
+ columns: number[],
255
+ sizing: Pick<TableSizing, "rowHeights" | "font"> = {},
256
+ ): number[] {
257
+ const charW = charWidth(sizing.font ?? "plain");
258
+ const full = columns.reduce((sum, px) => sum + px, 0);
259
+ return rows.map((row, i) => {
260
+ if (row.section) return Math.max(SECTION_ROW_H, sizing.rowHeights?.[i] ?? 0);
261
+ const indent = (row.depth ?? 0) * TABLE_DEPTH_INDENT;
262
+ const lines = columns.reduce(
263
+ (max, px, c) =>
264
+ Math.max(max, wrappedLines(row.cells?.[c] ?? "", c === 0 ? px - indent : px, charW)),
265
+ 1,
266
+ );
267
+ const note = row.note ? wrappedLines(row.note, full - indent) * NOTE_LINE_H : 0;
268
+ const content = TABLE_ROW_PAD_Y + lines * ROW_LINE_H + ROW_BORDER + note;
269
+ return Math.max(content, sizing.rowHeights?.[i] ?? 0);
270
+ });
271
+ }
272
+
273
+ /** The author's weights and what the user fixed, for `tableHeight`. */
274
+ export type TableHeightOptions = { explicit?: number[]; sizing?: TableSizing };
275
+
276
+ /** The card's height at a given width, wrapping included. A height the user set is a floor. */
277
+ export function tableHeight(
278
+ headers: string[],
279
+ rows: TableRow[],
280
+ width: number,
281
+ { explicit, sizing = {} }: TableHeightOptions = {},
282
+ ): number {
283
+ const head = headers.length > 0 ? TABLE_COLHEAD_H : 0;
284
+ if (rows.length === 0) return TABLE_TITLE_H + head + TABLE_EMPTY_H;
285
+
286
+ const columns = tableColumnsAt(headers, rows, width, { sizing, weights: explicit });
287
+ const body = tableRowHeights(rows, columns, sizing).reduce((sum, h) => sum + h, 0);
288
+ return Math.max(sizing.userHeight ?? 0, TABLE_TITLE_H + head + body);
289
+ }
290
+
291
+ /** The width a column needs to hold its longest line, for a double-click on its border. */
292
+ export function tableColumnFit(
293
+ headers: string[],
294
+ rows: TableRow[],
295
+ col: number,
296
+ font: TableFont = "plain",
297
+ ): number {
298
+ const longest = [
299
+ headers[col] ?? "",
300
+ ...rows.filter((row) => !row.section).map((row) => row.cells?.[col] ?? ""),
301
+ ].reduce((max, text) => Math.max(max, text.length), 0);
302
+ const px = Math.ceil(longest * charWidth(font)) + TABLE_CELL_PAD_X * 2;
303
+ return Math.max(TABLE_COL_MIN_W, Math.min(px, TABLE_RESIZE_MAX_W));
304
+ }
305
+
306
+ function normalize(weights: number[]): number[] {
307
+ const total = weights.reduce((sum, w) => sum + w, 0);
308
+ return total > 0 ? weights.map((w) => w / total) : weights.map(() => 1 / weights.length);
309
+ }
@@ -0,0 +1,35 @@
1
+ // @heyramzi/ui/canvas public API. Server-safe: nothing here touches the DOM.
2
+ // Svelte components are imported by path, like "@heyramzi/ui/canvas/ui/canvas-grid.svelte".
3
+
4
+ export * from "./types.js";
5
+ export * from "./plugin.js";
6
+ export { tryCatch, tryCatchAsync, type Result } from "../lib/result.js";
7
+
8
+ export * from "./ops/ops.js";
9
+
10
+ export * from "./headless/alignment.js";
11
+ export * from "./headless/canvas-cleanup.js";
12
+ export * from "./headless/edge-anchors.js";
13
+ export * from "./headless/edge-serialization.js";
14
+ export * from "./headless/fit-viewport.js";
15
+ export * from "./headless/grid.js";
16
+ export * from "./headless/icon-node.js";
17
+ export * from "./headless/linkify.js";
18
+ export * from "./headless/sketch.js";
19
+ export * from "./headless/snap-guides.js";
20
+ export * from "./headless/svg-figure.js";
21
+ export * from "./headless/table-layout.js";
22
+
23
+ export {
24
+ resolveContextMenuHit,
25
+ type ContextMenuHit,
26
+ type TableCellHit,
27
+ } from "./editor/context-menu.js";
28
+ export * from "./editor/editor-context.js";
29
+ export * from "./editor/dispatch.svelte.js";
30
+ export * from "./editor/editor-mode.js";
31
+ export * from "./editor/history-core.js";
32
+ export * from "./editor/keymap.js";
33
+ export * from "./editor/layout-actions.js";
34
+ export * from "./editor/rebase.js";
35
+ export * from "./editor/text-field-context.js";