diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index 9ba1848..16988ed 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -38,25 +38,107 @@ when-to-read: prima di creare o modificare qualsiasi componente visivo - **Hover inattivo:** `hover:border-editorial-accent/40 hover:text-editorial-accent` - **Disabled:** `disabled:opacity-40 disabled:cursor-not-allowed` - **Focus:** `focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent` -- **Tooltip obbligatorio:** ogni pulsante icon-only deve avere `title` + `aria-label` +- **Tooltip obbligatorio:** ogni pulsante icon-only usa `` — il tooltip è incluso automaticamente. +- **Nessuna variante locale:** i componenti feature non devono reintrodurre button/dot/label custom. Usa sempre le primitive condivise. --- -## Componenti +## Primitive condivise (`src/components/ui/`) -### Label di sezione +### IconButton — pulsanti icon-only (OBBLIGATORIO) -Ogni sezione ha intestazione icona + etichetta uppercase: +Ogni controllo icon-only **deve** usare ``. Non usare ` -``` - -Componente: `src/components/ui/IconButton.tsx` - ---- - ### Barra navigazione filtri (pattern LibraryPanel — OBBLIGATORIO) -Ogni gruppo di filtri/tab deve usare **esattamente** questo pattern: +Ogni gruppo di filtri/tab usa `` con separatore e label corsiva: ```tsx
- {OPTIONS.map((opt) => { - const isActive = current === opt; - return ( - - ); - })} + {OPTIONS.map((opt) => ( + setCurrent(opt)} + title={label(opt)} + ariaPressed={current === opt} + > + + + ))}
``` Regole: -- Pulsanti **solo icona** (descrizione in `title`/`aria-label`) - Separatore: `span w-px h-4 bg-editorial-border/70` - Label corsiva: `font-display text-sm italic text-editorial-ink` -- Hover inattivo: `hover:border-editorial-accent/40` (non `/60`) +- Hover inattivo: `hover:border-editorial-accent/40` (non `/60`) — gestito dal tone `default` di `IconButton` --- @@ -134,7 +195,7 @@ Regole: Prima di aggiungere un nuovo pulsante, tab, o filtro: 1. Cerca nell'app un componente analogo -2. Replica **esattamente** lo stesso stile e struttura +2. Usa la primitiva condivisa corrispondente (`IconButton`, `StatusDot`, `SectionLabel`, `Tooltip`) 3. Deviazioni richiedono approvazione esplicita dell'utente Colori da **non usare** fuori dai componenti UI (`StyleGuide.tsx` per riferimento): diff --git a/package-lock.json b/package-lock.json index e1dd4d6..22ee3ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-sql": "^2.4.0", "@vitejs/plugin-react": "^5.0.4", + "class-variance-authority": "^0.7.1", "diff": "^9.0.0", "i18next": "^25.1.0", "lucide-react": "^0.546.0", @@ -2404,6 +2405,27 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", diff --git a/package.json b/package.json index 7b29953..610f4c5 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-sql": "^2.4.0", "@vitejs/plugin-react": "^5.0.4", + "class-variance-authority": "^0.7.1", "diff": "^9.0.0", "i18next": "^25.1.0", "lucide-react": "^0.546.0", diff --git a/src/components/document/DocumentView.tsx b/src/components/document/DocumentView.tsx index 72a6535..b990f7f 100644 --- a/src/components/document/DocumentView.tsx +++ b/src/components/document/DocumentView.tsx @@ -22,7 +22,7 @@ import { useUiStore } from '../../stores/uiStore'; import type { TranslationChunk } from '../../types'; import { indexPad } from '../../utils'; import { CopyButton, HighlightedText, MarkdownEditor, ProcessingLine } from '../common'; -import { Tooltip } from '../ui'; +import { IconButton, Tooltip, type IconButtonTone } from '../ui'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { escapeHtml, useGlossaryHighlight } from '../../hooks/useGlossaryHighlight'; import { usePanelScrollSync } from '../../hooks/usePanelScrollSync'; @@ -35,38 +35,13 @@ interface DocumentViewProps { onRetranslateChunk: (chunkId: string) => void; } -const NAV_STAGE_TONE = { - completed: 'border-editorial-success/40 bg-editorial-success/12 text-editorial-success', - processing: 'border-editorial-running/45 bg-editorial-running/12 text-editorial-running animate-pulse', - retrying: 'border-editorial-running/45 bg-editorial-running/12 text-editorial-running animate-pulse', - error: 'border-editorial-accent/40 bg-editorial-accent/10 text-editorial-accent', - idle: 'border-editorial-border bg-editorial-bg text-editorial-muted/60', -} as const; - -function NavStageIndicator({ status, icon: Icon, onClick, disabled, title }: { - status: string; - icon: LucideIcon; - onClick: () => void; - disabled: boolean; - title: string; -}) { - const tone = (status === 'completed' || status === 'processing' || status === 'error' || status === 'retrying') - ? status as keyof typeof NAV_STAGE_TONE - : 'idle'; - return ( - - - - ); -} +const STAGE_TONE_MAP: Record = { + completed: 'success', + processing: 'running', + retrying: 'running', + error: 'accent', + idle: 'muted', +}; export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { @@ -290,34 +265,38 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { stage.role === 'refine' ? Pencil : stage.role === 'format' ? FileText : Languages; + const stageTone = STAGE_TONE_MAP[currentChunk.stageResults[stage.id]?.status ?? 'idle'] ?? 'muted'; return ( - setTraceStageId(traceStageId === stage.id ? null : stage.id)} - /> + > + +
); })} - setTraceStageId(traceStageId === '_judge' ? null : '_judge')} - /> + > + +
- prevChunk && setSelectedChunkId(prevChunk.id)} title={t('document.previousChunk')} disabled={!prevChunk} > - +
{t('document.chunkLabel')} @@ -326,13 +305,14 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { {indexPad(currentIndex + 1)}/{indexPad(chunks.length)}
- nextChunk && setSelectedChunkId(nextChunk.id)} title={t('document.nextChunk')} disabled={!nextChunk} > - +
@@ -390,23 +370,25 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { ) : null} actions={
- toggleChunkSourceEditing(currentChunk.id)} title={currentChunk.sourceEditable ? t('document.disableSourceEditing') : t('document.enableSourceEditing')} disabled={sourceEditDisabled} - active={currentChunk.sourceEditable === true} ariaPressed={currentChunk.sourceEditable === true} > - + {currentChunk.sourceDisplayText !== currentChunk.originalText && ( - restoreChunkSourceText(currentChunk.id)} title={t('document.restoreSourceText')} disabled={sourceEditDisabled} > - + )}
} @@ -429,22 +411,16 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { {paneFocus !== 'source' && (() => { const stageReadOnly = !isLastSelected || currentChunk.translationLocked === true; const lockToggle = ( - - - + toggleChunkTranslationLock(currentChunk.id)} + disabled={!currentChunk.currentDraft?.trim()} + ariaPressed={currentChunk.translationLocked === true} + > + + ); const stageButtons = enabledStages.map((s) => { const Icon = s.role === 'refine' ? Wand2 : s.role === 'format' ? FileText : Languages; @@ -453,16 +429,17 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { ? true : !!(currentChunk.stageResults[s.id]?.content); return ( - setSelectedStageId(s.id)} title={t('document.viewStageResult', { stage: t(`pipeline.stageRole.${s.role ?? 'translation'}`) })} disabled={!hasContent || showDiffMode} - active={isActive && !showDiffMode} ariaPressed={isActive && !showDiffMode} > - + ); }); const diffButtons = diffPairs.map((pair) => { @@ -470,34 +447,36 @@ export function DocumentView({ onRetranslateChunk }: DocumentViewProps) { const fromStage = enabledStages.find((s) => s.id === pair.fromId); const DiffIcon = fromStage?.role === 'refine' ? Wand2 : fromStage?.role === 'format' ? FileText : Languages; return ( - setDiffPairKey(pair.key)} title={`${pair.fromName} → ${pair.toName}`} disabled={!showDiffMode} - active={isActive} ariaPressed={isActive} > - + ); }); const stageActions = isEditorialMode ? (
{stageButtons}
) : null; @@ -748,89 +727,3 @@ function InlineStatusBadge({ ); } -function ChunkIconButton({ - onClick, - children, - title, - disabled = false, - active = false, - activeClassName, - ariaPressed, -}: { - onClick: () => void; - children: React.ReactNode; - title: string; - disabled?: boolean; - active?: boolean; - activeClassName?: string; - ariaPressed?: boolean; -}) { - return ( - - - - ); -} - -const COMPACT_STATUS_TONE = { - completed: - 'border-editorial-success/40 bg-editorial-success/12 text-editorial-success', - processing: - 'border-editorial-running/45 bg-editorial-running/12 text-editorial-running animate-pulse', - error: 'border-editorial-accent/40 bg-editorial-accent/10 text-editorial-accent', - retrying: 'border-editorial-running/45 bg-editorial-running/12 text-editorial-running animate-pulse', - idle: 'border-editorial-border bg-editorial-bg text-editorial-muted', -} as const; - -function CompactStatusIndicator({ - status, - label, - icon: Icon, - size = 'md', -}: { - status: string; - label?: string; - icon?: LucideIcon; - size?: 'sm' | 'md'; -}) { - const tone = - status === 'completed' || status === 'processing' || status === 'error' || status === 'retrying' - ? status - : 'idle'; - const sizeClass = size === 'sm' ? 'h-7 w-7' : 'h-9 w-9'; - const iconSize = size === 'sm' ? 13 : 16; - - return ( - - ); -} - -function truncateChunk(text: string) { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (normalized.length <= 58) return normalized; - return `${normalized.slice(0, 55)}...`; -} diff --git a/src/components/document/ImportPreviewDialog.tsx b/src/components/document/ImportPreviewDialog.tsx index be2ecaa..4e0b750 100644 --- a/src/components/document/ImportPreviewDialog.tsx +++ b/src/components/document/ImportPreviewDialog.tsx @@ -31,6 +31,7 @@ import { LANGUAGES } from '../../constants'; import { getSelectableModelIds, MODEL_PROVIDER_ORDER } from '../../models/catalog'; import { useUiStore } from '../../stores/uiStore'; import type { ModelProvider } from '../../types'; +import { IconButton } from '../ui'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -800,49 +801,40 @@ export function ImportPreviewDialog({
{/* Auto-segment toggle — icon button */} - + {/* Heading-aware toggle — icon button (markdown only) */} {markdownAware && ( - + )} - + {/* Separator — solo in stato normale */} {useChunking && !hasManualEdits && ( @@ -851,19 +843,16 @@ export function ImportPreviewDialog({ {/* Preset inline — solo in stato normale */} {useChunking && !hasManualEdits && CHUNK_PRESETS.map(({ words, titleKey, Icon }) => ( - + ))} {/* Con modifiche manuali: preset + ricalcola raggruppati a destra in warning */} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ee56946..146ae70 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -341,7 +341,7 @@ export function Header({ onRunPipeline, onCancelPipeline }: HeaderProps = {}) { title={sandboxLabel} ariaLabel={sandboxLabel} ariaPressed={viewMode === 'sandbox'} - active={viewMode === 'sandbox'} + tone={viewMode === 'sandbox' ? 'accent' : 'default'} > diff --git a/src/components/layout/PipelineSidebar.tsx b/src/components/layout/PipelineSidebar.tsx index 17110a3..40a7356 100644 --- a/src/components/layout/PipelineSidebar.tsx +++ b/src/components/layout/PipelineSidebar.tsx @@ -27,7 +27,7 @@ import { useProjectStore } from '../../stores/projectStore'; import { useUiStore } from '../../stores/uiStore'; import { estimatePipelineCost } from '../../utils/costEstimate'; import { CostBreakdownPanel } from '../pipeline/CostBadge'; -import { Tooltip } from '../ui'; +import { IconButton, Tooltip } from '../ui'; interface PipelineSidebarProps { onRunPipeline: () => void; @@ -385,54 +385,54 @@ export function PipelineSidebar({ {t('document.panelsTitle')}
- setDocumentPaneFocus('both')} title={t('document.focusBoth')} - active={documentPaneFocus === 'both'} ariaPressed={documentPaneFocus === 'both'} > - - + setDocumentPaneFocus('source')} title={t('document.focusSource')} - active={documentPaneFocus === 'source'} ariaPressed={documentPaneFocus === 'source'} > - - + setDocumentPaneFocus('translation')} title={t('document.focusTranslation')} - active={documentPaneFocus === 'translation'} ariaPressed={documentPaneFocus === 'translation'} > - +
- setSyncScrollEnabled(!syncScrollEnabled)} title={syncScrollEnabled ? t('document.scrollSyncDisable') : t('document.scrollSyncEnable')} - active={syncScrollEnabled && !syncScrollDisabled} disabled={syncScrollDisabled} ariaPressed={syncScrollEnabled && !syncScrollDisabled} > {syncScrollEnabled && !syncScrollDisabled ? : } - - + setHighlightsEnabled(!highlightsEnabled)} title={t('document.highlightsToggle')} - active={highlightsEnabled} ariaPressed={highlightsEnabled} > - +
@@ -440,44 +440,6 @@ export function PipelineSidebar({ ); } -function SidebarIconButton({ - children, - active = false, - disabled = false, - compact = false, - title, - onClick, - ariaPressed, -}: { - children: ReactNode; - active?: boolean; - disabled?: boolean; - compact?: boolean; - title: string; - onClick: () => void; - ariaPressed?: boolean; -}) { - return ( - - - - ); -} function SectionLabel({ children, diff --git a/src/components/library/PromptTemplatesTab.tsx b/src/components/library/PromptTemplatesTab.tsx index feb56a5..9a6510d 100644 --- a/src/components/library/PromptTemplatesTab.tsx +++ b/src/components/library/PromptTemplatesTab.tsx @@ -62,7 +62,7 @@ export function PromptTemplatesTab() { const contextBadgeClass = (context: 'stage' | 'audit' | 'persona') => { if (context === 'audit') return 'bg-editorial-warning/20 text-editorial-warning'; - if (context === 'persona') return 'bg-purple-500/15 text-purple-400'; + if (context === 'persona') return 'bg-editorial-textbox/60 text-editorial-muted'; return 'bg-editorial-accent/20 text-editorial-accent'; }; diff --git a/src/components/pipeline/PipelineConfig.tsx b/src/components/pipeline/PipelineConfig.tsx index b76c2be..1774caa 100644 --- a/src/components/pipeline/PipelineConfig.tsx +++ b/src/components/pipeline/PipelineConfig.tsx @@ -20,6 +20,7 @@ import { useOperationLogStore } from '../../stores/operationLogStore'; import { ReasoningPicker } from '../models/ReasoningPicker'; import { PromptPreviewTab } from './PromptPreviewTab'; import { canRefineWithProvider, formatProviderModelLabel, useProviderKeyStatus } from '../../hooks/useProviderKeyStatus'; +import { IconButton } from '../ui'; export type ConfigSection = 'settings' | 'translation' | 'audit' | 'glossary' | 'preview'; @@ -814,12 +815,6 @@ export function PipelineConfig({ preview: t('pipeline.tabPreview'), }; - const tabBtnCls = (active: boolean) => - `rounded-full border p-2.5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-editorial-accent ${ - active - ? 'border-editorial-accent bg-editorial-accent text-white' - : 'border-editorial-border text-editorial-muted hover:border-editorial-accent/40 hover:text-editorial-accent' - }`; return (
@@ -846,8 +841,9 @@ export function PipelineConfig({ ))} - +