feat(site-builder): section blocks default to palette tokens

Refs #44

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-10-07 11:24:52 -07:00
1 parent af43af7dc1
commit 488f208262
12 files changed
+93 -22

No files matched your search

@@ -182,3 +182,11 @@ describe('ContentSlider.craft.props exposes the animation/visibility rollout', (
expect(style.opacity).toBe('1');
});
});
describe('ContentSlider.toHtml palette token fallback (Refs #44)', () => {
test('a slide without bgColor falls back to the primary token', () => {
const { html } = toHtml({ slides: [{ type: 'image' as const, heading: 'One' }] }, '');
expect(html).toContain('var(--wsb-primary)');
expect(html).not.toContain('#3b82f6');
});
});
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState, useEffect, useRef, useCallback } from '
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface Slide {
type: 'image' | 'content';
@@ -116,7 +117,7 @@ export const ContentSlider: UserComponent<ContentSliderProps> = ({
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: slide.bgColor?.startsWith('linear-gradient')
? { backgroundImage: slide.bgColor }
: { backgroundColor: slide.bgColor || '#3b82f6' };
: { backgroundColor: slide.bgColor || tokenRef('primary') };
return (
<div
@@ -297,7 +298,7 @@ ContentSlider.craft = {
const hasBgImage = slide.imageSrc;
// Sanitized -- slide.bgColor is a per-slide raw string-interpolation
// sink (a malicious value could break out of the style="..." attribute).
const safeBgColor = cssValue(slide.bgColor) || '#3b82f6';
const safeBgColor = cssValue(slide.bgColor) || tokenRef('primary');
const bgStyle = hasBgImage
? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient')
@@ -32,3 +32,12 @@ describe('FeaturesGrid.craft.props includes the box-model/animation/visibility r
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('FeaturesGrid.toHtml palette token fallback (Refs #44)', () => {
test('feature button background is the primary token', () => {
const features = [{ title: 'F', description: 'D', buttonText: 'More', buttonUrl: '/x' }];
const { html } = toHtml({ features }, '');
expect(html).toContain('background:var(--wsb-primary)');
expect(html).not.toContain('#3b82f6');
});
});
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface FeatureItem {
title: string;
@@ -83,7 +84,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
<a
href={feat.buttonUrl || '#'}
onClick={(e) => e.preventDefault()}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: tokenRef('primary'), color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
>
{feat.buttonText}
</a>
@@ -129,7 +130,7 @@ 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:#3b82f6;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:#fff;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">
${media}
@@ -33,3 +33,18 @@ describe('HeroSimple.toHtml textAlign enum sink (attacker-controlled prop, not e
expect(html).toContain('text-align:center');
});
});
describe('HeroSimple.toHtml palette token fallback (Refs #44)', () => {
test('buttonBgColor absent/empty -> primary token on the CTA', () => {
for (const buttonBgColor of [undefined, '']) {
const { html } = toHtml({ heading: 'H', buttonText: 'Go', buttonLink: '#', buttonBgColor }, '');
expect(html).toContain('var(--wsb-primary)');
expect(html).not.toContain('#3b82f6');
}
});
test('explicit buttonBgColor wins', () => {
const { html } = toHtml({ heading: 'H', buttonText: 'Go', buttonLink: '#', buttonBgColor: '#ff0000' }, '');
expect(html).toContain('#ff0000');
expect(html).not.toContain('var(--wsb-primary)');
});
});
+4 -3
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, safeUrl, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface HeroProps {
heading?: string;
@@ -64,7 +65,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonBgColor = '#3b82f6',
buttonBgColor = tokenRef('primary'),
buttonTextColor = '#ffffff',
minHeight = '500px',
verticalAlign = 'center',
@@ -182,7 +183,7 @@ HeroSimple.craft = {
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonBgColor: '#3b82f6',
buttonBgColor: tokenRef('primary'),
buttonTextColor: '#ffffff',
minHeight: '500px',
verticalAlign: 'center',
@@ -240,7 +241,7 @@ HeroSimple.craft = {
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, {
primaryBg: props.buttonBgColor || '#3b82f6',
primaryBg: cssValue(props.buttonBgColor) || tokenRef('primary'),
primaryText: props.buttonTextColor || '#fff',
outlineText: props.textColor || '#fff',
});
@@ -84,3 +84,13 @@ describe('NumberCounter.craft.props includes the box-model/animation/visibility
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('NumberCounter.toHtml palette token fallbacks (Refs #44)', () => {
test('number -> primary token, label -> muted token when colours are absent', () => {
const { html } = toHtml({ counters }, '');
expect(html).toContain('color:var(--wsb-primary)');
expect(html).toContain('color:var(--wsb-muted)');
expect(html).not.toContain('#3b82f6');
expect(html).not.toContain('#6b7280');
});
});
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface Counter {
number: number;
@@ -33,8 +34,8 @@ const defaultCounters: Counter[] = [
export const NumberCounter: UserComponent<NumberCounterProps> = ({
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberColor = tokenRef('primary'),
labelColor = tokenRef('muted'),
numberSize = '48px',
style = {},
}) => {
@@ -100,8 +101,8 @@ NumberCounter.craft = {
props: {
counters: defaultCounters,
columns: 4,
numberColor: '#3b82f6',
labelColor: '#6b7280',
numberColor: tokenRef('primary'),
labelColor: tokenRef('muted'),
numberSize: '48px',
style: { backgroundColor: '#ffffff' },
animation: '',
@@ -130,8 +131,8 @@ NumberCounter.craft = {
const columns = Number(props.columns) || 4;
// Sanitized -- raw string-interpolation sinks in the counter/label spans
// below.
const numberColor = cssValue(props.numberColor) || '#3b82f6';
const labelColor = cssValue(props.labelColor) || '#6b7280';
const numberColor = cssValue(props.numberColor) || tokenRef('primary');
const labelColor = cssValue(props.labelColor) || tokenRef('muted');
const numberSize = cssValue(props.numberSize) || '48px';
const items = counters.length > 0 ? counters : defaultCounters;
@@ -47,7 +47,7 @@ describe('PricingTable.toHtml regular-card color overrides (previously hard-code
expect(html).toContain('color:#18181b');
expect(html).toContain('color:#64748b');
expect(html).toContain('color:#4b5563');
expect(html).toContain('color:#10b981');
expect(html).toContain('color:var(--wsb-success)');
});
test('unset btnBg falls back to featuredBg (original derivation)', () => {
@@ -103,3 +103,18 @@ describe('PricingTable.craft.props includes the box-model/animation/visibility r
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('PricingTable.toHtml palette token fallbacks (Refs #44)', () => {
test('featured card -> primary token, tick -> success token when colours are absent', () => {
const { html } = toHtml({ plans }, '');
expect(html).toContain('background-color:var(--wsb-primary)');
expect(html).toContain('color:var(--wsb-success)');
expect(html).not.toContain('#3b82f6');
expect(html).not.toContain('#10b981');
});
test('explicit featuredBg / checkColor win', () => {
const { html } = toHtml({ plans, featuredBg: '#111111', checkColor: '#222222' }, '');
expect(html).toContain('background-color:#111111');
expect(html).toContain('color:#222222');
});
});
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface PricingPlan {
name: string;
@@ -75,7 +76,7 @@ const defaultPlans: PricingPlan[] = [
export const PricingTable: UserComponent<PricingTableProps> = ({
plans = defaultPlans,
style = {},
featuredBg = '#3b82f6',
featuredBg = tokenRef('primary'),
bulletType = 'check',
anchorId,
cardBg,
@@ -97,7 +98,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
const regTextColor = textColor || '#18181b';
const regSubColor = subColor || '#64748b';
const regFeatColor = featColor || '#4b5563';
const regCheckColor = checkColor || '#10b981';
const regCheckColor = checkColor || tokenRef('success');
const regBtnBg = btnBg || featuredBg;
const regBtnColor = btnColor || '#ffffff';
@@ -236,7 +237,7 @@ PricingTable.craft = {
props: {
plans: defaultPlans,
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
featuredBg: tokenRef('primary'),
bulletType: 'check',
anchorId: '',
cardBg: '',
@@ -271,7 +272,7 @@ PricingTable.craft = {
const plans = props.plans || defaultPlans;
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
const featuredBg = cssValue(props.featuredBg) || tokenRef('primary');
// Sanitized -- regular (non-featured) card color overrides, all raw
// string-interpolation sinks into style="..." below. Each falls back to
// the exact literal the card was previously hard-coded to (or, for the
@@ -281,7 +282,7 @@ PricingTable.craft = {
const regTextColor = cssValue(props.textColor) || '#18181b';
const regSubColor = cssValue(props.subColor) || '#64748b';
const regFeatColor = cssValue(props.featColor) || '#4b5563';
const regCheckColor = cssValue(props.checkColor) || '#10b981';
const regCheckColor = cssValue(props.checkColor) || tokenRef('success');
const regBtnBg = cssValue(props.btnBg) || featuredBg;
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
@@ -91,3 +91,11 @@ describe('Tabs.craft.props includes the box-model/animation/visibility rollout p
expect(props).toHaveProperty('hideOnMobile', false);
});
});
describe('Tabs.toHtml palette token fallback (Refs #44)', () => {
test('activeTabBg absent -> primary token', () => {
const { html } = toHtml({ tabs }, '');
expect(html).toContain('var(--wsb-primary)');
expect(html).not.toContain('#3b82f6');
});
});
+4 -3
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
interface TabItem {
label: string;
@@ -33,7 +34,7 @@ const defaultTabs: TabItem[] = [
export const Tabs: UserComponent<TabsProps> = ({
tabs = defaultTabs,
style = {},
activeTabBg = '#3b82f6',
activeTabBg = tokenRef('primary'),
activeTabColor = '#ffffff',
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
@@ -113,7 +114,7 @@ Tabs.craft = {
props: {
tabs: defaultTabs,
style: { backgroundColor: '#ffffff' },
activeTabBg: '#3b82f6',
activeTabBg: tokenRef('primary'),
activeTabColor: '#ffffff',
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
@@ -145,7 +146,7 @@ Tabs.craft = {
// attributes AND into an inline <script> as single-quoted JS string
// 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) || '#3b82f6';
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';