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:
1 parent
7fe47160e9
commit
6afb1c9b22
9 files changed
+339
-43
No files matched your search
@@ -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 ---------- */
|
||||
|
||||
@@ -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>` : '';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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' }}>
|
||||
“{t.quote}”
|
||||
</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">“${escapeHtml(t.quote)}”</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">“${escapeHtml(t.quote)}”</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') {
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user