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
+9
View File
@@ -106,3 +106,12 @@ site/public/js/generated/
# Local-only scratch for exploratory scripts, parked pages, and unused asset candidates.
tmp/
# Isolated ablation staging (impeccable-evals copies the built staged skill here
# per rule, removes one rule, points a worker at it). Throwaway; never committed.
.ablate-stage/
# Local scratch from iterating on the OG card (weight comparisons, intermediate
# PNGs, the old card backup). The canonical generator is `bun run og-image`
# (scripts/generate-og-image.js); this dir is throwaway and safe to delete.
.og-build/
+14
View File
@@ -88,6 +88,20 @@ Hosted on Cloudflare Pages. Static assets served from `build/`, API routes handl
bun run deploy # Build + deploy to Cloudflare Pages
```
## Social sharing image (OG card)
The OG / Twitter card is generated, not hand-drawn. To regenerate after a brand or copy change:
```bash
bun run og-image # → site/public/og-image-v2.jpg
```
`scripts/generate-og-image.js` renders an inline HTML card with Playwright (Neo Kinpaku brand: lacquer ground, champagne Alumni Sans headline, kinpaku-gold accent, the kintsugi-seam art from `site/public/assets/neo-kinpaku/candidates/finalists/m-01-v2-01.png`). It renders at 2× and downscales to 1200×630 with `sharp` for crisp text. The "N commands" figure is read live from `command-metadata.json`, so it never goes stale; don't hardcode it.
The card is referenced as a **sitewide default** in `site/layouts/Base.astro` (every page emits `og:image` + a `summary_large_image` Twitter card; pages may override via the `ogImage` prop). The homepage sets its own `ogImage` in `site/pages/index.astro`.
**Cache-busting:** social scrapers cache by URL, so the filename carries a `-v2` suffix. When you ship a visibly different card, bump the suffix in three places together (`scripts/generate-og-image.js` `OUTPUT_PATH`, `Base.astro` `SITE_OG_IMAGE`, `index.astro` `ogImage`) so X/LinkedIn/Slack re-fetch instead of serving the stale image. After deploy, prime the caches by running the URL through X's Post Inspector and LinkedIn's Post Inspector once.
## Build System
The build system compiles the impeccable skill from `skill/` to provider-specific formats in `dist/`:
+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)`);
+19 -19
View File
@@ -47,6 +47,12 @@ const {
const canonical = canonicalPath
? `https://impeccable.style${canonicalPath}`
: undefined;
// Social card defaults. Every page gets the brand card unless it passes its own.
const SITE_OG_IMAGE = 'https://impeccable.style/og-image-v2.jpg';
const ogImageUrl = ogImage || SITE_OG_IMAGE;
const resolvedOgTitle = ogTitle || title;
const resolvedOgDescription = ogDescription || description;
---
<!DOCTYPE html>
@@ -60,26 +66,20 @@ const canonical = canonicalPath
{noIndex && <meta name="robots" content="noindex">}
{canonical && <link rel="canonical" href={canonical}>}
{ogTitle && (
<>
<meta property="og:type" content="website">
<meta property="og:url" content={canonical || 'https://impeccable.style'}>
<meta property="og:title" content={ogTitle}>
<meta property="og:description" content={ogDescription || description}>
{ogImage && <meta property="og:image" content={ogImage}>}
</>
)}
<meta property="og:type" content="website">
<meta property="og:url" content={canonical || 'https://impeccable.style'}>
<meta property="og:title" content={resolvedOgTitle}>
<meta property="og:description" content={resolvedOgDescription}>
<meta property="og:image" content={ogImageUrl}>
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{twitterSite && (
<>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content={twitterSite}>
{twitterCreator && <meta name="twitter:creator" content={twitterCreator}>}
<meta name="twitter:title" content={ogTitle || title}>
<meta name="twitter:description" content={ogDescription || description}>
{ogImage && <meta name="twitter:image" content={ogImage}>}
</>
)}
<meta name="twitter:card" content="summary_large_image">
{twitterSite && <meta name="twitter:site" content={twitterSite}>}
{twitterCreator && <meta name="twitter:creator" content={twitterCreator}>}
<meta name="twitter:title" content={resolvedOgTitle}>
<meta name="twitter:description" content={resolvedOgDescription}>
<meta name="twitter:image" content={ogImageUrl}>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
+1 -1
View File
@@ -27,7 +27,7 @@ import '../styles/testimonials.css';
mainClass="site-content"
ogTitle="Impeccable: Design skills for AI harnesses"
ogDescription="1 skill, 23 commands, and curated anti-patterns for impeccable frontend design. Works with Cursor, Claude Code, Gemini CLI, and Codex CLI."
ogImage="https://impeccable.style/og-image.jpg"
ogImage="https://impeccable.style/og-image-v2.jpg"
twitterSite="@pbakaus"
twitterCreator="@pbakaus"
>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 122 KiB