Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/guide/client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,37 @@ The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client scri
## Iframe panels

Dock iframes are their own documents, so they connect themselves instead of reading the host page's context: the panel SPA calls `connectDevframe()`, which discovers `./__connection.json` relative to its own base — `mountDevframe` serves the hub's connection meta under every dock base for exactly this. The client script (host page) and the iframe panel then share the server through RPC and shared state, or a same-origin `BroadcastChannel` when the loop must survive static builds.

## Shared-iframe soft navigation

A tool with many internal views — Nuxt DevTools' tabs, say — can surface each view as its own hub dock while they all share **one** live iframe, switching between them with client-side (soft) navigation instead of reloading. One iframe dock is the **anchor**: it owns a `frameId` and opts in with `subTabs`.

```ts
await mountDevframe(ctx, nuxtDevtools, {
dock: { frameId: 'nuxt-devtools', subTabs: { protocol: 'postmessage' } },
})
```

When the anchor's iframe mounts, the client host attaches a **frame-nav adapter** that speaks a small, versioned, origin-locked `postMessage` protocol with the embedded app. The app ships a ~40-line shim on the `devframe:frame-nav` channel:

| Message | Direction | Meaning |
|---|---|---|
| `ready` / `manifest` | frame → host | the current tab list (`{ tabs, current }`), on load and whenever it changes |
| `navigate` | host → frame | show this view (`{ tabId, navTarget }`) — the app routes client-side |
| `navigated` | frame → host | the app navigated internally, so the host highlights the matching dock |

