Skip to content

v0.0.2

Latest

Choose a tag to compare

@github-actions github-actions released this 22 Sep 20:25

两件事凑成了这一版:把 0.0.3 的桌面计划提前做完,以及一个外部使用者装上这个包、照着文档搭了一个报销审批控制台之后报回来的一串缺陷。后者有个共同点——没有一个是从库内部看得见的:要么是两个组件碰面的地方,要么是一个只在没人看演示的那个平台上才错的默认值。

升级前先看这两条:

  • TabBarItem.href 改成可选之后,onSelect 的事件类型变成 MouseEvent<HTMLAnchorElement | HTMLButtonElement>。如果你手写过 MouseEvent<HTMLAnchorElement> 的标注,会编译不过,改成新类型或者去掉标注即可。这是从 0.0.1 升上来唯一需要动手的地方。
  • 有几处外观和行为会变:GlassProvider 现在把解析出的主题写到 <html>(你自己补过这段的话可以删了)、SearchField 在桌面上从 24px 变成 36px、轻提示会让开 Screen 的底栏、玻璃里的列表不再画自己的底色。如果你为这些写过绕过代码,现在应该删掉——留着会叠加。

修复(theme="system" 下,内容层不跟随系统深色)

README 首页的写法就是 theme="system"。系统是深色时,栏和按钮变深,卡片、列表、表单留在浅色——黑字白卡压在深色页面上。

深色取值只挂在 [data-lg-theme="dark"] 上,整份样式表里没有一条 prefers-color-scheme——这是故意的,主题是按元素声明的,浅色页面里才放得下一条深色工具栏。但 GlassProvider 只把主题写在玻璃表面上,从不写到 <html>,于是页面本身成了唯一没人声明主题的那个表面。现在最外层的 Provider 也写根节点;每个表面仍然各声明各的。

顺带:[data-lg-theme] 现在同时设 color-scheme,深色表面里的滚动条、光标和原生控件跟着变。

修复(SearchField 在桌面上只有 24px 高)

它拿 --lg-hit-min 当自己的高度——那是 WCAG 2.2 给命中区的下限(指针 24px),不是控件高。旁边的 TextField 用的是 --lg-height-comfortable,36px。17px 的字塞进 24px 的胶囊里像被裁了。触摸档两个数都是 44,所以这个错只在有鼠标的时候看得见。

修复(Screen 给底部标签栏留的空间少 36–56px)

.lg-tabbar 无条件 position: fixed。放进 Screen 的 bottom 插槽后它被定位了两遍、为home指示条留白两遍,而且——因为脱离了文档流——插槽量出来是 0。Screen 于是给一条 60px 的栏留了 24px,每一页最后一截都压在栏底下。现在插槽里的标签栏由插槽定位;宽屏下它变成侧栏的那一档不受影响。

修复(轻提示整块盖住标签栏,把主导航点没了)

.lg-toast-region 钉死在视口底边,不认 Screen 已经发布出来的栏高。提示和标签栏落在同一排像素上,而提示是 pointer-events: auto——每弹一次提示,整条主导航有几秒点不动;而把鼠标移向标签栏这个动作本身会悬停到提示上,计时暂停,它连自己走都不走。现在 Screen 把量到的栏高也发布到文档根上,提示读它——轻提示是渲染在 portal 里的,根节点是它唯一够得着的地方。

修复(ListRow 在窄栏里把标题压成一列单字)

除了标题,每个槽位都是 flex: 0 0 auto。800px 窗口下三栏分栏自己分出来的 193px 内容栏里,前导图标、金额、徽标和箭头吃掉全部宽度,标题分到 41px,overflow-wrap: anywhere 把它排成一列单字,行高 598px。现在 400px 以下(按列表自己的宽度算,不是窗口)value 换到第二行。规则本身值得写下来:装饰不压过它装饰的那个内容。

修复(同时有 hint 和 error 时,aria-describedby 指向不存在的节点)

出错时提示文字被从 DOM 里拿掉了,id 还留在 aria-describedby 里。悬空的 IDREF 在各家读屏下行为不一致;而且它拿走的正是「怎么才算填对」那句——用户最需要它的时刻。现在两句一起留。

修复(命令面板重开会恢复上次的高亮行)

关闭时只重置了输入内容。命令列表没变,按列表内容触发的那个重置就不会跑,于是面板带着空输入框和上次那一行回来——⌘K 然后 Enter 跑的不是第一行。文档写的是「它不记任何东西」,现在是了。

修复(换品牌色,焦点环不跟着走)

docs/api.md 的表里写着焦点环由 --lg-accent 驱动,代码里是 --lg-focus: var(--lg-blue),写死。默认品牌色就是系统蓝,所以只有换了品牌色的人会撞上——然后全站焦点环还是 iOS 蓝。

改进

  • TabBarItem.href 改为可选:没有 URL 的区段渲染成 <button>,不用再编一个假地址再 preventDefault。onSelect 的事件类型相应变成 HTMLAnchorElement | HTMLButtonElement——如果你手写过 MouseEvent<HTMLAnchorElement> 的标注,需要跟着改。
  • PaletteCommand.keywords 接受数组,不再只有空格分隔的单串。
  • FormSection 有了 headingLevel,和 ListSection 一样。之前一个 h1 页题下面的四个表单分组只能是 h3,中间跳一级。
  • tint 传给用不上它的变体时,开发模式会出声。tint 会在按钮上写下 --lg-accent 和 --lg-accent-fill,但只有 glassProminent / primary / tinted / plain 会读它——传给默认的 glass,按钮和没传时长得一模一样,而且原先一声不吭。

修复(列表放进玻璃里会砸出一个方角黑块)

把导航列表放进 Sidebar,材质上会出现一个不透明的黑色方块。两个变体都声明了不透明底色——这在页面上是对的——而 plain 声明的是 --lg-bg,页面底色,深色下是纯黑。于是它既比所在的面板更黑,又用 0 圆角顶着面板自己的 26px 圆角。

内容块去重画它所在的那个表面,和玻璃套玻璃是同一个错误的两面。现在玻璃表面里的列表不画底色,圆角由容器算出来——用的是 Card 和 Concentric 本来就在用的 --lg-radius-container / --lg-concentric-inset,所以给 Sidebar 传了自己的 radius 它也跟得上。GlassSheet 和 GlassAlert 不在此列:它们是被模态呈现的一页,满屏时本来就是实色。

顺带,--lg-radius-container 现在由每一块玻璃发布,所以 Concentric 放进玻璃里和放进 Card 里一样好使。

新增(ListRow 的 selected)

split-views.md 要求「每一栏都要持续高亮当前选中项」,而 ListRow 此前没有任何办法说出这件事——没有东西发出 aria-current,样式表也只给标签链接和路径层级画过 [aria-current]。一个当作分栏中间列用的列表,看不出选中的是哪一条。(本站自己的侧栏示例因此一直是手写的 <a> 加自定义类名;现在换回了真组件。)

selected 落成 aria-current:链接是 "page",按钮是 "true"。颜色和 OutlineView 的选中透镜同一对——同一个界面里不该有两种「选中」——强制配色下换成系统的 Highlight / HighlightText。

文档

  • docs/api.md 第 5 行教人引入 styles.css,这个路径不存在,照抄会得到 ERR_PACKAGE_PATH_NOT_EXPORTED。正确的是 style.css,而且引它一个就够了。
  • SplitView 在紧凑档只渲染 sidebar 和 compact,children 和 inspector 不在 DOM 里。之前任何一处文档都没有写。
  • ListRow 的箭头是自动出现的(能点的行有,带 accessory 的行没有),disclosure 是用来推翻这个默认的。之前只有 .d.ts 的 TSDoc 里有。
  • 「不要让主题闪一下」那节原来的说法暗示 React 后面会写这个属性——它当时不会。现在会了,那段 head 脚本的作用改回它本来的样子:把同一件事提前到首帧。

新增(强调色拆成品牌色和底色)

--lg-accent 配 --lg-accent-contrast(白)量出来是浅色 3.52:1、深色 3.23:1——过大号文字的 3:1,过不了 4.5,而这一对被画在 15px 标签底下的地方不止按钮一处。任何还认得出是这个蓝的颜色配白字都到不了 4.5,所以拆:

  • --lg-accent 仍然是品牌色,图形用它——焦点环、滑块已填的轨道、单选点、进度条。图形只需要 3:1。
  • --lg-accent-fill 是白字压在上面的那块底色,由品牌色压暗 82% 得到,白字量到 4.98:1。--lg-red-fill 同理:通知徽标和危险主操作从 3.57 到 5.1。

两个都在 :root 上由 --lg-accent / --lg-red 算出来,所以改掉品牌色,底色自动跟着变。GlassButton 的 tint 是唯一同时设这两个、并且设成同一个值的地方:传 tint 的人已经指定了颜色和压在上面的文字色,开发模式量的也正是这一对,再压暗一次就是画一个没人要的颜色。

改动可见的地方:主操作按钮、强调色与通知徽标、危险主操作、菜单栏打开着的标题、复选框与单选钮的选中态、大纲视图的选中高亮。

修复(按住弹层里的滑块,旋钮会消失)

按下去那一刻旋钮不见了。「按住」的规则是抬起来变成玻璃——background: transparent,让底下的玻璃层透出来——而共享表面上的控件已经把自己那层玻璃交出去了,渲染出来是平的,压根没有那几层。填色被拿走,手交给了一层从来没被画出来的东西。

报的是 Panel,但气泡、底部面板、工具栏分组里全都一样。玻璃叠玻璃仍然不行,所以按住改用剩下的两样说:留住填色,放大,抬起。

修复(一打开,边缘就是高亮的)

