← 目录 04 / 11

构成主义 Web 技术实现手册

每个配方都给出:要解决什么视觉问题 → 可复制代码 → 坑 → 兼容性。 兼容性结论来自 MDN 页面上的 Baseline 标识(本环境实测抓取),不是凭印象。


0. 兼容性核查表(实测结果)

特性 MDN Baseline 状态 用途 备注
clip-path ✅ Widely available(2020-01 起全浏览器) 斜切、楔形、裁切揭示 主力工具
mix-blend-mode ✅ Widely available(2020-01 起,部分子特性有差异) 套印、双色调、文字压图 会创建层叠上下文
background-blend-mode ✅ Widely available 背景层叠(纸+网点) 只混合自身背景层
text-wrap ✅ Widely available(部分子特性有差异) balance / pretty 断行 标题必用
shape-outside ✅ Widely available(部分子特性有差异) 文字绕几何形 需配合 float
mask ✅ Widely available 网点遮罩、渐隐 加 -webkit- 前缀更稳
writing-mode ✅ Widely available(部分子特性有差异) 竖排文字 中文竖排可用
font-variant-numeric ✅ Widely available 表格数字对齐 编号列必用
SVG feTurbulence ✅ Widely available 颗粒/噪声 内联为 data-URI
SVG textPath ✅ Widely available(部分子特性有差异) 沿路径/沿圆排字 必须配 <path id>
filter: contrast() ✅ Widely available 照片加工 —
container-type ✅ Widely available(部分子特性有差异) 组件级响应式 —
:has() ✅ Widely available(部分子特性有差异) 父级/兄弟选择 —
@property ⚠️ 未验证 注册自定义属性做动画 使用前请自行在 MDN 确认
animation-timeline(滚动驱动动画) ⚠️ 未验证 滚动联动动效 使用前请自行确认
text-spacing-trim ⚠️ 未验证 中文标点压缩 使用前请自行确认

后三项在本环境抓取 MDN 时未取到 Baseline 标识,不做断言。它们是加分项,不是构成主义风格的必需品——不依赖它们也能 100% 实现这套风格。


1. 纸张底 + 印刷颗粒

问题:纯白背景是"屏幕感",构成主义必须是"纸感"。但直接用纹理图片会有:体积大、拉伸变形、色彩偏移。

解法:SVG feTurbulence 内联为 data-URI,程序生成颗粒。

:root {
  --kv-paper: #EFE9DC;
  /* feTurbulence 生成的噪声纹,baseFrequency 越小颗粒越大 */
  --kv-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.42'/%3E%3C/svg%3E");
}

body {
  background-color: var(--kv-paper);
  background-image: var(--kv-grain);
  background-size: 160px 160px;
  background-blend-mode: multiply;   /* 让噪声成为"墨点"而不是"白霜" */
  background-attachment: fixed;      /* 可选:滚动时纸不动,更像印刷品 */
}

坑: - baseFrequency 调大 → 颗粒更细;调到 0.9+ 会接近纯噪声,反而更脏。0.7–0.85 是甜区。 - opacity 必须压到 0.04–0.08 区间(这里靠 SVG 内部 opacity + background-blend-mode: multiply 双重压低)。超过 0.1 就从"纸"变成"脏屏幕"。 - 🔴 URL 编码:# 必须写成 %23。 这是最容易忘、且一忘就整个滤镜失效的坑(#a 会被浏览器当成 URL fragment 截断)。同理 % → %25(所以 100% 要写 100%25),< → %3C,> → %3E。内部属性用单引号,外层用双引号。 - 🔴 性能:内联 SVG feTurbulence 是"每次重绘都要重新光栅化滤镜"的。全屏常驻(比如挂在 body 上)在生产环境是有代价的 —— 滚动、缩放、合成都会触发重算。 - 全屏常驻颗粒层 → 换成无缝 PNG/WebP 贴图(GPU 平铺,成本极低)::root { --kv-grain: url("./grain-160.png"); } - 小面积元素(卡片、Hero、单个色块)用 SVG 版本完全没问题 - background-size 一律用固定 px,不要用 % / cover,否则滤镜被反复重算 - 🔴 不要用 background-attachment: fixed —— 移动端会强制全屏重绘。想要"纸不动"的观感,改用独立固定层(务必 pointer-events: none)。 - 独立覆盖层必须加 pointer-events: none,否则会挡住整页点击。 - 可用 contain: paint 或 content-visibility 把滤镜重算锁在局部。

