From ea48d03cee5a499ac82e23935455fd6191bb6205 Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 19 Apr 2026 14:30:42 +0100 Subject: [PATCH] =?UTF-8?q?agent:=20dogfood=20polish=202026/04/19=20?= =?UTF-8?q?=E2=80=94=20Linux=20native=20chrome=20+=20History=20redesign=20?= =?UTF-8?q?+=20mic=20picker=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second dogfood sprint. Headline fix: Linux now uses native KWin/Mutter decorations instead of fragile frameless `startResizeDragging`, which collapsed diagonal corner resize to a single axis and made drag feel laggy. macOS / Windows keep custom chrome via `useCustomChrome` gate. Other changes: - Cross-window preferences sync via `kon:preferences-changed` Tauri event — theme and font changes propagate live to float/viewer. - Hotkey recorder rewritten to use capture-phase document listener gated by $effect; button focus was unreliable in webkit2gtk. - History page redesigned for cognitive-load hygiene: title-first compact row, inline title input, Edit popout opening /viewer in edit mode, clipboard export as .md with YAML frontmatter, manual tag chips + + Add tag input, header tag filter (cap 7), global Starred filter, `tag:xyz` search syntax. - `deriveAutoTags` kept as empty hook for post-Task-7 LLM topic tags; research found all previous auto-tag chips redundant with row UI. - Viewer window adds edit mode with debounced-save textarea; native title renamed to "Kon - Transcription Editor". - Window minimums updated per GNOME HIG + WCAG reflow research: main 960x600, float 360x480, editor 560x520. - Microphone picker filters raw ALSA strings (hw:, plughw:, front:, sysdefault:, null) and dedupes by CARD=X. New `description` field on DeviceInfo reads /proc/asound/cards so Blue Yeti shows as "Blue Microphones" instead of the short "Microphones" card name. - GPU reporting fixed: get_runtime_capabilities now returns accelerators=[cpu,vulkan] and whisper.supports_gpu=true, matching the transcribe-rs whisper-vulkan feature linked unconditionally. - ResizeHandles kept for macOS/Windows frameless: 12px edges, 20px corners via CSS vars, pointerdown + setPointerCapture, corners above edges in z-order, rendered as sibling (not child) of the animated layout root so `position: fixed` is viewport-relative. - Dueling drag-region handlers removed — `data-tauri-drag-region` and manual `startDragging()` were stacked on the same elements; kept the manual handler which has the button/input early-return logic. See HANDOVER.md for the full session log and deferred items. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOVER-2026-04-18.md | 71 +++++++ HANDOVER.md | 114 ++++++---- crates/audio/src/capture.rs | 80 +++++++ src-tauri/src/commands/models.rs | 9 +- src-tauri/src/commands/windows.rs | 20 +- src-tauri/tauri.conf.json | 4 +- src-tauri/tauri.linux.conf.json | 17 ++ src/app.css | 5 + src/lib/components/HotkeyRecorder.svelte | 30 ++- src/lib/components/ResizeHandles.svelte | 141 +++++++++++++ src/lib/components/Titlebar.svelte | 7 +- src/lib/pages/HistoryPage.svelte | 255 +++++++++++++++++++---- src/lib/pages/SettingsPage.svelte | 66 +++++- src/lib/stores/page.svelte.js | 14 ++ src/lib/stores/preferences.svelte.js | 33 +++ src/lib/utils/frontmatter.js | 142 +++++++++++++ src/routes/+layout.svelte | 57 ++++- src/routes/float/+layout@.svelte | 34 ++- src/routes/float/+page.svelte | 17 +- src/routes/viewer/+layout@.svelte | 42 +++- src/routes/viewer/+page.svelte | 78 +++++-- 21 files changed, 1079 insertions(+), 157 deletions(-) create mode 100644 HANDOVER-2026-04-18.md create mode 100644 src-tauri/tauri.linux.conf.json create mode 100644 src/lib/components/ResizeHandles.svelte create mode 100644 src/lib/utils/frontmatter.js diff --git a/HANDOVER-2026-04-18.md b/HANDOVER-2026-04-18.md new file mode 100644 index 0000000..c24e82c --- /dev/null +++ b/HANDOVER-2026-04-18.md @@ -0,0 +1,71 @@ +--- +name: handover-2026-04-18 +type: reference +tags: [handover, session, kon] +description: Session handover — 2026/04/18 dogfooding sprint +--- + +# Kon Handover — 2026/04/18 + +## Current state + +Phase 1 brand migration and Phase 2 polish are both **complete and committed**. Today was the first dogfood attempt — Vulkan GPU build is in progress but not yet confirmed working. Three bugs were caught and fixed during the first launch attempt. + +## What's working + +- **18/18 automated validation checks pass** (Playwright, `python3 /tmp/kon_validation.py`) +- **Pre-warm fixed** — `tauri::async_runtime::spawn` instead of `tokio::spawn`; model loads in background before first dictation +- **Preferences infinite loop fixed** — `Object.assign` mutation instead of object reassignment; Svelte 5 module state now stable +- **DOM hydration fixed** — `applyToDOM` called on store init so `data-theme` is always set, even without Tauri webview injection +- **Vulkan feature flag committed** — `whisper-vulkan` in `crates/transcription/Cargo.toml` +- **`docs/dev-setup.md`** — authoritative dependency and launch reference + +## What's left + +### Immediate — Vulkan GPU build +Vulkan build was not yet confirmed. Three system packages needed before it will compile: + +```bash +sudo dnf install vulkan-headers vulkan-loader-devel glslc +``` + +Then launch: + +```bash +cd /home/jake/Documents/CORBEL-Projects/kon +LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev +``` + +Confirm GPU active in startup logs: +``` +whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 +``` + +### Manual validation (requires running app) +Three items from the validation checklist that need real Tauri runtime: +- [ ] Persistence test — set non-default zone/font, close, relaunch, verify zero flash +- [ ] Cross-window preferences — open float/viewer windows, check they hydrate correctly +- [ ] 90-second onboarding — fresh-model launch, first dictation under 90s + +### Pre-release (before any build beyond Jake's machine) +- [ ] Updater signing key — `tauri signer generate`, public key → `tauri.conf.json`, private key → CI secrets +- [ ] ggml dedup — plan at `docs/superpowers/plans/2026-04-18-kon-ggml-dedup.md`, Option A (system-ggml shared lib), execute at Phase 3 + +## Gotchas discovered today + +| Issue | Fix | +|---|---| +| `libclang` not on PATH | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` | +| `tokio::spawn` panics in Tauri `setup()` | Use `tauri::async_runtime::spawn` — Tokio runtime isn't live yet during setup | +| Svelte 5 `$effect` infinite loop on `updatePreferences` | Module-level `$state` must be mutated (`Object.assign`), never reassigned — stale references break loop guards | +| Duplicate theme sync `$effect` in both `+layout.svelte` and `SettingsPage.svelte` | Removed from SettingsPage — layout handles it | +| Vulkan build needs dev headers + shader compiler | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` | + +## Resume prompt + +``` +Picking up Kon dogfooding from the 2026/04/18 session. +HANDOVER is at HANDOVER.md in the project root. +First job: confirm Vulkan GPU build compiles and check startup logs for RTX 4070. +Then run the three manual validation items from the handover. +``` diff --git a/HANDOVER.md b/HANDOVER.md index c24e82c..5759210 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -1,71 +1,97 @@ --- -name: handover-2026-04-18 +name: handover-2026-04-19 type: reference tags: [handover, session, kon] -description: Session handover — 2026/04/18 dogfooding sprint +description: Session handover — 2026/04/19 dogfood polish + cross-platform window chrome --- -# Kon Handover — 2026/04/18 +# Kon Handover — 2026/04/19 -## Current state +Second dogfood sprint. Four phases: (1) fix bugs surfaced on first real use, (2) redesign History for cognitive-load hygiene, (3) resolve broken window resize/drag on Linux Wayland, (4) clean up microphone picker. -Phase 1 brand migration and Phase 2 polish are both **complete and committed**. Today was the first dogfood attempt — Vulkan GPU build is in progress but not yet confirmed working. Three bugs were caught and fixed during the first launch attempt. +## What shipped this session -## What's working +### Cross-window preferences sync +- `preferences.svelte.js` emits `kon:preferences-changed` Tauri event on update. +- Main / viewer / float layouts listen and call `applyExternalPreferences` without re-emit, so theme and font changes propagate live across sibling windows. +- Echo suppressed via source window label check. -- **18/18 automated validation checks pass** (Playwright, `python3 /tmp/kon_validation.py`) -- **Pre-warm fixed** — `tauri::async_runtime::spawn` instead of `tokio::spawn`; model loads in background before first dictation -- **Preferences infinite loop fixed** — `Object.assign` mutation instead of object reassignment; Svelte 5 module state now stable -- **DOM hydration fixed** — `applyToDOM` called on store init so `data-theme` is always set, even without Tauri webview injection -- **Vulkan feature flag committed** — `whisper-vulkan` in `crates/transcription/Cargo.toml` -- **`docs/dev-setup.md`** — authoritative dependency and launch reference +### Hotkey recorder +- Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee. +- Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels. -## What's left +### History page redesign (research-backed) +- Compact row now shows the **title** (or "Untitled"), not body-preview text — metadata already lives in the row columns (date, duration, source icon). +- Expanded row gets an inline title input (replaces the old Rename prompt modal). +- **Edit** button opens the viewer window in `edit` mode (editable textarea, debounced save to localStorage + storage-event sync back to main history). +- **Export .md** copies a full YAML-frontmatter markdown document to the clipboard — paste into Obsidian. +- **Tags**: `$lib/utils/frontmatter.js` exposes `deriveAutoTags` (currently returns `[]`), `buildFrontmatter`, `serialiseFrontmatter`, `buildMarkdown`. Manual tags stored as `item.manualTags`, rendered as removable chips in the expanded row with `+ add tag` input. +- Header tag chip bar (cap 7, click to filter, × to clear), plus `tag:xyz` search syntax. +- Global **Starred** filter toggle in the History header. +- Research memo found all five previous auto-tag families redundant with existing row UI — kept the derivation hook for the post-Task-7 `topic:*` content tag from kon-llm. +- Duplicate-transcript render fix: expanded `

