@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
@@ -2,25 +2,27 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
- import { ModeWatcher, userPrefersMode, type UserPrefersMode } from "mode-watcher";
5
+ import { ModeWatcher, userPrefersMode } from "mode-watcher";
6
6
  import type { ComponentProps } from "svelte";
7
7
 
8
8
  // =========================
9
9
  // TYPES
10
10
  // =========================
11
11
  type WatcherProps = ComponentProps<typeof ModeWatcher>;
12
+ // mode-watcher exports its store class under the name UserPrefersMode, so the value type comes from the store.
13
+ type ThemeMode = (typeof userPrefersMode)["current"];
12
14
 
13
15
  interface Props extends Omit<WatcherProps, "defaultMode"> {
14
16
  /**
15
17
  * The choice the server already knows, for a browser that has none stored. A stored
16
18
  * choice wins, so it seeds a first visit and never overrides a click.
17
19
  */
18
- initial?: UserPrefersMode;
20
+ initial?: ThemeMode;
19
21
  /**
20
22
  * Fires when the person changes the mode, never on load. An app that saves the choice
21
23
  * on its server posts it here.
22
24
  */
23
- onChange?: (mode: UserPrefersMode) => void;
25
+ onChange?: (mode: ThemeMode) => void;
24
26
  }
25
27
 
26
28
  // =========================
@@ -32,7 +34,7 @@
32
34
  // EFFECTS
33
35
  // =========================
34
36
  // The first run is the mode read from storage on mount, which nobody chose.
