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-<slot>, 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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 11:10:30 -08:00
1 parent 3413c7816d
commit 7a58921ba3
2 files changed
+27 -23

No files matched your search

+12 -10
View File
@@ -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-<slot> 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<b>', 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<b>', 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());
});
});
+15 -13
View File
@@ -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-<slot>).
* 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<string, unknown> {
const out: Record<string, unknown> = {};
@@ -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-<slot>, 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;
}