|
| 1 | +# Build Your Own Hub UI |
| 2 | + |
| 3 | +A hub viewer is a replaceable implementation of two contracts — the node-side |
| 4 | +`ui` slot and the client-side context — so you can ship a completely custom |
| 5 | +devtools surface (your framework, your design system) on top of the hub's |
| 6 | +infrastructure. `@devframes/hub-ui` is the reference implementation of both; |
| 7 | +this page is the map for writing another. |
| 8 | + |
| 9 | +## The node seam: `DevframeHubUi` |
| 10 | + |
| 11 | +`initHub({ ui })` takes pure data (see [the `ui` |
| 12 | +slot](./hub-initiate#the-ui-slot)): |
| 13 | + |
| 14 | +```ts |
| 15 | +interface DevframeHubUi { |
| 16 | + viewer?: { distDir: string } // a standalone SPA served at the hub base |
| 17 | + embedded?: { entry: string } // a self-contained bootstrap at <base>embedded.js |
| 18 | + assets?: Record<string, () => string | Uint8Array> // extra UI-owned files |
| 19 | +} |
| 20 | +``` |
| 21 | + |
| 22 | +Ship a function returning this object (the reference is `createUi()`), with |
| 23 | +prebuilt assets: the viewer SPA is built with relative asset paths, and the |
| 24 | +embedded entry is one self-contained ES module that mounts your dock into any |
| 25 | +host page. |
| 26 | + |
| 27 | +## The client contracts |
| 28 | + |
| 29 | +A viewer renders from the hub's shared state and drives it through |
| 30 | +`@devframes/hub/client`. The simplest boot is |
| 31 | +[`createDevframeClientHost()`](./client-context) — it assembles the whole |
| 32 | +`DevframeClientContext` (docks, commands, renderers, when-clauses, connection) |
| 33 | +and loads dock client scripts for you; the reference UI assembles the same |
| 34 | +context shape with its own reactive machinery instead. Either way, honor these |
| 35 | +contracts: |
| 36 | + |
| 37 | +### Dock entry types |
| 38 | + |
| 39 | +Render the built-in variants of the open dock union |
| 40 | +(`DevframeDockEntryRegistry` from `@devframes/hub/types`): |
| 41 | + |
| 42 | +| Type | The viewer renders | |
| 43 | +|---|---| |
| 44 | +| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` for shared frames); honor `subTabs` soft navigation | |
| 45 | +| `action` | a bar button only — activating it runs the entry's client script | |
| 46 | +| `custom-render` | a container the entry's client script mounts into | |
| 47 | +| `launcher` | a launch call-to-action reflecting `launcher.status` | |
| 48 | +| `group` | one bar button collapsing its member entries | |
| 49 | +| `~builtin` | your own native views (settings, feeds) for reserved ids | |
| 50 | + |
| 51 | +Honor `when` / `visibility` clauses, `category` grouping (order from |
| 52 | +`DEFAULT_CATEGORIES_ORDER` in `@devframes/hub/constants`), and the |
| 53 | +`hub:docks:activate` broadcast. |
| 54 | + |
| 55 | +### The renderer registry and its fallback |
| 56 | + |
| 57 | +**Every other dock type routes through the dock-renderer registry** — build it |
| 58 | +with `createDockRenderersContext()` from `@devframes/hub/client` so local |
| 59 | +registrations, the hub's [renderer |
| 60 | +manifest](./hub-initiate#renderer-modules), and the typed mount result behave |
| 61 | +like every other viewer: |
| 62 | + |
| 63 | +```ts |
| 64 | +import { createDockRenderersContext } from '@devframes/hub/client' |
| 65 | + |
| 66 | +const renderers = createDockRenderersContext({ |
| 67 | + context: () => context, |
| 68 | + manifest: () => manifestState.value(), // the devframe:dock-renderers slot |
| 69 | +}) |
| 70 | + |
| 71 | +const result = await renderers.mount(entry, container) |
| 72 | +``` |
| 73 | + |
| 74 | +The mount result is the fallback contract. A viewer shows a visible state for |
| 75 | +each variant instead of a dead panel: |
| 76 | + |
| 77 | +- `{ status: 'mounted', dispose }` — the renderer owns the container; call |
| 78 | + `dispose` when the view unmounts. |
| 79 | +- `{ status: 'missing-renderer' }` — render a fallback view: *No renderer for |
| 80 | + "`<type>`" in the current environment*. `renderers.has(type)` answers up |
| 81 | + front, so you can render this declaratively without a mount attempt. |
| 82 | +- `{ status: 'load-error', error }` — the module failed to import or the |
| 83 | + renderer threw; render the error with a retry affordance (a failed import is |
| 84 | + not cached, so retrying re-imports). |
| 85 | + |
| 86 | +### The theme contract for renderers |
| 87 | + |
| 88 | +Renderer modules style themselves (they may attach a shadow root inside your |
| 89 | +container). Your part: keep a live `dark` class on the mount container |
| 90 | +reflecting your color mode, and let CSS custom properties inherit — a |
| 91 | +`--devframe-primary` set on an ancestor rebrands rendered content too. |
| 92 | + |
| 93 | +## Reference points |
| 94 | + |
| 95 | +- `packages/hub-ui` — the full reference viewer (Vue, `@antfu/design`). |
| 96 | +- [`examples/hub-vite`](/examples/hub-vite) and |
| 97 | + [`examples/hub-next`](/examples/hub-next) — protocol witnesses: complete |
| 98 | + hand-rolled viewers in ~500 lines of vanilla DOM and React respectively, |
| 99 | + covering docks, the drawer subsystems, the renderer registry, and the |
| 100 | + missing-renderer fallback. |
0 commit comments