feat: add opt-in module widget theme bridge

Add a resolved Light/Dark subscription to the existing sandboxed module widget bridge, validate appearance intent metadata, and prove the author-kit path in Chromium and WebKit.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-20 04:22:43 +03:00
parent 3e71431e4f
commit d904c2e04b
17 changed files with 256 additions and 25 deletions

View file

@ -630,7 +630,7 @@ and do not return `PASS` for an item that also has a `FAIL` — the concrete
| 5 | env_allowlist | Is `env_from_settings` a short, justified list of settings keys? Core keys in `FORBIDDEN_SKILL_SETTINGS` (`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_COMPATIBLE_API_KEY`, `CLOUDRU_FOUNDATION_MODELS_API_KEY`, `GIGACHAT_CREDENTIALS`, `GIGACHAT_PASSWORD`, `ANTHROPIC_API_KEY`, `MINIMAX_API_KEY`, `DEEPSEEK_API_KEY`, `GITHUB_TOKEN`, `OUROBOROS_NETWORK_PASSWORD`) may be declared only when the skill genuinely needs that provider/token for its stated purpose; runtime forwards them only after a fresh executable review and a content-bound desktop-launcher owner grant. v5.2.2 dual-track grants: both `type: script` skills (forwarded by `_scrub_env`) and `type: extension` skills (forwarded by `PluginAPIImpl.get_settings`) are eligible; `type: instruction` skills cannot receive core keys. Mark unjustified core-key requests or non-forbidden secrets unrelated to the purpose as FAIL. An empty list is the default and always fine. | critical |
| 6 | timeout_and_output_discipline | Is `timeout_sec` reasonable for the stated workload (default 60, hard cap 300)? Do scripts print to stdout in chunks that the runtime can cap, rather than streaming unbounded output? Unbounded loops without a `break`/timeout path are a concrete FAIL. | advisory |
| 7 | extension_namespace_discipline | `type: extension` only: does the extension register its tool/route/ws-handler/ui-tab under the namespace derived from its `name` (e.g. provider-safe tool/ws names like `ext_<len>_<token>_<surface>`, route `/api/extensions/<name>/…`)? Tool and WS short names must be alphanumeric/underscore and at most 24 characters. Namespace collisions with built-in surfaces are a concrete FAIL. If the extension uses `api.send_ws_message`, are emitted event names short/provider-safe and paired with reviewed host-owned widget `subscription` components rather than arbitrary same-origin JavaScript? If the extension declares streaming UI, is it a reviewed extension route consumed by a host-owned `stream` component? A reviewed `module` widget may also consume the skill's own routes (including streaming responses) and the skill's namespaced WebSocket events through the host-mediated bridge (`OuroborosWidget.fetch` / `OuroborosWidget.onEvent`), which is not arbitrary same-origin JavaScript. If the extension owns background resources (threads, sockets, EventSource clients, subprocesses), does it register cleanup with `api.on_unload(callback)`? If the extension declares a widget render block, is it one of the host-owned schemas (`iframe`, `module`, or declarative v1: forms/actions, markdown/code, JSON/kv/table, tabs/chart, stream/subscription, progress/poll, file/gallery/media, map/calendar/kanban, group/metric/callout), with media sourced from extension routes or safe data URLs and no arbitrary same-origin JavaScript? Nested interactive group/tab children must use stable identity and one host-owned lifecycle, while `subscription.render` stays transitively passive. For non-extension skills, verdict PASS with reason "Not applicable — type != extension." | severity-driven for applicable extensions |
| 8 | widget_module_safety | **v5.7.0+. ``kind: "module"`` widgets only.** The host fetches reviewed ``widget.js`` through ``GET /api/extensions/<skill>/module/<entry>``, embeds the source into a sandboxed opaque-origin ``<iframe srcdoc sandbox="allow-scripts allow-pointer-lock allow-downloads" allow="autoplay; fullscreen; clipboard-write">`` with no ``allow-same-origin`` — ``document.cookie``, ``localStorage``, and ``sessionStorage`` throw ``SecurityError`` there by construction and need no source review — and injects a parent-mediated ``fetch`` bridge that rejects paths outside the owning skill route prefix. Reviewers confirm at the source level what the sandbox cannot: (a) no ``fetch``/``XMLHttpRequest`` URL outside ``/api/extensions/<skill>/`` and no bespoke ``postMessage`` protocol to ``window.parent`` beyond the host bridge; (b) the declared launch policy ``render.start`` (SSOT ``ouroboros/extension_ui_validation.py::WIDGET_START_MODES``; see CREATING_SKILLS "Launch policy") fits the widget's weight — ``auto`` only for a cheap instrument, ``manual`` for a program that should not run all the time, ``retain`` only for a program that genuinely must keep running while the owner is elsewhere and stays cheap while hidden; (c) a widget with state worth keeping registers ``window.__ouroWidgetOnDispose(fn)`` (never assigns over it) and saves that state through the skill's own routes, because the frame is disposable. Acceptable interactions: ``fetch('/api/extensions/<skill>/...')`` (through the host bridge), ``window.OuroborosWidget.fetch('/api/extensions/<skill>/...')``, and host-supplied data attributes. Mark non-module widgets and non-extension skills PASS with reason "Not applicable". | severity-driven when kind=module |
| 8 | widget_module_safety | **v5.7.0+. ``kind: "module"`` widgets only.** The host fetches reviewed ``widget.js`` through ``GET /api/extensions/<skill>/module/<entry>``, embeds the source into a sandboxed opaque-origin ``<iframe srcdoc sandbox="allow-scripts allow-pointer-lock allow-downloads" allow="autoplay; fullscreen; clipboard-write">`` with no ``allow-same-origin`` — ``document.cookie``, ``localStorage``, and ``sessionStorage`` throw ``SecurityError`` there by construction and need no source review — and injects a parent-mediated ``fetch`` bridge that rejects paths outside the owning skill route prefix. Reviewers confirm at the source level what the sandbox cannot: (a) no ``fetch``/``XMLHttpRequest`` URL outside ``/api/extensions/<skill>/`` and no bespoke ``postMessage`` protocol to ``window.parent`` beyond the host bridge; (b) the declared launch policy ``render.start`` (SSOT ``ouroboros/extension_ui_validation.py::WIDGET_START_MODES``; see CREATING_SKILLS "Launch policy") fits the widget's weight — ``auto`` only for a cheap instrument, ``manual`` for a program that should not run all the time, ``retain`` only for a program that genuinely must keep running while the owner is elsewhere and stays cheap while hidden; (c) a widget with state worth keeping registers ``window.__ouroWidgetOnDispose(fn)`` (never assigns over it) and saves that state through the skill's own routes, because the frame is disposable; (d) a module that declares ``render.appearance: host`` uses the optional ``OuroborosWidget.onTheme(callback)`` bridge and proves both resolved palettes in a real consumer, while ``independent``/``fixed`` modules remain author-owned and are not falsely treated as host-adaptive. The declaration is author/reviewer intent, not a source-level proof and not a runtime gate for legacy payloads. Acceptable interactions: ``fetch('/api/extensions/<skill>/...')`` (through the host bridge), ``window.OuroborosWidget.fetch('/api/extensions/<skill>/...')``, ``window.OuroborosWidget.onTheme(callback)``, and host-supplied data attributes. Mark non-module widgets and non-extension skills PASS with reason "Not applicable". | severity-driven when kind=module |
| 9 | inject_chat_minimization | Does any use of the `inject_chat` permission have a narrow, user-facing transport purpose? The Host Service enforces token auth, skill-source attribution, rate limits, in-flight limits, fresh executable review, enablement, and explicit content-hash-bound grants. Reviewed chat transports may carry the same raw owner text as direct chat, including slash commands such as `/panic`, `/restart`, `/review`, `/evolve`, `/bg`, and `/status`; reviewers must evaluate whether the transport itself is authorized, attributable, bounded, and user-facing rather than treating slash-shaped text as automatically forbidden. A skill that accepts external inbound traffic must still show local defense-in-depth appropriate to its transport: owner/chat binding or an equivalent access rule, bounded polling/backpressure, and no unaudited broadcast to unrelated parties. Missing local defense-in-depth is a concrete FAIL for network transports. Mark PASS with reason "Not applicable" when `inject_chat` is not declared. | critical |
| 10 | event_subscription_minimization | Are `subscribe_event` and `subscribe_events` limited to the minimum host event topics required by the skill? `chat.outbound`, `chat.typing`, `chat.photo`, `chat.video`, `chat.document`, and `chat.links` expose owner/agent conversation data (including delivered file bytes and outbound link actions) and require explicit justification. Wildcards, undeclared topics, or forwarding subscribed chat content to unrelated external services are concrete FAILs. Mark PASS with reason "Not applicable" when `subscribe_event` is not declared. | critical |
| 11 | companion_process_safety | For `companion_process` / `supervised_task` skills: is every command declared as an argument list (not shell string), using an allowlisted runtime, with no writes outside `skill_dir` / `state_dir`, no unbounded restart loop, and cleanup on unload/panic? Does the process avoid inheriting secrets except through reviewed `env_from_settings` grants? Mark PASS with reason "Not applicable" when no long-lived process/task is declared — a transient `subprocess.run`/`subprocess.Popen` invocation of a build tool like `ffmpeg`, `ImageMagick`, or `git` inside a normal request handler is NOT a long-lived companion process and does not trigger this item (its safety belongs under items 4 / 6 / 13). | severity-driven when applicable |

