diff --git a/craft/src/panels/right/styles/ButtonStylePanel.tsx b/craft/src/panels/right/styles/ButtonStylePanel.tsx index 842da48..442c8a6 100644 --- a/craft/src/panels/right/styles/ButtonStylePanel.tsx +++ b/craft/src/panels/right/styles/ButtonStylePanel.tsx @@ -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 = ({ 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" /> = ({ selectedId, nodePr label="Hover Background" value={nodeProps.hoverBg || ''} onChange={(v) => setPropContinuous('hoverBg', v)} + contrastWith={nodeProps.hoverColor || ''} + contrastRole="bg" + contrastLabel="Hover text on its colour" /> setPropContinuous('hoverColor', v)} + contrastWith={nodeProps.hoverBg || (style.backgroundColor as string) || ''} + contrastLabel="Hover text on its colour" /> diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx index e360321..3204a18 100644 --- a/craft/src/panels/right/styles/NavStylePanel.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.tsx @@ -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): { + 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 = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); const { setProp, setProps, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId); @@ -520,6 +540,7 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps label={f.label} value={nodeProps[f.key] || f.fallback} onChange={(v) => setPropContinuous(f.key, v)} + {...navContrast(f.key, nodeProps)} /> ))} diff --git a/craft/src/panels/right/styles/PricingStylePanel.tsx b/craft/src/panels/right/styles/PricingStylePanel.tsx index 6d9b52d..d80fece 100644 --- a/craft/src/panels/right/styles/PricingStylePanel.tsx +++ b/craft/src/panels/right/styles/PricingStylePanel.tsx @@ -243,8 +243,8 @@ export const PricingStylePanel: React.FC = ({ selectedId, nodeP setPropContinuous('subColor', v)} /> setPropContinuous('featColor', v)} /> setPropContinuous('checkColor', v)} /> - setPropContinuous('btnBg', v)} /> - setPropContinuous('btnColor', v)} /> + setPropContinuous('btnBg', v)} contrastWith={nodeProps.btnColor || '#ffffff'} contrastRole="bg" contrastLabel="Button text on its colour" /> + setPropContinuous('btnColor', v)} contrastWith={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} contrastLabel="Button text on its colour" /> {/* Box model: margin, padding, border, shadow, opacity */} diff --git a/craft/src/panels/right/styles/TextStylePanel.tsx b/craft/src/panels/right/styles/TextStylePanel.tsx index 2151847..09fa93a 100644 --- a/craft/src/panels/right/styles/TextStylePanel.tsx +++ b/craft/src/panels/right/styles/TextStylePanel.tsx @@ -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 = ({ selectedId, nodeProp const dot = (key: string | readonly string[]) => ; 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 = ({ 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" />
diff --git a/craft/src/panels/right/styles/contrast.panels.test.tsx b/craft/src/panels/right/styles/contrast.panels.test.tsx new file mode 100644 index 0000000..5970f72 --- /dev/null +++ b/craft/src/panels/right/styles/contrast.panels.test.tsx @@ -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, nodes: string[] = []) => ({ + type: { resolvedName: name }, isCanvas: name === 'Container', props, displayName: name, custom: {}, hidden: false, nodes, linkedNodes: {}, parent, +}); +const state = (rootStyle: Record) => 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({ui}); +} +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' }), ); + expect(allNotes()).toEqual([]); + h.unmount(); + mount(state({ backgroundColor: '#ffffff' }), ); + 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({}), ); + expect(failNotes().length).toBe(1); + }); + test('a gradient background is reported as not checked, never judged', () => { + mount(state({ background: 'linear-gradient(90deg,#000,#fff)' }), ); + 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({}), ); + expect(failNotes()).toEqual([]); + h.unmount(); + mount(state({}), ); + expect(failNotes().length).toBe(1); + }); +}); + +describe('Button text vs button colour', () => { + test('flags a failing pair on the Button Color control', () => { + mount(state({}), ); + expect(failNotes().some((t) => t.includes('Button text on its colour'))).toBe(true); + }); + test('a passing pair is silent', () => { + mount(state({}), ); + expect(failNotes()).toEqual([]); + }); +}); + +describe('Pricing button text vs background', () => { + test('flags btnColor on btnBg', () => { + mount(state({}), ); + 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({}), ); + expect(failNotes().some((t) => t.includes('Text on background'))).toBe(true); + }); + test('Menu link colours (no known background) get no note', () => { + mount(state({}), ); + expect(failNotes()).toEqual([]); + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 674423a..6d446a0 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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 ? {label} : 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 = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => { +export const ColorSwatchGrid: React.FC = ({ 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 = ({ colors, active
+ {contrastWith && ( + + )}
{colors.map((c) => (