ouroboros/docs/DESIGN.md
Ouroboros 0264abb302 Docs: relays quote every passed-on message; Stop stays on a moved work's portal
DESIGN's Replies paragraph says a relay shows each owner message it
passes on, a captionless file by its name, and that Telegram sends the
same as plain text; the client contract names the relay's quotes list;
architecture chapter 03 no longer says Cancel is withheld from a moved
(converted) work's Main card.
2026-10-16 23:12:00 +03:00

134 KiB
Raw Blame History

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.

Visual intent: airy, not faint: hierarchy, rhythm and light material, not paler or smaller text. Colour identifies at reading contrast; fill conveys importance. Identity marks stay subordinate; pressable looks belong to destinations.

The shell offers Settings → Appearance → Light / Dark / System. New clients start on System; explicit Light or Dark remains pinned. The shared semantic palettes in web/ui.css preserve geometry and status meanings. Light uses white reading surfaces, dark text and an opaque header, with decorative matrix hidden. 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.

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; interactive control labels
--text-meta Real secondary content: labels, notes, hints, meta lines
--text-secondary A quieter step below meta, for a dense repeated field
--text-disabled Genuinely inert or incidental content only

--text-muted is a legacy alias of --text-disabled; new work names --text-meta or --text-disabled so the intent is readable in the diff.

--text-secondary is a real fourth step, not an alias: it sits between meta and disabled and is written at ~50 call sites. Reach for it only when --text-meta is genuinely too loud — a value repeated on every row of a dense list, a chip's supporting word — and --text-disabled would be unreadable. The criterion is the same one that governs the whole table: if the owner has to read it to act, it is --text-meta or brighter. A load-bearing caveat, a one-off note, a hint that explains a control: those stay at meta. A tone or an unclassifiable ink is what produced the original "too much small high-contrast white text" report from the opposite direction, so a rule that declares a size and no colour is still the worst of the options.

Two failure modes this table exists to prevent, both observed in this codebase:

  • Secondary content parked at the disabled foreground. A load-bearing caveat rendered at --text-disabled reads as greyed-out chrome and gets skipped. If the owner is meant to read it, it is --text-meta.
  • Secondary content with no foreground at all, inheriting --text-primary. This is the loudest of all failures because it is invisible in the CSS — the rule simply declares a size and says nothing about colour.

Brand accent

The brand red is one value, --accent. Every appearance of it is either that token, the named roles built on it (--accent-light for text on dark, --accent-dim for a fill, --focus-accent-border / --focus-accent-ring for focus), or a rung of the accent alpha ladder (--accent-04 … --accent-65). A new alpha is a rung added to the ladder, never an rgba() literal in a rule: the ladder is what makes "make the accent calmer" a one-line change instead of a grep.

The app and first-run wizard consume the same palette source. A shared accent change updates its named roles and alpha ladder there; neither page shadows those roles with its own values. tests/test_web_typography_static.py checks the actual stylesheet links, nonempty shared roles and per-document variable resolution. The SM1 browser oracle also checks both rendered documents after restart, so a missing link or a page-local override cannot pass as consistency. A wizard-only layout token is fine; a second value for a shared role is not.

Focus

Keyboard focus has one appearance:

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

Two controls in the composer's pill row write a global owner setting rather than a fact about the next message: the context mode and the effort range. Both save at once through their owner endpoint, show the server's refusal as a toast and read the one saved value back from /api/state, so Main and every Project composer agree; Settings has no effort section.

Each is a capsule of the pills' height — ring, name, value: Context Max, Effort X-High. Closed, Effort names only the recommended level; its ring (a 300° arc: band minimum..maximum, dot recommended) 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 (lowering only while idle, outside Cyber Pro). Effort's: seven levels, two brackets, the recommended pill. A segment is its word plus --capsule-seg-pad a side; the pill covers its segment; band, pill and brackets share one vertical inset. Every handle pushes the ones it meets, a pushed bracket stays, the brackets never cross. A tap inside the range sets the recommended level, outside it the nearer bracket; a drag keeps the 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 a pending one so the next root starts at that level; 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 happens to sit 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, while 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 that a model has read it.