The adapter materializes one [client-only dock](#client-only-docks) per reported tab (id `<frameId>:<tabId>`), each sharing the anchor's `frameId` and carrying a `navTarget`. Selecting a member soft-navigates the shared iframe; navigating inside the iframe moves the hub's active dock — the loop runs both ways with an idempotent guard against echoes. The embedded app needs no hub or RPC dependency, only the shim; a plain iframe with no shim simply stays a single dock.

`frameId` is independent of [`groupId`](./hub#grouping-dock-entries): members sharing one iframe may live in a group, several groups, or none.

### The viewer's part

A viewer keeps one iframe alive per `frameId` (shown/hidden across switches, never re-`src`'d) and, when it mounts that element, sets it on the anchor's dock state and announces it:

```ts
const state = ctx.docks.getStateById(anchorId)!
state.domElements.iframe = iframeEl
state.events.emit('dom:iframe:mounted', iframeEl)
```

That announcement is what the adapter attaches to. Both minimal hubs wire this end to end — see the "Tabbed Tool" in [`examples/minimal-vite-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) and [`examples/minimal-next-devframe-hub`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub), including the SPA's `postMessage` shim.
4 changes: 4 additions & 0 deletions docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ ctx.docks.register({

`groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated.

Grouping is about the dock bar; it does not share an iframe. When several iframe docks should render into **one** live iframe and switch views by soft navigation — a multi-tab tool like Nuxt DevTools hosted as first-class docks — give them a shared `frameId` and mark the anchor with `subTabs`. `frameId` is an axis independent of `groupId`, so shared-iframe members may sit in a group, across groups, or ungrouped. See [Shared-iframe soft navigation](./client-context#shared-iframe-soft-navigation).

### The dual role of `category`

`category` decides an entry's outer bucket on the dock bar (ordered by `DEFAULT_CATEGORIES_ORDER`) — but its meaning shifts once an entry joins a group:
Expand Down Expand Up @@ -261,6 +263,8 @@ Two minimal, copyable hubs mount every built-in plugin (git, terminals, code-ser
- [`examples/minimal-vite-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub) — a ~120-line Vite plugin host with a vanilla DOM UI.
- [`examples/minimal-next-devframe-hub/`](https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub) — the same protocol hosted from a Next.js App Router app.

Both also mount a "Tabbed Tool" that demonstrates [shared-iframe soft navigation](./client-context#shared-iframe-soft-navigation) — one SPA whose tabs surface as separate docks sharing a single iframe.

Every framework's hub host follows the same shape: a thin `DevframeHost` adapter over the framework's dev server, with the dock/commands/messages/terminals protocol unchanged above it.

## Diagnostics
Expand Down
175 changes: 175 additions & 0 deletions examples/minimal-next-devframe-hub/spa/next-tabbed-tool/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Next Tabbed Tool</title>
<style>
:root {
color-scheme: light dark;
font-family: system-ui, sans-serif;
--fg: #1c2024;
--muted: #6b7280;
--line: #e4e6ea;
--accent: #3a6a45;
--panel: #f6f7f8;
}
@media (prefers-color-scheme: dark) {
:root { --fg: #e6e8ea; --muted: #9aa2ad; --line: #2a2d31; --accent: #7fb98a; --panel: #17191c; }
body { background: #0f1113; }
}
* { box-sizing: border-box; }
body { margin: 0; color: var(--fg); }
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); }
header .brand { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px; }
header .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
nav { display: flex; gap: 4px; margin-left: 8px; }
nav button {
appearance: none; border: 1px solid transparent; background: transparent; color: var(--muted);
font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 7px; cursor: pointer;
}
nav button:hover { color: var(--fg); background: var(--panel); }
nav button[aria-current="true"] { color: var(--fg); background: var(--panel); border-color: var(--line); }
main { padding: 24px; }
h1 { margin: 0 0 6px; font-size: 18px; }
p { max-width: 60ch; line-height: 1.6; color: var(--muted); }
code { font-family: ui-monospace, monospace; background: rgba(127, 127, 127, 0.16); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.view { display: none; }
.view[data-active="true"] { display: block; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; margin-top: 16px; }
.card { border: 1px solid var(--line); border-radius: 10px; padding: 14px; background: var(--panel); }
.card b { display: block; font-size: 13px; }
.card span { font-size: 12px; color: var(--muted); }
ol { padding-left: 18px; }
ol li { margin: 6px 0; }
.tag { display: inline-block; font-size: 11px; font-family: ui-monospace, monospace; color: var(--accent); }
</style>
</head>
<body>
<header>
<span class="brand"><span class="dot"></span>Next Tabbed Tool</span>
<nav id="tabs"></nav>
</header>
<main>
<section class="view" data-view="overview">
<h1>Overview</h1>
<p>
One iframe, many hub docks. This SPA hosts its own internal views
(<span class="tag">Overview · Components · Timeline · Settings</span>)
and exposes each as a first-class hub dock that shares
<em>this single iframe</em>. Switching docks soft-navigates here — no
reload — via the <code>devframe:frame-nav</code> postMessage protocol.
</p>
<div class="grid">
<div class="card"><b>Shared frame</b><span>frameId · next-tabbed-tool</span></div>
<div class="card"><b>Transport</b><span>window.postMessage</span></div>
<div class="card"><b>Docks</b><span>client-only, from manifest</span></div>
<div class="card"><b>Nav</b><span>bidirectional</span></div>
</div>
</section>

<section class="view" data-view="components">
<h1>Components</h1>
<p>A stand-in view. Selecting the <b>Components</b> dock in the hub posts
<code>navigate</code> to this frame, which routes here client-side.</p>
<div class="grid">
<div class="card"><b>&lt;Button /&gt;</b><span>12 usages</span></div>
<div class="card"><b>&lt;Dialog /&gt;</b><span>4 usages</span></div>
<div class="card"><b>&lt;Tabs /&gt;</b><span>7 usages</span></div>
</div>
</section>

<section class="view" data-view="timeline">
<h1>Timeline</h1>
<p>Navigate <em>inside</em> this frame (the tab bar above) and watch the
hub's active dock follow — the frame posts <code>navigated</code> back.</p>
<ol>
<li>10:02 — build started</li>
<li>10:02 — 214 modules transformed</li>
<li>10:03 — page reload</li>
</ol>
</section>

<section class="view" data-view="settings">
<h1>Settings</h1>
<p>The manifest each tab reports (<code>title</code>, <code>icon</code>,
<code>navTarget</code>) becomes a client-only dock in the hub — never
written to the <code>devframe:docks</code> shared state.</p>
<div class="grid">
<div class="card"><b>Theme</b><span>follows OS</span></div>
<div class="card"><b>Telemetry</b><span>off</span></div>
</div>
</section>
</main>

<script>
// ── The tabbed tool's own tiny router ────────────────────────────────
const TABS = [
{ id: 'overview', title: 'Overview', icon: 'ph:house-duotone', navTarget: { path: '/overview' } },
{ id: 'components', title: 'Components', icon: 'ph:puzzle-piece-duotone', navTarget: { path: '/components' } },
{ id: 'timeline', title: 'Timeline', icon: 'ph:clock-duotone', navTarget: { path: '/timeline' } },
{ id: 'settings', title: 'Settings', icon: 'ph:gear-duotone', navTarget: { path: '/settings' } },
]
const pathToTab = path => (path || '/overview').replace(/^#?\/?/, '') || 'overview'
let current = pathToTab(location.hash.slice(1))
if (!TABS.some(t => t.id === current)) current = 'overview'

const navEl = document.getElementById('tabs')
navEl.innerHTML = TABS.map(t => `<button data-tab="${t.id}">${t.title}</button>`).join('')

function render() {
for (const el of document.querySelectorAll('.view'))
el.dataset.active = String(el.dataset.view === current)
for (const b of navEl.querySelectorAll('button'))
b.setAttribute('aria-current', String(b.dataset.tab === current))
}

// ── The postMessage nav shim (devframe:frame-nav protocol) ───────────
const CHANNEL = 'devframe:frame-nav'
const VERSION = 1
const FRAME_ID = 'next-tabbed-tool'
// Best-effort parent origin so the first (unsolicited) `ready` can be sent
// even if the host's `hello` is missed during iframe boot.
let hostOrigin
= (location.ancestorOrigins && location.ancestorOrigins[0])
|| (document.referrer ? new URL(document.referrer).origin : '*')

function post(message) {
parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, hostOrigin)
}
const manifest = () => TABS.map(t => ({ id: t.id, title: t.title, icon: t.icon, navTarget: t.navTarget }))

function setView(id, fromHost) {
if (!TABS.some(t => t.id === id)) return
current = id
location.hash = `/${id}`
render()
// Report internal navigation so the host highlights the matching dock.
// When the host itself asked us to navigate, its idempotent guard makes
// this echo a no-op.
if (!fromHost) post({ type: 'navigated', tabId: id })
}

addEventListener('message', (e) => {
const d = e.data
if (!d || d.channel !== CHANNEL || d.v !== VERSION || d.from !== 'host') return
if (d.frameId && d.frameId !== FRAME_ID) return
hostOrigin = e.origin || hostOrigin
if (d.type === 'hello')
post({ type: 'ready', tabs: manifest(), current })
else if (d.type === 'navigate')
setView(pathToTab(d.navTarget && d.navTarget.path), true)
})

// The user clicking a tab inside this frame drives the loop the other way.
navEl.addEventListener('click', (e) => {
const b = e.target.closest('button[data-tab]')
if (b) setView(b.dataset.tab, false)
})

render()
// Announce ourselves on load (the host also sends `hello` — either wins).
post({ type: 'ready', tabs: manifest(), current })
</script>
</body>
</html>
8 changes: 8 additions & 0 deletions examples/minimal-next-devframe-hub/src/client/app/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ const ICON_CLASS: Record<string, string> = {
'ph:rocket-duotone': 'i-ph-rocket-duotone',
'ph:wrench-duotone': 'i-ph-wrench-duotone',
'ph:plug-duotone': 'i-ph-plug-duotone',
'ph:layout-duotone': 'i-ph-layout-duotone',
'ph:note-pencil-duotone': 'i-ph-note-pencil-duotone',
'ph:squares-four-duotone': 'i-ph-squares-four-duotone',
'ph:house-duotone': 'i-ph-house-duotone',
'ph:puzzle-piece-duotone': 'i-ph-puzzle-piece-duotone',
'ph:clock-duotone': 'i-ph-clock-duotone',
'ph:gear-duotone': 'i-ph-gear-duotone',
'ph:sliders-horizontal-duotone': 'i-ph-sliders-horizontal-duotone',
}

/**
Expand Down
Loading
Loading