DSH / Atlas
2026-08-10implementedbug-fix

Pre-Plugin Theme Bootstrap

插件激活前的主题引导

The web shell renders `Loading plugins…` before the browser-side plugin tree activates. ui-theme's token styles arrive with its dynamic client bundle, so the framework-free loading page uses a private light/dark fallback palette. Without an earlier write to `color-scheme` and `body[data-ds-dark-theme]`, a persisted dark preference would still render that page first with its light fallback and then switch to dark when

English

Problem

The web shell renders Loading plugins… before the browser-side plugin tree activates. ui-theme's token styles arrive with its dynamic client bundle, so the framework-free loading page uses a private light/dark fallback palette. Without an earlier write to color-scheme and body[data-ds-dark-theme], a persisted dark preference would still render that page first with its light fallback and then switch to dark when ui-theme's ThemeRuntime and ui-layout's ThemePresenter activate.

dshClient.immediately only includes the bundle in first-stage prefetching; it does not cause the plugin to execute before HTML parsing or the shell's initial render. Changing only the client plugin's loading tier cannot close this window.

Decision

ui-theme's host half answers every webserver/index-inject collection with one body-placed script row (bootThemeInjection), which renderIndex renders as a synchronous inline script immediately after the opening <body> tag. The subscription is unconditional — a composition without a web server never emits the event, so ui-theme still activates and contributes nothing. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.

The host half registers the ui-theme.preference settings section when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the system default. The browser resolves system through prefers-color-scheme, falling back to light when matchMedia is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: document.documentElement.style.colorScheme and body[data-ds-dark-theme].

The bootstrap logic recognizes only the built-in light, dark, and system semantics. It registers no listeners and does not resolve third-party themes or token overrides. After the browser-side plugin tree activates, ThemeRuntime remains authoritative for theme state, and ThemePresenter writes the complete resolved result back to the same DOM state and owns subsequent updates and disposal.

Verification

ui-theme's unit tests cover activation without either optional Host service, the script position, Host-setting precedence, the OS preference, missing matchMedia, input without a body, live settings reads, and disposal of the Host registrations with the plugin fiber. A Chromium scenario for the real web composition selects the durable dark preference, holds the plugin bundle request open to keep the loading page observable, then asserts that the index response produces a dark background, the body attribute, and the root element's color-scheme. The change does not alter the accessibility tree, so it produces no new page golden.

Alternatives considered

Hard-code the logic in apps/web/index.html. This would run at the same point, but static HTML cannot embed the current Host setting and would duplicate the preference resolution and DOM fields owned by ui-theme. The Host transform follows the theme plugin's lifecycle and keeps the application shell unaware of the theme domain.

Make the ui-theme client bundle synchronous or activate it earlier. immediately controls only prefetching; plugin instantiation still occurs after the shell starts running. Blocking the initial render until ThemeRuntime activates would delay the visible loading and error screens and make the shell depend on the plugin tree it monitors to render failures.

Rely only on CSS prefers-color-scheme. Media queries cannot read an explicit persisted choice, so a user who selects dark while the operating system uses light would still see a flash.

Run in <head> and add a temporary class to html. The body does not exist yet, and this would require a set of temporary selectors separate from the final palette attributes. Immediately after <body> is the earliest parse position that can write the final DOM fields directly.

Consequences

The loading page's first frame matches the durable built-in preference and defaults to the OS preference when no settings provider is composed. The index transform reads Host settings for every response, while the inline script contains only the selected built-in value and system resolution. Changes to the built-in preference semantics or ThemePresenter DOM fields must update both the script and ThemeRuntime. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses its private light or dark fallback palette.

中文

问题

Web 壳在浏览器侧插件树激活前呈现 Loading plugins…。ui-theme 的 token 样式随动态客户端 bundle 到达,因此不依赖框架的加载页使用私有的明暗回退配色。如果不提前写入 color-schemebody[data-ds-dark-theme],持久化偏好为深色时,该页面仍会先按浅色回退绘制,再在 ui-theme 的 ThemeRuntime 与 ui-layout 的 ThemePresenter 激活后切为深色。

dshClient.immediately 只把 bundle 纳入第一阶段预取,不会让插件在 HTML 解析或壳首次渲染前执行。仅调整客户端插件的加载档位无法关闭这段时间窗口。

决策

ui-theme 的主机侧以一条 body 定位的 script 行(bootThemeInjection)回应每次 webserver/index-inject 收集,renderIndex 把它渲染为 <body> 起始标签后紧接的一段同步内联脚本。订阅是无条件的——没有 web server 的组合根本不会 emit 该事件,ui-theme 照常激活且不贡献任何行。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。

settings provider 存在时,主机侧会注册 ui-theme.preference settings 分节。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 system。浏览器通过 prefers-color-scheme 解析 system,不支持 matchMedia 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:document.documentElement.style.colorSchemebody[data-ds-dark-theme]

引导逻辑只认识内建的 lightdarksystem 语义,不注册监听器,也不解析第三方主题或 token 覆盖。浏览器侧插件树激活后,ThemeRuntime 仍是主题状态的权威来源,ThemePresenter 会把完整解析结果重新写入同一组 DOM 状态并负责后续更新与释放。

验证

ui-theme 的单元测试覆盖不含任一可选 Host 服务时的激活、脚本位置、Host 设置优先级、系统偏好、缺少 matchMedia、不含 body 的输入、实时读取 settings,以及 Host 注册随插件 fiber 一同释放。真实 Web 组合的 Chromium 场景会选择持久化深色偏好并拦住插件 bundle 请求,使加载页保持可观察,再断言 index 响应产生了深色背景、body 属性和根元素 color-scheme。该变化不改变可访问性树,因此不产生新的页面 golden。

考虑过的替代方案

把逻辑固定写进 apps/web/index.html 这样能在相同时机执行,但静态 HTML 无法嵌入当前 Host 设置,还会复制 ui-theme 拥有的偏好解析和 DOM 字段;Host 转换会跟随主题插件的生命周期,并让应用壳无需了解主题领域。

让 ui-theme 客户端 bundle 同步或更早激活。 immediately 只控制预取,插件实例化仍发生在壳开始运行之后;把首次渲染阻塞到 ThemeRuntime 激活会延后可见的加载与报错界面,也会让壳的故障呈现依赖被它监测的插件树。

只依赖 prefers-color-scheme 的 CSS。 媒体查询无法读取显式持久化选择,因此操作系统为浅色而用户选择深色时仍会闪烁。

<head> 中执行并给 html 添加临时类。 body 此时尚不存在,还需要一套与正式调色板属性不同的临时选择器。紧接 <body> 是能够直接写正式 DOM 字段的最早解析位置。

后果

加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings,而内联脚本只包含选定的内建值与 system 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeRuntime。自定义主题仍会在浏览器插件激活后才完整应用;加载期间,页面使用自己的浅色或深色回退配色。