chrome-plugin-manga-translator

Round 10 audit — 这一轮不查钱,查手和眼

前九轮都在问同一类问题:这句话是真的吗? 钱花在哪、有没有人授权过、界面上说的话和代码做的事对不对得上。

这一轮换了个方向:

一句话到了眼前,人接得住吗?

派了三位审查员,分别只看三件事:键盘与无障碍、错误与空状态、第一次使用。 同时自己把弹窗、页面上的卡片、引导向导,从头到尾用键盘走了一遍。

结果:钱的句子没问题了,但手和眼还有几处接不住。


P1 — 弹窗顶上那块,从两行变成了六行

现象

第九轮把「一张图可能变成多次请求」补进了弹窗的报价句。补得对 —— 图片数不是请求数,这句话本来就该在按按钮之前说。

但弹窗最上面那块于是变成这样:

本页 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 整句消失 —— 没有图就不会有请求,那时候讲「一次请求可能变多次」是废话。


P1 — 页面上那张卡,每更新一次,键盘上的焦点就丢一次

现象

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();

新卡片里没有那颗按钮时(错误卡片没有取消键),不放 —— 焦点交给浏览器, 不塞到一个不相干的地方。


P1 — 引导向导走完一步,键盘就丢了

现象

向导关掉以后,焦点没有回到打开它的那颗按钮。

根因

「记住打开前焦点在哪」和「把焦点放到主按钮上」写在了同一个 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 一个写法。


P2 — 商店文案和产品对不上(三处)

第九轮统一了「忽略缓存重翻」这个名字,但只统一了代码和清单, 漏了另外三处:

  1. 商店文案(docs/store-listing-zh.md)里的快捷键还写着 「+X 忽略缓存重译」—— 而清单、代码、审核备注都是「重翻」。 商店截图的配文和几处代码注释里也是旧名。
  2. 「自动翻译状态一目了然:……(会产生费用)」 —— 对本地服务商是假的。 改成「云端服务商会产生费用,本地服务商不会」。
  3. FAQ「某站点不生效?确认该站点在自动翻译白名单中,或使用手动触发。」 —— 手动触发在任何普通网页上都能用,白名单只管自动翻译。 手动触发失败的用户被这句话带去检查白名单,是一条错路。

P1 — 演示视频比商店允许的长了一倍

现象

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 秒 —— 上传时会被商店拒掉

模式

前九轮的题目是「这句话对不对」。这一轮的题目是:

这句话到了眼前,人接得住吗?

一句真的话,说在错误的位置、或者和另一句真话挤在一起,它的效力就抵不上 一句假话 —— 用户读不完六行灰字,于是干脆不读。

这一轮改的都不是文案,是位置:报价和注解分层,焦点跟着按钮走。