fix(release): wrap card text by estimated width, not character count

A character budget is not a width budget. Codex measured a 34-character
line of `W` at font-size 52 rendering ~1948px into 1072px of available
space: the previous fix guaranteed line LENGTH, so its test passed while
the card still cropped.

Card text now wraps by estimated rendered width. `estimateTextWidth`
assigns per-glyph advance factors — calibrated against that measurement and
deliberately erring high, since over-estimating wraps a line early
(invisible) while under-estimating crops the card — and every call site
passes the real pixel budget instead of a character count. Text that still
cannot fit is ellipsized to width, and each emitted line carries an SVG
`textLength`/`spacingAndGlyphs` clamp when the estimate says it would
overflow anyway, so a mis-measured glyph compresses rather than crops.

Verified by rendering the pathological 60-`W` headline through sharp on
both card layouts and the hero: all stay inside the canvas.

Docs: the 60-character cap is now described as the authoring guideline it
is, not a rendering guarantee.

187 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 10:21:00 +02:00
1 parent 62359c80fc
commit 091693924c
6 files changed
+210 -65

No files matched your search

+3 -2
View File
@@ -52,8 +52,9 @@ The body is capped at 400 characters — depth belongs in the blog post.
- ✅ "Series whose title carries a season marker no longer show the wrong season"
`highlight` marks the change as one of the release's headline features and
gives it a short, poster-worthy name. The 60-character cap is the hero card's
single-line budget, so a valid highlight always renders in full. Highlights lead the Telegram/Reddit
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
one line on the hero card; card text wraps by estimated width, so a headline of
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
announcements (everything else collapses into a "+N more" counter) and become
ready-made section headings in the blog scaffold. Set it on the two or three
changes worth announcing — a release where everything is a highlight has none.
+1 -1
View File
@@ -66,7 +66,7 @@ This file provides guidance to coding agents working in this repository.
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
- `highlight: <short headline>` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
+1 -1
View File
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
- `highlight: <short headline>` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
+11 -3
View File
@@ -47,9 +47,17 @@ published its frames, since they composite them.
## `highlight:` — what leads a release
An optional note field naming one of the release's two or three headline
changes. It is rejected on `type: internal`, and capped at **60 characters**
because that is the hero card's single-line budget (`wrapText(headline, 60, 1)`)
— a longer value would be silently ellipsized on an image nobody re-reads.
changes. It is rejected on `type: internal`, and capped at **60 characters** to
keep it headline-sized — roughly what the hero card fits on one line.
The cap is an authoring guideline, not a rendering guarantee: character count
is not width. Card text wraps by *estimated rendered width*
(`estimateTextWidth`, calibrated against a measured bold rendering and erring
high), because 34 `W` at font-size 52 measures ~1948px where 1072px are
available — a character-capped line still ran off the canvas. Text that cannot
fit even after wrapping is ellipsized, and every emitted line carries an SVG
`textLength` clamp when the estimate says it would still overflow, so a
mis-measured glyph compresses rather than crops.
Highlights drive three behaviors:
+145 -37
View File
@@ -21,6 +21,13 @@ export const SHOT_TOP = 330;
export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2;
export const SHOT_RADIUS = 14;
/** Left margin, and the width text may occupy before the right margin. */
const TEXT_LEFT = 64;
export const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_LEFT * 2;
/** Hero bullet lines start further right, after the accent dot. */
const HERO_BULLET_LEFT = 100;
export const HERO_BULLET_MAX_WIDTH = CARD_WIDTH - HERO_BULLET_LEFT - TEXT_LEFT;
/** Feature-card text block, laid out to always clear the screenshot frame. */
const HEADLINE_TOP = 168;
const HEADLINE_LINE_HEIGHT = 58;
@@ -60,35 +67,89 @@ export function escapeXml(text) {
}
/**
* Greedy word wrap by character budget. SVG has no automatic text layout and
* font metrics vary by host, so the budget is conservative.
* Per-character advance width as a fraction of the font size.
*
* Every returned line is at most `maxChars` long, including when a single
* word exceeds the budget: such a word is broken at the budget rather than
* left whole. Leaving it whole is what a naive wrap does, and a 60-character
* unbroken headline — well inside the validated limit — then rendered past
* the edge of the 1200px card and was cropped.
* Counting characters is not a width budget: 34 `W` at font-size 52 measures
* ~1948px where only ~1072px are available, so a character-capped line still
* overflowed the canvas. These factors are calibrated against that measured
* bold rendering and deliberately err high — over-estimating wraps a line
* early, which is invisible; under-estimating crops the card.
*/
const WIDE_GLYPHS = new Set('MWmw@%ЖШЩбюфЮ');
const NARROW_GLYPHS = new Set("iljItfrJ.,;:'\"`!|()[]{}/\\-ЁІ");
/**
* @param {string} text
* @param {number} fontSize
* @returns {number} estimated rendered width in pixels
*/
export function estimateTextWidth(text, fontSize) {
let units = 0;
for (const character of text) {
if (character === ' ') {
units += 0.3;
} else if (NARROW_GLYPHS.has(character)) {
units += 0.35;
} else if (WIDE_GLYPHS.has(character)) {
units += 1.1;
} else if (character === character.toUpperCase() && character !== character.toLowerCase()) {
units += 0.78;
} else {
units += 0.6;
}
}
return units * fontSize;
}
/** Splits one overlong word into chunks that each fit `maxWidth`. */
function breakWord(word, maxWidth, fontSize) {
const chunks = [];
let chunk = '';
for (const character of word) {
if (
chunk &&
estimateTextWidth(chunk + character, fontSize) > maxWidth
) {
chunks.push(chunk);
chunk = character;
continue;
}
chunk += character;
}
if (chunk) {
chunks.push(chunk);
}
return chunks;
}
/**
* Greedy word wrap by estimated rendered width. SVG has no automatic text
* layout, so the wrap has to decide the breaks itself; every returned line is
* estimated to fit `maxWidth`, including when a single word does not — such a
* word is broken rather than left to run off the canvas.
*
* @param {string} text
* @param {number} maxChars
* @param {number} maxLines
* @returns {string[]} at most maxLines lines, the last one ellipsized on overflow
* @param {{ maxWidth: number, fontSize: number, maxLines: number }} options
* @returns {string[]} at most maxLines lines, the last ellipsized on overflow
*/
export function wrapText(text, maxChars, maxLines) {
const words = text.replace(/\s+/g, ' ').trim().split(' ');
export function wrapText(text, { maxWidth, fontSize, maxLines }) {
const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
const lines = [];
let current = '';
for (const word of words) {
if (word.length > maxChars) {
if (estimateTextWidth(word, fontSize) > maxWidth) {
if (current) {
lines.push(current);
}
for (let start = 0; start < word.length; start += maxChars) {
lines.push(word.slice(start, start + maxChars));
}
lines.push(...breakWord(word, maxWidth, fontSize));
// Keep the final chunk open so a following short word can join it.
current = lines.pop() ?? '';
continue;
@@ -96,7 +157,7 @@ export function wrapText(text, maxChars, maxLines) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length <= maxChars || !current) {
if (!current || estimateTextWidth(candidate, fontSize) <= maxWidth) {
current = candidate;
continue;
}
@@ -111,10 +172,16 @@ export function wrapText(text, maxChars, maxLines) {
if (lines.length > maxLines) {
const kept = lines.slice(0, maxLines);
const last = kept[maxLines - 1];
let last = kept[maxLines - 1];
kept[maxLines - 1] =
`${last.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
while (
last.length > 1 &&
estimateTextWidth(`${last}…`, fontSize) > maxWidth
) {
last = last.slice(0, -1);
}
kept[maxLines - 1] = `${last.trimEnd()}…`;
return kept;
}
@@ -249,12 +316,21 @@ function brandHeader(version) {
].join('');
}
function textLines(lines, { x, y, size, weight, fill, lineHeight }) {
/**
* `maxWidth` is a hard backstop, not the wrap budget: the wrap already fits
* every line by estimate, and this clamps anything the estimate got wrong so
* a mis-measured glyph compresses instead of running off the canvas.
*/
function textLines(lines, { x, y, size, weight, fill, lineHeight, maxWidth }) {
return lines
.map(
(line, index) =>
`<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}">${escapeXml(line)}</text>`
)
.map((line, index) => {
const clamp =
maxWidth && estimateTextWidth(line, size) > maxWidth
? ` textLength="${maxWidth}" lengthAdjust="spacingAndGlyphs"`
: '';
return `<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}"${clamp}>${escapeXml(line)}</text>`;
})
.join('');
}
@@ -277,7 +353,11 @@ export function buildFeatureCardSvg(job, version) {
];
if (job.screenshotPath) {
const headline = wrapText(job.headline, 34, 2);
const headline = wrapText(job.headline, {
maxWidth: TEXT_MAX_WIDTH,
fontSize: 52,
maxLines: 2,
});
// The body's line budget is derived from the space actually left above
// the screenshot frame, never assumed: the frame is opaque and painted
// after the text, so a fixed line count silently sliced the last line
@@ -291,26 +371,32 @@ export function buildFeatureCardSvg(job, version) {
Math.floor((TEXT_BOTTOM - bodyTop) / BODY_LINE_HEIGHT) + 1
)
);
const body = wrapText(job.body, 88, bodyLines);
const body = wrapText(job.body, {
maxWidth: TEXT_MAX_WIDTH,
fontSize: 23,
maxLines: bodyLines,
});
parts.push(
textLines(headline, {
x: 64,
x: TEXT_LEFT,
y: HEADLINE_TOP,
size: 52,
weight: 800,
fill: BRAND.text,
lineHeight: HEADLINE_LINE_HEIGHT,
maxWidth: TEXT_MAX_WIDTH,
})
);
parts.push(
textLines(body, {
x: 64,
x: TEXT_LEFT,
y: bodyTop,
size: 23,
weight: 400,
fill: BRAND.muted,
lineHeight: BODY_LINE_HEIGHT,
maxWidth: TEXT_MAX_WIDTH,
})
);
// Frame stroke sits behind the composited screenshot; the strip is
@@ -319,31 +405,41 @@ export function buildFeatureCardSvg(job, version) {
`<rect x="${SHOT_LEFT - 2}" y="${SHOT_TOP - 2}" width="${SHOT_WIDTH + 4}" height="${CARD_HEIGHT - SHOT_TOP + 4}" rx="${SHOT_RADIUS + 2}" fill="${BRAND.frame}"/>`
);
} else {
const headline = wrapText(job.headline, 30, 2);
const body = wrapText(job.body, 74, 3);
const headline = wrapText(job.headline, {
maxWidth: TEXT_MAX_WIDTH,
fontSize: 62,
maxLines: 2,
});
const body = wrapText(job.body, {
maxWidth: TEXT_MAX_WIDTH,
fontSize: 26,
maxLines: 3,
});
const headlineY = 250;
parts.push(
textLines(headline, {
x: 64,
x: TEXT_LEFT,
y: headlineY,
size: 62,
weight: 800,
fill: BRAND.text,
lineHeight: 74,
maxWidth: TEXT_MAX_WIDTH,
})
);
parts.push(
`<rect x="64" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
`<rect x="${TEXT_LEFT}" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
);
parts.push(
textLines(body, {
x: 64,
x: TEXT_LEFT,
y: headlineY + headline.length * 74 + 8,
size: 26,
weight: 400,
fill: BRAND.muted,
lineHeight: 38,
maxWidth: TEXT_MAX_WIDTH,
})
);
}
@@ -380,10 +476,22 @@ export function buildHeroCardSvg(hero) {
);
// wrapText yields no lines for whitespace-only input; validation
// rejects that upstream, but a card run must not die half-written.
const [line = ''] = wrapText(headline, 60, 1);
const [line = ''] = wrapText(headline, {
maxWidth: HERO_BULLET_MAX_WIDTH,
fontSize: 30,
maxLines: 1,
});
parts.push(
`<text x="100" y="${y}" font-family="${FONT_STACK}" font-size="30" font-weight="600" fill="${BRAND.text}">${escapeXml(line)}</text>`
textLines([line], {
x: HERO_BULLET_LEFT,
y,
size: 30,
weight: 600,
fill: BRAND.text,
lineHeight: 0,
maxWidth: HERO_BULLET_MAX_WIDTH,
})
);
});
+49 -21
View File
@@ -20,10 +20,13 @@ import {
CARD_HEIGHT,
CARD_WIDTH,
escapeXml,
estimateTextWidth,
HERO_BULLET_MAX_WIDTH,
isOwnedCardFile,
planHighlightCards,
SHOT_TOP,
TEXT_BOTTOM,
TEXT_MAX_WIDTH,
wrapText,
} from './highlight-cards.mjs';
import {
@@ -70,46 +73,71 @@ after(() => {
});
describe('wrapText', () => {
it('wraps on word boundaries within the budget', () => {
assert.deepEqual(wrapText('one two three four', 9, 3), [
const budget = (maxWidth, fontSize, maxLines) => ({
maxWidth,
fontSize,
maxLines,
});
it('wraps on word boundaries within the width budget', () => {
assert.deepEqual(wrapText('one two three four', budget(60, 10, 4)), [
'one two',
'three four',
]);
// A tighter budget breaks earlier, still only between words.
assert.deepEqual(wrapText('one two three four', budget(30, 10, 4)), [
'one',
'two',
'three',
'four',
]);
});
it('breaks a word longer than the budget instead of overflowing the card', () => {
assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [
'supercalif',
'ragilistic',
'ok',
]);
it('breaks a word wider than the budget instead of overflowing the card', () => {
const lines = wrapText('supercalifragilistic ok', budget(60, 10, 4));
assert.ok(lines.length > 1);
assert.equal(lines.join('').replace(/ /g, ''), 'supercalifragilisticok');
});
it('never returns a line wider than the budget', () => {
// A 60-character unbroken highlight is valid input; on a 34-character
// feature-card budget it must still fit inside the canvas.
for (const [text, maxChars, maxLines] of [
['W'.repeat(60), 34, 2],
['W'.repeat(60), 30, 2],
['W'.repeat(60), 60, 1],
['a'.repeat(200), 34, 2],
['short words only here', 34, 2],
it('keeps every line inside the budget, wide glyphs included', () => {
// Counting characters is not a width budget: 34 `W` at font-size 52
// measures ~1948px where only 1072px are available.
for (const [text, maxWidth, fontSize, maxLines] of [
['W'.repeat(60), TEXT_MAX_WIDTH, 52, 2],
['W'.repeat(60), TEXT_MAX_WIDTH, 62, 2],
['W'.repeat(60), HERO_BULLET_MAX_WIDTH, 30, 1],
['M'.repeat(200), TEXT_MAX_WIDTH, 23, 3],
['ЖЮ'.repeat(40), TEXT_MAX_WIDTH, 52, 2],
['Advanced subtitles with external files', TEXT_MAX_WIDTH, 52, 2],
]) {
for (const line of wrapText(text, maxChars, maxLines)) {
for (const line of wrapText(
text,
budget(maxWidth, fontSize, maxLines)
)) {
const width = estimateTextWidth(line, fontSize);
assert.ok(
line.length <= maxChars,
`"${line}" is ${line.length} > ${maxChars}`
width <= maxWidth,
`"${line}" estimates ${Math.round(width)}px > ${maxWidth}px`
);
}
}
});
it('estimates a wide glyph run near its measured rendered width', () => {
// Calibration anchor: 34 `W` at font-size 52 renders ~1948px.
const estimate = estimateTextWidth('W'.repeat(34), 52);
assert.ok(estimate > 1800, `estimate ${estimate} is too low`);
});
it('ellipsizes the last kept line on overflow', () => {
const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);
const lines = wrapText('aaa bbb ccc ddd eee', budget(30, 10, 2));
assert.equal(lines.length, 2);
assert.match(lines[1], /…$/);
assert.ok(estimateTextWidth(lines[1], 10) <= 30);
});
});