fix(card): smaller name, big right-side QR, cleaner geometry

- Name 82px -> 46px (was truncating 'KEEN HAWK' and dominating).
- QR now a proper right-hand verify stub: 214px band with a dashed divider and
  a 156px QR (was a small bottom-corner stub). Applied to all front types.
- Rebalanced geometry (portrait 244, text 338, stub 214); identity/badges sit on
  the lower band; back side stays plain typographic with a small right QR.
- Fixed a string of fillText(fitText(...)) calls that were missing their x.
This commit is contained in:
SkateSpots 2026-10-02 12:03:07 +07:00
parent 84665adc8a
commit 3b542caade

View file

@ -3,12 +3,10 @@
* *
* The card is drawn once, here, on a 856×540 canvas (10× a credit card at * The card is drawn once, here, on a 856×540 canvas (10× a credit card at
* 85.6×54mm). The studio preview and the public `/#/card/:npub` view render the * 85.6×54mm). The studio preview and the public `/#/card/:npub` view render the
* resulting PNG, so what you see is exactly what prints — no second React * resulting PNG, so what you see is exactly what prints.
* implementation to drift from it.
* *
* Design: "Credential" — a large portrait, one huge name, a single identity * Layout: portrait panel (left) · text column (middle) · verify stub with a
* line, and a clean verify stub. Nothing else. (No tiny stats, no npub clutter, * large QR (right). Three type sizes, no filler.
* no "Powered by Nostr" filler.)
*/ */
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard'; import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
@ -32,12 +30,9 @@ export const THEME_PALETTE: Record<string, {
sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.65)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' }, sunset: { bg1: '#0e0004', bg2: '#160606', bg3: '#0a040c', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,200,150,0.65)', chipBg: 'rgba(255,80,0,0.16)', chipBorder: 'rgba(255,80,0,0.4)', text: '#ffd0a0', shopColor: '#ffdd00', sponsorColor: '#ff88ff', sidebarBg: 'rgba(255,80,0,0.07)' },
}; };
export interface CanvasCrew { name: string }
export interface CanvasCardData { export interface CanvasCardData {
type: 'rider' | 'park' | 'crew' | 'event'; type: 'rider' | 'park' | 'crew' | 'event';
themeId: string; themeId: string;
/** Custom accent hex overriding the theme's primary accent. */
accentOverride?: string; accentOverride?: string;
name: string; name: string;
nip05?: string; nip05?: string;
@ -79,6 +74,14 @@ export interface CanvasCardData {
holographic?: boolean; holographic?: boolean;
} }
// ─── geometry ─────────────────────────────────────────────────────────────────
const PAD = 34;
const PORTRAIT_W = 244; // left portrait panel
const STUB_W = 214; // right verify stub (held the large QR)
const STUB_X = CARD_W - STUB_W; // 642
const TEXT_X = PORTRAIT_W + 34; // 278
const TEXT_W = STUB_X - TEXT_X - 26; // ~338
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
async function loadImage(url: string): Promise<HTMLImageElement | null> { async function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -105,14 +108,12 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
ctx.closePath(); ctx.closePath();
} }
/** Shrink text until it fits maxWidth (adds an ellipsis if truncated). */
function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
let t = text; let t = text;
while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1); while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1);
return t.length < text.length ? t.slice(0, -1) + '…' : t; return t.length < text.length ? t.slice(0, -1) + '…' : t;
} }
/** Draw an image covering a box (object-fit: cover), clipped to radius. */
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: number) { function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: number) {
const ar = img.width / img.height; const ar = img.width / img.height;
const boxAr = w / h; const boxAr = w / h;
@ -126,244 +127,214 @@ function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: numb
ctx.restore(); ctx.restore();
} }
/** Letter-spaced uppercase text (ctx.letterSpacing is Chromium-supported). */
function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number) { function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number) {
const prev = (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing; const c = ctx as CanvasRenderingContext2D & { letterSpacing?: string };
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = `${spacing}px`; } catch { /* unsupported */ } const prev = c.letterSpacing;
try { c.letterSpacing = `${spacing}px`; } catch { /* unsupported */ }
ctx.fillText(text, x, y); ctx.fillText(text, x, y);
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = prev; } catch { /* ignore */ } try { c.letterSpacing = prev; } catch { /* ignore */ }
} }
// ─── shared blocks ──────────────────────────────────────────────────────────── // ─── shared blocks ────────────────────────────────────────────────────────────
const PAD = 40; function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
const PORTRAIT_W = 296;
/** The brand lockup, top-left of the text column. */
function drawBrand(ctx: CanvasRenderingContext2D, x: number, y: number, p: typeof THEME_PALETTE[string], kind: string) {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.font = 'bold 17px system-ui, sans-serif';
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
wordmark(ctx, 'SKATESPOTS', x, y, 4); ctx.font = 'bold 16px system-ui, sans-serif';
ctx.font = 'bold 13px system-ui, sans-serif'; wordmark(ctx, 'SKATESPOTS', TEXT_X, 66, 4);
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
wordmark(ctx, kind, x + 1, y + 24, 3); ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, kind, TEXT_X + 1, 88, 3);
} }
/** The QR / verify stub, bottom-right. */ /** The right verify stub: dashed divider + a large QR. */
async function drawVerifyStub( async function drawStub(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
ctx: CanvasRenderingContext2D, ctx.save();
x: number, ctx.strokeStyle = 'rgba(255,255,255,0.16)';
y: number, ctx.lineWidth = 2;
size: number, ctx.setLineDash([9, 11]);
verifyUrl: string | undefined, ctx.beginPath();
p: typeof THEME_PALETTE[string], ctx.moveTo(STUB_X, 26);
dark = false, ctx.lineTo(STUB_X, CARD_H - 26);
) {
// panel
roundRect(ctx, x, y, size, size, 16);
ctx.fillStyle = dark ? 'rgba(255,255,255,0.06)' : '#ffffff';
ctx.fill();
ctx.strokeStyle = dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.08)';
ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
ctx.restore();
const pad = 16; const size = 156;
if (verifyUrl) { const qx = STUB_X + (STUB_W - size) / 2;
const qy = 128;
roundRect(ctx, qx - 12, qy - 12, size + 24, size + 24, 14);
ctx.fillStyle = '#ffffff';
ctx.fill();
if (data.verifyUrl) {
try { try {
const QRCode = (await import('qrcode')).default; const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(verifyUrl, { width: (size - pad * 2) * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } }); const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const img = await loadImage(dataUrl); const img = await loadImage(dataUrl);
if (img) ctx.drawImage(img, x + pad, y + pad, size - pad * 2, size - pad * 2); if (img) ctx.drawImage(img, qx, qy, size, size);
} catch { /* skip */ } } catch { /* skip */ }
} }
// caption under the stub
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', x + size / 2, y + size + 20, 2); ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, data.verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', STUB_X + STUB_W / 2, qy + size + 34, 2);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
// ─── front sides ────────────────────────────────────────────────────────────── // ─── front sides ──────────────────────────────────────────────────────────────
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) { async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
const RX = PORTRAIT_W + PAD; drawBrand(ctx, p, 'RIDER CARD');
const RW = CARD_W - RX - PAD;
drawBrand(ctx, RX, 74, p, 'RIDER CARD'); // Name — the focal point (sized to leave the QR room).
// Name — the focal point.
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 82px system-ui, sans-serif'; ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), RW), RX, 214); ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 156);
// One identity line: style · level · local. // One identity line: style · level · local.
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · '); const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
let y = 262; let y = 196;
if (identity) { if (identity) {
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = 'bold 25px system-ui, sans-serif'; ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText(fitText(ctx, identity.toUpperCase(), RW), RX, y); ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y);
y += 40; y += 30;
} }
// nip05
if (data.nip05) { if (data.nip05) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'italic 21px Georgia, serif'; ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, RW), RX, y); ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, TEXT_W), TEXT_X, y);
y += 34;
} else {
y += 8;
} }
// Crews (monogram circles) + roles, as one compact row. // Crew monogram circles on the lower band.
const chips: string[] = [];
if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 2)) { const m = roleMeta(r); if (m) chips.push(`${m.emoji} ${m.label}`); }
const witnessed = (data.receivedBadges ?? []).slice(0, 3).map(b => `★ ${b.skill}`);
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : []; const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
const circleR = 15;
// crew monogram circles just above the bottom band let cx = TEXT_X + circleR;
const circleR = 17; const cy = CARD_H - 66;
let cx = RX + circleR;
crewNames.forEach((name) => { crewNames.forEach((name) => {
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, CARD_H - 66, circleR, 0, Math.PI * 2); ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
ctx.fillStyle = p.chipBg; ctx.fillStyle = p.chipBg;
ctx.fill(); ctx.fill();
ctx.strokeStyle = p.chipBorder; ctx.strokeStyle = p.chipBorder;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif'; ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, CARD_H - 60); ctx.fillText((name[0] ?? '?').toUpperCase(), cx, cy + 5);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
// name
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif'; ctx.font = '14px system-ui, sans-serif';
ctx.fillText(fitText(ctx, name, 96), cx + circleR + 8, CARD_H - 60); const nm = fitText(ctx, name, 82);
cx += circleR * 2 + 16 + ctx.measureText(fitText(ctx, name, 96)).width + 12; ctx.fillText(nm, cx + circleR + 8, cy + 5);
cx += circleR * 2 + 14 + ctx.measureText(nm).width + 12;
}); });
// roles + witnessed chips, right-aligned on the bottom band // Roles + witnessed badges, on their own line just above the circles.
const bottomChips = [...chips, ...witnessed]; const chips: string[] = [];
if (bottomChips.length > 0) { if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 2)) { const m = roleMeta(r); if (m) chips.push(`${m.emoji} ${m.label}`); }
ctx.textAlign = 'right'; for (const b of (data.receivedBadges ?? []).slice(0, 3)) chips.push(`★ ${b.skill}`);
ctx.font = 'bold 15px system-ui, sans-serif'; if (chips.length > 0 && crewNames.length === 0) {
ctx.fillStyle = p.accent2; ctx.fillStyle = p.accent2;
ctx.fillText(fitText(ctx, bottomChips.join(' '), RW - 180), RX, CARD_H - 60); ctx.font = 'bold 15px system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.fillText(fitText(ctx, chips.join(' '), TEXT_W), TEXT_X, cy + 5);
} }
// Crest logo top-left of the portrait (if any) if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12);
if (logo) drawCover(ctx, logo, 24, 24, 62, 62, 14); await drawStub(ctx, data, p);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
} }
async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const RX = PORTRAIT_W + PAD;
const RW = CARD_W - RX - PAD;
const expired = passExpiry(data.validThru) === 'expired'; const expired = passExpiry(data.validThru) === 'expired';
drawBrand(ctx, p, 'ACCESS PASS');
drawBrand(ctx, RX, 74, p, 'ACCESS PASS');
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 64px system-ui, sans-serif'; ctx.font = 'bold 40px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), RW), RX, 186); ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), TEXT_W), TEXT_X, 150);
ctx.fillStyle = expired ? '#ff5555' : p.accent; ctx.fillStyle = expired ? '#ff5555' : p.accent;
ctx.font = 'bold 26px system-ui, sans-serif'; ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), RW), RX, 234); ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), TEXT_W), TEXT_X, 190);
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif'; ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', RX, 296); ctx.fillText('ADMITS', TEXT_X, 240);
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 34px system-ui, sans-serif'; ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.recipientName || data.name, RW), RX, 334); ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278);
if (data.validThru) { if (data.validThru) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif'; ctx.font = '14px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, RX, CARD_H - 60); ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62);
} }
await drawStub(ctx, data, p);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
} }
async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const RX = PORTRAIT_W + PAD;
const RW = CARD_W - RX - PAD;
const c = data.crew; const c = data.crew;
drawBrand(ctx, p, 'CREW CARD');
drawBrand(ctx, RX, 74, p, 'CREW CARD');
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 68px system-ui, sans-serif'; ctx.font = 'bold 42px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), RW), RX, 192); ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154);
const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · '); const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · ');
if (line2) { if (line2) {
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = 'bold 24px system-ui, sans-serif'; ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), RW), RX, 240); ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194);
} }
if (c) { if (c) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '20px system-ui, sans-serif'; ctx.font = '17px system-ui, sans-serif';
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, RX, 292); ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, TEXT_X, 244);
if (c.lookingFor.length > 0) { if (c.lookingFor.length > 0) {
ctx.fillStyle = p.accent2; ctx.fillStyle = p.accent2;
ctx.font = 'bold 20px system-ui, sans-serif'; ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('OPEN CALL', RX, 328); ctx.fillText('OPEN CALL', TEXT_X, 284);
} }
} }
await drawStub(ctx, data, p);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
} }
async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const RX = PORTRAIT_W + PAD;
const RW = CARD_W - RX - PAD;
const e = data.event; const e = data.event;
drawBrand(ctx, p, 'EVENT PASS');
drawBrand(ctx, RX, 74, p, 'EVENT PASS');
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 60px system-ui, sans-serif'; ctx.font = 'bold 38px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), RW), RX, 184); ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150);
if (e) { if (e) {
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = 'bold 23px system-ui, sans-serif'; ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), RW), RX, 228); ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190);
if (e.location) { if (e.location) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '19px system-ui, sans-serif'; ctx.font = '16px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, RW), RX, 264); ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222);
} }
} }
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif'; ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', RX, 320); ctx.fillText('ADMITS', TEXT_X, 274);
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 32px system-ui, sans-serif'; ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(fitText(ctx, e?.holder ?? data.name, RW), RX, 356); ctx.fillText(fitText(ctx, e?.holder ?? data.name, TEXT_W), TEXT_X, 310);
await drawStub(ctx, data, p);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
} }
// ─── back ───────────────────────────────────────────────────────────────────── // ─── back ─────────────────────────────────────────────────────────────────────
function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) { async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const lines: Array<[string, string]> = [];
const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · '); const roles = (data.roles ?? []).map(r => roleMeta(r)).filter(Boolean).map(m => `${m!.emoji} ${m!.label}`).join(' · ');
const lines: Array<[string, string]> = [];
if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]); if (data.crews?.length) lines.push(['CREWS', data.crews.join(' · ')]);
if (roles) lines.push(['LAB ROLES', roles]); if (roles) lines.push(['LAB ROLES', roles]);
if (data.local) lines.push(['LOCAL', data.local]); if (data.local) lines.push(['LOCAL', data.local]);
@ -371,35 +342,51 @@ function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof
if (data.sponsors) lines.push(['SPONSORS', data.sponsors]); if (data.sponsors) lines.push(['SPONSORS', data.sponsors]);
if (data.validThru) lines.push(['VALID THRU', data.validThru]); if (data.validThru) lines.push(['VALID THRU', data.validThru]);
drawBrand(ctx, PAD, 86, p, 'CARD BACK'); ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', PAD, 62, 4);
ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, 'CARD BACK', PAD + 1, 84, 3);
let y = 200; let y = 160;
for (const [label, value] of lines.slice(0, 6)) { for (const [label, value] of lines.slice(0, 6)) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'bold 15px system-ui, sans-serif'; ctx.font = 'bold 13px system-ui, sans-serif';
wordmark(ctx, label, PAD, y, 3); wordmark(ctx, label, PAD, y, 3);
ctx.fillStyle = p.text; ctx.fillStyle = p.text;
ctx.font = 'bold 26px system-ui, sans-serif'; ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, CARD_W - PAD * 2 - 220), PAD, y + 40); ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36);
y += 92; y += 74;
} }
if (lines.length === 0) { if (lines.length === 0) {
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = '22px system-ui, sans-serif'; ctx.font = '20px system-ui, sans-serif';
ctx.fillText('Add crews, roles or sponsors to fill the back.', PAD, y); ctx.fillText('Add crews, roles or sponsors to fill the back.', PAD, y);
} }
// Verify stub on the right // right stub: the website + a small QR
roundRect(ctx, CARD_W - PAD - 150, CARD_H - PAD - 150, 150, 150, 16); ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.fill(); ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.setLineDash([9, 11]);
ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(STUB_X, 26); ctx.lineTo(STUB_X, CARD_H - 26); ctx.stroke();
ctx.stroke(); ctx.restore();
const size = 120;
const qx = STUB_X + (STUB_W - size) / 2;
if (data.verifyUrl) {
try {
const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#ffffff', light: '#00000000' } });
const img = await loadImage(dataUrl);
if (img) ctx.drawImage(img, qx, 150, size, size);
} catch { /* skip */ }
}
ctx.textAlign = 'center';
ctx.fillStyle = p.muted; ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif'; ctx.font = 'bold 12px system-ui, sans-serif';
ctx.textAlign = 'center'; wordmark(ctx, 'skate-spots.org', STUB_X + STUB_W / 2, 300, 2);
wordmark(ctx, 'skate-spots.org', CARD_W - PAD - 75, CARD_H - PAD + 22, 2);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
@ -422,11 +409,10 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fill(); ctx.fill();
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.fillRect(0, 0, CARD_W, 8); ctx.fillRect(0, 0, CARD_W, 8);
drawBack(ctx, data, p); await drawBack(ctx, data, p);
return canvas.toDataURL('image/png'); return canvas.toDataURL('image/png');
} }
// Background
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H); const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bg.addColorStop(0, p.bg1); bg.addColorStop(0, p.bg1);
bg.addColorStop(0.55, p.bg2); bg.addColorStop(0.55, p.bg2);
@ -435,7 +421,6 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fillStyle = bg; ctx.fillStyle = bg;
ctx.fill(); ctx.fill();
// Portrait panel (full height left)
const portraitUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl; const portraitUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
const portrait = portraitUrl ? await loadImage(portraitUrl) : null; const portrait = portraitUrl ? await loadImage(portraitUrl) : null;
ctx.save(); ctx.save();
@ -447,22 +432,20 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fillStyle = p.sidebarBg; ctx.fillStyle = p.sidebarBg;
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H); ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
ctx.fillStyle = p.accent; ctx.fillStyle = p.accent;
ctx.font = '96px system-ui, sans-serif'; ctx.font = '84px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 30); ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 26);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
// darken the portrait's lower band so crew circles read const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H);
const shade = ctx.createLinearGradient(0, CARD_H - 200, 0, CARD_H);
shade.addColorStop(0, 'rgba(0,0,0,0)'); shade.addColorStop(0, 'rgba(0,0,0,0)');
shade.addColorStop(1, 'rgba(0,0,0,0.78)'); shade.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = shade; ctx.fillStyle = shade;
ctx.fillRect(0, CARD_H - 200, PORTRAIT_W, 200); ctx.fillRect(0, CARD_H - 180, PORTRAIT_W, 180);
ctx.restore(); ctx.restore();
// separator
ctx.strokeStyle = p.accent; ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.3; ctx.globalAlpha = 0.25;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke(); ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
@ -474,14 +457,12 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
else if (data.type === 'crew') await drawCrewFront(ctx, data, p); else if (data.type === 'crew') await drawCrewFront(ctx, data, p);
else await drawEventFront(ctx, data, p); else await drawEventFront(ctx, data, p);
// Signature bottom accent bar
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0); const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
bar.addColorStop(0, p.stripe1); bar.addColorStop(0, p.stripe1);
bar.addColorStop(1, p.stripe2); bar.addColorStop(1, p.stripe2);
ctx.fillStyle = bar; ctx.fillStyle = bar;
ctx.fillRect(0, CARD_H - 10, CARD_W, 10); ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
// Holographic sheen (optional) — a soft diagonal highlight.
if (data.holographic) { if (data.holographic) {
ctx.save(); ctx.save();
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28); roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);