diff --git a/public/images/categories/case-studies-banner-1200.webp b/public/images/categories/case-studies-banner-1200.webp
new file mode 100644
index 0000000..8c25e71
Binary files /dev/null and b/public/images/categories/case-studies-banner-1200.webp differ
diff --git a/public/images/categories/case-studies-banner.svg b/public/images/categories/case-studies-banner.svg
new file mode 100644
index 0000000..4ec33e7
--- /dev/null
+++ b/public/images/categories/case-studies-banner.svg
@@ -0,0 +1,324 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/images/categories/case-studies-banner.webp b/public/images/categories/case-studies-banner.webp
new file mode 100644
index 0000000..534b72d
Binary files /dev/null and b/public/images/categories/case-studies-banner.webp differ
diff --git a/scripts/gen-case-studies-banner.mjs b/scripts/gen-case-studies-banner.mjs
new file mode 100644
index 0000000..d87c538
--- /dev/null
+++ b/scripts/gen-case-studies-banner.mjs
@@ -0,0 +1,239 @@
+/**
+ * Generates the case-studies portfolio banner — 21:9, no baked-in text.
+ *
+ * The consultant's spec for /categories/case-studies/ asks for a wide architecture
+ * visual: three production hosts, Docker, Prometheus and the automation flow, in a
+ * dark terminal grey-green with restrained warm-copper accents, rendered into a
+ * 240–300 px tall band beside the hero copy. Everything here is geometry, no words:
+ * at that display height any label would be unreadable, and the headline copy has to
+ * stay semantic HTML.
+ *
+ * Deterministic (seeded), so regenerating never churns the committed asset.
+ *
+ * node scripts/gen-case-studies-banner.mjs
+ *
+ * Writes:
+ * public/images/categories/case-studies-banner.webp 2400x1029 (21:9)
+ * public/images/categories/case-studies-banner-1200.webp 1200x514
+ * public/images/categories/case-studies-banner.svg the source geometry
+ */
+import { mkdir, stat, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import sharp from 'sharp';
+
+const W = 2400;
+const H = 1029; // 21:9
+
+// Host panels: the real fleet is 3 hosts / 162 containers, and the visual carries the
+// same proportions (Unraid 62, Synology 78, VPS 22) so the art is not decorative lies.
+const HOSTS = [
+ { x: 130, y: 300, w: 300, rows: 10, cols: 10, count: 62, hot: [3, 17, 41] },
+ { x: 520, y: 210, w: 320, rows: 12, cols: 10, count: 78, hot: [2, 9, 33, 60] },
+ { x: 930, y: 396, w: 280, rows: 7, cols: 10, count: 22, hot: [6, 15] },
+];
+
+const HUB = { cx: 1560, cy: 514 };
+
+// Deterministic pseudo-random: same seed -> same picture, forever.
+function rnd(seed) {
+ let s = seed;
+ return () => {
+ s = (s * 1103515245 + 12345) % 2147483648;
+ return s / 2147483648;
+ };
+}
+
+function hostHeight(host) {
+ return 26 + 14 * 2 + host.rows * 15;
+}
+
+function hostPanel(host, seed) {
+ const r = rnd(seed);
+ const head = 26;
+ const pad = 14;
+ const cols = host.cols;
+ const cw = 20;
+ const ch = 11;
+ const gx = (host.w - pad * 2 - cols * cw) / (cols - 1);
+ const gy = 15;
+ const h = hostHeight(host);
+
+ const chips = [];
+ let drawn = 0;
+ const hot = new Set(host.hot);
+ for (let row = 0; row < host.rows && drawn < host.count; row++) {
+ for (let col = 0; col < cols && drawn < host.count; col++) {
+ const i = row * cols + col;
+ const accent = hot.has(i)
+ ? '#d08b4a'
+ : r() > 0.86
+ ? '#4fae7a'
+ : r() > 0.5
+ ? '#3a7157'
+ : '#2e5c48';
+ chips.push(
+ ` `,
+ );
+ drawn++;
+ }
+ }
+
+ return `
+
+
+
+
+
+
+ ${chips.join('\n ')}
+
+
+ `;
+}
+
+/** A Prometheus-ish readout hub: concentric rings, a flame glyph, four metric tiles. */
+function hub() {
+ const { cx, cy } = HUB;
+ const tiles = [
+ { top: cy - 196, pts: '0,34 14,26 28,30 42,16 56,20 70,8 84,12' },
+ { top: cy - 66, pts: '0,10 14,22 28,14 42,28 56,18 70,30 84,22' },
+ { top: cy + 66, pts: '0,28 14,12 28,20 42,6 56,14 70,10 84,18' },
+ { top: cy + 196, pts: '0,20 14,18 28,24 42,12 56,16 70,6 84,14' },
+ ].map((t) => ({ ...t, x: cx + 206, y: t.top - 43 }));
+
+ const tileSvg = tiles.map(
+ (t) => `
+
+
+
+ `,
+ );
+
+ return `
+
+
+
+
+
+
+ ${tileSvg.join('\n ')}
+ `;
+}
+
+/**
+ * Hosts → hub wiring, drawn as a network bus rather than three curves across the
+ * panels: each host drops a stub into a shared copper bus below the fleet, and the
+ * bus rises into the hub's outer ring. Crossing the panels looked like a mistake at
+ * banner size, which is exactly the kind of detail nobody should have to decode.
+ */
+function flows() {
+ const busY = 760;
+ const busX = HUB.cx;
+ const drops = HOSTS.map((host) => {
+ const x = host.x + host.w / 2;
+ const y0 = host.y + hostHeight(host) + 22;
+ return `
+
+
+
+ `;
+ }).join('\n ');
+
+ return `
+
+
+
+ ${drops}
+ `;
+}
+
+/** The automation tail: hub → three workflow nodes, drawn as a green chain. */
+function automation() {
+ const nodes = [
+ { x: 1960, y: 320, w: 170, h: 112 },
+ { x: 2160, y: 470, w: 170, h: 112 },
+ { x: 1960, y: 620, w: 170, h: 112 },
+ ];
+ const chain = nodes
+ .map(
+ (n) => `
+
+
+
+
+
+
+ `,
+ )
+ .join('\n ');
+
+ return `
+
+
+
+ ${chain}
+ `;
+}
+
+const svg = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${Array.from({ length: 24 }, (_, i) => ` `).join('\n ')}
+
+
+ ${HOSTS.map((h, i) => hostPanel(h, 97 + i * 13)).join('\n ')}
+ ${flows()}
+ ${hub()}
+ ${automation()}
+
+ `;
+
+const outDir = path.join(process.cwd(), 'public', 'images', 'categories');
+await mkdir(outDir, { recursive: true });
+
+await sharp(Buffer.from(svg)).webp({ quality: 82 }).toFile(path.join(outDir, 'case-studies-banner.webp'));
+await sharp(Buffer.from(svg))
+ .resize(1200, 514, { fit: 'fill' })
+ .webp({ quality: 80 })
+ .toFile(path.join(outDir, 'case-studies-banner-1200.webp'));
+
+// Keep the source next to the render so a future edit does not have to reverse-engineer
+// the geometry out of a WebP.
+await writeFile(path.join(outDir, 'case-studies-banner.svg'), svg, 'utf8');
+
+const report = [];
+for (const f of ['case-studies-banner.webp', 'case-studies-banner-1200.webp']) {
+ const { size } = await stat(path.join(outDir, f));
+ report.push(`${f}: ${(size / 1024).toFixed(1)} KB`);
+}
+console.log(report.join('\n'));
diff --git a/scripts/probe-layout.mjs b/scripts/probe-layout.mjs
new file mode 100644
index 0000000..cdb6019
--- /dev/null
+++ b/scripts/probe-layout.mjs
@@ -0,0 +1,194 @@
+/**
+ * Layout probe over CDP: horizontal-overflow + interaction checks at fixed viewports.
+ *
+ * Uses Node's built-in WebSocket client (Node >= 22) so nothing has to be installed.
+ *
+ * node scripts/probe-layout.mjs [baseUrl]
+ *
+ * Requires a headless Chrome with remote debugging and a static server on `baseUrl`
+ * (default http://127.0.0.1:4321, i.e. `python -m http.server 4321 --directory dist`).
+ *
+ * Checks per page × width:
+ * - documentElement.scrollWidth === innerWidth (no horizontal scroll)
+ * - names every element whose right edge exceeds the viewport by > 1px (so a failure
+ * is actionable instead of "something overflows")
+ * - the /posts/ featured block collapses on the first keystroke and comes back when
+ * the filter is cleared
+ */
+const BASE = process.argv[2] ?? 'http://127.0.0.1:4321';
+const CDP = process.argv[3] ?? 'http://127.0.0.1:9333';
+
+const PAGES = [
+ '/',
+ '/posts/',
+ '/zh/posts/',
+ '/categories/',
+ '/categories/case-studies/',
+ '/zh/categories/case-studies/',
+ '/categories/devops/',
+ '/tags/docker/',
+ '/about/',
+];
+
+const WIDTHS = [1920, 1280, 960, 768, 430, 390, 375, 320];
+
+async function target(url) {
+ const res = await fetch(`${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
+ if (!res.ok) throw new Error(`cannot create target: ${res.status} ${await res.text()}`);
+ return res.json();
+}
+
+function connect(wsUrl) {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(wsUrl);
+ ws.addEventListener('open', () => resolve(ws));
+ ws.addEventListener('error', reject);
+ });
+}
+
+let nextId = 0;
+function send(ws, method, params = {}) {
+ const id = ++nextId;
+ return new Promise((resolve, reject) => {
+ const onMessage = (ev) => {
+ const msg = JSON.parse(ev.data);
+ if (msg.id !== id) return;
+ ws.removeEventListener('message', onMessage);
+ if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
+ else resolve(msg.result);
+ };
+ ws.addEventListener('message', onMessage);
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+async function evaluate(ws, expression) {
+ const r = await send(ws, 'Runtime.evaluate', {
+ expression,
+ returnByValue: true,
+ awaitPromise: true,
+ userGesture: true,
+ });
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.text);
+ return r.result.value;
+}
+
+const OVERFLOW_JS = `(() => {
+ const vw = window.innerWidth;
+ const offenders = [];
+ for (const el of document.querySelectorAll('body *')) {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ if (r.right > vw + 1 || r.left < -1) {
+ offenders.push({
+ tag: el.tagName.toLowerCase(),
+ cls: (el.className && String(el.className).slice(0, 60)) || '',
+ left: Math.round(r.left),
+ right: Math.round(r.right),
+ });
+ }
+ }
+ return {
+ vw,
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ docScroll: document.documentElement.scrollHeight,
+ offenders: offenders.slice(0, 6),
+ };
+})()`;
+
+const COLLAPSE_JS = `(async () => {
+ const feat = document.getElementById('archive-featured');
+ const q = document.getElementById('archive-q');
+ const status = document.getElementById('archive-status-text');
+ const clear = document.getElementById('archive-clear');
+ if (!feat || !q) return { skipped: true };
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const before = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
+ q.value = 'docker';
+ q.dispatchEvent(new Event('input', { bubbles: true }));
+ await wait(420);
+ const during = {
+ collapsed: feat.dataset.collapsed,
+ h: Math.round(feat.getBoundingClientRect().height),
+ status: status?.textContent?.trim(),
+ clearVisible: clear ? !clear.hidden : null,
+ inert: feat.inert === true,
+ visibleRows: [...document.querySelectorAll('.archive-row')].filter((r) => !r.hidden).length,
+ };
+ clear?.click();
+ await wait(420);
+ const after = { collapsed: feat.dataset.collapsed, h: Math.round(feat.getBoundingClientRect().height), status: status?.textContent?.trim() };
+ return { before, during, after };
+})()`;
+
+const results = [];
+const fails = [];
+
+for (const path of PAGES) {
+ const t = await target('about:blank');
+ const ws = await connect(t.webSocketDebuggerUrl);
+ await send(ws, 'Page.enable');
+ await send(ws, 'Runtime.enable');
+
+ for (const width of WIDTHS) {
+ await send(ws, 'Emulation.setDeviceMetricsOverride', {
+ width,
+ height: 900,
+ deviceScaleFactor: 1,
+ mobile: width < 700,
+ });
+ await send(ws, 'Page.navigate', { url: BASE + path });
+ await new Promise((r) => setTimeout(r, 700));
+ const m = await evaluate(ws, OVERFLOW_JS);
+ const ok = m.scrollWidth <= m.clientWidth + 1;
+ results.push({ path, width, ok, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders.length });
+ if (!ok || m.offenders.length) {
+ fails.push({ path, width, scrollWidth: m.scrollWidth, clientWidth: m.clientWidth, offenders: m.offenders });
+ }
+ }
+
+ // Interaction check only where the featured block exists (desktop + phone widths).
+ if (path === '/posts/' || path === '/zh/posts/') {
+ for (const width of [1280, 390]) {
+ await send(ws, 'Emulation.setDeviceMetricsOverride', {
+ width,
+ height: 900,
+ deviceScaleFactor: 1,
+ mobile: width < 700,
+ });
+ await send(ws, 'Page.navigate', { url: BASE + path });
+ await new Promise((r) => setTimeout(r, 700));
+ const c = await evaluate(ws, COLLAPSE_JS);
+ results.push({ path, width, collapse: c });
+ if (!c.skipped) {
+ const good =
+ c.before.collapsed === 'false' &&
+ c.during.collapsed === 'true' &&
+ c.during.h === 0 &&
+ c.during.inert === true &&
+ c.during.clearVisible === true &&
+ /docker/.test(String(c.during.status)) === false &&
+ c.after.collapsed === 'false' &&
+ c.after.h > 100;
+ if (!good) fails.push({ path, width, collapse: c });
+ else console.log(`collapse ok ${path} @${width}: ${c.before.status} → ${c.during.status} → ${c.after.status} (h ${c.before.h} → ${c.during.h} → ${c.after.h})`);
+ }
+ }
+ }
+
+ await send(ws, 'Page.close');
+ ws.close();
+}
+
+const byWidth = results.filter((r) => r.ok !== undefined);
+const okCount = byWidth.filter((r) => r.ok).length;
+console.log(`\nviewport checks: ${okCount}/${byWidth.length} clean`);
+console.log(`pages × widths: ${PAGES.length} × ${WIDTHS.length}`);
+
+if (fails.length) {
+ console.log('\nFAILURES:');
+ for (const f of fails) console.log(JSON.stringify(f));
+ process.exit(1);
+}
+console.log('no horizontal overflow, featured collapse verified');
diff --git a/src/components/Archive.astro b/src/components/Archive.astro
index 0a8a58e..644fd3e 100644
--- a/src/components/Archive.astro
+++ b/src/components/Archive.astro
@@ -1,6 +1,8 @@
---
import type { CollectionEntry } from 'astro:content';
import { tagCounts, tagHref } from '../lib/tags';
+import { categoryLabel } from '../lib/categories';
+import type { FeaturedCard } from '../lib/featured';
interface Props {
posts: CollectionEntry<'posts'>[];
@@ -17,8 +19,13 @@ interface Props {
chipPosts?: CollectionEntry<'posts'>[];
/** On a tag page, the tag being viewed — its chip is marked active instead of looking clickable-to-self. */
activeTag?: string;
+ /**
+ * Featured cards above the rows. Only the archive passes these; the block collapses
+ * out of the way as soon as the reader types a filter or picks a category chip.
+ */
+ featured?: FeaturedCard[];
}
-const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag } = Astro.props;
+const { posts, lang = 'en', chrome = true, chipPosts = posts, activeTag, featured } = Astro.props;
const isZh = lang === 'zh';
const counts = tagCounts(chipPosts);
@@ -39,8 +46,39 @@ const shortDate = (d: Date) =>
const yearOf = (d: Date) => d.getUTCFullYear();
const labels = isZh
- ? { filter: '按标题或标签筛选…', all: '全部', empty: '没有匹配的文章。', search: '全文搜索', count: '篇' }
- : { filter: 'Filter by title or tag…', all: 'All', empty: 'No posts match.', search: 'Full-text search', count: 'posts' };
+ ? {
+ filter: '按标题或标签筛选…',
+ all: '全部',
+ empty: '没有匹配的文章。',
+ search: '全文搜索',
+ count: '篇',
+ featured: '精选案例',
+ readArticle: '阅读文章',
+ readCase: '阅读案例复盘',
+ minutes: '分钟',
+ statusAll: `共 ${posts.length} 篇`,
+ statusSome: `命中 {n} / ${posts.length} 篇`,
+ clear: '清除筛选',
+ }
+ : {
+ filter: 'Filter by title or tag…',
+ all: 'All',
+ empty: 'No posts match.',
+ search: 'Full-text search',
+ count: 'posts',
+ featured: 'Featured case studies',
+ readArticle: 'Read article',
+ readCase: 'Read case study',
+ minutes: 'min read',
+ statusAll: `All ${posts.length} posts`,
+ statusSome: `Showing {n} of ${posts.length} posts`,
+ clear: 'Clear filters',
+ };
+
+// A case study is a portfolio artefact, so its call to action says so. Applied at the
+// one place that renders listing cards so no page can drift from the rule.
+const ctaLabel = (category: string) =>
+ category === 'case-studies' ? labels.readCase : labels.readArticle;
---
{chrome && (
@@ -58,25 +96,90 @@ const labels = isZh
- {labels.all} {posts.length}
- {categories.map((c) => (
- {c.name} {c.n}
- ))}
+ {isZh ? '分类' : 'Category'}
+
+ {labels.all} {posts.length}
+ {categories.map((c) => (
+
+ {categoryLabel(c.name, lang)} {c.n}
+
+ ))}
+
>
)}
{!chrome && {posts.length} {labels.count}
}
+{featured && featured.length > 0 && (
+
+
+
+ {labels.featured}
+
+
+
+
+)}
+
+{chrome && (
+
+ {labels.statusAll}
+ {labels.clear}
+
+)}
+
{years.map((y) => (
@@ -91,7 +194,9 @@ const labels = isZh
>
{shortDate(p.data.pubDate)}
{p.data.title}
- {p.data.category}
+
+ {categoryLabel(p.data.category, lang)}
+
))}
@@ -119,6 +224,7 @@ const labels = isZh
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.8rem;
+ min-height: 44px;
}
.archive-input:focus { outline: none; border-color: var(--brand); }
.archive-overlay-btn {
@@ -129,13 +235,49 @@ const labels = isZh
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.6rem 0.8rem;
+ min-height: 44px;
cursor: pointer;
white-space: nowrap;
}
.archive-overlay-btn:hover { color: var(--brand); border-color: var(--brand); }
- .archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
+ .archive-chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; align-items: center; }
.archive-tags { margin-bottom: 1.1rem; }
+ /* Two strips of identical chips sat next to each other and read as one list, with
+ "devops" appearing twice (category 30, topic 6). Name each strip. */
+ .chip-strip-label {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+ min-width: 4.2rem;
+ align-self: flex-start;
+ padding-top: 0.4rem;
+ }
+ /* The chips wrap inside their own column so a second line lines up under the first
+ chip instead of sliding back under the strip label. */
+ .chip-strip-items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ /* flex-basis must be 0, not auto: a wrap container places items by their
+ hypothetical size, so a max-content basis pushed the whole topic strip onto its
+ own line instead of shrinking next to the label. */
+ flex: 1 1 0;
+ min-width: 0;
+ }
+ /* On a phone the label column cost ~67 px of a 342 px content width, which wrapped
+ the topic chips onto more lines than needed. Stack the label above there. */
+ @media (max-width: 700px) {
+ .chip-strip-label {
+ flex: 0 0 100%;
+ min-width: 0;
+ padding-top: 0;
+ margin-top: 0.3rem;
+ }
+ .chip-strip-items { margin-top: 0.3rem; }
+ }
.chip {
font: inherit;
font-size: 0.78rem;
@@ -147,11 +289,136 @@ const labels = isZh
cursor: pointer;
text-decoration: none;
white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
}
.chip span { opacity: 0.55; margin-left: 0.15rem; }
.chip:hover { color: var(--brand); border-color: var(--brand); }
.chip.is-active { color: var(--ink); border-color: var(--brand); }
+ /* Featured block: collapses to zero height the moment the reader filters, and the
+ viewport goes to the results. `grid-template-rows: 1fr → 0fr` animates without
+ knowing the block's height (Chrome 117+, Safari 17.4+, FF 66+); older engines
+ simply jump, which is acceptable for a 200 ms affordance. */
+ .archive-featured {
+ display: grid;
+ grid-template-rows: 1fr;
+ margin: 0 0 1.4rem;
+ transition: grid-template-rows 0.2s ease, opacity 0.2s ease, margin-bottom 0.2s ease;
+ }
+ .archive-featured.is-collapsed {
+ grid-template-rows: 0fr;
+ opacity: 0;
+ margin-bottom: 0;
+ pointer-events: none;
+ }
+ .archive-featured-inner { overflow: hidden; min-height: 0; }
+ .archive-featured-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.78rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--faint);
+ margin: 0.2rem 0 0.7rem;
+ }
+ .af-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--brand);
+ box-shadow: 0 0 8px rgba(109, 141, 255, 0.8);
+ }
+ .archive-featured-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 232px), 1fr));
+ gap: 0.9rem;
+ }
+ .feat-card {
+ display: flex;
+ flex-direction: column;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
+ }
+ .feat-card:hover,
+ .feat-card:focus-visible {
+ transform: translateY(-2px);
+ border-color: var(--brand);
+ box-shadow: 0 10px 28px -14px rgba(0, 0, 0, 0.5), 0 0 18px -6px rgba(109, 141, 255, 0.3);
+ color: inherit;
+ }
+ .feat-thumb { display: block; aspect-ratio: 16 / 9; background: var(--bg); }
+ .feat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .feat-body { display: flex; flex-direction: column; gap: 0.4rem; padding: 0.7rem 0.8rem 0.85rem; }
+ .feat-top { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
+ .feat-metric {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ color: var(--muted);
+ }
+ .pulse-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--good);
+ }
+ .feat-title { font-size: 0.98rem; font-weight: 600; line-height: 1.3; color: var(--ink); }
+ .feat-desc {
+ font-size: 0.82rem;
+ line-height: 1.45;
+ color: var(--muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+ .feat-cta {
+ margin-top: auto;
+ padding-top: 0.35rem;
+ font-size: 0.76rem;
+ font-weight: 600;
+ color: var(--brand);
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+ }
+ .feat-min { color: var(--faint); font-weight: 400; }
+
+ .archive-status {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+ color: var(--faint);
+ margin: 0 0 0.5rem;
+ min-height: 28px;
+ }
+ .archive-clear {
+ font: inherit;
+ font-size: 0.72rem;
+ color: var(--brand);
+ background: none;
+ border: 1px solid color-mix(in srgb, var(--brand) 45%, transparent);
+ border-radius: 999px;
+ padding: 0.15rem 0.55rem;
+ min-height: 28px;
+ cursor: pointer;
+ }
+ .archive-clear:hover { border-color: var(--brand); }
+
.archive { list-style: none; padding: 0; margin: 0; }
.archive-year {
font-size: 0.8rem;
@@ -197,11 +464,17 @@ const labels = isZh
.archive-empty { color: var(--muted); margin-top: 1.5rem; }
[hidden] { display: none !important; }
+ @media (prefers-reduced-motion: reduce) {
+ .archive-featured, .feat-card { transition: none; }
+ .feat-card:hover, .feat-card:focus-visible { transform: none; }
+ }
+
@media (max-width: 600px) {
.archive-row { flex-wrap: wrap; gap: 0.3rem 0.6rem; padding: 0.4rem 0; }
.archive-title { flex: 1 1 100%; order: 2; }
.archive-row time { flex: 0 0 auto; order: 1; }
.archive-cat { order: 1; }
+ .chip { min-height: 36px; }
}
@@ -209,16 +482,29 @@ const labels = isZh
// Client-side filter over an already-rendered list: zero network requests, works
// with the browser's own Ctrl+F, and keeps the archive one static page.
const q = document.getElementById('archive-q') as HTMLInputElement | null;
- const list = document.getElementById('archive-list');
const empty = document.getElementById('archive-empty');
const chips = Array.from(document.querySelectorAll('.chip[data-cat]'));
const overlayBtn = document.getElementById('archive-search');
+ const featuredBlock = document.getElementById('archive-featured');
+ const status = document.getElementById('archive-filter-status');
+ const statusText = document.getElementById('archive-status-text');
+ const clearBtn = document.getElementById('archive-clear') as HTMLButtonElement | null;
let activeCat = '';
overlayBtn?.addEventListener('click', () => {
document.getElementById('search-toggle')?.click();
});
+ // A collapsed block with 0fr height still leaves its links focusable, so keyboard
+ // and screen-reader users would tab into invisible cards — `inert` closes that.
+ function setCollapsed(collapsed: boolean) {
+ if (!featuredBlock) return;
+ featuredBlock.classList.toggle('is-collapsed', collapsed);
+ featuredBlock.dataset.collapsed = String(collapsed);
+ featuredBlock.setAttribute('aria-hidden', String(collapsed));
+ (featuredBlock as HTMLElement).inert = collapsed;
+ }
+
function apply() {
const term = (q?.value ?? '').trim().toLowerCase();
const terms = term ? term.split(/\s+/) : [];
@@ -239,6 +525,16 @@ const labels = isZh
el.hidden = !any;
});
if (empty) empty.hidden = shown !== 0;
+
+ // Featured posts are rows in the full list too, so a hit is never lost while the
+ // block is collapsed — the results list stays the single source of truth.
+ const filtering = Boolean(term || activeCat);
+ setCollapsed(filtering);
+ if (clearBtn) clearBtn.hidden = !filtering;
+ if (statusText && status) {
+ const template = (filtering ? status.dataset.labelSome : status.dataset.labelAll) ?? '';
+ statusText.textContent = template.replace('{n}', String(shown));
+ }
}
q?.addEventListener('input', apply);
@@ -249,4 +545,11 @@ const labels = isZh
apply();
});
});
+ clearBtn?.addEventListener('click', () => {
+ if (q) q.value = '';
+ activeCat = '';
+ chips.forEach((c) => c.classList.toggle('is-active', (c as HTMLElement).dataset.cat === ''));
+ apply();
+ q?.focus();
+ });
diff --git a/src/components/PortfolioHero.astro b/src/components/PortfolioHero.astro
new file mode 100644
index 0000000..3245265
--- /dev/null
+++ b/src/components/PortfolioHero.astro
@@ -0,0 +1,165 @@
+---
+/**
+ * PortfolioHero — the compact two-column hero for /categories/case-studies/.
+ *
+ * Round-2 ruling: text on the left, a wide (21:9) architecture visual on the right,
+ * stacking naturally on mobile. The headline, blurb and facts are semantic HTML — the
+ * banner carries no text, so nothing in the image has to be an SEO or a11y duplicate.
+ */
+interface Props {
+ lang: 'en' | 'zh';
+ title: string;
+ blurb?: string;
+ count: number;
+ /** How many of the listed write-ups are the flagship set. */
+ flagshipCount: number;
+}
+
+const { lang, title, blurb, count, flagshipCount } = Astro.props;
+const isZh = lang === 'zh';
+
+const copy = isZh
+ ? {
+ kicker: '实战作品集',
+ factsA: ['篇端到端复盘', '篇旗舰案例置顶'],
+ hosts: '3 台生产主机',
+ containers: '162 个容器',
+ bilingual: '中英双语',
+ cta: '先看旗舰案例',
+ }
+ : {
+ kicker: 'Working portfolio',
+ factsA: ['end-to-end write-ups', 'flagship cases pinned first'],
+ hosts: '3 production hosts',
+ containers: '162 containers',
+ bilingual: 'EN + 中文',
+ cta: 'Read the flagship cases',
+ };
+
+const alt = isZh
+ ? '架构示意图:三台生产主机(Unraid、群晖 NAS、VPS)通过 Docker 容器栈接入 Prometheus 监控中枢,再流向自动化工作流'
+ : 'Architecture diagram: three production hosts (Unraid, Synology NAS, VPS) feeding Docker container stacks into a Prometheus monitoring hub, then into automation workflows';
+---
+
+
+
+
{copy.kicker}
+
{title}
+ {blurb &&
{blurb}
}
+
+
+ {count} {copy.factsA[0]}
+ {flagshipCount} {copy.factsA[1]}
+ {copy.hosts} · {copy.containers}
+ {copy.bilingual}
+
+
+
+ {copy.cta} ↓
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/PostList.astro b/src/components/PostList.astro
index 6983446..7d1a2fb 100644
--- a/src/components/PostList.astro
+++ b/src/components/PostList.astro
@@ -9,9 +9,14 @@ interface Props {
posts: CollectionEntry<'posts'>[];
limit?: number;
lang?: 'en' | 'zh';
+ /**
+ * Measured headline per base slug (see lib/portfolio.ts) — rendered as a pill on a
+ * portfolio card. Omitted on the home page and tag pages, where a bare list is right.
+ */
+ metrics?: Record;
}
-const { posts, limit, lang = 'en' } = Astro.props;
+const { posts, limit, lang = 'en', metrics } = Astro.props;
const shown = limit ? posts.slice(0, limit) : posts;
const isZh = lang === 'zh';
@@ -45,6 +50,20 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
const base = post.slug.replace(/^zh\//, '');
return `/og/thumb/${base}-${w}.webp`;
};
+
+// A case study is a portfolio artefact, so the row's action says so. The same rule is
+// applied in Archive.astro for the /posts/ Featured block.
+const ctaLabel = (category: string) => {
+ if (category === 'case-studies') return isZh ? '阅读案例复盘' : 'Read case study';
+ return isZh ? '阅读文章' : 'Read article';
+};
+const metricFor = (post: CollectionEntry<'posts'>) =>
+ metrics?.[post.slug.replace(/^zh\//, '')];
+
+// A card that lists every tag wraps to two metadata lines on some rows and one on
+// others, so the grid loses its rhythm (and the summary/CTA drift). Show four and
+// fold the rest into a "+N" chip whose tooltip names them.
+const TAG_LIMIT = 4;
---
@@ -76,13 +95,30 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
)}
·
{categoryLabel(post.data.category, lang)}
- {post.data.tags.map((t) => {
+ {post.data.tags.slice(0, TAG_LIMIT).map((t) => {
const href = tagLink(t, counts, lang);
return href ? {t} : {t} ;
})}
+ {post.data.tags.length > TAG_LIMIT && (
+
+ +{post.data.tags.length - TAG_LIMIT}
+
+ )}
{post.data.description}
+
))}
-
\ No newline at end of file
+
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index a8705f2..603635a 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -64,6 +64,20 @@ const switchLabel = isZh ? 'EN' : '中文';
const nav = isZh
? { blueprints: '系统蓝图', caseStudies: '工程案例', posts: '全部文章', about: '关于与合作' }
: { blueprints: 'Blueprints', caseStudies: 'Case Studies', posts: 'All Posts', about: 'Work With Me' };
+
+// Which section is the reader in? Without this the four nav links looked identical on
+// every page, so the new portfolio route had no visible "you are here".
+const sectionPath = Astro.url.pathname.replace(/^\/zh(?=\/|$)/, '') || '/';
+const inSection = {
+ blueprints: sectionPath.startsWith('/blueprints'),
+ caseStudies: sectionPath.startsWith('/categories/case-studies'),
+ posts:
+ (sectionPath.startsWith('/posts') ||
+ sectionPath.startsWith('/tags') ||
+ sectionPath.startsWith('/categories')) &&
+ !sectionPath.startsWith('/categories/case-studies'),
+ about: sectionPath.startsWith('/about') || sectionPath.startsWith('/work-with-me'),
+};
---
@@ -184,10 +198,10 @@ const nav = isZh
whole group onto a second row (`flex-basis: 100%`) instead of letting
individual links wrap around the search / language / theme controls. */}
⌕ {isZh ? '搜索' : 'Search'} /
diff --git a/src/lib/featured.ts b/src/lib/featured.ts
new file mode 100644
index 0000000..9ee72ee
--- /dev/null
+++ b/src/lib/featured.ts
@@ -0,0 +1,99 @@
+import type { CollectionEntry } from 'astro:content';
+import { readingMinutes } from './reading-time';
+import { categoryLabel } from './categories';
+
+type Post = CollectionEntry<'posts'>;
+
+/**
+ * Flagship picks, shared by the home page and the archive's Featured block.
+ *
+ * Fixed slugs rather than "newest 4": the front page has to answer "who is this and
+ * what has he actually run" (scale → automation → observability → a delivered
+ * business result), and that answer must not change because a new post landed.
+ */
+export const FEATURED_SLUGS = [
+ 'running-production-infrastructure-solo',
+ 'loop-engineering-without-a-coding-agent',
+ 'one-prometheus-for-unraid-synology-and-a-vps',
+ 'self-healing-digital-goods-entitlements',
+] as const;
+
+/**
+ * The measured headline for each flagship — a number, not an adjective. Rendered as
+ * a pill next to the category badge so a reader scanning the grid gets the result
+ * before opening anything.
+ */
+export const FEATURED_METRICS: Record = {
+ 'running-production-infrastructure-solo': {
+ en: '3 Hosts · 162 Containers',
+ zh: '3 台主机 · 162 个容器',
+ },
+ 'loop-engineering-without-a-coding-agent': {
+ en: '11 Cron Jobs · 8 Pure Loops',
+ zh: '11 个定时任务 · 8 个循环',
+ },
+ 'one-prometheus-for-unraid-synology-and-a-vps': {
+ en: '3 Hosts · 1 Prometheus',
+ zh: '3 台主机 · 1 个 Prometheus',
+ },
+ 'self-healing-digital-goods-entitlements': {
+ en: 'NocoDB + n8n + AList',
+ zh: 'NocoDB + n8n + AList',
+ },
+};
+
+export interface FeaturedCard {
+ /** Slug as it renders in the URL (ZH posts keep their `zh/` prefix). */
+ slug: string;
+ href: string;
+ title: string;
+ description: string;
+ category: string;
+ categoryLabel: string;
+ metric: string;
+ minutes: number;
+ thumb400: string;
+ thumb800: string;
+}
+
+/**
+ * Turn the fixed slug list into render-ready cards for one language.
+ *
+ * `posts` must already be filtered to a single language (the listing pages do that
+ * with `isEn`/`isZh`). Falls back to the newest posts of that language if a
+ * flagship slug ever disappears, so a renamed file can never empty the block.
+ */
+export function pickFeatured(
+ posts: Post[],
+ lang: 'en' | 'zh' = 'en',
+ limit: number = FEATURED_SLUGS.length,
+): FeaturedCard[] {
+ const base = (slug: string) => slug.replace(/^zh\//, '');
+ const bySlug = new Map(posts.map((p) => [base(p.slug), p]));
+
+ const picked = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(
+ (p): p is Post => Boolean(p),
+ );
+ const source = picked.length >= Math.min(limit, FEATURED_SLUGS.length)
+ ? picked
+ : posts.slice(0, limit);
+
+ return source.slice(0, limit).map((post) => {
+ const clean = base(post.slug);
+ return {
+ slug: post.slug,
+ href: `/posts/${post.slug}/`,
+ title: post.data.title,
+ description: post.data.description,
+ category: post.data.category,
+ categoryLabel: categoryLabel(post.data.category, lang),
+ metric: FEATURED_METRICS[clean]?.[lang] ?? categoryLabel(post.data.category, lang),
+ minutes: readingMinutes(post.body),
+ // Thumbs are keyed by the base slug, never by the og file name: a ZH post's
+ // ogImage is /og/zh-.jpg while the renderer only emits
+ // /og/thumb/ -.webp.
+ thumb400: `/og/thumb/${clean}-400.webp`,
+ thumb800: `/og/thumb/${clean}-800.webp`,
+ };
+ });
+}
diff --git a/src/lib/portfolio.ts b/src/lib/portfolio.ts
new file mode 100644
index 0000000..bd0a9bc
--- /dev/null
+++ b/src/lib/portfolio.ts
@@ -0,0 +1,73 @@
+import type { CollectionEntry } from 'astro:content';
+import { sortForListing } from './sort';
+
+type Post = CollectionEntry<'posts'>;
+
+/**
+ * Which case studies lead the portfolio, and why.
+ *
+ * The consultant's round-2 ruling dropped plain reverse-chronological order for this
+ * category: a portfolio has to open with proof of scale, then proof of the hardest
+ * work, not with whatever happened to be written last. These four cover the four
+ * claims a hiring manager is checking — fleet scale, GPU/local-AI work, end-to-end
+ * automation, and a delivered business result.
+ *
+ * `pickFeatured` (lib/featured.ts) reuses a subset of these for the home page and the
+ * /posts/ Featured block, so the site never tells two different "flagship" stories.
+ */
+export const CASE_STUDY_FLAGSHIPS = [
+ 'running-production-infrastructure-solo',
+ 'self-hosted-tts-cosyvoice2-n8n-gpu',
+ 'loop-engineering-without-a-coding-agent',
+ 'self-healing-digital-goods-entitlements',
+] as const;
+
+/**
+ * A measured headline per case study — shown as a pill on the portfolio cards.
+ *
+ * Only entries taken from the post body are listed here (container counts, VRAM, cron
+ * counts, release counts). A post without a verifiable number gets no pill rather than
+ * a decorative one; `orderCaseStudies` never invents a metric.
+ */
+export const CASE_STUDY_METRICS: Record = {
+ 'running-production-infrastructure-solo': { en: '3 Hosts · 162 Containers', zh: '3 台主机 · 162 个容器' },
+ 'one-prometheus-for-unraid-synology-and-a-vps': { en: '3 Hosts · 1 Prometheus', zh: '3 台主机 · 1 个 Prometheus' },
+ 'loop-engineering-without-a-coding-agent': { en: '11 Cron Jobs · 8 Loops', zh: '11 个定时任务 · 8 个循环' },
+ 'self-healing-digital-goods-entitlements': { en: 'NocoDB + n8n + AList', zh: 'NocoDB + n8n + AList' },
+ 'self-hosted-tts-cosyvoice2-n8n-gpu': { en: 'RTX 3060 · 3.2 GB VRAM', zh: 'RTX 3060 · 3.2 GB 显存' },
+ 'self-hosted-speech-to-text-api': { en: '3× faster than typing', zh: '比打字快 3 倍' },
+ 'how-i-vetted-20-vps-providers-with-parallel-subagents': { en: '20 VPS · 3 hours', zh: '20 家 VPS · 3 小时' },
+ 'how-i-built-the-digikedai-telegram-bot': { en: '24/7 instant reply', zh: '24/7 即时回复' },
+ 'how-i-built-an-automated-music-video-pipeline': { en: 'Remotion · Docker CDP', zh: 'Remotion · Docker CDP' },
+ 'from-audio-to-a-4-minute-ai-music-video': { en: '4-minute MV · one pipeline', zh: '4 分钟 MV · 一条流水线' },
+ 'how-i-host-this-blog': { en: 'Astro · Gitea Actions', zh: 'Astro · Gitea Actions' },
+ 'self-hosting-mem0-memory-stack': { en: 'mem0 · pgvector · LiteLLM', zh: 'mem0 · pgvector · LiteLLM' },
+ 'shipping-an-ai-photo-editor-as-a-wordpress-plugin': { en: '22 releases', zh: '22 个版本' },
+ 'ai-furniture-compositing-with-flux-kontext': { en: 'FLUX.1 Kontext', zh: 'FLUX.1 Kontext' },
+ 'yue2-on-a-12gb-gpu-songs-that-lost-their-endings': { en: 'YuE2 · 12 GB VRAM', zh: 'YuE2 · 12 GB 显存' },
+ 'generating-full-length-songs-with-yue2-comfyui': { en: 'ComfyUI · YuE2', zh: 'ComfyUI · YuE2' },
+};
+
+const baseSlug = (slug: string) => slug.replace(/^zh\//, '');
+
+/**
+ * Flagships first (in the declared order), then everything else newest-first.
+ * Stable across languages: the same slugs lead /categories/case-studies/ and
+ * /zh/categories/case-studies/.
+ */
+export function orderCaseStudies(posts: Post[]): Post[] {
+ const bySlug = new Map(posts.map((p) => [baseSlug(p.slug), p]));
+ const flagshipPosts = CASE_STUDY_FLAGSHIPS.map((s) => bySlug.get(s)).filter(
+ (p): p is Post => Boolean(p),
+ );
+ const flagshipSet = new Set(flagshipPosts.map((p) => p.slug));
+ const rest = sortForListing(posts.filter((p) => !flagshipSet.has(p.slug)));
+ return [...flagshipPosts, ...rest];
+}
+
+/** Metrics map for PostList, keyed by base slug so an EN page and its ZH twin match. */
+export function caseStudyMetrics(lang: 'en' | 'zh'): Record {
+ const out: Record = {};
+ for (const [slug, value] of Object.entries(CASE_STUDY_METRICS)) out[slug] = value[lang];
+ return out;
+}
diff --git a/src/pages/categories/[category].astro b/src/pages/categories/[category].astro
index 3750440..f04d58e 100644
--- a/src/pages/categories/[category].astro
+++ b/src/pages/categories/[category].astro
@@ -3,10 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import { SITE } from '../../config';
import PostList from '../../components/PostList.astro';
import CategoryArt from '../../components/CategoryArt.astro';
+import PortfolioHero from '../../components/PortfolioHero.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
import { categoryMeta } from '../../lib/categories';
+import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../lib/portfolio';
export async function getStaticPaths() {
const posts = await getCollection('posts', isEn);
@@ -17,30 +19,54 @@ export async function getStaticPaths() {
const { category } = Astro.params;
const meta = categoryMeta(category as string);
-const posts = sortForListing(
- (await getCollection('posts', isEn)).filter((p) => p.data.category === category),
+// case-studies is the portfolio: flagship write-ups lead instead of the newest post,
+// and each card carries its measured headline. Every other category stays plain
+// reverse-chronological.
+const isPortfolio = category === 'case-studies';
+const inCategory = (await getCollection('posts', isEn)).filter(
+ (p) => p.data.category === category,
);
+const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
+const metrics = isPortfolio ? caseStudyMetrics('en') : undefined;
+const flagshipCount = isPortfolio
+ ? posts.filter((p) => (CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug)).length
+ : 0;
const name = meta?.name.en ?? category;
const blurb = meta?.blurb.en;
---
+
- {name}
- {blurb && {blurb}
}
+ {isPortfolio ? (
+
+ ) : (
+ <>
+ {name}
+ {blurb && {blurb}
}
-
-
-
+
+
+
+ >
+ )}
-
- {posts.length} post{posts.length === 1 ? '' : 's'} in this category
-
+
+
+ {posts.length} post{posts.length === 1 ? '' : 's'} in this category
+
-
+
+
\ No newline at end of file
diff --git a/src/pages/index.astro b/src/pages/index.astro
index f3962c2..52f7d7c 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -5,31 +5,18 @@ import { getCollection } from 'astro:content';
import { isEn } from '../lib/lang';
import { SITE } from '../config';
import { sortForListing } from '../lib/sort';
+import { pickFeatured } from '../lib/featured';
const posts = sortForListing(await getCollection('posts', isEn));
const engineeringPosts = posts.filter((p) => p.data.stream !== 'creative-lab');
const creativeLabPosts = posts.filter((p) => p.data.stream === 'creative-lab');
-// Featured picks: fixed slugs, not "newest 4", so the front page stays stable after a
-// publish and keeps answering "who is this and what has he actually run" (scale →
-// automation → observability → a delivered business result). Falls back to the newest
-// posts if a slug ever disappears.
-const FEATURED_SLUGS = [
- 'running-production-infrastructure-solo',
- 'loop-engineering-without-a-coding-agent',
- 'one-prometheus-for-unraid-synology-and-a-vps',
- 'self-healing-digital-goods-entitlements',
-];
-const bySlug = new Map(posts.map((p) => [p.slug, p]));
-const featured = FEATURED_SLUGS.map((s) => bySlug.get(s)).filter(Boolean);
-const featuredPosts = featured.length === 4 ? featured : posts.slice(0, 4);
-
-const featuredMetrics: Record = {
- 'running-production-infrastructure-solo': '3 Hosts · 162 Containers',
- 'loop-engineering-without-a-coding-agent': '11 Cron Jobs · 8 Pure Loops',
- 'one-prometheus-for-unraid-synology-and-a-vps': '3 Hosts · 1 Prometheus',
- 'self-healing-digital-goods-entitlements': 'NocoDB + n8n + AList',
-};
+// Featured picks live in src/lib/featured.ts so the front page and the /posts/
+// archive render the same flagship set with the same measured headline. Fixed slugs,
+// not "newest 4", so the front page stays stable after a publish — see the comment
+// in that module for the ordering rationale (scale → automation → observability →
+// a delivered business result).
+const featured = pickFeatured(posts, 'en', 4);
---
@@ -142,15 +129,13 @@ const featuredMetrics: Record = {
Featured
- {featuredPosts.map((post, idx) => {
- const cleanSlug = post.slug.replace(/^zh\//, '');
- const metric = featuredMetrics[cleanSlug] ?? post.data.category;
+ {featured.map((post, idx) => {
return (
-
+
= {
- {post.data.category}
+ {post.category}
- {metric}
+ {post.metric}
-
{post.data.title}
-
{post.data.description}
+
{post.title}
+
{post.description}
Read case study →
diff --git a/src/pages/posts/index.astro b/src/pages/posts/index.astro
index 7df5a1c..68720b1 100644
--- a/src/pages/posts/index.astro
+++ b/src/pages/posts/index.astro
@@ -4,12 +4,18 @@ import Archive from '../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isEn } from '../../lib/lang';
import { sortForListing } from '../../lib/sort';
+import { pickFeatured } from '../../lib/featured';
import { SITE } from '../../config';
// One static page for all posts: the filter (title/tags/category) runs client-side
// over this already-rendered list, so it costs no requests and Ctrl+F still works.
// Before 2026-10-16 this page rendered 71 full cards and stood 18,220px tall.
+//
+// Since 2026-10-27 the page is hybrid: three flagship case-study cards on top, then
+// the full year/month archive. The featured block collapses as soon as the reader
+// filters (Archive.astro), handing the viewport to the results.
const posts = sortForListing(await getCollection('posts', isEn));
+const featured = pickFeatured(posts, 'en', 3);
---
tag page.
-
+
diff --git a/src/pages/zh/categories/[category].astro b/src/pages/zh/categories/[category].astro
index 3ab8eec..00b44f4 100644
--- a/src/pages/zh/categories/[category].astro
+++ b/src/pages/zh/categories/[category].astro
@@ -3,10 +3,12 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
import { SITE } from '../../../config';
import PostList from '../../../components/PostList.astro';
import CategoryArt from '../../../components/CategoryArt.astro';
+import PortfolioHero from '../../../components/PortfolioHero.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
import { categoryMeta } from '../../../lib/categories';
+import { CASE_STUDY_FLAGSHIPS, caseStudyMetrics, orderCaseStudies } from '../../../lib/portfolio';
export async function getStaticPaths() {
const posts = await getCollection('posts', isZh);
@@ -17,14 +19,25 @@ export async function getStaticPaths() {
const { category } = Astro.params;
const meta = categoryMeta(category as string);
-const posts = sortForListing(
- (await getCollection('posts', isZh)).filter((p) => p.data.category === category),
+// Same portfolio treatment as the EN page: flagships lead, cards carry the measured
+// headline, and the ZH twins are matched by base slug so both languages agree.
+const isPortfolio = category === 'case-studies';
+const inCategory = (await getCollection('posts', isZh)).filter(
+ (p) => p.data.category === category,
);
+const posts = isPortfolio ? orderCaseStudies(inCategory) : sortForListing(inCategory);
+const metrics = isPortfolio ? caseStudyMetrics('zh') : undefined;
+const flagshipCount = isPortfolio
+ ? posts.filter((p) =>
+ (CASE_STUDY_FLAGSHIPS as readonly string[]).includes(p.slug.replace(/^zh\//, '')),
+ ).length
+ : 0;
const name = meta?.name.zh ?? category;
const blurb = meta?.blurb.zh;
---
+
- {name}
- {blurb && {blurb}
}
+ {isPortfolio ? (
+
+ ) : (
+ <>
+ {name}
+ {blurb && {blurb}
}
-
-
-
+
+
+
+ >
+ )}
- 本分类共 {posts.length} 篇文章
+
+ 本分类共 {posts.length} 篇文章
-
+
+
\ No newline at end of file
diff --git a/src/pages/zh/posts/index.astro b/src/pages/zh/posts/index.astro
index 61fc156..802cd39 100644
--- a/src/pages/zh/posts/index.astro
+++ b/src/pages/zh/posts/index.astro
@@ -4,12 +4,14 @@ import Archive from '../../../components/Archive.astro';
import { getCollection } from 'astro:content';
import { isZh } from '../../../lib/lang';
import { sortForListing } from '../../../lib/sort';
+import { pickFeatured } from '../../../lib/featured';
import { SITE } from '../../../config';
// The ZH archive. Until 2026-10-16 the ZH nav pointed "文章/关于" at /zh/ and this
// route did not exist at all: /posts/zh/ (a directory with no index) answered 403
// from nginx, and /zh/posts/ 404'd. Both are fixed here + in the nginx conf.
const posts = sortForListing(await getCollection('posts', isZh));
+const featured = pickFeatured(posts, 'zh', 3);
---
标签页 把一个主题读完。
-
+
diff --git a/src/styles/global.css b/src/styles/global.css
index 4a02f96..c51389b 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -139,6 +139,20 @@ body {
white-space: nowrap;
}
.nav .nav-link:hover { color: var(--brand); }
+.nav .nav-link[aria-current='page'] {
+ color: var(--ink);
+ position: relative;
+}
+.nav .nav-link[aria-current='page']::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -0.35rem;
+ height: 2px;
+ border-radius: 2px;
+ background: var(--brand);
+}
.nav-links {
display: flex;
align-items: center;
@@ -361,6 +375,12 @@ span.tag {
background: transparent;
border: 1px dashed var(--border);
}
+span.tag-more {
+ font-variant-numeric: tabular-nums;
+ color: var(--muted);
+ border-style: solid;
+ cursor: help;
+}
/* ---------- Code ---------- */
code {
@@ -1352,6 +1372,49 @@ pre code {
}
}
+/* Row footer: the action wording (article vs case study) plus, on the portfolio
+ category, the measured headline for that write-up. */
+.post-list .row-foot {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-top: 0.6rem;
+ min-height: 28px;
+}
+.post-list .row-cta {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--brand);
+ text-decoration: none;
+ min-height: 28px;
+ display: inline-flex;
+ align-items: center;
+}
+.post-list .row-cta:hover { text-decoration: underline; }
+.post-list .row-metric {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ color: var(--muted);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.12rem 0.55rem;
+}
+
+/* Touch targets: on a phone the card's title link is the primary tap surface, and a
+ single-line title only measured ~26 px. Give it the 44 px minimum. */
+@media (max-width: 700px) {
+ .post-list .body > a:first-child {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ }
+}
+
/* ---------- Page head (lead paragraphs on listing pages) ---------- */
.page-head h1 { margin: 0 0 0.75rem; }
.lede {