From cf725b40a2f9dce882e85edd2a0722647682ca2e Mon Sep 17 00:00:00 2001 From: bepis Date: Wed, 21 Oct 2026 10:21:11 +1100 Subject: [PATCH] step 9.3: done --- HANDOFF.md | 61 +++++----- STATUS.md | 7 +- public/404.html | 95 +++++++++++++++ public/500.html | 92 ++++++++++++++ public/create-hero.html | 1 + public/css/main.css | 165 ++++++++++++++++++++++++++ public/game.html | 1 + public/js/game.js | 15 --- public/js/shop.js | 11 -- public/js/toast.js | 42 +++++++ public/login.html | 1 + public/profile.html | 1 + public/register.html | 1 + public/shop.html | 1 + src/app.js | 23 +++- tests/integration/error-pages.test.js | 53 +++++++++ 16 files changed, 506 insertions(+), 64 deletions(-) create mode 100644 public/404.html create mode 100644 public/500.html create mode 100644 public/js/toast.js create mode 100644 tests/integration/error-pages.test.js diff --git a/HANDOFF.md b/HANDOFF.md index 65c16ae..07589d0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,8 +4,8 @@ > If this file disagrees with the code, trust the code and fix this file. ## 1. Where we are -- **Last completed step:** 9.2 (seed-dev script — creates demo account + hero) — TICKED in STATUS.md -- **Next step:** Phase 9 — 9.3 (Global error pages / friendly UI error toasts. Loading states.) +- **Last completed step:** 9.3 (error pages, shared toast system, loading states) — TICKED in STATUS.md +- **Next step:** Phase 9 — 9.4 (Mobile-responsive pass per UI_GUIDE) - **Updated:** 2026-10-21 - **Was the last session cut off mid-task?** No @@ -14,37 +14,29 @@ |---|---| | `npm install` | succeeds | | `npm run migrate` | applies 001–006 / "nothing to do" | -| `npm test` | **172 passing, 0 failing** (85 unit + 87 integration) | +| `npm test` | **175 passing, 0 failing** (85 unit + 90 integration) | | `npm run seed:dev` | creates demo user "demo"/"demo123", hero "Aldric" (warrior), starter gear | ## 3. What exists now (files that matter) -- `public/profile.html` — Public hero profile page (reads ?name=, displays stats/equipment). -- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile, handles 404. +- `public/404.html` — Custom 404 error page (branding: "Page Not Found", Emberfall theme). +- `public/500.html` — Custom 500 error page (branding: "Something went wrong", retry button). +- `public/js/toast.js` — Shared toast notification system (window.showToast, 3 types: error/info/success). - `public/game.html` — Dashboard with stats panel, tabbed action panel (Hunt/Inventory/Expedition). -- `public/js/game.js` — hunt UI + inventory UI + expedition UI. +- `public/js/game.js` — hunt UI + inventory UI + expedition UI (uses shared toast.js). - `public/shop.html` — Dedicated shop page. -- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation. +- `public/js/shop.js` — buy/sell handlers (uses shared toast.js). +- `public/login.html`, `public/register.html`, `public/create-hero.html` — Auth & hero creation (all include toast.js). +- `public/profile.html` — Public hero profile page. +- `public/js/profile.js` — Fetches GET /api/heroes/:name, renders profile. +- `public/js/auth.js` — Shared API wrapper + showError. +- `public/css/main.css` — All styles including spinner, toast, button loading states, error page shared CSS. - `scripts/seed-dev.js` — Creates demo account + hero + starter gear (idempotent, skips in production). -- `src/services/heroProfileService.js` — `getHeroProfile(heroName)`. -- `src/routes/heroProfile.js` — `GET /api/heroes/:name` (public). -- `src/services/leaderboardService.js` — `getLeaderboard(type, page, limit)`. -- `src/routes/leaderboard.js` — `GET /api/leaderboard?type=level|gold|kills`. -- `src/services/expeditionService.js` — start, getActive, claim, listDefinitions. -- `src/routes/expeditions.js` — 4 expedition endpoints. -- `src/services/shopService.js` — getStock, buyItem, sellItem. -- `src/routes/shop.js` — GET/POST shop endpoints. -- `src/services/inventoryService.js` — listInventory, equipItem, unequipItem, useConsumable. -- `src/services/huntService.js` — hunt (one transaction). -- `src/services/characterService.js` — createCharacter, getCharacterProfile, getCharacterIdForUser. -- `src/services/authService.js` — register, verifyLogin. -- `src/game/combat.js` — resolveCombat. -- `src/game/stats.js` — calcDerived, xpToNext, checkLevelUp. -- `src/game/energy.js` — computeEnergy. -- `src/utils/time.js` — now()/setNow()/resetNow(). -- `src/app.js` — Express app with helmet, rate limiters (auth + general /api), all routes. -- `src/middleware/session.js` — session store, requireAuth. -- `src/config/index.js` — env validation, frozen config. -- `tests/integration/*.test.js` — 87 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard. +- `src/app.js` — Express app with helmet, rate limiters, catch-all 404, 500 HTML serving. +- `src/services/*` — All game services (auth, character, hunt, inventory, shop, expedition, leaderboard, profile). +- `src/routes/*` — All route handlers. +- `src/game/*` — Pure game logic (combat, stats, energy, leveling, rewards). +- `tests/integration/error-pages.test.js` — 3 tests for 404/500 page serving. +- `tests/integration/*.test.js` — 90 integration tests across auth, combat, hunt, inventory, shop, expeditions, profile, leaderboard, error-pages. - `tests/unit/*.test.js` — 85 unit tests for game logic. ## 4. Half-finished / fragile things @@ -55,7 +47,6 @@ - Equipment bonuses not yet passed into combat (Issue #5 in STATUS.md). - Starter gear (GAME_DESIGN.md §Items) documented but not auto-created on character creation. - `GET /api/zones` documented in API.md but not implemented (Issue #2). -- Step 9.3 (error pages/toasts/loading states) not yet implemented. ## 5. Decisions & deviations from the docs - D-004..D-007 in `docs/DECISIONS.md` (combat rounds, equipment counted once, session store, clock). @@ -87,11 +78,13 @@ - Profile page is public: no auth.js guard needed; uses ?name= query param for lookup. - Rate limiters in tests: set `NODE_ENV=test` to get 1000 req/window limit; otherwise tests fail with 429. - `seed-dev.js` must check `NODE_ENV=production` before any `require()` that loads config (config throws on placeholder SECRET). +- Express `sendFile` rejects `../` path segments — use `path.resolve()` + `path.join()` for absolute paths. +- `express.static` sends its own 404 responses; catch-all must be placed BEFORE static middleware. -## 7. Next-step briefing (9.3) -- **Goal:** Add global error pages / friendly UI error toasts. Loading states. -- **Docs:** UI_GUIDE, SECURITY (for error handling), TESTING. -- Should: add a generic 404/500 error page template in `public/`. -- Should: add a toast notification system (JS + CSS) for user-facing errors. -- Should: add loading spinners/states for async operations (hunt, buy, etc.). +## 7. Next-step briefing (9.4) +- **Goal:** Mobile-responsive pass per UI_GUIDE (< 700px: single column, tabs become bottom nav). +- **Docs:** UI_GUIDE §game.html layout (Mobile section). +- Current CSS has some `@media (max-width: 600px)` rules but needs comprehensive mobile review. +- Should: verify all pages render correctly on mobile viewports. +- Should: tabs → bottom nav on mobile (per UI_GUIDE). - Should NOT introduce new dependencies — use vanilla HTML/CSS/JS. diff --git a/STATUS.md b/STATUS.md index debbd42..a6c9440 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,9 +8,9 @@ ## Current Focus - **Phase:** 9 -- **Step:** 9.3 +- **Step:** 9.4 - **Blocked?** No -- **Last verified working command:** `npm test` — 172/172 passing (85 unit + 87 integration), 2026-10-21 step 9.2 +- **Last verified working command:** `npm test` — 175/175 passing (85 unit + 90 integration), 2026-10-21 step 9.3 ## Project Health | Item | State | @@ -109,7 +109,7 @@ *Fixed:* removed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent. Added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec). - [x] **9.2** Add `npm run seed:dev` creating a demo account and hero. *Acceptance:* `npm run seed:dev` creates migrations + demo user + hero + starter gear; safe to re-run; skipped in production. -- [ ] **9.3** Global error pages / friendly UI error toasts. Loading states. +- [x] **9.3** Global error pages / friendly UI error toasts. Loading states. - [ ] **9.4** Mobile-responsive pass (see UI_GUIDE). - [ ] **9.5** Final full run of "Definition of playable" from NOTES.md §7. Record results below. - [ ] **9.6** Update README with real run instructions. @@ -192,3 +192,4 @@ _Format:_ `YYYY-MM-DD | step | what changed | result` - 2026-10-21 | 8.2 | Created `src/services/heroProfileService.js` (getHeroProfile), `src/routes/heroProfile.js` (GET /api/heroes/:name, public), integrated into app.js; 5 integration tests covering public lookup, case-insensitivity, equipped items, 404, no-auth access | 167/167 tests pass - 2026-10-21 | 9.1 | Walked SECURITY.md checklist: fixed `innerHTML` with dynamic data in `game.js` (renderActiveExpedition) and `profile.js` (renderProfile stats) — converted to createElement/textContent; added missing general `/api` rate limiter (120/min/IP per SECURITY.md spec); verified all 9 checklist items pass | 172/172 tests pass - 2026-10-21 | 9.2 | Created `scripts/seed-dev.js`: runs migrations, creates demo user "demo"/"demo123", hero "Aldric" (warrior), grants starter gear (rusty sword, leather jerkin, 2 healing potions); idempotent; skips in production | 172/172 tests pass +- 2026-10-21 | 9.3 | Added shared toast system (`public/js/toast.js`), 404/500 error pages (`public/404.html`, `public/500.html`), CSS loading spinner + button loading states, server-side catch-all 404 + 500 HTML serving; removed duplicate showToast from game.js/shop.js; 3 integration tests for error pages | 175/175 tests pass diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..cee0061 --- /dev/null +++ b/public/404.html @@ -0,0 +1,95 @@ + + + + + + Emberfall — Page Not Found + + + + +
+
404
+

