Ghost Shade 设计系统为什么不该用 Tailwinddark:变体写颜色——语义 Token 自动翻转深色模式原理与实践【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost本文基于 Ghost 仓库的 Skill 文档 .agents/skills/shade-no-dark-variants/SKILL.md 展开讲清楚 Shade 设计系统「不写dark:颜色变体」这条规则背后的机制语义 token 如何自动在明暗模式间翻转、dark:前缀为什么会制造冗余或错误样式以及遇到真正需要区分明暗模式的例外logo、插图、ring 透明度时应当怎么做。读完本文你不仅能按规范在 Shade 消费方应用中正确取色还能理解theme-variables.css与tailwind.theme.css的双文件分工并在排查「深色模式下某处显示不对」的问题时快速定位到「选错了 token」这一根因。规则本身dark:只该出现在例外里Skill 文档的核心主张只有一句话Shade 的语义 tokenbg-background、text-foreground、border-border-default、bg-surface-elevated等本身就自带明暗模式翻转能力任何dark:bg-.../dark:text-.../dark:border-...的出现都意味着工程师退回到了原始的非语义色板raw palette——正确做法是修正 token 选择而不是用dark:变体去打补丁。文档中给出的正反示例// 正确 —— 全部走语义 token明暗两种模式自动成立 div classNamebg-surface-elevated border border-border-default text-foreground p classNametext-muted-foregroundHint text/p /div// 错误 —— 用 dark: 给原始色板工具类打补丁 div classNamebg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 // 错误 —— 冗余语义 token 自己就会翻转dark: 变体只是叠加了一条不生效的重复规则 // 而且它向读者暗示「深色模式在这里被特殊处理了」实际上并没有 div classNamebg-surface-elevated dark:bg-surface-elevated第二种错误特别值得注意给语义 token 再加一层dark:不仅冗余同一 token 的深色值本来就由.dark作用域覆盖还会在样式表里生成一条语义上无意义的重复规则污染可读性。这条规则带有自动触发条件见 SKILL.md 的 frontmatterautoTrigger: - fileEdit: apps/{shade,admin,admin-x-framework,activitypub}/**/*.tsx也就是说只要编辑apps/shade、apps/admin、apps/admin-x-framework、apps/activitypub这四个 Shade 消费方应用下的 TSX 文件该 Skill 就会被触发提醒——这正是 Ghost 把「设计系统约束」做成 Agent 可执行守卫的机制之一。Token 为什么能「自动翻转」双文件分工这条规则成立的根基是 Shade 把颜色系统拆成了两个职责明确的 CSS 文件由 tokens.css 作为入口引入/* Token-only CSS entrypoint (no preflight or utility layers). */ import ./tailwind.theme.css; import ./theme-variables.css;theme-variables.csstoken 的「值」住在这里apps/shade/theme-variables.css 是语义 token 的取值源。它的结构是典型的「:root定义浅色值 .dark类覆盖深色值」:root块第 294 行定义浅色模式下的全部语义变量例如:root { --background: var(--color-white); --foreground: var(--color-black); --muted-foreground: var(--color-gray-700); --border-default: color-mix(in oklab, var(--color-gray-200), var(--color-gray-300)); --surface-elevated: var(--color-white); --control-surface: var(--surface-elevated); /* ...共 90 个语义变量含 hover、表格行、侧边栏、图表系列等 */ }.dark块第 96190 行在根元素带.dark类时整套翻转。深色模式并不只是「白变黑」Ghost 在这里做了一套降饱和的自定义灰阶注释原文Less-saturated gray scale for dark mode (lightness hue preserved, chroma halved again).dark { /* 深色模式下重新定义 raw 灰阶明度与色相保留、彩度再次减半 */ --color-gray-50: oklch(98.54% 0.0003 286.4); --color-gray-100: oklch(96.97% 0.0004 247.8); /* ... */ --color-gray-950: oklch(25.95% 0.0016 258.4); --background: oklch(0.178 0.003 271); --foreground: var(--color-gray-200); --surface-elevated: var(--color-sidebar-bg); /* 与 page 明显分层 */ --surface-elevated-2: oklch(0.235 0.004 260); --control-border: color-mix(in oklab, var(--color-gray-900) 70%, transparent); /* ... */ }注意一个关键细节.dark块不仅翻转了语义 token还重写了 raw 灰阶--color-gray-*本身。这解释了两件事其一即使你误用了bg-gray-900这类原始色板类它在深色模式下的实际取值也和浅色模式不同问题会以更隐蔽的方式出现其二深色模式下列表选中行--table-row-selected特意从「蓝色阶一步」改成了混入页面背景色的color-mix第 122125 行有注释说明原因深色明度下原始蓝色阶饱和度过高整行着色会刺眼——这些细节都只存在于 token 层是任何手写dark:补丁无法复现的设计决策。文件末尾还有一段专门针对深色模式的阴影加强第 192213 行Tailwind 默认阴影用约 10% 黑色透明度在深色页面上几乎不可见因此.dark .shadow-sm等规则会重写--tw-shadow变量而不是box-shadow以保留 Tailwind v4 组合式box-shadow中的 ring/focus 图层。tailwind.theme.css把 CSS 变量「接」到 Tailwind 工具名上apps/shade/tailwind.theme.css 是 Tailwind v4 的theme块负责把theme-variables.css里的变量映射为 Tailwind 工具类例如--color-background: var(--background); --color-surface-elevated: var(--surface-elevated); --color-border-default: var(--border-default); --color-control-border: var(--control-border); --color-table-row-hover: var(--table-row-hover);这样bg-surface-elevated才成为一条真实存在的工具类其最终颜色由--surface-elevated在运行时取决于.dark是否存在解析。该文件同时是 raw 色板目录——oklch格式的完整--color-gray-*、--color-blue-*等原始色阶定义在这里第 2537 行起。配套文档 apps/shade/src/docs/tokens.mdx 对此有明确约定编辑theme-variables.css来改变 token 的值编辑tailwind.theme.css来接入新的工具类名或新增 raw 颜色语义颜色变量应当直接解析到 raw--color-*token而不是另一个语义 token例如--text-primary: var(--color-black)优于--text-primary: var(--foreground)。另一条值得注意的实现细节是tailwind.theme.css第 5 行custom-variant dark (:is(.dark *):not(.light *));也就是说 Ghost 自定义了dark:变体的语义仅当元素是.dark后代、且不是.light后代时生效。这为「强制浅色」提供了逃生口也从变体层面解释了为什么「同一个 token 上叠dark:」是重复规则。取色决策表按「它是什么」而非「它长什么样」选 tokenSkill 文档要求看到dark:异味后先判断这个类描述的是 surface、text、border 还是 hover再挑匹配的语义 token。完整 token 清单见姊妹 Skill .agents/skills/shade-tokens-not-hex/SKILL.md其核心对照表如下值以 theme-variables.css 为准关注点语义 tokenTailwindCSS 变量页面画布bg-background--background页面之上的一层bg-surface-elevated--surface-elevated浮在 elevated 之上的浮动菜单bg-surface-elevated-2--surface-elevated-2主文本text-foreground/text-primary--text-primary次要 / 弱化文本text-muted-foreground--text-secondary默认边框卡片、横幅、分割线不透明border-border-default--border-default浮动层的复合边框popover、dropdown深色模式下半透明常配/60或/30透明度修饰border-border--border表单控件边框input、select、outline 按钮border-control-border--control-border通用 hoverbg-interactive-hover--interactive-hoveroutline 按钮 hoverbg-button-hover--button-hoverTabs / 页面菜单 hover/activebg-tab-hover、bg-tab-active--tab-hover、--tab-active表格行 hoverbg-table-row-hover--table-row-hover危险色bg-destructive、text-destructive--destructive焦点环ring-focus-ring、border-focus-ring--focus-ring表面高度surface elevation的选取规则是按元素后面垫着什么来选而不是按组件类型--background→ body、应用外壳、编辑器--surface-elevated→ 侧边栏、卡片、顶栏、sticky 表头--surface-elevated-2→ 浮动菜单DropdownMenu、Select、Popover以及侧边栏用户菜单。浅色模式下这三层都压扁到近白色靠边框和阴影撑起层次深色模式下它们是三个明显不同的颜色使层叠表面不依赖边框也保持可读——这一设计在 tokens.mdx 的Surface elevation一节有完整说明。例外什么时候dark:是合法的Skill 文档明确列出了两类可以使用dark:的场景1. 真正的「资产差异」——无法用 token 表达的内容Logo 与品牌标识插图 / SVG 艺术资产截图或图片。这类资源在明暗模式下本来就是两个不同文件/颜色没有语义概念可抽象dark:是对症的。2. 现有 token 缺失 透明度修饰——窄范围允许但先问一句「这个值该不该沉淀成新 token」。文档举了现成例子部分既有 Shade 组件对 ring 透明度使用了dark:例如inputSurface配方内的dark:ring-destructive/40。对照源码 apps/shade/src/components/ui/input-surface.ts 可以精确看到这个「合法窄范围dark:」的形态export const inputSurfaceClasses { base: rounded-md border border-control-border bg-control-surface transition-colors, focusSelf: focus-visible:outline-hidden focus-visible:border-focus-ring focus-visible:ring-2 focus-visible:ring-focus-ring/25, invalidSelf: aria-[invalidtrue]:border-destructive aria-[invalidtrue]:ring-destructive/20 dark:aria-[invalidtrue]:ring-destructive/40, invalidWithin: has-[[aria-invalidtrue]]:border-destructive has-[[aria-invalidtrue]]:ring-destructive/20 dark:has-[[aria-invalidtrue]]:ring-destructive/40, /* ... */ } as const;注意这里的模式border-destructive本身不带dark:语义 token 自动翻转唯一的dark:前缀出现在透明度修饰ring-destructive/20 → ring-destructive/40上——因为深色背景下同一不透明度的 ring 视觉权重偏弱需要单独提一档。这正是文档所说「when the value is an opacity modifier on an existing token」的情形同时文档也要求先用 theme-variables.css 判断这个新值是否应该沉淀为独立 token而不是就地打补丁。深色模式排查思路几乎总是「选错 token」不是「少了个 dark:」Skill 文档的最后一节给出了调试心法如果某处只在深色模式下显示异常修复几乎总是换对一个 token而不是补一个dark:变体。具体排查步骤重新按上面「表面高度 / 交互表面 / 表单边框」的决策表为这个元素重选 token打开 apps/shade/theme-variables.css 的.dark块确认该 token 在深色模式下的实际解析值验证它与你视觉上的预期是否一致例如--surface-elevated深色下是--color-sidebar-bg而非纯黑--border-default深色下是--color-gray-950如果差异来自阴影/环检查文件末尾的.dark .shadow-*覆盖是否适用。从源码结构看这条排查思路之所以有效是因为明暗模式的差异被完全集中在单一文件的一个作用域块内:rootvs.dark组件与页面里几乎不存在分散的dark:颜色规则——样式「只可能在 token 层出错」这一不变量使得调试收敛为一个查表动作。配套地admin 端的主题切换实现apps/admin/src/hooks/use-theme.ts也只是在根元素上切换.dark类html.classList.toggle(dark, resolvedTheme dark)主题判定支持light/dark/system三种模式并通过matchMedia((prefers-color-scheme: dark))跟随系统而 ShadeApp 通过darkModeprop 接收解析结果——业务侧不感知具体色值全部样式决策由 token 层闭环。小结问题规则组件/页面里该用什么颜色只用语义 token 工具类bg-surface-elevated、text-foreground、border-border-default……需要区分明暗模式的颜色不写dark:token 自动翻转。差异值沉淀到 theme-variables.css 的.dark块Logo、插图、截图允许dark:资产差异无 token 可表达现有 token 缺一个透明度档位窄范围dark:ring-.../40可用参照inputSurface但先评估是否应新增 token深色模式下显示异常换 token而不是加dark:到.dark块查该 token 的实际解析值这套「token 翻转、dark:例外化」的设计把主题一致性的维护成本从「每个组件 × 两种模式」压缩到了「一个文件里的一张变量表」也是 Ghost 能在admin、admin-x-framework、activitypub、shade多个应用中保持深浅模式视觉一致的核心机制。【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
