Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 20. októbra 2026

Prechádzať dokumentáciu · Shared UI and design system

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Developer reference

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:

  • VncTicketResult ticket values require expiresAt: number in epoch milliseconds, plus url and optional geometry/credentials.
  • VncSurfaceProps.ticket(signal: AbortSignal) and DesktopControlClient.ticket(signal: AbortSignal) require the attempt signal.
  • The viewer_limit refusal alone accepts retryAfterMs?: number | null; an available hint is a not-before minimum, not retry permission. unavailable remains a distinct refusal without a hint.
  • DesktopStreamState exposes connectionState: ConnectionState | null and retryConnection(): void. Its existing required connected: boolean remains 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 (TerminalSurfaceProps in the UI kit): sessionKey, ticketPath, socketPath and the optional refusalMessage(code), onState(state) and className. 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 translates TERMINAL_REFUSED, TERMINAL_UNSUPPORTED, TERMINAL_LIMIT, TERMINAL_UNKNOWN_SESSION, TERMINAL_RUNNING, TERMINAL_UNAVAILABLE and TERMINAL_FAILED in all three locales. An optional refusalMessage overrides that wording. onState receives the state union TerminalSurfaceState: connecting, open, reconnecting, openedElsewhere, ended { code, signal } or refused { 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 canonical t.connectionStatus.connecting label 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 ones Account → Terminal edits and TerminalPreview renders, 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/pty publishes the declaration-only PtyClientFrame and PtyServerFrame, so the terminal's server side and the browser surface share them without either reaching into a core source file; web/components/terminal/ptyProtocol.ts validates control frames with Zod and holds the bounds. Malformed control ends the connection with a visible failure. ptySocket.ts is the socket state machine (ticket per attempt, resume from the offset the server states, canonical finite retry episode, 4409 meaning another tab took the session over). A reconnect asks for the exact bytes it missed; when the server's ring cannot continue from there, onResync clears 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-links opens http/https only, with noopener; @xterm/addon-image is capped at 1 Mi-pixel frames and 32 MiB of cache; @xterm/addon-clipboard answers 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 a h-[min(88dvh,50rem)] w-[92vw] max-w-[var(--content-max)] frame on desktop; on phones use presentation="fullscreen", whose bare surface fills the viewport. The window has a 1px border-border frame and rounded-lg corners, 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. Two Button variant="ghost" size="icon" controls float fully inside the top-right corner at every width, inset by top-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 subtle white/10 wash marks hover-capable pointers; the shared button retains the visible keyboard focus ring and disabled states. Keep Modal.title as the accessible window name. The terminal omits the optional operation caption, 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 call onClose without 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 declares web.css so 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 using xtermTheme; 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: PageOverlay over SectionDeck and DeckNavigation. The content of a section is SettingsPage (eyebrow, title, description, status, action): a WorkspaceShell variant="deck" whose WorkspaceHero draws the section title as the only h1 with its description and hairline, over a SettingsDocument (a column, 1rem gap). A plugin panel is never wrapped by the plugin: it returns SettingsGroups (or a fragment of them) and the host puts them into the section's SettingsPage.
  • Group: SettingsGroup is a card with an h2 title (text-section-title), an optional description (CardDescription), group actions on the right (a Save button, a Badge, an autosave status) and a collapsible fold remembered under storageKey for a long, secondary group (limits, networking, Git author). One concern, one group; a group never nests another.
  • Row: SettingsRow is one fact or one setting. The label is text-card-title, the description under it is text-meta muted and says what the value means, not how to operate the control; a long explanation goes behind hint (a HelpTip). 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; status and actions are 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: SettingsValue in the row's control (mono for 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 SettingsList of SettingsItem (an outlined small item: optional media, title, one-line description, a muted end figure and actions), inside a group; it is never a table or a stack of cards.
  • States: loading, empty and failure are LoadingState, SettingsState and ErrorState inside the group that would have held the content, so a panel never draws a bare paragraph.
  • Colour means state only: a StatusDot with its word or a Badge, 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, PropertyGroup and PropertyRow in DetailPrimitives.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 sectioned they draw compact sections instead of cards (see "Sectioned drawer" under "Drawer anatomy"). Everything above describes the default page look.

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:

  • RegisterSearch owns the leading search icon, accessible label, touch height and native clear-button suppression. Pass value, onChange and a translated placeholder; label overrides 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 explicit onClear and clearLabel button clears.

  • Avatar accepts an optional online prop 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 a CardShell from ChartCard) 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 shows emptyLabel when 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).

  • DetailBlock for a titled section of a narrow detail drawer (windows use SettingsGroup, see "Window pattern (Settings)"): a 14px semibold sentence-case h3 (no icon, no uppercase caption), an optional HelpTip, an optional right-aligned aside (muted 13px text, a link or a small button) and the content below it with a 10px gap. It no longer takes an icon and 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 as elowen.detailBlock.<id>) was withdrawn with the Users drawer, its last caller. The optional status node is drawn in the heading row right after the title.

  • HelpTip for contextual help. It uses the shadcn tooltip parts, opens on hover, focus, and tap, and keeps tooltip semantics and accessible descriptions. Optional icon supplies 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 through pluginLucideIcon; an absent icon keeps the existing help mark. Optional trigger supplies 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. Badge accepts optional outline to remove its fill while keeping the tone's border and text tokens; dependency pills combine this with muted or warning tone.

  • ManageSelectionModal and SelectionSummary for the shared multi-select (and read-only list) management flow; a single choice is the RowPicker dropdown, and the modal has no single mode. Internal persistence and the caller's saving prop 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 carry filters, and callers may pass kindFilter ({ options, label }) to render one compact single-choice dropdown, the Select surface through SelectMenu, 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 are h-10 with a checkbox and the group headings are sentence-case 13px muted text with the group icon. Every selection of several models is one ModelSelection (web/components/ui/ModelSelection.tsx): a SelectionSummary card plus this modal, see "One model chooser" in docs/modules/web/models.md. Model selections pass marks ({ kind, marks }, where the marks are Marks from MarkStrip) instead of samples, so ModelSelection draws one brand mark per picked model, each named on hover and focus, while non-model selections keep the chip row. SelectionSummary is full width with an outline Button for Manage (disabled keeps 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 a SelectionSummary card opening this modal, as does every other non-model multi-selection through ManagedSelectionField (see "Unified managed selections"). The Users detail ToolPills is a consumer: it reads effective tool state from /users/:id/tools, takes the authoritative raw allowed_tools from 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=all view 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, unchecking MemorySearch removes only that name and disables automatic recall on later turns.

  • DataTable for responsive register tables, current-page selection (DataTableSelection with { ids, selected, onSelectionChange, selectAllLabel? }, together with DataTableSelectCell and a 2rem leading 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 semantic ConversationListPanel and its existing per-id deletion controller. DataTableSelection also accepts longPressToSelect: 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 cell disabled: 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 by SelectionActionBarProps in 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 presentational Checkbox, 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 by CardSelectCheckboxProps in the kit).

  • CardGrid and CardGridItem (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 default layout="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). CardGridItem is the card surface: selected paints the open entry, and passing onClick makes 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. Without onClick the card is a quiet, non-interactive surface. Props other than the card's own, ref and style included, 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.tsx wraps 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 in SidebarNav is a separate, desktop-only mechanism and has not been merged with it), the Markdown asset register (MarkdownAssetEditor, list, which draws /p/skills and the agent types on /p/subagent) and Settings → Plugins and Available plugins (PluginsSection, list). API 24 publishes both to plugin bundles as ElowenUiRuntime.components.CardGrid and CardGridItem (typed by CardGridProps/CardGridItemProps in 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 bulk CardSelectCheckbox where 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 a DataTable to "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.5 overlap, ring-2 ring-card cut-out) lives once in web/components/ui/avatarStack.ts and is shared with the team avatars on ProjectCard. Each mark is a solid bg-muted disc with a border-border-strong edge and text-foreground glyph, so it stays visible on every skin including Studio OLED; size glyphs at 11 to 12 px. Capped at six with a +N chip. Pass marks ({ node, label? }), the count in words as label (always the screen-reader text) and a kind that lands on the strip as data-mark-strip. When no mark has a label the whole strip shares one hover tooltip showing the count and is not focusable (the Users card is already one control: UserGrantStrip pictures a restricted account's models once per vendor and its tools once per kind). When every mark has a label each mark is a focusable image with its own tooltip on hover, tap and keyboard focus (each provider record in Settings → Elowen AI pictures one ModelIcon per stored model, named by the model id, with the count as the screen-reader label). The model summary in SelectionSummary is another consumer of the same strip, reached through ModelSelection and its marks. The tooltip is rendered in place by the shared Tooltip, so a strip must not sit inside a <p>.

  • The telemetry rail grid (web/components/ui/RailPrimitives.tsx): RailSection, RailList, RailRow, RailMeter, RailDot and the section heading RailSectionHead. 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 (a RailDot given as a row's lead is 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 at text-meta, the head at text-meta in 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 passes expanded, which becomes aria-expanded. RailRow is static, a button (onClick; the label is the real button and its ::after covers the row, so an action such as a kill button on the value edge keeps its own click), or the trigger of a click-only ActionMenu (actions). Beside a detail the name keeps its width up to 70% of the text column. A row with a note takes 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. RailMeter puts a fixed 5.5rem label column before the bar so every bar in the rail starts at the same x (flush, inside a RailList 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); a flush list 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 from usageWindowMeter in web/lib/usageMeter.ts, over the windows drawnUsageWindows keeps: 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 in web/lib/usageMeter.ts, progress keeps the primary fill. RailSectionHead takes count for the shared pill and free meta content. Plugin bundles get RailSection, RailList, RailRow and RailDot (API 27); RailMeter and RailSectionHead are host-only since API 45, because no bundle used them; web/tests/e2e/specs/chat.telemetry-rail.e2e.ts measures 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): TaskList and TaskRow, the one task list on the web, adapted from the Vercel AI Elements Queue and Task pattern and composed from the rail grid and ActionMenu rather than copied in (no new dependency). TaskRow is one task as an li: the status glyph (empty circle waiting, spinner running, ticked circle done), the title that truncates with its full text as title, 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 with blockedBy the tasks it waits on ("waiting on #3"). With onStatus the row is the trigger of a click-only menu of the three statuses, plus the way into the full list when onOpen is 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. TaskList is a Tasks section body inside RailSection: running then waiting tasks in the list's own order, TODO_PREVIEW_ITEMS of 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 (TaskRow inline), and the host's fallback todo card in BrainChatSurface. API 28 publishes both to bundles (kit TaskListProps, TaskRowProps, TaskListTask); chat.telemetry-rail.e2e.ts measures 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-text in app/styles/animations.css). It is a CSS adaptation of Vercel AI Elements' Shimmer (no motion element, which the app's strict LazyMotion refuses, and ink tokens instead of the background colour); quiet effect modes and prefers-reduced-motion render 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.

  • MotionCollapse and MotionFade (web/components/ui/Motion.tsx), the fold and label motion next to MotionReveal, MotionPresence and MotionLayoutItem. MotionCollapse takes open and its children and folds the region under them: height and opacity over the shared motionTransition, 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 is inert from the moment it starts to close. A region that is open or closed on its first render does not animate. MotionFade is the opacity-only enter and exit of a label that swaps in place inside a MotionPresence, 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 (AssistantRun and WorkSummary in web/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-wordmark class in primitives.css sizes 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 its h-full wrapper) 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 one usePagePath (web/lib/usePagePath.ts) — the mounted page, not the address — so Settings opened over /chat adds 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 manifest web.wordmark: false (read through pluginListingForPath in web/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.

  • Pager for range text, bounded page changes, and optional page-size selection. Use paginateItems from web/lib/paginateItems.ts for the matching visible-page calculation; plugin bundles get the same function as ElowenUiRuntime.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 from elowen-plugin-ui-kit: web/tsconfig.json maps the package to its index.d.ts, so a value import builds cleanly and reaches the browser as undefined (web/tests/lib/pluginUiKitTypeOnly.test.ts enforces this).

  • ModelPicker and ModelOptionList for the shared model catalog in full, compact, and status-line presentations. The menu, which /model opens through the controller's modelOpen (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 shared ReasoningScale below it. The name switches the model and closes its host as before; the scale keeps the host open, reads the active level from useBrainSessionStatus, 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. useReasoningLevel is the one live write path for this catalog and the bar's reasoning popover (ReasoningButton owns the write and refuses to close while it is saving; ReasoningPanel is its body, opened by the button or /reasoning): call apply(level, current) for an active session or pass a switch function returning Promise<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.setModel supplies that awaited switch result, and ReasoningScale accepts an optional disabled prop 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.

  • AlertsBell and the top bar alert surface for the authenticated /alerts resource. It uses the shared primitives, renders loading, error, empty, and bounded open-row states, and refetches after the owner-scoped alert SSE 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.

  • SelectMenu for the shared select behind a typed options array ({ 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.

  • ProjectIcon is 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 includes Laptop. Stored relative paths select the editor's project root, while guest-absolute paths select system for 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 durable projects.icon value. Existing relative icons need no migration. Returning an adopted project to host execution clears system icons and preserves relative icons.

  • DirectoryPicker for selecting an authorized server-side folder. It renders LoadingState until the current path has data, EmptyState only for a successful empty listing, and ErrorState with 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.

  • DataTable supports optional selection with { ids, selected, onSelectionChange, selectAllLabel?, longPressToSelect? }, together with the host's DataTableSelectCell (not published to bundles); the conversation history register enables it (with longPressToSelect for touch).
  • PageToolbar takes filterActions beside filters: 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"; without filterActions nothing changes, unless the page states filtersNamed: '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 the ConfirmDialog outside 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.
  • DeckNavigation records take badge (a count): the primary navigation's own badge (SidebarMenuBadge with .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' to PageToolbar and named: 'actions' to PageFilters, using common.actions for the trigger and sheet title. ControlSurfaceToolbar forwards filterActions and filtersNamed into 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.
  • ControlSurfaceToolbar supports structured search, filters, and actions contributions plus a children form. Its public promote prop defaults to true and sends the children toolbar through PageToolbarPortal; 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 import PageToolbarScope from the same owner so hidden retained sections cannot claim the slot.
  • ResizeHandle drives the advisor dock on all four sides. A translated label and the current value, min, and max make the divider focusable; axis arrow keys move it by step pixels, and double-click resets the active dimension to its command dock default. useDockState owns bounds, reset, and persistence: resize updates call setWidth(width, false) or setHeight(height, false) without persisting, and onEnd commits the final size once. Without a label the divider remains pointer-only. No network calls are made.
  • DateRangeFilter accepts compact and presets; 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 Lucide icon is drawn 18px in foreground, iconNode replaces 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, a div that aria-describedby points 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.ts measures it at 320 px. headerActions (an outline small "Edit" button, an ActionMenu in its kebab variant) sit in data-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>}. DetailMeta draws a 6px StatusDot (green success, amber warning, grey muted) with its word, then "·", then the children; the separator appears only when both exist. Colour is for state only.
  • Tabs: tabs takes a Segmented variant="line" nowrap without 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 own border-b off so only one line exists. The slot also states the strip as drawer tabs, once for every drawer ("Overlay header tabs" in web/app/styles/components/primitives.css): text-section-title labels (16/20/600) with 28px between tabs, a taller hit area (4px above, 12px above the underline), a 3px --color-primary underline under the open tab and a --color-border-strong underline previewed on hover. Label ink stays the line variant's (muted, foreground when open or hovered) and option counts stay muted text-meta. The strip spans the header (the nowrap frame 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.ts measures it. Other line strips (register tabs, plugin pickers) keep the compact text-ui look; web/tests/styles/overlayHeaderTabs.test.ts pins the rule.
  • Body: ModalBody is p-4 sm:p-6; gap={7} (28px) is the section rhythm, WorkspaceDetailRail uses it. Sections are DetailBlocks in a single-record drawer and Settings sections in a sectioned one (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. WorkspaceDetailRail takes it as footer. A read-only drawer has none.
  • WorkspaceDetailRail forwards icon, iconNode, actions, tabs and footer (all optional) to Modal, and description is 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):

  • SettingsGroup is a section, not a card (data-slot="settings-section", plus data-settings-group as everywhere): a header (data-slot="settings-section-header") with an h3 title in the section-title role (16/20/600, as a Settings card), the help mark, the description as a text-meta muted line under it and the actions right-aligned in text-meta; then the body as ONE box (data-slot="settings-section-body", rounded-xl hairline border like a Settings card, no fill or shadow). Each direct child of the box is padded px-4 py-2.5 inside a py-2 box 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) and rowId behave as on the card; columns and density change nothing, because a drawer is one column. A group without title, description and actions is just the box.
  • SettingsRow is 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, 10rem once 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 the description as 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 lone Button (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 keeps data-slot="field", data-trailing, data-row-id and the field-* slots (the band is display: contents), so touch floors, row anchors and tests still find them; it carries data-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.
  • SettingsValue is left-aligned (not right-aligned) after the label; mono stays 12px mono and may break anywhere, other values wrap; an empty value stays the muted em dash.
  • SettingsList drops its gap and SettingsItem becomes 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, end muted and tabular, actions on the right.
  • SettingsState is a quiet one-line row (13px muted, left-aligned, destructive for tone="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.

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-link on 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's Verify on GitHub / Open on GitHub anchors.
  • inline-links on a container whose anchors are generated markup, which gives every a inside it the look: rendered chat Markdown (ChatMarkdown), the editor plugin's Markdown preview, the changelog plugin's entries, and the shadcn FieldDescription and ItemDescription.

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).