@leavepulse/ui 0.58.1 → 0.60.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 (173) hide show
  1. package/dist/assets/{LpCommandPalette-DmQUsJqA.css → LpCommandPalette-DA5pmuPi.css} +21 -21
  2. package/dist/assets/{LpTable-BNzavdlv.css → LpTable-BZZf_rrt.css} +19 -19
  3. package/dist/chunks/{ContextMenuItems-BY5sTVQF.js → ContextMenuItems-qXReFtQW.js} +2 -2
  4. package/dist/chunks/{LayoutCanvas-BcGLJVBq.js → LayoutCanvas-t7LWFCEn.js} +1 -1
  5. package/dist/chunks/{LayoutNode-BkBn6dvM.js → LayoutNode-DHsE6GwM.js} +1 -1
  6. package/dist/chunks/{LpActionBar-DCN-_pZP.js → LpActionBar-BsSp66S_.js} +2 -2
  7. package/dist/chunks/{LpAddressInput-Dio6Sqed.js → LpAddressInput-DD4zTN4P.js} +7 -7
  8. package/dist/chunks/{LpAlert-CXMn1GYE.js → LpAlert--KLL4NLk.js} +1 -1
  9. package/dist/chunks/{LpAppShell-Be6T96td.js → LpAppShell-Cv-Y9qNA.js} +3 -3
  10. package/dist/chunks/{LpAutocomplete-hJ_6CHlh.js → LpAutocomplete-PnL2ntKl.js} +1 -1
  11. package/dist/chunks/{LpAvatar-BxNs23Xu.js → LpAvatar-CwGs1JCQ.js} +2 -2
  12. package/dist/chunks/{LpBreadcrumbs-BLzOEgAF.js → LpBreadcrumbs-CndzPYiG.js} +1 -1
  13. package/dist/chunks/{LpButton-vhlwmpBy.js → LpButton-CJSD7N0Z.js} +1 -1
  14. package/dist/chunks/{LpCalendar-BLoE8Qm7.js → LpCalendar-CXVDbb2M.js} +1 -1
  15. package/dist/chunks/{LpCard-DhR7u0d8.js → LpCard-B2D0GIUU.js} +1 -1
  16. package/dist/chunks/{LpCheckbox-B_C8sOoT.js → LpCheckbox-DV9W6T3H.js} +1 -1
  17. package/dist/chunks/{LpCodeBlock-CXsqJwDy.js → LpCodeBlock-t6MLA3fb.js} +5 -5
  18. package/dist/chunks/{LpColorPicker-DQ88niXq.js → LpColorPicker-CvBF8TeL.js} +6 -6
  19. package/dist/chunks/{LpConfirmDialog-DxlSdjmk.js → LpConfirmDialog-tkgyY3eu.js} +3 -3
  20. package/dist/chunks/{LpConfirmHost-BcrmTNu_.js → LpConfirmHost-CoYIcUdl.js} +1 -1
  21. package/dist/chunks/{LpContextMenu-B1NGfpp1.js → LpContextMenu-BnN1k7ga.js} +17 -5
  22. package/dist/chunks/{LpDatePicker-B-VMHMgy.js → LpDatePicker-DxdrVNCG.js} +3 -3
  23. package/dist/chunks/{LpDateRangePicker-B68KPKPl.js → LpDateRangePicker-DjeaqikC.js} +2 -2
  24. package/dist/chunks/{LpDisclosure-Ct-VBHxt.js → LpDisclosure-s8cQ7KQq.js} +1 -1
  25. package/dist/chunks/{LpDrawer-DSknigxH.js → LpDrawer-C97yoWmh.js} +2 -2
  26. package/dist/chunks/{LpDropdownMenu-C7oHVlKY.js → LpDropdownMenu-CbyYu4Rw.js} +12 -5
  27. package/dist/chunks/{LpEmptyState-CBwwkkdA.js → LpEmptyState-CX8fdGSU.js} +1 -1
  28. package/dist/chunks/{LpFieldNotice-9hH4ITZa.js → LpFieldNotice-B--RzKfj.js} +1 -1
  29. package/dist/chunks/{LpFileTree-CMA0_rsB.js → LpFileTree-DEqh2ySW.js} +2 -2
  30. package/dist/chunks/{LpFileTreeNode-B4T258hF.js → LpFileTreeNode-DCZA7sQ-.js} +3 -3
  31. package/dist/chunks/{LpFormField-BpQ6WJPe.js → LpFormField-DMPPL1jF.js} +1 -1
  32. package/dist/chunks/{LpHintLayer-B5-6FyEf.js → LpHintLayer-DsclGYcD.js} +1 -1
  33. package/dist/chunks/{LpHints-BqoZXHZk.js → LpHints-BTpvisgP.js} +10 -4
  34. package/dist/chunks/{LpIcon-B5guGKrW.js → LpIcon-CTuDVTYv.js} +1 -1
  35. package/dist/chunks/{LpInput-WvyP02ID.js → LpInput-C7FHxzj2.js} +2 -2
  36. package/dist/chunks/{LpLightbox-Dq-e36uT.js → LpLightbox-DiksFa4L.js} +1 -1
  37. package/dist/chunks/{LpLogViewer-DwiYE32z.js → LpLogViewer-BuPe23sZ.js} +2 -2
  38. package/dist/chunks/{LpMapPicker-BH5csaBt.js → LpMapPicker-DybIlWnF.js} +1 -1
  39. package/dist/chunks/{LpMediaGallery-B1kDypJ-.js → LpMediaGallery-Cic9UHsa.js} +2 -2
  40. package/dist/chunks/{LpModal-BxYssnXg.js → LpModal-QFfctZsj.js} +2 -2
  41. package/dist/chunks/{LpModalNav-Bn2ws64b.js → LpModalNav-Cw7m-eks.js} +4 -4
  42. package/dist/chunks/{LpNotificationBell-DJfs4JjQ.js → LpNotificationBell-xH_JphpS.js} +6 -6
  43. package/dist/chunks/{LpNumberField-CJPjmoDz.js → LpNumberField-BFSRVGkJ.js} +1 -1
  44. package/dist/chunks/{LpPagination-DehAaJLY.js → LpPagination-BFl0I7tL.js} +1 -1
  45. package/dist/chunks/{LpPasswordInput-Bg5uTZbx.js → LpPasswordInput-C1G4k_r0.js} +3 -3
  46. package/dist/chunks/{LpPhoneInput-DStwcbnQ.js → LpPhoneInput-CZrpVxla.js} +3 -3
  47. package/dist/chunks/{LpPie-DeTkbbOK.js → LpPie-BD5Lsh-2.js} +3 -3
  48. package/dist/chunks/{LpRangeCalendar-D5sDID9W.js → LpRangeCalendar-COeOYF52.js} +1 -1
  49. package/dist/chunks/{LpSection-B19VPYmr.js → LpSection-BAkbf0OK.js} +1 -1
  50. package/dist/chunks/{LpSegmented-D3Eam8sa.js → LpSegmented-DslFku0N.js} +3 -3
  51. package/dist/chunks/{LpSelect-DynjWK1A.js → LpSelect-C4OXN_q3.js} +1 -1
  52. package/dist/chunks/{LpSidebar-CshW0fqC.js → LpSidebar-B3E_iViL.js} +3 -3
  53. package/dist/chunks/{LpSidebarNav-C9b3i9G2.js → LpSidebarNav-32sAXNM-.js} +4 -4
  54. package/dist/chunks/{LpStat-BS2TEXYK.js → LpStat-oYk6V1LZ.js} +1 -1
  55. package/dist/chunks/{LpStepper-BSglWF45.js → LpStepper-YgHGzgfD.js} +1 -1
  56. package/dist/chunks/{LpTableFilter-CwHXVxJt.js → LpTableFilter-Ba-rS4H1.js} +2 -2
  57. package/dist/chunks/{LpTabs-BBGRHY38.js → LpTabs-00v3VQx4.js} +7 -6
  58. package/dist/chunks/{LpTagsInput-CHbQoAAA.js → LpTagsInput-Sc56iy_j.js} +3 -3
  59. package/dist/chunks/{LpTextarea-Bz2IWhTH.js → LpTextarea-CQZccQI3.js} +1 -1
  60. package/dist/chunks/{LpThemeSwitcher-B_qhSl7q.js → LpThemeSwitcher-CeBG0e_D.js} +2 -2
  61. package/dist/chunks/{LpTimeField-DvGXgdES.js → LpTimeField-BTEt0kvL.js} +1 -1
  62. package/dist/chunks/{LpToaster-Bo9Q4QBP.js → LpToaster-BcKs7vX7.js} +3 -3
  63. package/dist/chunks/{LpTour-D7m9ZxNk.js → LpTour-C29-1wha.js} +4 -1
  64. package/dist/chunks/{MenuTitle-DQP6hc2N.js → MenuTitle-FeC6I24N.js} +3 -3
  65. package/dist/chunks/{TourCard-DCHOjr6e.js → TourCard-AjN0uhs1.js} +2 -2
  66. package/dist/chunks/{icons-QqBscG5I.js → icons-CuO3W46H.js} +1 -1
  67. package/dist/components/ContextMenuItems.vue.js +1 -1
  68. package/dist/components/LayoutCanvas.vue.js +1 -1
  69. package/dist/components/LayoutNode.vue.js +1 -1
  70. package/dist/components/LpActionBar.vue.js +1 -1
  71. package/dist/components/LpAddressInput.vue.js +1 -1
  72. package/dist/components/LpAlert.vue.js +1 -1
  73. package/dist/components/LpAppShell.vue.js +1 -1
  74. package/dist/components/LpAutocomplete.vue.js +1 -1
  75. package/dist/components/LpAvatar.vue.js +1 -1
  76. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  77. package/dist/components/LpButton.vue.js +1 -1
  78. package/dist/components/LpCalendar.vue.js +1 -1
  79. package/dist/components/LpCard.vue.js +1 -1
  80. package/dist/components/LpChart.vue.js +4 -4
  81. package/dist/components/LpCheckbox.vue.js +1 -1
  82. package/dist/components/LpCodeBlock.vue.js +1 -1
  83. package/dist/components/LpColorPicker.vue.js +1 -1
  84. package/dist/components/LpCommandPalette.vue.js +38 -27
  85. package/dist/components/LpConfirmDialog.vue.js +1 -1
  86. package/dist/components/LpConfirmHost.vue.js +1 -1
  87. package/dist/components/LpContextMenu.vue.d.ts +7 -2
  88. package/dist/components/LpContextMenu.vue.js +1 -1
  89. package/dist/components/LpDatePicker.vue.js +1 -1
  90. package/dist/components/LpDateRangePicker.vue.js +1 -1
  91. package/dist/components/LpDisclosure.vue.js +1 -1
  92. package/dist/components/LpDrawer.vue.js +1 -1
  93. package/dist/components/LpDropdownMenu.vue.d.ts +7 -2
  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.js +1 -1
  102. package/dist/components/LpHints.vue.js +1 -1
  103. package/dist/components/LpIcon.vue.js +1 -1
  104. package/dist/components/LpImageEditor.vue.js +4 -2
  105. package/dist/components/LpInput.vue.js +1 -1
  106. package/dist/components/LpLightbox.vue.js +1 -1
  107. package/dist/components/LpLogViewer.vue.js +1 -1
  108. package/dist/components/LpMapPicker.vue.js +1 -1
  109. package/dist/components/LpMediaGallery.vue.js +1 -1
  110. package/dist/components/LpModal.vue.js +1 -1
  111. package/dist/components/LpModalNav.vue.js +1 -1
  112. package/dist/components/LpNotificationBell.vue.js +1 -1
  113. package/dist/components/LpNumberField.vue.js +1 -1
  114. package/dist/components/LpPagination.vue.js +1 -1
  115. package/dist/components/LpPaletteFacts.vue.js +1 -1
  116. package/dist/components/LpPaletteHeader.vue.js +2 -2
  117. package/dist/components/LpPasswordInput.vue.js +1 -1
  118. package/dist/components/LpPhoneInput.vue.js +1 -1
  119. package/dist/components/LpPie.vue.js +1 -1
  120. package/dist/components/LpRangeCalendar.vue.js +1 -1
  121. package/dist/components/LpSection.vue.js +1 -1
  122. package/dist/components/LpSegmented.vue.js +1 -1
  123. package/dist/components/LpSelect.vue.js +1 -1
  124. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  125. package/dist/components/LpSidebar.vue.js +1 -1
  126. package/dist/components/LpSidebarNav.vue.js +1 -1
  127. package/dist/components/LpStat.vue.js +1 -1
  128. package/dist/components/LpStepper.vue.js +1 -1
  129. package/dist/components/LpTable.vue.js +13 -12
  130. package/dist/components/LpTableFilter.vue.js +1 -1
  131. package/dist/components/LpTabs.vue.d.ts +7 -0
  132. package/dist/components/LpTabs.vue.js +1 -1
  133. package/dist/components/LpTagsInput.vue.js +1 -1
  134. package/dist/components/LpTextarea.vue.js +1 -1
  135. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  136. package/dist/components/LpTimeField.vue.js +1 -1
  137. package/dist/components/LpToaster.vue.js +1 -1
  138. package/dist/components/LpTour.vue.js +1 -1
  139. package/dist/components/MenuTitle.vue.js +1 -1
  140. package/dist/components/TourCard.vue.js +1 -1
  141. package/dist/components/dropdown.js +1 -1
  142. package/dist/composables/useHintSystem.d.ts +65 -0
  143. package/dist/composables/useHintSystem.js +98 -12
  144. package/dist/icons-all.js +1 -1
  145. package/dist/index.d.ts +2 -2
  146. package/dist/index.js +62 -58
  147. package/package.json +1 -1
  148. package/src/components/ContextMenuItems.vue +1 -1
  149. package/src/components/LpChart.vue +1 -1
  150. package/src/components/LpCodeBlock.vue +4 -4
  151. package/src/components/LpColorPicker.vue +4 -4
  152. package/src/components/LpCommandPalette.vue +36 -14
  153. package/src/components/LpContextMenu.vue +19 -1
  154. package/src/components/LpDrawer.vue +1 -1
  155. package/src/components/LpDropdownMenu.vue +14 -3
  156. package/src/components/LpHints.vue +7 -1
  157. package/src/components/LpModal.vue +1 -1
  158. package/src/components/LpNotificationBell.vue +2 -2
  159. package/src/components/LpPasswordInput.vue +1 -1
  160. package/src/components/LpPhoneInput.vue +1 -1
  161. package/src/components/LpPie.vue +1 -1
  162. package/src/components/LpSegmented.vue +2 -2
  163. package/src/components/LpSidebarNav.vue +2 -2
  164. package/src/components/LpTable.vue +3 -3
  165. package/src/components/LpTabs.vue +11 -5
  166. package/src/components/LpToaster.vue +2 -2
  167. package/src/components/LpTour.vue +3 -0
  168. package/src/components/MenuTitle.vue +2 -2
  169. package/src/components/dropdown.ts +1 -1
  170. package/src/composables/useHintSystem.ts +130 -12
  171. package/src/index.ts +2 -1
  172. package/src/kit-icons.ts +1 -1
  173. package/src/tokens/tokens.css +6 -0
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import { ContextMenuContent, ContextMenuPortal, ContextMenuRoot, ContextMenuTrigger } from "reka-ui"
10
10
  import { computed } from "vue"
