# Ouroboros Design System
Normative authority for the **visual and interaction semantics** of the Ouroboros
UI: what a size means, what a colour claims, what a chip is allowed to say.
Authority split:
- **This file** decides semantics — type scale, hierarchy, foreground and state
colour, status conventions, card/row anatomy, density.
- **`docs/DEVELOPMENT.md` → `## Design System`** decides the engineering rules
that keep those semantics intact — where values may live, which component is
the SSOT, what counts as review debt, how a visual change is verified.
Shared values and control recipes live in `web/ui.css`, loaded before page
styles by both the SPA and the served onboarding document. Page styles own
composition, not another copy of the shared palette. This file names roles;
it does not copy an inventory.
The shell offers **Settings → Appearance → Light / Dark / System**. New clients
start on System; explicit Light or Dark remains pinned. The shared semantic
palettes in `web/ui.css` preserve geometry and status meanings. Light uses white
reading surfaces, dark text and an opaque header, with decorative matrix hidden.
Appearance belongs to a browser profile or desktop client, not an account or
server setting. Existing saved Light/Dark choices keep their meaning. Storage
failures are visible; clearing site data returns the choice to System. Switching
repaints mounted charts and diagrams without rebuilding views or losing drafts.
Independent iframe interiors remain author-owned, not automatically recoloured.
Desktop persistence requires a launcher built with persistent WebView storage;
server restart alone cannot verify survival across full quit/relaunch. Mechanism
and deployment limits: ARCHITECTURE §3 “Navigation and shared UI contracts”.
---
## 1. Type scale
Four sizes. There is no fifth.
| Token | Value | Role |
| --- | --- | --- |
| `--type-meta` | 12px | Labels, notes, chips, timing/quota lines, captions |
| `--type-body` | 14px | Default reading text, values, controls, row titles |
| `--type-section` | 16px | Section and card titles |
| `--type-page` | 24px | Page / wizard step title, and display text (a login code) |
Line heights: `--line-meta` (1.35) for short meta lines, `--line-body` (1.5) for
prose, `--line-title` (1.3) for headings.
Rules:
- A value with no exact token **rounds to the nearest token**. It never mints a
fifth size. 13px becomes `--type-body`, 15px becomes `--type-section`, 11px
becomes `--type-meta`.
- **No new raw `10px` / `11px` text on a migrated surface.** Below 12px the
dark theme forces a choice between illegible and glaring, and the glaring
option is what produced the owner's "too much small high-contrast white text"
report. `tests/test_web_typography_static.py` enforces this.
- Control chrome keeps its own dimension tokens (`--button-font-size`,
`--pill-font-size`). They are control geometry, not the reading scale; do not
replace them with type tokens or vice versa.
## 2. Hierarchy rule
**In any row, card or field, exactly one thing is primary.** Everything else
steps down. Concretely:
- **Label** → `--type-meta` in `--text-meta`, sentence case.
- **Value** (the thing the owner came to read or change) → `--type-body` in
`--text-primary`.
- **Meta** (last run, quota, effort, timing, provenance) → `--type-meta` in
`--text-meta`.
- **Section title** → `--type-section` semibold; **subsection heading** →
`--type-body` semibold. A bare `
`/``/`` that inherits the
browser default is a defect: it lands at bold 16px full white and ties with,
or beats, the content it introduces.
**The 12px UPPERCASE label pattern is retired** on migrated surfaces. All-caps
at a small size costs legibility, widens every label, and when a panel repeats
it dozens of times the labels collectively out-shout the values they describe.
Authored label strings should read as sentence case; CSS must not manufacture
caps with `text-transform`.
## 3. Foreground and state colour
| Token | Meaning |
| --- | --- |
| `--text-primary` | The one thing this row/card is about; interactive control labels |
| `--text-meta` | Real secondary content: labels, notes, hints, meta lines |
| `--text-secondary` | A quieter step below meta, for a dense repeated field |
| `--text-disabled` | Genuinely inert or incidental content only |
`--text-muted` is a legacy alias of `--text-disabled`; new work names
`--text-meta` or `--text-disabled` so the intent is readable in the diff.
`--text-secondary` is a real fourth step, not an alias: it sits between meta
and disabled and is written at ~50 call sites. Reach for it only when
`--text-meta` is genuinely too loud — a value repeated on every row of a dense
list, a chip's supporting word — and `--text-disabled` would be unreadable.
The criterion is the same one that governs the whole table: **if the owner has
to read it to act, it is `--text-meta` or brighter.** A load-bearing caveat, a
one-off note, a hint that explains a control: those stay at meta. A tone or an
unclassifiable ink is what produced the original "too much small
high-contrast white text" report from the opposite direction, so a rule that
declares a size and no colour is still the worst of the options.
Two failure modes this table exists to prevent, both observed in this codebase:
- **Secondary content parked at the disabled foreground.** A load-bearing
caveat rendered at `--text-disabled` reads as greyed-out chrome and gets
skipped. If the owner is meant to read it, it is `--text-meta`.
- **Secondary content with no foreground at all**, inheriting `--text-primary`.
This is the loudest of all failures because it is invisible in the CSS — the
rule simply declares a size and says nothing about colour.
### Brand accent
The brand red is **one value**, `--accent`. Every appearance of it is either
that token, the named roles built on it (`--accent-light` for text on dark,
`--accent-dim` for a fill, `--focus-accent-border` / `--focus-accent-ring` for
focus), or a rung of the accent alpha ladder (`--accent-04` … `--accent-65`).
A new alpha is a rung added to the ladder, never an `rgba()` literal in a rule:
the ladder is what makes "make the accent calmer" a one-line change instead of
a grep.
The app and first-run wizard consume the same palette source. A shared accent
change updates its named roles and alpha ladder there; neither page shadows
those roles with its own values. `tests/test_web_typography_static.py` checks
the actual stylesheet links, nonempty shared roles and per-document variable
resolution. The SM1 browser oracle also checks both rendered documents after
restart, so a missing link or a page-local override cannot pass as consistency.
A wizard-only layout token is fine; a second value for a shared role is not.
### Focus
Keyboard focus has **one** appearance:
```css
outline: 2px solid var(--focus-accent-border);
outline-offset: 2px;
```
`outline-offset: -2px` is the only sanctioned variant, for a control that sits
flush inside a strip that clips an outer ring (sidebar rows, header buttons).
Nothing else: not a blue ring, not a green one, not a `box-shadow` standing in
for an outline, and not a colour picked to match the control it is on. A focus
ring is the reader's cursor; if it changes colour per surface, it stops
reading as one thing.
**Hover paint is not a focus ring.** A rule written as
`.x:hover, .x:focus-visible { background: … }` gives a keyboard user exactly
what a mouse user gets by accident and nothing that says "you are here". Where
a control wants both, the shared paint stays in the hybrid rule and the ring
goes in a `:focus-visible`-only rule of its own.
Text fields are the exception, and they keep their own established idiom:
`border-color: var(--focus-accent-border)` plus
`box-shadow: 0 0 0 3px var(--focus-accent-ring)`. A field already has a border
to recolour, so an outline outside it would be a second frame.
### Controls and editable choices
Text, number, password, select and multiline fields use the same `.ui-control`
family; `.ui-checkbox` keeps native checkbox behavior. `.ui-field` groups a
label, control and optional `.ui-field-help`. A placeholder is an example,
never the field's only name. Help and validation belong to that field without
changing the alignment of neighboring controls and their actions.
Short fixed choices keep native selects, including the platform's own popup.
A control never widens its column: a select shows its chosen label on one
line, clipped at its own edge, and the full label stays in the platform's
popup.
Model selection uses the shared editable chooser: suggestions assist typing
without becoming an allowlist. A saved unknown model remains editable; a
catalog refresh preserves the real input, selection and composition. Escape
or blur closes suggestions without assigning a value. Selected, hover, focus,
disabled and invalid states have different meanings and remain distinguishable.
Tabs expose one selected view and one keyboard entry point. Arrow keys and
Home/End move through available tabs; restoring a selected tab reveals it by
scrolling its strip, without moving the page or taking focus. Menus and
editable suggestion lists share viewport placement, not keyboard semantics:
a menu moves focus among actions, a chooser keeps it in the input. Dialog
focus stays in the modal context and returns on close when the caller remains
available. Popups sit outside decorative clipping and fit the usable viewport.
### `.muted`
`.muted` is a **colour-only utility**: `color: var(--text-meta)`, nothing else.
It must never set `font-size`. It is written at ~50 call sites that already
sized themselves, and a size here would silently resize all of them. A scoped
rule (`.some-context .muted`) still wins on specificity where a surface needs a
local variant.
### Dark-theme contrast
WCAG 4.5:1 is a **floor, not a target**. On near-black, pushing small text
toward pure white causes halation — the glyphs bloom, and because everything is
maximally bright, nothing is emphasised. The result is a screen that is
simultaneously harder to read and flatter in hierarchy.
**De-emphasise the secondary rather than amplifying the primary.** When
something needs to stand out, drop the ink around it, do not raise its own. All
Primary (15.9:1) and meta (9.2:1) clear the 4.5:1 floor against `--bg-primary`
with room to spare. `--text-disabled` is deliberately BELOW it (3.5:1) and is
therefore reserved for genuinely disabled or incidental content, which WCAG
exempts; it must never carry meaning a reader has to obtain.
### A selected state is not exempt from contrast
A **status hue** and a **status foreground** are different values, and the
selected state of a control must use the foreground. Selected Advisory in the
enforcement group read `--amber` (`#f59e0b`) over a 12% amber wash: ~2:1 on the
light surface, unreadable exactly when the owner had chosen it. The rule that
closes this: a selected control tints with the `--status-*-fg` /
`--status-*-bg` / `--status-*-border` triple, which is defined per theme, and
never with the raw hue token, which is not.
The same reasoning covers images. A colour baked into a `data:` URI cannot be
themed, because a custom property cannot be interpolated into the URI string —
which is why the select chevron was a pale `#e2e8f0` on white. **The whole
image is the token** (`--select-arrow`), overridden per theme, not the colour
inside it. `tests/test_appearance_static.py` holds both facts.
## 4. Status and chips
A status has **an explicit foreground/background pair**, never a foreground
derived from whatever generic opacity happens to sit on the element.
Status, owner action, and urgent notification are separate product concepts:
- **Status** states a fact about the affected object. It does not imply that the
owner can or must act. Task status uses one factual word family: `Working`,
`Done`, `Done with warnings`, `Failed`, `Cancelled`. The same five words are
the host's durable label vocabulary for its own task rows in Main and the
Project thread (`OUTCOME_PHASE_HEADLINE`), not only the browser's.
- **Owner action** exists only when the responsible domain exposes a current
concrete continuation, such as Resume, Retry, Connect, Repair, Grant access,
or Restart. The action is a real adjacent control; severity alone never
manufactures one.
- **Urgent notification** is a rare, time-sensitive interruption. It uses the
product's explicit incident/notification seam, not a red status or a failed
task as a proxy.
Activity schedule rows use the same factual status rule: `active`, `disabled`,
`suppressed`, and `consumed once · history` describe lifecycle state, while the
adjacent Disable/Enable, Restore, and Delete controls state the owner action. A
consumed one-shot is history even when its task succeeded or failed; the status
never implies a result. Retained rows — consumed and suppressed — collapse into
one disclosure rather than padding the standing list or disappearing: history
the owner can still open, read and act on. A suppressed skill row keeps Restore
so the owner can ask for it back; a consumed one keeps only Delete, because
offering Enable on a schedule that cannot fire again would be a lie.
A task-bound `Reviews` history row may be the only retained fact for its owner.
That row keeps a neutral owner anchor visible, but hides task status and typing
until a real task status or activity arrives; review presence alone never means
`Working`, `Done`, or owner attention.
A review that was only awaited when its task ended is not a warning. Reviewers
that had simply not answered yet leave the task `Done`. For a plan review the
task result keeps the host's typed disclosure that the review was still open
(the `terminal_host_notice` field, read by the CLI and by parents), the card
states the same fact as its cause sentence, and no second chat bubble carries
it; a card that is amber or red for another reason keeps that reason, with the
open review stated beside it when the result records it. For task acceptance the host's
decision sentence already says that no reviewer verdict was established. A real
outcome keeps its word: no reviewer quorum, a failed, refused or unresolved
slot, a collected blocking finding, a reviewer verdict nobody closed, a rail or
a blocking exit still read `Done with warnings` or `Failed`.
A host fact about a task is a row of that task's card, never a standalone
bubble beside it. A reviewer panel that settles after its task already ended
adds one System row naming the verdict and which revision it covered; that row
lands inside the finished card (its Reviews group carries the note, the timeline
keeps the row) without changing the card's chip, title or meta, and a standalone
row appears only when the task has no card record in the page. The untyped
terminal host notice and the origin-addressed routing notices stay ordinary rows by design. Local diagnostic failures remain inspectable
in details and Logs, but do not relabel the whole still-working task. A failed child keeps a compact factual
`Failed` marker inside its parent while the root continues under its own
authoritative status. Internal reason codes belong in details and diagnostics,
not compact headlines. Where a card does show a cause, it says it in the owner's
words while the record keeps the machine code; a cause with no sentence yet stays
raw rather than borrowing a wrong one. The routing receipt under an owner
message is such a surface: a refused addressing act carries the host-composed
`cause` sentence (`project_dialogue.routing_refusal_cause` — one host table for
the receipt line, the System row and the picker toast), a landed act carries
none, and an unknown reason stays raw. A terminal whose preserved output was
never reviewed shows that output labelled rather than hidden: a short labelled
excerpt beside the pointer to the full copy, so a `Failed` card over applied work
is never a bare headline and never names preserved bytes without a way to reach
them. Where a stop receipt already carries the same text
in the very chat the card is written to, the card keeps the label and the
pointer alone; a card in another chat keeps the excerpt.
| Role | Foreground | Background | Border |
| --- | --- | --- | --- |
| Success / connected | `--status-ok-fg` | `--status-ok-bg` | `--status-ok-border` |
| Warning / degraded | `--status-warn-fg` | `--status-warn-bg` | `--status-warn-border` |
| Error / failed | `--status-error-fg` | `--status-error-bg` | `--status-error-border` |
| Neutral / classification | `--status-neutral-fg` | `--status-neutral-bg` | `--status-neutral-border` |
- **Status renders as dot + text.** The dot carries the state at a glance, so
the sentence does not have to shout it in saturated colour and can sit at
ordinary reading contrast.
- **A known outcome owns the status; an unfinished lifecycle stands beside it.**
When a task's outcome is already settled while post-task work still runs, the
chip states that outcome from the five-word family and `Finalizing…` is a
SECOND, quieter fact next to it — not a replacement, not a sixth status word,
and never something the card title has to carry instead. The pair is one
accessible name, so a screen reader hears the outcome and the hold together.
An owner stop outranks the hold entirely and speaks for itself.
- **Neutral is a real state**, not an absence of one. A classification chip
(which agent, which family) is neutral: it is a tag, not an alarm.
A tone value the code actually emits (`muted`) must have a rule; falling
through to a default is how chips end up white.
- Chips are `--type-meta`, not smaller, and are not uppercased.
- `--green` / `--amber` / `--red` are the saturated hues, and they are for
things that are not text: dots, switch tracks, progress. The `--status-*-fg`
tints are for text on near-black; do not swap them. (There was also a
`--tone-ok` / `--tone-warn` / `--tone-danger` alias family, plus
`--accent-task` / `--accent-system` / `--accent-user` / `--accent-project`
and `--ui-tone-*`. They were named here and referenced by nothing at all, so
every surface kept inventing its own literal instead. They are gone; the
vocabulary above is the whole vocabulary.)
Current completion and independent criticism are separate facts. An informed Advisory
author finish may complete the current subject while its original review remains
FAIL or DEGRADED; the old critic alone must not paint that completion Failed.
Independent execution, artifact, verification or publication failures still apply.
Blocking corrections saved without fresh approval and an explicit unfinished stop
remain unaccepted; show the retained work and reason through the existing five-word
status family and details, without inventing reviewer PASS or a new status badge.
### The tone primitive
Two shapes carry a tone, and they are not interchangeable:
- **A status sentence** — `.ui-status[data-tone]`. Foreground only, rendered as
dot + text. Filling it would turn every inline status into a badge and make
"connected" the loudest thing on the panel.
- **A status chip** — `.ui-chip[data-tone]`. The full triple, because the chip
*is* the status and has nothing else to carry it.
Surfaces whose tone is a class suffix rather than `data-tone`
(`.skills-status-*`, `.skills-badge-*`, `.toast-*`, `.marketplace-state-*`,
`.chat-live-phase`, `.log-phase`, `.evo-runtime-pill`, `.widget-table-status`,
`.widget-metric` / `.widget-callout`) name the same tokens. A surface that
paints only its edge (a card tinted by its state, a callout's left rule) takes
the border and leaves its own background alone; a toast keeps its glass
background, because a translucent status fill over live page content costs the
text its contrast.
Adopting these tokens is applying the semantic status contract, which already
governs every surface — it is not a token migration of those surfaces and does
not move them into the migrated set in section 8.
### Sidebar activity dots
Project navigation rows may carry the existing three 4px working dots
(`chat-live-typing`, 3px gap) for the live `active_chat_activities` census.
`Working`, `Thinking` and `Finalizing` are the only moving states, using the
existing 1.4s bounce rhythm; `Queued` stays static at a quieter step.
Budget-paused work, confirmed model access waits and required owner questions
are static amber, with `resumed` questions no longer waiting. A wait on the same
producer row suppresses its working motion; an independent working row keeps
motion, and the row's accessible name states both facts. Unknown or unconfirmed
census state stays static and explicitly unavailable. The dots take the row's
own foreground, like the status-sentence dot, so they never out-shout the title
and follow its hover and selected ink; the wait is the one hue, and no row
paints them in a saturated project colour of its own. In a Project row the dots
and the unread dot each own a reserved trailing column, so both sit at one x
across rows whether or not the other is present, whatever the name length, and
while the sibling kebab is hovered, focused or has its menu open; a deleting
row draws only `Deleting…` and keeps its census fact in the accessible name.
The collapsed Projects header carries the aggregate dots beside its label, and
an activity repaint preserves the existing row and menu nodes. The dots are
separate from unread dots and never carry a counter, percent or text
animation. Reduced-motion clients receive the same state without the bounce.
### References and actions
One owner intent has one control, built in one module. Tokens and primitives
cannot guarantee that on their own: every copy of a control can truthfully reuse
a primitive while its callers still choose the words, the glyph and the class —
that is how "take me to this Project" came to be drawn six ways. The rule is
therefore about intents.
- A **reference** points at an entity that exists in the product and goes there
when pressed. A Project is referred to by the Project reference — the Projects
glyph, the Project's name, `↗` — and by nothing else, wherever content points
at it: a bound task card's footer, a converted card, a System lifecycle row,
the owner's routed message, a mirrored answer, a mirrored question's head.
`web/modules/project_reference.js` builds it and alone raises
`ouro:open-project`; a caller chooses a layout (`inline`, `bar`, `footer`),
never a label or a class. It names the Project wherever its row carries the
name; a Project that was never named, and the owner's routed message (whose
caption names the destination), read `Project`, never an id. The handoff's separate phase chip states observed activity; the reference
itself makes no running claim. Its accessible name
says in words what the glyph and the arrow say in pixels (`Open project `),
and nothing depends on hover.
- A **command** is a button: it changes or confirms something, or goes to a
place that is not such a reference (`Load older messages`, `Open widgets`).
Tabs, toggles, menus and the navigation list keep their own roles; a status
chip states a fact and is not a destination.
- An intent that will be drawn in a second place gets its door first.
`docs/inventories/UI_CONTROL_TEXT_INVENTORY.md` lists the fixed text of the
hand-written buttons sorted by text (a label passed to a factory is that
factory's business), and the `ouro:*` events with the modules that raise
them, so a diff that adds a button shows its siblings in the same hunk. Read
them: "it reuses a shared primitive" does not show that two controls agree.
### Chat authorship and System rows
Voice follows authorship, not severity or the transport that delivered the text.
Model-authored replies use Ouroboros's assistant voice; host-composed commands,
receipts and diagnostics use System voice. A **System row** is a chat message
with `role="system"`, rendered with the existing yellow/amber system treatment,
or its existing task-card placement. `role` alone selects voice; `system_type`
names the kind, not the author (two documented presentations of a System pointer row show
model-authored bytes in Ouroboros's voice: the Project question mirror and the Project
completion mirror). New host producers stamp both fields. Relays
preserve them through live delivery, persistence and history. Model narration,
proactive replies and questions remain model-authored even when typed.
Formatting is asymmetric: assistant text always uses the sanitized chat markdown
renderer regardless of `markdown`; ordinary System text is escaped unless
`markdown: true`. The typed `skill_review` row keeps its dedicated renderer.
Voice does not confer task finality. The existing untyped terminal-host-notice
contract remains a documented exception, not a pattern for new notices.
## 5. Card and section composition
- A panel is one `.ui-card`-family surface: `--ui-card-border`,
`--ui-card-bg`, `--radius`. Nested emphasis uses `--ui-card-bg-soft`, not a
second border weight.
- A section is: title (`--type-section`) → optional one-paragraph description
(`--type-body`, `--text-meta`) → content → optional note (`--type-meta`,
`--text-meta`). The description explains what the section decides; the note
carries consequences and caveats.
- Subsections inside a section use a `--type-body` semibold heading and stay
visually grouped with their own rows, their own add action in the head
(List editors, below). A heading that floats equidistant between two groups
belongs to neither.
- **A collapsed disclosure shows that it opens.** A `` summary always
carries a visible open/closed marker — the native triangle, the `▸`/`▾`
glyph pair, or button/card chrome. A summary that sits beside a help line at
the same size takes control ink (`--text-primary`) and its own line, so it
does not read as one more note; a summary that already reads as a control
through its own chrome or placement may stay in meta ink. Overriding
`display` on a summary drops the native marker, so the glyph must be drawn
explicitly.
- Spacing comes from the 8pt tokens (`--space-*`); a new visual dimension
becomes a CSS variable before it becomes a page-local literal.
- An item in a popup menu or a picker list highlights with
`--menu-item-hover`. One gesture, one fill: a menu that highlights at a
different strength than the menu beside it reads as a different control.
- **Content text is always selectable and copyable.** A control may suppress
selection (`user-select: none`) only on its own label or chrome, never on
content it contains. Where content lives inside a click-to-toggle surface
(a task card's summary), the surface ignores a pointer click whose drag
produced a non-empty selection; keyboard activation is unaffected.
- **A markdown heading inside chat is a subsection label**, never a page
title: in chat bubbles every heading level renders at `--type-body`
semibold; in a task card's timeline it renders inline, without block
margins, at its row's own size, with a copyable line break before the
following paragraph. The page-size `md-h1` belongs to non-chat
surfaces only.
- **A task card's summary outranks its details.** The latest-activity line is
`--type-body`; collapsed timeline rows are a dense log at `--type-meta` in
`--text-secondary`; an expanded row returns to `--type-body` in
`--text-primary`. Details never render larger than the summary above them;
an inline label inside a row is semibold at the row's own size.
- **A nested child card is subordinate to its root.** Its compact identity
row shows status, role, notes and a chevron; a short task id disambiguates
otherwise identical siblings. Executor facts and the agent/coordinator model
occupy the metadata row, so the task's coordinating model cannot masquerade
as its external executor. A child keeps one useful activity line visible;
a root permits up to three. Empty activity reserves no band, and a duplicate
title is not activity. Full narration and Reviews expand independently.
The root keeps primary title ink at weight 500, children secondary ink at
400. Nested frames preserve real ancestry; their opaque secondary surface
avoids accumulating translucent white tints at greater depth.
- **An executor label states its evidence.** A progress actor is labelled
`last update`, with requested model or `model unconfirmed`; it is not proof
of current computation. Settled observed models are separate historical
facts, not a claim that their union is the current actor. Missing identity
stays unconfirmed; marks and configured routes never manufacture execution.
### History edges
A paged transcript loads older portions automatically at the reading edge and
keeps a keyboard-reachable `Load older messages` button that retries the same
portion when reading fails. A short or empty portion never claims the beginning
of the archive; only the source reader establishes that boundary, and an empty
portion is never a reading position. Distant portions may leave the rendered
window and return quietly as the reader nears the live edge. There is no
`load newer` control: the one explicit return to the present is the floating
`Scroll to latest message` button. An edge control states a fact about the
rendered transcript, never about an internal cache or cursor. The visible
passage, selected text, focused control and expanded Reviews retain their
actual nodes.
### Project work pointer
A Project keeps its conversation and real nested task cards. One compact pointer
leads to an unfinished represented root, or the latest represented root when all
are finished. It occupies one line: it names the card (its coined name, else its
title) and ellipsizes rather than restating a status headline in full, so the status bar
never grows into the reading area; the complete text stays on the card itself,
one click away, not in a mouse-only tooltip. A default desktop panel keeps the
pointer, the coverage note and the status pill on one row while the pill is
short (Online, Working, Thinking, Sending, Queued); a longer pill, a narrower
panel or a phone wraps the bar to a second row, never a third. It states `Loaded messages only`
unless history coverage is complete; without a represented card the pointer and
that note are hidden, which is not a claim that the Project has no work.
Navigation moves the conversation to the existing card without changing the next
message's recipient, opening another work pane or manufacturing activity.
### List editors
A list editor is any section where the owner adds and edits entries in place:
the Available subagents roster, the Review lanes groups, MCP servers, custom
keys.
- A section-level add action acts from its group's header (§6). A list
editor's new entry appears at the end of its own group, is scrolled into
view — the shortest distance, without animation — and takes the caret in its
first field. A button that stays in view while the entry it made is born
off-screen has not finished its job.
- A freshly added entry is an invitation, not an error. Where a list editor
validates in the browser (today the Available subagents roster), the entry
shows a neutral hint in its own meta line until the owner tries to save; the
error then names the entry and stands beside it — the entry tinted with the
status pair, never dimmed — with the section-level line as the summary. A
save attempt judges the entries that existed then; one added afterwards is
an invitation again.
- A per-entry on/off switch is a native `.ui-checkbox` leading that entry's
head, before its title, with its own accessible name and pointer target. It
is a draft like every other field — the section's Save is the one writer, and
no entry saves on its own click. An entry switched off is not dimmed, locked
or removed: it keeps every control editable and its own status reading, and
the switch never merges into the availability axis beside it.
- A multi-field card (an MCP server) follows the add-and-reveal rule without
adopting the §6 row anatomy.
### Reviews inside task cards
Real tasks and real subagents are cards. Reviews are a subsection of the
exact real task that owns their presentation. Harness and neutral API marks
identify the delivery channel alongside explicit execution evidence; they are
not child-task cards and never prove execution by themselves.
- A collapsed task card shows only a quiet `Reviews N` count, docked on the
metadata row (it wraps under the metadata on a narrow card), optionally with an
active count; a collapsed nested child card docks it on its metadata row the
same way. It has no aggregate pass/fail alert, no synthesized verdict, and
no review dollars.
- Expanding `Reviews` reveals one row per currently admitted review group
(`Skill review`, `Plan review`, or `Task acceptance`). Expanding a group
reveals its ordered attempt rows. Group state and verdict remain
domain-specific; one blocker never recolours the whole task card.
- Start progress labels the frozen model/route/profile as requested; settlement
reports that same slot's observed execution or says it was not reported. An API
model sent in a request is not an independently observed provider label, and
duplicate model slots remain distinct. No global last-run identity fills a gap.
- Disclosure is user-owned. Review results, retries, failures, terminal task
state, reconnect, and lazy-detail loading update content in place but never
open or close the task, Reviews section, or group.
- A plan wave whose reviewers may still answer reads as work in progress only
while its own task is running: the working tone, `in progress · k of n
answered` where the verdict token would sit, and each awaited reviewer listed
as awaiting rather than unavailable, under no verdict word. Once the task has
ended the same wave reads `no verdict · k of n answered` in the neutral tone.
A reviewer whose window expired is unresolved. A settled wave whose reviewers
were too few for a verdict reads `no verdict · k of n answered · m
unavailable` in the neutral tone: the verdict word `DEGRADED` is the host's
placeholder and never paints.
- A task-acceptance panel whose reviewers may still answer never reads as a bare
verdict token. While its own task is running it is work in progress: the
working tone and `in progress · k of n answered`, or `PASS so far · k of n
answered` once the quorum is met. Once the task has ended the same panel reads
`no verdict · k of n answered` in the neutral tone, or `PASS · k of n
answered`, until a late settlement replaces it. A reviewer FAIL keeps the
error tone and reads `FAIL · k of n answered` in both.
- On a plan wave and an acceptance panel alike, a slot that is neither answered
nor awaited (a settled failure, an expired window, lost custody, a refusal)
adds `· m unavailable` and keeps the warning tone beside the awaited slots; a
settled plan wave with no awaited slot reads its `no verdict` line in the
neutral tone (an acceptance panel with none still keeps the warning tone and
its `DEGRADED` verdict), and each unavailable plan reviewer row names the
model and quotes the engine's reported sentence when one exists — the
failure code stays in the task detail and Logs.
- An awaited or unresolved reviewer row adds `· since HH:MM` in the viewer's
local 24-hour clock, prefixed with the short date when the wait began on an
earlier day, only where the host recorded the moment it sent that reviewer's
request; a time is never inferred.
- A panel that settled after its task ended stays one attempt row of its group,
labelled as settled after the task ended; its note (which verdict, which
revision, whether a reviewer's outcome is still unknown) is host-composed and
printed verbatim, leading the attempt detail.
- Stable keyed rows are reconciled in place. A routine update preserves the
exact lazy-detail node, focused descendant, and its reading position. Expanded
groups state exact aggregate accounting when projected and otherwise say
`Cost unavailable`; attempt detail states exact accounting when the domain can
prove it and otherwise says `Cost unavailable`. Collapsed rows never show dollars.
- Harness marks are monochrome `currentColor` vectors with adjacent visible
text. They carry identity only, remain neutral across status states, and use a
generic text-preserving fallback for unknown harnesses; direct API is shown
neutrally as `API`.
- Vector provenance: Claude, Cursor, and OpenCode paths come from Simple Icons;
Codex/OpenAI comes from SVGL. Product names and marks remain the property of
their owners.
### Quiz card
The owner quiz card (`web/modules/chat_decision.js`, `.chat-quiz-*` in
`web/style.css`) is a chat-delivered decision surface. Optional clarification is fire-and-continue: the task states its assumption.
Required waiting keeps the same card and explicitly says that the task awaits
the owner, with Stop and existing task deadlines still effective; an optional
bound on that wait resumes the task with a host notice and leaves the card open,
and that notice says the same thing the card does — with a stated assumption the
task proceeds under it, and without one, no answer is explicitly not consent.
Silence is never an answer on either surface. A card outlives its asking task:
after the task finishes, the owner can still answer, and the answer arrives as
their own message in that chat. After settlement, status and the owner's recorded
answer keep both forms readable. Anatomy, top to bottom:
1. **Head** — neutral `Question` chip (`--type-meta`, neutral pair) and a
status as dot + text. The lifecycle word family is closed and leads with the
one word that answers "is there an unanswered question for me?":
`Waiting for your answer` needs positive wait evidence (the task's live wait
record, or the original required flag before any record exists); a resumed
wait — the owner typed instead, or the bound closed — reads `Unanswered · the
task continued; an answer is still accepted`; an open question without any
wait evidence reads `Unanswered · an answer is still accepted`;
`Unanswered · the task finished; a late answer is accepted as your message`
keeps the neutral dot and answerability; `You answered` uses the ok dot;
`Replaced by a newer question` uses the disabled dot; an unreadable source
reads `Status unavailable`, never an invented invitation. No answer-deadline
countdown: task completion closes its mailbox, not the question's answerability.
2. **Question** — the one primary thing, by position and ink: readable
`--type-body` text in `--text-primary`, regular weight. Its emphasis is the
asker's own — authored headings and `**…**` are semibold — so a question of
several lines keeps a title instead of reading as one bold block. It may
contain paragraphs, lists, checklists, tables and code; those blocks keep
the shared rich-content gutter, rhythm and bounded code scrolling. The card
does not infer a title from the first line or rewrite authored Markdown to
make it fit, and the question has no quiz-specific length cap. Directly under
it, a muted plain-text host line (`.chat-quiz-host-facts`, `--type-meta`,
`--text-meta`) states what only the host knows: the asking task, how its run
started and when the owner last wrote in this chat, with unknown facts said
as unknown; the line is absent when the card carries no `host_facts`.
3. **Stake** — optional one-liner (`At stake: …`), `--type-meta`, `--text-meta`.
4. **Options** — real owner actions: buttons with `--text-primary` labels,
legible at rest; an optional per-option detail steps down to meta ink.
After settlement buttons drop to `--text-disabled`; the chosen option keeps
the ok pair. Options are capped by the shared Python↔JS constant
(`MAX_QUIZ_OPTIONS`).
5. **Free answer** — while the card is open, a compact always-visible field
(`Your answer or comment…`) with a `Send my answer` button, enabled only
once something is typed. No option ever has to be the least wrong one: the
text rides with an option click as the owner's remark, or goes alone as the
owner's own answer. It uses the card's own ink and surface tokens (never
the legacy chat input), is capped by the shared Python↔JS constant
(`MAX_DECISION_COMMENT`), and disappears the moment the card settles.
A settled card instead carries what the owner said as a second primary
line (`Owner's answer: …`, `--type-body`, `--text-primary`) under the
options — beside the highlighted option when one was chosen, and as the
whole answer when none was. Its line breaks stay visible; the recorded
answer remains literal text.
6. **Assumption or waiting** — the signature line (`Continuing meanwhile: …`
for optional clarification, an explicit waiting statement for required input),
`--type-meta`, `--text-meta`, separated by a hairline. While the card is
open it names the default path. The optional assumption remains after settlement
as the record of work continued meanwhile. Required waiting copy disappears
when the card settles; the status and recorded owner answer remain.
The card was born on tokens ahead of the rest of the chat surface (which has
since migrated too): type sizes and every colour come from tokens (no new
literals), the chip's pill radius and the option gap included; every focusable
element in the card shares one keyboard ring (2px `--focus-accent-border`,
2px offset). Component geometry (card min/max width) keeps local literals like
the rest of the chat surface.
**Project question mirror.** A Project question the owner has not answered appears in Main as the Project's own quiz card — the same `buildQuizCard` form with the question through the chat markdown pipeline, the options with their details and the `recommended` badge, the stake, the assumption or waiting line, the status and the own-answer field — inside the same assistant bubble. The one addition is the Project reference ("References and actions") in the head beside the `Question` chip: its inline pill (the `--project` tints, the Project name in project ink with `↗`) opens that exact question in its Project, with the card's shared keyboard ring. A long Project name yields first (the chip is capped and ellipsized, its title names the Project whole) so the status keeps its place; a phone column wraps the head. Every lifecycle state reads as it does in the Project: waiting, open, resumed and finished questions stay answerable, and a replaced question stays as a read-only record. An unreadable source keeps what Main already knew; with nothing known the copy says `Status unavailable`, takes no answer and keeps its chip, and a row that cannot carry the form yet shows `Open the original question for its text.` until it can. The first confirmed answer from any source — a press in Main, the Project form or another device, a history or census snapshot — shows the recorded result (the chosen option, `Owner's answer: …`, `You answered`) for five seconds and then removes only the Main copy, through the ordinary message retirement and without moving the reader's viewport; the Project keeps its card. The countdown starts once and later observations never restart it. When focus was inside the copy it stays there while the result shows, then moves to the next Main question, or to the composer for a keyboard owner, never summoning a touch keyboard. A copy that learns its form and its answer in one delivery shows that result for the same five seconds. An answered question never enters Main again: fresh history, a reconnect or a stale open snapshot cannot bring the copy back. Main remembers the lifecycle of a bounded number of questions; a question it no longer remembers mounts a safe unknown copy, whose answer controls appear only after a fresh canonical record confirms it unanswered. A failed, missing or wrong-project canonical read leaves a safe `Status unavailable` copy with its Project chip and no answer controls; the chip opens the original Project form, while a later owned refresh retries the Main copy, so an unavailable read never turns a stale open snapshot into an answerable form and never permanently suppresses a legitimate unanswered question. The mirror and the quiz header share the lifecycle wording above.
**Project handoff.** Each independent Main request transferred into a Project retains
one compact chronological anchor, not one mutable capsule for the whole Project.
The anchor names the work, projects its observed phase and ends with the shared
Project reference. It uses Project colour, not a warning treatment. Binding alone
means neither Working nor Done; unavailable activity stays explicitly unconfirmed.
A manually converted card opens its room; an agent-created handoff does not steal
focus. A converted card is always visible — two cards of one owner message both stay —
and the durable receipt row shows only when no card carries the transfer; a folded
receipt returns when its card leaves the feed. Matching Started and routing
references fold visually into that anchor only while it is mounted; their durable
records remain, including their plain-text presentation to non-browser consumers.
A converted card whose Main receipt is not durable keeps a dashed border and a plain
warning names the gap; the binding still holds. Genuine initiator work and failures
are not hidden. A later final answer remains a separate message at its completion
time, never a replacement for the handoff.
**Project completion mirror.** A Project root that ended with Ouroboros's own final answer reaches Main as an ordinary Ouroboros message: that answer through the chat markdown pipeline, in the assistant voice, because the bytes are model-authored — the host stamps the answer on the completion row only for a model-authored final, so the browser never infers authorship. A long answer is folded to about seven lines; the fold is a visual clamp over the complete, selectable text, with a fade only when it really hides something, and never a cut. Under it sits one control, the Project reference ("References and actions"). The durable row and its wire frame stay `role="system"`: like the Project question mirror, this is a browser presentation of model-authored bytes, not a change of the row's author. The message carries no status word, cause sentence, title or duration, so a host verdict on that answer (a warning, an unaccepted review) is not shown in Main: it stays loud on the task's card in the Project, one press away. Copy copies the answer. An ending with no model-authored answer (a provider failure, a stop, preserved output), a start row, and every row written before the answer rode the row keep the System row, which ends with that same reference: the voice of a row never chooses how the UI points at its Project. One durable row either way: its plain text is unchanged, so the Telegram mirror, Ouroboros's own context and one-ending-one-notification behave as before.
The row under a Project lifecycle row or a routed message is the shared `createSystemMessageActions` composition around the Project reference. It owns token-based space above and below the controls, wrapping and clearance for the focus ring; a control never sits in a clipped/nowrap text line. This is a row composition, not a new card framework or a global button-margin rule.
History with no current execution or known outcome keeps its expandable content under `Outcome unavailable`, without a task chip, typing or Stop. Before complete live-source reconciliation, it is `Activity unconfirmed`. Positive current activity restores only its proven controls. A delivery warning may coexist with a preserved task-acceptance PASS. Model metadata says `Last solve response`, naming the initial request only when the route changed.
### Conversation activity block
A task's activity block is in the transcript exactly when the record already
holds something to show — one predicate (`web/modules/chat.js::blockVisible`),
re-read at every mutation, with no sticky flag: no kind is shown
unconditionally (a Presence turn and a consciousness wake-up are direct turns
and follow the same rules as any other); open owner attention (a model wait, a
pending stop, a host-attested Stop the record still offers — the same reading
the control uses, so a block never stands on a Stop it hides); a child card; a
review group; a content row; a terminal outcome other than Done. The completion note is not content:
a turn that ran no tool and finished Done leaves no block — live, after a
reload and after a reconnect.
Narration leads; routine execution evidence stays compact; exceptions keep
their explanation and controls. The block's title, its collapsed activity line
and its timeline carry the turn's own narration — the progress frames the
model itself authored, marked by the typed `narration` fact the worker stamps
at that one producer (a frame without the fact is a legacy frame and is read
as narration) — and whatever needs the owner's eyes: a failed or timed-out
step, a wait, a review, a child. What the host says about how the turn is
running (a checkpoint, a model fallback, a review verdict, a nudge) is a
visible timeline row that never claims the title or the collapsed line, so a
turn whose only notes were the host's keeps its coined or task name and an
empty activity line. When the task ends with a cause, the collapsed line states
that cause in the owner's words — the same sentence as the durable row and the
expanded body — so a `Done with warnings` chip never stands beside an unrelated
last action; a clean ending keeps the last narration. Successful tool calls are not rows at all: they fold into
ONE evidence row per block — `N tool calls`, or `N tool calls · M errors` once
a call failed — that stands at the first call's position and time and is
patched in place; Expand shows the per-tool counts (`read_file ×3 ·
web_search`); its phase is `calling` while a tracked call is still running,
`warn` once a call failed, `result` otherwise, and the row says that phase in
ink rather than in extra words. A failed or timed-out call keeps
its own error row (content) and is counted in the evidence total.
`web/modules/chat_activity.js::toolEvidenceView` builds that row for the live
path and for the recorded metrics alike, so at rest the row carries the same
counts and names live, on reload and on reconnect; a cold reload mints it from
the metrics, so it carries the metrics' time and sits where the metrics
arrived, while a reconnect keeps the live position. Live it derives from the
observed call frames (once per call identity; identical repeats without an id
collapse into one), and the host's metrics replace those numbers as they
arrive, field by field: a fact that states a total says nothing about the
routing or error count, so it can neither erase one nor reclassify a receipt
row into content, and a call frame after the terminal changes nothing. Block presence is the same live, on
reload and on reconnect (a turn that moved itself into a Project with
`ensure_project_scope` is the exception: its block and answer live in the
Project room, and Main replays only the owner message and the Started
annotation); a child card reads the same voice rule for its own notes and folds
its calls live, but replays no evidence row.
`N notes` in the collapsed header counts timeline items, the evidence row
among them.
The block's chrome follows the work it stands on
(`web/modules/chat.js::blockHasWork`, the presence facts minus open attention
and minus a bare terminal outcome), never the lane that ran the turn (owner
decision 16.09: real work is a task card, a greeting is nothing). A block with
work — a review group, a child card, an evidence or narration row, a tool
error — is the task card whether a managed root or a direct
conversation turn produced it: a title (the coined name, the latest narration
headline, or the `Working…`/`Task activity` placeholder), the status chip, Stop
while the host attests it, and `Turn into project` in Main unless its origin is
already bound (a direct turn's later rows then route to the Project room like a
turn that called `ensure_project_scope`). A nested child card is work inside
its root's block, never a block of its own: it carries neither control, and its
root's conversion is the conversion of the whole work. A block that exists only for open
attention — a model wait, a pending or host-offered Stop — or only for a
non-Done ending of a turn that did no work carries no title placeholder and no
conversion; its chip says the state it is in (Waiting…, Cancelling…, Failed),
the wait controls stay, and its first row of work gives it the title. The
collapsed header carries the tool count live and, once the turn ends, cost and
duration (a replayed header carries the count and cost; duration is a live
fact); its `updated` stamp follows the turn's own narration, never a host note
and never a tool call. Money on that header is ONE line whose wording carries
its own openness, and it never renders an unknown as a number: `$0.00` only
where priced rows evidenced that zero, `up to $1.20` while the tracked subtotal
is still inexact, `Tracked: $1.20` with the reason stated in words
beside it (`some steps have no price`) when part of the same scope has no price
at all (`Tracked: up to $1.20` when that subtotal itself is inexact), and
`Cost unknown` for unpriced rows with no tracked amount. An empty, intact ledger
has no money line; an empty view with an integrity gap stays unknown. Neither
proves a free result. The explanation is text on the line,
never a hover-only title, because a tooltip is invisible on touch, to assistive
technology and in a copied line. Every part of that meta line is separated by a
real ` · ` text part for the same reason. The
host's `_is_direct_chat` fact keeps its host jobs (routing, census `kind`, Stop
custody, terminal rows) and, on the client, only the header pill (a direct turn
keeps the census verdict beside its block). A block whose only reason to exist
was open attention leaves when that attention closes: a wait-only block
disappears when the wait resolves, and the resolved episode's no-reopen ledger
survives with the record, so a stale revision cannot bring the block back.
An addressing call (`promote_chat_to_task`, `route_to_project`, `steer_task`, `ensure_project_scope` —
the routing-verb family `ouroboros/tool_capabilities.py::ROUTING_VERBS` owns) is stamped by
the host on its live tool-call frames (`routing_action`) and counted in the
task metrics (`routing_tool_calls`); its receipt is the typed routing
annotation on the owner's message. Such a call is a receipt row: it renders
inside a block that exists for other reasons but is never content the block
stands on, and the evidence row of a turn whose calls were all addressing
calls, without error, is a receipt row too — live from the stamped frames, on
reload from `routing_tool_calls`. So a turn that only
addressed work («turn this into a project») draws no block, live or on reload:
the annotation on the owner message and the managed root's own card are its
whole record (owner decision 11.09). A failed addressing call is an error row
and therefore content, as is any recorded tool error. A REFUSED addressing act
is told where the work lives and never in Ouroboros's voice (owner 16.09): the
receipt line states the cause in the owner's words, the failed call stays the
error row inside the block, and the tool result carries the typed reason (with
the cause and repair in `detail` where the producer holds one) so the model
narrates — no host bubble interrupts a narrating
turn. When the host itself issued the act (a Swarm message, a skill-card repair,
a picker click), or a refused owner steer carries no owner-message receipt,
the host states the refusal as ONE typed System row: `task_not_started`,
`task_start_unconfirmed` for unconfirmed admission, or `steer_not_delivered`
for steering. It stays in the issuing chat, keyed to the named target. The
Project start row is announced only once the task is really queued. A refusal receipt with neither options nor a cause sentence
reads «Not routed», never «Choose a target». No client list of tool
names decides presence (`docs/development/02-naming-and-boundaries.md`, "an
open default behind a closed exception list"), and no client reading of a
note's text decides whether it is narration. A change that draws one row or
line per event is judged at a realistic burst size — a multi-call turn,
collapsed and expanded, at desktop and phone width — never at a two-event
fixture.
### Subscription waits inside task cards
Quota exhaustion and a confirmed need to sign in again use the same component,
`model_wait.js` with `model_wait.css`, inside the turn's existing host: the task
card of a turn that has done work, the bare block of a turn that has not. Each
waiting role has its own row; the model, account and reason are separate facts.
The controls stay visible when the task's timeline is collapsed. Waiting carries
a quiet warning status and no computation animation, activity counter or invented
progress. Completed steps remain intact; a held worker slot and its effect on
the queue are stated only when the host reports that fact.
The quota row offers automatic continuation, initially enabled, and shows a
known reset time or an explicit unknown. An authentication row instead offers
the existing Accounts sign-in flow. Both can open Settings, retry explicitly,
or use the shared model-role editor to choose a replacement model/account.
The replacement affects the named waiting role until the task ends; an unchecked
"Also save this role in Settings" checkbox separately requests persistence.
Fallback Local remains shared by its Settings group. Changing Local for one
waiting fallback makes the replacement task-only: the persistence checkbox is
cleared and disabled with an explanation, while Apply remains available. Model
and account changes can still be saved when the shared Local value is unchanged.
Permanent group-wide Local changes remain in Models.
A pool confirmed to contain both accounts requiring sign-in and accounts waiting
for quota says "Waiting for access" and names both causes. It keeps automatic
continuation and opens Accounts without selecting a profile or initiating login.
Unknown pool availability is not this state. The confirmed quota component uses
the same execution-clock pause; an authentication-only wait still consumes that
clock, and calendar deadlines stay fixed.
A submitted action is shown as pending until the task reports its application.
A saved Settings change and a still-pending task change are disclosed separately.
Retries preserve the original request identity and payload. Revisioned rows reject
older observations, resolved episodes never reopen, and a terminal task removes
all wait actions. A mailbox delivery failure keeps Retry request available for
that same accepted command, even while its application remains pending. The
current task attempt selects live actions; a previous attempt's retained pause
never makes a new working attempt appear to wait. Updates and history rebuilds preserve the same keyed editor,
its draft, persistence checkbox, focus and reading position. Wait pickers omit
Context controls on every render: their action changes model/account only.
Waiting chips do not pulse or show typing. Closing the chat disposes view
resources without claiming that it stopped the task; continuation requires the
Ouroboros process to remain running.
A consciousness wake-up needs nothing of its own here. It is an ordinary direct
turn with its own task id, so its model wait, its controls and its temporary
model change behave exactly as an owner turn's and last until that turn ends;
the persistence checkbox still saves the consciousness role. What identifies the
turn is the origin label `Consciousness` in the block's meta line, on its final
bubble and on the cards of tasks it started — never a separate card, a reused
slot or a different vocabulary.
An already-delivered answer does not close a still-open post-task synthesis.
Reflection or consolidation waits use the same role controls in that task's
existing finalizing card. Pooled tasks, including API-only tasks, keep their
worker slot until post-work settles; the answer arrives early. Ordinary native
chat post-work holds no worker slot; its existing card keeps the same live
post-task model-wait controls after ordinary dialogue admission closes. Both claims follow the host's live owner
and post-task checkpoint, not the presence of answer text or a cost estimate.
Failed main work stays visibly failed after history reload while post-work
controls remain live; the unfinished checkpoint never erases the outcome.
## 6. Account group / row anatomy
For a repeated identity row (a connected agent account, a reviewer slot,
a server entry):
1. **Classification chip** — neutral pair, `--type-meta`. Only where the row's
family is not already expressed by the group it sits in; inside a per-family
card the chip repeats the header and is dropped.
2. **Name** — `--type-body` semibold, `--text-primary`. The one primary thing.
3. **Identity detail** (email, plan) — `--type-meta`, `--text-meta`.
4. **Status** — dot + text from the status pairs.
5. **Meta line** — `--type-meta`, `--text-meta`, on its own line under the
name. Quantities are stated in human words ("38% used · resets in 2h"), and
an instant is humanized. A row never leads with a raw ISO timestamp.
6. **Actions** — docked right, legible at rest. A control rendered at
secondary ink reads as disabled; if the owner can click it, it is
`--text-primary`.
For a row with one action and a durable result, the result occupies the flexible
left side and the neutral action stays docked on the right. Field-level actions
(for example Show/Clear) keep the field's control height; they are not reused as
the compact result-row action.
Rows of the same kind are equivalent: no row gets extra visual weight for
being first, default, or native. Grouping and section-level actions express
which family a row belongs to, and a section-level action (add, connect)
belongs in its group's header rather than attached to one privileged row.
**A degraded row is emphasised, not dimmed.** Lowering a whole row's opacity
greys out the sentence that reports the problem and makes its still-clickable
controls read as disabled. Tint the row with the matching `--status-*-bg`
instead, and let the status text carry the claim.
## 7. Onboarding density
Accounts is the common connection surface for subscriptions and API keys. Open sign-in link hands off to the current desktop, browser or Telegram host while retaining the wizard; Copy is separate. An unavailable host opener reports a retryable failure, and a supported copy fallback says that it copied rather than claiming an open.
Models and Agents edit assignments; adding a connection updates available
choices without replacing an owner's assignments. A model role uses one compact
Source / Model / Account row. The account is a property of that role: Auto
rotates compatible accounts and an explicit pin stays pinned. A model inherited
from Main remains visibly inherited while its account can be pinned
independently. Fallbacks use the same row in their saved order, with adjacent
move/remove controls and the group's Add action.
Context details distinguish the exact route's advertised Auto window from a
manual value labelled "set by you". Changing an account withdraws the previous
account's metadata immediately, including during a failed or pending catalog
read. Unknown limits stay unknown. Catalog updates keep the edited field and
caret in place and never assign a model. `model_roles.js` and `model_roles.css`
own the shared Settings/wizard editor; `reviewer_slots.css` supplies the same
reviewer-row layout to both documents.
A source is chosen, never spelled. Every surface that assigns a model — the
Models roles, Available subagents, every review lane, the first-run wizard and
the quota-wait picker — offers one grouped source select with the same groups
in the same order: configured subagents where references are allowed,
Subscriptions · models, API keys (one entry per provider with a stored
credential, then one disabled pointer to Accounts; a saved choice without a
credential stays selectable as "(no key)"), Agents · sessions where a session
is possible. The model chooser lists only the chosen source's catalog, so a
suggestion's transport is the selected source; any id can still be typed. The
stored spellings (`provider::model`, `claudexor::source=model`,
`harness=model`) are serialization authored by the editor: never required from
the owner, never a field placeholder or help-text instruction, never the
primary displayed value; the exact stored id may appear in a meta line or
tooltip. A configured-subagent reference is the one place a stored spelling
names a thing: a roster row is labelled by its handle — its route target plus
the facets that row really runs with, defaults omitted — because a friendlier
stored label rots as soon as the owner re-points the row. The route identity chip names the source (API · OpenAI, Codex · model,
Claude Code · agent), not the channel alone. A last-run receipt is shown
against the route that produced it: when the row's route changed since, the
line says so and names the earlier route.
Subscription model sources and agent sessions never imply each other's model
inventory. Source ids are opaque; the model-sources catalog names the
credential harness. Saved subscription-model account pins survive catalog gaps
and unrelated saves; direct API-key models do not offer a subscription-account
pin. Catalog entries are suggestions, not account-specific entitlement or
context evidence. Changing a model or account never changes the delivery kind:
a configured subagent reference remains a reference to its native inspection
episode, a scope or deep self-review row keeps reading the repository itself
whatever model it names, and an inline packet row stays inline. Catalog refreshes
preserve the edited value, focus, selection and scroll position. Returning to a
reviewer's previous source restores that source's model/account draft; a source
not previously selected starts without another source's pin.
The wizard has five steps: Accounts, Models, Review, Budget, Summary. Agent
connection is inside Accounts; Codex is the recommended connection for starting
without an API key. Other existing agent connections describe their actual agent
capability. Connected reports sign-in, independently of the model-source and
suggestion reads. Accounts names pending, failed or partial reads and offers a
contextual Retry that preserves the current fields. A known model source allows
Continue and manual model entry even when its inventory or automatic suggestions
cannot be read; an unknown source explains why Continue is unavailable. New
subscription-only installs clear only untouched shipped API suggestions without
access, requiring Main while Light can inherit it and Fallback can stay empty.
Stored or edited values remain intact.
Review & start computes the skipped model and reviewer steps before showing
Summary. After a failed automatic setup, an explicit recovery action prepares
all reviewers on the selected Main model, including its account and processing
choice. This is disclosed as one model for every review, not model diversity.
The resulting Summary is shown before a separate Start saves it; subsequent
manual edits remain authoritative. Completion without the automatic preset
leaves later configuration to Settings. Summary names exactly the assignments
the one atomic Finish saves, including deep self-review. Reviewers remain
editable with the same controls as Settings. A subscription-only Budget step leads with quota/reset facts and keeps
optional API spending fields collapsed. "No API key" never claims unlimited free
work or that paid provider credits were enabled.
The first-run wizard is a compact flow that must not scroll at the default
desktop window size merely because a step has several fields.
- Step title `--type-page`; card titles `--type-section`; field labels and
notes `--type-meta`. No display size above `--type-page`.
- Short-viewport adaptation hides explanatory copy rather than shrinking type.
Once copy is hidden, shaving pixels off a title buys nothing and costs the
scale.
- Field labels are sentence case at meta ink — a wizard step shows a dozen at
once, and its job is to get one value typed, not to present a grid of
headings.
### Advanced settings
The panels a new owner opens first (Models, Providers, Agents, Behavior) show only what an owner touches in the first week. Anything a typical owner never needs, or would have to look up before using it safely, lives under Advanced: deployment plumbing (bind hosts, process pools, runtime limits), transport and trust knobs such as the extra CA bundle, MCP servers, and the rarely used provider cards, which stay on the Providers page but fold under "More providers". A control that needs a paragraph of explanation before it can be used safely is an Advanced control by definition. The engineering seam is the `advanced` settings panel and the `advanced: true` card flag in `web/modules/settings_ui.js` (DEVELOPMENT "Onboarding and Settings surfaces").
## 8. Migration state
The scale is applied to complete component families and declared page regions.
Using a migrated field inside a historical page does not claim the whole page
has migrated. Migrated today:
- `web/ui.css` (shared palette, fields, buttons, status/chip recipes and
menu/chooser chrome, used by both top-level documents and optional author pages)
- `web/settings.css` (settings shell, model/effort cards, MCP cards)
- `web/onboarding.css` (the whole first-run wizard)
- `web/model_roles.css` and `web/reviewer_slots.css` (shared role editors)
- `web/style.css` between the `design-system:migrated-begin` and
`design-system:migrated-end` marker pairs (several — migrated surfaces are
not contiguous in the file): harness accounts, reviewer slots, the
Dashboard → Updates tab (status card, one action row, collapsed Recovery
with a single restore list), and chat (typography, foreground and status
colour; component geometry keeps its local literals per the viewport
reserve contract, while the shared palette channels keep translucent glass
surfaces coherent across themes)
- the global `.muted`, `.form-section h3` and shared `.ui-status` tone rules
The remaining page-specific typography in skills, marketplace, widgets, logs
and evolution is historical. Their adopted common controls follow the shared
family; unrelated page rules keep their literals until their own pass. Migrate
each selected family completely and remove its replaced recipes in the same
change. Do not introduce a half-tokenised second field family or describe a
control adoption as an all-page redesign. The semantic status/action/notification
contract already applies everywhere.
`tests/test_web_typography_static.py` guards the migrated set only. Extending
the guard and migrating the corresponding family or region are the same commit.
### Author freedom
An extension may use the optional shared buttons, fields and status functions
inside its own module or route-iframe page, override them, or design a completely
independent interface. `.ouro-ui` supplies font and native dark-control context;
the named classes opt controls into the recipes, with no page-wide reset.
The kit reads the installed source at a new mount; retained frames keep the styling
they loaded. A module may opt into the existing `OuroborosWidget.onTheme` signal
and apply its own `data-theme` rules, but the kit introduces no theme polling,
forced remount or mandatory visual conformance. Author layout, validation, operations and loading feedback remain
author-owned; the small source recipes are in `docs/examples/author_ui_kit/`.
---
## 9. Notifications
This section is the ONE canonical statement of when Ouroboros pulls the owner
back to the interface. `docs/DEVELOPMENT.md` points here and adds only
engineering rules; no second policy list may exist.
**Meaning.** A notification means *come back, there is something here for you* —
never *look, I am still working*. It exists so the owner can leave the window
and still be reached by a question or a finished task.
**When the client runs.** Notifications are a property of a running client. This
version adds no tray agent, no background process and no push channel, so
closing Ouroboros ends them. The existing Telegram bridge remains the separate
path that reaches the owner while nothing is open.
**Focus does not suppress, and neither does a closed room.** While a category is
on, its event notifies whether or not the window has focus and whether or not
the relevant chat is open. A Project's question reaches the owner even when that
Project was never opened in this session — which is the whole point, and the
reason the subscription belongs to the client rather than to a room. That is
deliberate for the first version: we measure how it feels before adding clever
exceptions.
**Two authorities, one surface.** Every category is one of two kinds:
| Category | Kind | What decides |
|---|---|---|
| A question or decision is waiting | required | a confirmed lifecycle fact: the question carries a positive wait |
| A task finished or stopped | required | a positive typed terminal fact on a ROOT task |
| Messages Ouroboros sends while working | LLM-first | Ouroboros chose to speak outside the turn's answer (a proactive message, or an optional question) |
| Ordinary replies in Main | separate toggle | an ordinary finished reply in the Main thread |
*Required* means the application asks for delivery from its own state rather
than relying on the model to remember. It does not mean the notification
bypasses OS permission, Do Not Disturb or platform limits — nothing here
claims that.
*LLM-first* costs no extra model call and needs no new host field: the signal is
that Ouroboros already chose to speak outside its answer
(`send_user_message`) or to ask something optional. The decision to send is the
mind's; the client only carries it. Stated precisely because the tool contract
asks for such a message at the START of long work as well: this category means
"Ouroboros said something while working", not a claim that it weighed whether
to interrupt you. Judge it by use and turn it off if it is too chatty.
**What never notifies.** Progress, a transient failure, one tool's error and a
reviewer's finding are not notifications; they stay in the transcript. A child
task never notifies the owner: it escalates to its parent, and only the parent's
own message or the root's terminal can ring. Machine partitions — the hidden
chat and agent-to-agent traffic — never reach a banner. A Project's room
notifies once this client knows it; any other ordinary conversation, including
one arriving over an external transport, is treated as the owner's exactly as
the Main thread itself treats it.
**A terminal is not always an ending.** An update or restart teardown reports a
task as interrupted and then requeues it; that is not a finished task and does
not notify, so the task's real completion still can.
**One ending, one notification.** An ordinary reply and the terminal of the same
task are one event, not two: whichever arrives first rings, and the other is
collapsed. The same holds for the several wire shapes a finished task has.
**One sound.** At most one sound per event. Where the system shows a banner, the
system owns the sound; where a desktop bridge is available, the launcher owns
one system sound (or reports that it could not play one); otherwise the app
plays one short tone. Never both, and the Sound choice remains authoritative.
**Each open window is its own client.** Settings, permission and the
duplicate-collapsing that keeps one event to one notification all belong to one
running page. Two windows of the same device — the desktop shell and a browser
tab — are two clients and can each notify for the same event. That is the
honest consequence of per-client settings, not a bug we have hidden.
**Click goes to the source.** A notification opens the question or the result it
is about — the Project room and the exact question when it has one, otherwise
the conversation. No reply is composed from the banner.
**Content is private by default.** Only the kind of event is shown until the
owner turns message text on, because a banner can appear on a shared screen.
**Deliberately absent.** No numeric badge, no repeated reminder, no inline
reply, no tray icon, no Telegram escalation, and no promise of a native
Notification Center/toast banner or attention after the application closes.
When the packaged desktop launcher exposes its optional `request_attention`
bridge, a live notification may raise that window and ask the operating system
for one standard sound. This is a native attention cue, not proof that a
system banner was delivered; unsupported or older launchers fall back to the
browser banner or in-app toast and report that capability honestly.
**Settings.** The controls live on **Settings → Appearance**, under the theme
block, and are stored per client exactly like the appearance choice: the desktop
window and each browser keep their own, nothing reaches the server. A test
button is the honest way to see what this system actually does with a
notification, including a denied permission.
**Known limits of this version**, stated rather than discovered later:
- A task retried under the same id has one conclusion as far as notifications
are concerned; a second terminal for that id stays quiet.
- Duplicate collapsing is bounded (the oldest keys are forgotten after a very
large number of events in one session), so a frame for a long-past event
could ring again.
- A child task is recognised from the delegation facts its traffic carries (its
declared lineage, or the executor enrichment on its own terminal). Nothing on
the wire declares a task to BE a root, so a task with no such fact is treated
as one: requiring proof of root-ness would silence every finished task. The
narrow residual is a child that carries no delegation fact at all and whose
first observed frame is its terminal — it would notify once.
- An event that happens while the socket is down never rings: reconnect replays
history, and history is deliberately silent.