@heyramzi/ui 2.1.0 → 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 (312) hide show
  1. package/package.json +7 -8
  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/card/card-frame.svelte +43 -39
  31. package/src/canvas/card/resize-handles.svelte +89 -80
  32. package/src/canvas/chrome/chrome-zoom.svelte +101 -100
  33. package/src/canvas/chrome/lock-badge.svelte +8 -8
  34. package/src/canvas/chrome/side-handles.svelte +24 -19
  35. package/src/canvas/chrome/size-chip.svelte +30 -30
  36. package/src/canvas/chrome/snap-guides.svelte +20 -20
  37. package/src/canvas/components/icons/ToolIcon.svelte +8 -8
  38. package/src/canvas/components/shared/svg/PreviewSvg.svelte +19 -19
  39. package/src/canvas/components/shared/svg/ShapeOutline.svelte +71 -48
  40. package/src/canvas/components/shared/svg/StrokeNode.svelte +21 -27
  41. package/src/canvas/components/shared/svg/TextRule.svelte +25 -17
  42. package/src/canvas/edges/bend-handle.svelte +46 -46
  43. package/src/canvas/edges/edge-gradient.svelte +10 -10
  44. package/src/canvas/edges/gradient-edge.svelte +45 -29
  45. package/src/canvas/editor/lock.test.ts +6 -1
  46. package/src/canvas/editor/style.ts +7 -1
  47. package/src/canvas/headless/svg-figure.test.ts +5 -1
  48. package/src/canvas/lod/lod-switch.svelte +111 -106
  49. package/src/canvas/pen/PenField.svelte +87 -81
  50. package/src/canvas/pen/PenOverlay.svelte +127 -127
  51. package/src/canvas/pen/ShapeNode.svelte +31 -32
  52. package/src/canvas/pen/TextNode.svelte +54 -53
  53. package/src/canvas/pen/host-field.test.svelte +7 -7
  54. package/src/canvas/pen/shapes.ts +7 -1
  55. package/src/canvas/screen/GlyphIcon.svelte +22 -17
  56. package/src/canvas/screen/PartView.svelte +138 -72
  57. package/src/canvas/screen/ScreenBody.svelte +81 -60
  58. package/src/canvas/screen/ScreenCard.svelte +20 -20
  59. package/src/canvas/ui/CanvasToolbar.svelte +173 -85
  60. package/src/canvas/ui/FrameLayer.svelte +59 -59
  61. package/src/canvas/ui/PenLayer.svelte +39 -30
  62. package/src/canvas/ui/StylePanel.svelte +119 -102
  63. package/src/canvas/ui/canvas-grid.svelte +137 -129
  64. package/src/charts/animated-value.svelte +1 -1
  65. package/src/charts/area-chart.css +2 -5
  66. package/src/charts/area-chart.svelte +1 -1
  67. package/src/charts/bar-chart.css +2 -3
  68. package/src/charts/bar-chart.svelte +1 -1
  69. package/src/charts/charts.css +5 -17
  70. package/src/charts/crosshair.svelte +1 -1
  71. package/src/charts/dashboard/dashboard-empty.svelte +25 -25
  72. package/src/charts/dashboard/dashboard-section.svelte +24 -24
  73. package/src/charts/dashboard/data-panel.svelte +50 -50
  74. package/src/charts/dashboard/data-sort-head.svelte +56 -56
  75. package/src/charts/dashboard/data-table.svelte +138 -138
  76. package/src/charts/dashboard/panel-grid.svelte +30 -30
  77. package/src/charts/dashboard/period-filter.svelte +25 -25
  78. package/src/charts/dashboard/rating-chip.svelte +19 -19
  79. package/src/charts/dashboard/stat-band.svelte +32 -32
  80. package/src/charts/dashboard/stat-tile.svelte +106 -99
  81. package/src/charts/donut-chart.svelte +1 -1
  82. package/src/charts/edge-fade.svelte +1 -1
  83. package/src/charts/figure.svelte +1 -1
  84. package/src/charts/gauge-chart.css +4 -7
  85. package/src/charts/gauge-chart.svelte +1 -1
  86. package/src/charts/hover-focus.svelte +1 -1
  87. package/src/charts/legend.css +1 -3
  88. package/src/charts/legend.svelte +1 -1
  89. package/src/charts/line-chart.svelte +1 -1
  90. package/src/charts/number-chart.css +6 -9
  91. package/src/charts/number-chart.svelte +1 -1
  92. package/src/charts/reference-line.svelte +19 -19
  93. package/src/charts/reveal.svelte +1 -1
  94. package/src/charts/ring-chart.css +2 -4
  95. package/src/charts/ring-chart.svelte +1 -1
  96. package/src/charts/sparkline.svelte +5 -2
  97. package/src/charts/stat-card.svelte +1 -1
  98. package/src/charts/time-series-chart.css +2 -5
  99. package/src/charts/trend-pill.css +2 -4
  100. package/src/charts/trend-pill.svelte +1 -1
  101. package/src/components/copy/copy-button.svelte +93 -98
  102. package/src/components/copy/copy-button.test.ts +10 -2
  103. package/src/components/data-table/infinite-scroll.svelte +42 -42
  104. package/src/components/data-table/pagination.svelte +34 -22
  105. package/src/components/data-table/row-arrow.svelte +19 -16
  106. package/src/components/data-table/row-context-menu.svelte +52 -52
  107. package/src/components/data-table/sortable-head.svelte +25 -25
  108. package/src/components/data-table/table-skeleton.svelte +41 -36
  109. package/src/components/docs/doc-page.svelte +18 -9
  110. package/src/components/docs/on-this-page.svelte +16 -3
  111. package/src/components/docs/scroll-spy.svelte.ts +2 -3
  112. package/src/components/field-row/field-row.svelte +10 -8
  113. package/src/components/hold-to-confirm/hold-to-confirm.svelte +5 -1
  114. package/src/components/icon/icon.svelte +14 -14
  115. package/src/components/language-flag/language-flag.svelte +3 -1
  116. package/src/components/list-page/detail-empty.svelte +34 -34
  117. package/src/components/list-page/detail-header.svelte +55 -47
  118. package/src/components/list-page/detail-panel.svelte +51 -59
  119. package/src/components/list-page/detail-row.svelte +19 -19
  120. package/src/components/list-page/detail-stack.svelte +21 -21
  121. package/src/components/list-page/empty-state.svelte +30 -30
  122. package/src/components/list-page/filter-item.svelte +20 -20
  123. package/src/components/list-page/filter-popover.svelte +60 -55
  124. package/src/components/list-page/grouped-list.svelte +4 -2
  125. package/src/components/list-page/index.ts +5 -1
  126. package/src/components/list-page/list-page-shell.svelte +2 -5
  127. package/src/components/list-page/master-detail.svelte +87 -88
  128. package/src/components/list-page/notice-bar.svelte +38 -38
  129. package/src/components/list-page/prev-next.svelte +63 -63
  130. package/src/components/list-page/rail-body.svelte +18 -18
  131. package/src/components/list-page/rail-empty.svelte +23 -30
  132. package/src/components/list-page/rail-head.svelte +24 -24
  133. package/src/components/list-page/rail-row.svelte +77 -73
  134. package/src/components/list-page/section-host.ts +2 -3
  135. package/src/components/locale-switch/locale-switch.svelte +41 -41
  136. package/src/components/mark/mark.svelte +34 -38
  137. package/src/components/open-in/index.ts +1 -5
  138. package/src/components/open-in/open-in.svelte +40 -40
  139. package/src/components/picker/mark-picker.svelte +49 -49
  140. package/src/components/picker/picker.svelte +5 -3
  141. package/src/components/section-header/section-header.test.ts +7 -1
  142. package/src/components/section-header/section-label.svelte +20 -20
  143. package/src/components/segmented/animated-tabs.svelte +31 -2
  144. package/src/components/segmented/collapse.test.ts +2 -1
  145. package/src/components/segmented/index.ts +2 -0
  146. package/src/components/segmented/segment.svelte +52 -49
  147. package/src/components/segmented/segmented-filter.svelte +79 -80
  148. package/src/components/segmented/segmented-nav.svelte +34 -40
  149. package/src/components/segmented/segmented-overflow.svelte +53 -50
  150. package/src/components/segmented/segmented-track.svelte +62 -46
  151. package/src/components/segmented/segmented.ts +2 -3
  152. package/src/components/star-rating-input/star-rating-input.svelte +103 -103
  153. package/src/components/svg/svg.svelte +4 -4
  154. package/src/components/table-view/table-view.svelte +17 -2
  155. package/src/components/table-view/table-view.test.ts +7 -1
  156. package/src/components/table-view/types.ts +11 -11
  157. package/src/components/tags-input/index.ts +13 -0
  158. package/src/components/tags-input/tags-input.svelte +305 -0
  159. package/src/components/tags-input/tags.test.ts +64 -0
  160. package/src/components/tags-input/tags.ts +142 -0
  161. package/src/components/theme/index.ts +2 -4
  162. package/src/components/theme/theme-provider.svelte +1 -3
  163. package/src/components/toolbar/toolbar-search.svelte +25 -25
  164. package/src/components/toolbar/toolbar.svelte +11 -7
  165. package/src/components/toolbar/toolbar.test.ts +10 -1
  166. package/src/design/index.css +21 -78
  167. package/src/design/polish.css +26 -35
  168. package/src/editor/editor-toolbar.svelte +104 -99
  169. package/src/editor/editor.svelte +296 -266
  170. package/src/editor/image-toolbar.svelte +20 -22
  171. package/src/editor/index.ts +1 -4
  172. package/src/editor/inline-editor.svelte +280 -238
  173. package/src/editor/markdown.ts +15 -15
  174. package/src/editor/renderer.svelte +71 -71
  175. package/src/editor/slash-menu.svelte +24 -27
  176. package/src/effects/animated-beam/diagrams/BeamSvg.svelte +1 -1
  177. package/src/effects/image-comparison/ImageComparison.svelte +11 -1
  178. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +59 -46
  179. package/src/effects/magic-card/MagicCard.css +14 -7
  180. package/src/effects/marquee/Marquee.svelte +3 -5
  181. package/src/effects/reveal/observe.ts +0 -3
  182. package/src/effects/spotlight/Spotlight.svelte +1 -1
  183. package/src/effects/theme-toggle/ThemeToggle.css +25 -22
  184. package/src/effects/theme-toggle/ThemeToggle.svelte +6 -7
  185. package/src/gallery.test.ts +14 -4
  186. package/src/inbox/README.md +1 -3
  187. package/src/inbox/inbox.test.ts +181 -138
  188. package/src/inbox/index.ts +20 -20
  189. package/src/inbox/logic.ts +114 -111
  190. package/src/inbox/thread-inbox.svelte +227 -151
  191. package/src/inbox/types.ts +130 -108
  192. package/src/lib/theme-transition.ts +2 -4
  193. package/src/lib/tone.ts +2 -4
  194. package/src/lib/utils.ts +2 -2
  195. package/src/modules.test.ts +79 -69
  196. package/src/onboarding/define-tours.test.ts +55 -40
  197. package/src/onboarding/define-tours.ts +23 -23
  198. package/src/onboarding/demo.svelte +24 -5
  199. package/src/onboarding/index.ts +1 -8
  200. package/src/onboarding/onboarding-provider.svelte +19 -7
  201. package/src/onboarding/onboarding.svelte.ts +16 -16
  202. package/src/onboarding/pick.test.ts +61 -52
  203. package/src/onboarding/pick.ts +16 -13
  204. package/src/onboarding/stores.test.ts +80 -80
  205. package/src/onboarding/stores.ts +41 -39
  206. package/src/onboarding/tour-card.svelte +30 -5
  207. package/src/onboarding/tour-runner.svelte +12 -3
  208. package/src/onboarding/types.ts +20 -12
  209. package/src/onboarding/wait.ts +22 -22
  210. package/src/panel/side-panel.svelte +94 -93
  211. package/src/record/demo-data.ts +291 -225
  212. package/src/record/field-section.svelte +38 -34
  213. package/src/record/fields.ts +148 -96
  214. package/src/record/format.ts +63 -56
  215. package/src/record/index.ts +49 -35
  216. package/src/record/info-list.svelte +145 -114
  217. package/src/record/kinds.ts +31 -31
  218. package/src/record/logic.ts +216 -214
  219. package/src/record/next-up.svelte +45 -34
  220. package/src/record/project-list.svelte +56 -50
  221. package/src/record/record-demo.svelte +130 -110
  222. package/src/record/record-header.svelte +37 -28
  223. package/src/record/record-shell.svelte +54 -54
  224. package/src/record/record-stats.svelte +25 -25
  225. package/src/record/record-timeline.svelte +134 -114
  226. package/src/record/record.test.ts +430 -309
  227. package/src/record/steps-rail.svelte +225 -216
  228. package/src/record/types.ts +234 -215
  229. package/src/record/where-we-are.svelte +62 -58
  230. package/src/shadcn/button/button.svelte +1 -1
  231. package/src/shadcn/card/card-title.svelte +1 -5
  232. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +2 -13
  233. package/src/shadcn/sheet/sheet-content.svelte +2 -4
  234. package/src/shadcn/table/table-cell.svelte +2 -3
  235. package/src/shadcn/utilities.css +3 -8
  236. package/src/slides/Controls.svelte +150 -124
  237. package/src/slides/Deck.svelte +243 -238
  238. package/src/slides/Fragment.svelte +64 -57
  239. package/src/slides/Overview.svelte +106 -101
  240. package/src/slides/Presenter.svelte +300 -266
  241. package/src/slides/Slide.svelte +185 -170
  242. package/src/slides/SlideList.svelte +16 -12
  243. package/src/slides/ambient.d.ts +1 -1
  244. package/src/slides/bridges.ts +42 -23
  245. package/src/slides/deck-controller.svelte.ts +47 -35
  246. package/src/slides/demo-slides.svelte +330 -306
  247. package/src/slides/demo.svelte +164 -160
  248. package/src/slides/export/capture.ts +20 -16
  249. package/src/slides/export/ffmpeg.ts +43 -38
  250. package/src/slides/export/index.ts +5 -5
  251. package/src/slides/export/launch.ts +3 -1
  252. package/src/slides/export/pdf.ts +30 -27
  253. package/src/slides/export/types.ts +10 -8
  254. package/src/slides/index.ts +11 -11
  255. package/src/slides/labels.ts +22 -19
  256. package/src/slides/layout.ts +9 -2
  257. package/src/slides/model.ts +4 -4
  258. package/src/slides/navigation.ts +43 -31
  259. package/src/slides/theme.css +5 -7
  260. package/src/slides/types.ts +9 -10
  261. package/src/status/define-statuses.ts +17 -24
  262. package/src/status/index.ts +5 -5
  263. package/src/status/status-badge.svelte +3 -1
  264. package/src/status/status-dot.svelte +1 -3
  265. package/src/status/status-icon.svelte +1 -1
  266. package/src/status/status-types.ts +21 -21
  267. package/src/tasks/board-order.ts +2 -4
  268. package/src/tasks/demo.svelte +189 -31
  269. package/src/tasks/detail/detail-demo.svelte +73 -13
  270. package/src/tasks/detail/detail-types.ts +45 -45
  271. package/src/tasks/detail/task-detail.svelte +40 -10
  272. package/src/tasks/detail/task-property.svelte +8 -2
  273. package/src/tasks/detail/task-thread.svelte +53 -12
  274. package/src/tasks/gantt/gantt-bar.svelte +17 -6
  275. package/src/tasks/gantt/gantt-grid.svelte +40 -9
  276. package/src/tasks/gantt/gantt-group-row.svelte +8 -2
  277. package/src/tasks/gantt/gantt-header.svelte +9 -3
  278. package/src/tasks/gantt/gantt-task-row.svelte +26 -6
  279. package/src/tasks/gantt/gantt.css +2 -1
  280. package/src/tasks/gantt/gantt.test.ts +3 -1
  281. package/src/tasks/gantt/logic.test.ts +110 -20
  282. package/src/tasks/gantt/logic.ts +42 -6
  283. package/src/tasks/inbox/inbox-card.svelte +13 -5
  284. package/src/tasks/inbox/inbox-draft.svelte +1 -1
  285. package/src/tasks/inbox/inbox-logic.ts +6 -40
  286. package/src/tasks/inbox/inbox-statuses.ts +23 -23
  287. package/src/tasks/inbox/inbox-trace.svelte +9 -3
  288. package/src/tasks/inbox/inbox-types.ts +54 -58
  289. package/src/tasks/inbox/index.ts +22 -21
  290. package/src/tasks/index.ts +49 -52
  291. package/src/tasks/kanban-board.svelte +16 -12
  292. package/src/tasks/kanban-types.ts +2 -3
  293. package/src/tasks/presets/sales.ts +6 -6
  294. package/src/tasks/task-gantt.svelte +28 -8
  295. package/src/tasks/task-list.svelte +41 -17
  296. package/src/tasks/time/duration.ts +40 -40
  297. package/src/tasks/time/time-chip.svelte +9 -2
  298. package/src/tasks/time/time-dot.svelte +1 -1
  299. package/src/tasks/time/time-ring.svelte +11 -3
  300. package/src/tasks/time/time-tracker.svelte +57 -15
  301. package/src/tasks/time/time-types.ts +57 -57
  302. package/src/tasks/use-kanban-dnd.svelte.ts +2 -3
  303. package/src/thread/index.ts +7 -7
  304. package/src/thread/logic.ts +36 -33
  305. package/src/thread/thread.svelte +82 -16
  306. package/src/thread/thread.test.ts +58 -47
  307. package/src/thread/types.ts +68 -68
  308. package/src/tokens/craft.css +17 -53
  309. package/src/tokens/index.css +3 -13
  310. package/src/tokens/palette.css +13 -11
  311. package/src/tokens/primitives.css +10 -23
  312. package/src/tasks/inbox/inbox.svelte +0 -203
