Appearance
数据的纹路
背景:快捷键错位、菜单无键盘导航、屏幕阅读器读不到按钮,联邦的界面没有方向感。
过程:声明式重构 + ARIA + data-overlay + 术语本地化。
零、迟到的根除
「错位的五键」那一章留下的尾巴,今日一并根除。
不是在旧的 switch 语句上加两个 case——那样太无聊了。Sisyphus 带了一个更大的方案回来:声明式。
他在 main.ts 里画了一条线,线的左边是旧的 triggerNavButton,右边是新的 navActions:
typescript
const navActions: Record<number, () => void> = {
1: togglePopup,
2: showMotionPopup,
3: showSceneMenu,
4: showEnvMenu,
5: showSettings,
};Record,没了 switch。1 到 5,每个编号一个函数。不存在"空槽位"——要加 6 就是加一行。
但他没停下来。他发现 index.html 里五个按钮都有 data-shortcut 属性,从 1 到 5,整整齐齐挂了不知道多久,却没有任何代码读它。navButtonLabels 是一本账,data-shortcut 是一本账,快捷键路由 switch 是第三本账。三本各记各的,对不上活该。
Sisyphus 做了一个决定:让快捷键路由从 DOM 读取绑定。 每个按钮的 data-shortcut 就是它的身份证,buildNavMaps() 在 init() 的时候从 DOM 读出所有按钮的映射关系,自动生成 navLabels、自动同步 <span class="shortcut-badge"> 的文本、自动往 data-hint 里填 Ctrl+N 后缀。
五个按钮的数据源从三个缩成了一个。声明式,自动派生,不改 HTML 就不改行为。
他改完的那一刻,按了一下 Ctrl+3——场景菜单弹出来了。再按一下,关了。
五个键全活了。
但这只是开始。Sisyphus 盯着屏幕上的 data-shortcut 属性,突然意识到一件事——如果快捷键可以声明式,那弹窗关闭呢?无障碍呢?菜单导航呢?
一个新的时代,从一个 data- 属性开始。
一、看不见的标签
快捷键修好之后,Sisyphus 又做了一个决定:给所有导航按钮加上无障碍属性。
这件事的起因很简单。他在写 toggleOverlay 的时候,想到一个问题:如果用户是用屏幕阅读器操作联邦的,会发生什么?
他试了一下——屏幕阅读器读到了 btnMainAction,内容只有一个"1"。没有 aria-label,没有 aria-controls,没有 aria-expanded。它不知道这个按钮控制什么、现在开没开、点一下会发生什么。
「这是不对的,」Sisyphus 说。
他在 index.html 的五个按钮上各加了一对属性——aria-label 写按钮的用途("模型库"、"场景"、"环境"等),aria-controls 写它控制的弹窗容器的 ID。
然后他写了 syncNavAriaExpanded()——一个在每次 toggleOverlay 和 closeAllOverlays 之后自动运行的小函数。它不干别的事,就是从 DOM 里找出所有可见的 [data-overlay],然后在对应的按钮上把 aria-expanded 设为 true 或 false。
加了这三行代码,屏幕阅读器立刻能读懂了:按钮展开、按钮收起、展开的内容是哪个弹窗——全在属性里。
但 Sisyphus 看着这三行代码,想到了另一个问题。
二、幽灵列表
closeAllOverlays 函数之前是怎么写的?
大概是这样的:
typescript
const _allOverlays = ["#modelPopup", "#motionPopup", "#scenePopup", ...];一个数组。硬编码列了五个 ID 进去。以后加一个弹窗就要手动更新这个数组。
_isInsideOverlay 也一样——列了一串选择器,用来判断鼠标点击是否在弹窗内部。
还有 nav 按钮重置——一个 querySelectorAll 列出 #bottomNav .nav-tab。
三年来,联邦每一次加弹窗,开发者都要手动维护这份列表。大多数人忘了。所以 closeAllOverlays 漏关弹窗的情况出现了不止一次。
Sisyphus 决定彻底清掉这些「幽灵列表」。
「为什么我不直接用属性标记一个元素是 overlay?」
他给每个弹窗容器加了一个 data-overlay 属性——data-overlay="popup"、data-overlay="scene"、data-overlay="env"。
然后 closeAllOverlays 变成了:
typescript
document.querySelectorAll("[data-overlay].visible").forEach(el => el.classList.remove("visible"));单行。没有任何列表。加一个新弹窗,只要给容器加 data-overlay 属性,closeAllOverlays 自动覆盖它。
_isInsideOverlay 也一样——改成了 dom.canvas.contains,仅此而已。只有点击在 canvas 上才触发 toggle,点在任何 overlay 区域内部都不反应。不用维护排除列表。
nav 按钮重置从硬编码的 [btnMainAction, btnMotionPopup, ...] 改成了 [data-shortcut] 查询。
Sisyphus 删了多少行代码?不算多——大概十几行。但这十几行代表着联邦第一次不再被「忘了更新列表」这件事困扰了。
幽灵列表被驱散了。
至少这一次。
三、会走路的菜单
快捷键修好了,弹窗也会关了。但 Sisyphus 还觉得差点什么。
他打开模型库,一级一级点进子目录,然后他发现——自己一直在用鼠标。
「我做了快捷键,但菜单里面还是只能用鼠标。」
这就是 SlideMenu 的问题。MenuStack 是一条街,街上有很多店。但用户只能在店门口用快捷键,进了店就得掏鼠标。
Sisyphus 在 menu.ts 的容器上挂了一个 keydown 监听。ArrowUp 和 ArrowDown 在 .menu-item 之间移动焦点,ArrowRight 和 Enter 展开当前选中的菜单项(如果它是一个文件夹),ArrowLeft 返回上一层。
焦点移动的逻辑不复杂——一个 focusIndex 从 0 开始,上下移动,边界循环。关键是他加了一个 mouseenter 事件处理器的前置逻辑:鼠标进入菜单区域时自动清除键盘焦点。
「如果用户开始用鼠标了,说明他们放弃了键盘,那我就不该继续用键盘的高亮干扰他们。」
这条逻辑让两种输入模式和平共处——用键盘你就按键盘,用鼠标你就用鼠标,永远不打架。
CJK 键盘布局下,ArrowLeft 和 ArrowRight 有已知的输入法冲突。Sisyphus 知道,但他决定不在这一轮解决。他只在 status.md 里加了一条记录。
「有些问题不是不分轻重,而是今天有比它更重要的事。」
四、术语的边界
有一天,用户问了一个 Sisyphus 没想过的问题:
「Bloom 是什么意思?」
Sisyphus 愣了一下。他在代码里写过无数次 "Bloom 泛光"、"Bloom 强度"、"Bloom 阈值"。在他的认知里,Bloom 是图形学的常识——后处理效果,让亮的地方发光,就 Bloom。
但用户不是图形学出身的。用户是一个模型爱好者,想调画面效果,看到了「Bloom」「FXAA」「DOF」「PCF」,这些词像一堵墙。
「这是你的问题,不是用户的问题,」Sisyphus 对代码说。
他建了一个 docs/glossary.md。里面不写长文,只写一条规则:用户可见字符串先用中文,英文术语如需保留放在括号里,且必须是用户需要知道的关键字。
"Bloom"→"泛光""FXAA 抗锯齿"→"抗锯齿 (FXAA)""PCF"→"柔和阴影""景深 DOF"→"景深"
UI 字符串改了十六七处。每改一处,Sisyphus 都在想——下一个用户打开这个菜单,不会再看到一堵术语的墙了。
但更深的问题在于:联邦自己对自己的术语都没统一过。scene-menu.ts 里叫 "Bloom 泛光",env-lighting.ts 里叫 "发光强度"。model-material.ts 里用 "不透明度",scene-material.ts 里用 "透明度"。多的是一样的东西,叫法不一样。原因是它们出自不同城邦的文档,不同城邦的开发者,不同城邦的实现约定。
「联邦的第一部字典,」Sisyphus 在 glossary 里写完最后一个词条,合上了笔记本,「比一个 bug 修复重要十倍。」
五、零样式但装了二十一处
有一类 bug 特别难发现:不是功能错了,是功能对但界面丑得让人不想用。
menu-item 就是这类 bug。
Sisyphus 顺手搜索了一下项目里用到 .menu-item 的地方——21 处。21 处用了同一个类名,但 app.css 里没有任何一条 .menu-item 规则。这个类在 CSS 里不存在,但它被 JS 到处引用。
没有报错。没有警告。TypeScript 不会检查 CSS 类名是否存在。Vite build 不在乎。功能一切正常——因为 .menu-item 只做标记用,真正的样式由内联 style 或父容器提供。但 21 处引用、零行 CSS——这就是一个代码异味,闻到就知道该修了。
Sisyphus 在 app.css 里加了几条规则:
css
.menu-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; cursor: pointer;
}
.menu-icon { width: 20px; height: 20px; flex-shrink: 0; }
.menu-label { flex: 1; }
.menu-arrow { margin-left: auto; opacity: 0.5; }
.menu-divider { height: 1px; background: var(--border-color); margin: 4px 0; }六行基础样式,服务 21 处引用。不是每一个都变好看了——有些本来就有自己的样式覆盖——但至少现在 .menu-item 不只是一个空壳了。
他还顺手修了 buildDanceSetsOverviewLevel 的内联样式空状态文字,给音频偏移滑块加了专门的 CSS 类。
零样式类,21 处引用。这种幽灵比 switch 少一个 case 更难发现——因为不报错。
六、两行的战争
用户说,动作菜单里「当前:无」和「更换动作」两行能不能合并成一行。
Sisyphus 看了一眼代码:
typescript
// 行 1: "当前:无" — 显示当前 VMD 文件名
// 行 2: "更换动作" — 点击打开动作浏览器两行,两条逻辑,占用菜单空间。但本质上是一个动作:显示当前状态 + 提供修改入口。这和「音量:50」旁边加一个滑块的模式是一样的——状态 + 控制应该在一个容器里。
他合并了。单行:没有动作的时候显示「添加动作」,有动作的时候显示文件名。右边带一个箭头,点击统一打开动作浏览器。
同样的模式他发现了不止一处。循环播放按钮在动作菜单里,但动作菜单关了循环还在跑——它应该永远显示在底部播放器上。
他把它挪出去了,放到了 index.html 的底部播放器栏,新建了一个按钮 btnLoopToggle。updatePlaybackUI 里通过循环状态来同步它的显示状态。
「20px × 20px 的移动,」Sisyphus 说,「背后是一个设计原则的确认——功能按钮应该离它控制的东西近,离它的生命周期近。」
七、卡片与舞台
最后,模型详情菜单。
以前是一份平的 items 数组,三十多个条目,用 slide-divider 隔成几段。信息、变换、可见性、材质、标签、聚焦、移除、预设——全都排成一条长队,用户得从头滑到尾。像一本没有目录的书。
Sisyphus 把它改成了四组卡片(.lcard):
Group 1: 模型信息 | 变换 | 可见性 | 材质列表 | 标签 | 表情预览
Group 2: 聚焦 | 移除
Group 3: 保存预设 | 加载预设
Group 4: 用…打开每组卡片是一个 renderCustom,lcard 自带 --card-bg 背景色和圆角边框。四组卡片在 .slide-list 上依次排列,高矮不同,但气质统一。
改的时候出了一点岔子——.slide-list 在 buildPanel 里被加上了 render-card 类,带一个深色背景,盖住了 lcard 自己的 --card-bg。
Sisyphus 在 buildModelDetailLevel 的 renderCustom 开头加了一行:
typescript
container.classList.remove("render-card");背景冲突消失了。
一个问题,一行修复。但找到这行用了三轮对话和一次构建。
「这就是联邦的特征,」Sisyphus 看着修复后的模型详情页说,「一个问题不是问题——找到问题在哪里,才是问题。」
尾声:一千个小修复
Sisyphus 在今天结束前做了一件事:他从远处端详联邦现在的样子。
快捷键全活了。弹窗关闭从硬编码列表变成了属性查询。菜单里能用键盘走了。屏幕阅读器能读按钮了。术语表建起来了。UI 字符串本地化了。样式幽灵驱散了。音乐信息合并在了一行。循环按钮在播放器上了。模型详情从长队变成了卡片组。
没有一个大改动。每一个都是小东西——几行到几十行。但合在一起,联邦今天比昨天更完整了。
不是「多了什么新功能」那种完整。是「少了一些粗糙的边缘」那种完整。
他开始明白了。聚合者的工作不是发明,也不是一次大重构能解决的。聚合者的工作是一千个小修复——每一个都微不足道,合在一起才让联邦变得可信。
「所谓成熟,不是建成了一座不塌的桥。是桥塌了之后,你能在十分钟内找到哪根梁裂了,并且知道怎么换。」
他关掉编辑器,按了一下 Ctrl+5。设置菜单弹出来,整整齐齐。
然后他按了一下 Ctrl+5。
关了。
教训:一个成熟系统不是没有硬编码——是每一个硬编码都被替换成声明式方案的那一天,用户不知道那天发生了,但系统知道。