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:
-
RegisterSearchowns the leading search icon, accessible label, touch height and native clear-button suppression. Passvalue,onChangeand a translatedplaceholder;labeloverrides its accessible name. ManageSelectionModal, DeckNavigation and PluginToolsPanel reuse it without adding clear or count controls. It has no filtering or network behavior. The log viewer keeps the shared Input because its search must be disabled before a file is selected. -
Avataraccepts an optionalonlineprop for a decorative success-coloured dot, defaulting to no dot. Users cards and detail supply the flag from the admin directory and always render visible status text alongside it; callers without presence data omit the prop. -
PulseRing(web/components/ui/PulseRing.tsx) for a donut over caller-supplied slices (RingSlice:key,label,value,colour,datum) with a headline figure in the hole, a pointer-anchored hover card the caller renders (renderCard, usually aCardShellfromChartCard) and a keyboard-reachable<details>legend that carries the same card (legend={false}for a caller that lists the slices itself in a keyboard-reachable list,showTitle={false}to keep the heading for assistive technology only when the caller prints its own). It knows nothing about people, tokens or surfaces. It draws zero-value slices nowhere and showsemptyLabelwhen nothing is left, and a single slice is a solid circle, so a caller whose data can be one owner (a per-user cut of one provider) states that case in words instead. Fixed 220 px tall, as wide as its column. Consumers: the dashboard pulse tile (PulseRings, four rings) and the provider statistics drawer (users and models). -
DetailBlockfor a labeled block of a detail pane (icon, caption, optionalHelpTip, content). PassingcollapseIdmakes it a collapsible block built on the shadcnCollapsible: the caption becomes a button with a chevron andaria-expanded, the content starts closed by default and is not mounted while closed, and the open state is remembered inlocalStorageaselowen.detailBlock.<collapseId>throughusePersistentState. Use a stable id, never a translated title; it is per block, not per record shown. OptionaldefaultOpenchanges only the initial fallback; a stored open/closed choice always wins under the unchanged key. WithoutcollapseIdthe block is always open. All Users core and plugin blocks passdefaultOpen, while other surfaces retain the closed default. The Users detail passesusers.<section>for every block, andPluginUserPanelspassesusers.plugin.<plugin>.<panelId>for plugin user panels. The optionalstatusnode is drawn in the header right after the title, outside the disclosure button and while the block is closed;PluginUserPanelsfills it from the plugin'suserStatusregistration (UI API 46, seedocs/PLUGIN_DEV.md), and it must stay cheap because it is mounted while the content is not.DetailBlockis also published to plugins; the new prop is optional. -
HelpTipfor contextual help. It uses the shadcn tooltip parts, opens on hover, focus, and tap, and keeps tooltip semantics and accessible descriptions. Optionaliconsupplies a lucide glyph instead of the default question mark, without changing hit areas, timing or keyboard behavior. Project name indicators use it with Monitor; absent icon keeps the existing help mark. Optionaltriggersupplies non-interactive content inside its existing button, such as Settings/Plugins dependency badges. It retains the shared hover/focus/tap behavior and accessible descriptions, with a focus ring and touch target for the content trigger; never supply a nested interactive element. Without it the icon-sized affordance is unchanged.Badgeaccepts optionaloutlineto remove its fill while keeping the tone's border and text tokens; dependency pills combine this with muted or warning tone. -
ManageSelectionModalandSelectionSummaryfor the shared multi-select (and read-only list) management flow; a single choice is theRowPickerdropdown, and the modal has no single mode. Internal persistence and the caller'ssavingprop disable every editable item, including pinned rows, without disabling search or filtering. A rejected save preserves the selected set and restores editing for retry; caller-disabled items remain disabled. An item may carryfilters, and callers may passfilterOptionsandfilterLabelto 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 detailToolPillsis a consumer: it reads effective tool state from/users/:id/tools, takes the authoritative rawallowed_toolsfrom the account row, and patches only changed toggles into that grant while preserving offline tool names. Native memory/image tools are toggleable like plugins for every account, including administrators. Administrators can edit their own tool, plugin and model grants in the same Users detail. The model grant editor reads the full configured catalog through the administrator-only?catalog=allview of/brain/models; the ordinary picker remains limited to the account's grants. An unavailable plugin tool remains disabled until its plugin is granted. A tool covered only by an MCP family grant is read-only in the modal: unchecking its exact name could not revoke the broader grant, so an administrator must edit that family explicitly through the account API. Infrastructure envelopes are not displayed. With no tools it shows the empty state, and a failed save leaves the editor open with an error toast. For example, uncheckingMemorySearchremoves only that name and disables automatic recall on later turns. -
DataTablefor responsive register tables, current-page selection (DataTableSelectionwith{ ids, selected, onSelectionChange, selectAllLabel? }, together withDataTableSelectCelland a2remleading track in every column template), open-row columns, and compact layouts. The 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.DataTableSelectionalso acceptslongPressToSelect: on a coarse pointer ((pointer: coarse), never the viewport width) the checkbox column and its grid track stay hidden until a long press (500ms, touch only, cancelled by moving or scrolling) on a selectable row enters selection mode with that row picked. In the mode, a recent touch tap on a row toggles it instead of opening it — a mouse keeps opening and a keyboard or screen-reader click opens the row it always opened — while explicit controls (action menu, links, fields) keep working; clearing the selection leaves the mode and hides the column again, and only the long-pressed row's own release click and context menu are suppressed. A row temporarily not selectable (the conversation rename editor) renders its celldisabled: a dimmed checkbox where the track is shown, a trackless marker cell where it is hidden, and no gesture ever picks it up. A fine pointer never notices the option. The keyboard way in on any device is the header's select-all, which stays focusable while hidden — picking anything reveals the whole column with its tabbable row checkboxes. Without the option, or on a fine pointer, the column behaves exactly as the plain selection above. The conversation 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 bySelectionActionBarPropsin the kit). -
CardSelectCheckbox(web/components/ui/CardSelectCheckbox.tsx) for the select checkbox one card leads with when its register supports bulk actions: a button owning the toggle and the accessible name around the presentationalCheckbox, with activation stopped so ticking never opens the entry. Only entries the reader may act on get one (user-owned asset entries, manageable MCP servers). API 25 publishes it to plugin bundles (typed byCardSelectCheckboxPropsin the kit). -
CardGridandCardGridItem(web/components/ui/CardGrid.tsx) for a register drawn as cards, when an entry is several kinds of fact (a sentence of description, badges, an owner) rather than short aligned values. The defaultlayout="grid"is one column in a narrow surface, two from 38rem and three from 58rem of its own wrapper's width, never four;layout="list"is one full-width card per row, for entries read as a line (a name and the sentence saying what it is for).CardGridItemis the card surface:selectedpaints the open entry, and passingonClickmakes the surface open the entry for the pointer, while the caller still renders one real named control inside for the keyboard and stops clicks on its own controls from reaching the surface. WithoutonClickthe card is a quiet, non-interactive surface. Props other than the card's own,refandstyleincluded, land on the card surface and not on the grid cell, so a caller that moves a card moves exactly the painted box. Consumers: the Project register (ProjectCard, grid, sortable:web/modules/projects/SortableProjects.tsxwraps it in@dnd-kit, the one drag-and-drop library of the web app; a 400 ms press-and-hold with a mouse or finger picks a card up, a touch that moves before the hold is over stays a scroll, the grid cells never resize while a card travels, and each card's move handle plus space/Enter and the arrow keys is the keyboard path, announced through dnd-kit's live region in the account's language; the saved order is the account's own and its first project is its default, marked with a badge. The sidebar's own pointer drag inSidebarNavis a separate, desktop-only mechanism and has not been merged with it) and the Markdown asset register (MarkdownAssetEditor, list, which draws/p/skillsand the agent types on/p/subagent). API 24 publishes both to plugin bundles asElowenUiRuntime.components.CardGridandCardGridItem(typed byCardGridProps/CardGridItemPropsin the kit), so a plugin's short register — the MCP server register is the first — draws the same cards rather than a table approximating them. 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. -
MetricTilesandMetricTile(web/components/ui/MetricTile.tsx) for the row of reading tiles that opens a drawer or a card: a soft icon mark (the shadcnItemMediatile), the figure or state word, the label that names it and an optional line of detail, composed from the shadcnItemparts.MetricTilesis the measured container, likeCardGrid: 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'sWorkspaceMetricstays 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 headerItem,CardGrid layout="list"rows drawn like the MCP server rows and the exact JSON behind a shadcnCollapsible. The dashboard's localStatCard(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.5overlap,ring-2 ring-cardcut-out) lives once inweb/components/ui/avatarStack.tsand is shared with the team avatars onProjectCard. Each mark is a solidbg-muteddisc with aborder-border-strongedge andtext-foregroundglyph, so it stays visible on every skin including Studio OLED; size glyphs at 11 to 12 px. Capped at six with a+Nchip. Passmarks({ node, label? }), the count in words aslabel(always the screen-reader text) and akindthat lands on the strip asdata-mark-strip. When no mark has alabelthe whole strip shares one hover tooltip showing the count and is not focusable (the Users card is already one control:UserGrantStrippictures a restricted account's models once per vendor and its tools once per kind). When every mark has alabeleach mark is a focusable image with its own tooltip on hover, tap and keyboard focus (the provider and account rows in Settings → Elowen AI picture oneModelIconper configured model, named by the stored model id). The tooltip is rendered in place by the sharedTooltip, so a strip must not sit inside a<p>. -
The telemetry rail grid (
web/components/ui/RailPrimitives.tsx):RailSection,RailList,RailRow,RailMeter,RailDotand the section headingRailSectionHead. Every chat-rail section, core (context, goal, limits, the project foot) and plugin (tasks, workflow, agents, processes, MCP, LSP), is drawn on one three-column grid: a 1rem lead for a glyph or the section icon (aRailDotgiven as a row'sleadis drawn in the text column ahead of the label instead, so the first mark of a dot row starts at the same x as the labels of the meter rows), a flexible text column, and a right-aligned tabular value column. Labels therefore start at one x and figures end on one right edge across sections. Rows sit at the body-small step (text-sm), details and figures attext-meta, the head attext-metain Studio. The head, every row kind and the meter share ONE line height, 28px on every pointer; a control row is deliberately not padded to the 44px--touch-target, because rows that tall, including 32px rows at conversation text size, read as separate lines instead of a list. Flush 28px rows clear the WCAG 2.5.8 24px target. Sections sit 16px apart with no gap under their head. A button row that folds the rows below it passesexpanded, which becomesaria-expanded.RailRowis static, a button (onClick; the label is the real button and its::aftercovers the row, so anactionsuch as a kill button on the value edge keeps its own click), or the trigger of a click-onlyActionMenu(actions). Beside adetailthe name keeps its width up to 70% of the text column.RailMeterputs a fixed 5.5rem label column before the bar so every bar in the rail starts at the same x (flush, inside aRailList flush, is the variant outside the rail: no lead column, and label and figure columns sized to their widest entry through a subgrid, so a narrow Settings record leaves the room to the bar); aflushlist is also a container: below 16rem a meter stacks, the label and figure on the first line and the bar at full width on the second, so a name and a money figure never squeeze the bar on a phone;OAuthUsageRail(web/modules/settings/OAuthUsageRail.tsx) is the one markup for usage windows (subscription windows, or an API account's balance, key limit and free-model allowance, whose label, figure and hover come fromusageWindowMeterinweb/lib/usageMeter.ts), rendered by the rail's Limits section and,flush, by a connected account in Settings;tone="pressure"colours the fill by the usage ramp inweb/lib/usageMeter.ts,progresskeeps the primary fill.RailSectionHeadtakescountfor the shared pill and freemetacontent. Plugin bundles getRailSection,RailList,RailRowandRailDot(API 27);RailMeterandRailSectionHeadare host-only since API 45, because no bundle used them;web/tests/e2e/specs/chat.telemetry-rail.e2e.tsmeasures the grid with the real bundles. Without a rail section contributed, nothing renders for it. Terminal's Processes section receives only{ processes }from the chat status context, hydrated by the daemon status/first SSE snapshot and replaced by live process events. The daemon includes the focused conversation and its direct delegated children, never another conversation. The web does not poll an account-wide process endpoint or derive ownership from transcript rows; empty/loading state has no process section. Output and confirmed kill use the existing owner-scoped endpoints, with errors kept visible. -
The checklist (
web/components/ui/TaskList.tsx):TaskListandTaskRow, the one task list on the web, adapted from the Vercel AI ElementsQueueandTaskpattern and composed from the rail grid andActionMenurather than copied in (no new dependency).TaskRowis one task as anli: the status glyph (empty circle waiting, spinner running, ticked circle done), the title that truncates with its full text astitle, a finished task muted and struck through, and one piece of meta in the value column, the running clock or frozen duration, or for a pending task withblockedBythe tasks it waits on ("waiting on #3"). WithonStatusthe row is the trigger of a click-only menu of the three statuses, plus the way into the full list whenonOpenis given; without it the row is a read-only line.density="rail"(default) is a rail grid row;density="inline"keeps the same three columns at the surrounding type, for the monospace transcript card.TaskListis a Tasks section body insideRailSection: running then waiting tasks in the list's own order,limitof 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 (TaskRowinline), and the host's fallback todo card inBrainChatSurface. API 28 publishes both to bundles (kitTaskListProps,TaskRowProps,TaskListTask);chat.telemetry-rail.e2e.tsmeasures the flush rows with the real bundle. -
ShimmerText(web/components/ui/ShimmerText.tsx) for a line that describes work happening right now: a soft band of the foreground ink sweeps across muted ink (.shimmer-textinapp/styles/animations.css). It is a CSS adaptation of Vercel AI Elements'Shimmer(nomotionelement, which the app's strictLazyMotionrefuses, and ink tokens instead of the background colour); quiet effect modes andprefers-reduced-motionrender plain muted text.active={false}stops the sweep without changing the box. Consumer: the running sub-agent's activity in the rail's Agents section. It is meant for the other live lines too (a running 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-wordmarkclass inprimitives.csssizes it from the name's length through a container query, so any name spans the width without horizontal scroll. It is rendered once by the shell's root template (web/app/template.tsx, after the page content inside itsh-fullwrapper) for every page except/chat— whose conversation owns its bottom edge — and for no overlay (PageOverlay,PluginPageOverlay, the login gate and setup screens never pass through that template). The decision follows the shell's oneusePagePath(web/lib/usePagePath.ts) — the mounted page, not the address — so Settings opened over/chatadds no word under the chat and a direct overlay visit shows none. A new page gets it without doing anything and must not render its own. It is decoration:aria-hidden, not selectable, never a hit target. The one opt-out is a plugin's manifestweb.wordmark: false(read throughpluginListingForPathinweb/lib/sameDocumentNavigation.ts, the same lookup the shell uses for the page measure), for a full-height application surface that owns its bottom edge; the editor is the consumer. Without it, and on an older daemon that sends no such key, the word closes the page. -
Pagerfor range text, bounded page changes, and optional page-size selection. UsepaginateItemsfromweb/lib/paginateItems.tsfor the matching visible-page calculation; plugin bundles get the same function asElowenUiRuntime.utils.paginateItems. It only clamps and slices, leaving filter reset and page-size state to the caller. The host memory register, Markdown asset register, and MCP server register are the concrete consumers; conversation-ID paging has separate identity and reset rules. The web app imports only types fromelowen-plugin-ui-kit:web/tsconfig.jsonmaps the package to itsindex.d.ts, so a value import builds cleanly and reaches the browser asundefined(web/tests/lib/pluginUiKitTypeOnly.test.tsenforces this). -
ModelPickerandModelOptionListfor the shared model catalog in full, compact, and status-line presentations. The menu, which/modelopens through the controller'smodelOpen(a phone's bar picker lives in the overflow menu, which opens itself for it), renders the name control and, when the catalog advertises levels, the sharedReasoningScalebelow it. The name switches the model and closes its host as before; the scale keeps the host open, reads the active level fromuseBrainSessionStatus, previews an inactive model using PI's canonical ladder to clamp the confirmed session/account level to its supported levels (no selected stop when those disagree, because PI may restore an earlier transcript marker), and switches first before writing that level. Without advertised levels it renders no scale; while a delegated child is focused it disables the scale with the same reason as/reasoning.useReasoningLevelis the one live write path for this catalog and the bar's reasoning popover (ReasoningButtonowns the write and refuses to close while it is saving;ReasoningPanelis its body, opened by the button or/reasoning): callapply(level, current)for an active session or pass a switch function returningPromise<boolean>for another model. A refused switch does not write reasoning; a successful write refreshes the account default and session status, while failure restores the displayed value and raises a toast.BrainChatProvider.setModelsupplies that awaited switch result, andReasoningScaleaccepts an optionaldisabledprop so a pending or refused control stays visible without pretending it can save. For example, picking Low under an inactive Claude model switches to Claude, then applies Low in the same conversation. -
AlertsBelland the top bar alert surface for the authenticated/alertsresource. It uses the shared primitives, renders loading, error, empty, and bounded open-row states, and refetches after the owner-scopedalertSSE invalidation. Opening the panel marks all currently open unread rows read through/alerts/read-all, including rows beyond the 200-row display cap; dismissal hides a row without changing producer state. -
State components for loading, error, and empty results, plus shared page, workspace, form, and control-surface primitives.
-
SelectMenufor the shared select behind a typedoptionsarray ({ value, label }), including the empty-string "all" option and dynamic option lists. The conversation diagnostics modal is a concrete consumer: its surface/status/role filters render through it with no local select wrapper. -
ProjectIconis the shared project glyph in cards, pills, and plugin previews. Pass{ id, icon }; an empty icon or absent Editor shows the folder glyph. Stored relative paths select the editor'sprojectroot, while guest-absolute paths selectsystemfor managed projects and are sent as root-relative paths. Core refuses absolute host icons, traversal, non-images, missing files, and canonical paths under/dev,/proc,/run, or/sys. The Registry Editor picker continues upward from the managed project directory into the guest filesystem; host pickers stop at their project root. Images are cookie-authenticated and cached once per project/icon as data URLs; reload reads the same durableprojects.iconvalue. Existing relative icons need no migration. Returning an adopted project to host execution clears system icons and preserves relative icons. -
DirectoryPickerfor selecting an authorized server-side folder. It rendersLoadingStateuntil the current path has data,EmptyStateonly for a successful empty listing, andErrorStatewith an in-place retry on failure. Project registration is its consumer; a pending navigation never presents the prior directory as the new path.
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.
DataTablesupports optionalselectionwith{ ids, selected, onSelectionChange, selectAllLabel?, longPressToSelect? }, together with the host'sDataTableSelectCell(not published to bundles); the conversation history register enables it (withlongPressToSelectfor touch).PageToolbartakesfilterActionsbesidefilters: buttons for the foot of the filter panel, for an action on the page's data that belongs with its filters (a destructive "delete all"). The panel's trigger, accessible name and phone sheet are then named "Options" (common.filterOptions) instead of "Filters"; withoutfilterActionsnothing changes, unless the page statesfiltersNamed: 'options'outright so the sibling pages of one deck (licensing Errors and Audit) open the same-looking control. The page owns the action and its confirmation (render theConfirmDialogoutside the panel, since the panel closes on outside interaction). A panel with actions but no filters still opens. Plugin API 30; the licensing Errors toolbar is the consumer. Below 34rem of shell width the row stays ONE line: the search is its only elastic part (it keeps a 7rem floor and its placeholder truncates with an ellipsis) so the filter trigger and a page action hold their whole labels, and anything that would push the field past that floor (a second action, the chips) takes the next line, while a toolbar promoted into the row's slot keeps a line of its own.DeckNavigationrecords takebadge(a count): the primary navigation's own badge (SidebarMenuBadgewith.sidebar-nav__badge) after the label in both the sidebar and the phone tab strip, included in the record's accessible name. Absent or zero draws none. The caller supplies the number; the deck fetches nothing. Plugin API 30; the licensing deck is the consumer.- UI API 53 adds
filtersNamed: 'actions'toPageToolbarandnamed: 'actions'toPageFilters, usingcommon.actionsfor the trigger and sheet title.ControlSurfaceToolbarforwardsfilterActionsandfiltersNamedinto that same contribution. Settings Plugins is the consumer: its category fields keep the canonical active chips while a batch-update button occupies the existing action footer. The page owns the external confirmation; absent naming keeps the previous Filters/Options behavior. There are no extra fetches or portals. ControlSurfaceToolbarsupports structuredsearch,filters, andactionscontributions plus a children form. Its publicpromoteprop defaults to true and sends the children toolbar throughPageToolbarPortal;promote={false}keeps it in place, and if no page slot is mounted its children render at the call site. PluginDetail uses the portal children form for its section tabs. Account panels importPageToolbarScopefrom the same owner so hidden retained sections cannot claim the slot.ResizeHandledrives the advisor dock on all four sides. A translatedlabeland the currentvalue,min, andmaxmake the divider focusable; axis arrow keys move it bysteppixels, and double-click resets the active dimension to its dock profile default.useDockStateowns bounds, reset, and persistence: resize updates passpersist: false, andonEndcommits the final size once. Without a label the divider remains pointer-only. No network calls are made.DateRangeFilteracceptscompactandpresets; production callers use the default non-compact layout and preset set.
For terminal appearance, web/components/terminal/xtermTheme.ts supplies xterm's palette from the account settings: call xtermTheme(settings) and xtermBackground(settings). With no preferences or theme: 'auto', it uses the dark palette; theme: 'custom' uses the validated palette, including the Elowen Light preset. TerminalPreview is the live consumer, and does not follow the app skin.
The shared DataTableCell accepts header, priority, lines, title and ordinary div attributes; the unused labelHidden and reveal props and their hover CSS have been removed after source and bundle consumer checks. SelectionSummary keeps samples, overflow count, variant and read-only management; the unused extraSamples slot is removed. Component names, SDK pending-input reveal() and API numbers remain unchanged.
Modal no longer accepts intent. Automatic presentation depends only on overlay depth and viewport: the first roomy overlay is a drawer, nested roomy overlays are centered, and phone overlays are fullscreen. presentation remains the explicit geometry override. WorkspaceDetailRail uses Modal with drawerWidth='default'; PageOverlay uses standsInForPage so editors opened from Settings resolve as first-level drawers. 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.