Talebook 界面打磨指南:Surfaces 表面设计原则的工程化落地

原创2026-10-03 19:11:201,454 阅读
文章标签:后端前端CMS

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 替代实心边框。理由有两个:

  1. 阴影基于透明度,可以适配任意背景色;实心边框的固定颜色在换了背景(图片、多色背景)后就失灵了;
  2. 阴影是"光学的",天然表达层级,实心边框只是"描线"。

但文档同时划出了明确的红线:不要把这个原则套用在分割线(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?

文档给出的理由是双重的:

  1. outline 永远不参与布局——任何 offset 下都不会增加元素宽高;
  2. 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)。

这带来两个实践要点:

  1. 圆角与阴影的"单一事实来源":浅层圆角(按钮 7px、输入框 7px)与外层容器(卡片 8px)之间只差 1px——它们嵌套时,按同心公式外减内应等于 padding;若你发现某处 padding 小于 1px 却有 2 层不同圆角贴在一起,就应当回到 token 层调整,而不是在单个组件里硬编码。
  2. 主题可以覆盖默认建议: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 组件体系结合,就能用最小的改动获得一致的表面质感。

登录后查看全文
talebook