Status, owner action, and urgent notification are separate product concepts:

  • Status states a fact about the affected object. It does not imply that the owner can or must act. Task status uses one factual word family: Working, Done, Done with warnings, Failed, Cancelled. The same five words are the host's durable label vocabulary for its own task rows in Main and the Project thread (OUTCOME_PHASE_HEADLINE), not only the browser's.
  • Owner action exists only when the responsible domain exposes a current concrete continuation, such as Resume, Retry, Connect, Repair, Grant access, or Restart. The action is a real adjacent control; severity alone never manufactures one.
  • Urgent notification is a rare, time-sensitive interruption. It uses the product's explicit incident/notification seam, not a red status or a failed task as a proxy.

Activity schedule rows use the same factual status rule: active, disabled, suppressed, 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 even when its task succeeded or failed; the status never implies a result. Retained rows — consumed and suppressed — collapse into one disclosure rather than padding the standing list or disappearing: history the owner can still open, read and act on. A suppressed skill row keeps Restore so the owner can ask for it back; a consumed one keeps only Delete, because offering Enable on a schedule that cannot fire again would be a lie. A 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, never gates or replaces the card's current action, and forgetting it is an explicit local choice rather than the way to unlock one.

A task-bound Reviews history row may be the only retained fact for its owner. That row keeps a neutral owner anchor visible, but hides task status and typing until a real task status or activity arrives; review presence alone never means Working, Done, or owner attention.

A review that was only awaited when its task ended is not a warning. Reviewers that had simply not answered yet leave the task Done. For a plan review the task result keeps the host's typed disclosure that the review was still open (the terminal_host_notice field, read by the CLI and by parents), the card states the same fact as its cause sentence, and no second chat bubble carries it; a card that is amber or red for another reason keeps that reason, with the open review stated beside it when the result records it. For task acceptance the host's decision sentence already says that no reviewer verdict was established. A real outcome keeps its word: no reviewer quorum, a failed, refused or unresolved slot, a collected blocking finding, a reviewer verdict nobody closed, a rail or a blocking exit still read Done with warnings or Failed.

A host fact about a task is a row of that task's card, never a standalone bubble beside it. A reviewer panel that settles after its task already ended adds one System row naming the 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. That row lands inside the finished card (its Reviews group carries the note, the timeline keeps the row) without changing the card's chip, title or meta, and a standalone row appears only when the task has no card record in the page. Saving and showing late criticism does not replace passing it to Ouroboros for consideration: 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 remain inspectable in details and Logs, but do not relabel the whole still-working task. A failed child keeps a compact factual Failed marker inside its parent while the root continues under its own authoritative status. Internal reason codes belong in details and diagnostics, not compact headlines. Where a card does show a cause, it says it in the owner's words while the record keeps the machine code; a cause with no sentence yet stays raw rather than borrowing a wrong one. The 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 on 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, its own 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 rather than hidden: a short labelled excerpt beside the pointer to the full copy, so a Failed card over applied work is never a bare headline and never names preserved bytes without a way to reach them. Where a stop receipt already carries the same text in the very chat the card is written to, the card keeps the label and the pointer alone; a card in another chat keeps the excerpt.

Role Foreground Background Border
Success / connected --status-ok-fg --status-ok-bg --status-ok-border
Warning / degraded --status-warn-fg --status-warn-bg --status-warn-border
Error / failed --status-error-fg --status-error-bg --status-error-border
Neutral / classification --status-neutral-fg --status-neutral-bg --status-neutral-border
  • Status renders as dot + text. The dot carries the state at a glance, so the sentence does not have to shout it in saturated colour and can sit at ordinary reading contrast.
  • A known outcome owns the status; an unfinished lifecycle stands beside it. When a task's outcome is already settled while post-task work still runs, the chip states that outcome from the five-word family and Finalizing… is a SECOND, quieter fact next to it — not a replacement, not a sixth status word, and never something the card title has to carry instead. The pair is one accessible name, so a screen reader hears the outcome and the hold together. An owner stop outranks the hold entirely and speaks for itself. 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: it is a tag, not an alarm. A tone value the code actually emits (muted) must have a rule; falling through to a default is how chips end up white.
  • Chips are --type-meta, not smaller, and are not uppercased.
  • --green / --amber / --red are the saturated hues, and they are for things that are not text: dots, switch tracks, progress. The --status-*-fg tints are for text on near-black; do not swap them. (There was also a --tone-ok / --tone-warn / --tone-danger alias family, plus --accent-task / --accent-system / --accent-user / --accent-project and --ui-tone-*. They were named here and referenced by nothing at all, so every surface kept inventing its own literal instead. They are gone; the vocabulary above is the whole vocabulary.)

