@leavepulse/ui 0.43.4 → 0.44.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 (337) hide show
  1. package/README.md +75 -7
  2. package/dist/assets/{LpCommandPalette-B-0Est4Z.css → LpCommandPalette-DCP5g2ik.css} +21 -21
  3. package/dist/assets/{LpDropzone-BBoWvi-J.css → LpDropzone-i4BdMi1H.css} +12 -12
  4. package/dist/assets/{LpImageEditor-Ct7Y3aNu.css → LpImageEditor-CbMA82g8.css} +6 -6
  5. package/dist/assets/{LpShortcutsDialog-C6YoQ8_M.css → LpShortcutsDialog-ChUyU7oV.css} +7 -7
  6. package/dist/assets/{LpSkeleton-D7xU8IPV.css → LpSkeleton-H3Z5dEIk.css} +4 -1
  7. package/dist/assets/{LpTable-BgPLJOXa.css → LpTable-DDQNdf86.css} +9 -9
  8. package/dist/chunks/ContextMenuItems-CrfeNPWT.js +123 -0
  9. package/dist/chunks/{LayoutCanvas-Dd20boUG.js → LayoutCanvas-D-jPOAvg.js} +26 -9
  10. package/dist/chunks/{LayoutNode-DGpZA9LX.js → LayoutNode-D8Xy9N-o.js} +20 -8
  11. package/dist/chunks/{LpActionBar-BlvtGB2P.js → LpActionBar-BbwCF6l5.js} +24 -10
  12. package/dist/chunks/{LpAddressInput-B-Zvxsn7.js → LpAddressInput-BrOqkZqg.js} +53 -48
  13. package/dist/chunks/{LpAlert-BAYHDSS1.js → LpAlert-DkOIjsbq.js} +15 -8
  14. package/dist/chunks/{LpAppShell-BGQVIY-3.js → LpAppShell-BUKPcHco.js} +51 -24
  15. package/dist/chunks/{LpAutocomplete-DExlIZe_.js → LpAutocomplete-DxV3Y9h4.js} +50 -23
  16. package/dist/chunks/{LpAvatar-B3-xAOH3.js → LpAvatar-ClBbEYZN.js} +17 -8
  17. package/dist/chunks/LpBreadcrumbs-B8IJgFIc.js +95 -0
  18. package/dist/chunks/{LpButton-5VXXCjwF.js → LpButton-kqJBHIIb.js} +44 -14
  19. package/dist/chunks/{LpCard-BhI1N_vL.js → LpCard-CpRByR0q.js} +1 -1
  20. package/dist/chunks/{LpCheckbox-CVccKYaH.js → LpCheckbox-DpqchzH4.js} +7 -3
  21. package/dist/chunks/{LpCodeBlock-Bo0B4TFR.js → LpCodeBlock-DeHJPq7p.js} +48 -16
  22. package/dist/chunks/{LpColorPicker-BlXea7LM.js → LpColorPicker-Bhf1qxhB.js} +32 -9
  23. package/dist/chunks/{LpConfirmDialog-Q-bxff8z.js → LpConfirmDialog-CIUOSnWo.js} +58 -16
  24. package/dist/chunks/LpConfirmHost-CDm0Wtpv.js +49 -0
  25. package/dist/chunks/LpContextMenu-DQw5dGfG.js +68 -0
  26. package/dist/chunks/LpDatePicker-Drirx1AK.js +191 -0
  27. package/dist/chunks/{LpDrawer-BZ6k3hvo.js → LpDrawer-bScx-KMO.js} +63 -26
  28. package/dist/chunks/{LpFileTree-_5vFBgaK.js → LpFileTree-CSI_5SK2.js} +23 -8
  29. package/dist/chunks/{LpFileTreeNode-BdcIYKIE.js → LpFileTreeNode-B4MiSQX2.js} +2 -2
  30. package/dist/chunks/LpFormField-CIk5qosh.js +87 -0
  31. package/dist/chunks/{LpInput-9EXWUM9B.js → LpInput-C0e8y8_d.js} +26 -11
  32. package/dist/chunks/{LpLightbox-2sYVMY-S.js → LpLightbox-Mr0W_6HK.js} +147 -60
  33. package/dist/chunks/{LpLink-DCND-zyu.js → LpLink-Krkjhe_y.js} +15 -6
  34. package/dist/chunks/{LpLogViewer-CaRy5MeS.js → LpLogViewer-YBOaFnc_.js} +68 -34
  35. package/dist/chunks/{LpMapPicker-ul4oYno6.js → LpMapPicker-NUzS9e37.js} +38 -13
  36. package/dist/chunks/{LpMediaGallery-FF-NquIo.js → LpMediaGallery-DfJmMW0I.js} +6 -3
  37. package/dist/chunks/{LpModal-Bt0pQCI_.js → LpModal-DPwGKBPv.js} +49 -26
  38. package/dist/chunks/{LpModalNav-YaifHrXC.js → LpModalNav-DPg5H9f3.js} +9 -6
  39. package/dist/chunks/{LpNotificationBell-DyGGsMAM.js → LpNotificationBell-t5yn-siX.js} +58 -24
  40. package/dist/chunks/{LpNumberField-CWuX08iX.js → LpNumberField-DWnQKktS.js} +20 -7
  41. package/dist/chunks/{LpNumberFlow-amWYaeuA.js → LpNumberFlow-z4WPZcZu.js} +22 -12
  42. package/dist/chunks/{LpOtpInput-y2v-pmiN.js → LpOtpInput-CziPF3Oq.js} +37 -8
  43. package/dist/chunks/{LpPagination-5FxjmmB5.js → LpPagination-DFngX7Xo.js} +30 -19
  44. package/dist/chunks/{LpPasswordInput--sjYCkLS.js → LpPasswordInput-CWVDuBJ5.js} +10 -7
  45. package/dist/chunks/{LpPhoneInput-BDSKVl_z.js → LpPhoneInput-CD-ZTeSd.js} +57 -33
  46. package/dist/chunks/{LpPie-D6OnzwlJ.js → LpPie-BLco31Nx.js} +78 -21
  47. package/dist/chunks/{LpPopover-8268UmlC.js → LpPopover-p_e_shN3.js} +4 -2
  48. package/dist/chunks/{LpProgress-Btq5W4Ut.js → LpProgress-CBU1iI69.js} +5 -4
  49. package/dist/chunks/{LpRadioGroup-xPIuVDO_.js → LpRadioGroup-DmEW0l78.js} +9 -2
  50. package/dist/chunks/{LpSegmented-fV0Sk92p.js → LpSegmented-Dejd7cIx.js} +7 -3
  51. package/dist/chunks/{LpSelect-ANIVtrpM.js → LpSelect-DrRD9I7L.js} +40 -18
  52. package/dist/chunks/{LpSidebar-ClBUwoEz.js → LpSidebar-BgwUNWpJ.js} +30 -22
  53. package/dist/chunks/{LpSidebarNav-llOcs_Fn.js → LpSidebarNav-Bqxz8aWT.js} +14 -9
  54. package/dist/chunks/{LpSkeleton-CTI-vBgW.js → LpSkeleton-BB6XIV-w.js} +1 -1
  55. package/dist/chunks/{LpSlider-BwNIBkAK.js → LpSlider-Cuzmrd7p.js} +7 -2
  56. package/dist/chunks/{LpSparkline-C6xQ2dwv.js → LpSparkline-CB80N8SJ.js} +11 -4
  57. package/dist/chunks/{LpStat-CQaW8R--.js → LpStat-dXtyrdX9.js} +1 -1
  58. package/dist/chunks/{LpSwitch-Blizvqyt.js → LpSwitch-B1aLKulj.js} +40 -26
  59. package/dist/chunks/{LpTableOfContents-CjvxPMpU.js → LpTableOfContents-BUcAfGPQ.js} +36 -41
  60. package/dist/chunks/{LpTabs-Cefe_lys.js → LpTabs-_qMVaZJ6.js} +19 -5
  61. package/dist/chunks/LpTextarea-yP8hGedR.js +77 -0
  62. package/dist/chunks/LpThemeProvider-Wjm7Tuit.js +35 -0
  63. package/dist/chunks/LpThemeSwitcher-CKLc10KZ.js +169 -0
  64. package/dist/chunks/{LpTilt-DuyKbpkA.js → LpTilt-B7KHX1zH.js} +2 -2
  65. package/dist/chunks/{LpTimeField-K6nAGboX.js → LpTimeField-C5HRgS5H.js} +13 -7
  66. package/dist/chunks/{LpToaster-gDB1EaU3.js → LpToaster--oT6lfhL.js} +57 -28
  67. package/dist/chunks/{LpTooltip-hCLPAqMG.js → LpTooltip-CBdzeKNf.js} +13 -6
  68. package/dist/chunks/LpTooltipProvider-CftjV7Xc.js +25 -0
  69. package/dist/chunks/{LpTopologyCanvas-Cm1L39uL.js → LpTopologyCanvas-DocsLal3.js} +6 -1
  70. package/dist/chunks/{LpUptimeBar-DvAMno0S.js → LpUptimeBar-DLtxOMRy.js} +43 -14
  71. package/dist/chunks/{LpThemeSwitcher-Cue-myST.js → useTheme-C8JHUlPM.js} +173 -215
  72. package/dist/component-names.d.ts +1 -1
  73. package/dist/components/ContextMenuItems.vue.d.ts +8 -0
  74. package/dist/components/ContextMenuItems.vue.js +4 -0
  75. package/dist/components/LayoutCanvas.vue.js +1 -1
  76. package/dist/components/LayoutNode.vue.d.ts +4 -0
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.d.ts +3 -0
  79. package/dist/components/LpActionBar.vue.js +1 -1
  80. package/dist/components/LpAddressInput.vue.d.ts +1 -1
  81. package/dist/components/LpAddressInput.vue.js +1 -1
  82. package/dist/components/LpAlert.vue.d.ts +9 -0
  83. package/dist/components/LpAlert.vue.js +1 -1
  84. package/dist/components/LpAppShell.vue.d.ts +10 -0
  85. package/dist/components/LpAppShell.vue.js +1 -1
  86. package/dist/components/LpAutocomplete.vue.d.ts +31 -6
  87. package/dist/components/LpAutocomplete.vue.js +1 -1
  88. package/dist/components/LpAvatar.vue.d.ts +3 -3
  89. package/dist/components/LpAvatar.vue.js +1 -1
  90. package/dist/components/LpBadge.vue.d.ts +2 -2
  91. package/dist/components/LpBreadcrumbs.vue.d.ts +13 -9
  92. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  93. package/dist/components/LpButton.vue.d.ts +35 -98
  94. package/dist/components/LpButton.vue.js +1 -1
  95. package/dist/components/LpCard.vue.js +1 -1
  96. package/dist/components/LpChart.vue.d.ts +31 -2
  97. package/dist/components/LpChart.vue.js +317 -95
  98. package/dist/components/LpCheckbox.vue.js +1 -1
  99. package/dist/components/LpCodeBlock.vue.d.ts +2 -0
  100. package/dist/components/LpCodeBlock.vue.js +1 -1
  101. package/dist/components/LpColorPicker.vue.js +1 -1
  102. package/dist/components/LpCommandPalette.vue.d.ts +8 -3
  103. package/dist/components/LpCommandPalette.vue.js +86 -72
  104. package/dist/components/LpConfirmDialog.vue.d.ts +15 -3
  105. package/dist/components/LpConfirmDialog.vue.js +1 -1
  106. package/dist/components/LpConfirmHost.vue.d.ts +9 -0
  107. package/dist/components/LpConfirmHost.vue.js +4 -0
  108. package/dist/components/LpContextMenu.vue.d.ts +3 -5
  109. package/dist/components/LpContextMenu.vue.js +1 -1
  110. package/dist/components/LpDatePicker.vue.d.ts +24 -6
  111. package/dist/components/LpDatePicker.vue.js +1 -1
  112. package/dist/components/LpDrawer.vue.d.ts +10 -0
  113. package/dist/components/LpDrawer.vue.js +1 -1
  114. package/dist/components/LpDropzone.vue.js +22 -6
  115. package/dist/components/LpFileTree.vue.d.ts +11 -2
  116. package/dist/components/LpFileTree.vue.js +1 -1
  117. package/dist/components/LpFileTreeNode.vue.js +1 -1
  118. package/dist/components/LpFormField.vue.d.ts +9 -0
  119. package/dist/components/LpFormField.vue.js +1 -1
  120. package/dist/components/LpImageEditor.vue.js +92 -57
  121. package/dist/components/LpInput.vue.d.ts +3 -3
  122. package/dist/components/LpInput.vue.js +1 -1
  123. package/dist/components/LpLightbox.vue.d.ts +17 -0
  124. package/dist/components/LpLightbox.vue.js +1 -1
  125. package/dist/components/LpLink.vue.d.ts +11 -11
  126. package/dist/components/LpLink.vue.js +1 -1
  127. package/dist/components/LpLogViewer.vue.d.ts +20 -2
  128. package/dist/components/LpLogViewer.vue.js +1 -1
  129. package/dist/components/LpMapPicker.vue.d.ts +4 -4
  130. package/dist/components/LpMapPicker.vue.js +1 -1
  131. package/dist/components/LpMediaGallery.vue.js +1 -1
  132. package/dist/components/LpModal.vue.d.ts +16 -6
  133. package/dist/components/LpModal.vue.js +1 -1
  134. package/dist/components/LpModalNav.vue.d.ts +0 -1
  135. package/dist/components/LpModalNav.vue.js +1 -1
  136. package/dist/components/LpNotificationBell.vue.d.ts +11 -2
  137. package/dist/components/LpNotificationBell.vue.js +1 -1
  138. package/dist/components/LpNumberField.vue.d.ts +2 -0
  139. package/dist/components/LpNumberField.vue.js +1 -1
  140. package/dist/components/LpNumberFlow.vue.js +1 -1
  141. package/dist/components/LpOtpInput.vue.js +1 -1
  142. package/dist/components/LpPagination.vue.d.ts +2 -0
  143. package/dist/components/LpPagination.vue.js +1 -1
  144. package/dist/components/LpPasswordInput.vue.js +1 -1
  145. package/dist/components/LpPhoneInput.vue.js +1 -1
  146. package/dist/components/LpPie.vue.d.ts +21 -2
  147. package/dist/components/LpPie.vue.js +1 -1
  148. package/dist/components/LpPopover.vue.d.ts +2 -0
  149. package/dist/components/LpPopover.vue.js +1 -1
  150. package/dist/components/LpProgress.vue.js +1 -1
  151. package/dist/components/LpRadioGroup.vue.js +1 -1
  152. package/dist/components/LpSection.vue.d.ts +1 -1
  153. package/dist/components/LpSegmented.vue.d.ts +5 -0
  154. package/dist/components/LpSegmented.vue.js +1 -1
  155. package/dist/components/LpSelect.vue.d.ts +1 -0
  156. package/dist/components/LpSelect.vue.js +1 -1
  157. package/dist/components/LpShortcutsDialog.vue.d.ts +1 -1
  158. package/dist/components/LpShortcutsDialog.vue.js +11 -8
  159. package/dist/components/LpSidebar.vue.d.ts +6 -2
  160. package/dist/components/LpSidebar.vue.js +1 -1
  161. package/dist/components/LpSidebarNav.vue.js +1 -1
  162. package/dist/components/LpSkeleton.vue.js +1 -1
  163. package/dist/components/LpSlider.vue.js +1 -1
  164. package/dist/components/LpSparkline.vue.d.ts +1 -0
  165. package/dist/components/LpSparkline.vue.js +1 -1
  166. package/dist/components/LpStat.vue.js +1 -1
  167. package/dist/components/LpSwitch.vue.js +1 -1
  168. package/dist/components/LpTable.vue.d.ts +23 -1
  169. package/dist/components/LpTable.vue.js +74 -36
  170. package/dist/components/LpTableOfContents.vue.d.ts +4 -2
  171. package/dist/components/LpTableOfContents.vue.js +1 -1
  172. package/dist/components/LpTabs.vue.d.ts +2 -0
  173. package/dist/components/LpTabs.vue.js +1 -1
  174. package/dist/components/LpTextarea.vue.d.ts +19 -6
  175. package/dist/components/LpTextarea.vue.js +1 -1
  176. package/dist/components/LpThemeProvider.vue.d.ts +22 -0
  177. package/dist/components/LpThemeProvider.vue.js +4 -0
  178. package/dist/components/LpThemeSwitcher.vue.d.ts +13 -0
  179. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  180. package/dist/components/LpTilt.vue.js +1 -1
  181. package/dist/components/LpTimeField.vue.js +1 -1
  182. package/dist/components/LpToaster.vue.d.ts +6 -1
  183. package/dist/components/LpToaster.vue.js +1 -1
  184. package/dist/components/LpTooltip.vue.d.ts +1 -2
  185. package/dist/components/LpTooltip.vue.js +1 -1
  186. package/dist/components/LpTooltipProvider.vue.d.ts +22 -0
  187. package/dist/components/LpTooltipProvider.vue.js +4 -0
  188. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  189. package/dist/components/LpUptimeBar.vue.d.ts +4 -0
  190. package/dist/components/LpUptimeBar.vue.js +1 -1
  191. package/dist/components/chart.d.ts +32 -1
  192. package/dist/components/chart.js +39 -4
  193. package/dist/components/codeHighlight.d.ts +6 -0
  194. package/dist/components/codeHighlight.js +92 -20
  195. package/dist/components/color.d.ts +1 -1
  196. package/dist/components/color.js +20 -9
  197. package/dist/components/confirm.d.ts +25 -0
  198. package/dist/components/confirm.js +26 -0
  199. package/dist/components/countries.d.ts +6 -0
  200. package/dist/components/countries.js +7 -0
  201. package/dist/components/fileTree.js +16 -7
  202. package/dist/components/imageCrop.d.ts +42 -0
  203. package/dist/components/imageCrop.js +47 -0
  204. package/dist/components/link.d.ts +12 -0
  205. package/dist/components/link.js +19 -0
  206. package/dist/components/modalDepth.d.ts +18 -2
  207. package/dist/components/modalDepth.js +17 -5
  208. package/dist/components/otp.d.ts +5 -0
  209. package/dist/components/otp.js +10 -0
  210. package/dist/components/sidebar.d.ts +7 -1
  211. package/dist/components/tableSort.d.ts +8 -0
  212. package/dist/components/tableSort.js +31 -0
  213. package/dist/composables/useField.d.ts +34 -0
  214. package/dist/composables/useField.js +45 -0
  215. package/dist/composables/useFormDraft.d.ts +10 -3
  216. package/dist/composables/useFormDraft.js +9 -14
  217. package/dist/composables/useHotkeys.d.ts +17 -1
  218. package/dist/composables/useHotkeys.js +55 -18
  219. package/dist/composables/useInputFilter.d.ts +17 -0
  220. package/dist/composables/useInputFilter.js +47 -10
  221. package/dist/composables/useLpConfig.d.ts +24 -0
  222. package/dist/composables/useLpConfig.js +44 -0
  223. package/dist/composables/useRovingFocus.js +7 -0
  224. package/dist/composables/useTilt.d.ts +2 -2
  225. package/dist/composables/useTilt.js +5 -6
  226. package/dist/composables/useToast.d.ts +20 -0
  227. package/dist/composables/useToast.js +28 -4
  228. package/dist/composables/useZoomPan.d.ts +3 -3
  229. package/dist/composables/useZoomPan.js +4 -4
  230. package/dist/index.d.ts +32 -2
  231. package/dist/index.js +184 -137
  232. package/dist/layout/tree.d.ts +2 -0
  233. package/dist/layout/tree.js +16 -1
  234. package/dist/layout/useLayout.d.ts +15 -0
  235. package/dist/layout/useLayout.js +41 -14
  236. package/dist/theme/useTheme.d.ts +32 -9
  237. package/package.json +8 -7
  238. package/src/component-names.ts +5 -7
  239. package/src/components/ContextMenuItems.vue +88 -0
  240. package/src/components/LayoutCanvas.vue +26 -6
  241. package/src/components/LayoutNode.vue +19 -2
  242. package/src/components/LpActionBar.vue +30 -7
  243. package/src/components/LpAddressInput.vue +57 -41
  244. package/src/components/LpAlert.vue +16 -3
  245. package/src/components/LpAppShell.vue +45 -12
  246. package/src/components/LpAutocomplete.vue +46 -10
  247. package/src/components/LpAvatar.vue +19 -5
  248. package/src/components/LpBreadcrumbs.vue +65 -41
  249. package/src/components/LpButton.vue +59 -13
  250. package/src/components/LpChart.vue +272 -28
  251. package/src/components/LpCheckbox.vue +7 -3
  252. package/src/components/LpCodeBlock.vue +50 -14
  253. package/src/components/LpColorPicker.vue +39 -9
  254. package/src/components/LpCommandPalette.vue +34 -13
  255. package/src/components/LpConfirmDialog.vue +68 -17
  256. package/src/components/LpConfirmHost.vue +59 -0
  257. package/src/components/LpContextMenu.vue +14 -115
  258. package/src/components/LpDatePicker.vue +99 -68
  259. package/src/components/LpDrawer.vue +56 -19
  260. package/src/components/LpDropzone.vue +21 -4
  261. package/src/components/LpFileTree.vue +33 -7
  262. package/src/components/LpFormField.vue +30 -7
  263. package/src/components/LpImageEditor.vue +96 -57
  264. package/src/components/LpInput.vue +18 -7
  265. package/src/components/LpLightbox.vue +140 -29
  266. package/src/components/LpLink.vue +24 -13
  267. package/src/components/LpLogViewer.vue +86 -17
  268. package/src/components/LpMapPicker.vue +36 -13
  269. package/src/components/LpMediaGallery.vue +8 -2
  270. package/src/components/LpModal.vue +53 -27
  271. package/src/components/LpModalNav.vue +5 -2
  272. package/src/components/LpNotificationBell.vue +63 -17
  273. package/src/components/LpNumberField.vue +21 -6
  274. package/src/components/LpNumberFlow.vue +26 -10
  275. package/src/components/LpOtpInput.vue +43 -8
  276. package/src/components/LpPagination.vue +23 -7
  277. package/src/components/LpPasswordInput.vue +4 -1
  278. package/src/components/LpPhoneInput.vue +53 -27
  279. package/src/components/LpPie.vue +94 -15
  280. package/src/components/LpPopover.vue +3 -0
  281. package/src/components/LpProgress.vue +5 -3
  282. package/src/components/LpRadioGroup.vue +9 -2
  283. package/src/components/LpSegmented.vue +12 -2
  284. package/src/components/LpSelect.vue +0 -0
  285. package/src/components/LpShortcutsDialog.vue +8 -3
  286. package/src/components/LpSidebar.vue +14 -9
  287. package/src/components/LpSidebarNav.vue +17 -5
  288. package/src/components/LpSkeleton.vue +4 -1
  289. package/src/components/LpSlider.vue +7 -1
  290. package/src/components/LpSparkline.vue +11 -3
  291. package/src/components/LpSwitch.vue +30 -7
  292. package/src/components/LpTable.vue +91 -32
  293. package/src/components/LpTableOfContents.vue +50 -32
  294. package/src/components/LpTabs.vue +23 -6
  295. package/src/components/LpTextarea.vue +44 -6
  296. package/src/components/LpThemeProvider.vue +41 -0
  297. package/src/components/LpThemeSwitcher.vue +64 -17
  298. package/src/components/LpTilt.vue +2 -2
  299. package/src/components/LpTimeField.vue +14 -3
  300. package/src/components/LpToaster.vue +66 -26
  301. package/src/components/LpTooltip.vue +22 -5
  302. package/src/components/LpTooltipProvider.vue +24 -0
  303. package/src/components/LpTopologyCanvas.vue +12 -1
  304. package/src/components/LpUptimeBar.vue +46 -8
  305. package/src/components/chart.ts +77 -5
  306. package/src/components/codeHighlight.ts +111 -15
  307. package/src/components/color.ts +30 -9
  308. package/src/components/confirm.ts +58 -0
  309. package/src/components/countries.ts +12 -0
  310. package/src/components/fileTree.ts +19 -7
  311. package/src/components/imageCrop.ts +107 -0
  312. package/src/components/link.ts +34 -0
  313. package/src/components/modalDepth.ts +48 -6
  314. package/src/components/otp.ts +16 -0
  315. package/src/components/sidebar.ts +7 -1
  316. package/src/components/tableSort.ts +37 -0
  317. package/src/composables/useField.ts +79 -0
  318. package/src/composables/useFormDraft.ts +25 -20
  319. package/src/composables/useHotkeys.ts +89 -18
  320. package/src/composables/useInputFilter.ts +77 -14
  321. package/src/composables/useLpConfig.ts +87 -0
  322. package/src/composables/useRovingFocus.ts +11 -0
  323. package/src/composables/useTilt.ts +5 -6
  324. package/src/composables/useToast.ts +56 -4
  325. package/src/composables/useZoomPan.ts +6 -6
  326. package/src/index.ts +37 -2
  327. package/src/layout/tree.ts +30 -0
  328. package/src/layout/useLayout.ts +68 -18
  329. package/src/nuxt.ts +59 -17
  330. package/src/runtime/config.plugin.ts +14 -0
  331. package/src/runtime/theme.plugin.ts +42 -22
  332. package/src/theme/useTheme.ts +206 -100
  333. package/dist/chunks/LpBreadcrumbs-p4jHe_V-.js +0 -62
  334. package/dist/chunks/LpContextMenu-CwW1QXe_.js +0 -217
  335. package/dist/chunks/LpDatePicker-CwOToxGq.js +0 -164
  336. package/dist/chunks/LpFormField-CazBc2-a.js +0 -62
  337. package/dist/chunks/LpTextarea-t7F6gv8E.js +0 -47