11
+ import { hints } from "../composables/useHints"
12
+ import { HINT_SIGNALS } from "../composables/useHintSystem"
11
13
  import ContextMenuItems from "./ContextMenuItems.vue"
12
14
  import MenuTitle from "./MenuTitle.vue"
13
15
  import { POPOVER_PANEL } from "./dropdown"
@@ -52,6 +54,11 @@ const props = defineProps<{
52
54
  /** An icon beside the title, or `titleImage` for a picture (an avatar, a logo). */
53
55
  titleIcon?: string
54
56
  titleImage?: string
57
+ /**
58
+ * Do not count this element as "has a right-click menu" for hint tips: for
59
+ * menus that only hold generic items (a whole table without row actions).
60
+ */
61
+ noHint?: boolean
55
62
  }>()
56
63
 
57
64
  // With no items the trigger is disabled, so the element keeps the browser's
@@ -59,6 +66,7 @@ const props = defineProps<{
59
66
  // per element. `always` overrides this so a late-populated menu is ready on the
60
67
  // very first click.
61
68
  const enabled = computed(() => props.always || props.items.length > 0)
69
+ const hinted = computed(() => enabled.value && !props.noHint)
62
70
 
63
71
  const PANEL = `${POPOVER_PANEL} z-(--z-popover) min-w-48 p-1 outline-none`
64
72
 
@@ -91,6 +99,14 @@ function keepContextMenu(event: Event) {
91
99
  if (enabled.value) event.stopPropagation()
92
100
  }
93
101
 
102
+ function opened(open: boolean) {
103
+ if (open) hints.use(HINT_SIGNALS.contextMenu)
104
+ }
105
+
106
+ function leftClick() {
107
+ if (hinted.value) hints.use(HINT_SIGNALS.contextTarget)
108
+ }
109
+
94
110
  function returnFocus(event: Event) {
95
111
  const saved = field
96
112
  field = null
@@ -106,10 +122,12 @@ function returnFocus(event: Event) {
106
122
  the reka trigger remounted the wrapped content whenever items arrived
107
123
  late, losing its state and focus. A disabled trigger leaves the browser's
108
124
  own menu in place. -->
109
- <ContextMenuRoot>
125
+ <ContextMenuRoot @update:open="opened">
110
126
  <ContextMenuTrigger
111
127
  as-child
112
128
  :disabled="!enabled"
129
+ :data-lp-context-menu="hinted ? '' : undefined"
130
+ @click.capture="leftClick"
113
131
  @contextmenu.capture="rememberField"
114
132
  @contextmenu="keepContextMenu"
115
133
  >
@@ -552,7 +552,7 @@ const previewStyle = computed(() => {
552
552
  </DrawerDescription>
553
553
  </div>
554
554
  <DrawerClose
555
- class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
555
+ class="group flex shrink-0 items-center rounded-inner p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
556
556
  :aria-label="text.close"
557
557
  >
558
558
  <LpIcon
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { hints } from "../composables/useHints"
3
+ import { HINT_SIGNALS } from "../composables/useHintSystem"
2
4
  import {
3
5
  DropdownMenuContent,
4
6
  DropdownMenuItem,
@@ -20,18 +22,27 @@ export interface MenuItem {
20
22
  onSelect?: () => void
21
23
  }
22
24
 
23
- defineProps<{
25
+ const props = defineProps<{
24
26
  items: MenuItem[]
25
27
  /** A heading naming what the menu acts on; see LpContextMenu's `title`. */
26
28
  title?: string
27
29
  subtitle?: string
28
30
  titleIcon?: string
29
31
  titleImage?: string
32
+ /**
33
+ * The same items are also on a right click (a row's "…" next to its context
34
+ * menu). Opening it then counts toward the hint system's right-click tip.
35
+ */
36
+ rightClickToo?: boolean
30
37
  }>()
38
+
39
+ function opened(open: boolean) {
40
+ if (open && props.rightClickToo) hints.use(HINT_SIGNALS.menuButton)
41
+ }
31
42
  </script>
32
43
 
33
44
  <template>
34
- <DropdownMenuRoot>
45
+ <DropdownMenuRoot @update:open="opened">
35
46
  <DropdownMenuTrigger as-child>
36
47
  <slot name="trigger" />
37
48
  </DropdownMenuTrigger>
@@ -47,7 +58,7 @@ defineProps<{
47
58
  <template v-for="(item, i) in items" :key="i">
48
59
  <DropdownMenuSeparator v-if="item.separatorBefore" class="my-1 h-px bg-line" />
49
60
  <DropdownMenuItem
50
- class="group/item flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-brand-soft"
61
+ class="group/item flex cursor-pointer items-center gap-2 rounded-inner px-2.5 py-1.5 text-sm outline-none transition-[background-color,color] duration-[var(--duration-fast)] data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-brand-soft"
51
62
  :class="item.danger ? 'text-danger data-[highlighted]:bg-danger-soft data-[highlighted]:text-danger' : 'text-ink data-[highlighted]:text-brand'"
52
63
  :disabled="item.disabled"
53
64
  @select="item.onSelect?.()"
@@ -68,6 +68,7 @@ const title = computed(() => {
68
68
  const o = offer.value
69
69
  if (!o) return ""
70
70
  if (o.kind === "nudge") return notes.value[o.nudge.feature]?.title ?? ""
71
+ if (o.kind === "tip") return o.tip.title
71
72
  if (o.kind === "news") return fill(labels.value.newsTitle)
72
73
  return fill(o.kind === "assigned" ? labels.value.assignedTitle : labels.value.tourTitle)
73
74
  })
@@ -76,6 +77,7 @@ const description = computed(() => {
76
77
  const o = offer.value
77
78
  if (!o) return ""
78
79
  if (o.kind === "nudge") return o.nudge.text
80
+ if (o.kind === "tip") return o.tip.keys ? `${o.tip.text} ${o.tip.keys}` : o.tip.text
79
81
  if (o.kind === "news") return o.items.map((n) => n.text).join(" ")
80
82
  return o.kind === "assigned" ? labels.value.assignedText : labels.value.tourText
81
83
  })
@@ -86,6 +88,10 @@ const actions = computed<ActionBarAction[]>(() => {
86
88
  const answer = (choice: "show" | "later" | "never") => () => props.system.answer(choice)
87
89
  const show: ActionBarAction = { label: labels.value.show, icon: "lucide:sparkles", onClick: answer("show") }
88
90
  if (o.kind === "nudge") return [{ label: labels.value.noThanks, variant: "ghost", onClick: answer("never") }, show]
91
+ if (o.kind === "tip") {
92
+ const later: ActionBarAction = { label: labels.value.gotIt, variant: o.tip.step ? "ghost" : undefined, onClick: answer("later") }
93
+ return [{ label: labels.value.never, variant: "ghost", onClick: answer("never") }, later, ...(o.tip.step ? [show] : [])]
94
+ }
89
95
  if (o.kind === "news") return [{ label: labels.value.gotIt, variant: "ghost", onClick: answer("later") }, show]
90
96
  return [
91
97
  ...(o.kind === "tour" ? [{ label: labels.value.never, variant: "ghost" as const, onClick: answer("never") }] : []),
@@ -109,7 +115,7 @@ const actions = computed<ActionBarAction[]>(() => {
109
115
  :open="!!offer"
110
116
  :title="title"
111
117
  :description="description"
112
- :icon="offer?.kind === 'news' ? 'lucide:gift' : 'lucide:compass'"
118
+ :icon="offer?.kind === 'news' ? 'lucide:gift' : offer?.kind === 'tip' ? 'lucide:lightbulb' : 'lucide:compass'"
113
119
  :actions="actions"
114
120
  />
115
121
  </template>
@@ -350,7 +350,7 @@ watch(
350
350
  and last in usefulness. Still reachable by Tab and Escape. -->
351
351
  <DialogClose
352
352
  data-lp-dialog-close
353
- class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
353
+ class="group flex shrink-0 items-center rounded-inner p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
354
354
  :aria-label="text.close"
355
355
  >
356
356
  <LpIcon
@@ -321,7 +321,7 @@ function timeAgo(iso?: string): string {
321
321
  <button
322
322
  v-if="items.length && handlesClear"
323
323
  type="button"
324
- class="rounded-md px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors hover:bg-surface-soft hover:text-ink focus-visible:bg-surface-soft"
324
+ class="rounded-inner px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors hover:bg-surface-soft hover:text-ink focus-visible:bg-surface-soft"
325
325
  @click="emit('clear')"
326
326
  >
327
327
  {{ l.clear }}
@@ -329,7 +329,7 @@ function timeAgo(iso?: string): string {
329
329
  <button
330
330
  v-if="hasUnread"
331
331
  type="button"
332
- class="rounded-md px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
332
+ class="rounded-inner px-1.5 py-0.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:bg-brand-soft"
333
333
  @click="emit('markAllRead')"
334
334
  >
335
335
  {{ l.markAllRead }}
@@ -53,7 +53,7 @@ defineExpose({
53
53
  <template #trailing>
54
54
  <button
55
55
  type="button"
56
- class="group rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:text-ink"
56
+ class="group rounded-inner p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:text-ink"
57
57
  :aria-label="visible ? text.hide : text.show"
58
58
  @click="visible = !visible"
59
59
  >
@@ -398,7 +398,7 @@ const inputAttrs = computed(() => {
398
398
  :model-value="query"
399
399
  :placeholder="searchPlaceholder"
400
400
  auto-focus
401
- class="h-(--size-control-sm) w-full rounded-md border border-line bg-surface-soft px-2.5 text-sm text-ink outline-none placeholder:text-muted focus:border-brand"
401
+ class="h-(--size-control-sm) w-full rounded-inner border border-line bg-surface-soft px-2.5 text-sm text-ink outline-none placeholder:text-muted focus:border-brand"
402
402
  @input="query = ($event.target as HTMLInputElement).value"
403
403
  />
404
404
  </div>
@@ -835,7 +835,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
835
835
  v-for="(a, i) in arcs"
836
836
  :key="`${i}-${a.label}`"
837
837
  type="button"
838
- class="group flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs outline-none transition-[opacity,background-color] duration-fast focus-visible:ring-2 focus-visible:ring-ring"
838
+ class="group flex cursor-pointer items-center gap-2 rounded-inner px-1 py-0.5 text-xs outline-none transition-[opacity,background-color] duration-fast focus-visible:ring-2 focus-visible:ring-ring"
839
839
  :class="[dimmed(i) ? 'opacity-45' : '', isSelected(i) ? 'bg-surface-soft' : '']"
840
840
  :aria-pressed="isSelected(i)"
841
841
  @pointerenter="onSlicePointerEnter(i)"
@@ -95,7 +95,7 @@ const pillTransition = usePillTransition()
95
95
  :key="opt.value"
96
96
  :value="opt.value"
97
97
  :aria-label="opt.ariaLabel ?? (opt.label ? undefined : opt.value)"
98
- class="relative inline-flex h-full items-center justify-center gap-1.5 rounded-md px-3 font-medium outline-none transition-colors duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] focus-visible:ring-2 focus-visible:ring-ring data-[state=on]:text-ink data-[state=off]:text-muted data-[state=off]:hover:text-ink disabled:cursor-not-allowed disabled:opacity-55"
98
+ class="relative inline-flex h-full items-center justify-center gap-1.5 rounded-inner px-3 font-medium outline-none transition-colors duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] focus-visible:ring-2 focus-visible:ring-ring data-[state=on]:text-ink data-[state=off]:text-muted data-[state=off]:hover:text-ink disabled:cursor-not-allowed disabled:opacity-55"
99
99
  :class="block ? 'flex-1' : ''"
100
100
  >
101
101
  <!-- Sliding pill behind the active segment. -->
@@ -103,7 +103,7 @@ const pillTransition = usePillTransition()
103
103
  v-if="opt.value === modelValue"
104
104
  :layout-id="pillId"
105
105
  :transition="pillTransition"
106
- class="absolute inset-0 z-0 rounded-md border border-line bg-surface-raised shadow-sm"
106
+ class="absolute inset-0 z-0 rounded-inner border border-line bg-surface-raised shadow-sm"
107
107
  />
108
108
  <LpIcon v-if="opt.icon" :name="opt.icon" :size="14" class="relative z-10 shrink-0" />
109
109
  <span v-if="opt.label" class="relative z-10">{{ opt.label }}</span>
@@ -103,8 +103,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
103
103
  <div v-if="skeletonHeader" class="flex items-center gap-3 px-1">
104
104
  <div class="size-10 shrink-0 animate-pulse rounded-pill bg-surface-soft" />
105
105
  <div class="flex-1 space-y-2">
106
- <div class="h-3.5 w-24 animate-pulse rounded bg-surface-soft" />
107
- <div class="h-3 w-16 animate-pulse rounded bg-surface-soft/60" />
106
+ <div class="h-3.5 w-24 animate-pulse rounded-small bg-surface-soft" />
107
+ <div class="h-3 w-16 animate-pulse rounded-small bg-surface-soft/60" />
108
108
  </div>
109
109
  </div>
110
110
  <div class="space-y-1">
@@ -1157,7 +1157,7 @@ const barInsetTop = computed(() =>
1157
1157
  <!-- The table-wide menu, which is what a right-click on empty header space
1158
1158
  reaches: rows and headers carry their own and take the event first, so
1159
1159
  this is the fallback rather than a competitor. -->
1160
- <LpContextMenu :items="contextMenu" always>
1160
+ <LpContextMenu :items="contextMenu" always :no-hint="!rowMenu">
1161
1161
  <table ref="tableEl" class="w-full border-collapse text-sm">
1162
1162
  <!-- The background belongs on `thead` itself, not only on its row:
1163
1163
  `border-collapse` collapses the borders, and with a sticky header the
@@ -1201,7 +1201,7 @@ const barInsetTop = computed(() =>
1201
1201
  <LpContextMenu v-if="col.sortable || col.filterable" :items="headerMenu(col)">
1202
1202
  <button
1203
1203
  type="button"
1204
- class="group/sort -mx-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1204
+ class="group/sort -mx-1 inline-flex items-center gap-1 rounded-small px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1205
1205
  :class="[
1206
1206
  col.align === 'right' ? 'flex-row-reverse' : '',
1207
1207
  activeSort?.key === col.key ? 'text-ink' : '',
@@ -1232,7 +1232,7 @@ const barInsetTop = computed(() =>
1232
1232
  <template #trigger>
1233
1233
  <button
1234
1234
  type="button"
1235
- class="ml-0.5 inline-grid size-5 place-items-center rounded align-middle outline-none transition-colors duration-fast hover:bg-surface-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1235
+ class="ml-0.5 inline-grid size-5 place-items-center rounded-small align-middle outline-none transition-colors duration-fast hover:bg-surface-raised hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
1236
1236
  :class="hiddenValues[col.key]?.size ? 'text-brand' : 'opacity-60'"
1237
1237
  :aria-label="text.filterColumn.replace('{column}', col.label)"
1238
1238
  :data-lp-filter="col.key"
@@ -38,8 +38,14 @@ const props = withDefaults(
38
38
  block?: boolean
39
39
  /** Accessible name of the tab list, e.g. "Main" for an app's navigation. */
40
40
  label?: string
41
+ /**
42
+ * The marker glides under the hovered tab and springs back on leave (the
43
+ * default). False pins it to the selected tab: calmer for a dense header
44
+ * where the pointer crosses the bar on its way elsewhere.
45
+ */
46
+ hoverFollow?: boolean
41
47
  }>(),
42
- { variant: "contained", accent: false, block: false },
48
+ { variant: "contained", accent: false, block: false, hoverFollow: true },
43
49
  )
44
50
 
45
51
  const bottom = computed(() => props.variant === "bottom")
@@ -154,12 +160,12 @@ function releaseFocusAfterPointer(event: PointerEvent) {
154
160
  ? 'min-h-14 min-w-0 flex-col gap-0.5 px-1 py-1.5 text-[11px] font-medium focus-visible:ring-inset'
155
161
  : 'shrink-0 gap-1.5 whitespace-nowrap text-sm',
156
162
  block && !bottom ? 'flex-1' : '',
157
- variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : '',
158
- variant === 'contained' || variant === 'plain' ? 'rounded-md px-3 py-1.5' : '',
163
+ variant === 'underline' ? 'rounded-t-inner px-3 pt-1.5 pb-2' : '',
164
+ variant === 'contained' || variant === 'plain' ? 'rounded-inner px-3 py-1.5' : '',
159
165
  accent ? 'data-[state=active]:text-brand' : 'data-[state=active]:text-ink',
160
166
  hovered === item.value && !bottom ? 'text-ink' : 'text-muted',
161
167
  ]"
162
- @pointerenter="hovered = item.value"
168
+ @pointerenter="hoverFollow && (hovered = item.value)"
163
169
  @pointerup="releaseFocusAfterPointer"
164
170
  >
165
171
  <!-- Active marker: a bar under the tab in "underline", over it in
@@ -180,7 +186,7 @@ function releaseFocusAfterPointer(event: PointerEvent) {
180
186
  accent ? 'bg-brand' : 'bg-ink',
181
187
  ]
182
188
  : [
183
- 'absolute inset-0 z-0 rounded-md shadow-sm',
189
+ 'absolute inset-0 z-0 rounded-inner shadow-sm',
184
190
  accent
185
191
  ? 'border border-brand/35 bg-brand/12'
186
192
  : 'border border-line bg-surface-raised',
@@ -305,7 +305,7 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
305
305
  <button
306
306
  v-if="copyable(t)"
307
307
  type="button"
308
- class="-mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
308
+ class="-mt-1 rounded-inner p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
309
309
  :aria-label="copiedId === tag(t) ? text.copied : text.copy"
310
310
  :title="copiedId === tag(t) ? text.copied : text.copy"
311
311
  @click.stop="copyToast(t)"
@@ -314,7 +314,7 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
314
314
  </button>
315
315
  <button
316
316
  type="button"
317
- class="group -mr-1 -mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
317
+ class="group -mr-1 -mt-1 rounded-inner p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
318
318
  :aria-label="text.dismiss"
319
319
  @click.stop="beginClose(t)"
320
320
  >
@@ -270,10 +270,12 @@ watch(
270
270
  if (value) {
271
271
  direction = 1
272
272
  document.addEventListener("click", onTargetClick, true)
273
+ document.addEventListener("contextmenu", onTargetClick, true)
273
274
  frame ||= requestAnimationFrame(track)
274
275
  void show()
275
276
  } else {
276
277
  document.removeEventListener("click", onTargetClick, true)
278
+ document.removeEventListener("contextmenu", onTargetClick, true)
277
279
  end()
278
280
  }
279
281
  },
@@ -286,6 +288,7 @@ watch(index, () => {
286
288
 
287
289
  onBeforeUnmount(() => {
288
290
  document.removeEventListener("click", onTargetClick, true)
291
+ document.removeEventListener("contextmenu", onTargetClick, true)
289
292
  if (open.value) end()
290
293
  })
291
294
 
@@ -18,10 +18,10 @@ defineProps<{
18
18
 
19
19
  <template>
20
20
  <div class="flex max-w-72 min-w-0 items-center gap-2 px-2.5 pt-1.5 pb-2" aria-hidden="true">
21
- <img v-if="image" :src="image" alt="" class="size-7 shrink-0 rounded-md object-cover" />
21
+ <img v-if="image" :src="image" alt="" class="size-7 shrink-0 rounded-inner object-cover" />
22
22
  <span
23
23
  v-else-if="icon"
24
- class="grid size-7 shrink-0 place-items-center rounded-md bg-surface-soft text-muted"
24
+ class="grid size-7 shrink-0 place-items-center rounded-inner bg-surface-soft text-muted"
25
25
  >
26
26
  <LpIcon :name="icon" :size="15" />
27
27
  </span>
@@ -49,7 +49,7 @@ export const POPOVER_PANEL = PANEL_SURFACE + " " + POPOVER_ANIM
49
49
  * stagger. `flex` + gap are included; add justify/extra layout if needed.
50
50
  */
51
51
  export const OPTION_ITEM =
52
- "flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-ink outline-none " +
52
+ "flex cursor-pointer items-center gap-2 rounded-inner px-2.5 py-1.5 text-sm text-ink outline-none " +
53
53
  "animate-[option-in_180ms_var(--ease-emphasized)_both] " +
54
54
  "group-data-[side=top]:animate-[option-in-up_180ms_var(--ease-emphasized)_both] " +
55
55
  "data-[highlighted]:bg-brand-soft data-[highlighted]:text-brand"
@@ -1,4 +1,4 @@
1
- import { computed, inject, provide, reactive, watch, type ComputedRef, type InjectionKey, type WatchStopHandle } from "vue"
1
+ import { computed, inject, onBeforeUnmount, provide, reactive, shallowRef, watch, type ComputedRef, type InjectionKey, type WatchStopHandle } from "vue"
2
2
  import type { HintNote } from "../components/LpHintLayer.vue"
3
3
  import type { TourContext, TourStep } from "../components/LpTour.vue"
4
4
  import { anyModalOpen } from "../components/modalDepth"
@@ -76,6 +76,69 @@ export interface HintNudge {
76
76
  text: string
77
77
  }
78
78
 
79
+ /**
80
+ * A better way to do something the user keeps doing the slow way: shown now
81
+ * and then (at most `max` times, `nudgeAgain` apart) and never again once the
82
+ * better way is used. `when` reads usage counters — kit components report
83
+ * their own (see HINT_SIGNALS), and `hints.use(name)` adds any of the app's.
84
+ */
85
+ export interface HintTip {
86
+ id: string
87
+ title: string
88
+ text: string
89
+ keys?: string
90
+ max?: number
91
+ when: (count: (signal: string) => number) => boolean
92
+ /** What "show me" lights up on the current screen; without it the tip only informs. */
93
+ step?: () => TourStep | null
94
+ }
95
+
96
+ /** Usage the kit reports by itself. */
97
+ export const HINT_SIGNALS = {
98
+ /** A context menu was opened (right click or long press). */
99
+ contextMenu: "lp.context-menu",
100
+ /** A "…" menu marked `rightClickToo` was opened: its items were one right click away. */
101
+ menuButton: "lp.menu-button",
102
+ /** A left click on something that also has a context menu. */
103
+ contextTarget: "lp.context-target",
104
+ /** The command palette was opened. */
105
+ palette: "lp.palette",
106
+ /** The current hint page changed: a navigation. */
107
+ navigate: "lp.navigate",
108
+ /** An item's actions were opened in the palette (Tab). */
109
+ paletteActions: "lp.palette-actions",
110
+ } as const
111
+
112
+ /** Right click instead of the "…" button. */
113
+ export function contextMenuTip(text: Partial<Pick<HintTip, "title" | "text"> & { stepText: string }> = {}): HintTip {
114
+ return {
115
+ id: "lp.tip.context-menu",
116
+ title: text.title ?? "Right-click works here",
117
+ text: text.text ?? "Rows and cards open their actions on right-click, even where no … button shows it.",
118
+ when: (count) =>
119
+ count(HINT_SIGNALS.contextMenu) === 0
120
+ && (count(HINT_SIGNALS.menuButton) >= 5 || count(HINT_SIGNALS.contextTarget) >= 25),
121
+ step: () => {
122
+ const target = [...document.querySelectorAll("[data-lp-context-menu]")].find((el) => {
123
+ const box = el.getBoundingClientRect()
124
+ return box.width > 0 && box.bottom > 0 && box.top < window.innerHeight
125
+ })
126
+ return target ? { target, title: text.title ?? "Right-click works here", text: text.stepText ?? "Right-click this to see what it can do.", try: true } : null
127
+ },
128
+ }
129
+ }
130
+
131
+ /** The palette instead of walking the menu. */
132
+ export function paletteTip(keys = "Ctrl+K", text: Partial<Pick<HintTip, "title" | "text">> = {}): HintTip {
133
+ return {
134
+ id: "lp.tip.palette",
135
+ title: text.title ?? "Jump anywhere",
136
+ text: text.text ?? "Search pages, people and settings from anywhere instead of the menu.",
137
+ keys,
138
+ when: (count) => count(HINT_SIGNALS.navigate) >= 15 && count(HINT_SIGNALS.palette) === 0,
139
+ }
140
+ }
141
+
79
142
  export type HintLevel = "seen" | "explained" | "used"
80
143
 
81
144
  export interface HintRecord {
@@ -113,6 +176,7 @@ export interface HintSystemOptions {
113
176
  features: Record<string, HintFeature>
114
177
  pages?: Record<string, HintPage>
115
178
  news?: HintNews[]
179
+ tips?: HintTip[]
116
180
  storage?: HintStorage
117
181
  /** The current page id, read reactively (map your route to it). */
118
182
  page: () => string
@@ -124,6 +188,7 @@ export type HintOffer =
124
188
  | { kind: "tour" | "assigned"; page: string }
125
189
  | { kind: "news"; page: string; items: HintNews[] }
126
190
  | { kind: "nudge"; page: string; nudge: HintNudge }
191
+ | { kind: "tip"; page: string; tip: HintTip }
127
192
 
128
193
  const RANK: Record<HintLevel, number> = { seen: 0, explained: 1, used: 2 }
129
194
  const DAY = 24 * 3600 * 1000
@@ -217,6 +282,8 @@ export function createHintSystem(options: HintSystemOptions) {
217
282
  const features = options.features
218
283
  const pages = options.pages ?? {}
219
284
  const news = options.news ?? []
285
+ const tips = options.tips ?? []
286
+ const TIP_MAX = 3
220
287
  const storage = options.storage ?? localHintStorage()
221
288
  const sessionKey = options.sessionKey ?? "lp-hints:interrupted"
222
289
 
@@ -224,6 +291,7 @@ export function createHintSystem(options: HintSystemOptions) {
224
291
  records: new Map<string, HintRecord>(),
225
292
  loaded: false,
226
293
  page: "",
294
+ overlay: null as string | null,
227
295
  tour: { open: false, step: 0, steps: [] as TourStep[], ids: [] as string[], page: "" },
228
296
  offer: null as HintOffer | null,
229
297
  candidate: null as (HintNudge & { page: string }) | null,
@@ -238,6 +306,9 @@ export function createHintSystem(options: HintSystemOptions) {
238
306
  const pageKey = hintKey.page
239
307
  const newsKey = hintKey.news
240
308
  const nudgeKey = hintKey.nudge
309
+ const signalKey = (name: string) => `u:${name}`
310
+ const tipKey = (id: string) => `p:${id}`
311
+ const count = (name: string) => state.records.get(signalKey(name))?.count ?? 0
241
312
 
242
313
  function interrupted(): boolean {
243
314
  try {
@@ -390,14 +461,28 @@ export function createHintSystem(options: HintSystemOptions) {
390
461
  return
391
462
  }
392
463
  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 }
464
+ if (n && n.page === page) {
465
+ const seen = state.records.get(nudgeKey(n.id))
466
+ const f = features[n.feature]
467
+ const visible = !!f && (!!f.target || !!f.enter || hints.present(n.feature))
468
+ if (!seen?.dismissed && state.records.get(featureKey(n.feature))?.state !== "used" && visible
469
+ && !(seen && Date.now() - Date.parse(seen.updatedAt) < timing.nudgeAgain)) {
470
+ state.candidate = null
471
+ spend()
472
+ record(nudgeKey(n.id), { add: 1 })
473
+ state.offer = { kind: "nudge", page, nudge: n }
474
+ return
475
+ }
476
+ }
477
+ for (const tip of tips) {
478
+ const seen = state.records.get(tipKey(tip.id))
479
+ if (seen?.dismissed || (seen?.count ?? 0) >= (tip.max ?? TIP_MAX)) continue
480
+ if (seen && Date.now() - Date.parse(seen.updatedAt) < timing.nudgeAgain) continue
481
+ if (!tip.when(count)) continue
482
+ spend()
483
+ state.offer = { kind: "tip", page, tip }
484
+ return
485
+ }
401
486
  }
402
487
 
403
488
  let stops: (() => void)[] = []
@@ -405,8 +490,9 @@ export function createHintSystem(options: HintSystemOptions) {
405
490
  function start(): void {
406
491
  if (stops.length) return
407
492
  const stopPage: WatchStopHandle = watch(
408
- options.page,
409
- (page) => {
493
+ () => state.overlay ?? options.page(),
494
+ (page, previous) => {
495
+ if (previous !== undefined && page !== previous && !state.overlay) record(signalKey(HINT_SIGNALS.navigate), { add: 1 })
410
496
  state.page = page
411
497
  state.settledAt = Date.now()
412
498
  if (state.offer && state.offer.page !== page) state.offer = null
@@ -428,6 +514,7 @@ export function createHintSystem(options: HintSystemOptions) {
428
514
  stopPage,
429
515
  hints.on("used", (id) => {
430
516
  if (features[id]) record(featureKey(id), { state: "used", add: 1 })
517
+ if (id.startsWith("lp.") || !features[id]) record(signalKey(id), { add: 1 })
431
518
  }),
432
519
  () => clearInterval(poll),
433
520
  () => window.removeEventListener("pagehide", flushOnLeave),
@@ -460,6 +547,16 @@ export function createHintSystem(options: HintSystemOptions) {
460
547
  nudge(n: HintNudge): void {
461
548
  state.candidate = { ...n, page: state.page }
462
549
  },
550
+ /**
551
+ * A dialog acts as its own page while open (its dots and tour), then hands
552
+ * back. Pass null on close, and on unmount: a dialog left open by a route
553
+ * change would otherwise keep the page.
554
+ */
555
+ overlay(page: string | null): void {
556
+ state.overlay = page
557
+ },
558
+ /** How many times a signal was reported (for an app's own tips or reports). */
559
+ count,
463
560
  /** The user navigated on purpose (palette, notification): do not interrupt this arrival. */
464
561
  markIntent(): void {
465
562
  state.intent = true
@@ -475,6 +572,7 @@ export function createHintSystem(options: HintSystemOptions) {
475
572
  if (id) record(featureKey(id), { state: "explained" })
476
573
  },
477
574
  tourEnded(finished: boolean): void {
575
+ if (!state.tour.ids.length) return
478
576
  record(pageKey(state.tour.page), finished ? { state: "explained" } : { add: 1 })
479
577
  baseline(state.tour.page)
480
578
  },
@@ -486,6 +584,10 @@ export function createHintSystem(options: HintSystemOptions) {
486
584
  const ids = offer.items.map((n) => n.feature).filter((id): id is string => !!id)
487
585
  readNews(offer.page)
488
586
  if (choice === "show" && ids.length) startTour(offer.page, ids)
587
+ } else if (offer.kind === "tip") {
588
+ record(tipKey(offer.tip.id), { add: 1, dismissed: choice === "never" })
589
+ const step = choice === "show" ? offer.tip.step?.() : null
590
+ if (step) Object.assign(state.tour, { open: true, step: 0, page: offer.page, ids: [], steps: [step] })
489
591
  } else if (offer.kind === "nudge") {
490
592
  if (choice === "show") startTour(features[offer.nudge.feature]!.page, [offer.nudge.feature])
491
593
  else record(nudgeKey(offer.nudge.id), { dismissed: true })
@@ -503,10 +605,26 @@ export type HintSystem = ReturnType<typeof createHintSystem>
503
605
 
504
606
  const HINT_SYSTEM: InjectionKey<HintSystem> = Symbol.for("lp-hint-system")
505
607
 
608
+ /*
609
+ * Overlays (the palette, menus) are portalled siblings of the host, not its
610
+ * descendants, so injection alone would never reach them: the mounted host is
611
+ * also kept here.
612
+ */
613
+ const running = shallowRef<HintSystem | null>(null)
614
+
506
615
  export function provideHintSystem(system: HintSystem): void {
507
616
  provide(HINT_SYSTEM, system)
617
+ running.value = system
618
+ onBeforeUnmount(() => {
619
+ if (running.value === system) running.value = null
620
+ })
508
621
  }
509
622
 
510
623
  export function useHintSystem(): HintSystem | null {
511
- return inject(HINT_SYSTEM, null)
624
+ return inject(HINT_SYSTEM, null) ?? running.value
625
+ }
626
+
627
+ /** The mounted host's system, read reactively from anywhere. */
628
+ export function runningHintSystem(): HintSystem | null {
629
+ return running.value
512
630
  }
package/src/index.ts CHANGED
@@ -212,7 +212,7 @@ export type { TourContext, TourStep } from "./components/LpTour.vue"
212
212
  export { holeOf, openContextMenu, panelsAround } from "./components/tour"
213
213
  export { default as LpHintLayer } from "./components/LpHintLayer.vue"
214
214
  export { default as LpHints } from "./components/LpHints.vue"
215
- export { createHintSystem, hintKey, hintSummary, localHintStorage, provideHintSystem, useHintSystem } from "./composables/useHintSystem"
215
+ export { HINT_SIGNALS, contextMenuTip, createHintSystem, hintKey, hintSummary, localHintStorage, paletteTip, provideHintSystem, runningHintSystem, useHintSystem } from "./composables/useHintSystem"
216
216
  export type {
217
217
  HintDelta,
218
218
  HintFeature,
@@ -225,6 +225,7 @@ export type {
225
225
  HintStorage,
226
226
  HintSummary,
227
227
  HintSystem,
228
+ HintTip,
228
229
  HintSystemOptions,
229
230
  HintTiming,
230
231
  } from "./composables/useHintSystem"