feat: add Unsplash search hook and API proxy routes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,9 +65,37 @@ export const QuestionAnswerSchema = z.object({
export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
// ========== AskUserQuestion-style Types ==========
/** AskUserQuestion-style option (value auto-generated from label) */
export const SimpleOptionSchema = z.object({
label: z.string(),
description: z.string().optional(),
});
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
/** AskUserQuestion-style question */
export const SimpleQuestionSchema = z.object({
question: z.string(), // 问题文本 → 映射到 title
header: z.string(), // 短标签 → 映射到 id
multiSelect: z.boolean().default(false),
options: z.array(SimpleOptionSchema).optional(),
});
export type SimpleQuestion = z.infer<typeof SimpleQuestionSchema>;
/** 新格式参数 (questions 数组) */
export const AskQuestionSimpleParamsSchema = z.object({
questions: z.array(SimpleQuestionSchema).min(1).max(4),
timeout: z.number().optional(),
});
export type AskQuestionSimpleParams = z.infer<typeof AskQuestionSimpleParamsSchema>;
// ========== Ask Question Parameters ==========
/** Parameters for ask_question tool */
/** Parameters for ask_question tool (legacy format) */
export const AskQuestionParamsSchema = z.object({
question: QuestionSchema,
timeout: z.number().optional().default(300000), // 5 minutes default

View File

@@ -4,10 +4,13 @@
// WebSocket transport for A2UI surfaces and actions
import type { Duplex } from 'stream';
import http from 'http';
import type { IncomingMessage } from 'http';
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
// ========== A2UI Message Types ==========
/** A2UI WebSocket message types */
@@ -60,8 +63,20 @@ export class A2UIWebSocketHandler {
private multiSelectSelections = new Map<string, Set<string>>();
private singleSelectSelections = new Map<string, string>();
private inputValues = new Map<string, string>();
/** Answers resolved by Dashboard but not yet consumed by MCP polling */
private resolvedAnswers = new Map<string, { answer: QuestionAnswer; timestamp: number }>();
private resolvedMultiAnswers = new Map<string, { compositeId: string; answers: QuestionAnswer[]; timestamp: number }>();
private answerCallback?: (answer: QuestionAnswer) => boolean;
private multiAnswerCallback?: (compositeId: string, answers: QuestionAnswer[]) => boolean;
/** Buffered surfaces waiting to be replayed to newly connected clients */
private pendingSurfaces: Array<{
surfaceUpdate: { surfaceId: string; components: unknown[]; initialState: Record<string, unknown>; displayMode?: 'popup' | 'panel' };
message: unknown;
}> = [];
/**
* Register callback for handling question answers
@@ -71,6 +86,14 @@ export class A2UIWebSocketHandler {
this.answerCallback = callback;
}
/**
* Register callback for handling multi-question composite answers (submit-all)
* @param callback - Function to handle composite answers
*/
registerMultiAnswerCallback(callback: (compositeId: string, answers: QuestionAnswer[]) => boolean): void {
this.multiAnswerCallback = callback;
}
/**
* Get the registered answer callback
*/
@@ -78,6 +101,20 @@ export class A2UIWebSocketHandler {
return this.answerCallback;
}
/**
* Initialize multi-select tracking for a question (used by multi-page surfaces)
*/
initMultiSelect(questionId: string): void {
this.multiSelectSelections.set(questionId, new Set<string>());
}
/**
* Initialize single-select tracking for a question (used by multi-page surfaces)
*/
initSingleSelect(questionId: string): void {
this.singleSelectSelections.set(questionId, '');
}
/**
* Send A2UI surface to all connected clients
* @param surfaceUpdate - A2UI surface update to send
@@ -115,6 +152,13 @@ export class A2UIWebSocketHandler {
}
}
// No local WebSocket clients — forward via HTTP to Dashboard server
// (Happens when running in MCP stdio process, separate from Dashboard)
if (wsClients.size === 0) {
this.forwardSurfaceViaDashboard(surfaceUpdate);
return 0;
}
// Broadcast to all clients
const frame = createWebSocketFrame(message);
let sentCount = 0;
@@ -132,6 +176,72 @@ export class A2UIWebSocketHandler {
return sentCount;
}
/**
* Replay buffered surfaces to a newly connected client, then clear the buffer.
* @param client - The newly connected WebSocket client
* @returns Number of surfaces replayed
*/
replayPendingSurfaces(client: Duplex): number {
if (this.pendingSurfaces.length === 0) {
return 0;
}
const count = this.pendingSurfaces.length;
for (const { surfaceUpdate, message } of this.pendingSurfaces) {
try {
const frame = createWebSocketFrame(message);
client.write(frame);
} catch (e) {
console.error(`[A2UI] Failed to replay surface ${surfaceUpdate.surfaceId}:`, e);
}
}
console.log(`[A2UI] Replayed ${count} buffered surface(s) to new client`);
this.pendingSurfaces = [];
return count;
}
/**
* Forward surface to Dashboard server via HTTP POST /api/hook.
* Used when running in a separate process (MCP stdio) without local WebSocket clients.
*/
private forwardSurfaceViaDashboard(surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
}): void {
// Send flat so the hook handler wraps it as { type, payload: { ...fields } }
// which matches the frontend's expected format: data.type === 'a2ui-surface' && data.payload
const body = JSON.stringify({
type: 'a2ui-surface',
surfaceId: surfaceUpdate.surfaceId,
components: surfaceUpdate.components,
initialState: surfaceUpdate.initialState,
displayMode: surfaceUpdate.displayMode,
});
const req = http.request({
hostname: 'localhost',
port: DASHBOARD_PORT,
path: '/api/hook',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
});
req.on('error', (err) => {
console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
});
req.write(body);
req.end();
console.log(`[A2UI] Forwarded surface ${surfaceUpdate.surfaceId} to Dashboard via HTTP`);
}
/**
* Send A2UI surface to specific client
* @param client - Specific WebSocket client
@@ -226,12 +336,16 @@ export class A2UIWebSocketHandler {
const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
const handled = answerCallback(answer);
if (handled) {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
if (!handled) {
// answerCallback couldn't deliver (MCP process has no local pendingQuestions)
// Store answer for HTTP polling retrieval
this.resolvedAnswers.set(questionId, { answer, timestamp: Date.now() });
}
return handled;
// Always clean up UI state regardless of delivery
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
return true;
};
switch (action.actionId) {
@@ -278,15 +392,88 @@ export class A2UIWebSocketHandler {
}
case 'submit': {
const otherText = this.inputValues.get(`__other__:${questionId}`);
// Check if this is a single-select or multi-select
const singleSelection = this.singleSelectSelections.get(questionId);
if (singleSelection !== undefined) {
// Single-select submit
return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false });
// Resolve __other__ to actual text input
const value = singleSelection === '__other__' && otherText ? otherText : singleSelection;
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value, cancelled: false });
}
// Multi-select submit
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false });
// Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSelected).map(v =>
v === '__other__' && otherText ? otherText : v
);
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value: values, cancelled: false });
}
case 'input-change': {
// Track text input value for multi-page surfaces
const value = params.value;
if (typeof value !== 'string') {
return false;
}
this.inputValues.set(questionId, value);
return true;
}
case 'submit-all': {
// Multi-question composite submit
const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined;
const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined;
if (!compositeId || !questionIds) {
return false;
}
// Collect answers for all sub-questions
const answers: QuestionAnswer[] = [];
for (const qId of questionIds) {
const singleSel = this.singleSelectSelections.get(qId);
const multiSel = this.multiSelectSelections.get(qId);
const inputVal = this.inputValues.get(qId);
const otherText = this.inputValues.get(`__other__:${qId}`);
if (singleSel !== undefined) {
// Resolve __other__ to actual text input
const value = singleSel === '__other__' && otherText ? otherText : singleSel;
answers.push({ questionId: qId, value, cancelled: false });
} else if (multiSel !== undefined) {
// Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSel).map(v =>
v === '__other__' && otherText ? otherText : v
);
answers.push({ questionId: qId, value: values, cancelled: false });
} else if (inputVal !== undefined) {
answers.push({ questionId: qId, value: inputVal, cancelled: false });
} else {
// No value recorded — include empty
answers.push({ questionId: qId, value: '', cancelled: false });
}
// Cleanup per-question tracking
this.singleSelectSelections.delete(qId);
this.multiSelectSelections.delete(qId);
this.inputValues.delete(qId);
this.inputValues.delete(`__other__:${qId}`);
}
// Call multi-answer callback
let handled = false;
if (this.multiAnswerCallback) {
handled = this.multiAnswerCallback(compositeId, answers);
}
if (!handled) {
// Store for HTTP polling retrieval
this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() });
}
// Always clean up UI state
this.activeSurfaces.delete(compositeId);
return true;
}
default:
@@ -324,6 +511,7 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.inputValues.delete(questionId);
return true;
}
@@ -346,6 +534,32 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.clear();
}
/**
* Get and remove a resolved answer (one-shot read).
* Used by MCP HTTP polling to retrieve answers stored by the Dashboard.
*/
getResolvedAnswer(questionId: string): QuestionAnswer | undefined {
const entry = this.resolvedAnswers.get(questionId);
if (entry) {
this.resolvedAnswers.delete(questionId);
return entry.answer;
}
return undefined;
}
/**
* Get and remove a resolved multi-answer (one-shot read).
* Used by MCP HTTP polling to retrieve composite answers stored by the Dashboard.
*/
getResolvedMultiAnswer(compositeId: string): QuestionAnswer[] | undefined {
const entry = this.resolvedMultiAnswers.get(compositeId);
if (entry) {
this.resolvedMultiAnswers.delete(compositeId);
return entry.answers;
}
return undefined;
}
/**
* Remove stale surfaces (older than specified time)
* @param maxAge - Maximum age in milliseconds
@@ -362,6 +576,18 @@ export class A2UIWebSocketHandler {
}
}
// Clean up stale resolved answers
for (const [id, entry] of this.resolvedAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedAnswers.delete(id);
}
}
for (const [id, entry] of this.resolvedMultiAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedMultiAnswers.delete(id);
}
}
return removed;
}
}

View File

@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
// Build CLI arguments based on index type
// Use 'index init' subcommand (new CLI structure)
const args = ['index', 'init', targetPath, '--json'];
// --force flag ensures full reindex (not incremental)
const args = ['index', 'init', targetPath, '--force', '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
@@ -380,8 +381,10 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
}
}
// Build CLI arguments for incremental update using 'index update' subcommand
const args = ['index', 'update', targetPath, '--json'];
// Build CLI arguments for incremental update using 'index init' without --force
// 'index init' defaults to incremental mode (skip unchanged files)
// 'index update' is only for single-file updates in hooks
const args = ['index', 'init', targetPath, '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {

View File

@@ -8,6 +8,8 @@ import {
executeCodexLens,
installSemantic,
} from '../../../tools/codex-lens.js';
import { getCodexLensPython } from '../../../utils/codexlens-path.js';
import { spawn } from 'child_process';
import type { GpuMode } from '../../../tools/codex-lens.js';
import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js';
import {
@@ -19,6 +21,86 @@ import { extractJSON } from './utils.js';
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
/**
* Execute CodexLens Python API call directly (bypasses CLI for richer API access).
*/
async function executeCodexLensPythonAPI(
apiFunction: string,
args: Record<string, unknown>,
timeout: number = 60000
): Promise<{ success: boolean; results?: unknown; error?: string }> {
return new Promise((resolve) => {
const pythonScript = `
import json
import sys
from dataclasses import is_dataclass, asdict
from codexlens.api import ${apiFunction}
def to_serializable(obj):
if obj is None:
return None
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, list):
return [to_serializable(item) for item in obj]
if isinstance(obj, dict):
return {key: to_serializable(value) for key, value in obj.items()}
if isinstance(obj, tuple):
return tuple(to_serializable(item) for item in obj)
return obj
try:
args = ${JSON.stringify(args)}
result = ${apiFunction}(**args)
output = to_serializable(result)
print(json.dumps({"success": True, "result": output}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
`;
const pythonPath = getCodexLensPython();
const child = spawn(pythonPath, ['-c', pythonScript], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
try {
const errorData = JSON.parse(stderr || stdout);
resolve({ success: false, error: errorData.error || 'Unknown error' });
} catch {
resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
}
return;
}
try {
const data = JSON.parse(stdout);
resolve({ success: data.success, results: data.result, error: data.error });
} catch (err) {
resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
}
});
child.on('error', (err) => {
resolve({ success: false, error: `Failed to execute: ${err.message}` });
});
});
}
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
@@ -928,5 +1010,154 @@ except Exception as e:
return true;
}
// ============================================================
// LSP / SEMANTIC SEARCH API ENDPOINTS
// ============================================================
// API: LSP Status - Check if LSP/semantic search capabilities are available
if (pathname === '/api/codexlens/lsp/status') {
try {
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: 'CodexLens not installed'
}));
return true;
}
// Check semantic deps and vector index availability in parallel
const [semanticStatus, workspaceResult] = await Promise.all([
checkSemanticStatus(),
executeCodexLens(['status', '--json'])
]);
let hasVectorIndex = false;
let projectCount = 0;
let embeddingsInfo: Record<string, unknown> = {};
if (workspaceResult.success) {
try {
const status = extractJSON(workspaceResult.output ?? '');
if (status.success !== false && status.result) {
projectCount = status.result.projects_count || 0;
embeddingsInfo = status.result.embeddings || {};
// Check if any projects have embeddings
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
} else if (status.projects_count !== undefined) {
projectCount = status.projects_count || 0;
embeddingsInfo = status.embeddings || {};
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
}
} catch {
// Parse failed
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: semanticStatus.available && hasVectorIndex,
semantic_available: semanticStatus.available,
vector_index: hasVectorIndex,
project_count: projectCount,
embeddings: embeddingsInfo,
modes: ['fusion', 'vector', 'structural'],
strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
}));
} catch (err: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: err instanceof Error ? err.message : String(err)
}));
}
return true;
}
// API: LSP Semantic Search - Advanced semantic search via Python API
if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const {
query,
path: projectPath,
mode = 'fusion',
fusion_strategy = 'rrf',
vector_weight = 0.5,
structural_weight = 0.3,
keyword_weight = 0.2,
kind_filter,
limit = 20,
include_match_reason = false,
} = body as {
query?: unknown;
path?: unknown;
mode?: unknown;
fusion_strategy?: unknown;
vector_weight?: unknown;
structural_weight?: unknown;
keyword_weight?: unknown;
kind_filter?: unknown;
limit?: unknown;
include_match_reason?: unknown;
};
const resolvedQuery = typeof query === 'string' ? query.trim() : '';
if (!resolvedQuery) {
return { success: false, error: 'Query parameter is required', status: 400 };
}
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
const resolvedStrategy = typeof fusion_strategy === 'string' &&
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
const resolvedLimit = typeof limit === 'number' ? limit : 20;
const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
// Build Python API call args
const apiArgs: Record<string, unknown> = {
project_root: targetPath,
query: resolvedQuery,
mode: resolvedMode,
vector_weight: resolvedVectorWeight,
structural_weight: resolvedStructuralWeight,
keyword_weight: resolvedKeywordWeight,
fusion_strategy: resolvedStrategy,
limit: resolvedLimit,
include_match_reason: resolvedIncludeReason,
};
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
apiArgs.kind_filter = kind_filter;
}
try {
const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
if (result.success) {
return {
success: true,
results: result.results,
query: resolvedQuery,
mode: resolvedMode,
fusion_strategy: resolvedStrategy,
count: Array.isArray(result.results) ? result.results.length : 0,
};
} else {
return { success: false, error: result.error || 'Semantic search failed', status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
// Parse enabled tools from request body
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
? (body.enabledTools as string[]).join(',')
: 'write_file,edit_file,read_file,core_memory,ask_question';
: 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
// Generate CCW MCP server config
// Use cmd /c on Windows to inherit Claude Code's working directory

View File

@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// API: Test multi-page ask_question popup (for development testing)
if (pathname === '/api/test/ask-question-multi' && req.method === 'GET') {
try {
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
const compositeId = `multi-${Date.now()}`;
const surfaceId = `question-${compositeId}`;
const testSurface = {
surfaceId,
components: [
// Page 0: Select question
{ id: 'page-0-title', page: 0, component: { Text: { text: { literalString: 'Which framework?' }, usageHint: 'h3' } } },
{ id: 'page-0-radio-group', page: 0, component: {
RadioGroup: {
options: [
{ label: { literalString: 'React' }, value: 'React', description: { literalString: 'UI library' } },
{ label: { literalString: 'Vue' }, value: 'Vue', description: { literalString: 'Progressive framework' } },
{ label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' } },
],
onChange: { actionId: 'select', parameters: { questionId: 'Framework' } },
},
}},
// Page 1: Multi-select question
{ id: 'page-1-title', page: 1, component: { Text: { text: { literalString: 'Which features?' }, usageHint: 'h3' } } },
{ id: 'page-1-checkbox-0', page: 1, component: {
Checkbox: { label: { literalString: 'Auth' }, description: { literalString: 'Authentication' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Auth' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-1', page: 1, component: {
Checkbox: { label: { literalString: 'Cache' }, description: { literalString: 'Caching layer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Cache' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-other', page: 1, component: {
Checkbox: { label: { literalString: 'Other' }, description: { literalString: 'Provide a custom answer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: '__other__' } }, checked: { literalBoolean: false } },
}},
],
initialState: {
questionId: compositeId,
questionType: 'multi-question',
pages: [
{ index: 0, questionId: 'Framework', title: 'Which framework?', type: 'select' },
{ index: 1, questionId: 'Features', title: 'Which features?', type: 'multi-select' },
],
totalPages: 2,
},
displayMode: 'popup' as const,
};
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Multi-page test popup sent',
sentToClients: sentCount,
surfaceId,
compositeId,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
}
return true;
}
// API: A2UI answer broker — retrieve answers stored for MCP polling
if (pathname === '/api/a2ui/answer' && req.method === 'GET') {
const questionId = url.searchParams.get('questionId');
const isComposite = url.searchParams.get('composite') === 'true';
if (!questionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'questionId is required' }));
return true;
}
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
if (isComposite) {
const answers = a2uiWebSocketHandler.getResolvedMultiAnswer(questionId);
if (answers) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answers }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
} else {
const answer = a2uiWebSocketHandler.getResolvedAnswer(questionId);
if (answer) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answer }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
}
return true;
}
return false;
}

View File

@@ -0,0 +1,238 @@
/**
* Unsplash Proxy Routes & Background Image Upload
* Proxies Unsplash API requests to keep API key server-side.
* API key is read from process.env.UNSPLASH_ACCESS_KEY.
* Also handles local background image upload and serving.
*/
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { randomBytes } from 'crypto';
import { homedir } from 'os';
import type { RouteContext } from './types.js';
const UNSPLASH_API = 'https://api.unsplash.com';
// Background upload config
const UPLOADS_DIR = join(homedir(), '.ccw', 'uploads', 'backgrounds');
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const EXT_MAP: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
const MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
function getAccessKey(): string | undefined {
return process.env.UNSPLASH_ACCESS_KEY;
}
interface UnsplashPhoto {
id: string;
urls: { thumb: string; small: string; regular: string };
user: { name: string; links: { html: string } };
links: { html: string; download_location: string };
blur_hash: string | null;
}
interface UnsplashSearchResult {
results: UnsplashPhoto[];
total: number;
total_pages: number;
}
export async function handleBackgroundRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res } = ctx;
// POST /api/background/upload
if (pathname === '/api/background/upload' && req.method === 'POST') {
const contentType = req.headers['content-type'] || '';
if (!ALLOWED_TYPES.has(contentType)) {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
return true;
}
try {
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req) {
totalSize += chunk.length;
if (totalSize > MAX_UPLOAD_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
return true;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const ext = EXT_MAP[contentType] || 'bin';
const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
mkdirSync(UPLOADS_DIR, { recursive: true });
writeFileSync(join(UPLOADS_DIR, filename), buffer);
const url = `/api/background/uploads/${filename}`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url, filename }));
} catch (err) {
console.error('[background] Upload error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Upload failed' }));
}
return true;
}
// GET /api/background/uploads/:filename
if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
const filename = pathname.slice('/api/background/uploads/'.length);
// Security: reject path traversal
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid filename' }));
return true;
}
const filePath = join(UPLOADS_DIR, filename);
if (!existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
const mime = MIME_MAP[ext] || 'application/octet-stream';
try {
const data = readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mime,
'Content-Length': data.length,
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} catch (err) {
console.error('[background] Serve error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read file' }));
}
return true;
}
return false;
}
export async function handleUnsplashRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res } = ctx;
// GET /api/unsplash/search?query=...&page=1&per_page=20
if (pathname === '/api/unsplash/search' && req.method === 'GET') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
const query = url.searchParams.get('query') || '';
const page = url.searchParams.get('page') || '1';
const perPage = url.searchParams.get('per_page') || '20';
if (!query.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing query parameter' }));
return true;
}
try {
const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
const response = await fetch(apiUrl, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
if (!response.ok) {
const status = response.status;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
return true;
}
const data = (await response.json()) as UnsplashSearchResult;
// Return simplified data
const photos = data.results.map((photo) => ({
id: photo.id,
thumbUrl: photo.urls.thumb,
smallUrl: photo.urls.small,
regularUrl: photo.urls.regular,
photographer: photo.user.name,
photographerUrl: photo.user.links.html,
photoUrl: photo.links.html,
blurHash: photo.blur_hash,
downloadLocation: photo.links.download_location,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
photos,
total: data.total,
totalPages: data.total_pages,
}));
} catch (err) {
console.error('[unsplash] Search error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
}
return true;
}
// POST /api/unsplash/download — trigger download event (Unsplash API requirement)
if (pathname === '/api/unsplash/download' && req.method === 'POST') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
try {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString());
const downloadLocation = body.downloadLocation;
if (!downloadLocation) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
return true;
}
// Trigger download event (Unsplash API guideline)
await fetch(downloadLocation, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('[unsplash] Download trigger error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to trigger download' }));
}
return true;
}
return false;
}

