# 做界面：令牌、组件与截图自查

> 先定设计令牌再写组件，用假数据做出响应式、支持暗色模式的书单界面，让 Claude Code 自己截图检查，并避开千篇一律的 AI 味审美。

- 网址：https://coding.chengyu.dev/learn/ship/build-ui/
- 学习路径：实战：从想法到上线一个网站
- 预计用时：35 分钟；核实日期：2026-10-25
- 官方来源：[Theme variables（Tailwind CSS 官方文档）](https://tailwindcss.com/docs/theme)，[Dark mode（Tailwind CSS 官方文档）](https://tailwindcss.com/docs/dark-mode)，[Styling and CSS（Astro 官方文档）](https://docs.astro.build/en/guides/styling/)，[Use Claude Code with Chrome（Claude Code 官方文档）](https://code.claude.com/docs/en/chrome)，[Claude Code Desktop：Preview your app（Claude Code 官方文档）](https://code.claude.com/docs/en/desktop)，[Best practices for Claude Code（Anthropic 官方文档）](https://code.claude.com/docs/en/best-practices)

## 学习目标

- 能用 Tailwind 4 的 @theme 和 CSS 变量定义语义化设计令牌
- 能把界面拆成可复用的 Astro 组件，并用带边界情况的假数据驱动
- 能让智能体在浏览器里截图、对照清单自查并修复
- 能写出避免「AI 味」的设计约束，而不是只说「好看一点」

你对智能体说「把界面做好看一点」，它会交出一个看起来很熟悉的页面：居中的大标题、紫蓝渐变、三张圆角阴影卡片，加一个到处都是的 emoji。每一处都不算错，合在一起却像任何一个网站，唯独不像你的。

原因很直白。「好看」是一个没有边界的词，智能体只能取训练数据里的平均值。这一课解决两个问题：**怎样把审美变成智能体能执行的约束**，**怎样让它自己检查成果**，而不是靠你一遍遍说「再改改」。

## 先定令牌，再写组件

设计令牌（design token）是把颜色、字体、间距、圆角这些选择命名后集中放在一处。先定令牌再写组件，有两个好处：改风格只改一个地方；智能体写组件时有现成的词汇可用，不会随手写 `#6366f1`。

Tailwind 4 把令牌放在 CSS 里，用 `@theme` 声明。官方的规则是：变量名的前缀决定生成什么工具类，比如 `--color-*` 会生成 `bg-*`、`text-*`、`border-*`。需要让令牌引用其他变量时，用 `@theme inline`。

我们做**两层**：底层是随明暗模式变化的 CSS 变量，上层是 Tailwind 令牌，只引用底层。这样组件里写 `bg-paper text-ink` 就够了，几乎不用到处写 `dark:`。

```css
/* src/styles/global.css */
@import "tailwindcss";

/* 手动切换主题：由 html 上的 data-theme 属性驱动 */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

:root {
  --paper: oklch(0.97 0.012 85);        /* 页面底色：偏暖的纸色 */
  --paper-raised: oklch(0.995 0.004 85); /* 卡片底色 */
  --ink: oklch(0.25 0.02 60);            /* 正文 */
  --ink-soft: oklch(0.45 0.02 60);       /* 次要文字 */
  --line: oklch(0.88 0.015 80);          /* 分隔线 */
  --accent: oklch(0.5 0.13 35);          /* 唯一强调色：赭红 */
}

:root[data-theme="dark"] {
  --paper: oklch(0.2 0.012 60);
  --paper-raised: oklch(0.25 0.014 60);
  --ink: oklch(0.93 0.01 85);
  --ink-soft: oklch(0.72 0.015 80);
  --line: oklch(0.35 0.015 60);
  --accent: oklch(0.74 0.12 45);
}

@theme inline {
  --color-paper: var(--paper);
  --color-paper-raised: var(--paper-raised);
  --color-ink: var(--ink);
  --color-ink-soft: var(--ink-soft);
  --color-line: var(--line);
  --color-accent: var(--accent);

  --font-serif: "Source Han Serif SC", "Noto Serif SC", "Songti SC", serif;
  --font-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
  --radius-card: 0.375rem;
}

body { background: var(--paper); color: var(--ink); }
```

几点说明：

- **颜色用 `oklch`**。调明暗时只改第一个数（亮度），不会像十六进制那样「改亮度顺带改了色相」。
- **名字表达用途，不表达颜色**。叫 `ink` 而不是 `gray-900`，因为暗色模式下它变成浅色。
- **字体不引入网页字体**。中文字体文件很大，用系统字体栈，标题用衬线体，书的气质就有了。
- **强调色只留一个**。多一种强调色，界面就多一份混乱。

主题切换需要一小段脚本在页面最早处运行，否则深色用户会先看到一闪而过的白底。把它放进布局组件的 `head` 里（`is:inline` 让 Astro 不要打包它）：

```astro
<script is:inline>
  try {
    const saved = localStorage.getItem("theme");
    const dark = saved ? saved === "dark" : matchMedia("(prefers-color-scheme: dark)").matches;
    document.documentElement.dataset.theme = dark ? "dark" : "light";
  } catch {}
</script>
```

读取 `localStorage` 要包 `try/catch`：隐私模式或被禁用 Cookie 时它会抛错，页面不能因此崩掉。

## 用假数据驱动组件

现在还没有数据库，所以先写一份假数据 `src/data/sample-books.ts`。**假数据要刻意包含边界情况**，因为真实内容总会撞上它们：

- 一个超长书名（30 个字以上），测试换行；
- 一个英文书名加长作者名；
- 一本没有评分、没有摘要、没有标签的书；
- 一本标签有 6 个的书；
- 一条很长的短评。

界面的毛病几乎都出在这些地方，而不是「标准书」上。组件拆分建议如下：

| 组件 | 职责 | 关键 props |
|---|---|---|
| `BaseLayout.astro` | 页头、页脚、主题脚本、`<head>` | `title`、`description` |
| `BookCard.astro` | 书架上的一张卡片 | `book` |
| `Rating.astro` | 1–5 星展示（无评分时不渲染） | `value` |
| `TagList.astro` | 标签列表 | `tags` |
| `ThemeToggle.astro` | 明暗切换按钮 | 无 |

用令牌写出来的卡片大概是这样，注意没有一个具体颜色值：

```astro
---
interface Props {
  book: { id: number; title: string; author: string; rating: number | null;
          summary: string | null; tags: string[] };
}
const { book } = Astro.props;
---
<a
  href={`/book/${book.id}`}
  class="block min-w-0 rounded-card border border-line bg-paper-raised p-5
         transition-colors hover:border-accent
         focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
  <h3 class="font-serif text-lg leading-snug text-ink break-words">{book.title}</h3>
  <p class="mt-1 text-sm text-ink-soft">{book.author}</p>
  {book.summary && <p class="mt-3 line-clamp-3 text-sm text-ink">{book.summary}</p>}
</a>
```

`min-w-0` 和 `break-words` 是给超长书名预备的；`focus-visible` 样式保证键盘用户看得见焦点；`{book.summary && ...}` 处理摘要为空的情况。这几处都对应上面的边界假数据。

响应式用移动优先：书架网格写成 `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`，先保证 375px 宽的手机上舒服，再向大屏扩展。

> **中文输入法的回车陷阱**
>
> 管理页的「标签」输入框如果按回车添加标签，要判断输入法是否正在组词：`event.isComposing` 为真时，回车是在确认拼音候选，不是提交。漏掉这一步，中文用户打字打到一半就会被提前提交。把它写进你给智能体的需求，也写进测试。

## 避开 AI 味：把审美写成约束

「AI 味」不是玄学，它有可识别的特征：

| 常见特征 | 为什么显得廉价 | 换成什么 |
|---|---|---|
| 紫蓝渐变、渐变文字 | 默认配色，没有立场 | 一种底色加一种强调色 |
| 所有卡片都是大圆角加阴影 | 缺少层次，哪里都一样重 | 细边框，圆角收小，少用阴影 |
| emoji 当图标 | 各平台渲染不一致，显得随意 | 用文字，或统一风格的 SVG 图标 |
| 居中大标题加三张特性卡 | 营销站模板，书架不是营销页 | 直接展示书，信息密度高一点 |
| 到处是「✨」「🚀」式文案 | 一眼 AI | 平实的短句 |

怎么让智能体不犯这些？三招：

1. **给气质，不给形容词**。别说「简洁优雅」，说「像图书馆的索引卡和旧书扉页：纸色底、衬线标题、一条细线分隔、赭红色只用于链接和选中状态」。
2. **给禁止清单**。把上表右边的反面写进提示词。禁令比期望更好执行。
3. **让它先给方案再动手**。在计划模式下让智能体写 3 个视觉方向的文字描述，你选一个，再让它实现。比做完再推翻便宜得多。

## 让 AI 自己截图检查

官方最佳实践说得很明白：界面类任务，**给智能体一个能看见结果的办法**，它才能自己迭代，否则你就是那个「人肉截图工具」。有三种常见途径，选你手边有的：

- **Claude Code 桌面应用的浏览器面板**：智能体启动开发服务器，在面板里截图、检查 DOM、点击、填表。开发服务器的启动方式记在项目的 `.claude/launch.json`（详见官方 Preview 一节）。
- **Claude in Chrome 扩展**：用 `claude --chrome` 启动或在会话里运行 `/chrome`，智能体可以打开 `localhost` 页面操作、读取控制台报错。它共用你浏览器的登录状态，所以别让它去碰无关的网站。
- **其他浏览器类 MCP 服务器**：能力类似，装法见 [MCP：连接外部工具](/learn/claude-code/mcp/)。

不管用哪种，检查流程是固定的：

```text
> 启动 dev 服务器，用 375x812 和 1280x800 两种宽度，各截浅色和深色共 4 张图
⏺ npm run dev  →  http://localhost:4321
⏺ 截图 4 张。对照检查清单：
  ✗ 375px：书名「《把时间当作朋友：运用心智获取更多自由与效率》」撑出卡片，出现横向滚动
  ✗ 深色：次要文字 ink-soft 对比度偏低
  ✓ 键盘 Tab 可见焦点环
⏺ 已修复前两项，重新截图确认。
  ✓ 4 张图均无横向滚动，正文对比度达标
```

把检查清单也写出来，比让它「看看有什么问题」可靠：无横向滚动、正文对比度不低于 4.5:1（WCAG AA 的常用基准）、所有可点击元素有焦点样式、空状态有提示而不是白板、超长文本不撑破布局。

**用令牌与假数据做出书单界面，并自行截图验收**

```text
读取 SPEC.md 和 CLAUDE.md，完成书单项目的界面，本阶段不接后端，所有数据来自 src/data/sample-books.ts。

设计约束：
- 气质：图书馆索引卡和旧书扉页。纸色底、标题用衬线体、细线分隔；只有一种强调色（赭红），仅用于链接、选中和焦点。
- 禁止：渐变（含渐变文字）、emoji 图标、大面积阴影、居中大标题加三张特性卡的营销页布局、「✨」式文案。
- 在 src/styles/global.css 里按语义命名定义令牌（paper、ink、line、accent 等），明暗两套，用 `@theme inline` 引用；组件里不得写具体色值，也尽量不写 `dark:`。
- 字体不引入网页字体，用系统字体栈。

任务：
1. 先建 src/data/sample-books.ts，至少 8 本书，必须包含：超长中文书名、英文书名加长作者名、无评分无摘要无标签的书、6 个标签的书、很长的短评；
2. 组件：BaseLayout、BookCard、Rating、TagList、ThemeToggle；页面：`/`（书架，按标签筛选可以先用查询参数）、`/book/[id]`（详情）、`/admin`（表单，先不提交）；
3. 主题脚本放在 head 最前，localStorage 访问必须 try/catch；
4. 标签输入框回车添加时要判断 isComposing；
5. 完成后启动开发服务器，用浏览器工具在 375x812 与 1280x800 下各截浅色、深色共 4 张图，按这份清单逐项检查：无横向滚动、正文对比度不低于 4.5:1、可点击元素有可见焦点、空状态有提示、超长文本不破坏布局。发现问题就修，修完重新截图，直到全部通过。

最后用表格汇报每项检查的结果，并附上你看过的截图对应的页面和宽度。
```

> 小测：为什么推荐先定义语义令牌（如 paper、ink），而不是在组件里直接写具体颜色或 gray-900 这样的名字？

> 小测：让智能体自查界面时，下面哪种说法最可靠？

## 动手任务

- [ ] 在 `global.css` 里定义好令牌，确认组件中没有写死的色值（让智能体用 grep 查一遍）
- [ ] 准备至少 8 条带边界情况的假数据，包括超长书名和空字段
- [ ] 用截图工具完成 4 张图自查，保留你亲自看过的至少 1 张
- [ ] 手动在浏览器里按 Tab 走一遍，确认焦点可见；切换明暗并刷新，确认没有闪白
- [ ] 在管理页标签输入框里用中文输入法打一个词再回车，确认选词时不会误提交
- [ ] 合并前看一眼 `git diff --stat`，提交这一课的成果

下一课：[加后端](/learn/ship/backend/)。界面有了，我们给它接上 D1 数据库和 Workers AI。
