From 0555e20461f882dfca8007900fd063a5ed61ea72 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 08:54:45 +0100 Subject: [PATCH] feat(tourmaline): add festival-poster share card export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instafest-style lineup poster: top artists in decreasing font-size tiers (3 huge headliners, 5 medium, 6 smaller, 8 smallest), wrapped into centered lines. Wired through the registry — a "Lineup" button next to Top Artists. Verified the generator directly with Node's native TS stripping (bypassing the flaky browser preview this session): caught a real bug in the process — accumulated line-height math (odd font sizes * 1.5) produces a fractional SVG height, and the aspect-ratio regex in post.ts used for the Bluesky post embed only matched integers. A fractional viewBox silently fell back to the wrong default aspect ratio instead of failing loudly. Fixed the regex to match decimals (affects every card type, not just this one) and rounded the festival card's own height for a clean viewBox regardless. Co-Authored-By: Claude Sonnet 5 --- .../tourmaline/src/lib/share/festival-svg.ts | 125 ++++++++++++++++++ packages/tourmaline/src/lib/share/post.ts | 30 ++++- packages/tourmaline/src/lib/share/registry.ts | 9 +- .../src/routes/profile/[did]/+page.svelte | 24 +++- 4 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 packages/tourmaline/src/lib/share/festival-svg.ts diff --git a/packages/tourmaline/src/lib/share/festival-svg.ts b/packages/tourmaline/src/lib/share/festival-svg.ts new file mode 100644 index 0000000..48e5c9c --- /dev/null +++ b/packages/tourmaline/src/lib/share/festival-svg.ts @@ -0,0 +1,125 @@ +/** + * Generates an Instafest-style festival lineup poster from a listener's top + * artists — headliners in large type at the top, smaller acts in rows below, + * font size falling off by rank tier. Same theme/escaping approach as the + * other share cards. + */ +import { BG, BORDER, ACCENT, TEXT, DIM, FONT_FACE_CSS, esc } from "./theme"; + +export interface FestivalCardData { + displayName?: string; + rangeLabel?: string; + artists: Array<{ name: string; count: number }>; +} + +const WIDTH = 600; +const PAD = 32; + +/** Rank tiers: [count of artists in this tier, font size, letter spacing]. */ +const TIERS: Array<{ count: number; size: number; weight: number; gap: number }> = [ + { count: 3, size: 30, weight: 800, gap: 8 }, + { count: 5, size: 19, weight: 700, gap: 6 }, + { count: 6, size: 14, weight: 600, gap: 5 }, + { count: 8, size: 11, weight: 500, gap: 4 }, +]; + +/** Wrap a list of names into lines that roughly fit `maxWidth`, using a crude per-character estimate. */ +function wrapNames(names: string[], fontSize: number, maxWidth: number, separator = " • "): string[] { + const charWidth = fontSize * 0.58; + const sepWidth = separator.length * charWidth; + const lines: string[] = []; + let current: string[] = []; + let currentWidth = 0; + + for (const name of names) { + const nameWidth = name.length * charWidth; + const addedWidth = current.length > 0 ? sepWidth + nameWidth : nameWidth; + if (currentWidth + addedWidth > maxWidth && current.length > 0) { + lines.push(current.join(separator)); + current = [name]; + currentWidth = nameWidth; + } else { + current.push(name); + currentWidth += addedWidth; + } + } + if (current.length > 0) lines.push(current.join(separator)); + return lines; +} + +export function renderFestivalSvg(card: FestivalCardData): string { + const artists = (Array.isArray(card.artists) ? card.artists : []) + .filter((a) => a && typeof a === "object" && typeof a.name === "string") + .slice(0, TIERS.reduce((sum, t) => sum + t.count, 0)); + + // ── Layout ────────────────────────────────────────────────────────── + let y = 0; + y += 40; // top padding + const titleY = y + 28; + y += 40; + const subtitleY = y + 12; + y += 24; + + y += 12; + const dividerTopY = y; + y += 28; + + let cursor = 0; + const tierRows: Array<{ lines: string[]; size: number; weight: number; startY: number }> = []; + for (const tier of TIERS) { + const names = artists.slice(cursor, cursor + tier.count).map((a) => a.name); + cursor += tier.count; + if (names.length === 0) continue; + const lines = wrapNames(names, tier.size, WIDTH - PAD * 2); + const startY = y; + tierRows.push({ lines, size: tier.size, weight: tier.weight, startY }); + for (const _line of lines) { + y += tier.size * 1.5; + } + y += tier.gap * 2; + } + + y += 12; + const dividerBottomY = y; + y += 28; + + const footerY = y + 10; + y += 30; + + const HEIGHT = Math.ceil(y); + + const tiersSvg = tierRows + .map((row) => + row.lines + .map((line, i) => { + const ty = row.startY + i * row.size * 1.5 + row.size; + return `${esc(line)}`; + }) + .join(""), + ) + .join(""); + + return ` + + + + + + + + + + + + + TOURMALINE FEST + ${esc((card.rangeLabel ?? "ALL TIME").toUpperCase())} · CURATED BY ${esc((card.displayName ?? "A LISTENER").toUpperCase())} + + + + ${tiersSvg} + + + TOURMALINE.CROFT.CLICK +`; +} diff --git a/packages/tourmaline/src/lib/share/post.ts b/packages/tourmaline/src/lib/share/post.ts index 964f07f..24558a1 100644 --- a/packages/tourmaline/src/lib/share/post.ts +++ b/packages/tourmaline/src/lib/share/post.ts @@ -12,6 +12,8 @@ import type { PersonalityCardData } from "./personality-svg"; import { renderPersonalitySvg } from "./personality-svg"; import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; +import type { FestivalCardData } from "./festival-svg"; +import { renderFestivalSvg } from "./festival-svg"; import { svgToPng } from "./svg-to-png"; export interface ShareResult { @@ -35,9 +37,12 @@ export async function postCardImage( const pngBytes = await svgToPng(opts.svg); // Parse SVG dimensions for aspect ratio - const viewBoxMatch = opts.svg.match(/viewBox="0 0 (\d+) (\d+)"/); - const svgW = viewBoxMatch ? parseInt(viewBoxMatch[1], 10) : 600; - const svgH = viewBoxMatch ? parseInt(viewBoxMatch[2], 10) : 620; + // Card heights can be non-integer (accumulated from fractional line-height + // math) — match decimals too, or a card with a fractional viewBox silently + // falls back to the wrong aspect ratio here. + const viewBoxMatch = opts.svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/); + const svgW = viewBoxMatch ? Math.round(parseFloat(viewBoxMatch[1])) : 600; + const svgH = viewBoxMatch ? Math.round(parseFloat(viewBoxMatch[2])) : 620; // 2. Upload the image blob const { data: blobData } = await agent.uploadBlob(pngBytes, { @@ -153,3 +158,22 @@ export async function shareReceipt(agent: Agent, card: ReceiptCardData): Promise card.totalScrobbles != null ? { scrobblesAnalyzed: card.totalScrobbles } : {}, }); } + +export async function shareFestival(agent: Agent, card: FestivalCardData): Promise { + const svg = renderFestivalSvg(card); + + const artists = (Array.isArray(card.artists) ? card.artists : []).slice(0, 10); + const alt = [ + `Festival-style lineup poster for ${card.displayName ?? "this listener"} (${card.rangeLabel ?? "all time"}).`, + artists.length ? `Lineup, headliners first: ${artists.map((a) => a.name).join(", ")}.` : "", + ] + .filter(Boolean) + .join(" "); + + return postCardImage(agent, { + svg, + alt, + postText: `My listening lineup (${card.rangeLabel ?? "all time"}) 🎪\n\nvia tourmaline by @ewancroft.uk`, + toolkitExtra: {}, + }); +} diff --git a/packages/tourmaline/src/lib/share/registry.ts b/packages/tourmaline/src/lib/share/registry.ts index 6d235a6..8991fc2 100644 --- a/packages/tourmaline/src/lib/share/registry.ts +++ b/packages/tourmaline/src/lib/share/registry.ts @@ -12,9 +12,11 @@ import type { PersonalityCardData } from "./personality-svg"; import { renderPersonalitySvg } from "./personality-svg"; import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; -import { sharePersonality, shareReceipt, type ShareResult } from "./post"; +import type { FestivalCardData } from "./festival-svg"; +import { renderFestivalSvg } from "./festival-svg"; +import { sharePersonality, shareReceipt, shareFestival, type ShareResult } from "./post"; -export type ShareCardType = "personality" | "receipt"; +export type ShareCardType = "personality" | "receipt" | "festival"; /** sessionStorage envelope written by a card's "Share" button, read by /share. */ export interface ShareEnvelope { @@ -31,6 +33,7 @@ interface CardHandler { const handlers: Record> = { personality: { render: renderPersonalitySvg, share: sharePersonality }, receipt: { render: renderReceiptSvg, share: shareReceipt }, + festival: { render: renderFestivalSvg, share: shareFestival }, }; export function renderCard(envelope: ShareEnvelope): string { @@ -46,4 +49,4 @@ export function writeShareEnvelope(type: ShareCardType, data: unknown): void { sessionStorage.setItem("tourmaline:share", JSON.stringify({ type, data })); } -export type { PersonalityCardData, ReceiptCardData }; +export type { PersonalityCardData, ReceiptCardData, FestivalCardData }; diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index b0f48f9..1e40210 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -1,7 +1,7 @@