扩展的 UI 散在四个地方:

  • 独立 HTML 页(侧栏、设置页、popup)——正常的网页;
  • shadow DOM 注入组件(浮动面板、浮层、宿主顶栏里的小挂件)——跑在宿主页面上。

一开始它们各写各的样式。结果就是四个组件各自手写了一套按钮、一套标签、一套开关——看起来像同一个产品,但没有一处是同一份代码。

统一之后的结构是:一份 jade-mint.css,是全站唯一的色值与组件定义源。 三种消费方各取各的。

三种取法,一个源

消费方怎么取
独立页面(侧栏)import './jade-mint.css' —— :root 命中,token 和 .jm-* 组件类全都有
被框架接管的页面(设置页)@import 进来,再把框架的变量映射到自己的 token:--primary: var(--ext-primary-500)。这一层本身不写任何品牌 hex
shadow DOM 组件用 Vite 的 ?raw 把 CSS 当文本读进来,注入进组件自己的 <style>

第三种是特殊的,因为 shadow 边界会挡住外部样式表。

shadow DOM:token 不够,组件类也得进去

这是我们踩的第一个坑,而且它的症状很有欺骗性。

最初的辅助函数只注入 token:

extThemeTokens(selector) // 只给 --ext-* 变量

于是组件里写 class="jm-btn jm-btn-primary" ——完全没样式。因为 .jm-btn 这条规则在外部样式表里,shadow 边界把它挡住了。

变量进去了(token 是靠 :host 规则注入的),类名没进去。所以你会看到颜色是对的、形状全错。

结果就是四个组件各自”绕过去”——自己手写一套按钮样式。它们看着都还行,直到你要改一个圆角。

修法是再给一个函数:

extThemeWithComponents(selector) // token + .jm-* 组件类整段

新组件一律用它。

组件段的切分靠 CSS 里的一个标记:

/*@jm-components-start*/

这行是机器读的,勿删勿改。

那个标记曾经是裸的 @ 开头,然后出事了

原本它写成 @jm-components-start——一个自造的 at-rule。源码里独立成行,看着没任何问题。

但 esbuild 压缩会去掉所有换行和注释,于是它和紧随其后的第一条规则挤成了一行:

@jm-components-start .jm-btn{font-size:…;padding:…;border-radius:…}

浏览器按 at-rule 解析 @ 开头的东西,遇到不认识的就连同后面那个块一起丢弃——恰好吃掉了 .jm-btn 基类。

而 .jm-btn:disabled、.jm-btn-primary 这些因为在下一条规则,全都活着。

于是症状是:按钮只剩背景色(来自那些活着的兄弟规则),而字号、内边距、圆角全都走浏览器 <button> 的默认值——400 13.33px Arial / 1px 6px / 0 圆角。

用户的原话是「仿古的按钮效果」。

这个 bug 存在了很久,因为旧的基类值和浏览器默认值差得不算多,一直没人注意。

修法是把标记改成注释形式 /*@jm-components-start*/——注释在压缩时整条消失,不会和下一条规则粘连。

自造的 at-rule 在压缩后是个陷阱。 需要在 CSS 里留机器可读的标记,用注释,别用 @。

加一道构建期闸门

上面那条链有个更一般的隐患:切出来的片段可能「文本上正确、CSS 上不可解析」。

比如标记恰好写在一个注释块的中间,切出来的片段就会以一个孤立的注释收尾开头——浏览器解析器直接丢弃整段,所有组件类静默失效。

所以加了一个脚本,接进 npm run build:

scripts/check-jade-mint.mjs
// 真正的结构校验:注释配对、括号配平、关键组件存在

它检查三件事:

  1. 标记存在且独立成行(文件顶部的说明文字里也会提到这个词,裸 indexOf 会命中那句话——所以用行锚正则);
  2. 切出来的片段注释配对、花括号配平;
  3. 几个关键组件类确实在片段里。

不通过就中止构建。

但要诚实地标注它的盲区

那个 esbuild 压缩粘连的 bug,这个脚本发现不了——因为它跑在源码上,而 bug 只在压缩产物里出现。

我把这句话写进了脚本的文件头注释:

「产物里搜得到类名」证明不了它生效。 查这类问题必须读浏览器的 computed style。

一个校验脚本最危险的状态不是不存在,是存在但覆盖不到你以为它覆盖的东西——它会给你虚假的安全感。所以盲区要写下来。

还有一条同源的要求:这个脚本的提取逻辑必须和运行时的提取逻辑逐字一致。改一边就要同步改另一边,否则校验的就不是真实行为。

暗色:主色阶翻转

暗色由一个显式的 .dark class 驱动,不跟随系统 prefers-color-scheme——因为它要和设置页里的开关联动,跟随系统会让「我明明关了它怎么还是暗的」。

关键设计是主色阶在暗色下翻转深浅:

50 ↔ 900 100 ↔ 800 200 ↔ 700 300 ↔ 600 (500 提亮一档)

这样组件里最常见的那种写法:

background: var(--ext-primary-50);
color: var(--ext-primary-900); /* 浅底 + 深字 */

在暗色下自动变成「深底 + 浅字」,用处一行都不用改。

如果不翻转,每个用到色阶的地方都要写一遍暗色覆盖——而漏掉的那几处,就是「暗色下某个标签白底白字」这类 bug 的来源。

两条配套的规矩

① 别拿「主色」当字色。 主色是给图标、线条、边框用的,直接当文字颜色对比度往往不够。要成对准备 --ext-accent-bg / --ext-accent-text,并且真的去量对比度(我们那对是暗 5.21 / 亮 4.33,都过 AA)。

② 改了底色就要检查字色是谁给的。 有一次只把某个按钮的底色映射到了自己的 token,没动框架自带的 --on-error——于是暗色下变成「浅红底 + 白字」。底色和字色经常来自不同的变量体系,只改一半是常态。

fail-soft:宁可丑,不可无

token 提取函数是 fail-soft 的:

// 抓不到选择器 → console.warn + 返回空串,不抛错

理由很直接:色值丢失让组件退化成浏览器默认外观(丑,但可用);抛错会让整个 shadow DOM 组件挂不上(全没了)。

对注入式 UI 来说这个取舍是明确的——它是宿主页面上的附加物,它自己的样式问题不应该升级成「功能消失」。

顺带几条只在注入式 UI 里遇到的

font 简写里不能只对 family 用 inherit。 font: 600 13px/1.4 inherit 整条声明会被判非法、整条丢弃——而它丢的不只是字体,还有你写在里面的字号和行高。拆开写。

别对 position: fixed 的元素做 transform: scale() 悬停动画。 在重页面上会触发合成层闪现。改成只换底色和阴影。

CSS filter 会制造 containing block——加了 filter 的元素内部,position: fixed 的后代会相对它定位而不是视口。我们的整页反色最初加在 :root 上,把所有浮动件的定位都搞乱了;改加在 body 上就好了(浮动件挂在 documentElement 下,不在 body 子树里)。

一条元经验

做完这套之后最大的收获,不是配色统一了,而是**「改配色 = 只改一个文件」这件事真的成立了**。

在此之前每次改色,你要去四个组件里各改一遍,而且总会漏掉一个——漏掉的那个会在某个很久之后被人截图发给你。

判断一个设计系统有没有落地,不看它有没有文档,看「改一个色值要动几个文件」。

如果答案大于一,那它还只是一份建议。