Hover capture via forced pseudo-state, text-wrap support, per-viewport video source re-selection, svg paint recovery, semantic tokens & section naming

Capture:
- Hover/focus states are driven with CDP CSS.forcePseudoState instead of
  moving the real cursor - transparent full-viewport overlay layers were
  swallowing every pointer hover, silently capturing zero hover states on
  hover-rich sites
- text-wrap (balance/pretty) captured and emitted (Tailwind text-balance/
  text-pretty, arbitrary fallback)
- Videos re-run <source> selection per viewport before frame-0
  normalization: resize-without-reload kept aspect-gated variants stuck on
  the load-time choice, poisoning mobile ground-truth screenshots
- svg roots capture their computed paint; fill="none" with a computed
  paint (the fill-current pattern) recovers the real color instead of
  rendering blank

Tokens & naming:
- Full CSS-Color-4 parsing (oklab/oklch/lab/lch/hsl) so modern colors
  cluster and earn semantic roles; visually-equal literals share one token;
  decoration/gradient/shadow colors consult the palette before minting
  opaque tokens (ridge: 45 opaque tokens -> 15, anthropic: 12 -> 7)
- Expanded role vocabulary (background/foreground/primary/accent/border/
  surface/muted) with deterministic tiebreaks and a chroma guard
- Section names mine CMS section ids and js-* hooks with hashy-suffix
  stripping (split_callout_JtTWTt -> split-callout-section)

