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 ``;
+}
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 @@