@heyramzi/ui 2.0.1 → 2.2.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 (341) hide show
  1. package/package.json +48 -24
  2. package/scripts/sync-shadcn.mjs +3 -4
  3. package/src/agents/agent-avatar.svelte +604 -575
  4. package/src/agents/agent-cast.svelte +179 -183
  5. package/src/agents/demo.svelte +125 -113
  6. package/src/agents/helpers.ts +23 -17
  7. package/src/agents/index.ts +4 -4
  8. package/src/agents/page-state.ts +6 -6
  9. package/src/agents/types.ts +6 -6
  10. package/src/ai/ai-button/AiButton.svelte +4 -12
  11. package/src/ai/generation-progress/GenerationProgress.svelte +10 -3
  12. package/src/ai/prompt-input/PromptInputTextarea.svelte +1 -2
  13. package/src/ai/response/Response.css +2 -3
  14. package/src/animations/avatar-group-hover.css +2 -16
  15. package/src/animations/card-tilt.css +3 -6
  16. package/src/animations/digit-pop-in.css +2 -15
  17. package/src/animations/dropdown.css +4 -17
  18. package/src/animations/input-clear-dissolve.css +5 -18
  19. package/src/animations/input-shake.css +9 -41
  20. package/src/animations/learn-more-hover.css +17 -9
  21. package/src/animations/like-button.css +2 -4
  22. package/src/animations/page-side-by-side.css +2 -10
  23. package/src/animations/plus-menu-morph.css +2 -5
  24. package/src/animations/shimmer-text.css +5 -18
  25. package/src/animations/spinning-counter.css +2 -6
  26. package/src/animations/success-check.css +2 -15
  27. package/src/animations/thinking-states.css +2 -7
  28. package/src/brand/brand.test.ts +21 -5
  29. package/src/brand/index.ts +4 -10
  30. package/src/canvas/README.md +9 -12
  31. package/src/canvas/card/card-frame.svelte +43 -39
  32. package/src/canvas/card/resize-handles.svelte +89 -80
  33. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  34. package/src/canvas/chrome/lock-badge.svelte +8 -8
  35. package/src/canvas/chrome/side-handles.svelte +24 -19
  36. package/src/canvas/chrome/size-chip.svelte +30 -30
  37. package/src/canvas/chrome/snap-guides.svelte +20 -20
  38. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  39. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  40. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  41. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  42. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  43. package/src/canvas/edges/bend-handle.svelte +46 -46
  44. package/src/canvas/edges/edge-gradient.svelte +10 -10
  45. package/src/canvas/edges/gradient-edge.svelte +45 -29
  46. package/src/canvas/editor/lock.test.ts +6 -1
  47. package/src/canvas/editor/style.ts +7 -1
  48. package/src/canvas/headless/svg-figure.test.ts +5 -1
  49. package/src/canvas/lod/lod-switch.svelte +111 -106
  50. package/src/canvas/pen/PenField.svelte +87 -81
  51. package/src/canvas/pen/PenOverlay.svelte +127 -127
  52. package/src/canvas/pen/ShapeNode.svelte +31 -32
  53. package/src/canvas/pen/TextNode.svelte +54 -53
  54. package/src/canvas/pen/host-field.test.svelte +7 -7
  55. package/src/canvas/pen/shapes.ts +7 -1
  56. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  57. package/src/canvas/screen/PartView.svelte +138 -72
  58. package/src/canvas/screen/ScreenBody.svelte +81 -60
  59. package/src/canvas/screen/ScreenCard.svelte +20 -20
  60. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  61. package/src/canvas/ui/FrameLayer.svelte +59 -59
  62. package/src/canvas/ui/PenLayer.svelte +39 -30
  63. package/src/canvas/ui/StylePanel.svelte +119 -102
  64. package/src/canvas/ui/canvas-grid.svelte +137 -129
  65. package/src/charts/animated-value.svelte +1 -1
  66. package/src/charts/area-chart.css +2 -5
  67. package/src/charts/area-chart.svelte +1 -1
  68. package/src/charts/bar-chart.css +2 -3
  69. package/src/charts/bar-chart.svelte +1 -1
  70. package/src/charts/charts.css +5 -17
  71. package/src/charts/crosshair.svelte +1 -1
  72. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  73. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  74. package/src/charts/dashboard/data-panel.svelte +50 -50
  75. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  76. package/src/charts/dashboard/data-table.svelte +138 -138
  77. package/src/charts/dashboard/panel-grid.svelte +30 -30
  78. package/src/charts/dashboard/period-filter.svelte +25 -25
  79. package/src/charts/dashboard/rating-chip.svelte +19 -19
  80. package/src/charts/dashboard/stat-band.svelte +32 -32
  81. package/src/charts/dashboard/stat-tile.svelte +106 -99
  82. package/src/charts/donut-chart.svelte +1 -1
  83. package/src/charts/edge-fade.svelte +1 -1
  84. package/src/charts/figure.svelte +1 -1
  85. package/src/charts/gauge-chart.css +4 -7
  86. package/src/charts/gauge-chart.svelte +1 -1
  87. package/src/charts/hover-focus.svelte +1 -1
  88. package/src/charts/legend.css +1 -3
  89. package/src/charts/legend.svelte +1 -1
  90. package/src/charts/line-chart.svelte +1 -1
  91. package/src/charts/number-chart.css +6 -9
  92. package/src/charts/number-chart.svelte +1 -1
  93. package/src/charts/reference-line.svelte +19 -19
  94. package/src/charts/reveal.svelte +1 -1
  95. package/src/charts/ring-chart.css +2 -4
  96. package/src/charts/ring-chart.svelte +1 -1
  97. package/src/charts/sparkline.svelte +5 -2
  98. package/src/charts/stat-card.svelte +1 -1
  99. package/src/charts/time-series-chart.css +2 -5
  100. package/src/charts/trend-pill.css +2 -4
  101. package/src/charts/trend-pill.svelte +1 -1
  102. package/src/components/copy/copy-button.svelte +93 -98
  103. package/src/components/copy/copy-button.test.ts +10 -2
  104. package/src/components/data-table/infinite-scroll.svelte +42 -42
  105. package/src/components/data-table/pagination.svelte +34 -22
  106. package/src/components/data-table/row-arrow.svelte +19 -16
  107. package/src/components/data-table/row-context-menu.svelte +52 -52
  108. package/src/components/data-table/sortable-head.svelte +25 -25
  109. package/src/components/data-table/table-skeleton.svelte +41 -36
  110. package/src/components/docs/doc-page.svelte +18 -9
  111. package/src/components/docs/on-this-page.svelte +16 -3
  112. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  113. package/src/components/field-row/field-row.svelte +10 -8
  114. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  115. package/src/components/icon/icon.svelte +14 -14
  116. package/src/components/language-flag/language-flag.svelte +3 -1
  117. package/src/components/list-page/detail-empty.svelte +34 -34
  118. package/src/components/list-page/detail-header.svelte +55 -47
  119. package/src/components/list-page/detail-panel.svelte +51 -59
  120. package/src/components/list-page/detail-row.svelte +19 -19
  121. package/src/components/list-page/detail-stack.svelte +21 -21
  122. package/src/components/list-page/empty-state.svelte +30 -30
  123. package/src/components/list-page/filter-item.svelte +20 -20
  124. package/src/components/list-page/filter-popover.svelte +60 -55
  125. package/src/components/list-page/grouped-list.svelte +4 -2
  126. package/src/components/list-page/index.ts +8 -4
  127. package/src/components/list-page/list-page-shell.svelte +2 -5
  128. package/src/components/list-page/master-detail.svelte +87 -88
  129. package/src/components/list-page/notice-bar.svelte +38 -38
  130. package/src/components/list-page/prev-next.svelte +63 -63
  131. package/src/components/list-page/rail-body.svelte +18 -18
  132. package/src/components/list-page/rail-empty.svelte +23 -30
  133. package/src/components/list-page/rail-head.svelte +24 -24
  134. package/src/components/list-page/rail-row.svelte +77 -73
  135. package/src/components/list-page/section-host.ts +2 -3
  136. package/src/components/locale-switch/locale-switch.svelte +41 -41
  137. package/src/components/mark/mark.svelte +34 -38
  138. package/src/components/open-in/index.ts +1 -5
  139. package/src/components/open-in/open-in.svelte +40 -40
  140. package/src/components/picker/mark-picker.svelte +49 -49
  141. package/src/components/picker/picker.svelte +5 -3
  142. package/src/components/section-header/index.ts +1 -0
  143. package/src/components/section-header/section-header.svelte +1 -1
  144. package/src/components/section-header/section-header.test.ts +7 -1
  145. package/src/components/section-header/section-label.svelte +39 -0
  146. package/src/components/segmented/animated-tabs.svelte +31 -2
  147. package/src/components/segmented/collapse.test.ts +2 -1
  148. package/src/components/segmented/index.ts +2 -0
  149. package/src/components/segmented/segment.svelte +52 -49
  150. package/src/components/segmented/segmented-filter.svelte +79 -80
  151. package/src/components/segmented/segmented-nav.svelte +34 -40
  152. package/src/components/segmented/segmented-overflow.svelte +53 -50
  153. package/src/components/segmented/segmented-track.svelte +62 -46
  154. package/src/components/segmented/segmented.ts +2 -3
  155. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  156. package/src/components/svg/svg.svelte +4 -4
  157. package/src/components/table-view/table-view.svelte +17 -2
  158. package/src/components/table-view/table-view.test.ts +7 -1
  159. package/src/components/table-view/types.ts +11 -11
  160. package/src/components/tags-input/index.ts +13 -0
  161. package/src/components/tags-input/tags-input.svelte +305 -0
  162. package/src/components/tags-input/tags.test.ts +64 -0
  163. package/src/components/tags-input/tags.ts +142 -0
  164. package/src/components/theme/index.ts +2 -4
  165. package/src/components/theme/theme-provider.svelte +1 -3
  166. package/src/components/toolbar/index.ts +2 -0
  167. package/src/components/{list-page → toolbar}/toolbar-search.svelte +25 -25
  168. package/src/components/toolbar/toolbar.svelte +13 -9
  169. package/src/components/toolbar/toolbar.test.ts +10 -1
  170. package/src/design/index.css +21 -78
  171. package/src/design/polish.css +26 -35
  172. package/src/editor/editor-toolbar.svelte +104 -99
  173. package/src/editor/editor.svelte +296 -266
  174. package/src/editor/image-toolbar.svelte +20 -22
  175. package/src/editor/index.ts +1 -4
  176. package/src/editor/inline-editor.svelte +280 -238
  177. package/src/editor/markdown.ts +15 -15
  178. package/src/editor/renderer.svelte +71 -71
  179. package/src/editor/slash-menu.svelte +24 -27
  180. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  181. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  182. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  183. package/src/effects/magic-card/MagicCard.css +14 -7
  184. package/src/effects/marquee/Marquee.svelte +3 -5
  185. package/src/effects/reveal/observe.ts +0 -3
  186. package/src/effects/spotlight/Spotlight.svelte +1 -1
  187. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  188. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  189. package/src/gallery.test.ts +14 -4
  190. package/src/inbox/README.md +27 -0
  191. package/src/inbox/inbox.test.ts +214 -0
  192. package/src/inbox/index.ts +31 -0
  193. package/src/inbox/logic.ts +169 -0
  194. package/src/inbox/thread-inbox.svelte +439 -0
  195. package/src/inbox/types.ts +161 -0
  196. package/src/lib/theme-transition.ts +2 -4
  197. package/src/lib/tone.ts +2 -4
  198. package/src/lib/utils.ts +2 -2
  199. package/src/modules.test.ts +105 -0
  200. package/src/onboarding/define-tours.test.ts +55 -40
  201. package/src/onboarding/define-tours.ts +23 -23
  202. package/src/onboarding/demo.svelte +24 -5
  203. package/src/onboarding/index.ts +1 -8
  204. package/src/onboarding/onboarding-provider.svelte +19 -7
  205. package/src/onboarding/onboarding.svelte.ts +16 -16
  206. package/src/onboarding/pick.test.ts +61 -52
  207. package/src/onboarding/pick.ts +16 -13
  208. package/src/onboarding/stores.test.ts +80 -80
  209. package/src/onboarding/stores.ts +41 -39
  210. package/src/onboarding/tour-card.svelte +30 -5
  211. package/src/onboarding/tour-runner.svelte +12 -3
  212. package/src/onboarding/types.ts +20 -12
  213. package/src/onboarding/wait.ts +22 -22
  214. package/src/panel/README.md +5 -0
  215. package/src/panel/index.ts +2 -0
  216. package/src/panel/side-panel.svelte +194 -0
  217. package/src/record/README.md +3 -0
  218. package/src/record/demo-data.ts +352 -0
  219. package/src/record/field-section.svelte +57 -0
  220. package/src/record/fields.ts +194 -0
  221. package/src/record/format.ts +94 -0
  222. package/src/{tasks/record → record}/index.ts +49 -35
  223. package/src/{tasks/record → record}/info-list.svelte +145 -114
  224. package/src/record/kinds.ts +42 -0
  225. package/src/record/logic.ts +326 -0
  226. package/src/record/next-up.svelte +81 -0
  227. package/src/{tasks/record → record}/project-list.svelte +56 -50
  228. package/src/{tasks/record → record}/record-demo.svelte +130 -110
  229. package/src/{tasks/record → record}/record-header.svelte +37 -28
  230. package/src/{tasks/record → record}/record-shell.svelte +54 -54
  231. package/src/record/record-stats.svelte +48 -0
  232. package/src/record/record-timeline.svelte +251 -0
  233. package/src/record/record.test.ts +471 -0
  234. package/src/{tasks/record → record}/steps-rail.svelte +225 -216
  235. package/src/record/types.ts +299 -0
  236. package/src/record/where-we-are.svelte +125 -0
  237. package/src/shadcn/button/button.svelte +1 -1
  238. package/src/shadcn/card/card-title.svelte +1 -5
  239. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  240. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  241. package/src/shadcn/table/table-cell.svelte +2 -3
  242. package/src/shadcn/utilities.css +3 -8
  243. package/src/slides/Controls.svelte +150 -124
  244. package/src/slides/Deck.svelte +243 -238
  245. package/src/slides/Fragment.svelte +64 -57
  246. package/src/slides/Overview.svelte +106 -101
  247. package/src/slides/Presenter.svelte +300 -266
  248. package/src/slides/Slide.svelte +185 -170
  249. package/src/slides/SlideList.svelte +16 -12
  250. package/src/slides/ambient.d.ts +1 -1
  251. package/src/slides/bridges.ts +42 -23
  252. package/src/slides/deck-controller.svelte.ts +47 -35
  253. package/src/slides/demo-slides.svelte +330 -306
  254. package/src/slides/demo.svelte +164 -160
  255. package/src/slides/export/capture.ts +20 -16
  256. package/src/slides/export/ffmpeg.ts +43 -38
  257. package/src/slides/export/index.ts +5 -5
  258. package/src/slides/export/launch.ts +3 -1
  259. package/src/slides/export/pdf.ts +30 -27
  260. package/src/slides/export/types.ts +10 -8
  261. package/src/slides/index.ts +11 -11
  262. package/src/slides/labels.ts +22 -19
  263. package/src/slides/layout.ts +9 -2
  264. package/src/slides/model.ts +4 -4
  265. package/src/slides/navigation.ts +43 -31
  266. package/src/slides/theme.css +5 -7
  267. package/src/slides/types.ts +9 -10
  268. package/src/status/README.md +18 -0
  269. package/src/status/define-statuses.ts +25 -0
  270. package/src/status/index.ts +11 -0
  271. package/src/{tasks → status}/status-badge.svelte +3 -1
  272. package/src/{tasks → status}/status-dot.svelte +1 -3
  273. package/src/{tasks → status}/status-icon.svelte +1 -1
  274. package/src/status/status-types.ts +40 -0
  275. package/src/tasks/README.md +1 -1
  276. package/src/tasks/board-order.ts +2 -4
  277. package/src/tasks/demo.svelte +192 -34
  278. package/src/tasks/detail/detail-demo.svelte +75 -15
  279. package/src/tasks/detail/detail-types.ts +45 -45
  280. package/src/tasks/detail/task-detail.svelte +40 -10
  281. package/src/tasks/detail/task-property.svelte +8 -2
  282. package/src/tasks/detail/task-thread.svelte +53 -12
  283. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  284. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  285. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  286. package/src/tasks/gantt/gantt-header.svelte +9 -3
  287. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  288. package/src/tasks/gantt/gantt.css +2 -1
  289. package/src/tasks/gantt/gantt.test.ts +4 -2
  290. package/src/tasks/gantt/logic.test.ts +110 -20
  291. package/src/tasks/gantt/logic.ts +42 -6
  292. package/src/tasks/inbox/inbox-card.svelte +15 -7
  293. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  294. package/src/tasks/inbox/inbox-logic.ts +6 -40
  295. package/src/tasks/inbox/inbox-statuses.ts +24 -24
  296. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  297. package/src/tasks/inbox/inbox-types.ts +54 -58
  298. package/src/tasks/inbox/index.ts +22 -21
  299. package/src/tasks/index.ts +52 -53
  300. package/src/tasks/kanban-board.svelte +16 -12
  301. package/src/tasks/kanban-types.ts +2 -3
  302. package/src/tasks/presets/sales.ts +7 -7
  303. package/src/tasks/task-gantt.svelte +28 -8
  304. package/src/tasks/task-list.svelte +41 -17
  305. package/src/tasks/time/duration.ts +40 -40
  306. package/src/tasks/time/time-chip.svelte +9 -2
  307. package/src/tasks/time/time-dot.svelte +1 -1
  308. package/src/tasks/time/time-ring.svelte +11 -3
  309. package/src/tasks/time/time-tracker.svelte +57 -15
  310. package/src/tasks/time/time-types.ts +57 -57
  311. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  312. package/src/thread/README.md +31 -0
  313. package/src/thread/index.ts +12 -0
  314. package/src/thread/logic.ts +61 -0
  315. package/src/thread/thread.svelte +432 -0
  316. package/src/thread/thread.test.ts +73 -0
  317. package/src/thread/types.ts +93 -0
  318. package/src/tokens/craft.css +17 -53
  319. package/src/tokens/index.css +3 -13
  320. package/src/tokens/palette.css +13 -11
  321. package/src/tokens/primitives.css +10 -23
  322. package/src/components/list-page/section-label.svelte +0 -39
  323. package/src/tasks/define-statuses.ts +0 -32
  324. package/src/tasks/inbox/inbox.svelte +0 -203
  325. package/src/tasks/record/demo-data.ts +0 -286
  326. package/src/tasks/record/field-section.svelte +0 -53
  327. package/src/tasks/record/fields.ts +0 -142
  328. package/src/tasks/record/format.ts +0 -87
  329. package/src/tasks/record/kinds.ts +0 -42
  330. package/src/tasks/record/logic.ts +0 -324
  331. package/src/tasks/record/next-up.svelte +0 -70
  332. package/src/tasks/record/record-stats.svelte +0 -48
  333. package/src/tasks/record/record-timeline.svelte +0 -231
  334. package/src/tasks/record/record.test.ts +0 -350
  335. package/src/tasks/record/types.ts +0 -280
  336. package/src/tasks/record/where-we-are.svelte +0 -121
  337. package/src/tasks/side-panel.svelte +0 -193
  338. package/src/tasks/status-types.ts +0 -40
  339. /package/src/components/{list-page → toolbar}/toolbar-trigger.ts +0 -0
  340. /package/src/{tasks → panel}/side-panel-types.ts +0 -0
  341. /package/src/{tasks/record → record}/gutter.ts +0 -0
