From 7a58921ba3889d51403498f6388ee03eee14ee2e Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 11:10:30 -0800 Subject: [PATCH] Sitesmith design payload: brand colours carry their published slot (G1) buildDesignPayload numbered brand colours by list position while the page publishes them as --wsb-brand-, so the prompt could name a different colour than the page renders. It now uses publishedBrandSwatches (same rule as the publisher) and sends {name?, hex, slot}. Co-Authored-By: Claude Opus 5.5 --- craft/src/utils/sitesmith-design.test.ts | 22 ++++++++++--------- craft/src/utils/sitesmith-design.ts | 28 +++++++++++++----------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/craft/src/utils/sitesmith-design.test.ts b/craft/src/utils/sitesmith-design.test.ts index 320cdd2..f4782ae 100644 --- a/craft/src/utils/sitesmith-design.test.ts +++ b/craft/src/utils/sitesmith-design.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'vitest'; import { buildDesignPayload } from './sitesmith-design'; +import { brandTokenDecls } from './swatches'; import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext'; import { normalizeUrlLabel, validateAttachment, sniffImageType, formatBytes } from './sitesmith-attachments'; @@ -14,18 +15,19 @@ describe('buildDesignPayload', () => { expect(Object.keys(p)).not.toContain('favicon'); expect(p.brandColors).toBeUndefined(); }); - test('brand swatches become brandColors (max 6, valid hex only, optional)', () => { + test('brand swatches become brandColors with their PUBLISHED slot (exactly the --wsb-brand- the page renders)', () => { const swatches = [ - { label: 'Ink', hex: '#0F172A', brand: true }, { label: 'Not brand', hex: '#ffffff', brand: false }, - { label: 'Bad', hex: 'red', brand: true }, { label: 'A', hex: '#abc', brand: true }, - ...Array.from({ length: 8 }, (_, i) => ({ label: `S${i}`, hex: '#00000' + i, brand: true })), + { id: 'a', label: 'Ink', hex: '#0F172A', brand: true, slot: 3 }, { id: 'b', label: 'Not brand', hex: '#ffffff', brand: false }, + { id: 'c', label: 'Bad', hex: 'red', brand: true, slot: 2 }, { id: 'd', label: 'A', hex: '#abc', brand: true, slot: 1 }, + { id: 'e', label: 'No slot', hex: '#123456', brand: true }, { id: 'f', label: 'Far', hex: '#654321', brand: true, slot: 7 }, + { id: 'g', label: 'Dup', hex: '#111111', brand: true, slot: 3 }, ]; - const p = buildDesignPayload({ ...DEFAULT_SITE_DESIGN, swatches } as any); - const b = p.brandColors as Array<{ name?: string; hex: string }>; - expect(b).toHaveLength(6); - expect(b[0]).toEqual({ name: 'Ink', hex: '#0f172a' }); - expect(b[1].name).toBe('Ab'); - expect(b.every((x) => /^#[0-9a-f]{3,8}$/.test(x.hex))).toBe(true); + const design = { ...DEFAULT_SITE_DESIGN, swatches } as any; + const p = buildDesignPayload(design); + const b = p.brandColors as Array<{ name?: string; hex: string; slot: number }>; + // slot order, only brand + slot 1..6 + valid hex, first per slot wins -- same rule as publishedBrandSwatches + expect(b).toEqual([{ name: 'Ab', hex: '#abc', slot: 1 }, { name: 'Ink', hex: '#0f172a', slot: 3 }]); + expect(b.map((x) => `;--wsb-brand-${x.slot}:${x.hex}`).join('')).toBe(brandTokenDecls(swatches).toLowerCase()); }); }); diff --git a/craft/src/utils/sitesmith-design.ts b/craft/src/utils/sitesmith-design.ts index 9014d65..5f2024c 100644 --- a/craft/src/utils/sitesmith-design.ts +++ b/craft/src/utils/sitesmith-design.ts @@ -1,19 +1,19 @@ /** * The site design block sent with every Sitesmith request, in the shape the backend prompt builder * (sitesmith_design_block in libs/sitesmith-prompts.php) reads: SiteDesign-named keys plus - * `brandColors: [{name?, hex}]` (up to 6, from the colour-system swatches flagged `brand`). + * `brandColors: [{name?, hex, slot}]` (the published brand swatches, slot 1..6 = --wsb-brand-). * The backend re-validates every value; this only keeps obviously unusable values out. */ import type { SiteDesign } from '../state/SiteDesignContext'; +import { publishedBrandSwatches } from './swatches'; -export interface DesignSwatch { label?: string; hex?: string; brand?: boolean } +export interface DesignSwatch { label?: string; hex?: string; brand?: boolean; slot?: number } const KEYS = [ 'primaryColor', 'secondaryColor', 'accentColor', 'backgroundColor', 'textColor', 'mutedTextColor', 'borderColor', 'headingFont', 'bodyFont', 'borderRadius', 'buttonRadius', ] as const; -const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; export function buildDesignPayload(design: SiteDesign & { swatches?: DesignSwatch[] }): Record { const out: Record = {}; @@ -23,17 +23,19 @@ export function buildDesignPayload(design: SiteDesign & { swatches?: DesignSwatc // Font stacks: the backend accepts letters, digits, space, comma, dash and underscore only. out[k] = k.endsWith('Font') ? v.replace(/["']/g, '').trim() : v.trim(); } + // Brand colours go out with the SLOT they are published under (--wsb-brand-, see + // utils/swatches.ts publishedBrandSwatches): the prompt must name the same token the page renders, + // so list position is never used. Same selection rule as the publisher; label = that swatch's label. const sw = (design as { swatches?: unknown }).swatches; - if (Array.isArray(sw)) { - const brand = sw - .filter((s): s is DesignSwatch => !!s && typeof s === 'object' && (s as DesignSwatch).brand === true - && typeof (s as DesignSwatch).hex === 'string' && HEX.test((s as DesignSwatch).hex!.trim())) - .slice(0, 6) - .map((s) => { - const name = typeof s.label === 'string' ? s.label.replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 40) : ''; - return name ? { name, hex: s.hex!.trim().toLowerCase() } : { hex: s.hex!.trim().toLowerCase() }; - }); - if (brand.length) out.brandColors = brand; + const published = publishedBrandSwatches(sw); + if (published.length && Array.isArray(sw)) { + const brand = published.map(({ slot, hex }) => { + const src = (sw as DesignSwatch[]).find((s) => !!s && s.brand === true && s.slot === slot && typeof s.hex === 'string' && s.hex.trim() === hex); + const name = typeof src?.label === 'string' ? src.label.replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 40) : ''; + const h = hex.trim().toLowerCase(); + return name ? { name, hex: h, slot } : { hex: h, slot }; + }); + out.brandColors = brand; } return out; }