命令面板一开,整块面板就戴着强调色的环,在任何人碰它之前;对话框里的输入框同理。输入框不管焦点怎么来的都匹配 :focus-visible,规范就是这么定的——可当焦点是被塞给读者的时候,这个环什么也没说。

现在浮层开的时候会记住焦点是它给的,读者第一次真的移动焦点(Tab、点别处)才把环点亮。在那之前说明「打字落在哪」的是光标。

顺带修掉一条更重的:命令面板里按 Tab,焦点会掉到页面 <body> 上——面板里只有一个焦点位,而模态对话框外面全是 inert,Tab 走到头就走出去了。环灭了,键也不进输入框,只能用鼠标救回来。现在 Tab 留在原地,并把环点亮。

修复(颜色被当成墨水用)

按 Apple-Style-Review 的清单把 66 个组件全部重过一遍:62 个页面 × 六种状态(浅色、深色、390、最大字号、从右到左、强制颜色)× 十个问题。整轮的过程和读数在 reports/hig-review.md。

  • tone="accent" 3.03:1、tone="destructive" 3.14:1,打开「增强对比度」也只到 3.94 / 4.01。系统色是底色,不是墨水——这条规则连同它的解法(--lg-ink-toward / --lg-ink-amount)早就写在 tokens.css 里,但只用在了 variant="tinted" 一个控件上。现在 Text 的两种色调、variant="plain" 和 variant="destructive" 的标签、菜单和操作表里的危险项、页面栈的返回按钮,全都走同一条配方,浅色下量到 5.9–6.0:1。一条只在写它的那个控件上成立的规则,还不是规则。
  • 窄窗口配鼠标时,小图标按钮的命中区是 32×44。 --lg-hit-min 问的是窗口宽度,--lg-hit-width 问的是指针种类,于是同一个控件的两条轴拿到了两个下限——而横幅页上印着「关闭按钮有 44×44 的点击范围」。两条现在问同一个问题,并且问的是 any-pointer: coarse:带触摸屏的笔记本和带触控板的平板都答得对,窗口宽度只继续决定控件高度。
  • 页码点的间距差 2px。 点是 18px,比一个目标该有的 24px 小,救它的是 WCAG 2.2 的间距豁免——而豁免是算术:以每个点为心画 24px 的圆,不能碰到邻居的。原来中心距 22px,圆是相交的,所以豁免不成立。现在 24px。
  • 路径栏上两个字的一级只有 42px 宽,手指够不着。有 min-height 没有 min-width。
  • GroupBox / OutlineView / Form 等处的无障碍说明写成了属性名。 文案那一轮一起改了,见下。

修复(文档站:能读的字,和人话)

  • 代码块的语法配色在浅色下读不了:字符串 1.95:1、属性 1.90:1、注释 2.37:1、标签 3.10:1、关键字 3.67:1。原因和上面同一条——直接拿系统调色板当了墨水。现在浅色是同样五种色相压暗到过线的版本(5.0–6.5:1),深色仍然用系统色,因为那里本来就够。
  • 页脚、左栏分组标题、「本页内容」用的是 tertiary,2.32:1。库自己的文本页写着 tertiary 是占位符和停用态的颜色,站点却拿它写了要读的字。
  • 正文次级灰在站点画布上 4.47:1:那个灰的透明度是按库的分组背景调到刚好过 4.5 的,而站点画布比它暗。画布调亮五个单位。
  • 首页图上的两行字直接压在插画上,「FIELD NOTES」3.28:1、右下角脚注 4.34:1。文字阴影让它们看起来能读,量出来是零;改成一层压暗的底片之后是 5.6 和 7.4。
  • 示例卡片的标题写在示例后面,于是自带标题的示例(文本、列表、导航栏、页面栈)把页面的标题层级从 h2 跳到了 h4。标题现在写在前面,用 order 摆到下面。
  • menu-button 页上一个 id 出现了两次,大纲里指向那个示例的锚点有两个落点。
  • 全站文案重写:把「aria-describedby」「role="tree"」「content-visibility: hidden」「auto-fill + minmax」「可辨识联合」这类实现术语,换成它们对使用者意味着什么;把几段解释「我们当初为什么这么定」的长注脚压成一句话;去掉「排在 0.3.0」这类只有维护者关心的说法;引号统一。

修复

外观这一组是在真实屏幕上看出来的,不是推出来的。每一条都换算成了一个可以量的数,tests/browser/appearance.spec.ts 守住它们。

  • 深色下指针高光太亮。玻璃的光晕和按压闪光是白光,而白光落在什么上,结果完全不同:浅色面板上 26% 是一层柔和的反光,深色面板上就是整块板被点亮,指针走到哪里拖着一团亮斑。现在这两个强度是按外观分开的 token,深色约为浅色的三分之一。

  • 浅色白得刺眼,很多文字看不清。顶层表面原来是满亮度的纯白——一块 #fff 面板在亮屏上就是整面板在反光,而它上面的分组背景为了能被区分还得更暗。现在顶层降三个点(仍然是系统里最亮的那一层,「白」本来也只是这个意思),分组背景跟着落一档。次级文字的灰从 60% 提到 74%:不是拍脑袋,那是这个灰压在分组表面上测出来刚过 4.5:1 的位置,用例量的是合成后的那一对而不是 token 本身——只看 token 正是「它过了」和「你读不清」同时成立的原因。

  • 折叠区没有高度动画。组件一直在量内容高度并写出 --lg-disclosure-height,而没有任何一条规则读它:箭头转了,内容一帧就出来了。现在用 interpolate-size + ::details-content 让浏览器做这件事,量出来的高度是它的兜底目标。

  • 分段控件和标签页看不出选中哪一个。白色胶囊压在白色玻璃轨道上——实测选中项与周围只差 5/255,加阴影再多三个点。光靠影子修不好:轨道得是凹的。现在轨道是一道压暗的凹槽,胶囊从里面抬起来,实测差值 20/255。这也是系统自己的画法:灰色沟槽 + 抬起的白色滑块。

  • 步进器按住不会连续加减,也没有 Shift 大步。从 1 到 40 只能点 40 次,而那正是用户放弃、改去找一个并不存在的输入框的时刻。现在按住 0.4 秒后每 90ms 一步,松手、指针取消、到达上下限都会停;Shift + 点击走 10 倍。

  • 轻提示只能等它自己消失。没有关闭按钮,不理 Escape——唯一的办法是等六秒,或者把指针悬在上面暂停它。键盘用户两样都没有。现在有关闭按钮,Escape 关最新一条。

  • 徽标在增强对比度下没有边框。白字压红色本身是 4.6:1、过 AA,所以这是一致性问题:其他表面在这个设置下都会长出边线,唯独它没有,看起来像被这个设置漏掉了。

  • RTL 下所有浮层都对错了边。align 写的是 start / end(前缘 / 后缘),锚定代码算的却是 rect.right,不看方向——实测 dir="rtl" 下触发器左缘 556,菜单左缘 432:对齐的是物理右边,而那在 RTL 里是前缘。菜单、气泡、操作表全中。现在按触发器自己的 direction 解析,所以 LTR 页面里的一小块 RTL 子树也对。

  • 面板只有顶部那条横条能拖。系统的面板是整块可拖的,判据是滚动位置而不是碰到了哪个元素。实测从内容区拖 8 步,--lg-sheet-offset 50% → 50%,纹丝不动。现在内容滚到顶时往下拖就是收起,往上拖在还有更高一档时长高、到顶了就让位给滚动;横向拖不接管;方向定下来之前不 preventDefault,所以面板里的按钮照常能按。

  • 气泡面板没有箭头,在手机上也不变形。HIG 的 popovers 页要求箭头尽量直指打开它的元素,样式表里一处都没有;同一页还说手机上应该换成面板,组件自己的注释也这么写着,然后在任何宽度都渲染一块 300px 的锚定面板。现在箭头贴在朝向触发器的那条边上、跟着触发器中心滑;紧凑尺寸下换成通栏贴底的面板,箭头一并去掉——钉在屏幕底部之后它已经没有东西可指了。

  • 分栏视图会溢出它自己的盒子。栏宽用的是刚性 flex 基准,容器比「侧栏 + 检查器」还窄时,多出来的部分直接从盒子里漫出去——实测 320px 的容器里溢出 161px,而它的父级完全不知道这些栏存在。这是布局容器唯一不能做的事。现在栏可以收缩,内容栏先让。

  • 分栏视图会报出一个没有人拥有的宽度。视图比自己声明的最大值还窄时,按 End 把 aria-valuenow 设成 400,画出来是 304。读屏用户听到的是一个不存在的数字。现在范围会被「实际有多少地方」二次收紧,aria-valuemax、aria-valuenow 和画面永远是同一个数。

  • 标签栏在最大字号下把整份文档推歪。AX5 下 390px 屏幕上四个标签是 400px 的文字,而标签栏是 inset-inline: 0——文档横移 69px。Dynamic Type 要的是重排,所以标签行现在会换行:只把栏宽封顶的话,标签会被挤成一列一个字,而那正好挤给了要求大字号的那些人。

  • 页面栈的压栈动画把自己撑宽。进入的那一页从侧面 16px 滑入,期间栈比自己宽 16px;一个满屏的栈会让页面每次导航都闪一次横向滚动条。现在这段位移被收在栈内。

  • 搜索建议的高亮会被调用方的重渲染清掉。「列表变了就清掉高亮」这个 effect 依赖的是 suggestions 数组的引用,而筛选归调用方,所以它几乎总是每次渲染新建——按下 ↓、高亮出现、下一帧换成新数组、effect 又把它清了。是一场竞态,所以它看起来大部分时候是好的。改为依赖列表的内容。

  • 颜色快捷色之间只有 4px,低于「控件之间至少 8pt」,两个焦点环会贴在一起。

  • disabled 的跳转行照样能跳走。ListRow 在 disabled 时仍渲染 <a href>,只多加一个 aria-disabled——回车、点击、Tab 停靠全都不受影响。aria-disabled 是播报,不是实现。现在改渲染 <button disabled>。

  • 37 个会渲染元素的组件里,25 个拿不到 ref,其中大半还只收 className。这是在真实项目里第一天就会撞上的墙:量不了尺寸、ScrollEdge.targetRef 指不到自己的列表、第三方库锚不上去、给 <label for> 设不了 id。现在每个组件都交还它渲染的那个元素,并透传 HTML 属性;TextField 和 SearchField 的 ref 落在 <input> 上,因为那才是你要聚焦、要读值的东西。四个纯 context 的 Provider 没有 ref,这是写下来的决定,不是漏掉的。

  • TabBar 的 ref 落错了元素:它混在被当成玻璃选项的 rest 里,被透传到内层第一个 GlassSurface,而不是整条 <nav>。

  • SearchField 收了 id 又用自己生成的覆盖掉,<label for> 和 aria-controls 指不过去。

  • 开发告警一直在生产环境里响。守卫读的是 globalThis.process?.env——打包器只替换字面量 process.env.NODE_ENV,所以浏览器产物里 process 是 undefined,判断永远是「开发模式」。实测旧写法在站点生产构建上吐 6 条告警,现在 0 条。

