@leavepulse/ui 0.12.0 → 0.14.6

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 (162) 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-DBTsNQaM.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-KLtTtCiT.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-Ce6g9ZMr.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-hC0OKkeE.js +482 -0
  30. package/dist/chunks/LpModal-CWoOExzT.js +128 -0
  31. package/dist/chunks/LpNotificationBell-BDy0tQwr.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-K1x-azgY.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-BPQabjae.js +152 -0
  46. package/dist/chunks/LpSidebarNav-CpF7rZxx.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-CJ4Zt1UH.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.d.ts +2 -2
  106. package/dist/components/LpScrollArea.vue.js +4 -0
  107. package/dist/components/LpSegmented.vue.js +4 -0
  108. package/dist/components/LpSelect.vue.d.ts +2 -0
  109. package/dist/components/LpSelect.vue.js +4 -0
  110. package/dist/components/LpServiceNode.vue.js +4 -0
  111. package/dist/components/LpSidebar.vue.js +4 -0
  112. package/dist/components/LpSidebarNav.vue.js +4 -0
  113. package/dist/components/LpSkeleton.vue.js +4 -0
  114. package/dist/components/LpSlider.vue.js +4 -0
  115. package/dist/components/LpStat.vue.js +4 -0
  116. package/dist/components/LpStepper.vue.js +4 -0
  117. package/dist/components/LpSwitch.vue.js +4 -0
  118. package/dist/components/LpTable.vue.js +4 -0
  119. package/dist/components/LpTableOfContents.vue.js +4 -0
  120. package/dist/components/LpTabs.vue.d.ts +4 -2
  121. package/dist/components/LpTabs.vue.js +4 -0
  122. package/dist/components/LpTextarea.vue.js +4 -0
  123. package/dist/components/LpThemeSwitcher.vue.js +4 -0
  124. package/dist/components/LpTilt.vue.d.ts +31 -0
  125. package/dist/components/LpTilt.vue.js +4 -0
  126. package/dist/components/LpToaster.vue.js +4 -0
  127. package/dist/components/LpTooltip.vue.js +4 -0
  128. package/dist/components/LpTopologyCanvas.vue.js +4 -0
  129. package/dist/components/LpUptimeBar.vue.js +4 -0
  130. package/dist/components/codeHighlight.js +230 -0
  131. package/dist/{countries-data-9rgqJWHo.js → components/countries-data.js} +2 -2
  132. package/dist/components/countries.js +38 -0
  133. package/dist/components/dropdown.js +14 -0
  134. package/dist/components/sidebar.js +1 -0
  135. package/dist/composables/useInputFilter.js +44 -0
  136. package/dist/composables/usePillTransition.js +11 -0
  137. package/dist/composables/useTilt.d.ts +23 -0
  138. package/dist/composables/useTilt.js +57 -0
  139. package/dist/composables/useToast.d.ts +2 -0
  140. package/dist/composables/useToast.js +49 -0
  141. package/dist/index.d.ts +3 -0
  142. package/dist/index.js +197 -17596
  143. package/dist/layout/registry.js +10 -0
  144. package/dist/layout/tree.js +192 -0
  145. package/dist/layout/useLayout.js +61 -0
  146. package/package.json +5 -3
  147. package/src/component-names.ts +1 -0
  148. package/src/components/LpContextMenu.vue +14 -4
  149. package/src/components/LpDrawer.vue +29 -1
  150. package/src/components/LpLogViewer.vue +180 -31
  151. package/src/components/LpScrollArea.vue +20 -15
  152. package/src/components/LpSelect.vue +0 -0
  153. package/src/components/LpSidebar.vue +5 -2
  154. package/src/components/LpTabs.vue +19 -6
  155. package/src/components/LpThemeSwitcher.vue +13 -2
  156. package/src/components/LpTilt.vue +61 -0
  157. package/src/components/LpToaster.vue +11 -4
  158. package/src/composables/useTilt.ts +103 -0
  159. package/src/composables/useToast.ts +2 -0
  160. package/src/index.ts +3 -0
  161. package/src/nuxt.ts +10 -1
  162. 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, onBeforeUnmount, 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,47 @@ watch(
422
504
  )
423
505
 
424
506
  const showJump = computed(() => props.tail && !pinned.value && props.lines.length > 0)