/* 若确实需要独立固定颗粒层 */
.kv-grain-layer {
  position: fixed; inset: 0; pointer-events: none; z-index: 9999;
  background-image: var(--kv-grain); background-size: 160px 160px;
  mix-blend-mode: multiply; opacity: .5;
}

💡 background-blend-mode 只混合元素自己的多层背景,要让文字也"压"进纸里,只能用 mix-blend-mode + isolation。这是两者的分工,别混。详见 05-前端技术方案.md 第 7 节。


2. 斜向网格底纹("可见的构造")

问题:构成主义的网格是露出来的。但用 <table> 或大量 div 画网格很蠢。

解法:两组 repeating-linear-gradient 叠加。

.kv-grid-bg {
  background-image:
    repeating-linear-gradient(90deg,  rgba(18,16,14,.07) 0 1px, transparent 1px 6.25%),
    repeating-linear-gradient(0deg,   rgba(18,16,14,.07) 0 1px, transparent 1px 48px);
  background-size: 100% 100%;
}

斜向网格(更有构成主义味):

.kv-grid-diagonal {
  background-image:
    repeating-linear-gradient(60deg,  rgba(18,16,14,.06) 0 1px, transparent 1px 28px),
    repeating-linear-gradient(-60deg, rgba(18,16,14,.06) 0 1px, transparent 1px 28px);
}

坑:透明度超过 0.1 就会和正文抢注意力。0.05–0.08 是上限。


3. 主斜轴(贯穿全页的"主梁")

问题:一条从页面顶部贯穿到底部的斜线,是构成主义最重要的动势来源。

解法:position: fixed + transform: rotate(),或用 clip-path 做一个斜切色带。

.kv-axis {
  position: fixed;
  inset: -20% -10%;
  width: 140%;
  height: 14px;
  background: var(--kv-ink);
  transform: rotate(-15deg);        /* 与 --kv-axis-angle 保持一致 */
  transform-origin: center;
  pointer-events: none;
  z-index: 1;                        /* 在内容之下、背景之上 */
  mix-blend-mode: multiply;
}

/* 变体:斜切色带(更宽,带面积) */
.kv-axis--band {
  height: 38vh;
  background: var(--kv-red);
  opacity: .94;
  z-index: 0;
}

坑: - 用 transform: rotate() 而不是 clip-path 画细线——旋转更简单且抗锯齿更好。 - 斜轴必须在 z-index 上低于内容,且 pointer-events: none,否则会拦截点击。 - mix-blend-mode: multiply 会让斜轴与纸张纹理叠印,产生"印上去"的感觉。但它会创建层叠上下文,可能影响内部元素的 z-index(见第 7 节)。


4. 楔形(最具识别度的符号)

问题:红楔子刺入白圆是构成主义的"Logo"。网页里要能反复使用且有动势。

解法:clip-path: polygon()。注意坐标系:百分比是相对元素自身宽高的,所以宽高比变化时尖角会变形。

/* 基础楔形:从左下向右上刺入 */
.kv-wedge {
  width: 100%;
  aspect-ratio: 16 / 7;              /* 锁死宽高比,防止尖角变形 */
  background: var(--kv-red);
  clip-path: polygon(0% 100%, 100% 8%, 100% 40%, 0% 100%);
}

/* 变体:双楔咬合(红+黑),制造"刺入"关系 */
.kv-wedge-clash { position: relative; aspect-ratio: 16/7; overflow: clip; }
.kv-wedge-clash::before,
.kv-wedge-clash::after {
  content: ""; position: absolute; inset: 0;
}
.kv-wedge-clash::before {
  background: var(--kv-paper);
  clip-path: polygon(0 0, 62% 0, 20% 100%, 0 100%);
}
.kv-wedge-clash::after {
  background: var(--kv-red);
  clip-path: polygon(24% 0, 78% 0, 40% 100%, 0% 100%);
  mix-blend-mode: multiply;
}