@@ -2,68 +2,68 @@ import type { Tone } from "../../lib/tone.js";
2
2
 
3
3
  /** "90", "45m", "1h", "1.5h", "1h30", "1h 30m" and "1:30" to whole minutes. Null when it isn't a duration. */
4
4
  export function parseMinutes(text: string): number | null {
5
- const value = text.trim().toLowerCase();
6
- if (!value) return null;
7
- if (/^\d+$/.test(value)) return Number(value);
8
- const clock = /^(\d+):([0-5]?\d)$/.exec(value);
9
- if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
10
- const decimalHours = /^(\d+(?:[.,]\d+)?)\s*h$/.exec(value);
11
- if (decimalHours?.[1]) return Math.round(Number(decimalHours[1].replace(",", ".")) * 60);
12
- const parts = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?$/.exec(value);
13
- if (!parts || (!parts[1] && !parts[2])) return null;
14
- return Number(parts[1] ?? 0) * 60 + Number(parts[2] ?? 0);
5
+ const value = text.trim().toLowerCase();
6
+ if (!value) return null;
7
+ if (/^\d+$/.test(value)) return Number(value);
8
+ const clock = /^(\d+):([0-5]?\d)$/.exec(value);
9
+ if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
10
+ const decimalHours = /^(\d+(?:[.,]\d+)?)\s*h$/.exec(value);
11
+ if (decimalHours?.[1]) return Math.round(Number(decimalHours[1].replace(",", ".")) * 60);
12
+ const parts = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?$/.exec(value);
13
+ if (!parts || (!parts[1] && !parts[2])) return null;
14
+ return Number(parts[1] ?? 0) * 60 + Number(parts[2] ?? 0);
15
15
  }
