649 lines
19 KiB
Svelte
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>
|