507
+
508
+ // Track the viewport height for the whole lifetime, not just at mount. Reading
509
+ // it once was a deadlock when the viewer mounted inside a container that had no
510
+ // height yet (a drawer mid open-animation, a hidden tab): the virtual window
511
+ // collapsed to a single row, so there was nothing to scroll, and `onScroll` —
512
+ // the only other place that refreshed the height — could never fire to correct
513
+ // it. The list looked stuck and wouldn't scroll at all.
514
+ let viewportResize: ResizeObserver | null = null
515
+
516
+ function measureViewport() {
517
+ const el = viewport()
518
+ if (el) viewportHeight.value = el.clientHeight
519
+ }
520
+
521
+ onMounted(() => {
522
+ measureViewport()
523
+ const el = viewport()
524
+ if (el && typeof ResizeObserver !== "undefined") {
525
+ viewportResize = new ResizeObserver(measureViewport)
526
+ viewportResize.observe(el)
527
+ }
528
+ })
529
+
530
+ onBeforeUnmount(() => {
531
+ viewportResize?.disconnect()
532
+ viewportResize = null
533
+ })
534
+
535
+ // FLIP / enter / leave classes for the full-render TransitionGroup, bound via
536
+ // v-bind so the same list element can fall back to a plain <ol> when virtualised
537
+ // (a component :is can't carry TransitionGroup-only props inertly otherwise).
538
+ const transitionProps = {
539
+ tag: "ol",
540
+ "enter-active-class": "transition duration-200 ease-[var(--ease-emphasized)]",
541
+ "enter-from-class": "-translate-x-1 opacity-0",
542
+ "leave-active-class":
543
+ "overflow-hidden transition-all duration-200 ease-[var(--ease-emphasized)]",
544
+ "leave-from-class": "max-h-8 opacity-100",
545
+ "leave-to-class": "max-h-0 -translate-y-1 opacity-0",
546
+ "move-class": "transition-transform duration-200 ease-[var(--ease-emphasized)]",
547
+ }
425
548
  </script>
426
549
 
427
550
  <template>
@@ -455,33 +578,53 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
455
578
  <span class="text-[11px]">loading older…</span>
456
579
  </div>
457
580
 
