@leavepulse/ui 0.12.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/component-names.d.ts +1 -0
  4. package/dist/components/LayoutCanvas.vue.d.ts +28 -0
  5. package/dist/components/LayoutNode.vue.d.ts +40 -0
  6. package/dist/components/LpAlert.vue.d.ts +44 -0
  7. package/dist/components/LpAppShell.vue.d.ts +83 -0
  8. package/dist/components/LpAutocomplete.vue.d.ts +50 -0
  9. package/dist/components/LpAvatar.vue.d.ts +20 -0
  10. package/dist/components/LpBadge.vue.d.ts +47 -0
  11. package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
  12. package/dist/components/LpButton.vue.d.ts +113 -0
  13. package/dist/components/LpCalendar.vue.d.ts +20 -0
  14. package/dist/components/LpCard.vue.d.ts +29 -0
  15. package/dist/components/LpCheckbox.vue.d.ts +28 -0
  16. package/dist/components/LpCodeBlock.vue.d.ts +39 -0
  17. package/dist/components/LpCommandPalette.vue.d.ts +42 -0
  18. package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
  19. package/dist/components/LpContextMenu.vue.d.ts +30 -0
  20. package/dist/components/LpDatePicker.vue.d.ts +25 -0
  21. package/dist/components/LpDisclosure.vue.d.ts +37 -0
  22. package/dist/components/LpDivider.vue.d.ts +17 -0
  23. package/dist/components/LpDrawer.vue.d.ts +88 -0
  24. package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
  25. package/dist/components/LpEmptyState.vue.d.ts +18 -0
  26. package/dist/components/LpFormField.vue.d.ts +21 -0
  27. package/dist/components/LpIcon.vue.d.ts +8 -0
  28. package/dist/components/LpInfraNode.vue.d.ts +21 -0
  29. package/dist/components/LpInput.vue.d.ts +68 -0
  30. package/dist/components/LpLaneNode.vue.d.ts +24 -0
  31. package/dist/components/LpLink.vue.d.ts +27 -0
  32. package/dist/components/LpLogViewer.vue.d.ts +92 -0
  33. package/dist/components/LpModal.vue.d.ts +43 -0
  34. package/dist/components/LpNotificationBell.vue.d.ts +51 -0
  35. package/dist/components/LpNumberField.vue.d.ts +14 -0
  36. package/dist/components/LpOtpInput.vue.d.ts +27 -0
  37. package/dist/components/LpPagination.vue.d.ts +25 -0
  38. package/dist/components/LpPasswordInput.vue.d.ts +28 -0
  39. package/dist/components/LpPhoneInput.vue.d.ts +37 -0
  40. package/dist/components/LpPopover.vue.d.ts +34 -0
  41. package/dist/components/LpProgress.vue.d.ts +12 -0
  42. package/dist/components/LpRadio.vue.d.ts +18 -0
  43. package/dist/components/LpRadioGroup.vue.d.ts +26 -0
  44. package/dist/components/LpScrollArea.vue.d.ts +24 -0
  45. package/dist/components/LpSegmented.vue.d.ts +24 -0
  46. package/dist/components/LpSelect.vue.d.ts +45 -0
  47. package/dist/components/LpServiceNode.vue.d.ts +14 -0
  48. package/dist/components/LpSidebar.vue.d.ts +85 -0
  49. package/dist/components/LpSidebarNav.vue.d.ts +34 -0
  50. package/dist/components/LpSkeleton.vue.d.ts +6 -0
  51. package/dist/components/LpSlider.vue.d.ts +23 -0
  52. package/dist/components/LpStat.vue.d.ts +22 -0
  53. package/dist/components/LpStepper.vue.d.ts +10 -0
  54. package/dist/components/LpSwitch.vue.d.ts +11 -0
  55. package/dist/components/LpTable.vue.d.ts +63 -0
  56. package/dist/components/LpTableOfContents.vue.d.ts +19 -0
  57. package/dist/components/LpTabs.vue.d.ts +45 -0
  58. package/dist/components/LpTextarea.vue.d.ts +17 -0
  59. package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
  60. package/dist/components/LpToaster.vue.d.ts +3 -0
  61. package/dist/components/LpTooltip.vue.d.ts +19 -0
  62. package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
  63. package/dist/components/LpUptimeBar.vue.d.ts +30 -0
  64. package/dist/components/codeHighlight.d.ts +8 -0
  65. package/dist/components/countries-data.d.ts +2 -0
  66. package/dist/components/countries.d.ts +20 -0
  67. package/dist/components/dropdown.d.ts +29 -0
  68. package/dist/components/sidebar.d.ts +18 -0
  69. package/dist/composables/useInputFilter.d.ts +14 -0
  70. package/dist/composables/usePillTransition.d.ts +9 -0
  71. package/dist/composables/useToast.d.ts +39 -0
  72. package/dist/countries-data-9rgqJWHo.js +203 -0
  73. package/dist/icons.d.ts +1 -0
  74. package/dist/index.css +1 -0
  75. package/dist/index.d.ts +100 -0
  76. package/dist/index.js +17600 -0
  77. package/dist/layout/registry.d.ts +26 -0
  78. package/dist/layout/tree.d.ts +75 -0
  79. package/dist/layout/useLayout.d.ts +39 -0
  80. package/dist/theme/config.d.ts +16 -0
  81. package/dist/theme/define.d.ts +12 -0
  82. package/dist/theme/presets.d.ts +20 -0
  83. package/dist/theme/tokens.d.ts +72 -0
  84. package/dist/theme/useTheme.d.ts +36 -0
  85. package/package.json +93 -0
  86. package/src/canvas.css +67 -0
  87. package/src/component-names.ts +65 -0
  88. package/src/components/LayoutCanvas.vue +76 -0
  89. package/src/components/LayoutNode.vue +248 -0
  90. package/src/components/LpAlert.vue +59 -0
  91. package/src/components/LpAppShell.vue +210 -0
  92. package/src/components/LpAutocomplete.vue +244 -0
  93. package/src/components/LpAvatar.vue +67 -0
  94. package/src/components/LpBadge.vue +32 -0
  95. package/src/components/LpBreadcrumbs.vue +35 -0
  96. package/src/components/LpButton.vue +111 -0
  97. package/src/components/LpCalendar.vue +134 -0
  98. package/src/components/LpCard.vue +71 -0
  99. package/src/components/LpCheckbox.vue +44 -0
  100. package/src/components/LpCodeBlock.vue +298 -0
  101. package/src/components/LpCommandPalette.vue +323 -0
  102. package/src/components/LpConfirmDialog.vue +49 -0
  103. package/src/components/LpContextMenu.vue +111 -0
  104. package/src/components/LpDatePicker.vue +117 -0
  105. package/src/components/LpDisclosure.vue +92 -0
  106. package/src/components/LpDivider.vue +20 -0
  107. package/src/components/LpDrawer.vue +402 -0
  108. package/src/components/LpDropdownMenu.vue +54 -0
  109. package/src/components/LpEmptyState.vue +21 -0
  110. package/src/components/LpFormField.vue +31 -0
  111. package/src/components/LpIcon.vue +49 -0
  112. package/src/components/LpInfraNode.vue +108 -0
  113. package/src/components/LpInput.vue +147 -0
  114. package/src/components/LpLaneNode.vue +48 -0
  115. package/src/components/LpLink.vue +48 -0
  116. package/src/components/LpLogViewer.vue +569 -0
  117. package/src/components/LpModal.vue +123 -0
  118. package/src/components/LpNotificationBell.vue +238 -0
  119. package/src/components/LpNumberField.vue +56 -0
  120. package/src/components/LpOtpInput.vue +81 -0
  121. package/src/components/LpPagination.vue +165 -0
  122. package/src/components/LpPasswordInput.vue +50 -0
  123. package/src/components/LpPhoneInput.vue +241 -0
  124. package/src/components/LpPopover.vue +42 -0
  125. package/src/components/LpProgress.vue +30 -0
  126. package/src/components/LpRadio.vue +28 -0
  127. package/src/components/LpRadioGroup.vue +43 -0
  128. package/src/components/LpScrollArea.vue +82 -0
  129. package/src/components/LpSegmented.vue +83 -0
  130. package/src/components/LpSelect.vue +0 -0
  131. package/src/components/LpServiceNode.vue +57 -0
  132. package/src/components/LpSidebar.vue +200 -0
  133. package/src/components/LpSidebarNav.vue +118 -0
  134. package/src/components/LpSkeleton.vue +16 -0
  135. package/src/components/LpSlider.vue +53 -0
  136. package/src/components/LpStat.vue +90 -0
  137. package/src/components/LpStepper.vue +46 -0
  138. package/src/components/LpSwitch.vue +19 -0
  139. package/src/components/LpTable.vue +252 -0
  140. package/src/components/LpTableOfContents.vue +183 -0
  141. package/src/components/LpTabs.vue +120 -0
  142. package/src/components/LpTextarea.vue +39 -0
  143. package/src/components/LpThemeSwitcher.vue +168 -0
  144. package/src/components/LpToaster.vue +216 -0
  145. package/src/components/LpTooltip.vue +30 -0
  146. package/src/components/LpTopologyCanvas.vue +370 -0
  147. package/src/components/LpUptimeBar.vue +126 -0
  148. package/src/components/codeHighlight.ts +137 -0
  149. package/src/components/countries-data.ts +207 -0
  150. package/src/components/countries.ts +67 -0
  151. package/src/components/dropdown.ts +64 -0
  152. package/src/components/sidebar.ts +26 -0
  153. package/src/composables/useInputFilter.ts +89 -0
  154. package/src/composables/usePillTransition.ts +19 -0
  155. package/src/composables/useToast.ts +92 -0
  156. package/src/icons.ts +11 -0
  157. package/src/index.css +21 -0
  158. package/src/index.ts +146 -0
  159. package/src/layout/registry.ts +44 -0
  160. package/src/layout/tree.ts +288 -0
  161. package/src/layout/useLayout.ts +134 -0
  162. package/src/nuxt.ts +22 -0
  163. package/src/theme/config.ts +48 -0
  164. package/src/theme/define.ts +50 -0
  165. package/src/theme/presets.ts +279 -0
  166. package/src/theme/tokens.ts +151 -0
  167. package/src/theme/useTheme.ts +288 -0
  168. package/src/tokens/scrollbar.css +138 -0
  169. package/src/tokens/tokens.css +333 -0
  170. package/src/tokens/utilities.css +23 -0
