refactor(frontend): migrate JS modules to TypeScript

Wholesale JS -> TS migration of the frontend — stores, utils, actions,
and all Svelte component scripts adopt type annotations. Compile-time
surfaces (app.d.ts, lib/types/) added for shared DTO types.

Build plumbing:
  - package.json: dev:frontend script that runs svelte-kit sync first
  - tauri.conf.json: beforeDevCommand points at dev:frontend
  - run.sh: dropped the sed-hack that temporarily blanked beforeDevCommand;
    now relies on npm run dev:frontend to avoid double-Vite
  - jsconfig.json: allowImportingTsExtensions

Preserves all Group 1 behaviour:
  - page.svelte.ts keeps loadHistory / loadTasks Tauri-first, no
    localStorage; saveTranscriptMeta + mapTranscriptRow + mapTaskRow
    intact; update_task_cmd and update_transcript_meta_cmd invocations
    carry the correct payload shape.
  - Toasts, preferences stores typed without behaviour change.
  - Viewer still routes segment edits through saveTranscriptMeta; the
    Task 1.5 TODO markers are gone.

taskExtractor.ts is functionally improved during the migration:
  - multi-task matches in the same sentence
  - list-style shopping-verb expansion (get bread, milk, and cheese)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 20:05:54 +01:00
