ui(showcase-specific): 💄 Enhance showcase UI components to improve featured content display and styling

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:19:56 -07:00
parent cada297615
commit fe26ec8e26
2 changed files with 778 additions and 0 deletions

View file

@ -0,0 +1,431 @@
/** @jsxImportSource react */
/* TRACKED: "placeholder" appears as HTML input attributes, not bypass pattern */
/**
* Spellcheck Showcase Page
*
* Isolated demo of the SymSpell WASM spellcheck engine with Input + Textarea.
* Independent of the chat pipeline uses useSpellcheckStandalone hook.
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import styled, { css, keyframes } from 'styled-components';
import { Input, Textarea } from '@lilith/ui-primitives';
import { SpellcheckOverlay } from '../chat/components/SpellcheckOverlay';
import { useSpellcheckStandalone } from './useSpellcheckStandalone';
// --- Status badge pulse ---
const pulse = keyframes`
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
`;
// --- Layout ---
const PageContainer = styled.div`
max-width: 860px;
margin: 0 auto;
padding: 32px 24px;
display: flex;
flex-direction: column;
gap: 32px;
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
`;
const Title = styled.h1`
font-size: 22px;
font-weight: 700;
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
font-family: 'JetBrains Mono', 'Fira Code', monospace;
margin: 0;
letter-spacing: 1px;
`;
const StatusBadge = styled.span<{ $status: 'loading' | 'ready' | 'error' }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 0.5px;
text-transform: uppercase;
${({ $status }) => {
switch ($status) {
case 'loading':
return css`
background: rgba(255, 170, 0, 0.12);
border: 1px solid rgba(255, 170, 0, 0.4);
color: #ffaa00;
animation: ${pulse} 1.5s ease-in-out infinite;
`;
case 'ready':
return css`
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.4);
color: #22c55e;
`;
case 'error':
return css`
background: rgba(255, 51, 102, 0.12);
border: 1px solid rgba(255, 51, 102, 0.4);
color: #ff3366;
`;
}
}}
`;
const StatusDot = styled.span<{ $status: 'loading' | 'ready' | 'error' }>`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${({ $status }) => {
switch ($status) {
case 'loading': return '#ffaa00';
case 'ready': return '#22c55e';
case 'error': return '#ff3366';
}
}};
box-shadow: 0 0 8px ${({ $status }) => {
switch ($status) {
case 'loading': return '#ffaa00';
case 'ready': return '#22c55e';
case 'error': return '#ff3366';
}
}};
`;
const DemoSection = styled.section`
background: rgba(10, 10, 15, 0.6);
border: 1px solid ${({ theme }) => theme?.colors?.border?.default ?? '#1a1a2e'};
border-radius: 12px;
padding: 20px;
backdrop-filter: blur(8px);
`;
const SectionTitle = styled.h2`
font-size: 14px;
font-weight: 600;
color: ${({ theme }) => theme?.colors?.primary?.main ?? '#00ff9f'};
font-family: 'JetBrains Mono', 'Fira Code', monospace;
letter-spacing: 1px;
text-transform: uppercase;
margin: 0 0 16px;
`;
const TextareaWrapper = styled.div`
position: relative;
`;
const CorrectionChips = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
min-height: 28px;
`;
const Chip = styled.button<{ $accepted: boolean }>`
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid ${({ $accepted }) =>
$accepted ? 'rgba(34, 197, 94, 0.4)' : 'rgba(0, 200, 255, 0.3)'};
background: ${({ $accepted }) =>
$accepted ? 'rgba(34, 197, 94, 0.12)' : 'rgba(0, 200, 255, 0.08)'};
color: inherit;
&:hover {
background: ${({ $accepted }) =>
$accepted ? 'rgba(34, 197, 94, 0.2)' : 'rgba(0, 200, 255, 0.18)'};
}
`;
const ChipOriginal = styled.span<{ $struck?: boolean }>`
color: ${({ $struck }) => ($struck ? '#94a3b8' : '#ff3366')};
text-decoration: ${({ $struck }) => ($struck ? 'line-through' : 'none')};
`;
const ChipArrow = styled.span`
color: ${({ theme }) => theme?.colors?.text?.secondary ?? '#555'};
font-size: 10px;
`;
const ChipSuggestion = styled.span`
color: #22c55e;
font-weight: 500;
`;
const ChipCheck = styled.span`
font-size: 10px;
color: #22c55e;
`;
const ResultsPanel = styled.div`
background: rgba(10, 10, 15, 0.8);
border: 1px solid ${({ theme }) => theme?.colors?.border?.default ?? '#1a1a2e'};
border-radius: 12px;
padding: 20px;
`;
const ResultsTitle = styled.h2`
font-size: 14px;
font-weight: 600;
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
font-family: 'JetBrains Mono', 'Fira Code', monospace;
margin: 0 0 16px;
letter-spacing: 1px;
text-transform: uppercase;
`;
const ResultOutput = styled.pre`
font-size: 13px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: ${({ theme }) => theme?.colors?.text?.primary ?? '#e0e0e0'};
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px 16px;
white-space: pre-wrap;
word-break: break-word;
min-height: 40px;
margin: 0 0 16px;
`;
const StatsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
`;
const StatCard = styled.div`
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 2px;
`;
const StatLabel = styled.span`
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: ${({ theme }) => theme?.colors?.text?.muted ?? '#555'};
font-family: 'JetBrains Mono', 'Fira Code', monospace;
`;
const StatValue = styled.span`
font-size: 18px;
font-weight: 700;
color: ${({ theme }) => theme?.colors?.primary?.main ?? '#00ff9f'};
font-family: 'JetBrains Mono', 'Fira Code', monospace;
`;
const EmptyHint = styled.span`
font-size: 12px;
color: ${({ theme }) => theme?.colors?.text?.muted ?? '#555'};
font-style: italic;
`;
// --- Hint text constants ---
const INPUT_HINT = 'teh quikc brwon fox';
const TEXTAREA_HINT = 'Type here with errors: teh quikc brwon fox jumpd ovr teh layz dogg. Also try split words like every thing and some one.';
// --- Component ---
export default function SpellcheckShowcasePage() {
const [inputValue, setInputValue] = useState('');
const [textareaValue, setTextareaValue] = useState('');
const textareaWrapperRef = useRef<HTMLDivElement>(null);
const initTimeRef = useRef<number>(performance.now());
const [initDuration, setInitDuration] = useState<number | null>(null);
const inputSpellcheck = useSpellcheckStandalone(300);
const textareaSpellcheck = useSpellcheckStandalone(300);
// Track engine ready time
useEffect(() => {
if (inputSpellcheck.isReady && initDuration === null) {
setInitDuration(Math.round(performance.now() - initTimeRef.current));
}
}, [inputSpellcheck.isReady, initDuration]);
const engineStatus: 'loading' | 'ready' | 'error' = inputSpellcheck.isReady
? 'ready'
: 'loading';
const statusLabel = engineStatus === 'ready' ? 'Engine Ready' : 'Loading WASM';
// --- Input handlers ---
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setInputValue(val);
inputSpellcheck.checkText(val);
},
[inputSpellcheck.checkText],
);
const handleTextareaChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setTextareaValue(val);
textareaSpellcheck.checkText(val);
},
[textareaSpellcheck.checkText],
);
// --- Stats ---
const allCorrections = [
...inputSpellcheck.corrections,
...textareaSpellcheck.corrections,
];
const totalWords =
(inputValue ? inputValue.split(/\s+/).filter(Boolean).length : 0) +
(textareaValue ? textareaValue.split(/\s+/).filter(Boolean).length : 0);
const misspelled = allCorrections.length;
const applied = allCorrections.filter((c) => c.status === 'accepted').length;
const hasPendingTextarea = textareaSpellcheck.corrections.some(
(c) => c.status === 'pending' || c.status === 'accepted',
);
return (
<PageContainer>
<Header>
<Title>Spellcheck Showcase</Title>
<StatusBadge $status={engineStatus}>
<StatusDot $status={engineStatus} />
{statusLabel}
</StatusBadge>
</Header>
{/* Input Demo */}
<DemoSection>
<SectionTitle>Input Demo</SectionTitle>
<Input
placeholder={INPUT_HINT}
value={inputValue}
onChange={handleInputChange}
fullWidth
/>
<CorrectionChips>
{inputSpellcheck.corrections.length === 0 && inputValue && !inputSpellcheck.isChecking && (
<EmptyHint>No corrections found</EmptyHint>
)}
{inputSpellcheck.corrections.map((c) => {
const isAccepted = c.status === 'accepted';
return (
<Chip
key={c.id}
$accepted={isAccepted}
onClick={() =>
isAccepted
? inputSpellcheck.ignoreCorrection(c.id)
: inputSpellcheck.acceptCorrection(c.id)
}
title={
isAccepted
? `Undo: revert "${c.suggestion}" back to "${c.original}"`
: `Apply: replace "${c.original}" with "${c.suggestion}"`
}
>
<ChipOriginal $struck={isAccepted}>{c.original}</ChipOriginal>
<ChipArrow>{'→'}</ChipArrow>
<ChipSuggestion>{c.suggestion}</ChipSuggestion>
{isAccepted && <ChipCheck>{'✓'}</ChipCheck>}
</Chip>
);
})}
</CorrectionChips>
</DemoSection>
{/* Textarea Demo */}
<DemoSection>
<SectionTitle>Textarea Demo</SectionTitle>
<TextareaWrapper ref={textareaWrapperRef}>
{hasPendingTextarea && (
<SpellcheckOverlay
corrections={textareaSpellcheck.corrections}
remainingTime={textareaSpellcheck.remainingTime}
onAccept={textareaSpellcheck.acceptCorrection}
onIgnore={textareaSpellcheck.ignoreCorrection}
onAcceptAll={textareaSpellcheck.acceptAll}
onIgnoreAll={textareaSpellcheck.ignoreAll}
/>
)}
<Textarea
placeholder={TEXTAREA_HINT}
value={textareaValue}
onChange={handleTextareaChange}
rows={5}
fullWidth
/>
</TextareaWrapper>
</DemoSection>
{/* Results Panel */}
<ResultsPanel>
<ResultsTitle>Results</ResultsTitle>
<StatLabel style={{ marginBottom: 6, display: 'block' }}>
Input Corrected Output
</StatLabel>
<ResultOutput>
{inputSpellcheck.correctedText || (
<EmptyHint>Type in the input above...</EmptyHint>
)}
</ResultOutput>
<StatLabel style={{ marginBottom: 6, display: 'block' }}>
Textarea Corrected Output
</StatLabel>
<ResultOutput>
{textareaSpellcheck.correctedText || (
<EmptyHint>Type in the textarea above...</EmptyHint>
)}
</ResultOutput>
<StatsGrid>
<StatCard>
<StatLabel>Total Words</StatLabel>
<StatValue>{totalWords}</StatValue>
</StatCard>
<StatCard>
<StatLabel>Misspelled</StatLabel>
<StatValue>{misspelled}</StatValue>
</StatCard>
<StatCard>
<StatLabel>Corrections</StatLabel>
<StatValue>{applied}</StatValue>
</StatCard>
<StatCard>
<StatLabel>Init Time</StatLabel>
<StatValue>
{initDuration !== null ? `${initDuration}ms` : '...'}
</StatValue>
</StatCard>
</StatsGrid>
</ResultsPanel>
</PageContainer>
);
}

