From 09c1dc1e42c0c6900c12e3fdee3b7c4828a8349e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 02:13:50 +0000 Subject: [PATCH 1/5] docs: design for shared-iframe docks with soft navigation Add a design proposal for hosting a foreign multi-tab devtool (e.g. Nuxt DevTools) as first-class hub docks that share one live iframe and switch views via client-side soft navigation. Splits the design into a hub-owned data model (a frameId shared-iframe axis orthogonal to groupId, plus client-only dock registration) and a versioned, origin-locked host<->iframe postMessage protocol for the live nav loop. The embedded app ships only a small postMessage shim and takes no hub/RPC dependency. Includes the viewer UI-behavior contract for Vite DevTools, the adapter behavior, fallback semantics, message tables, sequences, and TS type sketches. --- plans/shared-iframe-soft-nav.md | 427 ++++++++++++++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 plans/shared-iframe-soft-nav.md diff --git a/plans/shared-iframe-soft-nav.md b/plans/shared-iframe-soft-nav.md new file mode 100644 index 0000000..dd0e4e4 --- /dev/null +++ b/plans/shared-iframe-soft-nav.md @@ -0,0 +1,427 @@ +# Design: Shared-iframe docks with soft navigation + +**Status:** Proposal / design hand-off +**Audience:** Vite DevTools (`@vitejs/devtools-kit`) UI implementers + `@devframes/hub` maintainers +**Driving use case:** hosting a foreign multi-tab devtool (e.g. **Nuxt DevTools**) as a set of first-class hub docks that all share **one** live iframe and switch between views via **client-side (soft) navigation**, for a unified experience. + +--- + +## 1. Summary + +A large integration like Nuxt DevTools has many internal tabs (Modules, Timeline, Plugins, …) inside a single SPA with its own router. Today devframe mounts an integration as exactly **one** iframe dock ([`packages/hub/src/node/mount-devframe.ts:89`](../packages/hub/src/node/mount-devframe.ts)); the integration's own tabs are invisible to the hub. We want each of those tabs to appear as its **own hub dock**, participating in the dock bar, grouping, ordering, pinning, badges, and the command palette — while **reusing one already-booted iframe** and switching views **without reloading**. + +The design splits cleanly into two concerns, mirroring the existing a11y precedent (RPC carries the durable data model; a separate direct channel carries the live loop — see [`plugins/a11y/src/shared/protocol.ts:7`](../plugins/a11y/src/shared/protocol.ts)): + +- **Data model (hub-owned):** a shared-iframe axis (`frameId`) on iframe docks, plus the ability for the **viewer client** to register **client-only docks** (docks that live only in this client's dock context, never projected to the server's `devframe:docks` shared state). +- **Live loop (host↔iframe `postMessage`):** a small, versioned, origin-locked protocol carrying a **tab manifest**, **navigate** commands, **navigated** reports, and a **ready** handshake. The embedded app implements only this shim — it need **not** be a devframe and takes **no** hub/RPC dependency. + +The embedded app stays maximally decoupled: **~30 lines of `postMessage` shim** and it slots into the hub as a group of managed docks with instant soft-nav between them, working cross-origin and even in static builds (no server dependency for the loop). + +--- + +## 2. Terminology + +| Term | Meaning | +|---|---| +| **Shared frame** | One ` - - +
+ +
+ +
diff --git a/examples/minimal-vite-devframe-hub/spa/tabbed-tool/index.html b/examples/minimal-vite-devframe-hub/spa/tabbed-tool/index.html new file mode 100644 index 0000000..6064dfe --- /dev/null +++ b/examples/minimal-vite-devframe-hub/spa/tabbed-tool/index.html @@ -0,0 +1,175 @@ + + + + + + Tabbed Tool + + + +
+ Tabbed Tool + +
+
+
+

Overview

+

