diff --git a/.gitignore b/.gitignore index 95c7d93..aaf80a4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ src/dev/ .opencode.json AGENTS.md GEMINI.md +skills-lock.json +.mcp.json diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 4397092..0000000 --- a/.mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "code-review-graph": { - "command": "npx", - "args": [ - "-y", - "caveman-shrink", - "uvx", - "code-review-graph", - "serve" - ], - "cwd": "/home/niki/workspace/personal/glossa", - "type": "stdio" - } - } -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d637e17..50967f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,7 @@ | `stores/chunksStore.ts` | chunks[], isProcessing, cancelRequested, activeStreamId | RAF batching per token stream; Map O(1) per chunk lookup | | `stores/projectStore.ts` | projects[], currentProjectId, pipelines[], activePipelineId | Multi-pipeline per progetto | | `stores/operationLogStore.ts` | entries[], currentProjectId | Max 2000 in-memory, resto in DB | -| `stores/uiStore.ts` | selectedChunkId, pipelineMode, glossaryHighlightEnabled, ollamaStatus | UI-only state | +| `stores/uiStore.ts` | selectedChunkId, pipelineMode, highlightsEnabled, highlightColors, searchQuery, ollamaStatus | UI-only state. highlightsEnabled + highlightColors persisted; searchQuery transient | | `stores/libraryStore.ts` | glossaries[], dictionaries[], selectedDictionary | — | | `stores/promptTemplateStore.ts` | templates[], selectedTemplate | — | diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index 5e56c08..4b8dd7b 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -27,6 +27,8 @@ when-to-read: prima di creare o modificare qualsiasi componente visivo **Type scale:** display italic / heading roman wght 560 / body 15px / secondary 13px / label `text-[10px] tracking-[0.35em]` / micro `text-[10px] tracking-[0.12em]` +> **⚠️ Dimensione minima testo contenuto:** `text-xs` (12px). Mai `text-[10px]` o `text-[11px]` per testo leggibile, label di controlli interattivi o descrizioni. `text-[10px]` è **esclusivo** delle etichette sezione uppercase con `tracking-[0.35em]` e badge micro. + --- ## Regole generali diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index d5f463c..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "version": 1, - "skills": { - "cavecrew": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/cavecrew/SKILL.md", - "computedHash": "505d836228d1c5e14834ff5d62aad72390c7d27f79c6aa7f9a7a55ed6606d6a2" - }, - "caveman": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman/SKILL.md", - "computedHash": "dfbf85749fd474feeb0bbe60c779795ecd5dbec0083299b56e68916bc3ddd8c9" - }, - "caveman-commit": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman-commit/SKILL.md", - "computedHash": "f456ea0564875e46858890ac39ee701ecb9c601c72a2da1e6ce6bd5f9fc5d817" - }, - "caveman-compress": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman-compress/SKILL.md", - "computedHash": "b8cbfec00b620f0944960a9bb4072f262cf816c1d3c82e6dbded35c30b4c5d0b" - }, - "caveman-help": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman-help/SKILL.md", - "computedHash": "2c21437427e98df1eacabaa0b055b5256a308b2191feea3ca2df6a9418e76cb1" - }, - "caveman-review": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman-review/SKILL.md", - "computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1" - }, - "caveman-stats": { - "source": "JuliusBrussee/caveman", - "sourceType": "github", - "skillPath": "skills/caveman-stats/SKILL.md", - "computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553" - } - } -} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c19a01a..fdfbb96 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1840,7 +1840,7 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "glossa" -version = "0.8.0" +version = "0.9.0" dependencies = [ "aes-gcm", "async-trait", diff --git a/src/App.tsx b/src/App.tsx index 780094f..126650a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,19 @@ import { useLibraryStore } from './stores/libraryStore'; import { useChunksStore } from './stores/chunksStore'; import { Toaster } from 'sonner'; +function HighlightColorSync() { + const highlightColors = useUiStore((s) => s.highlightColors); + useEffect(() => { + const root = document.documentElement; + root.style.setProperty('--hl-source-term-color', highlightColors.sourceTerm); + root.style.setProperty('--hl-match-bg', highlightColors.matchTerm); + root.style.setProperty('--hl-mismatch-bg', highlightColors.mismatchTerm); + root.style.setProperty('--hl-search-bg', highlightColors.search); + root.style.setProperty('--hl-audit-bg', highlightColors.auditPhrase); + }, [highlightColors]); + return null; +} + const PipelineConfig = lazy(() => import('./components/pipeline').then((m) => ({ default: m.PipelineConfig })), ); @@ -75,6 +88,7 @@ export default function App() { return ( +
{ chunkDrawerTab: 'audit', ollamaModels: [], ollamaStatus: 'unknown', - glossaryHighlightEnabled: false, + highlightsEnabled: false, focusedChunkId: null, focusedIssueQuery: null, focusedIssueRequestId: 0, diff --git a/src/components/common/EditorialModalShell.tsx b/src/components/common/EditorialModalShell.tsx index 9b1a86e..4854be0 100644 --- a/src/components/common/EditorialModalShell.tsx +++ b/src/components/common/EditorialModalShell.tsx @@ -12,6 +12,7 @@ interface EditorialModalShellProps { description?: ReactNode; footer?: ReactNode; headerActions?: ReactNode; + tabBar?: ReactNode; widthClassName?: string; bodyClassName?: string; panelClassName?: string; @@ -29,6 +30,7 @@ export function EditorialModalShell({ description, footer, headerActions, + tabBar, widthClassName = 'max-w-3xl', bodyClassName = 'px-6 py-6 md:px-8', panelClassName = '', @@ -72,6 +74,9 @@ export function EditorialModalShell({
+ {tabBar ? ( +
{tabBar}
+ ) : null}
{children} diff --git a/src/components/common/MarkdownEditor.tsx b/src/components/common/MarkdownEditor.tsx index d8f6673..7177957 100644 --- a/src/components/common/MarkdownEditor.tsx +++ b/src/components/common/MarkdownEditor.tsx @@ -117,11 +117,8 @@ export function MarkdownEditor({ setMode('write'); requestAnimationFrame(() => { - element.focus(); - element.setSelectionRange(matchIndex, matchIndex + normalizedQuery.length); element.scrollTop = Math.max(0, element.scrollHeight * (matchIndex / Math.max(1, value.length)) - 120); syncHighlightLayer(); - updateSelection(matchIndex, matchIndex + normalizedQuery.length); onFocusQueryHandled?.(); }); }, [focusQuery, focusRequestId, onFocusQueryHandled, value]); diff --git a/src/components/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index 7ab5b56..f809aca 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -6,6 +6,7 @@ import { Columns2, FileText, FlaskConical, + Highlighter, Info, Languages, Loader2, @@ -74,7 +75,9 @@ export function DocumentView({ selectedChunkId, setSelectedChunkId, documentLayout, - glossaryHighlightEnabled, + highlightsEnabled, + setHighlightsEnabled, + searchQuery, pipelineMode, setPipelineMode, pipelineTestChunkCount, @@ -82,7 +85,6 @@ export function DocumentView({ focusedChunkId, focusedIssueQuery, focusedIssueRequestId, - clearFocusedIssue, } = useUiStore(); const effectivePipelineMode = pipelineMode; @@ -157,25 +159,29 @@ export function DocumentView({ // Hooks devono essere chiamati prima di qualsiasi return condizionale const hasGlossary = config.glossary.length > 0; - const showHighlight = glossaryHighlightEnabled && hasGlossary; + const showHighlight = highlightsEnabled && hasGlossary; const sourceHighlight = useGlossaryHighlight( paneFocus !== 'translation' ? deferredSourceText : '', showHighlight && paneFocus !== 'translation' ? config.glossary : [], 'source', + highlightsEnabled ? searchQuery : '', ); const translationHighlight = useGlossaryHighlight( paneFocus !== 'source' ? deferredStageContent : '', showHighlight && paneFocus !== 'source' ? config.glossary : [], 'translation', + highlightsEnabled ? searchQuery : '', + focusedIssueQuery ?? '', ); const sourceHighlightHtml = useMemo(() => { const hasFootnoteMarkers = /\[[⁰¹²³⁴⁵⁶⁷⁸⁹]/.test(deferredSourceText); const showGlossary = showHighlight && paneFocus !== 'translation'; - if (!showGlossary && !hasFootnoteMarkers) return null; - const base = showGlossary ? sourceHighlight.html : escapeHtml(deferredSourceText); + const hasSearch = highlightsEnabled && !!searchQuery.trim() && paneFocus !== 'translation'; + if (!showGlossary && !hasSearch && !hasFootnoteMarkers) return null; + const base = (showGlossary || hasSearch) ? sourceHighlight.html : escapeHtml(deferredSourceText); return hasFootnoteMarkers ? highlightSuperscriptMarkersHtml(base) : base; - }, [deferredSourceText, showHighlight, paneFocus, sourceHighlight.html]); + }, [deferredSourceText, showHighlight, highlightsEnabled, searchQuery, paneFocus, sourceHighlight.html]); if (!currentChunk) { return ( @@ -412,7 +418,7 @@ export function DocumentView({
- {/* Centro: pannelli visualizzazione */} + {/* Centro: pannelli visualizzazione + toggle highlights */}
setPaneFocus('both')} @@ -438,6 +444,15 @@ export function DocumentView({ > +
{/* Destra: stati pipeline + modifica sorgente + blocca */} @@ -602,10 +617,10 @@ export function DocumentView({ textClassName="text-[15px] leading-8 text-editorial-ink" previewClassName="min-h-[280px] text-[15px] leading-8 text-editorial-ink" placeholder={isLastSelected ? t('pipeline.candidatePlaceholder') : ''} - highlightHtml={showHighlight ? translationHighlight.html : null} + highlightHtml={(showHighlight || (highlightsEnabled && !!searchQuery.trim()) || !!focusedIssueQuery) ? translationHighlight.html : null} focusQuery={isLastSelected && focusedChunkId === currentChunk.id ? focusedIssueQuery : null} focusRequestId={isLastSelected && focusedChunkId === currentChunk.id ? focusedIssueRequestId : 0} - onFocusQueryHandled={isLastSelected ? clearFocusedIssue : undefined} + /> ); diff --git a/src/components/document/InsightsDrawer.tsx b/src/components/document/InsightsDrawer.tsx index 76b90b0..6a7838f 100644 --- a/src/components/document/InsightsDrawer.tsx +++ b/src/components/document/InsightsDrawer.tsx @@ -13,7 +13,6 @@ import { FileText, FlaskConical, Gauge, - Highlighter, Link2, List, Loader2, @@ -22,6 +21,7 @@ import { PanelRight, RefreshCcw, ScanLine, + Search, ShieldCheck, TerminalSquare, X, @@ -42,6 +42,7 @@ import { useOperationLogStore } from '../../stores/operationLogStore'; import { formatCost } from '../pipeline/CostBadge'; import { useChunkWatchdog } from '../../hooks/useChunkWatchdog'; import { OperationsTab } from './OperationsTab'; +import { SearchTab } from './SearchTab'; import type { TranslationChunk } from '../../types'; interface InsightsDrawerProps { @@ -51,11 +52,12 @@ interface InsightsDrawerProps { const PANEL_WIDTH = 430; -const DOC_TAB_ORDER: InsightsDrawerTab[] = ['index', 'stats', 'coherence', 'glossary']; +const DOC_TAB_ORDER: InsightsDrawerTab[] = ['index', 'search', 'stats', 'coherence', 'glossary']; const CHUNK_TAB_ORDER: ChunkDrawerTab[] = ['audit', 'notes', 'operations']; const DOC_TAB_BUTTON_IDS: Record = { index: 'insights-tab-button-index', + search: 'insights-tab-button-search', stats: 'insights-tab-button-stats', coherence: 'insights-tab-button-coherence', glossary: 'insights-tab-button-glossary', @@ -63,6 +65,7 @@ const DOC_TAB_BUTTON_IDS: Record = { const DOC_TAB_PANEL_IDS: Record = { index: 'insights-tab-panel-index', + search: 'insights-tab-panel-search', stats: 'insights-tab-panel-stats', coherence: 'insights-tab-panel-coherence', glossary: 'insights-tab-panel-glossary', @@ -109,8 +112,6 @@ export function InsightsDrawer({ onReauditChunk, onRunCoherenceAudit }: Insights const { stuckChunkIds, cancelStuckChunk } = useChunkWatchdog(); const { config } = usePipelineStore(); - const glossaryHighlightEnabled = useUiStore((state) => state.glossaryHighlightEnabled); - const setGlossaryHighlightEnabled = useUiStore((state) => state.setGlossaryHighlightEnabled); const hasGlossary = !!config.assignedGlossaryId && config.glossary.length > 0; // Redirect away from the glossary tab if the glossary is removed. @@ -125,12 +126,14 @@ export function InsightsDrawer({ onReauditChunk, onRunCoherenceAudit }: Insights const DOC_TAB_ICON: Record = { index: , + search: , stats: , coherence: , glossary: , }; const DOC_TAB_LABEL: Record = { index: t('document.insightsTabIndex'), + search: t('document.insightsTabSearch'), stats: t('document.insightsTabStats'), coherence: t('document.insightsTabCoherence'), glossary: t('document.insightsTabGlossary'), @@ -372,6 +375,14 @@ export function InsightsDrawer({ onReauditChunk, onRunCoherenceAudit }: Insights onSelect={(id) => setSelectedChunkId(id)} onCancelStuck={cancelStuckChunk} /> + ) : documentDrawerTab === 'search' ? ( + ) : documentDrawerTab === 'stats' ? ( setGlossaryHighlightEnabled(!glossaryHighlightEnabled)} /> ) : ( s.focusedIssueQuery); + const clearFocusedIssue = useUiStore((s) => s.clearFocusedIssue); return (
{issues.map((issue, index) => ( @@ -1017,17 +1028,31 @@ function IssueList({ issues, chunkId, onSelectChunk, onFocusIssue }: IssueListPr {issue.type}
- {issue.phrase && ( - - )} + {issue.phrase && (() => { + const isActive = focusedIssueQuery === issue.phrase; + return ( + + ); + })()}

{issue.description}

{issue.suggestedFix && ( @@ -1054,11 +1079,9 @@ interface GlossaryTabProps { panelId: string; labelledBy: string; glossary: Array<{ id?: string; term: string; translation: string; notes?: string }>; - highlightEnabled: boolean; - onToggleHighlight: () => void; } -function GlossaryTab({ panelId, labelledBy, glossary, highlightEnabled, onToggleHighlight }: GlossaryTabProps) { +function GlossaryTab({ panelId, labelledBy, glossary }: GlossaryTabProps) { const { t } = useTranslation(); return (
- {/* Toolbar */} -
+
{glossary.length} {t('document.insightsTabGlossary').toLowerCase()} -
- - {/* Entries */}
diff --git a/src/components/document/SearchTab.tsx b/src/components/document/SearchTab.tsx new file mode 100644 index 0000000..bff76cf --- /dev/null +++ b/src/components/document/SearchTab.tsx @@ -0,0 +1,221 @@ +import { Search, X, FileText, BookOpen, ScanLine } from 'lucide-react'; +import { useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useDebounce } from '../../hooks/useDebounce'; +import { useUiStore } from '../../stores/uiStore'; +import { indexPad } from '../../utils'; +import type { TranslationChunk } from '../../types'; + +interface SearchTabProps { + panelId: string; + labelledBy: string; + chunks: TranslationChunk[]; + currentChunkId: string | null; + onSelectChunk: (id: string) => void; +} + +type MatchScope = 'source' | 'translation' | 'audit'; + +interface ChunkMatch { + chunk: TranslationChunk; + index: number; + scopes: MatchScope[]; + snippet: string; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getSnippet(text: string, query: string, maxLen = 120): string { + if (!text) return ''; + const plain = text.replace(/\s+/g, ' ').trim(); + const idx = plain.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return plain.slice(0, maxLen); + const start = Math.max(0, idx - 40); + const end = Math.min(plain.length, idx + query.length + 60); + return (start > 0 ? '…' : '') + plain.slice(start, end) + (end < plain.length ? '…' : ''); +} + +function highlightSnippet(text: string, query: string): string { + const safe = escapeHtml(text); + if (!query.trim()) return safe; + const re = new RegExp(`(${escapeRegex(query.trim())})`, 'gi'); + return safe.replace(re, '$1'); +} + +function matchesChunk(chunk: TranslationChunk, query: string): { scopes: MatchScope[]; snippet: string } | null { + const q = query.toLowerCase().trim(); + if (!q) return null; + + const scopes: MatchScope[] = []; + let snippet = ''; + + if (chunk.originalText.toLowerCase().includes(q)) { + scopes.push('source'); + snippet = getSnippet(chunk.originalText, q); + } + + const draft = chunk.currentDraft ?? chunk.translationDisplayText ?? ''; + if (draft.toLowerCase().includes(q)) { + scopes.push('translation'); + if (!snippet) snippet = getSnippet(draft, q); + } + + const auditText = chunk.judgeResult.issues + .flatMap((i) => [i.description, i.phrase ?? '']) + .join(' '); + if (auditText.toLowerCase().includes(q)) { + scopes.push('audit'); + if (!snippet) snippet = getSnippet(auditText, q); + } + + return scopes.length > 0 ? { scopes, snippet } : null; +} + +const SCOPE_ICON: Record = { + source: , + translation: , + audit: , +}; + +export function SearchTab({ panelId, labelledBy, chunks, currentChunkId, onSelectChunk }: SearchTabProps) { + const { t } = useTranslation(); + const searchQuery = useUiStore((s) => s.searchQuery); + const setSearchQuery = useUiStore((s) => s.setSearchQuery); + const debouncedQuery = useDebounce(searchQuery, 250); + const inputRef = useRef(null); + const scrollRef = useRef(null); + + const matches = useMemo(() => { + if (!debouncedQuery.trim()) return []; + return chunks.flatMap((chunk, index) => { + const m = matchesChunk(chunk, debouncedQuery); + return m ? [{ chunk, index, scopes: m.scopes, snippet: m.snippet }] : []; + }); + }, [chunks, debouncedQuery]); + + const virtualizer = useVirtualizer({ + count: matches.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 90, + overscan: 5, + }); + + return ( +
+ {/* Input ricerca */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('document.searchPlaceholder')} + className="flex-1 bg-transparent text-xs text-editorial-ink placeholder:text-editorial-muted/60 outline-none" + aria-label={t('document.searchPlaceholder')} + /> + {searchQuery && ( + + )} +
+
+ + {/* Contatore risultati */} + {debouncedQuery.trim() && ( +
+ {matches.length > 0 + ? t('document.searchResults', { count: matches.length }) + : t('document.searchNoResults')} +
+ )} + + {/* Lista risultati */} + {!debouncedQuery.trim() ? ( +
+ +

{t('document.searchEmptyTitle')}

+

{t('document.searchEmptyBody')}

+
+ ) : matches.length === 0 ? ( +
+ +

{t('document.searchNoResults')}

+
+ ) : ( +
+
    + {virtualizer.getVirtualItems().map((virtualRow) => { + const { chunk, index, scopes, snippet } = matches[virtualRow.index]!; + const isActive = chunk.id === currentChunkId; + + return ( +
  • + +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/help/HelpGuide.tsx b/src/components/help/HelpGuide.tsx index 04dfaca..69ba954 100644 --- a/src/components/help/HelpGuide.tsx +++ b/src/components/help/HelpGuide.tsx @@ -576,6 +576,9 @@ function GlossarySection() { {t('help.glossary.highlightTitle')}

{t('help.glossary.highlightDesc')}

+ {t('help.glossary.searchTitle')} +

{t('help.glossary.searchDesc')}

+ {t('help.glossary.auditTitle')}

{t('help.glossary.auditDesc')}

diff --git a/src/components/pipeline/ProductionStream.tsx b/src/components/pipeline/ProductionStream.tsx index f7da108..3742632 100644 --- a/src/components/pipeline/ProductionStream.tsx +++ b/src/components/pipeline/ProductionStream.tsx @@ -264,14 +264,14 @@ export function ProductionStream({ updateChunkOriginalText, unlockChunkForEdit, } = useChunksStore(); - const { glossaryHighlightEnabled, setGlossaryHighlightEnabled, chunkPresetMedium } = useUiStore(); + const { highlightsEnabled, setHighlightsEnabled, chunkPresetMedium } = useUiStore(); const { t } = useTranslation(); const stats = useMemo(() => estimateTextStats(inputText), [inputText]); const recommendedChunks = useMemo(() => recommendChunkCount(inputText, chunkPresetMedium), [inputText, chunkPresetMedium]); const enabledStages = useMemo(() => config.stages.filter((s) => s.enabled), [config.stages]); const hasGlossary = config.glossary.length > 0; - const showHighlight = glossaryHighlightEnabled && hasGlossary; + const showHighlight = highlightsEnabled && hasGlossary; const markdownEnabled = config.markdownAware === true; const handleClearStream = useCallback(async () => { @@ -327,11 +327,11 @@ export function ProductionStream({ {hasGlossary && ( + ); + })} +
+ + + + + + + + + {MODEL_CATALOG.filter((e) => e.pricing).map((entry) => { + const key = `${entry.provider}/${entry.id}`; + const current = overrides[key] ?? MODEL_PRICING[key] ?? entry.pricing!; + const isOverridden = !!overrides[key]; return ( -
-
-
- {modelId} - - {entry?.contextWindow && ( - - {entry.contextWindow >= 1_000_000 - ? `${(entry.contextWindow / 1_000_000).toFixed(0)}M` - : `${Math.round(entry.contextWindow / 1_000)}K`} - - )} - {entry?.pricing && ( - - ${entry.pricing.input}/${entry.pricing.output} - - )} - {entry?.status === 'preview' && ( - - preview - - )} -
- {entry?.description && ( -

{entry.description}

+
+ + + + + ); })} - - ))} + +
ModelInput $/1MOutput $/1M +
+ + {entry.provider}/{entry.id} + + + setOverride(key, { ...current, input: parseFloat(e.target.value) || 0 })} + className="w-20 bg-editorial-textbox/60 border border-editorial-border/60 px-2 py-1 text-right outline-none focus-visible:ring-1 focus-visible:ring-editorial-accent" + /> + + setOverride(key, { ...current, output: parseFloat(e.target.value) || 0 })} + className="w-20 bg-editorial-textbox/60 border border-editorial-border/60 px-2 py-1 text-right outline-none focus-visible:ring-1 focus-visible:ring-editorial-accent" + /> + + {isOverridden && ( + )} - - +
- ); - })()} -
-
- - - {/* Pricing Overrides */} -
- - {showPricingOverrides && ( -
-

{t('cost.overrideHint')}

-
- - - - - - - - - - {MODEL_CATALOG.filter((e) => e.pricing).map((entry) => { - const key = `${entry.provider}/${entry.id}`; - const current = overrides[key] ?? MODEL_PRICING[key] ?? entry.pricing!; - const isOverridden = !!overrides[key]; - return ( - - - - - - - ); - })} - -
ModelInput $/1MOutput $/1M -
- - {entry.provider}/{entry.id} - - - setOverride(key, { ...current, input: parseFloat(e.target.value) || 0 })} - className="w-20 bg-editorial-textbox/60 border border-editorial-border/60 px-2 py-1 text-right outline-none focus-visible:ring-1 focus-visible:ring-editorial-accent" - /> - - setOverride(key, { ...current, output: parseFloat(e.target.value) || 0 })} - className="w-20 bg-editorial-textbox/60 border border-editorial-border/60 px-2 py-1 text-right outline-none focus-visible:ring-1 focus-visible:ring-editorial-accent" - /> - - {isOverridden && ( - - )} -
-
- {Object.keys(overrides).length > 0 && ( -
- + {Object.keys(overrides).length > 0 && ( +
+ +
+ )}
)}
- )} -
-
-
- - {showSecurityAdvisory ? : } - - {showSecurityAdvisory ? ( -
-

- {t('settings.securityMessage')} -

+ {showSecurityAdvisory ? : } + + {showSecurityAdvisory && ( +
+

+ {t('settings.securityMessage')} +

+
+ )}
- ) : null} - - - + + )} @@ -554,63 +698,3 @@ export function SettingsModal() { ); } - -interface LayoutOption { - value: string; - label: string; - icon: React.ReactNode; -} - -function LayoutRadioGroup({ - value, - onChange, - options, -}: { - value: string; - onChange: (v: any) => void; - options: LayoutOption[]; -}) { - const refs = useRef<(HTMLButtonElement | null)[]>([]); - - const handleKeyDown = (e: React.KeyboardEvent, index: number) => { - let next = -1; - if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (index + 1) % options.length; - if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (index - 1 + options.length) % options.length; - if (next === -1) return; - e.preventDefault(); - onChange(options[next].value); - refs.current[next]?.focus(); - }; - - return ( -
- {options.map(({ value: optValue, label, icon }, i) => { - const checked = value === optValue; - return ( - - ); - })} -
- ); -} diff --git a/src/hooks/useGlossaryHighlight.test.ts b/src/hooks/useGlossaryHighlight.test.ts index 1ba5a20..b60a291 100644 --- a/src/hooks/useGlossaryHighlight.test.ts +++ b/src/hooks/useGlossaryHighlight.test.ts @@ -54,6 +54,33 @@ describe('useGlossaryHighlight', () => { expect(result.current.html).toContain('class="hl-source-term"'); expect(result.current.html).toContain('>API<'); }); + + it('merges underline and background when search overlaps a source glossary term', () => { + const { result } = renderHook(() => + useGlossaryHighlight('Il libro è qui.', GLOSSARY, 'source', 'libro'), + ); + + act(() => { vi.advanceTimersByTime(300); }); + + expect(result.current.html).toContain('hl-source-term'); + expect(result.current.html).toContain('hl-search'); + // Both classes must be on the same , not separate elements + expect(result.current.html).toMatch( + /class="[^"]*hl-search[^"]*hl-source-term[^"]*"|class="[^"]*hl-source-term[^"]*hl-search[^"]*"/, + ); + }); + + it('resolves two background classes by priority — hl-match beats hl-search', () => { + const { result } = renderHook(() => + useGlossaryHighlight('These books are here.', GLOSSARY, 'translation', 'books'), + ); + + act(() => { vi.advanceTimersByTime(300); }); + + // hl-match (priority 0) wins over hl-search (priority 2) on "books" + expect(result.current.html).toContain('hl-match'); + expect(result.current.html).not.toMatch(/>books<\/mark>.*class="hl-search"/); + }); }); describe('escapeHtml', () => { diff --git a/src/hooks/useGlossaryHighlight.ts b/src/hooks/useGlossaryHighlight.ts index db2f9ae..7329b0f 100644 --- a/src/hooks/useGlossaryHighlight.ts +++ b/src/hooks/useGlossaryHighlight.ts @@ -77,25 +77,42 @@ function findSpans( return spans; } -// Runs matching on the raw text and builds HTML in one pass to avoid -// (a) entity-escaping breaking matches and (b) replacements matching inside markup. +// Classes that use background-color (mutually exclusive per interval). +// Classes not in this set use text-decoration and can coexist with a background. +const BG_CLASSES = new Set(['hl-match', 'hl-mismatch', 'hl-search', 'hl-audit']); + +// Builds HTML using an interval-breakpoint approach so that non-conflicting +// highlight properties (e.g. underline + background) can coexist on the same +// element when their spans overlap. function buildHtml(text: string, spans: MatchSpan[]): string { - const sorted = [...spans].sort((a, b) => - a.start !== b.start - ? a.start - b.start - : a.priority !== b.priority - ? a.priority - b.priority - : (b.end - b.start) - (a.end - a.start), - ); + if (spans.length === 0) return escapeHtml(text); + + const pts = [...new Set([0, text.length, ...spans.flatMap(s => [s.start, s.end])])].sort((a, b) => a - b); + + const bgSpans = spans + .filter(s => BG_CLASSES.has(s.cls)) + .sort((a, b) => a.priority - b.priority || (b.end - b.start) - (a.end - a.start)); + const decoSpans = spans.filter(s => !BG_CLASSES.has(s.cls)); + let result = ''; - let pos = 0; - for (const span of sorted) { - if (span.start < pos) continue; // skip overlapping spans - result += escapeHtml(text.slice(pos, span.start)); - result += `${escapeHtml(text.slice(span.start, span.end))}`; - pos = span.end; + for (let i = 0; i < pts.length - 1; i++) { + const from = pts[i]; + const to = pts[i + 1]; + const segment = escapeHtml(text.slice(from, to)); + + const activeBg = bgSpans.filter(s => s.start <= from && s.end >= to); + const activeDeco = decoSpans.filter(s => s.start <= from && s.end >= to); + + if (activeBg.length === 0 && activeDeco.length === 0) { result += segment; continue; } + + const bgWinner = activeBg[0]; + const decoClasses = [...new Set(activeDeco.map(s => s.cls))]; + const classes = [...(bgWinner ? [bgWinner.cls] : []), ...decoClasses]; + const tooltip = activeBg[0]?.tooltip || activeDeco[0]?.tooltip || ''; + + result += `${segment}`; } - result += escapeHtml(text.slice(pos)); + return result; } @@ -103,6 +120,8 @@ export function useGlossaryHighlight( text: string, glossary: GlossaryEntry[], mode: 'source' | 'translation', + searchQuery = '', + auditQuery = '', ): HighlightResult { const debouncedText = useDebounce(text, 300); const validEntries = useMemo( @@ -124,31 +143,43 @@ export function useGlossaryHighlight( if (text !== debouncedText) { return { html: escapeHtml(text), matchCount: 0, totalTerms: validEntries.length }; } - if (!debouncedText || patterns.length === 0) { - return { html: escapeHtml(debouncedText), matchCount: 0, totalTerms: validEntries.length }; + if (!debouncedText) { + return { html: '', matchCount: 0, totalTerms: validEntries.length }; } - if (mode === 'source') { - const spans: MatchSpan[] = []; - for (const { entry, termRe } of patterns) { - const tooltip = `→ ${entry.translation}${entry.notes ? ` | ${entry.notes}` : ''}`; - spans.push(...findSpans(debouncedText, termRe, 'hl-source-term', tooltip, 0)); + const spans: MatchSpan[] = []; + + if (patterns.length > 0) { + if (mode === 'source') { + for (const { entry, termRe } of patterns) { + const tooltip = `→ ${entry.translation}${entry.notes ? ` | ${entry.notes}` : ''}`; + spans.push(...findSpans(debouncedText, termRe, 'hl-source-term', tooltip, 0)); + } + } else { + // hl-match (priority 0): expected translation → correct + // hl-mismatch (priority 1): source term found untranslated → missed + for (const { entry, termRe, transRe } of patterns) { + const tooltip = `→ ${entry.translation}${entry.notes ? ` | ${entry.notes}` : ''}`; + spans.push(...findSpans(debouncedText, transRe, 'hl-match', tooltip, 0)); + spans.push(...findSpans(debouncedText, termRe, 'hl-mismatch', tooltip, 1)); + } } - return { html: buildHtml(debouncedText, spans), matchCount: 0, totalTerms: validEntries.length }; } - // translation mode: - // - hl-match (priority 0): expected translation found → correctly translated - // - hl-mismatch (priority 1): source term found untranslated → missed/wrong translation - const spans: MatchSpan[] = []; - let matchCount = 0; - for (const { entry, termRe, transRe } of patterns) { - const tooltip = `→ ${entry.translation}${entry.notes ? ` | ${entry.notes}` : ''}`; - const transSpans = findSpans(debouncedText, transRe, 'hl-match', tooltip, 0); - if (transSpans.length > 0) matchCount++; - spans.push(...transSpans); - spans.push(...findSpans(debouncedText, termRe, 'hl-mismatch', tooltip, 1)); + if (searchQuery.trim()) { + const searchRe = new RegExp(escapeRegex(searchQuery.trim()), 'gi'); + spans.push(...findSpans(debouncedText, searchRe, 'hl-search', '', 2)); } + + if (auditQuery.trim()) { + const auditRe = new RegExp(escapeRegex(auditQuery.trim()), 'gi'); + spans.push(...findSpans(debouncedText, auditRe, 'hl-audit', '', 3)); + } + + const matchCount = mode === 'translation' + ? patterns.filter(({ transRe }) => { transRe.lastIndex = 0; return transRe.test(debouncedText); }).length + : 0; + return { html: buildHtml(debouncedText, spans), matchCount, totalTerms: validEntries.length }; - }, [text, debouncedText, patterns, mode, validEntries.length]); + }, [text, debouncedText, patterns, mode, validEntries.length, searchQuery, auditQuery]); } diff --git a/src/i18n/en.json b/src/i18n/en.json index 6e3ef28..422afa3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -353,7 +353,7 @@ "markUnrejected": "Remove rejection" }, "settings": { - "title": "Global Config / Secrets", + "title": "Settings", "providerConfig": "Provider Configuration", "segmentation": "Segmentation Defaults", "segmentationHint": "Target chunk size for each preset. Min and max are derived automatically (×0.5 and ×1.5).", @@ -363,6 +363,12 @@ "chunkPresetMediumHint": "~700 words — balanced default.", "chunkPresetLong": "Long", "chunkPresetLongHint": "~1000 words — few large chunks, more context per call.", + "highlights": "Highlights", + "highlightSourceTerm": "Source term (glossary)", + "highlightMatchTerm": "Correct term (translation)", + "highlightMismatchTerm": "Missing term (translation)", + "highlightSearch": "Search result", + "highlightAuditPhrase": "Audit phrase", "newPipelineInit": "New pipeline initialisation", "newPipelineInitHint": "Settings to copy when creating a new pipeline.", "newPipelineInitCopyFirst": "Copy first pipeline", @@ -384,7 +390,9 @@ "keyDeleteFailed": "Failed to remove {{provider}} key", "keySavedFallback": "{{provider}} key saved to local encrypted fallback storage. This is less secure than the OS keychain.", "configureKeyToUse": "Add an API key to enable this provider's models in the pipeline.", - "contextWindowBadge": "{{count}} ctx" + "contextWindowBadge": "{{count}} ctx", + "panelTitle": "Configuration", + "providerTab": "Provider" }, "ollama": { "title": "Ollama (Local Models)", @@ -452,7 +460,8 @@ "close": "Close", "delete": "Delete", "save": "Save", - "all": "All" + "all": "All", + "clear": "Clear" }, "projects": { "title": "Projects", @@ -624,6 +633,16 @@ "insightsDrawerTitle": "Insights panel", "insightsDrawerHint": "Chunk index and judge findings for the current chunk.", "insightsTabIndex": "Index", + "insightsTabSearch": "Search", + "searchPlaceholder": "Search document…", + "searchEmptyTitle": "Document search", + "searchEmptyBody": "Type a word or phrase to search across all chunks — source text, translation, and audit findings.", + "searchNoResults": "No results found.", + "searchResults": "{{count}} results", + "searchScope_source": "source", + "searchScope_translation": "translation", + "searchScope_audit": "audit", + "highlightsToggle": "Toggle highlights", "insightsTabAudit": "Audit", "insightsTabStats": "Stats", "insightsTabCoherence": "Coherence", @@ -732,7 +751,7 @@ "documentToolsTitle": "Editor tools", "documentToolsDesc": "Source and translation editors have a compact toolbar that can be collapsed when you want to read the clean text. On the candidate translation, the green tick locks it as final; each stage status badge opens a modal with the full output of that stage (trace), useful to see what the model actually produced.", "insightsTitle": "Insights panel", - "insightsDesc": "Slides in from the right via the panel icon in the header. Two levels: the Document panel (Index, Stats, Coherence, Glossary) for whole-document metrics, and the Chunk panel (Audit, Notes, Operations) for the selected chunk. Selecting a chunk in the index automatically opens the Chunk level.", + "insightsDesc": "Slides in from the right via the panel icon in the header. Two levels: the Document panel (Index, Search, Stats, Coherence, Glossary) for whole-document metrics and search, and the Chunk panel (Audit, Notes, Operations) for the selected chunk. Selecting a chunk in the index automatically opens the Chunk level.", "statsTitle": "Session stats", "statsDesc": "The Stats tab aggregates input and output tokens, estimated cost, and chunk distribution for the current session, broken down by provider and model. Useful to monitor spend on long documents translated across multiple sessions.", "operationsLogTitle": "Structured operations log", @@ -879,7 +898,9 @@ "projectTitle": "Per-project glossary", "projectDesc": "In the configuration panel, Term Registry tab, you can pick the dictionary assigned to the project and edit entries inline. Edits act on the shared dictionary: the same dictionary can be assigned to multiple projects and entries are shared. The Save button appears as soon as there are pending changes.", "highlightTitle": "Term highlighting", - "highlightDesc": "In the document workspace, the Highlight terms toggle in the Insights panel (Glossary tab) enables an overlay: in the source, dictionary terms are underlined in blue; in the translation, correctly rendered terms have a green background and missing or differently rendered terms have a red background. Hover over a term to see the expected translation and notes.", + "highlightDesc": "The Highlighter toggle in the chunk navigation bar enables or disables all highlights at once. When active: source terms are underlined, correctly translated terms have a green background, missing terms have a red background, and search matches have a yellow background. Colors are customisable in Settings → Highlights. Hover over a term to see the expected translation and notes.", + "searchTitle": "Document search", + "searchDesc": "The Search tab in the Insights panel lets you search for a word or phrase across all chunks at once — in the source text, translation, and audit findings. Each result shows a badge indicating where the match was found (source / translation / audit) and a snippet with the matched text in bold. Clicking a result jumps the workspace to that chunk, where the search term also appears highlighted in yellow.", "auditTitle": "Audit and glossary", "auditDesc": "The AI Judge explicitly checks adherence to the assigned dictionary and flags every deviation as a 'glossary' category issue, with severity and suggested fix. Particularly useful for technical, legal, or academic texts where terminology must remain stable.", "templatesTitle": "Prompt templates", diff --git a/src/i18n/it.json b/src/i18n/it.json index 46b9445..f34ed26 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -353,7 +353,7 @@ "markUnrejected": "Rimuovi rifiuto" }, "settings": { - "title": "Configurazione / Chiavi API", + "title": "Impostazioni", "providerConfig": "Configurazione Provider", "segmentation": "Segmentazione Predefinita", "segmentationHint": "Parole obiettivo per ciascun preset. Minimo e massimo vengono derivati automaticamente (×0,5 e ×1,5).", @@ -363,6 +363,12 @@ "chunkPresetMediumHint": "~700 parole — default bilanciato.", "chunkPresetLong": "Lunghi", "chunkPresetLongHint": "~1000 parole — pochi segmenti grandi, più contesto per chiamata.", + "highlights": "Evidenziazioni", + "highlightSourceTerm": "Termine sorgente (glossario)", + "highlightMatchTerm": "Termine corretto (traduzione)", + "highlightMismatchTerm": "Termine mancante (traduzione)", + "highlightSearch": "Risultato ricerca", + "highlightAuditPhrase": "Frase audit", "newPipelineInit": "Inizializzazione nuova pipeline", "newPipelineInitHint": "Impostazioni da copiare alla creazione di una nuova pipeline.", "newPipelineInitCopyFirst": "Copia prima pipeline", @@ -384,7 +390,9 @@ "keyDeleteFailed": "Rimozione chiave {{provider}} fallita", "keySavedFallback": "Chiave {{provider}} salvata nel fallback locale cifrato. È meno sicuro del portachiavi OS.", "configureKeyToUse": "Aggiungi una API key per abilitare i modelli di questo provider nella pipeline.", - "contextWindowBadge": "{{count}} ctx" + "contextWindowBadge": "{{count}} ctx", + "panelTitle": "Configurazione", + "providerTab": "Provider" }, "ollama": { "title": "Ollama (Modelli Locali)", @@ -452,7 +460,8 @@ "close": "Chiudi", "delete": "Elimina", "save": "Salva", - "all": "Tutti" + "all": "Tutti", + "clear": "Cancella" }, "projects": { "title": "Progetti", @@ -624,6 +633,16 @@ "insightsDrawerTitle": "Pannello insight", "insightsDrawerHint": "Indice chunk e valutazione del giudice per il chunk corrente.", "insightsTabIndex": "Indice", + "insightsTabSearch": "Cerca", + "searchPlaceholder": "Cerca nel documento…", + "searchEmptyTitle": "Ricerca nel documento", + "searchEmptyBody": "Digita una parola o frase per cercare in tutti i chunk, sia nel testo originale che nella traduzione e nell'audit.", + "searchNoResults": "Nessun risultato trovato.", + "searchResults": "{{count}} risultati", + "searchScope_source": "sorgente", + "searchScope_translation": "traduzione", + "searchScope_audit": "audit", + "highlightsToggle": "Abilita/disabilita evidenziazioni", "insightsTabAudit": "Audit", "insightsTabStats": "Riepilogo", "insightsTabCoherence": "Coerenza", @@ -732,7 +751,7 @@ "documentToolsTitle": "Strumenti dell'editor", "documentToolsDesc": "Gli editor sorgente e traduzione hanno una toolbar compatta che si può collassare quando vuoi leggere il testo pulito. Sulla traduzione candidata, la spunta verde la blocca come definitiva; il badge di stato di ogni stage apre una modale con l'output completo di quello stage (trace), utile per capire cosa ha effettivamente prodotto il modello.", "insightsTitle": "Pannello Insight", - "insightsDesc": "Si apre dalla destra con l'icona pannello nell'header. Ha due livelli: il pannello Documento (Indice, Statistiche, Coerenza, Glossario) per metriche di tutto il documento, e il pannello Chunk (Audit, Note, Operazioni) per il chunk selezionato. La selezione di un chunk nell'indice apre automaticamente il livello Chunk.", + "insightsDesc": "Si apre dalla destra con l'icona pannello nell'header. Ha due livelli: il pannello Documento (Indice, Cerca, Statistiche, Coerenza, Glossario) per metriche e ricerca su tutto il documento, e il pannello Chunk (Audit, Note, Operazioni) per il chunk selezionato. La selezione di un chunk nell'indice apre automaticamente il livello Chunk.", "statsTitle": "Statistiche di sessione", "statsDesc": "Il tab Statistiche aggrega token in input e output, costo stimato e distribuzione dei chunk per la sessione corrente, separati per provider e modello. Utile per monitorare il consumo su documenti lunghi tradotti in più sessioni.", "operationsLogTitle": "Log operazioni strutturato", @@ -879,7 +898,9 @@ "projectTitle": "Glossario per progetto", "projectDesc": "Nel pannello di configurazione, tab Registro Termini, puoi selezionare il dizionario assegnato al progetto e modificare le voci inline. Le modifiche agiscono sul dizionario condiviso: lo stesso dizionario può essere assegnato a più progetti e le voci sono in comune. Il pulsante Salva appare appena ci sono modifiche pendenti.", "highlightTitle": "Evidenziazione termini", - "highlightDesc": "Nel workspace documento, il toggle Evidenzia termini nel pannello Insight (tab Glossario) attiva un overlay: nel sorgente i termini del dizionario sono sottolineati in blu, nella traduzione i termini resi correttamente hanno sfondo verde, quelli mancanti o resi diversamente hanno sfondo rosso. Passa il mouse su un termine per vedere la traduzione attesa e le note.", + "highlightDesc": "Nella barra di navigazione chunk (icona Evidenziatore) trovi il master toggle che abilita o disabilita tutte le evidenziazioni con un clic. Quando attivo: nel sorgente i termini del dizionario sono sottolineati, nella traduzione i termini resi correttamente hanno sfondo verde, quelli mancanti hanno sfondo rosso, e i match della ricerca hanno sfondo giallo. I colori sono personalizzabili in Impostazioni → Evidenziazioni. Passa il mouse su un termine per vedere la traduzione attesa e le note.", + "searchTitle": "Ricerca nel documento", + "searchDesc": "Il tab Cerca nel pannello Insight permette di cercare una parola o frase in tutti i chunk contemporaneamente: nel testo sorgente, nella traduzione e nei risultati dell'audit. Ogni risultato mostra un badge con il tipo di match (sorgente / traduzione / audit) e uno snippet con il testo evidenziato in grassetto. Cliccando un risultato il workspace si porta al chunk corrispondente, dove il termine cercato appare anche evidenziato in giallo nel testo.", "auditTitle": "Audit e glossario", "auditDesc": "Il Giudice AI verifica esplicitamente l'aderenza al dizionario assegnato e segnala come problema di categoria 'glossario' ogni deviazione, con severità e correzione suggerita. Particolarmente utile per testi tecnici, giuridici o accademici dove la terminologia deve essere stabile.", "templatesTitle": "Modelli di prompt", diff --git a/src/index.css b/src/index.css index 18dd671..b1308f8 100644 --- a/src/index.css +++ b/src/index.css @@ -74,11 +74,19 @@ border-width: 0; } -/* Glossary highlight classes */ +/* Highlight color tokens — overridden at runtime by App.tsx */ +:root { + --hl-source-term-color: #3b82f6; + --hl-match-bg: rgba(34, 197, 94, 0.18); + --hl-mismatch-bg: rgba(239, 68, 68, 0.15); + --hl-search-bg: rgba(234, 179, 8, 0.25); + --hl-audit-bg: rgba(249, 115, 22, 0.25); +} + mark.hl-source-term { background: transparent; text-decoration: underline; - text-decoration-color: #3b82f6; + text-decoration-color: var(--hl-source-term-color); text-decoration-thickness: 2px; text-underline-offset: 3px; color: inherit; @@ -86,7 +94,7 @@ mark.hl-source-term { } mark.hl-match { - background-color: rgba(34, 197, 94, 0.18); + background-color: var(--hl-match-bg); border-radius: 2px; padding: 0 1px; color: inherit; @@ -94,7 +102,22 @@ mark.hl-match { } mark.hl-mismatch { - background-color: rgba(239, 68, 68, 0.15); + background-color: var(--hl-mismatch-bg); + border-radius: 2px; + padding: 0 1px; + color: inherit; + cursor: help; +} + +mark.hl-search { + background-color: var(--hl-search-bg); + border-radius: 2px; + padding: 0 1px; + color: inherit; +} + +mark.hl-audit { + background-color: var(--hl-audit-bg); border-radius: 2px; padding: 0 1px; color: inherit; diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index 99a4261..c30a2ea 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -6,7 +6,7 @@ import type { ViewMode, } from '../types'; -export type InsightsDrawerTab = 'index' | 'stats' | 'coherence' | 'glossary'; +export type InsightsDrawerTab = 'index' | 'search' | 'stats' | 'coherence' | 'glossary'; export type ChunkDrawerTab = 'audit' | 'notes' | 'operations'; export type RunPhase = 'test' | 'production'; @@ -23,7 +23,15 @@ interface UiState { chunkDrawerTab: ChunkDrawerTab; ollamaModels: string[]; ollamaStatus: OllamaStatus; - glossaryHighlightEnabled: boolean; + highlightsEnabled: boolean; + highlightColors: { + sourceTerm: string; + matchTerm: string; + mismatchTerm: string; + search: string; + auditPhrase: string; + }; + searchQuery: string; focusedChunkId: string | null; focusedIssueQuery: string | null; focusedIssueRequestId: number; @@ -57,7 +65,9 @@ interface UiState { setChunkDrawerTab: (tab: ChunkDrawerTab) => void; setOllamaModels: (models: string[]) => void; setOllamaStatus: (status: OllamaStatus) => void; - setGlossaryHighlightEnabled: (enabled: boolean) => void; + setHighlightsEnabled: (enabled: boolean) => void; + setHighlightColor: (type: keyof UiState['highlightColors'], color: string) => void; + setSearchQuery: (query: string) => void; setFocusedChunkId: (chunkId: string | null) => void; focusIssueInChunk: (chunkId: string, query?: string | null) => void; clearFocusedIssue: () => void; @@ -82,7 +92,15 @@ export const useUiStore = create()( chunkDrawerTab: 'audit', ollamaModels: [], ollamaStatus: 'unknown', - glossaryHighlightEnabled: false, + highlightsEnabled: true, + highlightColors: { + sourceTerm: '#3b82f6', + matchTerm: 'rgba(34,197,94,0.18)', + mismatchTerm: 'rgba(239,68,68,0.15)', + search: 'rgba(234,179,8,0.25)', + auditPhrase: 'rgba(249,115,22,0.25)', + }, + searchQuery: '', pipelineMode: 'test', pipelineTestChunkCount: 3, focusedChunkId: null, @@ -174,7 +192,10 @@ export const useUiStore = create()( const normalized = Number.isFinite(count) ? Math.floor(count) : 1; set({ pipelineTestChunkCount: Math.max(1, normalized) }); }, - setGlossaryHighlightEnabled: (enabled) => set({ glossaryHighlightEnabled: enabled }), + setHighlightsEnabled: (enabled) => set({ highlightsEnabled: enabled }), + setHighlightColor: (type, color) => + set((state) => ({ highlightColors: { ...state.highlightColors, [type]: color } })), + setSearchQuery: (query) => set({ searchQuery: query }), setFocusedChunkId: (chunkId) => set({ focusedChunkId: chunkId }), focusIssueInChunk: (chunkId, query) => set((state) => ({ @@ -191,6 +212,27 @@ export const useUiStore = create()( }), { name: 'glossa-ui-prefs', + version: 2, + migrate: (persisted: unknown, fromVersion: number) => { + const s = persisted as Record; + if (fromVersion < 1) { + if ('glossaryHighlightEnabled' in s) { + s.highlightsEnabled = s.glossaryHighlightEnabled; + } + } + if (fromVersion < 2) { + const defaults: Record = { + sourceTerm: '#3b82f6', + matchTerm: 'rgba(34,197,94,0.18)', + mismatchTerm: 'rgba(239,68,68,0.15)', + search: 'rgba(234,179,8,0.25)', + auditPhrase: 'rgba(249,115,22,0.25)', + }; + const existing = (s.highlightColors ?? {}) as Record; + s.highlightColors = { ...defaults, ...existing }; + } + return s; + }, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ documentLayout: state.documentLayout, @@ -200,6 +242,8 @@ export const useUiStore = create()( pipelineTestChunkCount: state.pipelineTestChunkCount, ollamaBaseUrl: state.ollamaBaseUrl, newPipelineInit: state.newPipelineInit, + highlightsEnabled: state.highlightsEnabled, + highlightColors: state.highlightColors, }), }, ),