feat(card): hero full-bleed redesign (no vertical rules)

The card had a portrait panel + dashed stub divider — two vertical lines with a
dead middle. Rebuilt as one composition: full-bleed photo/cover, a scrim, the
name + identity overlaid bottom-left, and the QR floating bottom-right. No photo
falls back to a themed gradient with a monogram watermark. Applied to all four
front types; the back is a plain list. Also: DEFAULT_RIDER_CARD.style is now ''
so a profile-less card doesn't claim 'Street'. Verified by rendering a real
profile, an app-profile, and an empty npub.
This commit is contained in:
SkateSpots 2026-10-02 12:25:36 +07:00
parent 0dd7e9bfa9
commit 5ccc191b32
3 changed files with 217 additions and 234 deletions

View file

@ -111,6 +111,13 @@ session with **no other memory of this project**. So:
`valid-thru`** (`passExpiry()` → EXPIRED badge on card + verify; the input is a
date picker). Card schema → `v:3`. Public `/#/card/:npub` also flips + shows
the custom accent/logo.
**Hero redesign (2026-10-02):** the card is now a **full-bleed photo/cover**
with a bottom scrim, the name + identity overlaid bottom-left and the **QR
floating bottom-right** — **no vertical rules / columns** (the old portrait
panel + dashed stub read as two ugly lines). No photo → a **monogram
watermark**. Card pages resolve the profile via **`useCardProfile`** (tolerant
JSON over `querySkateContent`, so it can't drop a real profile the way
`useAuthor` could); `DEFAULT_RIDER_CARD.style` is now `''` (no fake "Street").
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page

View file

