From 6afb1c9b224558dfc0a41bd05e19e27f13cb54f7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 13:19:42 -0700 Subject: [PATCH] fix(site-builder): keep legacy light-surface text readable on dark palettes Nodes saved before the component-tokens work (9ab0102) store light surface hexes -- ContactForm inputBg '#ffffff', SubscribeForm style.backgroundColor '#f8fafc', Testimonials cardBg '#f8fafc' -- while the text on those surfaces became var(--wsb-text)/var(--wsb-muted). On a dark Site Design palette (light text token, e.g. Agency #fafafa) that rendered white-on-white. Text on a STORED light surface (hex #rgb/#rrggbb or rgb()) now keeps the pre-token dark hex (input #1f2937, subscribe heading #1f2937, testimonial quote #374151 / name #18181b / title #64748b); absent, token or dark surfaces keep the token text. Stored props are never rewritten. Applied in both the editor render and toHtml export. Generalises Navbar's isLightNavBg into utils/color.ts (isLightColor / textOnSurface, plus parseColor moved out of publish-checks and re-exported); Navbar and autoTextColor now share it. Co-Authored-By: Claude Opus 5.5 --- craft/src/components/basic/Navbar.tsx | 3 +- craft/src/components/forms/ContactForm.tsx | 13 +- craft/src/components/forms/SubscribeForm.tsx | 12 +- .../components/legacy-light-surface.test.tsx | 211 ++++++++++++++++++ .../src/components/sections/Testimonials.tsx | 28 ++- craft/src/panels/right/styles/shared.tsx | 12 +- craft/src/utils/color.test.ts | 30 +++ craft/src/utils/color.ts | 48 ++++ craft/src/utils/publish-checks.ts | 25 +-- 9 files changed, 339 insertions(+), 43 deletions(-) create mode 100644 craft/src/components/legacy-light-surface.test.tsx create mode 100644 craft/src/utils/color.test.ts create mode 100644 craft/src/utils/color.ts diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx index d8511d7..ab584b4 100644 --- a/craft/src/components/basic/Navbar.tsx +++ b/craft/src/components/basic/Navbar.tsx @@ -3,6 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { useSiteDesign } from '../../state/SiteDesignContext'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape'; +import { isLightColor } from '../../utils/color'; import { tokenRef } from '../../utils/tokens'; import { NavLink, DROPDOWN_TOGGLE_ONCLICK, hasDropdown } from '../../utils/nav-links'; @@ -55,7 +56,7 @@ const PADDING_PRESETS = [ function isLightNavBg(bg: string): boolean { const v = (bg || '').trim().toLowerCase(); - return v === tokenRef('bg') || v === '#ffffff' || v === '#f8fafc' || v === '#f9fafb'; + return v === tokenRef('bg') || isLightColor(v); } /* ---------- Component ---------- */ diff --git a/craft/src/components/forms/ContactForm.tsx b/craft/src/components/forms/ContactForm.tsx index c08eade..f70bd59 100644 --- a/craft/src/components/forms/ContactForm.tsx +++ b/craft/src/components/forms/ContactForm.tsx @@ -4,6 +4,15 @@ import { cssPropsToString } from '../../utils/style-helpers'; import { relayFormWiring } from '../../utils/form-relay-wiring'; import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; +import { textOnSurface } from '../../utils/color'; + +/* Legacy-surface text (finding 8): nodes saved before the component-tokens + work store inputBg '#ffffff'. The typed text on a stored LIGHT input keeps + the pre-token dark hex so it stays readable on a dark Site Design palette; + absent / token / dark inputs keep the token. Stored props are never + rewritten. (Labels need no rule: labelColor has been a stored prop since + ContactForm was introduced, so legacy nodes carry their own '#374151'.) */ +const LEGACY_INPUT_TEXT = '#1f2937'; // The allowlist enforced at export time lives in `sanitizeInputType` // (utils/escape.ts) -- this union is a superset (it also covers 'textarea' @@ -85,7 +94,7 @@ export const ContactForm: UserComponent = ({ border: `1px solid ${inputBorder}`, borderRadius: '6px', backgroundColor: inputBg, - color: tokenRef('text'), + color: textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text')), boxSizing: 'border-box', outline: 'none', }; @@ -216,7 +225,7 @@ ContactForm.craft = { const labelColor = cssValue(props.labelColor) || tokenRef('text'); const inputBg = cssValue(props.inputBg) || tokenRef('bg'); const inputBorder = cssValue(props.inputBorder) || tokenRef('border'); - const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:${tokenRef('text')};box-sizing:border-box;outline:none`; + const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:${textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text'))};box-sizing:border-box;outline:none`; const fieldsHtml = (props.fields || defaultFields).map((field, i) => { const reqStar = field.required ? `*` : ''; diff --git a/craft/src/components/forms/SubscribeForm.tsx b/craft/src/components/forms/SubscribeForm.tsx index f0f1330..f9c5fc6 100644 --- a/craft/src/components/forms/SubscribeForm.tsx +++ b/craft/src/components/forms/SubscribeForm.tsx @@ -4,6 +4,14 @@ import { cssPropsToString } from '../../utils/style-helpers'; import { relayFormWiring } from '../../utils/form-relay-wiring'; import { escapeHtml, escapeAttr } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; +import { textOnSurface } from '../../utils/color'; + +/* Legacy-surface text (finding 8): nodes saved before the component-tokens + work store style.backgroundColor '#f8fafc'. The heading on a stored LIGHT + wrapper keeps the pre-token dark hex (readable on a dark Site Design + palette); absent / token / dark wrappers keep the token. The email input + has its own token surface + token text, so it is unaffected. */ +const LEGACY_HEADING_TEXT = '#1f2937'; interface SubscribeFormProps { heading?: string; @@ -56,7 +64,7 @@ export const SubscribeForm: UserComponent = ({

@@ -159,7 +167,7 @@ SubscribeForm.craft = { }); const headingHtml = heading - ? `

${escapeHtml(heading)}

` + ? `

${escapeHtml(heading)}

` : ''; const formStyle = cssPropsToString({ diff --git a/craft/src/components/legacy-light-surface.test.tsx b/craft/src/components/legacy-light-surface.test.tsx new file mode 100644 index 0000000..1cb315e --- /dev/null +++ b/craft/src/components/legacy-light-surface.test.tsx @@ -0,0 +1,211 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { buildNodeTree } from '../utils/craft-tree'; +import { templateComponentToTreeNode } from '../templates/apply-template'; +import { exportToHtml } from '../utils/html-export'; +import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; +import { tokenRef } from '../utils/tokens'; +import { ContactForm } from './forms/ContactForm'; +import { SubscribeForm } from './forms/SubscribeForm'; +import { Testimonials } from './sections/Testimonials'; + +/** + * Finding 8: nodes saved before the component-tokens work (9ab0102) carry light + * surface hex props (ContactForm inputBg '#ffffff', SubscribeForm + * style.backgroundColor '#f8fafc', Testimonials cardBg '#f8fafc'). The text on + * those surfaces is now token-driven, so on a dark Site Design palette (light + * text token, e.g. Agency textColor #fafafa) it rendered white-on-white. Text on + * a STORED LIGHT surface must keep the old dark hex; absent/token/dark surfaces + * keep the token text. Checked in both the editor render and toHtml export. + */ + +// Agency-like dark palette: light text token on a dark page. +const DARK_DESIGN = { ...DEFAULT_SITE_DESIGN, backgroundColor: '#171717', textColor: '#fafafa', mutedTextColor: '#a1a1aa' }; +const TEXT = tokenRef('text'); +const MUTED = tokenRef('muted'); +const BG = tokenRef('bg'); + +/** Normalise a hex to what jsdom's CSSStyleDeclaration reports. */ +function rgb(hex: string): string { + const h = hex.replace('#', ''); + return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`; +} +function parse(html: string): HTMLElement { + const d = document.createElement('div'); + d.innerHTML = html; + return d; +} + +let h: EditorHarness | null = null; +afterEach(() => { + h?.unmount(); + h = null; +}); + +/** Render a node through the REAL editor, as a deserialized saved node: props + * listed in `drop` are deleted from the stored node (legacy nodes missing them). */ +function renderInEditor(type: string, props: Record, drop: string[] = []): HTMLElement { + const seed = renderEditorHarness(); + seed.act(() => { + seed.actions.addNodeTree(buildNodeTree(seed.query, templateComponentToTreeNode({ type, props } as any)), 'ROOT'); + }); + const state = JSON.parse(seed.getSerialized()); + seed.unmount(); + for (const node of Object.values(state)) { + if (node.type?.resolvedName === type) for (const k of drop) delete node.props[k]; + } + h = renderEditorHarness({ initialState: JSON.stringify(state) }); + return h.container; +} + +/** Body markup of a full export under the dark palette (asserts the palette is in effect). */ +function exportDark(type: string, props: Record): HTMLElement { + const state = { + ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {} }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n1'], linkedNodes: {} }, + n1: { type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, parent: 'ROOT', hidden: false, nodes: [], linkedNodes: {} }, + }; + const { html } = exportToHtml(JSON.stringify(state), { design: DARK_DESIGN }); + expect(html).toContain('--wsb-text:#fafafa'); + return parse(html.slice(html.indexOf(' parse((ContactForm as any).toHtml(props, '', 'n1').html); + +function cfColors(root: HTMLElement) { + const inputs = Array.from(root.querySelectorAll('input:not([tabindex="-1"]):not([type="hidden"]), textarea, select')) as HTMLElement[]; + const visible = inputs.filter((el) => !/left:-|position:absolute/.test(el.getAttribute('style') || '')); + return { + input: visible.map((el) => el.style.color), + inputBg: visible.map((el) => el.style.backgroundColor), + label: (Array.from(root.querySelectorAll('label')) as HTMLElement[]).filter((l) => l.textContent).map((l) => l.style.color), + }; +} + +const LEGACY_FIELDS = [ + { type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }, + { type: 'textarea', label: 'Message', name: 'message', placeholder: 'Hi', required: false }, + { type: 'select', label: 'Topic', name: 'topic', placeholder: 'Pick', required: false, options: ['a'] }, +]; + +describe('ContactForm: legacy light inputBg keeps dark input text', () => { + const legacy = { fields: LEGACY_FIELDS, inputBg: '#ffffff', labelColor: '#374151', style: { backgroundColor: '#ffffff' } }; + + test.each([['toHtml', (p: any) => cfToHtml(p)], ['export (dark palette)', (p: any) => exportDark('ContactForm', p)], ['editor render', (p: any) => renderInEditor('ContactForm', p)]])( + '%s: stored #ffffff inputBg -> #1f2937 input text', + (_n, run) => { + const c = cfColors(run(legacy)); + expect(c.input.length).toBe(3); + for (const col of c.input) expect(col).toBe(rgb('#1f2937')); + for (const col of c.label) expect(col).toBe(rgb('#374151')); + }, + ); + + test('shorthand / rgb() light surfaces count as light', () => { + for (const bg of ['#fff', 'rgb(248, 250, 252)']) { + for (const col of cfColors(cfToHtml({ ...legacy, inputBg: bg })).input) expect(col).toBe(rgb('#1f2937')); + } + }); + + test.each([['toHtml', (p: any) => cfToHtml(p)], ['editor render', (p: any) => renderInEditor('ContactForm', p, ['inputBg', 'labelColor'])]])( + '%s: absent inputBg/labelColor -> token surface + token text', + (_n, run) => { + const c = cfColors(run({ fields: LEGACY_FIELDS })); + for (const col of c.input) expect(col).toBe(TEXT); + for (const col of c.inputBg) expect(col).toBe(BG); + for (const col of c.label) expect(col).toBe(TEXT); + }, + ); + + test.each([['toHtml', (p: any) => cfToHtml(p)], ['editor render', (p: any) => renderInEditor('ContactForm', p)]])( + '%s: dark stored inputBg keeps token text', + (_n, run) => { + const c = cfColors(run({ fields: LEGACY_FIELDS, inputBg: '#171717', labelColor: TEXT, style: { backgroundColor: '#171717' } })); + for (const col of c.input) expect(col).toBe(TEXT); + for (const col of c.label) expect(col).toBe(TEXT); + }, + ); +}); + +/* ---------------- SubscribeForm ---------------- */ + +const sfToHtml = (props: any) => parse((SubscribeForm as any).toHtml(props, '', 'n1').html); +const h3Color = (root: HTMLElement) => (root.querySelector('h3') as HTMLElement).style.color; + +describe('SubscribeForm: legacy light wrapper keeps dark heading', () => { + const legacy = { heading: 'Join', layout: 'inline', style: { backgroundColor: '#f8fafc' } }; + + test.each([['toHtml', (p: any) => sfToHtml(p)], ['export (dark palette)', (p: any) => exportDark('SubscribeForm', p)], ['editor render', (p: any) => renderInEditor('SubscribeForm', p)]])( + '%s: stored #f8fafc background -> #1f2937 heading', + (_n, run) => { + expect(h3Color(run(legacy))).toBe(rgb('#1f2937')); + }, + ); + + test.each([['toHtml', (p: any) => sfToHtml(p)], ['editor render', (p: any) => renderInEditor('SubscribeForm', p, ['style'])]])( + '%s: absent style -> token heading, token input surface/text', + (_n, run) => { + const root = run({ heading: 'Join' }); + expect(h3Color(root)).toBe(TEXT); + const input = root.querySelector('input[type="email"]') as HTMLElement; + expect(input.style.backgroundColor).toBe(BG); + expect(input.style.color).toBe(TEXT); + }, + ); + + test.each([['toHtml', (p: any) => sfToHtml(p)], ['editor render', (p: any) => renderInEditor('SubscribeForm', p)]])( + '%s: dark stored background keeps token heading', + (_n, run) => { + expect(h3Color(run({ heading: 'Join', style: { backgroundColor: '#171717' } }))).toBe(TEXT); + }, + ); +}); + +/* ---------------- Testimonials ---------------- */ + +const tToHtml = (props: any) => parse((Testimonials as any).toHtml(props, '').html); +const ITEMS = [{ quote: 'Great', name: 'Ann', title: 'CEO', rating: 5 }]; + +function cardColors(root: HTMLElement) { + const p = root.querySelector('p') as HTMLElement; + const name = p.nextElementSibling as HTMLElement; + const title = name.nextElementSibling as HTMLElement; + const card = p.parentElement as HTMLElement; + return { quote: p.style.color, name: name.style.color, title: title.style.color, card: card.style.backgroundColor }; +} + +describe('Testimonials: legacy light cardBg keeps dark card text', () => { + const legacy = { testimonials: ITEMS, cardBg: '#f8fafc', style: { backgroundColor: '#ffffff' } }; + + test.each([['toHtml', (p: any) => tToHtml(p)], ['export (dark palette)', (p: any) => exportDark('Testimonials', p)], ['editor render', (p: any) => renderInEditor('Testimonials', p)]])( + '%s: stored #f8fafc cardBg -> #374151 quote / #18181b name / #64748b title', + (_n, run) => { + const c = cardColors(run(legacy)); + expect(c.quote).toBe(rgb('#374151')); + expect(c.name).toBe(rgb('#18181b')); + expect(c.title).toBe(rgb('#64748b')); + }, + ); + + test.each([['toHtml', (p: any) => tToHtml(p)], ['editor render', (p: any) => renderInEditor('Testimonials', p, ['cardBg'])]])( + '%s: absent cardBg -> token card surface + token text', + (_n, run) => { + const c = cardColors(run({ testimonials: ITEMS })); + expect(c.card).toBe(BG); + expect(c.quote).toBe(TEXT); + expect(c.name).toBe(TEXT); + expect(c.title).toBe(MUTED); + }, + ); + + test.each([['toHtml', (p: any) => tToHtml(p)], ['editor render', (p: any) => renderInEditor('Testimonials', p)]])( + '%s: dark stored cardBg keeps token text', + (_n, run) => { + const c = cardColors(run({ testimonials: ITEMS, cardBg: '#262626' })); + expect(c.quote).toBe(TEXT); + expect(c.name).toBe(TEXT); + expect(c.title).toBe(MUTED); + }, + ); +}); diff --git a/craft/src/components/sections/Testimonials.tsx b/craft/src/components/sections/Testimonials.tsx index 905c639..5ef7120 100644 --- a/craft/src/components/sections/Testimonials.tsx +++ b/craft/src/components/sections/Testimonials.tsx @@ -1,5 +1,6 @@ import React, { CSSProperties } from 'react'; import { tokenRef } from '../../utils/tokens'; +import { textOnSurface } from '../../utils/color'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape'; @@ -32,6 +33,19 @@ const defaultTestimonials: Testimonial[] = [ { quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 }, ]; +/* Legacy-surface text (finding 8): nodes saved before the component-tokens + work store cardBg '#f8fafc'. Card text on a stored LIGHT card keeps the + pre-token dark hexes (readable on a dark Site Design palette); absent / + token / dark cards keep the text/muted tokens. Stored props are never + rewritten. */ +function cardTextColors(cardBg: unknown): { quote: string; name: string; title: string } { + return { + quote: textOnSurface(cardBg, '#374151', tokenRef('text')), + name: textOnSurface(cardBg, '#18181b', tokenRef('text')), + title: textOnSurface(cardBg, '#64748b', tokenRef('muted')), + }; +} + function renderStars(count: number, color: string): React.ReactNode { return (
@@ -82,15 +96,16 @@ export const Testimonials: UserComponent = ({ textAlign: 'center', border: `1px solid ${tokenRef('border')}`, }; + const text = cardTextColors(cardBg); const renderCard = (t: Testimonial, i: number) => (
{renderStars(t.rating, starColor)} -

+

“{t.quote}”

-
{t.name}
-
{t.title}
+
{t.name}
+
{t.title}
); @@ -176,13 +191,14 @@ Testimonials.craft = { }); const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; + const text = cardTextColors(cardBg); const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid ${tokenRef('border')}`; const cardHtml = (t: Testimonial): string => `
${starsHtml(t.rating, starColor)} -

“${escapeHtml(t.quote)}”

-
${escapeHtml(t.name)}
-
${escapeHtml(t.title)}
+

“${escapeHtml(t.quote)}”

+
${escapeHtml(t.name)}
+
${escapeHtml(t.title)}
`; if (layout === 'single') { diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 1e3b8d5..a5ca0b0 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -11,6 +11,7 @@ import { uploadAsset } from '../../../utils/assets'; import { getPref, setPref } from '../../../utils/local-prefs'; import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/SiteDesignContext'; import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens'; +import { isLightColor } from '../../../utils/color'; import { TokenSwatchRow } from './TokenSwatchRow'; import { useDevice } from '../../../state/DeviceContext'; import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style'; @@ -21,16 +22,7 @@ import type { DeviceMode } from '../../../types'; `design` first. Anything still not a hex after that (gradients, unknown vars) gets the white default. */ export function autoTextColor(bg: string, design: SiteDesign = DEFAULT_SITE_DESIGN): string { - const resolved = resolveTokenValue(bg, design); - if (resolved.startsWith('#')) { - const hex = resolved.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16); - const g = parseInt(hex.substring(2, 4), 16); - const b = parseInt(hex.substring(4, 6), 16); - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - return luminance > 0.5 ? '#18181b' : '#ffffff'; - } - return '#ffffff'; + return isLightColor(resolveTokenValue(bg, design)) ? '#18181b' : '#ffffff'; } /* ---------- Token-aware colour display ---------- diff --git a/craft/src/utils/color.test.ts b/craft/src/utils/color.test.ts new file mode 100644 index 0000000..45a2a96 --- /dev/null +++ b/craft/src/utils/color.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'vitest'; +import { isLightColor, textOnSurface } from './color'; +import { tokenRef } from './tokens'; + +describe('isLightColor (stored-surface lightness heuristic)', () => { + test('light hex / rgb() surfaces are light', () => { + for (const v of ['#ffffff', '#fff', '#FFF', '#f8fafc', '#f9fafb', ' #F8FAFC ', 'rgb(248, 250, 252)', 'rgba(255,255,255,1)', '#e5e7eb']) { + expect(isLightColor(v), v).toBe(true); + } + }); + test('dark / mid hex surfaces are not light', () => { + for (const v of ['#171717', '#000', '#1f2937', 'rgb(23, 23, 23)', '#3b82f6']) { + expect(isLightColor(v), v).toBe(false); + } + }); + test('tokens, blanks, gradients, transparent and non-strings are not light', () => { + for (const v of [tokenRef('bg'), '', undefined, null, 'transparent', 'linear-gradient(#fff,#fff)', 'rgba(255,255,255,0)', 42]) { + expect(isLightColor(v as any), String(v)).toBe(false); + } + }); +}); + +describe('textOnSurface', () => { + test('stored light surface -> legacy dark hex; otherwise the token', () => { + expect(textOnSurface('#ffffff', '#1f2937', tokenRef('text'))).toBe('#1f2937'); + expect(textOnSurface('#171717', '#1f2937', tokenRef('text'))).toBe(tokenRef('text')); + expect(textOnSurface(undefined, '#1f2937', tokenRef('text'))).toBe(tokenRef('text')); + expect(textOnSurface(tokenRef('bg'), '#1f2937', tokenRef('text'))).toBe(tokenRef('text')); + }); +}); diff --git a/craft/src/utils/color.ts b/craft/src/utils/color.ts new file mode 100644 index 0000000..ed742b8 --- /dev/null +++ b/craft/src/utils/color.ts @@ -0,0 +1,48 @@ +/* ---------- Colour parsing + stored-surface lightness ---------- + Dependency-free (no component imports) so components can use it without + an import cycle through publish-checks. */ + +export type RGB = [number, number, number]; + +/** Hex (#rgb, #rgba, #rrggbb, #rrggbbaa) and rgb()/rgba() -> opaque RGB; null for anything else (tokens, gradients, names, alpha 0). */ +export function parseColor(input: string): RGB | null { + const v = input.trim().toLowerCase(); + let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v); + if (m) { + let h = m[1]; + if (h.length <= 4) h = h.split('').map((c) => c + c).join(''); + if (h.length === 8 && parseInt(h.slice(6), 16) === 0) return null; + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + } + m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v); + if (m) { + if (m[4] !== undefined) { + const a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]); + if (!(a > 0)) return null; + } + return [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])]; + } + return null; +} + +/** True when `value` is a literal (hex / rgb()) colour light enough to need + * dark text (perceived brightness > 0.5 -- same YIQ test as autoTextColor). + * Token refs (`var(--wsb-*)`), blanks, gradients and transparent are NOT + * light: their text should follow the Site Design tokens. */ +export function isLightColor(value: unknown): boolean { + if (typeof value !== 'string' || !value.trim()) return false; + const rgb = parseColor(value); + if (!rgb) return false; + const [r, g, b] = rgb; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.5; +} + +/** Text colour for a surface whose colour may be a STORED legacy hex. + * Nodes saved before the component-tokens work carry light surface hexes + * (e.g. '#ffffff' / '#f8fafc') while their text is now token-driven, so on a + * dark palette (light text token) the text vanished. A stored light surface + * keeps the pre-token dark `legacyHex`; an absent / token / dark surface keeps + * `token`. Stored values are never rewritten. */ +export function textOnSurface(surface: unknown, legacyHex: string, token: string): string { + return isLightColor(surface) ? legacyHex : token; +} diff --git a/craft/src/utils/publish-checks.ts b/craft/src/utils/publish-checks.ts index b168d9b..7b64e97 100644 --- a/craft/src/utils/publish-checks.ts +++ b/craft/src/utils/publish-checks.ts @@ -1,6 +1,7 @@ import type { SiteDesign } from '../state/SiteDesignContext'; import type { PageSeo } from '../types'; import { resolveTokenValue } from './tokens'; +import { parseColor, type RGB } from './color'; import { PLACEHOLDER_SRC } from '../components/media/ImageBlock'; import { normalizeCtas } from '../components/sections/_cta-helpers'; @@ -85,28 +86,8 @@ function walk(nodes: SNodes): string[] { // ---------- colour / contrast ---------- -type RGB = [number, number, number]; - -/** Hex (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba() -> opaque RGB; null for anything else (gradients, names, alpha 0). */ -export function parseColor(input: string): RGB | null { - const v = input.trim().toLowerCase(); - let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v); - if (m) { - let h = m[1]; - if (h.length <= 4) h = h.split('').map((c) => c + c).join(''); - if (h.length === 8 && parseInt(h.slice(6), 16) === 0) return null; - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; - } - m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v); - if (m) { - if (m[4] !== undefined) { - const a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]); - if (!(a > 0)) return null; - } - return [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])]; - } - return null; -} +// parseColor lives in ./color (dependency-free, shared with components); re-exported for existing callers. +export { parseColor }; function luminance([r, g, b]: RGB): number { const f = (c: number) => {