mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
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:
co-authored by
Claude Opus 4.8
parent
613b45ad03
commit
92b744beb0
@@ -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/
|
||||
|
||||
@@ -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
@@ -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">·</span>Skill<span class="dot">·</span>CLI<span class="dot">·</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
@@ -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">
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user