/**
* 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
? ``
: `${esc(brand.brandName)}`;
const recipient = project.client ? `prepared for ${project.client}` : 'client delivery';
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) => `