修复(文档站在宽屏上不成形)

所有者的原话是「这桌面效果也太丑了」和「整个文档站看起来就像是被压扁了一样」。两版才做对,第一版的账写在 reports/hig-review.md 里。

现在的形状是任何人从别的组件库文档站过来都已经认得的那个:顶上一条通栏(名字、四个分区、搜索与偏好),左边一列是当前分区的页面,中间是页面,右边是这一页的目录。分区在上、分区里的页在侧,而不是两种不同性质的列表挤在同一条栏里。

量出来的几件事:

  • 侧栏是一块浮着的小胶囊:导航 228×114 装在 260×1000 的栏里,而当前分区的页面列表渲染在这块玻璃外面。分区搬进通栏之后,左列就是一条普通的文档侧栏——一条分隔线、自己滚动、跟着通栏吸顶。它不压在任何东西上面,所以它不是玻璃;这一页里真正有东西从底下滚过去的是通栏,玻璃就在那儿。
  • 标题栏和正文错开 130px:标题栏是固定定位的,侧栏不在它的包含块里,于是它按整个窗口居中而正文按窗口减侧栏之后居中。现在名字、侧栏第一项和每一个页面标题都落在同一条竖线上。
  • 没有同级页面的页不再留一条空列:<SecondaryNav/> 是个元素,就算它渲染出 null 也仍然为真——概览和更新日志因此一直被分配着一条 252px 的空列。
  • 示例一行一个,占满内容列。两列三列看着省地方其实不是:三列时按钮页一行七个按钮折成三行,读者看到的是"窄列里会怎样"而不是这个组件长什么样;而且可调节的那个示例横跨整行,它上面任何奇数张卡片的行里都会空出一格。
  • 落地页的标题一直没生效。站点给它写了 clamp(...),而 Text 用 .lg-text[data-variant="largeTitle"] 给自己定样式——一个类加一个属性,站点那条只有一个类的规则无论谁先加载都赢不了。桌面文字表里 largeTitle 是 26px,作为窗口标题是对的,作为一页上第一行字太小了。一个站点可以写下一个尺寸,而库悄悄忽略它,没有任何地方会说。
  • 代码块横向滚动时,代码会从"复制"按钮底下穿过去。按钮后面现在垫了代码块自己的底色。

修复(有鼠标时整套尺寸都是错的)

所有者的原话是「主页和组件页里的按钮都仍然是过于扁平」。这一条不是站点的样式,是库自己那张桌面度量表。

它原来装的是 macOS AppKit 的那一套:标准控件 22、正文 13/16、大标题 26/32,理由是「光标不是指尖」。光标确实不是指尖——控件的高度现在还按这个道理定——但把它套到字号上是错的,而且是可以量出来的错:

  • 浏览器里的一页不是一个 AppKit 窗口。 13pt 正文是原生检查器里一行表格的尺寸。浏览器自己的默认正文是 16px,那张表从一开始就比同一台机器上别的每一页都小三个像素。
  • Apple 自己在网页上也不用它。 developer.apple.com 在有鼠标的桌面上是正文 17/25、章节导航行 44 与 30、页题 48/52——iOS 那张表。量的,不是记的。
  • 十一个组件库文档站在 1600px 下量下来也一致:九个的正文 16px(Ant Design 与 Naive UI 是 14),默认按钮 32–36 高、标签 14,主按钮 40–42,页题 30–44。22px 的按钮配 12px 的标签,也就是这个站点原来画出来的东西,不在这个范围里的任何一端。

现在的分法是一句话:量的是手,不是眼睛。 控件从触摸的 44 降到指针的 36(小 28 / 紧凑 32 / 大 44 / 超大 52),字号表一张,两个平台通用——顺带让 Dynamic Type 在两边行为一致,不再需要单独承诺。

同一轮里被这次测量牵出来的两个真缺陷:

  • controlSize 对玻璃按钮从来没有生效过。 每个玻璃面都把 --lg-control-height 写成内联样式,而内联样式压过任何样式表规则——于是 .lg-button[data-control-size="large"](设的是同一个属性)一次都没应用上。四种尺寸并排放,标签是四种、内边距是四种、盒子高度是同一个:36/36/36/36。现在高度从玻璃面本来就带着的 data-density 属性上读,规则之间按正常的优先级分胜负。
  • 常规输入框没有自己的高度,写的是 --lg-hit-min——命中区的下限,不是一个尺寸。它的表现也就是一个下限:有光标时常规和小号都落在内容高度上,是同一个盒子的两个名字。现在它是 --lg-height-comfortable,和它在表单行里要对齐的那个按钮同一个数。

站点跟着重新量了一遍:轨道行、「显示代码」那条、导出对话框里那个原生输入框,原来都拿 --lg-hit-min 当高度用;落地页副标题、目录页的卡片排布、卡片里列表的行宽也一起调了(1990px 下那些行有 1470px 长,约六十个汉字才折一次)。

修复(material="clear" 在大玻璃上一直无效)

要大玻璃是 clear,拿到的是 regular 外加一套说辞。

大玻璃(侧边栏、面板、菜单、卡片形态的面板)比按钮更厚,这是对的,WWDC25 也是这么说的。实现它的那两条规则把暗化层推到浅色 86%、深色 90%,而且不分材质,选择器又直接指着 tint 元素本身——于是它稳稳压过上面那条给"clear 压在浅色媒体上"的 35%。文档里写着 35%,画出来是 86%。

和 controlSize 那条是同一个形状:一条内层声明悄悄盖过调用方的意图,而且两次都要等到有人真的去用那个属性才会发现。现在那两条规则排除 clear——clear 的定义就是"能看穿",它只存在于压着媒体的场合,而那恰好是大表面应该让画面透出来的唯一场合。

修复(面板是唯一一块会让控件再长一层玻璃的玻璃)

「永远不要玻璃叠玻璃」是这套材质第一条会被破坏、也最显眼的规则。每一个承载调用方内容的玻璃容器都把自己的面借出去——对话框、面板(sheet)、气泡、警告框、轻提示、横幅、命令面板——只有 Panel 没有。没人发现,是因为仓库里唯一那个 Panel 里装的是滑块,而滑块的轨道是内容层。往里放一个按钮,第二层玻璃就压在第一层上了。

修复(大纲视图:选中项一碰就没,换行也不动)

  • 鼠标经过选中项,蓝色直接消失。 悬停规则比选中规则多一节选择器,于是两者相遇的地方悬停赢了,把强调色整个换成了那层灰。
  • 换一项没有动画。 高亮是画在每一行自己身上的底色,所以换行是两个盒子之间 90ms 的交叉淡入——读起来就是瞬移。

两条是同一个根因:高亮不该属于某一行。现在它是一块会走的高亮,和标签栏侧边栏形态里那块是同一种东西:位置是量出来的(一行的位置取决于它上面开着几层文件夹、以及那些文件夹的展开动画走到了哪一帧,所以跟着 ResizeObserver 走),压在行的下面,于是那层半透明的悬停底色是叠在它上面而不是换掉它。减少动效下不走。

修复(大纲视图:指一个,亮一串)

鼠标停在一个深处的文件上,它头上每一层文件夹都跟着亮。treeitem 就是那个 <li>,而 <li> 装着它的子树——所以 .lg-outline-item:hover 对指针底下那一行为真,对它所在的每一层文件夹也为真。指一个三层深的文件,亮四行。

选中态的底色早就因为同一个原因搬到了行上,旁边还写着一句注释说明为什么;悬停被留在了 item 上。 现在两者都在行上:行才是你指的那个东西。

这类错误只会在会嵌套的组件里出现,所以顺手把样式表里所有 :hover 规则过了一遍——其余每一处的宿主都不可能包含同类元素(菜单没有子菜单,列表行不嵌套,折叠区的正文是标题的兄弟而不是孩子)。大纲视图是唯一一个。