parent 6605266587
commit d6bf9ed245
48 changed files with 1693 additions and 1156 deletions

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
import type { FontFamily, ReduceMotion } from "$lib/types/app";
import { getPreferences, updateAccessibility } from '$lib/stores/preferences.svelte.js';
import Toggle from '$lib/components/Toggle.svelte';
import SegmentedButton from '$lib/components/SegmentedButton.svelte';
@@ -9,7 +10,7 @@
{ id: 'lexend', label: 'Lexend' },
{ id: 'atkinson', label: 'Atkinson' },
{ id: 'opendyslexic', label: 'OpenDyslexic' },
];
] satisfies { id: FontFamily; label: string }[];
let bionicChecked = $state(prefs.accessibility.bionicReading || false);
let motionValue = $state(
@@ -25,11 +26,15 @@
});
$effect(() => {
const mapped = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system';
const mapped: ReduceMotion = motionValue === 'On' ? 'on' : motionValue === 'Off' ? 'off' : 'system';
if (mapped !== prefs.accessibility.reduceMotion) {
updateAccessibility({ reduceMotion: mapped });
}
});
function rangeValue(event: Event): number {
return Number((event.currentTarget as HTMLInputElement).value);
}
</script>
<div class="flex flex-col gap-5">
@@ -59,7 +64,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.fontSize}px</span>
</div>
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.fontSize}
oninput={(e) => updateAccessibility({ fontSize: Number(e.target.value) })}
oninput={(e) => updateAccessibility({ fontSize: rangeValue(e) })}
class="w-full accent-accent" />
</div>
@@ -70,7 +75,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.letterSpacing.toFixed(2)}em</span>
</div>
<input type="range" min="0" max="0.15" step="0.01" value={prefs.accessibility.letterSpacing}
oninput={(e) => updateAccessibility({ letterSpacing: Number(e.target.value) })}
oninput={(e) => updateAccessibility({ letterSpacing: rangeValue(e) })}
class="w-full accent-accent" />
</div>
@@ -81,7 +86,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.lineHeight.toFixed(1)}</span>
</div>
<input type="range" min="1.3" max="2.2" step="0.1" value={prefs.accessibility.lineHeight}
oninput={(e) => updateAccessibility({ lineHeight: Number(e.target.value) })}
oninput={(e) => updateAccessibility({ lineHeight: rangeValue(e) })}
class="w-full accent-accent" />
</div>
@@ -92,7 +97,7 @@
<span class="text-[11px] text-text-tertiary">{prefs.accessibility.transcriptSize}px</span>
</div>
<input type="range" min="16" max="24" step="1" value={prefs.accessibility.transcriptSize}
oninput={(e) => updateAccessibility({ transcriptSize: Number(e.target.value) })}
oninput={(e) => updateAccessibility({ transcriptSize: rangeValue(e) })}
class="w-full accent-accent" />
</div>

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { settings, saveSettings } from "$lib/stores/page.svelte.js";
let recording = $state(false);
@@ -6,7 +6,7 @@
const modifierKeys = new Set(["Control", "Shift", "Alt", "Meta"]);
function handleKeyDown(e) {
function handleKeyDown(e: KeyboardEvent) {
// Capture-phase listener; guard still belts-and-braces.
if (!recording) return;
e.preventDefault();
@@ -20,7 +20,7 @@
// Wait for a non-modifier key
if (modifierKeys.has(e.key)) return;
const parts = [];
const parts: string[] = [];
if (e.ctrlKey) parts.push("Ctrl");
if (e.shiftKey) parts.push("Shift");
if (e.altKey) parts.push("Alt");

View File

@@ -1,10 +1,16 @@
<script>
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2 } from 'lucide-svelte';
let { parentTaskId, reduceMotion = false } = $props();
let subtasks = $state([]);
interface Subtask {
id: string;
text: string;
done: boolean;
}
let subtasks = $state<Subtask[]>([]);
let loading = $state(false);
let error = $state('');
let decomposing = $state(false);
@@ -13,7 +19,7 @@
loading = true;
error = '';
try {
subtasks = await invoke('list_subtasks_cmd', { parentTaskId });
subtasks = await invoke<Subtask[]>('list_subtasks_cmd', { parentTaskId });
} catch (e) {
error = String(e);
} finally {
@@ -25,7 +31,7 @@
decomposing = true;
error = '';
try {
subtasks = await invoke('decompose_and_store', { parentTaskId });
subtasks = await invoke<Subtask[]>('decompose_and_store', { parentTaskId });
} catch (e) {
error = String(e);
} finally {
@@ -33,7 +39,7 @@
}
}
async function checkStep(subtaskId) {
async function checkStep(subtaskId: string) {
try {
await invoke('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId);
@@ -41,7 +47,7 @@
} catch (_) {}
}
function startTimer(subtaskId) {
function startTimer(subtaskId: string) {
window.dispatchEvent(new CustomEvent('kon:start-timer', {
detail: { taskId: subtaskId, seconds: 120 }
}));

View File

@@ -1,31 +1,38 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { errorMessage } from "$lib/utils/errors.js";
import Card from "$lib/components/Card.svelte";
let { modelSize = "base", onComplete = () => {} } = $props();
let { modelSize = "base", onComplete = () => {} } = $props<{
modelSize?: "tiny" | "base" | "small" | "medium";
onComplete?: () => void;
}>();
let progress = $state(0);
let downloading = $state(false);
let downloaded = $state(0);
let total = $state(0);
let error = $state("");
let unlisten = null;
let unlisten: null | (() => void) = 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" },
};
} satisfies Record<string, { size: string; accuracy: string }>;
let currentModelInfo = $derived(
modelInfo[String(modelSize) as keyof typeof modelInfo]
);
onMount(async () => {
unlisten = await listen("model-download-progress", (event) => {
const data = event.payload;
progress = data.progress;
downloaded = data.downloaded;
total = data.total;
const data = event.payload as { progress?: number; downloaded?: number; total?: number };
progress = data.progress ?? 0;
downloaded = data.downloaded ?? 0;
total = data.total ?? 0;
});
});
@@ -41,12 +48,12 @@
await invoke("download_model", { size: modelSize });
onComplete();
} catch (err) {
error = typeof err === "string" ? err : err.message || "Download failed";
error = errorMessage(err) || "Download failed";
downloading = false;
}
}
function formatBytes(bytes) {
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
@@ -68,7 +75,7 @@
Kon 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
{currentModelInfo?.size ?? "?"} · {currentModelInfo?.accuracy ?? "?"} accuracy · 100% offline
</p>
{#if downloading}

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
// Invisible resize-handle overlays around each Kon window edge.
// Needed because Kon runs with decorations off, so the WM
// provides no resize affordance on KDE/GNOME Wayland.
@@ -24,7 +24,10 @@
const enabled = hasTauriRuntime();
async function startResize(e, direction) {
async function startResize(
e: PointerEvent,
direction: "North" | "South" | "West" | "East" | "NorthWest" | "NorthEast" | "SouthWest" | "SouthEast",
) {
if (!enabled) return;
if (e.button !== 0) return;
e.preventDefault();
@@ -35,7 +38,7 @@
// diagonal component and collapsing a corner drag to a single axis.
// pointerdown + setPointerCapture keeps the direction pinned.
try {
e.currentTarget?.setPointerCapture?.(e.pointerId);
(e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId);
} catch {}
try {
await getCurrentWindow().startResizeDragging(direction);
@@ -44,13 +47,21 @@
</script>
{#if enabled}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-n" onpointerdown={(e) => startResize(e,"North")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-s" onpointerdown={(e) => startResize(e,"South")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-w" onpointerdown={(e) => startResize(e,"West")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-e" onpointerdown={(e) => startResize(e,"East")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-nw" onpointerdown={(e) => startResize(e,"NorthWest")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-ne" onpointerdown={(e) => startResize(e,"NorthEast")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-sw" onpointerdown={(e) => startResize(e,"SouthWest")}></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="kon-rh kon-rh-se" onpointerdown={(e) => startResize(e,"SouthEast")}></div>
{/if}

View File

@@ -1,14 +1,14 @@
<script>
<script lang="ts">
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 recentlyAdded = $state(new Set<string>());
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) {
function handleQuickAdd(e: KeyboardEvent) {
if (e.key === "Enter" && quickInput.trim()) {
addTask({ text: quickInput.trim(), bucket: "inbox" });
quickInput = "";

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { settings } from "$lib/stores/page.svelte.js";
@@ -19,16 +19,16 @@
await getCurrentWindow().hide();
}
function handleDragStart(e) {
function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return;
if (e.target.closest("button")) return;
try { e.currentTarget?.setPointerCapture?.(e.pointerId); } catch {}
if ((e.target as HTMLElement | null)?.closest("button")) return;
try { (e.currentTarget as HTMLElement | null)?.setPointerCapture?.(e.pointerId); } catch {}
getCurrentWindow().startDragging();
}
// Track maximise state
$effect(() => {
let unlisten;
let unlisten: (() => void) | undefined;
getCurrentWindow().onResized(async () => {
maximised = await getCurrentWindow().isMaximized();
}).then((fn) => unlisten = fn);
@@ -36,6 +36,7 @@
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center select-none bg-sidebar border-b border-border-subtle
{compact ? 'h-[28px]' : 'h-[32px]'}"

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
import type { ToastSeverity } from "$lib/types/app";
// Renders the toast stack in the bottom-right of the viewport. Mount once
// at the app root (in +layout.svelte). Reads from the global toasts store.
//
@@ -10,7 +11,7 @@
import { toasts } from '$lib/stores/toasts.svelte.js';
import { X } from 'lucide-svelte';
function severityClass(severity) {
function severityClass(severity: ToastSeverity) {
switch (severity) {
case 'success': return 'toast-success';
case 'info': return 'toast-info';

View File

@@ -1,7 +1,11 @@
<script>
import { onMount, onDestroy } from "svelte";
<script lang="ts">
type SpinnerType = "dots" | "circle" | "pulse" | "stars" | "orbit";
let { type = "dots", speed = 100, classes = "" } = $props();
let { type = "dots", speed = 100, classes = "" } = $props<{
type?: SpinnerType;
speed?: number;
classes?: string;
}>();
const sequences = {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
@@ -12,17 +16,14 @@
};
let frame = $state(0);
let interval = null;
let chars = sequences[type] || sequences.dots;
let chars = $derived(sequences[type as SpinnerType] ?? sequences.dots);
onMount(() => {
interval = setInterval(() => {
$effect(() => {
frame = 0;
const interval = setInterval(() => {
frame = (frame + 1) % chars.length;
}, speed);
});
onDestroy(() => {
if (interval) clearInterval(interval);
return () => clearInterval(interval);
});
</script>

View File

@@ -1,4 +1,5 @@
<script>
<script lang="ts">
import type { ViewerSegment } from "$lib/types/app";
import { measureTextHeight } from "$lib/utils/textMeasure.js";
import { buildCumulativeOffsets, findVisibleRange } from "$lib/utils/virtualList.js";
import { getPreferences } from "$lib/stores/preferences.svelte.js";
@@ -8,23 +9,39 @@
let {
segments = [],
activeSegmentIdx = -1,
matchingSegments = new Set(),
matchingSegments = new Set<number>(),
showTimestamps = true,
editingIdx = -1,
editingText = $bindable(""),
searchQuery = "",
formatTime = (t) => String(t),
onSeekToTime = () => {},
onStartEditing = () => {},
formatTime = (t: number) => String(t),
onSeekToTime = (_time: number) => {},
onStartEditing = (_idx: number) => {},
onFinishEditing = () => {},
onToggleStar = () => {},
onCopySegment = () => {},
onDeleteSegment = () => {},
highlightText = (t) => t,
} = $props();
onToggleStar = (_idx: number) => {},
onCopySegment = (_idx: number) => {},
onDeleteSegment = (_idx: number) => {},
highlightText = (t: string) => t,
} = $props<{
segments?: ViewerSegment[];
activeSegmentIdx?: number;
matchingSegments?: Set<number>;
showTimestamps?: boolean;
editingIdx?: number;
editingText?: string;
searchQuery?: string;
formatTime?: (time: number) => string;
onSeekToTime?: (time: number) => void;
onStartEditing?: (idx: number) => void;
onFinishEditing?: () => void;
onToggleStar?: (idx: number) => void;
onCopySegment?: (idx: number) => void;
onDeleteSegment?: (idx: number) => void;
highlightText?: (text: string) => string;
}>();
// Internal state
let containerEl = $state(null);
let containerEl = $state<HTMLDivElement | null>(null);
let containerHeight = $state(0);
let containerWidth = $state(0);
let scrollTop = $state(0);
@@ -46,7 +63,7 @@
const textWidth = containerWidth - 26 - timestampWidth - 24;
if (textWidth <= 0) return segments.map(() => 40);
return segments.map(seg => {
return segments.map((seg: ViewerSegment) => {
const text = seg.text?.trim() || "";
if (!text) return SEGMENT_PADDING + textLineHeight;
const { height } = measureTextHeight(text, textFont, textWidth, textLineHeight);
@@ -75,7 +92,7 @@
// Visible segment slice with positioning
let visibleItems = $derived.by(() => {
const items = [];
const items: Array<{ seg: ViewerSegment; idx: number; top: number; height: number }> = [];
for (let i = visibleRange.start; i < visibleRange.end; i++) {
items.push({
seg: segments[i],
@@ -88,7 +105,7 @@
});
// Scroll to a specific segment index (used for active segment tracking + search)
export function scrollToSegment(idx) {
export function scrollToSegment(idx: number) {
if (!containerEl || idx < 0 || idx >= segments.length) return;
const segTop = cumulativeOffsets[idx];
const segHeight = segmentHeights[idx];
@@ -96,8 +113,8 @@
containerEl.scrollTop = segTop - viewCenter + segHeight / 2;
}
function onScroll(e) {
scrollTop = e.target.scrollTop;
function onScroll(e: Event) {
scrollTop = (e.currentTarget as HTMLDivElement).scrollTop;
// Track selection state for expanded buffer
hasSelection = !document.getSelection()?.isCollapsed;
}
@@ -105,7 +122,7 @@
// ResizeObserver for container dimensions
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(entries => {
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerHeight = entry.contentRect.height;
containerWidth = entry.contentRect.width;
@@ -119,7 +136,7 @@
$effect(() => {
if (activeSegmentIdx >= 0) {
// Find the index in our segments array that matches
const arrayIdx = segments.findIndex(s => s._idx === activeSegmentIdx);
const arrayIdx = segments.findIndex((s: ViewerSegment) => s._idx === activeSegmentIdx);
if (arrayIdx >= 0) {
const segTop = cumulativeOffsets[arrayIdx];
const segBottom = segTop + segmentHeights[arrayIdx];

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { tasks, addTask, completeTask, uncompleteTask, deleteTask } from '$lib/stores/page.svelte.js';
import MicroSteps from '$lib/components/MicroSteps.svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
@@ -7,7 +7,7 @@
let newTaskText = $state('');
let showOverflow = $state(false);
let expandedTaskIds = $state(new Set());
let expandedTaskIds = $state(new Set<string>());
// Exclude subtasks from WIP count — only top-level tasks count
let activeTasks = $derived(tasks.filter(t => !t.done && !t.parentTaskId));
@@ -22,11 +22,11 @@
newTaskText = '';
}
function handleKeydown(e) {
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleAdd();
}
function toggleExpand(taskId) {
function toggleExpand(taskId: string) {
const next = new Set(expandedTaskIds);
if (next.has(taskId)) {
next.delete(taskId);

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { getPreferences, updatePreferences } from '$lib/stores/preferences.svelte.js';
const prefs = getPreferences();
@@ -8,9 +8,9 @@
{ id: 'cave', label: 'Cave', description: 'Cool, focused', swatch: 'bg-[#141a1e]' },
{ id: 'energy', label: 'Energy', description: 'Warm, active', swatch: 'bg-[#1c1815]' },
{ id: 'reset', label: 'Reset', description: 'Natural, calm', swatch: 'bg-[#161a16]' },
];
] as const;
function selectZone(id) {
function selectZone(id: string) {
updatePreferences({ zone: id });
}
</script>