feat(site-builder): tokenise colours hard-coded inside components

Component render/toHtml now use var(--wsb-*) instead of hex so Site Design
palette changes reach FeaturesGrid, Testimonials, Accordion, PricingTable,
CTA/CallToAction, forms, Gallery, Tabs and others. Exemption lists removed.

Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 12:44:39 -07:00
1 parent 8227d768e6
commit 2877bc06fa
47 files changed
+473 -195

No files matched your search

@@ -126,3 +126,12 @@ describe('ButtonLink.toHtml palette token default (Refs #44)', () => {
expect(html).not.toContain('#3b82f6');
});
});
describe('ButtonLink.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(ButtonLink as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "primary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+1 -1
View File
@@ -77,7 +77,7 @@ ButtonLink.craft = {
hoverColor: '',
style: {
backgroundColor: tokenRef('primary'),
color: '#ffffff',
color: tokenRef('bg'),
padding: '12px 24px',
borderRadius: '8px',
fontWeight: '600',
@@ -32,3 +32,12 @@ describe('Divider.toHtml XSS hardening (thickness/color into style=)', () => {
expect(html).not.toMatch(/"\s+onmouseover="/);
});
});
describe('Divider.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({}, '');
const lower = html.toLowerCase();
for (const t of ["border"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#e4e4e7"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+4 -3
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
@@ -9,7 +10,7 @@ interface DividerProps {
}
export const Divider: UserComponent<DividerProps> = ({
color = '#e4e4e7',
color = tokenRef('border'),
thickness = '1px',
style = {},
}) => {
@@ -39,7 +40,7 @@ export const Divider: UserComponent<DividerProps> = ({
Divider.craft = {
displayName: 'Divider',
props: {
color: '#e4e4e7',
color: tokenRef('border'),
thickness: '1px',
style: {},
},
@@ -55,7 +56,7 @@ Divider.craft = {
(Divider as any).toHtml = (props: DividerProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
border: 'none',
borderTop: `${props.thickness || '1px'} solid ${props.color || '#e4e4e7'}`,
borderTop: `${props.thickness || '1px'} solid ${props.color || tokenRef('border')}`,
margin: '16px 0',
...props.style,
});
@@ -83,3 +83,12 @@ describe('Logo.toHtml text-logo styling sanitization', () => {
expect(html).toContain('MySite');
});
});
describe('Logo.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ text: "Acme" }, '');
const lower = html.toLowerCase();
for (const t of ["text"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#1f2937"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
@@ -125,7 +126,7 @@ Logo.craft = {
fontWeight: props.fontWeight || '700',
fontSize: props.fontSize || '20px',
fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937',
color: props.color || tokenRef('text'),
});
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${escapeHtml(props.text || 'MySite')}</span>`;
}
@@ -83,3 +83,12 @@ describe('SearchBar.toHtml palette token fallbacks (Refs #44)', () => {
expect(html).not.toContain('#1f2937');
});
});
describe('SearchBar.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({}, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "muted", "primary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#d1d5db", "#9ca3af", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+7 -7
View File
@@ -57,7 +57,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
left: '14px',
top: '50%',
transform: 'translateY(-50%)',
color: '#9ca3af',
color: tokenRef('muted'),
fontSize: '14px',
pointerEvents: 'none',
}}
@@ -71,9 +71,9 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
padding: '12px 16px 12px 40px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
border: `1px solid ${tokenRef('border')}`,
borderRadius: showButton ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
color: tokenRef('text'),
outline: 'none',
boxSizing: 'border-box',
@@ -88,7 +88,7 @@ export const SearchBar: UserComponent<SearchBarProps> = ({
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
color: tokenRef('bg'),
backgroundColor: tokenRef('primary'),
border: 'none',
borderRadius: '0 8px 8px 0',
@@ -148,10 +148,10 @@ SearchBar.craft = {
...style,
});
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:${tokenRef('text')};outline:none;box-sizing:border-box`;
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid ${tokenRef('border')};border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:${tokenRef('bg')};color:${tokenRef('text')};outline:none;box-sizing:border-box`;
const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:${tokenRef('bg')};background-color:${tokenRef('primary')};border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: '';
// F2: previously a purely decorative <form> -- no action/method/input
@@ -165,7 +165,7 @@ SearchBar.craft = {
return {
html: `<form role="search" action="${actionAttr}" method="GET"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:${tokenRef('muted')};font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" name="q" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
@@ -86,3 +86,12 @@ describe('StarRating.craft.props includes the box-model/animation/visibility rol
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('StarRating.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({}, '');
const lower = html.toLowerCase();
for (const t of ["warning", "border"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#f59e0b", "#d1d5db"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+7 -6
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { cssValue, escapeAttr } from '../../utils/escape';
@@ -21,8 +22,8 @@ export const StarRating: UserComponent<StarRatingProps> = ({
rating = 4.5,
maxStars = 5,
size = '24px',
filledColor = '#f59e0b',
emptyColor = '#d1d5db',
filledColor = tokenRef('warning'),
emptyColor = tokenRef('border'),
style = {},
}) => {
const {
@@ -89,8 +90,8 @@ StarRating.craft = {
rating: 4.5,
maxStars: 5,
size: '24px',
filledColor: '#f59e0b',
emptyColor: '#d1d5db',
filledColor: tokenRef('warning'),
emptyColor: tokenRef('border'),
style: {},
animation: '',
animationDelay: '',
@@ -123,8 +124,8 @@ StarRating.craft = {
: 5;
// Sanitized -- raw string-interpolation sinks in the star glyphs below.
const size = cssValue(props.size) || '24px';
const filledColor = cssValue(props.filledColor) || '#f59e0b';
const emptyColor = cssValue(props.emptyColor) || '#d1d5db';
const filledColor = cssValue(props.filledColor) || tokenRef('warning');
const emptyColor = cssValue(props.emptyColor) || tokenRef('border');
const wrapperStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
@@ -108,3 +108,12 @@ describe('TextBlock.toHtml inline rich text (#46)', () => {
expect((TextBlock as any).craft.props.html).toBe('');
});
});
describe('TextBlock.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(TextBlock as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["text"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#3f3f46"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
@@ -65,7 +66,7 @@ TextBlock.craft = {
style: {
fontSize: '16px',
lineHeight: '1.6',
color: '#3f3f46',
color: tokenRef('text'),
letterSpacing: '',
textTransform: '' as CSSProperties['textTransform'],
fontStyle: '' as CSSProperties['fontStyle'],
@@ -300,3 +300,12 @@ describe('ContactForm.toHtml palette token fallbacks (Refs #44)', () => {
expect(html).toContain('background-color:#ff0000');
});
});
describe('ContactForm.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ fields: [{ label: 'N', name: 'n', type: 'text', required: true }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "error"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#ef4444", "#374151", "#d1d5db", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+15 -15
View File
@@ -66,9 +66,9 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
formAction = '#',
successMessage = 'Thank you! We\'ll get back to you soon.',
style = {},
labelColor = '#374151',
inputBg = '#ffffff',
inputBorder = '#d1d5db',
labelColor = tokenRef('text'),
inputBg = tokenRef('bg'),
inputBorder = tokenRef('border'),
}) => {
const {
connectors: { connect, drag },
@@ -109,7 +109,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
{field.label}
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
{field.required && <span style={{ color: tokenRef('error'), marginLeft: '2px' }}>*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
@@ -148,7 +148,7 @@ export const ContactForm: UserComponent<ContactFormProps> = ({
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
color: tokenRef('bg'),
backgroundColor: submitColor,
border: 'none',
borderRadius: '8px',
@@ -173,13 +173,13 @@ ContactForm.craft = {
formAction: '#',
successMessage: 'Thank you! We\'ll get back to you soon.',
style: {
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
borderRadius: '12px',
border: '1px solid #e5e7eb',
border: `1px solid ${tokenRef('border')}`,
},
labelColor: '#374151',
inputBg: '#ffffff',
inputBorder: '#d1d5db',
labelColor: tokenRef('text'),
inputBg: tokenRef('bg'),
inputBorder: tokenRef('border'),
recipientEmail: '',
thankYouUrl: '',
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
@@ -213,13 +213,13 @@ ContactForm.craft = {
});
// Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
// below.
const labelColor = cssValue(props.labelColor) || '#374151';
const inputBg = cssValue(props.inputBg) || '#ffffff';
const inputBorder = cssValue(props.inputBorder) || '#d1d5db';
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:${tokenRef('text')};box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
const reqStar = field.required ? `<span style="color:${tokenRef('error')};margin-left:2px">*</span>` : '';
// Deterministic id: index + slugified name, so repeated fields with the
// same name (or no name) still get unique, stable ids -- no Math.random.
const fieldId = `field-${i}-${slugId(field.name)}`;
@@ -242,7 +242,7 @@ ContactForm.craft = {
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
color: tokenRef('bg'),
backgroundColor: cssValue(props.submitColor) || tokenRef('primary'),
border: 'none',
borderRadius: '8px',
@@ -46,3 +46,12 @@ describe('FormButton.toHtml palette token default (Refs #44)', () => {
expect(html).not.toContain('#3b82f6');
});
});
describe('FormButton.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(FormButton as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "primary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+2 -2
View File
@@ -33,7 +33,7 @@ export const FormButton: UserComponent<FormButtonProps> = ({
style={{
padding: '12px 32px',
backgroundColor: tokenRef('primary'),
color: '#ffffff',
color: tokenRef('bg'),
border: 'none',
borderRadius: '6px',
fontSize: '16px',
@@ -57,7 +57,7 @@ FormButton.craft = {
text: 'Submit',
style: {
backgroundColor: tokenRef('primary'),
color: '#ffffff',
color: tokenRef('bg'),
padding: '12px 32px',
borderRadius: '6px',
fontWeight: '600',
@@ -69,3 +69,12 @@ describe('FormContainer.craft.props includes animation/visibility defaults', ()
});
});
});
describe('FormContainer.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(FormContainer as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#ffffff", "#e4e4e7"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
@@ -60,9 +61,9 @@ FormContainer.craft = {
thankYouUrl: '',
style: {
padding: '24px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
borderRadius: '8px',
border: '1px solid #e4e4e7',
border: `1px solid ${tokenRef('border')}`,
},
animation: '',
animationDelay: '',
@@ -89,3 +89,12 @@ describe('InputField.craft.props includes animation/visibility defaults', () =>
});
});
});
describe('InputField.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ label: 'L', required: true }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "error"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#18181b", "#ef4444", "#d4d4d8", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+8 -7
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
@@ -45,8 +46,8 @@ export const InputField: UserComponent<InputFieldProps> = ({
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
<label style={{ fontSize: '14px', fontWeight: '500', color: tokenRef('text') }}>
{label}{required && <span style={{ color: tokenRef('error') }}> *</span>}
</label>
)}
<input
@@ -56,11 +57,11 @@ export const InputField: UserComponent<InputFieldProps> = ({
required={required}
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
border: `1px solid ${tokenRef('border')}`,
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
color: tokenRef('text'),
backgroundColor: tokenRef('bg'),
outline: 'none',
width: '100%',
boxSizing: 'border-box',
@@ -113,7 +114,7 @@ InputField.craft = {
// hash for legacy 2-arg call sites without a node id.
const fieldId = scopeId(nodeId, props.name || 'field', 'field');
const labelHtml = props.label
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${tokenRef('text')}">${escapeHtml(props.label)}${props.required ? `<span style="color:${tokenRef('error')}"> *</span>` : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
@@ -121,7 +122,7 @@ InputField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid ${tokenRef('border')};border-radius:6px;font-size:14px;color:${tokenRef('text')};background-color:${tokenRef('bg')};width:100%;box-sizing:border-box" />
</div>`,
};
};
@@ -93,3 +93,12 @@ describe('SubscribeForm.toHtml palette token fallbacks (Refs #44)', () => {
expect(html).toContain('background-color:#ff0000');
});
});
describe('SubscribeForm.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({}, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#d1d5db", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+6 -6
View File
@@ -82,9 +82,9 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
padding: '12px 16px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
border: `1px solid ${tokenRef('border')}`,
borderRadius: isInline ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
color: tokenRef('text'),
outline: 'none',
boxSizing: 'border-box',
@@ -97,7 +97,7 @@ export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
color: tokenRef('bg'),
backgroundColor: buttonColor,
border: 'none',
borderRadius: isInline ? '0 8px 8px 0' : '8px',
@@ -122,7 +122,7 @@ SubscribeForm.craft = {
buttonText: 'Subscribe',
buttonColor: tokenRef('primary'),
layout: 'inline',
style: { backgroundColor: '#f8fafc' },
style: { backgroundColor: tokenRef('bg') },
recipientEmail: '',
thankYouUrl: '',
animation: '',
@@ -171,14 +171,14 @@ SubscribeForm.craft = {
alignItems: 'stretch',
});
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:${tokenRef('text')};outline:none;box-sizing:border-box`;
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid ${tokenRef('border')};border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:${tokenRef('bg')};color:${tokenRef('text')};outline:none;box-sizing:border-box`;
const btnStyle = cssPropsToString({
padding: '12px 24px',
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
color: tokenRef('bg'),
backgroundColor: buttonColor,
border: 'none',
borderRadius: isInline ? '0 8px 8px 0' : '8px',
@@ -80,3 +80,12 @@ describe('TextareaField.craft.props includes animation/visibility defaults', ()
});
});
});
describe('TextareaField.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ label: 'L', required: true }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "error"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#18181b", "#ef4444", "#d4d4d8", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+8 -7
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
@@ -45,8 +46,8 @@ export const TextareaField: UserComponent<TextareaFieldProps> = ({
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
<label style={{ fontSize: '14px', fontWeight: '500', color: tokenRef('text') }}>
{label}{required && <span style={{ color: tokenRef('error') }}> *</span>}
</label>
)}
<textarea
@@ -57,11 +58,11 @@ export const TextareaField: UserComponent<TextareaFieldProps> = ({
readOnly
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
border: `1px solid ${tokenRef('border')}`,
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
color: tokenRef('text'),
backgroundColor: tokenRef('bg'),
outline: 'none',
width: '100%',
boxSizing: 'border-box',
@@ -120,7 +121,7 @@ TextareaField.craft = {
// 2-arg call sites without a node id.
const fieldId = scopeId(nodeId, props.name || 'message', 'field');
const labelHtml = props.label
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${tokenRef('text')}">${escapeHtml(props.label)}${props.required ? `<span style="color:${tokenRef('error')}"> *</span>` : ''}</label>`
: '';
const ariaLabelAttr = !props.label
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
@@ -128,7 +129,7 @@ TextareaField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid ${tokenRef('border')};border-radius:6px;font-size:14px;color:${tokenRef('text')};background-color:${tokenRef('bg')};width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`,
};
};
@@ -36,3 +36,12 @@ describe('Accordion.craft.props includes the box-model/animation/visibility roll
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('Accordion.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ items: [{ title: 'A', content: 'B' }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "muted"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#f8fafc", "#e2e8f0", "#18181b", "#4b5563"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+17 -16
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties, useState } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
@@ -33,10 +34,10 @@ const defaultItems: AccordionItem[] = [
export const Accordion: UserComponent<AccordionProps> = ({
items = defaultItems,
style = {},
headerBg = '#f8fafc',
headerColor = '#18181b',
contentBg = '#ffffff',
borderColor = '#e2e8f0',
headerBg = tokenRef('bg'),
headerColor = tokenRef('text'),
contentBg = tokenRef('bg'),
borderColor = tokenRef('border'),
anchorId,
}) => {
const {
@@ -67,7 +68,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
id={anchorId || undefined}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -110,7 +111,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
style={{
padding: '16px 20px',
backgroundColor: contentBg,
color: '#4b5563',
color: tokenRef('muted'),
fontSize: '14px',
lineHeight: '1.6',
borderTop: `1px solid ${borderColor}`,
@@ -134,11 +135,11 @@ Accordion.craft = {
displayName: 'Accordion',
props: {
items: defaultItems,
style: { backgroundColor: '#ffffff' },
headerBg: '#f8fafc',
headerColor: '#18181b',
contentBg: '#ffffff',
borderColor: '#e2e8f0',
style: { backgroundColor: tokenRef('bg') },
headerBg: tokenRef('bg'),
headerColor: tokenRef('text'),
contentBg: tokenRef('bg'),
borderColor: tokenRef('border'),
anchorId: '',
animation: '',
animationDelay: '',
@@ -163,10 +164,10 @@ Accordion.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
// Sanitized -- raw string-interpolation sinks in the <details>/<summary>
// style attributes below.
const headerBg = cssValue(props.headerBg) || '#f8fafc';
const headerColor = cssValue(props.headerColor) || '#18181b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
const headerBg = cssValue(props.headerBg) || tokenRef('bg');
const headerColor = cssValue(props.headerColor) || tokenRef('text');
const contentBg = cssValue(props.contentBg) || tokenRef('bg');
const borderColor = cssValue(props.borderColor) || tokenRef('border');
const items = props.items || defaultItems;
const panels = items.map((item, i) => {
@@ -178,7 +179,7 @@ Accordion.craft = {
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
${escapeHtml(item.title)}
</summary>
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
<div style="padding:16px 20px;background-color:${contentBg};color:${tokenRef('muted')};font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
${escapeHtml(item.content)}
</div>
</details>`;
@@ -35,3 +35,12 @@ describe('CTASection.craft.props includes the box-model/animation/visibility rol
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('CTASection.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(CTASection as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "text", "primary", "secondary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#18181b", "#2563eb", "#7c3aed"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+9 -5
View File
@@ -3,6 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface CTASectionProps {
heading?: string;
@@ -21,7 +22,10 @@ interface CTASectionProps {
hideOnMobile?: boolean;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
const defaultGradient = `linear-gradient(135deg, ${tokenRef('primary')} 0%, ${tokenRef('secondary')} 100%)`;
// Fixed white: the default background is a primary->secondary gradient / image, so heading
// and outline-button text stay light regardless of the palette (no token means "on gradient").
const WHITE_ON_GRADIENT = '#ffffff';
export const CTASection: UserComponent<CTASectionProps> = ({
heading = 'Ready to Get Started?',
@@ -41,7 +45,7 @@ export const CTASection: UserComponent<CTASectionProps> = ({
}));
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
const ctaDefaults = { primaryBg: tokenRef('bg'), primaryText: tokenRef('text'), outlineText: WHITE_ON_GRADIENT };
return (
<section
@@ -56,7 +60,7 @@ export const CTASection: UserComponent<CTASectionProps> = ({
}}
>
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: WHITE_ON_GRADIENT, marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
@@ -111,12 +115,12 @@ CTASection.craft = {
...props.style,
});
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
const buttonsHtml = ctasToHtml(ctas, { primaryBg: tokenRef('bg'), primaryText: tokenRef('text'), outlineText: WHITE_ON_GRADIENT });
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
return {
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
<h2 style="font-size:36px;font-weight:700;color:${WHITE_ON_GRADIENT};margin-bottom:12px">${escapeHtml(props.heading || '')}</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${escapeHtml(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
</div>
@@ -35,3 +35,12 @@ describe('CallToAction.craft.props includes the box-model/animation/visibility r
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('CallToAction.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ ...(CallToAction as any).craft.props }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "text", "primary", "secondary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#18181b", "#2563eb", "#7c3aed"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+19 -11
View File
@@ -3,6 +3,7 @@ import { useNode, UserComponent } from '@craftjs/core';
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';
interface CallToActionProps {
heading?: string;
@@ -28,7 +29,16 @@ interface CallToActionProps {
hideOnMobile?: boolean;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
const defaultGradient = `linear-gradient(135deg, ${tokenRef('primary')} 0%, ${tokenRef('secondary')} 100%)`;
// Fixed white: default background is a gradient / image + dark overlay, so text stays light
// whatever the palette. Everything else (button, gradient) follows the design tokens.
const WHITE_ON_GRADIENT = '#ffffff';
/** A light button (token bg, or legacy white / slate-50) takes the text token; any other fill takes the bg token. */
function isLightButton(c: string): boolean {
const v = c.trim().toLowerCase();
return v === tokenRef('bg') || v === '#ffffff' || v === '#f8fafc';
}
export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?',
@@ -42,8 +52,8 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
bgValue = defaultGradient,
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonColor = '#ffffff',
textColor = WHITE_ON_GRADIENT,
buttonColor = tokenRef('bg'),
anchorId,
style = {},
}) => {
@@ -65,8 +75,7 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
bgStyle.backgroundPosition = 'center';
}
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const buttonTextColor = isLightButton(buttonColor) ? tokenRef('text') : tokenRef('bg');
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
@@ -133,8 +142,8 @@ CallToAction.craft = {
bgValue: defaultGradient,
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonColor: '#ffffff',
textColor: WHITE_ON_GRADIENT,
buttonColor: tokenRef('bg'),
style: {},
animation: '',
animationDelay: '',
@@ -156,10 +165,9 @@ CallToAction.craft = {
const bgValue = props.bgValue || defaultGradient;
// Sanitized -- raw string-interpolation sink in the heading/description
// style attributes below.
const textColor = cssValue(props.textColor) || '#ffffff';
const buttonColor = props.buttonColor || '#ffffff';
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const textColor = cssValue(props.textColor) || WHITE_ON_GRADIENT;
const buttonColor = props.buttonColor || tokenRef('bg');
const buttonTextColor = isLightButton(buttonColor) ? tokenRef('text') : tokenRef('bg');
const sectionCss: CSSProperties = {
position: 'relative',
@@ -41,3 +41,12 @@ describe('FeaturesGrid.toHtml palette token fallback (Refs #44)', () => {
expect(html).not.toContain('#3b82f6');
});
});
describe('FeaturesGrid.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({}, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "muted"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#f8fafc", "#e2e8f0", "#18181b", "#64748b", "#fff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+11 -11
View File
@@ -52,7 +52,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -65,8 +65,8 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
textAlign: 'center',
padding: '32px 24px',
borderRadius: '12px',
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
backgroundColor: tokenRef('bg'),
border: `1px solid ${tokenRef('border')}`,
}}
>
{feat.image ? (
@@ -78,13 +78,13 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
) : (
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
)}
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
<h3 style={{ fontSize: '20px', fontWeight: '600', color: tokenRef('text'), marginBottom: '8px' }}>{feat.title}</h3>
<p style={{ fontSize: '14px', color: tokenRef('muted'), lineHeight: '1.6' }}>{feat.description}</p>
{feat.buttonText ? (
<a
href={feat.buttonUrl || '#'}
onClick={(e) => e.preventDefault()}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: tokenRef('primary'), color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: tokenRef('primary'), color: tokenRef('bg'), borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
>
{feat.buttonText}
</a>
@@ -102,7 +102,7 @@ FeaturesGrid.craft = {
displayName: 'Features Grid',
props: {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
style: { backgroundColor: tokenRef('bg') },
anchorId: '',
animation: '',
animationDelay: '',
@@ -130,12 +130,12 @@ FeaturesGrid.craft = {
? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:${tokenRef('primary')};color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:${tokenRef('primary')};color:${tokenRef('bg')};border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>`
: '';
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:${tokenRef('bg')};border:1px solid ${tokenRef('border')}">
${media}
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${escapeHtml(feat.title)}</h3>
<p style="font-size:14px;color:#64748b;line-height:1.6">${escapeHtml(feat.description)}</p>${button}
<h3 style="font-size:20px;font-weight:600;color:${tokenRef('text')};margin-bottom:8px">${escapeHtml(feat.title)}</h3>
<p style="font-size:14px;color:${tokenRef('muted')};line-height:1.6">${escapeHtml(feat.description)}</p>${button}
</div>`;
}).join('\n ');
@@ -216,3 +216,12 @@ describe('Gallery.toHtml lightbox focus management (M-2)', () => {
expect(closeFnMatch![0]).toMatch(/\.focus\(\)/);
});
});
describe('Gallery.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ images: [{ src: 'a.png', alt: 'a', caption: 'c' }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#f1f5f9"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+5 -4
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
@@ -56,7 +57,7 @@ export const Gallery: UserComponent<GalleryProps> = ({
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -81,7 +82,7 @@ export const Gallery: UserComponent<GalleryProps> = ({
objectFit: 'cover',
display: 'block',
borderRadius: '8px',
backgroundColor: '#f1f5f9',
backgroundColor: tokenRef('bg'),
}}
/>
{img.caption && (
@@ -118,7 +119,7 @@ Gallery.craft = {
columns: 3,
gap: '16px',
style: {
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
@@ -174,7 +175,7 @@ Gallery.craft = {
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : '';
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
return `<div${lbAttr} style="${itemStyle}">
<img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
<img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:${tokenRef('bg')}" />
${caption}
</div>`;
}).join('\n ');
@@ -94,3 +94,12 @@ describe('NumberCounter.toHtml palette token fallbacks (Refs #44)', () => {
expect(html).not.toContain('#6b7280');
});
});
describe('NumberCounter.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ counters: [{ number: 1, suffix: '+', label: 'L' }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
@@ -53,7 +53,7 @@ export const NumberCounter: UserComponent<NumberCounterProps> = ({
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -104,7 +104,7 @@ NumberCounter.craft = {
numberColor: tokenRef('primary'),
labelColor: tokenRef('muted'),
numberSize: '48px',
style: { backgroundColor: '#ffffff' },
style: { backgroundColor: tokenRef('bg') },
animation: '',
animationDelay: '',
hideOnDesktop: false,
@@ -146,7 +146,7 @@ NumberCounter.craft = {
const sectionStyle = cssPropsToString({
padding: '60px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
...style,
});
@@ -40,13 +40,12 @@ describe('PricingTable.toHtml regular-card color overrides (previously hard-code
expect(html).toContain('color:#eeeeee');
});
test('unset overrides fall back to the exact prior literals (backward compatible)', () => {
test('unset overrides fall back to the design tokens', () => {
const { html } = toHtml({ plans }, '');
// Regular (non-featured) card literals unchanged from before these props existed.
expect(html).toContain('background-color:#ffffff');
expect(html).toContain('color:#18181b');
expect(html).toContain('color:#64748b');
expect(html).toContain('color:#4b5563');
// Regular (non-featured) card fallbacks are now design tokens (#44), not hex literals.
expect(html).toContain('background-color:var(--wsb-bg)');
expect(html).toContain('color:var(--wsb-text)');
expect(html).toContain('color:var(--wsb-muted)');
expect(html).toContain('color:var(--wsb-success)');
});
@@ -118,3 +117,12 @@ describe('PricingTable.toHtml palette token fallbacks (Refs #44)', () => {
expect(html).toContain('color:#222222');
});
});
describe('PricingTable.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ plans: [{ name: 'A', price: '$1', period: '/m', features: ['f'], buttonText: 'B', buttonHref: '#', isFeatured: false }, { name: 'P', price: '$2', period: '/m', features: ['f'], buttonText: 'B', buttonHref: '#', isFeatured: true }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "muted", "success", "warning", "primary"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#e2e8f0", "#18181b", "#64748b", "#4b5563", "#bbf7d0", "#facc15", "#ffffff"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+24 -24
View File
@@ -94,13 +94,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
selected: node.events.selected,
}));
const regCardBg = cardBg || '#ffffff';
const regTextColor = textColor || '#18181b';
const regSubColor = subColor || '#64748b';
const regFeatColor = featColor || '#4b5563';
const regCardBg = cardBg || tokenRef('bg');
const regTextColor = textColor || tokenRef('text');
const regSubColor = subColor || tokenRef('muted');
const regFeatColor = featColor || tokenRef('muted');
const regCheckColor = checkColor || tokenRef('success');
const regBtnBg = btnBg || featuredBg;
const regBtnColor = btnColor || '#ffffff';
const regBtnColor = btnColor || tokenRef('bg');
return (
<section
@@ -108,7 +108,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -129,7 +129,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
flex: '1 1 280px',
maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
border: plan.isFeatured ? 'none' : `1px solid ${tokenRef('border')}`,
borderRadius: '16px',
padding: '40px 32px',
display: 'flex',
@@ -145,8 +145,8 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<div style={{
position: 'absolute',
top: '-12px',
backgroundColor: '#facc15',
color: '#18181b',
backgroundColor: tokenRef('warning'),
color: tokenRef('text'),
padding: '4px 16px',
borderRadius: '9999px',
fontSize: '12px',
@@ -160,7 +160,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : regTextColor,
color: plan.isFeatured ? tokenRef('bg') : regTextColor,
marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0',
}}>
@@ -170,7 +170,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
<span style={{
fontSize: '48px',
fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : regTextColor,
color: plan.isFeatured ? tokenRef('bg') : regTextColor,
lineHeight: '1',
}}>
{plan.price}
@@ -199,7 +199,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
alignItems: 'center',
gap: '8px',
}}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
<span style={{ color: plan.isFeatured ? tokenRef('success') : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature}
</li>
))}
@@ -211,7 +211,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
marginTop: 'auto',
display: 'inline-block',
padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
backgroundColor: plan.isFeatured ? tokenRef('bg') : regBtnBg,
color: plan.isFeatured ? featuredBg : regBtnColor,
textDecoration: 'none',
borderRadius: '8px',
@@ -236,7 +236,7 @@ PricingTable.craft = {
displayName: 'Pricing Table',
props: {
plans: defaultPlans,
style: { backgroundColor: '#ffffff' },
style: { backgroundColor: tokenRef('bg') },
featuredBg: tokenRef('primary'),
bulletType: 'check',
anchorId: '',
@@ -278,22 +278,22 @@ PricingTable.craft = {
// the exact literal the card was previously hard-coded to (or, for the
// button, to featuredBg) when unset, so unmodified pricing tables render
// identically to before these props existed.
const regCardBg = cssValue(props.cardBg) || '#ffffff';
const regTextColor = cssValue(props.textColor) || '#18181b';
const regSubColor = cssValue(props.subColor) || '#64748b';
const regFeatColor = cssValue(props.featColor) || '#4b5563';
const regCardBg = cssValue(props.cardBg) || tokenRef('bg');
const regTextColor = cssValue(props.textColor) || tokenRef('text');
const regSubColor = cssValue(props.subColor) || tokenRef('muted');
const regFeatColor = cssValue(props.featColor) || tokenRef('muted');
const regCheckColor = cssValue(props.checkColor) || tokenRef('success');
const regBtnBg = cssValue(props.btnBg) || featuredBg;
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
const regBtnColor = cssValue(props.btnColor) || tokenRef('bg');
const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
const cardBorder = plan.isFeatured ? 'border:none;' : `border:1px solid ${tokenRef('border')};`;
const textColor = plan.isFeatured ? tokenRef('bg') : regTextColor;
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
const checkColor = plan.isFeatured ? tokenRef('success') : regCheckColor;
const btnBg = plan.isFeatured ? tokenRef('bg') : regBtnBg;
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
@@ -303,7 +303,7 @@ PricingTable.craft = {
).join('\n ');
const badge = plan.isFeatured
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
? `<div style="position:absolute;top:-12px;background-color:${tokenRef('warning')};color:${tokenRef('text')};padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
: '';
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
@@ -99,3 +99,12 @@ describe('Tabs.toHtml palette token fallback (Refs #44)', () => {
expect(html).not.toContain('#3b82f6');
});
});
describe('Tabs.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ tabs: [{ title: 'A', content: 'B' }, { title: 'C', content: 'D' }] }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "muted"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#e2e8f0", "#4b5563", "#64748b", "#f1f5f9"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+19 -19
View File
@@ -35,10 +35,10 @@ export const Tabs: UserComponent<TabsProps> = ({
tabs = defaultTabs,
style = {},
activeTabBg = tokenRef('primary'),
activeTabColor = '#ffffff',
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
contentBg = '#ffffff',
activeTabColor = tokenRef('bg'),
inactiveTabBg = tokenRef('bg'),
inactiveTabColor = tokenRef('muted'),
contentBg = tokenRef('bg'),
anchorId,
}) => {
const {
@@ -56,14 +56,14 @@ export const Tabs: UserComponent<TabsProps> = ({
id={anchorId || undefined}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
{/* Tab buttons */}
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
<div style={{ display: 'flex', gap: '2px', borderBottom: `2px solid ${tokenRef('border')}` }}>
{tabs.map((tab, i) => (
<button
key={i}
@@ -90,13 +90,13 @@ export const Tabs: UserComponent<TabsProps> = ({
style={{
padding: '24px',
backgroundColor: contentBg,
border: '1px solid #e2e8f0',
border: `1px solid ${tokenRef('border')}`,
borderTop: 'none',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
fontSize: '14px',
lineHeight: '1.7',
color: '#4b5563',
color: tokenRef('muted'),
minHeight: '100px',
}}
>
@@ -113,12 +113,12 @@ Tabs.craft = {
displayName: 'Tabs',
props: {
tabs: defaultTabs,
style: { backgroundColor: '#ffffff' },
style: { backgroundColor: tokenRef('bg') },
activeTabBg: tokenRef('primary'),
activeTabColor: '#ffffff',
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
activeTabColor: tokenRef('bg'),
inactiveTabBg: tokenRef('bg'),
inactiveTabColor: tokenRef('muted'),
contentBg: tokenRef('bg'),
anchorId: '',
animation: '',
animationDelay: '',
@@ -147,10 +147,10 @@ Tabs.craft = {
// literals (a stray `'` there breaks out of the JS string, not just CSS);
// cssValue strips quotes too so it neutralizes both contexts at once.
const activeTabBg = cssValue(props.activeTabBg) || tokenRef('primary');
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
const activeTabColor = cssValue(props.activeTabColor) || tokenRef('bg');
const inactiveTabBg = cssValue(props.inactiveTabBg) || tokenRef('bg');
const inactiveTabColor = cssValue(props.inactiveTabColor) || tokenRef('muted');
const contentBg = cssValue(props.contentBg) || tokenRef('bg');
// tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
@@ -171,7 +171,7 @@ Tabs.craft = {
}).join('\n ');
const tabPanels = tabs.map((tab, i) => {
return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" style="padding:24px;background-color:${contentBg};border:1px solid ${tokenRef('border')};border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:${tokenRef('muted')};min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
}).join('\n ');
const switchScript = `<script>
@@ -211,7 +211,7 @@ function ${tabId}_switch(idx){
return {
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto">
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid ${tokenRef('border')}">
${tabButtons}
</div>
${tabPanels}
@@ -82,3 +82,12 @@ describe('Testimonials.craft.props includes the box-model/animation/visibility r
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('Testimonials.toHtml design tokens (#44)', () => {
test('default export references var(--wsb-*) and no longer carries the old hard-coded hex', () => {
const { html } = toHtml({ testimonials: [{ quote: "Q", name: "N", title: "T", rating: 5 }], layout: "single" }, '');
const lower = html.toLowerCase();
for (const t of ["bg", "border", "text", "muted"]) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ["#f8fafc", "#e2e8f0", "#374151", "#18181b", "#64748b"]) expect(lower, `old ${hex}`).not.toContain(hex);
});
});
+18 -17
View File
@@ -1,4 +1,5 @@
import React, { CSSProperties } from 'react';
import { tokenRef } from '../../utils/tokens';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
@@ -63,8 +64,8 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
cardBg = tokenRef('bg'),
starColor = tokenRef('warning'),
anchorId,
}) => {
const {
@@ -79,17 +80,17 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
borderRadius: '12px',
padding: '32px 24px',
textAlign: 'center',
border: '1px solid #e2e8f0',
border: `1px solid ${tokenRef('border')}`,
};
const renderCard = (t: Testimonial, i: number) => (
<div key={i} style={cardStyle}>
{renderStars(t.rating, starColor)}
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
<p style={{ fontSize: '15px', color: tokenRef('text'), lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
&ldquo;{t.quote}&rdquo;
</p>
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
<div style={{ fontWeight: '600', fontSize: '14px', color: tokenRef('text'), fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: tokenRef('muted'), fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
</div>
);
@@ -101,7 +102,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
@@ -132,9 +133,9 @@ Testimonials.craft = {
testimonials: defaultTestimonials,
layout: 'grid',
columns: 3,
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
style: { backgroundColor: tokenRef('bg') },
cardBg: tokenRef('bg'),
starColor: tokenRef('warning'),
anchorId: '',
animation: '',
animationDelay: '',
@@ -163,25 +164,25 @@ Testimonials.craft = {
const columns = Number(props.columns) || 3;
// Sanitized -- raw string-interpolation sinks below (cardCss / starsHtml
// style attributes).
const cardBg = cssValue(props.cardBg) || '#f8fafc';
const starColor = cssValue(props.starColor) || '#f59e0b';
const cardBg = cssValue(props.cardBg) || tokenRef('bg');
const starColor = cssValue(props.starColor) || tokenRef('warning');
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
const sectionStyle = cssPropsToString({
padding: '80px 20px',
backgroundColor: '#ffffff',
backgroundColor: tokenRef('bg'),
...style,
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid ${tokenRef('border')}`;
const cardHtml = (t: Testimonial): string => `<div style="${cardCss}">
${starsHtml(t.rating, starColor)}
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${escapeHtml(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
<p style="font-size:15px;color:${tokenRef('text')};line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${escapeHtml(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:${tokenRef('text')};font-family:Inter,sans-serif">${escapeHtml(t.name)}</div>
<div style="font-size:13px;color:${tokenRef('muted')};font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
</div>`;
if (layout === 'single') {
@@ -1,5 +1,6 @@
import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
export type CtaVariant = 'primary' | 'outline' | 'ghost';
@@ -72,9 +73,9 @@ export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string
// Callers pass user-controlled design-token colors (e.g. HeroSimple's
// buttonBgColor/buttonTextColor/textColor) through CtaStyleDefaults, so
// sanitize once here rather than at every call site.
const outlineText = cssValue(defaults.outlineText) || '#000000';
const primaryBg = cssValue(defaults.primaryBg) || '#000000';
const primaryText = cssValue(defaults.primaryText) || '#ffffff';
const outlineText = cssValue(defaults.outlineText) || tokenRef('text');
const primaryBg = cssValue(defaults.primaryBg) || tokenRef('primary');
const primaryText = cssValue(defaults.primaryText) || tokenRef('bg');
switch (variant) {
case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${outlineText}`;
@@ -0,0 +1,77 @@
import React from 'react';
import { describe, test, expect, afterEach } from 'vitest';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { buildNodeTree } from '../../utils/craft-tree';
import { templateComponentToTreeNode } from '../../templates/apply-template';
import { exportBodyHtml, exportToHtml } from '../../utils/html-export';
/**
* #44 component-tokens, real-editor harness: a page of FeaturesGrid + PricingTable +
* Testimonials (default props). Their colours must be token references, the canvas
* token <style> must follow a Site Design change, and the export must reference tokens
* (and define the changed primary once, in :root) rather than carry the old hex.
*/
let h: EditorHarness | null = null;
afterEach(() => {
h?.unmount();
h = null;
});
const DesignProbe: React.FC<{ out: { current: SiteDesignContextValue | null } }> = ({ out }) => {
out.current = useSiteDesign();
return null;
};
const OLD_HEX = ['#f8fafc', '#e2e8f0', '#18181b', '#64748b', '#4b5563', '#374151', '#bbf7d0', '#facc15', '#f59e0b'];
describe('component colours follow Site Design (real editor harness)', () => {
test('FeaturesGrid + PricingTable + Testimonials: canvas vars follow primary; export references tokens', () => {
h = renderEditorHarness();
const ctx: { current: SiteDesignContextValue | null } = { current: null };
h.mountChild(
<SiteDesignProvider>
<CanvasTokenStyle />
<DesignProbe out={ctx} />
</SiteDesignProvider>,
);
h.act(() => {
for (const type of ['FeaturesGrid', 'PricingTable', 'Testimonials']) {
h!.actions.addNodeTree(
buildNodeTree(h!.query, templateComponentToTreeNode({ type, props: {} } as any)),
'ROOT',
);
}
});
// Canvas: the editor-rendered DOM carries var() refs (jsdom keeps them verbatim).
const clone = h.container.cloneNode(true) as HTMLElement;
clone.querySelectorAll('style').forEach((el) => el.remove()); // token definitions legitimately hold hex
const dom = clone.innerHTML;
expect(dom).toContain('var(--wsb-border)');
expect(dom).toContain('var(--wsb-muted)');
expect(dom).toContain('var(--wsb-primary)');
for (const hex of OLD_HEX) expect(dom.toLowerCase(), `canvas ${hex}`).not.toContain(hex);
// Canvas custom properties follow Site Design.
const canvasVars = () => document.querySelector('style[data-wsb-canvas-tokens]')!.textContent ?? '';
expect(canvasVars()).toContain('--wsb-primary:#3b82f6');
h.act(() => ctx.current!.updateDesign({ primaryColor: '#c0ffee', mutedTextColor: '#123456' }));
expect(canvasVars()).toContain('--wsb-primary:#c0ffee');
expect(canvasVars()).toContain('--wsb-muted:#123456');
// Export: tokens, no old design hex in the body.
const ser = h.getSerialized();
const { html } = exportBodyHtml(ser);
expect(html).toContain('var(--wsb-primary)');
expect(html).toContain('var(--wsb-warning)');
for (const hex of OLD_HEX) expect(html.toLowerCase(), `export ${hex}`).not.toContain(hex);
// Restyling Site Design changes only :root, never the body markup.
const a = exportToHtml(ser, { design: { ...DEFAULT_SITE_DESIGN } }).html;
const b = exportToHtml(ser, { design: { ...DEFAULT_SITE_DESIGN, primaryColor: '#c0ffee', mutedTextColor: '#123456' } }).html;
expect(b).toContain('--wsb-muted:#123456');
expect(b.slice(b.indexOf('<body'))).toBe(a.slice(a.indexOf('<body')));
});
});
@@ -14,10 +14,6 @@ const FAMILIES: Array<[string, SectionDef[]]> = [
['about', ABOUT_SECTIONS],
['cta', CTA_SECTIONS],
];
/** FeaturesGrid hardcodes card colours in its own render/toHtml (no colour props), so the
* skeleton's `services-grid` cannot be token-clean; `services-icon-cards` is the token-clean
* equivalent. Only the exported-HTML hex check is skipped (the tree itself is still checked). */
const HEX_EXEMPT_EXPORT = new Set(['services-grid']);
const ALL = FAMILIES.flatMap(([, defs]) => defs);
function walk(node: TemplateComponent, fn: (n: TemplateComponent) => void) {
@@ -78,7 +74,7 @@ describe('A-content section families', () => {
const out = exportBodyHtml(h.getSerialized()).html;
expect(out).not.toContain('Export failed');
expect(out.length).toBeGreaterThan(50);
if (!HEX_EXEMPT_EXPORT.has(def.id)) expect(strayHex(out), 'stray hex').toEqual([]);
expect(strayHex(out), 'stray hex').toEqual([]);
for (const img of out.match(/<img\b[^>]*>/g) || []) expect(img, img).toMatch(/\balt="[^"]+"/);
expect(out).toContain('var(--wsb-');
for (const href of [...out.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])) {
@@ -18,12 +18,7 @@ const TOP_LEVEL = new Set(['Section', 'HeroSimple', 'CTASection', 'CallToAction'
export function stripAllowedHex(html: string): string {
return html
.replace(/linear-gradient\([^)]*\)/g, '')
.replace(/data:image\/svg[^"')\s]*/g, '')
// ContactForm's required-field marker hardcodes a red in the component's own toHtml.
.replace(/<span style="color:#ef4444;margin-left:2px">\*<\/span>/g, '')
// ...and its submit button text (#ffffff, no prop) and Gallery's <img> placeholder background (#f1f5f9, no prop).
.replace(/(<button type="submit"[^>]*?)color:#ffffff;/g, '$1')
.replace(/(<img [^>]*?);background-color:#f1f5f9/g, '$1');
.replace(/data:image\/svg[^"')\s]*/g, '');
}
function walk(t: TemplateComponent, fn: (n: TemplateComponent) => void) {
@@ -21,15 +21,14 @@ const FONT_KEYS = ['headingFont', 'bodyFont'] as const;
/**
* Design hexes that still appear in the exported first-page body because a
* COMPONENT hard-codes them (not the template): CTASection's heading/button
* (#ffffff, #18181b -- it has no colour props), FeaturesGrid's card
* background (#ffffff) and title (#18181b). Pinned as exact counts so a
* template that re-introduces a literal still fails; removing them needs a
* component change (out of scope for the template pass, tracked in the report).
* COMPONENT hard-codes them (not the template). After component-tokens (#44) the only
* survivors are the deliberate fixed whites on gradient/image backgrounds (CTASection
* heading, CallToAction text). Pinned as exact counts so a template that re-introduces a
* literal still fails.
*/
const COMPONENT_DEFAULT_HEX: Record<string, Record<string, number>> = {
medical: { '#ffffff': 5 },
portfolio: { '#18181b': 4, '#ffffff': 2 },
medical: { '#ffffff': 4 },
portfolio: { '#ffffff': 1 },
photography: {},
'content-creator': {},
};