Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Share-card SVG renderer.
|
||||
*
|
||||
* Five templates: standard (grade), victory ("I told you"), recap (multi),
|
||||
* cheatsheet (grid), gotd (Grade of the Day).
|
||||
*
|
||||
* Three sizes:
|
||||
* twitter — 1200x675 (X cards, OG)
|
||||
* story — 1080x1920 (IG Story, TikTok)
|
||||
* square — 1080x1080 (IG feed, Discord embed)
|
||||
*
|
||||
* The renderer composes a single SVG string (no DOM, no user-controlled
|
||||
* markup — every dynamic value passes through escapeXml first) and pipes
|
||||
* it through sharp for PNG. If sharp's native binding is unavailable in
|
||||
* a degraded environment, the route returns the raw SVG with
|
||||
* Content-Type: image/svg+xml.
|
||||
*
|
||||
* SECURITY: all caller-supplied strings are XML-escaped. No <foreignObject>,
|
||||
* no <script>, no <use href> external refs. Templates use only plain SVG
|
||||
* primitives (rect, text, path, circle, line, g, defs, gradient, filter).
|
||||
*/
|
||||
|
||||
const { COLORS, FONTS, SPORT_LABEL_COLOR, gradeColor } = require('./tokens');
|
||||
|
||||
const SIZES = Object.freeze({
|
||||
twitter: { w: 1200, h: 675 },
|
||||
story: { w: 1080, h: 1920 },
|
||||
square: { w: 1080, h: 1080 },
|
||||
});
|
||||
|
||||
const VALID_TYPES = new Set(['grade', 'victory', 'recap', 'cheatsheet', 'gotd']);
|
||||
|
||||
// ── tiny helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function escapeXml(input) {
|
||||
if (input == null) return '';
|
||||
return String(input)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function clampLen(s, max) {
|
||||
if (s == null) return '';
|
||||
const str = String(s);
|
||||
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
||||
}
|
||||
|
||||
function gradeGlowFilter(id, color) {
|
||||
return `
|
||||
<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="6" result="b1"/>
|
||||
<feFlood flood-color="${color}" flood-opacity="0.75"/>
|
||||
<feComposite in2="b1" operator="in" result="glow"/>
|
||||
<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>`;
|
||||
}
|
||||
|
||||
function scanLinesPattern(id) {
|
||||
// Subtle CRT scan lines, drawn at 4% opacity, repeating every 4px.
|
||||
return `
|
||||
<pattern id="${id}" width="4" height="4" patternUnits="userSpaceOnUse">
|
||||
<line x1="0" y1="0" x2="2000" y2="0" stroke="rgba(255,255,255,0.04)" stroke-width="1"/>
|
||||
</pattern>`;
|
||||
}
|
||||
|
||||
function diagonalSlash() {
|
||||
// The signature -2deg diagonal cut, drawn faintly.
|
||||
return `
|
||||
<linearGradient id="slash" x1="0" y1="0" x2="1" y2="0.07">
|
||||
<stop offset="0" stop-color="rgba(0,212,160,0)"/>
|
||||
<stop offset="0.5" stop-color="rgba(0,212,160,0.08)"/>
|
||||
<stop offset="1" stop-color="rgba(0,212,160,0)"/>
|
||||
</linearGradient>`;
|
||||
}
|
||||
|
||||
function commonDefs(width, height) {
|
||||
return `<defs>
|
||||
${diagonalSlash()}
|
||||
${scanLinesPattern('scan')}
|
||||
${gradeGlowFilter('glow-aplus', COLORS.gradeAplus)}
|
||||
${gradeGlowFilter('glow-a', COLORS.gradeA)}
|
||||
${gradeGlowFilter('glow-b', COLORS.gradeB)}
|
||||
${gradeGlowFilter('glow-c', COLORS.gradeC)}
|
||||
${gradeGlowFilter('glow-d', COLORS.gradeD)}
|
||||
<linearGradient id="bgFade" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="${COLORS.bg1}"/>
|
||||
<stop offset="1" stop-color="${COLORS.bg0}"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.35" r="0.6">
|
||||
<stop offset="0" stop-color="rgba(0,212,160,0.16)"/>
|
||||
<stop offset="1" stop-color="rgba(0,212,160,0)"/>
|
||||
</radialGradient>
|
||||
</defs>`;
|
||||
}
|
||||
|
||||
function baseBackground(width, height) {
|
||||
// Background stack: gradient + radial glow + scan lines + slash band.
|
||||
return `
|
||||
<rect width="${width}" height="${height}" fill="url(#bgFade)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#glow)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#scan)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#slash)"/>`;
|
||||
}
|
||||
|
||||
function wordmark(x, y, fontSize) {
|
||||
// VYNDR with green R + drop-shadow on the R. No external font load —
|
||||
// SVG falls back through the font-family stack at rasterize time.
|
||||
const rOffset = fontSize * 0.62; // approx width of "VYND" at this size
|
||||
return `
|
||||
<g font-family="${FONTS.mono}" font-weight="800" letter-spacing="${fontSize * 0.10}">
|
||||
<text x="${x}" y="${y}" font-size="${fontSize}" fill="${COLORS.text0}">VYND</text>
|
||||
<text x="${x + rOffset * 3.4}" y="${y}" font-size="${fontSize}" fill="${COLORS.gradeA}"
|
||||
style="filter:drop-shadow(0 0 ${fontSize * 0.25}px rgba(0,212,160,0.7))">R</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function watermarkFooter(width, height, extra = '') {
|
||||
return `
|
||||
<text x="${width / 2}" y="${height - 32}" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-size="14" letter-spacing="5" fill="${COLORS.text2}">
|
||||
VYNDR.XYZ · @GETVYNDR${extra ? ' · ' + escapeXml(extra) : ''}
|
||||
</text>`;
|
||||
}
|
||||
|
||||
function sportPill(x, y, sport) {
|
||||
const key = String(sport || '').toLowerCase();
|
||||
const color = SPORT_LABEL_COLOR[key] || COLORS.text1;
|
||||
const label = (key || 'sport').toUpperCase();
|
||||
// Simple pill: rounded rect + centered text.
|
||||
const w = 90;
|
||||
const h = 28;
|
||||
return `
|
||||
<g>
|
||||
<rect x="${x}" y="${y}" rx="14" ry="14" width="${w}" height="${h}"
|
||||
fill="${color}" fill-opacity="0.16" stroke="${color}" stroke-opacity="0.4"/>
|
||||
<text x="${x + w / 2}" y="${y + h / 2 + 5}" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-size="13" font-weight="700"
|
||||
letter-spacing="2" fill="${color}">${escapeXml(label)}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function gradeBadge(x, y, grade, scale = 1) {
|
||||
const safeGrade = clampLen(grade, 4) || '—';
|
||||
const color = gradeColor(grade);
|
||||
const filterId = ({
|
||||
'A+': 'glow-aplus', 'A': 'glow-a', 'A-': 'glow-a',
|
||||
'B+': 'glow-b', 'B': 'glow-b', 'B-': 'glow-b',
|
||||
'C+': 'glow-c', 'C': 'glow-c', 'C-': 'glow-c',
|
||||
'D': 'glow-d', 'F': 'glow-d',
|
||||
})[safeGrade] || 'glow-b';
|
||||
const size = 200 * scale;
|
||||
return `
|
||||
<text x="${x}" y="${y}" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-weight="800" font-size="${size}"
|
||||
letter-spacing="-4" fill="${color}" filter="url(#${filterId})"
|
||||
style="filter:drop-shadow(0 0 ${24 * scale}px ${color})">${escapeXml(safeGrade)}</text>`;
|
||||
}
|
||||
|
||||
// ── templates ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderStandardGrade({ width, height, player, sport, stat, line, direction, grade, projection, summary }) {
|
||||
const titleY = height < 700 ? 200 : 280;
|
||||
const gradeY = height < 700 ? 460 : 660;
|
||||
return `
|
||||
${baseBackground(width, height)}
|
||||
${wordmark(60, 80, 44)}
|
||||
${sportPill(width - 150, 56, sport)}
|
||||
|
||||
<text x="60" y="${titleY}" font-family="${FONTS.sans}" font-size="${width < 1100 ? 56 : 64}"
|
||||
font-weight="700" letter-spacing="-2" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(player, 28))}
|
||||
</text>
|
||||
<text x="60" y="${titleY + 60}" font-family="${FONTS.mono}" font-size="32"
|
||||
font-weight="500" fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(stat, 14))} ${escapeXml(String(direction || 'over').toUpperCase())} ${escapeXml(String(line ?? '—'))}
|
||||
</text>
|
||||
|
||||
${gradeBadge(width / 2, gradeY, grade, height < 700 ? 0.9 : 1.1)}
|
||||
|
||||
${projection != null ? `
|
||||
<text x="60" y="${gradeY + 80}" font-family="${FONTS.mono}" font-size="22"
|
||||
font-weight="700" fill="${COLORS.text1}" letter-spacing="3">
|
||||
PROJECTION ${escapeXml(String(projection))}
|
||||
</text>` : ''}
|
||||
${summary ? `
|
||||
<text x="60" y="${gradeY + 120}" font-family="${FONTS.sans}" font-size="20"
|
||||
fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(summary, 80))}
|
||||
</text>` : ''}
|
||||
|
||||
${watermarkFooter(width, height)}`;
|
||||
}
|
||||
|
||||
function renderVictoryCard(opts) {
|
||||
const { width, height, result_actual, grade, line } = opts;
|
||||
// Standard grade card body + a result banner across the top.
|
||||
const safeActual = escapeXml(clampLen(result_actual || 'HIT', 32));
|
||||
return `
|
||||
${baseBackground(width, height)}
|
||||
<rect x="0" y="0" width="${width}" height="80" fill="${COLORS.gradeA}" fill-opacity="0.18"/>
|
||||
<text x="${width / 2}" y="52" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-weight="800" font-size="28"
|
||||
letter-spacing="6" fill="${COLORS.gradeA}"
|
||||
style="filter:drop-shadow(0 0 8px rgba(0,212,160,0.7))">
|
||||
✓ HIT — ${safeActual}
|
||||
</text>
|
||||
|
||||
${wordmark(60, 140, 40)}
|
||||
${sportPill(width - 150, 110, opts.sport)}
|
||||
|
||||
<text x="60" y="${height < 700 ? 260 : 340}" font-family="${FONTS.sans}" font-size="${width < 1100 ? 56 : 64}"
|
||||
font-weight="700" letter-spacing="-2" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(opts.player, 28))}
|
||||
</text>
|
||||
<text x="60" y="${(height < 700 ? 260 : 340) + 60}" font-family="${FONTS.mono}" font-size="32"
|
||||
font-weight="500" fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(opts.stat, 14))} ${escapeXml(String(opts.direction || 'over').toUpperCase())} ${escapeXml(String(line ?? '—'))}
|
||||
</text>
|
||||
|
||||
${gradeBadge(width / 2, height - (height < 700 ? 180 : 260), grade, height < 700 ? 0.85 : 1.0)}
|
||||
|
||||
<text x="${width / 2}" y="${height - 70}" text-anchor="middle"
|
||||
font-family="${FONTS.sans}" font-size="22" font-weight="600" fill="${COLORS.text1}">
|
||||
VYNDR called it.
|
||||
</text>
|
||||
${watermarkFooter(width, height)}`;
|
||||
}
|
||||
|
||||
function renderRecap({ width, height, date, entries = [], accuracy }) {
|
||||
const safeEntries = entries.slice(0, 6);
|
||||
const rowH = 64;
|
||||
const startY = 240;
|
||||
const rows = safeEntries.map((e, i) => {
|
||||
const y = startY + i * rowH;
|
||||
const ok = e.result === 'hit';
|
||||
const tint = ok ? 'rgba(0,212,160,0.10)' : 'rgba(255,82,82,0.10)';
|
||||
const mark = ok ? '✓' : '✗';
|
||||
const markColor = ok ? COLORS.gradeA : COLORS.gradeD;
|
||||
return `
|
||||
<rect x="40" y="${y - 36}" width="${width - 80}" height="${rowH - 8}" rx="10" fill="${tint}"/>
|
||||
<text x="64" y="${y}" font-family="${FONTS.mono}" font-size="28" font-weight="800" fill="${markColor}">${mark}</text>
|
||||
<text x="100" y="${y}" font-family="${FONTS.sans}" font-size="24" font-weight="700" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(e.player, 22))}
|
||||
</text>
|
||||
<text x="${width / 2}" y="${y}" font-family="${FONTS.mono}" font-size="22" fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(`${e.stat || ''} ${String(e.direction || '').toUpperCase()} ${e.line ?? ''}`, 28))}
|
||||
</text>
|
||||
<text x="${width - 220}" y="${y}" font-family="${FONTS.mono}" font-size="22" font-weight="800"
|
||||
fill="${gradeColor(e.grade)}">${escapeXml(clampLen(e.grade, 4))}</text>
|
||||
<text x="${width - 64}" y="${y}" text-anchor="end" font-family="${FONTS.mono}" font-size="22"
|
||||
font-weight="700" fill="${ok ? COLORS.gradeA : COLORS.gradeD}">
|
||||
${ok ? 'HIT' : 'MISS'}
|
||||
</text>`;
|
||||
}).join('');
|
||||
const hits = safeEntries.filter((e) => e.result === 'hit').length;
|
||||
const acc = accuracy != null ? accuracy : (safeEntries.length ? Math.round((hits / safeEntries.length) * 100) : 0);
|
||||
return `
|
||||
${baseBackground(width, height)}
|
||||
${wordmark(60, 80, 36)}
|
||||
<text x="60" y="140" font-family="${FONTS.mono}" font-size="22" font-weight="700"
|
||||
letter-spacing="4" fill="${COLORS.text2}">LAST NIGHT'S RESULTS</text>
|
||||
<text x="60" y="180" font-family="${FONTS.sans}" font-size="28" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(date || '', 32))}
|
||||
</text>
|
||||
${rows}
|
||||
<text x="${width / 2}" y="${height - 90}" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-size="36" font-weight="800"
|
||||
fill="${COLORS.gradeA}" letter-spacing="4"
|
||||
style="filter:drop-shadow(0 0 10px rgba(0,212,160,0.6))">
|
||||
ACCURACY ${acc}% (${hits}/${safeEntries.length})
|
||||
</text>
|
||||
${watermarkFooter(width, height)}`;
|
||||
}
|
||||
|
||||
function renderCheatsheet({ width, height, date, gameCount, grades = [] }) {
|
||||
const safe = grades.slice(0, 8);
|
||||
const rowH = 60;
|
||||
const startY = 240;
|
||||
const rows = safe.map((g, i) => {
|
||||
const y = startY + i * rowH;
|
||||
return `
|
||||
<text x="60" y="${y}" font-family="${FONTS.mono}" font-size="32" font-weight="800"
|
||||
fill="${gradeColor(g.grade)}"
|
||||
style="filter:drop-shadow(0 0 6px ${gradeColor(g.grade)})">${escapeXml(clampLen(g.grade, 4))}</text>
|
||||
<text x="160" y="${y}" font-family="${FONTS.sans}" font-size="24" font-weight="600" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(g.player, 22))}
|
||||
</text>
|
||||
<text x="${width - 80}" y="${y}" text-anchor="end" font-family="${FONTS.mono}" font-size="22"
|
||||
fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(`${g.stat || ''} ${String(g.direction || '').toUpperCase()} ${g.line ?? ''}`, 28))}
|
||||
</text>`;
|
||||
}).join('');
|
||||
return `
|
||||
${baseBackground(width, height)}
|
||||
${wordmark(60, 80, 36)}
|
||||
<text x="60" y="140" font-family="${FONTS.mono}" font-size="22" font-weight="700"
|
||||
letter-spacing="4" fill="${COLORS.gradeA}">TONIGHT'S CHEATSHEET</text>
|
||||
<text x="60" y="180" font-family="${FONTS.sans}" font-size="22" fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(date || '', 24))} · ${escapeXml(String(gameCount ?? 0))} games
|
||||
</text>
|
||||
${rows}
|
||||
${watermarkFooter(width, height)}`;
|
||||
}
|
||||
|
||||
function renderGOTD(opts) {
|
||||
// Same shape as standard grade but with the "GRADE OF THE DAY" slug.
|
||||
const { width, height } = opts;
|
||||
return `
|
||||
${baseBackground(width, height)}
|
||||
<rect x="0" y="0" width="${width}" height="64" fill="${COLORS.gradeA}" fill-opacity="0.14"/>
|
||||
<text x="${width / 2}" y="42" text-anchor="middle"
|
||||
font-family="${FONTS.mono}" font-weight="800" font-size="22"
|
||||
letter-spacing="8" fill="${COLORS.gradeA}">◆ GRADE OF THE DAY</text>
|
||||
${wordmark(60, 130, 40)}
|
||||
${sportPill(width - 150, 100, opts.sport)}
|
||||
|
||||
<text x="60" y="${height < 700 ? 250 : 320}" font-family="${FONTS.sans}" font-size="${width < 1100 ? 56 : 64}"
|
||||
font-weight="700" letter-spacing="-2" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(opts.player, 28))}
|
||||
</text>
|
||||
<text x="60" y="${(height < 700 ? 250 : 320) + 56}" font-family="${FONTS.mono}" font-size="30"
|
||||
fill="${COLORS.text1}">
|
||||
${escapeXml(clampLen(opts.stat, 14))} ${escapeXml(String(opts.direction || 'over').toUpperCase())} ${escapeXml(String(opts.line ?? '—'))}
|
||||
</text>
|
||||
|
||||
${gradeBadge(width / 2, height - (height < 700 ? 180 : 260), opts.grade, height < 700 ? 0.95 : 1.15)}
|
||||
|
||||
${opts.summary ? `
|
||||
<text x="${width / 2}" y="${height - 110}" text-anchor="middle"
|
||||
font-family="${FONTS.sans}" font-size="22" fill="${COLORS.text0}">
|
||||
${escapeXml(clampLen(opts.summary, 96))}
|
||||
</text>` : ''}
|
||||
${watermarkFooter(width, height)}`;
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildSvg(type, format, payload) {
|
||||
if (!VALID_TYPES.has(type)) throw new Error(`unknown card type: ${type}`);
|
||||
const sz = SIZES[format] || SIZES.twitter;
|
||||
const body = (() => {
|
||||
switch (type) {
|
||||
case 'grade': return renderStandardGrade({ ...payload, width: sz.w, height: sz.h });
|
||||
case 'victory': return renderVictoryCard({ ...payload, width: sz.w, height: sz.h });
|
||||
case 'recap': return renderRecap({ ...payload, width: sz.w, height: sz.h });
|
||||
case 'cheatsheet': return renderCheatsheet({ ...payload, width: sz.w, height: sz.h });
|
||||
case 'gotd': return renderGOTD({ ...payload, width: sz.w, height: sz.h });
|
||||
default: throw new Error(`unhandled card type: ${type}`);
|
||||
}
|
||||
})();
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sz.w} ${sz.h}" width="${sz.w}" height="${sz.h}">
|
||||
${commonDefs(sz.w, sz.h)}
|
||||
${body}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
async function rasterize(svg) {
|
||||
// Lazy require so a missing sharp binding doesn't sink the whole module.
|
||||
let sharp;
|
||||
try { sharp = require('sharp'); }
|
||||
catch (err) {
|
||||
const e = new Error('sharp unavailable');
|
||||
e.code = 'SHARP_UNAVAILABLE';
|
||||
throw e;
|
||||
}
|
||||
return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SIZES,
|
||||
VALID_TYPES,
|
||||
buildSvg,
|
||||
rasterize,
|
||||
// exported for tests
|
||||
escapeXml,
|
||||
clampLen,
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared design tokens for SVG card rendering.
|
||||
* Mirror of the values in web/src/app/globals.css so social-card output
|
||||
* matches the in-app brand exactly.
|
||||
*/
|
||||
|
||||
const COLORS = Object.freeze({
|
||||
bg0: '#06060B',
|
||||
bg1: '#0E0E16',
|
||||
bg2: '#15151F',
|
||||
bg3: '#1A1A26',
|
||||
text0: '#E8E8F0',
|
||||
text1: '#7A7A8E',
|
||||
text2: '#4A4A5E',
|
||||
border: '#1E1E2E',
|
||||
borderHi: '#2A2A3E',
|
||||
acc0: '#0F3D2E',
|
||||
acc1: '#1A5A42',
|
||||
gradeAplus: '#00FFB8',
|
||||
gradeA: '#00D4A0',
|
||||
gradeB: '#4A9EFF',
|
||||
gradeC: '#FFB347',
|
||||
gradeD: '#FF5252',
|
||||
crimson: '#8B0000',
|
||||
nba: '#E94B3C',
|
||||
mlb: '#1E90FF',
|
||||
wnba: '#F7944A',
|
||||
nfl: '#013369',
|
||||
nhl: '#A0A0B0',
|
||||
tennis: '#C5B358',
|
||||
mma: '#D4AF37',
|
||||
boxing: '#8B0000',
|
||||
golf: '#2E7D32',
|
||||
});
|
||||
|
||||
const FONTS = Object.freeze({
|
||||
mono: "'IBM Plex Mono','JetBrains Mono','SF Mono',ui-monospace,monospace",
|
||||
sans: "'Instrument Sans','Helvetica Neue',-apple-system,sans-serif",
|
||||
});
|
||||
|
||||
const SPORT_LABEL_COLOR = Object.freeze({
|
||||
nba: COLORS.nba,
|
||||
wnba: COLORS.wnba,
|
||||
mlb: COLORS.mlb,
|
||||
nfl: COLORS.nfl,
|
||||
nhl: COLORS.nhl,
|
||||
tennis: COLORS.tennis,
|
||||
mma: COLORS.mma,
|
||||
boxing: COLORS.boxing,
|
||||
golf: COLORS.golf,
|
||||
});
|
||||
|
||||
function gradeColor(grade) {
|
||||
if (!grade) return COLORS.text1;
|
||||
const head = String(grade).trim().toUpperCase();
|
||||
if (head.startsWith('A+')) return COLORS.gradeAplus;
|
||||
if (head.startsWith('A')) return COLORS.gradeA;
|
||||
if (head.startsWith('B')) return COLORS.gradeB;
|
||||
if (head.startsWith('C')) return COLORS.gradeC;
|
||||
if (head.startsWith('D') || head.startsWith('F')) return COLORS.gradeD;
|
||||
return COLORS.text1;
|
||||
}
|
||||
|
||||
module.exports = { COLORS, FONTS, SPORT_LABEL_COLOR, gradeColor };
|
||||
Reference in New Issue
Block a user