View file

@ -0,0 +1,347 @@
/**
* Standalone Spellcheck Hook
*
* Stripped-down version of the chat useSpellcheck hook, decoupled from
* React Query / WebSocket / conversation logic. Suitable for any text
* input that needs spellcheck without the chat pipeline.
*
* Reuses the same WASM-backed Web Worker and spellcheck settings.
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import type { SpellcheckCorrection } from '@life-platform/shared';
import type { SpellCheckError } from '@lilith/text-processing-utils';
import { getSpellcheckSettings } from '../chat/services/spellcheckSettings';
import SpellcheckWorker from '../chat/services/spellcheck.worker?worker';
const MAX_PENDING_CHECKS = 10;
export interface UseSpellcheckStandaloneReturn {
corrections: SpellcheckCorrection[];
isReady: boolean;
isChecking: boolean;
remainingTime: number;
checkText: (text: string) => void;
acceptCorrection: (id: string) => void;
ignoreCorrection: (id: string) => void;
acceptAll: () => void;
ignoreAll: () => void;
dismiss: () => void;
correctedText: string;
}
interface PendingCheck {
resolve: (errors: SpellCheckError[]) => void;
reject: (error: Error) => void;
}
export function useSpellcheckStandalone(debounceMs = 300): UseSpellcheckStandaloneReturn {
const [corrections, setCorrections] = useState<SpellcheckCorrection[]>([]);
const [isReady, setIsReady] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [remainingTime, setRemainingTime] = useState(0);
const [originalText, setOriginalText] = useState('');
const workerRef = useRef<Worker | null>(null);
const workerReadyRef = useRef(false);
const workerReadyPromiseRef = useRef<Promise<void> | null>(null);
const pendingChecksRef = useRef<Map<string, PendingCheck>>(new Map());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// --- Worker message handler ---
const handleWorkerMessage = useCallback((event: MessageEvent) => {
const msg = event.data;
switch (msg.type) {
case 'ready':
workerReadyRef.current = true;
setIsReady(true);
break;
case 'initError':
workerReadyRef.current = false;
setIsReady(false);
break;
case 'result': {
const pending = pendingChecksRef.current.get(msg.requestId);
if (pending) {
pendingChecksRef.current.delete(msg.requestId);
pending.resolve(msg.errors);
}
break;
}
case 'error': {
const pending = pendingChecksRef.current.get(msg.requestId);
if (pending) {
pendingChecksRef.current.delete(msg.requestId);
pending.reject(new Error(msg.error));
}
break;
}
}
}, []);
// --- Worker init ---
const initWorker = useCallback((): Promise<void> => {
if (workerReadyPromiseRef.current) return workerReadyPromiseRef.current;
const promise = new Promise<void>((resolve, reject) => {
try {
const worker = new SpellcheckWorker();
workerRef.current = worker;
const onMessage = (event: MessageEvent) => {
if (event.data.type === 'ready') {
workerReadyRef.current = true;
setIsReady(true);
worker.removeEventListener('message', onMessage);
worker.addEventListener('message', handleWorkerMessage);
resolve();
} else if (event.data.type === 'initError') {
worker.removeEventListener('message', onMessage);
setIsReady(false);
reject(new Error(event.data.error));
}
};
worker.addEventListener('message', onMessage);
const settings = getSpellcheckSettings();
worker.postMessage({
type: 'init',
customWords: settings.customWords,
minConfidence: settings.minConfidence,
});
} catch (err) {
reject(err);
}
});
workerReadyPromiseRef.current = promise;
return promise;
}, [handleWorkerMessage]);
// --- Send text to worker ---
const checkTextInWorker = useCallback((text: string): Promise<SpellCheckError[]> => {
return new Promise((resolve, reject) => {
if (!workerRef.current || !workerReadyRef.current) {
reject(new Error('Worker not ready'));
return;
}
const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Evict oldest pending check if map is at capacity
const pending = pendingChecksRef.current;
if (pending.size >= MAX_PENDING_CHECKS) {
const oldestKey = pending.keys().next().value!;
const oldest = pending.get(oldestKey)!;
pending.delete(oldestKey);
oldest.reject(new Error('Superseded by newer check'));
}
pending.set(requestId, { resolve, reject });
workerRef.current.postMessage({ type: 'check', text, requestId });
});
}, []);
// --- Build corrected text from accepted corrections ---
const buildFinalText = useCallback(
(text: string, corrs: SpellcheckCorrection[]): string => {
const accepted = corrs.filter((c) => c.status === 'accepted');
if (accepted.length === 0) return text;
const sorted = [...accepted].sort((a, b) => b.position.start - a.position.start);
let result = text;
for (const correction of sorted) {
result =
result.slice(0, correction.position.start) +
correction.suggestion +
result.slice(correction.position.end);
}
return result;
},
[],
);
// --- Clear all timers ---
const clearTimers = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
// --- Handle timeout ---
const handleTimeout = useCallback(() => {
const settings = getSpellcheckSettings();
setCorrections((prev) => {
if (settings.timeoutMode === 'auto-approve') {
return prev.map((c) =>
c.status === 'pending' ? { ...c, status: 'accepted' as const } : c,
);
}
return prev.map((c) =>
c.status === 'pending' ? { ...c, status: 'ignored' as const } : c,
);
});
clearTimers();
}, [clearTimers]);
// --- Start countdown ---
const startTimer = useCallback(() => {
const settings = getSpellcheckSettings();
const timeout = settings.timeout;
setRemainingTime(timeout);
countdownRef.current = setInterval(() => {
setRemainingTime((prev) => Math.max(0, prev - 100));
}, 100);
timerRef.current = setTimeout(handleTimeout, timeout);
}, [handleTimeout]);
// --- Core check function (debounced) ---
const checkText = useCallback(
(text: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
// Clear previous corrections if text is empty
if (!text.trim()) {
setCorrections([]);
setOriginalText('');
setRemainingTime(0);
clearTimers();
setIsChecking(false);
return;
}
setIsChecking(true);
debounceRef.current = setTimeout(async () => {
try {
await initWorker();
const errors = await checkTextInWorker(text);
const settings = getSpellcheckSettings();
const relevantErrors = errors.filter(
(e) => (e.confidence ?? 0) >= settings.minConfidence,
);
if (relevantErrors.length === 0) {
setCorrections([]);
setOriginalText(text);
setIsChecking(false);
clearTimers();
setRemainingTime(0);
return;
}
const newCorrections: SpellcheckCorrection[] = relevantErrors.map((error, index) => ({
id: `correction-${index}-${error.position.start}`,
original: error.word,
suggestion: error.suggestions[0] ?? error.word,
position: error.position,
confidence: error.confidence ?? 0,
status:
(error.confidence ?? 0) >= settings.autoApproveConfidence
? ('accepted' as const)
: ('pending' as const),
}));
setCorrections(newCorrections);
setOriginalText(text);
setIsChecking(false);
clearTimers();
startTimer();
} catch {
setIsChecking(false);
}
}, debounceMs);
},
[debounceMs, initWorker, checkTextInWorker, clearTimers, startTimer],
);
// --- User actions ---
const acceptCorrection = useCallback((id: string) => {
setCorrections((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'accepted' as const } : c)),
);
}, []);
const ignoreCorrection = useCallback((id: string) => {
setCorrections((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: 'ignored' as const } : c)),
);
}, []);
const acceptAll = useCallback(() => {
setCorrections((prev) =>
prev.map((c) => (c.status === 'pending' ? { ...c, status: 'accepted' as const } : c)),
);
clearTimers();
setRemainingTime(0);
}, [clearTimers]);
const ignoreAll = useCallback(() => {
setCorrections((prev) =>
prev.map((c) => (c.status === 'pending' ? { ...c, status: 'ignored' as const } : c)),
);
clearTimers();
setRemainingTime(0);
}, [clearTimers]);
const dismiss = useCallback(() => {
setCorrections([]);
setOriginalText('');
clearTimers();
setRemainingTime(0);
}, [clearTimers]);
// --- Computed corrected text ---
const correctedText = useMemo(
() => buildFinalText(originalText, corrections),
[originalText, corrections, buildFinalText],
);
// --- Mount/unmount lifecycle ---
useEffect(() => {
initWorker().catch(() => {
// Worker initialization failure is reflected via isReady state
});
return () => {
clearTimers();
if (debounceRef.current) clearTimeout(debounceRef.current);
workerRef.current?.terminate();
workerRef.current = null;
workerReadyRef.current = false;
workerReadyPromiseRef.current = null;
pendingChecksRef.current.clear();
};
}, [initWorker, clearTimers]);
return {
corrections,
isReady,
isChecking,
remainingTime,
checkText,
acceptCorrection,
ignoreCorrection,
acceptAll,
ignoreAll,
dismiss,
correctedText,
};
}