From d67ebb2a35fbe9d134f1869308304050b3719b39 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 26 May 2026 07:56:13 -0400 Subject: [PATCH 1/3] =?UTF-8?q?Show=20newest=20research=20on=20home=20?= =?UTF-8?q?=E2=80=94=20posts=20and=20tools=20combined?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Expert policy analysis" section was pulling only blog posts, but the team has been shipping interactive tools (CliffWatch, South Carolina 2026, Coverage Compass, etc.) faster than written posts. As a result the home preview was lagging by ~2 months on /us and missing recent UK tools on /uk. Switches HomeBlogPreview from getPostsSorted() to getResearchItems(), which merges posts + apps with displayWithResearch into one newest-first feed. Cards now route correctly per item type: posts continue to use Next.js Link to //research/, while apps (served via Vercel rewrites) use a plain to // so the request hits the rewrite instead of a client-side 404. CTA flips to "Open" for apps and "Read more" for posts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/home/HomeBlogPreview.tsx | 161 ++++++++++-------- 1 file changed, 92 insertions(+), 69 deletions(-) diff --git a/website/src/components/home/HomeBlogPreview.tsx b/website/src/components/home/HomeBlogPreview.tsx index 950195c5a..a3e2854b3 100644 --- a/website/src/components/home/HomeBlogPreview.tsx +++ b/website/src/components/home/HomeBlogPreview.tsx @@ -4,34 +4,27 @@ import Link from "next/link"; import { Container, Group, Text } from "@/components/ui"; import OptimisedImage from "@/components/ui/OptimisedImage"; import { cn } from "@/lib/utils"; +import { colors, spacing, typography } from "@/designTokens"; import { - colors, - spacing, - typography, -} from "@/designTokens"; -import { getPostsSorted } from "@/data/posts/postTransformers"; -import type { BlogPost } from "@/types/blog"; + getResearchItems, + type ResearchItem, +} from "@/data/posts/postTransformers"; -/** - * Number of posts shown in the blog preview on the home page. - * 2 on the left (primary cards), 3 on the right (secondary cards). - */ const LEFT_COUNT = 2; const RIGHT_COUNT = 3; -const TOTAL_POSTS = LEFT_COUNT + RIGHT_COUNT; +const TOTAL_ITEMS = LEFT_COUNT + RIGHT_COUNT; -function getPostImageUrl(post: BlogPost): string { - if (!post.image) { +function getItemImageUrl(item: ResearchItem): string { + if (!item.image) { return ""; } - if (post.image.startsWith("http")) { - return post.image; + if (item.image.startsWith("http")) { + return item.image; } - return `/assets/posts/${post.image}`; + return `/assets/posts/${item.image}`; } -function formatPostDate(dateStr: string): string { - // Append T12:00:00 to date-only strings to avoid UTC midnight timezone shift +function formatItemDate(dateStr: string): string { const normalized = /^\d{4}-\d{2}-\d{2}$/.test(dateStr) ? `${dateStr}T12:00:00` : dateStr; @@ -42,23 +35,61 @@ function formatPostDate(dateStr: string): string { }); } +function getItemHref(item: ResearchItem, countryId: string): string { + return item.isApp + ? `/${item.countryId}/${item.slug}` + : `/${countryId}/research/${item.slug}`; +} + +// Apps are served via Vercel rewrites (reverse proxy), so they need a full +// document request — Next.js client navigation would hit a 404. +function CardLink({ + item, + countryId, + className, + style, + children, +}: { + item: ResearchItem; + countryId: string; + className?: string; + style?: React.CSSProperties; + children: React.ReactNode; +}) { + const href = getItemHref(item, countryId); + if (item.isApp) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + /* ------------------------------------------------------------------ */ /* PrimaryCard */ /* ------------------------------------------------------------------ */ interface PrimaryCardProps { - post: BlogPost; + item: ResearchItem; countryId: string; flex?: number; } -function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) { - const imageUrl = getPostImageUrl(post); - const date = formatPostDate(post.date); +function PrimaryCard({ item, countryId, flex }: PrimaryCardProps) { + const imageUrl = getItemImageUrl(item); + const date = formatItemDate(item.date); + const cta = item.isApp ? "Open" : "Read more"; return ( - @@ -75,7 +106,7 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
{ @@ -91,19 +122,19 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {

- {post.title} + {item.title}

- {post.description} + {item.description}

- Read more → + {cta} →

- + ); } @@ -112,17 +143,19 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) { /* ------------------------------------------------------------------ */ interface SecondaryCardProps { - post: BlogPost; + item: ResearchItem; countryId: string; } -function SecondaryCard({ post, countryId }: SecondaryCardProps) { - const imageUrl = getPostImageUrl(post); - const date = formatPostDate(post.date); +function SecondaryCard({ item, countryId }: SecondaryCardProps) { + const imageUrl = getItemImageUrl(item); + const date = formatItemDate(item.date); + const cta = item.isApp ? "Open" : "Read"; return ( -
{ @@ -154,19 +187,19 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) {

- {post.title} + {item.title}

- {post.description} + {item.description}

- Read → + {cta} →

- + ); } @@ -174,25 +207,21 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) { /* HomeBlogPreview */ /* ------------------------------------------------------------------ */ -export default function HomeBlogPreview({ - countryId, -}: { - countryId: string; -}) { - // getPostsSorted() returns posts sorted newest-first with slugs pre-computed - const relevantPosts = getPostsSorted() +export default function HomeBlogPreview({ countryId }: { countryId: string }) { + // Merged research feed: posts + apps with displayWithResearch, newest-first. + // Filter by country tag so /us shows US-relevant items and /uk shows UK. + const relevantItems = getResearchItems() .filter( - (post: BlogPost) => - post.tags.includes(countryId) || post.tags.includes("global"), + (item) => item.tags.includes(countryId) || item.tags.includes("global"), ) - .slice(0, TOTAL_POSTS); + .slice(0, TOTAL_ITEMS); - if (relevantPosts.length === 0) { + if (relevantItems.length === 0) { return null; } - const leftPosts = relevantPosts.slice(0, LEFT_COUNT); - const rightPosts = relevantPosts.slice(LEFT_COUNT, TOTAL_POSTS); + const leftItems = relevantItems.slice(0, LEFT_COUNT); + const rightItems = relevantItems.slice(LEFT_COUNT, TOTAL_ITEMS); return (
- {/* Left column: 2 posts stacked, filling equal height */} -
- {leftPosts.map((post: BlogPost) => ( + {/* Left column: 2 items stacked, filling equal height */} +
+ {leftItems.map((item) => ( ))}
- {/* Right column: 3 smaller posts stacked */} -
- {rightPosts.map((post: BlogPost) => ( + {/* Right column: 3 smaller items stacked */} +
+ {rightItems.map((item) => ( ))} From 2897fa04433ddb936d3c08135fe861e115b84620 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 26 May 2026 08:49:18 -0400 Subject: [PATCH 2/3] Serve 2x assets on retina screens via srcset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OptimisedImage hardcoded dpr = 1, so an image asked to fill a 640px slot was fetched at 640px and then upscaled by the browser to ~1280 physical pixels on every retina/high-DPI display — visibly soft across hero, team photos, blog/research cards, and tracker thumbnails. Switches to a 1x/2x srcset: the browser picks the right asset per device. Low-DPI screens keep using the 1x file (no bandwidth waste), high-DPI screens get the sharper 2x. When the snapped 1x and 2x widths collide (e.g. both round to the same ALLOWED_WIDTHS bucket) we drop the descriptor so the browser doesn't double-fetch the same file. Also factored out the bypass conditions into shouldSkipOptimisation() so the main render branch reads as straight srcset construction. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/components/ui/OptimisedImage.tsx | 87 ++++++++++++-------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/website/src/components/ui/OptimisedImage.tsx b/website/src/components/ui/OptimisedImage.tsx index eda12932e..234c6be45 100644 --- a/website/src/components/ui/OptimisedImage.tsx +++ b/website/src/components/ui/OptimisedImage.tsx @@ -1,4 +1,4 @@ -import type { ImgHTMLAttributes } from 'react'; +import type { ImgHTMLAttributes } from "react"; /** * Wraps an to serve images through Vercel's edge image optimisation. @@ -17,7 +17,10 @@ interface StaticImageData { width: number; } -interface OptimisedImageProps extends Omit, 'src'> { +interface OptimisedImageProps extends Omit< + ImgHTMLAttributes, + "src" +> { /** Desired display width in pixels — used for resizing on the edge. */ width?: number; /** Image quality 1–100 (default 80). */ @@ -39,36 +42,22 @@ function snapWidth(w: number): number { return ALLOWED_WIDTHS[ALLOWED_WIDTHS.length - 1]; } -function optimisedSrc(src: string, width?: number, quality = 80): string { - // Only optimise local paths served from the same origin - if (!src.startsWith('/')) { - return src; - } - - // Skip _next/static paths — already optimized at build time by Next.js - if (src.startsWith('/_next/')) { - return src; - } - - // Skip SVGs — vector images can't be raster-optimized - if (src.endsWith('.svg')) { - return src; - } - - // Skip in dev — Vercel image API isn't available locally - if (process.env.NODE_ENV === 'development') { - return src; - } - - // Vercel's image endpoint requires an explicit width. - // Fall back to the original asset path when callers omit one. - if (!width) { - return src; - } +/** + * True when the asset should bypass Vercel's image optimiser (external URLs, + * already-optimised Next.js static chunks, SVGs, local dev, or missing width). + */ +function shouldSkipOptimisation(src: string, width?: number): boolean { + if (!src.startsWith("/")) return true; + if (src.startsWith("/_next/")) return true; + if (src.endsWith(".svg")) return true; + if (process.env.NODE_ENV === "development") return true; + if (!width) return true; + return false; +} - const dpr = 1; +function optimisedSrc(src: string, width: number, quality = 80): string { const params = new URLSearchParams({ url: src, q: String(quality) }); - params.set('w', String(snapWidth(Math.round(width * dpr)))); + params.set("w", String(snapWidth(Math.round(width)))); return `/_vercel/image?${params}`; } @@ -76,12 +65,42 @@ export default function OptimisedImage({ src, width, quality = 80, - alt = '', + alt = "", ...rest }: OptimisedImageProps) { // Resolve Next.js static imports (StaticImageData) to their .src string - const rawSrc = typeof src === 'object' && src !== null && 'src' in src ? src.src : src; - const resolvedSrc = typeof rawSrc === 'string' ? optimisedSrc(rawSrc, width, quality) : undefined; + const rawSrc = + typeof src === "object" && src !== null && "src" in src ? src.src : src; + + if (typeof rawSrc !== "string") { + return ( + {alt} + ); + } + + if (shouldSkipOptimisation(rawSrc, width)) { + return ( + {alt} + ); + } + + // width is guaranteed by shouldSkipOptimisation above + const w = width!; + const src1x = optimisedSrc(rawSrc, w, quality); + const src2x = optimisedSrc(rawSrc, w * 2, quality); + + // If 1x and 2x snap to the same allowed width, the browser would download + // the same file twice via srcset — skip the descriptor in that case. + const srcSet = src1x === src2x ? undefined : `${src1x} 1x, ${src2x} 2x`; - return {alt}; + return ( + {alt} + ); } From 6c548d1b182cca40784d16d6a92707f1a2363099 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 26 May 2026 08:53:59 -0400 Subject: [PATCH 3/3] Match right-column card width hint to actual render size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-column cards (SecondaryCard) were passing width=384 to OptimisedImage, but they actually render at the same ~600 CSS px as the left-column cards because the grid is two equal columns. At DPR=2 the browser needs ~1200 physical pixels of data; the 384-hint 2x snapped only to 828 → still upscaled → still soft on retina. Bumping to 640 matches the primary cards, snapping the 2x to 1920 — plenty sharp. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/components/home/HomeBlogPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/home/HomeBlogPreview.tsx b/website/src/components/home/HomeBlogPreview.tsx index a3e2854b3..964a9050c 100644 --- a/website/src/components/home/HomeBlogPreview.tsx +++ b/website/src/components/home/HomeBlogPreview.tsx @@ -172,7 +172,7 @@ function SecondaryCard({ item, countryId }: SecondaryCardProps) { { e.currentTarget.style.display = "none";