From cdfc6c4c1ac7fe08315de71908ac8ce089c228ab Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 20:24:34 +0400 Subject: [PATCH 01/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/events/events-table.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/components/events/events-table.tsx b/components/events/events-table.tsx index 63afb762..04449b4f 100644 --- a/components/events/events-table.tsx +++ b/components/events/events-table.tsx @@ -126,7 +126,8 @@ function TimeRemainingProgress({ event }: { event: Event }) {
{timeString} - — {urgencyLabel} + {/* Visible urgency text keeps status independent of color. */} + — {urgencyLabel}
} @@ -452,4 +464,4 @@ export function EventsTable({ className }: EventsTableProps) {
) -} \ No newline at end of file +} From 2f7ba71e69364e44ffe10656c81f6eecf1df68ea Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 20:24:36 +0400 Subject: [PATCH 02/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/icons/OutcomeIcons.tsx | 150 +++--------------------------- 1 file changed, 11 insertions(+), 139 deletions(-) diff --git a/components/icons/OutcomeIcons.tsx b/components/icons/OutcomeIcons.tsx index 526cbde0..412ffa13 100644 --- a/components/icons/OutcomeIcons.tsx +++ b/components/icons/OutcomeIcons.tsx @@ -1,156 +1,28 @@ -/** +/* * OutcomeIcons * * Color-blind safe shape-based icons for prediction outcome tiles. * Shapes are distinguishable under Deuteranopia and Tritanopia simulations * because differentiation is based on geometry, not hue alone. * - * Palette → Shape mapping (also documented in app/design-system/tokens.md): - * "positive" / "yes" / index 0 → TriangleUp (▲) - * "negative" / "no" / index 1 → TriangleDown (▽) - * "neutral" / third / index 2 → Diamond (◇) + *Palette → Shape mapping (also documented in app/design-system/tokens.md): + * "positive" / "yes" / index 0 → TriangleUp (▊) + * "negative" / "no" / index 1 → TriangleDown (▰) + * "neutral" / third / index 2 → Diamond (▇) * * Usage: * * * * Accessibility: - * - Always render with aria-hidden="true"; the adjacent text label is the - * primary accessible name (WCAG 2.1 AA 1.4.1 Use of Color). - * - Optionally supply a `title` for standalone icon usage. + * - Shapes are the primary non-color differentiator (WCAG 1.4.1). + * - Icons are decorative by default and are hidden from assistive + * technology; include adjacent text for meaning. + * - For standalone icon usage, provide either a `title`, `aria-label`, + * or `aria-labelledby` to expose an accessible name. */ import React from 'react'; import { cn } from '@/lib/utils'; -// ─── Variant definitions ──────────────────────────────────────────────────── - -export type OutcomeVariant = 'positive' | 'negative' | 'neutral'; - -/** - * Map a zero-based tally index to a semantic OutcomeVariant. - * Index 0 → positive, 1 → negative, 2+ → neutral. - */ -export function getVariantByIndex(index: number): OutcomeVariant { - if (index === 0) return 'positive'; - if (index === 1) return 'negative'; - return 'neutral'; -} - -// ─── Icon components ───────────────────────────────────────────────────────── - -interface SVGIconProps extends React.SVGProps { - /** Accessible title for standalone (non-aria-hidden) usage */ - title?: string; - className?: string; -} - -/** - * TriangleUpIcon — represents a positive / "Yes" / first outcome. - * Shape: solid upward-pointing triangle (▲). - */ -export function TriangleUpIcon({ title, className, ...props }: SVGIconProps) { - return ( - - {title && {title}} - {/* Equilateral triangle pointing up */} - - - ); -} - -/** - * TriangleDownIcon — represents a negative / "No" / second outcome. - * Shape: solid downward-pointing triangle (▽). - */ -export function TriangleDownIcon({ title, className, ...props }: SVGIconProps) { - return ( - - {title && {title}} - {/* Equilateral triangle pointing down */} - - - ); -} - -/** - * DiamondIcon — represents a neutral / third outcome. - * Shape: solid diamond / rhombus (◇). - */ -export function DiamondIcon({ title, className, ...props }: SVGIconProps) { - return ( - - {title && {title}} - {/* Diamond / rhombus */} - - - ); -} - -// ─── Unified OutcomeIcon ────────────────────────────────────────────────────── - -interface OutcomeIconProps extends SVGIconProps { - /** Semantic outcome variant that determines the shape rendered */ - variant: OutcomeVariant; -} - -/** - * OutcomeIcon — a single component that renders the correct shape for a - * given outcome variant. Prefer this over using TriangleUpIcon et al. directly - * so variant-to-shape logic stays in one place. - * - * @example - * - * - * - */ -export function OutcomeIcon({ variant, ...rest }: OutcomeIconProps) { - switch (variant) { - case 'positive': - return ; - case 'negative': - return ; - case 'neutral': - return ; - } -} - -// ─── Outcome variant helpers ────────────────────────────────────────────────── - -/** - * Tailwind color-token class for each outcome variant. - * These reference chart-* tokens so colors are theme-aware and not bare - * hue names, satisfying WCAG 2.1 AA 1.4.1 (Use of Color). - */ -export const OUTCOME_COLOR_CLASS: Record = { - positive: 'text-chart-1', - negative: 'text-chart-2', - neutral: 'text-chart-3', -}; +// ── Variant definitions ── ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←← \ No newline at end of file From d078fce18025b2cdc5617b1adeacfd0644017ccf Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 20:24:37 +0400 Subject: [PATCH 03/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/market/StatusBadge.tsx | 38 ++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/components/market/StatusBadge.tsx b/components/market/StatusBadge.tsx index d122f258..8ac6ec98 100644 --- a/components/market/StatusBadge.tsx +++ b/components/market/StatusBadge.tsx @@ -5,6 +5,7 @@ import { Clock, TrendingUp, Lock, CheckCircle2, AlertCircle } from 'lucide-react import { Badge } from '@/components/ui/badge'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; +import { useStatusChangeAnnouncement } from '@/hooks/useStatusChangeAnnouncement'; /** * Market status values representing the lifecycle of a prediction market. @@ -22,6 +23,10 @@ interface StatusBadgeProps { className?: string; /** Whether to show tooltip on hover (default: true) */ showTooltip?: boolean; + /** Optional market ID for accessibility announcements */ + marketId?: string; + /** Optional market title for accessibility announcements */ + marketTitle?: string; } /** @@ -88,22 +93,37 @@ const STATUS_PATTERN_CLASSES: Record = { * Features: * - Displays current market status with appropriate icon and color * - Live tooltip showing status meaning and any transition information + * - Automatic screen reader announcements when status changes (Issue #906) * - Full screen reader support via sr-only text and ARIA attributes * - Keyboard accessible (visible on focus as well as hover) * - Responsive design that works on small screens * - Dark mode support using Tailwind class-based strategy * * Accessibility (WCAG 2.1 AA): - * - Uses role="status" to announce status changes to screen readers + * - Uses role="status" to display status to screen readers * - Includes aria-describedby linking to the sr-only description * - SR-only text contains full tooltip description for accessibility * - Keyboard accessible via focus (Radix Tooltip handles this) + * - Announces status changes via global live region when marketId provided + * + * Announcement Behavior (Issue #906): + * - Pass marketId and marketTitle to enable automatic status change announcements + * - Announcements are deduplicated (won't spam for rapid repeated calls) + * - Time-critical statuses (resolved, cancelled) use assertive priority + * - Other statuses use polite priority to avoid interrupting user * * @example * ```tsx - * // Basic usage + * // Basic usage (no announcements) * * + * // With announcements enabled + * + * * // With custom styling * * @@ -115,6 +135,8 @@ export function StatusBadge({ status, className, showTooltip = true, + marketId, + marketTitle, }: StatusBadgeProps): JSX.Element { const Icon = STATUS_ICONS[status]; const label = STATUS_LABELS[status]; @@ -122,7 +144,17 @@ export function StatusBadge({ const variant = STATUS_VARIANTS[status]; const patternClass = STATUS_PATTERN_CLASSES[status] ?? 'pattern-diagonal'; - const statusId = `status-description-${status}-${Math.random().toString(36).substr(2, 9)}`; + const statusId = React.useId(); + + // Announce status changes to assistive technology (WCAG 2.1 SC 4.1.3) + const { announceMarketStatus } = useStatusChangeAnnouncement(); + + React.useEffect(() => { + // Only announce if market ID is provided (opt-in to avoid spam) + if (marketId) { + announceMarketStatus(marketId, status, marketTitle); + } + }, [status, marketId, marketTitle, announceMarketStatus]); const badge = ( Date: Sat, 29 Aug 2026 20:24:38 +0400 Subject: [PATCH 04/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/claims/ClaimEligibilityStatus.tsx | 297 +++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 components/claims/ClaimEligibilityStatus.tsx diff --git a/components/claims/ClaimEligibilityStatus.tsx b/components/claims/ClaimEligibilityStatus.tsx new file mode 100644 index 00000000..39e5cac9 --- /dev/null +++ b/components/claims/ClaimEligibilityStatus.tsx @@ -0,0 +1,297 @@ +"use client"; + +/** + * ClaimEligibilityStatus + * + * 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 + * 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 + * 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 + * wallet (the authorization / permission state). + * - not_found - a neutral "unavailable" empty state. + * + * WCAG 2.1 AA: + * - role="status" + aria-live="polite" for dynamic updates. + * - Status conveyed by pattern AND text (SC 1.4.1). + * - Keyboard-focusable Retry control with a visible focus ring. + */ + +import React from "react"; +import { AlertCircle, Loader2, Lock, RefreshCw, ShieldCheck } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useReducedMotion } from "@/hooks/useReducedMotion"; +import { useClaimEligibility } from "@/hooks/useClaimEligibility"; +import { formatClaimEligibilityRelativeTime } from "@/lib/claim-eligibility"; +import type { + ClaimEligibilityClientError, +} from "@/lib/claim-eligibility-client"; +import type { + ClaimEligibilityDecision, + ClaimEvidence, +} 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; + /** Injectable fetcher; defaults to the production `fetchClaimEligibility`. */ + fetcher?: ( + marketId: string, + options: { account?: string; signal?: AbortSignal }, + ) => Promise; + reducedMotion?: boolean; + className?: string; +} + +interface DecisionStyle { + label: string; + /** Tailwind classes for the badge (colour + pattern overlay). */ + badgeClass: string; + tone: "positive" | "neutral" | "warning" | "destructive"; +} + +const DECISION_STYLES: Record = { + eligible: { + label: "Eligible to claim", + badgeClass: "bg-chart-2 text-white pattern-diagonal", + tone: "positive", + }, + already_claimed: { + label: "Already claimed", + badgeClass: "bg-muted text-muted-foreground pattern-crosshatch", + tone: "neutral", + }, + pending: { + label: "Pending settlement", + badgeClass: "bg-chart-3 text-white pattern-horizontal", + tone: "neutral", + }, + disputed: { + label: "Under dispute", + badgeClass: "bg-destructive/20 text-destructive pattern-vertical", + tone: "destructive", + }, + ineligible_wrong_outcome: { + label: "Not eligible", + badgeClass: "bg-muted text-muted-foreground pattern-crosshatch", + tone: "neutral", + }, + ineligible_unresolved: { + label: "Outcome pending", + badgeClass: "bg-muted text-muted-foreground pattern-horizontal", + tone: "neutral", + }, + unknown: { + label: "Eligibility unknown", + badgeClass: "bg-muted text-muted-foreground pattern-crosshatch", + tone: "warning", + }, +}; + +// 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 = { + 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]; + return ( + + + {style.label} + + ); +} + +function ErrorAlert({ + error, + onRetry, +}: { + error: ClaimEligibilityClientError; + onRetry: () => void; +}) { + const isNotFound = error.kind === "not_found"; + const isPermission = error.kind === "permission"; + const canRetry = error.retryable || error.kind === "unknown"; + + if (isPermission) { + return ( + + + Authorization required + + Connect your wallet to view claim eligibility for this market. + + + ); + } + + if (isNotFound) { + return ( + + + Eligibility unavailable + + Authoritative claim evidence is not available for this market. + + + ); + } + + return ( + + + Couldn't load eligibility + {error.message} + {canRetry && ( + + ))} + + ); +} + +export const ClaimEligibilityStatus: React.FC = ({ + marketId, + account, + fetcher, + reducedMotion = false, + className, +}) => { + const hookReducedMotion = useReducedMotion(); + const isReduced = reducedMotion || hookReducedMotion; + + const { + phase, + eligibility, + error, + freshness, + retry, + } = useClaimEligibility({ marketId, account, fetcher }); + + const staleNote = + phase === "success" && eligibility && freshness === "stale" + ? `Evidence may be outdated (resolved ${formatClaimEligibilityRelativeTime( + eligibility.resolvedAt, + Date.now(), + )}).` + : null; + + let content: React.ReactNode; + + if (phase === "loading" && !eligibility) { + content = ( +
+ + {!isReduced && ( + + )} +
+ ); + } else if (phase === "error" && error) { + content = ; + } else if (phase === "permission_denied" && error) { + content = ; + } else if (eligibility) { + // success, idle, or loading-with-last-good-data: the full result is always + // preserved and visible so the user never loses the data they were viewing. + content = ( +
+
+ + + {eligibility.reason} + +
+ {staleNote && ( +

+ {staleNote} +

+ )} +
+ ); + } else { + content = ; + } + + return ( +
+ {content} + {/* Screen-reader-only status hints for phases whose visible content is + decorative/unchanged so the change is still announced. */} + {phase === "loading" && !eligibility && ( + Checking claim eligibility… + )} + {phase === "loading" && eligibility && ( + Updating eligibility… + )} +
+ ); +}; + +export default ClaimEligibilityStatus; From ce62b2d3cdc166f709e2c66f0fe2ed5e4e923191 Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 20:24:39 +0400 Subject: [PATCH 05/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/moderation/MarketStatusBadge.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/moderation/MarketStatusBadge.tsx b/components/moderation/MarketStatusBadge.tsx index 8ffb8ea2..f838e65d 100644 --- a/components/moderation/MarketStatusBadge.tsx +++ b/components/moderation/MarketStatusBadge.tsx @@ -1,4 +1,4 @@ -'use client'; +'client'; import { Clock, AlertTriangle, ShieldAlert, Flag, XCircle, Loader2 } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -29,7 +29,7 @@ export function MarketStatusBadge({ state, className, showTooltip = true }: Mark const Icon = STATE_ICONS[state]; const isResolving = state === 'resolving'; - const variantMap: Record = { + const variantMap: Record = { under_review: 'info', paused: 'warning', restricted: 'danger', @@ -40,12 +40,14 @@ export function MarketStatusBadge({ state, className, showTooltip = true }: Mark const badge = ( @@ -57,7 +59,7 @@ export function MarketStatusBadge({ state, className, showTooltip = true }: Mark if (!showTooltip) return badge; return ( - + {badge} From 3d6e7899e0d5e0a5b5b8b3fe106b9e769e190d75 Mon Sep 17 00:00:00 2001 From: Caleb Date: Sat, 29 Aug 2026 20:24:41 +0400 Subject: [PATCH 06/21] security: [Quality-2][High] Add accessible outcome semantics beyond co (#905) --- components/active-bets/ActiveBetCard.tsx | 60 +++++++++++++++--------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/components/active-bets/ActiveBetCard.tsx b/components/active-bets/ActiveBetCard.tsx index 803575e8..c55b7da4 100644 --- a/components/active-bets/ActiveBetCard.tsx +++ b/components/active-bets/ActiveBetCard.tsx @@ -8,10 +8,19 @@ import { categoryColors } from '@/lib/mock-data'; import { useDensity } from '@/hooks/useDensity'; import { ProgressRing } from './progress-ring'; import { useReducedMotion } from '@/hooks/useReducedMotion'; +import { useStatusChangeAnnouncement } from '@/hooks/useStatusChangeAnnouncement'; -export const ActiveBetCard: React.FC = ({ bet, isLoading = false, className }) => { +export const ActiveBetCard: React.FC = ({ bet, isLoading = false, className }) => { const { tokens: t } = useDensity(); const reducedMotion = useReducedMotion(); + const { announceBetStatus } = useStatusChangeAnnouncement(); + + // Announce bet status changes (Issue #906) + React.useEffect(() => { + if (bet?.id && bet?.status) { + announceBetStatus(bet.id, bet.status, bet.title); + } + }, [bet?.id, bet?.status, bet?.title, announceBetStatus]); // Safe fallback properties const title = bet?.title || ''; @@ -45,7 +54,7 @@ export const ActiveBetCard: React.FC
-
+
@@ -54,9 +63,9 @@ export const ActiveBetCard: React.FC
- )} + ))}
-
+
@@ -66,24 +75,26 @@ export const ActiveBetCard: React.FC - {/* Skeleton Overlay */} + /* Skeleton Overlay */ - {/* Content Container */} + /* Content Container */
- {/* Thumbnail and Category */} + /* Thumbnail and Category */
{thumbnail && ( @@ -105,11 +116,11 @@ export const ActiveBetCard: React.FC )} -
+
{/* Category Chip */} @@ -129,16 +140,16 @@ export const ActiveBetCard: React.FC - {/* Title */} + /* Title */

{title}

- {/* Start/End Dates — hidden in ultra */} + /* Start/End Dates — hidden in ultra */ {t.showDates && bet && (
-
+
-
+