diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.i18n.yaml new file mode 100644 index 0000000000..13d341ab90 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md +2026-08-10-pre-plugin-theme-bootstrap.md: 276d564dcef60e5130b4b2d0ef7cbc12eeb8b6d6 +2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7162b2d851b50f75b3af6320ad9348383933e50f diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md new file mode 100644 index 0000000000..276d564dce --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md @@ -0,0 +1,37 @@ +# Agent Note: Pre-Plugin Theme Bootstrap + +Status: implemented + +English | [中文](2026-08-10-pre-plugin-theme-bootstrap.zh.md) + +## Problem + +The web shell renders `Loading plugins…` before the browser-side plugin tree activates. The theme tokens are already loaded with the shell styles, but `color-scheme` and `body[data-ds-dark-theme]` are not written until ui-theme's ThemeService and ui-layout's ThemePresenter activate; with a persisted dark preference, the loading page therefore renders first with the light palette and then switches to dark. + +`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 transforms each index HTML document through `ctx.httpServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and React root have not yet run. + +The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) 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, ThemeService 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 ThemeService 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 `` 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 `` 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 ThemeService. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses the light or dark base palette to which that theme resolves. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md new file mode 100644 index 0000000000..7162b2d851 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 插件激活前的主题引导 + +Status: implemented + +[English](2026-08-10-pre-plugin-theme-bootstrap.md) | 中文 + +## Problem + +Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。主题 token 已随壳样式加载,但 `color-scheme` 和 `body[data-ds-dark-theme]` 要等 ui-theme 的 ThemeService 与 ui-layout 的 ThemePresenter 激活后才写入;持久化偏好为深色时,加载页因此先按浅色调色板绘制,再切为深色。 + +`dshClient.immediately` 只把 bundle 纳入第一阶段预取,不会让插件在 HTML 解析或壳首次渲染前执行。仅调整客户端插件的加载档位无法关闭这段时间窗口。 + +## Decision + +ui-theme 的主机侧通过 `ctx.httpServer.tapIndex()` 转换每份 index HTML,在 `` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与 React 根节点尚未运行。 + +settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme` 与 `body[data-ds-dark-theme]`。 + +引导逻辑只认识内建的 `light`、`dark`、`system` 语义,不注册监听器,也不解析第三方主题或 token 覆盖。浏览器侧插件树激活后,ThemeService 仍是主题状态的权威来源,ThemePresenter 会把完整解析结果重新写入同一组 DOM 状态并负责后续更新与释放。 + +## Verification + +ui-theme 的单元测试覆盖不含任一可选 Host 服务时的激活、脚本位置、Host 设置优先级、系统偏好、缺少 `matchMedia`、不含 body 的输入、实时读取 settings,以及 Host 注册随插件 fiber 一同释放。真实 Web 组合的 Chromium 场景会选择持久化深色偏好并拦住插件 bundle 请求,使加载页保持可观察,再断言 index 响应产生了深色背景、body 属性和根元素 `color-scheme`。该变化不改变可访问性树,因此不产生新的页面 golden。 + +## Alternatives considered + +**把逻辑固定写进 `apps/web/index.html`。** 这样能在相同时机执行,但静态 HTML 无法嵌入当前 Host 设置,还会复制 ui-theme 拥有的偏好解析和 DOM 字段;Host 转换会跟随主题插件的生命周期,并让应用壳无需了解主题领域。 + +**让 ui-theme 客户端 bundle 同步或更早激活。** `immediately` 只控制预取,插件实例化仍发生在壳开始运行之后;把首次渲染阻塞到 ThemeService 激活会延后可见的加载与报错界面,也会让壳的故障呈现依赖被它监测的插件树。 + +**只依赖 `prefers-color-scheme` 的 CSS。** 媒体查询无法读取显式持久化选择,因此操作系统为浅色而用户选择深色时仍会闪烁。 + +**在 `` 中执行并给 html 添加临时类。** body 此时尚不存在,还需要一套与正式调色板属性不同的临时选择器。紧接 `` 是能够直接写正式 DOM 字段的最早解析位置。 + +## Consequences + +加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings,而内联脚本只包含选定的内建值与 `system` 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeService。自定义主题仍会在浏览器插件激活后才完整应用;加载期间,页面使用该主题解析后的浅色或深色基础调色板。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 974181450a..a3b387b660 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -152,6 +152,67 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('uses the persisted dark preference while plugins are still loading', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme')) + await page.emulateMedia({ colorScheme: 'light' }) + await page.getByRole('button', { name: '设置', exact: true }).click() + const initialDialog = page.getByRole('dialog', { name: '设置' }) + const darkCube = initialDialog.getByRole('button', { name: '深色' }) + await darkCube.click() + await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n\s+preference: dark/) + await page.keyboard.press('Escape') + + // Hold real plugin bundles so the shell-owned loading page remains observable. + const pluginPattern = '**/plugins/**' + let releaseBundles = (): void => {} + const bundlesReleased = new Promise((resolve) => { releaseBundles = resolve }) + await page.route(pluginPattern, async (route) => { + await bundlesReleased + await route.continue() + }) + + const warningStart = tripwire.warnings.length + let reload: ReturnType | undefined + try { + reload = page.reload({ waitUntil: 'domcontentloaded' }) + const loading = page.getByText('Loading plugins…', { exact: true }) + await loading.waitFor({ timeout: 10_000 }) + const state = await loading.evaluate((element) => { + const boot = element.parentElement?.parentElement + if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page') + return { + attr: document.body.hasAttribute('data-ds-dark-theme'), + background: getComputedStyle(boot).backgroundColor, + colorScheme: document.documentElement.style.colorScheme, + } + }) + expect(state).toEqual({ + attr: true, + background: 'rgb(21, 21, 23)', + colorScheme: 'dark', + }) + } finally { + releaseBundles() + await reload + await page.unroute(pluginPattern) + } + + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const restoredDialog = page.getByRole('dialog', { name: '设置' }) + const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' }) + await systemCube.click() + await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), { + timeout: 5_000, + }).toBe(false) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) interface ThemeState { diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index dd4924f958..9ced9ee90a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7e05f21a6d153bc91880721bda0821a92ea6da37 -module-graph.zh.md: 1eef008ddba5b290039f99dae408adde19ede5ee +module-graph.md: 74534ac78a30631c1b6114a6146bce9824843b78 +module-graph.zh.md: bd36e8ef9beaf1055dd7e029cad747748f5cc9db diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e05f21a6d..74534ac78a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -452,6 +452,7 @@ flowchart TD pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime @@ -1319,7 +1320,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1eef008ddb..bd36e8ef9b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -454,6 +454,7 @@ flowchart TD pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime @@ -1321,7 +1322,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index e99389ac79..30de666f6a 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: cab9961a6d703d600a856e71339cda7062d20d62 -README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739 +README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5 +README.zh.md: e0f614645a16b44e374e450bb8dd1e3c5805ea42 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index cab9961a6d..df4d5e0370 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates. + `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 81b64c3567..e0f614645a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -4,6 +4,8 @@ 主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +当主机组合包含 HTTP 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 + `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 99538ed6ec..1d2e1e6b19 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", + "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row", "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts new file mode 100644 index 0000000000..89b81b9de1 --- /dev/null +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -0,0 +1,40 @@ +/** + * Host-rendered theme bootstrap for the browser's pre-plugin interval. Each + * index response embeds the current durable built-in preference; the browser + * resolves only `system`, then writes the same DOM fields ui-layout's + * ThemePresenter owns after the client plugin tree activates. + */ + +import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' + +/** Build the inline script for one schema-validated built-in preference. */ +function bootThemeScript(preference: ThemePreference): string { + return `` +} + +/** + * Insert the theme bootstrap immediately after the opening body tag, before + * the shell mount and module script. Body-less fragments receive it at the + * end, where the HTML parser has already synthesized a body. + * @param html - Raw application index HTML. + * @param preference - Current Host-backed built-in preference. + * @returns HTML containing the theme bootstrap. + */ +export function injectBootTheme( + html: string, + preference: ThemePreference = DEFAULT_PREFERENCE, +): string { + const script = bootThemeScript(preference) + const body = /]*)?>/i.exec(html) + if (body === null) return `${html}${script}` + const at = body.index + body[0].length + return `${html.slice(0, at)}${script}${html.slice(at)}` +} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 8b8e0c6d30..ce52f66395 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,23 +1,43 @@ -/** Host registration for the browser theme preference. */ +/** Host registration for the browser theme preference and pre-plugin palette. */ import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' +import { injectBootTheme } from './boot-theme.ts' +import { + DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, + type ThemePreference, type ThemeSettings, +} from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' +const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE) + +/** Read the registered preference or use the schema default without a settings provider. */ +function readPreference(ctx: Context): ThemePreference { + const settings = ctx.get('settings') + if (settings === undefined) return DEFAULT_PREFERENCE + const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined + if (section === undefined) return DEFAULT_PREFERENCE + return section.preference +} + /** - * Register the durable theme section when a settings provider exists. - * @param ctx - Host context whose optional settings service owns the section. + * Register the durable theme section and initial-theme index transform when + * their optional Host services are composed. + * @param ctx - Host context that may acquire settings and HTTP services. */ export function apply(ctx: Context): void { ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.register( - settingsNamespace(THEME_SETTINGS_NAMESPACE), - ThemeSettingsSchema, + settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) + }) + ctx.inject(['httpServer'], (httpCtx) => { + httpCtx.effect( + () => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))), + 'client-ui-theme: initial theme bootstrap', ) }) } diff --git a/packages/client/ui-theme/tests/boot-theme.spec.ts b/packages/client/ui-theme/tests/boot-theme.spec.ts new file mode 100644 index 0000000000..a8d0dff235 --- /dev/null +++ b/packages/client/ui-theme/tests/boot-theme.spec.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +/** Host index injection and the resulting pre-plugin browser theme. */ +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { injectBootTheme } from '../src/boot-theme.ts' +import type { ThemePreference } from '../src/theme-settings.ts' + +const DARK_ATTRIBUTE = 'data-ds-dark-theme' + +function mockSystemDark(matches: boolean): void { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) +} + +function executeBootstrap( + preference?: ThemePreference, + html = '
', +): string { + const injected = injectBootTheme(html, preference) + const source = /