From 3e07244720f847ab83b6cf9ed60bba0bd069b6ae Mon Sep 17 00:00:00 2001 From: nikazzio Date: Thu, 28 May 2026 23:35:46 +0200 Subject: [PATCH 1/9] feat: ricerca globale documento + sistema evidenziazioni unificato (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nuovo tab Cerca in InsightsDrawer: ricerca full-text su source, traduzione e audit di tutti i chunk con debounce 250ms, snippet con match evidenziato, badge per tipo di match (source/translation/audit), virtualizer per risultati - Highlights unificati: useGlossaryHighlight accetta searchQuery opzionale, tutti i tipi di span (glossario + search) passano per un unico buildHtml pass - Master toggle Highlighter nella barra navigazione chunk (DocumentView) - Colori highlight configurabili per tipo in Impostazioni → Evidenziazioni con color picker che preserva il canale alpha - CSS custom properties per tutti i colori highlight, aggiornate reattivamente da HighlightColorSync in App.tsx - Rimosso glossaryHighlightEnabled (sostituito da highlightsEnabled globale) - Aggiornati ARCHITECTURE.md, help utente (it+en), i18n completo --- docs/ARCHITECTURE.md | 2 +- src/App.tsx | 13 ++ src/components/audit/AuditPanel.test.tsx | 2 +- src/components/document/DocumentView.tsx | 29 ++- src/components/document/InsightsDrawer.tsx | 43 ++-- src/components/document/SearchTab.tsx | 220 +++++++++++++++++++ src/components/help/HelpGuide.tsx | 3 + src/components/pipeline/ProductionStream.tsx | 10 +- src/components/settings/SettingsModal.tsx | 46 ++++ src/hooks/useGlossaryHighlight.ts | 48 ++-- src/i18n/en.json | 19 +- src/i18n/it.json | 19 +- src/index.css | 22 +- src/stores/uiStore.ts | 31 ++- 14 files changed, 436 insertions(+), 71 deletions(-) create mode 100644 src/components/document/SearchTab.tsx 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/src/App.tsx b/src/App.tsx index 780094f..0479f36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,18 @@ 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); + }, [highlightColors]); + return null; +} + const PipelineConfig = lazy(() => import('./components/pipeline').then((m) => ({ default: m.PipelineConfig })), ); @@ -75,6 +87,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/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index 7ab5b56..e409370 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, @@ -157,25 +160,28 @@ 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 : '', ); 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,7 +617,7 @@ 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())) ? 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..017612b 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)} /> ) : ( ; - 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..58a2140 --- /dev/null +++ b/src/components/document/SearchTab.tsx @@ -0,0 +1,220 @@ +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 && ( + )} - - +
+
+ {Object.keys(overrides).length > 0 && ( +
+ +
+ )} +
+ )} +
+ + {/* Security Advisory */} +
+ + {showSecurityAdvisory && ( +
+

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

+
+ )}
- + )} - {/* 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 && ( - - )} -
+ {/* Tab: Settings */} + {activeTab === 'settings' && ( +
+ {/* Segmentation defaults */} +
+

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

+

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

+
+
+ + setChunkPresetShort(Number(e.target.value) || 50)} + className="w-full rounded-[18px] border border-editorial-border bg-editorial-bg px-4 py-3 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" + /> +

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

+
+
+ + setChunkPresetMedium(Number(e.target.value) || 50)} + className="w-full rounded-[18px] border border-editorial-border bg-editorial-bg px-4 py-3 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" + /> +

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

+
+
+ + setChunkPresetLong(Number(e.target.value) || 50)} + className="w-full rounded-[18px] border border-editorial-border bg-editorial-bg px-4 py-3 text-sm font-mono outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent" + /> +

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

