From ced021ce1c1ac5b3443172a09181387336055a95 Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu <297354017+CodingAngel1@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:48:47 +0800 Subject: [PATCH] feat(app): loading, error and not-found states for user-facing routes - Closes #37 (Roadmap row M3-27) - Introduce shared Spinner and StatePanel primitives in components/ui/ with motion-safe animations and accessibility roles - Implement root app/loading.tsx and route-specific loading states for /locks and /payee with contextual plain-language descriptions - Implement app/error.tsx with retry button, autofocus for keyboard/screen-readers, zero technical trace exposure, and strict fragment privacy (Hard Rule 5; app/claim untouched) - Implement honest app/not-found.tsx acknowledging potential indexer lag without asserting on-chain absence (Hard Rule 7) - Externalize all strings to messages/en.json (M3-23) - Add unit tests in components/ui/state.test.ts (186/186 tests passing) - Update ROADMAP.md marking M3-27 as DONE with Changelog entry --- ROADMAP.md | 5 ++- app/error.tsx | 39 +++++++++++++++++++ app/loading.tsx | 16 ++++++++ app/locks/loading.tsx | 16 ++++++++ app/not-found.tsx | 23 ++++++++++++ app/payee/loading.tsx | 16 ++++++++ components/ui/Spinner.tsx | 27 +++++++++++++ components/ui/StatePanel.tsx | 34 +++++++++++++++++ components/ui/state.test.ts | 73 ++++++++++++++++++++++++++++++++++++ messages/en.json | 19 ++++++++++ 10 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 app/error.tsx create mode 100644 app/loading.tsx create mode 100644 app/locks/loading.tsx create mode 100644 app/not-found.tsx create mode 100644 app/payee/loading.tsx create mode 100644 components/ui/Spinner.tsx create mode 100644 components/ui/StatePanel.tsx create mode 100644 components/ui/state.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index a4bc565..9166b38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-10-08 (W-05 resolved; 17 Wave issues seeded with seven new rows; issue format proofed against Drips' maintainer guide; the three pre-format issues triaged — app #16 re-emitted, app #15 and registry #7 closed as superseded; repo-side Changelog lines unioned back into canonical by `scripts/roadmap-merge`; that merge's ADR-link normalization anchored so quoted prose cannot be rewritten) | +| **Last updated** | 2026-10-17 (M3-27 app #37 loading, error, and not-found states; 186 tests green) | | **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) | | **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) | | **Readiness** | See [§3](#3-progress-snapshot) | @@ -323,7 +323,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M3-24 | CI guard: fail on hard-coded country, currency, anchor, or locale literals outside registry data, tests, and fixtures | all | P1 | DONE | M3-01, M2-01 | Guard runs in app and sdk CI; passing and failing examples tested | | M3-25 | Favicon/app icon, persistent site header (nav, docs and GitHub links), mobile hamburger menu, reduced-motion-safe entrance motion | app | P2 | DONE | M3-01, M3-04 | Favicon set in every browser; nav usable one-handed on a narrow viewport; `prefers-reduced-motion` honored | | M3-26 | Interim chain-verified payee source for `/send` and `/request`, since no indexer is deployed (`M2-18`) | app | P1 | DONE | M3-05, M3-06 | `/send` and `/request` list real, on-chain `Active` payees and complete their flow up to wallet connect; **retired 2026-10-08**, superseded now that `M2-18`'s indexer is live (code removed, pages point at the real indexer again) | -| M3-27 | Route-level `loading.tsx`, `error.tsx` and `not-found.tsx` for the user-facing routes | app | P1 | TODO | M3-01 | Every in-scope route has a designed pending/error/404 state; no stack trace or file path reaches the UI; `/claim/*` untouched | +| M3-27 | Route-level `loading.tsx`, `error.tsx` and `not-found.tsx` for the user-facing routes | app | P1 | DONE | M3-01 | Every in-scope route has a designed pending/error/404 state; no stack trace or file path reaches the UI; `/claim/*` untouched | --- @@ -535,6 +535,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-10-17 | `feat/route-loading-error-states` (#47) | app | DONE: M3-27 | Closes #37. Route-level loading, error and not-found states across user-facing routes. Introduced shared Spinner and StatePanel primitives in `components/ui/`; implemented root `app/loading.tsx` and route-specific `loading.tsx` for `/locks` and `/payee` with contextual copy; implemented `app/error.tsx` with retry button, autofocus for keyboard/screen-readers, zero technical trace leakage, and fragment privacy (hard rule 5); implemented honest `app/not-found.tsx` acknowledging indexer lag without asserting on-chain absence (hard rule 7); all strings externalized to `messages/en.json`; 186 tests green, production build verified. | | 2026-10-08 | `chore/roadmap-copy-back` (#44) | app | no row changes | Copy-back run: `ROADMAP.md` replaced with the merged canonical (`.github` main as of #31) in repo-root link form, and §3 recomputed with `scripts/roadmap-progress`. This is the step `roadmap-merge`'s header requires after every merge and that was skipped between 2026-10-06 and 2026-10-08, which is how the seven copies reached 92 divergent statuses. Verified in this checkout: the file equals canonical byte for byte apart from this entry and the ADR link prefix, `W-05` reads `DONE`, the seven Wave rows are present, duplicate row-ID scan is 0. No row, status, priority, dependency or **Done when** was authored in this repo. | | 2026-10-08 | `fix/roadmap-merge-link-anchor` | org | no row changes | Found while preparing the copy-back. `roadmap-merge`'s read normalization rewrote the docs-rooted ADR link form into the root-relative one on every copy so the two depths compare equal — correct for links, wrong for prose: the union entry above quotes both forms, so reading that line collapsed its own quote and emitted a Changelog entry contradicting itself, in a copy that no longer matched canonical line for line. Fixed by anchoring the replace on the numeric ADR filename that always follows the slash, so only real links are rewritten, and by re-wording the union entry to name the two forms as `docs/adr/` and `adr/` rather than spelling them as link text: **an entry that describes this rewrite must not itself be a string the rewrite can act on.** Verified: the anchored read leaves a quoted form byte-identical where the blanket replace corrupted it; a full `roadmap-merge` over all five copies against `b12d290` exits 0 with **0 lines** differing from canonical, which also confirms the union is complete; the one real link (`ADR-0026`) still normalizes both ways. No row, status, priority, dependency or **Done when** in this diff. | | 2026-10-08 | `chore/roadmap-changelog-union` | org | no row changes | Second `scripts/roadmap-merge` run now that the Wave-format PRs have merged (`.github` #26 and #27, contracts #31, sdk #38, app #43, registry #18). Three-way against `b12d290`, the revision where all five copies were last identical. The merge's **entire** output is four lines: the repo-side `docs/wave-issue-format` Changelog rows that each repo wrote into its own copy and canonical never saw. No row ID, status, priority, dependency or **Done when** moved — the seven new rows went in byte-identically, and `W-05`'s `TODO` → `DONE` stayed on the `org` row in canonical as §12 requires, so the repo copies' deliberately-untouched `TODO` was not merged back over it. §3 verified unchanged by `roadmap-progress`: 211 rows, 75 `DONE`, 40%. **Not in this PR:** the copy-back. Each repo's `ROADMAP.md` must be rewritten from the merged canonical, with the ADR links rewritten for the repo-root location, so all five copies are byte-identical again — that invariant is what makes `--ancestor` meaningful next time, and it needs four repo PRs that can only be cut after this lands. | diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..443ba0e --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { StatePanel } from "@/components/ui/StatePanel"; +import { t } from "@/lib/i18n/messages"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function ErrorPage({ reset }: ErrorProps) { + const retryRef = useRef(null); + + useEffect(() => { + // Focus lands on the retry control for keyboard and screen-reader accessibility + retryRef.current?.focus(); + }, []); + + return ( +
+ + {t("states.error.retry")} + + } + /> +
+ ); +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..b206cb6 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,16 @@ +import { Spinner } from "@/components/ui/Spinner"; +import { StatePanel } from "@/components/ui/StatePanel"; +import { t } from "@/lib/i18n/messages"; + +export default function Loading() { + return ( +
+ } + title={t("states.loading.title")} + description={t("states.loading.generic")} + /> +
+ ); +} diff --git a/app/locks/loading.tsx b/app/locks/loading.tsx new file mode 100644 index 0000000..e908aa2 --- /dev/null +++ b/app/locks/loading.tsx @@ -0,0 +1,16 @@ +import { Spinner } from "@/components/ui/Spinner"; +import { StatePanel } from "@/components/ui/StatePanel"; +import { t } from "@/lib/i18n/messages"; + +export default function LocksLoading() { + return ( +
+ } + title={t("states.loading.title")} + description={t("states.loading.locks")} + /> +
+ ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..54a74ad --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { StatePanel } from "@/components/ui/StatePanel"; +import { t } from "@/lib/i18n/messages"; + +export default function NotFound() { + return ( +
+ + {t("states.notFound.home")} + + } + /> +
+ ); +} diff --git a/app/payee/loading.tsx b/app/payee/loading.tsx new file mode 100644 index 0000000..d318efe --- /dev/null +++ b/app/payee/loading.tsx @@ -0,0 +1,16 @@ +import { Spinner } from "@/components/ui/Spinner"; +import { StatePanel } from "@/components/ui/StatePanel"; +import { t } from "@/lib/i18n/messages"; + +export default function PayeeLoading() { + return ( +
+ } + title={t("states.loading.title")} + description={t("states.loading.payee")} + /> +
+ ); +} diff --git a/components/ui/Spinner.tsx b/components/ui/Spinner.tsx new file mode 100644 index 0000000..ec412d3 --- /dev/null +++ b/components/ui/Spinner.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export function Spinner({ + className = "size-6 text-forest", + label, + ...props +}: SVGProps & { label?: string }) { + return ( + + + + + ); +} diff --git a/components/ui/StatePanel.tsx b/components/ui/StatePanel.tsx new file mode 100644 index 0000000..7efdb16 --- /dev/null +++ b/components/ui/StatePanel.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +export interface StatePanelProps { + role?: "status" | "alert" | "region"; + title?: string; + description?: string; + action?: ReactNode; + icon?: ReactNode; + children?: ReactNode; + className?: string; +} + +export function StatePanel({ + role = "status", + title, + description, + action, + icon, + children, + className = "", +}: StatePanelProps) { + return ( +
+ {icon &&
{icon}
} + {title &&

{title}

} + {description &&

{description}

} + {children &&
{children}
} + {action &&
{action}
} +
+ ); +} diff --git a/components/ui/state.test.ts b/components/ui/state.test.ts new file mode 100644 index 0000000..3143441 --- /dev/null +++ b/components/ui/state.test.ts @@ -0,0 +1,73 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { t } from "@/lib/i18n/messages"; +import { Spinner } from "./Spinner"; +import { StatePanel } from "./StatePanel"; + +describe("Spinner component", () => { + it("renders with role=status and reduced-motion compliant classes", () => { + const element = Spinner({ label: "Loading lock state" }); + expect(element.type).toBe("svg"); + expect(element.props.role).toBe("status"); + expect(element.props["aria-label"]).toBe("Loading lock state"); + expect(element.props.className).toContain("motion-safe:animate-spin"); + }); + + it("marks as aria-hidden when no label is provided", () => { + const element = Spinner({}); + expect(element.props["aria-hidden"]).toBe(true); + }); +}); + +describe("StatePanel component", () => { + it("defaults to status role and renders title and description", () => { + const element = StatePanel({ + title: "Loading title", + description: "Reading data from network", + }); + expect(element.type).toBe("div"); + expect(element.props.role).toBe("status"); + + // Check inner text contents + const titleChild = element.props.children[1]; + expect(titleChild.type).toBe("h2"); + expect(titleChild.props.children).toBe("Loading title"); + + const descChild = element.props.children[2]; + expect(descChild.type).toBe("p"); + expect(descChild.props.children).toBe("Reading data from network"); + }); + + it("supports alert role for error views and custom actions", () => { + const actionNode = React.createElement("button", { type: "button" }, "Retry"); + const element = StatePanel({ + role: "alert", + title: "Error occurred", + action: actionNode, + }); + expect(element.props.role).toBe("alert"); + const actionContainer = element.props.children[4]; + expect(actionContainer.props.children).toBe(actionNode); + }); +}); + +describe("states translation keys", () => { + it("has non-empty message strings for all core states", () => { + expect(t("states.loading.title")).toBeTruthy(); + expect(t("states.loading.generic")).toBeTruthy(); + expect(t("states.loading.locks")).toBeTruthy(); + expect(t("states.loading.payee")).toBeTruthy(); + expect(t("states.error.title")).toBeTruthy(); + expect(t("states.error.description")).toBeTruthy(); + expect(t("states.error.retry")).toBeTruthy(); + expect(t("states.notFound.title")).toBeTruthy(); + expect(t("states.notFound.description")).toBeTruthy(); + expect(t("states.notFound.home")).toBeTruthy(); + }); + + it("honest wording in not-found description (does not assert on-chain absence)", () => { + const desc = t("states.notFound.description"); + expect(desc).toContain("in this app yet"); + expect(desc).toContain("indexer"); + }); +}); diff --git a/messages/en.json b/messages/en.json index 0e11ca9..5d1653f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -351,5 +351,24 @@ "unknown": "Earlier payments with this reference couldn't be checked.", "no_indexer": "Earlier payments with this reference couldn't be checked." } + }, + "states": { + "loading": { + "title": "Loading", + "generic": "Reading data from the network…", + "locks": "Reading the lock from the network…", + "payee": "Checking verified payees…", + "verify": "Checking payment status on chain…" + }, + "error": { + "title": "Something went wrong", + "description": "We couldn't load this page. Your funds, locks, and payments are safe on the blockchain.", + "retry": "Try again" + }, + "notFound": { + "title": "Page not found", + "description": "This page doesn't exist in this app yet. If you are looking for a recent lock, note that indexer updates may take a few minutes to appear.", + "home": "Back to home" + } } }