Horizon 的服务端对比色:用 Liquid 颜色过滤器推导文字与派生色
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
只要允许商家给区块单独设置背景色,就一定会有人选了深色背景却没改文字颜色,结果黑底黑字。
常见的解法是配色方案(color schemes):商家预先定义几套前景、背景组合,区块只能从中选一套。Horizon 这一基线没有 color_scheme 设置(见样式机制),走的是另一条路:区块可以随意设置背景色,文字色由 Liquid 在服务端自动算出来。涉及:
- snippets/color-palette.liquid:全局调色板与按钮悬停色
- snippets/contrast-override.liquid:区块级对比色覆盖
- snippets/util-palette-hover-shift.liquid:悬停色推导
- snippets/brightness-opacities.liquid:按背景亮度调整透明度
用到的 Liquid 颜色过滤器
| 过滤器 | 作用 |
|---|---|
color_brightness | 返回 0–255 的感知亮度 |
color_contrast: other | 返回两色之间的对比度(WCAG 定义,1–21) |
color_lighten / color_darken | 调整明度 |
color_modify: 'alpha', x | 修改透明度 |
.rgb / .alpha | 颜色对象的分量 |
这些都是 Shopify 内置的过滤器,计算在服务端完成,输出的是确定的颜色值,客户端没有运行时开销。
第一步:找出调色板的两个极值
主题设置里有一个调色板(settings_schema.json 中 type 为 color_palette 的设置),其他颜色设置的默认值引用它,比如 page_text_color 的默认值是 {{ settings.color_palette.foreground }}。
color-palette.liquid 遍历调色板,找出最亮和最暗的两个颜色,只统计完全不透明(alpha == 1)的颜色;初始值是 #ffffff 和 #000000:
for palette_color in settings.color_palette
if palette_color != blank and palette_color.alpha == 1
assign b = palette_color | color_brightness
# 更新 palette_lightest / palette_darkest
endif
endfor
结果以 CSS 变量的形式输出:
--palette-lightest: ...; --palette-lightest-rgb: ...;
--palette-darkest: ...; --palette-darkest-rgb: ...;
为什么不直接用纯白和纯黑?品牌调色板里的"最深色"往往是深藏青或深咖啡,而不是 #000,自动选出的对比色仍然属于品牌色系。
第二步:区块级的三级回退
区块或 section 设置了背景色时,渲染 contrast-override:
{% render 'contrast-override',
background_color: block.settings.background_color,
text_color: block.settings.text_color,
section_id: block.id %}
背景为空或完全透明(alpha == 0)时,只有显式文字色才会输出样式。有背景时,文字色的决策顺序:
- 商家显式设置了文字色 → 直接用;
- 否则,计算全局页面文字色
settings.page_text_color和这个背景的对比度,≥ 4.5(WCAG AA 正文标准)→ 沿用全局文字色; - 否则,背景亮度 > 128 用
var(--palette-darkest),否则用var(--palette-lightest)。
assign page_text_contrast = background_color | color_contrast: settings.page_text_color
if page_text_contrast >= 4.5
assign effective_text_color = settings.page_text_color
else
assign effective_text_color = contrast_var # 'var(--palette-darkest)' 或 'var(--palette-lightest)'
endif
第 2 步很重要:能不改就不改。浅灰背景配全局黑色文字已经足够可读,就不该被替换成调色板里的某个深色。
第 3 步输出的是 CSS 变量引用,不是具体颜色。全局调色板变化时,所有区块的回退色自动跟着变。
边框色也有回退:传了 border_color 就用它;否则 preset: 'ui' 时跟随有效文字色,默认的 content 预设则用同一个亮度二分得出的调色板极值。
最终输出的是一段作用域样式:
.color-custom-{{ section_id }},
.color-custom-{{ section_id }} .text-block {
--color-background: ...;
--color-foreground: ...;
--color-foreground-muted: rgb(... / var(--opacity-muted-text));
--color-border: ...;
}
禁用态输入框与复选框边框的令牌只写在 .color-custom-{{ section_id }} 上,不进 .text-block,注释说明 .text-block 是纯文本容器,不放表单控件。区块的 HTML 只需加上 color-custom-{{ block.id }} 这个类。全仓库有 59 个 section、block、snippet 文件调用了这个片段(不含它自身的文档示例)。
第三步:派生色也要跟着背景走
只换文字色还不够。半透明的分隔线、禁用态输入框、悬停背景,在深色背景上都可能"消失"。
透明度令牌按亮度切换。 brightness-opacities.liquid 在背景亮度 < 64 时,把一组透明度调高,节选如下:
| 令牌 | 浅色背景 | 深色背景 |
|---|---|---|
--opacity-5-15 | 0.05 | 0.15 |
--opacity-10-25 | 0.1 | 0.25 |
--opacity-35-55 | 0.35 | 0.55 |
令牌名直接写出了两种取值(5-15 表示浅色 5%、深色 15%),读 CSS 的人不用查定义就知道它会变化。
悬停色按亮度区间推导。 util-palette-hover-shift.liquid:
if alpha < 0.4
# 太透明,靠明度变化看不出来 → 不透明度加 0.15
echo color | color_modify: 'alpha', new_alpha
else
# ≤40 提亮 15;≤128 提亮 5;≤190 压暗 10;>190 压暗 5
endif
暗色端给了最大的提亮幅度:接近纯黑的颜色上,小幅变化几乎看不出来。
按钮的悬停文字色同样会重新计算:主按钮、次按钮、变体与选中变体四组,各自在悬停背景亮度 > 128 时用调色板最深色,否则用最浅色;原背景不是完全不透明时,沿用原来的文字色。
背景是图片或视频时
snippets/group.liquid 与 _card 区块在设置了背景媒体时传入 skip_contrast: true,hero、layered-slideshow 等分区则总是跳过。这时只输出背景色与透明度令牌,不做文字色的自动对比。源码没有写理由,可以理解为服务端无法知道媒体内容的亮度。
值得商榷的地方
- 亮度阈值是硬编码的 128。中等亮度的背景(比如饱和的品牌红)用亮度判断不一定准确。第 2 步用了真正的对比度计算,第 3 步却退回到亮度二分。更稳妥的做法是分别算出最亮色和最暗色与背景的对比度,取较高的那个。片段的文档注释写的是"选对比更好的极值",实现只做到了按亮度近似。
- 预设说明与实现不符。文档注释说
content预设比ui多输出 accent 令牌,但片段里没有任何 accent 变量;有背景时,两种预设的唯一区别是边框回退。 - 每个区块输出一段
<style>。59 个调用文件意味着页面上可能有几十段内联样式。体积通常不大,但会增加 HTML 的解析量。 color-palette.liquid有大量重复。主按钮、次按钮、变体、选中变体四组悬停色的计算结构几乎相同,各写了一遍。Liquid 没有函数,只能靠capture加render勉强复用,这是语言本身的限制。
小结
Horizon 的颜色系统可以概括为:商家只做选择,可读性由主题兜底。
- 全局调色板提供品牌色系的两个极值,作为兜底对比色;
- 区块级先尽量沿用全局文字色,不够才回退;
- 派生的透明度、悬停色都跟随背景亮度调整;
- 全部在服务端算好,以作用域 CSS 变量输出。
相比配色方案,这种做法给商家的自由度大得多。但阈值判断是近似的,本篇也未测量实际对比度,上线前仍要按样式机制中的方法检查计算样式。
Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。