@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,370 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * LpTopologyCanvas — a declarative infra topology graph on Vue Flow (the engine
4
+ * n8n & NetBird use), themed with kit tokens. Pan/zoom, draggable nodes, bezier
5
+ * edges, dot-grid background, minimap, controls. Data-driven: the consuming app
6
+ * passes `nodes`/`edges` and listens to `connect` / `node-select`; this
7
+ * component owns NO data or fetching, so any panel can reuse it.
8
+ *
9
+ * Edge observed-state styling: solid brand = applied, animated dashed =
10
+ * pending, dashed danger = drift.
11
+ */
12
+ import { Background } from "@vue-flow/background"
13
+ import { Controls } from "@vue-flow/controls"
14
+ import {
15
+ MarkerType,
16
+ Panel,
17
+ SelectionMode,
18
+ VueFlow,
19
+ useVueFlow,
20
+ type Connection,
21
+ type Node,
22
+ type NodeTypesObject,
23
+ } from "@vue-flow/core"
24
+ import { MiniMap } from "@vue-flow/minimap"
25
+ import { computed, markRaw, watch, type Component } from "vue"
26
+ import LpInfraNode, { type InfraNodeData } from "./LpInfraNode.vue"
27
+ import LpLaneNode from "./LpLaneNode.vue"
28
+ import LpServiceNode, { type ServiceNodeData } from "./LpServiceNode.vue"
29
+
30
+ // Vue Flow base CSS + canvas chrome theming is shipped via the kit's
31
+ // "@leavepulse/ui/canvas.css" export (a plain CSS file). Component-local CSS
32
+ // imports don't reach apps through the built dist, so the consuming app imports
33
+ // that one file. See src/canvas.css.
34
+
35
+ export type EdgeObserved = "applied" | "pending" | "drift"
36
+
37
+ export interface TopologyNode {
38
+ id: string
39
+ position: { x: number; y: number }
40
+ /** Host/group nodes use InfraNodeData; service nodes use ServiceNodeData. */
41
+ data: InfraNodeData | ServiceNodeData
42
+ /**
43
+ * Node kind: "infra" (a host card, default), "service" (a small service node
44
+ * placed inside a host frame), or "group" (a host frame that contains
45
+ * services). Services set `parent` to their group's id.
46
+ */
47
+ type?: "infra" | "service" | "group"
48
+ /** For service nodes: the id of the host-frame group node they live in. */
49
+ parent?: string
50
+ /** For group nodes: pixel size of the frame, and an optional label. */
51
+ width?: number
52
+ height?: number
53
+ label?: string
54
+ }
55
+
56
+ /**
57
+ * Edge semantics, encoded with both colour AND line style so the two kinds read
58
+ * apart at a glance (and for colour-blind users): "network" = a transport wire
59
+ * between hosts (solid, brand-cyan); "structural" = a depends_on relation
60
+ * between services (dotted, violet, thinner). Inferred from `kind` when omitted
61
+ * (`depends_on` → structural, everything else → network).
62
+ */
63
+ export type EdgeCategory = "network" | "structural"
64
+
65
+ export interface TopologyEdge {
66
+ id: string
67
+ source: string
68
+ target: string
69
+ /** transport label shown on the edge (e.g. "wg", "cloudflared"). */
70
+ kind?: string
71
+ /** Semantic class driving colour + dash. Inferred from `kind` if omitted. */
72
+ category?: EdgeCategory
73
+ observed?: EdgeObserved
74
+ }
75
+
76
+ /**
77
+ * A background swimlane: a sized zone (one per project / overlay network) drawn
78
+ * beneath the nodes. The consumer computes the geometry; the canvas renders it
79
+ * as a non-interactive Vue Flow node so it pans/zooms in lockstep with the
80
+ * graph. Lanes are layered below real nodes and never intercept pointer events.
81
+ */
82
+ export interface TopologyLane {
83
+ id: string
84
+ label: string
85
+ x: number
86
+ y: number
87
+ width: number
88
+ height: number
89
+ /** Optional accent colour token for the lane border + label. */
90
+ accent?: string
91
+ }
92
+
93
+ /** Canvas viewport transform — for restoring a saved pan/zoom. */
94
+ export interface CanvasViewport {
95
+ x: number
96
+ y: number
97
+ zoom: number
98
+ }
99
+
100
+ const props = withDefaults(
101
+ defineProps<{
102
+ nodes: TopologyNode[]
103
+ edges: TopologyEdge[]
104
+ /** Allow drag-to-connect (emits `connect`). Default false (read-only). */
105
+ connectable?: boolean
106
+ /**
107
+ * Desktop-style marquee selection: drag on empty canvas to rubber-band a
108
+ * box and select every node inside; the selected set then drags together.
109
+ * Hold Shift to add to the selection. Panning moves to a right-drag / the
110
+ * space bar so left-drag is free for selecting. Default false (left-drag
111
+ * pans, the classic read-only behaviour). Emits `selection-change`.
112
+ */
113
+ selectable?: boolean
114
+ /** Show the edge-type legend (Network / Depends-on / Pending / Drift). */
115
+ legend?: boolean
116
+ /**
117
+ * Background swimlanes drawn beneath the nodes (one per project / overlay).
118
+ * Non-interactive; pan/zoom with the graph. Empty = no lanes.
119
+ */
120
+ lanes?: TopologyLane[]
121
+ }>(),
122
+ { connectable: false, selectable: false, legend: true, lanes: () => [] },
123
+ )
124
+
125
+ const emit = defineEmits<{
126
+ (e: "connect", value: Connection): void
127
+ (e: "node-select", id: string): void
128
+ (e: "node-contextmenu", value: { id: string; x: number; y: number }): void
129
+ (e: "edge-contextmenu", value: { id: string; x: number; y: number }): void
130
+ (e: "pane-click"): void
131
+ /** Ids of the marquee/multi-selected nodes (empty when cleared). */
132
+ (e: "selection-change", ids: string[]): void
133
+ (e: "update:nodes", value: TopologyNode[]): void
134
+ /** A node finished being dragged — its new canvas position (for persistence). */
135
+ (
136
+ e: "node-drag-stop",
137
+ value: { id: string; position: { x: number; y: number } },
138
+ ): void
139
+ /** Pan/zoom settled — the new viewport (for per-user persistence). */
140
+ (
141
+ e: "viewport-change",
142
+ value: { zoom: number; x: number; y: number },
143
+ ): void
144
+ }>()
145
+
146
+ // Vue Flow types node components as `Component<NodeProps>`; our nodes declare
147
+ // their own props (data/selected) which Vue Flow supplies at runtime as part of
148
+ // NodeProps. Widen to `Component` (not the concrete DefineComponent) so the
149
+ // registry types cleanly without a cast.
150
+ const nodeTypes: NodeTypesObject = {
151
+ infra: markRaw(LpInfraNode) as Component,
152
+ service: markRaw(LpServiceNode) as Component,
153
+ lane: markRaw(LpLaneNode) as Component,
154
+ }
155
+
156
+ // Lanes render as non-interactive Vue Flow nodes BENEATH the graph: prepended
157
+ // so they paint first, with selecting/dragging/connecting off and a low z so
158
+ // real nodes always sit on top. They pan/zoom with the viewport for free.
159
+ const laneNodes = computed<Node[]>(() =>
160
+ props.lanes.map(
161
+ (l): Node => ({
162
+ id: `lane-${l.id}`,
163
+ type: "lane",
164
+ position: { x: l.x, y: l.y },
165
+ data: { label: l.label, accent: l.accent, width: l.width, height: l.height },
166
+ selectable: false,
167
+ draggable: false,
168
+ connectable: false,
169
+ focusable: false,
170
+ zIndex: 0,
171
+ }),
172
+ ),
173
+ )
174
+
175
+ const graphNodes = computed<Node[]>(() =>
176
+ props.nodes.map((n): Node => {
177
+ const type = n.type ?? "infra"
178
+ const node: Node = {
179
+ id: n.id,
180
+ type,
181
+ position: n.position,
182
+ data: n.data,
183
+ zIndex: 1,
184
+ ...(n.parent ? { parentNode: n.parent } : {}),
185
+ }
186
+ if (type === "group") {
187
+ // A host frame: a Vue Flow group node sized to hold its services.
188
+ node.style = {
189
+ width: `${n.width ?? 260}px`,
190
+ height: `${n.height ?? 160}px`,
191
+ background: "color-mix(in srgb, var(--color-surface-soft) 60%, transparent)",
192
+ border: "1px solid var(--color-line)",
193
+ borderRadius: "var(--radius-card)",
194
+ opacity: (n.data as { dimmed?: boolean }).dimmed ? "0.35" : "1",
195
+ transition: "opacity var(--duration-fast)",
196
+ }
197
+ node.label = n.label ?? n.data.name
198
+ }
199
+ return node
200
+ }),
201
+ )
202
+
203
+ // Lanes first (painted beneath), then the graph nodes on top.
204
+ const flowNodes = computed<Node[]>(() => [
205
+ ...laneNodes.value,
206
+ ...graphNodes.value,
207
+ ])
208
+
209
+ // Category drives the resting colour + base dash; `depends_on` is structural.
210
+ function edgeCategory(e: TopologyEdge): EdgeCategory {
211
+ return e.category ?? (e.kind === "depends_on" ? "structural" : "network")
212
+ }
213
+ const CATEGORY_COLOR: Record<EdgeCategory, string> = {
214
+ network: "var(--color-brand)",
215
+ structural: "var(--color-accent)",
216
+ }
217
+ // Drift is an alarm and overrides the category colour; otherwise the type's
218
+ // colour stays so the kind is always legible (pending only animates).
219
+ function edgeColor(cat: EdgeCategory, o: EdgeObserved): string {
220
+ return o === "drift" ? "var(--color-danger)" : CATEGORY_COLOR[cat]
221
+ }
222
+ // Dash encodes BOTH axes: structural is dotted at rest; pending/drift switch to
223
+ // a marching dash. Network applied = solid.
224
+ function edgeDash(cat: EdgeCategory, o: EdgeObserved): string {
225
+ if (o !== "applied") return "6 5"
226
+ return cat === "structural" ? "1 6" : "0"
227
+ }
228
+
229
+ const flowEdges = computed(() =>
230
+ props.edges.map((e) => {
231
+ const obs = e.observed ?? "applied"
232
+ const cat = edgeCategory(e)
233
+ const color = edgeColor(cat, obs)
234
+ return {
235
+ id: e.id,
236
+ source: e.source,
237
+ target: e.target,
238
+ type: "default",
239
+ animated: obs !== "applied",
240
+ label: e.kind,
241
+ labelBgStyle: { fill: "var(--color-surface)" },
242
+ labelStyle: { fill: "var(--color-muted-strong)", fontSize: "10px" },
243
+ style: {
244
+ stroke: color,
245
+ strokeWidth: cat === "structural" ? 1.5 : 2,
246
+ strokeDasharray: edgeDash(cat, obs),
247
+ strokeLinecap: "round" as const,
248
+ },
249
+ markerEnd: { type: MarkerType.ArrowClosed, color },
250
+ }
251
+ }),
252
+ )
253
+
254
+ const {
255
+ onNodeClick,
256
+ onNodeContextMenu,
257
+ onEdgeContextMenu,
258
+ onPaneClick,
259
+ onConnect,
260
+ onNodeDragStop,
261
+ onMoveEnd,
262
+ getSelectedNodes,
263
+ fitView,
264
+ setViewport,
265
+ } = useVueFlow()
266
+ onNodeClick(({ node }) => emit("node-select", node.id))
267
+ // Drag settled → surface the node's final position so the host can persist it.
268
+ onNodeDragStop(({ node }) =>
269
+ emit("node-drag-stop", { id: node.id, position: { ...node.position } }),
270
+ )
271
+ // Pan/zoom settled → surface the viewport so the host can persist it per user.
272
+ onMoveEnd(({ flowTransform }) => {
273
+ if (flowTransform)
274
+ emit("viewport-change", {
275
+ zoom: flowTransform.zoom,
276
+ x: flowTransform.x,
277
+ y: flowTransform.y,
278
+ })
279
+ })
280
+ // Vue Flow has no onSelectionChange hook in this version; the selected set is
281
+ // reactive on the store, so watch it and surface the ids.
282
+ watch(
283
+ () => getSelectedNodes.value.map((n) => n.id),
284
+ (ids) => emit("selection-change", ids),
285
+ )
286
+ onNodeContextMenu(({ node, event }) => {
287
+ // Suppress the browser menu and surface the node + cursor so the host can
288
+ // render its own actions (open page, focus, filter, copy…).
289
+ const e = event as MouseEvent
290
+ e.preventDefault?.()
291
+ emit("node-contextmenu", { id: node.id, x: e.clientX, y: e.clientY })
292
+ })
293
+ // Legend rows mirror the edge encoding exactly (same colours/dashes).
294
+ const legendItems = [
295
+ { label: "Network", color: CATEGORY_COLOR.network, width: 2, dash: "0" },
296
+ { label: "Depends-on", color: CATEGORY_COLOR.structural, width: 1.5, dash: "1 5" },
297
+ { label: "Pending", color: "var(--color-brand)", width: 2, dash: "6 5" },
298
+ { label: "Drift", color: "var(--color-danger)", width: 2, dash: "6 5" },
299
+ ] as const
300
+
301
+ onEdgeContextMenu(({ edge, event }) => {
302
+ // Right-click an edge → host renders edge actions (change transport, remove…).
303
+ const e = event as MouseEvent
304
+ e.preventDefault?.()
305
+ emit("edge-contextmenu", { id: edge.id, x: e.clientX, y: e.clientY })
306
+ })
307
+ onPaneClick(() => emit("pane-click"))
308
+ onConnect((conn) => emit("connect", conn))
309
+
310
+ // Restore a saved viewport (pan/zoom). The host calls this after loading the
311
+ // user's persisted CanvasViewport on mount.
312
+ async function applyViewport(v: CanvasViewport): Promise<void> {
313
+ await setViewport({ x: v.x, y: v.y, zoom: v.zoom })
314
+ }
315
+
316
+ defineExpose({ fitView, setViewport: applyViewport })
317
+ </script>
318
+
319
+ <template>
320
+ <VueFlow
321
+ :nodes="flowNodes"
322
+ :edges="flowEdges"
323
+ :node-types="nodeTypes"
324
+ :nodes-connectable="connectable"
325
+ :selection-on-drag="selectable"
326
+ :pan-on-drag="selectable ? [1, 2] : true"
327
+ :selection-key-code="selectable ? true : null"
328
+ multi-selection-key-code="Shift"
329
+ :selection-mode="SelectionMode.Partial"
330
+ :default-viewport="{ zoom: 0.9 }"
331
+ :min-zoom="0.3"
332
+ :max-zoom="2"
333
+ fit-view-on-init
334
+ class="lp-topology"
335
+ >
336
+ <Background :gap="22" :size="1.4" pattern-color="var(--color-line-strong)" />
337
+ <MiniMap pannable zoomable node-color="var(--color-surface-soft)" mask-color="rgba(0,0,0,0.55)" />
338
+ <Controls />
339
+
340
+ <!-- Edge-type legend: makes the colour/dash encoding self-explanatory. -->
341
+ <Panel v-if="legend" position="top-right">
342
+ <div
343
+ class="flex flex-col gap-1.5 rounded-card border border-line bg-surface-overlay/90 px-3 py-2 text-[11px] backdrop-blur"
344
+ >
345
+ <div
346
+ v-for="l in legendItems"
347
+ :key="l.label"
348
+ class="flex items-center gap-2 text-muted"
349
+ >
350
+ <svg width="22" height="6" class="shrink-0" aria-hidden="true">
351
+ <line
352
+ x1="0"
353
+ y1="3"
354
+ x2="22"
355
+ y2="3"
356
+ :stroke="l.color"
357
+ :stroke-width="l.width"
358
+ :stroke-dasharray="l.dash"
359
+ stroke-linecap="round"
360
+ />
361
+ </svg>
362
+ {{ l.label }}
363
+ </div>
364
+ </div>
365
+ </Panel>
366
+ </VueFlow>
367
+ </template>
368
+
369
+ <!-- Styling lives in src/canvas.css (shipped via @leavepulse/ui/canvas.css) so
370
+ the Vue Flow base CSS + chrome theming reaches consuming apps reliably. -->
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ TooltipContent,
4
+ TooltipPortal,
5
+ TooltipProvider,
6
+ TooltipRoot,
7
+ TooltipTrigger,
8
+ } from "reka-ui"
9
+ import { computed } from "vue"
10
+ import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
11
+
12
+ export type UptimeStatus = "operational" | "degraded" | "down" | "maintenance" | "empty"
13
+
14
+ export interface UptimeSegment {
15
+ /** Health of this slice. `empty` renders a faded placeholder (no data). */
16
+ status: UptimeStatus
17
+ /** Optional rich tooltip body; falls back to a humanised status. */
18
+ label?: string
19
+ }
20
+
21
+ const props = withDefaults(
22
+ defineProps<{
23
+ /** Oldest → newest. Each entry is one slice of the timeline. */
24
+ segments: UptimeSegment[]
25
+ /** Caption shown under the left edge (oldest point). */
26
+ startLabel?: string
27
+ /** Caption shown under the right edge (newest point). */
28
+ endLabel?: string
29
+ /** Show the computed uptime % above the bar. */
30
+ showUptime?: boolean
31
+ /** Override the headline shown next to the uptime %. */
32
+ title?: string
33
+ /**
34
+ * Per-status colours. Any CSS colour (hex, rgb, `var(--token)`). Unset
35
+ * statuses fall back to the theme tokens below. Pass e.g.
36
+ * `{ degraded: '#ffb84d' }` to recolour just one state.
37
+ */
38
+ colors?: Partial<Record<UptimeStatus, string>>
39
+ }>(),
40
+ { showUptime: true },
41
+ )
42
+
43
+ const defaultColor: Record<UptimeStatus, string> = {
44
+ operational: "var(--color-brand)",
45
+ degraded: "var(--color-accent)",
46
+ down: "var(--color-danger)",
47
+ maintenance: "var(--color-action)",
48
+ empty: "var(--color-surface-soft)",
49
+ }
50
+
51
+ const colorFor = (status: UptimeStatus): string =>
52
+ props.colors?.[status] ?? defaultColor[status]
53
+
54
+ const statusLabel: Record<UptimeStatus, string> = {
55
+ operational: "Operational",
56
+ degraded: "Degraded",
57
+ down: "Down",
58
+ maintenance: "Maintenance",
59
+ empty: "No data",
60
+ }
61
+
62
+ // Uptime counts only slices with data; operational + maintenance are "up".
63
+ const uptimePct = computed(() => {
64
+ const tracked = props.segments.filter((s) => s.status !== "empty")
65
+ if (!tracked.length) return null
66
+ const up = tracked.filter((s) => s.status === "operational" || s.status === "maintenance").length
67
+ return (up / tracked.length) * 100
68
+ })
69
+
70
+ const uptimeText = computed(() => {
71
+ const v = uptimePct.value
72
+ if (v == null) return "—"
73
+ // Trim trailing zeros: 100, 99.98, 99.9.
74
+ return `${Number(v.toFixed(2))}%`
75
+ })
76
+ </script>
77
+
78
+ <template>
79
+ <div class="flex flex-col gap-1.5">
80
+ <div v-if="showUptime || title" class="flex items-baseline justify-between gap-3 text-xs">
81
+ <span v-if="title" class="font-medium text-ink">{{ title }}</span>
82
+ <span v-else />
83
+ <span v-if="showUptime" class="tabular-nums text-muted-strong">
84
+ {{ uptimeText }}<span class="text-muted"> uptime</span>
85
+ </span>
86
+ </div>
87
+
88
+ <TooltipProvider :delay-duration="120">
89
+ <div class="flex h-7 items-stretch gap-0.5">
90
+ <TooltipRoot v-for="(seg, i) in segments" :key="i">
91
+ <TooltipTrigger as-child>
92
+ <button
93
+ type="button"
94
+ class="min-w-0 flex-1 rounded-xs outline-none transition-[scale,opacity] duration-150 hover:scale-y-110 focus-visible:ring-2 focus-visible:ring-ring"
95
+ :class="seg.status === 'empty' ? 'opacity-50' : ''"
96
+ :style="{ backgroundColor: colorFor(seg.status) }"
97
+ :aria-label="seg.label ?? statusLabel[seg.status]"
98
+ />
99
+ </TooltipTrigger>
100
+ <TooltipPortal>
101
+ <TooltipContent
102
+ :side-offset="6"
103
+ :class="[PANEL_SURFACE, TOOLTIP_ANIM, 'z-(--z-tooltip) px-2.5 py-1.5 text-xs text-ink']"
104
+ >
105
+ <span class="flex items-center gap-1.5">
106
+ <span
107
+ class="size-1.5 shrink-0 rounded-full"
108
+ :style="{ backgroundColor: colorFor(seg.status) }"
109
+ />
110
+ {{ seg.label ?? statusLabel[seg.status] }}
111
+ </span>
112
+ </TooltipContent>
113
+ </TooltipPortal>
114
+ </TooltipRoot>
115
+ </div>
116
+ </TooltipProvider>
117
+
118
+ <div
119
+ v-if="startLabel || endLabel"
120
+ class="flex items-center justify-between text-xs text-muted"
121
+ >
122
+ <span>{{ startLabel }}</span>
123
+ <span>{{ endLabel }}</span>
124
+ </div>
125
+ </div>
126
+ </template>
@@ -0,0 +1,137 @@
1
+ /*
2
+ * Tiny, dependency-free syntax highlighter for LpCodeBlock. NOT a full parser —
3
+ * a single regex tokenizer that covers strings, comments, annotations, numbers,
4
+ * keywords, booleans and punctuation well enough for snippets (config, API
5
+ * examples, CLI, JVM build files). Languages are just extra keyword sets over
6
+ * the same tokenizer; JS/TS, Python, Rust, and the JVM trio (Kotlin/Java/Groovy)
7
+ * share most of it. For pixel-perfect IDE highlighting a consumer can pass
8
+ * pre-highlighted HTML instead; this keeps the kit zero-dependency and
9
+ * synchronous (shiki is heavy and async). Tokens map to kit colour classes so
10
+ * highlighting follows the theme.
11
+ */
12
+
13
+ export type CodeLang =
14
+ | "ts"
15
+ | "js"
16
+ | "json"
17
+ | "bash"
18
+ | "html"
19
+ | "python"
20
+ | "rust"
21
+ | "yaml"
22
+ | "toml"
23
+ | "kotlin"
24
+ | "java"
25
+ | "groovy"
26
+ | "plain"
27
+
28
+ export interface Token {
29
+ text: string
30
+ /** CSS class for this token, or "" for plain text. */
31
+ cls: string
32
+ }
33
+
34
+ // Token colours (kit semantic tokens). Kept muted so code reads calmly.
35
+ const C = {
36
+ comment: "text-muted italic",
37
+ string: "text-action",
38
+ number: "text-accent",
39
+ keyword: "text-brand",
40
+ boolean: "text-accent",
41
+ property: "text-muted-strong",
42
+ func: "text-brand",
43
+ punct: "text-muted",
44
+ }
45
+
46
+ const KEYWORDS = new Set([
47
+ // JS/TS
48
+ "const", "let", "var", "function", "return", "if", "else", "for", "while",
49
+ "import", "export", "from", "default", "class", "extends", "new", "await",
50
+ "async", "type", "interface", "enum", "public", "private", "readonly",
51
+ "as", "in", "of", "try", "catch", "finally", "throw", "switch", "case",
52
+ "break", "continue", "typeof", "instanceof", "void", "yield",
53
+ // shell
54
+ "echo", "cd", "sudo", "npm", "npx", "git", "curl",
55
+ // python
56
+ "def", "elif", "lambda", "pass", "with", "global", "nonlocal", "del",
57
+ "raise", "except", "and", "or", "not", "is", "assert", "yield", "match",
58
+ // rust
59
+ "fn", "mut", "impl", "trait", "struct", "mod", "pub", "use", "crate",
60
+ "self", "Self", "move", "ref", "where", "dyn", "unsafe", "loop", "macro",
61
+ "use", "super", "static",
62
+ // kotlin
63
+ "fun", "val", "object", "companion", "data", "sealed", "override", "open",
64
+ "internal", "lateinit", "init", "when", "is", "vararg", "inline", "reified",
65
+ "suspend", "operator", "infix", "tailrec", "external", "annotation",
66
+ "constructor", "by", "get", "set", "field", "it", "run", "let", "apply",
67
+ "also", "with",
68
+ // java (extra, beyond shared C-family already above)
69
+ "package", "implements", "abstract", "final", "synchronized", "volatile",
70
+ "transient", "native", "strictfp", "protected", "throws", "boolean", "int",
71
+ "long", "short", "byte", "char", "float", "double", "String", "record",
72
+ // groovy adds few over java: def, trait already covered; add its extras
73
+ "def", "each", "closure",
74
+ ])
75
+
76
+ const BOOLEANS = new Set([
77
+ "true", "false", "null", "undefined", // js
78
+ "None", "True", "False", // python
79
+ "Some", "Ok", "Err", // rust common variants read as constants
80
+ ])
81
+
82
+ // One master regex, alternation ordered so longer/greedier rules win first.
83
+ // Order: comments → strings → numbers → identifiers → punctuation.
84
+ const MASTER = new RegExp(
85
+ [
86
+ "(\\/\\/[^\\n]*|#[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)", // 1 comment
87
+ "(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|`(?:\\\\.|[^`\\\\])*`)", // 2 string
88
+ "(@[A-Za-z_$][\\w$]*)", // 3 annotation (@Override, @ConfigSerializable — JVM)
89
+ "(\\b\\d[\\d_]*(?:\\.\\d+)?(?:e[+-]?\\d+)?[fFlLdD]?\\b)", // 4 number (JVM suffixes)
90
+ "([A-Za-z_$][\\w$]*)", // 5 identifier (keyword/bool/func/prop/plain)
91
+ "([{}()\\[\\];:,.<>=+\\-*/%!&|?]+)", // 6 punctuation
92
+ ].join("|"),
93
+ "g",
94
+ )
95
+
96
+ function classifyIdent(name: string, before: string, after: string): string {
97
+ if (KEYWORDS.has(name)) return C.keyword
98
+ if (BOOLEANS.has(name)) return C.boolean
99
+ if (after.startsWith("(")) return C.func // call/def
100
+ if (before.endsWith(".")) return C.property // member access
101
+ return ""
102
+ }
103
+
104
+ /** Tokenize one line into coloured spans. JSON keys get the property colour. */
105
+ export function tokenizeLine(line: string, lang: CodeLang): Token[] {
106
+ const out: Token[] = []
107
+ let last = 0
108
+ let m: RegExpExecArray | null
109
+ MASTER.lastIndex = 0
110
+ while ((m = MASTER.exec(line)) !== null) {
111
+ if (m.index > last) out.push({ text: line.slice(last, m.index), cls: "" })
112
+ const [full, comment, str, annotation, num, ident, punct] = m
113
+ if (comment != null) out.push({ text: full, cls: C.comment })
114
+ else if (annotation != null) out.push({ text: full, cls: C.func })
115
+ else if (str != null) {
116
+ // JSON: a quoted token immediately followed by ":" is a key.
117
+ const isKey = lang === "json" && line.slice(m.index + full.length).trimStart().startsWith(":")
118
+ out.push({ text: full, cls: isKey ? C.property : C.string })
119
+ } else if (num != null) out.push({ text: full, cls: C.number })
120
+ else if (ident != null) {
121
+ const after = line.slice(m.index + full.length).trimStart()
122
+ const before = line.slice(0, m.index).trimEnd()
123
+ // YAML (`key:`) / TOML (`key =`) leading key gets the property colour, like
124
+ // JSON keys — only when it's the first token of the line (the key position).
125
+ const isConfigKey =
126
+ (lang === "yaml" && before === "" && after.startsWith(":")) ||
127
+ (lang === "toml" && before === "" && after.startsWith("="))
128
+ out.push({
129
+ text: full,
130
+ cls: isConfigKey ? C.property : classifyIdent(ident, before, after),
131
+ })
132
+ } else if (punct != null) out.push({ text: full, cls: C.punct })
133
+ last = m.index + full.length
134
+ }
135
+ if (last < line.length) out.push({ text: line.slice(last), cls: "" })
136
+ return out
137
+ }