diff --git a/craft/src/components/layout/BackgroundSection.tsx b/craft/src/components/layout/BackgroundSection.tsx index 64f0d44..b878cca 100644 --- a/craft/src/components/layout/BackgroundSection.tsx +++ b/craft/src/components/layout/BackgroundSection.tsx @@ -3,6 +3,7 @@ import { useNode, Element, UserComponent } from '@craftjs/core'; import { Container } from './Container'; import { cssPropsToString } from '../../utils/style-helpers'; import { tokenRef } from '../../utils/tokens'; +import { PlaceholderHint } from './PlaceholderHint'; import { escapeAttr, cssValue } from '../../utils/escape'; interface BackgroundSectionProps { @@ -55,6 +56,7 @@ export const BackgroundSection: UserComponent = ({ pointerEvents: 'none', }} /> + {/* Content */} + Add a photo here + + ); +} diff --git a/craft/src/components/sections/CallToAction.tsx b/craft/src/components/sections/CallToAction.tsx index 0e46bf8..09554ea 100644 --- a/craft/src/components/sections/CallToAction.tsx +++ b/craft/src/components/sections/CallToAction.tsx @@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; +import { PlaceholderHint } from '../layout/PlaceholderHint'; interface CallToActionProps { heading?: string; @@ -106,6 +107,8 @@ export const CallToAction: UserComponent = ({ /> )} + {bgType === 'image' && } +

{heading} diff --git a/craft/src/components/sections/HeroSimple.tsx b/craft/src/components/sections/HeroSimple.tsx index c7e3093..18f69ea 100644 --- a/craft/src/components/sections/HeroSimple.tsx +++ b/craft/src/components/sections/HeroSimple.tsx @@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; +import { PlaceholderHint } from '../layout/PlaceholderHint'; interface HeroProps { heading?: string; @@ -146,6 +147,8 @@ export const HeroSimple: UserComponent = ({ }} /> )} + {bgType === 'image' && } + {/* Content */}
{ expect(cleanAiProps({ bgImage: LIVE }, 'BackgroundSection').bgImage).toBe('https://placehold.co/1920x1080/101a2b/101a2b?text=%E2%80%8B'); }); test('HeroSimple.bgImage, backgroundImageUrl, CallToAction bgValue, ContentSlider slide images', () => { - expect(cleanAiProps({ bgImage: 'https://placehold.co/1600x900?text=Hero' }, 'HeroSimple').bgImage).toBe('https://placehold.co/1600x900/dddddd/dddddd?text=%E2%80%8B'); - expect(cleanAiProps({ backgroundImageUrl: 'https://placehold.co/1600x900/abc.png?text=x' }, 'Section').backgroundImageUrl).toBe('https://placehold.co/1600x900/aabbcc/aabbcc.png?text=%E2%80%8B'); + expect(cleanAiProps({ bgImage: 'https://placehold.co/1600x900?text=Hero' }, 'HeroSimple').bgImage).toBe('https://placehold.co/1600x900/757575/757575?text=%E2%80%8B'); + expect(cleanAiProps({ backgroundImageUrl: 'https://placehold.co/1600x900/abc.png?text=x' }, 'Section').backgroundImageUrl).toBe('https://placehold.co/1600x900/646e78/646e78.png?text=%E2%80%8B'); expect(cleanAiProps({ bgType: 'image', bgValue: 'https://placehold.co/1200x600/222222/ffffff?text=Band' }, 'CallToAction').bgValue).toBe('https://placehold.co/1200x600/222222/222222?text=%E2%80%8B'); expect((cleanAiProps({ slides: [{ imageSrc: 'https://placehold.co/1200x600/000000/ffffff?text=Slide', heading: 'Hi' }] }, 'ContentSlider').slides as any)[0].imageSrc).toBe('https://placehold.co/1200x600/000000/000000?text=%E2%80%8B'); }); diff --git a/craft/src/sitesmith/clean-ai-response.ts b/craft/src/sitesmith/clean-ai-response.ts index 8b01cbc..52e3868 100644 --- a/craft/src/sitesmith/clean-ai-response.ts +++ b/craft/src/sitesmith/clean-ai-response.ts @@ -24,6 +24,7 @@ * the editor and at export); it is not changed here. * Pure function: never mutates its input. */ +import { LABEL_FREE_TEXT, placeholderBoxHex } from './placeholder-bg'; import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; /** Image hosts model output may reference: the stock provider the builder imports from @@ -163,7 +164,7 @@ const hex6 = (h: string) => (h.length === 3 ? h.split('').map((c) => c + c).join * zero-width space (`?text=%E2%80%8B`), for which placehold.co draws NO glyphs (measured: a bare SVG and a * 1-colour PNG); the same-colour text stays as a second guard. */ -export const LABEL_FREE_TEXT = '?text=%E2%80%8B'; +export { LABEL_FREE_TEXT }; export function labelFreePlaceholder(url: string): string { let u: URL; try { u = new URL(url); } catch { return url; } @@ -215,6 +216,26 @@ function cleanObject(obj: unknown, component: string | undefined, depth: number) const c = cleanValue(k, v, component, depth); if (c !== undefined) out[k] = c; } + return ensurePlaceholderContrast(out); +} + +/** + * Round 8: a label-free placeholder box colour is the model's pick and may sit under light OR dark text. + * When this object also carries a hex `textColor`/`color`, re-pick the box colour so the text reads on it + * (>= 4.5:1; no explicit hex text colour = white text, the hero/band default). Only label-free placehold.co + * background URLs are touched. + */ +const PLACEHOLDER_BG_KEYS = ['bgImage', 'backgroundImageUrl', 'bgValue', 'imageSrc']; +function ensurePlaceholderContrast(out: Record): Record { + const text = [out.textColor, out.color].find((t) => typeof t === 'string' && /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(t)) as string | undefined; + for (const k of PLACEHOLDER_BG_KEYS) { + const v = out[k]; + if (typeof v !== 'string' || !v.endsWith(LABEL_FREE_TEXT)) continue; + const m = /^(https:\/\/placehold\.co\/\d{1,4}x\d{1,4}\/)([0-9a-f]{6})\/([0-9a-f]{6})(\.[a-z]+)?\?/i.exec(v); + if (!m) continue; + const box = placeholderBoxHex(m[2], text); + out[k] = `${m[1]}${box}/${box}${m[4] ?? ''}${LABEL_FREE_TEXT}`; + } return out; } diff --git a/craft/src/sitesmith/placeholder-bg.test.tsx b/craft/src/sitesmith/placeholder-bg.test.tsx new file mode 100644 index 0000000..9bb0176 --- /dev/null +++ b/craft/src/sitesmith/placeholder-bg.test.tsx @@ -0,0 +1,71 @@ +import { describe, test, expect, vi } from 'vitest'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +vi.mock('@craftjs/core', () => ({ + useNode: () => ({ connectors: { connect: (el: any) => el, drag: (el: any) => el }, selected: false }), + Element: () => null, +})); + +import { isFillablePlaceholderBg, placeholderBoxHex, contrastRatioHex } from './placeholder-bg'; +import { cleanAiProps } from './clean-ai-response'; +import { HeroSimple } from '../components/sections/HeroSimple'; +import { BackgroundSection } from '../components/layout/BackgroundSection'; +import { CallToAction } from '../components/sections/CallToAction'; + +const PH = 'https://placehold.co/1920x1080/101a2b/101a2b?text=%E2%80%8B'; + +describe('placeholder contrast', () => { + test('light box under default (white) text is darkened to >= 4.5:1', () => { + const out = cleanAiProps({ bgImage: 'https://placehold.co/1920x1080/f5e6c8/f5e6c8?text=Hero' }, 'BackgroundSection').bgImage as string; + const hex = /placehold\.co\/1920x1080\/([0-9a-f]{6})\//.exec(out)![1]; + expect(contrastRatioHex(hex, 'ffffff')!).toBeGreaterThanOrEqual(4.5); + expect(out.endsWith('?text=%E2%80%8B')).toBe(true); + }); + test('dark box under white text is left alone', () => { + expect(cleanAiProps({ bgImage: 'https://placehold.co/1920x1080/101a2b/fbfaf7?text=x' }, 'HeroSimple').bgImage).toBe(PH); + }); + test('dark explicit textColor lightens a dark box instead', () => { + const out = cleanAiProps({ bgType: 'image', textColor: '#111111', bgImage: PH }, 'HeroSimple').bgImage as string; + const hex = /\/([0-9a-f]{6})\/[0-9a-f]{6}\?/.exec(out)![1]; + expect(contrastRatioHex(hex, '111111')!).toBeGreaterThanOrEqual(4.5); + }); + test('colour-name path (unparsable) falls back to a readable slate; the helper never loops forever', () => { + expect(placeholderBoxHex('navy', null)).toMatch(/^[0-9a-f]{6}$/); + expect(contrastRatioHex(placeholderBoxHex('ffffff', 'ffffff'), 'ffffff')!).toBeGreaterThanOrEqual(1); + }); +}); + +describe('isFillablePlaceholderBg', () => { + test('only the label-free placehold.co form', () => { + expect(isFillablePlaceholderBg(PH)).toBe(true); + expect(isFillablePlaceholderBg('https://placehold.co/800x600?text=Team')).toBe(false); + expect(isFillablePlaceholderBg('assets/hero.jpg')).toBe(false); + expect(isFillablePlaceholderBg(undefined)).toBe(false); + }); +}); + +function html(el: React.ReactElement): string { + const c = document.createElement('div'); document.body.appendChild(c); + act(() => { createRoot(c).render(el); }); + return c.innerHTML; +} + +describe('editor-only hint', () => { + test('shown in the editor render for a placeholder background, absent for a real photo', () => { + expect(html()).toContain('Add a photo here'); + expect(html()).toContain('Add a photo here'); + expect(html()).toContain('Add a photo here'); + expect(html()).not.toContain('Add a photo here'); + expect(html()).not.toContain('Add a photo here'); + }); + test('never in published HTML', () => { + const h = [ + (HeroSimple as any).toHtml({ bgType: 'image', bgImage: PH }, '').html, + (BackgroundSection as any).toHtml({ bgImage: PH }, '').html, + (CallToAction as any).toHtml({ bgType: 'image', bgValue: PH }, '').html, + ]; + for (const x of h) { expect(x).not.toContain('Add a photo'); expect(x).not.toContain('data-editor-only'); } + }); +}); diff --git a/craft/src/sitesmith/placeholder-bg.ts b/craft/src/sitesmith/placeholder-bg.ts new file mode 100644 index 0000000..31959a9 --- /dev/null +++ b/craft/src/sitesmith/placeholder-bg.ts @@ -0,0 +1,51 @@ +/** + * Round 8: label-free background placeholders (see clean-ai-response.ts labelFreePlaceholder) are a flat + * placehold.co box. Two helpers make that look intentional rather than like an empty dark hero: + * - isFillablePlaceholderBg(): recognises such a URL so the EDITOR can draw an "Add a photo here" hint + * (React render only; never in toHtml, so published HTML and head parity are untouched); + * - placeholderBoxHex(): picks a box colour that keeps the text on top readable (>= 4.5:1). + */ +export const LABEL_FREE_TEXT = '?text=%E2%80%8B'; + +export function isFillablePlaceholderBg(url: unknown): boolean { + if (typeof url !== 'string') return false; + try { + const u = new URL(url); + return u.hostname.toLowerCase() === 'placehold.co' && u.search === LABEL_FREE_TEXT; + } catch { return false; } +} + +function lin(c: number): number { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; } +function lum(rgb: [number, number, number]): number { return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); } +function ratio(a: [number, number, number], b: [number, number, number]): number { + const [x, y] = [lum(a), lum(b)]; + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} +function parseHex(h: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(h.trim()); + if (!m) return null; + const s = m[1].length === 3 ? m[1].split('').map((c) => c + c).join('') : m[1]; + return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)]; +} +const toHex = (rgb: number[]) => rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join(''); + +export function contrastRatioHex(a: string, b: string): number | null { + const x = parseHex(a); const y = parseHex(b); + return x && y ? ratio(x, y) : null; +} + +/** + * Box colour (6 hex digits, no '#') readable under `textHex` (default white). Light text -> darken the box + * toward black, dark text -> lighten toward white, in 5% steps, until contrast >= 4.5. An unparsable box + * colour (a placehold.co colour name) falls back to a deep slate / soft grey. + */ +export function placeholderBoxHex(boxHex: string, textHex?: string | null): string { + const text = (textHex && parseHex(textHex)) || [255, 255, 255] as [number, number, number]; + const textIsLight = lum(text) > 0.4; + let box = parseHex(boxHex) ?? (textIsLight ? [30, 41, 59] : [226, 232, 240]) as [number, number, number]; + const target: [number, number, number] = textIsLight ? [0, 0, 0] : [255, 255, 255]; + for (let i = 0; i < 20 && ratio(box, text) < 4.5; i++) { + box = [box[0] + (target[0] - box[0]) * 0.1, box[1] + (target[1] - box[1]) * 0.1, box[2] + (target[2] - box[2]) * 0.1] as [number, number, number]; + } + return toHex(box); +}