ouroboros/docs/DESIGN.md
Ouroboros 2c3520fe1f feat: integrate the macOS title bar with the page
Keep native traffic lights and window actions with document-scoped measured chrome; restore ordinary chrome for undeclared pages. Detect packaged helper changes through named and resolved bundle paths.

Credit Yan for the initial native title-bar proposal.

Version-neutral contribution. Cumulative independent Opus/Astra/GLM whole and repair reviews; original FAIL and nonblocking residuals retained. Selected and native qualification only, no full-suite claim.

Cyber Pro author decision: explicit Git after baseline_stale refused commit_reviewed on the deliberately rebased private candidate. Review records rl-20261017T065715-0a7ef5ddd043 and rl-20261017T090810-00fb9463c717.
2026-10-17 12:52:53 +03:00

2071 lines
142 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 a second shared palette. This file names roles, not an inventory.
## Design bible: "Airy dopamine"
This distils the interface Ouroboros already has into rules, so every agent extends
it the same way. Every rule below derives from these lines and holds in both themes;
a component that needs its own exception is a design defect.
**In one sentence (for a designer).** A light, borderless soft UI in two themes: a
plain room (white, or near-black violet under glass controls); voice-tinted objects resting on a soft shadow;
everything pressable a capsule (a card when it carries a paragraph) standing on that shadow in one of three tones, whose
states change its colour, never its height, as on macOS; one crimson accent and
candy-coloured status chips; Apple system type in four sizes. iOS airiness with
Material 3 tonal colour; no gradient behind content, and no glows, textures, outlines or neumorphism.
**Principles**
1. **The room is plain.** Page, sidebar and panel backgrounds are the room colour or
one step off it. Never a gradient, glow, wash, texture or animation behind content.
2. **Objects rest; they are not outlined.** A bubble, card or group is a filled shape
on the *rest* shadow. What floats over moving content (the Shade, menus, the
composer field, dialogs, ↓ latest) takes the *float* shadow. Inside an object a
group or row is a *tray*, a soft neutral fill without a shadow, and fills never
stack. Only fields keep a hairline, because an editable box must read as editable;
the composer's field floats instead, and focus gives it the field's ring. On a dark
room a shadow barely shows, so each height is also a lighter surface (room, object,
capsule), and what floats over content is glass. The Shade is the one exception: it
continues its bar's ink and hangs on the float shadow, so the bar and its Shade read as
one surface unfolding.
3. **Colour says who and what.** Each voice tints its own objects: the owner blue,
Ouroboros blush, System amber, a Project fuchsia. A state is a candy tint with its
own ink, or a dot and a word. Hue says identity and fill strength importance, so a
mark that only identifies stays quieter than the name it marks. Crimson is the one
brand accent: live dots, the chosen place, the primary action. Colour lives on
objects, never on the room.
4. **One control, three tones, one height.** Everything pressable with a body is a
capsule standing on the rest shadow in every state (on the float shadow when it
flies over moving content, like ↓ latest): *neutral* (the capsule surface)
or *tonal* (its hue's tint and ink: crimson for the primary action and the chosen
tab or place, green for save, red for danger, amber for the owner's turn). A
*quiet* control (a link, a filter, an icon, a navigation row) has no body and never
stands. A state changes colour, never height: hover deepens the fill, a press
deepens it again, a chosen or switched-on capsule takes its tone and keeps standing;
what cannot be pressed now loses its shadow. No outlines. Shape follows content: a
one-line control is a capsule; a choice that carries a paragraph (an offered answer)
is a card with the object radius, standing like every other. A label that acts takes the
primary ink; a setting's name or an unchosen option the secondary ink; a setting's
value the meta ink, never the darkest; a chosen state its tone's ink. A status chip
is a flat tint: standing on a shadow means pressable.
5. **Air is spacing, never paleness.** Text keeps reading contrast; lightness comes
from room between and inside objects and from the absence of lines. Nothing is
faded to say "not yet known"; it says so in words or with an unlit mark.
6. **Colour answers touch.** A press answers with the capsule's own colour, never a
sink or a lift; live work loops as three moving dots; nothing else animates by
itself, and a state change never moves a neighbour, resizes a bar or changes a
height.
**Seed (light | dark)**
| Role | Light | Dark |
| --- | --- | --- |
| Room · sidebar | `#ffffff` · `#fafbfc` | `#0d0b0f` · `#0d0b0f` |
| Object · tray | `#ffffff` · neutral 3 % | white 3.5 % · neutral 3.5 % |
| Capsule (hover) · float | `#ffffff` (`#f5f6f8`) · `#ffffff` | violet glass `rgb(44 34 52 / .62)` (`rgb(60 46 70 / .72)`) · `rgb(38 31 46 / .94)` |
| Glass on controls and sheets over content | none | `blur(18px)` |
| Owner · Ouroboros · task card | `#e9eef9` · `#fdf2f4` · `#fff8f9` | blue glass `rgb(80 120 190 / .16)`→`rgb(55 85 155 / .13)` · crimson glass `rgb(201 53 69 / .2)`→`rgb(120 24 36 / .2)` · crimson 6 % |
| Crimson ink · on its tonal fill | `#bf2f40` · `#b52b3b` | `#f07a86` · `#ffa0aa` |
| Tonal fill, rest/hover | crimson `#c93545` 10/14 %, green 14/22, red 10/16, amber 18/26 | crimson `#f05a6a` 18/24 %, green, red, amber 16/24 |
| Rest shadow | `0 1px 2px rgb(28 33 51 / .05), 0 6px 18px rgb(28 33 51 / .06)` | `0 1px 2px rgb(0 0 0 / .5), 0 8px 24px rgb(0 0 0 / .35)` |
| Float shadow | `0 2px 6px rgb(28 33 51 / .06), 0 18px 44px rgb(28 33 51 / .11)` | `0 2px 8px rgb(0 0 0 / .55), 0 20px 50px rgb(0 0 0 / .6)` |
| Press | `brightness(.95)` | `brightness(1.12)` |
- Radii by role, both themes: capsule `999px` (every one-line control and chip) · object `18px`
(bubbles, cards, groups, menus, answer cards) · inner `12px` (trays and rows inside an object) ·
sheet `24px` (the Shade's lower corners).
- Type: the system face (SF Pro on Apple), 12 / 14 / 16 / 24; weight 400 to read,
500 for a status word, a root's title or a setting's name, 600 for labels,
controls and a setting's value.
- Space: 4 / 8 / 12 / 16 / 24; 14 between messages; 12 x 18 inside a bubble.
- Motion: 120 ms for colour and shadow (`--motion-tone`); no press motion; the
three-dot loop only while work runs.
- Where it lives: the values are `web/ui.css` tokens, dark in `:root` and light in
`:root[data-theme="light"]` (`--elev-*`, `--radius-object` / `-inner` / `-sheet`,
`--surface-object` / `-capsule` / `-float` / `-tray` / `-side`, `--glass`, `--voice-*`,
`--tone-*` with `--tone-crimson-ink`, `--press`); the recipes are ui.css "One control, three tones" and
style.css "Airy dopamine", written once for both themes. A page maps its own
elements onto these roles and adds no recipe.
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. Both themes follow
the design bible above; neither has a decorative background.
On Windows the desktop window keeps its ordinary system caption, buttons, Snap and
resizing; only their light or dark tint follows the painted palette — the system
tint, not the page's colour — and a High Contrast theme keeps its own colours.
On macOS the main window is one surface: the page reaches the top edge under a
transparent title bar, the real traffic lights sit level with the header row, empty
parts of that band drag the window, and a double-click follows the Mac's setting:
Zoom, Minimize, Do Nothing or Fill. Fill relies on an available undocumented native
action, not an imitation by Zoom. Its appearance follows Light or Dark and inherits
under System. In full screen the bar hides until the pointer reveals it; the setup
and error windows and a UI that does not declare it keep the ordinary title bar. A
browser, another OS or an older desktop app reserves nothing.
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 desktop app (launcher) built with persistent
WebView storage, 7.2.0 or later; in-app updates replace the core, never that app.
An older app opens its WebView in private mode, which erases website data each
time its window opens, so Settings → Appearance names that app by its own version
and says the choices reset at restart, instead of presenting them as kept. 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.
- **The one reading exception: a full rich chat answer.** A chat bubble whose
message is rich Markdown (an answer, the Project completion mirror, a
`markdown: true` System row) is long text at the bubble's 16px reading
size. Its headings step up from that text: `--md-heading-major` (1.25em,
20px) for `#` and `##`, `--md-heading-minor` (1.125em, 18px) for `###`,
the reading size for deeper levels; its tables read at that size too. The
steps are relative to the reading text, not UI sizes, and only the rich
answer's heading rules read the two tokens (§5) — they and a delivered
document in the reader (§5 "Document reading"). Nothing else changes
size: controls, labels, cards and page chrome keep the four sizes, and so
do the compact surfaces (a Skill Review report, a task timeline) and the
other rich-content cards (the question card, the update letter).
## 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 `<h3>`/`<h4>`/`<strong>` that inherits the
browser default is a defect: it lands at bold 16px full white and ties with,
or beats, the content it introduces.
- A Markdown heading is a subsection heading too — a `--type-body` semibold
label in a Skill Review report, the question card and the update letter,
and its row's own size in a task timeline — except inside a full rich chat
answer, which is reading text and follows the §1 ladder instead.
**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; the label of an action |
| `--text-meta` | Real secondary content: labels, notes, hints, meta lines |
| `--text-secondary` | A quieter step below meta: a dense repeated field; a setting's name or an unchosen option on a capsule |
| `--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 control's own
label is not content: it follows bible principle 4 (an action's label is primary, a
setting's name or an unchosen option secondary). 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 accent text, the crimson ink on light,
`--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`; the crimson tone and its hover are `--accent-10` and `--accent-14`).
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.
**Secret visibility has one meaning across Settings.** Show reveals the complete
current value as selectable, read-only text below the unchanged editing field;
long values wrap within the column. An edited value is shown from the local
draft; an untouched saved value is read only when requested. A prefix or a
configured-value placeholder is never a successful reveal. Hide removes that
text and retains the draft. Viewing changes neither the dirty state nor the
values submitted by Save or Test. Editing, clearing, reloading or leaving the
page closes the reveal, and a late read cannot reopen it. Built-in, custom,
skill-requested and MCP header fields use the same interaction; loading Settings
keeps saved secrets masked. Pending reads and failures are shown at the field.
A segmented choice row gives every choice the same width. Up to four fill their
row; a longer scale wraps into equal columns of four and leaves its last row partly
empty rather than stretching a lone choice; a narrow card stacks two; one-glyph
values such as review cycles stay one row. An empty inherit choice is as real as
any other, and the focused choice wears the one focus ring. A choice the running
Access suspends — Blocking under Cyber Pro — stays selected; the note beside it
names its effect for the current process and for the selected next start, from
the draft before Save.
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.
Availability never rewrites a selection: a Claude `[1m]` variant whose base
model a usable account lists reads Available, noting that the engine checks the
variant at session start, and an unread catalog reads Not checked. Maximum
response sits beside the context window as Auto or a whole number for this
exact model, server and account; Apply acknowledges it outside the Settings
draft, and a route change clears it. Web search is its own Source and Model:
Auto lists the routes it may use, a chosen source is strict, and skills, MCP
and browser tools stay independent.
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.
A **message field** — the Main and Project composer, a question's own answer —
sends with Enter by pressing its own Send action, whose validation, disabled
and pending state decide what is sent; a message is sent once. Shift+Enter
breaks the line; Ctrl, Cmd or Alt with Enter send like Enter. Enter sends
nothing when it commits an input-method composition or the field is empty,
disabled, read-only or settled, and once when held; a rejected answer keeps
its draft. The composer grows with its text or placeholder to one cap, then
scrolls. Touch keyboards are asked for a Send label; support varies. Every
other multiline field — file editors, JSON, settings and widget forms — keeps
Enter as a line break.
### Composer owner controls
Each composer saves its room's **Context** and **Effort** through the owner endpoints.
Main supplies a one-time copy to new Projects; later changes stay in their room.
Readback comes from Main state or the named Project, never another room. Saves apply
to new participants; running work retains its choices through Resume. A refusal
keeps the saved value and says why. No scope selector or extra settings row.
Each is a neutral **capsule** of the pills' height — ring, name, value: `Context Max`,
`Effort X-High`; the name in secondary ink at 500, the value in meta ink at 600,
and only the ring's lit arc coloured (crimson; Low amber, Nano the Project's).
Until the room's values arrive the ring stays unlit and the value secondary,
never a faded capsule. Closed, Effort names only the primary level; its ring (a 300°
arc: band minimum..maximum, dot primary) and the button's name carry the range.
Context's ring is three arcs, the chosen one lit: one of three modes, never a fill
level or capacity. A capsule opens **inline**, never as a dropdown, one at a time;
its value yields to the strip. Context's is Nano · Low · Max under a glass thumb; a
choice posts at once and the strip waits for the answer. Effort's seven levels carry
a primary pill and two helper bounds; the primary may lie outside them. A
segment is its word plus `--capsule-seg-pad` a side; the pill covers its segment;
band, pill and brackets share one vertical inset. The primary and helper bounds never
move each other; the brackets cannot cross. A level tap sets primary; each bracket
has its own handle. A drag keeps its grab offset on whole levels. A stored `minimal` shows at Low until its handle moves.
**A mark keeps from a rounded end the clearance it keeps from the straight edges.**
A pill (the thumb, the recommended pill) is concentric with the capsule, so the band
inset suffices; a square bracket at the last level would graze the curve, so
Effort's end gutter also clears it. The gutter is measured into the fit and outlasts
scrolling and dragging.
**The open strip stays on the composer's line**: beside the others when it fits
(padding down to `--space-1`), else alone while they step aside (down to 3 px, the
pill's corners tightening; the name and divider yield first), last scrolling inside
itself, faded where levels hide. A closed line too narrow drops the names, then
ellipsizes the longest values, never rings. All is measured, so no breakpoint.
**Hover is an accelerator; the press always works.** With a hovering fine pointer a
capsule opens ~160 ms after arrival (not while the other is open) and closes a
second after it leaves a 32 px zone unless a press pinned it; the press unpins.
Touch and keyboard use the press: Enter or Space opens onto the recommended handle
or the chosen mode; arrows step a handle (PageUp/PageDown too) or choose the next
mode, Home/End reach the ends; Esc closes and returns focus to the head; an outside
press closes. Handles are sliders naming their level; closed, they leave the tab
order. An effort change saves when the gesture ends, saves run in order (last wins),
and Send waits for both pending Context and Effort saves; a refused
save keeps the draft, says why and returns to the saved value — unsaved values are
never shown as saved.
### `.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 sits on the element.
The connected Chat header says `Starting…` until a complete server-ready
observation confirms the supervisor can assign work; a live socket alone means
only that the UI transport is open. `Online` is reserved for a ready, idle
runtime; known queued, active or finalizing work keeps its own factual label. A
durably accepted incoming message likewise proves acceptance, not that its task
has begun or a model has read it.
Status, owner action, and urgent notification are separate product concepts:
- **Status** states a fact about the affected object, never implying that the
owner can or must act. Task status uses one factual word family, `Working`,
`Done`, `Done with warnings`, `Failed`, `Cancelled`, which is also 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: a real adjacent control, which severity alone never manufactures.
- **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`, `consumed once · history` and `deletion pending` describe
lifecycle state, while the adjacent Disable/Enable, Restore and Delete controls
state the owner action. A consumed one-shot is history whether 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, since Enable on a schedule that cannot fire again would
be a lie. A deleted row still finishing work it already accepted stays in the
standing list and says what it waits for. Where generic Restore can cancel the
pending deletion, the button says Cancel deletion, not Enable; an exact hold
release stays separate and preserves the deletion intent.
A record of what the owner did earlier is history, not status. A skill's
OuroborosHub submission (the version it sent and its PR link) lives in the
card's details disclosure without a tone; it never claims the PR merged or gates
or replaces the card's current action, and forgetting it is an explicit local
choice, not the way to unlock one.
A task-bound `Reviews` history row may be its owner's only retained fact: it
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 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 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 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 reviewer panel that settles after its task already ended adds one System row
naming the reviewed version (delivered, different, or delivery unknown) before
its verdict. Its reviewer lines name the model that answered as an engine
reported it (else the requested one, labelled so: a direct API route's own
target is a request, not a report; two seats of one model stay two lines) and
say each outcome in words: passed it, rejected it, inconclusive, still awaited,
outcome unknown, not sent, unavailable, or answered without a readable verdict;
an unavailable reviewer's cause is quoted as the delegated engine's report,
never as the provider's. A reviewer's shortened note, like an engine cause the
host's own bound shortened, says only that it was shortened; the row offers the
exact applied record as a download once the host stored it, and a cut of the
whole row names that record only then, never a guessed link. Slot ids, verdict
tokens and omission markers stay in that record and the model's mailbox. The 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; a standalone row
appears only when the task has no card record in the page. Saving and showing
late criticism does not replace passing it to Ouroboros: the host owns sources
and fact delivery; Ouroboros judges the evidence, explains it and chooses any
further work on an admissible turn. The untyped terminal host notice and the
origin-addressed routing notices stay ordinary rows by design. Local diagnostic
failures stay 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:
a card that shows a cause says it in the owner's words while the record keeps
the machine code, and a cause with no sentence yet stays raw rather than
borrowing a wrong one. The rails that end a task are such causes: the loop's
forced finalization (round limit, deadline, grace window, context, unabsorbed
children) and the supervisor's timeout reaper (maximum running time, deadline,
idle silence) keep their typed codes on the record and the incident key, and the
card, the reaper's grace toast, kill notice and salvage line, and the loop's own
fallback text all say the one sentence from the shared table
(`project_dialogue.TASK_CAUSE_PHRASES`, whose browser twin lives in
`log_events.js`). 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 provider failure quotes only the provider's own sentence (a
long one, or one its producer already cut, shortened in words beside the Logs
pointer); without one it names the host's classification of the failure (beside
the provider's own error code and parameter when an engine relays them, as the
provider's) and points at the Logs, which keep the host's record of it (a stream
error's sentence only as a bounded excerpt, so no row promises more): neither an
exception's text nor an engine's composed diagnostic reaches the owner's row.
Host text speaks only for the host's own actions, counts and signed quotes; a
source it could not read is unknown, never zero. A terminal whose preserved
output was never reviewed shows that output labelled, not 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 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 need not shout it in saturated colour and sits at ordinary
reading contrast.
- **A known outcome owns the status; an unfinished lifecycle stands beside it.**
When a task's outcome is 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, never
something the card title carries 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. Dashboard → Activity applies
this to a row's own outcome, never its root's: plain `running` yields to it, a
queue wait or hold (`pending`, paused, sleeping, held after Restart) stays
beside it as the second fact, and the row keeps its controls while the task
still occupies the queue.
- **Neutral is a real state**, not an absence of one. A classification chip
(which agent, which family) is neutral: a tag, not an alarm. A tone value the
code 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, for things that are
not text: dots, switch tracks, progress. The `--status-*-fg` tints are for
text on near-black; do not swap them. (A `--tone-ok` / `--tone-warn` /
`--tone-danger` alias family, plus `--accent-task` / `--accent-system` /
`--accent-user` / `--accent-project` and `--ui-tone-*`, were named here and
referenced by nothing, so every surface invented 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 room's status beside its title is
such a sentence, with a caret when it opens, never a filled chip.
- **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 applies 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, in the
existing 1.4s bounce rhythm; `Queued` stays static at a quieter step.
Budget-paused work, confirmed model access waits, required owner questions and
Project/task scope verification waits are static amber; `resumed` questions no
longer wait. 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…`, its census
fact kept in the accessible name. The collapsed Projects header carries the
aggregate dots beside its label, and an activity repaint keeps 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 get the same state
without the bounce.
### Project unread dot
A Project row's unread dot, and the Projects count, mean *there is a new message
in this Project's conversation that you have not read*, never *something moved*;
activity dots, notifications, toasts, task controls and Stop/Panic keep their
own meaning, unaffected by it.
Unread revisions follow successfully stored conversation rows: a failed write
keeps the existing live-delivery behavior but cannot advance saved history's
revision, so this counter guarantees no delivery durability.
- **Counts:** each new standalone message in the conversation — an ordinary or
proactive reply, a question, a root task's final answer, a standalone System
message (a root task's terminal incident included), a delivered photo, video,
file or link card. A question or a delivery is shown alone and counts whichever
task sends it, a child task included.
- **Does not count:** anything that changes a task card rather than the
conversation — narration and progress, lifecycle, incidents raised in a
task's progress (root worker failures included; they still toast), host rows placed in a card (custody, late
review results, merge receipts, a child task's terminal incident), and a child
task's own words and final: a child speaks to its parent, and its card shows
them. Where a row is shown decides, never its kind: an incident shown alone as
a System message counts, one shown in a card does not. A placed row is card
content even where the page has not loaded its card and shows it alone.
- **Read:** only after a covering history read paints the newest-arrived message
on screen in the visible room, clear of header and composer; being at the
bottom is not enough when later cards or owner messages buried it. Opening an
unread room therefore lands on that standalone message wherever it lies: until
a loaded page holds it, the opening reads older pages (the page limit bounds
what stays loaded, not this reach); a read room opens at the bottom. One
opening intent, ended by a gesture (in a bounded box too), a question, leaving
room or window or a failed page (bottom and Retry), never a jump on later
reads or new arrivals. A question opened from its Main mirror has priority;
showing it reads no newer messages elsewhere. Collapsed cards need not open.
The newest message is the one that arrived last: a late answer keeps its
written time and chronological place. While reading elsewhere the dot remains
until that message is shown by scrolling, `Scroll to latest message`, or a
return to the page/window; opening alone never clears it. Unknown newest
arrivals (an unfinished line, a damaged row) acknowledge nothing and are never
searched for; state refreshes recheck an open unread room until history is
repaired or newer standalone content arrives. A room with no standalone
message is read at the bottom only once its clean start is reached (by bounded
search, the opening or `Load more history`). An old child's final whose
lineage appears only on another page can keep the dot once the room is shown.
- **Several clients** share one read cursor that only moves forward: a room read
on one client stops showing the dot on the others at their next state
refresh, and nothing read is shown unread again.
### References and actions
One owner intent has one control, built in one module. Tokens and primitives
cannot guarantee that alone: every copy of a control can reuse a primitive while
its caller still chooses the words, glyph and class, which 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. `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, even beside
same-named work; a Project never named, and the owner's routed message (whose
caption names the destination), read `Project`, never an id. It claims no
activity (the work card's chip does). Its accessible name says in words what
the glyph and the arrow say in pixels (`Open project <name>`); nothing depends
on hover.
- **Its form follows the surface's job.** It is a bound work card's wide footer
(the body stays the work's); under a mirrored answer or a System lifecycle
row, a reading-size capsule in the shared `createSystemMessageActions` row
(token spacing, wrapping, focus-ring clearance, never a clipped line: a
composition, not a card framework or global button-margin rule); the same
capsule at the end of the owner's routed message's one receipt line ("Passed
on to «work» in", "Started as a task in"); a meta capsule in a question head.
Long names: two lines in the footer, ellipsis in capsules, no smaller type;
accessible names stay whole.
- 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 (siblings:
`docs/inventories/UI_CONTROL_TEXT_INVENTORY.md`).
### Chat authorship and System rows
Voice follows authorship, not severity or transport. 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"`, in the
yellow/amber system treatment or its task-card placement. `role` alone selects
voice; `system_type` names the kind, not the author (the Project question and
completion mirrors are System pointer rows showing model-authored bytes in
Ouroboros's voice). New host producers stamp both fields; relays keep them through
delivery, persistence and history. Model narration, proactive replies and
questions stay model-authored even when typed.
A host fact has a subject, and the subject names its carrier: a task's fact is a
row of its card, a fact about the owner's message is the typed annotation on that
message, and a landed act publishes no row (its receipt or card already says it).
A standalone System row exists only where nothing else can carry the fact: no mind
can speak, no mind will read the message, the owner's own direct act, the
runtime's state, Main's Project lifecycle, or words signed by their author (a
reminder, a skill notice). It is one short typed mark in owner words, never a
paragraph or text written for a model. `tests/test_host_message_voice.py` keeps
every host type stamped as a literal at its send site in one of these classes, with
its counted producer sites.
Language follows the same line. The install's interface language
(`OUROBOROS_UI_LANGUAGE`, Settings → Appearance → Language) translates chrome
and System voice at render from a translation memory the install generated or
imported, never from a dictionary in the repository: status words, cause
sentences and question status by their stable code (`tr`), labels and help copy
by their rendered English (the overlay), a relayed host sentence by its exact
text (`tx`). Model-authored text, owner-supplied names, logs and code are never
translated, and durable rows keep the English with their typed codes; a string
the memory lacks stays English, never blank or guessed.
Authorship also has a time axis. Ouroboros's voice means *the model, now*. Words
the mind wrote earlier for a later moment (a note left with
`schedule_followup(notify=true)`) reach the owner at that moment as a System row
(`system_type="reminder"`), never as a live reply: the host prepends one
signature line, `Reminder · Ouroboros · written <when> · for <when>` (plus
`· delivered <when>` when downtime held it back), and keeps the words verbatim,
never rewritten, summarized or re-timed; the next turn reads the row as a host
fact (`📋 [reminder]`), so Ouroboros knows what the owner was shown. A skill
holding the owner's `notify_owner` grant speaks the same way in its own name: a
`skill_notice` System row signed `Notice · <skill>`.
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.
Built-in chrome uses words and monochrome `currentColor` vectors, not decorative
emoji. System keeps its amber bubble and plain label; status stays explicit in
words and the tone primitive. Functional icons keep state and accessible names.
Known typed host prefixes are hidden only at display (`host_notice_text.js`);
unlisted or ambiguous records remain literal. History, keys, exports and the
existing Copy input are unchanged (Copy already uses translated text for two
notice types). Authored text, quoted evidence, names, documents, logs and author
widget icons/interiors are not chrome and keep their characters.
Main's empty-chat greeting speaks in neither voice: it is host copy in a quiet
`.chat-empty-welcome` placeholder (a `Welcome` label over one sentence), never a
bubble, a history row or a model reply (`web/modules/welcome_preference.js`). It
appears only after a successful recent history read whose own window reports
complete coverage, over a feed with no message or task card (the typing indicator
and the ephemeral reconnect notice are chrome, by the same rule that gives an empty
feed its history loading state). Every later read withdraws it while in flight, so
that loading state, then any failure, shows instead; only a complete answer brings
it back, and it leaves with the first message or task card. Project rooms never
show it.
The sentence is a hidden install-wide preference with no Settings control: the
`welcome` key of `state/ui_preferences.json` under the data root
(`~/Ouroboros/data` by default). Change only that key and keep the file's others:
```json
"welcome": {"mode": "default", "text": ""}
"welcome": {"mode": "hidden", "text": ""}
"welcome": {"mode": "custom", "text": "Good morning."}
```
`default` shows the built-in "Ouroboros has awakened", `hidden` shows nothing, and
`custom` shows `text` as plain text, never markup (nonblank, at most 500
characters; other modes retain but do not display valid text). The object has
exactly these two keys. Prefer `POST /api/ui/preferences` with `{"welcome": {...}}`:
it validates and merges the value (400 on refusal, without writing). For a hand
edit, stop Ouroboros, back up the file, and preserve valid whole-document JSON
and neighboring keys. An invalid welcome inside valid JSON falls back to
`default` independently; the next save stores that default. Malformed JSON
instead follows the existing whole-file fallback: all preferences read as
defaults, and a later save may replace the unreadable contents. Main reads the
preference when it opens and on every reconnect; there is no file watcher.
## 5. Card and section composition
- A panel is one `.ui-card`-family surface. A card on the room is an object
(`--surface-object` on `--elev-rest`, `--radius-object`, no border) and a group
or row inside it a tray (`--surface-tray`, `--radius-inner`); a tray inside a
tray drops its fill, never 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.
- **Order by importance and expected use.** Put first what a typical install
needs most often or what matters most, and last what most installs set once or
never: left to right for tabs, header actions, toolbars and pill rows, top to
bottom for sections, cards and sidebar entries. Decide from what the control does
on desktop, browser, Docker and phone installs, not from counts: the UI never
measures use and never reorders itself. Related sections stay together and move
as a group. The exceptions: the default tab is first; a setup flow keeps its
order (connect an account, choose models, assign agents); an ordered scale and an
owner-defined order (Widgets) are never re-sorted; destructive actions and About
come last, while an emergency stop (Panic) stays where people reach for it; Send
and Save keep their usual place. A familiar location moves only for a clear gain,
and a new section takes the place this rule gives it, not the top because it is
new.
- 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 disclosure shows that it opens.** Its trigger carries a visible open/closed
marker — the native triangle, the `▸`/`▾` pair, a caret or button/card chrome —
and reads as a control: a summary beside a help line at the same size takes
control ink (`--text-primary`) and its own line; one that already reads as a
control may stay in meta ink. Overriding `display` on a summary drops the
native marker, so draw it. What opens has one coherent boundary tied to its
trigger: what a bar discloses unfolds from the bar's bottom edge as its Shade,
and opening moves no neighbouring target. **Floating chrome stays readable:** over scrolling
content, closed or open, content never shows through its text, and state tints
sit on that backing. Text controls in one floating group share height and
baseline; width, tone and icon shape follow their role. The group reflows by
its own container's width without losing labels, targets or focus.
- 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 never a page title.** In a full rich
answer (§1: answers, the Project completion mirror, `markdown: true` System
rows) it is a section of the answer and never smaller than the text it
opens: the §1 reading ladder — `#` = `##`, then `###`, then deeper levels
at the reading size — semibold, in the bubble's own ink, with clearly more
space above than below. The compact renderer's surfaces keep their density
(§2): in a Skill Review report every level is a `--type-body` semibold
label, and in a task card's timeline a heading renders inline, without
block margins, at its row's own size, with a copyable line break before
the following paragraph. Other rich-content cards (the question card, the
update letter) keep `--type-body` semibold labels. Bold text in chat
Markdown and rich-content cards is semibold, so a bold lead sentence never
outweighs a heading. The page-size `md-h1` belongs to non-chat surfaces
only.
- **Rendered Markdown keeps its author's structure and text.**
- A table stays a grid and fills its column. Cells wrap between words and
align to the top; the author's GFM column alignment holds (numbers right,
centred columns centred) with tabular figures; only a link, hash or code
span may break anywhere. In a full rich answer it reads at the answer's
text size; compact surfaces and cards keep `--type-body`. A table in
either renderer scrolls sideways only when its columns cannot fit even
wrapped, and only then is it a named keyboard region (`Scrollable
table`) whose sides that still hide columns fade. No stacked row cards,
no guessing a table's purpose from its words, no smaller text to make it
fit.
- A thematic break is a `--surface-border` hairline with section spacing.
Inline code keeps the ink of the text around it on its neutral pill.
- Code is the author's literal text: a fence or code span shows and
copies exactly what was written (`&amp;`, `<div>`, `**stars**`
included), and no formatting rule reaches inside it. A rich answer
reads code as its Markdown parser does. The compact renderer keeps the
promise for its own narrower grammar only: a fence opened by three
backticks, an optional label made of ASCII letters, digits, underscores
and `+ # . -` (at least one letter, digit or underscore; `c++`, `c#` and
`objective-c` included), optional trailing spaces or tabs, and LF or CRLF,
closed by the next three backticks; and a code span of
one or two backticks around text on one line. Other code forms (info
attributes after a space, a span across lines, a longer backtick run,
a `~~~` or indented block) are outside that
grammar, and the compact renderer does not promise them literal. Rich
prose reads one stored entity layer (`&amp;` shows `&`); an entity never
creates structure, and raw HTML stays literal text.
- A Markdown image is never loaded. It stays a visible reference,
`Image: <alt>`. In a rich answer it is a link through the chat URL policy
when its address is allowed, plain text otherwise, and inside an authored
link that link's own text; the compact renderer links it like its other
links (a refused address becomes their `#` placeholder). Chat media sent
through the media path is unaffected.
- **A narrow chat column gives every bubble its full width.** A column of
620px or less — a phone or a narrow Project panel alike — drops the
wide-screen gutters; the column, not the viewport, decides.
- **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, the agent/coordinator model and
the effort chip occupy the metadata row, so the task's coordinating model
cannot masquerade as its external executor. The chip is one text part of that
line, composed from the child's own frames: the applied level (`Effort High`),
the asked level when it differed (`Effort High (asked Ultra)`), then its
source: a pin glyph before a pinned level, `· model name` for a level the
model name carries, `(Cyber Pro)` under Cyber Pro, nothing for the owner's
range. A frame without a level paints no chip; an older frame is never
back-filled. A child keeps one useful activity line visible, a root 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 names the helper and states only exceptions.** The
ordinary case is the bare name — an assigned route beside the card's own
`Working` chip, or every settled run ok; it names the selected helper and is
not proof of current computation. A progress actor adds `last update`; a
refused pin, no run, runs not settled, failed or unverified runs, unreadable
evidence and an undisclosed outcome split qualify it. One priority phrase
stays visible; the detail names the other exceptions. Terminal records without
run evidence remain explicitly unknown; no promise of a later receipt replaces
it. Counts, the model and its source (requested, observed or `model unconfirmed`), spend
and access form one detail sentence that the expanded card prints as text,
never only a title. 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.
A completion notice distinguishes the task's recorded end from the time the
notification was added, both as absolute local dates outside the answer's
copyable body; an unknown historical end time says so explicitly. Inside the
room, the task card's saved end line keeps its notification time and adds the
same note whenever the recorded end falls in another minute or is unknown;
minutes compare as instants, and two that read alike on the local clock (a
repeated daylight-saving hour) carry zone names. Lines saved before the host
recorded end times stay as they were. Delivery keeps its present place in Main;
an older Failed remains that task's result even after a different task succeeds.
### Document reading
A Markdown or plain-text file Ouroboros delivers in chat can be read in the app:
its card says `Read`, which opens a reading dialog over the conversation (on a
phone a full sheet), and Close or Escape returns to the same place in the chat
with focus back on the card. Leaving that chat's screen (another page, a Project
room over Main, a notification) closes the reader; files staged in the composer
stay. Other files keep the card's Open / Download dialog; the reader keeps Open
and Download too.
- It shows the delivered copy, never what a file path holds now. A copy that is
missing, failed its integrity check, changed after delivery or cannot be read
on this device says so in the reader, with Retry only where retrying can
help; nothing else is shown in its place.
- Name, type and size are the reader's header, not Ouroboros's words: a
document is not a chat message and adds none. Like model text, the name and
the document are never translated; the reader's own controls are.
- Markdown reads as a document: a single line break inside a paragraph is soft
(an explicit hard break stays), at the reading size and heading ladder of a
rich answer (§1). Everything else follows "Rendered Markdown keeps its
author's structure and text": raw HTML and SVG stay literal, images stay
references and never load, and a relative link, which would point into a
folder the reader does not open, reads as its words. `Source` shows the
decoded text unformatted (a leading byte-order mark dropped, invalid bytes
read as <20>), so it is not a byte view. Nothing is editable; all text is
selectable.
- Plain text reads monospaced and wraps; only a table or code block scrolls
sideways, inside itself.
- The reader reads at most the first 1 MiB. A longer file shows that prefix
with a note saying how much is shown and that Download has the whole; a
character cut by the limit is withheld, not reported as damage. Bytes that are
not UTF-8 show as <20> with a note; a file with NUL bytes is not shown as text.
A read that has not finished within 30 seconds stops and says so with Retry;
nothing retries by itself.
### History edges
An initial read shows one disabled `Loading saved history…` pill at the feed edge,
not a second loading sentence in the header. Task activity remains separate.
Persistent history notices are wrapping text in the room's header chrome: the
Project status bar without an opaque strip, Main's floating header on its own
reading backing (§5).
A room always opens at its newest message: Main and every Project, however long
ago its last message was and however much other rooms wrote since. Returning to
a room in the same app session opens it at its newest message too, Main after a
closed Project included (owner decisions 2026-07-10 and 2026-10-05). Only an explicit navigation to one place
lands elsewhere: a question opened from its Main mirror, or a row of the room's
work status, whose landing reads the page around that work as the room's history
from there on: `Load more history` continues older from it and `Scroll to latest
message` returns to the present. A window merely shown again keeps the reader where they were.
A room's history is its own. A page is counted in the room's messages, never in
bytes of other rooms' traffic: the newest page holds the room's newest messages,
and `Load more history` reads the next older messages of the same room, only
ever older ones (owner decisions 2026-09-14 and 2026-10-05). It shows only while
older history exists, and one press keeps reading until messages land or the
room's beginning is reached, so no press is empty. A positive scroll gesture at
the reading edge loads the same way. Messages arriving while the reader is in
older history never move where the next press goes on: it reads above what is
being read, and anything a long absence left unloaded below is read by the
return to the present. Narration (task progress) keeps loading
alongside the conversation, but only the conversation keeps the control: once the
room's beginning is reached it leaves, even if older narration of the oldest
cards remains unread. A page around work that only narrated pages that narration.
The floating `Scroll to latest message` is the one return to the present; when
the present is already loaded it moves there and follows without a read.
A failed history read offers Retry; a failed read of the present by ↓ leaves the
view in place with the same Retry. A failed full-output read keeps the line's
capped preview and offers no Retry of its own; collapsing and expanding the line
asks again. Reading on, even inside a bounded box, supersedes a ↓ or question
still loading. A wheel, swipe or key over a bounded box moves that box, not the
conversation, until the box reaches its edge. A scrollbar drag follows new
replies only when released at the live edge; a wheel, swipe or key reading down
follows once its scrolling ends there. Expanding a line or receiving its full
output leaves it in place; only a newer line moves a card timeline to its end.
New replies remain below in the same live conversation without moving the passage.
When loaded fragments are disconnected or their coverage is uncertain (reading
far back releases the newest pages from memory while replies keep arriving), the
same readable note stays in Main and Project header chrome: `Some saved history
is not loaded. Shown messages may have gaps.` A failed read says so distinctly.
Bytes written after a read found a source empty count as missing until a later
read delivers them from its start. Mixed task cards keep one node and use this
general note: dates, common row IDs and an exhausted cursor cannot establish a
separator or full coverage. `Beginning of saved history` requires the room's
conversation to be delivered completely. Retained origins say `Saved project
context`; a matching canonical source row adopts that node and removes the label.
The context itself certifies no archive coverage. When a window is shown again
and the reader's exact place is gone, the view falls back to its card, then the
previous clamped position, and the same persistent note explains the
approximation until explicit navigation clears it. Visible rows and card lines,
selection, focus and expanded Reviews keep their actual nodes during
reconciliation.
### Room work status
Each room's bar has one status, right after its title: the short word in its
status ink, then one mark, as on the sidebar's rows; no frame at rest, a neutral
tint on hover and while open, a caret when it opens. Live work moves three dots,
queued work holds three still ones, any other fact shows one. One producer
(`computeDerivedChatStatus`) gives the word, tone and dots together, in the
cards' short words; while a question waits for the owner's answer the mark turns
amber (moving if work runs), unless the word already says so. The title ellipsizes first, and
a new word never moves a neighbour or resizes the bar. When there is work to
show, pressing the status, or ↓/↑ on it, unfolds that room's work and only that
room's as one Shade under the bar, the same in every room: its census rows, the
owner's unconfirmed sends and unfinished cards the census has not confirmed. A
Project with nothing else keeps its latest represented root. A row names its work
(the card's coined name or title, else the owner's message) in two lines that
keyboard focus or open details show whole, with the state under the name in the
card's or census's own words: an outcome stays beside `Finalizing…`, an access
wait is not an answer wait, unknown stays unknown, a pause keeps its cause and
each paused remainder its own row. The name goes to the work's message wherever
it lies, finished work included: its card or the owner's message when loaded, else
the room reads the page of its history around the work's first message there and
lands on it, the list staying open while it looks. Before that a row claims nothing
about where its message is; when the room's history holds none (`No message yet.`,
or `No message saved in this chat.` once ended), belongs to another room, or cannot
be checked, its details say so. `View question`
goes to the exact waited question and answers nothing; Resume and ⋯ are that exact
activity's shared task controls, offered only while the census confirms it. While
open, order and focus hold, and work that ends keeps its row and outcome, without
controls, until close. Escape, an outside press or leaving closes it; `Back to
reading` returns to the passage left, reading its page again if it was let go, or
to the present the reader was following. Navigation never changes the next message's
recipient, opens another pane or manufactures activity.
### List editors
A list editor is any section where the owner adds and edits entries in place:
the Available subagents roster, 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, and a collapsed nested child card the same way, shows
only a quiet `Reviews N` count, optionally with an active count, at the start
of its one action row under the metadata line (its controls right-aligned on
that row): no aggregate pass/fail alert, no synthesized verdict, 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.
- The Plan group is the plan record (nobody votes, so it carries no verdict
word): its line reads `N objections may hold` (an owner-marked reviewer's
objection, warning tone), `N open objections` (only added critics', warning
tone), `awaiting answers` (working tone while its task runs), `no open
objection` (done tone) or `not asked yet`; `objections not projected` in the
neutral tone when the detail lacks the host's fold, which the panel never
re-derives. The revision row shows the latest revision's goal, `done_when`
and earlier revisions' claims; each ask row reads `k of n answered` while
replies are owed and lists each participant (`added — never holds`,
`awaiting`, `not sent — <reason>`) and its objections (`(not adjudicated)`,
`(never holds)`). A record of the earlier schema keeps its wave rendering
below.
- An earlier-schema plan wave whose reviewers may still answer reads as work in
progress only while its own task runs: the working tone,
`in progress · k of n answered` where the verdict token would sit, and each
awaited reviewer listed as awaiting, not 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 runs 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 it 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 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 reads the same line but keeps the
warning tone, its stored `DEGRADED` staying in the attempt detail), and each
unavailable plan reviewer row names the model and quotes the engine's reported
sentence when one exists, the failure code staying in the task detail and
Logs.
- A plan wave the mind ordered weaker than the owner's effort setting says so in
its attempt detail as one keyed line naming each seat, the ordered effort and
the setting (`Reviewers ordered weaker than your setting: s1 low (setting
xhigh)`), at desktop and phone width alike; the verdict token is never
recoloured for it. A seat that did not answer a same-spec cycle adds `· did
not answer; its earlier finding is still listed` to its unavailable row (a
never-sent seat: `· not sent; its earlier finding is still listed`).
- 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, attempt detail exact
accounting when the domain can prove it, and otherwise each 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 says explicitly 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, the notice saying what the
card does (with a stated assumption the task proceeds under it; 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 — owner input or other mail woke the task, or the bound closed — reads
`Unanswered · task continued`; an open question without any wait evidence
reads `Unanswered`; `Unanswered · task finished` keeps the neutral dot and
answerability (a late answer arrives as the owner's message); `You answered` uses the ok dot;
`Replaced by a newer question` and `Question closed` use 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.
Pressing immediately shows `Sending…` and the brand-marked choice, keeping the
full form and focus; inert controls use subdued ink and submitted fields become
read-only. No success before canonical confirmation. `Not confirmed` means unknown,
not failed: only then show Check status, Retry same answer and Forget unconfirmed
local answer. Recovery first reads exact-quiz truth; explicit retry sends identical
bytes. Forget cannot cancel or retract a server answer; answered-with-delivery-owed
remains recorded.
While the card takes an answer the head ends with one quiet × (`Close question
without answering`): one tap closes it, no reason to type, and it waits while an
answer is typed, so a mis-tap never discards the owner's words.
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. Its
paragraphs, lists, checklists, tables and code keep the shared rich-content
gutter, rhythm and bounded code scrolling. The card never infers a title from
the first line or rewrites authored Markdown to 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, unknown facts said as unknown; without `host_facts` the
line is absent.
3. **Stake** — optional one-liner (`At stake: …`), `--type-meta`, `--text-meta`.
4. **Options** — zero to six real owner actions: elevated cards (the capsule surface
on the rest shadow with the object radius; a choice, never a field), the label in
`--text-primary` at 600; an optional per-option detail steps down to meta ink inside
the card. Hover and press change the card's colour. After settlement they lie flat
in `--text-disabled`; the chosen option takes the ok pair, its detail the meta ink. Options are capped by the shared Python↔JS constant
(`MAX_QUIZ_OPTIONS`); with none, the free answer is the whole answer.
5. **Free answer** — while the card is open, a compact always-visible field
(`Your answer or comment…`, the field's hairline) with a `Send my answer`
crimson capsule, enabled and standing only once something is typed; it is a message field, so Enter sends and
Shift+Enter breaks the line (§3 "Controls and editable choices"). 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.
Origin-local drafts and frozen attempts persist across closure only with a
persistent browser profile (older private WebViews erase them: Appearance above).
Restore reads, never resends; storage failure warns to keep/copy words.
**Clear local draft** removes unsent words, not submitted answers. Canonical
settlement clears the copy; winning bytes appear literally as `Owner's answer: …`
under the options, beside the highlighted choice or as the whole answer if none,
retaining line breaks (`--type-body`/`--text-primary`).
6. **Assumption or waiting** — the signature line (`Continuing meanwhile: …`
for optional clarification, an explicit waiting statement for required input),
`--type-meta`, `--text-meta`, parted by space. 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.
Inside a bubble the card adds no box: the bubble is its object. Its × is a quiet
control, without a body until hover. The card uses tokens for every
type size and colour, including pill radius and option gap. Every focusable element shares the 2px `--focus-accent-border` ring
with 2px offset. Component min/max widths keep local literals, as elsewhere in chat.
**Project question mirror.** Main is the list of what needs the owner; nothing waits in a section of its own. A Project question's Main copy is the Project's own `buildQuizCard` card, whole as anatomized above, in an assistant bubble at its ask time in the feed; the Project reference beside `Question` opens the exact original, and answers, materials and recovery controls work in Main. It uses the shared ring and `--project` tints; names ellipsize (full titles) before status; narrow heads wrap. History, live pointers and the census share one task/quiz card and lifecycle wording; the canonical current questions only confirm or settle copies the feed holds, never mount them, and an incomplete read withholds only what its typed gaps name. Optional, resumed and terminal-author questions stay answerable until answered or closed; no display/history cap drops them. Replaced questions stay read-only. The owner closes from the card; a model closes its own or a host-proven continued task line's question, or a finished task's, always with its own reason. Closure records a reason, never an answer/approval. A late answer left delivery-unknown by a restart offers Remove from pending (no resend, never marked delivered). Publication destination stays fixed across Project conversion; current room membership controls display. Explicit materials use registered task artifacts and the existing reader.
A task that cannot continue without the owner is one System message in the feed, stamped when first seen: the task, one sentence, its own controls (revision-bound model access, Resume/Stop, children's included; activity counts stay root-only). Sign-in, unobservable automatic access, disabled automatic continuation, budget pauses and Resume-only holds qualify; viable automatic account/quota recovery, queues, self-waking sleep, background scope verification and own Pause stay quiet. Deduplicate the same quiz/wait identity, not all waits on a task; the message leaves when a complete census drops the row. Missing action evidence stays unavailable; visibility never authorizes login, budget changes or automatic Resume.
One amber pill among the composer's pills (their row, height and type, at its end; `--status-warn-*`) counts the unanswered questions and owner actions — `1 question`, `3 questions`, `1 action`, `4 waiting` — and shows only while one of them is off screen; a narrow row keeps the glyph and number. Each press shows the next, oldest first, cycling: the card head lands under the chat header and the question text takes focus, never the answer field, so no touch keyboard opens. A question outside the loaded history mounts at its own date under the existing history note; there is no history seek.
A confirmed answer/closure shows its result for five seconds, then retires only the Main copy through message/media disposal without moving the viewport; the Project card stays. Pending canonical delivery keeps the read-only form and a compact pending fact. The interval starts once, including simultaneous form/result delivery. Focus remains during it, then moves to the next Main question or keyboard owner's composer without summoning a touch keyboard. History, reconnect and stale snapshots cannot resurrect settled forms; a settled pointer never mounts unless its delivery is owed. A newly accepted question closed before first exposure publishes only its closed state; an old answer without explicit delivery debt creates no new owner turn.
Unavailable canonical refresh retains text, references and drafts but disables affected controls. Missing, failed or wrong-Project reads say `Status unavailable`; absent form text says `Open the original question for its text.` The chip opens the original; owned refresh retries.
**Work portal.** A work living in a Project room keeps its one Main card at the
request's place, whoever moved it (Turn into project, the mind's move, a
binding); a Project start or transfer row only says where it went, never a
second entry. The card is the work's portal: status chip and quiet fact, title,
one latest line (two lines, cut on a word), the meta line (tool calls · money ·
updated), one action row `[Reply] [Resume] [Stop…]` with one primary, and the
Project reference as its full-width footer. Timeline, Reviews and children live
in the room; the summary is reading content, not a disclosure. The room's census
row supplies the present (its last line, tool calls, `in background` for
detached work); a missing fact leaves the card as it was, never a guessed line
or count. When the work ends the line goes and the chip states the outcome from
the final's typed outcome or the durable detail. An undurable Main receipt keeps
a dashed border and a plain warning; the binding holds. The owner's routing line
under his message steps aside while that work's portal stands in the feed; words
passed on to the work later keep theirs. A Continue is the same card: the
admitted successor takes the work's place and first name, each episode one meta
line (`Episode 1 · 17:33–18:15 · Failed: <cause>`), the current one owning the
chip.
**Project completion mirror.** Eligible managed/transferred Project finals reach Main as Markdown in Ouroboros's voice, host-stamped only for a model-authored final, so the browser never infers authorship; ordinary Project-native replies stay local. A long answer folds to about seven lines of complete selectable text, faded only where text is hidden; folded text has no keyboard stops. The Project reference below opens the complete answer and its delivered documents; Main has no in-place expansion or Files inventory. A nonclean final carries one compact factual limitation from recorded outcome/acceptance beside the answer, wrapping legibly at narrow widths; a clean final has no status caption. Copy copies the answer alone. Ordinary document readers, quiz materials and task-card Files stay on their existing surfaces; retained availability is not delivery or owner receipt. Full reviews, logs and progress stay in their sources. Durable rows/wire frames stay `role="system"` with unchanged plain text, so the Telegram mirror and Ouroboros's own context behave as before; no-answer endings and historical rows without an authored answer keep System voice and the Project reference. One logical identity joins retries/history and keeps one-ending-one-notification; external transport stays at-least-once.
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.
History placement is separate from the task's outcome: ordinary
`Saving task history` and `Task history saved` appear only in task details and
Logs, and a storage failure adds `History storage problem` to the existing
collapsed-card metadata without changing Done/Failed/Cancelled or creating a
chat message (successful retention clears it). Details and Logs name the
recorded failure reasons, repeated ones grouped with their counts; Logs Raw
keeps the complete source/failure facts, and a missing recorded reason is stated
explicitly. Existing task-detail hydration and replay refresh this projection.
### Chat attachments
The owner's photos and files are part of the owner's message, shown the way
Ouroboros's own deliveries are: one message, one bubble, never one bubble per
file (owner choice 1C/2A/3A/4A/5A/6A with mobile first-class).
- **Order:** attachments sit above the caption inside the owner's bubble; a
message with no words shows its attachments alone, never an empty text row.
- **Photos are whole on both sides.** One photo keeps its own ratio up to
`--chat-photo-max-height`, never enlarged; a tiny, very narrow or very short
one sits inside a `--chat-photo-min-box` box so its `•••` is never clipped
(either side); several share the existing two-column grid with
letterboxed (`contain`) tiles, one column in a narrow chat column. Ouroboros's
own photo tiles letterbox the same way — no side crops an image.
- **Video and audio** play in the existing inline players; every other file is
the existing file card (no PDF viewer). A preview the engine cannot show (HEIC
in Chromium, an unsupported codec) becomes an ordinary card with Open/Download
and "Preview unavailable"; a file deleted since it was sent, or whose size or
mtime no longer matches the recorded stat witness (a replace or rewrite that
changed either), is the inert card "Unavailable". The witness is not a hash and
nothing rehashes per view: a same-size replacement that restores the mtime is
not detected and shows as the original.
- **Actions never depend on hover:** the photo `•••` and file cards are always
visible and finger-sized under a coarse pointer; a long name wraps to two lines
and the card's dialog shows it whole. Open/Save succeed only when the desktop
bridge says so; a copied link is a fallback, never reported as opened.
The photo's Copy to clipboard puts the image itself there or says it could
not (Open in new tab and Download stay); it never copies an address instead.
- **Handing a file to a task adds nothing on success:** it arrives in the task's
own message. A file the host could not attach is named in one wrapping line
right under the owner's words ("Not attached to the task: …", from the routing
receipt, live and on replay, never folded away in Main), and Telegram gets the
same line. A new turn whose every file was refused is the System mark "Not
started: the attachments could not be staged (N files)"; one that starts with
only some of them says so in its own words.
- **The same view everywhere:** the sender's bubble, another tab and the replay
after reload or restart render the one server view of each attachment. The
stored text still names the files for the model; only the exact tail the web
composer generated, on its own row (or text the row marks as host-written,
`text_placeholder`, such as "(image attached)", a mark the echo carries too),
is hidden from the caption: the owner's words, even those same ones, and a
Telegram or skill caption are never rewritten. Every attachment renders,
however many; only the text tail is bounded. A Project's start message copied
from Main shows the attachments its row recorded when that row is outside the
window. History without recorded attachments stays as it was; nothing is
guessed from a file name.
- **Composer:** each staged image shows a small thumbnail beside its name; the
field stays focusable (read-only, not disabled) while files upload, so a phone
keeps its keyboard. `/restart` typed with staged files stays the Restart
command (the files ride its row); any other words are a message. A sent
message (words or files; a bare slash command is a command) is not yet saved:
if the socket closes, or the host refuses the frame, before its "Input saved"
echo, the bubble says "Not confirmed as saved" with "Send again", which
resends the same frame and id (the host rejoins it if it was saved after all,
never a second message), and "Discard", which forgets this tab's copy (never
an upload). Nothing resends by itself. The frame is kept, with no count bound,
until that id's saved echo or history row settles it (a row the running host
has not yet dispatched keeps it until the host says which): a bubble the feed
released or rebuilt keeps its frame, and a Project room holding one is hidden,
not destroyed. The tab's sessionStorage keeps its words, id, routing and
upload references (never file bytes) across a reload (the asset reload a
reconnect forces included) and a room's teardown; after a reload the first
history read settles it or shows the same doubt. A browser that refuses to
keep it says so, and Send again then lasts until a reload; a kept copy it
cannot read back is said too, never silently dropped. A saved row the host
knows never reached the agent (its write failed before dispatch, in the
running host) says "Saved, not delivered." and keeps Send again: the host
hands that row over once. A kept message whose saved row a host process before
a restart took says "Saved; delivery not confirmed." with Send as new, which
only puts the words back in the composer as a new message: nothing is
replayed. Rows nobody kept stay plain history ("Input saved").
### Replies
A reply is a reference: the owner says which row he means, Ouroboros decides
what to do, and nothing routes by it. Every saved row offers Reply (beside Copy
on a bubble, first in a root work card's action row, in the row menu), never on
hover only. Reply stages one strip above the composer (`Replying to`, the quoted
author, one line, ×; Esc cancels); the sent message carries only the row's
address, and the host makes the snippet. An answerable question card answers in
its own field. The quote panel is one component (bubble, strip, relay): above
the text, a 2 px bar and a tint in the quoted author's ink, one line cut on a
word (a photo leads with its thumbnail); pressing it goes to the quoted row when
loaded. An Ouroboros answer quotes the message it answers only when that message
is not directly above it (its own card does not count). A relay arrives in the
work's room as one Ouroboros bubble: each owner message it passes on, in his
complete words with where and when he wrote them (six lines, then Show all;
files named, a captionless file by name alone), then Ouroboros's note (Telegram:
plain text); his Main message keeps its one receipt line. The row menu
(right-click, Shift+F10 or the ContextMenu key on the focused row, a long press
on touch) offers the row's own doors: Reply, Copy, Open project and, on a work
card, Resume, Pause and Stop… exactly when the card does; Esc or an outside
press closes it, focus returning to the row. Words the owner selected keep the
browser's menu.
### 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, collapsed activity line and
timeline carry the turn's own narration (progress frames the model authored,
marked by the typed `narration` fact the worker stamps at that one producer; a
frame without it is legacy and read as narration) and whatever needs the owner's
eyes: a failed or timed-out step, a wait, a review, a child. Host notes on how
the turn runs (a checkpoint, a model fallback, a review verdict, a nudge) are
visible timeline rows that never claim the title or the collapsed line, so a
turn with only host notes keeps its coined or task name and an empty activity
line. A task that ends with a cause states it on the collapsed line in the
owner's words (the sentence of 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: they fold
into ONE evidence row per block (`N tool calls`, `N tool calls · M errors` once
a call failed) at the first call's position and time, patched in place; Expand
shows per-tool counts (`read_file ×3 · web_search`), and the phase (`calling`
while a tracked call runs, `warn` once one failed, `result` otherwise) shows in
ink, not words. A failed or timed-out call keeps its own diagnostic row and
counts once. A completed check command whose recorded criterion did not pass
keeps a separate expandable warning, “Check did not pass: …”, adding no call
error or task failure; its first sentence tells the check process from its
criterion, and its details name both and disclose bounded output. Live,
post-terminal and replay share that invocation-bound row; a later passing check
does not erase the earlier failure, and a comparison without a verdict is
unconfirmed, not a proven mismatch. On replay, recorded call times place the
evidence row before its diagnostics; a missing start permits only an
observed-time or legacy carrier fallback, not a reconstructed first call. Wait
end and operation settlement are independent: late success retires the
provisional timeout notice but keeps “wait ended” in the evidence row, and late
failure keeps its operation error, in either arrival order. A historical start
alone means outcome unknown, never Running or Failed.
`web/modules/chat_activity.js::toolEvidenceView` builds the row live and on
replay. Host invocation IDs join start/wait/settlement; legacy observations
without sufficient identity stay separate even with matching names and
arguments. Host metrics fill absent counts field by field; canonical
per-invocation evidence reconstructs later settlements on history/reconnect
without resurrecting frozen wait errors. Reads are bounded and carry coverage;
absent evidence is no proof of success. Start-only (live or legacy) and
incomplete replay evidence keep a known aggregate error; only complete
settlement evidence can replace an earlier wait error. Typed tool evidence after
task terminal updates counts and diagnostics on root and child cards, keeping
the terminal task phase and controls. The row keeps its live position; history
admits carrier evidence before summaries, progress or references choose their
presentation. Block presence is consistent across reload and reconnect. A moved
work lives in its Project, its Main card the work's portal ("Work portal").
`N notes` in the collapsed header counts timeline items, the evidence row among
them. A threshold crossing names its bound and threshold without strategic
advice; distinct bounds and thresholds get distinct rows, and historical
producers without a named bound keep their old label.
Delegated executor activity keeps its own attributed voice in the root or child
timeline. Messages lead; thinking stays folded in its own details, and routine
technical events collapse into counts with per-event detail under Expand.
Problems, unread ranges and omitted previews stay visible with a full-source
reference. Preview limits include serialized size; they never erase the carrier
or its source. Overlapping reads deduplicate by sequence identity, retaining any
unseen tail's omission notice; equal words alone are not duplicates. A stream end
before the observed cursor states unresolved coverage. Live, reconnect and replay
use the same projection. Telegram reserves room for problems and incompleteness
beside its disclosed speech preview and keeps the complete task reference.
The block's chrome follows the work it stands on
(`web/modules/chat.js::blockHasWork`, the presence facts minus open attention
and 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 or the latest narration headline; a finished card with
neither reads `Task activity`, a running one stays untitled because its chip
already says Working), 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 work that called
`move_to_project`); its meta line and one action row ([Reply] first, then Turn
into project, Resume, Stop…, one primary) form its foot. 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 converts 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 its state (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 count and cost; duration is a live fact); its `updated`
stamp follows the turn's own narration, never a host note or a tool call. Money
there is ONE line whose wording carries its openness and 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 inexact, `Tracked: $1.20` when part
of the same scope has no price (`Tracked: up to $1.20` when that subtotal is
inexact too), 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 chip's short word (`Sending`,
`Queued`) and `Tracked:` are explained in the card's details (exact phase;
`some steps have no price`), not a tooltip; real ` · ` text separates meta parts
for copies and assistive technology. 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 that existed only for 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 it back.
An addressing call (`start_task`, `move_to_project`, `tell_task`: 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). Successful local completion acts get the same receipt semantics through
the host's `completion_control` fact and `completion_tool_calls` aggregate,
keeping their actual call count without manufacturing work; failed completion
calls keep their error. 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) 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 one that has not. Each
waiting role has its own row; model, account and reason are separate facts, and
the controls stay visible when the task's timeline is collapsed. Waiting carries
a quiet warning status and no computation animation, typing, 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
choose a replacement model/account in the shared model-role editor. The
replacement affects the named waiting role until its task or review operation
ends; an unchecked "Also save this role in Settings" checkbox separately
requests persistence. Fallback Local stays shared by its Settings group:
changing Local for one waiting fallback makes the replacement task-only, its
persistence checkbox 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.
A dated pool refusal without a confirmed cause says "Waiting for an account",
retains the engine's reset forecast and keeps automatic continuation without
claiming quota exhaustion or guaranteed availability. It never offers sign-in.
Only confirmed quota pauses the execution clock; authentication and unconfirmed
availability waits consume it. Calendar deadlines remain fixed. Configured
fallback routes are tried before a wait card is shown. Work continuing on a
fallback is not a wait: nothing claims the primary recovered, the model alone
asks to return, and only the primary's own accepted reply counts as a return.
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.
When a requested Settings save failed or is unconfirmed, resolving the wait
shows one toast with those facts; closing its card without resolution announces
nothing. Retries preserve the original request identity and payload. Revisioned
rows reject older observations, and resolved episodes never reopen. Terminal
authors retain exact live paid-review controls without reviving author status;
other waits end. 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. Closing the chat disposes view resources
without claiming it stopped the task; continuation requires the Ouroboros
process to keep 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, controls and temporary model
change behave exactly as an owner turn's and last until that turn ends; the
persistence checkbox still saves the consciousness role. The origin label
`Consciousness` identifies the turn 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.
Consolidation or chooser 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.
### Widgets board
The Widgets page is a board of live cards. The owner arranges the board; each
widget's content belongs to its author.
- **A masonry in the owner's order.** Cards are packed into columns like a
Pinterest board: in the order the owner set with each card's move handle,
every card goes to the lowest free place its width fits, so cards of different
heights stack without a row layout's voids. The board takes its column count
from the list's width and the widths its cards ask for; a board where the
owner set no width is exactly the board before owner widths existed. A new
widget joins the end of the order; the order is stored when the owner moves a
card, so after a window reload the widgets added since the last move follow
the stored ones alphabetically. A widget whose skill is off is not drawn; its
place in the order (moving the other cards keeps its slot) and its width stay
stored for its return, and nothing is reserved meanwhile.
- **Width is a column span, the owner's over the author's.** A card spans 1,
2 or 3 columns, or Full width. Until the owner picks one, the author's `span`
decides (`1` one column, `2` two), and the board may widen a lone one-column
card beside wide ones to fill its lane. The board never widens or narrows a
width the owner chose, except that no card is wider than the board: 3
columns on a board of 2 is the whole row. Full width is always the whole
row. Because the column count follows the widths the cards ask for, a
card's width can make the other cards' columns wider or narrower. The
owner's choice holds for that card on every screen of the installation
until Reset size returns the author's default.
- **Widths that do not tile leave gaps; the board does not backfill.** A
card that cannot fit beside the cards above it starts below them: a
3-column card after a 2-column one on a 4-column board leaves the column
beside the first card empty above it. The board never repacks cards out of
the owner's order to hide such a gap; the owner closes it by changing a
width or the order.
- **Height follows content.** A card is as tall as its widget: a table shows
every row, a framed widget its declared or reported frame height
(`docs/CREATING_SKILLS.md`). No card is pinned to a height. When a card
grows or shrinks, the masonry packs again, so cards after it can move,
even to another column.
- **A narrow list is one column.** When the list is too narrow for two
columns of 280px — a phone, the Telegram mini app, a narrow window — the
cards stack at full width in the same order and widths do not apply; the
board returns when the list is wide again. The list's own width decides,
not the viewport.
- **The card menu works on every surface.** Every card's ⋮ menu carries Size
(1, 2, 3 columns, Full width and Reset size) beside a framed card's launch
policy, so a touch screen, a keyboard or the mini app — no hover there, and
a vertical drag collapses it — can size any card; on a narrow list the menu
says that widths apply when the list is wide. Wherever a step would change a
card's width, its right edge can also be dragged between the widths the
steps give it (Escape cancels) or stepped with its arrow keys; the only card
on a board, which every step leaves the whole row, offers no edge.
- **Arranging never restarts a widget.** Reordering, resizing or a change of
the column count changes only where a card is painted; a running widget
keeps its frame and its state. Keyboard focus keeps the card's original
position until the window reloads.
- **One arrangement per installation.** Order and widths are owner UI state
stored on the server, the same for every window and device of the
installation; when two windows arrange at once the last change wins. A
width that failed to save stays on screen and says so under the list until
a later width change saves it; nothing retries on its own.
## 6. Account group / row anatomy
For a repeated identity row (a connected agent account, a subagent row,
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 + a factual label from the status pairs. On Agents cards,
use one short availability label; its sentence is the title, and is said
under the head when it adds a specific pin, model or limit. Other row types
keep the status wording their own contract requires.
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 in a slot of their own, legible at rest. An action
rendered at secondary ink reads as disabled: its label is `--text-primary` (a
setting's name and an unchosen option take the secondary ink, bible principle 4).
**The main view carries understanding, state and action; justification is
disclosed where needed.** A row reads identity, state, facts; its primary choices,
exceptions and owner actions stay visible, and a value keeps its honest form
(`up to`, `Tracked:`, unknown). A rule every row shares is said once for the
section; history, provenance and why a state or value reads so wait in details
pointer, touch and keyboard open, never hover alone. New copy serves a concrete decision or error and adds meaning no
control or status already carries: a checked box is not re-said beside it, and
an unsaved draft is said once by the editor or its host page, not on every row. A conditional
line never moves the control that caused it: controls own stable slots, and a
line an edit toggles opens beside or below them. Clarity comes from placement,
never from smaller text or a line quota; real exceptions all stay.
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.
A source is chosen, never spelled. Every surface that assigns a model — the
Models roles, Available subagents (reviewers included), the first-run wizard and
the quota-wait picker — offers one grouped source select with the same groups
in the same order: 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 secondary details
(Agents use Details & history). 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:
an API catalog row keeps its explicit Native or Packet choice regardless of its
subagent id, while a session row retrieves with its native tools. Session Access
uses the catalog's existing select: Full system access (default), Working files,
or Read only. Writable reviewers check private copies; their outputs remain
evidence without product integration. Access never changes delivery. 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` (shared role editor)
- `web/room_work.css` (room work disclosure, rows and reading return)
- `web/style.css` regions paired by `design-system:migrated-begin` /
`design-system:migrated-end`: harness accounts; Dashboard → Updates (status
card, one action row, collapsed Recovery with one restore list) and Storage;
chat typography, foreground and status colour. Chat geometry retains local
literals per the viewport reserve contract; shared palette channels keep
translucent glass 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.
The bible's materials (objects, trays, sheets and the three capsule tones) reach
every page in both themes through the "Airy dopamine" blocks of ui.css, style.css and
the page sheets; that mapping is not a typography migration of those pages.
### 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.
**Startup & background.** Settings → Behavior owns host lifecycle choices, separate from the client-local Appearance controls. Sign-in startup is off until the owner opts in and always names the computer running Ouroboros, even in a remote client. OS registration is the truth: another copy, an OS-disabled entry and an unavailable app build stay visible, never a saved-settings guess. Automatic startup preserves Panic stops and saved pauses; an explicit owner start releases Panic. The second control, *keep Ouroboros running in the background when the window is closed*, is also off until chosen and lives in the host's settings. Until the owner decides, the first close of the desktop window asks once — Ouroboros will keep working in the background (tasks, schedules, Telegram): keep it running, or quit? — and the answer becomes that checkbox; dismissing the question quits. macOS labels its buttons Keep running and Quit; Windows keeps standard OK and Cancel, with a line saying OK keeps it running and Cancel quits. While it is on, closing hides the window behind a live indicator (the Windows notification-area icon; the macOS menu-bar item and Dock) whose menu shows the server's real state (work only from confirmed phases; starting, an error, unconfirmed activity or no answer said as such) and offers Open, Panic and Quit; if no indicator appears the close quits, so a hidden process always has a visible way back. Quit, Cmd+Q and signing out always quit, unasked. A sign-in start stays hidden only when both checkboxes are on and the indicator actually appeared; opening the app while it starts shows the window; there is no third "start minimized" setting. Linux shows the background control as not yet available; closing quits there. Android keeps its existing automatic boot entry by default, under the same Panic-preservation contract. The card is last on Behavior: set once; on a browser or Docker host it only explains itself. A control the host cannot offer says why as a plain note in meta ink (no warning dot); two controls with the same reason say it once, after both; a state the owner can change (another copy, an entry the OS turned off) is a warning, a failed read or write an error. Notes and statuses start at the checkbox label's text, a status dot hanging under the checkbox.
**Telegram completion messages are separate from these client alerts.** The
existing completion toggle Off suppresses every additional terminal line, failures
included; On sends endings, naming material problems before review provenance.
An otherwise finished task with only technical caveats says *finished*, without
a warning or success icon, and points to review and verification details in the
app. It never claims approval. Missing promised output, unresolved work, delivery
uncertainty and unmet or unknown review obligations remain material. This phone
projection never shortens full card reasons or rewrites outcomes. The status-only
Main mirror follows the toggle too, but preserved intermediate content does not.
Ordinary replies, files, questions and reply-editing Silent Mode remain independent. Unrecoverable old mirrors keep their text rather than guessing it is status-only. There is no new mode, cross-channel deduplication or retroactive rewrite.
**When the client runs.** Notifications are a property of a running client, and
there is no push channel. On the desktop the Telegram bridge runs inside the same
server, so it reaches the owner while the window is closed only in background
mode; quitting ends it together with the server.
**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 in its room or as a Main notice, or an optional question) |
| A note Ouroboros left for this moment, or a granted skill's notice (same toggle) | LLM-first; owner grant | when it wrote the note, Ouroboros chose to leave these words for this time; a skill speaks only under the owner's `notify_owner` grant; the host only shows the signed `reminder`/`skill_notice` row |
| 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.
**System notifications first.** Where the desktop app offers them, an alert is
handed to the operating system's own notifications: sent even while the window
is in front, titled with the kind of event, sounding with the system's own sound,
and kept where that system keeps notifications. The system taking it is not a
promise that anyone saw a banner: its notification settings, Focus and Do Not
Disturb still decide. macOS uses `UNUserNotificationCenter`, whose one permission
question is asked only from the owner's gesture — switching notifications on, or
Test — never by an alert, which falls back while the question is unasked; Windows
shows a notification-area balloon, which Windows 10/11 present as a system
notification, on an icon of its own per notification so that each click opens
its own source; Linux uses the freedesktop notification service when
the desktop runs one. Not yet allowed, denied, unavailable or failed is a typed
answer, never silence (an error the system returns instead of asking is that
error, not a denial): that alert falls back to the browser banner or the in-app
toast. One the system took without answering in time may still appear, so
nothing else is shown for it and its click still opens its source. In-app
updates do not replace the desktop app, so an app built before this capability
falls back and Settings says that system notifications need the current app.
Android's host keeps its own channel for its own service; its page has no system
notifications and says so.
**One sound.** At most one sound per event. A system notification owns its
sound: the operating system plays it under its own volume and Do Not Disturb,
and nothing else plays one beside it. Without one, where the browser shows a
banner, the banner 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: Sound off sends a system notification silently, a Windows balloon
included (the shell's own balloon call with `NIIF_NOSOUND`). If that call is
unavailable, a silent request falls back to the page without a balloon; the
WinForms fallback is used only when sound was requested, and since it answers
nothing, its balloon counts as unconfirmed. An app built before system
notifications cannot silence the balloon it shows while its window is hidden,
so with Sound off the page does not ask that app at all: the alert is a silent
browser banner or in-app toast, without its balloon, Dock mark or window raise.
**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. Project identity comes from the full registry, including rooms
outside the sidebar. An unavailable destination leaves the view in place and says
so. A Main-transport notice opens Main, including Telegram; numeric addresses
never imply a Project. A system notification first opens the window, hidden or not,
then the same source. No reply is composed from the banner. The one exception is
the fallback banner the desktop indicator shows while the window is hidden in
background mode (Windows, when no system notification was shown): its click
opens the window as it was left and does not navigate.
**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 Telegram escalation, and no notification or attention after the
application quits. When no system notification was shown and the packaged
desktop launcher exposes its optional `request_attention` bridge, a live
notification may raise a visible window and ask the operating system for one
standard sound. A window the owner hid in background mode is never raised by an
alert: Windows shows a banner from its notification-area icon, macOS marks and
bounces the Dock icon with the system sound, and the window opens from that
banner, the indicator or the Dock. That holds with browser notifications allowed
too: the page asks the launcher before showing a browser banner, whose click
could not bring a hidden window back; a visible window keeps the browser banner,
which owns the sound. These are native attention cues, 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, and in the desktop app it is the
gesture that lets the system ask. The status line names the surface this client
is using — the system's notifications, a browser banner or the in-app toast —
keeps the system's permission apart from what became of the last alert (taken,
unconfirmed or fallen back), and, in an older desktop app, says that system
notifications need the current app.
**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.
- A Windows balloon stays in Windows' notification list only while its icon is
in the notification area: until it is clicked, until a fourth retires the
oldest of three, or until the app quits. One that timed out or was dismissed
keeps its icon; nothing is shown again.
- An alert the system took without confirming in time can stay unseen: it is
not repeated in the app, and Settings says so. On Linux its click cannot open
the source, because the server's id for it never came back.
- A system notification clicked after its page reloaded only opens the window:
click targets live as long as the page that sent them, with no stored ledger.
- What a system notification looks like is the system's choice: macOS's style
for Ouroboros (banners, alerts or none), Focus and Do Not Disturb, Windows'
notification settings, and a Linux server's optional actions (without them a
click cannot open the source) and sound (without it the alert is silent);
Settings names what that server lacks.