@ -43,7 +43,7 @@ export interface RiderCardConfig {
export const DEFAULT_RIDER_CARD: RiderCardConfig = {
theme: 'thug',
style: 'Street',
style: '',
skills: [],
sponsors: '',
ridesFor: '',

View file

@ -1,19 +1,16 @@
/**
* riderCardCanvas — THE single card renderer.
*
* 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
* resulting PNG, so what you see is exactly what prints.
*
* Layout: portrait panel (left) · text column (middle) · verify stub with a
* large QR (right). Three type sizes, no filler.
* "Hero" credential: the profile photo (or cover) is full-bleed; the identity
* overlays the lower-left on a scrim, and the QR floats bottom-right. No
* vertical rules, no columns — one composition that works with or without a
* photo, and prints identically.
*/
import { roleMeta, formatCardDate, passExpiry } from '@/lib/riderCard';
export const CARD_W = 856;
export const CARD_H = 540;
/** Theme palettes (also used by the CSS swatches in the studio picker). */
export const THEME_PALETTE: Record<string, {
bg1: string; bg2: string; bg3: string;
accent: string; accent2: string;
@ -22,12 +19,12 @@ export const THEME_PALETTE: Record<string, {
text: string; shopColor: string; sponsorColor: string;
sidebarBg: string;
}> = {
thug: { bg1: '#0a0a0a', bg2: '#180006', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
lime: { bg1: '#040c00', bg2: '#081500', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.55)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
chrome: { bg1: '#111111', bg2: '#252525', bg3: '#0e0e0e', accent: '#e6e6e6', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.6)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#f2f2f2', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
wood: { bg1: '#2e1804', bg2: '#4a2a0c', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.65)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
midnight: { bg1: '#010610', bg2: '#040c22', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.6)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#d0e4ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
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)' },
thug: { bg1: '#12030a', bg2: '#2a000f', bg3: '#0d0d0d', accent: '#ff0040', accent2: '#ccff00', stripe1: '#ff0040', stripe2: '#ff6600', muted: 'rgba(255,255,255,0.72)', chipBg: 'rgba(255,0,64,0.18)', chipBorder: 'rgba(255,0,64,0.45)', text: '#ffffff', shopColor: '#ccff00', sponsorColor: '#ff9900', sidebarBg: 'rgba(255,0,64,0.07)' },
lime: { bg1: '#071400', bg2: '#0f2400', bg3: '#040c00', accent: '#ccff00', accent2: '#ff6600', stripe1: '#ccff00', stripe2: '#88ff00', muted: 'rgba(255,255,255,0.72)', chipBg: 'rgba(204,255,0,0.14)', chipBorder: 'rgba(204,255,0,0.4)', text: '#ffffff', shopColor: '#ff6600', sponsorColor: '#ff0040', sidebarBg: 'rgba(204,255,0,0.05)' },
chrome: { bg1: '#171717', bg2: '#2c2c2c', bg3: '#0e0e0e', accent: '#e6e6e6', accent2: '#ff6600', stripe1: '#777777', stripe2: '#cccccc', muted: 'rgba(220,220,220,0.72)', chipBg: 'rgba(200,200,200,0.12)', chipBorder: 'rgba(200,200,200,0.3)', text: '#f2f2f2', shopColor: '#ff9900', sponsorColor: '#64cfff', sidebarBg: 'rgba(200,200,200,0.04)' },
wood: { bg1: '#3a2006', bg2: '#5a3410', bg3: '#261404', accent: '#f5c842', accent2: '#ff6600', stripe1: '#8b5e3c', stripe2: '#d4a05a', muted: 'rgba(245,230,200,0.78)', chipBg: 'rgba(160,100,40,0.25)', chipBorder: 'rgba(200,140,60,0.5)', text: '#f5e6c8', shopColor: '#ff9944', sponsorColor: '#aaffcc', sidebarBg: 'rgba(160,100,40,0.12)' },
midnight: { bg1: '#040b1a', bg2: '#071535', bg3: '#020814', accent: '#64a0ff', accent2: '#ccff00', stripe1: '#2040aa', stripe2: '#64a0ff', muted: 'rgba(200,220,255,0.75)', chipBg: 'rgba(100,160,255,0.12)', chipBorder: 'rgba(100,160,255,0.35)', text: '#eaf2ff', shopColor: '#ccff00', sponsorColor: '#ff88cc', sidebarBg: 'rgba(100,160,255,0.05)' },
sunset: { bg1: '#1a0208', bg2: '#241012', bg3: '#0e0810', accent: '#ff6020', accent2: '#ff0080', stripe1: '#ff0040', stripe2: '#ffaa00', muted: 'rgba(255,210,170,0.78)', 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 CanvasCardData {
@ -75,12 +72,11 @@ export interface CanvasCardData {
}
// ─── 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
const X = 44; // left text margin
const NAME_MAX = 560; // text column width (QR sits to the right)
const QR_SIZE = 150;
const QR_X = CARD_W - X - QR_SIZE; // 662
const QR_Y = 322;
// ─── helpers ──────────────────────────────────────────────────────────────────
async function loadImage(url: string): Promise<HTMLImageElement | null> {
@ -114,16 +110,22 @@ function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number):
return t.length < text.length ? t.slice(0, -1) + '…' : t;
}
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, r: number) {
function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, w: number, h: number) {
const ar = img.width / img.height;
const boxAr = w / h;
let dw = w, dh = h, dx = x, dy = y;
if (ar > boxAr) { dw = h * ar; dx = x - (dw - w) / 2; }
else { dh = w / ar; dy = y - (dh - h) / 2; }
ctx.save();
roundRect(ctx, x, y, w, h, r);
ctx.clip();
let dw = w, dh = h, dx = 0, dy = 0;
if (ar > boxAr) { dw = h * ar; dx = -(dw - w) / 2; }
else { dh = w / ar; dy = -(dh - h) / 2; }
ctx.drawImage(img, dx, dy, dw, dh);
}
/** Draw text with a soft shadow so it stays legible over a photo. */
function shadowed(ctx: CanvasRenderingContext2D, fn: () => void, blur = 16) {
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.75)';
ctx.shadowBlur = blur;
ctx.shadowOffsetY = 3;
fn();
ctx.restore();
}
@ -135,203 +137,191 @@ function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: num
try { c.letterSpacing = prev; } catch { /* ignore */ }
}
// ─── shared blocks ────────────────────────────────────────────────────────────
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
ctx.textAlign = 'left';
ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', TEXT_X, 66, 4);
ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, kind, TEXT_X + 1, 88, 3);
}
/** The right verify stub: dashed divider + a large QR. */
async function drawStub(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 2;
ctx.setLineDash([9, 11]);
ctx.beginPath();
ctx.moveTo(STUB_X, 26);
ctx.lineTo(STUB_X, CARD_H - 26);
ctx.stroke();
ctx.restore();
const size = 156;
const qx = STUB_X + (STUB_W - size) / 2;
const qy = 140;
roundRect(ctx, qx - 12, qy - 12, size + 24, size + 24, 14);
/** The floating white QR panel (bottom-right). */
async function drawQr(ctx: CanvasRenderingContext2D, verifyUrl: string | undefined, p: typeof THEME_PALETTE[string]) {
roundRect(ctx, QR_X, QR_Y, QR_SIZE, QR_SIZE, 16);
ctx.fillStyle = '#ffffff';
ctx.fill();
if (data.verifyUrl) {
if (verifyUrl) {
try {
const QRCode = (await import('qrcode')).default;
const dataUrl = await QRCode.toDataURL(data.verifyUrl, { width: size * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const dataUrl = await QRCode.toDataURL(verifyUrl, { width: QR_SIZE * 2, margin: 0, errorCorrectionLevel: 'M', color: { dark: '#000000', light: '#ffffff' } });
const img = await loadImage(dataUrl);
if (img) ctx.drawImage(img, qx, qy, size, size);
if (img) ctx.drawImage(img, QR_X + 12, QR_Y + 12, QR_SIZE - 24, QR_SIZE - 24);
} catch { /* skip */ }
}
ctx.textAlign = 'center';
ctx.fillStyle = p.muted;
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.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, verifyUrl ? 'SCAN TO VERIFY' : 'NOT PUBLISHED', QR_X + QR_SIZE / 2, QR_Y + QR_SIZE + 24, 2);
ctx.textAlign = 'left';
}
/** Brand lockup, top-left, with a small dark backing pill for legibility. */
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
ctx.font = 'bold 15px system-ui, sans-serif';
const t1 = 'SKATESPOTS';
const w1 = ctx.measureText(t1).width + 8 * (t1.length - 1);
ctx.font = 'bold 11px system-ui, sans-serif';
const w2 = ctx.measureText(kind).width + 3 * (kind.length - 1);
const w = Math.max(w1, w2) + 34;
roundRect(ctx, X - 14, 34, w, 58, 12);
ctx.fillStyle = 'rgba(0,0,0,0.42)';
ctx.fill();
ctx.fillStyle = p.accent;
ctx.font = 'bold 15px system-ui, sans-serif';
wordmark(ctx, t1, X + 3, 60, 8);
ctx.fillStyle = 'rgba(255,255,255,0.78)';
ctx.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, kind, X + 3, 82, 3);
}
// ─── front sides ──────────────────────────────────────────────────────────────
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
drawBrand(ctx, p, 'RIDER CARD');
// Name — the focal point (sized to leave the QR room).
ctx.fillStyle = p.text;
ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 148);
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
const meta: string[] = [];
if (data.showCrews) for (const c of (data.crews ?? []).slice(0, 2)) meta.push(`🏕️ ${c}`);
if (data.showRoles) for (const r of (data.roles ?? []).slice(0, 1)) { const m = roleMeta(r); if (m) meta.push(`${m.emoji} ${m.label}`); }
for (const b of (data.receivedBadges ?? []).slice(0, 3)) meta.push(`★ ${b.skill}`);
// One identity line: style · level · local.
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
let y = 188;
// Name — bottom-left hero.
shadowed(ctx, () => {
ctx.fillStyle = p.text;
ctx.font = 'bold 64px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), NAME_MAX), X, 398);
});
if (identity) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y);
y += 30;
shadowed(ctx, () => {
ctx.fillStyle = p.accent;
ctx.font = 'bold 23px system-ui, sans-serif';
ctx.fillText(fitText(ctx, identity, NAME_MAX), X, 440);
}, 12);
}
if (meta.length > 0) {
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = 'bold 17px system-ui, sans-serif';
ctx.fillText(fitText(ctx, meta.join(' '), NAME_MAX), X, 476);
}, 10);
}
if (data.nip05) {
const [local, domain] = data.nip05.split('@');
const handle = local && local !== '_' ? `@${local}` : domain ? domain : '';
const handle = local && local !== '_' ? `@${local}` : (domain ?? '');
if (handle) {
ctx.fillStyle = p.muted;
ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, handle, TEXT_W), TEXT_X, y);
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, handle, NAME_MAX), X, 508);
}, 10);
}
}
// Crew monogram circles on the lower band.
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
const circleR = 15;
let cx = TEXT_X + circleR;
const cy = CARD_H - 66;
crewNames.forEach((name) => {
ctx.beginPath();
ctx.arc(cx, cy, circleR, 0, Math.PI * 2);
ctx.fillStyle = p.chipBg;
// Crest logo, top-right (mirrors the brand).
if (logo) {
ctx.save();
roundRect(ctx, CARD_W - X - 56, 34, 56, 56, 12);
ctx.fillStyle = 'rgba(0,0,0,0.42)';
ctx.fill();
ctx.strokeStyle = p.chipBorder;
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = p.accent;
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, cy + 5);
ctx.textAlign = 'left';
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
const nm = fitText(ctx, name, 82);
ctx.fillText(nm, cx + circleR + 8, cy + 5);
cx += circleR * 2 + 14 + ctx.measureText(nm).width + 12;
});
// Roles + witnessed badges on their own line.
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}`); }
for (const b of (data.receivedBadges ?? []).slice(0, 3)) chips.push(`★ ${b.skill}`);
if (chips.length > 0) {
ctx.fillStyle = p.accent2;
ctx.font = 'bold 16px system-ui, sans-serif';
ctx.fillText(fitText(ctx, chips.join(' '), TEXT_W), TEXT_X, 276);
ctx.clip();
ctx.drawImage(logo, CARD_W - X - 56, 34, 56, 56);
ctx.restore();
}
if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12);
await drawStub(ctx, data, p);
await drawQr(ctx, data.verifyUrl, p);
}
async function drawParkFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const expired = passExpiry(data.validThru) === 'expired';
drawBrand(ctx, p, 'ACCESS PASS');
const expired = passExpiry(data.validThru) === 'expired';
ctx.fillStyle = p.text;
ctx.font = 'bold 40px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), TEXT_W), TEXT_X, 150);
ctx.fillStyle = expired ? '#ff5555' : p.accent;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), TEXT_W), TEXT_X, 190);
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', TEXT_X, 240);
ctx.fillStyle = p.text;
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278);
shadowed(ctx, () => {
ctx.fillStyle = p.text;
ctx.font = 'bold 58px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), NAME_MAX), X, 392);
});
shadowed(ctx, () => {
ctx.fillStyle = expired ? '#ff6b6b' : p.accent;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), NAME_MAX), X, 432);
}, 12);
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `ADMITS ${data.recipientName || data.name}`, NAME_MAX), X, 470);
}, 10);
if (data.validThru) {
ctx.fillStyle = p.muted;
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62);
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '17px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, X, 504);
}, 10);
}
await drawStub(ctx, data, p);
await drawQr(ctx, data.verifyUrl, p);
}
async function drawCrewFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const c = data.crew;
drawBrand(ctx, p, 'CREW CARD');
ctx.fillStyle = p.text;
ctx.font = 'bold 42px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154);
shadowed(ctx, () => {
ctx.fillStyle = p.text;
ctx.font = 'bold 62px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), NAME_MAX), X, 396);
});
const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · ');
if (line2) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194);
shadowed(ctx, () => {
ctx.fillStyle = p.accent;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), NAME_MAX), X, 436);
}, 12);
}
if (c) {
ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif';
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, TEXT_X, 244);
if (c.lookingFor.length > 0) {
ctx.fillStyle = p.accent2;
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.82)';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('OPEN CALL', TEXT_X, 284);
}
ctx.fillText(`${c.members} MEMBERS ${c.spots} SPOTS${c.lookingFor.length ? ' OPEN CALL' : ''}`, X, 474);
}, 10);
}
await drawStub(ctx, data, p);
await drawQr(ctx, data.verifyUrl, p);
}
async function drawEventFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const e = data.event;
drawBrand(ctx, p, 'EVENT PASS');
ctx.fillStyle = p.text;
ctx.font = 'bold 38px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150);
shadowed(ctx, () => {
ctx.fillStyle = p.text;
ctx.font = 'bold 54px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), NAME_MAX), X, 388);
});
if (e) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190);
shadowed(ctx, () => {
ctx.fillStyle = p.accent;
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), NAME_MAX), X, 428);
}, 12);
if (e.location) {
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222);
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.78)';
ctx.font = '17px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, NAME_MAX), X, 462);
}, 10);
}
}
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', TEXT_X, 274);
ctx.fillStyle = p.text;
ctx.font = 'bold 28px system-ui, sans-serif';
ctx.fillText(fitText(ctx, e?.holder ?? data.name, TEXT_W), TEXT_X, 310);
await drawStub(ctx, data, p);
shadowed(ctx, () => {
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `ADMITS ${e?.holder ?? data.name}`, NAME_MAX), X, 500);
}, 10);
await drawQr(ctx, data.verifyUrl, p);
}
// ─── back ─────────────────────────────────────────────────────────────────────
@ -347,51 +337,30 @@ async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p:
if (data.validThru) lines.push(['VALID THRU', data.validThru]);
ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', PAD, 62, 4);
ctx.font = 'bold 15px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', X, 64, 4);
ctx.fillStyle = p.muted;
ctx.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, 'CARD BACK', PAD + 1, 84, 3);
ctx.font = 'bold 11px system-ui, sans-serif';
wordmark(ctx, 'CARD BACK', X + 1, 84, 3);
let y = 160;
let y = 168;
const width = QR_X - X - 28;
for (const [label, value] of lines.slice(0, 6)) {
ctx.fillStyle = p.muted;
ctx.font = 'bold 13px system-ui, sans-serif';
wordmark(ctx, label, PAD, y, 3);
wordmark(ctx, label, X, y, 3);
ctx.fillStyle = p.text;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36);
y += 74;
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, width), X, y + 38);
y += 78;
}
if (lines.length === 0) {
ctx.fillStyle = p.muted;
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.', X, y);
}
// right stub: the website + a small QR
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.lineWidth = 2;
ctx.setLineDash([9, 11]);
ctx.beginPath(); ctx.moveTo(STUB_X, 26); ctx.lineTo(STUB_X, CARD_H - 26); 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.font = 'bold 12px system-ui, sans-serif';
wordmark(ctx, 'skate-spots.org', STUB_X + STUB_W / 2, 300, 2);
ctx.textAlign = 'left';
await drawQr(ctx, data.verifyUrl, p);
}
// ─── entry point ──────────────────────────────────────────────────────────────
@ -417,46 +386,48 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
return canvas.toDataURL('image/png');
}
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bg.addColorStop(0, p.bg1);
bg.addColorStop(0.55, p.bg2);
bg.addColorStop(1, p.bg3);
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.fillStyle = bg;
ctx.fill();
// Full-bleed background: the photo/cover, else a themed gradient.
const bgUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
const bgImg = bgUrl ? await loadImage(bgUrl) : null;
const portraitUrl = data.type === 'crew' ? data.crew?.image : data.type === 'event' ? data.event?.image : data.avatarUrl;
const portrait = portraitUrl ? await loadImage(portraitUrl) : null;
ctx.save();
roundRect(ctx, 0, 0, PORTRAIT_W, CARD_H, 28);
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.clip();
if (portrait) {
drawCover(ctx, portrait, 0, 0, PORTRAIT_W, CARD_H, 28);
} else {
ctx.fillStyle = p.sidebarBg;
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
// Initials monogram — more personal than a generic skateboard glyph.
const initials = (data.name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] ?? '').join('').toUpperCase() || '?';
ctx.fillStyle = p.accent;
ctx.font = 'bold 96px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.globalAlpha = 0.85;
ctx.fillText(initials, PORTRAIT_W / 2, CARD_H / 2 + 34);
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
const shade = ctx.createLinearGradient(0, CARD_H - 180, 0, CARD_H);
shade.addColorStop(0, 'rgba(0,0,0,0)');
shade.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = shade;
ctx.fillRect(0, CARD_H - 180, PORTRAIT_W, 180);
ctx.restore();
ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.25;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(PORTRAIT_W, 0); ctx.lineTo(PORTRAIT_W, CARD_H); ctx.stroke();
ctx.globalAlpha = 1;
if (bgImg) {
drawCover(ctx, bgImg, CARD_W, CARD_H);
} else {
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bg.addColorStop(0, p.bg1);
bg.addColorStop(0.55, p.bg2);
bg.addColorStop(1, p.bg3);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CARD_W, CARD_H);
// Monogram watermark so a photo-less card still feels personal.
const initials = (data.name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] ?? '').join('').toUpperCase() || '?';
ctx.globalAlpha = 0.10;
ctx.fillStyle = p.accent;
ctx.font = 'bold 300px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(initials, CARD_W - 40, CARD_H - 40);
ctx.textAlign = 'left';
ctx.globalAlpha = 1;
}
// Top + bottom scrims for legibility.
const top = ctx.createLinearGradient(0, 0, 0, 170);
top.addColorStop(0, 'rgba(0,0,0,0.55)');
top.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = top;
ctx.fillRect(0, 0, CARD_W, 170);
const bot = ctx.createLinearGradient(0, 210, 0, CARD_H);
bot.addColorStop(0, 'rgba(0,0,0,0)');
bot.addColorStop(0.55, 'rgba(0,0,0,0.45)');
bot.addColorStop(1, 'rgba(0,0,0,0.86)');
ctx.fillStyle = bot;
ctx.fillRect(0, 210, CARD_W, CARD_H - 210);
ctx.restore();
const logo = data.type === 'rider' && data.logoUrl ? await loadImage(data.logoUrl) : null;
@ -465,11 +436,16 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
else if (data.type === 'crew') await drawCrewFront(ctx, data, p);
else await drawEventFront(ctx, data, p);
// Signature accent bar (bottom).
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
bar.addColorStop(0, p.stripe1);
bar.addColorStop(1, p.stripe2);
ctx.save();
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);
ctx.clip();
ctx.fillStyle = bar;
ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
ctx.restore();
if (data.holographic) {
ctx.save();
@ -478,7 +454,7 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
const sheen = ctx.createLinearGradient(0, CARD_H, CARD_W, 0);
sheen.addColorStop(0, 'rgba(255,255,255,0)');
sheen.addColorStop(0.42, 'rgba(255,255,255,0)');
sheen.addColorStop(0.5, 'rgba(255,255,255,0.10)');
sheen.addColorStop(0.5, 'rgba(255,255,255,0.09)');
sheen.addColorStop(0.58, 'rgba(255,255,255,0)');
sheen.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = sheen;