From 12c417225fd64aafd773c2225b97596cd551299d Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 08:59:26 +0100 Subject: [PATCH] feat(tourmaline): add story recap share card export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit story-recap.ts already builds a sequence of narrative cards for on-page display (StoryRecap.svelte's Stories-style slideshow) but nothing exported any single card as a shareable image. Adds a portrait Instagram-Stories-shaped export (segmented progress bar, heading, big stat, body) of whichever card the listener is currently looking at — StoryRecap.svelte already tracks that as local state, so the share button lives there directly rather than lifting state to the parent. Height is computed dynamically from wrapped heading/body line counts with a 780px portrait floor, rather than a fixed constant — verified directly via Node (bypassing the flaky browser preview this session) that it stays at the floor for realistic short recap text but genuinely grows for pathological long content instead of clipping. Co-Authored-By: Claude Sonnet 5 --- packages/tourmaline/src/lib/share/post.ts | 21 +++ packages/tourmaline/src/lib/share/registry.ts | 9 +- .../tourmaline/src/lib/share/story-svg.ts | 127 ++++++++++++++++++ .../src/routes/profile/[did]/+page.svelte | 2 +- .../routes/profile/[did]/StoryRecap.svelte | 59 +++++--- 5 files changed, 198 insertions(+), 20 deletions(-) create mode 100644 packages/tourmaline/src/lib/share/story-svg.ts diff --git a/packages/tourmaline/src/lib/share/post.ts b/packages/tourmaline/src/lib/share/post.ts index 24558a1..122fb5e 100644 --- a/packages/tourmaline/src/lib/share/post.ts +++ b/packages/tourmaline/src/lib/share/post.ts @@ -14,6 +14,8 @@ import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; import type { FestivalCardData } from "./festival-svg"; import { renderFestivalSvg } from "./festival-svg"; +import type { StoryCardData } from "./story-svg"; +import { renderStorySvg } from "./story-svg"; import { svgToPng } from "./svg-to-png"; export interface ShareResult { @@ -177,3 +179,22 @@ export async function shareFestival(agent: Agent, card: FestivalCardData): Promi toolkitExtra: {}, }); } + +export async function shareStory(agent: Agent, card: StoryCardData): Promise { + const svg = renderStorySvg(card); + + const alt = [ + `${card.label} recap card ${card.cardIndex}/${card.cardTotal} for ${card.displayName ?? "this listener"}: ${card.heading}.`, + card.body, + card.stat ? `${card.statLabel ?? "Stat"}: ${card.stat}.` : "", + ] + .filter(Boolean) + .join(" "); + + return postCardImage(agent, { + svg, + alt, + postText: `${card.heading}\n\nfrom my ${card.label} recap on tourmaline by @ewancroft.uk`, + toolkitExtra: {}, + }); +} diff --git a/packages/tourmaline/src/lib/share/registry.ts b/packages/tourmaline/src/lib/share/registry.ts index 8991fc2..13f5f33 100644 --- a/packages/tourmaline/src/lib/share/registry.ts +++ b/packages/tourmaline/src/lib/share/registry.ts @@ -14,9 +14,11 @@ import type { ReceiptCardData } from "./receipt-svg"; import { renderReceiptSvg } from "./receipt-svg"; import type { FestivalCardData } from "./festival-svg"; import { renderFestivalSvg } from "./festival-svg"; -import { sharePersonality, shareReceipt, shareFestival, type ShareResult } from "./post"; +import type { StoryCardData } from "./story-svg"; +import { renderStorySvg } from "./story-svg"; +import { sharePersonality, shareReceipt, shareFestival, shareStory, type ShareResult } from "./post"; -export type ShareCardType = "personality" | "receipt" | "festival"; +export type ShareCardType = "personality" | "receipt" | "festival" | "story"; /** sessionStorage envelope written by a card's "Share" button, read by /share. */ export interface ShareEnvelope { @@ -34,6 +36,7 @@ const handlers: Record> = { personality: { render: renderPersonalitySvg, share: sharePersonality }, receipt: { render: renderReceiptSvg, share: shareReceipt }, festival: { render: renderFestivalSvg, share: shareFestival }, + story: { render: renderStorySvg, share: shareStory }, }; export function renderCard(envelope: ShareEnvelope): string { @@ -49,4 +52,4 @@ export function writeShareEnvelope(type: ShareCardType, data: unknown): void { sessionStorage.setItem("tourmaline:share", JSON.stringify({ type, data })); } -export type { PersonalityCardData, ReceiptCardData, FestivalCardData }; +export type { PersonalityCardData, ReceiptCardData, FestivalCardData, StoryCardData }; diff --git a/packages/tourmaline/src/lib/share/story-svg.ts b/packages/tourmaline/src/lib/share/story-svg.ts new file mode 100644 index 0000000..ca331bf --- /dev/null +++ b/packages/tourmaline/src/lib/share/story-svg.ts @@ -0,0 +1,127 @@ +/** + * Exports a single card from the on-page story recap (StoryRecap.svelte) as + * a shareable, Instagram-Stories-style portrait image — the recap itself is + * already built as a sequence of narrative cards for on-page display + * (story-recap.ts), but nothing exported any of them as an image before + * this. Shares whichever card the listener is currently looking at. + */ +import { BG, ACCENT, TEXT, MUTED, DIM, FONT_FACE_CSS, esc } from "./theme"; + +export interface StoryCardData { + displayName?: string; + /** e.g. "all-time" or "30-day" */ + label: string; + heading: string; + body: string; + stat?: string; + statLabel?: string; + /** 1-based position in the recap sequence, for the "3/11" indicator. */ + cardIndex: number; + cardTotal: number; +} + +const WIDTH = 480; +/** Portrait floor — short cards still read as an Instagram-Stories-shaped image. */ +const MIN_HEIGHT = 780; +const PAD = 32; + +/** Word-wrap plain text into lines that roughly fit `maxWidth` at `fontSize`. */ +function wrapText(text: string, fontSize: number, maxWidth: number): string[] { + const charWidth = fontSize * 0.52; + const maxChars = Math.max(4, Math.floor(maxWidth / charWidth)); + const words = String(text ?? "").split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > maxChars && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + return lines; +} + +export function renderStorySvg(card: StoryCardData): string { + const headingLines = wrapText(card.heading, 26, WIDTH - PAD * 2); + const bodyLines = wrapText(card.body, 15, WIDTH - PAD * 2); + + const progressY = 28; + const brandY = 56; + + const indexY = 84; + const headingStartY = 120; + const headingLineHeight = 32; + const headingEndY = headingStartY + headingLines.length * headingLineHeight; + + const hasStat = Boolean(card.stat); + const statY = headingEndY + 50; + const bodyStartY = hasStat ? statY + 50 : headingEndY + 40; + const bodyLineHeight = 22; + const bodyEndY = bodyStartY + bodyLines.length * bodyLineHeight; + + const HEIGHT = Math.max(MIN_HEIGHT, Math.ceil(bodyEndY + 80)); + const footerY = HEIGHT - 36; + + // Segmented progress bar across the top, one segment per card in the recap. + const total = Math.max(1, card.cardTotal); + const segGap = 4; + const segWidth = (WIDTH - PAD * 2 - segGap * (total - 1)) / total; + const progressSvg = Array.from({ length: total }, (_, i) => { + const x = PAD + i * (segWidth + segGap); + const filled = i < card.cardIndex; + return ``; + }).join(""); + + const headingSvg = headingLines + .map( + (line, i) => + `${esc(line)}`, + ) + .join(""); + + const bodySvg = bodyLines + .map( + (line, i) => + `${esc(line)}`, + ) + .join(""); + + return ` + + + + + + + + + + + + + ${progressSvg} + + + TOURMALINE + ${esc(card.cardIndex)}/${esc(card.cardTotal)} + + + ${esc(card.label.toUpperCase())} RECAP + + + ${headingSvg} + + ${hasStat ? `${esc(card.stat)}${card.statLabel ? `${esc(card.statLabel.toUpperCase())}` : ""}` : ""} + + + ${bodySvg} + + + ${esc(card.displayName ?? "")} · tourmaline.croft.click +`; +} diff --git a/packages/tourmaline/src/routes/profile/[did]/+page.svelte b/packages/tourmaline/src/routes/profile/[did]/+page.svelte index 1e40210..c3e6515 100644 --- a/packages/tourmaline/src/routes/profile/[did]/+page.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/+page.svelte @@ -322,7 +322,7 @@ {#key dateRange} {#if storyRecap_}
- +
{/if} {/key} diff --git a/packages/tourmaline/src/routes/profile/[did]/StoryRecap.svelte b/packages/tourmaline/src/routes/profile/[did]/StoryRecap.svelte index e573ff9..9e39e5c 100644 --- a/packages/tourmaline/src/routes/profile/[did]/StoryRecap.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/StoryRecap.svelte @@ -1,9 +1,10 @@