View File

@@ -13,6 +13,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
@@ -461,7 +462,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
const tokenManager = getTokenManager();
const secretKey = tokenManager.getSecretKey();
tokenManager.getOrCreateAuthToken();
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question']);
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
@@ -627,6 +628,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleHooksRoutes(routeContext)) return;
}
// Background image upload/serve routes (/api/background/*)
if (pathname.startsWith('/api/background/')) {
if (await handleBackgroundRoutes(routeContext)) return;
}
// Unsplash proxy routes (/api/unsplash/*)
if (pathname.startsWith('/api/unsplash/')) {
if (await handleUnsplashRoutes(routeContext)) return;
}
// CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens/')) {
if (await handleCodexLensRoutes(routeContext)) return;
@@ -728,11 +739,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return;
}
// System routes (data, health, version, paths, shutdown, notify, storage, dialog)
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker)
if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname === '/api/a2ui/answer' ||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
if (await handleSystemRoutes(routeContext)) return;
}

View File

@@ -164,6 +164,9 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
wsClients.add(socket);
console.log(`[WS] Client connected (${wsClients.size} total)`);
// Replay any buffered A2UI surfaces to the new client
a2uiWebSocketHandler.replayPendingSurfaces(socket);
// Handle incoming messages
let pendingBuffer = Buffer.alloc(0);

