@leavepulse/ui 0.12.0 → 0.14.4

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 (160) hide show
  1. package/dist/assets/LpCommandPalette-D315B47-.css +16 -0
  2. package/dist/chunks/LayoutCanvas-Dd20boUG.js +50 -0
  3. package/dist/chunks/LayoutNode-DGpZA9LX.js +225 -0
  4. package/dist/chunks/LpAlert-BAYHDSS1.js +69 -0
  5. package/dist/chunks/LpAppShell-C22642JK.js +170 -0
  6. package/dist/chunks/LpAutocomplete-B9uoCs4t.js +196 -0
  7. package/dist/chunks/LpAvatar-B1Ez0ZAx.js +88 -0
  8. package/dist/chunks/LpBadge-CXzBPnwO.js +42 -0
  9. package/dist/chunks/LpBreadcrumbs-B7Aqatzw.js +42 -0
  10. package/dist/chunks/LpButton-5VXXCjwF.js +101 -0
  11. package/dist/chunks/LpCalendar-B7ZL0ty3.js +150 -0
  12. package/dist/chunks/LpCard-1yvWT9fx.js +66 -0
  13. package/dist/chunks/LpCheckbox-BO0zuuPh.js +55 -0
  14. package/dist/chunks/LpCodeBlock-CSiOSPs8.js +253 -0
  15. package/dist/chunks/LpConfirmDialog-CnHtHWHm.js +62 -0
  16. package/dist/chunks/LpContextMenu-D0mzXqA7.js +151 -0
  17. package/dist/chunks/LpDatePicker-dY2q4dr4.js +122 -0
  18. package/dist/chunks/LpDisclosure-DP6yfgfM.js +76 -0
  19. package/dist/chunks/LpDivider-CUX46KR0.js +43 -0
  20. package/dist/chunks/LpDrawer-CBNVhkg0.js +310 -0
  21. package/dist/chunks/LpDropdownMenu-DJpoqRCb.js +65 -0
  22. package/dist/chunks/LpEmptyState-CrfyRJUG.js +47 -0
  23. package/dist/chunks/LpFormField-Bn-ZwC6z.js +47 -0
  24. package/dist/chunks/LpIcon-CCnX5_2j.js +41 -0
  25. package/dist/chunks/LpInfraNode-C_PHcthC.js +99 -0
  26. package/dist/chunks/LpInput-BauZRyzm.js +136 -0
  27. package/dist/chunks/LpLaneNode-HtnQf2FF.js +33 -0
  28. package/dist/chunks/LpLink-DHwOEp4e.js +36 -0
  29. package/dist/chunks/LpLogViewer-CDBjhXl6.js +469 -0
  30. package/dist/chunks/LpModal-D17Bocq7.js +128 -0
  31. package/dist/chunks/LpNotificationBell-Bm51SkgV.js +232 -0
  32. package/dist/chunks/LpNumberField-C5ar-OwE.js +55 -0
  33. package/dist/chunks/LpOtpInput-DqaT0Z75.js +65 -0
  34. package/dist/chunks/LpPagination-CeGuNe_q.js +132 -0
  35. package/dist/chunks/LpPasswordInput-DIHFw4u0.js +64 -0
  36. package/dist/chunks/LpPhoneInput-LQvVFGMw.js +199 -0
  37. package/dist/chunks/LpPopover-8268UmlC.js +54 -0
  38. package/dist/chunks/LpProgress-BhI0IbjC.js +37 -0
  39. package/dist/chunks/LpRadio-CL-pI0_O.js +39 -0
  40. package/dist/chunks/LpRadioGroup-B4yDTmi6.js +52 -0
  41. package/dist/chunks/LpScrollArea-CcHul_XS.js +67 -0
  42. package/dist/chunks/LpSegmented-BmpanyAo.js +74 -0
  43. package/dist/chunks/LpSelect-DznhPEWX.js +222 -0
  44. package/dist/chunks/LpServiceNode-Dc-Ut5RS.js +51 -0
  45. package/dist/chunks/LpSidebar-CTUdtX7W.js +152 -0
  46. package/dist/chunks/LpSidebarNav-BRwovIc8.js +126 -0
  47. package/dist/chunks/LpSkeleton-eSFIo4kD.js +22 -0
  48. package/dist/chunks/LpSlider-B1SF3pRV.js +53 -0
  49. package/dist/chunks/LpStat-CdbEFOvn.js +88 -0
  50. package/dist/chunks/LpStepper-5dFTrW21.js +53 -0
  51. package/dist/chunks/LpSwitch-v6fnccSM.js +28 -0
  52. package/dist/chunks/LpTable-PHE6UKor.js +229 -0
  53. package/dist/chunks/LpTableOfContents-CjvxPMpU.js +155 -0
  54. package/dist/chunks/LpTabs-D7aTVPOb.js +115 -0
  55. package/dist/chunks/LpTextarea-CRAkhemZ.js +44 -0
  56. package/dist/chunks/LpThemeSwitcher-BgWwfkxs.js +601 -0
  57. package/dist/chunks/LpTilt-DuyKbpkA.js +49 -0
  58. package/dist/chunks/LpToaster-gDB1EaU3.js +196 -0
  59. package/dist/chunks/LpTooltip-CIwoRCag.js +49 -0
  60. package/dist/chunks/LpTopologyCanvas-DVwyQfKa.js +237 -0
  61. package/dist/chunks/LpUptimeBar-CB366k0O.js +127 -0
  62. package/dist/component-names.d.ts +1 -1
  63. package/dist/components/LayoutCanvas.vue.js +4 -0
  64. package/dist/components/LayoutNode.vue.js +4 -0
  65. package/dist/components/LpAlert.vue.js +4 -0
  66. package/dist/components/LpAppShell.vue.js +4 -0
  67. package/dist/components/LpAutocomplete.vue.js +4 -0
  68. package/dist/components/LpAvatar.vue.js +4 -0
  69. package/dist/components/LpBadge.vue.js +4 -0
  70. package/dist/components/LpBreadcrumbs.vue.js +4 -0
  71. package/dist/components/LpButton.vue.js +4 -0
  72. package/dist/components/LpCalendar.vue.js +4 -0
  73. package/dist/components/LpCard.vue.js +4 -0
  74. package/dist/components/LpCheckbox.vue.js +4 -0
  75. package/dist/components/LpCodeBlock.vue.js +4 -0
  76. package/dist/components/LpCommandPalette.vue.js +310 -0
  77. package/dist/components/LpConfirmDialog.vue.js +4 -0
  78. package/dist/components/LpContextMenu.vue.d.ts +7 -0
  79. package/dist/components/LpContextMenu.vue.js +4 -0
  80. package/dist/components/LpDatePicker.vue.js +4 -0
  81. package/dist/components/LpDisclosure.vue.js +4 -0
  82. package/dist/components/LpDivider.vue.js +4 -0
  83. package/dist/components/LpDrawer.vue.d.ts +8 -0
  84. package/dist/components/LpDrawer.vue.js +4 -0
  85. package/dist/components/LpDropdownMenu.vue.js +4 -0
  86. package/dist/components/LpEmptyState.vue.js +4 -0
  87. package/dist/components/LpFormField.vue.js +4 -0
  88. package/dist/components/LpIcon.vue.js +4 -0
  89. package/dist/components/LpInfraNode.vue.js +4 -0
  90. package/dist/components/LpInput.vue.js +4 -0
  91. package/dist/components/LpLaneNode.vue.js +4 -0
  92. package/dist/components/LpLink.vue.js +4 -0
  93. package/dist/components/LpLogViewer.vue.js +4 -0
  94. package/dist/components/LpModal.vue.js +4 -0
  95. package/dist/components/LpNotificationBell.vue.js +4 -0
  96. package/dist/components/LpNumberField.vue.js +4 -0
  97. package/dist/components/LpOtpInput.vue.js +4 -0
  98. package/dist/components/LpPagination.vue.js +4 -0
  99. package/dist/components/LpPasswordInput.vue.js +4 -0
  100. package/dist/components/LpPhoneInput.vue.js +4 -0
  101. package/dist/components/LpPopover.vue.js +4 -0
  102. package/dist/components/LpProgress.vue.js +4 -0
  103. package/dist/components/LpRadio.vue.js +4 -0
  104. package/dist/components/LpRadioGroup.vue.js +4 -0
  105. package/dist/components/LpScrollArea.vue.js +4 -0
  106. package/dist/components/LpSegmented.vue.js +4 -0
  107. package/dist/components/LpSelect.vue.d.ts +2 -0
  108. package/dist/components/LpSelect.vue.js +4 -0
  109. package/dist/components/LpServiceNode.vue.js +4 -0
  110. package/dist/components/LpSidebar.vue.js +4 -0
  111. package/dist/components/LpSidebarNav.vue.js +4 -0
  112. package/dist/components/LpSkeleton.vue.js +4 -0
  113. package/dist/components/LpSlider.vue.js +4 -0
  114. package/dist/components/LpStat.vue.js +4 -0
  115. package/dist/components/LpStepper.vue.js +4 -0
  116. package/dist/components/LpSwitch.vue.js +4 -0
  117. package/dist/components/LpTable.vue.js +4 -0
  118. package/dist/components/LpTableOfContents.vue.js +4 -0
  119. package/dist/components/LpTabs.vue.d.ts +4 -2
  120. package/dist/components/LpTabs.vue.js +4 -0
  121. package/dist/components/LpTextarea.vue.js +4 -0
  122. package/dist/components/LpThemeSwitcher.vue.js +4 -0
  123. package/dist/components/LpTilt.vue.d.ts +31 -0
  124. package/dist/components/LpTilt.vue.js +4 -0
  125. package/dist/components/LpToaster.vue.js +4 -0
  126. package/dist/components/LpTooltip.vue.js +4 -0
  127. package/dist/components/LpTopologyCanvas.vue.js +4 -0
  128. package/dist/components/LpUptimeBar.vue.js +4 -0
  129. package/dist/components/codeHighlight.js +230 -0
  130. package/dist/{countries-data-9rgqJWHo.js → components/countries-data.js} +2 -2
  131. package/dist/components/countries.js +38 -0
  132. package/dist/components/dropdown.js +14 -0
  133. package/dist/components/sidebar.js +1 -0
  134. package/dist/composables/useInputFilter.js +44 -0
  135. package/dist/composables/usePillTransition.js +11 -0
  136. package/dist/composables/useTilt.d.ts +23 -0
  137. package/dist/composables/useTilt.js +57 -0
  138. package/dist/composables/useToast.d.ts +2 -0
  139. package/dist/composables/useToast.js +49 -0
  140. package/dist/index.d.ts +3 -0
  141. package/dist/index.js +197 -17596
  142. package/dist/layout/registry.js +10 -0
  143. package/dist/layout/tree.js +192 -0
  144. package/dist/layout/useLayout.js +61 -0
  145. package/package.json +5 -3
  146. package/src/component-names.ts +1 -0
  147. package/src/components/LpContextMenu.vue +14 -4
  148. package/src/components/LpDrawer.vue +29 -1
  149. package/src/components/LpLogViewer.vue +161 -31
  150. package/src/components/LpSelect.vue +0 -0
  151. package/src/components/LpSidebar.vue +5 -2
  152. package/src/components/LpTabs.vue +19 -6
  153. package/src/components/LpThemeSwitcher.vue +13 -2
  154. package/src/components/LpTilt.vue +61 -0
  155. package/src/components/LpToaster.vue +11 -4
  156. package/src/composables/useTilt.ts +103 -0
  157. package/src/composables/useToast.ts +2 -0
  158. package/src/index.ts +3 -0
  159. package/src/nuxt.ts +10 -1
  160. package/dist/index.css +0 -1