顺带修了检查器自己的一个假阳性:「每条 hover 规则都要有指针门」那条静态检查是按行扫文本的,跳过注释的办法是「这一行以 /* 开头」——于是它跳得过单行注释,跳不过块注释的第二行。上面那句解释为什么要挪这条规则的注释,被它报成了一条没有门的 hover 规则。一个会被散文绊倒的检查器,会教人为了让它闭嘴而把注释写差。 现在先剥注释再扫,剥的时候保留换行所以行号还准;撤掉修复、在顶层加一条真的 hover 规则,它照样会红。

修复(三件在浏览器里看出来的)

都是所有者打开页面看出来的,都改成了可以量的东西。

  • 命令面板的焦点环是一条横穿面板的亮线。 环画在字段那一行上,而那一行不是一个盒子——它是面板顶上的一块板,通栏、没有圆角。于是环成了一个方角矩形:上面两个角被面板自己的 overflow: hidden 裁掉,下边是一条蓝线横过面板中间。同一条规则(容器戴这个环)再往上一级就对了:面板是模态的,字段是它唯一的焦点位,"这个面板拿着键盘"正是这个环要说的话。

    第一版修完之后环仍然一个像素都没出现在屏幕上——getComputedStyle 全程报着 2px 实线强调色。玻璃的那几层是定位子元素,会盖住父元素的 outline,而向内偏移的环正好画在它们底下。现在偏移向外,和搜索框一样。守住它的那条用例因此量的是截图上的像素,不是样式表里的声明:问样式表它声明了什么,和问屏幕它画了什么,是两个问题。

  • 卡片页和文本页写着"调上面的旋钮",旋钮在下面。 两轮版式改造把旋钮面板从示例上方挪到了下方,两个示例的文案留在了原地。印在页面上的方向是最容易写错又最不容易被发现的东西,因为它在源码里永远看起来是对的。用例现在读页面上的"上面/下面",再比对旋钮面板和那句话的实际位置。

  • 首页打开边缘折射,看不出任何区别(两轮)。 所有者的判断是对的,而且不在渲染器里:折射弯的是玻璃背后的东西,而控件浮在湖面的下半部——那半张图是一条竖直渐变加四根发丝线。量出来是每通道 0.97/255。

    第一轮只修了背景(见下),数字到 4.8,但仍然不够一眼看出——因为折射是边缘效应,而那两个胶囊高 40px,边缘就那么几像素。第二轮补的是另外两半:远岸多了一整条针叶林(全图最锐的一条边,正好落在玻璃漂浮的那一带,倒影被同一层涌浪打散),以及画面上多了一块大的 clear 玻璃——可以拖着走的「调整」面板,里面是两个滑块、一个开关和一个按钮。长边缘 + 几乎不模糊的材质 + 背后一排树干 = 打开折射,树在玻璃里被拉出形状,关掉就是一团糊。面板还开了 chroma,边缘会分色。

    修的是那张图:湖现在倒映山脊,倒影再被一层 feTurbulence + feDisplacementMap 的涌浪打散(一个滤镜,不是几百个小矩形),近处的水面另有一组被同一层涌浪弯过的反光。倒影里有山脊的边缘,而边缘正是折射能弯给人看的东西。同一把尺子再量:4.8/255,最亮处 40/255,26% 的像素变化超过 8。

    顺带一条得说清楚的取舍:水一变亮,压在玻璃上的白字对比度从 6.59:1 掉到 4.99:1,而下限是 4.5。把倒映的太阳调暗、近岸加深之后回到 5.23:1。这两个数是互相顶着调的,不是各调各的。

原来那条折射用例问的是"属性翻了没有",那是接线的问题;读者的问题是"画面变了没有",而这个问题此前没人问过。现在两条都在。

新增(大纲视图)

OutlineView:有层级的数据,一层层展开收起,整棵树一个 Tab 位、进去之后全靠方向键。HIG 的 outline-views 页给了这个组件的边界,其中一句直接决定了它的形状:"层级只出现在第一列。" 所以它只有一列,一行右边的 value 是同一行上的读数而不是第二列——真正的多列大纲是 treegrid,方向键要在单元格之间走、列头可排序、列宽可拖,做一半的结果是它对谁都不像表格,也对谁都不像树。

另外两句照着做了:"让展开和收起这件事容易做"——点三角形开一层,按住 Option 点整支展开;"记住人的展开选择"——展开集合是受控的,库不替你存。它不知道该存到哪,而悄悄放进组件自己的 useState 会看起来像记住了,直到刷新。

三角形不是按钮。treeitem 里再套一个按钮,树的键盘模型里没有任何一个键能走到它,却要在每一行多按一次 Tab;开合由行自己的 aria-expanded 播报。点三角形是开合,点名字是选中——同一行两件事,Finder 就是这么分的。

写这个组件的过程里逮到一个只有键盘才会撞上的缺陷:

  • 刚打开的文件夹有 140 毫秒不存在。 收起的那一层用 content-visibility: hidden 而不是 display: none,这样既留下一个能做高度动画的盒子,又真的把里面的行移出无障碍树和页内查找。但过渡是按目标状态那条规则读的,而这个离散属性在两个方向的规则里都列着——于是它在动画进行到一半时才翻转,两个方向都如此。结果是快按两下 → 的人打开了一个文件夹,然后什么也没发生:那一刻里面的行还不可聚焦、读屏也还看不见。现在只有"收起"那条规则列它,"展开"那条不列,于是展开是立刻的、收起时行会留到盒子缩完。

修复(桌面度量下把全库重量了一遍)

第 4 周把有鼠标时的控件高度从 44 降到 36/32 之后,全库还没有按新的尺子重看过。这次是量的:48 个组件页,1440px,指针平台,逐个元素问三个问题——能按的东西有没有掉到光标的 24px 下限(WCAG 2.2)、有没有标签被变矮的盒子裁掉、有没有圆角大过了自己盒子的一半。文字下限(11pt)和焦点环能不能落进变矮的控件里另跑一遍,两项都是 0。

  • 页码点在有光标时只有 18×18。 它的命中区写在 @media (pointer: coarse) 里——把"命中区"读成了触摸的事。指尖有下限,光标也有,只是数不同;没有这条媒体查询包着,同一个 --lg-hit-min 自己会给出每种指针的答案。横向仍然只长在纵轴上:点与点相距 18px,两轴都长会让每个点盖住邻居的圆心,实测过——点第一个会选中第二个。沿着这一行它们就是相邻的目标,这一点是写下来的,不是没量。
  • 路径栏的每一级只有 22px 高。 脚注字号加 2px 内边距刚好差 2 像素到 24。带命中下限的是那个「…」按钮,而不是人真正会去点的那些名字——正好反了。
  • 「…」按钮只有 31px 宽,一个字形加 8px 内边距,对光标够、对指尖不够。它是在上面那条改完之后才被 pnpm test:matrix 报出来的:在那之前它有两个角恰好落在旁边那一级上,被"落到邻居控件上不算漏"这条规则吸收掉了。扫描器的豁免条款会藏东西,这是一例。

没有成为发现的几条也记一下,因为它们看起来像发现:复选框与单选的 <input> 是 16×16,但它是透明的,<label> 带着 --lg-hit-min 且整行可点;输入框本体 22px 高,而目标是带着 --lg-height-comfortable 的外框;分栏分隔线 1px 宽,命中区在 ::before 上——扫描器只读了 ::after。

顺带补上一个 CI 的洞:只存在于开发构建里的那 13 条用例,pnpm check 一直在跑,CI 从来没跑过。 它们跑在 Vite dev server 上而不是 site/dist,所以 pnpm test:chrome 够不到——三条设计规则的告警、Strict Mode 下不排帧、八棵树的 hydration,在流水线上一条都没被检查。桌上那道关比流水线那道严,等于流水线那道不是关。ci.yml 补了这一步,release.yml 通过 workflow_call 复用它,所以标签也一起收紧了。

新增(桌面窗体:分组框、路径栏、浮动面板、工具栏溢出、卡片形态的面板)

到上一版为止,这个库能画一条工具栏和一个分栏视图,画不出一扇窗。这一批补的是窗体里剩下的那几样。

  • GroupBox。一圈把相关内容框起来的边界,标题画在框外面(macOS 的画法),用 aria-labelledby 绑到 role="group" 上——读屏会先说这个分组叫什么。背景或描边,不是两样一起。和 Card 的分别写在文档里:卡片是一块承载内容的面,分组框是一圈边界,它的标题属于分组而不属于内容。

  • PathBar。根在前、当前项在最后,最后一项不是链接而是 aria-current="page"。中间放不下就折进「…」菜单,首尾两级永远不折——HIG 的原话是"列表太长装不下时,会隐藏首尾之间的名字",所以这是量出来的,不是一个层数上限。层级之间的箭头由样式表画,不在无障碍树里:读屏在每两级之间念一次"箭头"是在念标点。

  • Panel。浮在内容之上的一扇小窗,非模态——没有遮罩、没有焦点陷阱,背后照常能用,这正是它和对话框的分界。标题栏就是把手,而且可以聚焦、方向键能移动:只能用鼠标摆的位置是有些人摆不了的位置。没有最小化(HIG:一般不要提供);collapsed 是卷成一条标题栏。HUD 那一种没有做,理由写在文档里而不是留成一个空洞的属性。

  • ToolbarGroup 的 items。把一组按钮作为数据交给它,放不下的从后往前收进尾部的「更多」菜单。HIG 说系统会自动这么做并且要求不要手工加一个溢出菜单;同一页还说尾侧的项在任何宽度下保持可见,所以折叠的是给了 items 的那一组,不是整条工具栏。给 children 没有这个能力,这是有意的:要把按钮放进菜单得先知道它叫什么,而从任意子节点里猜名字会在"有人传了个没标签的图标"那天悄悄失败。

  • GlassSheet 在指针平台上是另一个东西,API 不变。HIG 对 macOS 的描述只有一句:"一张浮在父窗口之上、带圆角的卡片"。升起的边缘、跟手的拖动、停靠高度,没有一样还成立。所以有光标的宽窗口下它从窗口顶部落下、居中、按内容定大小、没有横条,detents 不起作用——写在文档里,而不是让它悄悄为真。

首页多了一扇窗。 菜单栏 + 工具栏 + 分栏 + 检查器 + 路径栏 + 浮动面板拼成一个应用窗口,窗口拖窄时分栏折成一摞页面、工具栏的那一组收进「更多」、菜单栏和面板退场(两者的 HIG 页都只覆盖桌面)。每一页单独讲一个组件是查阅的样子,不是"用这些东西做出来的应用长什么样"的答案。

顺带修掉三个只有拼起来才会露出来的问题:

  • 收起的面板和展开的一样高。 collapsed 给内容区加了 hidden,而 hidden 是一个表现提示,任何 display 都压得过它——.lg-panel-body 的 display: flex 就压过了。结果是内容离开了无障碍树却还画在屏幕上:读屏够不到,眼睛看得见,两头都不占。
  • 面板会开在容器外面。 位置只在拖动时被夹住,而 defaultPosition 是在任何东西被量之前的一个猜测。430px 的容器配一个 x=236、宽 196 的面板,开出来一半在框外被裁掉,而被裁掉的正是能把它拖回来的那条标题栏。
  • 溢出的组会被挤成一条十像素的缝。 flex 项不会缩到比内容窄,除非明说可以——不说,这一组在窄栏里保持原宽,它量的那一行永远不缺地方,菜单永远不出现。说了之后走到另一个极端:所有项都进了菜单,而打开菜单的那个按钮也被挤没了。下限现在是一个控件加上组的内边距。

新增(命令面:菜单栏与命令面板)

桌面应用是靠命令驱动的,而这个库到上一版为止只有「一个按钮挂一个菜单」。

  • MenuBar。它和一排菜单按钮的区别,全在打开之后:指针滑过标题时开着的菜单跟着走,方向键也是——一次按键换一个菜单,中间不会有一帧什么都没有。整排只占一个 Tab 位,下方向键打开,菜单内部还是 GlassMenu 那套键盘模型,Escape 关闭并把焦点还给标题。玻璃只在这条栏上,标题是栏上的项。

    HIG 关于菜单栏最硬的一条是「一直显示同一组菜单项」:不适用的时候置灰,不要拿走。菜单栏是靠位置记住的,换了形状就没人记得住了,所以 disabled 在整个菜单和单条命令上都是「仍然画出来」。

  • GlassMenuItem.alternate:按住 Option,「关闭」变「全部关闭」、「复制一份」变「存储为…」。原地替换,不是多一行——这是长菜单能保持短的原因。任何菜单都能用,不只是菜单栏里的。替代项永远只是快捷写法:按住修饰键这件事本身不可发现,所以不能有命令只住在那里。

  • CommandPalette(⌘K)。一个输入框,一张列表,一次按键到达。无障碍模型是 combobox 套 listbox 的虚拟焦点——真正的焦点一直留在输入框里,高亮那一行由 aria-activedescendant 点名。把焦点移进列表是这类控件最常见的做法,也正好废掉它唯一的用途:下一个字得还能打进去。

    匹配规则默认自带(输入的每个词都要在名字、分组、说明或 keywords 里出现过),可以整个换掉,filter={false} 表示「给你的列表就是答案」。这和 SearchField 坚持不自带筛选不矛盾:搜索框的结果可能在任何地方,只有应用知道「匹配」对它的数据意味着什么;而面板一开始就拿到了全部命令,对一个已经握着整份清单的函数没什么好隐瞒的。

    它不记任何东西:没有历史,没有「最近使用」,什么都不往外写。一份「你最近在干什么」的清单不该默认摆在屏幕上。

    打开它的快捷键是应用级的,有模态窗时不响;关掉它的那个绑在面板内部,所以是那条规则的例外——两个绑定,不是一个开关。

  • 文档站的 ⌘K 换成了这个组件。它原来是就地拼出来的对话框加搜索框,而一个写在文档站里的命令面板,别人是拿不到的。顺带修掉两件事:搜索按钮原来在每台机器上都写着 ⌘K(在没有 ⌘ 的机器上是句小谎,而绑定不再同时接受两个修饰键之后就是句看得见的谎),现在和绑定读同一个答案;以及输入过内容之后关闭原来要按两次 Escape——旧的 type="search" 会被浏览器抢走第一次用来清空。

  • useModalDialog:对话框、警告框、面板三处各写了一遍「记住焦点 → showModal() → 锁住背景 → 出来时关闭、解锁、把焦点放回去」。命令面板会是第四遍,所以抽成了一个。会被人注意到的是最后一步:关掉之后焦点留在 <body> 上,等于把键盘用户丢回页面顶端。

新增(表单的基础件与真的能用的快捷键)

桌面没有复选框和单选组,等于没有表单。两个都是内容层——HIG 写得很直白:开关、复选框、单选按钮属于窗口内容,不属于窗口边框——所以都不是玻璃。

  • GlassCheckbox,含 'mixed'。它不是重新实现的:底下是真正的 <input type="checkbox">,Tab 聚焦、空格切换、能参与表单提交,一行都没覆盖。开和关是两个形状(勾 / 空)而不是两种颜色。'mixed' 只是显示状态,按下半选的父项会全开——「一半」不是一个人点击时能表达的意思。indeterminate 是一个没有对应属性的 DOM 属性,React 渲染不出来,必须在渲染之后写到元素上;漏掉的话,框看起来是半选、念出来是未选中,而后者正是读屏用户拿到的那一半。

  • RadioGroup。同样建在共用 name 的原生单选按钮上,而这不是实现细节,这就是键盘模型:整组一个 Tab 位、方向键移动并选中、到头绕回、自动跳过不可用项。手写一套 roving tabindex 通常会在其中某一条上出错。组标题是真正的 <legend>——fieldset 上的 aria-label 各家读屏念得并不一致。超过五项在开发模式下告警并建议换 Picker。

  • useShortcut,以及菜单项右侧的快捷键提示改用 Kbd 渲染、并带上 aria-keyshortcuts。

    Kbd 本来就能把 "mod k" 画成 ⌘K——那是不会有人发现它错了的那一半。另一半是把它绑上,而两者必须出自同一次解析,否则界面印着一个快捷键、监听着另一个。mod 在苹果设备上是 ⌘、在别的机器上是 Ctrl,显示和绑定读同一张表。

    三条规则都是关于不触发的:有模态对话框打开时,只有限定在对话框内的快捷键还响(否则 ⌘S 会去保存那张正在问你要不要保存的表单背后的文档);不带修饰键的快捷键在输入框里就是一个字符,带修饰键的照常触发;按住不放只算一次命令。开发模式下两个同时存在的命令绑到同一组键会告警——先挂载的那个会赢,而那不是任何人做过的决定。

一处收回:Kbd 一度改成在非苹果平台上把符号换成单词(「Ctrl+Alt+Delete」而不是 ⌃⌥⌫),理由是 PC 键帽上没有 ⌃。理由成立,但这不是这个组件该替调用方做的决定——写 keys="ctrl alt delete" 的人要的就是那几个键,kbd.spec.ts 当场红了三条,它是对的。现在只有 mod 会按平台解析。

新增(桌面度量:platform)

这会改变桌面上的默认外观,但不改任何调用方代码。 库是按 iOS 那套表建起来的——Liquid Glass 是在那里发布的,数字也写得最全——而在一台有光标的机器上那套表是错的,且错得不小:44px 的控件和 17px 的正文是给指尖、给一臂之外的屏幕的。HIG 的 macOS 表说标准控件 22pt、正文 13/16,pointing-devices 那页说「只在带来价值时才区分指针与手指」——用 1px 的光标去点每一个 44px 的控件,是把手机界面画在了桌面上。

GlassProvider 新增 platform?: 'auto' | 'desktop' | 'touch',默认 auto = (pointer: fine) and (min-width: 768px)。两套度量的对照表见 docs/api.md。

  • 度量全部在 CSS 里决定,一个数字都不经过 JS。 --lg-control-height 是写在每一个玻璃面上的,如果它由 JavaScript 算出来,服务端渲染的页面会先带着触摸度量到达、再在 hydrate 的那一刻重排一次;而且内联样式写死之后,platform="desktop" 永远赢不了它。现在它是一个 var() 引用。
  • 命中区下限按指针分档:手指 44(HIG),光标 24(WCAG 2.2 Target Size (Minimum))。22px 的控件比 24 少两个像素——正是那种谁也看不出来、却决定了满不满足标准的差距——所以指针下也画命中区,只是只撑竖直方向:横向撑开的绝对定位区域会计入页面的可滚动溢出,实测五个布局页在 RTL 下「页面横向滚动 82px」。一条横向滚动条是真的缺陷,而一块看不见的命中区不是留着它的理由。光标是一个像素,需要 44px 宽是因为指尖是圆的。
  • 输入框里的 16px 下限只在触摸下保留。 它的理由是 iOS Safari 聚焦时会缩放页面,而那是关于触摸屏 Safari 的事实,不是排版偏好。留在桌面上的代价当场可见:16px 的行放不进 22px 的盒子,于是三种字段尺寸里有两种画出来一模一样——一个内容下限披着三个名字。
  • 控件标签跟平台自己的表走。 按钮的字号原来直接读 subhead,而桌面表里 subhead 是 11px——比平台自己的表小(macOS 的按钮标签是正文 13),也比旁边的正文小。这一条是在浏览器里看出来的,不是量出来的:数字全对,但按钮看起来像被缩过一次。
  • 拖动时的形变按平台分档(55%)。motion 那页的原话是 Liquid Glass「对直接触摸的响应更强调,用触控板或鼠标时更收敛」。手指就在那个东西上,光标是几十厘米外的一件工具,同样幅度的挤压在它底下读起来是界面松了而不是活了。

两条有意的偏离和一条没做的都写进了 docs/api.md:字号下限仍是 11px(macOS 把脚注放在 10pt,网页不行)、Dynamic Type 在桌面下照常工作、圆角不按平台切换(它喂给折射几何,改形状和改大小是两个决定)。

顺带修掉一处潜伏的 bug:分段控件的命中区是那个 <label> 的伪元素,而它的单选框是铺满整个 label 的透明输入——伪元素画在输入之上,把每一次瞄准输入的点击都吞了(Playwright 的报告很直白:「<label class="lg-segment"> intercepts pointer events」)。只在粗指针下画的时候它是潜伏的,因为点 label 两种情况都能用。放到输入底下(z-index: -1)之后,它照样把目标撑出画面之外,这正是它唯一的职责。

修复(从哪儿来,以及减少动效真的关得掉)

  • 「减少动效」只管得到玻璃和四个被记住的组件。 样式表里那条一刀切的 transition: none 写的是 .lg-root 加上手写的 .lg-tabbar、.lg-navbar、.lg-list-row、.lg-stepper;库里其余每一个组件在这个设置打开时照常动。一份需要每加一条过渡就回来补一次的名单,迟早是错的,而且错得没有声音——被记住的那几个正是你会先去检查的那几个。现在按类名前缀选([class^="lg-"],前缀不是包含,所以调用方自己的 mlg-card 不会被误伤)。这一条是计划里那句「减少动效反向断言」逼出来的:写断言的那一刻它就红了。
  • 气泡和菜单朝上打开时,是往远离按钮的方向长出来的。 缩放原点一直是对的(anchor.tsx 把触发器中心投影到面板盒上),但那 6px 的起手位移不带方向,朝下正确、朝上正好反了——实测 placement="above" 的例子从距按钮 16px 处展开、落在 10px,越长越远。真正的原因更细:@starting-style 是在面板不再是 display: none 的那一刻读的,也就是 showPopover() 那一刻,而 data-placement 是在那之后由定位代码写的。箭头、阴影、所有读这个属性的东西都是对的,唯独决定入场方向的那一帧不是。现在开之前先判方向;auto 时按上一次量到的面板高度预判,第一次则比较上下哪边空间大——和定位代码用的是同两个数字。
  • 颜色井选中的那圈描边直接出现。 它是 box-shadow,而 box-shadow 是那条过渡里唯一没列的属性——于是在一个全部职责就是告诉你选了什么的控件上,选中这件事没有过程。
  • 折叠区的标题行、分栏的分隔线:底色和线色在悬停/按下时直接换。一条在两个灰之间跳变的细线,读起来是重绘,不是控件在回应。
  • 当前选中的那一项对悬停毫无反应,四个控件都是:分段控件、页内标签、标签栏、页码点。它们的「未选中项悬停」规则都写了,选中项正好从规则里漏出去——而它恰恰是最该回应的那一项,因为选中项就是你可以拖的那一项。分段控件那一条清单在每个有它的页面上都报了一次,共 63 次。抬起来的是阴影,不是缩放——缩放写在 transform 里,悬停时开一条 transform 过渡会顶掉正在跑的那条,指针扫过控件就会让滑行重新开始。interruptible.spec.ts 在这条规则写出来一分钟内就说了这件事。
  • 颜色井本体没有任何按压或悬停反应。 指针落在一个铺满整个盒子的透明 <input type="color"> 上,所以要回应的是它底下那块色块。一个整个表面就是它的值的控件,没有反应的地方恰恰是最显眼的地方。

明确不做的一条:步进器按加减时数字直接换,没有像徽标那样弹一下。按住会每 90ms 重复一次,而一次 320ms 的弹跳会自己压住自己——数字在动本身就是反馈。清单里剩下的两条就是它,留着是为了说明它是决定而不是遗漏。

修复(来去成对)

「materialize, not fade」通常被当成一条关于出现的规则,这个库此前也是这么读的:轻提示弹进来,然后在两帧之间不再存在;横幅进场退场都没有;切标签时离开的那块面板只是被 hidden 掉。一个东西出现时有人照顾、消失时没人照顾的界面,是在教读者「消失」等于「出错了」。

这一组由一次新的扫描找出来:pnpm test:motion 在每个组件页上逐个控件做悬停、按下、激活、聚焦,然后问 getAnimations() 有没有东西在动。它报告,不断言;每一条缺口变成 tests/browser/motion.spec.ts 里一条有名有姓的用例。

  • 轻提示没有退场。 React 没法给一个已经被它删掉的节点做动画,CSS 也不行。现在「正在离开」是一个状态而不是一个事件:关闭按钮、Escape、倒计时走完、被新的一条挤出队列——四条路全部经过同一处,所以在屏幕上长得一样。记录带着 leaving 留在列表里 220ms,播完再删。挤出队列时数的是还在屏幕上的那些,否则一条正在退场的提示会把一条活着的顶掉。
  • 横幅进场退场都没有。 现在两头都有,而且都走 transform:translate 归那个「往上一甩就关掉」的手势,一出现就不再跟手的横幅比不动还糟。onDismiss 因此在退场播完之后才到达调用方——组件得还在树上才动得了,而把它从树上拿走的是调用方。减少动效下没有东西要播,当场就到。
  • 离场的标签面板是被切掉的。 hidden 就是 display: none,而 display: none 会当场结束动画。现在用 transition-behavior: allow-discrete 把那一下切换推到过渡末尾,面板先淡出再停止存在;所有面板放进同一个网格格子,否则两块同时显示的面板会在正常流里上下排开,页面在切换的那 220ms 里高度翻倍再塌回来。
  • 徽标的数字直接换。 二变三就是徽标全部要说的那件事,而在两帧之间换掉字形是唯一一种眼睛可以完全错过的送达方式。现在数字变化会弹一下——由脚本驱动,因为触发它的是内容变了,CSS 没有这个选择器;也因此样式表那条一刀切的 animation: none 管不到它,组件自己去问了偏好。
  • 分栏的侧栏和检查器是被删掉的。 260px 到没有,中间没有一帧,读者得自己判断它是收起来了、移走了,还是从来就不在。现在栏一直在树上,宽度收到零之后把 visibility 关掉——visibility 的过渡在出场时整段保持 visible、进场时立刻变 visible,所以两个方向都有动画而不需要 @starting-style;没有它,第一次绘制就不会变成一次入场。关掉的栏也因此离开了 Tab 顺序和无障碍树。

    第一版把这条过渡无条件写上去,于是它也抓住了每一次改宽:拖分隔线时侧栏落后指针四分之一秒,方向键看起来根本没反应。现在只在「显示/隐藏」那一次切换期间挂上。读者正在设的宽度必须和他们一样快。

  • 栏收起时内容会跟着重排。 宽度往零走的过程中里面的文字每帧重新折行——一个在退场途中自己重新排版的栏,读起来是出错了而不是关上了。滚动移进了内层盒子,它保持原来的宽度,动的只有外面那道裁剪。

修复(动效可打断)

所有者报的:快速连点可拖动控件的选项时,动画被跳过,直接闪到目标,而且不止一个组件。查实是一处手势代码,所有基于选中透镜的控件加上开关全都走它。

  • 按下就把元素标成「正在拖动」。pointerdown 一发生就盖上 data-pulling,而样式表在这个属性下会把 transform 移出过渡列表——这会取消正在进行的过渡,元素在那一帧直接落到终点。逐帧量到的:透镜从 45 滑向 0,滑到 51.3 时按下另一项,下一帧就是 0,一步 51.3px;而一整段顺畅的滑行每帧最多 9px。现在按下什么都不写、什么都不标,直到指针真的移动超过 4px——一次点击自始至终不碰过渡。
  • 按下还会把元素搬到手指底下。同一帧的偏移计算是「指针减去透镜静止中心」,按在透镜不在的那一项上,整段距离被当成偏移写出去:实测按住不动,透镜瞬移 45px(侧边栏 92px)。现在按在哪里由控件自己说了算——胶囊是你拖的东西,press 必须落在它上面;按别处是点击,松手后滑过去。
  • 中途接管要从当前画面接管。手指接手的那一帧仍然要取消过渡(不然透镜会跟不上手指),所以在盖属性的前后各量一次位置,把差值当成起始偏移交回去:元素从它看起来所在的位置继续,再由指针带走。
  • 识别阈值不白吃。识别一次拖动要先花掉几像素,留着会让元素永远落后手指(慢拖 8px,快甩半条轨道),一帧还清就是同样大小的跳变。改成在 50ms 内还清——所以「透镜 1:1 跟手,误差 2px 以内」这条既有用例一个字都没改。

HIG motion 那一页的原话是「Let people cancel motion」。可打断的意思是从当前位置改道,把它砍到终点再重来是反过来的——那正是「动画被跳过」。

还差一口气:改道之后弹簧是从零速度重新起步的,位置连续、速度不连续。真正的速度接管需要把透镜的位移换成 JS 弹簧,排在后面。

修复(把文档站写在页面上的断言拿去量)

这一组来自一次只问「页面自己说到的有没有做到」的审计。页面上写着「低于 4.5:1 就告警」「触摸时命中区是 44」「小号 36px」,那就一条条拿到浏览器里量。断言本身就是可测的用例,而此前没有人把它们当用例跑过。

  • 开发期的色调护栏量错了对子。它比的是 tint 对 tintContrast ?? '#fff',也就是主操作按钮画出来的那一对;而 tinted、plain、destructive 的标签是色调本身压在同色的淡底上。于是护栏量的是屏幕上不存在的两个颜色,并放行了一个实际 2.87:1 的按钮。现在它读渲染后的 color,再把按钮自己的色调层、背景和每一层祖先合成到不透明为止,量真正画出来的那一对。改完当场逮到站点自己的绿色确认按钮:4.45:1(旧算法给它 5.6:1 并放行)。一条量错对子的护栏比没有护栏更坏——它让每个下游调用方都以为自己被看过了。
  • tinted 的标签读不出来。系统蓝本来就是背景色,当文字压在自己 16% 的淡底上是 2.75:1。修的是墨色不是底色:新增 --lg-ink-toward / --lg-ink-amount 两个按外观分开的 token,在用到的那个元素上混色——自定义属性是在声明它的地方取值的,所以一个在 :root 上算好的「墨色」会永远跟着根强调色走,调用方传的 tint 反而拿不到它。
  • 「增强对比度」这个开关是空的。HIG 允许默认配色不到 4.5:1,前提是这个设置能把它救回来——而站内开关只换了三个标签灰,十二个系统色一个没动,于是徽标和强调色按钮从「和 Apple 一样的取舍」变成了「没有退路的失败」。现在属性路径和系统偏好用的是同一份调色板,tests/core/stylesheet.test.mjs 断言两份完全一致。
  • GlassProvider 的 contrast 和 motion 只走到 React。玻璃听它们,因为材质在 JS 里读 policy;任何在 CSS 里决定的东西——页面转场、标准材质、标签栏——只认媒体查询。把自己应用的设置界面接到这两个属性上的人,拿到的是一个看起来生效、实际什么都没变的开关。现在最外层的 provider 把显式覆盖写到 <html> 上:data-lg-motion / data-lg-transparency / data-lg-contrast,样式表每一处 @media 都配了一条属性选择器。
  • 分段控件和标签页在触摸下没有 44 的命中区。.lg-button、.lg-page-dot、.lg-split-divider、.lg-tab-link 都补了,这两个漏了——而按钮页上写着的那句承诺,读者会当成整个库的规矩。只撑竖直方向:分段是挨着排的,横向撑到 44 会让相邻两段的命中区互相压住,按到隔壁比按不到更糟。
  • Picker 在窄窗口里会把两个选项收成菜单,而这个库自己的规则说「三项以下的菜单比它替掉的按钮露出的信息更少」——八个组件页在手机宽度上打印这条告警。现在两个选项在任何宽度下都并排。
  • 小号输入框实际是 38px,文档写 36。输入框自己的行盒是 22 的行高加上下各 8 的内边距,36 是一个内容本来就够不到的下限。让内边距让步,16px 的字号不动(再小 iOS Safari 会在聚焦时缩放页面)。
  • NavigationBar / NavigationStack / ListSection 的标题层级。前两者只到 3,而文档站的示例标题本身就是 3,于是示例里的样例标题和页面目录混成一份;ListSection 的分区标题则是写死的 h3。层级范围放开到 6,ListSection 新增 headingLevel(默认 3)——分区标题确实是标题,读屏用户靠它找到这一组,所以不是取消,是放到对的深度。
  • 相关组件里有两条死链(nav-bar、screen),静默丢弃并在控制台报了很久。现在 scripts/check-props.mjs 会因为死链拒绝构建。
  • 浮层组没有一个示例能在照片上看。约 120 个示例里只有 13 个有背景可换,而「浮在内容之上」正是这十一个组件的全部说明——一个从不把它们放在任何内容之上的文档站,是在反驳自己。现在每个浮层页都有「图片背景」开关,并由构建脚本守住。
  • 显示偏好面板里 label 套 label,逐字踩中站点自己 Form 页写的禁令;顺带那条禁令给的理由也是错的(Chrome 里外层照样能点,问题在于名字被念两遍)。文字大小只给到 AX3,而布局规则的注释里量的是 AX5。
  • 相关组件卡片在 AX3 的手机上把文档推歪 50px——「分段控件 GlassSegmentedControl」是一个 408px 的不可断词,落在 341px 的盒子里。app.css 已经为同一个原因在六处加过同一行。
  • Text tone="tertiary" 被当成正文示范,还挑了 11px 那一档。tertiary 是占位符和停用态的颜色,低对比是设计意图——问题在于教排版的那一页照着它写了一段要读的正文。
  • 页码点的说明把话说满了:「触摸时命中区是 44」只有竖直方向成立,横向是 18。取舍合理,话不合理。
  • 旋钮面板的注释写着可以复位,而界面上没有。现在动过之后才出现「恢复示例原样」。

新增

  • Picker:从一小组值里选一个,形态是结论不是参数。四个以内且宽度够就并排成分段控件,再多或者到了紧凑尺寸就收成弹出式菜单按钮——HIG 的 layout 页反复说的那条,按尺寸类别决定布局,永远不按设备类型。它不是「弹出式按钮加个标签」:可以写死形态,但默认不该由每个调用方去猜,否则最后就是一个五段的分段控件被塞进手机。可见的那行文字对读屏是隐藏的(控件自己拿同一串字当名字,念两遍没有意义),而语音控制匹配的是控件的名字、和可见文字逐字相同,所以照样能命中。
  • Banner:留在顶部的一条通知。和轻提示的分工是「这件事发生在哪」——轻提示报告你刚做完的那一下、几秒后自己走;横幅报告别处发生的事,一直留到被处理掉,所以它有标题、有一整句话的余地、有关掉它的办法。必须先回答才能继续的,两个都不对,那是警告框。默认在流里而不是钉在窗口顶部:一个自己决定位置的组件没法被组合,而布局本来就知道自己的顶在哪。往上一甩可以关掉,但那永远是关闭按钮之外的一条路——没有可见入口的手势,对键盘用户等于不存在。
  • ColorWell:选颜色。底下是真正的 <input type="color">,铺满整个 44×44 的外壳并且透明——把它藏起来再用脚本点开,会同时丢掉焦点环、键盘和表单,而点开的本来就该是操作系统自己的取色器(带吸管、带最近用过的颜色)。旁边默认显示色值:一个控件的全部状态如果就是一种颜色,分不清颜色的人读不出它。快捷色每一项都必须有名字,选中态是一圈描边而不是「颜色深一点」。
  • DisclosureGroup:一行可以展开的内容,底层是原生 <details>。浏览器的页内查找命中折叠内容会自动展开它,摘要对读屏本来就是带展开状态的按钮,回车空格本来就能用——用 div 加 aria-expanded 重做一遍,会把第一条悄悄丢掉。内容层。
  • PageControl:在一组有顺序的页面里的位置。没有顺序的一组目的地是标签栏,用这个等于告诉读者存在一个并不存在的次序。顺着它拖能翻页,不只是点。超过十个点开发模式告警——数不过来的点不再表示位置。
  • SplitView 与 Inspector(布局第三、第四个):并排两到三栏,低于 768px 折叠成 NavigationStack——这正是 iPhone 上系统的退化方式。整个视图一个标题。分隔线永远可见、可拖、也可以用键盘(← → 调宽,Shift 走 40px,Home/End 到两端,双击复位):只能拖的宽度是键盘用户设不了的宽度。Inspector 是内容层不是玻璃——它是窗口的一块区域,在读者和他正在编辑的东西之间放一层半透明面板帮不了任何人。
  • ContextMenu:右键、长按、Shift+F10 / 菜单键。最后一条是平台打开右键菜单的键盘方式,也是唯一的一条——没有它整个功能只有指针能用。里面的每一条命令都必须有别的路径能做到;这条代码检查不了,写在文档里。
  • Grid:按 minItemWidth 自己算列数,所以同一个网格放进侧栏和铺满整宽都对——而它所在的那个盒子往往不是窗口。不做虚拟化。
  • Form / FormSection / FormRow:分组的设置表单。真 <form>,分区标题是真 heading,错误挂 aria-describedby。
  • GlassButton icon / trailingIcon / tint:图标插槽(间距是系统值,不该由每个调用方自己决定)和单个按钮的色调。一屏仍然只有一个主操作——tint 换的是它的颜色。色调需要配一个能读的文字色,而这一点算不出来(所以仍然没有全局 accent),但开发模式能量调用方配的那一对,低于 4.5:1 告警。
  • GlassSlider marks:刻度。true 是每步一个,步数超过 20 会被拒绝并告警——那时它是一条网格线不是信息。双滑块没做,顺延 0.4.0:两个重叠的滑块需要一套「按下时归谁」的仲裁规则,做半截比不做差。
  • TextField multiline / controlSize:真 <textarea>。multiline 是可辨识联合而不是布尔开关,所以不传它时单行那一套的 ref 类型完全不变。变矮的是控件不是文字——低于 16px 会让 iOS Safari 聚焦时放大整页。
  • SearchField suggestions:传了就是 combobox。高亮靠 aria-activedescendant,焦点不离开输入框(把真焦点移进列表是这个模式最常见也最错的做法:下一次按键就到不了输入框)。Escape 只关列表不清空。筛选永远是调用方的。
  • ToastOptions tone / icon:语气自带图标,所以不是只靠颜色。
  • Tooltip:把图标按钮的名字显示出来给鼠标用户看。aria-label 告诉了读屏它是什么,鼠标用户只能靠猜。触摸屏上整个组件不渲染——没有悬停的地方,提示只能变成「点一下先弹个东西挡住按钮」;用 aria-describedby 关联而不是 aria-labelledby,它是补充说明不是名字;聚焦立刻出现,悬停要等 600ms。
  • Kbd:快捷键提示。修饰键顺序由组件排(⌃ ⌥ ⇧ ⌘,Command 挨着被它修饰的键),写法随意。⌘ ⌥ ⇧ 这些符号读屏念不出来——有的跳过,有的念成「兴趣点符号」——所以元素自带 aria-label(「Command K」),符号本身 aria-hidden。
  • NavigationStack(布局第二个):一摞页面和一条跟着走的导航栏。返回按钮写的是上一页的标题,不是「返回」——方向你已经知道了,目的地你不知道;aria-label 两样都说。压栈弹栈都把焦点移到新页面,否则键盘用户点了一行、页面换了,下一次 Tab 会从那一行原来的位置继续,而那一页已经不在了。useNavigationStack() 在栈外调用会抛错,不是静默失效。
  • NavigationBar headingLevel:一个永远输出 h1 的组件一页只能用一次,而两个 h1 会破坏读屏用户靠标题跳转的能力。做 NavigationStack 的文档页时撞上了。
  • GlassMenuButton:自带菜单的按钮,两种。下拉式(pullDown)按钮说自己做什么,标签不变,菜单列相关命令;弹出式(popUp)按钮上显示的就是当前选中项,菜单项是 menuitemradio 而不是一排独立勾选框,打开时焦点落在已选中的那一项上。用 GlassButton 加 GlassMenu 拼得出形状,拼不出这几件事。少于三项时开发模式给一条告警。
  • Screen(第一个布局容器):把「内容延伸到栏下面、安全区打在栏上、一个视图一个滚动边缘效果」做成默认行为。内容预留的是量出来的栏高,不是写死的数字——栏有多高取决于它自己的内容,而内容随文字大小变。文档站改用它,顺带关掉了紧凑目录审查留下的那条发现:全站 0 个 ScrollEdge,工具栏就那么浮在滚动内容之上。站点自己那套量栏高的代码也一并删掉了,现在只有一处在量。
  • GlassMenu selection:single 让带勾的项变成 menuitemradio。ScrollEdge.targetRef 变为可选,省略时监听页面本身——这一种写不成 ref,页面的 scroll 事件派发在 document 上。
  • GlassProvider strings={…}:组件自己提供的那几个标签——对话框的关闭、步进器的两个箭头、搜索框的清除、面板拖动手柄——此前写死英文。它们也正是只有读屏用户才会听到的,所以一直是英文也不会在截图里露馅。三级优先:组件上的具体属性 > strings 表 > 内置英文;表可以只写一部分。库只内置英文,不从 navigator 猜语言——判断应用说什么语言是应用的事。
  • useSizeClass()('compact' | 'regular',以 768px 为界)与 REGULAR_MIN_WIDTH。HIG 要求按尺寸类别决定布局,而不是按设备类型或方向。--lg-margin 现在在同一个断点上自己从 16px 切到 20px(此前它永远是 16px,调用方得自己写媒体查询换成 --lg-margin-regular),GlassActionSheet 内联的那个 768 也改读同一处。
  • placement('below' | 'above' | 'auto')暴露到 GlassPopover 与 GlassMenu。锚定代码一直支持,只是没有接口能用上。
  • 三条开发模式告警,都是此前靠人眼在审查里查的规则:同一表面上两个 glassProminent、小玻璃套小玻璃、material="clear" 用在没声明色调的地方(会静默降级成 regular,调用方不知道)。每个节点每条规则只响一次,生产构建里完全不存在。

找 bug 的方式

  • 属性表比对进了 pnpm build:site(scripts/check-props.mjs):用 TypeScript 编译器 API 问每个 *Props 到底声明了什么,再用语法树问目录页到底列了什么,对不上就构建失败。第一次跑出 21 页对不上;排掉「文档里统一说过一次」的那些(children、浮层共用的 trigger/open/defaultOpen/onOpenChange)之后,剩下 15 页是真漏了——disabled、onValueChange、aria-label 这类最基本的属性。全部补上。

  • 审计矩阵(pnpm test:matrix → reports/matrix.json):把上一次人眼过 27 页的全面审计变成机器跑的东西。每个组件页 × 11 种变体,问的是答案与审美无关的问题——横向溢出、命中区、图标按钮有没有名字、文字下限、塌成 0 的元素、控制台报错。第一轮跑出三条真问题(都在文档站,不在库里),修完 341 格全清。

    第 4 周又改了一次:相邻的控件互相「挡住」对方的命中区,把页码点整排报成了太小。落在邻居控件上不是失手——一排点相互紧挨着,手指滑到下一个就是命中下一个。规则现在只报落在完全不可交互的东西上的情况。这条放宽有代价,写在用例里了:一整排都太小的控件会自己豁免自己,所以这类控件另外带自己的量测。撤掉 .lg-button::after 验证过它仍然会响(34 条)。

    第一版规则本身是错的:量 getBoundingClientRect 报了六千多条,几乎全是误报。控件常常画得比可触区小(输入框 38px 躺在 44px 的玻璃盒里,按钮用 ::after 撑开命中区),而 44 本来就是手指的要求,在指针平台上报 34px 的行只会把真问题埋掉。改成只在触摸变体里做命中测试——以控件中心取 42px 方块的四角问文档那里是什么。

  • 布局容器的适配矩阵(tests/browser/layout-matrix.spec.ts,进 pnpm check):5 个容器 × 4 个宽度 × LTR/RTL × 默认/AX5。和上面那张报告式矩阵不同,这一张是断言的——一个容器的全部工作就是把东西摆好,所以「它还放得下吗」不是对它的观察,而是它的承诺。四条布局缺陷是它抓出来的。

    途中有两件是工具本身的问题,一并修了。gesture.spec.ts 把选中透镜按 aria-label 归组,而一页里允许有两个同名控件,于是报了一个从没动过的透镜「动了」——键改成元素本身。playwright.config.ts 里 matrix\.spec\.ts 没有锚定到路径分隔符,layout-matrix.spec.ts 一建出来就被划进慢速报告项目,pnpm check 里哪个项目都不跑它。一个被静默跳过的用例比一个失败的用例更糟。

文档站

  • 每页至少三个示例,每页恰好一个可以直接调的示例。三个,是因为一个示例只能回答一个问题,而读者至少带着三个来:状态有哪些、换个尺寸或密度会怎样、压在别的东西上什么样。恰好一个可调,是因为全都带旋钮的页面是一块装了几个组件的控制面板,而一个都没有的页面回答不了「这个属性到底做什么」——除非你复制粘贴出去试。两条规则都挂进了 scripts/check-props.mjs,和属性表一起在 pnpm build:site 时把关。
  • 旋钮面板用本库自己的 Form:一页组件文档旁边的一排控件,正是表单容器存在的理由;在这里用别的东西,等于这一页在推荐一个它自己作者都不用的组件。改动同时进示例和下面的代码块——面板的意义是你既看得到这个属性做什么,也看得到要敲什么才能得到它,而一段跟不上旋钮的代码是在给读者看一个产生别的东西的调用。
  • ⌘K 不再只认组件名。人真正记得住的是属性名(marks)、示例标题(「刻度」)或者章节名(「键盘与辅助功能」),而搜这些以前什么都搜不到——读起来像「这个库没有这个东西」。现在四类都能搜,并且直接跳到页内那个位置。
  • 首页重排。标题、两个入口、一行「40 个组件页 / 0 个运行时依赖 / 4 项系统偏好自动生效」,然后才是演示。折射开关从一块占满整列的灰卡片变成压在演示上方的一条控制行——原来它比下面的演示还宽,两块对不齐,看起来像坏了。演示本身也重新铺满整列:它有固定宽高比和高度上限,作为 grid 子项时会反过来用那个上限算自己的宽度,于是自己缩窄了。
  • 概览首屏加了折射开关。「默认是磨砂玻璃,折射要自己打开,而且只有 Chrome 和 Edge 能跑」这句话在这一页上挂了几个月,却没有任何办法看到区别——一个你声明了却演示不了的边界,读起来像借口。浏览器做不到的地方开关是禁用的并写明原因:一个按下去什么也不会变的开关,比没有这个开关更糟。
  • 每个组件页补上三样常规的东西:该 import 什么、相关组件、查看源码 / 报告问题。相关组件的 slug 会被校验,写错不会变成死链。
  • 代码高亮,自己写的分词器,不引依赖——站点被「零外部请求」守着。颜色来自语义 token,所以跟着浅色 / 深色翻,强制颜色下让位给系统。
  • 切页会设 document.title、把焦点移进新页面。哈希路由不做浏览器为真实跳转做的任何事,而这两件只有看不见屏幕的人会付代价。这是 R1 里代码能解决的那一小部分,不替代其余部分。
  • 更新日志页(#/changelog),构建期内联仓库的 CHANGELOG.md。标签上的发布说明本来就是这个文件的对应小节,再手写一份等于给同一个版本两种说法。
  • 1280px 以下的「本页内容」:没有目录栏时,同一批目的地变成一个吸顶菜单——用的是本库自己的 GlassMenu。做完过了一遍 Apple-Style-Review,五条发现修掉四条,详见 docs/roadmap/0.0.2/phase-1-delivered.md。

查过但没复现

0.0.2 立项时从代码里读出六条疑点,逐条写成「如果它真的存在,怎么量得出来」,五条没复现:1024 边界上的选中透镜没有飞出去;RTL 滑块是 LTR 的镜像(第一版用例自己算错了位置——它拿轨道外面的导轨当基准,少算 13px 内缩,于是报了一个并不存在的缺陷);AX5 下面板标题没有压到拖动手柄;主题切换没有中间的白帧;融合层在强制颜色下根本不渲染(第一版数的是按压形变那个循环,而那个本来就该跑——强制颜色说的是颜色,不是动效)。

复现了一条:工具栏的方向键遍历进不去里面的分段控件。修复会改变既有键盘行为,归 0.3.0;用例先留下,断言的是现在的行为,等 0.3.0 修好再反过来。

Strict Mode 下的 rAF 循环重复启动、defaultOpen 浮层的 hydration 不匹配——两条都去查了,都没复现,用例留下当回归守卫。Firefox < 129 那条在这里无法复现(Playwright 装的是 155)。结论与证据见 docs/roadmap/0.0.2/phase-1-delivered.md 第三节。

验证

70 单元 · 7 SSR · 245 真实 Chrome · 11 开发模式 · 18 WebKit + Firefox
另有审计矩阵 pnpm test:matrix:37 个组件页 × 11 种变体 = 407 格,0 条发现