关于 spencer-approved-frontends
面向 Spencer 项目的前端设计 skill,规定紧凑、中性的视觉风格及 React 与 TypeScript 约定,帮助编码 Agent 构建严肃的内部工具界面。
主要功能
- 01紧凑、中性的界面风格
- 02React 18 与 Vite 项目默认配置
- 03严格的 TypeScript 约定
- 04手写 UI 组件模式
- 05表单与界面状态指南
适用场景
- 01启动新的内部工具前端
- 02将 Spencer 的视觉风格用于界面
- 03不借助组件库构建表单
- 04处理加载、空白与错误状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ schneidenbach/ skills --skill spencer-approved-frontends安装后在 Claude Code 中输入/spencer-approved-frontends确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamlreferences/anti-patterns.mdfull-index-css.mdfull-tailwind-config.mdstarter-app.mdtextarea-autoresize.md详细介绍
Spencer 认可的前端
默认的“AI 生成式前端”风格不适合 Spencer。玻璃质感渐变、超大的主视觉文字、宽松的内边距、每张卡片都加投影、蓝紫色线性渐变、所有元素都用 rounded-2xl、到处都是 lucide 图标——这些都不合适。Spencer 构建的是密集、中性、使用系统字体的 UI,看起来像严肃的内部工具。想想 Linear、Notion 的管理视图、macOS 设置面板,而不是 SaaS 落地页。
拿不准时,就减少装饰像素、缩小文字、收紧间距,并只用一组中性色阶。
本规范优先于其他技能的情况
如果 frontend-design(或类似技能)也处于启用状态,本规范在风格决策上优先。其他技能的流程没有问题,但其视觉默认值不适用。Spencer 的前端看起来像 ~/Repos/ 中的 due-diligence 和 security-review 仓库。如果你可以访问这些仓库,且用户正要开始一个新项目,可以把它们作为真实参考。否则,请遵循本文件中的模式。
新项目的技术栈默认值
从头开始新建项目时,除非 Spencer 另有说明,否则从这里开始:
- React 18 搭配 Vite 5+(不用 Next.js,也不用 CRA)
- TypeScript 严格模式 +
noUncheckedIndexedAccess+noImplicitOverride - Tailwind CSS 3.4(默认不用 Tailwind 4——Spencer 的仓库固定使用 3.4)
- npm(不用 pnpm/yarn/bun)
- 不用组件库。 不用 shadcn、Radix、MUI、Mantine、Chakra、Headless UI。手动实现所需的少数基础组件(Modal、Badge、Button、InlinePicker)。
- 默认不用图标库。 使用小型内联 SVG 或文本字符(拖动手柄用
::,关闭用×)。如果 Spencer 要求使用图标,先询问要用哪个库,再添加。 - 不用表单库。
useState+ 普通的<form onSubmit>就足够。 - 不用状态库。 除非证明确有需要,否则
useState/useReducer/useContext就足够。不用 Redux、Zustand、Jotai、Recoil。 - 不用数据获取库。 使用普通的
fetch,并封装在精简的lib/api.ts模块中。不用 React Query、SWR、axios。 - 不用动画库。 不用 framer-motion,也不用 react-spring。悬停/聚焦时的 CSS
transition就是全部的动画预算。 - 小型应用不用路由库——在
App.tsx中手动解析window.location.pathname。如果应用扩展到约 3 个以上路由,询问 Spencer 是否该使用路由库。
如果 Spencer 要求使用不同的技术栈,就按 Spencer 的要求来。但要先提出这个技术栈。
项目布局
src/
App.tsx root, manual routing
main.tsx entry
index.css global tokens + utilities
components/ one .tsx per component, PascalCase
lib/ cx.ts, format.ts, api.ts, hooks
shared/ types shared with backend, aliased as @shared/*
不要使用桶导出。小型应用不要创建 pages/ 目录。不要创建并列的 CSS 文件——只用 Tailwind 工具类。
视觉规范
每个 Spencer UI 都应遵循这些默认设置。请牢记。
颜色令牌
在 tailwind.config.ts 和 index.css 中将这些定义为 CSS 变量。不要跳过这一步——Tailwind 默认的中性色板色调太冷、饱和度太高。
// tailwind.config.ts (excerpt)
colors: {
surface: {
DEFAULT: "#ffffff",
soft: "#fafafa",
deep: "#f5f5f4",
hover: "#f4f4f4",
},
ink: {
DEFAULT: "#0a0a0a",
soft: "#404040",
muted: "#737373",
faint: "#a3a3a3",
},
line: {
DEFAULT: "#eaeaea",
strong: "#d4d4d4",
},
// domain-specific severity ramp — rename per project
// e.g. "risk" for due-diligence, "sev" for security-review,
// "status" or "tier" for whatever the app is about
risk: {
critical: "#7f1d1d",
high: "#b91c1c",
medium: "#b45309",
low: "#1d4ed8",
},
},
为每种语义颜色配上一个通过 CSS 变量定义的柔和背景变体,例如 --risk-high-soft,并在 bg-[var(--risk-high-soft)] 中使用。柔和背景 + 饱和文字 = 徽标效果。
字体排版
fontFamily: {
sans: ["-apple-system", "BlinkMacSystemFont", "'SF Pro Text'", "'Segoe UI'", "Roboto", "Helvetica", "Arial", "sans-serif"],
mono: ["'SFMono-Regular'", "'SF Mono'", "Menlo", "Consolas", "monospace"],
},
仅使用系统字体。不用 Google Fonts、Inter、Geist,也不导入字体。
允许的字号(Tailwind 默认值不合适时,使用 text-[Npx] 任意值):
11px— 带字距的标签、徽标、紧凑按钮12px— 主按钮、紧凑控件12.5px— 次级按钮13px— 正文(这是基础字号——<body>本身为font-size: 14px、line-height: 1.5,但大多数应用界面使用 13px)15px— 模态框标题、分区标题
字号范围仅限这些。不要用 text-base、text-lg、text-2xl,也不要用主视觉文字。若需强调,在 15px 下使用 font-semibold tracking-tight,不要增大字号。
对于表单/分区标签,使用此工具类(定义于 index.css):
.tracked-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-muted);
}
间距与密度
Spencer 的 UI 很紧凑。允许使用的间距值,按使用频率排序:
gap-1、gap-1.5、gap-2、gap-3px-2、px-2.5、px-3py-0.5(徽标/标签)、py-1、py-1.5、py-2
不要默认使用 p-4、p-6、p-8、gap-4、gap-6。这些适用于另一套设计系统。
按钮高度约为 28–32px。徽标高度约为 18–20px。这才是正确的。
边框、圆角、阴影
- 所有分隔处都使用 1px 边框:
border border-line或border-b border-line。 - 圆角:输入框使用
rounded-md,按钮和卡片使用rounded-lg,模态框使用rounded-xl,标签/徽标使用rounded-full。不要使用rounded-2xl。 - 常规元素不要加阴影。 Spencer 的
index.css中包含.clean-app * { box-shadow: none; }。唯一例外是模态框:shadow-[0_20px_60px_-24px_rgba(0,0,0,0.25)]。 - 不要使用渐变。 背景、按钮和文本都不行。只使用纯色填充。
动画
仅通过 Tailwind 默认的 transition 类实现悬停和焦点过渡。不使用 animate-pulse、animate-spin(除非它是 SVG 内真正的加载指示器)、入场/离场动画或 framer-motion。
实际需要的四种基础组件
实现以下四个手写组件,就能满足任何应用 80% 的需求。照着这些模式编写;不要从库中导入。
lib/cx.ts
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
它可以替代 clsx 和 classnames。不要安装这两个包。每个组件都用 cx 来处理条件类名。
Button(提取为 const,而非组件)
大多数文件甚至不需要构建 <Button /> 组件。只需在文件顶部将类名字符串提取为 const,然后用于 <button>:
const primaryButtonClass =
"inline-flex items-center justify-center rounded-lg bg-ink px-2.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-ink-soft disabled:cursor-not-allowed disabled:opacity-50";
const secondaryButtonClass =
"inline-flex items-center justify-center rounded-lg border border-line bg-surface px-3 py-1.5 text-[12.5px] font-medium text-ink-soft transition hover:border-line-strong hover:text-ink disabled:cursor-not-allowed disabled:opacity-40";
const dangerButtonClass =
"inline-flex items-center justify-center rounded-md border border-line bg-surface px-2 py-1 text-[11px] font-medium text-risk-high transition hover:border-risk-high";
直接使用这些字符串。如果有三个文件都用到了同一个字符串,再提取到共享模块中——不要提前提取。
Input
const inputClass =
"w-full rounded-lg border border-line bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none transition placeholder:text-ink-faint focus:border-ink";
模式相同:提取为 const,用于普通的 <input> 或 <textarea>。
Badge / pill
const pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium";
const riskTone: Record<Risk, { text: string; bg: string; dot: string }> = {
critical: { text: "text-risk-critical", bg: "bg-[var(--risk-critical-soft)]", dot: "bg-risk-critical" },
high: { text: "text-risk-high", bg: "bg-[var(--risk-high-soft)]", dot: "bg-risk-high" },
medium: { text: "text-risk-medium", bg: "bg-[var(--risk-medium-soft)]", dot: "bg-risk-medium" },
low: { text: "text-risk-low", bg: "bg-[var(--risk-low-soft)]", dot: "bg-risk-low" },
};
export function RiskBadge({ value }: { value: Risk }) {
const tone = riskTone[value];
return (
<span className={cx(pill, tone.text, tone.bg)}>
<span className={cx("h-1.5 w-1.5 rounded-full", tone.dot)} aria-hidden />
{value}
</span>
);
}
1.5px 圆点 + 柔和背景 + 高饱和度文本 + 首字母大写的值,就是 Spencer 的标志性徽标。
Modal
export function Modal({
open, title, children, onClose, width = "max-w-3xl",
}: {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
width?: string;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 px-4 py-10"
onClick={onClose}
>
<div
className={`w-full ${width} overflow-hidden rounded-xl border border-line bg-surface shadow-[0_20px_60px_-24px_rgba(0,0,0,0.25)]`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="text-[15px] font-semibold tracking-tight text-ink">{title}</h2>
<button type="button" onClick={onClose} className="text-sm text-ink-muted transition hover:text-ink">
Close
</button>
</div>
<div className="max-h-[78vh] overflow-y-auto scrollbar-thin">{children}</div>
</div>
</div>
);
}
你只需要这一个模态框。
App shell
export function App() {
return (
<div className="clean-app flex h-screen flex-col bg-surface pt-7 text-[13px] text-ink">
{bannerError && <ErrorBanner message={bannerError} onDismiss={() => setBannerError(null)} />}
<div className="min-h-0 flex-1">
{/* routed content */}
</div>
</div>
);
}
滚动容器上的 min-h-0 flex-1 不容更改——没有它,嵌套的 flex 列将无法滚动。
编写组件的约定
-
每个文件只放一个组件,文件名使用 PascalCase。不使用 barrel 文件,不通过 index.ts 重新导出。
-
只使用函数组件。不使用
React.FC。除非确实需要 ref,否则不使用forwardRef。 -
props 使用内联对象类型,不要单独定义
interface:// do export function Badge({ value }: { value: Risk }) { ... } // not interface BadgeProps { value: Risk } export function Badge({ value }: BadgeProps) { ... }例外:当 props 会被复用,或类型是泛型时(例如下方的
InlinePicker<T>模式),为 interface 命名。 -
对任何条件类名都使用
cx()。不要通过模板字符串拼接类名。 -
绝不使用
any。Spencer 的 TS 设置很严格,并启用了noUncheckedIndexedAccess,因此要处理数组索引可能返回的T | undefined。 -
导入顺序:React →
@shared/*类型 →lib/工具 → 相对路径组件。 -
注释:说明原因,而不是内容。不写 JSDoc。没有实际理由时,不写
// TODO: refactor。
泛型 InlinePicker 模式
遇到具有语义颜色(状态、严重程度、风险、等级)的类选择控件时,使用这个泛型。不要为每种情况单独编写 <select>。
interface InlinePickerProps<T extends string> {
value: T;
disabled?: boolean;
saving?: boolean;
onChange: (next: T) => void;
options: readonly T[];
toneClass: (value: T) => string;
ariaLabel: string;
label?: (value: T) => string;
}
function InlinePicker<T extends string>({
value, disabled, saving, onChange, options, toneClass, ariaLabel, label = (v) => v,
}: InlinePickerProps<T>) {
return (
<label className="relative inline-flex">
<span className="sr-only">{ariaLabel}</span>
<select
value={value}
disabled={disabled || saving}
onChange={(e) => onChange(e.target.value as T)}
className={cx(
"cursor-pointer appearance-none rounded-full border border-transparent bg-no-repeat py-0.5 pl-2.5 pr-6 text-[11px] font-medium capitalize",
toneClass(value),
saving && "opacity-60",
disabled && "cursor-not-allowed opacity-50",
)}
>
{options.map((o) => <option key={o} value={o}>{label(o)}</option>)}
</select>
</label>
);
}
然后进行特化:
export function RiskPicker(props: Omit<InlinePickerProps<Risk>, "options" | "toneClass" | "ariaLabel">) {
return <InlinePicker {...props} options={risks} toneClass={(v) => `${riskTone[v].text} ${riskTone[v].bg}`} ariaLabel="Risk" />;
}
表单
- 使用普通的
<form onSubmit={...}>。调用event.preventDefault()。不使用 react-hook-form 或 formik。 - 每个表单使用一个
saving布尔值。值为 true 时禁用提交按钮。 - 错误通过屏幕顶部的
ErrorBanner显示,或通过传给父组件的setError回调处理。不使用 toast 库。 - 校验:在提交处理函数中直接进行。如果逻辑变复杂,就在同一文件中提取为
validate()函数。除非 Spencer 要求,否则不要使用 zod。 - 使用
useEffect监听 scrollHeight 的技巧自动调整文本区域高度(参见references/textarea-autoresize.md)。
加载、空状态和错误状态
三条规则:
- 加载应使用小型行内标记,而不是全屏旋转指示器。禁用表单,并在按钮上显示
Saving…文本。对于列表,渲染与行高匹配的骨架行(1px 边框、相同内边距),而不是脉动的渐变色块。 - 空状态应使用一行
text-ink-muted文本,或许再加一个次要按钮。不要使用插图、剪贴画或大型“开始使用”卡片。 - 错误状态使用
bg-[var(--risk-high-soft)] text-risk-high,并采用ErrorBanner模式。始终包含一个关闭按钮。
Spencer UI 中绝不能出现的内容
如果你发现自己想要使用以下任何内容,请停下来:
- 渐变背景、渐变文本、渐变按钮。
- 玻璃效果/模糊效果/backdrop-filter 效果。
- 卡片、按钮、输入框上的投影(模态框是唯一例外)。
rounded-2xl或更大的圆角。任何大于rounded-xl的圆角都会显得像营销网站。- Hero 区域、大型居中标题、“欢迎来到 ___”启动页。
- 素材插图、undraw 风格的 SVG、吉祥物。
- 为了“增强视觉趣味”而在每个标签上都点缀 Lucide 图标。
- 入场动画、页面转场、滚动触发动画。
- 除以下内容外的任何颜色:墨色阶、表面色阶、线条色阶,以及唯一的语义色阶(风险/sev/状态)。
- Tailwind 的
gray-*、slate-*、zinc-*、neutral-*类——改用自定义的ink和line令牌,以保持调色板一致。 - Tailwind 的
blue-500、indigo-600、purple-*、pink-*点缀色。 - “现代 SaaS”风格的字体选择:Inter、Geist、Plus Jakarta Sans、Manrope。只使用系统字体。
- 除非 Spencer 明确要求深色模式,否则不要使用
dark:变体。
使用现有代码仓库
如果 Spencer 正在编辑的代码仓库已经使用了不同的技术栈——比如使用 shadcn 的 Next.js 项目、Mantine 应用等——不要重写它来匹配这项规范。遵循代码仓库的约定。但仍要采用这项规范中的审美默认值:更小的字号、更紧凑的间距、中性色调、不使用无意义的渐变或阴影。引导现有组件朝这个方向调整,而不是绕开它们。
参考文件
如果需要了解本文件未涵盖的更多详情,请阅读:
references/full-tailwind-config.md— 包含 CSS 变量的完整tailwind.config.ts。references/full-index-css.md— 全局 CSS,包括.clean-app、.tracked-label、.scrollbar-thin、.select-ink。references/starter-app.md— 全新项目所需的最精简App.tsx、main.tsx、index.html。references/anti-patterns.md— 扩展版“不要这样做”指南,包含具体的错误与正确代码对照示例;在审查或重构现有 AI 生成的前端、使其符合 Spencer 的风格时很有用。