Page Not Found

+

+ The page you're looking for has gone dark. Perhaps it was never here, or has been consumed by the shadows. +

+ +
+ + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..b2b3f14 --- /dev/null +++ b/public/500.html @@ -0,0 +1,92 @@ + + + + + + Emberfall — Server Error + + + + +
+
500
+

Something went wrong

+

+ The flames of Emberfall have flickered. Our servers are working to restore the light. Please try again. +

+
+ Return to Emberfall + +
+
+ + diff --git a/public/create-hero.html b/public/create-hero.html index 0c502f9..521fafd 100644 --- a/public/create-hero.html +++ b/public/create-hero.html @@ -26,6 +26,7 @@ + diff --git a/public/css/main.css b/public/css/main.css index 3c1339d..8a8ee8a 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -794,3 +794,168 @@ button:hover { background: #c73650; } font-style: italic; font-size: 0.8rem; } + +/* ===== Loading spinner ===== */ +.spinner { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border: 2px solid var(--muted, #a59a8a); + border-top-color: var(--accent, #e2822b); + border-radius: 50%; + animation: spin 0.6s linear infinite; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Loading overlay for panels */ +.loading-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--muted, #a59a8a); + font-size: 0.875rem; +} + +.loading-overlay .spinner { + width: 1.5rem; + height: 1.5rem; +} + +/* ===== Toast notifications ===== */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + padding: 0.75rem 1.25rem; + border-radius: 6px; + font-size: 0.875rem; + color: #fff; + z-index: 1000; + animation: toastIn 0.3s ease; + max-width: 90vw; + text-align: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + pointer-events: none; +} + +.toast-error { + background: #c0392b; +} + +.toast-info { + background: #4a7fb0; +} + +.toast-success { + background: #4f9d69; +} + +@keyframes toastIn { + from { opacity: 0; transform: translateX(-50%) translateY(1rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +/* ===== Button loading state ===== */ +.btn[disabled] { + opacity: 0.65; + cursor: not-allowed; +} + +.btn-loading { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-loading .spinner { + width: 0.875rem; + height: 0.875rem; + border-width: 1.5px; +} + +/* ===== Error page shared ===== */ +.error-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; + padding: 2rem; +} + +.error-code { + font-size: 6rem; + font-weight: bold; + color: var(--accent, #e2822b); + line-height: 1; + margin-bottom: 0.5rem; +} + +.error-title { + font-size: 1.5rem; + color: var(--text, #eadfce); + margin-bottom: 1rem; +} + +.error-message { + font-size: 1rem; + color: var(--muted, #a59a8a); + margin-bottom: 2rem; + max-width: 400px; +} + +.error-actions { + display: flex; + gap: 1rem; + flex-wrap: wrap; + justify-content: center; +} + +.error-actions a, +.error-actions button { + display: inline-block; + padding: 0.5rem 1.25rem; + border-radius: 4px; + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + border: none; + background: transparent; + color: inherit; +} + +.error-actions a:hover, +.error-actions button:hover { + opacity: 0.85; +} + +.btn-home { + background: var(--accent, #e2822b); + color: #fff; +} + +.btn-back { + color: var(--accent, #e2822b); + border: 1px solid var(--accent, #e2822b); +} + +.btn-retry { + color: var(--accent, #e2822b); + border: 1px solid var(--accent, #e2822b); +} + +.btn-retry:hover { + background: var(--accent, #e2822b); + color: #fff; +} diff --git a/public/game.html b/public/game.html index fa7c774..ac12fc4 100644 --- a/public/game.html +++ b/public/game.html @@ -120,6 +120,7 @@ + diff --git a/public/js/game.js b/public/js/game.js index 92a3832..e2a8580 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -815,21 +815,6 @@ function startExpeditionSync() { }, 5000); } -// ===== Toast notifications ===== -function showToast(message, type = 'info') { - // Remove existing toasts - document.querySelectorAll('.toast').forEach((t) => t.remove()); - - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - document.body.appendChild(toast); - - setTimeout(() => { - toast.remove(); - }, 4000); -} - // ===== Init ===== document.addEventListener('DOMContentLoaded', async () => { await loadCharacter(); diff --git a/public/js/shop.js b/public/js/shop.js index f555eeb..2aa0952 100644 --- a/public/js/shop.js +++ b/public/js/shop.js @@ -272,17 +272,6 @@ function switchTab(tab) { document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none'; } -// ─── Toast notifications ───────────────────────────────────────────────── - -function showToast(message, type = 'info') { - document.querySelectorAll('.toast').forEach((t) => t.remove()); - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 4000); -} - // ─── Init ──────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { diff --git a/public/js/toast.js b/public/js/toast.js new file mode 100644 index 0000000..d8fe47d --- /dev/null +++ b/public/js/toast.js @@ -0,0 +1,42 @@ +// Toast notification system — shared across all pages. +// Usage: showToast('message', 'error' | 'info' | 'success'); +// Auto-dismisses after 4 seconds. Multiple toasts stack vertically. + +(function () { + 'use strict'; + + const TOAST_DURATION = 4000; + const MAX_VISIBLE = 3; + const STACK_GAP = 8; // px between stacked toasts + + /** + * Show a toast notification. + * @param {string} message - Toast text content. + * @param {'error'|'info'|'success'} [type='info'] - Visual style. + */ + window.showToast = function showToast(message, type) { + if (typeof message !== 'string') return; + type = type || 'info'; + + // Remove old toasts of the same type to avoid clutter + document.querySelectorAll('.toast-' + type).forEach(function (t) { + t.remove(); + }); + + var toast = document.createElement('div'); + toast.className = 'toast toast-' + type; + toast.setAttribute('role', 'alert'); + toast.textContent = message; + + // Position: stack from bottom + var existing = document.querySelectorAll('.toast'); + var offset = existing.length * STACK_GAP; + toast.style.bottom = (1.5 + offset / 16) + 'rem'; + + document.body.appendChild(toast); + + setTimeout(function () { + if (toast.parentNode) toast.remove(); + }, TOAST_DURATION); + }; +})(); diff --git a/public/login.html b/public/login.html index f8cb91b..bba8e25 100644 --- a/public/login.html +++ b/public/login.html @@ -20,6 +20,7 @@

Don't have an account? Register

+ diff --git a/public/profile.html b/public/profile.html index f1ac3d0..f5e748e 100644 --- a/public/profile.html +++ b/public/profile.html @@ -70,6 +70,7 @@ + diff --git a/public/register.html b/public/register.html index 6864a38..da010ee 100644 --- a/public/register.html +++ b/public/register.html @@ -20,6 +20,7 @@

Already have an account? Login

+ diff --git a/public/shop.html b/public/shop.html index bd71230..07437a5 100644 --- a/public/shop.html +++ b/public/shop.html @@ -46,6 +46,7 @@ + diff --git a/src/app.js b/src/app.js index ca12d56..43f2036 100644 --- a/src/app.js +++ b/src/app.js @@ -1,6 +1,7 @@ const express = require('express'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); +const path = require('path'); const { createSessionMiddleware, requireAuth } = require('./middleware/session'); const { registerLimiter, loginLimiter, routes: authRoutes } = require('./routes/auth'); const { routes: characterRoutes } = require('./routes/character'); @@ -91,7 +92,24 @@ function createApp() { // Hero profile route (public, no session required) app.get('/api/heroes/:name', heroProfileRoutes.getHeroProfile); - // Central error handler + // Catch-all: serve 404 page for unknown paths (HTML requests only). + // API paths still return JSON errors via the central handler. + // Placed before static files so it intercepts non-file paths. + const PUBLIC_DIR = path.resolve(__dirname, '../public'); + app.use((req, res, next) => { + // If a route already set headers, let it through (e.g., static file not found) + if (res.headersSent) return next(); + if (req.accepts('html')) { + return res.status(404).sendFile(path.join(PUBLIC_DIR, '404.html')); + } + return res.status(404).json({ + ok: false, + error: { code: 'NOT_FOUND', message: 'Resource not found.' }, + }); + }); + + // Static files (after catch-all for API paths, but static will still serve HTML pages) + app.use(express.static('public')); app.use((err, _req, res, _next) => { // Log stack traces server-side only if (err.stack) { @@ -120,6 +138,9 @@ function createApp() { } // Generic error + if (req.accepts('html')) { + return res.status(500).sendFile(path.join(PUBLIC_DIR, '500.html')); + } res.status(500).json({ ok: false, error: { diff --git a/tests/integration/error-pages.test.js b/tests/integration/error-pages.test.js new file mode 100644 index 0000000..8328923 --- /dev/null +++ b/tests/integration/error-pages.test.js @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, cleanup } = require('../helpers'); + +test('GET /nonexistent → serves 404.html for HTML requests', async () => { + const base = await setup(); + try { + const res = await fetch(base + '/nonexistent-page', { + headers: { Accept: 'text/html' }, + }); + assert.equal(res.status, 404); + const html = await res.text(); + assert.ok(html.includes('404'), '404 page should contain "404"'); + assert.ok(html.includes('Page Not Found'), '404 page should have title'); + assert.ok(html.includes('Emberfall'), '404 page should be branded'); + } finally { + await teardown(); + cleanup(); + } +}); + +test('GET /nonexistent → JSON error for API requests', async () => { + const base = await setup(); + try { + const res = await fetch(base + '/api/nonexistent', { + headers: { Accept: 'application/json' }, + }); + assert.equal(res.status, 404); + const data = await res.json(); + assert.equal(data.ok, false); + assert.equal(data.error.code, 'NOT_FOUND'); + assert.ok(typeof data.error.message === 'string'); + } finally { + await teardown(); + cleanup(); + } +}); + +test('GET /api/heroes/nonexistent → 404.html (public route, unknown hero)', async () => { + const base = await setup(); + try { + const res = await fetch(base + '/api/heroes/doesnotexist', { + headers: { Accept: 'text/html' }, + }); + // Public route exists, so it returns JSON 404 (not the page 404) + assert.equal(res.status, 404); + const data = await res.json(); + assert.equal(data.ok, false); + } finally { + await teardown(); + cleanup(); + } +});