Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/_lib/challenge-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions api/_lib/kap-look.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
35 changes: 35 additions & 0 deletions api/_lib/kap-look.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

export function lookFromPrefs(prefs: Prefs): KapLook {
const slices = ((prefs.sync as { slices?: Record<string, { value?: unknown }> } | undefined)?.slices ?? {});
const unlockedAt = ((prefs.achievements as { unlockedAt?: Record<string, unknown> } | undefined)?.unlockedAt ?? {});
return sanitizeLook(slices.kapLook?.value, new Set(Object.keys(unlockedAt)));
}

async function readPrefs(userId: string): Promise<Prefs> {
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<Prefs> = readPrefs): Promise<string> {
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);
}
26 changes: 20 additions & 6 deletions api/_lib/og-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }, '</>');
}
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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)' },
Expand Down
3 changes: 2 additions & 1 deletion api/_lib/share-card.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>) {
const reads: string[] = [];
Expand Down Expand Up @@ -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('<meta property="og:image" content="https://codey.example/api/og/run_abc?v=4">');
expect(html).toContain(`<meta property="og:image" content="https://codey.example/api/og/run_abc?v=${CARD_VERSION}">`);
expect(html).toContain('<meta property="og:url" content="https://codey.example/r/run_abc">');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
expect(html).toContain('location.replace("https://codey.example/profile/user_1")');
Expand Down
7 changes: 4 additions & 3 deletions api/og/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -32,15 +33,15 @@ 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');
res.status(200).end(body);
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');
Expand Down
125 changes: 6 additions & 119 deletions src/components/streak/kap-skins.tsx
Original file line number Diff line number Diff line change
@@ -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" && (
<g>
<path d="M74 84 Q30 150 16 222 Q70 210 120 222 Q170 210 224 222 Q210 150 166 84 Z" fill="#dc2626" />
<path d="M74 84 Q38 146 26 212 Q42 208 56 210 Q58 150 84 92 Z" fill="#991b1b" />
<path d="M166 84 Q202 146 214 212 Q198 208 184 210 Q182 150 156 92 Z" fill="#b91c1c" />
</g>
)}
{look.head === "halo" && (
<g className="kap-halo">
<ellipse cx="120" cy="18" rx="46" ry="11" fill="none" stroke="#fde047" strokeWidth="7" />
<ellipse cx="120" cy="18" rx="46" ry="11" fill="none" stroke="#fef9c3" strokeWidth="2" />
</g>
)}
</>
);
return <g dangerouslySetInnerHTML={{ __html: behindMarkup(look) }} />;
}

/** 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" && (
<g>
<path d="M62 124 Q120 142 178 124 L181 140 Q120 158 59 140 Z" fill="#dc2626" />
<path d="M62 124 Q120 142 178 124 L179 130 Q120 148 61 130 Z" fill="#f87171" />
<path d="M154 138 L168 180 L153 183 L146 142 Z" fill="#b91c1c" />
<path d="M156 176 l12 4 M154 170 l12 4" stroke="#fecaca" strokeWidth="2" />
</g>
)}
{look.wear === "headband" && (
<g>
<path d="M67 131 L173 131 L175 143 L65 143 Z" fill="#ef4444" />
<path d="M67 131 L173 131 L173.6 135 L66.4 135 Z" fill="#fca5a5" />
<path d="M174 135 q16 -8 32 -2 q-14 4 -30 10 z" fill="#dc2626" />
<path d="M174 139 q14 6 26 18 q-16 -4 -27 -12 z" fill="#b91c1c" />
<circle cx="175" cy="138" r="5" fill="#b91c1c" />
</g>
)}
{look.eyes === "shades" && (
<g>
{/* Pixel shades, drawn on an 4px grid. */}
<path d="M80 146 h80 v6 h-80 z" fill="#0a0a0a" />
<path d="M84 152 h28 v8 h-4 v4 h-20 v-4 h-4 z M128 152 h28 v8 h-4 v4 h-20 v-4 h-4 z" fill="#0a0a0a" />
<path d="M88 154 h4 v4 h-4 z M92 158 h4 v4 h-4 z M132 154 h4 v4 h-4 z M136 158 h4 v4 h-4 z" fill="#fafafa" />
</g>
)}
{look.eyes === "nerd" && (
<g stroke={ink} strokeWidth="4" fill="none">
<circle cx="98" cy="157" r="14" fill="#fff" fillOpacity=".25" />
<circle cx="142" cy="157" r="14" fill="#fff" fillOpacity=".25" />
<path d="M112 155 q8 -6 16 0" />
<path d="M84 154 L68 150 M156 154 L172 150" strokeLinecap="round" />
</g>
)}
{look.eyes === "monocle" && (
<g>
<circle cx="142" cy="157" r="14" fill="#fff" fillOpacity=".2" stroke="#ca8a04" strokeWidth="4" />
<path d="M152 167 q10 14 2 30" stroke="#ca8a04" strokeWidth="2" fill="none" strokeDasharray="3 3" />
</g>
)}
{look.head === "headphones" && (
<g>
<path d="M47 128 C 38 -6, 202 -6, 193 128" stroke="#18181b" strokeWidth="12" fill="none" strokeLinecap="round" />
<path d="M47 128 C 38 -6, 202 -6, 193 128" stroke="#52525b" strokeWidth="5" fill="none" strokeLinecap="round" />
<rect x="30" y="108" width="28" height="50" rx="12" fill="#18181b" />
<rect x="182" y="108" width="28" height="50" rx="12" fill="#18181b" />
<rect x="34" y="114" width="8" height="38" rx="4" fill="#f59e0b" />
<rect x="198" y="114" width="8" height="38" rx="4" fill="#f59e0b" />
</g>
)}
{look.head === "party" && (
<g transform="translate(-30 -34) scale(1.4) rotate(-18 88 80)">
<path d="M74 84 L88 26 L104 84 Z" fill="#f472b6" />
<path d="M78.5 66 L98.5 62 L101 72 L76.5 76 Z M84 44 L93 42 L95.5 52 L81.5 54 Z" fill="#fde047" />
<path d="M72 84 Q88 90 106 84" stroke="#db2777" strokeWidth="4" fill="none" strokeLinecap="round" />
<circle cx="88" cy="24" r="7" fill="#fde047" />
</g>
)}
</>
);
return <g dangerouslySetInnerHTML={{ __html: frontMarkup(look, ink) }} />;
}
2 changes: 2 additions & 0 deletions src/lib/duel-card.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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}`;
Expand Down
30 changes: 30 additions & 0 deletions src/lib/kap-canvas.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading