diff --git a/craft/src/utils/publish-checks.test.ts b/craft/src/utils/publish-checks.test.ts index 15a6eb6..2c84534 100644 --- a/craft/src/utils/publish-checks.test.ts +++ b/craft/src/utils/publish-checks.test.ts @@ -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([]); + }); + }); }); diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts index 7b64e97..55f6ecb 100644 --- a/craft/src/utils/publish-checks.ts +++ b/craft/src/utils/publish-checks.ts @@ -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 | 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(); 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;