Shared UI and design system
Shared operation progress dialog
PluginOperationProgressDialog accepts only the shared operation presentation fields: title, common status, translated statusLabel, optional detail, percent and historyUrl. Core memory dreaming and plugin operations use the same progress, minimize, history and focus behavior. No source-specific transport or state lives in this dialog; absent detail renders no paragraph, and null percentage is indeterminate. The parent source owns durable acknowledgement and navigation.
Project identity
Project labels use projectName from packages/plugin-shared/projectChoice.mjs and ProjectIcon from
web/components/ui/ProjectIcon.tsx. The name reads computer.label when present and otherwise the
registered slug. The icon gives computers the laptop glyph and preserves stored project icons for
host and managed projects. ProjectFilterPills applies both to its options and selected summary;
chat menus, Projects cards and account computer rows use the same pair. The plugin runtime exposes
that exact implementation as utils.projectName and components.ProjectIcon in UI API 62, with the
computer metadata in PluginUiProject. No surface derives a computer name from its internal slug.
Explicit identifier fields and identifier search may still show or match a slug.
Project deletion
web/modules/projects/ProjectDeleteDialog.tsx owns confirmation for both the Project register and
Account > Computers. Mount it with a Project and an onClose callback. Each opening calls
projectDeletion(id, deleteFiles) with a required boolean, using
GET /projects/:id/deletion?deleteFiles=true|false; while that read is pending or fails, removal stays disabled.
The "Also delete files" checkbox starts false and its value belongs to the preview query key.
Changing it fetches a new inventory before confirmation is possible. The authoritative preview
deleteFiles value, not the requested value, is sent on DELETE. An existing durable operation
supplies its stored choice and disables the checkbox, including when discovered by the preview.
Unchecked preserves the working directory without resolving or contacting that path and carries
no file authority or preservation receipts (preservedPaths is empty); checked adds canonical paths to the inventory,
subject to protection and overlap preservation. Plugin-owned storage and any managed environment
disk are removed regardless of this option; the dialog states this next to the control.
The dialog lists the daemon's exact paths (including adopted host directories and paired working
directories) and generic participant labels/details, never plugin-specific browser inventory.
Unavailable owners are named as an incomplete inventory, not an empty resource list.
Destructive confirmation explicitly includes pre-existing directories and all their contents only
under paths. The required preservedPaths: { path, reason: 'protected' | 'overlap' }[] inventory is
listed separately with a localized reason: protected system/home/application storage or overlap
with another live Project. Its files remain unchanged while the Project and associated resources
are still removed. Classification and canonical path ownership belong to core, not the browser.
The shared ConfirmDialog description wraps unbroken path segments without changing their text; its scrolling
body must not hide inventory horizontally. The host-only optional ConfirmDialog.children slot renders
controls before the description inside the same scroll body; this dialog uses it for the shared
Radix checkbox and its explanatory copy. The caller owns control state and pending/operation locks;
without children the existing confirmation layout is unchanged. The public plugin props stay unchanged.
useRemoveProject submits required deleteFiles, confirmedPaths, confirmedItems and confirmedPreservedPaths
from that preview with DELETE, including retries. A changed inventory is refused by the daemon; the query is refreshed and the
dialog stays open for another explicit confirmation. HTTP 202 carries a durable ProjectDeletion,
not an environment operation. The register and computer list retain inactive deleting projects;
ProjectDeletionStatus distinguishes pending work from a blocked owner/error and offers the same
confirmation again to authorized managers. Normal Project actions and drawers are unavailable
during deletion. Existing Project polling (three seconds while deleting) observes completion across
reloads. No browser queue, path-protection heuristics or plugin-specific cleanup is involved.
Canonical selection surfaces
ManageSelectionItem.secondaryText optionally adds one muted line beneath a row's label.
Only those rows grow from 40 to 56 px; existing badges and selection behavior stay unchanged.
Editable rows expose the detail through aria-description, preserving their existing accessible
names. The provider model picker uses it for server-reported effective API names.
Single-choice rows use the Select's choiceItemClass and trailing choiceCheckClass, including
Radix DropdownMenu radio rows for work mode, language, dock position and desktop stream preference.
Radix retains menu keyboard and dismissal behavior; the model menu keeps its existing explicit
active-model check. ProjectFilterPills is a catalog adapter to the same anchored RowPicker,
with optional All and caller-owned persistence, rather than a second pill or menu renderer.
UI API 57 publishes exact choice, project-filter, selection-summary and summary-chip props.
The summary/chip line variant is removed, and registry UI tests consume the candidate's actual
choice, project-filter and summary components. The obsolete
picker and project variant presentation flags are removed. Editor uses the project adapter
without a variant. Fewer than two projects still omit the filter; catalog preparation is linear
and adds no requests beyond the existing project query.
WorkModeMenuGroup in ComposerActions is the single catalog-filtered mode group for the composer and telemetry mascot menus. Both execute the provider's existing runSlash command, retain their scoped test identifiers and suppress another command when the selected mode is chosen again. The mascot owns only its curated one-off action rows. Conversation surfaces use LoadingState until the first snapshot confirms history, then EmptyState for an empty history; the existing chat-empty hook owns full-page centering. Telemetry's empty rail also uses EmptyState. Stream reconnection, older-history paging and transcript disclosure keep their existing lifecycle owners.
The Agents window (web/modules/advisor/AgentsModal.tsx) draws each delegated child as a vertical card,
never as a table (owner, 13 Oct 2026). On a desktop it is the Settings reading frame (size="page",
centered) with the cards in the shared CardGrid, so a row of cards shares one height and the delivery
footer lines up; on a phone it takes the screen and the cards become equal-width 10.5rem columns that
continue sideways. A card carries status, name and task, the live activity line, model and thinking
level, runtime, tokens, tok/s, tools, start and update times, and mode and delivery. The whole card opens
the child transcript for the pointer; its chevron is the named open control and the single tab stop.
Only the in-conversation agents count opens this window. The expanded telemetry rows open their
individual child directly; the compact Agents tile offers those children in the existing shared
ActionMenu (shadcn dropdown), with a truncated name/task, status glyph and model mark. Its bounded
menu also includes running workflows; with no live children the tile keeps opening the first
workflow's DAG view. Empty live selections render no tile. ActionMenu labels truncate within their
menu's width and retain the full label in their native title. The Subagent manifest declares its
built web/index.css, so menu sizing and trigger styles do not depend on the host's utility scan.
Both telemetry densities send agent:<sessionId> to TelemetryPanel.useRailOpen, which uses
focusSubagentSession(id, name), the same read-only child navigation as the modal card, and reports
opening failures through the existing localized toast. The modal is not a telemetry target.
agentPresentation.orderAgents copies the input and sorts running children before terminal ones,
newest startedAt first within each group; entries without a start use updatedAt, then undated
entries retain input order. The modal applies it to every child; telemetry applies it before handing
the still-running/pending-result selection to the plugin. This is render-only, costs O(n log n),
makes no requests and adds no wire fields.
The rail's pinned project foot (TelemetryFoot in TelemetryPanel.tsx) draws project.cwd and, only when the
daemon reports one, project.branch from GET /brain/status, the same field the CLI rail renders. The daemon reads
the branch where the conversation works (host, managed environment or paired computer); when it reports none, for a
directory that is not a repository, a detached HEAD, a stopped environment or an offline computer, the foot draws the
directory alone and no branch row, and with neither a directory nor a branch it renders nothing. The browser never
derives or substitutes a branch. web/tests/modules/chat/ChatTelemetry.test.tsx covers both states.
Bounded shared UI
UI API 60 retires the unused ElowenUiRuntime.hooks.useProjectFiles and its
elowenClient.projectFiles wrapper. The host and UI kit share the new API number; the hook had no
named kit declaration and no production caller in bundled or registry plugins. The compatibility
ceiling cannot block an older bundle from calling a removed member, so the removal follows a search
of all consuming repositories. Editor keeps its own paged directory query over the authenticated
GET /projects/:id/files root mount, with the generic host API transport and existing loading,
empty and error states. This cleanup makes no visible UI change or performance claim.
UI API 57 published StatusDot from web/components/ui/DetailPrimitives.tsx (StatusDotProps lives in the
UI kit). UI API 58 withdrew PropertyGroup, PropertyRow and DetailBlock from the plugin runtime and
publishes the Settings primitives SettingsState, SettingsValue, SettingsList, SettingsItem and
SliderWithReading next to SettingsGroup and SettingsRow: plugin panels in a window draw the same
anatomy as the host's sections (see "Window pattern (Settings)" below).
UI API 56 exposes the host's web/lib/projectUploads.ts::uploadProjectFile through
ElowenUiRuntime.utils. Chat supplies its conversation, Editor supplies an explicit Project and
relative directory; both use one open/part/commit protocol with durable byte progress, offset recovery,
retry and AbortSignal cancellation. Editor renders the existing Progress primitive, refreshes its
tree on commit and discards pending uploads on unmount or root change. The client holds Blob slices
rather than buffering the file, and imposes no total size or type cap. Plugins import only its UI-kit
type and call the runtime function; no browser runtime code crosses the Next bundle root.
ConversationIcon is the core-only conversation identity renderer, analogous to ProjectIcon. An empty saved name uses MessageCircle; it resolves names through the same categoryIcons map and ICON_ALLOWLIST as memory categories. A known native programStatus uses exactly one 6px upper-right dot: success for idle/done/clear, destructive for working or independent background work, warning for blocked user input, and destructive without a pulse for error. Blocked and error take precedence over background work. Working pulses with reduced-motion support; an unknown status has no dot. Unread and background information remain in the screen-reader label and title alongside permission/question, scheduled completion and failure detail. The existing Chat badge keeps unread activity visible elsewhere. An optional identity node replaces only the saved glyph, for example <ConversationIcon identity={<ProjectIcon project={project} />} ... /> in the conversation register; omitting it preserves every existing consumer. Activity accepts managed projections without an unread cursor. The register always passes its explicit caller-relative item.unread === true, never another account's read state. The dot itself is the exported ConversationStateDot (free, working, input, error), decorative and labelled by its surroundings; the conversation register's Status filter draws its options with it (Unread as free, the finished state with a result), so the filter and the rows speak one colour language. The same filter bar marks Model options with ModelIcon and Type options with PlatformIcon (Scheduled as cron), the marks the rows already use.
ConversationIconPicker places that renderer in a ghost icon button and the existing in-place Popover. For example, SidebarNav mounts it beside a separate conversation-title button; both the trigger and picker stop row-click propagation. It persists through elowenClient.brainSetSessionIcon, updates and invalidates the existing owned/admin listing caches, locks choices during the write, reports errors through Toast and returns focus after selection or Escape. Read-only/foreign rows use ConversationIcon directly. No plugin UI runtime export or extra polling is added.
IconPicker is the single five-column choice grid used by CategoryModal and ConversationIconPicker. It takes a saved name, the caller's default icon and onChange; it enumerates the shared allowlist with cs/sk/en labels. Roving focus supports arrows and Home/End, native buttons commit with Enter/Space, and an empty value selects the caller's default. Categories still default to Folder; conversation bubble selection stores an empty name.
The base primitives are in web/components/ui/shadcn/. They wrap Radix primitives and provide the app's accessible keyboard, focus, dismissal, and responsive behavior. Use these primitives and existing app wrappers instead of implementing local dialogs, menus, selects, toasts, or focus handling.
DropdownMenuItem takes size="icon" for an icon-only row action beside a wider row in the same menu. It applies the shared Button icon geometry (36 px, the touch target on a coarse pointer), centres the glyph and keeps the row's variant, highlight, disabled state and roving focus; the icon needs an aria-label. Omitting size leaves the ordinary full-width row. The advisor launcher's Stop action per running conversation is the consumer; it combines size="icon" with variant="destructive", the menu's counterpart of the ghost-danger Button.
Connection status (API 64)
ElowenUiRuntime.components.ConnectionStatus publishes the actual host component
from web/components/ui/ConnectionStatus.tsx, not a bundled React duplicate.
ConnectionStatusProps imports canonical types from elowen-plugin-shared/retry:
type ConnectionStatusProps = {
label?: string;
onRetry?: () => void;
className?: string;
} & (
| { connectionState: ConnectionState | null; retry?: never }
| { retry: RetryProgress; connectionState?: never }
);
const { ConnectionStatus } = runtime().components;
<ConnectionStatus connectionState={connectionState} onRetry={retryConnection} />
<ConnectionStatus retry={modelRetry} label={modelLabel} />
Use one instance per owner/lane, never both union branches. null means unknown,
not disconnected; transport retry comes only from that observation's retry.
Fresh connected transport shows readiness through the dot: green only for ready.
Words follow only for pending; ready and unknown add no text beside
"Connected". Stale or
unknown freshness retains last-known observation with muted qualification and
suppresses live retry/countdown. A server-observed disconnected computer without
retry evidence says remote progress is unknown, never inventing remote attempts.
Fresh retry progress replaces the transport words with one short line from
formatRetryProgress (cs/sk/en), the same text the CLI prints: "Reconnecting 2/5"
for a connection, "Retrying 2/3" for a model request, "Connecting" for the initial
attempt, and one phrase for an outcome ("Connection lost", "Connection unavailable",
"Stopped", or the sign-in/access reason that the reader must act on). A count
without a known limit shows only the number; an unknown number shows none. Native
scheduled delay reads "delay 2.5 s"; with a local countdown the delay is dropped and
the countdown follows as "· next attempt in 2 s" (formatRetryCountdown), so the
whole status reads "Reconnecting 2/5 · next attempt in 2 s".
A separate aria-hidden countdown uses the existing shared useNow subscription
ONLY for a fresh ConnectionState with authority.kind === 'local-owner',
retryOwner === 'shared', non-request mode, scheduled phase and known
nextAttemptAt. Native PI/SDK/model progress stays static even when the producer
supplies a real delay/deadline; remote shared schedules also remain static because
the browser clock is not their authority. Stable
role="status", polite/atomic transition copy does not tick each second, and
reaching zero never dispatches or announces an attempt. Unmount removes the
clock subscription. onRetry belongs to the caller: no callback means no button;
with one, recovery is offered for terminal retry or absent/disconnected/unknown/
stale observation, not a second scheduled/attempting action. The component never
fetches policy, opens transport, changes authority or owns a controller.
ChatFooterDock consumes separate transport and model instances. The transport lane
carries its "Connection" label only without live retry text, which names the
connection itself; the model lane keeps "Model request". Its transport lane
stays hidden while the chat stream recovers on the first attempt of an outage: the
stream owner (brainChatStream) counts attempts from the retry number at which the
controller last reported the episode connected (a restarted episode from none) and
publishes connectionFirstAttempt in BrainChatStatus. A recovery that succeeds
there shows nothing; once that attempt fails, the scheduled second attempt and
everything after it, terminal phases and setup failures appear as before. This holds
for the initial attachment too. ComputerState
uses facts.connectionState without a callback; ComputerLastOnline appears only
for an explicit fresh disconnected observation, not a missing/stale/list-failure
read. Computer cards and usage rows wrap/min-width longer text. Other descriptions
remain intact. Presentation cost is linear in visible lanes, with no I/O or
retry timer; only a mounted visual ticker subscribes to the existing shared clock.
ProjectPicker also uses ConnectionStatus with
telemetry.project?.connectionState ?? null, not the catalog
computer.connected boolean. SwitchableProject.computer remains label/connected;
no catalog wire field is invented. Its local cwd/title, durable selection and
menu keyboard/mutation behavior remain unchanged. Focused tests cover the real
formatter/component/footer plus computer/picker/card callers (70 cases across
five files); neither these nor the strict kit probe establish integrated provider,
full-web type or browser acceptance.
Desktop declarations in API 64
The same coordinated API 64 change requires authoritative ticket expiry and attempt cancellation, rather than compatibility overloads:
VncTicketResultticket values requireexpiresAt: numberin epoch milliseconds, plusurland optional geometry/credentials.VncSurfaceProps.ticket(signal: AbortSignal)andDesktopControlClient.ticket(signal: AbortSignal)require the attempt signal.- The
viewer_limitrefusal alone acceptsretryAfterMs?: number | null; an available hint is a not-before minimum, not retry permission.unavailableremains a distinct refusal without a hint. DesktopStreamStateexposesconnectionState: ConnectionState | nullandretryConnection(): void. Its existing requiredconnected: booleanremains a domain projection for control/lease callers, not a competing transport owner.
A finite viewer attempt owns one cancellable ticket request and consumes the server expiry; it must not invent expiry or nest another REST retry loop. Manual stream recovery is GET-only on the current target and grants no control lease or lease reacquisition. The native ticket producer already returns its issued 15-second expiry. See the desktop boundary. These declarations require coordinated caller/fixture migration; strict type probes alone do not establish viewer runtime, browser or full-candidate acceptance.
Desktop connection presentation
DesktopPreview consumes the real host ConnectionStatus for independent viewer
and control lanes. Czech, Slovak and English desktop.viewerConnection and
desktop.controlConnection label those rows; no private formatter/countdown owns
timing. Collapsed rows sit BESIDE the tile's open button, never inside it. Opening
the desktop moves the same presentation into expanded notices instead of drawing
duplicate recovery controls. Ready lanes disappear independently; only fresh
ready viewer observation admits painting and setViewConnected.
Each Retry reaches only its matching viewer owner or existing control GET owner, never takeover/release or lease reacquisition. A closed session neither mints another viewer ticket nor offers connection retry. Scheduled/exhausted/terminal viewer work has no indefinite active spinner; passive still loading/stalled state remains independent. The control-domain dot no longer invents a local disconnected state. Disabled/absent surfaces start no viewer, and the single retained canvas still moves rather than duplicating expensive RFB viewers. See finite desktop runtime. Focused fake-network tests exercise the real policy/bootstrap and single-attempt ticket seams; presentation fixtures are not live API or browser-flow acceptance.
Conversation register list
The core conversation register is the list exception to the other long-register tables. Its
switcher uses the shared Settings reading frame (Modal size="page", centered on desktop,
fullscreen on phones), with no local dimension overrides. The feature-only
ConversationList, ConversationListRow and ConversationListToolbar consume the props in
web/modules/advisor/conversationListModel.ts; they fetch no listing and decide no ownership.
The panel supplies creation, destructive scope controls, read states, a single clock value,
authorized capabilities and the page's section fragments. Section totals are full filtered-root
counts, not child counts. No section heading is rendered without page roots.
ItemGroup owns list semantics. Each enclosing Item is one list item, holding its visual row
and, when expanded, a named branch region. The visual row uses ItemMedia, ItemContent and
ItemActions without adding another semantic list item. Only the root's visual row carries
data-conversation-root, so tests and specs can count page roots apart from headings and
expanded branches. A page holds a fixed 30 roots (PAGE_SIZE in ConversationListPanel) and
scrolls inside the panel's scroll box; headings and expanded content never change the page size.
Children recurse into their own ItemGroup; renderBranch(node, item) supplies the existing
plugin host or its list-compatible fallback inside that region. Collapsed branches are not mounted,
and disclosure aria-controls points only to a mounted region. No table roles or plugin-specific
renderers are embedded in these components.
Compact rows use two grid lanes: identity or selection, title and More above; state/snippet,
account attribution, time, disclosure, owner avatar and saved-icon picker below. Secondary controls
never consume the title track, including in All and selection mode. The identity and More tracks
use the shared touch-target token; coarse-pointer open and selection controls retain that target.
From md, the same controls return to the horizontal desktop flow, with title and detail stacked
in the flexible column. No alternate row, smaller type or modal geometry override is introduced.
The model mark and, in All, the owner avatar share one fixed-width data-conversation-faces
track and the avatarStack geometry (size and card ring), so both faces match in size, sit on one
centre line and align from row to row; the track is as wide in All whether or not a row has an owner.
A pinned row's pin and a platform conversation's platform glyph are labelled MarkStrip marks of the
same geometry, placed left of that track so it keeps its right edge; they never sit in the title line.
The identity tile is a borderless ItemMedia of the default variant. In Mine, which lists no delegated
sessions as rows, a conversation with sub-agents gets the branch disclosure, and its branch renders the
subagent history branch from the same links.subagents read the row menu uses; All keeps nesting
delegated sessions as child rows.
Rows without the saved-icon picker keep its desktop slot, and compact rows end on the time in a
fixed-width cell after their optional controls, so the time holds one right edge in every row.
Each row has one named native open button: Enter opens; Space prevents native activation and
selects only an eligible page root, never an expanded descendant. Other controls and menu items never open the conversation. Active
navigation uses aria-current, independently from the named selection Checkbox. Context ancestors,
busy rows and rename editors cannot be selected. More is always visible, including touch layouts;
it includes explicit Select and only capability-authorized actions. Desktop quick actions occupy a
reserved time slot and reveal on hover/focus without moving the title. Pin and More controls retain
focus when grouping moves a root to another section, without taking focus from another live control.
The saved-icon picker keeps its existing mutation and two-cache update path; it is visible on
compact layouts and reveals with the desktop row actions. Menu deletion waits for onAfterClose
before opening its confirmation, so Cancel and Escape restore a mounted row control. The context
menu focuses the row's native open button before handing off; More restores its own trigger.
Rename is local to the row. onRenameChange reports editor ownership, including unmount cleanup.
Enter/blur share a synchronous single-flight phase; a false actions.rename result leaves the
editor and entered value available for retry through the shared AutoSaveStatus.onRetry action.
Retry uses the same single-flight commit as Enter and blur. Empty titles remain editable without a request.
Escape claims the keyboard through data-keyboard-claimed, cancels without saving or dismissing
the switcher, and returns focus to the open button. Search snippets take precedence over native
state text and highlight the original accented text through findNormalizedRange; no message
preview is inferred. Project identity uses the shared icon/name pair, model and platform marks
retain their existing owners, and All explicitly distinguishes a room's hosting account and last writer.
The authorized users projection supplies optional ownerAvatar metadata. The shared Avatar receives
it alongside the hosting account's id and label, preserving its existing signed-image request and
monogram fallback; the row adds no avatar fetch or user-directory query.
The toolbar uses ControlSurfaceToolbar promote={false} and a separately labelled
RegisterSearch. New and Delete all are supplied slots, not alternate mutation owners. Facet
controls form one bounded horizontal scroll track; focusing an off-screen control reveals it.
Dashed inactive/solid active shared Buttons have separate clear buttons. The controller supplies
authorized options, including retained selections after refetch; the toolbar changes only the chosen
facet. Every existing sort key and both directions remain in one menu; Owner is offered only in All.
DropdownMenuCheckboxItem adds Radix's standard checkbox-menu semantics to the shared non-portaled
menu, using the same choice/check tokens as radio items. It contains an indicator, not a nested
focusable Checkbox. The conversation facet menus prevent the item's select default to keep the menu
open while toggling choices; Escape and focus restoration remain Radix-owned. Other consumers choose
their own selection/dismissal policy; no extra requests or global register abstraction are introduced.
The terminal surface (API 63)
ElowenUiRuntime.components.TerminalSurface is the one xterm screen in the app: web/components/terminal/TerminalSurface.tsx, published so the Terminal plugin's window draws a real shell instead of shipping a second terminal, a second copy of the appearance settings and a second socket client. It is loaded lazily through next/dynamic with its own Suspense boundary, because xterm with its addons is a large dependency that exactly one surface wants and the shell's own bundle is paid for on every page.
Minimal use, from a plugin's dock view:
// Inside a bundle: the runtime the bundle already holds, narrowed to the members it names.
const { TerminalSurface } = runtime().components;
<TerminalSurface sessionKey={sessionId} ticketPath={`/plugins/terminal/api/terminal/sessions/${sessionId}/ticket`}
socketPath="/ws/plugins/terminal/terminal/pty" />
- Props (
TerminalSurfacePropsin the UI kit):sessionKey,ticketPath,socketPathand the optionalrefusalMessage(code),onState(state)andclassName. The surface POSTs{}on its first attach or{ resumeFrom }once it knows the next byte offset, and validates{ ticket, expiresAt }from the server's{ session, ticket, expiresAt }reply. Typed terminal refusals stop; eligible rate-limit/server/network failures use the single finite PTY owner and a fresh ticket as described below. Core translatesTERMINAL_REFUSED,TERMINAL_UNSUPPORTED,TERMINAL_LIMIT,TERMINAL_UNKNOWN_SESSION,TERMINAL_RUNNING,TERMINAL_UNAVAILABLEandTERMINAL_FAILEDin all three locales. An optionalrefusalMessageoverrides that wording.onStatereceives the state unionTerminalSurfaceState:connecting,open,reconnecting,openedElsewhere,ended { code, signal }orrefused { code }. - The instance cache. Instances live in a module-level map keyed by
sessionKey, holding the element, the xterm, its addons and the socket. Mounting MOVES the element into the host; unmounting only detaches it, so minimizing the window or navigating away neither loses the screen nor closes the shell.components.TerminalSurface.release(sessionKey)is the only thing that tears one down, and a window calls it when the session ENDS (its own X, or the acknowledgement of an exited row) — never on minimize. - Stand-in states. Active connecting/reconnecting attempts retain the shared large spinner as decoration beside one canonical
ConnectionStatus; scheduled/exhausted work has no active spinner. Policy bootstrap retains the existing initial spinner with the canonicalt.connectionStatus.connectinglabel from the Czech, Slovak and English dictionaries, not removed terminal-local connecting/reconnecting keys; unavailable policy has a typed unknown observation plus explicit owner Retry. Ended, refused and opened-elsewhere states keep their visible text and existing actions; the Terminal plugin uses this core-owned presentation through the runtime surface. - Appearance comes from the account's own Terminal settings (
useMyTerminalSettings), the same onesAccount → Terminaledits andTerminalPreviewrenders, so the window and the preview are one palette. - Protocol. Binary frames carry PTY bytes both ways; text JSON frames carry control only (
{ type: 'resize', cols, rows }out,{ type: 'ready', offset },{ type: 'exit', code, signal }and{ type: 'error', code }in).elowen-plugin-shared/ptypublishes the declaration-onlyPtyClientFrameandPtyServerFrame, so the terminal's server side and the browser surface share them without either reaching into a core source file;web/components/terminal/ptyProtocol.tsvalidates control frames with Zod and holds the bounds. Malformed control ends the connection with a visible failure.ptySocket.tsis the socket state machine (ticket per attempt, resume from the offset the server states, canonical finite retry episode,4409meaning another tab took the session over). A reconnect asks for the exact bytes it missed; when the server's ring cannot continue from there,onResyncclears the screen so the replay is a redraw rather than a splice. - Keys and limits. Copy is Ctrl/Cmd+Shift+C, or Ctrl/Cmd+C with a selection (an unselected Ctrl+C stays the shell's interrupt); paste is Ctrl/Cmd+Shift+V or a right click.
@xterm/addon-web-linksopens http/https only, withnoopener;@xterm/addon-imageis capped at 1 Mi-pixel frames and 32 MiB of cache;@xterm/addon-clipboardanswers OSC 52 writes and refuses reads. Clipboard failures raise a localized toast; denied paste points to the browser's Paste command. Unicode 11 is active, and binary mouse reports retain their original bytes. Large pastes are split into binary frames of at most 64 KiB; input buffered before ready is capped at 64 KiB and overflow is a visible failure. - Window chrome. The plugin owns the window, using
Modal chrome="bare",presentation="center"and ah-[min(88dvh,50rem)] w-[92vw] max-w-[var(--content-max)]frame on desktop; on phones usepresentation="fullscreen", whose bare surface fills the viewport. The window has a 1pxborder-borderframe androunded-lgcorners, with no visible caption or title strip. The surface owns even 16px inner padding on every side on the terminal's own background, with no extra top band. Its nested host is the padded area measured by FitAddon, keeping the final rows and columns inside the frame. TwoButton variant="ghost" size="icon"controls float fully inside the top-right corner at every width, inset bytop-2 right-2(8px) from the window edges and overlaying the padding and terminal without changing the grid inset. They show only white minus and X icons at rest, with no circle, visible border or background backing. Hit targets remain 28px on desktop and 44px for coarse pointers. A subtlewhite/10wash marks hover-capable pointers; the shared button retains the visible keyboard focus ring and disabled states. KeepModal.titleas the accessible window name. The terminal omits the optional operationcaption, so its minimized bubble shows only the terminal glyph; the operation title retains the project name in the bubble tooltip and accessible name. Other sources may still supply a visible caption. Escape and minimize callonClosewithout releasing the surface; X ends the server session and releases the cache. This presentation belongs to the Terminal plugin, not the shared dock. Its manifest declaresweb.cssso the generated window spacing is served even when the host stylesheet does not carry that utility. TerminalPreview(Account → Terminal) stays the non-interactive palette preview and keeps usingxtermTheme; it does not carry the socket or the addons.
Finite PTY connection ownership
The retained TerminalSession owns one authenticated lifetime/bootstrap and
PtySocket owns one canonical finite connection controller for target
{ kind: 'pty', id: sessionKey }. It awaits strict authenticated
loadConnectionPolicy(lifetimeSignal) and injects getConnectionPolicy, never
admin config, defaults, a second policy store or a policy retry loop. Bootstrap
failure remains explicit unknown observation with localized policyUnavailable
and the owner's Retry action. Retry/takeover obtains current scoped policy first.
Minimize/remount preserves the same bootstrap/socket/episode; ordinary React
detach stops geometry observation, not the terminal. Auth-clear/transition or
explicit release aborts policy/ticket/socket work, fences old generations and
disposes xterm/cache. No old-account bootstrap can revive the retained surface.
The controller owns original plus maxRetries, deterministic exponential timing, 60000 ms exponential cap, pinned episode policy and the shared 60-second continuously-ready reset. A brief handshake never resets exhaustion, ordinary start stays idempotent and only explicit Retry renews an exhausted socket. Ended/refused/4409 openedElsewhere stops automatic work; takeover is a separate explicit action. No private backoff, periodic reset or always-on unlimited exception is introduced.
Every physical attempt obtains a new one-use ticket through core-only apiOnce:
one authenticated POST, existing caller-signal mutation semantics, no invented
POST read timeout and no nested REST retry/health recovery/replay. Zod validates
nonempty ticket and safe nonnegative integer epoch-ms expiresAt. Malformed or
non-JSON 2xx, ordinary 4xx except 429, and plugin TERMINAL_FAILED/UNAVAILABLE
refusals are terminal. Typed 429/generic 5xx/daemon_restarting outcomes reach the
PTY scheduler with actual nullable ElowenApiError.retryAfterMs; 504 is timeout.
Network rejection stays a transport failure. A server minimum can exceed 60s;
the cap applies to exponential delay only, never clips the observed minimum.
The attempt converts expiresAt - Date.now ONCE to a performance.now deadline. Socket-open is not readiness: a valid ready frame must reconcile the absolute output ring offset before ready publication or held size/input flush. Pre-ready frames also check expiry, covering a delayed timer or suspended tab. Timeout closes/fences that attempt; late callbacks cannot make its replacement ready. Malformed control, duplicate ready or binary data before ready is terminal. Only input NEVER passed to WebSocket.send survives loss; sent input has unknown outcome and is never replayed. Existing 64 KiB unsent ceiling/frame split and binary-byte output offsets remain unchanged; overflow visibly stops even a scheduled retry. Ring overrun resets the screen before authoritative redraw.
The surface publishes real ConnectionState with its local-owner authority,
controller generation/sequence, observedAt/freshness and exact progress to shared
ConnectionStatus; only policy failure uses null. This permits the shared local
connection countdown, not a new per-second state writer or retry timer. Public
TerminalSurfaceState, window framing, ended/refused/takeover presentation and
account appearance remain unchanged. The scheduler owns retry/stability timers;
the adapter owns its ticket handshake timer and attempt socket/listeners, all
released on terminal result or disposal. No mounted/retained session means no
bootstrap or transport work. Bundled Terminal is the actual consumer; fake
socket/surface tests do not establish browser or physical-device acceptance.
Window pattern (Settings)
Every window that has sections (Settings, the account defaults and presets windows, and the panels
plugins draw into them) is built from one set of primitives in
web/components/ui/SettingsSurface.tsx and looks the same: a window differs from Settings by its content, never
by its type. The Settings Playwright reference is web/tests/e2e/specs/settings.window-pattern.e2e.ts, which
measures computed styles in a Settings section and in each window and requires them to be equal at 1440 px
and 390 px, in both skins.
- Frame:
PageOverlayoverSectionDeckandDeckNavigation. The content of a section isSettingsPage(eyebrow,title,description,status,action): aWorkspaceShell variant="deck"whoseWorkspaceHerodraws the section title as the onlyh1with its description and hairline, over aSettingsDocument(a column, 1rem gap). A plugin panel is never wrapped by the plugin: it returnsSettingsGroups (or a fragment of them) and the host puts them into the section'sSettingsPage. - Group:
SettingsGroupis a card with anh2title (text-section-title), an optional description (CardDescription), groupactionson the right (a Save button, aBadge, an autosave status) and acollapsiblefold remembered understorageKeyfor a long, secondary group (limits, networking, Git author). One concern, one group; a group never nests another. - Row:
SettingsRowis one fact or one setting. The label istext-card-title, the description under it istext-metamuted and says what the value means, not how to operate the control; a long explanation goes behindhint(aHelpTip). The control sits in the right-aligned band (a switch, select, input, slider with its reading or a button);trailingLayout="stack"puts a wide control (chips, a multi-line editor) under the label;statusandactionsare the label-side status and the trailing buttons. Rows are separated by the card's hairlines and are the only place a label and a value share a line. - Read-only value:
SettingsValuein the row'scontrol(monofor a path or an id). An empty value draws an em dash in muted text, never an empty box. - Lists: a list of records (people, projects, snapshots, pull requests, commits) is
SettingsListofSettingsItem(an outlined small item: optionalmedia,title, one-linedescription, a mutedendfigure andactions), inside a group; it is never a table or a stack of cards. - States: loading, empty and failure are
LoadingState,SettingsStateandErrorStateinside the group that would have held the content, so a panel never draws a bare paragraph. - Colour means state only: a
StatusDotwith its word or aBadge, never a coloured pill for a fact. - Narrow single-record drawers (memory record, problem report, project models, compact thresholds,
permission rules, workflow node panel) keep
DetailBlock,PropertyGroupandPropertyRowinDetailPrimitives.tsx; they are core only and a window must not use them. - The same primitives, unchanged in their props, have a second presentation for a record drawer whose body
is Settings content: inside
WorkspaceDetailRail sectionedthey draw compact sections instead of cards (see "Sectioned drawer" under "Drawer anatomy"). Everything above describes the defaultpagelook.
SettingsRow is the shared settings/account record inside SettingsGroup: label and description,
one control, optional short status beside the label and at most three actions in the trailing band.
SettingsGroup provides the named FieldGroup width container; custom plugin rows belong inside
that group, including rows returned by nested field components. SettingsDocument alone is not a
width container. In a narrow group, a lone switch keeps its intrinsic width beside the label, even in a form:
checkbox, radio and hidden backing inputs do not activate the value-input width floor or action line.
Every secondary action belongs in actions as Button variant="ghost" size="sm" icon={…} with its
label and existing accessible name, loading and disabled state. Actions take their own full-width line
below the control, right-aligned at every width, including switches and drawer records. A record with
only actions puts its band below the label and description too, using the vertical Field orientation.
Buttons that
are the primary control remain in control; destructive actions keep their danger variant.
Without actions there is no extra line. trailingLayout="stack" still stacks every trailing part, at every width: the band spans the card under the label and is never a column
beside it (the card presentation is a vertical field, where the inline one is responsive). ModelRolesSection uses the rule for all model pickers and Test actions.
This is CSS-only geometry, shared unchanged with plugin bundles, with no data reads or persistence.
LinkedAccountRow likewise puts its actions below its value and description, right-aligned; the host's
platform disconnect uses a small icon-and-label ghost-danger button. SliderWithReading carries only the
slider and reading; its former action prop is removed after moving the account scale reset to
SettingsRow.actions. Model-limit inherited-value records use the same row action slot.
The public kit owns SettingsDocumentProps, SettingsGroupProps and SettingsRowProps;
core imports those same types. Bundles can use ElowenUiRuntime['components']['SettingsGroup']
directly, as Skills does, without copying props or casting the component. Their existing
runtime behavior and minimum API requirements are unchanged. Omitting folding props gives an
ordinary card. A collapsible group starts open, and a storageKey remembers
its fold in the host's existing browser-local fold store, with no new requests. useGroupFold is that
store's hook for a caller that must own the open state itself (the Settings Access rows keep it above
their detail fetch); SettingsGroup without storageKey is not remembered.
Collapsible headers reuse the ordinary title/help cluster. A native button covers the title and
optional description, preserving keyboard activation and the full header hit area; help and actions
stay outside that button, so opening help does not fold the group. Sandbox's Project Resources and
Networking groups consume this same runtime primitive. Omitting hint renders no help affordance.
Plugin settings use the existing Input wrapper with unit?: string. A defined
unit activates a full-width band capped at 18 rem: 13.5 rem native input, 0.5 rem
gap and a 4 rem left-aligned muted unit cell. unit="" reserves that cell for
unitless counts; omission keeps ordinary input geometry. The input shrinks on
narrow rows. Native props, className, events and forwardRef still target the
input; a meaningful unit augments aria-describedby without changing its name.
The renderer supplies unit={field.display?.unit ?? ''} for numeric inputs and
keeps canonical min/max/step, divisor conversion and invalid-slider fallback.
The schema slider's canonical conversion rounds the computed step to the decimal precision of the
manifest's minimum and step before publishing it to the shared draft. Sandbox's defaultCpus, for
example, stores 1.8 rather than binary arithmetic noise; scaled sliders retain their canonical grid.
The conversion belongs to modules/settings/pluginConfigNumber.ts, which imports
isStepAligned from the byte-pinned web/lib/pluginNumber.ts for slider eligibility.
Display rounding remains local to the conversion; it is not a second validation rule.
This uses the existing number renderer and adds no storage or requests.
UI API 51 publishes exact PluginConfigEditorProps from the public kit:
{ name, detail, draft, mode? }. Detail contains name, schema, secretsSet, optional
secretsUnreadable (a stored secret shown as unreadable in the danger tone, Replace kept) and
optional locale-to-field i18n overrides. The editor owns all field/option/help
translations and host risk strings, so host and plugin callers pass no callbacks.
Ordinary enums and providers use anchored dropdowns, and so does every
ChoiceField, whatever the number of choices. Multi-selection and document editors keep
their existing modal workflows and focus return. For example, the account host
passes its user schema as configSchema and renders
<PluginConfigEditor name={detail.name} detail={editorDetail} draft={draft} mode="all" />.
PluginConfigEditor owns schema placement, translations, draft writes and secret
settlement. Its PluginConfigFieldPickers.tsx collaborator owns entity, provider,
timezone and token-list controls using the existing query hooks and shared pickers.
Its private CatalogMultiSelectField adds stored unavailable multi-select
values for the non-model sets (projects, plugins, tools, options), before catalog rows and in stored order; domain wrappers supply catalog
rows only. With no catalog rows, stored values remain editable. Mapping remains
linear in catalog and selection size, with no additional requests.
RolePoliciesEditor.tsx owns role expansion and confirmed deletion through
draft.commitValue; ModalFieldRow.tsx owns document-modal opening, flush-before-close
and draft error/retry/conflict feedback. For example, Discord role policies keep
the same summary trigger and confirmation dialog through these collaborators.
These are internal owners; public editor props, runtime exposure and DOM stay unchanged.
Secret fields never enter the debounced snapshot; they commit through
draft.commitValue without a Save button. An unset secret is a password input
that commits a non-empty value on blur or Enter. A stored secret shows a Set
badge and a Replace action; the replacement input commits the same way, an
empty one commits null and erases the secret (required ones included), and
Escape returns to the stored record without a write. One commit runs at a time,
so the blur that follows Enter does not save twice. The replacement input carries
data-keyboard-claimed, the shared DialogContent mark for an element that owns
Escape, so cancelling it does not also close the settings takeover around it.
Titled plugin groups with visible inputs start open and keep their remembered fold keys.
PluginConfigEditor uses the existing forceOpen override
for visible missing required inputs, invalid numbers/JSON and failed explicit
secret saves in both implicit and declared groups. This override leaves the
remembered fold untouched and restores it when attention clears. The shared
group retains an attention reveal while focus remains in its React subtree,
including descendant picker/editor portals, so completing a required value
cannot hide the control mid-edit. Leaving the group releases that focus hold.
Conditional hidden fields and fields placed in plugin forms do not force unrelated groups
open. requiredConfigFieldMissing in usePluginConfigDraft is shared by Setup's
checklist and the disclosure rule; stored secrets and defaults count as complete.
Static settings search and row deep links reveal folded targets through
useRowAnchor, which clicks their ancestor triggers before scrolling.
Dynamic plugin configuration fields remain outside the static search index.
modules/settings/pluginDetail.shared.ts owns riskLabel(t, risk) and RISK_TONE for
configuration-field and permissions badges. Both surfaces use the same cs/sk/en risk strings
and tone mapping; this is a local translation lookup with no data reads.
Section hint/help is deduplicated in HelpTip rather than a visible description.
Headerless leading fields and intentional information-only sections have no
disclosure; sections with only hidden inputs are omitted. Explicit
display.placement: 'pluginForm' keeps an opted-in field in validation and
saving while assigning its only generic editing location to the plugin form.
Other intentional settings surfaces stay
available and use the same shared components.
The existing limits-dialog LimitSliderRow now lives in components/ui and
is published with LimitSliderRowProps. Brain, model, runtime, memory-retention and API-token
limits retain the same scalar slider, readout, help, note and sibling-control
behavior; Chatbot can reuse it. The row's icon, label and help-tip head is LimitRowHeader, which
LimitToggleRow reuses for an on/off setting inside a limits dialog (the sub-agent fork default): the
explanation sits behind the help tip, the switch where the sliders show their value, and children
sit under the head as in the slider row (the model-limits "inherited value, use default" line). It is
used by the Brain limits fork default, the runtime sub-agent runner switch, the memory-retention
switch and the model vision switch. LimitToggleRow is core-only and not published to plugins. These controls add no endpoint or storage.
Bundles using the changed signature or new controls require UI API 51 in both
manifest and registration and follow the host release.
Settings/System's token validity and conversation-cleanup age are inline rows, not drawers.
DaysPolicyEditor (web/modules/settings/DaysPolicyEditor.tsx) is their control: a ChoiceField
of the presets plus "Custom…" (a RowPicker dropdown), and, while "Custom…" is selected, a numeric
input with a "days" suffix beside it in the same control cell (it wraps below the dropdown on a narrow
cell). A preset commits at once; a custom value commits on blur or Enter, and an invalid draft reverts
without a save. The token row keeps AutoSaveStatus as its status. The cleanup toggle row is followed
by an "Older than" row only while cleanup is enabled; that row is disabled while a retention save
runs, so saves stay serialized through useAutoSaveStatus.
Settings/System's resource readings are the HOST's own. HostMetrics
(web/modules/settings/HostMetrics.tsx) draws one ResourceMeters strip of five rings — CPU, memory,
disk, IO and network — with the uptime as a plain figure beside it, because uptime has no ceiling to be a
fraction of. It reads GET /system/metrics through useHostMetrics (web/lib/queries.ts), whose
response a zod schema validates at the browser boundary, on a Project register's cadence: stale after 25 s,
polled every 30 s, never in a background tab. CPU, memory and disk are shares of the host's own ceiling; IO
and network are absolute rates drawn without an arc, because a host has no quota for either. A resource the
daemon reports as unavailable, and a live read that fails, keep their ring and lose the arc, so an unread
host is never drawn as an idle one. The strip is lazy (the rings pull in the charting library) inside the
System group, whose title and hint are unchanged.
Every ring carries an action that opens ResourceHistoryModal
(web/components/ui/ResourceHistoryModal.tsx, UI API 61): the shared Modal around a DateRangeFilter
(precision="instant"), the five resources as one inline row of role="checkbox" buttons with their metric icons (the last checked one is disabled, so the set is never empty), and TimeSeriesChart fed by
resourceHistoryChart from elowen-plugin-ui-kit/resourceHistory — so a host curve and a Project curve
are drawn by the same builder. It reads GET /system/metrics/history through useResourceHistory({ subject: 'host', ...selection }),
validated by the same schema and checked against the selection that was asked for; the daemon resolves a
preset window against its own clock, and the host is one scope, so there is no picker for it. A range the
daemon had to shorten is stated in a notice above the chart. With no readings in the period the modal says so
rather than drawing a line at zero: historyEmpty for a period that was covered, historyNotStarted for
a host whose recording has not begun (sampledSince is null). A refusal (invalid_history_query,
invalid_history_range, range_outside_retention, unsupported_history_resolution,
invalid_history_response) is its own localized sentence with a retry. Resource labels come from
settings.diagnostic*; resourceMetricStrings (web/lib/resourceMetricLabels.ts) is the one
place the kit's label keys are filled from the dictionary.
The User drawer mounts SpendLimit with the account record (user: User), keyed by its id, for administrators and members alike. One
useAutoSaveStatus controller patches only monthly_spend_limit_microusd from the shared
SpendLimitSlider (below), behind the account's GrantChangeGate.
A local ['user-spend', id] query reads elowenClient.userSpend, refreshes after a saved patch,
and renders the spent amount and the reset date from UserSpendSummary (web/modules/users/SpendLimit.tsx). It has no unknown-price row; the unpriced_model refusal reaches the user as a chat notice instead. A reset button under the rows opens a ConfirmDialog (SpendLimit.tsx) that calls useResetUserUsage (web/lib/mutations/account.ts).
Loading and failed reads use the shared states and never claim zero spending. Durable chat errors
localize monthly_limit and unpriced_model notices with their reset timestamps under spendLimit (web/modules/advisor/ChatEvents.tsx; strings in web/lib/i18n/dictionaries/en.ts).
SpendLimitSlider (web/modules/users/SpendLimit.tsx) is the ONE monthly spend-limit control: the user's
own limit, an account preset's (AccountPresetsWindow) and a provider account's (the provider drawer) all
render it in a SettingsRow labelled spendLimit.limit. It is a SliderWithReading over the ladder
5, 10, 25, 50, 100, 250, 500, 1000 and 2500 USD whose last stop reads spendLimit.unlimited and means
null. An amount reads in the spend bar's money format (formatCost: $50, $12.34), in every locale, and so does the Users block's spend this month ($0.00). spendLimitStops(value) inserts a stored amount that is not on the ladder as a stop of its own, so
opening and saving never moves it, and keeps only values isSpendLimitMicrousd accepts (the shared
SPEND_LIMIT_MICROUSD bounds). It reports through onCommit, once per finished drag or key press, never
per step; the owner persists the value and passes the stored one back in. It holds no draft of its own
beyond the position of a running drag.
The fake daemon's /__test/spend control seeds per-account usage, reset, delay and failure, and clears
on /__test/reset; its user PATCH persists the nullable limit and boolean voice grant in the
seeded directory. users.spend-limit.e2e.ts covers all three locales at 320px touch and 1440px.
Account's PlatformLinksCard uses SummaryChip from SelectionSummary for linked chat platforms,
followed by plugin-contributed connector chips in one wrapping row. Only platform marks and localized
names appear there; unlinked platforms contribute nothing, and an empty row stays hidden. A card with
available but unlinked platforms still offers Manage. Claimed ids and connector editors remain in the
existing linked-account drawer. Connector chips own their connection reads; the card adds none.
Plugin account and personal-config deep links retain their selected section while the owning listing
loads. Section validation may fall back to Profile after that listing settles with no matching
section or a terminal error, including a contribution moved into the linked-account drawer.
Stale plugin URLs are canonicalized only once both listings settle, preserving row and fragment.
TokenList is the shared free-form token editor for plugin configuration and Account notification
muted sources. An empty value renders only its add input and optional Browse action, with neither a
list nor filler text. Nonempty values retain the named list and per-token remove controls; normalization
and caller-owned writes are unchanged. The editor fills the shared control band instead of shrinking
to its contents, so its add input starts at the same edge as adjacent inputs. The localized default
placeholder is a short value/Enter hint that fits the settings band. It is not exposed to plugin bundles.
Settings/Data's LogsModal uses the shared large centered Modal on desktop and fullscreen on phones,
selected through useMobileViewport. A named container outside the flex layout measures its width:
wide frames place the bounded 16rem file list beside the flexible viewer; narrow frames put the list
above it, capped at 30% of the available body. The list scrolls internally and the viewer keeps its
filter, level controls and empty/loading/error/content pane visible within the shared dialog height.
The footer stays pinned. The file list uses shared LoadingState, ErrorState with query retry, and EmptyState; only a successful empty list is presented as an empty directory. A failed refresh shows its error while retaining cached file rows. Single and bulk deletion return their mutation promise to the existing ConfirmDialog, which owns pending controls and inline errors. Confirmation and selected-file cleanup happen only after success; a failed deletion preserves the viewer and permits retry, and single-file cleanup never clears a different newer selection. Log queries still exist only while open; presentation adds no reads.
Toast feedback has one adapter, web/components/ui/Toast.tsx, reached only through useToast(), including plugin bundles. It renders Sonner as a larger inverse-colour island at top centre on every viewport, below the top bar on phones, away from the bottom-right launcher (shown from 48rem) and operation column. Sonner's own phone layout stops at 600 px while the app's runs to 767 px, so both Toaster offsets read the --toast-offset-top token, which switches at the app's breakpoint. The body-level data-overlay-exempt portal stays interactive above modals; errors also enter a hidden assertive alert region. Plain successes are silent when the updated UI already confirms the result. The stable hook object adds promise(task, { loading, success, error }, options) and dismissKey(key) without changing toast(message, tone?). The only option is a scoped key; duration comes from the shared runtime limits and there is no action button, because a card above a modal cannot be reached by keyboard through the focus trap. Keyed successes replace one card and restart its timer, including after hover consumed the previous lifetime. Sonner 2.0.8 is pinned with a patch-package installation patch in web/patches/ that resets its remaining-time ref on toast updates and preserves a hovered single-card pause by reacting to count changes rather than every replacement, in both module builds; retain the regression test when upgrading and remove the patch only once upstream fixes this. npm run build:web refuses to build when web/node_modules/sonner lacks the patched code (scripts/check-web-patches.mjs), because patch-package runs on install only and an old install would otherwise ship the unpatched timing. Unkeyed identical successes are suppressed for 4000 ms. Errors always get a fresh card. Promise feedback returns the original result or rejection, never a success-shaped fallback. Callers never import Sonner or publish raw exception text. useAutosaveToast(feedback, key, enabled?, reportError?), where feedback is { status, editing? } (a useAutoSaveStatus result or a combineSaveFeedback fold), observes each owning feedback group once: after an active save settles it schedules a 1500 ms trailing confirmation; any newer edit, error, pending activation, conflict or teardown cancels it. The provider owns the keyed timer so a keyed error cancels it immediately too. useAutoSaveStatus exposes editing, true while a valid edit waits for its write debounce, so that gap cancels a stale confirmation; its status stays saving only while a write is running, because consumers gate close and Done controls on it. No confirmation is scheduled from an initial saved snapshot. Plugin config drafts use the same helper with separate instance/personal scopes; inline retries remain the durable failure action. reportError requests one keyed, curated failure toast per failed save where the owner does not already report it; conflicts and rejected values (validation) stay inline-only, so a plugin config draft never toasts on every typing pause with an invalid value. A successful controller status survives adoption of an acknowledged baseline; a different invalid draft remains idle. Restart/update promise success confirms only request acceptance; memory maintenance follows the existing job query through actual completion.
Clipboard writes use copyText(text) from web/lib/clipboard.ts, which makes one browser write and resolves true only on success. A missing Clipboard API or a denied write resolves false; callers show toast(t.common.copyFailed, 'error') through useToast() and do not announce success. Plugin readiness fix values and conversation diagnostics use this same boundary as the Projects Copy path action. Copying performs no server request, retry, or fallback write. Voice call reasons and dictation errors keep their existing dictionary paths, with the four identical refusal messages defined once per locale in sharedVoiceReasons.
Internal clipboard controls share useCopyAction(value) from web/lib/useCopyFeedback.ts. Call the returned async function on a user click; it uses copyText and returns whether a write succeeded while the consumer remains mounted. A refused or unavailable clipboard produces the existing localized common.copyFailed error toast. Completion after unmount produces no feedback. The boundary does not log values or choose success-display duration. ApiTokenRevealModal keeps a successful acknowledgement for the rest of its one-time reveal. useCopyFeedback(value) uses the same boundary and returns { copied, copy } for CopyableId and readiness FixValue; success lasts 1500 ms, each completed copy replaces the prior timer, a refusal clears timed success, and unmount clears pending timed feedback. Neither hook writes until clicked.
Model-limit edits are draft handoffs from ModelLimitsModal to SettingsView, not completed server writes. Only the owning catalog save group confirms persistence; the modal keeps local validation and flush behavior without its own success observer.
web/components/licence/LicenceControls.tsx owns LICENCE_QUERY_KEY, LicenceActivateForm
and CopyableId for both LicenceLocked and Settings LicenceGroup. The form normalizes pasted
keys and maps activation errors once, calling onSuccess with the daemon's fresh status view.
The locked page owns restart polling; Settings updates the shared query cache. An empty key disables
submission, a pending activation disables editing, and a rejected activation leaves the key available
for retry. Copy uses the existing browser clipboard boundary and has no server request.
The shared Segmented control composes the shadcn Radix RadioGroup for mutually exclusive filters,
modes and collection views. Its default presentation has a bordered bg-control rounded track
and a raised bg-sidebar-accent text-sidebar-accent-foreground selected pill with the shared small
shadow. Selection uses the same opaque token pair as the active sidebar row, so it remains visible
without hover in every built-in skin. Only enabled inactive options receive a faint bg-accent/25
hover wash; hovering the selected option keeps its fill. Disabled/focus-visible states use the Button
system's existing model. Skins supply all colours. Pass options, value, onChange and an aria-label; Radix owns checked
state and arrow-key focus. An option's disabled is passed to the native Radix item: unavailable
choices stay visible and faded, cannot be selected or focused, and do not receive hover emphasis.
Without disabled, the choice is enabled. A host-only iconOnly option drops the visible label: the label stays the radio's accessible name and becomes its title tooltip (the Elowen CLI installer's system marks; not part of the plugin kit). icon accepts a Lucide component; iconContent accepts
an already-rendered decorative brand mark, such as the model catalog's ModelIcon. Both are hidden
from assistive technology; option labels remain the accessible names. No icon input means no icon.
nowrap defaults to true: every mode/filter track scrolls horizontally when it exceeds its parent's
width, without wrapping, shrinking or truncating labels. Native touch swipe and arrow-key selection
keep the active option visible, without moving the page vertically. The scrollbar is hidden and
measured edge fades indicate more options. SettingsRow and PageFilters only bound the available
width; they never override the track or option geometry. The frame fits its contents until that bound.
Measurement is linear in the option list and creates no requests or persistence. horizontalScroll.ts owns the
resize/content observers and passive scroll listener through useHorizontalOverflow, shared with
WorkspaceHero metrics and the dashboard strip. Its default measures edges while mounted; enabled: false
disconnects observers and reports no edges. Segmented supplies onGeometryChange(track) to reveal
the selected item before measurement and calls measure() after wheel or option-content changes.
Segmented retains the non-passive wheel listener, item refs, option-content key and accessible counts;
all observers and listeners disconnect on teardown. On a fine pointer a nowrap track also draws a < or >
arrow on each edge that hides content (EdgeArrow in Segmented.tsx): a ghost icon Button labelled by
common.scrollLeft and common.scrollRight, placed over that edge so the track keeps its width and fade. A
click scrolls the track by 80% of its width and re-measures the edges. A coarse pointer hides the arrows with
pointer-coarse:hidden and keeps the native swipe. The arrows are siblings of the track inside a frame
(relative flex w-fit min-h-min min-w-0 max-w-full), because a radio group holds only radios; for a nowrap track
the caller's className lays out that frame, and the track is w-full inside it. min-h-min keeps a column
from squeezing the track vertically, which had clipped the labels of the provider tabs in the model window. The
option's focus ring is drawn inside the option by a nowrap rule in primitives.css, because the track clips
its own overflow on the vertical axis and the shared outside ring would be cut. Conversation diagnostics uses the
same control for Messages/Inspector and Pretty/JSON; Inspector is disabled until a segment is selected.
ManageSelectionModal (multi-select and read-only) uses it for its group tabs (nowrap, scrolling with an edge fade and the fine-pointer arrows), retaining group icons
and pinned ungrouped rows; the group control is absent when fewer than two groups exist. The item (kind) filter is a compact dropdown, not a second strip.
variant="line" is the tab treatment, the one drawers use in their header: 13px medium muted text with a 2px transparent underline,
gap-5, and a track border-b border-border. The open tab is foreground text with a foreground-coloured underline (never primary), hover
only darkens the text with no background wash, and a count is muted 13px tabular text. Icons stay supported for other callers, drawers pass none.
Reserved for navigation between distinct record sections in the Project and User drawer tabs slot
below. WorkspaceShell collection-view filters use the default pill presentation with their section
icons and counts, for example the Memory collection/map switch. Plugins receive this same component
through ElowenUiRuntime.components.Segmented; no private browser copy or UI API change is needed.
Editor is a registry consumer whose private ViewSwitch must be migrated in the registry repository.
app/styles/base.css states button, input, textarea, select { font: inherit } inside @layer base, so a size or weight utility on a
<button> or form control wins as on any other element; never reach for the ! form to beat it.
Loading glyphs use Spinner in components/ui/states.tsx, including operation dialogs and Sonner
promise feedback. The bounded sizes are xs 10 px, sm 13 px, md 16 px, toast 22 px and
lg 40 px; no free pixel size is accepted. Inline operation progress uses sm, and toast loading
uses toast to match its other status icons. Without label the glyph is decorative and hidden
from assistive technology; with label it is a named status. tone chooses a semantic text color.
Optional className lets the toast retain motion-reduce:animate-none; absent that class, existing
host effects rules remain authoritative. The component only renders an SVG and performs no I/O.
There is no fullscreen reconnect overlay. The chat reports transport recovery only in its
nonblocking footer lane above the composer through ConnectionStatus; the terminal's
connecting/reconnecting stand-in in the core-owned TerminalSurface shows Spinner size="lg" while
an attempt runs and ConnectionStatus once retry progress exists, so no plugin runtime component or
API level is involved.
Plugin contribution notices compose one PluginPlaceholder in components/plugin/PluginUiGuards.tsx.
It owns the bordered surface, decorative warning icon and wrapping message. Pass text; optional
action adds a caller-owned control inside that same frame, and role="alert" opts into announcement.
Without either optional prop the existing passive placeholder remains action-free. An unavailable
chat picker supplies the localized message, alert role and shared outline Button wired to its
closeAction, not another frame around the placeholder. Loading still waits for the bundle, and a
missing or incompatible registration remains explicitly unavailable; mount authorization is unchanged.
The notice does no loading or permission checks of its own.
Important shared surfaces include:
RegisterSearchowns the leading search icon, accessible label, touch height and native clear-button suppression. Passvalue,onChangeand a translatedplaceholder;labeloverrides its accessible name. ManageSelectionModal, DeckNavigation and PluginToolsPanel reuse it without adding clear or count controls. It has no filtering or network behavior. The native Chrome cancel button is hidden by a Tailwind variant on the input; the class must keep a space before the${…}that follows it in the template literal, because the scanner otherwise extracts no candidate and emits no rule (the unit test asks the real scanner, and the diagnostics E2E reads the stylesheet rule for the field). Only the explicitonClearandclearLabelbutton clears.Avataraccepts an optionalonlineprop for a decorative success-coloured dot, defaulting to no dot. Users cards and detail supply the flag from the admin directory and always render visible status text alongside it; callers without presence data omit the prop.PulseRing(web/components/ui/PulseRing.tsx) for a donut over caller-supplied slices (RingSlice:key,label,value,colour,datum) with a headline figure in the hole, a pointer-anchored hover card the caller renders (renderCard, usually aCardShellfromChartCard) and a keyboard-reachable<details>legend that carries the same card (legend={false}for a caller that lists the slices itself in a keyboard-reachable list,showTitle={false}to keep the heading for assistive technology only when the caller prints its own). It knows nothing about people, tokens or surfaces. It draws zero-value slices nowhere and showsemptyLabelwhen nothing is left, and a single slice is a solid circle, so a caller whose data can be one owner (a per-user cut of one provider) states that case in words instead. Fixed 220 px tall, as wide as its column. Consumers: the dashboard pulse tile (PulseRings, four rings) and the provider statistics drawer (users and models).DetailBlockfor a titled section of a narrow detail drawer (windows useSettingsGroup, see "Window pattern (Settings)"): a 14px semibold sentence-caseh3(no icon, no uppercase caption), an optionalHelpTip, an optional right-alignedaside(muted 13px text, a link or a small button) and the content below it with a 10px gap. It no longer takes aniconand is no longer published to plugins (UI API 58). The block is always open: a record with several sections is a window whose navigation picks the section, so the block's own collapse (collapseId, remembered aselowen.detailBlock.<id>) was withdrawn with the Users drawer, its last caller. The optionalstatusnode is drawn in the heading row right after the title.HelpTipfor contextual help. It uses the shadcn tooltip parts, opens on hover, focus, and tap, and keeps tooltip semantics and accessible descriptions. Optionaliconsupplies a lucide glyph instead of the default question mark, without changing hit areas, timing or keyboard behavior. Project name indicators pass the plugin's glyph throughpluginLucideIcon; an absent icon keeps the existing help mark. Optionaltriggersupplies non-interactive content inside its existing button, such as Settings/Plugins dependency badges. It retains the shared hover/focus/tap behavior and accessible descriptions, with a focus ring and touch target for the content trigger; never supply a nested interactive element. Without it the icon-sized affordance is unchanged.Badgeaccepts optionaloutlineto remove its fill while keeping the tone's border and text tokens; dependency pills combine this with muted or warning tone.ManageSelectionModalandSelectionSummaryfor the shared multi-select (and read-only list) management flow; a single choice is theRowPickerdropdown, and the modal has no single mode. Internal persistence and the caller'ssavingprop disable every editable item, including pinned rows, without disabling search or filtering. A rejected save preserves the selected set and restores editing for retry; caller-disabled items remain disabled. An item may carryfilters, and callers may passkindFilter({ options, label }) to render one compact single-choice dropdown, the Select surface throughSelectMenu, beside the search, led by the generic "All"; on a phone it takes its own line under the search so the search keeps its room. Options come from the caller's actual data; without options the filter renders nothing. The body holds still: the all-option switch, the search with its count badge, the kind dropdown and the group tabs stay pinned while only the list scrolls (ModalBody fill, a host-only prop that keeps the body from scrolling and lets one child take the rest of the height), so the list reaches the footer on a phone and on a desktop. Rows areh-10with a checkbox and the group headings are sentence-case 13px muted text with the group icon. Every selection of several models is oneModelSelection(web/components/ui/ModelSelection.tsx): aSelectionSummarycard plus this modal, see "One model chooser" indocs/modules/web/models.md. Model selections passmarks({ kind, marks }, where the marks areMarks fromMarkStrip) instead ofsamples, soModelSelectiondraws one brand mark per picked model, each named on hover and focus, while non-model selections keep the chip row.SelectionSummaryis full width with an outlineButtonfor Manage (disabledkeeps the card when there is nothing to choose from; the caller says why). The Users detail drawer (Projects, Allowed models, Granted plugins and Tools) and the account-defaults New accounts grants present a managed selection as aSelectionSummarycard opening this modal, as does every other non-model multi-selection throughManagedSelectionField(see "Unified managed selections"). The Users detailToolPillsis a consumer: it reads effective tool state from/users/:id/tools, takes the authoritative rawallowed_toolsfrom the account row, and patches only changed toggles into that grant while preserving offline tool names. Native memory/image tools are toggleable like plugins for every account, including administrators. Administrators can edit their own tool, plugin and model grants in the same Users detail. The model grant editor reads the full configured catalog through the administrator-only?catalog=allview of/brain/models; the ordinary picker remains limited to the account's grants. An unavailable plugin tool remains disabled until its plugin is granted. A tool covered only by an MCP family grant is read-only in the modal: unchecking its exact name could not revoke the broader grant, so an administrator must edit that family explicitly through the account API. Infrastructure envelopes are not displayed. With no tools it shows the empty state, and a failed save leaves the editor open with an error toast. For example, uncheckingMemorySearchremoves only that name and disables automatic recall on later turns.DataTablefor responsive register tables, current-page selection (DataTableSelectionwith{ ids, selected, onSelectionChange, selectAllLabel? }, together withDataTableSelectCelland a2remleading track in every column template), open-row columns, and compact layouts. The memory register (MemoryView) consumes current-page table selection; the conversation register instead uses the semanticConversationListPaneland its existing per-id deletion controller.DataTableSelectionalso acceptslongPressToSelect: on a coarse pointer ((pointer: coarse), never the viewport width) the checkbox column and its grid track stay hidden until a long press (500ms, touch only, cancelled by moving or scrolling) on a selectable row enters selection mode with that row picked. In the mode, a recent touch tap on a row toggles it instead of opening it — a mouse keeps opening and a keyboard or screen-reader click opens the row it always opened — while explicit controls (action menu, links, fields) keep working; clearing the selection leaves the mode and hides the column again, and only the long-pressed row's own release click and context menu are suppressed. A row temporarily not selectable renders its celldisabled: a dimmed checkbox where the track is shown, a trackless marker cell where it is hidden, and no gesture ever picks it up. A fine pointer never notices the option. The keyboard way in on any device is the header's select-all, which stays focusable while hidden — picking anything reveals the whole column with its tabbable row checkboxes. Without the option, or on a fine pointer, the column behaves exactly as the plain selection above. The conversation list does not consume this table option; it exposes explicit Select and native row checkboxes. The shared table component remains available to plugin bundles.SelectionActionBar(web/components/ui/SelectionActionBar.tsx) for the floating bulk-action bar a register raises while entries are selected: the already-formatted count line, the action buttons as children, and the X clear button. Extracted once from the memory register, which now renders it with no behaviour or look change; the Markdown asset table (skills, agent types) and the MCP server table raise this same bar. The caller shows it while its selection is non-empty as a sibling of the layout, and prunes the selection to the visible set when the filter changes. API 25 publishes it to plugin bundles (typed bySelectionActionBarPropsin the kit).CardSelectCheckbox(web/components/ui/CardSelectCheckbox.tsx) for the select checkbox one card leads with when its register supports bulk actions: a button owning the toggle and the accessible name around the presentationalCheckbox, with activation stopped so ticking never opens the entry. Only entries the reader may act on get one; the Markdown asset and MCP tables use the table's own selection column instead. API 25 publishes it to plugin bundles (typed byCardSelectCheckboxPropsin the kit).CardGridandCardGridItem(web/components/ui/CardGrid.tsx) for a register drawn as cards, when an entry is several kinds of fact (a sentence of description, badges, an owner) rather than short aligned values. The defaultlayout="grid"is one column in a narrow surface, two from 38rem and three from 58rem of its own wrapper's width, never four;layout="list"is one full-width card per row, for entries read as a line (a name and the sentence saying what it is for).CardGridItemis the card surface:selectedpaints the open entry, and passingonClickmakes the surface open the entry for the pointer, while the caller still renders one real named control inside for the keyboard and stops clicks on its own controls from reaching the surface. WithoutonClickthe card is a quiet, non-interactive surface. Props other than the card's own,refandstyleincluded, land on the card surface and not on the grid cell, so a caller that moves a card moves exactly the painted box. Consumers: the Project register (ProjectCard, grid, sortable:web/modules/projects/SortableProjects.tsxwraps it in@dnd-kit, the one drag-and-drop library of the web app; a 400 ms press-and-hold with a mouse or finger picks a card up, a touch that moves before the hold is over stays a scroll, the grid cells never resize while a card travels, and each card's move handle plus space/Enter and the arrow keys is the keyboard path, announced through dnd-kit's live region in the account's language; the saved order is the account's own and its first project is its default, marked with a badge. The sidebar's own pointer drag inSidebarNavis a separate, desktop-only mechanism and has not been merged with it), the Markdown asset register (MarkdownAssetEditor, list, which draws/p/skillsand the agent types on/p/subagent) and Settings → Plugins and Available plugins (PluginsSection, list). API 24 publishes both to plugin bundles asElowenUiRuntime.components.CardGridandCardGridItem(typed byCardGridProps/CardGridItemPropsin the kit), so a plugin's short register — the MCP server register is the first — draws the same cards rather than a table approximating them. Which registers are cards and which are tables is the owner's decision (27 Sep 2026), not a per-change judgement: short catalogs are list cards (skills, agent definitions, MCP servers, plugins, browser sessions, GitHub pull requests, OneDrive workspaces), and long or comparative registers are tables (memory, agent history, users, statistics, chatbot conversations, cron history). The conversation register is the explicit exception and uses the shared semantic list described above. Such a card is one record per full-width row (layout="list"): the record's toggle on the left (after the bulkCardSelectCheckboxwhere the register has bulk actions), a group glyph tile, the name and its tags on the first line, the owner or model pushed to the right, a description of at most two lines beneath, and the actions on the right. Never redraw a card catalog as aDataTableto "unify" registers; the guard tests (web/tests/modules/settings/MarkdownAssetEditor.test.tsx,PluginsSection.test.tsx,web/tests/pluginUi/mcpServersPage.test.tsx) fail when one renders a table. The Users directory also uses list cards (although the owner's rule lists users among the tables): each card leads with the shared Avatar, puts a named detail-opening button in the heading and keeps its action menu independent; with no user selected, no detail rail is shown. Project membership and usage remain in the selected user's detail because the directory does not load them per account. Project card identity headers wrap using native flex layout: the name/location band starts from an 8rem flex basis and grows into available room, so fixed icon and action targets cannot collapse the project name when an operation column narrows the page. Narrow cards put those bands on successive lines, without a viewport guess, containment or another observer.MarkStrip(web/components/ui/MarkStrip.tsx) for a quiet row of small round marks that overlap exactly like a project's team: the geometry (22 px diameter,-space-x-1.5overlap,ring-2 ring-cardcut-out) lives once inweb/components/ui/avatarStack.tsand is shared with the team avatars onProjectCard. Each mark is a solidbg-muteddisc with aborder-border-strongedge andtext-foregroundglyph, so it stays visible on every skin including Studio OLED; size glyphs at 11 to 12 px. Capped at six with a+Nchip. Passmarks({ node, label? }), the count in words aslabel(always the screen-reader text) and akindthat lands on the strip asdata-mark-strip. When no mark has alabelthe whole strip shares one hover tooltip showing the count and is not focusable (the Users card is already one control:UserGrantStrippictures a restricted account's models once per vendor and its tools once per kind). When every mark has alabeleach mark is a focusable image with its own tooltip on hover, tap and keyboard focus (each provider record in Settings → Elowen AI pictures oneModelIconper stored model, named by the model id, with the count as the screen-reader label). The model summary inSelectionSummaryis another consumer of the same strip, reached throughModelSelectionand itsmarks. The tooltip is rendered in place by the sharedTooltip, so a strip must not sit inside a<p>.The telemetry rail grid (
web/components/ui/RailPrimitives.tsx):RailSection,RailList,RailRow,RailMeter,RailDotand the section headingRailSectionHead. Every chat-rail section, core (context, goal, limits, the project foot) and plugin (tasks, workflow, agents, processes, MCP, LSP), is drawn on one three-column grid: a 1rem lead for a glyph or the section icon (aRailDotgiven as a row'sleadis drawn in the text column ahead of the label instead, so the first mark of a dot row starts at the same x as the labels of the meter rows), a flexible text column, and a right-aligned tabular value column. Labels therefore start at one x and figures end on one right edge across sections. Rows sit at the body-small step (text-sm), details and figures attext-meta, the head attext-metain Studio. The head, every row kind and the meter share ONE line height, 28px on every pointer; a control row is deliberately not padded to the 44px--touch-target, because rows that tall, including 32px rows at conversation text size, read as separate lines instead of a list. Flush 28px rows clear the WCAG 2.5.8 24px target. Sections sit 16px apart with no gap under their head. A button row that folds the rows below it passesexpanded, which becomesaria-expanded.RailRowis static, a button (onClick; the label is the real button and its::aftercovers the row, so anactionsuch as a kill button on the value edge keeps its own click), or the trigger of a click-onlyActionMenu(actions). Beside adetailthe name keeps its width up to 70% of the text column. A row with anotetakes two lines: the name keeps the rail's own line height on the first one, with the lead and the figure column, and the note runs on a line of its own below it.RailMeterputs a fixed 5.5rem label column before the bar so every bar in the rail starts at the same x (flush, inside aRailList flush, is the variant outside the rail: no lead column, and label and figure columns sized to their widest entry through a subgrid, so a narrow Settings record leaves the room to the bar, and the label reads as a record name,text-card-title, the size of the Settings rows beside it rather than the rail's conversation size); aflushlist is also a container: below 16rem a meter stacks, the label and figure on the first line and the bar at full width on the second, so a name and a money figure never squeeze the bar on a phone;OAuthUsageRail(web/modules/settings/OAuthUsageRail.tsx) is the one markup for usage windows (subscription windows, or an API account's balance, key limit and free-model allowance, whose label, figure and hover come fromusageWindowMeterinweb/lib/usageMeter.ts, over the windowsdrawnUsageWindowskeeps: an unlimited account spend is left out beside the provider's own windows), rendered by the rail's Limits section and,flush, by a connected account in Settings;tone="pressure"colours the fill by the usage ramp inweb/lib/usageMeter.ts,progresskeeps the primary fill.RailSectionHeadtakescountfor the shared pill and freemetacontent. Plugin bundles getRailSection,RailList,RailRowandRailDot(API 27);RailMeterandRailSectionHeadare host-only since API 45, because no bundle used them;web/tests/e2e/specs/chat.telemetry-rail.e2e.tsmeasures the grid with the real bundles. Without a rail section contributed, nothing renders for it. Terminal's Processes section receives only{ processes }from the chat status context, hydrated by the daemon status/first SSE snapshot and replaced by live process events. The daemon includes the focused conversation and its direct delegated children, never another conversation. The web does not poll an account-wide process endpoint or derive ownership from transcript rows; empty/loading state has no process section. Output and confirmed kill use the existing owner-scoped endpoints, with errors kept visible.The checklist (
web/components/ui/TaskList.tsx):TaskListandTaskRow, the one task list on the web, adapted from the Vercel AI ElementsQueueandTaskpattern and composed from the rail grid andActionMenurather than copied in (no new dependency).TaskRowis one task as anli: the status glyph (empty circle waiting, spinner running, ticked circle done), the title that truncates with its full text astitle, a finished task muted and struck through, and one piece of meta in the value column, the running clock or frozen duration, or for a pending task withblockedBythe tasks it waits on ("waiting on #3"). WithonStatusthe row is the trigger of a click-only menu of the three statuses, plus the way into the full list whenonOpenis given; without it the row is a read-only line.density="rail"(default) is a rail grid row;density="inline"keeps the same three columns at the surrounding type, for the monospace transcript card.TaskListis a Tasks section body insideRailSection: running then waiting tasks in the list's own order,TODO_PREVIEW_ITEMSof them (4, the card preview size) before a same-size "+N more" row that unfolds the rest in place (a list one over the limit shows whole instead of "+1 more"), then the finished tasks folded under one "Completed" row carrying their count. The caller owns the clock (now) and the mutation; the strings are the host's. With no tasks it renders an empty list, and a section with nothing open is the caller's to leave out. Consumers: the registry Todo plugin's rail section (TaskList) and its transcript card (TaskRowinline), and the host's fallback todo card inBrainChatSurface. API 28 publishes both to bundles (kitTaskListProps,TaskRowProps,TaskListTask);chat.telemetry-rail.e2e.tsmeasures the flush rows with the real bundle.ShimmerText(web/components/ui/ShimmerText.tsx) for a line that describes work happening right now: a soft band of the foreground ink sweeps across muted ink (.shimmer-textinapp/styles/animations.css). It is a CSS adaptation of Vercel AI Elements'Shimmer(nomotionelement, which the app's strictLazyMotionrefuses, and ink tokens instead of the background colour); quiet effect modes andprefers-reduced-motionrender plain muted text.active={false}stops the sweep without changing the box. Consumer: the running sub-agent's activity in the rail's Agents section. It is meant for the other live lines too (a running agent card in the Agents window, a running node in the workflow inspector, live dashboard activity), which still use plain text.MotionCollapseandMotionFade(web/components/ui/Motion.tsx), the fold and label motion next toMotionReveal,MotionPresenceandMotionLayoutItem.MotionCollapsetakesopenand its children and folds the region under them: height and opacity over the sharedmotionTransition, opacity only under reduced effects, and at once when effects are off. While it closes its children are the current ones, so content that leaves the region in the same render is never drawn twice; the children leave the DOM when the fold ends, and the region isinertfrom the moment it starts to close. A region that is open or closed on its first render does not animate.MotionFadeis the opacity-only enter and exit of a label that swaps in place inside aMotionPresence, which owns the exit timing; under off it renders plain, so the swap is instant. The real consumer of both is the chat's work timeline (AssistantRunandWorkSummaryinweb/modules/advisor/ChatTranscript.tsx): a settled run folds its steps while the closing answer slides up into the tail, and the live label cross-fades into the settled one. Limit: under reduced effects the region keeps its height during the fade, so the layout below it moves when the fade ends. Cost: one motion element per region and no measurement beyond the height Motion reads when it opens.InstanceWordmark(web/components/ui/InstanceWordmark.tsx) for the closing line of a page: the instance's name (useBrand().appName, so a themed instance shows its own name) in one oversized, near-invisible word across the content width, faded toward its foot and clipped at the edge. Its width is capped at the document measure (--content-max), so a wider workbench frame (web.layout: "workbench", e.g. the scheduler) centres the same-size word instead of growing it. The.instance-wordmarkclass inprimitives.csssizes it from the name's length through a container query, so any name spans the width without horizontal scroll. It is rendered once by the shell's root template (web/app/template.tsx, after the page content inside itsh-fullwrapper) for every page except/chat— whose conversation owns its bottom edge — and for no overlay (PageOverlay,PluginPageOverlay, the login gate and setup screens never pass through that template). The decision follows the shell's oneusePagePath(web/lib/usePagePath.ts) — the mounted page, not the address — so Settings opened over/chatadds no word under the chat and a direct overlay visit shows none. A new page gets it without doing anything and must not render its own. It is decoration:aria-hidden, not selectable, never a hit target. The one opt-out is a plugin's manifestweb.wordmark: false(read throughpluginListingForPathinweb/lib/sameDocumentNavigation.ts, the same lookup the shell uses for the page measure), for a full-height application surface that owns its bottom edge; the editor is the consumer. Without it, and on an older daemon that sends no such key, the word closes the page.Pagerfor range text, bounded page changes, and optional page-size selection. UsepaginateItemsfromweb/lib/paginateItems.tsfor the matching visible-page calculation; plugin bundles get the same function asElowenUiRuntime.utils.paginateItems. It only clamps and slices, leaving filter reset and page-size state to the caller. The host memory register, Markdown asset register, and MCP server register are the concrete consumers; conversation-ID paging has separate identity and reset rules. The web app imports only types fromelowen-plugin-ui-kit:web/tsconfig.jsonmaps the package to itsindex.d.ts, so a value import builds cleanly and reaches the browser asundefined(web/tests/lib/pluginUiKitTypeOnly.test.tsenforces this).ModelPickerandModelOptionListfor the shared model catalog in full, compact, and status-line presentations. The menu, which/modelopens through the controller'smodelOpen(a phone's bar picker lives in the overflow menu, which opens itself for it), renders the name control and, when the catalog advertises levels, the sharedReasoningScalebelow it. The name switches the model and closes its host as before; the scale keeps the host open, reads the active level fromuseBrainSessionStatus, previews an inactive model using PI's canonical ladder to clamp the confirmed session/account level to its supported levels (no selected stop when those disagree, because PI may restore an earlier transcript marker), and switches first before writing that level. Without advertised levels it renders no scale; while a delegated child is focused it disables the scale with the same reason as/reasoning.useReasoningLevelis the one live write path for this catalog and the bar's reasoning popover (ReasoningButtonowns the write and refuses to close while it is saving;ReasoningPanelis its body, opened by the button or/reasoning): callapply(level, current)for an active session or pass a switch function returningPromise<boolean>for another model. A refused switch does not write reasoning; a successful write refreshes the account default and session status, while failure restores the displayed value and raises a toast.BrainChatProvider.setModelsupplies that awaited switch result, andReasoningScaleaccepts an optionaldisabledprop so a pending or refused control stays visible without pretending it can save. For example, picking Low under an inactive Claude model switches to Claude, then applies Low in the same conversation.AlertsBelland the top bar alert surface for the authenticated/alertsresource. It uses the shared primitives, renders loading, error, empty, and bounded open-row states, and refetches after the owner-scopedalertSSE invalidation. Opening the panel marks all currently open unread rows read through/alerts/read-all, including rows beyond the 200-row display cap; dismissal hides a row without changing producer state.State components for loading, error, and empty results, plus shared page, workspace, form, and control-surface primitives.
SelectMenufor the shared select behind a typedoptionsarray ({ value, label }), including the empty-string "all" option and dynamic option lists. The conversation diagnostics modal is a concrete consumer: its surface/status/role filters render through it with no local select wrapper.ProjectIconis the shared project glyph in cards, pills, and plugin previews. Pass{ id, icon }; an empty icon or absent Editor shows the folder glyph. Host execution menus also accept{ targetId }for a paired local computer and render the laptop glyph without reading Editor or fetching a project icon. The closed manifest icon map includesLaptop. Stored relative paths select the editor'sprojectroot, while guest-absolute paths selectsystemfor managed projects and are sent as root-relative paths. Core refuses absolute host icons, traversal, non-images, missing files, and canonical paths under/dev,/proc,/run, or/sys. The Registry Editor picker continues upward from the managed project directory into the guest filesystem; host pickers stop at their project root. Images are cookie-authenticated and cached once per project/icon as data URLs; reload reads the same durableprojects.iconvalue. Existing relative icons need no migration. Returning an adopted project to host execution clears system icons and preserves relative icons.DirectoryPickerfor selecting an authorized server-side folder. It rendersLoadingStateuntil the current path has data,EmptyStateonly for a successful empty listing, andErrorStatewith an in-place retry on failure. Project registration is its consumer; a pending navigation never presents the prior directory as the new path.
The web app has no LiveTail component, polling hook or session pane preview, and no core or registry renderer consumes a terminal-preview chain. Terminal previews belong to the terminal plugin's live surface, not to a generic session pane.
The plugin UI kit and window.ElowenUiRuntime.components publish supported public components, including surfaces without a renderer in this repository. Keep plugin bundles on that runtime rather than importing web/; the package's PluginUiComponentName names the published component set, while each bundle types the props it actually uses.
UI API 45 withdraws every runtime member that no bundled, registry or installed bundle named: the components ExecutorPicker, BackendPicker, ProviderPicker, ModelCatalogField, ProviderLogo, RailSectionHead, RailMeter, CompactWorkspaceHeader, PluginPageFrame, OutcomeBadge, ProjectPill, ChangeStrip, EnvironmentOperationWindowDialog, SpatialIdentity, DataTableSelectCell, ProgressRibbon and MotionLayout; the utils contextMenuDivider, allModels, cliProviders, formatCost, fileIcon, dirName and eventIcon; and the hooks useVncSurface, useAutosaveToast, useConfig, useUpdateConfig, usePlugins, useSavePluginConfig, useWriteProjectFile, useNewProjectFile, useNewProjectDir, useRenameProjectEntry, useCopyProjectEntry, useDeleteProjectEntry, useActivity and useInfiniteQuery. Host code that still renders one of them keeps it; the ones nothing in core used (ChangeStrip, OutcomeBadge, ProgressRibbon, ProviderPicker, SpatialPrimitives, fileIcon) are deleted. web/tests/lib/pluginUi.test.ts freezes the remaining surface and pins the withdrawal.
DataTablesupports optionalselectionwith{ ids, selected, onSelectionChange, selectAllLabel?, longPressToSelect? }, together with the host'sDataTableSelectCell(not published to bundles); the conversation history register enables it (withlongPressToSelectfor touch).PageToolbartakesfilterActionsbesidefilters: buttons for the foot of the filter panel, for an action on the page's data that belongs with its filters (a destructive "delete all"). The panel's trigger, accessible name and phone sheet are then named "Options" (common.filterOptions) instead of "Filters"; withoutfilterActionsnothing changes, unless the page statesfiltersNamed: 'options'outright so the sibling pages of one deck (licensing Errors and Audit) open the same-looking control. The page owns the action and its confirmation (render theConfirmDialogoutside the panel, since the panel closes on outside interaction). A panel with actions but no filters still opens and shows the actions alone: the rule above the actions is drawn only when filter fields precede them (the Users defaults action is the consumer; the plugins toolbar keeps its rule). On a phone the sheet closes when a button or link inside the actions is used, because the sheet is a modal above the page band and an action that opens a window of its own (the Users defaults overlay) would otherwise stay underneath it; the desktop popover is dismissed by Radix when focus leaves it. Plugin API 30; the licensing Errors toolbar is the consumer. Below 34rem of shell width the row stays ONE line: the search is its only elastic part (it keeps a 7rem floor and its placeholder truncates with an ellipsis) so the filter trigger and a page action hold their whole labels, and anything that would push the field past that floor (a second action, the chips) takes the next line, while a toolbar promoted into the row's slot keeps a line of its own.DeckNavigationrecords takebadge(a count): the primary navigation's own badge (SidebarMenuBadgewith.sidebar-nav__badge) after the label in both the sidebar and the phone tab strip, included in the record's accessible name. Absent or zero draws none. The caller supplies the number; the deck fetches nothing. Plugin API 30; the licensing deck is the consumer.- UI API 53 adds
filtersNamed: 'actions'toPageToolbarandnamed: 'actions'toPageFilters, usingcommon.actionsfor the trigger and sheet title.ControlSurfaceToolbarforwardsfilterActionsandfiltersNamedinto that same contribution. Settings Plugins is the consumer: its category fields keep the canonical active chips while a batch-update button occupies the existing action footer. The page owns the external confirmation; absent naming keeps the previous Filters/Options behavior. There are no extra fetches or portals. ControlSurfaceToolbarsupports structuredsearch,filters, andactionscontributions plus a children form. Its publicpromoteprop defaults to true and sends the children toolbar throughPageToolbarPortal;promote={false}keeps it in place, and if no page slot is mounted its children render at the call site. PluginDetail uses the portal children form for its section tabs. Account panels importPageToolbarScopefrom the same owner so hidden retained sections cannot claim the slot.ResizeHandledrives the advisor dock on all four sides. A translatedlabeland the currentvalue,min, andmaxmake the divider focusable; axis arrow keys move it bysteppixels, and double-click resets the active dimension to its command dock default.useDockStateowns bounds, reset, and persistence: resize updates callsetWidth(width, false)orsetHeight(height, false)without persisting, andonEndcommits the final size once. Without a label the divider remains pointer-only. No network calls are made.DateRangeFilteracceptscompactandpresets; production callers use the default non-compact layout and preset set.
For terminal appearance, web/components/terminal/xtermTheme.ts supplies xterm's palette from the account settings: call xtermTheme(settings) and xtermBackground(settings). With no preferences or theme: 'auto', it uses the dark palette; theme: 'custom' uses the validated palette, including the Elowen Light preset. TerminalPreview is the live consumer, and does not follow the app skin.
The shared DataTableCell accepts header, priority, lines, title and ordinary div attributes; the unused labelHidden and reveal props and their hover CSS have been removed after source and bundle consumer checks. SelectionSummary keeps samples, overflow count, variant and read-only management; the unused extraSamples slot is removed. Component names, SDK pending-input reveal() and API numbers remain unchanged.
Modal no longer accepts intent. Automatic presentation depends only on overlay depth and viewport: the first roomy overlay is a drawer, nested roomy overlays are centered, and phone overlays are fullscreen. presentation remains the explicit geometry override. WorkspaceDetailRail uses Modal with drawerWidth='default'; PageOverlay uses standsInForPage so editors opened from Settings resolve as first-level drawers. PageOverlay takes, for a presentation that is not a route, onClose (replacing the history step) and closeDisabled (blocking dismissal while a write is in flight); /settings and /account pass neither of the last two, and the Users account-defaults window is the one caller that does. Resolved shape owns the z-band. Focus isolation, dismissal and onClosed are unchanged; no contribution is required for the default rules. The withdrawn plugin runtime hook useQueries is no longer published; useQuery, useMutation and useQueryClient remain on the host's shared query client.
Settings category ids, their union type and navigation order derive from the single literal SETTINGS_SECTIONS list in modules/settings/categories.ts. ModelCatalogField uses the outline RowPicker in its live model-role and voice settings callers. The model icon index in lib/modelIconSlugs.ts is maintained manually and pinned to the shipped SVG/WebP basenames by web/tests/lib/modelIcon.test.ts; update that index when adding or removing an asset.
When adding a surface, preserve visible focus, keyboard operation, localization, reduced-motion behavior, safe-area handling, and narrow-container behavior. Reuse the shared query and mutation helpers so invalidation, revisions, conflict handling, and autosave remain consistent.
Turbopack's root remains web/. Shared browser rules are importless pure modules with byte-identical local mirrors pinned in tests/contract/webMirrorContracts.test.ts; only erased type imports may cross into src/. Typecheck and Vitest do not prove cross-root values bundle. Next emits .next/standalone/server.js; the bundle script copies its assets beside it and prepends shutdown handling. A missing generated entry fails before the prior bundle is replaced.
Drawer anatomy
Every Modal, WorkspaceDetailRail and ConfirmDialog wears one header, DialogHeader in web/components/ui/shadcn/dialog.tsx. It is a px-4 pt-5 sm:px-6 block with exactly one border-b hairline between it and the body (pb-4, or pb-0 when tabs are present).
- Title row (
flex items-start gap-3): an optional 40px tile (data-slot="dialog-mark", hairline border, 10px radius, no fill; a Lucideiconis drawn 18px in foreground,iconNodereplaces it with a brand mark), the title (text-section-title, 16/24 weight 600) with the meta line under it (data-slot="dialog-description", 13px muted, adivthataria-describedbypoints at), then the actions and the ghost icon close button (size-8) on the same row. The meta line breaks anywhere (wrap-anywhere) when one token is longer than its column, such as a host project's path on a phone, so it wraps inside the title column instead of running under the actions and past the screen;web/tests/e2e/specs/dialog.header-meta.e2e.tsmeasures it at 320 px.headerActions(an outline small "Edit" button, anActionMenuin itskebabvariant) sit indata-slot="dialog-actions"before the close control. There is no separate action band and no wrap to a second row on a phone. - Meta line: pass
description={<DetailMeta status={{ tone, label }}>kind</DetailMeta>}.DetailMetadraws a 6pxStatusDot(greensuccess, amberwarning, greymuted) with its word, then "·", then the children; the separator appears only when both exist. Colour is for state only. - Tabs:
tabstakes aSegmented variant="line" nowrapwithout icons. It is drawn inside the header under the title row (data-slot="dialog-tabs",mt-4 -mb-px), so the active underline sits on the header hairline, and the slot switches the track's ownborder-boff so only one line exists. The slot also states the strip as drawer tabs, once for every drawer ("Overlay header tabs" inweb/app/styles/components/primitives.css):text-section-titlelabels (16/20/600) with 28px between tabs, a taller hit area (4px above, 12px above the underline), a 3px--color-primaryunderline under the open tab and a--color-border-strongunderline previewed on hover. Label ink stays thelinevariant's (muted, foreground when open or hovered) and option counts stay mutedtext-meta. The strip spans the header (thenowrapframe fills the slot and the track may grow past it by its focus margins), so the scroll arrows show only for real overflow: a phone at 320 px or many tabs scroll the strip instead of clipping it, while the four tabs of a 38rem drawer fit.users.drawer.e2e.tsmeasures it. Otherlinestrips (register tabs, plugin pickers) keep the compacttext-uilook;web/tests/styles/overlayHeaderTabs.test.tspins the rule. - Body:
ModalBodyisp-4 sm:p-6;gap={7}(28px) is the section rhythm,WorkspaceDetailRailuses it. Sections areDetailBlocks in a single-record drawer and Settings sections in asectionedone (below). - Footer: only a drawer that edits has one,
ModalFooter(px-4 py-3 sm:px-6) with Cancel (Button variant="outline") and Save (Button variant="accent") on the right.WorkspaceDetailRailtakes it asfooter. A read-only drawer has none. WorkspaceDetailRailforwardsicon,iconNode,actions,tabsandfooter(all optional) toModal, anddescriptionis a node.sectioned(boolean, default off) is described next.
Sectioned drawer
WorkspaceDetailRail sectioned is the record drawer whose body is Settings content: the Project and User drawers, and every plugin panel mounted in them (web.project panels in the tab, overview and overviewTop placements, web.user panels). The flag provides SettingsPresentationContext (web/components/ui/settingsPresentation.ts, values page and drawer, default page) around the rail body, and every SettingsGroup, SettingsRow, SettingsValue, SettingsList, SettingsItem and SettingsState in it draws the drawer presentation. They are the same components with the same props, so a section component and a plugin panel (which gets these components through window.ElowenUiRuntime) need no change and no knowledge of where they are mounted. Minimal use: <WorkspaceDetailRail sectioned label={name} tabs={<Segmented variant="line" nowrap … />} …>{sections}</WorkspaceDetailRail>. Without the flag, and everywhere else (Settings, Account, the Users defaults and presets windows, the sandbox limits window, every other drawer), nothing is provided and the primitives keep the card look described in "Window pattern (Settings)". Modal provides page again around every dialog's content, because context crosses its portal: a dialog opened from inside a sectioned drawer (a GitHub pull request's detail, a confirmation, a picker) keeps its ordinary look.
What the drawer presentation draws (only existing tokens and Tailwind utilities; the rows key on the data-slots the components already emit):
SettingsGroupis a section, not a card (data-slot="settings-section", plusdata-settings-groupas everywhere): a header (data-slot="settings-section-header") with anh3title in the section-title role (16/20/600, as a Settings card), the help mark, thedescriptionas atext-metamuted line under it and theactionsright-aligned intext-meta; then the body as ONE box (data-slot="settings-section-body",rounded-xlhairline border like a Settings card, no fill or shadow). Each direct child of the box is paddedpx-4 py-2.5inside apy-2box and separated from the previous one by air alone, with no hairline between records, as Settings separates its rows (owner, 13 Oct 2026), so anything that is not a Settings primitive (a ring grid,LoadingState,ErrorState, a form around a row, a paragraph) sits like a row and never touches the border.collapsible,storageKey,forceOpen,tone="danger"(a destructive-tinted box border) androwIdbehave as on the card;columnsanddensitychange nothing, because a drawer is one column. A group without title, description and actions is just the box.SettingsRowis one line of that box on a grid whose areas the row's kind picks. A property reads label, value, then status and actions: the label in a fixed column (8rem,10remonce the box is 28rem wide; 13px muted) so all labels of a box share one column, with its help mark in the text flow after the last word and held to a 16px slot so a coarse pointer's larger touch target does not push the label below its value, the value left-aligned after it (14px), status muted at the trailing edge, and thedescriptionas a 13px muted line under the value. Each cell is at least 32px tall on a wide box, so text rows and rows with a button or picker are the same height. Below a 20rem box (a 320 px phone) the value folds under the label. A row whose control starts with a switch, or is one loneButton(Reset usage), reads as a setting: its name at record size over the description, the switch or button at the trailing edge.trailingLayout="stack"puts the name on the first line, then the description, a block status and the control at full width. In both layouts, actions occupy their own final grid row, right-aligned below the control. The row keepsdata-slot="field",data-trailing,data-row-idand thefield-*slots (the band isdisplay: contents), so touch floors, row anchors and tests still find them; it carriesdata-settings-presentation="drawer", which also exempts it from Studio's record hover bleed (web/skins/studio/surfaces.css), since that bleed would paint over the box's border.SettingsValueis left-aligned (not right-aligned) after the label;monostays 12px mono and may break anywhere, other values wrap; an empty value stays the muted em dash.SettingsListdrops its gap andSettingsItembecomes an unframed line of the box (variant="default", a hairline above every item but the first,px-4 py-2, at least 48px): media, the 14px title and 13px description truncating,endmuted and tabular, actions on the right.SettingsStateis a quiet one-line row (13px muted, left-aligned, destructive fortone="danger", at least 48px). Inside a box it is a line of it; on its own (a plugin panel stating that it has nothing to show) its hairline border makes it a box of one line.
Limits: the flag is all or nothing for the rail body, and only these six primitives change; other content keeps its own look and only gains the box padding when it sits directly in a section. WorkspaceDetailRail is published to plugins, but sectioned is not part of the typed plugin contract (packages/plugin-ui-kit/index.d.ts does not type the rail's props) and no plugin passes it. The context is a module of its own because Modal reads it and SettingsSurface.tsx reaches Modal through WorkspaceShell. Cost: one context read per primitive; no requests, state or persistence beyond the existing fold store. web/tests/components/ui/SettingsSurface.test.tsx pins both anatomies, the fold in a drawer and the reset in a nested dialog.
Facts are properties, not pills. web/components/ui/DetailPrimitives.tsx holds StatusDot, DetailMeta, PropertyGroup (role="list", hairline border, 10px radius), PropertyRow and CodeChip; StatusDot is published to plugins (UI API 57); PropertyGroup, PropertyRow, DetailMeta and CodeChip are core only (the plugin runtime withdrew the property primitives in UI API 58 once every window and plugin panel drew SettingsGroup and SettingsRow instead). A PropertyRow has an optional muted 13px label (120px wide, 96px on a phone), a 14px value and optional muted trailing text end; rows are separated by air, never by dividers (owner, 13 Oct 2026), and a row without a label is free-form (a commit, a person, a setting). A git branch or hash is a CodeChip (Geist Mono 12), never a coloured pill. Type in a drawer uses three sizes only: 16, 14 and 13, plus mono 12.
Detail window and drawer
A record is a right drawer or a window, by what it holds. A record read or edited on its own (a memory record, a problem report, provider statistics, a markdown asset, compaction thresholds, a project's models) and an entity detail with tabs (a Project, a User) are right drawers on WorkspaceDetailRail, described above; the two entity details pass sectioned, so their Settings primitives draw in the compact drawer presentation. A surface whose sections are settings of their own (Settings, Account, the Users account defaults and account presets) is a WINDOW: a PageOverlay (frame="reading") over a SectionDeck and DeckNavigation, the frame and left navigation with search that /settings uses. On a phone both are fullscreen. The rule lives in overlayDepth.tsx; neither is a new shell.
PageOverlay names its window with a Lucide icon in the 40px tile and may carry headerActions (a plugin page's save status, the account defaults window's apply status). The content of every section is a SettingsPage of SettingsGroups, exactly as in Settings (see "Window pattern (Settings)").
The Project drawer (web/modules/projects/ProjectDrawer.tsx, opened from ProjectsView for the selected card and keyed by its id) is a WorkspaceDetailRail with sectioned, so the Settings primitives in its body, a plugin panel's included, draw in the drawer presentation. The mark is ProjectIcon, the meta line is DetailMeta with the plugin-reported project state (usePluginProjectRows status, mapped to a green, amber or grey dot by statusDotTone) and the kind (Managed environment, or the host path), and the kebab (convert to a managed environment, undo it) shares the name row with close; there is no Edit action. The tabs are a Segmented variant="line" without icons (t.projects.detailSections). useMobile enables its native nowrap scroll track only on phones; desktop tabs use the same Radix radio group with flex-wrap, so every label remains inside the available width without scroll arrows or overlaid controls. Arrow/Home/End keyboard selection remains owned by Radix. The sections are: Overview, People (with the member count read from the same query it lists; offered to an administrator and on a managed project) and the plugin panels declared with placement: 'tab', in listing order; the manifest icon is accepted but not drawn, and a tab whose plugin disappears falls back to Overview. Overview is the placement: 'overviewTop' panels (Sandbox's usage rings, with stale-state text when needed but no relative sample-age counter in the Resources header), then, for somebody who may manage the project (canManage from ProjectsView: an administrator, or any member on a managed project, never while it is being deleted), a Details group (the slug as a fixed mono value with its help mark, the web.projectEditIcon row the editor plugin contributes through PluginProjectEditIcon, and the notes as a stacked Textarea row) or, for anybody else, the notes as a muted paragraph, then a Location group (the host folder of a host project with a missing-directory state, an input with a Browse button that opens DirectoryPicker over the drawer for a manager, or the directory inside a managed project's environment and the host folder it was converted from; the state and kind stay in the header only), then the placement: 'overview' panels (the editor plugin's repository). People is two groups: User access (a SettingsList of person items with the Manage button in the group actions, and the invitation form for a member as a row) and, for an administrator, Memory (the shared-memory switch row, and while it is on a row with the sharers and Manage). Panels return SettingsGroups as direct children of the drawer body. Every editable field saves itself with its own useAutoSaveStatus (an 800 ms debounce, a row AutoSaveStatus with Retry, useAutosaveToast for the saved confirmation and a toast with the daemon's reason on a refusal) and patches only its own field through useUpdateProject; an emptied host folder is never sent. The drafts are seeded once from the project the drawer is keyed by, and leaving Overview flushes a pending save. The drawer has no edit mode and no footer, and the project card menu has no edit entry: the card opens the drawer.
The User drawer (web/modules/users/UserDrawer.tsx, opened from UsersView for the selected account and keyed by its id) is a WorkspaceDetailRail with sectioned. The mark is the account's Avatar, the meta line is DetailMeta with the web-activity status (useWebActivity: a green dot while a tab is open, otherwise a grey dot and the last visit; none for a chatbot, which never signs in) and then the role and preset name; the register's own account menu (enter the account, make or remove admin, delete, passed in by UsersView as moreActions) sits on the name row; there is no Edit action. The tabs (Segmented variant="line" nowrap, labelled t.users.detailSections) are Permissions, Projects (with the assigned count), Account and one tab per panel plugins declare for web.user; the drawer opens on Permissions, and a tab whose plugin disappears falls back to it; a plugin's userStatus mark is the first line of its tab, right-aligned beside the panel label; the line is omitted when the plugin registers none and keeps only the label while the mark renders nothing. The tab contents are UserSections.tsx: Account is two groups, Identity (name, login name, preset (PresetChoice), role) and Activity (created date and UserStatsRows), whose records carry their explanation as hint so each is one label/value line; the name and login name are inputs that save themselves; Permissions holds four groups (project grants, voice, spend limit, and models, plugins and tools as stack rows), each autosaving, and while the account follows a preset each group says From preset as muted text in its actions; the first change still goes through GrantChangeGate (keyed by the preset id), which asks before it detaches the account. Projects lists the assigned projects as SettingsItems with the state the Sandbox plugin reports through usePluginProjectRows (an amber dot and Deleting for a project being deleted), and Manage opens the shared ProjectSelectionModal. The name and login name each own a useAutoSaveStatus that patches only its field (trimmed; an empty login name is marked invalid and never sent), with the row's AutoSaveStatus and Retry, useAutosaveToast for the saved confirmation, and a toast on failure that names a username collision (409); so the drawer has no edit mode and no footer.
Literal UI interpolation
Translated UI substitutions use the existing interpolate(template, values) helper. Pass canonical numeric counts directly, or a caller-formatted string where that display requires it. It replaces every supplied placeholder literally, including dollar-containing values; unresolved placeholders remain unchanged. DirectoryPicker uses interpolate(t.projects.folderCreated, { name }) after a confirmed create operation. ManageSelectionModal, MorePill and RowPicker use the same helper for counts. ProjectAccessPanel and CustomSkinsRow select the grammatical form with plural first, then interpolate the count; this creates no new dictionary keys or formatter.
Shared controls
ControlSurfaceToolbar supports inline and split children layouts. Inline is the default wrapping control row; layout="split" separates both ends while allowing them to wrap when the container narrows. The unused stacked layout is retired. promote defaults to true and contributes the children toolbar through the existing page toolbar portal; promote={false} keeps it local, and no mounted page slot leaves it at its call site. Structured search, filters, filterActions, filtersNamed and actions continue through the existing PageToolbar contribution. PluginDetail consumes the children portal form.
SelectMenu accepts a typed flat options list, value, onChange, accessible label, optional id, line/default variant, disabled and className. It has no invalid prop. Radix owns arrows, Home/End, typeahead, Enter, Escape and returning focus to the trigger; disabled locks the trigger, and an omitted disabled prop leaves it interactive. The wrapper owns the caller's sizing class. Conversation diagnostics filters are a real consumer. Generic shadcn SelectTrigger callers can still use aria-invalid directly.
WorkspaceHeroActionGroup (web/components/ui/WorkspaceHero.tsx) holds a pair of related hero actions, ghost sm buttons, in the hero's action slot and keeps them on one line at every width. Without it a narrow hero wraps each action onto a row of its own. Inside the group each action shrinks from its own width and its label wraps beside its icon; on a phone hero (< 34rem) the actions trim their side padding to 0.25rem, which only a boxless ghost button can spare, and on a coarse pointer each keeps the touch minimum. It is host-only, not part of the plugin UI runtime. The System hero's "Restart service" / "Restart web" pair is the consumer: one line each at 390 px in cs, sk and en, labels wrapping beside their icons at 320 px (web/tests/e2e/specs/settings.system-licence.e2e.ts).
WorkspaceTakeover keeps its own compact header and owns the internal overlayHeaderControlClass (web/components/ui/shadcn/dialog.tsx) for its back control; it is an implementation constant, not an ElowenUiRuntime member. A takeover without a custom back label uses the localized Back label. ConfirmDialog composes DialogHeader for its confirmation chrome.
EmptyState retains EmptyMedia and an aria-hidden decorative icon when icon is supplied, and omits the media when it is absent. The unused empty-state__icon class is retired. The active empty-state, empty-state__title and empty-state__description styling hooks remain. DirectoryPicker consumes EmptyState for a successful empty listing; loading and error continue through the distinct shared states.
Published telemetry primitives
The published telemetry rail primitives are RailSection, RailList, RailRow and RailDot, plus the separately published ShimmerText. RailSectionHead and RailMeter remain internal host components, not runtime members. RailSection renders the shared three-column heading with label, optional icon and optional count pill; omit count to retain an empty value grid cell, and pass count={0} to show zero. There is no meta fallback. The Todo TasksRail is a real runtime consumer. The registry host fixture preserves this count-only shape without reproducing host styling.
API 65 publishes three more members a live rail row needs, because a row here is a thing with its own controls rather than a menu of actions. RailMenu is the rail's popover: a panel of rail rows opened by the caller's own trigger, at the rail's own widest width (RAIL_MAX_WIDTH), so the collapsed rail's instrument keeps its shape and its accessible name while the panel holds rows that carry buttons and the row's facts read whole. MarkStrip is the row of small marks that each name themselves on hover; the conversation list's model mark is its canonical consumer. HoverCard is the card that opens after the pointer rests on its anchor (600 ms, one shared delay) for content too long to sit in a row; its body is bounded and scrolls, and it never takes focus. The card renders in place, so while it is open its anchor is lifted to z-index: 50 above the neighbouring rows' lifted anchors, and the caller gives the anchor no native title, which would add the browser's own tooltip over the card. The same API publishes utils.formatSpeed, the one rendering of a measured generation rate, and utils.apiErrorMessage, the host's error shaping. The bundled Subagent rail is the consumer: its running sub-agent rows carry the elapsed time, the measured speed, the status note, the model mark, the brief and the stop control. The registry Todo and LSP sections require nothing new.
API 66 adds one prop to RailRow: note, the row's second line. A row with a note takes two lines — the name keeps the rail's own 28px line height on the first one, with the lead and the figure column, and the note runs on a line of its own below, in the rail's muted ink, one line deep. The note's own cell spans the text and the figure column, so a long note runs the full width of the row while the figures stay on the first line; the caller insets the note, so it sits under the NAME and not under whatever the label carries before it (a model's mark). A row without a note is unchanged: one line, the name beside its detail, the figures in the trailing column. The bundled Subagent rail is the consumer.
Dashboard pulse
MetricsTile uses trailingDays (web/modules/dashboard/metrics.ts) to fill its thirty UTC-day chart
slots, projecting each timestamp with utcDayOf from web/lib/utcDay.ts, the byte-identical browser
mirror of src/shared/utcDay.ts. This pure projection performs no I/O and throws on invalid timestamps.
The dashboard keeps its 86,400,000 ms step and measured row identities, including zero-valued rows;
missing rows alone receive zero token components and an unpriced (null) cost. Calendar-month figures
continue to use currentMonthBounds and utcDayWindow, independently of the trailing chart window.
The dashboard obtains team pulse data through the existing usePulse query for /activity/pulse; TeamPulseTile consumes the snapshot and MetricsTile passes its monthly data to PulseRings. Per-person hourly and daily activity counts remain available for activity shapes without a second heatmap request. Origin-usage counters and activity histograms have independent writers: a nonempty histogram must not turn an authoritative zero yesterday-turn count into a nonzero usage count. An absent origin store is signalled by spendAvailable: false; its bucket fallback supplies only activity counts. The removed aggregate endpoint has no replacement route.
Published page filter type
The declaration-only PageFilterField contract comes from elowen-plugin-ui-kit; active fields require their chip label and reset action, while inactive fields forbid both. PageFilters and PageToolbar consume it directly. See the canonical published type.
Typography: one scale, two families
web/app/styles/tokens.css is the one source of every font size, weight and family in the web app, its skins and the plugin bundles (owner, 13 Oct 2026). The values are the ones the Settings window shows; every other surface, drawers, pages, cards, tables, dialogs, chat chrome and plugin UIs included, uses the same roles and therefore reads at the same size. Each role is a Tailwind v4 text token, so the class (text-ui) and the variable (var(--text-ui)) both carry the size, the line height and the weight:
| Role | Size / line / weight | Use |
|---|---|---|
text-page-title |
30 / 36 / 600 | the single h1 of a page |
text-section-title |
16 / 20 / 600 | a section, card, drawer section or dialog heading |
text-card-title |
14 / 20 / 500 | the name of one record: a settings row, a list item, a field label |
text-ui |
14 / 20 / 500 | interface text: values, controls, tabs, cells, menu items |
text-meta |
13 / 20 / 400 | the secondary line: a description, a status, a hint, in the muted colour |
text-small |
12 / 16 / 500 | the 12px floor: badges, chips, counts, timestamps, axis labels |
text-figure |
24 / 1 / 500 | one statistic's value |
text-body |
16 / 24 / 400 | the document, and a phone input (iOS zooms below 16px) |
text-conversation |
16 / 1.5 (15 on phones) | the chat's reading text |
text-display |
clamp(34-69px) / 1.06 / 600 | the dashboard greeting |
The primary roles take the navigation's 500 weight and the full foreground colour; only meta is 400 and muted, so the hierarchy reads from weight and colour as well as size. A weight utility on top of a role is for a real distinction the roles do not express (a selected day, a total), never to restate the role. There are two families: the sans (--font-sans, Inter Variable) for everything and font-mono only for real code, meaning code blocks, inline code, paths, hashes, ids, terminal and log output and command lines; numbers are sans with tabular-nums where digits align.
Not allowed anywhere outside tokens.css: Tailwind's own text-xs|sm|base|lg|xl|…, an arbitrary text-[…] size, a font-size that is not var(--text-<role>) or inherit, a font-family outside tokens.css and base.css, font-display, font-[…], and font-mono outside the code components listed in web/tests/styles/typeScaleGuard.test.ts. That test fails on the first offender with "add a role in web/app/styles/tokens.css instead". When a surface really needs a size, weight or family no role gives it, add ONE new role token in tokens.css, document it there, add it to the cn merger list in web/lib/utils.ts, and run node scripts/generate-skin-contracts.mjs to mirror it into packages/plugin-ui-kit/theme.css and the skin token contract; never invent a local size or face. There is no runtime setting or manifest option for the scale: the token file is the setting, and a customer skin may override the role tokens like any other public token. The only stated exception is the instance wordmark (.instance-wordmark in primitives.css), a logotype fitted to the page width rather than text on the scale. Third-party widgets that take numbers rather than CSS (Recharts ticks, Monaco and xterm options, the avatar initials sized from the avatar) keep their numeric sizes at the floor or above.
Host Markdown HTML has one core-owned parser: packages/plugin-shared/documentMarkdown.mjs.
web/lib/markdown.ts keeps the browser boundary plainContent → parseMarkdown → DOMPurify
for ChatMarkdown and the plugin runtime's utils.renderMarkdown; do not replace either
decoding or final sanitization when reusing it. Authored documentation uses the same
Marked owner through packages/plugin-shared/document.mjs: validated YAML, stable
section IDs, H2/H3 TOC and heading-owned retrieval text. Environment adapters supply
link/image resolution and a mandatory final sanitizer; server article HTML uses
documentServer.mjs (DOMPurify + jsdom), never a browser-only DOMPurify import in Node.
The Guides article header owns its visible H1; the server renders the body with
omitTitleHeading: true, preserving the title fragment on an empty, aria-hidden anchor
without changing TOC/section identities. The public layout can retain the default source H1.
Code highlighting is an optional synchronous callback before sanitization. Neither
parser grants filesystem/network access or fetches images. See
packages/plugin-shared/README.md for the complete contract and translation rules.
Text links: one look
Every inline text link in the web app and in plugin bundles has one look, after shadcn's link: the colour of the text around it, always underlined 4px below the baseline, --color-primary on hover, the global keyboard focus ring from base.css, and no underline with 40% opacity and a not-allowed cursor when disabled. It is defined once, in web/app/styles/components/primitives.css, as two classes and nothing else:
inline-linkon the anchor itself: an<a>, a Next<Link>, or an in-app record link. Minimal use:<a className="inline-link" href={url}>{name}</a>. Consumers include the Account header's Agent API link, the Problem reporting licence-server link, a memory record link in the memory-dreaming card, and the editor plugin'sVerify on GitHub/Open on GitHubanchors.inline-linkson a container whose anchors are generated markup, which gives everyainside it the look: rendered chat Markdown (ChatMarkdown), the editor plugin's Markdown preview, the changelog plugin's entries, and the shadcnFieldDescriptionandItemDescription.
Neither class sets a colour, size or weight: put the text role and, when the link must differ from its line, a resting colour utility on the element (text-foreground for a record name in a muted line); the hover still wins, because the rule is unlayered. A whole row or card title that opens its record keeps its row hover, and a control that looks like a button stays a Button (or buttonClassName on an <a>); neither is a text link.
A text ACTION is a control, never a link (owner, 13 Oct 2026, replacing the underlined action link): Retry, Choose icon, Map repository, a disclosure toggle, Show all and Open are the shared Button with variant="ghost" — quiet, unadorned, no underline — the look Show all in the project drawer's commit group has always had. A size is sm where the control sits inside a line or a row (AutoSaveStatus's retry, reload and keep-my-changes, the operation dialog's log toggle, the Account spend retry, the chat tool-output toggle, the memory category icon suggestion, the project card's Open, the editor plugin's Choose icon and tree retry, the GitHub plugin's Map repository); the drawer's own state boxes keep the Button default. So no <button> wears .inline-link anywhere in the app.
Plugins use the same class names and the same C.Button with no UI API change, because the host stylesheet is global. On a host older than this rule the classes do nothing and a plugin link renders as plain text in the surrounding colour. web/tests/styles/inlineLinkGuard.test.ts pins the definition, and fails on any other underline — an underline, underline-offset-* or decoration-* utility in class text (read from the TypeScript AST of web/ and the bundled plugins' web-src), or a text-decoration other than none in any stylesheet — and on any <button> wearing either class.
Design tokens and skins
The canonical design tokens are in web/app/styles/tokens.css. They define semantic colors, typography, radii, spacing-related measures, shadows, motion, shell dimensions, overlay layers, safe-area values, and touch targets. Components should consume semantic tokens instead of feature-local color literals.
Plugin bundles cannot import host web modules. The plugin UI kit therefore carries the theme mirror in packages/plugin-ui-kit/theme.css. Keep the mirror compatible with the host token names when changing the shared plugin contract. Host-document tokens, such as shell gutters and overlay stacking, remain host-owned and are not copied into the plugin theme. --fab-size in the host interaction primitives sizes both the advisor launcher and background dock; --fab-clearance remains a published token derived from that size, but the shell and toast adapter no longer consume it.
A skin is the whole look of the app: its palette plus the structure the design family owns. There are two kinds, and one catalogue serves both.
A built-in skin is compiled code under web/skins/<id>/: its palette in skin.css, its structure in one of the family stylesheets. web/lib/skins.ts holds the registry (currently studio-light, studio-oled, umber-night and studio-graphite, all four in the studio family), and the structural selectors are scoped to [data-skin-family='studio'] rather than to a skin id, so a customer skin derived from any built-in inherits the same structure. A member states its palette in its own token block, and may add a presentation rule scoped to its own id where that one design genuinely differs (the placeholder ink of studio-oled, the hidden closing wordmark of umber-night); a rule scoped to an id has no place in a family stylesheet, because no build knows a customer skin's id. A rule that belongs to ONE variant and should carry over to customer skins derived from it — studio-graphite's pill controls and its lifted navigation search field — is instead scoped to [data-skin-base='<id>'], which a customer skin derived from that built-in keeps and one derived from another variant does not.
A customer skin is instance data under <dataDir>/skins/<custom-id>/: a skin.json manifest (id, display name, built-in base, optional colour scheme and uploaded font) and a tokens.css holding public token overrides. scripts/generate-skin-contracts.mjs derives the public vocabulary and kinds from both web/app/styles/tokens.css and every built-in palette block. This includes the studio chat, ask, fill and placeholder colors, shadows and both font families. src/shared/skinStore.ts owns validation, persistence and stylesheet generation. The submitted token sheet still cannot contain selectors beyond its single :root rule, url(), at-rules or code. Branding and filesystem theme packages remain owned by src/shared/themeStore.ts.
The shadow token kind accepts none or comma-separated shadows. Each shadow has optional inset, two to four literal lengths, and exactly one color using the existing color grammar. Blur cannot be negative, percentages and length expressions are refused, and commas inside color functions do not split shadows. Color var() references must name color tokens; rgb() and rgba() may also read an RGB-triple token such as --primary-rgb. Other functions, URLs and unknown references are refused. Font tokens accept named family stacks; they never supply resource URLs.
An administrator can attach one normal WOFF2 face, intended for a variable font covering weights 100–900. One file avoids a weight/style asset registry for a skin that needs a single family. The store caps raw upload bytes at 2 MiB and checks the wOF2 header before publishing anything. Manifest font: { family, digest } uses a 1–60 character family name beginning with an ASCII letter and containing only letters, digits, spaces or hyphens, plus a full SHA-256 digest. The generated filename is font-<digest>.woff2; uploaded filenames never reach a path. The existing bounded, no-follow regular-file reader has a binary projection for this asset. Reads recheck bytes, header and digest. Upload publishes font bytes before atomically replacing the manifest; removal updates the manifest before deleting the font. The reserved font filenames are removed with the skin, including an orphan left before a manifest publication. No uploaded font means no generated face and ordinary system-family resolution.
Settings' CustomSkinsRow uploads with PUT /skins/:id/font?family=<family>, a raw WOFF2 body, and removes with DELETE /skins/:id/font. Both use the existing administrator-or-setup gate and return { id, digest, revision }; invalid input returns 400 and an oversized upload returns 413. GET /public/skins/fonts/:id/<sha256>.woff2 serves the current file as font/woff2, with nosniff, same-origin resource policy and one-year immutable caching. It is public because the login page needs the face. An unknown id or digest returns 404. The store retains the previous stylesheet and its font bytes in memory for in-flight requests, at most two snapshots and 4 MiB of font data per skin; deletion clears both. The generated stylesheet uses a relative font URL, so it works through the web BFF and directly through the daemon. The generated face has a family scoped to the skin id and font digest, and matching named families in font token stacks are rewritten to it; two skins uploading the same family cannot affect each other or built-in typography. Operators use the supplied family in --font-sans or --font-mono, for example --font-sans: "Manrope", sans-serif. Catalogue entries expose the optional fontFamily for Settings. Font mutations invalidate both catalogues, and SkinProvider refreshes the current stylesheet when its digest moves, including when removal restores a previously loaded digest.
The root document carries data-skin, data-skin-family and data-skin-base. Switching between built-in designs is an attribute write; switching to a customer skin loads its stylesheet first, then writes the same attributes. SkinProvider writes them in a layout effect, so every descendant effect that reacts to useSkin().skin (the interactive view copies resolved tokens into its frame) already reads the new design. The server resolves the active skin before first paint from GET /public/skins (the public catalogue: built-ins plus valid customer skins) against the instance allow-list, and every route falls back to a compiled default when that catalogue cannot be fetched. ELOWEN_SKIN may name a customer skin the instance has.
The Settings surface manages allowedSkins from the merged catalogue and adds or removes customer skins through the daemon. A stored id the instance no longer has is kept in the list and shown as unavailable rather than dropped. SkinSwitcher cycles the account's choice through the allowed skins and renders nothing when fewer than two are allowed.
Shared resource meters
web/lib/format.ts formatUsageCount(value, locale, minimumFractionDigits = 0) renders localized usage figures with at most one decimal. The third argument is 0 or 1: chart percentages pass 1 to preserve fixed one-decimal labels, including "100,0" in Czech. Existing callers omit it to keep integral counts without a decimal suffix. CardHead and PulseRing read the viewer locale from LanguageProvider, so their shares update with the language setting without a new locale prop. An omitted CardHead share draws no share; an empty PulseRing retains its caller-supplied empty label. ResourceMeters uses the same formatter for measured shares while retaining its CPU fraction floor, capacity "<1%" rule and dense nonbreaking percent separator. ProviderStatsBody is a real consumer. These helpers only format already measured values and do not fetch or estimate data.
utils.formatBytes(bytes, locale?) delegates to the canonical shared byte-size policy. Pass the viewer's locale; 1536 bytes renders "1,5 KB" in cs/sk and "1.5 KB" in en. Units are B/KB/MB/GB/TB on a 1024 ladder, with no grouping, no decimals for bytes or amounts of ten units and above, and one decimal otherwise. Zero, negative and nonfinite sizes render "0 B"; omitted locale uses English. The TB ceiling is retained for larger sizes. The host resource rings and Sandbox resource history consume this synchronous formatter. It does not format network rates or perform I/O.
UI API 39 publishes components.ResourceMeters({ metrics }), implemented in web/components/ui/ResourceMeters.tsx, with ResourceMetersProps and PluginProjectRowMetrics in the UI kit. ProjectCard and Sandbox Account Desktops use this same resource-ring renderer, bounded to five readings, including CPU fraction formatting, exact capacity text, unknown/stopped/unavailable empty rings and quiet refreshing/stale presentation. It is a group, not a live region. Producers own the measurement and its labels, not the rendering. UI API 41 adds size="compact" for a surface where the strip is secondary to what it sits under: each resource becomes a 16px MetricDonut (size="compact", no figure in the hole) followed by its label, used capacity and percentage on one horizontally scrollable caption line, with the full used/limit pair in the tooltip and accessible text. CPU keeps its percentage; an absolute reading without a known limit shows only its value. The Sandbox chat desktop tile is its consumer. UI API 47 adds the optional figure on a metric: text of at most four characters drawn in the hole of the default ring instead of the percentage, for a reading that is not a share (Sandbox's disk speed shows 50 with MB/s in description beneath, while percent still sets the arc). Both ready and absolute readings display it; unmeasured states ignore it. Both sizes stay on one line with native horizontal scrolling when their contents do not fit. Default rings keep their 52px geometry, rate captions keep their full single-line unit, and fitting strips distribute the rings across the available width. The Sandbox Network reading supplies an absolute Mb/s figure when unlimited. Its history curve uses the shared chart's right axis in Mb/s, with percentages in full-point formatting when the sample has a limit. The compact caption shows figure with its unit in description and adds the localized percentage, for example 40,0 MB/s · 26,7 %. A default ring with a figure shows only the unit beneath it (owner, 13 Oct 2026): the figure is already in the hole, and the share lives in the tooltip and accessible text. Non-CPU meter tooltips and accessible descriptions include both the exact value and the localized percentage. With four or five rings a figure pair such as 640 MB / 1.0 GB is cut to its used part, the full pair staying in the tooltip. The host also accepts size="large", outside the plugin kit and the UI API: 76px MetricDonut rings (size="large", 28px hole, figure at text-meta) centred with a wider gap, for a card closing on one machine's live load. The computer pieces' load block (ComputerLoadRings) is its consumer.
The strip reuses useHorizontalOverflow and revealHorizontalItem from horizontalScroll.ts, and the existing Segmented edge-mask rule in primitives.css. Only an edge hiding more content fades; a fitting strip has no fade. Resize, content and scroll observers maintain that state and are cleaned up on unmount. Focus reveals the entire metric within this track without moving an ancestor page scroller. Pointer, mouse and touch starts on the entire strip, including captions and gaps, stop propagating to card reorder sensors while preserving native scrolling. A tap on an action still opens history, and normal Tab navigation moves through its named buttons. The strip performs no queries or writes and has no extra tab stop for passive readings.
Sandbox's Network resource uses this same strip and the existing history action. Its ring shows the busier download/upload in Mb/s and the limit share when configured; unlimited readings remain absolute. The Environments resource row autosaves netMbitPerSec through the existing action, with a localized unlimited readout at zero and the shared Slider otherwise. Network history has one curve combining netRx/netTx, an Mb/s right axis and the contemporaneous percentage in its legend and tooltip. There is no additional card query, and unmeasured or stopped traffic remains a gap.
Sandbox's useProjectUsageMetrics(projects) owns the batch query and generation-sensitive last-known readings for both the Projects register and Account Desktops. Both pass the full authorized Project list, so sorted managed ids produce the same query key and reuse the host cache. Non-permission failures retain marked stale readings; 401/403 drop them and clear other usage caches. Account cards put the strip under the Project name and state, beside the still on wide surfaces and below it on narrow surfaces using the existing card container layout.
UI API 50 adds optional PluginProjectRowMetric.action: { label, onSelect }. A Sandbox resource-history selection attaches an action when constructing its Project contribution, for example { ...cpu, action: { label: cpuHistoryForProject, onSelect: openCpuHistory } }. The plugin supplies a localized name including resource and Project and owns the dialog in its existing overlay. Actionable meters use the shared ghost Button around the default ring or the compact ring and caption, with a normal Tab stop, aria-haspopup="dialog" and the measurement as its accessible description. Enter and Space activate once; key, pointer/mouse/touch start and click events stop before the surrounding Project card's open/sort/drag handlers. The existing touch-target token applies. Unknown and stopped rings may still open an empty history. An absent action preserves the passive markup and measurement semantics. This adds no request, polling, live announcement or new chart library; only the plugin callback admits its own data read.
Computer pieces
A paired computer is a Project (executionKind 'local') whose device facts ride on it as computer
(ProjectView.computer: name, connected, lastSeenAt, platform, arch, clientVersion and the latest
compact load). web/modules/computers/ is the ONE place that turns those facts into pixels, and both surfaces
that show a computer — the projects/ register card and drawer, and Account › Computers — compose from it.
computer.ts holds ComputerFacts, computerOf(project) and the isComputer guard, so no surface branches on
a second list shape again. ComputerFacts.tsx holds computerSystem (<OS> <arch>) and computerMeta
(<OS> <arch> · client <version>, each part omitted while unreported), ComputerState (the shared
DetailMeta dot with Connected or Offline) and ComputerLastOnline (a clock fact, drawn only while the
computer is offline). ComputerLoad.tsx owns computerLoadMetrics (the contract's compact reading as
ResourceMeters metrics, with the honest unknown rings) and ComputerLoadRings (the shared renderer
at card, compact and large sizes, with one history action per ring).
ComputerUsage.tsx owns ComputerUsageChart (GET /cli/devices/:targetId/stats over one range: turns and
commands as bars, failures and cost as lines on the right axis, a zero-filled point per day, one totals line)
and ComputerUsageItem (the folding row Account lists). A consumer passes a Project and a range; the pieces
read the device themselves, and the chart takes enabled so a folded item costs no request. Nothing here
fetches a device list, and the platform label is the only place a system name is spelled.
ComputerUsageItem keeps the native SettingsGroup/CardHeader
minmax(0,1fr) auto allocation: the action column reserves intrinsic meter/button
width while longer canonical connection text wraps in the flexible title column.
It adds no local grid-column or full-width action override. Folded rows with a load
reading still show only CPU/memory/disk above @2xl/card-header, hidden below it;
expanded detail retains all five metrics including IO/network. Structural jsdom
checks pin this allocation; the Account browser assertion measures actual overflow.
The register's card adds the state line and the compact rings where a host project shows its directory and a
managed one its environment; its drawer draws the name, the system, client and last-online rows, the large
rings, the activity chart under its own remembered range (elowen.projects.computer.range) and the project removal
action, and it asks the register to run that removal through the shared authoritative deletion confirmation.
A computer's NAME is its paired device's name (computer.label), saved on its own like every other project
field through web/modules/projects/projectFieldSave.ts: the daemon takes PATCH /projects/:id { name } for a
computer Project only and refuses a slug for one, so the field sends name. projectName() in
web/modules/computers/computer.ts is the one rule every surface reads, for the card title, the open and move
labels, the drawer header, the team strip, the register search and the chat pickers, and the Project's slug
stays the row's identifier, never a word a reader sees on a computer.
Its People tab is the register's own access panel, with the rules the daemon enforces. A computer's members
are changed only by its owner (creatorUserId), so other members, including administrators, read the
list and get no control; the owner invites with can_share_projects exactly as a managed project's owner
does. Administrators are ordinary pickable accounts ON a computer, the one doing the picking included,
because nobody may run there without a membership; a host or managed project keeps implicit administrator
access and its picker still leaves them out. The picker's warning says what a member actually gets on the
computer rather than in an environment. Shared memory requires administrator authority and, for a computer,
ownership as well, so an administrator using another account's computer receives no memory-management controls.
The Sandbox plugin declares its Environment tab for every execution kind. For a computer Project,
ProjectEnvironmentPanel uses the host SettingsGroup and EmptyState, the same pattern as the GitHub
plugin's computer explanation, to state that configuration belongs on the paired machine. It issues no
environment read for computers; managed Projects retain ProjectEnvironmentSettings and host Projects
retain their host explanation. The editor plugin's Overview Git panel renders nothing after a successful
read reports isRepo: false; repositories retain their branch, working tree and recent commits.
Shared clock
window.ElowenUiRuntime.hooks.useNow(periodMs = 1000, enabled = true) exposes the existing
web/lib/useNow.ts function directly, with its signature in the UI kit. Todo's live cards and rail are
the coordinated registry consumers. For example, const now = hooks.useNow(1000, live) supplies the
elapsed-label timestamp. Initial time is evaluated on mount; disabled readers install no subscription.
One one-second heartbeat serves all enabled host and plugin readers, each applying its requested
resolution. Hidden tabs do not publish ticks; visibility return publishes immediately subject to that
resolution. The last enabled reader removes the timer and visibility listener. There is no I/O, and tick
work is linear in enabled readers. No subscriber means no timer.
The hook is an additive staged member without changing UI API 54. New unreleased bundles must assert the actual member through their existing runtime assertion and ship with the paired host artifact; they must not create a local clock when it is missing. Browser integration must cover live/done card and rail labels, hidden-tab return, and mobile/desktop layouts.
ProjectTeamStrip also uses useHorizontalOverflow: attach ref to the team track and read edges.overflow to expose its existing keyboard scroll region. Pass enabled: members !== undefined && members.total > 0 because missing and empty membership render no track. The hook observes track resizing, descendant text/child changes and native scrolling while mounted; disabling it disconnects observation and reports no edges. Each enabled track owns one ResizeObserver, one MutationObserver and one passive scroll listener, with geometry reads and no requests or persistence. The consumer retains its own wheel listener, bounded arrow/Home/End arithmetic and motion-gated mouse drift; the hook does not move focus or add scrolling gestures. HorizontalOverflowState, NO_HORIZONTAL_OVERFLOW and horizontalOverflowState are private implementation details; the existing hook return type remains intact.
The dashboard's dash-strip uses the same unlayered edge-mask rule in primitives.css as ResourceMeters and nowrap Segmented. Publish --segmented-edge-fade-left and --segmented-edge-fade-right from the measured edges, using 0px when an edge hides nothing. The dashboard overrides --segmented-edge-fade-size to 1.5rem immediately after the shared rule; WorkspaceHero metrics use 1.25rem; ResourceMeters and nowrap Segmented retain 0.75rem. Without overflow both edges remain unfaded. This CSS contract paints the affordance only and adds no input handling, requests or persistence; DashboardView is the concrete dashboard consumer.
Shared resource-history window
UI API 61 exposes components.ResourceHistoryModal through window.ElowenUiRuntime, typed by
ResourceHistoryModalProps in packages/plugin-ui-kit/index.d.ts. Host settings, computer rings and
Sandbox all use this exact component. The caller supplies metric, description, onClose and a
useHistory({range,metrics}) hook returning validated data, loading/error state, retention bounds and retry.
A caller may supply a localized domain failure; shared history errors use the host translations.
An optional scope slot holds the Sandbox Project picker; host and computer have a fixed subject.
The component owns range and metric selection, the reading frame, localized states and the chart.
Its caption distinguishes shares in percent, IO as a share of its limit or MB/s without one, and network
always in Mb/s, matching the chart builder for host, computer and container history.
It keeps five inline checkbox pills with coloured metric icons and disables the last checked pill.
It never replaces that row with a selection-management dialog. It issues no request of its own:
the supplied hook owns authorization, account/subject query keys and refresh policy. A closed window
mounts no reader. Host and computer use useResourceHistory; Sandbox keeps its existing
useProjectUsageHistory with account isolation and live-window refresh.
computerLoadMetrics returns CPU, memory, disk, IO and network. IO and network use ioFigure and
networkFigure in MB/s and Mb/s, state absolute and no percentage arc. Cards, the Project drawer
and Account compose these same items through ResourceMeters; no separate fitted computer strip remains.
A Project card handles only events from its DOM subtree. History controls live in a portal, so clicks,
keyboard navigation, context menus and mouse/touch sort sensors leave that dialog independent of the card.
Events still reach the document, preserving outside-click dismissal of its pickers.
Large computer meter actions occupy the same 76 px square as their donut, keeping captions below the ring.
Overflowing large strips align to the start so the first and last readings remain reachable by scrolling.
Shared measured viewport
UI API 50 publishes hooks.useMobileViewport(): boolean | undefined through window.ElowenUiRuntime and the UI kit. This is the existing hook from web/lib/useMobile.ts, using the host's phone breakpoint. It returns undefined during SSR and the first render, then measures in an effect and updates when the breakpoint changes. Each mounted consumer owns one media-query subscription, removed on unmount; there is no timer or network request. Plugins must wait for a boolean before mounting a viewport-specific frame and must not copy the media query. Older hosts refuse bundles requiring API 50 before mounting.
The shared ResourceHistoryModal waits for this measurement before rendering or enabling its history query, then passes size="page" and presentation={mobile ? 'fullscreen' : 'center'} to the shared Modal, matching Settings' reading frame. It draws one combined chart: each checked metric is a curve of its share of its own limit, in a fixed colour, and IO is the busier direction. The chart fills the height below the controls (height: 'fill', UI API 52) with a minimum of min(60vh, 560px), and ModalBody scrolls when a short phone cannot show that minimum. Visible live windows refresh once per minute; resuming from a custom range or hidden tab refreshes the cache anchor before the first request. Fixed custom ranges do not poll.
Shared precise ranges and time charts
The resource-history vocabulary is stated once, in packages/plugin-shared/resourceHistory.mjs: cpu, memory, disk, io and network expand through resourceHistorySeriesIds(metrics) into the seven wire series, each with its unit, physical source, value field and limit field (CPU receives its limit from the caller), plus the windows and resolutions (RESOURCE_WINDOW_IDS and RESOURCE_RESOLUTION_IDS are what the core and Sandbox HTTP boundaries validate window and resolution against, and RESOURCE_HISTORY.RESOLUTION_SECONDS states each fixed step), the point states (RESOURCE_POINT_STATES, which the browser response schemas take from the artifact), retention and bucket cap, and the point, series and response declarations (src/shared/wireContract.ts re-exports the point and the response for the web); the server-only range and bucket shaping live beside it. The chart rules (labels, colours, compact rate figures, resourceHistoryChart) are stated once, in packages/plugin-ui-kit/src/resourceHistory.js, which imports the vocabulary. Neither source is imported by a browser directly: a bundle cannot import the server package and the web build resolves nothing outside web/, so node scripts/generate-resource-history.mjs bundles the chart rules with the vocabulary they use (esbuild, server-only code tree-shaken away) into one self-contained artifact and writes the same bytes to packages/plugin-ui-kit/resourceHistory.js (elowen-plugin-ui-kit/resourceHistory) and web/lib/resourceHistoryChart.js, with packages/plugin-ui-kit/src/resourceHistory.d.ts copied beside each as its declarations. Edit the two sources and regenerate, never the outputs. tests/contract/resourceHistoryVocabulary.test.ts regenerates and fails on any drift, checks the artifact's values and the declarations' literal types against the vocabulary and the restated point, series and response interfaces against resourceHistory.d.mts, and tests/contract/webMirrorContracts.test.ts pins the kit and web copies byte for byte. Shipped migration CHECK literals remain frozen in the owning store, and metric icons stay local to each surface. With no requested selection, the HTTP history boundary selects all five resources; missing observations keep the existing explicit gaps. The vocabulary performs only bounded selection work, makes no requests and adds no metric registration mechanism. ResourceHistoryModal is the browser consumer.
UI API 50 publishes typed DateRangeFilterProps, InstantRange, InstantRangePreset, InstantRangeConstraints, InstantRangeBounds, TimeSeriesChartProps, TimeSeriesPoint, TimeSeriesSeries, TimeSeriesDetail and TimeSeriesTimeAxis from the UI kit. Plugins use the same components through window.ElowenUiRuntime; they do not import web files. Sandbox load history is the intended instant/time consumer, while Provider statistics and plugin daily statistics keep calendar days and categories.
DateRangeFilter with absent precision or precision="day" keeps its existing Today/7d/30d/90d/All/custom behavior, including partial custom bounds and local date inputs. precision="instant" takes an InstantRange, its typed onChange, optional presets, compact, and optional min/max canonical UTC ISO instants including milliseconds. Its default presets are 1h/6h/24h/7d/30d/custom. Presets emit the selected preset with null bounds; Sandbox sends window=1h|6h|24h|7d|30d for the server to resolve against its own current time, including the current partial bucket. Durations are exact elapsed hours, including 168 and 720 hours for 7d and 30d, independent of DST. Custom ranges alone use utils.instantRangeBounds({ from, to }, { min, max }). The validator returns { valid: true, from, to, fromMs, toMs }, or { valid: false, error } with incomplete/invalid/order/bounds. It does not clip, fetch, pick a clock or enforce a product's retention policy.
Custom instant fields use the shared Input and Popover with datetime-local, millisecond step, local min/max attributes and localized inline alerts connected by aria-describedby and aria-invalid. Only two complete, valid, increasing instants within the constraints reach onChange; invalid drafts stay in the picker and dismissing them resets to the selected range. Native Date parsing followed by a local round trip rejects nonexistent spring-forward times and normalized invalid dates. Newly entered repeated-hour values choose the earlier occurrence, as standard Date does; an unchanged existing bound keeps its original instant, including the later occurrence. Conversion uses the browser zone; labels use Intl in the viewer's locale. This mode requires no date dependency, network call or timer. The caller controls server-clock anchoring and retention bounds.
Sandbox's useUsageHistoryActions decorates Project, Account-desktop and chat-desktop meters with one ResourceHistoryModal; its useProjectUsageHistory sends a server-anchored live window or explicit fixed custom bounds, mutually exclusively, and plots the server's effective range through this composition:
const custom = range.preset === 'custom' ? runtime.utils.instantRangeBounds(range) : null;
const params = new URLSearchParams({ projectId: String(projectId), resolution: 'auto', metrics: 'cpu' });
if (range.preset !== 'custom') params.set('window', range.preset);
else if (custom?.valid) {
params.set('from', custom.from);
params.set('to', custom.to);
}
<C.DateRangeFilter precision="instant" value={range} onChange={setRange} min={retentionFrom} max={serverNow} />;
<C.TimeSeriesChart data={points} series={series} detail={detail}
timeAxis={{ dataKey: 'at', domain: [Date.parse(data.range.from), Date.parse(data.range.to)], tickFormat: formatTimeTick, tooltipFormat: formatPointTime }} />;
TimeSeriesChart.timeAxis is optional. Absent, the existing categorical label axis, monotone line interpolation and passive Recharts layer remain. Present, the descriptor supplies the numeric timestamp key, a finite increasing epoch-millisecond domain, and independent tick and tooltip formatters. Points must contain unique ascending finite timestamps; invalid keys/domains throw at the component boundary. Repeated local labels are safe because the text list keys by timestamp. Time mode uses Recharts' time scale, linear lines and left/right keyboard accessibility. Both modes keep nulls disconnected, isolated-point dots, independent per-series unit axes, numerical detail rows and no animation. The tooltip shows only the formatted time and the hovered values; a point where every series is null shows no tooltip unless a series supplies formatPoint with a measured value. Insert explicit null points for missing intervals; omitting a row alone cannot break a line. UI API 52 adds height: 'fill', for a chart that sits in a flex column whose remaining height belongs to it. Minimal use: <section className="flex min-h-[min(60vh,560px)] flex-1 flex-col"><C.TimeSeriesChart {...chart} height="fill" /></section>. The figure becomes a flex column that fills its parent, and the plot takes the leftover height with a 240px floor. The loading placeholder reserves the same space, so the figure does not jump when Recharts arrives. The parent must be a flex column with a definite height; no other parent is supported. Absent or numeric height keeps the fixed pixel height, defaulting to 220px. Its one consumer is Sandbox's load-history dialog, which draws one combined chart below its controls. The host's TimeSeriesChart also takes legendEnd, outside the plugin kit and the UI API: a control drawn at the trailing end of the legend row, right-aligned and wrapping under the legend when the row is too narrow; the Account token and computer items put their date range there. The chart does not resample or smooth data, infer units, clamp historical percentages or choose its surrounding layout.
An optional TimeSeriesSeries.formatPoint(point): string | null formats a whole reading for the tooltip, accessible text list and the latest measured value beside the legend label. null means no measured reading; the chart skips it in the tooltip and searches backward for the legend's latest reading. The numerical format(value) still owns axis ticks. Without formatPoint, the original numerical tooltips and label-only legend remain. A formatter may return an absolute value even when its plotted number is null; Recharts then retains the hovered point rather than fabricating a share. Sandbox's RAM, disk and IO history uses this hook to show 640 MB · 62,5 % or 40,0 MB/s · 26,7 %, and only the value when percentMean is null. Its axis remains percentage-based and unknown limits leave gaps. CPU remains percentage-only. IO's absolute value and percentage come from the same selected direction. The hook adds only bounded formatting work over the supplied rows and no I/O. These optional additions ship with the paired bundled Sandbox and host without a UI API version bump.
Both halves of the lazy chart import their types from web/components/ui/timeSeriesChartTypes.ts, including the host-only HostTimeSeriesChartProps.legendEnd used by computer usage. Neither half imports types from the other; the UI kit owns the public chart props.
The lazy Recharts boundary is unchanged and an empty range resolves before loading it. No second chart, SVG/canvas renderer or dependency is added. Rendering and validation cost scale with supplied points and series; callers must bound responses, as Sandbox does with its planned maximum of 720 buckets per series. Browser proof of geometry, tooltip containment and keyboard navigation is required after integration; jsdom only checks configuration and text.
Internal action and label adapters
ContextMenu.tsx::toMenuEntries maps ActionMenuItem leaf actions to MenuEntry rows in caller order, preserving label, Lucide icon, disabled/destructive state and selection callback. ProjectsView consumes it when opening its context menus; the conversation list composes its row menu directly. Empty input gives no entries; callers supply grouping and DIVIDER. It adds no submenus, iconNode or onAfterClose lifecycle and is not published through ElowenUiRuntime.
AutoSaveStatusProps and ConfirmDialogProps come directly from elowen-plugin-ui-kit. Web component modules use them internally without re-exporting the types; rendering props and the kit contract remain unchanged.
surfaceLabel(t, surface) in web/lib/i18n/surfaceLabel.ts supplies ActivityTile, PulseRings, PlatformIcon and PlatformLinksCard. Only own dictionary keys are read; unknown recorded IDs such as constructor/toString remain literal. Known labels, including subagent, come from the current dictionary on each render without plugin lookup or network work. PlatformIcon reads the existing safe locale context, which also supports isolated component mounts, while its glyph and plugin asset resolution are unchanged.
TooltipContent enforces pointerEvents: 'none' inline: Radix DismissableLayer sets an inline auto value when a modal disables outside pointer events, which overrode the utility class and let a help tooltip block a neighboring control. A display: contents span supplies inherited pointer transparency to Radix's positioning wrapper as well, whose empty box otherwise still intercepts taps. The Settings provider API-format field exercises both boxes on touch. Placement, dismissal and accessible descriptions remain unchanged; the tooltip body contains no interactive controls.
useTooltipAnchor(closeDelayMs = 0) beside the shadcn tooltip parts owns open state, the description id, hover/focus handlers and cancellation of a pending close timer. HelpTip passes its existing 120 ms delay; MarkStrip, ProgramStatusIndicator, ProjectLocationTip and the Settings provider state icons close immediately on pointer leave. A focus-opened tip closes on blur one task after the delay, never during the blur itself: unmounting the tip while focus is moving made the Settings dialog's Radix focus trap send Tab back to the dialog's first control. Consumers keep their own tap propagation and attach aria-describedby only while open. The nonfocusable group MarkStrip uses only hover handlers. Radix still owns placement and the Escape and outside-pointer dismissal, and the hook adds no network work or clock while idle. Focus never dismisses a tip: TooltipContent cancels Radix's focus-outside dismissal, because the tip hangs on an Anchor, which Radix does not exempt the way it exempts a Trigger, so the very focusin that opened a tip from the keyboard reached the layer it had just mounted and closed it again. Every consumer closes its own tip on blur, which is the one focus close.
ManagedSelectionField owns the summary card and editable ManageSelectionModal lifecycle for field records. SkinsRow supplies the canonical allowlist, catalog items, pinned unavailable ids, its existing autosave callback. The internal saving prop forwards the caller's pending state to the existing dialog; it adds no persistence or requests. ProviderModelsModal loads the connected accounts' catalogs for a single logical provider, opened by the Manage models item of the provider's kebab menu (the provider block has no SelectionSummary card; its models show as a MarkStrip). Loading/error/retry states lead to the same ManageSelectionModal window, with a kind dropdown. Saving updates the provider-level model selection.
Generated paint and narrowed component contracts
Theme manifests and public theme payloads no longer expose mascotScene or mascotAnimated. Unknown manifest keys remain ignored. Asset paths, mascotSrc, v, theme.version, ETag and versioned asset URLs retain their current behavior.
The host supplies UI-kit tokens before mounting plugin surfaces. Browser's authored CSS reads those tokens directly. Editor inherits the host scrollbar's base thin/color/track/thumb paint while retaining its own 0.5rem dimensions, stable gutter and pointer-hover/focus reveal rules. A missing token is not replaced by a plugin-specific literal palette; supported skins define the tokens.
scripts/generate-skin-contracts.mjs generates packages/plugin-ui-kit/theme.css alongside daemon skin contracts and browser built-in paint. web/app/styles/tokens.css owns the projected @theme tokens and shadow-card/shadow-raised/shadow-primary utilities. Host-document :root state is excluded. Regenerate rather than maintaining the plugin artifact by hand.
RowPicker uses the shared Select default with no variant prop. TaskList folds and slices using TODO_PREVIEW_ITEMS with no limit prop. The host no longer publishes baseName or recreatable. Environment progress retains normal retry of the original action and generation; repair classification and repair-specific labels are retired, while supported recreate API and operation labels remain.
Unified managed selections
Every multi-selection that is not a model catalog or a load-history window is one control: a SelectionSummary card (the count line at the value role text-ui, the picked rows with their icons, and a Manage button that is the shared outline Button with data-selection-manage and the --touch-target minimum height on a coarse pointer) opening a ManageSelectionModal with search, group chips, one checkbox row per item and Cancel/Save. The chip row is always one line: SelectionSummary passes every picked row as a sample, and useHorizontalFold in web/components/ui/horizontalScroll.ts keeps as many leading chips whole as fit the row's measured width (at least one, truncated with an ellipsis when it is long) and folds the rest into a trailing +N chip, which counts the folded chips plus the caller's moreCount. Folded chips stay in the DOM as invisible absolute so their natural widths can be measured, and the row is flex-nowrap overflow-hidden; the count line and the Manage button are unchanged. The hook re-measures on every render and on every resize of the row. Callers pass every picked row and let the row decide how many show; a caller that still caps its own samples folds the rest only through moreCount, so its chips never reach the fold. The card is full width and wraps its button under the chips when the record is narrow; there is no line-only variant. disabled keeps the card visible when ModelSelection has no selectable Fast route; a disabled <fieldset> disables a group of controls natively. ManagedSelectionField in web/components/ui/RowPicker.tsx is the field wrapper: it derives the samples from items in row order, names the empty state with emptyLabel, labels the button Manage: <label>, and hands the whole next set to onChange once, on Save; ids a catalog no longer offers must be pinned into items by the caller so a save never drops them. Its consumers are SkinsRow (allowed skins, a trailingLayout="stack" record whose unavailable ids are disabled rows) and the plugin config fields projects, plugins, tools and multiSelect in PluginConfigFieldPickers.tsx, whose PluginConfigEditor records stack the control the same way. The load-history windows (Projects and Settings host history) are the exception: their five resources are a fixed set that the reader scans at once, so they are one inline row of role="checkbox" buttons with each metric's icon and no manage dialog, and the last checked one is disabled so a chart always has a series. Model catalogs stay on BrainModelField and ModelSelection; free-text token lists (TokenList) and the custom-skin manager (CustomSkinsRow, which creates and deletes records) are not selections and keep their own controls.
Unified single choices
A single choice in a form is one control, never a count-dependent presentation. ChoiceField (web/components/ui/ChoiceField.tsx) is the thin domain adapter over the anchored RowPicker (the shadcn Select trigger and row classes selectTriggerVariants, choiceItemClass and choiceCheckClass): it adds the stored-unknown-value row and passes each option's icon, description and action through, and it has no picker prop, so two and three choices are the same one-line dropdown as a long catalog. Segmented remains for section tabs and filters only. Account Terminal's theme, cursor and font family, the voice and dashboard choices, DaysPolicyEditor, the key-account API format, the OAuth sign-in method and the provider response-limit field all use it; a permission rule's allow/ask/deny is the shared SelectMenu at an ordinary narrow width (w-28) inside PermissionRulesEditor, which keeps its async onChange and the surrounding fieldset disabled unchanged. web/tests/e2e/specs/forms.single-choice.e2e.ts measures the trigger height, the open list inside a 390px mobile and a 1440px viewport in both Studio palettes, and the Terminal autosave.
Published table selection
UI API 57 exposes the existing DataTableSelectCell directly, with DataTableSelectCellProps defined once in the kit. It consumes its enclosing DataTable selection context: current-page ids, selected set and the caller's change callback. Header and row props form a discriminated union; a row requires both its id and accessible label. No context renders no cell. No bundled plugin consumes it at present (the MCP register is cards, not a table); the memory register is the host consumer of the same cell, while conversation rows use their list-owned checkboxes. Core tables and plugins share the same checked/indeterminate, keyboard and optional touch-selection behavior; selection itself performs no writes.
MCP server register
The bundled MCP page (plugins/mcp/web-src/McpServersPage.tsx) draws its server list as host list cards (CardGrid layout="list" with one CardGridItem per server, owner's rule above) with Pager, inside SpatialWorkspaceLayout (hero, metrics, and the toolbar's search and Sort/Status/Ownership filters), ControlSurfaceDocument and ControlSurfaceRegister; the editor is a WorkspaceDetailRail whose header meta carries the transport, status and ownership badges and whose pinned ModalFooter carries Remove, Reconnect and Save plus the editor's error. A card leads with the bulk CardSelectCheckbox (only on servers this identity may write; a running bulk write locks it), the enable switch and the live-status glyph tile, carries the name with its transport, status and sign-in badges on the first line and the scope (with the owner's avatar on a personal server) and bridged tool count pushed to the right, and the description and the command or URL beneath. The name is the one open control and the card surface opens it for the pointer; the checkbox and switch stop their clicks from reaching the surface. Picks are scope:name keys, are pruned to the filtered set and feed SelectionActionBar. A card register has no header row, so the order is the filter surface's Sort SelectMenu: the API's own personal-then-instance order, either name direction or either tool-count direction. One Badge tone mapping per state lives in plugins/mcp/serverPresentation.mjs (statusTone, authTone). The bundle requires API 58 for SettingsState. The OAuth callback keeps its full location.replace('/p/mcp'), because the published navigate is a history push and the documented contract is that Back cannot resubmit the callback; its Back button uses the host's default variant, and runtime.ts types Button against the host's variant vocabulary so secondary no longer compiles.
Explicit name/tool-count sorting applies globally before pagination; the rendered
pageItems stay in that order in one existing CardGrid layout="list", labelled
with the register title, without regrouping by ownership. The shared McpServerCard
mapping retains scope/owner chips, scope:name keys, filters, selection and write/
reconnect authority. Only default ordering uses personal/instance SettingsGroups:
personal starts open, instance closed, and search/scope/status filters force matches
open. Returning to default restores those sections and the API order. Mixed-scope
unit tests cover both directions and return to default; real-browser acceptance
remains separate.
Unified scalar readings
SliderWithReading is the one scalar slider with its visible reading (the reading is also the thumb's aria-valuetext); a hand-rolled Slider plus value <span> or status cell is not used. Its readingWidth is a fixed column (w-10 for percentages and counts, w-20 default) so stacked tracks end together, or auto for a reading of unbounded length: the reading takes its own width, wraps under a track that keeps min-w-24 and breaks its own lines when the row is narrower than the text. Consumers: the schema number slider in PluginConfigEditor (auto, reading is the formatted value plus the manifest display.unit, conversion and validation still come from pluginConfigNumber.ts), the Brain max-steps record, the ToolDeferralModal threshold, and CompactThresholdsDrawer (default and per-model thresholds; the reading is always the effective percentage, and a stacked SettingsRow says "default" or offers a ghost sm icon-and-label reset in its actions slot below the slider). Every PluginConfigEditor enum is a ChoiceField; ToolDeferralModal group and tool loading modes are SelectMenu dropdowns (default, immediate, deferred), its search is RegisterSearch inside a plain block, and its loading, load-error and empty states are the shared LoadingState, ErrorState and EmptyState. Nothing changes in the saved values, requests or window.ElowenUiRuntime. The host Slider (and so SliderWithReading) takes an optional onCommit(value), the Radix onValueCommit fired once a drag ends or a key press lands, for a control that saves its value instead of previewing every step (SpendLimitSlider, the provider drawer's routing and temperature sliders); without it nothing changes, and it is not part of the plugin kit's SliderProps.
Unified diagnostic filters and states
The Settings diagnostics surfaces use the shared filter and state components instead of local controls. LogsModal puts its line search (RegisterSearch) and a SelectionSummary of log levels inside one fieldset disabled, so both controls stay inert until a file is chosen; the level choice is a ManageSelectionModal draft applied with Save, and saving it empty keeps the existing contract that no level selected means no level filter. ResourceHistoryModal has no picker for its resources: they are an inline row of checkboxes, and the last checked one is disabled, so a chart always has a series and no request with an empty metric list is sent. The level picker is rendered as a child of its owning Modal, not beside it, because the overlay depth is what makes it a centered window above it (a sibling resolves as a top-level drawer under it). ConversationDiagnosticsModal uses RegisterSearch for sessions, tools and messages and the day-precision DateRangeFilter for its period; the range is resolved by utcDayWindow to the inclusive UTC days GET /brain/debug/sessions already reads as from and to, and all sends neither. A message search without a match shows EmptyState. PluginDetail navigates its workspace sections with DeckNavigation layout="tabs" in its existing page-toolbar contribution at every width and states the setup checklist progress and each required field with Badge; it does not nest a SectionDeck, because Settings is already the parent deck and a second framed deck would leave the config document about 560 px wide at 1440 px and add a second scroller.
Unified memory and conversation controls
The memory and conversation surfaces compose shared parts instead of local copies, and none of this changes window.ElowenUiRuntime. The memory register (MemoryView, MemoryRow) uses DataTable selection with DataTableSelectCell in the header and in each row, keyed by the stringified memory id: select-all is the current page, the row checkbox is named by memory.selectRow, the row-open button keeps its arrow, Home and End traversal, and merge, delete and purge keep the existing pruning of ids a filter hides. RankSlider (MemoryFields.tsx) is one SliderWithReading over the integer range 1 to 10 whose label is the slider's accessible name; the create modal's Field and the detail editor's DetailBlock supply the visible caption. A failed memory detail read is an ErrorState that refetches the record, and ConversationListPanel draws loading, empty and failed reads with LoadingState, EmptyState and ErrorState, where a failed scheduled-job or sub-agent read retries the one job-links query. The project destinations are three controls over the same GET /brain/execution projects, each keyed by the durable execution identity (executionRefKey), and all three open ONE menu body, ProjectMenuContent (components/ui/ProjectMenu.tsx), inside their own shadcn DropdownMenu: the chat ProjectPicker is a compact trigger whose menu checks the conversation's current project (selected) and applies a pick at once through selectProjectExecution, keeping its retry status and operation dock hand-off; the dashboard HomeComposer destination is a rounded pill (data-testid="home-project-picker") whose menu checks the chosen project and remembers its key on this device with the account's first project preselected; and NewConversationMenu wraps every new-conversation control (the chat bar's compact and full buttons, the phone overflow row and the conversation switcher's toolbar button) as the trigger of a menu anchored to it (data-testid="new-conversation-menu") with plain items showing each project's execution kind (showKind), and its own loading label, failed read with a retry item and localized empty label passed as status, which replaces the search and the rows. Each surface keeps its trigger, its choose action (onChoose) and its states; ProjectMenuContent owns the panel and its content: the search field, the rows (the shared ProjectIcon and the slug, radio items when the surface passes selected, plain items otherwise), the no-match line (projects.menuNoMatch, data-testid="project-menu-no-match") and the keyboard handoff. The panel is w-72, never wider than the viewport minus 1rem, at most 32rem or the available height tall, and does not scroll itself: the field (data-testid="project-menu-search", placeholder projects.menuSearch) sits in a fixed head with no title line above it (the owner removed it on 15 Oct 2026) with the menu's padding around them and a hairline below, and only the rows under it scroll, so no scrollbar runs beside the field. The search matches the project name case- and diacritics-insensitively through normalizeText, restores the whole list when the field is cleared, and starts empty on every open because its state lives inside the content Radix unmounts on close. Printable keys stop at the field, because the menu content's own typeahead runs on the CONTENT and would move the caret into the first row the key matches, ending the typing after one character; ArrowDown and ArrowUp hand focus to the first or last filtered row instead, so the menu's keyboard navigation and Enter still reach and pick them. This field is the app's one control with no focus state at all: .chat-project-search:focus-visible in app/styles/components/chat.css holds its box-shadow at none, exactly as the command search and the composer's textarea do, and its border keeps one colour, so clicking into it paints nothing. In the new-conversation menu, choosing an item calls startNewConversation({ target }), then openBrainComposer(); a failure is a toast with the daemon's reason, and Escape closes the menu with nothing created and focus back on the control. An optional onChosen lets the host fold away first (the switcher dismisses itself, the phone overflow popover closes). Radix owns arrow keys, Enter, Escape, focus return and collision-aware placement. The /help filter is RegisterSearch, the workflow node vitals are PropertyGroup and PropertyRow, and the dashboard pulse "Live" mark, the project card runtime mark and the shared category mark are Badge.
Unified plugin controls
Bundled plugins compose their controls from the published kit, with no private copies. A multi-select is a SelectionSummary that opens a ManageSelectionModal with Cancel and Save, except the load-history windows: Sandbox load history shows its resources as an inline row of role="checkbox" buttons with their metric icons, and the last checked one is disabled, so the chart always has a series and no query is sent with an empty set. Terminal's process output modal reports an unreachable daemon through ErrorState with onRetry (the retry label is the host's), its kill control is a ghost-danger icon Button that only narrows the size to the rail's 28px line, and its compact rail tile is a ghost Button. Sandbox renders host provisioning progress with Progress (indeterminate when percent is null, bg-destructive indicator when failed), shows an empty desktop listing with EmptyState, opens a desktop's Project through the runtime navigate on a plain click and leaves modified clicks to the browser, and confirms a snapshot with the accent ConfirmDialog variant because it creates rather than destroys. Changelog keeps each release as a disclosure article but reports a failed release body with ErrorState and its loading with LoadingLine. Subagent's compact rail tile is a ghost Button as well: it opens the Agents window, or the DAG view when only workflows run.
Settings catalogs
Skills, agent definitions and MCP servers use non-persisted, collapsible SettingsGroup sections with the existing scope icons around CardGrid layout="list". Catalog callers remove the group's outer card border and horizontal content inset through its existing className, so the heading sits above full-width list cards without nesting a padded card around them. MarkdownAssetEditor reuses the caller's ownership.scopes labels and predicates: personal skills start open; instance, bundled and plugin skills start closed and retain their distinct ownership. Entries outside declared scope filters retain their source groups. Without ownership scopes, custom definitions start open and built-ins closed; custom agent definitions are instance-wide, not personal. MCP keeps personal and instance servers separate, with personal open. Empty groups are omitted. Groups partition the current page, while the existing shared search, filters, 25-item pagination, row actions and bulk selection remain authoritative. Search or an explicit scope/source/status filter reveals matching groups temporarily; clearing it restores their local expansion state. No new requests, storage or permission decisions are introduced. Without either catalog consumer mounted, this composition does no work.
MarkdownAssetEditor.labels contains only text the editor displays. Bundled Subagents supplies empty-state, source, form, action and deletion labels; the drawer saves inline and the refreshed cards confirm successful mutations. The unread edit, created, updated and deleted fields are removed. The host performs no translation lookup or additional request for them.
Settings → Plugins and Settings → Available plugins are two sections of the Settings deck (plugins and plugins-available in web/modules/settings/categories.ts, drawn by the deck's own DeckNavigation as a secondary column on desktop and a horizontal strip on a phone), and PluginsSection takes the view that selects between them: there is no Segmented switch inside the section, no settings card wraps the list, and each view keeps its own search and category filter. Both views are list cards (CardGrid layout="list", owner's rule above), not tables, and unpaged, since the catalog is short. An installed plugin's card leads with its enable switch and the plugin icon (with the running dot), carries the name, version, dependency pills and the update note on the first line with its capabilities pushed to the right, the description of at most two lines and any degraded or update reason beneath, and the status badges, Repair and the action menu on the right; the name is the open control for the plugin detail and the card surface opens it for the pointer, and right-click opens the same management menu. A registry entry or removed built-in is the same card without a switch, closed by its Install or Restore button. Batch update lives only in the installed view's filter actions, removed built-in plugins keep a list of their own above the registry, and consent, install, update and repair errors flow through the unchanged usePluginConsent toasts. MarkdownAssetEditor (skills and agent definitions, ElowenUiRuntime.components.MarkdownAssetEditor) is the same kind of card list, paged with Pager (25 per page): editable entries lead with a CardSelectCheckbox feeding the bulk SelectionActionBar (picks survive paging and are pruned to the filtered set), then the caller's renderRowControl, the scope glyph tile, the name with its badges and the caller's ownership and column facts on the right of the first line, the description beneath and the delete action on the right; editable and inspectable entries open through the name, built-in entries stay quiet. RolePoliciesEditor states an empty list with the shared EmptyState; ApiTokensSection and ElowenCliSection list their tokens and computers as unframed collapsible SettingsGroup items inside one card, the fold Skills, MCP and Agents use (see docs/modules/web/accounts.md).