坑(前两条踩了会出功能性事故,不是审美问题): - 🔴 clip-path 会把 outline 一起裁掉 → 键盘焦点环不可见 → WCAG 2.4.7(焦点可见)失败。 凡是可交互元素(a / button / input)都不要在它自己身上用 clip-path。 正确做法:把 clip-path 交给一个 aria-hidden="true" 的装饰子层,交互元素本体不裁剪: html <button class="kv-wedge-btn"> <span class="kv-wedge-btn__decor" aria-hidden="true"></span> <span class="kv-wedge-btn__label">按此</span> </button> css .kv-wedge-btn { position: relative; background: none; border: 0; padding: 0; } .kv-wedge-btn__decor { position: absolute; inset: 0; background: var(--kv-red); clip-path: polygon(0% 100%, 100% 6%, 100% 100%); z-index: 0; } .kv-wedge-btn__label { position: relative; z-index: 1; display: block; padding: 1rem 2rem; color: var(--kv-paper); } .kv-wedge-btn:focus-visible { outline: 4px solid var(--kv-blue); outline-offset: 3px; } - 🔴 非 none 的 clip-path 会新建 stacking context,从而截断 mix-blend-mode 的混合范围。 这就是"明明写了 multiply 却没变暗"的头号原因。同理会新建 stacking context 的属性还有: opacity < 1、transform、filter、mask、will-change、contain: paint、position: fixed/sticky。 构成主义页面里 transform 和 clip-path 到处都是 → 混合范围很容易被切得七零八落。 对策:用 isolation: isolate 显式划出混合作用域,并把需要混合的层放在同一层里。 - clip-path 会把 box-shadow / border 一起裁掉 → 硬阴影要靠外层包裹元素,不能加在被裁元素上; 楔形也不能靠 border 描边。要描边有三条出路:① 双层(底层当描边色,上层坐标内缩) ② 用 SVG <polygon stroke> + paint-order: stroke fill(让描边压在填充之下,保住尖角) ③ 放弃描边。 - clip-path 元素内的文字也会被裁,长文本可能被切掉 → 文字放到外层,或用 shape-outside。 - 参考盒默认是 border-box(不是 padding-box),百分比相对参考盒解析。 想做随元素缩放的斜切,用 polygon() / shape(),不要用 path() —— path() 里是 px,不会缩放。 - 务必锁 aspect-ratio,否则响应式下楔子的尖角角度会乱变。

更完整的实测证据(BCD 版本号、坐标系陷阱、fill-rule)见 05-前端技术方案.md 第 1 节。


5. 套印错位(misregistration)

问题:廉价印刷的红版和黑版对不齐,文字边缘有 1–3px 的红色重影。这正是"印刷感"最强烈的来源。

解法:一个真实的 <span aria-hidden="true"> 承载重影 + 偏移 + mix-blend-mode: multiply。

🔴 重要更正:不要用伪元素 content: attr(data-text) 复制文字。 部分屏幕阅读器会朗读伪元素内容 → 标题被念两遍。这是本手册里最容易踩的无障碍坑, 而网上流传的"套印错位"教程几乎全都这么写。默认请用下面的真实 span 写法。

<h2 class="kv-misreg">ПОСТРОИМ БУДУЩЕЕ<span class="kv-misreg__ghost" aria-hidden="true">ПОСТРОИМ БУДУЩЕЕ</span></h2>
.kv-misreg {
  position: relative;
  display: inline-block;   /* 让重影层有确定的包含块 */
  isolation: isolate;      /* 把 multiply 锁在组件内,避免混到 body 背景 */
  color: var(--kv-ink);
}
.kv-misreg__ghost {
  position: absolute;
  inset: 0;
  color: var(--kv-red);
  mix-blend-mode: multiply;
  transform: translate(3px, -2px);   /* 偏移量就是"错版量" */
  pointer-events: none;
  user-select: none;                 /* 防止选中重影造成复制出重复文字 */
}

替代方案:text-shadow 三色偏移(唯一零无障碍代价的做法)

.kv-misreg-shadow {
  color: var(--kv-ink);
  text-shadow:
     2.5px 0   0 rgba(214,43,31,.85),   /* 红版偏移 */
    -2.5px 0   0 rgba(0,81,214,.60),    /* 蓝版偏移 */
     0     3px 0 rgba(232,179,26,.55);  /* 黄版偏移 */
}

