流式token持续写入ARIA live region导致辅助技术永远无法完成播报,assertive打断用户自己导航,polite则语音落后文本无法追上,均无解。
几乎每个 AI 产品都在做同一件无障碍失败的事:将文本流式输出到一个 ARIA 实时区域,而这并非粗心所致。流式交互与实时区域模型基于互不兼容的假设,没有任何属性能让二者调和。
令牌流每秒会对容器进行多次突变,持续数十秒。实时区域是向辅助技术(AT)许诺:该容器的变化值得播报。将二者合在一起,就意味着要求 AT 去播报一个永不停歇变化着的区域。
两种可用的礼貌设置都失败了,只是方向相反:
aria-live="assertive" 会打断当前正在播报的内容。在流式传输下,每次突变都会打断前一次的播报,所以用户听到的是大量碎片的首部,什么完整内容都没有。这还会导致界面无法导航,因为用户自己的探索操作也会被打断。
aria-live="polite" 会等待一个停顿。播报会排队等候,而根据 AT 和浏览器的不同,队列要么被清空——丢失内容——要么被逐一处理,在后一种情况下语音会越来越落后于文本。有些组合会在每次突变时重新播报整个区域,这将一个 400 字的回答变成数百次越来越长的重复朗读。
这不是任何一个实现中的 bug。实时区域是为离散更新设计的——状态改变了、结果数量更新了——而令牌流是一种连续更新。规范中没有考虑一个区域连续突变 30 秒的情况,所以根本不存在一个正确的值可以设置。
值得精确表述,因为修复方法取决于此。实时区域是 AT 观察的容器;突变时,AT 计算发生了什么变化并排队等待播报。aria-atomic 控制是播报整个区域还是仅播报变化的部分。aria-relevant 控制哪些突变类型符合条件。礼貌设置控制队列相对于当前语音的行为。
这些属性中没有一个提供速率控制。没有"每个 N 秒最多播报一次这个区域",也没有"只在停止变化时才播报"。由于流的问题完全是速率问题,现有任何属性的组合都无法解决它——这就是为什么修复必须在应用程序层面而不是标记层面。
将视觉流与无障碍播报解耦。它们在为不同的用户提供不同的需求,错误在于假设一个 DOM 节点必须同时做两件事。
在视觉上流式传输,但不进入无障碍树。增量容器设置 aria-hidden。视力用户获得感知延迟的收益;AT 不被要求去播报一个消防水管般的内容。
礼貌地、以人类可接受的间隔播报状态。一个小的 role="status" 区域显示"正在生成回答",如果等待时间较长则定期发送安抚信息。这是一个离散更新,这正是实时区域的用途。
在完成时提交完整回答。一旦完成,将完整文本写入一个普通的、非实时的容器,并播报它已就绪。然后用户用自己的导航方式阅读——按行、按句子、按段落——这与其他内容的阅读方式相同,远比被朗读要好得多。
提供分块提交作为偏好选项。有些用户确实想要渐进式访问。按段落提交是一个合理的中间设置;按令牌提交则永远不是。
值得记住的洞见:对于屏幕阅读器用户来说,流式传输几乎没有视觉上的好处。视力用户可以略读到达的文本并立即开始提取价值。线性语音无法略读。流式传输的价值一直是能够提前阅读正在生成的内容,而这种能力在这里并不存在——所以忠实地再现视觉行为是在再现一个无法迁移的好处,代价是一个主动破坏体验的机制。
function Answer({ text, state }) {
// state: "waiting" | "streaming" | "done" | "error"
const streaming = state === "streaming";
return (
<>
{/* 1. Status: discrete, polite, low frequency. */}
<div role="status" aria-live="polite" className="sr-only">
{state === "waiting" && "Generating response"}
{state === "done" && "Response ready"}
{state === "error" && "Response failed"}
</div>
{/* 2. The visual stream, hidden from the a11y tree. */}
{streaming && (
<div aria-hidden="true" className="prose">
{text}
</div>
)}
{/* 3. The committed answer: a normal region, announced once
by the status above, then navigated by the user. */}
{state === "done" && (
<div className="prose" tabIndex={-1} ref={focusOnCommit}>
{text}
</div>
)}
</>
);
}
有两个细节比看起来更重要。状态区域必须在内容变化之前就存在于 DOM 中——在同一 tick 中添加并填充的实时区域经常根本不会被播报。而将焦点移动到已提交的回答必须是一个偏好选项而非默认行为:焦点移动对等待结果的用户有帮助,但对在等待期间已导航到其他位置的用户有害。
取消按钮必须始终可通过键盘触达。在 30 秒的生成过程中,取消控制是屏幕上最重要的东西。如果它只在延迟后才出现、出现在不断增长文本块的下方、或者只能通过鼠标触达,用户就没有出路。
打字机效果需要 prefers-reduced-motion。持续出现和重排的文本是运动。通过以更大的块或一次性提交来尊重该偏好,这对觉得移动文本难以阅读的人也是更好的行为。
保留布局稳定性。内容不断增长会将下方所有内容向下推,持续 30 秒。这对试图跟踪特定区域的放大镜用户不友好,也对任何试图击中移动目标的有运动障碍的用户不友好。
永远不要仅用颜色来编码状态。置信度显示中的 grounded/unverified 标记和引用 UI 中的引用状态需要形状、图标或文字作为辅助。
提供对详细程度的控制。冗长的带 hedging 的输出是一个认知无障碍问题,而且对所有人来说都更差。"更简短"控制是无障碍功能中少数几个同时也是成本控制的之一。
其中两点值得强调,因为它们不是对现有指南的适配——而是由模型本身创造的新义务。生成的内容没有固定长度,所以用静态内容时稳定的布局在这里不再稳定;生成的内容没有固定的阅读级别,所以为某一标准编写的文本现在变成了模型产生的任何内容。这两点都是内容的属性而非标记的属性,这意味着没有任何审计工具会发现它们,也没有任何组件库会修复它们。
这指向了本文的真实总结:AI 界面的无障碍设计主要不是关于 ARIA。实时区域的冲突是真实的,是这里最尖锐的技术问题,用大约 30 行代码的提交模式就能解决。除此之外的一切——长度、阅读级别、布局稳定性、停止能力——都是关于为你未编写的内容做设计,这才是持续困难的部分。