diff --git a/.gitignore b/.gitignore index 385f91464..502f49f54 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 96f8bdcd9..909b264f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`: diff --git a/scripts/generate-og-image.js b/scripts/generate-og-image.js index e22536425..8e37591bd 100644 --- a/scripts/generate-og-image.js +++ b/scripts/generate-og-image.js @@ -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 = ` - + - - - - - + + + + + -
-
-
-
Impeccable
-
Design fluency for AI harnesses
-
-
-
- ${commands} commands - · - Chrome extension - · - CLI -
-
impeccable.style
-
+
+
+
+
+ + + + + + + Impeccable
-
- Impeccable Chrome extension detection panel +
+

Design fluency for
every AI harness.

+

Stop shipping generic frontend. A design skill, CLI, and Chrome extension for the tools you already build with.

+
+
+
${commands} commands·Skill·CLI·Extension
+
impeccable.style
@@ -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)`); diff --git a/site/layouts/Base.astro b/site/layouts/Base.astro index a09943e37..2976dd10d 100644 --- a/site/layouts/Base.astro +++ b/site/layouts/Base.astro @@ -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; --- @@ -60,26 +66,20 @@ const canonical = canonicalPath {noIndex && } {canonical && } - {ogTitle && ( - <> - - - - - {ogImage && } - - )} + + + + + + + - {twitterSite && ( - <> - - - {twitterCreator && } - - - {ogImage && } - - )} + + {twitterSite && } + {twitterCreator && } + + + diff --git a/site/pages/index.astro b/site/pages/index.astro index 029901ea8..b76167248 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -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" > diff --git a/site/public/og-image-v2.jpg b/site/public/og-image-v2.jpg index 9b88826eb..c638aea3d 100644 Binary files a/site/public/og-image-v2.jpg and b/site/public/og-image-v2.jpg differ