@@ -7,7 +7,7 @@
7
7
  * viewport element and re-emits the native scroll event — exactly what tailing
8
8
  * needs.
9
9
  */
10
- import { computed, nextTick, ref, watch } from "vue"
10
+ import { computed, nextTick, onMounted, ref, TransitionGroup, watch } from "vue"
11
11
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
12
12
  import LpIcon from "./LpIcon.vue"
13
13
  import LpScrollArea from "./LpScrollArea.vue"
@@ -108,14 +108,18 @@ const emit = defineEmits<{
108
108
  // Level → gutter rail + text colour. Reads only semantic tokens so re-skinning
109
109
  // just works. trace/debug stay muted; success is green; warn amber; error/fatal
110
110
  // red (fatal gets a filled chip to stand apart from plain error).
111
- const levelMeta: Record<LogLevel, { rail: string; text: string; chip: string }> = {
112
- trace: { rail: "bg-line-strong", text: "text-muted", chip: "text-muted" },
113
- debug: { rail: "bg-line-strong", text: "text-muted-strong", chip: "text-muted-strong" },
114
- info: { rail: "bg-brand", text: "text-ink", chip: "text-brand" },
115
- success: { rail: "bg-action", text: "text-ink", chip: "text-action" },
116
- warn: { rail: "bg-accent", text: "text-ink", chip: "text-accent" },
117
- error: { rail: "bg-danger", text: "text-ink", chip: "text-danger" },
118
- fatal: { rail: "bg-danger", text: "text-ink", chip: "rounded-xs bg-danger px-1 text-ink-inverse" },
111
+ // `sel` tints ::selection in the row's semantic colour (info→brand, warn→accent,
112
+ // error/fatal→danger) via Tailwind's `selection:` variant the highlight then
113
+ // matches the level instead of the browser default. Static classes (not built
114
+ // from a var) so Tailwind emits them from the @source scan.
115
+ const levelMeta: Record<LogLevel, { rail: string; text: string; chip: string; sel: string }> = {
116
+ trace: { rail: "bg-line-strong", text: "text-muted", chip: "text-muted", sel: "selection:bg-muted/40" },
117
+ debug: { rail: "bg-line-strong", text: "text-muted-strong", chip: "text-muted-strong", sel: "selection:bg-muted-strong/40" },
118
+ info: { rail: "bg-brand", text: "text-ink", chip: "text-brand", sel: "selection:bg-brand/40" },
119
+ success: { rail: "bg-action", text: "text-ink", chip: "text-action", sel: "selection:bg-action/40" },
120
+ warn: { rail: "bg-accent", text: "text-ink", chip: "text-accent", sel: "selection:bg-accent/40" },
121
+ error: { rail: "bg-danger", text: "text-ink", chip: "text-danger", sel: "selection:bg-danger/40" },
122
+ fatal: { rail: "bg-danger", text: "text-ink", chip: "rounded-xs bg-danger px-1 text-ink-inverse", sel: "selection:bg-danger/40" },
119
123
  }
120
124
 
121
125
  function metaFor(level?: LogLevel) {
@@ -264,6 +268,34 @@ function lineText(line: LogLine): string {
264
268
  .join(" ")
265
269
  }
266
270
 
271
+ // One LpContextMenu wraps the whole list (not each row) so a mouse drag can
272
+ // select text across rows — a per-row reka trigger would bind pointerdown and
273
+ // pin the selection to one row. On right-click we resolve which row is under the
274
+ // cursor (data-log-row) and rebuild the menu items for it before reka opens.
275
+ const menuRow = ref<{ line: LogLine; n: number } | null>(null)
276
+ const menuItems = computed<ContextMenuItemDef[]>(() =>
277
+ menuRow.value ? menuFor(menuRow.value.line, menuRow.value.n) : [],
278
+ )
279
+
280
+ // Resolve the right-clicked row so menuItems reflects it before reka's trigger
281
+ // opens the menu (runs on the same contextmenu event, ahead of the open).
282
+ function onRowContext(e: MouseEvent) {
283
+ if (!props.rowMenu) return
284
+ const el = (e.target as HTMLElement | null)?.closest<HTMLElement>("[data-log-row]")
285
+ const n = el ? Number(el.dataset.logRow) : NaN
286
+ const row = Number.isNaN(n) ? undefined : visibleLines.value.find((r) => r.n === n)
287
+ menuRow.value = row ? { line: row.line, n: row.n } : null
288
+ }
289
+
290
+ // vaul (the drawer lib) treats a left-button pointerdown on a side drawer as the
291
+ // start of a swipe-to-dismiss (`shouldDrag` returns true unconditionally for
292
+ // left/right drawers) and its preventDefault kills text selection. Stop that
293
+ // pointerdown from bubbling to vaul — left button only, so scroll and the
294
+ // right-click context menu still reach it.
295
+ function onRowPointerDown(e: PointerEvent) {
296
+ if (e.button === 0) e.stopPropagation()
297
+ }
298
+
267
299
  // Built-in copy actions + optional "filter by" + consumer extras. Rebuilt per
268
300
  // right-click so it reflects the line under the cursor.
269
301
  function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
@@ -322,6 +354,52 @@ function splitHighlight(message: string): Part[] {
322
354
  return parts
323
355
  }
324
356
 
357
+ // ── virtualisation ───────────────────────────────────────────────────
358
+ // A live log stream can reach tens of thousands of rows; rendering them all
359
+ // (a v-for over the whole list, each row a flex box with a highlight-split and,
360
+ // worse, a TransitionGroup computing FLIP offsets for every node on each push)
361
+ // pins the main thread and the viewer stops responding. So past a threshold we
362
+ // render only the slice inside the viewport plus a small overscan, padding the
363
+ // rest with two spacer rows. This needs a uniform row height, which only holds
364
+ // when lines don't wrap — with `wrap` (or short lists) we fall back to the
365
+ // plain full render that keeps the fold/enter transitions.
366
+ const ROW_HEIGHT = 20 // px; matches leading-5 (1.25rem) monospace rows
367
+ const VIRTUALISE_ABOVE = 200 // rows; below this the full render is cheap enough
368
+ const OVERSCAN = 12 // rows rendered beyond each edge to cover fast scrolls
369
+
370
+ const virtualise = computed(() => !props.wrap && visibleLines.value.length > VIRTUALISE_ABOVE)
371
+
372
+ const viewportHeight = ref(0)
373
+ const scrollTop = ref(0)
374
+
375
+ // Window of rows to actually render when virtualising: [start, end) by index
376
+ // into visibleLines, widened by OVERSCAN on both sides and clamped.
377
+ const virtualRange = computed(() => {
378
+ const total = visibleLines.value.length
379
+ if (!virtualise.value) return { start: 0, end: total }
380
+ const visibleCount = Math.ceil(viewportHeight.value / ROW_HEIGHT) + 1
381
+ const start = Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - OVERSCAN)
382
+ const end = Math.min(total, start + visibleCount + OVERSCAN * 2)
383
+ return { start, end }
384
+ })
385
+
386
+ const renderedLines = computed(() => {
387
+ if (!virtualise.value) return visibleLines.value
388
+ const { start, end } = virtualRange.value
389
+ return visibleLines.value.slice(start, end)
390
+ })
391
+
392
+ // Spacer heights that stand in for the un-rendered rows above/below the window,
393
+ // so the scrollbar and scroll position stay honest.
394
+ const padTop = computed(() =>
395
+ virtualise.value ? virtualRange.value.start * ROW_HEIGHT : 0,
396
+ )
397
+ const padBottom = computed(() =>
398
+ virtualise.value
399
+ ? (visibleLines.value.length - virtualRange.value.end) * ROW_HEIGHT
400
+ : 0,
401
+ )
402
+
325
403
  // ── tail / auto-scroll ───────────────────────────────────────────────
326
404
  const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
327
405
  const pinned = ref(true) // currently glued to the bottom
@@ -349,6 +427,10 @@ let topArmed = true
349
427
  function onScroll(e: Event) {
350
428
  const el = e.target as HTMLElement
351
429
  pinned.value = atBottom(el)
430
+ // Feed the virtual window. Reading clientHeight here keeps it in sync with
431
+ // runtime resizes (drawer drag, window resize) without a ResizeObserver.
432
+ scrollTop.value = el.scrollTop
433
+ if (el.clientHeight !== viewportHeight.value) viewportHeight.value = el.clientHeight
352
434
  if (!props.loadOlder) return
353
435
  if (el.scrollTop <= TOP_NEAR) {
354
436
  if (topArmed && !props.loadingOlder) {
@@ -422,6 +504,28 @@ watch(
422
504
  )
423
505
 
424
506
  const showJump = computed(() => props.tail && !pinned.value && props.lines.length > 0)
507
+
508
+ // Seed the viewport height before the first scroll so the initial virtual
509
+ // window is correct (otherwise a tall list would render a single overscan slice
510
+ // until the user scrolls).
511
+ onMounted(() => {
512
+ const el = viewport()
513
+ if (el) viewportHeight.value = el.clientHeight
514
+ })
515
+
516
+ // FLIP / enter / leave classes for the full-render TransitionGroup, bound via
517
+ // v-bind so the same list element can fall back to a plain <ol> when virtualised
518
+ // (a component :is can't carry TransitionGroup-only props inertly otherwise).
519
+ const transitionProps = {
520
+ tag: "ol",
521
+ "enter-active-class": "transition duration-200 ease-[var(--ease-emphasized)]",
522
+ "enter-from-class": "-translate-x-1 opacity-0",
523
+ "leave-active-class":
524
+ "overflow-hidden transition-all duration-200 ease-[var(--ease-emphasized)]",
525
+ "leave-from-class": "max-h-8 opacity-100",
526
+ "leave-to-class": "max-h-0 -translate-y-1 opacity-0",
527
+ "move-class": "transition-transform duration-200 ease-[var(--ease-emphasized)]",
528
+ }
425
529
  </script>
426
530
 
427
531
  <template>
@@ -455,33 +559,53 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
455
559
  <span class="text-[11px]">loading older…</span>
456
560
  </div>
457
561
 
458
- <!-- Leave collapses a folded-away duplicate (max-height + opacity + a small
562
+ <!-- ONE context menu wraps the whole list (not each row): @contextmenu
563
+ resolves which row was right-clicked so menuItems reflects it before
564
+ reka opens. @pointerdown stops a left-button drag from reaching vaul
565
+ (which would start a swipe-dismiss and cancel text selection); the
566
+ right button still bubbles so the menu opens. reka's own trigger
567
+ pointerdown is touch/pen-only, so it doesn't interfere with the mouse.
568
+ Leave collapses a folded-away duplicate (max-height + opacity + a small
459
569
  lift) while the survivors FLIP-slide up via move-class — so toggling
460
- `compact` reads as the dupes melting into the kept line, not a jump. -->
461
- <TransitionGroup
462
- tag="ol"
570
+ `compact` reads as the dupes melting into the kept line. -->
571
+ <!-- Component switches on `virtualise`: a plain <ol> when we render only a
572
+ windowed slice (FLIP/enter/leave transitions can't run over a moving
573
+ virtual window, and computing them for thousands of nodes is exactly
574
+ the cost we're avoiding), or a TransitionGroup for the full render on
575
+ small lists, where the fold/enter animations are affordable and nice.
576
+ Spacer rows stand in for the un-rendered slices above/below. -->
577
+ <LpContextMenu :items="menuItems" :always="rowMenu">
578
+ <component
579
+ :is="virtualise ? 'ol' : TransitionGroup"
580
+ v-bind="virtualise ? {} : transitionProps"
463
581
  class="py-1"
464
- enter-active-class="transition duration-200 ease-[var(--ease-emphasized)]"
465
- enter-from-class="-translate-x-1 opacity-0"
466
- leave-active-class="overflow-hidden transition-all duration-200 ease-[var(--ease-emphasized)]"
467
- leave-from-class="max-h-8 opacity-100"
468
- leave-to-class="max-h-0 -translate-y-1 opacity-0"
469
- move-class="transition-transform duration-200 ease-[var(--ease-emphasized)]"
582
+ @contextmenu="onRowContext"
583
+ @pointerdown="onRowPointerDown"
470
584
  >
471
- <LpContextMenu
472
- v-for="{ line, n, count } in visibleLines"
473
- :key="n"
474
- :items="menuFor(line, n)"
475
- >
476
585
  <li
477
- class="group flex items-start gap-0 px-0 transition-colors hover:bg-surface-soft/60"
586
+ v-if="virtualise && padTop > 0"
587
+ key="pad-top"
588
+ :style="{ height: padTop + 'px' }"
589
+ aria-hidden="true"
590
+ />
591
+ <li
592
+ v-for="{ line, n, count } in renderedLines"
593
+ :key="n"
594
+ :data-log-row="n"
595
+ class="group relative flex items-start gap-0 px-0 leading-5 transition-colors hover:bg-surface-soft/60"
596
+ :class="metaFor(line.level).sel"
478
597
  >
479
598
  <!-- level rail -->
599
+ <!-- Level rail: absolutely positioned so it spans the FULL row height
600
+ regardless of content — self-stretch only reached the flex line box
601
+ (shorter than the relaxed line-height), which left visible gaps
602
+ between rows. A spacer keeps the text indented past it. -->
480
603
  <span
481
- class="mr-2 w-0.5 shrink-0 self-stretch"
604
+ class="absolute inset-y-0 left-0 w-0.5"
482
605
  :class="metaFor(line.level).rail"
483
606
  aria-hidden="true"
484
607
  />
608
+ <span class="mr-2 w-0.5 shrink-0" aria-hidden="true" />
485
609
 
486
610
  <!-- line number -->
487
611
  <span
@@ -492,20 +616,20 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
492
616
  <!-- timestamp -->
493
617
  <span
494
618
  v-if="showTime"
495
- class="mr-3 shrink-0 select-none tabular-nums text-muted"
619
+ class="mr-3 shrink-0 tabular-nums text-muted"
496
620
  >{{ fmtTime(line.time) }}</span>
497
621
 
498
622
  <!-- level chip -->
499
623
  <span
500
624
  v-if="showLevel"
501
- class="mr-3 w-12 shrink-0 select-none text-[10px] font-semibold uppercase tracking-[0.06em]"
625
+ class="mr-3 w-12 shrink-0 text-[10px] font-semibold uppercase tracking-[0.06em]"
502
626
  :class="metaFor(line.level).chip"
503
627
  >{{ line.level ?? "info" }}</span>
504
628
 
505
629
  <!-- source tag -->
506
630
  <span
507
631
  v-if="line.source"
508
- class="mr-2 shrink-0 select-none text-muted-strong"
632
+ class="mr-2 shrink-0 text-muted-strong"
509
633
  >[{{ line.source }}]</span>
510
634
 
511
635
  <!-- message -->
@@ -536,8 +660,14 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
536
660
  </Transition>
537
661
  </span>
538
662
  </li>
539
- </LpContextMenu>
540
- </TransitionGroup>
663
+ <li
664
+ v-if="virtualise && padBottom > 0"
665
+ key="pad-bottom"
666
+ :style="{ height: padBottom + 'px' }"
667
+ aria-hidden="true"
668
+ />
669
+ </component>
670
+ </LpContextMenu>
541
671
  </template>
542
672
  </LpScrollArea>
543
673
 
Binary file
@@ -122,10 +122,12 @@ defineOptions({ inheritAttrs: false })
122
122
  aria-label="Sidebar"
123
123
  v-bind="$attrs"
124
124
  >
125
+ <!-- Edge-to-edge (negative margins cancel the rail p-3) and h-16 to match the
126
+ app header, so its bottom hairline continues the header's border-b. -->
125
127
  <div
126
128
  v-if="$slots.header"
127
- class="shrink-0"
128
- :class="divider ? 'mb-3 border-b border-line pb-3' : 'mb-2'"
129
+ class="-mx-3 -mt-3 mb-3 flex h-16 shrink-0 items-center px-3"
130
+ :class="divider ? 'border-b border-line' : ''"
129
131
  >
130
132
  <slot name="header" />
131
133
  </div>
@@ -163,6 +165,7 @@ defineOptions({ inheritAttrs: false })
163
165
  direction="left"
164
166
  size="sm"
165
167
  :edge-open="edgeOpen"
168
+ :edge-breakpoint="mobileBreakpoint"
166
169
  @update:open="(v) => emit('update:open', v)"
167
170
  >
168
171
  <div class="flex h-full flex-col gap-1">
@@ -20,8 +20,10 @@ withDefaults(
20
20
  * "contained" (default) wraps the triggers in a bordered, filled bar.
21
21
  * "plain" drops the container chrome — a flat row of tabs with only the
22
22
  * sliding pill marking the active one (for nav bars over a custom surface).
23
+ * "underline" is a flat row over a hairline baseline, marking the active
24
+ * tab with a sliding bottom bar instead of a pill (page-section nav style).
23
25
  */
24
- variant?: "contained" | "plain"
26
+ variant?: "contained" | "plain" | "underline"
25
27
  /** Tint the active pill + label with the brand colour (nav-bar style). */
26
28
  accent?: boolean
27
29
  /** Stretch the bar to full width with equal-share triggers. */
@@ -67,6 +69,7 @@ function pillUnder(value: string, active?: string): boolean {
67
69
  :class="[
68
70
  block ? 'flex w-full' : 'inline-flex',
69
71
  variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
72
+ variant === 'underline' ? 'border-b border-line' : '',
70
73
  ]"
71
74
  @pointerleave="hovered = null"
72
75
  >
@@ -74,22 +77,32 @@ function pillUnder(value: string, active?: string): boolean {
74
77
  v-for="item in items"
75
78
  :key="item.value"
76
79
  :value="item.value"
77
- class="relative inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
80
+ class="relative inline-flex items-center justify-center gap-1.5 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
78
81
  :class="[
79
82
  block ? 'flex-1' : '',
83
+ variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : 'rounded-md px-3 py-1.5',
80
84
  accent ? 'data-[state=active]:text-brand' : 'data-[state=active]:text-ink',
81
85
  hovered === item.value ? 'text-ink' : 'text-muted',
82
86
  ]"
83
87
  @pointerenter="hovered = item.value"
84
88
  >
89
+ <!-- Active marker: a bottom bar in "underline", a filled pill otherwise.
90
+ Both slide between tabs via the shared motion layoutId. -->
85
91
  <Motion
86
92
  v-if="pillUnder(item.value, modelValue)"
87
93
  :layout-id="pillId"
88
94
  :transition="pillTransition"
89
- class="absolute inset-0 z-0 rounded-md shadow-sm"
90
- :class="accent
91
- ? 'border border-brand/35 bg-brand/12'
92
- : 'border border-line bg-surface-raised'"
95
+ :class="variant === 'underline'
96
+ ? [
97
+ 'absolute inset-x-0 -bottom-px z-0 h-0.5 rounded-full',
98
+ accent ? 'bg-brand' : 'bg-ink',
99
+ ]
100
+ : [
101
+ 'absolute inset-0 z-0 rounded-md shadow-sm',
102
+ accent
103
+ ? 'border border-brand/35 bg-brand/12'
104
+ : 'border border-line bg-surface-raised',
105
+ ]"
93
106
  />
94
107
  <LpIcon v-if="item.icon" :name="item.icon" :size="14" class="relative z-10" />
95
108
  <span class="relative z-10">{{ item.label }}</span>
@@ -90,6 +90,10 @@ const ICON_SIZE = { sm: 15, md: 16, lg: 18 } as const
90
90
 
91
91
  const wantsLabel = computed(() => props.showLabel || props.variant === "pill")
92
92
 
93
+ // Labelless icon variant renders as a plain square icon button (no border/fill),
94
+ // so it sits inline with other toolbar icon buttons. Other variants stay framed.
95
+ const bareIcon = computed(() => props.variant === "icon" && !wantsLabel.value)
96
+
93
97
  // Apply a theme through the kit's self-managed setter (it persists + updates
94
98
  // the shared `current`), then notify. The reveal animates from the click point.
95
99
  function applyTheme(t: TokenSet, ev?: MouseEvent) {
@@ -138,8 +142,15 @@ const triggerLabel = computed(
138
142
  type="button"
139
143
  :aria-label="triggerLabel"
140
144
  :title="triggerLabel"
141
- class="inline-flex items-center gap-2 rounded-control border border-line bg-surface-raised px-1.5 text-ink outline-none transition-colors hover:border-line-strong focus-visible:ring-2 focus-visible:ring-ring"
142
- :class="wantsLabel ? 'py-1 pr-2.5' : ''"
145
+ class="inline-flex items-center justify-center gap-2 rounded-control outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
146
+ :class="
147
+ bareIcon
148
+ ? [SIZES[size], 'text-muted hover:bg-surface-soft hover:text-ink']
149
+ : [
150
+ 'border border-line bg-surface-raised px-1.5 text-ink hover:border-line-strong',
151
+ wantsLabel ? 'py-1 pr-2.5' : '',
152
+ ]
153
+ "
143
154
  @click="cycle"
144
155
  >
145
156
  <!-- swatch: brand over surface, hinting the theme at a glance -->
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * LpTilt — a tactile 3D-tilt wrapper. Its slot content rotates toward the
4
+ * pointer and lifts on hover (see useTilt), so any element — a card, a stat
5
+ * tile, a floating island illustration — gains hand-held depth by wrapping it.
6
+ *
7
+ * <LpTilt :max="12" :lift="14" glare>
8
+ * <LpCard>…</LpCard>
9
+ * </LpTilt>
10
+ *
11
+ * The wrapper owns the `perspective` stage; the inner element is what actually
12
+ * tilts, so sibling content outside the wrapper is unaffected. Honors reduced
13
+ * motion (falls back to a flat, still element).
14
+ */
15
+ import { computed } from "vue"
16
+ import { useTilt } from "../composables/useTilt"
17
+
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ /** Max rotation toward the pointer, in degrees. */
21
+ max?: number
22
+ /** Upward lift on hover, in px. */
23
+ lift?: number
24
+ /** Scale on hover. */
25
+ scale?: number
26
+ /** Render a pointer-tracking glare highlight over the content. */
27
+ glare?: boolean
28
+ /** Perspective depth for the 3D stage, in px. Lower = stronger. */
29
+ perspective?: number
30
+ }>(),
31
+ { max: 10, lift: 8, scale: 1.03, glare: false, perspective: 900 },
32
+ )
33
+
34
+ const tilt = useTilt({
35
+ max: props.max,
36
+ lift: props.lift,
37
+ scale: props.scale,
38
+ glare: props.glare,
39
+ })
40
+
41
+ const stageStyle = computed(() => ({ perspective: `${props.perspective}px` }))
42
+ </script>
43
+
44
+ <template>
45
+ <div :style="stageStyle" class="lp-tilt-stage">
46
+ <div
47
+ class="lp-tilt-inner relative"
48
+ :style="tilt.style.value"
49
+ @pointermove="tilt.onPointerMove"
50
+ @pointerenter="tilt.onPointerEnter"
51
+ @pointerleave="tilt.onPointerLeave"
52
+ >
53
+ <slot />
54
+ <div
55
+ v-if="glare"
56
+ class="pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay"
57
+ :style="tilt.glareStyle.value"
58
+ />
59
+ </div>
60
+ </div>
61
+ </template>
@@ -178,9 +178,10 @@ async function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>
178
178
  v-for="(action, i) in t.actions"
179
179
  :key="i"
180
180
  type="button"
181
- class="rounded-control border border-line bg-surface-soft px-2.5 py-1 text-xs font-medium text-ink outline-none transition-[border-color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:border-line-strong hover:bg-surface-soft/70 active:scale-95 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring"
181
+ class="inline-flex items-center gap-1.5 rounded-control border border-line bg-surface-soft px-2.5 py-1 text-xs font-medium text-ink outline-none transition-[border-color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:border-line-strong hover:bg-surface-soft/70 active:scale-95 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring"
182
182
  @click="runAction(t, action)"
183
183
  >
184
+ <LpIcon v-if="action.icon" :name="action.icon" :size="14" />
184
185
  {{ action.label }}
185
186
  </button>
186
187
  </div>
@@ -209,8 +210,14 @@ async function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>
209
210
  />
210
211
  </ToastRoot>
211
212
 
212
- <ToastViewport
213
- class="fixed bottom-0 right-0 z-(--z-toast) flex w-[min(92vw,22rem)] flex-col gap-2 p-4 outline-none"
214
- />
213
+ <!-- Teleported to body so the viewport escapes any stacking context the
214
+ consumer's app root creates (e.g. an opaque `#app { z-index }` wrapper).
215
+ Modals portal to body too, so both compare at the same root level and
216
+ --z-toast (200) > --z-modal (110) wins as intended. -->
217
+ <Teleport to="body">
218
+ <ToastViewport
219
+ class="fixed bottom-0 right-0 z-(--z-toast) flex w-[min(92vw,22rem)] flex-col gap-2 p-4 outline-none"
220
+ />
221
+ </Teleport>
215
222
  </ToastProvider>
216
223
  </template>
@@ -0,0 +1,103 @@
1
+ import { useReducedMotion } from "motion-v"
2
+ import { computed, ref, type ComputedRef, type CSSProperties, type Ref } from "vue"
3
+
4
+ /*
5
+ * useTilt — pointer-reactive 3D tilt for a "tactile" surface (a card, a tile, a
6
+ * floating island). The element rotates toward the cursor and lifts on hover,
7
+ * giving physical, hand-held depth without any per-component motion wiring.
8
+ *
9
+ * Bind the returned handlers to the element and the returned `style` to it (it
10
+ * needs a shared `perspective` on an ANCESTOR — see LpTilt, which supplies one).
11
+ * Rotation eases to rest via CSS transition; the handlers only write the target
12
+ * transform, so there's no rAF loop. SSR-safe (no window access at setup) and
13
+ * collapses to a flat, no-tilt style when the OS prefers reduced motion.
14
+ */
15
+ export interface UseTiltOptions {
16
+ /** Max rotation toward the pointer, in degrees (per axis). Default 10. */
17
+ max?: number
18
+ /** Upward lift on hover, in px. Default 8. */
19
+ lift?: number
20
+ /** Scale on hover. Default 1.03. */
21
+ scale?: number
22
+ /** Glare highlight following the pointer. Default false. */
23
+ glare?: boolean
24
+ }
25
+
26
+ export interface UseTilt {
27
+ /** Bind to the tilting element's style. */
28
+ style: ComputedRef<CSSProperties>
29
+ /** Radial-gradient background for an optional glare overlay (0 when idle). */
30
+ glareStyle: ComputedRef<CSSProperties>
31
+ onPointerMove: (e: PointerEvent) => void
32
+ onPointerEnter: () => void
33
+ onPointerLeave: () => void
34
+ /** True while the pointer is over the element (drives lift/scale). */
35
+ active: Ref<boolean>
36
+ }
37
+
38
+ export function useTilt(options: UseTiltOptions = {}): UseTilt {
39
+ const max = options.max ?? 10
40
+ const lift = options.lift ?? 8
41
+ const scale = options.scale ?? 1.03
42
+ const reduceMotion = useReducedMotion()
43
+
44
+ const active = ref(false)
45
+ // Pointer position within the element, normalized to [-1, 1] from center.
46
+ const nx = ref(0)
47
+ const ny = ref(0)
48
+
49
+ function onPointerMove(e: PointerEvent): void {
50
+ if (reduceMotion.value) return
51
+ const el = e.currentTarget as HTMLElement | null
52
+ if (!el) return
53
+ const r = el.getBoundingClientRect()
54
+ nx.value = ((e.clientX - r.left) / r.width) * 2 - 1
55
+ ny.value = ((e.clientY - r.top) / r.height) * 2 - 1
56
+ }
57
+
58
+ function onPointerEnter(): void {
59
+ active.value = true
60
+ }
61
+
62
+ function onPointerLeave(): void {
63
+ active.value = false
64
+ nx.value = 0
65
+ ny.value = 0
66
+ }
67
+
68
+ const style = computed<CSSProperties>(() => {
69
+ if (reduceMotion.value) {
70
+ return { transition: "transform 0.2s ease" }
71
+ }
72
+ // rotateX tips away from the pointer's vertical position; rotateY toward its
73
+ // horizontal one. Only tilt while active so it rests flat when the pointer
74
+ // leaves (nx/ny are already zeroed on leave).
75
+ const rx = active.value ? -ny.value * max : 0
76
+ const ry = active.value ? nx.value * max : 0
77
+ const z = active.value ? lift : 0
78
+ const s = active.value ? scale : 1
79
+ return {
80
+ transform: `translate3d(0, ${-z}px, 0) rotateX(${rx}deg) rotateY(${ry}deg) scale(${s})`,
81
+ transformStyle: "preserve-3d",
82
+ transition: active.value
83
+ ? "transform 0.12s ease-out"
84
+ : "transform 0.5s cubic-bezier(0.22, 1, 0.36, 1)",
85
+ }
86
+ })
87
+
88
+ const glareStyle = computed<CSSProperties>(() => {
89
+ if (!options.glare || reduceMotion.value || !active.value) {
90
+ return { opacity: 0 }
91
+ }
92
+ // Bright spot tracks the pointer; map [-1,1] → [0%,100%].
93
+ const px = (nx.value + 1) * 50
94
+ const py = (ny.value + 1) * 50
95
+ return {
96
+ opacity: 1,
97
+ background: `radial-gradient(circle at ${px}% ${py}%, rgba(255,255,255,0.35), transparent 55%)`,
98
+ transition: "opacity 0.2s ease",
99
+ }
100
+ })
101
+
102
+ return { style, glareStyle, onPointerMove, onPointerEnter, onPointerLeave, active }
103
+ }
@@ -4,6 +4,8 @@ export type ToastVariant = "info" | "success" | "warning" | "error"
4
4
 
5
5
  export interface ToastAction {
6
6
  label: string
7
+ /** Optional leading icon (any name LpIcon accepts, e.g. "lucide:copy"). */
8
+ icon?: string
7
9
  onClick: () => void | Promise<void>
8
10
  }
9
11
 
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export { default as LpTabs } from "./components/LpTabs.vue"
72
72
  export type { TabItem } from "./components/LpTabs.vue"
73
73
  export { default as LpTextarea } from "./components/LpTextarea.vue"
74
74
  export { default as LpThemeSwitcher } from "./components/LpThemeSwitcher.vue"
75
+ export { default as LpTilt } from "./components/LpTilt.vue"
75
76
  export { default as LpToaster } from "./components/LpToaster.vue"
76
77
  export { default as LpTooltip } from "./components/LpTooltip.vue"
77
78
  export { default as LpTopologyCanvas } from "./components/LpTopologyCanvas.vue"
@@ -137,6 +138,8 @@ export type { UiConfig } from "./theme/config"
137
138
 
138
139
  export { useInputFilter } from "./composables/useInputFilter"
139
140
  export type { UseInputFilterOptions } from "./composables/useInputFilter"
141
+ export { useTilt } from "./composables/useTilt"
142
+ export type { UseTilt, UseTiltOptions } from "./composables/useTilt"
140
143
  export { useToast } from "./composables/useToast"
141
144
  export type {
142
145
  ToastAction,
package/src/nuxt.ts CHANGED
@@ -16,7 +16,16 @@ export default defineNuxtModule({
16
16
  meta: { name: "@leavepulse/ui", configKey: "leavepulseUi" },
17
17
  setup() {
18
18
  for (const name of COMPONENT_NAMES) {
19
- addComponent({ name, export: name, filePath: "@leavepulse/ui" })
19
+ // Point each component at its own dist chunk rather than the barrel
20
+ // ("@leavepulse/ui"). Importing through the barrel makes the bundler pull
21
+ // the whole kit; a per-component path lets it ship only the components an
22
+ // app actually uses (plus their real deps — e.g. @vue-flow only when a
23
+ // canvas component is used).
24
+ addComponent({
25
+ name,
26
+ export: "default",
27
+ filePath: `@leavepulse/ui/dist/components/${name}.vue.js`,
28
+ })
20
29
  }
21
30
  },
22
31
  })
package/dist/index.css DELETED
@@ -1 +0,0 @@
1
- .lp-cmd-item[data-v-d845063e]{animation:lp-cmd-in-d845063e .14s ease-out both}@keyframes lp-cmd-in-d845063e{0%{opacity:0;transform:translateY(3px)}}@media(prefers-reduced-motion:reduce){.lp-cmd-item[data-v-d845063e]{animation:none}}