+ One iframe, many hub docks. This SPA hosts its own internal views + (Overview · Components · Timeline · Settings) + and exposes each as a first-class hub dock that shares + this single iframe. Switching docks soft-navigates here — no + reload — via the devframe:frame-nav postMessage protocol. +

+
+
Shared frameframeId · tabbed-tool
+
Transportwindow.postMessage
+
Docksclient-only, from manifest
+
Navbidirectional
+
+
+ +
+

Components

+

A stand-in view. Selecting the Components dock in the hub posts + navigate to this frame, which routes here client-side.

+
+
<Button />12 usages
+
<Dialog />4 usages
+
<Tabs />7 usages
+
+
+ +
+

Timeline

+

Navigate inside this frame (the tab bar above) and watch the + hub's active dock follow — the frame posts navigated back.

+
    +
  1. 10:02 — build started
  2. +
  3. 10:02 — 214 modules transformed
  4. +
  5. 10:03 — page reload
  6. +
+
+ +
+

Settings

+

The manifest each tab reports (title, icon, + navTarget) becomes a client-only dock in the hub — never + written to the devframe:docks shared state.

+
+
Themefollows OS
+
Telemetryoff
+
+
+
+ + + + diff --git a/examples/minimal-vite-devframe-hub/src/client/icons.ts b/examples/minimal-vite-devframe-hub/src/client/icons.ts index b79a465..f960846 100644 --- a/examples/minimal-vite-devframe-hub/src/client/icons.ts +++ b/examples/minimal-vite-devframe-hub/src/client/icons.ts @@ -13,6 +13,13 @@ const ICON_CLASS: Record = { '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', } /** diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index 72b2655..78cb5c5 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -5,7 +5,7 @@ import type { DevframeTerminalSession, DevframeViewIframe, } from '@devframes/hub/types' -import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { connectDevframe, createDevframeClientHost, FRAME_NAV_CHANNEL } from '@devframes/hub/client' import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' import { iconClass } from './icons' import 'virtual:uno.css' @@ -19,10 +19,9 @@ const commandsEl = document.querySelector('#commands')! const messagesEl = document.querySelector('#messages')! const terminalsEl = document.querySelector('#terminals')! const pingBtn = document.querySelector('#ping')! -const iframeEl = document.querySelector('#dock-iframe')! +const stageEl = document.querySelector('#dock-stage')! const panelEl = document.querySelector('#dock-panel')! -let selectedDockId: string | null = null // Disposer for the currently mounted renderer dock (e.g. json-render). let disposePanel: (() => void) | null = null @@ -116,70 +115,117 @@ async function main() { clientDock.update({ badge: host.context.clientType }) // 1. Docks — the merged list from the client host: server docks (projected - // from `devframe:docks` shared state) plus the client-only dock above. We - // still subscribe to the shared state to re-render when server docks change. + // from `devframe:docks` shared state) plus the client-only docks above and + // any the frame-nav adapter materializes for a shared-frame anchor. const docks = await rpc.sharedState.get( 'devframe:docks', { initialValue: [] }, ) - // The dock currently mounted into the viewport (iframe or renderer panel). - let mountedDockId: string | null = null + // Selection is owned by the client host (`switchEntry`), not a local variable. + // That is what lets the frame-nav adapter hear a dock's `entry:activated` and + // drive soft-navigation for shared-frame member docks. + const docksCtx = host.context.docks - async function applySelection(list: DevframeDockEntry[]): Promise { - if (selectedDockId === mountedDockId) - return - mountedDockId = selectedDockId - // Tear down any active renderer mount (json-render) before switching. - disposePanel?.() - disposePanel = null - - const entry = list.find(d => d.id === selectedDockId) ?? null - if (!entry) { - iframeEl.hidden = false - iframeEl.src = 'about:blank' - panelEl.hidden = true - return + // Keep-alive iframe pool: one iframe per `frameId` (shared-frame docks) or per + // dock id (plain iframe docks). Switching docks toggles visibility instead of + // reloading — and shared-frame member docks reuse the same element, soft- + // navigating via the adapter rather than re-`src`-ing. + const iframePool = new Map() + const frameKeyOf = (e: DevframeDockEntry): string => + (e as DevframeViewIframe).frameId ?? e.id + let mountedRendererId: string | null = null + + function ensureIframe(entry: DevframeDockEntry & { url: string }): HTMLIFrameElement { + const key = frameKeyOf(entry) + let el = iframePool.get(key) + if (!el) { + el = document.createElement('iframe') + el.title = entry.title + el.className = 'absolute inset-0 block h-full w-full border-0 bg-base' + el.hidden = true + el.src = entry.url + stageEl.appendChild(el) + iframePool.set(key, el) + // Hand the element to the client host: setting it on the dock's + // DockEntryState and emitting `dom:iframe:mounted` is the seam that lets + // the frame-nav adapter attach to a `subTabs` anchor (plan §6.2). + const state = docksCtx.getStateById(entry.id) + if (state) { + state.domElements.iframe = el + state.events.emit('dom:iframe:mounted', el) + } } - if (isIframeDock(entry)) { + return el + } + + async function showSelection(list: DevframeDockEntry[]): Promise { + const entry = docksCtx.selectedId + ? list.find(d => d.id === docksCtx.selectedId) ?? null + : null + + if (!entry || isIframeDock(entry)) { + if (mountedRendererId) { + disposePanel?.() + disposePanel = null + mountedRendererId = null + } panelEl.hidden = true - panelEl.innerHTML = '' - iframeEl.hidden = false - iframeEl.src = entry.url + } + + if (entry && isIframeDock(entry)) { + const active = ensureIframe(entry) + for (const el of iframePool.values()) el.hidden = el !== active } else { + for (const el of iframePool.values()) el.hidden = true + } + + if (entry && !isIframeDock(entry) && mountedRendererId !== entry.id) { // A renderer dock (json-render): mount it into the panel via the client // host's renderer registry. The Vue app subscribes to the view's shared // state and updates live. - iframeEl.hidden = true - iframeEl.src = 'about:blank' + disposePanel?.() panelEl.hidden = false panelEl.innerHTML = '' + mountedRendererId = entry.id disposePanel = await host.context.renderers.mount(entry, panelEl) } } - const renderDocks = () => { - const list = host.context.docks.entries.filter(isRenderableDock) + // Subscribe each dock's state once, so a selection change (from a click, or + // from the frame-nav adapter reacting to in-frame navigation) re-renders. + const wired = new Set() + function wireSelection(list: DevframeDockEntry[]): void { + for (const entry of list) { + if (wired.has(entry.id)) + continue + const state = docksCtx.getStateById(entry.id) + if (!state) + continue + wired.add(entry.id) + state.events.on('entry:activated', render) + } + } - if (selectedDockId && !list.some(d => d.id === selectedDockId)) - selectedDockId = null - if (!selectedDockId && list.length > 0) - selectedDockId = list[0].id + function render(): void { + const list = docksCtx.entries.filter(isRenderableDock) + + if (!docksCtx.selectedId && list.length > 0) + void docksCtx.switchEntry(list[0].id) + + wireSelection(list) if (!list.length) { docksEl.innerHTML = '
  • No docks
  • ' - mountedDockId = null - disposePanel?.() - disposePanel = null - iframeEl.src = 'about:blank' + void showSelection(list) return } renderList(docksEl, list, d => - `
  • `) + `
  • `) - void applySelection(list) + void showSelection(list) } docksEl.addEventListener('click', (event) => { @@ -187,14 +233,21 @@ async function main() { if (!target) return const id = target.dataset.dockId - if (!id || id === selectedDockId) + if (!id || id === docksCtx.selectedId) return - selectedDockId = id - renderDocks() + void docksCtx.switchEntry(id) }) - docks.on('updated', renderDocks) - renderDocks() + docks.on('updated', render) + // The frame-nav adapter registers/updates client-only member docks in + // response to the anchor iframe's manifest; re-render (after the adapter has + // reconciled, hence the microtask) so those docks appear in the list. + window.addEventListener('message', (event) => { + const data = event.data as { channel?: string, from?: string } | undefined + if (data?.channel === FRAME_NAV_CHANNEL && data.from === 'frame') + queueMicrotask(render) + }) + render() // 2. Commands — read from `devframe:commands` shared state. const commands = await rpc.sharedState.get( diff --git a/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts b/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts new file mode 100644 index 0000000..01a165d --- /dev/null +++ b/examples/minimal-vite-devframe-hub/src/tabbed-tool.ts @@ -0,0 +1,35 @@ +import type { DevframeHubContext } from '@devframes/hub/node' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } + +/** + * A demo of **shared-iframe soft navigation**. This devframe is a single SPA + * with several internal views (Overview / Components / Timeline / Settings). + * Mounted as a `subTabs` anchor (see `vite.config.ts`), the hub's client host + * attaches its frame-nav adapter: the SPA's `postMessage` shim reports its tabs, + * each becomes a client-only hub dock sharing this one iframe, and switching + * docks soft-navigates inside it instead of reloading — a unified home for a + * multi-tab tool like Nuxt DevTools. + */ +export default defineDevframe({ + id: 'tabbed-tool', + name: 'Tabbed Tool', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: 'A multi-view SPA hosted as shared-iframe hub docks with soft navigation.', + icon: 'ph:squares-four-duotone', + basePath: '/__tabbed-tool/', + cli: { + distDir: fileURLToPath(new URL('../spa/tabbed-tool/', import.meta.url)), + }, + async setup(rawCtx) { + const ctx = rawCtx as unknown as DevframeHubContext + await ctx.messages.add({ + level: 'info', + message: 'Tabbed Tool mounted as a shared-iframe anchor', + description: 'Its tabs surface as client-only docks that soft-navigate one iframe.', + }) + }, +}) diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index 86a39bb..d9a2223 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,3 +1,4 @@ +import { mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' import codeServerDevframe from '@devframes/plugin-code-server' @@ -16,6 +17,7 @@ import { alias } from '../../alias' import demoDevframe from './src/devframe' import demoDevframeB from './src/devframe-b' import { minimalViteDevframeHub } from './src/minimal-vite-devframe-hub' +import tabbedToolDevframe from './src/tabbed-tool' export default defineConfig({ resolve: { alias }, @@ -81,7 +83,7 @@ export default defineConfig({ // Dogfood the opt-in JSON-render hub integration: author a view on the // hub context and project it onto a `json-render` dock. The client host // (src/client/main.ts) renders it via @devframes/json-render-ui. - onContextReady: (context) => { + onContextReady: async (context) => { const view = createDashboardView(context) context.docks.register(toJsonRenderDockEntry(view, { id: 'minimal-json-render', @@ -89,6 +91,19 @@ export default defineConfig({ icon: 'ph:layout-duotone', category: 'app', })) + + // Shared-iframe soft-navigation demo. mountDevframe serves the SPA and + // registers its iframe dock; the `dock` override marks it a `subTabs` + // anchor (a shared `frameId` + the postmessage protocol) so the client + // host attaches the frame-nav adapter, materializing one client-only + // dock per tab the SPA's shim reports — all sharing this one iframe. + await mountDevframe(context, tabbedToolDevframe, { + dock: { + category: 'app', + frameId: 'tabbed-tool', + subTabs: { protocol: 'postmessage' }, + }, + }) }, }), ], diff --git a/plans/shared-iframe-soft-nav.md b/plans/shared-iframe-soft-nav.md index f27080d..752dbf7 100644 --- a/plans/shared-iframe-soft-nav.md +++ b/plans/shared-iframe-soft-nav.md @@ -431,7 +431,8 @@ onTabsChanged(() => post({ type: 'manifest', tabs: tabsSnapshot(), current: curr - [x] Add `navTarget` + `subTabs` to `DevframeViewIframe`; add `NavTarget`, `FrameSubTabsConfig` ([`packages/hub/src/types/docks.ts`](../packages/hub/src/types/docks.ts)). - [x] Ship the frame-nav adapter (`attachFrameNavClient`, [`packages/hub/src/client/frame-nav.ts`](../packages/hub/src/client/frame-nav.ts)); auto-attach on `dom:iframe:mounted` for `subTabs` anchors ([`host.ts`](../packages/hub/src/client/host.ts)). - [x] Tests: handshake, manifest reconcile add/remove, echo-guard, internal-nav highlight, origin-lock, timeout→no-shim, active-removed→null ([`frame-nav.test.ts`](../packages/hub/src/client/__tests__/frame-nav.test.ts)). -- [ ] Follow-up: a worked example (wire a `subTabs` anchor + a demo shim in `examples/minimal-vite-devframe-hub`) and a Client Context guide section. +- [x] Worked examples: a `subTabs` "Tabbed Tool" anchor + a postMessage shim SPA, with keep-alive iframe pooling and `dom:iframe:mounted`, in both `examples/minimal-vite-devframe-hub` and `examples/minimal-next-devframe-hub`. +- [ ] Follow-up: a Client Context guide section. **Vite DevTools (UI contract):** - [ ] One kept-alive iframe per `frameId`; hide/show, never re-`src`. From 990fd746f1589461ebcd3c831c6293a271aaf93c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 05:52:52 +0000 Subject: [PATCH 5/5] docs: document shared-iframe soft navigation; tidy the plan Add a 'Shared-iframe soft navigation' section to the Client Context guide (the anchor's subTabs opt-in, the devframe:frame-nav postMessage protocol, the client-only member docks, and the viewer's dom:iframe:mounted seam), a frameId note + demo pointer in the Hub grouping guide, and a mention in the examples list. Refocus plans/shared-iframe-soft-nav.md now that the hub side has shipped: replace the status banner with a concise 'what shipped + where' list and turn the implementation checklist into the remaining downstream work (the Vite DevTools UI contract and the Nuxt shim). --- docs/guide/client-context.md | 34 ++++++++++++++++++++++++++ docs/guide/hub.md | 4 +++ plans/shared-iframe-soft-nav.md | 43 +++++++++++++++++---------------- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 293f976..cae0503 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -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 `:`), 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. diff --git a/docs/guide/hub.md b/docs/guide/hub.md index cda6ef7..4a588be 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -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: @@ -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 diff --git a/plans/shared-iframe-soft-nav.md b/plans/shared-iframe-soft-nav.md index 752dbf7..a4c266d 100644 --- a/plans/shared-iframe-soft-nav.md +++ b/plans/shared-iframe-soft-nav.md @@ -1,10 +1,19 @@ -# Design: Shared-iframe docks with soft navigation +# Shared-iframe docks with soft navigation -**Status:** Hub side implemented — hand-off spec for the Vite DevTools UI -**Audience:** Vite DevTools (`@vitejs/devtools-kit`) UI implementers + `@devframes/hub` maintainers **Driving use case:** hosting a foreign multi-tab devtool (e.g. **Nuxt DevTools**) as a set of first-class hub docks that all share **one** live iframe and switch between views via **client-side (soft) navigation**, for a unified experience. -> **Landed foundations.** [#129](https://github.com/devframes/devframe/pull/129) shipped client-only `docks.register()`/`docks.update()`; [#130](https://github.com/devframes/devframe/pull/130) reinterpreted a grouped entry's `category` as an in-group sub-category (outer bucket = the group's category) and made `framework` sort first. This spec and the hub-side implementation (types + the frame-nav adapter) build directly on both. +**Status — the hub side is shipped and documented.** This doc is the design record, plus the hand-off spec for the parts that live in other repos: the viewer UI contract (§6) and the embedded-app nav shim (§10). + +Shipped in `@devframes/hub`: + +- **Data model** — `frameId` + `navTarget` + `subTabs` on `DevframeViewIframe` ([`packages/hub/src/types/docks.ts`](../packages/hub/src/types/docks.ts)). +- **Client-only docks** — `docks.register()` / `update()` on the client context ([#129](https://github.com/devframes/devframe/pull/129)). +- **Frame-nav adapter** — `attachFrameNavClient`, auto-attached by `createDevframeClientHost` ([`packages/hub/src/client/frame-nav.ts`](../packages/hub/src/client/frame-nav.ts)). +- **Docs** — [Shared-iframe soft navigation](../docs/guide/client-context.md#shared-iframe-soft-navigation). +- **Examples** — the "Tabbed Tool" in both `examples/minimal-{vite,next}-devframe-hub`. +- **Tests** — [`packages/hub/src/client/__tests__/frame-nav.test.ts`](../packages/hub/src/client/__tests__/frame-nav.test.ts). + +Remaining work lives downstream (§11): the Vite DevTools UI contract and the Nuxt DevTools shim. --- @@ -424,24 +433,16 @@ onTabsChanged(() => post({ type: 'manifest', tabs: tabsSnapshot(), current: curr --- -## 11. Implementation checklist +## 11. Remaining work (downstream repos) -**`@devframes/hub` (data model + adapter):** -- [x] Client-only `docks.register()`/`update()` on the client `DocksEntriesContext` — **#129**. -- [x] Add `navTarget` + `subTabs` to `DevframeViewIframe`; add `NavTarget`, `FrameSubTabsConfig` ([`packages/hub/src/types/docks.ts`](../packages/hub/src/types/docks.ts)). -- [x] Ship the frame-nav adapter (`attachFrameNavClient`, [`packages/hub/src/client/frame-nav.ts`](../packages/hub/src/client/frame-nav.ts)); auto-attach on `dom:iframe:mounted` for `subTabs` anchors ([`host.ts`](../packages/hub/src/client/host.ts)). -- [x] Tests: handshake, manifest reconcile add/remove, echo-guard, internal-nav highlight, origin-lock, timeout→no-shim, active-removed→null ([`frame-nav.test.ts`](../packages/hub/src/client/__tests__/frame-nav.test.ts)). -- [x] Worked examples: a `subTabs` "Tabbed Tool" anchor + a postMessage shim SPA, with keep-alive iframe pooling and `dom:iframe:mounted`, in both `examples/minimal-vite-devframe-hub` and `examples/minimal-next-devframe-hub`. -- [ ] Follow-up: a Client Context guide section. +The `@devframes/hub` side is complete (see the status list at the top). What's left lives in other repos: -**Vite DevTools (UI contract):** -- [ ] One kept-alive iframe per `frameId`; hide/show, never re-`src`. -- [ ] Populate `domElements.iframe` + emit `dom:iframe:mounted` on the anchor entry. -- [ ] Render member docks (grouped or not) through the normal selection path. +**Vite DevTools — the UI contract (§6):** +- One kept-alive iframe per `frameId`; hide/show, never re-`src`. +- Populate `domElements.iframe` + emit `dom:iframe:mounted` on the anchor entry. +- Render member docks (grouped or not) through the normal selection path. -**Embedded app (Nuxt DevTools):** -- [ ] Ship the `postMessage` nav shim (§10). - ---- +**Nuxt DevTools — the embedded app:** +- Ship the `postMessage` nav shim (§10). -*This design was produced with the help of an agent, from a structured requirements interview.* +Both minimal hub examples already implement the viewer contract in miniature (vanilla DOM and React), so they double as a reference for the Vite DevTools port.