
一个 CSS 变量、五轮评审,还有一个撒谎的 Swift 工具链
GeekBye v2.0.7 让整个悬浮层变得可调节的半透明 — 这听起来像是一行 CSS 的改动,但绝对不是。真正的故事在于代码评审抓到了什么: 两个死活不肯变淡的面板、一个在同样透明度下看起来不对劲的录音条,还有一个来回变化了 672 字节的编译二进制文件,只因为我们自己的文档告诉了评审者一个错误的 Swift 版本。
"把悬浮层做成半透明"是那种听起来只需要一个滑块和一个 CSS opacity 属性的任务。GeekBye v2.0.7 交付的正是这个面向用户的结果 — 一个可调节的"浅色模式",让你能透过助手的面板看到背后的任何东西。但这次发布里有意思的部分不是这个功能。而是把它做对花了五轮评审,还有一段关于一个因为我们自己的文档撒谎而改变了大小的编译二进制文件的战斗故事。
让整个 UI 变半透明的正确方式
这件事最幼稚的做法,是把一个透明度值撒进每一个面板组件里然后就收工。这是一个维护陷阱: 十几个硬编码的值,只要有人碰一下其中一个,它们就开始各自漂移。
相反,v2.0.7 用的是一个共享的 CSS 工具类加上每个面板各自的令牌。 有一个单一的 overlay-surface 类,它从两个自定义属性计算出面板的背景: 一个每个表面为自己声明的 基础 透明度(控制条、聊天、转录气泡、代码块等等各自挑选自己的),以及一个挂在悬浮层根节点上、代表用户滑块设置的全局 透明度乘数。每个表面读取同一个乘数; 每个又保留自己的个性。关键在于,只有面板背景会缩放 — 文字、图标和 backdrop-blur 都保持完整渲染,所以"半透明"永远不意味着"看不清"。
这个设计里有两个决策值得单独拎出来说,因为它们正是一个帮到人的功能和一个制造工单的功能之间的区别:
- 默认值是完全不透明的。 透明度设置出厂时是 100% — 一个空操作。没有谁的悬浮层会在更新后突然变透; 半透明就在那里等你去用,在那之前它是隐形的。
- 这个设置经过防御性校验。 最小值、最大值、默认值和步长有唯一的真相来源,还有一个守护空值/缺失情况的归一化器。这很重要,因为有一个 JavaScript 的坑:
Number(null)是0,所以一个天真地被强制转换的缺失设置,会把悬浮层直接拉到它 最 透明的状态,而不是它的默认状态。归一化器在这件事发生之前就把它接住了。
拖动滑块,整个悬浮层会实时重新着色,在录音过程中也一样,因为每个表面都订阅了同一个设置。不用重启,不用重载。
评审抓到了什么(而一台显示器的测试永远抓不到)
正是在这里,"一个 CSS 变量"不再简单了。评审中浮现出三个 bug,其中两个是同一个教训披着不同的外衣。
一个不肯变淡的面板。 键盘快捷键悬浮层顽固地保持不透明,而其他一切都变成了半透明。原因: 它渲染在承载透明度变量的 DOM 子树 之外,所以它从来没有继承到从悬浮层根节点层层向下传递的那个值。CSS 自定义属性通过 DOM 树继承 — 如果一个节点并不真的是你设置变量的那个位置的后代,它就悄无声息地什么也拿不到。修复方法是在那个面板上也把变量设置一遍。
一个消失了的悬停着色。 Fn/Assist 按钮丢掉了它们青色的悬停光晕。半透明工具类用 !important 设置了背景(它必须压过表面的默认值),而这碾平了同一个元素上的 hover: 背景着色。特异性冲突。修复方法是让悬停着色同样强势,这样它才能在悬停时赢回来。
这两个都是同一个底层真相: 一个 CSS 变量的级联是一条真实的正确性边界,而不是一个样式细节。 继承和特异性决定了你的值是否连元素都到不了,而这两者在出错之前都不会出现在截图里。
同样的透明度,不同的观感。 最微妙的一个: 录音条和空闲条被设成 完全相同 的基础透明度,却依然看起来明显不一样。为什么? 录音药丸用了更重的 backdrop-blur,而深色背景上的强模糊会向着更暗的结果做平均 — 所以相等的透明度产生了不相等的外观。它还有一圈淡淡的边框,而空闲条有的是明亮的渐变环边缘。修复方法匹配了模糊等级和那圈环。教训记住了: 一旦模糊进入画面,相等的透明度就不等于相等的外观。 半透明是一套相互作用的效果的系统,而不是一个单一的数字。
那个因为文档撒谎而改变了大小的二进制文件
现在讲战斗故事,而且是我最喜欢的那一类 — 工具和文档互相矛盾,一个细心的评审者被卷进交火之中。
GeekBye 附带了一些预编译的 Swift 二进制文件(像会议检测这类东西的原生助手)。因为一个编译好的二进制文件被签入了仓库,每个开发者都必须用完全相同的 Swift 版本来编译它 — 不同的工具链会从相同的源码产出不同的字节,那意味着每次有人重新编译就会出现一个幽灵般的 git 冲突。所以有一个脚本强制使用一个特定的版本: Swift 6.3.x,在构建时检查。
在这次发布的评审中,一位评审者指出某个二进制文件应该被重新编译,以匹配"仓库规定的工具链"。开发者精确地照做了 — 而二进制文件的大小从 89,184 字节变成了 88,512。 提交信息甚至写着,它是通过用 Swift 6.2.1、即"仓库规定的工具链"重新编译,来"恢复字节稳定的输出"。
只不过 6.2.1 是错误的版本。构建强制脚本要求的是 6.3.x。问题在于 文字 文档 — README 和贡献者指南 — 在好几个地方仍然写着 6.2.1。于是一位尽责的评审者和一位尽责的开发者,都在努力做正确的事,却顺着文档一路走进了提交那个强制脚本正是为了拒绝而存在的产物。一个 6.3.x 仓库里的 6.2.1 二进制文件,正是那种会弄坏其他每个开发者干净重编译的漂移。
下一次提交修好了它 — 而这个修复正是重点。它不只是把二进制文件重新编译回它的 6.3.x 形态(大小恢复到 89,184,回到了它最初开始的地方)。它还纠正了文档里陈旧的"6.2.1"引用,这样这个陷阱就不会被再次触发。二进制文件在两次提交里来回变化了 672 字节又变回去,而真正的 bug 从来都不是那个二进制文件。而是两个真相来源 — 强制脚本和成文的文档 — 互相矛盾,而一个人相信了错误的那一个。
这次发布教会的三件事
- 一个 CSS 变量的级联是一条正确性边界。 三个评审 bug 中有两个归结为一个没有继承的变量和一个压垮了悬停状态的
!important。如果一个值必须通过继承到达一个元素,或者通过特异性取胜,那条路径就是逻辑 — 像验证逻辑一样验证它,而不是像验证样式那样。 - 相等的参数可以产生不相等的结果。 录音条和空闲条共用一个透明度,却依然看起来不一样,因为一个不同的模糊改变了结果。当你用一个数字来统一一套视觉系统时,检查一下每个表面周围的 其他 变量有没有在悄悄改变那个数字的含义。
- 当文档和强制脚本矛盾时,去修复那个矛盾 — 而不只是症状。 那个 bug 不是一个坏的二进制文件; 而是一份写了错误工具链的文档,和一个说法相反的强制脚本。重新编译二进制文件治的是症状。让每一个真相来源都保持一致才是真正的修复,因为下一个评审者也会相信文档。
GeekBye v2.0.7 交付了统一的半透明,所有边界情况都经过评审,工具链文档也被理顺了。它是那次可靠性冲刺之前,最后一个"安静匠心"系列的发布。想了解它底下的地基,见 一个真正的第二版需要付出什么(v2.0.0); 想看它的姊妹打磨发布,见 平静的软件: 闪烁修复与回答模式徽章(v2.0.3 + v2.0.5); 想知道悬浮层是如何在通话时保持隐藏的,见 如何在屏幕共享期间保持隐形。