Talebook 界面打磨指南:Surfaces 表面设计原则的工程化落地
Talebook 界面打磨指南:Surfaces 表面设计原则的工程化落地
圆角、光学对齐、阴影与图片描边——这些"表面(Surfaces)"细节的微小偏差,往往是界面"看起来不对劲"的根源。本文以 talebook 仓库内置的 .agents/skills/better-ui/surfaces.md 设计技能文档为核心,结合仓库中 Vuetify 3 + Vue 3 前端(app/ 目录)的真实组件与内置主题系统的源码实现,逐条拆解同心圆角公式、光学对齐法则、用阴影替代边框的分层方案、图片 outline 的严格取色规则,并给出可直接复制的 CSS 与 Tailwind 代码。读完本文,你将掌握一套可量化、可验收的界面表面设计规范,并能在 talebook 的主题系统(app/utils/builtin-theme-overlay.ts)与各组件样式中对照落地。
Surfaces 在 talebook 技能体系中的定位
talebook 仓库的 .agents/skills/better-ui/ 目录是一套面向"界面精致感"的设计工程技能包,surfaces.md 是其中的一个专题模块。它的父级入口 SKILL.md 将 UI 打磨拆分为六个可独立执行的方向:
| 模块 | 覆盖内容 |
|---|---|
| surfaces.md | 圆角、光学对齐、阴影、图片描边 |
| animations.md | 可打断过渡、按压缩放、加载时跳过动画、动效克制 |
| enter-exit.md | 分阶段入场、错峰时序、退场过渡 |
| icon-transitions.md | 状态切换时图标的交叉淡化 |
| icons.md | 图标描边粗细、currentColor 状态、线框与实心 |
| performance.md | transition 精确性、will-change 的使用边界 |
SKILL.md 明确要求:在应用这些原则时保留项目既有的组件库、设计 token 与密度,匹配其既有的动效语言,除非某条原则规定了精确的交互模式。这与本文要讲的"表面"原则是一体两面——surfaces.md 给出的不是推翻重来的规范,而是与 talebook 的 Vuetify 组件和主题 token 系统协同工作的微调准则。
在 talebook 中,这些原则有真实的落点:app/utils/builtin-theme-overlay.ts 定义了每个内置主题的 radius、buttonRadius、fieldRadius、chipRadius、shadow、border 等表面 token;app/components/AudioJobsPage.vue 的有声书任务卡片、app/components/BookCards.vue 的书卡、app/components/AudiobookPlayer.vue 的播放控件,都直接体现了圆角递进、阴影分层与焦点描边的工程化处理。下文将逐一对照。
同心圆角(Concentric Border Radius)
核心公式与边界条件
surfaces.md 给出了嵌套圆角的第一原则:
outerRadius = innerRadius + padding
即:外层元素的圆角半径,必须等于内层元素圆角半径加上两者之间的 padding。例如外层卡片 border-radius: 20px、内边距 padding: 8px,内层卡片就应是 border-radius: 12px(20 − 8)。
这一公式的适用前提是"嵌套表面彼此紧贴"(nested surfaces are close together)。文档特别给出了两条边界规则:
- 当 padding 大于
24px时,不再强行套用同心数学,而是把内外层视为两个独立表面,各自独立选择圆角; - 当内外层共享一条可见且均匀的内缩边(even inset)时,按同心公式计算;
- 当内外层彼此独立、或 padding 是有意不对称时,保留组件既有的设计 token,不要为了公式而公式。
嵌套表面圆角不匹配是界面"看着别扭"最常见的来源,这也是 SKILL.md 的 Common Mistakes 表格中列出的第一条高频错误("Same border radius on closely nested parent and child")。
CSS 示例(原文档完整代码)
/* Good: concentric radii */
.card {
border-radius: 20px; /* 12 + 8 */
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
/* Bad: same radius on both */
.card {
border-radius: 12px;
padding: 8px;
}
.card-inner {
border-radius: 12px;
}
Tailwind 示例
// Good: outer radius accounts for padding
<div className="rounded-2xl p-2"> {/* 16px radius, 8px padding */}
<div className="rounded-lg"> {/* 8px radius = 16 - 8 ✓ */}
...
</div>
</div>
// Bad: same radius on both
<div className="rounded-xl p-2">
<div className="rounded-xl"> {/* same radius, looks off */}
...
</div>
</div>
在 talebook 组件中的对照:job-card 与 job-book
talebook 的 AudioJobsPage.vue 是有声书任务列表页,其表面结构恰好演示了公式与边界条件的两条路径。
外层任务卡片定义了 18px 的较大圆角(L863):
.job-card { position: relative; overflow: hidden; border: 1px solid rgba(var(--v-border-color), .13); border-radius: 18px; }
内层的书籍信息区 .job-book 使用 12px 圆角(L870):
.job-book { min-width: 0; display: flex; align-items: center; gap: 14px; ... border-radius: 12px; }
此时 job-body 的 grid 布局在两者之间留出了 gap: 26px 的间距(L869),超过文档设定的 24px 阈值,因此按"独立表面"处理、各自取圆角是符合规范的——这正是 surfaces.md 强调的"padding 足够大时不要把层级强行绑在一起"的场景。而在间距小于等于 24px 的紧凑嵌套中(例如计划指标面板 .plan-metrics 的 13px 与阶段卡片 .phase-list li 的 12px 紧邻场景,L888、L895),就应回到同心公式核算,避免出现"两个圆角贴在一起却互不咬合"的视觉张力。
光学对齐(Optical Alignment)
几何居中在视觉上经常"看起来偏了",此时要用光学对齐来纠正。surfaces.md 给出了三个高频场景。
图标按钮:图标侧减少 padding
当图标让原本对称的 padding 显得失衡时,在图标一侧使用略小的 padding,起手公式是:
icon-side padding = text-side padding - 2px
/* Good: less padding on icon side */
.button-with-icon {
padding-inline-start: 16px;
padding-inline-end: 14px; /* trailing icon side = text side - 2px */
}
/* Bad: equal padding looks like icon is pushed too far right */
.button-with-icon {
padding-inline: 16px;
}
// Tailwind
<button className="ps-4 pe-3.5 flex items-center gap-2">
<span>Continue</span>
<ArrowRightIcon />
</button>
talebook 前端大量使用 Vuetify 的 v-btn,其 prepend-icon / append-icon 插槽本质就是"文本 + 图标"组合。从源码结构看,Vuetify 组件的默认 padding 是两侧对称的,当图标视觉重量明显(如 mdi-arrow-right、mdi-chevron-down)时,就可按上述 -2px 规则在图标侧补充 padding-inline-end 微调,或在图标本身(SVG 路径)层面修正。
播放按钮三角
播放图标是三角形,其几何中心并不是视觉中心,需要整体轻微右移:
/* Good: optically centered */
.play-button svg {
transform: translateX(2px); /* physical correction to the glyph itself */
}
/* Bad: geometrically centered but looks off */
.play-button svg {
/* no adjustment */
}
talebook 的 AudiobookPlayer.vue 播放/暂停按钮使用 MDI 图标字体的 mdi-play / mdi-pause(L53-L54)。这类字形自身的三角形不对称性正是本原则的适用场景——若在播放按钮上观察到"视觉上偏左/偏右",优先用 translateX(2px) 之类的小量物理修正,而不是改组件 padding。
不对称图标(星形、箭头、Caret)
部分图标视觉重量分布不均,最佳修法是把修正做进 SVG 本身——调整 viewBox 或 path 让图标在视觉上居中,这样组件代码里就不需要任何额外的 margin/padding:
// Best: fix in the SVG itself
// Adjust the viewBox or path to visually center the icon
// Fallback: adjust with margin
<span className="translate-x-px">
<StarIcon />
</span>
从实现角度,这个"先修 SVG、再退回 margin"的顺序很重要:修 SVG 是一次性的,所有使用点自动受益;margin 修正则会在每个调用处重复散落、难以维护。
用阴影代替边框(Shadows Instead of Borders)
原则与适用范围
对于按钮、卡片、容器这类用边框来制造"深度/抬升感"的元素,应优先用微妙的 box-shadow 替代实心边框。理由有两个:
- 阴影基于透明度,可以适配任意背景色;实心边框的固定颜色在换了背景(图片、多色背景)后就失灵了;
- 阴影是"光学的",天然表达层级,实心边框只是"描线"。
但文档同时划出了明确的红线:不要把这个原则套用在分割线(border-b、border-t、侧边框)以及任何"用于布局分隔而非表达元素深度"的边框上——这些必须保留为 border。这与 SKILL.md 的 Core Principle 3 完全一致:"阴影表达层级,边框表达结构"(Shadows for Elevation, Borders for Structure)。
阴影分层的三段式配方
实现"边框即阴影"时,阴影由三个图层构成:第一层充当 1px 边框环(ring),第二层提供轻微抬升,第三层提供环境纵深:
:root {
--shadow-border:
0px 0px 0px 1px oklch(0 0 0 / 0.06),
0px 1px 2px -1px oklch(0 0 0 / 0.06),
0px 2px 4px 0px oklch(0 0 0 / 0.04);
--shadow-border-hover:
0px 0px 0px 1px oklch(0 0 0 / 0.08),
0px 1px 2px -1px oklch(0 0 0 / 0.08),
0px 2px 4px 0px oklch(0 0 0 / 0.06);
}
暗色模式:简化为单层白色环
暗色模式下,分层深度阴影在深色背景上看不出来,简化为单个白色环即可:
/* Dark mode: adapt to whatever setup the project uses
(prefers-color-scheme, class, data attribute, etc.) */
--shadow-border: 0 0 0 1px oklch(1 0 0 / 0.08);
--shadow-border-hover: 0 0 0 1px oklch(1 0 0 / 0.13);
配合 hover 过渡
把变量应用上去,并为 box-shadow 单独声明过渡,即可获得平滑的 hover 抬升:
.card {
box-shadow: var(--shadow-border);
transition-property: box-shadow;
transition-duration: 150ms;
transition-timing-function: ease-out;
}
.card:hover {
box-shadow: var(--shadow-border-hover);
}
这里只过渡 box-shadow 一个属性,对应 SKILL.md 的原则 11("只过渡发生变化的属性"),避免 transition: all 带来的无谓开销。
何时用阴影、何时用边框
| 用阴影 | 用边框 |
|---|---|
| 卡片、有深度的容器 | 列表项之间的分割线 |
| 带边框样式的按钮 | 表格单元格边界 |
| 抬升元素(下拉、弹窗) | 表单输入框轮廓(无障碍需要) |
| 多色背景上的元素 | 密集 UI 中的细发丝分隔线 |
| hover/focus 状态的抬升感 |
注意表格中"表单输入框轮廓"被明确列入保留 border 的阵营——输入框需要清晰的轮廓保证可访问性,不能为深度而牺牲可识别性。
在 talebook 中的落地:书卡阴影与状态内阴影
talebook 的书卡 BookCards.vue 正是"卡片深度用阴影表达"的实例——外层 v-card(.book-card-shell)没有靠边框描边,而是用柔和的弥散阴影制造卡片浮起感(L166-L170):
.book-card-shell {
height: 150px;
overflow: hidden;
box-shadow: 0 0 6px rgb(0 0 0 / 24%);
}
有声书任务页则展示了"状态/结构信息保留边框或特殊阴影"的另一种形态:AudioJobsPage.vue 中当前阶段卡片用内阴影 box-shadow: inset 3px 0 #d99a2b 做左侧状态条(L898),阶段图标用圆形底色区分完成/失败/进行中状态(L896)——这些都是"结构/状态信号"而非"深度",按文档原则应保留,而不是被模糊成通用阴影。
与此对应,焦点态是保留边框/描边的另一类合法场景。.job-plan-toggle:focus-visible 使用 outline: 3px solid rgba(217,154,43,.42); outline-offset: 4px(L878)——焦点环属于"状态边框",且 outline 不参与布局,是文档明确支持的用法。
图片轮廓(Image Outlines)
给图片加一层微妙的 1px 低透明度 outline,能让图片在周围元素普遍使用边框/阴影的设计系统里获得一致的深度感。
取色规则(不可妥协)
surfaces.md 对 outline 颜色给出了三条硬性规则:
- 浅色模式:纯黑,
oklch(0 0 0 / 0.1); - 暗色模式:纯白,
oklch(1 0 0 / 0.1); - 禁止使用项目调色板里的近黑/近白(如 slate-900、zinc-900、
#0a0a0a、#111827、#f5f5f7)——带色调的描边会吸附底层表面颜色,在图片边缘上"像脏污"; - 禁止把描边匹配到项目的主色或墨色——outline 是中性的分隔符,不是主题化元素。
这也是 SKILL.md 原则 8 的原话:浅色模式必须用纯黑 oklch(0 0 0 / 0.1),暗色模式必须用纯白 oklch(1 0 0 / 0.1),绝不用 slate/zinc 等任何带色调的中性色。
浅色模式
img {
outline: 1px solid oklch(0 0 0 / 0.1);
outline-offset: -1px; /* draw the ring just inside the image edge */
}
暗色模式
img {
outline: 1px solid oklch(1 0 0 / 0.1);
outline-offset: -1px;
}
Tailwind 暗色适配
<img
className="outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
src={src}
alt={alt}
/>
必须精确使用 outline-black/10 与 outline-white/10,不要用 outline-slate-*、outline-zinc-*、outline-neutral-* 或任何带色调的刻度。
为什么用 outline 而不是 border?
文档给出的理由是双重的:
outline永远不参与布局——任何 offset 下都不会增加元素宽高;outline-offset: -1px把环画在图片边缘内侧,让它贴合圆角而不是悬在圆角外侧。
对照 talebook:书卡封面使用 v-img + aspect-ratio: 11/15(BookCards.vue),有声书任务卡封面同样用 object-fit: cover 配合圆角与投影(AudioJobsPage.vue)。在这些场景给 <img> 补 outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10,即可在不扰动 v-img 布局计算的前提下获得统一描边——这正是 outline 相对 border 的核心优势。反过来,如果使用 border: 1px solid ...,会挤进元素盒模型,可能撑破既有的高度约束(如 .book-card-shell { height: 150px; overflow: hidden } 这类场景)。
与 talebook 主题 token 系统的协同
surfaces.md 的所有数值都不是孤立的——在 talebook 中,它们应当落到内置主题的 token 体系里。查看 builtin-theme-overlay.ts,BuiltinThemePalette 接口把表面相关的维度全部 token 化(L6-L32):
export interface BuiltinThemePalette {
...
border: string
shadow: string
radius: string
buttonRadius: string
outlinedBorder: string
fieldRadius: string
...
chipRadius: string
...
}
例如 light-gray 主题暗色模式定义 shadow: '0 12px 30px rgba(0,0,0,.28)'、radius: '8px'、buttonRadius: '7px'、fieldRadius: '7px'(L47-L48),并通过 builtin-theme-page-css.ts 生成真实的覆盖样式,如 .v-main .v-card { ... box-shadow: 0 12px 30px rgba(0,0,0,.28) !important; border-radius: 8px !important; }、.v-main .v-btn { border-radius: 7px !important; }(L6、L8)。
这带来两个实践要点:
- 圆角与阴影的"单一事实来源":浅层圆角(按钮 7px、输入框 7px)与外层容器(卡片 8px)之间只差 1px——它们嵌套时,按同心公式外减内应等于 padding;若你发现某处 padding 小于 1px 却有 2 层不同圆角贴在一起,就应当回到 token 层调整,而不是在单个组件里硬编码。
- 主题可以覆盖默认建议:
minimal主题刻意使用radius: '0'、shadow: 'none'、border-radius: 0 !important; box-shadow: none !important;(builtin-theme-page-css.ts)——这是"保留既有组件 token"原则的正面例子:当设计意图本身就是无圆角、无阴影的极简复古风时,不强行套用圆角公式,而是让 token 决定表面语言。这也是 SKILL.md 反复强调的"preserve the project's component library, tokens, and density"在 talebook 里的具体体现。
另外注意 builtin-theme-overlay.ts 中对浮层内容的统一处理:border: 1px solid ${palette.outlinedBorder} !important; border-radius: ${palette.radius} !important; box-shadow: none !important;——弹层内卡片弃用阴影、改用主题化的 outlinedBorder 实线边框。这与文档"阴影用于深度、边框用于结构"的分工一致:浮层内容需要的是清晰的结构轮廓,而不是深度叠加。
实战审查清单
将 surfaces.md 与 SKILL.md 的 Common Mistakes 表格合并,形成一份可直接用于 talebook 组件审查的检查清单:
| 症状 | 修法 |
|---|---|
| 紧密嵌套的父子元素圆角相同 | 用 outerRadius = innerRadius + padding 重算 |
| 图标看起来没居中 | 光学调整:图标侧 padding 减 2px,或直接修 SVG |
| 边框只是在伪造抬升感 | 改用带透明度的分层 box-shadow;分割线、状态/焦点边框保留 border |
| 图片边缘"脏污感" | 改用纯黑/纯白 oklch(0 0 0 / 0.1) / oklch(1 0 0 / 0.1),禁止 tinted 中性色 |
| 输入框轮廓被误删 | 保留 border(无障碍需要),不要替换为阴影 |
| 深浅色模式阴影不一致 | 暗色简化为单层白色环,浅色保留三层结构 |
| hover 变化突兀 | 只过渡 box-shadow,150ms + ease-out |
实际执行时还可以借助 SKILL.md 的方法论:在浏览器 Animations 面板把交互回放放慢到 10%,逐一走查 hover、focus、active、loading、empty 各状态——"慢速下觉得别扭的细节,正是全速下被忽略的错误"。talebook 前端是 Vuetify 3 体系(app/components/ 大量使用 v-btn、v-card、v-chip、v-field),审查时优先检查这些既有组件的默认 token 是否与 surfaces 原则冲突,其次才是组件内 <style scoped> 的覆盖样式(如 AnnotationPanel.vue 中 .annotation-panel 的 14px 圆角与 .annotation-card 的 9px 圆角这类嵌套关系,就适合用同心公式快速核算)。
综上,surfaces.md 提供的不是一套"更漂亮的审美偏好",而是一组可计算、可复用的工程规则:圆角有公式,图标有偏移量,阴影有分层配方,图片描边有严格取色约束。把这些规则与 talebook 的主题 token、Vuetify 组件体系结合,就能用最小的改动获得一致的表面质感。