@wippy-fe/vue-host 0.0.25

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.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @wippy-fe/vue-host
2
+
3
+ Vue 3 reactive bindings for the Wippy `host.*` proxy API. Tree-shakable hooks that wrap `host.layout.*` in Vue refs + event subscriptions, with auto-cleanup on unmount.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @wippy-fe/vue-host
9
+ ```
10
+
11
+ Peer deps: `vue >= 3.5`, `@wippy-fe/proxy` (also a peer so your bundler deduplicates with the rest of your Wippy integration).
12
+
13
+ ## `useWippyLayout()`
14
+
15
+ The main composable. Returns reactive layout state + bound mutation methods.
16
+
17
+ ```vue
18
+ <script setup lang="ts">
19
+ import { useWippyLayout } from '@wippy-fe/vue-host'
20
+
21
+ const layout = useWippyLayout()
22
+ // layout.snapshot : Ref<LayoutSnapshot | null>
23
+ // layout.activeBreakpoint : ComputedRef<string>
24
+ // layout.panels : ComputedRef<Record<string, unknown>>
25
+ // layout.isManaged : ComputedRef<boolean>
26
+ </script>
27
+
28
+ <template>
29
+ <div>
30
+ <p v-if="!layout.isManaged">Not inside a managed-layout host.</p>
31
+ <p v-else>Current breakpoint: {{ layout.activeBreakpoint }}</p>
32
+ <button @click="layout.collapsePanel('nav')">Collapse nav</button>
33
+ <button @click="layout.expandPanel('nav')">Expand nav</button>
34
+ </div>
35
+ </template>
36
+ ```
37
+
38
+ Snapshot is seeded from `host.layout.snapshot` (sync, always available inside a managed-layout host — boot-payload carries the initial value). Subscribes to `@layout-change`; unsubscribes on unmount.
39
+
40
+ ## `useWippyPanel(panelId)`
41
+
42
+ Reactive ref to a single panel's live def. Returns `null` when the panel doesn't exist in the current snapshot.
43
+
44
+ ```vue
45
+ <script setup lang="ts">
46
+ import { useWippyPanel } from '@wippy-fe/vue-host'
47
+
48
+ const editor = useWippyPanel('editor')
49
+ // editor.value?.route — current internal route of the editor panel
50
+ // editor.value?.props?.sessionId — for chat-wrapper builtin
51
+ </script>
52
+ ```
53
+
54
+ ## `useWippyBreakpoint()`
55
+
56
+ Reactive breakpoint ref. Also updates off the `@layout-breakpoint` event (which fires immediately — not debounced with the 50ms `@layout-change` push).
57
+
58
+ ```ts
59
+ import { useWippyBreakpoint } from '@wippy-fe/vue-host'
60
+
61
+ const bp = useWippyBreakpoint()
62
+ // bp.value === 'md' | 'sm' | 'default' | ...
63
+ ```
64
+
65
+ ## `useWippyMainRoute()`
66
+
67
+ Reactive ref to the main panel's current route — the one that drives `window.location`.
68
+
69
+ ```ts
70
+ import { useWippyMainRoute } from '@wippy-fe/vue-host'
71
+
72
+ const route = useWippyMainRoute()
73
+ // route.value === '/projects/42/timeline'
74
+ ```
75
+
76
+ ## When to use this vs `@wippy-fe/layout`'s composables
77
+
78
+ - `@wippy-fe/layout` — `useLayoutSlot`, `useLayoutBreakpoint`, `useLayoutPanel`, `useLayoutFloating` operate on a `LayoutManager` instance provided in the current Vue app. Use inside panel components mounted by `LayoutManagerView` — they have direct access to the local manager.
79
+ - `@wippy-fe/vue-host` — `useWippyLayout` et al. operate on the `host.*` proxy surface from a CHILD iframe. The child doesn't have a LayoutManager; it reads the snapshot pushed from the parent.
80
+
81
+ Rule of thumb: inside a page/artifact/component iframe rendered by a managed-layout host, use `@wippy-fe/vue-host`. Inside host-layer code (resolvers, built-in panels) with direct `LayoutManager` access, use `@wippy-fe/layout`.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@wippy-fe/vue-host",
3
+ "version": "0.0.25",
4
+ "description": "Vue 3 reactive bindings for the Wippy host.* proxy API. Hooks for layout state, panel live state, breakpoints, and per-panel routes — inside a Wippy iframe.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "files": [
10
+ "src/",
11
+ "package.json",
12
+ "README.md"
13
+ ],
14
+ "peerDependencies": {
15
+ "vue": "^3.5.0",
16
+ "@wippy-fe/proxy": "0.0.25"
17
+ }
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export {
2
+ useWippyBreakpoint,
3
+ useWippyLayout,
4
+ useWippyMainRoute,
5
+ useWippyPanel,
6
+ } from './useWippyLayout'
7
+ export type { UseWippyLayout } from './useWippyLayout'
@@ -0,0 +1,399 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import type { LayoutSnapshot } from '@wippy-fe/proxy'
5
+ import { mount } from '@vue/test-utils'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+ import { defineComponent, h, isRef, ref } from 'vue'
8
+
9
+ // Mock @wippy-fe/proxy with a controllable host + on(). The shared
10
+ // state in useWippyLayout reads `host.layout.snapshot` ONCE at first
11
+ // hook call (per iframe lifetime — and per Vitest module), so we
12
+ // pre-seed the snapshot, then drive updates by firing registered
13
+ // `@layout-change` listeners.
14
+
15
+ const mockState = {
16
+ snapshot: null as LayoutSnapshot | null,
17
+ listeners: new Map<string, Set<(payload: any) => void>>(),
18
+ hostLayoutMethods: {
19
+ resizePanel: vi.fn(),
20
+ collapsePanel: vi.fn(),
21
+ expandPanel: vi.fn(),
22
+ movePanel: vi.fn(),
23
+ removePanel: vi.fn(),
24
+ closeModal: vi.fn(),
25
+ removeFloating: vi.fn(),
26
+ },
27
+ }
28
+
29
+ function fire(topic: string, payload: any) {
30
+ const set = mockState.listeners.get(topic)
31
+ if (set) {
32
+ for (const cb of set) cb(payload)
33
+ }
34
+ }
35
+
36
+ function listenerCount(topic: string): number {
37
+ return mockState.listeners.get(topic)?.size ?? 0
38
+ }
39
+
40
+ vi.mock('@wippy-fe/proxy', () => ({
41
+ host: {
42
+ layout: {
43
+ get snapshot() {
44
+ return mockState.snapshot
45
+ },
46
+ ...mockState.hostLayoutMethods,
47
+ },
48
+ },
49
+ on: (topic: string, cb: (payload: any) => void) => {
50
+ let set = mockState.listeners.get(topic)
51
+ if (!set) {
52
+ set = new Set()
53
+ mockState.listeners.set(topic, set)
54
+ }
55
+ set.add(cb)
56
+ return () => {
57
+ set!.delete(cb)
58
+ }
59
+ },
60
+ }))
61
+
62
+ // Import AFTER the mock — the hooks resolve `@wippy-fe/proxy` at module-load.
63
+ const {
64
+ useWippyLayout,
65
+ useWippyPanel,
66
+ useWippyBreakpoint,
67
+ useWippyMainRoute,
68
+ } = await import('./useWippyLayout')
69
+
70
+ function makeSnapshot(over: Partial<LayoutSnapshot> = {}): LayoutSnapshot {
71
+ return {
72
+ layouts: { default: { direction: 'horizontal', children: [] } },
73
+ panels: {},
74
+ activeBreakpoint: 'default',
75
+ ...over,
76
+ }
77
+ }
78
+
79
+ beforeEach(() => {
80
+ // Reset all mock counters; the shared snapshot ref persists across
81
+ // tests by design (it's installed once on first hook call).
82
+ for (const fn of Object.values(mockState.hostLayoutMethods)) fn.mockReset()
83
+ })
84
+
85
+ afterEach(() => {
86
+ // Reset mock state for next test
87
+ mockState.snapshot = null
88
+ })
89
+
90
+ describe('useWippyLayout', () => {
91
+ it('seeds snapshot.value from host.layout.snapshot at first call', () => {
92
+ const initial = makeSnapshot({ activeBreakpoint: 'md' })
93
+ mockState.snapshot = initial
94
+
95
+ let captured: any
96
+ const Comp = defineComponent({
97
+ setup() {
98
+ const layout = useWippyLayout()
99
+ captured = layout
100
+ return () => h('div')
101
+ },
102
+ })
103
+ const w = mount(Comp)
104
+
105
+ // First call seeds. Snapshot ref tracks shared state from this
106
+ // point — even if mockState.snapshot changes, the ref doesn't
107
+ // until @layout-change fires.
108
+ expect(captured.snapshot.value).toBeDefined()
109
+ w.unmount()
110
+ })
111
+
112
+ it('updates snapshot when @layout-change fires', () => {
113
+ let captured: any
114
+ const Comp = defineComponent({
115
+ setup() {
116
+ captured = useWippyLayout()
117
+ return () => h('div')
118
+ },
119
+ })
120
+ const w = mount(Comp)
121
+
122
+ const next = makeSnapshot({ activeBreakpoint: 'sm' })
123
+ fire('@layout-change', next)
124
+ expect(captured.snapshot.value).toBe(next)
125
+ expect(captured.activeBreakpoint.value).toBe('sm')
126
+ w.unmount()
127
+ })
128
+
129
+ it('isManaged tracks whether snapshot is null', () => {
130
+ let captured: any
131
+ const Comp = defineComponent({
132
+ setup() {
133
+ captured = useWippyLayout()
134
+ return () => h('div')
135
+ },
136
+ })
137
+ const w = mount(Comp)
138
+
139
+ fire('@layout-change', null)
140
+ expect(captured.isManaged.value).toBe(false)
141
+ fire('@layout-change', makeSnapshot())
142
+ expect(captured.isManaged.value).toBe(true)
143
+ w.unmount()
144
+ })
145
+
146
+ it('mutation methods call host.layout.* through', () => {
147
+ let captured: any
148
+ const Comp = defineComponent({
149
+ setup() {
150
+ captured = useWippyLayout()
151
+ return () => h('div')
152
+ },
153
+ })
154
+ const w = mount(Comp)
155
+
156
+ captured.resizePanel('files', '300px')
157
+ captured.collapsePanel('nav')
158
+ captured.expandPanel('nav')
159
+ captured.movePanel('a', { position: 'right' })
160
+ captured.removePanel('x')
161
+ captured.closeModal('m')
162
+ captured.removeFloating('f')
163
+
164
+ expect(mockState.hostLayoutMethods.resizePanel).toHaveBeenCalledWith('files', '300px')
165
+ expect(mockState.hostLayoutMethods.collapsePanel).toHaveBeenCalledWith('nav')
166
+ expect(mockState.hostLayoutMethods.expandPanel).toHaveBeenCalledWith('nav')
167
+ expect(mockState.hostLayoutMethods.movePanel).toHaveBeenCalledWith('a', { position: 'right' })
168
+ expect(mockState.hostLayoutMethods.removePanel).toHaveBeenCalledWith('x')
169
+ expect(mockState.hostLayoutMethods.closeModal).toHaveBeenCalledWith('m')
170
+ expect(mockState.hostLayoutMethods.removeFloating).toHaveBeenCalledWith('f')
171
+ w.unmount()
172
+ })
173
+ })
174
+
175
+ describe('useWippyPanel', () => {
176
+ it('returns panel def from snapshot.panels', () => {
177
+ let panelRef: any
178
+ const Comp = defineComponent({
179
+ setup() {
180
+ panelRef = useWippyPanel('editor')
181
+ return () => h('div')
182
+ },
183
+ })
184
+ const w = mount(Comp)
185
+
186
+ fire('@layout-change', makeSnapshot({
187
+ panels: { editor: { kind: 'page', id: 'editor', route: '/foo' } },
188
+ }))
189
+ expect(panelRef.value).toMatchObject({ kind: 'page', id: 'editor', route: '/foo' })
190
+ w.unmount()
191
+ })
192
+
193
+ it('returns null when panel id is not in snapshot', () => {
194
+ let panelRef: any
195
+ const Comp = defineComponent({
196
+ setup() {
197
+ panelRef = useWippyPanel('ghost')
198
+ return () => h('div')
199
+ },
200
+ })
201
+ const w = mount(Comp)
202
+
203
+ fire('@layout-change', makeSnapshot({ panels: {} }))
204
+ expect(panelRef.value).toBeNull()
205
+ w.unmount()
206
+ })
207
+
208
+ it('reacts to a Ref<string> id input', () => {
209
+ const id = ref('a')
210
+ let panelRef: any
211
+ const Comp = defineComponent({
212
+ setup() {
213
+ panelRef = useWippyPanel(id)
214
+ return () => h('div')
215
+ },
216
+ })
217
+ const w = mount(Comp)
218
+
219
+ fire('@layout-change', makeSnapshot({
220
+ panels: {
221
+ a: { kind: 'page', id: 'a', route: '/aa' },
222
+ b: { kind: 'page', id: 'b', route: '/bb' },
223
+ },
224
+ }))
225
+ expect(panelRef.value).toMatchObject({ id: 'a' })
226
+ id.value = 'b'
227
+ expect(panelRef.value).toMatchObject({ id: 'b' })
228
+ w.unmount()
229
+ })
230
+
231
+ it('reacts to a getter id input', () => {
232
+ const id = ref('a')
233
+ let panelRef: any
234
+ const Comp = defineComponent({
235
+ setup() {
236
+ panelRef = useWippyPanel(() => id.value)
237
+ return () => h('div')
238
+ },
239
+ })
240
+ const w = mount(Comp)
241
+
242
+ fire('@layout-change', makeSnapshot({
243
+ panels: {
244
+ a: { kind: 'page', id: 'a' },
245
+ b: { kind: 'page', id: 'b' },
246
+ },
247
+ }))
248
+ expect(panelRef.value?.id).toBe('a')
249
+ id.value = 'b'
250
+ expect(panelRef.value?.id).toBe('b')
251
+ w.unmount()
252
+ })
253
+ })
254
+
255
+ describe('useWippyBreakpoint', () => {
256
+ it('returns a ref', () => {
257
+ let bp: any
258
+ const Comp = defineComponent({
259
+ setup() {
260
+ bp = useWippyBreakpoint()
261
+ return () => h('div')
262
+ },
263
+ })
264
+ const w = mount(Comp)
265
+ expect(isRef(bp)).toBe(true)
266
+ w.unmount()
267
+ })
268
+
269
+ it('updates from @layout-breakpoint event', () => {
270
+ let bp: any
271
+ const Comp = defineComponent({
272
+ setup() {
273
+ bp = useWippyBreakpoint()
274
+ return () => h('div')
275
+ },
276
+ })
277
+ const w = mount(Comp)
278
+ fire('@layout-breakpoint', { name: 'lg', width: 1200 })
279
+ expect(bp.value).toBe('lg')
280
+ w.unmount()
281
+ })
282
+
283
+ it('also updates from @layout-change activeBreakpoint', () => {
284
+ let bp: any
285
+ const Comp = defineComponent({
286
+ setup() {
287
+ bp = useWippyBreakpoint()
288
+ return () => h('div')
289
+ },
290
+ })
291
+ const w = mount(Comp)
292
+ fire('@layout-change', makeSnapshot({ activeBreakpoint: 'sm' }))
293
+ expect(bp.value).toBe('sm')
294
+ w.unmount()
295
+ })
296
+ })
297
+
298
+ describe('useWippyMainRoute', () => {
299
+ it('walks active-breakpoint tree to find main panel', () => {
300
+ let route: any
301
+ const Comp = defineComponent({
302
+ setup() {
303
+ route = useWippyMainRoute()
304
+ return () => h('div')
305
+ },
306
+ })
307
+ const w = mount(Comp)
308
+
309
+ fire('@layout-change', makeSnapshot({
310
+ activeBreakpoint: 'default',
311
+ layouts: {
312
+ default: {
313
+ direction: 'horizontal',
314
+ children: [
315
+ { panel: 'sidebar', size: '200px' },
316
+ { panel: 'editor', size: '1fr', main: true },
317
+ ] as any,
318
+ },
319
+ },
320
+ panels: {
321
+ sidebar: { kind: 'component', tagName: 'x' },
322
+ editor: { kind: 'page', id: 'e1', route: '/edit' },
323
+ },
324
+ }))
325
+ expect(route.value).toBe('/edit')
326
+ w.unmount()
327
+ })
328
+
329
+ it('returns empty string when no main panel in active layout', () => {
330
+ let route: any
331
+ const Comp = defineComponent({
332
+ setup() {
333
+ route = useWippyMainRoute()
334
+ return () => h('div')
335
+ },
336
+ })
337
+ const w = mount(Comp)
338
+ fire('@layout-change', makeSnapshot({
339
+ layouts: {
340
+ default: { direction: 'horizontal', children: [{ panel: 'a', size: '1fr' }] },
341
+ },
342
+ panels: { a: { kind: 'page', id: 'a' } },
343
+ }))
344
+ expect(route.value).toBe('')
345
+ w.unmount()
346
+ })
347
+
348
+ it('finds main inside a nested tree', () => {
349
+ let route: any
350
+ const Comp = defineComponent({
351
+ setup() {
352
+ route = useWippyMainRoute()
353
+ return () => h('div')
354
+ },
355
+ })
356
+ const w = mount(Comp)
357
+ fire('@layout-change', makeSnapshot({
358
+ layouts: {
359
+ default: {
360
+ direction: 'horizontal',
361
+ children: [
362
+ {
363
+ direction: 'vertical',
364
+ children: [{ panel: 'editor', size: '1fr', main: true }],
365
+ } as any,
366
+ ],
367
+ },
368
+ },
369
+ panels: { editor: { kind: 'page', id: 'e', route: '/deep' } },
370
+ }))
371
+ expect(route.value).toBe('/deep')
372
+ w.unmount()
373
+ })
374
+ })
375
+
376
+ describe('subscription sharing', () => {
377
+ it('multiple hooks across multiple components share ONE @layout-change listener', () => {
378
+ const before = listenerCount('@layout-change')
379
+
380
+ const Comp = defineComponent({
381
+ setup() {
382
+ useWippyLayout()
383
+ useWippyPanel('a')
384
+ useWippyMainRoute()
385
+ return () => h('div')
386
+ },
387
+ })
388
+ const w1 = mount(Comp)
389
+ const w2 = mount(Comp)
390
+
391
+ // Module-scope shared snapshot — same listener regardless of how
392
+ // many hooks / components consume it.
393
+ expect(listenerCount('@layout-change') - before).toBeLessThanOrEqual(2)
394
+ // (At most: snapshot listener + breakpoint listener, both module-scoped.)
395
+
396
+ w1.unmount()
397
+ w2.unmount()
398
+ })
399
+ })
@@ -0,0 +1,181 @@
1
+ import type { LayoutSnapshot, PanelTarget } from '@wippy-fe/proxy'
2
+ import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef } from 'vue'
3
+ import { host, on } from '@wippy-fe/proxy'
4
+ import { computed, shallowRef, toValue } from 'vue'
5
+
6
+ /**
7
+ * Vue 3 reactive bindings for `host.layout.*`. Use from any component
8
+ * inside a Wippy iframe (managed-layout or compat). In compat mode
9
+ * `snapshot.value` stays `null` and mutations are silent no-ops.
10
+ *
11
+ * Boot-time seed comes from `host.layout.snapshot` (sync cache,
12
+ * populated from `AppContext.layout` in the child's `__WIPPY_INITIAL_CONFIG__`).
13
+ * Subsequent updates arrive via the `@layout-change` proxy topic —
14
+ * `events.ts` is the single writer that updates the cache and then
15
+ * fires the event, so reads here are always fresh.
16
+ *
17
+ * ```vue
18
+ * <script setup lang="ts">
19
+ * import { useWippyLayout } from '@wippy-fe/vue-host'
20
+ *
21
+ * const layout = useWippyLayout()
22
+ * // layout.snapshot — Ref<LayoutSnapshot | null>
23
+ * // layout.activeBreakpoint — ComputedRef<string>
24
+ * // layout.panels — ComputedRef<Record<string, unknown>>
25
+ * // layout.isManaged — ComputedRef<boolean>
26
+ *
27
+ * layout.resizePanel('files', '400px')
28
+ * layout.collapsePanel('sidebar')
29
+ * </script>
30
+ * ```
31
+ */
32
+ export interface UseWippyLayout {
33
+ /** Live layout snapshot — null outside a managed-layout host. */
34
+ snapshot: Ref<LayoutSnapshot | null>
35
+ /** Currently-active breakpoint name. Empty string when no snapshot. */
36
+ activeBreakpoint: ComputedRef<string>
37
+ /** Live per-panel declaration map. Includes `route`, `props`, etc. */
38
+ panels: ComputedRef<Record<string, unknown>>
39
+ /** True when a managed-layout host is driving this iframe. */
40
+ isManaged: ComputedRef<boolean>
41
+
42
+ resizePanel: (panelId: string, size: string) => void
43
+ collapsePanel: (panelId: string) => void
44
+ expandPanel: (panelId: string) => void
45
+ movePanel: (panelId: string, target: PanelTarget) => void
46
+ removePanel: (panelId: string) => void
47
+ closeModal: (modalId: string) => void
48
+ removeFloating: (floatingId: string) => void
49
+ }
50
+
51
+ /**
52
+ * Module-scoped shared snapshot ref + subscription. Every hook in this
53
+ * package reads from the same `ShallowRef`, so a component using
54
+ * `useWippyLayout()` + `useWippyPanel('a')` + `useWippyBreakpoint()`
55
+ * pays for ONE `@layout-change` subscription, not three.
56
+ *
57
+ * The subscription is installed lazily on first call and lives for the
58
+ * iframe's lifetime — no per-component cleanup needed (one closure on
59
+ * a process-wide bus is cheap; in compat-mode hosts this never fires).
60
+ */
61
+ let sharedSnapshot: ShallowRef<LayoutSnapshot | null> | null = null
62
+ let sharedBreakpoint: ShallowRef<string> | null = null
63
+
64
+ function getSharedSnapshot(): ShallowRef<LayoutSnapshot | null> {
65
+ if (sharedSnapshot)
66
+ return sharedSnapshot
67
+ const ref = shallowRef<LayoutSnapshot | null>(host.layout.snapshot)
68
+ on('@layout-change', (next) => {
69
+ ref.value = next
70
+ })
71
+ sharedSnapshot = ref
72
+ return ref
73
+ }
74
+
75
+ function getSharedBreakpoint(): ShallowRef<string> {
76
+ if (sharedBreakpoint)
77
+ return sharedBreakpoint
78
+ const ref = shallowRef<string>(host.layout.snapshot?.activeBreakpoint ?? '')
79
+ on('@layout-change', (snap) => {
80
+ if (snap?.activeBreakpoint)
81
+ ref.value = snap.activeBreakpoint
82
+ })
83
+ on('@layout-breakpoint', ({ name }) => {
84
+ ref.value = name
85
+ })
86
+ sharedBreakpoint = ref
87
+ return ref
88
+ }
89
+
90
+ export function useWippyLayout(): UseWippyLayout {
91
+ const snapshot = getSharedSnapshot()
92
+
93
+ return {
94
+ snapshot,
95
+ activeBreakpoint: computed(() => snapshot.value?.activeBreakpoint ?? ''),
96
+ panels: computed(() => (snapshot.value?.panels ?? {}) as Record<string, unknown>),
97
+ isManaged: computed(() => snapshot.value !== null),
98
+ resizePanel: (id, size) => host.layout.resizePanel(id, size),
99
+ collapsePanel: id => host.layout.collapsePanel(id),
100
+ expandPanel: id => host.layout.expandPanel(id),
101
+ movePanel: (id, t) => host.layout.movePanel(id, t),
102
+ removePanel: id => host.layout.removePanel(id),
103
+ closeModal: id => host.layout.closeModal(id),
104
+ removeFloating: id => host.layout.removeFloating(id),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Reactive ref to a single panel's live def. Returns `null` when the
110
+ * panel doesn't exist in the current snapshot (removed or not declared).
111
+ *
112
+ * `panelId` accepts a static string, a ref, or a getter — so a consumer
113
+ * passing `() => props.id` or a `Ref<string>` will see the panel def
114
+ * change reactively when the id input changes.
115
+ *
116
+ * Common use: read `panel.value?.route` inside an editor component to
117
+ * drive its internal router from the authoritative layout state.
118
+ */
119
+ export function useWippyPanel(
120
+ panelId: MaybeRefOrGetter<string>,
121
+ ): ComputedRef<Record<string, unknown> | null> {
122
+ const snapshot = getSharedSnapshot()
123
+ return computed(() => {
124
+ const id = toValue(panelId)
125
+ const panels = snapshot.value?.panels as Record<string, unknown> | undefined
126
+ return (panels?.[id] as Record<string, unknown> | undefined) ?? null
127
+ })
128
+ }
129
+
130
+ /**
131
+ * Reactive active-breakpoint ref. Updates off both `@layout-change`
132
+ * and `@layout-breakpoint`; the latter fires immediately on each
133
+ * transition (no 50ms debounce), so UI that reacts to breakpoint
134
+ * transitions is responsive.
135
+ */
136
+ export function useWippyBreakpoint(): Ref<string> {
137
+ return getSharedBreakpoint()
138
+ }
139
+
140
+ /**
141
+ * Reactive ref to the main panel's current route. Derived by walking
142
+ * the snapshot's active-breakpoint tree to find the slot with
143
+ * `main: true` and reading `panels[id].route`. Returns empty string
144
+ * when there's no main panel or no route set.
145
+ *
146
+ * Note: the active breakpoint may not have a main panel (e.g. an `sm`
147
+ * layout that drops the main entirely for a mobile-only view). In that
148
+ * case this ref returns ''. Bind to `useWippyPanel(<id>).value?.route`
149
+ * for a specific panel's route across breakpoints.
150
+ */
151
+ export function useWippyMainRoute(): ComputedRef<string> {
152
+ const snapshot = getSharedSnapshot()
153
+ return computed(() => {
154
+ const snap = snapshot.value
155
+ if (!snap)
156
+ return ''
157
+ const layouts = snap.layouts as Record<string, unknown>
158
+ const tree = layouts[snap.activeBreakpoint ?? 'default'] ?? layouts.default
159
+ const mainId = findMainPanel(tree)
160
+ if (!mainId)
161
+ return ''
162
+ const panelDef = (snap.panels as Record<string, { route?: string }>)[mainId]
163
+ return panelDef?.route ?? ''
164
+ })
165
+ }
166
+
167
+ function findMainPanel(node: unknown): string | null {
168
+ if (!node || typeof node !== 'object')
169
+ return null
170
+ const n = node as { panel?: string, main?: boolean, children?: unknown[] }
171
+ if (typeof n.panel === 'string' && n.main === true)
172
+ return n.panel
173
+ if (Array.isArray(n.children)) {
174
+ for (const child of n.children) {
175
+ const found = findMainPanel(child)
176
+ if (found !== null)
177
+ return found
178
+ }
179
+ }
180
+ return null
181
+ }