@@ -20,9 +20,24 @@
20
20
  route: "/demo/board",
21
21
  kind: "steps",
22
22
  steps: [
23
- { target: '[data-tour="demo-new"]', title: "Start here", body: "A new project takes a name and a client. That's all.", placement: "bottom-end" },
24
- { target: '[data-tour="demo-filter"]', title: "Narrow it down", body: "Late shows what slipped. Mine shows what's yours.", placement: "bottom-start" },
25
- { target: '[data-tour="demo-list"]', title: "Open a row", body: "Click a project to see its tasks, files and notes.", placement: "top" },
23
+ {
24
+ target: '[data-tour="demo-new"]',
25
+ title: "Start here",
26
+ body: "A new project takes a name and a client. That's all.",
27
+ placement: "bottom-end",
28
+ },
29
+ {
30
+ target: '[data-tour="demo-filter"]',
31
+ title: "Narrow it down",
32
+ body: "Late shows what slipped. Mine shows what's yours.",
33
+ placement: "bottom-start",
34
+ },
35
+ {
36
+ target: '[data-tour="demo-list"]',
37
+ title: "Open a row",
38
+ body: "Click a project to see its tasks, files and notes.",
39
+ placement: "top",
40
+ },
26
41
  ],
27
42
  },
28
43
  ]);
