| 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 |
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 |
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 |
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 |
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 |
} |