Files
deckyvault/docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md
adrianbonpin dcf6066b2b feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX
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
2026-05-05 16:20:28 +08:00

34 KiB
Raw Permalink Blame History

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:

  1. 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.
  2. 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.
  3. 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-Type is not being transformed (must remain application/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):

  1. Single responsibility: app/sitemap.ts owns the entire sitemap generation — DB queries, mapping, and error handling. No delegation to helper files that can fail independently.
  2. Explicit inclusion filter: Instead of WHERE syncStatus <> 'failed' (exclusion), use a positive inclusion filter like WHERE syncStatus IN ('pending', 'synced') or omit the filter entirely and let all games appear. The reference project uses eq(status, 'published') — a positive assertion of what SHOULD be included.
  3. 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.
  4. 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. A SELECT id, updated_at FROM games with ~50K rows is sub-millisecond on modern PostgreSQL. If this becomes measurable, add a materialized view or a WHERE clause 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.tsdisplay: "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.js instrumentation.ts hook

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 gets tabindex="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 devices
  • maximum-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-Cookie header
    • 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, ### Technical subsections

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 changes
  • app/api/* — No API route changes beyond games-listing
  • components/navbar.tsx — No layout changes
  • app/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" (NOT revalidate)
  • Games with NULL syncStatus are included (verified via sync_status IS NULL check)
  • DB errors are logged as structured JSON (console.info/console.error with event field)
  • 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 old fetchDynamicEntries function is deprecated

PWA

  • Service worker registers without errors in Chrome DevTools
  • Game pages are available offline after first visit
  • offline.html served 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 /games have ≥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.json version is 2026.0.97