From 7e60adc1b56dcfb39780de4af27270465c72ec16 Mon Sep 17 00:00:00 2001 From: Sanketh Kumar Date: Tue, 27 Oct 2026 19:28:20 +0530 Subject: [PATCH 1/2] feat(templates): add updated-post blog template Closes #289 --- src/templates/blog/index.ts | 1 + src/templates/blog/updated-post.ts | 289 +++++++++++++++++++++++++++ src/templates/registry.ts | 2 + tests/templates/updated-post.test.ts | 260 ++++++++++++++++++++++++ 4 files changed, 552 insertions(+) create mode 100644 src/templates/blog/updated-post.ts create mode 100644 tests/templates/updated-post.test.ts diff --git a/src/templates/blog/index.ts b/src/templates/blog/index.ts index 7b02d75..6d011d4 100644 --- a/src/templates/blog/index.ts +++ b/src/templates/blog/index.ts @@ -13,3 +13,4 @@ export { default as blogRetroTerminal } from './retro-terminal'; export { default as blogSeriesPart } from './series-part'; export { default as blogSummaryCard } from './summary-card'; export { default as blogTableOfContents } from './table-of-contents'; +export { default as blogUpdatedPost } from './updated-post'; diff --git a/src/templates/blog/updated-post.ts b/src/templates/blog/updated-post.ts new file mode 100644 index 0000000..967ca17 --- /dev/null +++ b/src/templates/blog/updated-post.ts @@ -0,0 +1,289 @@ +import type { SatoriElement, TemplateDefinition } from '../types'; +import { truncate, autoFontSize, isLightColor, mixColors, contrastText, commonFields } from '../utils'; + +const DEFAULT_TITLE = 'A practical guide to React Server Components'; +const DEFAULT_PUBLISHED = 'First published Mar 3, 2024'; +const DEFAULT_UPDATED = 'Updated Oct 27, 2026'; + +// Collapse runs of whitespace so a field padded with spaces reads as empty. +const cleanText = (value: unknown, maxLength: number) => + truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength); + +const template: TemplateDefinition = { + id: 'blog-updated-post', + name: 'Updated Post', + category: 'blog', + description: 'A refreshed post card with an Updated label, the publish and update dates, and a short list of what changed', + tags: ['blog', 'updated', 'changelog', 'post', 'revision'], + fields: [ + commonFields.title, + { key: 'published', label: 'First Published', type: 'text', defaultValue: DEFAULT_PUBLISHED, placeholder: 'Optional, e.g. First published Mar 3, 2024', group: 'Content' }, + { key: 'updated', label: 'Updated', type: 'text', defaultValue: DEFAULT_UPDATED, placeholder: 'Optional, e.g. Updated Oct 27, 2026', group: 'Content' }, + { key: 'change1', label: 'Change 1', type: 'text', defaultValue: 'Covers React 19', placeholder: 'Optional, what changed', group: 'Content' }, + { key: 'change2', label: 'Change 2', type: 'text', defaultValue: 'New section on server actions', placeholder: 'Optional, what changed', group: 'Content' }, + { key: 'change3', label: 'Change 3', type: 'text', defaultValue: 'Fixed the broken examples', placeholder: 'Optional, what changed', group: 'Content' }, + commonFields.siteName, + commonFields.bgColor, + commonFields.accentColor, + commonFields.textColor, + ], + defaults: { + title: DEFAULT_TITLE, + published: DEFAULT_PUBLISHED, + updated: DEFAULT_UPDATED, + change1: 'Covers React 19', + change2: 'New section on server actions', + change3: 'Fixed the broken examples', + siteName: 'codercops.com', + bgColor: '#0b0f1a', + accentColor: '#7dd3a0', + textColor: '#f8fafc', + }, + render: (params) => { + const title = cleanText(params.title, 84) || DEFAULT_TITLE; + // A blank or omitted date hides on its own, so the line never renders a stray separator. + const published = cleanText(params.published, 40); + const updated = cleanText(params.updated, 40); + const siteName = cleanText(params.siteName, 36); + + const changes = [params.change1, params.change2, params.change3] + .map((change) => cleanText(change, 64)) + .filter((change) => change.length > 0); + + const bgColor = String(params.bgColor || '#0b0f1a'); + const accentColor = String(params.accentColor || '#7dd3a0'); + const textColor = String(params.textColor || '#f8fafc'); + + const titleSize = autoFontSize(title, [ + { maxLen: 24, size: 72 }, + { maxLen: 40, size: 60 }, + { maxLen: 56, size: 52 }, + { maxLen: 72, size: 46 }, + { maxLen: 84, size: 40 }, + ]); + + // Neutral colors are mixed from the background and text colors, so the card reads the + // same on dark and on light backgrounds. + const lightBg = isLightColor(bgColor); + const secondaryText = mixColors(bgColor, textColor, 0.62); + // An accent as light as the background (or as dark as it) is hard to read as text, so + // pull it toward the text color. Otherwise it reads as it is. + const accentText = isLightColor(accentColor) === lightBg + ? mixColors(accentColor, textColor, 0.55) + : accentColor; + const pillText = contrastText(accentColor); + + const dates: SatoriElement[] = [ + ...(published + ? [ + { + type: 'span', + props: { + style: { fontSize: '22px', fontWeight: 500, color: secondaryText, whiteSpace: 'nowrap' }, + children: published, + }, + }, + ] + : []), + ...(published && updated + ? [ + { + type: 'div', + props: { + style: { display: 'flex', margin: '0 14px', flexShrink: 0 }, + children: { + type: 'span', + props: { + style: { fontSize: '22px', fontWeight: 500, color: secondaryText }, + children: '·', + }, + }, + }, + }, + ] + : []), + ...(updated + ? [ + { + type: 'span', + props: { + style: { fontSize: '22px', fontWeight: 600, color: accentText, whiteSpace: 'nowrap' }, + children: updated, + }, + }, + ] + : []), + ]; + + const changeRows: SatoriElement[] = changes.map((change, index) => ({ + type: 'div', + props: { + style: { + display: 'flex', + alignItems: 'center', + marginBottom: index < changes.length - 1 ? '14px' : '0px', + }, + children: [ + { + type: 'div', + props: { + style: { + display: 'flex', + width: '10px', + height: '10px', + borderRadius: '50%', + backgroundColor: accentText, + marginRight: '18px', + flexShrink: 0, + }, + children: [], + }, + }, + { + type: 'span', + props: { + style: { + display: 'flex', + fontSize: '26px', + fontWeight: 500, + color: textColor, + lineHeight: 1.3, + }, + children: change, + }, + }, + ], + }, + })); + + // Title, dates and changes share the space between the pill and the footer, so a card + // with only a few lines stays centred instead of leaving a gap at the bottom. + const body: SatoriElement = { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' }, + children: [ + { + type: 'h1', + props: { + style: { + display: 'flex', + fontSize: `${titleSize}px`, + fontWeight: 700, + color: textColor, + lineHeight: 1.12, + letterSpacing: '-0.03em', + margin: 0, + marginBottom: '20px', + }, + children: title, + }, + }, + ...(dates.length > 0 + ? [{ + type: 'div', + props: { + // No trailing gap when there is no change list underneath. + style: { display: 'flex', alignItems: 'center', marginBottom: changes.length > 0 ? '40px' : '0px' }, + children: dates, + }, + }] + : []), + ...(changes.length > 0 + ? [ + { + type: 'div', + props: { + style: { display: 'flex', flexDirection: 'column' }, + children: [ + { + type: 'span', + props: { + style: { + fontSize: '18px', + fontWeight: 700, + color: accentText, + letterSpacing: '0.14em', + textTransform: 'uppercase', + marginBottom: '18px', + }, + children: 'What changed', + }, + }, + ...changeRows, + ], + }, + }, + ] + : []), + ], + }, + }; + + return { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + width: '1200px', + height: '630px', + backgroundColor: bgColor, + fontFamily: 'Inter, sans-serif', + padding: '60px 80px', + boxSizing: 'border-box', + }, + children: [ + // Updated pill + { + type: 'div', + props: { + style: { display: 'flex', marginBottom: '26px' }, + children: { + type: 'div', + props: { + style: { display: 'flex', padding: '9px 24px', borderRadius: '999px', backgroundColor: accentColor }, + children: { + type: 'span', + props: { + style: { fontSize: '20px', fontWeight: 700, color: pillText, letterSpacing: '0.12em', textTransform: 'uppercase' }, + children: 'Updated', + }, + }, + }, + }, + }, + }, + body, + // Site name + ...(siteName + ? [ + { + type: 'div', + props: { + style: { + display: 'flex', + // Keeps a floor between the last change line and the rule once the + // body above is centred in the leftover space. + marginTop: '32px', + paddingTop: '20px', + borderTop: `1px solid ${textColor}1A`, + }, + children: { + type: 'span', + props: { + style: { fontSize: '17px', fontWeight: 600, color: secondaryText, letterSpacing: '0.04em' }, + children: siteName, + }, + }, + }, + }, + ] + : []), + ], + }, + }; + }, +}; + +export default template; diff --git a/src/templates/registry.ts b/src/templates/registry.ts index 7d71171..fe60657 100644 --- a/src/templates/registry.ts +++ b/src/templates/registry.ts @@ -57,6 +57,7 @@ import blogRetroTerminal from './blog/retro-terminal'; import blogSeriesPart from './blog/series-part'; import blogSummaryCard from './blog/summary-card'; import blogTableOfContents from './blog/table-of-contents'; +import blogUpdatedPost from './blog/updated-post'; [ blogMinimalDark, @@ -74,6 +75,7 @@ import blogTableOfContents from './blog/table-of-contents'; blogSeriesPart, blogSummaryCard, blogTableOfContents, + blogUpdatedPost, ].forEach(registerTemplate); // Product diff --git a/tests/templates/updated-post.test.ts b/tests/templates/updated-post.test.ts new file mode 100644 index 0000000..ae7dc57 --- /dev/null +++ b/tests/templates/updated-post.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import blogUpdatedPost from '@/templates/blog/updated-post'; +import { blogUpdatedPost as blogUpdatedPostExport } from '@/templates/blog'; +import { getTemplate, getTemplatesByCategory } from '@/templates/registry'; +import { hexToRgb } from '@/templates/utils'; +import type { SatoriElement } from '@/templates/types'; + +function walk(node: unknown, visit: (element: SatoriElement) => void): void { + if (Array.isArray(node)) { + node.forEach((child) => walk(child, visit)); + return; + } + if (!node || typeof node !== 'object' || !('type' in node)) return; + + const element = node as SatoriElement; + visit(element); + walk(element.props?.children, visit); +} + +function textContent(node: unknown): string[] { + if (typeof node === 'string') return [node]; + if (Array.isArray(node)) return node.flatMap(textContent); + if (node && typeof node === 'object' && 'props' in node) { + return textContent((node as SatoriElement).props.children); + } + return []; +} + +function styleOf(node: unknown, text: string): Record | undefined { + if (Array.isArray(node)) { + for (const child of node) { + const found = styleOf(child, text); + if (found) return found; + } + return undefined; + } + if (node && typeof node === 'object' && 'props' in node) { + const { style, children } = (node as SatoriElement).props; + if (children === text) return style; + return styleOf(children, text); + } + return undefined; +} + +// The bullet before each "What changed" line. +function bullets(node: unknown): Record[] { + const found: Record[] = []; + walk(node, (element) => { + if (element.props?.style?.borderRadius === '50%') found.push(element.props.style); + }); + return found; +} + +// The row wrapping each bullet, which carries the spacing between lines. +function bulletRows(node: unknown): Record[] { + const found: Record[] = []; + walk(node, (element) => { + if (element.type !== 'div' || element.props?.style?.display !== 'flex') return; + const children = element.props.children; + if (!Array.isArray(children)) return; + const hasBullet = children.some( + (child) => child && typeof child === 'object' && child.props?.style?.borderRadius === '50%' + ); + if (hasBullet) found.push(element.props.style); + }); + return found; +} + +function luminance(hex: string): number { + const rgb = hexToRgb(hex); + if (!rgb) throw new Error(`not a hex color: ${hex}`); + const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const tree = (overrides: Record = {}) => + blogUpdatedPost.render({ ...blogUpdatedPost.defaults, ...overrides }); + +describe('blog-updated-post', () => { + it('is re-exported and registered in the blog category', () => { + expect(blogUpdatedPostExport).toBe(blogUpdatedPost); + expect(getTemplate('blog-updated-post')).toBe(blogUpdatedPost); + expect( + getTemplatesByCategory('blog').filter((t) => t.id === 'blog-updated-post') + ).toHaveLength(1); + expect(blogUpdatedPost.category).toBe('blog'); + }); + + it('renders root element with 1200x630 dimensions', () => { + const root = tree(); + expect(root.props.style.width).toBe('1200px'); + expect(root.props.style.height).toBe('630px'); + }); + + it('ensures every div in the tree uses flex display', () => { + walk(tree(), (element) => { + if (element.type === 'div') { + expect(element.props.style?.display).toBe('flex'); + } + }); + }); + + it('renders the label, title, both dates, the changes and the site name with defaults', () => { + expect(textContent(tree())).toEqual([ + 'Updated', + 'A practical guide to React Server Components', + 'First published Mar 3, 2024', + '·', + 'Updated Oct 27, 2026', + 'What changed', + 'Covers React 19', + 'New section on server actions', + 'Fixed the broken examples', + 'codercops.com', + ]); + }); + + it('skips blank change lines with no gaps', () => { + for (const blank of ['', ' ']) { + const out = textContent(tree({ change1: blank, change3: blank })); + expect(out).toContain('What changed'); + expect(out).toContain('New section on server actions'); + expect(out).not.toContain('Covers React 19'); + expect(out).not.toContain('Fixed the broken examples'); + + // One bullet per rendered line, and the last one carries no trailing margin. + const rows = bulletRows(tree({ change1: blank, change3: blank })); + expect(rows).toHaveLength(1); + expect(rows[0].marginBottom).toBe('0px'); + + expect(bullets(tree({ change1: blank, change3: blank }))).toHaveLength(1); + } + }); + + it('keeps a gap only between the lines that are rendered', () => { + const rows = bulletRows(tree({ change2: ' ' })); + expect(rows).toHaveLength(2); + expect(rows[0].marginBottom).toBe('14px'); + expect(rows[1].marginBottom).toBe('0px'); + }); + + it('hides the What changed heading when every change line is blank', () => { + for (const blank of ['', ' ']) { + const out = textContent(tree({ change1: blank, change2: blank, change3: blank })); + expect(out).not.toContain('What changed'); + expect(bullets(tree({ change1: blank, change2: blank, change3: blank }))).toHaveLength(0); + // The rest of the card is untouched. + expect(out).toContain('A practical guide to React Server Components'); + expect(out).toContain('codercops.com'); + } + }); + + it('hides an empty or whitespace-only published date without a stray separator', () => { + for (const published of ['', ' ']) { + const out = textContent(tree({ published })); + expect(out).not.toContain('First published Mar 3, 2024'); + expect(out).not.toContain('·'); + expect(out).toContain('Updated Oct 27, 2026'); + } + }); + + it('hides an empty or whitespace-only updated date without a stray separator', () => { + for (const updated of ['', ' ']) { + const out = textContent(tree({ updated })); + expect(out).not.toContain('Updated Oct 27, 2026'); + expect(out).not.toContain('·'); + expect(out).toContain('First published Mar 3, 2024'); + } + }); + + it('hides the dates line entirely when both dates are empty', () => { + for (const blank of ['', ' ']) { + const out = textContent(tree({ published: blank, updated: blank })); + expect(out).not.toContain('·'); + expect(out).toEqual([ + 'Updated', + 'A practical guide to React Server Components', + 'What changed', + 'Covers React 19', + 'New section on server actions', + 'Fixed the broken examples', + 'codercops.com', + ]); + } + }); + + it('hides an empty or whitespace-only site name cleanly', () => { + for (const siteName of ['', ' ']) { + const out = textContent(tree({ siteName })); + expect(out).not.toContain('codercops.com'); + expect(out).toContain('Covers React 19'); + } + }); + + it('shrinks a long title with autoFontSize and then truncates it', () => { + const short = styleOf(tree({ title: 'Short' }), 'Short'); + expect(short?.fontSize).toBe('72px'); + + const long = 'Super long post title that goes on and on and clearly exceeds the maximum length allowed on the card '.repeat(3); + const out = textContent(tree({ title: long })); + const title = out[1]; + + expect(title.length).toBeLessThanOrEqual(84); + expect(title.endsWith('…')).toBe(true); + // Once truncated it sits at the smallest step rather than overflowing. + expect(styleOf(tree({ title: long }), title)?.fontSize).toBe('40px'); + }); + + it('truncates long change lines and the site name', () => { + const long = 'A change line that is far too long to fit on the card and keeps going '.repeat(3); + const out = textContent(tree({ change1: long, siteName: long })); + + const change = out.find((line) => line.endsWith('…')); + expect(change).toBeDefined(); + expect(change!.length).toBeLessThanOrEqual(64); + + const site = out[out.length - 1]; + expect(site.length).toBeLessThanOrEqual(36); + expect(site.endsWith('…')).toBe(true); + }); + + it('keeps the label and accent text readable on a light background', () => { + const lightTree = tree({ + bgColor: '#ffffff', + textColor: '#111827', + accentColor: '#E07A5F', + }); + + // The pill sets its own text color against the accent fill. + const labelStyle = styleOf(lightTree, 'Updated'); + expect(labelStyle?.color).toBeDefined(); + expect(contrast('#E07A5F', labelStyle!.color)).toBeGreaterThanOrEqual(4.5); + + for (const text of ['What changed', 'Updated Oct 27, 2026']) { + const style = styleOf(lightTree, text); + expect(style?.color).toBeDefined(); + expect(contrast('#ffffff', style!.color)).toBeGreaterThanOrEqual(4.5); + } + }); + + it('keeps the label and accent text readable on a dark background', () => { + const darkTree = tree(); + + const labelStyle = styleOf(darkTree, 'Updated'); + expect(contrast(blogUpdatedPost.defaults!.accentColor as string, labelStyle!.color)).toBeGreaterThanOrEqual(4.5); + + for (const text of ['What changed', 'Updated Oct 27, 2026']) { + const style = styleOf(darkTree, text); + expect(contrast(blogUpdatedPost.defaults!.bgColor as string, style!.color)).toBeGreaterThanOrEqual(4.5); + } + }); +}); From fff18e5c78e94e49cd78c113430bfd3613bc44cd Mon Sep 17 00:00:00 2001 From: Sanketh Kumar Date: Thu, 29 Oct 2026 08:06:48 +0530 Subject: [PATCH 2/2] fix(blog-updated-post): keep the longest values inside the card --- src/templates/blog/updated-post.ts | 66 +++++++++++++++++++++++----- tests/templates/updated-post.test.ts | 47 +++++++++++++++++++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/templates/blog/updated-post.ts b/src/templates/blog/updated-post.ts index 967ca17..cde3704 100644 --- a/src/templates/blog/updated-post.ts +++ b/src/templates/blog/updated-post.ts @@ -56,10 +56,20 @@ const template: TemplateDefinition = { const titleSize = autoFontSize(title, [ { maxLen: 24, size: 72 }, - { maxLen: 40, size: 60 }, - { maxLen: 56, size: 52 }, - { maxLen: 72, size: 46 }, - { maxLen: 84, size: 40 }, + { maxLen: 36, size: 62 }, + { maxLen: 48, size: 54 }, + { maxLen: 60, size: 46 }, + { maxLen: 72, size: 40 }, + { maxLen: 84, size: 34 }, + ]); + + // Both dates share one line, so size them together for the room they share. + const datesLength = published.length + updated.length + (published && updated ? 3 : 0); + // Both dates are capped at 40 characters, so 83 is the longest line they can make. + const dateSize = autoFontSize('x'.repeat(datesLength), [ + { maxLen: 54, size: 22 }, + { maxLen: 68, size: 20 }, + { maxLen: Number.MAX_SAFE_INTEGER, size: 18 }, ]); // Neutral colors are mixed from the background and text colors, so the card reads the @@ -79,7 +89,16 @@ const template: TemplateDefinition = { { type: 'span', props: { - style: { fontSize: '22px', fontWeight: 500, color: secondaryText, whiteSpace: 'nowrap' }, + style: { + display: 'flex', + minWidth: 0, + fontSize: `${dateSize}px`, + fontWeight: 500, + color: secondaryText, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, children: published, }, }, @@ -94,7 +113,7 @@ const template: TemplateDefinition = { children: { type: 'span', props: { - style: { fontSize: '22px', fontWeight: 500, color: secondaryText }, + style: { fontSize: `${dateSize}px`, fontWeight: 500, color: secondaryText }, children: '·', }, }, @@ -107,7 +126,16 @@ const template: TemplateDefinition = { { type: 'span', props: { - style: { fontSize: '22px', fontWeight: 600, color: accentText, whiteSpace: 'nowrap' }, + style: { + display: 'flex', + minWidth: 0, + fontSize: `${dateSize}px`, + fontWeight: 600, + color: accentText, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, children: updated, }, }, @@ -121,7 +149,7 @@ const template: TemplateDefinition = { style: { display: 'flex', alignItems: 'center', - marginBottom: index < changes.length - 1 ? '14px' : '0px', + marginBottom: index < changes.length - 1 ? '12px' : '0px', }, children: [ { @@ -144,10 +172,15 @@ const template: TemplateDefinition = { props: { style: { display: 'flex', - fontSize: '26px', + flex: 1, + minWidth: 0, + fontSize: '24px', fontWeight: 500, color: textColor, lineHeight: 1.3, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }, children: change, }, @@ -173,8 +206,12 @@ const template: TemplateDefinition = { color: textColor, lineHeight: 1.12, letterSpacing: '-0.03em', + overflowWrap: 'anywhere', + // Satori does not break a single unbroken word on overflowWrap alone, so a + // title with no spaces at all is broken by character instead. + ...(title.includes(' ') ? {} : { wordBreak: 'break-all' as const }), margin: 0, - marginBottom: '20px', + marginBottom: '16px', }, children: title, }, @@ -184,7 +221,12 @@ const template: TemplateDefinition = { type: 'div', props: { // No trailing gap when there is no change list underneath. - style: { display: 'flex', alignItems: 'center', marginBottom: changes.length > 0 ? '40px' : '0px' }, + style: { + display: 'flex', + alignItems: 'center', + width: '100%', + marginBottom: changes.length > 0 ? '32px' : '0px', + }, children: dates, }, }] @@ -205,7 +247,7 @@ const template: TemplateDefinition = { color: accentText, letterSpacing: '0.14em', textTransform: 'uppercase', - marginBottom: '18px', + marginBottom: '16px', }, children: 'What changed', }, diff --git a/tests/templates/updated-post.test.ts b/tests/templates/updated-post.test.ts index ae7dc57..ba309e3 100644 --- a/tests/templates/updated-post.test.ts +++ b/tests/templates/updated-post.test.ts @@ -143,7 +143,7 @@ describe('blog-updated-post', () => { it('keeps a gap only between the lines that are rendered', () => { const rows = bulletRows(tree({ change2: ' ' })); expect(rows).toHaveLength(2); - expect(rows[0].marginBottom).toBe('14px'); + expect(rows[0].marginBottom).toBe('12px'); expect(rows[1].marginBottom).toBe('0px'); }); @@ -211,7 +211,7 @@ describe('blog-updated-post', () => { expect(title.length).toBeLessThanOrEqual(84); expect(title.endsWith('…')).toBe(true); // Once truncated it sits at the smallest step rather than overflowing. - expect(styleOf(tree({ title: long }), title)?.fontSize).toBe('40px'); + expect(styleOf(tree({ title: long }), title)?.fontSize).toBe('34px'); }); it('truncates long change lines and the site name', () => { @@ -227,6 +227,49 @@ describe('blog-updated-post', () => { expect(site.endsWith('…')).toBe(true); }); + it('keeps the longest values on one line instead of overflowing the card', () => { + const maxed = tree({ + title: 'M'.repeat(84), + published: 'W'.repeat(40), + updated: 'W'.repeat(40), + change1: 'M'.repeat(64), + change2: 'M'.repeat(64), + change3: 'M'.repeat(64), + }); + + // Each change line is clipped with an ellipsis rather than wrapping into the footer. + const out = textContent(maxed); + const changes = out.filter((line) => line.length === 64); + expect(changes).toHaveLength(3); + for (const change of changes) { + const style = styleOf(maxed, change); + expect(style?.whiteSpace).toBe('nowrap'); + expect(style?.overflow).toBe('hidden'); + expect(style?.textOverflow).toBe('ellipsis'); + } + + // Both dates shrink together and clip, so neither runs past the right edge. + const updatedStyle = styleOf(maxed, 'W'.repeat(40)); + expect(updatedStyle?.fontSize).toBe('18px'); + expect(updatedStyle?.textOverflow).toBe('ellipsis'); + expect(updatedStyle?.minWidth).toBe(0); + + // A single unbroken word wraps instead of running off the card. + expect(styleOf(maxed, 'M'.repeat(84))?.overflowWrap).toBe('anywhere'); + }); + + it('sizes the dates down as the line gets longer', () => { + const size = (published: string, updated: string) => + styleOf(tree({ published, updated }), updated)?.fontSize; + + // Both dates share one line, so the size follows their combined length. + expect(size('First published Mar 3, 2024', 'Updated Oct 27, 2026')).toBe('22px'); + expect(size('P'.repeat(40), 'U'.repeat(15))).toBe('20px'); + expect(size('P'.repeat(40), 'U'.repeat(40))).toBe('18px'); + // A single date is never pushed below the floor. + expect(size('', 'U'.repeat(40))).toBe('22px'); + }); + it('keeps the label and accent text readable on a light background', () => { const lightTree = tree({ bgColor: '#ffffff',