this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #2 from nove-b/copilot/introduce-color-mode

feat: system-aware dark/light color mode with logo switching

authored by

nove-b and committed by
GitHub
ecc4f31f e2166bc4

+106 -16
+18 -1
api/graph.ts
··· 4 4 buildGraphSvg, 5 5 clampRange, 6 6 fetchActivityByDay, 7 + fetchLogoDataUri, 7 8 normalizeAccount, 8 9 } from '../src/tangledGraph'; 10 + 11 + const LOGO_URL_LIGHT = 'https://assets.tangled.network/tangled_logotype_black_on_trans.svg'; 12 + const LOGO_URL_DARK = 'https://assets.tangled.network/tangled_logotype_white_on_trans.svg'; 9 13 10 14 export default async function handler( 11 15 req: VercelRequest, ··· 36 40 return; 37 41 } 38 42 43 + const [logoLightDataUri, logoDarkDataUri] = await Promise.all([ 44 + fetchLogoDataUri(LOGO_URL_LIGHT), 45 + fetchLogoDataUri(LOGO_URL_DARK), 46 + ]); 47 + 39 48 try { 40 49 const days = await fetchActivityByDay(account, range); 41 - res.status(200).send(buildGraphSvg({ account, range, days })); 50 + res.status(200).send(buildGraphSvg({ 51 + account, 52 + range, 53 + days, 54 + logoLightDataUri, 55 + logoDarkDataUri, 56 + })); 42 57 } catch (error) { 43 58 const message = error instanceof Error ? error.message : 'Failed to load feed'; 44 59 res.status(502).send( ··· 47 62 range, 48 63 days: [], 49 64 error: message, 65 + logoLightDataUri, 66 + logoDarkDataUri, 50 67 }), 51 68 ); 52 69 }
+88 -15
src/tangledGraph.ts
··· 17 17 range: number; 18 18 days: HeatmapDay[]; 19 19 error?: string; 20 + logoLightDataUri?: string; 21 + logoDarkDataUri?: string; 20 22 }; 21 23 22 24 type ParsedFeed = { ··· 38 40 const HEADER_TITLE_Y = 53; 39 41 const LOGO_WIDTH = 140; 40 42 const LOGO_HEIGHT = 24; 41 - const LOGO_URL = 'https://assets.tangled.network/tangled_logotype_black_on_trans.svg'; 43 + const LOGO_URL_LIGHT = 'https://assets.tangled.network/tangled_logotype_black_on_trans.svg'; 44 + const LOGO_URL_DARK = 'https://assets.tangled.network/tangled_logotype_white_on_trans.svg'; 42 45 const MIN_CANVAS_WIDTH = 700; 43 46 const HEADER_CHAR_WIDTH = 8.2; 44 47 const SUMMARY_CHAR_WIDTH = 6.2; ··· 48 51 { label: 'Fri', row: 5 }, 49 52 ]; 50 53 const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 51 - const HEATMAP_COLORS = ['#ebedf0', '#d1d5db', '#9ca3af', '#4b5563', '#111827'] as const; 52 54 53 55 export function normalizeAccount(input: string | undefined): string | null { 54 56 if (!input) { ··· 103 105 return buildDays(counts, rangeStart, calendarStart, now); 104 106 } 105 107 106 - export function buildGraphSvg({ account, range, days, error }: GraphSvgOptions): string { 108 + export async function fetchLogoDataUri(url: string): Promise<string | undefined> { 109 + try { 110 + const response = await fetch(url, { 111 + headers: { 'User-Agent': 'tangled-activity-graph' }, 112 + }); 113 + if (!response.ok) { 114 + return undefined; 115 + } 116 + const contentType = response.headers.get('content-type') ?? 'image/svg+xml'; 117 + const buffer = await response.arrayBuffer(); 118 + const base64 = Buffer.from(buffer).toString('base64'); 119 + return `data:${contentType};base64,${base64}`; 120 + } catch { 121 + return undefined; 122 + } 123 + } 124 + 125 + export function buildGraphSvg({ account, range, days, error, logoLightDataUri, logoDarkDataUri }: GraphSvgOptions): string { 107 126 const weekCount = days.length === 0 ? 1 : Math.max(...days.map((day) => day.weekIndex)) + 1; 108 127 const graphWidth = weekCount * (CELL_SIZE + CELL_GAP) - CELL_GAP; 109 128 const graphHeight = 7 * (CELL_SIZE + CELL_GAP) - CELL_GAP; ··· 127 146 .map((day) => { 128 147 const x = LEFT_GUTTER + day.weekIndex * (CELL_SIZE + CELL_GAP); 129 148 const y = graphStartY + day.weekdayIndex * (CELL_SIZE + CELL_GAP); 130 - const fill = day.inRange ? HEATMAP_COLORS[day.level] : '#f8fafc'; 131 - const stroke = day.inRange ? 'none' : '#e5e7eb'; 149 + const cellClass = day.inRange ? `heat-${day.level}` : 'oor'; 132 150 const tooltip = `${day.isoDate}: ${day.count} activit${day.count === 1 ? 'y' : 'ies'}`; 133 151 134 - return `<g><title>${escapeXml(tooltip)}</title><rect x="${x}" y="${y}" width="${CELL_SIZE}" height="${CELL_SIZE}" rx="2" fill="${fill}" stroke="${stroke}" /></g>`; 152 + return `<g><title>${escapeXml(tooltip)}</title><rect x="${x}" y="${y}" width="${CELL_SIZE}" height="${CELL_SIZE}" rx="2" class="${cellClass}" /></g>`; 135 153 }) 136 154 .join(''); 137 155 138 156 const weekdays = WEEKDAY_LABELS.map(({ label, row }) => { 139 157 const y = graphStartY + row * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1; 140 - return `<text x="8" y="${y}" font-size="10" fill="#6b7280">${label}</text>`; 158 + return `<text x="8" y="${y}" font-size="10" class="label">${label}</text>`; 141 159 }).join(''); 142 160 143 161 const months = monthLabels 144 162 .map(({ label, weekIndex }) => { 145 163 const x = LEFT_GUTTER + weekIndex * (CELL_SIZE + CELL_GAP); 146 - return `<text x="${x}" y="${graphStartY - MONTH_LABEL_OFFSET}" font-size="10" fill="#6b7280">${label}</text>`; 164 + return `<text x="${x}" y="${graphStartY - MONTH_LABEL_OFFSET}" font-size="10" class="label">${label}</text>`; 147 165 }) 148 166 .join(''); 149 167 150 168 const summary = error 151 - ? `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" fill="#b91c1c">${escapeXml(error)}</text>` 152 - : `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" fill="#4b5563">${escapeXml(summaryText)}</text>`; 169 + ? `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" class="error">${escapeXml(error)}</text>` 170 + : `<text x="${LEFT_GUTTER}" y="${height - 10}" font-size="11" class="summary">${escapeXml(summaryText)}</text>`; 153 171 const accountUrl = `https://tangled.org/@${encodeURIComponent(account)}`; 154 172 155 173 return `<?xml version="1.0" encoding="UTF-8"?> 156 174 <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet" style="max-width:100%;height:auto" role="img" aria-labelledby="title desc"> 157 175 <title id="title">${escapeXml(headerText)}</title> 158 176 <desc id="desc">Calendar heatmap of Tangled activities for ${escapeXml(account)}</desc> 177 + <style> 178 + :root { 179 + --bg: #ffffff; 180 + --border: #e5e7eb; 181 + --title: #111827; 182 + --label: #6b7280; 183 + --summary: #4b5563; 184 + --error: #b91c1c; 185 + --heat-0: #ebedf0; 186 + --heat-1: #d1d5db; 187 + --heat-2: #9ca3af; 188 + --heat-3: #4b5563; 189 + --heat-4: #111827; 190 + --oor-fill: #f8fafc; 191 + --oor-stroke: #e5e7eb; 192 + } 193 + @media (prefers-color-scheme: dark) { 194 + :root { 195 + --bg: #161b22; 196 + --border: #30363d; 197 + --title: #e6edf3; 198 + --label: #8b949e; 199 + --summary: #8b949e; 200 + --error: #f85149; 201 + --heat-0: #21262d; 202 + --heat-1: #374151; 203 + --heat-2: #6b7280; 204 + --heat-3: #9ca3af; 205 + --heat-4: #e6edf3; 206 + --oor-fill: #0d1117; 207 + --oor-stroke: #21262d; 208 + } 209 + } 210 + .bg { fill: var(--bg); stroke: var(--border); } 211 + .title { fill: var(--title); } 212 + .label { fill: var(--label); } 213 + .summary { fill: var(--summary); } 214 + .error { fill: var(--error); } 215 + .heat-0 { fill: var(--heat-0); } 216 + .heat-1 { fill: var(--heat-1); } 217 + .heat-2 { fill: var(--heat-2); } 218 + .heat-3 { fill: var(--heat-3); } 219 + .heat-4 { fill: var(--heat-4); } 220 + .oor { fill: var(--oor-fill); stroke: var(--oor-stroke); } 221 + .logo-dark { display: none; } 222 + @media (prefers-color-scheme: dark) { 223 + .logo-light { display: none; } 224 + .logo-dark { display: block; } 225 + } 226 + </style> 159 227 <a href="${accountUrl}" target="_blank"> 160 - <rect width="100%" height="100%" rx="${CARD_RADIUS}" fill="#ffffff" stroke="#e5e7eb" /> 161 - <text x="${HEADER_TEXT_X}" y="${HEADER_TITLE_Y}" font-size="15" font-weight="600" fill="#111827">${escapeXml(headerText)}</text> 228 + <rect width="100%" height="100%" rx="${CARD_RADIUS}" class="bg" /> 229 + <text x="${HEADER_TEXT_X}" y="${HEADER_TITLE_Y}" font-size="15" font-weight="600" class="title">${escapeXml(headerText)}</text> 162 230 163 231 ${months} 164 232 ${weekdays} 165 233 ${rects} 166 234 ${summary} 167 - ${renderLogo(width, height)} 235 + ${renderLogo(width, height, logoLightDataUri, logoDarkDataUri)} 168 236 </a> 169 237 </svg>`; 170 238 } ··· 277 345 return 4; 278 346 } 279 347 280 - function renderLogo(width: number, height: number): string { 348 + function renderLogo(width: number, height: number, logoLightDataUri?: string, logoDarkDataUri?: string): string { 281 349 const x = width - RIGHT_GUTTER - LOGO_WIDTH; 282 350 const y = height - FOOTER_HEIGHT + 1; 351 + const lightSrc = logoLightDataUri ?? LOGO_URL_LIGHT; 352 + const darkSrc = logoDarkDataUri ?? LOGO_URL_DARK; 283 353 284 - return `<image x="${x}" y="${y}" width="${LOGO_WIDTH}" height="${LOGO_HEIGHT}" href="${LOGO_URL}" />`; 354 + return ( 355 + `<image class="logo-light" x="${x}" y="${y}" width="${LOGO_WIDTH}" height="${LOGO_HEIGHT}" href="${lightSrc}" />` + 356 + `<image class="logo-dark" x="${x}" y="${y}" width="${LOGO_WIDTH}" height="${LOGO_HEIGHT}" href="${darkSrc}" />` 357 + ); 285 358 } 286 359 287 360 function subtractUtcMonths(date: Date, months: number): Date {