From f03ecb3294789f245728055b5c0af9d83c9316c5 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 18 Nov 2025 18:21:12 +0800 Subject: [PATCH] feat: implement search history dropdown with liquid glass effect; add hooks for managing search history --- app/globals.css | 214 ++++++++++++++++++ components/search/SearchForm.tsx | 76 +++++++ components/search/SearchHistoryDropdown.tsx | 166 ++++++++++++++ contrast-test-results.json | 75 +++++++ lib/hooks/useSearchHistory.ts | 131 +++++++++++ lib/store/search-history-store.ts | 113 ++++++++++ scripts/test-contrast.ts | 232 ++++++++++++++++++++ 7 files changed, 1007 insertions(+) create mode 100644 components/search/SearchHistoryDropdown.tsx create mode 100644 contrast-test-results.json create mode 100644 lib/hooks/useSearchHistory.ts create mode 100644 lib/store/search-history-store.ts create mode 100644 scripts/test-contrast.ts diff --git a/app/globals.css b/app/globals.css index 6b14b26..810c4fd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -574,5 +574,219 @@ nav { contain: layout style; } +/* =========================================== + SEARCH HISTORY DROPDOWN - LIQUID GLASS + =========================================== */ + +/* Search History Dropdown - Fixed positioning, Liquid Glass effect */ +.search-history-dropdown { + position: fixed; + z-index: 9999; + overflow-y: auto; + + /* Liquid Glass effect - Core aesthetic */ + background: var(--glass-bg); + backdrop-filter: blur(25px) saturate(180%); + -webkit-backdrop-filter: blur(25px) saturate(180%); + + /* Rounded-2xl corners - Universal softness */ + border-radius: var(--radius-2xl); + + /* Depth and definition */ + box-shadow: var(--shadow-md); + border: 1px solid var(--glass-border); + + /* Content padding */ + padding: 0.75rem; + + /* Smooth appearance animation */ + opacity: 0; + transform: translateY(-10px) scale(0.95); + animation: search-dropdown-appear 0.3s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; + + /* Transform origin for natural scaling */ + transform-origin: top center; + + /* Prevent text selection during interaction */ + user-select: none; + -webkit-user-select: none; +} + +@keyframes search-dropdown-appear { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Header section */ +.search-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; +} + +/* Divider with glass effect subtlety */ +.search-history-divider { + height: 1px; + width: 100%; + background: var(--glass-border); + margin: 0.5rem 0; +} + +/* List container */ +.search-history-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* Individual history item */ +.search-history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + /* Universal rounded-2xl for container elements */ + border-radius: var(--radius-2xl); + + padding: 0.75rem 1rem; + cursor: pointer; + + /* Fluid animation - Physics-based transitions */ + transition: all var(--transition-fluid); + + /* Subtle background for hover/highlight */ + background: transparent; +} + +/* Hover state - Light interaction */ +.search-history-item:hover { + background: color-mix(in srgb, var(--accent-color) 10%, transparent); + transform: translateX(2px); +} + +/* Highlighted state - Keyboard navigation */ +.search-history-item.highlighted { + background: color-mix(in srgb, var(--accent-color) 15%, transparent); + transform: translateX(4px); + + /* Subtle inner-glow effect - Lensing principle */ + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent); +} + +/* Remove button */ +.search-history-remove { + display: flex; + align-items: center; + justify-content: center; + + /* Rounded-full for small circular elements */ + width: 24px; + height: 24px; + border-radius: var(--radius-full); + + background: transparent; + border: none; + color: var(--text-color-secondary); + + cursor: pointer; + flex-shrink: 0; + + /* Fluid animation */ + transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + + /* Prevent triggering parent hover */ + z-index: 1; +} + +.search-history-remove:hover { + background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent); + color: var(--text-color); + transform: scale(1.1); +} + +.search-history-remove:active { + transform: scale(0.95); +} + +/* Optimize scrolling performance */ +.search-history-dropdown { + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +/* Custom scrollbar for dropdown */ +.search-history-dropdown::-webkit-scrollbar { + width: 6px; +} + +.search-history-dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.search-history-dropdown::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--glass-bg) 80%, transparent); + border-radius: var(--radius-full); +} + +.search-history-dropdown::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--accent-color) 50%, transparent); +} + +/* Accessibility - Focus visible states */ +.search-history-item:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.search-history-remove:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 1px; + border-radius: var(--radius-full); +} + +/* Mobile optimizations */ +@media (max-width: 640px) { + .search-history-dropdown { + /* Increase touch target sizes */ + padding: 0.5rem; + } + + .search-history-item { + padding: 1rem 0.75rem; + /* Larger minimum touch target */ + min-height: 48px; + } + + .search-history-remove { + width: 32px; + height: 32px; + } +} + +/* Dark mode enhancements */ +body.dark .search-history-dropdown, +.dark .search-history-dropdown { + /* Enhanced glass effect in dark mode */ + backdrop-filter: blur(30px) saturate(200%); + -webkit-backdrop-filter: blur(30px) saturate(200%); + + /* Stronger shadow for depth */ + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +/* =========================================== + END SEARCH HISTORY DROPDOWN + =========================================== */ + diff --git a/components/search/SearchForm.tsx b/components/search/SearchForm.tsx index bae4948..39c5927 100644 --- a/components/search/SearchForm.tsx +++ b/components/search/SearchForm.tsx @@ -5,6 +5,8 @@ import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Icons } from '@/components/ui/Icon'; import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation'; +import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown'; +import { useSearchHistory } from '@/lib/hooks/useSearchHistory'; interface SearchFormProps { onSearch: (query: string) => void; @@ -28,6 +30,24 @@ export function SearchForm({ const [query, setQuery] = useState(initialQuery); const inputRef = useRef(null); + // Search history hook + const { + searchHistory, + isDropdownOpen, + highlightedIndex, + showDropdown, + hideDropdown, + addSearch, + removeSearch, + clearAll, + selectHistoryItem, + navigateDropdown, + resetHighlight, + } = useSearchHistory((selectedQuery) => { + setQuery(selectedQuery); + onSearch(selectedQuery); + }); + // Update query when initialQuery changes useEffect(() => { setQuery(initialQuery); @@ -36,7 +56,10 @@ export function SearchForm({ const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (query.trim() && !isLoading) { + // Add to search history before searching + addSearch(query.trim()); onSearch(query); + hideDropdown(); } }; @@ -45,6 +68,42 @@ export function SearchForm({ if (onClear) { onClear(); } + resetHighlight(); + }; + + const handleInputFocus = () => { + if (query.trim() === '') { + showDropdown(); + } + }; + + const handleInputBlur = () => { + hideDropdown(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isDropdownOpen) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + navigateDropdown('down'); + break; + case 'ArrowUp': + e.preventDefault(); + navigateDropdown('up'); + break; + case 'Enter': + if (highlightedIndex >= 0 && searchHistory[highlightedIndex]) { + e.preventDefault(); + selectHistoryItem(searchHistory[highlightedIndex].query); + } + break; + case 'Escape': + hideDropdown(); + inputRef.current?.blur(); + break; + } }; return ( @@ -55,9 +114,15 @@ export function SearchForm({ type="text" value={query} onChange={(e) => setQuery(e.target.value)} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + onKeyDown={handleKeyDown} placeholder="搜索电影、电视剧、综艺..." className="text-base sm:text-lg pr-24 md:pr-32 truncate" aria-label="搜索视频内容" + aria-expanded={isDropdownOpen} + aria-controls="search-history-dropdown" + aria-autocomplete="list" /> {query && ( + + {/* Search History Dropdown */} + {/* Loading Animation */} diff --git a/components/search/SearchHistoryDropdown.tsx b/components/search/SearchHistoryDropdown.tsx new file mode 100644 index 0000000..12564a4 --- /dev/null +++ b/components/search/SearchHistoryDropdown.tsx @@ -0,0 +1,166 @@ +/** + * SearchHistoryDropdown Component + * Liquid Glass design system compliant dropdown for search history + * Features: frosted glass effect, rounded-2xl corners, smooth animations + */ + +'use client'; + +import { useEffect, useRef } from 'react'; +import { Icons } from '@/components/ui/Icon'; +import type { SearchHistoryItem } from '@/lib/store/search-history-store'; + +interface SearchHistoryDropdownProps { + isOpen: boolean; + searchHistory: SearchHistoryItem[]; + highlightedIndex: number; + triggerRef: React.RefObject; + onSelectItem: (query: string) => void; + onRemoveItem: (query: string) => void; + onClearAll: () => void; +} + +export function SearchHistoryDropdown({ + isOpen, + searchHistory, + highlightedIndex, + triggerRef, + onSelectItem, + onRemoveItem, + onClearAll, +}: SearchHistoryDropdownProps) { + const dropdownRef = useRef(null); + + // Position dropdown below input field + useEffect(() => { + if (!isOpen || !triggerRef.current || !dropdownRef.current) return; + + const updatePosition = () => { + if (!triggerRef.current || !dropdownRef.current) return; + + const inputRect = triggerRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const dropdownMaxHeight = 400; + const spaceBelow = viewportHeight - inputRect.bottom; + + // Position below input + dropdownRef.current.style.top = `${inputRect.bottom + 8}px`; + dropdownRef.current.style.left = `${inputRect.left}px`; + dropdownRef.current.style.width = `${inputRect.width}px`; + + // Adjust max height if not enough space + if (spaceBelow < dropdownMaxHeight) { + dropdownRef.current.style.maxHeight = `${spaceBelow - 20}px`; + } else { + dropdownRef.current.style.maxHeight = `${dropdownMaxHeight}px`; + } + }; + + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, { passive: true }); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [isOpen, triggerRef]); + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex === -1 || !dropdownRef.current) return; + + const highlightedElement = dropdownRef.current.querySelector( + `[data-index="${highlightedIndex}"]` + ); + + if (highlightedElement) { + highlightedElement.scrollIntoView({ + block: 'nearest', + behavior: 'smooth', + }); + } + }, [highlightedIndex]); + + if (!isOpen || searchHistory.length === 0) { + return null; + } + + return ( +
+ {/* Header with clear all button */} +
+
+ + + 搜索历史 + +
+ +
+ + {/* Divider */} +
+ + {/* History items */} +
+ {searchHistory.map((item, index) => ( +
onSelectItem(item.query)} + onMouseEnter={() => { + // Visual feedback on hover + }} + > +
+ + + {item.query} + + {item.resultCount !== undefined && ( + + {item.resultCount} 个结果 + + )} +
+ +
+ ))} +
+
+ ); +} diff --git a/contrast-test-results.json b/contrast-test-results.json new file mode 100644 index 0000000..043f10b --- /dev/null +++ b/contrast-test-results.json @@ -0,0 +1,75 @@ +{ + "timestamp": "2025-11-18T06:40:11.559Z", + "tests": [ + { + "component": "Badge", + "variant": "Primary (Light)", + "foreground": "white", + "background": "#0056b3", + "ratio": 7.042135266678601, + "passAA": true, + "passAAA": true + }, + { + "component": "Badge", + "variant": "Primary (Dark)", + "foreground": "white", + "background": "#1A6DBF", + "ratio": 5.271486985034207, + "passAA": true, + "passAAA": false + }, + { + "component": "Badge", + "variant": "Secondary (Light)", + "foreground": "#1d1d1f", + "background": "#f2f2f7", + "ratio": 15.082365216973475, + "passAA": true, + "passAAA": true, + "notes": "Glass background approximated as solid color" + }, + { + "component": "Button", + "variant": "Primary (Light)", + "foreground": "white", + "background": "#0056b3", + "ratio": 7.042135266678601, + "passAA": true, + "passAAA": true + }, + { + "component": "Button", + "variant": "Secondary (Light)", + "foreground": "#1d1d1f", + "background": "#f2f2f7", + "ratio": 15.082365216973475, + "passAA": true, + "passAAA": true + }, + { + "component": "TypeBadges", + "variant": "Selected (Light)", + "foreground": "white", + "background": "#0056b3", + "ratio": 7.042135266678601, + "passAA": true, + "passAAA": true + }, + { + "component": "TypeBadges", + "variant": "Unselected (Light)", + "foreground": "#1d1d1f", + "background": "#f2f2f7", + "ratio": 15.082365216973475, + "passAA": true, + "passAAA": true + } + ], + "summary": { + "total": 7, + "passedAA": 7, + "passedAAA": 6, + "failedAA": 0 + } +} \ No newline at end of file diff --git a/lib/hooks/useSearchHistory.ts b/lib/hooks/useSearchHistory.ts new file mode 100644 index 0000000..9cb7b39 --- /dev/null +++ b/lib/hooks/useSearchHistory.ts @@ -0,0 +1,131 @@ +/** + * useSearchHistory Hook + * Manages search history dropdown state and interactions + * Liquid Glass design system compliant + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSearchHistoryStore } from '@/lib/store/search-history-store'; +import type { SearchHistoryItem } from '@/lib/store/search-history-store'; + +interface UseSearchHistoryReturn { + searchHistory: SearchHistoryItem[]; + isDropdownOpen: boolean; + highlightedIndex: number; + showDropdown: () => void; + hideDropdown: () => void; + addSearch: (query: string, resultCount?: number) => void; + removeSearch: (query: string) => void; + clearAll: () => void; + selectHistoryItem: (query: string) => void; + navigateDropdown: (direction: 'up' | 'down') => void; + resetHighlight: () => void; +} + +export function useSearchHistory( + onSelectHistory?: (query: string) => void +): UseSearchHistoryReturn { + const { + searchHistory, + addToSearchHistory, + removeFromSearchHistory, + clearSearchHistory, + getRecentSearches, + } = useSearchHistoryStore(); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const dropdownTimeoutRef = useRef(null); + + // Get recent searches (limit to 10 for dropdown) + const recentSearches = getRecentSearches(10); + + const showDropdown = useCallback(() => { + if (dropdownTimeoutRef.current) { + clearTimeout(dropdownTimeoutRef.current); + } + if (recentSearches.length > 0) { + setIsDropdownOpen(true); + setHighlightedIndex(-1); + } + }, [recentSearches.length]); + + const hideDropdown = useCallback(() => { + // Delay hiding to allow click events to fire + dropdownTimeoutRef.current = setTimeout(() => { + setIsDropdownOpen(false); + setHighlightedIndex(-1); + }, 150); + }, []); + + const addSearch = useCallback( + (query: string, resultCount?: number) => { + addToSearchHistory(query, resultCount); + }, + [addToSearchHistory] + ); + + const removeSearch = useCallback( + (query: string) => { + removeFromSearchHistory(query); + }, + [removeFromSearchHistory] + ); + + const clearAll = useCallback(() => { + clearSearchHistory(); + setIsDropdownOpen(false); + }, [clearSearchHistory]); + + const selectHistoryItem = useCallback( + (query: string) => { + if (onSelectHistory) { + onSelectHistory(query); + } + hideDropdown(); + }, + [onSelectHistory, hideDropdown] + ); + + const navigateDropdown = useCallback( + (direction: 'up' | 'down') => { + if (!isDropdownOpen || recentSearches.length === 0) return; + + setHighlightedIndex((prevIndex) => { + if (direction === 'down') { + return prevIndex < recentSearches.length - 1 ? prevIndex + 1 : 0; + } else { + return prevIndex > 0 ? prevIndex - 1 : recentSearches.length - 1; + } + }); + }, + [isDropdownOpen, recentSearches.length] + ); + + const resetHighlight = useCallback(() => { + setHighlightedIndex(-1); + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (dropdownTimeoutRef.current) { + clearTimeout(dropdownTimeoutRef.current); + } + }; + }, []); + + return { + searchHistory: recentSearches, + isDropdownOpen, + highlightedIndex, + showDropdown, + hideDropdown, + addSearch, + removeSearch, + clearAll, + selectHistoryItem, + navigateDropdown, + resetHighlight, + }; +} diff --git a/lib/store/search-history-store.ts b/lib/store/search-history-store.ts new file mode 100644 index 0000000..e94fc5d --- /dev/null +++ b/lib/store/search-history-store.ts @@ -0,0 +1,113 @@ +/** + * Search History Store using Zustand + * Manages search query history with localStorage persistence + * Following Liquid Glass design principles + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +const MAX_HISTORY_ITEMS = 20; + +export interface SearchHistoryItem { + query: string; + timestamp: number; + resultCount?: number; +} + +interface SearchHistoryStore { + searchHistory: SearchHistoryItem[]; + + // Actions + addToSearchHistory: (query: string, resultCount?: number) => void; + removeFromSearchHistory: (query: string) => void; + clearSearchHistory: () => void; + getRecentSearches: (limit?: number) => SearchHistoryItem[]; +} + +/** + * Normalize query for comparison (trim, lowercase) + */ +function normalizeQuery(query: string): string { + return query.trim().toLowerCase(); +} + +export const useSearchHistoryStore = create()( + persist( + (set, get) => ({ + searchHistory: [], + + addToSearchHistory: (query, resultCount) => { + const trimmedQuery = query.trim(); + + // Don't add empty queries + if (!trimmedQuery) return; + + const normalized = normalizeQuery(trimmedQuery); + const timestamp = Date.now(); + + set((state) => { + // Check if query already exists (case-insensitive) + const existingIndex = state.searchHistory.findIndex( + (item) => normalizeQuery(item.query) === normalized + ); + + let newHistory: SearchHistoryItem[]; + + if (existingIndex !== -1) { + // Update existing item and move to top + const updatedItem: SearchHistoryItem = { + query: trimmedQuery, // Keep original casing from new search + timestamp, + resultCount, + }; + + newHistory = [ + updatedItem, + ...state.searchHistory.filter((_, index) => index !== existingIndex), + ]; + } else { + // Add new item at the top + const newItem: SearchHistoryItem = { + query: trimmedQuery, + timestamp, + resultCount, + }; + + newHistory = [newItem, ...state.searchHistory]; + } + + // Trim to max items + if (newHistory.length > MAX_HISTORY_ITEMS) { + newHistory = newHistory.slice(0, MAX_HISTORY_ITEMS); + } + + return { searchHistory: newHistory }; + }); + }, + + removeFromSearchHistory: (query) => { + const normalized = normalizeQuery(query); + + set((state) => ({ + searchHistory: state.searchHistory.filter( + (item) => normalizeQuery(item.query) !== normalized + ), + })); + }, + + clearSearchHistory: () => { + set({ searchHistory: [] }); + }, + + getRecentSearches: (limit = 10) => { + const history = get().searchHistory; + return history.slice(0, limit); + }, + }), + { + name: 'kvideo-search-history', + version: 1, + } + ) +); diff --git a/scripts/test-contrast.ts b/scripts/test-contrast.ts new file mode 100644 index 0000000..567d09f --- /dev/null +++ b/scripts/test-contrast.ts @@ -0,0 +1,232 @@ +/** + * WCAG 2.2 Contrast Testing Script + * Tests color combinations against WCAG AA standards (4.5:1 for normal text, 3:1 for large text) + */ + +// Simple contrast ratio calculator +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +} + +function getLuminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map(c => { + c = c / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +function getContrastRatio(color1: string, color2: string): number { + const rgb1 = hexToRgb(color1); + const rgb2 = hexToRgb(color2); + + if (!rgb1 || !rgb2) return 0; + + const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b); + const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b); + + const brightest = Math.max(lum1, lum2); + const darkest = Math.min(lum1, lum2); + + return (brightest + 0.05) / (darkest + 0.05); +} + +interface ContrastTest { + component: string; + variant: string; + foreground: string; + background: string; + ratio: number; + passAA: boolean; + passAAA: boolean; + notes?: string; +} + +// Color definitions from globals.css +const colors = { + light: { + text: '#1d1d1f', + textSecondary: '#6e6e73', + accent: '#0056b3', // Updated for WCAG compliance + glassBg: 'rgba(242, 242, 247, 0.8)', // Approximated as #f2f2f7 + white: '#ffffff', + background: '#f0f2f5' + }, + dark: { + text: '#f5f5f7', + textSecondary: '#8e8e93', + accent: '#1A6DBF', // Updated for WCAG compliance + glassBg: 'rgba(28, 28, 30, 0.75)', // Approximated as #1c1c1e + white: '#ffffff', + background: '#121212' + } +}; + +// Tests to run +const tests: ContrastTest[] = []; + +console.log('🎨 KVideo WCAG 2.2 Contrast Testing Report\n'); +console.log('='.repeat(80)); +console.log('\n'); + +// Badge Tests +console.log('📛 BADGE COMPONENT\n'); + +// Badge Primary (Light Mode) +let ratio = getContrastRatio(colors.light.white, colors.light.accent); +tests.push({ + component: 'Badge', + variant: 'Primary (Light)', + foreground: 'white', + background: colors.light.accent, + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Primary (Light): ${colors.light.white} on ${colors.light.accent}`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Badge Primary (Dark Mode) +ratio = getContrastRatio(colors.dark.white, colors.dark.accent); +tests.push({ + component: 'Badge', + variant: 'Primary (Dark)', + foreground: 'white', + background: colors.dark.accent, + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Primary (Dark): ${colors.dark.white} on ${colors.dark.accent}`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Badge Secondary (Light Mode) +ratio = getContrastRatio(colors.light.text, '#f2f2f7'); // glass-bg approximation +tests.push({ + component: 'Badge', + variant: 'Secondary (Light)', + foreground: colors.light.text, + background: '#f2f2f7', + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7, + notes: 'Glass background approximated as solid color' +}); +console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Button Tests +console.log('🔘 BUTTON COMPONENT\n'); + +// Button Primary (Light Mode) +ratio = getContrastRatio(colors.light.white, colors.light.accent); +tests.push({ + component: 'Button', + variant: 'Primary (Light)', + foreground: 'white', + background: colors.light.accent, + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Primary (Light): white on ${colors.light.accent}`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Button Secondary (Light Mode) +ratio = getContrastRatio(colors.light.text, '#f2f2f7'); +tests.push({ + component: 'Button', + variant: 'Secondary (Light)', + foreground: colors.light.text, + background: '#f2f2f7', + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// TypeBadges Tests +console.log('🏷️ TYPE BADGES COMPONENT\n'); + +// Selected state (Light Mode) +ratio = getContrastRatio(colors.light.white, colors.light.accent); +tests.push({ + component: 'TypeBadges', + variant: 'Selected (Light)', + foreground: 'white', + background: colors.light.accent, + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Selected (Light): white on ${colors.light.accent}`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Unselected state (Light Mode) +ratio = getContrastRatio(colors.light.text, '#f2f2f7'); +tests.push({ + component: 'TypeBadges', + variant: 'Unselected (Light)', + foreground: colors.light.text, + background: '#f2f2f7', + ratio: ratio, + passAA: ratio >= 4.5, + passAAA: ratio >= 7 +}); +console.log(` Unselected (Light): ${colors.light.text} on #f2f2f7`); +console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`); +console.log(''); + +// Summary +console.log('\n'); +console.log('='.repeat(80)); +console.log('\n📊 SUMMARY\n'); + +const totalTests = tests.length; +const passedAA = tests.filter(t => t.passAA).length; +const passedAAA = tests.filter(t => t.passAAA).length; + +console.log(`Total tests: ${totalTests}`); +console.log(`AA Standard (4.5:1): ${passedAA}/${totalTests} passed (${((passedAA/totalTests)*100).toFixed(1)}%)`); +console.log(`AAA Standard (7:1): ${passedAAA}/${totalTests} passed (${((passedAAA/totalTests)*100).toFixed(1)}%)`); +console.log(''); + +if (passedAA < totalTests) { + console.log('⚠️ Some color combinations need adjustment to meet WCAG AA standards.\n'); +} + +// Export results +const results = { + timestamp: new Date().toISOString(), + tests, + summary: { + total: totalTests, + passedAA, + passedAAA, + failedAA: totalTests - passedAA + } +}; + +console.log('Results exported to: contrast-test-results.json\n'); + +// This would write to file in a Node environment +// For browser, you'd use different storage methods +if (typeof require !== 'undefined') { + const fs = require('fs'); + fs.writeFileSync( + 'contrast-test-results.json', + JSON.stringify(results, null, 2) + ); +}