Publish checks: button + site-level contrast via checkContrast, unchecked info line

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:52:44 -08:00
1 parent fb9ac0e6c4
commit 453268a31c
2 files changed
+134 -17

No files matched your search

+51 -3
View File
@@ -133,7 +133,8 @@ describe('runPublishChecks', () => {
test('token colours resolve through the design (text token on bg token)', () => {
const design = { ...DEFAULT_SITE_DESIGN, textColor: '#ffffff', backgroundColor: '#ffffff' };
const cs = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design });
expect(ids(cs)).toEqual(['contrast:p1:t']);
// (the same white-on-white design also trips the site-level text/background pair)
expect(ids(cs)).toEqual(['contrast:p1:t', 'contrast-site:text-bg']);
const ok = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design: { ...design, textColor: '#111111' } });
expect(ok).toEqual([]);
});
@@ -151,8 +152,11 @@ describe('runPublishChecks', () => {
});
test('unknown / gradient / image backgrounds and unparseable colours are skipped', () => {
expect(run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })])).toEqual([]);
expect(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })])).toEqual([]);
// gradient / image are never judged; they surface only as one info line
const gr = run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })]);
expect(gr.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]);
expect(gr[0].message).toContain('1 element(s)');
expect(ids(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })]))).toEqual(['contrast-unchecked:p1']);
expect(run([page({ nodes: sec('#ffffff', text('inherit')) })])).toEqual([]);
expect(run([page({ nodes: { t: text('#ffffff'), h: h1 } })])).toEqual([]);
});
@@ -165,4 +169,48 @@ describe('runPublishChecks', () => {
expect(cs).toHaveLength(MAX_CHECKS);
expect(new Set(ids(cs)).size).toBe(MAX_CHECKS);
});
describe('contrast (colour system)', () => {
const btn = (color: string, bg: string, extra: any = {}): N => ({ type: 'ButtonLink', props: { text: 'Go', href: '/x', style: { color, backgroundColor: bg, ...extra } } });
test('button text vs button background: fail warns, pass does not', () => {
expect(ids(run([page({ nodes: { h: h1, b: btn('#ffffff', '#fde047') } })]))).toEqual(['contrast:p1:b']);
expect(run([page({ nodes: { h: h1, b: btn('#ffffff', '#111111') } })])).toEqual([]);
});
test('button with tokens and brand tokens resolves', () => {
const design = { ...DEFAULT_SITE_DESIGN, swatches: [{ id: 's', label: 'B', hex: '#fde047', brand: true, slot: 1 } as any] };
const cs = runPublishChecks({ pages: [page({ nodes: { h: h1, b: btn('#ffffff', 'var(--wsb-brand-1)') } })], design });
expect(ids(cs)).toEqual(['contrast:p1:b']);
});
test('bold 18.66px+ button counts as large text (3:1)', () => {
// #8a8a8a on white ~3.4:1
expect(ids(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '16px', fontWeight: '600' }) } })]))).toEqual(['contrast:p1:b']);
expect(run([page({ nodes: { h: h1, b: btn('#8a8a8a', '#ffffff', { fontSize: '19px', fontWeight: '700' }) } })])).toEqual([]);
});
test('identical failing button pairs on a page warn once', () => {
const cs = run([page({ nodes: { h: h1, b1: btn('#ffffff', '#fde047'), b2: btn('#ffffff', '#fde047') } })]);
expect(cs).toHaveLength(1);
});
test('button with a gradient background is counted as not checked', () => {
const cs = run([page({ nodes: { h: h1, b: btn('#ffffff', 'linear-gradient(red, blue)') } })]);
expect(cs.map((c) => [c.id, c.severity])).toEqual([['contrast-unchecked:p1', 'info']]);
});
test('site text vs background: low contrast warns with site-design fix and no page', () => {
const design = { ...DEFAULT_SITE_DESIGN, textColor: '#cccccc', mutedTextColor: '#dddddd' };
const cs = runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design });
expect(cs.map((c) => c.id)).toEqual(['contrast-site:text-bg', 'contrast-site:muted-bg']);
expect(cs[0]).toMatchObject({ severity: 'warn', fix: 'site-design' });
expect(cs[0].pageId).toBeUndefined();
});
test('site pair with a gradient or unresolved colour is not judged', () => {
const design = { ...DEFAULT_SITE_DESIGN, backgroundColor: 'linear-gradient(red, blue)', textColor: '#ffffff' };
expect(runPublishChecks({ pages: [page({ nodes: { h: h1 } })], design })).toEqual([]);
});
});
});
+83 -14
View File
@@ -2,6 +2,7 @@ import type { SiteDesign } from '../state/SiteDesignContext';
import type { PageSeo } from '../types';
import { resolveTokenValue } from './tokens';
import { parseColor, type RGB } from './color';
import { checkContrast, siteColorPairs } from './contrast';
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
import { normalizeCtas } from '../components/sections/_cta-helpers';
@@ -136,6 +137,36 @@ function effectiveBackground(nodes: SNodes, id: string, design: SiteDesign): RGB
return null;
}
type BgSource = { css: string } | { reason: 'gradient' | 'image' } | null;
/** Like effectiveBackground, but reports WHY a background is not a flat colour. */
function effectiveBgSource(nodes: SNodes, id: string, design: SiteDesign): BgSource {
let cur = nodes[id]?.parent;
while (cur && nodes[cur]) {
const p = nodes[cur].props ?? {};
if (p.bgType && p.bgType !== 'color' && p.bgType !== 'solid') return { reason: p.bgType === 'image' ? 'image' : 'gradient' };
if (p.bgImage) return { reason: 'image' };
const raw = p.style?.backgroundColor || p.bgColor;
if (typeof raw === 'string' && raw.trim()) {
const resolved = resolveTokenValue(raw, design);
if (/gradient/i.test(resolved)) return { reason: 'gradient' };
if (/url\(/i.test(resolved)) return { reason: 'image' };
if (parseColor(resolved)) return { css: resolved };
}
cur = nodes[cur].parent;
}
return null;
}
/** WCAG "large text": >= 24px, or >= 18.66px and bold. */
function isLargeText(style: Record<string, any> | undefined): boolean {
const size = pxSize(style?.fontSize);
if (size === null) return false;
const w = style?.fontWeight;
const bold = w === 'bold' || w === 'bolder' || (Number(w) >= 700);
return size >= 24 || (size >= 18.66 && bold);
}
// ---------- helpers ----------
const isEmptyHref = (h: unknown) => typeof h !== 'string' || h.trim() === '' || h.trim() === '#';
@@ -233,6 +264,8 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
out.push({ id: `h1-multi:${page.id}`, severity: 'warn', message: `${where} has ${h1s} H1 headings (use one)`, pageId: page.id });
}
let uncheckedCount = 0;
const seenButtonPairs = new Set<string>();
for (const id of ids) {
const n = nodes[id];
const name = n.type?.resolvedName;
@@ -246,29 +279,65 @@ export function runPublishChecks(input: PublishChecksInput): Check[] {
pageId: page.id, nodeId: id, fix: 'select',
});
}
} else if (name === 'Heading' || name === 'TextBlock') {
} else if (name === 'Heading' || name === 'TextBlock' || name === 'ButtonLink') {
const isBtn = name === 'ButtonLink';
const colorRaw = p.style?.color;
if (typeof colorRaw !== 'string' || !colorRaw) continue;
const fg = parseColor(resolveTokenValue(colorRaw, design));
const bg = fg ? effectiveBackground(nodes, id, design) : null;
if (!fg || !bg) continue;
const size = pxSize(p.style?.fontSize);
const large = size !== null && size >= 24;
const ratio = contrastRatio(fg, bg);
const txt = label(plain(p.text ?? p.html), '');
if (ratio < (large ? 3 : 4.5)) {
out.push({
id: `contrast:${page.id}:${id}`, severity: 'warn',
message: `${where}: low-contrast ${name === 'Heading' ? 'heading' : 'text'}${txt ? ' ' + txt : ''} (${ratio.toFixed(1)}:1, needs ${large ? 3 : 4.5}:1)`,
pageId: page.id, nodeId: id, fix: 'select',
});
let bgCss: string | null;
if (isBtn) {
const raw = p.style?.backgroundColor;
bgCss = typeof raw === 'string' && raw.trim() ? raw : null;
} else {
const src = effectiveBgSource(nodes, id, design);
if (src && 'reason' in src) { uncheckedCount++; continue; }
bgCss = src ? src.css : null;
}
if (!bgCss) continue;
const large = isLargeText(p.style);
const v = checkContrast(colorRaw, bgCss, design, { large });
if (v.status === 'unchecked') {
if (v.reason === 'gradient' || v.reason === 'image') uncheckedCount++;
continue;
}
if (v.status !== 'fail') continue;
const pairKey = `${resolveTokenValue(colorRaw, design).toLowerCase()}|${resolveTokenValue(bgCss, design).toLowerCase()}|${large}`;
if (isBtn) {
if (seenButtonPairs.has(pairKey)) continue;
seenButtonPairs.add(pairKey);
}
const txt = label(plain(p.text ?? p.html), '');
const kind = isBtn ? 'button' : name === 'Heading' ? 'heading' : 'text';
out.push({
id: `contrast:${page.id}:${id}`, severity: 'warn',
message: `${where}: low-contrast ${kind}${txt ? ' ' + txt : ''} (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`,
pageId: page.id, nodeId: id, fix: 'select',
});
}
}
if (uncheckedCount > 0) {
out.push({
id: `contrast-unchecked:${page.id}`, severity: 'info',
message: `${where}: contrast not checked for ${uncheckedCount} element(s) with gradient/image backgrounds`,
pageId: page.id,
});
}
linkChecks(nodes, ids, page.id, where, out);
});
// Site-level pairs that are always seen together (text / muted text on the site background).
for (const pair of siteColorPairs(design)) {
if (pair.id !== 'text-bg' && pair.id !== 'muted-bg') continue;
const v = checkContrast(pair.fg, pair.bg, design, { large: pair.large });
if (v.status !== 'fail') continue;
out.push({
id: `contrast-site:${pair.id}`, severity: 'warn',
message: `Site design: ${pair.label.toLowerCase()} is low contrast (${v.ratio!.toFixed(1)}:1, needs ${v.required}:1)`,
fix: 'site-design',
});
}
for (const zone of zones) {
const nodes = parseState(zone.craftState);
if (!nodes) continue;