Skip to content
Merged
9 changes: 9 additions & 0 deletions docs/guide/build-your-own-hub-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base>embedded.js
assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
setup?: (ctx) => void | Promise<void> // publish static config via ctx.staticConfig
}
```

Expand All @@ -24,6 +25,14 @@ 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.

`setup(ctx)` runs once during hub init — write your static, boot-time config
to `ctx.staticConfig`, which is serialized into `ConnectionMeta.configs` and
read by the client from the one connection handshake it already performs. The
reference UI's `createUi({ branding })` uses it to set
`ctx.staticConfig.ui = { branding, … }`; the hub never interprets what you
write. It's the structured, read-only counterpart to `assets` (arbitrary
served files).

## The client contracts

A viewer renders from the hub's shared state and drives it through
Expand Down
18 changes: 18 additions & 0 deletions docs/guide/devframe-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ interface DevframeNodeContext {
diagnostics: DevframeDiagnosticsHost
agent: DevframeAgentHost // experimental
services: DevframeServicesHost // typed cross-plugin service registry
staticConfig: Partial<DevframeConnectionConfigsRegistry> // this context's own ConnectionMeta.configs

scope: (id) => DevframeScopedNodeContext // namespaced view (preferred)
}
Expand All @@ -130,6 +131,22 @@ ctx.services.whenAvailable('my-plugin:sources', (sources) => {
})
```

### Static connection configs

`ctx.staticConfig` is this context's own `ConnectionMeta.configs` — static, boot-time data delivered once through the connection handshake every client already performs, and read-only from the browser. It's a plain, **non-reactive** object: write it during `setup(ctx)`, never during the session (it's serialized once, after setup). Contrast it with `ctx.scope(id).settings`, which is mutable and synced bidirectionally over shared-state RPC for the life of the session.

```ts
declare module 'devframe/types' {
interface DevframeConnectionConfigsRegistry {
'my-plugin': { featureFlag: boolean }
}
}

ctx.staticConfig['my-plugin'] = { featureFlag: true }
```

`updater` receives whatever's been contributed to that key so far (or `undefined` on the first contribution), so multiple contributors sharing a key — a hub aggregating each installed devframe's own preference, for example — own their own merge semantics (overwrite, shallow-merge a record, …) rather than the host imposing one.

### Storage scopes

`ctx.host.getStorageDir(scope)` places persisted state in one of three classes:
Expand All @@ -152,6 +169,7 @@ Each devframe-level host has a dedicated page:
- [Shared State](./shared-state) — `ctx.rpc.sharedState`
- [Diagnostics](./diagnostics) — `ctx.diagnostics`
- [Agent-Native](./agent-native) — `ctx.agent`
- [Cross-Plugin Services](./services) — `ctx.services`

## Browser setup

Expand Down
19 changes: 18 additions & 1 deletion docs/guide/hub-initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,27 @@ 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 <base>embedded.js
assets?: Record<string, () => string | Uint8Array> // extra UI-owned files
setup?: (ctx) => void | Promise<void> // publish static config via ctx.staticConfig
}
```

`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself, always visible. A viewer product supplies a different object to the same slot and reuses all the infrastructure; visibility policy (keyboard summon, passive modes) belongs entirely to the entry's author.
`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself. A viewer product supplies a different object to the same slot and reuses all the infrastructure. Its `setup(ctx)` publishes the reference UI's config to `ctx.staticConfig.ui`, which rides `ConnectionMeta.configs.ui` to the client.

`createUi()` takes a few options:

- **`branding`** — rebrand the reference UI (logo, product name, primary color).
- **`dockPreferences`** — dock-bar rendering: `categoryOrder`, floating-dock `maxVisibleItems`, and the first-run `defaultMode` (`'float'` / `'edge'`) and `defaultPosition`.
- **`embeddedVisibility`** — the floating dock's reveal policy:
- `'normal'` (default) — the dock is shown immediately.
- `'passive'` — the dock starts hidden with a console hint; `Shift+Alt+D` reveals it, and the reveal persists per-origin so later sessions start shown.
- `'hidden'` — the dock starts hidden; `Shift+Alt+D` reveals it for the current session only.

```ts
createUi({ embeddedVisibility: 'passive', dockPreferences: { defaultMode: 'edge' } })
```

Each seeds a user-overridable preference — the config sets the default, the visitor's own choice (reveal/hide, float/edge, …) wins from then on.

## Renderer modules

Expand Down
6 changes: 3 additions & 3 deletions examples/hub-hono-minimal/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions examples/hub-next-minimal/src/client/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ async function loadHub(): Promise<HubInstance> {
// `@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' } }),
ui: (hubUi.createUi as typeof CreateUi)({ branding: { primaryColor: '#3f8ba9', productName: 'Devframes on Next.js' } }),
// Serve the reference json-render frontend as a prebuilt renderer module
// — the one-liner that makes `'json-render'` docks render in the prebuilt
// viewer. Swap it for any community implementation of the same contract.
Expand Down
6 changes: 3 additions & 3 deletions examples/hub-nitro-minimal/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions examples/hub-rsbuild-minimal/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions examples/hub-vite-minimal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/node/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom
diagnostics: undefined!,
agent: undefined!,
services: undefined!,
staticConfig: {},
scope: undefined!,
} as unknown as DevframeNodeContext