+
- {Object.keys(overrides).length > 0 && ( -
+
+ + {/* Inizializzazione nuova pipeline */} +
+

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

+
+ {(['copy-first', 'copy-previous', 'defaults'] as const).map((option) => ( -
- )} + ))} +
- )} -
-
- + ))}
- {showSecurityAdvisory ? : } - - {showSecurityAdvisory ? ( -
-

- {t('settings.securityMessage')} + + {/* Evidenziazioni */} +

+

+ {t('settings.highlights')}

+
+ {([ + { key: 'sourceTerm', label: t('settings.highlightSourceTerm') }, + { key: 'matchTerm', label: t('settings.highlightMatchTerm') }, + { key: 'mismatchTerm', label: t('settings.highlightMismatchTerm') }, + { key: 'search', label: t('settings.highlightSearch') }, + ] as const).map(({ key, label }) => ( + + ))} +
- ) : null} -
- -
+
+ )} @@ -600,63 +649,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/i18n/en.json b/src/i18n/en.json index 23112fd..8e89e8d 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).", diff --git a/src/i18n/it.json b/src/i18n/it.json index 976cd05..08b5d79 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).", From 1f95f29a3813db3fe8980c8e9a5fb6c0a17bc642 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Fri, 29 May 2026 01:42:38 +0200 Subject: [PATCH 4/9] feat: sistema evidenziazioni audit + toggle mirino + hl-audit configurabile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aggiunge hl-audit (sfondo arancio) nel pannello traduzione quando si preme il mirino su un issue audit - Il pulsante mirino in InsightsDrawer diventa rosso quando attivo e si togola al secondo click - Colore hl-audit configurabile da Impostazioni → Evidenziazioni ("Frase audit") - Algoritmo interval-breakpoint: hl-audit coesiste con hl-source-term (sottolineatura) e altri highlight - Rimuove la selezione nativa azzurra del browser (setSelectionRange) — lo scroll è mantenuto - Aggiunge hl-audit alla priorità 3 nel sistema BG_CLASSES - Migration Zustand v2 per aggiungere auditPhrase a highlightColors in stato persistito - Icona rossa sezione "prompt" in StageCard (coerente con design system pipeline) - Test copertura: overlay coesistenza underline+background, priorità bg class --- docs/UI_DESIGN_SYSTEM.md | 2 + src/App.tsx | 1 + src/components/common/EditorialModalShell.tsx | 5 + src/components/common/MarkdownEditor.tsx | 3 - src/components/document/DocumentView.tsx | 6 +- src/components/document/InsightsDrawer.tsx | 38 +- src/components/pipeline/StageCard.tsx | 2 + src/components/settings/SettingsModal.tsx | 397 ++++++++++-------- src/hooks/useGlossaryHighlight.test.ts | 27 ++ src/hooks/useGlossaryHighlight.ts | 55 ++- src/i18n/en.json | 4 +- src/i18n/it.json | 4 +- src/index.css | 9 + src/stores/uiStore.ts | 11 + 14 files changed, 351 insertions(+), 213 deletions(-) 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/src/App.tsx b/src/App.tsx index 0479f36..126650a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ function HighlightColorSync() { 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; } 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 e409370..f809aca 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -85,7 +85,6 @@ export function DocumentView({ focusedChunkId, focusedIssueQuery, focusedIssueRequestId, - clearFocusedIssue, } = useUiStore(); const effectivePipelineMode = pipelineMode; @@ -172,6 +171,7 @@ export function DocumentView({ showHighlight && paneFocus !== 'source' ? config.glossary : [], 'translation', highlightsEnabled ? searchQuery : '', + focusedIssueQuery ?? '', ); const sourceHighlightHtml = useMemo(() => { @@ -617,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 || (highlightsEnabled && !!searchQuery.trim())) ? 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 017612b..6a7838f 100644 --- a/src/components/document/InsightsDrawer.tsx +++ b/src/components/document/InsightsDrawer.tsx @@ -1011,6 +1011,8 @@ interface IssueListProps { function IssueList({ issues, chunkId, onSelectChunk, onFocusIssue }: IssueListProps) { const { t } = useTranslation(); + const focusedIssueQuery = useUiStore((s) => s.focusedIssueQuery); + const clearFocusedIssue = useUiStore((s) => s.clearFocusedIssue); return (
{issues.map((issue, index) => ( @@ -1026,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 && ( diff --git a/src/components/pipeline/StageCard.tsx b/src/components/pipeline/StageCard.tsx index 8496475..1c4bdb0 100644 --- a/src/components/pipeline/StageCard.tsx +++ b/src/components/pipeline/StageCard.tsx @@ -4,6 +4,7 @@ import { BookOpen, Check, Cpu, + FileText, Loader2, Pencil, RefreshCw, @@ -259,6 +260,7 @@ export function StageCard({
+ {t('pipeline.prompt')} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 8325db6..fe21d83 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,5 +1,10 @@ +import type { ReactNode } from 'react'; import { useState } from 'react'; -import { AlertCircle, Server, RefreshCw, CheckCircle2, XCircle, HelpCircle, Sparkles, Columns2, BookOpen, ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-react'; +import { + AlertCircle, Server, RefreshCw, CheckCircle2, XCircle, HelpCircle, + Sparkles, Columns2, BookOpen, ChevronDown, ChevronUp, SlidersHorizontal, + ChevronsLeft, Copy, RotateCcw, Scissors, Layers, LayoutTemplate, Palette, +} from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; @@ -83,12 +88,60 @@ function applyHexToColor(existing: string, hex: string): string { return hex; } -const LAYOUT_OPTIONS = [ - { value: 'auto' as const, labelKey: 'document.layoutAuto', icon: }, - { value: 'standard' as const, labelKey: 'document.layoutStandard', icon: }, - { value: 'book' as const, labelKey: 'document.layoutBook', icon: }, +const LAYOUT_OPTIONS: Array<{ value: 'auto' | 'standard' | 'book'; labelKey: string; icon: ReactNode }> = [ + { value: 'auto', labelKey: 'document.layoutAuto', icon: }, + { value: 'standard', labelKey: 'document.layoutStandard', icon: }, + { value: 'book', labelKey: 'document.layoutBook', icon: }, ]; +const PIPELINE_INIT_OPTIONS: Array<{ value: 'copy-first' | 'copy-previous' | 'defaults'; labelKey: string; icon: ReactNode }> = [ + { value: 'copy-first', labelKey: 'settings.newPipelineInitCopyFirst', icon: }, + { value: 'copy-previous', labelKey: 'settings.newPipelineInitCopyPrevious', icon: }, + { value: 'defaults', labelKey: 'settings.newPipelineInitDefaults', icon: }, +]; + +function NavSelector({ + options, + value, + onChange, + getLabel, +}: { + options: Array<{ value: T; icon: ReactNode; labelKey: string }>; + value: T; + onChange: (v: T) => void; + getLabel: (labelKey: string) => string; +}) { + const active = options.find((o) => o.value === value); + return ( +
+ {options.map((opt) => { + const isActive = value === opt.value; + const label = getLabel(opt.labelKey); + return ( + + ); + })} +
+ ); +} + export function SettingsModal() { const { showSettings, @@ -117,7 +170,7 @@ export function SettingsModal() { const [showPricingOverrides, setShowPricingOverrides] = useState(false); const [showSecurityAdvisory, setShowSecurityAdvisory] = useState(false); const [activeProviderTab, setActiveProviderTab] = useState('openai'); - const [activeTab, setActiveTab] = useState('provider'); + const [activeTab, setActiveTab] = useState('settings'); const [urlDraft, setUrlDraft] = useState(ollamaBaseUrl); const [urlError, setUrlError] = useState(null); const trapRef = useFocusTrap(showSettings, () => setShowSettings(false)); @@ -142,11 +195,39 @@ export function SettingsModal() { } }; - const tabConfig: Array<{ id: SettingsTab; icon: React.ReactNode; label: string }> = [ - { id: 'provider', icon: , label: 'Provider' }, - { id: 'settings', icon: , label: t('header.settings') }, + const tabConfig: Array<{ id: SettingsTab; icon: ReactNode; label: string }> = [ + { id: 'settings', icon: , label: t('header.settings') }, + { id: 'provider', icon: , label: 'Provider' }, ]; + const tabBar = ( +
+ {tabConfig.map((tab) => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ ); + return ( {showSettings && ( @@ -172,38 +253,13 @@ export function SettingsModal() { > setShowSettings(false)} widthClassName="max-w-3xl" bodyClassName="px-6 py-6 md:px-8" - headerActions={ -
- {tabConfig.map((tab) => { - const isActive = activeTab === tab.id; - return ( - - ); - })} -
- } + panelClassName="h-[85vh]" + tabBar={tabBar} footer={
- ))} -
-
- - {/* Layout lettura */} -
-

- {t('header.readerLayout')} -

-
- {LAYOUT_OPTIONS.map(({ value, labelKey, icon }) => ( - - ))} -
-
- - {/* Evidenziazioni */} -
-

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

-
- {([ - { key: 'sourceTerm', label: t('settings.highlightSourceTerm') }, - { key: 'matchTerm', label: t('settings.highlightMatchTerm') }, - { key: 'mismatchTerm', label: t('settings.highlightMismatchTerm') }, - { key: 'search', label: t('settings.highlightSearch') }, - ] as const).map(({ key, label }) => ( - - ))} -
-
-
- )}
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 0d5cb4c..458f170 100644 --- a/src/hooks/useGlossaryHighlight.ts +++ b/src/hooks/useGlossaryHighlight.ts @@ -77,25 +77,40 @@ 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); + 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 active = spans.filter(s => s.start <= from && s.end >= to); + if (active.length === 0) { result += segment; continue; } + + // Among background spans, keep only the highest-priority one (lowest number). + // Decoration spans (underline) don't conflict with backgrounds — keep all. + const bgWinner = active + .filter(s => BG_CLASSES.has(s.cls)) + .sort((a, b) => a.priority - b.priority || (b.end - b.start) - (a.end - a.start))[0]; + const decoClasses = [...new Set(active.filter(s => !BG_CLASSES.has(s.cls)).map(s => s.cls))]; + + const classes = [...(bgWinner ? [bgWinner.cls] : []), ...decoClasses]; + const tooltip = [...active].sort((a, b) => a.priority - b.priority).find(s => s.tooltip)?.tooltip ?? ''; + + result += `${segment}`; } - result += escapeHtml(text.slice(pos)); + return result; } @@ -104,6 +119,7 @@ export function useGlossaryHighlight( glossary: GlossaryEntry[], mode: 'source' | 'translation', searchQuery = '', + auditQuery = '', ): HighlightResult { const debouncedText = useDebounce(text, 300); const validEntries = useMemo( @@ -153,10 +169,15 @@ export function useGlossaryHighlight( 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, searchQuery]); + }, [text, debouncedText, patterns, mode, validEntries.length, searchQuery, auditQuery]); } diff --git a/src/i18n/en.json b/src/i18n/en.json index 8e89e8d..931ce78 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -368,6 +368,7 @@ "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", @@ -389,7 +390,8 @@ "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" }, "ollama": { "title": "Ollama (Local Models)", diff --git a/src/i18n/it.json b/src/i18n/it.json index 08b5d79..7273696 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -368,6 +368,7 @@ "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", @@ -389,7 +390,8 @@ "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" }, "ollama": { "title": "Ollama (Modelli Locali)", diff --git a/src/index.css b/src/index.css index e119eb5..b1308f8 100644 --- a/src/index.css +++ b/src/index.css @@ -80,6 +80,7 @@ --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 { @@ -115,6 +116,14 @@ mark.hl-search { color: inherit; } +mark.hl-audit { + background-color: var(--hl-audit-bg); + border-radius: 2px; + padding: 0 1px; + color: inherit; + cursor: help; +} + .hl-footnote-marker { color: var(--color-editorial-accent); font-family: var(--font-mono); diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index c000a1f..b481db9 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -29,6 +29,7 @@ interface UiState { matchTerm: string; mismatchTerm: string; search: string; + auditPhrase: string; }; searchQuery: string; focusedChunkId: string | null; @@ -97,6 +98,7 @@ export const useUiStore = create()( 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', @@ -210,6 +212,15 @@ export const useUiStore = create()( }), { name: 'glossa-ui-prefs', + version: 2, + migrate: (persisted: unknown, fromVersion: number) => { + const s = persisted as Record; + if (fromVersion < 2) { + const colors = (s.highlightColors ?? {}) as Record; + s.highlightColors = { ...colors, auditPhrase: 'rgba(249,115,22,0.25)' }; + } + return s; + }, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ documentLayout: state.documentLayout, From 68a119f0a882fcc088de9022c0e58d44945db826 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Fri, 29 May 2026 01:43:27 +0200 Subject: [PATCH 5/9] chore: aggiorna Cargo.lock --- src-tauri/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From e2ee33f2c44ce8fe1e10809d185309cf6bdc07c0 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Fri, 29 May 2026 01:48:17 +0200 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20risolve=20commenti=20PR=20=E2=80=94?= =?UTF-8?q?=20i18n,=20ARIA,=20migrate=20glossaryHighlightEnabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aggiunge common.clear (Cancella/Clear) nei file i18n - Localizza etichetta tab "Provider" via settings.providerTab - Aggiunge aria-pressed ai tab buttons e a NavSelector (+ role="group") - NavSelector riceve ariaLabel prop per semantica gruppo - uiStore migrate: gestisce fromVersion < 1 copiando glossaryHighlightEnabled → highlightsEnabled - buildHtml omette title quando tooltip è vuoto (già gestito dal ternario) --- src/components/settings/SettingsModal.tsx | 10 ++++++++-- src/i18n/en.json | 6 ++++-- src/i18n/it.json | 6 ++++-- src/stores/uiStore.ts | 5 +++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index fe21d83..a16e13a 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -105,15 +105,17 @@ function NavSelector({ value, onChange, getLabel, + ariaLabel, }: { options: Array<{ value: T; icon: ReactNode; labelKey: string }>; value: T; onChange: (v: T) => void; getLabel: (labelKey: string) => string; + ariaLabel?: string; }) { const active = options.find((o) => o.value === value); return ( -
+
{options.map((opt) => { const isActive = value === opt.value; const label = getLabel(opt.labelKey); @@ -124,6 +126,7 @@ function NavSelector({ onClick={() => onChange(opt.value)} title={label} aria-label={label} + aria-pressed={isActive} className={`rounded-full border p-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${ isActive ? 'border-editorial-accent bg-editorial-accent text-white' @@ -197,7 +200,7 @@ export function SettingsModal() { const tabConfig: Array<{ id: SettingsTab; icon: ReactNode; label: string }> = [ { id: 'settings', icon: , label: t('header.settings') }, - { id: 'provider', icon: , label: 'Provider' }, + { id: 'provider', icon: , label: t('settings.providerTab') }, ]; const tabBar = ( @@ -211,6 +214,7 @@ export function SettingsModal() { onClick={() => setActiveTab(tab.id)} title={tab.label} aria-label={tab.label} + aria-pressed={isActive} className={`rounded-full border p-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${ isActive ? 'border-editorial-accent bg-editorial-accent text-white' @@ -341,6 +345,7 @@ export function SettingsModal() { value={newPipelineInit} onChange={setNewPipelineInit} getLabel={(key) => t(key)} + ariaLabel={t('settings.newPipelineInit')} />
@@ -357,6 +362,7 @@ export function SettingsModal() { value={documentLayout} onChange={setDocumentLayout} getLabel={(key) => t(key)} + ariaLabel={t('header.readerLayout')} />
diff --git a/src/i18n/en.json b/src/i18n/en.json index 931ce78..422afa3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -391,7 +391,8 @@ "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", - "panelTitle": "Configuration" + "panelTitle": "Configuration", + "providerTab": "Provider" }, "ollama": { "title": "Ollama (Local Models)", @@ -459,7 +460,8 @@ "close": "Close", "delete": "Delete", "save": "Save", - "all": "All" + "all": "All", + "clear": "Clear" }, "projects": { "title": "Projects", diff --git a/src/i18n/it.json b/src/i18n/it.json index 7273696..f34ed26 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -391,7 +391,8 @@ "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", - "panelTitle": "Configurazione" + "panelTitle": "Configurazione", + "providerTab": "Provider" }, "ollama": { "title": "Ollama (Modelli Locali)", @@ -459,7 +460,8 @@ "close": "Chiudi", "delete": "Elimina", "save": "Salva", - "all": "Tutti" + "all": "Tutti", + "clear": "Cancella" }, "projects": { "title": "Progetti", diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index b481db9..b9dc3f0 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -215,6 +215,11 @@ export const useUiStore = create()( 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 colors = (s.highlightColors ?? {}) as Record; s.highlightColors = { ...colors, auditPhrase: 'rgba(249,115,22,0.25)' }; From b950653c4d77700b98d79e75079eb7bb659a9006 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Fri, 29 May 2026 01:51:57 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20risolve=20nuovi=20commenti=20PR=20?= =?UTF-8?q?=E2=80=94=20migrate,=20buildHtml,=20font-size,=20ARIA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uiStore: migrate v2 fa merge completo di tutti i default highlightColors - buildHtml: pre-separa bg/deco span fuori dal loop (evita re-sort per segmento) - SearchTab: aggiunge title al clear button; snippet text-[11px] → text-xs - SettingsModal: model description text-[11px] → text-xs --- src/components/document/SearchTab.tsx | 3 ++- src/components/settings/SettingsModal.tsx | 2 +- src/hooks/useGlossaryHighlight.ts | 20 +++++++++++--------- src/stores/uiStore.ts | 11 +++++++++-- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/document/SearchTab.tsx b/src/components/document/SearchTab.tsx index 58a2140..bff76cf 100644 --- a/src/components/document/SearchTab.tsx +++ b/src/components/document/SearchTab.tsx @@ -131,6 +131,7 @@ export function SearchTab({ panelId, labelledBy, chunks, currentChunkId, onSelec