Skip to content
Merged
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
28 changes: 28 additions & 0 deletions src/app/connect/ConnectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function ConnectPanel() {
error,
initializing: walletInitializing,
linkState,
pendingRelinkAddress,
confirmRelink,
cancelRelink,
connect,
disconnect,
} = useWallet();
Expand Down Expand Up @@ -229,6 +232,31 @@ export function ConnectPanel() {
</p>
</div>
)}
{pendingRelinkAddress && (
<div
role="alertdialog"
aria-labelledby="relink-title"
className="mt-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 ring-1 ring-inset ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30"
>
<p id="relink-title" className="font-medium">
{messages["connect.wallet.relinkTitle"]}
</p>
<p className="mt-1">
{t("connect.wallet.relinkBody", {
address: `${pendingRelinkAddress.slice(0, 4)}...${pendingRelinkAddress.slice(-4)}`,
onFile: `${user?.stellarAddress?.slice(0, 4)}...${user?.stellarAddress?.slice(-4)}`,
})}
</p>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={confirmRelink} loading={connecting}>
{messages["connect.wallet.relinkConfirm"]}
</Button>
<Button size="sm" variant="ghost" onClick={cancelRelink}>
{messages["connect.wallet.relinkCancel"]}
</Button>
</div>
</div>
)}
<Button
className="mt-4 w-full"
variant="outline"
Expand Down
57 changes: 57 additions & 0 deletions src/components/ui/Badge.contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Badge.contrast.test.ts (#49)
*
* WCAG AA (4.5:1) check for every status/difficulty badge text/background
* pair, light and dark. Tailwind palette hexes are inlined; translucent dark
* backgrounds (`/10`) are composited over the dark page background.
*/

const HEX = {
white: "#ffffff",
"slate-100": "#f1f5f9", "slate-300": "#cbd5e1", "slate-700": "#334155", "slate-800": "#1e293b",
"sky-50": "#f0f9ff", "sky-300": "#7dd3fc", "sky-500": "#0ea5e9", "sky-800": "#075985",
"amber-50": "#fffbeb", "amber-300": "#fcd34d", "amber-500": "#f59e0b", "amber-800": "#92400e",
"indigo-50": "#eef2ff", "indigo-300": "#a5b4fc", "indigo-500": "#6366f1", "indigo-800": "#3730a3",
"violet-50": "#f5f3ff", "violet-300": "#c4b5fd", "violet-500": "#8b5cf6", "violet-800": "#5b21b6",
"emerald-50": "#ecfdf5", "emerald-300": "#6ee7b7", "emerald-500": "#10b981", "emerald-700": "#047857", "emerald-800": "#065f46", "emerald-950": "#022c22",
"rose-50": "#fff1f2", "rose-300": "#fda4af", "rose-500": "#f43f5e", "rose-800": "#9f1239",
"fuchsia-50": "#fdf4ff", "fuchsia-300": "#f0abfc", "fuchsia-500": "#d946ef", "fuchsia-800": "#86198f",
} as const;

type RGB = [number, number, number];
const rgb = (h: string): RGB => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as RGB;
const over = (fg: RGB, a: number, bg: RGB): RGB => fg.map((c, i) => c * a + bg[i] * (1 - a)) as RGB;
const lum = ([r, g, b]: RGB) => {
const f = (c: number) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const ratio = (a: RGB, b: RGB) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};

const PAGE_DARK = rgb("#0a0a0f"); // globals.css dark background
const tint = (name: keyof typeof HEX) => over(rgb(HEX[name]), 0.1, PAGE_DARK);
const c = (name: keyof typeof HEX) => rgb(HEX[name]);

// [label, foreground, background]
const PAIRS: Array<[string, RGB, RGB]> = [
["open light", c("slate-700"), c("slate-100")],
["open dark", c("slate-300"), c("slate-800")],
["refunded light", c("slate-700"), c("slate-100")],
["refunded dark", c("slate-300"), c("slate-800")],
["paid light", c("white"), c("emerald-700")],
["paid dark", c("emerald-950"), c("emerald-500")],
...(["sky", "amber", "indigo", "violet", "rose", "fuchsia", "emerald"] as const).flatMap(
(h): Array<[string, RGB, RGB]> => [
[`${h} light`, c(`${h}-800` as keyof typeof HEX), c(`${h}-50` as keyof typeof HEX)],
[`${h} dark`, c(`${h}-300` as keyof typeof HEX), tint(`${h}-500` as keyof typeof HEX)],
],
),
];

describe("badge colour contrast", () => {
it.each(PAIRS)("%s meets WCAG AA", (_label, fg, bg) => {
expect(ratio(fg, bg)).toBeGreaterThanOrEqual(4.5);
});
});
30 changes: 30 additions & 0 deletions src/components/ui/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,33 @@ describe("Badge", () => {
expect(screen.getByText("Custom").className).toMatch(/test-extra-class/);
});
});

