Ghost Shade 设计系统:新增组件的验收清单与实现规范(命名、cva 变体、Story 约定与 Token 纪律)
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
Ghost 的 Shade 设计系统(apps/shade)为仓库中的各个微前端应用(apps/admin、apps/activitypub等)提供统一的 UI 组件层。仓库内置了一份名为shade-new-component的 Agent 技能文档 SKILL.md,它规定了在apps/shade/src/components下新增或编辑组件/模式文件时必须通过的验收清单:命名规则、Storybook 标题前缀、基于class-variance-authority的组件标准形态、四种必需交互状态以及语义 Token 纪律。读完本文,你将掌握在 Shade 中交付一个合格组件的完整流程,并能对照仓库源码理解每条规则背后的实际实现。
技能文档的定位与触发条件
这份清单文档带有标准的技能 frontmatter,明确了它的适用范围与自动触发条件:
name: Shade new component description: Acceptance checklist for adding or editing a Shade component or pattern file — naming, sibling story, className forwarding, cva variants, required states, recipe usage. autoTrigger: - fileEdit: "apps/shade/src/components/**/*.{ts,tsx}"即:只要发生对apps/shade/src/components下任意.ts/.tsx文件的编辑,就该清单自动生效。它的定位是"验收"而非"教学"——在把组件标记为完成之前,下列各项必须全部通过。Shade 的 Agent 指引文档 AGENTS.md 也要求:先使用shade-component-decision技能判断代码应落在哪一层,再按shade-new-component走验收清单,两者配合使用。
文件与命名约定
文档规定的命名规则与仓库源码一一对应:
| 对象 | 约定 | 仓库实例 |
|---|---|---|
| 文件名 | kebab-case(dropdown-menu.tsx),与 ShadCN CLI 输出保持一致,不要因大小写而改名 | apps/shade/src/components/ui/dropdown-menu.tsx、button.tsx |
| 导出标识符 | PascalCase(DropdownMenu) | export { Button, buttonVariants }(button.tsx) |
| 钩子、函数、变量 | camelCase | cn、debounce(ds-utils.ts) |
| 同目录 Story | 必需的<name>.stories.tsx兄弟文件 | button.stories.tsx与button.tsx同目录 |
| 包内导入 | 统一走@/别名(@/lib/utils、@/components/ui/input-surface) | import { cn } from '@/lib/utils' |
值得注意的是@/lib/utils本身是一个再导出层:utils.ts 只有一行export * from './ds-utils',真正的工具函数实现在 ds-utils.ts。Shade 的人类文档 contributing.mdx 中的命名表格与本清单完全一致,说明这是同一套约定的两个表达面(清单面向机器触发,文档面向人类阅读)。
Storybook 标题前缀与 Story 文件要求
Shade 按"层"组织 Storybook 的顶层目录,组件的title必须匹配其所处的层:
| 层 | 标题前缀 |
|---|---|
| Primitive(布局原语) | Primitives / <Name> |
| Component(通用控件) | Components / <Name> |
| Recipe(纯类名配方) | Recipes / <Name> |
| Pattern(产品组合) | Patterns / <Name> |
| Token gallery(Token 展示) | Tokens / <Topic> |
这五个前缀正好对应apps/shade/src/components/下的目录划分(primitives/、ui/、patterns/、page-templates/)以及根部的components.ts、primitives.ts、patterns.ts、page-templates.ts各层入口桶文件(见 package.json 的exports字段,各层均有独立子路径导出)。
每个 story 文件还必须具备以下要素:
tags: ['autodocs']—— 启用自动文档页;parameters.docs.description.component—— 一行的组件级摘要;- 每个 story 的
parameters.docs.description.story—— 一句话说明该变体在何时使用; - 每个重要变体/状态各写一个 story:宁要多个小而聚焦的 story,不要一个长文案的 story。
button.stories.tsx 是这些要求的标准范本:
const meta = { title: 'Components / Button', component: Button, tags: ['autodocs'], parameters: { docs: { description: { component: 'Reusable button for interactive actions across the UI. ...', }, }, }, } satisfies Meta<typeof Button>;其中title: 'Components / Button'使用了 Component 层前缀;随后Destructive、Outline、Secondary、Ghost、LinkVariant等每个变体各自一个 story,并逐个附带description.story(如 destructive 的说明是"用于危险或不可逆操作(删除、移除、重置)")。
组件标准形态:cva 变体 + forwardRef + className 透传
文档给出了组件的标准骨架,这是一个"可复制即可运行"的模板:
import * as React from 'react'; import {cva, type VariantProps} from 'class-variance-authority'; import {cn} from '@/lib/utils'; const thingVariants = cva('base-classes-here', { variants: { variant: {default: '...', destructive: '...'}, size: {default: '...', sm: '...'} }, defaultVariants: {variant: 'default', size: 'default'} }); export interface ThingProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof thingVariants> {} const Thing = React.forwardRef<HTMLDivElement, ThingProps>( ({className, variant, size, ...props}, ref) => ( <div ref={ref} className={cn(thingVariants({variant, size, className}))} {...props} /> ) ); Thing.displayName = 'Thing'; export {Thing, thingVariants};对照真实实现 button.tsx 可以看到该骨架的完整落地:buttonVariants用cva()声明了variant(default/destructive/outline/secondary/ghost/link/dropdown)与size(default/sm/lg/icon)两组变体并设置defaultVariants;Button通过React.forwardRef定义(L43),className经cn(buttonVariants({...}))合并后落到 DOM(L60),末尾Button.displayName = 'Button'并导出组件与变体函数两个符号(L66-L68)。
文档在此骨架上提出三条硬性要求:
className必须透传并用cn()合并——绝不覆盖、绝不丢弃,适用于每个渲染 DOM 的组件。cn()的实际实现是twMerge(clsx(inputs))(ds-utils.ts),即先做布尔/数组类归一化,再用 Tailwind 的冲突合并规则解决同类冲突(例如消费方传入的h-8能正确覆盖组件内部的h-9),这正是"透传不覆盖"的底层保障。- 只允许视觉/交互类 props(
variant、size、loading),禁止工作流类 props(如isMembersPage、layoutMode)——一旦出现这类 props,说明你需要的是一个 Pattern 包装层而不是通用 Component。 - 多区域组件用复合子组件(
.Title、.Actions、.Body)暴露结构,而不是堆一袋 props。
forwardRef、cva 与 Recipe 的适用边界
文档对三个易误用的机制给出了明确的"用 / 不用"判据:
forwardRef:组件渲染单个 DOM 元素且消费方可能需要 ref 时使用(大多数 UI 控件)。以下场景跳过:纯 provider/context 包装器、本身不渲染 DOM 的 Radix root 再导出、ref 语义已由子组件处理的组件。cva():组件有变体或状态化类名分支(variant、size、tone)时使用;单一样式且一行cn(...)更清晰的简单组件可不用。- Recipe(
<name>.ts,无 JSX):不写forwardRef、不写cva()、不引入 React,只返回类名字符串。
仓库中的 input-surface.ts 是 Recipe 的范例:它是纯.ts文件,导出inputSurfaceClasses原子类名(base、focusSelf、focusWithin、invalidSelf 等)和inputSurface(mode)函数,注释明确说明它"拥有"边框、背景、圆角、过渡、焦点环与失效态,消费方只负责尺寸、内边距与排版。
四种必需状态:default / hover / focus-visible / disabled
文档要求每个交互组件在以下四种状态下都必须正常工作,且每种状态都要在 story 中可见:
- default
- hover
- focus-visible(使用
focus-visible:前缀,绝不用focus:) - disabled
active、loading、error、empty 属于可选状态,仅在适用时提供。button.tsx的基础类串即体现了这条纪律:focus-visible:ring-1 focus-visible:ring-focus-ring focus-visible:outline-hidden负责焦点环,hover:bg-primary/90负责悬停,disabled:pointer-events-none disabled:opacity-50负责禁用态。
对表单控件,文档要求通过inputSurfacerecipe驱动外观,而不是各组件自己拼 border/focus ring。这正是 input-surface.ts 的设计:'self'模式直接作用于可聚焦元素(<input>、<textarea>),'within'模式作用于包含可聚焦子元素的包装器(通过:has()从任意聚焦后代派生焦点/失效样式);确有特殊场景时再手动组合inputSurfaceClasses原子。相关的深度说明在姊妹技能 shade-input-surface-recipe 中。
Token 纪律:无 hex、无裸灰阶、无dark:颜色变体
文档的 Token 章节只有两条规则,但都是硬性约束:
- 禁止 hex 值,禁止
bg-gray-200这类裸调色板工具类用于 UI 外观; - 禁止
dark:颜色变体——深色模式由语义 Token 统一处理。
这与 Shade 的 CSS 架构直接对应:根部的 tokens.css 是"仅 Token"的入口,它只做两件事——@import './tailwind.theme.css'(Tailwind 主题映射与原始 Token)和@import './theme-variables.css'(语义 Token 及其深色模式取值)。也就是说,深色模式不是靠组件里写dark:bg-xxx实现的,而是在theme-variables.css中为同一组语义变量提供深色值。button.tsx中使用的bg-primary、text-primary-foreground、border-control-border、ring-focus-ring全部是这类语义 Token。更详细的规则见 shade-tokens-not-hex 与 shade-no-dark-variants 两份技能文档。
完成前验收清单(逐项继承原文档)
以下清单必须逐项通过,才允许把组件/模式标记为完成:
- 位于正确的层(由 shade-component-decision 判定)
- kebab-case 文件名、PascalCase 导出、同目录
<name>.stories.tsx className已透传并用cn()合并(始终如此)- 若组件渲染的 DOM 可能被消费方持有 ref,则使用
forwardRef - 若组件有变体,则使用
cva()并设置defaultVariants - 四种必需状态均正常工作且在 story 中可见
- 仅用语义 Token——无 hex、无裸灰色、无
dark:颜色变体 - 通用 Component 上不携带产品特异的 props
- Story 具备
tags: ['autodocs']、组件级描述与每个 story 的一行描述 pnpm lint、pnpm test与 Storybook 全部干净
最后一条对应 package.json 中的真实脚本定义:pnpm test= 类型检查 + 带覆盖率的 Vitest(pnpm test:types && vitest run --coverage),pnpm lint= ESLint 检查src/与test/,pnpm storybook= 在 6006 端口启动 Storybook 查看src/docs/下的文档。在独立应用中消费 Shade 时注意:Shade 当前是私有包,嵌入 Ghost Admin 的应用(apps/admin、apps/activitypub)不应重复导入@tryghost/shade/styles.css或再包一层ShadeApp,应从层级子路径导入组件(如import {Button} from '@tryghost/shade/components'),CSS 与应用包装由 Admin 集中提供(见 apps/shade/README.md)。
唯一事实来源(Source of Truth)
清单文档末尾声明:规则的权威出处是Storybook 的 Overview / Contributing 页(contributing.mdx)与各组件自身的 stories。换言之,这份 SKILL.md 是"机器可触发的验收快照",当约定演进时,应以 Storybook 人类文档为准同步更新——这也是 AGENTS.md 中"当共享约定变化时,更新 Storybook 人类文档,不要只让本文件成为唯一来源"这条工作流要求。配合阅读同组技能文档(shade-component-decision、shade-imports、shade-shadcn-install、shade-use-primitives)可以覆盖"选层 → 安装/引入 → 实现 → 验收"的完整链路。
【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考