Merge pull request #1590 from josephsteuerjr/win/dwm-dark-frame

windows: dark DWM title bar for the pywebview 5.4 shell (#1417)
This commit is contained in:
Ouroboros 2026-10-08 23:35:05 +03:00 • committed by GitHub
commit e010c2095d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1461 additions and 100 deletions

View file

@ -156,7 +156,7 @@ diff, but it is not loaded as a standalone checklist the way the Change Review
| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. |
| 10 | Changing `ouroboros/tools/commit_gate.py`? | Coupled surfaces that MUST be updated atomically in the same commit: (a) `claude_advisory_review.py::get_tools()` tool description for `preflight_review` and `review_status`; (b) `claude_advisory_review.py::_next_step_guidance()` strings; (c) `docs/DEVELOPMENT.md` Review & Commit Protocol section; (d) the `prompts/SYSTEM.md` Self-Modification section IF the commit-gate rule it states changed. Missing any one has blocked review. |
| 11 | Changing VERSION + pyproject.toml? | Ordering matters: (1) write `VERSION`, `pyproject.toml`, `uv.lock`, `web/package.json` and `web/modules/api_types.js` first; (2) then write the `README.md` badge + changelog row + download links, both install pages' download links, and the `docs/ARCHITECTURE.md` header; (3) then run `pytest`. Never interleave — updating README before VERSION means `test_version_in_readme` will catch a stale badge. |
| 12 | Writing or editing any JS file under `web/modules/`? | New or changed static inline visual properties are blocked: inspect the diff for added/changed `style=""` markup and `.style.<property>` assignments, and use CSS classes/tokens plus `classList`/`hidden` instead. Unchanged legacy hits are debt, not a blocker. A dynamic measured value may update a narrowly named CSS custom property when that is the actual runtime data flow. |
| 12 | Writing or editing any JS file under `web/modules/`? | New or changed static inline visual properties are blocked: inspect the diff for added/changed `style=""` markup and `.style.<property>` assignments, and use CSS classes/tokens plus `classList`/`hidden` instead. Unchanged legacy hits are debt, not a blocker. A dynamic measured value, or a renderer's count of what it rendered, may update a narrowly named CSS custom property when that is the actual runtime data flow. |
| 13 | Changing LLM output-token budgets? | Grep the whole repo for `max_tokens`, `max_completion_tokens`, `_MAX_TOKENS`, and `max_toks`. Keep `docs/ARCHITECTURE.md` §LLM output token budgets and `tests/test_max_tokens_constants.py` in sync so main-loop, VLM, summaries, compaction, skill publish, and consciousness floors cannot drift independently. |
| 14 | Changing extension loader/dispatch or isolated deps? | Native-risk extension imports and tool/route/WS handlers must stay out-of-process. Add or run regression tests where a native-risk plugin aborts during import and the host survives, plus tool/route child-dispatch tests. Do not "fix" failures by importing native-risk plugin code in `server.py`. |
| 15 | Changing `supervisor/git_ops.py`, `launcher.py`, `server.py`, `ouroboros/tools/review_helpers.py`, `ouroboros/tools/git.py`, tests, or evolution scheduling/checkpoint code? | Prove two invariants before review spend: (1) pytest/preflight cannot mutate the live repo or live `data/` (`OUROBOROS_DATA_DIR` / `OUROBOROS_SETTINGS_PATH` must be isolated, and `OUROBOROS_MANAGED_BY_LAUNCHER` must not leak into test subprocesses); (2) autonomous restart/reset cannot erase active evolution work — it must either land a reviewed local commit or preserve a rescue/transaction recovery pointer and pause/stop the campaign. |

View file

@ -20,6 +20,9 @@ The shell offers **Settings → Appearance → Light / Dark / System**. New clie
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
@ -196,6 +199,12 @@ page closes the reveal, and a late read cannot reopen it. Built-in, custom,
skill-requested and MCP token 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 choice (Same as Task / Chat) is
as real as any other, and the focused choice wears the one focus ring.
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

View file

@ -25,10 +25,10 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t
| D15 | Memory, knowledge, consciousness & self-evolution | 28 | 0 |
| D16 | Observability, usage accounting & cost | 16 | 0 |
| D17 | Projects, workspaces & task results | 30 | 0 |
| D18 | Launcher, packaging, platform & shared substrate | 22 | 0 |
| D18 | Launcher, packaging, platform & shared substrate | 23 | 0 |
| D19 | Frozen contracts (ABI) | 11 | 0 |
| D20 | Presence | 12 | 0 |
| **total** | | **670** | **0** |
| **total** | | **671** | **0** |
## Dependency direction matrix (strict, pinned)
@ -878,6 +878,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne
- `ouroboros/desktop_autostart.py`
- `ouroboros/desktop_notifications.py`
- `ouroboros/jsonl_tail.py`
- `ouroboros/launcher_appearance.py`
- `ouroboros/launcher_background.py`
- `ouroboros/launcher_bootstrap.py`
- `ouroboros/launcher_server_reaper.py`

View file

@ -15,7 +15,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── web/ ← Web UI (SPA with ES modules in web/modules/; §3)
│ ├── ui.css + modules/ui_primitives.js ← The one palette/control stylesheet for the SPA, onboarding and optional author pages; self-contained safe-field, escaping and tone/status functions (§3 Navigation and shared UI contracts)
│ ├── modules/i18n.js, settings_language.js, ui_i18n_types.js ← The interface language in the browser: a DOM overlay rewrites rendered chrome (text, `title`/`placeholder`/`aria-label`, `<option>` labels) from the install's translation memory served by `GET /api/ui/i18n`, keyed by the English source plus an optional DOM scope; `tr(code, english)` and `fmt(template, params)` are the seams host sentences and composed strings read at the producer, so chat prose, logs, code and owner-supplied names are never walked; an unknown string stays English and is reported to `POST /api/ui/i18n/missing`; `settings_language.js` is the Appearance control (§3 Settings; §7 `OUROBOROS_UI_LANGUAGE`)
│ ├── modules/page_header.js, ui_interactions.js, scroll_fade.js ← Header/tab-strip binder; dialog-focus, menu and popup-geometry binders; overflow-driven scroll-edge fade — each with owned teardown (§3 Navigation and shared UI contracts)
│ ├── modules/page_header.js, ui_interactions.js, scroll_fade.js ← Header/tab-strip binder and the segmented-choice generator; dialog-focus, menu and popup-geometry binders; overflow-driven scroll-edge fade — each with owned teardown (§3 Navigation and shared UI contracts)
│ ├── modules/chat_decision.js, question_presentation.js, chat_render_batch.js, task_phase_chip.js, lifecycle_card.js ← Chat helpers: the typed decision (quiz) cards, one form for a Project question in its room and in its Main mirror, with one pure lifecycle projection shared by both (its Python twin: `project_dialogue.QUESTION_STATUS`); keyed timeline items, DOM patches, history-control presentation and reading anchors; pure desired-phase chip projection where terminal truth wins; skill lifecycle card state with best-effort polling
│ ├── modules/chat_history.js, chat_history_replay.js, chat_reading_position.js ← Bounded per-chat pages with exact return handles; source-keyed replay without live-task authority; one reading intent (§3 Timeline ownership and ordering)
│ ├── modules/delegated_activity.js ← Run/seq-based activity projection, Unicode fragment cuts, explicit preview gaps and retained-source links (§3 Child cards and executor presentation)
@ -332,12 +332,13 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── subscription_install_presets.py ← Pure sibling install compilers from one normalized draft + one discovery snapshot; output is linear, unpinned, exact-discovery-backed, all-or-nothing (§2)
├── settings_setup_contract.py ← SSOT for the setup contract, derived bootstrap state, payload validation, and the `TOTAL_BUDGET` resolver authority `resolve_total_budget_usd`
├── owner_mailbox.py ← Per-task user message mailbox (compat module name); revocation-aware drain and proven-empty peek; the closed task-message provenance set (`ancestor_task`, `peer_via_ancestor`, `system`, `descendant_task`, `independent_task`, `peer_task`)
├── peer_roster.py ← Host-listed roots from queue_snapshot/direct_roots (hidden included); forward_to_worker also admits source-bound inline Presence mailboxes without adding them to roots (§12). [INDEPENDENT_ROOTS] TAIL: 40 rows, cut disclosed, recorded focus/start/origin/waits; paginated live_roots (§6 Owner routing verbs)
├── peer_roster.py ← Host-listed roots from queue_snapshot/direct_roots (hidden included); forward_to_worker also admits source-bound inline Presence mailboxes without adding them to roots (§12). [INDEPENDENT_ROOTS] TAIL: 40 rows, cut disclosed, recorded focus/start/origin/waits named by their record (owner, review, model, sleep, owner Pause, budget, unknown); paginated live_roots (§6 Owner routing verbs)
├── launcher_bootstrap.py ← Bundle-to-repo bootstrap, launch-option parsing, managed sync and selected native-host artifact synchronization (used by launcher.py; §2)
├── launcher_onboarding.py ← First-run onboarding as the desktop launcher presents it (serves the gateway /onboarding page; §2)
├── launcher_server_reaper.py ← POSIX same-install server discovery, pre-signal descendant capture, root-first termination, live identity revalidation; PID-lock-owning launcher only (Runtime topology below)
├── launcher_windows_runtime.py ← Windows-only pythonnet/pywebview runtime preparation
├── launcher_background.py ← Desktop background mode: close vs quit, the one consent question, quiet start, the way back, second-launch activation (§9); `DesktopApi`, the bridge's alert half `MainApi` inherits: attention cue, `shell_info`, system notifications (§3)
├── launcher_background.py ← Desktop background mode: close vs quit, the one consent question, quiet start, the way back, second-launch activation (§9); `DesktopApi`, the bridge's alert half `MainApi` inherits: attention cue, `shell_info`, system notifications, caption tint (§3)
├── launcher_appearance.py ← A desktop window's native Windows caption takes the light/dark tint of its page's painted palette: `DWMWA_USE_IMMERSIVE_DARK_MODE` on its own handle from `before_show`, writes marshalled to the form's UI thread, newest request of the newest page, High Contrast cleared (§3)
├── launcher_tray.py, launcher_tray_macos.py ← Its indicators: Windows notification-area icon on an STA thread (FormClosing sees the close reason); macOS menu-bar item, Dock reopen, quit marking (§9)
├── desktop_notifications.py ← System notifications for the desktop window: macOS `UNUserNotificationCenter`, Windows balloon on one icon per notification until its click (`launcher_tray.NotificationIcon`), Linux freedesktop D-Bus; submitted/unknown/typed refusal, click token back to the page (§3, DESIGN §9)
├── desktop_autostart.py, windows_autostart.py ← Host sign-in adapter table; packaged-launcher gate, Windows registry, macOS LaunchAgent and Linux systemd/XDG state (Runtime topology below)

View file

@ -4,7 +4,7 @@ Shared pages, frontend contracts and runtime truth serve desktop, browser, Docke
The Web UI is a build-free vanilla-JavaScript SPA (`web/index.html`, shared CSS, `web/modules/*`) — no TypeScript or bundler step, so the running interface stays inspectable and editable by Ouroboros without regenerating opaque artifacts. `web/app.js` owns top-level page, Project-panel and mobile-navigation state; feature modules own their presentation; every long-lived UI acquisition carries a disposer bound to the lifecycle that created it (DEVELOPMENT "UI resources carry a disposer"). The same SPA serves every surface: the Linux browser fallback changes presentation only — no second API, onboarding contract, runtime identity or state owner — and the browser stays the owner's application outside process custody (§1). One shared WebSocket serves the whole application; Projects open no independent sockets, and REST remains the recovery and durable-read path (protocol and connection order: the WebSocket protocol subsection of §4).
The desktop shell exposes a small `MainApi` JS bridge (`window.pywebview.api`, `launcher.py`): three native confirmation methods (runtime mode, reviewed-skill auto-grant, skill key grant); the alert half inherited from `launcher_background.DesktopApi` — `request_attention`, also named `notify_owner` for pages that pass the alert's title and text (raise a visible window and request one platform system sound, returning explicit `native_sound`/`window_only`/`unsupported`/`unavailable` facts; a window hidden in background mode is never raised: a banner or Dock cue answers `background`; a page about to show its own browser banner asks first with a last `false`, and a visible window answers `visible` with no cue), `shell_info` (the app's own version, persistent storage and system-notification permission), `request_native_notifications` and `show_native_notification` (one `desktop_notifications.py` notification: `submitted`, `unknown` — handed over, unanswered — or a typed `not_determined`/`denied`/`unavailable`/`failed`; its click shows the window and calls `window.ouroNotifications.activate(token)`) — `download_file_to_downloads`, `open_file_with_default_app`, `open_external_url` (absolute http(s)/mailto) and `save_bytes_to_downloads` for live base64 payloads; the loopback-file methods share one guard — loopback host, exact server port, and a path allowlist of `/api/files/download`, `/api/extensions/...` and `/api/tasks/...`. For consistent native link and download handling, `ui_helpers.js` installs a shell-only link interceptor in BOTH top-level documents (SPA and framed onboarding wizard) when the bridge is present, routing each URL class to the matching bridge method; methods are feature-detected per call, because the packaged launcher updates only on reinstall while the served frontend updates with the managed repo, and a missing method degrades to copy-link-plus-toast or the file-helper fallback chain. The separate first-run `OnboardingHostApi` exposes window completion and the same external opener, injected by `launcher.py`. Subscription sign-in cards call `openExternalViaHostBridge` during an ordinary click or keyboard activation; handled clicks are not opened again by the document interceptor, while browser modifier clicks remain native. The helper resolves desktop and Telegram capabilities from a same-origin parent for the framed wizard. The authenticated Telegram proxy's `X-Ouroboros-Telegram-MiniApp: 1` presentation marker makes `server_web.make_index_page` include the asynchronous Telegram SDK and host hint in the main document; an unavailable SDK reports a click failure and never replays it after loading, and the marker grants no authentication authority.
The desktop shell exposes a small `MainApi` JS bridge (`window.pywebview.api`, `launcher.py`): three native confirmation methods (runtime mode, reviewed-skill auto-grant, skill key grant); the alert half inherited from `launcher_background.DesktopApi` — `request_attention`, also named `notify_owner` for pages that pass the alert's title and text (raise a visible window and request one platform system sound, returning explicit `native_sound`/`window_only`/`unsupported`/`unavailable` facts; a window hidden in background mode is never raised: a banner or Dock cue answers `background`; a page about to show its own browser banner asks first with a last `false`, and a visible window answers `visible` with no cue), `shell_info` (the app's own version, persistent storage and system-notification permission), `request_native_notifications` and `show_native_notification` (one `desktop_notifications.py` notification: `submitted`, `unknown` — handed over, unanswered — or a typed `not_determined`/`denied`/`unavailable`/`failed`; its click shows the window and calls `window.ouroNotifications.activate(token)`) and `set_native_appearance` (the page's painted palette for the window's caption tint, below) — `download_file_to_downloads`, `open_file_with_default_app`, `open_external_url` (absolute http(s)/mailto) and `save_bytes_to_downloads` for live base64 payloads; the loopback-file methods share one guard — loopback host, exact server port, and a path allowlist of `/api/files/download`, `/api/extensions/...` and `/api/tasks/...`. For consistent native link and download handling, `ui_helpers.js` installs a shell-only link interceptor in BOTH top-level documents (SPA and framed onboarding wizard) when the bridge is present, routing each URL class to the matching bridge method; methods are feature-detected per call: the served frontend can update before the shell, and older packaged launchers require reinstall while newer ones can re-execute changed launcher source (§2), and a missing method degrades to copy-link-plus-toast or the file-helper fallback chain. The separate first-run `OnboardingHostApi` exposes window completion, the same external opener and its own window's `set_native_appearance`, injected by `launcher.py`. Subscription sign-in cards call `openExternalViaHostBridge` during an ordinary click or keyboard activation; handled clicks are not opened again by the document interceptor, while browser modifier clicks remain native. The helper resolves desktop and Telegram capabilities from a same-origin parent for the framed wizard. The authenticated Telegram proxy's `X-Ouroboros-Telegram-MiniApp: 1` presentation marker makes `server_web.make_index_page` include the asynchronous Telegram SDK and host hint in the main document; an unavailable SDK reports a click failure and never replays it after loading, and the marker grants no authentication authority.
### Navigation and shared UI contracts
@ -22,6 +22,33 @@ Settings calls `window.ouroTheme.mount()`. It owns no `s-` input, so Settings'
collector never posts it. `ouro:theme-changed` announces only a resolved palette
change; Dark → System on a dark OS does not churn mounted views.
On the Windows desktop the window's ordinary system caption takes the light or
dark tint of the painted palette (DESIGN, top). `theme.js` in a top-level document
— never a framed copy, which would speak for its parent's window — calls its own
window's `set_native_appearance(theme, page, sequence)` at boot, on
`pywebviewready`, on every painted change and when the back-forward cache restores
it. `page` is when the document became the window's page (`performance.timeOrigin`,
renewed on that restore), `sequence` its count of calls. Each bridge call runs on
its own worker thread, so `launcher_appearance.py` keeps only a request newer by
that pair: a replaced page's late call loses even when it is that page's first.
The leaf takes the handle in pywebview's synchronous `before_show`, whose write
precedes the first paint; every later write runs on the form's UI thread
(`BeginInvoke`) and repaints the shown frame. The frame starts from the window's
own background colour, clears `DWMWA_USE_IMMERSIVE_DARK_MODE` while High Contrast
is on (a system preference change re-applies the page's palette) and drops its
subscription when the window closes. There is no setting or native store.
The intended caption is the app's palette whatever Windows' own mode: the
attribute is TRUE exactly when the page paints dark. Microsoft's sources disagree
on whether Windows honours it under a light system mode: the attribute reference
says TRUE honours dark mode "when the dark mode system setting is enabled", while
on Microsoft Q&A (question 966330, 16 Aug 2022) a Microsoft engineer reproduced a
dark caption under a light system and left bug-or-documentation open. No native
Windows run has checked it: dark-on-light, Windows 10 and the pixels are
unproven, not excluded, and an accepted call is not proof of pixels. The
launcher's transient pages (already running, Git install, startup failure) are
outside it, and a desktop app without the bridge keeps its caption as before.
`theme_palette.js` translates `--chart-*` tokens into mounted Chart options and
updates in place without replacing data or caller-authored option groups.
`chat_markdown.js` parks Mermaid source before awaiting the library and carries
@ -74,11 +101,12 @@ there is no forced remount. Module intent is declared in `render.appearance`:
author, and `fixed` names a stable visual world; the field is advisory. Desktop
`webview.start(private_mode=False)` requests persistent website storage in
`launcher.py` and `launcher_onboarding.py`, including
cookies, not just appearance. Existing packaged launchers must be rebuilt and
installed to change that flag: pywebview 5.4's default private mode erases the
WebView's website data whenever a window opens (macOS: every data type of the
bundle's default store; Windows: a temporary profile; GTK: local storage off),
and in-app updates replace only the core. `desktop_shell.js` therefore discloses
cookies, not just appearance. Older packaged launchers without source re-execution
need a new desktop build to change that flag; newer ones can select updated
launcher source at restart (§2), not merely by loading new frontend assets.
That update path is not qualified here on native Windows. pywebview 5.4's private
mode erases website data when a window opens (macOS: all default-store data;
Windows: a temporary profile; GTK: local storage off). `desktop_shell.js` therefore discloses
an app before 7.2.0 in Settings → Appearance, from `shell_info()` or, for an app
without it, the launcher-stamped `/api/health` `app_version`. Profile identity, origin and platform storage
still govern retention: source tests do not certify a cold-launch result. When
@ -93,7 +121,7 @@ Each active or deleting Project has a sidebar row with pointer- and keyboard-ope
Shared primitives keep pages from acquiring competing contracts — frontend work must not reimplement supervisor, review, marketplace, extension and provider semantics per page:
- `web/ui.css` ← shared values and the explicit field/button/status/popup classes; `index.html` and `onboarding_template.html` load it before their page sheets, `style.css` keeps shell composition and tab chrome, and page-specific layout does not duplicate the palette (DESIGN §8 names the migrated families/regions).
- `page_header.js` / `page_icons.js` ← page headers, tab-strip markup and `bindTabStrip` (selected class, ARIA, roving keyboard focus, reveal within the strip; pages keep panel/loading ownership; silent `select()` never calls the callback); navigation/header icons.
- `page_header.js` / `page_icons.js` ← page headers, tab-strip markup and `bindTabStrip` (selected class, ARIA, roving keyboard focus, reveal within the strip; pages keep panel/loading ownership; silent `select()` never calls the callback); `renderSegmentedField`, the one segmented single-select, which writes the number of choices it rendered (`--segment-count`) for `settings.css` to lay out equal columns: up to four fill a row, a longer scale keeps four with a partial last row, a card of 20rem or less stacks two, the one-glyph cycle values stay one row; `settings_controls.bindEffortSegments` keeps an empty choice (inherit) as a real value; navigation/header icons.
- `ui_interactions.js` ← `bindDialogFocus`, `bindMenu`, geometry-only `bindPopoverPosition` and `bindEnterSubmit` (message fields: Enter presses Send): callers mount/portal and remove their own markup, measured `--ui-popup-*` values drive the fixed, viewport-bounded `.ui-popup`, every binding returns teardown, and there is no global overlay registry.
- `scroll_fade.js` ← `bindScrollFade` projects actually hidden content onto the scroll body's edge attributes, keeping a fully visible first/last edge readable, and owns its observers, listener and pending frame.
- `api_client.js` / `api_types.js` ← browser API calls with typed error propagation; mirrors of the browser-facing contract shapes.

View file

@ -107,7 +107,7 @@ A workspace task's completion compares against the captured preflight base (task
`promote_chat_to_task`, `route_to_project`, `steer_task` and `ensure_project_scope` ride one receipt rail: an act succeeds only once its token-matched supervisor facts are durable in the task result, queue snapshot, annotation or mailbox authority; among several possible tasks the LLM chooses, code auto-delivers only the unambiguous one-target case, and an unconfirmed or stale receipt fails visibly instead of launching a second root. Receipts are retained per `(owner message, routing token)`: an earlier act's receipt stays readable by its token (`chat_annotation_receipt`) while the message's latest row is the UI projection and the picker's liveness test. A KNOWN rejection returns `rejected` with its reason, never a timeout, so `UNCONFIRMED` keeps its one meaning: no matching receipt exists. A receipt proves admission, not completion; an unread indicator proves a visible revision, not memory isolation.
WHO is speaking is ONE host-minted fact on the event (`control_routing._routing_issuer`; the model has no argument): an OWNER TURN (stamped by the owner door: `is_direct_chat` and `run_origin.owner_ingress`) or a TASK speaking for itself — a promoted root (inheriting the stamp as ancestry, not issuer), a root relaying an owner message it just drained, or a consciousness wake-up, Presence event or direct-lane auto-resume template, which nobody typed (a wake's promotes mint consciousness roots inheriting its origin, ledger category and autonomy level). An owner turn's steer travels as owner text: `[Message from my human]`, the owner corpus, the generation bump superseding a reviewed answer, the issuing chat's registry-lane room veto (a Project room reaches its own roots, Main every host-listed root) and the owner acknowledgement. A task's own words NEVER travel as owner text: the task-message writer shared with `forward_to_worker` sends them as `independent_task` provenance to any host-listed active independent root (through `forward_to_worker` a root inside the sender's own tree is a `peer_task` recipient instead; `steer_task` keeps `independent_task`; hidden roots included, no room veto; a Presence sender only its own binding's work, chapter 12), rendered `[Message from independent task <id>]`, with no owner corpus (`owner_source_sha256` and the acceptance premises stay the owner's) and no attachments, confirmed WRITTEN or refused with the host's reason. A relay keys/publishes its acknowledgement on the drained owner message; other task acts use their own synthetic receipt id without chat acknowledgement. Neither grants owner authority; author and target ride the receipt and one `task_message_routed` Logs row, and the receiver's `task_message_injected` row names the sender. Independent roots learn the roster from an `[INDEPENDENT_ROOTS]` TAIL note (`peer_roster.py`, `ROSTER_NOTE_CAP` = 40 rows shown, cut disclosed), appended only on roster change, never merged into a sent row. Direct rows omit unrecorded facts: suggested name, start, the typed `run_origin` subset (provenance, never authority) and the waits the root's durable result names (owner, review, model, budget), each dated by its own start (`owner_wait.parked_at`, the model wait's `started_at`, the pause's `paused_at`), never the task's; queue status stays separate; the direct fragment carries a live turn's name, start and origin until its result exists. Messaging is model judgment; `forward_to_worker` also reaches inline Presence via its existing mailbox (§12). A root may publish bounded `update_focus(text, source_ref)` for other live roots, shown in grouped notes and paginated `live_roots` while unsettled (waits included) and retained by `recent_tasks` on dormant results; publishing is the model's judgment; no project-level focus, refresh or timer. `update_focus` answers the `source_ref` through the reader it names under the caller's registry admission (`disabled_tools` included) and retains the exact answer (≤256 KiB) write-once on the canonical root as `focus.source_handle`, a native `task_source` ref peers read from any drive via `get_task_result(include_focus_source=True, focus_source_sha256=…)` against the PHYSICAL author's record; the digest selects the immutable historical file, so no later focus/retry can substitute its evidence (the roster's `retained_source`). A refusing reader or changed snapshot refuses the focus (`FOCUS_SOURCE_UNRESOLVED`); the roster drops only settled roots' focus. Focus has authored time separate from host observation, no TTL or owner authority. Explicit authorized project journal/workpad reads return exactly the requested source; foreign scoped writes refuse; children/Presence keep ceilings.
WHO is speaking is ONE host-minted fact on the event (`control_routing._routing_issuer`; the model has no argument): an OWNER TURN (stamped by the owner door: `is_direct_chat` and `run_origin.owner_ingress`) or a TASK speaking for itself — a promoted root (inheriting the stamp as ancestry, not issuer), a root relaying an owner message it just drained, or a consciousness wake-up, Presence event or direct-lane auto-resume template, which nobody typed (a wake's promotes mint consciousness roots inheriting its origin, ledger category and autonomy level). An owner turn's steer travels as owner text: `[Message from my human]`, the owner corpus, the generation bump superseding a reviewed answer, the issuing chat's registry-lane room veto (a Project room reaches its own roots, Main every host-listed root) and the owner acknowledgement. A task's own words NEVER travel as owner text: the task-message writer shared with `forward_to_worker` sends them as `independent_task` provenance to any host-listed active independent root (through `forward_to_worker` a root inside the sender's own tree is a `peer_task` recipient instead; `steer_task` keeps `independent_task`; hidden roots included, no room veto; a Presence sender only its own binding's work, chapter 12), rendered `[Message from independent task <id>]`, with no owner corpus (`owner_source_sha256` and the acceptance premises stay the owner's) and no attachments, confirmed WRITTEN or refused with the host's reason. A relay keys/publishes its acknowledgement on the drained owner message; other task acts use their own synthetic receipt id without chat acknowledgement. Neither grants owner authority; author and target ride the receipt and one `task_message_routed` Logs row, and the receiver's `task_message_injected` row names the sender. Independent roots learn the roster from an `[INDEPENDENT_ROOTS]` TAIL note (`peer_roster.py`, `ROSTER_NOTE_CAP` = 40 rows shown, cut disclosed), appended only on roster change, never merged into a sent row. Direct rows omit unrecorded facts: suggested name, start, the typed `run_origin` subset (provenance, never authority) and the waits the root's durable result names, each called what its record says — an owner answer, a review, model access, the model's own sleep with its saved mode and wake sources (names and ids, never process pins; a restart-retained one says so), the owner's Pause, a monetary pause; a reasonless owner wait is an owner answer only with its recorded quiz, and an unrecognized reason stays `unknown` with that reason — each dated by its own start (`owner_wait.parked_at`, the model wait's `started_at`, the pause's `paused_at`), never the task's; queue status stays separate; the direct fragment carries a live turn's name, start and origin until its result exists. Messaging is model judgment; `forward_to_worker` also reaches inline Presence via its existing mailbox (§12). A root may publish bounded `update_focus(text, source_ref)` for other live roots, shown in grouped notes and paginated `live_roots` while unsettled (waits included) and retained by `recent_tasks` on dormant results; publishing is the model's judgment; no project-level focus, refresh or timer. `update_focus` answers the `source_ref` through the reader it names under the caller's registry admission (`disabled_tools` included) and retains the exact answer (≤256 KiB) write-once on the canonical root as `focus.source_handle`, a native `task_source` ref peers read from any drive via `get_task_result(include_focus_source=True, focus_source_sha256=…)` against the PHYSICAL author's record; the digest selects the immutable historical file, so no later focus/retry can substitute its evidence (the roster's `retained_source`). A refusing reader or changed snapshot refuses the focus (`FOCUS_SOURCE_UNRESOLVED`); the roster drops only settled roots' focus. Focus has authored time separate from host observation, no TTL or owner authority. Explicit authorized project journal/workpad reads return exactly the requested source; foreign scoped writes refuse; children/Presence keep ceilings.
`steer_task` relays the owner's exact ingress bytes only on the turn's FIRST routing act, while it still acts on the message that started it; the window ends with the latest owner message the turn DRAINED (`ToolContext.last_owner_delivery`, stamped at the loop's mailbox drain; a message merely written to the mailbox ends nothing) or with a landed promote/route/steer receipt already on the origin message (a refused or unconfirmed act carried nothing, so the next act still relays). Past either, the turn RELAYS its own words, its receipt keyed on the message relayed (the drained delivery's client id, else the synthetic `agent-steer:<routing token>` id), never again on an origin this turn already routed. An agent-authored steer belonging to no owner message earns its receipt under that synthetic id, confirmable through the same `routing_wait` poll; no chat row carries the id, and the owner message's own receipt (what a later decision turn reads) stands. Each steer's mailbox entry is keyed by its routing token, so several instructions under one origin are several deliveries while a retried emit of one steer stays one.

View file

@ -2,7 +2,7 @@
This chapter owns the platform seam: which modules, calls and signals must go through `platform_layer.py`, the one documented function-local exception, and the shared atomic state-file helpers whose byte-exactness keeps a manifest or a hashed receipt identical across operating systems. It exists because the alternative is platform-conditional code scattered where an AST guard cannot see it.
Platform-specific code goes through `ouroboros/platform_layer.py`: platform modules (`fcntl`, `resource`, `grp`, `pwd`, `msvcrt`, `winreg`, `ctypes.windll`), direct `os.kill`/`os.killpg`/`os.setsid`/`os.getpgid`, `signal.SIGKILL`/`signal.SIGTERM`, and platform-conditional subprocess flags (`subprocess_new_group_kwargs()` / `subprocess_hidden_kwargs()`). Use `pathlib.Path` for paths, never string concatenation with hardcoded separators. One documented exception: a function-local import of a platform module under an explicit `sys.platform` guard is allowed outside the layer (the guarded `resource` import in `ouroboros/extension_process_runner.py`, the guarded `winreg` accessor in `ouroboros/windows_autostart.py`, the guarded `msvcrt`/kernel32 handle proof in `ouroboros/confined_files.py`, the guarded `fcntl`/`msvcrt` lock in `ouroboros/body_switch.py`, which must stay stdlib-only and cannot import the layer) — the AST guard inspects only top-level imports, so review must see function-local ones.
Platform-specific code goes through `ouroboros/platform_layer.py`: platform modules (`fcntl`, `resource`, `grp`, `pwd`, `msvcrt`, `winreg`, `ctypes.windll`), direct `os.kill`/`os.killpg`/`os.setsid`/`os.getpgid`, `signal.SIGKILL`/`signal.SIGTERM`, and platform-conditional subprocess flags (`subprocess_new_group_kwargs()` / `subprocess_hidden_kwargs()`). Use `pathlib.Path` for paths, never string concatenation with hardcoded separators. One documented exception: a function-local import of a platform module under an explicit `sys.platform` guard is allowed outside the layer (the guarded `resource` import in `ouroboros/extension_process_runner.py`, the guarded `winreg` accessor in `ouroboros/windows_autostart.py`, the guarded `msvcrt`/kernel32 handle proof in `ouroboros/confined_files.py`, the guarded `fcntl`/`msvcrt` lock in `ouroboros/body_switch.py`, which must stay stdlib-only and cannot import the layer, and the typed `ctypes` caption calls in `ouroboros/launcher_appearance.py`, which belong to one window's lifecycle — its `before_show` handle, its UI-thread `BeginInvoke` and its close — none of which a generic layer wrapper owns) — the AST guard inspects only top-level imports, so review must see function-local ones.
Enforcement: `tests/test_platform_guard.py` scans `ouroboros/`, `supervisor/` and `server.py` for top-level platform imports, the four `os` calls and the two signals; `launcher.py` and subprocess flag patterns are deliberately not scanned — code review and the `cross_platform` checklist item cover them — and CI tests run on Ubuntu, Windows and macOS. New platform behavior: add the wrapper to `platform_layer.py`, use it in callers, and add platform-conditional tests where behavior differs.

View file

@ -8,8 +8,8 @@ This chapter owns the engineering rules that preserve the visual and interaction
- **`web/modules/ui_primitives.js`** is the one safe-field renderer, collector, attribute-escaping and tone/status source; `ui_helpers.js` and `utils.js` keep the re-exports. Keep the leaf free of shell, network and document-global initialization so first-party and author consumers share one implementation. `web/tests/ui_primitives.test.js` pins that portability and the escaping/password contract.
- **Typography on a migrated surface** names a `--type-*` size token AND a named foreground token in every text declaration: a rule with a size and no colour is the exact defect that made secondary text inherit near-white primary ink. `tests/test_web_typography_static.py` keeps the class closed on the migrated families/regions; extending that guard and migrating its subject are the same commit (DESIGN §8 names the boundary).
- **The variable contract** is checked in BOTH directions across the whole stylesheet by the same test file: every `var(--x)` must resolve — an undeclared one silently renders its hardcoded fallback, which becomes the real value nobody can find — and every `:root` token must have a reader, because a token that resolves nowhere is what makes surfaces reach for literals. Each document resolves against the sheets it actually loads, and neither page may shadow a shared palette name. Fix a dangling name by pointing it at an existing token, not by declaring a new one.
- **Appearance is client-local.** Keep the choice and radiogroup in `theme.js`, outside Settings' `s-` input collector. Theme-dependent images use whole-image tokens (`--select-arrow`), and selected warning controls use the semantic foreground/background/border triple rather than a raw hue. Mounted charts update in place; Mermaid retains source before any async work and rejects stale epochs. Every theme subscription has an owner and disposer; `onTheme` is opt-in, resolved only, author-styled. Request persistent desktop storage at every `webview.start`; verify a rebuilt launcher through quit/relaunch, not a server restart. Storage/iframe boundaries and mechanisms live in ARCHITECTURE §3. Regressions: `web/tests/theme.test.js`, `theme_palette.test.js`, `chat_markdown_theme.test.js`, `evolution_theme.test.js`, `tests/test_appearance_static.py`, and the `ui_browser` `tests/test_theme_browser.py`.
- **Layout and controls.** Top-level pages use a fixed `renderPageHeader` outside an independently scrolling body; page icons come from `web/modules/page_icons.js`; primary actions, Refresh included, live in the `renderPageHeader({ actionsHtml })` slot. Tab strips are one design-system control (`renderTabStrip` + `bindTabStrip` in `page_header.js`, `.app-tab-strip`/`.app-tab`, the `--pill-*` tokens): the binder owns selected state, ARIA, roving focus and strip-only reveal, callbacks own loading and panels, programmatic `select()` never calls them, and the binder and its resize observer are disposed with the owning page. The navigation column's height must not grow with the number of items in a collection inside it; a variable-length collection owns a bounded window with its own scroll (`--nav-projects-list-max-height`). Scroll bodies share `.scroll-fade-y` — except a dense list whose rows are shorter than the 32px edge, where the fade would cover a whole row — and `scroll_fade.js::bindScrollFade` returns the disposer for its observer and listener. Masonry packing uses `web/modules/masonry.js::applyMasonry` (CSS Grid row packing leaves gaps under shorter cards): it packs in the page's key order, takes the owner's column spans by key (DESIGN "Widgets board") and writes only `--masonry-*` custom properties; never move `<article>` nodes to reorder or resize, because a moved `<iframe>` reloads; a card's menu carries no fact about its neighbours (`web/tests/widget_card.test.js`). Widget order, widths (`widget_size`) and the per-card start-mode override (`widget_start_mode`, values from `extension_ui_validation.WIDGET_START_MODES`) persist through `/api/ui/preferences` + `data/state/ui_preferences.json`, never in extension manifests. A new visual dimension becomes a CSS variable first and is consumed by shared classes; new inline `style=""` markup and `.style.<property>` assignments are review debt, except a dynamic measured value updating a narrowly named custom property when that is the real runtime data flow.
- **Appearance is client-local.** Keep the choice and radiogroup in `theme.js`, outside Settings' `s-` input collector. Theme-dependent images use whole-image tokens (`--select-arrow`), and selected warning controls use the semantic foreground/background/border triple rather than a raw hue. Mounted charts update in place; Mermaid retains source before any async work and rejects stale epochs. Every theme subscription has an owner and disposer; `onTheme` is opt-in, resolved only, author-styled. Request persistent desktop storage at every `webview.start`; verify a rebuilt launcher through quit/relaunch, not a server restart. The Windows caption tint is the top-level page's call to its own window's bridge, applied by `launcher_appearance.py` — never a second theme store, a frameless frame or a page-colour caption. Storage/iframe boundaries and mechanisms live in ARCHITECTURE §3. Regressions: `web/tests/theme.test.js`, `theme_palette.test.js`, `chat_markdown_theme.test.js`, `evolution_theme.test.js`, `tests/test_appearance_static.py`, `tests/test_launcher_appearance.py`, and the `ui_browser` `tests/test_theme_browser.py`.
- **Layout and controls.** Top-level pages use a fixed `renderPageHeader` outside an independently scrolling body; page icons come from `web/modules/page_icons.js`; primary actions, Refresh included, live in the `renderPageHeader({ actionsHtml })` slot. Tab strips are one design-system control (`renderTabStrip` + `bindTabStrip` in `page_header.js`, `.app-tab-strip`/`.app-tab`, the `--pill-*` tokens): the binder owns selected state, ARIA, roving focus and strip-only reveal, callbacks own loading and panels, programmatic `select()` never calls them, and the binder and its resize observer are disposed with the owning page. Segmented single-selects are one generator too (`renderSegmentedField`): their columns come from the count it writes, never a per-control column override, and their focus is the one ring apart from hover (`web/tests/segmented_field.test.js`; every group on the real page in Chromium and WebKit: `tests/test_segmented_layout_browser.py`). The navigation column's height must not grow with the number of items in a collection inside it; a variable-length collection owns a bounded window with its own scroll (`--nav-projects-list-max-height`). Scroll bodies share `.scroll-fade-y` — except a dense list whose rows are shorter than the 32px edge, where the fade would cover a whole row — and `scroll_fade.js::bindScrollFade` returns the disposer for its observer and listener. Masonry packing uses `web/modules/masonry.js::applyMasonry` (CSS Grid row packing leaves gaps under shorter cards): it packs in the page's key order, takes the owner's column spans by key (DESIGN "Widgets board") and writes only `--masonry-*` custom properties; never move `<article>` nodes to reorder or resize, because a moved `<iframe>` reloads; a card's menu carries no fact about its neighbours (`web/tests/widget_card.test.js`). Widget order, widths (`widget_size`) and the per-card start-mode override (`widget_start_mode`, values from `extension_ui_validation.WIDGET_START_MODES`) persist through `/api/ui/preferences` + `data/state/ui_preferences.json`, never in extension manifests. A new visual dimension becomes a CSS variable first and is consumed by shared classes; new inline `style=""` markup and `.style.<property>` assignments are review debt, except a dynamic measured value — or a renderer's count of what it rendered, as `renderSegmentedField` writes `--segment-count` for the shared equal-column grid — updating a narrowly named custom property when that is the real runtime data flow.
- **Containment.** A control never widens its column. Horizontal overflow lives in the wrapper that owns the wide content and declares `overflow-x: auto` (code block, `.md-table-wrap`, tab strip, Costs table cells) — never in a page scroll body, whose `overflow-y: auto` alone already makes `overflow-x` compute to `auto`. The shared `select.ui-control` recipe therefore clips its own value (`overflow: hidden`): WebKit computes `overflow: visible` on a native select, so an unclipped option label becomes scrollable overflow of the page scroller. A grid track holding controls takes a minimum that yields to its container — `minmax(0, …)` or `repeat(auto-fit, minmax(min(100%, Npx), 1fr))`; a fixed px minimum rescued only by a viewport media query is review debt, because the viewport does not know how wide the content column is. The global webkit scrollbar recipe sizes both axes. Enforced by `tests/test_web_typography_static.py::test_select_control_clips_its_value` and its `::test_webkit_scrollbar_recipe_covers_both_axes` neighbour, `tests/test_ui_settings_overflow_browser.py` (WebKit, the native-select clip) and `tests/test_ui_settings_grid_tracks_browser.py` (Chromium, yielding tracks). Two gaps stay open: the wizard document loads `ui.css` without `style.css` and keeps native scrollbars, and an element setting the standard `scrollbar-width`/`scrollbar-color` opts out of the webkit recipe on Blink.
- **One semantic button variant expresses one action role**: neutral Settings and onboarding controls use the existing `.btn.btn-default`; a one-action result row uses the named `.settings-action-row` contract (status first, action docked right); notifications use the shared toast host. Working, warning, error and destructive states keep one meaning across Chat, Logs, Settings and Skills. Enforced by `web/tests/settings_action_row.test.js` (the shared `.btn-default` role in both shells, the `.settings-action-row` contract and shared busy/status semantics); the toast-host and cross-page state-meaning clauses are review-only (CHECKLISTS item 30).
- **One owner intent has one control, built in one module** (semantics: DESIGN

View file

@ -2,7 +2,7 @@
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 692-792; UTF-8 SHA-256 `632927614e1022a00af7de0fe9eefe513fd8086cbb971e9f47e69fc315b0e693`.
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 693-793; UTF-8 SHA-256 `f595e658ea8df62cc0f12c1618705f8437c75ba7c2ecc03c3ca5f4816bac2c9d`.
- entries: **90** (code-ref: 83, repo-dir: 6, repo-path: 1)

View file

@ -78,6 +78,7 @@ D20 = "Presence"
"ouroboros/launcher_tray.py" = "D18"
"ouroboros/launcher_tray_macos.py" = "D18"
"ouroboros/launcher_background.py" = "D18"
"ouroboros/launcher_appearance.py" = "D18"
"ouroboros/desktop_notifications.py" = "D18"
"ouroboros/desktop_autostart.py" = "D18"
"ouroboros/windows_autostart.py" = "D18"

View file

@ -0,0 +1,250 @@
"""The desktop window's native caption follows the page's painted palette (Windows).
The page owns Light / Dark / System (``web/theme.js``): it resolves the palette
and tells its OWN window's bridge (``set_native_appearance``) at load, when the
bridge appears and whenever the painted palette changes. This leaf turns that
fact into ``DWMWA_USE_IMMERSIVE_DARK_MODE`` on that window's own handle, so the
ordinary system caption, its buttons, Snap and resizing stay native and only
their light or dark tint follows the app — the system tint, never the page's
exact colour. There is no setting and no native state: the page's stored choice
is the one authority, and each window keeps its own requests.
A window-lifecycle leaf, not a platform-layer wrapper (DEVELOPMENT "Platform
Abstraction Rule"): the handle exists from pywebview's ``before_show``, which
runs on the form's UI thread before the window is shown, so its write needs no
repaint; every later write is marshalled back onto that thread with
``BeginInvoke`` and repaints the shown frame; and the window's close releases
the one system-preference subscription. Bridge calls arrive on pywebview's
worker threads, possibly out of order, even across a reload, so each carries
when its document became the window's page and its count there: only a request
newer by that pair is kept, never the one that merely arrived last.
Until the page reports, the frame matches the window's own background colour,
the colour pywebview paints before the page loads (dark for the main window,
the default light for setup); a page whose palette differs corrects it once its
bridge is ready. High Contrast owns the caption colours: while it is on the
attribute is cleared explicitly, and a preference change, which the page does
not announce, re-applies the page's palette.
The intended caption is the app's palette whatever Windows' own mode: the
attribute is TRUE exactly when the page paints dark (High Contrast aside).
Microsoft's sources disagree on whether Windows honours that under a light
system mode: the attribute reference words TRUE as honoring dark mode "when the
dark mode system setting is enabled" (Windows 11 build 22000 on), while on
Microsoft Q&A (question 966330, 16 Aug 2022) a Microsoft engineer reproduced the
dark caption under a light system and left bug-or-documentation open. No native
Windows run has checked it here: dark-on-light, Windows 10 and the pixels are
unproven, not excluded. A refusal's HRESULT is logged once; an accepted call is
not proof of the pixels.
"""
from __future__ import annotations
import logging
import math
import sys
import threading
from typing import Any, Dict, Optional
log = logging.getLogger("launcher.appearance")
_WINDOWS = sys.platform == "win32"
THEMES = ("light", "dark")
_DWMWA_USE_IMMERSIVE_DARK_MODE = 20
_SPI_GETHIGHCONTRAST = 0x0042
_HCF_HIGHCONTRASTON = 0x00000001
# SetWindowPos: keep size, position, z-order and activation; recompute the frame
# (SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED).
_SWP_REFRESH_FRAME = 0x0001 | 0x0002 | 0x0004 | 0x0010 | 0x0020
def palette_of_background(color: Any) -> str:
"""``dark`` or ``light`` for a ``#rgb``/``#rrggbb`` window background; ``""`` when unreadable."""
text = str(color or "").strip().lstrip("#")
if len(text) == 3:
text = "".join(digit * 2 for digit in text)
if len(text) != 6:
return ""
try:
red, green, blue = (int(text[index:index + 2], 16) for index in (0, 2, 4))
except ValueError:
return ""
return "dark" if 0.2126 * red + 0.7152 * green + 0.0722 * blue < 128 else "light"
def _set_dark_frame(hwnd: int, dark: bool) -> int:
"""``DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &BOOL, sizeof(BOOL))``: its HRESULT."""
import ctypes
from ctypes import wintypes
# A private handle: pywebview's own helper sets argtypes on the shared windll function.
call = ctypes.WinDLL("dwmapi").DwmSetWindowAttribute
call.argtypes = (wintypes.HWND, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD)
call.restype = ctypes.c_long # HRESULT: negative is a refusal
value = wintypes.BOOL(1 if dark else 0)
return int(call(wintypes.HWND(hwnd), _DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)))
def _high_contrast() -> bool:
"""Whether a Windows contrast theme is on (``SPI_GETHIGHCONTRAST``); an unreadable answer reads as off."""
import ctypes
from ctypes import wintypes
class HighContrast(ctypes.Structure):
_fields_ = [("cbSize", wintypes.UINT), ("dwFlags", wintypes.DWORD), ("lpszDefaultScheme", wintypes.LPWSTR)]
call = ctypes.WinDLL("user32").SystemParametersInfoW
call.argtypes = (wintypes.UINT, wintypes.UINT, ctypes.c_void_p, wintypes.UINT)
call.restype = wintypes.BOOL
info = HighContrast(cbSize=ctypes.sizeof(HighContrast))
if not call(_SPI_GETHIGHCONTRAST, ctypes.sizeof(info), ctypes.byref(info), 0):
log.debug("High Contrast state unreadable; the page's palette applies.")
return False
return bool(info.dwFlags & _HCF_HIGHCONTRASTON)
def _refresh_frame(hwnd: int) -> None:
"""Ask Windows to recompute the frame so a change on a visible window repaints its caption."""
import ctypes
from ctypes import wintypes
call = ctypes.WinDLL("user32").SetWindowPos
call.argtypes = (wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
wintypes.UINT)
call.restype = wintypes.BOOL
call(wintypes.HWND(hwnd), None, 0, 0, 0, 0, _SWP_REFRESH_FRAME)
class NativeAppearance:
"""One desktop window's caption tint: the page's newest palette, written on its UI thread."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._window: Any = None
self._form: Any = None
self._hwnd = 0
self._theme = "" # the newest palette asked for ("" = none yet)
self._order: tuple[float, int] = (-math.inf, 0) # (its page's start, its count there)
self._preferences: Any = None # the subscribed .NET handler, released on close
self._closed = False
self._applied: Optional[bool] = None # UI thread only; None = never written (the light default)
self._refused = False
def attach(self, window: Any) -> None:
"""Register on ``window`` before ``webview.start``; inert off Windows."""
if not _WINDOWS or window is None:
return
self._window = window
self._theme = palette_of_background(getattr(window, "background_color", ""))
window.events.before_show += self._before_show
window.events.closed += self._close
def request(self, theme: Any, page: Any = None, sequence: Any = None) -> Dict[str, Any]:
"""The bridge call: keep the newest palette of the newest page, then post one write.
``page`` is when the calling document became the window's page (``theme.js``: its
time origin, renewed when the back-forward cache restores it) and ``sequence`` its
count of calls, so the order is the pages' own, not the order calls arrived in."""
theme = str(theme or "")
if theme not in THEMES:
return {"ok": False, "state": "invalid_theme"}
if not _WINDOWS:
return {"ok": False, "state": "unsupported"}
try:
order = (float(page), int(sequence))
except (TypeError, ValueError):
return {"ok": False, "state": "invalid_order"}
if not math.isfinite(order[0]):
return {"ok": False, "state": "invalid_order"}
with self._lock:
if self._closed:
return {"ok": False, "state": "closed"}
if order <= self._order:
return {"ok": True, "state": "stale"}
self._order, self._theme, form = order, theme, self._form
if form is None:
return {"ok": True, "state": "pending"} # before_show writes it
return {"ok": True, "state": "scheduled" if self._post(form) else "unavailable"}
def _before_show(self) -> None:
"""pywebview ``before_show`` (synchronous, the form's UI thread): take the handle, write the first tint."""
form = getattr(self._window, "native", None)
try:
hwnd = int(form.Handle.ToInt64())
except Exception:
log.warning("No native window handle; the caption keeps the system tint.", exc_info=True)
return
with self._lock:
if self._closed:
return
self._form, self._hwnd = form, hwnd
self._subscribe_preferences()
self._apply(shown=False)
def _post(self, form: Any) -> bool:
try:
from System import Action
form.BeginInvoke(Action(self._apply))
return True
except Exception:
log.debug("Caption tint not scheduled: the window is closing or gone.", exc_info=True)
return False
def _apply(self, shown: bool = True) -> None:
"""The UI-thread write: the newest palette, cleared while High Contrast is on.
Only ``before_show`` writes before the window is shown; every posted write runs
after it, so a change there asks Windows to repaint the frame already on screen."""
with self._lock:
if self._closed or not self._hwnd or not self._theme:
return
hwnd, dark = self._hwnd, self._theme == "dark"
dark = dark and not _high_contrast()
if self._applied == dark or (self._applied is None and not dark):
return # unchanged, or the never-written default is already the light frame
result = _set_dark_frame(hwnd, dark)
if result < 0:
if not self._refused:
self._refused = True
log.warning("DwmSetWindowAttribute refused the caption tint (HRESULT 0x%08X).", result & 0xFFFFFFFF)
return
self._applied = dark
if shown:
_refresh_frame(hwnd)
def _subscribe_preferences(self) -> None:
try:
import clr
clr.AddReference("System")
from Microsoft.Win32 import SystemEvents, UserPreferenceChangedEventHandler
handler = UserPreferenceChangedEventHandler(self._preference_changed)
SystemEvents.UserPreferenceChanged += handler
except Exception:
log.info("High Contrast changes are not observed; the caption follows the page only.", exc_info=True)
return
with self._lock:
self._preferences = handler
def _preference_changed(self, _sender: Any = None, _args: Any = None) -> None:
with self._lock:
form = None if self._closed else self._form
if form is not None:
self._post(form)
def _close(self) -> None:
"""pywebview ``closed``: no further writes, and the static system event lets go of this window."""
with self._lock:
self._closed = True
self._form = None
handler, self._preferences = self._preferences, None
if handler is None:
return
try:
from Microsoft.Win32 import SystemEvents
SystemEvents.UserPreferenceChanged -= handler
except Exception:
log.debug("System preference subscription not released.", exc_info=True)

View file

@ -11,7 +11,8 @@ window hidden on purpose. Linux and other hosts have no indicator: closing quits
``DesktopApi`` is the page's alert half of the window bridge (``launcher.MainApi`` inherits it): the
attention cue, the shell's own facts, and the system notifications of ``desktop_notifications``,
whose click opens the window and hands the page its token.
whose click opens the window and hands the page its token; it also carries the painted palette to
the window's native caption (``launcher_appearance``).
"""
from __future__ import annotations
@ -27,6 +28,7 @@ import urllib.request
from ouroboros.config import read_version
from ouroboros.desktop_autostart import BACKGROUND_ENV, keep_running_choice, set_keep_running
from ouroboros.desktop_notifications import UNAVAILABLE, capability, native_notifier, platform_name, refused
from ouroboros.launcher_appearance import NativeAppearance
from ouroboros.platform_layer import request_native_attention, signal_pid
log = logging.getLogger("launcher.background")
@ -288,6 +290,7 @@ class Background:
self.window = None
self.native_ready = False
self.indicator = cls(self) if cls is not None else None
self.appearance = NativeAppearance() # the window's caption tint follows its page (Windows)
global _notifier
self.notifications = _notifier = native_notifier(self.open_notification)
self._asking = threading.Lock()
@ -332,6 +335,7 @@ class Background:
def attach(self, window):
self.window = window
self.appearance.attach(window) # every window, indicator or not: its own before_show and close
window.events.closing += self.closing
window.events.shown += self._window_shown # set for a hidden window too (pywebview 5.4)
if self.indicator is not None:
@ -495,6 +499,10 @@ class DesktopApi:
notify_owner = request_attention # newer pages send the alert text; older launchers lack this name
def set_native_appearance(self, theme: str = "", page: float | None = None, sequence: int | None = None) -> dict:
"""The page's painted palette for this window's native caption; the newest request wins."""
return self._background.appearance.request(theme, page, sequence)
def shell_info(self) -> dict:
"""What this desktop app is: its own version (not the core's), its storage and its notifications."""
return {"shell_version": read_version(), "persistent_storage": PERSISTENT_WEBVIEW_STORAGE,

View file

@ -14,6 +14,7 @@ from ouroboros.config import (
apply_settings_to_env as _apply_settings_to_env,
load_settings,
)
from ouroboros.launcher_appearance import NativeAppearance
from ouroboros.server_runtime import apply_runtime_provider_defaults, has_startup_ready_provider
# The launcher's own logger: these lines belong in launcher.log next to the
@ -84,8 +85,10 @@ def present_first_run_onboarding(
import webview
appearance = NativeAppearance() # this window's own caption tint (the main window keeps its own)
class OnboardingHostApi:
"""Window lifecycle and external-link bridge for desktop setup.
"""Window lifecycle, caption tint and external-link bridge for desktop setup.
NOT a settings authority, and no longer even capable of being one. The
page completes through ``POST /api/onboarding/complete`` exactly as a
@ -100,6 +103,9 @@ def present_first_run_onboarding(
def open_external_url(self, url: str) -> dict:
return open_external_url(url)
def set_native_appearance(self, theme: str = "", page: float | None = None, sequence: int | None = None) -> dict:
return appearance.request(theme, page, sequence)
def onboarding_finished(self, result: dict | None = None) -> str:
payload = result if isinstance(result, dict) else {}
# Absent/malformed payload means the wizard told us NOTHING, and on
@ -115,14 +121,14 @@ def present_first_run_onboarding(
window.destroy()
return "ok"
webview.create_window(
appearance.attach(webview.create_window(
"Ouroboros — Setup",
url=f"http://127.0.0.1:{port}/onboarding",
js_api=OnboardingHostApi(),
width=980,
height=780,
min_size=(840, 640),
)
))
# Share persistent local UI storage with the same-origin main window.
# This includes cookies and website data, not only the appearance choice.
webview.start(private_mode=False)

View file

@ -13,10 +13,13 @@ read is one pair of small JSON reads.
Beside id, title, room and queue status a row carries only facts the host
already recorded, each absent when unrecorded: the suggested name, the start,
the typed origin (``dialogue_provenance.run_origin``: provenance, never
authority), the waits the root's durable result names -- owner, review, model,
budget -- each dated by its OWN start, never the task's -- and the dated
authored focus of any root that has not settled. The task result is read per
row; the direct fragment carries a live turn's facts until that result exists.
authority), the waits the root's durable result names -- each dated by its OWN
start, never the task's, and named by what its record says it is (an owner
answer, a review, model access, the model's own sleep with its recorded mode
and wake sources, the owner's Pause, a monetary pause; an unrecognized reason
stays ``unknown`` with that reason) -- and the dated authored focus of any root
that has not settled. The task result is read per row; the direct fragment
carries a live turn's facts until that result exists.
The 40-row cap is presentation only -- the addressability gate consults every
row -- and the cut is disclosed in the note. The note is appended to the
@ -125,27 +128,113 @@ def _same_attempt(recorded: Any, current: Any) -> bool:
return False
def _recorded_reason(record: Dict[str, Any]) -> str:
"""The ``reason`` a wait record states, as text; ``""`` when it states none."""
reason = record.get("reason")
return "" if reason is None else str(reason).strip()
def _sleep_facts(sleep: Any) -> Dict[str, Any]:
"""A sleep's recorded mode and wake sources: names and ids only, never pins.
``model_sleep.selectors`` already bounds each list, so nothing is cut here.
A service is named by its own name; its pinned start (pid, pgid) is
execution custody, not a wake condition.
"""
if not isinstance(sleep, dict):
return {}
facts: Dict[str, Any] = {}
if str(sleep.get("mode") or "").strip():
facts["mode"] = str(sleep["mode"]).strip()
if _iso_text(sleep.get("wake_at")):
facts["wake_at"] = _iso_text(sleep.get("wake_at"))
for key in ("senders", "tasks", "runs"):
ids = [item.strip() for item in sleep.get(key) or [] if isinstance(item, str) and item.strip()]
if ids:
facts[key] = ids
services = [str(pin["name"]) for pin in sleep.get("services") or []
if isinstance(pin, dict) and str(pin.get("name") or "").strip()]
if services:
facts["services"] = services
if sleep.get("any_mail") is True:
facts["any_mail"] = True
return facts
def _owner_wait_fact(wait: Dict[str, Any]) -> Dict[str, Any]:
"""Name an owner-wait record by what it records (``owner_wait.checkpoint_owner_wait``).
A review binding or ``reason=review`` is a review; ``sleep`` is the model's
own sleep; ``owner`` is a question to the owner. A record without a reason
predates the field: its quiz id is then the positive evidence of an owner
question, and with neither it is ``unknown``. Any other stated reason is
``unknown`` too, keeping the reason -- a quiz id never overrides it.
"""
reason = _recorded_reason(wait)
has_quiz = bool(str(wait.get("quiz_id") or "").strip())
if str(wait.get("review_binding") or "").strip() or reason == "review":
kind = "review"
elif reason in ("sleep", "owner"):
kind = reason
else:
kind = "owner" if not reason and has_quiz else "unknown"
fact: Dict[str, Any] = {"kind": kind, "since": _iso_text(wait.get("parked_at")) or None}
if kind == "sleep":
# Its bound IS the wake time the sleep records; nothing waits for an answer.
fact.update(_sleep_facts(wait.get("sleep")))
return fact
if kind == "unknown" and reason:
fact["reason"] = reason
if kind != "review" and has_quiz:
fact["quiz_id"] = str(wait["quiz_id"])
if _iso_text(wait.get("wait_deadline_at")):
fact["until"] = _iso_text(wait.get("wait_deadline_at"))
return fact
def _pause_fact(pause: Dict[str, Any]) -> Dict[str, Any]:
"""Name an exact-pause record by its ``reason`` (``budget_pause._RAIL_REASONS``).
One carrier parks three different things: the model's cold or retained
sleep (``sleep``, whose recorded mode is shown as recorded, never inferred
from the carrier), the owner's Pause (``owner``) and a monetary rail
(``budget``, also every row written before the field existed). A newer
stated reason is ``unknown`` with that reason, never silently money.
"""
reason = _recorded_reason(pause)
kind = {"sleep": "sleep", "owner": "owner_pause", "budget": "budget", "": "budget"}.get(reason, "unknown")
fact: Dict[str, Any] = {"kind": kind, "state": str(pause["state"])}
if kind == "sleep":
fact.update(_sleep_facts(pause.get("sleep")))
if str(pause.get("retained_owner_wait_id") or "").strip():
# A warm sleep a stop saved: its stack ended with that process, and an
# explicit Resume precedes any automatic wake (``restart_retention``).
fact["retained"] = True
elif kind == "owner_pause":
if str(pause.get("settlement") or "").strip():
fact["settlement"] = str(pause["settlement"])
else:
if kind == "unknown":
fact["reason"] = reason
fact["rail"] = str(pause.get("rail") or "")
fact["since"] = iso_from_epoch(pause.get("paused_at")) or iso_from_epoch(pause.get("pausing_since")) or None
return fact
def _waiting_facts(stored: Dict[str, Any], attempt: Any) -> List[Dict[str, Any]]:
"""What the durable result records this root as waiting on, dated by each wait.
Separate from the queue status: an owner or model wait keeps its row
``running``, a budget pause keeps it ``pending``. ``since`` is the wait's
OWN recorded start and ``None`` when the record has none (a wait written
before it was stamped) -- never the task's start.
Separate from the queue status: an owner wait, a warm sleep or a model
wait keeps its row ``running``, an exact pause keeps it ``pending``.
``since`` is the wait's OWN recorded start and ``None`` when the record has
none (a wait written before it was stamped) -- never the task's start.
"""
from ouroboros.budget_pause import LIVE_PAUSE_STATES
facts: List[Dict[str, Any]] = []
wait = stored.get("owner_wait")
if isinstance(wait, dict) and wait.get("state") == "waiting" and _same_attempt(wait.get("task_attempt"), attempt):
review = bool(str(wait.get("review_binding") or "").strip()) or wait.get("reason") == "review"
fact: Dict[str, Any] = {"kind": "review" if review else "owner",
"since": _iso_text(wait.get("parked_at")) or None}
if not review and str(wait.get("quiz_id") or "").strip():
fact["quiz_id"] = str(wait["quiz_id"])
if _iso_text(wait.get("wait_deadline_at")):
fact["until"] = _iso_text(wait.get("wait_deadline_at"))
facts.append(fact)
facts.append(_owner_wait_fact(wait))
waits = stored.get("model_waits")
for wait_id in sorted(waits) if isinstance(waits, dict) else []:
row = waits[wait_id]
@ -158,8 +247,7 @@ def _waiting_facts(stored: Dict[str, Any], attempt: Any) -> List[Dict[str, Any]]
pause = stored.get("budget_pause")
if (isinstance(pause, dict) and pause.get("state") in LIVE_PAUSE_STATES
and _same_attempt(pause.get("task_attempt"), attempt)):
facts.append({"kind": "budget", "state": str(pause["state"]), "rail": str(pause.get("rail") or ""),
"since": iso_from_epoch(pause.get("paused_at")) or iso_from_epoch(pause.get("pausing_since")) or None})
facts.append(_pause_fact(pause))
return facts
@ -345,11 +433,20 @@ def roster_fingerprint(roster: Dict[str, Any], *, exclude: str = "") -> tuple:
return rows + (("__projection_health__", bool(roster.get("incomplete"))),)
_WAIT_LABELS = {"owner": "owner answer", "review": "review", "model": "model access", "budget": "budget pause"}
_WAIT_LABELS = {"owner": "owner answer", "review": "review", "model": "model access", "budget": "budget pause",
"sleep": "sleep", "owner_pause": "owner Pause", "unknown": "unknown wait"}
_WAIT_DETAILS = ("quiz_id", "reason", "role", "mode", "retained", "state", "settlement", "rail", "until", "reset_at",
"wake_at", "senders", "tasks", "runs", "services", "any_mail")
def _detail_text(value: Any) -> str:
if isinstance(value, list):
return ",".join(value)
return ("true" if value else "false") if isinstance(value, bool) else str(value)
def _render_wait(wait: Dict[str, Any]) -> str:
details = [f"{key}={wait[key]}" for key in ("quiz_id", "reason", "role", "state", "rail", "until", "reset_at") if wait.get(key)]
details = [f"{key}={_detail_text(wait[key])}" for key in _WAIT_DETAILS if wait.get(key)]
details.append(f"since={wait.get('since') or 'unknown'}")
return f"{_WAIT_LABELS.get(str(wait.get('kind')), str(wait.get('kind')))} ({', '.join(details)})"

View file

@ -223,6 +223,7 @@ BAND_PATHS = {
"tests/test_repo_health_smoke.py": "size-ratchet redesign: merge-aware previous, pairwise base-vs-tip, candidate-mode generator contract tests",
"tests/test_review_agent_session_route.py": "D06 F2.3a re-entry by extraction: the session-route giant (3399) split into three sibling suites + shared fixtures; this remainder keeps the typed-verdict/route themes at 1218 with headroom",
"tests/test_review_fidelity.py": None,
"tests/test_review_session_delivery.py": "The existing session-delivery fixture now models the polling deadline deterministically while retaining extraction, cancellation and calendar controls together; splitting this small shared-fixture delta would duplicate the fake gateway.",
"tests/test_review_session_scope_wiring.py": "Scope-session integration scenarios share transport fixtures and verify exact subject delivery, manifest identity, and diagnostic coverage across native and delegated routes.",
"tests/test_review_verification_v6544.py": None,
"tests/test_safety_policy.py": None,

View file

@ -0,0 +1,2 @@
449
The launcher_appearance.py module row and the caption-tint, segmented-generator and named-wait facts on the launcher_background, page_header and peer_roster rows.

View file

@ -0,0 +1,2 @@
2664
The Windows caption tint the top-level page drives through its own window bridge (page order by when each document became the window's page, UI thread and post-show repaint, High Contrast, excluded transient pages), its intended app-palette caption stated apart from Microsoft's contradictory sources and the unrun native check, and the segmented generator's count-derived equal-column contract.

View file

@ -0,0 +1,2 @@
348
The roster names each recorded wait by its record: sleep with its saved mode and wake sources, owner Pause, money, legacy and unknown reasons.

View file

@ -0,0 +1,2 @@
235
Name launcher_appearance.py among the guarded function-local platform calls: its caption calls belong to one window's lifecycle, not a generic layer wrapper.

View file

@ -0,0 +1,2 @@
704
The renderer-count custom-property exception, the segmented-generator rule with its tests, and the caption tint as a bridge call rather than a second theme store.

View file

@ -75,7 +75,7 @@ def pywebview_exposed(api):
def test_the_bridge_exposes_the_alert_methods_and_hides_its_background():
api = bridge(Recorder())
assert pywebview_exposed(api) == ["notify_owner", "request_attention", "request_native_notifications",
"shell_info", "show_native_notification"]
"set_native_appearance", "shell_info", "show_native_notification"]
assert api.notify_owner(True, "Task finished", "", False) == {"ok": True, "args": (True, "Task finished", "", False)}

View file

@ -0,0 +1,312 @@
"""The desktop window's caption follows its page's palette: contract checks with stand-ins.
The pywebview window, its WinForms form, the .NET preference event and the three
Win32 calls are stand-ins, so this runs on every CI OS. What it pins is the
contract of ``ouroboros/launcher_appearance.py``: the handle is taken in
``before_show`` at full width; every later write happens in a UI-thread action
posted with ``BeginInvoke`` and repaints the shown frame, whatever was written
before; out-of-order bridge calls cannot let an older palette or an older page
win, even a replaced page's first call arriving after its successor's; High
Contrast clears the attribute and its change re-applies; the window's close
releases the subscription; the Win32 signatures are typed. None of this is a
Windows pixel or a native Windows run.
"""
from __future__ import annotations
import ctypes
import sys
import types
import pytest
from ouroboros import launcher_appearance as la
FULL_WIDTH_HWND = 0x7FFF_0000_1234 # above 32 bits: a ToInt32 handle would not survive
# Each page names itself by when it became the window's page (theme.js: performance.timeOrigin).
PAGE_A = 1_791_100_000_000.0
PAGE_B = PAGE_A + 5_250.4 # the reload of page A, five seconds later
class Hook:
def __init__(self):
self.handlers = []
def __iadd__(self, fn):
self.handlers.append(fn)
return self
def __isub__(self, fn):
self.handlers.remove(fn)
return self
def fire(self, *args):
for handler in list(self.handlers):
handler(*args)
class Form:
"""pywebview 5.4's BrowserForm surface the leaf touches: Handle and BeginInvoke."""
def __init__(self):
self.Handle = types.SimpleNamespace(ToInt64=lambda: FULL_WIDTH_HWND)
self.posted = []
def BeginInvoke(self, action):
self.posted.append(action)
def pump(self):
while self.posted:
self.posted.pop(0)()
class Window:
def __init__(self, background_color="#FFFFFF"):
self.background_color = background_color
self.events = types.SimpleNamespace(before_show=Hook(), closed=Hook())
self.native = Form()
@pytest.fixture
def windows(monkeypatch):
"""The Windows path with its native seams replaced by recorders."""
monkeypatch.setattr(la, "_WINDOWS", True)
state = types.SimpleNamespace(writes=[], refreshes=[], high_contrast=False, hresult=0)
def write(hwnd, dark):
state.writes.append((hwnd, dark))
return state.hresult
monkeypatch.setattr(la, "_set_dark_frame", write)
monkeypatch.setattr(la, "_refresh_frame", state.refreshes.append)
monkeypatch.setattr(la, "_high_contrast", lambda: state.high_contrast)
system = types.ModuleType("System")
system.Action = lambda fn: fn
events = types.SimpleNamespace(UserPreferenceChanged=Hook())
win32 = types.ModuleType("Microsoft.Win32")
win32.SystemEvents = events
win32.UserPreferenceChangedEventHandler = lambda fn: fn
microsoft = types.ModuleType("Microsoft")
microsoft.Win32 = win32
clr = types.ModuleType("clr")
clr.AddReference = lambda name: None
for name, module in (("System", system), ("Microsoft", microsoft), ("Microsoft.Win32", win32), ("clr", clr)):
monkeypatch.setitem(sys.modules, name, module)
state.preferences = events.UserPreferenceChanged
return state
def _shown(window, appearance):
appearance.attach(window)
window.events.before_show.fire()
return window.native
def test_the_frame_starts_with_the_windows_own_background_and_takes_a_full_width_handle(windows):
main, setup = la.NativeAppearance(), la.NativeAppearance()
_shown(Window("#0d0b0f"), main) # the main window paints this dark colour before its page loads
_shown(Window(), setup) # pywebview's default white
assert windows.writes == [(FULL_WIDTH_HWND, True)] # setup's light frame is the untouched default
assert windows.refreshes == [] # written before the window is shown: nothing to repaint
def test_only_the_newest_request_of_the_newest_page_paints(windows):
appearance = la.NativeAppearance()
form = _shown(Window("#0d0b0f"), appearance)
assert appearance.request("light", PAGE_A, 2) == {"ok": True, "state": "scheduled"}
# pywebview runs each bridge call on its own thread: the older call arrives late.
assert appearance.request("dark", PAGE_A, 1) == {"ok": True, "state": "stale"}
assert windows.writes == [(FULL_WIDTH_HWND, True)] # nothing is written off the UI thread
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, False) and windows.refreshes == [FULL_WIDTH_HWND]
# A reload is a newer page; the replaced page's late call can never win again.
assert appearance.request("dark", PAGE_B, 1)["state"] == "scheduled"
assert appearance.request("light", PAGE_A, 9)["state"] == "stale"
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, True)
# Two queued actions both read the newest palette, so their order cannot matter.
appearance.request("light", PAGE_B, 2)
appearance.request("dark", PAGE_B, 3)
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, True) and len(windows.writes) == 3
def test_a_replaced_pages_first_call_arriving_after_its_successors_cannot_win(windows):
"""Page A asked for light just before the reload, but that call's worker thread runs
only after the reloaded page B's first call. Arrival order is not page order: A was
never seen before, yet it is older than B, so B keeps the frame and goes on updating it."""
appearance = la.NativeAppearance()
form = _shown(Window("#0d0b0f"), appearance)
assert appearance.request("dark", PAGE_B, 1)["state"] == "scheduled"
assert appearance.request("light", PAGE_A, 1) == {"ok": True, "state": "stale"}
form.pump()
assert windows.writes == [(FULL_WIDTH_HWND, True)] # B's dark was already the frame
assert appearance.request("light", PAGE_B, 2)["state"] == "scheduled"
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, False)
# A page the back-forward cache restores after B speaks from its restore time.
assert appearance.request("dark", PAGE_B + 60_000.0, 3)["state"] == "scheduled"
def test_a_light_window_already_shown_repaints_on_its_first_dark_write(windows):
"""The setup window starts light, so before_show writes nothing; its first dark write
comes later, to a window on screen, and must still ask Windows to repaint the frame."""
appearance = la.NativeAppearance()
form = _shown(Window(), appearance)
assert windows.writes == [] and windows.refreshes == []
appearance.request("dark", PAGE_A, 1)
form.pump()
assert windows.writes == [(FULL_WIDTH_HWND, True)] and windows.refreshes == [FULL_WIDTH_HWND]
appearance.request("light", PAGE_A, 2)
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, False)
assert windows.refreshes == [FULL_WIDTH_HWND, FULL_WIDTH_HWND]
def test_a_request_before_the_window_exists_is_written_when_it_is_shown(windows):
appearance, window = la.NativeAppearance(), Window()
appearance.attach(window)
assert appearance.request("dark", PAGE_A, 1) == {"ok": True, "state": "pending"}
assert windows.writes == []
window.events.before_show.fire()
assert windows.writes == [(FULL_WIDTH_HWND, True)]
assert windows.refreshes == [] # before the first paint: nothing on screen to repaint
def test_high_contrast_clears_the_attribute_and_its_change_reapplies_the_page(windows):
appearance = la.NativeAppearance()
form = _shown(Window("#0d0b0f"), appearance)
windows.high_contrast = True
windows.preferences.fire(None, None) # Windows announces a preference change; the page does not
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, False)
windows.high_contrast = False
windows.preferences.fire(None, None)
form.pump()
assert windows.writes[-1] == (FULL_WIDTH_HWND, True)
windows.preferences.fire(None, None) # an unrelated preference: nothing to change
form.pump()
assert len(windows.writes) == 3
def test_the_windows_close_releases_the_subscription_and_stops_writing(windows):
appearance, window = la.NativeAppearance(), Window("#0d0b0f")
form = _shown(window, appearance)
assert len(windows.preferences.handlers) == 1
appearance.request("light", PAGE_A, 1) # queued, then the window closes before it runs
window.events.closed.fire()
form.pump()
assert windows.preferences.handlers == []
assert windows.writes == [(FULL_WIDTH_HWND, True)]
assert appearance.request("dark", PAGE_A, 2) == {"ok": False, "state": "closed"}
def test_a_refused_write_is_logged_once_and_claims_nothing(windows, caplog):
windows.hresult = -2147024809 # E_INVALIDARG
appearance = la.NativeAppearance()
form = _shown(Window("#0d0b0f"), appearance)
appearance.request("light", PAGE_A, 1)
appearance.request("dark", PAGE_A, 2)
form.pump()
refusals = [record for record in caplog.records if "refused the caption tint" in record.getMessage()]
assert len(refusals) == 1 and "0x80070057" in refusals[0].getMessage()
assert windows.refreshes == []
def test_unknown_palettes_and_other_platforms_are_refused_typed(monkeypatch):
appearance = la.NativeAppearance()
assert appearance.request("sepia", PAGE_A, 1) == {"ok": False, "state": "invalid_theme"}
monkeypatch.setattr(la, "_WINDOWS", True)
# A call that cannot say when its page began has no place in the order.
for page, sequence in (("page-a", 1), (None, 1), (float("nan"), 1), (float("inf"), 1), (PAGE_A, None)):
assert appearance.request("dark", page, sequence) == {"ok": False, "state": "invalid_order"}
monkeypatch.setattr(la, "_WINDOWS", False)
window = Window("#0d0b0f")
appearance.attach(window)
assert window.events.before_show.handlers == [] and window.events.closed.handlers == []
assert appearance.request("dark", PAGE_A, 1) == {"ok": False, "state": "unsupported"}
@pytest.mark.parametrize("color,palette", [("#0d0b0f", "dark"), ("#FFFFFF", "light"), ("fff", "light"),
("#1a1a2e", "dark"), ("", ""), ("#12", ""), ("#zzzzzz", "")])
def test_the_starting_palette_reads_the_windows_background(color, palette):
assert la.palette_of_background(color) == palette
class _Function:
def __init__(self, calls, name, result):
self.calls, self.name, self.result = calls, name, result
def __call__(self, *args):
self.calls.append((self.name, self.argtypes, self.restype, args))
if self.name == "SystemParametersInfoW":
info = args[2]._obj # the HIGHCONTRASTW the caller passed by reference
assert info.cbSize == ctypes.sizeof(info) == args[1]
info.dwFlags = la._HCF_HIGHCONTRASTON
return self.result
def test_the_win32_calls_are_typed_at_full_width(monkeypatch):
from ctypes import wintypes
calls = []
class Library:
def __init__(self, name):
self.name = name
def __getattr__(self, function):
found = _Function(calls, function, 0 if function == "DwmSetWindowAttribute" else 1)
setattr(self, function, found)
return found
monkeypatch.setattr(ctypes, "WinDLL", Library, raising=False)
assert la._set_dark_frame(FULL_WIDTH_HWND, True) == 0
assert la._high_contrast() is True
la._refresh_frame(FULL_WIDTH_HWND)
dwm, spi, swp = calls
assert dwm[1] == (wintypes.HWND, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD) and dwm[2] is ctypes.c_long
hwnd, attribute, value, size = dwm[3]
assert hwnd.value == FULL_WIDTH_HWND and attribute == 20 and size == ctypes.sizeof(wintypes.BOOL)
assert spi[0] == "SystemParametersInfoW" and spi[3][0] == la._SPI_GETHIGHCONTRAST and spi[2] is wintypes.BOOL
assert swp[3][0].value == FULL_WIDTH_HWND and swp[3][-1] == la._SWP_REFRESH_FRAME
assert swp[1][0] is wintypes.HWND and swp[2] is wintypes.BOOL
def test_each_desktop_window_owns_its_appearance_and_bridge(windows, monkeypatch):
"""The main window registers its hooks whether or not a background indicator exists,
its bridge (``DesktopApi``, inherited by ``launcher.MainApi``) reaches ITS appearance,
and the first-run setup window carries a separate one through ``OnboardingHostApi``."""
import threading
from ouroboros import launcher_background as lb
from ouroboros import launcher_onboarding
monkeypatch.setattr(lb, "indicator_class", lambda: None)
background = lb.Background(lambda: None, lambda: 8765, threading.Event())
main = Window("#0d0b0f")
main.events.closing, main.events.shown = Hook(), Hook()
background.attach(main)
assert main.events.before_show.handlers == [background.appearance._before_show] # no indicator hook
class Api(lb.DesktopApi):
_background = background
assert Api().set_native_appearance("light", PAGE_A, 1) == {"ok": True, "state": "pending"}
main.events.before_show.fire()
assert windows.writes == [] # the page's light arrived before the first paint: the default stays
created = {}
fake = types.ModuleType("webview")
fake.windows = []
def create_window(title, url=None, js_api=None, **kwargs):
created.update(js_api=js_api, window=Window())
return created["window"]
fake.create_window, fake.start = create_window, lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "webview", fake)
launcher_onboarding.present_first_run_onboarding({}, 8765, open_external_url=lambda url: {"ok": False})
setup = created["window"]
assert created["js_api"].set_native_appearance("dark", PAGE_A, 1) == {"ok": True, "state": "pending"}
setup.events.before_show.fire()
assert windows.writes == [(FULL_WIDTH_HWND, True)] # the setup window's own handle and request

