PostHog Quill 设计系统:@posthog/quill-tokens 设计令牌生成管线与运行时主题实现
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
@posthog/quill-tokens是 PostHog Quill 组件库的设计令牌(design tokens)唯一事实来源:令牌在 TypeScript 中编写,构建时生成 CSS,消费方(应用、图表包、带自有设计系统的第三方宿主)按各自形态引入生成产物。读完本文,你将掌握它的"TypeScript 源 → CSS 产物"生成管线、语义色 / 数据可视化令牌的定义规则、THEME_DERIVED_TOKENS运行时主题守卫的底层原理,以及"新增或修改一个令牌"的完整标准流程。
定位:令牌在哪里编写、在哪里生成
Quill 仓库位于 packages/quill,其中packages/quill/packages/tokens是独立的 npm 包 packages/quill/packages/tokens/package.json,包名@posthog/quill-tokens。其核心约定只有一句话:令牌在src/下用 TypeScript 编写,CSS 是构建产物,永远不要手工编辑dist/*.css——每个产物文件开头都印有Auto-generated by @posthog/quill-tokens — do not edit manually的戳记。
src/目录下的源码按令牌分组组织,各模块职责如下(均可从 index.ts 的导出看到全貌):
| 模块 | 内容 |
|---|---|
| colors.ts | 语义色映射、主题配置、CSS 生成器(核心) |
| data-viz.ts | 数据可视化分类色板 + 图表"外壳"(graph chrome)变量 |
| spacing.ts | 间距基值与spacing()/spacingPx()工具函数 |
| typography.ts | 字号、字体族令牌 |
| shadow.ts | 阴影令牌 |
| border-radius.ts | 圆角令牌 |
| css.ts | cssVars/cssVarsFlat等 CSS 拼装工具 |
生成管线:一条命令从 TypeScript 到六份 CSS
官方管线的形状是:
src/*.ts ──(tsx src/build.ts)──▶ dist/*.css在仓库中执行pnpm --filter @posthog/quill-tokens build,实际触发的是 package.json 中的两步:tsx src/build.ts && vite build。前者用 build.ts 把各生成器调用的结果写盘,后者用 Vite 打包 JS 入口(dist/index.js/dist/index.cjs及类型声明)。build.ts的写盘顺序是(build.ts#L26-L49):
| 产物文件 | 内容 | 消费方 |
|---|---|---|
color-system.css | :root(浅色)+ 暗色选择器覆盖——运行时 CSS 变量 | 应用与包在运行时直接引入 |
tailwind.css | @theme+@custom-variant+ base 层 | 应用(apps) |
tailwind-lib.css | 仅@theme+@custom-variant | 库包(library packages) |
*.scoped.css | 同上,但所有变量 gated 在[data-quill]选择器之后 | 带自有设计系统的宿主,避免变量名冲突 |
一个容易踩坑的细节:tailwind.css/tailwind-lib.css不包含@import "tailwindcss",引入 Tailwind 本身是消费应用自己的责任(build.ts#L10-L11)。这些产物同时通过包exports暴露为子路径,消费方可以精确引入某一份(package.json#L25-L31):
"./tailwind.css": "./dist/tailwind.css", "./tailwind-lib.css": "./dist/tailwind-lib.css", "./color-system.css": "./dist/color-system.css", "./tailwind.scoped.css": "./dist/tailwind.scoped.css", "./tailwind-lib.scoped.css": "./dist/tailwind-lib.scoped.css", "./color-system.scoped.css": "./dist/color-system.scoped.css"改完src/后必须重跑构建,保证dist/与源码一致。开发体验上,Quill Storybook 的main.ts里有quillTokensWatcher监听src/,改令牌可热更新,无需手动 build。
语义色:主题色相派生与静态值两条线
语义色是令牌体系的核心,定义在 colors.ts 的buildSemanticColors()中。每个令牌是一个三元组[light, dark, tailwindClass](类型ColorTuple),分别给出浅色值、暗色值和对应的 Tailwind 类名。
两条取值路线
主题派生(theme-derived):表面色与中性色由共享的主题色相 + 着色度(tint)推导。
surface()辅助函数构造的是引用 CSS 自定义属性的oklch()表达式,而非写死的颜色值(colors.ts#L62-L68):function surface(lightness: number, chromaScale: number, mode: 'light' | 'dark', alpha?: number): string { const hueVar = mode === 'light' ? 'var(--theme-hue)' : 'var(--theme-dark-hue)' const chromaExpr = chromaScale === 1 ? 'var(--theme-tint)' : chromaScale === 0 ? '0' : `calc(var(--theme-tint) * ${chromaScale})` const alphaSuffix = alpha !== undefined ? ` / ${alpha * 100}%` : '' return `oklch(${lightness} ${chromaExpr} ${hueVar}${alphaSuffix})` }因为色相与着色度落在
var(--theme-hue)/var(--theme-tint)上,消费方只要在运行时改这几个变量就能整体平移调色板,无需重新构建。PostHog 默认主题是暖黄灰色表面 + 橙/琥珀品牌色(colors.ts#L40-L46):export const DEFAULT_THEME: ThemeConfig = { hue: 90, darkHue: 264, tint: 0.006, primaryLight: 'oklch(0.65 0.21 37.41)', primaryDark: 'oklch(0.83 0.16 84.71)', }其中
--theme-hue、--theme-dark-hue、--theme-tint、--primary-light、--primary-dark就是产物里注释为 "Theme knobs" 的一组变量,生成时写入:root(colors.ts#L283-L291)。静态值(static):状态色(destructive、success、warning、info、completed)与品牌前景色是写死的
oklch()常量,独立于主题色相,保证"成功绿"不会因宿主换主题而变色。
值得注意的取值细节:fill-hover/fill-selected/fill-expanded这些交互填充令牌不是灰色固定值,而是color-mix(in oklab, var(--foreground) N%, transparent)——以前景色为基准的相对覆盖层,使得 hover/选中态在任何表面(background、muted、card)上都保持正确对比度;源码注释明确解释了动机:固定的半透明灰色在本身接近灰色的bg-muted上会不可见(colors.ts#L148-L167)。
THEME_DERIVED_TOKENS:加载期守卫
主题派生令牌有一个特殊的输出位置要求:它们必须挂在*选择器上,而不是:root,这样才能让容器级的局部覆盖(如在某个子树上写[--theme-hue:200])按元素重新求值。生成器会按 THEME_DERIVED_TOKENS 集合把令牌分成两批输出(colors.ts#L293-L305 的partition()):
/* color-system.css 的实际形态(节选) */ :root { color-scheme: light; } :is(.dark, [theme="dark"], [data-theme="dark"]) { color-scheme: dark; } /* Theme knobs — override these to shift the palette */ :root { --radius: 0.58rem; --theme-hue: 90; --theme-dark-hue: 264; --theme-tint: 0.006; --primary-light: oklch(0.65 0.21 37.41); --primary-dark: oklch(0.83 0.16 84.71); } /* 静态色(不引用主题变量,放 :root 安全) */ :root { /* --foreground、--success、--data-color-1… */ } /* 主题派生色 — 挂在 * 上,使元素级 [--theme-hue:X] 覆盖生效 */ * { /* --background、--card、--muted、--border、--fill-* … */ }为防止漏配导致"局部主题覆盖静默失效",模块加载时会执行一个构建期守卫assertThemeDerivedSyncedWithColors(colors.ts#L246-L266):任何令牌的值只要直接引用了var(--theme-hue)、var(--theme-dark-hue)、var(--theme-tint)、var(--primary-light)、var(--primary-dark)之一,就必须登记在THEME_DERIVED_TOKENS中,否则直接抛错:
if (refsThemeVar && !THEME_DERIVED_TOKENS.has(key)) { throw new Error( `[@posthog/quill-tokens] Token "${key}" references a theme variable ` + `but is missing from THEME_DERIVED_TOKENS. ...` ) }注意守卫只捕获直接引用;像fill-*这类经由var(--foreground)间接依赖主题的"传递性"令牌无法自动检测,必须在集合里手工登记(集合内注释也写明了这一点,colors.ts#L229-L233)。
暗色选择器:兼容三种暗色标记约定
generateStylesCSS()会生成 Tailwind v4 的@custom-variant dark,其选择器由resolveDarkSelector()归一化(colors.ts#L268-L273)。默认值是一个三合一的:is()选择器:
@custom-variant dark (&:is(:is(.dark, [theme="dark"], [data-theme="dark"]), :is(.dark, [theme="dark"], [data-theme="dark"]) *));三种形态分别覆盖 Tailwind 惯用的.darkclass、裸theme="dark"属性,以及data-theme="dark"属性(源码注释指出的一个真实动机:@modelcontextprotocol/ext-appsSDK 的applyDocumentTheme()使用的是data-*约定,colors.ts#L190-L200)。darkSelector可在StylesConfig中整体替换。
数据可视化令牌:有序分类色板与图表外壳变量
data-viz.ts 提供两类令牌,消费方是@posthog/quill-charts——图表包不带任何 CSS,在运行时把这些 CSS 变量读进ChartTheme(通过其themeFromCssVars/useChartTheme)。
有序分类色板--data-color-1..15
export const dataColors: readonly DataColorTuple[] = [ ['#1d4aff'], ['#621da6', '#7f26d9'], ['#42827e', '#3e7a76'], ['#ce0e74', '#bf0d6c'], ['#f14f58', '#f0474f'], ['#7c440e', '#b36114'], /* … 共 15 项,data-viz.ts#L27-L43 */ ] as const设计约束有三条:顺序有意义(图表按系列索引取色)、色相互不混淆(15 种色相在图上同时出现仍可分辨)、取静态品牌 hex 而非主题派生(系列色不应随宿主换主题而漂移)。类型是[light, dark?]元组——只有需要在暗色模式下额外提对比度的条目才带第二个值;generateDataVizVars()生成浅色块时输出全部 15 个变量,暗色块只输出带覆盖值的条目,其余沿用浅色值(data-viz.ts#L69-L79)。
图表外壳变量:别名到语义令牌
轴标签、轴线、十字线的变量不定义新颜色,而是别名到既有语义令牌,从而自动跟随明暗切换,无需单独的暗色块:
const graphChrome: Record<string, string> = { '--color-graph-axis-label': 'var(--muted-foreground)', '--color-graph-axis-line': 'var(--border)', '--color-graph-crosshair': 'var(--muted-foreground)', }双份副本 + CI 断言防漂移
JS 侧还导出三个非 CSS 消费者用的 API:dataColors、dataColorPalette(浅色 hex 的普通数组,兼作 CSS 变量未加载时的回退)、dataColorVarName(index)(把 0 基索引映射为--data-color-{index+1})。
quill-charts 中持有一份无 CSS 回退副本DEFAULT_CHART_COLORS(位于 packages/quill/packages/charts/src/core/theme.ts)。两份副本的一致性由 theme.test.ts 断言与dataColorPalette相等来保证——一旦dataColors改了而回退副本没跟上,测试失败直接挡住 CI,而不是让用户在运行时看到两张颜色对不上的图。
此外,变量名刻意与 PostHog 应用历史使用的--data-color-*/--color-graph-*保持一致,使应用侧可以删除本地定义、直接继承 quill 的产物。
间距、字号、圆角、阴影:各自的模块与生成器
这四类令牌各自独立成模块、各自带生成器,最终全部汇入generateStylesCSS()的@theme inline块。
间距采用 Tailwind v4 的单基值模型:整个间距刻度由一个--spacing变量驱动,所有间距工具类解析为calc(var(--spacing) * N)(spacing.ts)。默认基值0.25rem(16px 根字号下即 4px),与 Tailwind v4 默认一致。这一模型带来四个好处(源码注释归纳,spacing.ts#L3-L16):随根字号等比缩放(无障碍)、连续刻度(p-7、p-[17]、p-1.5都合法,无需预定义离散档位)、参数化(在任何节点上覆盖--spacing即可为子树调整密度)、与字号令牌单位一致(rem)。TS 消费方拿到的是纯函数:
import { spacing, spacingPx } from '@posthog/quill-tokens' spacing(4) // '1rem' —— CSS-in-JS spacingPx(4) // 16 —— React Native / Figma 插件 / 导出器圆角以--radius(默认0.58rem)为基值,派生出--radius-xs到--radius-4xl共 8 档,全部是calc(var(--radius) ± Npx)表达式(colors.ts#L436-L445 的RADIUS_VARS)。这里有个实现细节:圆角与阴影变量会同时写进两处——@theme inline(供 Tailwind 生成工具类)和运行时:root(或作用域选择器)块,因为 BEM 样式的 CSS 直接引用var(--radius-*)/var(--shadow-*)时,仅靠@theme inline是解析不到的(colors.ts#L511-L523)。
字号 / 字体族(typography.ts)与阴影(shadow.ts)同理:模块内定义源数据,generateFontSizeCSS()/generateFontFamilyCSS()/generateShadowCSS()各自产出@theme片段。
Tailwind 产物:@theme 映射与 base 层
generateStylesCSS()(colors.ts#L447-L545)生成的tailwind.css/tailwind-lib.css包含三段:
@custom-variant dark:如上所述的三合一暗色选择器;@theme inline块:颜色映射--color-{key}: var(--{key})(使bg-card、text-foreground、border-border等 Tailwind 工具类解析到 quill 的值)、间距基值、字号、字体族、阴影、圆角派生量,以及skeleton/pulse-glow/horizontal-shake/radar四个动画的 keyframes;@layer base(仅 app 形态,includeBaseLayer: true):全局重置* { @apply border-border outline-ring/50; }与body { @apply bg-background text-foreground; }。
lib 形态(includeBaseLayer: false)刻意不含 base 层——库包不该替宿主做全局样式重置。作用域形态(scope: '[data-quill]')还会额外做两件事:把 base 层的@apply收进[data-quill]子树内,并在作用域内重写 Tailwind--color-*映射,使bg-card等工具类在 quill 包裹区域内解析到 quill 的值而非宿主的(colors.ts#L352-L355)。作用域模式对暗色选择器的位置也做了兼容:无论.dark在[data-quill]的祖先上还是同一元素上(:is(.dark [data-quill], [data-quill].dark)及其后代变体),暗色模式都能生效(colors.ts#L317-L322)。源码注释解释了作用域迁移策略:迁移期间data-quill属性沿 DOM 树向上挪,到达<html>时作用域即等价于全局,届时可移除。
实操:新增或修改一个令牌的标准流程
这是日常改动的工作流(源自包内 agent 指南,并结合 build.ts 与守卫实现确认):
- 编辑对应的
src/*.ts模块。例如给dataColors数组加一项,或往semanticColors加一个[light, dark, tailwindClass]三元组。 - 如果新语义令牌直接引用了主题变量(
--theme-hue/--theme-dark-hue/--theme-tint/--primary-light/--primary-dark),把它登记进 THEME_DERIVED_TOKENS;忘了的话模块加载时会直接抛错,错误信息会告诉你该往哪里加。 - 重跑构建:
pnpm --filter @posthog/quill-tokens build。 - 验证产物:确认新变量出现在
dist/color-system.css(以及.scoped.css变体)里。
如果改动的是dataColors,还有一条隐含步骤:同步 quill-charts 的DEFAULT_CHART_COLORS(packages/quill/packages/charts/src/core/theme.ts),否则theme.test.ts的相等性断言会让 CI 变红——这其实是好事,说明防线在起作用。
小结
@posthog/quill-tokens把"设计令牌"当作一等工程问题来处理:TypeScript 是单一事实来源,CSS 全部可再生;主题派生与静态取色分轨,且用加载期守卫把"局部覆盖静默失效"这类隐性 bug 挡在构建阶段;数据可视化色板与图表包之间用测试断言锁住双副本一致性;四种产物(app / lib × scoped / 非 scoped)覆盖从自有应用到第三方宿主的全部引入形态。对这些机制的理解,是消费或扩展 Quill 设计体系的前提。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考