Chore ui #9
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"dompurify": "^3.4.11",
|
||||
@@ -650,7 +651,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -661,7 +661,6 @@
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -672,7 +671,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -682,20 +680,27 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.23.0.tgz",
|
||||
"integrity": "sha512-3LQbKXx9vId6Nx4E2Nu2qwgJfdmr5+CVeVJbxe5cy+HcnCRd9QVVtZXqvgBYAV1OJrPmQAf9/3gJWLCpASC/Ng==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||
@@ -1089,7 +1094,6 @@
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
|
||||
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8.9.0"
|
||||
@@ -1417,7 +1421,6 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -1431,7 +1434,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
@@ -1584,7 +1586,7 @@
|
||||
"version": "8.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
|
||||
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -1720,7 +1722,6 @@
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -1783,7 +1784,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1793,7 +1793,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1864,7 +1863,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -1964,7 +1962,6 @@
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
@@ -2169,7 +2166,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
@@ -2207,7 +2203,6 @@
|
||||
"version": "2.2.13",
|
||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz",
|
||||
"integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
@@ -2459,7 +2454,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -2571,7 +2565,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -2601,7 +2594,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -3110,7 +3102,6 @@
|
||||
"version": "5.56.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -3421,7 +3412,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"dompurify": "^3.4.11",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -1,4 +1,20 @@
|
||||
# Icons
|
||||
|
||||
Placeholder icon set (Phase 1) generated with `npm run tauri icon`. Replace `icon.ico`/`tray.png`
|
||||
(and re-run `npm run tauri icon path/to/source.png`) with real branding before shipping.
|
||||
Real branding — a purple speech-bubble-with-sparkles mark cropped from the
|
||||
WhispAssist logo/promo graphic, background color-keyed to transparent and
|
||||
centered on a 512x512 canvas.
|
||||
|
||||
To regenerate after a new source image: crop just the icon mark (not the
|
||||
full promo graphic — text and fine illustration detail don't read at 16-32px),
|
||||
then run:
|
||||
|
||||
```
|
||||
npm run tauri icon path/to/mark.png
|
||||
```
|
||||
|
||||
This only targets Windows (no iOS/Android/Appx bundle here), so delete
|
||||
`android/`, `ios/`, `icon.icns`, `Square*Logo.png`, and `StoreLogo.png` if
|
||||
`tauri icon` regenerates them — it defaults to generating icons for every
|
||||
platform regardless of what this project actually targets. `tray.png` is a
|
||||
manual 32x32 export (not one of `tauri icon`'s own output names) referenced
|
||||
directly by `lib.rs`'s system tray setup.
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -13,6 +13,8 @@
|
||||
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, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
@@ -32,6 +34,14 @@
|
||||
: 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();
|
||||
@@ -145,17 +155,28 @@
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="record-btn"
|
||||
onclick={startRecording}
|
||||
title="Start recording (Ctrl+Shift+R)"
|
||||
aria-keyshortcuts="Control+Shift+R">● Record</button
|
||||
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">■ Stop</button
|
||||
aria-keyshortcuts="Control+Shift+R"
|
||||
>
|
||||
<span class="rec">Recording…</span>
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
</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>
|
||||
@@ -166,28 +187,24 @@
|
||||
</label>
|
||||
{#if recording.deviceNotice}
|
||||
<span class="device-notice" role="status" title={recording.deviceNotice}>
|
||||
⚠ reconnecting audio device…
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
reconnecting audio device…
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<select
|
||||
class="theme-select"
|
||||
aria-label="Theme"
|
||||
title="Theme"
|
||||
value={settings.settings.theme}
|
||||
onchange={(e) => settings.patch({ theme: (e.target as HTMLSelectElement).value })}
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<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)}>⚙</button
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
>
|
||||
<SettingsIcon size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showConsent}
|
||||
@@ -226,24 +243,71 @@
|
||||
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) {
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a1a;
|
||||
--bg-subtle: #f7f8f9;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #f1f3f5;
|
||||
--fg: #16181d;
|
||||
--muted: #6b7280;
|
||||
--fg-subtle: #9aa1ab;
|
||||
--border: #e5e7eb;
|
||||
/* Status colors tuned per-theme for WCAG AA (4.5:1) against --bg. */
|
||||
--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"]) {
|
||||
--bg: #0f1115;
|
||||
--fg: #e6e6e6;
|
||||
--muted: #9aa0a6;
|
||||
--border: #242832;
|
||||
--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;
|
||||
@@ -251,14 +315,52 @@
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: system-ui, sans-serif;
|
||||
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.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
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;
|
||||
@@ -267,16 +369,74 @@
|
||||
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);
|
||||
}
|
||||
.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 {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
@@ -284,38 +444,51 @@
|
||||
.backend {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
.retention {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
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(0, 0, 0, 0.45);
|
||||
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(--fg);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
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;
|
||||
@@ -327,6 +500,7 @@
|
||||
.right {
|
||||
border-color: var(--border);
|
||||
overflow: auto;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.left {
|
||||
border-right: 1px solid var(--border);
|
||||
@@ -336,5 +510,6 @@
|
||||
}
|
||||
.center {
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
// One-time recording-consent notice (ADR-0009, FR-REC-2). Shared by the
|
||||
// Settings "Record by default" toggle and the mid-meeting retention toggle
|
||||
// so both gate on the same copy and the same acknowledgment call.
|
||||
import { ShieldAlert } from "@lucide/svelte";
|
||||
let { onAccept, onCancel }: { onAccept: () => void; onCancel: () => void } = $props();
|
||||
</script>
|
||||
|
||||
<div class="consent">
|
||||
<div class="heading">
|
||||
<ShieldAlert size={18} aria-hidden="true" />
|
||||
<strong>Before you record</strong>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Before you record:</strong> recording conversations without the consent of participants may
|
||||
be illegal in your region. Check your local recording laws. This is a caution, not legal advice.
|
||||
Recording conversations without the consent of participants may be illegal in your region. Check
|
||||
your local recording laws. This is a caution, not legal advice.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onAccept}>I understand — enable recording</button>
|
||||
@@ -19,26 +24,48 @@
|
||||
<style>
|
||||
.consent {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
max-width: 420px;
|
||||
}
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--warning);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.heading strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.6rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
button.primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid transparent;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
// Segmented System/Light/Dark control (T7.2, FR-UX-2), replacing the old
|
||||
// plain <select> — three icon buttons instead of a text dropdown, defaults
|
||||
// to "system" so the app follows the OS until the user picks an override.
|
||||
import { Monitor, Sun, Moon } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
value: "system" | "light" | "dark";
|
||||
onchange: (value: "system" | "light" | "dark") => void;
|
||||
}
|
||||
let { value, onchange }: Props = $props();
|
||||
|
||||
const options = [
|
||||
{ id: "system", label: "Match system", Icon: Monitor },
|
||||
{ id: "light", label: "Light", Icon: Sun },
|
||||
{ id: "dark", label: "Dark", Icon: Moon },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="toggle" role="radiogroup" aria-label="Theme">
|
||||
{#each options as opt (opt.id)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={value === opt.id}
|
||||
class:active={value === opt.id}
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
onclick={() => onchange(opt.id)}
|
||||
>
|
||||
<opt.Icon size={15} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease-out,
|
||||
color 150ms ease-out;
|
||||
}
|
||||
button:hover {
|
||||
color: var(--fg);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
button.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
import { recording } from "../stores/recording.svelte";
|
||||
import { api, type MeetingListItem, type SearchHit } from "../api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Search, Trash2, Download, RotateCcw } from "@lucide/svelte";
|
||||
|
||||
let query = $state("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -100,12 +101,15 @@
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<input
|
||||
placeholder="Search meetings…"
|
||||
aria-label="Search meetings"
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
<div class="search-box">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input
|
||||
placeholder="Search meetings…"
|
||||
aria-label="Search meetings"
|
||||
bind:value={query}
|
||||
oninput={onSearchInput}
|
||||
/>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select aria-label="Filter by tag" bind:value={tagFilter} onchange={onFilterChange}>
|
||||
<option value="">All tags</option>
|
||||
@@ -124,10 +128,12 @@
|
||||
<option value="bundle">bundle</option>
|
||||
</select>
|
||||
<button
|
||||
class="bulk-btn"
|
||||
onclick={bulkExport}
|
||||
disabled={bulkExporting}
|
||||
title="Export every meeting matching the tag/date filters above"
|
||||
>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{bulkExporting ? "Exporting…" : "Bulk export"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -141,7 +147,7 @@
|
||||
{:else if displayItems.length === 0 && meetings.searchResults !== null}
|
||||
<p class="muted">No matches.</p>
|
||||
{:else if displayItems.length === 0}
|
||||
<p class="muted">No meetings yet. Click ● Record to start.</p>
|
||||
<p class="muted">No meetings yet — click Record above to start.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each displayItems as m (m.id)}
|
||||
@@ -174,16 +180,18 @@
|
||||
</button>
|
||||
<div class="row actions">
|
||||
{#if m.status === "recovering"}
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}
|
||||
>Resume transcription</button
|
||||
>
|
||||
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
Resume transcription
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="link danger"
|
||||
onclick={(e) => removeMeeting(e, m.id)}
|
||||
aria-label="Delete meeting"
|
||||
title="Delete meeting"
|
||||
>
|
||||
🗑
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -196,11 +204,34 @@
|
||||
.wrap {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.search-box :global(svg) {
|
||||
position: absolute;
|
||||
left: 0.55rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--fg-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-box input {
|
||||
padding-left: 1.9rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.4rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
@@ -220,7 +251,7 @@
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.filters select,
|
||||
@@ -228,24 +259,34 @@
|
||||
.filters button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.25rem;
|
||||
padding: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.bulk-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bulk-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.tags {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--muted) 15%, transparent);
|
||||
color: var(--muted);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
@@ -253,11 +294,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
li {
|
||||
padding: 0.5rem 0.4rem;
|
||||
padding: 0.55rem 0.7rem 0.55rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-left: 2px solid transparent;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
li:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
li.active {
|
||||
background: color-mix(in srgb, #2563eb 12%, transparent);
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.item-btn {
|
||||
display: block;
|
||||
@@ -280,11 +327,13 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge.live {
|
||||
@@ -300,11 +349,14 @@
|
||||
border-color: var(--muted);
|
||||
}
|
||||
.actions {
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.3rem;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { BackendId, SyncKind, SyncTargetConfig } from "../api";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Mic,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
CalendarDays,
|
||||
UploadCloud,
|
||||
ShieldCheck,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
@@ -49,7 +60,7 @@
|
||||
return Math.round((p.received / p.total) * 100);
|
||||
}
|
||||
let showConsent = $state(false);
|
||||
let testResult = $state<string | null>(null);
|
||||
let testResult = $state<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
settings.load();
|
||||
@@ -113,7 +124,7 @@
|
||||
}
|
||||
async function testConnection() {
|
||||
const r = await settings.test(form);
|
||||
testResult = (r.ok ? "✓ " : "✗ ") + r.message;
|
||||
testResult = { ok: r.ok, message: r.message };
|
||||
}
|
||||
async function addTarget() {
|
||||
await settings.addTarget(form);
|
||||
@@ -138,24 +149,28 @@
|
||||
<header>
|
||||
<strong>Settings</strong>
|
||||
<nav>
|
||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}
|
||||
>Recording</button
|
||||
>
|
||||
<button class:active={section === "hardware"} onclick={() => (section = "hardware")}
|
||||
>Hardware</button
|
||||
>
|
||||
<button class:active={section === "storage"} onclick={() => (section = "storage")}
|
||||
>Storage</button
|
||||
>
|
||||
<button class:active={section === "calendar"} onclick={() => (section = "calendar")}
|
||||
>Calendar</button
|
||||
>
|
||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
||||
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}
|
||||
>Privacy</button
|
||||
>
|
||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}>
|
||||
<Mic size={14} aria-hidden="true" /> Recording
|
||||
</button>
|
||||
<button class:active={section === "hardware"} onclick={() => (section = "hardware")}>
|
||||
<Cpu size={14} aria-hidden="true" /> Hardware
|
||||
</button>
|
||||
<button class:active={section === "storage"} onclick={() => (section = "storage")}>
|
||||
<HardDrive size={14} aria-hidden="true" /> Storage
|
||||
</button>
|
||||
<button class:active={section === "calendar"} onclick={() => (section = "calendar")}>
|
||||
<CalendarDays size={14} aria-hidden="true" /> Calendar
|
||||
</button>
|
||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>
|
||||
<UploadCloud size={14} aria-hidden="true" /> Sync
|
||||
</button>
|
||||
<button class:active={section === "privacy"} onclick={() => (section = "privacy")}>
|
||||
<ShieldCheck size={14} aria-hidden="true" /> Privacy
|
||||
</button>
|
||||
</nav>
|
||||
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
||||
<button class="close" onclick={onClose} aria-label="Close" title="Close (Esc)">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if settings.backendStub && section === "sync"}
|
||||
@@ -449,11 +464,20 @@
|
||||
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
|
||||
>Add target</button
|
||||
>
|
||||
{#if testResult}<span class="test">{testResult}</span>{/if}
|
||||
{#if testResult}
|
||||
<span class="test" class:ok={testResult.ok} class:fail={!testResult.ok}>
|
||||
{#if testResult.ok}<Check size={14} aria-hidden="true" />{:else}<X
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
{testResult.message}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="banner">
|
||||
⚠ {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
{form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label class="wide">Name<input bind:value={form.name} placeholder={form.kind} /></label>
|
||||
@@ -494,7 +518,10 @@
|
||||
|
||||
<h4>Egress allowlist</h4>
|
||||
{#if settings.privacy.allowlistedHosts.length === 0}
|
||||
<p class="muted confirm">✓ No hosts are allowlisted — WA makes no content egress.</p>
|
||||
<p class="muted confirm">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
No hosts are allowlisted — WA makes no content egress.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="hosts">
|
||||
{#each settings.privacy.allowlistedHosts as host (host)}
|
||||
@@ -532,25 +559,26 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(10, 12, 16, 0.5);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
.panel {
|
||||
background: var(--bg);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--fg);
|
||||
width: min(720px, 92vw);
|
||||
max-height: 88vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
@@ -563,25 +591,44 @@
|
||||
}
|
||||
nav button,
|
||||
.kinds button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
nav button:hover,
|
||||
.kinds button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
nav button.active,
|
||||
.kinds button.active {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: transparent;
|
||||
}
|
||||
.close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
@@ -687,13 +734,21 @@
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.banner {
|
||||
background: color-mix(in srgb, var(--danger) 14%, var(--bg));
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--warning) 14%, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.banner :global(svg) {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--warning);
|
||||
}
|
||||
ul.targets,
|
||||
ul.models,
|
||||
ul.events {
|
||||
@@ -764,10 +819,22 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
.test {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.test.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.test.fail {
|
||||
color: var(--danger);
|
||||
}
|
||||
.confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--success);
|
||||
}
|
||||
ul.hosts {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { calendar } from "../stores/calendar.svelte";
|
||||
import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { Tags, Sparkles, ListTodo, CalendarDays, Users, Mic2, Bell } from "@lucide/svelte";
|
||||
|
||||
onMount(() => calendar.load());
|
||||
|
||||
@@ -131,7 +132,7 @@
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<h3>Tags</h3>
|
||||
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
{:else}
|
||||
@@ -152,7 +153,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<h3>Summary</h3>
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to generate a summary.</p>
|
||||
{:else if meetings.summarizingId === meetings.selected.id}
|
||||
@@ -178,14 +179,17 @@
|
||||
{#if llmStatus && llmStatus.provider === "off"}
|
||||
<p class="muted small">No local LLM provider configured — enable one in Settings.</p>
|
||||
{:else}
|
||||
<button onclick={generateSummary}>Generate summary</button>
|
||||
<button class="primary" onclick={generateSummary}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Generate summary
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if meetings.summaryError}
|
||||
<p class="error">{meetings.summaryError}</p>
|
||||
{/if}
|
||||
|
||||
<h3>Action items</h3>
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to see its action items.</p>
|
||||
{:else if editableItems.length === 0}
|
||||
@@ -208,7 +212,7 @@
|
||||
/>
|
||||
<label class="remind" title="Schedule a local reminder for this due date">
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
🔔
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -218,7 +222,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<h3>Calendar event</h3>
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> Calendar event</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to link it to a calendar event.</p>
|
||||
{:else}
|
||||
@@ -252,7 +256,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3>Participants</h3>
|
||||
<h3><Users size={14} aria-hidden="true" /> Participants</h3>
|
||||
{#if !eventDetail || eventDetail.participants.length === 0}
|
||||
<p class="muted">Populated from the linked calendar event.</p>
|
||||
{:else}
|
||||
@@ -266,7 +270,7 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h3>Speakers</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Speakers</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to name its speakers.</p>
|
||||
{:else if meetings.selected.speakers.length === 0}
|
||||
@@ -309,8 +313,38 @@
|
||||
padding: 0.75rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 0.9rem;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin: 1.1rem 0 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
button.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
@@ -357,11 +391,17 @@
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem;
|
||||
padding: 0.2rem;
|
||||
max-width: 8.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.remind {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.remind:has(input:checked) {
|
||||
color: var(--warning);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
import { api, type SpeakerInfo } from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Heading1,
|
||||
Heading2,
|
||||
List,
|
||||
ListChecks,
|
||||
FileDown,
|
||||
FileText,
|
||||
FolderOutput,
|
||||
RefreshCw,
|
||||
MessageSquareText,
|
||||
NotebookPen,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||
@@ -116,7 +130,7 @@
|
||||
{@const m = meetings.selected}
|
||||
<div class="split">
|
||||
<div class="pane transcript">
|
||||
<h4>Transcript</h4>
|
||||
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
|
||||
{#if m.recorded && settings.models.some((mo) => mo.installed)}
|
||||
<div class="reprocess">
|
||||
<select bind:value={reprocessModel}>
|
||||
@@ -126,6 +140,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
<button disabled={!reprocessModel || reprocessing} onclick={reprocess}>
|
||||
<RefreshCw size={13} aria-hidden="true" class={reprocessing ? "spin" : ""} />
|
||||
{reprocessing ? "Re-transcribing…" : "Go"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -139,31 +154,50 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="pane notes">
|
||||
<h4><NotebookPen size={14} aria-hidden="true" /> Notes</h4>
|
||||
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold"
|
||||
><b>B</b></button
|
||||
>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic"
|
||||
><i>I</i></button
|
||||
>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1">H1</button>
|
||||
<button onclick={() => insertLinePrefix("## ")} title="Heading 2">H2</button>
|
||||
<button onclick={() => wrapSelection("**")} title="Bold" aria-label="Bold">
|
||||
<Bold size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => wrapSelection("_")} title="Italic" aria-label="Italic">
|
||||
<Italic size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("# ")} title="Heading 1" aria-label="Heading 1">
|
||||
<Heading1 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button onclick={() => insertLinePrefix("## ")} title="Heading 2" aria-label="Heading 2">
|
||||
<Heading2 size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- ")}
|
||||
title="Bullet list"
|
||||
aria-label="Bullet list">•</button
|
||||
aria-label="Bullet list"
|
||||
>
|
||||
<List size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => insertLinePrefix("- [ ] ")}
|
||||
title="Checkbox"
|
||||
aria-label="Checkbox list item">☑</button
|
||||
aria-label="Checkbox list item"
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
|
||||
<button onclick={exportPdf} title="Export notes as .pdf">Export PDF</button>
|
||||
<button onclick={exportDocx} title="Export notes as .docx">Export Word</button>
|
||||
<button onclick={exportMd} title="Export notes as .md">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
.md
|
||||
</button>
|
||||
<button onclick={exportPdf} title="Export notes as .pdf">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
PDF
|
||||
</button>
|
||||
<button onclick={exportDocx} title="Export notes as .docx">
|
||||
<FileDown size={13} aria-hidden="true" />
|
||||
Word
|
||||
</button>
|
||||
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||
Export bundle
|
||||
<FolderOutput size={13} aria-hidden="true" />
|
||||
Bundle
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
@@ -226,8 +260,14 @@
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.pane h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.reprocess {
|
||||
@@ -238,29 +278,52 @@
|
||||
}
|
||||
.reprocess select,
|
||||
.reprocess button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.spin) {
|
||||
animation: spin 900ms linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.spin) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.toolbar button:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.toolbar .spacer {
|
||||
flex: 1;
|
||||
@@ -277,12 +340,16 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
textarea:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.preview {
|
||||
overflow: auto;
|
||||
|
||||