Merge component-tokens

This commit is contained in:
shadowdao committed 2026-10-07 13:01:14 -07:00
commit d4400e49ad
65 files changed
+662 -262

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,
});
@@ -31,3 +31,13 @@ describe('Footer (F4: box-model + animation + visibility props on craft.props)',
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Footer.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((Footer as any).craft.props as object) };
const { html } = (((Footer) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#18181b', '#a1a1aa']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
+4 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { tokenRef } from '../../utils/tokens';
import { escapeHtml } from '../../utils/escape';
interface FooterProps {
@@ -92,8 +93,9 @@ Footer.craft = {
props: {
text: '© 2026 MySite. All rights reserved.',
style: {
backgroundColor: '#18181b',
color: '#a1a1aa',
// Dark band: text token as the band, bg token as its (contrast-safe) text.
backgroundColor: tokenRef('text'),
color: tokenRef('bg'),
fontSize: '14px',
padding: '24px 20px',
},
@@ -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>`;
}
@@ -107,3 +107,13 @@ describe('Menu.toHtml dropdowns (#53)', () => {
expect((Menu as any).craft.props.followPages).toBe(false);
});
});
describe('Menu.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((Menu as any).craft.props as object) };
const { html } = (((Menu) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#3f3f46']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
+7 -7
View File
@@ -43,10 +43,10 @@ const defaultLinks: MenuLink[] = [
export const Menu: UserComponent<MenuProps> = ({
links = defaultLinks,
alignment = 'right',
linkColor = '#3f3f46',
linkColor = tokenRef('text'),
linkHoverColor = tokenRef('primary'),
ctaBgColor = tokenRef('primary'),
ctaTextColor = '#ffffff',
ctaTextColor = tokenRef('bg'),
gap = '24px',
orientation = 'horizontal',
fontSize = '14px',
@@ -139,7 +139,7 @@ export const Menu: UserComponent<MenuProps> = ({
const SUB_STYLE: CSSProperties = {
position: 'absolute', top: '100%', left: 0, zIndex: 1001, minWidth: 180, margin: 0, padding: '8px 0',
listStyle: 'none', background: 'var(--wsb-bg, #ffffff)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
listStyle: 'none', background: tokenRef('bg'), borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
};
const SUB_STATIC_STYLE: CSSProperties = { margin: 0, padding: '4px 0 0 12px', listStyle: 'none' };
@@ -151,10 +151,10 @@ Menu.craft = {
links: defaultLinks,
followPages: false,
alignment: 'right',
linkColor: '#3f3f46',
linkColor: tokenRef('text'),
linkHoverColor: tokenRef('primary'),
ctaBgColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
ctaTextColor: tokenRef('bg'),
gap: '24px',
orientation: 'horizontal',
fontSize: '14px',
@@ -179,10 +179,10 @@ Menu.craft = {
// string-interpolation sinks below (hoverCol goes into a <style> block,
// the worst case: </style> breakout -> arbitrary <script>), see
// task-cssxss-brief.md.
const linkCol = cssValue(props.linkColor) || '#3f3f46';
const linkCol = cssValue(props.linkColor) || tokenRef('text');
const hoverCol = cssValue(props.linkHoverColor) || tokenRef('primary');
const ctaBg = cssValue(props.ctaBgColor) || tokenRef('primary');
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
const ctaText = cssValue(props.ctaTextColor) || tokenRef('bg');
const gap = cssValue(props.gap) || '24px';
const orientation = props.orientation || 'horizontal';
const alignment = props.alignment || 'right';
@@ -185,3 +185,13 @@ describe('Navbar.toHtml dropdowns (#53)', () => {
expect((Navbar as any).craft.props.followPages).toBe(false);
});
});
describe('Navbar.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((Navbar as any).craft.props as object) };
const { html } = (((Navbar) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg', 'border']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#3f3f46', '#e4e4e7']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
+21 -12
View File
@@ -53,6 +53,11 @@ const PADDING_PRESETS = [
{ label: 'Spacious', value: '24px 48px' },
];
function isLightNavBg(bg: string): boolean {
const v = (bg || '').trim().toLowerCase();
return v === tokenRef('bg') || v === '#ffffff' || v === '#f8fafc' || v === '#f9fafb';
}
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
@@ -65,11 +70,11 @@ export const Navbar: UserComponent<NavbarProps> = ({
logoFontSize = '20px',
logoColor,
links = defaultLinks,
backgroundColor = '#ffffff',
textColor = '#3f3f46',
backgroundColor = tokenRef('bg'),
textColor = tokenRef('text'),
hoverColor = tokenRef('primary'),
ctaColor = tokenRef('primary'),
ctaTextColor = '#ffffff',
ctaTextColor = tokenRef('bg'),
padding = '16px 24px',
navAlignment = 'space-between',
isSticky = false,
@@ -84,8 +89,12 @@ export const Navbar: UserComponent<NavbarProps> = ({
}));
const { design } = useSiteDesign();
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
// Light-background heuristic: the bg token counts as light (it is the page
// background); legacy stored hex values keep the old hex test. White/zinc on
// an explicitly dark bar is "text on a dark custom colour" -- no token fits.
const lightBg = isLightNavBg(backgroundColor);
const resolvedLogoColor = logoColor || (lightBg ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (lightBg ? tokenRef('text') : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
@@ -225,17 +234,17 @@ Navbar.craft = {
logoColor: undefined,
links: defaultLinks,
followPages: false,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
backgroundColor: tokenRef('bg'),
textColor: tokenRef('text'),
hoverColor: tokenRef('primary'),
ctaColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
ctaTextColor: tokenRef('bg'),
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: {
borderBottom: '1px solid #e4e4e7',
borderBottom: `1px solid ${tokenRef('border')}`,
},
hideOnDesktop: false,
hideOnTablet: false,
@@ -256,11 +265,11 @@ Navbar.craft = {
// Sanitized once here -- these are raw string-interpolation sinks below
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
const textCol = cssValue(props.textColor) || '#3f3f46';
const bgColor = cssValue(props.backgroundColor) || tokenRef('bg');
const textCol = cssValue(props.textColor) || tokenRef('text');
const hoverCol = cssValue(props.hoverColor) || tokenRef('primary');
const ctaCol = cssValue(props.ctaColor) || tokenRef('primary');
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
const ctaTextCol = cssValue(props.ctaTextColor) || tokenRef('bg');
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
@@ -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}
@@ -91,3 +91,13 @@ describe('SocialLinks.craft.props includes the box-model/animation/visibility ro
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('SocialLinks.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((SocialLinks as any).craft.props as object) };
const { html } = (((SocialLinks) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#374151']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
+7 -6
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { tokenRef } from '../../utils/tokens';
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface SocialLink {
@@ -87,8 +88,8 @@ const alignMap: Record<string, string> = {
export const SocialLinks: UserComponent<SocialLinksProps> = ({
links = defaultLinks,
iconSize = '20px',
iconColor = '#ffffff',
iconBgColor = '#374151',
iconColor = tokenRef('bg'),
iconBgColor = tokenRef('text'),
iconShape = 'circle',
gap = '10px',
alignment = 'center',
@@ -150,8 +151,8 @@ SocialLinks.craft = {
props: {
links: defaultLinks,
iconSize: '20px',
iconColor: '#ffffff',
iconBgColor: '#374151',
iconColor: tokenRef('bg'),
iconBgColor: tokenRef('text'),
iconShape: 'circle',
gap: '10px',
alignment: 'center',
@@ -175,8 +176,8 @@ SocialLinks.craft = {
const links = props.links || defaultLinks;
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
const iconSize = cssValue(props.iconSize) || '20px';
const iconColor = cssValue(props.iconColor) || '#ffffff';
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
const iconColor = cssValue(props.iconColor) || tokenRef('bg');
const iconBgColor = cssValue(props.iconBgColor) || tokenRef('text');
const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px';
const alignment = props.alignment || 'center';
@@ -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'],
@@ -46,3 +46,13 @@ describe('StickyCallBar.toHtml', () => {
expect(body).toContain('href="tel:+15551234567"');
});
});
describe('StickyCallBar.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((StickyCallBar as any).craft.props as object) };
const { html } = (((StickyCallBar) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of [] as string[]) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
@@ -42,7 +42,7 @@ export const StickyCallBar: UserComponent<StickyCallBarProps> = ({
directionsUrl = 'https://maps.google.com/?q=Your+Business',
directionsLabel = 'Directions',
bgColor = tokenRef('primary'),
textColor = '#ffffff',
textColor = tokenRef('bg'),
style = {},
}) => {
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
@@ -66,7 +66,7 @@ StickyCallBar.craft = {
directionsUrl: 'https://maps.google.com/?q=Your+Business',
directionsLabel: 'Directions',
bgColor: tokenRef('primary'),
textColor: '#ffffff',
textColor: tokenRef('bg'),
position: 'bottom',
style: {},
animation: '',
@@ -83,7 +83,7 @@ StickyCallBar.craft = {
// end of the page to compensate (documented, not automatic).
(StickyCallBar as any).toHtml = (props: StickyCallBarProps, _childrenHtml: string) => {
const bg = cssValue(props.bgColor) || tokenRef('primary');
const fg = cssValue(props.textColor) || '#ffffff';
const fg = cssValue(props.textColor) || tokenRef('bg');
const tel = telHref(props.phone);
const dir = httpUrl(props.directionsUrl);
const links: string[] = [];
@@ -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>`,
};
};
@@ -72,3 +72,13 @@ describe('BackgroundSection.toHtml bgPosition focal point (#55)', () => {
expect(html).not.toContain('<script>alert(1)');
});
});
describe('BackgroundSection.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((BackgroundSection as any).craft.props as object) };
const { html } = (((BackgroundSection) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#1e293b']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { tokenRef } from '../../utils/tokens';
import { escapeAttr, cssValue } from '../../utils/escape';
interface BackgroundSectionProps {
@@ -20,7 +21,7 @@ interface BackgroundSectionProps {
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
bgImage = '',
bgPosition = '',
bgColor = '#1e293b',
bgColor = tokenRef('text'),
overlayColor = '#000000',
overlayOpacity = 0.4,
innerMaxWidth = '1200px',
@@ -79,7 +80,7 @@ BackgroundSection.craft = {
props: {
bgImage: '',
bgPosition: '',
bgColor: '#1e293b',
bgColor: tokenRef('text'),
overlayColor: '#000000',
overlayOpacity: 0.4,
innerMaxWidth: '1200px',
@@ -100,7 +101,7 @@ BackgroundSection.craft = {
position: 'relative',
width: '100%',
minHeight: '200px',
backgroundColor: props.bgColor || '#1e293b',
backgroundColor: props.bgColor || tokenRef('text'),
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
backgroundSize: 'cover',
backgroundPosition: cssValue(props.bgPosition) || 'center',
@@ -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',
@@ -190,3 +190,13 @@ describe('ContentSlider.toHtml palette token fallback (Refs #44)', () => {
expect(html).not.toContain('#3b82f6');
});
});
describe('ContentSlider.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((ContentSlider as any).craft.props as object) };
const { html } = (((ContentSlider) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['primary', 'secondary', 'success', 'warning', 'error', 'text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#3b82f6', '#8b5cf6', '#10b981', '#059669', '#f59e0b', '#ef4444', '#18181b']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
@@ -29,6 +29,9 @@ interface ContentSliderProps {
hideOnMobile?: boolean;
}
// White heading/subtitle/dots stay literal: they sit on a slide gradient or
// image, where no token means "text on imagery" (same rule as CTASection's
// WHITE_ON_GRADIENT); the translucent-white arrow discs are scrims.
const defaultSlides: Slide[] = [
{
type: 'image',
@@ -37,7 +40,7 @@ const defaultSlides: Slide[] = [
text: 'Welcome to our showcase',
buttonText: 'Learn More',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
bgColor: `linear-gradient(135deg, ${tokenRef('primary')} 0%, ${tokenRef('secondary')} 100%)`,
},
{
type: 'image',
@@ -46,7 +49,7 @@ const defaultSlides: Slide[] = [
text: 'Discover something amazing',
buttonText: 'Get Started',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
bgColor: `linear-gradient(135deg, ${tokenRef('success')} 0%, ${tokenRef('accent')} 100%)`,
},
{
type: 'image',
@@ -55,7 +58,7 @@ const defaultSlides: Slide[] = [
text: 'Build your future today',
buttonText: 'Contact Us',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
bgColor: `linear-gradient(135deg, ${tokenRef('warning')} 0%, ${tokenRef('error')} 100%)`,
},
];
@@ -102,7 +105,7 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
borderRadius: '50%',
border: 'none',
background: 'rgba(255,255,255,0.9)',
color: '#18181b',
color: tokenRef('text'),
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
@@ -156,8 +159,8 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
style={{
display: 'inline-block',
padding: '12px 28px',
background: '#ffffff',
color: '#18181b',
background: tokenRef('bg'),
color: tokenRef('text'),
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
@@ -313,7 +316,7 @@ ContentSlider.craft = {
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${escapeHtml(slide.text)}</p>`);
}
if (slide.buttonText) {
contentParts.push(`<a href="${escapeAttr(safeUrl(slide.buttonHref || '#'))}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`);
contentParts.push(`<a href="${escapeAttr(safeUrl(slide.buttonHref || '#'))}" style="display:inline-block;padding:12px 28px;background:${tokenRef('bg')};color:${tokenRef('text')};text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${escapeHtml(slide.buttonText)}</a>`);
}
const innerHtml = contentParts.length > 0
@@ -326,8 +329,8 @@ ContentSlider.craft = {
}).join('\n ');
const arrowsHtml = showArrows && items.length > 1
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:${tokenRef('text')};font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
: '';
const dotsHtml = showDots && items.length > 1
@@ -73,3 +73,13 @@ describe('Countdown.craft.props includes the box-model/animation/visibility roll
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('Countdown.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((Countdown as any).craft.props as object) };
const { html } = (((Countdown) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#18181b', 'rgba(255,255,255,0.7)']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
+14 -9
View File
@@ -1,8 +1,13 @@
import React, { CSSProperties, useEffect, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { tokenRef } from '../../utils/tokens';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
// Label text on the (text-token) band: the bg token at 70% so it stays
// contrast-safe under any palette.
const LABEL_ON_BAND = `color-mix(in srgb, ${tokenRef('bg')} 70%, transparent)`;
interface CountdownProps {
targetDate?: string;
heading?: string;
@@ -48,9 +53,9 @@ export const Countdown: UserComponent<CountdownProps> = ({
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
digitColor = tokenRef('bg'),
labelColor = LABEL_ON_BAND,
bgColor = tokenRef('text'),
anchorId,
}) => {
const {
@@ -140,9 +145,9 @@ Countdown.craft = {
targetDate: DEFAULT_TARGET,
heading: 'Coming Soon',
style: {},
digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
digitColor: tokenRef('bg'),
labelColor: LABEL_ON_BAND,
bgColor: tokenRef('text'),
anchorId: '',
animation: '',
animationDelay: '',
@@ -164,12 +169,12 @@ Countdown.craft = {
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
bgColor = '#18181b',
bgColor = tokenRef('text'),
} = props;
// Sanitized -- raw string-interpolation sinks in the heading/digit/label
// style attributes below.
const digitColor = cssValue(props.digitColor) || '#ffffff';
const labelColor = cssValue(props.labelColor) || 'rgba(255,255,255,0.7)';
const digitColor = cssValue(props.digitColor) || tokenRef('bg');
const labelColor = cssValue(props.labelColor) || LABEL_ON_BAND;
const sectionStyle = cssPropsToString({
padding: '60px 20px',
@@ -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 ');
@@ -65,3 +65,24 @@ describe('HeroSimple.toHtml bgPosition focal point (#55)', () => {
expect(html).not.toContain('<script>alert(1)');
});
});
describe('HeroSimple.toHtml design tokens (#44)', () => {
test('default craft props export var(--wsb-*) and none of the old design hex', () => {
const props = { ...((HeroSimple as any).craft.props as object) };
const { html } = (((HeroSimple) as any).toHtml)(props, '', 'node-tok');
const lower = html.toLowerCase();
for (const t of ['text', 'bg']) expect(html, `token ${t}`).toContain(`var(--wsb-${t})`);
for (const hex of ['#1e293b', '#667eea', '#764ba2']) expect(lower, `old ${hex}`).not.toContain(hex.toLowerCase());
});
});
describe('HeroSimple.toHtml gradient defaults are tokens (#44)', () => {
test('bgType gradient with the default stops uses primary -> secondary, not #667eea/#764ba2', () => {
const props = { ...((HeroSimple as any).craft.props as object), bgType: 'gradient' };
const { html } = toHtml(props, '', 'node-g');
expect(html).toContain('var(--wsb-primary)');
expect(html).toContain('var(--wsb-secondary)');
expect(html.toLowerCase()).not.toContain('#667eea');
expect(html.toLowerCase()).not.toContain('#764ba2');
});
});
+16 -16
View File
@@ -40,12 +40,12 @@ interface HeroProps {
function buildBackground(props: HeroProps): string {
switch (props.bgType) {
case 'gradient':
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || tokenRef('primary')}, ${props.bgGradientTo || tokenRef('secondary')})`;
case 'image':
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : tokenRef('text');
case 'color':
default:
return props.bgColor || '#1e293b';
return props.bgColor || tokenRef('text');
}
}
@@ -58,18 +58,18 @@ export const HeroSimple: UserComponent<HeroProps> = ({
secondaryButtonText,
secondaryButtonHref,
bgType = 'color',
bgColor = '#1e293b',
bgGradientFrom = '#667eea',
bgGradientTo = '#764ba2',
bgColor = tokenRef('text'),
bgGradientFrom = tokenRef('primary'),
bgGradientTo = tokenRef('secondary'),
bgGradientAngle = 135,
bgImage = '',
bgPosition = '',
bgVideo = '',
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
textColor = tokenRef('bg'),
buttonBgColor = tokenRef('primary'),
buttonTextColor = '#ffffff',
buttonTextColor = tokenRef('bg'),
minHeight = '500px',
verticalAlign = 'center',
textAlign = 'center',
@@ -177,18 +177,18 @@ HeroSimple.craft = {
{ text: 'Get Started', href: '#', variant: 'primary' },
] as CtaButton[],
bgType: 'color',
bgColor: '#1e293b',
bgGradientFrom: '#667eea',
bgGradientTo: '#764ba2',
bgColor: tokenRef('text'),
bgGradientFrom: tokenRef('primary'),
bgGradientTo: tokenRef('secondary'),
bgGradientAngle: 135,
bgImage: '',
bgPosition: '',
bgVideo: '',
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
textColor: tokenRef('bg'),
buttonBgColor: tokenRef('primary'),
buttonTextColor: '#ffffff',
buttonTextColor: tokenRef('bg'),
minHeight: '500px',
verticalAlign: 'center',
textAlign: 'center',
@@ -246,12 +246,12 @@ HeroSimple.craft = {
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, {
primaryBg: cssValue(props.buttonBgColor) || tokenRef('primary'),
primaryText: props.buttonTextColor || '#fff',
outlineText: props.textColor || '#fff',
primaryText: props.buttonTextColor || tokenRef('bg'),
outlineText: props.textColor || tokenRef('bg'),
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const heroTextColor = cssValue(props.textColor) || '#fff';
const heroTextColor = cssValue(props.textColor) || tokenRef('bg');
return {
html: `<section${idAttr} style="${sectionStyle}">
${videoHtml}${overlayHtml}
@@ -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': 1 },
portfolio: { '#ffffff': 1 },
photography: {},
'content-creator': {},
};