From d73fe67458701d0115f978f9a55cf1109ee66da2 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 14 Aug 2026 07:21:57 +0000 Subject: [PATCH 01/10] feat(devframe,hub,hub-ui): static connection-meta configs + plugin-declared dock layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConnectionMeta.configs — an augmentable, boot-time, read-only-from-the- browser config channel, distinct from the mutable/shared-state-synced ctx.settings and devframe:user-settings. Each key is contributed via declaration merging against DevframeConnectionConfigsRegistry. Ports vitejs/devtools#515 (plugin-declared dock ordering, layout, and window defaults) on top of it: DevframeDockDefaults gains categoryOrder, maxVisibleItems, defaultMode, and defaultPosition. The hub aggregates them across every installed devframe (last-installed wins per scalar, categoryOrder shallow-merged) into ConnectionMeta.configs.dock, folded into the dock bar's category sort, the floating dock's inline-item capacity, and a first-run visitor's mode/position seed. hub-ui's branding moves off the ad-hoc branding.json asset route onto configs.ui.branding, delivered through the one connection handshake the dock already performs instead of a separate fetch. createUi({ branding }) keeps its existing signature. Built with the help of an agent. --- docs/guide/build-your-own-hub-ui.md | 10 ++++ docs/guide/hub-initiate.md | 2 + examples/hub-hono-minimal/src/app.ts | 6 +- examples/hub-next-minimal/src/client/hub.ts | 7 ++- examples/hub-nitro-minimal/hub.ts | 6 +- .../hub-rsbuild-minimal/rsbuild.config.ts | 7 ++- examples/hub-vite-minimal/vite.config.ts | 8 +-- packages/devframe/src/types/context.ts | 32 +++++++++++ packages/devframe/src/types/devframe.ts | 30 ++++++++++ .../src/client/components/DockEmbedded.ts | 2 + packages/hub-ui/src/client/embedded/index.ts | 28 ++++++++-- packages/hub-ui/src/client/standalone/main.ts | 8 ++- packages/hub-ui/src/client/state/branding.ts | 42 +++++--------- packages/hub-ui/src/client/state/context.ts | 9 ++- packages/hub-ui/src/index.ts | 17 ++++-- .../hub/src/client/__tests__/host.test.ts | 17 ++++++ packages/hub/src/client/docks.ts | 8 +++ packages/hub/src/client/host.ts | 25 +++++++-- .../hub/src/node/__tests__/host-docks.test.ts | 28 ++++++++++ .../hub/src/node/__tests__/initiate.test.ts | 56 +++++++++++++++++++ .../node/__tests__/install-devframe.test.ts | 33 +++++++++++ packages/hub/src/node/host-docks.ts | 14 +++++ packages/hub/src/node/initiate.ts | 39 ++++++++++--- packages/hub/src/node/install-devframe.ts | 11 +++- packages/hub/src/types/docks.ts | 32 +++++++++++ .../@devframes/hub/client.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 8 +++ .../@devframes/hub/initiate.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/node.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 1 + .../tsnapi/devframe/index.snapshot.d.ts | 6 ++ .../tsnapi/devframe/types.snapshot.d.ts | 1 + 32 files changed, 423 insertions(+), 74 deletions(-) diff --git a/docs/guide/build-your-own-hub-ui.md b/docs/guide/build-your-own-hub-ui.md index 83258683..58878cc4 100644 --- a/docs/guide/build-your-own-hub-ui.md +++ b/docs/guide/build-your-own-hub-ui.md @@ -16,6 +16,7 @@ interface DevframeHubUi { viewer?: { distDir: string } // a standalone SPA served at the hub base embedded?: { entry: string } // a self-contained bootstrap at embedded.js assets?: Record string | Uint8Array> // extra UI-owned files + settings?: () => Record // static config, published as ConnectionMeta.configs.ui } ``` @@ -24,6 +25,15 @@ prebuilt assets: the viewer SPA is built with relative asset paths, and the embedded entry is one self-contained ES module that mounts your dock into any host page. +`settings` publishes whatever you return verbatim as +`ConnectionMeta.configs.ui` — the reference UI's `createUi({ branding })` uses +it to deliver `{ branding }`, read by the dock from the one connection +handshake it already performs, rather than a separate fetched file. The hub +never interprets this object; it's a policy-free pass-through to your own +client code. It's the read-only counterpart to `assets`: reach for `settings` +for small, structured, boot-time config, and `assets` for arbitrary served +files. + ## The client contracts A viewer renders from the hub's shared state and drives it through diff --git a/docs/guide/hub-initiate.md b/docs/guide/hub-initiate.md index 2ae4ab67..9984548d 100644 --- a/docs/guide/hub-initiate.md +++ b/docs/guide/hub-initiate.md @@ -56,6 +56,8 @@ The hub is headless — `DevframeHubUi` is pure data, and whoever fills it decid interface DevframeHubUi { viewer?: { distDir: string } // a standalone SPA served at the namespace root embedded?: { entry: string } // a prebuilt bootstrap served at embedded.js + assets?: Record string | Uint8Array> // extra UI-owned files + settings?: () => Record // static config, published as ConnectionMeta.configs.ui } ``` diff --git a/examples/hub-hono-minimal/src/app.ts b/examples/hub-hono-minimal/src/app.ts index ea2846a3..8096a056 100644 --- a/examples/hub-hono-minimal/src/app.ts +++ b/examples/hub-hono-minimal/src/app.ts @@ -39,9 +39,9 @@ export const hub: HubInstance = globalRef.__hubHonoMinimal ??= initHub({ createAssetsDevframe({ watch: false }), ], // Rebrand the reference UI to Hono's own orange — one field, no CSS: - // `createUi`'s `branding` option publishes `branding.json`, which the dock - // fetches at boot and feeds into `--devframe-primary` (see - // `@devframes/hub-ui`'s `primary-ramp.css`). + // `createUi`'s `branding` option publishes `ConnectionMeta.configs.ui.branding`, + // which the dock reads at connect time and feeds into `--devframe-primary` + // (see `@devframes/hub-ui`'s `primary-ramp.css`). ui: createUi({ branding: { primaryColor: '#e36002', productName: 'Devframes on Hono' } }), // Gate with devframe's interactive OTP (the default). The hub prints a // 6-digit code + magic link on startup, and the reference UI's authorization diff --git a/examples/hub-next-minimal/src/client/hub.ts b/examples/hub-next-minimal/src/client/hub.ts index 4c33e5f8..38d95bcc 100644 --- a/examples/hub-next-minimal/src/client/hub.ts +++ b/examples/hub-next-minimal/src/client/hub.ts @@ -73,9 +73,10 @@ async function loadHub(): Promise { // `@devframes/next/hub` runs the socket on a side-car (Next routes can't // accept WS upgrades). This host overrides the default UI slot to rebrand // the reference viewer to Next.js/Vercel's monochrome black — one field, no - // CSS: `createUi`'s `branding` option publishes `branding.json`, which the - // dock fetches at boot and feeds into `--devframe-primary` (see - // `@devframes/hub-ui`'s `primary-ramp.css`). + // CSS: `createUi`'s `branding` option publishes + // `ConnectionMeta.configs.ui.branding`, which the dock reads at connect + // time and feeds into `--devframe-primary` (see `@devframes/hub-ui`'s + // `primary-ramp.css`). return createNextDevframeHub({ devframes, ui: (hubUi.createUi as typeof CreateUi)({ branding: { primaryColor: '#000000', productName: 'Devframes on Next.js' } }), diff --git a/examples/hub-nitro-minimal/hub.ts b/examples/hub-nitro-minimal/hub.ts index bd9caa51..99599a85 100644 --- a/examples/hub-nitro-minimal/hub.ts +++ b/examples/hub-nitro-minimal/hub.ts @@ -42,9 +42,9 @@ export const hub: HubInstance = globalRef.__hubNitroMinimal ??= initHub({ createAssetsDevframe({ watch: false }), ], // Rebrand the reference UI to Nitro's own pink/red — one field, no CSS: - // `createUi`'s `branding` option publishes `branding.json`, which the dock - // fetches at boot and feeds into `--devframe-primary` (see - // `@devframes/hub-ui`'s `primary-ramp.css`). + // `createUi`'s `branding` option publishes `ConnectionMeta.configs.ui.branding`, + // which the dock reads at connect time and feeds into `--devframe-primary` + // (see `@devframes/hub-ui`'s `primary-ramp.css`). ui: createUi({ branding: { primaryColor: '#ff2056', productName: 'Devframes on Nitro' } }), // Gate with devframe's interactive OTP (the default). The hub prints a // 6-digit code + magic link on startup, and the reference UI's authorization diff --git a/examples/hub-rsbuild-minimal/rsbuild.config.ts b/examples/hub-rsbuild-minimal/rsbuild.config.ts index 16109b51..a90e079b 100644 --- a/examples/hub-rsbuild-minimal/rsbuild.config.ts +++ b/examples/hub-rsbuild-minimal/rsbuild.config.ts @@ -77,9 +77,10 @@ export default defineConfig({ base, devframes: builtinDevframes, // Rebrand the reference UI to Rsbuild's own orange — one field, no - // CSS: `createUi`'s `branding` option publishes `branding.json`, - // which the dock fetches at boot and feeds into `--devframe-primary` - // (see `@devframes/hub-ui`'s `primary-ramp.css`). + // CSS: `createUi`'s `branding` option publishes + // `ConnectionMeta.configs.ui.branding`, which the dock reads at + // connect time and feeds into `--devframe-primary` (see + // `@devframes/hub-ui`'s `primary-ramp.css`). ui: createUi({ branding: { primaryColor: '#ff5e00', productName: 'Devframes on Rsbuild' } }), // Serve the reference json-render frontend as a prebuilt renderer // module — the one-liner that makes `'json-render'` docks render in diff --git a/examples/hub-vite-minimal/vite.config.ts b/examples/hub-vite-minimal/vite.config.ts index 2f7735ed..7122b7c3 100644 --- a/examples/hub-vite-minimal/vite.config.ts +++ b/examples/hub-vite-minimal/vite.config.ts @@ -67,10 +67,10 @@ export default defineConfig({ quiet: true, devframes: builtinDevframes, // Rebrand the reference UI to Vite's own purple — one field, no CSS: - // `createUi`'s `branding` option publishes `branding.json`, which the - // dock fetches at boot and feeds into `--devframe-primary` (see - // `@devframes/hub-ui`'s `primary-ramp.css`). Passing `ui` overrides the - // default `createUi()` the plugin would otherwise use. + // `createUi`'s `branding` option publishes `ConnectionMeta.configs.ui.branding`, + // which the dock reads at connect time and feeds into `--devframe-primary` + // (see `@devframes/hub-ui`'s `primary-ramp.css`). Passing `ui` overrides + // the default `createUi()` the plugin would otherwise use. ui: createUi({ branding: { primaryColor: '#646cff', productName: 'Devframes on Vite' } }), // Serve the reference json-render frontend as a prebuilt renderer // module — the one-liner that makes `'json-render'` docks render in diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index c322c80a..af5acb01 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -205,4 +205,36 @@ export interface ConnectionMeta { * token same-origin until the requesting origin has been verified. */ viewerOriginToken?: string + /** + * Static, host-declared configuration — baked in once at connect time and + * fixed for the life of the server (e.g. a hub's UI rebrand, or its + * aggregated dock-bar layout preferences). Read-only from the browser: a + * client only ever reads `rpc.connectionMeta.configs`, never writes to it. + * + * Contrast this with {@link DevframeSettingsRegistry} (`ctx.scope(ns).settings`) + * and a hub's `devframe:user-settings` shared-state key — both are + * mutable, user-editable, and synced bidirectionally over RPC for the + * life of the session. `configs` is the opposite: one-way, immutable, + * decided by whoever assembled the server. + * + * Each key is owned by one package, contributed via declaration merging: + * + * ```ts + * declare module 'devframe/types' { + * interface DevframeConnectionConfigsRegistry { + * 'my-key': { some: 'shape' } + * } + * } + * ``` + */ + configs?: Partial } + +/** + * Augmentation point for {@link ConnectionMeta.configs}. Empty by default — + * a package that wants to publish static, boot-time config through the + * connection handshake augments this interface with its own key (see + * {@link ConnectionMeta.configs} for the pattern). `@devframes/hub` + * augments it with `dock`; `@devframes/hub-ui` augments it with `ui`. + */ +export interface DevframeConnectionConfigsRegistry {} diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index afd443b2..5939fd8c 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -262,6 +262,36 @@ export interface DevframeDockDefaults { badge?: string /** Id of the dock group this entry collapses under, if any. */ groupId?: string + /** + * This devframe's opinion about the top-level dock-bar **category** + * ordering, hub-wide — not an attribute of this entry itself. Every + * installed devframe's `categoryOrder` is shallow-merged into one + * aggregate (last-installed wins per key), delivered once via + * `ConnectionMeta.configs.dock.categoryOrder` and merged beneath + * `DEFAULT_CATEGORIES_ORDER`. A host page's own + * `createDevframeClientHost({ categoryOrder })` still overrides it. + */ + categoryOrder?: Record + /** + * This devframe's preferred inline-item capacity for the floating dock + * bar before entries overflow — hub-wide, not an attribute of this entry. + * The last installed devframe declaring it wins; an explicit `layout` + * prop passed to the dock UI still overrides it. Edge mode ignores this + * by design — it shows every entry with no capacity cutoff. + */ + maxVisibleItems?: number + /** + * Seeds a first-run visitor's dock mode — hub-wide, not an attribute of + * this entry. Only applies when the visitor has no stored dock + * preference yet; never overwrites one who already moved their dock. The + * last installed devframe declaring it wins. + */ + defaultMode?: 'float' | 'edge' + /** + * Seeds a first-run visitor's dock position, same override semantics as + * {@link defaultMode}. + */ + defaultPosition?: 'left' | 'right' | 'top' | 'bottom' } export interface DevframeSpaOptions { diff --git a/packages/hub-ui/src/client/components/DockEmbedded.ts b/packages/hub-ui/src/client/components/DockEmbedded.ts index a00f0a6c..c17241d6 100644 --- a/packages/hub-ui/src/client/components/DockEmbedded.ts +++ b/packages/hub-ui/src/client/components/DockEmbedded.ts @@ -1,5 +1,6 @@ import type { DocksContext } from '@devframes/hub/client' import type { VueElementConstructor } from 'vue' +import type { DockLayout } from './dock/dock-layout' import { defineCustomElement } from 'vue' import css from '../.generated/css' import Component from './dock/DockEmbedded.vue' @@ -12,6 +13,7 @@ export const DockEmbedded = defineCustomElement( }, ) as VueElementConstructor<{ context: DocksContext + layout?: Partial }> customElements.define('devframes-dock-embedded', DockEmbedded) diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index b069a6af..2219bfa4 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -39,19 +39,32 @@ async function mountDock(): Promise { simpleAuth: false, }) + // The hub's aggregated dock-bar preferences (declared by installed + // devframes), delivered once via the connection handshake we just + // performed — fixed for the life of this server, never re-fetched. + const dockConfig = rpc.connectionMeta.configs?.dock + + const defaultStore = DEFAULT_DOCK_PANEL_STORE() const state = useLocalStorage( 'devframes-dock-state', - DEFAULT_DOCK_PANEL_STORE(), + { + ...defaultStore, + // Seed a first-run visitor's mode/position from the hub's declared + // defaults — `useLocalStorage`'s own `mergeDefaults` already limits + // this to a visitor with no stored preference yet. + ...(dockConfig?.defaultMode ? { mode: dockConfig.defaultMode } : {}), + ...(dockConfig?.defaultPosition ? { position: dockConfig.defaultPosition } : {}), + }, { mergeDefaults: true }, ) // Resolve branding before the dock exists so the primary color and logo are - // in place on the first paint. Fetched from `branding.json` (served - // where this script is), then overridden by any host-page channel. + // in place on the first paint. Read from `ConnectionMeta.configs.ui.branding` + // (already fetched above), then overridden by any host-page channel. const { resolveBranding, applyPrimaryColor } = await import('../state/branding') - const branding = await resolveBranding({ + const branding = resolveBranding({ mode: 'embedded', - brandingUrl: new URL('branding.json', import.meta.url), + branding: rpc.connectionMeta.configs?.ui?.branding, }) const { createDocksContext } = await import('../state/context') @@ -59,7 +72,10 @@ async function mountDock(): Promise { setDevframeClientContext(context) const { DockEmbedded } = await import('../components/DockEmbedded') - dockEl = new DockEmbedded({ context }) as unknown as HTMLElement + dockEl = new DockEmbedded({ + context, + ...(dockConfig?.maxVisibleItems !== undefined ? { layout: { maxVisibleItems: dockConfig.maxVisibleItems } } : {}), + }) as unknown as HTMLElement // Inline on the host element — beats the generated `:host` ramp defaults and // inherits through the shadow tree. The embedded bootstrap never touches the // host page's /favicon (it's a guest there). diff --git a/packages/hub-ui/src/client/standalone/main.ts b/packages/hub-ui/src/client/standalone/main.ts index ba4c9511..e3518283 100644 --- a/packages/hub-ui/src/client/standalone/main.ts +++ b/packages/hub-ui/src/client/standalone/main.ts @@ -30,11 +30,13 @@ async function main(): Promise<void> { }) // Resolve branding before mount; the standalone page owns its own head, so - // apply title/favicon/description here too. + // apply title/favicon/description here too. Read from + // `ConnectionMeta.configs.ui.branding`, carried by the connection we just + // established above. const { resolveBranding, applyPrimaryColor, applyDocumentHead } = await import('../state/branding') - const branding = await resolveBranding({ + const branding = resolveBranding({ mode: 'standalone', - brandingUrl: new URL('branding.json', document.baseURI), + branding: rpc.connectionMeta.configs?.ui?.branding, }) applyDocumentHead(document, branding) diff --git a/packages/hub-ui/src/client/state/branding.ts b/packages/hub-ui/src/client/state/branding.ts index 9d4ad73e..d81f6f9f 100644 --- a/packages/hub-ui/src/client/state/branding.ts +++ b/packages/hub-ui/src/client/state/branding.ts @@ -12,10 +12,11 @@ export type BrandingLogo = string | { light: string, dark: string } /** * Consumer-facing branding for the reference hub-ui. Every field is optional * and falls back to devframe's own identity. Delivered three ways, merged - * field-by-field (later wins): the `branding.json` `createUi({ branding })` - * publishes, then the host page (a `window.__DEVFRAME_BRANDING__` global or - * `data-*` attrs on the embedding `<script>`, or `?query` params on the - * standalone viewer). + * field-by-field (later wins): the `ConnectionMeta.configs.ui.branding` + * `createUi({ branding })` publishes (read from the connection handshake the + * dock already performs), then the host page (a `window.__DEVFRAME_BRANDING__` + * global or `data-*` attrs on the embedding `<script>`, or `?query` params on + * the standalone viewer). */ export interface DevframeBranding { /** Product name — the wordmark, window titles, and all user-visible copy. */ @@ -160,36 +161,21 @@ function readQueryParams(): DevframeBranding { return out } -async function fetchBrandingJson(url: string | URL): Promise<DevframeBranding | undefined> { - try { - const res = await fetch(url) - if (!res.ok) - return undefined - const json = await res.json() - return json && typeof json === 'object' ? json as DevframeBranding : undefined - } - catch { - // A missing branding.json (embedded-only without the assets seam, offline, - // etc.) is expected — fall back to defaults + any host-page override. - return undefined - } -} - /** - * Resolve branding at boot: fetch the served `branding.json`, layer the - * host-page channels over it (they win per field), install the result, and - * return it. Awaited before the dock element mounts, so branding is applied on - * the first paint. + * Resolve branding at boot: take whatever `ConnectionMeta.configs.ui.branding` + * carried from the connection handshake the dock already performed, layer + * the host-page channels over it (they win per field), install the result, + * and return it. Called once the RPC client is connected, before the dock + * element mounts, so branding is applied on the first paint. */ -export async function resolveBranding(options: { +export function resolveBranding(options: { mode: 'embedded' | 'standalone' - brandingUrl: string | URL -}): Promise<ResolvedBranding> { - const fetched = await fetchBrandingJson(options.brandingUrl) + branding?: DevframeBranding +}): ResolvedBranding { const hostPage = options.mode === 'embedded' ? mergeBranding(readScriptDataAttrs(), readWindowGlobal()) : mergeBranding(readQueryParams(), readWindowGlobal()) - return setBranding(mergeBranding(fetched, hostPage)) + return setBranding(mergeBranding(options.branding, hostPage)) } // --- Applying to the DOM -------------------------------------------------- diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 1df7ed40..b43a2a8a 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -7,7 +7,7 @@ import type { HubDocksUserSettings } from './dock-settings' import { attachFrameNavClient } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '@devframes/hub/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' -import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, HUB_UI_HIDE_EVENT } from '../constants' +import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants' import { useBranding } from './branding' import { createCommandsContext } from './commands' import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings' @@ -353,8 +353,12 @@ export async function createDocksContext( // Settings store, `settings`, and `getWhenContext` are established earlier // (right before `switchEntry`) — its group→member resolution needs them. + // `categoryOrderOverride` folds in every installed devframe's own declared + // `dock.categoryOrder`, aggregated hub-wide and delivered once via the + // connection handshake (`ConnectionMeta.configs.dock.categoryOrder`). + const categoryOrderOverride = rpc.connectionMeta.configs?.dock?.categoryOrder const groupedEntries = computed(() => { - return docksGroupByCategories(entries.value, settings.value, { whenContext: getWhenContext(), collapseGroups: true }) + return docksGroupByCategories(entries.value, settings.value, { whenContext: getWhenContext(), collapseGroups: true, categoryOrderOverride }) }) // Initialize commands context with reactive when-context @@ -511,6 +515,7 @@ export async function createDocksContext( entries, entryToStateMap: markRaw(dockEntryStateMap), groupedEntries, + categoryOrder: categoryOrderOverride ? { ...DEFAULT_CATEGORIES_ORDER, ...categoryOrderOverride } : DEFAULT_CATEGORIES_ORDER, settings: settingsStore, getStateById: (id: string) => dockEntryStateMap.get(id), switchEntry, diff --git a/packages/hub-ui/src/index.ts b/packages/hub-ui/src/index.ts index 8dfe2ad3..d484ec81 100644 --- a/packages/hub-ui/src/index.ts +++ b/packages/hub-ui/src/index.ts @@ -6,6 +6,12 @@ import { fileURLToPath } from 'node:url' export type { DevframeBranding } from './client/state/branding' +declare module 'devframe/types' { + interface DevframeConnectionConfigsRegistry { + ui: { branding?: DevframeBranding } + } +} + /** * The built client assets live next to the built entry (`dist/index.mjs` → * `dist/client/`). When this module runs from source instead (tests and @@ -29,10 +35,11 @@ export interface CreateUiOptions { embedded?: boolean /** * Rebrand the reference UI — logo, product name, primary color, and more. - * Published as `<base>branding.json` (via the hub's generic `assets` seam) - * and fetched by the dock at boot. Reaches both the embedded dock and the - * standalone viewer. A host page can still override any field at runtime via - * `window.__DEVFRAME_BRANDING__` / `<script data-*>` / `?query` params. + * Published as `ConnectionMeta.configs.ui.branding`, read by the dock at + * boot from the one connection handshake it already performs. Reaches + * both the embedded dock and the standalone viewer. A host page can still + * override any field at runtime via `window.__DEVFRAME_BRANDING__` / + * `<script data-*>` / `?query` params. */ branding?: DevframeBranding } @@ -63,6 +70,6 @@ export function createUi(options: CreateUiOptions = {}): DevframeHubUi { ...(options.embedded !== false ? { embedded: { entry: join(client, 'embedded.js') } } : {}), - assets: { 'branding.json': () => JSON.stringify(options.branding || {}) }, + settings: () => ({ branding: options.branding || {} }), } } diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 37dad14a..4e91a369 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -157,6 +157,23 @@ describe('createDevframeClientHost', () => { host.dispose() }) + it('merges categoryOrder from connectionMeta.configs.dock beneath DEFAULT_CATEGORIES_ORDER, and the host option beneath that', async () => { + const { rpc, states } = createStubRpc() + ;(rpc as any).connectionMeta = { backend: 'websocket', configs: { dock: { categoryOrder: { app: -200, web: 10 } } } } + const host = await createDevframeClientHost({ rpc, categoryOrder: { web: 999 } }) + + states.get('devframe:docks')!.push([ + iframeEntry('app-entry', { category: 'app' }), + iframeEntry('web-entry', { category: 'web' }), + iframeEntry('framework-entry', { category: 'framework' }), + ]) + + expect(host.context.docks.categoryOrder).toMatchObject({ app: -200, web: 999, framework: -100 }) + // `app` (-200, from connectionMeta) now sorts ahead of `framework` (-100, default). + expect(host.context.docks.groupedEntries.map(([cat]) => cat)).toEqual(['app', 'framework', 'web']) + host.dispose() + }) + it('switches entries with activation/deactivation events and when-context updates', async () => { const { rpc, states } = createStubRpc() const host = await createDevframeClientHost({ rpc }) diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index 54194dc6..4eba597f 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -95,6 +95,14 @@ export interface DocksEntriesContext { entries: DevframeDockEntry[] entryToStateMap: Map<string, DockEntryState> groupedEntries: DevframeDockEntriesGrouped + /** + * The resolved top-level category ordering — `DEFAULT_CATEGORIES_ORDER`, + * overridden by every installed devframe's own `dock.categoryOrder` + * (`ConnectionMeta.configs.dock.categoryOrder`), overridden again by the + * host page's own `createDevframeClientHost({ categoryOrder })`. Fixed + * for the life of the session — resolved once at boot. + */ + readonly categoryOrder: Record<string, number> settings: SharedState<DevframeDocksUserSettings> /** * Get the state of a dock entry by its ID diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index 7bef46a1..eadd8c1f 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -78,9 +78,11 @@ export interface DevframeClientHostOptions { * distinct from {@link import('../types/docks').DevframeViewGroup.categoryOrder}, * which only reorders the IN-GROUP sub-categories of one specific group. * - * Keys are merged over `DEFAULT_CATEGORIES_ORDER`, so the host app only - * lists the categories it wants to move; any category absent from the map - * keeps its default weight (falling back to `0`). + * Keys are merged over `DEFAULT_CATEGORIES_ORDER` and every installed + * devframe's own declared `dock.categoryOrder` (see + * `ConnectionMeta.configs.dock.categoryOrder`) — this option sorts last + * and wins, so the host app only lists the categories it wants to move; + * any category absent from the map keeps its otherwise-resolved weight. * * @example * ```ts @@ -136,6 +138,16 @@ export async function createDevframeClientHost( const frameNavAdapters = new Map<string, () => void>() const loadScriptsEnabled = options.loadClientScripts ?? true + // Resolved once at boot, fixed for the session: default table, overridden + // by every installed devframe's own declared preference (delivered once + // via the connection handshake), overridden again by this host page's own + // explicit option. + const categoryOrder: Record<string, number> = { + ...DEFAULT_CATEGORIES_ORDER, + ...rpc.connectionMeta?.configs?.dock?.categoryOrder, + ...options.categoryOrder, + } + const panel = createPanelContext(clientType) const docks = createDocksContext() const commands = createCommandsContext() @@ -322,7 +334,7 @@ export async function createDevframeClientHost( } docks.entries = entries - docks.groupedEntries = groupByCategory(entries) + docks.groupedEntries = groupByCategory(entries, categoryOrder) if (selectedId && !entryToStateMap.has(selectedId)) selectedId = null } @@ -341,6 +353,7 @@ export async function createDevframeClientHost( entries: [], entryToStateMap, groupedEntries: [], + categoryOrder, settings, getStateById: id => entryToStateMap.get(id), switchEntry, @@ -522,7 +535,7 @@ function createPanelContext(clientType: DockClientType): DocksPanelContext { } } -function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGrouped { +function groupByCategory(entries: DevframeDockEntry[], categoryOrder: Record<string, number>): DevframeDockEntriesGrouped { // Index registered groups so a member whose `groupId` resolves takes its // OUTER bucket from the group's category, not its own (which becomes the // member's in-group sub-category). Orphan members — a `groupId` with no @@ -547,6 +560,6 @@ function groupByCategory(entries: DevframeDockEntry[]): DevframeDockEntriesGroup list.push(entry) } return [...groups.entries()].sort( - ([a], [b]) => (DEFAULT_CATEGORIES_ORDER[a] ?? 0) - (DEFAULT_CATEGORIES_ORDER[b] ?? 0), + ([a], [b]) => (categoryOrder[a] ?? 0) - (categoryOrder[b] ?? 0), ) } diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 392b6b6b..802efba1 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -191,6 +191,34 @@ describe('devframeDockHost grouping', () => { }) }) +describe('devframeDockHost dockConfig aggregation', () => { + it('starts empty', () => { + const host = new DevframeDocksHost(createContext()) + expect(host.dockConfig).toEqual({}) + }) + + it('shallow-merges categoryOrder across contributions, last wins per key', () => { + const host = new DevframeDocksHost(createContext()) + host.contributeDockConfig({ categoryOrder: { app: -40, web: 300 } }) + host.contributeDockConfig({ categoryOrder: { app: -60, advanced: -50 } }) + expect(host.dockConfig.categoryOrder).toEqual({ app: -60, web: 300, advanced: -50 }) + }) + + it('last contribution wins per scalar field', () => { + const host = new DevframeDocksHost(createContext()) + host.contributeDockConfig({ maxVisibleItems: 4, defaultMode: 'edge', defaultPosition: 'left' }) + host.contributeDockConfig({ maxVisibleItems: 10 }) + expect(host.dockConfig).toMatchObject({ maxVisibleItems: 10, defaultMode: 'edge', defaultPosition: 'left' }) + }) + + it('leaves an unset scalar field untouched by a contribution that omits it', () => { + const host = new DevframeDocksHost(createContext()) + host.contributeDockConfig({ defaultMode: 'float' }) + host.contributeDockConfig({ categoryOrder: { app: 1 } }) + expect(host.dockConfig.defaultMode).toBe('float') + }) +}) + describe('devframeDockHost activate', () => { it('emits a dock:activate event carrying the id and params', () => { const host = new DevframeDocksHost(createContext()) diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index a59777e2..3de86c37 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -174,6 +174,62 @@ describe('initHub', () => { } }) + it('assembles ConnectionMeta.configs from the ui slot and every installed devframe\'s dock preferences', async () => { + const wsPort = await getPort({ port: 18225, host: '127.0.0.1' }) + const alpha: DevframeDefinition = { + ...makeFrame('alpha', makeDist('<!doctype html><title>frame a')), + dock: { category: 'app', categoryOrder: { app: -40 }, maxVisibleItems: 4 }, + } + const beta: DevframeDefinition = { + ...makeFrame('beta'), + dock: { category: 'web', categoryOrder: { app: -60, web: 300 }, defaultMode: 'edge' }, + } + + const hub = initHub({ + base: DEVFRAMES_HUB_BASE, + auth: false, + host: '127.0.0.1', + ws: { port: wsPort }, + devframes: [alpha, beta], + ui: { settings: () => ({ branding: { productName: 'Test Hub' } }) }, + }) + + try { + await hub.ready + const origin = 'http://localhost:5173' + + // The hub's own meta… + const hubMeta = await (await hub.handler(new Request(`${origin}/__devframes/__connection.json`))).json() + expect(hubMeta.configs).toEqual({ + ui: { branding: { productName: 'Test Hub' } }, + // Last-installed devframe (`beta`) wins the `app` scalar key; `web` + // only `beta` declared; `maxVisibleItems` only `alpha` declared. + dock: { categoryOrder: { app: -60, web: 300 }, maxVisibleItems: 4, defaultMode: 'edge' }, + }) + + // …and every per-frame meta carries the identical aggregate. + const frameMeta = await (await hub.handler(new Request(`${origin}/__devframes/alpha/__connection.json`))).json() + expect(frameMeta.configs).toEqual(hubMeta.configs) + } + finally { + await hub.close() + } + }) + + it('omits ConnectionMeta.configs entirely when neither the ui slot nor any devframe declares anything', async () => { + const wsPort = await getPort({ port: 18226, host: '127.0.0.1' }) + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, devframes: [makeFrame('alpha')] }) + + try { + await hub.ready + const meta = await (await hub.handler(new Request(`http://localhost:5173/__devframes/__connection.json`))).json() + expect(meta.configs).toBeUndefined() + } + finally { + await hub.close() + } + }) + it('aggregate MCP: one endpoint lists tools from every mounted frame', async () => { const wsPort = await getPort({ port: 18230, host: '127.0.0.1' }) const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false, host: '127.0.0.1', ws: { port: wsPort }, mcp: true, devframes: [makeFrame('alpha'), makeFrame('beta')] }) diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts index 950c0d54..a0265907 100644 --- a/packages/hub/src/node/__tests__/install-devframe.test.ts +++ b/packages/hub/src/node/__tests__/install-devframe.test.ts @@ -97,6 +97,39 @@ describe('ctx.install', () => { }) }) + it('contributes categoryOrder/maxVisibleItems/defaultMode/defaultPosition to the hub aggregate instead of the entry', async () => { + const ctx = createContext() + await ctx.install(makeDevframe({ + dock: { + category: 'app', + categoryOrder: { app: -40 }, + maxVisibleItems: 4, + defaultMode: 'edge', + defaultPosition: 'left', + }, + })) + + expect(ctx.docks.dockConfig).toEqual({ + categoryOrder: { app: -40 }, + maxVisibleItems: 4, + defaultMode: 'edge', + defaultPosition: 'left', + }) + // Not spread onto the synthesized entry — those aren't entry attributes. + const entry = ctx.docks.views.get('demo') as unknown as Record + expect(entry.categoryOrder).toBeUndefined() + expect(entry.maxVisibleItems).toBeUndefined() + expect(entry.defaultMode).toBeUndefined() + expect(entry.defaultPosition).toBeUndefined() + expect(entry.category).toBe('app') + }) + + it('leaves the hub aggregate untouched when a devframe declares no dock-bar preferences', async () => { + const ctx = createContext() + await ctx.install(makeDevframe({ dock: { category: 'app' } })) + expect(ctx.docks.dockConfig).toEqual({}) + }) + it('warns and deduplicates by default, keeping the first registration', async () => { const ctx = createContext() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 4f1769a6..41867d9e 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -1,6 +1,7 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { + DevframeDockConfig, DevframeDockEntry, DevframeDocksHost as DevframeDocksHostType, DevframeDockUserEntry, @@ -35,6 +36,7 @@ export class DevframeDocksHost implements DevframeDocksHostType { public readonly views: DevframeDocksHostType['views'] = new Map() public readonly events: DevframeDocksHostType['events'] = createEventEmitter() public userSettings: SharedState = undefined! + public readonly dockConfig: DevframeDockConfig = {} /** Dock-id → allocated remote token + resolved options. */ private readonly remoteDocks = new Map() @@ -43,6 +45,18 @@ export class DevframeDocksHost implements DevframeDocksHostType { public readonly context: DevframeHubContext, ) {} + contributeDockConfig(config: DevframeDockConfig): void { + if (config.categoryOrder) { + this.dockConfig.categoryOrder = { ...this.dockConfig.categoryOrder, ...config.categoryOrder } + } + if (config.maxVisibleItems !== undefined) + this.dockConfig.maxVisibleItems = config.maxVisibleItems + if (config.defaultMode !== undefined) + this.dockConfig.defaultMode = config.defaultMode + if (config.defaultPosition !== undefined) + this.dockConfig.defaultPosition = config.defaultPosition + } + async init() { this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { sharedState: createStorage({ diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 1a736c82..a7e6219b 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -119,12 +119,22 @@ export interface DevframeHubUi { } /** * Extra UI-owned files the hub serves at ``, each produced lazily - * from memory. Keys are base-relative paths (e.g. `branding.json`); the - * content-type is inferred from the key's extension. A generic seam a viewer - * uses to publish small runtime documents (the reference UI serves its - * branding this way) without teaching the hub anything about their meaning. + * from memory. Keys are base-relative paths; the content-type is inferred + * from the key's extension. A generic seam a viewer uses to publish + * arbitrary runtime documents without teaching the hub anything about + * their meaning. */ assets?: Record string | Uint8Array> + /** + * Static, UI-owned configuration published verbatim as + * `ConnectionMeta.configs.ui` — the hub embeds whatever this returns + * without interpreting it, staying policy-free about what "ui" means. + * The reference UI's `createUi({ branding })` sets this to `{ branding }`, + * reaching every mounted frame and the standalone viewer through the one + * connection handshake they already perform, in place of a separate + * fetched asset. + */ + settings?: () => Record } export type DevframesInput = Array< @@ -523,6 +533,21 @@ export function initHub(options: InitHubOptions): HubInstance { }, mount(ctx, meta) { + // Static, boot-time config — the UI slot's own opaque settings (e.g. + // branding) plus the dock-bar preferences aggregated from every + // installed devframe — baked into the one connection meta every frame + // and the standalone viewer already fetch. Fixed for the life of the + // server: computed once, here, after every devframe has installed. + const uiConfig = options.ui?.settings?.() + const dockConfig = ctx.docks.dockConfig + const hasDockConfig = Object.keys(dockConfig).length > 0 + if (uiConfig || hasDockConfig) { + meta.configs = { + ...(uiConfig ? { ui: uiConfig } : {}), + ...(hasDockConfig ? { dock: dockConfig } : {}), + } + } + // Hub-level discovery endpoints, registered before the viewer's static // mount so its SPA-fallback can't swallow them. app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta) @@ -572,9 +597,9 @@ export function initHub(options: InitHubOptions): HubInstance { }) } - // UI-owned assets (e.g. the reference viewer's `branding.json`), served - // from memory. Registered before the viewer's SPA catch-all so these exact - // routes win, mirroring the discovery endpoints above. + // UI-owned assets, served from memory. Registered before the viewer's + // SPA catch-all so these exact routes win, mirroring the discovery + // endpoints above. for (const [key, produce] of Object.entries(options.ui?.assets ?? {})) { app.use(joinURL(base, key), (event) => { event.res.headers.set('Content-Type', assetContentType(key)) diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 87f67fe5..0a5c275d 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -93,6 +93,15 @@ export async function installDevframe( ctx.views.hostStatic(base, resolve(d.cli.distDir)) } + // `categoryOrder` / `maxVisibleItems` / `defaultMode` / `defaultPosition` + // are this devframe's opinion about the hub-wide dock bar, not attributes + // of its own synthesized entry — pull them out before spreading the rest + // into the entry and fold them into the hub's aggregate instead. + const { categoryOrder, maxVisibleItems, defaultMode, defaultPosition, ...entryDockDefaults } = d.dock ?? {} + if (categoryOrder || maxVisibleItems !== undefined || defaultMode !== undefined || defaultPosition !== undefined) { + ctx.docks.contributeDockConfig({ categoryOrder, maxVisibleItems, defaultMode, defaultPosition }) + } + ctx.docks.register({ id, title: d.name, @@ -100,7 +109,7 @@ export async function installDevframe( // Definition-level `dock` defaults sit above the name/icon-derived // defaults; per-mount `options.dock` overrides them; `type`/`url` // (and `id`) stay locked, derived from the definition. - ...d.dock, + ...entryDockDefaults, ...options.dock, type: 'iframe', url: base, diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index 65d4568f..d43a1f48 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -1,11 +1,43 @@ import type { ConnectionMeta, EventEmitter } from 'devframe/types' +/** + * The hub's aggregate, plugin-declared dock-bar layout preferences — + * collected across every installed {@link import('devframe/types').DevframeDefinition}'s + * own `dock.categoryOrder` / `maxVisibleItems` / `defaultMode` / + * `defaultPosition` (last-installed devframe wins per scalar key, + * `categoryOrder` shallow-merged). Published verbatim as + * `ConnectionMeta.configs.dock` — fixed for the life of the server, never + * mutated after boot. + */ +export interface DevframeDockConfig { + categoryOrder?: Record + maxVisibleItems?: number + defaultMode?: 'float' | 'edge' + defaultPosition?: 'left' | 'right' | 'top' | 'bottom' +} + +declare module 'devframe/types' { + interface DevframeConnectionConfigsRegistry { + dock: DevframeDockConfig + } +} + export interface DevframeDocksHost { readonly views: Map readonly events: EventEmitter<{ 'dock:entry:updated': (entry: DevframeDockUserEntry) => void 'dock:activate': (activation: DevframeDockActivation) => void }> + /** The aggregate dock-bar config collected from every installed devframe so far. */ + readonly dockConfig: DevframeDockConfig + /** + * Merge one devframe's declared dock-bar preferences into + * {@link dockConfig} — called by `installDevframe` for every devframe + * whose `dock` declares any of `categoryOrder` / `maxVisibleItems` / + * `defaultMode` / `defaultPosition`. Last caller wins per scalar key; + * `categoryOrder` shallow-merges over what's already aggregated. + */ + contributeDockConfig: (config: DevframeDockConfig) => void register: (entry: T, force?: boolean) => { update: (patch: Partial) => void diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 0f0daa53..a3940459 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -97,6 +97,7 @@ export interface DocksEntriesContext { entries: DevframeDockEntry[]; entryToStateMap: Map; groupedEntries: DevframeDockEntriesGrouped; + readonly categoryOrder: Record; settings: SharedState; getStateById: (_: string) => DockEntryState | undefined; switchEntry: (_?: string | null) => Promise; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 93346b37..5045bae8 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -81,6 +81,12 @@ export interface DevframeDockActivation { dockId: string; params?: Record; } +export interface DevframeDockConfig { + categoryOrder?: Record; + maxVisibleItems?: number; + defaultMode?: 'float' | 'edge'; + defaultPosition?: 'left' | 'right' | 'top' | 'bottom'; +} export interface DevframeDockEntryBase { id: string; title: string; @@ -110,6 +116,8 @@ export interface DevframeDocksHost { 'dock:entry:updated': (entry: DevframeDockUserEntry) => void; 'dock:activate': (activation: DevframeDockActivation) => void; }>; + readonly dockConfig: DevframeDockConfig; + contributeDockConfig: (_: DevframeDockConfig) => void; register: (_: T, _?: boolean) => { update: (_: Partial) => void; }; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index 489cc7ad..37adaadc 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -10,6 +10,7 @@ export interface DevframeHubUi { entry: string; }; assets?: Record string | Uint8Array>; + settings?: () => Record; } export interface DockRendererRegistration { type: string; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index 33011b13..214083cd 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -22,8 +22,10 @@ export declare class DevframeDocksHost implements DevframeDocksHost$1 { readonly views: DevframeDocksHost$1['views']; readonly events: DevframeDocksHost$1['events']; userSettings: SharedState; + readonly dockConfig: DevframeDockConfig; private readonly remoteDocks; constructor(_: DevframeHubContext); + contributeDockConfig(_: DevframeDockConfig): void; init(): Promise; values(): DevframeDockEntry[]; private projectView; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 40f0ac3c..fb87e17d 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -24,6 +24,7 @@ export { DevframeDiagnosticsHost } export { DevframeDiagnosticsLogger } export { DevframeDockActivation } export { DevframeDockBadgeVariant } +export { DevframeDockConfig } export { DevframeDockEntriesGrouped } export { DevframeDockEntry } export { DevframeDockEntryBase } diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 86ecc7c1..14d4e40a 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -75,6 +75,7 @@ export interface ConnectionMeta { baseUrl?: string; authToken?: string; viewerOriginToken?: string; + configs?: Partial; } export interface ConnectionMetaSse { path?: string; @@ -128,6 +129,7 @@ export interface DevframeCliOptions { configure?: (_: CAC) => void; flags?: CliFlagsSchema; } +export interface DevframeConnectionConfigsRegistry {} export interface DevframeDefineDiagnosticsOptions> { docsBase?: string | ((_: keyof Codes) => string | undefined); codes: Codes; @@ -174,6 +176,10 @@ export interface DevframeDockDefaults { visibility?: string; badge?: string; groupId?: string; + categoryOrder?: Record; + maxVisibleItems?: number; + defaultMode?: 'float' | 'edge'; + defaultPosition?: 'left' | 'right' | 'top' | 'bottom'; } export interface DevframeHost { mountStatic: (_: string, _: string) => void | Promise; diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 89f6a49d..481ccc85 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -19,6 +19,7 @@ export { DevframeAgentHostEvents } export { DevframeBrowserContext } export { DevframeCapabilities } export { DevframeCliOptions } +export { DevframeConnectionConfigsRegistry } export { DevframeDefineDiagnosticsOptions } export { DevframeDefinition } export { DevframeDeploymentKind } From 78bf2e67646de7bc154263cee846f5bedb2f2ebd Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 14 Aug 2026 16:42:37 +0900 Subject: [PATCH 02/10] chore: add review request --- packages/hub-ui/src/client/state/branding.ts | 1 + packages/hub/src/node/initiate.ts | 1 + packages/hub/src/node/install-devframe.ts | 1 + packages/hub/src/types/docks.ts | 2 ++ 4 files changed, 5 insertions(+) diff --git a/packages/hub-ui/src/client/state/branding.ts b/packages/hub-ui/src/client/state/branding.ts index d81f6f9f..316e1a7e 100644 --- a/packages/hub-ui/src/client/state/branding.ts +++ b/packages/hub-ui/src/client/state/branding.ts @@ -168,6 +168,7 @@ function readQueryParams(): DevframeBranding { * and return it. Called once the RPC client is connected, before the dock * element mounts, so branding is applied on the first paint. */ +// TODO: remove this, the branding would only be provided by the connection meta, which has its own propagation methods, no globals or queryies anymore. export function resolveBranding(options: { mode: 'embedded' | 'standalone' branding?: DevframeBranding diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index a7e6219b..9be26125 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -134,6 +134,7 @@ export interface DevframeHubUi { * connection handshake they already perform, in place of a separate * fetched asset. */ + // TODO: rename to "configs" settings?: () => Record } diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 0a5c275d..23d35477 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -97,6 +97,7 @@ export async function installDevframe( // are this devframe's opinion about the hub-wide dock bar, not attributes // of its own synthesized entry — pull them out before spreading the rest // into the entry and fold them into the hub's aggregate instead. + // TODO: rework this, instead of contributeDockConfig, we should have the APIs for configuring the connection meta's configs object const { categoryOrder, maxVisibleItems, defaultMode, defaultPosition, ...entryDockDefaults } = d.dock ?? {} if (categoryOrder || maxVisibleItems !== undefined || defaultMode !== undefined || defaultPosition !== undefined) { ctx.docks.contributeDockConfig({ categoryOrder, maxVisibleItems, defaultMode, defaultPosition }) diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index d43a1f48..c50c8b06 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -28,6 +28,7 @@ export interface DevframeDocksHost { 'dock:entry:updated': (entry: DevframeDockUserEntry) => void 'dock:activate': (activation: DevframeDockActivation) => void }> + // TODO: remove this, it doesn't need to be exposed in DevframeDocksHost /** The aggregate dock-bar config collected from every installed devframe so far. */ readonly dockConfig: DevframeDockConfig /** @@ -37,6 +38,7 @@ export interface DevframeDocksHost { * `defaultMode` / `defaultPosition`. Last caller wins per scalar key; * `categoryOrder` shallow-merges over what's already aggregated. */ + // TODO: remove this, it doesn't need to be exposed in DevframeDocksHost contributeDockConfig: (config: DevframeDockConfig) => void register: (entry: T, force?: boolean) => { From 5c518703b3f9f406b998a2ff9e6ed7375c7c56d8 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 14 Aug 2026 08:05:28 +0000 Subject: [PATCH 03/10] refactor(devframe,hub,hub-ui): generic ctx.configs API, drop host-page branding channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows up on review feedback: - Adds ctx.configs (DevframeConfigsHost) as a core devframe context primitive — the generic API for contributing to a context's own ConnectionMeta.configs, resolved once (instance-shell) after every contributor has run and baked into the served meta for every host adapter, not just the hub. Contributors own their own merge semantics via an (current) => next updater. - installDevframe now calls ctx.configs.contribute('dock', ...) instead of a docks-specific contributeDockConfig; dockConfig/contributeDockConfig are removed from DevframeDocksHost — dock-bar config aggregation isn't a docks-registry concern. - DevframeHubUi.settings renamed to configs, matching the ConnectionMeta.configs.ui wire shape it feeds. - hub-ui's resolveBranding drops the host-page override channels (window.__DEVFRAME_BRANDING__,