@@ -55,8 +70,12 @@
55
70
 
56
71
  <div class="flex flex-col gap-4">
57
72
  <div class="flex flex-wrap gap-2">
58
- <Button variant="outline" size="sm" onclick={() => start("/demo/board")}>Show the page tour</Button>
59
- <Button variant="outline" size="sm" onclick={() => start("/demo/intro")}>Show the intro card</Button>
73
+ <Button variant="outline" size="sm" onclick={() => start("/demo/board")}
74
+ >Show the page tour</Button
75
+ >
76
+ <Button variant="outline" size="sm" onclick={() => start("/demo/intro")}
77
+ >Show the intro card</Button
78
+ >
60
79
  </div>
61
80
 
62
81
  <div class="bg-card ring-foreground/10 flex flex-col gap-4 rounded-xl p-4 ring-1">
@@ -2,11 +2,4 @@ export { default as OnboardingProvider } from "./onboarding-provider.svelte";
2
2
  export { onboarding } from "./onboarding.svelte.js";
3
3
  export { defineTours } from "./define-tours.js";
4
4
  export { localStorageStore, memoryStore } from "./stores.js";
5
- export type {
6
- Outcome,
7
- Placement,
8
- SeenStore,
9
- Step,
10
- Strings,
11
- Tour,
12
- } from "./types.js";
5
+ export type { Outcome, Placement, SeenStore, Step, Strings, Tour } from "./types.js";
@@ -2,7 +2,13 @@
2
2
  import { tryCatchAsync } from "../lib/result.js";
