@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
package/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ export { default as LayoutCanvas } from "./components/LayoutCanvas.vue"
2
+ export { default as LpAlert } from "./components/LpAlert.vue"
3
+ export { default as LpAppShell } from "./components/LpAppShell.vue"
4
+ export { default as LpAutocomplete } from "./components/LpAutocomplete.vue"
5
+ export type { AutocompleteOption } from "./components/LpAutocomplete.vue"
6
+ export { default as LpAvatar } from "./components/LpAvatar.vue"
7
+ export { default as LpBadge } from "./components/LpBadge.vue"
8
+ export { default as LpBreadcrumbs } from "./components/LpBreadcrumbs.vue"
9
+ export type { Crumb } from "./components/LpBreadcrumbs.vue"
10
+ export { default as LpButton } from "./components/LpButton.vue"
11
+ export { default as LpCalendar } from "./components/LpCalendar.vue"
12
+ export { default as LpCard } from "./components/LpCard.vue"
13
+ export { default as LpCheckbox } from "./components/LpCheckbox.vue"
14
+ export { default as LpCodeBlock } from "./components/LpCodeBlock.vue"
15
+ export type { CodeLang } from "./components/codeHighlight"
16
+ export { default as LpCommandPalette } from "./components/LpCommandPalette.vue"
17
+ export type { Command } from "./components/LpCommandPalette.vue"
18
+ export { default as LpConfirmDialog } from "./components/LpConfirmDialog.vue"
19
+ export { default as LpContextMenu } from "./components/LpContextMenu.vue"
20
+ export type { ContextMenuItemDef } from "./components/LpContextMenu.vue"
21
+ export { default as LpDatePicker } from "./components/LpDatePicker.vue"
22
+ export { default as LpDisclosure } from "./components/LpDisclosure.vue"
23
+ export { default as LpDivider } from "./components/LpDivider.vue"
24
+ export { default as LpDrawer } from "./components/LpDrawer.vue"
25
+ export { default as LpDropdownMenu } from "./components/LpDropdownMenu.vue"
26
+ export type { MenuItem } from "./components/LpDropdownMenu.vue"
27
+ export { default as LpEmptyState } from "./components/LpEmptyState.vue"
28
+ export { default as LpFormField } from "./components/LpFormField.vue"
29
+ export { default as LpIcon } from "./components/LpIcon.vue"
30
+ export { default as LpInfraNode } from "./components/LpInfraNode.vue"
31
+ export type { InfraNodeData } from "./components/LpInfraNode.vue"
32
+ export { default as LpInput } from "./components/LpInput.vue"
33
+ export { default as LpLink } from "./components/LpLink.vue"
34
+ export { default as LpLogViewer } from "./components/LpLogViewer.vue"
35
+ export type { LogLevel, LogLine } from "./components/LpLogViewer.vue"
36
+ export { default as LpModal } from "./components/LpModal.vue"
37
+ export { default as LpNotificationBell } from "./components/LpNotificationBell.vue"
38
+ export type { NotificationItem } from "./components/LpNotificationBell.vue"
39
+ export { default as LpNumberField } from "./components/LpNumberField.vue"
40
+ export { default as LpOtpInput } from "./components/LpOtpInput.vue"
41
+ export { default as LpPagination } from "./components/LpPagination.vue"
42
+ export { default as LpPasswordInput } from "./components/LpPasswordInput.vue"
43
+ export { default as LpPhoneInput } from "./components/LpPhoneInput.vue"
44
+ export type { PhoneDetail } from "./components/LpPhoneInput.vue"
45
+ export { flagEmoji, loadCountries, matchCountryByValue } from "./components/countries"
46
+ export type { Country } from "./components/countries"
47
+ export { default as LpPopover } from "./components/LpPopover.vue"
48
+ export { default as LpProgress } from "./components/LpProgress.vue"
49
+ export { default as LpRadio } from "./components/LpRadio.vue"
50
+ export { default as LpRadioGroup } from "./components/LpRadioGroup.vue"
51
+ export type { RadioOption } from "./components/LpRadioGroup.vue"
52
+ export { default as LpScrollArea } from "./components/LpScrollArea.vue"
53
+ export { default as LpSegmented } from "./components/LpSegmented.vue"
54
+ export type { SegmentedOption } from "./components/LpSegmented.vue"
55
+ export { default as LpSelect } from "./components/LpSelect.vue"
56
+ export type { SelectOption, SelectValue } from "./components/LpSelect.vue"
57
+ export { default as LpServiceNode } from "./components/LpServiceNode.vue"
58
+ export type { ServiceNodeData } from "./components/LpServiceNode.vue"
59
+ export { default as LpSidebar } from "./components/LpSidebar.vue"
60
+ export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
61
+ export { default as LpSkeleton } from "./components/LpSkeleton.vue"
62
+ export { default as LpSlider } from "./components/LpSlider.vue"
63
+ export { default as LpStat } from "./components/LpStat.vue"
64
+ export { default as LpStepper } from "./components/LpStepper.vue"
65
+ export type { Step } from "./components/LpStepper.vue"
66
+ export { default as LpSwitch } from "./components/LpSwitch.vue"
67
+ export { default as LpTable } from "./components/LpTable.vue"
68
+ export type { SortState, TableColumn } from "./components/LpTable.vue"
69
+ export { default as LpTableOfContents } from "./components/LpTableOfContents.vue"
70
+ export type { TocLink } from "./components/LpTableOfContents.vue"
71
+ export { default as LpTabs } from "./components/LpTabs.vue"
72
+ export type { TabItem } from "./components/LpTabs.vue"
73
+ export { default as LpTextarea } from "./components/LpTextarea.vue"
74
+ export { default as LpThemeSwitcher } from "./components/LpThemeSwitcher.vue"
75
+ export { default as LpToaster } from "./components/LpToaster.vue"
76
+ export { default as LpTooltip } from "./components/LpTooltip.vue"
77
+ export { default as LpTopologyCanvas } from "./components/LpTopologyCanvas.vue"
78
+ export type {
79
+ CanvasViewport,
80
+ EdgeCategory,
81
+ EdgeObserved,
82
+ TopologyEdge,
83
+ TopologyLane,
84
+ TopologyNode,
85
+ } from "./components/LpTopologyCanvas.vue"
86
+ export { default as LpUptimeBar } from "./components/LpUptimeBar.vue"
87
+ export type { UptimeSegment, UptimeStatus } from "./components/LpUptimeBar.vue"
88
+
89
+ export {
90
+ addLeaf,
91
+ addTab,
92
+ countBlocks,
93
+ countLeaves,
94
+ deserializeLayout,
95
+ makeLayout,
96
+ moveLeaf,
97
+ removeBlock,
98
+ removeLeaf,
99
+ reorderTab,
100
+ resizeAt,
101
+ setActiveTab,
102
+ serializeLayout,
103
+ } from "./layout/tree"
104
+ export type { Dir, LayoutNode, Leaf, Side, Split } from "./layout/tree"
105
+ export { blockTitle, defineBlocks } from "./layout/registry"
106
+ export type { BlockDef, BlockRegistry } from "./layout/registry"
107
+ export { useLayout } from "./layout/useLayout"
108
+ export type { UseLayout, UseLayoutOptions } from "./layout/useLayout"
109
+
110
+ // theme engine — themes are JSON (TokenSet), applied at runtime
111
+ export {
112
+ applyTheme,
113
+ applyThemeWithTransition,
114
+ bootstrapTheme,
115
+ parseTheme,
116
+ serializeTheme,
117
+ themeToCssRule,
118
+ themeToCssVars,
119
+ useTheme,
120
+ } from "./theme/useTheme"
121
+ export type { RevealOrigin } from "./theme/useTheme"
122
+ export { DEFAULT_SURFACE } from "./theme/tokens"
123
+ export { defineTheme } from "./theme/define"
124
+ export type { ThemeOverrides } from "./theme/define"
125
+ export type {
126
+ ColorTokens,
127
+ DensityTokens,
128
+ FontTokens,
129
+ ShapeTokens,
130
+ SurfaceTokens,
131
+ TokenSet,
132
+ } from "./theme/tokens"
133
+ export { amber, dark, leavepulse, light, lime, nord, presets, rose, violet } from "./theme/presets"
134
+ export type { PresetName } from "./theme/presets"
135
+ export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from "./theme/config"
136
+ export type { UiConfig } from "./theme/config"
137
+
138
+ export { useInputFilter } from "./composables/useInputFilter"
139
+ export type { UseInputFilterOptions } from "./composables/useInputFilter"
140
+ export { useToast } from "./composables/useToast"
141
+ export type {
142
+ ToastAction,
143
+ ToastItem,
144
+ ToastOptions,
145
+ ToastVariant,
146
+ } from "./composables/useToast"
@@ -0,0 +1,44 @@
1
+ import type { Component } from "vue"
2
+
3
+ /*
4
+ * Block registry — the consumer's catalogue of what a layout cell can hold.
5
+ *
6
+ * The layout tree stores only block ids (plain strings, serializable). The
7
+ * registry maps each id to how it should render and chrome: the component, a
8
+ * human title for the tab/header, an optional icon, and whether the user may
9
+ * close it. LayoutCanvas reads this so it can draw tab bars + headers itself —
10
+ * the consumer supplies the catalogue, not per-cell wiring.
11
+ */
12
+
13
+ export interface BlockDef {
14
+ /**
15
+ * Content component for this block. Optional: when the consumer renders block
16
+ * content via the canvas's #block slot (keyed by id), the registry is used
17
+ * only for tab chrome (title/icon) and no component is needed here.
18
+ */
19
+ component?: Component
20
+ /** Human label shown on the tab / cell header. Defaults to the id. */
21
+ title?: string
22
+ /** Optional iconify name (e.g. "lucide:activity") for the tab. */
23
+ icon?: string
24
+ /** Whether the user may close this block. Defaults to true. */
25
+ closable?: boolean
26
+ /** Props passed to the component. */
27
+ props?: Record<string, unknown>
28
+ }
29
+
30
+ export type BlockRegistry = Record<string, BlockDef>
31
+
32
+ /**
33
+ * Identity helper that pins the key type so callers get autocomplete on block
34
+ * ids elsewhere (e.g. when seeding a layout). Purely for inference — it returns
35
+ * the object unchanged.
36
+ */
37
+ export function defineBlocks<const T extends BlockRegistry>(blocks: T): T {
38
+ return blocks
39
+ }
40
+
41
+ /** Resolve a block's display title, falling back to its id. */
42
+ export function blockTitle(registry: BlockRegistry, id: string): string {
43
+ return registry[id]?.title ?? id
44
+ }
@@ -0,0 +1,288 @@
1
+ import { reactive } from "vue"
2
+
3
+ /*
4
+ * Layout tree model for LayoutCanvas.
5
+ *
6
+ * A layout is a tree of splits (row/col containers) and leaves (blocks).
7
+ * Sizes are flex fractions among siblings, so the layout is resolution-
8
+ * independent and serializes cleanly to JSON (persist to disk / config).
9
+ */
10
+
11
+ export type Dir = "row" | "col"
12
+ export type Side = "left" | "right" | "top" | "bottom" | "center"
13
+
14
+ export interface Leaf {
15
+ kind: "leaf"
16
+ id: string
17
+ /**
18
+ * Block ids stacked as tabs in this cell. A plain cell is a single-element
19
+ * stack; dropping onto a cell's center adds another tab. The consumer maps
20
+ * each id to content via the canvas slot.
21
+ */
22
+ blocks: string[]
23
+ /** Index into `blocks` of the visible tab. */
24
+ active: number
25
+ /** Flex fraction among siblings. */
26
+ size: number
27
+ }
28
+ export interface Split {
29
+ kind: "split"
30
+ id: string
31
+ dir: Dir
32
+ size: number
33
+ children: LayoutNode[]
34
+ }
35
+ export type LayoutNode = Leaf | Split
36
+
37
+ let uid = 0
38
+ const nid = () => `lp-n${uid++}`
39
+
40
+ function leaf(block: string): Leaf {
41
+ return { kind: "leaf", id: nid(), blocks: [block], active: 0, size: 1 }
42
+ }
43
+
44
+ /** Build a single-row layout from a flat list of block ids (one cell each). */
45
+ export function makeLayout(blocks: string[]): Split {
46
+ return reactive<Split>({
47
+ kind: "split",
48
+ id: nid(),
49
+ dir: "row",
50
+ size: 1,
51
+ children: blocks.map<Leaf>(leaf),
52
+ })
53
+ }
54
+
55
+ export function findParent(node: Split, childId: string): Split | null {
56
+ for (const c of node.children) {
57
+ if (c.id === childId) return node
58
+ if (c.kind === "split") {
59
+ const r = findParent(c, childId)
60
+ if (r) return r
61
+ }
62
+ }
63
+ return null
64
+ }
65
+
66
+ export function findLeaf(node: LayoutNode, id: string): Leaf | null {
67
+ if (node.kind === "leaf") return node.id === id ? node : null
68
+ for (const c of node.children) {
69
+ const r = findLeaf(c, id)
70
+ if (r) return r
71
+ }
72
+ return null
73
+ }
74
+
75
+ export function countLeaves(node: LayoutNode): number {
76
+ if (node.kind === "leaf") return 1
77
+ return node.children.reduce((s, c) => s + countLeaves(c), 0)
78
+ }
79
+
80
+ /** Total blocks across all cells (a tabbed cell counts each of its tabs). */
81
+ export function countBlocks(node: LayoutNode): number {
82
+ if (node.kind === "leaf") return node.blocks.length
83
+ return node.children.reduce((s, c) => s + countBlocks(c), 0)
84
+ }
85
+
86
+ function detach(root: Split, id: string): void {
87
+ const parent = findParent(root, id)
88
+ if (!parent) return
89
+ const idx = parent.children.findIndex((c) => c.id === id)
90
+ parent.children.splice(idx, 1)
91
+ collapse(root, parent)
92
+ }
93
+
94
+ /** A container left with a single child collapses into that child. */
95
+ function collapse(root: Split, parent: Split): void {
96
+ if (parent === root || parent.children.length !== 1) return
97
+ const grand = findParent(root, parent.id)
98
+ if (!grand) return
99
+ const only = parent.children[0]
100
+ only.size = parent.size
101
+ const pIdx = grand.children.findIndex((c) => c.id === parent.id)
102
+ grand.children.splice(pIdx, 1, only)
103
+ }
104
+
105
+ /** Remove an entire cell (with all its tabs). */
106
+ export function removeLeaf(root: Split, id: string): void {
107
+ if (countLeaves(root) <= 1) return // keep at least one cell
108
+ detach(root, id)
109
+ }
110
+
111
+ /**
112
+ * Remove one tab from a cell. Removing the last tab removes the cell itself
113
+ * (unless it's the only block left in the whole layout, which is kept).
114
+ */
115
+ export function removeBlock(root: Split, leafId: string, index: number): void {
116
+ const target = findLeaf(root, leafId)
117
+ if (!target || index < 0 || index >= target.blocks.length) return
118
+ if (target.blocks.length > 1) {
119
+ target.blocks.splice(index, 1)
120
+ if (target.active >= target.blocks.length) target.active = target.blocks.length - 1
121
+ return
122
+ }
123
+ // Last tab in this cell → drop the cell, but never empty the whole layout.
124
+ if (countBlocks(root) <= 1) return
125
+ detach(root, leafId)
126
+ }
127
+
128
+ export function addLeaf(root: Split, block: string, side: Side = "right"): void {
129
+ const targetId = root.children[root.children.length - 1]?.id
130
+ if (!targetId) {
131
+ root.children.push(leaf(block))
132
+ return
133
+ }
134
+ if (side === "center") {
135
+ addTab(root, targetId, block)
136
+ return
137
+ }
138
+ insertBeside(root, leaf(block), targetId, side)
139
+ }
140
+
141
+ /** Append a block as a new tab in the target cell and focus it. */
142
+ export function addTab(root: Split, leafId: string, block: string): void {
143
+ const target = findLeaf(root, leafId)
144
+ if (!target) return
145
+ target.blocks.push(block)
146
+ target.active = target.blocks.length - 1
147
+ }
148
+
149
+ /** Switch the visible tab of a cell. */
150
+ export function setActiveTab(root: Split, leafId: string, index: number): void {
151
+ const target = findLeaf(root, leafId)
152
+ if (!target || index < 0 || index >= target.blocks.length) return
153
+ target.active = index
154
+ }
155
+
156
+ /**
157
+ * Reorder a tab within its cell, moving the block at `from` to `to`. The same
158
+ * block stays visible afterwards (active follows the moved block by identity,
159
+ * not by index). Always available — not gated by edit mode.
160
+ */
161
+ export function reorderTab(root: Split, leafId: string, from: number, to: number): void {
162
+ const target = findLeaf(root, leafId)
163
+ if (!target) return
164
+ const n = target.blocks.length
165
+ if (from < 0 || from >= n || to < 0 || to >= n || from === to) return
166
+ const activeBlock = target.blocks[target.active]
167
+ const [moved] = target.blocks.splice(from, 1)
168
+ target.blocks.splice(to, 0, moved)
169
+ target.active = target.blocks.indexOf(activeBlock)
170
+ }
171
+
172
+ function insertBeside(
173
+ root: Split,
174
+ moving: Leaf,
175
+ targetId: string,
176
+ side: Side,
177
+ ): void {
178
+ const parent = findParent(root, targetId)
179
+ if (!parent) return
180
+ const idx = parent.children.findIndex((c) => c.id === targetId)
181
+ const target = parent.children[idx]
182
+
183
+ const wantDir: Dir = side === "left" || side === "right" ? "row" : "col"
184
+ const before = side === "left" || side === "top"
185
+
186
+ if (parent.dir === wantDir) {
187
+ moving.size = target.size / 2
188
+ target.size = target.size / 2
189
+ parent.children.splice(before ? idx : idx + 1, 0, moving)
190
+ } else {
191
+ const wrap: Split = {
192
+ kind: "split",
193
+ id: nid(),
194
+ dir: wantDir,
195
+ size: target.size,
196
+ children: before
197
+ ? [{ ...moving, size: 1 }, { ...target, size: 1 }]
198
+ : [{ ...target, size: 1 }, { ...moving, size: 1 }],
199
+ }
200
+ parent.children.splice(idx, 1, wrap)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Move an existing cell to a target. An edge side splits beside the target;
206
+ * `center` merges the moving cell's tabs into the target cell (and focuses
207
+ * the first moved tab).
208
+ */
209
+ export function moveLeaf(
210
+ root: Split,
211
+ movingId: string,
212
+ targetId: string,
213
+ side: Side,
214
+ ): void {
215
+ if (movingId === targetId) return
216
+ const moving = findLeaf(root, movingId)
217
+ if (!moving) return
218
+
219
+ if (side === "center") {
220
+ const target = findLeaf(root, targetId)
221
+ if (!target) return
222
+ const merged = moving.blocks
223
+ detach(root, movingId)
224
+ target.active = target.blocks.length
225
+ target.blocks.push(...merged)
226
+ return
227
+ }
228
+
229
+ detach(root, movingId)
230
+ moving.size = 1
231
+ insertBeside(root, moving, targetId, side)
232
+ }
233
+
234
+ /** Splitter drag: redistribute size between two adjacent siblings. */
235
+ export function resizeAt(parent: Split, index: number, deltaFraction: number): void {
236
+ const a = parent.children[index]
237
+ const b = parent.children[index + 1]
238
+ if (!a || !b) return
239
+ const total = a.size + b.size
240
+ const min = total * 0.12
241
+ const na = Math.max(min, Math.min(total - min, a.size + deltaFraction * total))
242
+ a.size = na
243
+ b.size = total - na
244
+ }
245
+
246
+ // ── serialization ────────────────────────────────────────────
247
+ interface SerializedLeaf {
248
+ kind: "leaf"
249
+ blocks: string[]
250
+ active: number
251
+ size: number
252
+ /** Legacy single-block form (pre-tabs); hydrated into `blocks`. */
253
+ block?: string
254
+ }
255
+ interface SerializedSplit {
256
+ kind: "split"
257
+ dir: Dir
258
+ size: number
259
+ children: SerializedNode[]
260
+ }
261
+ type SerializedNode = SerializedLeaf | SerializedSplit
262
+
263
+ export function serializeLayout(root: Split): SerializedNode {
264
+ const strip = (n: LayoutNode): SerializedNode =>
265
+ n.kind === "leaf"
266
+ ? { kind: "leaf", blocks: [...n.blocks], active: n.active, size: n.size }
267
+ : { kind: "split", dir: n.dir, size: n.size, children: n.children.map(strip) }
268
+ return strip(root) as SerializedSplit
269
+ }
270
+
271
+ export function deserializeLayout(data: SerializedNode): Split {
272
+ const hydrate = (n: SerializedNode): LayoutNode => {
273
+ if (n.kind === "leaf") {
274
+ // Accept both the tabbed form and the legacy single-`block` form.
275
+ const blocks = n.blocks ?? (n.block != null ? [n.block] : [])
276
+ const active = Math.min(Math.max(n.active ?? 0, 0), Math.max(blocks.length - 1, 0))
277
+ return { kind: "leaf", id: nid(), blocks, active, size: n.size ?? 1 }
278
+ }
279
+ return {
280
+ kind: "split",
281
+ id: nid(),
282
+ dir: n.dir,
283
+ size: n.size ?? 1,
284
+ children: n.children.map(hydrate),
285
+ }
286
+ }
287
+ return reactive(hydrate(data) as Split)
288
+ }
@@ -0,0 +1,134 @@
1
+ import { computed, ref, shallowRef, type Ref } from "vue"
2
+ import {
3
+ addLeaf,
4
+ addTab,
5
+ countBlocks,
6
+ deserializeLayout,
7
+ makeLayout,
8
+ moveLeaf,
9
+ removeBlock,
10
+ removeLeaf,
11
+ reorderTab,
12
+ resizeAt,
13
+ setActiveTab,
14
+ serializeLayout,
15
+ type Side,
16
+ type Split,
17
+ } from "./tree"
18
+
19
+ /*
20
+ * useLayout — stateful controller around the layout tree.
21
+ *
22
+ * tree.ts is a pile of pure-ish mutators; this composable is the ergonomic
23
+ * surface a consumer actually wires to UI: it owns the reactive tree, snapshots
24
+ * every structural edit for undo/redo, and exposes save/load/reset against a
25
+ * serialized form. The canvas calls the same mutators for drag/drop/resize, so
26
+ * those go through `record()` too and land in history.
27
+ *
28
+ * History holds serialized snapshots (plain JSON), not live trees, so an undo
29
+ * can't smuggle stale reactive nodes back in.
30
+ */
31
+
32
+ export interface UseLayoutOptions {
33
+ /** Initial block ids, left-to-right, when no saved layout is loaded. */
34
+ initial: string[]
35
+ /** Max undo depth. */
36
+ historyLimit?: number
37
+ }
38
+
39
+ export interface UseLayout {
40
+ /** The live reactive tree to hand to <LayoutCanvas v-model>. */
41
+ layout: Ref<Split>
42
+ /** Block count across all tabs, handy for "N blocks" labels and guards. */
43
+ count: Ref<number>
44
+ canUndo: Ref<boolean>
45
+ canRedo: Ref<boolean>
46
+ /** Add a block: a new cell on an edge, or a tab on the last cell (center). */
47
+ add: (block: string, side?: Side) => void
48
+ /** Add a block as a tab in a specific cell. */
49
+ addTab: (leafId: string, block: string) => void
50
+ /** Switch the visible tab of a cell. */
51
+ setActive: (leafId: string, index: number) => void
52
+ /** Reorder a tab within its cell (always available, not edit-gated). */
53
+ reorderTab: (leafId: string, from: number, to: number) => void
54
+ /** Remove one tab (drops the cell when it was the last tab). */
55
+ removeBlock: (leafId: string, index: number) => void
56
+ /** Remove an entire cell with all its tabs. */
57
+ remove: (leafId: string) => void
58
+ move: (movingId: string, targetId: string, side: Side) => void
59
+ resize: (parent: Split, index: number, deltaFraction: number) => void
60
+ undo: () => void
61
+ redo: () => void
62
+ /** Replace the whole layout from a flat block list. */
63
+ reset: (blocks?: string[]) => void
64
+ /** Serialize to JSON-safe data for persistence. */
65
+ serialize: () => unknown
66
+ /** Replace the layout from previously serialized data. */
67
+ hydrate: (data: unknown) => void
68
+ }
69
+
70
+ export function useLayout(options: UseLayoutOptions): UseLayout {
71
+ const limit = options.historyLimit ?? 50
72
+ const layout = shallowRef<Split>(makeLayout(options.initial))
73
+ const count = computed(() => countBlocks(layout.value))
74
+
75
+ const past = ref<string[]>([])
76
+ const future = ref<string[]>([])
77
+ const canUndo = computed(() => past.value.length > 0)
78
+ const canRedo = computed(() => future.value.length > 0)
79
+
80
+ const snapshot = () => JSON.stringify(serializeLayout(layout.value))
81
+
82
+ // Capture the pre-edit state, run the structural change, drop any redo branch.
83
+ function record(mutate: () => void) {
84
+ const before = snapshot()
85
+ mutate()
86
+ past.value.push(before)
87
+ if (past.value.length > limit) past.value.shift()
88
+ future.value = []
89
+ }
90
+
91
+ function restore(serialized: string) {
92
+ layout.value = deserializeLayout(JSON.parse(serialized))
93
+ }
94
+
95
+ return {
96
+ layout,
97
+ count,
98
+ canUndo,
99
+ canRedo,
100
+ add: (block, side) => record(() => addLeaf(layout.value, block, side)),
101
+ addTab: (leafId, block) => record(() => addTab(layout.value, leafId, block)),
102
+ setActive: (leafId, index) => setActiveTab(layout.value, leafId, index),
103
+ reorderTab: (leafId, from, to) => record(() => reorderTab(layout.value, leafId, from, to)),
104
+ removeBlock: (leafId, index) => record(() => removeBlock(layout.value, leafId, index)),
105
+ remove: (leafId) => record(() => removeLeaf(layout.value, leafId)),
106
+ move: (movingId, targetId, side) =>
107
+ record(() => moveLeaf(layout.value, movingId, targetId, side)),
108
+ // Resize fires continuously during a divider drag; recording every frame
109
+ // would flood history, so it mutates without a snapshot. (A consumer that
110
+ // wants resize in history can wrap a single record() around the gesture.)
111
+ resize: (parent, index, delta) => resizeAt(parent, index, delta),
112
+ undo: () => {
113
+ const prev = past.value.pop()
114
+ if (prev === undefined) return
115
+ future.value.push(snapshot())
116
+ restore(prev)
117
+ },
118
+ redo: () => {
119
+ const next = future.value.pop()
120
+ if (next === undefined) return
121
+ past.value.push(snapshot())
122
+ restore(next)
123
+ },
124
+ reset: (blocks) =>
125
+ record(() => {
126
+ layout.value = makeLayout(blocks ?? options.initial)
127
+ }),
128
+ serialize: () => serializeLayout(layout.value),
129
+ hydrate: (data) =>
130
+ record(() => {
131
+ layout.value = deserializeLayout(data as Parameters<typeof deserializeLayout>[0])
132
+ }),
133
+ }
134
+ }
package/src/nuxt.ts ADDED
@@ -0,0 +1,22 @@
1
+ /*
2
+ * Nuxt module — auto-registers every kit component globally so consuming apps
3
+ * use <LpButton> / <LpThemeSwitcher> / … with no per-app component list to
4
+ * maintain. Add it to a Nuxt app's `modules`:
5
+ *
6
+ * export default defineNuxtConfig({ modules: ["@leavepulse/ui/nuxt"] })
7
+ *
8
+ * The list comes from the generated component-names file (kept in sync with the
9
+ * actual components at build), so a NEW component is available everywhere with
10
+ * no consumer change.
11
+ */
12
+ import { addComponent, defineNuxtModule } from "@nuxt/kit"
13
+ import { COMPONENT_NAMES } from "./component-names"
14
+
15
+ export default defineNuxtModule({
16
+ meta: { name: "@leavepulse/ui", configKey: "leavepulseUi" },
17
+ setup() {
18
+ for (const name of COMPONENT_NAMES) {
19
+ addComponent({ name, export: name, filePath: "@leavepulse/ui" })
20
+ }
21
+ },
22
+ })
@@ -0,0 +1,48 @@
1
+ /*
2
+ * UiConfig — the canonical, complete UI-state snapshot for a LeavePulse app.
3
+ *
4
+ * Deliberately broader than the launcher's old theme config: it carries the
5
+ * THEME *and* the LAYOUT (block placement) in one serializable object, plus a
6
+ * schema version for forward migration. This is the source of truth apps load
7
+ * from / save to disk.
8
+ */
9
+ import type { Split } from "../layout/tree"
10
+ import { serializeLayout, deserializeLayout } from "../layout/tree"
11
+ import type { TokenSet } from "./tokens"
12
+ import { parseTheme, serializeTheme } from "./useTheme"
13
+
14
+ export const UI_CONFIG_VERSION = 1
15
+
16
+ export interface UiConfig {
17
+ version: number
18
+ theme: TokenSet
19
+ /** Optional — apps without a composable layout omit it. */
20
+ layout?: Split
21
+ }
22
+
23
+ export interface SerializedUiConfig {
24
+ version: number
25
+ theme: TokenSet
26
+ layout?: unknown
27
+ }
28
+
29
+ export function serializeConfig(config: UiConfig): string {
30
+ const out: SerializedUiConfig = {
31
+ version: UI_CONFIG_VERSION,
32
+ theme: JSON.parse(serializeTheme(config.theme)),
33
+ layout: config.layout ? serializeLayout(config.layout) : undefined,
34
+ }
35
+ return JSON.stringify(out)
36
+ }
37
+
38
+ export function parseConfig(json: string): UiConfig {
39
+ const raw = JSON.parse(json) as SerializedUiConfig
40
+ if (!raw || typeof raw !== "object") throw new Error("Invalid UiConfig")
41
+ return {
42
+ version: raw.version ?? UI_CONFIG_VERSION,
43
+ theme: parseTheme(JSON.stringify(raw.theme)),
44
+ layout: raw.layout
45
+ ? deserializeLayout(raw.layout as Parameters<typeof deserializeLayout>[0])
46
+ : undefined,
47
+ }
48
+ }