mcpField
S
spencer-approved-frontends

spencer-approved-frontends

github.com/schneidenbach设计与 UI20100 次安装3 周前更新
代码仓库

关于 spencer-approved-frontends

面向 Spencer 项目的前端设计 skill,规定紧凑、中性的视觉风格及 React 与 TypeScript 约定,帮助编码 Agent 构建严肃的内部工具界面。

主要功能

  1. 01紧凑、中性的界面风格
  2. 02React 18 与 Vite 项目默认配置
  3. 03严格的 TypeScript 约定
  4. 04手写 UI 组件模式
  5. 05表单与界面状态指南

适用场景

  1. 01启动新的内部工具前端
  2. 02将 Spencer 的视觉风格用于界面
  3. 03不借助组件库构建表单
  4. 04处理加载、空白与错误状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/schneidenbach/skills --skill spencer-approved-frontends

安装后在 Claude Code 中输入/spencer-approved-frontends确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
references/
anti-patterns.md
full-index-css.md
full-tailwind-config.md
starter-app.md
textarea-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-3
  • px-2、px-2.5、px-3
  • py-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)。

加载、空状态和错误状态

三条规则:

  1. 加载应使用小型行内标记,而不是全屏旋转指示器。禁用表单,并在按钮上显示 Saving… 文本。对于列表,渲染与行高匹配的骨架行(1px 边框、相同内边距),而不是脉动的渐变色块。
  2. 空状态应使用一行 text-ink-muted 文本,或许再加一个次要按钮。不要使用插图、剪贴画或大型“开始使用”卡片。
  3. 错误状态使用 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 的风格时很有用。

评论

支持 Markdown