mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
98
ccw/frontend/src/components/shared/BackgroundImage.tsx
Normal file
98
ccw/frontend/src/components/shared/BackgroundImage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
405
ccw/frontend/src/components/shared/BackgroundImagePicker.tsx
Normal file
405
ccw/frontend/src/components/shared/BackgroundImagePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
26
ccw/frontend/src/hooks/useUnsplashSearch.ts
Normal file
26
ccw/frontend/src/hooks/useUnsplashSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
336
ccw/frontend/src/lib/accessibility.ts
Normal file
336
ccw/frontend/src/lib/accessibility.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
327
ccw/frontend/src/lib/themeShare.ts
Normal file
327
ccw/frontend/src/lib/themeShare.ts
Normal 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 };
|
||||
}
|
||||
102
ccw/frontend/src/lib/unsplash.ts
Normal file
102
ccw/frontend/src/lib/unsplash.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "已停止"
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
"ask_question": {
|
||||
"name": "ask_question",
|
||||
"desc": "通过 A2UI 界面发起交互式问答"
|
||||
},
|
||||
"smart_search": {
|
||||
"name": "smart_search",
|
||||
"desc": "智能代码搜索,支持模糊和语义搜索模式"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -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": "没有可用的主题槽位,请先删除一个自定义槽位。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user