Files
WhispAssist/src/App.svelte
T

649 lines
19 KiB
Svelte

<script lang="ts">
// Three-pane shell (FR-UX-1): meetings list · transcript/notes · summary.
// This is a skeleton — views are stubs that the roadmap fills in.
import MeetingsList from "./lib/views/MeetingsList.svelte";
import TranscriptNotes from "./lib/views/TranscriptNotes.svelte";
import SummaryPanel from "./lib/views/SummaryPanel.svelte";
import Settings from "./lib/views/Settings.svelte";
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
import LevelMeter from "./lib/components/LevelMeter.svelte";
import { trapFocus } from "./lib/actions/trapFocus";
import { recording } from "./lib/stores/recording.svelte";
import { settings } from "./lib/stores/settings.svelte";
import { meetings } from "./lib/stores/meetings.svelte";
import { api, type NoteTemplate } from "./lib/api";
import { onMount } from "svelte";
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
import { Circle, Square, Trash2, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
let showSettings = $state(false);
let showConsent = $state(false);
// Vault unlock gate (T8.8): if the vault is enabled but locked at startup,
// prompt for the password so encrypted meetings are readable.
let vaultLocked = $state(false);
let vaultPw = $state("");
let vaultErr = $state<string | null>(null);
async function checkVault() {
try {
const v = await api.vaultStatus();
vaultLocked = v.enabled && !v.unlocked;
} catch {
vaultLocked = false;
}
}
async function submitUnlock() {
vaultErr = null;
try {
await api.unlockVault(vaultPw);
vaultPw = "";
vaultLocked = false;
meetings.load(); // refresh now that encrypted content is readable
} catch {
vaultErr = "Incorrect password";
}
}
// Note templates (T8.1, FR-NOTE-5) — picked before starting a recording,
// applied to notes.md's structure once the meeting finalizes.
let noteTemplates = $state<NoteTemplate[]>([]);
let selectedTemplateId = $state("");
// Theme (T7.2, FR-UX-2): "system" tracks the OS preference live; "light"/"dark" override it.
let systemPrefersDark = $state(false);
let resolvedTheme = $derived(
settings.settings.theme === "system"
? systemPrefersDark
? "dark"
: "light"
: settings.settings.theme,
);
// Also set data-theme on <html> itself, not just the .app div — html/body
// sit above .app in the DOM, so without this they keep the (unthemed,
// white) browser-default background. That gap was invisible in light mode
// but showed as a bright white margin around the whole window in dark mode.
$effect(() => {
document.documentElement.dataset.theme = resolvedTheme;
});
onMount(() => {
recording.init();
settings.load();
meetings.init();
checkVault();
api
.listNoteTemplates()
.then((t) => (noteTemplates = t))
.catch(() => (noteTemplates = []));
const media = window.matchMedia("(prefers-color-scheme: dark)");
systemPrefersDark = media.matches;
const onSystemThemeChange = (e: MediaQueryListEvent) => (systemPrefersDark = e.matches);
media.addEventListener("change", onSystemThemeChange);
window.addEventListener("keydown", onGlobalKeydown);
return () => {
media.removeEventListener("change", onSystemThemeChange);
window.removeEventListener("keydown", onGlobalKeydown);
};
});
function startRecording() {
meetings.deselect(); // show the live view, not whatever past meeting was open
recording.start(undefined, settings.settings.default_record, selectedTemplateId || undefined);
}
function toggleRecording() {
if (recording.state === "idle") startRecording();
else recording.stop();
}
async function cancelRecording() {
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
await recording.cancel();
meetings.deselect();
}
// Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles.
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
);
}
// Screen-reader announcement for recording state changes (T7.5, FR-UX-4) —
// nothing else in the header is a live region, so this is the only way a
// non-visual user learns recording started/stopped. Tracked via an effect
// (not a plain $derived) so "stopped" fires only after having recorded,
// never on initial idle load.
let recordingAnnouncement = $state("");
let previousRecordingState = recording.state;
$effect(() => {
const current = recording.state;
if (current !== previousRecordingState) {
if (current === "recording") recordingAnnouncement = "Recording started";
else if (current === "paused") recordingAnnouncement = "Recording paused";
else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped";
previousRecordingState = current;
}
});
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (showConsent) showConsent = false;
else if (showSettings) showSettings = false;
return;
}
if (isEditableTarget(e.target)) return; // never hijack typing in a field
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "r") {
e.preventDefault();
toggleRecording();
} else if (e.ctrlKey && e.key === ",") {
e.preventDefault();
showSettings = !showSettings;
}
}
// Mid-meeting retention toggle (FR-REC-1); gates on the same one-time
// consent notice as the Settings default-record toggle (FR-REC-2).
function onToggleRetention(e: Event) {
const on = (e.target as HTMLInputElement).checked;
if (on && !settings.settings.consent_acknowledged) {
showConsent = true;
(e.target as HTMLInputElement).checked = false;
return;
}
recording.setRetention(on);
}
async function acceptConsentAndRecord() {
await settings.acknowledgeConsent();
await recording.setRetention(true);
showConsent = false;
}
</script>
<div class="app" data-theme={resolvedTheme}>
<div class="sr-only" role="status" aria-live="polite">{recordingAnnouncement}</div>
<header class="bar">
<strong>WhispAssist</strong>
<span class="muted">local · private</span>
<div class="spacer"></div>
{#if recording.state === "idle"}
<select
class="theme-select"
bind:value={selectedTemplateId}
aria-label="Note template"
title="Note template"
>
<option value="">No template</option>
{#each noteTemplates as t (t.id)}
<option value={t.id}>{t.name}</option>
{/each}
</select>
<button
class="record-btn"
onclick={startRecording}
title="Start recording (Ctrl+Shift+R)"
aria-keyshortcuts="Control+Shift+R"
>
<Circle size={11} fill="currentColor" aria-hidden="true" />
Record
</button>
{:else}
<button
class="stop-btn"
onclick={() => recording.stop()}
title="Stop recording (Ctrl+Shift+R)"
aria-keyshortcuts="Control+Shift+R"
>
<Square size={11} fill="currentColor" aria-hidden="true" />
Stop
</button>
<button
class="cancel-btn"
onclick={cancelRecording}
title="Discard this recording and delete it"
>
<Trash2 size={12} aria-hidden="true" />
Cancel
</button>
<span class="rec">
<span class="rec-dot" aria-hidden="true"></span>
Recording…
</span>
<LevelMeter rms={recording.levelRms} peak={recording.levelPeak} />
{#if settings.hardware}
<span class="backend" title="Active transcription backend">{settings.hardware.active}</span>
{/if}
<label class="retention" title="Save audio as .wav for this meeting">
<input type="checkbox" checked={recording.retention} onchange={onToggleRetention} />
<span>{recording.retention ? "saving" : "not saved"}</span>
</label>
{#if recording.deviceNotice}
<span class="device-notice" role="status" title={recording.deviceNotice}>
<AlertTriangle size={14} aria-hidden="true" />
reconnecting audio device…
</span>
{/if}
{/if}
<ThemeToggle
value={settings.settings.theme as "system" | "light" | "dark"}
onchange={(value) => settings.patch({ theme: value })}
/>
<button
class="icon"
aria-label="Settings"
title="Settings (Ctrl+,)"
aria-keyshortcuts="Control+,"
onclick={() => (showSettings = !showSettings)}
>
<SettingsIcon size={18} aria-hidden="true" />
</button>
</header>
{#if showConsent}
<div
class="consent-overlay"
role="dialog"
aria-modal="true"
aria-label="Recording consent"
tabindex="-1"
use:trapFocus
>
<ConsentNotice onAccept={acceptConsentAndRecord} onCancel={() => (showConsent = false)} />
</div>
{/if}
{#if showSettings}
<Settings onClose={() => (showSettings = false)} />
{/if}
{#if vaultLocked}
<div
class="vault-overlay"
role="dialog"
aria-modal="true"
aria-label="Unlock encryption vault"
tabindex="-1"
use:trapFocus
>
<div class="vault-card">
<h2>Unlock encryption vault</h2>
<p class="muted">Your meetings are encrypted at rest. Enter your password to read them.</p>
<input
type="password"
placeholder="Vault password"
bind:value={vaultPw}
onkeydown={(e) => e.key === "Enter" && submitUnlock()}
/>
{#if vaultErr}<p class="vault-err">{vaultErr}</p>{/if}
<div class="vault-actions">
<button class="primary" onclick={submitUnlock} disabled={!vaultPw}>Unlock</button>
<button class="link" onclick={() => (vaultLocked = false)}>Continue locked</button>
</div>
</div>
</div>
{/if}
<main class="panes">
<aside class="left"><MeetingsList /></aside>
<section class="center"><TranscriptNotes /></section>
<aside class="right"><SummaryPanel /></aside>
</main>
</div>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Design tokens (semantic — never reference raw hex in component styles).
Light/dark variants are designed together (accent, surfaces, text) so
contrast holds in both; every foreground/background pairing below is
checked to >=4.5:1 (WCAG AA) against the surface it's meant to sit on. */
:global(:root) {
/* Render native controls (select option popups, textareas, checkboxes,
scrollbars) in the active theme — without this they default to light,
showing bright-white dropdowns/inputs in dark mode. */
color-scheme: light;
--bg: #ffffff;
--bg-subtle: #f7f8f9;
--bg-elevated: #ffffff;
--bg-hover: #f1f3f5;
--fg: #16181d;
--muted: #6b7280;
--fg-subtle: #9aa1ab;
--border: #e5e7eb;
--border-strong: #d1d5db;
--danger: #b91c1c;
--warning: #b45309;
--success: #15803d;
/* Accent: pick one calm hue and use it everywhere (primary buttons, links,
focus rings, active states) — never a second "brand" color. */
--accent: #047857;
--accent-hover: #036049;
--accent-fg: #ffffff;
--accent-soft: #e3f5ec;
--focus-ring: #047857;
--shadow-sm: 0 1px 2px rgba(16, 24, 32, 0.06);
--shadow-md: 0 4px 12px rgba(16, 24, 32, 0.1);
--shadow-lg: 0 12px 32px rgba(16, 24, 32, 0.16);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-full: 999px;
}
:global([data-theme="dark"]) {
color-scheme: dark;
--bg: #14161a;
--bg-subtle: #191c22;
--bg-elevated: #1c1f26;
--bg-hover: #23262e;
--fg: #e8e9ec;
--muted: #9aa1ab;
--fg-subtle: #6b7280;
--border: #2a2e37;
--border-strong: #383d48;
--danger: #f87171;
--warning: #fbbf24;
--success: #4ade80;
--accent: #34d399;
--accent-hover: #6ee7b7;
--accent-fg: #06120d;
--accent-soft: rgba(52, 211, 153, 0.14);
--focus-ring: #34d399;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24);
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.32);
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.44);
}
/* html/body sit above .app in the DOM and default to a white background
with an 8px browser-UA margin — invisible against a light theme, but a
bright white gap around the whole window in dark mode. data-theme is
mirrored onto <html> (see the $effect above) so these tokens resolve
correctly there too, not just inside .app. */
:global(html),
:global(body) {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg);
color: var(--fg);
font-family:
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
font-size: 14px;
}
:global(*) {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
:global(*::-webkit-scrollbar) {
width: 10px;
height: 10px;
}
:global(*::-webkit-scrollbar-track) {
background: transparent;
}
:global(*::-webkit-scrollbar-thumb) {
background-color: var(--border-strong);
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
:global(*::-webkit-scrollbar-thumb:hover) {
background-color: var(--fg-subtle);
background-clip: padding-box;
}
:global(button) {
font: inherit;
}
:global(:focus-visible) {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.bar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
}
.bar strong {
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.spacer {
flex: 1;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.record-btn,
.stop-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.8rem;
border-radius: var(--radius-full);
border: 1px solid transparent;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 150ms ease-out,
transform 100ms ease-out;
}
.record-btn:active,
.stop-btn:active {
transform: scale(0.97);
}
.record-btn {
background: var(--danger);
color: #ffffff;
}
.record-btn:hover {
background: color-mix(in srgb, var(--danger) 88%, black);
}
.stop-btn {
background: var(--bg-hover);
color: var(--fg);
border-color: var(--border-strong);
}
.stop-btn:hover {
background: var(--border);
}
.cancel-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-full);
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
font-size: 0.85rem;
cursor: pointer;
transition:
color 150ms ease-out,
border-color 150ms ease-out;
}
.cancel-btn:hover {
color: var(--danger);
border-color: var(--danger);
}
.rec {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--danger);
font-size: 0.85rem;
font-weight: 600;
}
.rec-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--danger);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.rec-dot {
animation: none;
}
}
.theme-select {
/* Explicit surface (not `transparent`) so the native options popup inherits
a themed background instead of defaulting to white — matches the elevated
header it sits on, so the closed control looks unchanged. */
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--fg);
border-radius: var(--radius-sm);
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
cursor: pointer;
}
/* Author the option rows too — Chromium renders an author-styled select's
popup in light unless the options carry their own themed colors. */
.theme-select option {
background: var(--bg-elevated);
color: var(--fg);
}
.backend {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 0.15rem 0.5rem;
}
.retention {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
color: var(--muted);
cursor: pointer;
}
.device-notice {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--warning);
font-size: 0.8rem;
}
.consent-overlay {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.5);
display: grid;
place-items: center;
z-index: 60;
}
.icon {
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: var(--radius-sm);
width: 30px;
height: 30px;
padding: 0;
cursor: pointer;
transition: background-color 150ms ease-out;
}
.icon:hover {
background: var(--bg-hover);
color: var(--fg);
}
.panes {
display: grid;
grid-template-columns: 260px 1fr 320px;
flex: 1;
min-height: 0;
}
.left,
.right {
border-color: var(--border);
overflow: auto;
background: var(--bg-subtle);
}
.left {
border-right: 1px solid var(--border);
}
.right {
border-left: 1px solid var(--border);
}
.center {
overflow: auto;
background: var(--bg);
}
.vault-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(10, 12, 16, 0.6);
}
.vault-card {
background: var(--panel, var(--bg));
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
width: min(360px, 90vw);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.vault-card input {
padding: 0.5rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
}
.vault-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vault-err {
color: var(--danger, #dc2626);
margin: 0;
}
</style>