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