Expand Down
8 changes: 8 additions & 0 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,14 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
...(result.mcp ? { mcp: result.mcp } : {}),
}

// Whatever `setup(ctx)` wrote to `ctx.staticConfig` during
// `options.init(api)` — e.g. a hub aggregating each installed devframe's
// own dock-bar preferences — is in by now; bake it into the meta
// `options.mount` (and every host that re-serves this same meta at
// another base) publishes.
if (Object.keys(ctx.staticConfig).length > 0)
meta.configs = ctx.staticConfig

await options.mount?.(ctx, meta, api)

// A pinned origin means the banner and registry record needn't wait for a
Expand Down
48 changes: 48 additions & 0 deletions packages/devframe/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ export interface DevframeNodeContext {
* absorb setup-order differences between provider and consumer.
*/
services: DevframeServicesHost
/**
* This context's own {@link ConnectionMeta.configs} — static, boot-time
* config a host publishes once through the connection handshake and every
* client reads read-only. A plain, **non-reactive** object: mutate it
* during `setup(ctx)` (a plugin sets its keys, a hub aggregates across
* every installed devframe), never during the session — it's serialized
* once, after setup, and changing it afterwards reaches no client.
*
* ```ts
* ctx.staticConfig.dock = {
* ...ctx.staticConfig.dock,
* categoryOrder: { ...ctx.staticConfig.dock?.categoryOrder, ...myOrder },
* }
* ```
*/
staticConfig: Partial<DevframeConnectionConfigsRegistry>
/**
* Create a namespace-scoped view of this context. The returned
* `ctx.scope('my-plugin')` auto-namespaces every RPC id, shared-state
Expand Down Expand Up @@ -205,4 +221,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<DevframeConnectionConfigsRegistry>
}

/**
* 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 {}
2 changes: 2 additions & 0 deletions packages/hub-ui/src/client/components/DockEmbedded.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +13,7 @@ export const DockEmbedded = defineCustomElement(
},
) as VueElementConstructor<{
context: DocksContext
layout?: Partial<DockLayout>
}>

customElements.define('devframes-dock-embedded', DockEmbedded)
4 changes: 2 additions & 2 deletions packages/hub-ui/src/client/components/dock/DockEdge.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { DocksContext } from '@devframes/hub/client'
import type { CSSProperties } from 'vue'
import type { HubDocksUserSettings } from '../../state/dock-settings'
import type { DevframeDocksUserSettings } from '../../state/dock-settings'
import type { DockEdge as DockEdgePosition, DockLayout } from './dock-layout'
import { useEventListener } from '@vueuse/core'
import { computed, h, onMounted, ref, useTemplateRef } from 'vue'
Expand All @@ -24,7 +24,7 @@ const props = defineProps<{

const context = props.context
const store = context.panel.store
const settings = sharedStateToRef<HubDocksUserSettings>(context.docks.settings)
const settings = sharedStateToRef<DevframeDocksUserSettings>(context.docks.settings)
const layout = computed(() => resolveDockLayout(props.layout))

const viewsContainer = useTemplateRef<HTMLElement>('viewsContainer')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<script setup lang="ts">
import type { DocksContext } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { HubDocksUserSettings } from '../../state/dock-settings'
import type { DevframeDocksUserSettings } from '../../state/dock-settings'
import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants'
import { useBranding } from '../../state/branding'
import { useConfirm } from '../../state/confirm'
import { sharedStateToRef } from '../../state/docks'

const props = defineProps<{
context: DocksContext
settingsStore: SharedState<HubDocksUserSettings>
settingsStore: SharedState<DevframeDocksUserSettings>
}>()

const settings = sharedStateToRef(props.settingsStore)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { DocksContext } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { HubDocksUserSettings } from '../../state/dock-settings'
import type { DevframeDocksUserSettings } from '../../state/dock-settings'
import { computed } from 'vue'
import { useBranding } from '../../state/branding'
import { colorSchemePreference, setColorSchemePreference } from '../../state/color-mode'
Expand All @@ -10,7 +10,7 @@ import { isDockPopupSupported, requestDockPopupOpen, useIsDockPopupOpen } from '

const props = defineProps<{
context: DocksContext
settingsStore: SharedState<HubDocksUserSettings>
settingsStore: SharedState<DevframeDocksUserSettings>
}>()

const settings = sharedStateToRef(props.settingsStore)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DocksContext } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import type { DevframeDockEntriesGrouped, HubDocksUserSettings } from '../../state/dock-settings'
import type { DevframeDockEntriesGrouped, DevframeDocksUserSettings } from '../../state/dock-settings'
import { useDraggable } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, isCategoryHideable } from '../../state/dock-settings'
Expand All @@ -12,7 +12,7 @@ import DockIcon from '../dock/DockIcon.vue'

const props = defineProps<{
context: DocksContext
settingsStore: SharedState<HubDocksUserSettings>
settingsStore: SharedState<DevframeDocksUserSettings>
}>()

const settings = sharedStateToRef(props.settingsStore)
Expand Down
8 changes: 4 additions & 4 deletions packages/hub-ui/src/client/constants.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { DevframeViewBuiltin } from '@devframes/hub'

/**
* `window` event the "Hide" command dispatches to ask whoever mounted the
* embedded dock to tear it down for the session. The hub-ui dock is always
* visible by design — hiding is a page-lifetime action, and a reload brings
* the dock back (see `src/client/embedded/index.ts`).
* `window` event the "Hide" command dispatches to conceal the embedded dock.
* The embedded bootstrap's visibility controller catches it and detaches the
* dock; the `Shift+Alt+D` reveal shortcut (or, in `passive` mode, a later
* reload) brings it back (see `src/client/embedded/visibility.ts`).
*/
export const HUB_UI_HIDE_EVENT = 'devframes:hub-ui:hide'

Expand Down
26 changes: 26 additions & 0 deletions packages/hub-ui/src/client/dock-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* The reference UI's dock-bar rendering preferences, set via
* `createUi({ dockPreferences })` and published as
* `ConnectionMeta.configs.ui.dockPreferences`. Read by the embedded dock and
* the standalone viewer at boot.
*
* Like the float/edge dock mode, these seed user-overridable state — the
* config sets the default, the visitor's own choice wins from then on.
*/
export interface DevframeDockPreferences {
/**
* The top-level dock-bar **category** ordering — a map of category id →
* ordering weight (lower sorts earlier), merged beneath
* `DEFAULT_CATEGORIES_ORDER`.
*/
categoryOrder?: Record<string, number>
/**
* Preferred inline-item capacity for the floating dock bar before entries
* overflow. Edge mode ignores it — it shows every entry with no cutoff.
*/
maxVisibleItems?: number
/** Seeds a first-run visitor's dock mode (float vs edge). */
defaultMode?: 'float' | 'edge'
/** Seeds a first-run visitor's dock position. */
defaultPosition?: 'left' | 'right' | 'top' | 'bottom'
}
Loading
Loading