Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cdfc6c4
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
2f7ba71
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
d078fce
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
7d7fcea
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
ce62b2d
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
3d6e789
security: [Quality-2][High] Add accessible outcome semantics beyond c…
Calebstack Aug 29, 2026
c9cf8b1
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
1ba19fe
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
4de2731
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
e48f921
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
2209e63
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
7b23f28
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
7b74afb
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
5cc165f
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
f098be8
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
cbd4065
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
e712d33
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
606180b
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
444ca71
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
7ff5d80
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
b93a436
fix(ci): resolve failing checks for #905
Calebstack Aug 29, 2026
562c8a9
Merge upstream main into PR conflict resolution
greatest0fallt1me Aug 30, 2026
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
99 changes: 43 additions & 56 deletions components/active-bets/ActiveBetCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';
'Use client';

import React from 'react';
import Image from 'next/image';

import { cn } from '@/lib/utils';
import { ActiveBetCardProps } from '@/lib/types';
import { categoryColors } from '@/lib/mock-data';
Expand Down Expand Up @@ -30,9 +31,7 @@ export const ActiveBetCard: React.FC<ActiveBetCardProps & { isLoading?: boolean
const endDate = bet?.endDate || new Date();
const thumbnail = bet?.thumbnail || '';

const categoryStyle = bet
? categoryColors[bet.category.color]
: { bg: 'bg-muted/20', text: 'text-muted-foreground', border: 'border-muted/30', progress: 'bg-muted' };
const categoryStyle = bet ? categoryColors[bet.category.color] : { bg: 'bg-muted/20', text: 'text-muted-foreground', border: 'border-muted/30', progress: 'bg-muted' };

const formatTimeRemaining = (timeString: string) => {
if (!timeString) return '';
Expand Down Expand Up @@ -72,31 +71,29 @@ export const ActiveBetCard: React.FC<ActiveBetCardProps & { isLoading?: boolean
);

return (
<div
<div
className={cn("relative overflow-hidden rounded-xl", t.cardWidth)}
data-state={isLoading ? "loading" : "loaded"}
aria-busy={isLoading }
>
{/* Skeleton Overlay */}
<div
<div
className={cn(
"absolute inset-0 transition-opacity duration-300 ease-in-out z-10 pointer-events-none bg-background",
"absolute inset-0 transition-opacity duration-300ms ease-in-out z-10 pointer-events-none bg-background",
isLoading ? "opacity-100" : "opacity-0"
)}
aria-hidden="true"
>
<ActiveBetCardSkeleton />
</div>

{/* Content Container */}
<div
className={cn(
'flex-shrink-0 bg-card/50 backdrop-blur-sm border border-border/50 rounded-xl cursor-pointer group',
'flex-shrink-0 bg-card/50 backdrop-blur sm border border-border/50 rounded-xl cursor-pointer group',
'hover:bg-card/70 hover:border-border/70 hover:shadow-lg',
'focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50',
'transition-all duration-300 ease-out',
'transition-all duration-300ms ease-out',
reducedMotion ? "" : "transform",
isLoading
? "opacity-0 translate-y-1 pointer-events-none"
: "opacity-100 translate-y-0",
isLoading ? "opacity-0 translate-y1 pointer-events-none" : "opacity-100 translate-y0",
t.cardPadding,
t.cardWidth,
className
Expand All @@ -105,27 +102,31 @@ export const ActiveBetCard: React.FC<ActiveBetCardProps & { isLoading?: boolean
role="button"
aria-label={isLoading ? "Loading bet..." : `Active bet: ${title}`}
data-density
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.currentTarget.click();
}
}}
>
{/* Thumbnail and Category */}
<div className={cn("relative mb-3", t.thumbnailHeight)}>
<div className={cn("relative mb-3", t.thumbnailHeight)} >
<div className={cn("relative w-full rounded-lg overflow-hidden bg-muted", t.thumbnailHeight)}>
{thumbnail && (
<Image
src={thumbnail}
alt={title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-200"
className="object-cover group-hover:scale-105 transition-transform duration-300ms"
sizes="(max-width: 640px) 280px, 320px"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute inset-0 bg-gradient-to-bfrom black/60 via-transparent to-transparent" />
</div>

{/* Category Chip */}
{bet?.category?.name && (
<div
className={cn(
'absolute top-2 left-2 rounded-md font-medium border backdrop-blur-sm',
'absolute top-2 left-2 rounded-md font-medium border backdrop-blur sm',
categoryStyle.bg,
categoryStyle.text,
categoryStyle.border,
Expand All @@ -137,73 +138,59 @@ export const ActiveBetCard: React.FC<ActiveBetCardProps & { isLoading?: boolean
</div>
)}
</div>

{/* Title */}
<h3 className={cn("font-semibold text-foreground leading-tight mb-3", t.titleSize)}>
<h3 className={cn("font-semibold text-foreground leading-tight mb-3", t.titleSize)_>
{title}
</h3>

{/* Start/End Dates — hidden in ultra */}
{t.showDates && bet && (
<div className={cn("space-y-1 mb-3", t.bodySize)}>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="w-2 h-2 rounded-full bg-green-500" aria-hidden="true" />
<span className="text-muted-foreground">Starts:</span>
<span className="text-foreground font-medium">
{startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
{startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
<div className="w-2 h-2 rounded-full bg-red-500" aria-hidden="true" />
<span className="text-muted-foreground">Ends:</span>
<span className="text-foreground font-medium">
{endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
{endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
)}

{/* Progress Bar and Time Remaining */}
<div className="space-y-2">
{/* Progress Bar */}
<div className={cn("w-full bg-muted/50 rounded-full overflow-hidden", t.progressHeight)}>
<div
className={cn(
'h-full rounded-full transition-all duration-300',
categoryStyle.progress
)}
style={{ width: `${progress}%` }}
<div className={cn("w-full bg-muted/50 rounded-full overflow-hidden", t.progressHeight)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-label={`Active bet progress: ${progress}%`}
>
<div className={cn(
'h-full rounded-full transition-all duration-300ms',
categoryStyle.progress
)}
style={{ width: `${progress}%``}}
/>
</div>

{/* Time Remaining */}
<div className="flex justify-end items-center gap-2">
{bet && (
<ProgressRing
startDate={startDate}
endDate={endDate}
size={24}
/>
<span aria-hidden="true">
<ProgressRing startDate={startDate} endDate={endDate} size={24} />
</span>
)}

<span className={cn(
'font-mono font-medium',
t.captionSize,
categoryStyle.text
)}>
<span className="sr-only">Time remaining: </span>
{formatTimeRemaining(timeRemaining)}
</span>
</div>
</div>
</div>
</div>
);
};
};
86 changes: 68 additions & 18 deletions components/claims/ClaimEligibilityStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
* Displays whether the connected user is eligible to claim winnings for a market
* based on authoritative evidence. It models every required state explicitly:
*
* - loading accessible skeleton + screen-reader announcement; the
* - loading -- accessible skeleton + screen-reader announcement; the
* previous (last-good) result is preserved while reloading so
* the user never loses data they were viewing.
* - success a colour-blind-safe badge (pattern + text) describing the
* - success - a colour-blind-safe badge (pattern + text) describing the
* decision, plus a stale-evidence warning when applicable.
* - error an actionable alert with a Retry button when retryable.
* - permission a non-retryable alert prompting the user to connect a
* - error - an actionable alert with a Retry button when retryable.
* - permission - a non-retryable alert prompting the user to connect a
* wallet (the authorization / permission state).
* - not_found a neutral "unavailable" empty state.
* - not_found - a neutral "unavailable" empty state.
*
* WCAG 2.1 AA:
* - role="status" + aria-live="polite" for dynamic updates.
Expand All @@ -23,7 +23,8 @@
*/

import React from "react";
import { AlertCircle, Loader2, Lock, RefreshCw, ShieldCheck } from "lucide-react";
import { AlertCircle, AlertTriangle, CheckCircle2, Clock, HelpCircle, Loader2, Lock, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
Expand All @@ -39,6 +40,9 @@ import type {
} from "@/types/claim-eligibility";
import { cn } from "@/lib/utils";

// Imported CSSProperties for inline patterns that guarantee non-color distinction.
import type { CSSProperties } from "react";

export interface ClaimEligibilityStatusProps {
marketId: string;
account?: string;
Expand Down Expand Up @@ -66,7 +70,7 @@ const DECISION_STYLES: Record<ClaimEligibilityDecision, DecisionStyle> = {
},
already_claimed: {
label: "Already claimed",
badgeClass: "bg-muted text-muted-foreground pattern-crosshatch",
badgeClass: "bg-muted text-foreground pattern-crosshatch",
tone: "neutral",
},
pending: {
Expand All @@ -81,37 +85,81 @@ const DECISION_STYLES: Record<ClaimEligibilityDecision, DecisionStyle> = {
},
ineligible_wrong_outcome: {
label: "Not eligible",
badgeClass: "bg-muted text-muted-foreground pattern-crosshatch",
badgeClass: "bg-muted text-foreground pattern-crosshatch",
tone: "neutral",
},
ineligible_unresolved: {
label: "Outcome pending",
badgeClass: "bg-muted text-muted-foreground pattern-horizontal",
badgeClass: "bg-muted text-foreground pattern-horizontal",
tone: "neutral",
},
unknown: {
label: "Eligibility unknown",
badgeClass: "bg-muted text-muted-foreground pattern-crosshatch",
badgeClass: "bg-muted text-foreground pattern-crosshatch",
tone: "warning",
},
};

const DECISION_ICONS: Record<ClaimEligibilityDecision, LucideIcon> = {
eligible: CheckCircle2,
already_claimed: ShieldCheck,
pending: Clock,
disputed: AlertTriangle,
ineligible_wrong_outcome: XCircle,
ineligible_unresolved: Clock,
unknown: HelpCircle,
};

// Inline background patterns ensure the visual distinction is not dependent on
// external CSS (SC 1.4.1). Each pattern uses a subtle, high-contrast stripe/
// geometry that is visible on the decision-specific background class.
const DECISION_PATTERNS: Record<ClaimEligibilityDecision, CSSProperties> = {
eligible: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(255,255,255,0.18) 0, rgba(255,255,255,0.18) 6px, transparent 6px, transparent 12px)",
},
already_claimed: {
backgroundImage:
"repeating-linear-gradient(90deg, rgba(0,0,0,0.08) 0, rgba(0,0,0,0.08) 4px, transparent 4px, transparent 8px)",
},
pending: {
backgroundImage:
"repeating-linear-gradient(0deg, rgba(0,0,0,0.08) 0, rgba(0,0,0,0.08) 4px, transparent 4px, transparent 8px)",
},
disputed: {
backgroundImage:
"repeating-linear-gradient(90deg, rgba(0,0,0,0.12) 0, rgba(0,0,0,0.12) 6px, transparent 6px, transparent 12px)",
},
ineligible_wrong_outcome: {
backgroundImage:
"repeating-linear-gradient(45deg, rgba(0,0,0,0.08) 0, rgba(0,0,0,0.08) 4px, transparent 4px, transparent 8px)",
},
ineligible_unresolved: {
backgroundImage:
"repeating-linear-gradient(0deg, rgba(0,0,0,0.08) 0, rgba(0,0,0,0.08) 4px, transparent 4px, transparent 8px)",
},
unknown: {
backgroundImage:
"repeating-linear-gradient(135deg, rgba(0,0,0,0.08) 0, rgba(0,0,0,0.08) 4px, transparent 4px, transparent 8px)",
},
};

function EligibilityBadge({
decision,
}: {
decision: ClaimEligibilityDecision;
}) {
const style = DECISION_STYLES[decision];
const Icon = DECISION_ICONS[decision];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
style.badgeClass,
)}
role="status"
aria-label={`Claim eligibility: ${style.label}`}
style={DECISION_PATTERNS[decision]}
>
<ShieldCheck className="relative z-10 h-3.5 w-3.5" aria-hidden={true} />
<Icon className="relative z-10 h-3.5 w-3.5" aria-hidden={true} />
<span className="relative z-10">{style.label}</span>
</span>
);
Expand All @@ -131,7 +179,7 @@ function ErrorAlert({
if (isPermission) {
return (
<Alert className="border-amber-500/40 bg-amber-500/10">
<Lock className="h-4 w-4" />
<Lock className="h-4 w-4" aria-hidden={true} />
<AlertTitle>Authorization required</AlertTitle>
<AlertDescription>
Connect your wallet to view claim eligibility for this market.
Expand All @@ -143,7 +191,7 @@ function ErrorAlert({
if (isNotFound) {
return (
<Alert className="border-border/60 bg-card/60">
<AlertCircle className="h-4 w-4" />
<AlertCircle className="h-4 w-4" aria-hidden={true} />
<AlertTitle>Eligibility unavailable</AlertTitle>
<AlertDescription>
Authoritative claim evidence is not available for this market.
Expand All @@ -154,11 +202,12 @@ function ErrorAlert({

return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Couldn&apos;t load eligibility</AlertTitle>
<AlertCircle className="h-4 w-4" aria-hidden={true} />
<AlertTitle>Couldn&#39;t load eligibility</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
{canRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onRetry}
Expand All @@ -168,7 +217,7 @@ function ErrorAlert({
<RefreshCw className="mr-2 h-3.5 w-3.5" aria-hidden={true} />
Retry
</Button>
)}
))}
</Alert>
);
}
Expand Down Expand Up @@ -243,6 +292,7 @@ export const ClaimEligibilityStatus: React.FC<ClaimEligibilityStatusProps> = ({
aria-live="polite"
aria-atomic="true"
aria-busy={phase === "loading"}
aria-label={`Claim eligibility for market ${marketId}`}
data-testid={`claim-eligibility-${marketId}`}
>
{content}
Expand Down
Loading
Loading