坑: - 偏移量 ≤ 3px。超过 5px 就从"印刷错版"变成"故障艺术(glitch)",那是另一种风格。 - 父元素若有背景色,重影可能被盖住 → 把重影提到本体之上(z-index: 1,本体 position: relative; z-index: 0)。 - Blink(Chrome)与 WebKit(Safari)的 mix-blend-mode 实现有可见差异 → 错位量不要卡在临界值,留 ±1px 冗余。 - 若必须用伪元素版本,请至少加 aria-hidden 的可访问性替身,并接受它只是"装饰性重复"。 更完整的兼容性数据与实测证据见 05-前端技术方案.md 第 6 节。


6. 双色调照片(把照片变成"材料")

问题:原色照片太"数字",且颜色会和红黑白打架。构成主义要的是"两色印刷的照片"。

解法 A:纯 CSS 双色调(推荐,零依赖)

.kv-duotone {
  position: relative;
  display: block;
  filter: grayscale(1) contrast(1.25);
}
.kv-duotone img {
  display: block; width: 100%;
  filter: grayscale(1) contrast(1.15) brightness(1.02);
  mix-blend-mode: luminosity;        /* 只取原图的明度 */
}
.kv-duotone::after {
  content: ""; position: absolute; inset: 0;
  background: var(--kv-red);         /* 用红色当"影子色" */
  mix-blend-mode: multiply;
  pointer-events: none;
}
/* 想要"黑→红"双色而不是"红→红"时,改用渐变映射思路: */
.kv-duotone--ink-red::after {
  background: linear-gradient(160deg, var(--kv-ink) 0%, var(--kv-red) 100%);
  mix-blend-mode: lighten;
}

解法 B:SVG 滤镜做真正的渐变映射(更准,适合要精确控色的场合)

<svg width="0" height="0" aria-hidden="true" style="position:absolute">
  <filter id="kv-duotone" color-interpolation-filters="sRGB">
    <feColorMatrix type="matrix"
      values="0.299 0.587 0.114 0 0
              0.299 0.587 0.114 0 0
              0.299 0.587 0.114 0 0
              0     0     0     1 0"/>
    <feComponentTransfer>
      <feFuncR type="table" tableValues="0.071 0.839"/>  <!-- 黑 → 红 -->
      <feFuncG type="table" tableValues="0.063 0.157"/>
      <feFuncB type="table" tableValues="0.055 0.122"/>
    </feComponentTransfer>
  </filter>
</svg>
<img src="photo.jpg" style="filter: url(#kv-duotone)" alt="...">

可直接引用的现成实现: - LukyVj/colofilter.css — 619★,MIT,纯 CSS 双色调滤镜库 - rickymetz/duotonegenerator — 89★,MIT,CSS 双色调生成器

坑: - filter 会创建新的包含块,内部的 position: fixed 会失效。 - mix-blend-mode: luminosity 要求父元素有 isolation: isolate(或明确的层叠上下文),否则会混合到页面其他元素上。这是最常见的翻车点。


7. 半调网点(halftone)

问题:构成主义海报的照片有印刷网点。三种做法各有取舍。

做法 A:repeating-radial-gradient 做网点图案(最轻,纯 CSS)

.kv-halftone {
  background-image: radial-gradient(var(--kv-ink) 1.5px, transparent 1.6px);
  background-size: 6px 6px;          /* 6px 网点间距 = 印刷网点尺度 */
}
/* 用 mask 让网点只出现在图片的暗部(模拟真实半调) */
.kv-halftone-masked {
  -webkit-mask-image: radial-gradient(var(--kv-ink) 1.5px, transparent 1.6px);
          mask-image: radial-gradient(var(--kv-ink) 1.5px, transparent 1.6px);
  mask-size: 6px 6px;
}

做法 B:SVG feTurbulence + feColorMatrix 做有机网点(更有印刷脏感)

<svg width="0" height="0" aria-hidden="true" style="position:absolute">
  <filter id="kv-halftone" x="0" y="0" width="100%" height="100%">
    <feTurbulence type="fractalNoise" baseFrequency="0.55" numOctaves="1" result="noise"/>
    <feColorMatrix in="noise" type="saturate" values="0" result="mono"/>
    <feComponentTransfer in="mono" result="dots">
      <feFuncA type="discrete" tableValues="0 1"/>   <!-- 硬阈值 = 网点 -->
    </feComponentTransfer>
    <feComposite in="SourceGraphic" in2="dots" operator="in"/>
  </filter>