Current completion and independent criticism are separate facts. An informed Advisory author finish may complete the current subject while its original review remains FAIL or DEGRADED; the old critic alone must not paint that completion Failed. Independent execution, artifact, verification or publication failures still apply. Blocking corrections saved without fresh approval and an explicit unfinished stop remain unaccepted; show the retained work and reason through the existing five-word status family and details, without inventing reviewer PASS or a new status badge.

The tone primitive

Two shapes carry a tone, and they are not interchangeable:

  • A status sentence — .ui-status[data-tone]. Foreground only, rendered as dot + text. Filling it would turn every inline status into a badge and make "connected" the loudest thing on the panel.
  • A status chip — .ui-chip[data-tone]. The full triple, because the chip is the status and has nothing else to carry it.

Surfaces whose tone is a class suffix rather than data-tone (.skills-status-*, .skills-badge-*, .toast-*, .marketplace-state-*, .chat-live-phase, .log-phase, .evo-runtime-pill, .widget-table-status, .widget-metric / .widget-callout) name the same tokens. A surface that paints only its edge (a card tinted by its state, a callout's left rule) takes the border and leaves its own background alone; a toast keeps its glass background, because a translucent status fill over live page content costs the text its contrast.

Adopting these tokens is applying the semantic status contract, which already governs every surface — it is not a token migration of those surfaces and does not move them into the migrated set in section 8.

Sidebar activity dots

Project navigation rows may carry the existing three 4px working dots (chat-live-typing, 3px gap) for the live active_chat_activities census. Working, Thinking and Finalizing are the only moving states, using the existing 1.4s bounce rhythm; Queued stays static at a quieter step. Budget-paused work, confirmed model access waits, required owner questions and Project/task scope verification waits are static amber, with resumed questions no longer waiting. A wait on the same producer row suppresses its working motion; an independent working row keeps motion, and the row's accessible name states both facts. Unknown or unconfirmed census state stays static and explicitly unavailable. The dots take the row's own foreground, like the status-sentence dot, so they never out-shout the title and follow its hover and selected ink; the wait is the one hue, and no row paints them in a saturated project colour of its own. In a Project row the dots and the unread dot each own a reserved trailing column, so both sit at one x across rows whether or not the other is present, whatever the name length, and while the sibling kebab is hovered, focused or has its menu open; a deleting row draws only Deleting… and keeps its census fact in the accessible name. The collapsed Projects header carries the aggregate dots beside its label, and an activity repaint preserves the existing row and menu nodes. The dots are separate from unread dots and never carry a counter, percent or text animation. Reduced-motion clients receive the same state without the bounce.

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 and are 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; this counter is not a guarantee of 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 insufficient 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 does not read 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 returning 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 on their own: every copy of a control can truthfully reuse a primitive while its callers still choose the words, the glyph and the class — that is how "take me to this Project" came to be drawn six ways. The rule is therefore about intents.

  • A reference points at an entity that exists in the product and goes there when pressed. A Project is referred to by the Project reference — the Projects glyph, the Project's name, ↗ — and by nothing else, wherever content points at it. 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 (an entry's status chip does). Its accessible name says in words what the glyph and the arrow say in pixels (Open project <name>), and 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); at the end of the owner's routed message's one receipt line ("Passed on to «work» in", "Started as a task in"), the same capsule; in a question head, a meta capsule. Long names: two lines in entry/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 — 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) — from a translation memory the install generated or imported, never from a dictionary in the repository. 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 rather than 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. The host does not rewrite, summarize or re-time them; 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:

