feat(kon): scaffold hybrid modular workspace

- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers
- Minimal Tauri shell (lib.rs + main.rs) with plugin registration
- Svelte 5 frontend copied from Ramble v0.2
- All crates compile as empty stubs
- App identifier: uk.co.corbel.kon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:21:38 +00:00
commit 9926a42b7a
80 changed files with 13328 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<script>
let { children, classes = "" } = $props();
</script>
<div class="bg-bg-card border border-border rounded-2xl shadow-[0_1px_3px_rgba(0,0,0,0.2)] {classes}">
{@render children()}
</div>

View File

@@ -0,0 +1,73 @@
<script>
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
let recording = $state(false);
let captured = $state(false);
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
function startRecording() {
recording = true;
captured = false;
}
function handleKeyDown(e) {
if (!recording) return;
e.preventDefault();
e.stopPropagation();
// Wait for a non-modifier key
if (modifierKeys.has(e.key)) return;
const parts = [];
if (e.ctrlKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt");
if (e.metaKey) parts.push("Super");
// Must have at least one modifier
if (parts.length === 0) {
recording = false;
return;
}
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
settings.globalHotkey = parts.join("+");
saveSettings();
recording = false;
captured = true;
setTimeout(() => { captured = false; }, 1500);
}
function handleBlur() {
recording = false;
}
let chips = $derived(settings.globalHotkey.split("+"));
</script>
<button
class="inline-flex items-center gap-1.5 rounded-xl px-4 py-2.5
{recording
? 'bg-bg-input border-accent shadow-[0_0_0_3px_rgba(232,168,124,0.1)]'
: captured
? 'bg-bg-input border-border animate-pulse-warm'
: 'bg-bg-input border-border hover:border-border'}
border transition-all"
onclick={startRecording}
onkeydown={handleKeyDown}
onblur={handleBlur}
aria-label="Record hotkey"
>
{#if recording}
<span class="text-[11px] text-text-tertiary italic">Press new shortcut...</span>
{:else}
{#each chips as chip, i}
{#if i > 0}
<span class="text-[10px] text-text-tertiary">+</span>
{/if}
<span class="px-2 py-0.5 rounded-md bg-bg-elevated border border-border text-[11px] font-medium text-text
shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]">{chip}</span>
{/each}
{/if}
</button>

View File

@@ -0,0 +1,105 @@
<script>
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import Card from "$lib/components/Card.svelte";
let { modelSize = "base", onComplete = () => {} } = $props();
let progress = $state(0);
let downloading = $state(false);
let downloaded = $state(0);
let total = $state(0);
let error = $state("");
let unlisten = null;
const modelInfo = {
tiny: { size: "~75 MB", accuracy: "Basic" },
base: { size: "~142 MB", accuracy: "Good" },
small: { size: "~466 MB", accuracy: "Better" },
medium: { size: "~1.5 GB", accuracy: "Best" },
};
onMount(async () => {
unlisten = await listen("model-download-progress", (event) => {
const data = event.payload;
progress = data.progress;
downloaded = data.downloaded;
total = data.total;
});
});
onDestroy(() => {
if (unlisten) unlisten();
});
async function startDownload() {
downloading = true;
error = "";
progress = 0;
try {
await invoke("download_model", { size: modelSize });
onComplete();
} catch (err) {
error = typeof err === "string" ? err : err.message || "Download failed";
downloading = false;
}
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(2)} GB`;
}
</script>
<div class="flex flex-col items-center justify-center h-full px-8 animate-fade-in">
<Card classes="max-w-md w-full">
<div class="p-8 text-center">
<div class="w-16 h-16 rounded-full bg-accent/10 flex items-center justify-center mx-auto mb-5">
<svg class="w-8 h-8 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
</div>
<h3 class="text-[18px] font-semibold text-text mb-2">Download Whisper Model</h3>
<p class="text-[13px] text-text-secondary mb-1">
Ramble needs the <span class="font-medium text-text">{modelSize}</span> model to transcribe speech.
</p>
<p class="text-[11px] text-text-tertiary mb-6">
{modelInfo[modelSize]?.size ?? "?"} · {modelInfo[modelSize]?.accuracy ?? "?"} accuracy · 100% offline
</p>
{#if downloading}
<div class="mb-4">
<div class="h-[6px] bg-bg-elevated rounded-full overflow-hidden mb-2">
<div
class="h-full bg-accent rounded-full transition-all duration-300 shadow-[0_0_8px_rgba(232,168,124,0.4)]"
style="width: {progress}%"
></div>
</div>
<p class="text-[12px] text-text-secondary">
{progress}% · {formatBytes(downloaded)} / {formatBytes(total)}
</p>
</div>
{:else}
<button
class="px-6 py-2.5 rounded-xl text-[14px] font-medium text-white bg-accent hover:bg-accent-hover
shadow-[0_4px_16px_rgba(232,168,124,0.3)] active:scale-[0.97] transition-all duration-150"
onclick={startDownload}
>
Download Model
</button>
{/if}
{#if error}
<p class="text-[12px] text-danger mt-3">{error}</p>
{/if}
<p class="text-[10px] text-text-tertiary mt-5">
Models are cached locally. No data leaves your machine.
</p>
</div>
</Card>
</div>

View File

@@ -0,0 +1,18 @@
<script>
let { options = [], value = $bindable(""), size = "default" } = $props();
</script>
<div class="inline-flex bg-bg-elevated rounded-[10px] p-[3px] gap-[2px]">
{#each options as option}
<button
class="rounded-lg font-medium
{size === 'small' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-[6px] text-[12px]'}
{value === option
? 'bg-accent text-white shadow-[0_1px_4px_rgba(232,168,124,0.3)]'
: 'text-text-secondary hover:text-text hover:bg-hover'}"
onclick={() => value = option}
>
{option}
</button>
{/each}
</div>

View File

@@ -0,0 +1,95 @@
<script>
import { page, tasks, addTask, completeTask, uncompleteTask } from "$lib/stores/page.svelte.js";
import { BUCKET_DOT_COLORS, SIDEBAR_MAX_TASKS } from "$lib/utils/constants.js";
let quickInput = $state("");
let recentlyAdded = $state(new Set());
let activeTasks = $derived(tasks.filter((t) => !t.done).slice(0, SIDEBAR_MAX_TASKS));
let taskCount = $derived(tasks.filter((t) => !t.done).length);
function handleQuickAdd(e) {
if (e.key === "Enter" && quickInput.trim()) {
addTask({ text: quickInput.trim(), bucket: "inbox" });
quickInput = "";
}
}
function close() {
page.taskSidebarOpen = false;
}
// Track recently added tasks for highlight animation
let prevTaskIds = $state(new Set(tasks.map((t) => t.id)));
$effect(() => {
const currentIds = new Set(tasks.map((t) => t.id));
for (const id of currentIds) {
if (!prevTaskIds.has(id)) {
recentlyAdded.add(id);
setTimeout(() => { recentlyAdded.delete(id); }, 2000);
}
}
prevTaskIds = currentIds;
});
</script>
<aside class="flex flex-col w-[280px] min-w-[280px] bg-sidebar border-l border-border-subtle h-full animate-slide-in-right">
<!-- Header -->
<div class="flex items-center gap-2 px-4 pt-4 pb-3">
<h3 class="text-[13px] font-semibold text-text">Tasks</h3>
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
{taskCount}
</span>
<div class="flex-1"></div>
<button
class="text-[11px] text-text-tertiary hover:text-accent"
onclick={() => page.current = "tasks"}
>View all</button>
<button
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text rounded"
onclick={close}
aria-label="Close task sidebar"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" />
</svg>
</button>
</div>
<!-- Quick add -->
<div class="px-3 pb-3">
<input
type="text"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
placeholder="Add task..."
bind:value={quickInput}
onkeydown={handleQuickAdd}
data-no-transition
/>
</div>
<!-- Task list -->
<div class="flex-1 overflow-y-auto px-3 pb-3 min-h-0">
{#if activeTasks.length === 0}
<p class="text-[11px] text-text-tertiary text-center py-6 opacity-60">No active tasks</p>
{:else}
<div class="flex flex-col gap-0.5">
{#each activeTasks as task (task.id)}
<div
class="group flex items-start gap-2 px-2.5 py-1.5 rounded-lg hover:bg-hover
{recentlyAdded.has(task.id) ? 'animate-highlight-warm' : ''}"
>
<button
class="mt-0.5 w-4 h-4 rounded border-[1.5px] border-border-subtle hover:border-accent flex-shrink-0"
onclick={() => completeTask(task.id)}
aria-label="Complete task"
></button>
<span class="text-[11px] text-text leading-snug flex-1 min-w-0">{task.text}</span>
<span class="w-2 h-2 rounded-full mt-1 flex-shrink-0 {BUCKET_DOT_COLORS[task.bucket] || 'bg-text-tertiary'}"></span>
</div>
{/each}
</div>
{/if}
</div>
</aside>

View File

@@ -0,0 +1,70 @@
<script>
import { getCurrentWindow } from "@tauri-apps/api/window";
let maximised = $state(false);
async function handleMinimise() {
await getCurrentWindow().minimize();
}
async function handleMaximise() {
await getCurrentWindow().toggleMaximize();
maximised = await getCurrentWindow().isMaximized();
}
async function handleClose() {
await getCurrentWindow().hide();
}
function handleDragStart(e) {
if (e.button !== 0) return;
if (e.target.closest("button")) return;
getCurrentWindow().startDragging();
}
// Track maximise state
$effect(() => {
let unlisten;
getCurrentWindow().onResized(async () => {
maximised = await getCurrentWindow().isMaximized();
}).then((fn) => unlisten = fn);
return () => unlisten?.();
});
</script>
<div
class="flex items-center h-[32px] select-none bg-sidebar border-b border-border-subtle"
onmousedown={handleDragStart}
data-tauri-drag-region
>
<!-- Left spacer: aligns with sidebar width -->
<div class="w-[210px] min-w-[210px]" data-tauri-drag-region></div>
<!-- Centre: drag area -->
<div class="flex-1" data-tauri-drag-region></div>
<!-- Window controls -->
<div class="flex items-center h-full">
<button class="titlebar-btn hover:bg-hover" onclick={handleMinimise} aria-label="Minimise">
<svg class="w-3 h-3" viewBox="0 0 12 12">
<rect y="5" width="12" height="1.5" rx="0.5" fill="currentColor" />
</svg>
</button>
<button class="titlebar-btn hover:bg-hover" onclick={handleMaximise} aria-label="Maximise">
{#if maximised}
<svg class="w-3 h-3" viewBox="0 0 12 12">
<path d="M3 1h8v8h-2v2H1V3h2V1zm1 1v1h5v5h1V2H4zm-2 2v6h6V4H2z" fill="currentColor" />
</svg>
{:else}
<svg class="w-3 h-3" viewBox="0 0 12 12">
<rect x="1" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
{/if}
</button>
<button class="titlebar-btn hover:bg-danger/20 hover:text-danger" onclick={handleClose} aria-label="Close">
<svg class="w-3 h-3" viewBox="0 0 12 12">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script>
let { checked = $bindable(false), label = "", description = "" } = $props();
</script>
<div class="flex items-start gap-3 py-2.5">
<button
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
{checked ? 'bg-accent shadow-[0_0_8px_rgba(232,168,124,0.25)]' : 'bg-border'}
active:scale-95 transition-all duration-200"
onclick={() => checked = !checked}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]
{checked ? 'translate-x-[16px]' : 'translate-x-0'}"
></span>
</button>
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text leading-tight">{label}</p>
{#if description}
<p class="text-[11px] text-text-tertiary mt-0.5 leading-snug">{description}</p>
{/if}
</div>
</div>

View File

@@ -0,0 +1,29 @@
<script>
import { onMount, onDestroy } from "svelte";
let { type = "dots", speed = 100, classes = "" } = $props();
const sequences = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
circle: ["◴", "◷", "◶", "◵"],
pulse: ["◉", "◎", "○", "◎"],
stars: ["·", "✢", "✳", "✶", "✻", "✽"],
orbit: ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"],
};
let frame = $state(0);
let interval = null;
let chars = sequences[type] || sequences.dots;
onMount(() => {
interval = setInterval(() => {
frame = (frame + 1) % chars.length;
}, speed);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>
<span class="inline-block {classes}" aria-hidden="true">{chars[frame]}</span>