From 3b542caade1a121c37d97bd5cf1550d33e842a75 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 12:03:07 +0700 Subject: [PATCH] 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. --- src/lib/riderCardCanvas.ts | 315 +++++++++++++++++-------------------- 1 file changed, 148 insertions(+), 167 deletions(-) diff --git a/src/lib/riderCardCanvas.ts b/src/lib/riderCardCanvas.ts index e055f5b..ccab7e8 100644 --- a/src/lib/riderCardCanvas.ts +++ b/src/lib/riderCardCanvas.ts @@ -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 { 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);