Skip to content

Commit 44df509

Browse files
committed
feat(thumbnail): add TERRACOTTA_PLATE style using Plumb theme
- New terracotta renderer: bone paper, terra ink, Fraunces + IBM Plex Mono - Signature: crosshairs, double edge rule, plumb-bob on a cord, hatched fleuron - Seeded noise grain and colophon kicker 'PLATE Nº NN · MMXXVI' - Register in themes.js and append to picker options
1 parent 85414bc commit 44df509

3 files changed

Lines changed: 337 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
@@ -86,6 +86,7 @@ const THEME_OPTIONS = [
8686
{ value: 'dithered', label: 'RETRO_DITHERED' },
8787
{ value: 'abstractNonsense', label: 'ABSTRACT_NONSENSE' },
8888
{ value: 'needForSpeed', label: 'NEED_FOR_SPEED' },
89+
{ value: 'terracotta', label: 'TERRACOTTA_PLATE' },
8990
];
9091

9192
const GithubThumbnailGeneratorPage = () => {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { jungle } from './themes/jungle';
6969
import { dithered } from './themes/dithered';
7070
import { abstractNonsense } from './themes/abstractNonsense';
7171
import { needForSpeed } from './themes/needForSpeed';
72+
import { terracotta } from './themes/terracotta';
7273

7374
export const themeRenderers = {
7475
modern,
@@ -139,4 +140,5 @@ export const themeRenderers = {
139140
dithered,
140141
abstractNonsense,
141142
needForSpeed,
143+
terracotta,
142144
};
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
import { wrapText } from '../utils';
2+
3+
/*
4+
* TERRACOTTA — a Plumb-brand letterpress broadside.
5+
*
6+
* Bone paper, terra ink, brass and sage accents. Fraunces italic for the
7+
* repo name, IBM Plex Mono for meta. Signature details: registration
8+
* crosshairs in the four corners, a double edge rule, a plumb-bob hung on
9+
* a thin cord in the upper-right, and a hatched field at bottom-left.
10+
* Rendered deterministically from the repo seed.
11+
*/
12+
export const terracotta = (ctx, width, height, scale, data) => {
13+
const { repoOwner, repoName, description, language, stars } = data;
14+
15+
/* Palette */
16+
const BONE = '#F3ECE0';
17+
const BONE_DEEP = '#E8DECE';
18+
const INK = '#1A1613';
19+
const INK_SOFT = '#2E2620';
20+
const TERRA = '#C96442';
21+
const TERRA_DEEP = '#9E4A2F';
22+
const BRASS = '#B88532';
23+
const SAGE = '#6B8E23';
24+
const RULE = 'rgba(26,22,19,0.2)';
25+
26+
/* Seeded RNG */
27+
const seed = `${repoOwner || ''}${repoName || ''}`;
28+
let h = 0xc9_64_42_f0;
29+
for (let i = 0; i < seed.length; i++) {
30+
h = Math.imul(h ^ seed.charCodeAt(i), 2654435761);
31+
}
32+
const rng = () => {
33+
h = Math.imul(h ^ (h >>> 16), 2246822507);
34+
h = Math.imul(h ^ (h >>> 13), 3266489909);
35+
return ((h ^= h >>> 16) >>> 0) / 4294967296;
36+
};
37+
38+
/* Fonts */
39+
const serif = '"Fraunces", "Times New Roman", serif';
40+
const mono = '"IBM Plex Mono", "JetBrains Mono", monospace';
41+
42+
/* Background — bone paper with a warm radial bloom */
43+
ctx.fillStyle = BONE;
44+
ctx.fillRect(0, 0, width, height);
45+
46+
const grad = ctx.createRadialGradient(
47+
width * 0.82,
48+
height * -0.1,
49+
0,
50+
width * 0.82,
51+
height * -0.1,
52+
width * 0.9,
53+
);
54+
grad.addColorStop(0, BONE_DEEP);
55+
grad.addColorStop(1, BONE);
56+
ctx.fillStyle = grad;
57+
ctx.globalAlpha = 0.55;
58+
ctx.fillRect(0, 0, width, height);
59+
ctx.globalAlpha = 1;
60+
61+
/* Paper grain — speckles */
62+
ctx.save();
63+
for (let i = 0; i < 520; i++) {
64+
const cx = rng() * width;
65+
const cy = rng() * height;
66+
const r = rng() * 1.4 * scale;
67+
ctx.beginPath();
68+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
69+
ctx.fillStyle = `rgba(26,22,19,${0.05 + rng() * 0.07})`;
70+
ctx.fill();
71+
}
72+
ctx.restore();
73+
74+
/* Double edge rule */
75+
const margin = 44 * scale;
76+
ctx.strokeStyle = INK;
77+
ctx.lineWidth = 1.2 * scale;
78+
ctx.strokeRect(margin, margin, width - margin * 2, height - margin * 2);
79+
ctx.strokeStyle = RULE;
80+
ctx.lineWidth = 0.8 * scale;
81+
ctx.strokeRect(
82+
margin + 6 * scale,
83+
margin + 6 * scale,
84+
width - margin * 2 - 12 * scale,
85+
height - margin * 2 - 12 * scale,
86+
);
87+
88+
/* Registration crosshairs in the four corners */
89+
const drawCrosshair = (cx, cy) => {
90+
ctx.save();
91+
ctx.strokeStyle = 'rgba(26,22,19,0.55)';
92+
ctx.lineWidth = 1 * scale;
93+
ctx.beginPath();
94+
ctx.arc(cx, cy, 8 * scale, 0, Math.PI * 2);
95+
ctx.stroke();
96+
ctx.beginPath();
97+
ctx.moveTo(cx - 14 * scale, cy);
98+
ctx.lineTo(cx + 14 * scale, cy);
99+
ctx.moveTo(cx, cy - 14 * scale);
100+
ctx.lineTo(cx, cy + 14 * scale);
101+
ctx.stroke();
102+
ctx.restore();
103+
};
104+
const cOff = 20 * scale;
105+
drawCrosshair(cOff, cOff);
106+
drawCrosshair(width - cOff, cOff);
107+
drawCrosshair(cOff, height - cOff);
108+
drawCrosshair(width - cOff, height - cOff);
109+
110+
/* Top kicker — mono colophon strip */
111+
ctx.save();
112+
ctx.fillStyle = TERRA;
113+
ctx.beginPath();
114+
ctx.arc(margin + 14 * scale, margin + 34 * scale, 5 * scale, 0, Math.PI * 2);
115+
ctx.fill();
116+
117+
ctx.fillStyle = INK_SOFT;
118+
ctx.font = `500 ${16 * scale}px ${mono}`;
119+
ctx.textAlign = 'left';
120+
ctx.textBaseline = 'middle';
121+
const kicker = `FEZCODEX · PLATE № ${(
122+
Math.floor(rng() * 99) + 1
123+
)
124+
.toString()
125+
.padStart(2, '0')} · MMXXVI`;
126+
ctx.fillText(kicker, margin + 28 * scale, margin + 34 * scale);
127+
128+
ctx.strokeStyle = RULE;
129+
ctx.lineWidth = 1 * scale;
130+
const kickerTextWidth = ctx.measureText(kicker).width;
131+
const ruleStart = margin + 28 * scale + kickerTextWidth + 20 * scale;
132+
ctx.beginPath();
133+
ctx.moveTo(ruleStart, margin + 34 * scale);
134+
ctx.lineTo(width - margin - 220 * scale, margin + 34 * scale);
135+
ctx.stroke();
136+
137+
ctx.fillStyle = TERRA_DEEP;
138+
ctx.textAlign = 'right';
139+
ctx.fillText('A CODEX ENTRY', width - margin - 14 * scale, margin + 34 * scale);
140+
ctx.restore();
141+
142+
/* Plumb-bob mark — hung on a cord in the upper-right */
143+
ctx.save();
144+
const bobCx = width * 0.82;
145+
const cordTop = margin + 60 * scale;
146+
const cordBottom = height * 0.46;
147+
const bobBase = cordBottom;
148+
const bobSize = 58 * scale;
149+
150+
// cord
151+
ctx.strokeStyle = INK;
152+
ctx.lineWidth = 1.2 * scale;
153+
ctx.beginPath();
154+
ctx.moveTo(bobCx, cordTop);
155+
ctx.lineTo(bobCx, bobBase);
156+
ctx.stroke();
157+
158+
// cap — small ink square at the top of the bob (brass cap)
159+
ctx.fillStyle = BRASS;
160+
ctx.fillRect(
161+
bobCx - 6 * scale,
162+
bobBase - 4 * scale,
163+
12 * scale,
164+
8 * scale,
165+
);
166+
ctx.strokeStyle = INK;
167+
ctx.lineWidth = 0.8 * scale;
168+
ctx.strokeRect(
169+
bobCx - 6 * scale,
170+
bobBase - 4 * scale,
171+
12 * scale,
172+
8 * scale,
173+
);
174+
175+
// the teardrop bob — faceted terra body
176+
ctx.fillStyle = TERRA;
177+
ctx.beginPath();
178+
ctx.moveTo(bobCx - bobSize / 2, bobBase + 4 * scale);
179+
ctx.lineTo(bobCx + bobSize / 2, bobBase + 4 * scale);
180+
ctx.lineTo(bobCx + bobSize / 2 - 2 * scale, bobBase + bobSize * 0.55);
181+
ctx.lineTo(bobCx, bobBase + bobSize * 1.4);
182+
ctx.lineTo(bobCx - bobSize / 2 + 2 * scale, bobBase + bobSize * 0.55);
183+
ctx.closePath();
184+
ctx.fill();
185+
186+
// chisel line down the middle
187+
ctx.strokeStyle = TERRA_DEEP;
188+
ctx.lineWidth = 1 * scale;
189+
ctx.beginPath();
190+
ctx.moveTo(bobCx, bobBase + 4 * scale);
191+
ctx.lineTo(bobCx, bobBase + bobSize * 1.4);
192+
ctx.stroke();
193+
194+
// gleam — thin highlight
195+
ctx.strokeStyle = 'rgba(243,236,224,0.55)';
196+
ctx.lineWidth = 1.4 * scale;
197+
ctx.beginPath();
198+
ctx.moveTo(bobCx - bobSize / 4, bobBase + 10 * scale);
199+
ctx.lineTo(bobCx - bobSize / 8, bobBase + bobSize * 0.9);
200+
ctx.stroke();
201+
202+
// outline
203+
ctx.strokeStyle = INK;
204+
ctx.lineWidth = 1 * scale;
205+
ctx.beginPath();
206+
ctx.moveTo(bobCx - bobSize / 2, bobBase + 4 * scale);
207+
ctx.lineTo(bobCx + bobSize / 2, bobBase + 4 * scale);
208+
ctx.lineTo(bobCx + bobSize / 2 - 2 * scale, bobBase + bobSize * 0.55);
209+
ctx.lineTo(bobCx, bobBase + bobSize * 1.4);
210+
ctx.lineTo(bobCx - bobSize / 2 + 2 * scale, bobBase + bobSize * 0.55);
211+
ctx.closePath();
212+
ctx.stroke();
213+
ctx.restore();
214+
215+
/* Hatched field — bottom-left decorative ornament */
216+
ctx.save();
217+
ctx.strokeStyle = INK;
218+
ctx.lineWidth = 0.8 * scale;
219+
const hatchX = margin + 14 * scale;
220+
const hatchY = height - margin - 120 * scale;
221+
const hatchW = 200 * scale;
222+
const hatchH = 60 * scale;
223+
for (let i = 0; i < 26; i++) {
224+
const x1 = hatchX + (hatchW * i) / 25;
225+
ctx.beginPath();
226+
ctx.moveTo(x1, hatchY);
227+
ctx.lineTo(x1 - hatchH * 0.55, hatchY + hatchH);
228+
ctx.stroke();
229+
}
230+
// thin fleuron dot line below
231+
for (let i = 0; i < 5; i++) {
232+
const fx = hatchX + (hatchW * i) / 4;
233+
ctx.fillStyle = INK;
234+
ctx.beginPath();
235+
ctx.arc(fx, hatchY + hatchH + 14 * scale, 1.6 * scale, 0, Math.PI * 2);
236+
ctx.fill();
237+
}
238+
ctx.restore();
239+
240+
/* Repo owner — mono, tracked uppercase */
241+
ctx.save();
242+
ctx.fillStyle = INK_SOFT;
243+
ctx.font = `500 ${20 * scale}px ${mono}`;
244+
ctx.textAlign = 'left';
245+
ctx.textBaseline = 'alphabetic';
246+
const ownerLabel = (repoOwner || '').toUpperCase().split('').join(' ');
247+
ctx.fillText(ownerLabel, margin + 14 * scale, height * 0.34);
248+
ctx.restore();
249+
250+
/* Repo name — giant Fraunces italic with terra period */
251+
ctx.save();
252+
ctx.fillStyle = INK;
253+
const repoFontSize = repoName && repoName.length > 14 ? 96 : 128;
254+
ctx.font = `italic 500 ${repoFontSize * scale}px ${serif}`;
255+
ctx.textAlign = 'left';
256+
ctx.textBaseline = 'alphabetic';
257+
const nameY = height * 0.52;
258+
ctx.fillText(repoName || 'untitled', margin + 14 * scale, nameY);
259+
260+
const nameW = ctx.measureText(repoName || 'untitled').width;
261+
ctx.fillStyle = TERRA;
262+
ctx.font = `900 ${repoFontSize * scale}px ${serif}`;
263+
ctx.fillText('.', margin + 14 * scale + nameW, nameY);
264+
ctx.restore();
265+
266+
/* Description — Fraunces italic body */
267+
ctx.save();
268+
ctx.fillStyle = INK_SOFT;
269+
ctx.font = `italic 400 ${28 * scale}px ${serif}`;
270+
ctx.textAlign = 'left';
271+
wrapText(
272+
ctx,
273+
description || '',
274+
margin + 14 * scale,
275+
height * 0.62,
276+
Math.min(width * 0.62, 760 * scale),
277+
42 * scale,
278+
);
279+
ctx.restore();
280+
281+
/* Bottom dictionary-entry footer */
282+
ctx.save();
283+
const footY = height - margin - 34 * scale;
284+
ctx.strokeStyle = RULE;
285+
ctx.lineWidth = 0.8 * scale;
286+
ctx.beginPath();
287+
ctx.moveTo(margin + 14 * scale, footY - 18 * scale);
288+
ctx.lineTo(width - margin - 14 * scale, footY - 18 * scale);
289+
ctx.stroke();
290+
291+
// language — sage label
292+
ctx.fillStyle = SAGE;
293+
ctx.font = `500 ${13 * scale}px ${mono}`;
294+
ctx.textAlign = 'left';
295+
ctx.textBaseline = 'alphabetic';
296+
ctx.fillText(
297+
`LANG ·`,
298+
margin + 14 * scale,
299+
footY,
300+
);
301+
ctx.fillStyle = INK;
302+
ctx.fillText(
303+
`${(language || 'n/a').toUpperCase()}`,
304+
margin + 14 * scale + 70 * scale,
305+
footY,
306+
);
307+
308+
// stars — brass label
309+
ctx.fillStyle = BRASS;
310+
const starsLabelX = margin + 14 * scale + 260 * scale;
311+
ctx.fillText('STARS ·', starsLabelX, footY);
312+
ctx.fillStyle = INK;
313+
ctx.fillText(String(stars ?? 0), starsLabelX + 80 * scale, footY);
314+
315+
// handle right-aligned
316+
ctx.fillStyle = TERRA_DEEP;
317+
ctx.textAlign = 'right';
318+
ctx.fillText(
319+
`${repoOwner || '—'} / ${repoName || '—'}`,
320+
width - margin - 14 * scale,
321+
footY,
322+
);
323+
324+
// signature italic
325+
ctx.fillStyle = INK_SOFT;
326+
ctx.font = `italic 400 ${16 * scale}px ${serif}`;
327+
ctx.textAlign = 'left';
328+
ctx.fillText(
329+
'— set from the stone, read in order.',
330+
margin + 14 * scale,
331+
height - margin + 22 * scale,
332+
);
333+
ctx.restore();
334+
};

0 commit comments

Comments
 (0)