Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,15 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P
| 1 M0 Validate | 17 | 1 | 6 | 10 | 0 | 0 | 0 | 6% |
| 2 Contract + registry | 41 | 24 | 3 | 14 | 0 | 0 | 0 | 59% |
| 3 SDK + indexer | 21 | 14 | 3 | 4 | 0 | 0 | 0 | 67% |
| 4 App | 32 | 20 | 8 | 4 | 0 | 0 | 0 | 63% |
| 4 App | 32 | 21 | 8 | 3 | 0 | 0 | 0 | 66% |
| 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% |
| 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% |
| 7 Hardening | 26 | 0 | 0 | 26 | 0 | 0 | 0 | 0% |
| 8 Launch | 8 | 0 | 0 | 8 | 0 | 0 | 0 | 0% |
| 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a |
| 10 Wave + community | 10 | 5 | 1 | 4 | 0 | 0 | 0 | 50% |
| 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a |
| **All** | **216** | **80** | **22** | **89** | **0** | **25** | **0** | **42%** |
| **All** | **216** | **81** | **22** | **88** | **0** | **25** | **0** | **42%** |

---

Expand Down Expand Up @@ -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 |
| M3-29 | Bump `next` off the vulnerable `16.3.8` pin: 2 critical unauthenticated-RCE advisories (Image Optimization API, `use cache`) plus a high-severity Server Actions DoS | app | P0 | DONE | — | `pnpm audit --audit-level=high` clean of the three advisories; lint, typecheck, full test suite, build, and e2e all still pass |
| M3-30 | Add OpenSSF Scorecard CI (`ossf/scorecard-action`): weekly supply-chain security posture scan published to the public Scorecard API/badge | app | P2 | DONE | — | `.github/workflows/scorecard.yml` runs on push to `main` and weekly; results visible on the Security tab and via `https://api.scorecard.dev/projects/github.com/Kinlock-Org/kinlock-app/badge`; verified on a real run — merged 2026-10-26, confirmed live at `api.scorecard.dev` (score 4.9, informing `M3-31`) |
| M3-31 | Scorecard remediation, Tier 1: pin every `uses:` in every workflow file to a commit SHA (`Pinned-Dependencies` was 3/10); add `.github/dependabot.yml` for `npm` + `github-actions` (`Dependency-Update-Tool` was 0/10); enable CodeQL default setup for `actions`+`javascript-typescript` via the Code Scanning API (`SAST` was 0/10) | app | P2 | DONE | M3-30 | Next scheduled Scorecard run shows `Pinned-Dependencies` at 10/10 and `Dependency-Update-Tool`/`SAST` both non-zero; verified against a real re-run, not assumed — merged; Dependabot and CodeQL both confirmed working for real |
Expand Down Expand Up @@ -555,6 +555,7 @@ Newest first. One entry per PR. Required for every contribution (see §2).
| 2026-10-26 | `ci/pin-action-shas` | app | M3-30 DONE, M3-31 IN PROGRESS | Confirmed `M3-30` live: fetched `api.scorecard.dev/projects/github.com/Kinlock-Org/kinlock-app`, score 4.9/10 — marked `DONE`. Started Tier 1 remediation (`M3-31`): pinned `actions/checkout@v4`, `pnpm/action-setup@v4`, `actions/setup-node@v4` to commit SHAs across `ci.yml`, `e2e.yml`, `docs-in-sync.yml`, `roadmap-check.yml` (resolved via `gh api .../git/refs/tags/{tag}`); added `.github/dependabot.yml`; enabled CodeQL default setup for `actions`+`javascript-typescript` via the Code Scanning API. Left `IN PROGRESS` pending a real Scorecard re-run. |
| 2026-10-26 | `ci/scorecard` | app | M3-30 IN PROGRESS | Added `.github/workflows/scorecard.yml` (OpenSSF Scorecard), copied from the current canonical `ossf/scorecard/.github/workflows/scorecard-analysis.yml`, pinned to the same commit SHAs upstream uses. `push`-to-`main` and weekly schedule only (`pull_request` is documented upstream as experimental). `publish_results: true` since this repo is public. **Update 2026-10-26:** added the Scorecard badge to `README.md`'s badge row, per request. Left `IN PROGRESS` pending the first real run confirming the job executes and publishes successfully. |
| 2026-10-23 | `fix/next-critical-rce` | app | M3-29 DONE | Found while adding the malware-scan CI job (PR #51): `pnpm audit` flagged 2 critical + 1 high Next.js advisories on the `16.3.8` pin (`GHSA-2464-fhr4-w9xw` unauthenticated RCE via Image Optimization API; `GHSA-673q-mjm6-r5xr` unauthenticated RCE via `use cache`; `GHSA-p3m5-59mp-jv5r` Server Actions DoS). Bumped to `next@16.4.3` (latest stable patch in the 16.x line). Verified: `pnpm audit --audit-level=high` clean of all three (5 unrelated low/moderate advisories remain, pre-existing, below the gate); `pnpm lint`, `pnpm typecheck`, `pnpm test` (166/166), `pnpm build`, and `pnpm e2e` (1 passed, 1 skipped — same skip as before the bump) all green. |
| 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. |
Expand Down
39 changes: 39 additions & 0 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);

