前九轮都在问同一类问题:这句话是真的吗? 钱花在哪、有没有人授权过、界面上说的话和代码做的事对不对得上。
这一轮换了个方向:
一句话到了眼前,人接得住吗?
派了三位审查员,分别只看三件事:键盘与无障碍、错误与空状态、第一次使用。 同时自己把弹窗、页面上的卡片、引导向导,从头到尾用键盘走了一遍。
结果:钱的句子没问题了,但手和眼还有几处接不住。
第九轮把「一张图可能变成多次请求」补进了弹窗的报价句。补得对 —— 图片数不是请求数,这句话本来就该在按按钮之前说。
但弹窗最上面那块于是变成这样:
本页 2 张可翻译图片,翻译会调用模型服务,会产生费用。一张图可能变成 多次请求:长条图会拆成多片分别请求,模型没读出文字时还会用 OCR 再分批 试一次,请求超时或失败时还会自动重试一次。实际请求次数可能高于图片数。
六行 12px 灰字。用户要的那一句(「2 张、会花钱」)是第一行,后面跟着四行 注解,然后才是那颗按钮。
「这一页有几张图」和「计费是怎么算的」是两类事实,被印成了同一段。
前者关于这一页,后者关于这个产品怎么收钱。混在一段里,两边都读不出来: 读数字的人被注解打断,读注解的人找不到上下文。
拆成两层,各有各的视觉重量:
| 字号 / 颜色 | 内容 | |
|---|---|---|
| headline | 12px slate-300 |
本页 N 张可翻译图片,翻译会调用模型服务,会产生费用。 |
| caveat | 11px slate-400 |
一张图可能变成多次请求:……实际请求次数可能高于图片数。 |
caveat 没有被藏起来,也没有被折叠进「详情」——它只是不再和按钮抢注意力。
颜色是量过的:slate-400 落在卡片填充色(rgba(255,255,255,0.08) 压在
slate-950 上)上是 6.8:1,过 WCAG 的 4.5:1;slate-500 只有 3.7:1,
不能用。
顺带修掉的一句噪音:这一页没有图的时候,caveat 整句消失 —— 没有图就不会有请求,那时候讲「一次请求可能变多次」是废话。
HUD(页面上那张小卡片)是一整块 innerHTML:
hud.innerHTML = this.renderState(state); // 进度每动一次,整块换掉
键盘用户按 Tab 找到「取消当前翻译」,下一次进度跳动,那颗按钮连同焦点
一起没了 —— 焦点掉回 <body>,用户得从页面开头重新 Tab 一遍。
更糟的是重试按钮。第九轮给它加了两步确认:第一次点击只是「上膛」, 按钮变成「再点一次确认:重新翻译 3 张」,第二次点击才真的发请求。
而「上膛」这件事本身就是一次 render() —— 也就是又一次整块替换:
键盘用户按回车上膛 → 按钮被换掉 → 焦点没了 → 第二次回车按不到那颗按钮 → 上膛等于白上。
记住焦点,换完再放回去:
const focused = this.shadow.activeElement; // shadow root 里的焦点
const focusedId = focused instanceof HTMLElement ? focused.id : null;
hud.innerHTML = html;
if (focusedId) this.shadow.getElementById(focusedId)?.focus();
新卡片里没有那颗按钮时(错误卡片没有取消键),不放 —— 焦点交给浏览器, 不塞到一个不相干的地方。
向导关掉以后,焦点没有回到打开它的那颗按钮。
「记住打开前焦点在哪」和「把焦点放到主按钮上」写在了同一个 effect 里,
依赖数组是 [visible, step]:
useEffect(() => {
if (!visible) { /* 归还焦点 */ return; }
previouslyFocused.current = document.activeElement; // ← 每换一步就覆盖一次
primary?.focus();
}, [visible, step]);
每换一步,这个 effect 重跑一次,而此时 document.activeElement 已经是
弹窗里那颗主按钮了 —— 于是「打开前的那颗按钮」被覆盖成弹窗内部的
按钮。等向导关掉,记住的是一个已经卸载的元素,isConnected 为 false,
焦点被丢在页面上。
弹窗里的 ConfirmDialog 早就写对了(它用 wasOpen 只在 false→true 的
那一次记住),向导是漏掉的那个。
拆成两个 effect:记住的那颗只在 visible 由 false 变 true 时记一次,
归还只在 visible 变 false 时做。和 ConfirmDialog 一个写法。
第九轮统一了「忽略缓存重翻」这个名字,但只统一了代码和清单, 漏了另外三处:
docs/store-listing-zh.md)里的快捷键还写着
「+X 忽略缓存重译」—— 而清单、代码、审核备注都是「重翻」。
商店截图的配文和几处代码注释里也是旧名。store/listing/demo.mp4 是 45.8 秒。商店的上限是 30 秒:
An optional promotional video uploaded with a Chrome Web Store listing must use MP4 or WebM and be no longer than 30 seconds. —— developer.chrome.com/docs/webstore/images
超长的视频在上传时就会被拒 —— 那时商品详情已经写完、审核已经排队。 而项目自己的上架文案里就写着「演示视频 | ≤30 秒 MP4」:文档知道规矩, 产物没有遵守。
视频是「冷启动」录的:录屏一开始就去翻那两页整漫画,而模型翻它们要好几 分钟。三十秒录下来,片子里全是「正在翻译」,一个字译文都没出现。
所以它既超长,又没有内容。
先真的翻一遍(这一次花钱),等它翻完;再录命中缓存的第二遍。缓存命中时 译文一两秒就落到原图上,于是三十秒里有完整的故事:扫描 → 翻译 → 译文盖 在原图上 → 阅读面板 → 往下滚一遍。
长度不再是”希望”而是检查:脚本把成片的长度读回来,超过 30 秒就失败,
并在报错里写明该拧哪个旋钮(RECORD_SECONDS / --hold / --warmup)。
顺带说明为什么以前会超长:录屏窗口是 30 秒,加上”翻完之后停 4 秒”和 “慢慢滚一遍再滚回顶部”约 5 秒 —— 45 秒就是这么来的。新的预算是 18 + 4 + 5 = 27。
| 测试 | 去掉修复后 |
|---|---|
| 弹窗的价格行与注解分成两句 | describeImageCount 返回一段话,断言 headline 不含注解 |
| 进度更新后焦点还在取消键上 | 焦点掉回 <body> |
| 上膛之后焦点还在重试键上 | 焦点掉回 <body>,第二下回车按不到按钮 |
| 向导走完一步再关闭,焦点回到触发按钮 | 焦点落在页面上 |
| 商店文案的快捷键名 | 清单与文案对不上(check-store-claims 失败) |
| 演示视频的长度 | 45.8 秒 —— 上传时会被商店拒掉 |
前九轮的题目是「这句话对不对」。这一轮的题目是:
这句话到了眼前,人接得住吗?
一句真的话,说在错误的位置、或者和另一句真话挤在一起,它的效力就抵不上 一句假话 —— 用户读不完六行灰字,于是干脆不读。
这一轮改的都不是文案,是位置:报价和注解分层,焦点跟着按钮走。