From be5aa0b45c3a8f58a2ce315f3a7b7c2fe355e45d Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:05 +0100 Subject: [PATCH 01/10] fix: repair botched merge in src/app/page.tsx blocking the dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3e131ad ("Merge branch 'main' into feat/live-tvl-user-metrics") kept both sides of a conflict instead of resolving it: two full, competing implementations of Home() ended up concatenated in the same file (one using useStats()/a plain layout, cut off mid-JSX; one using usePlatformStats()/useTotalUserCredits() with the current motion-animated dashboard). The result doesn't parse ("Expected ' - - - YOU - - - CURRENTLY - - - - HAVE EARNED: - - - 202 CREDITS - - - - - - {/* Total Users */} - - - Total Users - {isLoading ? ( - - ) : ( - - {stats ? formatCount(stats.totalUsers) : "—"} - - )} - - - - {/* TVL + sparkline */} - - - - Total Value Locked - {stats?.source === "demo" && ( - - - Demo - - - )} - - {isLoading ? ( - - ) : ( - - - {stats?.tvl ?? "—"} - - {stats && stats.sparkline.length >= 2 && ( - - )} - - )} - - - - {/* Last Updated */} - - - Last Updated - {isLoading ? ( - - ) : ( - - {stats ? relativeTime(stats.lastUpdated) : "—"} - - )} import { useEffect } from "react"; import { Flex, From a114ff555b80653800a5dda97d84b69da315d2d8 Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:17 +0100 Subject: [PATCH 02/10] fix: remove duplicate output/images keys in next.config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config object defined images and output twice — the second images: { unoptimized: true } was a harmless no-op duplicate, but the unconditional output: "export" on line 45 always overrode the conditional ...(isStaticExport ? { output: "export" } : {}) above it, contradicting the file's own doc comment ("Unset (default) → server mode for Vercel / next start, API routes active") and making `next build` a TypeScript error in newer Next.js versions ("An object literal cannot have multiple properties with the same name"), which is what's actually failing in the current "Deploy to GitHub Pages" CI runs on main. Pre-existing, unrelated to this PR's Leaderboard/History work — fixed because it blocked running a production build to verify nothing else broke. --- next.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/next.config.ts b/next.config.ts index 138669f..4e3abba 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,8 +42,6 @@ const nextConfig: NextConfig = { unoptimized: true, }, ...(isStaticExport ? { output: "export" } : {}), - output: "export", - images: { unoptimized: true }, devIndicators: false, ...(basePath ? { basePath, assetPrefix: basePath } : {}), }; From b624c43a16a6fa131e89fb1dce15f3c6e00e4783 Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:31 +0100 Subject: [PATCH 03/10] test: add rerender support to the shared renderHook harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useLiveAnnouncer's test (next commit) needs to change a hook's input across renders and assert the returned value updates — the existing harness only exposed { result, unmount }, with no way to trigger a second render. Adds rerender(), which just re-invokes the same render pass; a caller that captures an outer `let` and mutates it before calling rerender() sees the hook re-run with the new value, matching how @testing-library/react-hooks' rerender works. Purely additive — useCountdown.test.ts and useSorobanEvents.test.ts (the harness's other consumers) still pass unchanged. --- src/test/renderHook.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/test/renderHook.tsx b/src/test/renderHook.tsx index a2baab7..b84ecf8 100644 --- a/src/test/renderHook.tsx +++ b/src/test/renderHook.tsx @@ -25,12 +25,19 @@ export function renderHook( } const root = createRoot(container); - act(() => { - root.render(createElement(Wrapper, null, createElement(HookHarness))); - }); + // `callback` is a closure — a caller that captures an outer `let` and + // mutates it before calling `rerender()` will see the hook re-invoked + // with the new value, the same way @testing-library/react-hooks works. + function renderOnce() { + act(() => { + root.render(createElement(Wrapper, null, createElement(HookHarness))); + }); + } + renderOnce(); return { result, + rerender: renderOnce, unmount: () => { act(() => { root.unmount(); From 77470c56a7ada6e59300f5ddf0191dc636efcc2a Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:44 +0100 Subject: [PATCH 04/10] feat(a11y): add reusable useLiveAnnouncer hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For #86: a shared helper for driving aria-live regions, so Leaderboard and History (and any future page) don't each hand-roll their own announcement-state logic. Only updates its returned value when the input message's string value actually changes — since useEffect's dependency comparison is a plain value check for primitives, a parent that recomputes the same announcement text on every render (a keystroke before search-debounce settles, an unrelated poll tick) doesn't cause redundant state churn. Directly addresses the issue's callout that a naively-implemented live-region hook could add to the same class of unnecessary-render problems #88 documents for the Farm page. --- src/hooks/useLiveAnnouncer.test.ts | 37 ++++++++++++++++++++++++++++++ src/hooks/useLiveAnnouncer.ts | 21 +++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/hooks/useLiveAnnouncer.test.ts create mode 100644 src/hooks/useLiveAnnouncer.ts diff --git a/src/hooks/useLiveAnnouncer.test.ts b/src/hooks/useLiveAnnouncer.test.ts new file mode 100644 index 0000000..5e0cf63 --- /dev/null +++ b/src/hooks/useLiveAnnouncer.test.ts @@ -0,0 +1,37 @@ +import { renderHook } from "@/test/renderHook"; +import { describe, expect, it } from "vitest"; +import { useLiveAnnouncer } from "./useLiveAnnouncer"; + +describe("useLiveAnnouncer", () => { + it("Test 1 — returns the initial message on first render", () => { + const { result } = renderHook(() => useLiveAnnouncer("hello")); + expect(result.current).toBe("hello"); + }); + + it("Test 2 — updates once the message value actually changes", () => { + let message = "first"; + const { result, rerender } = renderHook(() => useLiveAnnouncer(message)); + expect(result.current).toBe("first"); + + message = "second"; + rerender(); + + expect(result.current).toBe("second"); + }); + + it("Test 3 — re-rendering with the same message value is a no-op (still returns that value)", () => { + const message = "stable"; + const { result, rerender } = renderHook(() => useLiveAnnouncer(message)); + expect(result.current).toBe("stable"); + + rerender(); + rerender(); + + expect(result.current).toBe("stable"); + }); + + it("Test 4 — an empty string is a valid message (used to represent 'nothing to announce yet')", () => { + const { result } = renderHook(() => useLiveAnnouncer("")); + expect(result.current).toBe(""); + }); +}); diff --git a/src/hooks/useLiveAnnouncer.ts b/src/hooks/useLiveAnnouncer.ts new file mode 100644 index 0000000..018b79f --- /dev/null +++ b/src/hooks/useLiveAnnouncer.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Holds the text for an aria-live region, only updating when the message + * value itself changes. A parent can recompute the same announcement on + * every render (e.g. a keystroke before search-debounce settles, or an + * unrelated poll tick) without that re-deriving a new state update or + * risking a redundant announcement — the effect's dependency comparison + * is a plain string-value check, so same-text renders are no-ops. + */ +export function useLiveAnnouncer(message: string): string { + const [announcement, setAnnouncement] = useState(message); + + useEffect(() => { + setAnnouncement(message); + }, [message]); + + return announcement; +} From 973de5ecfd0ae7f654ef2a68263c67940e8687d7 Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:12:55 +0100 Subject: [PATCH 05/10] feat(a11y): add reusable LiveRegion component For #86: wraps Chakra's VisuallyHidden with aria-live="polite", aria-atomic="true", and role="status" (a fallback announcement path for older screen readers/browsers that don't watch aria-live directly). Visually hidden via clipping rather than display:none/ visibility:hidden, which would otherwise remove it from the accessibility tree entirely and defeat the point of the region. Pairs with useLiveAnnouncer; used by both Leaderboard and History in the following commits. --- src/components/LiveRegion/LiveRegion.test.tsx | 48 +++++++++++++++++++ src/components/LiveRegion/LiveRegion.tsx | 25 ++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/components/LiveRegion/LiveRegion.test.tsx create mode 100644 src/components/LiveRegion/LiveRegion.tsx diff --git a/src/components/LiveRegion/LiveRegion.test.tsx b/src/components/LiveRegion/LiveRegion.test.tsx new file mode 100644 index 0000000..cff4826 --- /dev/null +++ b/src/components/LiveRegion/LiveRegion.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { ChakraProvider } from "@chakra-ui/react"; +import { describe, expect, it } from "vitest"; +import LiveRegion from "./LiveRegion"; + +describe("LiveRegion", () => { + it("renders the message inside a role=status element with aria-live=polite by default", () => { + render( + + + , + ); + + const region = screen.getByRole("status"); + expect(region.textContent).toBe("Leaderboard updated"); + expect(region.getAttribute("aria-live")).toBe("polite"); + expect(region.getAttribute("aria-atomic")).toBe("true"); + }); + + it("supports an assertive politeness level for urgent announcements", () => { + render( + + + , + ); + + expect(screen.getByRole("status").getAttribute("aria-live")).toBe( + "assertive", + ); + }); + + it("is visually hidden but still present in the accessibility tree", () => { + render( + + + , + ); + + const region = screen.getByRole("status"); + const style = window.getComputedStyle(region); + // Chakra's VisuallyHidden clips content off-screen rather than using + // display:none/visibility:hidden, which would remove it from the a11y + // tree entirely and defeat the point of an aria-live region. + expect(style.position).toBe("absolute"); + expect(style.display).not.toBe("none"); + expect(style.visibility).not.toBe("hidden"); + }); +}); diff --git a/src/components/LiveRegion/LiveRegion.tsx b/src/components/LiveRegion/LiveRegion.tsx new file mode 100644 index 0000000..12ae679 --- /dev/null +++ b/src/components/LiveRegion/LiveRegion.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { VisuallyHidden } from "@chakra-ui/react"; + +type LiveRegionProps = { + message: string; + politeness?: "polite" | "assertive"; +}; + +/** + * Visually-hidden aria-live region for announcing dynamic content changes + * (table refresh, sort, pagination, search results) to assistive tech. + * `role="status"` gives older screen readers a fallback announcement path + * on top of aria-live for browsers/AT that don't watch it directly. + */ +export default function LiveRegion({ + message, + politeness = "polite", +}: LiveRegionProps) { + return ( + + {message} + + ); +} From 47532871dd027614711302ff35a07538d5eb9d14 Mon Sep 17 00:00:00 2001 From: miraclesonly <304894442+miraclesonly@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:13:11 +0100 Subject: [PATCH 06/10] feat(leaderboard): add aria-sort semantics and live-region announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For #86, acceptance criteria on the Leaderboard page: - aria-sort ("descending"/"none") on the Credits/Stake Th elements, kept in sync with sortKey. Following the WAI-ARIA APG sortable-table pattern, the actual clickable/focusable control is a