"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: --ui-card-border, --ui-card-bg, --radius. Nested emphasis uses --ui-card-bg-soft, not a second border weight.
  • A section is: title (--type-section) → optional one-paragraph description (--type-body, --text-meta) → content → optional note (--type-meta, --text-meta). The description explains what the section decides; the note carries consequences and caveats.
  • 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's group: an attached surface continues that group's edges, 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 permits up to three. Empty activity reserves no band, and a duplicate title is not activity. Full narration and Reviews expand independently. The root keeps primary title ink at weight 500, children secondary ink at 400. Nested frames preserve real ancestry; their opaque secondary surface avoids accumulating translucent white tints at greater depth.
  • An executor label 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 dates are absolute local dates outside the answer's copyable body. 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 are compared 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 inside the app. Its card says Read; pressing it 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 as well.

  • 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 is dropped and 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. 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. 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 header has one status control: the header's status word (§4) in the shared task colours and dots. A filled chip has work to show; pressing it, or ↓/↑ on it, lists that room's work and only that room's: 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. Main's list continues its header control group, which becomes its lid; a Project's unfolds as a Shade under its bar. 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, and says the card's or census's own words: an outcome stays beside Finalizing…, an access wait is not an answer wait, unknown stays unknown. The name goes to the card, or opens details when the card is not loaded, finished work included; 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. 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 shows only a quiet Reviews N count at the start of its one action row, under the metadata line (the card's controls right-aligned on the same row), optionally with an active count; a collapsed nested child card the same way. It has no aggregate pass/fail alert, no synthesized verdict, and no review dollars.
  • Expanding Reviews reveals one row per currently admitted review group (Skill review, Plan review, or Task acceptance). Expanding a group reveals its ordered attempt rows. Group state and verdict remain domain-specific; one blocker never recolours the whole task card.
  • Start progress labels the frozen model/route/profile as requested; settlement reports that same slot's observed execution or says it was not reported. An API model sent in a request is not an independently observed provider label, and duplicate model slots remain distinct. No global last-run identity fills a gap.
  • Disclosure is user-owned. Review results, retries, failures, terminal task state, reconnect, and lazy-detail loading update content in place but never open or close the task, Reviews section, or group.
  • 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 is running: the working tone, in progress · k of n answered where the verdict token would sit, and each awaited reviewer listed as awaiting rather than unavailable, under no verdict word. Once the task has ended the same wave reads no verdict · k of n answered in the neutral tone. A reviewer whose window expired is unresolved. A settled wave whose reviewers were too few for a verdict reads no verdict · k of n answered · m unavailable in the neutral tone: the verdict word DEGRADED is the host's placeholder and never paints.
  • A task-acceptance panel whose reviewers may still answer never reads as a bare verdict token. While its own task is running it is work in progress: the working tone and in progress · k of n answered, or PASS so far · k of n answered once the quorum is met. Once the task has ended the same panel reads no verdict · k of n answered in the neutral tone, or PASS · k of n answered, until a late settlement replaces it. A reviewer FAIL keeps the error tone and reads FAIL · k of n answered in both.
  • On a plan wave and an acceptance panel alike, a slot that is neither answered nor awaited (a settled failure, an expired window, lost custody, a refusal) adds · m unavailable and keeps the warning tone beside the awaited slots; a settled plan wave with no awaited slot reads its no verdict line in the neutral tone (an acceptance panel with none reads the same line but keeps the warning tone; its stored DEGRADED stays 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 stays 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 and otherwise say Cost unavailable; attempt detail states exact accounting when the domain can prove it and otherwise says Cost unavailable. Collapsed rows never show dollars.
  • Harness marks are monochrome currentColor vectors with adjacent visible text. They carry identity only, remain neutral across status states, and use a generic text-preserving fallback for unknown harnesses; direct API is shown neutrally as API.
  • Vector provenance: Claude, Cursor, and OpenCode paths come from Simple Icons; Codex/OpenAI comes from SVGL. Product names and marks remain the property of their owners.

Quiz card

The owner quiz card (web/modules/chat_decision.js, .chat-quiz-* in web/style.css) is a chat-delivered decision surface. Optional clarification is fire-and-continue: the task states its assumption. Required waiting keeps the same card and explicitly says that the task awaits the owner, with Stop and existing task deadlines still effective; an optional bound on that wait resumes the task with a host notice and leaves the card open, and that notice says the same thing the card does — with a stated assumption the task proceeds under it, and without one, no answer is explicitly not consent. Silence is never an answer on either surface. A card outlives its asking task: after the task finishes, the owner can still answer, and the answer arrives as their own message in that chat. After settlement, status and the owner's recorded answer keep both forms readable. Anatomy, top to bottom:

  1. Head — neutral Question chip (--type-meta, neutral pair) and a status as dot + text. The lifecycle word family is closed and leads with the one word that answers "is there an unanswered question for me?": Waiting for your answer needs positive wait evidence (the task's live wait record, or the original required flag before any record exists); a resumed wait — 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. 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. It may contain paragraphs, lists, checklists, tables and code; those blocks keep the shared rich-content gutter, rhythm and bounded code scrolling. The card does not infer a title from the first line or rewrite authored Markdown to make it fit, and the question has no quiz-specific length cap. Directly under it, a muted plain-text host line (.chat-quiz-host-facts, --type-meta, --text-meta) states what only the host knows: the asking task, how its run started and when the owner last wrote in this chat, with unknown facts said as unknown; the line is absent when the card carries no host_facts.
  3. Stake — optional one-liner (At stake: …), --type-meta, --text-meta.
  4. Options — zero to six real owner actions: buttons with --text-primary labels, legible at rest; an optional per-option detail steps down to meta ink. After settlement buttons drop to --text-disabled; the chosen option keeps the ok pair. Options are capped by the shared Python↔JS constant (MAX_QUIZ_OPTIONS); 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…) with a Send my answer button, enabled 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. A settled card instead carries what the owner said as a second primary line (Owner's answer: …, --type-body, --text-primary) under the options — beside the highlighted option when one was chosen, and as the whole answer when none was. Its line breaks stay visible; the recorded answer remains literal text.
  6. Assumption or waiting — the signature line (Continuing meanwhile: … for optional clarification, an explicit waiting statement for required input), --type-meta, --text-meta, separated by a hairline. While the card is open it names the default path. The optional assumption remains after settlement as the record of work continued meanwhile. Required waiting copy disappears when the card settles; the status and recorded owner answer remain.

The card 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 that lives 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 and is never a second entry. The card becomes 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) and one action row [Reply] [Resume] [Stop…] with one primary, 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 the portal of that work 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 inside task details and Logs. 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, grouping repeated reasons with their counts. Logs Raw retains the complete source/failure facts; 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)" — the echo carries the same mark), 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 puts the words back in the composer as a new message and sends nothing: 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. A question card that still takes an answer 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, 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 one receipt line naming the work and its room. 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 and focus returns 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, its collapsed activity line and its timeline carry the turn's own narration — the progress frames the model itself authored, marked by the typed narration fact the worker stamps at that one producer (a frame without the fact is a legacy frame and is read as narration) — and whatever needs the owner's eyes: a failed or timed-out step, a wait, a review, a child. What the host says about how the turn is running (a checkpoint, a model fallback, a review verdict, a nudge) is a visible timeline row that never claims the title or the collapsed line, so a turn whose only notes were the host's keeps its coined or task name and an empty activity line. When the task ends with a cause, the collapsed line states that cause in the owner's words — the same sentence as the durable row and the expanded body — so a Done with warnings chip never stands beside an unrelated last action; a clean ending keeps the last narration. Successful tool calls are not rows at all: they fold into ONE evidence row per block — N tool calls, or N tool calls · M errors once a call failed — that stands at the first call's position and time and is patched in place; Expand shows the per-tool counts (read_file ×3 · web_search); its phase is calling while a tracked call is still running, warn once a call failed, result otherwise, and the row says that phase in ink rather than in extra words. A failed or timed-out call keeps its own diagnostic row and counts once. A completed check command whose recorded criterion did not pass keeps a separate expandable warning, “Check did not pass: …”. Its first sentence distinguishes the check process from its criterion; the details name the check and criterion and disclose bounded output. It adds no call error or task failure. Live, post-terminal and replay use the same invocation-bound row; a later passing check does not erase the earlier failure. 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 facts: late success retires the provisional timeout notice but keeps “wait ended” in the evidence row; late failure keeps its operation error. Either arrival order produces the same outcome. A historical start alone means outcome unknown, never Running or Failed. web/modules/chat_activity.js::toolEvidenceView builds the same row live and on replay. Host invocation IDs join start/wait/settlement; legacy observations without sufficient identity remain separate even when names and arguments match. 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 not proof of success. Start-only (live or legacy) and incomplete replay evidence preserve 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 both root and child cards, preserving 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 work moved into a Project lives there; Main keeps its card as 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 have distinct rows. 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 minus a bare terminal outcome), never the lane that ran the turn (owner decision 16.09: real work is a task card, a greeting is nothing). A block with work — a review group, a child card, an evidence or narration row, a tool error — is the task card whether a managed root or a direct conversation turn produced it: a title (the coined name or the latest narration headline; a finished card with neither reads Task activity, a running one stays untitled because its status 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 the card's 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 is the conversion of the whole work. A block that exists only for open attention — a model wait, a pending or host-offered Stop — or only for a non-Done ending of a turn that did no work carries no title placeholder and no conversion; its chip says the state it is in (Waiting…, Cancelling…, Failed), the wait controls stay, and its first row of work gives it the title. The collapsed header carries the tool count live and, once the turn ends, cost and duration (a replayed header carries the count and cost; duration is a live fact); its updated stamp follows the turn's own narration, never a host note and never a tool call. Money on that header is ONE line whose wording carries its 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 still inexact, Tracked: $1.20 when part of the same scope has no price at all (Tracked: up to $1.20 when that subtotal itself is inexact), and Cost unknown for unpriced rows with no tracked amount. An empty, intact ledger has no money line; an empty view with an integrity gap stays unknown. Neither proves a free result. The 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 whose only reason to exist was open attention leaves when that attention closes: a wait-only block disappears when the wait resolves, and the resolved episode's no-reopen ledger survives with the record, so a stale revision cannot bring the block back.

