diff --git a/.changes/README.md b/.changes/README.md
index 6e7dfd52f..0e3e49811 100644
--- a/.changes/README.md
+++ b/.changes/README.md
@@ -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.
diff --git a/AGENTS.md b/AGENTS.md
index 52bc15ee5..380832d3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,7 +66,7 @@ This file provides guidance to coding agents working in this repository.
- Name it `-.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: ` (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: ` (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`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 63eeff64d..a6e35f48e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Name it `-.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: ` (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: ` (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`.
diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md
index 6f8b9a795..b90a94c8a 100644
--- a/docs/architecture/release-pipeline.md
+++ b/docs/architecture/release-pipeline.md
@@ -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:
diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs
index e422d0aaf..8ba5a6225 100644
--- a/tools/release/highlight-cards.mjs
+++ b/tools/release/highlight-cards.mjs
@@ -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) =>
- `${escapeXml(line)}`
- )
+ .map((line, index) => {
+ const clamp =
+ maxWidth && estimateTextWidth(line, size) > maxWidth
+ ? ` textLength="${maxWidth}" lengthAdjust="spacingAndGlyphs"`
+ : '';
+
+ return `${escapeXml(line)}`;
+ })
.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) {
``
);
} 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(
- ``
+ ``
);
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(
- `${escapeXml(line)}`
+ textLines([line], {
+ x: HERO_BULLET_LEFT,
+ y,
+ size: 30,
+ weight: 600,
+ fill: BRAND.text,
+ lineHeight: 0,
+ maxWidth: HERO_BULLET_MAX_WIDTH,
+ })
);
});
diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs
index a10c0dfb8..390323fd2 100644
--- a/tools/release/highlight-cards.test.mjs
+++ b/tools/release/highlight-cards.test.mjs
@@ -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);
});
});