View file

@ -953,6 +953,7 @@ ui_tab:
kind: module
entry: widget.js
start: manual # auto | manual | retain — see "Launch policy" below
appearance: host # host | independent | fixed (author intent)
```
The manifest declaration is checked during preflight and review; it does not
@ -1040,6 +1041,18 @@ are the one exception — "What the frame may do" below). The bridge exposes:
host strips its own namespace prefix. The first listener subscribes the frame,
the last unsubscribe stops delivery, and other skills' events never reach it.
- **`OuroborosWidget.onTheme(callback)`** is an optional resolved-palette
subscription for module widgets. The callback receives `light` or `dark`
through the nonce-bound parent bridge and returns an unsubscribe function.
The module applies the value itself, commonly with
`document.documentElement.dataset.theme = theme`; the host never injects CSS,
changes the child DOM or forces a remount. The first callback receives the
resolved value embedded in the frame's initial document, later changes arrive
on `ouro:theme-changed`, and disposal releases the parent subscription. Route
iframes have no bridge. A module declaration may record
`render.appearance: host | independent | fixed` for author/reviewer intent;
the declaration does not gate legacy modules or prove that the source repaints.
- **`OuroborosWidget.download(name, source)`** saves an existing `Blob`, a
`data:` URL, or a URL under this skill's extension route prefix. It resolves
to the host's delivery result or rejects with a visible error. In the desktop

View file

@ -869,6 +869,7 @@ inside its own module or route-iframe page, override them, or design a completel
independent interface. `.ouro-ui` supplies font and native dark-control context;
the named classes opt controls into the recipes, with no page-wide reset.
The kit reads the installed source at a new mount; retained frames keep the styling
they loaded. It introduces no theme polling, forced remount or mandatory visual
conformance. Author layout, validation, operations and loading feedback remain
they loaded. A module may opt into the existing `OuroborosWidget.onTheme` signal
and apply its own `data-theme` rules, but the kit introduces no theme polling,
forced remount or mandatory visual conformance. Author layout, validation, operations and loading feedback remain
author-owned; the small source recipes are in `docs/examples/author_ui_kit/`.

View file

@ -32,8 +32,10 @@ widget disposers release theme subscriptions; Evolution returns a disposer to
`--select-arrow` tokens, since variables cannot interpolate inside a data URI.
Independent iframe documents do not inherit the host's tokens or stored choice.
The optional author UI kit supplies styles/primitives, not a hot-theme protocol;
there is no forced remount. Desktop `webview.start(private_mode=False)` requests
The optional author UI kit supplies styles/primitives. A module may opt into the
resolved host palette through `OuroborosWidget.onTheme(callback)`; the callback
is opt-in, applies no styling automatically, and does not force a remount. Route
iframes have no bridge and remain author-owned. 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. Profile identity, origin and platform storage
@ -198,11 +200,11 @@ Declarative widgets support forms and actions, status/data/text/code/markdown, t
#### Module widget bridge
A module widget receives one parent-mediated I/O bridge on a per-mount nonce — the frame's only scriptable network path, since `connect-src` is closed — which keeps useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM or broad API authority. `OuroborosWidget.fetch` (also the frame's `fetch`) is relayed by the parent, which accepts only the exact owning prefix under `/api/extensions/<skill>/...`, sends same-origin credentials, refuses to follow a redirect, and streams the answer back as a real `Response` over a `ReadableStream` — no default timeout (the author's `init.signal` or `init.timeoutMs` aborts), while declarative requests and the module source load keep a 25-second bound. The skill's namespaced WebSocket events arrive through `OuroborosWidget.onEvent`, filtered by the card's `ws_prefix`; out-of-process responses ride the same framed stream with no total or pre-header timer. Downloads reuse the common native/browser save owners, and external links go through `ui_helpers.openExternalViaHostBridge` on the same bridge (trusted anchor clicks, `OuroborosWidget.openExternal`, the frame's no-handle `window.open`): the host hands off to the native opener, the ready Telegram SDK or the browser before any asynchronous work, and a null `noopener` handle is not proof of failure. The out-of-process WS push (`POST /ui/ws-message`) admits a 60-message burst reserve per skill refilling one message per second and refuses the excess with a typed 429 (§12). The module source endpoint (`GET /api/extensions/{skill}/module/{entry:path}`) authorizes against the live loader registration only and serves the declared entry or any reviewed sibling `.js`/`.mjs` from the texts captured when the bundle registered — an edit after load is not served until the skill reloads — answering with `Access-Control-Allow-Origin: *` because the requesting frame is an opaque origin. The same nonce carries the fault channel: an in-frame script error, unhandled rejection or CSP violation is posted as one bounded, deduplicated `ouro-widget-error` message into the card's own status slot while the lifecycle state stays running, because the frame is still mounted; the frame also exposes `data-widget-content-height` and `data-widget-frame-capped`, so a widget pinned at its ceiling is distinguishable from one that painted nothing.
A module widget receives one parent-mediated I/O bridge on a per-mount nonce — the frame's only scriptable network path, since `connect-src` is closed — which keeps useful route I/O without giving reviewed skill JavaScript the SPA's cookies, DOM or broad API authority. `OuroborosWidget.fetch` (also the frame's `fetch`) is relayed by the parent, which accepts only the exact owning prefix under `/api/extensions/<skill>/...`, sends same-origin credentials, refuses to follow a redirect, and streams the answer back as a real `Response` over a `ReadableStream` — no default timeout (the author's `init.signal` or `init.timeoutMs` aborts), while declarative requests and the module source load keep a 25-second bound. The skill's namespaced WebSocket events arrive through `OuroborosWidget.onEvent`, filtered by the card's `ws_prefix`; out-of-process responses ride the same framed stream with no total or pre-header timer. Downloads reuse the common native/browser save owners, and external links go through `ui_helpers.openExternalViaHostBridge` on the same bridge (trusted anchor clicks, `OuroborosWidget.openExternal`, the frame's no-handle `window.open`): the host hands off to the native opener, the ready Telegram SDK or the browser before any asynchronous work, and a null `noopener` handle is not proof of failure. The out-of-process WS push (`POST /ui/ws-message`) admits a 60-message burst reserve per skill refilling one message per second and refuses the excess with a typed 429 (§12). The module source endpoint (`GET /api/extensions/{skill}/module/{entry:path}`) authorizes against the live loader registration only and serves the declared entry or any reviewed sibling `.js`/`.mjs` from the texts captured when the bundle registered — an edit after load is not served until the skill reloads — answering with `Access-Control-Allow-Origin: *` because the requesting frame is an opaque origin. The same nonce carries the fault channel: an in-frame script error, unhandled rejection or CSP violation is posted as one bounded, deduplicated `ouro-widget-error` message into the card's own status slot while the lifecycle state stays running, because the frame is still mounted; the frame also exposes `data-widget-content-height` and `data-widget-frame-capped`, so a widget pinned at its ceiling is distinguishable from one that painted nothing. An opted-in module may also subscribe to the host's resolved `light`/`dark` value through `OuroborosWidget.onTheme(callback)`; delivery is nonce/source-bound, retained frames continue receiving it, and disposal releases the subscription. The parent never injects a palette or grants theme-setting authority.
#### Author UI kit
Optional author controls reuse the SAME installed CSS and pure field/status source, not a second declarative renderer. `ouroboros.server_web.read_author_kit_assets(request.app.state.repo_dir)` reads the fixed `web/ui.css` and `web/modules/ui_primitives.js` through the serving-root web resolver and creates no endpoint, cache or state; `docs/examples/author_ui_kit/` holds the two ordinary recipes (a module fetches the texts through its own GET route and `OuroborosWidget.fetch`; a route iframe embeds them under its own nonce CSP); neither changes auth, the bridge, the sandbox or the widget schema. Authors opt controls into the named classes and `.ouro-ui` and may override or omit the kit; there is no body reset, required conformance, hot-theme protocol or forced remount; retained frames keep their loaded copy, and a failed kit load is shown inside that author application. Tests: `tests/test_author_ui_kit.py`, `tests/test_author_ui_kit_browser.py`.
Optional author controls reuse the SAME installed CSS and pure field/status source, not a second declarative renderer. `ouroboros.server_web.read_author_kit_assets(request.app.state.repo_dir)` reads the fixed `web/ui.css` and `web/modules/ui_primitives.js` through the serving-root web resolver and creates no endpoint, cache or state; `docs/examples/author_ui_kit/` holds the two ordinary recipes (a module fetches the texts through its own GET route and `OuroborosWidget.fetch`; a route iframe embeds them under its own nonce CSP); neither changes auth, the sandbox or the widget schema. Authors opt controls into the named classes and `.ouro-ui` and may override or omit the kit; a module may opt into `OuroborosWidget.onTheme(callback)` and apply `data-theme` itself, while non-subscribing and route frames remain independent. There is no body reset, forced remount or automatic palette injection; retained frames keep their loaded copy, and a failed kit load is shown inside that author application. Tests: `tests/test_author_ui_kit.py`, `tests/test_author_ui_kit_browser.py`.
#### Out-of-process extension responses

View file

@ -8,7 +8,7 @@ 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. Persistent desktop storage must be requested at every first-party `webview.start`; verify the rebuilt launcher through full quit/relaunch, not a server restart or source assertion. 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`.
- **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, including an opted-in module's `OuroborosWidget.onTheme` callback. A module receives only the resolved `light`/`dark` signal and applies its own styling; there is no automatic CSS injection or remount, and route iframes remain independent. Persistent desktop storage must be requested at every first-party `webview.start`; verify the rebuilt launcher through full quit/relaunch, not a server restart or source assertion. 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 and writes only `--masonry-*` custom properties; never move `<article>` nodes to reorder, because a moved `<iframe>` reloads. Widget order 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.
- **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).
@ -91,4 +91,4 @@ Enforcement: `tests/test_widgets_ui_static.py` at commit tier; in the release-ti
Use `ouroboros.server_web.read_author_kit_assets(request.app.state.repo_dir)` to read the fixed installed `web/ui.css` and `web/modules/ui_primitives.js` sources for an author-owned page (what the kit is and is not: ARCHITECTURE §3 "Author UI kit"). Resolve at the page/kit GET that serves a new mount, not at extension registration; the request root is propagated by both in-process and out-of-process dispatch. No bundle cache, new endpoint or auth exception belongs in the helper.
`docs/examples/author_ui_kit/` holds the two ordinary extension recipes. Revoke temporary Blob URLs; keep the kit optional and author-overridable, with no theme poller or forced remount; add no opaque `/static` request, bridge message or widget schema flag. `test_author_ui_kit.py` and `test_author_ui_kit_browser.py` cover source-root delivery and actual framed consumers; they do not certify an arbitrary author's CSP or application.
`docs/examples/author_ui_kit/` holds the two ordinary extension recipes. Revoke temporary Blob URLs; keep the kit optional and author-overridable, with no theme poller or forced remount. The existing module bridge may carry the author's opt-in `onTheme` subscription; this is not a kit-specific message or an automatic style contract. `test_author_ui_kit.py` and `test_author_ui_kit_browser.py` cover source-root delivery and actual framed consumers; they do not certify an arbitrary author's CSP or application.

