/** * Brand theming for the client-facing surfaces: the accent/typography theme * block derived from a brand definition, the brand bar, hero tagline, status * chip and the brand book section. Extracted verbatim from `render.ts` * (roadmap Phase 3d). */ import type { PreviewPortalBrand, PreviewPortalProject, PreviewPortalSurface } from './types.js'; import { esc, escAttr } from './render-primitives.js'; /* ── Brand theming: palette-driven accent, client wordmark/logo, brand book ── Sourced from artifacts/brand-definition.json (discovered onto project.brand). No brand → every helper returns '' and the stock theme renders unchanged. */ /** Surfaces that carry the client's identity (vs the operator's utilitarian view). */ function isClientSurface(surface: PreviewPortalSurface): boolean { return surface === 'preview' || surface === 'client-review'; } /** WCAG relative luminance of a #RRGGBB color. */ function relativeLuminance(hex: string): number { const channel = (i: number) => { const c = parseInt(hex.slice(i, i + 2), 16) / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); } /** * Pick the brand accent: the first palette role (metallic → primary → secondary * → contrast) bright enough to read on the near-black portal background * (luminance ≥ 0.2 ≈ 4.5:1 contrast). All-dark palettes return null and the * stock amber accent stays — readability beats brand fidelity. */ function pickBrandAccent(brand: PreviewPortalBrand): string | null { for (const role of ['metallic', 'primary', 'secondary', 'contrast']) { const color = brand.palette.find((entry) => entry.role === role); if (color && relativeLuminance(color.hex) >= 0.2) return color.hex; } return null; } function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgba(${r},${g},${b},${alpha})`; } /** First numeric CSS weight found in the artifact's free-form weight string. */ function fontWeightOf(weight: string): string | null { const m = /\d{3}/.exec(weight); return m ? m[0] : null; } /** * Reduce a brand font name to the characters a Google-font family legitimately * uses (letters, digits, spaces). This is the one guard for BOTH the Google * Fonts URL and the `:root{}` font-family values: an unsanitized name carrying * `& ; } :` would otherwise corrupt the font-link query string or break out of * the inline ``; } /** Client identity bar above the hero: logo image when discovered, else the * brand name typeset in the wordmark face; right side names the recipient. */ export function renderBrandBar(project: PreviewPortalProject, surface: PreviewPortalSurface): string { const brand = project.brand; if (!brand || !isClientSurface(surface)) return ''; const mark = brand.logoPath ? `${escAttr(brand.brandName)}` : `${esc(brand.brandName)}`; const recipient = project.client ? `prepared for ${project.client}` : 'client delivery'; return `
${mark}${esc(recipient)}
`; } /** Emotional tagline as the hero subline on client surfaces. */ export function renderHeroTagline(project: PreviewPortalProject, surface: PreviewPortalSurface): string { const line = project.brand?.taglines?.emotional; if (!line || !isClientSurface(surface)) return ''; return `

${esc(line)}

`; } /** Review-status chip in the hero eyebrow (preview only; silent while draft). */ export function renderStatusChip(project: PreviewPortalProject, surface: PreviewPortalSurface): string { if (surface !== 'preview' || project.status === 'draft') return ''; const approved = project.status === 'client-approved' || project.status === 'final' || project.status === 'published'; return `${esc(project.status)}`; } /** Prettify a camelCase palette role for the swatch label. */ function roleLabel(role: string): string { return role.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); } /** * The brand-book annex (preview only): palette swatches, typography specimens * rendered in their own faces, and the three taglines — the page doubles as a * mini brand portal the client can keep using after delivery. */ export function renderBrandBook(project: PreviewPortalProject): string { const brand = project.brand; if (!brand) return ''; const swatches = brand.palette .map( (color) => `
${esc(roleLabel(color.role))}
${color.name ? `
${esc(color.name)}
` : ''}
${esc(color.hex)}
`, ) .join(''); const specimens = brand.typography .map((t) => { const weight = fontWeightOf(t.weight); const sample = t.level === 'wordmark' ? brand.brandName : 'Aa Bb Cc 0123'; // Same sanitization as the head theme: a font name with `'`/`;`/`}` would // otherwise corrupt this inline font-family. Show the cleaned name too. const cleanFont = sanitizeFontFamily(t.font); const style = cleanFont ? `font-family:'${cleanFont}',var(--font-body)${weight ? `;font-weight:${weight}` : ''}` : weight ? `font-weight:${weight}` : ''; return `
${esc(t.level)}${esc(sample)}${esc(cleanFont || t.level)}${t.weight ? ` · ${esc(t.weight)}` : ''}
`; }) .join(''); const taglineKinds: Array<[string, string | undefined]> = [ ['functional', brand.taglines?.functional], ['emotional', brand.taglines?.emotional], ['community', brand.taglines?.community], ]; const taglines = taglineKinds.filter(([, line]) => line); const taglineHtml = taglines.length ? `
${taglines .map(([kind, line]) => `
${esc(kind)}
${esc(line as string)}
`) .join('')}
` : ''; return `

Brand

${esc(brand.brandName)} Brand System
${brand.positioning ? `
${esc(brand.positioning)}
` : ''}
${swatches ? `
${swatches}
` : ''} ${specimens ? `
${specimens}
` : ''} ${taglineHtml}
`; }