16
16
 
17
17
  /** 150 to "2h 30m". */
18
18
  export function formatMinutes(minutes: number): string {
19
- const whole = Math.max(0, Math.round(Number.isFinite(minutes) ? minutes : 0));
20
- const hours = Math.floor(whole / 60);
21
- const rest = whole % 60;
22
- if (hours === 0) return `${rest}m`;
23
- return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
19
+ const whole = Math.max(0, Math.round(Number.isFinite(minutes) ? minutes : 0));
20
+ const hours = Math.floor(whole / 60);
21
+ const rest = whole % 60;
22
+ if (hours === 0) return `${rest}m`;
23
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
24
24
  }
25
25
 
26
26
  /** Milliseconds to the timer readout, "1:05:09". */
27
27
  export function formatClock(ms: number): { hours: string; minutes: string; seconds: string } {
28
- const total = Math.max(0, Math.floor(ms / 1000));
29
- return {
30
- hours: String(Math.floor(total / 3600)),
31
- minutes: String(Math.floor((total % 3600) / 60)).padStart(2, "0"),
32
- seconds: String(total % 60).padStart(2, "0"),
33
- };
28
+ const total = Math.max(0, Math.floor(ms / 1000));
29
+ return {
30
+ hours: String(Math.floor(total / 3600)),
31
+ minutes: String(Math.floor((total % 3600) / 60)).padStart(2, "0"),
32
+ seconds: String(total % 60).padStart(2, "0"),
33
+ };
34
34
  }
