Skip to content

Commit 25b9995

Browse files
gershamclaude
andcommitted
Compose poster headlines, a real far viewing scale, and named contrast lints
A region with headline: true lets its title grow past the role size to the largest that fits the region's width and height, so a one-line poster or sale sign no longer stops near 130 px on 1080p. viewing: far now sets every role 1.5x larger rather than only raising floors. The catalog documents valign (auto, top, center, bottom) and headline, and low_contrast_rendered names the region, role and quoted copy it flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 205cd1c commit 25b9995

8 files changed

Lines changed: 69 additions & 7 deletions

File tree

‎src/compose/catalog.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface ComposeCatalog {
5555
image_src: string;
5656
shadow: string;
5757
outline: string;
58+
valign: string;
59+
headline: string;
5860
layered: string;
5961
envelope: string;
6062
viewing: string;
@@ -71,7 +73,7 @@ const PAGE_KEYS = [
7173
];
7274
const REGION_FIELDS = [
7375
"eyebrow", "title", "subtitle", "text", "footer", "image", "video", "iframe", "webapp", "cards", "card", "table",
74-
"enter", "stagger", "motion", "align", "valign", "fill", "color", "z", "shadow", "outline",
76+
"enter", "stagger", "motion", "align", "valign", "fill", "color", "z", "shadow", "outline", "headline",
7577
];
7678

7779
export async function composeCatalog(): Promise<ComposeCatalog> {
@@ -289,6 +291,8 @@ export async function composeCatalog(): Promise<ComposeCatalog> {
289291
image_src: "local filesystem path relative to the spec file directory; iframe and webapp src may be a URL",
290292
shadow: 'Text over a page image or video with no fill gets a 1px unblurred drop shadow: #000000E6 on light type, #FFFFFFE6 on dark type. Set shadow to "none" or { x, y, color, blur? } to override. Optional blur is an integer 0 through 32; omit is 0 (unblurred offset fill). Auto media shadow stays 1 px unblurred. A card plate is backing, so type on a card does not get the automatic shadow.',
291293
outline: "outline is { width: 0.5-12, color } and is off unless set.",
294+
valign: "valign places a region's content vertically: top, center or bottom; auto (default) centers it, and side-by-side regions with the same top and height share one scale and offset so their titles line up. Explicit top, center or bottom opts a region out of that row.",
295+
headline: "headline: true lets the region's title grow past its role size to the largest that fits the region's width and height, one line per \\n line (posters, sale signs). Needs a title; other roles keep their sizes, and the whole pack still has to fit.",
292296
layered: "compose render writes one PNG per region plus manifest.json. --combined writes a flattened PNG for inspection. Default for agent work is layered.",
293297
envelope: "structured JSON, not pixels",
294298
viewing: viewingGuidance(),
@@ -333,6 +337,8 @@ export function formatComposeCatalog(catalog: ComposeCatalog): string {
333337
`image_src: ${catalog.rules.image_src}`,
334338
`shadow: ${catalog.rules.shadow}`,
335339
`outline: ${catalog.rules.outline}`,
340+
`valign: ${catalog.rules.valign}`,
341+
`headline: ${catalog.rules.headline}`,
336342
`layered: ${catalog.rules.layered}`,
337343
`viewing: ${catalog.rules.viewing}`,
338344
`lint: ${catalog.rules.lint}`,

‎src/compose/compose.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,24 @@ test("a type-only region uses one scale for every role", async () => {
318318
await rm(dir, { recursive: true, force: true });
319319
});
320320

321+
test("a headline region grows a one-line title past the role wish, bounded by the region", async () => {
322+
const dir = await testTemp("compose-headline-");
323+
const base = { width: 1920, height: 1080, background: "#101010", text: "#FFFFFF" };
324+
const titleSize = async (spec: Record<string, unknown>) => {
325+
const result = await composeDocument({ ...base, ...spec }, { baseDir: dir });
326+
return result.pages[0]!.quality.text.find((item) => item.role === "title")!.font_size;
327+
};
328+
const plain = await titleSize({ fullpage: { title: "SALE 20% OFF", align: "center", valign: "center" } });
329+
const headline = await titleSize({ fullpage: { title: "SALE 20% OFF", align: "center", valign: "center", headline: true } });
330+
assert.ok(plain <= Math.round(wishOf("title", 1080) * 1.35), `plain ${plain}`);
331+
assert.ok(headline >= 220 && headline > plain * 1.4, `headline ${headline} plain ${plain}`);
332+
assert.ok(headline <= 1080, `headline ${headline}`);
333+
const far = await titleSize({ viewing: "far", fullpage: { title: "SALE 20% OFF", align: "center", valign: "center" } });
334+
assert.ok(far > plain, `far ${far} plain ${plain}`);
335+
await assertUsageRejects(() => composeDocument({ ...base, fullpage: { text: "x", headline: true } }, { baseDir: dir }), /headline needs a title/);
336+
await rm(dir, { recursive: true, force: true });
337+
});
338+
321339
test("missing font is usage_error", async () => {
322340
await assertUsageRejects(
323341
() => composeDocument({ ...page(), font: "DefinitelyNotAInstalledFamily" }, { baseDir: process.cwd() }),

‎src/compose/compose.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { parseComposeSpec } from "./parse.js";
1515
import {
1616
SCALE_MAX,
1717
SCALE_MIN,
18+
headlineMax,
1819
leadingOf,
1920
sizesFor,
2021
textHeight,
@@ -86,6 +87,8 @@ export interface ComposeQuality {
8687
ink: Box;
8788
font_size: number;
8889
family: string;
90+
/** The run's plain copy, so a lint can name what it flags. */
91+
text?: string;
8992
}>;
9093
fonts: Array<{ layer: string; family: string; fallback_from?: string; missing_codepoints: string[] }>;
9194
overlaps: Array<{ first: string; second: string; kind: "layer_layer"; area: number }>;
@@ -263,6 +266,7 @@ function chooseScale(
263266
title: title && title.role === "title" ? title.text : "",
264267
subtitle: subtitle && subtitle.role === "subtitle" ? subtitle.text : "",
265268
scale,
269+
...(layer.headline && title && title.role === "title" ? { titleMax: headlineMax(title.text, inner.h) } : {}),
266270
});
267271
const footerH = footerText ? textHeight(ctx, footerText, inner.w, ramp.footer, family, leadingOf(ramp.footer)) : 0;
268272
const footerGap = footerText ? Math.round(ramp.footer * 0.8) : 0;
@@ -830,6 +834,7 @@ async function paintLayer(
830834
title: title && title.role === "title" ? title.text : "",
831835
subtitle: subtitle && subtitle.role === "subtitle" ? subtitle.text : "",
832836
scale,
837+
...(layer.headline && title && title.role === "title" ? { titleMax: headlineMax(title.text, fitBox.h) } : {}),
833838
});
834839
if (shared.scale === undefined && !hasFluid && fixed.length) {
835840
const chosen = chooseScale(ctx, layer, fitBox, family, fixed, footerText);

‎src/compose/lint.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ test("low_contrast_rendered fires on grey-on-grey and stays silent on a clean pa
7373
fullpage: { title: "Hello contrast" },
7474
});
7575
assert.ok(codes(findings).includes("low_contrast_rendered"), JSON.stringify(findings));
76+
const flagged = findings.find((finding) => finding.code === "low_contrast_rendered")!;
77+
assert.match(flagged.message, /fullpage title "Hello contrast" contrast/);
7678
const clean = await lintSpec(CLEAN);
7779
assert.equal(codes(clean).includes("low_contrast_rendered"), false);
7880
});

‎src/compose/lint.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function lintComposedPage(args: {
137137
page_id: args.page_id,
138138
code: "low_contrast_rendered",
139139
id: run.layer,
140-
message: `${args.page_id}: ${run.layer} contrast ${contrast.toFixed(2)}:1 is below 4.5:1.`,
140+
message: `${args.page_id}: ${run.layer} ${run.role}${quotedExcerpt(run.text)} contrast ${contrast.toFixed(2)}:1 is below 4.5:1.`,
141141
});
142142
}
143143
if (overBusyImage(run.layer, run.ink, args.quality, args.pixels)) {
@@ -153,6 +153,13 @@ export function lintComposedPage(args: {
153153
return findings;
154154
}
155155

156+
/** A short quote of the flagged copy, so the agent knows which text to change. */
157+
function quotedExcerpt(text: string | undefined): string {
158+
const flat = (text ?? "").replace(/\s+/g, " ").trim();
159+
if (!flat) return "";
160+
return ` "${flat.length > 40 ? `${flat.slice(0, 40)}…` : flat}"`;
161+
}
162+
156163
function countSpecWords(value: unknown): number {
157164
if (typeof value === "string") {
158165
const bits = value.trim().split(/\s+/);

‎src/compose/parse.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const REGION_KEYS = new Set([
6262
"image", "video", "iframe", "webapp",
6363
"cards", "card", "table",
6464
"enter", "stagger", "motion",
65-
"align", "valign", "fill", "color", "z", "shadow", "outline",
65+
"align", "valign", "fill", "color", "z", "shadow", "outline", "headline",
6666
]);
6767
const SHADOW_KEYS = new Set(["x", "y", "color", "blur"]);
6868
const OUTLINE_KEYS = new Set(["width", "color"]);
@@ -365,6 +365,9 @@ function parseRegion(name: RegionName, value: unknown, canvas: { width: number;
365365
if (!(ALIGN as readonly string[]).includes(String(align))) fail(`${name}.align must be left|center|right`);
366366
const valign = value.valign == null ? "auto" : value.valign;
367367
if (!(VALIGN as readonly string[]).includes(String(valign))) fail(`${name}.valign must be auto|top|center|bottom`);
368+
if (value.headline != null && typeof value.headline !== "boolean") fail(`${name}.headline must be true or false`);
369+
const headline = value.headline === true;
370+
if (headline && content.title == null) fail(`${name}.headline needs a title`);
368371
let enter: PrimitiveEnter | null = null;
369372
if (value.enter != null) {
370373
if (typeof value.enter !== "string" || !(ENTER_TYPES as readonly string[]).includes(value.enter)) {
@@ -425,6 +428,7 @@ function parseRegion(name: RegionName, value: unknown, canvas: { width: number;
425428
overMedia: Boolean(defaults.image || defaults.video),
426429
align: align as Align,
427430
valign: valign as Valign,
431+
headline,
428432
font: defaults.font,
429433
text: defaults.text,
430434
muted: defaults.muted,
@@ -464,6 +468,7 @@ function backgroundLayer(canvas: { width: number; height: number }, defaults: Pa
464468
overMedia: false,
465469
align: "left",
466470
valign: "top",
471+
headline: false,
467472
font: defaults.font,
468473
text: defaults.text,
469474
muted: defaults.muted,
@@ -502,6 +507,7 @@ function logoLayer(canvas: { width: number; height: number }, defaults: PageDefa
502507
overMedia: Boolean(defaults.image || defaults.video),
503508
align: "left",
504509
valign: "top",
510+
headline: false,
505511
font: defaults.font,
506512
text: defaults.text,
507513
muted: defaults.muted,

‎src/compose/type.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export const VIEWING_XHEIGHT_RATIO: Record<ViewingDistance, number> = {
1414

1515
/** Fallback x-height as a fraction of font size when metrics are missing. */
1616
export const XHEIGHT_FALLBACK = 0.52;
17+
/**
18+
* Role wishes per viewing distance. Far-away readers get larger type, not just
19+
* a higher floor; near and mid keep the reference ramp.
20+
*/
21+
export const VIEWING_WISH_SCALE: Record<ViewingDistance, number> = { near: 1, mid: 1, far: 1.5 };
1722
export const SCALE_MIN = 0.65;
1823
export const SCALE_MAX = 1.35;
1924
export const REFERENCE_CANVAS = { width: 1920, height: 1080 } as const;
@@ -75,18 +80,20 @@ export function sizeFor(
7580
root: number;
7681
viewing: ViewingDistance;
7782
scale?: number;
83+
/** Headline: a line-fit role may grow past its wish up to this size, as wide as the region allows. */
84+
max?: number;
7885
},
7986
): number {
8087
const spec = ROLE[args.role];
81-
const baseWish = wishOf(args.role, args.root);
88+
const baseWish = Math.round(wishOf(args.role, args.root) * VIEWING_WISH_SCALE[args.viewing]);
8289
const floor = floorOf(args.role, args.root, args.viewing);
8390
const scale = args.scale ?? 1;
8491
const ceiling = Math.max(floor, Math.round(baseWish * SCALE_MAX));
8592
const wish = Math.min(ceiling, Math.max(floor, Math.round(baseWish * scale)));
8693
if (spec.fit !== "line" || !args.text) return wish;
8794
const lines = String(args.text).split("\n");
8895
let lo = floor;
89-
let hi = wish;
96+
let hi = args.max !== undefined ? Math.max(floor, Math.round(args.max * scale)) : wish;
9097
let best = floor;
9198
while (lo <= hi) {
9299
const mid = Math.floor((lo + hi) / 2);
@@ -122,12 +129,14 @@ export function sizesFor(
122129
title?: string;
123130
subtitle?: string;
124131
scale?: number;
132+
/** Headline regions: the title's size limit from the region height. */
133+
titleMax?: number;
125134
},
126135
): TypeRamp {
127136
const shared = { family: args.family, width: args.width, root: args.root, viewing: args.viewing, scale: args.scale ?? 1 };
128137
return {
129138
eyebrow: sizeFor(ctx, { role: "eyebrow", text: args.eyebrow, ...shared }),
130-
title: sizeFor(ctx, { role: "title", text: args.title, ...shared }),
139+
title: sizeFor(ctx, { role: "title", text: args.title, ...shared, ...(args.titleMax !== undefined ? { max: args.titleMax } : {}) }),
131140
subtitle: sizeFor(ctx, { role: "subtitle", text: args.subtitle, ...shared }),
132141
text: sizeFor(ctx, { role: "text", ...shared }),
133142
footer: sizeFor(ctx, { role: "footer", ...shared }),
@@ -177,5 +186,12 @@ export function textHeight(
177186
}
178187

179188
export function viewingGuidance(): string {
180-
return 'optional page "near"|"mid"|"far"; default mid. Type floors use that x-height at layout (1080p mid body wish is ~45 px); lint warns too_small_for_distance if a node still undershoots 0.7%/1.2%/2.0% of the shorter edge.';
189+
return 'optional page "near"|"mid"|"far"; default mid. far sets every role 1.5x larger (1080p far body ~68 px, title ~195 px) and every distance raises type floors to its x-height; lint warns too_small_for_distance if a node still undershoots 0.7%/1.2%/2.0% of the shorter edge.';
190+
}
191+
192+
/** The largest title size a headline region can hold: its lines stacked in the region height. */
193+
export function headlineMax(title: string | undefined, regionHeight: number): number | undefined {
194+
if (!title) return undefined;
195+
const lines = Math.max(1, String(title).split("\n").length);
196+
return Math.floor(regionHeight / (lines * 1.28));
181197
}

‎src/compose/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export interface LayerSpec {
138138
overMedia: boolean;
139139
align: Align;
140140
valign: Valign;
141+
/** Region title grows to the largest size that fits the region, one line per line. */
142+
headline: boolean;
141143
font: string | null;
142144
text: string;
143145
muted: string;

0 commit comments

Comments
 (0)