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
* 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
* implementation to drift from it.
* resulting PNG, so what you see is exactly what prints.
*
* Design: "Credential" — a large portrait, one huge name, a single identity
* line, and a clean verify stub. Nothing else. (No tiny stats, no npub clutter,
* no "Powered by Nostr" filler.)
* Layout: portrait panel (left) · text column (middle) · verify stub with a
* large QR (right). Three type sizes, no filler.
*/
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)' },
};
export interface CanvasCrew { name: string }
export interface CanvasCardData {
type: 'rider' | 'park' | 'crew' | 'event';
themeId: string;
/** Custom accent hex overriding the theme's primary accent. */
accentOverride?: string;
name: string;
nip05?: string;
@ -79,6 +74,14 @@ export interface CanvasCardData {
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 ──────────────────────────────────────────────────────────────────
async function loadImage(url: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
@ -105,14 +108,12 @@ function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: numbe
ctx.closePath();
}
/** Shrink text until it fits maxWidth (adds an ellipsis if truncated). */
function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string {
let t = text;
while (ctx.measureText(t).width > maxWidth && t.length > 2) t = t.slice(0, -1);
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) {
const ar = img.width / img.height;
const boxAr = w / h;
@ -126,244 +127,214 @@ function drawCover(ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: numb
ctx.restore();
}
/** Letter-spaced uppercase text (ctx.letterSpacing is Chromium-supported). */
function wordmark(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number) {
const prev = (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing;
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = `${spacing}px`; } catch { /* unsupported */ }
const c = ctx as CanvasRenderingContext2D & { letterSpacing?: string };
const prev = c.letterSpacing;
try { c.letterSpacing = `${spacing}px`; } catch { /* unsupported */ }
ctx.fillText(text, x, y);
try { (ctx as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = prev; } catch { /* ignore */ }
try { c.letterSpacing = prev; } catch { /* ignore */ }
}
// ─── shared blocks ────────────────────────────────────────────────────────────
const PAD = 40;
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) {
function drawBrand(ctx: CanvasRenderingContext2D, p: typeof THEME_PALETTE[string], kind: string) {
ctx.textAlign = 'left';
ctx.font = 'bold 17px system-ui, sans-serif';
ctx.fillStyle = p.accent;
wordmark(ctx, 'SKATESPOTS', x, y, 4);
ctx.font = 'bold 13px system-ui, sans-serif';
ctx.font = 'bold 16px system-ui, sans-serif';
wordmark(ctx, 'SKATESPOTS', TEXT_X, 66, 4);
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. */
async function drawVerifyStub(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
verifyUrl: string | undefined,
p: typeof THEME_PALETTE[string],
dark = false,
) {
// 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;
/** 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 pad = 16;
if (verifyUrl) {
const size = 156;
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 {
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);
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 */ }
}
// caption under the stub
ctx.textAlign = 'center';
ctx.font = 'bold 12px system-ui, sans-serif';
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';
}
// ─── front sides ──────────────────────────────────────────────────────────────
async function drawRiderFront(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string], logo: HTMLImageElement | null) {
const RX = PORTRAIT_W + PAD;
const RW = CARD_W - RX - PAD;
drawBrand(ctx, p, 'RIDER CARD');
drawBrand(ctx, RX, 74, p, 'RIDER CARD');
// Name — the focal point.
// Name — the focal point (sized to leave the QR room).
ctx.fillStyle = p.text;
ctx.font = 'bold 82px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), RW), RX, 214);
ctx.font = 'bold 46px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.name.toUpperCase(), TEXT_W), TEXT_X, 156);
// One identity line: style · level · local.
const identity = [data.style, data.level, data.showLocal ? data.local : ''].filter(Boolean).join(' · ');
let y = 262;
let y = 196;
if (identity) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 25px system-ui, sans-serif';
ctx.fillText(fitText(ctx, identity.toUpperCase(), RW), RX, y);
y += 40;
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText(fitText(ctx, identity.toUpperCase(), TEXT_W), TEXT_X, y);
y += 30;
}
// nip05
if (data.nip05) {
ctx.fillStyle = p.muted;
ctx.font = 'italic 21px Georgia, serif';
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, RW), RX, y);
y += 34;
} else {
y += 8;
ctx.font = 'italic 17px Georgia, serif';
ctx.fillText(fitText(ctx, `@${data.nip05.split('@')[0]}`, TEXT_W), TEXT_X, y);
}
// Crews (monogram circles) + roles, as one compact row.
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}`);
// Crew monogram circles on the lower band.
const crewNames = data.showCrews ? (data.crews ?? []).slice(0, 2) : [];
// crew monogram circles just above the bottom band
const circleR = 17;
let cx = RX + circleR;
const circleR = 15;
let cx = TEXT_X + circleR;
const cy = CARD_H - 66;
crewNames.forEach((name) => {
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.fill();
ctx.strokeStyle = p.chipBorder;
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = p.accent;
ctx.font = 'bold 16px system-ui, sans-serif';
ctx.font = 'bold 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, CARD_H - 60);
ctx.fillText((name[0] ?? '?').toUpperCase(), cx, cy + 5);
ctx.textAlign = 'left';
// name
ctx.fillStyle = p.muted;
ctx.font = '15px system-ui, sans-serif';
ctx.fillText(fitText(ctx, name, 96), cx + circleR + 8, CARD_H - 60);
cx += circleR * 2 + 16 + ctx.measureText(fitText(ctx, name, 96)).width + 12;
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 chips, right-aligned on the bottom band
const bottomChips = [...chips, ...witnessed];
if (bottomChips.length > 0) {
ctx.textAlign = 'right';
ctx.font = 'bold 15px system-ui, sans-serif';
// Roles + witnessed badges, on their own line just above the circles.
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 && crewNames.length === 0) {
ctx.fillStyle = p.accent2;
ctx.fillText(fitText(ctx, bottomChips.join(' '), RW - 180), RX, CARD_H - 60);
ctx.textAlign = 'left';
ctx.font = 'bold 15px system-ui, sans-serif';
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, 24, 24, 62, 62, 14);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
if (logo) drawCover(ctx, logo, 22, 22, 58, 58, 12);
await drawStub(ctx, data, p);
}
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';
drawBrand(ctx, RX, 74, p, 'ACCESS PASS');
drawBrand(ctx, p, 'ACCESS PASS');
ctx.fillStyle = p.text;
ctx.font = 'bold 64px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (data.issuerName || 'SKATEPARK').toUpperCase(), RW), RX, 186);
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 26px system-ui, sans-serif';
ctx.fillText(fitText(ctx, expired ? 'EXPIRED' : (data.accessLevel || 'FULL ACCESS').toUpperCase(), RW), RX, 234);
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 = '17px system-ui, sans-serif';
ctx.fillText('ADMITS', RX, 296);
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', TEXT_X, 240);
ctx.fillStyle = p.text;
ctx.font = 'bold 34px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.recipientName || data.name, RW), RX, 334);
ctx.font = 'bold 30px system-ui, sans-serif';
ctx.fillText(fitText(ctx, data.recipientName || data.name, TEXT_W), TEXT_X, 278);
if (data.validThru) {
ctx.fillStyle = p.muted;
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, RX, CARD_H - 60);
ctx.font = '14px system-ui, sans-serif';
ctx.fillText(`VALID THRU ${data.validThru}`, TEXT_X, CARD_H - 62);
}
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
await drawStub(ctx, data, p);
}
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;
drawBrand(ctx, RX, 74, p, 'CREW CARD');
drawBrand(ctx, p, 'CREW CARD');
ctx.fillStyle = p.text;
ctx.font = 'bold 68px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), RW), RX, 192);
ctx.font = 'bold 42px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (c?.name ?? '').toUpperCase(), TEXT_W), TEXT_X, 154);
const line2 = [c?.location, c?.founded ? `EST. ${c.founded}` : ''].filter(Boolean).join(' · ');
if (line2) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), RW), RX, 240);
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, line2.toUpperCase(), TEXT_W), TEXT_X, 194);
}
if (c) {
ctx.fillStyle = p.muted;
ctx.font = '20px system-ui, sans-serif';
ctx.fillText(`${c.members} MEMBERS · ${c.spots} SPOTS`, RX, 292);
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;
ctx.font = 'bold 20px system-ui, sans-serif';
ctx.fillText('OPEN CALL', RX, 328);
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.fillText('OPEN CALL', TEXT_X, 284);
}
}
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
await drawStub(ctx, data, p);
}
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;
drawBrand(ctx, RX, 74, p, 'EVENT PASS');
drawBrand(ctx, p, 'EVENT PASS');
ctx.fillStyle = p.text;
ctx.font = 'bold 60px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), RW), RX, 184);
ctx.font = 'bold 38px system-ui, sans-serif';
ctx.fillText(fitText(ctx, (e?.title ?? '').toUpperCase(), TEXT_W), TEXT_X, 150);
if (e) {
ctx.fillStyle = p.accent;
ctx.font = 'bold 23px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), RW), RX, 228);
ctx.font = 'bold 19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, formatCardDate(e.start, e.end), TEXT_W), TEXT_X, 190);
if (e.location) {
ctx.fillStyle = p.muted;
ctx.font = '19px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, RW), RX, 264);
ctx.font = '16px system-ui, sans-serif';
ctx.fillText(fitText(ctx, `📍 ${e.location}`, TEXT_W), TEXT_X, 222);
}
}
ctx.fillStyle = p.muted;
ctx.font = '17px system-ui, sans-serif';
ctx.fillText('ADMITS', RX, 320);
ctx.font = '15px system-ui, sans-serif';
ctx.fillText('ADMITS', TEXT_X, 274);
ctx.fillStyle = p.text;
ctx.font = 'bold 32px system-ui, sans-serif';
ctx.fillText(fitText(ctx, e?.holder ?? data.name, RW), RX, 356);
await drawVerifyStub(ctx, CARD_W - PAD - 156, CARD_H - PAD - 156 - 8, 156, data.verifyUrl, p);
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);
}
// ─── back ─────────────────────────────────────────────────────────────────────
function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
const lines: Array<[string, string]> = [];
async function drawBack(ctx: CanvasRenderingContext2D, data: CanvasCardData, p: typeof THEME_PALETTE[string]) {
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 (roles) lines.push(['LAB ROLES', roles]);
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.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)) {
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);
ctx.fillStyle = p.text;
ctx.font = 'bold 26px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, CARD_W - PAD * 2 - 220), PAD, y + 40);
y += 92;
ctx.font = 'bold 24px system-ui, sans-serif';
ctx.fillText(fitText(ctx, value, STUB_X - PAD - 24), PAD, y + 36);
y += 74;
}
if (lines.length === 0) {
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);
}
// Verify stub on the right
roundRect(ctx, CARD_W - PAD - 150, CARD_H - PAD - 150, 150, 150, 16);
ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.stroke();
// 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';
ctx.textAlign = 'center';
wordmark(ctx, 'skate-spots.org', CARD_W - PAD - 75, CARD_H - PAD + 22, 2);
wordmark(ctx, 'skate-spots.org', STUB_X + STUB_W / 2, 300, 2);
ctx.textAlign = 'left';
}
@ -422,11 +409,10 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fill();
ctx.fillStyle = p.accent;
ctx.fillRect(0, 0, CARD_W, 8);
drawBack(ctx, data, p);
await drawBack(ctx, data, p);
return canvas.toDataURL('image/png');
}
// Background
const bg = ctx.createLinearGradient(0, 0, CARD_W, CARD_H);
bg.addColorStop(0, p.bg1);
bg.addColorStop(0.55, p.bg2);
@ -435,7 +421,6 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fillStyle = bg;
ctx.fill();
// Portrait panel (full height left)
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();
@ -447,22 +432,20 @@ export async function drawCardToCanvas(data: CanvasCardData, side: 'front' | 'ba
ctx.fillStyle = p.sidebarBg;
ctx.fillRect(0, 0, PORTRAIT_W, CARD_H);
ctx.fillStyle = p.accent;
ctx.font = '96px system-ui, sans-serif';
ctx.font = '84px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 30);
ctx.fillText('🛹', PORTRAIT_W / 2, CARD_H / 2 + 26);
ctx.textAlign = 'left';
}
// darken the portrait's lower band so crew circles read
const shade = ctx.createLinearGradient(0, CARD_H - 200, 0, CARD_H);
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.78)');
shade.addColorStop(1, 'rgba(0,0,0,0.72)');
ctx.fillStyle = shade;
ctx.fillRect(0, CARD_H - 200, PORTRAIT_W, 200);
ctx.fillRect(0, CARD_H - 180, PORTRAIT_W, 180);
ctx.restore();
// separator
ctx.strokeStyle = p.accent;
ctx.globalAlpha = 0.3;
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;
@ -474,14 +457,12 @@ 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 bottom accent bar
const bar = ctx.createLinearGradient(0, 0, CARD_W, 0);
bar.addColorStop(0, p.stripe1);
bar.addColorStop(1, p.stripe2);
ctx.fillStyle = bar;
ctx.fillRect(0, CARD_H - 10, CARD_W, 10);
// Holographic sheen (optional) — a soft diagonal highlight.
if (data.holographic) {
ctx.save();
roundRect(ctx, 0, 0, CARD_W, CARD_H, 28);