` only if compact preview actually truncated. -### Immediate — Vulkan GPU build -Vulkan build was not yet confirmed. Three system packages needed before it will compile: +### Viewer / editor popout +- `/viewer` route now reads `kon_viewer_mode` from localStorage ("view" | "edit"). +- Edit mode renders a plain textarea bound to `item.text`; 400ms debounced save flushes on input, final flush on `onDestroy`. Segment-specific controls (Compact, Starred) hidden in edit mode. +- Native title: **"Kon - Transcription Editor"**. -```bash -sudo dnf install vulkan-headers vulkan-loader-devel glslc -``` +### Platform-aware window chrome (Linux fix) +**Root cause:** Tauri v2 frameless `decorations: false` on KDE Wayland + webkit2gtk does not honour diagonal corner resize (collapses `NorthEast` etc. to a single axis via GTK's `gtk_window_begin_resize_drag`), and `data-tauri-drag-region` adds noticeable drag latency. Setting `setPointerCapture` ahead of `startResizeDragging` does not help once the compositor has taken over the pointer grab. Verified via Context7 docs + Codex diagnosis — Linux frameless is a known-fragile path. -Then launch: +**Fix:** +- Linux uses **native KWin/Mutter decorations**. `src-tauri/tauri.linux.conf.json` overlays `decorations: true` + full main window config (title, sizes) — overlays **replace** the windows array, so every field must be present, not just the delta. `src-tauri/src/commands/windows.rs` uses `cfg!(target_os = "linux")` to set decorations per window. +- macOS / Windows keep custom chrome. `src/lib/utils/osInfo.js` `isLinux()` gates `` and `` via `useCustomChrome = $state(false)`; flips to `!isLinux()` after `loadOsInfo()` resolves. +- Dueling drag-region handlers removed across Titlebar, float page, viewer page — everywhere a manual `startDragging()` lives, the `data-tauri-drag-region` attribute was deleted (they're alternatives per Tauri docs, not combinable). +- `ResizeHandles` kept for macOS/Windows frameless: 12 px edges / 20 px corners via CSS vars (`--kon-resize-edge`, `--kon-resize-corner`), `pointerdown` + `setPointerCapture`, corners with explicit higher z-index. Handles rendered as siblings of the animated layout div so `position: fixed` is viewport-relative rather than captured by the transform containing block. -```bash -cd /home/jake/Documents/CORBEL-Projects/kon -LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev -``` +### Window minimum sizes (evidence-backed) +Research pass cited GNOME HIG (1024×600 desktop / 360×294 mobile floors), WCAG 2.2 SC 1.4.10 Reflow (320 CSS px), Raycast 750×474 as a reference for single-pane working width, and consistent A11y principle that nothing should clip in the default configuration. -Confirm GPU active in startup logs: -``` -whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 -``` +| Window | Was | Now | Rationale | +|---|---|---|---| +| Main | 1020×540 | **960×600** | Fits 210 px sidebar + ~750 px content; GNOME vertical floor. | +| Float | 400×400 | **360×480** | 360 = GNOME mobile floor; 480 fits pills + quick-add + sort + ~6 task rows without scroll. | +| Transcript editor | 450×500 | **560×520** | Exceeds WCAG reflow floor; ~60-70 char measure for editing. | -### Manual validation (requires running app) -Three items from the validation checklist that need real Tauri runtime: -- [ ] Persistence test — set non-default zone/font, close, relaunch, verify zero flash -- [ ] Cross-window preferences — open float/viewer windows, check they hydrate correctly -- [ ] 90-second onboarding — fresh-model launch, first dictation under 90s +### Microphone picker cleanup +- ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown. +- `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias. +- `crates/audio/src/capture.rs` reads `/proc/asound/cards` and populates a new `description` field on `DeviceInfo` with the card's full product string (e.g. "Blue Microphones" for Jake's Yeti). Frontend prefers description → CARD=X short name → raw name. -### Pre-release (before any build beyond Jake's machine) -- [ ] Updater signing key — `tauri signer generate`, public key → `tauri.conf.json`, private key → CI secrets -- [ ] ggml dedup — plan at `docs/superpowers/plans/2026-04-18-kon-ggml-dedup.md`, Option A (system-ggml shared lib), execute at Phase 3 +### GPU reporting +- `commands/models.rs::get_runtime_capabilities` was hardcoded to `accelerators: vec!["cpu"]` and `supports_gpu: false` for whisper. Updated to `["cpu", "vulkan"]` and whisper `supports_gpu: true`, reflecting that `crates/transcription/Cargo.toml` links transcribe-rs with the `whisper-vulkan` feature unconditionally. +- Settings now shows the Vulkan option instead of the "This build is CPU-only" notice. + +### Desktop shortcut +- `~/Desktop/Kon.desktop` launcher with the 128×128 icon, `Terminal=true` so logs are visible and Ctrl+C cleanly stops the run.sh wrapper. + +## What's deferred + +- **Transparent windows (`transparent: true`)** — Tauri issue #13270 reports this smooths drag/resize further on Linux, but it's moot now that Linux uses native decorations. +- **File-system export (.md save dialog)** — currently clipboard-only. Needs a Rust `write_text_file` command for plugin-less file writes. +- **Bulk select + bulk export** in History. +- **LLM-powered content tags** (`topic:*`, `intent:*`) — slots into Task 7 `kon-llm` stub once Phase 3 wires real llama-cpp-2. +- **Settings UX overhaul** — Jake flagged that current settings feel overwhelming. Proposed: bunch high-traffic settings, hide advanced behind a toggle. Brainstorm + plan deferred to a dedicated session. +- **Task 7 (MicroSteps end-to-end)** — storage + Tauri CRUD + kon-llm stub + frontend dual-write all landed in an earlier commit chain. The MicroSteps UI was written as the final task 7 step but not yet dogfooded against the stub LLM. Needs manual walkthrough. ## Gotchas discovered today | Issue | Fix | |---|---| -| `libclang` not on PATH | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` | -| `tokio::spawn` panics in Tauri `setup()` | Use `tauri::async_runtime::spawn` — Tokio runtime isn't live yet during setup | -| Svelte 5 `$effect` infinite loop on `updatePreferences` | Module-level `$state` must be mutated (`Object.assign`), never reassigned — stale references break loop guards | -| Duplicate theme sync `$effect` in both `+layout.svelte` and `SettingsPage.svelte` | Removed from SettingsPage — layout handles it | -| Vulkan build needs dev headers + shader compiler | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` | +| `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta | +| `data-tauri-drag-region` + manual `startDragging()` on the same node caused drag latency | Pick one — we use manual `startDragging` for the button/input early-return logic | +| Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path | +| `animate-float-enter` on the viewer/float layout root created a containing block that broke `position: fixed` on ResizeHandles children | Render ResizeHandles as a sibling of the animated div, not a descendant | +| Kon binary auto-respawned on file-save while a second run.sh was also launching → two visible instances sharing one Vite server | Do not script `./run.sh` while the user has already launched via the desktop icon; rely on HMR | +| `run.sh` leaves `"beforeDevCommand": ""` in tauri.conf.json if its cleanup trap is bypassed (e.g. SIGKILL) | Cleanup trap restores `"npm run dev"` on graceful exit; SIGTERM (not SIGKILL) is the right kill signal | +| `/proc/asound/cards` header lines have leading whitespace for 2-digit card ID alignment | Parser trims leading whitespace before checking for leading digit | -## Resume prompt +## How to resume ``` -Picking up Kon dogfooding from the 2026/04/18 session. +Picking up Kon dogfooding from 2026/04/19. HANDOVER is at HANDOVER.md in the project root. -First job: confirm Vulkan GPU build compiles and check startup logs for RTX 4070. -Then run the three manual validation items from the handover. +Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps +dogfood with kon-llm stub, (3) Settings UX brainstorm. ``` diff --git a/crates/audio/src/capture.rs b/crates/audio/src/capture.rs index 6d14b98..c4bcc0f 100644 --- a/crates/audio/src/capture.rs +++ b/crates/audio/src/capture.rs @@ -42,6 +42,11 @@ pub struct DeviceInfo { pub is_likely_monitor: bool, /// True if cpal reports this as the host's default input device. pub is_default: bool, + /// Human-readable product description, if known (Linux: from + /// `/proc/asound/cards`). Empty string when unavailable or on + /// platforms that don't expose one. + #[serde(default)] + pub description: String, } /// A non-fatal capture-time error emitted by the cpal stream callback after @@ -97,6 +102,12 @@ impl MicrophoneCapture { .input_devices() .map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?; + // Load ALSA card descriptions once per enumeration. These are the + // "real" product names (e.g. "Blue Microphones") that cpal's + // short card name (e.g. "Microphones") alone can't convey. Empty + // map on non-Linux or if the file is missing. + let card_descriptions = load_alsa_card_descriptions(); + let mut out = Vec::new(); for device in devices { let name = device.name().unwrap_or_else(|_| "".to_string()); @@ -106,12 +117,16 @@ impl MicrophoneCapture { }; let is_likely_monitor = is_monitor_name(&name); let is_default = !default_name.is_empty() && name == default_name; + let description = extract_card_id(&name) + .and_then(|card| card_descriptions.get(card).cloned()) + .unwrap_or_default(); out.push(DeviceInfo { name, sample_rate, channels, is_likely_monitor, is_default, + description, }); } Ok(out) @@ -252,6 +267,71 @@ fn is_monitor_name(name: &str) -> bool { || lower.contains("loopback") } +/// Pull the CARD= value from an ALSA device string. +/// +/// `sysdefault:CARD=Microphones` → `Some("Microphones")` +/// `hw:CARD=C920,DEV=0` → `Some("C920")` +/// `pipewire` / `default` → `None` +fn extract_card_id(name: &str) -> Option<&str> { + let rest = name.split("CARD=").nth(1)?; + Some(rest.split(|c: char| c == ',' || c == ';').next().unwrap_or(rest)) +} + +/// Read `/proc/asound/cards` and return a map from ALSA card short name +/// (e.g. "Microphones") to the richer product string (e.g. "Blue +/// Microphones"). Empty map on non-Linux or if the file is missing. +/// +/// Format of `/proc/asound/cards`: +/// ```text +/// 2 [Microphones ]: USB-Audio - Blue Microphones +/// Blue Microphones at usb-... +/// 3 [C920 ]: USB-Audio - HD Pro Webcam C920 +/// HD Pro Webcam C920 at usb-... +/// ``` +/// The bracket contains the short name that cpal reports; the text +/// after the colon on that same line is the description we want. The +/// next indented line is a longer location string we ignore. +fn load_alsa_card_descriptions() -> std::collections::HashMap { + use std::collections::HashMap; + let mut map = HashMap::new(); + #[cfg(target_os = "linux")] + { + let Ok(contents) = std::fs::read_to_string("/proc/asound/cards") else { + return map; + }; + for line in contents.lines() { + // Header lines start with an optional leading space plus a + // digit (the card ID, right-aligned to 2 chars for readable + // formatting). Continuation lines are indented beyond that. + let trimmed = line.trim_start(); + if !trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + continue; + } + let Some(open) = trimmed.find('[') else { continue }; + let Some(close) = trimmed[open..].find(']') else { continue }; + let short_name = trimmed[open + 1..open + close].trim().to_string(); + if short_name.is_empty() { + continue; + } + let after_bracket = &trimmed[open + close + 1..]; + let Some(colon) = after_bracket.find(':') else { continue }; + // Format: "USB-Audio - Blue Microphones" + // We keep everything after the " - " if present, otherwise + // the whole post-colon fragment. + let raw = after_bracket[colon + 1..].trim(); + let description = raw + .split(" - ") + .nth(1) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| raw.to_string()); + if !description.is_empty() { + map.insert(short_name, description); + } + } + } + map +} + /// Open the given device and validate it produces non-silent audio. /// If `require_audio` is false, accept any data (used for monitor fallback). fn open_and_validate( diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index b7ed101..d9000e0 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -243,9 +243,12 @@ pub fn get_runtime_capabilities( .map(|entry| model_capability(entry, ¶keet)) .collect(); + // Kon's desktop build links transcribe-rs with the `whisper-vulkan` + // feature unconditionally (see crates/transcription/Cargo.toml), so + // whisper.cpp boots with Vulkan backend on any machine with a Vulkan + // loader + ICD. Parakeet (ONNX) still runs on CPU. Reflect both. Ok(RuntimeCapabilities { - // Current desktop build ships CPU-only inference backends. - accelerators: vec!["cpu".into()], + accelerators: vec!["cpu".into(), "vulkan".into()], engines: vec![ EngineRuntimeCapabilities { id: "whisper".into(), @@ -254,7 +257,7 @@ pub fn get_runtime_capabilities( loaded_model_id: whisper .loaded_model_id() .map(|model_id| model_id.to_string()), - supports_gpu: false, + supports_gpu: true, models: whisper_models, }, EngineRuntimeCapabilities { diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 9c3a40e..949721d 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -15,6 +15,13 @@ pub async fn open_task_window( return Ok(()); } + // On Linux we use native KWin/Mutter decorations so resize and drag + // are handled by the compositor. Tauri's frameless path on Wayland + // doesn't honour diagonal resize reliably and Tauri's own drag + // region adds latency on webkit2gtk. macOS and Windows keep the + // custom frameless chrome drawn by the Titlebar component. + let use_native_decorations = cfg!(target_os = "linux"); + let mut builder = WebviewWindowBuilder::new( &app, "tasks-float", @@ -22,9 +29,9 @@ pub async fn open_task_window( ) .title("Kon Tasks") .inner_size(480.0, 520.0) - .min_inner_size(400.0, 400.0) + .min_inner_size(360.0, 480.0) .always_on_top(true) - .decorations(false) + .decorations(use_native_decorations) .resizable(true); // Inject preferences before Svelte mounts @@ -50,15 +57,18 @@ pub async fn open_viewer_window( return Ok(()); } + // See note in open_task_window for the Linux-vs-other platform split. + let use_native_decorations = cfg!(target_os = "linux"); + let mut builder = WebviewWindowBuilder::new( &app, "transcript-viewer", WebviewUrl::App("/viewer".into()), ) - .title("Kon - Viewer") + .title("Kon - Transcription Editor") .inner_size(600.0, 700.0) - .min_inner_size(450.0, 500.0) - .decorations(false) + .min_inner_size(560.0, 520.0) + .decorations(use_native_decorations) .resizable(true); // Inject preferences before Svelte mounts diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c25ab71..3800cc5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -15,8 +15,8 @@ "title": "Kon", "width": 1020, "height": 720, - "minWidth": 1020, - "minHeight": 540, + "minWidth": 960, + "minHeight": 600, "decorations": false, "resizable": true, "center": true diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..fa59fc4 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "Kon", + "width": 1020, + "height": 720, + "minWidth": 960, + "minHeight": 600, + "decorations": true, + "resizable": true, + "center": true + } + ] + } +} diff --git a/src/app.css b/src/app.css index 1cffa56..5db522b 100644 --- a/src/app.css +++ b/src/app.css @@ -86,6 +86,11 @@ /* Motion */ --duration-ui: 150ms; --duration-decorative: 300ms; + + /* Window resize hit zones — consumed by ResizeHandles.svelte. One source + of truth so every Kon window feels identical. */ + --kon-resize-edge: 12px; + --kon-resize-corner: 20px; } /* === Button Component Classes === */ diff --git a/src/lib/components/HotkeyRecorder.svelte b/src/lib/components/HotkeyRecorder.svelte index 5ff247b..bebd144 100644 --- a/src/lib/components/HotkeyRecorder.svelte +++ b/src/lib/components/HotkeyRecorder.svelte @@ -6,16 +6,17 @@ const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]); - function startRecording() { - recording = true; - captured = false; - } - function handleKeyDown(e) { + // Capture-phase listener; guard still belts-and-braces. if (!recording) return; e.preventDefault(); e.stopPropagation(); + if (e.key === "Escape") { + recording = false; + return; + } + // Wait for a non-modifier key if (modifierKeys.has(e.key)) return; @@ -39,8 +40,21 @@ setTimeout(() => { captured = false; }, 1500); } - function handleBlur() { - recording = false; + // Register the listener only while recording, at the capture phase so no + // descendant handler (or the parent layout's svelte:window keydown) can + // swallow the event first. Button-level onkeydown would require the + // button to hold keyboard focus after a click, which webkit2gtk on Linux + // does not guarantee. + $effect(() => { + if (!recording) return; + const handler = handleKeyDown; + document.addEventListener("keydown", handler, { capture: true }); + return () => document.removeEventListener("keydown", handler, { capture: true }); + }); + + function startRecording() { + recording = true; + captured = false; } let chips = $derived(settings.globalHotkey.split("+")); @@ -55,8 +69,6 @@ : 'bg-bg-input border-border hover:border-border'} border transition-all" onclick={startRecording} - onkeydown={handleKeyDown} - onblur={handleBlur} aria-label="Record hotkey" > {#if recording} diff --git a/src/lib/components/ResizeHandles.svelte b/src/lib/components/ResizeHandles.svelte new file mode 100644 index 0000000..404ec8f --- /dev/null +++ b/src/lib/components/ResizeHandles.svelte @@ -0,0 +1,141 @@ + + +{#if enabled} + +

