From 0db967f16d7639ef3e89b7ceabb5501cbbc5b3b6 Mon Sep 17 00:00:00 2001 From: richardtoms100 Date: Tue, 25 Aug 2026 02:46:05 +0100 Subject: [PATCH 1/4] fix(theme): read initial theme synchronously to stop the icon flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeProvider initialized theme state unconditionally to "light", then corrected it a render later inside a useEffect by reading back the class themeInitScript had already applied to before hydration. Between first paint and that effect running, theme was "light" regardless of the visitor's actual preference — ThemeToggle renders its icon directly from this state, so a visitor whose page was actually dark saw a Moon icon (meaning "click to go dark") for one render even though the page was already dark: the icon momentarily backwards relative to the real state, a visible flash on every page load in dark mode. Switched to a lazy useState initializer that reads document.documentElement.classList synchronously at mount/hydration time instead of hardcoding "light" and fixing it up afterward — safe here since ThemeProvider is a Client Component executing after the inline theme-init script has already run. Guarded for SSR, where this component still executes once with no `document` available; the client's own hydration render (which is what actually paints) always has it by then (closes #208). --- src/context/ThemeContext.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 5d91de1..0bcc389 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -16,13 +16,21 @@ interface ThemeContextValue { const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState("light"); + // Lazy initializer reads the class themeInitScript already applied to + // synchronously, at mount/hydration time — not hardcoded to + // "light" and corrected a render later, which made ThemeToggle briefly + // show the wrong icon (backwards relative to the real theme) on every + // page load in dark mode (#208). Guarded for SSR, where this Client + // Component still executes once with no `document` available; the + // client's own hydration render is what actually matters here and always + // has `document` by then, since the inline script runs before React. + const [theme, setTheme] = useState(() => + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light", + ); useEffect(() => { - // Reflects the class the no-flash init script already applied to . - // eslint-disable-next-line react-hooks/set-state-in-effect - setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light"); - // Listen for system color scheme changes when no explicit preference is set. const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleSystemThemeChange = (e: MediaQueryListEvent) => { From ab55832d521c05bbb8b85fdb19e9e14eca46e2be Mon Sep 17 00:00:00 2001 From: richardtoms100 Date: Tue, 25 Aug 2026 02:46:15 +0100 Subject: [PATCH 2/4] fix(avatar-stack): add an accessible label to the "+N" overflow badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AvatarStack's numeric overflow indicator had no aria-label, title, or any text alternative beyond the bare "+{rest}" string. A screen reader encountering it read only "+3" with no indication it represents additional, hidden contributors beyond the ones already announced — sighted users infer this from the stacked-circle visual context, which isn't conveyed to assistive technology. This badge sits right next to the homepage's "Joined by N contributors already earning" copy, so it's one of the first interactive-looking elements a screen reader user encounters on the page. Added aria-label={`+${rest} more contributors`} plus a title listing the hidden seeds, for sighted mouse users hovering it (closes #209). --- src/components/ui/Avatar.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/ui/Avatar.tsx b/src/components/ui/Avatar.tsx index d7811d6..a25c91b 100644 --- a/src/components/ui/Avatar.tsx +++ b/src/components/ui/Avatar.tsx @@ -34,6 +34,7 @@ export function Avatar({ export function AvatarStack({ seeds, max = 5 }: { seeds: string[]; max?: number }) { const shown = seeds.slice(0, max); const rest = seeds.length - shown.length; + const hidden = seeds.slice(max); return (
{shown.map((seed, i) => ( @@ -45,7 +46,11 @@ export function AvatarStack({ seeds, max = 5 }: { seeds: string[]; max?: number /> ))} {rest > 0 && ( - + +{rest} )} From e6af689893c58783d459d04d3c67f09c337ec2a9 Mon Sep 17 00:00:00 2001 From: richardtoms100 Date: Tue, 25 Aug 2026 02:46:24 +0100 Subject: [PATCH 3/4] fix(empty-state): mark the decorative icon aria-hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmptyState's icon had no aria-hidden and no accessible-name suppression, despite every meaningful message it conveys already being present as text immediately below it — the icon is purely decorative, but some screen readers may still attempt to announce it (lucide icons render as inline SVG, and an unlabeled SVG's a11y treatment varies by browser/AT). EmptyState is used across every dashboard and list view in the app, so this same gap repeated everywhere it's rendered. Added aria-hidden="true", the same way StatCard.tsx already does for its own decorative icons (closes #210). --- src/components/ui/EmptyState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx index 5c34031..d7c4321 100644 --- a/src/components/ui/EmptyState.tsx +++ b/src/components/ui/EmptyState.tsx @@ -14,7 +14,7 @@ export function EmptyState({ return (
- +

{title}

{description}

From 4f1af24c8cb5acfbe0998864a12ecccec98aa333 Mon Sep 17 00:00:00 2001 From: richardtoms100 Date: Tue, 25 Aug 2026 02:46:36 +0100 Subject: [PATCH 4/4] fix(button): add a loading prop that sets aria-busy and standardizes the pending pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Button exposed no loading/pending prop — every consumer performing an async action reimplemented the same manual pattern independently (disabled={pending || connecting} + a manually swapped text label), across five separate call sites in IssueActions, MilestoneActions (x2), and ConnectPanel. None set aria-busy while pending, and none rendered a visual spinner — only the text label changed, which isn't reliably announced to a screen reader without an aria-live region, and a low-vision user relying on zoom/high contrast may not notice a text-only change either. Every one of these is a real, often money-moving action (fund, claim, refund, deposit, wallet connect). Added a `loading` prop to Button: sets aria-busy="true", forces disabled (independent of an explicitly-passed disabled), and renders a small inline spinner. Migrated all five existing call sites from disabled={pending || connecting} to loading={pending || connecting}, so the standardized behavior actually replaces the five independent reimplementations rather than existing unused alongside them. Added Button.test.tsx (no prior test file existed) covering the default, loading, and explicitly-disabled states (closes #211). --- src/app/connect/ConnectPanel.tsx | 2 +- src/app/issues/[id]/IssueActions.tsx | 6 ++-- src/app/milestones/MilestoneActions.tsx | 4 +-- src/components/ui/Button.test.tsx | 44 +++++++++++++++++++++++++ src/components/ui/Button.tsx | 24 +++++++++++++- 5 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/Button.test.tsx diff --git a/src/app/connect/ConnectPanel.tsx b/src/app/connect/ConnectPanel.tsx index e5a5c77..a109411 100644 --- a/src/app/connect/ConnectPanel.tsx +++ b/src/app/connect/ConnectPanel.tsx @@ -71,7 +71,7 @@ export function ConnectPanel() { className="mt-4 w-full" variant="outline" onClick={connect} - disabled={connecting} + loading={connecting} > {connecting ? "Connecting..." : "Connect Freighter"} diff --git a/src/app/issues/[id]/IssueActions.tsx b/src/app/issues/[id]/IssueActions.tsx index 89f4665..0b7854d 100644 --- a/src/app/issues/[id]/IssueActions.tsx +++ b/src/app/issues/[id]/IssueActions.tsx @@ -85,17 +85,17 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
{bounty.status === "open" && ( - )} {bounty.status === "funded" && ( - )} {(bounty.status === "funded" || bounty.status === "claimed") && ( - )} diff --git a/src/app/milestones/MilestoneActions.tsx b/src/app/milestones/MilestoneActions.tsx index 2991bb5..978f9a6 100644 --- a/src/app/milestones/MilestoneActions.tsx +++ b/src/app/milestones/MilestoneActions.tsx @@ -38,7 +38,7 @@ export function MilestoneFundButton({ milestoneId }: { milestoneId: string }) { return (
- {error && ( @@ -97,7 +97,7 @@ export function PoolDepositButton({ poolId }: { poolId: string }) { onChange={(e) => setAmount(e.target.value)} className="w-24 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-900 focus:border-indigo-400 focus:outline-none dark:border-slate-800 dark:bg-slate-900 dark:text-white" /> - {error && ( diff --git a/src/components/ui/Button.test.tsx b/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..1c1acff --- /dev/null +++ b/src/components/ui/Button.test.tsx @@ -0,0 +1,44 @@ +/** + * Button.test.tsx (#211) + * + * Covers the `loading` prop: it should set aria-busy, force disabled + * (even when `disabled` isn't separately passed), and render a spinner — + * standardizing the pattern every async-action call site previously + * reimplemented independently with no aria-busy at all. + */ + +import { render, screen } from "@testing-library/react"; +import { Button } from "./Button"; + +describe("Button — loading prop", () => { + it("is not busy or disabled by default", () => { + render(); + const button = screen.getByRole("button", { name: "Fund this bounty" }); + expect(button).not.toHaveAttribute("aria-busy"); + expect(button).not.toBeDisabled(); + }); + + it("sets aria-busy and disables the button when loading", () => { + render(); + const button = screen.getByRole("button", { name: /Confirming in wallet/ }); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toBeDisabled(); + }); + + it("renders a spinner when loading", () => { + const { container } = render(); + expect(container.querySelector(".animate-spin")).not.toBeNull(); + }); + + it("renders no spinner when not loading", () => { + const { container } = render(); + expect(container.querySelector(".animate-spin")).toBeNull(); + }); + + it("stays disabled when explicitly disabled, independent of loading", () => { + render(); + const button = screen.getByRole("button", { name: "No action available" }); + expect(button).toBeDisabled(); + expect(button).not.toHaveAttribute("aria-busy"); + }); +}); diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index ef9b907..e1baf6f 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -24,16 +24,30 @@ const sizeClasses: Record = { interface ButtonProps extends ButtonHTMLAttributes { variant?: Variant; size?: Size; + /** + * Marks the button as performing an async action: sets aria-busy, forces + * disabled, and renders a small spinner. Every async-action call site in + * the app (fund/claim/refund/deposit) previously reimplemented this + * pattern independently via `disabled={pending}` + a manually swapped + * text label, with no aria-busy anywhere — a screen reader user got no + * indication anything happened until the DOM text changed (#211). + */ + loading?: boolean; } export function Button({ variant = "primary", size = "md", + loading = false, + disabled, className, + children, ...props }: ButtonProps) { return ( ); }