The consultant-approved "one art, four targets" work existed only in the
working tree. Commit it so the 72-post rollout is reproducible from the repo
instead of from one session's scratch files.
- template-v2.html / v2.1 / v2.2: the iteration the consultant reviewed, plus
the accepted frosted-bento layout (736x536 card, blueprint grid, full-width
evidence bar, self-fitting headline script that reports "FIT <n>px").
- render_sample_suite{,_v2,_v3}.mjs + fit-title-probe.mjs: how the two sample
posts were rendered for rounds 2-4 (kept for provenance).
- generate_prompts_catalog.py + prompts_catalog.json: the 72 per-post prompts
and their visual metaphors, extracted from the posts themselves.
- render_from_art.mjs (new): the batch renderer. One zero-text base frame per
post -> banner 1600x900 webp (+jpg fallback), social card 1200x630 (EN and
zh-<slug>), thumbs 800x420 and 400x210. Cards are filled from frontmatter +
art-meta.json; a missing evidence line or a headline that never settles is a
hard failure, never a silently-degraded card.
- art-meta.json: the per-slug input contract. Two slugs wired; 70 still need
base art (GPU) and a hand-written evidence line.
- README: documents the pipeline, the measured format choice (558 KB PNG ->
84 KB mozjpeg q85) and the PostList thumb-helper trap when ogImage moves to
.jpg.
Proof the renderer reproduces the accepted output: re-rendering the two sample
posts yields banner + 800px thumb files BYTE-IDENTICAL to the consultant's
delivery (sha256 16e6a7b0, f3b29f38, c4e28291, 8095ec6c), and all four cards
report FIT, 2-3 title lines, 2 description lines, 0 overflow in x and y.
325 lines
9.8 KiB
HTML
325 lines
9.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{LANG}}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
:root {
|
|
--bg-base: #090d16;
|
|
--primary: #4d82ff;
|
|
--primary-bright: #6d8dff;
|
|
--accent-green: #3fb950;
|
|
--accent-red: #ff7b72;
|
|
--text-main: #f8fafc;
|
|
--text-sub: #cbd5e1;
|
|
--text-muted: #94a3b8;
|
|
--text-dim: #64748b;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body {
|
|
width: 1200px;
|
|
height: 630px;
|
|
overflow: hidden;
|
|
background: var(--bg-base);
|
|
font-family: "Space Grotesk", "Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
color: var(--text-main);
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.canvas {
|
|
position: relative;
|
|
width: 1200px;
|
|
height: 630px;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 44px 52px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bg-artwork {
|
|
position: absolute;
|
|
top: -9%;
|
|
right: -4%;
|
|
width: 116%;
|
|
height: 118%;
|
|
object-fit: cover;
|
|
object-position: right center;
|
|
z-index: 1;
|
|
}
|
|
|
|
.scrim-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
background:
|
|
radial-gradient(circle at 18% 22%, rgba(77, 130, 255, 0.14) 0%, transparent 48%),
|
|
radial-gradient(circle at 80% 50%, rgba(9, 13, 22, 0.0) 0%, rgba(9, 13, 22, 0.55) 58%, rgba(9, 13, 22, 0.90) 82%),
|
|
linear-gradient(90deg, rgba(9, 13, 22, 0.84) 0%, rgba(9, 13, 22, 0.78) 42%, rgba(9, 13, 22, 0.28) 68%, rgba(9, 13, 22, 0.05) 100%);
|
|
}
|
|
|
|
.blueprint-grid {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 2;
|
|
background-image:
|
|
linear-gradient(to right, rgba(109, 141, 255, 0.05) 1px, transparent 1px),
|
|
linear-gradient(to bottom, rgba(109, 141, 255, 0.05) 1px, transparent 1px);
|
|
background-size: 36px 36px;
|
|
mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
|
|
-webkit-mask-image: linear-gradient(90deg, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 55%, transparent 85%);
|
|
}
|
|
|
|
.blueprint-accents {
|
|
position: absolute;
|
|
inset: 20px;
|
|
border: 1px solid rgba(109, 141, 255, 0.10);
|
|
border-radius: 16px;
|
|
pointer-events: none;
|
|
z-index: 3;
|
|
}
|
|
.crosshair { position: absolute; width: 15px; height: 15px; opacity: 0.45; }
|
|
.crosshair::before, .crosshair::after {
|
|
content: ""; position: absolute; background: var(--primary-bright);
|
|
}
|
|
.crosshair::before { top: 7px; left: 0; width: 15px; height: 1px; }
|
|
.crosshair::after { left: 7px; top: 0; width: 1px; height: 15px; }
|
|
.ch-tr { top: 22px; right: 26px; }
|
|
.ch-br { bottom: 22px; right: 26px; }
|
|
|
|
.bento-card {
|
|
position: relative;
|
|
z-index: 4;
|
|
width: 736px;
|
|
height: 536px;
|
|
padding: 34px 44px 30px;
|
|
border-radius: 18px;
|
|
background: linear-gradient(145deg, rgba(21, 29, 46, 0.82) 0%, rgba(13, 18, 30, 0.74) 100%);
|
|
border: 1px solid rgba(109, 141, 255, 0.24);
|
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
|
backdrop-filter: blur(16px);
|
|
-webkit-backdrop-filter: blur(16px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.bento-card::before {
|
|
content: "";
|
|
position: absolute;
|
|
top: -1px; left: 44px;
|
|
width: 96px; height: 2px;
|
|
background: linear-gradient(90deg, var(--primary-bright), transparent);
|
|
}
|
|
|
|
.top-row { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
|
|
.badge-group { display: flex; align-items: center; gap: 12px; }
|
|
.cat-badge {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
padding: 6px 14px; border-radius: 999px;
|
|
background: rgba(77, 130, 255, 0.2);
|
|
border: 1px solid rgba(109, 141, 255, 0.5);
|
|
color: #a5c0ff;
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
font-size: 13px; font-weight: 700;
|
|
letter-spacing: 1px; text-transform: uppercase;
|
|
}
|
|
.cat-badge .dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
background: var(--accent-green);
|
|
box-shadow: 0 0 8px var(--accent-green);
|
|
}
|
|
.top-tags {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
font-size: 13px;
|
|
}
|
|
.top-tags span {
|
|
background: rgba(15, 21, 35, 0.75);
|
|
border: 1px solid rgba(56, 72, 99, 0.7);
|
|
padding: 4px 10px; border-radius: 6px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.center-block {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
flex: 1 1 auto;
|
|
justify-content: center;
|
|
min-height: 0;
|
|
padding: 8px 0;
|
|
}
|
|
.title {
|
|
font-weight: 800;
|
|
font-size: 44px;
|
|
line-height: 1.24;
|
|
letter-spacing: -0.02em;
|
|
color: #ffffff;
|
|
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
|
|
text-wrap: balance;
|
|
}
|
|
.desc {
|
|
font-size: 17px;
|
|
line-height: 1.5;
|
|
color: var(--text-sub);
|
|
font-weight: 400;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.evidence-strip {
|
|
display: flex; align-items: center; gap: 12px;
|
|
width: 100%;
|
|
padding: 12px 18px;
|
|
border-radius: 10px;
|
|
background: rgba(9, 13, 22, 0.85);
|
|
border: 1px solid rgba(77, 130, 255, 0.28);
|
|
border-left: 3px solid var(--primary-bright);
|
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
|
font-size: 15px; line-height: 1.4;
|
|
color: #e2e8f0;
|
|
}
|
|
.evidence-strip .prompt { color: var(--accent-green); font-weight: 700; }
|
|
.evidence-strip .text {
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
|
|
.bottom-row {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding-top: 16px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
|
flex: 0 0 auto;
|
|
}
|
|
.author-lockup { display: flex; align-items: center; gap: 14px; }
|
|
.author-avatar {
|
|
width: 44px; height: 44px; border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 2px solid var(--primary-bright);
|
|
}
|
|
.author-meta { display: flex; flex-direction: column; gap: 2px; }
|
|
.author-name { font-size: 17px; font-weight: 700; color: #ffffff; }
|
|
.author-name .handle {
|
|
font-weight: 500; color: var(--text-muted);
|
|
font-size: 14px; margin-left: 6px;
|
|
}
|
|
.author-role {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 12px; letter-spacing: 0.5px;
|
|
color: var(--primary-bright);
|
|
}
|
|
.domain-pill {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 14px; font-weight: 600;
|
|
color: #cbd5e1;
|
|
background: rgba(77, 130, 255, 0.12);
|
|
border: 1px solid rgba(109, 141, 255, 0.3);
|
|
padding: 6px 14px; border-radius: 8px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="canvas">
|
|
<img class="bg-artwork" src="{{BG_IMAGE_SRC}}" alt="">
|
|
<div class="scrim-overlay"></div>
|
|
<div class="blueprint-grid"></div>
|
|
<div class="blueprint-accents">
|
|
<div class="crosshair ch-tr"></div>
|
|
<div class="crosshair ch-br"></div>
|
|
</div>
|
|
|
|
<div class="bento-card" id="bento">
|
|
<div class="top-row">
|
|
<div class="badge-group">
|
|
<div class="cat-badge"><span class="dot"></span>{{CATEGORY}}</div>
|
|
<div class="top-tags">{{TAGS_HTML}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="center-block">
|
|
<h1 class="title" id="title">{{TITLE_HTML}}</h1>
|
|
<p class="desc" id="desc">{{DESCRIPTION}}</p>
|
|
<div class="evidence-strip">
|
|
<span class="prompt">$</span>
|
|
<span class="text">{{EVIDENCE_SUMMARY}}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bottom-row">
|
|
<div class="author-lockup">
|
|
<img class="author-avatar" src="{{AVATAR_PATH}}" alt="Lee Teong Hoe">
|
|
<div class="author-meta">
|
|
<div class="author-name">Lee Teong Hoe <span class="handle">· Mr Hoelee</span></div>
|
|
<div class="author-role">BUILD · OPERATE · DOCUMENT</div>
|
|
</div>
|
|
</div>
|
|
<div class="domain-pill">blog.hoelee.com</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
/* Auto-fit the headline: shrink until the block fits maxLines with no widow line.
|
|
Runs before the screenshot (Chrome is invoked with --virtual-time-budget), so the
|
|
captured PNG is always the settled state — never a mid-layout frame. */
|
|
(function () {
|
|
var MAX_PX = {{TITLE_SIZE}};
|
|
var MIN_PX = 26;
|
|
var MAX_LINES = 3;
|
|
var WIDOW_RATIO = 0.40;
|
|
|
|
var title = document.getElementById('title');
|
|
var bento = document.getElementById('bento');
|
|
var block = title.parentElement;
|
|
|
|
function lineRects(el) {
|
|
var range = document.createRange();
|
|
var rects = [];
|
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
var n;
|
|
while ((n = walker.nextNode())) {
|
|
if (!n.textContent.trim()) continue;
|
|
range.selectNodeContents(n);
|
|
var rs = range.getClientRects();
|
|
for (var i = 0; i < rs.length; i++) if (rs[i].width > 1) rects.push(rs[i]);
|
|
}
|
|
var rows = {};
|
|
rects.forEach(function (r) {
|
|
var key = Math.round((r.top + r.height / 2) / 6);
|
|
if (!rows[key]) rows[key] = { left: r.left, right: r.right, top: r.top };
|
|
else {
|
|
rows[key].left = Math.min(rows[key].left, r.left);
|
|
rows[key].right = Math.max(rows[key].right, r.right);
|
|
}
|
|
});
|
|
return Object.keys(rows).sort(function (a, b) { return rows[a].top - rows[b].top; })
|
|
.map(function (k) { return rows[k]; });
|
|
}
|
|
|
|
function fits(px) {
|
|
title.style.fontSize = px + 'px';
|
|
var avail = block.clientWidth;
|
|
var rows = lineRects(title);
|
|
if (rows.length === 0) return false;
|
|
if (rows.length > MAX_LINES) return false;
|
|
var last = rows[rows.length - 1];
|
|
var lastW = last.right - last.left;
|
|
if (rows.length > 1 && lastW < avail * WIDOW_RATIO) return false;
|
|
// Whole card must still fit its fixed height.
|
|
if (bento.scrollHeight > bento.clientHeight + 1) return false;
|
|
return true;
|
|
}
|
|
|
|
for (var px = MAX_PX; px >= MIN_PX; px--) {
|
|
if (fits(px)) {
|
|
document.title = 'FIT ' + px + 'px';
|
|
return;
|
|
}
|
|
}
|
|
title.style.fontSize = MIN_PX + 'px';
|
|
document.title = 'FIT ' + MIN_PX + 'px (floor)';
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|