Multi-Panel Layout
Status: Draft 1 (preview) — early access, not for production. The managed-layout API is shipped but not yet battle-tested on a production consumer. Field names, defaults, and validation rules may still change between minor releases. Pin to an exact CDN version until this label is removed. For nearly all applications the standard
compatmode is the recommended production mode — reach for managed layout only when you genuinely need to compose the chrome itself.
The managed-layout mode replaces the standard Wippy chrome with a fully declarative panel tree. Instead of the fixed chat-and-sidebar shell, you describe a tree of named panels in your backend YAML. The Web Host assembles the layout at boot, validates it, and maintains it reactively at runtime. Panels can be resized, collapsed, swapped, added, and removed without a page reload.
When to Use Managed Layout
The standard compat mode (the default) gives you the fixed Wippy product: nav sidebar, chat panel, page area, and a right artifact panel. It is the current, most-used production mode and is sufficient for nearly all applications.
Opt in to fe_mode = managed (early access) only when you need to compose the chrome itself:
| Need | Compat | Managed |
|---|---|---|
| Standard Wippy chat + nav | Yes | Replaceable |
| Multiple page slots side by side | No | Yes |
| Custom sidebar or coordinator component | Limited | Yes — any panel kind |
| Responsive layouts per breakpoint | No | Yes |
| Floating overlay panels | No | Yes |
| Headless coordinator component | No | Yes (coordinators) |
| Per-panel URL-aware routing | Main panel only | Every kind: page panel |
| Cross-panel message bus | No | Yes (broadcast/send/on) |
Compatibility
Managed layout spans the Web Host, facade, and several @wippy-fe/* packages. Use one compatible package family for the exact target Web Host release and verify its served import map; do not mix package versions from unrelated releases.
Release map
| Release | Managed-layout additions |
|---|---|
Web Host 1.0.50, Wippy FE 0.0.50 |
Typed compat intents, @HOST/compat-coordinator, browser URL and Back/Forward synchronization, built-in panel tabs, anchored floating panels, and useSwapBuffer(). |
Web Host 1.0.51, Wippy FE 0.0.51 |
Reactive and race-safe <wippy-chat> session/token control, opt-in themed splitter handles, split-axis-only size constraints, drawer geometry/stacking fixes, and the packaged proxy source map. |
Web Host 1.0.52, Wippy FE 0.0.52 |
Typed retained-WC visibility and useHostVisibilityRefresh(), immediate page readiness instead of waiting for the 14-second fallback, stale renderer-key rejection, in-place component prop updates, and the isolated splitter layer with --wippy-layout-splitter-z-index. |
The 14-second page reveal is a Web Host 1.0.52 fallback, not a 1.0.51
feature or an application loading delay. Split-axis sizing and reactive chat
landed in 1.0.51; retained visibility, keyed readiness, and splitter layering
landed in 1.0.52.
Retained direct-web-component visibility requires Web Host 1.0.52 and
@wippy-fe/webcomponent-core, @wippy-fe/webcomponent-vue, and
@wippy-fe/shared 0.0.52. Earlier managed-layout releases do not provide the
typed data-wippy-visible contract or useHostVisibilityRefresh().
Retained web-component activity
Managed layouts keep panels mounted across buffer swaps, breakpoint changes,
and drawer close/open cycles. The host sets
data-wippy-visible="true" | "false" before connecting a direct custom element
and updates it in place when logical ownership changes. This is not CSS,
viewport, or document visibility, and it never implies a remount.
Vue components read the state with useHostVisibility() or combine ordinary
initial loading with reveal refreshes through useHostVisibilityRefresh(task).
The latter runs after mount and then only on exact false -> true. Do not use
the proxy @visibility topic in a direct WC; it is the iframe/Web Fragment
message channel.
Pin to an exact CDN tag — at least https://web-host.wippy.ai/webcomponents-1.0.52 — until the Draft 1 label is removed.
Enabling Managed Layout
Enable the managed entry in your facade configuration and provide a backend host_config.layout declaration:
host_config:
layout:
layouts:
default:
direction: horizontal
children:
- panel: nav
size: 240px
- panel: main
size: 1fr
main: true
panels:
nav: { kind: builtin, id: '@HOST/nav-sidebar' }
main: { kind: page, id: home }
When the managed entry is selected, the facade serves managed-layout.js instead of module.js. fe_mode is a current facade requirement parameter (default compat, opt-in managed); it is set on the wippy.facade requirement, not carried inside the AppConfig payload. There is no AppConfig.feature field — the managed layout is conveyed to the child entirely through AppConfig.hostConfig.layout. The proxy API surface is identical in both modes, but some commands only take effect in one mode — see What works in which mode.
The HostLayoutDeclaration
The entire layout is described by a single HostLayoutDeclaration object nested under backend host_config.layout in your facade configuration and projected to frontend AppConfig.hostConfig.layout. The host validates it before mounting — any LayoutValidationError surfaces in the browser console with { kind, message, panelId? }.
| Field | Type | Description |
|---|---|---|
layouts |
Record<string, PanelTree> & { default: PanelTree } |
Breakpoint-keyed panel trees. The default key is required. |
breakpoints? |
Record<string, number> |
Pixel widths that activate non-default layout keys. |
panels |
Record<string, HostPanelDef> |
Named panel content definitions. |
floating? |
Record<string, HostFloatingDef> |
Boot-time floating overlay panels. |
modals? |
Record<string, HostModalDef> |
Boot-time modal definitions. |
coordinators? |
Record<string, HostCoordinatorDef> |
Headless coordinator components. |
services? |
Record<string, HostCoordinatorDef> |
Deprecated alias for coordinators; new declarations must use coordinators. |
dragEnabled? |
boolean | Allow user-driven splitter drag. Default true. |
Panel Kinds
Each entry in panels, floating, modals, and coordinators is a tagged union on kind:
| Kind | Description | Required fields |
|---|---|---|
page |
A Wippy page module mounted in a srcdoc iframe | id (page registry id) |
artifact |
A Wippy artifact mounted in a srcdoc iframe | id (artifact UUID) |
component |
A web component mounted directly in host DOM | tagName |
builtin |
A framework-owned host component (see below) | id |
Exactly one panel in the layout tree must carry main: true. Browser URL ownership still requires route synchronization through @HOST/compat-coordinator or equivalent consumer coordination. All other panels route independently inside their iframes.
Built-in Panel IDs
kind: builtin accepts the following id values. The @HOST/ prefix is reserved for framework-owned panels:
| ID | What it renders |
|---|---|
@HOST/nav-sidebar |
Standard Wippy nav sidebar (sessions, pages, settings) |
@HOST/chat-wrapper |
Standard Wippy chat panel for the active session |
@HOST/artifact-viewer |
Generic artifact viewer (pair with route /:uuid) |
@HOST/session-selector |
Session list and picker |
@HOST/compat-coordinator |
Headless compat-intent and main-route coordinator; declare under coordinators |
@HOST/panel-tab |
Edge tab for revealing a collapsed panel; declare under floating |
An unknown @HOST/<id> causes a LayoutValidationError at declaration-load rather than silently rendering an empty slot.
Breakpoint-Keyed Layouts
The layouts field maps breakpoint keys to panel trees. default is always used unless a narrower breakpoint matches. Breakpoint pixel widths are defined under breakpoints:
host_config:
layout:
breakpoints:
sm: 768
layouts:
default:
direction: horizontal
children:
- panel: side
size: 300px
- panel: main
size: 1fr
main: true
sm:
direction: vertical
children:
- panel: main
size: 1fr
main: true
- panel: side
display: drawer-left
drawerSize: { width: 320px }
panels:
side: { kind: page, id: app-sidebar, route: / }
main: { kind: page, id: app-home, route: / }
When the breakpoint changes, panels with the same id keep one stable content host that visually tracks the active slot without reparenting. Iframe contentWindow, web-component state, Vue state, and scroll position survive the transition; reparenting via Teleport is intentionally avoided because removing and reinserting an iframe reloads it.
Drawer-Mode Panels
A panel slot can declare display: 'drawer-left' | 'drawer-right' | 'drawer-bottom' to render as a slide-in overlay instead of an inline flex item. Drawer panels:
- Do not participate in their parent container's track sizing (
sizeis ignored) - Render as absolutely-positioned overlays anchored to the named edge
- Have an open/close state toggled via
host.layout.openDrawer(id)/closeDrawer(id)/toggleDrawer(id) - Show a backdrop when open; clicking the backdrop closes all open drawers
main: true slots cannot be drawer-mode — host validation throws. The drawerSize.width field controls the width for left/right drawers; drawerSize.height for bottom drawers. Default is 320px.
Floating Panels
Floating panels are free-positioned overlays declared under floating. They do not participate in the flex layout tree and can be added or removed at runtime:
floating:
flap:
kind: component
tagName: my-right-flap
position: { x: 0, y: 200 }
size: { width: 48, height: 80 }
Runtime management:
// Add a floating panel
host.layout.addFloating('inspector', {
kind: 'component',
tagName: 'my-inspector',
position: { x: 100, y: 100 },
size: { width: 400, height: 300 },
})
// Remove it
host.layout.removeFloating('inspector')
Headless Coordinators
Coordinators are components mounted in a hidden host. They have no visible slot but receive the panel-scoped host API. Use them for cross-cutting logic so display panels stay focused on rendering. The older services field remains a deprecated compatibility alias.
coordinators:
coordinator:
kind: component
tagName: my-coordinator
A coordinator component receives the panel-scoped host wrapper and can subscribe to bus channels immediately in onMount:
import { WippyElement } from '@wippy-fe/webcomponent-core'
class MyCoordinator extends WippyElement {
protected onMount() {
this.host?.layout.on('open-chat', ({ payload }) => {
this.host?.layout.updatePanel('right', { route: `/open-chat/${payload.token}` })
this.host?.layout.expandPanel('right')
})
}
protected onUnmount() {}
static get wippyConfig() { return { propsSchema: { properties: {} } } }
}
customElements.define('my-coordinator', MyCoordinator)
Shipped compat coordinator
Managed layout contains only declared surfaces. Calls such as
host.openArtifact(), host.startChat(), host.openSession(), and
host.navigate() therefore publish typed intents on the reserved
@HOST/intent channel. Declare the shipped coordinator to act on them and to
bind the browser URL to the main panel:
coordinators:
compat:
kind: builtin
id: '@HOST/compat-coordinator'
props:
artifactPanel: right
chatPanel: chat
modalId: artifact-modal
routeSync: true
wsActions: true
Keep routeSync: true when using the standard navigation contract. Without a
coordinator or equivalent consumer logic, deep links, Back/Forward, and
@HOST/nav-sidebar navigation have no panel route to drive. Intents raised
during child boot are held in a bounded queue until the first coordinator
subscribes.
@HOST/ is reserved in both directions: ordinary panels cannot publish system
traffic, and only entries under coordinators receive it through supported
host APIs. This boundary is enforced for iframe/Web Fragment panels. A direct
component mounted in the host realm shares the host DOM and is not a security
sandbox. At boot the host prints a parity table when coordinator handling, a
modal target surface, main-panel URL binding, or a declared coordinator tag is
missing; a complete declaration produces no warning.
The In-Tab Broadcast Bus
Panels communicate through a bus scoped to the current browser tab. The bus never crosses to other tabs — use a custom WebSocket topic if you need multi-tab sync.
| Method | Description |
|---|---|
host.layout.broadcast(channel, payload) |
Publish to all panels; sender excluded |
host.layout.send(targetPanelId, channel, payload) |
Publish to one specific panel |
host.layout.on(channel, handler) |
Subscribe; returns off() unsubscribe function |
The sourcePanelId on received messages is set by the host from the publishing window and cannot be spoofed. Channel names are case-sensitive plain strings.
Important: Components that import host directly from @wippy-fe/proxy bypass panel scoping — bus calls go through but lose sourcePanelId. Always use the panel-scoped wrapper instead:
// raw HTMLElement
import { getWippyHost } from '@wippy-fe/webcomponent-core'
const host = getWippyHost(this)
// WippyElement subclass — this.host is already panel-scoped
this.host?.layout.broadcast('open-chat', { token: 'abc' })
// Vue component
import { useHost } from '@wippy-fe/webcomponent-vue'
// ProxyApiInstance is an ambient global type (from @wippy-fe/types-global-proxy) — reference it without an import.
const host = useHost<ProxyApiInstance['host']>()
host?.layout.broadcast('open-chat', { token: 'abc' })
Layout API Reference (host.layout)
| Method | Description |
|---|---|
.snapshot |
Synchronous getter returning the full layout snapshot, or null outside managed-layout mode |
.resizePanel(id, size) |
Resize the named panel in the active breakpoint |
.collapsePanel(id) |
Collapse a panel declared collapsible: true |
.expandPanel(id) |
Expand a collapsed panel |
.openDrawer(id) |
Open a drawer-mode panel |
.closeDrawer(id) |
Close a drawer-mode panel |
.toggleDrawer(id) |
Toggle a drawer-mode panel |
.movePanel(id, target) |
Move panel to a new tree position |
.removePanel(id) |
Remove panel from all breakpoint layouts |
.updatePanel(id, def) |
Patch panel definition at runtime; props shallow-merges, top-level fields replace |
.addFloating(id, def) |
Add a floating panel |
.removeFloating(id) |
Remove a floating panel |
.openModal(id, def?) |
Open a declared modal by id, optionally overriding its definition. Runtime-only modals require def. Native <dialog>.showModal() is the default; pass useNativeDialog: false for the legacy div overlay. Re-opening an open id is a silent no-op. |
.closeModal(id) |
Close an open modal |
.broadcast(channel, payload) |
Publish to all panels |
.send(target, channel, payload) |
Publish to one panel |
.on(channel, handler) |
Subscribe to a bus channel |
openModal() documents host-internal layout infrastructure, not an application-component recipe. Shipped Vue product UI should use PrimeVue Dialog or the host confirmation API rather than cloning this native-dialog behavior with custom modal styling.
updatePanel Merge Semantics
host.layout.updatePanel(id, def) patches an existing panel def — it does not replace it. The props object is shallow-merged into the panel's current props: supplied keys are added or overwritten, omitted keys are preserved. Every other top-level field of def (route, kind, id, tagName, title, icon, …) replaces the current value wholesale.
Given a panel whose current props are { artifactId: 'old', zoom: 2 }:
// props shallow-merges → { artifactId: 'abc', zoom: 2 }
host.layout.updatePanel('right', { props: { artifactId: 'abc' } })
// route replaces wholesale; props left untouched
host.layout.updatePanel('right', { route: '/x' })
Two caveats: the props merge is shallow — a nested object inside props is replaced entirely, not deep-merged — and a shallow merge cannot delete a prop key (you can only overwrite it).
Vue Composables — @wippy-fe/vue-host
These composables wrap the proxy layout API in reactive Vue 3 refs. The underlying subscription is module-scoped and lives for the iframe's lifetime, so there is no per-component cleanup on unmount:
| Composable | Returns |
|---|---|
useWippyLayout() |
Full layout state and mutation methods |
useWippyPanel(panelId) |
Named panel's live state (panelId is required — string, Ref<string>, or getter) |
useWippyBreakpoint() |
Active breakpoint name as a reactive ref |
useWippyMainRoute() |
Reactive ref to the main panel's current route |
The composables never return null — they always hand back objects/refs whose inner .value degrades when no managed-layout host is present: useWippyLayout().snapshot.value is null (and isManaged.value is false, so mutations are silent no-ops), useWippyBreakpoint().value and useWippyMainRoute().value are empty strings, and useWippyPanel(id).value is null when the id is absent. Guard host presence with layout.isManaged.value (or layout.snapshot.value !== null) rather than a === null check on the return value. This keeps the composables usable in standalone playgrounds and unit tests where no managed-layout host is present.
Swap buffering without remounts
useSwapBuffer() from @wippy-fe/layout keeps the outgoing surface mounted
until incoming content reports readiness, with an explicit timeout ceiling.
Use immutable slot.index as the DOM key, pass both index and content key to
markReady() / markFailed() so stale async signals are rejected, and keep
errors scoped per buffer. Content identity belongs in keyOf; changing the DOM
key would reinsert an iframe and destroy the state buffering is meant to retain.
const swap = useSwapBuffer<Surface>({
keyOf: surface => surface.ownerId,
buffers: 2,
readyTimeoutMs: 8_000,
loaderDelayMs: 250,
loaderMinMs: 400,
})
const slot = swap.push(surface)
swap.markReady(slot.index, slot.key)
// or: swap.markFailed(slot.index, error, slot.key)
The values shown are the defaults. A readiness timeout reveals the content by
default rather than leaving stale content behind a loader. Bind loading UI to
swap.showLoader, not directly to readiness. A failed buffer remains isolated
from its sibling; after handling the error, call clearError(index) to retry.
Web Host page readiness
Web Host uses the same keyed readiness discipline for managed page surfaces,
with a 14-second final reveal ceiling. Iframe and direct Web Component renderers
emit load / error through Vue event listeners and include the immutable
content key owned by that renderer. Painted content is therefore revealed
immediately; the ceiling is only a fallback for content that never reports.
A late event from an evicted renderer is rejected when its buffer index has
already been reused.
Do not use the 14-second host ceiling as an application loading delay, and do not add a second timer around normal page readiness. A page that regularly reaches the ceiling has a broken readiness or lifecycle path that should be fixed at its owner.
Stable component updates and panel sizing
For kind: component, changing panel props updates or removes attributes on
the existing custom element. The host replaces the element only when tagName
changes. This preserves element-owned state during updatePanel() calls and
breakpoint transitions.
minSize and maxSize constrain only the active split axis: width in a
horizontal tree and height in a vertical tree. They do not cap the cross axis,
so navigation, chat, and other full-height mounts can fill their track. Drawer
mounts follow the animated drawer geometry and are promoted above their anchor
and backdrop only while open, without remounting their content.
Splitter and handle styling
The splitter hit area is wider than its visible line and lives in the package's
isolated layer stack. --wippy-layout-splitter-z-index defaults to 700, below
drawers and modal backdrops. The circular handle is opt-in:
| Variable | Default | Purpose |
|---|---|---|
--wippy-layout-splitter-size |
1px |
Visible splitter line thickness |
--wippy-layout-splitter-hit-size |
10px |
Pointer hit area around the line; 24px on coarse pointers |
--wippy-layout-splitter-z-index |
700 |
Splitter and handle layer |
--wippy-layout-splitter-handle-size |
0 |
Handle diameter; 0 disables it |
--wippy-layout-splitter-handle-bg |
transparent |
Handle fill |
--wippy-layout-splitter-handle-border |
0 solid transparent |
Border shorthand |
--wippy-layout-splitter-handle-shadow |
none |
Handle shadow |
--wippy-layout-splitter-handle-icon-color |
transparent |
Theme-aware SVG color via currentColor |
Set size, fill, border/shadow, and icon color together when opting in. The SVG rotates 90 degrees for vertical splitters and remains hidden for locked splits.
What works in which mode
The proxy API surface is identical in compat and managed mode — the same @wippy-fe/proxy imports resolve in both — but two parts of it are mode-specific in effect. This mismatch is the main thing to watch when moving an app onto managed layout (and a reason managed is still early access).
host.layout takes effect only in managed mode
The host installs the layout receiver only when a layout is declared (the managed entry, gated on hostConfig.layout). In compat mode host.layout still exists, but host.layout.snapshot is null and every mutation and bus call (resizePanel, updatePanel, movePanel, openModal, addFloating, broadcast, send, on, …) is a silent no-op — the message is posted but nothing on the host is listening. Gate on the snapshot before mutating:
if (host.layout.snapshot) {
host.layout.updatePanel('right', { route: '/details' }) // managed only
}
// Vue: const { isManaged } = useWippyLayout(); if (isManaged.value) { … }
(Separately — a different axis — addPanel and setLayout are not exposed over the proxy at all, in either mode; see Known Limitations.)
host.* commands that assume the compat shell
The managed shell renders only your declared layout. Starting with Web Host 1.0.50, commands that normally target compat chrome publish typed @HOST/intent messages instead of failing silently. Declare @HOST/compat-coordinator or implement an equivalent coordinator to map those intents to your panels:
host.* command |
Compat (default) | Managed |
|---|---|---|
setContext, toast, confirm, handleError, logout, bridge.*, top-level state / ws / on |
Works | Works directly; managed mounts the global toast and confirmation surfaces |
openArtifact(id, ...) |
Opens in the right panel or a modal | Publishes an intent; the compat coordinator targets artifactPanel or modalId |
startChat(token) / openSession(uuid) |
Opens and displays the session | Publishes an intent; the compat coordinator resolves start tokens and updates the declared chatPanel |
navigate(url) |
Pushes the compat root router | Publishes an intent; routeSync applies it to the main panel and keeps browser history aligned |
onRouteChanged(route, navId?) |
Drives the host browser URL | Updates panel route state; routeSync projects the main panel route to the browser URL |
If no coordinator is available yet, boot-time intents are held in a bounded queue for the first coordinator subscription. A declaration with no handler is reported by the boot parity table. Reserved intents are readable only by coordinators entries and cannot be forged by ordinary panels.
State Management Approach
Three tiers, in order of preference:
Route — If the user could meaningfully bookmark or share the state, put it in the URL. Each kind: page panel runs its own router and reacts to @history events. This is decoupled, deep-linkable, and browser-history-aware.
Layout snapshot — If it affects layout shape (sizes, collapsed flags, component props), put it in the snapshot via updatePanel or resizePanel. Every subscribed panel sees every snapshot change, so keep payloads small.
Panel-local — Everything else (form drafts, modal state, transient UI) stays inside the panel's own Pinia stores or refs and never leaves the panel.
Canonical Coordination Pattern
The recommended pattern for cross-panel interaction is: bus event → coordinator service → updatePanel → panel reacts via its own router.
// In the coordinator service
this.host?.layout.on('open-chat', ({ payload }) => {
this.host?.layout.updatePanel('right', { route: `/open-chat/${payload.token}` })
this.host?.layout.expandPanel('right')
})
// In the right-panel app (a normal Vue page module)
const router = createAppRouter([...])
// createAppRouter already mirrors host history events into the router
// with an echo/current-route guard; add no manual routing subscription.
Keep coordinators thin. Keep panels owning their own UI.
Known Limitations
As of Draft 1, the following are not yet implemented:
addPanel/setLayoutover the proxy — not shipped. These exist only on the internal@wippy-fe/layoutLayoutManagerand are not exposed across the iframe proxy boundary. (openModal,closeModal, andmovePanelare shipped — see the Layout API Reference.)- Panel drag-to-rearrange UI — the data model and
movePanel()API work; user-facing drag is not yet implemented. - Tab primitive — not yet implemented.
- Grid-tile container — tracked for a follow-up.
- Runtime mutation persistence — mutations are not persisted across reloads. Persist manually if needed:
on('@layout-change', () => state.set('layout', host.layout.snapshot) ) nav-sidebarheader slot extension points — logo, app-name, and toggle button positions are fixed in this draft.
See Also
- Facade Entry Point — how the facade loads the JS-module entry and delivers config
- Bootstrap Sequence — how the host dispatches to the managed-layout entry at boot
- Packages —
@wippy-fe/layout,@wippy-fe/vue-host,@wippy-fe/webcomponent-core,@wippy-fe/webcomponent-vue