@leavepulse/ui 0.44.0 → 0.45.1

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 (271) hide show
  1. package/README.md +49 -3
  2. package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-HzAA4gri.css} +21 -21
  3. package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
  4. package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
  5. package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
  6. package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
  7. package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
  8. package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
  9. package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
  10. package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-CJOYGZlI.js} +7 -7
  11. package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
  12. package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-B07Lf48e.js} +4 -4
  13. package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
  14. package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
  15. package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
  16. package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
  17. package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
  18. package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
  19. package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
  20. package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
  21. package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
  22. package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-BTVAR4tF.js} +3 -3
  23. package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-Ci_uCHgQ.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
  25. package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-BrdFtSTy.js} +16 -10
  26. package/dist/chunks/LpDateRangePicker-Drd7EbIz.js +210 -0
  27. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  28. package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-CfRhHwmE.js} +3 -3
  29. package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
  30. package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
  31. package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
  32. package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
  33. package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
  34. package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
  35. package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
  36. package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
  37. package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
  38. package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
  39. package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
  40. package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
  41. package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-BzTVeNqe.js} +3 -3
  42. package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-CjABSGCu.js} +14 -8
  43. package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
  44. package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
  45. package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
  46. package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
  47. package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
  48. package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
  49. package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
  50. package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
  51. package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
  52. package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
  53. package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
  54. package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
  55. package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
  56. package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-Bwws_X29.js} +3 -3
  57. package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
  58. package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
  59. package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
  60. package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
  61. package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
  62. package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
  63. package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
  64. package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
  65. package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
  66. package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
  67. package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
  68. package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DTtWuKvq.js} +76 -9
  69. package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
  70. package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
  71. package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
  72. package/dist/chunks/icons-CkA0njzm.js +43 -0
  73. package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
  74. package/dist/component-names.d.ts +1 -1
  75. package/dist/components/ContextMenuItems.vue.js +1 -1
  76. package/dist/components/LayoutCanvas.vue.js +1 -1
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.js +1 -1
  79. package/dist/components/LpAddressInput.vue.js +1 -1
  80. package/dist/components/LpAlert.vue.js +1 -1
  81. package/dist/components/LpAppShell.vue.js +1 -1
  82. package/dist/components/LpAutocomplete.vue.js +1 -1
  83. package/dist/components/LpAvatar.vue.js +1 -1
  84. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  85. package/dist/components/LpButton.vue.d.ts +2 -2
  86. package/dist/components/LpButton.vue.js +1 -1
  87. package/dist/components/LpCalendar.vue.js +1 -1
  88. package/dist/components/LpCard.vue.js +1 -1
  89. package/dist/components/LpChart.vue.d.ts +16 -1
  90. package/dist/components/LpChart.vue.js +63 -5
  91. package/dist/components/LpCheckbox.vue.js +1 -1
  92. package/dist/components/LpCodeBlock.vue.js +1 -1
  93. package/dist/components/LpColorPicker.vue.d.ts +2 -2
  94. package/dist/components/LpColorPicker.vue.js +1 -1
  95. package/dist/components/LpCommandPalette.vue.d.ts +6 -0
  96. package/dist/components/LpCommandPalette.vue.js +62 -11
  97. package/dist/components/LpConfirmDialog.vue.js +1 -1
  98. package/dist/components/LpConfirmHost.vue.js +1 -1
  99. package/dist/components/LpContextMenu.vue.js +1 -1
  100. package/dist/components/LpDatePicker.vue.js +1 -1
  101. package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
  102. package/dist/components/LpDateRangePicker.vue.js +4 -0
  103. package/dist/components/LpDisclosure.vue.js +1 -1
  104. package/dist/components/LpDrawer.vue.js +1 -1
  105. package/dist/components/LpDropdownMenu.vue.js +1 -1
  106. package/dist/components/LpDropzone.vue.js +1 -1
  107. package/dist/components/LpEmptyState.vue.js +1 -1
  108. package/dist/components/LpFieldNotice.vue.d.ts +8 -0
  109. package/dist/components/LpFieldNotice.vue.js +4 -0
  110. package/dist/components/LpFileTree.vue.js +1 -1
  111. package/dist/components/LpFileTreeNode.vue.js +1 -1
  112. package/dist/components/LpFormField.vue.d.ts +4 -1
  113. package/dist/components/LpFormField.vue.js +1 -1
  114. package/dist/components/LpIcon.vue.d.ts +0 -1
  115. package/dist/components/LpIcon.vue.js +1 -1
  116. package/dist/components/LpImageEditor.vue.js +2 -2
  117. package/dist/components/LpInput.vue.d.ts +19 -0
  118. package/dist/components/LpInput.vue.js +1 -1
  119. package/dist/components/LpLightbox.vue.js +1 -1
  120. package/dist/components/LpLogViewer.vue.d.ts +52 -1
  121. package/dist/components/LpLogViewer.vue.js +1 -1
  122. package/dist/components/LpMapPicker.vue.js +1 -1
  123. package/dist/components/LpMediaGallery.vue.js +1 -1
  124. package/dist/components/LpModal.vue.js +1 -1
  125. package/dist/components/LpModalNav.vue.js +1 -1
  126. package/dist/components/LpNotificationBell.vue.js +1 -1
  127. package/dist/components/LpNumberField.vue.js +1 -1
  128. package/dist/components/LpPagination.vue.js +1 -1
  129. package/dist/components/LpPasswordInput.vue.js +1 -1
  130. package/dist/components/LpPhoneInput.vue.d.ts +13 -0
  131. package/dist/components/LpPhoneInput.vue.js +1 -1
  132. package/dist/components/LpPie.vue.js +1 -1
  133. package/dist/components/LpPopover.vue.js +1 -1
  134. package/dist/components/LpProgress.vue.d.ts +1 -1
  135. package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
  136. package/dist/components/LpRangeCalendar.vue.js +4 -0
  137. package/dist/components/LpRollingText.vue.d.ts +1 -1
  138. package/dist/components/LpRollingText.vue.js +1 -1
  139. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  140. package/dist/components/LpScrollArea.vue.js +1 -1
  141. package/dist/components/LpSection.vue.js +1 -1
  142. package/dist/components/LpSegmented.vue.js +1 -1
  143. package/dist/components/LpSelect.vue.js +1 -1
  144. package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
  145. package/dist/components/LpShortcutsDialog.vue.js +56 -14
  146. package/dist/components/LpSidebar.vue.js +1 -1
  147. package/dist/components/LpSidebarNav.vue.js +1 -1
  148. package/dist/components/LpStat.vue.js +1 -1
  149. package/dist/components/LpStepper.vue.js +1 -1
  150. package/dist/components/LpSwitch.vue.js +1 -1
  151. package/dist/components/LpTable.vue.d.ts +84 -2
  152. package/dist/components/LpTable.vue.js +481 -107
  153. package/dist/components/LpTableFilter.vue.d.ts +31 -0
  154. package/dist/components/LpTableFilter.vue.js +4 -0
  155. package/dist/components/LpTabs.vue.js +1 -1
  156. package/dist/components/LpTagsInput.vue.d.ts +77 -0
  157. package/dist/components/LpTagsInput.vue.js +4 -0
  158. package/dist/components/LpTextarea.vue.js +1 -1
  159. package/dist/components/LpThemeProvider.vue.js +1 -1
  160. package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
  161. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  162. package/dist/components/LpTimeField.vue.js +1 -1
  163. package/dist/components/LpToaster.vue.d.ts +2 -0
  164. package/dist/components/LpToaster.vue.js +1 -1
  165. package/dist/components/LpTooltip.vue.js +1 -1
  166. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  167. package/dist/components/LpUptimeBar.vue.js +1 -1
  168. package/dist/components/chart.js +3 -2
  169. package/dist/components/commandSearch.js +3 -0
  170. package/dist/components/dateRange.d.ts +43 -0
  171. package/dist/components/dateRange.js +76 -0
  172. package/dist/components/exportData.d.ts +58 -0
  173. package/dist/components/exportData.js +165 -0
  174. package/dist/components/logSearch.d.ts +18 -0
  175. package/dist/components/logSearch.js +36 -0
  176. package/dist/components/phone.d.ts +41 -0
  177. package/dist/components/phone.js +80 -0
  178. package/dist/components/tableFilter.d.ts +36 -0
  179. package/dist/components/tableFilter.js +84 -0
  180. package/dist/components/tags.d.ts +20 -0
  181. package/dist/components/tags.js +37 -0
  182. package/dist/composables/useField.d.ts +4 -0
  183. package/dist/composables/useFieldNotice.d.ts +17 -0
  184. package/dist/composables/useFieldNotice.js +28 -0
  185. package/dist/composables/useForm.d.ts +53 -0
  186. package/dist/composables/useForm.js +207 -0
  187. package/dist/composables/useHotkeys.d.ts +65 -5
  188. package/dist/composables/useHotkeys.js +170 -9
  189. package/dist/composables/useInputMask.d.ts +59 -0
  190. package/dist/composables/useInputMask.js +170 -0
  191. package/dist/composables/useLocale.d.ts +2 -0
  192. package/dist/composables/useLocale.js +9 -0
  193. package/dist/composables/usePaintSelect.d.ts +22 -0
  194. package/dist/composables/usePaintSelect.js +169 -0
  195. package/dist/composables/useShift.js +7 -2
  196. package/dist/icons-all.d.ts +1 -0
  197. package/dist/icons-all.js +3 -0
  198. package/dist/icons.d.ts +14 -1
  199. package/dist/index.d.ts +29 -3
  200. package/dist/index.js +195 -138
  201. package/dist/kit-icons.d.ts +2 -0
  202. package/dist/theme/backdrop.d.ts +12 -0
  203. package/dist/theme/presets.d.ts +0 -2
  204. package/package.json +13 -4
  205. package/src/component-names.ts +4 -0
  206. package/src/components/ContextMenuItems.vue +1 -1
  207. package/src/components/LayoutNode.vue +11 -2
  208. package/src/components/LpActionBar.vue +1 -1
  209. package/src/components/LpAutocomplete.vue +1 -0
  210. package/src/components/LpAvatar.vue +2 -2
  211. package/src/components/LpButton.vue +1 -1
  212. package/src/components/LpChart.vue +80 -1
  213. package/src/components/LpColorPicker.vue +34 -1
  214. package/src/components/LpCommandPalette.vue +82 -8
  215. package/src/components/LpContextMenu.vue +1 -1
  216. package/src/components/LpDatePicker.vue +11 -4
  217. package/src/components/LpDateRangePicker.vue +226 -0
  218. package/src/components/LpDrawer.vue +1 -1
  219. package/src/components/LpDropdownMenu.vue +1 -0
  220. package/src/components/LpFieldNotice.vue +65 -0
  221. package/src/components/LpFormField.vue +33 -4
  222. package/src/components/LpIcon.vue +22 -4
  223. package/src/components/LpInput.vue +46 -6
  224. package/src/components/LpLogViewer.vue +225 -22
  225. package/src/components/LpModal.vue +2 -2
  226. package/src/components/LpModalNav.vue +13 -2
  227. package/src/components/LpNotificationBell.vue +15 -7
  228. package/src/components/LpPhoneInput.vue +179 -20
  229. package/src/components/LpPie.vue +8 -5
  230. package/src/components/LpPopover.vue +1 -0
  231. package/src/components/LpRangeCalendar.vue +365 -0
  232. package/src/components/LpRollingText.vue +3 -0
  233. package/src/components/LpScrollArea.vue +22 -2
  234. package/src/components/LpSelect.vue +0 -0
  235. package/src/components/LpShortcutsDialog.vue +77 -8
  236. package/src/components/LpStepper.vue +23 -10
  237. package/src/components/LpSwitch.vue +1 -1
  238. package/src/components/LpTable.vue +584 -65
  239. package/src/components/LpTableFilter.vue +175 -0
  240. package/src/components/LpTagsInput.vue +364 -0
  241. package/src/components/LpThemeSwitcher.vue +19 -23
  242. package/src/components/LpToaster.vue +91 -5
  243. package/src/components/LpTooltip.vue +9 -2
  244. package/src/components/LpTopologyCanvas.vue +7 -1
  245. package/src/components/chart.ts +5 -3
  246. package/src/components/commandSearch.ts +6 -0
  247. package/src/components/dateRange.ts +134 -0
  248. package/src/components/exportData.ts +228 -0
  249. package/src/components/logSearch.ts +46 -0
  250. package/src/components/phone.ts +140 -0
  251. package/src/components/tableFilter.ts +122 -0
  252. package/src/components/tags.ts +62 -0
  253. package/src/composables/useField.ts +4 -0
  254. package/src/composables/useFieldNotice.ts +59 -0
  255. package/src/composables/useForm.ts +314 -0
  256. package/src/composables/useHotkeys.ts +269 -13
  257. package/src/composables/useInputMask.ts +254 -0
  258. package/src/composables/useLocale.ts +14 -0
  259. package/src/composables/usePaintSelect.ts +243 -0
  260. package/src/composables/useShift.ts +13 -2
  261. package/src/iconSubset.d.mts +10 -0
  262. package/src/iconSubset.mjs +51 -0
  263. package/src/icons-all.ts +10 -0
  264. package/src/icons.ts +59 -8
  265. package/src/index.ts +37 -3
  266. package/src/kit-icons.ts +5 -0
  267. package/src/nuxt.ts +30 -2
  268. package/src/runtime/theme.plugin.ts +2 -2
  269. package/src/theme/backdrop.ts +69 -0
  270. package/src/theme/presets.ts +6 -64
  271. package/src/tokens/tokens.css +53 -4
