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:
7
src/lib/components/Card.svelte
Normal file
7
src/lib/components/Card.svelte
Normal 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>
|
||||
73
src/lib/components/HotkeyRecorder.svelte
Normal file
73
src/lib/components/HotkeyRecorder.svelte
Normal 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>
|
||||
105
src/lib/components/ModelDownloader.svelte
Normal file
105
src/lib/components/ModelDownloader.svelte
Normal 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>
|
||||
18
src/lib/components/SegmentedButton.svelte
Normal file
18
src/lib/components/SegmentedButton.svelte
Normal 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>
|
||||
95
src/lib/components/TaskSidebar.svelte
Normal file
95
src/lib/components/TaskSidebar.svelte
Normal 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>
|
||||
70
src/lib/components/Titlebar.svelte
Normal file
70
src/lib/components/Titlebar.svelte
Normal 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>
|
||||
27
src/lib/components/Toggle.svelte
Normal file
27
src/lib/components/Toggle.svelte
Normal 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>
|
||||
29
src/lib/components/UnicodeSpinner.svelte
Normal file
29
src/lib/components/UnicodeSpinner.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user