3
3
  import { untrack } from "svelte";
4
4
  import { onboarding } from "./onboarding.svelte.js";
5
- import { keepFoundSteps, pickForReplay, pickTour, toursForRoute, type SeenMap } from "./pick.js";
5
+ import {
6
+ keepFoundSteps,
7
+ pickForReplay,
8
+ pickTour,
9
+ toursForRoute,
10
+ type SeenMap,
11
+ } from "./pick.js";
6
12
  import TourRunner from "./tour-runner.svelte";
7
13
  import type { Outcome, SeenStore, Step, Strings, Tour } from "./types.js";
8
14
  import { waitForElement } from "./wait.js";
@@ -71,17 +77,23 @@
71
77
  controller = mine;
72
78
  void loadSeen().then((map) => {
73
79
  if (!map || mine.signal.aborted) return;
74
- const tour = pickTour(untrack(() => tours), id, map);
80
+ const tour = pickTour(
81
+ untrack(() => tours),
82
+ id,
83
+ map,
84
+ );
75
85
  if (tour) void launch(tour, false, mine.signal);
76
86
  });
77
87
  return () => mine.abort();
78
88
  });
79
89
 
80
- $effect(() => onboarding.bind(async (tourId) => {
81
- const tour = pickForReplay(tours, routeId, tourId);
82
- if (!tour || !controller || controller.signal.aborted) return false;
83
- return launch(tour, true, controller.signal);
84
- }));
90
+ $effect(() =>
91
+ onboarding.bind(async (tourId) => {
92
+ const tour = pickForReplay(tours, routeId, tourId);
93
+ if (!tour || !controller || controller.signal.aborted) return false;
94
+ return launch(tour, true, controller.signal);
95
+ }),
96
+ );
85
97
 
