Skip to content

Commit bba21ee

Browse files
committed
docs: add a GitHub social preview card
Generated the same way as the docs screenshots — a headless Chromium render of a self-contained artboard — so it stays in the brand's hands rather than being a one-off export nobody can reproduce. Reuses the "Emitting" mark, the indigo/amber duotone and the receding PV array from the docs hero. 1280x640, written to docs/images/social-preview.png. GitHub does not read this from the repo: it has to be uploaded under Settings -> General -> Social preview. Regenerate with `npm run social` from site/. Claude-Session: https://claude.ai/code/session_01RgSnMCa3JQigphGnPbazdw
1 parent 6ed05b9 commit bba21ee

4 files changed

Lines changed: 234 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ site/.astro/
1616
# Docs screenshots are regenerated from components/tigo_server/web/app.html
1717
# on every docs build — never commit them, they would only go stale.
1818
site/src/assets/screenshots/
19+
# Intermediate artboard for the social card; the PNG it produces IS committed.
20+
site/screenshots/social-preview.html

docs/images/social-preview.png

615 KB
Loading

site/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"build": "npm run screenshots && astro build",
88
"preview": "astro preview",
99
"test": "node --test test/*.test.mjs",
10-
"screenshots": "node screenshots/capture.mjs"
10+
"screenshots": "node screenshots/capture.mjs",
11+
"social": "node screenshots/social-preview.mjs"
1112
},
1213
"dependencies": {
1314
"@astrojs/starlight": "^0.41.4",
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Render the GitHub social preview card (docs/images/social-preview.png).
2+
//
3+
// GitHub wants >=640x320 and shows 1280x640 best, so that is the artboard.
4+
// The card is built from the same brand pieces as the docs site — the
5+
// "Emitting" mark, the silicon-indigo/solar-amber duotone, and the receding
6+
// PV array from site/src/styles/theme.css — so the repo page, the docs hero
7+
// and the device UI all read as one thing.
8+
//
9+
// Fonts are inlined as data URIs from node_modules rather than linked, because
10+
// this renders from a file:// page with no dev server and a silent fallback to
11+
// a system face would be invisible until the image was already published.
12+
//
13+
// Run: npm run social (from site/)
14+
// Out: docs/images/social-preview.png — COMMITTED; set it on the repo under
15+
// Settings → General → Social preview. GitHub does not read it from the
16+
// repo automatically.
17+
18+
import { chromium } from 'playwright';
19+
import { readFileSync, writeFileSync } from 'node:fs';
20+
import { resolve, dirname } from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
23+
const HERE = dirname(fileURLToPath(import.meta.url));
24+
const OUT = resolve(HERE, '../../docs/images/social-preview.png');
25+
const HTML = resolve(HERE, 'social-preview.html');
26+
27+
const font = (p) =>
28+
`url(data:font/woff2;base64,${readFileSync(resolve(HERE, '../node_modules', p)).toString('base64')}) format('woff2')`;
29+
30+
const DISPLAY = font('@fontsource-variable/big-shoulders-display/files/big-shoulders-display-latin-wght-normal.woff2');
31+
const SANS = font('@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff2');
32+
const MONO = font('@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2');
33+
34+
// The mark, at the full-detail cut — at 128px the six-block frame reads, so
35+
// this is the docs/images/logo.svg drawing rather than the collapsed header one.
36+
const MARK = `
37+
<svg viewBox="0 0 64 64" class="mark" role="img" aria-label="Tigo Monitor">
38+
<rect x="9" y="17" width="34" height="28" rx="5" fill="#1D2A63" stroke="#C6D0E2" stroke-width="3.5"/>
39+
<rect x="16" y="24" width="20" height="5" rx="1" fill="#C6D0E2" fill-opacity=".65"/>
40+
<rect x="16" y="34" width="13" height="5" rx="1" fill="#F4B23C"/>
41+
<g fill="none" stroke="#F4B23C" stroke-width="4" stroke-linecap="round">
42+
<path d="M48 25 a11 11 0 0 1 0 14"/>
43+
<path d="M53 19 a19 19 0 0 1 0 26"/>
44+
</g>
45+
<g fill="none" stroke="#C6D0E2" stroke-width="4.5" stroke-linecap="round">
46+
<path d="M17 45 v5 a4 4 0 0 1 -4 4 h-4"/>
47+
<path d="M35 45 v5 a4 4 0 0 0 4 4 h4"/>
48+
</g>
49+
</svg>`;
50+
51+
const html = `<!doctype html>
52+
<meta charset="utf-8">
53+
<style>
54+
@font-face { font-family: 'Big Shoulders Display'; src: ${DISPLAY}; font-weight: 100 900; }
55+
@font-face { font-family: 'IBM Plex Sans'; src: ${SANS}; font-weight: 400; }
56+
@font-face { font-family: 'IBM Plex Mono'; src: ${MONO}; font-weight: 500; }
57+
58+
:root {
59+
--cell: #1d2a63;
60+
--busbar: #c6d0e2;
61+
--frame: #b9c6e4;
62+
--gap: #05070d;
63+
--amber: #f4b23c;
64+
--indigo: #4c63c4;
65+
--silver: #c6d0e2;
66+
}
67+
* { margin: 0; padding: 0; box-sizing: border-box; }
68+
html, body { width: 1280px; height: 640px; }
69+
body {
70+
background: linear-gradient(160deg, #0d1424 0%, #05070d 60%);
71+
font-family: 'IBM Plex Sans', sans-serif;
72+
overflow: hidden;
73+
}
74+
75+
.card { position: relative; width: 1280px; height: 640px; isolation: isolate; }
76+
77+
/* The PV array, same construction as the docs hero: cell gaps at 56px, busbars
78+
on the 14px quarter-pitch, and module edges drawn frame/gap/frame so two
79+
modules actually separate instead of reading as one more cell line. Here it
80+
is pushed to the right half and tipped harder, so it sits behind the type
81+
without competing with it. */
82+
.array {
83+
position: absolute;
84+
inset: -30% -25% -10% 30%;
85+
z-index: -1;
86+
transform: perspective(900px) rotateX(38deg) rotateZ(-4deg) scale(1.2);
87+
transform-origin: 50% 100%;
88+
background:
89+
repeating-linear-gradient(90deg, transparent 0 13px,
90+
color-mix(in srgb, var(--busbar) 14%, transparent) 13px 14px),
91+
repeating-linear-gradient(0deg, transparent 0 53px,
92+
color-mix(in srgb, var(--busbar) 13%, transparent) 53px 56px),
93+
repeating-linear-gradient(90deg, transparent 0 53px,
94+
color-mix(in srgb, var(--busbar) 13%, transparent) 53px 56px),
95+
repeating-linear-gradient(0deg, transparent 0 213px,
96+
color-mix(in srgb, var(--frame) 30%, transparent) 213px 217px,
97+
color-mix(in srgb, var(--gap) 70%, transparent) 217px 220px,
98+
color-mix(in srgb, var(--frame) 30%, transparent) 220px 224px),
99+
repeating-linear-gradient(90deg, transparent 0 325px,
100+
color-mix(in srgb, var(--frame) 30%, transparent) 325px 329px,
101+
color-mix(in srgb, var(--gap) 70%, transparent) 329px 332px,
102+
color-mix(in srgb, var(--frame) 30%, transparent) 332px 336px),
103+
linear-gradient(0deg, color-mix(in srgb, var(--cell) 58%, transparent),
104+
color-mix(in srgb, var(--cell) 58%, transparent));
105+
-webkit-mask-image: radial-gradient(85% 75% at 62% 55%, #000 10%, transparent 82%);
106+
mask-image: radial-gradient(85% 75% at 62% 55%, #000 10%, transparent 82%);
107+
}
108+
109+
/* Low amber sun, top right — the one warm thing in the frame, and the same
110+
signal colour the UI uses for live telemetry. */
111+
.sun {
112+
position: absolute; inset: 0; z-index: -1;
113+
background:
114+
radial-gradient(38% 46% at 84% 12%, color-mix(in srgb, var(--amber) 30%, transparent), transparent 70%),
115+
radial-gradient(50% 50% at 18% 22%, color-mix(in srgb, var(--indigo) 22%, transparent), transparent 72%);
116+
}
117+
118+
.content {
119+
position: absolute;
120+
left: 84px; top: 0; bottom: 0;
121+
width: 780px;
122+
display: flex; flex-direction: column; justify-content: center;
123+
gap: 26px;
124+
}
125+
126+
/* The mark hangs off the cap-height of TIGO rather than the centre of the
127+
two-line wordmark — centred, it floats in the gap between the lines. */
128+
.lockup { display: flex; align-items: flex-start; gap: 24px; }
129+
.mark { width: 108px; height: 108px; flex: none; margin-top: -14px; }
130+
131+
.wordmark {
132+
font-family: 'Big Shoulders Display', sans-serif;
133+
font-weight: 700;
134+
font-size: 116px;
135+
line-height: 0.84;
136+
letter-spacing: -0.005em;
137+
color: #f2f5fb;
138+
text-wrap: balance;
139+
}
140+
.wordmark span { display: block; color: var(--silver); }
141+
142+
.tagline {
143+
font-size: 27px;
144+
line-height: 1.38;
145+
color: #b6c0d4;
146+
max-width: 726px;
147+
}
148+
.tagline b { color: #eef2f9; font-weight: 400; }
149+
150+
.chips { display: flex; gap: 10px; flex-wrap: wrap; }
151+
.chip {
152+
font-family: 'IBM Plex Mono', monospace;
153+
font-weight: 500;
154+
font-size: 16px;
155+
letter-spacing: 0.12em;
156+
text-transform: uppercase;
157+
color: #a9b6d2;
158+
border: 1px solid color-mix(in srgb, var(--silver) 22%, transparent);
159+
border-radius: 999px;
160+
padding: 7px 16px 6px;
161+
background: color-mix(in srgb, #0b111f 55%, transparent);
162+
}
163+
.chip.live {
164+
color: #0d1424;
165+
background: var(--amber);
166+
border-color: var(--amber);
167+
}
168+
169+
/* A hairline of amber along the bottom edge: the telemetry frame arriving.
170+
It also gives the card a defined edge against light backgrounds. */
171+
.rule {
172+
position: absolute; left: 0; right: 0; bottom: 0; height: 6px;
173+
background: linear-gradient(90deg,
174+
var(--amber) 0%, var(--amber) 22%,
175+
color-mix(in srgb, var(--amber) 35%, transparent) 40%,
176+
color-mix(in srgb, var(--indigo) 45%, transparent) 70%,
177+
transparent 100%);
178+
}
179+
</style>
180+
<div class="card">
181+
<div class="sun"></div>
182+
<div class="array"></div>
183+
<div class="content">
184+
<div class="lockup">
185+
${MARK}
186+
<div class="wordmark">TIGO<span>MONITOR</span></div>
187+
</div>
188+
<p class="tagline">
189+
<b>Per-panel solar data in Home Assistant</b>, read straight off the
190+
Tigo RS485 bus. No cloud account, no Tigo API.
191+
</p>
192+
<div class="chips">
193+
<span class="chip live">ESPHome</span>
194+
<span class="chip">ESP32</span>
195+
<span class="chip">RS485</span>
196+
<span class="chip">Home Assistant</span>
197+
</div>
198+
</div>
199+
<div class="rule"></div>
200+
</div>`;
201+
202+
writeFileSync(HTML, html);
203+
204+
const browser = await chromium.launch({
205+
args: ['--no-sandbox', '--force-color-profile=srgb', '--font-render-hinting=none'],
206+
});
207+
const page = await browser.newPage({
208+
viewport: { width: 1280, height: 640 },
209+
deviceScaleFactor: 1,
210+
reducedMotion: 'reduce',
211+
});
212+
let failed = 0;
213+
page.on('pageerror', (e) => { failed++; console.error(` ! ${e.message}`); });
214+
await page.goto(`file://${HTML}`, { waitUntil: 'load' });
215+
await page.evaluate(() => document.fonts.ready);
216+
217+
// A missing @font-face silently falls back, and the card would look merely
218+
// "off" rather than broken. Assert the display face actually loaded.
219+
const gotDisplay = await page.evaluate(() =>
220+
document.fonts.check('700 116px "Big Shoulders Display"'));
221+
if (!gotDisplay) {
222+
console.error(' FAILED: Big Shoulders Display did not load — check node_modules paths.');
223+
process.exit(1);
224+
}
225+
226+
await page.screenshot({ path: OUT });
227+
await browser.close();
228+
229+
console.log(` ✓ 1280x640 → docs/images/social-preview.png`);
230+
if (failed) process.exit(1);

0 commit comments

Comments
 (0)