458
- <!-- Leave collapses a folded-away duplicate (max-height + opacity + a small
581
+ <!-- ONE context menu wraps the whole list (not each row): @contextmenu
582
+ resolves which row was right-clicked so menuItems reflects it before
583
+ reka opens. @pointerdown stops a left-button drag from reaching vaul
584
+ (which would start a swipe-dismiss and cancel text selection); the
585
+ right button still bubbles so the menu opens. reka's own trigger
586
+ pointerdown is touch/pen-only, so it doesn't interfere with the mouse.
587
+ Leave collapses a folded-away duplicate (max-height + opacity + a small
459
588
  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"
589
+ `compact` reads as the dupes melting into the kept line. -->
590
+ <!-- Component switches on `virtualise`: a plain <ol> when we render only a
591
+ windowed slice (FLIP/enter/leave transitions can't run over a moving
592
+ virtual window, and computing them for thousands of nodes is exactly
593
+ the cost we're avoiding), or a TransitionGroup for the full render on
594
+ small lists, where the fold/enter animations are affordable and nice.
595
+ Spacer rows stand in for the un-rendered slices above/below. -->
596
+ <LpContextMenu :items="menuItems" :always="rowMenu">
597
+ <component
598
+ :is="virtualise ? 'ol' : TransitionGroup"
599
+ v-bind="virtualise ? {} : transitionProps"
463
600
  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)]"
601
+ @contextmenu="onRowContext"
602
+ @pointerdown="onRowPointerDown"
470
603
  >
471
- <LpContextMenu
472
- v-for="{ line, n, count } in visibleLines"
473
- :key="n"
474
- :items="menuFor(line, n)"
475
- >
476
604
  <li
477
- class="group flex items-start gap-0 px-0 transition-colors hover:bg-surface-soft/60"
605
+ v-if="virtualise && padTop > 0"
606
+ key="pad-top"
607
+ :style="{ height: padTop + 'px' }"
608
+ aria-hidden="true"
609
+ />
610
+ <li
611
+ v-for="{ line, n, count } in renderedLines"
612
+ :key="n"
613
+ :data-log-row="n"
614
+ class="group relative flex items-start gap-0 px-0 leading-5 transition-colors hover:bg-surface-soft/60"
615
+ :class="metaFor(line.level).sel"
478
616
  >
479
617
  <!-- level rail -->
618
+ <!-- Level rail: absolutely positioned so it spans the FULL row height
619
+ regardless of content — self-stretch only reached the flex line box
620
+ (shorter than the relaxed line-height), which left visible gaps
621
+ between rows. A spacer keeps the text indented past it. -->
480
622
  <span
481
- class="mr-2 w-0.5 shrink-0 self-stretch"
623
+ class="absolute inset-y-0 left-0 w-0.5"
482
624
  :class="metaFor(line.level).rail"
483
625
  aria-hidden="true"
484
626
  />
627
+ <span class="mr-2 w-0.5 shrink-0" aria-hidden="true" />
485
628
 
486
629
  <!-- line number -->
487
630
  <span
@@ -492,20 +635,20 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
492
635
  <!-- timestamp -->
493
636
  <span
494
637
  v-if="showTime"
495
- class="mr-3 shrink-0 select-none tabular-nums text-muted"
638
+ class="mr-3 shrink-0 tabular-nums text-muted"
496
639
  >{{ fmtTime(line.time) }}</span>
497
640
 
498
641
  <!-- level chip -->
499
642
  <span
500
643
  v-if="showLevel"
501
- class="mr-3 w-12 shrink-0 select-none text-[10px] font-semibold uppercase tracking-[0.06em]"
644
+ class="mr-3 w-12 shrink-0 text-[10px] font-semibold uppercase tracking-[0.06em]"
502
645
  :class="metaFor(line.level).chip"
503
646
  >{{ line.level ?? "info" }}</span>
504
647
 
505
648
  <!-- source tag -->
506
649
  <span
507
650
  v-if="line.source"
508
- class="mr-2 shrink-0 select-none text-muted-strong"
651
+ class="mr-2 shrink-0 text-muted-strong"
509
652
  >[{{ line.source }}]</span>
510
653
 
511
654
  <!-- message -->
@@ -536,8 +679,14 @@ const showJump = computed(() => props.tail && !pinned.value && props.lines.lengt
536
679
  </Transition>
537
680
  </span>
538
681
  </li>
539
- </LpContextMenu>
540
- </TransitionGroup>
682
+ <li
683
+ v-if="virtualise && padBottom > 0"
684
+ key="pad-bottom"
685
+ :style="{ height: padBottom + 'px' }"
686
+ aria-hidden="true"
687
+ />
688
+ </component>
689
+ </LpContextMenu>
541
690
  </template>
542
691
  </LpScrollArea>
543
692
 
@@ -47,36 +47,41 @@ const barFade =
47
47
 
48
48
  <template>
49
49
  <ScrollAreaRoot class="relative overflow-hidden" type="hover" :scroll-hide-delay="500">
50
- <ScrollAreaViewport
51
- ref="viewportRef"
52
- class="size-full min-w-0 [&>div]:!block [&>div]:!min-w-0"
53
- :class="fade ? '[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]' : ''"
54
- @scroll.passive="$emit('scroll', $event)"
55
- >
56
- <div :class="contentClass">
57
- <slot />
58
- </div>
59
- </ScrollAreaViewport>
60
-
50
+ <!-- The bar itself is a pointer target far wider than it looks: the visible
51
+ rail is the thumb, sized below, while the track pads it out to a
52
+ grabbable strip. A 4px-wide hit area is near-impossible to catch with a
53
+ mouse. Padding (not width) carries the extra room so the thumb stays
54
+ flush against the wall. -->
61
55
  <ScrollAreaScrollbar
62
56
  orientation="vertical"
63
- class="group flex w-1 touch-none select-none p-px transition-[width] duration-[var(--duration-fast)] hover:w-1.5"
57
+ class="group flex w-3.5 touch-none select-none justify-end py-px pl-2.5 pr-px"
64
58
  :class="barFade"
65
59
  :style="barInsetTop ? { marginTop: barInsetTop } : undefined"
66
60
  >
67
61
  <ScrollAreaThumb
68
- class="flex-1 rounded-l-pill bg-brand/40 transition-colors group-hover:bg-brand"
62
+ class="w-1 rounded-l-pill bg-brand/40 transition-[width,background-color] duration-[var(--duration-fast)] group-hover:w-1.5 group-hover:bg-brand"
69
63
  />
70
64
  </ScrollAreaScrollbar>
71
65
 
72
66
  <ScrollAreaScrollbar
73
67
  orientation="horizontal"
74
- class="group flex h-1 flex-col touch-none select-none p-px transition-[height] duration-[var(--duration-fast)] hover:h-1.5"
68
+ class="group flex h-3.5 flex-col touch-none select-none justify-end px-px pb-px pt-2.5"
75
69
  :class="barFade"
76
70
  >
77
71
  <ScrollAreaThumb
78
- class="flex-1 rounded-t-pill bg-brand/40 transition-colors group-hover:bg-brand"
72
+ class="h-1 rounded-t-pill bg-brand/40 transition-[height,background-color] duration-[var(--duration-fast)] group-hover:h-1.5 group-hover:bg-brand"
79
73
  />
80
74
  </ScrollAreaScrollbar>
75
+
76
+ <ScrollAreaViewport
77
+ ref="viewportRef"
78
+ class="size-full min-w-0 [&>div]:!block [&>div]:!min-w-0"
79
+ :class="fade ? '[mask-image:linear-gradient(to_bottom,transparent_0,black_14px,black_calc(100%-14px),transparent_100%)]' : ''"
80
+ @scroll.passive="$emit('scroll', $event)"
81
+ >
82
+ <div :class="contentClass">
83
+ <slot />
84
+ </div>
85
+ </ScrollAreaViewport>
81
86
  </ScrollAreaRoot>
82
87
  </template>
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>