@leavepulse/ui 0.56.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 (182) 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-BEGjSqFl.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-Drd7EbIz.js → LpDateRangePicker-B68KPKPl.js} +6 -15
  22. package/dist/chunks/{LpDisclosure-Cc23-JOT.js → LpDisclosure-Ct-VBHxt.js} +1 -1
  23. package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-DSknigxH.js} +5 -7
  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-B5-6FyEf.js +154 -0
  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-DXBilXr0.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-B7ti5qCH.js → LpPie-DeTkbbOK.js} +3 -3
  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-C2oolgwy.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/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
  56. package/dist/chunks/{LpTabs-BMSBB78h.js → LpTabs-BBGRHY38.js} +1 -1
  57. package/dist/chunks/{LpTagsInput-D1SuqbhB.js → LpTagsInput-CHbQoAAA.js} +3 -3
  58. package/dist/chunks/{LpTextarea-uuScWo6-.js → LpTextarea-Bz2IWhTH.js} +1 -1
  59. package/dist/chunks/{LpThemeSwitcher-Fz85BCRS.js → LpThemeSwitcher-B_qhSl7q.js} +2 -2
  60. package/dist/chunks/{LpTimeField-Cqe7fXnh.js → LpTimeField-DvGXgdES.js} +1 -1
  61. package/dist/chunks/{LpToaster-DTtWuKvq.js → LpToaster-Bo9Q4QBP.js} +1 -1
  62. package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
  63. package/dist/chunks/LpTour-D7m9ZxNk.js +334 -0
  64. package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
  65. package/dist/chunks/{MenuTitle-J3ZDYjpV.js → MenuTitle-DQP6hc2N.js} +1 -1
  66. package/dist/chunks/TourCard-DCHOjr6e.js +100 -0
  67. package/dist/chunks/icons-QqBscG5I.js +43 -0
  68. package/dist/component-names.d.ts +1 -1
  69. package/dist/components/ContextMenuItems.vue.js +1 -1
  70. package/dist/components/LayoutCanvas.vue.js +1 -1
  71. package/dist/components/LayoutNode.vue.js +1 -1
  72. package/dist/components/LpActionBar.vue.js +1 -1
  73. package/dist/components/LpAddressInput.vue.js +1 -1
  74. package/dist/components/LpAlert.vue.js +1 -1
  75. package/dist/components/LpAppShell.vue.js +1 -1
  76. package/dist/components/LpAutocomplete.vue.js +1 -1
  77. package/dist/components/LpAvatar.vue.js +1 -1
  78. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  79. package/dist/components/LpButton.vue.js +1 -1
  80. package/dist/components/LpCalendar.vue.js +1 -1
  81. package/dist/components/LpCard.vue.js +1 -1
  82. package/dist/components/LpChart.vue.js +2 -2
  83. package/dist/components/LpCheckbox.vue.js +1 -1
  84. package/dist/components/LpCodeBlock.vue.js +1 -1
  85. package/dist/components/LpColorPicker.vue.js +1 -1
  86. package/dist/components/LpCommandPalette.vue.js +2 -2
  87. package/dist/components/LpConfirmDialog.vue.js +1 -1
  88. package/dist/components/LpConfirmHost.vue.js +1 -1
  89. package/dist/components/LpContextMenu.vue.js +1 -1
  90. package/dist/components/LpDatePicker.vue.js +1 -1
  91. package/dist/components/LpDateRangePicker.vue.js +1 -1
  92. package/dist/components/LpDisclosure.vue.js +1 -1
  93. package/dist/components/LpDrawer.vue.js +1 -1
  94. package/dist/components/LpDropdownMenu.vue.js +1 -1
  95. package/dist/components/LpDropzone.vue.js +1 -1
  96. package/dist/components/LpEmptyState.vue.js +1 -1
  97. package/dist/components/LpFieldNotice.vue.js +1 -1
  98. package/dist/components/LpFileTree.vue.js +1 -1
  99. package/dist/components/LpFileTreeNode.vue.js +1 -1
  100. package/dist/components/LpFormField.vue.js +1 -1
  101. package/dist/components/LpHintLayer.vue.d.ts +33 -0
  102. package/dist/components/LpHintLayer.vue.js +4 -0
  103. package/dist/components/LpHints.vue.d.ts +20 -0
  104. package/dist/components/LpHints.vue.js +4 -0
  105. package/dist/components/LpIcon.vue.js +1 -1
  106. package/dist/components/LpImageEditor.vue.js +2 -2
  107. package/dist/components/LpInput.vue.js +1 -1
  108. package/dist/components/LpLightbox.vue.js +1 -1
  109. package/dist/components/LpLogViewer.vue.js +1 -1
  110. package/dist/components/LpMapPicker.vue.js +1 -1
  111. package/dist/components/LpMediaGallery.vue.js +1 -1
  112. package/dist/components/LpModal.vue.js +1 -1
  113. package/dist/components/LpModalNav.vue.js +1 -1
  114. package/dist/components/LpNotificationBell.vue.js +1 -1
  115. package/dist/components/LpNumberField.vue.js +1 -1
  116. package/dist/components/LpPagination.vue.js +1 -1
  117. package/dist/components/LpPaletteFacts.vue.js +1 -1
  118. package/dist/components/LpPaletteHeader.vue.js +2 -2
  119. package/dist/components/LpPasswordInput.vue.js +1 -1
  120. package/dist/components/LpPhoneInput.vue.js +1 -1
  121. package/dist/components/LpPie.vue.js +1 -1
  122. package/dist/components/LpRangeCalendar.vue.js +1 -1
  123. package/dist/components/LpSection.vue.js +1 -1
  124. package/dist/components/LpSegmented.vue.js +1 -1
  125. package/dist/components/LpSelect.vue.js +1 -1
  126. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  127. package/dist/components/LpSidebar.vue.js +1 -1
  128. package/dist/components/LpSidebarNav.vue.js +1 -1
  129. package/dist/components/LpStat.vue.js +1 -1
  130. package/dist/components/LpStepper.vue.js +1 -1
  131. package/dist/components/LpTable.vue.js +6 -6
  132. package/dist/components/LpTableFilter.vue.js +1 -1
  133. package/dist/components/LpTableOfContents.vue.js +1 -1
  134. package/dist/components/LpTabs.vue.js +1 -1
  135. package/dist/components/LpTagsInput.vue.js +1 -1
  136. package/dist/components/LpTextarea.vue.js +1 -1
  137. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  138. package/dist/components/LpTimeField.vue.js +1 -1
  139. package/dist/components/LpToaster.vue.js +1 -1
  140. package/dist/components/LpTooltip.vue.js +1 -1
  141. package/dist/components/LpTour.vue.d.ts +98 -0
  142. package/dist/components/LpTour.vue.js +4 -0
  143. package/dist/components/LpUptimeBar.vue.js +1 -1
  144. package/dist/components/MenuTitle.vue.js +1 -1
  145. package/dist/components/TourCard.vue.d.ts +28 -0
  146. package/dist/components/TourCard.vue.js +4 -0
  147. package/dist/components/tour.d.ts +21 -0
  148. package/dist/components/tour.js +38 -0
  149. package/dist/composables/scroll.d.ts +17 -0
  150. package/dist/composables/scroll.js +29 -0
  151. package/dist/composables/useGamepadNav.js +2 -7
  152. package/dist/composables/useHintSystem.d.ts +195 -0
  153. package/dist/composables/useHintSystem.js +334 -0
  154. package/dist/composables/useHints.d.ts +18 -0
  155. package/dist/composables/useHints.js +120 -0
  156. package/dist/composables/useMediaQuery.d.ts +2 -0
  157. package/dist/composables/useMediaQuery.js +18 -0
  158. package/dist/composables/usePaintSelect.js +1 -7
  159. package/dist/icons-all.js +1 -1
  160. package/dist/index.d.ts +13 -0
  161. package/dist/index.js +89 -64
  162. package/package.json +1 -1
  163. package/src/component-names.ts +3 -0
  164. package/src/components/LpDateRangePicker.vue +3 -12
  165. package/src/components/LpDrawer.vue +4 -7
  166. package/src/components/LpHintLayer.vue +148 -0
  167. package/src/components/LpHints.vue +116 -0
  168. package/src/components/LpTableOfContents.vue +2 -14
  169. package/src/components/LpTooltip.vue +2 -4
  170. package/src/components/LpTour.vue +358 -0
  171. package/src/components/TourCard.vue +41 -0
  172. package/src/components/tour.ts +57 -0
  173. package/src/composables/scroll.ts +54 -0
  174. package/src/composables/useGamepadNav.ts +2 -7
  175. package/src/composables/useHintSystem.ts +512 -0
  176. package/src/composables/useHints.ts +151 -0
  177. package/src/composables/useMediaQuery.ts +20 -0
  178. package/src/composables/usePaintSelect.ts +1 -8
  179. package/src/index.ts +27 -0
  180. package/src/kit-icons.ts +1 -1
  181. package/src/tokens/tokens.css +1 -0
  182. package/dist/chunks/icons-CkA0njzm.js +0 -43
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import { shallowReactive, type Directive } from "vue"
2
+
3
+ /*
4
+ * Which parts of the interface the user can see right now, and which ones they
5
+ * actually use — the ground truth a tour, a "new" badge or a nudge needs.
6
+ *
7
+ * The element carries its own id (`v-hint="'branches.remind'"`), so a tour is
8
+ * built from what is on screen, in whatever order the app wants: a button the
9
+ * user has no right to is not rendered, therefore not hinted, and nobody keeps
10
+ * a second list of steps that drifts away from the page.
11
+ *
12
+ * Three signals, all per id:
13
+ * mounted — the element is in the DOM (reactive, for "is it here at all");
14
+ * seen — it stayed at least half on screen for SEEN_MS, once per mount,
15
+ * so scrolling past a row of buttons does not count as seeing them;
16
+ * used — the user clicked it. Non-click use (a shortcut, a saved view) is
17
+ * reported with `hints.use(id)`.
18
+ *
19
+ * The kit decides nothing beyond that. Whether to badge, explain or nudge is
20
+ * the app's call, made from these events.
21
+ */
22
+
23
+ export type HintEvent = "seen" | "used" | "hover"
24
+ type Listener = (id: string, el: HTMLElement) => void
25
+
26
+ const SEEN_MS = 800
27
+ const SEEN_RATIO = 0.5
28
+
29
+ const mounted = shallowReactive(new Map<string, Set<HTMLElement>>())
30
+ const listeners: Record<HintEvent, Set<Listener>> = { seen: new Set(), used: new Set(), hover: new Set() }
31
+ const idOf = new WeakMap<HTMLElement, string>()
32
+ const timers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
33
+ const seenOnce = new WeakSet<HTMLElement>()
34
+
35
+ function emit(event: HintEvent, id: string, el: HTMLElement) {
36
+ for (const listener of listeners[event]) listener(id, el)
37
+ }
38
+
39
+ let observer: IntersectionObserver | null = null
40
+
41
+ function watchSight(el: HTMLElement) {
42
+ if (typeof IntersectionObserver === "undefined") {
43
+ seenOnce.add(el)
44
+ emit("seen", idOf.get(el)!, el)
45
+ return
46
+ }
47
+ observer ??= new IntersectionObserver(
48
+ (entries) => {
49
+ for (const entry of entries) {
50
+ const target = entry.target as HTMLElement
51
+ const id = idOf.get(target)
52
+ if (!id || seenOnce.has(target)) continue
53
+ clearTimeout(timers.get(target))
54
+ if (entry.intersectionRatio >= SEEN_RATIO) {
55
+ timers.set(
56
+ target,
57
+ setTimeout(() => {
58
+ seenOnce.add(target)
59
+ observer?.unobserve(target)
60
+ emit("seen", id, target)
61
+ }, SEEN_MS),
62
+ )
63
+ }
64
+ }
65
+ },
66
+ { threshold: [0, SEEN_RATIO] },
67
+ )
68
+ observer.observe(el)
69
+ }
70
+
71
+ function onClick(event: Event) {
72
+ const el = event.currentTarget as HTMLElement
73
+ const id = idOf.get(el)
74
+ if (id) emit("used", id, el)
75
+ }
76
+
77
+ function onHover(event: Event) {
78
+ const el = event.currentTarget as HTMLElement
79
+ const id = idOf.get(el)
80
+ if (id) emit("hover", id, el)
81
+ }
82
+
83
+ function attach(el: HTMLElement, id: string) {
84
+ idOf.set(el, id)
85
+ el.dataset.hint = id
86
+ const set = mounted.get(id) ?? new Set<HTMLElement>()
87
+ set.add(el)
88
+ mounted.set(id, new Set(set))
89
+ el.addEventListener("click", onClick, { capture: true })
90
+ el.addEventListener("pointerenter", onHover)
91
+ el.addEventListener("focusin", onHover)
92
+ watchSight(el)
93
+ }
94
+
95
+ function detach(el: HTMLElement) {
96
+ const id = idOf.get(el)
97
+ if (!id) return
98
+ clearTimeout(timers.get(el))
99
+ observer?.unobserve(el)
100
+ el.removeEventListener("click", onClick, { capture: true })
101
+ el.removeEventListener("pointerenter", onHover)
102
+ el.removeEventListener("focusin", onHover)
103
+ const set = new Set(mounted.get(id))
104
+ set.delete(el)
105
+ if (set.size) mounted.set(id, set)
106
+ else mounted.delete(id)
107
+ idOf.delete(el)
108
+ }
109
+
110
+ /** `v-hint="'page.feature'"` on the element that represents a feature. */
111
+ export const vHint: Directive<HTMLElement, string | null | undefined> = {
112
+ mounted(el, binding) {
113
+ if (binding.value) attach(el, binding.value)
114
+ },
115
+ updated(el, binding) {
116
+ if (binding.value === idOf.get(el)) return
117
+ detach(el)
118
+ if (binding.value) attach(el, binding.value)
119
+ },
120
+ beforeUnmount(el) {
121
+ detach(el)
122
+ },
123
+ }
124
+
125
+ export const hints = {
126
+ /** Ids of everything hinted that is in the DOM now. Reactive. */
127
+ ids(): string[] {
128
+ return [...mounted.keys()]
129
+ },
130
+ /** Whether an element with this id is in the DOM. Reactive. */
131
+ present(id: string): boolean {
132
+ return mounted.has(id)
133
+ },
134
+ /** The first connected element with this id that has a box, or null. */
135
+ element(id: string): HTMLElement | null {
136
+ for (const el of mounted.get(id) ?? []) {
137
+ if (el.isConnected && el.getClientRects().length) return el
138
+ }
139
+ return null
140
+ },
141
+ /** Subscribe to a signal; returns the unsubscribe. */
142
+ on(event: HintEvent, listener: Listener): () => void {
143
+ listeners[event].add(listener)
144
+ return () => listeners[event].delete(listener)
145
+ },
146
+ /** Report use that is not a click on the element. */
147
+ use(id: string): void {
148
+ const el = hints.element(id)
149
+ for (const listener of listeners.used) listener(id, el ?? document.body)
150
+ },
151
+ }
@@ -0,0 +1,20 @@
1
+ import { onBeforeUnmount, onMounted, ref, type Ref } from "vue"
2
+
3
+ /*
4
+ * A media query as a ref. False until mounted, so the server and the hydrating
5
+ * client render the same thing and the client switches right after.
6
+ */
7
+ export function useMediaQuery(query: string): Ref<boolean> {
8
+ const matches = ref(false)
9
+ let list: MediaQueryList | undefined
10
+ const update = () => {
11
+ matches.value = !!list?.matches
12
+ }
13
+ onMounted(() => {
14
+ list = window.matchMedia?.(query)
15
+ update()
16
+ list?.addEventListener("change", update)
17
+ })
18
+ onBeforeUnmount(() => list?.removeEventListener("change", update))
19
+ return matches
20
+ }
@@ -1,5 +1,6 @@
1
1
  import { onBeforeUnmount, onMounted, readonly, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue"
2
2
  import { useHotkeys, type HotkeyHelp } from "./useHotkeys"
3
+ import { scrollParent } from "./scroll"
3
4
 
4
5
  /*
5
6
  * Paint selection: press on one item's tick and sweep across the others, and
@@ -53,14 +54,6 @@ interface Stroke<K> {
53
54
  scroller: HTMLElement | null
54
55
  }
55
56
 
56
- function scrollParent(el: HTMLElement | null): HTMLElement | null {
57
- for (let node = el?.parentElement; node; node = node.parentElement) {
58
- const { overflowY } = getComputedStyle(node)
59
- if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) return node
60
- }
61
- return null
62
- }
63
-
64
57
  export function usePaintSelect<K = string>(options: UsePaintSelectOptions<K>) {
65
58
  const resolve = options.resolve ?? ((raw: string) => raw as unknown as K)
66
59
  let stroke: Stroke<K> | null = null