useEffect(() => {
// Focus lands on the retry control for keyboard and screen-reader accessibility
retryRef.current?.focus();
}, []);

return (
<main className="flex min-h-[60vh] items-center justify-center px-4 py-16">
<StatePanel
role="alert"
title={t("states.error.title")}
description={t("states.error.description")}
action={
<button
ref={retryRef}
type="button"
onClick={reset}
className="rounded-lg bg-forest px-5 py-2.5 text-sm font-medium text-paper transition-colors hover:bg-forest/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-forest"
>
{t("states.error.retry")}
</button>
}
/>
</main>
);
}
16 changes: 16 additions & 0 deletions app/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-[60vh] items-center justify-center px-4 py-16">
<StatePanel
role="status"
icon={<Spinner label={t("states.loading.title")} className="size-8 text-forest" />}
title={t("states.loading.title")}
description={t("states.loading.generic")}
/>
</main>
);
}
16 changes: 16 additions & 0 deletions app/locks/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-xl p-4 py-12">
<StatePanel
role="status"
icon={<Spinner label={t("states.loading.title")} className="size-8 text-forest" />}
title={t("states.loading.title")}
description={t("states.loading.locks")}
/>
</main>
);
}
23 changes: 23 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-[60vh] items-center justify-center px-4 py-16">
<StatePanel
role="region"
title={t("states.notFound.title")}
description={t("states.notFound.description")}
action={
<Link
href="/"
className="rounded-lg bg-forest px-5 py-2.5 text-sm font-medium text-paper transition-colors hover:bg-forest/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-forest"
>
{t("states.notFound.home")}
</Link>
}
/>
</main>
);
}
16 changes: 16 additions & 0 deletions app/payee/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto max-w-xl p-4 py-12">
<StatePanel
role="status"
icon={<Spinner label={t("states.loading.title")} className="size-8 text-forest" />}
title={t("states.loading.title")}
description={t("states.loading.payee")}
/>
</main>
);
}
27 changes: 27 additions & 0 deletions components/ui/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SVGProps } from "react";

export function Spinner({
className = "size-6 text-forest",
label,
...props
}: SVGProps<SVGSVGElement> & { label?: string }) {
return (
<svg
role="status"
aria-label={label}
aria-hidden={label ? undefined : true}
className={`motion-safe:animate-spin ${className}`}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}
34 changes: 34 additions & 0 deletions components/ui/StatePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role={role}
className={`mx-auto flex max-w-lg flex-col items-center justify-center rounded-2xl border border-ink/10 bg-paper px-6 py-12 text-center shadow-xs ${className}`}
>
{icon && <div className="mb-4 flex items-center justify-center">{icon}</div>}
{title && <h2 className="text-xl font-medium tracking-tight text-ink">{title}</h2>}
{description && <p className="mt-2 text-sm leading-relaxed text-ink/70">{description}</p>}
{children && <div className="mt-4 w-full">{children}</div>}
{action && <div className="mt-6 flex justify-center">{action}</div>}
</div>
);
}
73 changes: 73 additions & 0 deletions components/ui/state.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
19 changes: 19 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Loading