View file

@ -43,7 +43,7 @@ class Hook:
class Window:
def __init__(self, answer=None):
self.events = SimpleNamespace(closing=Hook(), before_show=Hook(), shown=Hook())
self.events = SimpleNamespace(closing=Hook(), before_show=Hook(), shown=Hook(), closed=Hook())
self.localization = {"global.ok": "OK", "global.cancel": "Cancel"}
self.visible, self.calls, self.answer = True, [], answer

View file

@ -304,11 +304,16 @@ def test_chat_remote_passes_no_proxy_to_anthropic():
assert captured_timeout[0] == 88.0
def test_chat_remote_no_proxy_retries_openrouter_parameter_rejection():
def test_chat_remote_no_proxy_retries_openrouter_parameter_rejection(monkeypatch):
"""OpenRouter no_proxy path retries once without optional sampling params."""
from ouroboros.llm import LLMClient
LLMClient._REJECTED_PARAMS_CACHE.clear()
# Premise: a cold no_proxy worker with no capability metadata yet strips nothing
# up front, so temperature reaches the first send and only the provider's
# rejection removes it. Metadata warmed by an earlier test would strip it first.
monkeypatch.setattr(LLMClient, "_SUPPORTED_PARAMS_FETCHED", False)
monkeypatch.setattr(LLMClient, "_SUPPORTED_PARAMS_CACHE", {})
client = LLMClient(api_key="test-or-key")
target = client._resolve_remote_target("anthropic/claude-opus-4.8")
messages = [{"role": "user", "content": "hello"}]

