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