startResize(e,"North")}>
+
startResize(e,"South")}>
+
startResize(e,"West")}>
+
startResize(e,"East")}>
+ + +
startResize(e,"NorthWest")}>
+
startResize(e,"NorthEast")}>
+
startResize(e,"SouthWest")}>
+
startResize(e,"SouthEast")}>
+{/if} + + diff --git a/src/lib/components/Titlebar.svelte b/src/lib/components/Titlebar.svelte index 465608c..c4fee77 100644 --- a/src/lib/components/Titlebar.svelte +++ b/src/lib/components/Titlebar.svelte @@ -22,6 +22,7 @@ function handleDragStart(e) { if (e.button !== 0) return; if (e.target.closest("button")) return; + try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {} getCurrentWindow().startDragging(); } @@ -38,20 +39,18 @@
{#if !compact}
{/if} -
+
diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 31d8e97..f0e34d0 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -4,6 +4,9 @@ import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js"; import { convertFileSrc } from "@tauri-apps/api/core"; + import { + deriveAutoTags, buildFrontmatter, buildMarkdown, normaliseTag, + } from "$lib/utils/frontmatter.js"; import { getPreferences } from "$lib/stores/preferences.svelte.js"; import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; @@ -12,12 +15,14 @@ import EmptyState from "$lib/components/EmptyState.svelte"; import { formatTime, formatDuration } from "$lib/utils/time.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; - import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown } from 'lucide-svelte'; + import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star } from 'lucide-svelte'; const prefs = getPreferences(); const COLLAPSED_ROW_MIN_HEIGHT = 54; const COLLAPSED_ROW_VERTICAL_PADDING = 24; const EXPANDED_BASE_HEIGHT = 92; + const EXPANDED_TITLE_INPUT_HEIGHT = 48; + const EXPANDED_TAGS_ROW_HEIGHT = 40; const AUDIO_PLAYER_HEIGHT = 54; const HISTORY_PREVIEW_LINES = 2; const HISTORY_DURATION_WIDTH = 48; @@ -31,6 +36,8 @@ const BUFFER_COUNT = 6; let searchQuery = $state(""); + let showStarredOnly = $state(false); + let activeTagFilter = $state(null); // null = all; string = tag value let expandedId = $state(null); let playingId = $state(null); let audioEl = $state(null); @@ -47,25 +54,70 @@ stopPlayback(); }); - let filtered = $derived( - searchQuery - ? history.filter((h) => { - const q = searchQuery.toLowerCase(); - return ( - (h.text && h.text.toLowerCase().includes(q)) || - (h.preview && h.preview.toLowerCase().includes(q)) || - (h.source && h.source.toLowerCase().includes(q)) || - (h.title && h.title.toLowerCase().includes(q)) - ); - }) - : history - ); + function itemHasStar(h) { + if (Array.isArray(h?.segments)) { + return h.segments.some((s) => s?.starred); + } + return Boolean(h?.starred); + } + + function itemAllTags(h) { + const auto = deriveAutoTags(h); + const manual = Array.isArray(h?.manualTags) ? h.manualTags : []; + return [...auto, ...manual]; + } + + function parseTagFilter(q) { + // Matches `tag:value` anywhere in the query; returns { tag, rest }. + const match = q.match(/(?:^|\s)tag:([^\s]+)/i); + if (!match) return { tag: null, rest: q }; + const rest = (q.slice(0, match.index) + " " + q.slice(match.index + match[0].length)).trim(); + return { tag: match[1].toLowerCase(), rest }; + } + + let searchParsed = $derived(parseTagFilter(searchQuery || "")); + + let allTags = $derived.by(() => { + const counts = new Map(); + for (const h of history) { + for (const tag of itemAllTags(h)) { + const t = tag.toLowerCase(); + counts.set(t, (counts.get(t) || 0) + 1); + } + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([tag, count]) => ({ tag, count })); + }); + + let filtered = $derived.by(() => { + let items = history; + if (showStarredOnly) items = items.filter(itemHasStar); + if (activeTagFilter) { + items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === activeTagFilter)); + } + if (searchParsed.tag) { + items = items.filter((h) => itemAllTags(h).some((t) => t.toLowerCase() === searchParsed.tag)); + } + const q = searchParsed.rest.trim().toLowerCase(); + if (q) { + items = items.filter((h) => ( + (h.text && h.text.toLowerCase().includes(q)) || + (h.preview && h.preview.toLowerCase().includes(q)) || + (h.source && h.source.toLowerCase().includes(q)) || + (h.title && h.title.toLowerCase().includes(q)) + )); + } + return items; + }); let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13)); let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13)); function compactPreviewText(item) { - return item.title || item.preview || item.text || ""; + // The compact row shows the title (or a placeholder). The transcript + // body lives in the expanded drawer so the two are visually distinct. + return item.title?.trim() || "Untitled"; } let compactPreviews = $derived.by(() => { @@ -123,10 +175,11 @@ ); let height = compactHeight; if (expandedId === item.id) { + height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT; const transcriptHeight = item.text && textWidth > 0 ? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height : historyLineHeight; - height += transcriptHeight + EXPANDED_BASE_HEIGHT; + height += transcriptHeight; if (item.audioPath && playingId === item.id) { height += AUDIO_PLAYER_HEIGHT; } @@ -192,27 +245,6 @@ } } - async function renameItem(item) { - const name = prompt("Name this transcript:", item.title || ""); - if (name === null) return; - - const trimmed = name.trim(); - item.title = trimmed; - item.preview = trimmed ? `${trimmed} — ${item.text.slice(0, 80)}` : item.text.slice(0, 120); - - // Persist via the dual-write helper. Updates SQLite + localStorage and - // surfaces a toast on failure (Day 4 of the upgrade plan, closes - // architecture-review.md §13). - try { - await renameHistoryEntry(item.id, { title: trimmed }); - } catch (err) { - toasts.warn( - "Rename did not persist", - "Your change is visible now but did not save. It may revert on next launch." - ); - } - } - function togglePlay(item) { if (playingId === item.id) { if (audioEl && !audioEl.paused) { @@ -276,9 +308,57 @@ async function openViewer(item) { try { localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_mode", "view"); await invoke("open_viewer_window"); } catch { localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_mode", "view"); + window.open("/viewer", "_blank", "width=600,height=700"); + } + } + + function handleAddTagKey(e, item) { + if (e.key !== "Enter" && e.key !== ",") return; + e.preventDefault(); + const raw = e.target.value || ""; + const next = normaliseTag(raw); + if (!next) return; + const existing = new Set((item.manualTags || []).map((t) => normaliseTag(t))); + if (existing.has(next)) { + e.target.value = ""; + return; + } + item.manualTags = [...(item.manualTags || []), next]; + saveHistory(); + e.target.value = ""; + } + + function removeManualTag(item, tag) { + const t = normaliseTag(tag); + item.manualTags = (item.manualTags || []).filter((x) => normaliseTag(x) !== t); + saveHistory(); + } + + async function exportMarkdown(item) { + const md = buildMarkdown(item); + try { + await navigator.clipboard.writeText(md); + } catch { + try { + await invoke("copy_to_clipboard", { text: md }); + } catch {} + } + toasts.info("Markdown copied to clipboard — paste into Obsidian or save as .md"); + } + + async function openEditor(item) { + try { + localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_mode", "edit"); + await invoke("open_viewer_window"); + } catch { + localStorage.setItem("kon_viewer_item", JSON.stringify(item)); + localStorage.setItem("kon_viewer_mode", "edit"); window.open("/viewer", "_blank", "width=600,height=700"); } } @@ -308,6 +388,16 @@