35
35
 
36
36
  /** The reader's own calendar day as `YYYY-MM-DD`. */
37
37
  export function localDay(date: Date = new Date()): string {
38
- const month = String(date.getMonth() + 1).padStart(2, "0");
39
- const day = String(date.getDate()).padStart(2, "0");
40
- return `${date.getFullYear()}-${month}-${day}`;
38
+ const month = String(date.getMonth() + 1).padStart(2, "0");
39
+ const day = String(date.getDate()).padStart(2, "0");
40
+ return `${date.getFullYear()}-${month}-${day}`;
41
41
  }
42
42
 
43
43
  /** "Today", "Yesterday", then "Mon, Oct 12", in the given locale. */
44
44
  export function dayLabel(day: string, locale?: string): string {
45
- const date = new Date(`${day}T12:00:00`);
46
- const now = new Date();
47
- now.setHours(12, 0, 0, 0);
48
- const gap = Math.round((date.getTime() - now.getTime()) / 86_400_000);
49
- if (gap === 0 || gap === -1) {
50
- const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(gap, "day");
51
- return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
52
- }
53
- return date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
45
+ const date = new Date(`${day}T12:00:00`);
46
+ const now = new Date();
47
+ now.setHours(12, 0, 0, 0);
48
+ const gap = Math.round((date.getTime() - now.getTime()) / 86_400_000);
49
+ if (gap === 0 || gap === -1) {
50
+ const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(gap, "day");
51
+ return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
52
+ }
53
+ return date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
54
54
  }
