前端样式规范
概述:raricy.com(聪明山)的整套前端样式体系。来源是
src/styles-scss/下的 SCSS,由 src/app/layout.tsx 引入入口文件、交给 Next.js 编译。全站不依赖 Bootstrap——所有 Bootstrap 风格工具类都有本地 fallback 实现。
1. 架构与构建
样式全部用 SCSS 编写,按 7-1 风格目录组织,入口是 src/styles-scss/main.scss:
src/styles-scss/
├── abstracts/ 变量、断点、mixin、函数、主题 map
├── base/ reset、root(CSS 变量)、排版、表单、Bootstrap fallback
├── components/ 按钮、导航、图标、弹窗、卡片、告警、表单控件、toast、分页
├── layout/ 容器、栅格、顶栏、页脚、后台侧边栏
├── pages/ 各页面样式(首页、博客、讨论、通知、管理后台…)
├── utilities/ 间距、显示、flex、文本工具类
└── main.scss 入口,控制 import 顺序
构建:src/app/layout.tsx 直接 import '@/styles-scss/main.scss',
由 Next 自己编译(sassOptions 见 next.config.mjs):
npm run dev— 改任意 SCSS 立即生效。HMR 带 source map,DevTools 里直接看到_*.scss的行号npm run build—next build编译进.next/static/css/
没有手工编译步骤,也没有入库的编译产物(src/styles-scss/compiled/ 已进 .gitignore)。
改样式只改 SCSS 源文件,不需要提交任何产物。
⚠️ 别把入口改名成
main.module.scss:.module.后缀会让 Next 按 CSS Modules 处理、把全站类名哈希化,样式整体失效。
npm run css:probe 仍在,但只服务离线调试:产物是 src/styles-scss/compiled/probe.css,
tests/.tmp/ 下那几个手工像素探针 HTML 用 <link> 直接引它。要跑探针先 npm run css:probe;
日常开发与部署都用不到它。
名字里刻意不带 build: —— 它不在构建链上,别让名字把人骗了。
四条守卫(css-classes / css-js-classes / css-tsx-classes / check:links §4)读的
CSS 由 scripts/compiled-css.mjs 现编入口 SCSS —— 不读任何
落盘产物,也不能改成扫 SCSS 源(&--has 这类嵌套在源里没有展开后的字面量,
扫源会假阳性)。
其中 css-tsx-classes(tests/unit/css-tsx-classes.test.ts)是无样式类名的权威名单:
.tsx/.ts 里写了、CSS 里没有的类名,要么补样式,要么在那份 UNSTYLED / CONSUMED
里登记一行并写清理由(.clipboard-markdown-content 这类包装就在那儿)。下面 §12
再提到「某个类刻意没有样式」时,以那份名单为准,别在这里另抄一份。
行尾:.css / .scss 一律 LF,由根目录 .gitattributes 声明。
2. 设计令牌(CSS 变量)
主题令牌集中在 src/styles-scss/base/_root.scss,通过 <html data-theme="light|dark"> 切换。所有组件样式必须引用这些变量,禁止在组件里写死主题色(有意的硬编码除外)。
2.1 主色板(浅色 data-theme="light")
| 变量 | 值 | 用途 |
|---|---|---|
--color-brand-primary |
#2563EB |
品牌主色:链接、主按钮、激活态 |
--color-brand-secondary |
rgba(37,99,235,0.1) |
品牌浅底:胶囊底、hover 背景 |
--color-background-page |
#F8FAFC |
页面背景 |
--color-background-card |
#fff |
卡片 / 顶栏 / 底栏背景 |
--color-background-content |
#eff2f5 |
输入框、代码块、内容底色 |
--color-background-card-unread |
#fffdf0 |
通知未读卡片底 |
--color-background-subtle |
#f3f4f6 |
弱背景(hover、徽章) |
--color-background-highlight |
#eef2ff |
高亮背景 |
--color-border |
#E2E8F0 |
常规边框(只剩分隔线、chip、按钮与共存边框在用它;容器轮廓见 §6.2) |
--color-border-unread |
#f1c40f |
未读高亮边框 |
--color-border-highlight |
#007bff |
当前无消费者:它原先是「卡片 hover 边框」,而卡片已不许描边(§6.2)、hover 一律走 --shadow-card-brand。留着是为了不擅动色板,别再拿它给卡片画 hover 边框 |
--color-text-primary |
#0f172a |
主文本 |
--color-text-secondary |
#64748B |
次要文本、meta |
--color-text-tertiary |
#dde4ee |
禁用态底 / 分割线 |
--color-warning-primary |
#ea3b3b |
危险色(点赞、删除) |
--color-warning-secondary |
rgba(255,47,47,0.1) |
危险浅底 |
--color-success-primary |
#10b981 |
成功色(投喂、签到) |
--color-success-secondary |
rgba(16,185,129,0.1) |
成功浅底 |
--color-info-secondary |
rgba(59,130,246,0.08) |
信息浅底 |
--color-star-primary |
#f1c40f |
收藏夹:黄色五角星(唯一的黄色语义色;--color-warning-* 其实是红)。只在「已收藏」与 hover 时用,未收藏不亮 —— 见 §6.7 |
--color-star-secondary |
rgba(241,196,15,0.12) |
收藏夹按钮的选中态浅底 |
--color-accent-blue / -soft |
#3b82f6 |
分类强调色:把同页几项彼此分开的色相,非语义色(见 §2.6) |
--color-accent-amber / -soft |
#f59e0b |
同上 |
--color-accent-cyan / -soft |
#06b6d4 |
同上 |
--color-accent-violet / -soft |
#8b5cf6 |
同上 |
⚠️
--color-text-tertiary不是「第三级文字」:它的值(浅色#dde4ee、暗色#2b3036)是背景/分割线档,拿来写color几乎是隐形的。弱化文字一律用--color-text-secondary(--fd-ink-3映射的也是它,可作旁证)。 名字里带text-所以极易误用 —— 补样式时从旧文件搬--ink-3要落在--color-text-secondary上(旧--ink-3是#86868B那样的可读灰)。
2.2 主色板(深色 data-theme="dark")
| 变量 | 值 |
|---|---|
--color-brand-primary |
#23A5FF(更亮的蓝,保证对比度) |
--color-brand-secondary |
rgba(35,165,255,0.1) |
--color-background-page |
#131517 |
--color-background-card |
#181A1D |
--color-background-content |
#21252A |
--color-background-card-unread |
#08102D |
--color-background-subtle |
#1e2024 |
--color-background-highlight |
#1a2040 |
--color-border |
#334155 |
--color-border-unread |
#033ae3 |
--color-border-highlight |
#23A5FF |
--color-text-primary |
#f1f5f9 |
--color-text-secondary |
#94A3B8 |
--color-text-tertiary |
#2b3036 |
--color-warning-primary |
#FF3535 |
--color-warning-secondary |
rgba(255,53,53,0.1) |
--color-success-primary |
#10b981 |
--color-success-secondary |
rgba(16,185,129,0.12) |
--color-info-secondary |
rgba(59,130,246,0.12) |
--color-star-primary |
#ffd93d(比浅色侧提亮一档,否则压在深底上发闷) |
--color-star-secondary |
rgba(255,217,61,0.14) |
2.3 阴影
2026-09 收敛:此前有六条阴影令牌,其中四条(
--shadow-xs/-sm/-card/-card-hover)的展开值全是rgba(0,0,0,…),被用在卡片常态与 hover 上。现全站 只留两条,都是品牌色;四条中性阴影整体退役(base/_root.scss里已删除定义, 别再引入 —— 需要分层就用 2.1 的背景色阶)。
| 变量 | 浅色 | 深色 | 用途 |
|---|---|---|---|
--shadow-card-brand |
0 2px 25px rgba(37,99,235,.15) |
0 2px 20px rgba(35,165,255,.1) |
「抬起」:整块可点的卡片 hover、浮层(弹窗 / toast / 下拉 / 贴边面板) |
--shadow-focus-brand |
0 0 25px rgba(37,99,235,.28) |
0 0 20px rgba(35,165,255,.32) |
「聚焦」:字段与输入面板 |
三条硬规则:
- 卡片常态不托阴影、也不描边(§6.2)。空闲就给阴影会让一整页卡片各自「浮」起来 把版面切碎 —— 改成「指到哪儿哪儿才浮起来」,让位给背景色阶。
- 只有「整块可点」的卡片 hover 才亮
--shadow-card-brand。判据是那个元素本身是<a>/<Link>/<button>(含.blog-item-cover那种铺满整卡的拉伸链接)—— 卡里只有某颗按钮可点的,整卡不许有 hover 阴影。不可点的卡若带着 hover 阴影, 那多半是照抄邻居抄错的,删掉而不是换成蓝的。 - 不留任何黑色阴影。写死的
rgba(0,0,0,…)只允许作background(遮罩 scrim), 作box-shadow一律不允许 —— 浮层需要「抬起来」就用--shadow-card-brand。
--shadow-focus-brand 是聚焦态专用的光晕:与 --shadow-card-brand 同色同模糊半径,
但偏移为 0(四周均匀),不透明度高一档 —— 「抬起」是托住元素,聚焦态得自己站得出来。
不要拿它当常驻阴影或 hover 用。--shadow-card-brand 反之也别拿去当聚焦环。
它是全站字段唯一的聚焦反馈(见 §4.2):单行输入框、textarea、<select>、
金额外壳(focus-within)、评论/讨论输入面板(focus-within)都用它。
聚焦一律只加光晕、不换底色,也绝不用 border 表达 —— 边框会改变元素高度,
失焦瞬间会让下方内容位移(注册页 e2e 的勾选失败踩过这个坑,见 §6.3)。
2.4 SCSS 侧令牌
src/styles-scss/abstracts/_variables.scss:
- 圆角:
$radius-max: 999px(胶囊)、$radius-card: 30px(卡片)、$radius-field: 20px(多行字段)、$radius-large: 10px、$radius-small: 5px—— 取哪个由内容形态决定,判据见 §4 - 间距:
$space-1: 4px…$space-8: 32px(4px 递进) - 容器:
$container-max: 1140px
abstracts/_theme-map.scss里只有一个 light 主题的 map,配套的themeify/themedmixin 已被注释停用。实际主题切换完全靠 CSS 变量 +[data-theme],不要用旧的 theme-map 方案。
2.5 Fluent(fd-)别名令牌
OAuth 授权 / 图片 / 小鱼干等较新页面与工具类用 fd- 前缀令牌(沿用新页面注释里的 "Fluent Design" 命名),定义在 base/_root.scss,全部以 var() 别名指向 2.1 / 2.2 的 --color-* 体系,随 [data-theme] 自动切换;深色主题只覆写个别值(如 --fd-accent-soft)。新增页面可复用,不必自己再造一套:
- 文字:
--fd-ink/--fd-ink-2/--fd-ink-3(主 / 次 / 弱文本) - 品牌:
--fd-accent(=--color-brand-primary)、--fd-accent-tint(浅色高亮底)、--fd-accent-soft - 状态:
--fd-danger/--fd-danger-soft、--fd-success/--fd-success-soft - 字号:
--fd-text-base(0.95rem)/--fd-text-sm(0.85rem)/--fd-text-xs(0.75rem) - 间距:
--fd-space-1(4px)…--fd-space-8(32px),4px 递进 - 工具类:
utilities/_text.scss的.u-text-sm等引用 fd 字号令牌
2.6 分类强调色(--color-accent-*)
「语义色」与「分类色」是两回事,分开是因为混用会让人误读:
- 语义色(
--color-warning-*红 = 危险/删除、--color-success-*绿 = 成功、--color-brand-*蓝 = 主行动)表达状态 —— 看到红就知道出事了。 - 分类色(
--color-accent-*)只表达「这是第 N 项,和第 M 项不是一回事」。
用到分类色的地方:首页四张功能卡的图标、管理概览的统计卡、 精选与分类标记、审计状态。这些场景里的红不是「危险」、绿不是「成功」, 所以不能拿语义色去顶 —— 那会让「总用户数」那张卡看起来像报错。
四对令牌,与其它色系同构(-soft 作淡底),深色主题各提亮一档:
--color-accent-blue / -amber / -cyan / -violet。
⚠️ 黄色的
--color-star-*不属于这一组:它是「收藏夹五角星」的专用语义色, 用途边界明确(见 §6.7)。需要第 5 个色相时用--color-accent-amber。
3. 排版
- 英文正文使用 Kumbh Sans,顶栏品牌的英文 Raricy 使用 Outfit,代码块、行内
代码、等宽字段与 Markdown 编辑器使用 JetBrains Mono。字体文件随站点提供于
public/static/fonts/,不依赖访客安装字体或第三方字体服务。三款都按 SIL Open Font License 1.1 再分发,来源与版本见public/static/fonts/README.md,各自目录保留OFL.txt。 base/_fonts.scss定义@font-face,使用 WOFF2 与font-display: swap: Kumbh Sans 与 Outfit 都是 100–900 字重的可变字体,前者带 Latin / Latin Extended 两个子集,后者只带 Latin; JetBrains Mono 带 400 / 700 的正体与斜体,按需加载。中文继续走系统回退字体, MathJax 仍使用自己的数学字体。- 字体栈集中在
base/_root.scss的--font-body/--font-brand/--font-mono/--font-numeric,与明暗主题无关。 正文回退栈仍为"Segoe UI", "Helvetica Neue", Arial, sans-serif;新增页面引用这些 令牌,交互控件继承字体,不再另写一份系统字体栈。 body:-webkit-font-smoothing: antialiased,主色var(--color-text-primary)。- 链接默认无下划线(
reset+typography里a { text-decoration: none }),hover 也不加,靠变色/加粗区分。 - 标题字重以
600 / 700为主;页级大标题常见font-size: 2rem–3.5rem+font-weight: 700。 - 正文阅读内容(博客正文)
font-size: 1.15rem; line-height: 2;,段落text-align: justify。 - 全局
transition: color 0.3s ease, background-color 0.3s ease(base/_reset.scss), 保证主题切换平滑。⚠️ 这条是全局*规则且只有这一处定义:别为了给某个元素加 过渡而扩成transform—— 那会让全站的 transform 都带上动画,chat-image.spec.ts读matrix(1.5,…)那类断言会变成时序相关。给单个组件写自己的transition即可。
动态数字
界面中的数据数字统一使用 Raricy Numeric(从 JetBrains Mono 提取的数字子集,
0 使用大写 O 的字形)。英文标签、单位与中文字体保持原有回退
方式;用户名、标题、文件名和自由正文中的数字仍属于文字内容。
| 位置 | 数字字段 |
|---|---|
| 博客、评论、个人主页 | 点赞、评论、投喂、文章数、发表与更新时间 |
| 签到 | 累计天数、奖励、日期、排行名次 |
| 鱼干、收银台、市场 | 余额、金额、流水、租用期限、备注字数 |
| 练手盘 | 行情价格、比例、杠杆、盈亏、费率、图表刻度和统计 |
| 投票、收藏夹 | 票数、选项数、比例、条目数,含正文嵌入卡片 |
| 讨论、通知 | 通知角标、未读数、新消息数、人数、日期与时间 |
| 图床、音频床、工具 | 文件大小、配额、进度、字数、任务数、放大比例 |
| 后台、审计、设置 | 统计、记录数、时间范围、剩余时长、到期时间 |
| 共用控件 | 页码、数值输入、range 数值输出、资源面板与上传批次 |
新增字段给数值所在元素加 .numeric;同一个元素混有用户名、标题等自由文字时,
只包住数值,例如 <span className="numeric">{count}</span>。不要把类挂到整页或正文
容器,也不要在运行时扫描正文、按数字正则改写 DOM。
base/_fonts.scss 的 Raricy Numeric 只覆盖 0123456789#$%+,-./:׉−,
由 scripts/make-numeric-fonts.py 从仓库内的 JetBrains Mono 2.304 提取,保留原有数字
与符号的轮廓、提示信息和等宽度量,仅将字符 0 的字形映射到大写 O。
实际文本仍是数字 0,复制、输入与计算都不变。四份 WOFF2 分别覆盖 400 / 700 正体与
斜体,随站点入库,普通构建不需要 Python;再生成与验证方法见
public/static/fonts/README.md。派生字体使用独立名称并保留 OFL 授权。
--font-numeric 在它之后接 --font-body,因此 123.4 MB 的数值等宽、MB 仍用
Kumbh Sans;代码块与编辑器继续使用完整的 JetBrains Mono。
utilities/_numeric.scss 在入口末尾加载:.numeric、time、数值 / 日期时间输入框与
.fc-range output 共用字体与 tabular-nums。正文投票、收藏夹计数,以及编辑器的资源
副标题和上传进度采用同一规则,覆盖 React 与脚本生成的两种显示方式。
4. 圆角与形状语言
「胶囊 + 大圆角」是视觉基调:
选哪个圆角由内容形态决定,不是按页面随手挑:
| 形态 | 圆角 | 落点 |
|---|---|---|
| 单行元素 | 999px 胶囊 |
按钮、单行输入框、徽章、导航项、切页、页码 |
| 卡片 | 30px($radius-card) |
博客条目、通知卡、登录/注册容器、博客正文容器、弹窗内容 |
| 多行字段 | 20px($radius-field) |
textarea、多行输入面板 |
| 次级面板 / 小组件 | 10px / 6–8px |
分页外壳、代码块、内联标记 |
| 图标徽章 | 圆形 50% 或胶囊 999px |
红点、角标 |
| 头像 | 固定 8% |
见 §4.1,不用圆形头像 |
输入框的圆角判据是硬规则,见 §4.2。
4.1 头像圆角规范(全站统一 8%)
2026-09 统一:此前各页面头像圆角漂移在
4px–10px/5%–25%之间,观感不一;现全站收敛为同一比例。
头像一律 border-radius: 8%(≈ 尺寸 × 1/12.5),用百分比而非固定 px——随头像尺寸自动缩放,不同尺寸下圆角观感一致:
| 头像尺寸 | 20px | 24px | 28px | 32px | 34px | 120px |
|---|---|---|---|---|---|---|
| 实际圆角 | 1.6px | 1.9px | 2.2px | 2.6px | 2.7px | 9.6px |
要点:
- 不用圆形头像(历史上曾有
border-radius: 50%的写法,按「方形小圆角」取向移除——残留注释见pages/blog/_menu.scss)。 - 内联样式同样写
border-radius: '8%',不要写死 px(例:FeedButton 弹窗名单)。 - 全站头像走共用组件
<Avatar>(src/app/components/Avatar.tsx+components/_avatar.scss, 2026-09 收敛)。8%这条现在由组件持有,各站点类里残留的 8% 是冗余,别顺手删 ——组件一旦被绕过,那些点会静默退回方角。尺寸仍由各站点原来的类决定,.avatar盒子不设 width/height(设了会把imgClassName那几处撑变形)。 头像框贴图是盒子里的绝对定位<img class="avatar__frame">,随盒子尺寸自适应, 所以 20px 与 120px 是同一个写法。 - 现有落点清单(改样式或加新头像时对照,勿再漂移):
| 位置 | 选择器 / 出处 | 尺寸 |
|---|---|---|
| 顶栏用户头像 | .site-user-avatar(layout/_header.scss) |
32px |
| 个人主页大图 | .profile-hero__avatar(pages/_profile.scss) |
120px |
| 讨论 · 频道列表 / 折叠图标 | .chat-chan__avatar / .chat-chan__icon(pages/_chat.scss) |
34px |
| 讨论 · 会话标题栏 | .chat-main__peer-avatar(pages/_chat.scss) |
32px |
| 讨论 · 消息作者 | .chat-msg__avatar(pages/_chat.scss) |
34px |
| 新会话弹窗列表 | .chat-new-item__avatar(pages/_chat.scss) |
32px |
| 签到排行榜(含占位) | .checkin-leaderboard__avatar / -placeholder(pages/_checkin.scss) |
32px |
| 后台用户卡片 | .user-card__avatar(pages/admin/_users.scss) |
28px |
| 博客列表 / 详情作者 | .blog-author img(pages/blog/_menu.scss,博客列表与详情页共用) |
20px |
| 博客评论作者 | .comment-author-avatar(pages/blog/_blog.scss) |
24px |
| Feed 弹窗名单(点赞 / 动态) | FeedButton.tsx 内联 style(两处) | 32px |
| 讨论 / 评论正文里的用户名片 | .rich-user-ref__avatar(components/_markdown-body.scss) |
1.4em(随正文字号,两条管线基准字号不同) |
名片那一行是全站唯一手搭 DOM 的头像落点(正文管线是字符串进字符串出,塞不进
<Avatar>,见tests/unit/avatar-sites-guard.test.ts的 DOM 档台账)。它也就享受不到 组件那层保护 —— 圆角仍由.avatar盒子的 8% 给,但通用.类 img规则会漏进来, 见 §11.1 的最后一条。
4.2 输入框的圆角判据(硬规则)
2026-09 统一:此前全站输入框的圆角漂在
15px / 10px / 8px / 6px / 5px / 4px / 0.375rem之间,一半带描边一半不带,底色在「卡片白 / 页面灰 / 内容灰」之间随机, 聚焦反馈有四种写法(描边变色 / 硬环 / 写死的 Bootstrap 蓝 / 什么都没有)。
当且仅当「内部元素仅为一行文字」时,圆角恰好是胶囊形:
| 元素 | 圆角 |
|---|---|
<input>、<select>、单行搜索框、金额外壳 |
999px |
<textarea>、多行输入面板、脚本/正文编辑器 |
20px($radius-field) |
配套的三条:
- 外观统一:无边框 +
--color-background-page底 + 聚焦--shadow-focus-brand::placeholder用--color-text-secondary。基准是博客首页的搜索框。
- 内嵌按钮:输入框内部若嵌了按钮,按钮圆角与输入框一致,
top/right/bottom: 4px贴边(.search-field+.search-btn),两条弧线贴合。只有这种搜索框需要 右侧留白,故padding-right: 80px挂在.search-field .search-input上, 不挂在共用类上 —— 否则没按钮的那几处会凭空多出 80px 空隙。 - 不要另抄一份:
.search-input是跨页共用类(博客 / 剪贴板 / 工具 / 投票),.form-control是表单通用类。改它们就是改全站,别在页面样式里重声明。
⚠️ 聚焦态永远不要用
border表达:常态是border: none,聚焦改成边框会让字段 随焦点撑高、失焦瞬间塌缩,整张表单下方内容位移,点按中的 checkbox/按钮会被「挪走」。 用box-shadow(不参与布局)。
5. 布局
容器:
.container是正文的唯一容器,左右 16px padding、margin: auto, 宽度走统一阶梯(abstracts/_mixins.scss的site-width-ladder): ≥992px → 960px,≥1200px → 1140px。顶栏.site-container、页脚容器与.content-wrapper(历史名,9 个页面在用)引用同一个 mixin,四者左边缘对齐。⚠️ 加宽度前先看这条:
.container曾经只在 ≥1200px 才生效,992–1199px 区间 正文是全幅的而顶栏已收到 960px —— 宽屏笔记本上正文比顶栏宽出一大截, 是「各页边距看起来不一样」的主因。加任何新容器都走这个 mixin,别再写一套@media (min-width: 1200px) { max-width: 1140px }(仓库里曾有四份拷贝)。抬头带(hero)也要吃檐沟:整幅铺满视口的抬头带里,内容必须再套一层
.container(.blogs-hero/.story-hero就是这么分层的)—— 别把宽度阶梯加到 色带自己身上,那会连色带一起收窄,页面顶部凭空短一截。 底色透明的抬头带是例外:.read-hero没有底色,直接吃container-padding, 并与.blog-detail共用 940px 阅读行宽,标题与正文左对齐。/admin那一档的檐沟是 20px(=.admin-container的值),抬头与它下面的内容列 左边缘对齐。签到页.checkin-page只有檐沟、没有列宽上限(卡片仍按视口铺开), 那是现有排版,别顺手「统一」成居中列。⚠️ 漏了这一层不报错、不警告,构建与单测都拦不住 —— 窄屏下标题与简介就那样 贴着屏幕两条边。
tests/e2e/page-gutter.spec.ts按真视口量左边缘,盯着故事区 (含阅读页与互动小说页)/ 后台 / 文章编辑器 / 签到页各处的抬头与首屏内容。 它断言的是定值(16,/admin20)而不是「≥16」—— 20px 这种多缩 4px 的 漂移同样是它要收的东西,≥会放过去。正文行宽上限 ≠ 页边距:博客详情(
.blog-detail940px)与正文卡 (.blog-content-container900px)在容器之内再收一道,那是阅读行宽,别跟着 容器一起拉宽 —— 拉到 1108 会让最长的文本页读起来更累,也会比博客首页自己的 列表列还宽。页边距归.container,行宽归这些内层上限。 故事阅读页 / 互动小说页同理:.story-reader820px、.story-cattca720px 是行宽, 檐沟各自@include m.container-padding(它们不是.container元素,别把 宽度阶梯也加上去 —— 行宽上限已经比阶梯窄了)。文章页眉(
.read-hero):透明底色、左对齐标题、作者与时间一组,底部淡分隔线 接正文。标题采用响应字号与自然折行;作者头像 40px,头像与名字共同链接到个人 主页。作者链接和时间详情入口至少 44px 高,键盘聚焦有可见轮廓。时间详情用原生<details>,展开面板沿用主题卡片色与--shadow-card-brand,窄屏限定在 16px 檐沟内。文章目录时间(站内 / 公开列表与个人主页):普通字重与次要文字色,仅显示一枚 短日期,今年省略年份,发布 / 更新说明与完整时间收进提示。日期随站内列表当前排序 选择,避免在卡片底部同时陈列两种时间与编辑标记。
顶栏:
.site-navbar固定顶部(position: fixed; top:0),高 62px,背景--color-background-card,阴影--shadow-card-brand。body有padding-top: 62px补偿。页脚:
.site-footer,背景卡片色 + 顶部分隔,内容容器同 1140px 体系。后台:
.admin-layout左侧 220px 固定侧边栏(移动端折叠成横向标签条)+ 右侧滚动内容区,内容容器最大 1400px。栅格:首页用 flex +
gap或 CSS Grid(repeat(auto-fit, minmax(...))/ 显式repeat(3,1fr)),不用浮点栅格。另有layout/_grid.scss与base/_forms.scss提供 Bootstrap 风格行/列工具。页面骨架:
body { display:flex; flex-direction:column; height:100dvh; min-height:100dvh }+main { flex:1 0 auto },页脚始终贴底。⚠️ 量视口高度一律写
dvh,不要只写vh(保住老浏览器就先写一行vh、 下一行再写dvh)。手机上vh量的是地址栏收起时的大视口,比眼前看得见的高度 多出约一条地址栏;文档因此比可见区高 → 页面平白能上下滑,而一滑地址栏就收起、 可见区随即变高,文档下沿之外露出通栏一条底色。 讨论区是唯一「整屏工作台」(body与.chat-page必须严丝合缝,见 §6.5), 它对这个最敏感;其余页面只是白多出几十像素的滚动余量。
6. 组件风格要点
6.1 按钮(components/_button.scss)
2026-09 统一:此前同一套「品牌浅底 → hover 实底白字 → 按下压暗」的配方被抄了 至少五遍(
.upload-button、.search-btn、.button-primary(-small/-warning)、.blog-sort-btn、十来个页面私有主按钮),改一处漏三处。现在只有三个 mixin。
全站按钮只有三档,定义在 abstracts/_mixins.scss:
| 档位 | mixin | 空闲 | hover | 按下 |
|---|---|---|---|---|
| 最高级 | btn-primary |
品牌浅底 + 品牌字 | 实底品牌色 + 白字 | filter: brightness(.5) |
| 次级 | btn-secondary |
无底色 + --color-text-secondary |
= 最高级的空闲态 | = 最高级的 hover 态 |
| 切页 / 分类 | btn-tab |
= 次级空闲态 | = 最高级空闲态 | 无变化 |
- 最高级的基准是博客首页的「创建」按钮(
.upload-button)。统一的是表面 + 交互, 不是尺寸 —— 各按钮保留自己的字号与内距。 - 次级三态递进(越靠近越显眼),所以它不会一上来就和主按钮争。
.button-*那一族只差尺寸与档位:.button-primary(16px / 10px 16px)、.button-primary-small(12px / 5px 10px)、.button-secondary(次级档, 尺寸同.button-primary)。同一排按钮要么共用一个尺寸,要么刻意一大一小 —— 博客编辑页那排踩过:.button-primary(保存修改)边上挂着两颗.button-primary-small(取消 / 返回阅读页),三颗三种大小,读起来像三条互不相干的按钮。 那两颗现走.button-secondary,尺寸与「保存修改」逐像素一致,表面归次级档、 不跟主按钮抢视线。- 切页档:「当前所在页」常驻最高级空闲态,且按下不叠加变化 —— 已经在的页面
再点一下不该闪成实底。当前项挂
.is-active(.active作为历史写法一并认, 新代码用.is-active)。落点:顶栏.site-link、博客分类栏.category-link/.sub-category-link、页码.page-link、管理页的选中/未选中成对按钮、 工具箱.filter-pill、鱼干流水.filter-btn。 - 任何一档都不做垂直位移。hover/按下只改颜色;卡片 hover 也只亮品牌色光晕
(对齐
.blog-item),不translateY。入场动画(fadeInUp/ toast / 汉堡变形) 与居中用的 transform 不受此限。 - Bootstrap 风格 fallback 族(
.btn/.btn-secondary/.btn-outline-*/.btn-danger/.btn-success/.btn-warning/.btn-info)全部改走令牌与这三个 mixin:.btn-warning由琥珀改为站内唯一的警示色(黄那支是收藏夹专用,不外借),.btn-info站内无语义、按层级归次级档。原先那些#0d6efd/#6c757d/#dc3545写死值连同整块[data-theme="dark"]补丁一并删除(色值随令牌走,不需要补丁)。
⚠️ 唯一的有边框按钮是
.btn--ghost,这是「无边框」总则的刻意例外:投票详情页 底部那几颗(「返回上页」等)在暗色下没有边框就只剩一行字。tests/e2e/dark-theme.spec.ts断言的正是「暗色下这个按钮的边框必须非透明」—— 去掉边框会让用例失败,且确实是 可读性倒退。另一处保留的border-left是顶栏下拉的箭头三角形 (.site-user-dropdown-toggle::after),那是 CSS 画的图形,不是设计元素。
写按钮样式前先看一眼有没有更具体的选择器会盖掉它,例如
pages/_tool-new.scss的.tool-new-card__footer .btn是 0-2-0,会压过全局.btn与.btn-primary—— 那种地方必须自己@include同一套 mixin。
6.2 卡片
全站卡片的总则(2026-09 统一,原先只有首页四卡这么做):
- 不描边。容器轮廓不用
border—— 圆角本身就够说明形状,边上再压一圈线是两套 形状语言的混搭(同 §11.8 的「不留左侧竖条」)。border-radius保留。 - 常态不托阴影(§2.3)。空闲就给阴影会让一整页卡片各自「浮」起来、把版面切碎,
改成「指到哪儿哪儿才浮起来」。层级改由底色深浅承担:
页面底
--color-background-page→ 卡片--color-background-card→ 卡内子块--color-background-content(再弱一档--color-background-subtle)。 卡内子块这一档是必须的:它原先靠 1px 描边与父卡分开,描边一去、同色相贴就没了边界。 - 只有整块可点的卡片 hover 才亮
--shadow-card-brand。判据是承载卡片的那一层 自己就是<a>/<Link>/<button>(.blog-item走.blog-item-cover那种 铺满整卡的拉伸链接也算)。卡里只有某颗按钮可点的,整卡不许有 hover 反馈。 配上transition: box-shadow 0.2s ease,别让光晕瞬间弹出。 - 浮层是另一档:弹窗 / toast / 下拉菜单 / 贴边面板 / 移动端抽屉用
--shadow-card-brand常驻(它们本来就要「浮」在页面之上),不是 hover 才有。
⚠️ 别用 box-shadow: inset … 或 outline 去模拟描边 —— 那是伪边框,同样是边框
(判据与搜法见 §11.8)。状态(选中 / 佩戴中 / 「我」)一律用淡底 + 同色字表达。
首页
.feature-card(「探索」区四张功能卡):上面的总则原样适用,唯一的例外是 hover 光晕的颜色 —— 它是卡片自己的颜色,不是品牌蓝(四张卡是页面主体内容, 四个色相是首页的视觉语言)。光晕是卡片自己的颜色,不是品牌蓝。每张卡只在
.card-*上定义一次--card-accent(色相)与--card-glow(同色系的-soft淡色), 图标、hover 光晕、卡内按钮三者都读这两个变量 ——卡 --card-accent图标 mask .card-story--color-accent-bluebook.svg.card-blog--color-accent-amberjournal-text.svg.card-tool--color-accent-cyantools.svg.card-chat--color-success-primarychat-dots_new.svg改卡片颜色只需改
.card-*那一行。⚠️ 别再让图标、光晕、按钮各自取色 —— 那会做出「图标是琥珀的、光晕是蓝的、按钮还是品牌蓝」这种半拉子状态。 卡内按钮因此由--card-accent驱动(.feature-card .home-btn,0-2-0 压过.home-btn--outline-*的 0-1-0),它自己那支--outline-*变体只在卡片之外 单独使用时才生效。卡内按钮的 hover 是在同一支淡底上再叠一层同色(12% → 约 23%):
box-shadow: inset 0 0 0 999px var(--card-glow)。两个刻意之处 —— ① 用inset box-shadow叠而不是换background:box-shadow参与过渡, 换background-image的渐变是离散跳变,会硬闪一下;② 不走「转实底 + 白字」 (那是最高级按钮的 hover 配方):四张卡的色相里只有蓝压得住白字,琥珀#f59e0b、 青#06b6d4、绿#10b981配白字的对比度都不到 2.6:1。同一套 hover 配方要在 四张卡上都成立,所以统一「加深淡底」而不是「转实底」。管理后台
.admin-stat-card:用卡片淡底 + 数字同色区分类型(blue/green/amber/ purple/red)。原先靠左侧 4px 彩色竖条,已按「无左侧边框」总则去掉;色值也从写死的 hex 换成了令牌(站内没有紫色语义令牌,purple 与 blue 合并到品牌色系)。
专注模式的入口:整段不渲染,没有「禁用态」这一档(2026-09 改的口径)。
此前这里有一节「禁用入口」配方 —— 保留元素、置灰、换成不导航的 <span> / <button>、
title 取 FOCUS_MODE_BLOCKED_TITLE、再显式压掉各自的 :hover,两个用户是讨论侧栏的
大区行与练手盘的三处外链。口径改成「用不了就不画」之后,那套配方一个使用者都没有了
(连同 .chat-chan.is-disabled / .fish-card__info-link.is-disabled /
.market-foot__link.is-disabled 三条规则一起删掉)。
判据与理由见 CLAUDE.md 的档位阶梯那条:档位不够 ≠ 专注模式。要加新的「专注模式下用不了
的组件」,照现在的做法——由调用方条件渲染整段(含外层 <p>:p + p 的行距命中的是
「有没有那个 <p>」,留个空壳会凭空多出一段空白),别去复活那套置灰配方。
6.3 表单控件(components/_form-controls.scss)
.form-control:无边框 +--color-background-page底 + 聚焦--shadow-focus-brand; 圆角按元素分 ——input.form-control/select.form-control胶囊,textarea.form-control20px。判据见 §4.2。.form-control-sm:紧凑变体(管理页在用)。- ⚠️ 这里原有两处
!important(color与background-color)。它们会压掉组件自己的:focus背景切换 —— 作者重要性高于作者普通声明,与特异性无关 —— 所以「聚焦时换成卡片底色」这条一直是死代码。现已去掉:与搜索框一致, 聚焦只加光晕、不换底。今后也不要在这里加!important,它会静默吃掉所有聚焦态与 变体色。 .form-select/select.form-control:与.form-control同一套外观(无边框、--color-background-page底、胶囊圆角、聚焦光晕)。不再是「品牌底 + 品牌字 + 粗体」 ——那是「最高级按钮」的样子,<select>属于字段而不是按钮。- 展开后的列表(原生弹出层)要自己上色:
option/optgroup显式给--color-background-card底 +--color-text-primary字。条目的字色本来就从<select>继承(暗色下是近白),而底色由浏览器/系统决定;两者不一致时展开就是白底白字, 看起来像「没适配夜间模式」,且不报错。color-scheme只管浏览器画那一层的明暗基准 (见 §8),不足以保证底色与字色配对,所以这两条不能删。高亮行仍由浏览器自己画。 .form-check-input:圆形 checkbox,选中变品牌色。- 校验态:
.is-invalid+.invalid-feedback(红色)。 - 文件选择器(
components/_file-picker.scss,.filepick一族):这套类在.tsx里 一个都搜不到 —— 那 DOM 是public/static/js/core/base.js的enhanceFileInputs运行时注入的(把页面上写着的原生<input type="file">包进.filepick,再补一颗 「选择文件」label、一个文件名 span、一个清除钮)。改样式别去 tsx 里找调用点。 两条不能删的: ① 原生 input 必须视觉隐藏(.filepick input[type="file"]的 sr-only)。 不隐藏就会有两套控件同屏:浏览器自带的「选择文件 / 未选择文件」和注入的 label + span。隐藏也不能改用hidden属性或display:none—— base.js 正是靠hasAttribute('hidden') || style.display === 'none'判断「这个已被自定义 UI 接管, 别再包一层」(图床拖拽区、讨论输入区走这条早退)。 ② 内嵌按钮走btn-primarymixin(对齐.search-btn的「字段内主操作」档位), 几何照 §4.2 第 2 条:圆角与字段一致、四边贴边 4px。别在这里另抄一份按钮样式。 两个守卫盯着:样式侧是tests/unit/css-js-classes.test.ts(JS 注入的类名必须有定义), 行为侧是tests/unit/base-js-filepick.test.ts(含「客户端路由跳转后插入的 input 也要 被增强」—— Next 的<Link>不重载文档,只在 init 时跑一次是不够的)。
6.4 弹窗 / Toast / 分页 / 告警
.modal:居中遮罩(rgba(0,0,0,.5)),内容 500px 宽、30px 圆角、--shadow-card-brand。.toast:右上角 360px 栈,深色底白字,按类型着色(success/error/info/warning)。.pagination:居中,.page-link走切页档(无底色胶囊;当前页品牌浅底 + 品牌字, 按下不叠加变化)。.page-input(跳页框)单行 → 胶囊。 原先每页都带 1px 描边、6px 圆角,当前页是品牌实底白字(那是最高级的 hover 态)。.alert:Bootstrap 风格 4 色 +body.dark-mode适配。
6.5 讨论页(pages/_chat.scss)
/chat 是双栏工作台,页面高度 calc(100dvh - 62px)(vh 兜底)、overflow: hidden,色板全部走 CSS 变量随明暗主题:
- 会话侧栏
.chat-sidebar:固定 280px,会话项.chat-chan(头像 / 标题 / 预览 / 未读徽标 / 删除),头部.chat-sidebar__head带折叠钮 ——.chat-page--collapsed时收到 60px 只留图标。- 选中项只有「整行品牌淡底」这一种表达(
.chat-chan.is-active)。它原先还带一条box-shadow: inset 3px 0 0 品牌色—— 那是伪装的border-left: 3px,全站清左侧竖条时 漏掉了(按border-left搜是搜不到的)。折叠态则改用头像外一圈 3px 品牌描边 (60px 轨道里整行淡底会被头像占满、读不出来)。 - 默认款中
@到我那条消息(.chat-msg__content--mention)用品牌淡底强调,不描边。
- 选中项只有「整行品牌淡底」这一种表达(
- 消息主区
.chat-main:头部标题 + 操作;消息气泡.chat-msg(自己发的加.chat-msg--mine),含作者名 / 时间 / 操作 / 图片 / 引用回复 / 已删占位.chat-msg__deleted。- 正文气泡用一级卡片底色
--color-background-card,无描边,内边距8px 16px。 圆角calc(0.775em + 8px)= 半个行高(1.55em / 2)+ 单侧内边距: 单行恰好是胶囊,多行保持同一个圆角,不随消息高度继续增大。 ChatBubble统一渲染真实消息、设置及商城预览。data-bubble只映射代码白名单;blue-square(蓝底直角,原「蓝色直角」)用品牌蓝淡底与border-radius: 0,@提及加深底色。blue-outline-square(蓝色直角)用 1px 品牌蓝边框、直角及与.chat-main相同的--color-background-page底色,@提及时边框加粗到 2px;从内边距扣掉边框尺寸,避免正文位置或外部尺寸变化。 两款底色与文字都使用主题令牌,浅底深字、深底浅字。纯图片仍透明且无边框,删除占位不应用付费气泡。 款式不覆盖正文排版与宽度约束。- 最终正文仅有一张图床图片或表情时,
.chat-msg__md--single-image让外层气泡 去掉底色与内边距;文字混合图片、多图,以及带其他图片附件或博客卡的消息仍保留气泡。 图片附件本身继续独立显示;缺失表情回退成文字时摘掉修饰类,恢复普通气泡。 - 同人连续发言:只有这一串的第一条画时间 / 作者名 / 头像,后续几条加
.chat-msg--grouped省略它们,两条气泡挨在一起(间距 3px,视觉上并成一簇)。 判据(距这一串的第一条 ≤5 分钟,即CHAT_GROUP_WINDOW_MS;换人 / 跨自然日 / 拍一拍都断开)在src/app/chat/ChatMessageItem.tsx的markGroupedMessages—— 别在渲染层另算一套。 - 能挨在一起的前提:后继消息的表头行整行退出了文档流(
.chat-msg--grouped .chat-msg__meta { position: absolute }),挂到气泡外侧(别人的在右、自己的 在左,都对齐顶边)。它在流里时要占 17px 行高 + 4px 间距,光靠负外边距最多把两条 气泡的距离从 27px 收到 21px,怎么调都贴不上;而浮在气泡上会盖住正文 (气泡是 fit-content 宽度,「回复 删除」能占掉短气泡的一半)。同理,那 8px 让位 用的是padding而不是margin—— 浮层的 box 边缘必须贴着气泡边缘,否则指针 从气泡移向按钮的途中会落进一段空隙,:hover中断、按钮够不着。 - 浮层的定位祖先是
.chat-msg__own-blocks(气泡 / 图 / 博客卡那一层, 不含引用块),不是.chat-msg__body:body 的宽度是max(引用块, 自己的块), 挂它上面时「贴气泡边缘」只在气泡恰好最宽时成立 —— 引用比正文长,回执与 「回复 / 删除」就跟过去贴引用的边了(2026-09 站长报的:对齐的是两者里更长的那一个)。 所以 grouped 的表头行住在 own-blocks 里面(组件里按grouped换父节点), 那一层也带了max-width: 100%—— 理由与气泡上那条完全同源(代码块的 min-content 会把每一层祖先都撑破),由tests/e2e/chat-codeblock.spec.ts盯着。 结构契约由tests/unit/chat-message-dom.test.ts钉住,几何由tests/e2e/chat-features.spec.ts的「工具条贴气泡」那条钉住。 - 三处隐藏方式刻意不同,改动前先看清各自为什么:头像
opacity: 0(它是 「拍一拍 / @ta / 主页」的入口,藏了也要能点,悬停淡入;触屏没有 hover,故@media (hover: none)下常显);作者名与时间display: none(不可交互; 整行已不在文档流里,「保住行高免得顶下去」这层顾虑随之消失,而留着它们反而 会占宽 ——visibility: hidden的元素仍参与排版,会把浮层凭空撑宽六七十 px, 把overflow-y: auto的列表撑出横向滚动条。私聊的已读回执与它们在同一行, 所以跟着一起挂到气泡外侧,照常显示)。
- 正文气泡用一级卡片底色
- 输入条
.chat-composer:附件条(回复 / 引用博客 / 待发图片)+ 面板(工具条.chat-composer__icon-btn→ 输入框.chat-composer__input→ 底条:提示.chat-composer__hint+ 发送.chat-composer__send)。样式与评论区共用components/_composer.scss的rich-composer($p)mixin,组件也是同一个RichComposer(className注入 BEM 前缀,见 §11.1)。 - 发起私聊弹窗
.chat-new-modal:搜索框.chat-new-search+ 结果项.chat-new-item(头像 / 昵称 / 角色 / 自己标记)。 - 响应式:
≤900px时侧栏变抽屉,.chat-page--drawer-open展开。
6.6 博客列表排序工具栏(pages/blog/_menu.scss)
/blog 列表顶部的「发布时间 / 更新时间」切换(组件 src/app/blog/BlogSort.tsx):
.blog-sort:容器,列表区顶部右对齐一行(justify-content: flex-end),空结果态也渲染(要挂载客户端恢复 effect)。- 它现在是一个胶囊滑块(
.segmented,见 §6.8)—— 切换时滑块有 0.25s 的移动反馈。 按钮尺寸写在.blog-sort .segmented__btn,几何与动画来自components/_segmented.scss。 - 可访问性:容器
role="group"+aria-label,按钮type="button"+aria-pressed。 ⚠️ 不要改成role="tab":tests/e2e/blog.spec.ts用getByRole('button', { name: '更新时间' })定位,tab角色会覆盖按钮角色, 那条用例会直接失配。
⚠️ 桌面端(≥992px)
.blog-sort是绝对定位参与一条「停放带」:hero 的底 padding 10 +.blog-layout的 margin-top 60 拼出停放空间,滑块top: -52px落在其中。 它顶部锚定,变高只会向下长进首篇博客上方那 24px 余量里。不要为了「重新居中」 把top往上调 —— 那会撞进 hero 色块(_menu.scss的注释记的正是这个坑)。
6.7 文章详情页读者交互区(pages/blog/_blog.scss + pages/_favorite.scss)
.read-controls 两行:第一行 点赞 / 投喂 / 收藏,第二行 返回上页 / 管理文章。
三颗状态色都走「未激活跟随 currentColor,激活才上色」的同一套手法,别让任何一颗常亮:
.like-btn→.liked红(--color-warning-*);.fish-btn→.fish-btn--fed绿(--color-success-*);.favorite-btn→.favorited黄(--color-star-*)。- ⚠️ 星标曾经恒亮(给
.icon-star-fill直接写死background-color),后果是三颗里 只有它一直有颜色,「已收藏」反而看不出来。不要再给.icon-star-fill加background-color——.icon的机制就是background-color: currentColor,颜色只由按钮的color决定(§7)。 - 收藏按钮没有计数徽标(点赞/投喂有):站内不显示一篇文章的被收藏数。
窄屏(≤768px)三颗收成三个 44px 圆钮,压在同一行、不许换行
(flex-wrap: nowrap + flex: 0 0 44px + max-width: 24rem;横向交给
justify-content: space-evenly —— 定宽之后不能再平分轨道宽度,正圆要求宽 = 高):
- 文字标签隐藏(
> span:not([class])—— 计数徽标也是span,但它带类名,正好被排除)。 一排字换成一列之后,320px 档也宽松得很,原先<360px那条「退回按内容宽度 + 允许换行」 的兜底连同它的前提(文字宽度 339px 塞不下)一并删了。 - 计数落在圆钮外面、正下方居中:44px 见方的圆钮里再挤一行数字,圆就不成圆了。
所以
.like-count-badge/.fish-count-badge走position: absolute+top: calc(100% + 4px)+left: 50%/translateX(-50%)—— DOM 不动 (仍是按钮的孩子,点数字照样触发按钮),也不占布局位置,因此按钮自己还是 44px。 那 4px 是数字离圆底的一口气(贴top: 100%时数字上沿紧挨圆边,像溢出来的); 字号0.85rem(与桌面端徽标同号,不再是窄屏专属的0.7rem)。 行上必须留padding-bottom: 20px接住「4px 间隙 + 数字行高」, 否则那行数字会压到下一行「返回上页 / 管理文章」。图标放大一档(1rem → 1.25rem)。 - ⚠️ 高度由
.read-controls__row的min-height: 44px钉死,两个断点共用, 窄屏再补一个height: 44px把正圆坐实。桌面端它由内容撑出(点赞/投喂 ≈44.3px、 收藏 41.0px —— 徽标比文字行高);两边各写各的高度就会在断点前后跳一下(用户报的就是它)。 窄屏那条里不要再写padding/font-size的整体缩放,那正是原来会变矮的原因。 - 这条不能只看代码:
tests/e2e/favorite-layout.spec.ts用真视口断几何,并登记在playwright.config.ts的RESPONSIVE_SPECS里(desktop 那一遍同样要跑)。 其中「窄屏高度 = 桌面高度」那条要在同一个用例里换视口量两次,且必须 先setViewportSize再goto—— 三颗都带transition: all .3s ease, 先加载再改视口会量到过渡起点的值(桌面端的),于是这个 bug 永远量不出来。
收藏夹选择器弹窗(.favorite-picker__*):名称独占一行,两颗创建按钮并排在下一行
(.favorite-picker__new-actions,flex: 1 1 0 等宽)。三者挤一行时「创建公开」会被挤到
第二行,而两颗按钮代表的是对等的两种性质(创建后不可改),分行会被读成「公开是次要的」。
<360px 退回上下堆叠。/favorite 页的创建/导入条复用同一组类。
正文里的收藏夹卡片(.favorite-embed,HTML 由 src/lib/favorite-refs.ts 的
buildFavoriteCardHtml 直接产出)与投票嵌入卡 .vote-embed-widget 同一副面孔:
--color-background-content 底 + 圆角 + 内距(无描边,见 §6.2)。它先后用过「左侧金色
竖条」、「--color-star-secondary 星色淡底」与「卡片色 + 1px 描边」三种身份提示:第一种
违反 §11 第 8 条(无左侧边框);第二种让正文里整块发黄 —— 星色是「已收藏」的状态色
(§2.1 写明它只在该处与 hover 时用),拿它当一整段正文的背景,读起来像那段内容被整个
标记了;第三种随「卡片不描边」总则退役。嵌入块现在靠底色深浅(比正文卡深一档的
content 色)+ 形状(圆角 + 内距)与正文分开,不靠色相,也不靠描边。
评论区的两条几何契约
楼中楼的缩进是「DOM 嵌套 + 同一条 .comment-list 规则重复生效」叠出来的:React 侧
完全不知道深度(CommentItem 不收 depth,服务端也没有层数上限),所以纵向与横向各有一条
必须成立的契约,由 tests/e2e/comment-layout.spec.ts 的两条用例分别钉住
(3 层:什么都不许溢出;20 层:只有顶层列表可以溢出且必须能滚)。
- 纵向 —— 末尾空白不许随层数累加。 有楼中楼的评论
padding-bottom: 0、嵌套列表margin-bottom: 0(.comment-item:has(> .comment-list)/.comment-item > .comment-list)。 否则每一层要付两次账:父级内距 18 + 内层列表下距 15 —— 父级的 padding 挡住外边距折叠、 子级的下外边距又算进父级的 auto 高度,两者是相加而非取大者:每深一层多 33px, 3 层实测末尾空出 66px(与「根评论 → 根评论」的 0 相比就是用户报的那一大段空)。 归零后任何层数都只剩最深那一层自己的 18px。 ⚠️ 这是本仓第一条实际生效的:has()(§12 里那条@supports not (selector(:has(*)))是没了对手的孤儿);刻意不写降级规则 —— 不支持时退回的正是改动前的间距,只是没修好 而已,没有比现状更好的「降级样式」可写。 - 横向 —— 深层评论不许被挤成一个字宽。
.comment-item { min-width: 240px }必须固定 px(写min(240px, 100%)会随可用宽度一起缩 = 等于没设),缩进每层 15px、窄屏 8px (窄屏压缩见下面的媒体查询)。可用宽度 = 容器宽 − 缩进 × (层数−1):不设地板的话 390px 手机上第 44 层只剩 14px(一行一个字);设了之后窄屏第 16 层踩到地板,再深就交给滚动。 240 的依据:评论里不能换行的最宽一行是.actions的 flex 行(§6.1,没写flex-wrap⇒ 点赞胶囊 ≈54 + 间距 10 + 回复 ≈46 + 间距 10 + 删除 ≈46 ≈ 166px,计数三位数时 ≈186px), 且必须低于最窄的容器(320px 屏 → 288px),否则根评论自己就会顶出横条。 - 溢出只许由
.comment-section > .comment-list接住(overflow-x: auto)。中间层必须 保持visible,否则每层各画一条横条;.blog-detail与documentElement都不许出现 横向滚动条。⚠️ 那条overflow-x必须带.comment-section >限定 —— 嵌套列表复用的是 同一个.comment-list类名。 - ⚠️ 横条画在整片评论区的底部:列表多高它就在多低处,鼠标用户要滚到列表底下才够得着 (触屏 / 触控板 / shift 滚轮在列表内任意位置可用)。这是「整片只给一条横条」的固有代价, 不是 bug —— 想改成随手可及就得把列表关进固定高度的内滚容器,那是另一套版式。
6.8 胶囊滑块(components/_segmented.scss)
一条会滑动的胶囊轨道:容器 .segmented + 滑块 .segmented__thumb + 按钮
.segmented__btn。位移由 CSS 算,组件只传两个变量(React 的 style 即可):
<div className="segmented" style={{ '--seg-i': idx, '--seg-n': count } as CSSProperties}>
<span className="segmented__thumb" aria-hidden="true" />
<button className="segmented__btn is-active" aria-pressed>…</button>
…
</div>
用
:has()也能做,但那样还得再写一遍@supports not的降级样式。传变量既没有 兼容问题,也让「第 i 格 × 一格宽」这段几何只有一处。
什么时候用 —— 只有「2 选 1 的互斥视图切换」
判据一句话:这块滑块代表同一个视图的两种呈现。 目前全站只有两处:
| 落点 | 组件 | ARIA |
|---|---|---|
| 博客列表「发布时间 / 更新时间」 | src/app/blog/BlogSort.tsx |
role="group" + aria-pressed |
| 个人主页「文章 / 评论」 | src/app/u/[id]/ProfileTabs.tsx |
role="tablist" / role="tab" + aria-selected |
视觉共用,语义各按各的 —— 前者是一组互斥开关,后者在切换面板。
什么时候不要用
⚠️ 这不是通用分段控件。别顺手往这些地方套:
- 工具箱的分类筛选 —— N 选 1,选项数量不定,滑块宽度会被摊薄成一条细缝。
它是「切页档」的
.filter-pill。 .action-button那类互斥按钮 —— 语义是「执行一个动作」,不是「换一种看法」。- 设置页的开关 —— 那是
input[type=checkbox]做的 switch,不是分段选择。 - 鱼干流水的收支筛选 —— 同第一条,用切页档。
滥用会让「滑块 = 换个角度看同一份内容」这层意思失效,而它正是这个控件唯一的价值。 但凡选项可能变多,或者点下去会提交/执行什么,就该用按钮而不是滑块。
几何约束
- 按钮必须等宽(
flex: 1 1 0)—— 滑块按「第 i 格 × 一格宽」定位,不等宽就错位。 个人主页两个页签的文字自带计数(「文章 (3)」「评论 (5)」)本来宽度不同, 等宽后各占一半,是刻意的。 - 未选中项在任何状态下都是灰字(
--color-text-secondary),没有 hover 换色 —— 这不是漏写:① 触屏上浏览器会把最后一次点按的:hover留在元素上,hover 改色会让 「刚点过的那一项」一直亮着品牌蓝,像同时选中了两个;② 滑块本身已经在回答「选中哪个」, 指针划过再变蓝等于把这层意思分给了两个状态。次级 → 最高级空闲态那套递进留给按钮档。 prefers-reduced-motion: reduce下关掉过渡。
6.9 练手盘的三栏工作台与图表(pages/_fish-trade.scss)
/fish/trade 是除讨论区之外第二个多栏工作台(自选 | 图表 | 下单 + 持仓),
全部类名走 trade-* 前缀(与 fish-card__* / market-* 分开,三页布局各不相同)。
四段阶梯,每一档的判据都是容器阶梯(§5)而不是随手挑的:
| 视口 | 布局 | 中间那列的宽度 |
|---|---|---|
| ≥1200 | 三栏 200px | 1fr | 300px |
容器 1140 → ~608px |
| ≤1199 | 三栏收窄 168px | 1fr | 264px |
容器 960 → ~496px(自选这一档隐藏走势线) |
| ≤991 | 自选变横向条横跨整行,下面「图 | 右栏」两列 | — |
| ≤767 | 单列:自选条 → 图 → 右栏两卡 | — |
minmax(0, 1fr)不是可选的:不写它,列宽就是 min-content —— 图里的 SVG 与轴标签 会把列撑开,窄屏直接出横向滚动条。同理网格里的 SVG 要显式给高度, 否则height: 100%会塌成 0。- 窄屏是同一份 DOM:自选列表靠 CSS 从竖排摊成横条,不另写一套 JSX (两套 DOM = 两份状态 + 两处会 drift 的类名)。
图表的硬规则:SVG 里一个字都没有。 绘图区是 viewBox="0 0 100 100" +
preserveAspectRatio="none" 的 SVG —— 它把横向与纵向各自拉伸到容器尺寸(这正是图表要的:
蜡烛随容器变宽),但文字会跟着一起拉伸,容器一扁就把字压成一张饼。所以轴标签、
十字光标、价/时徽标全是 HTML,用百分比定位叠在同一格网格上(.trade-chart 是 grid,
plot / price / volume / time 各占一格):与 SVG 共用同一个分数,天然对齐,字号归 CSS 管。
线宽靠 vector-effect="non-scaling-stroke" 保住 1px(同 .trade-spark 那根走势线)。
成交量带只占绘图区那一列(右边那格留空),否则它比画布宽出 56px,量柱与蜡烛对不上。
三个选中态各归各档(这套是 §6.1 / §6.8 的判据在这张页上的落点):
| 控件 | 档位 | 为什么 |
|---|---|---|
| 周期档(1m…1d) | 切页档 btn-tab |
6 选 1,且选项还可能变多 —— §6.8 的「不要用胶囊滑块」 |
| 方向(做多 / 做空) | 切页档 btn-tab |
2 选 1 —— §6.8 的正例(「同一个视图的两种呈现」)。.trade-direction |
| 杠杆快捷档(1×…100×) | 切页档 btn-tab |
8 选 1、且选项可自由填。它不是「换一种看法」,是「这一笔用几倍」,所以明确不走 .segmented |
| 杠杆自由输入 | 一行数字 → 胶囊 | .trade-leverage__input,与金额输入同一档(§4.2);聚焦用 --shadow-focus-brand,绝不改 border |
| K 线 / 折线 | .segmented 胶囊滑块 |
同一个视图的两种呈现 —— §6.8 的正例 |
| 自选列表选中行 | 整行 --color-brand-secondary 淡底 |
不加左侧竖条(§11.8) |
杠杆与方向那一块还有四处判据值得记:
杠杆自 2026-10 起是「快捷档一排 + 一个自由输入框」,快捷档来自
LEVERAGE_PRESETS—— 它只是按钮,不是合法集(合法集是 1–100 的每一个整数)。 所以这一排不再是「N 选 1」:选中态只表示「当前值恰好等于这一档」, 手输 37 时那一排就没有任何一枚是 active 的(这是对的)。高倍警告(≥
HIGH_RISK_LEVERAGE)常驻在选择器下方(.trade-leverage__warn), 不是弹一次就完的 modal —— 每次都要点掉的东西会立刻脱敏。它刻意不复用.trade-field__hint--danger:后者现在的用法是「给提示行里的某一个数上色」 (一个<strong>),共参与一个类就会改一处顺手改掉另一处(同--error与--danger刻意分家的理由)。「强平引擎没在跑」时「做空」与 >1 的档位是灰的(
disabled+opacity: .35), 不是隐藏的 —— 用户看得到有这个功能、也看得到它此刻不可用,比整排消失更好解释 (服务端那边也会拒单,见docs/architecture.md§6.13)。这不是「入口跟着藏」那条红线: 1 倍做多照旧能买。⚠️ 别把判据写成「>1 才灰」—— 1 倍做空同样要引擎。方向角标(
.trade-position__dir/.trade-confirm__dir/.trade-settled__dir/.tstats-dir)是徽标不是按钮(淡底 +$radius-small,做空那侧借 warning 那一对色), 恒显示 —— 1× 空头既没有倍数角标(倍数是 1),把它省掉那一行就和一笔无关紧要的 1× 多单长得一模一样。同一个理由:持仓行里的倍数角标(.trade-position__lev)也是 徽标,绝不能套btn-tab—— 套了就会被读成「点它能改倍数」,而上方那个选择器只 作用于下一笔。--danger与--error在.trade-field__hint上是两个类名, 虽然此刻同色(「你填错了」vs「这个数会吃掉你的本金」)。
持仓行的折行:容器查询,不是 media query
.trade-position 在卡窄了的时候折成两行(身份与价格一行、盈亏与卖出按钮一行),
判据是 @container (max-width: 380px),容器是 .trade-positions(container-type: inline-size)。
为什么不能写 @media:持仓卡所在那一栏的宽度不是视口的单调函数 ——
| 视口 | 那一栏的宽 |
|---|---|
| ≥1200 | 300px(右栏) |
| 992–1199 | 264px(右栏) |
| 768–991 | 280px(右栏) |
| ≤767 | 整幅容器宽(单列,最宽 ~730px) |
三栏档把这张卡挤窄、单列档反而给它最宽,而单列档内部从 767 缩到 375 它又缩回 ~343px。
想用 @media 表达「卡窄了就折行」得拼出一串不相连的区间,还得跟着 .trade-layout
的列宽改动一起维护。
这条是踩过才知道的:2026-09 加三栏工作台时,折行规则还写着
@media (max-width: 560px)(判的是视口),没有任何用例盯着这一行,于是桌面端出现了
「一个词一行」—— 实测 1280px 视口下 .trade-position 只有 252px 宽、
grid-template-columns 是 47px / 90px / 59px,首列 47px:开仓 / 80,000.00 /
爆仓 / 72,000.00 / 09-26 / 13:16 各占一行。窗口越宽越糟。
- ⚠️ 折行之后 main 必须跨满两列(
grid-column: 1 / -1)。只折行、让 main 与盈亏 挤在第一行的话,那 252px 里 main 只剩 ~114px,那一行照样碎成三行 ——「折了但没治好」。 - ⚠️ 阈值 380px 是量出来的:行内三样加两条间隙需要 ~331px(main 150 + 盈亏 90 + 卖出 59 + 2×16),再加这一行自己的左右内距 32px。改列宽或往行里加内容时回来重量一次。
- 老浏览器不支持容器查询时这条规则整块丢掉,落回单行版式(即与修复前一样)——
所以它不需要
@supports降级。这也是它不违背 §6.8 那条「避开:has()」的理由: 当年避开是因为滑块几何丢了就坏,这里丢了只是少折一次行。 - 几何用例:
tests/e2e/fish-trade-layout.spec.ts(三档各量一次首列宽度 + 开仓那一行的 行数,外加一条「卡最宽那档真的不折行」)。
图标钮(缩放 +/−)走「无底色 + hover 才亮」那一款(30px 见方、圆角 8px,
见 components/_composer.scss 的 .__icon-btn),不套 .btn-secondary ——
后者是文案按钮的档位,会把字号与内距一起带过来。
6.10 文档页(/docs,pages/_docs.scss)
站内文档的索引页与正文页。正文排版不在这个文件里 —— 它渲染的是仓库里的 Markdown,
与 6 个指南页是同一批内容,两边共用 components/_doc-prose.scss 的 doc-prose($cls)
(见 §11.1)。本文件只管索引页的目录版式与正文页的外壳(返回条 / 来源行 / 同组文档)。
- 索引页是两列卡片网格(≤768px 落成单列),每张卡是「标题 + 一句话」。分组标题 右边的份数徽标走淡底 + 次要字色。
- 正文页在容器之内再收一道行宽(
.docs-page--doc的 820px,与指南页同档): 页边距归.container、行宽归这层上限,两者不是一回事(§5)。 - ⚠️
.docs-page只写纵向内距。它与.container同挂一个元素,写padding简写会 把container-padding的两侧檐沟一并覆盖成 0 —— 窄屏下整页贴着屏幕两条边,不报错。 - ⚠️
.docs-list必须显式padding: 0:list-style: none只去掉项目符号,浏览器给<ul>的padding-inline-start: 40px还在,整列卡片会相对组标题右移 40px (2026-09 实测:窄屏第一张卡左边距 56 而不是 16)。 - 宽表在 ≤768px 上变成可横向滚动的块(
.docs-content table的display: block)。 只给文档页写,不放进共用 mixin:指南页那几份文档表格列数少、用不上, 而这个改动会顺带改掉它们的表现。 - 檐沟由
tests/e2e/page-gutter.spec.ts盯着(索引页的main h1与抬头简介、 正文页的.docs-content)。⚠️ 正文页不能量main h1(那个 h1 住在卡片内部, 还要多出卡片自己的内距),也不能量.docs-back(内容宽的链接,右边缘不在容器边上) —— 这两条各会当场判红。
6.11 练手盘统计页(/fish/trade/stats,pages/_fish-trade-stats.scss)
与 §6.9 那个三栏工作台同属练手盘、版式却是另一套:这是一份报表(KPI 行 + 两张
拆解表 + 持仓快照),所以类名前缀单开 tstats-(§11.1 那条「四页各有各的版式」)。
页面外壳沿用 .content-wrapper + .page-title(与 /fish/trade 同款,没有整幅
底色抬头带,因此不必登记进 tests/e2e/page-gutter.spec.ts)。
- KPI 行是 6 格网格(
repeat(6, minmax(0, 1fr))→ ≤991px 三列 → ≤575px 两列)。 配方抄/u/<id>的.profile-stats(淡底 +$radius-large+ 大号数字 + 小号标签), 但不共用类名 —— 那一份是主页的,改它会静默改到这一页。 - 数字一律
font-variant-numeric: tabular-nums:6 格宽度不同、数字长度也不同, 不等宽的话同一行几个数看着会左右跳(同.trade-card__balance-number)。 - 卡片复用通用
.card、表格复用通用.table(+.table-responsive) —— 不复用页面专属的.trade-card,理由同上一条。 - ⚠️ 权重陷阱(这一页最容易踩的一处):
components/_table.scss里是.table th/.table td= 0-1-1,压得住任何挂在单元格上的单类选择器(0-1-0)。 所以数字列的右对齐必须写成th.tstats-table__num, td.tstats-table__num双类;而 涨跌色必须挂在里层的<span class="tstats-pnl">上 —— 挂在<td>上会被.table td { color: … }静默盖掉,整列数字全变成正文色,不报任何错。 (同一个坑弹窗那边也踩过,见_fish-trade.scss里.trade-confirm__row dd的注释。) - 涨跌那一对色走
abstracts/_mixins.scss的trade-up-down(与工作台同一份定义)。 0 不上色(既不是涨也不是跌,同formatPct的判据)。 - 窄屏由
.table-responsive横向滚动(两张表都一样),不撑破页面;.tstats-table在 ≤575px 给一个min-width,否则列会被挤扁而不是滚动。
7. 图标方案
不用图标字体 / icon 库,采用「SVG + CSS mask」:components/_icons.scss 定义 .icon 基类,用 mask-image 引用 public/static/img/icons/*.svg,颜色跟随 currentColor(即继承 color),天然适配亮/暗主题。
- 常用类:
.icon-bell、.icon-gear、.icon-person、.icon-house、.icon-fish、.icon-theme-toggle、.icon-book/controller/journal-text/tools(首页四大入口)等。 - 首页卡片的
feature-icon/__icon用同一手法,给不同卡片指定不同color形成彩色图标(无需多色 SVG)。 - 新增图标:放一个单色 SVG 到
public/static/img/icons/,在 SCSS 里加一条 mask 规则即可。 - 例外:自带配色的多色图标不走 mask。 mask 是单色模板(只取形状,颜色一律来自
currentColor),所以填充色与描边色必须分开的图标套不进这条约定 —— 例如「白填充 + 深描边」才立得住的主体,mask 会把这一层信息抹平。这类素材直接放public/static/img/下,用<img>引用,配色烤在 SVG 里。 第三方素材还要在同目录留一份LICENSE.txt(先例:public/static/emoji/LICENSE.txt—— 它由复制脚本自己生成,不要照拷 npm 包里那份,理由见 CLAUDE.md「表情包」节)。
8. 主题切换机制
- 主题状态存
localStorage['theme'],取light/dark,未设置时跟随prefers-color-scheme。 src/app/layout.tsx内联一段防闪烁脚本,在 CSS 加载前就根据 localStorage/系统偏好设好<html data-theme>。- 交互切换在
public/static/js/core/base.js:switchTheme()设置data-theme属性并写 localStorage,主题按钮点击切换 + 图标旋转动画。 - 深色适配:所有组件用 CSS 变量即可自动适配,新代码一律走
[data-theme="dark"]或纯变量。body.dark-mode那套旧写法已全部清除(含pages/_tool-new.scss里一套 永不生效的第二配色与pages/blog/_blog.scss注释掉的旧令牌表)—— 全站只认<html data-theme>,没有任何代码会给body加.dark-mode。 color-scheme:两个主题块里各写一行(base/_root.scss)。它是「原生控件 跟着页面明暗走」的开关 —— 缺了它,暗色主题下 Chrome 照样按浅色画滚动条、<select>下拉、日期选择器与 canvas 底色。它管的不是样式,是浏览器自己画的那一层。- ⚠️ 但
color-scheme不是「原生控件一定好看」的保证,别拿它当唯一手段。<select>展开后的列表就是反例:条目字色继承页面(暗色下近白),底色却由 浏览器/系统拍板 —— 不保证跟着页面的color-scheme走,于是白底白字。 凡是「字色我们能定、底色我们不能定」的原生层,都要像 §6.3 那样把两层一起钉死, 不能只留一行color-scheme。 - 滚动条:
base/_root.scss里一组全局::-webkit-scrollbar(10px 轨道、--color-text-secondary的 thumb、hover 变品牌蓝、2px 透明描边 +background-clip: padding-box让视觉上是 6px 细条而手感仍是 10px)。刻意不写scrollbar-color/scrollbar-width—— 按规范它们一旦生效就会让::-webkit-scrollbar整族失效 (Chrome 121+ 已支持),圆角与 hover 都没了;不写的代价是 Firefox 只得color-scheme那层的原生暗色条,而它已经满足「跟着主题走」。
9. 响应式断点
abstracts/_breakpoints.scss:
| 断点 | 值 | 用途 |
|---|---|---|
$bp-sm |
576px | 小屏 |
$bp-md |
768px | 平板 / 移动折叠、栅格两列 |
$bp-lg |
992px | 容器收窄、栅格多列 |
$bp-xl |
1200px | 容器 1140px、栅格满列 |
写法:@include bp.up($bp-lg)(min-width)/ @include bp.down($bp-md)(max-width)。页面内也常见直接裸写 @media (min-width: ...)。移动端优先折叠导航(808px)、栅格降列、标题与字号降级。
10. 动画
- 过渡统一
transition: all 0.2s–0.3s ease。 @keyframes pulse(红点/签到绿点通知闪烁,2s 无限)。@keyframes fadeInUp(首页卡片入场)。- hover 微交互:卡片升阴影、图标
scale(1.1)、按钮反转前景背景。 按钮不做垂直位移(见 §6.1);入场动画与居中用的translateY不受限。
首页 Hero 使用 src/app/components/HeroLandscape.tsx 的原创 SVG 几何山景,样式在
src/styles-scss/pages/_home.scss。六层共享同一画幅:鼠标轻移按景深分配位移,页面向下
滚动时景物上移,近景移动与放大更多,文字保持在独立内容层。前景地面延伸到画幅之外,
避免较大的上移露出底边;缓动收敛时落到精确目标,回顶恢复原位。
鼠标视差只响应精细指针;触屏保留滚动景深。
动画缓动收敛后停止,画面离开视口或页面隐藏时暂停。prefers-reduced-motion: reduce
关闭视差与文字入场动画;禁用 JavaScript 时插画和文字直接由 SSR 展示,探索链接仍可用。
窄屏将山景移到文字下方并保留山体比例,不把整幅插画压扁。
山体以品牌蓝为主,Logo 红太阳和青绿山谷、湖水提供冷暖对比;--hero-* 局部画板由
品牌色、装饰色和主题表面通过 color-mix() 调出,太阳色沿用 Logo,字色使用主题文字色。
浅色显示红色太阳,深色显示琥珀黄色弯月。两颗天体共享山后的旋转中心,切换时沿弧线
交替升落;主题观察器每次把角度减去半圈,两个方向切换都保持逆时针。CSS 控制旋转、
淡入淡出和轻微变色,天体各自独立于外层视差;无 JavaScript 时使用当前主题的静态角度。
减少动态效果偏好下直接切换日月,不播放升落动画。
Hero 只保留「开始探索」入口,复用 btn-primary 并增加 backdrop-filter: blur(16px)。
文字入场动画结束后不保留 opacity/transform 效果,以免阻隔按钮采样背后的山景。
11. 新页面 / 新功能样式约定
- 主题色一律用
_root.scss的 CSS 变量,别写死色值。 - 圆角 / 间距 / 阴影复用 2.4 节的令牌,别自造一套。
- 改 SCSS 直接改源文件就行,没有产物要提交 —— dev 走 HMR,build 由 Next 编译。
- 图标优先复用
public/static/img/icons/现成 SVG + mask 方案,别引 icon 库。 - 组件优先复用现有
.button-*、.card、.form-control等类名,少写一次性样式。 按钮与输入框一律引 mixin / 既有类,不要另抄一份:按钮是@include m.btn-primary(或btn-secondary/btn-tab,见 §6.1), 字段是.form-control(或input.form-control/textarea.form-control的圆角, 见 §4.2)。这两样是全站最容易 drift 的地方 —— 抄一份的代价不是重复代码, 是「同一个按钮在两页长得不一样」,而这类不一致没人会当成 bug 报上来。 - 响应式:从
up(992px)多列 →up(768px)两列 → 单列,字号同步降级。 - 深色主题检查:切到
data-theme="dark"看一眼对比度(品牌色已选更亮的#23A5FF)。 - 不留「左侧边框」做装饰:容器已经是大圆角,边上再压一条 3–4px 竖条,弧线会被
切掉一截 —— 那是两套形状语言的混搭。表达「这是哪一类/已选中」用淡底 + 同色字。
两个例外:正文渲染出的
blockquote(内容语义,Markdown 通行约定)、 CSS 画的箭头三角形。同理,卡片 hover 只亮品牌色光晕,不做垂直位移。这条与 §6.2 的「不描边」是同一族规矩的两端:整圈轮廓归 §6.2(
border与伪边框一起禁),局部/状态描边归本条。状态画成环的(头像框面板「佩戴中」 的inset 0 0 0 2px、签到排行榜「我」那一行的outline)已统一改成--color-brand-secondary淡底 —— 那两处是同一次收敛,别再改回描边环。 ⚠️ 这类竖条不一定写成border-left—— 讨论区侧栏的选中项与@我到的消息 那两处都是box-shadow: inset 3px 0 0 品牌色(伪装的左边框)。清的时候要按 「box-shadow里带inset且 x 偏移为正」搜,只搜border-left一定漏。 还有一处必须连镜像一起删(@提及那条右对齐时竖条翻到右边,只删一半会左右不对称)。 - 不留幽灵变量:写
var(--x)前确认base/_root.scss里真的有x。变量不存在时 整条声明在计算值阶段失效(退回unset),不报错 —— 历史上有过--color-brand-primary-rgb(聚焦环整个不存在)、--color-bg-primary(角标边框没了)、--r-pill(403 页按钮一直是直角)、--box-bg/--text/--muted-color三兄弟(规则整条失效)以及一个拼错的--color-background-card-unrend(浅色未读底色从未生效)—— 全是静默的,只有肉眼能发现。
11.1 跨场景复用的样式写成带前缀的 mixin
同一套视觉用在两处(且两处的 BEM 前缀不同)时,不要复制一份,写成参数化 mixin,
两边各 @include 一次:
| 组件 | mixin | 用处 |
|---|---|---|
| 富文本输入区 | components/_composer.scss → rich-composer($p) |
讨论 chat-composer / 评论 comment-composer |
| Markdown 正文块级元素 | components/_markdown-body.scss → rich-markdown($cls) |
讨论 chat-msg__md / 评论 comment-content__md |
| 文档正文(仓库里的 Markdown) | components/_doc-prose.scss → doc-prose($cls) |
指南页 guide__content / 文档页 docs-content |
复制一份的代价不是重复代码,是必然 drift —— 用户会看到「列表在讨论里长这样、在评论里
长那样」,而这类不一致没有人会当成 bug 报上来。React 侧同理:RichComposer 的 BEM 前缀
由 className 注入(见组件文件头)。
两处的 @ 选人提示共用 MentionInput,样式在 components/_composer.scss:
.mention-input 定位,.mention-picker 浮在输入框上方,宽度不超过输入区,长列表内滚;
用品牌浮层阴影,选项沿用 btn-tab,当前项为 .is-active。鼠标 / 触屏点选保留输入框
焦点;键盘确认只插入用户名与尾随空格,不提交正文。
⚠️ 改这类共享样式后,两处都要在页面上看一眼 —— 单测与构建都拦不住「另一边被改花了」。
⚠️ 选择器权重陷阱:删掉旧样式时要确认新规则不会被更具体的老选择器盖掉。真实踩过:
.comment-form textarea(0-1-1)权重高于 .comment-composer__input(0-1-0),不删旧规则
就会把面板内的透明输入框重新涂成卡片底色,从外面看是一个突兀的色块。
⚠️ 正文里的通用 .类 img 会漏进每一个内联元素(2026-09 踩第三次):那条规则是给
「正文流里的图床图」写的(整行独占、描边、圆角 8px、不透明底色、cursor: zoom-in),
可它是后代选择器 —— 正文里所有内联引用(表情包 / 黄脸 / 用户名片)都在射程内,而每
一处自己的规则通常只声明尺寸,其余几条会原样漏进来。三个受害者里名片最惨:框贴图
的画布中心是透明的,漏进来的不透明底色把它连头像一起盖住,页面上只是个灰方块。
所以:新增任何一种内联引用,都要为它逐条清零(margin / border / border-radius
/ background / cursor),并在那一处写清为什么。表情那两档是现成的样板。
(黄脸还有第三档:整条正文只有它一张时用 :not(.rich-emoji-solo) 让压缩那条不匹配、
直接落回表情包盒子 —— 「和图片表情一样大」因此只有一处定义,改一处两档都跟上。)
12. 已知遗留 / 注意事项
abstracts/_theme-map.scss的 light map 与themeifymixin 已停用,不要基于它扩展。utilities/_text.scss里.u-text-muted/.text-primary/.text-danger等工具类 带!important的color,会盖掉任何组件颜色规则。给带这些类的元素写按钮态 时要留意。- 表单控件里另有两处
!important是刻意保留的(.form-check-label a等),改动前先确认。
刻意保留的写死色(不是遗留,别「顺手」改掉):
- 首页山景的太阳红
#fb787c(pages/_home.scss)—— 取自public/static/img/favicon.png右上角的红色圆点,浅色沿用 Logo 色,深色略向暖色偏移。 - 403 页的彩虹色相循环(
pages/_error.scss的.rainbow-error__bg)—— 同理, 它不是主题表面。 - 设置页开关的圆钮
background: #fff(pages/_settings.scss的.settings-toggle__slider::before)—— 它在两个主题下都必须是白的(开关就长这样), 换--color-background-card会让暗色主题的钮跟着变深、压在--color-border的轨道上 反而消失。原先还有一圈0 1px 3px rgba(0,0,0,.15)给它压出深度,已按「不留任何黑色 阴影」去掉,也没有换成品牌蓝光晕(那是「可点卡片 hover」与「浮层」两个语义, 一颗常态下的开关圆钮两者都不是)。钮与轨道靠白/灰的明度差 + 圆形本身分辨。 - 画报(
src/lib/poster.ts)与 identicon(src/lib/identicon.ts)里的配色 —— 输出的是 图片,不参与data-theme。代价是它们各有一份色板副本,改品牌色时不会跟着变。 - 各处实底按钮/角标上的
color: #fff—— 那是「实底 + 白字」配方的一部分, 两套主题下都成立(约 40 处,逐一核过)。
同类陷阱(当前约束与风险):
下文同时包含已存在的重复样式和尚未发生冲突的复用风险,不能一概称为「还没踩」。
需要改动的维护清单统一登记在 docs/plan/maintenance-plan.md §1;本节保留规则与排查依据。
同名嵌套(组件根复用了页面级容器类) —— 与 .blog-detail 同一个形状。
判据:该组件被渲染在任何已经带同名类的父节点里时,就会双份内缩 / 双份外边距。
下列四条目前的状态各不相同,改之前先按判据自己走一遍 DOM:
src/app/clipboard/upload/UploadForm.tsx的根节点 =clipboard-page+clipboard-title, 而clipboard/upload/page.tsx与clipboard/[id]/edit/page.tsx也各自套了一层clipboard-page→ 双份padding: 24px 16px+ 双重宽度阶梯。upload 页更严重: 页面与组件各渲染一个<h1 class="clipboard-title">上传云剪贴板</h1>,同一页两个 内容相同的主标题(a11y / SEO 硬伤;edit 页因isEdit分支不同文案而不重复)。 修法:组件根去掉这两个类,由两个页面各自提供外壳。src/app/admin/oauth/ApplicationRow.tsx的根节点 =management-card,而admin/oauth/page.tsx把它渲染在<section className="management-card">里面 → 卡中卡:双 30px padding、双底色。pages/_oauth.scss的.management-card + .management-card { margin-top: 0 }只是把兄弟行之间的margin-top: 20px压掉,压不住卡壳本身。 ⚠️ 2026-09 起这条已经会肉眼可见:卡片一律不描边之后,两层同为卡片色 = 明度差 0, 内层边界彻底消失。pages/admin/_users.scss里加了一条.management-card .management-card { background: var(--color-background-content) }作兜底(内层压深一档)。那是治标的:正解仍是把类名从组件根节点摘掉、由页面 统一提供外壳 —— 动它时要连这条兜底一起删。src/app/components/AdminArticlesManager.tsx的根节点 =admin-container(页面级容器:max-width: 1400px; margin: 32px auto; padding: 0 20px)。当前没爆,纯属admin/blogs/page.tsx自己没写容器、由组件提供。一旦它被放进任何一个已有admin-container的 admin 页(另外 6 个都写了)立刻复现。这也与 §5 那条「页边距归容器、 行宽归内层上限」相悖 —— 容器该上提到admin/blogs/page.tsx。src/app/fish/PayError.tsx的根节点 =content-wrapper+page-title(整页外壳)。 当前不冲突:它只出现在fish/pay与fish/collect的参数非法 early-return 分支, 与正常分支的content-wrapper不会同时渲染。但pages/_notifications.scss的注释 专门警告过.content-wrapper的后代选择器会随类名漂移,属同一类债。
同名不同义(两处定义指的是两回事):
pages/admin/_articles.scss的.current-category(后台文章表格里的分类徽章:padding/radius 20px/ 配.is-categorized换色)与pages/blog/_menu.scss的 同名类(博客分类栏的**「当前分类」标签**,只写margin-top: 12px)都是根级 0-1-0, 互不知情。唯一消费方是AdminArticlesManager.tsx(徽章那份),于是徽章白拿margin-top: 12px。另见上面.current-category .badge那条死规则。.copy-btn(pages/blog/_blog.scss根级全局 vspages/_story.scss在.story-reader__content下整套重写):注入点是src/lib/blog-renderer.ts拼的内联 HTML 字符串,所以任何用了共享渲染管线的页面都会命中 blog 那份。 story 页目前靠 0-2-0 压 0-1-0 才没走样 —— 改_blog.scss里那条的权重就会穿透。.filter-btn(pages/_fish.scssvspages/_notifications.scss):后者一个属性都没 生效(main.scss里 notifications 先于 fish 加载,fish 全胜),而通知页压根没有 筛选栏 —— 唯一消费方是fish/transactions。改_notifications.scss那份是静默无效的。.notification-card(pages/_notifications.scss的.content-wrapper .notification-cardvspages/admin/_notifications.scss的裸.notification-card):同名不同义 —— 前者是通知中心的一条通知(30px 圆角、无边框、12px 下距),后者是/admin/broadcast的整块面板(32px 内距、1px 描边、10px 圆角),唯一消费方是admin/broadcast/page.tsx。 通知中心那份靠 0-2-0 压住了它的内距 / 圆角 / 底色,唯独margin-top: 30px没人接 ——卡片之间于是隔 30px 而不是 12px(相邻外边距折叠取大者,那条margin-bottom根本不 参与)。通知中心用margin-top: 0抵消,不是冗余,别删。「按博客卡片风格重做」 (276848d)时把原有的这一行弄丢了;手机端的密度同样被它拦着,一并核过。pages/blog/_menu.scss的.current-category .badge是死规则:唯一渲染.current-category的src/app/components/AdminArticlesManager.tsx里没有.badge子元素。一旦有人把它加回去,浅色主题下就是白字压半透明白底。pages/_checkin.scss的@keyframes btnPulse光环写死浅色主题品牌蓝rgba(37,99,235,…),暗色下与按钮本体(#23A5FF)不同色 —— 只在 1s 的脉冲里可见, 且站内没有「品牌色 + 指定透明度」的令牌可用,暂留。
2026-09-18 已修(第五轮:样式写了、但没写到能命中的地方):
判据:类名在 .tsx 里用了,编译产物里没有定义。与第四轮的区别是这个筛选不看 旧
rebuild.css有没有 —— 这几处从来没在任何一个文件里有过定义, 所以「旧文件里有过的才补」那轮把它们全漏在了名单外。起因是第四轮之后重新扫了一遍(这次连
.tsx里带${}的模板串一起解析)。 扫出的缺口分两种,第二种更隐蔽:规则写了,但选择器谁也命中不了。这一轮起由
tests/unit/css-tsx-classes.test.ts盯着(见 §1)。
收银台
/fish/pay的四个类写在 JSX 里、CSS 从来没有过:.pay-quick/.pay-quick__btn(快捷金额那排)落回浏览器默认按钮 —— 灰底、方角、Arial 13px, 且三颗按钮彼此零间隙;.pay-amount__input少了金额档,同一个PayForm换个 variant 金额就从 24px 粗体掉成 14.4px 常规体;.pay-amount__error与正文同色,读不出是报错。 修法是补上它们的market-*孪生类(market-quick/market-quick__btn/market-amount__input/market-field__hint--error)——_fish-pay.scss头部 本来就写着「卡片/字段/按钮全部复用_fish-market.scss的类」,这几个类名漏了而已。 只有.pay-quick__hint在市场上没有对应物,写在_fish-pay.scss。剪贴板编辑器:
.clipboard-form__editor/__fallback的嵌套层级写错 (_clipboard.scss里落进了&__group内部)→ 编译成.clipboard-form__group__editor这个 DOM 里不存在的选择器。规则一直在,只是没人能命中它:#clipboard-editor于是吃编辑器自带的1px 描边 + 3px 圆角(当时是 Vditor 给的默认外观),既没有 页面底色也没有聚焦光晕(违反 §4.2 的三条)。b81a201 把外观从行内 style 挪进 SCSS 时就是这么错的。 ⚠️ 今天的反向禁令:那一段规则整块删掉了,编辑器外观由.md-editor(components/_markdown-editor.scss)承担。别为#clipboard-editor再写选择器 —— 这个 id 现在挂在.md-editor自己身上,而 id(1-0-0)会盖掉.md-editor(0-1-0) 的border: 1px solid,编辑区静默少一圈描边。禁令写在_clipboard.scss里 那段空位注释上(那里原来是那条规则的位置)。.chat-search-item__time漏了white-space: nowrap:fmtTime输出 「09-18 19:00」中间那个空格是折行点,作者名一长,flex 就把它压到 min-content 并在空格处断成两行(同一次提交里__head/__author/__text都写了,漏了这一条)。两个死类名删掉了:
.article-checkbox(AdminArticlesManager)与.toggle-featured(AdminBlogActions)—— 它们一直以「JS 钩子类」的名义被引用在 守卫的注释里,作为「.tsx 类名不查」的理由,但全仓没有任何 JS 消费它们 (复选框与按钮都是 React 状态驱动的)。这不是「刻意无样式」,是死代码。
2026-09-18 已修(第四轮:SCSS 拆分漏搬的那一整块):
判据:类名在代码里还在用、编译产物里没有定义、而旧
rebuild.css里有定义。 这类故障构建不失败、tsc 管不着、肉眼也未必立刻发现(很多只是间距/圆角差一点), 直到有人看见「某个按钮没有样式」才暴露。起因:收藏夹的「选择文件」按钮顶着浏览器默认样式。根因是
02f6ab5(SCSS 拆分) 把app/static/css/rebuild.css(1892 行,末尾有一整段注释为「补齐审计发现的 『用了但没定义』的类」的补丁层)拆进src/styles-scss/时,那一整块没有跟着 搬过去,随后旧文件被删除。扫描确认:仍在引用的 1105 个类名里 145 个没有定义, 其中 82 个旧文件里有过。已分批补齐 —— 文件选择器、工具页(hex 查看器 / 进度条 / 面板 / 算法徽章)、工具类(间距 / 显示 / 弹性 / 栅格 / 文字)、后台残余 (.card/.table/.badge-*/.page-item/.form-row/.wrap…)。
enhanceFileInputs只在整页加载时跑过一次,而 Next 的<Link>跳转不重载文档 —— 工具页与「我的收藏夹」的入口都在工具箱,所以从常规入口进去时那颗 file input 是 init 之后才挂上的,自定义包装根本没生成。现由 base.js 的 MutationObserver 补做(只在新增节点是/含 file input 时才调度,rAF 去重)。找这类时序问题的判据: 整页刷新正常、点链接进去不正常 → 十有八九是「只跑了一次」。- 刻意不补的两个(写在这里免得下次扫描又当成缺口):
.children—— 旧规则margin-left: 24px + padding-left: 16px会与.comment-list已承担的缩进叠加,让每层楼向右溢出 15px(§12 上面那条横向滚动条)。.comment-item的细线则恢复了(纯纵向,不影响溢出)。 ⚠️ 那条.comment-item .children .comment-item:first-child不能删、也不能顺手 「整理」成> .comment-list >:children这个类名令牌在编译产物里只由它提供, 而tests/unit/css-tsx-classes.test.ts要求 tsx 里出现的每个类名都有定义 —— 改掉它那道守卫会当场红(CommentSection.tsx的<ul className="children comment-list">)。.modal-dialog-centered—— 现在的.modal.is-open已是 flex 居中、.modal-dialog还有margin: auto;补上它反而把对话框变成 flex 容器、压过现有居中。
- 「用了但没有样式」里有一批是正确的(纯语义包装 / 占位修饰类 / 命名钩子)——
完整名单连同逐条理由在
tests/unit/css-tsx-classes.test.ts的UNSTYLED/CONSUMED, 这里不重抄:.chat-msg__reply-text(属性全部继承自父级 button)、.fish-card__body、.fish-card__link-label、.home-grid-item、.clipboard-markdown-content、.rc-medal、.nf__btn--ghost与两个*__btn--secondary(空规则占位)。下次扫描报出这些不必再查一遍。 .form-hint/.file-hint看着像.form-text的重复,其实不是:它们所在的 元素挂的是.text-muted(只给颜色),删掉就只剩一行没有字号/行高/上边距的裸字。 判断「某类是不是纯遗留」要连同一个元素上还挂了什么一起看,不能只比规则内容。- 两个新守卫:
tests/unit/css-js-classes.test.ts(JS 注入的类名必须有定义 ——.filepick那套 DOM 在.tsx里一个都搜不到,图标那条检查扫不到它)与tests/unit/base-js-filepick.test.ts(含「客户端路由跳转后插入的 input 也要被增强」)。
2026-09-18 已修(第三轮:同名的类拆开):
判据:同一个类名出现在两处、而两处指的不是一回事(同名嵌套最典型)。这类 bug 不报错也不变色,只是让组件白拿一份页面级容器样式 —— 内缩、外边距、滚动容器全跟着走。
.blog-detail同名嵌套(本节此前登记为遗留):src/app/components/CommentSection.tsx的根节点原来是className="blog-detail",与src/app/blog/[id]/page.tsx的正文外层<article>同名 → 评论区白拿max-width: 940px/margin: 50px auto/padding: 0 16px/overflow-x: auto。后果有二:评论块左右比正文卡各宽 4px(窄屏反而窄 16px), 以及自带一个滚动容器(tests/e2e/comment-layout.spec.ts里那条横向滚动条正是画在 它身上的)。现改用与正文同列的.blog-content-container-container(MarkdownRenderer的根节点用的也是它):左右边缘与正文卡完全对齐,桌面/窄屏两个断点都对,且檐沟只有一份 定义(桌面 20px / 窄屏 0),不会 drift。别在评论根节点上另写 padding。 顺带丢掉原属于.blog-detail的 50px 上外边距 —— 评论区与操作区的间距回到.read-controls自己的 40px(窄屏 30px)。该类名从此只属于页面正文外层。 评论区自己需要滚动容器时写.comment-section > .comment-list(2026-09-19 起, 刻意留的那一条,见 §6.7),别再给.blog-content-container-container或评论根节点 写overflow-x—— 那正是这一轮拆掉的东西。- 剪贴板详情页的「白卡 → 灰框 → 白卡」(2026-10 已修)—— 同一形状的非同名版本,
这条扫描抓不到:
clipboard/[id]/ClipDetailClient.tsx复用MarkdownRenderer渲染正文, 而它的根节点.blog-content-container就是博客详情页的文章卡本体(白底 / 30px 圆角 / 40px 内距 /max-width: 900px/margin: 40px auto 60px)。剪贴板详情页自己已经有一张 白卡(.clipboard-detail),中间还垫了一块灰底(.clipboard-detail__content)→ 于是 内卡被 900px 掐窄,宽屏下两侧各露 64px 灰边、上下各多出 56 / 76px 灰(那 40 与 60 是 博客页留给自己的外边距,与这里无关)。类名不同,所以「同名嵌套」那套判据看不见它 —— 判据要换成「我复用的这个组件,根节点自带什么页面级外观」。 现由_clipboard.scss的&__content就地抵消那张卡的外观(max-width/margin/padding/background/border-radius清零),本页回到与博客详情页同构的 「一页一张白卡」;.blog-content-container那套正文排版(代码块 / 引用 / 行内代码的 灰底——本来就是为白卡设计的)一条不动。_blog.scss不改,那是博客那条管线的版式。 ⚠️ 覆写锚在.blog-content-container这个类名上:哪天MarkdownRenderer换了根节点 类名,它会静默失效,表现就是本页又长回那张内卡。 - 其余同名嵌套的风险说明见本节上方「同类陷阱」,维护清单见
docs/plan/maintenance-plan.md§1。
2026-09-18 已修(第二轮:滚动条 / 左侧竖条收尾 / 幽灵引用):
- 滚动条:全站此前既没有滚动条样式、也没有
color-scheme,暗色主题下浏览器 照画浅色滚动条(讨论区主区与侧栏、博客评论区都看得见)。见 §8。 - 最后一条装饰性左侧竖条已删:
pages/_chat.scss的.chat-msg__content--mention及其右侧镜像(box-shadow: inset ±3px 0 0)。同一文件的.chat-chan.is-active也是这一轮删的(它是漏网的伪border-left)。 - 评论区的横向滚动条:
.comment-list同时写了width: 100%+padding-left: 15px+margin-left: 15px—— border-box 下 padding 算进 100%、margin 不算,于是每层楼中楼 向右溢出 15px,冒到最近的滚动容器上画出横向条。缩进只保留padding-left。 另加tests/e2e/comment-layout.spec.ts钉住(登记在RESPONSIVE_SPECS)。⚠️ 2026-09-19 起顶层列表又成了滚动容器 —— 是刻意的,不是上面这条回归。 深层楼中楼被
.comment-item的 min-width 撑宽后,溢出只允许由.comment-section > .comment-list接住(那里有overflow-x: auto),于是整片评论区 只有一条横条,而页面与.blog-detail仍然不滚。上面「缩进只保留padding-left」的 判据不变:每层再叠一个同值margin-left会让溢出提前发生、且发生在每一层上。 完整口径(含 240px 的来历与:has()那条)见 §6.7「评论区的两条几何契约」。 - 幽灵引用:
pages/blog/_blog.scss的.read-hero { background: var(--background-color) }(--background-color从未定义 → 标题带一直是透明的,死声明已删)、src/app/components/AdminCategoryEditor.tsx的var(--ink-3)→--fd-ink-3。 - 写死色:
pages/_audit-logs.scss的#198754→--color-success-primary;pages/_tool-new.scss的.tag-primary两支rgba(59,130,246,…)→--color-accent-blue-soft;public/static/js/core/base.js里<meta name="theme-color">的浅色值#FBFBFD→#F8FAFC(与--color-background-page对齐)。
2026-09 上旬已修(本节曾列为遗留,勿再按旧描述排查):
- 通知未读底令牌的拼写错误 —— 浅色侧
--color-background-card-unrend已改为-unread,浅色未读底色首次真正生效。 --box-bg/--text/--muted-color三个幽灵变量及其引用规则已删除;--color-brand-primary-rgb、--color-bg-primary、--r-pill、--color-brand-primary-dark也已换成真实令牌。pages/_tool-new.scss里那套不生效的body.dark-mode第二配色已删除;components/_form-controls.scss与pages/blog/_blog.scss里注释掉的旧变量表 一并清掉(它们是幽灵引用的源头)。.form-control的!important已去掉(它曾压掉自己的聚焦态)。