Inline contrast in Text, Button, Pricing and Nav/Footer panels (effective background from node parents)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 10:02:57 -08:00
1 parent dcf9c92fee
commit ba5611c3e3
8 files changed
+245 -3

No files matched your search

@@ -31,6 +31,7 @@ import {
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { isLargeText } from '../../../utils/effective-background';
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
@@ -89,6 +90,10 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
activeValue={style.backgroundColor as string}
onSelect={setButtonColor}
onContinuous={setButtonColorContinuous}
contrastWith={(style.color as string) || ''}
contrastRole="bg"
contrastLarge={isLargeText(style.fontSize as string, style.fontWeight as string)}
contrastLabel="Button text on its colour"
/>
</div>
<TextInputField
@@ -142,11 +147,16 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
label="Hover Background"
value={nodeProps.hoverBg || ''}
onChange={(v) => setPropContinuous('hoverBg', v)}
contrastWith={nodeProps.hoverColor || ''}
contrastRole="bg"
contrastLabel="Hover text on its colour"
/>
<ColorPickerField
label="Hover Text Color"
value={nodeProps.hoverColor || ''}
onChange={(v) => setPropContinuous('hoverColor', v)}
contrastWith={nodeProps.hoverBg || (style.backgroundColor as string) || ''}
contrastLabel="Hover text on its colour"
/>
</CollapsibleSection>
@@ -188,6 +188,26 @@ const PageLinkRow: React.FC<{ link: NavLink; pages: PageData[]; onHide: (pageId:
};
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
/**
* Inline-contrast props for the nav family's colour pairs that are plainly
* seen together: text vs the bar's own background, CTA text vs the CTA colour.
* Menu's link colours have no known background here, so they get none.
*/
export function navContrast(key: string, nodeProps: Record<string, any>): {
contrastWith?: string; contrastRole?: 'fg' | 'bg'; contrastLabel?: string;
} {
const bar = nodeProps.backgroundColor || '#ffffff';
const text = nodeProps.textColor || '#18181b';
const cta = (nodeProps.ctaColor !== undefined ? nodeProps.ctaColor : nodeProps.ctaBgColor) || 'var(--wsb-primary)';
if (key === 'textColor' && nodeProps.backgroundColor !== undefined) return { contrastWith: bar, contrastLabel: 'Text on background' };
if (key === 'backgroundColor' && nodeProps.textColor !== undefined) return { contrastWith: text, contrastRole: 'bg', contrastLabel: 'Text on background' };
if (key === 'ctaTextColor' && (nodeProps.ctaColor !== undefined || nodeProps.ctaBgColor !== undefined)) return { contrastWith: cta, contrastLabel: 'Button text on its colour' };
if (key === 'ctaColor' || key === 'ctaBgColor') {
if (nodeProps.ctaTextColor !== undefined) return { contrastWith: nodeProps.ctaTextColor || '#ffffff', contrastRole: 'bg', contrastLabel: 'Button text on its colour' };
}
return {};
}
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setProps, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
@@ -520,6 +540,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setPropContinuous(f.key, v)}
{...navContrast(f.key, nodeProps)}
/>
))}
</CollapsibleSection>
@@ -243,8 +243,8 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setPropContinuous('subColor', v)} />
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setPropContinuous('featColor', v)} />
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setPropContinuous('checkColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => setPropContinuous('btnBg', v)} />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => setPropContinuous('btnBg', v)} contrastWith={nodeProps.btnColor || '#ffffff'} contrastRole="bg" contrastLabel="Button text on its colour" />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} contrastWith={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} contrastLabel="Button text on its colour" />
</CollapsibleSection>
{/* Box model: margin, padding, border, shadow, opacity */}
@@ -25,9 +25,11 @@ import {
sectionGap,
labelStyle,
useNodeProp,
useEffectiveBackground,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { isLargeText } from '../../../utils/effective-background';
/* Text-transform is a small fixed enum with no natural home in the shared
foundation presets (constants/presets.ts is import-only for this
@@ -67,6 +69,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId);
const effectiveBg = useEffectiveBackground(selectedId);
const isItalic = style.fontStyle === 'italic';
const isUnderline = style.textDecoration === 'underline';
@@ -79,6 +82,9 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
colors={TEXT_COLORS}
activeValue={style.color as string}
onSelect={(v) => setPropStyle('color', v)}
contrastWith={effectiveBg}
contrastLarge={isLargeText(style.fontSize as string, style.fontWeight as string)}
contrastLabel="Text on its background"
/>
</div>
<div className="guided-section">
@@ -0,0 +1,83 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { SiteDesignProvider } from '../../../state/SiteDesignContext';
import { TextStylePanel } from './TextStylePanel';
import { ButtonStylePanel } from './ButtonStylePanel';
import { PricingStylePanel } from './PricingStylePanel';
import { NavStylePanel } from './NavStylePanel';
let h: EditorHarness;
afterEach(() => { h?.unmount(); document.body.innerHTML = ''; });
const node = (parent: string | null, name: string, props: Record<string, unknown>, nodes: string[] = []) => ({
type: { resolvedName: name }, isCanvas: name === 'Container', props, displayName: name, custom: {}, hidden: false, nodes, linkedNodes: {}, parent,
});
const state = (rootStyle: Record<string, string>) => JSON.stringify({
ROOT: node(null, 'Container', { style: rootStyle, tag: 'div' }, ['t1']),
t1: node('ROOT', 'TextBlock', { text: 'hi', style: {} }),
});
function mount(initialState: string, ui: React.ReactNode) {
h = renderEditorHarness({ initialState });
h.mountChild(<SiteDesignProvider>{ui}</SiteDesignProvider>);
}
const failNotes = () => Array.from(h.container.querySelectorAll('.contrast-note--fail')).map((n) => n.textContent ?? '');
const allNotes = () => Array.from(h.container.querySelectorAll('.contrast-note')).map((n) => n.textContent ?? '');
describe('Text / Heading colour vs its effective background', () => {
test('uses the nearest ancestor background: light text on a dark parent passes, on white it fails', () => {
mount(state({ backgroundColor: '#0f172a' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#ffffff' } }} />);
expect(allNotes()).toEqual([]);
h.unmount();
mount(state({ backgroundColor: '#ffffff' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#eeeeee' } }} />);
expect(failNotes().length).toBe(1);
expect(failNotes()[0]).toContain('Text on its background');
});
test('no background anywhere -> the site Background token (white by default)', () => {
mount(state({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#eeeeee' } }} />);
expect(failNotes().length).toBe(1);
});
test('a gradient background is reported as not checked, never judged', () => {
mount(state({ background: 'linear-gradient(90deg,#000,#fff)' }), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#777777' } }} />);
expect(failNotes()).toEqual([]);
expect(allNotes().join('')).toContain('not checked');
});
test('large text uses the 3:1 threshold', () => {
// #949494 on white = ~3.03:1: fails normal, passes large.
mount(state({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#949494', fontSize: '32px' } }} />);
expect(failNotes()).toEqual([]);
h.unmount();
mount(state({}), <TextStylePanel selectedId="t1" nodeProps={{ text: 'hi', style: { color: '#949494', fontSize: '16px' } }} />);
expect(failNotes().length).toBe(1);
});
});
describe('Button text vs button colour', () => {
test('flags a failing pair on the Button Color control', () => {
mount(state({}), <ButtonStylePanel selectedId="t1" nodeProps={{ text: 'Go', style: { backgroundColor: '#ffff00', color: '#ffffff' } }} />);
expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true);
});
test('a passing pair is silent', () => {
mount(state({}), <ButtonStylePanel selectedId="t1" nodeProps={{ text: 'Go', style: { backgroundColor: '#1d4ed8', color: '#ffffff' } }} />);
expect(failNotes()).toEqual([]);
});
});
describe('Pricing button text vs background', () => {
test('flags btnColor on btnBg', () => {
mount(state({}), <PricingStylePanel selectedId="t1" nodeProps={{ btnBg: '#ffee00', btnColor: '#ffffff', plans: [] }} />);
expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true);
});
});
describe('Navbar / Footer text vs background', () => {
test('flags textColor on backgroundColor', () => {
mount(state({}), <NavStylePanel selectedId="t1" nodeProps={{ backgroundColor: '#ffffff', textColor: '#eeeeee', links: [] }} />);
expect(failNotes().some((t) => t.includes('Text on background'))).toBe(true);
});
test('Menu link colours (no known background) get no note', () => {
mount(state({}), <NavStylePanel selectedId="t1" nodeProps={{ linkColor: '#fafafa', links: [] }} />);
expect(failNotes()).toEqual([]);
});
});
+26 -1
View File
@@ -16,6 +16,7 @@ import { TokenSwatchRow } from './TokenSwatchRow';
import { ColorPickTools } from './ColorPickTools';
import { ContrastNote } from './ContrastNote';
import { parseColorInput } from '../../../utils/color-input';
import { effectiveBackgroundOf } from '../../../utils/effective-background';
import { brandSlotOf } from '../../../utils/swatches';
import { useDevice } from '../../../state/DeviceContext';
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
@@ -48,6 +49,16 @@ export function useResolvedColor(value: string | undefined): { resolved: string;
return { resolved, nativeValue, tokenLabel };
}
/**
* The background the selected node's text is read against (see
* utils/effective-background.ts), else the site Background token. Defensive
* about a mocked `useEditor` in unit tests.
*/
export function useEffectiveBackground(selectedId: string): string {
const r = useEditor((state) => ({ bg: effectiveBackgroundOf(state.nodes as never, selectedId) })) as unknown as { bg?: string | null } | undefined;
return r?.bg || tokenRef('bg');
}
/** Small "linked to a site colour" badge shown beside the hex box. */
export const TokenChip: React.FC<{ label: string | null }> = ({ label }) =>
label ? <span className="token-chip" title={`Linked to site colour: ${label}`}>{label}</span> : null;
@@ -98,8 +109,13 @@ interface ColorSwatchGridProps {
onContinuous?: (value: string) => void;
/** Show the site-colour (token) row first. Default true. */
showTokens?: boolean;
/** Same inline contrast hint as ColorPickerField. */
contrastWith?: string;
contrastLarge?: boolean;
contrastLabel?: string;
contrastRole?: 'fg' | 'bg';
}
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true, contrastWith, contrastLarge, contrastLabel, contrastRole = 'fg' }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue);
const { hex, invalid } = useColorText(resolved, onSelect);
return (
@@ -125,6 +141,15 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
</div>
<HexError show={invalid} />
<ColorPickTools value={activeValue || ''} onPick={onSelect} allowRefs={showTokens} />
{contrastWith && (
<ContrastNote
fg={contrastRole === 'fg' ? (activeValue || '') : contrastWith}
bg={contrastRole === 'fg' ? contrastWith : (activeValue || '')}
large={contrastLarge}
label={contrastLabel}
onUseSuggestion={contrastRole === 'fg' ? onSelect : undefined}
/>
)}
<div className="preset-grid">
{colors.map((c) => (
<button
@@ -0,0 +1,52 @@
import { describe, test, expect } from 'vitest';
import { effectiveBackgroundOf, isLargeText } from './effective-background';
const n = (parent: string | null, props: Record<string, unknown> = {}) => ({ data: { parent, props } });
describe('effectiveBackgroundOf', () => {
test("uses the node's own flat background first", () => {
const nodes = { ROOT: n(null, { style: { backgroundColor: '#fff' } }), t: n('ROOT', { style: { backgroundColor: '#123456' } }) };
expect(effectiveBackgroundOf(nodes, 't')).toBe('#123456');
});
test('else the nearest ancestor with a background (style or direct prop)', () => {
const nodes = {
ROOT: n(null, { style: { backgroundColor: '#ffffff' } }),
sec: n('ROOT', { backgroundColor: 'var(--wsb-primary)' }),
col: n('sec', { style: {} }),
t: n('col', {}),
};
expect(effectiveBackgroundOf(nodes, 't')).toBe('var(--wsb-primary)');
});
test('a gradient / image background is returned as-is so the contrast check reports "not checked"', () => {
const nodes = {
ROOT: n(null, {}),
hero: n('ROOT', { style: { backgroundImage: 'url(a.jpg)' } }),
t: n('hero', {}),
};
expect(effectiveBackgroundOf(nodes, 't')).toMatch(/url\(/);
const g = { ROOT: n(null, {}), s: n('ROOT', { style: { background: 'linear-gradient(90deg,#000,#fff)' } }), t: n('s', {}) };
expect(effectiveBackgroundOf(g, 't')).toMatch(/gradient/);
});
test('transparent / blank backgrounds are skipped; nothing found -> null', () => {
const nodes = { ROOT: n(null, { style: { backgroundColor: 'transparent' } }), t: n('ROOT', { style: { backgroundColor: '' } }) };
expect(effectiveBackgroundOf(nodes, 't')).toBeNull();
});
test('unknown id / cyclic parents do not hang', () => {
expect(effectiveBackgroundOf({}, 'x')).toBeNull();
const cyc = { a: n('b', {}), b: n('a', {}) };
expect(effectiveBackgroundOf(cyc, 'a')).toBeNull();
});
});
describe('isLargeText (WCAG: >=24px, or >=18.66px bold)', () => {
test('px / rem / em', () => {
expect(isLargeText('24px', '400')).toBe(true);
expect(isLargeText('23px', '400')).toBe(false);
expect(isLargeText('19px', '700')).toBe(true);
expect(isLargeText('18px', 'bold')).toBe(false);
expect(isLargeText('1.5rem', undefined)).toBe(true);
expect(isLargeText('2em', '400')).toBe(true);
expect(isLargeText(undefined, undefined)).toBe(false);
expect(isLargeText('large', '400')).toBe(false);
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* The background a text element is actually read against, found cheaply from
* the Craft node tree. Bounded rule (spec: "own text colour vs own background"):
* 1. the element's own flat background, else
* 2. the nearest ancestor that declares one (style.backgroundColor or a
* direct backgroundColor prop), else
* 3. null (the caller falls back to the site Background token).
* A gradient / image on the way is returned verbatim so the check reports
* "not checked" instead of guessing. No stacking of translucent layers, no
* overlay inference, no computed styles.
*/
interface NodeLike { data?: { parent?: string | null; props?: Record<string, any> } }
const blank = (v: unknown): boolean => {
if (typeof v !== 'string') return true;
const t = v.trim().toLowerCase();
return t === '' || t === 'transparent' || t === 'none' || t === 'inherit' || t === 'initial';
};
const paintsImage = (v: unknown): v is string => typeof v === 'string' && /gradient|url\(/i.test(v);
export function effectiveBackgroundOf(nodes: Record<string, NodeLike>, id: string): string | null {
const seen = new Set<string>();
let cur: string | null | undefined = id;
while (cur && !seen.has(cur) && nodes[cur]) {
seen.add(cur);
const props = nodes[cur].data?.props ?? {};
const style = (props.style ?? {}) as Record<string, unknown>;
for (const v of [style.backgroundImage, style.background]) if (paintsImage(v)) return v;
if (!blank(style.backgroundColor)) return String(style.backgroundColor);
if (!blank(props.backgroundColor)) return String(props.backgroundColor);
if (paintsImage(props.backgroundColor)) return props.backgroundColor;
cur = nodes[cur].data?.parent;
}
return null;
}
/** WCAG "large text": >= 24px, or >= 18.66px (14pt) and bold. rem/em count as 16px. */
export function isLargeText(fontSize: string | undefined, fontWeight: string | number | undefined): boolean {
const m = /^\s*([\d.]+)\s*(px|rem|em)\s*$/i.exec(String(fontSize ?? ''));
if (!m) return false;
const px = parseFloat(m[1]) * (m[2].toLowerCase() === 'px' ? 1 : 16);
const w = String(fontWeight ?? '').toLowerCase();
const bold = w === 'bold' || w === 'bolder' || Number(w) >= 700;
return px >= 24 || (px >= 18.66 && bold);
}