55
55
 
56
56
  /** The tone of a budget: quiet until 90%, warning to 100%, destructive past it. */
57
57
  export function budgetTone(logged: number, estimate: number | null | undefined): Tone {
58
- if (!estimate || estimate <= 0) return "neutral";
59
- const ratio = logged / estimate;
60
- if (ratio > 1) return "destructive";
61
- return ratio >= 0.9 ? "warning" : "neutral";
58
+ if (!estimate || estimate <= 0) return "neutral";
59
+ const ratio = logged / estimate;
60
+ if (ratio > 1) return "destructive";
61
+ return ratio >= 0.9 ? "warning" : "neutral";
62
62
  }
63
63
 
64
64
  /** A stable color for a person, from their name or id. */
65
65
  export function personColor(key: string): string {
66
- let hash = 0;
67
- for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 360;
68
- return `oklch(0.72 0.12 ${hash})`;
66
+ let hash = 0;
67
+ for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 360;
68
+ return `oklch(0.72 0.12 ${hash})`;
69
69
  }
@@ -16,7 +16,7 @@
16
16
  /** Replaces the spoken name. Defaults to a sentence built from the numbers. */
17
17
  label?: string;
18
18
  class?: string;
19
- };
19
+ }
20
20
 
21
21
  let { minutes, estimate, running = false, label, class: className }: Props = $props();
22
22
 
@@ -56,7 +56,14 @@
56
56
  <span class="size-3 shrink-0 [&>svg]:size-full" aria-hidden="true"><Clock /></span>
57
57
  {/if}
58
58
  <span aria-hidden="true">
59
- {formatMinutes(minutes)}{#if estimate}<span class={cn("font-normal", tone === "neutral" ? "text-muted-foreground/70" : TONE_TEXT[tone])}> / {formatMinutes(estimate)}</span>{/if}
59
+ {formatMinutes(minutes)}{#if estimate}<span
60
+ class={cn(
61
+ "font-normal",
62
+ tone === "neutral" ? "text-muted-foreground/70" : TONE_TEXT[tone],
63
+ )}
64
+ >
65
+ / {formatMinutes(estimate)}</span
66
+ >{/if}
60
67
  </span>
61
68
  </span>
62
69
  {/if}
@@ -6,7 +6,7 @@
6
6
  running?: boolean;
7
7
  size?: number;
8
8
  class?: string;
9
- };
9
+ }
10
10
 
11
11
  let { running = false, size = 8, class: className }: Props = $props();
12
12
  </script>
@@ -17,7 +17,7 @@
17
17
  /** Rendered in the middle, e.g. the percentage. */
18
18
  children?: Snippet;
19
19
  class?: string;
20
- };
20
+ }
21
21
 
22
22
  let { value, max, size = 56, stroke = 4, label, children, class: className }: Props = $props();
23
23
 
@@ -36,7 +36,14 @@
36
36
  aria-hidden={label ? undefined : "true"}
37
37
  >
38
38
  <svg viewBox="0 0 36 36" class="size-full -rotate-90" fill="none" aria-hidden="true">
39
- <circle cx="18" cy="18" r={R} stroke="currentColor" stroke-width={stroke} class="text-foreground/10" />
39
+ <circle
40
+ cx="18"
41
+ cy="18"
42
+ r={R}
43
+ stroke="currentColor"
44
+ stroke-width={stroke}
45
+ class="text-foreground/10"
46
+ />
40
47
  <circle
41
48
  cx="18"
42
49
  cy="18"
@@ -58,7 +65,8 @@
58
65
  <style>
59
66
  .time-ring-arc {
60
67
  transition:
61
- stroke-dasharray var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
68
+ stroke-dasharray var(--duration-slow, 400ms)
69
+ var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
62
70
  color var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
63
71
  }
64
72
 
@@ -13,7 +13,12 @@
13
13
  personColor,
14
14
  } from "./duration.js";
15
15
  import { DEFAULT_TIME_LABELS } from "./time-types.js";
16
- import type { TimeLabels, TimeLogInput, TimeRunning, TimeStopInfo } from "./time-types.js";
16
+ import type {
17
+ TimeLabels,
18
+ TimeLogInput,
19
+ TimeRunning,
20
+ TimeStopInfo,
21
+ } from "./time-types.js";
17
22
  import TimeDot from "./time-dot.svelte";
18
23
  import { CaretDown, Play, Stop, X } from "phosphor-svelte";
19
24
 
@@ -135,7 +140,9 @@
135
140
  let invalid = $state(false);
136
141
  let durationInput = $state<HTMLInputElement>();