View file

@ -39,9 +39,21 @@ REPO = pathlib.Path(__file__).resolve().parents[1]
# --------------------------------------------------------------------------
class _FakeHook:
def __init__(self) -> None:
self.handlers: list = []
def __iadd__(self, handler):
self.handlers.append(handler)
return self
class _FakeWindow:
"""pywebview 5.4's window surface the setup host touches (the caption tint hooks included)."""
def __init__(self) -> None:
self.destroyed = False
self.events = types.SimpleNamespace(before_show=_FakeHook(), closed=_FakeHook())
def destroy(self) -> None:
self.destroyed = True
@ -288,7 +300,8 @@ def test_the_desktop_setup_window_cannot_write_settings_at_all(monkeypatch, tmp_
{}, 8765, open_external_url=lambda url: {"ok": False},
)
assert seen["methods"] == ["onboarding_finished", "open_external_url"]
# Completion, the external opener and the caption tint: none of them writes settings.
assert seen["methods"] == ["onboarding_finished", "open_external_url", "set_native_appearance"]
assert outcome == {"saved": True, "restart_required": True}
# The window reported completion, and the LAUNCHER still wrote nothing: the
# bytes on disk (if any) came from the endpoint the page posted to.

View file

@ -30,7 +30,11 @@ def test_shared_page_header_helper_has_no_inline_styles():
assert "export function renderPageHeader" in source
assert "export function renderTabStrip" in source
assert "style=" not in source
# The one inline value is the segmented generator's own count of the choices it
# rendered, a narrowly named custom property the shared grid reads (DEVELOPMENT
# "Design System"); no visual property is ever written inline.
assert re.findall(r'style="([^"]*)"', source) == ["--segment-count: ${options.length}"]
assert ".style." not in source
assert "app-page-header" in source
assert "app-tab-strip" in source

