Skip to content

Commit 34d00a1

Browse files
committed
feat(thumbnail): add Mac Miller — Swimming in Circles theme
1 parent 8c97049 commit 34d00a1

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

src/pages/apps/github-thumbnail/GithubThumbnailGeneratorPage.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const THEME_OPTIONS = [
107107
{ value: 'tarotCard', label: 'Tarot Card' },
108108
{ value: 'illuminatedManuscript', label: 'Illuminated Manuscript' },
109109
{ value: 'charon', label: 'Charon Editorial' },
110+
{ value: 'macMiller', label: 'Mac Miller — Swimming in Circles' },
110111
];
111112

112113
const PALETTE_BANK = [

src/pages/apps/github-thumbnail/themes.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { constructivist } from './themes/constructivist';
7979
import { tarotCard } from './themes/tarotCard';
8080
import { illuminatedManuscript } from './themes/illuminatedManuscript';
8181
import { charon } from './themes/charon';
82+
import { macMiller } from './themes/macMiller';
8283

8384
export const themeRenderers = {
8485
modern,
@@ -159,4 +160,5 @@ export const themeRenderers = {
159160
tarotCard,
160161
illuminatedManuscript,
161162
charon,
163+
macMiller,
162164
};
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { wrapText } from '../utils';
2+
3+
// Mac Miller — a quiet tribute to the look and feel of Swimming (2018)
4+
// and Circles (2020). Soft sunset gradient (peach to dusty rose to faded
5+
// pool blue), concentric hand-drawn rings echoing the Circles cover, a
6+
// melancholic serif for the title, and warm muted mono crumbs underneath.
7+
// Lowercase by default — the late records didn't shout.
8+
export const macMiller = (ctx, width, height, scale, data) => {
9+
const {
10+
primaryColor,
11+
secondaryColor,
12+
repoOwner,
13+
repoName,
14+
description,
15+
language,
16+
stars,
17+
forks,
18+
supportUrl,
19+
} = data;
20+
21+
// Palette — warm dusk over a still pool. The user's primary becomes the
22+
// ring accent; secondary nudges the sunset.
23+
const ringInk = primaryColor || '#C16E5C';
24+
const sunsetTop = secondaryColor && isLight(secondaryColor)
25+
? secondaryColor
26+
: '#F4C9A8';
27+
const sunsetMid = '#E8A38E';
28+
const sunsetBottom = '#A8B8C4';
29+
const cream = '#F2E4D2';
30+
const ink = '#2B2522';
31+
const inkSoft = '#5C504A';
32+
const inkFaint = '#8A7E76';
33+
34+
const serif = '"Cormorant Garamond", "Iowan Old Style", "EB Garamond", Georgia, serif';
35+
const mono = '"JetBrains Mono", "Geist Mono", ui-monospace, monospace';
36+
37+
// 1. Sunset wash — vertical gradient, peach into dusty rose into pool blue.
38+
const wash = ctx.createLinearGradient(0, 0, 0, height);
39+
wash.addColorStop(0, sunsetTop);
40+
wash.addColorStop(0.55, sunsetMid);
41+
wash.addColorStop(1, sunsetBottom);
42+
ctx.fillStyle = wash;
43+
ctx.fillRect(0, 0, width, height);
44+
45+
// 2. Soft horizon glow — radial bloom roughly where the sun would sit.
46+
const glow = ctx.createRadialGradient(
47+
width * 0.72, height * 0.42, 0,
48+
width * 0.72, height * 0.42, width * 0.55,
49+
);
50+
glow.addColorStop(0, hexA('#FFE8C8', 0.55));
51+
glow.addColorStop(1, hexA('#FFE8C8', 0));
52+
ctx.fillStyle = glow;
53+
ctx.fillRect(0, 0, width, height);
54+
55+
// 3. Grain — faint pastel film grain to take the digital edge off.
56+
ctx.save();
57+
ctx.globalAlpha = 0.06;
58+
const grainSeed = (repoName || 'circles').length * 7 + 13;
59+
for (let i = 0; i < 1400; i++) {
60+
const gx = mulberry(grainSeed + i) * width;
61+
const gy = mulberry(grainSeed + i * 3) * height;
62+
const tone = mulberry(grainSeed + i * 5) > 0.5 ? '#FFFFFF' : '#1A0F0A';
63+
ctx.fillStyle = tone;
64+
ctx.fillRect(gx, gy, 1.2 * scale, 1.2 * scale);
65+
}
66+
ctx.restore();
67+
68+
// 4. Concentric rings — the Circles motif, lower-right, hand-drawn-ish wobble.
69+
const ringCx = width * 0.78;
70+
const ringCy = height * 0.52;
71+
const ringMax = Math.min(width, height) * 0.42;
72+
ctx.save();
73+
ctx.strokeStyle = ringInk;
74+
ctx.lineCap = 'round';
75+
for (let r = ringMax; r > 18 * scale; r -= 22 * scale) {
76+
const wob = (mulberry(Math.floor(r) + grainSeed) - 0.5) * 6 * scale;
77+
ctx.globalAlpha = 0.18 + 0.55 * (1 - r / ringMax);
78+
ctx.lineWidth = (1 + 1.6 * (1 - r / ringMax)) * scale;
79+
ctx.beginPath();
80+
// hand-drawn arc — 360° in 36 segments with slight noise
81+
const seg = 36;
82+
for (let s = 0; s <= seg; s++) {
83+
const ang = (s / seg) * Math.PI * 2;
84+
const noise = (mulberry(Math.floor(r) + s) - 0.5) * 2.4 * scale;
85+
const px = ringCx + Math.cos(ang) * (r + noise) + wob;
86+
const py = ringCy + Math.sin(ang) * (r + noise);
87+
if (s === 0) ctx.moveTo(px, py);
88+
else ctx.lineTo(px, py);
89+
}
90+
ctx.stroke();
91+
}
92+
ctx.restore();
93+
94+
// 5. Tiny filled centre dot — the still point.
95+
ctx.fillStyle = ringInk;
96+
ctx.beginPath();
97+
ctx.arc(ringCx, ringCy, 5 * scale, 0, Math.PI * 2);
98+
ctx.fill();
99+
100+
// 6. Lap lane — a single soft horizontal line near the bottom, swimming-pool
101+
// nod. Pale, feathered.
102+
ctx.save();
103+
ctx.strokeStyle = hexA('#FFFFFF', 0.35);
104+
ctx.lineWidth = 2 * scale;
105+
ctx.setLineDash([14 * scale, 18 * scale]);
106+
ctx.beginPath();
107+
ctx.moveTo(0, height * 0.86);
108+
ctx.lineTo(width, height * 0.86);
109+
ctx.stroke();
110+
ctx.restore();
111+
112+
// 7. Type stack — left column. Lowercase by default; this isn't loud music.
113+
const padX = 64 * scale;
114+
115+
// Tiny mono crumb at top
116+
ctx.fillStyle = inkFaint;
117+
ctx.font = `500 ${15 * scale}px ${mono}`;
118+
ctx.textAlign = 'left';
119+
letterSpaced(
120+
ctx,
121+
`side a / ${(repoOwner || '').toLowerCase()}`,
122+
padX,
123+
62 * scale,
124+
2.6 * scale,
125+
);
126+
127+
// Track-number style label
128+
ctx.fillStyle = ringInk;
129+
ctx.font = `italic 600 ${16 * scale}px ${serif}`;
130+
ctx.fillText('track 01.', padX, 100 * scale);
131+
132+
// The title — soft serif, italic, lowercase, generous size
133+
ctx.fillStyle = ink;
134+
const titleRaw = (repoName || '').toLowerCase();
135+
const titleSize = titleRaw.length > 14 ? 96 * scale : 124 * scale;
136+
ctx.font = `italic 400 ${titleSize}px ${serif}`;
137+
ctx.textBaseline = 'alphabetic';
138+
ctx.fillText(titleRaw, padX, height * 0.52);
139+
140+
// A handwritten-feel attribution under the title
141+
ctx.fillStyle = inkSoft;
142+
ctx.font = `italic 400 ${28 * scale}px ${serif}`;
143+
ctx.fillText(
144+
`— a record by ${(repoOwner || '').toLowerCase()}`,
145+
padX,
146+
height * 0.52 + 46 * scale,
147+
);
148+
149+
// Liner-note description, narrow column
150+
if (description) {
151+
ctx.fillStyle = ink;
152+
ctx.font = `400 ${26 * scale}px ${serif}`;
153+
wrapText(
154+
ctx,
155+
description,
156+
padX,
157+
height * 0.52 + 110 * scale,
158+
width * 0.46,
159+
38 * scale,
160+
);
161+
}
162+
163+
// 8. Bottom liner — mono uppercase, letter-spaced, faded.
164+
const linerY = height - 64 * scale;
165+
ctx.font = `600 ${14 * scale}px ${mono}`;
166+
let mx = padX;
167+
const writeMeta = (label, value) => {
168+
ctx.fillStyle = inkFaint;
169+
const lw = letterSpaced(ctx, label, mx, linerY, 2.4 * scale);
170+
ctx.fillStyle = ink;
171+
const vw = letterSpaced(ctx, ' ' + value, mx + lw, linerY, 2.4 * scale);
172+
mx += lw + vw + 36 * scale;
173+
};
174+
if (stars !== undefined && stars !== null && stars !== '') writeMeta('LISTENS', String(stars));
175+
if (forks !== undefined && forks !== null && forks !== '') writeMeta('PRESSED', String(forks));
176+
if (language) writeMeta('IN', String(language).toUpperCase());
177+
178+
// 9. Corner stamp — a tiny dedication, low-contrast.
179+
ctx.textAlign = 'right';
180+
ctx.fillStyle = inkFaint;
181+
ctx.font = `italic 400 ${15 * scale}px ${serif}`;
182+
ctx.fillText('for malcolm.', width - padX, linerY);
183+
184+
if (supportUrl) {
185+
ctx.fillStyle = inkFaint;
186+
ctx.font = `500 ${12 * scale}px ${mono}`;
187+
ctx.fillText(supportUrl, width - padX, linerY + 22 * scale);
188+
}
189+
ctx.textAlign = 'left';
190+
};
191+
192+
// ─── helpers ──────────────────────────────────────────────────────────────
193+
194+
function letterSpaced(ctx, text, x, y, spacing) {
195+
let cx = x;
196+
for (const ch of text) {
197+
ctx.fillText(ch, cx, y);
198+
cx += ctx.measureText(ch).width + spacing;
199+
}
200+
return cx - x;
201+
}
202+
203+
function mulberry(seed) {
204+
let t = (seed * 1831565813) | 0;
205+
t = (t + 0x6D2B79F5) | 0;
206+
let r = Math.imul(t ^ (t >>> 15), 1 | t);
207+
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
208+
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
209+
}
210+
211+
function hexA(hex, alpha) {
212+
const { r, g, b } = parseHex(hex);
213+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
214+
}
215+
216+
function isLight(hex) {
217+
const { r, g, b } = parseHex(hex);
218+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 170;
219+
}
220+
221+
function parseHex(hex) {
222+
const h = (hex || '#000').replace('#', '');
223+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
224+
return {
225+
r: parseInt(full.slice(0, 2), 16) || 0,
226+
g: parseInt(full.slice(2, 4), 16) || 0,
227+
b: parseInt(full.slice(4, 6), 16) || 0,
228+
};
229+
}

0 commit comments

Comments
 (0)