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: {

View File

@@ -56,11 +56,16 @@ import {
type CodexLensWorkspaceStatus,
type CodexLensSearchParams,
type CodexLensSearchResponse,
type CodexLensFileSearchResponse,
type CodexLensSymbolSearchResponse,
type CodexLensIndexesResponse,
type CodexLensIndexingStatusResponse,
type CodexLensSemanticInstallResponse,
type CodexLensWatcherStatusResponse,
type CodexLensLspStatusResponse,
type CodexLensSemanticSearchParams,
type CodexLensSemanticSearchResponse,
fetchCodexLensLspStatus,
semanticSearchCodexLens,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -83,6 +88,8 @@ export const codexLensKeys = {
search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const,
filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const,
symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const,
lspStatus: () => [...codexLensKeys.all, 'lspStatus'] as const,
semanticSearch: (params: CodexLensSemanticSearchParams) => [...codexLensKeys.all, 'semanticSearch', params] as const,
watcher: () => [...codexLensKeys.all, 'watcher'] as const,
};
@@ -1288,10 +1295,18 @@ export function useCodexLensSearch(params: CodexLensSearchParams, options: UseCo
};
}
export interface UseCodexLensFileSearchReturn {
data: CodexLensFileSearchResponse | undefined;
files: string[] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for file search using CodexLens
*/
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensSearchReturn {
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensFileSearchReturn {
const { enabled = false } = options;
const query = useQuery({
@@ -1308,7 +1323,7 @@ export function useCodexLensFilesSearch(params: CodexLensSearchParams, options:
return {
data: query.data,
results: query.data?.results,
files: query.data?.files,
isLoading: query.isLoading,
error: query.error,
refetch,
@@ -1357,6 +1372,98 @@ export function useCodexLensSymbolSearch(
};
}
// ========== LSP / Semantic Search Hooks ==========
export interface UseCodexLensLspStatusOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensLspStatusReturn {
data: CodexLensLspStatusResponse | undefined;
available: boolean;
semanticAvailable: boolean;
vectorIndex: boolean;
modes: string[];
strategies: string[];
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for checking CodexLens LSP/semantic search availability
*/
export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {}): UseCodexLensLspStatusReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.lspStatus(),
queryFn: fetchCodexLensLspStatus,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
available: query.data?.available ?? false,
semanticAvailable: query.data?.semantic_available ?? false,
vectorIndex: query.data?.vector_index ?? false,
modes: query.data?.modes ?? [],
strategies: query.data?.strategies ?? [],
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensSemanticSearchOptions {
enabled?: boolean;
}
export interface UseCodexLensSemanticSearchReturn {
data: CodexLensSemanticSearchResponse | undefined;
results: CodexLensSemanticSearchResponse['results'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for semantic search using CodexLens Python API
*/
export function useCodexLensSemanticSearch(
params: CodexLensSemanticSearchParams,
options: UseCodexLensSemanticSearchOptions = {}
): UseCodexLensSemanticSearchReturn {
const { enabled = false } = options;
const query = useQuery({
queryKey: codexLensKeys.semanticSearch(params),
queryFn: () => semanticSearchCodexLens(params),
enabled,
staleTime: STALE_TIME_SHORT,
retry: 1,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
results: query.data?.results,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
// ========== File Watcher Hooks ==========
export interface UseCodexLensWatcherOptions {

View File

@@ -3,7 +3,7 @@
// ========================================
// Convenient hook for theme management with multi-color scheme support
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
useAppStore,
selectTheme,
@@ -13,8 +13,16 @@ import {
selectGradientLevel,
selectEnableHoverGlow,
selectEnableBackgroundAnimation,
selectMotionPreference,
selectThemeSlots,
selectActiveSlotId,
selectDeletedSlotBuffer,
} from '../stores/appStore';
import type { Theme, ColorScheme, GradientLevel } from '../types/store';
import type { Theme, ColorScheme, GradientLevel, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { resolveMotionPreference } from '../lib/accessibility';
import { THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
import { encodeTheme, decodeTheme } from '../lib/themeShare';
import type { ImportResult } from '../lib/themeShare';
export interface UseThemeReturn {
/** Current theme preference ('light', 'dark', 'system') */
@@ -35,6 +43,10 @@ export interface UseThemeReturn {
enableHoverGlow: boolean;
/** Whether background gradient animation is enabled */
enableBackgroundAnimation: boolean;
/** User's motion preference setting */
motionPreference: MotionPreference;
/** Resolved motion preference (true = reduce motion) */
resolvedMotion: boolean;
/** Set theme preference */
setTheme: (theme: Theme) => void;
/** Set color scheme */
@@ -49,6 +61,46 @@ export interface UseThemeReturn {
setEnableHoverGlow: (enabled: boolean) => void;
/** Set background animation enabled */
setEnableBackgroundAnimation: (enabled: boolean) => void;
/** Set motion preference */
setMotionPreference: (pref: MotionPreference) => void;
/** Current style tier ('soft', 'standard', 'high-contrast') */
styleTier: StyleTier;
/** Set style tier */
setStyleTier: (tier: StyleTier) => void;
/** All theme slots */
themeSlots: ThemeSlot[];
/** Currently active slot ID */
activeSlotId: ThemeSlotId;
/** Currently active slot object */
activeSlot: ThemeSlot | undefined;
/** Buffer holding recently deleted slot for undo */
deletedSlotBuffer: ThemeSlot | null;
/** Whether user can add more slots (below THEME_SLOT_LIMIT) */
canAddSlot: boolean;
/** Switch to a different theme slot */
setActiveSlot: (slotId: ThemeSlotId) => void;
/** Copy current slot to a new slot */
copySlot: () => void;
/** Rename a slot */
renameSlot: (slotId: ThemeSlotId, name: string) => void;
/** Delete a slot (moves to deletedSlotBuffer for undo) */
deleteSlot: (slotId: ThemeSlotId) => void;
/** Undo the last slot deletion */
undoDeleteSlot: () => void;
/** Export current active slot as a shareable theme code string */
exportThemeCode: () => string;
/** Decode and validate an imported theme code string */
importThemeCode: (code: string) => ImportResult;
/** Current background configuration for the active slot */
backgroundConfig: BackgroundConfig;
/** Set full background config */
setBackgroundConfig: (config: BackgroundConfig) => void;
/** Update a single background effect property */
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => void;
/** Set background mode */
setBackgroundMode: (mode: BackgroundMode) => void;
/** Set background image URL and attribution */
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
}
/**
@@ -78,6 +130,7 @@ export function useTheme(): UseThemeReturn {
const gradientLevel = useAppStore(selectGradientLevel);
const enableHoverGlow = useAppStore(selectEnableHoverGlow);
const enableBackgroundAnimation = useAppStore(selectEnableBackgroundAnimation);
const motionPreference = useAppStore(selectMotionPreference);
const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
const setCustomHueAction = useAppStore((state) => state.setCustomHue);
@@ -85,6 +138,26 @@ export function useTheme(): UseThemeReturn {
const setGradientLevelAction = useAppStore((state) => state.setGradientLevel);
const setEnableHoverGlowAction = useAppStore((state) => state.setEnableHoverGlow);
const setEnableBackgroundAnimationAction = useAppStore((state) => state.setEnableBackgroundAnimation);
const setMotionPreferenceAction = useAppStore((state) => state.setMotionPreference);
const setStyleTierAction = useAppStore((state) => state.setStyleTier);
// Slot state
const themeSlots = useAppStore(selectThemeSlots);
const activeSlotId = useAppStore(selectActiveSlotId);
const deletedSlotBuffer = useAppStore(selectDeletedSlotBuffer);
// Background actions
const setBackgroundConfigAction = useAppStore((state) => state.setBackgroundConfig);
const updateBackgroundEffectAction = useAppStore((state) => state.updateBackgroundEffect);
const setBackgroundModeAction = useAppStore((state) => state.setBackgroundMode);
const setBackgroundImageAction = useAppStore((state) => state.setBackgroundImage);
// Slot actions
const setActiveSlotAction = useAppStore((state) => state.setActiveSlot);
const copySlotAction = useAppStore((state) => state.copySlot);
const renameSlotAction = useAppStore((state) => state.renameSlot);
const deleteSlotAction = useAppStore((state) => state.deleteSlot);
const undoDeleteSlotAction = useAppStore((state) => state.undoDeleteSlot);
const setTheme = useCallback(
(newTheme: Theme) => {
@@ -132,6 +205,85 @@ export function useTheme(): UseThemeReturn {
[setEnableBackgroundAnimationAction]
);
const setMotionPreference = useCallback(
(pref: MotionPreference) => {
setMotionPreferenceAction(pref);
},
[setMotionPreferenceAction]
);
const setStyleTier = useCallback(
(tier: StyleTier) => {
setStyleTierAction(tier);
},
[setStyleTierAction]
);
const resolvedMotion = resolveMotionPreference(motionPreference);
// Slot computed values
const activeSlot = useMemo(
() => themeSlots.find(s => s.id === activeSlotId),
[themeSlots, activeSlotId]
);
const canAddSlot = themeSlots.length < THEME_SLOT_LIMIT;
const styleTier = activeSlot?.styleTier ?? 'standard';
const backgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
// Slot callbacks
const setActiveSlot = useCallback(
(slotId: ThemeSlotId) => {
setActiveSlotAction(slotId);
},
[setActiveSlotAction]
);
const copySlot = useCallback(() => {
copySlotAction();
}, [copySlotAction]);
const renameSlot = useCallback(
(slotId: ThemeSlotId, name: string) => {
renameSlotAction(slotId, name);
},
[renameSlotAction]
);
const deleteSlot = useCallback(
(slotId: ThemeSlotId) => {
deleteSlotAction(slotId);
},
[deleteSlotAction]
);
const undoDeleteSlot = useCallback(() => {
undoDeleteSlotAction();
}, [undoDeleteSlotAction]);
const exportThemeCode = useCallback((): string => {
if (!activeSlot) {
// Fallback: build a minimal slot from current state
const fallbackSlot: ThemeSlot = {
id: activeSlotId,
name: '',
colorScheme,
customHue,
isCustomTheme,
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
styleTier,
isDefault: false,
};
return encodeTheme(fallbackSlot);
}
return encodeTheme(activeSlot);
}, [activeSlot, activeSlotId, colorScheme, customHue, isCustomTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation, styleTier]);
const importThemeCode = useCallback((code: string): ImportResult => {
return decodeTheme(code);
}, []);
return {
theme,
resolvedTheme,
@@ -142,6 +294,8 @@ export function useTheme(): UseThemeReturn {
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
motionPreference,
resolvedMotion,
setTheme,
setColorScheme,
setCustomHue,
@@ -149,5 +303,25 @@ export function useTheme(): UseThemeReturn {
setGradientLevel,
setEnableHoverGlow,
setEnableBackgroundAnimation,
setMotionPreference,
styleTier,
setStyleTier,
themeSlots,
activeSlotId,
activeSlot,
deletedSlotBuffer,
canAddSlot,
setActiveSlot,
copySlot,
renameSlot,
deleteSlot,
undoDeleteSlot,
exportThemeCode,
importThemeCode,
backgroundConfig,
setBackgroundConfig: setBackgroundConfigAction,
updateBackgroundEffect: updateBackgroundEffectAction,
setBackgroundMode: setBackgroundModeAction,
setBackgroundImage: setBackgroundImageAction,
};
}

View File

@@ -0,0 +1,26 @@
/**
* React Query hook for searching Unsplash photos with debounce.
*/
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { searchUnsplash } from '@/lib/unsplash';
import type { UnsplashSearchResult } from '@/lib/unsplash';
export function useUnsplashSearch(query: string, page = 1, perPage = 20) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
return useQuery<UnsplashSearchResult>({
queryKey: ['unsplash-search', debouncedQuery, page, perPage],
queryFn: () => searchUnsplash(debouncedQuery, page, perPage),
enabled: debouncedQuery.trim().length > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}

View File

@@ -340,6 +340,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Schedule reconnection with exponential backoff
// Define this first to avoid circular dependency
const scheduleReconnect = useCallback(() => {
// Don't reconnect after unmount
if (!mountedRef.current) return;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
@@ -363,7 +366,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
}, []); // No dependencies - uses connectRef and getStoreState()
const connect = useCallback(() => {
if (!enabled) return;
if (!enabled || !mountedRef.current) return;
// Close existing connection to avoid orphaned sockets
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering reconnect
wsRef.current.close();
wsRef.current = null;
}
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -430,6 +440,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Connect on mount, cleanup on unmount
useEffect(() => {
// Reset mounted flag (needed after React Strict Mode remount)
mountedRef.current = true;
if (enabled) {
connect();
}
@@ -455,6 +468,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering orphaned reconnect
wsRef.current.close();
wsRef.current = null;
}

View File

@@ -454,6 +454,10 @@
-webkit-text-fill-color: inherit;
}
[data-gradient="off"] .border-gradient-brand::before {
display: none;
}
/* Standard gradients (default) */
[data-gradient="standard"] .bg-gradient-primary {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
@@ -467,6 +471,13 @@
background: linear-gradient(90deg, hsl(var(--accent)) 0%, hsl(var(--primary)) 100%);
}
[data-gradient="standard"] .gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Enhanced gradients - more vibrant with multiple color stops */
[data-gradient="enhanced"] .bg-gradient-primary {
background: linear-gradient(135deg,
@@ -492,6 +503,17 @@
);
}
[data-gradient="enhanced"] .gradient-text {
background: linear-gradient(135deg,
hsl(var(--primary)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--secondary)) 100%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Hover glow effects - disabled when data-hover-glow="false" */
.hover-glow,
.hover-glow-primary {
@@ -581,3 +603,144 @@
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-2%, -2%); }
}
/* ===========================
Reduced Motion System
data-reduced-motion attribute + prefers-reduced-motion fallback
=========================== */
/* Disable View Transition animations when reduced motion is active */
[data-reduced-motion="true"]::view-transition-old(*),
[data-reduced-motion="true"]::view-transition-new(*),
[data-reduced-motion="true"]::view-transition-group(*) {
animation-duration: 0s !important;
}
/* Disable background gradient animation */
[data-reduced-motion="true"] .animate-slow-gradient {
animation: none;
background-size: 100% 100%;
}
/* Disable hover glow pulse */
[data-reduced-motion="true"] .hover-glow:hover,
[data-reduced-motion="true"] .hover-glow-primary:hover {
box-shadow: none;
}
/* Disable ambient gradient shift animation */
[data-reduced-motion="true"] body::before {
animation: none !important;
}
/* Reduce gradient opacity for enhanced mode */
[data-reduced-motion="true"][data-gradient="enhanced"] body::before {
opacity: 0.5;
}
/* OS-level prefers-reduced-motion fallback (applies when data attr not set) */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
}
.animate-slow-gradient {
animation: none;
background-size: 100% 100%;
}
.hover-glow:hover,
.hover-glow-primary:hover {
box-shadow: none;
}
body::before {
animation: none !important;
}
[data-gradient="enhanced"] body::before {
opacity: 0.5;
}
}
/* ===========================
Background Image System
Layered rendering with effects
=========================== */
/* Hide gradient layer when image-only mode */
[data-bg-mode="image-only"] body::before {
display: none;
}
/* Reduce gradient opacity when overlaying on image */
[data-bg-mode="image-gradient"] body::before {
opacity: 0.6;
}
/* Background image layer */
.bg-image-layer {
position: fixed;
inset: 0;
z-index: -3;
overflow: hidden;
transition: opacity 0.5s ease;
}
.bg-image-layer img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Darken overlay */
.bg-darken-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
}
/* Grain texture overlay */
.bg-grain-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
opacity: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
[data-bg-grain="true"] .bg-grain-overlay {
opacity: 0.08;
}
/* Vignette overlay */
.bg-vignette-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
}
[data-bg-vignette="true"] .bg-vignette-overlay {
opacity: 1;
}
/* Frosted glass effect on content area */
[data-bg-frosted="true"] .app-shell-content {
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
background-color: hsla(var(--bg), 0.75);
}
/* Disable image layer transitions when reduced motion is active */
[data-reduced-motion="true"] .bg-image-layer {
transition: none !important;
}

View File

@@ -0,0 +1,336 @@
// ========================================
// Accessibility Utilities
// ========================================
// WCAG 2.1 contrast checking and motion preference management
// for the theme system. Operates on HSL 'H S% L%' format strings.
// ========== Types ==========
/** User preference for animation behavior */
export type MotionPreference = 'system' | 'reduce' | 'enable';
/** Result of evaluating one critical color pair against WCAG thresholds */
export interface ContrastResult {
/** Foreground CSS variable name (e.g. '--text') */
fgVar: string;
/** Background CSS variable name (e.g. '--bg') */
bgVar: string;
/** Computed contrast ratio (e.g. 4.52) */
ratio: number;
/** Required minimum contrast ratio for this pair */
required: number;
/** Whether the pair passes WCAG AA */
passed: boolean;
}
/** A single suggested fix to improve contrast, with visual distance metric */
export interface FixSuggestion {
/** Which variable to adjust ('fg' or 'bg') */
target: 'fg' | 'bg';
/** Original HSL value */
original: string;
/** Suggested replacement HSL value */
suggested: string;
/** Resulting contrast ratio after applying the fix */
resultRatio: number;
/** Visual distance from original (lower = less visible change) */
distance: number;
}
// ========== Critical Color Pairs ==========
/**
* Whitelist of critical UI color pairs to check for WCAG AA compliance.
* Each entry: [foreground variable, background variable, required ratio]
*
* - 4.5:1 for normal text (WCAG AA)
* - 3.0:1 for large text / UI components (WCAG AA)
*/
export const CRITICAL_COLOR_PAIRS: ReadonlyArray<[string, string, number]> = [
// Text on backgrounds (4.5:1 - normal text)
['--text', '--bg', 4.5],
['--text', '--surface', 4.5],
['--text-secondary', '--bg', 4.5],
['--text-secondary', '--surface', 4.5],
['--muted-text', '--muted', 4.5],
['--muted-text', '--bg', 4.5],
// Tertiary/disabled text (3:1 - large text threshold)
['--text-tertiary', '--bg', 3.0],
['--text-disabled', '--bg', 3.0],
// Accent/interactive on backgrounds (3:1 - UI component threshold)
['--accent', '--bg', 3.0],
['--accent', '--surface', 3.0],
// Semantic text on semantic light backgrounds (4.5:1)
['--success-text', '--success-light', 4.5],
['--warning-text', '--warning-light', 4.5],
['--error-text', '--error-light', 4.5],
['--info-text', '--info-light', 4.5],
// Foreground on primary/destructive buttons (4.5:1)
['--primary-foreground', '--primary', 4.5],
['--destructive-foreground', '--destructive', 4.5],
// Text on muted surface (4.5:1)
['--text', '--muted', 4.5],
];
// ========== HSL Parsing and Color Conversion ==========
/**
* Parse 'H S% L%' format HSL string into numeric [h, s, l] values.
* h in degrees (0-360), s and l as fractions (0-1).
*/
function parseHSL(hslString: string): [number, number, number] | null {
const trimmed = hslString.trim();
// Match patterns: "220 60% 65%" or "220 60% 65"
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]) / 100;
const l = parseFloat(match[3]) / 100;
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return [h, s, l];
}
/**
* Convert HSL values to linear sRGB [R, G, B] in 0-1 range.
* Then apply sRGB linearization per WCAG 2.1 spec.
*/
function hslToLinearRGB(h: number, s: number, l: number): [number, number, number] {
// HSL to sRGB conversion
const c = (1 - Math.abs(2 * l - 1)) * s;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = l - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) { r1 = c; g1 = x; b1 = 0; }
else if (hPrime < 2) { r1 = x; g1 = c; b1 = 0; }
else if (hPrime < 3) { r1 = 0; g1 = c; b1 = x; }
else if (hPrime < 4) { r1 = 0; g1 = x; b1 = c; }
else if (hPrime < 5) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
const rSRGB = r1 + m;
const gSRGB = g1 + m;
const bSRGB = b1 + m;
// sRGB linearization per WCAG 2.1
const linearize = (v: number): number => {
if (v <= 0.04045) return v / 12.92;
return Math.pow((v + 0.055) / 1.055, 2.4);
};
return [linearize(rSRGB), linearize(gSRGB), linearize(bSRGB)];
}
/**
* Parse 'H S% L%' format HSL string, convert to sRGB,
* compute WCAG 2.1 relative luminance.
*
* Formula: L = 0.2126*R + 0.7152*G + 0.0722*B
* with sRGB linearization applied.
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Relative luminance (0-1), or -1 if parsing fails
*/
export function hslToRelativeLuminance(hslString: string): number {
const parsed = parseHSL(hslString);
if (!parsed) return -1;
const [h, s, l] = parsed;
const [rLin, gLin, bLin] = hslToLinearRGB(h, s, l);
// Round to 4-decimal precision to avoid floating-point edge cases
return Math.round((0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin) * 10000) / 10000;
}
/**
* Compute WCAG contrast ratio from two relative luminance values.
* Returns ratio in format X:1 (just the X number).
*
* @param l1 - Relative luminance of first color
* @param l2 - Relative luminance of second color
* @returns Contrast ratio (always >= 1)
*/
export function getContrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
}
// ========== Theme Contrast Checking ==========
/**
* Evaluate all critical color pairs in a generated theme against WCAG AA thresholds.
* Only checks pairs where both variables exist in the provided vars map.
*
* @param vars - Record of CSS variable names to HSL values (e.g. from generateThemeFromHue)
* @returns Array of ContrastResult for each evaluated pair
*/
export function checkThemeContrast(vars: Record<string, string>): ContrastResult[] {
const results: ContrastResult[] = [];
for (const [fgVar, bgVar, required] of CRITICAL_COLOR_PAIRS) {
const fgValue = vars[fgVar];
const bgValue = vars[bgVar];
if (!fgValue || !bgValue) continue;
const fgLum = hslToRelativeLuminance(fgValue);
const bgLum = hslToRelativeLuminance(bgValue);
if (fgLum < 0 || bgLum < 0) continue;
const ratio = getContrastRatio(fgLum, bgLum);
// Use 0.01 tolerance buffer for borderline cases
const passed = ratio >= (required - 0.01);
results.push({ fgVar, bgVar, ratio, required, passed });
}
return results;
}
// ========== Contrast Fix Generation ==========
/**
* Reconstruct 'H S% L%' string from components.
*/
function toHSLString(h: number, s: number, l: number): string {
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Generate 2-3 lightness-adjusted alternatives that achieve target contrast ratio.
* Preserves hue and saturation, only adjusts lightness.
* Sorted by minimal visual change (distance).
*
* @param fgVar - Foreground CSS variable name
* @param bgVar - Background CSS variable name
* @param currentVars - Current theme variable values
* @param targetRatio - Target contrast ratio to achieve
* @returns Array of 2-3 FixSuggestion sorted by distance (ascending)
*/
export function generateContrastFix(
fgVar: string,
bgVar: string,
currentVars: Record<string, string>,
targetRatio: number
): FixSuggestion[] {
const fgValue = currentVars[fgVar];
const bgValue = currentVars[bgVar];
if (!fgValue || !bgValue) return [];
const fgParsed = parseHSL(fgValue);
const bgParsed = parseHSL(bgValue);
if (!fgParsed || !bgParsed) return [];
const suggestions: FixSuggestion[] = [];
// Strategy 1: Adjust foreground lightness (darken or lighten)
const bgLum = hslToRelativeLuminance(bgValue);
const fgSuggestions = findLightnessForContrast(
fgParsed[0], fgParsed[1], fgParsed[2], bgLum, targetRatio
);
for (const newL of fgSuggestions) {
const suggested = toHSLString(fgParsed[0], fgParsed[1] * 100, newL * 100);
const newFgLum = hslToRelativeLuminance(suggested);
if (newFgLum < 0) continue;
const resultRatio = getContrastRatio(newFgLum, bgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'fg',
original: fgValue,
suggested,
resultRatio,
distance: Math.abs(newL - fgParsed[2]),
});
}
}
// Strategy 2: Adjust background lightness
const fgLum = hslToRelativeLuminance(fgValue);
const bgSuggestions = findLightnessForContrast(
bgParsed[0], bgParsed[1], bgParsed[2], fgLum, targetRatio
);
for (const newL of bgSuggestions) {
const suggested = toHSLString(bgParsed[0], bgParsed[1] * 100, newL * 100);
const newBgLum = hslToRelativeLuminance(suggested);
if (newBgLum < 0) continue;
const resultRatio = getContrastRatio(fgLum, newBgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'bg',
original: bgValue,
suggested,
resultRatio,
distance: Math.abs(newL - bgParsed[2]),
});
}
}
// Sort by distance (minimal visual change first) and take up to 3
suggestions.sort((a, b) => a.distance - b.distance);
return suggestions.slice(0, 3);
}
/**
* Find lightness values that achieve target contrast against a reference luminance.
* Searches in both lighter and darker directions from current lightness.
* Returns up to 2 candidates (one lighter, one darker if found).
*/
function findLightnessForContrast(
h: number,
s: number,
currentL: number,
refLum: number,
targetRatio: number
): number[] {
const candidates: number[] = [];
const step = 0.01;
// Search darker direction (decreasing lightness)
for (let l = currentL - step; l >= 0; l -= step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
// Search lighter direction (increasing lightness)
for (let l = currentL + step; l <= 1; l += step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
return candidates;
}
// ========== Motion Preference ==========
/**
* Resolve user preference to actual reduced-motion boolean.
* 'system' checks matchMedia, 'reduce' returns true, 'enable' returns false.
*
* @param pref - User's motion preference setting
* @returns true if motion should be reduced, false otherwise
*/
export function resolveMotionPreference(pref: MotionPreference): boolean {
if (pref === 'reduce') return true;
if (pref === 'enable') return false;
// 'system' - check OS preference
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

View File

@@ -3239,7 +3239,7 @@ function buildCcwMcpServerConfig(config: {
if (config.enabledTools && config.enabledTools.length > 0) {
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
} else {
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question';
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
}
if (config.projectRoot) {
@@ -3352,7 +3352,7 @@ export async function installCcwMcp(
projectPath?: string
): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfig({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
});
if (scope === 'project' && projectPath) {
@@ -3853,7 +3853,7 @@ export interface CodexLensGpuListResponse {
}
/**
* Model info
* Model info (normalized from CLI output)
*/
export interface CodexLensModel {
profile: string;
@@ -3863,10 +3863,26 @@ export interface CodexLensModel {
size?: string;
installed: boolean;
cache_path?: string;
/** Original HuggingFace model name */
model_name?: string;
/** Model description */
description?: string;
/** Use case description */
use_case?: string;
/** Embedding dimensions */
dimensions?: number;
/** Whether this model is recommended */
recommended?: boolean;
/** Model source: 'predefined' | 'discovered' */
source?: string;
/** Estimated size in MB */
estimated_size_mb?: number;
/** Actual size in MB (when installed) */
actual_size_mb?: number | null;
}
/**
* Model list response
* Model list response (normalized)
*/
export interface CodexLensModelsResponse {
success: boolean;
@@ -4067,9 +4083,43 @@ export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse>
/**
* Fetch CodexLens models list
* Normalizes the CLI response format to match the frontend interface.
* CLI returns: { success, result: { models: [{ model_name, estimated_size_mb, ... }] } }
* Frontend expects: { success, models: [{ name, size, type, backend, ... }] }
*/
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = await fetchApi<any>('/api/codexlens/models');
// Handle nested result structure from CLI
const rawModels = raw?.result?.models ?? raw?.models ?? [];
const models: CodexLensModel[] = rawModels.map((m: Record<string, unknown>) => ({
profile: (m.profile as string) || '',
name: (m.model_name as string) || (m.name as string) || (m.profile as string) || '',
type: (m.type as 'embedding' | 'reranker') || 'embedding',
backend: (m.source as string) || 'fastembed',
size: m.installed && m.actual_size_mb
? `${(m.actual_size_mb as number).toFixed(0)} MB`
: m.estimated_size_mb
? `~${m.estimated_size_mb} MB`
: undefined,
installed: (m.installed as boolean) ?? false,
cache_path: m.cache_path as string | undefined,
model_name: m.model_name as string | undefined,
description: m.description as string | undefined,
use_case: m.use_case as string | undefined,
dimensions: m.dimensions as number | undefined,
recommended: m.recommended as boolean | undefined,
source: m.source as string | undefined,
estimated_size_mb: m.estimated_size_mb as number | undefined,
actual_size_mb: m.actual_size_mb as number | null | undefined,
}));
return {
success: raw?.success ?? true,
models,
};
}
/**
@@ -4240,6 +4290,17 @@ export interface CodexLensSymbolSearchResponse {
error?: string;
}
/**
* CodexLens file search response (returns file paths only)
*/
export interface CodexLensFileSearchResponse {
success: boolean;
query?: string;
count?: number;
files: string[];
error?: string;
}
/**
* Perform content search using CodexLens
*/
@@ -4257,7 +4318,7 @@ export async function searchCodexLens(params: CodexLensSearchParams): Promise<Co
/**
* Perform file search using CodexLens
*/
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> {
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensFileSearchResponse> {
const queryParams = new URLSearchParams();
queryParams.append('query', params.query);
if (params.limit) queryParams.append('limit', String(params.limit));
@@ -4265,7 +4326,7 @@ export async function searchFilesCodexLens(params: CodexLensSearchParams): Promi
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
return fetchApi<CodexLensFileSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
}
/**
@@ -4279,6 +4340,84 @@ export async function searchSymbolCodexLens(params: Pick<CodexLensSearchParams,
return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`);
}
// ========== CodexLens LSP / Semantic Search API ==========
/**
* CodexLens LSP status response
*/
export interface CodexLensLspStatusResponse {
available: boolean;
semantic_available: boolean;
vector_index: boolean;
project_count?: number;
embeddings?: Record<string, unknown>;
modes?: string[];
strategies?: string[];
error?: string;
}
/**
* CodexLens semantic search params (Python API)
*/
export type CodexLensSemanticSearchMode = 'fusion' | 'vector' | 'structural';
export type CodexLensFusionStrategy = 'rrf' | 'staged' | 'binary' | 'hybrid' | 'dense_rerank';
export interface CodexLensSemanticSearchParams {
query: string;
path?: string;
mode?: CodexLensSemanticSearchMode;
fusion_strategy?: CodexLensFusionStrategy;
vector_weight?: number;
structural_weight?: number;
keyword_weight?: number;
kind_filter?: string[];
limit?: number;
include_match_reason?: boolean;
}
/**
* CodexLens semantic search result
*/
export interface CodexLensSemanticSearchResult {
name?: string;
kind?: string;
file_path?: string;
score?: number;
match_reason?: string;
range?: { start_line: number; end_line: number };
[key: string]: unknown;
}
/**
* CodexLens semantic search response
*/
export interface CodexLensSemanticSearchResponse {
success: boolean;
results?: CodexLensSemanticSearchResult[];
query?: string;
mode?: string;
fusion_strategy?: string;
count?: number;
error?: string;
}
/**
* Fetch CodexLens LSP status
*/
export async function fetchCodexLensLspStatus(): Promise<CodexLensLspStatusResponse> {
return fetchApi<CodexLensLspStatusResponse>('/api/codexlens/lsp/status');
}
/**
* Perform semantic search using CodexLens Python API
*/
export async function semanticSearchCodexLens(params: CodexLensSemanticSearchParams): Promise<CodexLensSemanticSearchResponse> {
return fetchApi<CodexLensSemanticSearchResponse>('/api/codexlens/lsp/search', {
method: 'POST',
body: JSON.stringify(params),
});
}
// ========== CodexLens Index Management API ==========
/**

View File

@@ -10,6 +10,126 @@
* @module colorGenerator
*/
import type { StyleTier } from '../types/store';
// ========== Style Tier System ==========
/** Per-tier adjustment factors for saturation, lightness, and contrast */
export interface StyleTierCoefficients {
saturationScale: number;
lightnessOffset: { light: number; dark: number };
contrastBoost: number;
}
/**
* Style tier coefficient definitions.
* - soft: reduced saturation, lighter feel, lower contrast
* - standard: identity transform (no change)
* - high-contrast: boosted saturation, sharper text/background separation
*/
export const STYLE_TIER_COEFFICIENTS: Record<StyleTier, StyleTierCoefficients> = {
soft: {
saturationScale: 0.6,
lightnessOffset: { light: 5, dark: -3 },
contrastBoost: 0.9,
},
standard: {
saturationScale: 1.0,
lightnessOffset: { light: 0, dark: 0 },
contrastBoost: 1.0,
},
'high-contrast': {
saturationScale: 1.3,
lightnessOffset: { light: -5, dark: 3 },
contrastBoost: 1.2,
},
};
/**
* Parse 'H S% L%' format HSL string into numeric components.
* H in degrees (0-360), S and L as percentages (0-100).
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Parsed components or null if parsing fails
*/
export function parseHSL(hslString: string): { h: number; s: number; l: number } | null {
const trimmed = hslString.trim();
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]);
const l = parseFloat(match[3]);
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return { h, s, l };
}
/**
* Format numeric HSL values back to 'H S% L%' string.
* Values are clamped to valid ranges.
*
* @param h - Hue in degrees (0-360)
* @param s - Saturation as percentage (0-100)
* @param l - Lightness as percentage (0-100)
* @returns Formatted HSL string
*/
export function formatHSL(h: number, s: number, l: number): string {
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Apply style tier coefficients to a set of CSS variable values.
* Adjusts saturation and lightness per tier, preserving hue.
*
* Processing pipeline per variable:
* 1. Scale saturation: s * saturationScale
* 2. Apply contrast boost: stretch lightness from midpoint (50%)
* 3. Apply lightness offset (mode-specific)
* 4. Clamp to valid ranges
*
* Standard tier is an identity transform (returns input unchanged).
*
* @param vars - Record of CSS variable names to HSL values in 'H S% L%' format
* @param tier - Style tier to apply
* @param mode - Current theme mode
* @returns Modified CSS variables record
*/
export function applyStyleTier(
vars: Record<string, string>,
tier: StyleTier,
mode: 'light' | 'dark'
): Record<string, string> {
if (tier === 'standard') return vars;
const coeffs = STYLE_TIER_COEFFICIENTS[tier];
const offset = mode === 'light' ? coeffs.lightnessOffset.light : coeffs.lightnessOffset.dark;
const result: Record<string, string> = {};
for (const [varName, value] of Object.entries(vars)) {
const parsed = parseHSL(value);
if (!parsed) {
result[varName] = value;
continue;
}
// 1. Apply saturation scaling
let s = parsed.s * coeffs.saturationScale;
s = Math.max(0, Math.min(100, s));
// 2. Apply contrast boost (stretch lightness from midpoint)
let l = 50 + (parsed.l - 50) * coeffs.contrastBoost;
// 3. Apply lightness offset
l = l + offset;
l = Math.max(0, Math.min(100, l));
result[varName] = formatHSL(parsed.h, s, l);
}
return result;
}
/**
* Generate a complete theme from a single hue value
*

View File

@@ -3,6 +3,8 @@
* Defines available color schemes and theme modes for the CCW application
*/
import type { ThemeSlot, ThemeSlotId, BackgroundEffects, BackgroundConfig } from '../types/store';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type ThemeMode = 'light' | 'dark';
export type ThemeId = `${ThemeMode}-${ColorScheme}`;
@@ -112,3 +114,62 @@ export const DEFAULT_THEME: Theme = {
mode: 'light',
name: '经典蓝 · 浅色'
};
// ========== Background Defaults ==========
export const DEFAULT_BACKGROUND_EFFECTS: BackgroundEffects = {
blur: 0,
darkenOpacity: 0,
saturation: 100,
enableFrostedGlass: false,
enableGrain: false,
enableVignette: false,
};
export const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
mode: 'gradient-only',
imageUrl: null,
attribution: null,
effects: DEFAULT_BACKGROUND_EFFECTS,
};
// ========== Theme Slot Constants ==========
/** Maximum number of theme slots a user can have */
export const THEME_SLOT_LIMIT = 3;
/** Default theme slot with preset values */
export const DEFAULT_SLOT: ThemeSlot = {
id: 'default',
name: 'Default',
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: true,
};
/**
* Factory function to create a new empty theme slot with default values.
*
* @param id - Slot identifier
* @param name - Display name for the slot
* @returns A new ThemeSlot with default theme values
*/
export function createEmptySlot(id: ThemeSlotId, name: string): ThemeSlot {
return {
id,
name,
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: false,
};
}

View File

@@ -0,0 +1,327 @@
/**
* Theme Sharing Module
* Encodes/decodes theme configurations as compact base64url strings
* for copy-paste sharing between users.
*
* Format: 'ccw{version}:{base64url_payload}'
* Payload uses short field names for compactness:
* v=version, c=colorScheme, h=customHue, t=styleTier,
* g=gradientLevel, w=enableHoverGlow, a=enableBackgroundAnimation
* bm=backgroundMode, bi=backgroundImageUrl, bp=photographerName,
* bu=photographerUrl, bpu=photoUrl, be=backgroundEffects
*
* @module themeShare
*/
import type { ThemeSlot, BackgroundConfig, BackgroundEffects } from '../types/store';
import type { ColorScheme, GradientLevel, StyleTier, BackgroundMode } from '../types/store';
import { DEFAULT_BACKGROUND_EFFECTS } from './theme';
// ========== Constants ==========
/** Current share format version. Bump when payload schema changes. */
export const SHARE_VERSION = 2;
/** Maximum encoded string length accepted for import */
const MAX_ENCODED_LENGTH = 800;
/** Version prefix pattern: 'ccw' followed by version number and colon */
const PREFIX_PATTERN = /^ccw(\d+):(.+)$/;
// ========== Types ==========
/** Serializable theme state for encoding/decoding */
export interface ThemeSharePayload {
version: number;
colorScheme: ColorScheme;
customHue: number | null;
styleTier: StyleTier;
gradientLevel: GradientLevel;
enableHoverGlow: boolean;
enableBackgroundAnimation: boolean;
backgroundConfig?: BackgroundConfig;
}
/** Compact wire format using short keys for smaller base64 output */
interface CompactPayload {
v: number;
c: string;
h: number | null;
t: string;
g: string;
w: boolean;
a: boolean;
// v2 background fields (optional)
bm?: string;
bi?: string;
bp?: string;
bu?: string;
bpu?: string;
be?: CompactEffects;
}
/** Compact background effects */
interface CompactEffects {
b: number; // blur
d: number; // darkenOpacity
s: number; // saturation
f: boolean; // enableFrostedGlass
g: boolean; // enableGrain
v: boolean; // enableVignette
}
/** Result of decoding and validating an import string */
export type ImportResult =
| { ok: true; payload: ThemeSharePayload; warning?: string }
| { ok: false; error: string };
/** Version compatibility check result */
export interface VersionCheckResult {
compatible: boolean;
warning?: string;
}
// ========== Validation Constants ==========
const VALID_COLOR_SCHEMES: readonly string[] = ['blue', 'green', 'orange', 'purple'];
const VALID_STYLE_TIERS: readonly string[] = ['soft', 'standard', 'high-contrast'];
const VALID_GRADIENT_LEVELS: readonly string[] = ['off', 'standard', 'enhanced'];
const VALID_BACKGROUND_MODES: readonly string[] = ['gradient-only', 'image-only', 'image-gradient'];
// ========== Encoding ==========
/**
* Encode a theme slot into a compact URL-safe base64 string with version prefix.
*
* Output format: 'ccw2:{base64url}'
* The base64url payload contains JSON with short keys for compactness.
* Background fields are only included when mode != gradient-only.
*
* @param slot - Theme slot to encode
* @returns Encoded theme string (typically under 300 characters)
*/
export function encodeTheme(slot: ThemeSlot): string {
const compact: CompactPayload = {
v: SHARE_VERSION,
c: slot.colorScheme,
h: slot.customHue,
t: slot.styleTier,
g: slot.gradientLevel,
w: slot.enableHoverGlow,
a: slot.enableBackgroundAnimation,
};
// Only include background fields when mode != gradient-only
const bg = slot.backgroundConfig;
if (bg && bg.mode !== 'gradient-only') {
compact.bm = bg.mode;
if (bg.imageUrl) compact.bi = bg.imageUrl;
if (bg.attribution) {
compact.bp = bg.attribution.photographerName;
compact.bu = bg.attribution.photographerUrl;
compact.bpu = bg.attribution.photoUrl;
}
compact.be = {
b: bg.effects.blur,
d: bg.effects.darkenOpacity,
s: bg.effects.saturation,
f: bg.effects.enableFrostedGlass,
g: bg.effects.enableGrain,
v: bg.effects.enableVignette,
};
}
const json = JSON.stringify(compact);
// Use TextEncoder for consistent UTF-8 handling
const encoder = new TextEncoder();
const bytes = encoder.encode(json);
// Convert bytes to binary string for btoa
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
// Base64 encode, then make URL-safe
const base64 = btoa(binary);
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `ccw${SHARE_VERSION}:${base64url}`;
}
// ========== Decoding ==========
/**
* Decode and validate a theme share string.
* Checks version compatibility and validates all field types/ranges.
* Invalid input never causes side effects.
* Handles both v1 (no background) and v2 (with background) payloads.
*
* @param encoded - The encoded theme string to decode
* @returns ImportResult with decoded payload on success, or error message on failure
*/
export function decodeTheme(encoded: string): ImportResult {
// Guard: reject empty input
if (!encoded || typeof encoded !== 'string') {
return { ok: false, error: 'empty_input' };
}
const trimmed = encoded.trim();
// Guard: reject strings exceeding max length
if (trimmed.length > MAX_ENCODED_LENGTH) {
return { ok: false, error: 'too_long' };
}
// Extract version and payload from prefix
const prefixMatch = trimmed.match(PREFIX_PATTERN);
if (!prefixMatch) {
return { ok: false, error: 'invalid_format' };
}
const prefixVersion = parseInt(prefixMatch[1], 10);
const base64url = prefixMatch[2];
// Restore standard base64 from URL-safe variant
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const remainder = base64.length % 4;
if (remainder === 2) base64 += '==';
else if (remainder === 3) base64 += '=';
// Decode base64 to bytes
let json: string;
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Use TextDecoder for consistent UTF-8 handling
const decoder = new TextDecoder();
json = decoder.decode(bytes);
} catch {
return { ok: false, error: 'decode_failed' };
}
// Parse JSON
let compact: unknown;
try {
compact = JSON.parse(json);
} catch {
return { ok: false, error: 'parse_failed' };
}
// Validate object shape
if (!compact || typeof compact !== 'object' || Array.isArray(compact)) {
return { ok: false, error: 'invalid_payload' };
}
const obj = compact as Record<string, unknown>;
// Extract and validate version
const payloadVersion = typeof obj.v === 'number' ? obj.v : prefixVersion;
// Check version compatibility
const versionCheck = isVersionCompatible(payloadVersion);
if (!versionCheck.compatible) {
return { ok: false, error: 'incompatible_version' };
}
// Validate colorScheme
if (typeof obj.c !== 'string' || !VALID_COLOR_SCHEMES.includes(obj.c)) {
return { ok: false, error: 'invalid_field' };
}
// Validate customHue
if (obj.h !== null && (typeof obj.h !== 'number' || !isFinite(obj.h))) {
return { ok: false, error: 'invalid_field' };
}
// Validate styleTier
if (typeof obj.t !== 'string' || !VALID_STYLE_TIERS.includes(obj.t)) {
return { ok: false, error: 'invalid_field' };
}
// Validate gradientLevel
if (typeof obj.g !== 'string' || !VALID_GRADIENT_LEVELS.includes(obj.g)) {
return { ok: false, error: 'invalid_field' };
}
// Validate booleans
if (typeof obj.w !== 'boolean' || typeof obj.a !== 'boolean') {
return { ok: false, error: 'invalid_field' };
}
const payload: ThemeSharePayload = {
version: payloadVersion,
colorScheme: obj.c as ColorScheme,
customHue: obj.h as number | null,
styleTier: obj.t as StyleTier,
gradientLevel: obj.g as GradientLevel,
enableHoverGlow: obj.w,
enableBackgroundAnimation: obj.a,
};
// Decode v2 background fields (optional — v1 payloads simply lack them)
if (typeof obj.bm === 'string' && VALID_BACKGROUND_MODES.includes(obj.bm)) {
const effects: BackgroundEffects = { ...DEFAULT_BACKGROUND_EFFECTS };
// Parse compact effects
if (obj.be && typeof obj.be === 'object' && !Array.isArray(obj.be)) {
const be = obj.be as Record<string, unknown>;
if (typeof be.b === 'number') effects.blur = be.b;
if (typeof be.d === 'number') effects.darkenOpacity = be.d;
if (typeof be.s === 'number') effects.saturation = be.s;
if (typeof be.f === 'boolean') effects.enableFrostedGlass = be.f;
if (typeof be.g === 'boolean') effects.enableGrain = be.g;
if (typeof be.v === 'boolean') effects.enableVignette = be.v;
}
payload.backgroundConfig = {
mode: obj.bm as BackgroundMode,
imageUrl: typeof obj.bi === 'string' ? obj.bi : null,
attribution: (typeof obj.bp === 'string' && typeof obj.bu === 'string' && typeof obj.bpu === 'string')
? { photographerName: obj.bp, photographerUrl: obj.bu, photoUrl: obj.bpu }
: null,
effects,
};
}
return {
ok: true,
payload,
warning: versionCheck.warning,
};
}
// ========== Version Compatibility ==========
/**
* Check if a payload version is within +/-2 of the current SHARE_VERSION.
* Versions outside range are incompatible. Versions within range but
* not equal get a warning that accuracy may vary.
*
* @param payloadVersion - Version number from the decoded payload
* @returns Compatibility result with optional warning
*/
export function isVersionCompatible(payloadVersion: number): VersionCheckResult {
const diff = Math.abs(payloadVersion - SHARE_VERSION);
if (diff > 2) {
return { compatible: false };
}
if (diff > 0) {
return {
compatible: true,
warning: 'version_mismatch',
};
}
return { compatible: true };
}

View File

@@ -0,0 +1,102 @@
/**
* Unsplash API Client
* Frontend functions to search Unsplash via the backend proxy.
*/
export interface UnsplashPhoto {
id: string;
thumbUrl: string;
smallUrl: string;
regularUrl: string;
photographer: string;
photographerUrl: string;
photoUrl: string;
blurHash: string | null;
downloadLocation: string;
}
export interface UnsplashSearchResult {
photos: UnsplashPhoto[];
total: number;
totalPages: number;
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Search Unsplash photos via backend proxy.
*/
export async function searchUnsplash(
query: string,
page = 1,
perPage = 20
): Promise<UnsplashSearchResult> {
const params = new URLSearchParams({
query,
page: String(page),
per_page: String(perPage),
});
const response = await fetch(`/api/unsplash/search?${params}`, {
credentials: 'same-origin',
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Unsplash search failed: ${response.status}`);
}
return response.json();
}
/**
* Upload a local image as background.
* Sends raw binary to avoid base64 overhead.
*/
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
const headers: Record<string, string> = {
'Content-Type': file.type,
'X-Filename': file.name,
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/background/upload', {
method: 'POST',
headers,
credentials: 'same-origin',
body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Upload failed: ${response.status}`);
}
return response.json();
}
/**
* Trigger Unsplash download event (required by API guidelines).
*/
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
await fetch('/api/unsplash/download', {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ downloadLocation }),
});
}

View File

@@ -224,10 +224,26 @@
"content": "Content Search",
"files": "File Search",
"symbol": "Symbol Search",
"semantic": "Semantic Search (LSP)",
"mode": "Mode",
"mode.semantic": "Semantic (default)",
"mode.exact": "Exact (FTS)",
"mode.fuzzy": "Fuzzy",
"semanticMode": "Search Mode",
"semanticMode.fusion": "Fusion Search",
"semanticMode.vector": "Vector Search",
"semanticMode.structural": "Structural Search",
"fusionStrategy": "Fusion Strategy",
"fusionStrategy.rrf": "RRF (default)",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"lspStatus": "LSP Status",
"lspAvailable": "Semantic search available",
"lspUnavailable": "Semantic search unavailable",
"lspNoVector": "Vector index required",
"lspNoSemantic": "Semantic dependencies required",
"query": "Query",
"queryPlaceholder": "Enter search query...",
"button": "Search",

View File

@@ -132,6 +132,10 @@
"ask_question": {
"name": "ask_question",
"desc": "Ask interactive questions through A2UI interface"
},
"smart_search": {
"name": "smart_search",
"desc": "Intelligent code search with fuzzy and semantic modes"
}
},
"paths": {

View File

@@ -34,5 +34,87 @@
"enhanced": "Enhanced",
"hoverGlow": "Enable hover glow effects",
"bgAnimation": "Enable background gradient animation"
},
"accessibility": {
"contrastWarning": "Some color combinations may not meet WCAG AA contrast requirements:",
"fixSuggestion": "Suggested fix: adjust {target} from {original} to {suggested} (ratio: {ratio}:1)",
"applyFix": "Apply Fix",
"dismiss": "Dismiss"
},
"motion": {
"label": "Motion Preference",
"system": "System",
"reduce": "Reduce",
"enable": "Enable"
},
"slot": {
"title": "Theme Slots",
"default": "Default",
"custom1": "Custom 1",
"custom2": "Custom 2",
"copy": "Copy Slot",
"rename": "Rename",
"delete": "Delete",
"undoDelete": "Slot deleted. Undo?",
"undo": "Undo",
"limitReached": "Maximum of {limit} theme slots reached",
"deleteConfirm": "Delete this theme slot?",
"cannotDeleteDefault": "Cannot delete the default slot",
"renameEmpty": "Slot name cannot be empty",
"copyOf": "Copy of {name}",
"active": "Active"
},
"styleTier": {
"label": "Style Tier",
"soft": "Soft",
"standard": "Standard",
"highContrast": "High Contrast",
"softDesc": "Reduced saturation, gentle colors",
"standardDesc": "Default balanced appearance",
"highContrastDesc": "Enhanced readability, sharper colors"
},
"background": {
"title": "Background Image",
"mode": {
"gradientOnly": "Gradient",
"imageOnly": "Image",
"imageGradient": "Image+Gradient"
},
"searchPlaceholder": "Search Unsplash photos...",
"customUrlPlaceholder": "Custom image URL...",
"apply": "Apply",
"removeImage": "Remove",
"effects": "Visual Effects",
"blur": "Blur",
"darken": "Darken",
"saturation": "Saturation",
"frostedGlass": "Frosted glass effect on content",
"grain": "Noise texture overlay",
"vignette": "Vignette (dark edges)",
"searchError": "Failed to search photos. Check if Unsplash API is configured.",
"noResults": "No photos found",
"prev": "Prev",
"next": "Next",
"loadFailed": "Image failed to load, fallback to gradient",
"upload": "Upload local image",
"uploading": "Uploading...",
"uploadError": "Upload failed",
"fileTooLarge": "File too large (max 10MB)",
"invalidType": "Only JPG, PNG, WebP, GIF supported"
},
"share": {
"label": "Theme Sharing",
"copyCode": "Copy Theme Code",
"copied": "Theme code copied to clipboard",
"import": "Import Theme",
"paste": "Paste theme code here...",
"preview": "Import Preview",
"apply": "Apply Theme",
"cancel": "Cancel",
"invalidCode": "Invalid theme code. Please check and try again.",
"incompatibleVersion": "This theme code is from an incompatible version and cannot be imported.",
"versionWarning": "This theme code is from a different version. Some settings may not be accurate.",
"importSuccess": "Theme imported successfully",
"noSlotAvailable": "No available theme slot. Delete a custom slot first."
}
}

View File

@@ -224,10 +224,26 @@
"content": "内容搜索",
"files": "文件搜索",
"symbol": "符号搜索",
"semantic": "语义搜索 (LSP)",
"mode": "模式",
"mode.semantic": "语义(默认)",
"mode.exact": "精确FTS",
"mode.fuzzy": "模糊",
"semanticMode": "搜索模式",
"semanticMode.fusion": "融合搜索",
"semanticMode.vector": "向量搜索",
"semanticMode.structural": "结构搜索",
"fusionStrategy": "融合策略",
"fusionStrategy.rrf": "RRF默认",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"lspStatus": "LSP 状态",
"lspAvailable": "语义搜索可用",
"lspUnavailable": "语义搜索不可用",
"lspNoVector": "需要先建立向量索引",
"lspNoSemantic": "需要先安装语义依赖",
"query": "查询",
"queryPlaceholder": "输入搜索查询...",
"button": "搜索",
@@ -294,6 +310,7 @@
"installing": "安装中..."
},
"watcher": {
"title": "文件监听器",
"status": {
"running": "运行中",
"stopped": "已停止"

View File

@@ -132,6 +132,10 @@
"ask_question": {
"name": "ask_question",
"desc": "通过 A2UI 界面发起交互式问答"
},
"smart_search": {
"name": "smart_search",
"desc": "智能代码搜索,支持模糊和语义搜索模式"
}
},
"paths": {

View File

@@ -34,5 +34,87 @@
"enhanced": "增强",
"hoverGlow": "启用悬停光晕效果",
"bgAnimation": "启用背景渐变动画"
},
"accessibility": {
"contrastWarning": "部分颜色组合可能不符合 WCAG AA 对比度要求:",
"fixSuggestion": "建议修复: 将{target}从 {original} 调整为 {suggested} (对比度: {ratio}:1)",
"applyFix": "应用修复",
"dismiss": "忽略"
},
"motion": {
"label": "动效偏好",
"system": "跟随系统",
"reduce": "减少动效",
"enable": "启用动效"
},
"slot": {
"title": "主题槽位",
"default": "默认",
"custom1": "自定义 1",
"custom2": "自定义 2",
"copy": "复制槽位",
"rename": "重命名",
"delete": "删除",
"undoDelete": "槽位已删除,是否撤销?",
"undo": "撤销",
"limitReached": "最多只能创建 {limit} 个主题槽位",
"deleteConfirm": "确定删除此主题槽位?",
"cannotDeleteDefault": "无法删除默认槽位",
"renameEmpty": "槽位名称不能为空",
"copyOf": "{name}的副本",
"active": "使用中"
},
"styleTier": {
"label": "风格档位",
"soft": "柔和",
"standard": "标准",
"highContrast": "高对比",
"softDesc": "降低饱和度,柔和色彩",
"standardDesc": "默认均衡外观",
"highContrastDesc": "增强可读性,色彩更鲜明"
},
"background": {
"title": "背景图片",
"mode": {
"gradientOnly": "仅渐变",
"imageOnly": "仅图片",
"imageGradient": "图片+渐变"
},
"searchPlaceholder": "搜索 Unsplash 图片...",
"customUrlPlaceholder": "自定义图片 URL...",
"apply": "应用",
"removeImage": "移除",
"effects": "视觉效果",
"blur": "模糊",
"darken": "暗化",
"saturation": "饱和度",
"frostedGlass": "内容区毛玻璃效果",
"grain": "噪点纹理叠加",
"vignette": "暗角效果",
"searchError": "搜索图片失败,请检查 Unsplash API 是否已配置。",
"noResults": "未找到图片",
"prev": "上一页",
"next": "下一页",
"loadFailed": "图片加载失败,已回退到渐变模式",
"upload": "上传本地图片",
"uploading": "上传中...",
"uploadError": "上传失败",
"fileTooLarge": "文件过大(最大 10MB",
"invalidType": "仅支持 JPG、PNG、WebP、GIF"
},
"share": {
"label": "主题分享",
"copyCode": "复制主题代码",
"copied": "主题代码已复制到剪贴板",
"import": "导入主题",
"paste": "在此粘贴主题代码...",
"preview": "导入预览",
"apply": "应用主题",
"cancel": "取消",
"invalidCode": "无效的主题代码,请检查后重试。",
"incompatibleVersion": "此主题代码来自不兼容的版本,无法导入。",
"versionWarning": "此主题代码来自不同版本,部分设置可能不准确。",
"importSuccess": "主题导入成功",
"noSlotAvailable": "没有可用的主题槽位,请先删除一个自定义槽位。"
}
}

View File

@@ -116,6 +116,7 @@ export const CheckboxComponentSchema = z.object({
checked: BooleanContentSchema.optional(),
onChange: ActionSchema,
label: TextContentSchema.optional(),
description: TextContentSchema.optional(),
}),
});
@@ -202,6 +203,8 @@ export const ComponentSchema: z.ZodType<any> = z.union([
export const SurfaceComponentSchema = z.object({
id: z.string(),
component: ComponentSchema,
/** Page index for multi-page surfaces (0-based) */
page: z.number().int().min(0).optional(),
});
/** Display mode for A2UI surfaces */

View File

@@ -51,17 +51,28 @@ export const A2UICheckbox: ComponentRenderer = ({ component, state, onAction, re
? resolveTextContent(checkboxConfig.label, resolveBinding)
: '';
// Resolve description text
const descriptionText = checkboxConfig.description
? resolveTextContent(checkboxConfig.description, resolveBinding)
: '';
return (
<div className="flex items-center space-x-2">
<div className="flex items-start space-x-2">
<Checkbox
className="mt-0.5"
checked={checked}
onCheckedChange={handleChange}
/>
{labelText && (
<Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
</Label>
)}
<div className="grid gap-0.5">
{labelText && (
<Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
</Label>
)}
{descriptionText && (
<p className="text-xs text-muted-foreground">{descriptionText}</p>
)}
</div>
</div>
);
};

View File

@@ -286,7 +286,7 @@ export function HelpPage() {
</div>
{/* Search Documentation CTA */}
<Card className="p-6 sm:p-8 bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
<Card className="p-6 sm:p-8 bg-gradient-accent border-primary/20">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:justify-between">
<div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-3 rounded-lg bg-primary/20 flex-shrink-0">

View File

@@ -235,7 +235,7 @@ export function ProjectOverviewPage() {
{/* Header Row */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-border">
<div className="flex-1">
<h1 className="text-lg font-semibold text-foreground mb-1">
<h1 className="text-lg font-semibold text-foreground gradient-text mb-1">
{projectOverview.projectName}
</h1>
<p className="text-sm text-muted-foreground">

View File

@@ -5,11 +5,12 @@
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig } from '../types/store';
import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts';
import { getInitialLocale, updateIntl } from '../lib/i18n';
import { getThemeId } from '../lib/theme';
import { generateThemeFromHue } from '../lib/colorGenerator';
import { getThemeId, DEFAULT_SLOT, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
import { generateThemeFromHue, applyStyleTier } from '../lib/colorGenerator';
import { resolveMotionPreference, checkThemeContrast } from '../lib/accessibility';
// Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => {
@@ -25,6 +26,12 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
return theme;
};
/** Get the style tier from the active slot */
const getActiveStyleTier = (themeSlots: ThemeSlot[], activeSlotId: ThemeSlotId): StyleTier => {
const slot = themeSlots.find(s => s.id === activeSlotId);
return slot?.styleTier ?? 'standard';
};
/**
* DOM Theme Application Helper
*
@@ -44,7 +51,9 @@ const applyThemeToDocument = (
customHue: number | null,
gradientLevel: GradientLevel = 'standard',
enableHoverGlow: boolean = true,
enableBackgroundAnimation: boolean = false
enableBackgroundAnimation: boolean = false,
motionPreference: MotionPreference = 'system',
styleTier: StyleTier = 'standard'
): void => {
if (typeof document === 'undefined') return;
@@ -78,11 +87,29 @@ const applyThemeToDocument = (
// Apply custom theme or preset theme
if (customHue !== null) {
const cssVars = generateThemeFromHue(customHue, resolvedTheme);
let cssVars = generateThemeFromHue(customHue, resolvedTheme);
// Apply style tier post-processing
if (styleTier !== 'standard') {
cssVars = applyStyleTier(cssVars, styleTier, resolvedTheme);
}
Object.entries(cssVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue);
});
document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`);
// Contrast validation for non-standard tiers
if (styleTier !== 'standard') {
const contrastResults = checkThemeContrast(cssVars);
const failures = contrastResults.filter(r => !r.passed);
if (failures.length > 0) {
console.warn(
'[Theme] Style tier "%s" caused %d WCAG AA contrast failures:',
styleTier,
failures.length,
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
);
}
}
} else {
// Clear custom CSS variables
customVars.forEach(varName => {
@@ -91,6 +118,35 @@ const applyThemeToDocument = (
// Apply preset theme
const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId);
// Apply style tier to preset theme (if not standard)
if (styleTier !== 'standard') {
const computed = getComputedStyle(document.documentElement);
const presetVars: Record<string, string> = {};
for (const varName of customVars) {
const value = computed.getPropertyValue(varName).trim();
if (value) {
presetVars[varName] = value;
}
}
const tieredVars = applyStyleTier(presetVars, styleTier, resolvedTheme);
Object.entries(tieredVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue);
});
// Contrast validation for preset themes with non-standard tiers
const contrastResults = checkThemeContrast(tieredVars);
const failures = contrastResults.filter(r => !r.passed);
if (failures.length > 0) {
console.warn(
'[Theme] Style tier "%s" on preset "%s" caused %d WCAG AA contrast failures:',
styleTier,
colorScheme,
failures.length,
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
);
}
}
}
// Set color scheme attribute
@@ -100,10 +156,19 @@ const applyThemeToDocument = (
document.documentElement.setAttribute('data-gradient', gradientLevel);
document.documentElement.setAttribute('data-hover-glow', String(enableHoverGlow));
document.documentElement.setAttribute('data-bg-animation', String(enableBackgroundAnimation));
// Apply reduced motion preference
const reducedMotion = resolveMotionPreference(motionPreference);
document.documentElement.setAttribute('data-reduced-motion', String(reducedMotion));
// Set style tier data attribute
document.documentElement.setAttribute('data-style-tier', styleTier);
};
// Use View Transition API for smooth transitions (progressive enhancement)
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
// Skip view transition when reduced motion is active
const reducedMotion = resolveMotionPreference(motionPreference);
if (!reducedMotion && typeof document !== 'undefined' && 'startViewTransition' in document) {
(document as unknown as { startViewTransition: (callback: () => void) => void }).startViewTransition(performThemeUpdate);
} else {
// Fallback: apply immediately without transition
@@ -111,6 +176,23 @@ const applyThemeToDocument = (
}
};
/**
* Apply background configuration to document data attributes.
* Sets data-bg-* attributes on <html> that CSS rules respond to.
*/
const applyBackgroundToDocument = (config: BackgroundConfig): void => {
if (typeof document === 'undefined') return;
const el = document.documentElement;
el.setAttribute('data-bg-mode', config.mode);
el.setAttribute('data-bg-blur', String(config.effects.blur));
el.setAttribute('data-bg-darken', String(config.effects.darkenOpacity));
el.setAttribute('data-bg-saturation', String(config.effects.saturation));
el.setAttribute('data-bg-frosted', String(config.effects.enableFrostedGlass));
el.setAttribute('data-bg-grain', String(config.effects.enableGrain));
el.setAttribute('data-bg-vignette', String(config.effects.enableVignette));
};
// Initial state
const initialState = {
// Theme
@@ -125,6 +207,9 @@ const initialState = {
enableHoverGlow: true,
enableBackgroundAnimation: false,
// Motion preference
motionPreference: 'system' as MotionPreference,
// Locale
locale: getInitialLocale() as Locale,
@@ -146,6 +231,11 @@ const initialState = {
// Dashboard layout
dashboardLayout: null,
// Theme slots
themeSlots: [DEFAULT_SLOT] as ThemeSlot[],
activeSlotId: 'default' as ThemeSlotId,
deletedSlotBuffer: null as ThemeSlot | null,
};
export const useAppStore = create<AppStore>()(
@@ -161,31 +251,60 @@ export const useAppStore = create<AppStore>()(
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme using helper (encapsulates DOM manipulation)
const { colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
applyThemeToDocument(resolved, colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(resolved, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setColorScheme: (colorScheme: ColorScheme) => {
set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme');
set((state) => ({
colorScheme,
customHue: null,
isCustomTheme: false,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, colorScheme, customHue: null, isCustomTheme: false }
: slot
),
}), false, 'setColorScheme');
// Apply color scheme using helper (encapsulates DOM manipulation)
const { resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setCustomHue: (hue: number | null) => {
if (hue === null) {
// Reset to preset theme
const { colorScheme, resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue');
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
set((s) => ({
customHue: null,
isCustomTheme: false,
themeSlots: s.themeSlots.map(slot =>
slot.id === s.activeSlotId
? { ...slot, customHue: null, isCustomTheme: false }
: slot
),
}), false, 'setCustomHue');
applyThemeToDocument(state.resolvedTheme, state.colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
return;
}
// Apply custom hue
set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue');
const { resolvedTheme, colorScheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get();
applyThemeToDocument(resolvedTheme, colorScheme, hue, gradientLevel, enableHoverGlow, enableBackgroundAnimation);
set((state) => ({
customHue: hue,
isCustomTheme: true,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, customHue: hue, isCustomTheme: true }
: slot
),
}), false, 'setCustomHue');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, hue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
toggleTheme: () => {
@@ -197,21 +316,64 @@ export const useAppStore = create<AppStore>()(
// ========== Gradient Settings Actions ==========
setGradientLevel: (level: GradientLevel) => {
set({ gradientLevel: level }, false, 'setGradientLevel');
const { resolvedTheme, colorScheme, customHue, enableHoverGlow, enableBackgroundAnimation } = get();
applyThemeToDocument(resolvedTheme, colorScheme, customHue, level, enableHoverGlow, enableBackgroundAnimation);
set((state) => ({
gradientLevel: level,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, gradientLevel: level }
: slot
),
}), false, 'setGradientLevel');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, level, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setEnableHoverGlow: (enabled: boolean) => {
set({ enableHoverGlow: enabled }, false, 'setEnableHoverGlow');
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableBackgroundAnimation } = get();
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enabled, enableBackgroundAnimation);
set((state) => ({
enableHoverGlow: enabled,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, enableHoverGlow: enabled }
: slot
),
}), false, 'setEnableHoverGlow');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, enabled, state.enableBackgroundAnimation, state.motionPreference, styleTier);
},
setEnableBackgroundAnimation: (enabled: boolean) => {
set({ enableBackgroundAnimation: enabled }, false, 'setEnableBackgroundAnimation');
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow } = get();
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow, enabled);
set((state) => ({
enableBackgroundAnimation: enabled,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, enableBackgroundAnimation: enabled }
: slot
),
}), false, 'setEnableBackgroundAnimation');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, enabled, state.motionPreference, styleTier);
},
setMotionPreference: (pref: MotionPreference) => {
set({ motionPreference: pref }, false, 'setMotionPreference');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, pref, styleTier);
},
setStyleTier: (tier: StyleTier) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, styleTier: tier }
: slot
),
}), false, 'setStyleTier');
const state = get();
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, tier);
},
// ========== Locale Actions ==========
@@ -302,10 +464,216 @@ export const useAppStore = create<AppStore>()(
resetDashboardLayout: () => {
set({ dashboardLayout: DEFAULT_DASHBOARD_LAYOUT }, false, 'resetDashboardLayout');
},
// ========== Theme Slot Actions ==========
setActiveSlot: (slotId: ThemeSlotId) => {
const { themeSlots, motionPreference } = get();
const slot = themeSlots.find(s => s.id === slotId);
if (!slot) return;
const resolved = resolveTheme(get().theme);
set({
activeSlotId: slotId,
colorScheme: slot.colorScheme,
customHue: slot.customHue,
isCustomTheme: slot.isCustomTheme,
gradientLevel: slot.gradientLevel,
enableHoverGlow: slot.enableHoverGlow,
enableBackgroundAnimation: slot.enableBackgroundAnimation,
}, false, 'setActiveSlot');
applyThemeToDocument(
resolved,
slot.colorScheme,
slot.customHue,
slot.gradientLevel,
slot.enableHoverGlow,
slot.enableBackgroundAnimation,
motionPreference,
slot.styleTier
);
applyBackgroundToDocument(slot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
},
copySlot: () => {
const state = get();
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
// Determine next available slot id
const usedIds = new Set(state.themeSlots.map(s => s.id));
const candidateIds: ThemeSlotId[] = ['custom-1', 'custom-2'];
const nextId = candidateIds.find(id => !usedIds.has(id));
if (!nextId) return;
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
if (!activeSlot) return;
const newSlot: ThemeSlot = {
id: nextId,
name: `Copy of ${activeSlot.name}`,
colorScheme: state.colorScheme,
customHue: state.customHue,
isCustomTheme: state.isCustomTheme,
gradientLevel: state.gradientLevel,
enableHoverGlow: state.enableHoverGlow,
enableBackgroundAnimation: state.enableBackgroundAnimation,
styleTier: activeSlot.styleTier,
isDefault: false,
backgroundConfig: activeSlot.backgroundConfig,
};
set({
themeSlots: [...state.themeSlots, newSlot],
activeSlotId: nextId,
}, false, 'copySlot');
},
renameSlot: (slotId: ThemeSlotId, name: string) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === slotId ? { ...slot, name } : slot
),
}), false, 'renameSlot');
},
deleteSlot: (slotId: ThemeSlotId) => {
const state = get();
const slot = state.themeSlots.find(s => s.id === slotId);
if (!slot || slot.isDefault) return;
set({
themeSlots: state.themeSlots.filter(s => s.id !== slotId),
deletedSlotBuffer: slot,
activeSlotId: 'default',
}, false, 'deleteSlot');
// Load default slot values into active state
const defaultSlot = state.themeSlots.find(s => s.id === 'default');
if (defaultSlot) {
const resolved = resolveTheme(state.theme);
set({
colorScheme: defaultSlot.colorScheme,
customHue: defaultSlot.customHue,
isCustomTheme: defaultSlot.isCustomTheme,
gradientLevel: defaultSlot.gradientLevel,
enableHoverGlow: defaultSlot.enableHoverGlow,
enableBackgroundAnimation: defaultSlot.enableBackgroundAnimation,
}, false, 'deleteSlot/applyDefault');
applyThemeToDocument(
resolved,
defaultSlot.colorScheme,
defaultSlot.customHue,
defaultSlot.gradientLevel,
defaultSlot.enableHoverGlow,
defaultSlot.enableBackgroundAnimation,
state.motionPreference,
defaultSlot.styleTier
);
applyBackgroundToDocument(defaultSlot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Clear buffer after 10 seconds
setTimeout(() => {
const current = useAppStore.getState();
if (current.deletedSlotBuffer?.id === slotId) {
useAppStore.setState({ deletedSlotBuffer: null }, false);
}
}, 10000);
},
undoDeleteSlot: () => {
const state = get();
const restored = state.deletedSlotBuffer;
if (!restored) return;
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
set({
themeSlots: [...state.themeSlots, restored],
deletedSlotBuffer: null,
activeSlotId: restored.id,
}, false, 'undoDeleteSlot');
// Apply restored slot values
const resolved = resolveTheme(state.theme);
set({
colorScheme: restored.colorScheme,
customHue: restored.customHue,
isCustomTheme: restored.isCustomTheme,
gradientLevel: restored.gradientLevel,
enableHoverGlow: restored.enableHoverGlow,
enableBackgroundAnimation: restored.enableBackgroundAnimation,
}, false, 'undoDeleteSlot/apply');
applyThemeToDocument(
resolved,
restored.colorScheme,
restored.customHue,
restored.gradientLevel,
restored.enableHoverGlow,
restored.enableBackgroundAnimation,
state.motionPreference,
restored.styleTier
);
applyBackgroundToDocument(restored.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
},
// ========== Background Actions ==========
setBackgroundConfig: (config: BackgroundConfig) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, backgroundConfig: config }
: slot
),
}), false, 'setBackgroundConfig');
applyBackgroundToDocument(config);
},
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = {
...current,
effects: { ...current.effects, [key]: value },
};
get().setBackgroundConfig(updated);
},
setBackgroundMode: (mode: BackgroundMode) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = { ...current, mode };
get().setBackgroundConfig(updated);
},
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = {
...current,
imageUrl: url,
attribution,
};
// Auto-switch mode if currently gradient-only and setting an image
if (url && current.mode === 'gradient-only') {
updated.mode = 'image-gradient';
}
// Auto-switch to gradient-only if removing image
if (!url && current.mode !== 'gradient-only') {
updated.mode = 'gradient-only';
}
get().setBackgroundConfig(updated);
},
}),
{
name: 'ccw-app-store',
// Only persist theme and locale preferences
// Only persist theme, locale, and slot preferences
partialize: (state) => ({
theme: state.theme,
colorScheme: state.colorScheme,
@@ -313,26 +681,59 @@ export const useAppStore = create<AppStore>()(
gradientLevel: state.gradientLevel,
enableHoverGlow: state.enableHoverGlow,
enableBackgroundAnimation: state.enableBackgroundAnimation,
motionPreference: state.motionPreference,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups,
dashboardLayout: state.dashboardLayout,
themeSlots: state.themeSlots,
activeSlotId: state.activeSlotId,
}),
onRehydrateStorage: () => (state) => {
// Apply theme on rehydration
if (state) {
// Migrate legacy schema: if no themeSlots, construct from flat fields
if (!state.themeSlots || !Array.isArray(state.themeSlots) || state.themeSlots.length === 0) {
const migratedSlot: ThemeSlot = {
id: 'default',
name: 'Default',
colorScheme: state.colorScheme ?? 'blue',
customHue: state.customHue ?? null,
isCustomTheme: (state.customHue ?? null) !== null,
gradientLevel: state.gradientLevel ?? 'standard',
enableHoverGlow: state.enableHoverGlow ?? true,
enableBackgroundAnimation: state.enableBackgroundAnimation ?? false,
styleTier: 'standard',
isDefault: true,
};
state.themeSlots = [migratedSlot];
state.activeSlotId = 'default';
}
// Ensure activeSlotId is valid
if (!state.activeSlotId || !state.themeSlots.find(s => s.id === state.activeSlotId)) {
state.activeSlotId = 'default';
}
// Apply theme on rehydration
const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved;
state.isCustomTheme = state.customHue !== null;
// Apply theme using helper (encapsulates DOM manipulation)
const rehydratedStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
resolved,
state.colorScheme,
state.customHue,
state.gradientLevel ?? 'standard',
state.enableHoverGlow ?? true,
state.enableBackgroundAnimation ?? false
state.enableBackgroundAnimation ?? false,
state.motionPreference ?? 'system',
rehydratedStyleTier
);
// Apply background config on rehydration
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
applyBackgroundToDocument(activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Apply locale on rehydration
if (state) {
@@ -354,13 +755,16 @@ if (typeof window !== 'undefined') {
const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved });
// Apply theme using helper (encapsulates DOM manipulation)
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
resolved,
state.colorScheme,
state.customHue,
state.gradientLevel,
state.enableHoverGlow,
state.enableBackgroundAnimation
state.enableBackgroundAnimation,
state.motionPreference,
styleTier
);
}
});
@@ -368,14 +772,21 @@ if (typeof window !== 'undefined') {
// Apply initial theme immediately (before localStorage rehydration)
// This ensures gradient attributes are set from the start
const state = useAppStore.getState();
const initialStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(
state.resolvedTheme,
state.colorScheme,
state.customHue,
state.gradientLevel,
state.enableHoverGlow,
state.enableBackgroundAnimation
state.enableBackgroundAnimation,
state.motionPreference,
initialStyleTier
);
// Apply initial background config
const initialActiveSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
applyBackgroundToDocument(initialActiveSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Selectors for common access patterns
@@ -387,8 +798,12 @@ export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme;
export const selectGradientLevel = (state: AppStore) => state.gradientLevel;
export const selectEnableHoverGlow = (state: AppStore) => state.enableHoverGlow;
export const selectEnableBackgroundAnimation = (state: AppStore) => state.enableBackgroundAnimation;
export const selectMotionPreference = (state: AppStore) => state.motionPreference;
export const selectLocale = (state: AppStore) => state.locale;
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
export const selectCurrentView = (state: AppStore) => state.currentView;
export const selectIsLoading = (state: AppStore) => state.isLoading;
export const selectError = (state: AppStore) => state.error;
export const selectThemeSlots = (state: AppStore) => state.themeSlots;
export const selectActiveSlotId = (state: AppStore) => state.activeSlotId;
export const selectDeletedSlotBuffer = (state: AppStore) => state.deletedSlotBuffer;

View File

@@ -8,6 +8,32 @@
export type Theme = 'light' | 'dark' | 'system';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type GradientLevel = 'off' | 'standard' | 'enhanced';
export type MotionPreference = 'system' | 'reduce' | 'enable';
export type StyleTier = 'soft' | 'standard' | 'high-contrast';
export type ThemeSlotId = 'default' | 'custom-1' | 'custom-2';
export type BackgroundMode = 'gradient-only' | 'image-only' | 'image-gradient';
export interface BackgroundEffects {
blur: number; // 0-20 px
darkenOpacity: number; // 0-80 %
saturation: number; // 0-200 % (100=normal)
enableFrostedGlass: boolean;
enableGrain: boolean;
enableVignette: boolean;
}
export interface UnsplashAttribution {
photographerName: string;
photographerUrl: string;
photoUrl: string;
}
export interface BackgroundConfig {
mode: BackgroundMode;
imageUrl: string | null;
attribution: UnsplashAttribution | null;
effects: BackgroundEffects;
}
export type Locale = 'en' | 'zh';
export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator';
export type SessionFilter = 'all' | 'active' | 'archived';
@@ -35,6 +61,20 @@ export type LiteTaskType = 'lite-plan' | 'lite-fix' | null;
*/
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix';
export interface ThemeSlot {
id: ThemeSlotId;
name: string;
colorScheme: ColorScheme;
customHue: number | null;
isCustomTheme: boolean;
gradientLevel: GradientLevel;
enableHoverGlow: boolean;
enableBackgroundAnimation: boolean;
styleTier: StyleTier;
isDefault: boolean;
backgroundConfig?: BackgroundConfig;
}
export interface AppState {
// Theme
theme: Theme;
@@ -48,6 +88,9 @@ export interface AppState {
enableHoverGlow: boolean; // Enable hover glow effects
enableBackgroundAnimation: boolean; // Enable background gradient animation
// Motion preference
motionPreference: MotionPreference; // Reduced motion preference: system, reduce, enable
// Locale
locale: Locale;
@@ -69,6 +112,11 @@ export interface AppState {
// Dashboard layout
dashboardLayout: DashboardLayoutState | null;
// Theme slots
themeSlots: ThemeSlot[];
activeSlotId: ThemeSlotId;
deletedSlotBuffer: ThemeSlot | null;
}
export interface AppActions {
@@ -82,6 +130,8 @@ export interface AppActions {
setGradientLevel: (level: GradientLevel) => void;
setEnableHoverGlow: (enabled: boolean) => void;
setEnableBackgroundAnimation: (enabled: boolean) => void;
setMotionPreference: (pref: MotionPreference) => void;
setStyleTier: (tier: StyleTier) => void;
// Locale actions
setLocale: (locale: Locale) => void;
@@ -107,6 +157,19 @@ export interface AppActions {
setDashboardLayouts: (layouts: DashboardLayouts) => void;
setDashboardWidgets: (widgets: WidgetConfig[]) => void;
resetDashboardLayout: () => void;
// Theme slot actions
setActiveSlot: (slotId: ThemeSlotId) => void;
copySlot: () => void;
renameSlot: (slotId: ThemeSlotId, name: string) => void;
deleteSlot: (slotId: ThemeSlotId) => void;
undoDeleteSlot: () => void;
// Background actions
setBackgroundConfig: (config: BackgroundConfig) => void;
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => void;
setBackgroundMode: (mode: BackgroundMode) => void;
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
}
export type AppStore = AppState & AppActions;