diff --git a/package.json b/package.json
index 3a4e14c..4bcbbe9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@isimplifyme/ui",
- "version": "1.4.3",
+ "version": "1.5.0",
"description": "React/Next.js UI library for iSimplifyMe properties \u2014 design system, article layouts, SEO helpers, bot middleware, and the iSM Concierge widget.",
"homepage": "https://isimplifyme.com",
"type": "module",
diff --git a/src/bento-grid.tsx b/src/bento-grid.tsx
index 86300bc..eeb05b9 100644
--- a/src/bento-grid.tsx
+++ b/src/bento-grid.tsx
@@ -1,6 +1,7 @@
'use client'
import { motion } from 'framer-motion'
+import Image from 'next/image'
// bentoCard and stagger imports removed — individual cards now use whileInView with index-based delay
import type { ReactNode } from 'react'
@@ -16,6 +17,9 @@ export interface BentoItem {
backgroundImage?: string
/** Alt text for background image */
backgroundAlt?: string
+ /** `sizes` for the background image. Default assumes a 4-column grid where a
+ * card may span 2. Set it per item when the span is known. */
+ imageSizes?: string
/** Accent color for hover effects: 'fallout' | 'cobalt' | 'hazard' */
accentColor?: 'fallout' | 'cobalt' | 'hazard'
}
@@ -90,10 +94,15 @@ export function BentoGrid({ items, className = '' }: BentoGridProps) {
{/* Background image layer */}
{item.backgroundImage && (
<>
-
: this fetched the source file at
+ full resolution to paint a ~312px tile. See related-articles
+ for the measurement that prompted the change. */}
+
>
diff --git a/src/related-articles.test.tsx b/src/related-articles.test.tsx
new file mode 100644
index 0000000..3c2090e
--- /dev/null
+++ b/src/related-articles.test.tsx
@@ -0,0 +1,175 @@
+/**
+ * The thumbnails these two components paint.
+ *
+ * Until 2026-09-20 both rendered a raw `
`, so a browser
+ * downloaded the original at full resolution to fill a card: measured on
+ * endsights.com/roblox-tycoon-games, 3.905 MB of image weight for three
+ * 379x237 thumbnails, two of them 1.9 MB each. The suite was green the whole
+ * time, because neither component had a single test — a green run on a
+ * component with no coverage is not evidence.
+ *
+ * So these assertions pin the property that produced the saving (the browser
+ * is offered narrow candidates and a `sizes` hint, never the source file at
+ * its own URL) rather than the identity of the component that provides it.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { createRoot, type Root } from 'react-dom/client';
+import { act } from 'react';
+import { RelatedArticles } from './related-articles';
+import { BentoGrid } from './bento-grid';
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ // Both components reveal their cards with framer-motion's `whileInView`,
+ // which reaches for IntersectionObserver on mount. jsdom ships none. A stub
+ // that never reports an intersection is the honest default: it leaves the
+ // cards in their initial state, and nothing here asserts on the reveal.
+ vi.stubGlobal(
+ 'IntersectionObserver',
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords() {
+ return [];
+ }
+ root = null;
+ rootMargin = '';
+ thresholds = [];
+ },
+ );
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+const post = (over: Record = {}) => ({
+ title: 'Roblox Obby Games in 2026',
+ slug: 'roblox-obby-games',
+ date: '2026-08-10',
+ excerptText: 'Why the oldest genre still outlasts its trends.',
+ image: '/images/roblox-obby-genre.jpg',
+ category: 'Roblox News',
+ readingTime: '9 min read',
+ ...over,
+});
+
+/** The `w` descriptors a `srcset` offers, as numbers. */
+function candidates(img: HTMLImageElement): number[] {
+ return (img.getAttribute('srcset') ?? '')
+ .split(',')
+ .map((c) => Number(c.trim().split(/\s+/)[1]?.replace('w', '')))
+ .filter((n) => Number.isFinite(n));
+}
+
+describe('RelatedArticles thumbnails', () => {
+ function render(props: Record = {}) {
+ act(() => {
+ root.render();
+ });
+ return container.querySelector('img') as HTMLImageElement;
+ }
+
+ it('never points the browser at the source file', () => {
+ const img = render();
+ // The defect in one line: if this ever equals the path the caller passed,
+ // every consumer is downloading a full-size original for a thumbnail again.
+ expect(img.getAttribute('src')).not.toBe('/images/roblox-obby-genre.jpg');
+ expect(img.getAttribute('src')).toContain('/_next/image');
+ });
+
+ it('offers narrow candidates, not one fixed size', () => {
+ const w = candidates(render());
+ expect(w.length).toBeGreaterThan(1);
+ // A 379px card at 1x needs nothing near the 1536px source. Without a
+ // candidate this small the browser has nothing cheap to pick.
+ expect(Math.min(...w)).toBeLessThanOrEqual(384);
+ });
+
+ it('tells the browser how wide the card actually is', () => {
+ // `sizes` is what makes the candidates usable: without it the browser
+ // assumes 100vw and picks a desktop-width file for a one-third column.
+ expect(render().getAttribute('sizes')).toBe(
+ '(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw',
+ );
+ });
+
+ it('lets a consumer in a narrower container say so', () => {
+ expect(render({ imageSizes: '200px' }).getAttribute('sizes')).toBe('200px');
+ });
+
+ it('leaves the image decorative — the card heading already names the post', () => {
+ // alt was deliberately emptied: the below carries the same string, so
+ // a duplicate alt reads the title twice to a screen reader. Pinned here so
+ // nobody "restores" it.
+ expect(render().getAttribute('alt')).toBe('');
+ expect(container.querySelector('h3')?.textContent).toBe(
+ 'Roblox Obby Games in 2026',
+ );
+ });
+
+ it('renders no image at all for a post without one', () => {
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector('img')).toBeNull();
+ expect(container.querySelector('h3')).not.toBeNull();
+ });
+
+ it('renders nothing for an empty post list', () => {
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector('section')).toBeNull();
+ });
+});
+
+describe('BentoGrid background images', () => {
+ const item = (over: Record = {}) => ({
+ title: 'Sale orders',
+ description: 'Synced nightly.',
+ backgroundImage: '/images/bento-hero.jpg',
+ ...over,
+ });
+
+ function render(items: Record[]) {
+ act(() => {
+ root.render();
+ });
+ return container.querySelector('img') as HTMLImageElement;
+ }
+
+ it('never points the browser at the source file', () => {
+ const img = render([item()]);
+ expect(img.getAttribute('src')).not.toBe('/images/bento-hero.jpg');
+ expect(img.getAttribute('src')).toContain('/_next/image');
+ });
+
+ it('offers narrow candidates and a default `sizes` for its own grid', () => {
+ const img = render([item()]);
+ expect(Math.min(...candidates(img))).toBeLessThanOrEqual(384);
+ expect(img.getAttribute('sizes')).toBe(
+ '(min-width: 1024px) 50vw, (min-width: 640px) 50vw, 100vw',
+ );
+ });
+
+ it('lets an item that knows its span override `sizes`', () => {
+ expect(render([item({ imageSizes: '25vw' })]).getAttribute('sizes')).toBe(
+ '25vw',
+ );
+ });
+
+ it('renders no image for an item without a background', () => {
+ render([item({ backgroundImage: undefined })]);
+ expect(container.querySelector('img')).toBeNull();
+ });
+});
diff --git a/src/related-articles.tsx b/src/related-articles.tsx
index a1f8cf0..ce7b405 100644
--- a/src/related-articles.tsx
+++ b/src/related-articles.tsx
@@ -1,6 +1,7 @@
'use client'
import { motion } from 'framer-motion'
+import Image from 'next/image'
interface RelatedPost {
title: string
@@ -16,10 +17,14 @@ export function RelatedArticles({
posts,
heading = 'Related Articles',
accentColor = 'cobalt',
+ /** Matches the default 1 / 2 / 3-column grid below. Override if a consumer
+ * renders this inside a narrower container. */
+ imageSizes = '(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw',
}: {
posts: RelatedPost[]
heading?: string
accentColor?: 'cobalt' | 'hazard' | 'fallout'
+ imageSizes?: string
}) {
if (posts.length === 0) return null
@@ -51,10 +56,17 @@ export function RelatedArticles({
{post.image && (
-
![]()
: this rendered the source file
+ at full resolution into a ~400px thumbnail. Measured
+ 2026-09-19 on endsights.com/roblox-tycoon-games — 15.6 MB
+ of images on one article page, with single files at
+ 1.9 MB. Every site on the package paid it. */}
+
)}