From 8cfd128f658d26fd640f2edc996b010312d462f4 Mon Sep 17 00:00:00 2001 From: Kazama Date: Thu, 30 Jul 2026 09:35:21 +0530 Subject: [PATCH 01/15] refactor(forms): share form error presentation --- src/components/AddressInput.css | 13 +------------ src/components/forms/FormError.css | 7 +++++++ src/components/forms/FormError.test.tsx | 13 +++++++++++++ src/components/forms/FormError.tsx | 16 ++++++++++++++++ src/components/forms/FormField.css | 8 -------- src/components/forms/FormField.tsx | 7 ++----- src/components/forms/index.ts | 1 + 7 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 src/components/forms/FormError.css create mode 100644 src/components/forms/FormError.test.tsx create mode 100644 src/components/forms/FormError.tsx diff --git a/src/components/AddressInput.css b/src/components/AddressInput.css index 87762999..b3124cf1 100644 --- a/src/components/AddressInput.css +++ b/src/components/AddressInput.css @@ -320,9 +320,7 @@ text-align: right; } -/** - * Form field wrapper styles (if not already defined) - */ +/** Form field wrapper styles. */ .form-field { display: flex; flex-direction: column; @@ -341,15 +339,6 @@ margin-top: -var(--credence-space-1); } -.form-error { - font-size: var(--credence-font-size-sm); - color: var(--credence-color-danger-text); - margin-top: -var(--credence-space-1); - display: flex; - align-items: flex-start; - gap: var(--credence-space-1); -} - /** * Responsive adjustments for smaller screens */ diff --git a/src/components/forms/FormError.css b/src/components/forms/FormError.css new file mode 100644 index 00000000..e1fab343 --- /dev/null +++ b/src/components/forms/FormError.css @@ -0,0 +1,7 @@ +.form-error { + display: inline-flex; + align-items: center; + gap: var(--credence-space-2); + color: var(--credence-color-danger-text); + font-size: var(--credence-font-size-sm); +} diff --git a/src/components/forms/FormError.test.tsx b/src/components/forms/FormError.test.tsx new file mode 100644 index 00000000..d533cad0 --- /dev/null +++ b/src/components/forms/FormError.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FormError } from './FormError' + +describe('FormError', () => { + it('renders one shared alert shape for form validation messages', () => { + render(Address is invalid) + + const alert = screen.getByRole('alert') + expect(alert).toHaveAttribute('id', 'address-error') + expect(alert).toHaveTextContent('⚠ Address is invalid') + }) +}) diff --git a/src/components/forms/FormError.tsx b/src/components/forms/FormError.tsx new file mode 100644 index 00000000..132698e8 --- /dev/null +++ b/src/components/forms/FormError.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import './FormError.css' + +interface FormErrorProps { + id: string + children: ReactNode +} + +/** Shared accessible error message used by every form field primitive. */ +export function FormError({ id, children }: FormErrorProps) { + return ( + + ⚠ {children} + + ) +} diff --git a/src/components/forms/FormField.css b/src/components/forms/FormField.css index e830c6a0..4f1e1d73 100644 --- a/src/components/forms/FormField.css +++ b/src/components/forms/FormField.css @@ -18,14 +18,6 @@ font-size: var(--credence-font-size-sm); } -.form-error { - display: inline-flex; - align-items: center; - gap: var(--credence-space-2); - color: var(--credence-color-danger-text); - font-size: var(--credence-font-size-sm); -} - .form-success { display: inline-flex; align-items: center; diff --git a/src/components/forms/FormField.tsx b/src/components/forms/FormField.tsx index 60c30a8f..0033955d 100644 --- a/src/components/forms/FormField.tsx +++ b/src/components/forms/FormField.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { FormError } from './FormError' import './FormField.css' export type FormFieldState = 'default' | 'error' | 'success' @@ -68,11 +69,7 @@ export function FormField({ 'aria-required': required ? 'true' : children.props['aria-required'], })} - {error && ( - - ⚠ {error} - - )} + {error && {error}} {successMessage && ( diff --git a/src/components/forms/index.ts b/src/components/forms/index.ts index 6ba0b4fc..961c5ddb 100644 --- a/src/components/forms/index.ts +++ b/src/components/forms/index.ts @@ -1,5 +1,6 @@ export { FormField } from './FormField' export type { FormFieldState } from './FormField' +export { FormError } from './FormError' export { Input } from './Input' export type { InputProps } from './Input' export { Textarea } from './Textarea' From abcbd70d4fd02ac3d860f0e46d2e2c2055eb7a2a Mon Sep 17 00:00:00 2001 From: Kazama Date: Thu, 30 Jul 2026 09:32:57 +0530 Subject: [PATCH 02/15] test(wallet): guide users when Freighter is unavailable --- src/components/ConnectWalletDialog.test.tsx | 4 ++++ src/components/ConnectWalletDialog.tsx | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/ConnectWalletDialog.test.tsx b/src/components/ConnectWalletDialog.test.tsx index f1c84295..6717c21d 100644 --- a/src/components/ConnectWalletDialog.test.tsx +++ b/src/components/ConnectWalletDialog.test.tsx @@ -116,6 +116,10 @@ describe('ConnectWalletDialog — error display', () => { mockError = { code: 'not_installed', message: 'Not installed' } renderModal() expect(screen.getByRole('alert')).toHaveTextContent(/Freighter is not installed/i) + expect(screen.getByRole('link', { name: /install freighter/i })).toHaveAttribute( + 'href', + 'https://www.freighter.app/' + ) }) it('renders a rejected error message', () => { diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx index e68626fd..7a51b78b 100644 --- a/src/components/ConnectWalletDialog.tsx +++ b/src/components/ConnectWalletDialog.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom' import { useFocusTrap } from '../hooks/useFocusTrap' import { useScrollPreserver } from '../hooks/useScrollPreserver' import { useWallet } from '../context/WalletContext' +import { FREIGHTER_INSTALL_URL } from '../lib/freighterClient' import Button from './Button' import './ConnectWalletDialog.css' @@ -105,7 +106,17 @@ export default function ConnectWalletDialog({ {errorMessage && (
- {errorMessage} + {errorMessage} + {error?.code === 'not_installed' && ( + + Install Freighter + + )}
)} From e304aacb058ee7f0e63b7ba278867fc53db7c667 Mon Sep 17 00:00:00 2001 From: Martin Ngutswen <164114946+Aonlike@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:45:28 +0000 Subject: [PATCH 03/15] feat(uiux): add multi-step create-bond flow spec - Add proper props interface (onComplete/onCancel) to CreateBondFlow - Integrate useWallet, useUsdcBalance, useReducedMotion hooks - Add balance display with loading/error/retry states - Add reduced-motion-aware transition gating - Reuse calcUnlockDate from bondPenalty lib - Fix Bond.tsx missing imports, state, BondRow with disclosure toggles - Add network mismatch banner id and txStatus announcer Closes #836 --- src/components/CreateBondFlow.tsx | 127 +++++++++++++----------------- src/pages/Bond.tsx | 127 +++++++++++++++++++++++++++--- 2 files changed, 168 insertions(+), 86 deletions(-) diff --git a/src/components/CreateBondFlow.tsx b/src/components/CreateBondFlow.tsx index d66a5255..ac1865f6 100644 --- a/src/components/CreateBondFlow.tsx +++ b/src/components/CreateBondFlow.tsx @@ -20,37 +20,46 @@ import Button from './Button' import Banner from './Banner' import Disclaimer from './Disclaimer' import { useToast } from './ToastProvider' -import { computeBondSlashBreakdown } from '../lib/bondPenalty' +import { useWallet } from '../context/WalletContext' +import { useUsdcBalance } from '../hooks/useUsdcBalance' +import { useReducedMotion } from '../hooks/useReducedMotion' +import { formatUsdc } from '../lib/format' +import { LoadingSkeleton } from './states' +import { computeBondSlashBreakdown, calcUnlockDate } from '../lib/bondPenalty' import './CreateBondFlow.css' // --------------------------------------------------------------------------- -// Helpers +// Types // --------------------------------------------------------------------------- -/** - * Derives the estimated unlock date from today + `days`. - * - * @param days - Lock duration in days. - * @returns A locale-formatted date string (e.g. "Jul 19, 2026"). - */ -const calcUnlockDate = (days: number) => { - const today = new Date() - const unlock = new Date(today.getTime() + days * 24 * 60 * 60 * 1000) - return unlock.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +export interface CreateBondFlowProps { + /** Called after the user confirms and the bond is "created" */ + onComplete?: () => void + /** Called when the user cancels the flow */ + onCancel?: () => void } // --------------------------------------------------------------------------- // Divider used between review card sections // --------------------------------------------------------------------------- + const ReviewDivider = () =>
// --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- -export default function CreateBondFlow() { +export default function CreateBondFlow({ onComplete, onCancel }: CreateBondFlowProps) { const { addToast } = useToast() + const { isConnected } = useWallet() + const { + balance, + status: balanceStatus, + refetch: refetchBalance, + } = useUsdcBalance() + const prefersReducedMotion = useReducedMotion() + const [step, setStep] = useState(1) const [amount, setAmount] = useState('') const [duration, setDuration] = useState(null) @@ -99,9 +108,15 @@ export default function CreateBondFlow() { setStep(step - 1) } + const handleCancel = () => { + reset() + onCancel?.() + } + const handleConfirm = () => { addToast('success', 'Bond created successfully.') reset() + onComplete?.() } /** @@ -120,6 +135,10 @@ export default function CreateBondFlow() { // --------------------------------------------------------------------------- // Step indicator // --------------------------------------------------------------------------- + + const stepIndicatorTransition = prefersReducedMotion ? 'none' : 'background 0.2s ease' + const durationButtonTransition = prefersReducedMotion ? 'none' : 'all 0.2s ease' + const StepIndicator = () => (
{[1, 2, 3, 4].map((i) => ( @@ -128,6 +147,7 @@ export default function CreateBondFlow() { className={['createBondFlow__stepBar', i <= step ? 'createBondFlow__stepBar--active' : ''] .filter(Boolean) .join(' ')} + style={{ transition: stepIndicatorTransition }} /> ))}
@@ -136,6 +156,7 @@ export default function CreateBondFlow() { // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- + return (
@@ -152,70 +173,29 @@ export default function CreateBondFlow() { {/* ── Balance display ── */} -
+
{!isConnected ? ( - + Connect your wallet to see your available balance. ) : balanceStatus === 'loading' ? ( ) : balanceStatus === 'error' ? ( - balanceError instanceof SessionReauthRequiredError ? ( - - - Re-authentication required. - - + + + Could not load balance. - ) : ( - - - Could not load balance. - - - - ) + Retry + + ) : ( - + Available: {formatUsdc(balance)} )} @@ -228,9 +208,9 @@ export default function CreateBondFlow() { setAmount(next) if (error) setError('') }} - balance={100000} + balance={balance} placeholder="0" - presets={[30, 90, 180]} + presets={[100, 500, 1000]} currencyLabel="USDC" disabled={!isConnected} hideErrorMessage={Boolean(error)} @@ -272,6 +252,7 @@ export default function CreateBondFlow() { ? 'createBondFlow__durationButton createBondFlow__durationButton--active' : 'createBondFlow__durationButton' } + style={{ transition: durationButtonTransition }} > {d} Days @@ -299,7 +280,7 @@ export default function CreateBondFlow() {
Bond Amount: - {amount} USDC + {formatUsdc(Number(amount))}
@@ -428,13 +409,13 @@ export default function CreateBondFlow() { disabled={!acknowledged} className="createBondFlow__navButton createBondFlow__confirmButton" > - Confirm & Create Bond + Confirm & Create Bond )} + {open && ( +
+

+ Penalty ({breakdown.penaltyPercent}%) +

+

+ + −{breakdown.penaltyAmount} + +

+

+ You would receive:{' '} + {breakdown.resultingBalance} +

+
+ )} + + ) : ( + No early-withdrawal penalty + )} + +
+ + ) +} + +const initialBonds: MockBond[] = [ + { id: 1, amountUsdc: 1000, status: 'locked' }, + { id: 2, amountUsdc: 500, status: 'grace-period' }, + { id: 3, amountUsdc: 750, status: 'active' }, +] + export default function Bond() { + const { t } = useTranslation() + const navigate = useNavigate() const { addToast } = useToast() + const { isConnected, connect, isConnecting, network: walletNetwork } = useWallet() + const networkMismatch = useNetworkMismatch() + const [withdrawTarget, setWithdrawTarget] = useState(null) const withdrawTriggerRef = useRef(null) - const mockedBalance = 10000 - const [amount, setAmount] = useState('') - const overBalance = parseFloat(amount) > mockedBalance - const balanceLabel = mockedBalance.toLocaleString('en-US', { maximumFractionDigits: 2 }) + const [bondAmount, setBondAmount] = useState('') + const [bondAmountError, setBondAmountError] = useState('') + const [isPendingCreate, setIsPendingCreate] = useState(false) + const [isPendingWithdraw, setIsPendingWithdraw] = useState(false) + const [txStatus, setTxStatus] = useState('') // Persistent error state for create/withdraw failures (wallet rejected, network down, etc.) // These surface as dismissible critical Banners rather than transient Toasts. @@ -95,16 +186,24 @@ export default function Bond() { const [withdrawError, setWithdrawError] = useState<{ type: ReturnType; message: string } | null>(null) const createErrorBannerId = 'bond-create-error' const withdrawErrorBannerId = 'bond-withdraw-error' + const mismatchBannerId = 'bond-network-mismatch' // Simulated bonds-fetch error state — replace with real data-fetch hook error when available. // When the bond list fails to load, surface an inline ErrorState inside the Active Bonds card. - const [bondsError] = useState<{ type: ReturnType; message: string } | null>(null) - // TODO: replace with real loading state when bond list is fetched from the API const isLoadingBonds = false const bonds = initialBonds + // ── Live-region announcer for transaction progress ── + const txStatusAnnouncer = txStatus ? ( + + {txStatus} + + ) : ( + + ) + const handleCreateBond = useCallback(async () => { if (!isConnected) { connect() @@ -139,7 +238,7 @@ export default function Bond() { } finally { setIsPendingCreate(false) } - }, [isConnected, connect, navigate, isPendingCreate, bondAmount]) + }, [isConnected, connect, navigate, isPendingCreate, bondAmount, setIsPendingCreate, setTxStatus, setCreateError]) const withdrawBreakdown = useMemo( () => (withdrawTarget ? computeWithdrawBreakdown(withdrawTarget) : null), @@ -158,7 +257,7 @@ export default function Bond() { setWithdrawTarget(null) }, []) - const confirmWithdraw = useCallback(() => { + const confirmWithdraw = useCallback(async () => { if (!withdrawTarget || !withdrawBreakdown) return if (isPendingWithdraw) return @@ -195,9 +294,9 @@ export default function Bond() { setWithdrawError({ type: errType, message: errMessage }) } finally { setIsPendingWithdraw(false) + setWithdrawTarget(null) } - setWithdrawTarget(null) - }, [withdrawTarget, withdrawBreakdown, addToast]) + }, [withdrawTarget, withdrawBreakdown, addToast, walletNetwork, isPendingWithdraw, setIsPendingWithdraw, setTxStatus, setWithdrawError]) const slashExposureBond = useMemo(() => bonds.find((b) => getPenaltyRate(b.status) > 0), [bonds]) @@ -286,7 +385,7 @@ export default function Bond() { > { + onChange={(next: string) => { setBondAmount(next) if (bondAmountError) setBondAmountError('') }} @@ -354,7 +453,7 @@ export default function Bond() { bond={bond} isConnected={isConnected} onWithdraw={requestWithdraw} - onConnect={() => setConnectModalOpen(true)} + onConnect={connect} /> ))} @@ -379,6 +478,8 @@ export default function Bond() { context="Bonding USDC locks funds in a non-custodial smart contract. Slashing conditions apply." termsHref="#" /> + + {txStatusAnnouncer}
) } \ No newline at end of file From 0e7e89325514eaba24d65919924aaf5249f678c4 Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <1nonlygem@gmail.com> Date: Thu, 30 Jul 2026 11:27:24 +0530 Subject: [PATCH 04/15] perf: lazy load shared icon implementations --- src/components/icons/index.test.tsx | 26 ++++++++++++++++++++++++++ src/components/icons/index.ts | 5 ----- src/components/icons/index.tsx | 28 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 src/components/icons/index.test.tsx delete mode 100644 src/components/icons/index.ts create mode 100644 src/components/icons/index.tsx diff --git a/src/components/icons/index.test.tsx b/src/components/icons/index.test.tsx new file mode 100644 index 00000000..253a9c23 --- /dev/null +++ b/src/components/icons/index.test.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react' +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { CopyIcon, WalletIcon } from './index' + +describe('lazy icon exports', () => { + it('loads an icon implementation on demand', async () => { + render( + loading icon}> + + + ) + + expect(await screen.findByLabelText('Copy')).toBeInTheDocument() + }) + + it('keeps each icon independently loadable', async () => { + render( + + + + ) + + expect(await screen.findByLabelText('Wallet')).toBeInTheDocument() + }) +}) diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts deleted file mode 100644 index f218f165..00000000 --- a/src/components/icons/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as Icon } from './Icon' -export { default as CopyIcon } from './CopyIcon' -export { default as CheckIcon } from './CheckIcon' -export { default as ExternalLinkIcon } from './ExternalLinkIcon' -export { default as WalletIcon } from './WalletIcon' diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx new file mode 100644 index 00000000..b14dcd4f --- /dev/null +++ b/src/components/icons/index.tsx @@ -0,0 +1,28 @@ +import { lazy, Suspense, type ComponentType, type ReactElement } from 'react' +import type { IconProps } from './Icon' + +export { default as Icon } from './Icon' + +type LazyIconLoader = () => Promise<{ default: ComponentType }> + +/** + * Keep icon consumers synchronous while loading SVG implementations only when + * a route actually renders them. The null fallback preserves the old compact + * layout during the one-frame chunk fetch and the app-level Suspense boundary + * still handles route-level loading states. + */ +function lazyIcon(loader: LazyIconLoader) { + const Component = lazy(loader) + return function LazyIcon(props: IconProps): ReactElement | null { + return ( + + + + ) + } +} + +export const CopyIcon = lazyIcon(() => import('./CopyIcon')) +export const CheckIcon = lazyIcon(() => import('./CheckIcon')) +export const ExternalLinkIcon = lazyIcon(() => import('./ExternalLinkIcon')) +export const WalletIcon = lazyIcon(() => import('./WalletIcon')) From ead3b181acbec4816d56022df4a16df99cf66f8d Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <1nonlygem@gmail.com> Date: Thu, 30 Jul 2026 11:26:03 +0530 Subject: [PATCH 05/15] refactor: type transactions with generated API client --- src/hooks/useTransactions.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/useTransactions.ts b/src/hooks/useTransactions.ts index 886b0f47..00448d3d 100644 --- a/src/hooks/useTransactions.ts +++ b/src/hooks/useTransactions.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { apiFetch, ApiError } from '../api/client' -import type { Transaction, ApiListResponse } from '../api/types' +import type { ApiResponse, operations, Transaction } from '../api/types' const PENDING_TXS_KEY = 'credence:pendingTransactions' const PAGE_SIZE = 20 +type TransactionsResponse = ApiResponse + function getPendingTransactions(): Transaction[] { try { const stored = localStorage.getItem(PENDING_TXS_KEY) @@ -80,7 +82,7 @@ export function useTransactions(): UseTransactionsResult { params.set('cursor', cursor) } - const result = await apiFetch>( + const result = await apiFetch( `/transactions?${params.toString()}`, { signal } ) @@ -255,4 +257,4 @@ export function useTransactions(): UseTransactionsResult { goToPage, prefetchPage, } -} \ No newline at end of file +} From 7c8f6decb9c382aafcee7d4f8fc8736665307bd0 Mon Sep 17 00:00:00 2001 From: greatest0fallt1me <1nonlygem@gmail.com> Date: Thu, 30 Jul 2026 11:25:40 +0530 Subject: [PATCH 06/15] fix: scrub PII before query cache writes --- src/hooks/useApiQuery.test.ts | 32 ++++++++++++++++++++++++++++++++ src/hooks/useApiQuery.ts | 9 +++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/hooks/useApiQuery.test.ts b/src/hooks/useApiQuery.test.ts index cba11e63..c83bc6bf 100644 --- a/src/hooks/useApiQuery.test.ts +++ b/src/hooks/useApiQuery.test.ts @@ -59,6 +59,38 @@ describe('useApiQuery', () => { expect(result.current.isStale).toBe(false) }) + it('scrubs PII before exposing and caching a query response', async () => { + apiFetchMock.mockResolvedValueOnce({ + score: 720, + tier: 'gold', + email: 'holder@example.com', + profile: { fullName: 'Wallet Holder', score: 720 }, + }) + + const { result, unmount } = renderHook(() => + useApiQuery( + '/trust-score/GABC' + ) + ) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + expect(result.current.data).toMatchObject({ + score: 720, + email: '[REDACTED]', + profile: { fullName: '[REDACTED]', score: 720 }, + }) + + unmount() + const { result: cachedResult } = renderHook(() => + useApiQuery( + '/trust-score/GABC' + ) + ) + expect(cachedResult.current.data?.email).toBe('[REDACTED]') + expect(apiFetchMock).toHaveBeenCalledTimes(1) + }) + it('skips fetch when enabled=false', async () => { const { result } = renderHook(() => useApiQuery('/trust-score/GABC', { enabled: false }) diff --git a/src/hooks/useApiQuery.ts b/src/hooks/useApiQuery.ts index 29e5102d..79b88982 100644 --- a/src/hooks/useApiQuery.ts +++ b/src/hooks/useApiQuery.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { apiFetch, ApiError } from '../api/client' import { WIDGET_CACHE_DEFAULTS } from '../config/widgetCache' +import { scrubPII } from '../lib/piiScrub' // ── Cache ─────────────────────────────────────────────────────────────────── @@ -130,10 +131,14 @@ export function useApiQuery( const result = await apiFetch(currentPath, { signal: controller.signal, }) + // Keep the cache as a safe boundary: callers receive the same + // sanitized value that is stored, so PII cannot leak through the + // query hook before it reaches the cache. + const sanitized = scrubPII(result) if (mountedRef.current && currentRunId === runIdRef.current) { - setData(result) - setCacheEntry(currentPath, result) + setData(sanitized) + setCacheEntry(currentPath, sanitized) setIsStale(false) setError(null) } From cd03d5f37fbd0677ff4d69b12e585c9891bb98f9 Mon Sep 17 00:00:00 2001 From: Shade Developer Date: Thu, 30 Jul 2026 06:43:47 +0100 Subject: [PATCH 07/15] feat(uiux): improve Trust Score layout hierarchy and readability --- src/pages/TrustScore.css | 325 ++++++++++++++++++++++++++++++++++----- 1 file changed, 287 insertions(+), 38 deletions(-) diff --git a/src/pages/TrustScore.css b/src/pages/TrustScore.css index 6e5fce68..9be9ec44 100644 --- a/src/pages/TrustScore.css +++ b/src/pages/TrustScore.css @@ -3,55 +3,161 @@ justify-content: space-between; align-items: flex-start; gap: var(--credence-space-4); - margin-bottom: var(--credence-space-2); + margin-bottom: var(--credence-space-6); } .trustScore__title { margin: 0; color: var(--credence-text-primary); line-height: var(--credence-line-height-tight); + font-size: var(--credence-font-size-2xl); } .trustScore__description { color: var(--credence-text-secondary); - margin-bottom: var(--credence-space-4); + margin-bottom: var(--credence-space-8); +} + +/* ─── Hero Section ────────────────────────────────────────────────────── */ + +.trustScore__results { + margin-top: var(--credence-space-8); + margin-bottom: var(--credence-space-12); +} + +.trustScore__hero { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--credence-space-8); + padding: var(--credence-space-12); + background: linear-gradient(135deg, var(--credence-surface-card) 0%, rgba(7, 89, 133, 0.05) 100%); + border: 2px solid var(--credence-border-default); + border-radius: var(--credence-radius-xl); + align-items: center; +} + +/* Score display - primary focal point */ +.trustScore__heroScore { + display: flex; + align-items: baseline; + gap: var(--credence-space-2); } +.trustScore__heroScoreValue { + font-size: clamp(4rem, 15vw, 6.5rem); + font-weight: var(--credence-font-weight-bold); + line-height: var(--credence-line-height-tight); + color: var(--credence-text-primary); +} + +.trustScore__heroScoreTotal { + font-size: var(--credence-font-size-2xl); + color: var(--credence-text-secondary); + font-weight: var(--credence-font-weight-regular); +} + +/* Meta information grouped with score */ +.trustScore__heroMeta { + display: flex; + flex-direction: column; + gap: var(--credence-space-4); + margin-top: var(--credence-space-2); +} + +.trustScore__heroBadge { + display: inline-flex; + align-items: center; + max-width: fit-content; +} + +.trustScore__heroNext { + margin: 0; + font-size: var(--credence-font-size-base); + color: var(--credence-text-primary); + font-weight: var(--credence-font-weight-medium); +} + +/* Gauge positioned on right side */ +.trustScore__heroGauge { + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +/* Footer metadata - address, attestations, update date */ +.trustScore__heroFooter { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: var(--credence-space-3); + flex-wrap: wrap; + padding-top: var(--credence-space-6); + border-top: 1px solid var(--credence-border-default); + font-size: var(--credence-font-size-sm); + color: var(--credence-text-secondary); +} + +.trustScore__heroFooterItem { + font-variant-numeric: tabular-nums; +} + +.trustScore__heroFooterSep { + color: var(--credence-text-secondary); + opacity: 0.5; +} + +/* ─── Supporting Grid ────────────────────────────────────────────────── */ + .trustScore__grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: 1fr 1.2fr; gap: var(--credence-space-8); - margin-top: var(--credence-space-8); } .trustScore__card { - padding: var(--credence-space-6); + display: flex; + flex-direction: column; + padding: var(--credence-space-8); border: 1px solid var(--credence-border-default); border-radius: var(--credence-radius-xl); background: var(--credence-surface-card); color: var(--credence-text-primary); + height: 100%; } .trustScore__cardTitle { - font-size: var(--credence-font-size-xl); - margin-bottom: var(--credence-space-4); + font-size: var(--credence-font-size-lg); + font-weight: var(--credence-font-weight-semibold); + margin: 0 0 var(--credence-space-6) 0; + color: var(--credence-text-primary); } .trustScore__label { display: block; - margin-bottom: var(--credence-space-2); + margin-bottom: var(--credence-space-3); font-weight: var(--credence-font-weight-semibold); + font-size: var(--credence-font-size-base); + color: var(--credence-text-primary); } .trustScore__input { width: 100%; - padding: var(--credence-space-3); + padding: var(--credence-space-4); border: 1px solid var(--credence-border-default); border-radius: var(--credence-radius-lg); font-size: var(--credence-font-size-base); - margin-bottom: var(--credence-space-4); + margin-bottom: var(--credence-space-6); background: var(--credence-surface-page); color: var(--credence-text-primary); + transition: + border-color var(--credence-motion-duration-fast) var(--credence-motion-easing-standard), + box-shadow var(--credence-motion-duration-fast) var(--credence-motion-easing-standard); +} + +.trustScore__input:focus { + outline: none; + border-color: var(--credence-color-primary); + box-shadow: 0 0 0 3px rgba(7, 89, 133, 0.1); } .trustScore__activityList { @@ -82,46 +188,46 @@ } .trustScore__buttonRow { - margin-top: var(--credence-space-4); -} - -.trustScore__results { margin-top: var(--credence-space-6); + display: flex; + gap: var(--credence-space-3); } .trustScore__recentLookups { - margin-top: var(--credence-space-4); - padding-top: var(--credence-space-3); - border-top: 1px dashed var(--credence-border-default); + margin-top: var(--credence-space-8); + padding-top: var(--credence-space-6); + border-top: 1px solid var(--credence-border-default); } .trustScore__recentLookupsHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--credence-space-2); + margin-bottom: var(--credence-space-4); } .trustScore__recentLookupsTitle { font-size: var(--credence-font-size-sm); font-weight: var(--credence-font-weight-semibold); - color: var(--credence-text-secondary); + color: var(--credence-text-primary); } .trustScore__clearButton { background: none; border: none; font-size: var(--credence-font-size-xs); - color: var(--credence-text-secondary); + color: var(--credence-color-primary); cursor: pointer; padding: 0; - text-decoration: underline; + text-decoration: none; + font-weight: var(--credence-font-weight-medium); transition: color var(--credence-motion-duration-fast) var(--credence-motion-easing-standard); } .trustScore__clearButton:hover, .trustScore__clearButton:focus-visible { - color: var(--credence-text-primary); + color: var(--credence-color-primary-strong); + text-decoration: underline; } .trustScore__clearButton:focus-visible { @@ -136,34 +242,36 @@ margin: 0; display: flex; flex-direction: column; - gap: var(--credence-space-2); + gap: var(--credence-space-3); } .trustScore__recentListItem { display: flex; align-items: center; - gap: var(--credence-space-1); + gap: var(--credence-space-2); } .trustScore__recentItemBtn { background: var(--credence-surface-page); border: 1px solid var(--credence-border-default); border-radius: var(--credence-radius-md); - padding: var(--credence-space-2) var(--credence-space-3); + padding: var(--credence-space-3) var(--credence-space-4); font-family: var(--credence-font-family-mono); - font-size: var(--credence-font-size-xs); + font-size: var(--credence-font-size-sm); color: var(--credence-text-primary); cursor: pointer; width: 100%; text-align: left; transition: background-color var(--credence-motion-duration-fast) var(--credence-motion-easing-standard), - border-color var(--credence-motion-duration-fast) var(--credence-motion-easing-standard); + border-color var(--credence-motion-duration-fast) var(--credence-motion-easing-standard), + box-shadow var(--credence-motion-duration-fast) var(--credence-motion-easing-standard); } .trustScore__recentItemBtn:hover { - background: var(--credence-surface-hover); - border-color: var(--credence-border-hover); + background: var(--credence-surface-card); + border-color: var(--credence-color-primary); + box-shadow: 0 2px 8px rgba(7, 89, 133, 0.08); } .trustScore__recentItemBtn:focus-visible { @@ -197,39 +305,180 @@ /* ─── Responsive ────────────────────────────────────────────────────────── */ +@media (max-width: 1024px) { + .trustScore__hero { + grid-template-columns: 1fr; + gap: var(--credence-space-6); + padding: var(--credence-space-8); + } + + .trustScore__heroGauge { + width: 100%; + } + + .trustScore__grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .trustScore__headerRow { + margin-bottom: var(--credence-space-4); + } + + .trustScore__title { + font-size: var(--credence-font-size-xl); + } + + .trustScore__description { + margin-bottom: var(--credence-space-6); + font-size: var(--credence-font-size-base); + } + + .trustScore__hero { + padding: var(--credence-space-6); + gap: var(--credence-space-4); + background: linear-gradient(135deg, var(--credence-surface-card) 0%, rgba(7, 89, 133, 0.02) 100%); + } + + .trustScore__heroScore { + flex-direction: column; + gap: 0; + align-items: flex-start; + } + + .trustScore__heroScoreValue { + font-size: clamp(3rem, 10vw, 4.5rem); + } + + .trustScore__heroScoreTotal { + font-size: var(--credence-font-size-lg); + } + + .trustScore__heroMeta { + margin-top: var(--credence-space-4); + } + + .trustScore__heroFooter { + font-size: var(--credence-font-size-xs); + gap: var(--credence-space-2); + } + + .trustScore__card { + padding: var(--credence-space-6); + } + + .trustScore__cardTitle { + font-size: var(--credence-font-size-base); + font-weight: var(--credence-font-weight-semibold); + margin-bottom: var(--credence-space-4); + } + + .trustScore__grid { + gap: var(--credence-space-6); + } + + .trustScore__recentList { + gap: var(--credence-space-2); + } + + .trustScore__recentItemBtn { + padding: var(--credence-space-2) var(--credence-space-3); + font-size: var(--credence-font-size-xs); + } +} + @media (max-width: 640px) { + .trustScore__results { + margin-top: var(--credence-space-6); + margin-bottom: var(--credence-space-8); + } + .trustScore__hero { - padding: var(--credence-space-8) var(--credence-space-4) var(--credence-space-6); + padding: var(--credence-space-4); + border: 1px solid var(--credence-border-default); + } + + .trustScore__heroScore { + gap: 0; } .trustScore__heroScoreValue { - font-size: clamp(3.5rem, 12vw, 5rem); + font-size: clamp(2.5rem, 8vw, 3.5rem); } .trustScore__heroScoreTotal { - font-size: var(--credence-font-size-xl); + font-size: var(--credence-font-size-base); + } + + .trustScore__heroMeta { + margin-top: var(--credence-space-3); + gap: var(--credence-space-3); } .trustScore__heroBadge { - font-size: var(--credence-font-size-base) !important; - padding: 0.375rem 1rem !important; + font-size: var(--credence-font-size-sm) !important; + padding: 0.25rem 0.75rem !important; } - .trustScore__heroMeta { - margin-bottom: var(--credence-space-4); + .trustScore__heroNext { + font-size: var(--credence-font-size-sm); } .trustScore__heroGauge { - margin-bottom: var(--credence-space-4); + margin-top: var(--credence-space-4); } .trustScore__heroFooter { + grid-column: 1 / -1; + margin-top: var(--credence-space-4); + padding-top: var(--credence-space-4); font-size: var(--credence-font-size-xs); gap: var(--credence-space-1); } + .trustScore__card { + padding: var(--credence-space-4); + } + + .trustScore__cardTitle { + font-size: var(--credence-font-size-base); + margin-bottom: var(--credence-space-3); + } + .trustScore__grid { - grid-template-columns: 1fr; gap: var(--credence-space-4); } + + .trustScore__input { + padding: var(--credence-space-3); + margin-bottom: var(--credence-space-4); + } + + .trustScore__recentLookups { + margin-top: var(--credence-space-6); + padding-top: var(--credence-space-4); + } + + .trustScore__recentLookupsTitle { + font-size: var(--credence-font-size-xs); + } + + .trustScore__recentList { + gap: var(--credence-space-2); + } +} + +@media (max-width: 375px) { + .trustScore__heroScoreValue { + font-size: clamp(2rem, 6vw, 2.5rem); + } + + .trustScore__card { + padding: var(--credence-space-3); + } + + .trustScore__cardTitle { + font-size: var(--credence-font-size-sm); + } } From 4262eecfb4050444626b73bfd5dcd3f4ffa617cd Mon Sep 17 00:00:00 2001 From: Mistersmile4585 Date: Thu, 30 Jul 2026 04:33:52 +0100 Subject: [PATCH 08/15] feat(uiux): fix contrast issues for interactive components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of text and non-text contrast across interactive states — links, all button variants, primary-filled controls, and focus rings — in both themes. 57 state/theme combinations were measured; 8 failed, and writing the regression test surfaced a ninth. Two token-misuse patterns account for all of them. A theme-dependent fill was paired with a fixed on-colour. --credence-color-primary flips to a light tint (#7dd3fc) in dark mode while the label stayed hard-coded white, so the primary button rendered at 1.67:1 and its hover state at 1.33:1. The same pairing reached the speed-dial FAB, back-to-top button, toggle, segmented control, and active mobile-nav link. Introduce --credence-color-on-primary (white in light, slate-900 in dark) and consume it everywhere the primary fill is painted. Text tokens were used as fills. --credence-color-danger-text is tuned to be read as text on a page, so the dark theme lightens it to #fca5a5; reusing it as the danger button's active background put a white label at 1.9:1. The hover state had the inverse problem in light mode, lightening the fill to #ef4444 for 3.76:1. Split the fills out as --credence-color-danger-fill, -fill-hover and -fill-active so they can darken on interaction without dragging the penalty-amount text along with them. --credence-color-danger- action keeps its values because it is still that text colour. Because the danger fill now darkens, hover and active pin border-color to --credence-color-danger-border; a darkened red would otherwise sink the button's edge below 3:1 against the dark page. The secondary button's fill matches the card behind it, leaving its border as the only affordance that identifies it as a control — which puts the border under the 3:1 non-text floor (SC 1.4.11), where the decorative --credence-border-default managed 1.23:1. Add --credence-color-border-interactive/-hover for control boundaries and leave the decorative token alone for cards and dividers. Disabled states are deliberately untouched. SC 1.4.3 and SC 1.4.11 both exempt inactive components, and the low contrast is the affordance; raising it would make disabled buttons read as enabled. Links and focus rings already passed in both themes and are unchanged, but are now covered so they stay that way. Add src/components/interactiveContrast.test.ts, which resolves colours out of index.css and the component stylesheets rather than a copied colour table, so reverting either a token or a declaration breaks a test. Shared helpers live in src/test/contrast.ts; they strip CSS comments before parsing because prose containing a colon and a later semicolon otherwise reads as a declaration and swallows the next one. Full results in docs/interactive-contrast-audit.md. Closes #814 --- docs/interactive-contrast-audit.md | 134 ++++++++++ src/components/BackToTop.css | 2 +- src/components/Button.css | 36 ++- src/components/SpeedDial.css | 6 +- src/components/controls/controls.css | 4 +- src/components/interactiveContrast.test.ts | 287 +++++++++++++++++++++ src/components/navigation/MobileNav.css | 4 +- src/index.css | 38 +++ src/test/contrast.ts | 197 ++++++++++++++ 9 files changed, 690 insertions(+), 18 deletions(-) create mode 100644 docs/interactive-contrast-audit.md create mode 100644 src/components/interactiveContrast.test.ts create mode 100644 src/test/contrast.ts diff --git a/docs/interactive-contrast-audit.md b/docs/interactive-contrast-audit.md new file mode 100644 index 00000000..52ca9b3f --- /dev/null +++ b/docs/interactive-contrast-audit.md @@ -0,0 +1,134 @@ +# Interactive States Contrast Audit + +Scope: text and non-text contrast for **interactive states** — links, all `Button` +variants, primary-filled controls, and focus rings — in light and dark themes. + +Method: + +- Text target: WCAG 2.1 SC 1.4.3 Level AA, **4.5:1**. Every affected label is + below 18.66px bold / 24px regular, so the large-text 3:1 allowance never applies. +- Non-text target: WCAG 2.1 SC 1.4.11, **3:1** for a control's visual boundary + and for focus indicators. +- Dark-theme translucent fills were composited over `--credence-surface-page` + (`#0f172a`) before measuring. +- An automated regression check in `src/components/interactiveContrast.test.ts` + resolves colours out of `src/index.css` and the component stylesheets, so a + token or declaration reverting to a failing value breaks the build. Shared + contrast helpers live in `src/test/contrast.ts`. + +## Failures found + +The initial sweep covered 57 state/theme combinations and found 8 genuine +failures. Writing the regression test surfaced a ninth — the danger button's +`:active` fill in dark mode (row 5) — which the sweep had not enumerated. The +rows below are the full set; row 8 covers both themes. + +| # | State | Theme | Before | Cause | +| --- | -------------------------------- | ----- | ----------: | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Primary button label | Dark | **1.67** | `color: var(--credence-color-white)` hard-coded while `--credence-color-primary` flips to a light tint (`#7dd3fc`) in dark mode | +| 2 | Primary button label `:hover` | Dark | **1.33** | same, over `--credence-color-primary-strong` (`#bae6fd`) | +| 3 | Danger button label `:hover` | Light | **3.76** | hover _lightened_ the fill to `--credence-color-danger-border` (`#ef4444`) | +| 4 | Danger button label | Dark | **3.76** | dark `--credence-color-danger-action` is `#ef4444` | +| 5 | Danger button label `:active` | Dark | **1.90** | active fill reused `--credence-color-danger-text`, which the dark theme redefines to a light pink (`#fca5a5`) | +| 6 | Secondary button border | Light | **1.23** | fill matches the card, so the border is the sole affordance, but it used the decorative `--credence-border-default` (`#e2e8f0`) | +| 7 | Secondary button border | Dark | **1.37** | `--credence-color-slate-600` on a `slate-700` fill | +| 8 | Secondary button border `:hover` | Both | 2.34 / 1.59 | same class of problem in the hover state | + +Failures 1, 2 and 5 also reached the FAB, back-to-top button, toggle, segmented +control, and active mobile-nav link, all of which paint the primary fill and +hard-coded a white label. + +## Root cause + +Two token-misuse patterns, not eight unrelated bugs: + +1. **A fill token was paired with a fixed on-colour.** `--credence-color-primary` + is theme-dependent; `--credence-color-white` is not. Any pairing of the two + is a latent dark-mode failure. +2. **Text tokens were used as fills.** `--credence-color-danger-text` and + `--credence-color-danger-action` are tuned to be read _as text on a page_, so + the dark theme lightens them. Reusing them as button backgrounds inverts the + requirement. + +## Adjusted tokens + +| Token | Value | Reason | +| ------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--credence-color-on-primary` | `#ffffff` light, `--credence-color-slate-900` dark | Theme-aware label colour for anything on the primary fill. Replaces hard-coded white. | +| `--credence-color-danger-fill` | `#dc2626` | Destructive button background, split from `--credence-color-danger-action` (still used for penalty _text_ in `ConfirmDialog`, `BondDetail`, `Bond`) so the fill can darken independently. | +| `--credence-color-danger-fill-hover` | `#b91c1c` | Hover darkens instead of lightening. | +| `--credence-color-danger-fill-active` | `#991b1b` | Replaces the reuse of `--credence-color-danger-text`. | +| `--credence-color-border-interactive` | `slate-500` light, `slate-400` dark | 3:1 boundary for controls whose fill matches their surface. Distinct from the intentionally lighter decorative `--credence-border-default`. | +| `--credence-color-border-interactive-hover` | `slate-600` light, `slate-300` dark | Same, for the hover fill. | +| `--credence-color-slate-300` | `#cbd5e1` | Added; the scale was missing this step. | + +The danger button's hover and active states pin `border-color` to +`--credence-color-danger-border` (`#ef4444`). The fill darkens on interaction, +which in dark mode would otherwise sink the button's edge below 3:1 against the +page; the lighter border holds the boundary while the fill carries the state. + +## Results matrix + +All ratios are post-fix. Text rows target 4.5:1, non-text rows 3:1. + +| Element / state | Theme | Ratio | Target | Result | +| --------------------------------------------------------- | ----- | ----: | -----: | ------ | +| Body link | Light | 7.23 | 4.5 | Pass | +| Body link | Dark | 10.71 | 4.5 | Pass | +| Body link `:hover` | Light | 9.04 | 4.5 | Pass | +| Body link `:hover` | Dark | 13.45 | 4.5 | Pass | +| Footer link | Light | 4.76 | 4.5 | Pass | +| Footer link | Dark | 5.71 | 4.5 | Pass | +| Skip link | Light | 7.23 | 4.5 | Pass | +| Skip link | Dark | 10.71 | 4.5 | Pass | +| Primary button label | Light | 7.56 | 4.5 | Pass | +| Primary button label | Dark | 10.71 | 4.5 | Pass | +| Primary button label `:hover` | Light | 9.46 | 4.5 | Pass | +| Primary button label `:hover` | Dark | 13.45 | 4.5 | Pass | +| Secondary button label | Light | 17.85 | 4.5 | Pass | +| Secondary button label | Dark | 9.90 | 4.5 | Pass | +| Secondary button border | Light | 4.76 | 3.0 | Pass | +| Secondary button border | Dark | 4.04 | 3.0 | Pass | +| Secondary button border `:hover` | Light | 6.92 | 3.0 | Pass | +| Secondary button border `:hover` | Dark | 5.10 | 3.0 | Pass | +| Ghost button label | Light | 7.23 | 4.5 | Pass | +| Ghost button label | Dark | 10.71 | 4.5 | Pass | +| Ghost button label `:hover` | Light | 8.69 | 4.5 | Pass | +| Link button label | Light | 7.56 | 4.5 | Pass | +| Link button label | Dark | 8.77 | 4.5 | Pass | +| Danger button label | Both | 4.83 | 4.5 | Pass | +| Danger button label `:hover` | Both | 6.47 | 4.5 | Pass | +| Danger button label `:active` | Both | 8.31 | 4.5 | Pass | +| Danger button edge | Light | 4.62 | 3.0 | Pass | +| Danger button edge | Dark | 3.70 | 3.0 | Pass | +| Danger edge `:hover` / `:active` | Dark | 4.74 | 3.0 | Pass | +| Focus ring vs page | Light | 7.23 | 3.0 | Pass | +| Focus ring vs page | Dark | 10.71 | 3.0 | Pass | +| Focus ring vs card | Light | 7.56 | 3.0 | Pass | +| Focus ring vs card | Dark | 8.77 | 3.0 | Pass | +| Danger focus ring vs page | Light | 3.60 | 3.0 | Pass | +| Danger focus ring vs page | Dark | 4.74 | 3.0 | Pass | +| FAB / back-to-top / toggle / segmented / nav-active label | Dark | 10.71 | 4.5 | Pass | + +## Deliberately unchanged + +**Disabled controls.** SC 1.4.3 and SC 1.4.11 both exempt inactive user +interface components, and the disabled states measure 2.18–3.07:1 by design — +the low contrast _is_ the affordance. Raising it would make disabled buttons +read as enabled. Affected: `:disabled` on the primary, secondary, ghost and link +button variants, plus `.footer-link[aria-disabled='true']`. The regression test +omits them on purpose. + +Links and focus rings **already passed** in both themes before this change and +were left alone; they are covered by the regression test so they stay that way. + +`--credence-color-danger-action` keeps its current values because it is a text +colour for penalty amounts, not a fill. Its dark value (`#fca5a5`) is correct in +that role. + +## Out of scope + +Static (non-interactive) text was not audited here. While tracing +`--credence-color-danger-action` this audit noted that it renders at 3.89:1 as +text on `--credence-surface-card` in dark mode, which is below AA. That belongs +to a static-text audit, not this one, and is left for a follow-up. diff --git a/src/components/BackToTop.css b/src/components/BackToTop.css index 36285827..702bf0ae 100644 --- a/src/components/BackToTop.css +++ b/src/components/BackToTop.css @@ -7,7 +7,7 @@ gap: var(--credence-space-2); padding: var(--credence-space-3) var(--credence-space-4); background: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); border: none; border-radius: var(--credence-radius-full); font-family: var(--credence-font-family-base); diff --git a/src/components/Button.css b/src/components/Button.css index e9c3dbdf..09d7ad63 100644 --- a/src/components/Button.css +++ b/src/components/Button.css @@ -98,7 +98,9 @@ .credence-button--primary { background: var(--credence-color-primary); - color: var(--credence-color-white); + /* Flips with the theme — primary is a light tint in dark mode, where a white + * label would render at 1.67:1. */ + color: var(--credence-color-on-primary); border-color: var(--credence-color-primary); } @@ -113,29 +115,34 @@ /* ─── Secondary variant — alternative actions ────────────────────────────── */ +/* + * The secondary button's fill matches the card it sits on, so its border is the + * only thing that identifies it as a control. That makes the border subject to + * the 3:1 non-text floor (SC 1.4.11) — --credence-border-default managed 1.23:1. + */ .credence-button--secondary { background: var(--credence-surface-card); color: var(--credence-text-primary); - border-color: var(--credence-border-default); + border-color: var(--credence-color-border-interactive); } .credence-button--secondary:hover:not(:disabled) { background: var(--credence-color-slate-100); - border-color: var(--credence-color-slate-400); + border-color: var(--credence-color-border-interactive-hover); } .credence-button--secondary:active:not(:disabled) { background: var(--credence-color-slate-200); } +/* Only the fill changes in dark mode; the border comes from + * --credence-color-border-interactive, which already flips lighter. */ [data-theme='dark'] .credence-button--secondary { background: var(--credence-color-slate-700); - border-color: var(--credence-color-slate-600); } [data-theme='dark'] .credence-button--secondary:hover:not(:disabled) { background: var(--credence-color-slate-600); - border-color: var(--credence-color-slate-500); } /* ─── Ghost variant — subtle actions ─────────────────────────────────────── */ @@ -165,20 +172,29 @@ /* ─── Danger variant — destructive confirmations ─────────────────────────── */ +/* + * Hover and active darken the fill rather than lightening it. The previous + * hover moved to --credence-color-danger-border (#ef4444), which dropped the + * white label to 3.76:1; in dark mode the base fill did the same. + * + * Because the fill darkens, the border is pinned to --credence-color-danger- + * border for hover/active so the button's outer edge keeps 3:1 against the page + * in dark mode, where a darkened red would otherwise sink into the background. + */ .credence-button--danger { - background: var(--credence-color-danger-action); + background: var(--credence-color-danger-fill); color: var(--credence-color-white); - border-color: var(--credence-color-danger-action); + border-color: var(--credence-color-danger-fill); } .credence-button--danger:hover:not(:disabled) { - background: var(--credence-color-danger-border); + background: var(--credence-color-danger-fill-hover); border-color: var(--credence-color-danger-border); } .credence-button--danger:active:not(:disabled) { - background: var(--credence-color-danger-text); - border-color: var(--credence-color-danger-text); + background: var(--credence-color-danger-fill-active); + border-color: var(--credence-color-danger-border); } .credence-button--danger:disabled { diff --git a/src/components/SpeedDial.css b/src/components/SpeedDial.css index ce2beabb..f250f2d0 100644 --- a/src/components/SpeedDial.css +++ b/src/components/SpeedDial.css @@ -28,7 +28,7 @@ border-radius: var(--credence-radius-full); border: none; background: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); cursor: pointer; display: flex; align-items: center; @@ -142,7 +142,7 @@ .speedDial__action:hover { background: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); border-color: var(--credence-color-primary); transform: scale(1.08); } @@ -174,7 +174,7 @@ [data-theme='dark'] .speedDial__action:hover { background: var(--credence-color-primary); border-color: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); } [data-theme='dark'] .speedDial__label { diff --git a/src/components/controls/controls.css b/src/components/controls/controls.css index 9d81fea9..abfa5f2a 100644 --- a/src/components/controls/controls.css +++ b/src/components/controls/controls.css @@ -102,7 +102,7 @@ .control-toggle[aria-checked='true'] { background: var(--credence-color-primary); border-color: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); } .control-toggle:focus-visible { @@ -176,7 +176,7 @@ .control-segmented__option--selected { background: var(--credence-color-primary); border-color: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); } .control-segmented__option:hover:not(:disabled):not(.control-segmented__option--selected) { diff --git a/src/components/interactiveContrast.test.ts b/src/components/interactiveContrast.test.ts new file mode 100644 index 00000000..05474ff1 --- /dev/null +++ b/src/components/interactiveContrast.test.ts @@ -0,0 +1,287 @@ +/** + * Contrast regression guard for interactive states — links, buttons, and focus + * rings — in both themes. + * + * Every case resolves colours out of the real stylesheets (`index.css` plus the + * relevant component CSS) rather than a copied colour table, so swapping a + * token or a `color:` declaration back to a failing value breaks a test here. + * + * Targets: 4.5:1 for text (SC 1.4.3), 3:1 for control boundaries and focus + * indicators (SC 1.4.11). + * + * Disabled controls are deliberately absent: SC 1.4.3 exempts "inactive user + * interface components", and raising their contrast would make them read as + * enabled. See docs/interactive-contrast-audit.md. + */ +import { describe, it, expect } from 'vitest' +import { + NON_TEXT, + TEXT_AA, + getRuleDeclarations, + getThemeTokens, + ratio, + readCss, + resolveCssValue, +} from '../test/contrast' + +const indexCss = readCss('src/index.css') +const buttonCss = readCss('src/components/Button.css') + +const THEMES = [ + { name: 'light', selector: undefined }, + { name: 'dark', selector: "[data-theme='dark']" }, +] as const + +/** Token values as the browser would resolve them for a given theme. */ +function tokensFor(themeSelector?: string) { + const tokens = getThemeTokens(indexCss, themeSelector) + const token = (name: string) => resolveCssValue(`var(${name})`, tokens) + + return { + token, + page: token('--credence-surface-page'), + card: token('--credence-surface-card'), + primary: token('--credence-color-primary'), + primaryStrong: token('--credence-color-primary-strong'), + onPrimary: token('--credence-color-on-primary'), + focusRing: token('--credence-color-focus-ring'), + textPrimary: token('--credence-text-primary'), + textSecondary: token('--credence-text-secondary'), + /** Resolve a declaration taken from a component rule. */ + resolve: (value: string) => resolveCssValue(value, tokens), + } +} + +/** Read one declaration out of a component rule and resolve it. */ +function declaration(css: string, selector: string, property: string, themeSelector?: string) { + const value = getRuleDeclarations(css, selector).get(property) + + if (!value) { + throw new Error(`Expected ${selector} to declare ${property}`) + } + + return tokensFor(themeSelector).resolve(value) +} + +describe.each(THEMES)('interactive contrast — $name theme', ({ selector }) => { + const t = tokensFor(selector) + + describe('links', () => { + it('body link meets AA on the page and on cards', () => { + const linkColor = declaration(indexCss, 'a', 'color', selector) + + expect(ratio(linkColor, t.page)).toBeGreaterThanOrEqual(TEXT_AA) + expect(ratio(linkColor, t.card)).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('link hover meets AA', () => { + const hoverColor = declaration(indexCss, 'a:hover', 'color', selector) + + expect(ratio(hoverColor, t.page)).toBeGreaterThanOrEqual(TEXT_AA) + expect(ratio(hoverColor, t.card)).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('footer link meets AA on the card surface it sits on', () => { + const footerColor = declaration(indexCss, '.footer-link', 'color', selector) + + expect(ratio(footerColor, t.card)).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('skip link meets AA against its primary fill', () => { + const rule = getRuleDeclarations(indexCss, '.skip-link') + + expect( + ratio(t.resolve(rule.get('color')!), t.resolve(rule.get('background')!)) + ).toBeGreaterThanOrEqual(TEXT_AA) + }) + }) + + describe('primary button', () => { + // Regression: --credence-color-primary flips to a light tint in dark mode. + // A hard-coded white label rendered at 1.67:1 there. + it('label meets AA on the base fill', () => { + const rule = getRuleDeclarations(buttonCss, '.credence-button--primary') + + expect( + ratio(t.resolve(rule.get('color')!), t.resolve(rule.get('background')!)) + ).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('label meets AA on the hover fill', () => { + const label = declaration(buttonCss, '.credence-button--primary', 'color', selector) + + expect(ratio(label, t.primaryStrong)).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('fill is distinguishable from the page', () => { + expect(ratio(t.primary, t.page)).toBeGreaterThanOrEqual(NON_TEXT) + }) + }) + + describe('secondary button', () => { + // The fill matches the surface behind it, so the border is the only thing + // identifying the control and must clear the non-text floor. + it('border is distinguishable from the surface', () => { + // Read the declaration, not the token, so pointing the button back at a + // decorative border colour fails here. + const border = declaration(buttonCss, '.credence-button--secondary', 'border-color', selector) + const fill = selector + ? declaration( + buttonCss, + "[data-theme='dark'] .credence-button--secondary", + 'background', + selector + ) + : declaration(buttonCss, '.credence-button--secondary', 'background', selector) + + expect(ratio(border, fill)).toBeGreaterThanOrEqual(NON_TEXT) + expect(ratio(border, t.card)).toBeGreaterThanOrEqual(NON_TEXT) + }) + + it('hover border is distinguishable from the hover fill', () => { + const border = declaration( + buttonCss, + '.credence-button--secondary:hover:not(:disabled)', + 'border-color', + selector + ) + const hoverFill = selector + ? declaration( + buttonCss, + "[data-theme='dark'] .credence-button--secondary:hover:not(:disabled)", + 'background', + selector + ) + : declaration( + buttonCss, + '.credence-button--secondary:hover:not(:disabled)', + 'background', + selector + ) + + expect(ratio(border, hoverFill)).toBeGreaterThanOrEqual(NON_TEXT) + }) + + it('label meets AA on the base fill', () => { + const fill = selector + ? declaration( + buttonCss, + "[data-theme='dark'] .credence-button--secondary", + 'background', + selector + ) + : declaration(buttonCss, '.credence-button--secondary', 'background', selector) + + expect(ratio(t.textPrimary, fill)).toBeGreaterThanOrEqual(TEXT_AA) + }) + }) + + describe('ghost and link buttons', () => { + it('ghost label meets AA over the page', () => { + const label = declaration(buttonCss, '.credence-button--ghost', 'color', selector) + + expect(ratio(label, t.page)).toBeGreaterThanOrEqual(TEXT_AA) + }) + + it('link-variant label meets AA over cards', () => { + const label = declaration(buttonCss, '.credence-button--link', 'color', selector) + + expect(ratio(label, t.card)).toBeGreaterThanOrEqual(TEXT_AA) + }) + }) + + describe('danger button', () => { + // Regression: hover previously lightened the fill to #ef4444, dropping the + // white label to 3.76:1. + it('label meets AA on base, hover and active fills', () => { + const label = declaration(buttonCss, '.credence-button--danger', 'color', selector) + + const fills = [ + declaration(buttonCss, '.credence-button--danger', 'background', selector), + declaration( + buttonCss, + '.credence-button--danger:hover:not(:disabled)', + 'background', + selector + ), + declaration( + buttonCss, + '.credence-button--danger:active:not(:disabled)', + 'background', + selector + ), + ] + + for (const fill of fills) { + expect(ratio(label, fill)).toBeGreaterThanOrEqual(TEXT_AA) + } + }) + + it('keeps a distinguishable edge against the page in every state', () => { + const borders = [ + declaration(buttonCss, '.credence-button--danger', 'border-color', selector), + declaration( + buttonCss, + '.credence-button--danger:hover:not(:disabled)', + 'border-color', + selector + ), + declaration( + buttonCss, + '.credence-button--danger:active:not(:disabled)', + 'border-color', + selector + ), + ] + + for (const border of borders) { + expect(ratio(border, t.page)).toBeGreaterThanOrEqual(NON_TEXT) + } + }) + }) + + describe('focus indicators', () => { + it('focus ring is visible against the page and cards', () => { + expect(ratio(t.focusRing, t.page)).toBeGreaterThanOrEqual(NON_TEXT) + expect(ratio(t.focusRing, t.card)).toBeGreaterThanOrEqual(NON_TEXT) + }) + + it('danger focus ring is visible against the page', () => { + const ringColor = declaration( + buttonCss, + '.credence-button--danger:focus-visible', + 'outline-color', + selector + ) + + expect(ratio(ringColor, t.page)).toBeGreaterThanOrEqual(NON_TEXT) + }) + }) +}) + +describe('primary-filled controls outside Button.css', () => { + // Every control that paints --credence-color-primary as its background must + // take its label from --credence-color-on-primary, otherwise it regresses to + // white-on-light-blue in dark mode. + const cases = [ + ['src/components/SpeedDial.css', '.speedDial__fab'], + ['src/components/BackToTop.css', '.back-to-top'], + ['src/components/controls/controls.css', ".control-toggle[aria-checked='true']"], + ['src/components/controls/controls.css', '.control-segmented__option--selected'], + ['src/components/navigation/MobileNav.css', '.mobileNav-link--active'], + ] as const + + it.each(cases)('%s %s pairs its primary fill with on-primary', (file, selector) => { + const rule = getRuleDeclarations(readCss(file), selector) + + expect(rule.get('background')).toContain('--credence-color-primary') + expect(rule.get('color')).toContain('--credence-color-on-primary') + }) + + it.each(THEMES)('on-primary label meets AA in the $name theme', ({ selector }) => { + const t = tokensFor(selector) + + expect(ratio(t.onPrimary, t.primary)).toBeGreaterThanOrEqual(TEXT_AA) + expect(ratio(t.onPrimary, t.primaryStrong)).toBeGreaterThanOrEqual(TEXT_AA) + }) +}) diff --git a/src/components/navigation/MobileNav.css b/src/components/navigation/MobileNav.css index ed9ac741..17816ac5 100644 --- a/src/components/navigation/MobileNav.css +++ b/src/components/navigation/MobileNav.css @@ -127,14 +127,14 @@ .mobileNav-link--active { background: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); } /* Keep the active indication visible when the active link is also hovered or focused — .mobileNav-link:hover otherwise wins on specificity and hides it. */ .mobileNav-link--active:hover { background: var(--credence-color-primary); - color: var(--credence-color-white); + color: var(--credence-color-on-primary); } .mobileNav-link:focus-visible { diff --git a/src/index.css b/src/index.css index 0d08e054..ccafd4ae 100644 --- a/src/index.css +++ b/src/index.css @@ -95,6 +95,7 @@ --credence-color-slate-50: #f8fafc; --credence-color-slate-100: #f1f5f9; --credence-color-slate-200: #e2e8f0; + --credence-color-slate-300: #cbd5e1; --credence-color-slate-400: #94a3b8; --credence-color-slate-500: #64748b; --credence-color-slate-600: #475569; @@ -105,6 +106,36 @@ --credence-color-primary: #075985; --credence-color-primary-strong: #0c4a6e; --credence-color-primary-soft: #0284c7; + + /* Text/icon colour that sits on top of a primary fill. + * + * --credence-color-primary flips to a light tint in dark mode, so anything + * layered on it must flip too. Hard-coding white here is what put a white + * label on a light-blue button at 1.67:1. Consume this token instead of + * --credence-color-white whenever the background is the primary fill. */ + --credence-color-on-primary: var(--credence-color-white); + + /* Destructive button fill. + * + * Deliberately separate from --credence-color-danger-action, which is used as + * *text* for penalty amounts (ConfirmDialog, BondDetail, Bond). Splitting the + * two lets the fill darken far enough for a white label to clear 4.5:1 + * without dragging that text darker at the same time. */ + --credence-color-danger-fill: #dc2626; + --credence-color-danger-fill-hover: #b91c1c; + /* The active fill previously reused --credence-color-danger-text, which the + * dark theme redefines to a light pink (#fca5a5) — a white label on it landed + * at 1.9:1. Fills and text colours are not interchangeable. */ + --credence-color-danger-fill-active: #991b1b; + + /* Boundary colour for interactive controls. + * + * A control whose only visual boundary is its border must clear 3:1 against + * the surface behind it (WCAG 2.1 SC 1.4.11). --credence-border-default is + * decorative — it separates cards and rows, where no such floor applies — + * so interactive borders get their own, darker token. */ + --credence-color-border-interactive: var(--credence-color-slate-500); + --credence-color-border-interactive-hover: var(--credence-color-slate-600); --credence-color-info-surface: #eff6ff; --credence-color-info-border: #3b82f6; --credence-color-info-text: #1e40af; @@ -209,6 +240,13 @@ [data-theme='dark'] { --credence-color-primary: #7dd3fc; --credence-color-primary-strong: #bae6fd; + /* Primary is a light tint here, so labels on it must be dark: 10.71:1 on the + * base fill and 13.45:1 on the hover fill. */ + --credence-color-on-primary: var(--credence-color-slate-900); + /* Interactive borders sit on slate-700/600 button fills in dark mode, so they + * need to be lighter than the surface rather than darker. */ + --credence-color-border-interactive: var(--credence-color-slate-400); + --credence-color-border-interactive-hover: var(--credence-color-slate-300); --credence-color-info-surface: rgba(59, 130, 246, 0.15); --credence-color-info-border: #3b82f6; --credence-color-info-text: #93c5fd; diff --git a/src/test/contrast.ts b/src/test/contrast.ts new file mode 100644 index 00000000..d47b4a48 --- /dev/null +++ b/src/test/contrast.ts @@ -0,0 +1,197 @@ +/** + * Shared WCAG contrast helpers for design-system regression tests. + * + * These read the real stylesheets and resolve `var()` chains against the theme + * token blocks, so a test asserts what the app actually renders rather than a + * hand-copied colour table that can silently drift from the CSS. + * + * Thresholds: WCAG 2.1 SC 1.4.3 requires 4.5:1 for normal-size text; SC 1.4.11 + * requires 3:1 for the visual boundary of an interactive control and for focus + * indicators. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +export const TEXT_AA = 4.5 +export const NON_TEXT = 3 + +export type Rgb = [number, number, number] +export type Rgba = [number, number, number, number] +export type ColorValue = string | Rgb | Rgba + +/** + * Read a stylesheet from the project root, with comments stripped. + * + * Comments must go before anything parses declarations: prose containing a colon + * and a later semicolon (e.g. "…at 1.67:1. Consume this token instead…") reads + * as a declaration and swallows the real one that follows it. + */ +export function readCss(relativePath: string): string { + return stripComments(readFileSync(resolve(process.cwd(), relativePath), 'utf8')) +} + +export function stripComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, '') +} + +function escapeSelector(selector: string): string { + return selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Extract a rule's declarations, keyed by property name. + * + * The selector must match at the start of a line so that + * `.credence-button--primary` does not accidentally return the body of + * `[data-theme='dark'] .credence-button--primary`. + */ +export function getRuleDeclarations(css: string, selector: string): Map { + const pattern = new RegExp(`(?:^|\\n)\\s*${escapeSelector(selector)}\\s*\\{([^}]*)\\}`) + const match = stripComments(css).match(pattern) + + if (!match?.[1]) { + throw new Error(`Unable to locate CSS rule for selector: ${selector}`) + } + + const declarations = new Map() + + for (const declaration of match[1].matchAll(/([\w-]+)\s*:\s*([^;]+);/g)) { + declarations.set(declaration[1].trim(), declaration[2].trim()) + } + + return declarations +} + +/** Resolve a `var(--token, fallback)` chain to a literal colour value. */ +export function resolveCssValue(value: string, tokens: Map): string { + const match = value.match(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/) + + if (!match) { + return value.trim() + } + + const resolved = tokens.get(match[1]) ?? match[2] + + if (resolved === undefined) { + throw new Error(`Unresolved CSS custom property: ${match[1]}`) + } + + return resolveCssValue(resolved.trim(), tokens) +} + +/** + * Build the token map for a theme: `:root` declarations with the theme block's + * declarations layered on top, matching the cascade. + */ +export function getThemeTokens(css: string, themeSelector?: string): Map { + const tokens = new Map() + + const collect = (selector: string) => { + for (const [property, value] of getRuleDeclarations(css, selector)) { + if (property.startsWith('--')) { + tokens.set(property, value) + } + } + } + + collect(':root') + + if (themeSelector) { + collect(themeSelector) + } + + return tokens +} + +export function parseColor(value: ColorValue): Rgb | Rgba { + if (Array.isArray(value)) { + return value + } + + const trimmed = value.trim() + + if (trimmed.startsWith('#')) { + const hex = trimmed.slice(1) + const normalized = + hex.length === 3 + ? hex + .split('') + .map((char) => char + char) + .join('') + : hex + + return [ + Number.parseInt(normalized.slice(0, 2), 16) / 255, + Number.parseInt(normalized.slice(2, 4), 16) / 255, + Number.parseInt(normalized.slice(4, 6), 16) / 255, + ] + } + + if (trimmed.startsWith('rgb(') || trimmed.startsWith('rgba(')) { + const parts = trimmed + .slice(trimmed.indexOf('(') + 1, trimmed.lastIndexOf(')')) + .split(/[,/]/) + .map((part) => Number.parseFloat(part.trim())) + + const [r, g, b, alpha] = parts + + if (r === undefined || g === undefined || b === undefined) { + throw new Error(`Malformed rgb()/rgba() color value: ${trimmed}`) + } + + return alpha === undefined ? [r / 255, g / 255, b / 255] : [r / 255, g / 255, b / 255, alpha] + } + + throw new Error(`Unsupported CSS color value: ${trimmed}`) +} + +/** Flatten a translucent colour onto an opaque backdrop. */ +export function compositeColor(foreground: ColorValue, backdrop: ColorValue): Rgb { + const fg = parseColor(foreground) + const bg = parseColor(backdrop) + const tint = fg.slice(0, 3) as Rgb + const alpha = fg.length < 4 ? 1 : (fg[3] as number) + + if (alpha >= 1) { + return tint + } + + const base = bg.slice(0, 3) as Rgb + + return [ + tint[0] * alpha + base[0] * (1 - alpha), + tint[1] * alpha + base[1] * (1 - alpha), + tint[2] * alpha + base[2] * (1 - alpha), + ] +} + +/** Apply a CSS `opacity` value to a colour over a known backdrop. */ +export function applyOpacity(color: ColorValue, backdrop: ColorValue, opacity: number): Rgb { + const rgb = parseColor(color).slice(0, 3) as Rgb + return compositeColor([...rgb, opacity] as Rgba, backdrop) +} + +export function getRelativeLuminance(color: Rgb): number { + const normalize = (channel: number) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + + return 0.2126 * normalize(color[0]) + 0.7152 * normalize(color[1]) + 0.0722 * normalize(color[2]) +} + +export function getContrastRatio(foreground: ColorValue, background: ColorValue): number { + const backgroundRgb = parseColor(background).slice(0, 3) as Rgb + // A translucent foreground is only meaningful once flattened onto its backdrop. + const foregroundRgb = compositeColor(foreground, backgroundRgb) + + const a = getRelativeLuminance(foregroundRgb) + const b = getRelativeLuminance(backgroundRgb) + const lighter = Math.max(a, b) + const darker = Math.min(a, b) + + return (lighter + 0.05) / (darker + 0.05) +} + +/** Round to 2dp for readable assertion messages. */ +export function ratio(foreground: ColorValue, background: ColorValue): number { + return Math.round(getContrastRatio(foreground, background) * 100) / 100 +} From 0c05948214fde1c849fb7d5c9421247fce8ca6f2 Mon Sep 17 00:00:00 2001 From: Derekwalter999 Date: Thu, 30 Jul 2026 19:35:11 +0100 Subject: [PATCH 09/15] docs(uiux): add release QA checklist for responsive and accessibility (#1030) Co-authored-by: derekwalter999 --- docs/uiux/release-qa-checklist.md | 61 +++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/uiux/release-qa-checklist.md diff --git a/docs/uiux/release-qa-checklist.md b/docs/uiux/release-qa-checklist.md new file mode 100644 index 00000000..57426fd6 --- /dev/null +++ b/docs/uiux/release-qa-checklist.md @@ -0,0 +1,61 @@ +# UI/UX Release QA Checklist + +This checklist is meant to be run through before any major release to ensure that UI/UX standards, responsive breakpoints, accessibility basics, and critical user flows are maintained and regression-free. + +## 1. Responsive Breakpoints +Ensure the application renders correctly across all supported screen sizes. + +- [ ] **Mobile (320px - 480px)**: + - Navigation collapses to a hamburger menu. + - Touch targets are at least 44x44px. + - Content fits within the viewport without horizontal scrolling. + - Modals and dialogs are full-screen or properly padded. +- [ ] **Tablet (481px - 768px)**: + - Layout adapts smoothly (e.g., stacked columns become side-by-side). + - Tap targets remain adequately sized. +- [ ] **Desktop (769px - 1024px)**: + - Multi-column layouts are correct. + - Hover states are visible and functional. +- [ ] **Large Screens (1025px+)**: + - Content doesn't stretch awkwardly. + - Max-widths are applied to containers to maintain readability. + +## 2. Accessibility Basics (a11y) +Verify that the application is accessible to all users. + +- [ ] **Keyboard Navigation**: + - All interactive elements can be reached via `Tab`. + - Focus order is logical and predictable. + - Visible focus indicators (`:focus-visible`) exist on all active elements. +- [ ] **Screen Readers**: + - `aria-labels` and `aria-describedby` are present where visual text is missing. + - Dialogs and modals manage focus correctly (focus trap, return focus on close). + - Loading states use `aria-busy` or appropriate live regions. +- [ ] **Color & Contrast**: + - Text maintains a minimum contrast ratio of 4.5:1 against its background. + - Interactive elements have distinct states (hover, active, focus, disabled). + - Information is not conveyed by color alone. +- [ ] **Form Inputs**: + - All form fields have associated `