137
142
  let parsed = $derived(parseMinutes(duration));
138
- let preview = $derived(parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "");
143
+ let preview = $derived(
144
+ parsed !== null && parsed >= 1 && parsed <= 1440 ? formatMinutes(parsed) : "",
145
+ );
139
146
 
140
147
  async function submit(event: SubmitEvent): Promise<void> {
141
148
  event.preventDefault();
@@ -228,9 +235,15 @@
228
235
  <Popover.Trigger class="time-open" aria-label={labels.title}>
229
236
  {#if running}
230
237
  <TimeDot running size={6} />
231
- <span role="timer" aria-label={labels.elapsed}>{clock.hours}:{clock.minutes}:{clock.seconds}</span>
238
+ <span role="timer" aria-label={labels.elapsed}
239
+ >{clock.hours}:{clock.minutes}:{clock.seconds}</span
240
+ >
232
241
  {:else if total > 0}
233
- <span>{formatMinutes(total)}{#if estimate}<span class="ms-1 font-normal text-muted-foreground">/ {formatMinutes(estimate)}</span>{/if}</span>
242
+ <span
243
+ >{formatMinutes(total)}{#if estimate}<span
244
+ class="ms-1 font-normal text-muted-foreground">/ {formatMinutes(estimate)}</span
245
+ >{/if}</span
246
+ >
234
247
  {:else}
235
248
  <span class="text-muted-foreground">{labels.timer}</span>
236
249
  {/if}
@@ -254,12 +267,20 @@
254
267
  class={cn("time-field w-full tabular-nums", preview && "pr-12")}
255
268
  />
256
269
  {#if preview && preview !== duration.trim()}
257
- <span class="time-preview pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground tabular-nums" aria-hidden="true">
270
+ <span
271
+ class="time-preview pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground tabular-nums"
272
+ aria-hidden="true"
273
+ >
258
274
  {preview}
259
275
  </span>
260
276
  {/if}
261
277
  </div>
262
- <input type="date" bind:value={day} aria-label={labels.date} class="time-field w-full tabular-nums" />
278
+ <input
279
+ type="date"
280
+ bind:value={day}
281
+ aria-label={labels.date}
282
+ class="time-field w-full tabular-nums"
283
+ />
263
284
  <input
264
285
  bind:value={note}
265
286
  placeholder={labels.notePlaceholder}
@@ -288,11 +309,18 @@
288
309
  <div class="h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
289
310
  <div
290
311
  class="time-segment h-full rounded-full"
291
- style:background={tone === "neutral" ? "var(--color-foreground)" : TONE_COLOR[tone]}
312
+ style:background={tone === "neutral"
313
+ ? "var(--color-foreground)"
314
+ : TONE_COLOR[tone]}
292
315
  style:width="{Math.min(100, percent)}%"
293
316
  ></div>
294
317
  </div>
295
- <p class={cn("text-xs tabular-nums", tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone])}>
318
+ <p
319
+ class={cn(
320
+ "text-xs tabular-nums",
321
+ tone === "neutral" ? "text-muted-foreground" : TONE_TEXT[tone],
322
+ )}
323
+ >
296
324
  {liveTotal > estimate
297
325
  ? labels.over(formatMinutes(liveTotal - estimate), formatMinutes(estimate))
298
326
  : labels.remaining(formatMinutes(estimate - liveTotal), formatMinutes(estimate))}
@@ -303,7 +331,11 @@
303
331
  <ul class="flex flex-wrap gap-x-3 gap-y-1">
304
332
  {#each people as person (person.key)}
305
333
  <li class="flex items-center gap-1.5 text-xs">
306
- <span class="size-2 rounded-full" style:background={personColor(person.key)} aria-hidden="true"></span>
334
+ <span
335
+ class="size-2 rounded-full"
336
+ style:background={personColor(person.key)}
337
+ aria-hidden="true"
338
+ ></span>
307
339
  <span class="text-muted-foreground">{person.name}</span>
308
340
  <span class="font-medium tabular-nums">{formatMinutes(person.minutes)}</span>
309
341
  </li>
@@ -318,8 +350,12 @@
318
350
  {#each groups as group (group.day)}
319
351
  <div>
320
352
  <div class="flex items-baseline justify-between border-b border-border/60 pb-1">
321
- <h3 class="text-xs font-medium text-muted-foreground">{dayLabel(group.day, locale)}</h3>
322
- <span class="text-xs font-medium text-muted-foreground tabular-nums">{formatMinutes(group.minutes)}</span>
353
+ <h3 class="text-xs font-medium text-muted-foreground">
354
+ {dayLabel(group.day, locale)}
355
+ </h3>
356
+ <span class="text-xs font-medium text-muted-foreground tabular-nums"
357
+ >{formatMinutes(group.minutes)}</span
358
+ >
323
359
  </div>
324
360
  <ul>
325
361
  {#each group.items as entry (getId(entry))}
@@ -341,9 +377,13 @@
341
377
  <span class="min-w-0 flex-1 truncate">
342
378
  <span class="sr-only">{name},</span>
343
379
  {#if text}{text}{:else}<span class="text-muted-foreground">{name}</span>{/if}
344
- {#if renderMeta}<span class="ms-1.5 text-xs text-muted-foreground">{@render renderMeta(entry)}</span>{/if}
380
+ {#if renderMeta}<span class="ms-1.5 text-xs text-muted-foreground"
381
+ >{@render renderMeta(entry)}</span
382
+ >{/if}
345
383
  </span>
346
- <span class="shrink-0 font-medium tabular-nums">{formatMinutes(getMinutes(entry))}</span>
384
+ <span class="shrink-0 font-medium tabular-nums"
385
+ >{formatMinutes(getMinutes(entry))}</span
386
+ >
347
387
  {#if onDelete && (canDelete?.(entry) ?? true)}
348
388
  <button
349
389
  type="button"
@@ -498,7 +538,8 @@
498
538
  }
499
539
 
500
540
  .time-segment {
501
- transition: width var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
541
+ transition: width var(--duration-slow, 400ms)
542
+ var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
502
543
  }
503
544
 
504
545
  .time-delete {
@@ -527,7 +568,8 @@
527
568
  }
528
569
 
529
570
  .time-row.is-new {
530
- animation: time-row-in var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
571
+ animation: time-row-in var(--duration-fast, 250ms)
572
+ var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
531
573
  }
532
574
 
533
575
  @keyframes time-pop {
@@ -1,74 +1,74 @@
1
1
  /** A running timer. The host persists it, the tracker only reads it. */
2
2
  export type TimeRunning = {
3
- startedAt: Date | string | number;
3
+ startedAt: Date | string | number;
4
4
  };
5
5
 
6
6
  /** What the tracker hands back when a manual entry is submitted. */
7
7
  export type TimeLogInput = {
8
- minutes: number;
9
- /** The day worked, `YYYY-MM-DD`. */
10
- day: string;
11
- note?: string;
8
+ minutes: number;
9
+ /** The day worked, `YYYY-MM-DD`. */
10
+ day: string;
11
+ note?: string;
12
12
  };
13
13
 
14
14
  /** What the tracker hands back when the timer stops. */
15
15
  export type TimeStopInfo = {
16
- /** Elapsed whole minutes, never below 1. */
17
- minutes: number;
18
- startedAt: Date;
19
- stoppedAt: Date;
20
- /** The day it started, `YYYY-MM-DD`, in the reader's calendar. */
21
- day: string;
16
+ /** Elapsed whole minutes, never below 1. */
17
+ minutes: number;
18
+ startedAt: Date;
19
+ stoppedAt: Date;
20
+ /** The day it started, `YYYY-MM-DD`, in the reader's calendar. */
21
+ day: string;
22
22
  };
23
23
 
24
24
  export type TimeLabels = {
25
- title: string;
26
- timer: string;
27
- start: string;
28
- stop: string;
29
- running: string;
30
- since: (time: string) => string;
31
- started: string;
32
- runningFor: (duration: string) => string;
33
- stopped: (duration: string) => string;
34
- elapsed: string;
35
- logged: string;
36
- duration: string;
37
- durationPlaceholder: string;
38
- date: string;
39
- note: string;
40
- notePlaceholder: string;
41
- log: string;
42
- saving: string;
43
- invalid: string;
44
- empty: string;
45
- remaining: (left: string, estimate: string) => string;
46
- over: (over: string, estimate: string) => string;
47
- deleteEntry: (duration: string) => string;
25
+ title: string;
26
+ timer: string;
27
+ start: string;
28
+ stop: string;
29
+ running: string;
30
+ since: (time: string) => string;
31
+ started: string;
32
+ runningFor: (duration: string) => string;
33
+ stopped: (duration: string) => string;
34
+ elapsed: string;
35
+ logged: string;
36
+ duration: string;
37
+ durationPlaceholder: string;
38
+ date: string;
39
+ note: string;
40
+ notePlaceholder: string;
41
+ log: string;
42
+ saving: string;
43
+ invalid: string;
44
+ empty: string;
45
+ remaining: (left: string, estimate: string) => string;
46
+ over: (over: string, estimate: string) => string;
47
+ deleteEntry: (duration: string) => string;
48
48
  };
49
49
 
50
50
  export const DEFAULT_TIME_LABELS: TimeLabels = {
51
- title: "Time",
52
- timer: "Timer",
53
- start: "Start timer",
54
- stop: "Stop timer",
55
- running: "Running",
56
- since: (time) => `Since ${time}`,
57
- started: "Timer started",
58
- runningFor: (duration) => `Timer running, ${duration}`,
59
- stopped: (duration) => `Timer stopped, ${duration} to log`,
60
- elapsed: "Elapsed time",
61
- logged: "Logged",
62
- duration: "Time spent",
63
- durationPlaceholder: "1h30",
64
- date: "Date worked",
65
- note: "Note",
66
- notePlaceholder: "What was done",
67
- log: "Log",
68
- saving: "Saving",
69
- invalid: "Try 45m, 1h30 or 1.5h",
70
- empty: "Log your first hour",
71
- remaining: (left, estimate) => `${left} left of ${estimate}`,
72
- over: (over, estimate) => `${over} over ${estimate}`,
73
- deleteEntry: (duration) => `Delete ${duration} entry`,
51
+ title: "Time",
52
+ timer: "Timer",
53
+ start: "Start timer",
54
+ stop: "Stop timer",
55
+ running: "Running",
56
+ since: (time) => `Since ${time}`,
57
+ started: "Timer started",
58
+ runningFor: (duration) => `Timer running, ${duration}`,
59
+ stopped: (duration) => `Timer stopped, ${duration} to log`,
60
+ elapsed: "Elapsed time",
61
+ logged: "Logged",
62
+ duration: "Time spent",
63
+ durationPlaceholder: "1h30",
64
+ date: "Date worked",
65
+ note: "Note",
66
+ notePlaceholder: "What was done",
67
+ log: "Log",
68
+ saving: "Saving",
69
+ invalid: "Try 45m, 1h30 or 1.5h",
70
+ empty: "Log your first hour",
71
+ remaining: (left, estimate) => `${left} left of ${estimate}`,
72
+ over: (over, estimate) => `${over} over ${estimate}`,
73
+ deleteEntry: (duration) => `Delete ${duration} entry`,
74
74
  };
@@ -22,9 +22,8 @@ export type KanbanDndConfig<T> = {
22
22
  };
23
23
 
24
24
  /**
25
- * The drag state and handlers for a board. Native HTML5 drag is deliberate:
26
- * it reuses the browser's own drop targets, so a card can leave the board and
27
- * land on any other element a caller wired up.
25
+ * A board's drag state and handlers, on native HTML5 drag so a card can leave the board for
26
+ * any drop target a caller wired up.
28
27
  */
29
28
  export function createKanbanDnd<T>(config: KanbanDndConfig<T>) {
30
29
  let draggedId = $state<string | null>(null);
@@ -0,0 +1,31 @@
1
+ # @heyramzi/ui/thread
2
+
3
+ One comment thread for any subject: a task, a project, a doc or a diagram. The thread owns the layout and the draft state. It never fetches. You pass comments and callbacks, and it hands back what the person did.
4
+
5
+ ```svelte
6
+ <script lang="ts">
7
+ import { Thread, type ThreadComment, type ThreadPost } from "@heyramzi/ui/thread";
8
+
9
+ let comments = $state<ThreadComment[]>([]);
10
+
11
+ async function onPost({ body, parentId }: ThreadPost) {
12
+ const saved = await save(body, parentId);
13
+ if (!saved) return false; // keeps the draft
14
+ comments = [...comments, saved];
15
+ }
16
+ </script>
17
+
18
+ <Thread {comments} {onPost} {onEdit} {onDelete} {onResolve} canEdit={(c) => c.author.name === me} />
19
+ ```
20
+
21
+ - **Replies.** `parentId` points at the comment answered. A reply to a reply sits under the same root, a reply whose parent is gone becomes a root, and `buildThread(comments)` gives you the same grouping without the component.
22
+ - **Authors.** `author.kind` is `user` or `agent`. An agent shows an "AI" chip. The `avatar` snippet replaces the face for both kinds, so an app passes its cast portrait there.
23
+ - **Rich bodies.** A comment with `content` (Tiptap JSON) draws through `@heyramzi/ui/editor`'s renderer. `body` stays the plain-text fallback. A rich comment has no edit button, because the thread edits text only.
24
+ - **Resolve.** Pass `onResolve` and each root gets a check. Resolved roots fold away behind a "Show N resolved" button.
25
+ - **Anchor.** `anchor` carries `{ nodeId, dx, dy, x, y }` for a canvas pin. A pinned comment shows a chip, and `onAnchor` fires on a click so the canvas can move to the node. `anchorChip` replaces the chip.
26
+ - **Attachments.** The `attachments` snippet draws under a comment's body.
27
+ - **Labels.** Every string comes from `labels`, a partial of `ThreadLabels`. `showResolved` holds a `{count}` slot. `when(comment)` formats the time.
28
+
29
+ ```ts
30
+ const fr = { title: "Activité", send: "Commenter", showResolved: "Afficher {count} résolu" };
31
+ ```
@@ -0,0 +1,12 @@
1
+ export { default as Thread } from "./thread.svelte";
2
+ export { buildThread, fillCount, resolveThreadLabels, resolvedCount } from "./logic.js";
3
+ export { DEFAULT_THREAD_LABELS } from "./types.js";
4
+ export type {
5
+ ThreadAnchor,
6
+ ThreadAuthor,
7
+ ThreadAuthorKind,
8
+ ThreadComment,
9
+ ThreadLabels,
10
+ ThreadNode,
11
+ ThreadPost,
12
+ } from "./types.js";
@@ -0,0 +1,61 @@
1
+ import type { ThreadComment, ThreadLabels, ThreadNode } from "./types.js";
2
+ import { DEFAULT_THREAD_LABELS } from "./types.js";
3
+
4
+ function time(comment: ThreadComment): number {
5
+ const ms = Date.parse(comment.createdAt);
6
+ return Number.isNaN(ms) ? Number.POSITIVE_INFINITY : ms;
7
+ }
8
+
9
+ /** Oldest first, a malformed timestamp last, ties in the order they came. */
10
+ function byTime(comments: ThreadComment[]): ThreadComment[] {
11
+ return comments
12
+ .map((comment, index) => ({ comment, index }))
13
+ .toSorted((a, b) => time(a.comment) - time(b.comment) || a.index - b.index)
14
+ .map((row) => row.comment);
15
+ }
16
+
17
+ /**
18
+ * Roots in time order with their replies: a reply to a reply joins the root, an orphan becomes a
19
+ * root, and a parent loop can't hang the walk.
20
+ */
21
+ export function buildThread(comments: ThreadComment[]): ThreadNode[] {
22
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
23
+
24
+ function rootOf(comment: ThreadComment): ThreadComment {
25
+ const seen = new Set<string>([comment.id]);
26
+ let current = comment;
27
+ for (;;) {
28
+ const parent = current.parentId ? byId.get(current.parentId) : undefined;
29
+ if (!parent) return current;
30
+ if (seen.has(parent.id)) return comment;
31
+ seen.add(parent.id);
32
+ current = parent;
33
+ }
34
+ }
35
+
36
+ const replies = new Map<string, ThreadComment[]>();
37
+ const roots: ThreadComment[] = [];
38
+ for (const comment of comments) {
39
+ const root = rootOf(comment);
40
+ if (root.id === comment.id) roots.push(comment);
41
+ else replies.set(root.id, [...(replies.get(root.id) ?? []), comment]);
42
+ }
43
+ return byTime(roots).map((comment) => ({
44
+ comment,
45
+ replies: byTime(replies.get(comment.id) ?? []),
46
+ }));
47
+ }
48
+
49
+ /** How many conversations are resolved. */
50
+ export function resolvedCount(nodes: ThreadNode[]): number {
51
+ return nodes.filter((node) => node.comment.resolved).length;
52
+ }
53
+
54
+ export function resolveThreadLabels(overrides?: Partial<ThreadLabels>): ThreadLabels {
55
+ return { ...DEFAULT_THREAD_LABELS, ...overrides };
56
+ }
57
+
58
+ /** Fills `{count}` in a label, so a French host writes its own sentence. */
59
+ export function fillCount(label: string, count: number): string {
60
+ return label.replaceAll("{count}", String(count));
61
+ }