SPB Git forge

spb/spbgit

Public MIT ★ Pinned

SPB Git — the platform hosting itself

15commits 1branches 1releases
833.0 KBsize
maindefault branch
17 days agolast push
JavaScript 67.4% CSS 16.7% Nunjucks 13.7% Shell 2.3%

feat: logo & app icon v3 — git-branch 'S' mark with commit nodes on hot gradient

- logo.svg: new mark — white S-curve stroked like a git branch, commit-node
  dots at both ends, violet→indigo→cyan→mint gradient tile with top gloss
- layout.njk: nav + footer marks updated to the new inline SVG (unique
  gradient ids per instance)
- og-image.mjs: v3 palette, monogramSvg() vector source shared by favicons
  (renderMonogramPng no longer needs satori/fonts) and OG cards (embedded
  as data-URI img), gradient bottom strip on repo/site cards + fallback
- apple-touch-icon rendered full-bleed (iOS applies its own corner mask;
  transparent corners would go black)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 12, 2026) parent 9ce88e1

4 changed files +123 −83

modified src/render/og-image.mjs +77 −75
@@ -21,14 +21,52 @@ const WIDTH = 1200;
21 21 const HEIGHT = 630;
22 22
23 23 const PALETTE = {
24 − bg: '#0b0e14',
25 − surface: '#11151c',
26 − border: '#1f2530',
27 − text: '#e6e9ef',
28 − muted: '#8b93a3',
29 − accent: '#4f8cff',
30 − accent2: '#22d3aa',
24 + bg: '#05070c',
25 + surface: '#0d1119',
26 + border: '#1d2434',
27 + text: '#e7eaf3',
28 + muted: '#8d95ab',
29 + accent: '#7c9aff',
30 + accent2: '#34e3b0',
31 + accent3: '#c084fc',
32 + cyan: '#38cffd',
31 33 };
34 +const GRADIENT = `linear-gradient(115deg, ${PALETTE.accent3} 0%, ${PALETTE.accent} 30%, ${PALETTE.cyan} 62%, ${PALETTE.accent2} 100%)`;
35 +
36 +/**
37 + * The SPB mark v3 — git-branch "S" with commit nodes on the hot gradient.
38 + * Standalone SVG document, mirrored from src/web/assets/img/logo.svg.
39 + * @param {number} size
40 + * @param {{fullBleed?: boolean}} [opts] fullBleed → square corners (iOS masks touch icons itself)
41 + * @returns {string}
42 + */
43 +function monogramSvg(size, { fullBleed = false } = {}) {
44 + const rx = fullBleed ? 0 : 7;
45 + return [
46 + `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 28 28">`,
47 + '<defs>',
48 + '<linearGradient id="g" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse">',
49 + `<stop offset="0" stop-color="${PALETTE.accent3}"/><stop offset="0.35" stop-color="${PALETTE.accent}"/>`,
50 + `<stop offset="0.7" stop-color="${PALETTE.cyan}"/><stop offset="1" stop-color="${PALETTE.accent2}"/>`,
51 + '</linearGradient>',
52 + '<linearGradient id="gl" x1="0" y1="0" x2="0" y2="28" gradientUnits="userSpaceOnUse">',
53 + '<stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="0.55" stop-color="#fff" stop-opacity="0"/>',
54 + '</linearGradient>',
55 + '</defs>',
56 + `<rect width="28" height="28" rx="${rx}" fill="url(#g)"/>`,
57 + `<rect width="28" height="28" rx="${rx}" fill="url(#gl)"/>`,
58 + '<path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>',
59 + '<circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/>',
60 + '<circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/>',
61 + '</svg>',
62 + ].join('');
63 +}
64 +
65 +/** Satori <img> element embedding the mark as a data URI. */
66 +function markImg(size) {
67 + const src = `data:image/svg+xml;base64,${Buffer.from(monogramSvg(size)).toString('base64')}`;
68 + return { type: 'img', props: { src, width: size, height: size } };
69 +}
32 70
33 71 let fontsCache = null;
34 72
@@ -90,41 +128,34 @@ function repoCardTree(data) {
90 128 height: '100%',
91 129 display: 'flex',
92 130 flexDirection: 'column',
93 − justifyContent: 'space-between',
94 131 backgroundColor: PALETTE.bg,
95 − padding: 64,
96 132 fontFamily: 'Inter',
97 − borderBottom: `14px solid ${PALETTE.accent}`,
98 133 },
99 134 h(
100 135 'div',
101 − { display: 'flex', alignItems: 'center', gap: 20 },
136 + {
137 + display: 'flex',
138 + flexDirection: 'column',
139 + justifyContent: 'space-between',
140 + flexGrow: 1,
141 + padding: 64,
142 + },
102 143 h(
103 144 'div',
104 − {
105 − width: 64,
106 − height: 64,
107 − borderRadius: 16,
108 − backgroundColor: PALETTE.accent,
109 − color: '#ffffff',
110 − display: 'flex',
111 − alignItems: 'center',
112 − justifyContent: 'center',
113 − fontSize: 28,
114 − fontWeight: 700,
115 − },
116 − 'SPB',
145 + { display: 'flex', alignItems: 'center', gap: 20 },
146 + markImg(64),
147 + h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
148 + h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'),
117 149 ),
118 − h('div', { fontSize: 34, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
119 − h('div', { fontSize: 26, color: PALETTE.muted, marginLeft: 8 }, '· git.spboucher.ai'),
120 − ),
121 − h(
122 − 'div',
123 − { display: 'flex', flexDirection: 'column', gap: 22 },
124 − h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)),
125 − h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)),
150 + h(
151 + 'div',
152 + { display: 'flex', flexDirection: 'column', gap: 22 },
153 + h('div', { fontSize: 72, fontWeight: 700, color: PALETTE.text, lineHeight: 1.1 }, truncate(data.name, 28)),
154 + h('div', { fontSize: 32, color: PALETTE.muted, lineHeight: 1.4 }, truncate(description, 110)),
155 + ),
156 + h('div', { display: 'flex', flexDirection: 'column' }, bar, legend),
126 157 ),
127 − h('div', { display: 'flex', flexDirection: 'column' }, bar, legend),
158 + h('div', { width: '100%', height: 14, backgroundImage: GRADIENT }),
128 159 );
129 160 }
130 161
@@ -137,32 +168,25 @@ function siteCardTree() {
137 168 height: '100%',
138 169 display: 'flex',
139 170 flexDirection: 'column',
140 − justifyContent: 'center',
141 − alignItems: 'center',
142 − gap: 26,
143 171 backgroundColor: PALETTE.bg,
144 172 fontFamily: 'Inter',
145 − borderBottom: `14px solid ${PALETTE.accent}`,
146 173 },
147 174 h(
148 175 'div',
149 176 {
150 − width: 110,
151 − height: 110,
152 − borderRadius: 28,
153 − backgroundColor: PALETTE.accent,
154 − color: '#ffffff',
155 177 display: 'flex',
156 − alignItems: 'center',
178 + flexDirection: 'column',
157 179 justifyContent: 'center',
158 − fontSize: 44,
159 − fontWeight: 700,
180 + alignItems: 'center',
181 + gap: 26,
182 + flexGrow: 1,
160 183 },
161 − 'SPB',
184 + markImg(110),
185 + h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
186 + h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`),
187 + h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'),
162 188 ),
163 − h('div', { fontSize: 70, fontWeight: 700, color: PALETTE.text }, 'SPB Git'),
164 − h('div', { fontSize: 32, color: PALETTE.muted }, `${OWNER.name} · ${OWNER.email}`),
165 − h('div', { fontSize: 28, color: PALETTE.accent2, fontFamily: 'JetBrains Mono' }, 'git.spboucher.ai'),
189 + h('div', { width: '100%', height: 14, backgroundImage: GRADIENT }),
166 190 );
167 191 }
168 192
@@ -174,7 +198,7 @@ function truncate(text, max) {
174 198 /** Minimal static SVG used when fonts were never downloaded. */
175 199 function fallbackSvg(title) {
176 200 const safe = String(title).replace(/[<>&"]/g, '');
177 − return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><rect width="${WIDTH}" height="${HEIGHT}" fill="${PALETTE.bg}"/><rect y="${HEIGHT - 14}" width="${WIDTH}" height="14" fill="${PALETTE.accent}"/><text x="64" y="330" font-family="sans-serif" font-size="72" font-weight="bold" fill="${PALETTE.text}">${safe}</text><text x="64" y="400" font-family="sans-serif" font-size="30" fill="${PALETTE.muted}">git.spboucher.ai</text></svg>`;
201 + return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}"><defs><linearGradient id="strip" x1="0" y1="0" x2="${WIDTH}" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${PALETTE.accent3}"/><stop offset="0.3" stop-color="${PALETTE.accent}"/><stop offset="0.62" stop-color="${PALETTE.cyan}"/><stop offset="1" stop-color="${PALETTE.accent2}"/></linearGradient></defs><rect width="${WIDTH}" height="${HEIGHT}" fill="${PALETTE.bg}"/><rect y="${HEIGHT - 14}" width="${WIDTH}" height="14" fill="url(#strip)"/><text x="64" y="330" font-family="sans-serif" font-size="72" font-weight="bold" fill="${PALETTE.text}">${safe}</text><text x="64" y="400" font-family="sans-serif" font-size="30" fill="${PALETTE.muted}">git.spboucher.ai</text></svg>`;
178 202 }
179 203
180 204 /**
@@ -200,30 +224,8 @@ export async function renderOgImage(repoData) {
200 224 * @param {number} size
201 225 * @returns {Promise<Buffer>}
202 226 */
203 −export async function renderMonogramPng(size) {
204 − const fonts = loadFonts();
205 − const svg = fonts
206 − ? await satori(
207 − h(
208 − 'div',
209 − {
210 − width: '100%',
211 − height: '100%',
212 − display: 'flex',
213 − alignItems: 'center',
214 − justifyContent: 'center',
215 − backgroundColor: PALETTE.accent,
216 − borderRadius: Math.round(size * 0.22),
217 − color: '#ffffff',
218 − fontFamily: 'Inter',
219 − fontWeight: 700,
220 − fontSize: Math.round(size * 0.42),
221 − },
222 − 'SPB',
223 − ),
224 − { width: size, height: size, fonts },
225 − )
226 − : `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${Math.round(size * 0.22)}" fill="${PALETTE.accent}"/></svg>`;
227 − const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: size } });
227 +export async function renderMonogramPng(size, opts = {}) {
228 + // Pure vector — no satori/fonts needed; always matches logo.svg exactly.
229 + const resvg = new Resvg(monogramSvg(size, opts), { fitTo: { mode: 'width', value: size } });
228 230 return resvg.render().asPng();
229 231 }
modified src/web/assets/img/logo.svg +19 −3
@@ -5,11 +5,27 @@
5 5 Author : Simon-Pierre Boucher
6 6 Contact : contact@spboucher.ai
7 7 File : src/web/assets/img/logo.svg
8 − Purpose : SPB monogram favicon / logo
8 + Purpose : SPB mark v3 — git-branch "S" with commit nodes on hot gradient
9 9 License : MIT © Simon-Pierre Boucher
10 10 ─────────────────────────────────────────────
11 11 -->
12 12 <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">
13 − <rect width="28" height="28" rx="7" fill="#4f8cff"/>
14 − <path d="M8 18.5c1 1.4 2.7 2.2 4.6 2.2 2.6 0 4.4-1.3 4.4-3.3 0-1.8-1.2-2.7-3.6-3.3l-1.6-.4c-1.4-.4-2-.9-2-1.8 0-1.1 1-1.8 2.5-1.8 1.4 0 2.5.6 3.2 1.7l1.9-1.3c-1-1.6-2.8-2.5-5-2.5C10 8 8.2 9.4 8.2 11.4c0 1.9 1.2 2.8 3.4 3.3l1.7.4c1.5.4 2.1.9 2.1 1.8 0 1.1-1.1 1.8-2.7 1.8-1.6 0-2.9-.7-3.7-2z" fill="#fff"/>
13 + <defs>
14 + <linearGradient id="spb-g" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse">
15 + <stop offset="0" stop-color="#c084fc"/>
16 + <stop offset="0.35" stop-color="#7c9aff"/>
17 + <stop offset="0.7" stop-color="#38cffd"/>
18 + <stop offset="1" stop-color="#34e3b0"/>
19 + </linearGradient>
20 + <linearGradient id="spb-gloss" x1="0" y1="0" x2="0" y2="28" gradientUnits="userSpaceOnUse">
21 + <stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
22 + <stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>
23 + </linearGradient>
24 + </defs>
25 + <rect width="28" height="28" rx="7" fill="url(#spb-g)"/>
26 + <rect width="28" height="28" rx="7" fill="url(#spb-gloss)"/>
27 + <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3"
28 + fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
29 + <circle cx="18.6" cy="8.3" r="2.05" fill="#ffffff"/>
30 + <circle cx="9.2" cy="19.3" r="2.05" fill="#ffffff"/>
15 31 </svg>
modified src/web/routes.mjs +1 −1
@@ -307,7 +307,7 @@ export async function registerWeb(app, ctx) {
307 307 const path = ctx.cache.path('global', 'favicon-180.png');
308 308 let buf = ctx.cache.getBuffer(path);
309 309 if (!buf) {
310 − buf = await renderMonogramPng(180);
310 + buf = await renderMonogramPng(180, { fullBleed: true });
311 311 ctx.cache.set(path, buf);
312 312 }
313 313 reply.type('image/png').header('Cache-Control', 'public, max-age=604800');
modified src/web/views/layout.njk +26 −4
@@ -46,8 +46,21 @@
46 46 <div class="container topnav-inner">
47 47 <a class="brand" href="/" aria-label="SPB Git home">
48 48 <svg class="brand-mark" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
49 − <rect width="28" height="28" rx="7" fill="var(--accent)"/>
50 − <path d="M8 18.5c1 1.4 2.7 2.2 4.6 2.2 2.6 0 4.4-1.3 4.4-3.3 0-1.8-1.2-2.7-3.6-3.3l-1.6-.4c-1.4-.4-2-.9-2-1.8 0-1.1 1-1.8 2.5-1.8 1.4 0 2.5.6 3.2 1.7l1.9-1.3c-1-1.6-2.8-2.5-5-2.5C10 8 8.2 9.4 8.2 11.4c0 1.9 1.2 2.8 3.4 3.3l1.7.4c1.5.4 2.1.9 2.1 1.8 0 1.1-1.1 1.8-2.7 1.8-1.6 0-2.9-.7-3.7-2z" fill="#fff"/>
49 + <defs>
50 + <linearGradient id="spbg-nav" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse">
51 + <stop offset="0" stop-color="#c084fc"/><stop offset="0.35" stop-color="#7c9aff"/>
52 + <stop offset="0.7" stop-color="#38cffd"/><stop offset="1" stop-color="#34e3b0"/>
53 + </linearGradient>
54 + <linearGradient id="spbg-nav-gloss" x1="0" y1="0" x2="0" y2="28" gradientUnits="userSpaceOnUse">
55 + <stop offset="0" stop-color="#fff" stop-opacity="0.28"/><stop offset="0.55" stop-color="#fff" stop-opacity="0"/>
56 + </linearGradient>
57 + </defs>
58 + <rect width="28" height="28" rx="7" fill="url(#spbg-nav)"/>
59 + <rect width="28" height="28" rx="7" fill="url(#spbg-nav-gloss)"/>
60 + <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3"
61 + fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
62 + <circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/>
63 + <circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/>
51 64 </svg>
52 65 <span class="brand-name">SPB Git</span>
53 66 </a>
@@ -72,8 +85,17 @@
72 85 <div class="container footer-inner">
73 86 <span class="footer-brand">
74 87 <svg width="18" height="18" viewBox="0 0 28 28" aria-hidden="true">
75 − <rect width="28" height="28" rx="7" fill="var(--accent)"/>
76 − <path d="M8 18.5c1 1.4 2.7 2.2 4.6 2.2 2.6 0 4.4-1.3 4.4-3.3 0-1.8-1.2-2.7-3.6-3.3l-1.6-.4c-1.4-.4-2-.9-2-1.8 0-1.1 1-1.8 2.5-1.8 1.4 0 2.5.6 3.2 1.7l1.9-1.3c-1-1.6-2.8-2.5-5-2.5C10 8 8.2 9.4 8.2 11.4c0 1.9 1.2 2.8 3.4 3.3l1.7.4c1.5.4 2.1.9 2.1 1.8 0 1.1-1.1 1.8-2.7 1.8-1.6 0-2.9-.7-3.7-2z" fill="#fff"/>
88 + <defs>
89 + <linearGradient id="spbg-ft" x1="0" y1="0" x2="28" y2="28" gradientUnits="userSpaceOnUse">
90 + <stop offset="0" stop-color="#c084fc"/><stop offset="0.35" stop-color="#7c9aff"/>
91 + <stop offset="0.7" stop-color="#38cffd"/><stop offset="1" stop-color="#34e3b0"/>
92 + </linearGradient>
93 + </defs>
94 + <rect width="28" height="28" rx="7" fill="url(#spbg-ft)"/>
95 + <path d="M 18.6 8.3 C 17.6 6.9 15.9 6.1 13.9 6.1 C 11.1 6.1 9.2 7.7 9.2 9.8 C 9.2 11.8 10.7 12.9 13.5 13.55 L 14.5 13.78 C 17.3 14.45 18.8 15.6 18.8 17.7 C 18.8 20 16.9 21.6 14.1 21.6 C 11.9 21.6 10.2 20.8 9.2 19.3"
96 + fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
97 + <circle cx="18.6" cy="8.3" r="2.05" fill="#fff"/>
98 + <circle cx="9.2" cy="19.3" r="2.05" fill="#fff"/>
77 99 </svg>
78 100 SPB Git
79 101 </span>
80 102