agent: dogfood polish 2026/04/19 — Linux native chrome + History redesign + mic picker cleanup
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 14:30:42 +01:00
parent 8c9c9390d8
commit ea48d03cee
21 changed files with 1079 additions and 157 deletions

71
HANDOVER-2026-04-18.md Normal file
View File

@@ -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.
```

View File

@@ -1,71 +1,97 @@
--- ---
name: handover-2026-04-18 name: handover-2026-04-19
type: reference type: reference
tags: [handover, session, kon] 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`) ### Hotkey recorder
- **Pre-warm fixed** — `tauri::async_runtime::spawn` instead of `tokio::spawn`; model loads in background before first dictation - Root cause of "can't change hotkey": button-level `onkeydown` relied on post-click keyboard focus, which webkit2gtk on Linux does not guarantee.
- **Preferences infinite loop fixed** — `Object.assign` mutation instead of object reassignment; Svelte 5 module state now stable - Fix: `document.addEventListener("keydown", ..., { capture: true })` inside a `$effect` gated by `recording`. Beats any descendant handler. Escape now cancels.
- **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 ### 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 `<p>` only if compact preview actually truncated.
### Immediate — Vulkan GPU build ### Viewer / editor popout
Vulkan build was not yet confirmed. Three system packages needed before it will compile: - `/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 ### Platform-aware window chrome (Linux fix)
sudo dnf install vulkan-headers vulkan-loader-devel glslc **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 `<Titlebar>` and `<ResizeHandles>` 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 ### Window minimum sizes (evidence-backed)
cd /home/jake/Documents/CORBEL-Projects/kon 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.
LIBCLANG_PATH=/usr/lib64/llvm21/lib64 npm run tauri dev
```
Confirm GPU active in startup logs: | Window | Was | Now | Rationale |
``` |---|---|---|---|
whisper_backend_init_gpu: device 0: NVIDIA GeForce RTX 4070 | 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) ### Microphone picker cleanup
Three items from the validation checklist that need real Tauri runtime: - ALSA enumeration was leaking `hw:`, `plughw:`, `front:`, `sysdefault:`, `null` et al into the dropdown.
- [ ] Persistence test — set non-default zone/font, close, relaunch, verify zero flash - `SettingsPage.svelte` now renders only sentinel devices (`default`, `pipewire`, `pulse`) + one entry per unique sound card, keyed off the `sysdefault:CARD=X` alias.
- [ ] Cross-window preferences — open float/viewer windows, check they hydrate correctly - `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.
- [ ] 90-second onboarding — fresh-model launch, first dictation under 90s
### Pre-release (before any build beyond Jake's machine) ### GPU reporting
- [ ] Updater signing key — `tauri signer generate`, public key → `tauri.conf.json`, private key → CI secrets - `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.
- [ ] ggml dedup — plan at `docs/superpowers/plans/2026-04-18-kon-ggml-dedup.md`, Option A (system-ggml shared lib), execute at Phase 3 - 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 ## Gotchas discovered today
| Issue | Fix | | Issue | Fix |
|---|---| |---|---|
| `libclang` not on PATH | `set -Ux LIBCLANG_PATH /usr/lib64/llvm21/lib64` | | `tauri.linux.conf.json` stripped title and min sizes from main window | Overlay **replaces** the windows array — include every field, not just the delta |
| `tokio::spawn` panics in Tauri `setup()` | Use `tauri::async_runtime::spawn` — Tokio runtime isn't live yet during setup | | `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 |
| Svelte 5 `$effect` infinite loop on `updatePreferences` | Module-level `$state` must be mutated (`Object.assign`), never reassigned — stale references break loop guards | | Corner resize collapsed to single axis on KWin Wayland | Native decorations on Linux side-step the whole frameless path |
| Duplicate theme sync `$effect` in both `+layout.svelte` and `SettingsPage.svelte` | Removed from SettingsPage — layout handles it | | `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 |
| Vulkan build needs dev headers + shader compiler | `sudo dnf install vulkan-headers vulkan-loader-devel glslc` | | 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. HANDOVER is at HANDOVER.md in the project root.
First job: confirm Vulkan GPU build compiles and check startup logs for RTX 4070. Active priorities: (1) confirm resize/drag/mic cleanup, (2) Task 7 MicroSteps
Then run the three manual validation items from the handover. dogfood with kon-llm stub, (3) Settings UX brainstorm.
``` ```

View File

@@ -42,6 +42,11 @@ pub struct DeviceInfo {
pub is_likely_monitor: bool, pub is_likely_monitor: bool,
/// True if cpal reports this as the host's default input device. /// True if cpal reports this as the host's default input device.
pub is_default: bool, 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 /// A non-fatal capture-time error emitted by the cpal stream callback after
@@ -97,6 +102,12 @@ impl MicrophoneCapture {
.input_devices() .input_devices()
.map_err(|e| KonError::AudioCaptureFailed(format!("input_devices: {e}")))?; .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(); let mut out = Vec::new();
for device in devices { for device in devices {
let name = device.name().unwrap_or_else(|_| "<unnamed>".to_string()); let name = device.name().unwrap_or_else(|_| "<unnamed>".to_string());
@@ -106,12 +117,16 @@ impl MicrophoneCapture {
}; };
let is_likely_monitor = is_monitor_name(&name); let is_likely_monitor = is_monitor_name(&name);
let is_default = !default_name.is_empty() && name == default_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 { out.push(DeviceInfo {
name, name,
sample_rate, sample_rate,
channels, channels,
is_likely_monitor, is_likely_monitor,
is_default, is_default,
description,
}); });
} }
Ok(out) Ok(out)
@@ -252,6 +267,71 @@ fn is_monitor_name(name: &str) -> bool {
|| lower.contains("loopback") || 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<String, String> {
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. /// Open the given device and validate it produces non-silent audio.
/// If `require_audio` is false, accept any data (used for monitor fallback). /// If `require_audio` is false, accept any data (used for monitor fallback).
fn open_and_validate( fn open_and_validate(

View File

@@ -243,9 +243,12 @@ pub fn get_runtime_capabilities(
.map(|entry| model_capability(entry, &parakeet)) .map(|entry| model_capability(entry, &parakeet))
.collect(); .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 { Ok(RuntimeCapabilities {
// Current desktop build ships CPU-only inference backends. accelerators: vec!["cpu".into(), "vulkan".into()],
accelerators: vec!["cpu".into()],
engines: vec![ engines: vec![
EngineRuntimeCapabilities { EngineRuntimeCapabilities {
id: "whisper".into(), id: "whisper".into(),
@@ -254,7 +257,7 @@ pub fn get_runtime_capabilities(
loaded_model_id: whisper loaded_model_id: whisper
.loaded_model_id() .loaded_model_id()
.map(|model_id| model_id.to_string()), .map(|model_id| model_id.to_string()),
supports_gpu: false, supports_gpu: true,
models: whisper_models, models: whisper_models,
}, },
EngineRuntimeCapabilities { EngineRuntimeCapabilities {

View File

@@ -15,6 +15,13 @@ pub async fn open_task_window(
return Ok(()); 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( let mut builder = WebviewWindowBuilder::new(
&app, &app,
"tasks-float", "tasks-float",
@@ -22,9 +29,9 @@ pub async fn open_task_window(
) )
.title("Kon Tasks") .title("Kon Tasks")
.inner_size(480.0, 520.0) .inner_size(480.0, 520.0)
.min_inner_size(400.0, 400.0) .min_inner_size(360.0, 480.0)
.always_on_top(true) .always_on_top(true)
.decorations(false) .decorations(use_native_decorations)
.resizable(true); .resizable(true);
// Inject preferences before Svelte mounts // Inject preferences before Svelte mounts
@@ -50,15 +57,18 @@ pub async fn open_viewer_window(
return Ok(()); 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( let mut builder = WebviewWindowBuilder::new(
&app, &app,
"transcript-viewer", "transcript-viewer",
WebviewUrl::App("/viewer".into()), WebviewUrl::App("/viewer".into()),
) )
.title("Kon - Viewer") .title("Kon - Transcription Editor")
.inner_size(600.0, 700.0) .inner_size(600.0, 700.0)
.min_inner_size(450.0, 500.0) .min_inner_size(560.0, 520.0)
.decorations(false) .decorations(use_native_decorations)
.resizable(true); .resizable(true);
// Inject preferences before Svelte mounts // Inject preferences before Svelte mounts

View File

@@ -15,8 +15,8 @@
"title": "Kon", "title": "Kon",
"width": 1020, "width": 1020,
"height": 720, "height": 720,
"minWidth": 1020, "minWidth": 960,
"minHeight": 540, "minHeight": 600,
"decorations": false, "decorations": false,
"resizable": true, "resizable": true,
"center": true "center": true

View File

@@ -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
}
]
}
}

View File

@@ -86,6 +86,11 @@
/* Motion */ /* Motion */
--duration-ui: 150ms; --duration-ui: 150ms;
--duration-decorative: 300ms; --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 === */ /* === Button Component Classes === */

View File

@@ -6,16 +6,17 @@
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]); const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
function startRecording() {
recording = true;
captured = false;
}
function handleKeyDown(e) { function handleKeyDown(e) {
// Capture-phase listener; guard still belts-and-braces.
if (!recording) return; if (!recording) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.key === "Escape") {
recording = false;
return;
}
// Wait for a non-modifier key // Wait for a non-modifier key
if (modifierKeys.has(e.key)) return; if (modifierKeys.has(e.key)) return;
@@ -39,8 +40,21 @@
setTimeout(() => { captured = false; }, 1500); setTimeout(() => { captured = false; }, 1500);
} }
function handleBlur() { // Register the listener only while recording, at the capture phase so no
recording = false; // 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("+")); let chips = $derived(settings.globalHotkey.split("+"));
@@ -55,8 +69,6 @@
: 'bg-bg-input border-border hover:border-border'} : 'bg-bg-input border-border hover:border-border'}
border transition-all" border transition-all"
onclick={startRecording} onclick={startRecording}
onkeydown={handleKeyDown}
onblur={handleBlur}
aria-label="Record hotkey" aria-label="Record hotkey"
> >
{#if recording} {#if recording}

View File

@@ -0,0 +1,141 @@
<script>
// Invisible resize-handle overlays around each Kon window edge.
// Needed because Kon runs with `decorations: false`, so the WM
// provides no resize affordance on KDE/GNOME Wayland.
//
// Architecture: eight fixed-position divs are the click target for
// resize. Because the divs themselves are the event target (not any
// ancestor), Tauri's `data-tauri-drag-region` delegated handler walks
// the div's ancestors, finds no drag-region, and does not start a
// window move. This gives us a clean separation: resize wins on the
// edges, drag wins on the titlebar.
//
// Hit zone sizes come from CSS custom properties so every Kon window
// across the app stays identical. See `--kon-resize-edge` and
// `--kon-resize-corner` in app.css.
//
// Placement requirement: instances of this component MUST be mounted
// as a sibling of the animated/transformed layout root (not a child),
// or `position: fixed` becomes relative to that transformed ancestor
// instead of the viewport.
import { getCurrentWindow } from "@tauri-apps/api/window";
import { hasTauriRuntime } from "$lib/utils/runtime.js";
const enabled = hasTauriRuntime();
async function startResize(e, direction) {
if (!enabled) return;
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
// Lock the pointer to this handle BEFORE asking Wayland to begin a
// resize. Without capture, KWin's xdg_toplevel.resize grab arrives
// between mousedown synthesis and pointer movement, often losing the
// diagonal component and collapsing a corner drag to a single axis.
// pointerdown + setPointerCapture keeps the direction pinned.
try {
e.currentTarget?.setPointerCapture?.(e.pointerId);
} catch {}
try {
await getCurrentWindow().startResizeDragging(direction);
} catch {}
}
</script>
{#if enabled}
<!-- Edge strips. Inset by corner size so edge and corner zones don't overlap. -->
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
<!-- Corner hit zones, larger for easier diagonal targeting. -->
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
{/if}
<style>
.kon-rh {
position: fixed;
z-index: 2147483646;
background: transparent;
pointer-events: auto;
/* Prevent the browser from consuming the pointerdown for its own
defaults (text selection, drag scroll) before we capture it. */
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
/* Corners stack above edges so, at the 1-2 px of overlap on some
compositors, a corner click is never mistaken for an edge click. */
.kon-rh-nw,
.kon-rh-ne,
.kon-rh-sw,
.kon-rh-se {
z-index: 2147483647;
}
/* Edges */
.kon-rh-n {
top: 0;
left: var(--kon-resize-corner);
right: var(--kon-resize-corner);
height: var(--kon-resize-edge);
cursor: n-resize;
}
.kon-rh-s {
bottom: 0;
left: var(--kon-resize-corner);
right: var(--kon-resize-corner);
height: var(--kon-resize-edge);
cursor: s-resize;
}
.kon-rh-w {
top: var(--kon-resize-corner);
bottom: var(--kon-resize-corner);
left: 0;
width: var(--kon-resize-edge);
cursor: w-resize;
}
.kon-rh-e {
top: var(--kon-resize-corner);
bottom: var(--kon-resize-corner);
right: 0;
width: var(--kon-resize-edge);
cursor: e-resize;
}
/* Corners */
.kon-rh-nw {
top: 0;
left: 0;
width: var(--kon-resize-corner);
height: var(--kon-resize-corner);
cursor: nw-resize;
}
.kon-rh-ne {
top: 0;
right: 0;
width: var(--kon-resize-corner);
height: var(--kon-resize-corner);
cursor: ne-resize;
}
.kon-rh-sw {
bottom: 0;
left: 0;
width: var(--kon-resize-corner);
height: var(--kon-resize-corner);
cursor: sw-resize;
}
.kon-rh-se {
bottom: 0;
right: 0;
width: var(--kon-resize-corner);
height: var(--kon-resize-corner);
cursor: se-resize;
}
</style>

View File

@@ -22,6 +22,7 @@
function handleDragStart(e) { function handleDragStart(e) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if (e.target.closest("button")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
@@ -38,20 +39,18 @@
<div <div
class="flex items-center select-none bg-sidebar border-b border-border-subtle class="flex items-center select-none bg-sidebar border-b border-border-subtle
{compact ? 'h-[28px]' : 'h-[32px]'}" {compact ? 'h-[28px]' : 'h-[32px]'}"
onmousedown={handleDragStart} onpointerdown={handleDragStart}
data-tauri-drag-region
> >
{#if !compact} {#if !compact}
<!-- Left spacer: aligns with sidebar width --> <!-- Left spacer: aligns with sidebar width -->
<div <div
class="transition-all {settings.sidebarCollapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}" class="transition-all {settings.sidebarCollapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
data-tauri-drag-region
></div> ></div>
{/if} {/if}
<!-- Centre: drag area --> <!-- Centre: drag area -->
<div class="flex-1" data-tauri-drag-region></div> <div class="flex-1"></div>
<!-- Window controls --> <!-- Window controls -->
<div class="flex items-center h-full"> <div class="flex items-center h-full">

View File

@@ -4,6 +4,9 @@
import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js"; import { history, saveHistory, deleteFromHistory, renameHistoryEntry } from "$lib/stores/page.svelte.js";
import { toasts } from "$lib/stores/toasts.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js";
import { convertFileSrc } from "@tauri-apps/api/core"; 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 { getPreferences } from "$lib/stores/preferences.svelte.js";
import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js"; import { clampTextLines, measurePreWrap } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
@@ -12,12 +15,14 @@
import EmptyState from "$lib/components/EmptyState.svelte"; import EmptyState from "$lib/components/EmptyState.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js"; import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.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 prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54; const COLLAPSED_ROW_MIN_HEIGHT = 54;
const COLLAPSED_ROW_VERTICAL_PADDING = 24; const COLLAPSED_ROW_VERTICAL_PADDING = 24;
const EXPANDED_BASE_HEIGHT = 92; const EXPANDED_BASE_HEIGHT = 92;
const EXPANDED_TITLE_INPUT_HEIGHT = 48;
const EXPANDED_TAGS_ROW_HEIGHT = 40;
const AUDIO_PLAYER_HEIGHT = 54; const AUDIO_PLAYER_HEIGHT = 54;
const HISTORY_PREVIEW_LINES = 2; const HISTORY_PREVIEW_LINES = 2;
const HISTORY_DURATION_WIDTH = 48; const HISTORY_DURATION_WIDTH = 48;
@@ -31,6 +36,8 @@
const BUFFER_COUNT = 6; const BUFFER_COUNT = 6;
let searchQuery = $state(""); let searchQuery = $state("");
let showStarredOnly = $state(false);
let activeTagFilter = $state(null); // null = all; string = tag value
let expandedId = $state(null); let expandedId = $state(null);
let playingId = $state(null); let playingId = $state(null);
let audioEl = $state(null); let audioEl = $state(null);
@@ -47,25 +54,70 @@
stopPlayback(); stopPlayback();
}); });
let filtered = $derived( function itemHasStar(h) {
searchQuery if (Array.isArray(h?.segments)) {
? history.filter((h) => { return h.segments.some((s) => s?.starred);
const q = searchQuery.toLowerCase(); }
return ( 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.text && h.text.toLowerCase().includes(q)) ||
(h.preview && h.preview.toLowerCase().includes(q)) || (h.preview && h.preview.toLowerCase().includes(q)) ||
(h.source && h.source.toLowerCase().includes(q)) || (h.source && h.source.toLowerCase().includes(q)) ||
(h.title && h.title.toLowerCase().includes(q)) (h.title && h.title.toLowerCase().includes(q))
); ));
}) }
: history return items;
); });
let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13)); let historyTextFont = $derived(pretextFontShorthand(prefs.accessibility, 13));
let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13)); let historyLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 13));
function compactPreviewText(item) { 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(() => { let compactPreviews = $derived.by(() => {
@@ -123,10 +175,11 @@
); );
let height = compactHeight; let height = compactHeight;
if (expandedId === item.id) { if (expandedId === item.id) {
height += EXPANDED_BASE_HEIGHT + EXPANDED_TITLE_INPUT_HEIGHT + EXPANDED_TAGS_ROW_HEIGHT;
const transcriptHeight = item.text && textWidth > 0 const transcriptHeight = item.text && textWidth > 0
? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height ? measurePreWrap(item.text, historyTextFont, textWidth, historyLineHeight).height
: historyLineHeight; : historyLineHeight;
height += transcriptHeight + EXPANDED_BASE_HEIGHT; height += transcriptHeight;
if (item.audioPath && playingId === item.id) { if (item.audioPath && playingId === item.id) {
height += AUDIO_PLAYER_HEIGHT; 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) { function togglePlay(item) {
if (playingId === item.id) { if (playingId === item.id) {
if (audioEl && !audioEl.paused) { if (audioEl && !audioEl.paused) {
@@ -276,9 +308,57 @@
async function openViewer(item) { async function openViewer(item) {
try { try {
localStorage.setItem("kon_viewer_item", JSON.stringify(item)); localStorage.setItem("kon_viewer_item", JSON.stringify(item));
localStorage.setItem("kon_viewer_mode", "view");
await invoke("open_viewer_window"); await invoke("open_viewer_window");
} catch { } catch {
localStorage.setItem("kon_viewer_item", JSON.stringify(item)); 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"); window.open("/viewer", "_blank", "width=600,height=700");
} }
} }
@@ -308,6 +388,16 @@
<h2 class="font-display text-[26px] italic text-text">History</h2> <h2 class="font-display text-[26px] italic text-text">History</h2>
<span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span> <span class="text-[11px] text-text-tertiary mt-1">{history.length} saved</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button
class="inline-flex items-center gap-1.5 btn-md rounded-lg
{showStarredOnly ? 'text-accent bg-accent/10' : 'text-text-tertiary hover:text-text-secondary hover:bg-hover'}"
onclick={() => (showStarredOnly = !showStarredOnly)}
aria-pressed={showStarredOnly}
title={showStarredOnly ? "Showing starred only (click to show all)" : "Show starred only"}
>
<Star size={14} aria-hidden="true" />
<span class="text-[11px]">Starred</span>
</button>
{#if history.length > 0} {#if history.length > 0}
<button <button
class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover" class="btn-md rounded-lg text-text-tertiary hover:text-danger hover:bg-hover"
@@ -319,13 +409,13 @@
</div> </div>
<!-- Search --> <!-- Search -->
<div class="px-7 pb-4"> <div class="px-7 pb-3">
<Card> <Card>
<div class="flex items-center gap-3 px-4 py-2.5"> <div class="flex items-center gap-3 px-4 py-2.5">
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" /> <Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
<input <input
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none" class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
placeholder="Search all transcripts..." placeholder="Search all transcripts... (try tag:meetings)"
bind:value={searchQuery} bind:value={searchQuery}
data-no-transition data-no-transition
/> />
@@ -340,6 +430,33 @@
</Card> </Card>
</div> </div>
<!-- Tag chip filter bar -->
{#if allTags.length > 0}
<div class="px-7 pb-3 flex items-center gap-1.5 flex-wrap">
<span class="text-[10px] uppercase tracking-wider text-text-tertiary mr-1">Tags</span>
{#if activeTagFilter}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-accent text-[10px] text-accent bg-accent/10"
onclick={() => (activeTagFilter = null)}
title="Clear tag filter"
>
{activeTagFilter}
<span class="text-[12px] leading-none">×</span>
</button>
{:else}
{#each allTags.slice(0, 7) as t (t.tag)}
<button
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-subtle text-[10px] text-text-secondary hover:border-accent hover:text-accent"
onclick={() => (activeTagFilter = t.tag)}
>
{t.tag}
<span class="text-[9px] text-text-tertiary">{t.count}</span>
</button>
{/each}
{/if}
</div>
{/if}
<!-- History list --> <!-- History list -->
<div class="flex-1 px-7 pb-4 min-h-0"> <div class="flex-1 px-7 pb-4 min-h-0">
<Card classes="h-full flex flex-col overflow-hidden"> <Card classes="h-full flex flex-col overflow-hidden">
@@ -456,6 +573,48 @@
</div> </div>
{/if} {/if}
<!-- Inline title input -->
<input
type="text"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent mb-3"
placeholder="Name this transcript..."
value={item.title || ""}
oninput={(e) => { item.title = e.target.value; }}
onblur={() => renameHistoryEntry(item.id, { title: (item.title || "").trim() }).catch(() => {})}
onclick={(e) => e.stopPropagation()}
data-no-transition
/>
<!-- Tags: auto + manual -->
<div class="flex items-center gap-1.5 flex-wrap mb-3" onclick={(e) => e.stopPropagation()} role="presentation">
{#each deriveAutoTags(item) as t (t)}
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] bg-bg-input text-text-tertiary border border-border-subtle"
title="Auto-generated tag"
>{t}</span>
{/each}
{#each (item.manualTags || []) as t (t)}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] bg-accent/10 text-accent border border-accent/30">
{t}
<button
class="text-[12px] leading-none hover:text-danger"
onclick={() => removeManualTag(item, t)}
title="Remove tag"
aria-label="Remove tag {t}"
>×</button>
</span>
{/each}
<input
type="text"
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[110px]"
placeholder="+ add tag"
onkeydown={(e) => handleAddTagKey(e, item)}
data-no-transition
/>
</div>
<!-- Full transcript --> <!-- Full transcript -->
<p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p> <p class="text-[13px] text-text whitespace-pre-wrap mb-4" style="line-height: {historyLineHeight}px">{item.text}</p>
@@ -464,13 +623,23 @@
<button <button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text" class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); renameItem(item); }} onclick={(e) => { e.stopPropagation(); copyItem(item); }}
>Rename</button> >Copy</button>
<button
class="inline-flex items-center gap-1.5 text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); openEditor(item); }}
title="Open transcript in a popout editor"
>
Edit
<ExternalLink size={11} aria-hidden="true" />
</button>
<button <button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text" class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-text-secondary hover:text-text"
style="transition-duration: var(--duration-ui)" style="transition-duration: var(--duration-ui)"
onclick={(e) => { e.stopPropagation(); copyItem(item); }} onclick={(e) => { e.stopPropagation(); exportMarkdown(item); }}
>Copy</button> title="Export this transcript as a Markdown file with YAML frontmatter"
>Export .md</button>
{#if item.audioPath && item.segments && item.segments.length > 0} {#if item.audioPath && item.segments && item.segments.length > 0}
<button <button
class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover" class="text-[11px] px-3 py-1.5 rounded-lg bg-hover text-accent hover:text-accent-hover"

View File

@@ -34,6 +34,64 @@
let audioDevices = $state([]); let audioDevices = $state([]);
let audioDevicesError = $state(null); let audioDevicesError = $state(null);
// ALSA enumeration leaks raw device strings (hw:, plughw:, front:,
// sysdefault:, back:, surround:, iec958:, dmix:, usbstream:, plus a
// bogus "null" device). These are kernel-level aliases — no user
// needs to pick between "hw:CARD=2,DEV=0" and "plughw:CARD=2,DEV=0".
//
// The strategy: keep a small set of well-known sentinel devices
// (default/pipewire/pulse), then pull a single entry per unique
// sound card by parsing CARD=X from the sysdefault: alias. That
// gives us "Microphones" / "C920" / "Generic" as friendly labels
// while mapping them to a reliable ALSA path.
const SENTINEL_DEVICES = new Set(["default", "pipewire", "pulse"]);
function parseCardName(name) {
const match = String(name || "").match(/CARD=([^,]+)/);
return match ? match[1] : null;
}
function buildVisibleDevices(devices) {
const out = [];
const seenCards = new Set();
for (const dev of devices) {
const name = dev?.name || "";
if (!name || name === "null") continue;
if (SENTINEL_DEVICES.has(name)) {
out.push(dev);
continue;
}
if (name.startsWith("sysdefault:CARD=")) {
const card = parseCardName(name);
if (card && !seenCards.has(card)) {
seenCards.add(card);
out.push(dev);
}
}
// Everything else (hw:, plughw:, front:, dmix:, etc.) is silently
// dropped — cpal will resolve the sysdefault:CARD= form fine.
}
return out;
}
function friendlyLabel(dev) {
const name = dev?.name || "";
if (name === "default") return "System default";
if (name === "pipewire") return "PipeWire";
if (name === "pulse") return "PulseAudio";
// Prefer the rich product description from /proc/asound/cards
// (e.g. "Blue Microphones" for the Yeti). Falls back to the raw
// CARD=X short name if we couldn't load descriptions.
const desc = (dev?.description || "").trim();
if (desc) return desc;
const card = parseCardName(name);
if (card) return card;
return name;
}
let visibleAudioDevices = $derived(buildVisibleDevices(audioDevices));
async function refreshAudioDevices() { async function refreshAudioDevices() {
audioDevicesError = null; audioDevicesError = null;
try { try {
@@ -448,11 +506,9 @@
onfocus={refreshAudioDevices} onfocus={refreshAudioDevices}
> >
<option value="">Auto (recommended) — let Kon pick the working mic</option> <option value="">Auto (recommended) — let Kon pick the working mic</option>
{#each audioDevices as dev} {#each visibleAudioDevices as dev}
<option value={dev.name} disabled={dev.is_likely_monitor}> <option value={dev.name} disabled={dev.is_likely_monitor}>
{dev.name} {friendlyLabel(dev)}{#if dev.is_default && dev.name !== "default"} (system default){/if}{#if dev.is_likely_monitor} — speaker monitor, skip{/if}
{dev.is_default ? " (system default)" : ""}
{dev.is_likely_monitor ? " — speaker monitor, skip" : ""}
</option> </option>
{/each} {/each}
</select> </select>
@@ -464,7 +520,7 @@
</div> </div>
{#if audioDevicesError} {#if audioDevicesError}
<p class="text-[11px] text-error mt-2">{audioDevicesError}</p> <p class="text-[11px] text-error mt-2">{audioDevicesError}</p>
{:else if audioDevices.length === 0} {:else if visibleAudioDevices.length === 0}
<p class="text-[11px] text-text-tertiary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p> <p class="text-[11px] text-text-tertiary mt-2">No input devices detected. Check that a microphone is connected and PulseAudio/PipeWire is running.</p>
{:else} {:else}
<p class="text-[11px] text-text-tertiary mt-2"> <p class="text-[11px] text-text-tertiary mt-2">

View File

@@ -99,6 +99,20 @@ function loadHistory() {
export const history = $state(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() { export function saveHistory() {
try { try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); localStorage.setItem(HISTORY_KEY, JSON.stringify(history));

View File

@@ -1,7 +1,27 @@
// src/lib/stores/preferences.svelte.js // src/lib/stores/preferences.svelte.js
import { invoke } from '@tauri-apps/api/core'; 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'; 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 = { const DEFAULTS = {
theme: 'dark', theme: 'dark',
zone: 'default', zone: 'default',
@@ -117,12 +137,25 @@ export function updatePreferences(updates) {
Object.assign(preferences, updates); Object.assign(preferences, updates);
applyToDOM(preferences); applyToDOM(preferences);
persistToSQLite(preferences); persistToSQLite(preferences);
broadcastPreferences(preferences);
} }
export function updateAccessibility(updates) { export function updateAccessibility(updates) {
Object.assign(preferences.accessibility, updates); Object.assign(preferences.accessibility, updates);
applyToDOM(preferences); applyToDOM(preferences);
persistToSQLite(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 // Re-resolve when OS preferences change

View File

@@ -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`;
}

