@agentaily/theme
Agentaily 各产品共享的主题切换机制 —— system / light / dark 选择、实时 跟踪 prefers-color-scheme、跨子域持久化,以及一段无闪烁的 SSR 初始化脚本 —— 与 任何 UI 解耦。是 @agentaily/i18n 的姊妹包。
<ThemeProvider> + useTheme() → { theme, resolvedTheme, setTheme }
themeInitScript() → 先把正确主题画上去的内联-在-<head>-里的字符串- 精简依赖。 React 是唯一的 peer dependency。它所依托的跨子域偏好持久化原语住在 独立、无框架的
@agentaily/persistence(一条 workspace 依赖)里 —— 所以 theme 永远不会把组件库一起拉进来。初始化脚本是零依赖的(无 React)—— 可在 edge/SSR 里用。 - 不闪错误主题(FOUC)。
themeInitScript()返回一小段同步代码,你把它内联进<head>、在任何绘制之前;它读取持久化的选择并设置<html data-theme>,于是首屏绘制 就已经正确。provider 只会把主题作为副作用写到<html>上(从不写进 JSX 树),所以 哪怕服务端和客户端不一致,也没有 hydration mismatch。 - SSR 安全。 每次访问
window/document/matchMedia都漏斗式地经过一个isBrowser()守卫;在服务端 import 或渲染永远不会碰到缺失的全局对象,持久化会静默 降级(cookie → localStorage → memory → no-op)。 - XSS 安全的初始化脚本。 每个被插值的配置值都用 JSON 编码、并转义
<,所以你 attribute/key 里的</script>(或任何标记)都无法逃逸出<script>标签。
这个包为什么存在
theme 机制曾经活在 @agentaily/design-system 的运行时里(从已退役的 @agentaily/web-kit 移植过去)。那把一个横切的运行时关注点耦合进了 UI 库。本包是它的 独立归宿,这样任何消费者 —— 包括非 UI 代码以及 SSR 的 <head> —— 都能切换主题 而不必依赖组件库。
迁移状态(2026-06-19): 这是独立抽取版(行为与 design-system 里那份完全 一致),已在 workspace 里注册、并依托独立的
@agentaily/persistence包。design-system 目前仍自带它自己的ThemeProvider/themeInitScript;去重(让 DS 从这里 re-export) 以及把apps/web切过来都是后续。每个后续都各走自己的 worktree + PR(见仓库CLAUDE.md→ "🌳 推进模型")。
安装(在 monorepo 内)
// consumer package.json
"dependencies": {
"@agentaily/theme": "workspace:*"
}本包消费为 TypeScript 源码(无构建步)—— exports 指向 ./src/index.ts,和 @agentaily/aml、@agentaily/i18n 一样。消费者里必须有 React >=18(只为 ThemeProvider / useTheme;初始化脚本和持久化原语都不需要 React)。
用法
1 —— 把初始化脚本内联进你的 SSR <head>,在任何样式表之前。 这就是防闪烁的 关键。从那条零依赖子路径 import 它,这样服务端 / edge 代码不会把 React 一起拉进来:
import { themeInitScript } from "@agentaily/theme/init-script";
// In your document <head> (React SSR, Pages Function, plain HTML template…):
<script dangerouslySetInnerHTML={{ __html: themeInitScript() }} />2 —— 把你的 app 包进 <ThemeProvider>:
import { ThemeProvider } from "@agentaily/theme";
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>;3 —— 在任何地方读取并切换主题:
import { useTheme } from "@agentaily/theme";
function ThemeToggle() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "system" | "light" | "dark" (the user's selection)
// resolvedTheme: "light" | "dark" (what's actually applied)
return (
<button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
{resolvedTheme === "dark" ? "🌙" : "☀️"}
</button>
);
}4 —— 对着 provider / 初始化脚本设在 <html> 上的那个 attribute 写样式:
:root { --bg: #fff; --fg: #111; } /* light (default) */
[data-theme="dark"] { --bg: #111; --fg: #eee; }最终应用主题的解析顺序:持久化的选择 → 经 prefers-color-scheme 的 system → 默认 light。切换主题会持久化该选择(默认共享 .agentaily.com cookie → localStorage → memory)并就地更新 <html data-theme> —— 不刷新页面。在跟随 system 期间,它实时跟踪操作系统层面的配色方案变化。
⚠️ 让初始化脚本的
storageKey与 provider 的存储保持同步。 provider 持久化在keyPrefix + "theme"下(默认agentaily:theme),而themeInitScript()默认就读 这个。如果你覆盖了 provider 的storage.keyPrefix,就把匹配的storageKey传给themeInitScript({ storageKey }),否则初始化脚本读错 key、闪烁就回来了。
API
| 导出 | 是什么 |
|---|---|
<ThemeProvider> | 提供主题状态;把解析后的主题应用到 <html>。Props:defaultTheme?、storage?、attribute?(默认 data-theme)。 |
useTheme() | { theme, resolvedTheme, setTheme }。在 provider 之外抛错。 |
themeInitScript(options?) | 给 SSR <head> 用的纯字符串(无闪烁的首屏绘制)。选项:defaultTheme?、attribute?、storageKey?。零依赖;也在 @agentaily/theme/init-script。 |
resolveTheme(mode) / systemResolvedTheme() | 纯解析器(经 matchMedia 把 system → light/dark)。在 React 之外也可复用。 |
isBrowser() | SSR 守卫。 |
createStorage, persistentState | 持久化原语(从 @agentaily/persistence re-export)。 |
ThemeMode = "system" | "light" | "dark";ResolvedTheme = "light" | "dark"。 storage 是一个 StorageConfig(backend auto|cookie|local、cookieDomain、 keyPrefix、cookieMaxAge)。
持久化住在哪里
ThemeProvider 需要持久化主题选择,所以它依托一个通用的键 / 值偏好原语。那个 原语如今住在它自己独立、无框架的包里 —— @agentaily/persistence(本包的一条 workspace 依赖)—— 而 theme re-export 了其中相关的几样(createStorage、 persistentState、isBrowser)以供复用。(它过去就地内嵌在包内;去重进 @agentaily/persistence 已经做完。@agentaily/i18n 仍内嵌着它自己逐字节相同的 孪生副本,迁移过来是一项后续。)
测试
pnpm --filter @agentaily/theme test—— node 环境,覆盖纯逻辑:主题解析 (resolve)和无闪烁初始化脚本(字符串生成、XSS 转义,以及经new Function在沙箱里 执行其运行时行为)。(持久化原语自己的测试住在@agentaily/persistence。)pnpm --filter @agentaily/theme test:dom—— jsdom 环境(vitest.dom.config.ts), 覆盖 React 绑定(ThemeProvider/useTheme:默认 /system解析、setTheme+ 跨重挂载的持久化、自定义 attribute、实时跟踪操作系统配色、provider 外抛错)。用react/react-dom/@testing-library/react/@vitejs/plugin-react/jsdom。
目录结构
src/
index.ts 对外统一导出 barrel(也从 @agentaily/persistence re-export 持久化原语)
ThemeProvider.tsx React provider(把解析后的主题应用到 <html>,跟踪 system)
useTheme.ts hook → { theme, resolvedTheme, setTheme }
context.ts ThemeContext
themeInitScript.ts 无闪烁的 SSR <head> 片段(纯函数、零依赖)
resolve.ts systemResolvedTheme() / resolveTheme()(纯函数)
types.ts ThemeMode / ResolvedTheme / props / options
test/ resolve.test.ts · themeInitScript.test.ts(node)· ThemeProvider.test.tsx(jsdom)偏好存储(cookie → localStorage → memory → no-op)不再就地内嵌在这里 —— 它住在
@agentaily/persistence(本包依赖它);isBrowser()也来自那里。