1#!/usr/bin/env node
2
3/**
4 * Generate OG Image
5 *
6 * Renders the OG image using Playwright with proper Google Fonts.
7 * Counts commands dynamically from the source/ directory and composes
8 * the wordmark alongside a real screenshot of the Chrome extension
9 * detection panel from public/assets/extension-detection.png.
10 *
11 * Usage: bun run og-image
12 */
13
14import { chromium } from 'playwright';
15import path from 'path';
16import fs from 'fs';
17import { fileURLToPath } from 'url';
18
19const __filename = fileURLToPath(import.meta.url);
20const __dirname = path.dirname(__filename);
21const ROOT_DIR = path.resolve(__dirname, '..');
22const OUTPUT_PATH = path.join(ROOT_DIR, 'site', 'public', 'og-image.jpg');
23const EXTENSION_IMAGE_PATH = path.join(
24 ROOT_DIR,
25 'public',
26 'assets',
27 'extension-detection.png',
28);
29
30// Count sub-commands from skill/scripts/command-metadata.json (the post-v3.0
31// single source of truth). Commands and skills were unified in v2.0; v3.0
32// then collapsed to a single user-invocable skill (`impeccable`) with
33// sub-commands listed in command-metadata.json.
34function getCommandCount() {
35 const metadataPath = path.join(ROOT_DIR, 'skill', 'scripts', 'command-metadata.json');
36 if (!fs.existsSync(metadataPath)) return 0;
37 const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
38 return Object.keys(metadata).length;
39}
40
41// Load extension screenshot as base64 data URL so setContent is self-contained
42function getExtensionDataUrl() {
43 const buf = fs.readFileSync(EXTENSION_IMAGE_PATH);
44 return `data:image/png;base64,${buf.toString('base64')}`;
45}
46
47async function generateOgImage() {
48 const commands = getCommandCount();
49 const extensionDataUrl = getExtensionDataUrl();
50 console.log(`Detected ${commands} command(s)`);
51
52 const html = `<!DOCTYPE html>
53<html>
54<head>
55 <meta charset="UTF-8">
56 <link rel="preconnect" href="https://fonts.googleapis.com">
57 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
58 <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&family=Instrument+Sans:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
59 <style>
60 * { margin: 0; padding: 0; box-sizing: border-box; }
61
62 body {
63 width: 1200px;
64 height: 630px;
65 overflow: hidden;
66 background: #f5f2ee;
67 position: relative;
68 font-family: 'Instrument Sans', system-ui, sans-serif;
69 -webkit-font-smoothing: antialiased;
70 color: #1a1a1a;
71 }
72
73 /* Soft brand glow for depth — subtle magenta accents in opposite corners */
74 body::before {
75 content: '';
76 position: absolute;
77 inset: 0;
78 background:
79 radial-gradient(circle at 15% 8%, rgba(200, 50, 120, 0.07) 0%, transparent 55%),
80 radial-gradient(circle at 90% 95%, rgba(200, 50, 120, 0.05) 0%, transparent 60%);
81 pointer-events: none;
82 }
83
84 .container {
85 position: relative;
86 width: 100%;
87 height: 100%;
88 padding: 72px 80px;
89 }
90
91 .content {
92 position: relative;
93 display: flex;
94 flex-direction: column;
95 justify-content: space-between;
96 height: 100%;
97 max-width: 560px;
98 z-index: 2;
99 }
100
101 .top {
102 display: flex;
103 flex-direction: column;
104 gap: 22px;
105 }
106
107 .title {
108 font-family: 'Cormorant Garamond', Georgia, serif;
109 font-size: 108px;
110 font-weight: 300;
111 font-style: italic;
112 color: #1a1a1a;
113 letter-spacing: -0.03em;
114 line-height: 0.95;
115 }
116
117 .tagline {
118 font-family: 'Cormorant Garamond', Georgia, serif;
119 font-size: 34px;
120 font-weight: 400;
121 font-style: italic;
122 color: #3a3a3a;
123 line-height: 1.3;
124 max-width: 480px;
125 }
126
127 .bottom {
128 display: flex;
129 flex-direction: column;
130 gap: 14px;
131 }
132
133 .features {
134 display: flex;
135 align-items: center;
136 gap: 14px;
137 font-family: 'Space Grotesk', monospace;
138 font-size: 19px;
139 font-weight: 500;
140 color: #1a1a1a;
141 letter-spacing: 0.005em;
142 }
143
144 .feature-sep {
145 color: #c83278;
146 font-weight: 400;
147 font-size: 22px;
148 line-height: 1;
149 }
150
151 .url {
152 font-family: 'Space Grotesk', monospace;
153 font-size: 16px;
154 color: #999;
155 letter-spacing: 0.02em;
156 }
157
158 /* Extension panel — floating product shot, anchored right-center */
159 .panel {
160 position: absolute;
161 right: 54px;
162 top: 50%;
163 width: 500px;
164 transform: translateY(-50%) rotate(-2deg);
165 border-radius: 14px;
166 overflow: hidden;
167 box-shadow:
168 0 1px 2px rgba(0, 0, 0, 0.05),
169 0 6px 14px rgba(0, 0, 0, 0.06),
170 0 24px 48px -10px rgba(30, 15, 25, 0.18),
171 0 48px 100px -20px rgba(200, 50, 120, 0.14);
172 z-index: 1;
173 }
174
175 .panel::after {
176 content: '';
177 position: absolute;
178 inset: 0;
179 border-radius: 14px;
180 border: 1px solid rgba(0, 0, 0, 0.08);
181 pointer-events: none;
182 }
183
184 .panel img {
185 display: block;
186 width: 100%;
187 height: auto;
188 }
189 </style>
190</head>
191<body>
192 <div class="container">
193 <div class="content">
194 <div class="top">
195 <div class="title">Impeccable</div>
196 <div class="tagline">Design fluency for AI harnesses</div>
197 </div>
198 <div class="bottom">
199 <div class="features">
200 <span>${commands} commands</span>
201 <span class="feature-sep">·</span>
202 <span>Chrome extension</span>
203 <span class="feature-sep">·</span>
204 <span>CLI</span>
205 </div>
206 <div class="url">impeccable.style</div>
207 </div>
208 </div>
209 <div class="panel">
210 <img src="${extensionDataUrl}" alt="Impeccable Chrome extension detection panel">
211 </div>
212 </div>
213</body>
214</html>`;
215
216 const browser = await chromium.launch();
217 const page = await browser.newPage({
218 viewport: { width: 1200, height: 630 },
219 deviceScaleFactor: 1,
220 });
221
222 await page.setContent(html, { waitUntil: 'networkidle' });
223
224 // Wait for fonts to load
225 await page.evaluate(() => document.fonts.ready);
226
227 await page.screenshot({
228 path: OUTPUT_PATH,
229 type: 'jpeg',
230 quality: 90,
231 });
232
233 await browser.close();
234
235 const size = (fs.statSync(OUTPUT_PATH).size / 1024).toFixed(0);
236 console.log(`Generated ${OUTPUT_PATH} (${size} KB)`);
237}
238
239generateOgImage().catch((err) => {
240 console.error('Failed to generate OG image:', err);
241 process.exit(1);
242});