diff --git a/craft/src/constants/palettes.ts b/craft/src/constants/palettes.ts new file mode 100644 index 0000000..6467e06 --- /dev/null +++ b/craft/src/constants/palettes.ts @@ -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 { + 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]); +} diff --git a/craft/src/panels/right/SiteDesignPanel.palettes.test.tsx b/craft/src/panels/right/SiteDesignPanel.palettes.test.tsx new file mode 100644 index 0000000..8967e75 --- /dev/null +++ b/craft/src/panels/right/SiteDesignPanel.palettes.test.tsx @@ -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 | null = null; + const Probe: React.FC = () => { design = useSiteDesign(); guard = useSaveGuard(); return null; }; + h = renderEditorHarness(); + h.mountChild( + + + + + + + + + + + , + ); + 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('.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(); + const click = (name: string) => h.act(() => { + Array.from(h.container.querySelectorAll('.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(); + 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) => {tree}, + }); + h.act(() => { + const tree = h.query.parseReactElement().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('')); + expect(head).toContain('--wsb-primary:#ff0000'); + expect(html.slice(html.indexOf(''))).toContain('var(--wsb-primary)'); + }); +}); diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index 0bd7903..bf04ae9 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -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 = ({ label, value, onChange, swatches = COLOR_SWATCHES }) => ( -
- -
- 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', - }} - /> - onChange(e.target.value)} - style={{ flex: 1, fontSize: 11, fontFamily: 'monospace' }} - /> -
-
- {swatches.map((c) => ( -
-
+const ColorField: React.FC = ({ label, value, onChange }) => ( + ); +/* ---------- Try a palette ---------- */ + +const PalettePicker: React.FC = () => { + const { design, updateDesign } = useSiteDesign(); + return ( +
+ +
+ {PALETTES.map((p) => { + const active = paletteMatches(p, design); + return ( + + ); + })} +
+
+ ); +}; + /* ---------- Font dropdown ---------- */ interface FontFieldProps { @@ -292,6 +273,7 @@ export const SiteDesignPanel: React.FC = () => { {/* Basic tab */} {tab === 'basic' && ( <> +
{ label="Background Color" value={design.backgroundColor} onChange={(v) => updateDesign({ backgroundColor: v })} - swatches={NEUTRAL_SWATCHES} /> updateDesign({ textColor: v })} - swatches={NEUTRAL_SWATCHES} /> updateDesign({ mutedTextColor: v })} - swatches={NEUTRAL_SWATCHES} /> updateDesign({ borderColor: v })} - swatches={NEUTRAL_SWATCHES} /> - - { - 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 }} /> -
+ { + continuousSetProp(actions, selectedId, `${propKey}:${index}:${field}`, (props: any) => { + const updated = [...(props[propKey] || [])]; + updated[index] = { ...updated[index], [field]: v }; + props[propKey] = updated; + }); + }} /> ); } // long text diff --git a/craft/src/panels/right/styles/ButtonStylePanel.tsx b/craft/src/panels/right/styles/ButtonStylePanel.tsx index 312a43f..a57cf74 100644 --- a/craft/src/panels/right/styles/ButtonStylePanel.tsx +++ b/craft/src/panels/right/styles/ButtonStylePanel.tsx @@ -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 = ({ 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 = ({ 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 ( diff --git a/craft/src/panels/right/styles/HeroStylePanel.tsx b/craft/src/panels/right/styles/HeroStylePanel.tsx index e4a86b3..e243075 100644 --- a/craft/src/panels/right/styles/HeroStylePanel.tsx +++ b/craft/src/panels/right/styles/HeroStylePanel.tsx @@ -98,11 +98,9 @@ export const HeroStylePanel: React.FC = ({ selectedId, nodeProp )}
+ setPropContinuous('overlayColor', v)} /> -
- setPropContinuous('overlayColor', e.target.value)} style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} /> - setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ flex: 1 }} /> -
+ setPropContinuous('overlayOpacity', parseInt(e.target.value))} style={{ width: '100%' }} />
diff --git a/craft/src/panels/right/styles/PricingStylePanel.tsx b/craft/src/panels/right/styles/PricingStylePanel.tsx index 9414296..199cb8f 100644 --- a/craft/src/panels/right/styles/PricingStylePanel.tsx +++ b/craft/src/panels/right/styles/PricingStylePanel.tsx @@ -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 = ({ selectedId, nodeP {/* Colors */} - continuousSetProp(actions, selectedId, 'featuredBg', (p: any) => { p.featuredBg = 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 = ({ selectedId, nodeP setPropContinuous('subColor', v)} /> setPropContinuous('featColor', v)} /> setPropContinuous('checkColor', v)} /> - setPropContinuous('btnBg', v)} /> + setPropContinuous('btnBg', v)} /> setPropContinuous('btnColor', v)} /> diff --git a/craft/src/panels/right/styles/SocialStylePanel.tsx b/craft/src/panels/right/styles/SocialStylePanel.tsx index 048a294..e6446c4 100644 --- a/craft/src/panels/right/styles/SocialStylePanel.tsx +++ b/craft/src/panels/right/styles/SocialStylePanel.tsx @@ -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 = ({ selectedId, nodePr {/* Colors */} {nodeProps.iconColor !== undefined && ( - setPropContinuous('iconColor', v)} /> + setPropContinuous('iconColor', v)} /> )} {nodeProps.iconBgColor !== undefined && ( setPropContinuous('iconBgColor', v)} /> @@ -215,7 +216,7 @@ export const SocialStylePanel: React.FC = ({ selectedId, nodePr setPropContinuous('starColor', v)} /> )} {nodeProps.color !== undefined && typeof nodeProps.color === 'string' && ( - setPropContinuous('color', v)} /> + setPropContinuous('color', v)} /> )} {/* Size */} diff --git a/craft/src/panels/right/styles/color-controls.test.tsx b/craft/src/panels/right/styles/color-controls.test.tsx new file mode 100644 index 0000000..1e137b1 --- /dev/null +++ b/craft/src/panels/right/styles/color-controls.test.tsx @@ -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({ui}); }); +} +afterEach(() => { + vi.useRealTimers(); + if (!container) return; + act(() => { root.unmount(); }); + container.remove(); + container = null; +}); + +const native = () => container!.querySelector('input[type="color"]')!; +const hexBox = () => container!.querySelector('input[type="text"]')!; +const chip = () => container!.querySelector('.token-chip'); +const swatch = (key: string) => container!.querySelector(`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( {}} />); + 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(); + 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 { onChange(x); setV(x); }} />; + }; + const onChange = vi.fn(); + render(); + 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( {}} 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(); + 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(); + 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(); + 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('[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( {}} />); + act(() => { Array.from(container!.querySelectorAll('button')).find((b) => b.textContent?.includes('Custom Gradient'))!.click(); }); + const natives = Array.from(container!.querySelectorAll('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'); + }); +}); diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 8f07cdc..03c912f 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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 `` 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 ? {label} : 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 = ({ colors, activeValue, onSelect, onContinuous }) => { - const hex = useDebouncedCommit(activeValue || '', onSelect); +export const ColorSwatchGrid: React.FC = ({ colors, activeValue, onSelect, onContinuous, showTokens = true }) => { + const { resolved, nativeValue, tokenLabel } = useResolvedColor(activeValue); + const hex = useDebouncedCommit(resolved, onSelect); return (
+ {showTokens && }
(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 = ({ 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 }} /> +
{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 = ({ 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 = ({ activeVa
- applyCustomGradient(e.target.value, customTo, customAngle)} + applyCustomGradient(e.target.value, customTo, customAngle)} style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> - applyCustomGradient(e.target.value, customTo, customAngle)} + 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 }} />
- applyCustomGradient(customFrom, e.target.value, customAngle)} + applyCustomGradient(customFrom, e.target.value, customAngle)} style={{ width: 28, height: 24, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} /> - applyCustomGradient(customFrom, e.target.value, customAngle)} + 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 }} />
+ applyCustomGradient(v, customTo, customAngle)} /> + applyCustomGradient(customFrom, v, customAngle)} />
applyCustomGradient(customFrom, customTo, parseInt(e.target.value))} style={{ width: '100%' }} />
{/* Live preview */} -
+
)} {/* 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 = ({ label, value, onChange, onContinuous }) => { - const hex = useDebouncedCommit(value || '', onChange); +export const ColorPickerField: React.FC = ({ label, value, onChange, onContinuous, showTokens = true }) => { + const { resolved, nativeValue, tokenLabel } = useResolvedColor(value); + const hex = useDebouncedCommit(resolved, onChange); return (
+ {showTokens && }
(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 = ({ label, value placeholder="#000000" style={{ ...inputStyle, flex: 1 }} /> +
); diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 9b67aa5..d15f937 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; }