mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-23 23:29:32 +00:00
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:
commit
e010c2095d
38 changed files with 1461 additions and 100 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
250
ouroboros/launcher_appearance.py
Normal file
250
ouroboros/launcher_appearance.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)})"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)}
|
||||
|
||||
|
||||
|
|
|
|||
312
tests/test_launcher_appearance.py
Normal file
312
tests/test_launcher_appearance.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
174
tests/test_peer_roster_wait_kinds.py
Normal file
174
tests/test_peer_roster_wait_kinds.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
180
tests/test_segmented_layout_browser.py
Normal file
180
tests/test_segmented_layout_browser.py
Normal 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()
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
66
web/tests/segmented_field.test.js
Normal file
66
web/tests/segmented_field.test.js
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
37
web/theme.js
37
web/theme.js
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue