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 - -

- A live Punch campaign for the fictional Northstar Goods brand -

- -This campaign was generated live with Claude Sonnet 5 from two public, newly -fictional product pages. The `sales` safety policy was combined with a custom -desk-reset brief; both supplied products remained grounded and all ten campaign -and render checks passed. - -[Inspect the brief, source commit and validation record](https://github.com/plmn95/punch/blob/main/docs/showcase/README.md). +## One campaign, different looks + +Change colours and fonts without changing the products, copy or links, or +making another AI call. These screenshots show the same fictional campaign +rendered with two brand profiles, on desktop and mobile. Click either image +to inspect it at full resolution. + + + + + + + + + + +
Blue · desktopDark · mobile
Soft Orbit campaign with blue accents and Verdana headings on desktopThe same Soft Orbit campaign with a dark background, lime accents and monospace headings on mobile
+ +These are renderer examples, not fresh AI generations or automatic brand-detection +results. [Reproduce them without an API key](docs/showcase/branding/README.md). +For an end-to-end generation with source evidence and a validation record, +see the [recorded Northstar Goods live run](docs/showcase/README.md). ## Quick start -Punch currently ships from source. Node.js 24 or newer and an Anthropic API key -are required. +Punch currently ships from source. Node.js 24 or newer is required; an Anthropic +API key is needed for generation, but not for rendering an existing campaign. ```bash git clone https://github.com/plmn95/punch.git @@ -91,6 +104,51 @@ Add `--trace` for redacted structured stage artifacts or `--json` for exactly one terminal JSON result on stdout. Run `node dist/cli/bin.js --help` for the complete explicit interface. +### Guided input and brand settings + +Run `node dist/cli/bin.js` in a terminal to start the optional guide. It collects +website/product URLs and the campaign brief, shows detected colours and fonts, +then asks for confirmation **before any AI call**. Keep the detected settings +with Enter, change individual six-digit hex colours or font families, preview +the actual email in a browser, and export when ready. + +Complete commands stay prompt-free. Add `--interactive` to request review even +with complete inputs. `--json`, `--no-interactive`, CI and non-TTY input/output +always disable prompting. + +```bash +node dist/cli/bin.js generate \ + --website "https://example.com" \ + --product "https://example.com/products/first-product" \ + --goal "sales" \ + --primary-colour "#2563EB" \ + --heading-font "Verdana" \ + --save-brand "./brand.json" \ + --output "./campaign" +``` + +Use `--brand ./brand.json` to reuse a saved profile. Explicit flags override +the profile; supplied settings override website detection. Output paths and +profile filenames must be new; Punch never overwrites an existing profile. + +### Restyle without another AI call + +The guide's **adjust branding** action re-renders the same campaign without +changing its copy or spending more model tokens. Saved campaigns can also be +restyled without an API key: + +```bash +node dist/cli/bin.js render \ + --campaign "./campaign/campaign.json" \ + --primary-colour "#006644" \ + --output "./campaign-green" +``` + +Render-only output is explicitly labelled `render-only` in its validation +metadata: it checks the HTML, not current product facts or source grounding. +Saved campaign settings are retained unless overridden. See +[brand settings and CLI behaviour](docs/brand-settings.md) for the full contract. + ## Custom campaign briefs Punch has three fixed goal policies and open-ended campaign direction. Keep the @@ -186,6 +244,9 @@ artifact is not automatically ready for lawful sending. ## Current limits - Anthropic is the only supported provider. +- Brand detection is conservative, not a pixel-perfect website clone. Colours + and fonts can be overridden; custom font files are not downloaded or embedded. +- Browser checks are not certification across all email clients. - Input is one website plus one to six explicit product URLs. - Punch does not discover products or crawl a catalogue. - Safe forced directory replacement is unavailable in `0.1.0`; choose a fresh diff --git a/docs/brand-settings.md b/docs/brand-settings.md new file mode 100644 index 0000000..bf70d59 --- /dev/null +++ b/docs/brand-settings.md @@ -0,0 +1,123 @@ +# Brand settings and the CLI + +Punch keeps email layout in controlled React blocks. Branding is a separate, +validated input to those blocks, never model-written CSS or HTML. + +## Five settings + +| Setting | CLI flag | Meaning | +| ------------------ | --------------------- | ----------------------------------------- | +| `primaryColour` | `--primary-colour` | Main action colour and decorative accents | +| `backgroundColour` | `--background-colour` | Main email surface | +| `textColour` | `--text-colour` | Readable content ink | +| `headingFont` | `--heading-font` | One heading font family | +| `bodyFont` | `--body-font` | One body font family | + +Colours use `#RRGGBB`. Font names are bounded plain family names, not CSS stacks, +URLs or font files. The renderer supplies fallback stacks. It does not download +or embed custom fonts; naming one does not guarantee the recipient has it. + +The renderer derives cards, borders and supporting surfaces. It preserves the +primary colour on buttons and chooses readable button text. Links may use +readable ink instead of a low-contrast brand colour. Explicit text must meet +4.5:1 contrast against the background; an invalid manual combination is refused. +The guide offers a correction that the user must accept. Unreadable detected +text receives a labelled fallback. + +## Conservative website detection + +The existing bounded fetcher supplies HTML and same-origin CSS. Role extraction +recognises explicit root tokens such as `--primary`, `--color-primary`, +`--background`, `--text`, `--font-heading` and `--font-body`, plus unconditional +body, heading and button rules. It supports opaque hex/integer RGB values and +short local variable chains. Explicit tokens take priority over semantic rules. +Conflicting top-ranked candidates are omitted, not selected by stylesheet order. + +This is not a browser-computed cascade or a pixel-perfect website clone. It does +not evaluate JavaScript, follow CSS imports, infer roles from arbitrary class +names, or choose between conditional/hover/dark-mode rules. Missing roles use +defaults. A website that provides only ambiguous signals may retain neutral +styling until the caller supplies overrides. + +`result.brand` contains the resolved settings, per-slot `website`/`manual`/ +`fallback` origins and warnings. These also live in `campaign.json`, independent +of optional traces. They contain no raw stylesheet or provider payload. + +## Reusable profiles + +```json +{ + "version": "1", + "settings": { + "primaryColour": "#2563EB", + "backgroundColour": "#FFFFFF", + "textColour": "#172033", + "headingFont": "Verdana", + "bodyFont": "Arial" + } +} +``` + +Profiles may specify a subset of settings. `--brand` reads a bounded, regular +JSON file; symlinks, hardlinks and linked parent paths are refused. `--save-brand` +and the guide's save action require a new filename in an existing real parent +directory. There is no global profile, credential store or automatic saving. +The campaign and an external profile are separate saves: if the latter fails, +the CLI reports that the campaign was saved and leaves it intact. + +Precedence is explicit flags → loaded profile → detected website roles → +fallbacks. During restyling, saved campaign settings replace website detection. + +## Guided and automated use + +Bare `punch` and incomplete `punch generate` invocations guide only when both +stdin and stdout are TTYs, no CI environment is detected, and prompting has not +been disabled. Complete commands bypass the guide unless `--interactive` is +present. `--json` and `--no-interactive` always win over `--interactive`. +Unknown flags and duplicate scalar flags fail before any question. + +The guide collects sources and the brief, fetches the pages, then reviews brand +settings before optional voice inference or campaign generation. Source-fetch +resources are released before waiting for input. Ctrl-C, Ctrl-D and a declined +generation confirmation cancel without publishing an output bundle. + +After generation: `p` opens a temporary browser preview, `b` adjusts branding, +`s` chooses a profile filename, and Enter exports. Temporary previews are removed +when the session ends. The final HTML remains in the chosen output directory. +Brand-only changes reuse the same semantic campaign and its generation usage. + +`punch render --campaign --output ` works without +credentials or AI calls. It accepts a saved Punch campaign document or a canonical +semantic campaign. It does not reuse prior grounding claims: validation is +explicitly `render-only`, with zero model usage. Neither rendering mode sends +email or resolves the caller-owned compliance placeholders. + +## TypeScript integration + +```ts +import { generateCampaign, restyleCampaign, renderCampaign } from "punch-email"; + +const result = await generateCampaign( + { website, products, goal: "sales", brand: { primaryColour: "#2563EB" } }, + { provider }, +); + +// In-memory restyling preserves the existing generation proof and usage. +const green = await restyleCampaign(result, { primaryColour: "#006644" }); + +// Independent rendering makes only render-validation claims. +const preview = await renderCampaign(result.campaign, result.brand?.settings); +``` + +A platform can supply its existing brand settings through the same `brand` +input; no CLI or platform-specific connector is required. An optional +`reviewBrand` callback can return overrides before model work begins. Ordinary +API calls remain non-interactive. + +## Verification boundary + +Tests cover role selection and ambiguity, strict settings, contrast, concurrent +theme isolation, single/six-product rendering, profile safety, TTY/CI gating, +manual correction, cancellation, preview cleanup and render-only reproducibility. +Browser fixtures check desktop/mobile layout. Browser proof is not certification +across Gmail, Outlook, Apple Mail or every installed font. diff --git a/docs/showcase/README.md b/docs/showcase/README.md index a51a0dd..a044fbc 100644 --- a/docs/showcase/README.md +++ b/docs/showcase/README.md @@ -1,5 +1,9 @@ # Live Northstar Goods showcase +This is the original recorded generation, captured before brand-aware rendering +was added. Its neutral theme is retained as historical evidence. For the current +configurable renderer, see the [blue and dark brand examples](branding/README.md). + ![Generated Northstar Goods campaign](northstar-campaign.png) This is a real Punch generation from public HTTP input through Claude Sonnet 5, diff --git a/docs/showcase/branding/README.md b/docs/showcase/branding/README.md new file mode 100644 index 0000000..70398b6 --- /dev/null +++ b/docs/showcase/branding/README.md @@ -0,0 +1,61 @@ +# One campaign, two brand profiles + +These are real browser captures of Punch's HTML renderer, not image-generated +mockups. Both use the same fictional Soft Orbit campaign. Only the brand +settings and viewport differ; the product, copy, price and destination links +are unchanged. + +| Preview | Brand settings | Capture | Maximum README display width | +| -------------------------------- | ---------------------- | ------------- | ---------------------------- | +| [Blue desktop](blue-desktop.jpg) | [blue.json](blue.json) | 820 × 1000 px | 520 px | +| [Dark mobile](dark-mobile.jpg) | [dark.json](dark.json) | 390 × 1000 px | 250 px | + +The JPEGs are original browser captures with no extra compression or upscaling. +They show the upper part of each email; the complete HTML includes the closing +CTA and Punch's compliance placeholders. The mobile layout is a real responsive +render, not a resized desktop screenshot. Installed fonts may affect line breaks +on another machine. + +## Reproduce the HTML without an API key + +From the repository root, after installing dependencies: + +```bash +npm run build + +node dist/cli/bin.js render \ + --campaign docs/showcase/branding/campaign.json \ + --brand docs/showcase/branding/blue.json \ + --output ./showcase-blue + +node dist/cli/bin.js render \ + --campaign docs/showcase/branding/campaign.json \ + --brand docs/showcase/branding/dark.json \ + --output ./showcase-dark +``` + +Both output directories must be new. Open each `email.html` in a browser, or +add `--interactive` to review settings and open a preview through the CLI. +To try a different accent, add `--primary-colour "#006644"`; explicit flags +override the selected profile. + +## Provenance and limits + +[`campaign.json`](campaign.json) is the public +[single-product renderer fixture](../../../tests/fixtures/checkpoint-4/single-product.json) +with the placeholder product image omitted. It intentionally demonstrates +Punch's image-free rendering and requires no external image requests. Its +reserved `.example.com` links are fictional, not working shop destinations. + +The screenshots were captured from the same content with local fixture links +for browser interaction checks. The committed campaign keeps the original +reserved-domain destinations; this does not change the visible rendering. + +The profiles are explicit inputs, not evidence of automatic website detection. +No AI call, fresh source fetch or product-grounding claim is made by this +showcase. The render command produces `render-only` validation and zero model +usage. Browser verification is not certification across all email clients. + +The separate [Northstar Goods live-generation record](../README.md) retains the +original end-to-end generation evidence. See [brand settings](../../brand-settings.md) +for detection behaviour, contrast checks and the TypeScript integration. diff --git a/docs/showcase/branding/blue-desktop.jpg b/docs/showcase/branding/blue-desktop.jpg new file mode 100644 index 0000000..fffa67a Binary files /dev/null and b/docs/showcase/branding/blue-desktop.jpg differ diff --git a/docs/showcase/branding/blue.json b/docs/showcase/branding/blue.json new file mode 100644 index 0000000..167e6c3 --- /dev/null +++ b/docs/showcase/branding/blue.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "settings": { + "primaryColour": "#2563EB", + "backgroundColour": "#FFFFFF", + "textColour": "#172033", + "headingFont": "Verdana", + "bodyFont": "Arial" + } +} diff --git a/docs/showcase/branding/campaign.json b/docs/showcase/branding/campaign.json new file mode 100644 index 0000000..2a024c4 --- /dev/null +++ b/docs/showcase/branding/campaign.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": "0.1.0", + "goal": "product-launch", + "subject": "Meet the Pebble Weekender", + "preheader": "A soft-sided overnight bag with thoughtful space for the essentials.", + "blocks": [ + { + "type": "header-standard", + "brandName": "Soft Orbit", + "homeUrl": "https://soft-orbit.example.com/", + "id": "block-01" + }, + { + "type": "hero-stacked", + "eyebrow": "New for short escapes", + "heading": "Pack one more good day", + "body": "Meet the Pebble Weekender, arranged for overnight plans and easy unpacking.", + "id": "block-02" + }, + { + "type": "product-feature", + "eyebrow": "The new arrival", + "productId": "product-01", + "name": "Pebble Weekender", + "description": "A soft-sided overnight bag with a wide zip opening, two interior pockets, and an adjustable woven strap.", + "price": { "amount": "148.00", "currency": "USD", "display": "$148" }, + "cta": { + "label": "Explore the Pebble Weekender", + "href": "https://soft-orbit.example.com/products/pebble-weekender" + }, + "id": "block-03" + }, + { + "type": "heading", + "level": 2, + "text": "Small details, easier departures", + "id": "block-04" + }, + { + "type": "body-paragraph", + "markdown": "A wide zip opening keeps the main compartment easy to scan, while **two interior pockets** separate smaller essentials.", + "id": "block-05" + }, + { + "type": "cta-block", + "heading": "Ready for the next overnight?", + "body": "See the complete bag details before you pack.", + "actions": [ + { + "label": "View the Pebble Weekender", + "href": "https://soft-orbit.example.com/products/pebble-weekender" + } + ], + "id": "block-06" + } + ] +} diff --git a/docs/showcase/branding/dark-mobile.jpg b/docs/showcase/branding/dark-mobile.jpg new file mode 100644 index 0000000..5d45d21 Binary files /dev/null and b/docs/showcase/branding/dark-mobile.jpg differ diff --git a/docs/showcase/branding/dark.json b/docs/showcase/branding/dark.json new file mode 100644 index 0000000..1145cb2 --- /dev/null +++ b/docs/showcase/branding/dark.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "settings": { + "primaryColour": "#C7F36B", + "backgroundColour": "#111827", + "textColour": "#F9FAFB", + "headingFont": "Courier New", + "bodyFont": "Verdana" + } +} diff --git a/package-lock.json b/package-lock.json index 9f39d64..ed4666a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/node": "24.12.2", "@types/react": "19.2.18", "eslint": "10.9.1", - "prettier": "3.8.3", + "prettier": "3.9.6", "typescript": "5.9.3", "typescript-eslint": "8.68.0", "vitest": "4.1.11" @@ -2342,9 +2342,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index e676b2c..418daf7 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@types/node": "24.12.2", "@types/react": "19.2.18", "eslint": "10.9.1", - "prettier": "3.8.3", + "prettier": "3.9.6", "typescript": "5.9.3", "typescript-eslint": "8.68.0", "vitest": "4.1.11" diff --git a/src/brand/colour.ts b/src/brand/colour.ts new file mode 100644 index 0000000..25f52de --- /dev/null +++ b/src/brand/colour.ts @@ -0,0 +1,63 @@ +/** Parses one opaque six-digit hexadecimal colour. */ +function parseHex(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 linearise(value: number): number { + const channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +} + +/** Computes the relative luminance of a supported opaque colour. */ +function luminance([red, green, blue]: [number, number, number]): number { + return ( + 0.2126 * linearise(red) + + 0.7152 * linearise(green) + + 0.0722 * linearise(blue) + ); +} + +/** Returns the contrast ratio for supported opaque foreground/background colours. */ +export function contrastRatio( + foreground: unknown, + background: unknown, +): number | undefined { + const front = parseHex(foreground); + const back = parseHex(background); + if (!front || !back) return undefined; + const first = luminance(front); + const second = luminance(back); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} + +/** Selects the higher-contrast black or white text for a validated background. */ +export function readableInk(background: string): string { + return (contrastRatio("#000000", background) ?? 0) >= + (contrastRatio("#FFFFFF", background) ?? 0) + ? "#000000" + : "#FFFFFF"; +} + +/** Mixes a bounded proportion of a validated colour into a background. */ +export function tint( + background: string, + colour: string, + amount: number, +): string { + const base = parseHex(background)!; + const target = parseHex(colour)!; + return `#${base + .map((channel, index) => + Math.round(channel * (1 - amount) + target[index]! * amount) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`.toUpperCase(); +} diff --git a/src/brand/resolve-brand.ts b/src/brand/resolve-brand.ts new file mode 100644 index 0000000..a1fbd79 --- /dev/null +++ b/src/brand/resolve-brand.ts @@ -0,0 +1,76 @@ +import { contrastRatio, readableInk } from "./colour.js"; +import { + BRAND_KEYS, + BrandStyleEvidenceSchema, + BrandStyleError, + CompleteBrandSettingsSchema, + DEFAULT_BRAND_SETTINGS, + parseBrandSettings, + ResolvedBrandSchema, + type BrandStyleEvidence, + type CompleteBrandSettings, + type ResolvedBrand, +} from "./settings.js"; + +/** Resolves role-aware website evidence and explicit overrides independently per run. */ +export function resolveBrand( + evidence: BrandStyleEvidence = {}, + overrides: unknown = {}, +): ResolvedBrand { + const facts = BrandStyleEvidenceSchema.parse(evidence); + const manual = parseBrandSettings(overrides); + const settings = { ...DEFAULT_BRAND_SETTINGS }; + const sources = Object.fromEntries( + BRAND_KEYS.map((key) => [key, "fallback"]), + ) as ResolvedBrand["sources"]; + for (const key of BRAND_KEYS) { + const candidate = CompleteBrandSettingsSchema.shape[key].safeParse( + facts[key]?.value, + ); + if (candidate.success) { + settings[key] = candidate.data; + sources[key] = "website"; + } + if (manual[key] !== undefined) { + settings[key] = manual[key]; + sources[key] = "manual"; + } + } + const warnings: ResolvedBrand["warnings"] = []; + if ( + (contrastRatio(settings.textColour, settings.backgroundColour) ?? 0) < 4.5 + ) { + if (manual.textColour !== undefined) { + throw new BrandStyleError( + `Text and background need at least 4.5:1 contrast. Suggested text: ${readableInk(settings.backgroundColour)}.`, + ); + } + settings.textColour = readableInk(settings.backgroundColour); + sources.textColour = "fallback"; + warnings.push("text-contrast-fallback"); + } + warnings.push(...styleWarnings(settings)); + return ResolvedBrandSchema.parse({ settings, sources, warnings }); +} + +/** Explains safe link/font substitutions without changing caller-owned settings. */ +function styleWarnings( + settings: CompleteBrandSettings, +): ResolvedBrand["warnings"] { + const warnings: ResolvedBrand["warnings"] = []; + if ( + (contrastRatio(settings.primaryColour, settings.backgroundColour) ?? 0) < + 4.5 + ) + warnings.push("accessible-link-colour"); + if ( + [settings.bodyFont, settings.headingFont].some( + (font) => + !/^(Arial|Georgia|Helvetica|Verdana|Tahoma|Times New Roman|Courier New|Trebuchet MS|serif|sans-serif|monospace)$/iu.test( + font, + ), + ) + ) + warnings.push("font-fallbacks"); + return warnings; +} diff --git a/src/brand/settings.ts b/src/brand/settings.ts new file mode 100644 index 0000000..405b812 --- /dev/null +++ b/src/brand/settings.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { HttpUrlSchema } from "../core/schemas/primitives.js"; + +export const BRAND_KEYS = [ + "primaryColour", + "backgroundColour", + "textColour", + "headingFont", + "bodyFont", +] as const; + +export const HexColourSchema = z + .string() + .trim() + .regex(/^#[\da-f]{6}$/iu, "Use a six-digit hex colour, for example #2563EB") + .transform((value) => value.toUpperCase()); + +export const FontFamilySchema = z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[a-z][a-z\d -]*$/iu, "Use a single font family name, without CSS") + .refine( + (value) => + !["inherit", "initial", "unset", "revert", "revert-layer"].includes( + value.toLowerCase(), + ), + ); + +export const CompleteBrandSettingsSchema = z.strictObject({ + primaryColour: HexColourSchema, + backgroundColour: HexColourSchema, + textColour: HexColourSchema, + headingFont: FontFamilySchema, + bodyFont: FontFamilySchema, +}); +export const BrandSettingsSchema = CompleteBrandSettingsSchema.partial(); +export const BrandProfileSchema = z.strictObject({ + version: z.literal("1"), + settings: BrandSettingsSchema, +}); +export const BrandStyleEvidenceSchema = z.partialRecord( + z.enum(BRAND_KEYS), + z.strictObject({ + value: z.string().max(80), + confidence: z.enum(["explicit", "semantic"]), + evidence: z.strictObject({ + url: HttpUrlSchema, + field: z.string().max(120), + }), + }), +); +export const ResolvedBrandSchema = z.strictObject({ + settings: CompleteBrandSettingsSchema, + sources: z.record( + z.enum(BRAND_KEYS), + z.enum(["manual", "website", "fallback"]), + ), + warnings: z + .array( + z.enum([ + "text-contrast-fallback", + "accessible-link-colour", + "font-fallbacks", + ]), + ) + .max(3), +}); + +export type BrandSettings = z.infer; +export type CompleteBrandSettings = z.infer; +export type BrandSettingKey = (typeof BRAND_KEYS)[number]; +export type BrandStyleEvidence = z.infer; +export type ResolvedBrand = z.infer; +export type BrandReviewer = (brand: ResolvedBrand) => Promise; + +export const DEFAULT_BRAND_SETTINGS: Readonly = + Object.freeze({ + primaryColour: "#9A5137", + backgroundColour: "#FFFDF9", + textColour: "#2F251F", + headingFont: "Georgia", + bodyFont: "Arial", + }); + +/** Safe brand failure that never echoes untrusted CSS or file contents. */ +export class BrandStyleError extends Error { + readonly code = "invalid-brand"; + readonly retryable = false; + + constructor( + message = "Brand settings are invalid. Check hex colours and font family names.", + ) { + super(message); + this.name = "BrandStyleError"; + } +} + +/** Validates caller-owned overrides without retaining unsafe input in errors. */ +export function parseBrandSettings(value: unknown): BrandSettings { + const parsed = BrandSettingsSchema.safeParse(value); + if (!parsed.success) throw new BrandStyleError(); + return parsed.data; +} diff --git a/src/cli/arguments.ts b/src/cli/arguments.ts index e0c9146..008a9ce 100644 --- a/src/cli/arguments.ts +++ b/src/cli/arguments.ts @@ -2,179 +2,158 @@ import { GenerateCampaignInputSchema, type GenerateCampaignInput, } from "../core/schemas/index.js"; +import type { BrandSettings } from "../brand/settings.js"; +import { CliArgumentError } from "./cli-error.js"; +import { brandFlags, flagValue, readFlags, type CliFlags } from "./flags.js"; +import { localPath } from "./local-files.js"; -const VALUE_FLAGS = new Set([ - "--website", - "--product", - "--goal", - "--output", - "--instructions", - "--offer", - "--discount-code", - "--offer-ends-at", -]); -const BOOLEAN_FLAGS = new Set([ - "--trace", - "--json", - "--force", - "--no-interactive", -]); +export { CliArgumentError } from "./cli-error.js"; +export type { CliArgumentErrorCode } from "./cli-error.js"; -export type GenerateCommand = Readonly<{ - kind: "generate"; - input: GenerateCampaignInput; +export type CommonCommand = Readonly<{ output: string; - trace: boolean; json: boolean; force: boolean; + brandPath?: string; + saveBrandPath?: string; }>; - +export type GenerateCommand = CommonCommand & + Readonly<{ + kind: "generate"; + input: GenerateCampaignInput; + trace: boolean; + }>; +export type RenderCommand = CommonCommand & + Readonly<{ + kind: "render"; + campaignPath: string; + brand: BrandSettings; + }>; export type CliCommand = | GenerateCommand + | RenderCommand | Readonly<{ kind: "help" }> | Readonly<{ kind: "version" }>; -export type CliArgumentErrorCode = - | "invalid-arguments" - | "missing-arguments" - | "unknown-command"; - -/** Stable usage error without echoing user-supplied values. */ -export class CliArgumentError extends Error { - readonly code: CliArgumentErrorCode; - - constructor(code: CliArgumentErrorCode, message: string) { - super(message); - this.name = "CliArgumentError"; - this.code = code; - } -} - -/** Parses the canonical non-interactive Punch command. */ +/** Parses explicit invocations without reading files, credentials or terminal state. */ export function parseCliArguments(argv: readonly string[]): CliCommand { - if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { kind: "help" }; - } - if (argv[0] === "--version" || argv[0] === "-v") { - return { kind: "version" }; - } - if (argv[0] !== "generate") { + if (argv[0] === "--version" || argv[0] === "-v") return { kind: "version" }; + if (argv[0] !== "generate" && argv[0] !== "render") throw new CliArgumentError( "unknown-command", "Unknown command. Use punch --help.", ); - } - return parseGenerate(argv.slice(1)); -} - -/** Parses generate flags without prompting or accepting positional input. */ -function parseGenerate(argv: readonly string[]): GenerateCommand { - const values = new Map(); - const booleans = new Set(); - for (let index = 0; index < argv.length; index += 1) { - const flag = argv[index]!; - if (BOOLEAN_FLAGS.has(flag)) { - booleans.add(flag); - continue; - } - if (!VALUE_FLAGS.has(flag)) { - throw new CliArgumentError( - "invalid-arguments", - "Unknown or misplaced generate flag.", - ); - } - const value = argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - throw new CliArgumentError( - "missing-arguments", - "A generate flag is missing its value.", - ); - } - values.set(flag, [...(values.get(flag) ?? []), value]); - index += 1; - } - return buildGenerateCommand(values, booleans); + const flags = readFlags(argv.slice(1)); + return argv[0] === "render" ? parseRender(flags) : parseGenerate(flags); } -/** Validates multiplicity and builds the public campaign input. */ -function buildGenerateCommand( - values: ReadonlyMap, - booleans: ReadonlySet, -): GenerateCommand { - const website = singleValue(values, "--website"); - const goal = singleValue(values, "--goal"); - const output = singleValue(values, "--output"); - const products = values.get("--product") ?? []; - if (!website || !goal || !output || products.length === 0) { +/** Collects shared options while refusing unsafe local path spellings. */ +function commonCommand(flags: CliFlags): CommonCommand { + const output = flagValue(flags, "--output"); + if (!output) throw new CliArgumentError( "missing-arguments", - "Generate requires --website, --product, --goal and --output.", + "Choose an --output directory.", ); - } - const input = campaignInput(values, website, products, goal); + localPath(output); + const brandPath = flagValue(flags, "--brand"); + const saveBrandPath = flagValue(flags, "--save-brand"); return { - kind: "generate", - input: GenerateCampaignInputSchema.parse(input), output, - trace: booleans.has("--trace"), - json: booleans.has("--json"), - force: booleans.has("--force"), + json: flags.booleans.has("--json"), + force: flags.booleans.has("--force"), + ...(brandPath ? { brandPath: localPath(brandPath) } : {}), + ...(saveBrandPath ? { saveBrandPath: localPath(saveBrandPath) } : {}), }; } -/** Returns one scalar flag and rejects accidental duplicates. */ -function singleValue( - values: ReadonlyMap, - flag: string, -): string | undefined { - const entries = values.get(flag); - if (entries && entries.length > 1) { +/** Validates generation flags through the canonical public input contract. */ +function parseGenerate(flags: CliFlags): GenerateCommand { + if (flags.values.has("--campaign")) throw new CliArgumentError( "invalid-arguments", - "A scalar flag was supplied more than once.", + "--campaign belongs to punch render.", ); - } - return entries?.[0]; -} - -/** Maps CLI offer flags to the discriminated public input schema. */ -function campaignInput( - values: ReadonlyMap, - website: string, - products: readonly string[], - goal: string, -): unknown { - const instructions = singleValue(values, "--instructions"); - const offerDescription = singleValue(values, "--offer"); - const code = singleValue(values, "--discount-code"); - const endsAt = singleValue(values, "--offer-ends-at"); - const base = { + const website = flagValue(flags, "--website"); + const goal = flagValue(flags, "--goal"); + const products = flags.values.get("--product") ?? []; + if (!website || !goal || products.length === 0) + throw new CliArgumentError( + "missing-arguments", + "Generate requires --website, --product, --goal and --output.", + ); + const offer = offerFlags(flags, goal); + const instructions = flagValue(flags, "--instructions"); + const brand = brandFlags(flags); + const input = GenerateCampaignInputSchema.parse({ website, products, goal, ...(instructions ? { instructions } : {}), + ...(Object.keys(brand).length ? { brand } : {}), + ...(offer ? { offer } : {}), + }); + return { + ...commonCommand(flags), + kind: "generate", + input, + trace: flags.booleans.has("--trace"), }; - if (goal !== "promotion") { - if (offerDescription || code || endsAt) { - throw new CliArgumentError( - "invalid-arguments", - "Offer flags require --goal promotion.", - ); - } - return base; - } - if (!offerDescription) { +} + +/** Keeps offer requirements identical in explicit and guided generation. */ +function offerFlags(flags: CliFlags, goal: string) { + const description = flagValue(flags, "--offer"); + const code = flagValue(flags, "--discount-code"); + const endsAt = flagValue(flags, "--offer-ends-at"); + if (goal !== "promotion" && (description || code || endsAt)) + throw new CliArgumentError( + "invalid-arguments", + "Offer flags require --goal promotion.", + ); + if (goal === "promotion" && !description) throw new CliArgumentError( "missing-arguments", "Promotion requires --offer.", ); - } + return goal === "promotion" + ? { description, ...(code ? { code } : {}), ...(endsAt ? { endsAt } : {}) } + : undefined; +} + +/** Parses a render-only invocation that never requires an API key. */ +function parseRender(flags: CliFlags): RenderCommand { + const allowed = new Set([ + "--campaign", + "--output", + "--brand", + "--save-brand", + "--primary-colour", + "--background-colour", + "--text-colour", + "--heading-font", + "--body-font", + ]); + if ( + [...flags.values.keys()].some((key) => !allowed.has(key)) || + flags.booleans.has("--trace") + ) + throw new CliArgumentError( + "invalid-arguments", + "Generation-only flags cannot be used with punch render.", + ); + const campaignPath = flagValue(flags, "--campaign"); + if (!campaignPath) + throw new CliArgumentError( + "missing-arguments", + "Render requires --campaign and --output.", + ); return { - ...base, - offer: { - description: offerDescription, - ...(code ? { code } : {}), - ...(endsAt ? { endsAt } : {}), - }, + ...commonCommand(flags), + kind: "render", + campaignPath: localPath(campaignPath), + brand: brandFlags(flags), }; } diff --git a/src/cli/bin.ts b/src/cli/bin.ts index d755f05..e19d1a0 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,19 +1,27 @@ #!/usr/bin/env node import { runCli } from "./run-cli.js"; +import { createTerminalPrompts, openPreview } from "./terminal.js"; const controller = new AbortController(); const cancel = (): void => controller.abort(); process.once("SIGINT", cancel); process.once("SIGTERM", cancel); +const prompts = createTerminalPrompts(controller); const exitCode = await runCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value), env: process.env, signal: controller.signal, + stdinIsTTY: Boolean(process.stdin.isTTY), + stdoutIsTTY: Boolean(process.stdout.isTTY), + ask: prompts.ask, + openPreview, }); +prompts.close(); + process.removeListener("SIGINT", cancel); process.removeListener("SIGTERM", cancel); process.exitCode = exitCode; diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts new file mode 100644 index 0000000..bbc1c98 --- /dev/null +++ b/src/cli/cli-error.ts @@ -0,0 +1,16 @@ +export type CliArgumentErrorCode = + | "invalid-arguments" + | "missing-arguments" + | "unknown-command" + | "invalid-file" + | "cancelled"; + +/** Stable usage failure without echoing untrusted input or filesystem contents. */ +export class CliArgumentError extends Error { + readonly code: CliArgumentErrorCode; + constructor(code: CliArgumentErrorCode, message: string) { + super(message); + this.name = "CliArgumentError"; + this.code = code; + } +} diff --git a/src/cli/execute-command.ts b/src/cli/execute-command.ts new file mode 100644 index 0000000..52febb0 --- /dev/null +++ b/src/cli/execute-command.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +import { ResolvedBrandSchema } from "../brand/settings.js"; +import { + generateCampaign, + type GenerateCampaignResult, +} from "../core/generate-campaign.js"; +import { renderCampaign } from "../core/render-campaign.js"; +import { CampaignSchema } from "../core/schemas/campaign.js"; +import { + assertOutputAvailable, + writeCampaignOutput, +} from "../output/write-output.js"; +import { createAnthropicProvider } from "../providers/anthropic.js"; +import type { GenerateCommand, RenderCommand } from "./arguments.js"; +import { CliArgumentError } from "./cli-error.js"; +import { editBrand } from "./guide-brand.js"; +import { confirm, type CliIo } from "./io.js"; +import { + readBrandProfile, + readLocalJson, + saveBrandProfile, +} from "./local-files.js"; +import { reviewResult } from "./preview-result.js"; + +const SavedCampaignSchema = z.object({ + generator: z.literal("punch"), + campaign: CampaignSchema, + brand: ResolvedBrandSchema.optional(), +}); + +/** Executes a validated command with optional human review around the unchanged engine. */ +export async function executeCommand( + command: GenerateCommand | RenderCommand, + io: CliIo, + guided: boolean, +): Promise { + await assertOutputAvailable(command.output, command.force); + const initial = + command.kind === "generate" + ? await generate(command, io, guided) + : await renderSaved(command); + const reviewed = guided + ? await reviewResult(io, initial) + : { result: initial }; + if (io.signal.aborted) + throw new CliArgumentError("cancelled", "Cancelled before saving."); + const output = await writeCampaignOutput(reviewed.result, command.output, { + force: command.force, + }); + const profile = + reviewed.saveBrandPath === undefined + ? command.saveBrandPath + : reviewed.saveBrandPath; + if (profile && reviewed.result.brand) { + try { + await saveBrandProfile(profile, reviewed.result.brand.settings); + } catch { + throw new CliArgumentError( + "invalid-file", + `Campaign saved in ${output}, but the profile could not be saved. Choose a new profile filename.`, + ); + } + } + return output; +} + +/** Runs generation only after credentials exist and the guided brand review is confirmed. */ +async function generate( + command: GenerateCommand, + io: CliIo, + guided: boolean, +): Promise { + const apiKey = io.env.ANTHROPIC_API_KEY?.trim(); + if (!apiKey) + throw new CliArgumentError( + "missing-arguments", + "ANTHROPIC_API_KEY is required.", + ); + const profile = command.brandPath + ? await readBrandProfile(command.brandPath) + : {}; + return generateCampaign( + { ...command.input, brand: { ...profile, ...command.input.brand } }, + { + provider: createAnthropicProvider({ apiKey }), + signal: io.signal, + trace: command.trace, + ...(guided + ? { + reviewBrand: async (brand) => { + const changes = await editBrand(io, brand); + await confirm(io, "Generate this campaign using AI?"); + io.stderr("Generating and validating the campaign…\n"); + return changes; + }, + } + : {}), + }, + ); +} + +/** Loads semantic content and saved settings but does not trust prior validation claims. */ +async function renderSaved( + command: RenderCommand, +): Promise { + const data = await readLocalJson(command.campaignPath, 1_000_000); + const saved = SavedCampaignSchema.safeParse(data); + const campaign = saved.success + ? saved.data.campaign + : CampaignSchema.parse(data); + const profile = command.brandPath + ? await readBrandProfile(command.brandPath) + : {}; + return renderCampaign(campaign, { + ...(saved.success ? saved.data.brand?.settings : {}), + ...profile, + ...command.brand, + }); +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts new file mode 100644 index 0000000..730eab3 --- /dev/null +++ b/src/cli/flags.ts @@ -0,0 +1,91 @@ +import { + parseBrandSettings, + type BrandSettings, + type BrandSettingKey, +} from "../brand/settings.js"; +import { CliArgumentError } from "./cli-error.js"; + +export const BRAND_FLAGS: Readonly> = { + "--primary-colour": "primaryColour", + "--background-colour": "backgroundColour", + "--text-colour": "textColour", + "--heading-font": "headingFont", + "--body-font": "bodyFont", +}; +const VALUE_FLAGS = new Set([ + "--website", + "--product", + "--goal", + "--output", + "--instructions", + "--offer", + "--discount-code", + "--offer-ends-at", + "--brand", + "--save-brand", + "--campaign", + ...Object.keys(BRAND_FLAGS), +]); +const BOOLEAN_FLAGS = new Set([ + "--trace", + "--json", + "--force", + "--no-interactive", + "--interactive", +]); +export type CliFlags = { values: Map; booleans: Set }; + +/** Parses flags first, so unknown flags and duplicate scalars fail before prompting. */ +export function readFlags(argv: readonly string[]): CliFlags { + const values = new Map(); + const booleans = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]!; + if (BOOLEAN_FLAGS.has(flag)) { + booleans.add(flag); + continue; + } + if (!VALUE_FLAGS.has(flag)) + throw new CliArgumentError( + "invalid-arguments", + "Unknown or misplaced flag.", + ); + const value = argv[++index]; + if (value === undefined || value.startsWith("--")) + throw new CliArgumentError( + "invalid-arguments", + "A flag is missing its value.", + ); + if (flag !== "--product" && values.has(flag)) + throw new CliArgumentError( + "invalid-arguments", + "A scalar flag was supplied more than once.", + ); + values.set(flag, [...(values.get(flag) ?? []), value]); + } + return { values, booleans }; +} + +/** Reads a single already-checked flag value. */ +export function flagValue(flags: CliFlags, flag: string): string | undefined { + return flags.values.get(flag)?.[0]; +} + +/** Parses overrides through the same schema used by the engine and wizard. */ +export function brandFlags(flags: CliFlags): BrandSettings { + return parseBrandSettings( + Object.fromEntries( + Object.entries(BRAND_FLAGS).flatMap(([flag, key]) => { + const value = flagValue(flags, flag); + return value === undefined ? [] : [[key, value]]; + }), + ), + ); +} + +/** Serialises a draft back through the canonical explicit parser. */ +export function flagsToArgv(flags: CliFlags): string[] { + return [...flags.values] + .flatMap(([key, values]) => values.flatMap((value) => [key, value])) + .concat([...flags.booleans]); +} diff --git a/src/cli/guide-brand.ts b/src/cli/guide-brand.ts new file mode 100644 index 0000000..dd24974 --- /dev/null +++ b/src/cli/guide-brand.ts @@ -0,0 +1,124 @@ +import { readableInk } from "../brand/colour.js"; +import { resolveBrand } from "../brand/resolve-brand.js"; +import { + BRAND_KEYS, + BrandStyleError, + CompleteBrandSettingsSchema, + type BrandSettings, + type ResolvedBrand, +} from "../brand/settings.js"; +import { ask, type CliIo } from "./io.js"; + +const LABELS = [ + "Primary colour", + "Background", + "Text colour", + "Heading font", + "Body font", +]; +const WARNINGS = { + "text-contrast-fallback": + "Detected text was unreadable on the background; a readable fallback is shown.", + "accessible-link-colour": + "The primary colour stays on buttons; links use readable ink.", + "font-fallbacks": + "Custom fonts are named, not downloaded. Email-safe fallback fonts are included.", +}; + +/** Displays validated settings and their origins, with optional terminal swatches. */ +function showBrand(io: CliIo, brand: ResolvedBrand): void { + io.stderr("\n3. Review branding — Enter keeps these settings\n"); + BRAND_KEYS.forEach((key, index) => { + const value = brand.settings[key]; + const swatch = + key.endsWith("Colour") && io.stdoutIsTTY && !io.env.NO_COLOR + ? colourSwatch(value) + : ""; + io.stderr( + ` ${index + 1}. ${LABELS[index]}: ${swatch}${value} (${brand.sources[key]})\n`, + ); + }); + brand.warnings.forEach((warning) => + io.stderr(` Note: ${WARNINGS[warning]}\n`), + ); +} + +/** Produces a colour swatch only from a validated six-digit hexadecimal value. */ +function colourSwatch(hex: string): string { + const channels = [1, 3, 5].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16), + ); + return `\u001b[48;2;${channels.join(";")}m \u001b[0m `; +} + +/** Lets the user correct individual slots while retaining untouched detection provenance. */ +export async function editBrand( + io: CliIo, + initial: ResolvedBrand, +): Promise { + let changes: BrandSettings = {}; + let current = initial; + for (;;) { + showBrand(io, current); + const choice = await ask( + io, + "Change 1–5, reset changes (r), or Enter to continue: ", + ); + if (!choice) return changes; + if (choice === "r") { + changes = {}; + current = initial; + continue; + } + const index = Number(choice) - 1; + const key = BRAND_KEYS[index]; + if (!key) { + io.stderr("Choose a number from 1 to 5.\n"); + continue; + } + const value = await ask( + io, + `${LABELS[index]} (${key.endsWith("Colour") ? "#RRGGBB" : "one font family name"}): `, + ); + const parsed = CompleteBrandSettingsSchema.shape[key].safeParse(value); + if (!parsed.success) { + io.stderr( + "Use six-digit hex colours or a single plain font family name.\n", + ); + continue; + } + const candidate = { ...changes, [key]: parsed.data }; + const accepted = await validateEdit(io, initial, candidate); + if (accepted) { + changes = accepted.changes; + current = accepted.brand; + } + } +} + +/** Offers an explicit text correction instead of silently changing a manual colour. */ +async function validateEdit( + io: CliIo, + initial: ResolvedBrand, + changes: BrandSettings, +): Promise<{ changes: BrandSettings; brand: ResolvedBrand } | undefined> { + try { + const brand = resolveBrand({}, { ...initial.settings, ...changes }); + for (const key of BRAND_KEYS) + if (changes[key] === undefined) brand.sources[key] = initial.sources[key]; + return { changes, brand }; + } catch (error) { + if (!(error instanceof BrandStyleError)) throw error; + const suggested = readableInk( + changes.backgroundColour ?? initial.settings.backgroundColour, + ); + io.stderr( + `That text/background combination is unreadable. Suggested text: ${suggested}.\n`, + ); + if ( + !/^y(?:es)?$/iu.test(await ask(io, "Apply that text correction? [y/N] ")) + ) + return undefined; + return validateEdit(io, initial, { ...changes, textColour: suggested }); + } +} diff --git a/src/cli/guide-command.ts b/src/cli/guide-command.ts new file mode 100644 index 0000000..08db3cb --- /dev/null +++ b/src/cli/guide-command.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { HttpUrlSchema } from "../core/schemas/index.js"; +import { parseCliArguments, type CliCommand } from "./arguments.js"; +import { CliArgumentError } from "./cli-error.js"; +import { flagsToArgv, readFlags, type CliFlags } from "./flags.js"; +import { promptBrief, promptField, promptProducts } from "./guide-fields.js"; +import { confirm, interactiveAllowed, type CliIo } from "./io.js"; + +const PathSchema = z + .string() + .trim() + .min(1) + .max(4096) + .regex(/^[^\u0000-\u001f\u007f]+$/u); + +/** Chooses explicit mode or a terminal-only guide, without prompting after unknown flags. */ +export async function resolveInvocation( + argv: readonly string[], + io: CliIo, +): Promise<{ command: CliCommand; guided: boolean }> { + if ( + !interactiveAllowed(argv, io) || + argv.some((arg) => ["--help", "-h", "--version", "-v"].includes(arg)) + ) { + return { command: parseCliArguments(argv), guided: false }; + } + const kind = argv[0] ?? "generate"; + if (kind !== "generate" && kind !== "render") + return { command: parseCliArguments(argv), guided: false }; + const flags = readFlags(argv.slice(1)); + if (argv.length && !flags.booleans.has("--interactive")) { + try { + return { command: parseCliArguments(argv), guided: false }; + } catch (error) { + if ( + !(error instanceof CliArgumentError) || + error.code !== "missing-arguments" + ) + throw error; + } + } + return { command: await guideCommand(kind, flags, io), guided: true }; +} + +/** Collects missing inputs then reparses the same flags used by automation. */ +async function guideCommand( + kind: "generate" | "render", + flags: CliFlags, + io: CliIo, +): Promise { + io.stderr( + `\nPunch — ${kind === "generate" ? "guided generation" : "restyle an existing campaign"}\n\n1. Sources\n`, + ); + if (kind === "generate") { + await promptField(io, flags, "--website", "Brand website", HttpUrlSchema); + await promptProducts(io, flags); + await promptBrief(io, flags); + } else { + await promptField( + io, + flags, + "--campaign", + "Existing campaign.json", + PathSchema, + ); + } + await promptField( + io, + flags, + "--brand", + "Saved brand profile (optional)", + PathSchema, + "", + true, + ); + await promptField( + io, + flags, + "--output", + "New output directory", + PathSchema, + kind === "render" ? "./campaign-restyled" : "./campaign", + ); + const command = parseCliArguments([kind, ...flagsToArgv(flags)]); + if (command.kind === "generate") { + io.stderr( + `\nWebsite: ${command.input.website}\nProducts: ${command.input.products.length}\nGoal: ${command.input.goal}\nOutput: ${command.output}\n`, + ); + await confirm( + io, + "Fetch these pages and review the detected branding? No AI call yet.", + ); + } + return command; +} diff --git a/src/cli/guide-fields.ts b/src/cli/guide-fields.ts new file mode 100644 index 0000000..cc72eef --- /dev/null +++ b/src/cli/guide-fields.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +import { CampaignGoalSchema, HttpUrlSchema } from "../core/schemas/index.js"; +import { + LongTextSchema, + ShortTextSchema, + CodeTextSchema, +} from "../core/schemas/primitives.js"; +import { flagValue, type CliFlags } from "./flags.js"; +import { ask, type CliIo } from "./io.js"; + +/** Fills one missing flag through its canonical schema, retrying invalid answers. */ +export async function promptField( + io: CliIo, + flags: CliFlags, + flag: string, + label: string, + schema: z.ZodType, + fallback = "", + optional = false, +): Promise { + const existing = flagValue(flags, flag); + if (existing !== undefined) { + schema.parse(existing); + return; + } + for (;;) { + const answer = + (await ask(io, `${label}${fallback ? ` [${fallback}]` : ""}: `)) || + fallback; + if (!answer && optional) return; + const parsed = schema.safeParse(answer); + if (parsed.success) { + flags.values.set(flag, [parsed.data]); + return; + } + io.stderr("That value is not valid. Please try again.\n"); + } +} + +/** Collects an ordered, unique product list with explicit add/remove controls. */ +export async function promptProducts( + io: CliIo, + flags: CliFlags, +): Promise { + const products = (flags.values.get("--product") ?? []).map((url) => + HttpUrlSchema.parse(url), + ); + for (;;) { + io.stderr( + products.length + ? `Products:\n${products.map((url, index) => ` ${index + 1}. ${url}`).join("\n")}\n` + : "Add at least one product page.\n", + ); + const answer = await ask( + io, + "Product URL, 'remove 1', or Enter to continue: ", + ); + if ( + !answer && + products.length >= 1 && + products.length <= 6 && + new Set(products).size === products.length + ) + break; + const remove = /^remove ([1-6])$/u.exec(answer); + if (remove) { + products.splice(Number(remove[1]) - 1, 1); + continue; + } + const parsed = HttpUrlSchema.safeParse(answer); + if ( + !parsed.success || + products.length >= 6 || + products.includes(parsed.data) + ) { + io.stderr( + "Use one to six unique HTTP(S) product URLs. Remove a product to correct the list.\n", + ); + continue; + } + products.push(parsed.data); + } + flags.values.set("--product", products); +} + +/** Collects the campaign safety policy and its optional human brief. */ +export async function promptBrief(io: CliIo, flags: CliFlags): Promise { + io.stderr( + "\n2. Campaign brief\n sales: sell existing products\n product-launch: introduce products\n promotion: promote an explicit offer\n", + ); + await promptField(io, flags, "--goal", "Goal", CampaignGoalSchema, "sales"); + if (flagValue(flags, "--goal") === "promotion") { + await promptField( + io, + flags, + "--offer", + "Offer description", + ShortTextSchema, + ); + await promptField( + io, + flags, + "--discount-code", + "Discount code (optional)", + CodeTextSchema, + "", + true, + ); + await promptField( + io, + flags, + "--offer-ends-at", + "Expiry with timezone (optional)", + z.iso.datetime({ offset: true }), + "", + true, + ); + } + await promptField( + io, + flags, + "--instructions", + "Campaign direction (optional)", + LongTextSchema, + "", + true, + ); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index bdec798..5016fd8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,7 +1,9 @@ export const CLI_HELP = `Punch — grounded ecommerce email generation Usage: + punch Start the guide in an interactive terminal punch generate --website --product ... --goal --output + punch render --campaign --output Required: --website Brand website @@ -10,6 +12,14 @@ Required: --output New output directory Optional: + --brand Load saved brand settings + --save-brand Save the final settings as a reusable JSON profile + --primary-colour Override the primary colour (#RRGGBB) + --background-colour Override the email background + --text-colour Override text (must pass contrast checks) + --heading-font Override one heading font family + --body-font Override one body font family + --interactive Review branding and preview even with complete inputs --instructions Additional bounded campaign direction --offer Required with the promotion goal --discount-code 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({}); + }); +});