feat(frontend): add visual asset fallback strategy and QA notes

This commit is contained in:
Paul Huliganga 2026-03-26 16:39:18 -04:00
parent 012a5362bb
commit adf8386daf
4 changed files with 62 additions and 2 deletions

View File

@ -52,3 +52,11 @@ src/
## Architecture
See `/ARCHITECTURE.md` for full system architecture and patterns.
## Visual Assets + Fallbacks
- Visual asset definitions live in `src/assets/visualAssets.ts`.
- The homepage hero uses a fallback chain: bundled `hero.png` first, then `/images/hero-fallback.svg`.
- Keep fallback assets lightweight (SVG preferred) and store browser-served fallbacks under `public/images/`.
- Any new UI-critical image should follow the same fallback pattern to avoid broken-image regressions in production.

View File

@ -0,0 +1,26 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Recipe hero fallback illustration</title>
<desc id="desc">Soft gradient with simple food-themed shapes used when the hero image is unavailable.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#fde68a"/>
<stop offset="55%" stop-color="#fed7aa"/>
<stop offset="100%" stop-color="#bfdbfe"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)"/>
<g opacity="0.15" fill="#1e293b">
<circle cx="250" cy="180" r="120"/>
<circle cx="1430" cy="710" r="180"/>
<circle cx="1210" cy="180" r="90"/>
</g>
<g fill="#0f172a" opacity="0.65" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" text-anchor="middle">
<text x="800" y="420" font-size="84" font-weight="700">Recipe Manager</text>
<text x="800" y="500" font-size="34" font-weight="500">Fresh meals. Organized beautifully.</text>
</g>
<g font-size="84" text-anchor="middle">
<text x="620" y="640">🥗</text>
<text x="800" y="640">🍲</text>
<text x="980" y="640">🍰</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,13 @@
import heroImage from './hero.png';
export const visualAssets = {
hero: {
primary: heroImage,
fallbacks: ['/images/hero-fallback.svg'],
alt: 'Fresh ingredients and plated food',
},
} as const;
export function heroImageChain(): string[] {
return [visualAssets.hero.primary, ...visualAssets.hero.fallbacks];
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import heroImage from '../assets/hero.png';
import { heroImageChain, visualAssets } from '../assets/visualAssets';
import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard';
@ -18,6 +18,10 @@ export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedTagId, setSelectedTagId] = useState<number | null>(null);
const [heroCandidateIndex, setHeroCandidateIndex] = useState(0);
const heroCandidates = heroImageChain();
const heroSrc = heroCandidates[Math.min(heroCandidateIndex, heroCandidates.length - 1)];
const { recipes, loading, error, hasMore, loadMore } = useRecipes({
search: searchQuery,
@ -81,7 +85,16 @@ export function RecipeListPage() {
</div>
</div>
<div className="relative min-h-[220px] bg-slate-100 md:min-h-full">
<img src={heroImage} alt="Fresh ingredients and plated food" className="h-full w-full object-cover" />
<img
src={heroSrc}
alt={visualAssets.hero.alt}
className="h-full w-full object-cover"
onError={() => {
if (heroCandidateIndex < heroCandidates.length - 1) {
setHeroCandidateIndex((current) => current + 1);
}
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/20 via-transparent to-transparent" />
</div>
</div>