</svg>

做法 C:mask + 位图网点(最可控,但要素材)

.kv-halftone-img {
  -webkit-mask-image: url("halftone-tile.png");
          mask-image: url("halftone-tile.png");
  mask-size: 400px 400px;
  mask-repeat: repeat;
}

坑: - 网点尺寸 3–6px。小于 3px 在高分屏上会摩尔纹闪烁;大于 6px 会变成"圆点装饰画"。 - 网点绝对不能覆盖正文,会严重降低可读性(对比度实测会掉到 3:1 以下)。 - 做法 B 的 SVG 滤镜在大面积元素上性能开销明显,只用于单张图片,不要用于整页背景。 - 用 mask 时记得加 -webkit- 前缀(老 Safari)。


8. 沿路径 / 绕圆排字

问题:构成主义常把文字沿弧线或斜线排布。

解法:SVG <textPath>(这是唯一可靠的做法,CSS 做不到)。

<svg viewBox="0 0 400 400" class="kv-type-circle" role="img" aria-label="ПРОУН">
  <defs>
    <path id="kv-circle" d="M 200,200 m -150,0 a 150,150 0 1,1 300,0 a 150,150 0 1,1 -300,0"/>
  </defs>
  <circle cx="200" cy="200" r="150" fill="none" stroke="#12100E" stroke-width="2"/>
  <text font-family="Oswald, sans-serif" font-size="26" fill="#12100E" letter-spacing="6">
    <textPath href="#kv-circle" startOffset="0">ПРОУН · ПОСТРОЕНИЕ · НОВОГО ·</textPath>
  </text>
</svg>

想让它转起来(构成主义海报里圆环常被想象成旋转的):

@keyframes kv-spin { to { transform: rotate(360deg); } }
.kv-type-circle { animation: kv-spin 40s linear infinite; transform-origin: 50% 50%; }
@media (prefers-reduced-motion: reduce) { .kv-type-circle { animation: none; } }

坑: - <textPath> 的文本无法自动换行,超出路径长度会被直接截断。必须自己控制字数。 - 路径长度要略长于文字长度,startOffset 用来调起点。 - 必须给 SVG 一个可访问名称(role="img" + aria-label),否则读屏用户会听到无意义的字符流,或者完全听不到。这是无障碍红线。 - 用 href 而非 xlink:href(现代浏览器都支持;若需兼容极老浏览器再加 xlink:)。


9. 文字绕几何形(shape-outside)

问题:让文字顺着一个圆形/三角形/斜边流动,而不是规规矩矩排在矩形里。

.kv-shape {
  float: left;
  width: 240px; height: 240px;
  margin: 0 1.5rem 1rem 0;
  background: var(--kv-red);
  border-radius: 50%;                              /* 视觉是圆 */
  shape-outside: circle(50% at 50% 50%);           /* 文字绕圆形流动 */
  clip-path: circle(50% at 50% 50%);
}

/* 三角形楔子让文字绕斜边 */
.kv-shape--wedge {
  border-radius: 0;
  clip-path: polygon(0 0, 100% 0, 0 100%);
  shape-outside: polygon(0 0, 100% 0, 0 100%);
}

坑: - 必须配合 float,shape-outside 对非浮动元素无效。 - shape-outside 只影响外部文字环绕,不裁切元素自身——所以要同时写 clip-path。 - 浮动元素在 Flex/Grid 容器里会失效,必须放在普通块级流里。


10. 竖排文字

.kv-vertical {
  writing-mode: vertical-rl;
  text-orientation: mixed;      /* 中文竖排用 mixed;纯西文可 upright */
  letter-spacing: 0.12em;
}

坑: - 🔴 writing-mode 会交换 inline 与 block 轴。 使用竖排的容器内部,所有 margin / padding / width / height 都必须改用逻辑属性(margin-block / padding-inline / inline-size / block-size),否则左右与上下的语义会整体错位——这是竖排最容易出的布局事故。 - 🔴 text-orientation: upright 会让拉丁字母变成"一堆正立字母",几乎不可读 → 只用于装饰性短标签。中文竖排是可读的,可以用于标题。 - 竖排文本复制出来的纯文本顺序可能异常,PDF 打印也可能乱序 → 主内容请提供水平版本。 - 屏幕阅读器一般能正确处理 vertical-rl,但不要用它承载主要信息层级。


