NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Shared UI and design system
Developer reference

Shared UI and design system

Bounded shared UI

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.

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. An inline picker/input control ([role=combobox], [data-row-picker], value input, select) gives actions their own full-width line below the control, right-aligned in a wide card. Without actions there is no extra line. Switch and button-only records keep their existing inline layout; trailingLayout="stack" still stacks every trailing part. 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. 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 storageKey uses the host's existing browser-local fold store, with no new requests. 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 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; operational ChoiceField auto presentation stays unchanged. 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. Only its private CatalogMultiSelectField adds stored unavailable multi-select values, 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 closed using defaultOpen={false} and unchanged 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. Teams uses People for role policies. 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. 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.

Users detail mounts SpendLimit with the account record (user: User, SpendLimit.tsx:39), keyed by its id, for administrators and members alike. One useAutoSaveStatus controller patches only monthly_spend_limit_microusd: off writes null, on starts at 25 USD, and ChoiceField offers 10/25/50/100/250 USD plus Custom. The shared number input accepts 0.01–100,000 USD in whole cents and converts integer cents to micro-USD; invalid drafts cancel pending writes, including teardown and retry. Selecting Custom alone does not write. 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:96-97). 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:99-105) that calls useResetUserUsage (web/lib/mutations.ts:10-22). 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:103-104; strings in web/lib/i18n/dictionaries/en.ts:171-172). 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 web consumers that confirm a copied value use useCopyFeedback(value) from web/lib/useCopyFeedback.ts. It returns { copied, copy }: attach copy to the existing copy button and use copied to select the consumer's confirmation icon and label. The hook calls the existing copyText boundary only on the click, confirms a successful write for 1500 ms, and reports a false result through toast(t.common.copyFailed, 'error'). Each completed write cancels the previous confirmation timer; a refusal clears any previous success. Unmount cancels the timer and ignores clipboard completion that arrives after teardown. Before any click there is no feedback or clipboard work. The hook owns at most one confirmation timer per mounted consumer, adds no server request, retry or fallback, and is not exposed through the plugin UI runtime. CopyableId uses it for both the locked licence page and Settings licence rows; plugin readiness FixValue uses the same policy while retaining its distinct layout and labels.

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 LicenceRows. 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 section views. 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. 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 retains the existing bounded horizontal scrolling and selected-item reveal, with linear measurement in the option list and 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. 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 both independent item and group filters, retaining group icons and pinned ungrouped rows; the group control is absent when fewer than two groups exist.

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.

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 log viewer keeps the shared Input because its search must be disabled before a file is selected.

  • 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 labeled block of a detail pane (icon, caption, optional HelpTip, content). Passing collapseId makes it a collapsible block built on the shadcn Collapsible: the caption becomes a button with a chevron and aria-expanded, the content starts closed by default and is not mounted while closed, and the open state is remembered in localStorage as elowen.detailBlock.<collapseId> through usePersistentState. Use a stable id, never a translated title; it is per block, not per record shown. Optional defaultOpen changes only the initial fallback; a stored open/closed choice always wins under the unchanged key. Without collapseId the block is always open. All Users core and plugin blocks pass defaultOpen, while other surfaces retain the closed default. The Users detail passes users.<section> for every block, and PluginUserPanels passes users.plugin.<plugin>.<panelId> for plugin user panels. The optional status node is drawn in the header right after the title, outside the disclosure button and while the block is closed; PluginUserPanels fills it from the plugin's userStatus registration (UI API 46, see docs/PLUGIN_DEV.md), and it must stay cheap because it is mounted while the content is not. DetailBlock is also published to plugins; the new prop is optional.

  • 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 use it with Monitor; 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 filterOptions and filterLabel to render a shared single-choice filter below the search, led by the generic "All". Options come from the caller's actual data; without options the filter renders nothing. The provider model dialog uses this for reported output kinds, leaving models with unknown kinds visible under All only. 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 conversation history register (ConversationHistoryPanel) is the first production consumer of selection; its bulk delete reuses the controller's per-id delete so the single-delete ownership check stays the only authority. 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 (the conversation rename editor) 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 history register is the first consumer.

  • 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 card registers (skills, agent types, MCP servers) 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 (user-owned asset entries, manageable MCP servers). 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) and the Markdown asset register (MarkdownAssetEditor, list, which draws /p/skills and the agent types on /p/subagent). 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. Keep tables for long or comparative registers such as memory and conversation history. The Users directory also uses list cards: 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.

  • MetricTiles and MetricTile (web/components/ui/MetricTile.tsx) for the row of reading tiles that opens a drawer or a card: a soft icon mark (the shadcn ItemMedia tile), the figure or state word, the label that names it and an optional line of detail, composed from the shadcn Item parts. MetricTiles is the measured container, like CardGrid: one column in a surface narrower than 28rem, three above it, so the tiles answer to the room they were given and not to the viewport. Stacked, a tile reads as a compact row (mark beside the text); in three columns the mark moves above the text so a long state word keeps the whole tile width. A value wraps instead of truncating. A tile is a reading, never a control. Not published to plugin bundles: the hero's WorkspaceMetric stays the figure rail of a page, a tile is for the body of a surface. Consumer: the problem-reporting inspection drawer (ProblemReportingRows), which stacks the tiles over a header Item, CardGrid layout="list" rows drawn like the MCP server rows and the exact JSON behind a shadcn Collapsible. The dashboard's local StatCard (PulseStats) is a sparkline variant of the same idea and has not been moved onto it.

  • 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 (the provider and account rows in Settings → Elowen AI picture one ModelIcon per configured model, named by the stored model id). 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. 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); 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), 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, limit of them (the card preview size, 4) 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 row in the Agents table, a running node in the workflow inspector, live dashboard activity), which still use plain text.

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

Browser API 33 retires LiveTail, its polling hook, and the absent /sessions/:name/pane preview endpoint. No current core or registry renderer consumes this 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. 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 dock profile default. useDockState owns bounds, reset, and persistence: resize updates pass persist: false, 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. 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 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.

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 and studio-oled, both using the command shell profile), and the structural selectors are scoped to [data-skin-family='studio'] rather than to a skin id, so a customer skin derived from either built-in inherits the same structure.

A customer skin is instance data under <dataDir>/skins/<custom-id>/: a skin.json manifest (id, display name, the built-in it derives from, optional colour scheme) and a tokens.css holding nothing but overrides of the public design tokens in web/app/styles/tokens.css. src/store/skinStore.ts validates both against a generated token contract (scripts/generate-skin-contracts.mjs), renders the stylesheet it serves, and addresses it by content digest. A skin may not carry selectors, url(), at-rules or code, and it cannot change a name, logo, icon or prompt.

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.

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. System diagnostics 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 percentage, 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 %. Default IO rings show the unit and percentage beneath the speed on one line. 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 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.

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; the other consumers 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 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.

Sandbox's ProjectUsageHistoryModal 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

lib/resourceHistoryMetrics.mjs owns the current resource-history vocabulary: cpu, memory, disk, io and network expand through resourceHistorySeriesIds(metrics) into the seven wire series. RESOURCE_SERIES states each series' physical source, value field, limit field and unit; CPU receives its limit from the caller. The server sampler and history response, ResourceHistoryMetric/ResourceHistorySeriesId types, browser response validation and chart selection derive from this vocabulary. Shipped migration CHECK literals remain frozen, and UI labels, icons and colours remain local. 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. ProjectUsageHistoryModal 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 ProjectUsageHistoryModal; 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 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.

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.