diff --git a/craft/scripts/e2e-capture-fidelity.mjs b/craft/scripts/e2e-capture-fidelity.mjs
index 2404e3a..9261ebc 100644
--- a/craft/scripts/e2e-capture-fidelity.mjs
+++ b/craft/scripts/e2e-capture-fidelity.mjs
@@ -28,6 +28,7 @@ const PAGE = `
@@ -36,6 +37,9 @@ body{margin:0;background:#ffffff}
+
+
+
`;
// [label, x, y, expected rgb]
const POINTS = [
@@ -48,6 +52,17 @@ const POINTS = [
['inline `background: linear-gradient(var(--wsb-accent), ...)`', 640, 550, [0xd9, 0xa2, 0x4a]],
['inline `padding: var(--pad) 0 0 0` keeps the 40px gap (white above the green box)', 640, 620, [0xff, 0xff, 0xff]],
['... and the green box starts below the padding', 640, 670, [0x00, 0xaa, 0x00]],
+ // A card written `border: ... var(); border-top: ` (the AI's "accent top" card, eval 2026-11-01):
+ // the CSSOM cannot serialise the overridden var() shorthand, so the left/right/bottom sides were lost.
+ ...[['inline `border:var()` + `border-top:var()` card', 700, [0xd9, 0xa2, 0x4a]],
+ ['inline `border:var()` + literal `border-top:#00aa00` card', 780, [0x00, 0xaa, 0x00]],
+ ['stylesheet `.sheet-card{border:var(); border-top:var()}`', 860, [0xd9, 0xa2, 0x4a]]].flatMap(([label, y0, top]) => [
+ [`${label}: top border`, 640, y0 + 7, top],
+ [`${label}: left border`, 2, y0 + 50, [0xd4, 0x37, 0x2c]],
+ [`${label}: right border`, 1277, y0 + 50, [0xd4, 0x37, 0x2c]],
+ [`${label}: bottom border`, 640, y0 + 77, [0xd4, 0x37, 0x2c]],
+ [`${label}: interior stays white`, 640, y0 + 45, [0xff, 0xff, 0xff]],
+ ]),
];
const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('hosthost'); }).listen(0);
@@ -67,6 +82,13 @@ async function run(name, launcher, exe) {
// The recovered shorthand goes through the same URL gate: a var() fallback url() to a non-allowed target is dropped.
const hostile = await page.evaluate((h) => window.sanitizeCaptureHtml(h), 'x
');
check(name, 'recovered var() shorthand with a disallowed url() fallback is dropped (inline + sheet)', hostile.ok && !/beacon|evil\.example/.test(hostile.html) && /border:\s*1px solid var\(--b\)/.test(hostile.html), hostile.ok ? hostile.html.slice(0, 300) : hostile.reason);
+ check(name, 'sanitiser keeps an overridden `border: ... var()` before its `border-top` override (inline + sheet)', clean.ok
+ && /border:\s*6px solid var\(--wsb-border\);\s*border-top:\s*14px solid var\(--wsb-accent\)/.test(clean.html)
+ && /border:\s*6px solid var\(--wsb-border\);\s*border-top(-width)?:\s*14px/.test(clean.html)
+ && /\.sheet-card\s*\{[^}]*border:\s*6px solid var\(--wsb-border\);\s*border-top:/.test(clean.html));
+ // Recovered-from-source shorthands pass the same URL gate.
+ const hostile2 = await page.evaluate((h) => window.sanitizeCaptureHtml(h), 'x
');
+ check(name, 'overridden var() shorthand with a disallowed url() fallback is still dropped (inline + sheet)', hostile2.ok && !/beacon2|evil\.example/.test(hostile2.html) && /border:\s*1px solid var\(--b\)/.test(hostile2.html), hostile2.ok ? '' : hostile2.reason);
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]);
check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason);
if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`));
diff --git a/craft/src/utils/sanitize-capture-html.ts b/craft/src/utils/sanitize-capture-html.ts
index 417835e..47e01a5 100644
--- a/craft/src/utils/sanitize-capture-html.ts
+++ b/craft/src/utils/sanitize-capture-html.ts
@@ -167,38 +167,133 @@ const COVER_EXTRA: Record = {
'place-self': /^(align|justify)-self$/,
columns: /^column-(width|count)$/,
};
+function covers(sh: string, prop: string): boolean {
+ return prop.startsWith(sh + '-') || !!COVER_EXTRA[sh]?.test(prop);
+}
function shorthandFor(style: CSSStyleDeclaration, prop: string): string | null {
for (const sh of SHORTHANDS) {
- if (!(prop.startsWith(sh + '-') || COVER_EXTRA[sh]?.test(prop))) continue;
+ if (!covers(sh, prop)) continue;
const v = style.getPropertyValue(sh);
if (v && /var\(/i.test(v)) return sh;
}
return null;
}
-function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string {
+/** var() shorthands the CSSOM can no longer serialise, recovered from the source text (shorthand -> declaration). */
+type Recovered = Map;
+
+/**
+ * Split a declaration block's SOURCE text into `prop: value` pairs (top-level `;`, strings, parens, comments).
+ * Only used to find candidates; every candidate is re-parsed by the CSSOM and judged by the normal gate.
+ */
+function sourceDecls(text: string): Array<[string, string]> {
+ const out: Array<[string, string]> = [];
+ let depth = 0; let quote = ''; let start = 0;
+ const push = (end: number) => {
+ const d = text.slice(start, end);
+ const c = d.indexOf(':');
+ if (c > 0) out.push([d.slice(0, c).trim().toLowerCase(), d.slice(c + 1).trim()]);
+ };
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; }
+ if (ch === '"' || ch === "'") quote = ch;
+ else if (ch === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; }
+ else if (ch === '(') depth++;
+ else if (ch === ')') depth = Math.max(0, depth - 1);
+ else if (ch === ';' && depth === 0) { push(i); start = i + 1; }
+ }
+ push(text.length);
+ return out;
+}
+
+/**
+ * The CSSOM keeps a var() shorthand only while ALL its longhands still come from it. Once a later declaration
+ * overrides one (`border: 1px solid var(--b); border-top: 4px solid var(--a)` -- the AI's accent-top card),
+ * the shorthand reads back as "" in Chromium and Firefox and the other sides were lost from the capture.
+ * Recover each such shorthand from the block's source text: the LAST source declaration of it, re-parsed ALONE
+ * by the CSSOM (must be one valid declaration producing only its own longhands), canonical value kept.
+ */
+function recoverShorthands(blockText: string, style: CSSStyleDeclaration): Recovered | null {
+ let rec: Recovered | null = null;
+ for (const [prop, raw] of sourceDecls(blockText)) {
+ if (!(SHORTHANDS as readonly string[]).includes(prop) || !/var\(/i.test(raw) || style.getPropertyValue(prop)) continue;
+ const rules = parseSheet(`a{${prop}:${raw}}`);
+ if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue;
+ const one = (rules[0] as CSSStyleRule).style;
+ if ((rules[0] as CSSStyleRule).selectorText.trim() !== 'a' || !one.length) continue;
+ let own = true;
+ for (let i = 0; i < one.length; i++) if (!covers(prop, one[i]) && one[i] !== prop) own = false;
+ const value = one.getPropertyValue(prop);
+ if (!own || !value) continue;
+ (rec ??= new Map()).set(prop, { value, important: !!one.getPropertyPriority(prop) });
+ }
+ return rec;
+}
+
+function declarations(style: CSSStyleDeclaration, pol: Policy, font = false, recovered: Recovered | null = null): string {
const out: string[] = [];
const seenShorthand = new Set();
for (let i = 0; i < style.length; i++) {
let prop = style[i];
if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue;
let value = style.getPropertyValue(prop);
+ let pri = style.getPropertyPriority(prop) ? ' !important' : '';
if (!value) {
- // A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept),
- // judged by exactly the same value gate as any other declaration.
- const sh = shorthandFor(style, prop);
+ // A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept: the
+ // CSSOM lists overriding longhands after it), judged by exactly the same value gate as any other declaration.
+ let sh = shorthandFor(style, prop);
+ if (sh) { value = style.getPropertyValue(sh); pri = style.getPropertyPriority(sh) ? ' !important' : ''; }
+ else if (recovered) {
+ sh = SHORTHANDS.find((s) => recovered.has(s) && covers(s, prop)) ?? null;
+ if (sh) { const r = recovered.get(sh)!; value = r.value; pri = r.important ? ' !important' : ''; }
+ }
if (!sh || seenShorthand.has(sh)) continue;
seenShorthand.add(sh);
prop = sh;
- value = style.getPropertyValue(sh);
}
if (!value || !valueOk(value, pol, font || prop === 'src')) continue;
- const pri = style.getPropertyPriority(prop) ? ' !important' : '';
out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);
}
return out.join(';');
}
+/** Innermost `{...}` blocks of a stylesheet's source text (declaration blocks), in order. */
+function sourceBlocks(css: string): string[] {
+ const out: string[] = [];
+ const open: Array<{ at: number; nested: boolean }> = [];
+ let quote = '';
+ for (let i = 0; i < css.length; i++) {
+ const ch = css[i];
+ if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; }
+ if (ch === '"' || ch === "'") quote = ch;
+ else if (ch === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); i = e < 0 ? css.length : e + 1; }
+ else if (ch === '{') { if (open.length) open[open.length - 1].nested = true; open.push({ at: i + 1, nested: false }); }
+ else if (ch === '}') { const b = open.pop(); if (b && !b.nested) out.push(css.slice(b.at, i)); }
+ }
+ return out;
+}
+
+/** Style-rule cssText -> recovered shorthands of the source blocks that serialise to it (consumed in order). */
+type RecoverIndex = Map>;
+function recoverIndex(css: string): RecoverIndex | null {
+ if (!/var\(/i.test(css)) return null;
+ let idx: RecoverIndex | null = null;
+ for (const block of sourceBlocks(css)) {
+ if (!/var\(/i.test(block)) continue;
+ const rules = parseSheet(`a{${block}}`);
+ if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue;
+ const st = (rules[0] as CSSStyleRule).style;
+ const rec = recoverShorthands(block, st);
+ if (!rec) continue;
+ idx ??= new Map();
+ const k = st.cssText;
+ if (!idx.has(k)) idx.set(k, []);
+ idx.get(k)!.push(rec);
+ }
+ return idx;
+}
+
function parseSheet(css: string): CSSRuleList | null {
try {
if (typeof CSSStyleSheet !== 'undefined') {
@@ -216,14 +311,14 @@ function parseSheet(css: string): CSSRuleList | null {
} catch { return null; }
}
-function rulesToCss(rules: CSSRuleList, pol: Policy): string {
+function rulesToCss(rules: CSSRuleList, pol: Policy, idx: RecoverIndex | null = null): string {
const out: string[] = [];
for (const r of Array.from(rules)) {
switch (r.type) {
case RULE.STYLE: {
const sr = r as CSSStyleRule;
if (/\\/.test(sr.selectorText) || /[<>]/.test(sr.selectorText.replace(/[>+~]/g, ''))) break;
- const d = declarations(sr.style, pol);
+ const d = declarations(sr.style, pol, false, idx?.get(sr.style.cssText)?.shift() ?? null);
if (d) out.push(`${sr.selectorText}{${d}}`);
break;
}
@@ -236,7 +331,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const mr = r as CSSMediaRule;
const cond = mr.media.mediaText;
if (/[\\{}<]/.test(cond)) break;
- const inner = rulesToCss(mr.cssRules, pol);
+ const inner = rulesToCss(mr.cssRules, pol, idx);
if (inner) out.push(`@media ${cond}{${inner}}`);
break;
}
@@ -244,7 +339,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const sr = r as CSSRule & { conditionText?: string; cssRules: CSSRuleList };
const cond = sr.conditionText ?? '';
if (!cond || /[\\{}<]/.test(cond)) break;
- const inner = rulesToCss(sr.cssRules, pol);
+ const inner = rulesToCss(sr.cssRules, pol, idx);
if (inner) out.push(`@supports ${cond}{${inner}}`);
break;
}
@@ -271,7 +366,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
/** Sanitise a stylesheet's text through the CSSOM. Empty string when nothing safe is left. */
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(css);
- return rules ? rulesToCss(rules, pol) : '';
+ return rules ? rulesToCss(rules, pol, recoverIndex(css)) : '';
}
/** Sanitise a style="" attribute value; '' when it is not a plain declaration list. */
@@ -280,7 +375,7 @@ export function cleanInlineStyle(value: string, pol: Policy = { allow: new Set()
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) return '';
const sr = rules[0] as CSSStyleRule;
if (sr.selectorText.trim() !== 'a') return '';
- return declarations(sr.style, pol);
+ return declarations(sr.style, pol, false, /var\(/i.test(value) ? recoverShorthands(value, sr.style) : null);
}
// ---------------------------------------------------------------- HTML