diff --git a/.doctor/dupe-ignore b/.doctor/dupe-ignore index f160f98..c858103 100644 --- a/.doctor/dupe-ignore +++ b/.doctor/dupe-ignore @@ -23,6 +23,7 @@ MAGIC:ghost MAGIC:secondary MAGIC:accent MAGIC:default +MAGIC:link # Config key — only 2 files, not worth extracting MAGIC:sttModel diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..7b3ff5d --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,21 @@ +{ + "rules": { + "at-rule-no-unknown": [ + true, + { + "ignoreAtRules": [ + "theme", + "apply", + "variant", + "custom-variant", + "utility", + "source", + "reference", + "config", + "plugin", + "tailwind" + ] + } + ] + } +} diff --git a/src-tauri/src/agent/pipeline.rs b/src-tauri/src/agent/pipeline.rs index feaf03e..6a60644 100644 --- a/src-tauri/src/agent/pipeline.rs +++ b/src-tauri/src/agent/pipeline.rs @@ -245,7 +245,7 @@ fn process_recording(app_handle: &AppHandle, context: RecordingContext) -> Resul // Auto-prune if size-based retention is configured if let Ok(Some(retention)) = db.get_config("transcriptRetention") { - if let Some(max_bytes) = parse_retention_bytes(&retention) { + if let Some(max_bytes) = crate::storage::transcripts::parse_retention_bytes(&retention) { match db.prune_transcripts(max_bytes) { Ok(n) if n > 0 => log::info!("Pruned {n} old transcripts (retention: {retention})"), Err(e) => log::error!("Failed to prune transcripts: {e}"), @@ -485,15 +485,12 @@ fn hot_swap_llm(app_handle: &AppHandle, model_id: &str) -> Result<(), AppError> } fn emit_state(app_handle: &AppHandle, state: &str) -> Result<(), tauri::Error> { - app_handle.emit("agent-state-changed", serde_json::json!({ "state": state })) -} - -fn parse_retention_bytes(value: &str) -> Option { - match value { - "100mb" => Some(104_857_600), - "500mb" => Some(524_288_000), - "1gb" => Some(1_073_741_824), - "5gb" => Some(5_368_709_120), - _ => None, + // Surface the widget while a recording is active (even when hidden), then + // restore the user's configured visibility once we settle back to idle. + match state { + "recording" => crate::window::widget::show(app_handle), + "idle" => crate::window::widget::restore_visibility(app_handle), + _ => {} } + app_handle.emit("agent-state-changed", serde_json::json!({ "state": state })) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 746db49..3345cc4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -176,7 +176,7 @@ pub fn run() { if onboarding_done { if let Some(widget) = app.get_webview_window("widget") { - let (position, size) = { + let (position, size, visible) = { let db = state.db.lock().unwrap(); let pos = db .get_config("widgetPosition") @@ -188,11 +188,20 @@ pub fn run() { .ok() .flatten() .unwrap_or_else(|| "medium".to_string()); - (pos, sz) + let vis = db + .get_config("widgetVisible") + .ok() + .flatten() + .map_or(true, |v| v == "true"); + (pos, sz, vis) }; let _ = window::widget::apply_position_and_size(&widget, &position, &size); - let _ = widget.show(); + // Respect the user's hide preference at startup. When hidden, + // the pipeline still surfaces the widget during recording. + if visible { + let _ = widget.show(); + } } // Load STT model in background — but only if the user is on diff --git a/src-tauri/src/storage/transcripts.rs b/src-tauri/src/storage/transcripts.rs index f2371e4..644cee6 100644 --- a/src-tauri/src/storage/transcripts.rs +++ b/src-tauri/src/storage/transcripts.rs @@ -73,3 +73,15 @@ pub trait TranscriptStore { fn get_db_size(&self) -> Result; fn prune_transcripts(&self, max_bytes: i64) -> Result; } + +/// Map a size-based retention setting (e.g. `"500mb"`) to its byte budget. +/// Returns `None` for unbounded / unrecognized values. +pub fn parse_retention_bytes(value: &str) -> Option { + match value { + "100mb" => Some(104_857_600), + "500mb" => Some(524_288_000), + "1gb" => Some(1_073_741_824), + "5gb" => Some(5_368_709_120), + _ => None, + } +} diff --git a/src-tauri/src/window/widget.rs b/src-tauri/src/window/widget.rs index 920df14..df02234 100644 --- a/src-tauri/src/window/widget.rs +++ b/src-tauri/src/window/widget.rs @@ -1,5 +1,75 @@ use crate::error::AppError; -use tauri::{LogicalSize, Manager, PhysicalPosition, WebviewWindow}; +use crate::state::AppState; +use crate::storage::config::ConfigStore; +use tauri::{AppHandle, LogicalSize, Manager, PhysicalPosition, WebviewWindow}; + +/// Force the widget visible (used when a recording starts), WITHOUT stealing +/// keyboard focus from the user's active app. On macOS the widget is an +/// `NSPanel`; a plain `show()` calls `makeKeyAndOrderFront`, which would steal +/// focus and break paste injection — so we order it front "regardless" instead. +/// +/// NSPanel/AppKit calls must happen on the main thread. The pipeline drives +/// this from the hotkey listener thread, so we hop to the main thread first — +/// calling AppKit off-main crashes the process. +pub fn show(app_handle: &AppHandle) { + let app = app_handle.clone(); + if let Err(e) = app_handle.run_on_main_thread(move || { + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt; + if let Ok(panel) = app.get_webview_panel("widget") { + if !panel.is_visible() { + panel.order_front_regardless(); + } + return; + } + } + + if let Some(widget) = app.get_webview_window("widget") { + if !widget.is_visible().unwrap_or(false) { + let _ = widget.show(); + } + } + }) { + log::error!("Failed to dispatch widget show to main thread: {e}"); + } +} + +/// Restore the widget to the user's configured visibility. Called when the +/// pipeline returns to idle, so a hidden widget that we surfaced for recording +/// gets hidden again. The DB read is thread-safe; the AppKit hide is dispatched +/// to the main thread (see [`show`]). +pub fn restore_visibility(app_handle: &AppHandle) { + let state = app_handle.state::(); + let visible = state + .db + .lock() + .ok() + .and_then(|db| db.get_config("widgetVisible").ok().flatten()) + .map_or(true, |v| v == "true"); + + if visible { + return; + } + + let app = app_handle.clone(); + if let Err(e) = app_handle.run_on_main_thread(move || { + #[cfg(target_os = "macos")] + { + use tauri_nspanel::ManagerExt; + if let Ok(panel) = app.get_webview_panel("widget") { + panel.order_out(None); + return; + } + } + + if let Some(widget) = app.get_webview_window("widget") { + let _ = widget.hide(); + } + }) { + log::error!("Failed to dispatch widget hide to main thread: {e}"); + } +} pub fn apply_position_and_size( widget: &WebviewWindow, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 263e9c7..5f065ad 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,8 +21,10 @@ "minHeight": 500, "center": true, "resizable": true, - "decorations": false, - "transparent": true, + "decorations": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "transparent": false, "visible": true, "url": "index.html" }, diff --git a/src/dashboard/dashboard-page.tsx b/src/dashboard/dashboard-page.tsx index c4adb5d..12c24c3 100644 --- a/src/dashboard/dashboard-page.tsx +++ b/src/dashboard/dashboard-page.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import PageHeader from "../shared/components/page-header"; import PeriodSelector from "../shared/components/period-selector"; import statPeriods from "./activity/stat-periods"; import periodToDays from "./activity/period-to-days"; @@ -11,13 +12,16 @@ export default function DashboardPage() { return (
-