@@ -22,11 +22,29 @@ import {
22
22
  DialogTitle,
23
23
  } from "reka-ui"
24
24
  import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"
25
+ import { useLabels } from "../composables/useLpConfig"
25
26
  import { useZoomPan } from "../composables/useZoomPan"
26
27
  import { fileNameOf, type LightboxItem } from "./lightbox"
27
28
  import LpIcon from "./LpIcon.vue"
28
29
  import { useModalLayer } from "./modalDepth"
29
30
 
31
+ export type LightboxLabels = {
32
+ viewer: string
33
+ /** `{index}` and `{total}` are filled in. */
34
+ position: string
35
+ previous: string
36
+ next: string
37
+ zoomIn: string
38
+ zoomOut: string
39
+ rotate: string
40
+ copy: string
41
+ download: string
42
+ close: string
43
+ failed: string
44
+ spoiler: string
45
+ reveal: string
46
+ }
47
+
30
48
  const props = withDefaults(
31
49
  defineProps<{
32
50
  open?: boolean
@@ -46,6 +64,7 @@ const props = withDefaults(
46
64
  loop?: boolean
47
65
  /** Largest zoom level. */
48
66
  maxZoom?: number
67
+ labels?: Partial<LightboxLabels>
49
68
  }>(),
50
69
  {
51
70
  open: false,
@@ -68,7 +87,31 @@ const emit = defineEmits<{
68
87
  (e: "copy", item: LightboxItem, ok: boolean): void
69
88
  }>()
70
89
 
71
- const { claim, release, layer } = useModalLayer()
90
+ const text = useLabels<LightboxLabels>(
91
+ "lightbox",
92
+ {
93
+ viewer: "Image viewer",
94
+ position: "Image {index} of {total}",
95
+ previous: "Previous image",
96
+ next: "Next image",
97
+ zoomIn: "Zoom in (+)",
98
+ zoomOut: "Zoom out (−)",
99
+ rotate: "Rotate (R)",
100
+ copy: "Copy image",
101
+ download: "Download",
102
+ close: "Close (Esc)",
103
+ failed: "This image couldn't be loaded.",
104
+ spoiler: "Spoiler",
105
+ reveal: "Show image",
106
+ },
107
+ () => props.labels,
108
+ )
109
+
110
+ const position = (i: number) =>
111
+ text.value.position.replace("{index}", String(i + 1)).replace("{total}", String(props.items.length))
112
+
113
+ const contentRef = ref<{ $el: HTMLElement } | null>(null)
114
+ const { claim, release, layer } = useModalLayer({ element: () => contentRef.value?.$el })
72
115
  watch(() => props.open, (open) => (open ? claim() : release()), { immediate: true })
73
116
  onBeforeUnmount(release)
74
117
 
@@ -79,7 +122,7 @@ defineSlots<{
79
122
  actions(props: { item: LightboxItem; index: number }): unknown
80
123
  }>()
81
124
 
82
- const zoom = useZoomPan({ max: props.maxZoom })
125
+ const zoom = useZoomPan({ max: () => props.maxZoom })
83
126
 
84
127
  // `zoom` is a plain object, so its refs don't unwrap in the template — surface
85
128
  // the two the chrome reads as computeds rather than writing `.value` in markup.
@@ -87,7 +130,11 @@ const scale = computed(() => zoom.scale.value)
87
130
  const isZoomed = computed(() => zoom.zoomed.value)
88
131
  const isPanning = computed(() => zoom.panning.value)
89
132
 
90
- const current = computed(() => Math.min(Math.max(props.index, 0), Math.max(props.items.length - 1, 0)))
133
+ // Works with and without v-model:index: the prop seeds and steers it, paging
134
+ // moves it locally and reports the move.
135
+ const shown = ref(props.index)
136
+ watch(() => props.index, (i) => (shown.value = i))
137
+ const current = computed(() => Math.min(Math.max(shown.value, 0), Math.max(props.items.length - 1, 0)))
91
138
  const item = computed<LightboxItem | undefined>(() => props.items[current.value])
92
139
  const total = computed(() => props.items.length)
93
140
  const showStrip = computed(() => props.filmstrip ?? total.value > 1)
@@ -113,6 +160,7 @@ function go(next: number) {
113
160
  // Compare against the raw target, not the wrapped one: stepping past the last
114
161
  // image is still "forward" even though the index jumps back to 0.
115
162
  direction.value = next > current.value ? 1 : -1
163
+ shown.value = wrapped
116
164
  emit("update:index", wrapped)
117
165
  emit("change", wrapped, props.items[wrapped])
118
166
  }
@@ -193,6 +241,23 @@ function close() {
193
241
  emit("update:open", false)
194
242
  }
195
243
 
244
+ /*
245
+ * A spoiler stays covered until it is asked for, here as in the gallery: the
246
+ * viewer is often opened straight from a link or a filmstrip, past whatever
247
+ * cover the grid had.
248
+ */
249
+ const revealed = ref<Set<number>>(new Set())
250
+ const covered = (i: number) => !!props.items[i]?.spoiler && !revealed.value.has(i)
251
+ function reveal(i: number) {
252
+ revealed.value = new Set(revealed.value).add(i)
253
+ }
254
+ watch(
255
+ () => props.open,
256
+ (open) => {
257
+ if (!open) revealed.value = new Set()
258
+ },
259
+ )
260
+
196
261
  function onKeydown(e: KeyboardEvent) {
197
262
  switch (e.key) {
198
263
  case "ArrowRight":
@@ -270,21 +335,51 @@ function onViewportClick(e: MouseEvent) {
270
335
  close()
271
336
  }
272
337
 
273
- async function download() {
274
- const target = item.value
275
- if (!target) return
276
- emit("download", target)
338
+ function sameOrigin(src: string): boolean {
339
+ try {
340
+ const url = new URL(src, window.location.href)
341
+ return url.protocol === "blob:" || url.protocol === "data:" || url.origin === window.location.origin
342
+ } catch {
343
+ return false
344
+ }
345
+ }
346
+
347
+ function save(href: string, name: string, newTab = false) {
277
348
  const a = document.createElement("a")
278
- a.href = target.src
279
- a.download = fileNameOf(target)
280
- // Cross-origin sources ignore `download` and just navigate, so open those in
281
- // a new tab instead of replacing the app.
282
- a.rel = "noopener"
349
+ a.href = href
350
+ if (newTab) {
351
+ a.target = "_blank"
352
+ a.rel = "noopener"
353
+ } else {
354
+ a.download = name
355
+ }
283
356
  document.body.appendChild(a)
284
357
  a.click()
285
358
  a.remove()
286
359
  }
287
360
 
361
+ /*
362
+ * A cross-origin `download` attribute is ignored and the link just navigates,
363
+ * which unloaded the whole app in favour of the image. Such a source is fetched
364
+ * into a blob first; one that refuses CORS opens in a new tab instead.
365
+ */
366
+ async function download() {
367
+ const target = item.value
368
+ if (!target) return
369
+ emit("download", target)
370
+ const name = fileNameOf(target)
371
+ if (sameOrigin(target.src)) return save(target.src, name)
372
+ try {
373
+ const response = await fetch(target.src, { mode: "cors" })
374
+ if (!response.ok) throw new Error(String(response.status))
375
+ const url = URL.createObjectURL(await response.blob())
376
+ save(url, name)
377
+ window.setTimeout(() => URL.revokeObjectURL(url), 0)
378
+ } catch {
379
+ save(target.src, name, true)
380
+ }
381
+ }
382
+
288
383
  async function copy() {
289
384
  const target = item.value
290
385
  if (!target) return
@@ -346,14 +441,15 @@ const ARROW =
346
441
  :style="{ zIndex: layer.scrim }"
347
442
  />
348
443
  <DialogContent
444
+ ref="contentRef"
349
445
  class="fixed inset-0 flex flex-col outline-none data-[state=open]:animate-[lightbox-in_var(--duration-medium)_var(--ease-emphasized)] data-[state=closed]:animate-[lightbox-out_140ms_cubic-bezier(0.4,0,1,1)] motion-reduce:animate-none"
350
446
  :style="{ zIndex: layer.panel }"
351
447
  @keydown="onKeydown"
352
448
  >
353
449
  <!-- reka wants both for a labelled dialog; the caption is the title. -->
354
- <DialogTitle class="sr-only">{{ item?.title ?? "Image viewer" }}</DialogTitle>
450
+ <DialogTitle class="sr-only">{{ item?.title ?? text.viewer }}</DialogTitle>
355
451
  <DialogDescription class="sr-only">
356
- {{ item?.description ?? `Image ${current + 1} of ${total}` }}
452
+ {{ item?.description ?? position(current) }}
357
453
  </DialogDescription>
358
454
 
359
455
  <!-- Toolbar. Chrome drifts in from its own edge, slightly behind the
@@ -369,34 +465,34 @@ const ARROW =
369
465
  <div class="ml-auto flex items-center gap-1">
370
466
  <slot name="actions" :item="item!" :index="current" />
371
467
 
372
- <button :class="BTN" type="button" title="Zoom out (−)" @click="zoom.zoomBy(1 / 1.3)">
468
+ <button :class="BTN" type="button" :title="text.zoomOut" @click="zoom.zoomBy(1 / 1.3)">
373
469
  <LpIcon name="lucide:zoom-out" :size="18" />
374
470
  </button>
375
- <button :class="BTN" type="button" title="Zoom in (+)" @click="zoom.zoomBy(1.3)">
471
+ <button :class="BTN" type="button" :title="text.zoomIn" @click="zoom.zoomBy(1.3)">
376
472
  <LpIcon name="lucide:zoom-in" :size="18" />
377
473
  </button>
378
474
  <button
379
475
  v-if="rotatable"
380
476
  :class="BTN"
381
477
  type="button"
382
- title="Rotate (R)"
478
+ :title="text.rotate"
383
479
  @click="rotate"
384
480
  >
385
481
  <LpIcon name="lucide:rotate-cw" :size="18" />
386
482
  </button>
387
- <button v-if="copyable" :class="BTN" type="button" title="Copy image" @click="copy">
483
+ <button v-if="copyable" :class="BTN" type="button" :title="text.copy" @click="copy">
388
484
  <LpIcon name="lucide:copy" :size="18" />
389
485
  </button>
390
486
  <button
391
487
  v-if="downloadable"
392
488
  :class="BTN"
393
489
  type="button"
394
- title="Download"
490
+ :title="text.download"
395
491
  @click="download"
396
492
  >
397
493
  <LpIcon name="lucide:download" :size="18" />
398
494
  </button>
399
- <button :class="BTN" type="button" title="Close (Esc)" @click="close">
495
+ <button :class="BTN" type="button" :title="text.close" @click="close">
400
496
  <LpIcon name="lucide:x" :size="20" />
401
497
  </button>
402
498
  </div>
@@ -419,7 +515,7 @@ const ARROW =
419
515
  :class="[ARROW, 'left-4']"
420
516
  type="button"
421
517
  :disabled="!canPrev"
422
- aria-label="Previous image"
518
+ :aria-label="text.previous"
423
519
  @click.stop="go(current - 1)"
424
520
  >
425
521
  <LpIcon name="lucide:chevron-left" :size="24" />
@@ -430,14 +526,28 @@ const ARROW =
430
526
  class="flex flex-col items-center gap-3 text-white/60"
431
527
  >
432
528
  <LpIcon name="lucide:image-off" :size="40" />
433
- <p class="text-sm">This image couldn't be loaded.</p>
529
+ <p class="text-sm">{{ text.failed }}</p>
530
+ </div>
531
+ <div
532
+ v-else-if="item && covered(current)"
533
+ class="flex flex-col items-center gap-3 text-white/70"
534
+ >
535
+ <LpIcon name="lucide:eye-off" :size="36" />
536
+ <p class="text-sm font-medium">{{ text.spoiler }}</p>
537
+ <button
538
+ type="button"
539
+ class="rounded-control bg-white/15 px-3 py-1.5 text-sm text-white outline-none transition-colors hover:bg-white/25 focus-visible:ring-2 focus-visible:ring-ring"
540
+ @click.stop="reveal(current)"
541
+ >
542
+ {{ text.reveal }}
543
+ </button>
434
544
  </div>
435
- <!-- Keyed on src so paging remounts the img and replays the slide;
436
- without the key Vue patches it in place and the change is a hard
437
- cut with no sense of direction. -->
545
+ <!-- Keyed on position and src so paging remounts the img and replays
546
+ the slide, even between two items with the same source (whose
547
+ load event would otherwise never fire, leaving the spinner up). -->
438
548
  <img
439
549
  v-else-if="item"
440
- :key="item.src"
550
+ :key="`${current}:${item.src}`"
441
551
  data-lightbox-image
442
552
  :src="item.src"
443
553
  :alt="item.title ?? ''"
@@ -449,7 +559,7 @@ const ARROW =
449
559
  />
450
560
 
451
561
  <LpIcon
452
- v-if="loading && !failed"
562
+ v-if="loading && !failed && !covered(current)"
453
563
  name="lucide:loader-circle"
454
564
  :size="28"
455
565
  class="absolute animate-spin text-white/50"
@@ -459,7 +569,7 @@ const ARROW =
459
569
  :class="[ARROW, 'right-4']"
460
570
  type="button"
461
571
  :disabled="!canNext"
462
- aria-label="Next image"
572
+ :aria-label="text.next"
463
573
  @click.stop="go(current + 1)"
464
574
  >
465
575
  <LpIcon name="lucide:chevron-right" :size="24" />
@@ -494,13 +604,14 @@ const ARROW =
494
604
  :style="{ '--thumb-delay': `${Math.min(i * 30, 240)}ms` }"
495
605
  class="size-14 shrink-0 overflow-hidden rounded-control outline-none ring-2 transition-[opacity,box-shadow,scale] duration-[var(--duration-fast)] hover:scale-105 focus-visible:ring-ring animate-[lightbox-chrome-in_var(--duration-medium)_var(--ease-emphasized)_both] [animation-delay:var(--thumb-delay)] motion-reduce:animate-none"
496
606
  :class="i === current ? 'opacity-100 ring-brand' : 'opacity-45 ring-transparent hover:opacity-80'"
497
- :aria-label="entry.title ?? `Image ${i + 1}`"
607
+ :aria-label="entry.title ?? position(i)"
498
608
  @click="go(i)"
499
609
  >
500
610
  <img
501
611
  :src="entry.thumb ?? entry.src"
502
612
  :alt="entry.title ?? ''"
503
613
  class="size-full object-cover"
614
+ :class="covered(i) ? 'scale-110 blur-md' : ''"
504
615
  draggable="false"
505
616
  />
506
617
  </button>
@@ -7,11 +7,15 @@ export default { inheritAttrs: false }
7
7
 
8
8
  <script setup lang="ts">
9
9
  import { computed, type Component } from "vue"
10
+ import { useLinkComponent } from "../composables/useLpConfig"
10
11
  import { useMergedAttrs } from "../composables/useMergedClass"
12
+ import { resolveLink, type LinkTarget } from "./link"
11
13
 
12
14
  const props = withDefaults(
13
15
  defineProps<{
14
16
  href?: string
17
+ /** Router destination; rendered through the configured link component. */
18
+ to?: LinkTarget
15
19
  external?: boolean
16
20
  /** Shorthand for `tone="muted"`. */
17
21
  muted?: boolean
@@ -23,11 +27,9 @@ const props = withDefaults(
23
27
  */
24
28
  tone?: "brand" | "ink" | "muted" | "soft"
25
29
  /**
26
- * Root element/component. Defaults to a native <a>. Pass a router link
27
- * component (e.g. NuxtLink or RouterLink) to keep client-side navigation;
28
- * the kit stays DOM-agnostic, the consumer supplies the link component.
29
- * Router links take their destination through their own prop (`to`), which
30
- * falls through as a plain attribute.
30
+ * Root element/component. By default an internal `to`/`href` renders the
31
+ * link component set with provideLpConfig (NuxtLink under the Nuxt module),
32
+ * anything else a native <a>. Pass a component here to override per link.
31
33
  */
32
34
  as?: string | Component
33
35
  /**
@@ -50,13 +52,23 @@ const props = withDefaults(
50
52
  */
51
53
  revealOn?: "self" | "group"
52
54
  }>(),
53
- { variant: "default", as: "a", revealOn: "self" },
55
+ { variant: "default", revealOn: "self" },
54
56
  )
55
57
 
56
- // A router link component owns its destination through its own prop (`to`),
57
- // so `href` is only bound when this really renders an anchor. `target`/`rel`
58
- // stay in play either way — router links honour them for external targets.
59
- const isAnchor = computed(() => props.as === "a")
58
+ const linkComponent = useLinkComponent()
59
+
60
+ // An explicit `as` keeps its old contract (href on an anchor, `to` passed on to
61
+ // the component). Otherwise the target decides, and an external link is always
62
+ // a plain anchor.
63
+ const resolved = computed(() => {
64
+ if (props.as) {
65
+ return props.as === "a"
66
+ ? { is: "a", props: { href: props.href } }
67
+ : { is: props.as, props: props.to != null ? { to: props.to } : { href: props.href } }
68
+ }
69
+ const component = props.external ? "a" : linkComponent.value
70
+ return resolveLink(component, { to: props.to, href: props.href }) ?? { is: "a", props: {} }
71
+ })
60
72
 
61
73
  const rel = computed(() => (props.external ? "noopener noreferrer" : undefined))
62
74
  const target = computed(() => (props.external ? "_blank" : undefined))
@@ -121,12 +133,11 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
121
133
 
122
134
  <template>
123
135
  <component
124
- :is="as"
125
- :href="isAnchor ? href : undefined"
136
+ :is="resolved.is"
137
+ v-bind="{ ...resolved.props, ...rest }"
126
138
  :target="target"
127
139
  :rel="rel"
128
140
  :class="rootClass"
129
- v-bind="rest"
130
141
  >
131
142
  <slot />
132
143
  </component>
@@ -31,6 +31,26 @@ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
31
31
  import LpIcon from "./LpIcon.vue"
32
32
  import LpScrollArea from "./LpScrollArea.vue"
33
33
  import { useMergedAttrs } from "../composables/useMergedClass"
34
+ import { useLabels } from "../composables/useLpConfig"
35
+
36
+ export interface LogViewerLabels {
37
+ [key: string]: string
38
+ empty: string
39
+ frozen: string
40
+ noMatch: string
41
+ loadingOlder: string
42
+ copyMessage: string
43
+ copyLine: string
44
+ copyTimestamp: string
45
+ copied: string
46
+ /** `{source}` is replaced by the line's source. */
47
+ filterSource: string
48
+ /** `{level}` is replaced by the line's level. */
49
+ filterLevel: string
50
+ /** `{n}` is replaced by the count. */
51
+ identical: string
52
+ jump: string
53
+ }
34
54
 
35
55
  export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "success"
36
56
 
@@ -103,6 +123,7 @@ const props = withDefaults(
103
123
  * so a duplicate level word / timestamp doesn't repeat in the message body.
104
124
  */
105
125
  parse?: boolean
126
+ labels?: Partial<LogViewerLabels>
106
127
  }>(),
107
128
  {
108
129
  showTime: true,
@@ -111,8 +132,6 @@ const props = withDefaults(
111
132
  wrap: false,
112
133
  tail: true,
113
134
  height: "20rem",
114
- emptyLabel: "No logs yet",
115
- frozenLabel: "Held — release Ctrl to resume",
116
135
  rowMenu: true,
117
136
  parse: false,
118
137
  },
@@ -129,6 +148,25 @@ const emit = defineEmits<{
129
148
  (e: "reach-top"): void
130
149
  }>()
131
150
 
151
+ const text = useLabels<LogViewerLabels>(
152
+ "logViewer",
153
+ {
154
+ empty: "No logs yet",
155
+ frozen: "Held — release Ctrl to resume",
156
+ noMatch: "No lines match",
157
+ loadingOlder: "loading older…",
158
+ copyMessage: "Copy message",
159
+ copyLine: "Copy line",
160
+ copyTimestamp: "Copy timestamp",
161
+ copied: "Copied to clipboard",
162
+ filterSource: "Filter by [{source}]",
163
+ filterLevel: "Filter by {level}",
164
+ identical: "{n} identical lines",
165
+ jump: "Jump to latest",
166
+ },
167
+ () => props.labels,
168
+ )
169
+
132
170
  // Level → gutter rail + text colour. Reads only semantic tokens so re-skinning
133
171
  // just works. trace/debug stay muted; success is green; warn amber; error/fatal
134
172
  // red (fatal gets a filled chip to stand apart from plain error).
@@ -336,6 +374,24 @@ const visibleLines = computed<{ line: LogLine; n: number; count: number }[]>(()
336
374
  return folded
337
375
  })
338
376
 
377
+ /*
378
+ * Rows are keyed by the identity of the line object the consumer passed, not by
379
+ * position: prepending older history shifts every index, and index keys made
380
+ * the bottom rows play the enter animation instead of the new top ones.
381
+ */
382
+ const lineIds = new WeakMap<LogLine, number>()
383
+ let nextLineId = 0
384
+ function lineKey(n: number): number {
385
+ const line = sourceLines.value[n]
386
+ if (!line) return -1 - n
387
+ let id = lineIds.get(line)
388
+ if (id === undefined) {
389
+ id = nextLineId++
390
+ lineIds.set(line, id)
391
+ }
392
+ return id
393
+ }
394
+
339
395
  // True when filtering has emptied an otherwise non-empty stream.
340
396
  const filteredEmpty = computed(
341
397
  () => props.lines.length > 0 && visibleLines.value.length === 0,
@@ -356,7 +412,7 @@ function fmtTime(time?: number | string): string {
356
412
  // A menu item closes the menu as it runs, leaving nothing on screen to say the
357
413
  // copy happened — so unlike LpCodeBlock's button, this one announces itself.
358
414
  const { copy: writeClipboard } = useClipboard()
359
- const copy = (text: string) => writeClipboard(text, { toast: "Copied to clipboard" })
415
+ const copy = (value: string) => writeClipboard(value, { toast: text.value.copied })
360
416
 
361
417
  // The whole line as one copyable string: "time LEVEL [source] message".
362
418
  function lineText(line: LogLine): string {
@@ -398,15 +454,15 @@ function onRowPointerDown(e: PointerEvent) {
398
454
  function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
399
455
  if (!props.rowMenu) return []
400
456
  const items: ContextMenuItemDef[] = [
401
- { label: "Copy message", icon: "lucide:copy", onSelect: () => copy(line.message) },
402
- { label: "Copy line", icon: "lucide:clipboard-list", onSelect: () => copy(lineText(line)) },
457
+ { label: text.value.copyMessage, icon: "lucide:copy", onSelect: () => copy(line.message) },
458
+ { label: text.value.copyLine, icon: "lucide:clipboard-list", onSelect: () => copy(lineText(line)) },
403
459
  ]
404
460
  if (line.time != null) {
405
- items.push({ label: "Copy timestamp", icon: "lucide:clock", onSelect: () => copy(fmtTime(line.time)) })
461
+ items.push({ label: text.value.copyTimestamp, icon: "lucide:clock", onSelect: () => copy(fmtTime(line.time)) })
406
462
  }
407
463
  if (line.source) {
408
464
  items.push({
409
- label: `Filter by [${line.source}]`,
465
+ label: text.value.filterSource.replace("{source}", line.source),
410
466
  icon: "lucide:filter",
411
467
  separatorBefore: true,
412
468
  onSelect: () => emit("filter", { source: line.source }),
@@ -414,7 +470,7 @@ function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
414
470
  }
415
471
  if (line.level) {
416
472
  items.push({
417
- label: `Filter by ${line.level}`,
473
+ label: text.value.filterLevel.replace("{level}", line.level),
418
474
  icon: "lucide:filter",
419
475
  separatorBefore: !line.source,
420
476
  onSelect: () => emit("filter", { level: line.level }),
@@ -576,8 +632,15 @@ function scrollToBottom() {
576
632
  // Snapshot the scroll anchor BEFORE the DOM updates for a line change (flush:pre)
577
633
  // so a prepend can be compensated post-update. Only matters when NOT pinned to
578
634
  // the bottom (i.e. the user is reading history up the stream).
635
+ /*
636
+ * A capped buffer (shift + push, or slice(-N)) keeps the length constant once
637
+ * full, so the length alone stops seeing new lines. The array, its length and
638
+ * its last line together change on every append, prepend or replacement.
639
+ */
640
+ const linesSignal = () => [props.lines, props.lines.length, props.lines[props.lines.length - 1]]
641
+
579
642
  watch(
580
- () => props.lines.length,
643
+ linesSignal,
581
644
  () => {
582
645
  if (!pinned.value) captureScrollAnchor()
583
646
  },
@@ -587,7 +650,7 @@ watch(
587
650
  // After the DOM updates: tail to the bottom if pinned, else keep the view glued
588
651
  // to where it was so a top prepend (older history) doesn't yank it.
589
652
  watch(
590
- () => props.lines.length,
653
+ linesSignal,
591
654
  async () => {
592
655
  await nextTick()
593
656
  if (props.tail && pinned.value) {
@@ -673,7 +736,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
673
736
  v-if="frozen"
674
737
  class="pointer-events-none absolute top-2 left-1/2 z-10 -translate-x-1/2 rounded-pill bg-brand px-2.5 py-1 text-[11px] font-medium text-ink-inverse"
675
738
  >
676
- {{ frozenLabel }}
739
+ {{ frozenLabel ?? text.frozen }}
677
740
  </div>
678
741
  </Transition>
679
742
 
@@ -691,7 +754,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
691
754
  v-if="lines.length === 0 || filteredEmpty"
692
755
  class="flex h-full flex-col items-center justify-center gap-1 px-4 py-10 text-center"
693
756
  >
694
- <span class="text-muted">{{ filteredEmpty ? "No lines match" : emptyLabel }}</span>
757
+ <span class="text-muted">{{ filteredEmpty ? text.noMatch : (emptyLabel ?? text.empty) }}</span>
695
758
  <span v-if="filteredEmpty" class="text-muted/70">“{{ highlight }}”</span>
696
759
  </div>
697
760
 
@@ -704,7 +767,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
704
767
  class="flex items-center justify-center gap-2 py-2 text-muted"
705
768
  >
706
769
  <LpIcon name="lucide:loader-circle" :size="14" class="animate-spin" />
707
- <span class="text-[11px]">loading older…</span>
770
+ <span class="text-[11px]">{{ text.loadingOlder }}</span>
708
771
  </div>
709
772
 
710
773
  <!-- ONE context menu wraps the whole list (not each row): @contextmenu
@@ -738,7 +801,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
738
801
  />
739
802
  <li
740
803
  v-for="{ line, n, count } in renderedLines"
741
- :key="n"
804
+ :key="lineKey(n)"
742
805
  :data-log-row="n"
743
806
  class="group relative flex items-start gap-0 px-0 leading-5 transition-colors hover:bg-surface-soft/60"
744
807
  :class="metaFor(line.level).sel"
@@ -785,7 +848,13 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
785
848
  class="min-w-0 pr-3"
786
849
  :class="[metaFor(line.level).text, wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre']"
787
850
  >
788
- <template v-for="(part, p) in splitHighlight(line.message)" :key="p">
851
+ <!-- Virtualised rows must stay one row tall, so a multi-line
852
+ message (a stack trace) is shown on one line with ↵ marks;
853
+ copying still yields the original text. -->
854
+ <template
855
+ v-for="(part, p) in splitHighlight(virtualise ? line.message.replace(/\r?\n/g, ' ↵ ') : line.message)"
856
+ :key="p"
857
+ >
789
858
  <mark
790
859
  v-if="part.hit"
791
860
  class="rounded-xs bg-accent/30 text-ink"
@@ -803,7 +872,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
803
872
  v-if="count > 1"
804
873
  :key="count"
805
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"
806
- :title="`${count} identical lines`"
875
+ :title="text.identical.replace('{n}', String(count))"
807
876
  >×{{ count }}</span>
808
877
  </Transition>
809
878
  </span>
@@ -839,7 +908,7 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(() =>
839
908
  :size="14"
840
909
  class="transition-transform duration-200 ease-[var(--ease-emphasized)] group-hover:translate-y-0.5"
841
910
  />
842
- Jump to latest
911
+ {{ text.jump }}
843
912
  </button>
844
913
  </div>
845
914
  </Transition>