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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:20:24 -07:00
1 parent 7fe47160e9
commit 6afb1c9b22
9 files changed
+339 -43

No files matched your search

+2 -1
View File
@@ -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 ---------- */
+11 -2
View File
@@ -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<ContactFormProps> = ({
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 ? `<span style="color:${tokenRef('error')};margin-left:2px">*</span>` : '';
+10 -2
View File
@@ -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<SubscribeFormProps> = ({
<h3 style={{
fontSize: '22px',
fontWeight: '600',
color: tokenRef('text'),
color: textOnSurface(style.backgroundColor, LEGACY_HEADING_TEXT, tokenRef('text')),
marginBottom: '20px',
fontFamily: 'Inter, sans-serif',
}}>
@@ -159,7 +167,7 @@ SubscribeForm.craft = {
});
const headingHtml = heading
? `<h3 style="font-size:22px;font-weight:600;color:${tokenRef('text')};margin-bottom:20px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h3>`
? `<h3 style="font-size:22px;font-weight:600;color:${textOnSurface(style.backgroundColor, LEGACY_HEADING_TEXT, tokenRef('text'))};margin-bottom:20px;font-family:Inter,sans-serif">${escapeHtml(heading)}</h3>`
: '';
const formStyle = cssPropsToString({
@@ -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<string, unknown>, 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<any>(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<string, unknown>): 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('<body')));
}
/* ---------------- ContactForm ---------------- */
const cfToHtml = (props: any) => 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);
},
);
});
+22 -6
View File
@@ -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 (
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
@@ -82,15 +96,16 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
textAlign: 'center',
border: `1px solid ${tokenRef('border')}`,
};
const text = cardTextColors(cardBg);
const renderCard = (t: Testimonial, i: number) => (
<div key={i} style={cardStyle}>
{renderStars(t.rating, starColor)}
<p style={{ fontSize: '15px', color: tokenRef('text'), lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
<p style={{ fontSize: '15px', color: text.quote, lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
&ldquo;{t.quote}&rdquo;
</p>
<div style={{ fontWeight: '600', fontSize: '14px', color: tokenRef('text'), fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: tokenRef('muted'), fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
<div style={{ fontWeight: '600', fontSize: '14px', color: text.name, fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: text.title, fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
</div>
);
@@ -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 => `<div style="${cardCss}">
${starsHtml(t.rating, starColor)}
<p style="font-size:15px;color:${tokenRef('text')};line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${escapeHtml(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:${tokenRef('text')};font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
<div style="font-size:13px;color:${tokenRef('muted')};font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
<p style="font-size:15px;color:${text.quote};line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${escapeHtml(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:${text.name};font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
<div style="font-size:13px;color:${text.title};font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
</div>`;
if (layout === 'single') {
+2 -10
View File
@@ -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 ----------
+30
View File
@@ -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'));
});
});
+48
View File
@@ -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;
}
+3 -22
View File
@@ -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) => {