feat(design): deploy default OG card (JPG/PNG), complete banner hygiene (E8), modernize CJK typography (E9), align title & hero (A2)
Deploy / build (push) Successful in 21s

This commit is contained in:
hoelee committed 2026-10-18 02:27:45 +08:00
1 parent c1aa62ecca
commit 3f63150d51
17 files changed
+564 -42

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/**
* og-default-gen — regenerate the site-wide default OG card (public/og-default.png).
*
* WHY: the card that shipped since 2026-09-06 still carried the retired brand name
* "hoelee.dev" and had no face. This is the fallback card for the homepage and the
* category pages (BaseLayout default + PostList fallback), so it is the share preview
* most people see when they link to the blog at all.
*
* DESIGN PROVENANCE: the layout in template-default.html is an outside delivery
* (2026-10-17, `hoelee-og-delivery`) — fixed-size 1200x630 HTML with inline CSS and a
* pixel spec. That spec is kept in-repo as `og-default-specs.md` beside this script.
* A previous in-house attempt was rejected on sight by the owner, so **do not redesign
* this card here** — change the template only against a new delivery.
*
* Usage:
* node scripts/og-gen/generate-default.mjs
*
* Renders template-default.html with the headshot + brand mark, MEASURES the layout in
* the real DOM (--dump-dom, never a screenshot read), enforces the spec's acceptance
* checks, then writes public/og-default.png (1200x630).
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
const fileUrl = (p) => 'file:///' + p.replace(/\\/g, '/');
const photo = fileUrl(join(ROOT, 'public', 'images', 'lee-teong-hoe-640.jpg'));
const logo = fileUrl(join(ROOT, 'public', 'logo-square.png'));
const chrome = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
for (const [label, p] of [['photo', photo], ['logo', logo]]) {
if (!existsSync(p.replace('file:///', ''))) {
console.error(`missing ${label} asset: ${p}`);
process.exit(1);
}
}
const tpl = readFileSync(join(__dirname, 'template-default.html'), 'utf8')
.replace('{{PHOTO_PATH}}', photo)
.replace('{{LOGO_PATH}}', logo);
if (/\{\{[A-Z_]+\}\}/.test(tpl)) {
console.error('unsubstituted placeholder left in the template:', tpl.match(/\{\{[A-Z_]+\}\}/g));
process.exit(1);
}
const outDir = join(ROOT, '.og', 'gen');
mkdirSync(outDir, { recursive: true });
// --- pass 1: measure the layout in the real DOM -------------------------------
const probe = tpl.replace('</body>', `
<script>
window.addEventListener('load', () => {
const r = (s) => { const e = document.querySelector(s); if (!e) return null;
const b = e.getBoundingClientRect();
return { top: Math.round(b.top), bottom: Math.round(b.bottom), left: Math.round(b.left),
right: Math.round(b.right), w: Math.round(b.width), h: Math.round(b.height),
fs: Math.round(parseFloat(getComputedStyle(e).fontSize)) }; };
const body = document.body;
document.title = 'MEASURE' + JSON.stringify({
scrollH: body.scrollHeight, clientH: body.clientHeight,
scrollW: body.scrollWidth, clientW: body.clientWidth,
header: r('.header'), h1: r('h1'), content: r('.left-content'),
portrait: r('.portrait-frame'), workstream: r('.workstream'), caption: r('.portrait-caption'),
text: body.innerText.slice(0, 400),
});
});
</script>
</body>`);
const probePath = join(outDir, 'og-default-probe.html');
writeFileSync(probePath, probe);
let dom = '';
try {
dom = execSync(`"${chrome}" --headless --disable-gpu --virtual-time-budget=4000 --dump-dom "${fileUrl(probePath)}"`,
{ stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }).toString();
} catch (e) {
console.error('probe render failed:', e.message);
process.exit(1);
}
const m = /MEASURE(\{.*?\})<\/title>/s.exec(dom);
if (!m) { console.error('probe did not report — no MEASURE payload in the DOM'); process.exit(1); }
const M = JSON.parse(m[1]);
// The payload rode inside document.title, so the DOM serialiser escaped `&` → `&amp;`.
M.text = M.text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
// --- pass 2: render the real card --------------------------------------------
const htmlPath = join(outDir, 'og-default.html');
writeFileSync(htmlPath, tpl);
const shot = join(outDir, 'og-default-raw.png');
try {
execSync(`"${chrome}" --headless --disable-gpu --force-device-scale-factor=1 --hide-scrollbars --window-size=1200,900 --virtual-time-budget=3000 --screenshot="${shot}" "${fileUrl(htmlPath)}"`,
{ stdio: 'pipe' });
} catch (e) {
console.error('Chrome render failed:', e.message);
process.exit(1);
}
const finalJpg = join(ROOT, 'public', 'og-default.jpg');
await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 })
.jpeg({ quality: 85, mozjpeg: true })
.toFile(finalJpg);
// Keep og-default.png as a backward-compatible alias / fallback
const finalPng = join(ROOT, 'public', 'og-default.png');
await sharp(shot).extract({ left: 0, top: 0, width: 1200, height: 630 }).toFile(finalPng);
// --- acceptance checks (spec: og-default-specs.md) ----------------------------
const lowest = Math.max(M.workstream.bottom, M.caption.bottom, M.portrait.bottom);
const checks = [
['no overflow', !(M.scrollH > M.clientH || M.scrollW > M.clientW),
`scrollH ${M.scrollH} vs ${M.clientH} · scrollW ${M.scrollW} vs ${M.clientW}`],
['safe margin >= 64px', M.content.left >= 64 && 1200 - M.portrait.right >= 64,
`left ${M.content.left} · right ${1200 - M.portrait.right}`],
['headline >= 48px', M.h1.fs >= 48, `${M.h1.fs}px`],
['bottom clear >= 64px', 630 - lowest >= 64, `lowest element ends at y=${lowest}`],
['retired wordmark gone', !/hoelee\.dev/i.test(M.text), 'no "hoelee.dev" in the rendered text'],
['identity lines present', /Mr Hoelee/.test(M.text) && /Lee Teong Hoe/.test(M.text)
&& /Full-stack Developer & DevOps Engineer/.test(M.text), 'Mr Hoelee / Lee Teong Hoe / job title'],
];
const failed = checks.filter(([, ok]) => !ok);
console.log(JSON.stringify(M, null, 1));
console.log('');
for (const [name, ok, detail] of checks) console.log(`${ok ? 'PASS' : 'FAIL'} ${name} — ${detail}`);
console.log(`\n✓ ${finalJpg}`);
const metaJpg = await sharp(finalJpg).metadata();
console.log(` size: ${metaJpg.width}x${metaJpg.height} ${metaJpg.format} (${Math.round(statSync(finalJpg).size / 1024)} KB)`);
console.log(`✓ ${finalPng}`);
const meta = await sharp(finalPng).metadata();
console.log(` size: ${meta.width}x${meta.height} ${meta.format} (${Math.round(statSync(finalPng).size / 1024)} KB)`);
if (failed.length) {
console.error(`\n✗ ${failed.length} acceptance check(s) failed — do NOT ship this card.`);
process.exit(2);
}
+8
View File
@@ -412,6 +412,14 @@ TERMINALS['embedding-fonts-so-a-pdf-looks-the-same-everywhere'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">before: one CJK face, bold was synthetic</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">8 static faces embedded</span><span class="fix">-> same look everywhere</span></div>`;
TERMINALS['why-500000-lines-of-commit-churn-only-left-230000-in-production'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">git log --author=hoelee --since='30 days' --numstat</span></div>
<div class="line"><span class="prompt">></span><span class="fix">865 commits: +518,233 additions / -130,892 deletions</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">cloc . --exclude-dir=vendor,dist</span></div>
<div class="line"><span class="prompt">></span><span class="fix">234,976 clean lines surviving in production</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">pytest && phpunit && ./verify-gates.sh</span></div>
<div class="line"><span class="prompt">></span><span class="fix">All test gates passed: 0 regressions</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
+41
View File
@@ -0,0 +1,41 @@
<!-- Kept in-repo from the 2026-10-17 outside delivery (hoelee-og-delivery), so a future
regeneration knows the design's exact pixel intent. Rendered by scripts/og-gen/generate-default.mjs. -->
# blog.hoelee.com — Default OG Card Pixel Specification
**Canvas:** 1200 × 630 px · **Aspect ratio:** 1.9048:1 · **Palette:** site dark theme
**Primary source:** `og-default.html` (fixed-size HTML; inline CSS; local relative image assets)
All coordinates below are in CSS pixels, measured from the canvas top-left. Text coordinates refer to the line box or the visible content box noted below; minor glyph-edge differences are expected across font renderers.
| Element | X | Y | Width × height | Font size / line height | Weight | Color / treatment |
|---|---:|---:|---:|---|---:|---|
| Canvas | 0 | 0 | 1200 × 630 | — | — | Background `#111827` |
| Logo (`logo-square.png`) | 64 | 64 | 44 × 44 | — | — | Original sage / mint / grey mark; transparent background |
| “ENGINEERING NOTES” line box | 122 | 72.8 | approx. 227 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, uppercase, letter spacing 0.7 px |
| `blog.hoelee.com` line box | 937.3 | 72.8 | approx. 198.7 × 26.4 | 22 / 26.4 px | 600 | `#6d8dff`, monospace |
| “Mr Hoelee” heading line box | 64 | 160 | 700 × 69.1 | 64 / 69.1 px | 750 | `#e5e7eb`, letter spacing −2 px |
| “Lee Teong Hoe” text line box | 66 | 246.1 | approx. 187.4 × 33.6 | 28 / 33.6 px | 500 | `#9ca3af` |
| Blue accent rule | 64 | 307.7 | 68 × 4 | — | — | `#6d8dff`, 2 px radius |
| Job title text line box | 64 | 334.7 | approx. 480.3 × 35 | 28 / 35 px | 650 | `#e5e7eb` |
| Positioning line | 64 | 386.7 | approx. 404.2 × 29.7 | 22 / 29.7 px | 500 | `#60a5fa` |
| Portrait outer frame | 815 | 115 | 321 × 352 | — | — | `#1f2937` surface, `#344159` border, 19 px radius; 10 px padding plus 1 px border |
| Portrait image (`lee-teong-hoe-640.jpg`) | 826 | 126 | 299 × 330 | — | — | `object-fit: cover`; `object-position: center 32%`; 11 px radius |
| Frame accent | 838 | 478 | 46 × 4 | — | — | `#6d8dff`, 2 px radius |
| Caption panel | 815 | 486 | 321 × 42 | — | — | `#1f2937` translucent surface, `#2a3345` border, 9 px radius |
| Caption dot | 830 | 503.5 | 7 × 7 | — | — | `#4ade80`, circular |
| Caption text “BUILD · RUN · LEARN” | approx. 849 | approx. 496 | content-sized | 22 / 22 px | 500 | `#9ca3af`, monospace |
| Workstream divider | 64 | 469 | 690 × 1 | — | — | `#2a3345` |
| “BUILD” label | 82 | 487.3 | approx. 67.2 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#6d8dff` square marker at x=64 |
| “OPERATE” label | approx. 297.6 | 487.3 | approx. 94.1 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#60a5fa` square marker at x≈279.6 |
| “DOCUMENT” label | approx. 513.3 | 487.3 | approx. 107.6 × 26.4 | 22 / 26.4 px | 600 | `#9ca3af`, monospace; `#4ade80` square marker at x≈495.3 |
| Decorative cross | 1116 | 100 | 20 × 20 | — | — | `#6d8dff`, 65% opacity |
| Background technical grid | 700 | 0 | 500 × 630 | — | — | `#6d8dff` grid at low opacity; 48 px spacing |
## Typography and constraints
- Uses only three text sizes: **64 px**, **28 px**, and **22 px**.
- Font stack: `"Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif`; terminal details use `"JetBrains Mono", Consolas, monospace`.
- Safe margin: primary content starts at x=64; the portrait and domain end at x=1136; top logo starts at y=64. Main content and captions end above y=550, leaving the bottom 80 px clear.
- `html`, `body`, and `.canvas` are fixed at 1200 × 630 px with overflow hidden.
- Text remains live HTML text. The two image assets are referenced by relative filename and must stay beside `og-default.html`.
+304
View File
@@ -0,0 +1,304 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1200, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Mr Hoelee — Default Open Graph Card</title>
<style>
@page { size: 1200px 630px; margin: 0; }
:root {
--paper: #111827;
--surface: #1f2937;
--surface-2: #263244;
--border: #2a3345;
--ink: #e5e7eb;
--muted: #9ca3af;
--faint: #6b7280;
--brand: #6d8dff;
--brand-soft: #60a5fa;
--good: #4ade80;
}
* { box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
min-width: 1200px;
min-height: 630px;
margin: 0;
padding: 0;
overflow: hidden;
background: var(--paper);
}
body {
color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.canvas {
position: relative;
width: 1200px;
height: 630px;
overflow: hidden;
isolation: isolate;
background: var(--paper);
}
.grid-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 700px;
width: 500px;
z-index: -1;
opacity: .18;
background-image:
linear-gradient(rgba(109,141,255,.10) 1px, transparent 1px),
linear-gradient(90deg, rgba(109,141,255,.10) 1px, transparent 1px);
background-size: 48px 48px;
}
.ambient-lines {
position: absolute;
right: 20px;
top: 112px;
width: 386px;
height: 380px;
z-index: -1;
opacity: .5;
}
.header {
position: absolute;
left: 64px;
top: 64px;
width: 1072px;
height: 44px;
display: flex;
align-items: center;
}
.brand-mark {
width: 44px;
height: 44px;
object-fit: contain;
display: block;
flex: 0 0 auto;
}
.eyebrow {
margin-left: 14px;
color: var(--muted);
font-size: 22px;
line-height: 1.2;
font-weight: 600;
letter-spacing: .7px;
text-transform: uppercase;
white-space: nowrap;
}
.domain {
margin-left: auto;
color: var(--brand);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 22px;
line-height: 1.2;
font-weight: 600;
white-space: nowrap;
}
.left-content {
position: absolute;
left: 64px;
top: 160px;
width: 700px;
}
h1 {
margin: 0;
color: var(--ink);
font-size: 64px;
line-height: 1.08;
letter-spacing: -2px;
font-weight: 750;
white-space: nowrap;
}
.real-name {
margin: 17px 0 0 2px;
color: var(--muted);
font-size: 28px;
line-height: 1.2;
font-weight: 500;
white-space: nowrap;
}
.accent-rule {
width: 68px;
height: 4px;
margin-top: 28px;
border-radius: 2px;
background: var(--brand);
}
.job-title {
margin: 23px 0 0;
color: var(--ink);
font-size: 28px;
line-height: 1.25;
font-weight: 650;
letter-spacing: -.35px;
white-space: nowrap;
}
.positioning {
margin: 17px 0 0;
color: var(--brand-soft);
font-size: 22px;
line-height: 1.35;
font-weight: 500;
white-space: nowrap;
}
.workstream {
position: absolute;
left: 64px;
top: 469px;
width: 690px;
height: 62px;
display: grid;
grid-template-columns: 1fr 1fr 1.2fr;
align-items: center;
border-top: 1px solid var(--border);
}
.workstream-item {
display: flex;
align-items: center;
gap: 11px;
color: var(--muted);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 22px;
line-height: 1.2;
font-weight: 600;
letter-spacing: .2px;
white-space: nowrap;
}
.workstream-item::before {
content: "";
width: 7px;
height: 7px;
border-radius: 2px;
background: var(--brand);
box-shadow: 0 0 0 4px rgba(109,141,255,.10);
flex: 0 0 auto;
}
.workstream-item:nth-child(2)::before { background: var(--brand-soft); }
.workstream-item:nth-child(3)::before { background: var(--good); }
.portrait-frame {
position: absolute;
margin: 0;
left: 815px;
top: 115px;
width: 321px;
height: 352px;
padding: 10px;
border: 1px solid #344159;
border-radius: 19px;
background: rgba(31,41,55,.88);
box-shadow: 0 16px 48px rgba(0,0,0,.20);
}
.portrait {
display: block;
width: 299px;
height: 330px;
border-radius: 11px;
object-fit: cover;
object-position: center 32%;
}
.frame-accent {
position: absolute;
left: 838px;
bottom: 148px;
width: 46px;
height: 4px;
background: var(--brand);
border-radius: 2px;
}
.portrait-caption {
position: absolute;
left: 815px;
top: 486px;
width: 321px;
height: 42px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--border);
border-radius: 9px;
padding: 0 14px;
background: rgba(31,41,55,.70);
color: var(--muted);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 22px;
line-height: 1;
font-weight: 500;
white-space: nowrap;
}
.caption-dot {
width: 7px;
height: 7px;
background: var(--good);
border-radius: 50%;
flex: 0 0 auto;
}
.corner-mark {
position: absolute;
right: 64px;
top: 100px;
width: 20px;
height: 20px;
opacity: .65;
}
.corner-mark::before, .corner-mark::after {
content: "";
position: absolute;
background: var(--brand);
}
.corner-mark::before { width: 20px; height: 1px; top: 9px; }
.corner-mark::after { height: 20px; width: 1px; left: 9px; }
</style>
</head>
<body>
<main class="canvas" aria-label="Mr Hoelee default social-share card">
<div class="grid-overlay" aria-hidden="true"></div>
<svg class="ambient-lines" viewBox="0 0 386 380" fill="none" aria-hidden="true">
<g stroke="#6d8dff" stroke-width="1">
<path d="M190 8H354V58H300V91H360" opacity=".38"/>
<path d="M246 16V72H208V114H170" opacity=".25"/>
<path d="M30 170H88V207H126V246H164" opacity=".24"/>
<path d="M338 133V172H308V218H276" opacity=".26"/>
<path d="M206 300H260V332H338" opacity=".22"/>
<circle cx="354" cy="58" r="3" fill="#6d8dff" fill-opacity=".65" stroke="none"/>
<circle cx="170" cy="114" r="3" fill="#60a5fa" fill-opacity=".65" stroke="none"/>
<circle cx="164" cy="246" r="3" fill="#6d8dff" fill-opacity=".65" stroke="none"/>
<circle cx="276" cy="218" r="3" fill="#60a5fa" fill-opacity=".65" stroke="none"/>
</g>
</svg>
<div class="corner-mark" aria-hidden="true"></div>
<header class="header">
<img class="brand-mark" src="{{LOGO_PATH}}" alt="" />
<div class="eyebrow">Engineering Notes</div>
<div class="domain">blog.hoelee.com</div>
</header>
<section class="left-content">
<h1>Mr Hoelee</h1>
<p class="real-name">Lee Teong Hoe</p>
<div class="accent-rule" aria-hidden="true"></div>
<p class="job-title">Full-stack Developer &amp; DevOps Engineer</p>
<p class="positioning">Web development · DevOps · self-hosting</p>
</section>
<figure class="portrait-frame">
<img class="portrait" src="{{PHOTO_PATH}}" alt="" />
</figure>
<div class="frame-accent" aria-hidden="true"></div>
<div class="portrait-caption"><span class="caption-dot" aria-hidden="true"></span><span>BUILD · RUN · LEARN</span></div>
<div class="workstream" aria-label="Engineering themes">
<div class="workstream-item">BUILD</div>
<div class="workstream-item">OPERATE</div>
<div class="workstream-item">DOCUMENT</div>
</div>
</main>
</body>
</html>