diff --git a/README.md b/README.md index 770f102..81871b1 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ Punch turns one brand website and one to six product pages into a grounded, responsive ecommerce email. It extracts evidence, asks Claude for a semantic -campaign, checks product and claim associations deterministically, then writes -standalone HTML and machine-readable validation artifacts. +campaign, checks product and claim associations deterministically, then renders +it with configurable brand colours and fonts. The result is standalone HTML +and machine-readable validation artifacts. It is an engine and CLI, not an ESP. Punch does not manage contacts, send messages or hide unsupported claims behind a confidence score. @@ -39,28 +40,40 @@ the useful generative part while making commerce facts inspectable: - unknown or conflicted critical facts cannot be promoted to truth; - availability, promotion and selected high-risk claims require source support; - Claude produces semantic blocks, never raw layout HTML; +- website style roles inform a validated brand theme, with explicit overrides + and readable fallbacks; - final HTML passes deterministic accessibility, geometry, resource and compliance-placeholder checks; and - public fetching rejects local/private networks, unsafe redirects, oversized responses and credential-bearing URLs. -## Live showcase - -
-
-
| Blue · desktop | +Dark · mobile | +
|---|---|
![]() |
+ ![]() |
+
Structured promotion code
@@ -17,10 +27,15 @@ Optional:
--trace Write redacted structured stage traces
--json Emit exactly one JSON result on stdout
--force Fail closed unless atomic replacement is supported
- --no-interactive Explicitly disable guided input
+ --no-interactive Never prompt (also implied by --json, CI or piped input)
--help Show this help
--version Show the installed version
Environment:
ANTHROPIC_API_KEY Required for generation; never written to output
+
+Render reuses existing campaign copy and needs no API key or AI call.
+Its validation covers rendering, not fresh product or claim grounding.
+Manual flags override a saved profile, which overrides detected website styles.
+Custom fonts are named with fallbacks; font files are not downloaded or embedded.
`;
diff --git a/src/cli/io.ts b/src/cli/io.ts
new file mode 100644
index 0000000..fb32af5
--- /dev/null
+++ b/src/cli/io.ts
@@ -0,0 +1,61 @@
+import { ExtractionError } from "../extraction/extraction-error.js";
+
+export type CliIo = Readonly<{
+ stdout: (value: string) => void;
+ stderr: (value: string) => void;
+ env: Readonly>;
+ signal: AbortSignal;
+ stdinIsTTY?: boolean;
+ stdoutIsTTY?: boolean;
+ ask?: (question: string) => Promise;
+ openPreview?: (path: string) => Promise;
+}>;
+
+/** Reads one bounded answer and converts EOF, interruption and cancellation to a safe failure. */
+export async function ask(io: CliIo, question: string): Promise {
+ if (io.signal.aborted || !io.ask)
+ throw new ExtractionError("cancelled", false);
+ let answer: string;
+ try {
+ answer = await io.ask(question);
+ } catch {
+ throw new ExtractionError("cancelled", false);
+ }
+ if (io.signal.aborted) throw new ExtractionError("cancelled", false);
+ if (
+ answer.length > 4096 ||
+ /[\u0000-\u0008\u000b-\u001f\u007f]/u.test(answer)
+ ) {
+ io.stderr("Please enter a shorter value without control characters.\n");
+ return ask(io, question);
+ }
+ return answer.trim();
+}
+
+/** Requires explicit agreement before paid generation or final publication. */
+export async function confirm(io: CliIo, question: string): Promise {
+ const answer = await ask(io, `${question} [y/N] `);
+ if (!/^y(?:es)?$/iu.test(answer))
+ throw new ExtractionError("cancelled", false);
+}
+
+/** Allows guided input only in a real interactive terminal, never automation. */
+export function interactiveAllowed(
+ argv: readonly string[],
+ io: CliIo,
+): boolean {
+ const ci = [
+ "CI",
+ "CONTINUOUS_INTEGRATION",
+ "GITHUB_ACTIONS",
+ "BUILD_NUMBER",
+ ].some((key) => Boolean(io.env[key]));
+ return Boolean(
+ io.stdinIsTTY &&
+ io.stdoutIsTTY &&
+ io.ask &&
+ !ci &&
+ !argv.includes("--json") &&
+ !argv.includes("--no-interactive"),
+ );
+}
diff --git a/src/cli/local-files.ts b/src/cli/local-files.ts
new file mode 100644
index 0000000..ff32d68
--- /dev/null
+++ b/src/cli/local-files.ts
@@ -0,0 +1,97 @@
+import { randomUUID } from "node:crypto";
+import { constants } from "node:fs";
+import { link, open, unlink } from "node:fs/promises";
+import { basename, dirname, join, resolve } from "node:path";
+
+import { BrandProfileSchema, type BrandSettings } from "../brand/settings.js";
+import {
+ assertSameDirectory,
+ safeParentIdentity,
+} from "../output/filesystem-safety.js";
+import { CliArgumentError } from "./cli-error.js";
+
+/** Rejects empty paths and terminal-control characters before filesystem use. */
+export function localPath(value: string): string {
+ if (!value.trim() || /[\u0000-\u001f\u007f]/u.test(value)) throw fileError();
+ return resolve(value);
+}
+
+/** Reads bounded regular JSON through a no-follow descriptor. */
+export async function readLocalJson(
+ path: string,
+ limit: number,
+): Promise {
+ const target = localPath(path);
+ try {
+ const identity = await safeParentIdentity(dirname(target));
+ const file = await open(
+ target,
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
+ );
+ try {
+ const before = await file.stat();
+ if (!before.isFile() || before.nlink !== 1 || before.size > limit)
+ throw fileError();
+ const buffer = Buffer.alloc(limit + 1);
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
+ const after = await file.stat();
+ if (
+ bytesRead > limit ||
+ bytesRead !== before.size ||
+ after.size !== before.size ||
+ after.mtimeMs !== before.mtimeMs
+ )
+ throw fileError();
+ await assertSameDirectory(dirname(target), identity);
+ return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
+ } finally {
+ await file.close();
+ }
+ } catch {
+ throw fileError();
+ }
+}
+
+/** Reads only the versioned, strict brand-profile format. */
+export async function readBrandProfile(path: string): Promise {
+ const parsed = BrandProfileSchema.safeParse(await readLocalJson(path, 8192));
+ if (!parsed.success) throw fileError();
+ return parsed.data.settings;
+}
+
+/** Atomically creates a profile without overwriting any existing file or symlink. */
+export async function saveBrandProfile(
+ path: string,
+ settings: BrandSettings,
+): Promise {
+ const target = localPath(path);
+ const profile = BrandProfileSchema.parse({ version: "1", settings });
+ const parent = dirname(target);
+ const staging = join(parent, `.${basename(target)}.punch-${randomUUID()}`);
+ let created = false;
+ try {
+ const identity = await safeParentIdentity(parent);
+ const file = await open(staging, "wx", 0o600);
+ created = true;
+ try {
+ await file.writeFile(`${JSON.stringify(profile, null, 2)}\n`);
+ await file.sync();
+ } finally {
+ await file.close();
+ }
+ await assertSameDirectory(parent, identity);
+ await link(staging, target);
+ } catch {
+ throw fileError();
+ } finally {
+ if (created) await unlink(staging).catch(() => undefined);
+ }
+}
+
+/** Creates a safe error for malformed, oversized, linked or occupied local files. */
+function fileError(): CliArgumentError {
+ return new CliArgumentError(
+ "invalid-file",
+ "Use a bounded regular JSON file and an existing real parent directory. Saving requires a new filename; linked files are refused.",
+ );
+}
diff --git a/src/cli/preview-result.ts b/src/cli/preview-result.ts
new file mode 100644
index 0000000..6f76fed
--- /dev/null
+++ b/src/cli/preview-result.ts
@@ -0,0 +1,80 @@
+import { mkdtemp, realpath, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { resolveBrand } from "../brand/resolve-brand.js";
+import type { GenerateCampaignResult } from "../core/generate-campaign.js";
+import { restyleCampaign } from "../core/render-campaign.js";
+import { writeCampaignOutput } from "../output/write-output.js";
+import { editBrand } from "./guide-brand.js";
+import { ask, type CliIo } from "./io.js";
+import { localPath } from "./local-files.js";
+
+/** Reviews the actual result and restyles it without rerunning extraction or generation. */
+export async function reviewResult(
+ io: CliIo,
+ initial: GenerateCampaignResult,
+): Promise<{ result: GenerateCampaignResult; saveBrandPath?: string | null }> {
+ let result = initial;
+ let saveBrandPath: string | null | undefined;
+ let temporary: string | undefined;
+ let revision = 0;
+ try {
+ for (;;) {
+ const choice = await ask(
+ io,
+ "\n4. Preview: (p) open email, (b) adjust branding, (s) save profile, Enter to export: ",
+ );
+ if (!choice)
+ return {
+ result,
+ ...(saveBrandPath !== undefined ? { saveBrandPath } : {}),
+ };
+ if (choice === "b") {
+ const changes = await editBrand(io, result.brand ?? resolveBrand());
+ result = await restyleCampaign(result, changes);
+ io.stderr(
+ "Re-rendered the same campaign. No AI call or copy change.\n",
+ );
+ } else if (choice === "s") {
+ const path = await ask(
+ io,
+ "New profile filename (blank cancels saving): ",
+ );
+ saveBrandPath = path ? localPath(path) : null;
+ } else if (choice === "p") {
+ temporary ??= await mkdtemp(
+ join(await realpath(tmpdir()), "punch-preview-"),
+ );
+ await openResultPreview(
+ io,
+ result,
+ join(temporary, `revision-${++revision}`),
+ );
+ } else io.stderr("Choose p, b, s, or Enter.\n");
+ }
+ } finally {
+ if (temporary) await rm(temporary, { recursive: true, force: true });
+ }
+}
+
+/** Writes one fresh preview and opens it only after the user selected that action. */
+async function openResultPreview(
+ io: CliIo,
+ result: GenerateCampaignResult,
+ destination: string,
+): Promise {
+ const output = await writeCampaignOutput(result, destination);
+ const path = join(output, "email.html");
+ io.stderr(
+ `Preview: ${path}\nTemporary previews are removed when this session ends.\n`,
+ );
+ if (io.openPreview)
+ await io
+ .openPreview(path)
+ .catch(() =>
+ io.stderr(
+ "Could not open a browser automatically. Open the preview path above.\n",
+ ),
+ );
+}
diff --git a/src/cli/run-cli.ts b/src/cli/run-cli.ts
index 28b8d85..e1af69f 100644
--- a/src/cli/run-cli.ts
+++ b/src/cli/run-cli.ts
@@ -1,22 +1,19 @@
import { ZodError } from "zod";
-import { generateCampaign } from "../core/generate-campaign.js";
import { ExtractionError } from "../extraction/extraction-error.js";
import { GenerationError } from "../generation/generation-error.js";
-import { OutputError, writeCampaignOutput } from "../output/index.js";
-import { createAnthropicProvider } from "../providers/anthropic.js";
-import { CliArgumentError, parseCliArguments } from "./arguments.js";
+import { OutputError } from "../output/index.js";
+import { BrandStyleError } from "../brand/settings.js";
+import { PublicFetchError } from "../extraction/http/index.js";
+import { CliArgumentError } from "./arguments.js";
+import { resolveInvocation } from "./guide-command.js";
+import { executeCommand } from "./execute-command.js";
+import type { CliIo } from "./io.js";
+export type { CliIo } from "./io.js";
import { CLI_HELP } from "./help.js";
export const PUNCH_VERSION = "0.1.0";
-export type CliIo = Readonly<{
- stdout: (value: string) => void;
- stderr: (value: string) => void;
- env: Readonly>;
- signal: AbortSignal;
-}>;
-
type CliFailure = Readonly<{
code: string;
message: string;
@@ -30,7 +27,7 @@ export async function runCli(
): Promise {
let json = argv.includes("--json");
try {
- const command = parseCliArguments(argv);
+ const { command, guided } = await resolveInvocation(argv, io);
if (command.kind === "help") {
io.stdout(CLI_HELP);
return 0;
@@ -40,22 +37,8 @@ export async function runCli(
return 0;
}
json = command.json;
- const apiKey = io.env.ANTHROPIC_API_KEY?.trim();
- if (!apiKey) {
- throw new CliArgumentError(
- "missing-arguments",
- "ANTHROPIC_API_KEY is required.",
- );
- }
- const result = await generateCampaign(command.input, {
- provider: createAnthropicProvider({ apiKey }),
- signal: io.signal,
- trace: command.trace,
- });
- const output = await writeCampaignOutput(result, command.output, {
- force: command.force,
- });
- writeSuccess(io, json, output);
+ const output = await executeCommand(command, io, guided);
+ writeSuccess(io, json, output, command.kind);
return 0;
} catch (error) {
const failure = normaliseCliFailure(error);
@@ -65,12 +48,21 @@ export async function runCli(
}
/** Writes one stable success result without mixing stdout modes. */
-function writeSuccess(io: CliIo, json: boolean, output: string): void {
+function writeSuccess(
+ io: CliIo,
+ json: boolean,
+ output: string,
+ kind: "generate" | "render",
+): void {
if (json) {
- io.stdout(`${JSON.stringify({ ok: true, status: "valid", output })}\n`);
+ io.stdout(
+ `${JSON.stringify({ ok: true, status: "valid", output, validationScope: kind === "render" ? "render-only" : "generation-and-render" })}\n`,
+ );
return;
}
- io.stdout(`Generated a validated campaign in ${output}\n`);
+ io.stdout(
+ `${kind === "render" ? "Rendered" : "Generated"} a validated campaign in ${output}\n`,
+ );
}
/** Writes exactly one JSON failure or one plain stderr diagnostic. */
@@ -94,11 +86,19 @@ function normaliseCliFailure(error: unknown): CliFailure {
retryable: false,
};
}
- if (error instanceof ExtractionError || error instanceof GenerationError) {
+ if (
+ error instanceof ExtractionError ||
+ error instanceof GenerationError ||
+ error instanceof BrandStyleError ||
+ error instanceof PublicFetchError
+ ) {
return {
code: error.code,
message: error.message,
- retryable: error.retryable,
+ retryable:
+ "retryable" in error
+ ? error.retryable
+ : ["network", "timeout", "dns-failure"].includes(error.code),
};
}
if (error instanceof OutputError) {
diff --git a/src/cli/terminal.ts b/src/cli/terminal.ts
new file mode 100644
index 0000000..86d08ae
--- /dev/null
+++ b/src/cli/terminal.ts
@@ -0,0 +1,51 @@
+import { spawn } from "node:child_process";
+import { createInterface, type Interface } from "node:readline/promises";
+import { pathToFileURL } from "node:url";
+
+/** Lazily owns readline only when the CLI has selected interactive mode. */
+export function createTerminalPrompts(controller: AbortController) {
+ let readline: Interface | undefined;
+ let pending = false;
+ return {
+ ask: async (question: string): Promise => {
+ if (!readline) {
+ readline = createInterface({
+ input: process.stdin,
+ output: process.stderr,
+ terminal: Boolean(process.stdin.isTTY && process.stderr.isTTY),
+ });
+ readline.on("SIGINT", () => controller.abort());
+ readline.on("close", () => {
+ if (pending) controller.abort();
+ });
+ }
+ pending = true;
+ try {
+ return await readline.question(question, { signal: controller.signal });
+ } finally {
+ pending = false;
+ }
+ },
+ close: (): void => readline?.close(),
+ };
+}
+
+/** Opens only a caller-requested, generated preview using arguments rather than a shell. */
+export async function openPreview(path: string): Promise {
+ const command =
+ process.platform === "darwin"
+ ? "open"
+ : process.platform === "win32"
+ ? "explorer.exe"
+ : "xdg-open";
+ await new Promise((resolve, reject) => {
+ const child = spawn(command, [pathToFileURL(path).href], {
+ shell: false,
+ stdio: "ignore",
+ });
+ child.once("error", reject);
+ child.once("exit", (code) =>
+ code === 0 ? resolve() : reject(new Error("Preview opener failed.")),
+ );
+ });
+}
diff --git a/src/core/generate-campaign.ts b/src/core/generate-campaign.ts
index e8b7030..8d23326 100644
--- a/src/core/generate-campaign.ts
+++ b/src/core/generate-campaign.ts
@@ -4,13 +4,18 @@ import type {
GenerateCampaignInput,
ProductEvidence,
} from "./schemas/index.js";
-import { runCampaignPipeline } from "./run-campaign-pipeline.js";
+import {
+ runCampaignPipeline,
+ type CampaignPipelineRun,
+} from "./run-campaign-pipeline.js";
import type { PunchProvider } from "../providers/anthropic.js";
import { renderCampaignHtml } from "../rendering/index.js";
import { validateRenderedCampaign } from "../validation/index.js";
import type { GenerationUsage } from "../providers/index.js";
+import type { BrandReviewer, ResolvedBrand } from "../brand/settings.js";
export type GenerateCampaignOptions = Readonly<{
+ reviewBrand?: BrandReviewer;
provider: PunchProvider;
signal?: AbortSignal;
trace?: boolean;
@@ -19,6 +24,7 @@ export type GenerateCampaignOptions = Readonly<{
}>;
export type CampaignValidation = Readonly<{
+ scope?: "generation-and-render" | "render-only";
valid: true;
checks: ReadonlyArray>;
}>;
@@ -33,6 +39,7 @@ export type CampaignTrace = Readonly<{
}>;
export type GenerateCampaignResult = Readonly<{
+ brand?: ResolvedBrand;
campaign: Campaign;
html: string;
validation: CampaignValidation;
@@ -46,6 +53,7 @@ export async function generateCampaign(
options: GenerateCampaignOptions,
): Promise {
const run = await runCampaignPipeline(input, {
+ ...(options.reviewBrand ? { reviewBrand: options.reviewBrand } : {}),
model: options.provider.textModel,
...(options.signal ? { signal: options.signal } : {}),
...(options.callTimeoutMs !== undefined
@@ -56,7 +64,10 @@ export async function generateCampaign(
: {}),
});
const campaign = run.generation.finalCampaign;
- const html = await renderCampaignHtml(campaign);
+ const html = await renderCampaignHtml(
+ campaign,
+ run.extraction.brand?.settings,
+ );
const rendered = validateRenderedCampaign(campaign, html);
const checks = [
{ id: "campaign-grounding", passed: true as const },
@@ -69,22 +80,24 @@ export async function generateCampaign(
return {
campaign,
+ ...(run.extraction.brand ? { brand: run.extraction.brand } : {}),
html,
- validation: { valid: true, checks },
+ validation: { valid: true, scope: "generation-and-render", checks },
usage: run.generation.usage,
- ...(options.trace
- ? {
- trace: {
- brandProfile: run.extraction.context.brand,
- productProfiles: run.extraction.context.products,
- draft: run.generation.draft,
- critique: run.generation.critique,
- ...(run.generation.revisedCampaign
- ? { revisedCampaign: run.generation.revisedCampaign }
- : {}),
- promptVersions: run.generation.promptVersions,
- },
- }
+ ...(options.trace ? { trace: campaignTrace(run) } : {}),
+ };
+}
+
+/** Selects only the approved redacted fields for an opt-in generation trace. */
+function campaignTrace(run: CampaignPipelineRun): CampaignTrace {
+ return {
+ brandProfile: run.extraction.context.brand,
+ productProfiles: run.extraction.context.products,
+ draft: run.generation.draft,
+ critique: run.generation.critique,
+ ...(run.generation.revisedCampaign
+ ? { revisedCampaign: run.generation.revisedCampaign }
: {}),
+ promptVersions: run.generation.promptVersions,
};
}
diff --git a/src/core/render-campaign.ts b/src/core/render-campaign.ts
new file mode 100644
index 0000000..3787149
--- /dev/null
+++ b/src/core/render-campaign.ts
@@ -0,0 +1,68 @@
+import { resolveBrand } from "../brand/resolve-brand.js";
+import {
+ BRAND_KEYS,
+ parseBrandSettings,
+ type BrandSettings,
+} from "../brand/settings.js";
+import { aggregateModelUsage } from "../providers/model-usage.js";
+import { renderCampaignHtml } from "../rendering/render-campaign-html.js";
+import { validateRenderedCampaign } from "../validation/render-validation.js";
+import type { GenerateCampaignResult } from "./generate-campaign.js";
+import { CampaignSchema } from "./schemas/campaign.js";
+
+/** Renders existing semantic content without a model, network fetch, or grounding claim. */
+export async function renderCampaign(
+ input: unknown,
+ settings: BrandSettings = {},
+): Promise {
+ const campaign = CampaignSchema.parse(input);
+ const brand = resolveBrand({}, settings);
+ const html = await renderCampaignHtml(campaign, brand.settings);
+ const rendered = validateRenderedCampaign(campaign, html);
+ return {
+ campaign,
+ brand,
+ html,
+ validation: {
+ valid: true,
+ scope: "render-only",
+ checks: rendered.checks.map((check) => ({
+ id: `render-${check.id}`,
+ passed: true,
+ })),
+ },
+ usage: aggregateModelUsage([]),
+ };
+}
+
+/** Restyles the same in-memory generated campaign, retaining its generation proof and usage. */
+export async function restyleCampaign(
+ result: GenerateCampaignResult,
+ settings: BrandSettings,
+): Promise {
+ const changes = parseBrandSettings(settings);
+ const rendered = await renderCampaign(result.campaign, {
+ ...result.brand?.settings,
+ ...changes,
+ });
+ if (rendered.brand && result.brand) {
+ for (const key of BRAND_KEYS) {
+ if (changes[key] === undefined)
+ rendered.brand.sources[key] = result.brand.sources[key];
+ }
+ }
+ return {
+ ...result,
+ html: rendered.html,
+ brand: rendered.brand!,
+ validation: {
+ ...result.validation,
+ checks: [
+ ...result.validation.checks.filter(
+ (check) => !check.id.startsWith("render-"),
+ ),
+ ...rendered.validation.checks,
+ ],
+ },
+ };
+}
diff --git a/src/core/run-campaign-pipeline.ts b/src/core/run-campaign-pipeline.ts
index 754d323..e0990a6 100644
--- a/src/core/run-campaign-pipeline.ts
+++ b/src/core/run-campaign-pipeline.ts
@@ -5,9 +5,11 @@ import {
import { runGeneration, type GenerationRun } from "../generation/index.js";
import type { TextModel } from "../providers/index.js";
import type { PublicFetchSession } from "../extraction/http/index.js";
+import type { BrandReviewer } from "../brand/settings.js";
/** Internal dependencies for extraction followed by semantic generation. */
export type CampaignPipelineOptions = Readonly<{
+ reviewBrand?: BrandReviewer;
model: TextModel;
signal?: AbortSignal;
fetchSession?: PublicFetchSession;
@@ -28,6 +30,7 @@ export async function runCampaignPipeline(
): Promise {
const signal = options.signal ?? new AbortController().signal;
const extraction = await extractGenerationContext(input, {
+ ...(options.reviewBrand ? { reviewBrand: options.reviewBrand } : {}),
model: options.model,
signal,
...(options.fetchSession ? { fetchSession: options.fetchSession } : {}),
diff --git a/src/core/schemas/input.ts b/src/core/schemas/input.ts
index 204fb78..e7ff1fd 100644
--- a/src/core/schemas/input.ts
+++ b/src/core/schemas/input.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { BrandSettingsSchema } from "../../brand/settings.js";
import {
CodeTextSchema,
@@ -20,6 +21,7 @@ export const OfferInputSchema = z.strictObject({
});
const generateCampaignInputBase = {
+ brand: BrandSettingsSchema.optional(),
website: HttpUrlSchema,
products: z.array(HttpUrlSchema).min(1).max(6),
instructions: LongTextSchema.optional(),
diff --git a/src/extraction/brand-styles.ts b/src/extraction/brand-styles.ts
index a4509b3..c7f3539 100644
--- a/src/extraction/brand-styles.ts
+++ b/src/extraction/brand-styles.ts
@@ -1,4 +1,11 @@
import postcss from "postcss";
+import type { BrandStyleEvidence } from "../brand/settings.js";
+import {
+ collectStyleRoles,
+ resolveStyleRoles,
+ type SourcedStyleCandidate,
+ type StyleRoleCandidate,
+} from "./style-role-candidates.js";
import type { EvidenceRef } from "../core/schemas/index.js";
import type { CssSource, HtmlSource } from "./contracts.js";
@@ -38,6 +45,7 @@ const MAX_DECLARATION_VALUE_BYTES = 4_096;
const MAX_STYLE_VALUES_PER_SOURCE = 32;
export type BrandStyles = Readonly<{
+ roles: BrandStyleEvidence;
colours: string[];
fonts: string[];
colourEvidence: EvidenceRef[];
@@ -45,6 +53,7 @@ export type BrandStyles = Readonly<{
}>;
type SourceStyles = Readonly<{
+ roles: StyleRoleCandidate[];
colours: string[];
fonts: string[];
reference: EvidenceRef;
@@ -65,6 +74,7 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles {
const fonts: string[] = [];
const colourEvidence: EvidenceRef[] = [];
const fontEvidence: EvidenceRef[] = [];
+ const candidates: SourcedStyleCandidate[] = [];
for (const source of sources) {
const extracted = extractSourceStyles(source);
if (!extracted) {
@@ -72,6 +82,12 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles {
}
colours.push(...extracted.colours);
fonts.push(...extracted.fonts);
+ candidates.push(
+ ...extracted.roles.map((role) => ({
+ ...role,
+ evidence: { url: source.url, field: source.field },
+ })),
+ );
if (extracted.colours.length > 0) {
colourEvidence.push(extracted.reference);
}
@@ -80,6 +96,7 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles {
}
}
return {
+ roles: resolveStyleRoles(candidates),
colours: unique(colours).slice(0, 8),
fonts: unique(fonts).slice(0, 8),
colourEvidence: uniqueByJson(colourEvidence).slice(0, 8),
@@ -130,7 +147,7 @@ function collectStyleValues(css: string): Omit {
appendBounded(fonts, extractFonts(declaration.value));
}
});
- return { colours, fonts };
+ return { colours, fonts, roles: collectStyleRoles(root) };
}
/** Reports whether a declaration value is safe for bounded token extraction. */
@@ -178,7 +195,7 @@ function inlineCssSources(
if (style) {
sources.push({
url: source.finalUrl,
- css: `x{${style}}`,
+ css: `${elementName(element)}{${style}}`,
field: styleField(sources.length),
});
}
diff --git a/src/extraction/contracts.ts b/src/extraction/contracts.ts
index caecbeb..7066ee9 100644
--- a/src/extraction/contracts.ts
+++ b/src/extraction/contracts.ts
@@ -6,6 +6,11 @@ import type {
} from "../core/schemas/index.js";
import type { ModelUsage, TextModel } from "../providers/index.js";
import type { PublicFetchSession } from "./http/index.js";
+import type {
+ BrandReviewer,
+ BrandStyleEvidence,
+ ResolvedBrand,
+} from "../brand/settings.js";
export type ExtractionModelCall = Readonly<{
stage: "extract-brand";
@@ -18,11 +23,13 @@ export type ExtractionUsage = Readonly<{
}>;
export type ExtractionResult = Readonly<{
+ brand?: ResolvedBrand;
context: GenerationContext;
usage: ExtractionUsage;
}>;
export type ExtractionOptions = Readonly<{
+ reviewBrand?: BrandReviewer;
model?: TextModel;
signal?: AbortSignal;
fetchSession?: PublicFetchSession;
@@ -52,6 +59,7 @@ export type CssSource = Readonly<{
}>;
export type DeterministicBrandExtraction = Readonly<{
+ styleRoles: BrandStyleEvidence;
evidence: BrandEvidence;
segments: readonly SourceSegment[];
stylesheetUrls: readonly string[];
diff --git a/src/extraction/extract-brand.ts b/src/extraction/extract-brand.ts
index ff0878c..b005df6 100644
--- a/src/extraction/extract-brand.ts
+++ b/src/extraction/extract-brand.ts
@@ -72,6 +72,7 @@ export function extractBrand(
return {
evidence,
+ styleRoles: styles.roles,
segments: buildSourceSegments(document),
stylesheetUrls: discoverStylesheetUrls(document, source.finalUrl),
};
diff --git a/src/extraction/extract-generation-context.ts b/src/extraction/extract-generation-context.ts
index 19b400e..880d970 100644
--- a/src/extraction/extract-generation-context.ts
+++ b/src/extraction/extract-generation-context.ts
@@ -37,6 +37,12 @@ import {
type PublicFetchSession,
} from "./http/index.js";
import { applyBrandFallback } from "./model-fallback.js";
+import { resolveBrand } from "../brand/resolve-brand.js";
+import {
+ BrandStyleError,
+ parseBrandSettings,
+ type ResolvedBrand,
+} from "../brand/settings.js";
type ProductSlot = Readonly<{
productId: ProductId;
@@ -60,12 +66,15 @@ export async function extractGenerationContext(
try {
return await runExtraction(parsed, options, session, signal);
} catch (error) {
- if (error instanceof ExtractionError || error instanceof PublicFetchError) {
+ if (signal.aborted) throw new ExtractionError("cancelled", false);
+ if (
+ error instanceof ExtractionError ||
+ error instanceof PublicFetchError ||
+ error instanceof BrandStyleError
+ ) {
throw error;
}
throw new ExtractionError("invalid-source", false);
- } finally {
- session.dispose();
}
}
@@ -76,15 +85,17 @@ async function runExtraction(
session: PublicFetchSession,
signal: AbortSignal,
): Promise {
- const deterministic = await extractDeterministicSources(
- parsed,
- session,
- signal,
- );
+ const deterministic = await readAndDisposeSources(parsed, session, signal);
const productEvidence = deterministic.products.map(
(extraction) => extraction.evidence,
);
assertMinimumProductEvidence(productEvidence);
+ const resolvedBrand = await reviewBrandStyles(
+ deterministic.brand,
+ parsed,
+ options,
+ signal,
+ );
const calls: ExtractionModelCall[] = [];
const brand = await applyBrandFallback(
deterministic.brand.evidence,
@@ -94,7 +105,38 @@ async function runExtraction(
calls,
);
const context = parseContext(parsed, brand, productEvidence);
- return { context, usage: { total: aggregateUsage(calls), calls } };
+ return {
+ context,
+ brand: resolvedBrand,
+ usage: { total: aggregateUsage(calls), calls },
+ };
+}
+
+/** Releases network timers exactly once before human review or model work begins. */
+async function readAndDisposeSources(
+ input: GenerateCampaignInput,
+ session: PublicFetchSession,
+ signal: AbortSignal,
+): Promise {
+ try {
+ return await extractDeterministicSources(input, session, signal);
+ } finally {
+ session.dispose();
+ }
+}
+
+/** Reviews deterministic style evidence before any optional model call. */
+async function reviewBrandStyles(
+ brand: DeterministicBrandExtraction,
+ input: GenerateCampaignInput,
+ options: ExtractionOptions,
+ signal: AbortSignal,
+): Promise {
+ const resolved = resolveBrand(brand.styleRoles, input.brand);
+ if (!options.reviewBrand) return resolved;
+ const overrides = parseBrandSettings(await options.reviewBrand(resolved));
+ assertExtractionNotAborted(signal);
+ return resolveBrand(brand.styleRoles, { ...input.brand, ...overrides });
}
/** Fetches and parses all deterministic brand and product evidence. */
diff --git a/src/extraction/http/response-policy.ts b/src/extraction/http/response-policy.ts
index 58ed7d3..01cbaca 100644
--- a/src/extraction/http/response-policy.ts
+++ b/src/extraction/http/response-policy.ts
@@ -4,9 +4,7 @@ import type { TransportResponse } from "./node-transport.js";
export type ResourceKind = "html" | "stylesheet";
export type PermittedMediaType =
- | "text/html"
- | "application/xhtml+xml"
- | "text/css";
+ "text/html" | "application/xhtml+xml" | "text/css";
/** Validates bounded raw headers and contradictory response framing. */
export function validateHeaders(
diff --git a/src/extraction/style-role-candidates.ts b/src/extraction/style-role-candidates.ts
new file mode 100644
index 0000000..ba66229
--- /dev/null
+++ b/src/extraction/style-role-candidates.ts
@@ -0,0 +1,172 @@
+import type { Declaration, Root, Rule } from "postcss";
+
+import {
+ CompleteBrandSettingsSchema,
+ type BrandSettingKey,
+ type BrandStyleEvidence,
+} from "../brand/settings.js";
+
+export type StyleRoleCandidate = {
+ key: BrandSettingKey;
+ value: string;
+ rank: number;
+};
+export type SourcedStyleCandidate = StyleRoleCandidate & {
+ evidence: { url: string; field: string };
+};
+
+const VARIABLE_ROLES: readonly [BrandSettingKey, RegExp][] = [
+ [
+ "primaryColour",
+ /^--(?:(?:brand|color|colour)-)?(?:primary|accent)(?:-color|-colour)?$/iu,
+ ],
+ [
+ "backgroundColour",
+ /^--(?:(?:color|colour)-)?(?:background|bg)(?:-color|-colour)?$/iu,
+ ],
+ [
+ "textColour",
+ /^--(?:(?:color|colour)-)?(?:text|foreground)(?:-color|-colour)?$/iu,
+ ],
+ [
+ "headingFont",
+ /^--(?:font(?:-family)?-heading|heading-font(?:-family)?)$/iu,
+ ],
+ ["bodyFont", /^--(?:font(?:-family)?-body|body-font(?:-family)?)$/iu],
+];
+
+/** Returns only unconditional CSS rules; viewport/hover/dark-mode variants are not guesses. */
+function plainRule(declaration: Declaration): Rule | undefined {
+ const parent = declaration.parent;
+ return parent?.type === "rule" &&
+ parent.parent?.type === "root" &&
+ !/:(?!root\b)/iu.test(parent.selector)
+ ? parent
+ : undefined;
+}
+
+/** Resolves a short local variable chain without following imports or evaluating CSS. */
+function resolveValue(
+ value: string,
+ variables: ReadonlyMap,
+): string {
+ let current = value.trim();
+ for (let depth = 0; depth < 4; depth += 1) {
+ const variable = /^var\((--[a-z\d_-]+)\)$/iu.exec(current);
+ if (!variable) return current;
+ current = variables.get(variable[1]!)?.trim() ?? "";
+ }
+ return "";
+}
+
+/** Normalises only complete opaque hex or integer RGB colour declarations. */
+function cssColour(value: string): string {
+ const short = /^#([\da-f]{3})$/iu.exec(value);
+ if (short)
+ return `#${[...short[1]!].map((digit) => digit.repeat(2)).join("")}`;
+ const rgb =
+ /^rgb\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*\)$/iu.exec(
+ value,
+ );
+ if (!rgb) return value;
+ const channels = rgb.slice(1).map(Number);
+ return channels.every((channel) => channel <= 255)
+ ? `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`
+ : "";
+}
+
+/** Retains a validated family or colour, never executable CSS. */
+function roleValue(key: BrandSettingKey, value: string): string | undefined {
+ const candidate = key.endsWith("Font")
+ ? value
+ .split(",")[0]!
+ .trim()
+ .replace(/^(['"])(.*)\1$/u, "$2")
+ : cssColour(value);
+ const parsed = CompleteBrandSettingsSchema.shape[key].safeParse(candidate);
+ return parsed.success ? parsed.data : undefined;
+}
+
+/** Assigns semantic roles only to explicit tokens and recognisable page elements. */
+function declarationRole(
+ declaration: Declaration,
+ rule: Rule,
+): [BrandSettingKey, number] | undefined {
+ const property = declaration.prop.toLowerCase();
+ const root = /^(?:\s*(?::root|html)\s*,?)+$/u.test(rule.selector);
+ const variable = root
+ ? VARIABLE_ROLES.find(([, pattern]) => pattern.test(property))
+ : undefined;
+ if (variable) return [variable[0], 3];
+ const page = /^(?:\s*(?:body|html|:root)\s*,?)+$/u.test(rule.selector);
+ const heading = /^(?:\s*h[1-6]\s*,?)+$/u.test(rule.selector);
+ const action =
+ /^(?:\s*(?:button|\.btn|\.button|\.cta|\.button-primary|\.btn-primary)\s*,?)+$/u.test(
+ rule.selector,
+ );
+ if (page && ["background", "background-color"].includes(property))
+ return ["backgroundColour", 2];
+ if (page && property === "color") return ["textColour", 2];
+ if (action && ["background", "background-color"].includes(property))
+ return ["primaryColour", 2];
+ if (heading && property === "font-family") return ["headingFont", 2];
+ if (page && property === "font-family") return ["bodyFont", 2];
+ return undefined;
+}
+
+/** Collects bounded role candidates from the already-parsed inert stylesheet. */
+export function collectStyleRoles(root: Root): StyleRoleCandidate[] {
+ const variables = new Map();
+ root.walkDecls((declaration) => {
+ const rule = plainRule(declaration);
+ if (
+ rule &&
+ /^(?:\s*(?::root|html)\s*,?)+$/u.test(rule.selector) &&
+ declaration.prop.startsWith("--") &&
+ declaration.value.length <= 4096 &&
+ variables.size < 128
+ ) {
+ variables.set(declaration.prop, declaration.value);
+ }
+ });
+ const candidates: StyleRoleCandidate[] = [];
+ root.walkDecls((declaration) => {
+ const rule = plainRule(declaration);
+ if (!rule || declaration.value.length > 4096 || candidates.length >= 64)
+ return;
+ const role = declarationRole(declaration, rule);
+ if (!role) return;
+ const value = roleValue(
+ role[0],
+ resolveValue(declaration.value, variables),
+ );
+ if (value !== undefined)
+ candidates.push({ key: role[0], value, rank: role[1] });
+ });
+ return candidates;
+}
+
+/** Omits conflicting top-ranked roles instead of picking a colour by source order. */
+export function resolveStyleRoles(
+ candidates: readonly SourcedStyleCandidate[],
+): BrandStyleEvidence {
+ const result: BrandStyleEvidence = {};
+ for (const key of Object.keys(
+ CompleteBrandSettingsSchema.shape,
+ ) as BrandSettingKey[]) {
+ const matching = candidates.filter((candidate) => candidate.key === key);
+ const rank = Math.max(...matching.map((candidate) => candidate.rank));
+ const best = matching.filter((candidate) => candidate.rank === rank);
+ if (
+ best.length === 0 ||
+ new Set(best.map((candidate) => candidate.value)).size !== 1
+ )
+ continue;
+ result[key] = {
+ value: best[0]!.value,
+ evidence: best[0]!.evidence,
+ confidence: rank >= 3 ? "explicit" : "semantic",
+ };
+ }
+ return result;
+}
diff --git a/src/index.ts b/src/index.ts
index 047ec9a..b4e0c4c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -67,6 +67,17 @@ export {
} from "./output/index.js";
export { renderCampaignHtml } from "./rendering/index.js";
+export { renderCampaign, restyleCampaign } from "./core/render-campaign.js";
+export { resolveBrand } from "./brand/resolve-brand.js";
+export {
+ BrandSettingsSchema,
+ BrandProfileSchema,
+ ResolvedBrandSchema,
+ BrandStyleError,
+ type BrandSettings,
+ type CompleteBrandSettings,
+ type ResolvedBrand,
+} from "./brand/settings.js";
export {
CAMPAIGN_CLAIM_ISSUE_CODES,
diff --git a/src/output/artifact-builder.ts b/src/output/artifact-builder.ts
index 2e91124..4603d46 100644
--- a/src/output/artifact-builder.ts
+++ b/src/output/artifact-builder.ts
@@ -50,6 +50,10 @@ function campaignDocument(result: GenerateCampaignResult): CampaignDocument {
goal: result.campaign.goal,
productIds: [...new Set(productIds)],
campaign: result.campaign,
+ ...(result.brand ? { brand: result.brand } : {}),
+ ...(result.validation.scope
+ ? { validationScope: result.validation.scope }
+ : {}),
};
}
diff --git a/src/output/contracts.ts b/src/output/contracts.ts
index af7a374..4084806 100644
--- a/src/output/contracts.ts
+++ b/src/output/contracts.ts
@@ -5,6 +5,7 @@ import type {
} from "../core/generate-campaign.js";
import type { Campaign, ProductId } from "../core/schemas/index.js";
import type { GenerationUsage } from "../providers/index.js";
+import type { ResolvedBrand } from "../brand/settings.js";
export const ARTIFACT_SCHEMA_VERSION = "0.1.0";
export const TRACE_SCHEMA_VERSION = "0.1.0";
@@ -25,6 +26,8 @@ export type ArtifactDescriptor = Readonly<{
}>;
export type CampaignDocument = Readonly<{
+ brand?: ResolvedBrand;
+ validationScope?: "generation-and-render" | "render-only";
generator: "punch";
artifactSchemaVersion: string;
status: "valid";
diff --git a/src/output/filesystem-safety.ts b/src/output/filesystem-safety.ts
new file mode 100644
index 0000000..d553ef7
--- /dev/null
+++ b/src/output/filesystem-safety.ts
@@ -0,0 +1,53 @@
+import { lstat, realpath, stat } from "node:fs/promises";
+import { join, parse, relative, sep } from "node:path";
+import { OutputError } from "./output-error.js";
+
+/** Resolves a real parent and rejects a symlink as its final component. */
+export async function safeParentIdentity(
+ parent: string,
+): Promise> {
+ try {
+ await assertNoLinkedAncestors(parent);
+ const info = await lstat(parent, { bigint: true });
+ if (!info.isDirectory() || info.isSymbolicLink()) {
+ throw new OutputError("unsafe-output-path");
+ }
+ const real = await realpath(parent);
+ const actual = await stat(real, { bigint: true });
+ return { real, dev: actual.dev, ino: actual.ino };
+ } catch (error) {
+ if (error instanceof OutputError) {
+ throw error;
+ }
+ throw new OutputError("invalid-output-path");
+ }
+}
+
+/** Rejects a symlink or non-directory anywhere in the existing parent chain. */
+async function assertNoLinkedAncestors(parent: string): Promise {
+ const root = parse(parent).root;
+ const segments = relative(root, parent).split(sep).filter(Boolean);
+ let current = root;
+ for (const segment of segments) {
+ current = join(current, segment);
+ const info = await lstat(current);
+ if (!info.isDirectory() || info.isSymbolicLink()) {
+ throw new OutputError("unsafe-output-path");
+ }
+ }
+}
+
+/** Rechecks parent identity immediately before atomic publication. */
+export async function assertSameDirectory(
+ parent: string,
+ expected: Readonly<{ real: string; dev: bigint; ino: bigint }>,
+): Promise {
+ const actual = await safeParentIdentity(parent);
+ if (
+ actual.real !== expected.real ||
+ actual.dev !== expected.dev ||
+ actual.ino !== expected.ino
+ ) {
+ throw new OutputError("unsafe-output-path");
+ }
+}
diff --git a/src/output/write-output.ts b/src/output/write-output.ts
index 97b0bee..d39e3e4 100644
--- a/src/output/write-output.ts
+++ b/src/output/write-output.ts
@@ -1,23 +1,10 @@
-import { randomUUID } from "node:crypto";
-import {
- lstat,
- mkdir,
- realpath,
- rename,
- rm,
- stat,
- writeFile,
-} from "node:fs/promises";
import {
- basename,
- dirname,
- isAbsolute,
- join,
- parse,
- relative,
- resolve,
- sep,
-} from "node:path";
+ safeParentIdentity,
+ assertSameDirectory,
+} from "./filesystem-safety.js";
+import { randomUUID } from "node:crypto";
+import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
+import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import type { GenerateCampaignResult } from "../core/generate-campaign.js";
import { buildOutputBundle } from "./artifact-builder.js";
@@ -29,6 +16,16 @@ export type WriteOutputOptions = Readonly<{
cwd?: string;
}>;
+/** Checks an intended destination without creating files or reserving the path. */
+export async function assertOutputAvailable(
+ output: string,
+ force = false,
+): Promise {
+ const destination = resolveOutput(output, process.cwd());
+ await safeParentIdentity(dirname(destination));
+ await assertDestinationMissing(destination, force);
+}
+
/** Atomically publishes a complete result into one previously absent directory. */
export async function writeCampaignOutput(
result: GenerateCampaignResult,
@@ -75,41 +72,6 @@ function resolveOutput(output: string, cwd: string): string {
return destination;
}
-/** Resolves a real parent and rejects a symlink as its final component. */
-async function safeParentIdentity(
- parent: string,
-): Promise> {
- try {
- await assertNoLinkedAncestors(parent);
- const info = await lstat(parent, { bigint: true });
- if (!info.isDirectory() || info.isSymbolicLink()) {
- throw new OutputError("unsafe-output-path");
- }
- const real = await realpath(parent);
- const actual = await stat(real, { bigint: true });
- return { real, dev: actual.dev, ino: actual.ino };
- } catch (error) {
- if (error instanceof OutputError) {
- throw error;
- }
- throw new OutputError("invalid-output-path");
- }
-}
-
-/** Rejects a symlink or non-directory anywhere in the existing parent chain. */
-async function assertNoLinkedAncestors(parent: string): Promise {
- const root = parse(parent).root;
- const segments = relative(root, parent).split(sep).filter(Boolean);
- let current = root;
- for (const segment of segments) {
- current = join(current, segment);
- const info = await lstat(current);
- if (!info.isDirectory() || info.isSymbolicLink()) {
- throw new OutputError("unsafe-output-path");
- }
- }
-}
-
/** Rejects every existing destination and fails closed for forced replacement. */
async function assertDestinationMissing(
destination: string,
@@ -160,21 +122,6 @@ function safeRelativePath(value: string): boolean {
);
}
-/** Rechecks parent identity immediately before atomic publication. */
-async function assertSameDirectory(
- parent: string,
- expected: Readonly<{ real: string; dev: bigint; ino: bigint }>,
-): Promise {
- const actual = await safeParentIdentity(parent);
- if (
- actual.real !== expected.real ||
- actual.dev !== expected.dev ||
- actual.ino !== expected.ino
- ) {
- throw new OutputError("unsafe-output-path");
- }
-}
-
/** Removes only the unguessable staging directory owned by this invocation. */
async function removeStaging(staging: string): Promise {
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
diff --git a/src/rendering/blocks/body-paragraph.tsx b/src/rendering/blocks/body-paragraph.tsx
index 2484d31..2709dd2 100644
--- a/src/rendering/blocks/body-paragraph.tsx
+++ b/src/rendering/blocks/body-paragraph.tsx
@@ -1,6 +1,7 @@
+import { useRenderStyles } from "../render-style-context.js";
import type { BodyParagraphBlock } from "../../core/schemas/index.js";
import { renderSafeInlineMarkdown } from "../safe-inline-markdown.js";
-import { bodySectionCellStyle, bodyTextStyle } from "../styles.js";
+
import { BlockFrame } from "./shared.js";
type BodyParagraphProps = {
@@ -9,6 +10,7 @@ type BodyParagraphProps = {
/** Renders one paragraph through Punch's restricted inline Markdown path. */
export function BodyParagraph({ block }: BodyParagraphProps) {
+ const { bodySectionCellStyle, bodyTextStyle } = useRenderStyles();
return (
+ <>
{block.heading === undefined ? null : (
{block.heading}
@@ -33,6 +25,21 @@ export function ClosingCta({ block }: CtaBlockProps) {
{block.body}
)}
+ >
+ );
+}
+
+/** Renders a closing region with one or two schema-validated actions. */
+export function ClosingCta({ block }: CtaBlockProps) {
+ const { centeredSectionCellStyle } = useRenderStyles();
+ const actionWidth = `${100 / block.actions.length}%`;
+ return (
+
+
{block.heading === undefined ? null : (
@@ -62,6 +60,7 @@ function DiscountContent({ block }: DiscountCodeProps) {
/** Renders only the explicit fields carried by a promotion-code block. */
export function DiscountCode({ block }: DiscountCodeProps) {
+ const { sectionCellStyle, discountPanelStyle } = useRenderStyles();
return (
@@ -76,6 +69,7 @@ function ProductFeatureCopy({ block }: ProductFeatureProps) {
/** Renders one complete featured-product presentation table. */
function ProductFeaturePanel({ block }: ProductFeatureProps) {
+ const { imageFreeFeaturePanelStyle, featurePanelStyle } = useRenderStyles();
return (
) {
+ const { cardContentStyle } = useRenderStyles();
const copyHeight = EMAIL_THEME.geometry.productCopyHeight[columns];
return (
@@ -231,6 +234,7 @@ export function ProductCard({
imageWidth,
product,
}: ProductCardProps) {
+ const { imageFreeCardStyle, cardStyle } = useRenderStyles();
return (
{
+ const candidate = tint(canvas, accent, amount);
+ return (contrastRatio(ink, candidate) ?? 0) >= 4.5 ? candidate : canvas;
+ };
+ const card = surface(0.04);
+ const promotion = surface(0.08);
+ const link = [canvas, card, promotion].every(
+ (bg) => (contrastRatio(accent, bg) ?? 0) >= 4.5,
+ )
+ ? accent
+ : ink;
+ return {
+ accent,
+ link,
+ canvas,
+ card,
+ promotion,
+ code: canvas,
+ primary: ink,
+ body: ink,
+ compliance: ink,
+ page: tint(canvas, ink, 0.05),
+ border: tint(canvas, ink, 0.18),
+ promotionBorder: tint(canvas, accent, 0.3),
+ buttonText: readableInk(accent),
+ };
+}
+
+/** Creates an isolated render theme; no global style state is changed. */
+export function createBrandTheme(settings: CompleteBrandSettings): RenderTheme {
+ return {
+ colours: brandColours(settings),
+ fonts: {
+ body: fontStack(settings.bodyFont),
+ display: fontStack(settings.headingFont),
+ },
+ geometry: EMAIL_THEME.geometry,
+ typography: EMAIL_THEME.typography,
+ };
+}
diff --git a/src/rendering/commerce-styles.ts b/src/rendering/commerce-styles.ts
index 538f644..8fa4a09 100644
--- a/src/rendering/commerce-styles.ts
+++ b/src/rendering/commerce-styles.ts
@@ -1,134 +1,175 @@
import type { CSSProperties } from "react";
-import { EMAIL_THEME } from "./render-theme.js";
+import type { RenderTheme } from "./brand-theme.js";
-export const productNameStyle = {
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.display,
- fontSize: `${EMAIL_THEME.typography.product}px`,
+/** Creates productNameStyle without changing shared render state. */
+export const productNameStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.display,
+ fontSize: `${theme.typography.product}px`,
fontWeight: 700,
lineHeight: "28px",
margin: "0 0 10px",
-} satisfies CSSProperties;
+});
-export const productPriceStyle = {
- color: EMAIL_THEME.colours.primary,
- fontSize: `${EMAIL_THEME.typography.body}px`,
+/** Creates productPriceStyle without changing shared render state. */
+export const productPriceStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontSize: `${theme.typography.body}px`,
fontWeight: 700,
lineHeight: "22px",
margin: "12px 0 0",
-} satisfies CSSProperties;
+});
-export const cardStyle = {
- backgroundColor: EMAIL_THEME.colours.card,
- border: `1px solid ${EMAIL_THEME.colours.border}`,
+/** Creates cardStyle without changing shared render state. */
+export const cardStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.card,
+ border: `1px solid ${theme.colours.border}`,
borderCollapse: "separate",
borderRadius: "12px",
overflow: "hidden",
width: "100%",
-} satisfies CSSProperties;
+});
-export const imageFreeCardStyle = {
- ...cardStyle,
- borderTop: `4px solid ${EMAIL_THEME.colours.accent}`,
-} satisfies CSSProperties;
+/** Creates imageFreeCardStyle without changing shared render state. */
+export const imageFreeCardStyle = (theme: RenderTheme): CSSProperties => ({
+ ...cardStyle(theme),
+ borderTop: `4px solid ${theme.colours.accent}`,
+});
-export const cardContentStyle = {
+/** Creates cardContentStyle without changing shared render state. */
+export const cardContentStyle = (): CSSProperties => ({
padding: "18px",
-} satisfies CSSProperties;
+});
-export const productCopyCellStyle = {
+/** Creates productCopyCellStyle without changing shared render state. */
+export const productCopyCellStyle = (): CSSProperties => ({
verticalAlign: "top",
-} satisfies CSSProperties;
+});
-export const featurePanelStyle = {
- backgroundColor: EMAIL_THEME.colours.card,
- border: `1px solid ${EMAIL_THEME.colours.border}`,
+/** Creates featurePanelStyle without changing shared render state. */
+export const featurePanelStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.card,
+ border: `1px solid ${theme.colours.border}`,
borderCollapse: "separate",
borderRadius: "12px",
overflow: "hidden",
width: "100%",
-} satisfies CSSProperties;
-
-export const imageFreeFeaturePanelStyle = {
- ...featurePanelStyle,
- borderTop: `5px solid ${EMAIL_THEME.colours.accent}`,
-} satisfies CSSProperties;
-
-export const buttonTableStyle = {
+});
+
+/** Creates imageFreeFeaturePanelStyle without changing shared render state. */
+export const imageFreeFeaturePanelStyle = (
+ theme: RenderTheme,
+): CSSProperties => ({
+ ...featurePanelStyle(theme),
+ borderTop: `5px solid ${theme.colours.accent}`,
+});
+
+/** Creates buttonTableStyle without changing shared render state. */
+export const buttonTableStyle = (): CSSProperties => ({
borderCollapse: "separate",
margin: "20px auto 0",
-} satisfies CSSProperties;
+});
-export const buttonCellStyle = {
- backgroundColor: EMAIL_THEME.colours.accent,
+/** Creates buttonCellStyle without changing shared render state. */
+export const buttonCellStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.accent,
borderRadius: "8px",
- height: `${EMAIL_THEME.geometry.ctaHeight}px`,
+ height: `${theme.geometry.ctaHeight}px`,
textAlign: "center",
-} satisfies CSSProperties;
+});
-export const buttonLinkStyle = {
+/** Creates buttonLinkStyle without changing shared render state. */
+export const buttonLinkStyle = (theme: RenderTheme): CSSProperties => ({
boxSizing: "border-box",
- color: EMAIL_THEME.colours.buttonText,
+ color: theme.colours.buttonText,
display: "inline-block",
- fontSize: `${EMAIL_THEME.typography.button}px`,
+ fontSize: `${theme.typography.button}px`,
fontWeight: 700,
- lineHeight: `${EMAIL_THEME.geometry.ctaLineHeight}px`,
- minHeight: `${EMAIL_THEME.geometry.ctaHeight}px`,
- padding: `${EMAIL_THEME.geometry.ctaVerticalPadding}px 22px`,
+ lineHeight: `${theme.geometry.ctaLineHeight}px`,
+ minHeight: `${theme.geometry.ctaHeight}px`,
+ padding: `${theme.geometry.ctaVerticalPadding}px 22px`,
textDecoration: "none",
-} satisfies CSSProperties;
+});
-export const compactButtonTableStyle = {
- ...buttonTableStyle,
+/** Creates compactButtonTableStyle without changing shared render state. */
+export const compactButtonTableStyle = (): CSSProperties => ({
+ ...buttonTableStyle(),
width: "100%",
-} satisfies CSSProperties;
+});
-export const compactButtonLinkStyle = {
- ...buttonLinkStyle,
+/** Creates compactButtonLinkStyle without changing shared render state. */
+export const compactButtonLinkStyle = (theme: RenderTheme): CSSProperties => ({
+ ...buttonLinkStyle(theme),
paddingLeft: "12px",
paddingRight: "12px",
width: "100%",
-} satisfies CSSProperties;
+});
-export const discountPanelStyle = {
- backgroundColor: EMAIL_THEME.colours.promotion,
- border: `1px solid ${EMAIL_THEME.colours.promotionBorder}`,
+/** Creates discountPanelStyle without changing shared render state. */
+export const discountPanelStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.promotion,
+ border: `1px solid ${theme.colours.promotionBorder}`,
borderCollapse: "separate",
borderRadius: "12px",
width: "100%",
-} satisfies CSSProperties;
+});
-export const discountCodeStyle = {
- backgroundColor: EMAIL_THEME.colours.code,
- border: `1px dashed ${EMAIL_THEME.colours.accent}`,
+/** Creates discountCodeStyle without changing shared render state. */
+export const discountCodeStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.code,
+ border: `1px dashed ${theme.colours.accent}`,
borderRadius: "6px",
- color: EMAIL_THEME.colours.primary,
+ color: theme.colours.primary,
display: "inline-block",
- fontSize: `${EMAIL_THEME.typography.discountCode}px`,
+ fontSize: `${theme.typography.discountCode}px`,
fontWeight: 700,
letterSpacing: "2px",
lineHeight: "26px",
marginTop: "16px",
padding: "10px 16px",
-} satisfies CSSProperties;
+});
-export const complianceStyle = {
- borderTop: `1px solid ${EMAIL_THEME.colours.border}`,
- color: EMAIL_THEME.colours.compliance,
- fontSize: `${EMAIL_THEME.typography.compliance}px`,
+/** Creates complianceStyle without changing shared render state. */
+export const complianceStyle = (theme: RenderTheme): CSSProperties => ({
+ borderTop: `1px solid ${theme.colours.border}`,
+ color: theme.colours.compliance,
+ fontSize: `${theme.typography.compliance}px`,
lineHeight: "18px",
padding: "24px 40px 32px",
textAlign: "center",
-} satisfies CSSProperties;
+});
-export const complianceParagraphStyle = {
+/** Creates complianceParagraphStyle without changing shared render state. */
+export const complianceParagraphStyle = (): CSSProperties => ({
margin: "0 0 8px",
-} satisfies CSSProperties;
+});
-export const complianceLinkStyle = {
- color: EMAIL_THEME.colours.compliance,
- fontSize: `${EMAIL_THEME.typography.compliance}px`,
+/** Creates complianceLinkStyle without changing shared render state. */
+export const complianceLinkStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.compliance,
+ fontSize: `${theme.typography.compliance}px`,
lineHeight: "18px",
textDecoration: "underline",
-} satisfies CSSProperties;
+});
+
+export const commerceStyleFactories = {
+ productNameStyle,
+ productPriceStyle,
+ cardStyle,
+ imageFreeCardStyle,
+ cardContentStyle,
+ productCopyCellStyle,
+ featurePanelStyle,
+ imageFreeFeaturePanelStyle,
+ buttonTableStyle,
+ buttonCellStyle,
+ buttonLinkStyle,
+ compactButtonTableStyle,
+ compactButtonLinkStyle,
+ discountPanelStyle,
+ discountCodeStyle,
+ complianceStyle,
+ complianceParagraphStyle,
+ complianceLinkStyle,
+};
diff --git a/src/rendering/email-document.tsx b/src/rendering/email-document.tsx
index ce8e047..b04e3ea 100644
--- a/src/rendering/email-document.tsx
+++ b/src/rendering/email-document.tsx
@@ -1,10 +1,7 @@
+import { useRenderStyles } from "./render-style-context.js";
import type { Campaign } from "../core/schemas/index.js";
import { DispatchBlock } from "./dispatch-block.js";
-import {
- complianceLinkStyle,
- complianceParagraphStyle,
- complianceStyle,
-} from "./commerce-styles.js";
+
import {
COMPLIANCE_VERSION,
EMAIL_WIDTH,
@@ -12,14 +9,7 @@ import {
RENDER_VERSION,
UNSUBSCRIBE_PLACEHOLDER,
} from "./render-contract.js";
-import {
- containerStyle,
- outerTableStyle,
- pageStyle,
- preheaderStyle,
- RESPONSIVE_CSS,
- shellCellStyle,
-} from "./styles.js";
+import { RESPONSIVE_CSS } from "./styles.js";
type EmailDocumentProps = {
readonly campaign: Campaign;
@@ -27,6 +17,8 @@ type EmailDocumentProps = {
/** Renders Punch-owned compliance chrome after all generated blocks. */
function ComplianceFooter() {
+ const { complianceStyle, complianceParagraphStyle, complianceLinkStyle } =
+ useRenderStyles();
return (
@@ -49,6 +41,7 @@ function ComplianceFooter() {
/** Renders the fixed-width campaign table and owned compliance footer. */
function CampaignContainer({ campaign }: EmailDocumentProps) {
+ const { containerStyle } = useRenderStyles();
return (
diff --git a/src/rendering/render-campaign-html.tsx b/src/rendering/render-campaign-html.tsx
index 7aba9bc..ec0fa9c 100644
--- a/src/rendering/render-campaign-html.tsx
+++ b/src/rendering/render-campaign-html.tsx
@@ -4,15 +4,27 @@ import { CampaignSchema } from "../core/schemas/campaign.js";
import { assertRenderedCampaign } from "../validation/render-validation.js";
import { EmailDocument } from "./email-document.js";
import { assertNoReservedPlaceholders } from "./render-contract.js";
+import { resolveBrand } from "../brand/resolve-brand.js";
+import type { BrandSettings } from "../brand/settings.js";
+import { BrandStyleProvider } from "./render-style-context.js";
/** Validates unknown campaign input and renders standalone HTML in memory. */
-export async function renderCampaignHtml(input: unknown): Promise {
+export async function renderCampaignHtml(
+ input: unknown,
+ brand: BrandSettings = {},
+): Promise {
const campaign = CampaignSchema.parse(input);
+ const resolved = resolveBrand({}, brand);
assertNoReservedPlaceholders(campaign);
- const html = await render( , {
- pretty: false,
- });
+ const html = await render(
+
+
+ ,
+ {
+ pretty: false,
+ },
+ );
assertRenderedCampaign(campaign, html);
return html;
}
diff --git a/src/rendering/render-contract.ts b/src/rendering/render-contract.ts
index 722178f..2081869 100644
--- a/src/rendering/render-contract.ts
+++ b/src/rendering/render-contract.ts
@@ -15,12 +15,7 @@ export const UNSUBSCRIBE_PLACEHOLDER = "{{unsubscribe_url}}";
export const PHYSICAL_ADDRESS_PLACEHOLDER = "{{physical_address}}";
export type RenderImageRole =
- | "feature"
- | "grid-2"
- | "grid-3"
- | "grid-4"
- | "hero"
- | "logo";
+ "feature" | "grid-2" | "grid-3" | "grid-4" | "hero" | "logo";
const RESERVED_PLACEHOLDERS = [
UNSUBSCRIBE_PLACEHOLDER,
diff --git a/src/rendering/render-style-context.tsx b/src/rendering/render-style-context.tsx
new file mode 100644
index 0000000..1f41deb
--- /dev/null
+++ b/src/rendering/render-style-context.tsx
@@ -0,0 +1,49 @@
+import {
+ createContext,
+ useContext,
+ type CSSProperties,
+ type ReactNode,
+} from "react";
+
+import {
+ DEFAULT_BRAND_SETTINGS,
+ type CompleteBrandSettings,
+} from "../brand/settings.js";
+import { createBrandTheme } from "./brand-theme.js";
+import { baseStyleFactories } from "./styles.js";
+import { commerceStyleFactories } from "./commerce-styles.js";
+
+const factories = { ...baseStyleFactories, ...commerceStyleFactories };
+type RenderStyles = Readonly<{ [K in keyof typeof factories]: CSSProperties }>;
+
+/** Creates the style set once for this document, never in process-global mutable state. */
+function createStyles(settings: CompleteBrandSettings): RenderStyles {
+ const theme = createBrandTheme(settings);
+ return Object.fromEntries(
+ Object.entries(factories).map(([key, factory]) => [key, factory(theme)]),
+ ) as RenderStyles;
+}
+
+const RenderStyleContext = createContext(
+ createStyles(DEFAULT_BRAND_SETTINGS),
+);
+
+/** Supplies isolated styles to all React email blocks in one render. */
+export function BrandStyleProvider({
+ settings,
+ children,
+}: {
+ settings: CompleteBrandSettings;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Reads the current document's style set without recomputing its theme. */
+export function useRenderStyles(): RenderStyles {
+ return useContext(RenderStyleContext);
+}
diff --git a/src/rendering/safe-inline-markdown.tsx b/src/rendering/safe-inline-markdown.tsx
index 1ea5809..43c177c 100644
--- a/src/rendering/safe-inline-markdown.tsx
+++ b/src/rendering/safe-inline-markdown.tsx
@@ -4,7 +4,17 @@ import {
tokeniseSafeInlineMarkdown,
type SafeInlineMarkdownToken,
} from "../core/inline-markdown.js";
-import { inlineLinkStyle } from "./styles.js";
+import { useRenderStyles } from "./render-style-context.js";
+
+/** Renders a safe Markdown link with this document's accessible brand colour. */
+function InlineLink({ href, text }: { href: string; text: string }) {
+ const { inlineLinkStyle } = useRenderStyles();
+ return (
+
+ {text}
+
+ );
+}
/** Renders one restricted inline Markdown token without raw HTML. */
function renderToken(token: SafeInlineMarkdownToken, key: number): ReactNode {
@@ -17,16 +27,7 @@ function renderToken(token: SafeInlineMarkdownToken, key: number): ReactNode {
if (token.kind === "text") {
return token.text;
}
- return (
-
- {token.text}
-
- );
+ return ;
}
/** Converts the supported inline Markdown subset to safely escaped React nodes. */
diff --git a/src/rendering/styles.ts b/src/rendering/styles.ts
index a647950..7468789 100644
--- a/src/rendering/styles.ts
+++ b/src/rendering/styles.ts
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
-import { EMAIL_THEME } from "./render-theme.js";
+import type { RenderTheme } from "./brand-theme.js";
export const RESPONSIVE_CSS = `
body, table, td, a {
@@ -50,38 +50,43 @@ body, table, td, a {
}
`;
-export const pageStyle = {
+/** Creates pageStyle without changing shared render state. */
+export const pageStyle = (theme: RenderTheme): CSSProperties => ({
WebkitTextSizeAdjust: "100%",
- backgroundColor: EMAIL_THEME.colours.page,
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.body,
+ backgroundColor: theme.colours.page,
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.body,
margin: "0",
padding: "0",
textSizeAdjust: "100%",
-} satisfies CSSProperties;
+});
-export const outerTableStyle = {
- backgroundColor: EMAIL_THEME.colours.page,
+/** Creates outerTableStyle without changing shared render state. */
+export const outerTableStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.page,
borderCollapse: "collapse",
width: "100%",
-} satisfies CSSProperties;
+});
-export const shellCellStyle = {
+/** Creates shellCellStyle without changing shared render state. */
+export const shellCellStyle = (): CSSProperties => ({
padding: "32px 16px",
-} satisfies CSSProperties;
+});
-export const containerStyle = {
- backgroundColor: EMAIL_THEME.colours.canvas,
- border: `1px solid ${EMAIL_THEME.colours.border}`,
+/** Creates containerStyle without changing shared render state. */
+export const containerStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.canvas,
+ border: `1px solid ${theme.colours.border}`,
borderCollapse: "separate",
borderRadius: "14px",
boxShadow: "0 12px 32px rgba(47, 37, 31, 0.08)",
maxWidth: "600px",
overflow: "hidden",
width: "100%",
-} satisfies CSSProperties;
+});
-export const preheaderStyle = {
+/** Creates preheaderStyle without changing shared render state. */
+export const preheaderStyle = (): CSSProperties => ({
color: "transparent",
display: "none",
fontSize: "1px",
@@ -90,121 +95,167 @@ export const preheaderStyle = {
maxWidth: "0",
opacity: 0,
overflow: "hidden",
-} satisfies CSSProperties;
+});
-export const sectionCellStyle = {
+/** Creates sectionCellStyle without changing shared render state. */
+export const sectionCellStyle = (): CSSProperties => ({
padding: "24px 40px",
-} satisfies CSSProperties;
+});
-export const compactSectionCellStyle = {
+/** Creates compactSectionCellStyle without changing shared render state. */
+export const compactSectionCellStyle = (): CSSProperties => ({
padding: "24px 40px",
textAlign: "center",
-} satisfies CSSProperties;
+});
-export const centeredSectionCellStyle = {
+/** Creates centeredSectionCellStyle without changing shared render state. */
+export const centeredSectionCellStyle = (): CSSProperties => ({
padding: "36px 40px 40px",
textAlign: "center",
-} satisfies CSSProperties;
+});
-export const heroSectionCellStyle = {
- backgroundColor: EMAIL_THEME.colours.card,
+/** Creates heroSectionCellStyle without changing shared render state. */
+export const heroSectionCellStyle = (theme: RenderTheme): CSSProperties => ({
+ backgroundColor: theme.colours.card,
padding: "44px 40px",
textAlign: "center",
-} satisfies CSSProperties;
+});
-export const headingSectionCellStyle = {
+/** Creates headingSectionCellStyle without changing shared render state. */
+export const headingSectionCellStyle = (): CSSProperties => ({
padding: "32px 40px 8px",
-} satisfies CSSProperties;
+});
-export const bodySectionCellStyle = {
+/** Creates bodySectionCellStyle without changing shared render state. */
+export const bodySectionCellStyle = (): CSSProperties => ({
padding: "0 40px 24px",
-} satisfies CSSProperties;
+});
-export const productSectionCellStyle = {
+/** Creates productSectionCellStyle without changing shared render state. */
+export const productSectionCellStyle = (): CSSProperties => ({
padding: "16px 40px 24px",
-} satisfies CSSProperties;
+});
-export const wordmarkStyle = {
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.display,
- fontSize: `${EMAIL_THEME.typography.wordmark}px`,
+/** Creates wordmarkStyle without changing shared render state. */
+export const wordmarkStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.display,
+ fontSize: `${theme.typography.wordmark}px`,
fontWeight: 700,
lineHeight: "32px",
textDecoration: "none",
-} satisfies CSSProperties;
+});
-export const imageStyle = {
+/** Creates imageStyle without changing shared render state. */
+export const imageStyle = (): CSSProperties => ({
border: "0",
display: "block",
height: "auto",
maxWidth: "100%",
outline: "none",
textDecoration: "none",
-} satisfies CSSProperties;
+});
-export const fullWidthImageStyle = {
- ...imageStyle,
+/** Creates fullWidthImageStyle without changing shared render state. */
+export const fullWidthImageStyle = (): CSSProperties => ({
+ ...imageStyle(),
width: "100%",
-} satisfies CSSProperties;
+});
-export const heroImageStyle = {
- ...fullWidthImageStyle,
+/** Creates heroImageStyle without changing shared render state. */
+export const heroImageStyle = (): CSSProperties => ({
+ ...fullWidthImageStyle(),
borderRadius: "10px",
marginBottom: "26px",
-} satisfies CSSProperties;
+});
-export const eyebrowStyle = {
- color: EMAIL_THEME.colours.accent,
- fontSize: `${EMAIL_THEME.typography.eyebrow}px`,
+/** Creates eyebrowStyle without changing shared render state. */
+export const eyebrowStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.link,
+ fontSize: `${theme.typography.eyebrow}px`,
fontWeight: 700,
letterSpacing: "1.2px",
lineHeight: "18px",
margin: "0 0 10px",
textTransform: "uppercase",
-} satisfies CSSProperties;
+});
-export const heroHeadingStyle = {
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.display,
- fontSize: `${EMAIL_THEME.typography.hero}px`,
+/** Creates heroHeadingStyle without changing shared render state. */
+export const heroHeadingStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.display,
+ fontSize: `${theme.typography.hero}px`,
fontWeight: 700,
lineHeight: "44px",
margin: "0 0 16px",
-} satisfies CSSProperties;
+});
-export const headingTwoStyle = {
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.display,
- fontSize: `${EMAIL_THEME.typography.heading}px`,
+/** Creates headingTwoStyle without changing shared render state. */
+export const headingTwoStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.display,
+ fontSize: `${theme.typography.heading}px`,
fontWeight: 700,
lineHeight: "34px",
margin: "0",
-} satisfies CSSProperties;
+});
-export const headingThreeStyle = {
- color: EMAIL_THEME.colours.primary,
- fontFamily: EMAIL_THEME.fonts.display,
- fontSize: `${EMAIL_THEME.typography.subheading}px`,
+/** Creates headingThreeStyle without changing shared render state. */
+export const headingThreeStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.primary,
+ fontFamily: theme.fonts.display,
+ fontSize: `${theme.typography.subheading}px`,
fontWeight: 700,
lineHeight: "28px",
margin: "0",
-} satisfies CSSProperties;
+});
-export const bodyTextStyle = {
- color: EMAIL_THEME.colours.body,
- fontSize: `${EMAIL_THEME.typography.body}px`,
+/** Creates bodyTextStyle without changing shared render state. */
+export const bodyTextStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.body,
+ fontSize: `${theme.typography.body}px`,
lineHeight: "25px",
margin: "0",
-} satisfies CSSProperties;
+});
-export const bodyTextWithTopMarginStyle = {
- ...bodyTextStyle,
+/** Creates bodyTextWithTopMarginStyle without changing shared render state. */
+export const bodyTextWithTopMarginStyle = (
+ theme: RenderTheme,
+): CSSProperties => ({
+ ...bodyTextStyle(theme),
margin: "12px 0 0",
-} satisfies CSSProperties;
+});
-export const inlineLinkStyle = {
- color: EMAIL_THEME.colours.accent,
- fontSize: `${EMAIL_THEME.typography.body}px`,
+/** Creates inlineLinkStyle without changing shared render state. */
+export const inlineLinkStyle = (theme: RenderTheme): CSSProperties => ({
+ color: theme.colours.link,
+ fontSize: `${theme.typography.body}px`,
lineHeight: "25px",
textDecoration: "underline",
-} satisfies CSSProperties;
+});
+
+export const baseStyleFactories = {
+ pageStyle,
+ outerTableStyle,
+ shellCellStyle,
+ containerStyle,
+ preheaderStyle,
+ sectionCellStyle,
+ compactSectionCellStyle,
+ centeredSectionCellStyle,
+ heroSectionCellStyle,
+ headingSectionCellStyle,
+ bodySectionCellStyle,
+ productSectionCellStyle,
+ wordmarkStyle,
+ imageStyle,
+ fullWidthImageStyle,
+ heroImageStyle,
+ eyebrowStyle,
+ heroHeadingStyle,
+ headingTwoStyle,
+ headingThreeStyle,
+ bodyTextStyle,
+ bodyTextWithTopMarginStyle,
+ inlineLinkStyle,
+};
diff --git a/src/validation/render-geometry-validation.ts b/src/validation/render-geometry-validation.ts
index 4b560ac..f479f1f 100644
--- a/src/validation/render-geometry-validation.ts
+++ b/src/validation/render-geometry-validation.ts
@@ -200,8 +200,7 @@ function imageGeometryPasses(campaign: Campaign, html: string): boolean {
actual.every((tag, index) => {
const image = expected[index];
const role = exactAttribute(tag, "data-punch-image-role") as
- | RenderImageRole
- | undefined;
+ RenderImageRole | undefined;
const width = Number(exactAttribute(tag, "width"));
const style = exactAttribute(tag, "style") ?? "";
return (
diff --git a/src/validation/render-style-validation.ts b/src/validation/render-style-validation.ts
index d0966d4..19750f4 100644
--- a/src/validation/render-style-validation.ts
+++ b/src/validation/render-style-validation.ts
@@ -1,3 +1,5 @@
+import { contrastRatio } from "../brand/colour.js";
+export { contrastRatio } from "../brand/colour.js";
import {
MIN_COMPLIANCE_FONT_SIZE,
MIN_CONTENT_FONT_SIZE,
@@ -27,48 +29,6 @@ const REQUIRED_ROLES = [
const COMPLIANCE_ROLES = new Set(["compliance", "compliance-link"]);
-/** Parses one opaque six-digit hexadecimal colour. */
-function parseHexColour(value: unknown): [number, number, number] | undefined {
- if (typeof value !== "string" || !/^#[\da-f]{6}$/iu.test(value)) {
- return undefined;
- }
- return [1, 3, 5].map((offset) =>
- Number.parseInt(value.slice(offset, offset + 2), 16),
- ) as [number, number, number];
-}
-
-/** Converts one sRGB channel to relative luminance. */
-function lineariseChannel(value: number): number {
- const channel = value / 255;
- return channel <= 0.04045
- ? channel / 12.92
- : ((channel + 0.055) / 1.055) ** 2.4;
-}
-
-/** Returns the relative luminance for one opaque colour tuple. */
-function luminance([red, green, blue]: [number, number, number]): number {
- return (
- 0.2126 * lineariseChannel(red) +
- 0.7152 * lineariseChannel(green) +
- 0.0722 * lineariseChannel(blue)
- );
-}
-
-/** Returns the WCAG contrast ratio for two supported opaque colours. */
-export function contrastRatio(
- foreground: unknown,
- background: unknown,
-): number | undefined {
- const foregroundRgb = parseHexColour(foreground);
- const backgroundRgb = parseHexColour(background);
- if (foregroundRgb === undefined || backgroundRgb === undefined) {
- return undefined;
- }
- const first = luminance(foregroundRgb);
- const second = luminance(backgroundRgb);
- return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
-}
-
/** Parses a non-negative pixel value from one rendered style property. */
export function stylePixels(value: unknown): number | undefined {
if (typeof value === "number") {
diff --git a/tests/brand/brand-rendering.test.ts b/tests/brand/brand-rendering.test.ts
new file mode 100644
index 0000000..d87f69e
--- /dev/null
+++ b/tests/brand/brand-rendering.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveBrand } from "../../src/brand/resolve-brand.js";
+import {
+ BrandSettingsSchema,
+ DEFAULT_BRAND_SETTINGS,
+} from "../../src/brand/settings.js";
+import {
+ renderCampaign,
+ restyleCampaign,
+} from "../../src/core/render-campaign.js";
+import { renderCampaignHtml } from "../../src/rendering/render-campaign-html.js";
+import { validateRenderedCampaign } from "../../src/validation/render-validation.js";
+import { FIXED_CAMPAIGN } from "../rendering/support.js";
+import { CampaignSchema } from "../../src/core/schemas/campaign.js";
+import six from "../fixtures/checkpoint-4/six-product.json" with { type: "json" };
+import single from "../fixtures/checkpoint-4/single-product.json" with { type: "json" };
+
+describe("brand settings and isolated rendering", () => {
+ it.each([
+ "red",
+ "#abc",
+ "#123456;background:red",
+ "url(https://example.com)",
+ ])("rejects unsafe or ambiguous colour %s", (colour) => {
+ expect(
+ BrandSettingsSchema.safeParse({ primaryColour: colour }).success,
+ ).toBe(false);
+ });
+
+ it.each([
+ "Arial; color:red",
+ 'A";background:url(x)',
+ "var(--font)",
+ "inherit",
+ "A\u001b[31m",
+ ])("rejects executable or control-bearing font %s", (font) => {
+ expect(BrandSettingsSchema.safeParse({ bodyFont: font }).success).toBe(
+ false,
+ );
+ });
+
+ it("resolves manual, website and fallback slots separately", () => {
+ const brand = resolveBrand(
+ {
+ primaryColour: {
+ value: "#006644",
+ confidence: "explicit",
+ evidence: {
+ url: "https://grove.example.com/",
+ field: "styles.inline-01",
+ },
+ },
+ },
+ { bodyFont: "Verdana" },
+ );
+ expect(brand.sources).toEqual({
+ primaryColour: "website",
+ backgroundColour: "fallback",
+ textColour: "fallback",
+ headingFont: "fallback",
+ bodyFont: "manual",
+ });
+ expect(brand.settings.primaryColour).toBe("#006644");
+ expect(resolveBrand().settings).toEqual(DEFAULT_BRAND_SETTINGS);
+ });
+
+ it("preserves manual primary colours but refuses unreadable manual text", async () => {
+ const html = await renderCampaignHtml(FIXED_CAMPAIGN, {
+ primaryColour: "#FFFF00",
+ });
+ expect(html).toContain("background-color:#FFFF00");
+ expect(validateRenderedCampaign(FIXED_CAMPAIGN, html).valid).toBe(true);
+ expect(() =>
+ resolveBrand({}, { backgroundColour: "#FFFFFF", textColour: "#EEEEEE" }),
+ ).toThrow("4.5:1");
+ const dark = resolveBrand({}, { backgroundColour: "#111111" });
+ expect(dark.settings.textColour).toBe("#FFFFFF");
+ expect(dark.warnings).toContain("text-contrast-fallback");
+ });
+
+ it("renders distinct brands concurrently without leaking colours or fonts", async () => {
+ const before = JSON.stringify(FIXED_CAMPAIGN);
+ const [blue, dark, baseline] = await Promise.all([
+ renderCampaignHtml(FIXED_CAMPAIGN, {
+ primaryColour: "#2563EB",
+ headingFont: "Verdana",
+ }),
+ renderCampaignHtml(FIXED_CAMPAIGN, {
+ primaryColour: "#F0ABFC",
+ backgroundColour: "#111827",
+ textColour: "#F9FAFB",
+ headingFont: "Courier New",
+ }),
+ renderCampaignHtml(FIXED_CAMPAIGN),
+ ]);
+ expect(blue).toContain("#2563EB");
+ expect(blue).not.toContain("#F0ABFC");
+ expect(dark).toContain("#F0ABFC");
+ expect(dark).not.toContain("#2563EB");
+ expect(baseline).not.toContain("#F0ABFC");
+ expect(blue).toContain("Verdana");
+ expect(dark).toContain("Courier New");
+ expect(await renderCampaignHtml(FIXED_CAMPAIGN)).toBe(baseline);
+ expect(JSON.stringify(FIXED_CAMPAIGN)).toBe(before);
+ });
+
+ it.each([single, six])(
+ "retains render checks across diverse light/dark palettes",
+ async (fixture) => {
+ const campaign = CampaignSchema.parse(fixture);
+ for (const backgroundColour of [
+ "#FFFFFF",
+ "#101010",
+ "#808080",
+ "#FDF6E3",
+ ]) {
+ for (const primaryColour of [
+ "#FFDD00",
+ "#2563EB",
+ "#000000",
+ "#FFFFFF",
+ ]) {
+ const html = await renderCampaignHtml(campaign, {
+ primaryColour,
+ backgroundColour,
+ bodyFont: "Verdana",
+ headingFont: "Georgia",
+ });
+ expect(validateRenderedCampaign(campaign, html).valid).toBe(true);
+ }
+ }
+ },
+ );
+
+ it("restyles identical copy with zero provider usage and explicit render-only scope", async () => {
+ const result = await renderCampaign(FIXED_CAMPAIGN);
+ const next = await restyleCampaign(result, { primaryColour: "#006644" });
+ expect(next.campaign).toEqual(result.campaign);
+ expect(next.html).not.toEqual(result.html);
+ expect(next.usage.calls).toHaveLength(0);
+ expect(next.validation.scope).toBe("render-only");
+ expect(
+ next.validation.checks.every((check) => check.id.startsWith("render-")),
+ ).toBe(true);
+ });
+});
diff --git a/tests/cli/brand-files-render.test.ts b/tests/cli/brand-files-render.test.ts
new file mode 100644
index 0000000..a92bebb
--- /dev/null
+++ b/tests/cli/brand-files-render.test.ts
@@ -0,0 +1,232 @@
+import {
+ mkdtemp,
+ readFile,
+ readdir,
+ realpath,
+ rm,
+ symlink,
+ writeFile,
+ link,
+} from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ readBrandProfile,
+ saveBrandProfile,
+} from "../../src/cli/local-files.js";
+import { runCli } from "../../src/cli/run-cli.js";
+import { reviewResult } from "../../src/cli/preview-result.js";
+import { renderCampaign } from "../../src/core/render-campaign.js";
+import { FIXED_CAMPAIGN } from "../rendering/support.js";
+import type { CliIo } from "../../src/cli/io.js";
+
+const directories: string[] = [];
+afterEach(async () => {
+ await Promise.all(
+ directories
+ .splice(0)
+ .map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+/** Creates one canonical temporary parent owned by this test. */
+async function temporaryParent() {
+ const path = await mkdtemp(
+ join(await realpath(tmpdir()), "punch-brand-test-"),
+ );
+ directories.push(path);
+ return path;
+}
+
+describe("reusable brand profiles and render-only CLI", () => {
+ it("round-trips profiles and refuses overwrite, symlinks, hardlinks and oversized JSON", async () => {
+ const parent = await temporaryParent();
+ const path = join(parent, "brand.json");
+ await saveBrandProfile(path, { primaryColour: "#2563eb" });
+ expect(await readBrandProfile(path)).toEqual({ primaryColour: "#2563EB" });
+ const before = await readFile(path, "utf8");
+ await expect(
+ saveBrandProfile(path, { primaryColour: "#006644" }),
+ ).rejects.toMatchObject({ code: "invalid-file" });
+ expect(await readFile(path, "utf8")).toBe(before);
+ await symlink(path, join(parent, "linked.json"));
+ await expect(
+ readBrandProfile(join(parent, "linked.json")),
+ ).rejects.toMatchObject({ code: "invalid-file" });
+ await expect(
+ saveBrandProfile(join(parent, "linked.json"), {}),
+ ).rejects.toMatchObject({ code: "invalid-file" });
+ await link(path, join(parent, "hardlinked.json"));
+ await expect(readBrandProfile(path)).rejects.toMatchObject({
+ code: "invalid-file",
+ });
+ await writeFile(join(parent, "large.json"), " ".repeat(8193));
+ await expect(
+ readBrandProfile(join(parent, "large.json")),
+ ).rejects.toMatchObject({ code: "invalid-file" });
+ await expect(readBrandProfile(parent)).rejects.toMatchObject({
+ code: "invalid-file",
+ });
+ expect(
+ (await readdir(parent)).some((name) => name.includes(".punch-")),
+ ).toBe(false);
+ });
+
+ it("refuses linked parent directories and arbitrary profile properties", async () => {
+ const parent = await temporaryParent();
+ const alias = `${parent}-alias`;
+ await symlink(parent, alias);
+ directories.push(alias);
+ await expect(
+ saveBrandProfile(join(alias, "new.json"), {}),
+ ).rejects.toMatchObject({ code: "invalid-file" });
+ const path = join(parent, "invalid.json");
+ await writeFile(
+ path,
+ JSON.stringify({ version: "1", settings: { css: "body{}" } }),
+ );
+ await expect(readBrandProfile(path)).rejects.toMatchObject({
+ code: "invalid-file",
+ });
+ });
+
+ it("renders without a key, applies explicit flags over profiles and saves reproducible settings", async () => {
+ const parent = await temporaryParent();
+ const campaign = join(parent, "source.json");
+ const profile = join(parent, "brand.json");
+ const savedProfile = join(parent, "saved-brand.json");
+ await writeFile(campaign, JSON.stringify(FIXED_CAMPAIGN));
+ await saveBrandProfile(profile, {
+ primaryColour: "#006644",
+ bodyFont: "Verdana",
+ });
+ const stdout: string[] = [];
+ const stderr: string[] = [];
+ const io: CliIo = {
+ stdout: (value) => stdout.push(value),
+ stderr: (value) => stderr.push(value),
+ env: {},
+ signal: new AbortController().signal,
+ ask: vi.fn(),
+ };
+ const first = join(parent, "first");
+ const code = await runCli(
+ [
+ "render",
+ "--campaign",
+ campaign,
+ "--brand",
+ profile,
+ "--primary-colour",
+ "#2563EB",
+ "--save-brand",
+ savedProfile,
+ "--output",
+ first,
+ "--json",
+ ],
+ io,
+ );
+ expect(code).toBe(0);
+ expect(stdout).toHaveLength(1);
+ expect(stderr).toEqual([]);
+ expect(io.ask).not.toHaveBeenCalled();
+ expect(JSON.parse(stdout[0]!).validationScope).toBe("render-only");
+ const document = JSON.parse(
+ await readFile(join(first, "campaign.json"), "utf8"),
+ );
+ expect(document.campaign).toEqual(FIXED_CAMPAIGN);
+ expect(document.brand.settings.primaryColour).toBe("#2563EB");
+ expect((await readBrandProfile(savedProfile)).bodyFont).toBe("Verdana");
+ const second = join(parent, "second");
+ expect(
+ await runCli(
+ [
+ "render",
+ "--campaign",
+ join(first, "campaign.json"),
+ "--output",
+ second,
+ ],
+ io,
+ ),
+ ).toBe(0);
+ expect(await readFile(join(second, "email.html"), "utf8")).toBe(
+ await readFile(join(first, "email.html"), "utf8"),
+ );
+ const validation = JSON.parse(
+ await readFile(join(second, "validation.json"), "utf8"),
+ );
+ expect(validation.usage.total.inputTokens).toBe(0);
+ expect(validation.validation.scope).toBe("render-only");
+ });
+
+ it("lets the guide cancel a profile save requested on the command line", async () => {
+ const parent = await temporaryParent();
+ const campaign = join(parent, "source.json");
+ const profile = join(parent, "cancelled-brand.json");
+ await writeFile(campaign, JSON.stringify(FIXED_CAMPAIGN));
+ const answers = ["", "s", "", ""];
+ const code = await runCli(
+ [
+ "render",
+ "--campaign",
+ campaign,
+ "--output",
+ join(parent, "output"),
+ "--save-brand",
+ profile,
+ "--interactive",
+ ],
+ {
+ stdout: vi.fn(),
+ stderr: vi.fn(),
+ env: {},
+ signal: new AbortController().signal,
+ stdinIsTTY: true,
+ stdoutIsTTY: true,
+ ask: async () => {
+ const answer = answers.shift();
+ if (answer === undefined) throw new Error("Unexpected question");
+ return answer;
+ },
+ },
+ );
+ expect(code).toBe(0);
+ expect(answers).toEqual([]);
+ await expect(readFile(profile)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("previews two revisions, preserves copy, and removes owned temporary previews", async () => {
+ const answers = ["p", "b", "1", "#2563EB", "", "p", ""];
+ const previews: string[] = [];
+ const html: string[] = [];
+ const initial = await renderCampaign(FIXED_CAMPAIGN);
+ const reviewed = await reviewResult(
+ {
+ stdout: vi.fn(),
+ stderr: vi.fn(),
+ env: {},
+ signal: new AbortController().signal,
+ ask: async () => {
+ const value = answers.shift();
+ if (value === undefined) throw new Error("Unexpected question");
+ return value;
+ },
+ openPreview: async (path) => {
+ previews.push(path);
+ html.push(await readFile(path, "utf8"));
+ },
+ },
+ initial,
+ );
+ expect(html).toHaveLength(2);
+ expect(html[0]).not.toBe(html[1]);
+ expect(reviewed.result.campaign).toEqual(initial.campaign);
+ expect(reviewed.result.usage).toEqual(initial.usage);
+ for (const path of previews)
+ await expect(readFile(path)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+});
diff --git a/tests/cli/guide.test.ts b/tests/cli/guide.test.ts
new file mode 100644
index 0000000..3b18ad9
--- /dev/null
+++ b/tests/cli/guide.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi } from "vitest";
+import { resolveInvocation } from "../../src/cli/guide-command.js";
+import { editBrand } from "../../src/cli/guide-brand.js";
+import { interactiveAllowed, type CliIo } from "../../src/cli/io.js";
+import { resolveBrand } from "../../src/brand/resolve-brand.js";
+
+const complete = [
+ "generate",
+ "--website",
+ "https://grove.example.com",
+ "--product",
+ "https://grove.example.com/mug",
+ "--goal",
+ "sales",
+ "--output",
+ "campaign",
+];
+
+/** Creates a finite terminal script that fails on any unexpected extra prompt. */
+function terminal(answers: string[] = [], extra: Partial = {}) {
+ const questions: string[] = [];
+ const io: CliIo = {
+ stdinIsTTY: true,
+ stdoutIsTTY: true,
+ env: { NO_COLOR: "1" },
+ signal: new AbortController().signal,
+ stdout: vi.fn(),
+ stderr: vi.fn(),
+ ask: vi.fn(async (question) => {
+ questions.push(question);
+ const answer = answers.shift();
+ if (answer === undefined) throw new Error("Unexpected prompt");
+ return answer;
+ }),
+ ...extra,
+ };
+ return { io, questions };
+}
+
+describe("terminal-only guided input", () => {
+ it.each([
+ { stdinIsTTY: false },
+ { stdoutIsTTY: false },
+ { env: { CI: "true" } },
+ { env: { GITHUB_ACTIONS: "true" } },
+ ])("does not guide unsafe terminal state %j", async (state) => {
+ const { io } = terminal([], state);
+ expect(interactiveAllowed([], io)).toBe(false);
+ await expect(resolveInvocation(["generate"], io)).rejects.toThrow();
+ expect(io.ask).not.toHaveBeenCalled();
+ });
+
+ it.each(["--json", "--no-interactive"])(
+ "never prompts with %s even when interactive was requested",
+ async (flag) => {
+ const { io } = terminal();
+ const invocation = await resolveInvocation(
+ [...complete, "--interactive", flag],
+ io,
+ );
+ expect(invocation.guided).toBe(false);
+ expect(io.ask).not.toHaveBeenCalled();
+ },
+ );
+
+ it("leaves complete commands prompt-free and rejects typos before questions", async () => {
+ const { io } = terminal();
+ expect((await resolveInvocation(complete, io)).guided).toBe(false);
+ await expect(
+ resolveInvocation(
+ ["generate", "--webiste", "https://grove.example.com"],
+ io,
+ ),
+ ).rejects.toThrow("Unknown");
+ expect(io.ask).not.toHaveBeenCalled();
+ });
+
+ it("guides a bare invocation, retries a URL, and supports product removal", async () => {
+ const { io, questions } = terminal([
+ "bad-url",
+ "https://grove.example.com",
+ "https://grove.example.com/mug",
+ "https://grove.example.com/bowl",
+ "remove 1",
+ "",
+ "",
+ "A gift campaign",
+ "",
+ "",
+ "y",
+ ]);
+ const result = await resolveInvocation([], io);
+ expect(result.guided).toBe(true);
+ expect(result.command).toMatchObject({
+ kind: "generate",
+ input: {
+ products: ["https://grove.example.com/bowl"],
+ instructions: "A gift campaign",
+ goal: "sales",
+ },
+ });
+ expect(
+ questions.filter((question) => question.startsWith("Brand website")),
+ ).toHaveLength(2);
+ });
+
+ it("edits hex colours with an explicit contrast repair and supports reset", async () => {
+ const { io } = terminal([
+ "1",
+ "red",
+ "1",
+ "#2563eb",
+ "2",
+ "#111111",
+ "y",
+ "",
+ ]);
+ const changed = await editBrand(io, resolveBrand());
+ expect(changed).toEqual({
+ primaryColour: "#2563EB",
+ backgroundColour: "#111111",
+ textColour: "#FFFFFF",
+ });
+ expect(
+ await editBrand(terminal(["1", "#2563eb", "r", ""]).io, resolveBrand()),
+ ).toEqual({});
+ });
+
+ it("treats EOF and abort as cancellation without retries", async () => {
+ await expect(resolveInvocation([], terminal().io)).rejects.toMatchObject({
+ code: "cancelled",
+ });
+ const controller = new AbortController();
+ controller.abort();
+ const { io } = terminal([], { signal: controller.signal });
+ await expect(resolveInvocation([], io)).rejects.toMatchObject({
+ code: "cancelled",
+ });
+ expect(io.ask).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/extraction/brand-review.test.ts b/tests/extraction/brand-review.test.ts
new file mode 100644
index 0000000..3c364d4
--- /dev/null
+++ b/tests/extraction/brand-review.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { extractGenerationContext } from "../../src/extraction/extract-generation-context.js";
+import { ExtractionError } from "../../src/extraction/extraction-error.js";
+import type {
+ FetchedResource,
+ PublicFetchSession,
+} from "../../src/extraction/http/index.js";
+import {
+ QueuedTextModel,
+ modelResponse,
+} from "../support/queued-text-model.js";
+
+const website = "https://grove.example.com/";
+const product = `${website}products/mug`;
+const input = { website, products: [product], goal: "sales" };
+
+/** Creates bounded fictional resources without touching a real website. */
+function resource(url: string, html: string): FetchedResource {
+ const body = new TextEncoder().encode(html);
+ return {
+ requestedUrl: url,
+ finalUrl: url,
+ mediaType: "text/html",
+ charset: "utf-8",
+ body,
+ compressedBytes: body.length,
+ decompressedBytes: body.length,
+ redirectCount: 0,
+ };
+}
+
+/** Supplies one brand and one observed product to the real extraction path. */
+function session(): PublicFetchSession {
+ return {
+ fetchHtml: async (url) =>
+ resource(
+ url,
+ url === website
+ ? "Quiet goods for your home.
"
+ : ``,
+ ),
+ fetchStylesheet: vi.fn(),
+ dispose: vi.fn(),
+ };
+}
+
+describe("brand review before paid model work", () => {
+ it("reviews deterministic styles after disposing fetch resources and before voice inference", async () => {
+ const fetchSession = session();
+ const model = new QueuedTextModel([modelResponse("{}")]);
+ const result = await extractGenerationContext(input, {
+ fetchSession,
+ model,
+ reviewBrand: async (brand) => {
+ expect(model.requests).toHaveLength(0);
+ expect(fetchSession.dispose).toHaveBeenCalled();
+ expect(brand.settings.primaryColour).toBe("#006644");
+ return { primaryColour: "#2563EB" };
+ },
+ });
+ expect(result.brand?.settings.primaryColour).toBe("#2563EB");
+ expect(result.brand?.sources.primaryColour).toBe("manual");
+ expect(result.brand?.sources.bodyFont).toBe("website");
+ expect(model.requests).toHaveLength(1);
+ expect(JSON.stringify(result.context)).not.toContain("#2563EB");
+ });
+
+ it("cancels review without spending tokens or returning a campaign", async () => {
+ const model = new QueuedTextModel([]);
+ await expect(
+ extractGenerationContext(input, {
+ fetchSession: session(),
+ model,
+ reviewBrand: async () => {
+ throw new ExtractionError("cancelled", false);
+ },
+ }),
+ ).rejects.toMatchObject({ code: "cancelled" });
+ expect(model.requests).toHaveLength(0);
+ });
+});
diff --git a/tests/extraction/brand-style-roles.test.ts b/tests/extraction/brand-style-roles.test.ts
new file mode 100644
index 0000000..9b4c6d5
--- /dev/null
+++ b/tests/extraction/brand-style-roles.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from "vitest";
+import { extractBrand } from "../../src/extraction/extract-brand.js";
+
+const url = "https://grove.example.com/";
+
+/** Extracts only fictional styles for deterministic role tests. */
+function roles(css: string) {
+ return extractBrand({ finalUrl: url, html: `` })
+ .styleRoles;
+}
+
+describe("role-aware brand style extraction", () => {
+ it("keeps semantic roles rather than choosing the first colour", () => {
+ const styles = roles(
+ `.error{color:#ff0000} :root{--primary:#2563eb;--background:#ffffff;--text:#111827;--font-heading:"Grove Serif";--font-body:Verdana} button{background:#ffff00}`,
+ );
+ expect(styles.primaryColour?.value).toBe("#2563EB");
+ expect(styles.backgroundColour?.value).toBe("#FFFFFF");
+ expect(styles.textColour?.value).toBe("#111827");
+ expect(styles.headingFont?.value).toBe("Grove Serif");
+ expect(styles.bodyFont?.value).toBe("Verdana");
+ expect(styles.primaryColour?.evidence.url).toBe(url);
+ });
+
+ it("resolves local variables, RGB, shorthand hex and body/heading roles", () => {
+ const styles = roles(
+ ':root{--ink:#123;--action:#006644} body{background:rgb(255, 255, 255);color:var(--ink);font-family:Arial,sans-serif} h1,h2{font-family:"Grove Serif",serif} .button{background-color:var(--action)}',
+ );
+ expect(styles.textColour?.value).toBe("#112233");
+ expect(styles.backgroundColour?.value).toBe("#FFFFFF");
+ expect(styles.primaryColour?.value).toBe("#006644");
+ expect(styles.headingFont?.value).toBe("Grove Serif");
+ expect(styles.bodyFont?.value).toBe("Arial");
+ });
+
+ it("retains element roles for inline styles", () => {
+ const result = extractBrand({
+ finalUrl: url,
+ html: 'Grove
',
+ });
+ expect(result.styleRoles.primaryColour?.value).toBe("#006644");
+ expect(result.styleRoles.headingFont?.value).toBe("Georgia");
+ });
+
+ it("omits ambiguous, conditional, unrecognised and cyclic values", () => {
+ expect(
+ roles("button{background:#123456}.button{background:#654321}")
+ .primaryColour,
+ ).toBeUndefined();
+ expect(
+ roles(
+ "@media(prefers-color-scheme:dark){body{background:#111}} button:hover{background:#123456} .alert{color:#abcdef}",
+ ),
+ ).toEqual({});
+ expect(
+ roles(":root{--a:var(--b);--b:var(--a);--primary:var(--a)}"),
+ ).toEqual({});
+ expect(
+ roles(
+ "body{background:url(https://outside.example.com/x);font-family:var(--missing)}",
+ ),
+ ).toEqual({});
+ });
+});