View File

@@ -21,8 +21,8 @@ const SERVER_VERSION = '6.2.0';
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
// Default enabled tools (core set - file operations and core memory only)
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory'];
// Default enabled tools (core set - file operations, core memory, and smart search)
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory', 'smart_search'];
/**
* Get list of enabled tools from environment or defaults

View File

@@ -7,16 +7,18 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
execute,
handleAnswer,
handleMultiAnswer,
cancelQuestion,
getPendingQuestions,
clearPendingQuestions,
} from '../tools/ask-question';
handler,
} from '../ask-question';
import type {
Question,
QuestionAnswer,
AskQuestionParams,
AskQuestionResult,
} from '../core/a2ui/A2UITypes';
} from '../../core/a2ui/A2UITypes';
describe('ask_question Tool', () => {
beforeEach(() => {
@@ -44,8 +46,11 @@ describe('ask_question Tool', () => {
};
// Should not throw during validation
const result = await execute(params);
expect(result).toBeDefined();
const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
cancelQuestion('test-question-1');
await executePromise;
});
it('should validate a valid select question with options', async () => {
@@ -60,9 +65,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
const result = await execute(params);
const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined();
cancelQuestion('test-select');
await executePromise;
});
it('should validate a valid input question', async () => {
@@ -74,9 +81,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
const result = await execute(params);
const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined();
cancelQuestion('test-input');
await executePromise;
});
it('should validate a valid multi-select question', async () => {
@@ -92,9 +101,11 @@ describe('ask_question Tool', () => {
};
const params: AskQuestionParams = { question };
const result = await execute(params);
const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined();
cancelQuestion('test-multi');
await executePromise;
});
it('should reject question with missing id', async () => {
@@ -162,7 +173,7 @@ describe('ask_question Tool', () => {
const result = await execute(params);
expect(result.success).toBe(false);
expect(result.error).toContain('options');
expect(result.error).toContain('option');
});
it('should reject options with missing value', async () => {
@@ -554,10 +565,9 @@ describe('ask_question Tool', () => {
id: 'test-timeout',
type: 'confirm',
title: 'Test',
timeout: 5000, // 5 seconds
};
const params: AskQuestionParams = { question };
const params: AskQuestionParams = { question, timeout: 5000 };
const executePromise = execute(params);
// Fast-forward time
@@ -658,7 +668,8 @@ describe('ask_question Tool', () => {
};
const params1: AskQuestionParams = { question };
const executePromise1 = execute(params1);
// Don't await — first promise becomes orphaned when id is reused
execute(params1);
// Second execution with same ID should replace first
const question2: Question = {
@@ -671,10 +682,11 @@ describe('ask_question Tool', () => {
// There should still be only one pending
expect(getPendingQuestions()).toHaveLength(1);
expect(getPendingQuestions()[0].question.title).toBe('Second');
// Clean up
// Clean up the active pending question
cancelQuestion('duplicate-id');
await Promise.all([executePromise1, executePromise2]);
await executePromise2;
});
it('should handle answer after question is cancelled', async () => {
@@ -730,4 +742,204 @@ describe('ask_question Tool', () => {
}
});
});
describe('AskUserQuestion-style Format (via handler)', () => {
it('should handle single select question', async () => {
const params = {
questions: [{
question: 'Which library?',
header: 'Library',
multiSelect: false,
options: [
{ label: 'React', description: 'UI library' },
{ label: 'Vue', description: 'Progressive framework' },
],
}],
};
const handlerPromise = handler(params);
// Answer the normalized question (id = header)
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe('Library');
expect(pending[0].question.type).toBe('select');
expect(pending[0].question.title).toBe('Which library?');
// Options should use label as value
expect(pending[0].question.options).toEqual([
{ value: 'React', label: 'React', description: 'UI library' },
{ value: 'Vue', label: 'Vue', description: 'Progressive framework' },
]);
const answer: QuestionAnswer = {
questionId: 'Library',
value: 'React',
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Library: 'React' });
});
it('should handle multiSelect question', async () => {
const params = {
questions: [{
question: 'Which features?',
header: 'Features',
multiSelect: true,
options: [
{ label: 'Auth', description: 'Authentication' },
{ label: 'Cache', description: 'Caching layer' },
{ label: 'Logging' },
],
}],
};
const handlerPromise = handler(params);
const pending = getPendingQuestions();
expect(pending[0].question.type).toBe('multi-select');
const answer: QuestionAnswer = {
questionId: 'Features',
value: ['Auth', 'Logging'],
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Features: ['Auth', 'Logging'] });
});
it('should handle input question (no options)', async () => {
const params = {
questions: [{
question: 'What is your name?',
header: 'Name',
multiSelect: false,
}],
};
const handlerPromise = handler(params);
const pending = getPendingQuestions();
expect(pending[0].question.type).toBe('input');
const answer: QuestionAnswer = {
questionId: 'Name',
value: 'John',
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Name: 'John' });
});
it('should handle multiple questions in single multi-page surface', async () => {
const params = {
questions: [
{
question: 'Which library?',
header: 'Library',
multiSelect: false,
options: [
{ label: 'React' },
{ label: 'Vue' },
],
},
{
question: 'Which level?',
header: 'Level',
multiSelect: false,
options: [
{ label: 'Beginner' },
{ label: 'Advanced' },
],
},
],
};
const handlerPromise = handler(params);
// A single composite question should be pending
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toMatch(/^multi-/);
const compositeId = pending[0].id;
// Simulate submit-all with answers for all pages
handleMultiAnswer(compositeId, [
{ questionId: 'Library', value: 'React', cancelled: false },
{ questionId: 'Level', value: 'Advanced', cancelled: false },
]);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({
Library: 'React',
Level: 'Advanced',
});
});
it('should cancel multi-question composite on cancel', async () => {
const params = {
questions: [
{
question: 'First?',
header: 'Q1',
multiSelect: false,
options: [{ label: 'A' }],
},
{
question: 'Second?',
header: 'Q2',
multiSelect: false,
options: [{ label: 'B' }],
},
],
};
const handlerPromise = handler(params);
// Cancel the composite question
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
cancelQuestion(pending[0].id);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(true);
});
it('should still support legacy format via handler', async () => {
const params = {
question: {
id: 'legacy-test',
type: 'confirm',
title: 'Legacy question?',
},
};
const handlerPromise = handler(params as any);
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe('legacy-test');
const answer: QuestionAnswer = {
questionId: 'legacy-test',
value: true,
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
// Legacy format should NOT have answersDict
expect((result.result as any).answersDict).toBeUndefined();
});
});
});

View File

@@ -13,9 +13,19 @@ import type {
AskQuestionParams,
AskQuestionResult,
PendingQuestion,
SimpleQuestion,
} from '../core/a2ui/A2UITypes.js';
import http from 'http';
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
const POLL_INTERVAL_MS = 1000;
// Register multi-answer callback for multi-page question surfaces
a2uiWebSocketHandler.registerMultiAnswerCallback(
(compositeId: string, answers: QuestionAnswer[]) => handleMultiAnswer(compositeId, answers)
);
// ========== Constants ==========
/** Default question timeout (5 minutes) */
@@ -114,6 +124,10 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
if (!question.options) {
return false;
}
// Accept __other__ as a valid value (custom input)
if (answer.value === '__other__' || answer.value.startsWith('__other__:')) {
return true;
}
return question.options.some((opt) => opt.value === answer.value);
case 'multi-select':
@@ -124,13 +138,51 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
return false;
}
const validValues = new Set(question.options.map((opt) => opt.value));
return answer.value.every((v) => validValues.has(v));
// Accept __other__ as a valid value (custom input)
validValues.add('__other__');
return answer.value.every((v) => typeof v === 'string' && (validValues.has(v) || v.startsWith('__other__:')));
default:
return false;
}
}
// ========== Simple Format Normalization ==========
/**
* Normalize a SimpleQuestion (AskUserQuestion-style) to internal Question format
* @param simple - SimpleQuestion to normalize
* @returns Normalized Question
*/
function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
let type: QuestionType;
if (simple.options && simple.options.length > 0) {
type = simple.multiSelect ? 'multi-select' : 'select';
} else {
type = 'input';
}
const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
value: opt.label,
label: opt.label,
description: opt.description,
}));
return {
id: simple.header,
type,
title: simple.question,
options,
} as Question;
}
/**
* Detect if params use the new "questions" array format
*/
function isSimpleFormat(params: Record<string, unknown>): params is { questions: SimpleQuestion[]; timeout?: number } {
return Array.isArray(params.questions);
}
// ========== A2UI Surface Generation ==========
/**
@@ -223,6 +275,13 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Add "Other" option for custom input
options.push({
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
});
// Use RadioGroup for direct selection display (not dropdown)
components.push({
id: 'radio-group',
@@ -267,6 +326,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Add each checkbox as a separate component for better layout control
@@ -276,6 +336,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
component: {
Checkbox: {
label: opt.label,
...(opt.description && { description: opt.description }),
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
checked: { literalBoolean: false },
},
@@ -283,6 +344,19 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
});
});
// Add "Other" checkbox for custom input
components.push({
id: 'checkbox-other',
component: {
Checkbox: {
label: { literalString: 'Other' },
description: { literalString: 'Provide a custom answer' },
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: '__other__' } },
checked: { literalBoolean: false },
},
},
});
// Submit/cancel actions for multi-select so users can choose multiple options before resolving
components.push({
id: 'submit-btn',
@@ -390,7 +464,12 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
// Send A2UI surface via WebSocket to frontend
const a2uiSurface = generateQuestionSurface(question, surfaceId);
a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
// If no local WS clients, start HTTP polling for answer from Dashboard
if (sentCount === 0) {
startAnswerPolling(question.id);
}
// Wait for answer
const result = await resultPromise;
@@ -440,6 +519,85 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
return true;
}
/**
* Handle multi-question composite answer from frontend (submit-all)
* @param compositeId - The composite question ID (multi-xxx)
* @param answers - Array of answers for each page
* @returns True if answer was processed
*/
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
const pending = pendingQuestions.get(compositeId);
if (!pending) {
return false;
}
pending.resolve({
success: true,
surfaceId: pending.surfaceId,
cancelled: false,
answers,
timestamp: new Date().toISOString(),
});
pendingQuestions.delete(compositeId);
return true;
}
// ========== Answer Polling (MCP stdio mode) ==========
/**
* Poll Dashboard server for answers when running in a separate MCP process.
* Starts polling GET /api/a2ui/answer and resolves the pending promise when an answer arrives.
* Automatically stops when the questionId is no longer in pendingQuestions (timeout cleanup).
*/
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
const path = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
const poll = () => {
// Stop if the question was already resolved or timed out
if (!pendingQuestions.has(questionId)) {
return;
}
const req = http.get({ hostname: 'localhost', port: DASHBOARD_PORT, path }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (parsed.pending) {
// No answer yet, schedule next poll
setTimeout(poll, POLL_INTERVAL_MS);
return;
}
if (isComposite && Array.isArray(parsed.answers)) {
handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
} else if (!isComposite && parsed.answer) {
handleAnswer(parsed.answer as QuestionAnswer);
} else {
// Unexpected shape, keep polling
setTimeout(poll, POLL_INTERVAL_MS);
}
} catch {
// Parse error, keep polling
setTimeout(poll, POLL_INTERVAL_MS);
}
});
});
req.on('error', () => {
// Network error (Dashboard not reachable), keep trying
if (pendingQuestions.has(questionId)) {
setTimeout(poll, POLL_INTERVAL_MS);
}
});
};
// Start first poll after a short delay to give the Dashboard time to receive the surface
setTimeout(poll, POLL_INTERVAL_MS);
}
// ========== Cleanup ==========
/**
@@ -488,12 +646,70 @@ export function clearPendingQuestions(): void {
export const schema: ToolSchema = {
name: 'ask_question',
description: 'Ask the user a question through an interactive A2UI interface. Supports confirmation dialogs, selection from options, text input, and multi-select checkboxes.',
description: `Ask the user a question through an interactive A2UI interface. Supports two calling styles:
**Style 1 - AskUserQuestion-compatible (recommended)**:
\`\`\`json
{
"questions": [{
"question": "Which library?",
"header": "Library",
"multiSelect": false,
"options": [
{ "label": "React", "description": "UI library" },
{ "label": "Vue", "description": "Progressive framework" }
]
}]
}
\`\`\`
Response includes \`answersDict\`: \`{ "Library": "React" }\`
Type inference: options + multiSelect=true → multi-select; options + multiSelect=false → select; no options → input.
**Style 2 - Legacy format**:
\`\`\`json
{
"question": {
"id": "q1",
"type": "select",
"title": "Which library?",
"options": [{ "value": "react", "label": "React" }]
}
}
\`\`\``,
inputSchema: {
type: 'object',
properties: {
questions: {
type: 'array',
description: 'AskUserQuestion-style questions array (1-4 questions). Use this OR "question", not both.',
items: {
type: 'object',
properties: {
question: { type: 'string', description: 'The question text' },
header: { type: 'string', description: 'Short label, also used as response key (max 12 chars)' },
multiSelect: { type: 'boolean', description: 'Allow multiple selections (default: false)' },
options: {
type: 'array',
description: 'Available choices. Omit for text input.',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Display text, also used as value' },
description: { type: 'string', description: 'Option description' },
},
required: ['label'],
},
},
},
required: ['question', 'header'],
},
minItems: 1,
maxItems: 4,
},
question: {
type: 'object',
description: 'Legacy format: single question object. Use this OR "questions", not both.',
properties: {
id: { type: 'string', description: 'Unique identifier for this question' },
type: {
@@ -524,16 +740,343 @@ export const schema: ToolSchema = {
required: ['id', 'type', 'title'],
},
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000 / 5 minutes)' },
surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided)' },
surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided). Legacy format only.' },
},
required: ['question'],
},
};
/**
* Tool handler for MCP integration
* Wraps the execute function to match the expected handler signature
* Supports both legacy format (question object) and AskUserQuestion-style format (questions array)
*/
export async function handler(params: Record<string, unknown>): Promise<ToolResult<AskQuestionResult>> {
if (isSimpleFormat(params)) {
return executeSimpleFormat(params.questions, params.timeout);
}
return execute(params as AskQuestionParams);
}
// ========== Multi-Question Surface Generation ==========
/**
* Page metadata for multi-question surfaces
*/
interface PageMeta {
index: number;
questionId: string;
title: string;
type: string;
}
/**
* Generate a single A2UI surface containing all questions, each tagged with a page index.
* @param questions - Array of SimpleQuestion
* @returns Surface update with page-tagged components and page metadata
*/
function generateMultiQuestionSurface(
questions: SimpleQuestion[],
surfaceId: string,
): {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode: 'popup';
};
pages: PageMeta[];
} {
const components: unknown[] = [];
const pages: PageMeta[] = [];
for (let pageIdx = 0; pageIdx < questions.length; pageIdx++) {
const simpleQ = questions[pageIdx];
const question = normalizeSimpleQuestion(simpleQ);
const qId = question.id; // header used as id
pages.push({
index: pageIdx,
questionId: qId,
title: question.title,
type: question.type,
});
// Title
components.push({
id: `page-${pageIdx}-title`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.title },
usageHint: 'h3',
},
},
});
// Message
if (question.message) {
components.push({
id: `page-${pageIdx}-message`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.message },
usageHint: 'p',
},
},
});
}
// Description
if (question.description) {
components.push({
id: `page-${pageIdx}-description`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.description },
usageHint: 'small',
},
},
});
}
// Interactive components based on question type
switch (question.type) {
case 'select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Add "Other" option for custom input
options.push({
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
});
components.push({
id: `page-${pageIdx}-radio-group`,
page: pageIdx,
component: {
RadioGroup: {
options,
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'select', parameters: { questionId: qId } },
},
},
});
break;
}
case 'multi-select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
options.forEach((opt, idx) => {
components.push({
id: `page-${pageIdx}-checkbox-${idx}`,
page: pageIdx,
component: {
Checkbox: {
label: opt.label,
...(opt.description && { description: opt.description }),
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: opt.value } },
checked: { literalBoolean: false },
},
},
});
});
// Add "Other" checkbox for custom input
components.push({
id: `page-${pageIdx}-checkbox-other`,
page: pageIdx,
component: {
Checkbox: {
label: { literalString: 'Other' },
description: { literalString: 'Provide a custom answer' },
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: '__other__' } },
checked: { literalBoolean: false },
},
},
});
break;
}
case 'input': {
components.push({
id: `page-${pageIdx}-input`,
page: pageIdx,
component: {
TextField: {
value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'input-change', parameters: { questionId: qId } },
placeholder: question.placeholder || 'Enter your answer',
type: 'text',
},
},
});
break;
}
case 'confirm': {
// Confirm type gets handled as a single boolean per page
// No extra component — the page navigation handles yes/no
break;
}
}
}
return {
surfaceUpdate: {
surfaceId,
components,
initialState: {
questionId: `multi-${Date.now()}`,
questionType: 'multi-question',
pages,
totalPages: questions.length,
},
displayMode: 'popup',
},
pages,
};
}
/**
* Execute questions in AskUserQuestion-style format.
* Single question: falls back to legacy sequential popup.
* Multiple questions: generates a single multi-page surface.
*/
async function executeSimpleFormat(
questions: SimpleQuestion[],
timeout?: number,
): Promise<ToolResult<AskQuestionResult>> {
// Single question: use legacy single-popup flow
if (questions.length === 1) {
const simpleQ = questions[0];
const question = normalizeSimpleQuestion(simpleQ);
const params = {
question,
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
} satisfies AskQuestionParams;
const result = await execute(params);
if (!result.success || !result.result) {
return result;
}
if (result.result.cancelled) {
return result;
}
const answersDict: Record<string, string | string[]> = {};
if (result.result.answers.length > 0) {
const answer = result.result.answers[0];
answersDict[simpleQ.header] = answer.value as string | string[];
}
return {
success: true,
result: {
success: true,
surfaceId: result.result.surfaceId,
cancelled: false,
answers: result.result.answers,
timestamp: new Date().toISOString(),
answersDict,
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
};
}
// Multiple questions: single multi-page surface
const compositeId = `multi-${Date.now()}`;
const surfaceId = `question-${compositeId}`;
const { surfaceUpdate, pages } = generateMultiQuestionSurface(questions, surfaceId);
// Create promise for the composite answer
const resultPromise = new Promise<AskQuestionResult>((resolve, reject) => {
const pendingQuestion: PendingQuestion = {
id: compositeId,
surfaceId,
question: {
id: compositeId,
type: 'input', // placeholder type — multi-question uses custom answer handling
title: 'Multi-question',
required: false,
},
timestamp: Date.now(),
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
resolve,
reject,
};
pendingQuestions.set(compositeId, pendingQuestion);
// Also register each sub-question's questionId pointing to the same pending entry
// so that select/toggle actions on individual questions get tracked
for (const page of pages) {
// Initialize selection tracking in the websocket handler
if (page.type === 'multi-select') {
a2uiWebSocketHandler.initMultiSelect(page.questionId);
} else if (page.type === 'select') {
a2uiWebSocketHandler.initSingleSelect(page.questionId);
}
}
setTimeout(() => {
if (pendingQuestions.has(compositeId)) {
pendingQuestions.delete(compositeId);
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
}
}, timeout ?? DEFAULT_TIMEOUT_MS);
});
// Send the surface
const sentCount = a2uiWebSocketHandler.sendSurface(surfaceUpdate);
// If no local WS clients, start HTTP polling for answer from Dashboard
if (sentCount === 0) {
startAnswerPolling(compositeId, true);
}
// Wait for answer
const result = await resultPromise;
// If cancelled, return as-is
if (result.cancelled) {
return { success: true, result };
}
// Build answersDict from the answers array
const answersDict: Record<string, string | string[]> = {};
if (result.answers) {
for (const answer of result.answers) {
// Find the matching SimpleQuestion by questionId (which maps to header)
const simpleQ = questions.find(q => q.header === answer.questionId);
if (simpleQ) {
answersDict[simpleQ.header] = answer.value as string | string[];
}
}
}
return {
success: true,
result: {
...result,
answersDict,
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
};
}