Programs import the standard UUI functions from /p/the8020/uui/mod.ts.
Mark interactive programs in programs/<name>/program.toml:
schema = 1
description = "Orders"
entrypoint = "program.ts"
uui = trueuui defaults to false; discoverable defaults to true. Home lists only
programs with both flags enabled. Set discoverable = false on helper screens
that require inputs or should be opened from another program. Returning to Home
does not emit a completion notification. These flags classify programs; they do
not restrict generic kernel execution.
The Programs application (the8020/admin-core/programs) lists every ready
program, including hidden and non-UUI programs. Its main page shows the
description, execution kind, and linked package; Advanced exposes flags,
entrypoint, and active commit. Execute opens the shared
the8020/jobs/run-program form with that program selected. Enter positional
arguments as a JSON array, such as [{"customer":"Example"},42].
The manual form opens UUI programs in the current authenticated session. Job programs offer execution user, sandbox group, and node options, then open their captured result and logs. Selecting another program preserves entered inputs.
Dynamic UUI calls use the same positional argument convention:
import { invokeProgram } from "/p/the8020/uui/mod.ts";
await invokeProgram("example/sales/orders", [{ customer: "Example" }, 42]);Pass a single argument inside an array; omit the array for parameterless programs. The function retains ordinary calls, presentation context, and standard program-failure recovery. Manifests are parsed as TOML and validated, including boolean flags and duplicate-key rejection.
Create a Model once for each logical screen and retain it in your function,
class, closure, or navigation entry:
import { BACK_EVENT, callScreen, Model, z } from "/p/the8020/uui/mod.ts";
const Screen = z.object({ my: z.string() });
const data = { my: "screen_data" };
const model = new Model(data);
while (true) {
const event = await callScreen({ id: "example", schema: Screen, model });
if (event.action === BACK_EVENT) break;
}
model.data.my; // string; model.data === data until explicitly replaced
model.screen.scroll.y; // numberBusiness validation and dirty edits apply to model.data. model.screen holds
framework state: a logical instanceId, reset version, screen scroll, and an
elements dictionary keyed by resolved element ID. A list element's list
state contains its page, measured page size, and complete query; scroll and
toolbarOpen hold its presentation state. Tab regions retain selectedTab so
measuring a newly revealed list does not switch its tab. Transport snapshots are
plain data.
Reassign model.data when refreshing results. Reusing the wrapper preserves its
query, page (clamped when results shrink), and viewport. Creating a new wrapper
starts a separate screen at the top. Call model.resetScreen() to intentionally
clear presentation state while keeping the data and logical instance identity.
One wrapper may belong to only one pending screen call at a time.
Ordinary functions remain ordinary navigation. presentPage() and
presentModal() create presentation surfaces; callScreen() can also replace a
screen on the current surface. Keep the caller's wrapper while awaiting a child
function, or retain it in the corresponding navigation-history entry. A frame
must also retain program-owned context such as a backend batch cursor.
The browser captures screen and element scroll with actions, changes, Back, paging, queries, and capacity updates. Scrolling itself sends no traffic. Newer local scroll survives redraws and navigation within the current browser; reload restores the last synchronized position. Brief reconnects preserve live browser state. This lasts within the live session/Worker; there is no database view-state storage or recovery of program continuations after Worker loss.
Programs and browser components use send(id, data) and
subscribe(pattern, (id, data, context) => ...). The returned
ChannelSubscription exposes readonly id, the identical original handler,
and idempotent unsubscribe(); framework owners store and dispatch that same
object in the backend and browser. Data is ordinary JSON or a readable byte
stream/lazy async iterable. IDs name application messages; no method
registration, payload/result schema, peer catalog or separate stream-handler map
is needed. Subscribers own business validation and any actual request/reply
correlation. context.signal cancels work when its subscription, screen or
connection loses ownership.
A backend program uses its ScreenChannel directly and attaches component Channel objects in their declarations. UUI assigns internal identities; authors never register channel names or repeat component IDs for wiring:
import {
callScreen,
Channel,
Model,
packageAssetURL,
ScreenChannel,
z,
} from "/p/the8020/uui/mod.ts";
const program = new ScreenChannel();
const widget = new Channel();
widget.subscribe("*", async (id, data) => {
if (data instanceof ReadableStream) {
const bytes = new Uint8Array(await new Response(data).arrayBuffer());
widget.send("uploaded", { bytes: bytes.length });
return;
}
if (id === "ready") widget.send("update", { label: "Ready" });
if (id === "selection") widget.send("selected", { value: data });
});
program.subscribe("status", (_id, data) => {
program.send("status-result", { running: true });
});
const pending = callScreen({
id: "channel-example",
schema: z.object({}),
model: new Model({}),
channel: program,
customElements: [{
id: "widget",
module: packageAssetURL("example/widget", "widget.js"),
config: {},
channel: widget,
}],
layout: {
schema: 1,
id: "channel-layout",
root: { type: "custom", customElement: "widget" },
},
});
await pending;The schema above belongs to the screen model. Channels have no required schemas.
Custom fields can likewise supply channel in their custom options. A browser
module uses the automatically attached component or owning program:
export default function mount(context) {
const subscription = context.channel.subscribe("*", async (id, data) => {
if (data instanceof ReadableStream) {
const bytes = new Uint8Array(await new Response(data).arrayBuffer());
context.host.textContent = String(bytes.length);
return;
}
if (id === "update") context.host.textContent = data.label;
});
context.channel.send("ready", null);
context.channel.send("selection", { key: 42 });
context.programChannel.send("status", null);
return { dispose: subscription.unsubscribe };
}Patterns match the entire ID. Only * is special: it matches zero or more
characters, including dots. Literal patterns match exactly; regex punctuation
and other glob characters remain literal. Internal and multiple stars use the
same rule (dashboard.*.rows*).
const handler = (id: string, data: unknown) => console.log(id, data);
const subscription = widget.subscribe("dashboard.updates.*", handler);
subscription.id; // "dashboard.updates.*"
subscription.handler === handler; // true
subscription.unsubscribe(); // safe to call repeatedlyEvery screen owns a real program channel even when callScreen omits channel.
Custom hosts and custom fields always receive both context.channel and
context.programChannel; component Channels are also created automatically when
their declarations omit one. Explicit backend channels only add application
listeners and screen commands. Sending to an unconsumed route drops ordinary
data and cancels streams before source demand, without a backlog. Detached,
disconnected, stale or covered owners still reject operations.
Ordinary data fans out to the matching current subscribers. Synchronous throws and rejected handler promises are isolated; another subscriber and later messages remain usable. Messages with no listeners are dropped. There is no replay, backlog, automatic stream tee, central quota/fairness scheduler or generic RPC layer. Owner-selected stream bounds and the list's 30-second read deadline stay at their owners; transport JSON, framing and size validation remain required.
Each full callScreen and Model reset creates a new generation. Subscription
bindings reattach for a reused backend Channel or preserved browser host; their
in-flight work is aborted. Settlement, removal, failure, exit and disconnect or
takeover cancel unfinished streams. Covered surfaces retain state but admit no
new channel traffic. An unsubscribe removes that subscription and cancels
streams it is consuming. Ordinary sends have no remote completion promise.
Browser context.channel and context.programChannel getters acquire a view of
the current owning call/connection. At the start of a handler or UI action,
acquire a view before awaiting and retain it for that work. Saved output views
never retarget a replacement. Backend subscribers preserve their initiating
trusted async context, and their Channel/ScreenChannel sends reject old work.
Backend work started outside a handler can use channel.capture() before an
await. A producer ignoring abort can continue internally; late framework output
is discarded. Arbitrary business side effects remain owner responsibilities.
One ChannelRouter handles all traffic on the existing authenticated UUI
WebSocket. The session service retains controller/socket/session checks;
internal envelopes bind messages and stream controls to their initiating side,
sequence and screen/connection generations. Protocol version 11 replaces the
previous channel wire forms without a compatibility layer. No endpoint catalog,
permission system, extra backend service or socket is exposed. Do not import the
server mod.ts barrel into browser modules.
Lists use ordinary read, update, result and cancel IDs and only the
list-owned token needed to match a read/update. Headless commands use the same
validated list-domain owner. Independent reads preserve dirty fields and leave
callScreen pending; updates keep existing atomic validation. Only matching list
results/errors or valid presentations release the shell's interaction gate;
ordinary messages, stream controls and acknowledgements never release it.
sendMessage(body, kind?) records an ordinary program outcome in the current
session's bounded message history. Kinds are info (default), success,
warning, and error; Markdown bodies must contain 1–20,000 characters.
Between screen calls, including after a modal returns or before the first
socket, this accepts semantic state only. The next explicit presentation
contains that history; no detached operation waits for or rebinds to its
channel. All existing success/error helpers use this domain contract.
For output from work started while a screen is pending, capture its owner before an await or callback:
import { callScreen, captureMessages, Model, z } from "/p/the8020/uui/mod.ts";
const pending = callScreen({
id: "work",
schema: z.object({}),
model: new Model({}),
});
const output = captureMessages();
await doWork();
output.send("Work completed", "success");
await pending;Initial/headless captures may accept domain state without a physical socket; the first connection change expires that capture too. The domain helper validates its registered owner before accepting history; generic channel sends require a live transport.
The built-in message center attaches even without a supplied ScreenChannel. Its
append, history, and refresh IDs use generic subscriptions on the same
ChannelRouter as lists, custom hosts and programs. Captured output preserves
trusted async context and rejects before accepting history when its call,
namespace, Model reset, connection or initiating operation becomes stale.
Coverage blocks output; cancelled handlers remain cancelled after restoration.
Disconnect/takeover cancels old output; acquire a fresh capture for new work.
Never retain the ambient sendMessage helper as a substitute for that capture.
Download and provider errors use their initiating capture. A source ignoring
abort may continue internally; its framework output cannot enter a new owner.
Accepted history retains at most 100 messages; authenticated headless responses read the ten most recent from that same owner. An accepted interaction clears prior outcomes while preserving messages accepted during its asynchronous work. Browser roundtrips clear their interaction collection. New live records or between-screen outcomes show at most ten toasts; history retains 100. Snapshot initialization/reload reconstructs history without old toasts. Replacement and reconnect merge by monotonic domain identity; dismissed or already seen records are not reintroduced. A registered history refresh transfers state without replaying operations or releasing an unrelated request gate. Pushes neither commit dirty data nor reissue a pending screen. Toast/dialog Markdown, badge, dismissal, focus, keyboard and accessibility behavior stay with the shell.
Define a semantic field with ordinary Zod and the runtime-independent DB helper. Its schema can be reused in tables, forms, and list row structures:
import { field, money, z } from "/p/the8020/db/fields.ts";
export const customer = field(z.string(), {
label: "Customer",
description: "Choose **who receives the order**.",
valueHelp: async ({ query, offset, limit }) => {
// Your query returns at most limit items and whether another batch exists.
return await findCustomers({ query, offset, limit });
// { items: [{ value: "acme", label: "Acme", description: "London" }], more: false }
},
open: async (id) => {
const { default: customerDetails } = await import("./programs/customer.ts");
await customerDetails(id);
},
});
export const orderSummary = z.object({ customer, total: money() });Use t.from(customer) or columns(orderSummary) from the DB package for table
columns; use orderSummary, .pick(), .extend(), and z.array(orderSummary)
for UUI structures. A screen can add presentation settings without changing the
shared definition: field(customer, { length: "long" }), using UUI's field
import. Shared field storage travels with the schema; table-specific keys and
defaults stay in the table declaration.
Every scalar field has pencil help, or a Chevron Right when read-only. Click the
button or press F1 or F4 on the field to open its value with the full Markdown
description. The helper owns a separate draft. Done validates against the
complete caller schema and applies that draft. Close, Back, and Escape
discard it, including invalid input. Done is shown only for editable fields.
Read-only help shows the value and description without loading editable choices.
Use fieldHelp: false in UUI metadata or a control declaration to suppress help
for a particular control.
Tab visits fields, controls, and standalone buttons, skipping field-help icons and overflow ellipses. F2 clicks the focused element. F3 goes Back, closing the active modal before returning to a previous screen. Shift+F2/Shift+F3 loop backward/forward through editable fields, controls, and buttons in the active screen or modal. They skip read-only, disabled, hidden, and inert controls. From another focused element they move relative to its position; with no focus they select the first eligible control. F1–F4 otherwise use unmodified function keys.
UUI field metadata and control declarations accept enterEvent, an optional
non-empty event name (excluding reserved back and uui.account). Unmodified
Enter on an editable native input dispatches that name as the ordinary
screen.event action (eventType: "action"), with the current field value and
all dirty model bindings through the existing interaction gate. Handle
event.action exactly as for a button. Omitting it sends nothing; there is no
boolean option or default event. Textareas, custom editors, selects, and radio
groups retain their own Enter behavior. Composition and held keys do not
dispatch additional actions. An explicitly named event can request a round trip
even when a program does no work for that name.
const schema = z.object({
where: field(z.string(), { enterEvent: "run", shortcut: { key: "F5" } }),
});
const model = new Model({ where: "" });
const event = await callScreen({
id: "query",
schema,
model,
actions: [{
id: "run",
label: "Execute",
shortcut: { key: "F6", control: true },
}],
});
if (event.action === "run") await executeQuery(model.data.where);shortcut is available on fields, control declarations, and body/header
actions. Its key is F5–F12; optional control, alt, and shift booleans
match exactly (omitted means false). F1–F4 are reserved even with modifiers.
Screen construction rejects duplicate combinations across body and header,
including hidden controls. Separate screens and modals may reuse a combination.
Buttons click their normal action; other controls focus their first eligible
native/custom descendant, including read-only text. Lists focus their first
eligible control. An explicit { type: "list", bind: "rows" } layout uses the
shortcut of its bound list control, just like an inferred list. A shortcut may
identify only one placement: ambiguous bindings and repeated placements of a
shortcut-bearing control are rejected. For repeated list bindings, declare
separate list controls with distinct shortcuts and place them by ID in the
layout's controls. Hidden, disabled, inert, detached, and inactive surfaces
are excluded. The top native modal takes priority, including shell dialogs;
closed header overflow stays hidden. Component handlers run first and may
consume a key. Unhandled keys keep browser defaults. The shell installs one
listener and reads the current DOM on each key.
Browser/OS shortcuts can intercept function keys before a page receives them (for example browser refresh, fullscreen, or developer tools), and some keyboards require Fn. These declarations cannot guarantee interception on every platform. Chromium CDP checks exercise Enter, F5/F6, action keys, and a combined-modifier key; synthetic events check every supported combination and do not prove native OS interception. Keep ordinary clickable/focusable controls available.
The optional valueHelp callback runs on the server. A Value help list
below the value field starts with its extended toolbar collapsed. List tools
(+) reveals search and tools. Search resets to page one; pagination and
measured capacity fetch only the requested page. Return at most the requested
limit (1–500) of { value, label, description? } items and a more boolean.
Raw values remain on the server; selecting a row fills the draft, which Done
commits. Enum and explicit control options also appear in the list when there is
no provider. Callbacks should import runtime queries lazily so the same fields
remain usable during table evaluation.
An optional open(value) callback enables Navigate, placed to the right of
Done. It opens the related program on a new page without committing the draft.
Returning restores the helper and its caller. The caller Model stays reserved
while help is open. A reactive field delivers one change event when help closes.
Its ScreenChannel stays attached: background redraws update the covered screen,
and exit/failure settles when help returns.
All array controls, including inferred controls, use the same Worker-owned pipeline: quick search and column filters, stable sorting, then pagination. The program's source array stays intact. Each displayed list has an independent projection even when two lists share the same binding.
const layout = {
schema: 1,
id: "orders-layout",
root: {
id: "orders-list",
type: "list",
bind: "orders",
key: "id", // The Worker derives selection values from the selected row.
display: ["number", "customer", "amount"],
columnOptions: {
customer: { heading: "Customer name", length: "long" },
amount: {
heading: "Total amount",
shortHeading: "Total",
length: "compact",
},
},
triggerFilterEvents: false,
},
};Pass this serializable declaration as callScreen({ ..., layout }). Use
LayoutDeclaration for an explicitly typed declaration with optional region
IDs. For an inferred list, supply the same list options through
field(z.array(Row), { list: { ... } }) or a control declaration's list
member. display selects columns in order. Scalar-array lists have a single
column with the empty-string key. headings is a simple full-heading map;
columnOptions also supports a full heading, shortHeading (including
[[icon=...]]), length (compact, short, medium, long), and
semanticType.
Column semantics come from the row schema. Numbers and booleans receive compact widths; date/datetime metadata identifies date-valued strings explicitly. Formatted strings stay text unless their declaration specifies another semantic type. Comparisons use stable source order for ties. Null/undefined/empty strings sort first ascending and last descending.
Filters use these rules:
| Column type | Filter |
|---|---|
| Text / JSON | Case-insensitive substring of the displayed text |
| Number | Equality, or =, !=, <, <=, >, >= followed by a numeric value |
| Boolean | true / yes / 1, or false / no / 0 |
| Date / datetime | ISO date or datetime, optionally prefixed with a comparison; date-only equality matches the UTC calendar day |
| Any | is:empty, is:not-empty, or is:null |
An empty filter clears that column. Invalid typed operands match no rows. Quick
search uses displayed columns, so omitted navigation fields do not affect it.
Filters combine with AND; quick search matches any displayed column. Changing a
query resets only that list to page 1. Counts refer to the supplied array:
1–25 of 60 (filtered, total 993). Sorting alone does not add the filtered
suffix. Empty results retain headings, tools, filters, and 0–0 of 0 feedback.
Column headings open keyboard-accessible sort/filter popovers. The rightmost
List tools disclosure reveals quick search and icon-only clear-all actions
for applied sorts and filters. Clear all filters removes both quick search and
column filters while preserving sort; clear all sorts preserves filters. The
toolbar has the same height as the column header. Its disclosure overlays the
header's right edge without reserving a table column or body space; it stays
visible during horizontal scrolling. Compact headings keep the full heading
accessible and available in the popover. Its two Sort buttons share one row,
with ascending/descending bars matching the header indicator. The selected sort
has a trailing X; nonempty filters have an X inside the field. Selecting or
clearing sort, clearing a filter, or pressing Enter in the filter field confirms
and closes the popover. Typing filters still applies after a short debounce
without losing focus.
Rows stay one line at a fixed height; truncated cells expose complete text on click or keyboard activation. Readable column widths overflow horizontally only when they exceed the available width, while vertical movement chains to the page or modal.
The expanded toolbar also has icon-only Table processor, Export, Copy page, and Copy all buttons with tooltips. Table processor opens a large, read-only spreadsheet grid supporting rectangular and whole-column selection and copying. Its independent pager defaults to 1,000 rows and offers an editable page number plus previous/next controls. All tools use the current list query, sort, and displayed columns. Copy page uses the page visible in UUI; Copy all traverses all matching rows. The clipboard includes a tab-separated and an HTML table representation for Excel and other spreadsheets.
Export offers CSV, JSON, XML, YAML, XLSX, and ODS with a one-based inclusive row
range defaulting to first through last. JSON/XML/YAML identify columns by their
keys; other formats include full headings. Exact decimal text is retained in
workbooks. Data reads are bounded to 1,000 rows, using the same WebSocket while
leaving the UUI page and unsaved fields unchanged. Export/copy results are held
in browser memory. Tabulator (MIT) and SheetJS CE (Apache-2.0) are vendored
inside services/shell/frontend/components/list/ and load only when needed.
Register an async source once with setListSource(model, listId, source):
setListSource(model, "orders", async ({ query, offset, limit, signal }) => {
return fetchAuthorizedOrders({ query, offset, limit, signal });
});The program chooses the authorized source. Bound arrays use local projection;
registered sources receive the ordinary typed query, offset, a 1–500-row limit
and AbortSignal. Return { rows, more, totalItems?, totalSourceItems? }. UUI
loads and refreshes the visible page, validates its fixed row schema and totals,
and updates rows, geometry and view revisions while callScreen stays pending.
Paging, queries, capacity changes and ScreenChannel.redraw() do not require
program reload loops. Providers retain remote order and own filtering, sorting
and consistency; searchOnly: true registration options hide and reject
unsupported column queries. Whole-table checkbox selection requires a complete
bound array, so registered paged sources cannot opt into selection.
Provider pages belong to their list ID and never replace the bound business array; separate lists may query the same binding independently. Row navigation uses that list's accepted page. Providers expose reads only: dirty row edits on a provider list reject the entire interaction before any data, query, capacity or page state commits. Editable rows and checkbox updates require a complete bound array. Other editable fields can still commit with provider browsing.
The disposer or replacement cancels the source's work and invalidates old row
mappings. Reset, screen settlement/replacement, coverage and connection loss
cancel active work; late results are discarded even if the source ignores abort.
Covered redraws retain presentation state and refresh when restored. Remote
snapshot consistency and interrupting unabortable queries remain source-owned.
Schema and columns stay fixed within a screen call; rebuild the screen contract
explicitly to change them. Optional source-returned value-help schemas must
match that contract. Unknown totals remain unknown until an accepted end page.
The same provider fills bounded independent read operations without changing
the visible page or committing drafts. Field help registers its provider
automatically and retains its first-column key, isolated draft and Done commit.
The first presentation supplies one row for measurement. The browser reports capacity from the actual page/modal viewport and list geometry; the Worker bounds it to 1–500 rows. Resize, toolbar, and visibility changes can update capacity; scrolling does not. Changing capacity retains the former first row within the new page when possible. Lists below other content receive a usable viewport budget after being scrolled into view. The body reserves space for the unfiltered source total up to the measured page capacity, keeping the list, containing card, and pagination in place on shorter pages and filtered or empty results. Reload uses the same source total even when only a few rows are shown; genuinely small sources reserve only their own rows, with one row for an empty source.
triggerFilterEvents defaults to false: list queries and page/capacity
changes update the pending screen internally. With true, a changed query first
merges valid dirty data and screen metadata, then resolves the call with a
ListQueryScreenEvent:
if (event.eventType === "list-query") {
event.listId; // resolved element ID
event.bind; // source collection binding
event.change; // "search" | "filter" | "sort"
event.query; // { search, filters, sort: { column, direction } | null }
// Refresh model.data as appropriate, then callScreen with the retained model.
}Repeat queries, rerenders, pagination, and capacity changes do not emit query events. Standard local array processing still applies after a program responds. Registered providers use the same pending-screen operations as bound arrays. The deliberate query-event option remains separate from ordinary browsing; programs should use it only when a changed query requires program work.
Controls, actions, custom descriptors, and layout regions accept explicit IDs. Omitted IDs use a canonical fingerprint of the element kind, binding, and stable metadata such as its label/title. Values, query/page state, child collections, and global sibling positions do not enter identity. All explicit screen IDs are reserved before implicit IDs are generated; duplicates are rejected. A binding reference in a layout resolves centrally to its declared controls. Each resolved element has one placement; use separate declarations for repeated bindings.
Hash collisions and identical declarations use occurrence ordinals within their collision group. Inserting unrelated elements preserves existing IDs. Removing or reordering indistinguishable duplicates cannot preserve their individual identity reliably; give them explicit IDs when that distinction matters. List column IDs are local to their list. DOM IDs additionally include the presentation surface and model instance, avoiding collisions between simultaneous screens. Custom component internals remain owned by the providing program package.
Programs supply custom browser code through customElements, using a
same-origin module URL, optional styles, JSON config, and preserve: true
for retained instances. UUI loads these assets only when the element is
rendered. A package can publish built files in public/ and reference them
with:
customElements: [{
id: "editor",
module: packageAssetURL("example/editor", "editor.js"),
styles: [packageAssetURL("example/editor", "editor.css")],
preserve: true,
config: { documentId },
}];The browser module default-exports mount(context) using the browser-only types
in /p/the8020/uui/custom_element.ts. The context supplies host, live
config, an abort signal, renderText for text and [[icon=...]] markup,
and send(action, value) through the owning screen's normal interaction gate.
Return an object with optional update, captureState, setActive, and
dispose methods. Async mounts must observe cancellation; a late returned
instance is disposed automatically. The program owns the wrapper's contents,
scoped CSS, dependencies, and any additional assets. Use content-hashed build
filenames for immutable caching; unversioned files use ETag revalidation.
Custom fields use the same descriptor in
field(schema, { custom: descriptor }). The host exposes the current control,
value, and setValue(value); writes use ordinary dirty bindings and reactive
change events, and read-only/hidden controls reject edits. length and
rowSpan use the normal field grid. A component's update reads the current
context after server redraws or another control changes the same binding.
Define an agent fallback once in the component descriptor. Inputs and outputs
address named paths relative to its bound value; "" means the whole value.
Outputs are read-only, and actions use the component's existing screen events:
fallback: {
inputs: [{ name: "input", path: "input", label: "Input", multiline: true }],
outputs: [{ name: "output", path: "output", label: "Output" }],
actions: [{ name: "run", label: "Run", event: "run" }],
}The code editor already supplies its value input. The sandbox's uui command
can inspect it with uui screen and edit it with
uui set FIELD/value --file source.ts. The shipped the8020-dev-uui-control
skill documents session creation, commands, value help, and browser takeover
through the URL returned by uui url.
Every component can write presentation metadata through context.state, which
is its entry in model.screen.elements[id]. Use scroll for its scroll offsets
and data for component-specific JSON such as selection or expansion. data
has a 32,768-character JSON limit, eight nesting levels, 128 properties per
object, and 256 items per array. Built-in components use the same
screenElement(state, id) accessor. Optional captureState() flushes pending
local metadata before normal interactions and redraws. State survives redraw,
navigation, and reload after synchronization; Model.resetScreen() clears it.
Scroll never sends standalone network messages. Business values belong in the
model rather than metadata.
import { codeEditor, field, z } from "/p/the8020/uui/mod.ts";
const schema = z.object({
source: field(z.string(), {
label: "Source",
custom: codeEditor({
language: "typescript",
markers: [{ line: 12, kind: "added", label: "+" }],
}),
length: "long",
rowSpan: 5,
readOnly: false,
reactive: false,
}),
});The optional CodeMirror 6 component and its selected
language load on demand from vendored shell assets. The checked-in browser
bundles need no editor installation or CDN access at runtime; vendor.sh only
refreshes dependencies during development. Supported modes are text,
javascript, typescript, jsx, tsx, json, html, css, sql,
python, markdown, yaml, go, shell, and toml. Structural diagnostics
use the local parser, without resolving imports, types, or external symbols.
Text, Markdown, SQL, Go, shell, and TOML have highlighting only.
syntaxCheck: false disables checks; use it for partial source excerpts.
Diagnostics do not block model submission or replace schema validation.
The editor follows the UUI light/dark palette and includes Copy all, undo/redo,
content fullscreen, and remembered scroll/selection. Tab retains native focus
navigation. wrap enables line wrapping. firstLine offsets source-excerpt
line numbers; revealLine selects the initial viewport until retained state
exists. markers accepts one-based line numbers with added, removed,
error, or breakpoint styling and optional gutter labels (including icon
markup). Markers refer to displayed lines; callers supply diff or debugger data.
The terminated program uses a read-only excerpt with original line numbers and
an error marker. Program-owned custom modules can wrap the editor's default
mount and use the returned editor (native EditorView) for commands and
extensions; the vendored library exports the shared CodeMirror state/view
primitives.
Protocol version 11 carries generic ID/data channel frames. Each screen snapshot
carries state, element-specific lists, and channelGeneration. List
bindings in the wire business model are empty arrays; displayed rows live in
lists[].rows. The list's update operation on the common namespace carries
page/capacity/query/refresh/selection with validated dirty edits and
presentation metadata; multi-list capacity updates commit as one transaction.
Independent reads use that namespace's read operation. View revision and
source identity/content/order checks remain list-owned. There are no read wire
aliases.
A byte stream is sent as the data through the same interface in either direction:
const handle = widget.send(
"bytes",
ReadableStream.from([
new Uint8Array([0, 255, 42]),
]),
);
await handle.done;
// handle.cancel() cancels this transfer.Handle streams in the same browser subscriber used for ordinary messages (extend that callback rather than installing a second one). Return the consumption promise so the sender can observe completion or failure:
context.channel.subscribe("bytes", async (_id, data) => {
if (data instanceof ReadableStream) {
const bytes = new Uint8Array(await new Response(data).arrayBuffer());
context.host.textContent = String(bytes.length);
}
});
// Reverse direction:
await context.channel.send(
"upload",
ReadableStream.from([
new Uint8Array([128, 0, 7]),
]),
).done;Streams require exactly one matching active subscriber, which owns consumption;
zero or multiple matching subscribers reject the transfer before source demand.
Unrelated subscriptions do not affect stream admission. The same wildcard
matcher filters ordinary data and stream-open delivery. Consume EOF and resolve
only after consumption or the domain's native handoff is accepted. Early
completion, a thrown/rejected subscriber, producer failure or stream
cancellation rejects the sender's done. Ordinary data may still fan out. The
core never buffers missing-listener traffic or duplicates a byte stream.
channel_streams.ts retains the sole binary codec, lazy pump and
consumption-credit implementation. Generic callers need no configuration. The
transport splits source chunks into at most 64 KiB frames, bounds individual
source chunks at 64 MiB, and uses four frames per stream. The stream-open frame
advertises its window so a smaller sender window is respected. Credit follows
pending reads and includes queued/in-flight reservations. There is no shared
application active-stream quota or scheduler.
Owners can pass { signal, policy } as an optional third send argument when
their domain needs tighter stream bounds. Native saves retain their existing
shared policy object and startup deadline. These policies do not add required
message contracts or a separate receiving API. Streams retain screen/connection
ownership; they are transient and never replayed.
download(options, attachment) starts native save through that same stream
core. Capture channel.downloads from an explicit pending callScreen, then
supply a filename and a readable byte stream or lazy async iterable:
import {
callScreen,
download,
Model,
ScreenChannel,
z,
} from "/p/the8020/uui/mod.ts";
const channel = new ScreenChannel();
const pending = callScreen({
id: "export",
schema: z.object({}),
model: new Model({}),
channel,
});
const handle = download({
filename: "backup.csv",
contentType: "text/csv; charset=utf-8",
body: csv(),
}, channel.downloads);
await pending;Keep that call pending while producing the file so the UI remains interactive.
Settlement or full replacement cancels unfinished saves. Calls between screens
may prepare options, then explicitly create the next pending call and start with
its captured attachment, as the demo text/CSV producers do. Detached or saved
old attachments fail before source demand; there is no next-screen rebinding or
backlog. Ignore the handle if desired; failures are attributed to the filename
through the existing message center. Use handle.cancel() or await
handle.done while the owning screen remains pending.
For a virtual CSV file, produce one database batch at a time. In this example,
readNextRows(afterId, limit) is your application's query, returning rows
ordered by a unique numeric id, strictly after afterId when supplied:
async function* csv(): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder();
yield encoder.encode("id,name\r\n");
let afterId: number | undefined;
while (true) {
const rows = await readNextRows(afterId, 1_000);
if (rows.length === 0) return;
yield encoder.encode(
rows.map((row) => `${row.id},${csvCell(row.name)}\r\n`).join(""),
);
afterId = rows[rows.length - 1]!.id;
}
}
function csvCell(value: string): string {
return `"${value.replaceAll('"', '""')}"`;
}Queries and CSV encoding belong to the program. If an export needs a consistent
database snapshot, the program must also provide that query/transaction policy.
Use the generator's finally block to release cursors or other resources. On
cancellation UUI calls the iterator's return() or the stream reader's
cancel(); an already running query must settle before an async generator can
finish cleanup.
Downloads share the existing authenticated WebSocket and can run concurrently. UUI splits source chunks into at most 64 KiB frames, with a shared 16-frame per-direction window, a 32-frame aggregate bound and at most four outstanding frames per transfer. Credits advance as the browser consumes the stream. Memory depends on your producer's batch/chunk size plus bounded transport buffers, rather than total file size. Up to 32 transfers can be active under the native-save policy. Transfers cancel on owning screen settlement/replacement, WebSocket loss or session end; reconnect does not restart them. Source chunks are at most 64 MiB and startup is bounded to 30 seconds.
The browser shell uses a local service worker to hand the stream to the native
download manager. File bytes never use a second backend request. This requires
HTTPS (or localhost), service workers, and transferable streams; the browser
test covers Chromium. The filename is a suggested name without a directory; the
browser chooses the destination. done means the native response consumed the
stream and the local service worker acknowledged handoff, not that UUI knows the
final disk path or can attest to durable storage.
Run deno task check, deno task test, rebuild with
DENO_IMPORT_MAP="$PWD/deno.local.json" bash services/shell/build.sh, then run
deno task test:download-browser for native browser download verification.
Use [[icon=delete]], [[icon=rocket_launch color=primary]], or
[[icon=10k color=#3A7]] in button labels, headings, or list text. The entire
vendored Google Material Symbols collection is available without registering
individual names. Browse names in the
Google icon library or
services/shell/frontend/assets/material_symbols.json for the exact bundled
catalogue. The font is served locally and cached; clients never contact Google.
Unknown names and invalid colors remain literal text.
List cells, field hints, and column descriptions share a subtle, font-sized ellipsis button. The text keeps its normal behavior, including row navigation and selection. Enter or Space opens the full Markdown content; Escape returns focus to the button. Popovers open below and to the left, stay inside the viewport, and scroll when their content exceeds the maximum height. Column headers inherit the shared field description, previewing up to 100 characters and three lines before the filter and sort controls.
Browser renderers can reuse services/shell/frontend/overflow.ts's
createOverflowText(content, { label, preview: "markdown", maxCharacters: 100 }).
Omit maxCharacters for width-based truncation and use the default text preview
for literal values. Append the returned element and call disposeOverflowText
on its owning subtree before permanent removal. refreshOverflowText batches
measurements when a renderer already owns a layout pass; ordinary size changes
are observed automatically. popover.ts owns the shared AnchoredPopover
controller for other local popover content.