◈ 夜宵吧

4.48 和 4.5 之间:一次全站 UI 体检

这些页面在别人的设备上真的能用吗?凭感觉答不了,于是写了个脚本,把十八个页面在三种宽度下过了一遍。

查八件事:横向溢出、对比度、触摸目标、表单标签、可访问名、标题层级、图片 alt、主内容是否为空。前五条能用数字判定,后三条要靠判断。

这篇记录修完之后回头看最有价值的部分:哪些问题是真问题、哪些是误报、以及为什么「合格」这件事不能只在一个条件下测。

查什么

自动化能查的,先列清楚:

维度 判据
横向溢出 scrollWidth > clientWidth
对比度 沿祖先找到第一个不透明背景,算 WCAG 比值
触摸目标 手机上可点元素的高度
表单标签 控件有没有 label[for] / aria-label
可访问名 按钮和链接有没有可读文字
语义 有没有 h1、有没有跳级

对比度这条最容易出错,因为计算器要自己写:把 RGB 转成相对亮度,再算 (亮+0.05)/(暗+0.05)。而且不能只看元素自身的背景色——大多数文字背景是透明的,得往上找到第一个真正上色的祖先。

跑完十八个页面,问题清单比预期长。

对比度检测的层次:文字实际所在的卡片底色才决定比值,往上找的第一个不透明祖先才是真正背景
同一行字,换一层底色就从 4.73 掉到 4.48。测哪一层,结果就不同。

4.48 和 4.5 之间

之前修过一次次要文字的对比度,把灰调深到刚好 4.73。

这次报出一批 4.47 / 4.48。

差 0.02。

原因很有意思:同一套灰,在页面底色上算出来是 4.73,在卡片底色上只有 4.48。而卡片底色比页面底色暗一点点——浅灰底上的灰字,对比度自然低一点。

而真正大量使用这个灰的,就是卡片里的英文名、体积、许可证那一堆小字。

所以「合格」不能只测一个背景。得测文字实际出现的那一层。

另一个同类问题:三套配色主题各自有主色,测出来分别是 4.47、4.51、3.87——全部擦线或不达标。它们当初是看着「顺眼」挑的,没做反算。

压深一档之后:

主色   4.47 → 5.24
       4.51 → 5.85
       3.87 → 5.05
次要灰 4.48 → 4.76

一个自己验过的反例

「合格不能只在一个背景上测」这句话,值得用一个具体数字说清楚。

浅色主题下,正文区有一行字用的是次级灰。它出现在两个地方:页面底色上的说明文、卡片底色上的英文名。

同一份 CSS,同一行字,两个背景:

位置 背景 对比度
页面底色上的说明文 很浅的蓝白 4.73
卡片底色上的英文名 略深的灰蓝 4.48

差 0.25。

WCAG AA 对正文的要求是 4.5。所以这行字在页面底色上合格,在卡片底色上不合格——而它出现在卡片里的次数,远多于页面底色里。

更麻烦的是这个差距肉眼看不出来。两种背景叠在一起时,你只会觉得「那行小字有点淡」,不会知道它其实越过了线。

反算的方法是:把目标对比度定在 4.5,倒推文字色应该多深。深一档之后,它在两个背景上的实测值变成 5.19 / 4.76,两边都过线,视觉上几乎无差别。

结论:对比度要按「文字实际出现的那一层」测,而不是按「最外面那层」。 这一次的偏差只有 0.25,但在更深的卡片底色上,同一套灰可能直接从 4.73 掉到 3.9——那就是「看起来对、实际不合格」的典型。

一条 CSS 引发的连锁

这个 bug 不在清单上,是修搜索框时撞出来的。

现象:手机上打开搜索页,输入框只剩二十像素宽,搜索按钮占满整行。

查下来是一条很早以前写的规则:

@media (max-width: 640px) {
  .btn { width: 100%; }
}