View File

@@ -6,10 +6,18 @@
import TaskSidebar from "$lib/components/TaskSidebar.svelte"; import TaskSidebar from "$lib/components/TaskSidebar.svelte";
import Titlebar from "$lib/components/Titlebar.svelte"; import Titlebar from "$lib/components/Titlebar.svelte";
import ToastViewport from "$lib/components/ToastViewport.svelte"; import ToastViewport from "$lib/components/ToastViewport.svelte";
import ResizeHandles from "$lib/components/ResizeHandles.svelte";
import { hasTauriRuntime } from "$lib/utils/runtime.js"; 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 { 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 { toasts } from "$lib/stores/toasts.svelte.js";
import { page as sveltePage } from "$app/stores"; import { page as sveltePage } from "$app/stores";
@@ -19,6 +27,12 @@
const prefs = getPreferences(); const prefs = getPreferences();
const tauriRuntimeAvailable = hasTauriRuntime(); 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 // Detect secondary windows (float, viewer) — they use +layout@.svelte
// but as a fallback, hide chrome if the URL matches // 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 // Capture global frontend errors and forward to the Rust error_log via
// log_frontend_error. Best-effort: never let the error handler itself // log_frontend_error. Best-effort: never let the error handler itself
// throw, never crash the app over a logging failure. // throw, never crash the app over a logging failure.
@@ -198,12 +226,19 @@
handleResize(); handleResize();
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// Cross-window preference sync (no-op outside Tauri).
setupPreferencesSync();
// Diagnostics: capture every uncaught frontend error to error_log. // Diagnostics: capture every uncaught frontend error to error_log.
installGlobalErrorCapture(); installGlobalErrorCapture();
// OS detection: warm the cache so components can use modKeyLabel() / // OS detection: warm the cache so components can use modKeyLabel() /
// isMac() / isWayland() synchronously after first render. // isMac() / isWayland() synchronously after first render. We also
loadOsInfo().catch(() => { /* fallback already populated */ }); // 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) { if (!tauriRuntimeAvailable) {
hotkeyBackend = "unavailable"; hotkeyBackend = "unavailable";
@@ -250,6 +285,9 @@
if (unlistenEvdev) { if (unlistenEvdev) {
unlistenEvdev(); unlistenEvdev();
} }
if (unlistenPrefs) {
unlistenPrefs();
}
}); });
</script> </script>
@@ -261,7 +299,9 @@
{@render children()} {@render children()}
{:else} {:else}
<div class="flex flex-col h-screen w-screen overflow-hidden grain"> <div class="flex flex-col h-screen w-screen overflow-hidden grain">
{#if useCustomChrome}
<Titlebar /> <Titlebar />
{/if}
<div class="flex flex-1 min-h-0 relative"> <div class="flex flex-1 min-h-0 relative">
{#if page.current !== "first-run"} {#if page.current !== "first-run"}
<Sidebar /> <Sidebar />
@@ -282,3 +322,10 @@
can call toasts.error(...), toasts.success(...) etc and have it render can call toasts.error(...), toasts.success(...) etc and have it render
in the bottom-right of the viewport. (Day 3 of the upgrade plan) --> in the bottom-right of the viewport. (Day 3 of the upgrade plan) -->
<ToastViewport /> <ToastViewport />
<!-- Invisible resize margins for frameless (macOS/Windows). On Linux we
use native decorations, so ResizeHandles would compete with the
compositor's own resize and is suppressed. -->
{#if useCustomChrome}
<ResizeHandles />
{/if}

View File

@@ -4,12 +4,20 @@
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { settings } from "$lib/stores/page.svelte.js"; 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 Titlebar from "$lib/components/Titlebar.svelte";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
let { children } = $props(); let { children } = $props();
let glowing = $state(false); let glowing = $state(false);
let unlistenFocus = null; let unlistenFocus = null;
let unlistenPrefs = null;
let useCustomChrome = $state(false);
const prefs = getPreferences(); const prefs = getPreferences();
@@ -34,6 +42,9 @@
} }
onMount(async () => { onMount(async () => {
loadOsInfo()
.then(() => { useCustomChrome = !isLinux(); })
.catch(() => {});
try { try {
unlistenFocus = await listen("task-window-focus", () => { unlistenFocus = await listen("task-window-focus", () => {
glowing = true; glowing = true;
@@ -43,10 +54,21 @@
if (input) input.focus(); if (input) input.focus();
}); });
} catch {} } 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(() => { onDestroy(() => {
if (unlistenFocus) unlistenFocus(); if (unlistenFocus) unlistenFocus();
if (unlistenPrefs) unlistenPrefs();
}); });
// Escape to close // Escape to close
@@ -59,7 +81,11 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter {glowing ? 'animate-glow-pulse' : ''}"> <div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col {glowing ? 'animate-glow-pulse' : ''}">
{#if useCustomChrome}
<Titlebar compact /> <Titlebar compact />
{/if}
<div class="flex-1 min-h-0 overflow-hidden">
{@render children()} {@render children()}
</div> </div>
</div>

View File

@@ -60,6 +60,7 @@
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if (e.target.closest("button")) return;
if (e.target.closest("input")) return; if (e.target.closest("input")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
@@ -164,14 +165,13 @@
<!-- Drag handle with title --> <!-- Drag handle with title -->
<div <div
class="flex items-center h-[36px] bg-bg-elevated select-none px-3" class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
onmousedown={handleDragStart} onpointerdown={handleDragStart}
data-tauri-drag-region
> >
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region> <span class="text-[12px] font-medium text-text-secondary tracking-wide">
Kon - To-do Kon - To-do
</span> </span>
<div class="flex-1" data-tauri-drag-region></div> <div class="flex-1"></div>
<!-- Pin button --> <!-- Pin button -->
<button <button
@@ -206,7 +206,9 @@
<!-- Single-column content --> <!-- Single-column content -->
<div class="flex flex-col flex-1 min-h-0"> <div class="flex flex-col flex-1 min-h-0">
<!-- List selector pills --> <!-- List selector pills -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-border-subtle overflow-x-auto"> <!-- Outer row does not scroll, so the sort dropdown below is not clipped. -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-border-subtle">
<div class="flex items-center gap-1 overflow-x-auto flex-1 min-w-0">
{#each builtInLists as list (list.id)} {#each builtInLists as list (list.id)}
<button <button
class="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] whitespace-nowrap flex-shrink-0 class="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[11px] whitespace-nowrap flex-shrink-0
@@ -336,9 +338,10 @@
onclick={(e) => { e.stopPropagation(); showNewList = true; }} onclick={(e) => { e.stopPropagation(); showNewList = true; }}
>+</button> >+</button>
{/if} {/if}
</div>
<!-- Sort --> <!-- Sort — sibling of the scroll container so its dropdown is not clipped -->
<div class="relative flex-shrink-0 ml-auto"> <div class="relative flex-shrink-0 pl-1">
<button <button
class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary" class="px-1.5 py-1 rounded text-[10px] text-text-tertiary hover:text-text-secondary"
onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }} onclick={(e) => { e.stopPropagation(); showSortMenu = !showSortMenu; }}

View File

@@ -1,12 +1,21 @@
<script> <script>
import "../../app.css"; import "../../app.css";
import { onDestroy } from "svelte"; import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { listen } from "@tauri-apps/api/event";
import { settings } from "$lib/stores/page.svelte.js"; 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 Titlebar from "$lib/components/Titlebar.svelte";
import { loadOsInfo, isLinux } from "$lib/utils/osInfo.js";
let { children } = $props(); let { children } = $props();
let unlistenPrefs = null;
let useCustomChrome = $state(false);
const prefs = getPreferences(); const prefs = getPreferences();
@@ -28,6 +37,25 @@
}); });
} }
onMount(async () => {
loadOsInfo()
.then(() => { useCustomChrome = !isLinux(); })
.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 (unlistenPrefs) unlistenPrefs();
});
// Escape to close // Escape to close
function handleKeydown(e) { function handleKeydown(e) {
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -38,7 +66,11 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<div class="h-screen w-screen overflow-hidden grain rounded-lg border border-border shadow-xl animate-float-enter"> <div class="h-screen w-screen overflow-hidden grain border border-border shadow-xl animate-float-enter flex flex-col">
{#if useCustomChrome}
<Titlebar compact /> <Titlebar compact />
{/if}
<div class="flex-1 min-h-0 overflow-hidden">
{@render children()} {@render children()}
</div> </div>
</div>

View File

@@ -19,6 +19,10 @@
let showStarredOnly = $state(false); let showStarredOnly = $state(false);
let animFrameId = null; let animFrameId = null;
let segmentRefs = []; let segmentRefs = [];
let viewerMode = $state("view"); // "view" | "edit"
let textDraft = $state("");
let textDirty = $state(false);
let textSaveTimer = null;
// Load item data from localStorage (set by HistoryPage before opening this window) // Load item data from localStorage (set by HistoryPage before opening this window)
@@ -27,6 +31,7 @@
const raw = localStorage.getItem("kon_viewer_item"); const raw = localStorage.getItem("kon_viewer_item");
if (raw) { if (raw) {
item = JSON.parse(raw); item = JSON.parse(raw);
textDraft = item?.text || "";
if (item.audioPath) { if (item.audioPath) {
const src = convertFileSrc(item.audioPath); const src = convertFileSrc(item.audioPath);
const audio = new Audio(src); const audio = new Audio(src);
@@ -36,6 +41,8 @@
audioEl = audio; audioEl = audio;
} }
} }
const mode = localStorage.getItem("kon_viewer_mode");
if (mode === "edit" || mode === "view") viewerMode = mode;
} catch {} } catch {}
// Listen for new items via storage events // Listen for new items via storage events
@@ -46,6 +53,12 @@
if (audioEl) audioEl.pause(); if (audioEl) audioEl.pause();
cancelAnimationFrame(animFrameId); cancelAnimationFrame(animFrameId);
window.removeEventListener("storage", handleStorageChange); window.removeEventListener("storage", handleStorageChange);
// Flush any pending text edit before the window tears down.
if (textSaveTimer) {
clearTimeout(textSaveTimer);
textSaveTimer = null;
}
if (textDirty) commitTextEdit();
}); });
function handleStorageChange(e) { function handleStorageChange(e) {
@@ -218,6 +231,29 @@
} catch {} } catch {}
} }
function scheduleTextSave() {
textDirty = true;
clearTimeout(textSaveTimer);
textSaveTimer = setTimeout(() => {
commitTextEdit();
}, 400);
}
function commitTextEdit() {
if (!item) return;
const next = textDraft;
if (next === item.text) {
textDirty = false;
return;
}
item.text = next;
// The compact preview in History is derived from `preview`; keep it
// roughly in sync so the list does not show stale copy after an edit.
item.preview = next.slice(0, 120);
saveItemToHistory();
textDirty = false;
}
// Filtered segments (starred filter + search) // Filtered segments (starred filter + search)
let visibleSegments = $derived.by(() => { let visibleSegments = $derived.by(() => {
if (!item?.segments) return []; if (!item?.segments) return [];
@@ -228,39 +264,28 @@
return segs; return segs;
}); });
// Window drag // Window drag — pointerdown + setPointerCapture avoids the mousedown
// latency that makes KWin's initial grab feel draggy.
function handleDragStart(e) { function handleDragStart(e) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest("button")) return; if (e.target.closest("button")) return;
if (e.target.closest("input")) return; if (e.target.closest("input")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging(); getCurrentWindow().startDragging();
} }
function closeWindow() {
getCurrentWindow().close();
}
</script> </script>
<div class="flex flex-col h-full bg-bg"> <div class="flex flex-col h-full bg-bg">
<!-- Drag handle --> <!-- Drag handle -->
<div <div
class="flex items-center h-[36px] bg-bg-elevated select-none px-3" class="flex items-center h-[36px] bg-bg-elevated select-none px-3"
onmousedown={handleDragStart} onpointerdown={handleDragStart}
data-tauri-drag-region
> >
<span class="text-[12px] font-medium text-text-secondary tracking-wide" data-tauri-drag-region> <span class="text-[12px] font-medium text-text-secondary tracking-wide">
Kon - Viewer Kon - {viewerMode === "edit" ? "Editor" : "Viewer"}
</span> </span>
<div class="flex-1" data-tauri-drag-region></div> <div class="flex-1"></div>
<button
class="w-7 h-7 flex items-center justify-center text-text-tertiary hover:text-danger rounded-md hover:bg-hover"
onclick={closeWindow}
aria-label="Close"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div> </div>
{#if item} {#if item}
@@ -328,7 +353,8 @@
</div> </div>
{/if} {/if}
<!-- Search bar + view controls --> <!-- Search bar + view controls (segment-specific; hidden in edit mode) -->
{#if viewerMode !== "edit"}
<div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle"> <div class="flex items-center gap-2 px-5 py-2 border-b border-border-subtle">
<div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1"> <div class="flex items-center gap-2 bg-bg-input border border-border rounded-lg px-3 py-1.5 focus-within:border-accent flex-1">
<svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-3.5 h-3.5 text-text-tertiary flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -362,10 +388,20 @@
title={showStarredOnly ? "Show all segments" : "Show starred only"} title={showStarredOnly ? "Show all segments" : "Show starred only"}
>Starred</button> >Starred</button>
</div> </div>
{/if}
<!-- Transcript segments --> <!-- Transcript segments / editor -->
<div class="flex-1 overflow-y-auto px-5 py-3 min-h-0"> <div class="flex-1 overflow-y-auto px-5 py-3 min-h-0">
{#if item.segments && item.segments.length > 0} {#if viewerMode === "edit"}
<textarea
class="w-full h-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[13px]
text-text leading-relaxed resize-none focus:outline-none focus:border-accent"
bind:value={textDraft}
oninput={scheduleTextSave}
data-no-transition
placeholder="Edit the transcript..."
></textarea>
{:else if item.segments && item.segments.length > 0}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each visibleSegments as seg (seg._idx)} {#each visibleSegments as seg (seg._idx)}
<div <div