Skip to content

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

jsonc
// 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 一起拉进来:

tsx
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>:

tsx
import { ThemeProvider } from "@agentaily/theme";

<ThemeProvider defaultTheme="system">
  <App />
</ThemeProvider>;

3 —— 在任何地方读取并切换主题:

tsx
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 写样式:

css
:root            { --bg: #fff; --fg: #111; } /* light (default) */
[data-theme="dark"] { --bg: #111; --fg: #eee; }

最终应用主题的解析顺序:持久化的选择 → 经 prefers-color-schemesystem → 默认 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 把 systemlight/dark)。在 React 之外也可复用。
isBrowser()SSR 守卫。
createStorage, persistentState持久化原语(从 @agentaily/persistence re-export)。

ThemeMode = "system" | "light" | "dark";ResolvedTheme = "light" | "dark"storage 是一个 StorageConfig(backend auto|cookie|localcookieDomainkeyPrefixcookieMaxAge)。

持久化住在哪里

ThemeProvider 需要持久化主题选择,所以它依托一个通用的键 / 值偏好原语。那个 原语如今住在它自己独立、无框架的包里 —— @agentaily/persistence(本包的一条 workspace 依赖)—— 而 theme re-export 了其中相关的几样(createStoragepersistentStateisBrowser)以供复用。(它过去就地内嵌在包内;去重进 @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() 也来自那里。