-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy paththeme-system.html
More file actions
228 lines (221 loc) · 19.4 KB
/
Copy paththeme-system.html
File metadata and controls
228 lines (221 loc) · 19.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OpenProgram 主题系统</title>
<style>
:root {
--page: #f4f2ec;
--panel: #fffefa;
--ink: #252522;
--muted: #6f6d67;
--line: #d9d5cc;
--accent: #c15f3c;
--accent-soft: #f2ddd4;
--ok: #557a5d;
--code: #242422;
--code-ink: #eceae3;
--radius: 12px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "PingFang SC", sans-serif;
color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--page); color: var(--ink); line-height: 1.58; }
a { color: var(--accent); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
.shell { display: grid; grid-template-columns: 220px minmax(0, 980px); gap: 36px; max-width: 1280px; margin: 0 auto; padding: 40px 28px 88px; }
nav { position: sticky; top: 24px; align-self: start; }
nav strong { display: block; font-size: 14px; margin-bottom: 10px; }
nav a { display: block; padding: 6px 8px; border-radius: 7px; color: var(--muted); text-decoration: none; font-size: 13px; }
nav a:hover, nav a:focus-visible { color: var(--ink); background: var(--panel); outline: none; }
main { min-width: 0; }
header { padding: 8px 0 30px; border-bottom: 1px solid var(--line); }
h1 { margin: 0 0 8px; font-size: clamp(32px, 5vw, 50px); letter-spacing: -.035em; line-height: 1.05; }
h2 { margin: 0 0 14px; font-size: 25px; letter-spacing: -.02em; }
h3 { margin: 0 0 8px; font-size: 16px; }
p { margin: 0 0 12px; }
.lede { max-width: 760px; color: var(--muted); font-size: 16px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.tag { padding: 4px 9px; border-radius: 999px; background: var(--panel); border: 1px solid var(--line); font-size: 12px; }
section { padding-top: 52px; }
.callout { padding: 18px 20px; background: var(--accent-soft); border-left: 3px solid var(--accent); border-radius: 0 var(--radius) var(--radius) 0; }
.flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin-top: 22px; }
.flow article { position: relative; padding: 16px; min-height: 132px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.flow article:not(:last-child)::after { content: "→"; position: absolute; right: -11px; top: 48%; z-index: 2; color: var(--muted); }
.kicker { display: block; color: var(--accent); font: 600 11px/1.3 ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; }
.flow p { margin: 8px 0 0; color: var(--muted); font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.card { padding: 20px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.card p, .card li { color: var(--muted); font-size: 14px; }
ul { margin: 8px 0 0; padding-left: 20px; }
table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; display: table; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; font-size: 13px; }
th { color: var(--muted); font-weight: 600; background: #eeece5; }
tr:last-child td { border-bottom: 0; }
.yes { color: var(--ok); font-weight: 650; }
.no { color: #9b4b3c; font-weight: 650; }
.token-groups { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.token-group { padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
.token-group code { display: block; color: var(--muted); margin-top: 4px; overflow-wrap: anywhere; }
.demo-toolbar { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 14px; }
.demo-toolbar button { padding: 7px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--ink); cursor: pointer; }
.demo-toolbar button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.demo-toolbar button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.theme-demo { --d-bg:#262624; --d-panel:#30302e; --d-text:#b8b5ad; --d-bright:#f0efea; --d-muted:#757370; --d-line:rgba(255,255,255,.1); --d-action:#d97757; --d-action-fg:#fff; --d-focus:rgba(240,239,234,.5); padding: 18px; background: var(--d-bg); color: var(--d-text); border-radius: var(--radius); }
.theme-demo[data-demo-theme="beige-light"] { --d-bg:#faf9f5; --d-panel:#fff; --d-text:#3d3d3a; --d-bright:#141413; --d-muted:#91908c; --d-line:#dedcd1; --d-action:#c15f3c; --d-focus:rgba(20,20,19,.45); }
.theme-demo[data-demo-theme="dark"] { --d-bg:#1e1e20; --d-panel:#252529; --d-text:#b6b6bb; --d-bright:#ededf0; --d-muted:#74747c; --d-line:rgba(255,255,255,.12); --d-action:#3b82f6; --d-focus:rgba(237,237,240,.5); }
.theme-demo[data-demo-theme="light"] { --d-bg:#fff; --d-panel:#f5f5f7; --d-text:#3a3a40; --d-bright:#131317; --d-muted:#8c8c94; --d-line:#dcdce2; --d-action:#2563eb; --d-focus:rgba(19,19,23,.45); }
.theme-demo[data-demo-theme="aurora"] { --d-bg:#171528; --d-panel:#241f3d; --d-text:#c4c0e0; --d-bright:#f2f0ff; --d-muted:#7b77a0; --d-line:rgba(180,165,255,.18); --d-action:#35b8a4; --d-action-fg:#0d1a1a; --d-focus:rgba(99,220,255,.6); }
.demo-strip { height: 40px; display: flex; gap: 7px; align-items: center; border-bottom: 1px solid var(--d-line); }
.demo-tab { height: 28px; padding: 0 12px; display: inline-flex; align-items: center; border-radius: 8px 8px 0 0; color: var(--d-muted); }
.demo-tab.active { color: var(--d-bright); background: var(--d-panel); outline: 2px solid var(--d-focus); outline-offset: -2px; }
.demo-content { display: grid; grid-template-columns: 1fr auto; gap: 14px; align-items: center; padding: 20px 4px 4px; }
.demo-content strong { color: var(--d-bright); }
.demo-content small { display: block; color: var(--d-muted); margin-top: 3px; }
.demo-action { border: 0; border-radius: 8px; padding: 8px 12px; background: var(--d-action); color: var(--d-action-fg); }
pre { padding: 16px 18px; background: var(--code); color: var(--code-ink); border-radius: var(--radius); overflow: auto; font-size: 12px; line-height: 1.6; }
.status-list { list-style: none; padding: 0; }
.status-list li { display: grid; grid-template-columns: 110px 1fr; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--line); }
.status-list strong { font-size: 13px; }
.status-list span { color: var(--muted); font-size: 13px; }
@media (max-width: 900px) { .shell { grid-template-columns: 1fr; } nav { position: static; display: flex; flex-wrap: wrap; } nav strong { width: 100%; } nav a { display: inline-block; } .flow { grid-template-columns: 1fr; } .flow article:not(:last-child)::after { content: "↓"; right: 18px; top: auto; bottom: -15px; } .token-groups { grid-template-columns: 1fr 1fr; } }
@media (max-width: 620px) { .shell { padding: 24px 16px 64px; } .grid, .token-groups { grid-template-columns: 1fr; } table { display: block; overflow-x: auto; white-space: nowrap; } .status-list li { grid-template-columns: 1fr; gap: 2px; } }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
</style>
</head>
<body>
<div class="shell">
<nav aria-label="文档章节">
<strong>主题系统</strong>
<a href="#contract">设计边界</a>
<a href="#flow">设置到组件</a>
<a href="#tokens">Token 契约</a>
<a href="#preview">五套主题</a>
<a href="#frameworks">方案比较</a>
<a href="#acceptance">验收</a>
<a href="#status">实现状态</a>
</nav>
<main>
<header>
<span class="kicker">OpenProgram / Web UI</span>
<h1>主题是一份完整数据,组件只读取语义 token</h1>
<p class="lede">回答三个问题:主题由哪里选择和持久化;一次设置会影响哪些界面;怎样保证新增或修改主题时不会只改变部分组件、部分窗口或部分状态。</p>
<div class="meta"><span class="tag">受众:Web / Desktop 实现者</span><span class="tag">状态:当前规范</span><span class="tag">范围:颜色、字体、跨窗口主题</span></div>
</header>
<section id="contract">
<h2>设计边界</h2>
<div class="callout"><strong>唯一运行时仍是 CSS custom properties。</strong> 不引入 JS theme object、CSS-in-JS 或第二套组件皮肤。内置主题必须提供相同 token 集;组件不得按 <code>dark</code>、<code>light</code> 等具体主题名写分支。</div>
<div class="grid" style="margin-top:14px">
<article class="card"><h3>包含</h3><ul><li>Settings 中的 Appearance 与 Font</li><li>首帧、React 水合后及系统明暗变化</li><li>主窗口、桌面浮层和新建窗口</li><li>表面、文字、边框、状态、focus、composer 和 DAG</li></ul></article>
<article class="card"><h3>不包含</h3><ul><li>改变组件尺寸、布局或交互逻辑</li><li>自动改写用户的 Custom CSS</li><li>以主题名为条件的业务逻辑</li><li>把语法高亮或网站内容强制套用应用主题</li></ul></article>
</div>
</section>
<section id="flow">
<h2>设置如何影响组件</h2>
<div class="flow">
<article><span class="kicker">1 · 选择</span><h3>Settings</h3><p><code>THEME_PREFS</code> 生成主题卡片;字体选项来自 <code>font-pref.ts</code>。</p></article>
<article><span class="kicker">2 · 持久化</span><h3>Browser preference</h3><p><code>agentic_theme</code> 写入 localStorage;字体同时维护 cookie 以保证首帧。</p></article>
<article><span class="kicker">3 · 应用</span><h3>HTML attributes</h3><p>首帧脚本和 hook 都只设置 <code><html data-theme></code> 与 <code>--font-sans</code>。</p></article>
<article><span class="kicker">4 · 解析</span><h3>完整 token 文件</h3><p>每个内置主题定义同一 token 契约;<code>:root</code> 只负责未知值和 Custom 的回退。</p></article>
<article><span class="kicker">5 · 消费</span><h3>全部组件</h3><p>Tailwind bridge、CSS modules、桌面浮层全部读取 token,不判断具体主题名。</p></article>
</div>
</section>
<section id="tokens">
<h2>完整 token 契约</h2>
<p>主题文件负责“值”;组件负责“用途”。同一用途在所有主题中使用同一个 token 名。</p>
<div class="token-groups">
<article class="token-group"><h3>Surface</h3><code>--bg-primary / secondary / tertiary</code><code>--bg-input / hover / selected</code><code>--surface-popover / tooltip</code></article>
<article class="token-group"><h3>Text</h3><code>--text-bright / primary</code><code>--text-secondary / muted</code><code>--nav-color / hover</code></article>
<article class="token-group"><h3>Boundary & depth</h3><code>--border / border-light</code><code>--border-popover</code><code>--shadow-* / --scrim-*</code></article>
<article class="token-group"><h3>Action & status</h3><code>--theme-accent</code><code>--theme-accent-fill / fill-hover</code><code>--accent-green / red / yellow</code><code>--success-soft / warning-soft / danger-soft</code></article>
<article class="token-group"><h3>Interaction</h3><code>--selection-bg / --focus-ring</code><code>--chip-bg / --chip-ring</code><code>--meter-fill / --meter-track</code></article>
<article class="token-group"><h3>Component-owned roles</h3><code>--composer-surface / shadow / filter</code><code>--provider-icon-bg</code><code>--dag-ghost</code></article>
</div>
<table style="margin-top:18px">
<thead><tr><th>设置</th><th>直接状态</th><th>影响范围</th><th>禁止</th></tr></thead>
<tbody>
<tr><td>Appearance</td><td><code>data-theme</code></td><td>背景、文字、边框、shadow、状态色、tab focus、composer、popover、DAG、desktop overlay</td><td>组件内写 <code>[data-theme="light"]</code></td></tr>
<tr><td>Font</td><td><code>--font-sans</code></td><td>body、button、input、select、textarea、optgroup;显式 mono 区域除外</td><td>普通组件重新声明系统字体栈</td></tr>
<tr><td>Custom CSS</td><td><code>#user-custom-css</code></td><td>同一 token 契约,未覆写项从默认主题回退</td><td>新增只有 Custom 才认识的组件分支</td></tr>
</tbody>
</table>
<table style="margin-top:18px">
<thead><tr><th>主题族</th><th>主题</th><th>主色</th><th>作用范围</th></tr></thead>
<tbody>
<tr><td>Neutral</td><td><code>dark</code> / <code>light</code></td><td>蓝色</td><td>Default Button、主操作填充、选中态、链接与强调边界</td></tr>
<tr><td>Warm</td><td><code>beige-dark</code> / <code>beige-light</code></td><td>橙色 / 珊瑚色</td><td>同一套组件角色,只替换主题值</td></tr>
<tr><td>Aurora</td><td><code>aurora</code></td><td>青绿色</td><td>同一套组件角色,并使用深色 foreground 保证填充对比</td></tr>
<tr><td>Custom</td><td><code>custom</code></td><td>用户定义</td><td>未定义时使用基础蓝色回退</td></tr>
</tbody>
</table>
</section>
<section id="preview">
<h2>五套内置主题共享同一组件结构</h2>
<div class="demo-toolbar" role="group" aria-label="预览主题">
<button type="button" data-theme-button="beige-dark" aria-pressed="true">暖色深</button>
<button type="button" data-theme-button="beige-light" aria-pressed="false">暖色浅</button>
<button type="button" data-theme-button="dark" aria-pressed="false">深色</button>
<button type="button" data-theme-button="light" aria-pressed="false">浅色</button>
<button type="button" data-theme-button="aurora" aria-pressed="false">极光</button>
</div>
<div class="theme-demo" id="theme-demo" data-demo-theme="beige-dark">
<div class="demo-strip"><span class="demo-tab">New chat</span><span class="demo-tab active">Browser</span><span class="demo-tab">Terminal</span></div>
<div class="demo-content"><div><strong>同一 tab、同一文字层级、同一按钮用途</strong><small>主题只替换 token 值,不替换组件结构或行为。</small></div><button class="demo-action" type="button" tabindex="-1">Primary action</button></div>
</div>
</section>
<section id="frameworks">
<h2>实现方案比较</h2>
<table>
<thead><tr><th>方案</th><th>采用</th><th>原因与边界</th><th>当前缺失能力</th></tr></thead>
<tbody>
<tr><td>CSS custom properties + <code>data-theme</code></td><td class="yes">采用</td><td>原生级联覆盖 Web、CSS modules、Tailwind arbitrary values 和桌面同源浮层。</td><td>CSS 本身不能验证每个主题是否漏 token,因此增加静态契约检查。</td></tr>
<tr><td>Tailwind <code>@theme</code></td><td class="yes">作为 bridge</td><td>只把 CSS token 暴露给 utility 名,不保存另一份色值。</td><td>不能动态生成 CSS import,主题文件与 TS id 由检查脚本对齐。</td></tr>
<tr><td>JavaScript theme object / Context</td><td class="no">拒绝</td><td>会复制色值且无法自然覆盖原生 CSS、首帧和 Electron overlay。</td><td>不提供运行时逐组件主题 props。</td></tr>
<tr><td>组件级 <code>[data-theme]</code> 分支</td><td class="no">拒绝</td><td>新增主题时必然漏改;改为主题文件提供组件角色 token。</td><td>组件不得知道内置主题 id。</td></tr>
<tr><td><code>light-dark()</code> 仅按 color-scheme</td><td class="no">拒绝</td><td>只能表达明暗两类,不能区分 beige、neutral、aurora 和 custom。</td><td>仍使用各主题显式值。</td></tr>
</tbody>
</table>
<p style="margin-top:12px;color:var(--muted);font-size:13px">规范依据:<a href="https://www.w3.org/TR/css-variables-1/">CSS Custom Properties Level 1</a>、<a href="https://www.w3.org/TR/css-color-adjust-1/#color-scheme-prop">CSS Color Adjustment 的 color-scheme</a>。现有实现入口见 <code>apps/web/lib/prefs/theme-pref.ts</code>、<code>apps/web/app/globals.css</code> 与 <code>apps/web/app/styles/themes/</code>。</p>
</section>
<section id="acceptance">
<h2>验收标准</h2>
<ul class="status-list">
<li><strong>主题列表</strong><span>设置页、CSS imports、主题文件、桌面浮层允许值由检查脚本证明一致。</span></li>
<li><strong>Token 完整</strong><span>五个内置主题提供相同 token 集;缺一个即失败。</span></li>
<li><strong>组件隔离</strong><span>theme 文件之外不存在按具体内置主题名分支的组件 CSS。</span></li>
<li><strong>跨窗口一致</strong><span>beige-dark、beige-light、dark、light、aurora、custom 都原样传给 desktop overlay。</span></li>
<li><strong>字体一致</strong><span>普通文本和原生表单控件继承同一 <code>--font-sans</code>;mono 区域显式例外。</span></li>
<li><strong>主色矩阵</strong><span>Neutral 深浅主题为蓝色,Warm 深浅主题为橙色,Aurora 为青绿色;常态、填充与 hover 必须属于同一主题色族。</span></li>
<li><strong>可见验收</strong><span>默认安装版在深色、浅色和 aurora 下检查主窗口、tab、composer、popover 与桌面浮层。</span></li>
</ul>
</section>
<section id="status">
<h2>实现状态</h2>
<div class="card">
<p><strong>设计与代码实施已完成。</strong></p>
<p><code>THEME_PREFS</code> 现在是主题入口的唯一清单;五套内置主题各自完整定义 58 个契约 token;composer、provider icon 与 DAG 只消费角色 token,不再判断具体主题 ID;desktop menu overlay 接受并传播全部主题 ID。共享按钮只消费 <code>--primary</code>,由主题层统一映射主色,不在组件内维护主题分支。主题契约检查已纳入 Web 总检查,安装版验收结果记录在本节下方。</p>
<ul>
<li><code>npm --prefix apps/web run check</code>:通过,包括 <code>check:theme-contract</code>。</li>
<li><code>npm exec --workspace apps/web -- tsc --noEmit</code>:通过。</li>
<li><code>npm --prefix apps/web run build</code>:通过。</li>
<li>默认安装版 <code>/Applications/OpenProgram.app</code>:五套内置主题均读取到完整 58 个 token,页面背景均按主题变化,原生按钮继承字体设置;Aurora 下 desktop main-menu overlay 收到 <code>theme=aurora</code> 并使用对应 token。</li>
<li>默认安装版共享按钮实测:dark / light 使用蓝色,beige-dark / beige-light 使用橙色,aurora 使用青绿色;常态、填充、hover 均保持各自主题色族。Custom 主题原有 <code>--accent-orange</code>、<code>--accent-fill</code>、<code>--accent-orange-hover</code> 覆盖方式继续有效。</li>
</ul>
</div>
</section>
</main>
</div>
<script>
const demo = document.getElementById('theme-demo');
const buttons = [...document.querySelectorAll('[data-theme-button]')];
for (const button of buttons) {
button.addEventListener('click', () => {
demo.dataset.demoTheme = button.dataset.themeButton;
for (const item of buttons) item.setAttribute('aria-pressed', String(item === button));
});
}
</script>
</body>
</html>