Sitemap: - Switch from ISR (revalidate=3600) to force-dynamic for per-request generation - Flatten into single app/sitemap.ts with inlined static entries and DB queries - NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus) - Structured JSON logging for generated URLs and DB errors - Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route - Add basic vitest coverage for sitemap exports PWA & Offline: - Add @serwist/next service worker (webpack build) with runtime caching - Cache strategies: stale-while-revalidate for game pages, network-first for listings/API, cache-first for Steam CDN images - Offline fallback page (public/offline.html) - Manifest icons: 192px maskable + 512px any - Viewport meta with viewport-fit=cover, user-scalable=no - Apple mobile web app meta tags Search / Filter Refinements: - Multi-genre OR support in listing API (comma-separated genres) - Device-scoped FPS filter (min/max FPS constrained to selected device) - Client-side URL state sync via router.replace for shareable filtered views - Initialize filter state from URL params on mount - Auto-collapse filter panel on saved-filter load - Fix multi-genre saved filter parsing (comma-separated) Steam Deck / Touch / Gamepad UX: - WCAG 2.1 AA touch targets (44x44px) on all filter controls - .gamepad-focus CSS focus ring for controller navigation - useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions - Integrate gamepad hook into games page (X=search, Y=toggle filters) Chore: - Bump version to 2026.0.97
34 KiB
Architectural Spec: Sitemap Remediation, PWA Activation & Search Refinements
Date: 2026-05-05
Status: Final — Awaiting Tactical Planning
Author: Autonomous Architect
Depends On: docs/2025-05-05-sync-pwa-architectural-spec.md (existing PWA constraints — extended, not replaced)
1. Problem Statement
Three objectives converge on site reliability and user experience for the primary audience: Steam Deck handheld users. The sitemap is a critical SEO signal that is currently failing silently; the PWA foundation exists as a manifest but lacks the service-worker layer that enables offline use; the advanced search/filter system shipped in v2026.0.95 needs hardening for edge cases and Steam Deck touch ergonomics.
2. Objective 1 — Sitemap Remediation
2.1 Observed Symptoms
| Symptom | Evidence |
|---|---|
| Google Search Console reports sitemap unreadable | User report — no indexed games despite manual load working |
Sitemap loads at https://deckyvault.xyz/sitemap.xml but shows no games |
User report — confirmed manually |
| No Google indexing for game pages | Implied by Search Console failure |
2.2 Root Cause Analysis
A comparison against a known-working reference sitemap (Next.js 15 + Drizzle + PostgreSQL, same stack) reveals two structural anti-patterns and one SQL semantics bug that together explain the symptoms.
Root Cause A: ISR Caching with revalidate = 3600 (PRIMARY CAUSE)
// app/sitemap.ts — CURRENT (broken)
export const revalidate = 3600
export default async function sitemap() { ... }
The sitemap uses ISR (Incremental Static Regeneration) with a 1-hour revalidation window. This is fundamentally wrong for sitemaps because:
- Cache poisoning: If ANY generation event produces an empty or incomplete sitemap (cold start, DB hiccup, deployment restart), that broken response is cached and served to ALL consumers — including Googlebot — for up to an hour.
- No diagnostic signal: There is no way to know what Googlebot received. You manually visit and see a populated sitemap, but Google may have hit the cache during a poisoned window.
- Anti-pattern: ISR is designed for content pages that are expensive to compute and tolerate staleness. A sitemap is a machine-readable inventory that MUST be authoritative at the moment Googlebot reads it.
The reference project (same stack, works correctly) uses:
export const dynamic = "force-dynamic"
This tells Next.js to never cache the route. Every request generates a fresh sitemap from live DB queries. No cache poisoning possible.
Compounding factor: SITEMAP_REVALIDATE_SECONDS is defined in .env.example but never read by app/sitemap.ts. The revalidation interval is hardcoded to 3600. There is no way to tune this even if ISR were appropriate.
Root Cause B: Silent Error Suppression in fetchGameEntries()
// lib/sitemap/fetch-dynamic-entries.ts (lines 10-28)
async function fetchGameEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db.select(...).from(games).where(ne(games.syncStatus, "failed")).limit(MAX_GAME_ENTRIES)
return rows.map(...)
} catch {
return [] // ← SILENTLY RETURNS EMPTY ARRAY ON ANY DB ERROR
}
}
If the PostgreSQL connection times out, is exhausted, or throws any error, the game entries array is silently emptied. Combined with ISR caching (Root Cause A), this one-two punch means: DB hiccup → empty response → cache it for 1 hour → Googlebot reads empty sitemap → zero games indexed.
The reference project does not wrap its DB queries in try/catch — it lets errors propagate naturally, which at least returns HTTP 500, prompting Googlebot to retry later rather than consuming a false empty sitemap.
Trigger conditions:
- Cold start with connection pool not yet warmed up
- Deployment restart during Googlebot's crawl window
- Connection pool exhaustion from concurrent requests
- PostgreSQL maintenance window or brief outage
Root Cause C: NULL syncStatus Exclusion (SQL Semantics Bug)
.where(ne(games.syncStatus, "failed"))
ne(column, "failed") generates "sync_status" <> 'failed', which in PostgreSQL returns NULL (not TRUE) when the column is NULL. Rows with NULL syncStatus are silently excluded from the sitemap — even when ISR caching is bypassed and the DB query succeeds.
Impact: Any game where syncStatus IS NULL is invisible to crawlers. This could happen if:
- The column default was added after initial data insertion
- A direct DB manipulation bypassed the application layer
- A migration introduced the column without backfilling existing rows
Verification query (run against production DB):
SELECT COUNT(*) FROM games WHERE sync_status IS NULL;
Root Cause D: Cloudflare / CDN Cache Interference (Secondary)
If Cloudflare sits in front of the origin, it may independently cache the sitemap XML response. Even with force-dynamic at the origin, a Cloudflare Cache Rule with non-zero TTL on *.xml would serve a stale copy to Googlebot.
Verification: Check Cloudflare dashboard for cached /sitemap.xml status and any Page Rules or Cache Rules matching *.xml.
2.3 Mandatory Fixes
Fix 1: Replace ISR with force-dynamic (CRITICAL — ONE LINE CHANGE)
Constraint: app/sitemap.ts MUST use export const dynamic = "force-dynamic" instead of export const revalidate = 3600. No caching whatsoever. Every request generates a fresh, authoritative sitemap.
Before:
export const revalidate = 3600
After:
export const dynamic = "force-dynamic"
This matches the proven reference pattern. The sitemap is lightweight enough (DB query + JSON serialization of ~50K rows) that per-request generation is acceptable. If this becomes a performance concern at scale (>100K games), the correct approach is a sitemap index with paginated child sitemaps — not ISR caching.
Note: The SITEMAP_REVALIDATE_SECONDS env var becomes unused and should be cleaned up (or left for future sitemap-index implementation).
Fix 2: Remove Silent Error Suppression
Constraint: fetchGameEntries() and fetchDeviceEntries() MUST NOT silently return empty arrays on failure. Errors must propagate so they're observable.
Design: Remove the try/catch wrappers from both functions. Let DB errors surface naturally. The sitemap route will return HTTP 500 on DB failure, which is the correct behavior — Googlebot retries on 5xx and the error is logged in Vercel/Cloudflare observability.
If partial resilience is desired (static entries even when DB is down), implement it at the app/sitemap.ts level with explicit structured logging:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries = buildStaticEntries()
try {
const { gameEntries, deviceEntries } = await fetchDynamicEntries()
console.info("[Sitemap] Generated", { games: gameEntries.length, devices: deviceEntries.length })
return [...staticEntries, ...gameEntries, ...deviceEntries]
} catch (err) {
console.error("[Sitemap] DB query failed, returning static entries only", err)
return staticEntries
}
}
This way a DB failure still returns a partial sitemap (not 500), but the error is logged with full context rather than silently swallowed.
Fix 3: Handle NULL syncStatus Explicitly
Constraint: The sitemap query MUST include rows where syncStatus IS NULL. Use an explicit OR condition.
-- Current (buggy): WHERE "sync_status" <> 'failed'
-- Correct: WHERE ("sync_status" <> 'failed' OR "sync_status" IS NULL)
In Drizzle:
import { or, ne, isNull } from "drizzle-orm"
// ...
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
Fix 4: Add Structured Logging for Monitoring
Constraint: The sitemap generator MUST log a structured metrics object on each successful generation so operations can monitor sitemap health over time.
console.info(JSON.stringify({
event: "sitemap_generated",
gameCount: gameEntries.length,
deviceCount: deviceEntries.length,
staticCount: staticEntries.length,
totalUrls: staticEntries.length + gameEntries.length + deviceEntries.length,
generatedAt: new Date().toISOString(),
}))
This integrates with existing observability (Vercel logs, Cloudflare Logpush, or a logging drain) without requiring custom HTTP headers.
Fix 5: Cloudflare Cache Rule Audit
Constraint: If Cloudflare is in use, verify there is NO cache rule matching /sitemap.xml with a non-zero TTL. The sitemap must be fetched fresh from origin on every request.
- Check: Page Rules, Cache Rules, and Transform Rules
- Verify:
Content-Typeis not being transformed (must remainapplication/xml) - Verify: Cloudflare's "Always Online" feature is not serving a stale copy from its own cache
2.4 Architecture: Flattened Sitemap Design
Following the reference pattern, the sitemap generator should be restructured as a single flat function in app/sitemap.ts rather than delegating to separate files with independent error handling.
Design principles (derived from reference):
- Single responsibility:
app/sitemap.tsowns the entire sitemap generation — DB queries, mapping, and error handling. No delegation to helper files that can fail independently. - Explicit inclusion filter: Instead of
WHERE syncStatus <> 'failed'(exclusion), use a positive inclusion filter likeWHERE syncStatus IN ('pending', 'synced')or omit the filter entirely and let all games appear. The reference project useseq(status, 'published')— a positive assertion of what SHOULD be included. - Inline mapping: Game rows are mapped to sitemap entries directly in the sitemap function, not in a separate module. This keeps the transformation logic visible and testable in one place.
- No premature optimization: Don't add limits, pagination, or caching until measurements prove they're needed. The reference project queries all matching rows without LIMIT and without caching.
Recommended structure:
// app/sitemap.ts
export const dynamic = "force-dynamic"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
const staticEntries = [/* inline static pages */]
try {
const gameRows = await db.select({ id: games.id, updatedAt: games.updatedAt }).from(games)
const gameEntries = gameRows.map((g) => ({
url: `${baseUrl}/game/${g.id}`,
lastModified: g.updatedAt ?? new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
}))
console.info(JSON.stringify({
event: "sitemap_generated", games: gameEntries.length, generatedAt: new Date().toISOString()
}))
return [...staticEntries, ...gameEntries]
} catch (err) {
console.error(JSON.stringify({ event: "sitemap_db_error", error: String(err) }))
return staticEntries
}
}
2.5 Sitemap Security & Rate Limiting
- No auth required: Sitemap is public by design
- No rate limiting on sitemap endpoint: Crawlers must access it freely
- DB query cost: With
force-dynamic, the sitemap hits the DB on every request. ASELECT id, updated_at FROM gameswith ~50K rows is sub-millisecond on modern PostgreSQL. If this becomes measurable, add a materialized view or aWHEREclause on an indexed status column.
3. Objective 2 — PWA & Steam Deck UX
3.1 Current State Baseline
| Feature | Status | Location |
|---|---|---|
Web manifest (manifest.json) |
✅ Present | app/manifest.ts — display: "standalone", theme color #eb3779, bg #100b14 |
| Service Worker | ❌ Missing | No SW file exists |
| Offline fallback | ❌ Missing | No offline UI |
| Touch targets (44×44px WCAG) | ⚠️ Partial | Game cards use full-card Link wrapping ✅; filter chips and select inputs are undersized ❌ |
| Gamepad navigation | ❌ Missing | No Gamepad API integration |
viewport-fit=cover |
❌ Missing | layout.tsx uses overscroll-none class but no viewport meta |
| App icon | ⚠️ Partial | icon.png exists but manifest declares "any" size — should declare explicit 192px and 512px maskable icons |
| Offline caching strategy | ❌ Missing | No cache-first or stale-while-revalidate strategy |
3.2 PWA Architecture Constraints
These extend the existing PWA spec at docs/2025-05-05-sync-pwa-architectural-spec.md §5. The original decisions remain binding unless explicitly overridden here.
3.2.1 Service Worker Generation
Constraint: Use @serwist/next (v9+) — NOT next-pwa (unmaintained). @serwist/next is the successor maintained by the same community and supports Next.js 15/16 App Router with Turbopack.
Why @serwist/next over alternatives:
next-pwa: Unmaintained since 2023; broken on Next.js 14+workbox-webpack-plugin: Requires manual webpack config injection; doesn't work with Turbopack@serwist/next: Actively maintained, supports App Router, generates SW with TypeScript types, compatible with Next.jsinstrumentation.tshook
SW Caching Strategy:
| Resource Type | Strategy | TTL | Rationale |
|---|---|---|---|
| App shell (HTML layout, CSS, fonts) | Precache | N/A (cache-first after install) | Must load instantly offline |
Game detail pages (/game/:id) |
Stale-while-revalidate | 24h | Show cached content immediately, refresh in background |
Game listing pages (/games) |
Network-first | 5min | Filters change results; stale data is misleading |
| Images (Steam CDN, SteamGridDB) | Cache-first | 30 days | Images rarely change; huge bandwidth savings |
API responses (/api/games/*) |
Network-first | N/A | Live data is critical for benchmarks |
Static assets (/_next/static/*) |
Precache | N/A (immutable hashes) | Standard Next.js behavior |
3.2.2 Offline Fallback UI
Constraint: An offline.html page MUST be served for navigation requests when the network is unavailable and the requested page isn't cached. This is a static page in public/offline.html that displays:
- DeckyVault branding
- "You're offline" message
- List of previously viewed games (from SW cache metadata)
- "Go back" button (uses
history.back())
Design: The SW intercepts all navigation requests. If the network is unavailable and the page isn't in cache, respond with offline.html instead of the browser's default offline dinosaur. This provides a branded experience consistent with the installed PWA feel.
3.2.3 Touch Targets
Constraint: All interactive elements in the /games filter panel, the /search results, and the game detail page MUST meet a minimum 44×44px touch target. This is WCAG 2.1 AA Level.
Affected components:
| Component | Current State | Required Fix |
|---|---|---|
| Filter chips (genre, device) | ~28px height | Increase to 44px with min-h-[44px] |
| Select dropdowns (sort, proton, anti-cheat) | ~34px | Increase to 44px |
| Checkbox labels (FSR, free, multiplayer) | Standard text | Add py-2 for 44px vertical target |
| Search input | ~36px | Increase to 44px |
| Number inputs (min/max FPS, review %) | ~28px | Increase to 44px |
| "Clear all filters" text link | ~16px | Increase to 44px clickable area |
| Navbar links | Varies | Ensure 44px tap area with padding |
Implementation approach: Apply touch-optimized sizing via a CSS class .touch-target that sets min-h-[44px] min-w-[44px]. Use media queries NOT to restrict these to touch devices (impossible to detect reliably), but to apply them universally. The visual density trade-off is acceptable given the primary audience is handheld device users.
3.2.4 Gamepad Navigation
Constraint: Use the Gamepad API with the following mapping:
| Input | Action | Scope |
|---|---|---|
| D-pad / Left Stick | Move focus between focusable elements (roving tabindex) | Global |
| A button (index 0) | Activate focused element (click()) |
Global |
| B button (index 1) | Browser back (history.back()) |
Global |
| X button (index 2) | Open search (/search) |
Global |
| Y button (index 3) | Toggle filters panel (on /games) |
Context-aware |
| L1 bumper (index 4) | Previous tab (on game detail page) | Context-aware |
| R1 bumper (index 5) | Next tab (on game detail page) | Context-aware |
Activation rules:
- Gamepad mode activates only on first gamepad input detected (any button press or axis movement beyond deadzone)
- Gamepad mode deactivates on any mouse movement or keyboard input
- While active, a subtle indicator (small icon in corner) shows gamepad mode is active
- Focus ring uses a custom CSS class
.gamepad-focus(distinct from:focus-visible) — this prevents showing gamepad-style focus rings to mouse users - All focusable elements get
tabindex="-1"when gamepad mode is active, except the currently focused one which getstabindex="0"(roving tabindex pattern)
Dead zone: Apply 0.15 deadzone to analog stick axes to prevent drift-induced focus movement.
Implementation boundary: A single custom hook useGamepadNavigation(containerRef) exported from lib/hooks/use-gamepad-navigation.ts. This hook manages all Gamepad API lifecycle, focus management, and activation detection. Individual pages consume it via a wrapper component.
3.2.5 Viewport and PWA Meta Tags
Constraint: app/layout.tsx MUST include a <meta name="viewport"> tag:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
viewport-fit=cover: Extends content into the notch/safe areas on modern devicesmaximum-scale=1, user-scalable=no: Prevents accidental zoom on double-tap during gameplay on Steam Deck touchscreen
Additional required meta tags:
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#eb3779" />
Manifest icon update: app/manifest.ts must declare explicit sizes:
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
3.3 PWA Security Constraints
- Service Worker MUST NOT cache:
/manage/*(admin dashboard)/profile/*(authenticated user data)/api/auth/*(authentication endpoints)- Any API response containing a
Set-Cookieheader - POST, PATCH, DELETE, PUT API responses
- Service Worker scope:
/(root) — standard PWA scope - CSP considerations: The SW must not break Content-Security-Policy headers. Test with strict CSP.
- HSTS: Ensure HSTS headers are set at the reverse proxy/Cloudflare level to enforce HTTPS for SW registration (SW requires HTTPS).
4. Objective 3 — Advanced Search & Filtering
4.1 State Assessment
Per CHANGELOG v2026.0.95, the advanced search and filtering system was shipped. The following audit identifies gaps that require attention.
4.2 Implemented Features (Verification)
| Feature | Implementation | File | Verified |
|---|---|---|---|
| FPS range (min/max) | Query param → DB subquery | lib/api/games-listing.ts |
✅ |
| Device filter | Subquery on gamePlatformSupport + performanceEntries |
lib/api/games-listing.ts |
✅ |
| FSR support filter | Subquery on performanceEntries.upscalerType = 'fsr' |
lib/api/games-listing.ts |
✅ |
| Proton/Native filter | Subquery on gamePlatformSupport.protonStatus |
lib/api/games-listing.ts |
✅ |
| Anti-cheat filter | Subquery on gamePlatformSupport.antiCheatStatus |
lib/api/games-listing.ts |
✅ |
| Playability filter | Direct WHERE on games.playabilityStatus |
lib/api/games-listing.ts |
✅ |
| Steam review min % | Direct WHERE gte(games.steamReviewScore) |
lib/api/games-listing.ts |
✅ |
| Free-to-play filter | Direct WHERE eq(games.isFree, true) |
lib/api/games-listing.ts |
✅ |
| Multiplayer filter | Direct WHERE on onlineMultiplayerStatus |
lib/api/games-listing.ts |
✅ |
| Saved/bookmarked filters | CRUD API + SavedFilters component |
lib/api/saved-filters.ts + components/saved-filters.tsx |
✅ |
| Sort: Recent, Name, Benchmarks, Performance, Popularity, Release Date, Steam Reviews | 7 sort dimensions | games-page-client.tsx + games-listing.ts |
✅ |
| Infinite scroll with IntersectionObserver | Client-side | games-page-client.tsx |
✅ |
| Genre multi-select | Client-side filter chips | games-page-client.tsx |
✅ |
4.3 Gaps & Refinements
Gap 1: Genre Multi-Select Limited to Single Genre
Current behavior: games-listing.ts only accepts a single genre parameter (query.genre). The client-side UI allows selecting multiple genres, but buildUrl only sends the first one:
// games-page-client.tsx (lines ~108)
if (selectedGenres.length === 1) params.set("genre", selectedGenres[0])
Impact: Selecting multiple genres in the filter panel silently ignores all but the first. The user sees 2+ active genre chips but only gets results for one.
Fix: The API must support a comma-separated genre list (or repeated genre params) and use OR logic:
-- Current: WHERE genres @> '["Action"]'::jsonb
-- Needed: WHERE genres @> '["Action"]'::jsonb OR genres @> '["RPG"]'::jsonb
Constraint: Genre multi-select uses OR logic (game matches ANY selected genre), not AND (game must match ALL). This is the standard UX pattern for discovery filters.
Gap 2: Search/Title Search on /games Doesn't Use Fuzzy Matching
Current behavior: The /games page search uses ilike(games.title, titleTerm) where titleTerm = fuzzySearchTerm(search). The fuzzy helper converts spaces to % wildcards for multi-word matching but doesn't handle typos.
Constraint: This is acceptable for a filter page (users know what they're looking for). The dedicated /search page (search-unified.ts) handles broader discovery including Steam API fallback. No change required.
Gap 3: FPS Filter Performance
Current behavior: FPS range filter uses a subquery:
WHERE games.id IN (SELECT gv.game_id FROM performance_entries pe JOIN game_versions gv ON pe.version_id = gv.id WHERE pe.fps_avg BETWEEN min AND max GROUP BY gv.game_id)
This scans performance_entries without device filtering. A game with a 60fps benchmark on a high-end desktop (but 15fps on Steam Deck) would pass the FPS filter, misleading Deck users.
Constraint: When a device filter is active concurrently with FPS range, the FPS subquery MUST scope to that device:
WHERE pe.hardware_slug = '<device>' AND pe.fps_avg BETWEEN min AND max
When no device filter is active, the current behavior (any device) is acceptable as a coarse filter.
Gap 4: No Search Within Results
Current behavior: Search and filters operate independently. There's no way to "search within filtered results" — the search text always searches the full games table.
Constraint: This is acceptable for the current scope. The search bar + filter panel on /games provides sufficient narrowing. Adding "search within results" would require combining text search conditions with all filter subquery conditions, which is a query complexity concern. Mark as a future enhancement.
Gap 5: Saved Filters — No Visual Feedback on Load
Current behavior: When loading a saved filter, the filter state updates but there's no visual confirmation (toast, animation, or active state on the loaded filter pill).
Constraint: Add a brief highlight animation to the loaded filter pill and auto-collapse the filter panel after load. This provides immediate visual feedback that the action succeeded.
Gap 6: Missing "Search Intent" URL Parameter Tracking
Current behavior: Filter state is purely client-side. Refreshing the /games page resets all filters. The URL never reflects active filters (no query params in the address bar).
Impact: Users can't share filtered views or bookmark specific filter combinations (beyond the saved-filters feature which requires auth).
Constraint: Filter state SHOULD be reflected in the URL as query parameters. This enables:
- Sharing filtered views via link
- Browser back/forward through filter changes
- Bookmarking filtered views without auth
- Better analytics tracking
Implementation approach: Use nuqs or manual useSearchParams + router.replace to sync filter state to URL.
5. Objective 4 — Changelog
5.1 Changelog Procedure
Constraint: The CHANGELOG.md follows Keep a Changelog format. Each version entry uses:
## [version] - YYYY-MM-DD### Added,### Changed,### Fixed,### Technicalsubsections
Pending entries for next version (v2026.0.97):
Based on the work scoped in this spec, the following entries should be appended:
## [2026.0.97] - 2026-05-XX
### Fixed
- Sitemap.xml used ISR caching (`revalidate = 3600`) which poisoned the cache with empty responses on DB hiccups; switched to `force-dynamic` for per-request fresh generation
- DB errors during sitemap generation were silently caught and returned as empty arrays (no games indexed); errors now propagate to observability with structured logging
- Games with NULL `syncStatus` were excluded from sitemap due to SQL `<> 'failed'` returning NULL (not TRUE) for NULL values
- Multi-genre selection in games filter panel only applied the first selected genre; now supports comma-separated OR-matching
- FPS range filter included results from non-Steam-Deck devices; now scoped to active device filter when present
### Added
- PWA service worker with offline caching for game pages and images (stale-while-revalidate for HTML, cache-first for Steam CDN images)
- Offline fallback page (`offline.html`) when navigating without network
- Gamepad navigation support (D-pad/left stick focus, A/B/X/Y buttons, L1/R1 tab switching)
- Structured logging for sitemap generation metrics (games count, timestamps) via `console.info` JSON
- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views
### Changed
- All interactive elements on `/games` and `/search` pages now meet 44×44px minimum touch targets (WCAG 2.1 AA)
- Web manifest icons now declare explicit 192px (maskable) and 512px (any) sizes
- Viewport meta tag added with `viewport-fit=cover` and `user-scalable=no` for installed PWA feel
- Loading a saved filter now highlights the filter pill and collapses the panel for visual feedback
- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`)
5.2 Version Bump
Constraint: The version in package.json ("version": "2026.0.96") must be bumped to "2026.0.97" when any of the above work is shipped. The changelog date 2026-05-XX must be replaced with the actual release date.
6. Architecture Boundaries — Cross-Cutting
6.1 Performance Budget
| Metric | Budget | Rationale |
|---|---|---|
| Service Worker size | < 50KB (compressed) | Large SW delays registration and first paint |
| Offline page size | < 10KB (total, including inline CSS) | Must load instantly on slow connections |
| Gamepad hook bundle impact | < 3KB (gzipped) | Added to every page that imports it |
| Sitemap generation time | < 5s (p95) | Prevents request timeout during Googlebot crawl |
| Sitemap response size | < 50MB (uncompressed) or 50,000 URLs | Google's sitemap limit. Beyond this, use sitemap index |
6.2 Dependency Additions
| Package | Purpose | Version Constraint |
|---|---|---|
@serwist/next |
Service worker generation for Next.js App Router | ^9.0.0 |
@serwist/precaching |
Precaching utilities | ^9.0.0 (peer) |
@serwist/sw |
Service worker runtime | ^9.0.0 (peer) |
nuqs |
URL query state management (optional, alternative to manual useSearchParams) |
^2.0.0 |
No other new dependencies are required. Gamepad API is browser-native. Touch targets are CSS-only. Sitemap fixes are logic changes to existing code.
6.3 Files Affected (Summary)
| File | Change Type | Objective |
|---|---|---|
app/sitemap.ts |
Rewrite — flatten to single function, force-dynamic, inline DB queries, structured logging, NULL-safe filter |
Sitemap |
lib/sitemap/fetch-dynamic-entries.ts |
Mark deprecated (or delete if no other consumers) — logic moves into app/sitemap.ts |
Sitemap |
lib/api/games-listing.ts |
Modify — multi-genre support, device-scoped FPS filter | Search |
app/games/games-page-client.tsx |
Modify — multi-genre URL params, touch targets, URL sync | Search + PWA |
app/manifest.ts |
Modify — explicit icon sizes (192px maskable, 512px any) | PWA |
app/layout.tsx |
Modify — viewport meta, PWA meta tags (apple-mobile-web-app-capable, theme-color) |
PWA |
components/saved-filters.tsx |
Modify — load highlight animation, auto-collapse panel | Search |
public/offline.html |
Create — offline fallback page with branding + previously-viewed games list | PWA |
lib/hooks/use-gamepad-navigation.ts |
Create — Gamepad API hook with activation detection, roving tabindex, focus ring | PWA |
instrumentation.ts or app/sw.ts |
Create — Service worker entry point (via @serwist/next) | PWA |
CHANGELOG.md |
Modify — append v2026.0.97 entries | Changelog |
package.json |
Modify — version bump to 2026.0.97, add @serwist deps | Changelog + PWA |
6.4 Files NOT Affected
lib/steam/sync.ts— No changes (sync unification was completed in previous spec)lib/db/schema/*— No schema changesapp/api/*— No API route changes beyond games-listingcomponents/navbar.tsx— No layout changesapp/search/page.tsx— Search page uses unified search API, already complete
7. Risk Register
| # | Risk | Probability | Impact | Mitigation |
|---|---|---|---|---|
| R1 | @serwist/next incompatible with Next.js 16 + Turbopack |
Medium | High (PWA blocked entirely) | Verify compatibility in a branch before full implementation. Fallback: manual workbox-build integration as a postbuild script. |
| R2 | Service worker caches stale game data shown to users | Medium | Medium | Stale-while-revalidate with 24h max TTL. Service worker update flow triggers refresh on new version detection. |
| R3 | Gamepad API unavailable on some Steam Deck firmware versions | Low | Medium | Feature-detect navigator.getGamepads. Degrade silently with no gamepad functionality. SteamOS 3.5+ ships Chromium 114+ which has Gamepad API. |
| R4 | Touch target increases break the visual design on desktop | Low | Low | Desktop users benefit from larger click targets too. Current design uses generous spacing already. |
| R5 | force-dynamic sitemap causes excessive DB load under crawler traffic |
Low | Low | Googlebot fetches sitemap ~once daily. SELECT id, updated_at FROM games on ~50K rows is single-digit ms on PostgreSQL. |
| R6 | URL-based filter state causes excessive history entries | Medium | Low | Use router.replace instead of router.push to avoid polluting browser history with every filter change. |
8. Out of Scope (Explicitly)
- Steam Deck OLED vs LCD device-specific filtering: Current device filter already supports this via hardware slugs
- Steam Machine-specific optimizations: Already in the hardware table
- Native mobile app packaging: PWA-only approach is the stated objective
- ProtonDB integration beyond existing links: Already on search results
- Review snippet rich results: Structured data for reviews — separate SEO project
- Dark mode toggle: Already dark-mode-only by design (
bg-background text-text) - Internationalization / i18n: English-only for current scope
- Performance budget enforcement in CI: Separate devops project
- Removing
/app/updates/directory: Cosmetic, deferred per original spec
9. Validation Checklist (Post-Implementation)
Sitemap
- Sitemap uses
export const dynamic = "force-dynamic"(NOTrevalidate) - Games with NULL
syncStatusare included (verified viasync_status IS NULLcheck) - DB errors are logged as structured JSON (
console.info/console.errorwitheventfield) - DB errors return partial sitemap (static entries only), not HTTP 500
- Google Search Console successfully parses and indexes sitemap
- Cloudflare is NOT caching
/sitemap.xml(verified in dashboard) lib/sitemap/directory is either cleaned up or the oldfetchDynamicEntriesfunction is deprecated
PWA
- Service worker registers without errors in Chrome DevTools
- Game pages are available offline after first visit
offline.htmlserved for uncached pages when offline- Images from Steam CDN served from cache on revisit
- Authenticated pages are NOT cached by service worker
- PWA install prompt appears on Chrome for Android / Steam Deck
- Manifest icons display correctly on home screen
Touch & Gamepad
- All filter controls on
/gameshave ≥44×44px touch targets (verified via DevTools element inspection) - Gamepad D-pad navigates through game cards on
/games - A button activates/opens focused game
- B button navigates back
- Gamepad focus ring appears only after gamepad input
- Gamepad focus ring disappears on mouse input
Search & Filters
- Multi-genre selection filters correctly (OR logic)
- FPS filter respects device when device filter is active
- Saved filter load shows visual feedback (highlight + panel collapse)
- Filter state is reflected in URL query parameters
- Browser back/forward changes filter state correctly
- Direct URL with filter params applies filters on load
Changelog & Version
- CHANGELOG.md has v2026.0.97 entry
package.jsonversion is2026.0.97