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 @@