History

{history.length} saved
+ {#if history.length > 0} + {:else} + {#each allTags.slice(0, 7) as t (t.tag)} + + {/each} + {/if} +
+ {/if} +
@@ -456,6 +573,48 @@
{/if} + + { item.title = e.target.value; }} + onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})} + onclick={(e) => e.stopPropagation()} + data-no-transition + /> + + +
e.stopPropagation()} role="presentation"> + {#each deriveAutoTags(item) as t (t)} + {t} + {/each} + {#each (item.manualTags || []) as t (t)} + + {t} + + + {/each} + handleAddTagKey(e, item)} + data-no-transition + /> +
+

{item.text}

@@ -464,13 +623,23 @@ + onclick={(e) => { e.stopPropagation(); copyItem(item); }} + >Copy + + onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }} + title="Export this transcript as a Markdown file with YAML frontmatter" + >Export .md {#if item.audioPath && item.segments && item.segments.length > 0}
{#if audioDevicesError}

{audioDevicesError}

- {:else if audioDevices.length === 0} + {:else if visibleAudioDevices.length === 0}

No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.

{:else}

diff --git a/src/lib/stores/page.svelte.js b/src/lib/stores/page.svelte.js index e19196d..c1c02e9 100644 --- a/src/lib/stores/page.svelte.js +++ b/src/lib/stores/page.svelte.js @@ -99,6 +99,20 @@ function loadHistory() { export const history = $state(loadHistory()); +// Keep the in-memory history in sync with edits made by sibling windows +// (e.g. the viewer saving a cleaned-up transcript). Storage events fire +// only on *other* windows than the writer, so we won't re-enter our own +// writes. +if (typeof window !== "undefined") { + window.addEventListener("storage", (e) => { + if (e.key !== HISTORY_KEY || !e.newValue) return; + try { + const next = JSON.parse(e.newValue); + history.splice(0, history.length, ...next); + } catch {} + }); +} + export function saveHistory() { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); diff --git a/src/lib/stores/preferences.svelte.js b/src/lib/stores/preferences.svelte.js index 354cd40..817e18c 100644 --- a/src/lib/stores/preferences.svelte.js +++ b/src/lib/stores/preferences.svelte.js @@ -1,7 +1,27 @@ // src/lib/stores/preferences.svelte.js import { invoke } from '@tauri-apps/api/core'; +import { emit } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; import { toasts } from './toasts.svelte.js'; +export const PREFERENCES_CHANGED_EVENT = 'kon:preferences-changed'; + +function currentWindowLabel() { + try { + return getCurrentWindow().label; + } catch { + return null; + } +} + +function broadcastPreferences(prefs) { + const source = currentWindowLabel(); + if (source === null) return; + // Fire-and-forget — cross-window sync must never block the local apply path. + emit(PREFERENCES_CHANGED_EVENT, { source, prefs: JSON.parse(JSON.stringify(prefs)) }) + .catch(() => {}); +} + const DEFAULTS = { theme: 'dark', zone: 'default', @@ -117,12 +137,25 @@ export function updatePreferences(updates) { Object.assign(preferences, updates); applyToDOM(preferences); persistToSQLite(preferences); + broadcastPreferences(preferences); } export function updateAccessibility(updates) { Object.assign(preferences.accessibility, updates); applyToDOM(preferences); persistToSQLite(preferences); + broadcastPreferences(preferences); +} + +// Apply preferences received from another Tauri window. Mutates local state +// and DOM only — never persists or re-broadcasts, so there is no echo loop. +export function applyExternalPreferences(prefs) { + if (!prefs || typeof prefs !== 'object') return; + Object.assign(preferences, prefs); + if (prefs.accessibility) { + Object.assign(preferences.accessibility, prefs.accessibility); + } + applyToDOM(preferences); } // Re-resolve when OS preferences change diff --git a/src/lib/utils/frontmatter.js b/src/lib/utils/frontmatter.js new file mode 100644 index 0000000..13bf4ce --- /dev/null +++ b/src/lib/utils/frontmatter.js @@ -0,0 +1,142 @@ +// Transcript frontmatter + auto-tag derivation. +// +// A transcript's "frontmatter" is a flat object of metadata that can be +// exported as YAML for Obsidian or other Markdown consumers. Auto-tags are +// derived deterministically from existing fields (date, duration, source, +// text length) so they stay in sync without migration. +// +// Storage model: +// - Source of truth is the existing transcript fields (id, title, date, +// duration, source, text, segments). +// - Manual tags live on `item.manualTags: string[]`. +// - Auto-tags are never stored — derived on demand. + +const DURATION_BUCKETS = [ + { max: 60, tag: "duration:short" }, // < 1 minute + { max: 300, tag: "duration:medium" }, // < 5 minutes + { max: 1800, tag: "duration:long" }, // < 30 minutes + { max: Infinity, tag: "duration:very-long" }, +]; + +const WORD_BUCKETS = [ + { max: 50, tag: "words:short" }, + { max: 300, tag: "words:medium" }, + { max: 1500, tag: "words:long" }, + { max: Infinity, tag: "words:very-long" }, +]; + +function durationTag(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return DURATION_BUCKETS.find((b) => seconds < b.max)?.tag ?? null; +} + +function wordCountTag(text) { + if (!text || typeof text !== "string") return null; + const count = text.trim().split(/\s+/).filter(Boolean).length; + return WORD_BUCKETS.find((b) => count < b.max)?.tag ?? null; +} + +// Resolve time-of-day bucket from an ISO date or a legacy string like +// "19/04/2026, 11:37:23". Thresholds are fixed and local to the user's +// machine — hour 6-11 morning, 12-17 afternoon, 18-21 evening, else night. +function timeOfDayTag(dateStr) { + if (!dateStr) return null; + let ts = Date.parse(dateStr); + if (Number.isNaN(ts)) { + // Try DD/MM/YYYY, HH:MM:SS (UK local format used by Kon history rows). + const match = String(dateStr).match( + /(\d{1,2})\/(\d{1,2})\/(\d{4})[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?/, + ); + if (!match) return null; + const [, dd, mm, yyyy, hh, min, ss] = match; + const d = new Date( + Number(yyyy), Number(mm) - 1, Number(dd), + Number(hh), Number(min), Number(ss || 0), + ); + ts = d.getTime(); + } + const hour = new Date(ts).getHours(); + if (hour >= 6 && hour < 12) return "time:morning"; + if (hour >= 12 && hour < 18) return "time:afternoon"; + if (hour >= 18 && hour < 22) return "time:evening"; + return "time:night"; +} + +function sourceTag(source) { + if (!source) return null; + const s = String(source).toLowerCase(); + if (s.includes("file")) return "source:file"; + if (s.includes("live") || s.includes("mic")) return "source:live"; + return `source:${s.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")}`; +} + +// Returns tags to display as chips. Intentionally empty by default: the +// metadata these tags used to encode (duration, date, source, starred) is +// already shown elsewhere in the History row, so chips would duplicate +// information and add cognitive load without improving retrieval. The +// function is kept as a hook for one future AI-derived content tag +// (`topic:*`) once kon-llm wires up real llama-cpp-2 in Phase 3. +export function deriveAutoTags(_item) { + return []; +} + +export function normaliseTag(raw) { + return String(raw || "").trim().toLowerCase().replace(/\s+/g, "-"); +} + +// Build the flat frontmatter object that represents a transcript's metadata. +// Shown in the expanded History row and serialised when exporting to .md. +export function buildFrontmatter(item) { + if (!item) return {}; + const auto = deriveAutoTags(item); + const manual = Array.isArray(item.manualTags) ? item.manualTags : []; + const tags = Array.from(new Set([...auto, ...manual.map(normaliseTag)])).filter(Boolean); + const wordCount = item.text ? item.text.trim().split(/\s+/).filter(Boolean).length : 0; + return { + id: item.id, + title: item.title || null, + date: item.createdAt || item.date || null, + duration_s: Number.isFinite(item.duration) ? item.duration : null, + source: item.source || null, + word_count: wordCount, + tags, + }; +} + +// Escape a YAML scalar. Keeps things simple — quote if it contains any +// character that would otherwise need escaping in plain scalars. +function yamlScalar(value) { + if (value === null || value === undefined) return "null"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + const s = String(value); + if (s === "") return '""'; + if (/^[A-Za-z0-9._/:\- ]+$/.test(s) && !/^\s|\s$/.test(s)) return s; + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function serialiseFrontmatter(fm) { + const lines = ["---"]; + for (const [key, value] of Object.entries(fm)) { + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${key}: []`); + } else { + lines.push(`${key}:`); + for (const v of value) lines.push(` - ${yamlScalar(v)}`); + } + } else { + lines.push(`${key}: ${yamlScalar(value)}`); + } + } + lines.push("---"); + return lines.join("\n"); +} + +// Produce an Obsidian-flavoured markdown document for a transcript. +export function buildMarkdown(item) { + const fm = buildFrontmatter(item); + const header = serialiseFrontmatter(fm); + const title = fm.title || "Transcript"; + const body = item?.text || ""; + return `${header}\n\n# ${title}\n\n${body}\n`; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c8484d9..ee936d9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -6,10 +6,18 @@ import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte"; import ToastViewport from "$lib/components/ToastViewport.svelte"; + import ResizeHandles from "$lib/components/ResizeHandles.svelte"; import { hasTauriRuntime } from "$lib/utils/runtime.js"; - import { loadOsInfo } from "$lib/utils/osInfo.js"; + import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js"; import { page, settings, saveSettings } from "$lib/stores/page.svelte.js"; - import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { + getPreferences, + updatePreferences, + applyExternalPreferences, + PREFERENCES_CHANGED_EVENT, + } from "$lib/stores/preferences.svelte.js"; + import { getCurrentWindow } from "@tauri-apps/api/window"; + import { listen } from "@tauri-apps/api/event"; import { toasts } from "$lib/stores/toasts.svelte.js"; import { page as sveltePage } from "$app/stores"; @@ -19,6 +27,12 @@ const prefs = getPreferences(); const tauriRuntimeAvailable = hasTauriRuntime(); + // On Linux Kon uses native KWin/Mutter decorations (see + // src-tauri/tauri.linux.conf.json and windows.rs). Frameless custom + // chrome stays for macOS and Windows. Default to false so Linux users + // don't see a flash of custom titlebar before loadOsInfo resolves. + let useCustomChrome = $state(false); + // Detect secondary windows (float, viewer) — they use +layout@.svelte // but as a fallback, hide chrome if the URL matches @@ -158,6 +172,20 @@ } } + // Cross-window preference sync: apply updates broadcast by any other + // window (float, viewer) while skipping our own echoes. + let unlistenPrefs = null; + async function setupPreferencesSync() { + if (!tauriRuntimeAvailable) return; + let ownLabel = null; + try { ownLabel = getCurrentWindow().label; } catch {} + unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => { + const payload = event?.payload; + if (!payload || payload.source === ownLabel) return; + applyExternalPreferences(payload.prefs); + }); + } + // Capture global frontend errors and forward to the Rust error_log via // log_frontend_error. Best-effort: never let the error handler itself // throw, never crash the app over a logging failure. @@ -198,12 +226,19 @@ handleResize(); window.addEventListener("resize", handleResize); + // Cross-window preference sync (no-op outside Tauri). + setupPreferencesSync(); + // Diagnostics: capture every uncaught frontend error to error_log. installGlobalErrorCapture(); // OS detection: warm the cache so components can use modKeyLabel() / - // isMac() / isWayland() synchronously after first render. - loadOsInfo().catch(() => { /* fallback already populated */ }); + // isMac() / isWayland() synchronously after first render. We also + // use the result to decide whether to render the custom Titlebar + + // ResizeHandles (non-Linux) or rely on native decorations (Linux). + loadOsInfo() + .then(() => { useCustomChrome = !isLinux(); }) + .catch(() => { /* fallback already populated */ }); if (!tauriRuntimeAvailable) { hotkeyBackend = "unavailable"; @@ -250,6 +285,9 @@ if (unlistenEvdev) { unlistenEvdev(); } + if (unlistenPrefs) { + unlistenPrefs(); + } }); @@ -261,7 +299,9 @@ {@render children()} {:else}

- + {#if useCustomChrome} + + {/if}
{#if page.current !== "first-run"} @@ -282,3 +322,10 @@ can call toasts.error(...), toasts.success(...) etc and have it render in the bottom-right of the viewport. (Day 3 of the upgrade plan) --> + + +{#if useCustomChrome} + +{/if} diff --git a/src/routes/float/+layout@.svelte b/src/routes/float/+layout@.svelte index 55d0152..c58b6c6 100644 --- a/src/routes/float/+layout@.svelte +++ b/src/routes/float/+layout@.svelte @@ -4,12 +4,20 @@ import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { settings } from "$lib/stores/page.svelte.js"; - import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; + import { + getPreferences, + updatePreferences, + applyExternalPreferences, + PREFERENCES_CHANGED_EVENT, + } from "$lib/stores/preferences.svelte.js"; import Titlebar from "$lib/components/Titlebar.svelte"; + import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js"; let { children } = $props(); let glowing = $state(false); let unlistenFocus = null; + let unlistenPrefs = null; + let useCustomChrome = $state(false); const prefs = getPreferences(); @@ -34,6 +42,9 @@ } onMount(async () => { + loadOsInfo() + .then(() => { useCustomChrome = !isLinux(); }) + .catch(() => {}); try { unlistenFocus = await listen("task-window-focus", () => { glowing = true; @@ -43,10 +54,21 @@ if (input) input.focus(); }); } catch {} + + try { + let ownLabel = null; + try { ownLabel = getCurrentWindow().label; } catch {} + unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => { + const payload = event?.payload; + if (!payload || payload.source === ownLabel) return; + applyExternalPreferences(payload.prefs); + }); + } catch {} }); onDestroy(() => { if (unlistenFocus) unlistenFocus(); + if (unlistenPrefs) unlistenPrefs(); }); // Escape to close @@ -59,7 +81,11 @@ -
- - {@render children()} +
+ {#if useCustomChrome} + + {/if} +
+ {@render children()} +
diff --git a/src/routes/float/+page.svelte b/src/routes/float/+page.svelte index 1779d84..71c0ba2 100644 --- a/src/routes/float/+page.svelte +++ b/src/routes/float/+page.svelte @@ -60,6 +60,7 @@ if (e.button !== 0) return; if (e.target.closest("button")) return; if (e.target.closest("input")) return; + try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {} getCurrentWindow().startDragging(); } @@ -164,14 +165,13 @@
- + Kon - To-do -
+
+
{#if item} @@ -328,7 +353,8 @@
{/if} - + + {#if viewerMode !== "edit"}
@@ -362,10 +388,20 @@ title={showStarredOnly ? "Show all segments" : "Show starred only"} >Starred
+ {/if} - +
- {#if item.segments && item.segments.length > 0} + {#if viewerMode === "edit"} + + {:else if item.segments && item.segments.length > 0}
{#each visibleSegments as seg (seg._idx)}