An addressing call (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 use the same receipt semantics through the host's completion_control fact and completion_tool_calls aggregate. They keep their actual call count without manufacturing work, and 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. It stays in the issuing chat, keyed to the named target. The Project start row is announced only once the task is really queued. A refusal receipt with neither options nor a cause sentence reads «Not routed», never «Choose a target». No client list of tool names decides presence (docs/development/02-naming-and-boundaries.md, "an open default behind a closed exception list"), and no client reading of a note's text decides whether it is narration. A change that draws one row or line per event is judged at a realistic burst size — a multi-call turn, collapsed and expanded, at desktop and phone width — never at a two-event fixture.

Subscription waits inside task cards

Quota exhaustion and a confirmed need to sign in again use the same component, model_wait.js with model_wait.css, inside the turn's existing host: the task card of a turn that has done work, the bare block of a turn that has not. Each waiting role has its own row; the model, account and reason are separate facts. The controls stay visible when the task's timeline is collapsed. Waiting carries a quiet warning status and no computation animation, activity counter or invented progress. Completed steps remain intact; a held worker slot and its effect on the queue are stated only when the host reports that fact.

The quota row offers automatic continuation, initially enabled, and shows a known reset time or an explicit unknown. An authentication row instead offers the existing Accounts sign-in flow. Both can open Settings, retry explicitly, or use the shared model-role editor to choose a replacement model/account. The replacement affects the named waiting role until its task or review operation ends; an unchecked "Also save this role in Settings" checkbox separately requests persistence. Fallback Local remains shared by its Settings group. Changing Local for one waiting fallback makes the replacement task-only: the persistence checkbox is cleared and disabled with an explanation, while Apply remains available. Model and account changes can still be saved when the shared Local value is unchanged. Permanent group-wide Local changes remain in Models.

A pool confirmed to contain both accounts requiring sign-in and accounts waiting for quota says "Waiting for access" and names both causes. It keeps automatic continuation and opens Accounts without selecting a profile or initiating login. 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. Waiting chips do not pulse or show typing. Closing the chat disposes view resources without claiming that it stopped the task; continuation requires the Ouroboros process to remain running.

A consciousness wake-up needs nothing of its own here. It is an ordinary direct turn with its own task id, so its model wait, its controls and its temporary model change behave exactly as an owner turn's and last until that turn ends; the persistence checkbox still saves the consciousness role. What identifies the turn is the origin label Consciousness in the block's meta line, on its final bubble and on the cards of tasks it started — never a separate card, a reused slot or a different vocabulary.

An already-delivered answer does not close a still-open post-task synthesis. 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 the way a Pinterest board is: 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 the voids of a row layout. The board decides how many columns it has from the list's width and from the widths its cards ask for. A board where the owner has 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. A control rendered at secondary ink reads as disabled; if the owner can click it, it is --text-primary.

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.

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.