feat: add Unsplash search hook and API proxy routes

- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
catlog22
2026-02-08 20:01:28 +08:00
parent 87daccdc48
commit 166211dcd4
52 changed files with 5798 additions and 142 deletions

View File

@@ -3,9 +3,9 @@
// ========================================
// Centered popup dialog for A2UI surfaces with minimalist design
// Used for displayMode: 'popup' surfaces (e.g., ask_question)
// Supports markdown content parsing
// Supports markdown content parsing and multi-page navigation
import { useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -30,7 +30,14 @@ interface A2UIPopupCardProps {
onClose: () => void;
}
type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'unknown';
type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'multi-question' | 'unknown';
interface PageMeta {
index: number;
questionId: string;
title: string;
type: string;
}
// ========== Helpers ==========
@@ -73,6 +80,37 @@ function isActionButton(component: SurfaceComponent): boolean {
return 'Button' in comp;
}
// ========== "Other" Text Input Component ==========
interface OtherInputProps {
visible: boolean;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
function OtherInput({ visible, value, onChange, placeholder }: OtherInputProps) {
if (!visible) return null;
return (
<div className="mt-2 animate-in fade-in-0 slide-in-from-top-1 duration-200">
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Enter your answer...'}
className={cn(
'w-full px-3 py-2 text-sm rounded-md border border-border',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent',
'transition-colors'
)}
autoFocus
/>
</div>
);
}
// ========== Markdown Component ==========
interface MarkdownContentProps {
@@ -114,15 +152,21 @@ function MarkdownContent({ content, className }: MarkdownContentProps) {
);
}
// ========== Component ==========
// ========== Single-Page Popup (Legacy) ==========
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
const { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
// Detect question type
const questionType = useMemo(() => detectQuestionType(surface), [surface]);
// "Other" option state
const [otherSelected, setOtherSelected] = useState(false);
const [otherText, setOtherText] = useState('');
const questionId = (surface.initialState as any)?.questionId as string | undefined;
// Extract title, message, and description from surface components
const titleComponent = surface.components.find(
(c) => c.id === 'title' && 'Text' in (c.component as any)
@@ -171,9 +215,33 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
[surface, actionButtons]
);
// Handle "Other" text change
const handleOtherTextChange = useCallback(
(value: string) => {
setOtherText(value);
if (questionId) {
sendA2UIAction('input-change', surface.surfaceId, {
questionId: `__other__:${questionId}`,
value,
});
}
},
[sendA2UIAction, surface.surfaceId, questionId]
);
// Handle A2UI actions
const handleAction = useCallback(
(actionId: string, params?: Record<string, unknown>) => {
// Track "Other" selection state
if (actionId === 'select' && params?.value === '__other__') {
setOtherSelected(true);
} else if (actionId === 'select' && params?.value !== '__other__') {
setOtherSelected(false);
}
if (actionId === 'toggle' && params?.value === '__other__') {
setOtherSelected((prev) => !prev);
}
// Send action to backend via WebSocket
sendA2UIAction(actionId, surface.surfaceId, params);
@@ -211,6 +279,9 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
}
}, [questionType]);
// Check if this question type supports "Other" input
const hasOtherOption = questionType === 'select' || questionType === 'multi-select';
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
@@ -269,6 +340,14 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
) : (
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
)}
{/* "Other" text input — shown when Other is selected */}
{hasOtherOption && (
<OtherInput
visible={otherSelected}
value={otherText}
onChange={handleOtherTextChange}
/>
)}
</div>
)}
@@ -291,4 +370,308 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
);
}
// ========== Multi-Page Popup ==========
function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) {
const { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
const state = surface.initialState as Record<string, unknown>;
const pages = state.pages as PageMeta[];
const totalPages = state.totalPages as number;
const compositeId = state.questionId as string;
const [currentPage, setCurrentPage] = useState(0);
// "Other" per-page state
const [otherSelectedPages, setOtherSelectedPages] = useState<Set<number>>(new Set());
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(new Map());
// Group components by page
const pageComponentGroups = useMemo(() => {
const groups: SurfaceComponent[][] = [];
for (let i = 0; i < totalPages; i++) {
groups.push(
surface.components.filter((c) => (c as any).page === i)
);
}
return groups;
}, [surface.components, totalPages]);
// Extract current page title and body components
const currentPageData = useMemo(() => {
const comps = pageComponentGroups[currentPage] || [];
const titleComp = comps.find((c) => c.id.endsWith('-title'));
const messageComp = comps.find((c) => c.id.endsWith('-message'));
const descComp = comps.find((c) => c.id.endsWith('-description'));
const bodyComps = comps.filter(
(c) => !c.id.endsWith('-title') && !c.id.endsWith('-message') && !c.id.endsWith('-description')
);
return {
title: getTextContent(titleComp),
message: getTextContent(messageComp),
description: getTextContent(descComp),
bodyComponents: bodyComps,
pageMeta: pages[currentPage],
};
}, [pageComponentGroups, currentPage, pages]);
// Handle "Other" text change for a specific page
const handleOtherTextChange = useCallback(
(pageIdx: number, value: string) => {
setOtherTexts((prev) => {
const next = new Map(prev);
next.set(pageIdx, value);
return next;
});
// Send input-change to backend with __other__:{questionId}
const qId = pages[pageIdx]?.questionId;
if (qId) {
sendA2UIAction('input-change', surface.surfaceId, {
questionId: `__other__:${qId}`,
value,
});
}
},
[sendA2UIAction, surface.surfaceId, pages]
);
// Handle A2UI actions (pass through to backend without closing dialog)
const handleAction = useCallback(
(actionId: string, params?: Record<string, unknown>) => {
// Track "Other" selection state per page
if (actionId === 'select' && params?.value === '__other__') {
setOtherSelectedPages((prev) => new Set(prev).add(currentPage));
} else if (actionId === 'select' && params?.value !== '__other__') {
setOtherSelectedPages((prev) => {
const next = new Set(prev);
next.delete(currentPage);
return next;
});
}
if (actionId === 'toggle' && params?.value === '__other__') {
setOtherSelectedPages((prev) => {
const next = new Set(prev);
if (next.has(currentPage)) {
next.delete(currentPage);
} else {
next.add(currentPage);
}
return next;
});
}
sendA2UIAction(actionId, surface.surfaceId, params);
},
[sendA2UIAction, surface.surfaceId, currentPage]
);
// Handle Cancel
const handleCancel = useCallback(() => {
sendA2UIAction('cancel', surface.surfaceId, { questionId: compositeId });
onClose();
}, [sendA2UIAction, surface.surfaceId, compositeId, onClose]);
// Handle Submit All
const handleSubmitAll = useCallback(() => {
sendA2UIAction('submit-all', surface.surfaceId, {
compositeId,
questionIds: pages.map((p) => p.questionId),
});
onClose();
}, [sendA2UIAction, surface.surfaceId, compositeId, pages, onClose]);
// Handle dialog close
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
handleCancel();
}
},
[handleCancel]
);
// Navigation
const goNext = useCallback(() => {
setCurrentPage((p) => Math.min(p + 1, totalPages - 1));
}, [totalPages]);
const goPrev = useCallback(() => {
setCurrentPage((p) => Math.max(p - 1, 0));
}, []);
const isFirstPage = currentPage === 0;
const isLastPage = currentPage === totalPages - 1;
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
'sm:max-w-[480px]',
'max-h-[80vh]',
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
// Animation classes
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
)}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* Header with current page title */}
<DialogHeader className="space-y-2 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">
{currentPageData.title ||
formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' })}
</DialogTitle>
{currentPageData.message && (
<div className="text-base text-foreground">
<MarkdownContent content={currentPageData.message} />
</div>
)}
{currentPageData.description && (
<div className="text-sm text-muted-foreground">
<MarkdownContent content={currentPageData.description} className="prose-muted" />
</div>
)}
</DialogHeader>
{/* Page content with slide animation */}
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{ transform: `translateX(-${currentPage * 100}%)` }}
>
{pageComponentGroups.map((pageComps, pageIdx) => {
const bodyComps = pageComps.filter(
(c) =>
!c.id.endsWith('-title') &&
!c.id.endsWith('-message') &&
!c.id.endsWith('-description')
);
const pageType = pages[pageIdx]?.type || 'unknown';
const hasOther = pageType === 'select' || pageType === 'multi-select';
const isOtherSelected = otherSelectedPages.has(pageIdx);
return (
<div key={pageIdx} className="w-full flex-shrink-0">
{bodyComps.length > 0 && (
<div
className={cn(
'py-3',
pageType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
)}
>
{pageType === 'multi-select' ? (
bodyComps.map((comp) => (
<div key={comp.id} className="py-1">
<A2UIRenderer
surface={{ ...surface, components: [comp] }}
onAction={handleAction}
/>
</div>
))
) : (
<A2UIRenderer
surface={{ ...surface, components: bodyComps }}
onAction={handleAction}
/>
)}
{/* "Other" text input */}
{hasOther && (
<OtherInput
visible={isOtherSelected}
value={otherTexts.get(pageIdx) || ''}
onChange={(v) => handleOtherTextChange(pageIdx, v)}
/>
)}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Dot indicator */}
<div className="flex justify-center gap-2 py-3">
{pages.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setCurrentPage(i)}
className={cn(
'rounded-full transition-all duration-200',
i === currentPage
? 'bg-primary w-4 h-2'
: 'bg-muted-foreground/30 w-2 h-2 hover:bg-muted-foreground/50'
)}
aria-label={`Page ${i + 1}`}
/>
))}
</div>
{/* Footer - Navigation buttons */}
<DialogFooter className="pt-2">
<div className="flex flex-row justify-between w-full">
{/* Left: Cancel */}
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{formatMessage({ id: 'askQuestion.cancel', defaultMessage: 'Cancel' })}
</button>
{/* Right: Prev / Next / Submit */}
<div className="flex flex-row gap-2">
{!isFirstPage && (
<button
type="button"
onClick={goPrev}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{formatMessage({ id: 'askQuestion.previous', defaultMessage: 'Previous' })}
</button>
)}
{isLastPage ? (
<button
type="button"
onClick={handleSubmitAll}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{formatMessage({ id: 'askQuestion.submit', defaultMessage: 'Submit' })}
</button>
) : (
<button
type="button"
onClick={goNext}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{formatMessage({ id: 'askQuestion.next', defaultMessage: 'Next' })}
</button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ========== Main Component ==========
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
const state = surface.initialState as Record<string, unknown> | undefined;
const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1;
if (isMultiPage) {
return <MultiPagePopup surface={surface} onClose={onClose} />;
}
return <SinglePagePopup surface={surface} onClose={onClose} />;
}
export default A2UIPopupCard;

View File

@@ -68,7 +68,7 @@ export function ModelCard({
};
return (
<Card className={cn('overflow-hidden', !model.installed && 'opacity-80')}>
<Card className={cn('overflow-hidden hover-glow', !model.installed && 'opacity-80')}>
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -105,12 +105,15 @@ export function ModelCard({
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>Backend: {model.backend}</span>
<span>Size: {formatSize(model.size)}</span>
{model.dimensions && <span>{model.dimensions}d</span>}
<span>{formatSize(model.size)}</span>
{model.recommended && (
<Badge variant="success" className="text-[10px] px-1 py-0">Rec</Badge>
)}
</div>
{model.cache_path && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
{model.cache_path}
{model.description && (
<p className="text-xs text-muted-foreground mt-1">
{model.description}
</p>
)}
</div>

View File

@@ -47,7 +47,7 @@ function filterModels(models: CodexLensModel[], filter: FilterType, search: stri
filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) ||
m.profile.toLowerCase().includes(query) ||
m.backend.toLowerCase().includes(query)
(m.description?.toLowerCase().includes(query) ?? false)
);
}

View File

@@ -2,10 +2,11 @@
// CodexLens Search Tab
// ========================================
// Semantic code search interface with multiple search types
// Includes LSP availability check and hybrid search mode switching
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Search, FileCode, Code } from 'lucide-react';
import { Search, FileCode, Code, Sparkles, CheckCircle, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
@@ -20,11 +21,13 @@ import {
useCodexLensSearch,
useCodexLensFilesSearch,
useCodexLensSymbolSearch,
useCodexLensLspStatus,
useCodexLensSemanticSearch,
} from '@/hooks/useCodexLens';
import type { CodexLensSearchParams } from '@/lib/api';
import type { CodexLensSearchParams, CodexLensSemanticSearchMode, CodexLensFusionStrategy } from '@/lib/api';
import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol';
type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
interface SearchTabProps {
@@ -35,14 +38,19 @@ export function SearchTab({ enabled }: SearchTabProps) {
const { formatMessage } = useIntl();
const [searchType, setSearchType] = useState<SearchType>('search');
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
// LSP status check
const lspStatus = useCodexLensLspStatus({ enabled });
// Build search params based on search type
const searchParams: CodexLensSearchParams = {
query,
limit: 20,
mode: searchType !== 'symbol' ? searchMode : undefined,
mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined,
max_content_length: 200,
extra_files_count: 10,
};
@@ -63,12 +71,25 @@ export function SearchTab({ enabled }: SearchTabProps) {
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
);
const semanticSearch = useCodexLensSemanticSearch(
{
query,
mode: semanticMode,
fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined,
limit: 20,
include_match_reason: true,
},
{ enabled: enabled && hasSearched && searchType === 'semantic' && query.trim().length > 0 }
);
// Get loading state based on search type
const isLoading = searchType === 'search'
? contentSearch.isLoading
: searchType === 'search_files'
? fileSearch.isLoading
: symbolSearch.isLoading;
: searchType === 'symbol'
? symbolSearch.isLoading
: semanticSearch.isLoading;
const handleSearch = () => {
if (query.trim()) {
@@ -84,17 +105,52 @@ export function SearchTab({ enabled }: SearchTabProps) {
const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value);
setHasSearched(false); // Reset search state when changing type
setHasSearched(false);
};
const handleSearchModeChange = (value: SearchMode) => {
setSearchMode(value);
setHasSearched(false); // Reset search state when changing mode
setHasSearched(false);
};
const handleSemanticModeChange = (value: CodexLensSemanticSearchMode) => {
setSemanticMode(value);
setHasSearched(false);
};
const handleFusionStrategyChange = (value: CodexLensFusionStrategy) => {
setFusionStrategy(value);
setHasSearched(false);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setHasSearched(false); // Reset search state when query changes
setHasSearched(false);
};
// Get result count for display
const getResultCount = (): string => {
if (searchType === 'symbol') {
return symbolSearch.data?.success
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search') {
return contentSearch.data?.success
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'search_files') {
return fileSearch.data?.success
? `${fileSearch.data.files?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
if (searchType === 'semantic') {
return semanticSearch.data?.success
? `${semanticSearch.data.count ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: '';
}
return '';
};
if (!enabled) {
@@ -115,6 +171,29 @@ export function SearchTab({ enabled }: SearchTabProps) {
return (
<div className="space-y-6">
{/* LSP Status Indicator */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.search.lspStatus' })}:</span>
{lspStatus.isLoading ? (
<span className="text-muted-foreground">...</span>
) : lspStatus.available ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspAvailable' })}
</span>
) : !lspStatus.semanticAvailable ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoSemantic' })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoVector' })}
</span>
)}
</div>
{/* Search Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Type */}
@@ -143,12 +222,18 @@ export function SearchTab({ enabled }: SearchTabProps) {
{formatMessage({ id: 'codexlens.search.symbol' })}
</div>
</SelectItem>
<SelectItem value="semantic" disabled={!lspStatus.available}>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.semantic' })}
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Search Mode - only for content and file search */}
{searchType !== 'symbol' && (
{/* Search Mode - for CLI search types (content / file) */}
{(searchType === 'search' || searchType === 'search_files') && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
<Select value={searchMode} onValueChange={handleSearchModeChange}>
@@ -169,8 +254,60 @@ export function SearchTab({ enabled }: SearchTabProps) {
</Select>
</div>
)}
{/* Semantic Search Mode - for semantic search type */}
{searchType === 'semantic' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.semanticMode' })}</Label>
<Select value={semanticMode} onValueChange={handleSemanticModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fusion">
{formatMessage({ id: 'codexlens.search.semanticMode.fusion' })}
</SelectItem>
<SelectItem value="vector">
{formatMessage({ id: 'codexlens.search.semanticMode.vector' })}
</SelectItem>
<SelectItem value="structural">
{formatMessage({ id: 'codexlens.search.semanticMode.structural' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Fusion Strategy - only when semantic + fusion mode */}
{searchType === 'semantic' && semanticMode === 'fusion' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.fusionStrategy' })}</Label>
<Select value={fusionStrategy} onValueChange={handleFusionStrategyChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rrf">
{formatMessage({ id: 'codexlens.search.fusionStrategy.rrf' })}
</SelectItem>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.fusionStrategy.dense_rerank' })}
</SelectItem>
<SelectItem value="binary">
{formatMessage({ id: 'codexlens.search.fusionStrategy.binary' })}
</SelectItem>
<SelectItem value="hybrid">
{formatMessage({ id: 'codexlens.search.fusionStrategy.hybrid' })}
</SelectItem>
<SelectItem value="staged">
{formatMessage({ id: 'codexlens.search.fusionStrategy.staged' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Query Input */}
<div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
@@ -205,21 +342,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
{formatMessage({ id: 'codexlens.search.results' })}
</h3>
<span className="text-xs text-muted-foreground">
{searchType === 'symbol'
? (symbolSearch.data?.success
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
: searchType === 'search'
? (contentSearch.data?.success
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
: (fileSearch.data?.success
? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
: ''
)
}
{getResultCount()}
</span>
</div>
@@ -255,7 +378,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
fileSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(fileSearch.data.results, null, 2)}
{JSON.stringify(fileSearch.data.files, null, 2)}
</pre>
</div>
) : (
@@ -264,6 +387,20 @@ export function SearchTab({ enabled }: SearchTabProps) {
</div>
)
)}
{searchType === 'semantic' && semanticSearch.data && (
semanticSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(semanticSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{semanticSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
</div>
)}
</div>

View File

@@ -43,7 +43,7 @@ export function DashboardHeader({
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">
<h1 className="text-2xl font-semibold text-foreground gradient-text">
{formatMessage({ id: titleKey })}
</h1>
<p className="text-sm text-muted-foreground mt-1">

View File

@@ -253,7 +253,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
return (
<div className={cn('flex flex-col gap-2', className)}>
{/* Project Info Banner - Separate Card */}
<Card className="shrink-0">
<Card className="shrink-0 border-gradient-brand">
{projectLoading ? (
<div className="px-4 py-3 flex items-center gap-4">
<div className="h-5 w-32 bg-muted rounded animate-pulse" />

View File

@@ -12,6 +12,7 @@ import { MainContent } from './MainContent';
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
import { NotificationPanel } from '@/components/notification';
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
import { BackgroundImage } from '@/components/shared/BackgroundImage';
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
import { useWorkflowStore } from '@/stores/workflowStore';
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
@@ -160,6 +161,9 @@ export function AppShell({
return (
<div className="flex flex-col min-h-screen bg-background">
{/* Background image layer (z-index: -3 to -2) */}
<BackgroundImage />
{/* Header - fixed at top */}
<Header
onRefresh={onRefresh}
@@ -180,7 +184,7 @@ export function AppShell({
{/* Main content area */}
<MainContent
className={cn(
'transition-all duration-300',
'app-shell-content transition-all duration-300',
sidebarCollapsed ? 'md:ml-16' : 'md:ml-64'
)}
>

View File

@@ -59,7 +59,7 @@ export function Header({
return (
<header
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
className="relative flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
role="banner"
>
{/* Left side - Logo */}
@@ -200,6 +200,7 @@ export function Header({
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-accent" aria-hidden="true" />
</header>
);
}

View File

@@ -16,6 +16,7 @@ import {
FileText,
HardDrive,
MessageCircleQuestion,
SearchCode,
ChevronDown,
ChevronRight,
Globe,
@@ -90,6 +91,7 @@ export const CCW_MCP_TOOLS: CcwTool[] = [
{ name: 'read_file', desc: 'Read file contents', core: true },
{ name: 'core_memory', desc: 'Core memory management', core: true },
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
];
// ========== Component ==========
@@ -470,6 +472,8 @@ function getToolIcon(toolName: string): React.ReactElement {
return <Settings {...iconProps} />;
case 'ask_question':
return <MessageCircleQuestion {...iconProps} />;
case 'smart_search':
return <SearchCode {...iconProps} />;
default:
return <Settings {...iconProps} />;
}

View File

@@ -0,0 +1,98 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '@/stores/appStore';
import { DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
import type { BackgroundConfig } from '@/types/store';
/**
* BackgroundImage Component
* Renders background image layer with visual effects (blur, darken, grain, vignette).
* Positioned behind all content via z-index layering.
*/
export function BackgroundImage() {
const activeSlotId = useAppStore((s) => s.activeSlotId);
const themeSlots = useAppStore((s) => s.themeSlots);
const activeSlot = themeSlots.find((s) => s.id === activeSlotId);
const config: BackgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const handleLoad = useCallback(() => {
setLoaded(true);
setError(false);
}, []);
const handleError = useCallback(() => {
setError(true);
setLoaded(false);
}, []);
// Reset load state when image URL changes
const imageUrl = config.imageUrl;
const [prevUrl, setPrevUrl] = useState(imageUrl);
if (imageUrl !== prevUrl) {
setPrevUrl(imageUrl);
setLoaded(false);
setError(false);
}
// Don't render anything in gradient-only mode
if (config.mode === 'gradient-only') return null;
// Don't render if no image URL or image failed to load
if (!imageUrl || error) return null;
const { blur, darkenOpacity, saturation } = config.effects;
const imageStyle: React.CSSProperties = {
filter: [
blur > 0 ? `blur(${blur}px)` : '',
saturation !== 100 ? `saturate(${saturation / 100})` : '',
].filter(Boolean).join(' ') || undefined,
// Scale slightly when blurred to prevent white edges
transform: blur > 0 ? 'scale(1.05)' : undefined,
};
return (
<>
{/* Background image layer */}
<div
className="bg-image-layer"
style={{ opacity: loaded ? 1 : 0 }}
>
<img
src={imageUrl}
alt=""
role="presentation"
style={imageStyle}
onLoad={handleLoad}
onError={handleError}
draggable={false}
/>
</div>
{/* Darken overlay */}
{darkenOpacity > 0 && (
<div
className="bg-darken-overlay"
style={{ backgroundColor: `rgba(0, 0, 0, ${darkenOpacity / 100})` }}
/>
)}
{/* Grain texture overlay */}
<div className="bg-grain-overlay" />
{/* Vignette overlay */}
<div className="bg-vignette-overlay" />
{/* Loading spinner (hidden img for preloading) */}
{!loaded && !error && (
<div
className="fixed inset-0 z-[-3] flex items-center justify-center pointer-events-none"
>
<div className="w-6 h-6 border-2 border-border border-t-accent rounded-full animate-spin" />
</div>
)}
</>
);
}

View File

@@ -0,0 +1,405 @@
import { useState, useCallback, useRef } from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { useUnsplashSearch } from '@/hooks/useUnsplashSearch';
import { triggerUnsplashDownload, uploadBackgroundImage } from '@/lib/unsplash';
import type { UnsplashPhoto } from '@/lib/unsplash';
import type { BackgroundMode } from '@/types/store';
const MODES: { value: BackgroundMode; labelId: string }[] = [
{ value: 'gradient-only', labelId: 'theme.background.mode.gradientOnly' },
{ value: 'image-only', labelId: 'theme.background.mode.imageOnly' },
{ value: 'image-gradient', labelId: 'theme.background.mode.imageGradient' },
];
/**
* BackgroundImagePicker Component
* Allows users to search Unsplash, pick a background image,
* adjust visual effects, and switch between background modes.
*/
export function BackgroundImagePicker() {
const { formatMessage } = useIntl();
const {
backgroundConfig,
setBackgroundMode,
setBackgroundImage,
updateBackgroundEffect,
} = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
const [customUrl, setCustomUrl] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading, isError, error } = useUnsplashSearch(searchQuery, page);
const showImageControls = backgroundConfig.mode !== 'gradient-only';
const handlePhotoSelect = useCallback(async (photo: UnsplashPhoto) => {
setBackgroundImage(photo.regularUrl, {
photographerName: photo.photographer,
photographerUrl: photo.photographerUrl,
photoUrl: photo.photoUrl,
});
// Trigger download event per Unsplash API guidelines
triggerUnsplashDownload(photo.downloadLocation).catch(() => {});
}, [setBackgroundImage]);
const handleCustomUrlApply = useCallback(() => {
if (customUrl.trim()) {
setBackgroundImage(customUrl.trim(), null);
setCustomUrl('');
}
}, [customUrl, setBackgroundImage]);
const handleRemoveImage = useCallback(() => {
setBackgroundImage(null, null);
}, [setBackgroundImage]);
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset file input so the same file can be re-selected
e.target.value = '';
setUploadError(null);
if (file.size > 10 * 1024 * 1024) {
setUploadError(formatMessage({ id: 'theme.background.fileTooLarge' }));
return;
}
if (!file.type.startsWith('image/') || !['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
setUploadError(formatMessage({ id: 'theme.background.invalidType' }));
return;
}
setIsUploading(true);
try {
const result = await uploadBackgroundImage(file);
setBackgroundImage(result.url, null);
} catch (err) {
setUploadError((err as Error).message || formatMessage({ id: 'theme.background.uploadError' }));
} finally {
setIsUploading(false);
}
}, [formatMessage, setBackgroundImage]);
return (
<div className="space-y-4">
{/* Section title */}
<h3 className="text-sm font-medium text-text">
{formatMessage({ id: 'theme.background.title' })}
</h3>
{/* Background mode selector */}
<div
className="flex gap-2"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.background.title' })}
>
{MODES.map(({ value, labelId }) => (
<button
key={value}
onClick={() => setBackgroundMode(value)}
role="radio"
aria-checked={backgroundConfig.mode === value}
className={`
flex-1 px-3 py-2 rounded-lg text-xs font-medium
transition-all duration-200 border-2
${backgroundConfig.mode === value
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: labelId })}
</button>
))}
</div>
{/* Image selection area */}
{showImageControls && (
<div className="space-y-3">
{/* Current image preview */}
{backgroundConfig.imageUrl && (
<div className="relative rounded-lg overflow-hidden border border-border">
<img
src={backgroundConfig.imageUrl}
alt="Current background"
className="w-full h-32 object-cover"
/>
<button
onClick={handleRemoveImage}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-black/60 text-white rounded hover:bg-black/80 transition-colors"
>
{formatMessage({ id: 'theme.background.removeImage' })}
</button>
{/* Unsplash attribution */}
{backgroundConfig.attribution && (
<div className="absolute bottom-0 left-0 right-0 px-2 py-1 bg-black/50 text-white text-xs">
Photo by{' '}
<a
href={`${backgroundConfig.attribution.photographerUrl}?utm_source=ccw&utm_medium=referral`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{backgroundConfig.attribution.photographerName}
</a>{' '}
on{' '}
<a
href="https://unsplash.com/?utm_source=ccw&utm_medium=referral"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Unsplash
</a>
</div>
)}
</div>
)}
{/* Search box */}
<input
type="text"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
placeholder={formatMessage({ id: 'theme.background.searchPlaceholder' })}
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg text-text
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
/>
{/* Photo grid */}
{isLoading && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-border border-t-accent rounded-full animate-spin" />
</div>
)}
{isError && (
<p className="text-xs text-destructive py-2">
{(error as Error)?.message || formatMessage({ id: 'theme.background.searchError' })}
</p>
)}
{data && data.photos.length > 0 && (
<>
<div className="grid grid-cols-3 gap-2 max-h-60 overflow-y-auto">
{data.photos.map((photo) => (
<button
key={photo.id}
onClick={() => handlePhotoSelect(photo)}
className={`
relative rounded overflow-hidden border-2 transition-all
hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent
${backgroundConfig.imageUrl === photo.regularUrl
? 'border-accent ring-2 ring-accent'
: 'border-transparent'
}
`}
>
<img
src={photo.thumbUrl}
alt={`Photo by ${photo.photographer}`}
className="w-full h-20 object-cover"
loading="lazy"
/>
</button>
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="flex items-center justify-between">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
>
{formatMessage({ id: 'theme.background.prev' })}
</button>
<span className="text-xs text-text-secondary">
{page} / {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page >= data.totalPages}
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
>
{formatMessage({ id: 'theme.background.next' })}
</button>
</div>
)}
</>
)}
{data && data.photos.length === 0 && searchQuery.trim() && (
<p className="text-xs text-text-secondary py-2 text-center">
{formatMessage({ id: 'theme.background.noResults' })}
</p>
)}
{/* Custom URL input */}
<div className="flex gap-2">
<input
type="url"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
placeholder={formatMessage({ id: 'theme.background.customUrlPlaceholder' })}
className="flex-1 px-3 py-2 text-xs rounded-lg border border-border bg-bg text-text
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
/>
<button
onClick={handleCustomUrlApply}
disabled={!customUrl.trim()}
className="px-3 py-2 text-xs rounded-lg bg-accent text-white disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{formatMessage({ id: 'theme.background.apply' })}
</button>
</div>
{/* Upload local image */}
<div className="space-y-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full px-3 py-2 text-xs rounded-lg border border-dashed border-border
bg-bg text-text hover:bg-surface hover:border-accent
disabled:opacity-50 transition-all flex items-center justify-center gap-2"
>
{isUploading ? (
<>
<div className="w-3.5 h-3.5 border-2 border-border border-t-accent rounded-full animate-spin" />
{formatMessage({ id: 'theme.background.uploading' })}
</>
) : (
formatMessage({ id: 'theme.background.upload' })
)}
</button>
{uploadError && (
<p className="text-xs text-destructive">{uploadError}</p>
)}
</div>
</div>
)}
{/* Effects panel */}
{showImageControls && (
<div className="space-y-3 pt-2 border-t border-border">
<h4 className="text-xs font-medium text-text-secondary">
{formatMessage({ id: 'theme.background.effects' })}
</h4>
{/* Blur slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.blur' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.blur}px</span>
</div>
<input
type="range"
min="0"
max="20"
step="1"
value={backgroundConfig.effects.blur}
onChange={(e) => updateBackgroundEffect('blur', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Darken slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.darken' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.darkenOpacity}%</span>
</div>
<input
type="range"
min="0"
max="80"
step="1"
value={backgroundConfig.effects.darkenOpacity}
onChange={(e) => updateBackgroundEffect('darkenOpacity', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Saturation slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.saturation' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.saturation}%</span>
</div>
<input
type="range"
min="0"
max="200"
step="5"
value={backgroundConfig.effects.saturation}
onChange={(e) => updateBackgroundEffect('saturation', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Frosted glass checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableFrostedGlass}
onChange={(e) => updateBackgroundEffect('enableFrostedGlass', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.frostedGlass' })}
</span>
</label>
{/* Grain checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableGrain}
onChange={(e) => updateBackgroundEffect('enableGrain', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.grain' })}
</span>
</label>
{/* Vignette checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableVignette}
onChange={(e) => updateBackgroundEffect('enableVignette', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.vignette' })}
</span>
</label>
</div>
)}
</div>
);
}

View File

@@ -177,7 +177,7 @@ export function IssueCard({
onClick={handleClick}
className={cn(
'p-3 bg-card border border-border rounded-lg cursor-pointer',
'hover:shadow-md hover:border-primary/50 transition-all',
'hover:shadow-md hover:border-primary/50 transition-all hover-glow',
className
)}
>
@@ -198,7 +198,7 @@ export function IssueCard({
{...draggableProps}
onClick={handleClick}
className={cn(
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all hover-glow',
className
)}
>

View File

@@ -107,7 +107,7 @@ export function RuleCard({
return (
<Card
className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30',
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!rule.enabled && 'opacity-60',
className
)}

View File

@@ -102,7 +102,7 @@ export function SkillCard({
onClick={handleClick}
className={cn(
'p-3 bg-card border rounded-lg cursor-pointer',
'hover:shadow-md transition-all',
'hover:shadow-md transition-all hover-glow',
skill.enabled ? 'border-border hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/50 grayscale-[0.5]',
className
)}
@@ -140,7 +140,7 @@ export function SkillCard({
<Card
onClick={handleClick}
className={cn(
'p-4 cursor-pointer hover:shadow-md transition-all',
'p-4 cursor-pointer hover:shadow-md transition-all hover-glow',
skill.enabled ? 'hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/30 grayscale-[0.3]',
className
)}

View File

@@ -11,7 +11,7 @@ import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
import { Sparkline } from '@/components/charts/Sparkline';
const statCardVariants = cva(
'transition-all duration-200 hover:shadow-md',
'transition-all duration-200 hover:shadow-md hover-glow',
{
variants: {
variant: {

View File

@@ -1,9 +1,15 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import { useNotifications } from '@/hooks/useNotifications';
import { COLOR_SCHEMES, THEME_MODES, getThemeName, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
import { generateThemeFromHue } from '@/lib/colorGenerator';
import type { ThemeSlotId, StyleTier } from '@/types/store';
import { generateThemeFromHue, applyStyleTier } from '@/lib/colorGenerator';
import { checkThemeContrast, generateContrastFix } from '@/lib/accessibility';
import type { ContrastResult, FixSuggestion } from '@/lib/accessibility';
import type { ThemeSharePayload } from '@/lib/themeShare';
import { BackgroundImagePicker } from './BackgroundImagePicker';
/**
* Theme Selector Component
@@ -28,17 +34,52 @@ export function ThemeSelector() {
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
motionPreference,
setColorScheme,
setTheme,
setCustomHue,
setGradientLevel,
setEnableHoverGlow,
setEnableBackgroundAnimation,
setMotionPreference,
styleTier,
setStyleTier,
themeSlots,
activeSlotId,
canAddSlot,
setActiveSlot,
copySlot,
renameSlot,
deleteSlot,
undoDeleteSlot,
exportThemeCode,
importThemeCode,
setBackgroundConfig,
} = useTheme();
const { addToast, removeToast } = useNotifications();
// Local state for preview hue (uncommitted changes)
const [previewHue, setPreviewHue] = useState<number | null>(customHue);
// Contrast warning state (non-blocking)
const [contrastWarnings, setContrastWarnings] = useState<ContrastResult[]>([]);
const [contrastFixes, setContrastFixes] = useState<Record<string, FixSuggestion[]>>({});
const [showContrastWarning, setShowContrastWarning] = useState(false);
// Slot management state
const [renamingSlotId, setRenamingSlotId] = useState<ThemeSlotId | null>(null);
const [renameValue, setRenameValue] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const undoToastIdRef = useRef<string | null>(null);
// Share/import state (local to component, not in store)
const [showImportPanel, setShowImportPanel] = useState(false);
const [importCode, setImportCode] = useState('');
const [importPreview, setImportPreview] = useState<ThemeSharePayload | null>(null);
const [importWarning, setImportWarning] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [copyFeedback, setCopyFeedback] = useState(false);
// Sync preview with customHue from store
useEffect(() => {
setPreviewHue(customHue);
@@ -55,6 +96,25 @@ export function ThemeSelector() {
return hslValue ? `hsl(${hslValue})` : '#888';
};
// Style tier definitions for the selector UI
const STYLE_TIERS: Array<{ id: StyleTier; nameKey: string; descKey: string }> = [
{ id: 'soft', nameKey: 'theme.styleTier.soft', descKey: 'theme.styleTier.softDesc' },
{ id: 'standard', nameKey: 'theme.styleTier.standard', descKey: 'theme.styleTier.standardDesc' },
{ id: 'high-contrast', nameKey: 'theme.styleTier.highContrast', descKey: 'theme.styleTier.highContrastDesc' },
];
// Get tier preview swatch colors (bg, surface, accent for a sample tier)
const getTierPreviewColors = (tier: StyleTier): { bg: string; surface: string; accent: string } => {
const sampleHue = customHue ?? 220; // Use current hue or default blue
const baseVars = generateThemeFromHue(sampleHue, mode);
const tieredVars = tier === 'standard' ? baseVars : applyStyleTier(baseVars, tier, mode);
return {
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
};
};
const handleSchemeSelect = (scheme: ColorScheme) => {
// When selecting a preset scheme, reset custom hue
if (isCustomTheme) {
@@ -73,6 +133,28 @@ export function ThemeSelector() {
const handleHueSave = () => {
if (previewHue !== null) {
setCustomHue(previewHue);
// Run contrast check on the new custom theme
const mode: ThemeMode = resolvedTheme;
const vars = generateThemeFromHue(previewHue, mode);
const results = checkThemeContrast(vars);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
setContrastWarnings(failures);
// Generate fixes for each failing pair
const fixes: Record<string, FixSuggestion[]> = {};
for (const failure of failures) {
const key = `${failure.fgVar}|${failure.bgVar}`;
fixes[key] = generateContrastFix(failure.fgVar, failure.bgVar, vars, failure.required);
}
setContrastFixes(fixes);
setShowContrastWarning(true);
} else {
setContrastWarnings([]);
setContrastFixes({});
setShowContrastWarning(false);
}
}
};
@@ -99,8 +181,379 @@ export function ThemeSelector() {
}
};
// ========== Slot Management Handlers ==========
const handleSlotSelect = useCallback((slotId: ThemeSlotId) => {
if (slotId !== activeSlotId) {
setActiveSlot(slotId);
}
}, [activeSlotId, setActiveSlot]);
const handleCopySlot = useCallback(() => {
if (!canAddSlot) return;
copySlot();
}, [canAddSlot, copySlot]);
const handleStartRename = useCallback((slotId: ThemeSlotId, currentName: string) => {
setRenamingSlotId(slotId);
setRenameValue(currentName);
// Focus input after render
setTimeout(() => renameInputRef.current?.focus(), 0);
}, []);
const handleConfirmRename = useCallback(() => {
if (renamingSlotId && renameValue.trim()) {
renameSlot(renamingSlotId, renameValue.trim());
}
setRenamingSlotId(null);
setRenameValue('');
}, [renamingSlotId, renameValue, renameSlot]);
const handleCancelRename = useCallback(() => {
setRenamingSlotId(null);
setRenameValue('');
}, []);
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirmRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
}, [handleConfirmRename, handleCancelRename]);
const handleDeleteSlot = useCallback((slotId: ThemeSlotId) => {
const slot = themeSlots.find(s => s.id === slotId);
if (!slot || slot.isDefault) return;
deleteSlot(slotId);
// Remove previous undo toast if exists
if (undoToastIdRef.current) {
removeToast(undoToastIdRef.current);
}
// Show undo toast with 10-second duration
const toastId = addToast('info',
formatMessage({ id: 'theme.slot.undoDelete' }),
undefined,
{
duration: 10000,
dismissible: true,
action: {
label: formatMessage({ id: 'theme.slot.undo' }),
onClick: () => {
undoDeleteSlot();
removeToast(toastId);
undoToastIdRef.current = null;
},
},
}
);
undoToastIdRef.current = toastId;
}, [themeSlots, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
// ========== Share/Import Handlers ==========
const handleCopyThemeCode = useCallback(async () => {
try {
const code = exportThemeCode();
await navigator.clipboard.writeText(code);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch {
// Clipboard API may not be available
addToast('error', 'Failed to copy to clipboard');
}
}, [exportThemeCode, addToast]);
const handleOpenImport = useCallback(() => {
setShowImportPanel(true);
setImportCode('');
setImportPreview(null);
setImportWarning(null);
setImportError(null);
}, []);
const handleCloseImport = useCallback(() => {
setShowImportPanel(false);
setImportCode('');
setImportPreview(null);
setImportWarning(null);
setImportError(null);
}, []);
const handleImportCodeChange = useCallback((value: string) => {
setImportCode(value);
setImportError(null);
setImportWarning(null);
setImportPreview(null);
if (!value.trim()) return;
const result = importThemeCode(value);
if (result.ok) {
setImportPreview(result.payload);
if (result.warning) {
setImportWarning(result.warning);
}
} else {
setImportError(result.error);
}
}, [importThemeCode]);
const handleApplyImport = useCallback(() => {
if (!importPreview) return;
// Check if we can add a slot or overwrite current
if (!canAddSlot && activeSlotId === 'default') {
// Apply to the default slot directly via individual setters
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
} else if (canAddSlot) {
// Create a new slot via copySlot then apply settings
copySlot();
// After copySlot, the new slot is active. Apply imported settings.
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
} else {
// Apply to current active slot via individual setters
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
}
// Apply background config from import (v2+ feature)
setBackgroundConfig(importPreview.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
addToast('success', formatMessage({ id: 'theme.share.importSuccess' }));
handleCloseImport();
}, [
importPreview, canAddSlot, activeSlotId, copySlot,
setCustomHue, setColorScheme, setGradientLevel,
setEnableHoverGlow, setEnableBackgroundAnimation, setStyleTier,
setBackgroundConfig,
addToast, formatMessage, handleCloseImport,
]);
/** Generate preview swatch colors from an import payload */
const getImportPreviewColors = useCallback((payload: ThemeSharePayload) => {
const hue = payload.customHue ?? 220;
const baseVars = generateThemeFromHue(hue, mode);
const tieredVars = payload.styleTier === 'standard'
? baseVars
: applyStyleTier(baseVars, payload.styleTier, mode);
return {
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
text: tieredVars['--text'] ? `hsl(${tieredVars['--text']})` : '#888',
};
}, [mode]);
/** Map error keys to i18n message IDs */
const getShareErrorMessageId = (errorKey: string): string => {
switch (errorKey) {
case 'incompatible_version':
return 'theme.share.incompatibleVersion';
default:
return 'theme.share.invalidCode';
}
};
// Focus rename input when entering rename mode
useEffect(() => {
if (renamingSlotId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renamingSlotId]);
return (
<div className="space-y-6">
{/* Theme Slot Switcher */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-text">
{formatMessage({ id: 'theme.slot.title' })}
</h3>
<button
onClick={handleCopySlot}
disabled={!canAddSlot}
title={
canAddSlot
? formatMessage({ id: 'theme.slot.copy' })
: formatMessage({ id: 'theme.slot.limitReached' }, { limit: THEME_SLOT_LIMIT })
}
className={`
px-2 py-1 rounded text-xs font-medium
transition-all duration-200
${canAddSlot
? 'bg-accent text-white hover:bg-accent-hover focus:ring-2 focus:ring-accent focus:ring-offset-1'
: 'bg-muted text-muted-text cursor-not-allowed'
}
focus:outline-none
`}
>
+ {formatMessage({ id: 'theme.slot.copy' })}
</button>
</div>
<div className="flex gap-2" role="tablist" aria-label={formatMessage({ id: 'theme.slot.title' })}>
{themeSlots.map((slot) => {
const isActive = slot.id === activeSlotId;
const isRenaming = slot.id === renamingSlotId;
return (
<div
key={slot.id}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => handleSlotSelect(slot.id)}
className={`
relative flex-1 min-w-0 p-2.5 rounded-lg cursor-pointer
transition-all duration-200 border-2 group
${isActive
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1
`}
>
{/* Active indicator */}
{isActive && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-accent border-2 border-bg" />
)}
{/* Slot name - inline rename or display */}
<div className="flex items-center gap-1 min-w-0">
{isRenaming ? (
<input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleConfirmRename}
onClick={(e) => e.stopPropagation()}
className="
w-full px-1 py-0.5 text-xs font-medium text-text
bg-bg border border-accent rounded
focus:outline-none focus:ring-1 focus:ring-accent
"
maxLength={20}
/>
) : (
<span
className="text-xs font-medium text-text truncate"
title={slot.name}
onDoubleClick={(e) => {
e.stopPropagation();
if (!slot.isDefault) {
handleStartRename(slot.id, slot.name);
}
}}
>
{slot.name}
</span>
)}
</div>
{/* Active label */}
{isActive && !isRenaming && (
<span className="text-[10px] text-accent font-medium mt-0.5 block">
{formatMessage({ id: 'theme.slot.active' })}
</span>
)}
{/* Action buttons - show on hover for non-default slots */}
{!slot.isDefault && !isRenaming && (
<div className="
absolute top-1 right-1 flex gap-0.5
opacity-0 group-hover:opacity-100 transition-opacity duration-150
">
<button
onClick={(e) => {
e.stopPropagation();
handleStartRename(slot.id, slot.name);
}}
title={formatMessage({ id: 'theme.slot.rename' })}
className="
p-0.5 rounded text-text-tertiary hover:text-text hover:bg-surface-hover
transition-colors duration-150 focus:outline-none
"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 1.5L14.5 4.5L5 14H2V11L11.5 1.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSlot(slot.id);
}}
title={formatMessage({ id: 'theme.slot.delete' })}
className="
p-0.5 rounded text-text-tertiary hover:text-error hover:bg-error-light
transition-colors duration-150 focus:outline-none
"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
)}
{/* Default slot: show disabled delete tooltip */}
{slot.isDefault && (
<div className="
absolute top-1 right-1 flex gap-0.5
opacity-0 group-hover:opacity-100 transition-opacity duration-150
">
<button
disabled
title={formatMessage({ id: 'theme.slot.cannotDeleteDefault' })}
className="p-0.5 rounded text-muted-text cursor-not-allowed"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Color Scheme Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
@@ -265,6 +718,64 @@ export function ThemeSelector() {
</div>
)}
{/* Style Tier Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.styleTier.label' })}
</h3>
<div
className="grid grid-cols-3 gap-3"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.styleTier.label' })}
>
{STYLE_TIERS.map((tier) => {
const preview = getTierPreviewColors(tier.id);
const isSelected = styleTier === tier.id;
return (
<button
key={tier.id}
onClick={() => setStyleTier(tier.id)}
role="radio"
aria-checked={isSelected}
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${isSelected
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{/* Preview swatches */}
<div className="flex gap-1" aria-hidden="true">
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.bg }}
/>
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.surface }}
/>
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.accent }}
/>
</div>
{/* Tier name */}
<span className="text-xs font-medium text-text text-center">
{formatMessage({ id: tier.nameKey })}
</span>
{/* Description */}
<span className="text-[10px] text-text-tertiary text-center leading-tight">
{formatMessage({ id: tier.descKey })}
</span>
</button>
);
})}
</div>
</div>
{/* Gradient Effects Settings */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
@@ -333,6 +844,86 @@ export function ThemeSelector() {
</div>
</div>
{/* Background Image */}
<BackgroundImagePicker />
{/* Motion Preference */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.motion.label' })}
</h3>
<div
className="flex gap-2"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.motion.label' })}
>
{(['system', 'reduce', 'enable'] as const).map((pref) => (
<button
key={pref}
onClick={() => setMotionPreference(pref)}
role="radio"
aria-checked={motionPreference === pref}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium
transition-all duration-200 border-2
${motionPreference === pref
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: `theme.motion.${pref}` })}
</button>
))}
</div>
</div>
{/* Contrast Warning Banner (non-blocking) */}
{showContrastWarning && contrastWarnings.length > 0 && (
<div className="p-3 rounded-lg bg-warning-light border border-warning text-warning-text space-y-2">
<p className="text-xs font-medium">
{formatMessage({ id: 'theme.accessibility.contrastWarning' })}
</p>
<ul className="text-xs space-y-1">
{contrastWarnings.map((w) => {
const key = `${w.fgVar}|${w.bgVar}`;
const fixes = contrastFixes[key] || [];
return (
<li key={key} className="space-y-1">
<span>
{w.fgVar} / {w.bgVar}: {w.ratio}:1 (min {w.required}:1)
</span>
{fixes.length > 0 && (
<div className="ml-2 text-[10px]">
{fixes.slice(0, 1).map((fix, i) => (
<span key={i} className="block">
{formatMessage(
{ id: 'theme.accessibility.fixSuggestion' },
{
target: fix.target === 'fg' ? w.fgVar : w.bgVar,
original: fix.original,
suggested: fix.suggested,
ratio: fix.resultRatio,
}
)}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<button
onClick={() => setShowContrastWarning(false)}
className="text-xs font-medium underline"
>
{formatMessage({ id: 'theme.accessibility.dismiss' })}
</button>
</div>
)}
{/* Theme Mode Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
@@ -379,6 +970,173 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })}
</p>
</div>
{/* Theme Sharing Section */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.share.label' })}
</h3>
<div className="flex gap-2">
{/* Copy Theme Code button */}
<button
onClick={handleCopyThemeCode}
className="
flex-1 px-3 py-2 rounded-lg text-sm font-medium
border-2 border-border bg-bg text-text
hover:bg-surface transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{copyFeedback
? formatMessage({ id: 'theme.share.copied' })
: formatMessage({ id: 'theme.share.copyCode' })
}
</button>
{/* Import Theme button */}
<button
onClick={showImportPanel ? handleCloseImport : handleOpenImport}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium
transition-all duration-200 border-2
${showImportPanel
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg text-text hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: 'theme.share.import' })}
</button>
</div>
{/* Import Panel */}
{showImportPanel && (
<div className="mt-3 space-y-3">
{/* Paste textarea */}
<textarea
value={importCode}
onChange={(e) => handleImportCodeChange(e.target.value)}
placeholder={formatMessage({ id: 'theme.share.paste' })}
rows={3}
className="
w-full px-3 py-2 rounded-lg text-sm font-mono
bg-bg border-2 border-border text-text
placeholder-text-tertiary resize-none
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
"
/>
{/* Error message */}
{importError && (
<div className="p-2 rounded-lg bg-error-light border border-error text-error-text text-xs">
{formatMessage({ id: getShareErrorMessageId(importError) })}
</div>
)}
{/* Version warning */}
{importWarning && !importError && (
<div className="p-2 rounded-lg bg-warning-light border border-warning text-warning-text text-xs">
{formatMessage({ id: 'theme.share.versionWarning' })}
</div>
)}
{/* Import Preview Card */}
{importPreview && !importError && (
<div className="p-3 rounded-lg bg-surface border border-border space-y-3">
<p className="text-xs font-medium text-text">
{formatMessage({ id: 'theme.share.preview' })}
</p>
{/* Preview swatches */}
<div className="flex gap-3 items-end">
{(() => {
const previewColors = getImportPreviewColors(importPreview);
return (
<>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.bg }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.background' })}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.surface }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.surface' })}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.accent }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.accent' })}
</span>
</div>
</>
);
})()}
</div>
{/* Settings summary */}
<div className="text-xs text-text-secondary space-y-1">
<p>
{formatMessage({ id: 'theme.styleTier.label' })}: {formatMessage({ id: `theme.styleTier.${importPreview.styleTier === 'high-contrast' ? 'highContrast' : importPreview.styleTier}` })}
</p>
<p>
{formatMessage({ id: 'theme.gradient.title' })}: {formatMessage({ id: `theme.gradient.${importPreview.gradientLevel}` })}
</p>
{importPreview.customHue !== null && (
<p>
{formatMessage({ id: 'theme.hueValue' }, { value: importPreview.customHue })}
</p>
)}
{importPreview.customHue === null && (
<p>
{formatMessage({ id: 'theme.title.colorScheme' })}: {formatMessage({ id: `theme.colorScheme.${importPreview.colorScheme}` })}
</p>
)}
</div>
{/* Apply / Cancel buttons */}
<div className="flex gap-2 pt-1">
<button
onClick={handleApplyImport}
className="
flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-accent text-white hover:bg-accent-hover
transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{formatMessage({ id: 'theme.share.apply' })}
</button>
<button
onClick={handleCloseImport}
className="
px-4 py-2 rounded-lg text-sm font-medium
border-2 border-border bg-bg text-text
hover:bg-surface transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{formatMessage({ id: 'theme.share.cancel' })}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -23,6 +23,8 @@ const badgeVariants = cva(
"border-transparent bg-info text-white",
review:
"border-transparent bg-purple-600 text-white",
gradient:
"border-transparent bg-gradient-brand bg-primary text-primary-foreground",
},
},
defaultVariants: {