feat(listing): portfolio-first archive, case-study hero, row action links

/posts/ and /zh/posts/ become hybrid: three fixed-slug flagship cards above the
complete year/month archive, with the flagship block collapsing (inert, h=0) as
soon as the reader filters. /categories/case-studies/ becomes a portfolio: compact
two-column hero with a 21:9 deterministic topology banner, flagship ordering from
lib/portfolio.ts instead of pubDate, and one measured pill per card.

Also fixes what the screenshot review turned up: ZH archive rows and category chips
were printing raw English slugs, the two chip strips were unlabelled and read as one
list with a duplicated 'devops', card meta rows broke rhythm past four tags, the nav
had no current-section indicator, and mobile title links were under the 44px tap
minimum. scripts/probe-layout.mjs is the repeatable gate: 72/72 page x viewport
checks and the collapse test.
This commit is contained in:
hoelee committed 2026-10-27 04:06:54 +08:00
1 parent 68771101ea
commit 041471d25e
17 files changed
+1632 -76

No files matched your search

+39 -3
View File
@@ -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<string, string>;
}
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;
---
<ul class="post-list">
@@ -76,13 +95,30 @@ const thumb = (post: CollectionEntry<'posts'>, w: 400 | 800) => {
)}
<span class="sep">·</span>
<a class="tag" href={catHref(post.data.category)}>{categoryLabel(post.data.category, lang)}</a>
{post.data.tags.map((t) => {
{post.data.tags.slice(0, TAG_LIMIT).map((t) => {
const href = tagLink(t, counts, lang);
return href ? <a class="tag" href={href}>{t}</a> : <span class="tag">{t}</span>;
})}
{post.data.tags.length > TAG_LIMIT && (
<span class="tag tag-more" title={post.data.tags.slice(TAG_LIMIT).join(', ')}>
+{post.data.tags.length - TAG_LIMIT}
</span>
)}
</div>
<p>{post.data.description}</p>
<div class="row-foot">
{/* A second visual target for the same destination — deliberately out of the
tab order so keyboard users get one stop per card, not two. */}
<a class="row-cta" href={`/posts/${post.slug}/`} tabindex="-1" aria-hidden="true">
{ctaLabel(post.data.category)} <span aria-hidden="true">→</span>
</a>
{metricFor(post) && (
<span class="row-metric">
<span class="pulse-dot" aria-hidden="true"></span>{metricFor(post)}
</span>
)}
</div>
</div>
</li>
))}
</ul>
</ul>