Pi 终端主题如何根据终端配色自动生成对比度安全的主题
There are many themes, but this one is yours
Earendil 为 Pi 推出新的 system theme 并设为默认,它会读取终端的颜色,据此生成 Pi 的主题。Pi 有约 60 个颜色角色,其做法是用 OKLCH 的明度轴按对比度要求决定每个颜色的明度,色相与彩度则取自终端调色板。团队还分析了 Ghostty 自带的 460 多个主题,发现深色主题中亮色变体仅在约 60% 的情况下对比度更高。
在 Earendil,我们希望打造尊重使用者选择的产品。所以当我们重新设计 Pi 的主题时,我们想要一个能适应其运行终端的主题。大多数整天泡在终端里的人都已经为自己挑选了主题。为什么不干脆让 Pi 反映他们的选择呢?
结果就是新的系统主题,它现在是 Pi 的默认主题。它会向你的终端询问其颜色,并据此构建 Pi 的主题。在这篇文章中,我们分享它的工作原理。
终端中的 Pi 会话。启用 JavaScript 后,它会在此处播放,并可在 24 种终端主题中展示。
在你阅读文章剩余部分之前,我们想先剧透一点:一切都关乎对比度。在终端 UI 中选择颜色是一个用户已经回答过的品味问题。从那里开始,所有其他决定都归结为一个问题:一种颜色在它所在的背景上有多突出?如果我们搞错了,就会给用户带来糟糕的体验。
但问题在于,对比度是一种感知问题,而不是我们可以轻易测量的东西。两个人看同一对颜色,可能会对是否可读产生分歧。我们做了一个小型互动调查,你可以参与,向你展示你如何感知这种对比度,以及它与其他人的比较。你会在文章末尾找到关于该调查的详细信息。
你能信任 ANSI 调色板吗?
每个终端主题都定义了 16 种 ANSI 颜色,匹配终端最简单的方法就是直接使用它们。但 ANSI 调色板其实并没有什么规则。最初的标准 ECMA-48 于 1976 年通过。其几乎相同的美国版本 ANSI X3.64 于 1979 年发布,名字也由此而来。该标准命名了八种颜色(黑、红、绿、黄、蓝、品红、青和白色),但没有说明它们应该是什么样子,或者它们应该如何与背景相关联。
八种亮色变体并不属于该标准。相当多的终端用更亮的颜色渲染粗体文本,这实际上又给了它们八种颜色。直接选择亮色的代码后来由 IBM 的 aixterm 添加,并被其他终端如 xterm 采用。
由于亮色最初是作为粗体文本出现的,我们会假设“亮”意味着更突出。而且由于早期终端大多在深色屏幕上显示浅色文本,更亮也意味着更高的对比度。我们查看了 Ghostty 自带的 460 多个主题,如今这一点只是勉强成立。在深色主题中,亮色变体在约 60% 的情况下具有更高的对比度。在浅色主题中,只有约四分之一,因为亮通常仍然意味着更浅,而在浅色背景上这意味着更低的对比度。各个主题之间也不一致:在 Gruvbox Dark 中,亮蓝色比蓝色对比度更高,在 Catppuccin Mocha 中则更低,而在 Tokyo Night 中它们是同一种颜色。与背景的对比度也差异很大。我们用 WCAG 2 对比度来测量,它比较两种颜色的亮度,范围从 1:1(无对比度)到 21:1(黑底白字)。普通文本至少应达到 4.5:1,大号文本和 UI 元素应达到 3:1。亮黑色,很多软件用它来显示次要文本,在大多数深色主题中甚至达不到 3:1。
这不是主题的缺陷。调色板是为了给简单应用的输出上色而设计的,比如红色的错误或绿色的成功消息,而且许多主题的设计目标是好看,而不是满足最低对比度要求。但这使得在其之上构建一个可访问的、更复杂的 TUI 变得困难。Pi 有大约 60 个颜色角色,从正文文本和暗淡文本到工具调用背后的面板,以及红色错误面板上的红色文本。我们希望使用你的颜色,同时保证它们都保持可读。
对比度就是你所需要的一切
对比度是用户界面中颜色最重要的方面之一。如果对比度太低,人们将很难使用你的产品。对比度主要由明度决定。饱和度对它有一点影响,但到目前为止最重要的因素是一种颜色相对于其背后颜色的明暗程度。
RGB,我们通常书写颜色的方式,没有明度轴。它是为了让机器在显示器上显示颜色而制造的,而不是为了让人类理解颜色。作为一个 3D 形状,它是一个整齐的立方体,每个通道对应一个轴。但在这个立方体中彼此接近的颜色不一定是人类会描述为相似的颜色。例如,#0000ff 和 #00ff00 都有一个通道处于满强度,但在白色背景上,蓝色的 WCAG 对比度为 8.6:1,而绿色只有 1.4:1。
RGB 色彩空间作为一个立方体,每个通道对应一个轴:黑色和白色位于相对的角上,红色、绿色、蓝色及其混合色位于中间的角上。
RGB 色彩空间。拖动以旋转,并选择一种颜色在该处将其切开。
像 OKLCH 这样的感知色彩空间则是围绕人类感知构建的。在 OKLCH 中彼此接近的颜色也是人类会描述为相似的颜色,它的轴是人类用来描述颜色的轴:明度、色度(颜色的鲜艳程度)和色相。因为它遵循人类感知而非显示器的硬件,它的形状比立方体要奇怪得多。
同样的颜色在 OKLCH 中呈现为一片景观:明度从黑到白,色相从前到后,高度是那种明度和色相的颜色所能具有的色度。每种色相都在不同的明度处达到峰值:蓝色接近黑色,黄色接近白色。
同样的颜色在 OKLCH 中:明度从左到右,色相从前到后,色度作为高度。拖动以旋转,并选择一种颜色在该处将其切开。
有了明度轴,系统主题背后的理念就很简单:Pi 根据对比度要求决定每种颜色的明度,并从你的终端调色板中获取色相和色度。
明度
为了弄清楚每种颜色需要什么明度,我们写下了 UI 中两个颜色相遇的每一个地方。每个面板都需要与终端背景有足够的对比度,以被读作一个单独的区域,但又不能太强烈以至于分散注意力。每个前景色都需要在它可能出现的每个背景上有足够的对比度。例如,一条错误消息必须能在背景上、在选中行上以及在所有三个工具面板上可读。在代码中,这是一个规则列表:
const COLORS = ["accent", "success", "error", "warning"];
const SURFACES = ["background", "selectedBg", ...TOOL_PANELS];
{ token: "text", on: ["background"], level: "text" },
...each(COLORS, SURFACES, "readable"),
{ token: "dim", on: [...SURFACES, "customMessageBg"], level: "subtle" },
对比度算法通常接收两种颜色并返回它们之间的对比度。这里我们需要反过来:我们知道背景和想要的对比度,需要求出颜色。我以前反推过对比度算法,你可以在 GitHub 上找到 WCAG 和 感知对比度 两者的实现。有了反推算法,计算主题就变成了一个循环:从面板开始,Pi 为每条规则计算每种颜色所需的明度,并取最严格的那个。
算法
我们的第一个原型正是这样做的,同时还有一个用于调整对比度下限的审查应用。该应用可以用 Ghostty 自带的任意主题渲染 Pi,这样我们就能检查一些差异很大的主题样本,以确保该系统在默认主题之外也能成立。
那个原型使用了一种知名的感知对比度算法及其参考实现。然后我们将其应用于大量 ghostty 主题,并确保它在所有主题下都表现良好。之后我们不想直接发布那个算法本身。我们尝试使用更简单的度量,但无法近似出结果。最终我们让一个编码代理来做拟合。对于参考中的每个对比度级别,代理在从白到黑的每个灰色背景上运行原始算法,记录明度,并对结果拟合多项式。它最终选定了一个五次多项式,发现其与参考明度的偏差足够小。Pi 现在只附带这些系数。
下图展示了这些多项式,上面每条规则中的每个对比度级别对应一条曲线。底部是颜色所绘制表面的明度,侧边是该颜色在其上所需的明度。对角线表示完全没有对比度:颜色与其表面明度完全相同。垂直线是各个表面:背景,以及面板,Pi 先在背景上求解面板。当某条规则的曲线与其某个表面相交时,你可以读出该规则在该表面所需的明度,最严格的那个胜出。拖动背景,看看每种颜色如何随之变化。
Pi 的对比度级别以曲线形式呈现的图表:对于表面的每个明度,颜色在其上所需的明度。在深色表面上,曲线位于对角线上方,即颜色比其表面更亮;在浅色表面上则位于下方。级别越强,其曲线离对角线越远。
在图表上拖动,或使用滑块,来改变背景的明度,或选择一个终端主题。选择一条规则,或终端中的一个词,查看它在每个表面上所需的明度。点击一种颜色,将其显示在其他图中。
我们首先将查询到的终端颜色从 RGB 转换为 OKLCH 和 OKHSL。由此我们得到原始亮度 L、色度 C 和色相 H,以及相对于 sRGB 在该亮度下可显示范围的饱和度 S。一旦根据背景亮度对多项式求值,得到的亮度不会直接用作替换,而是转换为 OKHSL 亮度,并使用原始色相和调整后的饱和度计算新颜色。应用钟形饱和度曲线:在中间亮度最强,向黑色和白色减弱。最后将其转换为 OKLCH,Pi 限制原始色度,使 H 保持不变,L 来自对比度规则,C 变为调整后的 OKHSL 所产生的结果。这样,例如浅粉色在移向较暗色调时,否则可能会变得过于鲜艳。该调整确保它永远不会比原始颜色更鲜艳。
色相与色度
Pi 保持调色板颜色的色相不变。色度则更棘手。OKLCH 的奇特形状显示了屏幕能显示多少色度,这取决于色相和亮度。在亮度为 0.9 时,屏幕能显示的最鲜艳的黄色色度约为 0.2,而最鲜艳的蓝色仅达到约 0.05。因此,当 Pi 将调色板颜色移动到所需的亮度时,其色度在该亮度下可能不存在,将其映射回可显示的颜色可能会改变 Pi 刚刚计算出的亮度。
这就是为什么 Pi 在 OKHSL 中构建颜色。OKHSL 与 OKLCH 建立在相同的基础上,并使用相同的色相,但它将奇特的形状拉伸回圆柱体。其饱和度从 0% 到 100%,其中 100% 始终表示“该色相在该亮度下能达到的最鲜艳程度”。它试图兼顾两者:尽可能接近人类感知,同时恢复使基于 RGB 的色彩空间易于使用的简单几何结构。色相、饱和度和亮度的每种组合都是屏幕可以显示的颜色,这使其非常适合生成主题和调色板。Pi 还让饱和度向黑色和白色衰减,这样非常暗和非常亮的颜色,例如仅比背景稍亮的面板,会带有一丝颜色,而不是明亮的色块。
但保持饱和度相同并不能让颜色保持同样鲜艳。由于饱和度是相对于屏幕可显示范围而言的,相同的百分比在不同亮度下可能意味着非常不同的色度。发布后不久,一份 错误报告 显示,Pi 看起来比使用 Catppuccin Frappé 的终端鲜艳得多。Catppuccin 的粉色 #f4b8e4 的 OKHSL 饱和度为 84%,但那是屏幕在如此高亮度下能显示的一小部分色度的 84%。Pi 的强调色需要更暗才能可读,而且由于该处的形状宽得多,84% 的饱和度变成 #eb76d1,色度约为原始粉色的两倍。修复方法是同时限制色度:调色板颜色可以移动到不同的亮度,但它永远不会比在调色板中更鲜艳。有了这个限制,强调色变成 #cc92bd,看起来又像 Catppuccin 了。
因此最终,调色板颜色的色度受到三重限制:通过 OKHSL 受屏幕可显示范围的限制;受向黑色和白色衰减的限制;以及受其在调色板中色度的限制。
下面两张视图把色彩空间与 Pi 在其中所做的工作分开呈现。OKHSL 的圆柱体保持固定。Pi 的范围取决于源色以及 UI 角色所属的族系:源色的饱和度在其自身明度上生效,衰减也相对于该点。向中间移动时,饱和度绝不会超过源色的饱和度。不同族系使用不同的衰减;黄色警告和紫色强调色不会使用同一条曲线。
OKHSL 色彩空间
完整的 OKHSL 圆柱体:明度从底部的黑色到顶部的白色,饱和度从中心的灰色向外递增,色相环绕一周。它在源色的色相处被切开。
Pi 从调色板颜色中生成的内容
Pi 为终端主题的某一个 ANSI 颜色以及使用它的角色所输出的范围,表现为圆柱体轮廓内的一片彩色色相切片。它的外缘遵循锚定的饱和度衰减和源色度上限,并在源色自身的明度上穿过源色本身。其内部显示较低饱和度设置下的输出。如果没有终端调色板,Pi 则改用该族系自身的色相和饱和度曲线。
选择一种颜色,检查 Pi 能否从源色生成它,可在此处或上方终端中进行。圆点是源色;方块是 Pi 在此主题中使用的颜色。拖动任一视图可同时旋转两者。
结果
Pi 在启动时向终端询问其前景色、背景色和 ANSI 颜色,并在终端于浅色和深色之间切换时重建主题。如果终端只报告背景色,Pi 会使用自己的色相。如果终端什么都不报告,Pi 会回退到 ANSI 颜色,并让终端自行绘制它们。生成的颜色保留你终端的色相,但其明度会被调整到相近的对比度。Gruvbox 的深红色会变得亮很多,Catppuccin Latte 的红色会稍暗一些,而 Nord 的颜色几乎不变。
如果你还没有选择主题,那你已经在使用系统主题了。否则,你可以在 /settings 的 Theme 下切换到它。如果你的终端主题在 Pi 中看起来不对劲,请提交 issue并附上主题名称。
调查
本文中所有与对比度相关的数字,都是对普通人如何感知对比度的估计,依据的是现有的对比度感知算法。但目前还没有真正能说明问题的开放数据集。我们正在开发自己的开源对比度算法,为此我们很想了解真实的人在真实屏幕上如何感知对比度。
这项调查大约需要 3 分钟完成,最后我们会向你展示你的回答与其他参与调查者的对比情况。
我们非常希望你能参与。你可以在 contrastsurvey.earendil.com 找到它,如果你参与其中,我们将用你的回答来训练对比度算法。
来源:Hacker News · earendil.com