Severity: medium · Effort: S · Aspect: ux
Goal
Blog post hero images should render at a reserved, predictable box size so the page does not visibly jump (CLS) while the image loads.
Context & Why
Every blog post page (https://blog.duyet.net/{year}/{month}/{slug}) renders its hero image via -post-hero.tsx with loading="eager" (i.e. it is intentionally not lazy-loaded, because it's above the fold) but no width/height attributes and no aspect-ratio reservation in its class list. Because the browser doesn't know the image's intrinsic size until it downloads, the surrounding header content (byline, reading time, TOC controls) visibly reflows/jumps once the image loads — a textbook Cumulative Layout Shift regression, and one an end user on a slow connection will directly experience as page "jumping" while reading. This affects every single post that has a post.thumbnail, i.e. most of the blog's traffic-driving pages.
Evidence
apps/blog/src/routes/$year/$month/-post-hero.tsx:53-60:
{post.thumbnail && (
``<img· src={post.thumbnail}· alt={post.title}· loading="eager"· className="relative left-1/2 -translate-x-1/2 block w-[92vw] max-w-[1024px] max-h-[88vh] object-contain my-[30px] rounded-[var(--rd-r)]"· />``
)}
No width, height, or CSS aspect-ratio is set — the browser has zero size hint before the image downloads.
- Contrast with
apps/blog/components/MdxComponents.tsx:64-71, which handles in-body images and at least wraps them with loading="lazy" (deferring the shift until scroll, though it too lacks explicit dimensions) — the hero is worse because it's eager/above-the-fold, where CLS is most visible and most heavily weighted by Core Web Vitals.
apps/blog/CLAUDE.md confirms: "Images are served unoptimized (static SPA, no image optimization server)" — so there's no build-time image-processing step that could inject dimensions automatically; they must be set explicitly or the frontmatter/data layer must supply them.
Suggested approach
If post frontmatter/data (post.thumbnail) doesn't already carry known dimensions, either (a) probe/store the image's natural width/height at build time (in the existing prebuild pipeline that already processes _posts/) and pass them through as width/height props, or (b) reserve space purely in CSS with a fixed aspect-ratio on the wrapping element sized to the common thumbnail aspect ratio, accepting minor letterboxing for outliers. Apply the same treatment to apps/blog/components/home/FeaturedPost.tsx:33 and apps/blog/components/post/FeaturedPost.tsx:27, which look like they render the same kind of hero/featured image without explicit dimensions either.
Validation
Run a Lighthouse/PageSpeed Insights audit against a real post URL (e.g. any https://blog.duyet.net/{year}/{month}/{slug} with a thumbnail) on a throttled connection, and confirm the CLS metric drops to ~0 for that page, with no visible header reflow once the hero image finishes loading.
Filed by automated multi-repo audit (2026-07-16) — aspect: ux (UI/UX and accessibility).
Severity: medium · Effort: S · Aspect: ux
Goal
Blog post hero images should render at a reserved, predictable box size so the page does not visibly jump (CLS) while the image loads.
Context & Why
Every blog post page (
https://blog.duyet.net/{year}/{month}/{slug}) renders its hero image via-post-hero.tsxwithloading="eager"(i.e. it is intentionally not lazy-loaded, because it's above the fold) but nowidth/heightattributes and no aspect-ratio reservation in its class list. Because the browser doesn't know the image's intrinsic size until it downloads, the surrounding header content (byline, reading time, TOC controls) visibly reflows/jumps once the image loads — a textbook Cumulative Layout Shift regression, and one an end user on a slow connection will directly experience as page "jumping" while reading. This affects every single post that has apost.thumbnail, i.e. most of the blog's traffic-driving pages.Evidence
apps/blog/src/routes/$year/$month/-post-hero.tsx:53-60:width,height, or CSSaspect-ratiois set — the browser has zero size hint before the image downloads.apps/blog/components/MdxComponents.tsx:64-71, which handles in-body images and at least wraps them withloading="lazy"(deferring the shift until scroll, though it too lacks explicit dimensions) — the hero is worse because it's eager/above-the-fold, where CLS is most visible and most heavily weighted by Core Web Vitals.apps/blog/CLAUDE.mdconfirms: "Images are served unoptimized (static SPA, no image optimization server)" — so there's no build-time image-processing step that could inject dimensions automatically; they must be set explicitly or the frontmatter/data layer must supply them.Suggested approach
If post frontmatter/data (
post.thumbnail) doesn't already carry known dimensions, either (a) probe/store the image's natural width/height at build time (in the existing prebuild pipeline that already processes_posts/) and pass them through aswidth/heightprops, or (b) reserve space purely in CSS with a fixedaspect-ratioon the wrapping element sized to the common thumbnail aspect ratio, accepting minor letterboxing for outliers. Apply the same treatment toapps/blog/components/home/FeaturedPost.tsx:33andapps/blog/components/post/FeaturedPost.tsx:27, which look like they render the same kind of hero/featured image without explicit dimensions either.Validation
Run a Lighthouse/PageSpeed Insights audit against a real post URL (e.g. any
https://blog.duyet.net/{year}/{month}/{slug}with a thumbnail) on a throttled connection, and confirm the CLS metric drops to ~0 for that page, with no visible header reflow once the hero image finishes loading.Filed by automated multi-repo audit (2026-07-16) — aspect: ux (UI/UX and accessibility).