OpsMind UI 重构实录(二):设计 Token、暗色重构和双主题切换

OpsMind UI Redesign Day 2 Cover

如果说第一天是在把链路打通,那第二天才真正进入了我最喜欢的部分:

开始给界面建立秩序。

因为我很清楚,这次我想要的并不是“页面功能更全了”,而是让整个 OpsMind 前端从一种“能用”的状态,慢慢变成一种我自己也愿意多看几眼的东西。

而这件事靠的不是多写几个渐变,也不是多加几个阴影。真正有用的,是先把所有零散样式背后的规则抽出来。

我想解决的,不是某一个颜色,而是混乱

很多界面到后面越来越难改,不是因为它们功能复杂,而是因为样式决策是散着长出来的。

这里一个 #27272a,那里一个 rgba(255,255,255,0.08),另一个组件又自己写了套 hover 色。短期看都能跑,长期看就会有一种典型后果:

你已经能感觉到风格不统一,但又不知道该先动哪一层。

所以这一天最核心的决定其实很简单:

把视觉决策先从组件里拿出来,收回到 token 层。

于是我开始搭一整套 CSS 自定义属性,把它当成单一数据源:

--bg
--surface
--surface-high
--text
--text-bright
--text-muted
--text-subtle
--session-active-bg
--session-hover-bg
--newchat-bg
--card-bg
--input-border
--shadow-input

这件事做完之后,组件终于不再负责“我今天到底是什么颜色”,它们只负责消费语义。

我很喜欢这种分工,因为它会让样式系统一下子变得很工程化:

  • 组件写的是意图
  • token 决定的是实现
  • 主题切换只需要覆盖变量

这时候界面才真正开始“有骨架”。

暗色模式我几乎是重做了一遍

原来的暗色不是不能用,但总有一种还没完全收住的感觉。
颜色里残留着一些偏蓝紫的调子,短时间看挺显眼,长时间看就会有点累。

这次我索性全换到 Zinc 体系:

  • --bg: #09090b
  • --surface: #18181b
  • --surface-high: #27272a
  • --text: #a1a1aa
  • --text-bright: #e4e4e7

这里我最在意的一笔其实是正文色。

我没有让 AI 正文直接上特别亮的文字,而是把 --text 压在 zinc-400 这一档。原因很简单:聊天产品不是海报,正文不是拿来“亮”的,而是拿来读的。

标题、激活菜单、用户气泡这些地方可以更亮,但正文如果整块太刺,读久了会累得很快。

所以第二天我越来越确定一件事:

真正的质感,很多时候不是“更强烈”,而是“更克制”。

语义 token 才是双主题真正的关键

这次改主题时,我最不想继续忍的一个问题,就是那些写死的 rgba(...)

因为它们在单一主题下经常“刚好能看”,一旦切到另一套主题,立刻就开始露馅。

最典型的就是这种值:

rgba(255,255,255,0.11)

放在暗色里没问题,但丢到浅色里就很容易接近不可见。
如果组件里全是这种硬编码,最后主题切换基本就是灾难片。

所以我把这些交互色全部语义化了,比如:

--session-active-bg
--session-hover-bg
--newchat-bg
--layer-border
--shadow-input

然后在暗色和浅色下各给一套对应值。

这一层做完之后,很多原来很麻烦的问题会突然简单很多。
比如会话项激活态、新建对话按钮、输入框阴影、卡片背景这些,全都不需要在组件里写 if else 了。

组件只写:

background: var(--session-active-bg);

至于暗色是浅白透明,浅色是浅黑透明,交给 token 层处理。

浅色模式不是暗色模式的反相

第二天另一个让我很满意的点,是我终于认真把浅色模式当成一套独立系统来做,而不是暗色的附属品。

比如背景我没有用纯白,而是刻意留在 #f8f8f8
因为纯白在很多屏幕上太刺,也太像“默认没设计”。

文字层级也不是简单反色,而是重新分级:

  • --text-bright 负责标题和激活态
  • --text 负责正文和普通菜单
  • --text-muted 负责次级信息
  • --text-subtle 才是 placeholder 这种极淡内容

这里有个我觉得非常值得记住的小结论:

浅色模式里,text-muted 不能太淡。

如果偷懒直接用很浅的灰,白底上的对比度会掉得很厉害。看起来“高级”的同时,也会开始不好读。最后那种高级感通常维持不到三秒。

所以这次我宁愿它稳一点,也不追求那种一眼很轻、三眼就费劲的配色。

主题切换的实现本身,也得有点体面

有了 token 之后,主题切换本身就比较直接了:

:root { ...dark values... }
html.light { ...light values... }

再配一个 useTheme()

document.documentElement.classList.toggle('light', t === 'light')

这一层其实不复杂,但我很在意另一个细节:别闪。

如果页面先按暗色渲染,再在 hydration 之后切成浅色,那一瞬间的闪烁会很破坏体验。
所以我在 app/layout.tsx 里加了一小段内联脚本,优先从 localStorage 里把主题 class 打上去。

这种代码看起来不浪漫,但它非常像真正产品里会在意的事。
用户未必会夸你“这个 FOUC 处理得真棒”,但他们会很自然地觉得页面很稳。

很多顺滑感,往往就是这么来的。

这一天最让我开心的,是界面终于开始“统一说话”

第二天做完之后,最大的变化不一定是某个组件一下子变好看了,而是整个界面开始有一种统一的语言:

  • 同一类层级有同一类背景
  • 同一类文本有同一类对比度
  • hover、active、card、input 都在同一个语义系统里
  • 暗色和浅色不再互相将就

这会让一个产品从“零件堆起来了”变成“像同一个人做的”。

而我自己最喜欢的,也正是这种时刻。
你会觉得自己不是在修一个个组件,而是在慢慢把一个前端界面的性格雕出来。

现在回头看,第二天决定了这次重构的上限

第一天解决的是功能和链路,当然重要。
但如果没有第二天这套 token 和双主题体系,后面很多细节优化其实都只能算局部补丁。

因为一旦底层语义不统一:

  • 侧边栏怎么改都可能东一块西一块
  • 输入框怎么磨都可能只是局部精致
  • 新加一个组件又会重新发明自己的颜色系统

而把 token 层立住之后,整个前端才终于有了一个可以持续扩展的基础。

所以如果我要给第二天下一个定义,我会说:

这一天不是在加样式,而是在给整个 UI 建立秩序。

而秩序一旦建立起来,质感就不再完全靠灵感了。