Files
Lumotia/src/routes/preview/+page.svelte
Jake e857a814ad fix(ui): remove side-stripe borders in favour of subtle bg tints
Side-stripe borders (border-left: 2px or 3px in an accent colour, with
the rest of the element having no border) are the textbook AI-coded-IDE
"selected-state" pattern. The brand vocabulary is colour-tint and
chevron, not stripe-and-fill.

Replaced eight sites:
- VirtualSegmentList active/match/default segment rows: bg-accent/10,
  bg-warning/10, hover:bg-hover (drop the stripes; bg-warning bumped
  /5 → /10 so it's visible without the stripe doing the work).
- viewer/+page.svelte: same pattern as VirtualSegmentList.
- TasksPage profile-list tabs: bg-accent/10 + font-medium for active,
  hover:bg-hover for inactive. rounded-md added so the tint follows
  the row outline.
- ToastViewport: replaced border-left + variant colour with full 1px
  border + bg tint + border-color tint (color-mix() with the matching
  semantic token at 35% / 10%). Also removed the orphan --moss /
  --signal / --ember tokens — they were not defined in app.css and
  fell back to hex literals.
- preview/+page.svelte: dropped the phase-coloured left stripe and
  the borderColorClass derivation; the header already has a pulsing
  dot / animated bars / spinner for each phase, so the stripe was
  redundant.
2026-05-07 09:18:54 +01:00

275 lines
9.3 KiB
Svelte

<script lang="ts">
// @ts-nocheck
import { onDestroy, onMount, tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Copy, Check, X, RotateCcw } from "lucide-svelte";
import { settings } from "$lib/stores/page.svelte.js";
// Phase state machine:
// listening → live → cleanup → final → (auto-hide)
type Phase = "listening" | "live" | "cleanup" | "final";
let phase = $state<Phase>("listening");
let rawText = $state("");
let finalText = $state("");
let copied = $state(false);
let scrollEl: HTMLDivElement | null = null;
const unlisteners: Array<() => void> = [];
let autoHideTimer: number | null = null;
let copyResetTimer: number | null = null;
const AUTO_HIDE_MS = 4000;
const COPY_RESET_MS = 1400;
function clearAutoHide() {
if (autoHideTimer !== null) {
window.clearTimeout(autoHideTimer);
autoHideTimer = null;
}
}
function scheduleAutoHide() {
clearAutoHide();
autoHideTimer = window.setTimeout(() => {
getCurrentWindow().hide().catch(() => {});
}, AUTO_HIDE_MS);
}
async function scrollToBottom() {
await tick();
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
}
async function copyActiveText() {
const text = phase === "final" ? finalText : rawText;
if (!text.trim()) return;
let ok = false;
try {
await navigator.clipboard.writeText(text);
ok = true;
} catch {
ok = await invoke("copy_to_clipboard", { text }).then(() => true).catch(() => false);
}
if (!ok) return;
copied = true;
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
copyResetTimer = window.setTimeout(() => { copied = false; }, COPY_RESET_MS);
// Re-arm auto-hide so the user's copy action doesn't get cut short.
if (phase === "final") scheduleAutoHide();
}
function dismiss() {
clearAutoHide();
getCurrentWindow().hide().catch(() => {});
}
let revertBusy = $state(false);
let reverted = $state(false);
// Brief item #17: the raw Whisper transcript is the source of truth.
// In final phase the user sees both raw (in rawText) and formatted
// (in finalText). If the LLM cleanup changed their meaning, one
// click here replaces the pasted output with raw instead.
//
// Two paths depending on how the transcript was delivered to the
// target app:
// autoPaste on → the LLM output was pasted into whatever app had
// focus. paste_text_replacing sends undo, waits a
// beat, then pastes the raw text.
// autoPaste off → nothing was pasted; the LLM output is only on
// the clipboard. We just overwrite the clipboard
// with raw so the user's own paste yields raw.
async function revertToRaw() {
if (!rawText.trim() || revertBusy) return;
// Extend auto-hide so the user can actually see the confirmation
// without the window disappearing under them.
clearAutoHide();
revertBusy = true;
try {
if (settings.autoPaste) {
const outcome = await invoke("paste_text_replacing", { text: rawText });
if (!outcome?.pasted && outcome?.message) {
console.warn("[preview] paste_text_replacing partial failure:", outcome.message);
}
} else {
try {
await navigator.clipboard.writeText(rawText);
} catch {
await invoke("copy_to_clipboard", { text: rawText }).catch(() => {});
}
}
reverted = true;
} finally {
revertBusy = false;
scheduleAutoHide();
}
}
onMount(async () => {
// preview-listening: recording just started, no text yet.
unlisteners.push(
await listen("preview-listening", () => {
clearAutoHide();
phase = "listening";
rawText = "";
finalText = "";
}),
);
// preview-append: main forwards each live chunk's raw_text. We also
// accept the full payload from single-shot transcriptions as append.
unlisteners.push(
await listen<{ text: string; replace?: boolean }>("preview-append", (event) => {
const chunk = (event.payload?.text ?? "").trim();
if (!chunk) return;
if (event.payload?.replace) {
rawText = chunk;
} else if (rawText.length === 0) {
rawText = chunk;
} else {
rawText = `${rawText} ${chunk}`;
}
phase = "live";
scrollToBottom();
}),
);
// preview-cleanup: main window is about to run the LLM cleanup pass.
unlisteners.push(
await listen("preview-cleanup", () => {
phase = "cleanup";
}),
);
// preview-final: cleanup done (or skipped). Show formatted text and
// start the 4s auto-hide countdown.
unlisteners.push(
await listen<{ text: string }>("preview-final", (event) => {
finalText = (event.payload?.text ?? "").trim();
phase = "final";
reverted = false;
scrollToBottom();
scheduleAutoHide();
}),
);
// preview-hide: main asks us to dismiss immediately (recording
// cancelled, or the user re-focused the main window mid-stream).
unlisteners.push(
await listen("preview-hide", () => {
dismiss();
}),
);
});
onDestroy(() => {
clearAutoHide();
if (copyResetTimer !== null) window.clearTimeout(copyResetTimer);
for (const off of unlisteners) off();
});
let activeText = $derived(phase === "final" ? finalText : rawText);
// Only offer "use raw" when there's a meaningful delta to revert to.
// If rawText and finalText are identical (Raw format mode, or LLM
// cleanup disabled), the button is pointless.
let canRevertToRaw = $derived(
phase === "final" && rawText.trim().length > 0 && rawText.trim() !== finalText.trim(),
);
let phaseLabel = $derived(
phase === "listening" ? "Listening"
: phase === "live" ? "Raw"
: phase === "cleanup" ? "Cleaning up"
: "Final",
);
</script>
<div
class="h-full w-full flex flex-col bg-bg text-text p-3 gap-2"
data-tauri-drag-region
>
<header class="flex items-center justify-between gap-2 text-[11px] text-text-secondary" data-tauri-drag-region>
<div class="flex items-center gap-2" data-tauri-drag-region>
{#if phase === "listening"}
<span class="inline-block w-[8px] h-[8px] rounded-full bg-text-tertiary animate-pulse"></span>
{:else if phase === "live"}
<span class="inline-flex items-end gap-[2px] h-[10px]">
<span class="w-[3px] bg-accent rounded-sm animate-bars-1"></span>
<span class="w-[3px] bg-accent rounded-sm animate-bars-2"></span>
<span class="w-[3px] bg-accent rounded-sm animate-bars-3"></span>
</span>
{:else if phase === "cleanup"}
<span class="inline-flex items-end gap-[2px] h-[10px]">
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-1"></span>
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-2"></span>
<span class="w-[3px] bg-accent/80 rounded-sm animate-bars-3"></span>
</span>
{:else}
<Check size={12} class="text-success" />
{/if}
<span class="uppercase tracking-wider font-medium">{phaseLabel}</span>
</div>
<div class="flex items-center gap-1">
{#if canRevertToRaw}
<button
class="px-2 py-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40 flex items-center gap-1 text-[11px]"
onclick={revertToRaw}
disabled={revertBusy}
title={settings.autoPaste ? "Replace pasted text with raw transcript" : "Put raw transcript on clipboard"}
>
{#if reverted}
<Check size={12} />
<span>Raw used</span>
{:else}
<RotateCcw size={12} />
<span>{settings.autoPaste ? "Use raw" : "Copy raw"}</span>
{/if}
</button>
{/if}
<button
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text disabled:opacity-40"
onclick={copyActiveText}
disabled={!activeText.trim()}
title="Copy"
>
{#if copied}
<Check size={14} />
{:else}
<Copy size={14} />
{/if}
</button>
<button
class="p-1 rounded hover:bg-hover text-text-tertiary hover:text-text"
onclick={dismiss}
title="Dismiss"
>
<X size={14} />
</button>
</div>
</header>
<div
bind:this={scrollEl}
class="flex-1 min-h-0 overflow-y-auto text-[14px] leading-relaxed whitespace-pre-wrap break-words"
>
{#if activeText.trim()}
{activeText}
{:else}
<span class="text-text-tertiary italic">
{phase === "listening" ? "Waiting for speech…" : ""}
</span>
{/if}
</div>
</div>
<style>
@keyframes bars-1 { 0%,100% { height: 40%; } 50% { height: 100%; } }
@keyframes bars-2 { 0%,100% { height: 70%; } 50% { height: 30%; } }
@keyframes bars-3 { 0%,100% { height: 50%; } 50% { height: 90%; } }
:global(.animate-bars-1) { animation: bars-1 0.9s ease-in-out infinite; }
:global(.animate-bars-2) { animation: bars-2 0.9s ease-in-out infinite 0.15s; }
:global(.animate-bars-3) { animation: bars-3 0.9s ease-in-out infinite 0.3s; }
</style>