sitesmith: readable form inputs on dark sections + sRGB color-mix() in contrast repair

Eval 2026-11-01 (ttm-likes): a ContactForm on a dark band had dark typed text
and UA-grey placeholders on a color-mix() input surface, and its inputBorder
"1px solid X" came out as the invalid "1px solid 1px solid X".

- ContactForm: new inputTextColor prop (typed text); placeholders now follow
  the input's own text colour via a scoped form[data-wsb-form] ::placeholder
  rule (export + editor canvas); an inputBorder that is a whole border is used
  as-is. Without inputTextColor the legacy derivation is unchanged.
- contrast repair: resolves opaque color-mix(in srgb, A p%, B) surfaces (the
  brief prescribes these tints; translucent / other spaces stay unchecked),
  checks ContactForm labelColor against its section and the derived input text
  colour against an explicitly set inputBg, writing inputTextColor on failure.
- catalog generator: (rebased onto main 01a78c9, which already omits the
  clock-derived Countdown.targetDate default via VOLATILE_DEFAULTS + the
  date-frozen drift test; this commit's duplicate inline check was dropped.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 16:43:01 -08:00
1 parent 0c9faf0495
commit ed50732d58
5 files changed
+137 -11

No files matched your search

@@ -311,3 +311,27 @@ describe('ContactForm.toHtml design tokens (#44)', () => {
for (const hex of ["#ef4444", "#374151", "#d1d5db", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
describe('ContactForm.toHtml dark-aware inputs (eval 2026-11-01)', () => {
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
test('inputTextColor sets the typed text colour; placeholders follow it (currentColor) via a scoped rule', () => {
const { html } = toHtml({ fields, inputBg: '#1b1530', inputTextColor: '#f5f3ff' }, '', 'n1');
expect(html).toMatch(/<input[^>]*style="[^"]*color:#f5f3ff/);
expect(html).toMatch(/<form[^>]*data-wsb-form/);
expect(html).toContain('<style>form[data-wsb-form] ::placeholder{color:currentColor;opacity:.7}</style>');
});
test('without inputTextColor the old derivation stays (light stored surface -> legacy dark text)', () => {
expect(toHtml({ fields, inputBg: '#ffffff' }, '').html).toMatch(/<input[^>]*color:#1f2937/);
expect(toHtml({ fields }, '').html).toMatch(/<input[^>]*color:var\(--wsb-text\)/);
});
test('an inputBorder written as a whole border ("1px solid X") is used as-is, not doubled into "1px solid 1px solid X"', () => {
const { html } = toHtml({ fields, inputBorder: '1px solid color-mix(in srgb, var(--wsb-secondary) 45%, transparent)' }, '');
expect(html).not.toContain('1px solid 1px solid');
expect(html).toContain('border:1px solid color-mix(in srgb, var(--wsb-secondary) 45%, transparent)');
expect(toHtml({ fields, inputBorder: '#334155' }, '').html).toContain('border:1px solid #334155');
});
test('a hostile inputTextColor cannot break out of the style attribute', () => {
const { html } = toHtml({ fields, inputTextColor: 'red;}"><script>alert(1)</script>' }, '');
expect(html).not.toContain('<script>');
});
});
+18 -5
View File
@@ -15,6 +15,14 @@ import { textOnSurface } from '../../utils/color';
ContactForm was introduced, so legacy nodes carry their own '#374151'.) */
const LEGACY_INPUT_TEXT = '#1f2937';
/** Placeholders follow the input's own text colour, so they stay readable on dark inputs too (the UA grey did
* not). Scoped to these forms; emitted once per form in export, once globally in the editor canvas. */
export const FORM_PLACEHOLDER_CSS = 'form[data-wsb-form] ::placeholder{color:currentColor;opacity:.7}';
/** `inputBorder` is a colour; a model sometimes writes a whole border ("1px solid X"): use that as-is. */
export const inputBorderCss = (v: string): string =>
/\b(?:solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)\b/i.test(v) ? v : `1px solid ${v}`;
// The allowlist enforced at export time lives in `sanitizeInputType`
// (utils/escape.ts) -- this union is a superset (it also covers 'textarea'
// and 'select', which take their own render branches instead of an
@@ -42,6 +50,8 @@ interface ContactFormProps {
style?: CSSProperties;
labelColor?: string;
inputBg?: string;
/** Typed text (and, via currentColor, placeholder) colour in the inputs. Absent = derived from inputBg (legacy rule). */
inputTextColor?: string;
inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
@@ -82,6 +92,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
style = {},
labelColor = tokenRef('text'),
inputBg = tokenRef('bg'),
inputTextColor,
inputBorder = tokenRef('border'),
}) => {
const {
@@ -96,10 +107,10 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
padding: '10px 14px',
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
border: `1px solid ${inputBorder}`,
border: inputBorderCss(inputBorder),
borderRadius: '6px',
backgroundColor: inputBg,
color: textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text')),
color: inputTextColor || textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text')),
boxSizing: 'border-box',
outline: 'none',
};
@@ -109,6 +120,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={formAction}
method="POST"
data-wsb-form=""
onSubmit={(e) => e.preventDefault()}
style={{
padding: '32px',
@@ -231,7 +243,8 @@ ContactForm.craft = {
const labelColor = cssValue(props.labelColor) || tokenRef('text');
const inputBg = cssValue(props.inputBg) || tokenRef('bg');
const inputBorder = cssValue(props.inputBorder) || tokenRef('border');
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:${textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text'))};box-sizing:border-box;outline:none`;
const inputText = cssValue(props.inputTextColor) || textOnSurface(inputBg, LEGACY_INPUT_TEXT, tokenRef('text'));
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:${inputBorderCss(inputBorder)};border-radius:6px;background-color:${inputBg};color:${inputText};box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
const reqStar = field.required ? `<span style="color:${tokenRef('error')};margin-left:2px">*</span>` : '';
@@ -285,8 +298,8 @@ ContactForm.craft = {
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
return {
html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
html: `${marker}<form action="${actionAttr}" method="POST" data-wsb-form${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
</form>`,
</form><style>${FORM_PLACEHOLDER_CSS}</style>`,
};
};
+3
View File
@@ -3431,3 +3431,6 @@ body {
.color-tool-btn:hover:not(:disabled) { color: var(--color-text); border-color: var(--color-text-muted); }
.color-tool-btn:disabled { opacity: 0.5; cursor: default; }
.color-hex-error { font-size: 11px; color: #ef4444; margin-top: 3px; }
/* ContactForm placeholders follow the input text colour (mirrors FORM_PLACEHOLDER_CSS in ContactForm.tsx export). */
form[data-wsb-form] ::placeholder { color: currentColor; opacity: .7; }
+48 -3
View File
@@ -204,7 +204,8 @@ describe('background resolution (Sitesmith eval bug C)', () => {
test('unparseable gradient stops (color-mix, unresolved token, alpha) -> not checked, never repaired', () => {
for (const g of [
'linear-gradient(135deg, color-mix(in srgb, var(--wsb-secondary) 45%, var(--wsb-text)), var(--wsb-text))',
'linear-gradient(135deg, color-mix(in srgb, var(--wsb-secondary) 45%, transparent), var(--wsb-text))',
'linear-gradient(135deg, color-mix(in oklch, var(--wsb-secondary) 45%, var(--wsb-text)), var(--wsb-text))',
'linear-gradient(#000000, var(--wsb-brand-9))',
'linear-gradient(rgba(0,0,0,0.2), #ffffff)',
'linear-gradient(#000000, #ffffff), url(a.jpg)',
@@ -217,7 +218,7 @@ describe('background resolution (Sitesmith eval bug C)', () => {
});
test('an unresolvable background colour is "not checked", not silently the page background', () => {
for (const bg of ['color-mix(in srgb, #ffffff 8%, var(--wsb-text))', 'var(--wsb-brand-3)', 'rgba(0,0,0,0.5)']) {
for (const bg of ['color-mix(in srgb, #ffffff 8%, transparent)', 'color-mix(in oklch, #ffffff 8%, var(--wsb-text))', 'var(--wsb-brand-3)', 'rgba(0,0,0,0.5)']) {
const out = repairResponseContrast(on({ style: { backgroundColor: bg } }, '#ffffff'), D);
expect(out.adjustments, bg).toEqual([]);
expect(out.notChecked, bg).toBe(1);
@@ -328,7 +329,7 @@ describe('Sitesmith eval fixtures (real model responses)', () => {
for (const [name, d] of Object.entries(designs)) {
const out = repairResponseContrast(ttmLikes as unknown as SitesmithResponse, d);
expect(out.adjustments, name).toEqual([]);
expect(out.notChecked, name).toBeGreaterThan(0);
// (its color-mix() tints were "not checked" before sRGB color-mix() was resolved; white text passes on them)
expect(JSON.stringify(out.response), name).toBe(JSON.stringify(ttmLikes));
}
});
@@ -351,3 +352,47 @@ describe('Sitesmith eval fixtures (real model responses)', () => {
expect(repairResponseContrast(olsHome as unknown as SitesmithResponse, designs['ols design injected']).adjustments).toEqual([]);
});
});
describe('color-mix() surfaces and ContactForm inputs (eval 2026-11-01: placeholders/typed text on dark inputs)', () => {
test('an opaque sRGB color-mix() background is resolved and checked (the brief prescribes these tints)', () => {
const resp = replace(n('Container', { style: { backgroundColor: 'color-mix(in srgb, var(--wsb-primary) 16%, #0d0b14)' } }, [
n('TextBlock', { text: 'Hello', style: { color: '#334155' } }),
]));
const out = repairResponseContrast(resp, D);
expect(out.notChecked).toBe(0);
expect(out.adjustments).toHaveLength(1);
const to = (firstTree(out.response).nodes![0].props.style as any).color;
expect(ratio(to, '#0d0b14')).toBeGreaterThan(4.5);
});
test('a translucent color-mix() (with transparent) stays not checked', () => {
const resp = replace(n('Container', { style: { backgroundColor: 'color-mix(in srgb, var(--wsb-primary) 12%, transparent)' } }, [
n('TextBlock', { text: 'Hello', style: { color: '#334155' } }),
]));
const out = repairResponseContrast(resp, D);
expect(out.adjustments).toEqual([]);
expect(out.notChecked).toBe(1);
});
test('ContactForm on a dark input surface: typed/placeholder text colour is set so it reads (light-token site)', () => {
const resp = replace(n('Section', { style: { backgroundColor: '#0d0b14' } }, [
n('ContactForm', { inputBg: 'color-mix(in srgb, var(--wsb-secondary) 16%, #0d0b14)', labelColor: '#ffffff', fields: [] }),
]));
const out = repairResponseContrast(resp, D);
const f = firstTree(out.response).nodes![0].props as any;
expect(typeof f.inputTextColor).toBe('string');
expect(ratio(f.inputTextColor, '#1b1530')).toBeGreaterThan(4.5);
expect(out.adjustments.some((a) => a.node === 'ContactForm')).toBe(true);
});
test('ContactForm label colour is checked against the section it sits on', () => {
const resp = replace(n('Section', { style: { backgroundColor: '#0d0b14' } }, [
n('ContactForm', { labelColor: 'var(--wsb-text)', fields: [] }),
]));
const out = repairResponseContrast(resp, D);
const f = firstTree(out.response).nodes![0].props as any;
expect(f.labelColor).not.toBe('var(--wsb-text)');
});
test('ContactForm with the default (token) input surface is left alone', () => {
const resp = replace(n('Section', { style: { backgroundColor: '#0d0b14' } }, [n('ContactForm', { labelColor: '#ffffff', fields: [] })]));
const out = repairResponseContrast(resp, D);
expect(firstTree(out.response).nodes![0].props.inputTextColor).toBeUndefined();
});
});
+44 -3
View File
@@ -23,7 +23,7 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import type { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
import { contrastRatio } from './publish-checks';
import { parseColor, type RGB } from './color';
import { parseColor, textOnSurface, type RGB } from './color';
import { resolveTokenValue, tokenRef, type TokenKey } from './tokens';
export interface ContrastAdjustment {
@@ -72,6 +72,22 @@ const NAMED_PAIRS: Record<string, NamedPair[]> = {
{ fg: 'textColor', bg: 'backgroundColor', label: 'nav links' },
{ fg: 'ctaTextColor', bg: 'ctaColor', label: 'nav button' },
],
// Labels sit on the form/section; typed text AND placeholders (currentColor, ContactForm.tsx) on inputBg.
ContactForm: [
{ fg: 'labelColor', label: 'form labels' },
{ fg: 'inputTextColor', bg: 'inputBg', label: 'form fields' },
],
};
/**
* A colour the component derives when the prop is absent, checked as if it were set (a repair then writes
* the prop). ContactForm: typed text on an explicitly set input surface (see ContactForm.tsx); with no
* inputBg the input is the designed token pair (bg/text) and is not checked.
*/
const DERIVED_FG: Record<string, Record<string, (p: Record<string, any>) => string | undefined>> = {
ContactForm: {
inputTextColor: (p) => (typeof p.inputBg === 'string' && p.inputBg.trim() ? textOnSurface(p.inputBg, '#1f2937', tokenRef('text')) : undefined),
},
};
const OVERLAY_SCALE: Record<string, number> = { HeroSimple: 100, CallToAction: 100, BackgroundSection: 1 };
@@ -158,11 +174,35 @@ function alphaOf(v: string): number {
/** A single literal colour or token -> opaque RGB, else null (translucent colours are NOT opaque). */
function opaqueRgb(v: string, design: SiteDesign): RGB | null {
const mix = /^color-mix\(\s*in\s+srgb\s*,(.*)\)$/i.exec(v.trim());
if (mix) return colorMixSrgb(mix[1], design);
const resolved = resolveTokenValue(v.trim(), design);
if (alphaOf(resolved) < 1) return null;
return parseColor(resolved);
}
/**
* `color-mix(in srgb, A p%, B q%)` of two OPAQUE colours whose weights add up to 100% (an omitted weight
* is the rest; both omitted = 50/50) -> the mixed colour. Anything translucent (transparent, alpha, weights
* under 100%) or another colour space -> null (not checked).
*/
function colorMixSrgb(args: string, design: SiteDesign): RGB | null {
const parts = splitTopLevel(args);
if (parts.length !== 2) return null;
const parsed = parts.map((part) => {
const m = /^(.*?)(?:\s+([\d.]+)%)?$/.exec(part.trim());
return m ? { colour: m[1].trim(), pct: m[2] !== undefined ? parseFloat(m[2]) : null } : null;
});
if (parsed.some((x) => !x)) return null;
const [a, b] = parsed as Array<{ colour: string; pct: number | null }>;
let pa = a.pct; let pb = b.pct;
if (pa === null && pb === null) { pa = 50; pb = 50; } else if (pa === null) pa = 100 - pb!; else if (pb === null) pb = 100 - pa;
if (!(pa! >= 0 && pb! >= 0) || Math.abs(pa! + pb! - 100) > 0.01) return null;
const ca = opaqueRgb(a.colour, design); const cb = opaqueRgb(b.colour, design);
if (!ca || !cb) return null;
return [0, 1, 2].map((i) => Math.round((ca[i] * pa! + cb[i] * pb!) / 100)) as unknown as RGB;
}
function splitTopLevel(s: string): string[] {
const out: string[] = [];
let depth = 0; let cur = '';
@@ -334,7 +374,8 @@ class Repairer {
node: SerializedTreeNode, name: string, chain: SerializedTreeNode[], props: Record<string, any>,
fgKey: string, bgKey: string | undefined, style: Record<string, any> | undefined,
): void {
const raw = fgKey === 'style.color' ? props.style?.color : props[fgKey];
const own = fgKey === 'style.color' ? props.style?.color : props[fgKey];
const raw = own ?? DERIVED_FG[name]?.[fgKey]?.(props);
const fg = toRgb(raw, this.design);
if (!fg) return;
const bg = this.bgFor(node, chain, props, bgKey);
@@ -354,7 +395,7 @@ class Repairer {
if (!pick || after < need || !(after > before)) { this.notChecked++; return; }
const target = fgKey === 'style.color' ? props.style : props;
const key = fgKey === 'style.color' ? 'color' : fgKey;
const from = String(target[key]);
const from = String(target[key] ?? raw);
target[key] = pick.value;
this.adjustments.push({
kind: 'text-colour', node: name,