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/src-tauri/src/window/widget.rs b/src-tauri/src/window/widget.rs index 596b6a2..3ca9145 100644 --- a/src-tauri/src/window/widget.rs +++ b/src-tauri/src/window/widget.rs @@ -7,28 +7,36 @@ use tauri::{AppHandle, LogicalSize, Manager, PhysicalPosition, WebviewWindow}; /// 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) { - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app_handle.get_webview_panel("widget") { - if !panel.is_visible() { - panel.order_front_regardless(); + let app = app_handle.clone(); + let _ = 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; } - return; } - } - if let Some(widget) = app_handle.get_webview_window("widget") { - if !widget.is_visible().unwrap_or(false) { - let _ = widget.show(); + if let Some(widget) = app.get_webview_window("widget") { + if !widget.is_visible().unwrap_or(false) { + let _ = widget.show(); + } } - } + }); } /// 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. +/// 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 @@ -42,18 +50,21 @@ pub fn restore_visibility(app_handle: &AppHandle) { return; } - #[cfg(target_os = "macos")] - { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app_handle.get_webview_panel("widget") { - panel.order_out(None); - return; + let app = app_handle.clone(); + let _ = 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_handle.get_webview_window("widget") { - let _ = widget.hide(); - } + if let Some(widget) = app.get_webview_window("widget") { + let _ = widget.hide(); + } + }); } pub fn apply_position_and_size( 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 9c7b78a..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 6c7fd36..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%; @@ -67,6 +83,35 @@ 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) { *, @@ -81,9 +126,9 @@ #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 (