diff --git a/thread-cards/generate-card.ts b/thread-cards/generate-card.ts --- a/thread-cards/generate-card.ts +++ b/thread-cards/generate-card.ts @@ -309,21 +309,27 @@ title: string subtitle?: string pattern: string + layout?: string }): string { const patternFn = PATTERNS[opts.pattern] ?? PATTERNS.ripple - const titleLines = wrapTitle(opts.title, 14) - const subtitleLines = opts.subtitle ? wrapText(opts.subtitle, 42, 2) : [] + const layout = opts.layout ?? "split" + const isEditorial = layout === "editorial" + const textX = isEditorial ? 86 : 500 + const maxTitleChars = isEditorial ? 23 : 14 + const maxTitleLines = isEditorial ? 3 : 4 + const titleLines = wrapText(opts.title, maxTitleChars, maxTitleLines) + const subtitleLines = opts.subtitle ? wrapText(opts.subtitle, isEditorial ? 62 : 42, isEditorial ? 2 : 2) : [] - // Title positioning — right side, vertically centered - // Sized for legibility at ~500px display width in Bluesky timeline - const titleFontSize = titleLines.some((l) => l.length > 12) ? 92 : 108 - const subtitleFontSize = 38 - const lineHeight = titleFontSize * 1.25 - const subtitleLineHeight = subtitleFontSize * 1.2 + // Title positioning. The editorial layout is safer for article cards: fewer huge + // words, no right-column squeeze, and better mobile crops. + const titleFontSize = isEditorial ? 74 : (titleLines.some((l) => l.length > 12) ? 92 : 108) + const subtitleFontSize = isEditorial ? 30 : 38 + const lineHeight = titleFontSize * (isEditorial ? 1.08 : 1.25) + const subtitleLineHeight = subtitleFontSize * 1.28 const titleBlockHeight = titleLines.length * lineHeight - const subtitleSpace = subtitleLines.length > 0 ? subtitleLines.length * subtitleLineHeight + 24 : 0 + const subtitleSpace = subtitleLines.length > 0 ? subtitleLines.length * subtitleLineHeight + (isEditorial ? 28 : 24) : 0 const totalBlock = titleBlockHeight + subtitleSpace - const titleStartY = (HEIGHT - totalBlock) / 2 + titleFontSize * 0.85 + const titleStartY = isEditorial ? 145 : (HEIGHT - totalBlock) / 2 + titleFontSize * 0.85 // Typography matches sensemaker.computer (Leaflet) page CSS: // --theme-heading-font: Newsreader → titles @@ -331,7 +337,7 @@ const titleElements = titleLines .map( (line, i) => - `${escapeXml(line)}` + `${escapeXml(line)}` ) .join("\n ") @@ -339,7 +345,7 @@ const subtitleElement = subtitleLines.length > 0 ? subtitleLines .map((line, i) => - `${escapeXml(line)}` + `${escapeXml(line)}` ) .join("\n ") : "" @@ -347,16 +353,26 @@ // Logo mark in bottom-right const logo = logoMark(WIDTH - 65, HEIGHT - 55, 55) - // Divider line between pattern and text - const divider = `` + // Divider line between pattern and text. Editorial cards use the pattern as a + // quiet background, not a hard split that steals width from the title. + const divider = isEditorial ? "" : `` + const editorialShade = isEditorial ? `` : "" return ` + + + + + + + - + ${patternFn()} + ${editorialShade} ${divider} @@ -411,12 +427,14 @@ subtitle?: string pattern: string output: string + layout: string } { const args = process.argv.slice(2) let title = "" let subtitle: string | undefined let pattern = "ripple" let output = "/tmp/thread-card.png" + let layout = "split" for (let i = 0; i < args.length; i++) { switch (args[i]) { @@ -432,15 +450,18 @@ case "--output": output = args[++i] break + case "--layout": + layout = args[++i] + break } } if (!title) { - console.error("Usage: generate-card.ts --title 'Thread title' [--subtitle '...'] [--pattern ripple|angular|orbital|grid] [--output path.png]") + console.error("Usage: generate-card.ts --title 'Thread title' [--subtitle '...'] [--pattern ripple|angular|orbital|grid] [--layout split|editorial] [--output path.png]") process.exit(1) } - return { title, subtitle, pattern, output: resolve(process.cwd(), output) } + return { title, subtitle, pattern, layout, output: resolve(process.cwd(), output) } } // ── Main ────────────────────────────────────────────────────────────