@leavepulse/ui 0.57.0 → 0.58.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 (150) hide show
  1. package/dist/chunks/{ContextMenuItems-BtKU7074.js → ContextMenuItems-BY5sTVQF.js} +1 -1
  2. package/dist/chunks/{LayoutCanvas-BkaGXTjx.js → LayoutCanvas-BcGLJVBq.js} +1 -1
  3. package/dist/chunks/{LayoutNode-B3_9E1Xa.js → LayoutNode-BkBn6dvM.js} +1 -1
  4. package/dist/chunks/{LpActionBar-C3hkVsFz.js → LpActionBar-DCN-_pZP.js} +2 -2
  5. package/dist/chunks/{LpAddressInput-NUHmplCd.js → LpAddressInput-Dio6Sqed.js} +7 -7
  6. package/dist/chunks/{LpAlert-B6ilrM7T.js → LpAlert-CXMn1GYE.js} +1 -1
  7. package/dist/chunks/{LpAppShell-Bl1Ch68N.js → LpAppShell-Be6T96td.js} +3 -3
  8. package/dist/chunks/{LpAutocomplete-C8IgSMsi.js → LpAutocomplete-hJ_6CHlh.js} +1 -1
  9. package/dist/chunks/{LpAvatar-LTvy0OkE.js → LpAvatar-BxNs23Xu.js} +2 -2
  10. package/dist/chunks/{LpBreadcrumbs-D4TsoFju.js → LpBreadcrumbs-BLzOEgAF.js} +1 -1
  11. package/dist/chunks/{LpButton-BdfZFNqi.js → LpButton-vhlwmpBy.js} +1 -1
  12. package/dist/chunks/{LpCalendar-DrA2McTU.js → LpCalendar-BLoE8Qm7.js} +1 -1
  13. package/dist/chunks/{LpCard-DbjO5yaF.js → LpCard-DhR7u0d8.js} +1 -1
  14. package/dist/chunks/{LpCheckbox-DvVYeOD-.js → LpCheckbox-B_C8sOoT.js} +1 -1
  15. package/dist/chunks/{LpCodeBlock-ca7ANFyW.js → LpCodeBlock-CXsqJwDy.js} +1 -1
  16. package/dist/chunks/{LpColorPicker-D1sQqtXS.js → LpColorPicker-DQ88niXq.js} +2 -2
  17. package/dist/chunks/{LpConfirmDialog-CrbUZgJE.js → LpConfirmDialog-DxlSdjmk.js} +3 -3
  18. package/dist/chunks/{LpConfirmHost-BgMIV8r9.js → LpConfirmHost-BcrmTNu_.js} +1 -1
  19. package/dist/chunks/{LpContextMenu-EF_90u3g.js → LpContextMenu-B1NGfpp1.js} +2 -2
  20. package/dist/chunks/{LpDatePicker-BrdFtSTy.js → LpDatePicker-B-VMHMgy.js} +3 -3
  21. package/dist/chunks/{LpDateRangePicker-CxV5jW7p.js → LpDateRangePicker-B68KPKPl.js} +2 -2
  22. package/dist/chunks/{LpDisclosure-Cc23-JOT.js → LpDisclosure-Ct-VBHxt.js} +1 -1
  23. package/dist/chunks/{LpDrawer-D9lLOC0C.js → LpDrawer-DSknigxH.js} +1 -1
  24. package/dist/chunks/{LpDropdownMenu-DrpJJBp0.js → LpDropdownMenu-C7oHVlKY.js} +2 -2
  25. package/dist/chunks/{LpEmptyState-BJbZXC8D.js → LpEmptyState-CBwwkkdA.js} +1 -1
  26. package/dist/chunks/{LpFieldNotice-wtieLUTj.js → LpFieldNotice-9hH4ITZa.js} +1 -1
  27. package/dist/chunks/{LpFileTree-3E4w4Rjr.js → LpFileTree-CMA0_rsB.js} +2 -2
  28. package/dist/chunks/{LpFileTreeNode-B4uB8Ks_.js → LpFileTreeNode-B4T258hF.js} +3 -3
  29. package/dist/chunks/{LpFormField-CtVYj6tO.js → LpFormField-BpQ6WJPe.js} +1 -1
  30. package/dist/chunks/{LpHintLayer-CI1oZ9Pd.js → LpHintLayer-B5-6FyEf.js} +1 -1
  31. package/dist/chunks/LpHints-BqoZXHZk.js +109 -0
  32. package/dist/chunks/{LpIcon-DYtaid0K.js → LpIcon-B5guGKrW.js} +1 -1
  33. package/dist/chunks/{LpInput-D-YGQbEv.js → LpInput-WvyP02ID.js} +2 -2
  34. package/dist/chunks/{LpLightbox-EVXzAq62.js → LpLightbox-Dq-e36uT.js} +1 -1
  35. package/dist/chunks/{LpLogViewer-BfgtXVKm.js → LpLogViewer-DwiYE32z.js} +2 -2
  36. package/dist/chunks/{LpMapPicker-Cc84LaM3.js → LpMapPicker-BH5csaBt.js} +1 -1
  37. package/dist/chunks/{LpMediaGallery-BnlkN_1f.js → LpMediaGallery-B1kDypJ-.js} +2 -2
  38. package/dist/chunks/{LpModal-JNa3B_Lr.js → LpModal-BxYssnXg.js} +1 -1
  39. package/dist/chunks/{LpModalNav-DXyAyhQl.js → LpModalNav-Bn2ws64b.js} +4 -4
  40. package/dist/chunks/{LpNotificationBell-DEL-RoHS.js → LpNotificationBell-DJfs4JjQ.js} +4 -4
  41. package/dist/chunks/{LpNumberField-B77O-z28.js → LpNumberField-CJPjmoDz.js} +1 -1
  42. package/dist/chunks/{LpPagination-BcDlo3r_.js → LpPagination-DehAaJLY.js} +1 -1
  43. package/dist/chunks/{LpPasswordInput-eKzQhbQP.js → LpPasswordInput-Bg5uTZbx.js} +2 -2
  44. package/dist/chunks/{LpPhoneInput-BCbrrhgE.js → LpPhoneInput-DStwcbnQ.js} +2 -2
  45. package/dist/chunks/{LpPie-CXka8Jvx.js → LpPie-DeTkbbOK.js} +2 -2
  46. package/dist/chunks/{LpRangeCalendar-DZuaRV0W.js → LpRangeCalendar-D5sDID9W.js} +1 -1
  47. package/dist/chunks/{LpSection-tKEH0jYf.js → LpSection-B19VPYmr.js} +1 -1
  48. package/dist/chunks/{LpSegmented-C09EEam2.js → LpSegmented-D3Eam8sa.js} +1 -1
  49. package/dist/chunks/{LpSelect-DNIL6MB9.js → LpSelect-DynjWK1A.js} +1 -1
  50. package/dist/chunks/{LpSidebar-hZ8D1cVr.js → LpSidebar-CshW0fqC.js} +3 -3
  51. package/dist/chunks/{LpSidebarNav-C9dHu-2b.js → LpSidebarNav-C9b3i9G2.js} +2 -2
  52. package/dist/chunks/{LpStat-B-7udBRP.js → LpStat-BS2TEXYK.js} +1 -1
  53. package/dist/chunks/{LpStepper-C7XiVKZj.js → LpStepper-BSglWF45.js} +1 -1
  54. package/dist/chunks/{LpTableFilter-CrW7_Rj4.js → LpTableFilter-CwHXVxJt.js} +2 -2
  55. package/dist/chunks/{LpTabs-BMSBB78h.js → LpTabs-BBGRHY38.js} +1 -1
  56. package/dist/chunks/{LpTagsInput-D1SuqbhB.js → LpTagsInput-CHbQoAAA.js} +3 -3
  57. package/dist/chunks/{LpTextarea-uuScWo6-.js → LpTextarea-Bz2IWhTH.js} +1 -1
  58. package/dist/chunks/{LpThemeSwitcher-Fz85BCRS.js → LpThemeSwitcher-B_qhSl7q.js} +2 -2
  59. package/dist/chunks/{LpTimeField-Cqe7fXnh.js → LpTimeField-DvGXgdES.js} +1 -1
  60. package/dist/chunks/{LpToaster-DTtWuKvq.js → LpToaster-Bo9Q4QBP.js} +1 -1
  61. package/dist/chunks/{LpTour-C1TC2GyY.js → LpTour-D7m9ZxNk.js} +26 -7
  62. package/dist/chunks/{MenuTitle-J3ZDYjpV.js → MenuTitle-DQP6hc2N.js} +1 -1
  63. package/dist/chunks/{TourCard-BQS3ztqj.js → TourCard-DCHOjr6e.js} +2 -2
  64. package/dist/chunks/icons-QqBscG5I.js +43 -0
  65. package/dist/component-names.d.ts +1 -1
  66. package/dist/components/ContextMenuItems.vue.js +1 -1
  67. package/dist/components/LayoutCanvas.vue.js +1 -1
  68. package/dist/components/LayoutNode.vue.js +1 -1
  69. package/dist/components/LpActionBar.vue.js +1 -1
  70. package/dist/components/LpAddressInput.vue.js +1 -1
  71. package/dist/components/LpAlert.vue.js +1 -1
  72. package/dist/components/LpAppShell.vue.js +1 -1
  73. package/dist/components/LpAutocomplete.vue.js +1 -1
  74. package/dist/components/LpAvatar.vue.js +1 -1
  75. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  76. package/dist/components/LpButton.vue.js +1 -1
  77. package/dist/components/LpCalendar.vue.js +1 -1
  78. package/dist/components/LpCard.vue.js +1 -1
  79. package/dist/components/LpChart.vue.js +2 -2
  80. package/dist/components/LpCheckbox.vue.js +1 -1
  81. package/dist/components/LpCodeBlock.vue.js +1 -1
  82. package/dist/components/LpColorPicker.vue.js +1 -1
  83. package/dist/components/LpCommandPalette.vue.js +2 -2
  84. package/dist/components/LpConfirmDialog.vue.js +1 -1
  85. package/dist/components/LpConfirmHost.vue.js +1 -1
  86. package/dist/components/LpContextMenu.vue.js +1 -1
  87. package/dist/components/LpDatePicker.vue.js +1 -1
  88. package/dist/components/LpDateRangePicker.vue.js +1 -1
  89. package/dist/components/LpDisclosure.vue.js +1 -1
  90. package/dist/components/LpDrawer.vue.js +1 -1
  91. package/dist/components/LpDropdownMenu.vue.js +1 -1
  92. package/dist/components/LpDropzone.vue.js +1 -1
  93. package/dist/components/LpEmptyState.vue.js +1 -1
  94. package/dist/components/LpFieldNotice.vue.js +1 -1
  95. package/dist/components/LpFileTree.vue.js +1 -1
  96. package/dist/components/LpFileTreeNode.vue.js +1 -1
  97. package/dist/components/LpFormField.vue.js +1 -1
  98. package/dist/components/LpHintLayer.vue.js +1 -1
  99. package/dist/components/LpHints.vue.d.ts +20 -0
  100. package/dist/components/LpHints.vue.js +4 -0
  101. package/dist/components/LpIcon.vue.js +1 -1
  102. package/dist/components/LpImageEditor.vue.js +2 -2
  103. package/dist/components/LpInput.vue.js +1 -1
  104. package/dist/components/LpLightbox.vue.js +1 -1
  105. package/dist/components/LpLogViewer.vue.js +1 -1
  106. package/dist/components/LpMapPicker.vue.js +1 -1
  107. package/dist/components/LpMediaGallery.vue.js +1 -1
  108. package/dist/components/LpModal.vue.js +1 -1
  109. package/dist/components/LpModalNav.vue.js +1 -1
  110. package/dist/components/LpNotificationBell.vue.js +1 -1
  111. package/dist/components/LpNumberField.vue.js +1 -1
  112. package/dist/components/LpPagination.vue.js +1 -1
  113. package/dist/components/LpPaletteFacts.vue.js +1 -1
  114. package/dist/components/LpPaletteHeader.vue.js +2 -2
  115. package/dist/components/LpPasswordInput.vue.js +1 -1
  116. package/dist/components/LpPhoneInput.vue.js +1 -1
  117. package/dist/components/LpPie.vue.js +1 -1
  118. package/dist/components/LpRangeCalendar.vue.js +1 -1
  119. package/dist/components/LpSection.vue.js +1 -1
  120. package/dist/components/LpSegmented.vue.js +1 -1
  121. package/dist/components/LpSelect.vue.js +1 -1
  122. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  123. package/dist/components/LpSidebar.vue.js +1 -1
  124. package/dist/components/LpSidebarNav.vue.js +1 -1
  125. package/dist/components/LpStat.vue.js +1 -1
  126. package/dist/components/LpStepper.vue.js +1 -1
  127. package/dist/components/LpTable.vue.js +6 -6
  128. package/dist/components/LpTableFilter.vue.js +1 -1
  129. package/dist/components/LpTabs.vue.js +1 -1
  130. package/dist/components/LpTagsInput.vue.js +1 -1
  131. package/dist/components/LpTextarea.vue.js +1 -1
  132. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  133. package/dist/components/LpTimeField.vue.js +1 -1
  134. package/dist/components/LpToaster.vue.js +1 -1
  135. package/dist/components/LpTour.vue.js +1 -1
  136. package/dist/components/MenuTitle.vue.js +1 -1
  137. package/dist/components/TourCard.vue.js +1 -1
  138. package/dist/composables/useHintSystem.d.ts +195 -0
  139. package/dist/composables/useHintSystem.js +334 -0
  140. package/dist/icons-all.js +1 -1
  141. package/dist/index.d.ts +3 -0
  142. package/dist/index.js +73 -64
  143. package/package.json +1 -1
  144. package/src/component-names.ts +1 -0
  145. package/src/components/LpHints.vue +116 -0
  146. package/src/components/LpTour.vue +26 -6
  147. package/src/composables/useHintSystem.ts +512 -0
  148. package/src/index.ts +17 -0
  149. package/src/kit-icons.ts +1 -1
  150. package/dist/chunks/icons-CkA0njzm.js +0 -43
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Drop-in onboarding: put it once next to the app's root, hand it a hint
4
+ * system, and the tour offer, the "new" dots, "what changed" and nudges all
5
+ * work. Nothing to wire per page beyond `v-hint` on the elements.
6
+ *
7
+ * It must never cost the page itself. Any error thrown inside (a tour step's
8
+ * `enter`, a storage that rejects, a target that misbehaves) switches the layer
9
+ * off for the session and is logged once; the page under it keeps working.
10
+ */
11
+ import { computed, onBeforeUnmount, onErrorCaptured, onMounted, ref, unref, watch } from "vue"
12
+ import { provideHintSystem, type HintSystem } from "../composables/useHintSystem"
13
+ import { useLabels } from "../composables/useLpConfig"
14
+ import LpActionBar, { type ActionBarAction } from "./LpActionBar.vue"
15
+ import LpHintLayer from "./LpHintLayer.vue"
16
+ import LpTour from "./LpTour.vue"
17
+
18
+ const props = defineProps<{
19
+ system: HintSystem
20
+ labels?: Partial<typeof DEFAULT_LABELS>
21
+ }>()
22
+
23
+ const DEFAULT_LABELS = {
24
+ tourTitle: "New to {page}?",
25
+ tourText: "A one-minute look at what you can do here.",
26
+ assignedTitle: "You were asked to look at {page}",
27
+ assignedText: "A short tour of this page, about a minute.",
28
+ newsTitle: "What changed: {page}",
29
+ show: "Show me",
30
+ notNow: "Not now",
31
+ never: "Don't offer again",
32
+ gotIt: "Got it",
33
+ noThanks: "No thanks",
34
+ }
35
+ const labels = useLabels("hints", DEFAULT_LABELS, () => props.labels)
36
+
37
+ provideHintSystem(props.system)
38
+
39
+ const broken = ref(false)
40
+ onErrorCaptured((error) => {
41
+ if (!broken.value) console.warn("[LpHints] disabled after an error:", error)
42
+ broken.value = true
43
+ props.system.stop()
44
+ return false
45
+ })
46
+
47
+ onMounted(() => {
48
+ try {
49
+ props.system.start()
50
+ if (!props.system.state.loaded) void props.system.load()
51
+ } catch (error) {
52
+ broken.value = true
53
+ console.warn("[LpHints] disabled after an error:", error)
54
+ }
55
+ })
56
+ onBeforeUnmount(() => props.system.stop())
57
+
58
+ const state = props.system.state
59
+ const fresh = computed(() => unref(props.system.fresh))
60
+ const notes = computed(() => unref(props.system.notes))
61
+ watch(() => [state.tour.open, state.tour.step] as const, ([open, step]) => open && props.system.tourShown(step))
62
+
63
+ const offer = computed(() => state.offer)
64
+ const page = computed(() => (offer.value ? props.system.pageTitle(offer.value.page) : ""))
65
+ const fill = (text: string) => text.replace("{page}", page.value)
66
+
67
+ const title = computed(() => {
68
+ const o = offer.value
69
+ if (!o) return ""
70
+ if (o.kind === "nudge") return notes.value[o.nudge.feature]?.title ?? ""
71
+ if (o.kind === "news") return fill(labels.value.newsTitle)
72
+ return fill(o.kind === "assigned" ? labels.value.assignedTitle : labels.value.tourTitle)
73
+ })
74
+
75
+ const description = computed(() => {
76
+ const o = offer.value
77
+ if (!o) return ""
78
+ if (o.kind === "nudge") return o.nudge.text
79
+ if (o.kind === "news") return o.items.map((n) => n.text).join(" ")
80
+ return o.kind === "assigned" ? labels.value.assignedText : labels.value.tourText
81
+ })
82
+
83
+ const actions = computed<ActionBarAction[]>(() => {
84
+ const o = offer.value
85
+ if (!o) return []
86
+ const answer = (choice: "show" | "later" | "never") => () => props.system.answer(choice)
87
+ const show: ActionBarAction = { label: labels.value.show, icon: "lucide:sparkles", onClick: answer("show") }
88
+ if (o.kind === "nudge") return [{ label: labels.value.noThanks, variant: "ghost", onClick: answer("never") }, show]
89
+ if (o.kind === "news") return [{ label: labels.value.gotIt, variant: "ghost", onClick: answer("later") }, show]
90
+ return [
91
+ ...(o.kind === "tour" ? [{ label: labels.value.never, variant: "ghost" as const, onClick: answer("never") }] : []),
92
+ { label: labels.value.notNow, variant: "ghost", onClick: answer("later") },
93
+ show,
94
+ ]
95
+ })
96
+ </script>
97
+
98
+ <template>
99
+ <template v-if="!broken">
100
+ <LpTour
101
+ v-model:open="state.tour.open"
102
+ v-model:step="state.tour.step"
103
+ :steps="state.tour.steps"
104
+ @finish="system.tourEnded(true)"
105
+ @dismiss="system.tourEnded(false)"
106
+ />
107
+ <LpHintLayer :fresh="fresh" :notes="notes" @read="system.readFresh" />
108
+ <LpActionBar
109
+ :open="!!offer"
110
+ :title="title"
111
+ :description="description"
112
+ :icon="offer?.kind === 'news' ? 'lucide:gift' : 'lucide:compass'"
113
+ :actions="actions"
114
+ />
115
+ </template>
116
+ </template>
@@ -125,9 +125,13 @@ const smooth = () => !prefersReducedMotion()
125
125
 
