Skip to content

Commit fa77e0f

Browse files
committed
feat(thumbnail): add Boarding Pass, Periodic Element & Thermal Receipt styles
Three new GitHub thumbnail styles that map repo fields into real-world objects: an airline boarding pass with a perforated tear-off stub and seeded barcode; a periodic-table element tile (repo initials as the symbol, stars as the atomic number, language as the category color); and a monospace thermal store receipt with itemized stars/forks/language and a torn paper edge. All honor the primary/secondary/bg color pickers, gate on field presence so cleared values don't render, and derive synthetic details deterministically from a repo-name seed.
1 parent 620c92e commit fa77e0f

5 files changed

Lines changed: 1131 additions & 0 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ const THEME_OPTIONS = [
110110
{ value: 'macMiller', label: 'Mac Miller — Swimming in Circles' },
111111
{ value: 'squarified', label: 'Squarified Treemap' },
112112
{ value: 'markdownEditor', label: 'Markdown Editor' },
113+
{ value: 'boardingPass', label: 'Boarding Pass' },
114+
{ value: 'periodicElement', label: 'Periodic Element' },
115+
{ value: 'thermalReceipt', label: 'Thermal Receipt' },
113116
];
114117

115118
const PALETTE_BANK = [

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ import { charon } from './themes/charon';
8282
import { macMiller } from './themes/macMiller';
8383
import { squarified } from './themes/squarified';
8484
import { markdownEditor } from './themes/markdownEditor';
85+
import { boardingPass } from './themes/boardingPass';
86+
import { periodicElement } from './themes/periodicElement';
87+
import { thermalReceipt } from './themes/thermalReceipt';
8588

8689
export const themeRenderers = {
8790
modern,
@@ -165,4 +168,7 @@ export const themeRenderers = {
165168
macMiller,
166169
squarified,
167170
markdownEditor,
171+
boardingPass,
172+
periodicElement,
173+
thermalReceipt,
168174
};
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
export const boardingPass = (ctx, width, height, scale, data) => {
2+
const {
3+
primaryColor = '#6366f1',
4+
secondaryColor = '#22d3ee',
5+
bgColor = '#0f172a',
6+
repoOwner = '',
7+
repoName = '',
8+
description = '',
9+
language = '',
10+
stars = '',
11+
forks = '',
12+
} = data || {};
13+
14+
const mono = '"JetBrains Mono", ui-monospace, monospace';
15+
const sans = 'system-ui, -apple-system, "Segoe UI", sans-serif';
16+
17+
// Deterministic ticket data derived from a stable seed
18+
const seed = seedFrom(repoName || repoOwner || 'flight');
19+
const flightNo = 1000 + Math.floor(mulberry(seed + 1) * 9000); // 4-digit
20+
const flightCode = `GH ${flightNo}`;
21+
const gateLetter = 'ABCDEF'[Math.floor(mulberry(seed + 2) * 6)];
22+
const gateDigit = 1 + Math.floor(mulberry(seed + 3) * 9);
23+
const gate = `${gateLetter}${gateDigit}`;
24+
const bh = String(Math.floor(mulberry(seed + 4) * 24)).padStart(2, '0');
25+
const bm = String(Math.floor(mulberry(seed + 5) * 60)).padStart(2, '0');
26+
const boardTime = `${bh}:${bm}`;
27+
const seatRow = 1 + Math.floor(mulberry(seed + 6) * 30);
28+
const seatCol = 'ABCDEF'[Math.floor(mulberry(seed + 7) * 6)];
29+
const seat = `${seatRow}${seatCol}`;
30+
31+
const ink = '#1A1A1A';
32+
const muted = '#8A8A8A';
33+
const paper = '#F7F5F0';
34+
const onPrimary = readableOn(primaryColor);
35+
36+
// --- Ground: bgColor with subtle vertical gradient ---
37+
const grad = ctx.createLinearGradient(0, 0, 0, height);
38+
grad.addColorStop(0, bgColor);
39+
grad.addColorStop(1, lighten(bgColor, 0.04));
40+
ctx.fillStyle = grad;
41+
ctx.fillRect(0, 0, width, height);
42+
43+
// Decorative dotted flight arcs (low alpha)
44+
ctx.save();
45+
ctx.globalAlpha = 0.06;
46+
ctx.strokeStyle = lighten(bgColor, 0.5);
47+
ctx.lineWidth = 1.5 * scale;
48+
ctx.setLineDash([3 * scale, 7 * scale]);
49+
for (let a = 0; a < 3; a++) {
50+
ctx.beginPath();
51+
const baseY = (0.18 + a * 0.34) * height;
52+
for (let x = 0; x <= width; x += 4 * scale) {
53+
const y = baseY + Math.sin(x / (160 * scale) + a) * 60 * scale;
54+
if (x === 0) ctx.moveTo(x, y);
55+
else ctx.lineTo(x, y);
56+
}
57+
ctx.stroke();
58+
}
59+
ctx.restore();
60+
61+
// Faint plane silhouettes
62+
ctx.save();
63+
ctx.globalAlpha = 0.05;
64+
ctx.fillStyle = lighten(bgColor, 0.6);
65+
drawPlane(ctx, width * 0.12, height * 0.16, 70 * scale, -0.25);
66+
drawPlane(ctx, width * 0.86, height * 0.86, 90 * scale, 2.9);
67+
ctx.restore();
68+
69+
// --- Card geometry ---
70+
const cardMargin = 90 * scale;
71+
const cardW = width - cardMargin * 2;
72+
const cardH = 360 * scale;
73+
const cardX = cardMargin;
74+
const cardY = (height - cardH) / 2;
75+
const cardR = 22 * scale;
76+
77+
// Soft drop shadow (translucent offset rounded rect + canvas blur)
78+
ctx.save();
79+
ctx.shadowColor = 'rgba(0,0,0,0.35)';
80+
ctx.shadowBlur = 40 * scale;
81+
ctx.shadowOffsetY = 18 * scale;
82+
ctx.fillStyle = 'rgba(0,0,0,0.25)';
83+
roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR);
84+
ctx.fill();
85+
ctx.restore();
86+
87+
// Card paper
88+
ctx.save();
89+
roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR);
90+
ctx.fillStyle = paper;
91+
ctx.fill();
92+
ctx.restore();
93+
94+
// Split: main body (left ~72%) | stub (right ~28%)
95+
const stubW = cardW * 0.28;
96+
const perfX = cardX + cardW - stubW;
97+
const bodyX = cardX;
98+
const bodyW = perfX - bodyX;
99+
100+
// Clip everything to the card for clean edges
101+
ctx.save();
102+
roundRectPath(ctx, cardX, cardY, cardW, cardH, cardR);
103+
ctx.clip();
104+
105+
// --- Header band across the main body ---
106+
const headerH = 56 * scale;
107+
ctx.fillStyle = primaryColor;
108+
ctx.fillRect(bodyX, cardY, bodyW, headerH);
109+
110+
ctx.fillStyle = onPrimary;
111+
ctx.textBaseline = 'middle';
112+
ctx.textAlign = 'left';
113+
ctx.font = `700 ${22 * scale}px ${sans}`;
114+
ctx.fillText('✈ GITHUB AIRWAYS', bodyX + 28 * scale, cardY + headerH / 2);
115+
116+
ctx.textAlign = 'right';
117+
ctx.font = `700 ${16 * scale}px ${mono}`;
118+
ctx.fillText('B O A R D I N G P A S S', perfX - 26 * scale, cardY + headerH / 2);
119+
ctx.textAlign = 'left';
120+
121+
// --- Body field grid ---
122+
const padL = bodyX + 28 * scale;
123+
const fieldsY = cardY + headerH + 36 * scale;
124+
const fields = [
125+
['PASSENGER', truncate(ctx, (repoOwner || '—').toUpperCase(), bodyW * 0.34, `700 ${22 * scale}px ${mono}`)],
126+
['FLIGHT', flightCode],
127+
['GATE', gate],
128+
['BOARDING', boardTime],
129+
];
130+
const colW = (bodyW - 56 * scale) / 4;
131+
fields.forEach((f, i) => {
132+
const fx = padL + i * colW;
133+
ctx.textAlign = 'left';
134+
ctx.textBaseline = 'alphabetic';
135+
ctx.fillStyle = muted;
136+
ctx.font = `700 ${12 * scale}px ${mono}`;
137+
ctx.fillText(letterSpace(f[0]), fx, fieldsY);
138+
ctx.fillStyle = ink;
139+
ctx.font = `700 ${22 * scale}px ${mono}`;
140+
ctx.fillText(f[1], fx, fieldsY + 28 * scale);
141+
});
142+
143+
// --- Route line: MAIN -> repoName ---
144+
const routeY = fieldsY + 78 * scale;
145+
ctx.textBaseline = 'middle';
146+
ctx.textAlign = 'left';
147+
ctx.fillStyle = ink;
148+
ctx.font = `700 ${30 * scale}px ${sans}`;
149+
ctx.fillText('MAIN', padL, routeY);
150+
const originW = ctx.measureText('MAIN').width;
151+
152+
// Destination (big repoName)
153+
ctx.font = `800 ${42 * scale}px ${sans}`;
154+
const destMaxW = bodyW - (padL - bodyX) - originW - 120 * scale;
155+
const destText = truncate(ctx, repoName || 'repository', destMaxW, `800 ${42 * scale}px ${sans}`);
156+
const destW = ctx.measureText(destText).width;
157+
const destX = perfX - 26 * scale - destW;
158+
159+
// Dotted connector with a plane glyph in secondaryColor
160+
const connStart = padL + originW + 18 * scale;
161+
const connEnd = destX - 18 * scale;
162+
if (connEnd > connStart) {
163+
ctx.save();
164+
ctx.strokeStyle = mix(ink, paper, 0.55);
165+
ctx.lineWidth = 2 * scale;
166+
ctx.setLineDash([2 * scale, 6 * scale]);
167+
ctx.beginPath();
168+
ctx.moveTo(connStart, routeY);
169+
ctx.lineTo(connEnd, routeY);
170+
ctx.stroke();
171+
ctx.restore();
172+
ctx.fillStyle = secondaryColor;
173+
ctx.font = `${24 * scale}px ${sans}`;
174+
ctx.textAlign = 'center';
175+
ctx.fillText('✈', (connStart + connEnd) / 2, routeY - 1 * scale);
176+
}
177+
ctx.textAlign = 'left';
178+
ctx.fillStyle = ink;
179+
ctx.font = `800 ${42 * scale}px ${sans}`;
180+
ctx.fillText(destText, destX, routeY);
181+
182+
// --- Meta row: stars / forks / language pill ---
183+
const metaY = routeY + 52 * scale;
184+
let metaX = padL;
185+
ctx.textBaseline = 'middle';
186+
ctx.textAlign = 'left';
187+
if (has(stars)) {
188+
ctx.fillStyle = ink;
189+
ctx.font = `700 ${20 * scale}px ${mono}`;
190+
const t = `★ ${String(stars)}`;
191+
ctx.fillText(t, metaX, metaY);
192+
metaX += ctx.measureText(t).width + 30 * scale;
193+
}
194+
if (has(forks)) {
195+
ctx.fillStyle = secondaryColor;
196+
ctx.font = `700 ${20 * scale}px ${mono}`;
197+
ctx.fillText('⑂', metaX, metaY);
198+
const gw = ctx.measureText('⑂').width + 8 * scale;
199+
ctx.fillStyle = ink;
200+
ctx.fillText(String(forks), metaX + gw, metaY);
201+
metaX += gw + ctx.measureText(String(forks)).width + 30 * scale;
202+
}
203+
if (has(language)) {
204+
ctx.font = `700 ${16 * scale}px ${mono}`;
205+
const lang = language.toUpperCase();
206+
const lw = ctx.measureText(lang).width;
207+
const pillH = 32 * scale;
208+
const pillW = lw + 28 * scale;
209+
roundRectPath(ctx, metaX, metaY - pillH / 2, pillW, pillH, pillH / 2);
210+
ctx.strokeStyle = primaryColor;
211+
ctx.lineWidth = 2 * scale;
212+
ctx.stroke();
213+
ctx.fillStyle = primaryColor;
214+
ctx.textAlign = 'left';
215+
ctx.fillText(lang, metaX + 14 * scale, metaY + 1 * scale);
216+
}
217+
218+
// --- Description: single muted truncated line ---
219+
if (has(description)) {
220+
const descY = metaY + 34 * scale;
221+
ctx.fillStyle = muted;
222+
ctx.font = `400 ${16 * scale}px ${sans}`;
223+
ctx.textBaseline = 'middle';
224+
ctx.textAlign = 'left';
225+
const desc = truncate(ctx, description, bodyW - 56 * scale, `400 ${16 * scale}px ${sans}`);
226+
ctx.fillText(desc, padL, descY);
227+
}
228+
229+
// --- Horizontal barcode along bottom of main body ---
230+
const barAreaH = 40 * scale;
231+
const barY = cardY + cardH - barAreaH - 34 * scale;
232+
let bx = padL;
233+
const barMaxX = perfX - 26 * scale;
234+
ctx.fillStyle = ink;
235+
let bi = 0;
236+
while (bx < barMaxX - 2 * scale) {
237+
const bw = (1 + Math.floor(mulberry(seed + 100 + bi) * 4)) * scale;
238+
if (mulberry(seed + 200 + bi) > 0.32) {
239+
ctx.fillRect(bx, barY, bw, barAreaH);
240+
}
241+
bx += bw + (1 + Math.floor(mulberry(seed + 300 + bi) * 2)) * scale;
242+
bi++;
243+
}
244+
ctx.fillStyle = muted;
245+
ctx.font = `400 ${13 * scale}px ${mono}`;
246+
ctx.textAlign = 'left';
247+
ctx.textBaseline = 'top';
248+
ctx.fillText(`${flightCode} • GATE ${gate}${boardTime}`, padL, barY + barAreaH + 8 * scale);
249+
250+
// ====================== STUB ======================
251+
// Stub mini header
252+
ctx.fillStyle = primaryColor;
253+
ctx.fillRect(perfX, cardY, stubW, headerH);
254+
ctx.fillStyle = onPrimary;
255+
ctx.font = `700 ${13 * scale}px ${mono}`;
256+
ctx.textAlign = 'center';
257+
ctx.textBaseline = 'middle';
258+
ctx.fillText(letterSpace('SEAT'), perfX + stubW / 2, cardY + headerH / 2);
259+
260+
// Big SEAT value
261+
const stubCx = perfX + stubW / 2;
262+
ctx.fillStyle = ink;
263+
ctx.font = `800 ${64 * scale}px ${sans}`;
264+
ctx.textAlign = 'center';
265+
ctx.textBaseline = 'middle';
266+
ctx.fillText(seat, stubCx - 4 * scale, cardY + headerH + 70 * scale);
267+
// secondaryColor accent underline
268+
ctx.fillStyle = secondaryColor;
269+
ctx.fillRect(stubCx - 44 * scale, cardY + headerH + 116 * scale, 88 * scale, 4 * scale);
270+
271+
// GATE + FLIGHT repeated on stub
272+
const stubInfoY = cardY + headerH + 156 * scale;
273+
const stubInfo = [
274+
['GATE', gate],
275+
['FLIGHT', flightCode],
276+
['BOARD', boardTime],
277+
];
278+
stubInfo.forEach((f, i) => {
279+
const sy = stubInfoY + i * 42 * scale;
280+
ctx.fillStyle = muted;
281+
ctx.font = `700 ${11 * scale}px ${mono}`;
282+
ctx.textAlign = 'center';
283+
ctx.textBaseline = 'alphabetic';
284+
ctx.fillText(letterSpace(f[0]), stubCx, sy);
285+
ctx.fillStyle = ink;
286+
ctx.font = `700 ${20 * scale}px ${mono}`;
287+
ctx.fillText(f[1], stubCx, sy + 22 * scale);
288+
});
289+
290+
// Vertical barcode along stub's right edge
291+
const vbX = cardX + cardW - 26 * scale;
292+
let vy = cardY + 80 * scale;
293+
const vbMaxY = cardY + cardH - 26 * scale;
294+
ctx.fillStyle = ink;
295+
let vi = 0;
296+
const vbW = 22 * scale;
297+
while (vy < vbMaxY - 2 * scale) {
298+
const bh2 = (1 + Math.floor(mulberry(seed + 400 + vi) * 4)) * scale;
299+
if (mulberry(seed + 500 + vi) > 0.32) {
300+
ctx.fillRect(vbX - vbW, vy, vbW, bh2);
301+
}
302+
vy += bh2 + (1 + Math.floor(mulberry(seed + 600 + vi) * 2)) * scale;
303+
vi++;
304+
}
305+
306+
ctx.restore(); // end card clip
307+
308+
// --- Perforation: dashed vertical line + punched notch circles ---
309+
ctx.save();
310+
ctx.strokeStyle = mix(ink, paper, 0.6);
311+
ctx.lineWidth = 2 * scale;
312+
ctx.setLineDash([6 * scale, 6 * scale]);
313+
ctx.beginPath();
314+
ctx.moveTo(perfX, cardY + 14 * scale);
315+
ctx.lineTo(perfX, cardY + cardH - 14 * scale);
316+
ctx.stroke();
317+
ctx.restore();
318+
319+
// Punched notches at top & bottom (filled with the ground gradient so they read as cut-outs)
320+
const notchR = 14 * scale;
321+
ctx.save();
322+
ctx.fillStyle = grad;
323+
ctx.beginPath();
324+
ctx.arc(perfX, cardY, notchR, 0, Math.PI * 2);
325+
ctx.fill();
326+
ctx.beginPath();
327+
ctx.arc(perfX, cardY + cardH, notchR, 0, Math.PI * 2);
328+
ctx.fill();
329+
ctx.restore();
330+
};
331+
332+
// ---------------- helpers (hoisted) ----------------
333+
function letterSpace(s) {
334+
return String(s || '').split('').join(' ');
335+
}
336+
337+
function drawPlane(ctx, cx, cy, size, rot) {
338+
ctx.save();
339+
ctx.translate(cx, cy);
340+
ctx.rotate(rot);
341+
const s = size;
342+
ctx.beginPath();
343+
ctx.moveTo(0.5 * s, 0);
344+
ctx.lineTo(-0.1 * s, 0.1 * s);
345+
ctx.lineTo(-0.35 * s, 0.1 * s);
346+
ctx.lineTo(-0.18 * s, 0.32 * s);
347+
ctx.lineTo(-0.34 * s, 0.32 * s);
348+
ctx.lineTo(-0.5 * s, 0.06 * s);
349+
ctx.lineTo(-0.5 * s, -0.06 * s);
350+
ctx.lineTo(-0.34 * s, -0.32 * s);
351+
ctx.lineTo(-0.18 * s, -0.32 * s);
352+
ctx.lineTo(-0.35 * s, -0.1 * s);
353+
ctx.lineTo(-0.1 * s, -0.1 * s);
354+
ctx.closePath();
355+
ctx.fill();
356+
ctx.restore();
357+
}
358+
359+
function parseHex(hex){const h=(hex||'#000').replace('#','');const f=h.length===3?h.split('').map(c=>c+c).join(''):h;return{r:parseInt(f.slice(0,2),16)||0,g:parseInt(f.slice(2,4),16)||0,b:parseInt(f.slice(4,6),16)||0};}
360+
function rgbToHex(r,g,b){const h=n=>Math.max(0,Math.min(255,Math.round(n))).toString(16).padStart(2,'0');return '#'+h(r)+h(g)+h(b);}
361+
function mix(a,b,t){const x=parseHex(a),y=parseHex(b);return rgbToHex(x.r+(y.r-x.r)*t,x.g+(y.g-x.g)*t,x.b+(y.b-x.b)*t);}
362+
function lighten(hex,a){const{r,g,b}=parseHex(hex);return rgbToHex(r+255*a,g+255*a,b+255*a);}
363+
function readableOn(hex){const{r,g,b}=parseHex(hex);return (0.299*r+0.587*g+0.114*b)>150?'#111111':'#ffffff';}
364+
function mulberry(seed){let t=(seed*1831565813)|0;t=(t+0x6D2B79F5)|0;let r=Math.imul(t^(t>>>15),1|t);r^=r+Math.imul(r^(r>>>7),61|r);return ((r^(r>>>14))>>>0)/4294967296;}
365+
function seedFrom(str){let s=0;for(const c of (str||'x'))s=(s*31+c.charCodeAt(0))|0;return Math.abs(s)||1;}
366+
function has(v){return v!==undefined&&v!==null&&String(v).trim()!=='';}
367+
function truncate(ctx,text,maxW,font){ctx.font=font;if(ctx.measureText(text).width<=maxW)return text;let t=text;while(t.length>1&&ctx.measureText(t+'…').width>maxW)t=t.slice(0,-1);return t+'…';}
368+
function roundRectPath(ctx,x,y,w,h,r){const rr=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+rr,y);ctx.arcTo(x+w,y,x+w,y+h,rr);ctx.arcTo(x+w,y+h,x,y+h,rr);ctx.arcTo(x,y+h,x,y,rr);ctx.arcTo(x,y,x+w,y,rr);ctx.closePath();}

0 commit comments

Comments
 (0)