feat(site-builder): token-aware colour controls and Try a palette

Colour controls offer the site palette first, show the resolved colour for
var(--wsb-*) values with a token chip, and keep the stored reference.
autoTextColor resolves tokens. Site Design gains six one-click palettes.

Refs #44
This commit is contained in:
shadowdao committed 2026-10-07 11:38:17 -07:00
1 parent ce3db23506
commit dd7a50cf3d
11 files changed
+488 -101

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import type { SiteDesign } from '../state/SiteDesignContext';
/** Colour keys a palette sets. Fonts are optional and applied only when present. */
export type PaletteColors = Pick<
SiteDesign,
| 'primaryColor'
| 'secondaryColor'
| 'accentColor'
| 'linkColor'
| 'backgroundColor'
| 'textColor'
| 'mutedTextColor'
| 'borderColor'
>;
export interface Palette {
id: string;
name: string;
design: PaletteColors;
fonts?: { headingFont: string; bodyFont: string };
}
/** One-click site palettes ("Try a palette" in Site Design; reused by #58). */
export const PALETTES: Palette[] = [
{
id: 'ocean',
name: 'Ocean',
design: {
primaryColor: '#0e7490', secondaryColor: '#1e40af', accentColor: '#f59e0b', linkColor: '#0e7490',
backgroundColor: '#ffffff', textColor: '#0f172a', mutedTextColor: '#475569', borderColor: '#cbd5e1',
},
},
{
id: 'forest',
name: 'Forest',
design: {
primaryColor: '#166534', secondaryColor: '#854d0e', accentColor: '#ca8a04', linkColor: '#166534',
backgroundColor: '#fafaf5', textColor: '#1c2b1f', mutedTextColor: '#52614f', borderColor: '#d4dccb',
},
fonts: { headingFont: 'Merriweather, serif', bodyFont: 'Open Sans, sans-serif' },
},
{
id: 'sunset',
name: 'Sunset',
design: {
primaryColor: '#ea580c', secondaryColor: '#be123c', accentColor: '#7c3aed', linkColor: '#c2410c',
backgroundColor: '#fffaf5', textColor: '#2a1a12', mutedTextColor: '#78594a', borderColor: '#f3d9c7',
},
fonts: { headingFont: 'Poppins, sans-serif', bodyFont: 'Inter, sans-serif' },
},
{
id: 'slate',
name: 'Slate',
design: {
primaryColor: '#334155', secondaryColor: '#64748b', accentColor: '#0ea5e9', linkColor: '#0369a1',
backgroundColor: '#ffffff', textColor: '#0f172a', mutedTextColor: '#64748b', borderColor: '#e2e8f0',
},
},
{
id: 'berry',
name: 'Berry',
design: {
primaryColor: '#9d174d', secondaryColor: '#6d28d9', accentColor: '#0d9488', linkColor: '#9d174d',
backgroundColor: '#fff8fb', textColor: '#2b1020', mutedTextColor: '#7a5468', borderColor: '#f0d5e1',
},
fonts: { headingFont: 'Playfair Display, serif', bodyFont: 'Inter, sans-serif' },
},
{
id: 'sand',
name: 'Sand',
design: {
primaryColor: '#a16207', secondaryColor: '#57534e', accentColor: '#0f766e', linkColor: '#92400e',
backgroundColor: '#faf7f0', textColor: '#292524', mutedTextColor: '#78716c', borderColor: '#e7e0d0',
},
fonts: { headingFont: 'Playfair Display, serif', bodyFont: 'Open Sans, sans-serif' },
},
];
/** The design patch a palette applies: colours always, fonts only when the palette defines them. */
export function paletteUpdates(p: Palette): Partial<SiteDesign> {
return { ...p.design, ...(p.fonts ?? {}) };
}
/** True when every colour of `p` equals the design's (used to mark the active preview). */
export function paletteMatches(p: Palette, design: SiteDesign): boolean {
return (Object.keys(p.design) as (keyof PaletteColors)[]).every((k) => design[k] === p.design[k]);
}
@@ -0,0 +1,107 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { EditorConfigProvider } from '../../state/EditorConfigContext';
import { PageProvider } from '../../state/PageContext';
import { SaveGuardProvider, useSaveGuard } from '../../state/SaveGuardContext';
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesignContextValue } from '../../state/SiteDesignContext';
import { DirtyTracker } from '../../editor/DirtyTracker';
import { SiteDesignPanel } from './SiteDesignPanel';
import { PALETTES } from '../../constants/palettes';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { exportToHtml } from '../../utils/html-export';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
let h: EditorHarness;
afterEach(() => { h?.unmount(); document.body.innerHTML = ''; });
const whp = {
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
};
describe('Try a palette', () => {
test('clicking Ocean updates the design and marks the editor dirty', () => {
let design: SiteDesignContextValue | null = null;
let guard: ReturnType<typeof useSaveGuard> | null = null;
const Probe: React.FC = () => { design = useSiteDesign(); guard = useSaveGuard(); return null; };
h = renderEditorHarness();
h.mountChild(
<EditorConfigProvider config={whp}>
<SaveGuardProvider>
<PageProvider>
<SiteDesignProvider>
<Probe />
<DirtyTracker />
<SiteDesignPanel />
</SiteDesignProvider>
</PageProvider>
</SaveGuardProvider>
</EditorConfigProvider>,
);
h.act(() => { guard!.setLoadState('loaded'); });
// let DirtyTracker record the loaded baseline
h.act(() => {});
expect(guard!.isDirty()).toBe(false);
const ocean = PALETTES.find((p) => p.id === 'ocean')!;
const btn = Array.from(h.container.querySelectorAll<HTMLButtonElement>('.palette-preview')).find((b) => b.textContent?.includes('Ocean'))!;
h.act(() => { btn.click(); });
expect(design!.design.primaryColor).toBe(ocean.design.primaryColor);
expect(design!.design.primaryColor).not.toBe(DEFAULT_SITE_DESIGN.primaryColor);
expect(design!.design.backgroundColor).toBe(ocean.design.backgroundColor);
expect(guard!.isDirty()).toBe(true);
});
test('a palette with fonts sets them; one without leaves the fonts alone', () => {
let design: SiteDesignContextValue | null = null;
const Probe: React.FC = () => { design = useSiteDesign(); return null; };
h = renderEditorHarness();
h.mountChild(<SiteDesignProvider><Probe /><SiteDesignPanel /></SiteDesignProvider>);
const click = (name: string) => h.act(() => {
Array.from(h.container.querySelectorAll<HTMLButtonElement>('.palette-preview')).find((b) => b.textContent?.includes(name))!.click();
});
click('Berry');
expect(design!.design.headingFont).toBe('Playfair Display, serif');
click('Ocean');
expect(design!.design.headingFont).toBe('Playfair Display, serif');
expect(design!.design.primaryColor).toBe('#0e7490');
});
test('palettes are well-formed: 6, unique ids, hex colours', () => {
expect(PALETTES).toHaveLength(6);
expect(new Set(PALETTES.map((p) => p.id)).size).toBe(6);
for (const p of PALETTES) for (const v of Object.values(p.design)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
});
test('Site Design colour fields have no token row (tokens store hex)', () => {
h = renderEditorHarness();
h.mountChild(<SiteDesignProvider><SiteDesignPanel /></SiteDesignProvider>);
expect(h.container.querySelector('.token-swatch-row')).toBeNull();
});
});
describe('user palette choice restyles canvas AND published output', () => {
test('ButtonLink default background follows updateDesign({primaryColor})', () => {
let design: SiteDesignContextValue | null = null;
const Probe: React.FC = () => { design = useSiteDesign(); return null; };
h = renderEditorHarness({
wrap: (tree) => <SiteDesignProvider><Probe /><CanvasTokenStyle />{tree}</SiteDesignProvider>,
});
h.act(() => {
const tree = h.query.parseReactElement(<ButtonLink />).toNodeTree();
h.actions.addNodeTree(tree, 'ROOT');
});
h.act(() => { design!.updateDesign({ primaryColor: '#ff0000' }); });
const canvasCss = document.querySelector('[data-wsb-canvas-tokens]')!.textContent!;
expect(canvasCss).toContain('--wsb-primary:#ff0000');
const serialized = h.getSerialized();
const { html } = exportToHtml(serialized, { design: design!.design });
const head = html.slice(0, html.indexOf('</head>'));
expect(head).toContain('--wsb-primary:#ff0000');
expect(html.slice(html.indexOf('</head>'))).toContain('var(--wsb-primary)');
});
});
+41 -63
View File
@@ -6,77 +6,58 @@ import { useSaveGuard } from '../../state/SaveGuardContext';
import { SerializedTreeNode } from '../../types/sitesmith';
import { FONT_FAMILIES } from '../../constants/presets';
import { AssetPicker } from '../../ui/AssetPicker';
import { inputStyle } from './styles/shared';
import { inputStyle, ColorPickerField } from './styles/shared';
import { PALETTES, paletteUpdates, paletteMatches } from '../../constants/palettes';
type DesignTab = 'basic' | 'advanced';
/* ---------- Color picker with preset swatches ---------- */
const COLOR_SWATCHES = [
'#3b82f6', '#8b5cf6', '#10b981', '#ef4444',
'#f59e0b', '#ec4899', '#06b6d4', '#f97316',
];
const NEUTRAL_SWATCHES = [
'#ffffff', '#f9fafb', '#e5e7eb', '#9ca3af',
'#6b7280', '#374151', '#1f2937', '#111827',
];
/* ---------- Color field ----------
Site tokens are the SOURCE of every `var(--wsb-*)`, so they store hex:
the token row is off (a token referencing itself would be a cycle). */
interface ColorFieldProps {
label: string;
value: string;
onChange: (value: string) => void;
swatches?: string[];
}
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => (
<div className="guided-section">
<label className="guided-section-label">{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width: 32,
height: 32,
padding: 0,
border: '2px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
background: 'none',
}}
/>
<input
type="text"
className="guided-input"
value={value}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{swatches.map((c) => (
<button
key={c}
onClick={() => onChange(c)}
style={{
width: '100%',
aspectRatio: '1',
borderRadius: 'var(--radius-sm)',
border: value === c ? '2px solid var(--color-accent)' : '2px solid var(--color-border)',
background: c,
cursor: 'pointer',
boxShadow: value === c ? '0 0 0 2px var(--color-accent)' : 'none',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
title={c}
/>
))}
</div>
</div>
const ColorField: React.FC<ColorFieldProps> = ({ label, value, onChange }) => (
<ColorPickerField label={label} value={value} onChange={onChange} showTokens={false} />
);
/* ---------- Try a palette ---------- */
const PalettePicker: React.FC = () => {
const { design, updateDesign } = useSiteDesign();
return (
<div className="guided-section">
<label className="guided-section-label">Try a palette</label>
<div className="palette-grid">
{PALETTES.map((p) => {
const active = paletteMatches(p, design);
return (
<button
key={p.id}
type="button"
className={`palette-preview${active ? ' active' : ''}`}
aria-pressed={active}
title={p.name}
onClick={() => updateDesign(paletteUpdates(p))}
>
<span className="palette-dots">
{[p.design.primaryColor, p.design.secondaryColor, p.design.accentColor, p.design.backgroundColor].map((c, i) => (
<span key={i} className="palette-dot" style={{ background: c }} />
))}
</span>
<span className="palette-name">{p.name}</span>
</button>
);
})}
</div>
</div>
);
};
/* ---------- Font dropdown ---------- */
interface FontFieldProps {
@@ -292,6 +273,7 @@ export const SiteDesignPanel: React.FC = () => {
{/* Basic tab */}
{tab === 'basic' && (
<>
<PalettePicker />
<div className="guided-section">
<label className="guided-section-label">Favicon</label>
<AssetPicker
@@ -375,25 +357,21 @@ export const SiteDesignPanel: React.FC = () => {
label="Background Color"
value={design.backgroundColor}
onChange={(v) => updateDesign({ backgroundColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Text Color"
value={design.textColor}
onChange={(v) => updateDesign({ textColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Muted Text Color"
value={design.mutedTextColor}
onChange={(v) => updateDesign({ mutedTextColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<ColorField
label="Border Color"
value={design.borderColor}
onChange={(v) => updateDesign({ borderColor: v })}
swatches={NEUTRAL_SWATCHES}
/>
<FontField
label="Heading Font"
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle, continuousSetProp } from './shared';
import { CollapsibleSection, ArrayPropEditor, smallInputStyle, continuousSetProp, ColorPickerField } from './shared';
import { useLayerFocus } from '../../left/LayerFocusContext';
/* ---------- Shared array-item field editor ----------
@@ -97,16 +97,13 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
// color fields
if (/color/i.test(field) && typeof fieldVal === 'string') {
return (
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: e.target.value };
props[propKey] = updated;
});
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
</div>
<ColorPickerField key={field} label={field} value={fieldVal || '#000000'} onChange={(v) => {
continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (props: any) => {
const updated = [...(props[propKey] || [])];
updated[index] = { ...updated[index], [field]: v };
props[propKey] = updated;
});
}} />
);
}
// long text
@@ -1,5 +1,6 @@
import React, { useCallback, CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import { useSiteDesign } from '../../../state/SiteDesignContext';
import {
BG_COLORS,
RADIUS_PRESETS,
@@ -55,6 +56,7 @@ function opacityPercent(v: unknown): number {
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const { design } = useSiteDesign();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
@@ -62,17 +64,17 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
props.style = {
...props.style,
backgroundColor: bgColor,
color: autoTextColor(bgColor),
color: autoTextColor(bgColor, design),
};
};
// Swatch / hex commit: its own undo step. Native picker drag: one burst.
const setButtonColor = useCallback(
(bgColor: string) => discreteSetProp(actions, selectedId, applyButtonColor(bgColor)),
[actions, selectedId],
[actions, selectedId, design],
);
const setButtonColorContinuous = useCallback(
(bgColor: string) => continuousSetProp(actions, selectedId, 'buttonColor', applyButtonColor(bgColor)),
[actions, selectedId],
[actions, selectedId, design],
);
return (
@@ -98,11 +98,9 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
)}
<div style={sectionGap}>
<ColorPickerField label="Overlay Color" value={nodeProps.overlayColor || '#000000'} onChange={(v) => setPropContinuous('overlayColor', v)} />
<label style={labelStyle}>Overlay ({nodeProps.overlayOpacity || 0}%)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="color" value={nodeProps.overlayColor || '#000000'} onChange={(e) => setPropContinuous('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} />
</div>
<input type="range" min={0} max={100} value={nodeProps.overlayOpacity || 0} onChange={(e) => setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
</div>
</CollapsibleSection>
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { tokenRef } from '../../../utils/tokens';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
StylePanelProps,
@@ -228,7 +229,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
{/* Colors */}
<CollapsibleSection title="Colors">
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => continuousSetProp(actions, selectedId, 'featuredBg', (p: any) => { p.featuredBg = v; })} />
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => continuousSetProp(actions, selectedId, 'featuredBg', (p: any) => { p.featuredBg = v; })} />
{/* Regular (non-featured) card colors -- built into PricingTable's
render/toHtml but previously hard-coded literals with no control
surfacing them. Each falls back to the prior literal when blank. */}
@@ -237,7 +238,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setPropContinuous('subColor', v)} />
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setPropContinuous('featColor', v)} />
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setPropContinuous('checkColor', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setPropContinuous('btnBg', v)} />
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || tokenRef('primary')} onChange={(v) => setPropContinuous('btnBg', v)} />
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setPropContinuous('btnColor', v)} />
</CollapsibleSection>
@@ -1,5 +1,6 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import { tokenRef } from '../../../utils/tokens';
import {
BG_COLORS,
SPACING_PRESETS,
@@ -206,7 +207,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{/* Colors */}
{nodeProps.iconColor !== undefined && (
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setPropContinuous('iconColor', v)} />
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || tokenRef('primary')} onChange={(v) => setPropContinuous('iconColor', v)} />
)}
{nodeProps.iconBgColor !== undefined && (
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setPropContinuous('iconBgColor', v)} />
@@ -215,7 +216,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setPropContinuous('starColor', v)} />
)}
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setPropContinuous('color', v)} />
<ColorPickerField label="Color" value={nodeProps.color || tokenRef('primary')} onChange={(v) => setPropContinuous('color', v)} />
)}
{/* Size */}
@@ -0,0 +1,121 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { ColorPickerField, ColorSwatchGrid, GradientSwatchGrid, autoTextColor } from './shared';
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from '../../../state/SiteDesignContext';
let container: HTMLDivElement | null = null;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => { root = createRoot(container!); root.render(<SiteDesignProvider>{ui}</SiteDesignProvider>); });
}
afterEach(() => {
vi.useRealTimers();
if (!container) return;
act(() => { root.unmount(); });
container.remove();
container = null;
});
const native = () => container!.querySelector<HTMLInputElement>('input[type="color"]')!;
const hexBox = () => container!.querySelector<HTMLInputElement>('input[type="text"]')!;
const chip = () => container!.querySelector('.token-chip');
const swatch = (key: string) => container!.querySelector<HTMLButtonElement>(`button.token-swatch[data-token="${key}"]`)!;
function type(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('ColorPickerField with a token value', () => {
test('native input shows the resolved hex, hex box too, chip names the token', () => {
render(<ColorPickerField label="C" value="var(--wsb-primary)" onChange={() => {}} />);
expect(native().value).toBe('#3b82f6');
expect(hexBox().value).toBe('#3b82f6');
expect(chip()!.textContent).toBe('Primary');
});
test('clicking the Secondary swatch selects the token reference', () => {
const onChange = vi.fn();
render(<ColorPickerField label="C" value="#000000" onChange={onChange} />);
act(() => { swatch('secondary').click(); });
expect(onChange).toHaveBeenCalledWith('var(--wsb-secondary)');
});
test('typing a hex writes the hex and the chip goes away once the value is a hex', () => {
vi.useFakeTimers();
const Host: React.FC = () => {
const [v, setV] = React.useState('var(--wsb-primary)');
return <ColorPickerField label="C" value={v} onChange={(x) => { onChange(x); setV(x); }} />;
};
const onChange = vi.fn();
render(<Host />);
expect(chip()).not.toBeNull();
act(() => { type(hexBox(), '#123456'); });
act(() => { vi.advanceTimersByTime(300); });
expect(onChange).toHaveBeenCalledWith('#123456');
expect(chip()).toBeNull();
expect(native().value).toBe('#123456');
});
test('showTokens={false} renders no token row', () => {
render(<ColorPickerField label="C" value="#abcdef" onChange={() => {}} showTokens={false} />);
expect(container!.querySelector('.token-swatch-row')).toBeNull();
expect(container!.querySelector('.token-swatch')).toBeNull();
});
test('the native picker still reports through onContinuous', () => {
const onChange = vi.fn();
const onContinuous = vi.fn();
render(<ColorPickerField label="C" value="#000000" onChange={onChange} onContinuous={onContinuous} />);
act(() => { type(native(), '#00ff00'); });
expect(onContinuous).toHaveBeenCalledWith('#00ff00');
expect(onChange).not.toHaveBeenCalled();
});
});
describe('ColorSwatchGrid with a token value', () => {
test('offers the token row, shows resolved colour + chip, token pick passes the ref', () => {
const onSelect = vi.fn();
render(<ColorSwatchGrid colors={[{ label: 'Red', value: '#ff0000' }]} activeValue="var(--wsb-accent)" onSelect={onSelect} />);
expect(native().value).toBe('#10b981');
expect(chip()!.textContent).toBe('Accent');
act(() => { swatch('primary').click(); });
expect(onSelect).toHaveBeenCalledWith('var(--wsb-primary)');
});
});
describe('GradientSwatchGrid custom builder', () => {
test('token pick for From writes a gradient that keeps the reference', () => {
const onSelect = vi.fn();
render(<GradientSwatchGrid activeValue="none" onSelect={onSelect} />);
act(() => { Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); });
const rows = container!.querySelectorAll('.token-swatch-row');
expect(rows).toHaveLength(2);
act(() => { rows[0].querySelector<HTMLButtonElement>('[data-token="primary"]')!.click(); });
expect(onSelect).toHaveBeenLastCalledWith('linear-gradient(135deg, var(--wsb-primary) 0%, #764ba2 100%)');
});
test('an existing token gradient re-opens with the resolved colours', () => {
render(<GradientSwatchGrid activeValue="linear-gradient(90deg, var(--wsb-primary) 0%, var(--wsb-secondary) 100%)" onSelect={() => {}} />);
act(() => { Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); });
const natives = Array.from(container!.querySelectorAll<HTMLInputElement>('input[type="color"]')).map((i) => i.value);
expect(natives).toEqual(['#3b82f6', '#8b5cf6']);
});
});
describe('autoTextColor', () => {
test('resolves token refs through the design before the luminance maths', () => {
const design = { ...DEFAULT_SITE_DESIGN, primaryColor: '#fde047' }; // light -> dark text
expect(autoTextColor('var(--wsb-primary)', design)).toBe(autoTextColor('#fde047'));
expect(autoTextColor('var(--wsb-primary)', design)).toBe('#18181b');
expect(autoTextColor('var(--wsb-primary)', DEFAULT_SITE_DESIGN)).toBe(autoTextColor(DEFAULT_SITE_DESIGN.primaryColor));
});
test('unresolvable values fall back to white', () => {
expect(autoTextColor('linear-gradient(red, blue)')).toBe('#ffffff');
});
});
+56 -16
View File
@@ -9,11 +9,18 @@ import {
} from '../../../constants/presets';
import { uploadAsset } from '../../../utils/assets';
import { getPref, setPref } from '../../../utils/local-prefs';
import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/SiteDesignContext';
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf } from '../../../utils/tokens';
import { TokenSwatchRow } from './TokenSwatchRow';
/* ---------- Helper: auto text color for bg ---------- */
export function autoTextColor(bg: string): string {
if (bg.startsWith('#')) {
const hex = bg.replace('#', '');
/* ---------- Helper: auto text color for bg ----------
`bg` may be a token reference (`var(--wsb-primary)`); it is resolved through
`design` first. Anything still not a hex after that (gradients, unknown
vars) gets the white default. */
export function autoTextColor(bg: string, design: SiteDesign = DEFAULT_SITE_DESIGN): string {
const resolved = resolveTokenValue(bg, design);
if (resolved.startsWith('#')) {
const hex = resolved.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
@@ -23,6 +30,24 @@ export function autoTextColor(bg: string): string {
return '#ffffff';
}
/* ---------- Token-aware colour display ----------
A native `<input type=color>` cannot show `var(--wsb-*)` (it renders black),
so linked values display their RESOLVED colour. The stored value stays the
token reference; only picking a custom colour replaces it. */
export function useResolvedColor(value: string | undefined): { resolved: string; nativeValue: string; tokenLabel: string | null } {
const { design } = useSiteDesign();
const v = value || '';
const resolved = resolveTokenValue(v, design);
const key = tokenKeyOf(v);
const tokenLabel = key ? (COLOR_TOKENS.find((t) => t.key === key)?.label ?? key) : null;
const nativeValue = /^#[0-9a-fA-F]{6}$/.test(resolved) ? resolved : '#000000';
return { resolved, nativeValue, tokenLabel };
}
/** Small "linked to a site colour" badge shown beside the hex box. */
export const TokenChip: React.FC<{ label: string | null }> = ({ label }) =>
label ? <span className="token-chip" title={`Linked to site colour: ${label}`}>{label}</span> : null;
/* ---------- Helper: upload to WHP ----------
Thin re-export over the shared `uploadAsset` util (`@/utils/assets`) so
the existing callers importing `uploadToWhp` from here keep working
@@ -57,15 +82,19 @@ interface ColorSwatchGridProps {
/** Native picker drag ticks; wire to a `*Continuous` setter so a drag is one
* undo step. Falls back to `onSelect` when absent. */
onContinuous?: (value: string) => void;
/** Show the site-colour (token) row first. Default true. */
showTokens?: boolean;
}
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous }) => {
const hex = useDebouncedCommit(activeValue || '', onSelect);
export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue);
const hex = useDebouncedCommit(resolved, onSelect);
return (
<div>
{showTokens && <TokenSwatchRow value={activeValue} onSelect={onSelect} />}
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="color"
value={activeValue || '#000000'}
value={nativeValue}
onChange={(e) => (onContinuous || onSelect)(e.target.value)}
style={{ width: 32, height: 28, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
@@ -78,6 +107,7 @@ export const ColorSwatchGrid: React.FC<ColorSwatchGridProps> = ({ colors, active
placeholder="#000000"
style={{ flex: 1, padding: '3px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, fontFamily: 'monospace', boxSizing: 'border-box' as const }}
/>
<TokenChip label={tokenLabel} />
</div>
<div className="preset-grid">
{colors.map((c) => (
@@ -143,7 +173,7 @@ interface GradientSwatchGridProps {
/* Parse "linear-gradient(135deg, #aaa 0%, #bbb 100%)" into parts */
function parseGradient(val: string | undefined): { angle: number; from: string; to: string } {
if (!val || val === 'none') return { angle: 135, from: '#667eea', to: '#764ba2' };
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8})/);
const m = val.match(/linear-gradient\(\s*(\d+)deg\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))\s*(?:\d+%?)?\s*,\s*(#[0-9a-fA-F]{3,8}|var\(--wsb-[a-z-]+\))/);
if (m) return { angle: parseInt(m[1]), from: m[2], to: m[3] };
return { angle: 135, from: '#667eea', to: '#764ba2' };
}
@@ -154,6 +184,8 @@ export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeVa
const [customFrom, setCustomFrom] = useState(parsed.from);
const [customTo, setCustomTo] = useState(parsed.to);
const [customAngle, setCustomAngle] = useState(parsed.angle);
const from = useResolvedColor(customFrom);
const to = useResolvedColor(customTo);
const applyCustomGradient = (from: string, to: string, angle: number) => {
setCustomFrom(from);
@@ -183,29 +215,31 @@ export const GradientSwatchGrid: React.FC<GradientSwatchGridProps> = ({ activeVa
<div style={{ flex: 1 }}>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>From</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
<input type="color" value={from.nativeValue} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
<input type="text" value={customFrom} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
<input type="text" value={from.resolved} onChange={(e) => applyCustomGradient(e.target.value, customTo, customAngle)}
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
</div>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>To</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
<input type="color" value={to.nativeValue} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
<input type="text" value={customTo} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
<input type="text" value={to.resolved} onChange={(e) => applyCustomGradient(customFrom, e.target.value, customAngle)}
style={{ flex: 1, padding: '2px 4px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 3, fontSize: 10, fontFamily: 'monospace', boxSizing: 'border-box' as const }} />
</div>
</div>
</div>
<TokenSwatchRow label="From: site colors" value={customFrom} onSelect={(v) => applyCustomGradient(v, customTo, customAngle)} />
<TokenSwatchRow label="To: site colors" value={customTo} onSelect={(v) => applyCustomGradient(customFrom, v, customAngle)} />
<div>
<label style={{ fontSize: 10, color: '#71717a', display: 'block', marginBottom: 3 }}>Angle: {customAngle}°</label>
<input type="range" min={0} max={360} value={customAngle} onChange={(e) => applyCustomGradient(customFrom, customTo, parseInt(e.target.value))}
style={{ width: '100%' }} />
</div>
{/* Live preview */}
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${customFrom}, ${customTo})`, border: '1px solid #3f3f46' }} />
<div style={{ height: 20, borderRadius: 4, marginTop: 6, background: `linear-gradient(${customAngle}deg, ${from.resolved}, ${to.resolved})`, border: '1px solid #3f3f46' }} />
</div>
)}
{/* Preset swatches */}
@@ -331,16 +365,21 @@ interface ColorPickerFieldProps {
/** Native picker drag ticks; wire to a `*Continuous` setter so a drag is one
* undo step. Falls back to `onChange`. */
onContinuous?: (value: string) => void;
/** Show the site-colour (token) row. Default true; Site Design's own
* colours pass false (they are the token SOURCE and must store hex). */
showTokens?: boolean;
}
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous }) => {
const hex = useDebouncedCommit(value || '', onChange);
export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value, onChange, onContinuous, showTokens = true }) => {
const { resolved, nativeValue, tokenLabel } = useResolvedColor(value);
const hex = useDebouncedCommit(resolved, onChange);
return (
<div style={sectionGap}>
<label style={labelStyle}>{label}</label>
{showTokens && <TokenSwatchRow value={value} onSelect={onChange} />}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="color"
value={value || '#000000'}
value={nativeValue}
onChange={(e) => (onContinuous || onChange)(e.target.value)}
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }}
/>
@@ -353,6 +392,7 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
placeholder="#000000"
style={{ ...inputStyle, flex: 1 }}
/>
<TokenChip label={tokenLabel} />
</div>
</div>
);
+55
View File
@@ -2232,3 +2232,58 @@ body {
color: var(--color-text-muted);
text-align: center;
}
/* #44 palettes */
.token-chip {
flex: none;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
line-height: 16px;
color: var(--color-accent);
background: var(--color-accent-subtle);
border: 1px solid var(--color-accent);
border-radius: 999px;
white-space: nowrap;
}
.palette-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.palette-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
font-size: 10px;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.palette-preview:hover { border-color: var(--color-text-muted); }
.palette-preview.active {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-subtle);
color: var(--color-text);
}
.palette-dots { display: flex; }
.palette-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--color-border);
margin-left: -3px;
}
.palette-dot:first-child { margin-left: 0; }