Dashboard

+ -
+
-

Activity

+

Activity

-

Recent Transcripts

+

+ Recent Transcripts +

diff --git a/src/dashboard/stats/stat-cards.ts b/src/dashboard/stats/stat-cards.ts new file mode 100644 index 0000000..5cf51fc --- /dev/null +++ b/src/dashboard/stats/stat-cards.ts @@ -0,0 +1,19 @@ +import { Type, Gauge, Mic, Clock, type LucideIcon } from "lucide-react"; +import formatNumber from "../../shared/lib/utils/format-number"; +import formatMinutes from "../../shared/lib/utils/format-minutes"; +import type { TranscriptStats } from "../../shared/types/transcript"; + +export interface StatCard { + label: string; + icon: LucideIcon; + value: (stats: TranscriptStats) => string; +} + +const statCards: StatCard[] = [ + { label: "words total", icon: Type, value: (s) => formatNumber(s.totalWords) }, + { label: "w/sec avg", icon: Gauge, value: (s) => s.avgWordsPerSecond.toFixed(1) }, + { label: "transcriptions", icon: Mic, value: (s) => formatNumber(s.totalTranscripts) }, + { label: "time saved", icon: Clock, value: (s) => formatMinutes(s.timeSavedMinutes) }, +]; + +export default statCards; diff --git a/src/dashboard/stats/stat-placeholders.ts b/src/dashboard/stats/stat-placeholders.ts deleted file mode 100644 index 75134cf..0000000 --- a/src/dashboard/stats/stat-placeholders.ts +++ /dev/null @@ -1,8 +0,0 @@ -const statPlaceholders = [ - { value: "\u2014", label: "words total" }, - { value: "\u2014", label: "w/sec avg" }, - { value: "\u2014", label: "transcriptions" }, - { value: "\u2014", label: "time saved" }, -]; - -export default statPlaceholders; diff --git a/src/dashboard/stats/stats-row.tsx b/src/dashboard/stats/stats-row.tsx index 73fee36..92876dd 100644 --- a/src/dashboard/stats/stats-row.tsx +++ b/src/dashboard/stats/stats-row.tsx @@ -2,9 +2,7 @@ import { useEffect, useState } from "react"; import Card from "../../shared/components/card"; import { getTranscriptStats } from "../../shared/lib/tauri-commands"; import type { TranscriptStats } from "../../shared/types/transcript"; -import formatNumber from "../../shared/lib/utils/format-number"; -import formatMinutes from "../../shared/lib/utils/format-minutes"; -import statPlaceholders from "./stat-placeholders"; +import statCards from "./stat-cards"; export default function StatsRow() { const [stats, setStats] = useState(null); @@ -13,23 +11,17 @@ export default function StatsRow() { getTranscriptStats().then(setStats).catch(console.error); }, []); - const items = stats - ? [ - { value: formatNumber(stats.totalWords), label: "words total" }, - { value: `${stats.avgWordsPerSecond.toFixed(1)}`, label: "w/sec avg" }, - { value: formatNumber(stats.totalTranscripts), label: "transcriptions" }, - { value: formatMinutes(stats.timeSavedMinutes), label: "time saved" }, - ] - : statPlaceholders; - return ( -
- {items.map((stat) => ( - -
- {stat.value} +
+ {statCards.map(({ label, icon: Icon, value }) => ( + +
+ +
+
+ {stats ? value(stats) : "—"}
-
{stat.label}
+
{label}
))}
diff --git a/src/index.css b/src/index.css index 1bbebb5..d0206e2 100644 --- a/src/index.css +++ b/src/index.css @@ -16,9 +16,25 @@ --color-warning: #f59e0b; --color-recording: #ef4444; --color-border: #e5e5e0; + + /* Elevation scale — each pairs a hairline ring with a soft shadow so cards + and overlays blend on any background instead of showing a harsh border. + Maps to `shadow-card` / `shadow-popover` / `shadow-overlay` utilities. */ + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 0.5px rgba(0, 0, 0, 0.03); + --shadow-popover: 0 8px 28px -6px rgba(0, 0, 0, 0.16), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-overlay: 0 16px 48px -12px rgba(0, 0, 0, 0.22), 0 0 0 0.5px rgba(0, 0, 0, 0.05); } @layer base { + /* Fixed z-index scale — reference with `z-[var(--z-modal)]` etc. so stacking + order is intentional instead of a scatter of arbitrary values. */ + :root { + --z-dropdown: 100; + --z-modal: 200; + --z-tooltip: 300; + --z-toast: 400; + } + html, body { height: 100%; @@ -28,17 +44,91 @@ font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } #root { height: 100%; } + /* Tighter, balanced headings — large text wants negative tracking. */ + h1, + h2, + h3 { + text-wrap: balance; + letter-spacing: -0.011em; + } + + /* Dynamic figures (stats, counters, durations) must not jitter as they + change width. Opt in with `tabular-nums`. */ + .tabular-nums { + font-variant-numeric: tabular-nums; + } + + /* Subtle entrance for popovers/menus — origin-aware, ease-out. */ + @keyframes pop-in { + from { + opacity: 0; + transform: scale(0.96) translateY(-2px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } + } + + .animate-pop-in { + animation: pop-in 0.14s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top; + } + + /* Backdrop fade + centered dialog entrance. */ + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes modal-in { + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .animate-fade-in { + animation: fade-in 0.15s ease-out; + } + + .animate-modal-in { + animation: modal-in 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Respect the user's motion preference everywhere by default. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } + #root[data-window="main"] { background-color: var(--color-bg-primary); - border-radius: 12px; + /* Native window chrome (decorations + Overlay titlebar) draws the frame, + corners and shadow now — no custom border/radius needed. */ overflow: hidden; - border: 1px solid var(--color-border); } /* Scrollbar styling */ diff --git a/src/layout/main-layout.tsx b/src/layout/main-layout.tsx index 0e6eeaa..b22736d 100644 --- a/src/layout/main-layout.tsx +++ b/src/layout/main-layout.tsx @@ -5,7 +5,7 @@ export default function MainLayout() { return (
-
+
diff --git a/src/layout/sidebar.tsx b/src/layout/sidebar.tsx index f7fc58a..6e9c401 100644 --- a/src/layout/sidebar.tsx +++ b/src/layout/sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from "react-router-dom"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/plugin-shell"; import cn from "../shared/lib/utils/cn"; +import Button from "../shared/components/button/button"; import appVersion from "../shared/constants/app-version"; import useUpdateCheck from "../shared/hooks/use-update-check"; import sidebarNavItems from "./sidebar-nav-items"; @@ -11,20 +11,12 @@ export default function Sidebar() { return ( diff --git a/src/onboarding/onboarding-layout.tsx b/src/onboarding/onboarding-layout.tsx index b3d6bdc..ecd2661 100644 --- a/src/onboarding/onboarding-layout.tsx +++ b/src/onboarding/onboarding-layout.tsx @@ -50,7 +50,7 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) if (!steps) { return (
- Loading... + Loading…
); } @@ -84,8 +84,10 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps)
{step.label} @@ -115,7 +117,7 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) @@ -131,11 +133,11 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) +
Report an issue - +
diff --git a/src/settings/data/data-section.tsx b/src/settings/data/data-section.tsx index bdcde17..3515178 100644 --- a/src/settings/data/data-section.tsx +++ b/src/settings/data/data-section.tsx @@ -54,7 +54,7 @@ export default function DataSection() { return (
-

Data

+

Data

Transcript retention diff --git a/src/settings/debug-section.tsx b/src/settings/debug-section.tsx index 92ebd88..89e0cd2 100644 --- a/src/settings/debug-section.tsx +++ b/src/settings/debug-section.tsx @@ -11,7 +11,7 @@ export default function DebugSection() { return (
-

Debugging

+

Debugging

diff --git a/src/settings/general/general-section.tsx b/src/settings/general/general-section.tsx index f925574..3bd004e 100644 --- a/src/settings/general/general-section.tsx +++ b/src/settings/general/general-section.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import Card from "../../shared/components/card"; import Toggle from "../../shared/components/toggle"; +import Button from "../../shared/components/button/button"; import { setConfig, enableAutostart, @@ -42,7 +43,7 @@ export default function GeneralSection() { return (
-

General

+

General

@@ -53,12 +54,13 @@ export default function GeneralSection() {
- +
@@ -69,6 +71,7 @@ export default function GeneralSection() {
@@ -76,6 +79,7 @@ export default function GeneralSection() {
Launch at startup diff --git a/src/settings/llm/llm-model-download-row.tsx b/src/settings/llm/llm-model-download-row.tsx index 91776a8..639a7ef 100644 --- a/src/settings/llm/llm-model-download-row.tsx +++ b/src/settings/llm/llm-model-download-row.tsx @@ -80,7 +80,7 @@ export default function LlmModelDownloadRow({
diff --git a/src/settings/llm/llm-section.tsx b/src/settings/llm/llm-section.tsx index 9ec4ba9..0e604d6 100644 --- a/src/settings/llm/llm-section.tsx +++ b/src/settings/llm/llm-section.tsx @@ -45,7 +45,7 @@ export default function LlmSection() { return (
-

LLM Refactoring

+

LLM Refactoring

diff --git a/src/settings/settings-page.tsx b/src/settings/settings-page.tsx index 3c8dd27..8d4522f 100644 --- a/src/settings/settings-page.tsx +++ b/src/settings/settings-page.tsx @@ -1,3 +1,4 @@ +import PageHeader from "../shared/components/page-header"; import GeneralSection from "./general/general-section"; import SpeechSection from "./speech/speech-section"; import LlmSection from "./llm/llm-section"; @@ -9,7 +10,10 @@ import DebugSection from "./debug-section"; export default function SettingsPage() { return (
-

Settings

+ diff --git a/src/settings/speech/speech-model-modal.tsx b/src/settings/speech/speech-model-modal.tsx index 6af55cd..6c7576d 100644 --- a/src/settings/speech/speech-model-modal.tsx +++ b/src/settings/speech/speech-model-modal.tsx @@ -120,7 +120,7 @@ export default function SpeechModelModal({ > {downloading && ( )} diff --git a/src/settings/speech/speech-section.tsx b/src/settings/speech/speech-section.tsx index 95686ac..5a76c24 100644 --- a/src/settings/speech/speech-section.tsx +++ b/src/settings/speech/speech-section.tsx @@ -87,7 +87,7 @@ export default function SpeechSection() { return (
-

Speech Recognition

+

Speech Recognition

diff --git a/src/settings/widget/widget-section.tsx b/src/settings/widget/widget-section.tsx index a43cea0..0128aca 100644 --- a/src/settings/widget/widget-section.tsx +++ b/src/settings/widget/widget-section.tsx @@ -28,11 +28,12 @@ export default function WidgetSection() { return (
-

Widget

+

Widget

Show widget { setVisible(v); diff --git a/src/shared/components/badge/badge.tsx b/src/shared/components/badge/badge.tsx index af97278..0ab18ec 100644 --- a/src/shared/components/badge/badge.tsx +++ b/src/shared/components/badge/badge.tsx @@ -11,7 +11,7 @@ export default function Badge({ children, variant = "default" }: BadgeProps) { return ( diff --git a/src/shared/components/button/button-size-styles.ts b/src/shared/components/button/button-size-styles.ts index 857928c..649a30f 100644 --- a/src/shared/components/button/button-size-styles.ts +++ b/src/shared/components/button/button-size-styles.ts @@ -1,4 +1,6 @@ const buttonSizeStyles: Record = { + // No box chrome — for variant="link" sitting inside flowing text. + inline: "text-sm", sm: "h-8 px-3 text-[13px] rounded-lg", md: "h-9 px-4 text-sm rounded-lg", lg: "h-11 px-6 text-sm rounded-lg", diff --git a/src/shared/components/button/button-variant-styles.ts b/src/shared/components/button/button-variant-styles.ts index 61407a1..3c9fe02 100644 --- a/src/shared/components/button/button-variant-styles.ts +++ b/src/shared/components/button/button-variant-styles.ts @@ -5,6 +5,9 @@ const buttonVariantStyles: Record = { "bg-bg-secondary text-text-primary border border-border hover:bg-bg-tertiary", destructive: "bg-error text-white hover:bg-error/90 active:bg-error/80", ghost: "text-text-secondary hover:bg-bg-secondary active:bg-bg-tertiary", + // Inline text action (e.g. "Change", "Update available"). No box — pairs + // with `size="inline"` so it sits in flowing text without button chrome. + link: "text-accent hover:text-accent-hover hover:underline underline-offset-2 rounded-sm", }; export default buttonVariantStyles; diff --git a/src/shared/components/button/button.tsx b/src/shared/components/button/button.tsx index aaa09d2..66854cf 100644 --- a/src/shared/components/button/button.tsx +++ b/src/shared/components/button/button.tsx @@ -4,8 +4,8 @@ import buttonVariantStyles from "./button-variant-styles"; import buttonSizeStyles from "./button-size-styles"; interface ButtonProps extends ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "destructive" | "ghost"; - size?: "sm" | "md" | "lg"; + variant?: "primary" | "secondary" | "destructive" | "ghost" | "link"; + size?: "inline" | "sm" | "md" | "lg"; } export default function Button({ @@ -16,12 +16,20 @@ export default function Button({ children, ...props }: ButtonProps) { + const isLink = variant === "link"; return ( - {label && ( - {label} - )} + {label && {label}} ); } diff --git a/src/transcripts/transcript-item.tsx b/src/transcripts/transcript-item.tsx index ee5eb7e..cfdbec4 100644 --- a/src/transcripts/transcript-item.tsx +++ b/src/transcripts/transcript-item.tsx @@ -19,7 +19,9 @@ export default function TranscriptItem({