From 416883db9f8d0b1797db47fe6162b0ff30879919 Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 13 Aug 2026 08:17:40 +0100 Subject: [PATCH] refactor(tourmaline): generalize /share to a card-type registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously /share (OAuth + posting flow) and share/post.ts were both hardcoded to PersonalityCardData. Extracted the render→upload→post→log pipeline into a generic postCardImage(), and added a small type -> {render, share} registry (share/registry.ts) that /share dispatches through via a {type, data} sessionStorage envelope instead of a bare card object. No behavior change for the existing personality card — same post text, alt text, and toolkit-use record. This is prep for the receipt, festival, and story-recap card exports, which now only need a registry entry each instead of a second copy of the OAuth/posting page. Co-Authored-By: Claude Sonnet 5 --- packages/tourmaline/src/lib/share/post.ts | 107 +++++++++++------- packages/tourmaline/src/lib/share/registry.ts | 46 ++++++++ .../profile/[did]/PersonalityCard.svelte | 28 ++--- .../tourmaline/src/routes/share/+page.svelte | 24 ++-- 4 files changed, 133 insertions(+), 72 deletions(-) create mode 100644 packages/tourmaline/src/lib/share/registry.ts diff --git a/packages/tourmaline/src/lib/share/post.ts b/packages/tourmaline/src/lib/share/post.ts index f4e8175..f48e224 100644 --- a/packages/tourmaline/src/lib/share/post.ts +++ b/packages/tourmaline/src/lib/share/post.ts @@ -1,6 +1,10 @@ /** - * Creates a Bluesky post with the personality card image attached. + * Creates a Bluesky post with a share card image attached. * Uses RichText for proper @mention resolution. + * + * `postCardImage` is the generic render→upload→post→log pipeline shared by + * every card type; each card type's own module (e.g. personality below) + * only needs to build the SVG, alt text, and post copy. */ import { Agent, RichText } from "@atproto/api"; @@ -13,16 +17,23 @@ export interface ShareResult { cid: string; } -export async function sharePersonality( +export interface PostCardImageOptions { + svg: string; + alt: string; + postText: string; + /** Extra fields merged into the click.croft.tools.tourmaline toolkit-use record. */ + toolkitExtra?: Record; +} + +export async function postCardImage( agent: Agent, - card: PersonalityCardData, + opts: PostCardImageOptions, ): Promise { // 1. Render SVG → PNG - const svg = renderPersonalitySvg(card); - const pngBytes = await svgToPng(svg); + const pngBytes = await svgToPng(opts.svg); // Parse SVG dimensions for aspect ratio - const viewBoxMatch = svg.match(/viewBox="0 0 (\d+) (\d+)"/); + 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; @@ -31,40 +42,11 @@ export async function sharePersonality( encoding: "image/png", }); - // 3. Build alt text describing the card content - const genres = (card.genres ?? []) - .slice(0, 5) - .map((g) => g.name) - .join(", "); - const moods = Object.entries(card.mood ?? {}) - .sort(([, a], [, b]) => b - a) - .slice(0, 4) - .filter(([, v]) => v > 0) - .map(([m]) => m.toLowerCase()) - .join(", "); - const traits = card.traits - .map((t) => `${t.label.toLowerCase()}: ${t.value}`) - .join("; "); - - const alt = [ - `Personality profile for ${card.displayName ?? "this listener"}: ${card.archetype}.`, - card.archetypeBlurb, - genres ? `Top genres: ${genres}.` : "", - moods ? `Mood: ${moods}.` : "", - card.diversityScore != null ? `Diversity: ${card.diversityScore}/100.` : "", - card.obscurityIndex != null ? `Obscurity: ${card.obscurityIndex}/100.` : "", - traits ? traits : "", - ] - .filter(Boolean) - .join(" "); - - // 4. Build rich text with @mention - const rt = new RichText({ - text: `I'm a ${card.archetype}!\n\nfound out by using tourmaline by @ewancroft.uk`, - }); + // 3. Build rich text with @mention + const rt = new RichText({ text: opts.postText }); await rt.detectFacets(agent); - // 5. Create the post + // 4. Create the post const result = await agent.post({ text: rt.text, facets: rt.facets, @@ -72,7 +54,7 @@ export async function sharePersonality( $type: "app.bsky.embed.images", images: [ { - alt, + alt: opts.alt, image: blobData.blob, aspectRatio: { width: svgW, @@ -84,7 +66,7 @@ export async function sharePersonality( createdAt: new Date().toISOString(), }); - // 6. Log toolkit usage — best-effort, don't let it fail the share + // 5. Log toolkit usage — best-effort, don't let it fail the share try { await agent.com.atproto.repo.createRecord({ repo: agent.sessionManager.did ?? agent.did ?? "", @@ -93,9 +75,7 @@ export async function sharePersonality( $type: "click.croft.toolkit.use", tool: { $type: "click.croft.tools.tourmaline", - ...(card.totalScrobbles != null - ? { scrobblesAnalyzed: card.totalScrobbles } - : {}), + ...opts.toolkitExtra, sharedToBluesky: true, }, createdAt: new Date().toISOString(), @@ -107,3 +87,44 @@ export async function sharePersonality( return { uri: result.uri, cid: result.cid }; } + +export async function sharePersonality( + agent: Agent, + card: PersonalityCardData, +): Promise { + const svg = renderPersonalitySvg(card); + + const genres = (card.genres ?? []) + .slice(0, 5) + .map((g) => g.name) + .join(", "); + const moods = Object.entries(card.mood ?? {}) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .filter(([, v]) => v > 0) + .map(([m]) => m.toLowerCase()) + .join(", "); + const traits = card.traits + .map((t) => `${t.label.toLowerCase()}: ${t.value}`) + .join("; "); + + const alt = [ + `Personality profile for ${card.displayName ?? "this listener"}: ${card.archetype}.`, + card.archetypeBlurb, + genres ? `Top genres: ${genres}.` : "", + moods ? `Mood: ${moods}.` : "", + card.diversityScore != null ? `Diversity: ${card.diversityScore}/100.` : "", + card.obscurityIndex != null ? `Obscurity: ${card.obscurityIndex}/100.` : "", + traits ? traits : "", + ] + .filter(Boolean) + .join(" "); + + return postCardImage(agent, { + svg, + alt, + postText: `I'm a ${card.archetype}!\n\nfound out by using tourmaline by @ewancroft.uk`, + toolkitExtra: + card.totalScrobbles != null ? { scrobblesAnalyzed: card.totalScrobbles } : {}, + }); +} diff --git a/packages/tourmaline/src/lib/share/registry.ts b/packages/tourmaline/src/lib/share/registry.ts new file mode 100644 index 0000000..9861f18 --- /dev/null +++ b/packages/tourmaline/src/lib/share/registry.ts @@ -0,0 +1,46 @@ +/** + * Card-type registry for the /share page. + * + * Every shareable card type (personality, and later receipt/festival/story) + * registers a renderer and a share function here. The /share page and the + * "Share" buttons that launch it work against this registry rather than any + * one card type directly, so adding a new card type is a matter of adding + * an entry here — not touching the OAuth/posting page. + */ +import type { Agent } from "@atproto/api"; +import type { PersonalityCardData } from "./personality-svg"; +import { renderPersonalitySvg } from "./personality-svg"; +import { sharePersonality, type ShareResult } from "./post"; + +export type ShareCardType = "personality"; + +/** sessionStorage envelope written by a card's "Share" button, read by /share. */ +export interface ShareEnvelope { + type: ShareCardType; + data: unknown; +} + +interface CardHandler { + render: (data: T) => string; + share: (agent: Agent, data: T) => Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const handlers: Record> = { + personality: { render: renderPersonalitySvg, share: sharePersonality }, +}; + +export function renderCard(envelope: ShareEnvelope): string { + return handlers[envelope.type].render(envelope.data); +} + +export function shareCard(agent: Agent, envelope: ShareEnvelope): Promise { + return handlers[envelope.type].share(agent, envelope.data); +} + +/** Convenience for a card's "Share" button: writes the envelope tourmaline/share reads. */ +export function writeShareEnvelope(type: ShareCardType, data: unknown): void { + sessionStorage.setItem("tourmaline:share", JSON.stringify({ type, data })); +} + +export type { PersonalityCardData }; diff --git a/packages/tourmaline/src/routes/profile/[did]/PersonalityCard.svelte b/packages/tourmaline/src/routes/profile/[did]/PersonalityCard.svelte index e5e09fa..46c544a 100644 --- a/packages/tourmaline/src/routes/profile/[did]/PersonalityCard.svelte +++ b/packages/tourmaline/src/routes/profile/[did]/PersonalityCard.svelte @@ -2,11 +2,10 @@ import { Share2 } from '@lucide/svelte'; import type { ListenerProfile } from '$lib/types'; import type { PersonalityProfile } from '$lib/analysis/personality'; + import { writeShareEnvelope } from '$lib/share/registry'; let { profile, displayName, personality }: { profile: ListenerProfile; displayName: string; personality: PersonalityProfile } = $props(); - const STORAGE_KEY = 'tourmaline:share'; - /** Genre bar colour per category (consistent across renders). */ const GENRE_COLORS: Record = { Metal: '#ef4444', @@ -54,20 +53,17 @@ ); function share() { - sessionStorage.setItem( - STORAGE_KEY, - JSON.stringify({ - archetype: personality.archetype, - archetypeBlurb: personality.archetypeBlurb, - traits: personality.traits, - genres: topGenres.map((g) => ({ name: g.name, weight: g.weight })), - mood: Object.fromEntries(topMoods), - diversityScore: profile.diversityScore, - obscurityIndex: profile.obscurityIndex, - displayName, - totalScrobbles: profile.totalScrobbles - }) - ); + writeShareEnvelope('personality', { + archetype: personality.archetype, + archetypeBlurb: personality.archetypeBlurb, + traits: personality.traits, + genres: topGenres.map((g) => ({ name: g.name, weight: g.weight })), + mood: Object.fromEntries(topMoods), + diversityScore: profile.diversityScore, + obscurityIndex: profile.obscurityIndex, + displayName, + totalScrobbles: profile.totalScrobbles + }); const params = new URLSearchParams({ handle: profile.handle ?? '', did: profile.did }); window.location.href = `/share?${params}`; } diff --git a/packages/tourmaline/src/routes/share/+page.svelte b/packages/tourmaline/src/routes/share/+page.svelte index c95958d..5d3fd8e 100644 --- a/packages/tourmaline/src/routes/share/+page.svelte +++ b/packages/tourmaline/src/routes/share/+page.svelte @@ -2,15 +2,13 @@ import { onMount } from 'svelte'; import type { Agent } from '@atproto/api'; import { initOAuth, signInWithOAuth } from '$lib/atproto/oauth'; - import { sharePersonality } from '$lib/share/post'; - import { renderPersonalitySvg } from '$lib/share/personality-svg'; - import type { PersonalityCardData } from '$lib/share/personality-svg'; + import { renderCard, shareCard, type ShareEnvelope } from '$lib/share/registry'; import { ExternalLink, ArrowLeft } from '@lucide/svelte'; const STORAGE_KEY = 'tourmaline:share'; let agent = $state(null); - let card = $state(null); + let envelope = $state(null); let handle = $state(''); let svgPreview = $state(''); let profileUrl = $state('/'); @@ -23,18 +21,18 @@ let error = $state(''); onMount(async () => { - // Restore personality data from sessionStorage + // Restore the share envelope from sessionStorage const stored = sessionStorage.getItem(STORAGE_KEY); if (stored) { try { - const parsed = JSON.parse(stored) as PersonalityCardData; - card = parsed; - svgPreview = renderPersonalitySvg(parsed); + const parsed = JSON.parse(stored) as ShareEnvelope; + envelope = parsed; + svgPreview = renderCard(parsed); } catch { - error = 'No personality data found. Go back to your profile and try again.'; + error = 'No share data found. Go back to your profile and try again.'; } } else { - error = 'No personality data found. Go back to your profile and try again.'; + error = 'No share data found. Go back to your profile and try again.'; } // Build a link back to the profile page @@ -82,11 +80,11 @@ } async function doPost() { - if (!agent || !card) return; + if (!agent || !envelope) return; posting = true; error = ''; try { - const result = await sharePersonality(agent, card); + const result = await shareCard(agent, envelope); postUri = result.uri; done = true; @@ -158,7 +156,7 @@ View post - {:else if card} + {:else if envelope}
{@html svgPreview} -- 2.51.2