diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3de1f..ddb4607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to Ulpaso will be documented in this file. The format follow ## [Unreleased] +## [0.5.3] - 2026-08-15 + +### Fixed + +- Trust the bounded final diarization pass when it reduces noisy four-slot live output to two or more stable speakers, and compact sparse speaker numbers before writing Markdown. + +### Added + +- Added a two-step Settings action that removes downloaded transcription models, runtime files, tools, caches, and retained recovery audio before uninstalling Ulpaso. + ## [0.5.2] - 2026-08-15 ### Fixed diff --git a/README.md b/README.md index cc9cc26..7a02ea4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Ulpaso currently requires: Download the DMG, move Ulpaso to Applications, and open it normally. Current releases include checksums and a signed, notarized, stapled macOS build. +Before moving Ulpaso to the Trash, open **Settings → Local transcription data** and choose **Remove** twice. This deletes the downloaded runtime, speech and speaker models, and retained recovery audio; Markdown documents remain untouched in the folders where you saved them. + The editor UI can run in a browser on other platforms, but native saving, system-audio capture, meeting detection, and transcription require the macOS desktop app. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e6f71d1..58ad260 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -39,4 +39,6 @@ Ulpaso also contacts GitHub Releases to check for signed application updates. Th ## Removing local data -Quit Ulpaso, then remove its application data directory if you want to delete downloaded models, runtime files, and retained meeting recovery audio. Saving or moving Markdown documents is independent of this directory because the documents remain wherever you chose to store them. +Before moving Ulpaso to the Trash, open **Settings → Local transcription data** and confirm **Remove models & audio**. Ulpaso then deletes the downloaded models, local runtime and tools, model caches, and retained meeting recovery audio from its application data directory. This action is unavailable while a meeting is active. + +macOS does not launch an application after it has been moved to the Trash, so the cleanup must be requested inside Ulpaso before removal. Saving or moving Markdown documents is independent of this action because the documents remain wherever you chose to store them. diff --git a/package.json b/package.json index bb26dc0..4072913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ulpaso", - "version": "0.5.2", + "version": "0.5.3", "private": true, "description": "A local Markdown editor with on-device meeting transcription for macOS", "license": "MIT", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0e8943a..54bafa5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4484,7 +4484,7 @@ dependencies = [ [[package]] name = "ulpaso" -version = "0.5.2" +version = "0.5.3" dependencies = [ "cc", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ec36c44..b60a94c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ulpaso" -version = "0.5.2" +version = "0.5.3" description = "A quiet, focused Markdown editor" authors = ["Ulpaso contributors"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a2e555..26149e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,6 +159,7 @@ pub fn run() { updater::update_install, meeting::meeting_status, meeting::meeting_resources, + meeting::meeting_remove_local_data, meeting::meeting_prepare, meeting::meeting_start, meeting::meeting_stop, diff --git a/src-tauri/src/meeting.rs b/src-tauri/src/meeting.rs index 1d42d16..ba5042a 100644 --- a/src-tauri/src/meeting.rs +++ b/src-tauri/src/meeting.rs @@ -5,7 +5,7 @@ use audio::{ }; use chrono::Utc; use recovery::repair_recovery_wav_headers; -use resources::MeetingResourceStatus; +use resources::{MeetingResourceRemoval, MeetingResourceStatus}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ @@ -188,6 +188,24 @@ impl MeetingController { Ok(resources::inspect(&resource_dir, &app_data)) } + pub fn remove_local_data(&self) -> Result { + let active = self + .inner + .lock() + .map_err(|_| "Could not inspect the meeting state".to_string())? + .active + .is_some(); + if active { + return Err("Finish the current meeting before removing transcription data".into()); + } + let app_data = self + .app + .path() + .app_data_dir() + .map_err(|error| format!("Could not locate the app data folder: {error}"))?; + resources::remove_downloaded(&app_data) + } + pub fn start( &self, microphone_only: bool, @@ -1382,6 +1400,13 @@ pub fn meeting_resources( controller.resources() } +#[tauri::command] +pub fn meeting_remove_local_data( + controller: tauri::State<'_, MeetingController>, +) -> Result { + controller.remove_local_data() +} + #[tauri::command] pub fn meeting_prepare( controller: tauri::State<'_, MeetingController>, diff --git a/src-tauri/src/meeting/resources.rs b/src-tauri/src/meeting/resources.rs index a76f6c3..b26ec93 100644 --- a/src-tauri/src/meeting/resources.rs +++ b/src-tauri/src/meeting/resources.rs @@ -1,11 +1,14 @@ -//! Read-only inspection of local transcription resources. +//! Inspection and explicit removal of local transcription resources. //! //! The UI uses this before the first meeting so it can disclose the real //! download and disk cost instead of beginning a multi-gigabyte setup without //! context. use serde::Serialize; -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; const RUNTIME_BYTES: u64 = 500_000_000; const ASR_MODEL_BYTES: u64 = 1_020_000_000; @@ -25,6 +28,12 @@ pub struct MeetingResourceStatus { pub disk_space_sufficient: bool, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeetingResourceRemoval { + pub removed_bytes: u64, +} + pub(crate) fn inspect(resource_dir: &Path, app_data: &Path) -> MeetingResourceStatus { let runtime_ready = runtime_candidates(resource_dir, app_data) .iter() @@ -89,6 +98,44 @@ fn model_ready(path: &Path) -> bool { }) } +pub(crate) fn remove_downloaded(app_data: &Path) -> Result { + let targets = [ + app_data.join("Models"), + app_data.join("ASR Runtime"), + app_data.join("ASR Tools"), + app_data.join("Meeting Recovery"), + ]; + let removed_bytes = targets.iter().map(|path| directory_bytes(path)).sum(); + for target in targets { + if !target.exists() { + continue; + } + if target.is_dir() { + fs::remove_dir_all(&target) + } else { + fs::remove_file(&target) + } + .map_err(|error| format!("Could not remove {}: {error}", target.display()))?; + } + Ok(MeetingResourceRemoval { removed_bytes }) +} + +fn directory_bytes(path: &Path) -> u64 { + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return metadata.len(); + } + fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .flatten() + .map(|entry| directory_bytes(&entry.path())) + .sum() +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +179,29 @@ mod tests { assert_eq!(status.estimated_download_bytes, 0); let _ = std::fs::remove_dir_all(root); } + + #[test] + fn removes_only_downloaded_transcription_data() { + let root = test_root(); + let data = root.join("data"); + std::fs::create_dir_all(data.join("Models/model")).expect("create models"); + std::fs::create_dir_all(data.join("ASR Runtime/bin")).expect("create runtime"); + std::fs::create_dir_all(data.join("ASR Tools")).expect("create tools"); + std::fs::create_dir_all(data.join("Meeting Recovery")).expect("create recovery"); + std::fs::write(data.join("Models/model/weights"), b"1234").expect("write model"); + std::fs::write(data.join("ASR Runtime/bin/python3"), b"12").expect("write runtime"); + std::fs::write(data.join("ASR Tools/uv"), b"1").expect("write tool"); + std::fs::write(data.join("Meeting Recovery/session.wav"), b"123").expect("write audio"); + std::fs::write(data.join("keep.txt"), b"document state").expect("write unrelated data"); + + let removal = remove_downloaded(&data).expect("remove downloaded data"); + + assert_eq!(removal.removed_bytes, 10); + assert!(!data.join("Models").exists()); + assert!(!data.join("ASR Runtime").exists()); + assert!(!data.join("ASR Tools").exists()); + assert!(!data.join("Meeting Recovery").exists()); + assert!(data.join("keep.txt").exists()); + let _ = std::fs::remove_dir_all(root); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9587b95..ce36942 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Ulpaso", - "version": "0.5.2", + "version": "0.5.3", "identifier": "app.ulpaso.editor", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/App.tsx b/src/App.tsx index 2e3e2e1..b6a23da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { type RecentDocument, } from "./document/storage"; import { + MEETING_RESOURCE_CONSENT_KEY, formatBytes, hasMeetingResourceConsent, saveMeetingResourceConsent, @@ -71,6 +72,7 @@ type PendingMeetingStart = { type AppUpdate = { version: string }; type UpdateProgress = { downloaded: number; total: number | null }; type MicrophonePermission = "not-determined" | "authorized" | "denied" | "restricted" | "unavailable"; +type MeetingResourceRemoval = { removedBytes: number }; const IDLE_MEETING: MeetingState = { phase: "idle", sessionId: null, progress: null, message: null, @@ -162,6 +164,7 @@ export default function App() { const [settingsOpen, setSettingsOpen] = createSignal(false); const [microphonePermission, setMicrophonePermission] = createSignal("unavailable"); const [microphonePermissionBusy, setMicrophonePermissionBusy] = createSignal(false); + const [localDataRemovalBusy, setLocalDataRemovalBusy] = createSignal(false); const [pendingDocumentAction, setPendingDocumentAction] = createSignal(null); const [appUpdate, setAppUpdate] = createSignal(null); const [updatePhase, setUpdatePhase] = createSignal("available"); @@ -527,6 +530,21 @@ export default function App() { return t("settings.meetingDownload", { size: formatBytes(bytes) }); } + async function removeLocalMeetingData() { + if (!isNative || localDataRemovalBusy() || meeting().phase !== "idle") return; + setLocalDataRemovalBusy(true); + try { + const result = await invoke("meeting_remove_local_data"); + try { localStorage.removeItem(MEETING_RESOURCE_CONSENT_KEY); } catch { /* storage can be disabled */ } + await refreshMeetingResources(); + showToast(t("settings.localDataRemoved", { size: formatBytes(result.removedBytes) }), "success", 4200); + } catch (error) { + showToast(readableError(error, t("settings.localDataRemoveFailed")), "error", 4200); + } finally { + setLocalDataRemovalBusy(false); + } + } + async function retryMeeting(microphoneOnly = false, systemOnly = false) { try { await invoke("meeting_cancel"); } catch { /* already reset */ } setMeetingErrorOpen(false); @@ -884,11 +902,14 @@ export default function App() { editorFullWidth={editorFullWidth()} microphonePermission={microphonePermission()} microphonePermissionBusy={microphonePermissionBusy()} + localDataRemovalBusy={localDataRemovalBusy()} + localDataRemovalDisabled={!isNative || meeting().phase !== "idle"} onClose={() => setSettingsOpen(false)} onToggleTheme={toggleTheme} onToggleMeetingDetection={toggleMeetingDetection} onToggleEditorFullWidth={toggleEditorFullWidth} onManageMicrophonePermission={() => void manageMicrophonePermission()} + onRemoveLocalMeetingData={() => void removeLocalMeetingData()} /> diff --git a/src/components/SettingsPopover.test.tsx b/src/components/SettingsPopover.test.tsx index ed75e58..52b10c8 100644 --- a/src/components/SettingsPopover.test.tsx +++ b/src/components/SettingsPopover.test.tsx @@ -5,9 +5,16 @@ import { render } from "solid-js/web"; import { setLocale } from "../i18n"; import SettingsPopover from "./SettingsPopover"; +const localDataProps = { + localDataRemovalBusy: false, + localDataRemovalDisabled: false, + onRemoveLocalMeetingData: vi.fn(), +}; + afterEach(() => { setLocale("en"); document.body.replaceChildren(); + vi.clearAllMocks(); }); describe("SettingsPopover shortcut guide", () => { @@ -16,6 +23,7 @@ describe("SettingsPopover shortcut guide", () => { document.body.append(root); const dispose = render(() => ( { document.body.append(root); const dispose = render(() => ( { const onToggleMeetingDetection = vi.fn(); const dispose = render(() => ( { const onToggleEditorFullWidth = vi.fn(); const dispose = render(() => ( { dispose(); }); + it("requires a second explicit click before deleting models and recovery audio", () => { + const root = document.createElement("div"); + document.body.append(root); + const onRemoveLocalMeetingData = vi.fn(); + const dispose = render(() => ( + + ), root); + + const button = root.querySelector(".settings-link-danger")!; + button.click(); + expect(onRemoveLocalMeetingData).not.toHaveBeenCalled(); + expect(button.textContent).toContain("Remove models & audio"); + + button.click(); + expect(onRemoveLocalMeetingData).toHaveBeenCalledOnce(); + dispose(); + }); }); diff --git a/src/components/SettingsPopover.tsx b/src/components/SettingsPopover.tsx index b071623..21c69ca 100644 --- a/src/components/SettingsPopover.tsx +++ b/src/components/SettingsPopover.tsx @@ -10,11 +10,14 @@ interface SettingsPopoverProps { editorFullWidth: boolean; microphonePermission: "not-determined" | "authorized" | "denied" | "restricted" | "unavailable"; microphonePermissionBusy: boolean; + localDataRemovalBusy: boolean; + localDataRemovalDisabled: boolean; onClose(): void; onToggleTheme(): void; onToggleMeetingDetection(): void; onToggleEditorFullWidth(): void; onManageMicrophonePermission(): void; + onRemoveLocalMeetingData(): void; } const shortcutGroupColumns = [ @@ -83,6 +86,7 @@ function ShortcutGuideTrigger() { } export default function SettingsPopover(props: SettingsPopoverProps) { + const [localDataRemovalArmed, setLocalDataRemovalArmed] = createSignal(false); const microphoneActionLabel = () => { if (props.microphonePermissionBusy) return t("settings.microphoneRequesting"); if (props.microphonePermission === "authorized") return t("settings.microphoneAllowed"); @@ -163,6 +167,29 @@ export default function SettingsPopover(props: SettingsPopoverProps) { {microphoneActionLabel()} +
+
{t("settings.localData")}{t("settings.localDataDescription")}
+ +
{t("settings.shortcuts")}{t("settings.shortcutsDescription")}
diff --git a/src/editor/KukuEditor.meeting.test.tsx b/src/editor/KukuEditor.meeting.test.tsx index 5b2be04..e964b9d 100644 --- a/src/editor/KukuEditor.meeting.test.tsx +++ b/src/editor/KukuEditor.meeting.test.tsx @@ -225,6 +225,40 @@ describe("KukuEditor meeting integration", () => { dispose(); }); + it("removes provisional third and fourth speakers after final cleanup", async () => { + const root = document.createElement("div"); + document.body.append(root); + let handle: KukuEditorHandle | undefined; + const dispose = render( + () => ( + { handle = next; }} + onChange={() => undefined} + /> + ), + root, + ); + await Promise.resolve(); + + handle!.beginMeeting("session-over-split", "미팅 노트"); + handle!.updateMeeting("session-over-split", "첫 발언", "", 1); + handle!.updateMeeting("session-over-split", "첫 발언 임시 둘", "", 2); + handle!.updateMeeting("session-over-split", "첫 발언 임시 둘 상대방", "", 3); + handle!.updateMeeting("session-over-split", "첫 발언 임시 둘 상대방 임시 넷", "", 4); + handle!.finalizeMeeting("session-over-split", [ + { speaker: 1, text: "첫 발언 임시 둘" }, + { speaker: 2, text: "상대방 임시 넷" }, + ]); + + const markdown = handle!.getMarkdown(); + expect(markdown.match(/\*\*Speaker 1\*\*/g)).toHaveLength(1); + expect(markdown.match(/\*\*Speaker 2\*\*/g)).toHaveLength(1); + expect(markdown).not.toContain("**Speaker 3**"); + expect(markdown).not.toContain("**Speaker 4**"); + dispose(); + }); + it("follows the live transcript tail until the user scrolls away", async () => { const root = document.createElement("div"); document.body.append(root); diff --git a/src/editor/meeting_document.test.ts b/src/editor/meeting_document.test.ts index 66e5411..7d0490d 100644 --- a/src/editor/meeting_document.test.ts +++ b/src/editor/meeting_document.test.ts @@ -49,6 +49,36 @@ describe("meeting document markdown", () => { ); }); + it("trusts final two-speaker cleanup over four provisional live labels", () => { + const cleaned = preserveSpeakerBoundaries( + [ + { speaker: 1, text: "첫 발언" }, + { speaker: 2, text: "두 번째 임시 라벨" }, + { speaker: 3, text: "상대방 발언" }, + { speaker: 4, text: "네 번째 임시 라벨" }, + ], + [ + { speaker: 1, text: "첫 발언 두 번째 임시 라벨" }, + { speaker: 2, text: "상대방 발언 네 번째 임시 라벨" }, + ], + ); + + expect(cleaned.map((segment) => segment.speaker)).toEqual([1, 2]); + }); + + it("compacts sparse final speaker labels in first-appearance order", () => { + const cleaned = preserveSpeakerBoundaries( + [{ speaker: 1, text: "실시간 문장" }], + [ + { speaker: 1, text: "첫 화자" }, + { speaker: 3, text: "둘째 화자" }, + { speaker: 1, text: "첫 화자 마무리" }, + ], + ); + + expect(cleaned.map((segment) => segment.speaker)).toEqual([1, 2, 1]); + }); + it("serializes final speaker turns as editable standard markdown", () => { const content = createMeetingDocumentNodes("미팅 노트 · 2026-08-04 14:30", [ { speaker: 1, text: "첫 번째 발화 내용" }, diff --git a/src/editor/meeting_document.ts b/src/editor/meeting_document.ts index 2499fb1..e2871f4 100644 --- a/src/editor/meeting_document.ts +++ b/src/editor/meeting_document.ts @@ -118,15 +118,30 @@ function preserveSpeakerBoundaries( previousSegments: MeetingTranscriptSegment[], correctedSegments: MeetingTranscriptSegment[], ): MeetingTranscriptSegment[] { - const previous = previousSegments.filter((segment) => segment.text.trim()); - const corrected = correctedSegments.filter((segment) => segment.text.trim()); + const compactSpeakerLabels = (segments: MeetingTranscriptSegment[]) => { + const labels = new Map(); + return segments + .filter((segment) => segment.text.trim()) + .map((segment) => { + if (segment.speaker == null) return { ...segment, speaker: null }; + if (!labels.has(segment.speaker)) labels.set(segment.speaker, labels.size + 1); + return { ...segment, speaker: labels.get(segment.speaker) }; + }); + }; + const previous = compactSpeakerLabels(previousSegments); + const corrected = compactSpeakerLabels(correctedSegments); const previousSpeakers = new Set(previous.flatMap((segment) => ( segment.speaker == null ? [] : [segment.speaker] ))); const correctedSpeakers = new Set(corrected.flatMap((segment) => ( segment.speaker == null ? [] : [segment.speaker] ))); - if (previousSpeakers.size < 2 || correctedSpeakers.size >= previousSpeakers.size) return corrected; + // Live Sortformer labels are provisional and can briefly occupy all four + // slots even in a two-person recording. Once the bounded full-file pass + // finds two or more speakers, trust it instead of restoring those noisy + // live labels. Preserve live boundaries only for the narrow regression we + // can defend: exactly two stable live speakers collapsed to zero or one. + if (previousSpeakers.size !== 2 || correctedSpeakers.size >= 2) return corrected; const correctedText = corrected.map((segment) => segment.text.trim()).join(" "); if (!correctedText) return previous; diff --git a/src/i18n.ts b/src/i18n.ts index a503b25..423ebb8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -43,6 +43,13 @@ const en = { "settings.meetingDescription": "On-device transcription", "settings.meetingReady": "Local models ready", "settings.meetingDownload": "Downloads about {size} of AI models on first use", + "settings.localData": "Local transcription data", + "settings.localDataDescription": "Models and recovery audio left on this Mac", + "settings.localDataRemove": "Remove…", + "settings.localDataConfirm": "Remove models & audio", + "settings.localDataRemoving": "Removing…", + "settings.localDataRemoved": "Removed {size} of local transcription data", + "settings.localDataRemoveFailed": "Could not remove local transcription data", "settings.audioPermissions": "Audio permissions", "settings.microphone": "Microphone", "settings.microphoneStatus.not-determined": "Permission not requested yet", @@ -202,6 +209,7 @@ const en = { type MessageKey = keyof typeof en; const ko: Partial> = { + "settings.localData": "로컬 전사 데이터", "settings.localDataDescription": "앱 삭제 전 모델과 복구 오디오를 함께 정리", "settings.localDataRemove": "삭제…", "settings.localDataConfirm": "모델과 오디오 삭제", "settings.localDataRemoving": "삭제 중…", "settings.localDataRemoved": "로컬 전사 데이터 {size}를 삭제했습니다", "settings.localDataRemoveFailed": "로컬 전사 데이터를 삭제하지 못했습니다", "settings.microphone": "마이크", "settings.microphoneStatus.not-determined": "아직 권한을 요청하지 않았습니다", "settings.microphoneStatus.authorized": "Ulpaso에 허용됨", "settings.microphoneStatus.denied": "시스템 설정에서 차단됨", "settings.microphoneStatus.restricted": "이 Mac에서 제한됨", "settings.microphoneStatus.unavailable": "Mac 앱에서 사용할 수 있습니다", "settings.microphoneAllow": "허용", "settings.microphoneAllowed": "허용됨", "settings.microphoneOpenSettings": "설정 열기", "settings.microphoneRequesting": "기다리는 중…", "settings.microphoneDenied": "회의 기록을 위해 마이크 접근을 허용해 주세요.", "settings.editorWidth": "편집기 너비", "settings.editorWidthDescription": "집중형 또는 창 전체로 사용", "settings.editorWidthFocused": "집중형", "settings.editorWidthFull": "전체 너비", "settings.meetingDetection": "회의 감지", "settings.meetingDetectionDescription": "회의 앱이 마이크를 사용하면 기록 여부를 묻습니다", "meeting.detectedTitle": "이 미팅을 기록할까요?", "meeting.detectedBody": "{app}에서 마이크를 사용 중입니다. 오디오와 전사 내용은 이 Mac에만 남습니다.", "meeting.detectedCancel": "지금은 안 함", "meeting.detectedConfirm": "기록 시작", @@ -219,6 +227,7 @@ const ko: Partial> = { }; const ja: Partial> = { + "settings.localData": "ローカル文字起こしデータ", "settings.localDataDescription": "アプリ削除前にモデルと復旧音声をまとめて削除", "settings.localDataRemove": "削除…", "settings.localDataConfirm": "モデルと音声を削除", "settings.localDataRemoving": "削除中…", "settings.localDataRemoved": "ローカル文字起こしデータ{size}を削除しました", "settings.localDataRemoveFailed": "ローカル文字起こしデータを削除できませんでした", "settings.microphone": "マイク", "settings.microphoneStatus.not-determined": "まだ権限を要求していません", "settings.microphoneStatus.authorized": "Ulpasoに許可済み", "settings.microphoneStatus.denied": "システム設定でブロック中", "settings.microphoneStatus.restricted": "このMacで制限されています", "settings.microphoneStatus.unavailable": "Macアプリで利用できます", "settings.microphoneAllow": "許可", "settings.microphoneAllowed": "許可済み", "settings.microphoneOpenSettings": "設定を開く", "settings.microphoneRequesting": "待機中…", "settings.microphoneDenied": "会議メモにはマイクへのアクセスを許可してください。", "settings.editorWidth": "エディター幅", "settings.editorWidthDescription": "集中表示またはウィンドウ全体を使用", "settings.editorWidthFocused": "集中", "settings.editorWidthFull": "全幅", "settings.meetingDetection": "会議の検出", "settings.meetingDetectionDescription": "会議アプリがマイクを使用すると記録するか確認します", "meeting.detectedTitle": "この会議を記録しますか?", "meeting.detectedBody": "{app}がマイクを使用しています。音声と文字起こしはこのMac内に残ります。", "meeting.detectedCancel": "今はしない", "meeting.detectedConfirm": "記録を開始", diff --git a/src/styles.css b/src/styles.css index 2c95ed6..c880691 100644 --- a/src/styles.css +++ b/src/styles.css @@ -459,6 +459,9 @@ kbd { min-width: 22px; padding: 3px 6px; border-radius: 4px; border: 1px solid v .settings-link { height: 29px; flex: 0 0 auto; padding: 0 8px 0 9px; border: 1px solid var(--border); border-radius: 5px; display: flex; align-items: center; gap: 5px; background: transparent; color: var(--text-soft); cursor: pointer; font-size: 10px; transition: background-color .12s ease, border-color .12s ease, color .12s ease; } .settings-link:hover { background: var(--sidebar-hover); color: var(--text); } .settings-link:active { background: var(--color-ghost-selected); } +.settings-link-danger { white-space: nowrap; } +.settings-link-danger:hover:not(:disabled) { border-color: color-mix(in srgb, #c44 42%, var(--border)); color: #b33; } +.settings-link-danger:disabled { cursor: default; opacity: .45; } .confirmation-backdrop { align-items: center; padding-top: 0; } .confirmation-dialog { width: min(430px, calc(100vw - 40px)); display: grid; grid-template-columns: 34px 1fr; gap: 0 12px; padding: 22px; border: 1px solid var(--border-strong); border-radius: 12px; background: var(--color-bg-elevated); box-shadow: 0 20px 64px rgba(0,0,0,.22), 0 3px 12px rgba(0,0,0,.10); color: var(--text); animation: dialog-in .16s ease-out; } .confirmation-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; background: var(--color-ghost-selected); color: var(--text-soft); }