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); }); });