sitesmith: label-free background placeholders get a contrast-safe box colour and an editor-only 'Add a photo here' hint

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185ukXgk6UunFCKyXqLe2Cq
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-02 07:48:36 -08:00
1 parent 6604ff4674
commit 93f0a72f2a
8 files changed
+178 -3

No files matched your search

@@ -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<BackgroundSectionProps> = ({
pointerEvents: 'none',
}}
/>
<PlaceholderHint url={bgImage} />
{/* Content */}
<Element
id="bg-section-inner"
@@ -0,0 +1,24 @@
import React from 'react';
import { isFillablePlaceholderBg } from '../../sitesmith/placeholder-bg';
/**
* EDITOR-ONLY hint for a Sitesmith label-free background placeholder. Rendered by the React components only;
* never by toHtml, so it cannot reach published HTML (and head parity is unaffected). Not interactive
* (pointer-events none) and marked data-editor-only so capture/sanitise code can recognise it.
*/
export function PlaceholderHint({ url }: { url: unknown }): React.ReactElement | null {
if (!isFillablePlaceholderBg(url)) return null;
return (
<div
data-editor-only="placeholder-hint"
style={{
position: 'absolute', top: 10, right: 10, zIndex: 3, pointerEvents: 'none',
padding: '4px 10px', borderRadius: 999, fontSize: 12, lineHeight: '16px', fontWeight: 600,
fontFamily: 'system-ui, sans-serif', color: '#fff', background: 'rgba(17,24,39,0.72)',
border: '1px dashed rgba(255,255,255,0.7)',
}}
>
Add a photo here
</div>
);
}
@@ -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<CallToActionProps> = ({
/>
)}
{bgType === 'image' && <PlaceholderHint url={bgValue} />}
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
{heading}
@@ -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<HeroProps> = ({
}} />
)}
{bgType === 'image' && <PlaceholderHint url={bgImage} />}
{/* Content */}
<div style={{
maxWidth: '800px',
@@ -199,8 +199,8 @@ describe('placehold.co backgrounds are label-free', () => {
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');
});
+22 -1
View File
@@ -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 <rect> 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<string, unknown>): Record<string, unknown> {
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;
}
@@ -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(<HeroSimple bgType="image" bgImage={PH} />)).toContain('Add a photo here');
expect(html(<BackgroundSection bgImage={PH} />)).toContain('Add a photo here');
expect(html(<CallToAction bgType="image" bgValue={PH} />)).toContain('Add a photo here');
expect(html(<HeroSimple bgType="image" bgImage="assets/hero.jpg" />)).not.toContain('Add a photo here');
expect(html(<HeroSimple bgType="gradient" bgImage={PH} />)).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'); }
});
});
+51
View File
@@ -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);
}