35
- let seen: UserPrefersMode | null = null;
37
+ let seen: ThemeMode | null = null;
36
38
  $effect(() => {
37
39
  const next = userPrefersMode.current;
38
40
  if (seen !== null && next !== seen) onChange?.(next);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * One toolbar row for a list page. Wide, it shows search, tabs and a view switch inline. Narrow,
3
+ * each of those folds into a menu and the row never wraps.
4
+ */
5
+ export { default as Toolbar } from "./toolbar.svelte";
@@ -0,0 +1,168 @@
1
+ <script lang="ts" generics="Tab extends string, View extends string">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import type { Snippet } from "svelte";
6
+ import { CaretDown, MagnifyingGlass } from "phosphor-svelte";
7
+ import { SegmentedFilter } from "../segmented/index.js";
8
+ import type { TabDef } from "../segmented/animated-tabs.svelte";
9
+ import { toolbarTriggerClass } from "../list-page/toolbar-trigger.js";
10
+ import ToolbarSearch from "../list-page/toolbar-search.svelte";
11
+ import { Input } from "../../shadcn/input/index.js";
12
+ import * as DropdownMenu from "../../shadcn/dropdown-menu/index.js";
13
+ import * as Popover from "../../shadcn/popover/index.js";
14
+ import { cn } from "../../lib/utils.js";
15
+
16
+ // =========================
17
+ // TYPES
18
+ // =========================
19
+ interface Props {
20
+ /** The search text. Leave it out for no search. */
21
+ search?: string;
22
+ searchPlaceholder?: string;
23
+ /** The label of the narrow search button. Translate at the call site. */
24
+ searchLabel?: string;
25
+ /** A strip of tabs, such as All, Open and Done. */
26
+ tabs?: readonly TabDef<Tab>[];
27
+ tab?: Tab;
28
+ tabsLabel?: string;
29
+ /** How the page shows its records: a list, a board, a grid. Each one needs an icon. */
30
+ views?: readonly (TabDef<View> & { icon: NonNullable<TabDef<View>["icon"]> })[];
31
+ view?: View;
32
+ viewLabel?: string;
33
+ /** Controls that stay on the row at every width, such as filter chips. They scroll sideways before they wrap. */
34
+ filters?: Snippet;
35
+ /** The primary action and its neighbours, pinned to the right edge. */
36
+ actions?: Snippet;
37
+ class?: string;
38
+ }
39
+
40
+ // =========================
41
+ // PROPS
42
+ // =========================
43
+ let {
44
+ search = $bindable(),
45
+ searchPlaceholder = "Search...",
46
+ searchLabel = "Search",
47
+ tabs,
48
+ tab = $bindable(),
49
+ tabsLabel = "Show",
50
+ views,
51
+ view = $bindable(),
52
+ viewLabel = "View",
53
+ filters,
54
+ actions,
55
+ class: className,
56
+ }: Props = $props();
57
+
58
+ // =========================
59
+ // DERIVED
60
+ // =========================
61
+ let currentTab = $derived(tabs?.find((t) => t.value === tab));
62
+ let currentView = $derived(views?.find((v) => v.value === view));
63
+ let searching = $derived(Boolean(search));
64
+ </script>
65
+
66
+ <!-- =========================== -->
67
+ <!-- MARKUP -->
68
+ <!-- =========================== -->
69
+ <!-- WHY a container query: the toolbar sits in a pane that is narrower than the screen, and a
70
+ breakpoint reads the screen. WHY nowrap: a second row pushes the content down and moves the
71
+ page as a search is opened. Search, tabs and view are the three that fold, each into the one
72
+ menu that holds them; both forms are in the DOM and the query shows one, so there is no
73
+ measuring and no flash on first paint. -->
74
+ <div class={cn("@container/toolbar min-w-0", className)}>
75
+ <div class="flex flex-nowrap items-center gap-2">
76
+ {#if search !== undefined}
77
+ <div class="w-56 shrink-0 @max-2xl/toolbar:hidden">
78
+ <ToolbarSearch bind:value={search} placeholder={searchPlaceholder} />
79
+ </div>
80
+ <Popover.Root>
81
+ <Popover.Trigger
82
+ aria-label={searchLabel}
83
+ class={cn(toolbarTriggerClass({ active: searching }), "@2xl/toolbar:hidden")}
84
+ >
85
+ <MagnifyingGlass class="size-3.5" aria-hidden="true" />
86
+ </Popover.Trigger>
87
+ <Popover.Content align="start" class="w-64 p-2">
88
+ <Input bind:value={search} placeholder={searchPlaceholder} aria-label={searchLabel} class="h-8" />
89
+ </Popover.Content>
90
+ </Popover.Root>
91
+ {/if}
92
+
93
+ {#if tabs && tab !== undefined}
94
+ <SegmentedFilter
95
+ options={tabs}
96
+ bind:value={tab}
97
+ ariaLabel={tabsLabel}
98
+ block={false}
99
+ class="@max-2xl/toolbar:hidden"
100
+ />
101
+ <DropdownMenu.Root>
102
+ <DropdownMenu.Trigger
103
+ aria-label={tabsLabel}
104
+ class={cn(toolbarTriggerClass(), "min-w-0 @2xl/toolbar:hidden")}
105
+ >
106
+ <span class="truncate">{currentTab?.label}</span>
107
+ <CaretDown class="size-3 shrink-0" aria-hidden="true" />
108
+ </DropdownMenu.Trigger>
109
+ <DropdownMenu.Content align="start">
110
+ <DropdownMenu.RadioGroup bind:value={tab}>
111
+ {#each tabs as item (item.value)}
112
+ <DropdownMenu.RadioItem value={item.value}>
113
+ {item.label}
114
+ {#if item.count != null}
115
+ <span class="ms-auto pe-2 text-xs text-muted-foreground tabular-nums">{item.count}</span>
116
+ {/if}
117
+ </DropdownMenu.RadioItem>
118
+ {/each}
119
+ </DropdownMenu.RadioGroup>
120
+ </DropdownMenu.Content>
121
+ </DropdownMenu.Root>
122
+ {/if}
123
+
124
+ {#if filters}
125
+ <div class="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
126
+ {@render filters()}
127
+ </div>
128
+ {:else}
129
+ <div class="flex-1"></div>
130
+ {/if}
131
+
132
+ {#if views && view !== undefined}
133
+ <SegmentedFilter
134
+ options={views}
135
+ bind:value={view}
136
+ ariaLabel={viewLabel}
137
+ block={false}
138
+ class="@max-2xl/toolbar:hidden"
139
+ />
140
+ <DropdownMenu.Root>
141
+ <DropdownMenu.Trigger
142
+ aria-label={viewLabel}
143
+ class={cn(toolbarTriggerClass(), "@2xl/toolbar:hidden")}
144
+ >
145
+ {#if currentView}
146
+ <currentView.icon class="size-3.5" aria-hidden="true" />
147
+ {/if}
148
+ </DropdownMenu.Trigger>
149
+ <DropdownMenu.Content align="end">
150
+ <DropdownMenu.RadioGroup bind:value={view}>
151
+ {#each views as item (item.value)}
152
+ <DropdownMenu.RadioItem value={item.value}>
153
+ <item.icon class="size-4" aria-hidden="true" />
154
+ {item.label}
155
+ </DropdownMenu.RadioItem>
156
+ {/each}
157
+ </DropdownMenu.RadioGroup>
158
+ </DropdownMenu.Content>
159
+ </DropdownMenu.Root>
160
+ {/if}
161
+
162
+ {#if actions}
163
+ <div class="flex shrink-0 items-center gap-2">
164
+ {@render actions()}
165
+ </div>
166
+ {/if}
167
+ </div>
168
+ </div>
@@ -0,0 +1,66 @@
1
+ import { mount, tick, unmount } from "svelte";
2
+ import { afterEach, describe, expect, it } from "vite-plus/test";
3
+ import { List, SquaresFour } from "phosphor-svelte";
4
+ import Toolbar from "./toolbar.svelte";
5
+
6
+ const tabs = [
7
+ { value: "all", label: "All", count: 12 },
8
+ { value: "open", label: "Open", count: 4 },
9
+ ];
10
+ const views = [
11
+ { value: "list", label: "List", icon: List },
12
+ { value: "grid", label: "Grid", icon: SquaresFour },
13
+ ];
14
+
15
+ let target: HTMLElement;
16
+ let app: Record<string, unknown> | undefined;
17
+
18
+ function render(props: Record<string, unknown>) {
19
+ target = document.createElement("div");
20
+ document.body.append(target);
21
+ app = mount(Toolbar, { target, props }) as Record<string, unknown>;
22
+ return target;
23
+ }
24
+
25
+ afterEach(() => {
26
+ if (app) void unmount(app);
27
+ target?.remove();
28
+ app = undefined;
29
+ });
30
+
31
+ describe("Toolbar", () => {
32
+ it("is one row that never wraps", () => {
33
+ const root = render({ search: "", tabs, tab: "all", views, view: "list" });
34
+ const row = root.querySelector(".flex");
35
+ expect(row?.classList.contains("flex-nowrap")).toBe(true);
36
+ expect(row?.classList.contains("flex-wrap")).toBe(false);
37
+ });
38
+
39
+ it("draws search, tabs and view twice, one for each width, and queries pick one", () => {
40
+ const root = render({ search: "", tabs, tab: "all", views, view: "list" });
41
+ const wide = root.querySelectorAll(".\\@max-2xl\\/toolbar\\:hidden");
42
+ const narrow = root.querySelectorAll(".\\@2xl\\/toolbar\\:hidden");
43
+ expect(wide.length).toBe(3);
44
+ expect(narrow.length).toBe(3);
45
+ });
46
+
47
+ it("names the narrow controls so a screen reader can find them", () => {
48
+ const root = render({ search: "", tabs, tab: "all", views, view: "list", searchLabel: "Find", tabsLabel: "Status", viewLabel: "Layout" });
49
+ for (const label of ["Find", "Status", "Layout"]) {
50
+ expect(root.querySelector(`button[aria-label="${label}"]`), label).not.toBeNull();
51
+ }
52
+ });
53
+
54
+ it("shows the current tab on the narrow menu trigger", async () => {
55
+ const root = render({ search: "", tabs, tab: "open", views, view: "list" });
56
+ await tick();
57
+ expect(root.querySelector('button[aria-label="Show"]')?.textContent?.trim()).toBe("Open");
58
+ });
59
+
60
+ it("leaves out what it isn't given", () => {
61
+ const root = render({ tabs, tab: "all" });
62
+ expect(root.querySelector('button[aria-label="Search"]')).toBeNull();
63
+ expect(root.querySelector('button[aria-label="View"]')).toBeNull();
64
+ expect(root.querySelector('button[aria-label="Show"]')).not.toBeNull();
65
+ });
66
+ });
@@ -0,0 +1,58 @@
1
+ # @heyramzi/ui/editor
2
+
3
+ A Svelte 5 rich text editor on [Tiptap](https://tiptap.dev): headings, lists, tables, images, YouTube links, a slash menu, an image toolbar and optional block ids. It saves Tiptap JSON, and a renderer turns that JSON back into HTML on the server, so a crawler sees the body.
4
+
5
+ Tiptap is a dependency of `@heyramzi/ui`, and a bundler only ships it when you import this subpath.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pnpm add @heyramzi/ui
11
+ ```
12
+
13
+ `svelte` (5.57 or newer) is a peer. The editor's toolbar uses the kit's `Button` and `Input`, so import the kit's `tokens.css` once and point Tailwind at the package:
14
+
15
+ ```css
16
+ @import "tailwindcss";
17
+ @import "@heyramzi/ui/tokens.css";
18
+
19
+ @source "../node_modules/@heyramzi/ui";
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```svelte
25
+ <script lang="ts">
26
+ import { RichEditor, RichRenderer } from "@heyramzi/ui/editor";
27
+
28
+ let doc = $state<Record<string, unknown> | null>(null);
29
+ </script>
30
+
31
+ <RichEditor content={doc} onchange={(json) => (doc = json)} preset="full" />
32
+
33
+ <RichRenderer content={doc} />
34
+ ```
35
+
36
+ ## What is inside
37
+
38
+ | Export | What it is |
39
+ | --- | --- |
40
+ | `RichEditor` | The editor. `preset` is `full` or `notes`, `blockIds` stamps a stable id on each block |
41
+ | `RichRenderer` | Renders saved JSON to HTML, on the server too, with a click-to-load YouTube facade |
42
+ | `renderTiptapContent`, `extractHeadings`, `extractYoutubeVideos` | The serializer, for a table of contents or a sitemap |
43
+ | `BlockIdExtension`, `generateBlockId` | The block id extension |
44
+ | `@heyramzi/ui/editor/markdown` | `tiptapToMarkdown` and `markdownToTiptap`. It pulls in markdown-it, so it isn't in the root export |
45
+ | `@heyramzi/ui/editor/block-ops` | Edit a document by block id: replace, insert, delete |
46
+ | `@heyramzi/ui/editor/inline-editor` | A single-block editor for one field |
47
+ | `@heyramzi/ui/editor/content.css` | The typography for editor and renderer. Both import it already |
48
+
49
+ ## Comparison tables
50
+
51
+ A table whose first row has a `Winner` (or `Gagnant`, `Vainqueur`) header gets a `comparison-table` class, and each cell in that column becomes a pill with `data-winner` set to the first word of the cell, lowercased. `tie` and `other` are styled for you. Colour one product from your own CSS:
52
+
53
+ ```css
54
+ .tiptap-content table.comparison-table .winner-cell[data-winner="acme"] p {
55
+ background: color-mix(in srgb, var(--color-signal) 14%, transparent);
56
+ color: var(--color-signal);
57
+ }
58
+ ```
@@ -0,0 +1 @@
1
+ declare module "*.css";
@@ -0,0 +1,77 @@
1
+ import { Extension } from "@tiptap/core";
2
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
3
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
4
+
5
+ // WHY: Top-level block types that need stable IDs for agentic read/write ops.
6
+ // Inline nodes (text, hardBreak) and list items are not top-level in ProseMirror
7
+ // doc structure, so they are excluded.
8
+ const TOP_LEVEL_BLOCK_TYPES = new Set([
9
+ "paragraph",
10
+ "heading",
11
+ "bulletList",
12
+ "orderedList",
13
+ "blockquote",
14
+ "codeBlock",
15
+ "horizontalRule",
16
+ ]);
17
+
18
+ /** Generate a stable unique ID for a block. Uses crypto.randomUUID() which is
19
+ * available in Cloudflare Workers, Node 24+, and all modern browsers. */
20
+ export function generateBlockId(): string {
21
+ return crypto.randomUUID();
22
+ }
23
+
24
+ function isTopLevelBlock(node: ProseMirrorNode): boolean {
25
+ return TOP_LEVEL_BLOCK_TYPES.has(node.type.name);
26
+ }
27
+
28
+ const blockIdPluginKey = new PluginKey("blockId");
29
+
30
+ /**
31
+ * TipTap extension that stamps a stable unique `id` on every top-level block,
32
+ * set on creation and kept on update. Opt in via the `blockIds` prop.
33
+ */
34
+ export const BlockIdExtension = Extension.create({
35
+ name: "blockId",
36
+
37
+ addGlobalAttributes() {
38
+ return [
39
+ {
40
+ types: Array.from(TOP_LEVEL_BLOCK_TYPES),
41
+ attributes: {
42
+ id: {
43
+ default: null,
44
+ parseHTML: (element) => element.getAttribute("data-block-id") || null,
45
+ renderHTML: (attributes) => {
46
+ if (!attributes.id) return {};
47
+ return { "data-block-id": attributes.id };
48
+ },
49
+ },
50
+ },
51
+ },
52
+ ];
53
+ },
54
+
55
+ addProseMirrorPlugins() {
56
+ return [
57
+ new Plugin({
58
+ key: blockIdPluginKey,
59
+ appendTransaction(_transactions, _oldState, newState) {
60
+ const { tr, doc } = newState;
61
+ let changed = false;
62
+
63
+ doc.forEach((node, offset) => {
64
+ if (!isTopLevelBlock(node)) return;
65
+ if (node.attrs.id) return;
66
+
67
+ const id = generateBlockId();
68
+ tr.setNodeMarkup(offset, undefined, { ...node.attrs, id });
69
+ changed = true;
70
+ });
71
+
72
+ return changed ? tr : null;
73
+ },
74
+ }),
75
+ ];
76
+ },
77
+ });
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ listBlocks,
4
+ appendBlocks,
5
+ prependBlocks,
6
+ insertBlocksAfter,
7
+ replaceBlock,
8
+ deleteBlock,
9
+ } from "./block-ops.js";
10
+ import { markdownToTiptap } from "./markdown.js";
11
+
12
+ // Build a test doc from markdown so blocks carry real ids.
13
+ function makeDoc(md: string) {
14
+ return markdownToTiptap(md);
15
+ }
16
+
17
+ // ---- listBlocks ----
18
+ describe("listBlocks", () => {
19
+ it("returns one entry per top-level block", () => {
20
+ const doc = makeDoc("# Heading\n\nParagraph.\n\n---");
21
+ const list = listBlocks(doc);
22
+ expect(list).toHaveLength(3);
23
+ });
24
+
25
+ it("each entry has id, type, text", () => {
26
+ const doc = makeDoc("Hello world");
27
+ const list = listBlocks(doc);
28
+ expect(list[0].id).toBeTruthy();
29
+ expect(list[0].type).toBe("paragraph");
30
+ expect(list[0].text).toContain("Hello world");
31
+ });
32
+
33
+ it("id matches the block's attrs.id", () => {
34
+ const doc = makeDoc("# Title\n\nBody");
35
+ const list = listBlocks(doc);
36
+ expect(list[0].id).toBe(doc.content[0].attrs?.id);
37
+ expect(list[1].id).toBe(doc.content[1].attrs?.id);
38
+ });
39
+
40
+ it("truncates long text to 120 chars", () => {
41
+ const longText = "x".repeat(200);
42
+ const doc = makeDoc(longText);
43
+ const list = listBlocks(doc);
44
+ expect(list[0].text.length).toBeLessThanOrEqual(120);
45
+ });
46
+ });
47
+
48
+ // ---- appendBlocks ----
49
+ describe("appendBlocks", () => {
50
+ it("adds blocks at the end", () => {
51
+ const doc = makeDoc("First block");
52
+ const result = appendBlocks(doc, "Second block");
53
+ expect(result.content).toHaveLength(2);
54
+ const types = listBlocks(result).map((b) => b.text);
55
+ expect(types[0]).toContain("First block");
56
+ expect(types[1]).toContain("Second block");
57
+ });
58
+
59
+ it("does not mutate the input doc", () => {
60
+ const doc = makeDoc("Original");
61
+ const originalLen = doc.content.length;
62
+ appendBlocks(doc, "Extra");
63
+ expect(doc.content).toHaveLength(originalLen);
64
+ });
65
+
66
+ it("new blocks have ids", () => {
67
+ const doc = makeDoc("First");
68
+ const result = appendBlocks(doc, "Added paragraph");
69
+ const newBlock = result.content[1];
70
+ const newId = newBlock.attrs?.id;
71
+ expect(typeof newId).toBe("string");
72
+ expect(typeof newId === "string" && newId.length).toBeGreaterThan(0);
73
+ });
74
+
75
+ it("preserves existing block ids", () => {
76
+ const doc = makeDoc("First");
77
+ const originalId = doc.content[0].attrs?.id;
78
+ const result = appendBlocks(doc, "Extra");
79
+ expect(result.content[0].attrs?.id).toBe(originalId);
80
+ });
81
+ });
82
+
83
+ // ---- prependBlocks ----
84
+ describe("prependBlocks", () => {
85
+ it("adds blocks at the beginning", () => {
86
+ const doc = makeDoc("Second");
87
+ const result = prependBlocks(doc, "First");
88
+ expect(result.content).toHaveLength(2);
89
+ expect(listBlocks(result)[0].text).toContain("First");
90
+ expect(listBlocks(result)[1].text).toContain("Second");
91
+ });
92
+
93
+ it("does not mutate the input doc", () => {
94
+ const doc = makeDoc("Original");
95
+ const originalLen = doc.content.length;
96
+ prependBlocks(doc, "New first");
97
+ expect(doc.content).toHaveLength(originalLen);
98
+ });
99
+
100
+ it("new blocks have ids", () => {
101
+ const doc = makeDoc("Existing");
102
+ const result = prependBlocks(doc, "Prepended");
103
+ expect(typeof result.content[0].attrs?.id).toBe("string");
104
+ });
105
+
106
+ it("preserves existing block ids", () => {
107
+ const doc = makeDoc("Existing");
108
+ const existingId = doc.content[0].attrs?.id;
109
+ const result = prependBlocks(doc, "Prepended");
110
+ expect(result.content[1].attrs?.id).toBe(existingId);
111
+ });
112
+ });
113
+
114
+ // ---- insertBlocksAfter ----
115
+ describe("insertBlocksAfter", () => {
116
+ it("inserts after the correct block", () => {
117
+ const doc = makeDoc("# First\n\n## Second\n\n### Third");
118
+ const secondId = doc.content[1].attrs?.id as string;
119
+ const result = insertBlocksAfter(doc, secondId, "Inserted");
120
+ expect(result.content).toHaveLength(4);
121
+ expect(listBlocks(result)[2].text).toContain("Inserted");
122
+ });
123
+
124
+ it("inserts at the end when afterId is the last block", () => {
125
+ const doc = makeDoc("A\n\nB");
126
+ const lastId = doc.content[1].attrs?.id as string;
127
+ const result = insertBlocksAfter(doc, lastId, "C");
128
+ expect(result.content).toHaveLength(3);
129
+ expect(listBlocks(result)[2].text).toContain("C");
130
+ });
131
+
132
+ it("throws for unknown id", () => {
133
+ const doc = makeDoc("Block");
134
+ expect(() => insertBlocksAfter(doc, "nonexistent-id", "new")).toThrow(/nonexistent-id/);
135
+ });
136
+
137
+ it("does not mutate the input doc", () => {
138
+ const doc = makeDoc("A\n\nB");
139
+ const firstId = doc.content[0].attrs?.id as string;
140
+ const originalLen = doc.content.length;
141
+ insertBlocksAfter(doc, firstId, "C");
142
+ expect(doc.content).toHaveLength(originalLen);
143
+ });
144
+
145
+ it("new blocks have ids", () => {
146
+ const doc = makeDoc("A");
147
+ const firstId = doc.content[0].attrs?.id as string;
148
+ const result = insertBlocksAfter(doc, firstId, "B");
149
+ expect(typeof result.content[1].attrs?.id).toBe("string");
150
+ });
151
+ });
152
+
153
+ // ---- replaceBlock ----
154
+ describe("replaceBlock", () => {
155
+ it("replaces the target block", () => {
156
+ const doc = makeDoc("# Old heading\n\nOther");
157
+ const headingId = doc.content[0].attrs?.id as string;
158
+ const result = replaceBlock(doc, headingId, "# New heading");
159
+ expect(result.content).toHaveLength(2);
160
+ const summary = listBlocks(result);
161
+ expect(summary[0].text).toContain("New heading");
162
+ expect(summary[1].text).toContain("Other");
163
+ });
164
+
165
+ it("throws for unknown id", () => {
166
+ const doc = makeDoc("Block");
167
+ expect(() => replaceBlock(doc, "no-such-id", "replacement")).toThrow(/no-such-id/);
168
+ });
169
+
170
+ it("does not mutate the input doc", () => {
171
+ const doc = makeDoc("Original");
172
+ const id = doc.content[0].attrs?.id as string;
173
+ const originalText = listBlocks(doc)[0].text;
174
+ replaceBlock(doc, id, "Replaced");
175
+ expect(listBlocks(doc)[0].text).toBe(originalText);
176
+ });
177
+
178
+ it("replacement blocks have fresh ids", () => {
179
+ const doc = makeDoc("Old");
180
+ const id = doc.content[0].attrs?.id as string;
181
+ const result = replaceBlock(doc, id, "New");
182
+ const newId = result.content[0].attrs?.id;
183
+ expect(typeof newId).toBe("string");
184
+ // Replacement block gets a fresh id, not the old one.
185
+ expect(newId).not.toBe(id);
186
+ });
187
+
188
+ it("can replace one block with multiple blocks", () => {
189
+ const doc = makeDoc("# Heading\n\nPara");
190
+ const headingId = doc.content[0].attrs?.id as string;
191
+ const result = replaceBlock(doc, headingId, "# A\n\n# B");
192
+ expect(result.content).toHaveLength(3);
193
+ });
194
+ });
195
+
196
+ // ---- deleteBlock ----
197
+ describe("deleteBlock", () => {
198
+ it("removes the target block", () => {
199
+ const doc = makeDoc("A\n\nB\n\nC");
200
+ const bId = doc.content[1].attrs?.id as string;
201
+ const result = deleteBlock(doc, bId);
202
+ expect(result.content).toHaveLength(2);
203
+ expect(listBlocks(result).map((b) => b.text)).not.toContain("B");
204
+ });
205
+
206
+ it("throws for unknown id", () => {
207
+ const doc = makeDoc("Block");
208
+ expect(() => deleteBlock(doc, "ghost-id")).toThrow(/ghost-id/);
209
+ });
210
+
211
+ it("does not mutate the input doc", () => {
212
+ const doc = makeDoc("A\n\nB");
213
+ const originalLen = doc.content.length;
214
+ const firstId = doc.content[0].attrs?.id as string;
215
+ deleteBlock(doc, firstId);
216
+ expect(doc.content).toHaveLength(originalLen);
217
+ });
218
+
219
+ it("preserves ids of remaining blocks", () => {
220
+ const doc = makeDoc("A\n\nB\n\nC");
221
+ const ids = doc.content.map((n) => n.attrs?.id as string);
222
+ const result = deleteBlock(doc, ids[1]);
223
+ expect(result.content[0].attrs?.id).toBe(ids[0]);
224
+ expect(result.content[1].attrs?.id).toBe(ids[2]);
225
+ });
226
+ });
227
+
228
+ // ---- invalid doc ----
229
+ describe("invalid doc input", () => {
230
+ it("listBlocks throws on null", () => {
231
+ expect(() => listBlocks(null)).toThrow();
232
+ });
233
+
234
+ it("listBlocks throws on non-doc object", () => {
235
+ expect(() => listBlocks({ type: "paragraph" })).toThrow();
236
+ });
237
+ });