@@ -32,6 +32,7 @@ import LpIcon from "./LpIcon.vue"
32
32
  import LpScrollArea from "./LpScrollArea.vue"
33
33
  import { useMergedAttrs } from "../composables/useMergedClass"
34
34
  import { useLabels } from "../composables/useLpConfig"
35
+ import { countLevels, matchPositions, ringWindow, stepMatch } from "./logSearch"
35
36
 
36
37
  export interface LogViewerLabels {
37
38
  [key: string]: string
@@ -50,6 +51,14 @@ export interface LogViewerLabels {
50
51
  /** `{n}` is replaced by the count. */
51
52
  identical: string
52
53
  jump: string
54
+ search: string
55
+ clearSearch: string
56
+ previousMatch: string
57
+ nextMatch: string
58
+ /** `{current}` and `{total}` are replaced. */
59
+ matchCount: string
60
+ noMatches: string
61
+ levels: string
53
62
  }
54
63
 
55
64
  export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "success"
@@ -123,6 +132,18 @@ const props = withDefaults(
123
132
  * so a duplicate level word / timestamp doesn't repeat in the message body.
124
133
  */
125
134
  parse?: boolean
135
+ /**
136
+ * Keep only the newest N lines on screen. The consumer can keep pushing
137
+ * into the same array (or hand over a longer one) and the viewer shows the
138
+ * tail; trimming the source itself is still the cheaper way to cap memory.
139
+ */
140
+ maxLines?: number
141
+ /**
142
+ * A bar above the stream with a search box (Enter / Shift+Enter or F3 step
143
+ * through matches) and level chips. The term binds via `v-model:search`,
144
+ * the chosen levels via `v-model:levels`.
145
+ */
146
+ toolbar?: boolean
126
147
  labels?: Partial<LogViewerLabels>
127
148
  }>(),
128
149
  {
@@ -148,6 +169,12 @@ const emit = defineEmits<{
148
169
  (e: "reach-top"): void
149
170
  }>()
150
171
 
172
+ /** The toolbar's search term. Without the toolbar, `highlight` is the term. */
173
+ const query = defineModel<string>("search", { default: "" })
174
+ /** Levels to show. Empty shows every level. */
175
+ const levels = defineModel<LogLevel[]>("levels", { default: () => [] })
176
+ const term = computed(() => (props.toolbar && query.value ? query.value : (props.highlight ?? "")).trim())
177
+
151
178
  const text = useLabels<LogViewerLabels>(
152
179
  "logViewer",
153
180
  {
@@ -163,6 +190,13 @@ const text = useLabels<LogViewerLabels>(
163
190
  filterLevel: "Filter by {level}",
164
191
  identical: "{n} identical lines",
165
192
  jump: "Jump to latest",
193
+ search: "Search logs",
194
+ clearSearch: "Clear search",
195
+ previousMatch: "Previous match",
196
+ nextMatch: "Next match",
197
+ matchCount: "{current} / {total}",
198
+ noMatches: "No matches",
199
+ levels: "Levels",
166
200
  },
167
201
  () => props.labels,
168
202
  )
@@ -280,7 +314,10 @@ const root = ref<HTMLElement | null>(null)
280
314
  const frozen = ref(false)
281
315
  const frozenLines = shallowRef<LogLine[] | null>(null)
282
316
 
283
- const sourceLines = computed<LogLine[]>(() => frozenLines.value ?? props.lines)
317
+ const windowed = computed(() => ringWindow(frozenLines.value ?? props.lines, props.maxLines))
318
+ const sourceLines = computed<LogLine[]>(() => windowed.value.lines as LogLine[])
319
+ // Line numbers keep counting from the start of the stream after the cap trims it.
320
+ const lineOffset = computed(() => windowed.value.offset)
284
321
 
285
322
  function freeze(): boolean {
286
323
  // Declining unless the stream is being looked at: `false` hands Ctrl back
@@ -291,7 +328,9 @@ function freeze(): boolean {
291
328
  // Snapshotted by reference, not copied: the array the consumer hands over is
292
329
  // replaced wholesale on every update, so holding the old one is enough — and
293
330
  // a copy of ten thousand lines on every keypress is not.
294
- frozenLines.value = props.lines
331
+ // A capped stream is usually one array pushed into in place, which a
332
+ // reference would not hold still; its window is small enough to copy.
333
+ frozenLines.value = props.maxLines ? props.lines.slice(-props.maxLines) : props.lines
295
334
  frozen.value = true
296
335
  return true
297
336
  }
@@ -317,9 +356,13 @@ function focusWithin() {
317
356
  return !!el && el.contains(document.activeElement)
318
357
  }
319
358
 
359
+ // `when` rather than a bare decline: several viewers on one page all bind these
360
+ // keys, and only the one being looked at should claim them.
361
+ const lookedAt = () => hovered.value || focusWithin()
362
+
320
363
  useHotkeys(() => [
321
- { key: "control", allowInInput: true, handler: freeze },
322
- { key: "meta", allowInInput: true, handler: freeze },
364
+ { key: "control", allowInInput: true, when: lookedAt, handler: freeze },
365
+ { key: "meta", allowInInput: true, when: lookedAt, handler: freeze },
323
366
  ])
324
367
 
325
368
  // Released on keyup anywhere, and on blur: a window that loses focus mid-hold
@@ -347,10 +390,12 @@ const parsedLines = computed<LogLine[]>(() =>
347
390
  // With filterMatches + a search term, keep only lines whose message contains it
348
391
  // (case-insensitive); otherwise pass everything.
349
392
  const visibleLines = computed<{ line: LogLine; n: number; count: number }[]>(() => {
350
- const term = props.filterMatches ? props.highlight?.trim().toLowerCase() : ""
393
+ const needle = props.filterMatches ? term.value.toLowerCase() : ""
394
+ const shown = levels.value.length ? new Set<LogLevel>(levels.value) : null
351
395
  const filtered = parsedLines.value
352
396
  .map((line, n) => ({ line, n }))
353
- .filter(({ line }) => !term || line.message.toLowerCase().includes(term))
397
+ .filter(({ line }) => !shown || shown.has(line.level ?? "info"))
398
+ .filter(({ line }) => !needle || matchPositions([line], needle).length > 0)
354
399
 
355
400
  if (!props.compact) return filtered.map((row) => ({ ...row, count: 1 }))
356
401
 
@@ -392,6 +437,88 @@ function lineKey(n: number): number {
392
437
  return id
393
438
  }
394
439
 
440
+ // ── toolbar: level chips and match navigation ───────────────────────
441
+ const LEVEL_ORDER: LogLevel[] = ["trace", "debug", "info", "success", "warn", "error", "fatal"]
442
+ const levelCounts = computed(() => countLevels<LogLevel>(parsedLines.value, "info"))
443
+ const presentLevels = computed(() => LEVEL_ORDER.filter((l) => levelCounts.value.has(l)))
444
+
445
+ function toggleLevel(level: LogLevel) {
446
+ levels.value = levels.value.includes(level)
447
+ ? levels.value.filter((l) => l !== level)
448
+ : [...levels.value, level]
449
+ }
450
+
451
+ const matches = computed(() => matchPositions(visibleLines.value.map((r) => r.line), term.value))
452
+ const current = ref(-1)
453
+ const currentRow = computed(() => {
454
+ const pos = matches.value[current.value]
455
+ return pos === undefined ? -1 : (visibleLines.value[pos]?.n ?? -1)
456
+ })
457
+ watch(term, () => {
458
+ current.value = -1
459
+ })
460
+ watch(matches, (list) => {
461
+ if (current.value >= list.length) current.value = list.length - 1
462
+ })
463
+
464
+ const matchLabel = computed(() => {
465
+ if (!term.value) return ""
466
+ const total = matches.value.length
467
+ if (!total) return text.value.noMatches
468
+ return text.value.matchCount
469
+ .replace("{current}", current.value >= 0 ? String(current.value + 1) : "–")
470
+ .replace("{total}", String(total))
471
+ })
472
+
473
+ function step(direction: 1 | -1) {
474
+ const next = stepMatch(current.value, matches.value.length, direction)
475
+ if (next < 0) return
476
+ current.value = next
477
+ pinned.value = false
478
+ nextTick(() => revealMatch(matches.value[next]))
479
+ }
480
+
481
+ // Brings the match to the middle of the viewport. A virtualised row may not be
482
+ // in the DOM yet, so the offset is computed from the fixed row height instead.
483
+ function revealMatch(position: number | undefined) {
484
+ const el = viewport()
485
+ if (!el || position === undefined) return
486
+ const behavior: ScrollBehavior = prefersReducedMotion() ? "auto" : "smooth"
487
+ if (virtualise.value) {
488
+ el.scrollTo({ top: Math.max(0, position * ROW_HEIGHT - el.clientHeight / 2), behavior })
489
+ return
490
+ }
491
+ const n = visibleLines.value[position]?.n
492
+ el.querySelector<HTMLElement>(`[data-log-row="${n}"]`)?.scrollIntoView({ block: "center", behavior })
493
+ }
494
+
495
+ const searchEl = ref<HTMLInputElement | null>(null)
496
+
497
+ function onSearchKeydown(event: KeyboardEvent) {
498
+ if (event.isComposing) return
499
+ if (event.key === "Enter") {
500
+ event.preventDefault()
501
+ step(event.shiftKey ? -1 : 1)
502
+ } else if (event.key === "Escape" && query.value) {
503
+ event.preventDefault()
504
+ event.stopPropagation()
505
+ query.value = ""
506
+ }
507
+ }
508
+
509
+ const searching = () => !!term.value && lookedAt()
510
+
511
+ useHotkeys(() => [
512
+ { key: "f3", allowInInput: true, when: searching, handler: () => step(1) },
513
+ { key: "f3", shift: true, allowInInput: true, when: searching, handler: () => step(-1) },
514
+ ])
515
+
516
+ defineExpose({
517
+ next: () => step(1),
518
+ previous: () => step(-1),
519
+ focusSearch: () => searchEl.value?.focus(),
520
+ })
521
+
395
522
  // True when filtering has emptied an otherwise non-empty stream.
396
523
  const filteredEmpty = computed(
397
524
  () => props.lines.length > 0 && visibleLines.value.length === 0,
@@ -473,7 +600,10 @@ function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
473
600
  label: text.value.filterLevel.replace("{level}", line.level),
474
601
  icon: "lucide:filter",
475
602
  separatorBefore: !line.source,
476
- onSelect: () => emit("filter", { level: line.level }),
603
+ onSelect: () => {
604
+ if (props.toolbar && line.level) levels.value = [line.level]
605
+ emit("filter", { level: line.level })
606
+ },
477
607
  })
478
608
  }
479
609
  const extra = props.extraRowItems?.(line, index) ?? []
@@ -488,11 +618,10 @@ interface Part {
488
618
  hit: boolean
489
619
  }
490
620
  function splitHighlight(message: string): Part[] {
491
- const term = props.highlight?.trim()
492
- if (!term) return [{ text: message, hit: false }]
621
+ if (!term.value) return [{ text: message, hit: false }]
493
622
  const parts: Part[] = []
494
623
  const lower = message.toLowerCase()
495
- const needle = term.toLowerCase()
624
+ const needle = term.value.toLowerCase()
496
625
  let i = 0
497
626
  while (i < message.length) {
498
627
  const at = lower.indexOf(needle, i)
@@ -625,6 +754,7 @@ function rideToBottom(smooth = true, force = false) {
625
754
  }
626
755
 
627
756
  function scrollToBottom() {
757
+ current.value = -1
628
758
  rideToBottom(true, true)
629
759
  pinned.value = true
630
760
  }
@@ -653,7 +783,7 @@ watch(
653
783
  linesSignal,
654
784
  async () => {
655
785
  await nextTick()
656
- if (props.tail && pinned.value) {
786
+ if (props.tail && pinned.value && current.value < 0) {
657
787
  requestAnimationFrame(() => rideToBottom(true))
658
788
  } else {
659
789
  restoreScrollAnchor()
@@ -695,13 +825,13 @@ onBeforeUnmount(() => {
695
825
  // (a component :is can't carry TransitionGroup-only props inertly otherwise).
696
826
  const transitionProps = {
697
827
  tag: "ol",
698
- "enter-active-class": "transition duration-200 ease-[var(--ease-emphasized)]",
828
+ "enter-active-class": "transition duration-[var(--duration-medium)] ease-[var(--ease-emphasized)]",
699
829
  "enter-from-class": "-translate-x-1 opacity-0",
700
830
  "leave-active-class":
701
- "overflow-hidden transition-all duration-200 ease-[var(--ease-emphasized)]",
831
+ "overflow-hidden transition-all duration-[var(--duration-medium)] ease-[var(--ease-emphasized)]",
702
832
  "leave-from-class": "max-h-8 opacity-100",
703
833
  "leave-to-class": "max-h-0 -translate-y-1 opacity-0",
704
- "move-class": "transition-transform duration-200 ease-[var(--ease-emphasized)]",
834
+ "move-class": "transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)]",
705
835
  }
706
836
 
707
837
  // The viewer draws its own card frame and hides its overflow; a consumer
@@ -740,6 +870,78 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
740
870
  </div>
741
871
  </Transition>
742
872
 
873
+ <div
874
+ v-if="toolbar"
875
+ class="flex flex-wrap items-center gap-2 border-b border-line px-2 py-1.5 font-sans"
876
+ >
877
+ <div class="flex min-w-48 flex-1 items-center gap-1.5 rounded-control bg-surface-soft px-2 focus-within:ring-2 focus-within:ring-ring">
878
+ <LpIcon name="lucide:search" :size="14" class="shrink-0 text-muted" />
879
+ <input
880
+ ref="searchEl"
881
+ v-model="query"
882
+ type="search"
883
+ :aria-label="text.search"
884
+ :placeholder="text.search"
885
+ class="h-7 min-w-0 flex-1 bg-transparent text-xs text-ink outline-none placeholder:text-muted [&::-webkit-search-cancel-button]:hidden"
886
+ @keydown="onSearchKeydown"
887
+ />
888
+ <span
889
+ v-if="term"
890
+ class="shrink-0 text-[11px] tabular-nums text-muted"
891
+ aria-live="polite"
892
+ >{{ matchLabel }}</span>
893
+ <button
894
+ v-if="query"
895
+ type="button"
896
+ class="rounded-xs p-0.5 text-muted outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
897
+ :aria-label="text.clearSearch"
898
+ @click="query = ''; searchEl?.focus()"
899
+ >
900
+ <LpIcon name="lucide:x" :size="12" />
901
+ </button>
902
+ </div>
903
+ <div class="flex items-center">
904
+ <button
905
+ type="button"
906
+ class="rounded-xs p-1 text-muted outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
907
+ :aria-label="text.previousMatch"
908
+ :disabled="!matches.length"
909
+ @click="step(-1)"
910
+ >
911
+ <LpIcon name="lucide:chevron-up" :size="14" />
912
+ </button>
913
+ <button
914
+ type="button"
915
+ class="rounded-xs p-1 text-muted outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
916
+ :aria-label="text.nextMatch"
917
+ :disabled="!matches.length"
918
+ @click="step(1)"
919
+ >
920
+ <LpIcon name="lucide:chevron-down" :size="14" />
921
+ </button>
922
+ </div>
923
+ <div
924
+ v-if="presentLevels.length > 1"
925
+ role="group"
926
+ :aria-label="text.levels"
927
+ class="flex flex-wrap items-center gap-1"
928
+ >
929
+ <button
930
+ v-for="level in presentLevels"
931
+ :key="level"
932
+ type="button"
933
+ class="flex items-center gap-1 rounded-pill border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
934
+ :class="levels.includes(level) ? 'border-line-strong bg-surface-soft text-ink' : 'border-line text-muted hover:text-ink'"
935
+ :aria-pressed="levels.includes(level)"
936
+ @click="toggleLevel(level)"
937
+ >
938
+ <span class="size-1.5 rounded-full" :class="metaFor(level).rail" aria-hidden="true" />
939
+ {{ level }}
940
+ <span class="font-normal tabular-nums text-muted">{{ levelCounts.get(level) }}</span>
941
+ </button>
942
+ </div>
943
+ </div>
944
+
743
945
  <!-- `instant`: this viewer decides for itself when a jump animates (see
744
946
  rideToBottom) and restores scrollTop directly after prepending older
745
947
  lines. A CSS smooth scroll would override both. -->
@@ -755,7 +957,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
755
957
  class="flex h-full flex-col items-center justify-center gap-1 px-4 py-10 text-center"
756
958
  >
757
959
  <span class="text-muted">{{ filteredEmpty ? text.noMatch : (emptyLabel ?? text.empty) }}</span>
758
- <span v-if="filteredEmpty" class="text-muted/70">“{{ highlight }}”</span>
960
+ <span v-if="filteredEmpty && term" class="text-muted/70">“{{ term }}”</span>
759
961
  </div>
760
962
 
761
963
  <template v-else>
@@ -804,7 +1006,8 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
804
1006
  :key="lineKey(n)"
805
1007
  :data-log-row="n"
806
1008
  class="group relative flex items-start gap-0 px-0 leading-5 transition-colors hover:bg-surface-soft/60"
807
- :class="metaFor(line.level).sel"
1009
+ :class="[metaFor(line.level).sel, n === currentRow ? 'bg-accent/15' : '']"
1010
+ :aria-current="n === currentRow ? 'true' : undefined"
808
1011
  >
809
1012
  <!-- level rail -->
810
1013
  <!-- Level rail: absolutely positioned so it spans the FULL row height
@@ -822,7 +1025,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
822
1025
  <span
823
1026
  v-if="lineNumbers"
824
1027
  class="mr-3 w-10 shrink-0 select-none text-right tabular-nums text-muted/70"
825
- >{{ n + 1 }}</span>
1028
+ >{{ n + lineOffset + 1 }}</span>
826
1029
 
827
1030
  <!-- timestamp -->
828
1031
  <span
@@ -865,13 +1068,13 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
865
1068
  Keyed on count so each new fold re-mounts the chip and pops it,
866
1069
  cueing where the duplicate just merged. -->
867
1070
  <Transition
868
- enter-active-class="inline-block transition duration-200 ease-[var(--ease-emphasized)]"
1071
+ enter-active-class="transition duration-[var(--duration-medium)] ease-[var(--ease-emphasized)]"
869
1072
  enter-from-class="scale-50 opacity-0"
870
1073
  >
871
1074
  <span
872
1075
  v-if="count > 1"
873
1076
  :key="count"
874
- class="ml-2 select-none rounded-pill bg-surface-soft px-1.5 align-[1px] text-[10px] font-semibold tabular-nums text-muted-strong"
1077
+ class="ml-2 inline-block select-none rounded-pill bg-surface-soft px-1.5 align-[1px] text-[10px] font-semibold tabular-nums text-muted-strong"
875
1078
  :title="text.identical.replace('{n}', String(count))"
876
1079
  >×{{ count }}</span>
877
1080
  </Transition>
@@ -892,9 +1095,9 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
892
1095
  Transition rides an outer wrapper so its transform/opacity don't fight
893
1096
  the button's own -translate-x-1/2 centring and hover colour transition. -->
894
1097
  <Transition
895
- enter-active-class="transition duration-200 ease-[var(--ease-emphasized)]"
1098
+ enter-active-class="transition duration-[var(--duration-medium)] ease-[var(--ease-emphasized)]"
896
1099
  enter-from-class="translate-y-3 scale-90 opacity-0"
897
- leave-active-class="transition duration-150 ease-in"
1100
+ leave-active-class="transition duration-[var(--duration-fast)] ease-in"
898
1101
  leave-to-class="translate-y-3 scale-90 opacity-0"
899
1102
  >
900
1103
  <div v-if="showJump" class="absolute inset-x-0 bottom-3 flex justify-center">
@@ -906,7 +1109,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
906
1109
  <LpIcon
907
1110
  name="lucide:arrow-down"
908
1111
  :size="14"
909
- class="transition-transform duration-200 ease-[var(--ease-emphasized)] group-hover:translate-y-0.5"
1112
+ class="transition-transform duration-[var(--duration-medium)] ease-[var(--ease-emphasized)] group-hover:translate-y-0.5"
910
1113
  />
911
1114
  {{ text.jump }}
912
1115
  </button>
@@ -324,8 +324,8 @@ watch(
324
324
  </aside>
325
325
  <div
326
326
  ref="panelRef"
327
- class="flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line shadow-panel"
328
- :class="[frosted ? (layer.nested ? 'lp-glass-overlay--nested' : 'lp-glass-overlay') : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
327
+ class="relative flex max-h-[min(90vh,calc(100dvh-2rem))] min-h-0 flex-col overflow-hidden rounded-card border border-line shadow-panel"
328
+ :class="[frosted ? (layer.nested ? 'lp-glass-overlay lp-glass-overlay--nested' : 'lp-glass-overlay') : 'bg-surface-raised', widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
329
329
  :style="{ ...(width ? { width } : {}), ...(hasAside ? { alignSelf: 'stretch' } : {}) }"
330
330
  >
331
331
  <DialogTitle v-if="!(title || $slots.title) && ariaLabel" class="sr-only">
@@ -14,6 +14,7 @@
14
14
  * one the sections are simply unreachable on a phone.
15
15
  */
16
16
  import { computed, ref } from "vue"
17
+ import { useGlass } from "../composables/useGlass"
17
18
  import { useLabels } from "../composables/useLpConfig"
18
19
  import LpButton from "./LpButton.vue"
19
20
  import LpIcon from "./LpIcon.vue"
@@ -127,6 +128,16 @@ const triggerClass = computed(() => TRIGGER_VISIBILITY[props.mobileBreakpoint])
127
128
  * consuming app's scan.
128
129
  */
129
130
  const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
131
+
132
+ /*
133
+ * The rail is part of the dialog, so it takes the same surface: a glass
134
+ * dialog beside a solid rail read as two different windows.
135
+ */
136
+ const frosted = useGlass(() => props.glass)
137
+ const railPanel = computed(() => [
138
+ "rounded-card border border-line p-2 shadow-panel",
139
+ frosted.value ? "lp-glass-overlay" : "bg-surface-raised",
140
+ ])
130
141
  </script>
131
142
 
132
143
  <template>
@@ -158,7 +169,7 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
158
169
  responsive
159
170
  :mobile-breakpoint="mobileBreakpoint"
160
171
  :style="railStyle"
161
- class="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
172
+ :class="railPanel"
162
173
  />
163
174
  </div>
164
175
  </template>
@@ -178,7 +189,7 @@ const railStyle = { maxHeight: "min(90vh, calc(100dvh - 2rem))" }
178
189
  responsive
179
190
  :mobile-breakpoint="mobileBreakpoint"
180
191
  :style="railStyle"
181
- class="rounded-card border border-line bg-surface-raised p-2 shadow-panel"
192
+ :class="railPanel"
182
193
  />
183
194
  </div>
184
195
  </template>
@@ -16,6 +16,7 @@ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
16
16
  import LpEmptyState from "./LpEmptyState.vue"
17
17
  import LpIcon from "./LpIcon.vue"
18
18
  import LpPopover from "./LpPopover.vue"
19
+ import LpRollingText from "./LpRollingText.vue"
19
20
  import LpScrollArea from "./LpScrollArea.vue"
20
21
  import LpShift from "./LpShift.vue"
21
22
 
@@ -291,14 +292,21 @@ function timeAgo(iso?: string): string {
291
292
  @click="open = !open"
292
293
  >
293
294
  <LpIcon name="lucide:bell" :size="18" />
294
- <LpBadge
295
- v-if="hasUnread"
296
- aria-hidden="true"
297
- tone="danger"
298
- class="absolute -right-1 -top-1 min-w-4 justify-center px-1 py-0 text-[10px] leading-4"
295
+ <!-- The badge pops in and out, and its count rolls rather than
296
+ swapping, so new mail reads as arriving. -->
297
+ <Transition
298
+ enter-active-class="animate-[chip-in_var(--duration-fast)_var(--ease-emphasized)] motion-reduce:animate-none"
299
+ leave-active-class="animate-[chip-out_var(--duration-fast)_ease] motion-reduce:animate-none"
299
300
  >
300
- {{ badgeLabel }}
301
- </LpBadge>
301
+ <LpBadge
302
+ v-if="hasUnread"
303
+ aria-hidden="true"
304
+ tone="danger"
305
+ class="absolute -right-1 -top-1 min-w-4 justify-center px-1 py-0 text-[10px] leading-4"
306
+ >
307
+ <LpRollingText :value="badgeLabel" :duration="220" />
308
+ </LpBadge>
309
+ </Transition>
302
310
  </LpButton>
303
311
  </LpContextMenu>
304
312
  </template>