ouroboros/docs/development/11-design-system.md
Anton Razzhigaev 7e4a7796b7 Keep live turns visible and honor worker startup progress
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>
2026-09-18 12:12:28 +03:00

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.js is the one safe-field renderer, collector, attribute-escaping and tone/status source; ui_helpers.js and utils.js keep 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.js pins 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.py keeps 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 :root token 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 renderPageHeader outside an independently scrolling body; page icons come from web/modules/page_icons.js; primary actions, Refresh included, live in the renderPageHeader({ actionsHtml }) slot. Tab strips are one design-system control (renderTabStrip + bindTabStrip in page_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, programmatic select() 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 — and scroll_fade.js::bindScrollFade returns the disposer for its observer and listener. Masonry packing uses web/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 from extension_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 inline style="" 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, whose overflow-y: auto alone already makes overflow-x compute to auto. The shared select.ui-control recipe therefore clips its own value (overflow: hidden): WebKit computes overflow: visible on 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, …) or repeat(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 by tests/test_web_typography_static.py::test_select_control_clips_its_value and its ::test_webkit_scrollbar_recipe_covers_both_axes neighbour, tests/test_ui_settings_overflow_browser.py (WebKit, the native-select clip) and tests/test_ui_settings_grid_tracks_browser.py (Chromium, yielding tracks). Two gaps stay open: the wizard document loads ui.css without style.css and keeps native scrollbars, and an element setting the standard scrollbar-width/scrollbar-color opts 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-row contract (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 by web/tests/settings_action_row.test.js (the shared .btn-default role in both shells, the .settings-action-row contract 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.createSystemMessageActions around 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 in question_presentation.js (pointer and quiz header) and their Python twin project_dialogue.QUESTION_STATUS; a new state is added to both sides in the same commit with a row in question_presentation_parity.json (the pointer's data flow and precedence: ARCHITECTURE "Chat and Projects"). Geometry and exact-question navigation are exercised by test_ui_coherence_browser.py; answer delivery and the Main answer preview by test_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.py pins the seam; the ui_browser acceptance in tests/test_ui_smoke_agents_panel.py pins 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_row with card_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::taskOutcomeSeverity and taskTerminalPhase; taskPresentation is the one compact factual projection consumed by chips, live completion, history replay and child terminal presentation. Its host mirror is project_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 from taskReasonDetail alone (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 from error/crash/fail keyword matching. The Chat header reports connection, the /api/state activity 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/state census is the only inserter into the client live-activity set (contract and residuals: ARCHITECTURE §3 "Liveness census and the chat header"; enforced by web/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 narration fact 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 by web/tests/chat_activity_block.test.js, web/tests/chat_addressing_card.test.js, web/tests/progress_narration_voice.test.js and tests/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_observation event-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-only execution_evidence and actual_substrate, with no new poller or execution-state store. Render the chip/model facts through harness_presentation.js::executorIdentityMarkup, keep execution-evidence selection in log_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) and web/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.