feat(site): new Neo Kinpaku social card, sitewide OG default

Replaces the retired light/magenta OG card with a brand-true Kinpaku
card (lacquer ground, champagne Alumni Sans headline, kinpaku-gold
accent, kintsugi-seam art). Headline: "Design fluency for every AI
harness." Command count is read live from command-metadata.json.

- scripts/generate-og-image.js: rewritten to render the Kinpaku card
  via Playwright at 2x and downscale with sharp; outputs og-image-v2.jpg
- Base.astro: emit og:image + summary_large_image on every page with a
  sitewide default (was homepage-only); pages override via ogImage prop
- og-image.jpg renamed to og-image-v2.jpg for cache-busting; index.astro
  reference updated
- CLAUDE.md: document `bun run og-image` regeneration + cache-bust steps
- .gitignore: ignore .og-build scratch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-05-28 17:19:27 -07:00
co-authored by Claude Opus 4.8
parent 613b45ad03
commit 92b744beb0
6 changed files with 162 additions and 207 deletions
+119 -187
View File
@@ -1,36 +1,39 @@
#!/usr/bin/env node
/**
* Generate OG Image
* Generate OG Image (Neo Kinpaku brand)
*
* Renders the OG image using Playwright with proper Google Fonts.
* Counts commands dynamically from the source/ directory and composes
* the wordmark alongside a real screenshot of the Chrome extension
* detection panel from public/assets/extension-detection.png.
* Renders the social sharing card with Playwright using the real Kinpaku
* tokens (lacquer ground, champagne headline, kinpaku-gold accent) and the
* kintsugi-seam hero art. Renders at 2x and downscales with sharp for crisp
* text. The command count is read live from command-metadata.json so it can
* never go stale.
*
* Output: site/public/og-image-v2.jpg (the cache-busted filename Base.astro
* and index.astro reference). Bump the version suffix here and in those two
* files together when you want social scrapers to re-fetch a fresh card.
*
* Usage: bun run og-image
*/
import { chromium } from 'playwright';
import sharp from 'sharp';
import os from 'os';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '..');
const OUTPUT_PATH = path.join(ROOT_DIR, 'site', 'public', 'og-image.jpg');
const EXTENSION_IMAGE_PATH = path.join(
const OUTPUT_PATH = path.join(ROOT_DIR, 'site', 'public', 'og-image-v2.jpg');
const ART_PATH = path.join(
ROOT_DIR,
'public',
'assets',
'extension-detection.png',
'site', 'public', 'assets', 'neo-kinpaku', 'candidates', 'finalists', 'm-01-v2-01.png',
);
// Count sub-commands from skill/scripts/command-metadata.json (the post-v3.0
// single source of truth). Commands and skills were unified in v2.0; v3.0
// then collapsed to a single user-invocable skill (`impeccable`) with
// sub-commands listed in command-metadata.json.
// single source of truth), so the card's "N commands" tracks the real total.
function getCommandCount() {
const metadataPath = path.join(ROOT_DIR, 'skill', 'scripts', 'command-metadata.json');
if (!fs.existsSync(metadataPath)) return 0;
@@ -38,176 +41,101 @@ function getCommandCount() {
return Object.keys(metadata).length;
}
// Load extension screenshot as base64 data URL so setContent is self-contained
function getExtensionDataUrl() {
const buf = fs.readFileSync(EXTENSION_IMAGE_PATH);
return `data:image/png;base64,${buf.toString('base64')}`;
}
async function generateOgImage() {
const commands = getCommandCount();
const extensionDataUrl = getExtensionDataUrl();
// Reference the art by file:// URL (not base64): goto + networkidle then
// genuinely waits for it to load, where a data URL emits no network event
// and paints black before it decodes.
const artUrl = pathToFileURL(ART_PATH).href;
console.log(`Detected ${commands} command(s)`);
const html = `<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
overflow: hidden;
background: #f5f2ee;
position: relative;
font-family: 'Instrument Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
color: #1a1a1a;
}
/* Soft brand glow for depth — subtle magenta accents in opposite corners */
body::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 15% 8%, rgba(200, 50, 120, 0.07) 0%, transparent 55%),
radial-gradient(circle at 90% 95%, rgba(200, 50, 120, 0.05) 0%, transparent 60%);
pointer-events: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
padding: 72px 80px;
}
.content {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 560px;
z-index: 2;
}
.top {
display: flex;
flex-direction: column;
gap: 22px;
}
.title {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 108px;
font-weight: 300;
font-style: italic;
color: #1a1a1a;
letter-spacing: -0.03em;
line-height: 0.95;
}
.tagline {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 34px;
font-weight: 400;
font-style: italic;
color: #3a3a3a;
line-height: 1.3;
max-width: 480px;
}
.bottom {
display: flex;
flex-direction: column;
gap: 14px;
}
.features {
display: flex;
align-items: center;
gap: 14px;
font-family: 'Space Grotesk', monospace;
font-size: 19px;
font-weight: 500;
color: #1a1a1a;
letter-spacing: 0.005em;
}
.feature-sep {
color: #c83278;
font-weight: 400;
font-size: 22px;
line-height: 1;
}
.url {
font-family: 'Space Grotesk', monospace;
font-size: 16px;
color: #999;
letter-spacing: 0.02em;
}
/* Extension panel — floating product shot, anchored right-center */
.panel {
position: absolute;
right: 54px;
top: 50%;
width: 500px;
transform: translateY(-50%) rotate(-2deg);
border-radius: 14px;
overflow: hidden;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.05),
0 6px 14px rgba(0, 0, 0, 0.06),
0 24px 48px -10px rgba(30, 15, 25, 0.18),
0 48px 100px -20px rgba(200, 50, 120, 0.14);
z-index: 1;
}
.panel::after {
content: '';
position: absolute;
inset: 0;
border-radius: 14px;
border: 1px solid rgba(0, 0, 0, 0.08);
pointer-events: none;
}
.panel img {
display: block;
width: 100%;
height: auto;
}
</style>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Alumni+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--ks-kinpaku: oklch(84% 0.19 80.46);
--ks-lacquer: oklch(7% 0.006 95);
--ks-champagne: oklch(84% 0.035 82);
--ks-text: oklch(81% 0.03 82);
--ks-muted: oklch(63% 0.024 82);
--ks-font: "Albert Sans", system-ui, sans-serif;
--ks-display: "Alumni Sans", "Albert Sans", sans-serif;
}
* { margin: 0; box-sizing: border-box; }
html, body { width: 1200px; height: 630px; }
body {
position: relative; overflow: hidden;
background: var(--ks-lacquer);
font-family: var(--ks-font);
-webkit-font-smoothing: antialiased;
}
/* Kintsugi seam art, full bleed, with a left-to-right lacquer scrim so the
text column stays legible while the gold seam reads on the right. */
.art {
position: absolute; inset: 0; z-index: 0;
background:
linear-gradient(101deg,
var(--ks-lacquer) 0%,
var(--ks-lacquer) 30%,
oklch(7% 0.006 95 / 0.55) 50%,
oklch(7% 0.006 95 / 0) 78%),
url("${artUrl}") center / cover no-repeat;
filter: saturate(1.18) contrast(1.06);
}
/* Hairline gold frame inset, the kit's "oxidation edge" cue. */
.frame { position: absolute; inset: 0; z-index: 2; box-shadow: inset 0 0 0 1px oklch(84% 0.19 80.46 / 0.16); }
.stage { position: absolute; inset: 0; z-index: 1; padding: 76px 80px; display: flex; flex-direction: column; }
.brand { display: flex; align-items: center; gap: 6px; }
.mark { width: 40px; height: 40px; color: var(--ks-kinpaku); display: grid; place-items: center; }
.mark svg { width: 34px; height: 34px; display: block; }
.wordmark {
color: var(--ks-kinpaku); font-family: var(--ks-display); font-weight: 600;
font-size: 27px; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1;
}
.headline-wrap { margin-top: auto; margin-bottom: auto; }
.headline {
color: var(--ks-champagne); font-family: var(--ks-display); font-weight: 500;
font-size: 82px; line-height: 1.0; letter-spacing: -0.012em; max-width: 720px;
}
.sub {
margin-top: 26px; color: var(--ks-muted); font-size: 27px; font-weight: 400;
line-height: 1.38; max-width: 540px; letter-spacing: 0.005em;
}
.meta { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
.meta-left { color: var(--ks-text); font-size: 21px; font-weight: 500; letter-spacing: 0.01em; }
.meta-left .dot { color: var(--ks-kinpaku); padding: 0 10px; }
.meta-left .lead { color: var(--ks-champagne); font-weight: 600; }
.domain {
color: var(--ks-kinpaku); font-family: var(--ks-display); font-weight: 600;
font-size: 23px; letter-spacing: 0.06em;
}
</style>
</head>
<body>
<div class="container">
<div class="content">
<div class="top">
<div class="title">Impeccable</div>
<div class="tagline">Design fluency for AI harnesses</div>
</div>
<div class="bottom">
<div class="features">
<span>${commands} commands</span>
<span class="feature-sep">·</span>
<span>Chrome extension</span>
<span class="feature-sep">·</span>
<span>CLI</span>
</div>
<div class="url">impeccable.style</div>
</div>
<div class="art"></div>
<div class="frame"></div>
<div class="stage">
<div class="brand">
<span class="mark">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/>
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/>
</svg>
</span>
<span class="wordmark">Impeccable</span>
</div>
<div class="panel">
<img src="${extensionDataUrl}" alt="Impeccable Chrome extension detection panel">
<div class="headline-wrap">
<h1 class="headline">Design fluency for<br>every AI harness.</h1>
<p class="sub">Stop shipping generic frontend. A design skill, CLI, and Chrome extension for the tools you already build with.</p>
</div>
<div class="meta">
<div class="meta-left"><span class="lead">${commands} commands</span><span class="dot">&middot;</span>Skill<span class="dot">&middot;</span>CLI<span class="dot">&middot;</span>Extension</div>
<div class="domain">impeccable.style</div>
</div>
</div>
</body>
@@ -216,21 +144,25 @@ async function generateOgImage() {
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
deviceScaleFactor: 2,
});
await page.setContent(html, { waitUntil: 'networkidle' });
// Write to a temp file and load via file:// so networkidle waits for the
// art (a file:// page can reference file:// resources; data: cannot).
const tmpHtml = path.join(os.tmpdir(), `impeccable-og-${process.pid}.html`);
fs.writeFileSync(tmpHtml, html);
try {
await page.goto(pathToFileURL(tmpHtml).href, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(200);
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
await page.screenshot({
path: OUTPUT_PATH,
type: 'jpeg',
quality: 90,
});
await browser.close();
// Screenshot at 2x (2400x1260), then downscale to 1200x630 for crisp text.
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: 1200, height: 630 } });
await browser.close();
await sharp(buf).resize(1200, 630).jpeg({ quality: 86 }).toFile(OUTPUT_PATH);
} finally {
fs.rmSync(tmpHtml, { force: true });
}
const size = (fs.statSync(OUTPUT_PATH).size / 1024).toFixed(0);
console.log(`Generated ${OUTPUT_PATH} (${size} KB)`);