View file

@ -38,8 +38,11 @@ Settings → Appearance controls the host document only. Module and route iframe
have independent roots: CSS variables and `ouroboros.theme` do not propagate.
The kit delivers a stylesheet snapshot, not the host's current choice; without
an author-set `data-theme="light"` it uses that stylesheet's default palette.
There is no theme message, poller or forced remount. Do not assume that fetching
the kit again synchronizes appearance.
Modules may opt into the host's resolved palette with
`OuroborosWidget.onTheme(theme => { document.documentElement.dataset.theme = theme; })`.
Keep the returned unsubscribe in the module disposer. The bridge delivers
`light` or `dark` without applying styles or forcing a remount; route iframes
have no bridge. Fetching the kit again alone does not synchronize appearance.
Authors may set their own root's `data-theme`, track their document's media
query, or keep a fixed palette. Native `Canvas`/`CanvasText` colours follow the

View file

@ -49,7 +49,7 @@ def register(api):
api.register_route("author-kit", author_kit, methods=("GET",))
api.register_route("page", page, methods=("GET",))
api.register_ui_tab("module", "Shared controls", render={
"kind": "module", "entry": "widget.js",
"kind": "module", "entry": "widget.js", "appearance": "host",
})
api.register_ui_tab("page", "Independent page", render={
"kind": "iframe", "route": "page",

View file

@ -2,6 +2,16 @@
(async () => {
const root = document.getElementById('root');
const embedded = document.getElementById('author-kit-source');
let themeOff = () => {};
const dispose = () => { themeOff(); themeOff = () => {}; };
if (typeof window.OuroborosWidget?.onTheme === 'function') {
themeOff = window.OuroborosWidget.onTheme((theme) => {
document.documentElement.dataset.theme = theme;
});
}
if (typeof window.__ouroWidgetOnDispose === 'function') {
window.__ouroWidgetOnDispose(dispose);
}
// Author layout and readable native colors also work before the kit loads.
const style = document.createElement('style');
if (root.dataset.styleNonce) style.nonce = root.dataset.styleNonce;

View file

@ -385,6 +385,12 @@ class PluginAPI(Protocol):
module served only for a live tab and bridged to this skill's route prefix.
Same-origin SPA modules are outside this contract.
A module may declare ``render.appearance`` as ``host``, ``independent`` or
``fixed`` for author/reviewer intent. The declaration does not style the
frame or gate legacy payloads: a module that wants the resolved host
palette opts into ``OuroborosWidget.onTheme(callback)``. Declarative
widgets already inherit host appearance; route iframes have no bridge.
``render.start`` declares the card's launch policy: ``"auto"`` starts when
the Widgets page is shown and stops when the owner leaves; ``"manual"``
shows a Start button and leaving the page is an ordered Stop; ``"retain"``

View file

@ -8,7 +8,7 @@ import pathlib
import re
from typing import Any, Dict
from ouroboros.contracts.plugin_api import ExtensionRegistrationError, VALID_EXTENSION_ROUTE_METHODS
from ouroboros.contracts.plugin_api import VALID_EXTENSION_ROUTE_METHODS, ExtensionRegistrationError
from ouroboros.skill_loader import SkillPayloadUnreadable, _iter_payload_files
_EXTENSION_SHORT_MAX = 24
@ -36,6 +36,9 @@ WIDGET_FRAME_MAX_HEIGHT = 8192
# enum; ``gateway/ui_preferences.py`` imports it for the owner's per-card override.
WIDGET_START_MODES = ("auto", "manual", "retain")
_START_MODE_DEFAULTS = {"module": "manual", "iframe": "manual", "declarative": "auto"}
# Appearance is an author declaration for framed module surfaces. Delivery is
# still opt-in through OuroborosWidget.onTheme so legacy payloads remain live.
WIDGET_APPEARANCE_MODES = ("host", "independent", "fixed")
def _text(value: Any) -> str:
@ -124,6 +127,24 @@ def _validate_start_mode(render: Dict[str, Any], *, kind: str) -> None:
render["start"] = mode
def _validate_appearance(render: Dict[str, Any], *, kind: str) -> None:
"""Validate the optional appearance declaration without changing legacy payloads."""
if "appearance" not in render:
return
raw = render.get("appearance")
appearance = raw.strip() if isinstance(raw, str) else raw
if kind != "module":
raise ExtensionRegistrationError(
"ui render appearance is supported for module widgets only"
)
if appearance not in WIDGET_APPEARANCE_MODES:
raise ExtensionRegistrationError(
f"ui render appearance {appearance!r} is unsupported; "
f"expected one of {list(WIDGET_APPEARANCE_MODES)}"
)
render["appearance"] = appearance
def _validate_frame_geometry(render: Dict[str, Any], *, kind: str) -> None:
"""Normalize the bounded geometry shared by framed widget renderers."""
for key in ("height", "max_height"):
@ -395,6 +416,7 @@ def validate_ui_render(render: Dict[str, Any]) -> Dict[str, Any]:
if kind not in _UI_RENDER_KINDS:
raise ExtensionRegistrationError(f"ui render kind {kind!r} is unsupported; expected one of {sorted(_UI_RENDER_KINDS - {''})}")
_validate_start_mode(clean, kind=kind)
_validate_appearance(clean, kind=kind)
if kind in {"iframe", "module"}:
_validate_frame_geometry(clean, kind=kind)
if kind == "iframe" and not _text(clean.get("route")):
@ -484,6 +506,7 @@ def validate_settings_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
__all__ = [
"WIDGET_FRAME_MAX_HEIGHT",
"WIDGET_FRAME_MIN_HEIGHT",
"WIDGET_APPEARANCE_MODES",
"WIDGET_START_MODES",
"validate_runtime_ui_render",
"read_module_sources",

View file

@ -5,11 +5,11 @@ client. This tests the real network gate without exposing a test server on LAN.
"""
from __future__ import annotations
import os
import json
from pathlib import Path
import os
import re
import shutil
from pathlib import Path
import pytest
@ -20,6 +20,7 @@ pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
REPO = Path(__file__).resolve().parents[1]
_HOST = """<!doctype html><html><head><meta charset="utf-8">
<script src="/static/theme.js"></script>
<link rel="stylesheet" href="/static/ui.css"><link rel="stylesheet" href="/static/style.css">
</head><body><h1>Installed author controls</h1><div id="consumers"></div>
<script type="module">
@ -33,7 +34,7 @@ window.mountExample = async (id, kind) => {
document.getElementById('consumers').append(card);
const mount = card.querySelector('.mount'), tab = {skill:'export_widget', ws_prefix:'ext:export_widget:'};
window.disposers[id] = kind === 'module'
? await mountModuleWidget(mount, tab, {entry:'widget.js', height:420}, null, window.handlers)
? await mountModuleWidget(mount, tab, {entry:'widget.js', height:420, appearance:'host'}, null, window.handlers)
: mountRouteIframeWidget(mount, tab, {route:kind, height:420});
};
await mountExample('module-old', 'module');
@ -77,6 +78,7 @@ def author_kit_server(tmp_path, monkeypatch):
import uvicorn
from starlette.responses import HTMLResponse
from starlette.routing import Mount, Route
from ouroboros import server_auth
from ouroboros.server_web import NoCacheStaticFiles
from tests import _extension_loader_shared as extension_fixture
@ -173,6 +175,7 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path
try:
context = browser.new_context(viewport={"width": 1100, "height": 850}, accept_downloads=True)
page = context.new_page()
page.emulate_media(color_scheme="dark")
errors = []
style_mismatches = []
browser_requests = []
@ -187,6 +190,27 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path
page.get_by_role("button", name="Unlock", exact=True).click()
page.wait_for_function("window.ready === true")
module, route, custom = [_frame(page, name) for name in ("module-old", "page-old", "custom")]
for frame in (module, route):
frame.locator('.ui-control').first.wait_for()
module_node = page.locator('[data-widget-key="module-old"] iframe').element_handle()
assert route.evaluate("window.kitCspViolations") == []
assert module.evaluate("document.documentElement.dataset.theme") == "dark"
page.evaluate("() => window.ouroTheme.set('light')")
module.wait_for_function("document.documentElement.dataset.theme === 'light'")
assert page.evaluate(
"node => document.querySelector('[data-widget-key=\\\"module-old\\\"] iframe') === node",
module_node,
)
assert module.evaluate(
"getComputedStyle(document.querySelector('.ui-control')).backgroundColor"
) == "rgb(245, 246, 248)"
theme_evidence = Path(os.environ.get("OUROBOROS_UI_EVIDENCE_OUT", str(tmp_path / "evidence")))
theme_evidence.mkdir(parents=True, exist_ok=True)
module.locator('body').screenshot(
path=str(theme_evidence / f"author-kit-{browser_name}-module-light.png")
)
page.evaluate("() => window.ouroTheme.set('dark')")
module.wait_for_function("document.documentElement.dataset.theme === 'dark'")
for frame in (module, route):
frame.get_by_role("button", name="Preview", exact=True).wait_for()
assert frame.get_by_label("Title", exact=True).input_value() == "My notes"
@ -204,7 +228,8 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path
assert frame.locator('[role="status"]').inner_text() == "Personal: Grid, disabled"
assert frame.locator('[role="status"]').get_attribute("data-tone") == "ok"
frame.evaluate("document.activeElement.blur()")
assert frame.evaluate("window.kitCspViolations") == []
if frame is module:
assert frame.evaluate("window.kitCspViolations") == []
page.mouse.move(0, 0)
assert route.evaluate("typeof window.OuroborosWidget") == "undefined"
assert not any('/static/' in request.url and request.frame == route for request in browser_requests)

View file

@ -316,7 +316,7 @@ def test_register_ui_tab_promotes_bounded_frame_geometry(tmp_path):
tmp_path,
"frameui",
"def register(api):\n"
" api.register_ui_tab('quota', 'Quota', render={'kind': 'module', 'entry': 'widget.js', 'height': 640.4, 'max_height': 4096})\n",
" api.register_ui_tab('quota', 'Quota', render={'kind': 'module', 'entry': 'widget.js', 'height': 640.4, 'max_height': 4096, 'appearance': ' host '})\n",
permissions=["widget"],
)
err = extension_loader.load_extension(loaded, lambda: {}, drive_root=drive_root)
@ -326,6 +326,7 @@ def test_register_ui_tab_promotes_bounded_frame_geometry(tmp_path):
assert tab["max_height"] == 4096
assert tab["render"]["height"] == 640
assert tab["render"]["max_height"] == 4096
assert tab["render"]["appearance"] == "host"
extension_loader.unload_extension("frameui")
@ -397,6 +398,34 @@ def test_validate_ui_render_normalizes_module_entry_once():
assert validate_ui_render({"kind": "module", "entry": " widget.js "})["entry"] == "widget.js"
@pytest.mark.parametrize("appearance", ["host", "independent", "fixed"])
def test_validate_ui_render_accepts_module_appearance_intent(appearance):
render = validate_ui_render({
"kind": "module", "entry": "widget.js", "appearance": f" {appearance} ",
})
assert render["appearance"] == appearance
def test_validate_ui_render_leaves_legacy_module_appearance_absent():
render = validate_ui_render({"kind": "module", "entry": "widget.js"})
assert "appearance" not in render
@pytest.mark.parametrize("appearance", ["", "system", 1, False])
def test_validate_ui_render_rejects_invalid_module_appearance(appearance):
with pytest.raises(ExtensionRegistrationError, match="appearance"):
validate_ui_render({"kind": "module", "entry": "widget.js", "appearance": appearance})
@pytest.mark.parametrize("kind_render", [
{"kind": "declarative", "schema_version": 1, "components": [], "appearance": "host"},
{"kind": "iframe", "route": "view", "appearance": "host"},
])
def test_validate_ui_render_rejects_appearance_on_non_module(kind_render):
with pytest.raises(ExtensionRegistrationError, match="module widgets only"):
validate_ui_render(kind_render)
_UI_TAB_REJECTION_CASES = [
(
"unsupported_render_kind",

View file

@ -233,7 +233,7 @@ def test_widgets_keep_iframe_sandbox_locked_down():
assert "const csp = moduleFrameCsp(tab.skill);" in module
assert "connect-src" not in source
assert "'unsafe-eval'" not in source
assert "window.OuroborosWidget = { fetch: request, onEvent, download, openExternal: (url) => openExternal(url) };" in source
assert "window.OuroborosWidget = { fetch: request, onEvent, onTheme, download, openExternal: (url) => openExternal(url) };" in source
assert "module widget fetch outside extension route prefix" in source

View file

@ -16,19 +16,22 @@ export function bridgeChunkBuffer(view) {
// ouro-widget-fetch-pull {id} · ouro-widget-download {id, name, source}
// ouro-widget-open-external {id, url}
// ouro-widget-events {op: subscribe | unsubscribe} · ouro-widget-disposed
// ouro-widget-theme {op: subscribe | unsubscribe}
// ouro-widget-error {kind: error | rejection | csp, message, source, line}
// parent → child ouro-widget-fetch-chunk {id, phase: headers | data | end | error, …}
// ouro-widget-open-external-result {id, result}
// ouro-widget-event {event, data} · ouro-widget-dispose
// ouro-widget-event {event, data} · ouro-widget-theme {theme: light | dark}
// · ouro-widget-dispose
// Every bridged fetch streams: the child rebuilds a real Response over a
// ReadableStream fed by `data` frames (binary by default), so text/json/blob
// and incremental body reads all work. No default timeout — `init.timeoutMs`
// is the author's opt-in bound; `init.signal` aborts through the parent.
export function moduleBridgeScript(nonce, routeBase = '') {
export function moduleBridgeScript(nonce, routeBase = '', initialTheme = '') {
return `
(() => {
const nonce = ${JSON.stringify(nonce)};
const routeBase = ${JSON.stringify(routeBase)};
const initialTheme = ${JSON.stringify(initialTheme)};
const safeExternalUrl = (${safeExternalUrl.toString()});
let seq = 0;
let disposing = false;
@ -41,6 +44,8 @@ export function moduleBridgeScript(nonce, routeBase = '') {
const originalOpen = window.open;
const cleanup = new Set();
const eventListeners = new Set();
const themeListeners = new Set();
let theme = ['light', 'dark'].includes(initialTheme) ? initialTheme : null;
const post = (message) => window.parent.postMessage({ ...message, nonce }, '*');
const abortError = () => new DOMException('The operation was aborted.', 'AbortError');
const onDispose = (fn) => {
@ -48,6 +53,22 @@ export function moduleBridgeScript(nonce, routeBase = '') {
if (disposing) { try { fn(); } catch {} return; }
cleanup.add(fn);
};
const notifyTheme = (callback) => {
try { callback(theme); } catch (error) { console.error('widget theme listener failed', error); }
};
const onTheme = (callback) => {
if (disposing || disposed || typeof callback !== 'function') return () => {};
themeListeners.add(callback);
if (theme) notifyTheme(callback);
if (themeListeners.size === 1) post({ type: 'ouro-widget-theme', op: 'subscribe' });
return () => {
if (!themeListeners.delete(callback)) return;
if (!themeListeners.size && !disposed) {
theme = null;
post({ type: 'ouro-widget-theme', op: 'unsubscribe' });
}
};
};
// Ordered dispose: every hook runs first (async hooks are awaited and
// the bridge keeps streaming for them), then the parent gets the
// acknowledgement, and only then are pending fetches rejected, open
@ -74,6 +95,8 @@ export function moduleBridgeScript(nonce, routeBase = '') {
externalLinks.forEach(({ reject }) => reject(new Error('widget disposed')));
externalLinks.clear();
eventListeners.clear();
themeListeners.clear();
theme = null;
window.removeEventListener('message', onMessage);
window.removeEventListener('error', onError);
window.removeEventListener('unhandledrejection', onRejection);
@ -89,6 +112,13 @@ export function moduleBridgeScript(nonce, routeBase = '') {
}
// The bridge answers during the hooks; frames are refused only once disposed.
if (disposed) return;
if (msg.type === 'ouro-widget-theme') {
if (disposing || !themeListeners.size || !['light', 'dark'].includes(msg.theme)) return;
if (msg.theme === theme) return;
theme = msg.theme;
themeListeners.forEach(notifyTheme);
return;
}
if (msg.type === 'ouro-widget-event') {
const detail = { type: String(msg.event || ''), data: msg.data };
eventListeners.forEach((callback) => {
@ -311,7 +341,7 @@ export function moduleBridgeScript(nonce, routeBase = '') {
};
window.document?.addEventListener('click', clickExternal);
window.document?.addEventListener('click', clickDownload);
window.OuroborosWidget = { fetch: request, onEvent, download, openExternal: (url) => openExternal(url) };
window.OuroborosWidget = { fetch: request, onEvent, onTheme, download, openExternal: (url) => openExternal(url) };
})();
`;
}

View file

@ -9,6 +9,7 @@ import { apiFetch, extensionRoutePath, extensionRoutePrefix } from './api_client
import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { bridgeChunkBuffer, moduleBridgeScript, moduleResizeScript } from './widget_frame.js';
import { boundedNumber, WIDGET_DISPOSE_ACK_TIMEOUT_MS, WIDGET_REQUEST_TIMEOUT_MS } from './widget_job.js';
import { onThemeChange } from './theme_palette.js';
import { setWidgetCardFault } from './widget_card.js';
import { downloadViaHostBridge, downloadBlobViaHostBridge, openExternalViaHostBridge } from './ui_helpers.js';
@ -137,12 +138,20 @@ export async function mountModuleWidget(mount, tab, render, mountSignal = null,
.replace(/<!--/g, '<\\!--');
const autoHeight = render.height === undefined || render.height === null;
const maxHeight = frameMaxHeight(render);
const bridge = moduleBridgeScript(nonce, `${window.location.origin}${expectedPrefix}`);
const resolvedTheme = () => {
const candidate = window.ouroTheme?.theme || document.documentElement?.dataset?.theme;
return ['light', 'dark'].includes(candidate) ? candidate : 'dark';
};
const initialTheme = resolvedTheme();
const bridge = moduleBridgeScript(nonce, `${window.location.origin}${expectedPrefix}`, initialTheme);
const resizeBridge = autoHeight
? moduleResizeScript(
nonce, WIDGET_FRAME_DEFAULT_HEIGHT, maxHeight, WIDGET_FRAME_BORDER_RESERVE,
)
: '';
// The initial theme is carried inside the bridge bootstrap, not as a
// document attribute: a module must explicitly subscribe before it gets
// appearance data or changes its own palette.
const srcdoc = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${csp}"></head><body><div id="root"></div><script>${bridge}</script><script>${resizeBridge}</script><script>${escapeScript(moduleSource)}</script></body></html>`;
// The document goes in through the `srcdoc` property (no attribute
// escaping round-trip of a module-sized payload); the frame carries the
@ -156,9 +165,26 @@ export async function mountModuleWidget(mount, tab, render, mountSignal = null,
let disposed = false;
let disposing = null;
let onDisposed = null;
let themeSubscribed = false;
let stopTheme = () => {};
const post = (message, transfer = []) => {
if (!disposed) iframe.contentWindow?.postMessage({ ...message, nonce }, '*', transfer);
};
const postTheme = () => {
const theme = resolvedTheme();
if (themeSubscribed) post({ type: 'ouro-widget-theme', theme });
};
const startTheme = () => {
if (themeSubscribed) return;
themeSubscribed = true;
stopTheme = onThemeChange(postTheme);
postTheme();
};
const stopThemeSubscription = () => {
themeSubscribed = false;
stopTheme();
stopTheme = () => {};
};
// The skill's namespaced WebSocket events — the same `ws_prefix` filter the
// declarative `subscription` uses — forwarded only while the child subscribes.
const onWsMessage = (msg) => {
@ -301,6 +327,12 @@ export async function mountModuleWidget(mount, tab, render, mountSignal = null,
setWidgetCardFault(mount.closest('[data-widget-key]'), `${label}: ${detail || 'unknown'}`);
return;
}
if (msg.type === 'ouro-widget-theme') {
if (disposing) return;
if (msg.op === 'subscribe') startTheme();
else if (msg.op === 'unsubscribe') stopThemeSubscription();
return;
}
if (msg.type === 'ouro-widget-events') {
if (msg.op === 'subscribe') messageHandlers?.add(onWsMessage);
else if (msg.op === 'unsubscribe') messageHandlers?.delete(onWsMessage);
@ -338,6 +370,7 @@ export async function mountModuleWidget(mount, tab, render, mountSignal = null,
if (disposed) return;
disposed = true;
clearTimeout(ackTimer);
stopThemeSubscription();
pendingRequests.forEach((controller) => controller.abort());
pendingRequests.clear();
messageHandlers?.delete(onWsMessage);

View file

@ -5,7 +5,7 @@ import { bridgeChunkBuffer, moduleBridgeScript, moduleResizeScript } from '../mo
// Runs the child bootstrap against a fake `window`; `deliver` plays a
// parent → child message, `posted` records child → parent messages.
function bridgeHarness({ active = false, activationApi = true } = {}) {
function bridgeHarness({ active = false, activationApi = true, initialTheme = '' } = {}) {
const posted = [];
const parent = { postMessage(message) { posted.push(message); } };
const listeners = new Map();
@ -24,7 +24,7 @@ function bridgeHarness({ active = false, activationApi = true } = {}) {
if (listeners.get(type) === listener) listeners.delete(type);
},
};
Function('window', moduleBridgeScript('nonce-1'))(window);
Function('window', moduleBridgeScript('nonce-1', '', initialTheme))(window);
const deliver = (data, source = parent) => listeners.get('message')?.({ source, data: { nonce: 'nonce-1', ...data } });
const chunk = (id, phase, extra = {}) => deliver({ type: 'ouro-widget-fetch-chunk', id, phase, ...extra });
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
@ -181,6 +181,41 @@ test('events subscribe on the first listener, deliver {type, data}, unsubscribe
assert.equal(seen.length, 2);
});
test('theme is opt-in, starts from the injected resolved palette and follows live updates', () => {
const { window, posted, deliver } = bridgeHarness({ initialTheme: 'light' });
const seen = [];
const off = window.OuroborosWidget.onTheme((theme) => seen.push(theme));
assert.deepEqual(seen, ['light']);
assert.deepEqual(posted.at(-1), { type: 'ouro-widget-theme', nonce: 'nonce-1', op: 'subscribe' });
// The parent may answer the handshake with the same value; it must not
// repaint a host widget twice at mount.
deliver({ type: 'ouro-widget-theme', theme: 'light' });
assert.deepEqual(seen, ['light']);
deliver({ type: 'ouro-widget-theme', theme: 'system' });
deliver({ type: 'ouro-widget-theme', theme: 'dark' });
assert.deepEqual(seen, ['light', 'dark']);
off();
assert.deepEqual(posted.at(-1), { type: 'ouro-widget-theme', nonce: 'nonce-1', op: 'unsubscribe' });
});
test('theme callbacks reject foreign sources, invalid values and dispose cleanly', async () => {
const { window, listeners, deliver, posted, flush } = bridgeHarness({ initialTheme: 'dark' });
const seen = [];
window.OuroborosWidget.onTheme((theme) => seen.push(theme));
deliver({ type: 'ouro-widget-theme', theme: 'light' }, {});
listeners.get('message')({ source: window.parent, data: { nonce: 'wrong', type: 'ouro-widget-theme', theme: 'light' } });
deliver({ type: 'ouro-widget-theme', theme: 'system' });
assert.deepEqual(seen, ['dark']);
deliver({ type: 'ouro-widget-dispose' });
await flush();
deliver({ type: 'ouro-widget-theme', theme: 'light' });
assert.deepEqual(seen, ['dark']);
const count = posted.length;
const lateOff = window.OuroborosWidget.onTheme(() => {});
lateOff();
assert.equal(posted.length, count);
});
test('dispose awaits hooks (bridge live), acks, then fails pending work and unlistens', async () => {
const { window, posted, listeners, deliver, chunk, flush } = bridgeHarness();
const hookSaw = [];

View file

@ -118,3 +118,24 @@ test('module parent uses the native bridge and detaches the relay after disposal
assert.equal(h.iframe.isConnected, false);
assert.equal(native.length, 1);
});
test('module parent forwards the resolved theme only after child opt-in and releases it on dispose', async (t) => {
const h = await relayHarness(t);
document.documentElement.dataset.theme = 'light';
h.send({ type: 'ouro-widget-theme', op: 'subscribe' });
assert.equal(h.replies.at(-1).theme, 'light');
h.win.ouroTheme = { theme: 'dark' };
h.listeners.get('ouro:theme-changed')?.();
assert.equal(h.replies.at(-1).theme, 'dark');
const replyCount = h.replies.length;
h.send({ type: 'ouro-widget-theme', op: 'unsubscribe' });
assert.equal(h.listeners.has('ouro:theme-changed'), false);
h.win.ouroTheme = { theme: 'light' };
h.listeners.get('ouro:theme-changed')?.();
assert.equal(h.replies.length, replyCount, 'unsubscribe itself is not a reply');
const stopped = h.dispose();
h.send({ type: 'ouro-widget-theme', op: 'subscribe' });
h.send({ type: 'ouro-widget-disposed' });
await stopped;
assert.equal(h.listeners.has('ouro:theme-changed'), false);
});