364 tests pass (42 new), typecheck clean; determinism verified by
double-regen byte-comparison on two reference runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-04 02:22:15 -07:00
co-authored by Claude Fable 5
parent db0054e43b
commit 6e4921884c
16 changed files with 968 additions and 54 deletions
+151 -22
View File
@@ -27,12 +27,117 @@ export type ColorPalette = {
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
type RGBA = [number, number, number, number];
/** Parse the numeric args of a `fn(a b c / d)` / `fn(a,b,c,d)` color into [a,b,c,alpha].
* Percentages resolve against `pctBase` (255 for rgb channels, 1 for lab/lch/oklab/oklch
* L and the c/ab axes where the caller passes their own scale). `none` → 0. */
function parseColorArgs(inner: string): { nums: number[]; alpha: number } | null {
const parts = inner.split("/");
const main = parts[0]!.trim().split(/[\s,]+/).filter(Boolean);
const nums = main.map((s) => (s === "none" ? 0 : parseFloat(s)));
if (nums.length < 3 || nums.slice(0, 3).some(Number.isNaN)) return null;
let alpha = 1;
const alphaTok = parts.length > 1 ? parts[1]!.trim() : main[3];
if (alphaTok !== undefined && alphaTok !== "none") {
const a = alphaTok.endsWith("%") ? parseFloat(alphaTok) / 100 : parseFloat(alphaTok);
if (!Number.isNaN(a)) alpha = a;
}
return { nums, alpha };
}
function pctOr(tok: string, base: number): number {
return tok.endsWith("%") ? (parseFloat(tok) / 100) * base : parseFloat(tok);
}
/** Linear-light sRGB channel → gamma-encoded 0255. */
function lin2srgb(c: number): number {
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, v)) * 255);
}
/** OKLab → sRGB (CSS Color 4 reference matrices). Returns gamma-encoded 0255. */
function oklabToRgb(L: number, a: number, b: number): [number, number, number] {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
}
/** CIE Lab (D50) → sRGB, gamma-encoded 0255. */
function labToRgb(L: number, a: number, b: number): [number, number, number] {
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
const d = 6 / 29;
const inv = (t: number): number => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29));
// D50 white point.
const X = 0.9642956 * inv(fx), Y = 1.0 * inv(fy), Z = 0.8251046 * inv(fz);
// XYZ(D50) → linear sRGB (Bradford-adapted D50→D65 folded in).
const r = +3.1341359 * X - 1.6172206 * Y - 0.4906860 * Z;
const g = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
const bl = +0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
}
/** Parse any CSS color value (`rgb`/`rgba`/`hsl`/`hsla`/`oklab`/`oklch`/`lab`/`lch`/hex/
* named-ish) into rounded sRGB `[r,g,b,alpha]`, else null. The literal value is kept
* verbatim in emitted CSS; this RGB is used ONLY for clustering (grader works in sRGB
* ±2), so oklab/lch colours cluster + earn semantic roles instead of falling to --clr-N. */
function parseColor(v: string): RGBA | null {
const s = v.trim().toLowerCase();
if (s === "white") return [255, 255, 255, 1];
if (s === "black") return [0, 0, 0, 1];
const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
if (hex) {
let h = hex[1]!;
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return [r, g, b, a];
}
const fn = /^(rgba?|hsla?|oklab|oklch|lab|lch)\(([^)]*)\)$/i.exec(s);
if (!fn) return null;
const kind = fn[1]!, parsed = parseColorArgs(fn[2]!);
if (!parsed) return null;
const { nums, alpha } = parsed;
const raw = fn[2]!.split("/")[0]!.trim().split(/[\s,]+/).filter(Boolean);
if (kind.startsWith("rgb")) {
return [Math.round(pctOr(raw[0]!, 255)), Math.round(pctOr(raw[1]!, 255)), Math.round(pctOr(raw[2]!, 255)), alpha];
}
if (kind.startsWith("hsl")) {
const H = ((nums[0]! % 360) + 360) % 360, S = pctOr(raw[1]!, 1), Lh = pctOr(raw[2]!, 1);
const c = (1 - Math.abs(2 * Lh - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = Lh - c / 2;
const [r1, g1, b1] = H < 60 ? [c, x, 0] : H < 120 ? [x, c, 0] : H < 180 ? [0, c, x] : H < 240 ? [0, x, c] : H < 300 ? [x, 0, c] : [c, 0, x];
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255), alpha];
}
if (kind === "oklab") { const [r, g, b] = oklabToRgb(pctOr(raw[0]!, 1), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
if (kind === "oklch") {
const L = pctOr(raw[0]!, 1), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
const [r, g, b] = oklabToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
}
if (kind === "lab") { const [r, g, b] = labToRgb(pctOr(raw[0]!, 100), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
if (kind === "lch") {
const L = pctOr(raw[0]!, 100), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
const [r, g, b] = labToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
}
return null;
}
/** Back-compat alias: the palette parses every colour space now, not just rgb(). */
function parseRgb(v: string): RGBA | null {
const m = v.match(/rgba?\(([^)]+)\)/i);
if (!m) return null;
const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim()));
if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null;
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
return parseColor(v);
}
/** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals
* that render the same colour (`oklab(0.988242 …)` and `oklab(0.988371 …)` → rgb(251,251,251))
* collapse to ONE minted token instead of a wall of near-identical --clr-N. Null when the
* value can't be parsed (keeps the raw literal as its own key). Alpha bucketed to ±0.02. */
export function colorClusterKey(v: string): string | null {
const c = parseColor(v);
if (!c) return null;
return `${c[0]},${c[1]},${c[2]},${Math.round(c[3] * 50)}`;
}
function within2(a: RGBA, b: RGBA): boolean {
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
@@ -140,33 +245,57 @@ function paletteFrom(usage: Map<string, Usage>, roleIr: IR, minCount: number): C
const fgCluster = findCluster(pv?.bodyColor);
if (fgCluster) assign("--foreground", fgCluster);
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage.
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m)));
// Luminance (01) for deterministic tiebreaks (Rec.709). Ties in every ranking below
// break by count, then luminance (dark→light), then the literal value — so the palette
// is byte-stable for a given capture (determinism gate 6).
const lum = (c: Usage): number => (0.2126 * c.rgba[0] + 0.7152 * c.rgba[1] + 0.0722 * c.rgba[2]) / 255;
const unnamed = (c: { canon: Usage; members: string[] }): boolean => !c.members.some((m) => valueToName.has(m));
const byCount = (score: (c: Usage) => number) => (a: { canon: Usage }, b: { canon: Usage }): number =>
score(b.canon) - score(a.canon) || b.canon.count - a.canon.count || lum(a.canon) - lum(b.canon) || a.canon.value.localeCompare(b.canon.value);
// 2) Brand + accent: the most-used chromatic colours, weighted toward interactive usage
// (buttons/links). `--primary` (a.k.a. brand) is the strongest; `--accent` the next.
// A colour is "chromatic" only with real hue: HSL saturation ≥ 0.25 AND an absolute
// channel spread ≥ 24, so near-neutral off-whites/creams (maxmin ≈ 10) that merely
// tip over the ratio threshold stay neutrals → --surface, not a fake --accent.
const chroma = (c: RGBA): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
const remaining = clusters.filter(unnamed);
const chromatic = remaining
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; })
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count));
.filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24)
.sort(byCount((c) => c.interactive * 3 + c.count));
if (chromatic[0]) assign("--primary", chromatic[0]);
if (chromatic[1]) assign("--accent", chromatic[1]);
// 3) Border: most-used color that appears predominantly as a border.
// 3) Border: most-used colour that appears predominantly as a border.
const borderC = remaining
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0)
.sort((a, b) => b.canon.border - a.canon.border)[0];
.filter((c) => unnamed(c) && c.canon.border > 0)
.sort(byCount((c) => c.border))[0];
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
// 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted.
for (const c of remaining) {
if (c.members.some((m) => valueToName.has(m))) continue;
if (c.canon.count < minCount) continue;
const { s, l } = satLight(c.canon.rgba);
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c);
else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c);
// 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as
// backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds);
// mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the
// complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread),
// so no colour falls through the gap between the two thresholds.
const neutrals = remaining
.filter((c) => unnamed(c) && c.canon.count >= minCount && (satLight(c.canon.rgba).s < 0.25 || chroma(c.canon.rgba) < 24))
.sort(byCount((c) => c.count));
let surfaceN = 0;
for (const c of neutrals) {
if (!unnamed(c)) continue;
const { l } = satLight(c.canon.rgba);
if (c.canon.bg >= c.canon.text && l > 0.85) {
assign(surfaceN === 0 ? "--surface" : `--surface-${surfaceN + 1}`, c);
surfaceN++;
} else if (c.canon.text >= c.canon.bg && l > 0.2 && l < 0.7) {
assign(!taken.has("--muted-foreground") ? "--muted-foreground" : "--muted", c);
}
}
// 5) Everything else used >= minCount → numbered.
// 5) Everything else used >= minCount → numbered, in a deterministic order.
let ci = 1;
for (const c of remaining) {
if (c.members.some((m) => valueToName.has(m))) continue;
for (const c of [...remaining].sort(byCount(() => 0))) {
if (!unnamed(c)) continue;
if (c.canon.count < minCount) continue;
assign(`--color-${String(ci++).padStart(3, "0")}`, c);
}