describe("non-colour cues (#49)", () => {
it.each([...ALL_STATUSES])("renders a decorative icon alongside the %s label", (status) => {
const { container } = render(<StatusBadge status={status} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("aria-hidden", "true");
});

it("gives every status a distinct icon", () => {
const icons = ALL_STATUSES.map((status) => {
const { container, unmount } = render(<StatusBadge status={status} />);
const html = container.querySelector("svg")?.getAttribute("class");
unmount();
return html;
});
expect(new Set(icons).size).toBe(ALL_STATUSES.length);
});

it("makes funded and paid differ by fill as well as icon", () => {
render(
<>
<StatusBadge status="funded" />
<StatusBadge status="paid" />
</>,
);
expect(screen.getByText("Paid").className).toMatch(/bg-emerald-700/);
expect(screen.getByText("Funded").className).not.toMatch(/bg-emerald-700/);
});
});
134 changes: 117 additions & 17 deletions src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,130 @@
import {
CheckCheck,
CircleDashed,
Eye,
Flame,
Gem,
GitMerge,
Lock,
Sprout,
TimerOff,
Undo2,
UserCheck,
Zap,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { BountyStatus, Difficulty } from "@/types";

const statusStyles: Record<BountyStatus, string> = {
open: "bg-slate-100 text-slate-700 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700",
funded: "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/30",
claimed: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
in_review: "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
merged: "bg-violet-50 text-violet-700 ring-violet-200 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/30",
paid: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
refunded: "bg-slate-100 text-slate-600 ring-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-700",
expired: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30",
/**
* Badge encoding (#49)
*
* Colour is never the only carrier of meaning. Every state pairs a hue with
* (1) a distinct lucide icon and (2) a text label, and the money-adjacent
* pairs that are most easily confused get a third cue:
*
* funded (escrowed) → Lock icon, tinted sky, solid outline
* paid (released) → CheckCheck icon, *filled* emerald, white text
*
* `paid` is the only status with a solid fill, so it reads differently from
* every tinted badge even in greyscale. `refunded` and `open` use a dashed
* outline (nothing is committed / money returned) to separate them from the
* solid-outlined states. Emerald is used for the terminal "money delivered"
* state and for `beginner`; both are non-interactive pills, so they don't
* collide with the emerald button affordance (buttons are never pill-shaped
* with an icon prefix).
*
* Contrast (WCAG AA, 4.5:1) of every text/background pair is asserted in
* Badge.contrast.test.ts against the Tailwind palette hexes below.
*/
interface BadgeStyle {
className: string;
Icon: LucideIcon;
}

const statusStyles: Record<BountyStatus, BadgeStyle> = {
open: {
Icon: CircleDashed,
className:
"bg-slate-100 text-slate-700 ring-slate-300 ring-dashed dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600",
},
funded: {
Icon: Lock,
className:
"bg-sky-50 text-sky-800 ring-sky-300 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/40",
},
claimed: {
Icon: UserCheck,
className:
"bg-amber-50 text-amber-800 ring-amber-300 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/40",
},
in_review: {
Icon: Eye,
className:
"bg-indigo-50 text-indigo-800 ring-indigo-300 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/40",
},
merged: {
Icon: GitMerge,
className:
"bg-violet-50 text-violet-800 ring-violet-300 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-500/40",
},
paid: {
Icon: CheckCheck,
className:
"bg-emerald-700 text-white ring-emerald-700 font-semibold dark:bg-emerald-500 dark:text-emerald-950 dark:ring-emerald-500",
},
refunded: {
Icon: Undo2,
className:
"bg-slate-100 text-slate-700 ring-slate-400 ring-dashed dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-500",
},
expired: {
Icon: TimerOff,
className:
"bg-rose-50 text-rose-800 ring-rose-300 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/40",
},
};

const difficultyStyles: Record<Difficulty, string> = {
beginner: "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
intermediate: "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
advanced: "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30",
expert: "bg-fuchsia-50 text-fuchsia-700 ring-fuchsia-200 dark:bg-fuchsia-500/10 dark:text-fuchsia-300 dark:ring-fuchsia-500/30",
const difficultyStyles: Record<Difficulty, BadgeStyle> = {
beginner: {
Icon: Sprout,
className:
"bg-emerald-50 text-emerald-800 ring-emerald-300 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/40",
},
intermediate: {
Icon: Zap,
className:
"bg-amber-50 text-amber-800 ring-amber-300 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/40",
},
advanced: {
Icon: Flame,
className:
"bg-rose-50 text-rose-800 ring-rose-300 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/40",
},
expert: {
Icon: Gem,
className:
"bg-fuchsia-50 text-fuchsia-800 ring-fuchsia-300 dark:bg-fuchsia-500/10 dark:text-fuchsia-300 dark:ring-fuchsia-500/40",
},
};

function BaseBadge({
className,
Icon,
children,
}: {
className?: string;
Icon?: LucideIcon;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset",
"inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset",
className,
)}
>
{Icon && <Icon aria-hidden="true" className="h-3 w-3 shrink-0" />}
{children}
</span>
);
Expand All @@ -54,14 +146,22 @@ const statusLabels: Record<BountyStatus, string> = {

export function StatusBadge({ status }: { status: BountyStatus }) {
return (
<BaseBadge className={statusStyles[status]}>
<BaseBadge
className={statusStyles[status].className}
Icon={statusStyles[status].Icon}
>
{statusLabels[status]}
</BaseBadge>
);
}

export function DifficultyBadge({ difficulty }: { difficulty: Difficulty }) {
return <BaseBadge className={difficultyStyles[difficulty]}>{difficulty}</BaseBadge>;
const { className, Icon } = difficultyStyles[difficulty];
return (
<BaseBadge className={cn("capitalize", className)} Icon={Icon}>
{difficulty}
</BaseBadge>
);
}

export function Badge({ children, className }: { children: React.ReactNode; className?: string }) {
Expand Down
Loading