From 77c6357612e62cbf96353be52a5eac17f68eaff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:58:49 +0000 Subject: [PATCH 1/3] Kap on share cards, dressed in the player's Wardrobe look Kap's artwork moves to src/utils/kap-art.ts as SVG markup shared by the app and the API; the React mascot draws accessories from the same strings. Server cards (run, profile, challenge) read the look from the owner's synced account prefs, limited to items their synced achievements unlock, and fall back to the plain Kap. The weekly recap and duel cards draw the local look. CARD_VERSION 5 refreshes cached previews. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- api/_lib/challenge-card.ts | 2 + api/_lib/kap-look.test.ts | 30 ++++ api/_lib/kap-look.ts | 35 +++++ api/_lib/og-image.ts | 26 +++- api/og/[id].ts | 7 +- src/components/streak/kap-skins.tsx | 125 +--------------- src/lib/duel-card.ts | 2 + src/lib/kap-canvas.ts | 30 ++++ src/lib/kap-wardrobe.ts | 52 +------ src/lib/recap-card.ts | 2 + src/lib/streak.ts | 30 +--- src/utils/flame-tiers.ts | 29 ++++ src/utils/kap-art.ts | 213 ++++++++++++++++++++++++++++ src/utils/share-card-version.ts | 2 +- 14 files changed, 380 insertions(+), 205 deletions(-) create mode 100644 api/_lib/kap-look.test.ts create mode 100644 api/_lib/kap-look.ts create mode 100644 src/lib/kap-canvas.ts create mode 100644 src/utils/flame-tiers.ts create mode 100644 src/utils/kap-art.ts diff --git a/api/_lib/challenge-card.ts b/api/_lib/challenge-card.ts index eb4748c..20410dc 100644 --- a/api/_lib/challenge-card.ts +++ b/api/_lib/challenge-card.ts @@ -9,6 +9,7 @@ import { CARD_VERSION } from '../../src/utils/share-card-version.js'; export interface SharedChallenge { id: string; + userId: string; name: string; username: string | null; avatarUrl: string | null; @@ -41,6 +42,7 @@ export async function loadChallenge(id: string, db: ChallengeDb = adminDatabases const lines = code.split('\n'); return { id, + userId: text(doc.userId, 36), name: text(doc.name, 64) || username || 'A Codey typist', username, avatarUrl: username ? `https://avatars.githubusercontent.com/${encodeURIComponent(username)}?s=200` : null, diff --git a/api/_lib/kap-look.test.ts b/api/_lib/kap-look.test.ts new file mode 100644 index 0000000..e9053fc --- /dev/null +++ b/api/_lib/kap-look.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { lookFromPrefs, playerKap } from './kap-look'; + +describe('kap look on share cards', () => { + it('reads the synced look, keeping only unlocked items', () => { + const prefs = { + sync: { slices: { kapLook: { at: 1, value: { color: 'ocean', eyes: 'shades', wear: 'cape' } } } }, + achievements: { unlockedAt: { 'streak-2': 1, 'speed-3': 1 } }, + }; + expect(lookFromPrefs(prefs)).toEqual({ color: 'ocean', eyes: 'shades' }); + expect(lookFromPrefs({})).toEqual({}); + }); + + it('falls back to the plain Kap when prefs cannot be read', async () => { + const uri = await playerKap('user1', 7, 200, async () => { throw new Error('offline'); }); + expect(uri.startsWith('data:image/svg+xml;base64,')).toBe(true); + const svg = Buffer.from(uri.split(',')[1], 'base64').toString(); + expect(svg).toContain('#fbbf24'); + }); + + it('dresses Kap from the prefs', async () => { + const uri = await playerKap('user1', 7, 200, async () => ({ + sync: { slices: { kapLook: { value: { color: 'sakura', head: 'party' } } } }, + achievements: { unlockedAt: { 'streak-3': 1, 'duel-1': 1 } }, + })); + const svg = Buffer.from(uri.split(',')[1], 'base64').toString(); + expect(svg).toContain('#f9a8d4'); + expect(svg).toContain('#f472b6'); + }); +}); diff --git a/api/_lib/kap-look.ts b/api/_lib/kap-look.ts new file mode 100644 index 0000000..e2bab3e --- /dev/null +++ b/api/_lib/kap-look.ts @@ -0,0 +1,35 @@ +import { Client, Users } from 'node-appwrite'; +import { APPWRITE } from './appwrite-admin.js'; +import { FLAME_TIERS, tierFor } from '../../src/utils/flame-tiers.js'; +import { kapDataUri, sanitizeLook, type KapLook } from '../../src/utils/kap-art.js'; + +/** + * The player's Kap for share cards: the look they chose in the Wardrobe, + * read from their account prefs (the synced `kapLook` slice), limited to + * items their synced achievements unlock. + */ + +type Prefs = Record; + +export function lookFromPrefs(prefs: Prefs): KapLook { + const slices = ((prefs.sync as { slices?: Record } | undefined)?.slices ?? {}); + const unlockedAt = ((prefs.achievements as { unlockedAt?: Record } | undefined)?.unlockedAt ?? {}); + return sanitizeLook(slices.kapLook?.value, new Set(Object.keys(unlockedAt))); +} + +async function readPrefs(userId: string): Promise { + if (!APPWRITE.apiKey || !APPWRITE.projectId) return {}; + const client = new Client().setEndpoint(APPWRITE.endpoint).setProject(APPWRITE.projectId).setKey(APPWRITE.apiKey); + return (await new Users(client).getPrefs({ userId })) as Prefs; +} + +/** A data URI of the player's Kap; the plain Kap when the look cannot be read. */ +export async function playerKap(userId: string, streakDays = 7, width = 200, read: (id: string) => Promise = readPrefs): Promise { + let look: KapLook = {}; + try { + if (userId) look = lookFromPrefs(await read(userId)); + } catch { + // The card still draws, with Kap in his usual amber. + } + return kapDataUri(tierFor(streakDays) ?? FLAME_TIERS[1], look, width); +} diff --git a/api/_lib/og-image.ts b/api/_lib/og-image.ts index d433b9e..ae44fa9 100644 --- a/api/_lib/og-image.ts +++ b/api/_lib/og-image.ts @@ -39,6 +39,20 @@ function stat(label: string, value: string, size = 38) { ); } +/** Bottom-left branding: the player's Kap on a coal tile when there is one, else the mark. */ +function brand(kap?: string, tile = 132) { + if (!kap) return h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')); + return h('div', { alignItems: 'center', gap: 16 }, + h('div', { width: tile, height: tile, borderRadius: Math.round(tile / 4), background: 'rgba(120,53,15,0.16)', alignItems: 'center', justifyContent: 'center' }, + img(kap, { width: Math.round(tile * 0.85), height: Math.round(tile * 0.91) }), + ), + h('div', { flexDirection: 'column' }, + h('div', { fontSize: 30, color: COAL }, 'Codey'), + h('div', { fontSize: 20, color: DEEP }, 'with Kap'), + ), + ); +} + function mark(background: string, color: string) { return h('div', { width: 44, height: 44, borderRadius: 12, background, color, fontSize: 26, alignItems: 'center', justifyContent: 'center' }, ''); } @@ -80,7 +94,7 @@ function wpmSize(text: string) { return 128; } -export function renderShareImage(run: SharedRun | null, host: string): ImageResponse { +export function renderShareImage(run: SharedRun | null, host: string, kap?: string): ImageResponse { if (!run) { const root = h('div', { width: 1200, height: 630, background: DARK, fontFamily: 'Geist' }, h('div', { width: PANEL, height: 630, background: AMBER, flexDirection: 'column', justifyContent: 'space-between', padding: '56px 52px' }, @@ -118,7 +132,7 @@ export function renderShareImage(run: SharedRun | null, host: string): ImageResp h('div', { fontSize: wpmSize(wpm), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, wpm), h('div', { fontSize: captionSize(where), color: DEEP, marginTop: 10 }, where), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap), ); const middle = hasTrace @@ -158,7 +172,7 @@ function avatar(name: string, url: string | null) { } /** The player's card: best WPM on the amber side, recent form and totals on the dark side. */ -export function renderProfileImage(profile: SharedProfile, host: string): ImageResponse { +export function renderProfileImage(profile: SharedProfile, host: string, kap?: string): ImageResponse { const best = profile.runs ? formatWpm(profile.bestWpm) : '–'; const where = !profile.runs ? 'no runs yet' : profile.bestLanguage === 'Mixed' ? 'best wpm across languages' : `best wpm in ${profile.bestLanguage}`; const hasTrend = profile.trend.length >= 3; @@ -175,7 +189,7 @@ export function renderProfileImage(profile: SharedProfile, host: string): ImageR h('div', { fontSize: wpmSize(best), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, best), h('div', { fontSize: captionSize(where), color: DEEP, marginTop: 10 }, where), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap), ); const middle = hasTrend @@ -221,7 +235,7 @@ export function renderProfileImage(profile: SharedProfile, host: string): ImageR } /** "Can you beat it?": the score to beat on the amber side, the snippet on the dark side. */ -export function renderChallengeImage(challenge: SharedChallenge, host: string): ImageResponse { +export function renderChallengeImage(challenge: SharedChallenge, host: string, kap?: string): ImageResponse { const wpm = formatWpm(challenge.wpm); const left = h('div', { width: PANEL, height: 630, background: AMBER, flexDirection: 'column', justifyContent: 'space-between', padding: '56px 52px' }, h('div', { alignItems: 'center', gap: 16 }, @@ -236,7 +250,7 @@ export function renderChallengeImage(challenge: SharedChallenge, host: string): h('div', { fontSize: wpmSize(wpm), lineHeight: 0.85, color: COAL, letterSpacing: -8 }, wpm), h('div', { fontSize: 40, color: DEEP, marginTop: 10 }, 'wpm?'), ), - h('div', { alignItems: 'center', gap: 14 }, mark(COAL, AMBER), h('div', { fontSize: 28, color: COAL }, 'Codey')), + brand(kap, 96), ); const code = h('div', { flexDirection: 'column', gap: 8, padding: '22px 24px', borderRadius: 18, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }, diff --git a/api/og/[id].ts b/api/og/[id].ts index 0f07d40..3e2820d 100644 --- a/api/og/[id].ts +++ b/api/og/[id].ts @@ -3,6 +3,7 @@ import { loadSharedRun } from '../_lib/share-card.js'; import { renderChallengeImage, renderProfileImage, renderShareImage } from '../_lib/og-image.js'; import { loadChallenge } from '../_lib/challenge-card.js'; import { loadSharedProfile } from '../_lib/profile-card.js'; +import { playerKap } from '../_lib/kap-look.js'; interface BinaryResponse { status: (code: number) => BinaryResponse; @@ -22,7 +23,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { const host = first(req.headers['x-forwarded-host']) || first(req.headers.host) || 'codey'; if (first(req.query.kind) === 'challenge') { const challenge = isConfigured() ? await loadChallenge(id) : null; - const image = challenge ? renderChallengeImage(challenge, host) : renderShareImage(null, host); + const image = challenge ? renderChallengeImage(challenge, host, await playerKap(challenge.userId)) : renderShareImage(null, host); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); // Challenges never change once created. @@ -32,7 +33,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { } if (first(req.query.kind) === 'profile') { const profile = isConfigured() ? await loadSharedProfile(id) : null; - const image = profile ? renderProfileImage(profile, host) : renderShareImage(null, host); + const image = profile ? renderProfileImage(profile, host, await playerKap(profile.userId, profile.bestStreak)) : renderShareImage(null, host); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', profile ? 'public, max-age=300, s-maxage=600' : 'public, max-age=300'); @@ -40,7 +41,7 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) { return; } const run = isConfigured() ? await loadSharedRun(id) : null; - const image = renderShareImage(run, host); + const image = renderShareImage(run, host, run ? await playerKap(run.userId) : undefined); const body = Buffer.from(await image.arrayBuffer()); res.setHeader('Content-Type', 'image/png'); res.setHeader('Cache-Control', run ? 'public, max-age=86400, s-maxage=31536000, immutable' : 'public, max-age=300'); diff --git a/src/components/streak/kap-skins.tsx b/src/components/streak/kap-skins.tsx index bec583c..b03dedb 100644 --- a/src/components/streak/kap-skins.tsx +++ b/src/components/streak/kap-skins.tsx @@ -1,126 +1,13 @@ -/** - * Kap's wardrobe: keycap colourways plus one item per slot (head, eyes, wear). - * Items are drawn in the 240×258 Kap artwork space; the flame sits at x 120 - * on the top face, the eyes at y 156, so nothing here covers either. - */ +import { behindMarkup, frontMarkup, type KapLook } from "@/utils/kap-art"; -export type KapColorId = "amber" | "retro" | "ocean" | "sakura" | "matcha" | "graphite" | "obsidian"; -export type KapHeadId = "headphones" | "party" | "halo"; -export type KapEyesId = "shades" | "nerd" | "monocle"; -export type KapWearId = "scarf" | "headband" | "cape"; +export { KAP_COLORS, colorway, type KapColorId, type KapColorway, type KapEyesId, type KapHeadId, type KapLook, type KapWearId } from "@/utils/kap-art"; -export interface KapLook { - color?: KapColorId; - head?: KapHeadId | null; - eyes?: KapEyesId | null; - wear?: KapWearId | null; -} - -export interface KapColorway { - id: KapColorId; - name: string; - /** Top face, front skirt, sides and arms; undefined keeps the flame tier's. */ - cap?: [string, string, string]; - /** Face lines, for dark caps. */ - ink?: string; -} - -export const KAP_COLORS: KapColorway[] = [ - { id: "amber", name: "Amber" }, - { id: "retro", name: "Retro beige", cap: ["#f5f0e1", "#e3dac3", "#b3a78b"] }, - { id: "ocean", name: "Ocean", cap: ["#cffafe", "#67e8f9", "#0891b2"] }, - { id: "sakura", name: "Sakura", cap: ["#fce7f3", "#f9a8d4", "#db2777"] }, - { id: "matcha", name: "Matcha", cap: ["#ecfccb", "#bef264", "#65a30d"] }, - { id: "graphite", name: "Graphite", cap: ["#e4e4e7", "#a1a1aa", "#52525b"] }, - { id: "obsidian", name: "Obsidian", cap: ["#52525b", "#3f3f46", "#18181b"], ink: "#fafafa" }, -]; - -export function colorway(id: KapColorId | undefined): KapColorway { - return KAP_COLORS.find((color) => color.id === id) ?? KAP_COLORS[0]; -} - -/** Drawn before the body: only the parts outside the keycap show. */ +/** Accessories drawn before the body; the markup comes from our own constants, never user input. */ export function KapBehind({ look }: { look: KapLook }) { - return ( - <> - {look.wear === "cape" && ( - - - - - - )} - {look.head === "halo" && ( - - - - - )} - - ); + return ; } -/** Drawn over the face and flame. */ +/** Accessories drawn over the face and flame. */ export function KapFront({ look, ink }: { look: KapLook; ink: string }) { - return ( - <> - {look.wear === "scarf" && ( - - - - - - - )} - {look.wear === "headband" && ( - - - - - - - - )} - {look.eyes === "shades" && ( - - {/* Pixel shades, drawn on an 4px grid. */} - - - - - )} - {look.eyes === "nerd" && ( - - - - - - - )} - {look.eyes === "monocle" && ( - - - - - )} - {look.head === "headphones" && ( - - - - - - - - - )} - {look.head === "party" && ( - - - - - - - )} - - ); + return ; } diff --git a/src/lib/duel-card.ts b/src/lib/duel-card.ts index bca26da..278cdfe 100644 --- a/src/lib/duel-card.ts +++ b/src/lib/duel-card.ts @@ -1,3 +1,4 @@ +import { drawPlayerKap } from "./kap-canvas"; /** A duel's result as a 1200×630 image, in the same split style as the other cards. */ export interface DuelCardRacer { @@ -102,6 +103,7 @@ export async function createDuelCard(input: DuelCardInput, host = window.locatio context.fillStyle = COAL; context.font = `500 28px ${FONT}`; context.fillText("Codey", 110, 562); + await drawPlayerKap(context, 322, 454, 124); context.fillStyle = MUTED; context.font = `500 18px ${FONT}`; diff --git a/src/lib/kap-canvas.ts b/src/lib/kap-canvas.ts new file mode 100644 index 0000000..ac366fa --- /dev/null +++ b/src/lib/kap-canvas.ts @@ -0,0 +1,30 @@ +import { getStreak } from "@/utils/storage"; +import { FLAME_TIERS, tierFor } from "@/utils/flame-tiers"; +import { kapSvg } from "@/utils/kap-art"; +import { readLook } from "@/lib/kap-wardrobe"; + +/** + * Draws the player's Kap (their Wardrobe look and current flame) onto a + * share card, on a soft tile. Resolves quietly if the image cannot load, so + * a card never fails over the mascot. + */ +export async function drawPlayerKap(context: CanvasRenderingContext2D, x: number, y: number, tile: number) { + const tier = tierFor(getStreak().current) ?? FLAME_TIERS[1]; + const width = Math.round(tile * 0.85); + const height = Math.round(width * 258 / 240); + const url = URL.createObjectURL(new Blob([kapSvg(tier, readLook(), width * 2)], { type: "image/svg+xml" })); + try { + const image = new Image(); + image.src = url; + await image.decode(); + context.fillStyle = "rgba(120,53,15,0.16)"; + context.beginPath(); + context.roundRect(x, y, tile, tile, tile / 4); + context.fill(); + context.drawImage(image, x + (tile - width) / 2, y + (tile - height) / 2 + 2, width, height); + } catch { + // No Kap on this card. + } finally { + URL.revokeObjectURL(url); + } +} diff --git a/src/lib/kap-wardrobe.ts b/src/lib/kap-wardrobe.ts index 2d909b0..7341339 100644 --- a/src/lib/kap-wardrobe.ts +++ b/src/lib/kap-wardrobe.ts @@ -1,48 +1,15 @@ import { describe as describeBadge, readStore } from "@/lib/achievements"; -import { KAP_COLORS, type KapColorId, type KapEyesId, type KapHeadId, type KapLook, type KapWearId } from "@/components/streak/kap-skins"; +import { isUnlocked, sanitizeLook, WARDROBE, type KapColorId, type KapLook, type WardrobeItem, type WardrobeSlot } from "@/utils/kap-art"; + +export { isUnlocked, sanitizeLook, WARDROBE, type WardrobeItem, type WardrobeSlot }; /** * Kap's wardrobe: every item is unlocked by an achievement, so progress, * device sync and "you earned it" moments all come from the badge system. */ -export type WardrobeSlot = "color" | "head" | "eyes" | "wear"; - -export interface WardrobeItem { - slot: WardrobeSlot; - id: KapColorId | KapHeadId | KapEyesId | KapWearId; - name: string; - /** Achievement id that unlocks it; null is free. */ - requires: string | null; -} - -const COLOR_UNLOCKS: Record = { - amber: null, - retro: "runs-1", - ocean: "streak-2", - sakura: "streak-3", - matcha: "runs-2", - graphite: "ranked-2", - obsidian: "time-3", -}; - -export const WARDROBE: WardrobeItem[] = [ - ...KAP_COLORS.map((color) => ({ slot: "color" as const, id: color.id, name: color.name, requires: COLOR_UNLOCKS[color.id] })), - { slot: "head", id: "headphones", name: "Headphones", requires: "time-2" }, - { slot: "head", id: "party", name: "Party hat", requires: "duel-1" }, - { slot: "head", id: "halo", name: "Halo", requires: "flawless" }, - { slot: "eyes", id: "shades", name: "Pixel shades", requires: "speed-3" }, - { slot: "eyes", id: "nerd", name: "Nerd glasses", requires: "polyglot-3" }, - { slot: "eyes", id: "monocle", name: "Monocle", requires: "daily-2" }, - { slot: "wear", id: "scarf", name: "Scarf", requires: "streak-1" }, - { slot: "wear", id: "headband", name: "Headband", requires: "streak-4" }, - { slot: "wear", id: "cape", name: "Cape", requires: "speed-4" }, -]; - export const SLOT_NAMES: Record = { color: "Keycap", head: "Head", eyes: "Eyes", wear: "Wear" }; -export const isUnlocked = (item: WardrobeItem, earned: ReadonlySet) => item.requires === null || earned.has(item.requires); - /** What it takes, from the badge's own goal text, e.g. "Hit 100 WPM in a run". */ export function unlockHint(item: WardrobeItem): string { if (!item.requires) return "Free"; @@ -63,19 +30,6 @@ export function itemsUnlockedBy(badgeId: string): WardrobeItem[] { export const LOOK_KEY = "codey_kap_look"; export const LOOK_EVENT = "codey:kap-look"; -const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; - -/** Keeps only known items that are unlocked, so a stale or synced look never shows locked gear. */ -export function sanitizeLook(value: unknown, earned: ReadonlySet): KapLook { - const raw = (value && typeof value === "object" ? value : {}) as Record; - const look: KapLook = {}; - for (const slot of SLOTS) { - const item = WARDROBE.find((candidate) => candidate.slot === slot && candidate.id === raw[slot]); - if (item && isUnlocked(item, earned)) (look as Record)[slot] = item.id; - } - return look; -} - export function readLook(earned: ReadonlySet = earnedBadges()): KapLook { try { return sanitizeLook(JSON.parse(localStorage.getItem(LOOK_KEY) ?? "null"), earned); diff --git a/src/lib/recap-card.ts b/src/lib/recap-card.ts index 40821e4..26e7c85 100644 --- a/src/lib/recap-card.ts +++ b/src/lib/recap-card.ts @@ -1,3 +1,4 @@ +import { drawPlayerKap } from "./kap-canvas"; import type { WeeklyRecap } from "./weekly-recap"; /** The weekly recap as a 1200×630 image, in the same split style as the link cards. */ @@ -69,6 +70,7 @@ export async function createRecapCard(recap: WeeklyRecap, player: { name?: strin context.fillStyle = COAL; context.font = `500 28px ${FONT}`; context.fillText("Codey", 110, 562); + await drawPlayerKap(context, 322, 454, 124); // Dark side: who, the days, the numbers. context.fillStyle = INK; diff --git a/src/lib/streak.ts b/src/lib/streak.ts index 7a77a9b..a88e344 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -1,4 +1,7 @@ import type { StreakData } from "@/utils/storage"; +import { FLAME_TIERS, tierFor, type FlameTier } from "@/utils/flame-tiers"; + +export { FLAME_TIERS, tierFor, type FlameTier }; /** * Streak state for Kap, the keycap mascot: whether today is done, how big the @@ -8,35 +11,8 @@ import type { StreakData } from "@/utils/storage"; export type StreakMood = "sleep" | "risk" | "lit" | "frozen"; -export interface FlameTier { - id: "spark" | "flame" | "blaze" | "blue" | "legend"; - name: string; - /** Days needed to reach this flame. */ - min: number; - /** Flame height relative to the "blaze" flame. */ - scale: number; - flame: string; - core: string; - /** Keycap colours when lit: top face, front skirt, sides and arms. */ - cap: [string, string, string]; - crown?: boolean; -} - -export const FLAME_TIERS: FlameTier[] = [ - { id: "spark", name: "Spark", min: 1, scale: 0.6, flame: "#f97316", core: "#fed7aa", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "flame", name: "Flame", min: 3, scale: 0.8, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "blaze", name: "Blaze", min: 7, scale: 1, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, - { id: "blue", name: "Blue fire", min: 30, scale: 1.08, flame: "#3b82f6", core: "#dbeafe", cap: ["#e0f2fe", "#7dd3fc", "#0284c7"] }, - { id: "legend", name: "Legend", min: 100, scale: 1.2, flame: "#a855f7", core: "#f3e8ff", cap: ["#ede9fe", "#c4b5fd", "#8b5cf6"], crown: true }, -]; - export const STREAK_MILESTONES = [3, 7, 14, 30, 50, 100, 200, 365, 500, 1000]; -export function tierFor(days: number): FlameTier | null { - let tier: FlameTier | null = null; - for (const candidate of FLAME_TIERS) if (days >= candidate.min) tier = candidate; - return tier; -} export function dateKey(date: Date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; diff --git a/src/utils/flame-tiers.ts b/src/utils/flame-tiers.ts new file mode 100644 index 0000000..e0b9e62 --- /dev/null +++ b/src/utils/flame-tiers.ts @@ -0,0 +1,29 @@ +/** Kap's flame by streak length. Shared by the app and the share-card server, so no path aliases. */ + +export interface FlameTier { + id: "spark" | "flame" | "blaze" | "blue" | "legend"; + name: string; + /** Days needed to reach this flame. */ + min: number; + /** Flame height relative to the "blaze" flame. */ + scale: number; + flame: string; + core: string; + /** Keycap colours when lit: top face, front skirt, sides and arms. */ + cap: [string, string, string]; + crown?: boolean; +} + +export const FLAME_TIERS: FlameTier[] = [ + { id: "spark", name: "Spark", min: 1, scale: 0.6, flame: "#f97316", core: "#fed7aa", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "flame", name: "Flame", min: 3, scale: 0.8, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "blaze", name: "Blaze", min: 7, scale: 1, flame: "#f59e0b", core: "#fef3c7", cap: ["#fde68a", "#fbbf24", "#d97706"] }, + { id: "blue", name: "Blue fire", min: 30, scale: 1.08, flame: "#3b82f6", core: "#dbeafe", cap: ["#e0f2fe", "#7dd3fc", "#0284c7"] }, + { id: "legend", name: "Legend", min: 100, scale: 1.2, flame: "#a855f7", core: "#f3e8ff", cap: ["#ede9fe", "#c4b5fd", "#8b5cf6"], crown: true }, +]; + +export function tierFor(days: number): FlameTier | null { + let tier: FlameTier | null = null; + for (const candidate of FLAME_TIERS) if (days >= candidate.min) tier = candidate; + return tier; +} diff --git a/src/utils/kap-art.ts b/src/utils/kap-art.ts new file mode 100644 index 0000000..3444477 --- /dev/null +++ b/src/utils/kap-art.ts @@ -0,0 +1,213 @@ +/** + * Kap as SVG markup: the wardrobe catalog, each accessory, and a static + * "lit" Kap for share cards. The React mascot draws accessories from these + * same strings, so the app and the server-rendered cards always match. + * Shared with the API, so no path aliases. + */ +import type { FlameTier } from "./flame-tiers.js"; + +export type KapColorId = "amber" | "retro" | "ocean" | "sakura" | "matcha" | "graphite" | "obsidian"; +export type KapHeadId = "headphones" | "party" | "halo"; +export type KapEyesId = "shades" | "nerd" | "monocle"; +export type KapWearId = "scarf" | "headband" | "cape"; +export type WardrobeSlot = "color" | "head" | "eyes" | "wear"; + +export interface KapLook { + color?: KapColorId; + head?: KapHeadId | null; + eyes?: KapEyesId | null; + wear?: KapWearId | null; +} + +export interface KapColorway { + id: KapColorId; + name: string; + /** Top face, front skirt, sides and arms; undefined keeps the flame tier's. */ + cap?: [string, string, string]; + /** Face lines, for dark caps. */ + ink?: string; +} + +export const KAP_INK = "#1c1917"; + +export const KAP_COLORS: KapColorway[] = [ + { id: "amber", name: "Amber" }, + { id: "retro", name: "Retro beige", cap: ["#f5f0e1", "#e3dac3", "#b3a78b"] }, + { id: "ocean", name: "Ocean", cap: ["#cffafe", "#67e8f9", "#0891b2"] }, + { id: "sakura", name: "Sakura", cap: ["#fce7f3", "#f9a8d4", "#db2777"] }, + { id: "matcha", name: "Matcha", cap: ["#ecfccb", "#bef264", "#65a30d"] }, + { id: "graphite", name: "Graphite", cap: ["#e4e4e7", "#a1a1aa", "#52525b"] }, + { id: "obsidian", name: "Obsidian", cap: ["#52525b", "#3f3f46", "#18181b"], ink: "#fafafa" }, +]; + +export function colorway(id: KapColorId | undefined): KapColorway { + return KAP_COLORS.find((color) => color.id === id) ?? KAP_COLORS[0]; +} + +export interface WardrobeItem { + slot: WardrobeSlot; + id: KapColorId | KapHeadId | KapEyesId | KapWearId; + name: string; + /** Achievement id that unlocks it; null is free. */ + requires: string | null; +} + +const COLOR_UNLOCKS: Record = { + amber: null, + retro: "runs-1", + ocean: "streak-2", + sakura: "streak-3", + matcha: "runs-2", + graphite: "ranked-2", + obsidian: "time-3", +}; + +export const WARDROBE: WardrobeItem[] = [ + ...KAP_COLORS.map((color) => ({ slot: "color" as const, id: color.id, name: color.name, requires: COLOR_UNLOCKS[color.id] })), + { slot: "head", id: "headphones", name: "Headphones", requires: "time-2" }, + { slot: "head", id: "party", name: "Party hat", requires: "duel-1" }, + { slot: "head", id: "halo", name: "Halo", requires: "flawless" }, + { slot: "eyes", id: "shades", name: "Pixel shades", requires: "speed-3" }, + { slot: "eyes", id: "nerd", name: "Nerd glasses", requires: "polyglot-3" }, + { slot: "eyes", id: "monocle", name: "Monocle", requires: "daily-2" }, + { slot: "wear", id: "scarf", name: "Scarf", requires: "streak-1" }, + { slot: "wear", id: "headband", name: "Headband", requires: "streak-4" }, + { slot: "wear", id: "cape", name: "Cape", requires: "speed-4" }, +]; + +export const isUnlocked = (item: WardrobeItem, earned: ReadonlySet) => item.requires === null || earned.has(item.requires); + +const SLOTS: WardrobeSlot[] = ["color", "head", "eyes", "wear"]; + +/** Keeps only known items that are unlocked, so a stale or synced look never shows locked gear. */ +export function sanitizeLook(value: unknown, earned: ReadonlySet): KapLook { + const raw = (value && typeof value === "object" ? value : {}) as Record; + const look: KapLook = {}; + for (const slot of SLOTS) { + const item = WARDROBE.find((candidate) => candidate.slot === slot && candidate.id === raw[slot]); + if (item && isUnlocked(item, earned)) (look as Record)[slot] = item.id; + } + return look; +} + +/* ---- Accessories, in the 240×258 artwork space ---- */ + +/** Drawn before the body: only the parts outside the keycap show. */ +export function behindMarkup(look: KapLook): string { + let out = ""; + if (look.wear === "cape") { + out += '' + + '' + + ''; + } + if (look.head === "halo") { + out += '' + + ''; + } + return out; +} + +/** Drawn over the face and flame. */ +export function frontMarkup(look: KapLook, ink: string): string { + let out = ""; + if (look.wear === "scarf") { + out += '' + + '' + + '' + + ''; + } + if (look.wear === "headband") { + out += '' + + '' + + '' + + '' + + ''; + } + if (look.eyes === "shades") { + // Pixel shades, drawn on a 4px grid. + out += '' + + '' + + ''; + } + if (look.eyes === "nerd") { + out += `` + + '' + + '' + + '' + + ''; + } + if (look.eyes === "monocle") { + out += '' + + ''; + } + if (look.head === "headphones") { + const band = "M47 128 C 38 -6, 202 -6, 193 128"; + out += `` + + `` + + '' + + '' + + '' + + ''; + } + if (look.head === "party") { + out += '' + + '' + + '' + + '' + + ''; + } + return out; +} + +/* ---- A static, happy Kap for share cards ---- */ + +function flame(cx: number, base: number, scale: number, outer: string, inner: string) { + const h = 78 * scale; + const w = 40 * scale; + return `` + + ``; +} + +const TOP_FACE = "M80 76 L160 76 Q170 76 171 86 L174 122 Q174 132 164 132 L76 132 Q66 132 66 122 L69 86 Q70 76 80 76 Z"; + +/** Kap with arms up and the flame burning, as a standalone SVG document. */ +export function kapSvg(tier: FlameTier, look: KapLook = {}, width = 240): string { + const paint = colorway(look.color); + const [top, front, side] = paint.cap ?? tier.cap; + const ink = paint.ink ?? KAP_INK; + const s = tier.scale; + const cx = 120; + const y = 156; + const crown = tier.crown ? `` : ""; + const eyes = tier.crown + ? [cx - 22, cx + 22].map((x) => ``).join("") + : ``; + const body = [ + behindMarkup(look), + '', + '', + ``, + ``, + ``, + ``, + ``, + ``, + ``, + flame(120, 108, s, tier.flame, tier.core), + crown, + eyes, + ``, + ``, + frontMarkup(look, ink), + ].join(""); + const height = Math.round((width * 258) / 240); + return `` + + '' + + `${body}`; +} + +export function kapDataUri(tier: FlameTier, look: KapLook = {}, width = 240): string { + const svg = kapSvg(tier, look, width); + const base64 = typeof btoa === "function" ? btoa(svg) : Buffer.from(svg).toString("base64"); + return `data:image/svg+xml;base64,${base64}`; +} diff --git a/src/utils/share-card-version.ts b/src/utils/share-card-version.ts index 269f638..3a06eea 100644 --- a/src/utils/share-card-version.ts +++ b/src/utils/share-card-version.ts @@ -2,4 +2,4 @@ * Version of the server-drawn share card (/api/og/). Bump it whenever * the card's design changes: platforms and browsers cache the image by URL. */ -export const CARD_VERSION = 4; +export const CARD_VERSION = 5; From 5886b7dc51ecaede8ea4d2cc8b85b022257ca26e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:59:41 +0000 Subject: [PATCH 2/3] Share-card test reads the card version instead of hardcoding it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- api/_lib/share-card.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/_lib/share-card.test.ts b/api/_lib/share-card.test.ts index 0f92918..a49a242 100644 --- a/api/_lib/share-card.test.ts +++ b/api/_lib/share-card.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { formatWpm } from "./og-image"; import { formatLabel, loadSharedRun, shareHtml, type SharedRun } from "./share-card"; +import { CARD_VERSION } from "../../src/utils/share-card-version"; function fakeDb(docs: Record>) { const reads: string[] = []; @@ -74,7 +75,7 @@ describe("shareHtml", () => { it("points crawlers at the rendered card and people at the profile", () => { const html = shareHtml(shared, "https://codey.example", "run_abc"); - expect(html).toContain(''); + expect(html).toContain('`); expect(html).toContain(''); expect(html).toContain(''); expect(html).toContain('location.replace("https://codey.example/profile/user_1")'); From b4d51286f060ca2494a9930bb73d2d645127ce2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:00:08 +0000 Subject: [PATCH 3/3] Fix quoting in the share-card test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018xNjY7412Rp5bFuw5h69yu --- api/_lib/share-card.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/_lib/share-card.test.ts b/api/_lib/share-card.test.ts index a49a242..192c2f0 100644 --- a/api/_lib/share-card.test.ts +++ b/api/_lib/share-card.test.ts @@ -75,7 +75,7 @@ describe("shareHtml", () => { it("points crawlers at the rendered card and people at the profile", () => { const html = shareHtml(shared, "https://codey.example", "run_abc"); - expect(html).toContain('`); + expect(html).toContain(``); expect(html).toContain(''); expect(html).toContain(''); expect(html).toContain('location.replace("https://codey.example/profile/user_1")');