A worker can ship pages, function and trigger-activity renderers, forms, and stylesheets into every console tab
at runtime—no rebuild, no iframe, and hot reload. Treat database, console functions/triggers,
iii-directory, and state as proven patterns, not visual templates: reuse
their visual grammar and interaction mechanics while choosing the information
architecture that best fits the worker.
How it works
A worker registers console:script and console:style triggers whose
config.path identifies an asset and whose function_id serves {content}
for {path}. The console hashes and serves those bytes, then pushes changes
to open tabs. Tabs import() scripts and call their default setup(host);
styles load as scoped <link> assets. Re-registering a path hot-reloads it.
Registration is deployment; disconnect is teardown.
Add the internal dependencies
This repository versions both sides of the contract together. Do not try to install them from a public registry:
-
Add
<worker>/uito the rootpnpm-workspace.yaml. -
Add the compile-time UI surface to
<worker>/ui/package.json:{ "name": "@iii-workers/mywork-ui", "private": true, "version": "0.0.0", "type": "module", "scripts": { "build": "tsc --noEmit && node build.mjs", "watch": "node build.mjs --watch" }, "dependencies": { "@iii-dev/console-ui": "workspace:*" }, "devDependencies": { "@types/react": "^19.2.14", "esbuild": "^0.25.0", "typescript": "^5.9.2" } } -
For a Rust worker, link the worker-side registration helper in
<worker>/Cargo.toml:iii-console-ui = { path = "../crates/console-ui" }
@iii-dev/console-ui is types-only at build time; the console serves its
runtime implementation from the active SPA. iii-console-ui registers the
content function, asset triggers, and development watcher. Node workers have
no worker-side helper and implement the wire contract directly.
Project layout
mywork/
build.rs # ensure dist assets exist before include_str!
ui/
page.tsx # the script asset — default-exports setup(host)
styles.css # the style asset — every rule scoped
build.mjs # esbuild, five external specifiers
package.json # workspace dependency on @iii-dev/console-ui
tsconfig.json
src/ # page, renderer, config form, hooks, widgets
src/
ui.rs # embed and register dist/page.js + dist/styles.css
Start from state/ui/tsconfig.json, state/build.rs, and state/src/ui.rs
for the mechanical files; rename worker/asset paths and keep their tests.
For UI structure, consult the references below before writing code.
Authoring workflow
- Read
packages/console-ui/index.d.ts; never guess a component or prop. - Select only the needed slots, then model the primary object, navigation, actions, async states, and state that must survive navigation or reload.
- Design wide and narrow flows deliberately; do not squeeze desktop UI.
- Build with shared primitives and minimal scoped CSS, then type-check, register, inspect the manifest, and exercise the real console.
Living references
| Need | Read | Reuse |
|---|---|---|
| Public API | packages/console-ui/index.d.ts |
Exact exports and props |
| Shared page chrome | console/web/src/components/ui/PageChrome.tsx |
PageShell, PageHeader, surface roles |
| Catalog/detail | console/ui/src/catalog/widgets.tsx, console/ui/src/catalog/FunctionsPage.tsx, console/ui/src/catalog/TriggersPage.tsx, console/ui/styles.css |
Grouped rows, persistent hero, identity masthead, facts, tabs, contextual rail |
| Data workbench | database/ui/src/page/index.tsx, database/ui/src/page/TableDataPanel.tsx, database/ui/styles.css |
Mode bar, schema tree, toolbars, data grid, inspector, nested container responses |
| List/detail editor | iii-directory/ui/page.tsx, iii-directory/ui/src/page/browser.tsx, iii-directory/ui/styles.css |
setup(host), container-width drill-in, dirty-draft guards, per-tab state |
| Multi-level browser | state/ui/page.tsx, state/ui/src/page/browser.tsx, state/ui/styles.css |
One-pane-at-a-time narrow flow, live state updates, stale-request guards |
| Rust delivery | state/src/ui.rs, state/build.rs |
Embedding, registration, asset tests, build freshness |
Copy delivery plumbing when it matches. Do not copy a reference page's sidebar count, breakpoints, controls, or visual hierarchy without deriving them from the new worker's content.
Visual quality is part of correctness
Choose one dominant archetype before writing JSX. Mixing all four produces a generic dashboard with too many panels.
| Archetype | Use for | Required shape |
|---|---|---|
| Console catalog | Many searchable objects with rich detail | Grouped list → persistent hero or breadcrumb + identity masthead + tabs; add a contextual rail only for genuinely related information |
| Database workbench | Several tools operating on one selected resource | Compact mode switcher, collapsible resource tree, one active work surface, local toolbar/status bar, optional inspector |
| Directory editor | Searchable documents with drafts or preview | List → document identity → edit/preview modes; keep draft state mounted and put save status beside the work |
| State explorer | Deep but compact hierarchy | Progressive columns on wide panes and one-level-at-a-time drill-in on narrow panes |
Apply the shared visual grammar
- Build hierarchy with surfaces, not boxes: sidebar, panel, raised toolbar, hover/selected wash. Reserve 1 px edges for structural or tabular separation; avoid borders, shadows, or a card around every section.
- Elevation is three shared tokens, never a hand-rolled stack:
--shadow-raisedfor a card sitting on a panel,--shadow-floatingfor menus, popovers, and sheets, and--shadow-liftfor an instrument surface that must read as lifted off the canvas with a crisp edge (the chat composer). Each is a complete, theme-awarebox-shadowvalue — writebox-shadow: var(--shadow-lift)and nothing else: no border, ring, or extra drop beside it, and never a literal shadow color. - Use a restrained scale: 4/6/8 px for internal gaps, 12/14/20/24 px for section spacing, and the system 6 px radius. Oversized padding makes these dense operator tools look like marketing pages.
- Set document/hero titles around 17–18 px at weight 600; body copy around
12.5–13 px with 1.55–1.65 line height and a 60–72ch measure; metadata around
10–11.5 px. Author interface copy in natural sentence/title case; never use
CSS
lowercaseoruppercasetransforms on tabs, buttons, menus, or forms. - Use sans for all interface chrome, labels, actions, explanations, and prose. Reserve mono for machine-produced ids, paths, schemas, values, payloads, code, and tabular data. Never make a whole panel or its controls mono.
- Repeat one restrained identity glyph in the list row, empty hero, and detail masthead, as console functions/triggers do. Use Lucide icons at the shared 16 px baseline; do not add application icon usages, component defaults, or root SVGs below 16 px, emoji, or a new icon dependency.
- Make list rows full-width targets with one strong primary line and at most one or two quieter supporting lines. Indicate selection with a surface wash, stronger ink, and an optional 2 px neutral edge—never accent color alone.
- Keep page actions in
PageHeader; put resource actions in the identity masthead and work actions in the nearest toolbar. Show one clear primary action at the point of work; move rare actions into a menu. - Use shared line
Tabsfor peer views of the same object. They have a bottom rule, neutral active underline, 600 weight, natural casing, and a semantic 16 px icon by default.SegmentedControl variant="tabs"uses the same line recipe; reservevariant="radio"and its surface track for persisted mutually exclusive choices. Do not fork private boxed tab CSS. - Use compact fact sheets or stat tiles only for useful comparisons. Prefer a
quiet
--color-surfacegroup with label/value rows over a grid of large KPI cards. - Put loading, empty, error, and success states where content will appear so
the page silhouette stays stable. Use
Skeleton,EmptyState, andStatusPanel; never present raw error text as the main design. - For simple tables, compose the shared
TableViewport/TableFrame/Tablefamily. Use natural-case sans headers, horizontal row dividers, comfortable page density or compact chat density, and mono only for technical cells. Make only interactive rows hoverable. Long data grids may add sticky headers, aligned tabular numbers, selection, and an inspector or context rail.
Reject generic generated UI
Do not ship card soup, gradients, glows, ornamental shadows, giant centered headings, excessive badges, random accent colors, repeated descriptions, or an empty canvas with controls floating in corners. Do not give navigation, metadata, and the primary task equal visual weight. Compare the result beside the closest reference at the same width in both themes; its structure may differ, but density, typography, surface hierarchy, and control treatment must feel native to the same console.
1. The script asset (ui/page.tsx)
Ordinary React. Import from react and @iii-dev/console-ui — both resolve
at runtime through the console's import map, so they must stay external
in your build. Default-export a setup(host) function and make every
registration through host (the loader attributes registrations to your
script so it can dispose them on reload):
import {
type Host,
PageHeader,
PageMain,
type PageRenderProps,
PageShell,
} from '@iii-dev/console-ui'
function MyworkPage({
host,
onRequestClose,
}: PageRenderProps & { host: Host }) {
return (
<PageShell className="mywork-ui-shell">
<PageHeader
icon={<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" aria-hidden><circle cx="8" cy="8" r="5" /></svg>}
title="Mywork"
description={host.path}
onClose={onRequestClose}
/>
<PageMain className="mywork-ui-main">
{/* Compose the chosen archetype here. */}
</PageMain>
</PageShell>
)
}
export default function setup(host: Host) {
host.pages.register({
id: 'mywork-manager', // page URL: #/ext/mywork-manager
title: 'Mywork', // nav label
configurationId: 'mywork', // host adds the standard settings action
render: (props) => <MyworkPage host={host} {...props} />,
})
// Register other slots only when their implementations exist.
// host.functionTriggers.register(createMyTriggerRenderer(host))
// host.triggerRenderers?.register(createMyTriggerActivityRenderer())
// host.configForms.register('mywork', MyConfigForm)
// host.providerConfigForms?.register('my-provider', MyProviderConfigForm)
}
This is a delivery skeleton, not a finished design. Compose one archetype in its body before evaluating the UI. Imports from the shared package add zero bundle bytes because they resolve to the running console's React tree.
The shared component library
The package exports page chrome; List/ListItem, Card, CollapsibleCard, CardHighlight, Panel, Chip,
IconButton, and semantic Table parts; line Tabs and SegmentedControl;
Selector and Select; buttons, inputs, dialogs, menus and tooltips;
status/empty/loading components; Markdown and JSON renderers; the terminal atoms (AnsiText,
TerminalStream, TerminalCommandLine); CodeEditor, FileDiff; and
settings primitives (SettingsSection, SettingsList, SettingsRow,
SettingsField, RawValueInput, SettingsDeck, Switch). It also exports the stable uiClasses recipes
and canonical tokens inventory. Read packages/console-ui/index.d.ts for
the authoritative names and props.
Use Selector for searchable single-choice input, including grouped or
disabled options, async caller-owned filtering, loading/empty/error states,
validation, and explicitly enabled free-form creation. Use Select for a
small finite non-searchable list. Use the shared Tooltip parts, or
IconButton for an icon-only action; do not implement independent hover
timers, geometry, or portals. Keep a local selector only for a genuinely
different interaction such as hierarchical drill-in, multi-select, or a
persistent command palette, and document that exception.
Use TabsList variant="line"/TabsTrigger or SegmentedControl variant="tabs" for content navigation. Shared tabs add a semantic icon by
default; pass an explicit icon only when the default does not express the
view, or icon={false} only when there is a documented space constraint.
Use IconButton for icon-only actions such as Refresh or Configure so the
16 px glyph retains an accessible name and tooltip.
Configuration forms: one shared grammar
Every host.configForms implementation uses host-owned primitives. Do not
paint native inputs, selects, switches, buttons, or a private collection deck
to resemble the Console.
- Structure ordinary settings as
SettingsSection→SettingsList→SettingsField/SettingsRow. - Use
SettingsFieldfor editable values. Pass every prop supplied by itsrenderControlcallback intoInput,Select,Selector,Switch, or a domain wrapper. It generates the clickable label, description/error ARIA,data-field, and standard control width. UsecontrolSize="fit"withlayout="inline"for intrinsic controls such asSwitch. - Use
SettingsRowfor values/actions that are not a single labelled field. - Use
RawValueInputfor${ENV}templates and unknown/future scalar values. It may suggest a typed literal, but conversion happens only after the user invokesonUseLiteral. A non-string opaque value still belongs in aSettingsFieldwith an explicit conversion button so field errors remain associated with the control. - Use the shared
Selectfor finite choices andSelectorfor searchable choices. Both supportid,name, anddata-field; a native<select>with worker CSS is never the fallback. - Worker CSS may arrange controls, constrain width, or apply mono to a machine-readable value. It must not override shared control color, border, radius, height, chevron, focus, disabled, or type styles.
- Size responsive controls from the pane/container rather than the viewport.
Back, section/row actions, and field controls keep at least a 44 px target in
a narrow split pane even when the desktop window itself is wide. Put
data-settings-narrow-actionon standalone empty-state actions so they use the same target rule.
For a collection whose item opens a meaningful sub-form, use SettingsDeck.
Its overview should compose Panel + List/ListItem; its detail contains
the selected item's settings. open selects exactly one level at every width:
the overview and detail are never squeezed side-by-side or stacked together.
The deck focuses the pushed heading and restores the originating row on Back.
Keep selection by a stable domain key and set it to null when the item is
removed. For a host deep link, open the requested item first, focus the exact
data-field, and temporarily disable autoFocusDetail so the heading cannot
steal that specific focus. Encode the host path as
focusField.map(String).join('.'), use that exact dotted value in
SettingsField.field, and consume each serialized request only once after its
conditional/deck content mounts. If an item can be removed, put
data-settings-deck-fallback on the surviving overview action that should
receive focus when the originating row disappears.
<SettingsField
id="redis-url"
field="adapter.config.redis_url"
label="Redis URL"
description="Connection used for distributed locks."
error={errors?.get('/adapter/config/redis_url')}
renderControl={(controlProps) => (
<Input {...controlProps} value={redisUrl} onChange={setRedisUrl} />
)}
/>
<SettingsDeck
open={activeId !== null}
title={activeItem?.label ?? 'Connection'}
backLabel="Connections"
overview={<ConnectionList onOpen={setActiveId} />}
detail={activeItem ? <ConnectionSettings item={activeItem} /> : null}
onBack={() => setActiveId(null)}
/>
The host owns dirty tracking, validation, save, reset, and the SaveBar. Back is
navigation only. Update config objects surgically: preserve unknown siblings,
unknown enum/adapter payloads, and templates; display defaults without
materializing them. An opaque root is preserved just like an opaque nested
block and requires an explicit conversion; never coerce it to {} to enter
the typed form. Database is the canonical resource-deck example and Cron is
the canonical small settings-form example.
Selection is always neutral in both themes: --color-surface-selected,
--color-ink, and optionally --color-edge. Reserve --color-accent for a
primary action, form focus, live activity, or semantic domain data.
Use CardHighlight or uiClasses.cardHighlight only for related content that
needs emphasis inside an existing card. It is a borderless, shadowless inset
backed by --color-card-highlight; never use it for interaction states,
status, or as a standalone card.
Use CollapsibleCard, CollapsibleCardTrigger, and CollapsibleCardContent
for expandable cards. The shared primitive owns accessible state, auto-height
motion, reduced-motion behavior, and mounted content; workers must not copy a
private disclosure or height-animation implementation.
PageShell and PageHeader are the stable outer contract for full
pages. They keep identity, height behavior, close affordance, and header
styling consistent with the console. Use PageBody, PageSidebar, and
PageMain when their navigation/workspace model fits; replace the body with
a custom structure when the domain needs columns, a canvas, or a drill-in
flow. Do not replace the outer shell and header.
Use PageSidebar's declarative collapsible, resizable, storageKey,
width bounds, side, narrow, narrowBelow, and narrowMode props instead
of shipping local collapse DOM, drag handlers, width clamps, persistence,
focus logic, or transitions. The Console host keeps a single stable aside,
leaves children mounted while collapsed, synchronizes instances sharing a
storage key, and owns motion plus reduced-motion behavior. Pass narrow when
the page's drill-in state already knows the pane is narrow; use narrowBelow
when the shared sidebar may observe its PageBody parent.
narrowMode="inline" is the default and the required presentation for
primary navigation. It temporarily makes the sidebar a full-width mobile
navigation screen, hides collapse/resize affordances, and ignores (without
overwriting) the saved wide collapsed preference. Use it for catalogs,
list/detail pages, and hierarchies such as scopes → keys → value; preserve
each level as a distinct list, advance one level when a row is activated, and
provide a labelled Back action. The page owns that domain route, not private
sidebar mechanics. Build each level with List, optional ListGroup /
ListGroupLabel, and ListItem; pass its selected, leading, label,
description, and trailing data instead of copying row/button CSS. The
shared row owns full-width card targeting, neutral selection, keyboard
traversal, focus treatment, and mobile touch height.
Use narrowMode="drawer" only for secondary navigation that must overlay an
unchanged, still-mounted PageMain, such as a short section switcher. Do not
put a primary tree or catalog in the drawer. Neither responsive mode
overwrites the saved wide preference.
The pieces own the surface hierarchy (header on --color-panel-raised
with a hairline --color-edge border, sidebar on --color-sidebar, main
on --color-panel) — don't repaint those tokens yourself. No sidebar?
Put content straight into PageMain. Keep onRequestClose wired to
PageHeader.onClose. Keep header actions few and essential; at narrow widths,
move secondary actions into a DropdownMenu rather than allowing the header
to wrap or overflow.
When the page's worker has a configuration entry, set configurationId on
host.pages.register. The Console then places one consistent settings action
in PageHeader and opens the worker in the global Settings modal. Do not add a
second Configure action or mount WorkerConfigurationDialog; that component
exists only as a compatibility bridge for older bundles. This is the stable
form-family id: keep configurationId: 'browser' even when a runtime instance
uses III_CONFIG_NAME=browser-team-a; its configuration::register payload
must carry metadata: { ui_form: 'browser' }.
Responsive structure: pane width, not viewport width
An injected page may occupy a full tab, half of a split tab, or a narrow mobile viewport. A viewport media query cannot distinguish those cases. Observe the page body's own width (or use CSS container queries for purely visual changes) and switch the interaction model at the width where the content actually stops working.
When React must mount different narrow views, reuse the callback-ref
useContainerNarrow implementation from database, iii-directory, or
state: measure synchronously, observe with ResizeObserver, disconnect on
ref changes, and ignore zero-width hidden panes.
Apply these rules:
- Derive the threshold from the content's minimum usable width; do not copy
800or850merely because a reference uses it. - Prefer a drill-in sequence on narrow panes: list → detail, or scope → key → value. Render one primary pane at a time and provide a visible, labelled back control.
- Keep
PageSidebarin its default inline narrow mode for that sequence. Its full-width presentation is shared; do not recreate mobile rails, sheets, width overrides, or collapse state in worker CSS/JS. - Style mobile navigation as one scannable list of full-width rows/cards. Activating a row advances exactly one level; do not flatten parent and child collections into one selector or show a collapsed desktop rail first.
- Remove modes that require width. For example, collapse split edit/preview to one mode at a time.
- Make narrow interactive rows at least 44 px tall. Keep labels truncated or wrapped deliberately; never let the whole page scroll horizontally.
- Put
min-width: 0andmin-height: 0on nested flex/grid panes. Give only the content region that needs itoverflow: auto. - Use
panelSideto mirror side navigation in a wide right-hand pane. Do not mirror reading order or a single-pane narrow flow. - Key persisted UI state with
tabId; treatlocalStorageas best-effort. Guard dirty drafts before navigation and ignore stale async responses after the selection changes. - Keep editors mounted when hiding a preview/editor mode if cursor and scroll continuity matter. Unmount when state must reset between domain objects.
- Test keyboard focus, back navigation, reduced motion, and touch targets in addition to visual width.
Use the shared Monaco-backed CodeEditor for code or long text and
FileDiff for diffs. Put the editor inside an overflow-auto pane. Never
bundle Monaco, CodeMirror, or another editor/diff renderer into the asset.
Terminal-shaped cards (exec output, code runs, build logs) compose the shared
terminal atoms under the same rule: TerminalCommandLine for the $ command
header, TerminalStream for the labeled stdout/stderr pane (set ansi to
color it; tone="err" for stderr), and AnsiText for ANSI SGR text mapped
onto the design tokens. Never bundle an ANSI parser or carry private
terminal-rendering copies.
2. The style asset (ui/styles.css)
Plain CSS, every rule scoped under your worker's wrapper attribute:
[data-iii-ui="mywork"] .mywork-ui-main {
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-iii-ui="mywork"] .mywork-ui-browser.narrow .mywork-ui-row {
min-height: 44px;
}
@keyframes mywork-flash { /* prefix keyframes names — they are global */ }
The console mounts every injected render inside
<div data-iii-ui="<first path segment>" style="display:contents">, so
scoped rules apply to your UI and nothing else. Use the console's design
tokens. The main roles are:
Use --color-bg/sidebar/panel/panel-raised/surface* for hierarchy,
--color-ink/ink-faint/ink-ghost for text, --color-alert/warn/ok and their
muted variants for status, --color-edge/rule-focus for structure,
--shadow-raised/floating/lift for elevation, and
--font-sans/--font-mono/--font-code by semantic role. Accent is not a
selected-state token.
Dark mode is a variable flip, so token-based styles theme for free. Prefer
shared components for controls, keep all UI chrome and prose in --font-sans,
and reserve --font-mono for identifiers, paths, values, payloads, and data.
Use --font-code only for source code, structured payloads, and editor text.
Never hardcode theme colors.
What must NOT be in the sheet: unscoped selectors (:root, html, body,
*, bare element names) and @font-face — injected CSS is unlayered, so an
unscoped rule silently beats the console's fully-layered CSS document-wide.
The console lints every style on fetch (warn-only) and reports findings in
the manifest's warnings array; keep it empty.
Do not use Tailwind utility classes in injected markup: the worker's class
names are not part of the console's compiled Tailwind output. Use the named
shared components and uiClasses recipes; add scoped worker CSS only for
domain-specific layout and data visualization. Use --motion-duration-* and
--motion-ease-* (or the shared motion recipe classes) for state changes.
Streaming text, rapidly updating meters, and cursor-following geometry update
without transitions. Scope custom selectors inside
@media (prefers-reduced-motion: reduce) too; keyframe names remain global
and must carry the worker prefix. Shared components and recipes already honor
the Console's global reduced-motion contract.
Shared Dialog, DropdownMenu, Select, Selector, Tooltip, and
BottomSheet portals preserve the worker's data-iii-ui scope
automatically. If custom domain UI portals directly to document.body, wrap
its portal root with data-iii-ui="<worker>".
3. The build (ui/build.mjs)
esbuild with the five shared specifiers external:
import esbuild from 'esbuild'
const options = {
entryPoints: ['page.tsx', 'styles.css'],
bundle: true,
format: 'esm',
jsx: 'automatic',
outdir: 'dist',
external: ['react', 'react-dom', 'react-dom/client',
'react/jsx-runtime', '@iii-dev/console-ui'],
logLevel: 'info',
}
if (process.argv.includes('--watch')) {
const context = await esbuild.context(options)
await context.watch()
} else {
await esbuild.build(options)
}
Everything else gets bundled in; keep output well under the console's 8 MiB per-asset cap (a slot component should be tens of KiB). Three footguns:
- A forgotten
reactexternal bundles a second React — hooks resolve against the bundled copy's never-installed dispatcher and fail at runtime as a cryptic "Invalid hook call". (A forgotten@iii-dev/console-uiexternal fails loudly instead: the package's bundleable entry throws with the fix in the message.) - Only those five specifiers exist in the import map. A transitive
dependency importing any other bare react-family specifier
(
react-dom/server, …) fails atimport()time, not build time. - Never bundle an editor — use the shared Monaco-backed
CodeEditor(above).
4. Registration (the worker side)
The wire contract is: one content function serving all of the worker's
assets (dispatch on path), one trigger per asset.
Rust workers — the iii-console-ui crate
use iii_console_ui::ConsoleUi;
ConsoleUi::new("mywork")
.script(
"mywork/page.js",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")),
)
.style(
"mywork/styles.css",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")),
)
.register(&iii);
This registers <worker>::ui-content, one Message-path trigger per asset,
and the III_<WORKER>_UI_WATCH watcher. It rejects invalid paths early.
Export ui from the worker library and call ui::register(&iii) after its
normal functions. Adapt state/build.rs so missing/stale UI sources build
before include_str!; preserve unrelated duties of an existing build script.
Always register triggers through your SDK's Message path, never through
the engine's durable register_trigger function. Message-path triggers are
garbage-collected on disconnect and replayed on reconnect.
Node workers implement the same contract directly: register one function
that maps {path} to {content, content_type?}, then one Message-path
console:script or console:style trigger per asset with
config: {path}.
Runtime contract and slots
| Trigger types | console:script (ESM JS), console:style (CSS); never register the tab-only console:assets type |
| Trigger config | { "path": string }, nothing else |
| Path rules | lowercase [a-z0-9._-] segments, no leading slash, no ./.. segments, ≤ 512 chars; extension must match the type (.js / .css); convention: first segment = your worker name — it becomes the data-iii-ui scope and the only human-readable attribution |
| Content function | input { "path": string } → output { "content": string, "content_type"?: string } (content_type defaults from the asset kind) |
| Size cap | 8 MiB per asset — registrations over it are rejected |
| Reload | same path + changed content hash replaces the asset; unchanged content is a no-op |
All registration goes through the per-script host; every entry is disposed
automatically on hot reload and worker disconnect. Each register also
returns a remover for manual teardown.
host.pages.register({id, title, render}) creates #/ext/<id> and adds it to
the nav. Its render receives:
panelSide:'left' | 'right'— which side of the workspace tab the pane occupies; use it only to keep wide side navigation on the outer edge;tabId: the hosting workspace tab's stable id (tabs persist across reloads); key per-tab UI state on it;onRequestClose: close the pane hosting your page (a split drops the column; a single pane detaches); wire it toPageHeader.onClose;workingDir: the active conversation's live working directory, ornull/absent; use only for filesystem-shaped pages and react to changes.
| Surface | What it is |
|---|---|
host.functionTriggers |
Custom chat/trace renderers. Match only the worker's function ids and return null to fall through. message.description is the harness's short activity label. Set renderer metadata: { display: true } only for successful rich artifacts that should remain visible while raw details are collapsed; the hint applies to the renderer that returned the winning node. If raw data contains secrets, implement a pure, total, cycle-safe redactRaw; the raw tab and copy action otherwise expose the original input/output. |
host.triggerRenderers? |
Layered trigger presentation. Match the inner triggerType. tryRender supplies the source section; optional tryRenderDetails and tryRenderDisplay replace the expanded Terminal content and compact timeline content; redactRaw filters raw registration/fire values. Every slot falls through on null. Feature-detect for older consoles. |
host.configForms |
Provide the deliberate UI for one configuration entry in global Settings. There is no schema-generated fallback: every configurable worker must register a form. Render purpose-built fields and call onChange; the schema validates but never generates UI, and the host retains dirty tracking, save, and reset. Use SettingsSection/SettingsList/SettingsField/SettingsRow; use SettingsDeck for collection drill-in and RawValueInput for opaque/template scalars. Never use a raw JSON textarea or restyled native controls, and honor focusField. Pass { layout: 'full' } only when the form is a workbench that owns its internal scrolling; the default contained layout keeps the centered host column. |
host.providerConfigForms? |
Replace the form body for one exact llm-router provider id inside the chat model picker. Use it for provider-owned OAuth, device flow, or companion-app login. The host retains the provider slice, schema validation, dirty guard, save/reset, and model refresh; the component receives { providerId, schema, value, onChange, errors, configured, available, modelCount }. Feature-detect for older consoles. Never solicit plaintext API keys here—direct operators to the provider's declared environment variable. |
host.chat? |
Optional chat integrations: session chips, turn summaries and transcript renderers, plus selectConversation? for explicit worker-driven navigation and composerModel? for the live model selection (including unsaved drafts). Feature-detect the namespace and each newer method. |
host.iii |
The tab's bus client: trigger(functionId, payload?, {timeoutMs?}), on(functionId, handler) (returns un-listen), registerTrigger({type, function_id, config}) (returns un-register), addConnectionStateListener, browserId. Injected UI acts by invoking its own worker's functions. |
host.components / host.path |
Runtime component record and the current script asset path. |
Live data pattern: a page can register its own trigger over host.iii
with a handler id like iii::<worker>-ui::events::<browserId> (the iii::
prefix keeps per-event invocations out of the trace feed). The binding is
GC'd with the tab.
Every injected render is error-bounded; import or setup failures remove the extension contribution and appear in the browser console instead of breaking the entire console. Scripts still run with full console-origin privileges; the wrapper scopes styles but is not a security sandbox.
Give trigger renderers layered ownership
Use the smallest override that communicates the trigger well. The base hook keeps the generic lifecycle/delivery UI; optional hooks provide the same compact-display and complete-detail freedom as function renderers:
interface TriggerActivityRenderer {
id: string
isMatch(triggerType: string): boolean
tryRender(activity: TriggerActivityMessage): React.ReactNode | null
tryRenderDetails?(activity: TriggerActivityMessage): React.ReactNode | null
tryRenderDisplay?(activity: TriggerActivityMessage): React.ReactNode | null
redactRaw?(value: unknown): unknown
}
TriggerActivityMessage.kind is registration, fired, or retirement;
the normalized model also carries triggerType, opaque config, optional
label and action, delivery, lifecycle, and optional payload/outcome fields.
Match triggerType, not engine::register_trigger, because many sources
share that registration function. Parse opaque worker config without throwing
and return null per slot to reach the next renderer or host fallback.
tryRender: source-specific section inside the generic detail view.tryRenderDetails: complete expanded Terminal tab. If provided, include the lifecycle and delivery facts operators need; the host no longer adds its generic terminal content beside it.tryRenderDisplay: compact timeline content inside the host's disclosure button. Keep it non-interactive, one-line, and truncation-safe.redactRaw: pure, non-mutating, total, cycle-safe filtering applied before registration/notification/fire raw panes and copy actions. A throw fails closed to a withheld-value placeholder.
The host always owns the click target, accessible expanded state, animation, renderer isolation, per-slot fallback, and the Raw JSON tab. A once firing and automatic retirement remain one activity; never create a duplicate unbind notice.
For harness registrations, keep identity and event copy distinct:
{
"trigger_type": "on-message",
"config": { "scope": "explorer" },
"label": "explorer-messages",
"metadata": { "action": "new Explorer message received" }
}
label names the binding. metadata.action describes what a future event
means, is available as binding data before it fires, and becomes
activity.action on durable fire records. Registration and active-binding
surfaces identify the binding with label; show action only when
activity.kind === 'fired'. The default fired row is a status mark plus action,
falling back to label, state scope/key, then source; clicking opens the detail
already expanded. Keep action short, standalone, and user-facing. It affects
presentation only, never routing or authorization.
The dev loop (hot reload)
Rebuild-on-save stays in the build tool; re-registration stays in the worker.
Serve the new bytes, register a fresh trigger for the same path, then
unregister the old handle. Register-first avoids a flash; unregistering keeps
the SDK replay map bounded. The Rust helper does this with a one-second
poller. Set III_<WORKER>_UI_WATCH=1 for ui/dist:
# terminal 1, from the workers repo root
pnpm --dir mywork/ui watch
# terminal 2; the default watch path is relative to the worker cwd
cd mywork && III_MYWORK_UI_WATCH=1 cargo run
Every open tab hot-swaps the asset in place — scripts re-import() +
re-setup() (React state in your slots is lost — dispose + remount), styles
link-swap with no flash. Unchanged content is hash-deduped end to end.
Debugging
| Symptom | Cause |
|---|---|
| Registration rejected with a path error | path violates the rules table (wrong extension, uppercase, .., …) |
| Registration rejected with a fetch error | your content function threw, returned no string content, or timed out |
| "Invalid hook call" in the tab | your bundle contains a second React — a missing external |
import() fails on a bare specifier |
a dependency imports a react-family subpath outside the five shared specifiers |
| Styles apply on your page but not in a custom portal | Shared portalled components preserve scope automatically; a custom document.body portal must carry data-iii-ui="<worker>" on its root |
| Whole console restyled | your sheet has unscoped rules — check warnings in the manifest |
| Registered but absent | inspect workers[].enabled and injectableUi.disabledWorkers in the manifest |
Inspect console::ui-manifest (or GET <console-host>:3113/ui),
/ui/<path>, registered triggers, and [iii-ui] browser logs in that order.
The manifest is authoritative; its warnings must be empty.
Testing your worker's UI
Validate all four layers; a successful esbuild run alone is not enough.
- Static: run
pnpm --dir <worker>/ui build; require type-check success, non-empty assets, and no bundled React/editor copy. - Embedding: test accepted assets, an ESM export, and the built worker CSS scope (esbuild may omit selector quotes); run targeted Rust tests.
- Delivery: boot engine + console + worker; require manifest paths, hashes, no warnings, fetchable bytes, and a changed hash after hot reload.
- Real rendering: exercise the actual console, not only an isolated component harness. Cover at least a phone-sized pane (~320–430 px), a narrow split pane, and a wide pane; left and right split positions; light and dark themes; keyboard-only navigation; reduced motion; long names and payloads; loading, empty, error, success, and live-update states; dirty navigation; and worker reconnect.
For host.triggerRenderers, additionally cover exact type match, malformed
config fallthrough, every implemented slot and activity kind, compact
non-interactive display, complete-detail lifecycle fidelity, action fallback,
raw redaction (including thrown-error fail-closed behavior), and worker
disable/disconnect fallback.
The UI is done only when:
PageShell+PageHeaderare present and the close action works;- every configurable page declares
configurationId, and every configuration entry has a registered, purpose-builthost.configFormsinterface; - configuration controls come from
@iii-dev/console-ui; collection details useSettingsDeck, preserve unknown/template data, and restore focus on Back; - the narrow flow exposes every action without horizontal page overflow;
- focus is visible, controls have names, and narrow targets are at least 44 px;
- selected rows, cards, tabs, chips, and segments remain neutral in both themes;
- content tabs use the shared line recipe, natural casing, and default 16 px icons; application icons are never authored below 16 px;
- human-facing chrome is sans; mono is limited to machine-readable content;
- transitions use the shared motion vocabulary and reduced motion is immediate;
- async responses cannot overwrite a newer selection or a dirty draft;
- all styles are scoped and token-based in both themes;
- disconnect/reconnect and hot reload leave no duplicate registrations;
- the manifest has no warnings and the browser console has no
[iii-ui]errors.