diff --git a/README.md b/README.md index 0bfcd39..6d84d16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenFlow -Current version: `1.3.044` +Current version: `1.3.045` OpenFlow is a desktop voice dictation app for Windows and macOS built with Electron on the UI layer and Faster-Whisper for local transcription. diff --git a/package-lock.json b/package-lock.json index 9b42b5a..4259a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openflow", - "version": "1.3.044", + "version": "1.3.045", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openflow", - "version": "1.3.044", + "version": "1.3.045", "license": "SEE LICENSE IN LICENSE", "dependencies": { "dotenv": "^17.3.1", diff --git a/package.json b/package.json index b0d566b..7267136 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openflow", "productName": "OpenFlow", - "version": "1.3.044", + "version": "1.3.045", "description": "Cross-platform desktop dictation app OpenFlow using Electron and Faster-Whisper.", "main": "src/main/main.js", "scripts": { diff --git a/src/main/main.js b/src/main/main.js index 753ea2c..22615dd 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -8,6 +8,7 @@ const { Menu, Tray, clipboard, + dialog, globalShortcut, ipcMain, nativeImage, @@ -28,6 +29,7 @@ const DEFAULT_INTERFACE_LANGUAGE = 'en'; const DEFAULT_SHOW_OVERLAY_BAR = true; const DEFAULT_SOUND_EFFECTS_ENABLED = true; const DEFAULT_LAUNCH_AT_LOGIN = false; +const DEFAULT_START_HIDDEN = false; const DEFAULT_KEEP_ALL_TRANSCRIPTIONS = false; const DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS = false; const DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE = false; @@ -1080,6 +1082,7 @@ function getDefaultsFromEnv() { transcribeCancelledRecordings: DEFAULT_TRANSCRIBE_CANCELLED_RECORDINGS, autoEnableHandsFreeMode: DEFAULT_AUTO_ENABLE_HANDS_FREE_MODE, duckAudioEnabled: DEFAULT_DUCK_AUDIO, + startHidden: DEFAULT_START_HIDDEN, overlayOpacity: DEFAULT_OVERLAY_OPACITY, overlayScale: DEFAULT_OVERLAY_SCALE, overlayDynamicSize: DEFAULT_OVERLAY_DYNAMIC_SIZE, @@ -1140,6 +1143,7 @@ const state = { transcribeCancelledRecordings: defaults.transcribeCancelledRecordings, autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode, duckAudioEnabled: defaults.duckAudioEnabled, + startHidden: defaults.startHidden, overlayOpacity: defaults.overlayOpacity, overlayScale: defaults.overlayScale, overlayDynamicSize: defaults.overlayDynamicSize, @@ -1384,6 +1388,7 @@ function createEmptyPersistedState() { transcribeCancelledRecordings: defaults.transcribeCancelledRecordings, autoEnableHandsFreeMode: defaults.autoEnableHandsFreeMode, duckAudioEnabled: defaults.duckAudioEnabled, + startHidden: defaults.startHidden, overlayOpacity: defaults.overlayOpacity, overlayScale: defaults.overlayScale, overlayDynamicSize: defaults.overlayDynamicSize, @@ -1456,6 +1461,7 @@ function normalizePersistedState(payload) { preferencesSource.duckAudioEnabled, defaults.duckAudioEnabled, ), + startHidden: normalizeBooleanPreference(preferencesSource.startHidden, defaults.startHidden), overlayOpacity: normalizeOverlayOpacity(preferencesSource.overlayOpacity), overlayScale: normalizeOverlayScale(preferencesSource.overlayScale), overlayDynamicSize: normalizeBooleanPreference( @@ -1851,6 +1857,7 @@ function savePersistentState() { transcribeCancelledRecordings: state.transcribeCancelledRecordings, autoEnableHandsFreeMode: state.autoEnableHandsFreeMode, duckAudioEnabled: state.duckAudioEnabled, + startHidden: state.startHidden, overlayOpacity: state.overlayOpacity, overlayScale: state.overlayScale, overlayDynamicSize: state.overlayDynamicSize, @@ -4377,6 +4384,8 @@ async function applySettings(patch) { : state.pasteLastShortcut; const nextDuckAudioEnabled = typeof patch.duckAudioEnabled === 'boolean' ? patch.duckAudioEnabled : state.duckAudioEnabled; + const nextStartHidden = + typeof patch.startHidden === 'boolean' ? patch.startHidden : state.startHidden; const nextOverlayOpacity = Object.prototype.hasOwnProperty.call(patch, 'overlayOpacity') ? normalizeOverlayOpacity(patch.overlayOpacity) : state.overlayOpacity; @@ -4532,6 +4541,7 @@ async function applySettings(patch) { transcribeCancelledRecordings: nextTranscribeCancelledRecordings, autoEnableHandsFreeMode: nextAutoEnableHandsFreeMode, duckAudioEnabled: nextDuckAudioEnabled, + startHidden: nextStartHidden, overlayOpacity: nextOverlayOpacity, overlayScale: nextOverlayScale, overlayDynamicSize: nextOverlayDynamicSize, @@ -5152,8 +5162,145 @@ function scheduleStartupUpdateCheck() { }, 8000); } +function deleteHistoryEntry(entryKey) { + const key = entryKey && typeof entryKey === 'object' ? entryKey : {}; + const timestamp = String(key.timestamp || ''); + const text = String(key.text || ''); + const index = state.history.findIndex( + (entry) => entry.timestamp === timestamp && entry.text === text, + ); + if (index === -1) { + return snapshotState(); + } + + const nextHistory = [...state.history]; + nextHistory.splice(index, 1); + setState({ history: nextHistory }); + savePersistentState(); + return snapshotState(); +} + +function formatHistoryExportText(entries) { + return entries + .map((entry) => { + const timestamp = new Date(entry.timestamp); + const stamp = Number.isNaN(timestamp.getTime()) + ? String(entry.timestamp || '') + : timestamp.toLocaleString(); + return `[${stamp}] ${entry.text}`; + }) + .join('\n\n'); +} + +async function exportHistoryToFile() { + const entries = Array.isArray(state.history) ? state.history : []; + if (entries.length === 0) { + return { status: 'empty' }; + } + + const dateSuffix = new Date().toISOString().slice(0, 10); + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: `openflow-history-${dateSuffix}.txt`, + filters: [ + { name: 'Text', extensions: ['txt'] }, + { name: 'JSON', extensions: ['json'] }, + ], + }); + if (result.canceled || !result.filePath) { + return { status: 'canceled' }; + } + + const isJson = path.extname(result.filePath).toLowerCase() === '.json'; + const contents = isJson + ? `${JSON.stringify(entries, null, 2)}\n` + : `${formatHistoryExportText(entries)}\n`; + try { + fs.writeFileSync(result.filePath, contents, 'utf8'); + } catch (error) { + return { status: 'error', message: error?.message || String(error) }; + } + + return { status: 'saved', filePath: result.filePath, count: entries.length }; +} + +async function exportDictionaryToFile() { + const entries = Array.isArray(state.dictionaryEntries) ? state.dictionaryEntries : []; + if (entries.length === 0) { + return { status: 'empty' }; + } + + const dateSuffix = new Date().toISOString().slice(0, 10); + const result = await dialog.showSaveDialog(mainWindow, { + defaultPath: `openflow-dictionary-${dateSuffix}.json`, + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (result.canceled || !result.filePath) { + return { status: 'canceled' }; + } + + try { + fs.writeFileSync( + result.filePath, + `${JSON.stringify({ dictionaryEntries: entries }, null, 2)}\n`, + 'utf8', + ); + } catch (error) { + return { status: 'error', message: error?.message || String(error) }; + } + + return { status: 'saved', filePath: result.filePath, count: entries.length }; +} + +async function importDictionaryFromFile() { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (result.canceled || !result.filePaths || result.filePaths.length === 0) { + return { status: 'canceled' }; + } + + let payload = null; + try { + payload = JSON.parse(fs.readFileSync(result.filePaths[0], 'utf8')); + } catch (error) { + return { status: 'invalid', message: error?.message || String(error) }; + } + + const rawEntries = Array.isArray(payload) ? payload : payload?.dictionaryEntries; + if (!Array.isArray(rawEntries)) { + return { status: 'invalid' }; + } + + // Drop imported ids so merged rules always get fresh ids and cannot collide + // with existing ones. normalizeDictionaryEntries dedupes identical rules. + const imported = rawEntries.map((entry) => + entry && typeof entry === 'object' ? { ...entry, id: undefined } : entry, + ); + const before = state.dictionaryEntries.length; + const merged = normalizeDictionaryEntries([...state.dictionaryEntries, ...imported]); + const added = merged.length - before; + if (added <= 0) { + return { status: 'none' }; + } + + await applySettings({ dictionaryEntries: merged }); + return { status: 'imported', count: added }; +} + +function resetOverlayPosition() { + state.overlayPosition = null; + positionOverlayWindow(null, true); + return snapshotState(); +} + ipcMain.handle('get-state', async () => snapshotState()); ipcMain.handle('update-settings', async (_event, patch) => applySettings(patch || {})); +ipcMain.handle('delete-history-entry', async (_event, entryKey) => deleteHistoryEntry(entryKey)); +ipcMain.handle('export-history', async () => exportHistoryToFile()); +ipcMain.handle('export-dictionary', async () => exportDictionaryToFile()); +ipcMain.handle('import-dictionary', async () => importDictionaryFromFile()); +ipcMain.handle('reset-overlay-position', async () => resetOverlayPosition()); ipcMain.handle('reset-model-stats', async () => resetModelStats()); ipcMain.handle('save-openrouter-api-key', async (_event, apiKey) => saveOpenRouterApiKey(apiKey)); ipcMain.handle('clear-openrouter-api-key', async () => clearOpenRouterApiKey()); @@ -5223,6 +5370,7 @@ app.whenReady().then(() => { transcribeCancelledRecordings: persistedState.preferences.transcribeCancelledRecordings, autoEnableHandsFreeMode: persistedState.preferences.autoEnableHandsFreeMode, duckAudioEnabled: persistedState.preferences.duckAudioEnabled, + startHidden: persistedState.preferences.startHidden, overlayOpacity: persistedState.preferences.overlayOpacity, overlayScale: persistedState.preferences.overlayScale, overlayDynamicSize: persistedState.preferences.overlayDynamicSize, @@ -5239,7 +5387,9 @@ app.whenReady().then(() => { savePersistentState(); } shouldStartHiddenOnLaunch = - shouldStartHiddenOnLaunch || Boolean(app.getLoginItemSettings().wasOpenedAsHidden); + shouldStartHiddenOnLaunch || + Boolean(app.getLoginItemSettings().wasOpenedAsHidden) || + Boolean(persistedState.preferences.startHidden); rebuildDictionaryReplacementIndex(persistedState.preferences.dictionaryEntries); syncLaunchAtLoginSetting(); registerPasteLastShortcut(); diff --git a/src/main/preload.js b/src/main/preload.js index d4e89c4..b73af77 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('flowLocal', { previewOverlayStyle: (patch) => ipcRenderer.send('preview-overlay-style', patch), resetModelStats: () => ipcRenderer.invoke('reset-model-stats'), copyText: (text) => ipcRenderer.invoke('copy-text', text), + deleteHistoryEntry: (entryKey) => ipcRenderer.invoke('delete-history-entry', entryKey), + exportHistory: () => ipcRenderer.invoke('export-history'), + exportDictionary: () => ipcRenderer.invoke('export-dictionary'), + importDictionary: () => ipcRenderer.invoke('import-dictionary'), + resetOverlayPosition: () => ipcRenderer.invoke('reset-overlay-position'), onStateUpdate: (callback) => { const listener = (_event, state) => callback(state); ipcRenderer.on('app-state', listener); diff --git a/src/renderer/index.html b/src/renderer/index.html index dff18e1..abb55a6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -84,7 +84,7 @@

Latest transcriptions

-
@@ -220,6 +229,18 @@

Floating bar

+
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 2c0fe84..32523ac 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -146,6 +146,24 @@ const TRANSLATIONS = { fillRuleError: 'Add at least one variation to replace.', copy: 'Copy', copied: 'Copied', + deleteEntry: 'Delete', + exportHistory: 'Export', + historyExported: 'History exported ({count} messages).', + historyExportEmpty: 'No transcriptions to export.', + exportFailed: 'Export failed: {message}', + confirmReset: 'Click again to confirm', + startHidden: 'Start minimized to tray', + startHiddenCopy: 'Keep the main window hidden when OpenFlow starts. Use the tray icon to open it.', + resetOverlayPosition: 'Reset position', + resetOverlayPositionCopy: 'Moved the pill somewhere awkward? Bring it back to the default spot.', + overlayPositionReset: 'Floating bar is back at the default position.', + exportDictionary: 'Export', + importDictionary: 'Import', + dictionaryExported: 'Dictionary exported ({count} rules).', + dictionaryExportEmpty: 'No rules to export.', + dictionaryImported: '{count} rule(s) imported.', + dictionaryImportNone: 'No new rules to import.', + dictionaryImportInvalid: 'This file is not a valid dictionary export.', loadingStatusTitle: 'Loading model', loadingStatusDetail: '', loadingStatusHint: '', @@ -304,6 +322,24 @@ const TRANSLATIONS = { fillRuleError: 'Adicione pelo menos uma variação para substituir.', copy: 'Copiar', copied: 'Copiado', + deleteEntry: 'Excluir', + exportHistory: 'Exportar', + historyExported: 'Histórico exportado ({count} mensagens).', + historyExportEmpty: 'Nenhuma transcrição para exportar.', + exportFailed: 'Falha ao exportar: {message}', + confirmReset: 'Clique de novo para confirmar', + startHidden: 'Iniciar minimizado na bandeja', + startHiddenCopy: 'Mantém a janela principal oculta quando o OpenFlow abre. Use o ícone da bandeja para abrir.', + resetOverlayPosition: 'Restaurar posição', + resetOverlayPositionCopy: 'Moveu a barra para um lugar ruim? Traga ela de volta para o lugar padrão.', + overlayPositionReset: 'Barra flutuante de volta à posição padrão.', + exportDictionary: 'Exportar', + importDictionary: 'Importar', + dictionaryExported: 'Dicionário exportado ({count} regras).', + dictionaryExportEmpty: 'Nenhuma regra para exportar.', + dictionaryImported: '{count} regra(s) importada(s).', + dictionaryImportNone: 'Nenhuma regra nova para importar.', + dictionaryImportInvalid: 'Este arquivo não é um dicionário exportado válido.', loadingStatusTitle: 'Carregando o modelo', loadingStatusDetail: '', loadingStatusHint: '', @@ -600,6 +636,8 @@ const els = { historyList: document.getElementById('history-list'), historyCount: document.getElementById('history-count'), historySearch: document.getElementById('history-search'), + historySearchClear: document.getElementById('history-search-clear'), + exportHistory: document.getElementById('export-history'), historyCopy: document.getElementById('history-copy'), shortcutLabel: document.getElementById('shortcut-label'), pasteShortcutLabel: document.getElementById('paste-shortcut-label'), @@ -655,6 +693,8 @@ const els = { dictionaryList: document.getElementById('dictionary-list'), dictionaryCount: document.getElementById('dictionary-count'), dictionarySearch: document.getElementById('dictionary-search'), + exportDictionary: document.getElementById('export-dictionary'), + importDictionary: document.getElementById('import-dictionary'), cancelDictionaryEdit: document.getElementById('cancel-dictionary-edit'), submitDictionaryRule: document.getElementById('submit-dictionary-rule'), openSettings: document.getElementById('open-settings'), @@ -670,6 +710,8 @@ const els = { shortcutCaptureKeys: document.getElementById('shortcut-capture-keys'), pasteShortcutCaptureKeys: document.getElementById('paste-shortcut-capture-keys'), duckAudio: document.getElementById('duck-audio'), + startHidden: document.getElementById('start-hidden'), + resetOverlayPosition: document.getElementById('reset-overlay-position'), overlayOpacity: document.getElementById('overlay-opacity'), overlayOpacityValue: document.getElementById('overlay-opacity-value'), overlayScale: document.getElementById('overlay-scale'), @@ -1173,6 +1215,33 @@ function renderInterfaceLanguages() { .join(''); } +function highlightMatch(text, query) { + const value = String(text ?? ''); + if (!query) { + return esc(value); + } + + const needle = query.toLocaleLowerCase(locale()); + const haystack = value.toLocaleLowerCase(locale()); + if (haystack.length !== value.length) { + // Case folding changed string length; offsets would be unreliable. + return esc(value); + } + + let result = ''; + let index = 0; + while (index <= value.length) { + const found = haystack.indexOf(needle, index); + if (found === -1) { + result += esc(value.slice(index)); + break; + } + result += `${esc(value.slice(index, found))}${esc(value.slice(found, found + needle.length))}`; + index = found + needle.length; + } + return result; +} + function renderHistory(history, total) { const list = Array.isArray(history) ? history : []; const signature = [ @@ -1230,7 +1299,7 @@ function renderHistory(history, total) { ${timestamp.toLocaleDateString(locale(), { day: '2-digit', month: '2-digit' })}
-

${esc(entry.text)}

+

${highlightMatch(entry.text, query)}

${esc(modelLabel(entry.model))} ${esc(capitalizeLanguageLabel(langName(entry.language || 'en')))} @@ -1239,7 +1308,10 @@ function renderHistory(history, total) { ${intFmt(entry.wordCount || 0)} ${wordsLabel}
- +
+ + +
`; }) @@ -1673,6 +1745,7 @@ function renderState(state) { els.transcribeCancelledRecordings.checked = Boolean(state.transcribeCancelledRecordings); els.autoEnableHandsFreeMode.checked = Boolean(state.autoEnableHandsFreeMode); els.duckAudio.checked = Boolean(state.duckAudioEnabled); + els.startHidden.checked = Boolean(state.startHidden); els.overlayDynamicSize.checked = Boolean(state.overlayDynamicSize); // Don't fight the user while they are dragging a slider or recording a shortcut. @@ -2079,7 +2152,23 @@ function setupHandlers() { } }); + let resetStatsConfirmTimer = null; + const restoreResetStatsButton = () => { + if (resetStatsConfirmTimer) { + window.clearTimeout(resetStatsConfirmTimer); + resetStatsConfirmTimer = null; + } + delete els.resetStats.dataset.confirming; + els.resetStats.textContent = t('resetAverage'); + }; els.resetStats.addEventListener('click', async () => { + if (els.resetStats.dataset.confirming !== 'true') { + els.resetStats.dataset.confirming = 'true'; + els.resetStats.textContent = t('confirmReset'); + resetStatsConfirmTimer = window.setTimeout(restoreResetStatsButton, 3000); + return; + } + restoreResetStatsButton(); renderState(await window.flowLocal.resetModelStats()); }); @@ -2291,6 +2380,13 @@ function setupHandlers() { els.duckAudio.addEventListener('change', async () => { renderState(await window.flowLocal.updateSettings({ duckAudioEnabled: els.duckAudio.checked })); }); + els.startHidden.addEventListener('change', async () => { + renderState(await window.flowLocal.updateSettings({ startHidden: els.startHidden.checked })); + }); + els.resetOverlayPosition.addEventListener('click', async () => { + renderState(await window.flowLocal.resetOverlayPosition()); + showToast(t('overlayPositionReset')); + }); els.overlayOpacity.addEventListener('input', () => { els.overlayOpacityValue.textContent = `${els.overlayOpacity.value}%`; @@ -2327,12 +2423,47 @@ function setupHandlers() { true, ); - els.historySearch.addEventListener('input', () => { + const applyHistoryFilter = () => { historyFilter = els.historySearch.value || ''; + els.historySearchClear.classList.toggle('hidden', !historyFilter); if (lastState) renderHistory(lastState.history, lastState.historyTotal); + }; + let historySearchDebounce = null; + els.historySearch.addEventListener('input', () => { + els.historySearchClear.classList.toggle('hidden', !els.historySearch.value); + // Debounced: re-rendering the full list on every keystroke gets slow once + // "keep all transcriptions" grows the history into the thousands. + if (historySearchDebounce) window.clearTimeout(historySearchDebounce); + historySearchDebounce = window.setTimeout(applyHistoryFilter, 150); + }); + els.historySearchClear.addEventListener('click', () => { + els.historySearch.value = ''; + applyHistoryFilter(); + els.historySearch.focus(); + }); + + els.exportHistory.addEventListener('click', async () => { + const result = await window.flowLocal.exportHistory(); + if (result?.status === 'saved') { + showToast(template('historyExported', { count: result.count })); + } else if (result?.status === 'empty') { + showToast(t('historyExportEmpty')); + } else if (result?.status === 'error') { + showToast(template('exportFailed', { message: result.message || '' })); + } }); els.historyList.addEventListener('click', async (event) => { + const deleteButton = event.target.closest('[data-history-delete]'); + if (deleteButton) { + const entry = renderedHistory[Number(deleteButton.getAttribute('data-history-delete'))]; + if (!entry) return; + renderState( + await window.flowLocal.deleteHistoryEntry({ timestamp: entry.timestamp, text: entry.text }), + ); + return; + } + const button = event.target.closest('[data-history-index]'); if (!button) return; const entry = renderedHistory[Number(button.getAttribute('data-history-index'))]; @@ -2418,7 +2549,41 @@ function setupHandlers() { resetDictionaryForm(getDictionaryFallbackLanguages()); }); + els.exportDictionary.addEventListener('click', async () => { + const result = await window.flowLocal.exportDictionary(); + if (result?.status === 'saved') { + showToast(template('dictionaryExported', { count: result.count })); + } else if (result?.status === 'empty') { + showToast(t('dictionaryExportEmpty')); + } else if (result?.status === 'error') { + showToast(template('exportFailed', { message: result.message || '' })); + } + }); + + els.importDictionary.addEventListener('click', async () => { + const result = await window.flowLocal.importDictionary(); + if (result?.status === 'imported') { + renderState(await window.flowLocal.getState()); + showToast(template('dictionaryImported', { count: result.count })); + } else if (result?.status === 'none') { + showToast(t('dictionaryImportNone')); + } else if (result?.status === 'invalid') { + showToast(t('dictionaryImportInvalid')); + } + }); + window.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && String(event.key).toLowerCase() === 'f') { + event.preventDefault(); + if (dictionaryOpen) { + els.dictionarySearch.focus(); + els.dictionarySearch.select(); + } else if (!settingsOpen && !advancedOpen) { + els.historySearch.focus(); + els.historySearch.select(); + } + return; + } if (event.key !== 'Escape') { return; } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index bf09c15..c9a09f2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -391,12 +391,60 @@ h1, h2, h3 { .history-toolbar { margin-bottom: 16px; + display: flex; + align-items: center; + gap: 10px; +} + +.history-toolbar .search-field { + flex: 1 1 auto; + min-width: 0; +} + +.history-toolbar .secondary-button { + flex: 0 0 auto; } .search-field { display: block; } +.search-field--clearable { + position: relative; +} + +.search-field--clearable input { + padding-right: 38px; +} + +.search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.search-clear:hover { + background: var(--panel-bg-subtle); + color: var(--text); +} + +.search-clear.hidden { + display: none; +} + .search-field--settings { margin-bottom: 14px; } @@ -482,6 +530,31 @@ h1, h2, h3 { color: var(--muted); } +.history-item__body mark { + /* Translucent amber keeps readable contrast in both dark and light themes. */ + background: rgba(245, 158, 11, 0.35); + color: inherit; + border-radius: 3px; + padding: 0 1px; +} + +.history-item__actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +.history-delete { + opacity: 0; + transition: opacity 0.15s; +} + +.history-item:hover .history-delete, +.history-delete:focus-visible { + opacity: 1; +} + .copy-button { height: 32px; padding: 0 12px; @@ -891,6 +964,27 @@ h1, h2, h3 { font-size: 1.05rem; } +.dictionary-toolbar__actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.reset-position-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-top: 12px; +} + +.reset-position-row p { + margin: 0; + color: var(--muted); + font-size: 0.85rem; +} + .dictionary-search { position: relative; flex: 0 1 260px;