11. 动效三件套

11.1 裁切框扫过(图片揭示)

@keyframes kv-reveal { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
.kv-reveal { animation: kv-reveal var(--kv-dur-slow) var(--kv-ease-mech) both; }

11.2 逐行跳出(标题 staggger)

.kv-stagger > * {
  opacity: 0;
  transform: translateY(0.6em) ;
  animation: kv-jump var(--kv-dur-base) var(--kv-ease-mech) forwards;
}
.kv-stagger > *:nth-child(1) { animation-delay: 0ms; }
.kv-stagger > *:nth-child(2) { animation-delay: 50ms; }
.kv-stagger > *:nth-child(3) { animation-delay: 100ms; }
.kv-stagger > *:nth-child(4) { animation-delay: 150ms; }
@keyframes kv-jump { to { opacity: 1; transform: none; } }

11.3 色块硬切(hover)

.kv-btn {
  background: var(--kv-ink); color: var(--kv-paper);
  border: 2px solid var(--kv-ink);
  border-radius: 0;
  min-height: 44px;                       /* 触控目标红线 */
  padding: 0.75rem 1.5rem;
  font-family: var(--kv-font-display);
  font-weight: var(--kv-weight-label);
  letter-spacing: var(--kv-track-label);
  text-transform: uppercase;
  transition: background-color var(--kv-dur-fast) var(--kv-ease-mech),
              color            var(--kv-dur-fast) var(--kv-ease-mech);
  cursor: pointer;
}
.kv-btn:hover  { background: var(--kv-red); border-color: var(--kv-red); color: var(--kv-paper); }
.kv-btn:focus-visible { outline: 4px solid var(--kv-blue); outline-offset: 3px; }
.kv-btn:active { transform: translate(2px, 2px); }
.kv-btn[disabled] { opacity: .45; cursor: not-allowed; }

注意:hover 用 --kv-red 底 + 纸色字 = 4.15:1,按钮文字通常 ≥16px 粗体,刚好卡在 AA 大字阈值。若要严格达标,把 hover 底色改为 --kv-red-ink。


12. 极端字号与溢出防护

.kv-mega {
  font-family: var(--kv-font-display);
  font-size: var(--kv-size-mega);
  line-height: var(--kv-leading-mega);
  letter-spacing: var(--kv-track-mega);
  text-transform: uppercase;
  text-wrap: balance;
  overflow-wrap: anywhere;      /* 超长单词不得撑破容器 */
  hyphens: auto;
}
/* 容器必须允许裁切,否则 vw 字号会在窄屏溢出 */
.kv-hero { overflow: clip; }

坑:clamp() 里的 vw 单位不感知容器宽度,只感知视口。如果这个组件被放在一个窄侧栏里,字号会照样巨大 → 该用 cqw(容器查询单位):

.kv-hero { container-type: inline-size; }
.kv-mega { font-size: clamp(2rem, 14cqw, 12rem); }

13. 对比度自检脚本

改动画板后必须重跑,不要目测。

function Lum([string]$hex) {
  $hex = $hex.TrimStart('#')
  $v = @(0,2,4) | ForEach-Object {
    $c = [Convert]::ToInt32($hex.Substring($_,2),16) / 255.0
    if ($c -le 0.03928) { $c / 12.92 } else { [Math]::Pow((($c + 0.055) / 1.055), 2.4) }
  }
  0.2126*$v[0] + 0.7152*$v[1] + 0.0722*$v[2]
}
function Ratio([string]$a,[string]$b) {
  $la = Lum $a; $lb = Lum $b
  [Math]::Round(($([Math]::Max($la,$lb)) + 0.05) / ($([Math]::Min($la,$lb)) + 0.05), 2)
}
Ratio '#B31B12' '#EFE9DC'   # → 5.63  ✅ AA 正文
Ratio '#D6281F' '#EFE9DC'   # → 4.15  ⚠️ 仅大字

判定线:≥4.5:1 = AA 正文合格;≥3:1 = 仅限 ≥24px 或 ≥18.66px 粗体。


14. 印刷级排版细节(最容易被忽略、也最"显专业"的一层)

构成主义海报的文字是排字工手工排的,所以有大量现代浏览器默认不做的细节。这一层做好了,"像印刷品"和"像网页"的差距就出来了。

13.1 悬挂标点

行首的引号、破折号应当溢出到版心之外,让文本左边缘看起来对齐(视觉边距对齐)。

.kv-hang { hanging-punctuation: first last; }

⚠️ hanging-punctuation 的兼容性本环境未取到 MDN Baseline 标识,不做断言。它目前主要在 Safari 生效。可靠替代: - 用 davidmerfield/Typeset(2,680★, CC0)—— 它在构建期处理悬挂标点,无需客户端 JS,CSS <1KB - 或对已知的少量标题手动 text-indent: -0.5em 微调

13.2 小型大写(small caps)

用于编号、缩写、"卷/期"这类次级信息,比缩小字号更专业。

.kv-smallcaps { font-variant-caps: all-small-caps; letter-spacing: 0.06em; }
/* 只对真实存在小 caps 字形的字体生效;否则浏览器会伪造成缩小的大写 —— 那很难看 */

⚠️ 前提:字体必须真的有小 caps 字形。Oswald / Unbounded 没有小 caps,所以不要在这两套字体上用 all-small-caps。需要小 caps 就用 PT Serif 或 Inter。

13.3 OpenType 特性

.kv-num-tabular { font-variant-numeric: tabular-nums; }        /* 编号列对齐,必用 */
.kv-liga-off    { font-variant-ligatures: none; }              /* 大标题常关掉连字,字距更均匀 */
.kv-caps-alt    { font-feature-settings: "ss01" 1; }           /* 某些字体的单层 a / 方点 i 等 */

构成主义偏好:大标题关闭连字(font-variant-ligatures: none)—— 手工排字时代不会为标题启用连字,关掉后字面更"机械"、更均匀。

13.4 断词与断行

.kv-title { text-wrap: balance;  }   /* 标题:让各行长度均衡 */
.kv-body  { text-wrap: pretty;   }   /* 正文:避免末行孤字 */
.kv-title, .kv-body { hyphens: auto; overflow-wrap: anywhere; }

⚠️ 中文注意:hyphens: auto 需要标注 lang。中文断行依赖 line-break 与 word-break:

:lang(zh) .kv-body { line-break: strict; }   /* 严格避头尾 */
:lang(zh) .kv-title { word-break: keep-all; } /* 标题不在词内断开 */

text-spacing-trim 可压缩中文标点间距(让行首行尾的标点不占满字宽),但该特性兼容性本次未验证,建议先不用,或加 @supports 保护:

@supports (text-spacing-trim: trim-start) {
  :lang(zh) .kv-body { text-spacing-trim: trim-start; }
}

13.5 竖排中的标点

writing-mode: vertical-rl 下,中文标点会自动旋转到正确位置(浏览器的 text-orientation: mixed 会处理)。但西文括号和引号需要检查,必要时用 text-combine-upright 组合数字/短西文:

.kv-vertical .kv-num { text-combine-upright: digits 2; }  /* 两位数字横排在竖排文本里 */

15. 页面骨架清单

必需(缺了就不像构成主义): - [ ] 全局 border-radius: 0 - [ ] 米黄纸底 + 极轻颗粒 - [ ] 一个统一的斜轴角度(CSS 变量) - [ ] 极端字号对比(标题:正文 ≥ 6:1) - [ ] 标题全大写 + 负字距 + 紧行高 - [ ] 大量纸白(红色面积 ≤ 15%) - [ ] 至少一处几何原语(楔形/圆/粗线) - [ ] 可见的网格线或编号系统 - [ ] prefers-reduced-motion 支持

加分(决定是"像"还是"精"): - [ ] 照片经双色调/半调加工 - [ ] 一处套印错位 - [ ] 一条贯穿式斜轴 - [ ] 沿圆或沿斜线排的短文字 - [ ] 竖排的短标签 - [ ] 硬边偏移阴影 - [ ] 中文字体与西文字体的视觉重量对齐

禁止(出现即出戏): - [ ] 圆角、柔和投影、渐变 - [ ] 紫/粉/蓝科技渐变 - [ ] Emoji 当图标 - [ ] 居中的对称布局(那是瑞士风格的领域) - [ ] 模糊背景(毛玻璃) - [ ] 弹性/回弹缓动动画