Chore ui #9

Merged
iamdoubz merged 12 commits from chore_ui into main 2026-07-02 13:40:00 -05:00
18 changed files with 670 additions and 163 deletions
+11 -21
View File
@@ -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"
}
}
+1
View File
@@ -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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+18 -2
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+208 -33
View File
@@ -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>
+37 -10
View File
@@ -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;
}
+1 -1
View File
@@ -27,7 +27,7 @@
position: relative;
width: 60px;
height: 8px;
border-radius: 4px;
border-radius: var(--radius-full);
background: var(--border);
overflow: hidden;
}
+72
View File
@@ -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>
+77 -25
View File
@@ -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;
+101 -34
View File
@@ -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 {
+52 -12
View File
@@ -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);
+92 -25
View File
@@ -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;