fix: 预设几何配色压暗降饱和,保证白字可读

- 线性空间限亮度:底色对纯白 WCAG ≥ ~7:1,最亮的强调色也不低于 ~4.9:1,
  任意种子/色相都不会再产出浅亮底(之前空种子的草绿只有 2-3:1)
- 饱和度整体下压、偏中性,不再和白字抢注意力
- 空种子不再共用同一个色相:每种底纹有专属默认色相
  (条纹 215° 蓝 / 噪点 25° 琥珀 / 网格 265° 紫 / 同心弧 350° 玫红)
- 缩略图标签加半透明深色底衬,白字不再直接压在图案上
- 确定性机制、纯函数拆分、三个来源结构均未动
This commit is contained in:
2026-09-04 16:38:05 +08:00
parent d072067b00
commit 3e41c3fcf7
2 changed files with 75 additions and 14 deletions
+5 -3
View File
@@ -458,12 +458,14 @@ onBeforeUnmount(() => {
} }
.thumb-label { .thumb-label {
position: absolute; position: absolute;
left: 6px; left: 4px;
bottom: 5px; bottom: 4px;
color: var(--fg); color: var(--fg);
font-size: 11px; font-size: 11px;
line-height: 1.2; line-height: 1.2;
text-shadow: 0 1px 3px rgb(0 0 0 / 0.7); padding: 2px 5px;
border-radius: 3px;
background: rgb(0 0 0 / 0.55); /* 底衬:压暗条,白字底下不再直接是图案 */
} }
/* Unsplash 结果网格:3 列 */ /* Unsplash 结果网格:3 列 */
+70 -11
View File
@@ -18,6 +18,11 @@ import type { BackgroundAsset } from "../types";
* *
* 种子建议填标题:同一系列标题前缀相同 → 落进同一个 45° 色区, * 种子建议填标题:同一系列标题前缀相同 → 落进同一个 45° 色区,
* 系列内不同篇再在色区内按全文 hash 微差几度,列表里自然成组又不至于一模一样。 * 系列内不同篇再在色区内按全文 hash 微差几度,列表里自然成组又不至于一模一样。
* 种子留空时每种底纹各有一个专属默认色相,四张缩略图一眼分得开。
*
* 配色目标是「能压白字的背板」而不是装饰画:所有颜色都在线性空间里限过亮度——
* 底色对纯白的 WCAG 对比度至少 ~7:1,最亮的强调色也 ≥ ~4.9:1,
* 饱和度压到低饱和、偏中性。
*/ */
export type GeometryKind = "stripes" | "noise" | "grid" | "arcs"; export type GeometryKind = "stripes" | "noise" | "grid" | "arcs";
@@ -51,6 +56,14 @@ export interface GeometryParams {
toneB: RGB; toneB: RGB;
} }
/** 留空种子时每种底纹的默认色相:分散在色环上,缩略图可一眼区分。 */
const EMPTY_HUE: Record<GeometryKind, number> = {
stripes: 215, // 蓝
noise: 25, // 琥珀
grid: 265, // 紫
arcs: 350, // 玫红
};
/** 由种子文本派生一组确定性参数。纯函数。 */ /** 由种子文本派生一组确定性参数。纯函数。 */
export function deriveGeometryParams( export function deriveGeometryParams(
seedText: string, seedText: string,
@@ -58,22 +71,34 @@ export function deriveGeometryParams(
width: number, width: number,
height: number, height: number,
): GeometryParams { ): GeometryParams {
const text = (seedText ?? "").trim() || "cover"; const text = (seedText ?? "").trim();
const hFull = hashText(text); const empty = text.length === 0;
const hPrefix = hashText(text.slice(0, 4)); // 空种子也必须有确定性:以 kind 本身作 hashKey,四种底纹天然不同源。
const hashKey = empty ? `geometry:${kind}` : text;
const hFull = hashText(hashKey);
const hPrefix = hashText(hashKey.slice(0, 4));
const rand = mulberry32(hFull); const rand = mulberry32(hFull);
const u: number[] = []; const u: number[] = [];
for (let i = 0; i < 16; i++) u.push(rand()); for (let i = 0; i < 16; i++) u.push(rand());
// 色相分桶:前 4 个字符决定 45° 色区 → 同前缀同色区; // 色相:非空种子按「前 4 字符分桶 + 全文微调」,同系列相邻成组;
// 桶内再按全文 hash 偏 0~30°,同系列不同篇相邻成组。 // 空种子直接取该 kind 的专属默认色相。
const hue = ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100); const hue = empty
? EMPTY_HUE[kind]
: ((hPrefix >>> 0) % 8) * 45 + (((hFull >>> 0) % 3000) / 100);
const off = 46 + u[1] * 54; // 主色到强调色的色相偏移(46~100°,类比色相,不花) const off = 46 + u[1] * 54; // 主色到强调色的色相偏移(46~100°,类比色相,不花)
const s = Math.min(0.44, 0.28 + u[0] * 0.16); // 低饱和:底是要被压白字的 // 低饱和、偏中性(底要被压白字,高饱和会抢注意力)
const base = hsl2rgb(hue, s, 0.27 + u[2] * 0.06); const sBase = Math.min(0.18, 0.08 + u[0] * 0.1);
const toneA = hsl2rgb(hue + off, Math.min(s + 0.06, 0.5), 0.52 + u[3] * 0.1); const sA = Math.min(0.3, 0.18 + u[3] * 0.12);
const toneB = hsl2rgb(hue + off * 0.5, s * 0.92, 0.44 + u[4] * 0.06); const sB = Math.min(0.24, 0.14 + u[4] * 0.1);
// 线性空间限亮度,保证对白字的 WCAG 对比度:
// 底色 ≤ 0.10 相对亮度 → 对 #fff ≈ 7:1;
// 副强调 ≤ 0.135 → ≈ 5.7:1;主强调 ≤ 0.165 → ≈ 4.9:1(最亮也不破 4.5)。
const base = clampLuminance(hsl2rgb(hue, sBase, 0.21 + u[2] * 0.05), 0.1);
const toneA = clampLuminance(hsl2rgb(hue + off, sA, 0.38 + u[5] * 0.06), 0.165);
const toneB = clampLuminance(hsl2rgb(hue + off * 0.5, sB, 0.29 + u[6] * 0.05), 0.135);
return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB }; return { kind, width, height, seed32: hFull >>> 0, u, base, toneA, toneB };
} }
@@ -167,7 +192,7 @@ function renderNoiseGradient(p: GeometryParams, out: Uint8ClampedArray): void {
const { width: w, height: h, u } = p; const { width: w, height: h, u } = p;
const sx = u[8] < 0.5 ? 1 : -1; const sx = u[8] < 0.5 ? 1 : -1;
const sy = u[9] < 0.5 ? 1 : -1; const sy = u[9] < 0.5 ? 1 : -1;
const amp = 4 + u[10] * 5; // 噪点幅度 const amp = 3 + u[10] * 4; // 噪点幅度
// 对角投影 t 的取值区间(四个角的极值) // 对角投影 t 的取值区间(四个角的极值)
let lo = 0; let lo = 0;
let hi = 0; let hi = 0;
@@ -386,6 +411,40 @@ function hsl2rgb(hueDeg: number, s: number, l: number): RGB {
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
} }
/** sRGB 通道值(0-255) → 线性光 (0-1)。 */
function linearize(v: number): number {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/** 线性光 → sRGB 通道值。 */
function toSrgb(lin: number): number {
const c = lin <= 0.0031308 ? lin * 12.92 : 1.055 * Math.pow(lin, 1 / 2.4) - 0.055;
const v = Math.round((c < 0 ? 0 : c > 1 ? 1 : c) * 255);
return v;
}
/** WCAG 相对亮度(0-1)。 */
function luminance(rgb: RGB): number {
return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]);
}
/**
* 若颜色相对亮度超过上限,就在**线性空间**里整体缩小(保持色相与色度不变),
* 让它与纯白的对比度达标。这保证任意种子/色相都不会产出太亮的底。
*/
function clampLuminance(rgb: RGB, maxLum: number): RGB {
const l = luminance(rgb);
if (l <= maxLum) return rgb;
const k = maxLum / l;
return [
toSrgb(linearize(rgb[0]) * k),
toSrgb(linearize(rgb[1]) * k),
toSrgb(linearize(rgb[2]) * k),
];
}
/* ---------------------------------------------------------------- DOM 出口层 */ /* ---------------------------------------------------------------- DOM 出口层 */
/** 像素缓冲 → dataURL。唯一用到 DOM 的层,浏览器里才调用。 */ /** 像素缓冲 → dataURL。唯一用到 DOM 的层,浏览器里才调用。 */