前端样式规范
概述: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 |
常规边框 |
--color-border-unread |
#f1c40f |
未读高亮边框 |
--color-border-highlight |
#007bff |
聚焦 / 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 阴影
| 变量 | 浅色 | 深色 |
|---|---|---|
--shadow-xs |
0 2px 10px rgba(0,0,0,.05) |
rgba(0,0,0,.2) |
--shadow-sm |
0 2px 10px rgba(0,0,0,.1) |
rgba(0,0,0,.25) |
--shadow-card |
0 4px 20px rgba(0,0,0,.08) |
rgba(0,0,0,.3) |
--shadow-card-hover |
0 8px 30px rgba(0,0,0,.12) |
rgba(0,0,0,.4) |
--shadow-card-brand |
0 2px 25px rgba(37,99,235,.15) |
0 2px 20px rgba(35,165,255,.1) |
--shadow-focus-brand |
0 0 25px rgba(37,99,235,.28) |
0 0 20px rgba(35,165,255,.32) |
卡片 hover 统一升到 --shadow-card-brand(品牌色光晕),同时边框切 --color-border-highlight。
--shadow-focus-brand 是聚焦态专用的光晕:与 --shadow-card-brand 同色同模糊半径,
但偏移为 0(四周均匀),不透明度高一档 —— 常驻阴影是「托住」元素,聚焦态得自己站得出来。
不要拿它当常驻阴影用。
它是全站字段唯一的聚焦反馈(见 §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. 排版
- 未引入 web font,使用系统字体栈(
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, …/system-ui)。 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即可。
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+site-width-ladder吃在自己身上即可。/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元素,别把 宽度阶梯也加上去 —— 行宽上限已经比阶梯窄了)。顶栏:
.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 卡片
首页
.feature-card(「探索」区四张功能卡):空闲不托阴影也不描边,只有 卡片色背景 + 30px 圆角;hover 才亮起光晕。四张卡是页面主体内容、不是浮层, 空闲就给阴影会让四块各自「浮」起来把首页切碎 —— 改成「指到哪儿哪儿才浮起来」。 禁用态(专注模式)连 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 合并到品牌色系)。
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。- 同人连续发言:只有这一串的第一条画时间 / 作者名 / 头像,后续几条加
.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-card 底 + 1px --color-border 描边。它先后用过「左侧金色竖条」与
「--color-star-secondary 星色淡底」两种身份提示:前者违反 §11 第 8 条(无左侧边框),
后者让正文里整块发黄 —— 星色是「已收藏」的状态色(§2.1 写明它只在该处与 hover 时用),
拿它当一整段正文的背景,读起来像那段内容被整个标记了。嵌入块靠形状(圆角 + 描边 +
内距)与正文分开,不靠色相。
评论区的两条几何契约
楼中楼的缩进是「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 的「不要用胶囊滑块」 |
| 杠杆档(1×…20×) | 切页档 btn-tab |
同上:6 选 1、加档位只改 LEVERAGE_OPTIONS。它不是「换一种看法」,是「这一笔用几倍」,所以明确不走 .segmented |
| K 线 / 折线 | .segmented 胶囊滑块 |
同一个视图的两种呈现 —— §6.8 的正例 |
| 自选列表选中行 | 整行 --color-brand-secondary 淡底 |
不加左侧竖条(§11.8) |
杠杆那一排还有三处判据值得记:
彩票档(100×)单独一行,不在那排里(
.trade-lottery:虚线上边距 + 「彩票档」 标签 + 选中时那段.trade-lottery__warn)。混进阶梯就会被读成「下一档」,而它是 另一个产品(见market-service的LOTTERY_LEVERAGE)。按钮仍走btn-tab—— 三档之外不自造第四档,分开靠的是位置与文案。那段警告刻意不复用.trade-field__hint--danger:后者现在的用法是「给提示行里的某一个数上色」 (一个<strong>),共用一个类就会改一处顺手改掉另一处。「强平引擎没在跑」时那几档是灰的(
disabled+opacity: .35),不是隐藏的 —— 用户看得到有这个功能、也看得到它此刻不可用,比整排消失更好解释(服务端那边也会拒单, 见docs/architecture.md§6.13 的杠杆小节)。这不是「入口跟着藏」那条红线:1 倍照旧能买。持仓行里的倍数角标(
.trade-position__lev)是徽标不是按钮(淡底 +$radius-small), 绝不能套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/vditor/dist/js/mathjax/LICENSE)。
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不受限。
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 只提阴影,不做垂直位移。 ⚠️ 这类竖条不一定写成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 注入(见组件文件头)。
⚠️ 改这类共享样式后,两处都要在页面上看一眼 —— 单测与构建都拦不住「另一边被改花了」。
⚠️ 选择器权重陷阱:删掉旧样式时要确认新规则不会被更具体的老选择器盖掉。真实踩过:
.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等),改动前先确认。
刻意保留的写死色(不是遗留,别「顺手」改掉):
- 首页
.hero-section的深色渐变(#1f2937 → #111827+ 白字)—— 明暗两套主题下 刻意保持一致的主视觉,改用令牌会让它在浅色主题下变浅底、与白字打架。 - 403 页的彩虹色相循环(
pages/_error.scss的.rainbow-error__bg)—— 同理, 它不是主题表面。 - 设置页开关的圆钮
background: #fff(pages/_settings.scss的.settings-toggle__slider::before)—— 它在两个主题下都必须是白的(开关就长这样), 换--color-background-card会让暗色主题的钮跟着变深、压在--color-border的轨道上 反而消失。可读性由那圈0 1px 3px阴影兜住。 - 画报(
src/lib/poster.ts)与 identicon(src/lib/identicon.ts)里的配色 —— 输出的是 图片,不参与data-theme。代价是它们各有一份色板副本,改品牌色时不会跟着变。 - 各处实底按钮/角标上的
color: #fff—— 那是「实底 + 白字」配方的一部分, 两套主题下都成立(约 40 处,逐一核过)。
同类陷阱(已登记,还没踩):
同名嵌套(组件根复用了页面级容器类) —— 与 .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压掉,压不住卡壳本身。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下整套重写):注入点是components/MarkdownRenderer.tsx拼的内联 HTML 字符串,所以任何用了 MarkdownRenderer 的页面都会命中 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于是吃 Vditor 自带的1px 描边 + 3px 圆角,既没有页面底色也没有聚焦光晕 (违反 §4.2 的三条)。b81a201 把外观从行内 style 挪进 SCSS 时就是这么错的。 ⚠️ 修的时候带上#clipboard-editor:.vditor与它同为 0-1-0,而vditor/dist/index.css是页面段的独立 chunk(主 SCSS 挂在 layout 段、先加载), 等权重下后到的赢 —— 不带 id 会被盖掉。.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-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已去掉(它曾压掉自己的聚焦态)。