Merge branch 'master' into feat/produced-files-folder

This commit is contained in:
Ziya
2026-08-11 17:02:30 +08:00
committed by GitHub
30 changed files with 508 additions and 27 deletions

View File

@@ -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

View File

@@ -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 `<body>` 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.

View File

@@ -4,6 +4,8 @@
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好`light``dark``system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-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 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 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 那组不算重新绑定,它只是重述基础表面的默认值。

View File

@@ -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:^",

View File

@@ -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 `<script>(() => {
const preference = ${JSON.stringify(preference)}
const systemDark = preference === 'system'
&& typeof matchMedia !== 'undefined'
&& matchMedia('(prefers-color-scheme: dark)').matches
const dark = preference === 'dark' || systemDark
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.body.toggleAttribute('data-ds-dark-theme', dark)
})()</script>`
}
/**
* 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 = /<body(?:\s[^>]*)?>/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)}`
}

View File

@@ -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',
)
})
}

View File

@@ -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 = '<html><body><div id="root"></div><script type="module"></script></body></html>',
): string {
const injected = injectBootTheme(html, preference)
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
if (source === undefined) throw new Error('theme bootstrap script missing')
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
return injected
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
document.documentElement.style.removeProperty('color-scheme')
document.body.removeAttribute(DARK_ATTRIBUTE)
})
describe('theme boot index transform', () => {
it('runs immediately inside the body before the shell mount', () => {
mockSystemDark(false)
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
})
it('lets durable light override a dark OS and clears stale dark state', () => {
document.body.setAttribute(DARK_ATTRIBUTE, '')
mockSystemDark(true)
executeBootstrap('light')
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it.each([
[true, 'dark', true],
[false, 'light', false],
] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
mockSystemDark(matches)
executeBootstrap('system')
expect(document.documentElement.style.colorScheme).toBe(colorScheme)
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
})
it('defaults to system and falls back to light when matchMedia is unavailable', () => {
vi.stubGlobal('matchMedia', undefined)
executeBootstrap()
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('appends the script to a body-less fragment', () => {
const html = injectBootTheme('<main>loading</main>', 'dark')
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
@@ -27,4 +28,38 @@ describe('ui-theme host', () => {
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
it('renders the current durable preference and disposes the index transform', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
let transform: ((html: string) => string) | undefined
let disposed = false
ctx.provide('httpServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => { disposed = true }
},
} as HttpServerService)
const fiber = ctx.plugin({ apply })
await fiber.await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
await fiber.dispose()
expect(disposed).toBe(true)
expect(transform?.('<body></body>')).toContain('const preference = "system"')
})
it('uses the system preference when only an HTTP server exists', async () => {
const ctx = new Context()
let transform: ((html: string) => string) | undefined
ctx.provide('httpServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => undefined
},
} as HttpServerService)
await ctx.plugin({ apply }).await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
})
})

View File

@@ -15,7 +15,7 @@ describe('invariant companion', () => {
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
})
it('node-half waits for an optional settings provider', () => {
it('node-half waits for optional Host services', () => {
nodeApply(new Context())
expect(true).toBe(true)
})

View File

@@ -20,6 +20,9 @@
{
"path": "../ui-slots"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/cordis"
},