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:
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');
|
||||
});
|
||||
|
||||
@@ -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'); }
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user