Reserve chat composer space with a flex item for native WebKit. Combine queue and live activity identities through the existing task controls. Give a worker with its own early progress one readiness extension bounded to 300 seconds from spawn. Wait for the actual chat socket before the large-attachment browser test sends its files. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
24 KiB
Design System
This chapter owns the engineering rules that preserve the visual and interaction semantics docs/DESIGN.md defines: where values may live, which component is the single source of truth for a control, what counts as review debt, how history and chat viewport transactions behave, and how a visible change is actually verified. It exists because the failures it prevents are silent ones — a size token declared without a colour token, a control that widens its column, a dialog that bypasses the design system entirely.
docs/DESIGN.md owns visual and interaction semantics; this chapter owns the engineering rules that preserve them. web/ui.css owns shared values and the field/button/status/popup recipes, and both web/index.html and web/onboarding_template.html load it before their page styles; web/style.css and the page sheets keep shell/page composition (the primitive registry: ARCHITECTURE §3 "Navigation and shared UI contracts"). Documentation keeps semantic roles and failure-prevention rules, never a copied colour/radius/dimension inventory. These are reusable components inside the existing SPA, not a relocatable-page or multi-instance panel framework.
- Shared control classes are selected on the controls themselves (
.ui-control,.ui-checkbox,.ui-field,.ui-field-help), never through an expanding list of page ancestors. A family migration removes the replaced chrome in the same change and does not claim unrelated page typography migrated; domain drafts, validation and serializers stay with their current owners. No general guard: review-only under CHECKLISTS item 30 (web_design_system); migrated consumers are pinned individually (web/tests/ui_primitives.test.js,tests/test_mcp_ui_static.py,tests/test_ui_coherence_browser.py). DESIGN §8 lists the migrated families. web/modules/ui_primitives.jsis the one safe-field renderer, collector, attribute-escaping and tone/status source;ui_helpers.jsandutils.jskeep the re-exports. Keep the leaf free of shell, network and document-global initialization so first-party and author consumers share one implementation.web/tests/ui_primitives.test.jspins that portability and the escaping/password contract.- Typography on a migrated surface names a
--type-*size token AND a named foreground token in every text declaration: a rule with a size and no colour is the exact defect that made secondary text inherit near-white primary ink.tests/test_web_typography_static.pykeeps the class closed on the migrated families/regions; extending that guard and migrating its subject are the same commit (DESIGN §8 names the boundary). - The variable contract is checked in BOTH directions across the whole stylesheet by the same test file: every
var(--x)must resolve — an undeclared one silently renders its hardcoded fallback, which becomes the real value nobody can find — and every:roottoken must have a reader, because a token that resolves nowhere is what makes surfaces reach for literals. Each document resolves against the sheets it actually loads, and neither page may shadow a shared palette name. Fix a dangling name by pointing it at an existing token, not by declaring a new one. - Layout and controls. Top-level pages use a fixed
renderPageHeaderoutside an independently scrolling body; page icons come fromweb/modules/page_icons.js; primary actions, Refresh included, live in therenderPageHeader({ actionsHtml })slot. Tab strips are one design-system control (renderTabStrip+bindTabStripinpage_header.js,.app-tab-strip/.app-tab, the--pill-*tokens): the binder owns selected state, ARIA, roving focus and strip-only reveal, callbacks own loading and panels, programmaticselect()never calls them, and the binder and its resize observer are disposed with the owning page. The navigation column's height must not grow with the number of items in a collection inside it; a variable-length collection owns a bounded window with its own scroll (--nav-projects-list-max-height). Scroll bodies share.scroll-fade-y— except a dense list whose rows are shorter than the 32px edge, where the fade would cover a whole row — andscroll_fade.js::bindScrollFadereturns the disposer for its observer and listener. Masonry packing usesweb/modules/masonry.js::applyMasonry(CSS Grid row packing leaves gaps under shorter cards): it packs in the page's key order and writes only--masonry-*custom properties; never move<article>nodes to reorder, because a moved<iframe>reloads. Widget order and the per-card start-mode override (widget_start_mode, values fromextension_ui_validation.WIDGET_START_MODES) persist through/api/ui/preferences+data/state/ui_preferences.json, never in extension manifests. A new visual dimension becomes a CSS variable first and is consumed by shared classes; new inlinestyle=""markup and.style.<property>assignments are review debt, except a dynamic measured value updating a narrowly named custom property when that is the real runtime data flow. - Containment. A control never widens its column. Horizontal overflow lives in the wrapper that owns the wide content and declares
overflow-x: auto(code block,.md-table-wrap, tab strip, Costs table cells) — never in a page scroll body, whoseoverflow-y: autoalone already makesoverflow-xcompute toauto. The sharedselect.ui-controlrecipe therefore clips its own value (overflow: hidden): WebKit computesoverflow: visibleon a native select, so an unclipped option label becomes scrollable overflow of the page scroller. A grid track holding controls takes a minimum that yields to its container —minmax(0, …)orrepeat(auto-fit, minmax(min(100%, Npx), 1fr)); a fixed px minimum rescued only by a viewport media query is review debt, because the viewport does not know how wide the content column is. The global webkit scrollbar recipe sizes both axes. Enforced bytests/test_web_typography_static.py::test_select_control_clips_its_valueand its::test_webkit_scrollbar_recipe_covers_both_axesneighbour,tests/test_ui_settings_overflow_browser.py(WebKit, the native-select clip) andtests/test_ui_settings_grid_tracks_browser.py(Chromium, yielding tracks). Two gaps stay open: the wizard document loadsui.csswithoutstyle.cssand keeps native scrollbars, and an element setting the standardscrollbar-width/scrollbar-coloropts out of the webkit recipe on Blink. - One semantic button variant expresses one action role: neutral Settings and onboarding controls use the existing
.btn.btn-default; a one-action result row uses the named.settings-action-rowcontract (status first, action docked right); notifications use the shared toast host. Working, warning, error and destructive states keep one meaning across Chat, Logs, Settings and Skills. Enforced byweb/tests/settings_action_row.test.js(the shared.btn-defaultrole in both shells, the.settings-action-rowcontract and shared busy/status semantics); the toast-host and cross-page state-meaning clauses are review-only (CHECKLISTS item 30). - System-message actions use
ui_helpers.createSystemMessageActionsaround the shared button, including question pointers, Project lifecycle and routing receipts. Spacing above/below, wrap and focus clearance belong to this one composition, never a global button margin or a nowrap text ancestor. Question lifecycle words live once inquestion_presentation.js(pointer and quiz header) and their Python twinproject_dialogue.QUESTION_STATUS; a new state is added to both sides in the same commit with a row inquestion_presentation_parity.json(the pointer's data flow and precedence: ARCHITECTURE "Chat and Projects"). Geometry and exact-question navigation are exercised bytest_ui_coherence_browser.py; answer delivery and the Main answer preview bytest_ui_result_browser.py. - A list editor reveals the entry it just added through
ui_helpers.revealNewRow(row, field)— the one seam for "scrolled into view, caret in the first field" — and a freshly added entry shows no error before the owner tries to save.tests/test_available_subagents_ui_static.pypins the seam; theui_browseracceptance intests/test_ui_smoke_agents_panel.pypins the behaviour. - A host fact about a task is a row of that task's card, never a standalone bubble beside it: the producer stamps the placement (
card_rowwithcard_row_id) and the browser attaches the row to the card record through one helper shared by the live branch and replay, falling back to a standalone System row only when the task has no card record in the page (mechanism: ARCHITECTURE §3 "Main rows and host-stamped card rows"). A host fact that belongs in the card but is produced without a placement fact is review debt under this rule (CHECKLISTS item 30 scores conformance); the untyped terminal host notice and the origin-addressed routing notices stay ordinary rows by design. A client-side list of row types is not the rule (docs/development/02, an open default behind a closed exception list). - Task outcome truth stays in
log_events.js::taskOutcomeSeverityandtaskTerminalPhase;taskPresentationis the one compact factual projection consumed by chips, live completion, history replay and child terminal presentation. Its host mirror isproject_dialogue.outcome_phase, pinned to the browser by one shared fixture (web/tests/fixtures/outcome_phase_parity.json): a new axis, reason or acceptance status is added to both sides in the same commit, with a row in that fixture. The detail line under the headline comes fromtaskReasonDetailalone (precedence: ARCHITECTURE §3 "Task cards, errors and reason lines") — never from a second producer. A non-terminal diagnostic may add a timeline fact but must not promote the whole task, and an unknown event name never acquires Chat severity fromerror/crash/failkeyword matching. The Chat header reports connection, the/api/stateactivity census, the owner's own unconfirmed sends and live task cards only; a failed task status does not synthesize header attention, a toast, unread state or an owner action. Never derive header liveness from a WS frame: a typing frame is a submission receipt, and the/api/statecensus is the only inserter into the client live-activity set (contract and residuals: ARCHITECTURE §3 "Liveness census and the chat header"; enforced byweb/tests/chat_header_census.test.js). - Narration leads; routine execution evidence stays compact; exceptions keep their explanation and controls (semantics: DESIGN "Conversation activity block"; mechanism: ARCHITECTURE §3 "Direct turns and the activity block"). A note is promoted on the typed
narrationfact its producer stamps, never on a client reading of its text; execution evidence is one bounded row per block, never a row per event and never a client tool-name list; host snapshots of the same turn merge field-wise, because an absent field is unknown rather than zero. A change that draws one row or line per event is shown at a realistic burst size (a multi-call turn, collapsed and expanded, at desktop and phone width) before it is accepted; a two-event fixture proves layout only for itself. Enforced byweb/tests/chat_activity_block.test.js,web/tests/chat_addressing_card.test.js,web/tests/progress_narration_voice.test.jsandtests/test_progress_narration_voice.py; the visual check follows "Responsive and accessible behavior" below. - Executor presentation consumes the existing task/run attempt facts (shape and producer: ARCHITECTURE §3 "Child cards and executor presentation"). Keep
executor_observationevent-local through Agent, supervisor delivery, progress history and both Chat metadata paths; ordinary coordinator notes inherit none. Do not borrow a final-attempt model or parse progress prose to fill an absent live observation. Label last activity separately from current computation, configured/coordinator model and settled observed-model history. Preserve terminal-onlyexecution_evidenceandactual_substrate, with no new poller or execution-state store. Render the chip/model facts throughharness_presentation.js::executorIdentityMarkup, keep execution-evidence selection inlog_events.js, and add no second label builder in Chat. Tests:tests/test_executor_observation.py,web/tests/wire_contract.test.js. - Model-call provenance owned by the task survives result storage, copy-back and terminal/history rendering. Show the last usable solve response separately from initial routing, executor observations and final-answer authorship; a post-task or cost-only update cannot erase it. Fan-out counters describe emissions and wall-clock intervals, never inferred execution waves. Enforced by
tests/test_task_model_execution.py(storage, the terminal projection and the fan-out key vocabulary) andweb/tests/task_result_reconciliation.test.js(terminal and history rendering). - Chat viewport invariant. Sample live-edge intent before an ordinary transcript mutation — native scroll anchoring is not proof that the owner's visible message stays stable, so focused regressions disable it. The stable-viewport seam (ARCHITECTURE §3 "Timeline ownership and ordering") decides when the transcript follows the bottom; otherwise preserve the visible keyed message, nested card or Reviews anchor. Route late application-controlled DOM writes through that seam, and keep awaited Load-older, reconnect reconciliation and cross-instance restoration as explicit lifecycle transactions. Main and Project transcripts reserve their overlaid composer with one non-shrinking flex spacer, because bottom padding can fall outside native WKWebView's scrollable overflow; the keyboard-in-flow layout removes that item and its gap. Preserve each surface's measured safe-area reserve. Browser coverage is chosen by risk; this WebKit-sensitive contract requires the engines exercised by its marker-gated UI smoke, plus the system WKWebView when the defect is specific to that engine.
History pages and reconnect merge into the existing keyed card/row owners. Preserve the actual selected/focused/expanded nodes — rebuilding an equivalent node is not preservation. Timeline patches compare generated markup so unchanged enhanced markdown keeps its controls; the Reviews reconciler separately owns lazy attempt-detail state and cannot replace that behaviour. Physical source identity orders equal-time archive rows without rewriting JSONL. A historical frame never grants current activity or replaces newer terminal evidence. Eviction releases only its own page's media, markdown and decision views and protects visible reading, focus and selection. Exact page handles retain return navigation; read gaps and sparse empty pages never become false EOF; readable recent rows survive an unavailable archive with an explicit gap/retry and no fabricated physical cursor. Flush historical timeline changes once per card and skip idle scroll cleanup when no work is pending.
History chrome describes the rendered transcript, not the pager cache: canNewer (contiguous cached descriptors around focus) is never permission to tell the reader that newer messages exist. A server page with zero rows for the room is a bounded scan — it advances the cursor and keeps has_more honest, but it is not a reading position, not focus and not newer. Automatic continuation loads pages only at the older edge; at the live edge it only fills the gap toward already-mounted rows through exact page handles, never a rebuild. Return to the present is the explicit floating button, which rebuilds the chain with latest() only while non-empty pages above the window are still missing. Distant evicted pages plus retained live rows may leave a mid-transcript hole; that residual is disclosed rather than covered by a second load-newer control. Tests: tests/test_chat_history_paging.py, the pass-through cases in web/tests/chat_history_pager.test.js, the sparse-walk, anti-loop and dense-return cases in web/tests/chat_history_integration.test.js, and tests/test_chat_history_paging_browser.py.
The Project work pointer (project_work_pointer.js; selection and label rules: ARCHITECTURE §3 "Project rooms") is a navigation component over the existing Chat card registry, updated inside the same viewport mutation transaction. Its label names the card on one line (projectWorkLabel; .project-work-pointer-label ellipsizes) and never restates the card's full status headline; without a represented root card it is hidden, not shown disabled. Preserve its loaded-window coverage disclosure — a represented unfinished card is not independent proof of current execution. Its click changes only the messages container's scroll position and existing reading intent, never message routing. Dispose it with the chat; add no second card tree, poller or task-state store for this affordance.
Responsive and accessible behavior
Navigation, headers, controls, and dialogs stay operable by pointer and
keyboard, preserve focus order, and fit the relevant narrow viewport without
stealing usable text space; use the shared responsive component before
adding a page-specific layout. A visible change is inspected with vision in
at least one relevant real consumer flow. A stored screenshot alone is not
verification; mobile or WebKit is not a universal requirement and is
selected from risk. Containment is the WebKit-sensitive exception — a native
select is not clipped there — so a change to a control recipe or a page
scroll body is verified on the engine that shows the class (Playwright
WebKit for native-control clipping, Chromium for engine-independent track
geometry), measuring overflow on the scroll body's scrollWidth rather than
on documentElement. Review-only: scored by CHECKLISTS items 2(i) and 30
(web_design_system).
Browser dialogs
For agent page readiness, use browser_action(action="wait", selector=..., state=...) on the current page, or browse_page(wait_for=..., state=...) after navigation. States are attached, visible, hidden and detached; hidden also accepts an absent element. A timeout returns the requested state, URL, current match count and first-element visibility rather than navigating again. These observations do not decide whether the task should continue; bounded evaluate remains available to its existing profiles.
Image-reader regressions include a real PNG under a synthetic user home with distinct task and canonical skill roots, and exercise same-round auto-attachment, the durable local copy and the actual send-time image block; a placeholder PNG, a flat drive or a mocked attachment helper cannot prove that path. Keep the secret/owner-state and protected-artifact denial controls.
Browser-boundary regressions run the installed Chromium and WebKit (PLAYWRIGHT_BROWSERS_PATH; a test never installs a browser, and OUROBOROS_EXPECT_BROWSER_ENGINES turns a missing engine from a skip into a failure) against real loopback servers bound through server_entrypoint.bound_service_socket, so the control endpoint under test is an actual recorded binding rather than a fixed port. The redirect residual is one strict xfail (tests/test_browser_private_service.py, the server-side dispatch counter) beside the passing content-refusal proof (tests/test_browser_redirect_chain.py); do not turn either into the other. The private-service proof takes its LAN target from OUROBOROS_TEST_PRIVATE_BROWSER_HOST/_ADDRESS (OUROBOROS_EXPECT_PRIVATE_BROWSER=1 fails instead of skipping) and writes the images it viewed to OUROBOROS_BROWSER_EVIDENCE_OUT.
window.prompt, window.confirm and window.alert are forbidden in web/modules; use confirm_dialog.js::openConfirmDialog (why, and its mode contract: ARCHITECTURE §3 "Navigation and shared UI contracts"). A critical action tests the exact confirmed result and keeps the confirmation plus its side effect in one injectable flow. tests/test_web_dialogs_static.py keeps the native-dialog class closed.
ui_interactions.js owns the modal keyboard boundary, menus and popup geometry (the three binders: ARCHITECTURE §3 "Navigation and shared UI contracts"). Callers mount first and dispose before removal, keep their own result/cancel contracts, mount .ui-popup outside clipping ancestors, dispose before removing a popup, and close/restore a menu before opening a dialog from its action. web/tests/ui_interactions.test.js pins callbacks, focus, geometry and cleanup; real menu/chooser/dialog browser consumers remain necessary for viewport- and engine-sensitive behaviour.
Files keeps one current editable document through cancelled navigation, ordinary folder refresh, failed Save and clipboard feedback; pointer and keyboard submission share one in-flight write, and newer text remains dirty after an earlier save. The New Project adapter shares dialog focus and menu behaviour while retaining all source modes and its selected target independently of browser navigation. tests/test_ui_smoke_files_project_drafts.py verifies these real consumers.
Declarative widgets
A module handler that calls OuroborosWidget.openExternal(url) or window.open(url) from an anchor click also calls event.preventDefault(), invoking the helper directly during the gesture, before awaiting other work; automatic relaying respects an already-handled click.
web/modules/widgets.js is the host for reviewed widget declarations — forms/actions, text/data/media, tabs/charts, async jobs, files, map/calendar/kanban, and composition through group, metric and callout (the author-facing schema, depth/node limits and composition rules: docs/CREATING_SKILLS.md; the render modes, frame CSP and bridge: ARCHITECTURE §3 "Widgets page" and "Module widget bridge"). The host-side rules: nested interactive components use stable identity and one disposer, and subscription.render is transitively passive. Data updates patch the existing component/field nodes at the mount's identity seam, preserving selection, composition, native popup state and password input; passwords stay only in their mounted control, never in the retained form-value snapshot. Forms and actions own visible pending/result/error feedback by component id — an optional status component or a sibling's shared data target is not that action's result. Preserve the existing job identity, bounded-retry and disposal contracts; long-running actions use a durable job id and resumable status polling. Escape text and attributes for their actual HTML contexts, constrain media to extension routes or safe data URLs, and keep charts accessible through a semantic table. Rare kind: "module" UI runs only in the sandboxed opaque-origin iframe — never load skill JavaScript into the SPA origin. A failed list read exposes contextual Retry through the keyed list reconciliation, preserving unchanged frames and the owner's Stop choices; do not turn Retry into a global refresh/remount. Every timer, listener, observer, stream, abort controller, chart and mounted widget has a paired disposer.
Enforcement: tests/test_widgets_ui_static.py at commit tier; in the release-tier ui_browser lane tests/test_widgets_ui_browser.py (geometry, job retry), tests/test_widgets_ui_browser_lifecycle.py (launch policy, ordered stop, retain, the streaming bridge), tests/test_widgets_ui_browser_patch.py (keyed patch of a running card, reconnect reconcile) and tests/test_widgets_ui_browser_capabilities.py (the frame CSP, sandbox and permissions boundary on Chromium and WebKit) — run all four before a release that touched Widgets. tests/test_widgets_ui_browser_identity.py additionally pins retained interactive nodes, composition/password lifetime, local action feedback and non-destructive list Retry through real declarative consumers.
Optional author controls
Use ouroboros.server_web.read_author_kit_assets(request.app.state.repo_dir) to read the fixed installed web/ui.css and web/modules/ui_primitives.js sources for an author-owned page (what the kit is and is not: ARCHITECTURE §3 "Author UI kit"). Resolve at the page/kit GET that serves a new mount, not at extension registration; the request root is propagated by both in-process and out-of-process dispatch. No bundle cache, new endpoint or auth exception belongs in the helper.
docs/examples/author_ui_kit/ holds the two ordinary extension recipes. Revoke temporary Blob URLs; keep the kit optional and author-overridable, with no theme poller or forced remount; add no opaque /static request, bridge message or widget schema flag. test_author_ui_kit.py and test_author_ui_kit_browser.py cover source-root delivery and actual framed consumers; they do not certify an arbitrary author's CSP or application.