diff --git a/examples/hub-next/src/client/app/page.tsx b/examples/hub-next/src/client/app/page.tsx
index df9ded24..fc2e80a4 100644
--- a/examples/hub-next/src/client/app/page.tsx
+++ b/examples/hub-next/src/client/app/page.tsx
@@ -6,6 +6,7 @@ import type {
DevframeDockEntry,
DevframeMessageEntry,
DevframeTerminalSession,
+ DevframeViewBuiltin,
DevframeViewIframe,
} from '@devframes/hub/types'
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
@@ -55,14 +56,21 @@ function isIframeDock(d: DevframeDockEntry): d is IframeDock {
return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
}
-// Dock types this shell renders natively (or that carry no panel view of
-// their own). Everything else routes through the hub's dock-renderer
-// registry - the local React renderer registered at boot, or a prebuilt
-// module from the hub's renderer manifest - and a type nothing covers shows
-// the missing-renderer fallback.
-const NATIVE_TYPES = new Set(['action', 'launcher', 'group', '~builtin'])
+function isBuiltinDock(d: DevframeDockEntry): d is DevframeViewBuiltin {
+ return d.type === '~builtin'
+}
+
+// Dock types this shell renders inline, with no tab of their own: an `action`
+// fires its client script from a bar button, a `launcher` shows a
+// call-to-action, and a `group` collapses its members into a popover. Every
+// other type gets a tab in the rail - an iframe dock, a `~builtin` reserved
+// view (rendered natively by `SettingsPanel`, e.g. Settings), or a custom type
+// routed through the hub's dock-renderer registry (the local React renderer
+// registered at boot, or a prebuilt module from the hub's renderer manifest),
+// falling back to the missing-renderer message when nothing covers it.
+const NO_TAB_TYPES = new Set(['action', 'launcher', 'group'])
function isRenderableDock(d: DevframeDockEntry): boolean {
- return isIframeDock(d) || !NATIVE_TYPES.has(d.type)
+ return !NO_TAB_TYPES.has(d.type)
}
// One iframe is kept alive per `frameId` (shared-frame docks) or per dock id
@@ -254,6 +262,38 @@ function DockIcon({ entry }: { entry: DevframeDockEntry }) {
return {initial}
}
+// The viewer's own native view for the reserved `~settings` id - the hub
+// registers no UI for `~builtin` docks itself (see
+// docs/guide/build-your-own-hub-ui.md). A per-dock visibility toggle backed by
+// the same `devframe:user-settings` shared state every viewer reads.
+function SettingsPanel({ docks, hidden, onToggle }: {
+ docks: DevframeDockEntry[]
+ hidden: string[]
+ onToggle: (id: string, visible: boolean) => void
+}) {
+ const hideable = docks.filter(d => d.id !== '~settings' && !NO_TAB_TYPES.has(d.type))
+ return (
+
+
Settings
+
Toggle which docks appear in the rail.
+
+
+ )
+}
+
// ── authorization gate (interactive OTP) ────────────────────────────────────
// The hub gates every connection; this shell opts out of devframe's native
// `prompt()` (`simpleAuth: false`) and renders its own authorization view,
@@ -340,6 +380,7 @@ export default function Page() {
const [transport, setTransport] = useState(null)
const [transportPref, setTransportPref] = useState('auto')
const [docks, setDocks] = useState([])
+ const [docksHidden, setDocksHidden] = useState([])
const [commands, setCommands] = useState([])
const [messages, setMessages] = useState([])
const [terminals, setTerminals] = useState([])
@@ -446,11 +487,16 @@ export default function Page() {
const syncDocks = () => setDocks([...ctx.docks.entries])
const syncSelected = () => setSelectedDockId(ctx.docks.selectedId)
const renderCommands = () => setCommands([...(commandsState.value() ?? [])] as DevframeCommandEntry[])
+ // Toggling a dock's visibility in the Settings panel writes here -
+ // re-sync so hidden docks disappear from `renderableDocks` live.
+ const syncSettings = () => setDocksHidden([...ctx.docks.settings.value().docksHidden])
docksState.on('updated', syncDocks)
commandsState.on('updated', renderCommands)
+ ctx.docks.settings.on('updated', syncSettings)
syncDocks()
syncSelected()
renderCommands()
+ syncSettings()
// The frame-nav adapter registers/updates client-only member docks in
// response to a shared-frame anchor's manifest; re-sync (after it has
@@ -497,7 +543,10 @@ export default function Page() {
}
}, [])
- const renderableDocks = useMemo(() => docks.filter(isRenderableDock), [docks])
+ const renderableDocks = useMemo(
+ () => docks.filter(d => isRenderableDock(d) && (d.id === '~settings' || !docksHidden.includes(d.id))),
+ [docks, docksHidden],
+ )
// Wire each dock's state once so a selection change - from a click, or from
// the frame-nav adapter reacting to in-frame navigation - updates the UI.
@@ -527,6 +576,7 @@ export default function Page() {
const selectedDock = renderableDocks.find(d => d.id === selectedDockId) ?? null
const selectedIsIframe = selectedDock ? isIframeDock(selectedDock) : false
+ const selectedIsBuiltin = selectedDock ? isBuiltinDock(selectedDock) : false
// Keep-alive iframe pool: ensure + show the iframe for the selected dock's
// frame, hide the rest. Creating an iframe hands it to the client host
@@ -572,7 +622,7 @@ export default function Page() {
const host = hostRef.current
const dock = selectedDock
const stage = panelRef.current
- if (!host || !dock || isIframeDock(dock) || !stage)
+ if (!host || !dock || isIframeDock(dock) || isBuiltinDock(dock) || !stage)
return
let alive = true
let dispose: (() => void) | undefined
@@ -606,7 +656,7 @@ export default function Page() {
container.remove()
setPanelFallback(null)
}
- }, [selectedDockId, selectedIsIframe])
+ }, [selectedDockId, selectedIsIframe, selectedIsBuiltin])
async function ping() {
if (!rpcRef.current)
@@ -669,13 +719,34 @@ export default function Page() {
shown/hidden on switch so shared-frame tabs soft-navigate. */}
{/* Renderer docks (json-render, …) mount here via the client host. */}
-
- {panelFallback && selectedDock && !selectedIsIframe && (
+
+ {panelFallback && selectedDock && !selectedIsIframe && !selectedIsBuiltin && (
{panelFallback.message}
{panelFallback.hint}
)}
+ {selectedDock && selectedIsBuiltin && (
+ selectedDock.id === '~settings'
+ ? (
+ hostRef.current?.context.docks.settings.mutate((state) => {
+ state.docksHidden = visible
+ ? state.docksHidden.filter(hiddenId => hiddenId !== id)
+ : [...state.docksHidden, id]
+ })}
+ />
+ )
+ : (
+
+ Unknown built-in view “
+ {selectedDock.id}
+ ”
+
+ )
+ )}
diff --git a/examples/hub-vite/src/client/main.ts b/examples/hub-vite/src/client/main.ts
index 636fdb39..e1269e31 100644
--- a/examples/hub-vite/src/client/main.ts
+++ b/examples/hub-vite/src/client/main.ts
@@ -3,6 +3,7 @@ import type {
DevframeDockEntry,
DevframeMessageEntry,
DevframeTerminalSession,
+ DevframeViewBuiltin,
DevframeViewIframe,
} from '@devframes/hub/types'
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
@@ -120,14 +121,22 @@ function isIframeDock(d: DevframeDockEntry): d is DevframeViewIframe & { url: st
return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string'
}
-// Dock types this shell renders natively (or that carry no panel view). Every
-// other type routes through the client host's renderer registry - a renderer
-// registered locally or served by the hub's renderer manifest (e.g.
-// `json-render`) - and a type nothing covers shows the missing-renderer
-// fallback in `mountRenderer`.
-const NATIVE_TYPES = new Set(['action', 'launcher', 'group', '~builtin'])
+function isBuiltinDock(d: DevframeDockEntry): d is DevframeViewBuiltin {
+ return d.type === '~builtin'
+}
+
+// Dock types this shell renders inline, with no tab of their own: an `action`
+// fires its client script from a bar button, a `launcher` shows a
+// call-to-action, and a `group` collapses its members into a popover. Every
+// other type gets a tab in the rail - an iframe dock, a `~builtin` reserved
+// view (rendered natively in `mountBuiltinPanel`, e.g. Settings), or a custom
+// type routed through the client host's renderer registry (a renderer
+// registered locally or served by the hub's renderer manifest, e.g.
+// `json-render`), falling back to the missing-renderer message in
+// `mountRenderer` when nothing covers it.
+const NO_TAB_TYPES = new Set(['action', 'launcher', 'group'])
function isRenderableDock(d: DevframeDockEntry): boolean {
- return isIframeDock(d) || !NATIVE_TYPES.has(d.type)
+ return !NO_TAB_TYPES.has(d.type)
}
// ── client-only dock content (synthesized in the browser) ───────────────────
@@ -435,9 +444,61 @@ function wireDockRail(host: Awaited>
`
}
+ // The viewer's own native view for a reserved `~builtin` id - the hub
+ // registers no UI for these itself (see
+ // docs/guide/build-your-own-hub-ui.md). This reference shell recognizes
+ // `~settings`: a per-dock visibility toggle backed by the same
+ // `devframe:user-settings` shared state every viewer reads.
+ function mountBuiltinPanel(entry: DevframeViewBuiltin): void {
+ if (mounted) {
+ mounted.dispose()
+ mounted = null
+ }
+ el.panel.hidden = false
+ if (entry.id !== '~settings') {
+ el.panel.innerHTML = `Unknown built-in view “${entry.id}”
`
+ return
+ }
+ renderSettingsPanel()
+ }
+
+ function renderSettingsPanel(): void {
+ const hidden = docksCtx.settings.value().docksHidden
+ const hideable = docksCtx.entries.filter(d => d.id !== '~settings' && !NO_TAB_TYPES.has(d.type))
+ el.panel.innerHTML = `
+
Settings
+
Toggle which docks appear in the rail.
+
+
`
+ for (const input of el.panel.querySelectorAll('[data-settings-dock-id]')) {
+ input.addEventListener('change', () => {
+ const id = input.dataset.settingsDockId!
+ docksCtx.settings.mutate((state) => {
+ state.docksHidden = input.checked
+ ? state.docksHidden.filter(hiddenId => hiddenId !== id)
+ : [...state.docksHidden, id]
+ })
+ })
+ }
+ }
+
function showSelection(list: DevframeDockEntry[]): void {
const entry = docksCtx.selectedId ? list.find(d => d.id === docksCtx.selectedId) ?? null : null
+ // A `~builtin` reserved view owns the panel through the viewer's own
+ // native rendering, never the renderer registry.
+ if (entry && isBuiltinDock(entry)) {
+ for (const frame of iframes.values()) frame.hidden = true
+ mountBuiltinPanel(entry)
+ return
+ }
+
// A renderer dock (json-render, …) owns the panel; anything else (an
// iframe dock, or no selection) hides the panel and disposes any mount.
if (entry && !isIframeDock(entry)) {
@@ -459,7 +520,10 @@ function wireDockRail(host: Awaited>
// (a click, or the frame-nav adapter reacting to in-frame navigation).
const wired = new Set()
function render(): void {
- const list = docksCtx.entries.filter(isRenderableDock)
+ const hidden = docksCtx.settings.value().docksHidden
+ const list = docksCtx.entries.filter(entry =>
+ isRenderableDock(entry) && (entry.id === '~settings' || !hidden.includes(entry.id)),
+ )
if (!docksCtx.selectedId && list.length > 0)
void docksCtx.switchEntry(list[0].id)
@@ -485,6 +549,9 @@ function wireDockRail(host: Awaited>
void host.context.rpc.sharedState
.get('devframe:docks', { initialValue: [] })
.then(docks => docks.on('updated', render))
+ // Toggling a dock's visibility in the Settings panel writes here - re-render
+ // the rail (and the panel's own checkbox list) so it takes effect live.
+ docksCtx.settings.on('updated', render)
// The frame-nav adapter registers client-only member docks in response to a
// shared-frame anchor's manifest; re-render after it reconciles (microtask).
window.addEventListener('message', (event) => {
diff --git a/examples/hub-vite/vite.config.ts b/examples/hub-vite/vite.config.ts
index faabdb20..339aad9a 100644
--- a/examples/hub-vite/vite.config.ts
+++ b/examples/hub-vite/vite.config.ts
@@ -166,6 +166,18 @@ export default defineConfig({
handler: () => 'pong',
})
+ // The hub synthesizes no built-in docks - a high-level integration
+ // registers the viewer's native views it wants, declaring the
+ // `~builtin` category itself so this Settings tab groups and sorts
+ // last.
+ context.docks.register({
+ type: '~builtin',
+ id: '~settings',
+ title: 'Settings',
+ icon: 'ph:gear-duotone',
+ category: '~builtin',
+ })
+
// Dogfood the opt-in JSON-render hub integration: author a view on the
// hub context and project it onto a `json-render` dock, rendered by the
// manifest module above.