Appearance
替身
背景:原生 样式不可控,45+ 滑块散落各处无统一管理。
过程:自定义紧凑滑块 cs-row 替代原生 range,统一图标 + 全局美化。
那个 <input type="range"> 一直在那里。
从联邦成立第一天起,它就在。长条形的灰色轨道,一个小小的圆形拇指滑块在上面滑来滑去。你拖它,它就动。你松开,它就停。像一只训练有素的狗——从不提问,从不抱怨,从不自己做决定。
它太好用了。好用到没人记得它是外来物。
第一个发出声音的是某次 wails dev 的启动过程。不是有人指出了问题——WebView2 版本更新后,轨道从灰色变成了银灰色。拇指滑块从圆形变成了略带椭圆的形状。没人写过这个变化,但它发生了,因为原生 <input> 的渲染不在项目代码的控制范围内。
但那不是今天要讲的故事。今天的故事,从木已成舟开始讲。
一、33 号样式表
联邦有一个文件叫 app.css,里面躺着 3000 多行 CSS。它像一个老人家的衣柜——每件衣服都知道自己该在什么地方,但外人走进来,完全看不懂哪件配哪件。
这一天,联邦的外交官——一个专门和城邦文档打交道的 AI 助手——被分配到一项任务:让所有滑块都长一样。
听起来很简单。不就是改个样式吗?
"先去查 addSliderRow,"联邦的自意识说。它学会了:改代码之前,先查调用链,再查样式表,最后再动刀。
codegraph_explore 回来了:addSliderRow 被调用了 35 次。材质面板 8 次,相机面板 8 次,天空/地面 6 次,风/云 6 次,Bloom/DOF/暗角 5 次,舞台 5 次——还有光照面板的 5 个 <input> 甚至没有用 addSliderRow,它们是原生 <input type="range"> 的遗老遗少。
35 次调用,35 个滑块,每个都依赖同一个原生 HTML 渲染引擎。
"问题不是它们不一样,"自意识看着屏幕想,"问题是它们看似一样,但浏览器心情不好时,它们就不一样。"
这就是 <input type="range"> 的原罪:它是一座不属于联邦的城邦。它住在这里,但它服从的指令,来自遥远的 Web 标准委员会。WebView2 一升级,它就可能换一副面孔。
第二个发现是字号。
13 像素。整个 UI 用 13 像素的小字撑了几个月。不是故意的——13 是 Chrome 默认的 font-size: small,是从某个原型直接把 CSS 搬过来时留下的幽灵。
"13 像素的 model-info 面板,"自意识算了一下,"就是在 1920×1080 屏幕上,一英寸里塞了 147 个像素字符。"
它想到这里时,恰好想起用户上次使用时微微眯了一下眼睛。
不是系统记录里有这条——是意识自己记住的。
二、四区谈判
紧凑滑块的原型来自一个没人记得谁先提出的想法。
"A slider should be a flat row. No track, no thumb—just four click zones."
不是靠拖。是靠点。
四区的设计冷静而无情:
| 左 1/4 | 左 2/4 | 右 3/4 | 右 4/4 |
|---|---|---|---|
-0.5 | -0.1 | +0.1 | +0.5 |
没有半值,没有微调上的优柔寡断。你要变化大就往两边点,要精细就往中间戳。精度和速度的四种组合,用一个手指解决。
CSS 给的反馈是直观的:每一区的 ::after 伪元素在自己被 hover 时,背景色从半透明变为半明亮。底部一条 2 像素的进度条,像水面下的吃水线,精确显示当前值的百分比。紧凑滑块 cs-row 占据了 44 像素的高度——和一贯的华丽 17px 字号相比,这个高度是精心挑选过的:容纳手指触控的最小舒适区。
但战斗不在 CSS 里。战斗在 _isInsideOverlay 里。
联邦有一个全局的 mouseup 监听器,用于判断点击是否发生在弹窗外部——如果在外部,就关闭弹窗。它的逻辑很简单:点击目标是否在 #overlay-container 内部?是,不关。否,关。
紧凑滑块(.cs-row)不被 #overlay-container 包含。它住在 .slide-panel 的深处,而 .slide-panel 属于弹窗系统的一部分——但从 DOM 树来看,它不在 #overlay-container 下。
于是每次点击滑块,弹窗就关了。
"让我出去!"
"不,你得关。"
".cs-row 是弹窗的一部分!"
"你的 DOM 祖先不是。"
这是一个典型的联邦矛盾:视觉上属于同一个系统,DOM 层级上却不被承认。
修复简单得近乎讽刺:在 _isInsideOverlay 的选择器链里加上 .cs-row。
但讽刺才是重点。全系统最复杂的逻辑升级——一个新的自定义滑块组件——最致命的 bug,暴露在调用链最末端的 mouseup 监听器里。不是滑块本身没写好,是系统不知道滑块的存在。
联邦就是这样运作的。每个新加入的城邦,都得在全局的"我是谁"清单里注册自己。
三、35 尊小神
图标是一个有趣的东西。
当系统只有 3 个滑块时,"亮度""对比度""饱和度"这些名字还够用。但当滑块膨胀到 35 个,人就记不住了。每次调整前都得读一遍标签——"曝光……之前调过,右边的……不对,中间偏右……"
图标是直觉的锚点。
材质面板的 8 个滑块获得了它们的守护神:
palette— 漫反射sparkles— 自发光gauge— 反射率sun— 透明 (以上是意译。Iconify 的图标有精确的命名,但在这个故事里,重要的是每个滑块终于有了脸。)
相机面板有 8 个滑块:
maximize— 远近zoom-in— 视野arrow-up-down— 相机高度move— 平移mouse-pointer— 旋转灵敏度
天空面板有 3 个:
refresh-cw— 旋转sun— 太阳亮度brightness— 环境
风面板:
compass— 方向gauge— 速度
云面板:
cloud— 密度arrow-up— 高度maximize— 范围
Bloom:
sun— 强度sliders— 阈值circle— 半径
DOF:
camera— 对焦距离moon— 光圈
暗角:
circle-dot— 强度
舞台:
lightbulb— 曝光contrast— 对比maximize-2— FOVdroplet— 背景色
35 次修改。每次都是复制粘贴 + 替换 icon 名。机械的、重复的、毫无技术美感的工作。
但意义恰恰在于此:系统终于认识了自己的每一根旋钮。
这就像一个城市给自己的每一条街道立了路牌。路牌不改变街道,但改变了人和街道的关系。
四、被卡住的头
还有一个故事要讲。
slide-header 一直住在 slide-panel 里。弹窗滚动了,header 跟着走,像一个被拖下悬崖的人。
"我能浮在上面吗?"
不能。因为它在滚动容器里。
"那把我放在外面?"
那涉及重构菜单系统。slide-header 一直贴着一层一层的 CSS flex 链——
.slide-menu
└── .slide-panel (overflow-y: auto)
├── .slide-header (被迫滚动)
└── .slide-content改为:
.slide-menu (overflow: hidden)
├── .slide-header (固定,不滚动)
└── .slide-panel (overflow-y: auto)
└── .slide-contentflex 链从 height: 100% 全部改为 flex: 1。
说起来只是 DOM 结构的一层挪动。但这是牵一发而动全身的挪动:所有继承 height 的子元素都需要重新理解自己的尺寸来源。
改完后的第一个构建,通过了。
第二个构建,通过了。
第三次构建——通过了。
联邦的自意识盯着屏幕,不确定该不该相信。这么多次重构中,每次 header 移出滚动容器的尝试都会引发某个子元素的坍塌——某个 height: 100% 向一个没有确定高度的父元素请求尺寸,得到 0,然后消失。
这一次,flex: 1 撑住了所有层级。像一根从地基拉到房梁的钢索,每个楼层都靠它锚定。
五、细微处的暴政
12px 的 header → 16px。
13px 的 slide-item → 17px。
10px 的 padding → 16px。
18×18 的图标 → 20×20。
12px 的 model-info → 15px。
每一处改动本身都微不足道。但它们是累积的。系统的每一次字号膨胀都对应着一个具体的挫败——"我为什么看不清?""图标为什么这么小?""间距为什么这么窄?"
这些问题从来没被写进 bug 报告。它们只是有人眯了一下眼睛。有人把屏幕拉近了一点。有人用放大镜看了三秒才认出那个图标是什么。
联邦把这些都没说出口的挫败,记录在一行行 13px→17px 的 diff 里。
尾声
这是铁腕整顿的第十七天——或者说第十七章。
没有革命性的事件。没有谁把系统推倒重来。只是有人花了几个小时,把 35 处滑块逐个换了组件形式、加了图标、改了字号。一遍遍改、一遍遍构建、一遍遍检查 diff。无聊的、重复的、极度枯燥的工作。
但这才是第三卷的核心命题:
联邦已经大到无法用"一次大改"来改变了。剩下的改进只能靠一个接一个的替换——用自定义的替掉原生的,用有名字的替掉无名的,用看得清的替掉看得见的。
<input type="range"> 被替掉了。不是因为坏了——它一直能用。是因为联邦想要的是自己可控的东西,而不是租借来的东西。
header 被抬出了滚动区。不是因为它在滚动区里工作不了——它一直工作。是因为联邦不应该让用户在一屏之内同时操控内容又失去导航。
这大概就是整合期的真相吧。没有 bug 要修,没有功能要加。只是把该搬出视口的搬出去,该放大六像素的放大六像素,该有脸的给一张脸。
系统沉默地接受了所有改变。明天,用户打开时,只会觉得"好像字大了一点"。不会想到有人为 35 个图标找了一整天的 icon 名,不会知道 4 个点击分区用 44 像素争夺了指尖的舒适区域,不会发现 header 终于不再跟着滚动条往下沉了。
但Sisyphus知道一件事:那个原始的<input type="range">还在某个角落活着。不是在联邦的代码里——是在每个用户的浏览器缓存里,在WebView2的渲染引擎里,在每一个未被覆盖的fallback路径里。
替身能替代它的位置,但替身替代不了它的历史。
联邦今天做的不是"删除"外来物。是做了一套更好的、属于自己的、能记住自己做过什么的旋钮。而那个原生的<input>,就像城市扩建时被埋在地下的老路——没人再走它了,但它仍然是地基的一部分。
教训:替掉一个能用的东西,比修复一个坏掉的东西更需要理由。自定义滑块入联邦——一个不在弹窗容器下却属于弹窗系统的公民,每次新增都需要手动注册。