@@ -0,0 +1,569 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * Terminal-flavoured log stream. Monospace rows, a tonal level gutter, optional
4
+ * timestamps + source tags, search highlighting, and a sticky "tail" that pins
5
+ * to the bottom as lines arrive (drops out the moment you scroll up, with a
6
+ * jump-to-latest pill to re-arm). Built on LpScrollArea, which exposes its
7
+ * viewport element and re-emits the native scroll event — exactly what tailing
8
+ * needs.
9
+ */
10
+ import { computed, nextTick, ref, watch } from "vue"
11
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
12
+ import LpIcon from "./LpIcon.vue"
13
+ import LpScrollArea from "./LpScrollArea.vue"
14
+
15
+ export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal" | "success"
16
+
17
+ export interface LogLine {
18
+ /** Body of the line. */
19
+ message: string
20
+ level?: LogLevel
21
+ /** Epoch ms, ISO string, or pre-formatted label — shown in the time column. */
22
+ time?: number | string
23
+ /** Short origin tag (service / module), rendered as a dim chip. */
24
+ source?: string
25
+ }
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ lines: LogLine[]
30
+ /** Show the timestamp column. */
31
+ showTime?: boolean
32
+ /**
33
+ * Show the per-line level chip (INFO/WARN/…). On by default. Turn off for
34
+ * raw streams (e.g. docker logs) where most lines have no real level and a
35
+ * blanket "INFO" chip is just noise — the tonal level RAIL still renders, so
36
+ * errors/warnings stay visually distinct.
37
+ */
38
+ showLevel?: boolean
39
+ /** Show 1-based gutter line numbers. */
40
+ lineNumbers?: boolean
41
+ /** Wrap long lines instead of scrolling horizontally. */
42
+ wrap?: boolean
43
+ /** Case-insensitive substring to highlight in message bodies. */
44
+ highlight?: string
45
+ /** When set with `highlight`, hide every line that doesn't match. */
46
+ filterMatches?: boolean
47
+ /** Collapse consecutive identical lines into one, badged with a ×N count. */
48
+ compact?: boolean
49
+ /** Pin to the bottom as new lines arrive (until the user scrolls up). */
50
+ tail?: boolean
51
+ /** Fixed height. Anything CSS-valid; defaults to a comfortable terminal. */
52
+ height?: string
53
+ emptyLabel?: string
54
+ /**
55
+ * When true, a "loading older…" row is shown pinned at the top — set it while
56
+ * fetching a previous page in response to `reach-top`, clear it when done.
57
+ */
58
+ loadingOlder?: boolean
59
+ /**
60
+ * When true, the viewer emits `reach-top` as the user scrolls near the top so
61
+ * the consumer can prepend older history (infinite scroll-back). The viewer
62
+ * preserves the scroll position across a prepend so the view doesn't jump.
63
+ */
64
+ loadOlder?: boolean
65
+ /**
66
+ * Right-click row menu: built-in copy actions (+ "filter by" when the line
67
+ * has a source/level). Set false to disable; the row keeps the native menu.
68
+ */
69
+ rowMenu?: boolean
70
+ /** Extra menu items per line, appended below the built-ins with a divider. */
71
+ extraRowItems?: (line: LogLine, index: number) => ContextMenuItemDef[]
72
+ /**
73
+ * Recognise common log formats inside each `message` and lift the level,
74
+ * timestamp and [source] out of the text into the dedicated columns. When a
75
+ * line already carries an explicit `level`/`time`/`source`, those win and
76
+ * the message is left untouched. Off by default (raw). Handles e.g.:
77
+ * "2025-01-02T03:04:05.123Z INFO [auth] user logged in"
78
+ * "[2025-01-02 03:04:05] WARN: disk almost full"
79
+ * "ERROR something broke"
80
+ * so a duplicate level word / timestamp doesn't repeat in the message body.
81
+ */
82
+ parse?: boolean
83
+ }>(),
84
+ {
85
+ showTime: true,
86
+ showLevel: true,
87
+ lineNumbers: false,
88
+ wrap: false,
89
+ tail: true,
90
+ height: "20rem",
91
+ emptyLabel: "No logs yet",
92
+ rowMenu: true,
93
+ parse: false,
94
+ },
95
+ )
96
+
97
+ const emit = defineEmits<{
98
+ /** A "Filter by source/level" item was chosen — the consumer applies it. */
99
+ (e: "filter", by: { source?: string; level?: LogLevel }): void
100
+ /**
101
+ * The user scrolled near the top with `loadOlder` on — fetch the previous
102
+ * page. Re-armed only after the scroll leaves the top zone, so one trip up
103
+ * fires once. The viewer keeps the scroll anchored when the page prepends.
104
+ */
105
+ (e: "reach-top"): void
106
+ }>()
107
+
108
+ // Level → gutter rail + text colour. Reads only semantic tokens so re-skinning
109
+ // just works. trace/debug stay muted; success is green; warn amber; error/fatal
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" },
119
+ }
120
+
121
+ function metaFor(level?: LogLevel) {
122
+ return levelMeta[level ?? "info"]
123
+ }
124
+
125
+ // ── format recognition (opt-in via `parse`) ──────────────────────────────
126
+ // Map a recognised level word to a canonical LogLevel; unknown → undefined.
127
+ const LEVEL_WORDS: Record<string, LogLevel> = {
128
+ trace: "trace",
129
+ debug: "debug",
130
+ info: "info",
131
+ notice: "info",
132
+ warn: "warn",
133
+ warning: "warn",
134
+ error: "error",
135
+ err: "error",
136
+ fatal: "fatal",
137
+ crit: "fatal",
138
+ critical: "fatal",
139
+ panic: "fatal",
140
+ success: "success",
141
+ ok: "success",
142
+ }
143
+ // Leading ISO-8601 / "YYYY-MM-DD HH:MM:SS" timestamp, optionally bracketed.
144
+ const TS_RE =
145
+ /^\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?)\]?\s+/
146
+ // A leading LEVEL token, optionally followed by ':' .
147
+ const LEVEL_RE = /^([A-Za-z]{2,8})\s*:?\s+/
148
+ // A leading "[source]" tag.
149
+ const SOURCE_RE = /^\[([^\]]{1,40})\]\s+/
150
+
151
+ // Pull level/time/source out of a raw message; only fields not already set on
152
+ // the line are filled, and only the recognised prefixes are stripped from the
153
+ // body — so an unrecognised line is returned unchanged.
154
+ function parseLine(line: LogLine): LogLine {
155
+ if (!props.parse) return line
156
+ let msg = line.message
157
+ let time = line.time
158
+ let level = line.level
159
+ let source = line.source
160
+
161
+ // 1) timestamp prefix
162
+ if (time == null) {
163
+ const m = TS_RE.exec(msg)
164
+ if (m) {
165
+ time = m[1]
166
+ msg = msg.slice(m[0].length)
167
+ }
168
+ } else {
169
+ // strip a duplicate leading timestamp even if time is already set
170
+ const m = TS_RE.exec(msg)
171
+ if (m) msg = msg.slice(m[0].length)
172
+ }
173
+
174
+ // 2) level word
175
+ const lm = LEVEL_RE.exec(msg)
176
+ if (lm) {
177
+ const canon = LEVEL_WORDS[lm[1].toLowerCase()]
178
+ if (canon) {
179
+ if (level == null) level = canon
180
+ msg = msg.slice(lm[0].length) // drop the duplicate word from the body
181
+ }
182
+ }
183
+
184
+ // 3) [source] tag
185
+ if (source == null) {
186
+ const sm = SOURCE_RE.exec(msg)
187
+ if (sm) {
188
+ source = sm[1]
189
+ msg = msg.slice(sm[0].length)
190
+ }
191
+ }
192
+
193
+ return msg === line.message && time === line.time && level === line.level && source === line.source
194
+ ? line
195
+ : { ...line, message: msg, time, level, source }
196
+ }
197
+
198
+ const parsedLines = computed<LogLine[]>(() =>
199
+ props.parse ? props.lines.map(parseLine) : props.lines,
200
+ )
201
+
202
+ // Rows actually rendered, each carrying its ORIGINAL index so line numbers stay
203
+ // truthful when filtering, plus a `count` for compact mode (1 unless folded).
204
+ // With filterMatches + a search term, keep only lines whose message contains it
205
+ // (case-insensitive); otherwise pass everything.
206
+ const visibleLines = computed<{ line: LogLine; n: number; count: number }[]>(() => {
207
+ const term = props.filterMatches ? props.highlight?.trim().toLowerCase() : ""
208
+ const filtered = parsedLines.value
209
+ .map((line, n) => ({ line, n }))
210
+ .filter(({ line }) => !term || line.message.toLowerCase().includes(term))
211
+
212
+ if (!props.compact) return filtered.map((row) => ({ ...row, count: 1 }))
213
+
214
+ // Fold runs of consecutive identical lines (same message/level/source, time
215
+ // ignored). Keeps the FIRST occurrence so its index/time stay meaningful;
216
+ // non-adjacent repeats are left alone so chronology isn't scrambled.
217
+ const folded: { line: LogLine; n: number; count: number }[] = []
218
+ for (const row of filtered) {
219
+ const prev = folded[folded.length - 1]
220
+ if (
221
+ prev &&
222
+ prev.line.message === row.line.message &&
223
+ prev.line.level === row.line.level &&
224
+ prev.line.source === row.line.source
225
+ ) {
226
+ prev.count++
227
+ } else {
228
+ folded.push({ ...row, count: 1 })
229
+ }
230
+ }
231
+ return folded
232
+ })
233
+
234
+ // True when filtering has emptied an otherwise non-empty stream.
235
+ const filteredEmpty = computed(
236
+ () => props.lines.length > 0 && visibleLines.value.length === 0,
237
+ )
238
+
239
+ function fmtTime(time?: number | string): string {
240
+ if (time == null) return ""
241
+ if (typeof time === "number" || /^\d+$/.test(time)) {
242
+ const d = new Date(Number(time))
243
+ if (Number.isNaN(d.getTime())) return String(time)
244
+ return d.toLocaleTimeString(undefined, { hour12: false }) + "." +
245
+ String(d.getMilliseconds()).padStart(3, "0")
246
+ }
247
+ return time
248
+ }
249
+
250
+ // ── row context menu ─────────────────────────────────────────────────
251
+ async function copy(text: string) {
252
+ try {
253
+ await navigator.clipboard?.writeText(text)
254
+ } catch {
255
+ // Clipboard can reject (insecure context / denied permission); swallow —
256
+ // the menu action is best-effort and shouldn't throw into the UI.
257
+ }
258
+ }
259
+
260
+ // The whole line as one copyable string: "time LEVEL [source] message".
261
+ function lineText(line: LogLine): string {
262
+ return [fmtTime(line.time), line.level?.toUpperCase(), line.source && `[${line.source}]`, line.message]
263
+ .filter(Boolean)
264
+ .join(" ")
265
+ }
266
+
267
+ // Built-in copy actions + optional "filter by" + consumer extras. Rebuilt per
268
+ // right-click so it reflects the line under the cursor.
269
+ function menuFor(line: LogLine, index: number): ContextMenuItemDef[] {
270
+ if (!props.rowMenu) return []
271
+ const items: ContextMenuItemDef[] = [
272
+ { label: "Copy message", icon: "lucide:copy", onSelect: () => copy(line.message) },
273
+ { label: "Copy line", icon: "lucide:clipboard-list", onSelect: () => copy(lineText(line)) },
274
+ ]
275
+ if (line.time != null) {
276
+ items.push({ label: "Copy timestamp", icon: "lucide:clock", onSelect: () => copy(fmtTime(line.time)) })
277
+ }
278
+ if (line.source) {
279
+ items.push({
280
+ label: `Filter by [${line.source}]`,
281
+ icon: "lucide:filter",
282
+ separatorBefore: true,
283
+ onSelect: () => emit("filter", { source: line.source }),
284
+ })
285
+ }
286
+ if (line.level) {
287
+ items.push({
288
+ label: `Filter by ${line.level}`,
289
+ icon: "lucide:filter",
290
+ separatorBefore: !line.source,
291
+ onSelect: () => emit("filter", { level: line.level }),
292
+ })
293
+ }
294
+ const extra = props.extraRowItems?.(line, index) ?? []
295
+ if (extra.length) extra[0] = { ...extra[0], separatorBefore: true }
296
+ return [...items, ...extra]
297
+ }
298
+
299
+ // Split a message around the (case-insensitive) highlight term so we can wrap
300
+ // matches in <mark> without v-html. Empty/absent term → single plain part.
301
+ interface Part {
302
+ text: string
303
+ hit: boolean
304
+ }
305
+ function splitHighlight(message: string): Part[] {
306
+ const term = props.highlight?.trim()
307
+ if (!term) return [{ text: message, hit: false }]
308
+ const parts: Part[] = []
309
+ const lower = message.toLowerCase()
310
+ const needle = term.toLowerCase()
311
+ let i = 0
312
+ while (i < message.length) {
313
+ const at = lower.indexOf(needle, i)
314
+ if (at === -1) {
315
+ parts.push({ text: message.slice(i), hit: false })
316
+ break
317
+ }
318
+ if (at > i) parts.push({ text: message.slice(i, at), hit: false })
319
+ parts.push({ text: message.slice(at, at + needle.length), hit: true })
320
+ i = at + needle.length
321
+ }
322
+ return parts
323
+ }
324
+
325
+ // ── tail / auto-scroll ───────────────────────────────────────────────
326
+ const scrollRef = ref<{ viewportEl: HTMLElement | null } | null>(null)
327
+ const pinned = ref(true) // currently glued to the bottom
328
+ // Slack from the bottom that still counts as "pinned". Generous (a few rows
329
+ // tall) so a freshly pushed line — which momentarily grows scrollHeight before
330
+ // the tail catches up — doesn't unpin us and flash the jump-to-latest pill;
331
+ // only a deliberate scroll up past this clears it.
332
+ const NEAR = 120
333
+
334
+ function viewport(): HTMLElement | null {
335
+ return scrollRef.value?.viewportEl ?? null
336
+ }
337
+
338
+ function atBottom(el: HTMLElement): boolean {
339
+ return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR
340
+ }
341
+
342
+ // Distance from the top that arms a scroll-back fetch (a few rows of slack so
343
+ // the next page is on its way before the user hits the very top).
344
+ const TOP_NEAR = 80
345
+ // Armed = ready to fire reach-top; cleared until the scroll leaves the top zone
346
+ // so one trip to the top emits exactly once.
347
+ let topArmed = true
348
+
349
+ function onScroll(e: Event) {
350
+ const el = e.target as HTMLElement
351
+ pinned.value = atBottom(el)
352
+ if (!props.loadOlder) return
353
+ if (el.scrollTop <= TOP_NEAR) {
354
+ if (topArmed && !props.loadingOlder) {
355
+ topArmed = false
356
+ emit("reach-top")
357
+ }
358
+ } else {
359
+ topArmed = true
360
+ }
361
+ }
362
+
363
+ // Preserve scroll position across a prepend: when older lines are added at the
364
+ // top, scrollTop must grow by the height they introduced or the view jumps. We
365
+ // snapshot scrollHeight before the lines change and restore the offset after.
366
+ let prevScrollHeight = 0
367
+ let prevScrollTop = 0
368
+ function captureScrollAnchor() {
369
+ const el = viewport()
370
+ if (!el) return
371
+ prevScrollHeight = el.scrollHeight
372
+ prevScrollTop = el.scrollTop
373
+ }
374
+ function restoreScrollAnchor() {
375
+ const el = viewport()
376
+ if (!el) return
377
+ const added = el.scrollHeight - prevScrollHeight
378
+ if (added > 0) el.scrollTop = prevScrollTop + added
379
+ }
380
+
381
+ // Glide to the bottom. Honours OS reduce-motion. For auto-tail we skip the
382
+ // animation on large jumps (`far`), where a smooth chase would feel sluggish —
383
+ // but a deliberate "jump to latest" passes force=true to always animate.
384
+ function rideToBottom(smooth = true, force = false) {
385
+ const el = viewport()
386
+ if (!el) return
387
+ const reduce =
388
+ typeof window !== "undefined" &&
389
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
390
+ const far = !force && el.scrollHeight - el.scrollTop - el.clientHeight > el.clientHeight
391
+ el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce && !far ? "smooth" : "auto" })
392
+ }
393
+
394
+ function scrollToBottom() {
395
+ rideToBottom(true, true)
396
+ pinned.value = true
397
+ }
398
+
399
+ // Snapshot the scroll anchor BEFORE the DOM updates for a line change (flush:pre)
400
+ // so a prepend can be compensated post-update. Only matters when NOT pinned to
401
+ // the bottom (i.e. the user is reading history up the stream).
402
+ watch(
403
+ () => props.lines.length,
404
+ () => {
405
+ if (!pinned.value) captureScrollAnchor()
406
+ },
407
+ { flush: "pre" },
408
+ )
409
+
410
+ // After the DOM updates: tail to the bottom if pinned, else keep the view glued
411
+ // to where it was so a top prepend (older history) doesn't yank it.
412
+ watch(
413
+ () => props.lines.length,
414
+ async () => {
415
+ await nextTick()
416
+ if (props.tail && pinned.value) {
417
+ requestAnimationFrame(() => rideToBottom(true))
418
+ } else {
419
+ restoreScrollAnchor()
420
+ }
421
+ },
422
+ )
423
+
424
+ const showJump = computed(() => props.tail && !pinned.value && props.lines.length > 0)
425
+ </script>
426
+
427
+ <template>
428
+ <div
429
+ class="relative overflow-hidden rounded-card border border-line bg-surface font-mono text-xs leading-relaxed"
430
+ >
431
+ <LpScrollArea
432
+ ref="scrollRef"
433
+ fade
434
+ :content-class="wrap ? '' : 'min-w-max'"
435
+ :style="{ height }"
436
+ @scroll="onScroll"
437
+ >
438
+ <div
439
+ v-if="lines.length === 0 || filteredEmpty"
440
+ class="flex h-full flex-col items-center justify-center gap-1 px-4 py-10 text-center"
441
+ >
442
+ <span class="text-muted">{{ filteredEmpty ? "No lines match" : emptyLabel }}</span>
443
+ <span v-if="filteredEmpty" class="text-muted/70">“{{ highlight }}”</span>
444
+ </div>
445
+
446
+ <template v-else>
447
+ <!-- Older-history loading row, pinned at the top while a scroll-back page
448
+ is fetched (driven by `loadingOlder`) — sits ABOVE the lines so it
449
+ doesn't replace them. -->
450
+ <div
451
+ v-if="loadingOlder"
452
+ class="flex items-center justify-center gap-2 py-2 text-muted"
453
+ >
454
+ <LpIcon name="lucide:loader-circle" :size="14" class="animate-spin" />
455
+ <span class="text-[11px]">loading older…</span>
456
+ </div>
457
+
458
+ <!-- Leave collapses a folded-away duplicate (max-height + opacity + a small
459
+ 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"
463
+ 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)]"
470
+ >
471
+ <LpContextMenu
472
+ v-for="{ line, n, count } in visibleLines"
473
+ :key="n"
474
+ :items="menuFor(line, n)"
475
+ >
476
+ <li
477
+ class="group flex items-start gap-0 px-0 transition-colors hover:bg-surface-soft/60"
478
+ >
479
+ <!-- level rail -->
480
+ <span
481
+ class="mr-2 w-0.5 shrink-0 self-stretch"
482
+ :class="metaFor(line.level).rail"
483
+ aria-hidden="true"
484
+ />
485
+
486
+ <!-- line number -->
487
+ <span
488
+ v-if="lineNumbers"
489
+ class="mr-3 w-10 shrink-0 select-none text-right tabular-nums text-muted/70"
490
+ >{{ n + 1 }}</span>
491
+
492
+ <!-- timestamp -->
493
+ <span
494
+ v-if="showTime"
495
+ class="mr-3 shrink-0 select-none tabular-nums text-muted"
496
+ >{{ fmtTime(line.time) }}</span>
497
+
498
+ <!-- level chip -->
499
+ <span
500
+ v-if="showLevel"
501
+ class="mr-3 w-12 shrink-0 select-none text-[10px] font-semibold uppercase tracking-[0.06em]"
502
+ :class="metaFor(line.level).chip"
503
+ >{{ line.level ?? "info" }}</span>
504
+
505
+ <!-- source tag -->
506
+ <span
507
+ v-if="line.source"
508
+ class="mr-2 shrink-0 select-none text-muted-strong"
509
+ >[{{ line.source }}]</span>
510
+
511
+ <!-- message -->
512
+ <span
513
+ class="min-w-0 pr-3"
514
+ :class="[metaFor(line.level).text, wrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre']"
515
+ >
516
+ <template v-for="(part, p) in splitHighlight(line.message)" :key="p">
517
+ <mark
518
+ v-if="part.hit"
519
+ class="rounded-xs bg-accent/30 text-ink"
520
+ >{{ part.text }}</mark>
521
+ <template v-else>{{ part.text }}</template>
522
+ </template>
523
+ <!-- compact: how many consecutive identical lines were folded here.
524
+ Keyed on count so each new fold re-mounts the chip and pops it,
525
+ cueing where the duplicate just merged. -->
526
+ <Transition
527
+ enter-active-class="inline-block transition duration-200 ease-[var(--ease-emphasized)]"
528
+ enter-from-class="scale-50 opacity-0"
529
+ >
530
+ <span
531
+ v-if="count > 1"
532
+ :key="count"
533
+ class="ml-2 select-none rounded-pill bg-surface-soft px-1.5 align-[1px] text-[10px] font-semibold tabular-nums text-muted-strong"
534
+ :title="`${count} identical lines`"
535
+ >×{{ count }}</span>
536
+ </Transition>
537
+ </span>
538
+ </li>
539
+ </LpContextMenu>
540
+ </TransitionGroup>
541
+ </template>
542
+ </LpScrollArea>
543
+
544
+ <!-- jump-to-latest: shown only when tailing and scrolled away. The
545
+ Transition rides an outer wrapper so its transform/opacity don't fight
546
+ the button's own -translate-x-1/2 centring and hover colour transition. -->
547
+ <Transition
548
+ enter-active-class="transition duration-200 ease-[var(--ease-emphasized)]"
549
+ enter-from-class="translate-y-3 scale-90 opacity-0"
550
+ leave-active-class="transition duration-150 ease-in"
551
+ leave-to-class="translate-y-3 scale-90 opacity-0"
552
+ >
553
+ <div v-if="showJump" class="absolute inset-x-0 bottom-3 flex justify-center">
554
+ <button
555
+ type="button"
556
+ class="group flex items-center gap-1.5 rounded-pill border border-line bg-surface-overlay px-3 py-1 text-xs font-medium text-ink shadow-[var(--shadow-panel)] backdrop-blur outline-none transition-colors hover:bg-surface-soft focus-visible:ring-2 focus-visible:ring-ring"
557
+ @click="scrollToBottom"
558
+ >
559
+ <LpIcon
560
+ name="lucide:arrow-down"
561
+ :size="14"
562
+ class="transition-transform duration-200 ease-[var(--ease-emphasized)] group-hover:translate-y-0.5"
563
+ />
564
+ Jump to latest
565
+ </button>
566
+ </div>
567
+ </Transition>
568
+ </div>
569
+ </template>
@@ -0,0 +1,123 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ DialogClose,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogOverlay,
7
+ DialogPortal,
8
+ DialogRoot,
9
+ DialogTitle,
10
+ } from "reka-ui"
11
+ import { computed, useSlots } from "vue"
12
+ import { CLOSE_ICON } from "./dropdown"
13
+ import LpIcon from "./LpIcon.vue"
14
+ import LpScrollArea from "./LpScrollArea.vue"
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ open?: boolean
19
+ title?: string
20
+ description?: string
21
+ /**
22
+ * Max content width preset. sm≈24rem … xl≈42rem, 2xl≈56rem, 3xl≈72rem,
23
+ * full≈96vw (near-fullscreen, for dense catalogues).
24
+ */
25
+ size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
26
+ /** Explicit width override (any CSS length), wins over `size`. */
27
+ width?: string
28
+ /**
29
+ * Let the body fill the available height as a flex column instead of
30
+ * scrolling itself. Use for dense dashboards that own their inner scroll
31
+ * regions (e.g. multi-pane catalogues) — the panes scroll, not the modal.
32
+ */
33
+ fillBody?: boolean
34
+ }>(),
35
+ { size: "md" },
36
+ )
37
+
38
+ defineEmits<{ (e: "update:open", value: boolean): void }>()
39
+
40
+ const widthClass = computed(() => {
41
+ if (props.width) return ""
42
+ return {
43
+ sm: "w-[min(92vw,24rem)]",
44
+ md: "w-[min(92vw,28rem)]",
45
+ lg: "w-[min(92vw,34rem)]",
46
+ xl: "w-[min(92vw,42rem)]",
47
+ "2xl": "w-[min(94vw,56rem)]",
48
+ "3xl": "w-[min(95vw,72rem)]",
49
+ full: "w-[96vw]",
50
+ }[props.size]
51
+ })
52
+
53
+ // Body padding, shared by the fillBody flex path and the scroll path. Always
54
+ // inset horizontally; top only when there's no header, bottom only when there's
55
+ // no footer (header/footer own those edges).
56
+ const slots = useSlots()
57
+ const bodyPad = computed(() =>
58
+ [
59
+ "px-5",
60
+ props.title || slots.title ? "" : "pt-5",
61
+ slots.footer ? "" : "pb-5",
62
+ ]
63
+ .filter(Boolean)
64
+ .join(" "),
65
+ )
66
+ </script>
67
+
68
+ <template>
69
+ <DialogRoot :open="open" @update:open="(v) => $emit('update:open', v)">
70
+ <DialogPortal>
71
+ <DialogOverlay
72
+ class="fixed inset-0 z-(--z-overlay) bg-black/50 backdrop-blur-sm data-[state=open]:animate-[fade-in_150ms_ease] data-[state=closed]:animate-[fade-out_130ms_ease]"
73
+ />
74
+ <DialogContent
75
+ class="fixed left-1/2 top-1/2 z-(--z-modal) flex max-h-[min(90vh,calc(100dvh-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col rounded-card border border-line bg-surface-raised shadow-panel outline-none data-[state=open]:animate-[pop-in_160ms_var(--ease-emphasized)] data-[state=closed]:animate-[pop-out_130ms_ease]"
76
+ :class="widthClass"
77
+ :style="width ? { width } : undefined"
78
+ >
79
+ <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
80
+ <div class="flex flex-col gap-1">
81
+ <DialogTitle class="text-base font-semibold text-ink">
82
+ <slot name="title">{{ title }}</slot>
83
+ </DialogTitle>
84
+ <DialogDescription v-if="description" class="text-sm text-muted">
85
+ {{ description }}
86
+ </DialogDescription>
87
+ </div>
88
+ <DialogClose
89
+ class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
90
+ aria-label="Close"
91
+ >
92
+ <LpIcon
93
+ name="lucide:x"
94
+ :size="18"
95
+ :class="CLOSE_ICON"
96
+ />
97
+ </DialogClose>
98
+ </header>
99
+
100
+ <!-- fillBody: a plain flex column that owns its own inner scroll regions.
101
+ Otherwise the body scrolls itself via LpScrollArea's overlay bar. -->
102
+ <div
103
+ v-if="fillBody"
104
+ class="flex min-h-0 flex-1 flex-col overflow-hidden text-sm text-ink/90"
105
+ :class="bodyPad"
106
+ >
107
+ <slot />
108
+ </div>
109
+ <LpScrollArea
110
+ v-else
111
+ class="min-h-0 flex-1 text-sm text-ink/90"
112
+ :content-class="bodyPad"
113
+ >
114
+ <slot />
115
+ </LpScrollArea>
116
+
117
+ <footer v-if="$slots.footer" class="flex shrink-0 justify-end gap-2 p-5 pt-4">
118
+ <slot name="footer" />
119
+ </footer>
120
+ </DialogContent>
121
+ </DialogPortal>
122
+ </DialogRoot>
123
+ </template>