View file

@ -0,0 +1,174 @@
"""The neighbour roster names each recorded wait by what its record says it is.
Both carriers are read: the owner-wait row (a question, a review park, or the
model's warm sleep) and the exact-pause row (a cold or restart-retained sleep,
the owner's Pause, or money). The records are written by the real producers
where one exists -- ``owner_wait.checkpoint_owner_wait`` and
``restart_retention.retain_sleep_checkpoint`` -- so the roster is checked
against the shapes the runtime actually stores, and the model-visible note and
the paginated ``live_roots`` JSON must agree.
"""
from __future__ import annotations
import types
from ouroboros.task_results import STATUS_RUNNING, write_task_result
from ouroboros.utils import atomic_write_json, utc_now_iso
_PAUSED_AT = 1790409600.0 # 2026-09-26T08:00:00+00:00
_PAUSED_ISO = "2026-09-26T08:00:00+00:00"
_WAKE_AT = "2026-09-26T09:30:00+00:00"
def _snapshot(root, running=(), pending=()):
atomic_write_json(root / "state" / "queue_snapshot.json", {
"ts": utc_now_iso(),
"running": [{"id": task_id, "attempt": 1, "task": {"id": task_id, "title": task_id, "_attempt": 1}}
for task_id in running],
"pending": [{"id": task_id, "attempt": 1, "task": {"id": task_id, "title": task_id, "_attempt": 1,
"_budget_pause": {"exact_continuation": True}}}
for task_id in pending],
})
def _waits(root):
from ouroboros.peer_roster import independent_roots
return {row["task_id"]: row.get("waiting") for row in independent_roots(root)["roots"]}
def _note(root):
from ouroboros.peer_roster import independent_roots, render_roster_note
return render_roster_note(independent_roots(root))
def _sleep(mode):
"""The selected sources as ``model_sleep.request_sleep`` arms them (a service pin included)."""
return {"sleep_id": f"s-{mode}", "mode": mode, "senders": ["sender-a"], "tasks": ["child-1", "child-2"],
"runs": ["run-9"], "wake_at": _WAKE_AT, "any_mail": False,
"services": [{"name": "api", "service_id": "warm:api", "started_at": 1790400000.0,
"pid": 4242, "pgid": 4242}]}
def _park(root, task_id, *, sleep=None, quiz="", review_binding=""):
"""Write a waiting owner-wait row through the real checkpoint producer."""
from ouroboros.owner_wait import checkpoint_owner_wait, set_owner_wait
write_task_result(root, task_id, STATUS_RUNNING, started_at="2026-09-26T07:00:00+00:00")
ctx = types.SimpleNamespace(
task_id=task_id, task_attempt=1, drive_root=root, budget_drive_root=str(root),
task_started_at=1790406000.0, _model_sleep=sleep, _model_sleep_started=_PAUSED_AT,
_owner_wait_requested=(f"sleep:{sleep['sleep_id']}" if sleep else quiz),
_owner_wait_deadline_at=(sleep or {}).get("wake_at", ""),
)
checkpoint = checkpoint_owner_wait(ctx, [], {}, {}, 3, [], set(), review_binding=review_binding)
return set_owner_wait(root, task_id, {**checkpoint, "state": "waiting"})
def test_a_warm_sleep_is_a_sleep_with_its_recorded_wake_sources_not_an_owner_answer(tmp_path):
from ouroboros.peer_roster import live_root_catalogue
stored = _park(tmp_path, "sleeper", sleep=_sleep("warm"))
assert stored["reason"] == "sleep" and stored["quiz_id"] == "" # the producer's own shape
_park(tmp_path, "asker", quiz="q7")
_park(tmp_path, "reviewed", review_binding="plan-review-1")
_snapshot(tmp_path, running=["sleeper", "asker", "reviewed"])
waits = _waits(tmp_path)
sleep = waits["sleeper"][0]
assert sleep["kind"] == "sleep" and sleep["mode"] == "warm"
assert sleep["senders"] == ["sender-a"] and sleep["tasks"] == ["child-1", "child-2"] and sleep["runs"] == ["run-9"]
assert sleep["wake_at"] == _WAKE_AT and sleep["services"] == ["api"] and "any_mail" not in sleep
assert "quiz_id" not in sleep and "until" not in sleep and sleep["since"]
assert waits["asker"][0]["kind"] == "owner" and waits["asker"][0]["quiz_id"] == "q7"
assert waits["reviewed"][0]["kind"] == "review" and "quiz_id" not in waits["reviewed"][0]
note = _note(tmp_path)
sleeper_line = next(line for line in note.splitlines() if line.startswith(" waiting") and "sleep (" in line)
assert ("sleep (mode=warm, wake_at=2026-09-26T09:30:00+00:00, senders=sender-a, tasks=child-1,child-2, "
"runs=run-9, services=api, since=") in sleeper_line
assert "owner answer" not in sleeper_line
assert "owner answer (quiz_id=q7, since=" in note and "review (since=unknown)" in note
# Service pins are execution custody, not a wake condition: no pid anywhere.
assert "4242" not in note
page = live_root_catalogue(tmp_path, limit=10)
json_waits = {row["task_id"]: row["waiting"] for row in page["roots"]}
assert json_waits == waits and "4242" not in str(json_waits)
def test_legacy_and_unrecognized_owner_waits_keep_their_evidence(tmp_path):
"""A reasonless row with a quiz predates the field and stays an owner question;
a reasonless row without one is unknown; a stated reason the roster does not
know is unknown WITH that reason, and its quiz id never turns it into owner."""
rows = {
"legacy-quiz": {"quiz_id": "q0"},
"legacy-bare": {"quiz_id": ""},
"blank-reason": {"quiz_id": "", "reason": " "},
"future": {"quiz_id": "q9", "reason": "handoff", "wait_deadline_at": _WAKE_AT},
}
for task_id, extra in rows.items():
write_task_result(tmp_path, task_id, STATUS_RUNNING, owner_wait={
"wait_id": f"w-{task_id}", "state": "waiting", "task_attempt": 1, **extra})
_snapshot(tmp_path, running=list(rows))
waits = _waits(tmp_path)
assert waits["legacy-quiz"] == [{"kind": "owner", "since": None, "quiz_id": "q0"}]
assert waits["legacy-bare"] == [{"kind": "unknown", "since": None}]
assert waits["blank-reason"] == [{"kind": "unknown", "since": None}]
assert waits["future"] == [{"kind": "unknown", "since": None, "reason": "handoff", "quiz_id": "q9",
"until": _WAKE_AT}]
note = _note(tmp_path)
assert "owner answer (quiz_id=q0, since=unknown)" in note
assert "unknown wait (since=unknown)" in note
assert f"unknown wait (quiz_id=q9, reason=handoff, until={_WAKE_AT}, since=unknown)" in note
def test_the_pause_carrier_tells_sleep_owner_pause_and_money_apart(tmp_path):
"""A cold sleep, the owner's Pause and a monetary rail share one carrier; a
reasonless (pre-field) row stays money and a newer reason is never money."""
pauses = {
"cold": {"reason": "sleep", "rail": "model_sleep", "sleep": _sleep("cold")},
"paused-by-owner": {"reason": "owner", "rail": "owner_pause", "settlement": "external_running"},
"money": {"reason": "budget", "rail": "global_exhausted"},
"legacy-money": {"rail": "graceful_ceiling"},
"future": {"reason": "maintenance", "rail": "maintenance_window"},
}
for task_id, extra in pauses.items():
write_task_result(tmp_path, task_id, "scheduled", reason_code="budget_paused", budget_pause={
"pause_id": f"p-{task_id}", "state": "paused", "task_attempt": 1, "paused_at": _PAUSED_AT, **extra})
_snapshot(tmp_path, pending=list(pauses))
waits = _waits(tmp_path)
cold = waits["cold"][0]
assert cold["kind"] == "sleep" and cold["mode"] == "cold" and cold["state"] == "paused"
assert cold["services"] == ["api"] and "rail" not in cold and "retained" not in cold
assert waits["paused-by-owner"] == [{"kind": "owner_pause", "state": "paused",
"settlement": "external_running", "since": _PAUSED_ISO}]
assert waits["money"] == [{"kind": "budget", "state": "paused", "rail": "global_exhausted", "since": _PAUSED_ISO}]
assert waits["legacy-money"] == [{"kind": "budget", "state": "paused", "rail": "graceful_ceiling",
"since": _PAUSED_ISO}]
assert waits["future"] == [{"kind": "unknown", "state": "paused", "reason": "maintenance",
"rail": "maintenance_window", "since": _PAUSED_ISO}]
note = _note(tmp_path)
assert "sleep (mode=cold, state=paused, wake_at=" in note
assert f"owner Pause (state=paused, settlement=external_running, since={_PAUSED_ISO})" in note
assert f"budget pause (state=paused, rail=global_exhausted, since={_PAUSED_ISO})" in note
assert f"unknown wait (reason=maintenance, state=paused, rail=maintenance_window, since={_PAUSED_ISO})" in note
def test_a_restart_retained_warm_sleep_keeps_its_warm_mode_and_says_it_was_retained(tmp_path):
"""The real retention moves a warm sleep into the exact pause: the roster reads
the saved mode (warm, never inferred cold from the carrier), marks it retained
(its stack ended with the stop) and no longer lists the retained owner wait."""
from supervisor.restart_retention import retain_sleep_checkpoint
_park(tmp_path, "kept", sleep=_sleep("warm"))
retain_sleep_checkpoint(tmp_path, "kept", 1)
_snapshot(tmp_path, pending=["kept"])
waits = _waits(tmp_path)["kept"]
assert len(waits) == 1
kept = waits[0]
assert kept["kind"] == "sleep" and kept["mode"] == "warm" and kept["retained"] is True
assert kept["state"] == "paused" and kept["tasks"] == ["child-1", "child-2"] and kept["since"] == _PAUSED_ISO
assert "sleep (mode=warm, retained=true, state=paused, wake_at=" in _note(tmp_path)