86
98
  $effect(() => {
87
99
  onboarding.running = active !== null;
@@ -1,24 +1,24 @@
1
1
  type Replay = (tourId?: string) => Promise<boolean>;
2
2
 
3
3
  class Onboarding {
4
- /** True when the current page has a tour, so a menu item can hide itself. */
5
- hasTour = $state(false);
6
- /** True while a tour is on screen. */
7
- running = $state(false);
8
- #replay: Replay | null = null;
4
+ /** True when the current page has a tour, so a menu item can hide itself. */
5
+ hasTour = $state(false);
6
+ /** True while a tour is on screen. */
7
+ running = $state(false);
8
+ #replay: Replay | null = null;
9
9
 
10
- /** The provider registers itself here. Returns the undo for cleanup. */
11
- bind(replay: Replay): () => void {
12
- this.#replay = replay;
13
- return () => {
14
- if (this.#replay === replay) this.#replay = null;
15
- };
16
- }
10
+ /** The provider registers itself here. Returns the undo for cleanup. */
11
+ bind(replay: Replay): () => void {
12
+ this.#replay = replay;
13
+ return () => {
14
+ if (this.#replay === replay) this.#replay = null;
15
+ };
16
+ }
17
17
 
18
- /** Restarts this page's tour, or the one named. Writes no seen-state. False when nothing started. */
19
- replay(tourId?: string): Promise<boolean> {
20
- return this.#replay ? this.#replay(tourId) : Promise.resolve(false);
21
- }
18
+ /** Restarts this page's tour, or the one named. Writes no seen-state. False when nothing started. */
19
+ replay(tourId?: string): Promise<boolean> {
20
+ return this.#replay ? this.#replay(tourId) : Promise.resolve(false);
21
+ }
22
22
  }
23
23
 
24
24
  export const onboarding = new Onboarding();
@@ -1,79 +1,88 @@
1
1
  import { describe, expect, it } from "vite-plus/test";
2
- import { isSeen, keepFoundSteps, pickForReplay, pickTour, toursForRoute } from "./pick.js";
2
+ import {
3
+ isSeen,
4
+ keepFoundSteps,
5
+ pickForReplay,
6
+ pickTour,
7
+ toursForRoute,
8
+ } from "./pick.js";
3
9
  import type { Tour } from "./types.js";
4
10
 
5
11
  const step = { target: "#a", title: "A", body: "a" };
6
12
  const tours: Tour[] = [
7
- { id: "home", route: "/(app)", kind: "intro", title: "Hi", body: "Welcome." },
8
- { id: "board", route: "/(app)/projects", kind: "steps", steps: [step] },
9
- { id: "board-extra", route: "/(app)/projects", kind: "steps", steps: [step], version: 2 },
10
- { id: "record", route: "/(app)/projects/[id]", kind: "steps", steps: [step] },
13
+ { id: "home", route: "/(app)", kind: "intro", title: "Hi", body: "Welcome." },
14
+ { id: "board", route: "/(app)/projects", kind: "steps", steps: [step] },
15
+ { id: "board-extra", route: "/(app)/projects", kind: "steps", steps: [step], version: 2 },
16
+ { id: "record", route: "/(app)/projects/[id]", kind: "steps", steps: [step] },
11
17
  ];
12
18
 
13
19
  describe("isSeen", () => {
14
- it("counts a tour as seen once the stored version reaches its own", () => {
15
- expect(isSeen(tours[1], {})).toBe(false);
16
- expect(isSeen(tours[1], { board: 1 })).toBe(true);
17
- expect(isSeen(tours[2], { "board-extra": 1 })).toBe(false);
18
- expect(isSeen(tours[2], { "board-extra": 2 })).toBe(true);
19
- });
20
+ it("counts a tour as seen once the stored version reaches its own", () => {
21
+ expect(isSeen(tours[1], {})).toBe(false);
22
+ expect(isSeen(tours[1], { board: 1 })).toBe(true);
23
+ expect(isSeen(tours[2], { "board-extra": 1 })).toBe(false);
24
+ expect(isSeen(tours[2], { "board-extra": 2 })).toBe(true);
25
+ });
20
26
 
21
- it("shows a tour again after its version is bumped", () => {
22
- const v1: Tour = { id: "t", route: "/", kind: "steps", steps: [step] };
23
- const v2: Tour = { ...v1, version: 2 };
24
- expect(isSeen(v1, { t: 1 })).toBe(true);
25
- expect(isSeen(v2, { t: 1 })).toBe(false);
26
- });
27
+ it("shows a tour again after its version is bumped", () => {
28
+ const v1: Tour = { id: "t", route: "/", kind: "steps", steps: [step] };
29
+ const v2: Tour = { ...v1, version: 2 };
30
+ expect(isSeen(v1, { t: 1 })).toBe(true);
31
+ expect(isSeen(v2, { t: 1 })).toBe(false);
32
+ });
27
33
  });
28
34
 
29
35
  describe("pickTour", () => {
30
- it("matches the route id exactly, so a params route never borrows its parent's tour", () => {
31
- expect(pickTour(tours, "/(app)", {})?.id).toBe("home");
32
- expect(pickTour(tours, "/(app)/projects/[id]", {})?.id).toBe("record");
33
- expect(pickTour(tours, "/(app)/settings", {})).toBeUndefined();
34
- });
36
+ it("matches the route id exactly, so a params route never borrows its parent's tour", () => {
37
+ expect(pickTour(tours, "/(app)", {})?.id).toBe("home");
38
+ expect(pickTour(tours, "/(app)/projects/[id]", {})?.id).toBe("record");
39
+ expect(pickTour(tours, "/(app)/settings", {})).toBeUndefined();
40
+ });
35
41
 
36
- it("takes the first unseen tour on a route, one at a time", () => {
37
- expect(pickTour(tours, "/(app)/projects", {})?.id).toBe("board");
38
- expect(pickTour(tours, "/(app)/projects", { board: 1 })?.id).toBe("board-extra");
39
- expect(pickTour(tours, "/(app)/projects", { board: 1, "board-extra": 2 })).toBeUndefined();
40
- });
42
+ it("takes the first unseen tour on a route, one at a time", () => {
43
+ expect(pickTour(tours, "/(app)/projects", {})?.id).toBe("board");
44
+ expect(pickTour(tours, "/(app)/projects", { board: 1 })?.id).toBe("board-extra");
45
+ expect(pickTour(tours, "/(app)/projects", { board: 1, "board-extra": 2 })).toBeUndefined();
46
+ });
41
47
 
42
- it("picks nothing without a route", () => {
43
- expect(pickTour(tours, null, {})).toBeUndefined();
44
- expect(pickTour(tours, undefined, {})).toBeUndefined();
45
- });
48
+ it("picks nothing without a route", () => {
49
+ expect(pickTour(tours, null, {})).toBeUndefined();
50
+ expect(pickTour(tours, undefined, {})).toBeUndefined();
51
+ });
46
52
  });
47
53
 
48
54
  describe("toursForRoute", () => {
49
- it("lists every tour on the route in definition order", () => {
50
- expect(toursForRoute(tours, "/(app)/projects").map((t) => t.id)).toEqual(["board", "board-extra"]);
51
- });
55
+ it("lists every tour on the route in definition order", () => {
56
+ expect(toursForRoute(tours, "/(app)/projects").map((t) => t.id)).toEqual([
57
+ "board",
58
+ "board-extra",
59
+ ]);
60
+ });
52
61
  });
53
62
 
54
63
  describe("pickForReplay", () => {
55
- it("restarts the page's first tour even when it was seen", () => {
56
- expect(pickForReplay(tours, "/(app)/projects")?.id).toBe("board");
57
- });
64
+ it("restarts the page's first tour even when it was seen", () => {
65
+ expect(pickForReplay(tours, "/(app)/projects")?.id).toBe("board");
66
+ });
58
67
 
59
- it("finds a tour by id on any route", () => {
60
- expect(pickForReplay(tours, "/(app)", "record")?.id).toBe("record");
61
- expect(pickForReplay(tours, "/(app)", "missing")).toBeUndefined();
62
- });
68
+ it("finds a tour by id on any route", () => {
69
+ expect(pickForReplay(tours, "/(app)", "record")?.id).toBe("record");
70
+ expect(pickForReplay(tours, "/(app)", "missing")).toBeUndefined();
71
+ });
63
72
  });
64
73
 
65
74
  describe("keepFoundSteps", () => {
66
- const steps = [
67
- { ...step, target: "#a" },
68
- { ...step, target: "#b" },
69
- { ...step, target: "#c" },
70
- ];
75
+ const steps = [
76
+ { ...step, target: "#a" },
77
+ { ...step, target: "#b" },
78
+ { ...step, target: "#c" },
79
+ ];
71
80
 
72
- it("drops the steps whose target isn't on the page", () => {
73
- expect(keepFoundSteps(steps, (s) => s !== "#b").map((s) => s.target)).toEqual(["#a", "#c"]);
74
- });
81
+ it("drops the steps whose target isn't on the page", () => {
82
+ expect(keepFoundSteps(steps, (s) => s !== "#b").map((s) => s.target)).toEqual(["#a", "#c"]);
83
+ });
75
84
 
76
- it("returns nothing when no target is found, so no empty spotlight starts", () => {
77
- expect(keepFoundSteps(steps, () => false)).toEqual([]);
78
- });
85
+ it("returns nothing when no target is found, so no empty spotlight starts", () => {
86
+ expect(keepFoundSteps(steps, () => false)).toEqual([]);
87
+ });
79
88
  });
@@ -4,33 +4,36 @@ export type SeenMap = Record<string, number>;
4
4
 
5
5
  /** The seen version has to reach the tour's own, so bumping `version` shows it again. */
6
6
  export function isSeen(tour: Tour, seen: SeenMap): boolean {
7
- return (seen[tour.id] ?? 0) >= (tour.version ?? 1);
7
+ return (seen[tour.id] ?? 0) >= (tour.version ?? 1);
8
8
  }
9
9
 
10
10
  export function toursForRoute(tours: readonly Tour[], routeId: string | null | undefined): Tour[] {
11
- return routeId ? tours.filter((tour) => tour.route === routeId) : [];
11
+ return routeId ? tours.filter((tour) => tour.route === routeId) : [];
12
12
  }
13
13
 
14
14
  /** The first unseen tour on a route, in the order they were defined. One per page view. */
15
15
  export function pickTour(
16
- tours: readonly Tour[],
17
- routeId: string | null | undefined,
18
- seen: SeenMap,
16
+ tours: readonly Tour[],
17
+ routeId: string | null | undefined,
18
+ seen: SeenMap,
19
19
  ): Tour | undefined {
20
- return toursForRoute(tours, routeId).find((tour) => !isSeen(tour, seen));
20
+ return toursForRoute(tours, routeId).find((tour) => !isSeen(tour, seen));
21
21
  }
22
22
 
23
23
  /** What replay starts: a named tour, or the page's first one whether or not it was seen. */
24
24
  export function pickForReplay(
25
- tours: readonly Tour[],
26
- routeId: string | null | undefined,
27
- tourId?: string,
25
+ tours: readonly Tour[],
26
+ routeId: string | null | undefined,
27
+ tourId?: string,
28
28
  ): Tour | undefined {
29
- if (tourId) return tours.find((tour) => tour.id === tourId);
30
- return toursForRoute(tours, routeId)[0];
29
+ if (tourId) return tours.find((tour) => tour.id === tourId);
30
+ return toursForRoute(tours, routeId)[0];
31
31
  }
32
32
 
33
33
  /** Keeps the steps whose target is on the page. A tour with none left starts nothing. */
34
- export function keepFoundSteps(steps: readonly Step[], exists: (selector: string) => boolean): Step[] {
35
- return steps.filter((step) => exists(step.target));
34
+ export function keepFoundSteps(
35
+ steps: readonly Step[],
36
+ exists: (selector: string) => boolean,
37
+ ): Step[] {
38
+ return steps.filter((step) => exists(step.target));
36
39
  }
@@ -3,101 +3,101 @@ import { localStorageStore, memoryStore } from "./stores.js";
3
3
  import type { SeenStore } from "./types.js";
4
4
 
5
5
  function fakeStorage(): Storage {
6
- const data = new Map<string, string>();
7
- return {
8
- get length() {
9
- return data.size;
10
- },
11
- clear: () => data.clear(),
12
- getItem: (key) => data.get(key) ?? null,
13
- key: (i) => [...data.keys()][i] ?? null,
14
- removeItem: (key) => void data.delete(key),
15
- setItem: (key, value) => void data.set(key, String(value)),
16
- };
6
+ const data = new Map<string, string>();
7
+ return {
8
+ get length() {
9
+ return data.size;
10
+ },
11
+ clear: () => data.clear(),
12
+ getItem: (key) => data.get(key) ?? null,
13
+ key: (i) => [...data.keys()][i] ?? null,
14
+ removeItem: (key) => void data.delete(key),
15
+ setItem: (key, value) => void data.set(key, String(value)),
16
+ };
17
17
  }
18
18
 
19
19
  function behavesLikeAStore(make: () => SeenStore) {
20
- it("starts empty", async () => {
21
- expect(await make().load()).toEqual({});
22
- });
20
+ it("starts empty", async () => {
21
+ expect(await make().load()).toEqual({});
22
+ });
23
23
 
24
- it("remembers the version it saw", async () => {
25
- const store = make();
26
- await store.markSeen("home", 1, "completed");
27
- await store.markSeen("board", 2, "dismissed");
28
- expect(await store.load()).toEqual({ home: 1, board: 2 });
29
- });
24
+ it("remembers the version it saw", async () => {
25
+ const store = make();
26
+ await store.markSeen("home", 1, "completed");
27
+ await store.markSeen("board", 2, "dismissed");
28
+ expect(await store.load()).toEqual({ home: 1, board: 2 });
29
+ });
30
30
 
31
- it("never goes back to an older version", async () => {
32
- const store = make();
33
- await store.markSeen("home", 3, "completed");
34
- await store.markSeen("home", 2, "completed");
35
- expect((await store.load()).home).toBe(3);
36
- });
31
+ it("never goes back to an older version", async () => {
32
+ const store = make();
33
+ await store.markSeen("home", 3, "completed");
34
+ await store.markSeen("home", 2, "completed");
35
+ expect((await store.load()).home).toBe(3);
36
+ });
37
37
 
38
- it("resets one tour or all of them", async () => {
39
- const store = make();
40
- await store.markSeen("home", 1, "completed");
41
- await store.markSeen("board", 1, "completed");
42
- await store.reset("home");
43
- expect(await store.load()).toEqual({ board: 1 });
44
- await store.reset();
45
- expect(await store.load()).toEqual({});
46
- });
38
+ it("resets one tour or all of them", async () => {
39
+ const store = make();
40
+ await store.markSeen("home", 1, "completed");
41
+ await store.markSeen("board", 1, "completed");
42
+ await store.reset("home");
43
+ expect(await store.load()).toEqual({ board: 1 });
44
+ await store.reset();
45
+ expect(await store.load()).toEqual({});
46
+ });
47
47
  }
48
48
 
49
49
  describe("memoryStore", () => {
50
- behavesLikeAStore(() => memoryStore());
50
+ behavesLikeAStore(() => memoryStore());
51
51
 
52
- it("takes a starting state and keeps its own copy", async () => {
53
- const initial = { home: 1 };
54
- const store = memoryStore(initial);
55
- await store.markSeen("board", 1, "completed");
56
- expect(initial).toEqual({ home: 1 });
57
- expect(await store.load()).toEqual({ home: 1, board: 1 });
58
- });
52
+ it("takes a starting state and keeps its own copy", async () => {
53
+ const initial = { home: 1 };
54
+ const store = memoryStore(initial);
55
+ await store.markSeen("board", 1, "completed");
56
+ expect(initial).toEqual({ home: 1 });
57
+ expect(await store.load()).toEqual({ home: 1, board: 1 });
58
+ });
59
59
  });
60
60
 
61
61
  describe("localStorageStore", () => {
62
- beforeEach(() => {
63
- vi.stubGlobal("localStorage", fakeStorage());
64
- });
65
- afterEach(() => {
66
- vi.unstubAllGlobals();
67
- });
62
+ beforeEach(() => {
63
+ vi.stubGlobal("localStorage", fakeStorage());
64
+ });
65
+ afterEach(() => {
66
+ vi.unstubAllGlobals();
67
+ });
68
68
 
69
- behavesLikeAStore(() => localStorageStore());
69
+ behavesLikeAStore(() => localStorageStore());
70
70
 
71
- it("shares state between two stores on one key and keeps keys apart", async () => {
72
- await localStorageStore("a").markSeen("home", 1, "completed");
73
- expect(await localStorageStore("a").load()).toEqual({ home: 1 });
74
- expect(await localStorageStore("b").load()).toEqual({});
75
- });
71
+ it("shares state between two stores on one key and keeps keys apart", async () => {
72
+ await localStorageStore("a").markSeen("home", 1, "completed");
73
+ expect(await localStorageStore("a").load()).toEqual({ home: 1 });
74
+ expect(await localStorageStore("b").load()).toEqual({});
75
+ });
76
76
 
77
- it("reads corrupt or foreign data as empty", async () => {
78
- localStorage.setItem("heyramzi:onboarding", "{nope");
79
- expect(await localStorageStore().load()).toEqual({});
80
- localStorage.setItem("heyramzi:onboarding", JSON.stringify(["x"]));
81
- expect(await localStorageStore().load()).toEqual({});
82
- localStorage.setItem("heyramzi:onboarding", JSON.stringify({ home: 1, bad: "yes" }));
83
- expect(await localStorageStore().load()).toEqual({ home: 1 });
84
- });
77
+ it("reads corrupt or foreign data as empty", async () => {
78
+ localStorage.setItem("heyramzi:onboarding", "{nope");
79
+ expect(await localStorageStore().load()).toEqual({});
80
+ localStorage.setItem("heyramzi:onboarding", JSON.stringify(["x"]));
81
+ expect(await localStorageStore().load()).toEqual({});
82
+ localStorage.setItem("heyramzi:onboarding", JSON.stringify({ home: 1, bad: "yes" }));
83
+ expect(await localStorageStore().load()).toEqual({ home: 1 });
84
+ });
85
85
 
86
- it("doesn't throw when storage is blocked", async () => {
87
- vi.stubGlobal("localStorage", {
88
- getItem: () => {
89
- throw new Error("blocked");
90
- },
91
- setItem: () => {
92
- throw new Error("blocked");
93
- },
94
- removeItem: () => {
95
- throw new Error("blocked");
96
- },
97
- });
98
- const store = localStorageStore();
99
- expect(await store.load()).toEqual({});
100
- await expect(store.markSeen("home", 1, "completed")).resolves.toBeUndefined();
101
- await expect(store.reset()).resolves.toBeUndefined();
102
- });
86
+ it("doesn't throw when storage is blocked", async () => {
87
+ vi.stubGlobal("localStorage", {
88
+ getItem: () => {
89
+ throw new Error("blocked");
90
+ },
91
+ setItem: () => {
92
+ throw new Error("blocked");
93
+ },
94
+ removeItem: () => {
95
+ throw new Error("blocked");
96
+ },
97
+ });
98
+ const store = localStorageStore();
99
+ expect(await store.load()).toEqual({});
100
+ await expect(store.markSeen("home", 1, "completed")).resolves.toBeUndefined();
101
+ await expect(store.reset()).resolves.toBeUndefined();
102
+ });
103
103
  });
@@ -5,54 +5,56 @@ type Seen = Record<string, number>;
5
5
 
6
6
  /** Holds seen-state in memory. Tests and previews. */
7
7
  export function memoryStore(initial: Seen = {}): SeenStore {
8
- let seen: Seen = { ...initial };
9
- return {
10
- async load() {
11
- return { ...seen };
12
- },
13
- async markSeen(tourId, version, _outcome: Outcome) {
14
- seen[tourId] = Math.max(seen[tourId] ?? 0, version);
15
- },
16
- async reset(tourId) {
17
- if (tourId === undefined) seen = {};
18
- else delete seen[tourId];
19
- },
20
- };
8
+ let seen: Seen = { ...initial };
9
+ return {
10
+ async load() {
11
+ return { ...seen };
12
+ },
13
+ async markSeen(tourId, version, _outcome: Outcome) {
14
+ seen[tourId] = Math.max(seen[tourId] ?? 0, version);
15
+ },
16
+ async reset(tourId) {
17
+ if (tourId === undefined) seen = {};
18
+ else delete seen[tourId];
19
+ },
20
+ };
21
21
  }
22
22
 
23
23
  function readSeen(key: string): Seen {
24
- const read = tryCatch(() => JSON.parse(localStorage.getItem(key) ?? "{}") as unknown);
25
- const parsed = read.ok ? read.value : null;
26
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
27
- return Object.fromEntries(
28
- Object.entries(parsed).filter((entry): entry is [string, number] => typeof entry[1] === "number"),
29
- );
24
+ const read = tryCatch(() => JSON.parse(localStorage.getItem(key) ?? "{}") as unknown);
25
+ const parsed = read.ok ? read.value : null;
26
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
27
+ return Object.fromEntries(
28
+ Object.entries(parsed).filter(
29
+ (entry): entry is [string, number] => typeof entry[1] === "number",
30
+ ),
31
+ );
30
32
  }
31
33
 
32
34
  // Blocked storage (private mode, a full disk) means the tour shows again next visit, which beats an error.
33
35
  function writeSeen(key: string, seen: Seen): void {
34
- tryCatch(() => localStorage.setItem(key, JSON.stringify(seen)));
36
+ tryCatch(() => localStorage.setItem(key, JSON.stringify(seen)));
35
37
  }
36
38
 
37
39
  /** Keeps seen-state in this browser. Demos and signed-out pages. */
38
40
  export function localStorageStore(key = "heyramzi:onboarding"): SeenStore {
39
- return {
40
- async load() {
41
- return readSeen(key);
42
- },
43
- async markSeen(tourId, version, _outcome: Outcome) {
44
- const seen = readSeen(key);
45
- seen[tourId] = Math.max(seen[tourId] ?? 0, version);
46
- writeSeen(key, seen);
47
- },
48
- async reset(tourId) {
49
- if (tourId === undefined) {
50
- tryCatch(() => localStorage.removeItem(key));
51
- return;
52
- }
53
- const seen = readSeen(key);
54
- delete seen[tourId];
55
- writeSeen(key, seen);
56
- },
57
- };
41
+ return {
42
+ async load() {
43
+ return readSeen(key);
44
+ },
45
+ async markSeen(tourId, version, _outcome: Outcome) {
46
+ const seen = readSeen(key);
47
+ seen[tourId] = Math.max(seen[tourId] ?? 0, version);
48
+ writeSeen(key, seen);
49
+ },
50
+ async reset(tourId) {
51
+ if (tourId === undefined) {
52
+ tryCatch(() => localStorage.removeItem(key));
53
+ return;
54
+ }
55
+ const seen = readSeen(key);
56
+ delete seen[tourId];
57
+ writeSeen(key, seen);
58
+ },
59
+ };
58
60
  }