本意是让登录、注册页的提交按钮在手机上占满一行。但它写的是所有按钮。

而搜索栏是 flex 布局:输入框 flex: 1,按钮 width: 100%。按钮先吃掉整行,输入框被压到零。

同样受害的还有:评论区的发表按钮、写文章页的四个操作按钮、后台的分类改名/删除、文章历史的回退按钮。它们在手机上全都变成了整行的大色块,把旁边的文字挤没。

修法不是「给搜索框加个例外」,而是删掉这条规则:

.form-foot .btn { width: 100%; }   /* 只保留竖排表单 */

登录页的按钮照样满宽——因为 .form-foot 本身就是 column 布局,子元素自动撑满,那条 width: 100% 本来就是多余的。

一条为了局部而写的全局规则,会在别处留一串伏笔。

grid 里的 min-width

第二个连锁 bug:访问日志页在手机上横向溢出 204 像素,整个页面能左右拖动。

那个页面有两栏排行榜,grid-template-columns: 1fr 1fr,窄屏时改成单列。看起来没问题。

问题在于 grid 子项的默认最小宽度是 auto——它拒绝比内容更窄。

而排行榜里有一条不换行的长路径,它的最小宽度就是整条路径的长度。于是列被撑到五百多像素,容器被撑破,页面能横向滚动。

修法一行:

.visit-col { min-width: 0; }

这是 flex 和 grid 都有的老坑:min-width: auto 让子项保留内容的「固有尺寸」,想要它服从布局,必须显式归零。里面的文字再用 overflow: hidden 或 text-overflow: ellipsis 收尾。

二十五个没有名字的输入框

自动化报了一批:写文章页、账号设置页、访问日志的筛选栏,一共二十五个输入框没有可读名字。

它们都有 <label>,但没写 for:

<label>邮箱</label>
<input id="s-email">

肉眼完全没问题——标签就在上面。但屏幕阅读器靠 for 和 id 的关联来播报,没有关联就等于「编辑框」,不说是干什么的。

另外一批连 <label> 都没有,只有 placeholder。而 placeholder 有两个问题:输入一开始就消失,以及不是所有辅助技术都读它。

统一补上:有关联的加 for,没标签的加 aria-label。

触摸目标:24 还是 44

清单里剩下最扎眼的一项:卡片标题只有 29 像素高。

但这里有个判断要做。

卡片标题是链接,看起来太小。实际上整张卡片都可以点——点空白处也进文章。真实触摸区域是 180 像素高的整张卡,不是那 29 像素。

而真正该改的是那些独占一行的操作按钮:手机上把按钮和下拉统一撑到 40 像素。

至于正文里嵌着的行内链接(页脚那行「关于」、登录页的「去注册」),标准里对它们有明确豁免——把句子里的每个词都撑成 44 像素,排版会散架。

判断依据不是「看起来小」,是这个元素是不是独占一行。

剩下的都是判断

修完复检,清单从几十条降到十几条,剩下的全是同一类:行内文本链接。

这类东西不该继续动。检查的意义在于把「凭感觉」换成「可复算」——对比度是三个数字算出来的,横向溢出是一个减法,触摸目标是一个高度值。

而那些算不出来的:留白够不够、层次清不清楚、动效有没有分寸——还得自己看。

这套脚本查不出什么

自动化能判定的只有可计算的部分。下面这些全部要人看:

留白是否合适。 一个元素四周是 12px 还是 24px,脚本量得出来,但「对不对」只能看。

层级是否清楚。 能算出 h2 比正文大 8px,算不出读者扫一眼能不能分出层次。

动效是否克制。 能数出有几个 @keyframes,数不出哪个让人分心。

文案是否清楚。 能读到按钮上的字,读不出这句话在这个语境里有没有歧义。

所以这类检查的定位是:把机械的部分自动化掉,把人的注意力省下来给判断。它替代不了看,只是让看的时候不用再怀疑基础的东西。