View file

@ -824,30 +824,53 @@ class _DeadlineWitnessLLM(FakeLLM):
"""Records what the stop axes say at the moment the extraction call is made."""
def chat(self, **kwargs):
import time
from ouroboros.model_wait import dispatch_deadline_remaining_sec
self.remaining_at_send = dispatch_deadline_remaining_sec()
self.sent_at = time.monotonic()
return super().chat(**kwargs)
def _late_success_executor(tmp_path, fake_route, monkeypatch, *, llm, seen_by, window=1.0):
"""A run still `running` through the whole slot budget whose natural
`succeeded` is first seen either by the spent window's last read or by the
verify read of the slot-deadline cancel (completion wins)."""
import time
verify read of the slot-deadline cancel (completion wins).
The runtime's poll clock is test-local: it moves only by the runtime's own sleeps,
each really slept so the slot's execution deadline does expire, and the run turns
`succeeded` on the read the runtime itself makes with its window spent. No setup,
custody or host stall can then move the success relative to the window. Returns the
executor and the slot budget each of the runtime's reads was made with."""
import time
from types import SimpleNamespace
from ouroboros import review_execution as rx
from ouroboros.review_execution import AgentSessionReviewExecutor, ReviewAssignment
fake_route.nonterminal = True
fake_route.manifest_capabilities = {}
fake_route.detail = _terminal_detail("narrative first\n[]\nNO_FINDINGS")
flip_at, original = time.monotonic() + window, FakeGateway.get_run
clock, reads, observe, original = [0.0], [], rx._observe_session, FakeGateway.get_run
def sleep(seconds):
time.sleep(seconds)
clock[0] += seconds
def observe_session(gateway, run_id, remaining, streak):
reads.append(remaining)
if seen_by == "spent_read" and remaining <= 0:
FakeGateway.nonterminal = False # it finished during the window's last sleep
return observe(gateway, run_id, remaining, streak)
def get_run(self, run_id, **kw):
if (time.monotonic() >= flip_at) if seen_by == "spent_read" else bool(self.cancels):
if seen_by == "verify_read" and self.cancels:
FakeGateway.nonterminal = False
return original(self, run_id, **kw)
monkeypatch.setattr(rx, "time", SimpleNamespace(monotonic=lambda: clock[0], sleep=sleep))
monkeypatch.setattr(rx, "_observe_session", observe_session)
monkeypatch.setattr(FakeGateway, "get_run", get_run)
if seen_by == "spent_read":
# The subject is what the deadline read DOES with a `succeeded` it sees, not whether a
@ -860,7 +883,7 @@ def _late_success_executor(tmp_path, fake_route, monkeypatch, *, llm, seen_by, w
return AgentSessionReviewExecutor(
ReviewAssignment(request=_agent_request(), slot=_agent_slot(timeout_sec=window),
call_id="c-late", call_type="scope_review", custody_root=tmp_path),
llm=llm)
llm=llm), reads
@pytest.mark.parametrize("seen_by, cancels", [("spent_read", []), ("verify_read", ["review_slot_timeout"])])
@ -876,10 +899,14 @@ def test_a_success_first_seen_at_the_slot_deadline_is_accepted_and_extracted_pas
from ouroboros.model_wait import execution_deadline_scope
llm = _DeadlineWitnessLLM()
executor = _late_success_executor(tmp_path, fake_route, monkeypatch, llm=llm, seen_by=seen_by)
with execution_deadline_scope(time.monotonic() + 1.0, review_slot_id="scope_slot_1"):
executor, reads = _late_success_executor(tmp_path, fake_route, monkeypatch, llm=llm, seen_by=seen_by)
slot_deadline = time.monotonic() + 1.0
with execution_deadline_scope(slot_deadline, review_slot_id="scope_slot_1"):
result = executor.execute()
assert reads[-1] == 0 and all(budget > 0 for budget in reads[:-1]), \
"the run read `running` inside its window; the runtime's last read was its spent one"
assert llm.calls, "the late verdict was extracted, not dropped"
assert llm.sent_at >= slot_deadline, "the slot deadline had really expired by the extraction send"
assert llm.remaining_at_send is None, "the expired SLOT deadline must not reach the paid verdict's extraction"
assert result.usage["late_success_accepted"] is True and result.usage["extraction"]
assert empty_array_is_verified_clean(result.raw_text)
@ -894,12 +921,13 @@ def test_the_calendar_deadline_still_interrupts_the_late_verdict_phase(tmp_path,
from ouroboros.model_wait import calendar_scope, execution_deadline_scope
llm = _DeadlineWitnessLLM()
executor = _late_success_executor(tmp_path, fake_route, monkeypatch, llm=llm, seen_by="verify_read")
executor, _reads = _late_success_executor(tmp_path, fake_route, monkeypatch, llm=llm, seen_by="verify_read")
with calendar_scope("2000-01-01T00:00:00+00:00"), \
execution_deadline_scope(time.monotonic() + 1.0, review_slot_id="scope_slot_1"):
result = executor.execute()
assert llm.remaining_at_send == 0.0, "a spent calendar deadline is still visible to the send"
assert result.usage["late_success_accepted"] is True
assert [reason for _rid, reason in fake_route.instances[0].cancels] == ["review_slot_timeout"]
def test_a_success_read_inside_the_slot_budget_is_not_marked_late(tmp_path, fake_route):

View file

@ -0,0 +1,180 @@
"""Every Settings segmented control on the real page: equal columns derived from its choices.
The one renderer (`page_header.renderSegmentedField`) feeds 13 call sites and 16
groups. On a real server in Chromium and WebKit, at a desktop and a phone width:
up to four choices fill their row; the seven- and eight-step effort scales keep four
equal columns with a partial last row whose buttons are not stretched; the five
one-glyph cycle values stay one row; a narrow card stacks two equal columns; no
label leaves its button. Then the keyboard: Enter and Space choose, focus wears the
one DESIGN ring (not the hover paint), the draft turns dirty, and Same as Task / Chat
— the empty value — survives Save and reload where `high` stood before.
"""
from __future__ import annotations
import json
import math
import os
import pathlib
import pytest
pytest_plugins = ("tests.test_ui_smoke_playwright",)
# Every group the renderer draws, with its number of choices.
GROUPS = {
"s-allow-mutative-subagents": 3, "s-effort-task": 7, "s-effort-evolution": 7,
"s-effort-deep-self-review": 7, "s-effort-consciousness": 8, "s-review-enforcement": 2,
"s-task-review-mode": 3, "s-review-max-cycles": 5, "s-image-input-mode": 4, "s-context-mode": 3,
"s-prompt-cache-ttl": 3, "s-safety-mode": 3, "s-update-channel": 3, "s-runtime-mode": 4,
"s-post-task-evolution-mode": 3, "s-consciousness-autonomy": 3,
}
COMPACT = {"s-review-max-cycles"}
_MEASURE = """() => [...document.querySelectorAll('[data-effort-group]')]
.filter((group) => group.getClientRects().length).map((group) => {
const box = group.getBoundingClientRect();
const buttons = [...group.querySelectorAll('[data-effort-value]')].map((button) => {
const rect = button.getBoundingClientRect();
const range = document.createRange();
range.selectNodeContents(button);
const text = range.getBoundingClientRect();
return {left: rect.left, right: rect.right, top: Math.round(rect.top), width: rect.width,
textLeft: text.left, textRight: text.right};
});
return {target: group.dataset.effortTarget, left: box.left, right: box.right, width: box.width, buttons};
})"""
def _open(page, url: str, width: int) -> None:
page.set_viewport_size({"width": width, "height": 900})
page.goto(url, wait_until="domcontentloaded")
page.wait_for_selector("#page-chat", timeout=30_000)
toggle = page.locator("[data-mobile-nav-toggle]:visible")
if toggle.count():
toggle.first.click()
page.locator('[data-nav-page="settings"]').click()
page.wait_for_selector(".settings-shell", timeout=20_000)
def _groups(page) -> dict:
found = {}
for tab in ("agents", "behavior"):
page.locator(f'[data-settings-tab="{tab}"]').click()
page.evaluate("() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))")
found.update({group["target"]: group for group in page.evaluate(_MEASURE)})
return found
def _check_layout(groups: dict, *, narrow: bool) -> list[str]:
problems = []
assert set(groups) == set(GROUPS), sorted(set(GROUPS) ^ set(groups))
for target, group in groups.items():
count = GROUPS[target]
buttons = group["buttons"]
columns = count if target in COMPACT else min(count, 2 if narrow else 4)
rows = [top for top in sorted({button["top"] for button in buttons})]
widths = [button["width"] for button in buttons]
first_row = [button for button in buttons if button["top"] == rows[0]]
if len(buttons) != count:
problems.append(f"{target}: {len(buttons)} buttons, expected {count}")
if len(rows) != math.ceil(count / columns) or len(first_row) != columns:
problems.append(f"{target}: rows {len(rows)} x {len(first_row)}, expected {columns} columns")
if max(widths) - min(widths) > 1: # equal columns in every row, the partial last row included
problems.append(f"{target}: unequal widths {widths}")
if abs(first_row[0]["left"] - group["left"]) > 1.5 or abs(group["right"] - first_row[-1]["right"]) > 1.5:
problems.append(f"{target}: the first row does not fill the card ({group['right'] - first_row[-1]['right']:.1f}px gap)")
for button in buttons:
if button["textLeft"] < button["left"] - 0.5 or button["textRight"] > button["right"] + 0.5:
problems.append(f"{target}: a label leaves its button ({button})")
return problems
@pytest.mark.serial
@pytest.mark.ui_browser
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_every_segmented_group_lays_out_from_its_own_choices(direct_server_with_data, engine):
from playwright.sync_api import sync_playwright
fixture = direct_server_with_data
evidence = pathlib.Path(os.environ.get("OUROBOROS_UI_EVIDENCE_DIR", str(fixture["data_dir"].parent)))
evidence.mkdir(parents=True, exist_ok=True)
problems = []
with sync_playwright() as pw:
browser = getattr(pw, engine).launch(headless=True)
try:
page = browser.new_page()
for width, narrow in ((1280, False), (390, True)):
_open(page, fixture["url"], width)
groups = _groups(page)
problems += [f"{engine} {width}: {problem}" for problem in _check_layout(groups, narrow=narrow)]
page.screenshot(path=str(evidence / f"segmented-{engine}-{width}-behavior.png"), full_page=True)
finally:
browser.close()
assert not problems, "\n".join(problems)
@pytest.mark.serial
@pytest.mark.ui_browser
@pytest.mark.parametrize("engine", ["chromium", "webkit"])
def test_keyboard_choice_focus_ring_and_the_empty_inherit_value_survive_save(direct_server_with_data, engine):
from playwright.sync_api import expect, sync_playwright
fixture = direct_server_with_data
settings_path = fixture["data_dir"] / "settings.json"
group = '[data-effort-target="s-effort-consciousness"]'
with sync_playwright() as pw:
browser = getattr(pw, engine).launch(headless=True)
try:
page = browser.new_page()
def open_behavior():
_open(page, fixture["url"], 1280)
page.locator('[data-settings-tab="behavior"]').click()
expect(page.locator("#btn-save-settings")).to_be_enabled(timeout=30_000)
def press(value: str, key: str) -> None:
page.keyboard.press("Shift") # keyboard modality: script focus then counts as keyboard focus
page.locator(f'{group} [data-effort-value="{value}"]').focus()
page.keyboard.press(key)
def save() -> dict:
page.locator("#btn-save-settings").click()
expect(page.locator("#settings-status")).to_contain_text("Settings saved", timeout=30_000)
return json.loads(settings_path.read_text(encoding="utf-8"))
open_behavior()
press("high", "Enter")
assert page.locator("#s-effort-consciousness").input_value() == "high"
expect(page.locator("#settings-unsaved-indicator")).to_have_class(__import__("re").compile("is-visible"))
ring = page.evaluate("""(selector) => {
const focused = document.activeElement;
const probe = document.createElement('span');
probe.style.color = 'var(--focus-accent-border)';
document.body.append(probe);
const token = getComputedStyle(probe).color;
probe.remove();
const style = getComputedStyle(focused);
return {visible: focused.matches(':focus-visible'), value: focused.dataset.effortValue, token,
outline: [style.outlineStyle, style.outlineWidth, style.outlineOffset, style.outlineColor]};
}""", group)
assert ring["visible"] and ring["value"] == "high", ring
assert ring["outline"] == ["solid", "2px", "2px", ring["token"]], ring
# Hover paints the button; it never draws the focus ring.
hovered = page.locator(f'{group} [data-effort-value="low"]')
hovered.hover()
assert hovered.evaluate("(node) => getComputedStyle(node).outlineStyle") == "none"
assert save()["OUROBOROS_EFFORT_CONSCIOUSNESS"] == "high"
open_behavior()
assert page.locator(f'{group} [data-effort-value="high"]').get_attribute("aria-pressed") == "true"
press("", " ") # Space on Same as Task / Chat
assert page.locator("#s-effort-consciousness").input_value() == ""
assert save()["OUROBOROS_EFFORT_CONSCIOUSNESS"] == ""
open_behavior()
inherit = page.locator(f'{group} [data-effort-value=""]')
assert inherit.get_attribute("aria-pressed") == "true" and "Same as Task" in inherit.inner_text()
assert page.locator(f'{group} [data-effort-value="high"]').get_attribute("aria-pressed") == "false"
finally:
browser.close()

View file

@ -109,6 +109,55 @@ def test_theme_onboarding_tristate(subscription_ui):
assert page.evaluate("localStorage.getItem('ouroboros.theme')") == 'system'
# Every frame gets a recording stand-in bridge, so a framed document that spoke for its
# parent's window would be caught by its own record, not just by a missing bridge.
_CAPTION_BRIDGE = """
window.pywebview = {api: {set_native_appearance: (...args) => {
const record = (window.top.__captionCalls = window.top.__captionCalls || []);
record.push([location.pathname, ...args]);
return Promise.resolve({ok: true, state: 'scheduled'});
}}};
"""
def test_the_desktop_caption_hears_only_the_top_document(subscription_ui):
"""The real SPA tells its window's bridge the painted palette at boot, when the bridge
announces itself and on each painted change; the setup document framed inside it never
calls, even with a bridge of its own. The frame's choice still reaches the caption: it
is stored, the top document repaints from the storage event and reports that."""
ui = subscription_ui
page = ui['page']
page.emulate_media(color_scheme='dark')
page.add_init_script(_CAPTION_BRIDGE)
page.goto(ui['url'])
page.wait_for_selector('#chat-input')
page.evaluate("window.dispatchEvent(new CustomEvent('pywebviewready'))")
open_appearance(page).locator('[data-theme-choice="light"]').click()
page.evaluate("""() => new Promise((done) => {
const frame = Object.assign(document.createElement('iframe'), {src: '/onboarding', id: 'caption-frame'});
frame.addEventListener('load', () => done(), {once: true});
document.body.append(frame);
})""")
frame = page.frame_locator('#caption-frame')
frame.locator('.onboarding-appearance [data-theme-control] [data-theme-choice="dark"]').click()
page.wait_for_function("document.documentElement.dataset.theme === 'dark'")
page.evaluate("document.getElementById('caption-frame').contentWindow.dispatchEvent(new CustomEvent('pywebviewready'))")
page.wait_for_function("window.__captionCalls.length >= 4")
calls = page.evaluate('window.__captionCalls')
assert {path for path, *_ in calls} == {'/'}, calls
assert [(theme, sequence) for _path, theme, _page, sequence in calls] == [
('dark', 1), ('dark', 2), ('light', 3), ('dark', 4)]
# The page names itself by when it began; the reload that replaces it began later.
began = page.evaluate('performance.timeOrigin')
assert {token for _path, _theme, token, _sequence in calls} == {began}
page.reload()
page.wait_for_selector('#chat-input')
page.evaluate("window.dispatchEvent(new CustomEvent('pywebviewready'))")
page.wait_for_function("window.__captionCalls && window.__captionCalls.length >= 2")
reloaded = page.evaluate('window.__captionCalls')
assert reloaded[0][2] == page.evaluate('performance.timeOrigin') > began, (began, reloaded)
def test_light_contrast_and_mounted_views_follow_the_theme(subscription_ui):
ui = subscription_ui
page = ui['page']

View file

@ -57,7 +57,10 @@ def test_update_channel_setting_is_exposed_and_round_trips_in_ui():
assert "{ value: 'stable', label: 'Stable' }" in settings_ui
assert "{ value: 'qa', label: 'QA' }" in settings_ui
assert "{ value: 'development', label: 'Development' }" in settings_ui
assert "[data-update-channel-group].settings-effort-group" in settings_css
# Three choices take three equal columns from the shared count-derived grid,
# not from a per-control override (tests/test_segmented_layout_browser.py).
assert "[data-update-channel-group]" not in settings_css
assert "--segment-columns: min(var(--segment-count, 4), 4)" in settings_css
def test_update_source_can_be_imported_first_in_a_clean_process():

View file

@ -63,9 +63,12 @@ export function renderPageHeader({
// one generator owns the markup so every group renders identically — the sibling
// of renderTabStrip/.app-tab, but for in-card segmented choices (.ui-segment).
// Buttons keep the data-effort-* hooks the controls layer binds on, plus the
// legacy .settings-effort-* classes that carry settings-specific overrides
// (per-group column counts and accent colors). `modifier` is a bare boolean
// data-attribute (e.g. 'data-enforcement-group') that selects a column override.
// legacy .settings-effort-* classes that carry the settings layout and accents.
// The columns come from the choices actually rendered: the generator writes their
// number once as `--segment-count`, a fact of this rendering rather than a
// per-control setting, and settings.css derives equal columns from it. `modifier`
// is a bare boolean data-attribute (e.g. 'data-enforcement-group') for a per-group
// override such as accent colors.
export function renderSegmentedField({
target,
options = [],
@ -84,7 +87,7 @@ export function renderSegmentedField({
const modAttr = mod ? ` ${mod}` : '';
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
return `
<div class="ui-segment-group settings-effort-group" data-effort-group${modAttr} data-effort-target="${escapeHtml(tgt)}"${titleAttr}>
<div class="ui-segment-group settings-effort-group" data-effort-group${modAttr} data-effort-target="${escapeHtml(tgt)}" style="--segment-count: ${options.length}"${titleAttr}>
${buttons}
</div>
`;

View file

@ -20,7 +20,8 @@ export function bindEffortSegments(root) {
buttons.forEach((button) => {
button.addEventListener('click', () => {
input.value = button.dataset.effortValue || input.value;
// Presence, not truthiness: '' is a real choice (inherit the Task / Chat effort).
input.value = button.dataset.effortValue ?? input.value;
input.dataset.effortTouched = '1';
sync();
});

View file

@ -468,7 +468,6 @@ export function renderSettingsPage() {
<input id="s-task-review-mode" type="hidden" value="auto">
${renderSegmentedField({
target: 's-task-review-mode',
modifier: 'data-task-review-group',
options: [
{ value: 'off', label: 'Off' },
{ value: 'auto', label: 'Auto' },
@ -613,7 +612,6 @@ export function renderSettingsPage() {
<input id="s-update-channel" type="hidden" value="stable">
${renderSegmentedField({
target: 's-update-channel',
modifier: 'data-update-channel-group',
title: 'Applies immediately; no restart required.',
options: [
{ value: 'stable', label: 'Stable' },

View file

@ -678,6 +678,8 @@
.settings-effort-card {
gap: 10px;
/* The card's own width, not the viewport, decides when a segmented group wraps. */
container: effortcard / inline-size;
}
.settings-effort-card label {
@ -689,16 +691,41 @@
letter-spacing: normal;
}
/* One equal-width grid for every segmented control. The renderer writes how many
choices it drew (`--segment-count`, page_header.js renderSegmentedField): up to
four share one row and fill it; a longer scale keeps four equal columns and
leaves its last row partly empty rather than stretching a lone choice. Each
track is that exact fraction of the row, so no math function sits in the
repeat count. */
.settings-effort-group {
--segment-gap: 8px;
--segment-columns: min(var(--segment-count, 4), 4);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(calc((100% - (var(--segment-columns) - 1) * var(--segment-gap)) / var(--segment-columns)), 1fr));
gap: var(--segment-gap);
}
/* A card of 20rem or less stacks two equal columns. Above it today's longest
labels ("Development" in three columns, "Advanced" in four) still sit inside
their buttons, and the default desktop window keeps its four-column effort
scales. */
@container effortcard (max-width: 20rem) {
.settings-effort-group {
--segment-columns: min(var(--segment-count, 4), 2);
}
}
/* Max review cycles: five one-glyph values (1 / 2 / 3 / 5 / ∞) keep one compact
row at every width. */
[data-review-cycles-group].settings-effort-group {
--segment-columns: var(--segment-count, 5);
}
/* .ui-segment — SSOT base look for a segmented single-select button (sibling of
.app-tab). Settings-specific overrides (column counts, accent colors) stay on
.app-tab). Settings-specific overrides (layout, accent colors) stay on
.settings-effort-*; the resting look + centering live here. The flex centering
fixes labels that sat top-left in taller cells (the effort-button bug). */
fixes labels that sat top-left in taller cells (the effort-button bug); a word
longer than its column (a translated label) breaks rather than overflowing. */
.ui-segment {
display: inline-flex;
align-items: center;
@ -714,6 +741,13 @@
cursor: pointer;
font-size: var(--button-font-size);
font-weight: 600;
overflow-wrap: anywhere;
}
/* Keyboard focus is the one DESIGN ring, apart from the hover and selected paint. */
.ui-segment:focus-visible {
outline: 2px solid var(--focus-accent-border);
outline-offset: 2px;
}
.settings-effort-btn.active,
@ -723,25 +757,6 @@
background: rgba(232, 93, 111, 0.12);
}
/* Review enforcement toggle: 2 columns. */
[data-enforcement-group].settings-effort-group {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-update-channel-group].settings-effort-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* Task-result review keeps its compact 3-column shape. */
[data-task-review-group].settings-effort-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
/* Max review cycles: five short segments (1 / 2 / 3 / 5 / ∞) on one row. */
[data-review-cycles-group].settings-effort-group {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
/* Shared disabled segmented-button style. */
.settings-effort-btn:disabled {
cursor: not-allowed;
@ -872,19 +887,6 @@
.settings-action-row > .btn-default {
justify-self: end;
}
.settings-effort-group {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
[data-update-channel-group].settings-effort-group,
[data-task-review-group].settings-effort-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
[data-review-cycles-group].settings-effort-group {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media (max-width: 640px) {

View file

@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderSegmentedField } from '../modules/page_header.js';
import { bindEffortSegments, syncEffortSegments } from '../modules/settings_controls.js';
const labels = (count) => Array.from({ length: count }, (_, index) => ({ value: `v${index}`, label: `Choice ${index}` }));
test('the generator writes how many choices it rendered, once, as the layout input', () => {
for (const count of [2, 3, 4, 5, 7, 8]) {
const html = renderSegmentedField({ target: 's-x', options: labels(count) });
assert.equal((html.match(/data-effort-value=/g) || []).length, count);
assert.match(html, new RegExp(`style="--segment-count: ${count}"`));
assert.equal((html.match(/--segment-count/g) || []).length, 1);
}
const html = renderSegmentedField({ target: 's-x', modifier: 'data-review-cycles-group', options: labels(5) });
assert.match(html, /data-review-cycles-group data-effort-target="s-x" style="--segment-count: 5"/);
assert.throws(() => renderSegmentedField({ options: labels(2) }), /requires target/);
});
/* The smallest DOM the binder touches: a labelled hidden input and a group of buttons. */
function mount(values, initial) {
const input = { value: initial, dataset: {}, previousElementSibling: { tagName: 'LABEL', id: '' } };
const buttons = values.map((value) => {
const classes = new Set();
const listeners = {};
return {
dataset: { effortValue: value }, attrs: {}, classes,
classList: { toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)) },
setAttribute(key, value_) { this.attrs[key] = value_; },
addEventListener: (type, fn) => { listeners[type] = fn; },
click() { listeners.click(); },
};
});
const group = {
dataset: { effortTarget: 's-effort-consciousness' }, attrs: {},
setAttribute(key, value) { this.attrs[key] = value; },
querySelectorAll: () => buttons,
};
const root = {
querySelectorAll: () => [group],
querySelector: (selector) => (selector === '#s-effort-consciousness' ? input : null),
};
bindEffortSegments(root);
const button = (value) => buttons.find((candidate) => candidate.dataset.effortValue === value);
return { root, input, group, button, buttons };
}
test('the empty inherit choice is a real value: high -> Same as Task / Chat saves ""', () => {
const s = mount(['', 'none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], 'high');
assert.equal(s.button('high').attrs['aria-pressed'], 'true');
s.button('').click();
assert.equal(s.input.value, '');
assert.equal(s.input.dataset.effortTouched, '1');
assert.equal(s.button('').attrs['aria-pressed'], 'true');
assert.equal(s.button('high').attrs['aria-pressed'], 'false');
assert.ok(s.button('').classes.has('active'));
s.button('low').click();
s.button('').click();
assert.equal(s.input.value, '');
// A reload of the saved '' marks the inherit choice, not a stale button.
s.input.value = '';
syncEffortSegments(s.root);
assert.deepEqual(s.buttons.filter((candidate) => candidate.classes.has('active')).map((b) => b.dataset.effortValue), ['']);
assert.equal(s.group.attrs.role, 'group');
});

View file

@ -7,7 +7,7 @@ const source = fs.readFileSync(new URL('../theme.js', import.meta.url), 'utf8');
/* A fake just wide enough for the controller: it renders its own buttons, so the
harness has to hand back real child nodes rather than one stubbed element. */
function boot({ saved = null, storage = true, systemLight = false, media = true } = {}) {
function boot({ saved = null, storage = true, systemLight = false, media = true, topLevel = false, framed = false, bridge = null, clock = null } = {}) {
const handlers = new Map();
const events = [];
const root = { dataset: {} };
@ -55,7 +55,12 @@ function boot({ saved = null, storage = true, systemLight = false, media = true
removeEventListener: (key) => handlers.delete(key),
dispatchEvent: (event) => events.push(event),
...(media ? { matchMedia: () => query } : {}),
...(bridge ? { pywebview: { api: bridge } } : {}),
...(clock ? { performance: clock } : {}),
};
// A desktop page is its window's top document; the onboarding overlay is a framed copy.
if (topLevel) window.top = window;
if (framed) window.top = {};
let store = saved;
const localStorage = {
getItem() { if (!storage) throw new Error('disabled'); return store; },
@ -194,3 +199,72 @@ test('both first-party documents load the controller before CSS', () => {
assert.match(fs.readFileSync(new URL('../onboarding_template.html', import.meta.url), 'utf8'), /data-theme-control/);
assert.match(fs.readFileSync(new URL('../modules/settings_ui.js', import.meta.url), 'utf8'), /data-theme-control/);
});
/* The desktop caption follows the painted palette through the window's own bridge. */
function recorder({ reject = false, raise = false } = {}) {
const calls = [];
return {
calls,
set_native_appearance(...args) {
calls.push(args);
if (raise) throw new Error('older shell');
return reject ? Promise.reject(new Error('refused')) : Promise.resolve({ ok: true });
},
};
}
/* A document's own clock: when it began (performance.timeOrigin) and how long ago. */
const documentClock = (timeOrigin, elapsed = 0) => ({ timeOrigin, now: () => elapsed });
test('the top-level desktop page tells its own window each painted palette, newest last', () => {
const bridge = recorder();
const s = boot({ saved: 'dark', systemLight: false, topLevel: true, bridge, clock: documentClock(1791100000000.5) });
s.click('light');
s.click('system'); // a dark OS: the painted palette moves back to dark
s.click('dark'); // the same paint: nothing to tell the frame
// Every call names the page by when it began, so a reload's calls outrank this page's.
assert.deepEqual(bridge.calls, [['dark', 1791100000000.5, 1], ['light', 1791100000000.5, 2],
['dark', 1791100000000.5, 3]]);
});
test('a page the back-forward cache restores speaks from its restore time', () => {
const bridge = recorder();
const clock = documentClock(1791100000000.5);
const s = boot({ saved: 'light', topLevel: true, bridge, clock });
s.handlers.get('pagehide')({ persisted: true }); // kept in the cache: still listening
s.handlers.get('pageshow')({ persisted: false }); // an ordinary load announces nothing more
clock.now = () => 60000;
s.handlers.get('pageshow')({ persisted: true });
assert.deepEqual(bridge.calls, [['light', 1791100000000.5, 1], ['light', 1791100060000.5, 2]]);
});
test('a bridge injected after load hears the palette on pywebviewready, and cleanup releases it', () => {
const s = boot({ saved: 'light', topLevel: true });
assert.ok(s.handlers.has('pywebviewready'));
const bridge = recorder();
s.window.pywebview = { api: bridge };
s.handlers.get('pywebviewready')({ type: 'pywebviewready' });
assert.deepEqual(bridge.calls.map(([theme, , sequence]) => [theme, sequence]), [['light', 1]]);
s.handlers.get('pagehide')({ persisted: false });
assert.equal(s.handlers.size, 0);
});
test("a framed copy never drives its parent's frame", () => {
const bridge = recorder();
const s = boot({ saved: 'dark', framed: true, bridge });
s.click('light');
assert.equal(bridge.calls.length, 0);
assert.equal(s.handlers.has('pywebviewready'), false);
assert.equal(s.handlers.has('pageshow'), false);
assert.equal(s.root.dataset.theme, 'light');
});
test('a refusing or older bridge never stops the page repainting', async () => {
const refusing = boot({ saved: 'dark', topLevel: true, bridge: recorder({ reject: true }) });
refusing.click('light');
const older = boot({ saved: 'dark', topLevel: true, bridge: recorder({ raise: true }) });
older.click('light');
await new Promise((resolve) => setImmediate(resolve)); // a rejection would surface here
assert.equal(refusing.root.dataset.theme, 'light');
assert.equal(older.root.dataset.theme, 'light');
});

View file

@ -73,6 +73,34 @@
syncControls();
};
/* The desktop window's ordinary system caption takes the light or dark tint of
the painted palette (launcher_appearance.py). Only a top-level document speaks
for its window: a framed copy (the onboarding overlay) never drives its
parent's frame. pywebview runs each bridge call on its own thread, so calls
can arrive out of order, even a replaced page's after its successor's: each
carries when this document became the window's page (its time origin on the
browser's monotonic clock, renewed when the back-forward cache restores it)
and its count there, and the launcher keeps only a request newer by that
pair. A browser, or a desktop app built before the bridge, has none. */
const ownsWindow = (() => { try { return window.top === window; } catch { return false; } })();
const clock = window.performance;
let pageSince = clock?.timeOrigin;
let nativeSequence = 0;
const syncNativeFrame = () => {
const api = ownsWindow ? window.pywebview?.api : null;
if (typeof api?.set_native_appearance !== 'function') return;
nativeSequence += 1;
try {
Promise.resolve(api.set_native_appearance(resolved, pageSince, nativeSequence)).catch(() => {});
} catch { /* the frame keeps its tint; the page is unaffected */ }
};
// Restored from the back-forward cache after a newer page: this one is the window's again.
const onPageShow = (event) => {
if (!event.persisted || !clock) return;
pageSince = clock.timeOrigin + clock.now();
syncNativeFrame();
};
// Re-resolve, repaint, and tell mounted views only when the PAINTED theme
// moved. Switching Dark -> System on a dark OS changes the choice without
// changing a single colour, and must not churn charts or diagrams.
@ -82,6 +110,7 @@
resolved = next;
paint();
if (changed) {
syncNativeFrame();
window.dispatchEvent(new CustomEvent('ouro:theme-changed', { detail: { theme: resolved, choice } }));
}
};
@ -95,6 +124,8 @@
};
paint();
// The first paint announces nothing; the bridge, injected after load, hears it then.
syncNativeFrame();
const onClick = (event) => {
const button = event.target.closest?.('[data-theme-choice]');
@ -142,6 +173,8 @@
document.removeEventListener('keydown', onKeydown);
document.removeEventListener('DOMContentLoaded', ready);
window.removeEventListener('storage', onStorage);
window.removeEventListener('pywebviewready', syncNativeFrame);
window.removeEventListener('pageshow', onPageShow);
window.removeEventListener('pagehide', cleanup);
query?.removeEventListener?.('change', onSystemChange);
};
@ -149,6 +182,10 @@
document.addEventListener('keydown', onKeydown);
document.addEventListener('DOMContentLoaded', ready, { once: true });
window.addEventListener('storage', onStorage);
if (ownsWindow) {
window.addEventListener('pywebviewready', syncNativeFrame);
window.addEventListener('pageshow', onPageShow);
}
window.addEventListener('pagehide', cleanup);
query?.addEventListener?.('change', onSystemChange);