126
126
  function resolve(raw: TourStep["target"]): Element | null {
127
127
  if (!raw) return null
128
- if (typeof raw === "function") return raw()
129
- if (typeof raw !== "string") return raw.isConnected ? raw : null
130
- return hints.element(raw) ?? document.querySelector(raw)
128
+ try {
129
+ if (typeof raw === "function") return raw()
130
+ if (typeof raw !== "string") return raw.isConnected ? raw : null
131
+ return hints.element(raw) ?? document.querySelector(raw)
132
+ } catch {
133
+ return null
134
+ }
131
135
  }
132
136
 
133
137
  function waitFor(raw: TourStep["target"], mine: number): Promise<Element | null> {
@@ -169,7 +173,13 @@ async function show(): Promise<void> {
169
173
  leave()
170
174
  const current = step.value
171
175
  if (!current) return
172
- await current.enter?.({ onLeave: (fn) => leaving.push(fn), onEnd: (fn) => cleanups.push(fn) })
176
+ try {
177
+ await current.enter?.({ onLeave: (fn) => leaving.push(fn), onEnd: (fn) => cleanups.push(fn) })
178
+ } catch (error) {
179
+ console.warn("[LpTour] step skipped, its enter() failed:", error)
180
+ if (mine === token) skip()
181
+ return
182
+ }
173
183
  if (mine !== token) return
174
184
  if (!current.target) {
175
185
  target.value = null
@@ -214,8 +224,18 @@ async function perform() {
214
224
  setTimeout(next, TRY_SETTLE_MS)
215
225
  }
216
226
 
227
+ function runAll(list: (() => void)[]) {
228
+ for (const cleanup of list.splice(0).reverse()) {
229
+ try {
230
+ cleanup()
231
+ } catch (error) {
232
+ console.warn("[LpTour] cleanup failed:", error)
233
+ }
234
+ }
235
+ }
236
+
217
237
  function leave() {
218
- for (const cleanup of leaving.splice(0).reverse()) cleanup()
238
+ runAll(leaving)
219
239
  }
220
240
 
221
241
  function end() {
@@ -225,7 +245,7 @@ function end() {
225
245
  frame = 0
226
246
  target.value = null
227
247
  box.value = null
228
- for (const cleanup of cleanups.splice(0).reverse()) cleanup()
248
+ runAll(cleanups)
229
249
  }
230
250
 
231
251
  function finish(): void {
@@ -0,0 +1,512 @@
1
+ import { computed, inject, provide, reactive, watch, type ComputedRef, type InjectionKey, type WatchStopHandle } from "vue"
2
+ import type { HintNote } from "../components/LpHintLayer.vue"
3
+ import type { TourContext, TourStep } from "../components/LpTour.vue"
4
+ import { anyModalOpen } from "../components/modalDepth"
5
+ import { hints } from "./useHints"
6
+
7
+ /*
8
+ * Onboarding that does not nag: a first-visit tour offer, quiet "new" dots,
9
+ * "what changed" after an update, and nudges toward unused features — all
10
+ * spending one shared attention budget.
11
+ *
12
+ * The app describes its features once (each tied to the `v-hint` element that
13
+ * represents it) and says which page is current; the system decides what to
14
+ * show and when, remembers what each person has seen, and renders through
15
+ * <LpHints>. Everything below is policy the app would otherwise re-derive:
16
+ *
17
+ * budget — at most one interruption (offer, changes, nudge) per session,
18
+ * never over a dialog, while typing, right after navigation, or
19
+ * when the user arrived with intent (palette, notification);
20
+ * memory — per key, a state that only climbs (seen → explained → used),
21
+ * so what was used is never explained again;
22
+ * fresh — a feature becomes "new" only after the page is known: when a
23
+ * page is first met, everything on it is quietly baselined, so a
24
+ * rollout does not light every control for existing users;
25
+ * news — "what changed" is for people who knew the page before: a
26
+ * newcomer's first visit marks the page's news as read, while a
27
+ * returning user (recognised by `known`) still gets it;
28
+ * retries — a declined tour is offered again at most `offersMax` times,
29
+ * `offerAgain` apart; a nudge waits `nudgeAgain` between showings.
30
+ */
31
+
32
+ export interface HintFeature {
33
+ page: string
34
+ title: string
35
+ text: string
36
+ order?: number
37
+ keys?: string
38
+ try?: boolean
39
+ side?: TourStep["side"]
40
+ /** Bump to announce a changed feature again: it reads as new to everyone. */
41
+ rev?: number
42
+ /** Keeps its dot until explained, even after "not now". */
43
+ required?: boolean
44
+ /**
45
+ * Rendered only in some states (data-dependent, behind a filter). Its first
46
+ * appearance is not news, so it is never marked new for merely showing up;
47
+ * a `rev` bump still announces it.
48
+ */
49
+ conditional?: boolean
50
+ /** When the `v-hint` id is not the element to light (a column header, an opened menu). */
51
+ target?: () => Element | null
52
+ enter?: (context: TourContext) => void | Promise<void>
53
+ }
54
+
55
+ export interface HintPage {
56
+ title: string
57
+ /**
58
+ * A localStorage key prefix that proves the user worked on this page before
59
+ * the system existed (a remembered table layout). Such a page is treated as
60
+ * known on first sight: no tour offer, no dots for what was always there.
61
+ */
62
+ known?: string
63
+ }
64
+
65
+ export interface HintNews {
66
+ id: string
67
+ date: string
68
+ page: string
69
+ text: string
70
+ feature?: string
71
+ }
72
+
73
+ export interface HintNudge {
74
+ id: string
75
+ feature: string
76
+ text: string
77
+ }
78
+
79
+ export type HintLevel = "seen" | "explained" | "used"
80
+
81
+ export interface HintRecord {
82
+ state: HintLevel
83
+ dismissed: boolean
84
+ count: number
85
+ assigned?: boolean
86
+ updatedAt: string
87
+ }
88
+
89
+ export interface HintDelta {
90
+ key: string
91
+ state?: HintLevel
92
+ dismissed?: boolean
93
+ add?: number
94
+ }
95
+
96
+ export interface HintStorage {
97
+ load(): Promise<Record<string, HintRecord>> | Record<string, HintRecord>
98
+ save(deltas: HintDelta[]): Promise<void> | void
99
+ /** Last chance on page hide (sendBeacon); falls back to `save`. */
100
+ saveOnLeave?(deltas: HintDelta[]): void
101
+ }
102
+
103
+ export interface HintTiming {
104
+ settle: number
105
+ offerAgain: number
106
+ offersMax: number
107
+ freshLimit: number
108
+ nudgeAgain: number
109
+ flush: number
110
+ }
111
+
112
+ export interface HintSystemOptions {
113
+ features: Record<string, HintFeature>
114
+ pages?: Record<string, HintPage>
115
+ news?: HintNews[]
116
+ storage?: HintStorage
117
+ /** The current page id, read reactively (map your route to it). */
118
+ page: () => string
119
+ sessionKey?: string
120
+ timing?: Partial<HintTiming>
121
+ }
122
+
123
+ export type HintOffer =
124
+ | { kind: "tour" | "assigned"; page: string }
125
+ | { kind: "news"; page: string; items: HintNews[] }
126
+ | { kind: "nudge"; page: string; nudge: HintNudge }
127
+
128
+ const RANK: Record<HintLevel, number> = { seen: 0, explained: 1, used: 2 }
129
+ const DAY = 24 * 3600 * 1000
130
+ const DEFAULT_TIMING: HintTiming = {
131
+ settle: 2500,
132
+ offerAgain: 3 * DAY,
133
+ offersMax: 2,
134
+ freshLimit: 3,
135
+ nudgeAgain: 7 * DAY,
136
+ flush: 2000,
137
+ }
138
+
139
+ /** Records in localStorage: for anonymous sessions and apps without a server. */
140
+ export function localHintStorage(key = "lp-hints"): HintStorage {
141
+ const read = (): Record<string, HintRecord> => {
142
+ try {
143
+ return JSON.parse(localStorage.getItem(key) || "{}") as Record<string, HintRecord>
144
+ } catch {
145
+ return {}
146
+ }
147
+ }
148
+ return {
149
+ load: read,
150
+ save(deltas) {
151
+ const all = read()
152
+ for (const d of deltas) all[d.key] = merge(all[d.key], d)
153
+ try {
154
+ localStorage.setItem(key, JSON.stringify(all))
155
+ } catch {
156
+ return
157
+ }
158
+ },
159
+ }
160
+ }
161
+
162
+ function merge(record: HintRecord | undefined, delta: Omit<HintDelta, "key">): HintRecord {
163
+ const base = record ?? { state: "seen" as HintLevel, dismissed: false, count: 0, updatedAt: "" }
164
+ return {
165
+ ...base,
166
+ state: delta.state && RANK[delta.state] > RANK[base.state] ? delta.state : base.state,
167
+ dismissed: delta.dismissed ?? base.dismissed,
168
+ count: base.count + (delta.add ?? 0),
169
+ updatedAt: new Date().toISOString(),
170
+ }
171
+ }
172
+
173
+ /** Record keys, for apps that read or assign records directly (a "who knows what" view). */
174
+ export const hintKey = {
175
+ feature: (id: string, rev = 1) => `f:${id}:${rev}`,
176
+ page: (page: string) => `t:${page}`,
177
+ news: (id: string) => `c:${id}`,
178
+ nudge: (id: string) => `n:${id}`,
179
+ }
180
+
181
+ export interface HintSummary {
182
+ tour: "done" | "assigned" | "declined" | "offered" | "never"
183
+ offers: number
184
+ known: number
185
+ total: number
186
+ /** Titles of `required` features this person has not been shown. */
187
+ missing: string[]
188
+ }
189
+
190
+ /** What one person knows of a page, from their records: for a manager's view. */
191
+ export function hintSummary(
192
+ records: Map<string, Pick<HintRecord, "state" | "dismissed" | "count" | "assigned">>,
193
+ features: Record<string, HintFeature>,
194
+ page: string,
195
+ ): HintSummary {
196
+ const own = Object.entries(features).filter(([, f]) => f.page === page)
197
+ const knows = (id: string, f: HintFeature) => RANK[records.get(hintKey.feature(id, f.rev))?.state ?? "seen"] >= RANK.explained
198
+ const tour = records.get(hintKey.page(page))
199
+ return {
200
+ tour: tour && RANK[tour.state] >= RANK.explained
201
+ ? "done"
202
+ : tour?.assigned ? "assigned" : tour?.dismissed ? "declined" : tour ? "offered" : "never",
203
+ offers: tour?.count ?? 0,
204
+ known: own.filter(([id, f]) => knows(id, f)).length,
205
+ total: own.length,
206
+ missing: own.filter(([id, f]) => f.required && !knows(id, f)).map(([, f]) => f.title),
207
+ }
208
+ }
209
+
210
+ function typing(): boolean {
211
+ const el = document.activeElement
212
+ return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el instanceof HTMLElement && el.isContentEditable)
213
+ }
214
+
215
+ export function createHintSystem(options: HintSystemOptions) {
216
+ const timing = { ...DEFAULT_TIMING, ...options.timing }
217
+ const features = options.features
218
+ const pages = options.pages ?? {}
219
+ const news = options.news ?? []
220
+ const storage = options.storage ?? localHintStorage()
221
+ const sessionKey = options.sessionKey ?? "lp-hints:interrupted"
222
+
223
+ const state = reactive({
224
+ records: new Map<string, HintRecord>(),
225
+ loaded: false,
226
+ page: "",
227
+ tour: { open: false, step: 0, steps: [] as TourStep[], ids: [] as string[], page: "" },
228
+ offer: null as HintOffer | null,
229
+ candidate: null as (HintNudge & { page: string }) | null,
230
+ settledAt: 0,
231
+ intent: false,
232
+ })
233
+
234
+ const pending = new Map<string, HintDelta>()
235
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
236
+
237
+ const featureKey = (id: string) => hintKey.feature(id, features[id]?.rev)
238
+ const pageKey = hintKey.page
239
+ const newsKey = hintKey.news
240
+ const nudgeKey = hintKey.nudge
241
+
242
+ function interrupted(): boolean {
243
+ try {
244
+ return sessionStorage.getItem(sessionKey) === "1"
245
+ } catch {
246
+ return false
247
+ }
248
+ }
249
+
250
+ function spend(): void {
251
+ try {
252
+ sessionStorage.setItem(sessionKey, "1")
253
+ } catch {
254
+ return
255
+ }
256
+ }
257
+
258
+ async function flush(): Promise<void> {
259
+ clearTimeout(flushTimer)
260
+ flushTimer = undefined
261
+ if (!pending.size) return
262
+ const deltas = [...pending.values()]
263
+ pending.clear()
264
+ try {
265
+ await storage.save(deltas)
266
+ } catch {
267
+ for (const d of deltas) if (!pending.has(d.key)) pending.set(d.key, d)
268
+ }
269
+ }
270
+
271
+ function flushOnLeave(): void {
272
+ if (!pending.size) return
273
+ const deltas = [...pending.values()]
274
+ pending.clear()
275
+ if (storage.saveOnLeave) storage.saveOnLeave(deltas)
276
+ else void storage.save(deltas)
277
+ }
278
+
279
+ function record(key: string, delta: Omit<HintDelta, "key">): void {
280
+ state.records.set(key, merge(state.records.get(key), delta))
281
+ const queued = pending.get(key)
282
+ pending.set(key, {
283
+ key,
284
+ state: delta.state ?? queued?.state,
285
+ dismissed: delta.dismissed ?? queued?.dismissed,
286
+ add: (queued?.add ?? 0) + (delta.add ?? 0),
287
+ })
288
+ flushTimer ??= setTimeout(() => void flush(), timing.flush)
289
+ }
290
+
291
+ const present = (page: string) => Object.keys(features).filter((id) => features[id]!.page === page && hints.present(id))
292
+
293
+ function knownBefore(page: string): boolean {
294
+ const marker = pages[page]?.known
295
+ if (!marker) return false
296
+ try {
297
+ return Object.keys(localStorage).some((key) => key.startsWith(marker))
298
+ } catch {
299
+ return false
300
+ }
301
+ }
302
+
303
+ function baseline(page: string): void {
304
+ for (const id of present(page)) if (!state.records.has(featureKey(id))) record(featureKey(id), { state: "seen" })
305
+ }
306
+
307
+ const fresh: ComputedRef<string[]> = computed(() => {
308
+ const page = state.page
309
+ if (!page || !state.loaded || state.tour.open || !state.records.has(pageKey(page))) return []
310
+ return present(page)
311
+ .filter((id) => {
312
+ const f = features[id]!
313
+ const rec = state.records.get(featureKey(id))
314
+ if (!rec) return !f.conditional || (f.rev ?? 1) > 1
315
+ return !!f.required && RANK[rec.state] < RANK.explained && !rec.dismissed
316
+ })
317
+ .sort((a, b) => Number(!!features[b]!.required) - Number(!!features[a]!.required) || (features[a]!.order ?? 0) - (features[b]!.order ?? 0))
318
+ .slice(0, timing.freshLimit)
319
+ })
320
+
321
+ const notes: ComputedRef<Record<string, HintNote>> = computed(() =>
322
+ Object.fromEntries(Object.entries(features).map(([id, f]) => [id, { title: f.title, text: f.text }])),
323
+ )
324
+
325
+ const hasTour = (page = state.page) => Object.values(features).some((f) => f.page === page)
326
+
327
+ function startTour(page = state.page, only?: string[]): void {
328
+ const entries = Object.entries(features)
329
+ .filter(([id, f]) => f.page === page && (!only || only.includes(id)))
330
+ .sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
331
+ if (!entries.length) return
332
+ state.offer = null
333
+ Object.assign(state.tour, {
334
+ open: true,
335
+ step: 0,
336
+ page,
337
+ ids: entries.map(([id]) => id),
338
+ steps: entries.map(([id, f]) => ({
339
+ target: f.target ?? id,
340
+ title: f.title,
341
+ text: f.text,
342
+ keys: f.keys,
343
+ try: f.try,
344
+ side: f.side,
345
+ enter: f.enter,
346
+ })),
347
+ })
348
+ }
349
+
350
+ const unread = (page?: string) =>
351
+ news.filter((n) => (!page || n.page === page) && !state.records.has(newsKey(n.id)) && (!page || !n.feature || hints.present(n.feature)))
352
+
353
+ function readNews(page: string): void {
354
+ for (const n of unread(page)) record(newsKey(n.id), { state: "explained" })
355
+ }
356
+
357
+ function considerOffer(): void {
358
+ const page = state.page
359
+ const ready = state.loaded && !state.tour.open && !state.offer && !anyModalOpen() && !typing()
360
+ && Date.now() - state.settledAt > timing.settle
361
+ if (!page || !ready) return
362
+ const rec = state.records.get(pageKey(page))
363
+ if (rec?.assigned && RANK[rec.state] < RANK.explained && hasTour(page)) {
364
+ state.offer = { kind: "assigned", page }
365
+ return
366
+ }
367
+ if (state.intent || interrupted()) return
368
+ if (!rec && knownBefore(page)) {
369
+ record(pageKey(page), { state: "seen" })
370
+ baseline(page)
371
+ } else if (
372
+ !rec
373
+ || (!rec.dismissed && RANK[rec.state] < RANK.explained && rec.count < timing.offersMax
374
+ && Date.now() - Date.parse(rec.updatedAt || "0") > timing.offerAgain)
375
+ ) {
376
+ if (!hasTour(page)) return
377
+ spend()
378
+ if (!rec) {
379
+ record(pageKey(page), { state: "seen" })
380
+ baseline(page)
381
+ readNews(page)
382
+ }
383
+ state.offer = { kind: "tour", page }
384
+ return
385
+ }
386
+ const items = unread(page)
387
+ if (items.length) {
388
+ spend()
389
+ state.offer = { kind: "news", page, items: items.slice(0, 3) }
390
+ return
391
+ }
392
+ const n = state.candidate
393
+ if (!n || n.page !== page) return
394
+ const seen = state.records.get(nudgeKey(n.id))
395
+ if (seen?.dismissed || state.records.get(featureKey(n.feature))?.state === "used" || !hints.present(n.feature)) return
396
+ if (seen && Date.now() - Date.parse(seen.updatedAt) < timing.nudgeAgain) return
397
+ state.candidate = null
398
+ spend()
399
+ record(nudgeKey(n.id), { add: 1 })
400
+ state.offer = { kind: "nudge", page, nudge: n }
401
+ }
402
+
403
+ let stops: (() => void)[] = []
404
+
405
+ function start(): void {
406
+ if (stops.length) return
407
+ const stopPage: WatchStopHandle = watch(
408
+ options.page,
409
+ (page) => {
410
+ state.page = page
411
+ state.settledAt = Date.now()
412
+ if (state.offer && state.offer.page !== page) state.offer = null
413
+ if (state.candidate && state.candidate.page !== page) state.candidate = null
414
+ setTimeout(() => (state.intent = false), timing.settle * 2)
415
+ },
416
+ { immediate: true },
417
+ )
418
+ const poll = setInterval(() => {
419
+ try {
420
+ considerOffer()
421
+ } catch (error) {
422
+ console.warn("[hints] disabled after an error:", error)
423
+ stop()
424
+ }
425
+ }, 1000)
426
+ window.addEventListener("pagehide", flushOnLeave)
427
+ stops = [
428
+ stopPage,
429
+ hints.on("used", (id) => {
430
+ if (features[id]) record(featureKey(id), { state: "used", add: 1 })
431
+ }),
432
+ () => clearInterval(poll),
433
+ () => window.removeEventListener("pagehide", flushOnLeave),
434
+ ]
435
+ }
436
+
437
+ function stop(): void {
438
+ for (const s of stops.splice(0)) s()
439
+ flushOnLeave()
440
+ }
441
+
442
+ return {
443
+ state,
444
+ fresh,
445
+ notes,
446
+ start,
447
+ stop,
448
+ hasTour,
449
+ startTour,
450
+ async load(): Promise<void> {
451
+ state.records.clear()
452
+ try {
453
+ for (const [key, rec] of Object.entries(await storage.load())) state.records.set(key, rec)
454
+ state.loaded = true
455
+ } catch {
456
+ state.loaded = false
457
+ }
458
+ },
459
+ /** Ask for a nudge; it shows when the budget and the page allow. */
460
+ nudge(n: HintNudge): void {
461
+ state.candidate = { ...n, page: state.page }
462
+ },
463
+ /** The user navigated on purpose (palette, notification): do not interrupt this arrival. */
464
+ markIntent(): void {
465
+ state.intent = true
466
+ },
467
+ unreadNews: (page?: string) => unread(page),
468
+ readNews,
469
+ pageTitle: (page: string) => pages[page]?.title ?? page,
470
+ readFresh(id: string): void {
471
+ record(featureKey(id), { state: "explained" })
472
+ },
473
+ tourShown(index: number): void {
474
+ const id = state.tour.ids[index]
475
+ if (id) record(featureKey(id), { state: "explained" })
476
+ },
477
+ tourEnded(finished: boolean): void {
478
+ record(pageKey(state.tour.page), finished ? { state: "explained" } : { add: 1 })
479
+ baseline(state.tour.page)
480
+ },
481
+ answer(choice: "show" | "later" | "never"): void {
482
+ const offer = state.offer
483
+ state.offer = null
484
+ if (!offer) return
485
+ if (offer.kind === "news") {
486
+ const ids = offer.items.map((n) => n.feature).filter((id): id is string => !!id)
487
+ readNews(offer.page)
488
+ if (choice === "show" && ids.length) startTour(offer.page, ids)
489
+ } else if (offer.kind === "nudge") {
490
+ if (choice === "show") startTour(features[offer.nudge.feature]!.page, [offer.nudge.feature])
491
+ else record(nudgeKey(offer.nudge.id), { dismissed: true })
492
+ } else if (choice === "show") {
493
+ startTour(offer.page)
494
+ } else {
495
+ record(pageKey(offer.page), { add: 1, dismissed: choice === "never" })
496
+ baseline(offer.page)
497
+ }
498
+ },
499
+ }
500
+ }
501
+
502
+ export type HintSystem = ReturnType<typeof createHintSystem>
503
+
504
+ const HINT_SYSTEM: InjectionKey<HintSystem> = Symbol.for("lp-hint-system")
505
+
506
+ export function provideHintSystem(system: HintSystem): void {
507
+ provide(HINT_SYSTEM, system)
508
+ }
509
+
510
+ export function useHintSystem(): HintSystem | null {
511
+ return inject(HINT_SYSTEM, null)
512
+ }