fix(landing): download buttons 404 — use real v1.0.7 filenames (dots, not spaces) #4782
42 changed files+2349−125
Modified.github/workflows/build-android.yml+2−2View fileUnifiedSplit
@@ -47,7 +47,7 @@ jobs:
4747 - name: Upload debug APK
4848 uses: actions/upload-artifact@v4
4949 with:
50 name: marco-reid-voice-keyboard-debug
50 name: voxlen-keyboard-debug
5151 path: android/app/build/outputs/apk/debug/*.apk
5252 if-no-files-found: warn
5353 retention-days: 30
@@ -55,7 +55,7 @@ jobs:
5555 - name: Upload release APK
5656 uses: actions/upload-artifact@v4
5757 with:
58 name: marco-reid-voice-keyboard-release
58 name: voxlen-keyboard-release
5959 path: android/app/build/outputs/apk/release/*.apk
6060 if-no-files-found: warn
6161 retention-days: 30
Modified.github/workflows/build.yml+4−4View fileUnifiedSplit
@@ -1,4 +1,4 @@
1name: Build & Release Voxlen
1name: Build & Release Marco Reid Voice
22
33on:
44 push:
@@ -96,7 +96,7 @@ jobs:
9696 if: always()
9797 uses: actions/upload-artifact@v4
9898 with:
99 name: marco-reid-voice-${{ matrix.platform }}-${{ matrix.target || 'host' }}
99 name: voxlen-${{ matrix.platform }}-${{ matrix.target || 'host' }}
100100 path: ${{ matrix.bundle_glob }}
101101 if-no-files-found: ignore
102102 retention-days: 7
@@ -138,9 +138,9 @@ jobs:
138138 uses: softprops/action-gh-release@v2
139139 with:
140140 tag_name: v${{ steps.version.outputs.version }}
141 name: Voxlen v${{ steps.version.outputs.version }}
141 name: Marco Reid Voice v${{ steps.version.outputs.version }}
142142 body: |
143 Voxlen v${{ steps.version.outputs.version }} — AI-powered voice dictation for professionals.
143 Marco Reid Voice v${{ steps.version.outputs.version }} — AI-powered voice dictation for professionals.
144144
145145 Download the installer for your OS below.
146146
Modifiedindex.html+1−1View fileUnifiedSplit
@@ -4,7 +4,7 @@
44 <meta charset="UTF-8" />
55 <link rel="icon" type="image/svg+xml" href="/vox-icon.svg" />
66 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Voxlen</title>
7 <title>Marco Reid Voice</title>
88 </head>
99 <body class="bg-surface-0 text-surface-950 antialiased">
1010 <div id="root"></div>
Modifiedlanding/index.html+11−9View fileUnifiedSplit
@@ -3,30 +3,32 @@
33 <head>
44 <meta charset="UTF-8" />
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Voxlen — Speak. It is done.</title>
6 <title>Marco Reid Voice — Speak. It is done.</title>
77 <meta name="description" content="The most advanced AI dictation tool. Real-time voice-to-text, AI grammar correction, and universal text injection. Works everywhere, never interrupts." />
8 <link rel="canonical" href="https://marcoreid.com/voice/" />
8 <link rel="canonical" href="https://voxlen.ai/" />
99 <meta property="og:title" content="Voxlen — AI Voice Dictation" />
1010 <meta property="og:description" content="Speak naturally. AI transcribes, polishes, and injects text into any app. 20+ languages. Works in every app. Fraction of the cost of alternatives." />
1111 <meta property="og:type" content="website" />
12 <meta property="og:url" content="https://marcoreid.com/voice/" />
12 <meta property="og:url" content="https://voxlen.ai/" />
1313 <meta property="og:site_name" content="Voxlen" />
14 <meta property="og:image" content="https://marcoreid.com/og-voice.png" />
14 <meta property="og:image" content="https://voxlen.ai/og-image.svg" />
15 <meta property="og:image:width" content="1200" />
16 <meta property="og:image:height" content="630" />
1517 <meta name="twitter:card" content="summary_large_image" />
16 <meta name="twitter:title" content="Voxlen — AI Voice Dictation" />
18 <meta name="twitter:title" content="Marco Reid Voice — AI Voice Dictation" />
1719 <meta name="twitter:description" content="Speak naturally. AI transcribes, polishes, and injects text into any app. 20+ languages. Works in every app." />
18 <meta name="twitter:image" content="https://marcoreid.com/og-voice.png" />
20 <meta name="twitter:image" content="https://voxlen.ai/og-image.svg" />
1921 <meta name="theme-color" content="#7345D1" />
2022 <link rel="icon" type="image/svg+xml" href="/vox-icon.svg" />
2123 <script type="application/ld+json">
2224 {
2325 "@context": "https://schema.org",
2426 "@type": "SoftwareApplication",
25 "name": "Voxlen",
27 "name": "Marco Reid Voice",
2628 "applicationCategory": "ProductivityApplication",
2729 "operatingSystem": "macOS, Windows, Linux, iOS, Android",
2830 "description": "AI-powered voice dictation with real-time grammar correction and universal text injection. Designed for professionals including lawyers and accountants.",
29 "url": "https://marcoreid.com/voice/",
31 "url": "https://voxlen.ai/",
3032 "offers": [
3133 {
3234 "@type": "Offer",
@@ -63,7 +65,7 @@
6365 "Offline mode",
6466 "Cross-platform (macOS, Windows, Linux, iOS, Android)"
6567 ],
66 "screenshot": "https://marcoreid.com/og-voice.png"
68 "screenshot": "https://voxlen.ai/og-image.svg"
6769 }
6870 </script>
6971 <link rel="preconnect" href="https://fonts.googleapis.com" />
Modifiedlanding/package-lock.json+2−2View fileUnifiedSplit
@@ -1,11 +1,11 @@
11{
2 "name": "marcoreid-voice-landing",
2 "name": "voxlen-landing",
33 "version": "1.0.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
8 "name": "marcoreid-voice-landing",
8 "name": "voxlen-landing",
99 "version": "1.0.0",
1010 "dependencies": {
1111 "framer-motion": "^11.15.0",
Modifiedlanding/package.json+1−1View fileUnifiedSplit
@@ -1,5 +1,5 @@
11{
2 "name": "marcoreid-voice-landing",
2 "name": "voxlen-landing",
33 "private": true,
44 "version": "1.0.0",
55 "type": "module",
Addedlanding/public/og-image.svg+65−0View fileUnifiedSplit
@@ -0,0 +1,65 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
2 <defs>
3 <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4 <stop offset="0" stop-color="#0a0a0f"/>
5 <stop offset="1" stop-color="#1a0e30"/>
6 </linearGradient>
7 <linearGradient id="brand" x1="0" y1="0" x2="1" y2="0">
8 <stop offset="0" stop-color="#a78bfa"/>
9 <stop offset="1" stop-color="#7345d1"/>
10 </linearGradient>
11 <linearGradient id="brass" x1="0" y1="0" x2="1" y2="0">
12 <stop offset="0" stop-color="#d4a24c"/>
13 <stop offset="1" stop-color="#b3853a"/>
14 </linearGradient>
15 <filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
16 <feGaussianBlur stdDeviation="18" result="b"/>
17 <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
18 </filter>
19 </defs>
20
21 <rect width="1200" height="630" fill="url(#bg)"/>
22
23 <!-- soft glow orb -->
24 <circle cx="930" cy="315" r="220" fill="#7345d1" opacity="0.18" filter="url(#glow)"/>
25
26 <!-- waveform bars -->
27 <g transform="translate(760 315)" opacity="0.85">
28 <rect x="0" y="-40" width="10" height="80" rx="5" fill="url(#brand)"/>
29 <rect x="22" y="-70" width="10" height="140" rx="5" fill="url(#brand)"/>
30 <rect x="44" y="-110" width="10" height="220" rx="5" fill="url(#brand)"/>
31 <rect x="66" y="-90" width="10" height="180" rx="5" fill="url(#brand)"/>
32 <rect x="88" y="-130" width="10" height="260" rx="5" fill="url(#brand)"/>
33 <rect x="110" y="-80" width="10" height="160" rx="5" fill="url(#brand)"/>
34 <rect x="132" y="-50" width="10" height="100" rx="5" fill="url(#brand)"/>
35 <rect x="154" y="-30" width="10" height="60" rx="5" fill="url(#brand)"/>
36 <rect x="176" y="-70" width="10" height="140" rx="5" fill="url(#brand)"/>
37 <rect x="198" y="-45" width="10" height="90" rx="5" fill="url(#brand)"/>
38 <rect x="220" y="-20" width="10" height="40" rx="5" fill="url(#brand)"/>
39 </g>
40
41 <!-- logo mark -->
42 <g transform="translate(80 100)">
43 <rect x="0" y="0" width="72" height="72" rx="18" fill="url(#brand)"/>
44 <path d="M22 24 L36 56 L50 24" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
45 </g>
46
47 <!-- wordmark -->
48 <text x="80" y="290" font-family="Inter, system-ui, sans-serif" font-size="120" font-weight="800" fill="#fff" letter-spacing="-4">Voxlen</text>
49
50 <!-- tagline -->
51 <text x="80" y="360" font-family="Inter, system-ui, sans-serif" font-size="42" font-weight="500" fill="#e4e4e7">Speak. It is done.</text>
52
53 <!-- subtitle -->
54 <text x="80" y="430" font-family="Inter, system-ui, sans-serif" font-size="26" font-weight="400" fill="#a1a1aa">AI voice dictation with real-time grammar correction.</text>
55 <text x="80" y="462" font-family="Inter, system-ui, sans-serif" font-size="26" font-weight="400" fill="#a1a1aa">macOS · Windows · Linux · iOS · Web</text>
56
57 <!-- brass divider -->
58 <rect x="80" y="500" width="120" height="3" rx="1" fill="url(#brass)"/>
59
60 <!-- footer -->
61 <text x="80" y="540" font-family="Inter, system-ui, sans-serif" font-size="22" font-weight="500" fill="#d4a24c">Built for lawyers, accountants, and professionals who talk for a living.</text>
62
63 <!-- url -->
64 <text x="80" y="580" font-family="'JetBrains Mono', monospace" font-size="20" font-weight="500" fill="#71717a">voxlen.ai</text>
65</svg>
Addedlanding/public/robots.txt+4−0View fileUnifiedSplit
@@ -0,0 +1,4 @@
1User-agent: *
2Allow: /
3
4Sitemap: https://voxlen.ai/sitemap.xml
Addedlanding/public/sitemap.xml+18−0View fileUnifiedSplit
@@ -0,0 +1,18 @@
1
2<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3 <url>
4 <loc>https://voxlen.ai/</loc>
5 <changefreq>weekly</changefreq>
6 <priority>1.0</priority>
7 </url>
8 <url>
9 <loc>https://voxlen.ai/privacy</loc>
10 <changefreq>monthly</changefreq>
11 <priority>0.5</priority>
12 </url>
13 <url>
14 <loc>https://voxlen.ai/terms</loc>
15 <changefreq>monthly</changefreq>
16 <priority>0.5</priority>
17 </url>
18</urlset>
Modifiedlanding/src/App.tsx+121−49View fileUnifiedSplit
@@ -46,6 +46,63 @@ export default function App() {
4646 {legalModal && (
4747 <LegalModal type={legalModal} onClose={() => setLegalModal(null)} />
4848 )}
49 <CookieConsent onOpenLegal={setLegalModal} />
50 </div>
51 );
52}
53
54function CookieConsent({ onOpenLegal }: { onOpenLegal: (type: "privacy") => void }) {
55 const [visible, setVisible] = useState(false);
56
57 useEffect(() => {
58 try {
59 const accepted = localStorage.getItem("voxlen_cookie_consent");
60 if (!accepted) setVisible(true);
61 } catch {
62 setVisible(true);
63 }
64 }, []);
65
66 const accept = () => {
67 try {
68 localStorage.setItem("voxlen_cookie_consent", new Date().toISOString());
69 } catch {
70 // Storage disabled — just hide banner for the session
71 }
72 setVisible(false);
73 };
74
75 if (!visible) return null;
76
77 return (
78 <div
79 role="dialog"
80 aria-live="polite"
81 aria-label="Cookie notice"
82 className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[60] w-[calc(100%-2rem)] max-w-3xl rounded-xl border border-white/10 bg-[#0f0f14]/95 backdrop-blur-xl shadow-2xl"
83 >
84 <div className="p-4 sm:p-5 flex flex-col sm:flex-row items-start sm:items-center gap-4">
85 <p className="text-sm text-zinc-300 flex-1">
86 We use only essential storage to keep your preferences on this site. No tracking, no ads, no cross-site cookies. See our{" "}
87 <button
88 type="button"
89 onClick={() => onOpenLegal("privacy")}
90 className="text-marcoreid-400 hover:text-marcoreid-300 underline underline-offset-2"
91 >
92 Privacy Policy
93 </button>{" "}
94 for details.
95 </p>
96 <div className="flex gap-2 w-full sm:w-auto">
97 <button
98 type="button"
99 onClick={accept}
100 className="flex-1 sm:flex-none h-9 px-4 rounded-lg bg-marcoreid-600 hover:bg-marcoreid-700 text-white text-sm font-medium transition-colors"
101 >
102 Got it
103 </button>
104 </div>
105 </div>
49106 </div>
50107 );
51108}
@@ -58,7 +115,7 @@ function Navbar() {
58115 <div className="w-8 h-8 rounded-lg bg-marcoreid-600 flex items-center justify-center">
59116 <Mic className="h-4 w-4 text-white" />
60117 </div>
61 <span className="text-lg font-bold tracking-tight">Voxlen</span>
118 <span className="text-lg font-bold tracking-tight">Marco Reid Voice</span>
62119 </div>
63120 <div className="hidden md:flex items-center gap-8 text-sm text-zinc-400">
64121 <a href="#features" className="hover:text-white transition-colors">Features</a>
@@ -184,7 +241,7 @@ function Hero() {
184241 <div className="w-5 h-5 rounded bg-marcoreid-600 flex items-center justify-center">
185242 <Mic className="h-3 w-3 text-white" />
186243 </div>
187 <span className="text-xs font-semibold text-zinc-300">Voxlen</span>
244 <span className="text-xs font-semibold text-zinc-300">Marco Reid Voice</span>
188245 <span className="px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-medium">
189246 Listening
190247 </span>
@@ -273,7 +330,7 @@ function Features() {
273330 {
274331 icon: Shield,
275332 title: "Never Interrupted",
276 description: "Unlike Windows+H or Apple Dictation, Voxlen NEVER stops when you switch apps. Runs as a background service with its own audio pipeline.",
333 description: "Unlike Windows+H or Apple Dictation, Marco Reid Voice NEVER stops when you switch apps. Runs as a background service with its own audio pipeline.",
277334 color: "text-green-400",
278335 bg: "bg-green-400/10",
279336 },
@@ -433,7 +490,7 @@ function Comparison() {
433490 { name: "Grammarly", price: "$12/mo", realtime: false, neverInterrupts: false, grammar: true, anyApp: false, offline: false, extMic: false },
434491 { name: "Dragon", price: "$700", realtime: true, neverInterrupts: false, grammar: false, anyApp: true, offline: true, extMic: false },
435492 { name: "Wispr Flow", price: "$12/mo", realtime: true, neverInterrupts: true, grammar: false, anyApp: true, offline: false, extMic: false },
436 { name: "Voxlen", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: true, extMic: true, highlight: true },
493 { name: "Marco Reid Voice", price: "$29/mo", realtime: true, neverInterrupts: true, grammar: true, anyApp: true, offline: true, extMic: true, highlight: true },
437494 ];
438495
439496 return (
@@ -450,7 +507,7 @@ function Comparison() {
450507 Comparison
451508 </motion.p>
452509 <motion.h2 variants={fadeUp} className="text-4xl md:text-5xl font-black tracking-tight">
453 See why Voxlen wins.
510 See why we win.
454511 </motion.h2>
455512 </motion.div>
456513
@@ -666,7 +723,7 @@ function Pricing() {
666723 Pro and Professional include a 14-day free trial. No credit card required. Cancel anytime.
667724 All AI costs (speech-to-text and grammar correction) are included — your subscription
668725 covers everything. Audio still streams directly from your device to AI providers and is
669 never routed through or stored by Voxlen.
726 never routed through or stored by Marco Reid.
670727 </p>
671728 </div>
672729 </section>
@@ -677,11 +734,11 @@ function FAQ() {
677734 const faqs = [
678735 {
679736 q: "How is this different from Windows+H or Apple Dictation?",
680 a: "Those stop working the moment you switch apps or click somewhere else. Voxlen runs as a background service — it NEVER gets interrupted. Plus, it has AI grammar correction, works with your external mic, and supports 20+ languages.",
737 a: "Those stop working the moment you switch apps or click somewhere else. Marco Reid Voice runs as a background service — it NEVER gets interrupted. Plus, it has AI grammar correction, works with your external mic, and supports 20+ languages.",
681738 },
682739 {
683740 q: "Why is this better than Grammarly?",
684 a: "Grammarly is a typing-focused tool that killed their voice features — and their grammar engine is weaker than the frontier AI models we use. Voxlen is built on Claude, the most capable language AI available, and combines real-time dictation with grammar correction in a single product. It is more advanced and more accurate, and it works everywhere you type — not just inside Grammarly's browser extension.",
741 a: "Grammarly is a typing-focused tool that killed their voice features — and their grammar engine is weaker than the frontier AI models we use. Marco Reid Voice is built on Claude, the most capable language AI available, and combines real-time dictation with grammar correction in a single product. It is more advanced and more accurate, and it works everywhere you type — not just inside Grammarly's browser extension.",
685742 },
686743 {
687744 q: "Do I need to set up API keys or separate accounts?",
@@ -693,15 +750,15 @@ function FAQ() {
693750 },
694751 {
695752 q: "Do I need an internet connection?",
696 a: "Not always. Voxlen includes a fully offline mode that runs entirely on your device — ideal for flights or sensitive work. Our cloud models give higher accuracy and lower latency, but you always have the choice.",
753 a: "Not always. Marco Reid Voice includes a fully offline mode that runs entirely on your device — ideal for flights or sensitive work. Our cloud models give higher accuracy and lower latency, but you always have the choice.",
697754 },
698755 {
699756 q: "Does it work with my external USB microphone?",
700 a: "Yes. Voxlen auto-detects external mics (Razer, Blue Yeti, Rode, HyperX, etc.) and prioritizes them over your built-in laptop mic. You will get a warning if you are accidentally using the internal mic.",
757 a: "Yes. Marco Reid Voice auto-detects external mics (Razer, Blue Yeti, Rode, HyperX, etc.) and prioritizes them over your built-in laptop mic. You will get a warning if you are accidentally using the internal mic.",
701758 },
702759 {
703760 q: "Is my audio private? I handle privileged information.",
704 a: "Yes. Even though we provide the AI infrastructure as part of your subscription, your audio and transcripts are NEVER routed through or stored on Voxlen-operated servers. Audio streams directly from your device to the AI provider using zero-retention endpoints and is discarded immediately after transcription. On the Professional plan, we enable the strictest zero-retention guarantees from every AI provider. Offline mode means nothing leaves your device at all. This is a hard architectural rule we will never compromise.",
761 a: "Yes. Even though we provide the AI infrastructure as part of your subscription, your audio and transcripts are NEVER routed through or stored on Marco Reid Voice-operated servers. Audio streams directly from your device to the AI provider using zero-retention endpoints and is discarded immediately after transcription. On the Professional plan, we enable the strictest zero-retention guarantees from every AI provider. Offline mode means nothing leaves your device at all. This is a hard architectural rule we will never compromise.",
705762 },
706763 {
707764 q: "Can my firm get a team plan?",
@@ -769,11 +826,7 @@ function detectPlatform(): Platform {
769826 const ua = window.navigator.userAgent;
770827 const platform = window.navigator.platform || "";
771828 if (/Mac/i.test(platform) || /Mac/i.test(ua)) {
772 // Best-effort Apple Silicon detection
773 if (/ARM|Apple/i.test(ua) || (window.navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData?.architecture === "arm") {
774 return "mac-arm";
775 }
776 return "mac-arm"; // Default modern Macs to ARM; Intel users can pick manually
829 return "mac-arm";
777830 }
778831 if (/Win/i.test(platform) || /Windows/i.test(ua)) return "windows";
779832 if (/Linux/i.test(platform) || /Linux/i.test(ua)) return "linux";
@@ -782,33 +835,29 @@ function detectPlatform(): Platform {
782835
783836const DOWNLOADS: Record<
784837 Exclude<Platform, "unknown">,
785 { label: string; subLabel: string; file: string; size: string; icon: "apple" | "monitor" }
838 { label: string; subLabel: string; size: string; icon: "apple" | "monitor" }
786839> = {
787840 "mac-arm": {
788841 label: "Download for macOS",
789842 subLabel: "Apple Silicon (M1/M2/M3/M4)",
790 file: `Voxlen_${APP_VERSION}_aarch64.dmg`,
791843 size: "~18 MB",
792844 icon: "apple",
793845 },
794846 "mac-intel": {
795847 label: "Download for macOS",
796848 subLabel: "Intel (x86_64) — build on request",
797 file: `Voxlen_${APP_VERSION}_aarch64.dmg`,
798849 size: "~18 MB",
799850 icon: "apple",
800851 },
801852 windows: {
802853 label: "Download for Windows",
803854 subLabel: "Windows 10/11 (x64)",
804 file: `Voxlen_${APP_VERSION}_x64_en-US.msi`,
805855 size: "~5 MB",
806856 icon: "monitor",
807857 },
808858 linux: {
809859 label: "Download for Linux",
810860 subLabel: "AppImage (x86_64)",
811 file: `Voxlen_${APP_VERSION}_amd64.AppImage`,
812861 size: "~80 MB",
813862 icon: "monitor",
814863 },
@@ -816,11 +865,34 @@ const DOWNLOADS: Record<
816865
817866function CTA() {
818867 const [platform, setPlatform] = useState<Platform>("unknown");
868 const [liveAssets, setLiveAssets] = useState<ReleaseAsset[] | null>(null);
819869
820870 useEffect(() => {
821871 setPlatform(detectPlatform());
872 const ac = new AbortController();
873 fetch(GH_API_LATEST, { signal: ac.signal, headers: { Accept: "application/vnd.github+json" } })
874 .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`GitHub API ${r.status}`))))
875 .then((json: { assets?: ReleaseAsset[] }) => {
876 if (Array.isArray(json.assets)) setLiveAssets(json.assets);
877 })
878 .catch(() => {
879 // Network blocked or rate-limited: fall back to hardcoded filenames below.
880 });
881 return () => ac.abort();
822882 }, []);
823883
884 // Never return a guessed filename URL. If we have the live asset list and
885 // a match for this platform, link direct; otherwise fall back to the
886 // releases page so the user always lands on *something* that exists.
887 // This is the difference between a red 404 and a working page.
888 const hrefFor = (key: Exclude<Platform, "unknown">): string => {
889 if (liveAssets) {
890 const picked = pickAssetFor(key, liveAssets);
891 if (picked) return picked.browser_download_url;
892 }
893 return GH_RELEASES_PAGE;
894 };
895
824896 const primary = platform !== "unknown" ? DOWNLOADS[platform] : null;
825897 const PrimaryIcon = primary?.icon === "apple" ? Apple : Monitor;
826898
@@ -849,7 +921,7 @@ function CTA() {
849921 {primary && (
850922 <motion.div variants={fadeUp} className="flex justify-center mb-4">
851923 <a
852 href={`${GH_RELEASES}/${encodeURIComponent(primary.file)}`}
924 href={hrefFor(platform as Exclude<Platform, "unknown">)}
853925 className="group h-16 px-10 rounded-2xl bg-marcoreid-600 text-white font-bold flex items-center gap-4 hover:bg-marcoreid-700 transition-all shadow-xl shadow-marcoreid-600/30 hover:shadow-marcoreid-600/50 hover:scale-[1.02]"
854926 >
855927 <PrimaryIcon className="h-7 w-7" />
@@ -885,7 +957,7 @@ function CTA() {
885957 <motion.a
886958 key={key}
887959 variants={fadeUp}
888 href={`${GH_RELEASES}/${encodeURIComponent(d.file)}`}
960 href={hrefFor(key)}
889961 className={`group relative p-5 rounded-xl border transition-all ${
890962 isDetected
891963 ? "bg-marcoreid-600/5 border-marcoreid-600/40 hover:border-marcoreid-600/60"
@@ -979,17 +1051,17 @@ function Footer({ onOpenLegal }: { onOpenLegal: (type: "privacy" | "terms") => v
9791051 <div className="w-6 h-6 rounded bg-marcoreid-600 flex items-center justify-center">
9801052 <Mic className="h-3 w-3 text-white" />
9811053 </div>
982 <span className="text-sm font-bold">Voxlen</span>
1054 <span className="text-sm font-bold">Marco Reid Voice</span>
9831055 <span className="text-xs text-zinc-600">v{APP_VERSION}</span>
9841056 </div>
9851057 <div className="flex items-center gap-6 text-xs text-zinc-500">
9861058 <button onClick={() => onOpenLegal("privacy")} className="hover:text-white transition-colors">Privacy Policy</button>
9871059 <button onClick={() => onOpenLegal("terms")} className="hover:text-white transition-colors">Terms of Service</button>
988 <a href="mailto:support@voxlen.ai" className="hover:text-white transition-colors">Support</a>
1060 <a href="mailto:support@marcoreid.com" className="hover:text-white transition-colors">Support</a>
9891061 <a href="https://github.com/ccantynz-alt/voxlen" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">GitHub</a>
9901062 </div>
9911063 <p className="text-xs text-zinc-600">
992 © {new Date().getFullYear()} Voxlen. All rights reserved.
1064 © {new Date().getFullYear()} Marco Reid. All rights reserved.
9931065 </p>
9941066 </div>
9951067 </div>
@@ -1034,8 +1106,8 @@ function PrivacyContent() {
10341106 <p className="text-xs text-zinc-500">Last updated: April 2026</p>
10351107
10361108 <p className="text-zinc-300 leading-relaxed">
1037 Voxlen ("we", "us", "our") is committed to protecting your privacy. This policy explains
1038 how our voice dictation application handles your data. We designed Voxlen with
1109 Marco Reid Voice ("we", "us", "our") is committed to protecting your privacy. This policy explains
1110 how our voice dictation application handles your data. We designed Marco Reid Voice with
10391111 privacy-first principles, especially for professionals handling sensitive information.
10401112 </p>
10411113
@@ -1044,29 +1116,29 @@ function PrivacyContent() {
10441116 <li><strong className="text-zinc-200">Audio recordings</strong> — We never store, log, or retain your voice audio. Audio is streamed to your chosen STT provider and immediately discarded after transcription.</li>
10451117 <li><strong className="text-zinc-200">Transcribed text</strong> — Your dictated text stays on your device. We never transmit transcription content to our servers.</li>
10461118 <li><strong className="text-zinc-200">Grammar-corrected content</strong> — Text sent for AI grammar correction goes directly to your chosen provider (Anthropic or OpenAI) using your own API key. We have no access to this content.</li>
1047 <li><strong className="text-zinc-200">Documents or files</strong> — Voxlen never reads, scans, or accesses any files on your device beyond its own configuration.</li>
1119 <li><strong className="text-zinc-200">Documents or files</strong> — Marco Reid Voice never reads, scans, or accesses any files on your device beyond its own configuration.</li>
10481120 </ul>
10491121
10501122 <h2 className="text-lg font-bold mt-8">2. Data Processing Architecture</h2>
10511123 <p className="text-zinc-300 leading-relaxed">
1052 Voxlen operates as a <strong>pass-through</strong> application. Even though paid plans
1124 Marco Reid Voice operates as a <strong>pass-through</strong> application. Even though paid plans
10531125 include AI infrastructure as part of your subscription, your data flows directly between
1054 your device and the underlying AI providers — never through Voxlen-operated servers:
1126 your device and the underlying AI providers — never through Marco Reid Voice-operated servers:
10551127 </p>
10561128 <ul className="text-zinc-400 space-y-2 list-disc pl-5">
10571129 <li><strong className="text-zinc-200">Speech-to-Text:</strong> Audio streams directly from your device to the speech-to-text provider on zero-retention endpoints. In offline mode, audio never leaves your device.</li>
10581130 <li><strong className="text-zinc-200">Grammar Correction:</strong> Text is sent directly from your device to the grammar AI provider (Anthropic or OpenAI) on zero-retention endpoints. We have no intermediary server.</li>
10591131 <li><strong className="text-zinc-200">Text Injection:</strong> All text injection happens locally via OS-level APIs. No network transmission involved.</li>
1060 <li><strong className="text-zinc-200">API credentials:</strong> Voxlen provisions provider credentials as part of your subscription, but credentials are issued to your device and used only for direct device-to-provider traffic.</li>
1132 <li><strong className="text-zinc-200">API credentials:</strong> Marco Reid Voice provisions provider credentials as part of your subscription, but credentials are issued to your device and used only for direct device-to-provider traffic.</li>
10611133 </ul>
10621134
10631135 <h2 className="text-lg font-bold mt-8">3. Confidentiality for Legal & Accounting Professionals</h2>
10641136 <p className="text-zinc-300 leading-relaxed">
1065 We understand that attorneys, accountants, and other professionals using Voxlen may handle
1066 privileged or confidential information. Voxlen is designed to respect these obligations:
1137 We understand that attorneys, accountants, and other professionals using Marco Reid Voice may handle
1138 privileged or confidential information. Marco Reid Voice is designed to respect these obligations:
10671139 </p>
10681140 <ul className="text-zinc-400 space-y-2 list-disc pl-5">
1069 <li>No Voxlen-operated server ever receives your content — this is a hard architectural rule</li>
1141 <li>No Marco Reid Voice-operated server ever receives your content — this is a hard architectural rule</li>
10701142 <li>Session history is stored only on your local device and never synced to our infrastructure</li>
10711143 <li>Custom vocabulary and dictionaries remain local to your device</li>
10721144 <li>All AI provider traffic uses zero-retention endpoints</li>
@@ -1076,7 +1148,7 @@ function PrivacyContent() {
10761148
10771149 <h2 className="text-lg font-bold mt-8">4. Analytics & Telemetry</h2>
10781150 <p className="text-zinc-300 leading-relaxed">
1079 Voxlen collects minimal, anonymous usage telemetry to improve the product:
1151 Marco Reid Voice collects minimal, anonymous usage telemetry to improve the product:
10801152 </p>
10811153 <ul className="text-zinc-400 space-y-2 list-disc pl-5">
10821154 <li>Application launch and feature usage counts (no content)</li>
@@ -1089,7 +1161,7 @@ function PrivacyContent() {
10891161
10901162 <h2 className="text-lg font-bold mt-8">5. Third-Party Services</h2>
10911163 <p className="text-zinc-300 leading-relaxed">
1092 Voxlen includes AI infrastructure as part of your paid subscription. Your audio and text
1164 Marco Reid Voice includes AI infrastructure as part of your paid subscription. Your audio and text
10931165 are processed by our underlying AI providers on zero-retention endpoints:
10941166 </p>
10951167 <ul className="text-zinc-400 space-y-2 list-disc pl-5">
@@ -1105,7 +1177,7 @@ function PrivacyContent() {
11051177
11061178 <h2 className="text-lg font-bold mt-8">6. Contact</h2>
11071179 <p className="text-zinc-300 leading-relaxed">
1108 For privacy inquiries, contact us at <a href="mailto:privacy@voxlen.ai" className="text-voxlen-400 hover:underline">privacy.ai</a>.
1180 For privacy inquiries, contact us at <a href="mailto:privacy@voxlen.ai" className="text-marcoreid-400 hover:underline">privacy@voxlen.ai</a>.
11091181 </p>
11101182 </div>
11111183 );
@@ -1118,12 +1190,12 @@ function TermsContent() {
11181190 <p className="text-xs text-zinc-500">Last updated: April 2026</p>
11191191
11201192 <p className="text-zinc-300 leading-relaxed">
1121 By downloading or using Voxlen, you agree to these terms. Please read them carefully.
1193 By downloading or using Marco Reid Voice, you agree to these terms. Please read them carefully.
11221194 </p>
11231195
11241196 <h2 className="text-lg font-bold mt-8">1. Service Description</h2>
11251197 <p className="text-zinc-300 leading-relaxed">
1126 Voxlen is a desktop and mobile application that provides voice-to-text dictation with
1198 Marco Reid Voice is a desktop and mobile application that provides voice-to-text dictation with
11271199 AI-powered grammar correction and universal text injection. The application runs locally
11281200 on your device and connects to third-party APIs using your own credentials.
11291201 </p>
@@ -1132,14 +1204,14 @@ function TermsContent() {
11321204 <p className="text-zinc-300 leading-relaxed">
11331205 Paid plans include all AI infrastructure (speech-to-text and grammar correction) as part of
11341206 your subscription. You do not need to provide your own API keys. Audio streams directly
1135 from your device to the relevant AI providers on zero-retention endpoints — Voxlen
1136 provisions the credentials, but your content never passes through Voxlen-operated
1207 from your device to the relevant AI providers on zero-retention endpoints — Marco Reid Voice
1208 provisions the credentials, but your content never passes through Marco Reid Voice-operated
11371209 infrastructure. Advanced users may optionally supply their own API keys.
11381210 </p>
11391211
11401212 <h2 className="text-lg font-bold mt-8">3. Subscription Plans</h2>
11411213 <p className="text-zinc-300 leading-relaxed">
1142 Voxlen offers Free, Pro ($29/month), Professional ($79/month for legal and accounting
1214 Marco Reid Voice offers Free, Pro ($29/month), Professional ($79/month for legal and accounting
11431215 teams), and Lifetime ($599 one-time) plans. The Free plan includes limited dictation. Paid
11441216 plans unlock all features and include all AI costs. Subscriptions can be cancelled at any
11451217 time. We offer a 14-day free trial for Pro and Professional with no credit card required.
@@ -1156,35 +1228,35 @@ function TermsContent() {
11561228
11571229 <h2 className="text-lg font-bold mt-8">5. Intellectual Property</h2>
11581230 <p className="text-zinc-300 leading-relaxed">
1159 Voxlen and its original content, features, and functionality are owned by Voxlen and are
1231 Marco Reid Voice and its original content, features, and functionality are owned by Marco Reid Voice and are
11601232 protected by international copyright and trademark laws. Your transcribed content remains
1161 entirely yours — we claim no rights over content you create using Voxlen.
1233 entirely yours — we claim no rights over content you create using Marco Reid Voice.
11621234 </p>
11631235
11641236 <h2 className="text-lg font-bold mt-8">6. Disclaimer of Warranties</h2>
11651237 <p className="text-zinc-300 leading-relaxed">
1166 Voxlen is provided "as is" without warranties of any kind. We do not guarantee that
1238 Marco Reid Voice is provided "as is" without warranties of any kind. We do not guarantee that
11671239 transcriptions or grammar corrections will be error-free. You should review all output,
11681240 especially for legal, medical, or financial documents.
11691241 </p>
11701242
11711243 <h2 className="text-lg font-bold mt-8">7. Limitation of Liability</h2>
11721244 <p className="text-zinc-300 leading-relaxed">
1173 Voxlen shall not be liable for any indirect, incidental, special, consequential, or punitive
1245 Marco Reid Voice shall not be liable for any indirect, incidental, special, consequential, or punitive
11741246 damages resulting from your use of the application, including but not limited to errors in
11751247 transcription or grammar correction.
11761248 </p>
11771249
11781250 <h2 className="text-lg font-bold mt-8">8. Changes to Terms</h2>
11791251 <p className="text-zinc-300 leading-relaxed">
1180 We may update these terms from time to time. Continued use of Voxlen after changes
1252 We may update these terms from time to time. Continued use of Marco Reid Voice after changes
11811253 constitutes acceptance of the new terms. We will notify users of significant changes
11821254 through the application.
11831255 </p>
11841256
11851257 <h2 className="text-lg font-bold mt-8">9. Contact</h2>
11861258 <p className="text-zinc-300 leading-relaxed">
1187 For questions about these terms, contact us at <a href="mailto:legal@voxlen.ai" className="text-voxlen-400 hover:underline">legal.ai</a>.
1259 For questions about these terms, contact us at <a href="mailto:legal@voxlen.ai" className="text-marcoreid-400 hover:underline">legal@voxlen.ai</a>.
11881260 </p>
11891261 </div>
11901262 );
Modifiedpackage-lock.json+462−1View fileUnifiedSplit
@@ -36,11 +36,13 @@
3636 "@types/react": "^18.3.18",
3737 "@types/react-dom": "^18.3.5",
3838 "@vitejs/plugin-react": "^4.3.4",
39 "@vitest/ui": "^4.1.4",
3940 "autoprefixer": "^10.4.20",
4041 "postcss": "^8.4.49",
4142 "tailwindcss": "^3.4.17",
4243 "typescript": "^5.7.3",
43 "vite": "^6.0.7"
44 "vite": "^6.0.7",
45 "vitest": "^4.1.4"
4446 }
4547 },
4648 "node_modules/@alloc/quick-lru": {
@@ -906,6 +908,13 @@
906908 "node": ">= 8"
907909 }
908910 },
911 "node_modules/@polka/url": {
912 "version": "1.0.0-next.29",
913 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
914 "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
915 "dev": true,
916 "license": "MIT"
917 },
909918 "node_modules/@radix-ui/number": {
910919 "version": "1.1.1",
911920 "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -2082,6 +2091,13 @@
20822091 "win32"
20832092 ]
20842093 },
2094 "node_modules/@standard-schema/spec": {
2095 "version": "1.1.0",
2096 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
2097 "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
2098 "dev": true,
2099 "license": "MIT"
2100 },
20852101 "node_modules/@tauri-apps/api": {
20862102 "version": "2.10.1",
20872103 "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
@@ -2408,6 +2424,24 @@
24082424 "@babel/types": "^7.28.2"
24092425 }
24102426 },
2427 "node_modules/@types/chai": {
2428 "version": "5.2.3",
2429 "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
2430 "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
2431 "dev": true,
2432 "license": "MIT",
2433 "dependencies": {
2434 "@types/deep-eql": "*",
2435 "assertion-error": "^2.0.1"
2436 }
2437 },
2438 "node_modules/@types/deep-eql": {
2439 "version": "4.0.2",
2440 "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
2441 "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
2442 "dev": true,
2443 "license": "MIT"
2444 },
24112445 "node_modules/@types/estree": {
24122446 "version": "1.0.8",
24132447 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2464,6 +2498,141 @@
24642498 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
24652499 }
24662500 },
2501 "node_modules/@vitest/expect": {
2502 "version": "4.1.4",
2503 "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
2504 "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
2505 "dev": true,
2506 "license": "MIT",
2507 "dependencies": {
2508 "@standard-schema/spec": "^1.1.0",
2509 "@types/chai": "^5.2.2",
2510 "@vitest/spy": "4.1.4",
2511 "@vitest/utils": "4.1.4",
2512 "chai": "^6.2.2",
2513 "tinyrainbow": "^3.1.0"
2514 },
2515 "funding": {
2516 "url": "https://opencollective.com/vitest"
2517 }
2518 },
2519 "node_modules/@vitest/mocker": {
2520 "version": "4.1.4",
2521 "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
2522 "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
2523 "dev": true,
2524 "license": "MIT",
2525 "dependencies": {
2526 "@vitest/spy": "4.1.4",
2527 "estree-walker": "^3.0.3",
2528 "magic-string": "^0.30.21"
2529 },
2530 "funding": {
2531 "url": "https://opencollective.com/vitest"
2532 },
2533 "peerDependencies": {
2534 "msw": "^2.4.9",
2535 "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
2536 },
2537 "peerDependenciesMeta": {
2538 "msw": {
2539 "optional": true
2540 },
2541 "vite": {
2542 "optional": true
2543 }
2544 }
2545 },
2546 "node_modules/@vitest/pretty-format": {
2547 "version": "4.1.4",
2548 "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
2549 "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
2550 "dev": true,
2551 "license": "MIT",
2552 "dependencies": {
2553 "tinyrainbow": "^3.1.0"
2554 },
2555 "funding": {
2556 "url": "https://opencollective.com/vitest"
2557 }
2558 },
2559 "node_modules/@vitest/runner": {
2560 "version": "4.1.4",
2561 "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
2562 "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
2563 "dev": true,
2564 "license": "MIT",
2565 "dependencies": {
2566 "@vitest/utils": "4.1.4",
2567 "pathe": "^2.0.3"
2568 },
2569 "funding": {
2570 "url": "https://opencollective.com/vitest"
2571 }
2572 },
2573 "node_modules/@vitest/snapshot": {
2574 "version": "4.1.4",
2575 "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
2576 "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
2577 "dev": true,
2578 "license": "MIT",
2579 "dependencies": {
2580 "@vitest/pretty-format": "4.1.4",
2581 "@vitest/utils": "4.1.4",
2582 "magic-string": "^0.30.21",
2583 "pathe": "^2.0.3"
2584 },
2585 "funding": {
2586 "url": "https://opencollective.com/vitest"
2587 }
2588 },
2589 "node_modules/@vitest/spy": {
2590 "version": "4.1.4",
2591 "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
2592 "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
2593 "dev": true,
2594 "license": "MIT",
2595 "funding": {
2596 "url": "https://opencollective.com/vitest"
2597 }
2598 },
2599 "node_modules/@vitest/ui": {
2600 "version": "4.1.4",
2601 "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.4.tgz",
2602 "integrity": "sha512-EgFR7nlj5iTDYZYCvavjFokNYwr3c3ry0sFiCg+N7B233Nwp+NNx7eoF/XvMWDCKY71xXAG3kFkt97ZHBJVL8A==",
2603 "dev": true,
2604 "license": "MIT",
2605 "dependencies": {
2606 "@vitest/utils": "4.1.4",
2607 "fflate": "^0.8.2",
2608 "flatted": "^3.4.2",
2609 "pathe": "^2.0.3",
2610 "sirv": "^3.0.2",
2611 "tinyglobby": "^0.2.15",
2612 "tinyrainbow": "^3.1.0"
2613 },
2614 "funding": {
2615 "url": "https://opencollective.com/vitest"
2616 },
2617 "peerDependencies": {
2618 "vitest": "4.1.4"
2619 }
2620 },
2621 "node_modules/@vitest/utils": {
2622 "version": "4.1.4",
2623 "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
2624 "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
2625 "dev": true,
2626 "license": "MIT",
2627 "dependencies": {
2628 "@vitest/pretty-format": "4.1.4",
2629 "convert-source-map": "^2.0.0",
2630 "tinyrainbow": "^3.1.0"
2631 },
2632 "funding": {
2633 "url": "https://opencollective.com/vitest"
2634 }
2635 },
24672636 "node_modules/any-promise": {
24682637 "version": "1.3.0",
24692638 "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -2504,6 +2673,16 @@
25042673 "node": ">=10"
25052674 }
25062675 },
2676 "node_modules/assertion-error": {
2677 "version": "2.0.1",
2678 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
2679 "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
2680 "dev": true,
2681 "license": "MIT",
2682 "engines": {
2683 "node": ">=12"
2684 }
2685 },
25072686 "node_modules/autoprefixer": {
25082687 "version": "10.4.27",
25092688 "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -2645,6 +2824,16 @@
26452824 ],
26462825 "license": "CC-BY-4.0"
26472826 },
2827 "node_modules/chai": {
2828 "version": "6.2.2",
2829 "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
2830 "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
2831 "dev": true,
2832 "license": "MIT",
2833 "engines": {
2834 "node": ">=18"
2835 }
2836 },
26482837 "node_modules/chokidar": {
26492838 "version": "3.6.0",
26502839 "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2774,6 +2963,13 @@
27742963 "dev": true,
27752964 "license": "ISC"
27762965 },
2966 "node_modules/es-module-lexer": {
2967 "version": "2.0.0",
2968 "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
2969 "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
2970 "dev": true,
2971 "license": "MIT"
2972 },
27772973 "node_modules/esbuild": {
27782974 "version": "0.25.12",
27792975 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2826,6 +3022,26 @@
28263022 "node": ">=6"
28273023 }
28283024 },
3025 "node_modules/estree-walker": {
3026 "version": "3.0.3",
3027 "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
3028 "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
3029 "dev": true,
3030 "license": "MIT",
3031 "dependencies": {
3032 "@types/estree": "^1.0.0"
3033 }
3034 },
3035 "node_modules/expect-type": {
3036 "version": "1.3.0",
3037 "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
3038 "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
3039 "dev": true,
3040 "license": "Apache-2.0",
3041 "engines": {
3042 "node": ">=12.0.0"
3043 }
3044 },
28293045 "node_modules/fast-glob": {
28303046 "version": "3.3.3",
28313047 "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2866,6 +3082,13 @@
28663082 "reusify": "^1.0.4"
28673083 }
28683084 },
3085 "node_modules/fflate": {
3086 "version": "0.8.2",
3087 "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
3088 "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
3089 "dev": true,
3090 "license": "MIT"
3091 },
28693092 "node_modules/fill-range": {
28703093 "version": "7.1.1",
28713094 "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2879,6 +3102,13 @@
28793102 "node": ">=8"
28803103 }
28813104 },
3105 "node_modules/flatted": {
3106 "version": "3.4.2",
3107 "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
3108 "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
3109 "dev": true,
3110 "license": "ISC"
3111 },
28823112 "node_modules/fraction.js": {
28833113 "version": "5.3.4",
28843114 "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -3145,6 +3375,16 @@
31453375 "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
31463376 }
31473377 },
3378 "node_modules/magic-string": {
3379 "version": "0.30.21",
3380 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
3381 "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
3382 "dev": true,
3383 "license": "MIT",
3384 "dependencies": {
3385 "@jridgewell/sourcemap-codec": "^1.5.5"
3386 }
3387 },
31483388 "node_modules/merge2": {
31493389 "version": "1.4.1",
31503390 "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3184,6 +3424,16 @@
31843424 "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
31853425 "license": "MIT"
31863426 },
3427 "node_modules/mrmime": {
3428 "version": "2.0.1",
3429 "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
3430 "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
3431 "dev": true,
3432 "license": "MIT",
3433 "engines": {
3434 "node": ">=10"
3435 }
3436 },
31873437 "node_modules/ms": {
31883438 "version": "2.1.3",
31893439 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3259,6 +3509,17 @@
32593509 "node": ">= 6"
32603510 }
32613511 },
3512 "node_modules/obug": {
3513 "version": "2.1.1",
3514 "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
3515 "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
3516 "dev": true,
3517 "funding": [
3518 "https://github.com/sponsors/sxzz",
3519 "https://opencollective.com/debug"
3520 ],
3521 "license": "MIT"
3522 },
32623523 "node_modules/path-parse": {
32633524 "version": "1.0.7",
32643525 "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3266,6 +3527,13 @@
32663527 "dev": true,
32673528 "license": "MIT"
32683529 },
3530 "node_modules/pathe": {
3531 "version": "2.0.3",
3532 "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
3533 "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
3534 "dev": true,
3535 "license": "MIT"
3536 },
32693537 "node_modules/picocolors": {
32703538 "version": "1.1.1",
32713539 "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3737,6 +4005,28 @@
37374005 "semver": "bin/semver.js"
37384006 }
37394007 },
4008 "node_modules/siginfo": {
4009 "version": "2.0.0",
4010 "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
4011 "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
4012 "dev": true,
4013 "license": "ISC"
4014 },
4015 "node_modules/sirv": {
4016 "version": "3.0.2",
4017 "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
4018 "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
4019 "dev": true,
4020 "license": "MIT",
4021 "dependencies": {
4022 "@polka/url": "^1.0.0-next.24",
4023 "mrmime": "^2.0.0",
4024 "totalist": "^3.0.0"
4025 },
4026 "engines": {
4027 "node": ">=18"
4028 }
4029 },
37404030 "node_modules/source-map-js": {
37414031 "version": "1.2.1",
37424032 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3747,6 +4037,20 @@
37474037 "node": ">=0.10.0"
37484038 }
37494039 },
4040 "node_modules/stackback": {
4041 "version": "0.0.2",
4042 "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
4043 "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
4044 "dev": true,
4045 "license": "MIT"
4046 },
4047 "node_modules/std-env": {
4048 "version": "4.1.0",
4049 "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
4050 "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
4051 "dev": true,
4052 "license": "MIT"
4053 },
37504054 "node_modules/sucrase": {
37514055 "version": "3.35.1",
37524056 "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -3854,6 +4158,23 @@
38544158 "node": ">=0.8"
38554159 }
38564160 },
4161 "node_modules/tinybench": {
4162 "version": "2.9.0",
4163 "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
4164 "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
4165 "dev": true,
4166 "license": "MIT"
4167 },
4168 "node_modules/tinyexec": {
4169 "version": "1.1.1",
4170 "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz",
4171 "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==",
4172 "dev": true,
4173 "license": "MIT",
4174 "engines": {
4175 "node": ">=18"
4176 }
4177 },
38574178 "node_modules/tinyglobby": {
38584179 "version": "0.2.16",
38594180 "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -3902,6 +4223,16 @@
39024223 "url": "https://github.com/sponsors/jonschlinkert"
39034224 }
39044225 },
4226 "node_modules/tinyrainbow": {
4227 "version": "3.1.0",
4228 "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
4229 "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
4230 "dev": true,
4231 "license": "MIT",
4232 "engines": {
4233 "node": ">=14.0.0"
4234 }
4235 },
39054236 "node_modules/to-regex-range": {
39064237 "version": "5.0.1",
39074238 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3915,6 +4246,16 @@
39154246 "node": ">=8.0"
39164247 }
39174248 },
4249 "node_modules/totalist": {
4250 "version": "3.0.1",
4251 "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
4252 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
4253 "dev": true,
4254 "license": "MIT",
4255 "engines": {
4256 "node": ">=6"
4257 }
4258 },
39184259 "node_modules/ts-interface-checker": {
39194260 "version": "0.1.13",
39204261 "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -4129,6 +4470,126 @@
41294470 "url": "https://github.com/sponsors/jonschlinkert"
41304471 }
41314472 },
4473 "node_modules/vitest": {
4474 "version": "4.1.4",
4475 "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
4476 "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
4477 "dev": true,
4478 "license": "MIT",
4479 "dependencies": {
4480 "@vitest/expect": "4.1.4",
4481 "@vitest/mocker": "4.1.4",
4482 "@vitest/pretty-format": "4.1.4",
4483 "@vitest/runner": "4.1.4",
4484 "@vitest/snapshot": "4.1.4",
4485 "@vitest/spy": "4.1.4",
4486 "@vitest/utils": "4.1.4",
4487 "es-module-lexer": "^2.0.0",
4488 "expect-type": "^1.3.0",
4489 "magic-string": "^0.30.21",
4490 "obug": "^2.1.1",
4491 "pathe": "^2.0.3",
4492 "picomatch": "^4.0.3",
4493 "std-env": "^4.0.0-rc.1",
4494 "tinybench": "^2.9.0",
4495 "tinyexec": "^1.0.2",
4496 "tinyglobby": "^0.2.15",
4497 "tinyrainbow": "^3.1.0",
4498 "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
4499 "why-is-node-running": "^2.3.0"
4500 },
4501 "bin": {
4502 "vitest": "vitest.mjs"
4503 },
4504 "engines": {
4505 "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
4506 },
4507 "funding": {
4508 "url": "https://opencollective.com/vitest"
4509 },
4510 "peerDependencies": {
4511 "@edge-runtime/vm": "*",
4512 "@opentelemetry/api": "^1.9.0",
4513 "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
4514 "@vitest/browser-playwright": "4.1.4",
4515 "@vitest/browser-preview": "4.1.4",
4516 "@vitest/browser-webdriverio": "4.1.4",
4517 "@vitest/coverage-istanbul": "4.1.4",
4518 "@vitest/coverage-v8": "4.1.4",
4519 "@vitest/ui": "4.1.4",
4520 "happy-dom": "*",
4521 "jsdom": "*",
4522 "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
4523 },
4524 "peerDependenciesMeta": {
4525 "@edge-runtime/vm": {
4526 "optional": true
4527 },
4528 "@opentelemetry/api": {
4529 "optional": true
4530 },
4531 "@types/node": {
4532 "optional": true
4533 },
4534 "@vitest/browser-playwright": {
4535 "optional": true
4536 },
4537 "@vitest/browser-preview": {
4538 "optional": true
4539 },
4540 "@vitest/browser-webdriverio": {
4541 "optional": true
4542 },
4543 "@vitest/coverage-istanbul": {
4544 "optional": true
4545 },
4546 "@vitest/coverage-v8": {
4547 "optional": true
4548 },
4549 "@vitest/ui": {
4550 "optional": true
4551 },
4552 "happy-dom": {
4553 "optional": true
4554 },
4555 "jsdom": {
4556 "optional": true
4557 },
4558 "vite": {
4559 "optional": false
4560 }
4561 }
4562 },
4563 "node_modules/vitest/node_modules/picomatch": {
4564 "version": "4.0.4",
4565 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
4566 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
4567 "dev": true,
4568 "license": "MIT",
4569 "engines": {
4570 "node": ">=12"
4571 },
4572 "funding": {
4573 "url": "https://github.com/sponsors/jonschlinkert"
4574 }
4575 },
4576 "node_modules/why-is-node-running": {
4577 "version": "2.3.0",
4578 "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
4579 "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
4580 "dev": true,
4581 "license": "MIT",
4582 "dependencies": {
4583 "siginfo": "^2.0.0",
4584 "stackback": "0.0.2"
4585 },
4586 "bin": {
4587 "why-is-node-running": "cli.js"
4588 },
4589 "engines": {
4590 "node": ">=8"
4591 }
4592 },
41324593 "node_modules/yallist": {
41334594 "version": "3.1.1",
41344595 "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
Modifiedpackage.json+9−5View fileUnifiedSplit
@@ -1,5 +1,5 @@
11{
2 "name": "marco-reid-voice",
2 "name": "voxlen",
33 "private": true,
44 "version": "1.0.9",
55 "description": "Marco Reid Voice - the platform input layer for Marco Reid. Voice-first dictation and commands for legal and accounting professionals across desktop, mobile, and web.",
@@ -8,7 +8,9 @@
88 "dev": "vite",
99 "build": "tsc && vite build",
1010 "preview": "vite preview",
11 "tauri": "tauri"
11 "tauri": "tauri",
12 "test": "vitest run",
13 "test:watch": "vitest"
1214 },
1315 "dependencies": {
1416 "@radix-ui/react-dialog": "^1.1.4",
@@ -20,10 +22,10 @@
2022 "@radix-ui/react-tabs": "^1.1.2",
2123 "@radix-ui/react-tooltip": "^1.1.6",
2224 "@tauri-apps/api": "^2.2.0",
23 "@tauri-apps/plugin-global-shortcut": "^2.2.0",
24 "@tauri-apps/plugin-notification": "^2.2.0",
2525 "@tauri-apps/plugin-dialog": "^2.2.0",
2626 "@tauri-apps/plugin-fs": "^2.2.0",
27 "@tauri-apps/plugin-global-shortcut": "^2.2.0",
28 "@tauri-apps/plugin-notification": "^2.2.0",
2729 "@tauri-apps/plugin-store": "^2.2.0",
2830 "@tauri-apps/plugin-updater": "^2.2.0",
2931 "clsx": "^2.1.1",
@@ -39,10 +41,12 @@
3941 "@types/react": "^18.3.18",
4042 "@types/react-dom": "^18.3.5",
4143 "@vitejs/plugin-react": "^4.3.4",
44 "@vitest/ui": "^4.1.4",
4245 "autoprefixer": "^10.4.20",
4346 "postcss": "^8.4.49",
4447 "tailwindcss": "^3.4.17",
4548 "typescript": "^5.7.3",
46 "vite": "^6.0.7"
49 "vite": "^6.0.7",
50 "vitest": "^4.1.4"
4751 }
4852}
Modifiedsrc-tauri/capabilities/default.json+1−1View fileUnifiedSplit
@@ -1,7 +1,7 @@
11{
22 "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
33 "identifier": "default",
4 "description": "Default capabilities for Voxlen",
4 "description": "Default capabilities for Marco Reid Voice",
55 "windows": ["main"],
66 "permissions": [
77 "core:default",
Modifiedsrc-tauri/src/commands/mod.rs+1−0View fileUnifiedSplit
@@ -7,4 +7,5 @@ pub mod permissions;
77pub mod settings;
88pub mod stt;
99pub mod text_injection;
10pub mod translate;
1011pub mod window;
Modifiedsrc-tauri/src/commands/settings.rs+4−0View fileUnifiedSplit
@@ -20,6 +20,8 @@ pub struct AppSettings {
2020 pub stt_language: String,
2121 pub auto_detect_language: bool,
2222 pub custom_vocabulary: Vec<String>,
23
24 pub speaker_diarization: bool,
2325
2426 // Grammar
2527 pub grammar_enabled: bool,
@@ -66,6 +68,7 @@ impl Default for AppSettings {
6668 stt_language: "en".to_string(),
6769 auto_detect_language: true,
6870 custom_vocabulary: Vec::new(),
71 speaker_diarization: false,
6972
7073 grammar_enabled: true,
7174 grammar_api_key: None,
@@ -184,6 +187,7 @@ fn apply_settings_to_engines(
184187 smart_format: s.smart_format,
185188 profanity_filter: false,
186189 custom_vocabulary: s.custom_vocabulary.clone(),
190 speaker_diarization: s.speaker_diarization,
187191 };
188192 stt_engine_arc.read().set_config(stt_config);
189193
Addedsrc-tauri/src/commands/translate.rs+184−0View fileUnifiedSplit
@@ -0,0 +1,184 @@
1use serde::{Deserialize, Serialize};
2
3use super::grammar::{GrammarProvider, get_grammar_config};
4
5
6pub struct TranslationResult {
7 pub original: String,
8 pub translated: String,
9 pub target_language: String,
10 pub detected_source: Option<String>,
11}
12
13/// Translate `text` to `target_language_code` using whichever provider the
14/// user has configured for grammar correction. Re-uses the same API key so
15/// users never need to supply a separate one — consistent with Voxlen's
16/// "bring your own key" model.
17
18pub async fn translate_text(
19 text: String,
20 target_language: String,
21) -> Result<TranslationResult, String> {
22 if text.trim().is_empty() {
23 return Ok(TranslationResult {
24 original: text.clone(),
25 translated: text,
26 target_language,
27 detected_source: None,
28 });
29 }
30
31 let config = get_grammar_config()?;
32 let api_key = config
33 .api_key
34 .clone()
35 .ok_or("No translation API key configured. Set the Grammar API key in Settings.")?;
36
37 match config.provider {
38 GrammarProvider::Claude => translate_with_claude(&text, &target_language, &api_key).await,
39 GrammarProvider::OpenAI => translate_with_openai(&text, &target_language, &api_key).await,
40 }
41}
42
43fn language_name(code: &str) -> &str {
44 match code {
45 "en" => "English",
46 "es" => "Spanish",
47 "fr" => "French",
48 "de" => "German",
49 "it" => "Italian",
50 "pt" => "Portuguese",
51 "nl" => "Dutch",
52 "pl" => "Polish",
53 "ru" => "Russian",
54 "ja" => "Japanese",
55 "ko" => "Korean",
56 "zh" => "Chinese",
57 "ar" => "Arabic",
58 "hi" => "Hindi",
59 "tr" => "Turkish",
60 "sv" => "Swedish",
61 "da" => "Danish",
62 "fi" => "Finnish",
63 "no" => "Norwegian",
64 "uk" => "Ukrainian",
65 _ => code,
66 }
67}
68
69async fn translate_with_claude(
70 text: &str,
71 target_code: &str,
72 api_key: &str,
73) -> Result<TranslationResult, String> {
74 let target = language_name(target_code);
75 let prompt = format!(
76 r#"Translate the following text into {target}. Preserve meaning, tone, and any professional/legal terminology. Do NOT add commentary — respond ONLY with JSON in this format:
77{{"translated": "...", "detected_source": "ISO-639-1 code or null"}}
78
79Text:
80"{text}""#,
81 target = target,
82 text = text
83 );
84
85 let client = reqwest::Client::new();
86 let response = client
87 .post("https://api.anthropic.com/v1/messages")
88 .header("x-api-key", api_key)
89 .header("anthropic-version", "2023-06-01")
90 .header("content-type", "application/json")
91 .json(&serde_json::json!({
92 "model": "claude-haiku-4-5-20251001",
93 "max_tokens": 2048,
94 "messages": [{ "role": "user", "content": prompt }]
95 }))
96 .send()
97 .await
98 .map_err(|e| format!("Translation request failed: {}", e))?;
99
100 if !response.status().is_success() {
101 let error = response.text().await.unwrap_or_default();
102 return Err(format!("Claude API error: {}", error));
103 }
104
105 let result: serde_json::Value = response
106 .json()
107 .await
108 .map_err(|e| format!("Failed to parse response: {}", e))?;
109
110 let content = result["content"][0]["text"]
111 .as_str()
112 .ok_or("No content in response")?;
113
114 let parsed: serde_json::Value = serde_json::from_str(content)
115 .map_err(|e| format!("Failed to parse translation JSON: {}", e))?;
116
117 Ok(TranslationResult {
118 original: text.to_string(),
119 translated: parsed["translated"]
120 .as_str()
121 .unwrap_or(text)
122 .to_string(),
123 target_language: target_code.to_string(),
124 detected_source: parsed["detected_source"].as_str().map(|s| s.to_string()),
125 })
126}
127
128async fn translate_with_openai(
129 text: &str,
130 target_code: &str,
131 api_key: &str,
132) -> Result<TranslationResult, String> {
133 let target = language_name(target_code);
134 let prompt = format!(
135 r#"Translate this text into {target}. Preserve meaning, tone, and domain terminology. Respond ONLY with JSON:
136{{"translated": "text", "detected_source": "ISO-639-1 or null"}}
137
138Text: "{text}""#,
139 target = target,
140 text = text
141 );
142
143 let client = reqwest::Client::new();
144 let response = client
145 .post("https://api.openai.com/v1/chat/completions")
146 .header("Authorization", format!("Bearer {}", api_key))
147 .header("Content-Type", "application/json")
148 .json(&serde_json::json!({
149 "model": "gpt-4o-mini",
150 "messages": [{ "role": "user", "content": prompt }],
151 "temperature": 0.0,
152 "response_format": { "type": "json_object" }
153 }))
154 .send()
155 .await
156 .map_err(|e| format!("Translation request failed: {}", e))?;
157
158 if !response.status().is_success() {
159 let error = response.text().await.unwrap_or_default();
160 return Err(format!("OpenAI API error: {}", error));
161 }
162
163 let result: serde_json::Value = response
164 .json()
165 .await
166 .map_err(|e| format!("Failed to parse response: {}", e))?;
167
168 let content = result["choices"][0]["message"]["content"]
169 .as_str()
170 .ok_or("No content in response")?;
171
172 let parsed: serde_json::Value = serde_json::from_str(content)
173 .map_err(|e| format!("Failed to parse translation JSON: {}", e))?;
174
175 Ok(TranslationResult {
176 original: text.to_string(),
177 translated: parsed["translated"]
178 .as_str()
179 .unwrap_or(text)
180 .to_string(),
181 target_language: target_code.to_string(),
182 detected_source: parsed["detected_source"].as_str().map(|s| s.to_string()),
183 })
184}
Modifiedsrc-tauri/src/lib.rs+7−5View fileUnifiedSplit
@@ -68,12 +68,12 @@ pub fn run() {
6868 app.manage(text_injection::InjectorState::new(injector));
6969
7070 // Build system tray menu
71 let show_item = MenuItem::with_id(app, "show", "Show Voxlen", true, None::<&str>)?;
71 let show_item = MenuItem::with_id(app, "show", "Show Marco Reid Voice", true, None::<&str>)?;
7272 let dictate_item = MenuItem::with_id(app, "dictate", "Start Dictation", true, None::<&str>)?;
7373 let grammar_item = MenuItem::with_id(app, "grammar", "Grammar Panel", true, None::<&str>)?;
7474 let separator = MenuItem::with_id(app, "sep", "────────────", false, None::<&str>)?;
7575 let settings_item = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
76 let quit_item = MenuItem::with_id(app, "quit", "Quit Voxlen", true, None::<&str>)?;
76 let quit_item = MenuItem::with_id(app, "quit", "Quit Marco Reid Voice", true, None::<&str>)?;
7777
7878 let menu = Menu::with_items(
7979 app,
@@ -89,7 +89,7 @@ pub fn run() {
8989 )?;
9090
9191 let _tray = TrayIconBuilder::new()
92 .tooltip("Voxlen — AI voice dictation for legal and accounting professionals")
92 .tooltip("Marco Reid Voice — AI voice dictation for legal and accounting professionals")
9393 .menu(&menu)
9494 .show_menu_on_left_click(false)
9595 .on_menu_event(move |app, event| {
@@ -138,7 +138,7 @@ pub fn run() {
138138 })
139139 .build(app)?;
140140
141 log::info!("Voxlen initialized successfully");
141 log::info!("Marco Reid Voice initialized successfully");
142142 Ok(())
143143 })
144144 .invoke_handler(tauri::generate_handler![
@@ -161,6 +161,8 @@ pub fn run() {
161161 commands::grammar::correct_grammar,
162162 commands::grammar::get_grammar_config,
163163 commands::grammar::set_grammar_config,
164 // Translation
165 commands::translate::translate_text,
164166 // Text injection commands
165167 commands::text_injection::inject_text,
166168 commands::text_injection::get_injection_mode,
@@ -196,5 +198,5 @@ pub fn run() {
196198 }
197199 })
198200 .run(tauri::generate_context!())
199 .expect("error while running Voxlen");
201 .expect("error while running Marco Reid Voice");
200202}
Modifiedsrc-tauri/src/stt/cloud.rs+41−1View fileUnifiedSplit
@@ -101,6 +101,10 @@ pub async fn deepgram_transcribe(
101101 url.push_str(&format!("&keywords={}", urlencoding(word)));
102102 }
103103
104 if config.speaker_diarization {
105 url.push_str("&diarize=true");
106 }
107
104108 let client = reqwest::Client::new();
105109 let response = client
106110 .post(&url)
@@ -118,9 +122,45 @@ pub async fn deepgram_transcribe(
118122 let result: serde_json::Value = response.json().await?;
119123
120124 let channel = &result["results"]["channels"][0]["alternatives"][0];
121 let text = channel["transcript"].as_str().unwrap_or("").to_string();
125 let plain_text = channel["transcript"].as_str().unwrap_or("").to_string();
122126 let confidence = channel["confidence"].as_f64().unwrap_or(0.0) as f32;
123127
128 // When diarization is enabled, re-assemble the transcript with speaker
129 // labels by grouping consecutive words that share the same `speaker`.
130 let text = if config.speaker_diarization {
131 if let Some(word_arr) = channel["words"].as_array() {
132 let mut out = String::new();
133 let mut current_speaker: Option<i64> = None;
134 for w in word_arr {
135 let spk = w["speaker"].as_i64();
136 let token = w["punctuated_word"]
137 .as_str()
138 .or_else(|| w["word"].as_str())
139 .unwrap_or("");
140 if token.is_empty() {
141 continue;
142 }
143 if spk != current_speaker {
144 if !out.is_empty() {
145 out.push('\n');
146 }
147 if let Some(s) = spk {
148 out.push_str(&format!("[Speaker {}] ", s + 1));
149 }
150 current_speaker = spk;
151 } else if !out.is_empty() && !out.ends_with(' ') && !out.ends_with('\n') {
152 out.push(' ');
153 }
154 out.push_str(token);
155 }
156 if out.is_empty() { plain_text } else { out }
157 } else {
158 plain_text
159 }
160 } else {
161 plain_text
162 };
163
124164 let language = result["results"]["channels"][0]["detected_language"]
125165 .as_str()
126166 .map(|s| s.to_string());
Modifiedsrc-tauri/src/stt/mod.rs+5−0View fileUnifiedSplit
@@ -42,6 +42,10 @@ pub struct SttConfig {
4242 pub smart_format: bool,
4343 pub profanity_filter: bool,
4444 pub custom_vocabulary: Vec<String>,
45 /// When true (Deepgram only), request speaker diarization and
46 /// prefix output with speaker labels like "[Speaker 1]: ...".
47
48 pub speaker_diarization: bool,
4549}
4650
4751impl Default for SttConfig {
@@ -56,6 +60,7 @@ impl Default for SttConfig {
5660 smart_format: true,
5761 profanity_filter: false,
5862 custom_vocabulary: Vec::new(),
63 speaker_diarization: false,
5964 }
6065 }
6166}
Modifiedsrc-tauri/tauri.conf.json+4−4View fileUnifiedSplit
@@ -14,7 +14,7 @@
1414 "windows": [
1515 {
1616 "label": "main",
17 "title": "Voxlen",
17 "title": "Marco Reid Voice",
1818 "width": 1100,
1919 "height": 720,
2020 "minWidth": 800,
@@ -30,7 +30,7 @@
3030 "trayIcon": {
3131 "iconPath": "icons/icon.png",
3232 "iconAsTemplate": true,
33 "tooltip": "Voxlen"
33 "tooltip": "Marco Reid Voice"
3434 },
3535 "security": {
3636 "csp": null
@@ -48,8 +48,8 @@
4848 ],
4949 "category": "Productivity",
5050 "shortDescription": "AI-powered voice dictation for professionals",
51 "longDescription": "Voxlen is the most advanced AI voice dictation desktop app. Speak naturally to dictate legal letters, court filings, demand letters, tax advisories, and audit opinions — with real-time Claude enhancement, matter-aware context, custom vocabulary, and injection into any application.",
52 "copyright": "Copyright 2026 Voxlen",
51 "longDescription": "Marco Reid Voice is the most advanced AI voice dictation desktop app. Speak naturally to dictate legal letters, court filings, demand letters, tax advisories, and audit opinions — with real-time Claude enhancement, matter-aware context, custom vocabulary, and injection into any application.",
52 "copyright": "Copyright 2026 Marco Reid",
5353 "macOS": {
5454 "entitlements": "Entitlements.plist",
5555 "minimumSystemVersion": "12.0",
Modifiedsrc/App.tsx+13−4View fileUnifiedSplit
@@ -1,9 +1,11 @@
11import { useState, useEffect, useCallback } from "react";
22import { TitleBar } from "@/components/layout/TitleBar";
33import { Sidebar } from "@/components/layout/Sidebar";
4import { ShortcutsCheatsheet } from "@/components/layout/ShortcutsCheatsheet";
45import { DictationPanel } from "@/components/dictation/DictationPanel";
56import { GrammarPanel } from "@/components/grammar/GrammarPanel";
67import { HistoryPanel } from "@/components/dictation/HistoryPanel";
8import { FlywheelPanel } from "@/components/flywheel/FlywheelPanel";
79import { SettingsPanel } from "@/components/settings/SettingsPanel";
810import { AdminPanel } from "@/components/settings/AdminPanel";
911import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
@@ -16,7 +18,7 @@ import { useTauriEvents } from "@/hooks/useTauriEvents";
1618import { useGlobalShortcuts } from "@/hooks/useGlobalShortcuts";
1719import { loadFlywheel } from "@/stores/flywheel";
1820
19type View = "dictation" | "grammar" | "history" | "settings" | "admin";
21type View = "dictation" | "grammar" | "history" | "flywheel" | "settings" | "admin";
2022
2123export default function App() {
2224 // Load saved settings from disk/localStorage on startup
@@ -66,12 +68,12 @@ export default function App() {
6668 }
6769 } catch {
6870 // Not in Tauri - check localStorage
69 const completed = localStorage.getItem("marcoreid_onboarding_complete");
71 const completed = localStorage.getItem("voxlen_onboarding_complete");
7072 setShowOnboarding(!completed);
7173
7274 // Load saved settings from localStorage
7375 try {
74 const saved = localStorage.getItem("voxlen_settings");
76 const saved = localStorage.getItem("marcoreid_settings");
7577 if (saved) {
7678 useSettingsStore.getState().updateSettings(JSON.parse(saved));
7779 }
@@ -202,7 +204,7 @@ export default function App() {
202204 await store.set("onboarding_complete", true);
203205 await store.save();
204206 } catch {
205 localStorage.setItem("marcoreid_onboarding_complete", "true");
207 localStorage.setItem("voxlen_onboarding_complete", "true");
206208 }
207209
208210 // Save current settings through the persistence pipeline
@@ -242,6 +244,12 @@ export default function App() {
242244 <HistoryPanel />
243245 </ErrorBoundary>
244246 );
247 case "flywheel":
248 return (
249 <ErrorBoundary label="Flywheel">
250 <FlywheelPanel />
251 </ErrorBoundary>
252 );
245253 case "settings":
246254 return (
247255 <ErrorBoundary label="Settings">
@@ -285,6 +293,7 @@ export default function App() {
285293 />
286294 <main className="flex-1 min-w-0 overflow-hidden">{renderView()}</main>
287295 </div>
296 <ShortcutsCheatsheet />
288297 </div>
289298 );
290299}
Modifiedsrc/components/dictation/HistoryPanel.tsx+1−1View fileUnifiedSplit
@@ -195,7 +195,7 @@ export function HistoryPanel() {
195195 onClick={() =>
196196 setExpandedId(expandedId === entry.id ? null : entry.id)
197197 }
198 className="text-xs text-voxlen-500 hover:text-voxlen-400 mt-1 flex items-center gap-1"
198 className="text-xs text-marcoreid-500 hover:text-marcoreid-400 mt-1 flex items-center gap-1"
199199 >
200200 {expandedId === entry.id ? (
201201 <>
Modifiedsrc/components/dictation/TranscriptView.tsx+30−15View fileUnifiedSplit
@@ -2,7 +2,7 @@ import React, { useEffect, useRef } from "react";
22import { cn } from "@/lib/utils";
33import { useDictationStore } from "@/stores/dictation";
44import { useSettingsStore } from "@/stores/settings";
5import { Copy, Check, Wand2 } from "lucide-react";
5import { Copy, Check, Wand2, Languages } from "lucide-react";
66import { Button } from "@/components/ui/Button";
77import { formatTimestamp } from "@/lib/utils";
88
@@ -111,21 +111,36 @@ export function TranscriptView({
111111 <span className="text-[10px] text-surface-600 font-mono tabular-nums mt-1 shrink-0">
112112 {formatTimestamp(segment.timestamp)}
113113 </span>
114 <p
115 className={cn(
116 "leading-relaxed",
117 segment.correctedText
118 ? "text-surface-950"
119 : "text-surface-900"
114 <div className="flex-1">
115 <p
116 className={cn(
117 "leading-relaxed",
118 segment.correctedText
119 ? "text-surface-950"
120 : "text-surface-900"
121 )}
122 >
123 {segment.correctedText || segment.text}
124 {segment.grammarApplied && (
125 <span className="inline-flex ml-1.5 align-middle">
126 <Wand2 className="h-3 w-3 text-brass-500" strokeWidth={1.75} />
127 </span>
128 )}
129 </p>
130 {segment.translatedText && (
131 <p className="mt-1 text-[0.92em] italic text-surface-700 leading-relaxed font-display flex items-baseline gap-1.5">
132 <Languages className="h-3 w-3 text-brass-500 shrink-0 self-center" strokeWidth={1.75} />
133 <span>
134 {segment.translatedText}
135 {segment.translatedToLanguage && (
136 <span className="ml-1.5 text-[10px] uppercase tracking-wider text-surface-500 font-mono not-italic">
137 {segment.translatedToLanguage}
138 </span>
139 )}
140 </span>
141 </p>
120142 )}
121 >
122 {segment.correctedText || segment.text}
123 {segment.grammarApplied && (
124 <span className="inline-flex ml-1.5 align-middle">
125 <Wand2 className="h-3 w-3 text-brass-500" strokeWidth={1.75} />
126 </span>
127 )}
128 </p>
143 </div>
129144 </div>
130145 </div>
131146 ))}
Addedsrc/components/flywheel/FlywheelPanel.tsx+450−0View fileUnifiedSplit
@@ -0,0 +1,450 @@
1import { useMemo, useState } from "react";
2import {
3 Brain,
4 BookOpen,
5 Wand2,
6 Trash2,
7 Plus,
8 BarChart3,
9 ShieldCheck,
10 Search,
11} from "lucide-react";
12import { Button } from "@/components/ui/Button";
13import { Badge } from "@/components/ui/Badge";
14import { Input } from "@/components/ui/Input";
15import { useFlywheelStore } from "@/stores/flywheel";
16import { formatDuration } from "@/lib/utils";
17
18type Tab = "vocabulary" | "corrections" | "metrics";
19
20export function FlywheelPanel() {
21 const [tab, setTab] = useState<Tab>("vocabulary");
22 const [query, setQuery] = useState("");
23 const [newWord, setNewWord] = useState("");
24 const [confirmClear, setConfirmClear] = useState(false);
25
26 const vocabulary = useFlywheelStore((s) => s.vocabulary);
27 const corrections = useFlywheelStore((s) => s.corrections);
28 const metrics = useFlywheelStore((s) => s.metrics);
29 const addVocabulary = useFlywheelStore((s) => s.addVocabulary);
30 const removeVocabulary = useFlywheelStore((s) => s.removeVocabulary);
31 const clearAll = useFlywheelStore((s) => s.clearAll);
32
33 const filteredVocab = useMemo(() => {
34 const q = query.toLowerCase().trim();
35 const sorted = [...vocabulary].sort((a, b) => b.frequency - a.frequency);
36 return q ? sorted.filter((v) => v.word.toLowerCase().includes(q)) : sorted;
37 }, [vocabulary, query]);
38
39 const filteredCorrections = useMemo(() => {
40 const q = query.toLowerCase().trim();
41 const sorted = [...corrections].sort((a, b) => b.occurrences - a.occurrences);
42 return q
43 ? sorted.filter(
44 (c) =>
45 c.original.toLowerCase().includes(q) ||
46 c.corrected.toLowerCase().includes(q)
47 )
48 : sorted;
49 }, [corrections, query]);
50
51 const handleAdd = () => {
52 const word = newWord.trim();
53 if (!word) return;
54 addVocabulary(word, "manual");
55 setNewWord("");
56 };
57
58 const totalAcceptance =
59 metrics.correctionsApplied + metrics.correctionsRejected;
60 const acceptanceRate =
61 totalAcceptance > 0
62 ? Math.round((metrics.correctionsApplied / totalAcceptance) * 100)
63 : 0;
64
65 return (
66 <div className="flex flex-col h-full p-8 gap-5 overflow-hidden">
67 <div className="flex items-center justify-between">
68 <div className="flex items-center gap-3.5">
69 <div className="flex items-center justify-center w-10 h-10 rounded-xl bg-surface-200">
70 <Brain className="h-5 w-5 text-surface-700" />
71 </div>
72 <div>
73 <h2 className="text-xl font-bold text-surface-950">Flywheel</h2>
74 <p className="text-xs text-surface-600">
75 Local-only learning · nothing leaves your device
76 </p>
77 </div>
78 </div>
79 <div className="flex items-center gap-2">
80 <Badge variant="success" dot>
81 <ShieldCheck className="h-2.5 w-2.5" /> On-device
82 </Badge>
83 {(vocabulary.length > 0 ||
84 corrections.length > 0 ||
85 metrics.totalSessions > 0) && (
86 <>
87 {confirmClear ? (
88 <>
89 <span className="text-xs text-surface-600">Reset all?</span>
90 <Button
91 variant="danger"
92 size="sm"
93 onClick={() => {
94 clearAll();
95 setConfirmClear(false);
96 }}
97 >
98 Reset
99 </Button>
100 <Button
101 variant="ghost"
102 size="sm"
103 onClick={() => setConfirmClear(false)}
104 >
105 Cancel
106 </Button>
107 </>
108 ) : (
109 <Button
110 variant="ghost"
111 size="sm"
112 onClick={() => setConfirmClear(true)}
113 >
114 <Trash2 className="h-3.5 w-3.5" /> Reset
115 </Button>
116 )}
117 </>
118 )}
119 </div>
120 </div>
121
122 <div className="flex items-center gap-1 border-b border-surface-300/50">
123 <TabButton
124 active={tab === "vocabulary"}
125 onClick={() => setTab("vocabulary")}
126 icon={<BookOpen className="h-3.5 w-3.5" />}
127 label="Vocabulary"
128 count={vocabulary.length}
129 />
130 <TabButton
131 active={tab === "corrections"}
132 onClick={() => setTab("corrections")}
133 icon={<Wand2 className="h-3.5 w-3.5" />}
134 label="Corrections"
135 count={corrections.length}
136 />
137 <TabButton
138 active={tab === "metrics"}
139 onClick={() => setTab("metrics")}
140 icon={<BarChart3 className="h-3.5 w-3.5" />}
141 label="Metrics"
142 />
143 </div>
144
145 {tab !== "metrics" && (
146 <div className="flex items-center gap-2">
147 <div className="flex-1">
148 <Input
149 icon={<Search className="h-4 w-4" />}
150 placeholder={
151 tab === "vocabulary"
152 ? "Search vocabulary..."
153 : "Search correction patterns..."
154 }
155 value={query}
156 onChange={(e) => setQuery(e.target.value)}
157 />
158 </div>
159 {tab === "vocabulary" && (
160 <div className="flex items-center gap-2 w-[320px]">
161 <Input
162 placeholder="Add a word..."
163 value={newWord}
164 onChange={(e) => setNewWord(e.target.value)}
165 onKeyDown={(e) => {
166 if (e.key === "Enter") handleAdd();
167 }}
168 />
169 <Button
170 variant="secondary"
171 size="md"
172 onClick={handleAdd}
173 disabled={!newWord.trim()}
174 >
175 <Plus className="h-3.5 w-3.5" /> Add
176 </Button>
177 </div>
178 )}
179 </div>
180 )}
181
182 <div className="flex-1 min-h-0 overflow-auto rounded-lg border border-surface-300/50 bg-surface-50/50">
183 {tab === "vocabulary" && (
184 <VocabularyList
185 entries={filteredVocab}
186 onRemove={removeVocabulary}
187 />
188 )}
189 {tab === "corrections" && (
190 <CorrectionsList entries={filteredCorrections} />
191 )}
192 {tab === "metrics" && (
193 <MetricsView
194 metrics={metrics}
195 acceptanceRate={acceptanceRate}
196 />
197 )}
198 </div>
199 </div>
200 );
201}
202
203function TabButton({
204 active,
205 onClick,
206 icon,
207 label,
208 count,
209}: {
210 active: boolean;
211 onClick: () => void;
212 icon: React.ReactNode;
213 label: string;
214 count?: number;
215}) {
216 return (
217 <button
218 type="button"
219 onClick={onClick}
220 className={
221 "relative inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors " +
222 (active
223 ? "text-surface-950"
224 : "text-surface-600 hover:text-surface-900")
225 }
226 >
227 {icon}
228 {label}
229 {typeof count === "number" && (
230 <span className="text-[10px] text-surface-600">{count}</span>
231 )}
232 {active && (
233 <span className="absolute left-3 right-3 -bottom-px h-[2px] bg-brass-400 rounded-full" />
234 )}
235 </button>
236 );
237}
238
239function VocabularyList({
240 entries,
241 onRemove,
242}: {
243 entries: ReturnType<typeof useFlywheelStore.getState>["vocabulary"];
244 onRemove: (word: string) => void;
245}) {
246 if (entries.length === 0) {
247 return (
248 <EmptyState
249 icon={<BookOpen className="h-6 w-6" />}
250 title="No vocabulary yet"
251 description="Terms you dictate often get added automatically. You can also add words manually so the AI never flags them."
252 />
253 );
254 }
255
256 return (
257 <ul className="divide-y divide-surface-300/40">
258 {entries.map((entry) => (
259 <li
260 key={entry.word + entry.addedAt}
261 className="flex items-center justify-between px-4 py-2.5"
262 >
263 <div className="flex items-center gap-3 min-w-0">
264 <span className="text-sm text-surface-900 font-medium truncate">
265 {entry.word}
266 </span>
267 <Badge variant={entry.source === "manual" ? "info" : "default"}>
268 {entry.source === "manual" ? "Manual" : "Auto"}
269 </Badge>
270 <span className="text-[11px] text-surface-600">
271 {entry.frequency}×
272 </span>
273 </div>
274 <button
275 type="button"
276 onClick={() => onRemove(entry.word)}
277 className="text-surface-600 hover:text-red-500 transition-colors p-1"
278 aria-label={"Remove " + entry.word}
279 >
280 <Trash2 className="h-3.5 w-3.5" />
281 </button>
282 </li>
283 ))}
284 </ul>
285 );
286}
287
288function CorrectionsList({
289 entries,
290}: {
291 entries: ReturnType<typeof useFlywheelStore.getState>["corrections"];
292}) {
293 if (entries.length === 0) {
294 return (
295 <EmptyState
296 icon={<Wand2 className="h-6 w-6" />}
297 title="No correction patterns yet"
298 description="As you accept grammar corrections, the most common edits appear here. Over time they can be pre-applied locally to cut API latency."
299 />
300 );
301 }
302
303 return (
304 <ul className="divide-y divide-surface-300/40">
305 {entries.map((entry, idx) => (
306 <li key={idx} className="px-4 py-2.5 flex items-center gap-4">
307 <div className="flex-1 min-w-0 text-sm flex items-center gap-3">
308 <span className="text-surface-600 line-through truncate">
309 {entry.original}
310 </span>
311 <span className="text-surface-400">→</span>
312 <span className="text-surface-950 font-medium truncate">
313 {entry.corrected}
314 </span>
315 </div>
316 <Badge variant="default">{entry.category}</Badge>
317 <span className="text-[11px] text-surface-600 w-10 text-right">
318 {entry.occurrences}×
319 </span>
320 </li>
321 ))}
322 </ul>
323 );
324}
325
326function MetricsView({
327 metrics,
328 acceptanceRate,
329}: {
330 metrics: ReturnType<typeof useFlywheelStore.getState>["metrics"];
331 acceptanceRate: number;
332}) {
333 const last14 = useMemo(() => {
334 const out: { day: string; count: number }[] = [];
335 const now = new Date();
336 for (let i = 13; i >= 0; i--) {
337 const d = new Date(now);
338 d.setDate(d.getDate() - i);
339 const key = d.toISOString().slice(0, 10);
340 out.push({ day: key, count: metrics.sessionsPerDay[key] || 0 });
341 }
342 return out;
343 }, [metrics.sessionsPerDay]);
344
345 const max = Math.max(1, ...last14.map((d) => d.count));
346
347 return (
348 <div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-5">
349 <MetricCard
350 label="Sessions"
351 value={metrics.totalSessions.toLocaleString()}
352 />
353 <MetricCard
354 label="Words dictated"
355 value={metrics.totalWords.toLocaleString()}
356 />
357 <MetricCard
358 label="Total time"
359 value={formatDuration(metrics.totalDuration * 1000)}
360 />
361 <MetricCard
362 label="Avg WPM"
363 value={metrics.avgWordsPerMinute.toString()}
364 />
365 <MetricCard
366 label="Preferred engine"
367 value={metrics.mostUsedEngine || "—"}
368 />
369 <MetricCard
370 label="Correction acceptance"
371 value={acceptanceRate + "%"}
372 sub={
373 metrics.correctionsApplied +
374 " accepted · " +
375 metrics.correctionsRejected +
376 " rejected"
377 }
378 />
379 <div className="md:col-span-2 rounded-lg border border-surface-300/50 bg-surface-100/40 p-4">
380 <div className="flex items-center justify-between mb-3">
381 <span className="text-[11px] uppercase tracking-wide-caps text-surface-600">
382 Last 14 days
383 </span>
384 <span className="text-[11px] text-surface-600">
385 {last14.reduce((s, d) => s + d.count, 0)} sessions
386 </span>
387 </div>
388 <div className="flex items-end gap-1.5 h-28">
389 {last14.map((d) => {
390 const h = (d.count / max) * 100;
391 return (
392 <div
393 key={d.day}
394 className="flex-1 flex flex-col items-center gap-1"
395 title={d.day + ": " + d.count}
396 >
397 <div
398 className="w-full rounded-sm bg-gradient-to-t from-marcoreid-700 to-brass-400/70 min-h-[2px]"
399 style={{ height: h + "%" }}
400 />
401 </div>
402 );
403 })}
404 </div>
405 </div>
406 </div>
407 );
408}
409
410function MetricCard({
411 label,
412 value,
413 sub,
414}: {
415 label: string;
416 value: string;
417 sub?: string;
418}) {
419 return (
420 <div className="rounded-lg border border-surface-300/50 bg-surface-100/40 p-4">
421 <div className="text-[11px] uppercase tracking-wide-caps text-surface-600">
422 {label}
423 </div>
424 <div className="mt-1 text-2xl font-bold text-surface-950 tracking-tight">
425 {value}
426 </div>
427 {sub && <div className="text-[11px] text-surface-600 mt-0.5">{sub}</div>}
428 </div>
429 );
430}
431
432function EmptyState({
433 icon,
434 title,
435 description,
436}: {
437 icon: React.ReactNode;
438 title: string;
439 description: string;
440}) {
441 return (
442 <div className="h-full w-full flex flex-col items-center justify-center text-center p-10 text-surface-600">
443 <div className="w-12 h-12 rounded-xl bg-surface-200 flex items-center justify-center mb-3">
444 {icon}
445 </div>
446 <div className="text-sm font-medium text-surface-900">{title}</div>
447 <p className="text-xs mt-1 max-w-md">{description}</p>
448 </div>
449 );
450}
Addedsrc/components/layout/ShortcutsCheatsheet.tsx+190−0View fileUnifiedSplit
@@ -0,0 +1,190 @@
1import { useEffect, useState } from "react";
2import { Keyboard, X } from "lucide-react";
3import { useSettingsStore } from "@/stores/settings";
4import { VOICE_COMMANDS } from "@/lib/constants";
5
6/**
7 * Global keyboard shortcut cheatsheet. Open with `?` or `Ctrl/Cmd+/`
8 * from anywhere in the app. Reads live shortcut bindings from settings
9 * so re-maps stay accurate.
10 */
11export function ShortcutsCheatsheet() {
12 const [open, setOpen] = useState(false);
13
14 const shortcutToggle = useSettingsStore((s) => s.shortcutToggle);
15 const shortcutPushToTalk = useSettingsStore((s) => s.shortcutPushToTalk);
16 const shortcutCancel = useSettingsStore((s) => s.shortcutCancel);
17 const shortcutCorrectGrammar = useSettingsStore(
18 (s) => s.shortcutCorrectGrammar
19 );
20
21 useEffect(() => {
22 const onKey = (e: KeyboardEvent) => {
23 // Avoid hijacking typing in inputs/textareas/contentEditable.
24 const target = e.target as HTMLElement | null;
25 const inEditable =
26 target &&
27 (target.tagName === "INPUT" ||
28 target.tagName === "TEXTAREA" ||
29 target.isContentEditable);
30
31 if (e.key === "Escape" && open) {
32 e.preventDefault();
33 setOpen(false);
34 return;
35 }
36 if (inEditable) return;
37
38 const isHelp =
39 (e.key === "?" && !e.ctrlKey && !e.metaKey) ||
40 ((e.ctrlKey || e.metaKey) && e.key === "/");
41
42 if (isHelp) {
43 e.preventDefault();
44 setOpen((v) => !v);
45 }
46 };
47 window.addEventListener("keydown", onKey);
48 return () => window.removeEventListener("keydown", onKey);
49 }, [open]);
50
51 if (!open) return null;
52
53 const globalShortcuts: Array<{ label: string; keys: string }> = [
54 { label: "Toggle dictation", keys: shortcutToggle },
55 { label: "Push-to-talk (hold)", keys: shortcutPushToTalk },
56 { label: "Cancel / stop", keys: shortcutCancel },
57 { label: "Correct grammar on last segment", keys: shortcutCorrectGrammar },
58 ].filter((s) => !!s.keys);
59
60 const appShortcuts: Array<{ label: string; keys: string }> = [
61 { label: "Open this cheatsheet", keys: "? or Ctrl/Cmd+/" },
62 { label: "Close dialog", keys: "Esc" },
63 ];
64
65 return (
66 <div
67 role="dialog"
68 aria-modal="true"
69 aria-label="Keyboard shortcuts"
70 className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
71 onClick={() => setOpen(false)}
72 >
73 <div
74 className="w-full max-w-3xl max-h-[85vh] overflow-auto rounded-xl border border-surface-300/60 bg-surface-50 shadow-2xl"
75 onClick={(e) => e.stopPropagation()}
76 >
77 <div className="flex items-center justify-between px-6 py-4 border-b border-surface-300/50 sticky top-0 bg-surface-50/95 backdrop-blur">
78 <div className="flex items-center gap-3">
79 <div className="flex items-center justify-center w-9 h-9 rounded-lg bg-surface-200">
80 <Keyboard className="h-4 w-4 text-surface-700" />
81 </div>
82 <div>
83 <h2 className="text-base font-bold text-surface-950">
84 Keyboard shortcuts
85 </h2>
86 <p className="text-[11px] text-surface-600">
87 Press <KeyHint>?</KeyHint> any time to open this list.
88 </p>
89 </div>
90 </div>
91 <button
92 type="button"
93 onClick={() => setOpen(false)}
94 className="text-surface-600 hover:text-surface-900 transition-colors p-1"
95 aria-label="Close"
96 >
97 <X className="h-4 w-4" />
98 </button>
99 </div>
100
101 <div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
102 <Section title="Global">
103 {globalShortcuts.map((row) => (
104 <Row key={row.label} label={row.label} keys={row.keys} />
105 ))}
106 </Section>
107 <Section title="App">
108 {appShortcuts.map((row) => (
109 <Row key={row.label} label={row.label} keys={row.keys} />
110 ))}
111 </Section>
112 <Section title="Voice commands" className="md:col-span-2">
113 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-1.5">
114 {VOICE_COMMANDS.map((c) => (
115 <div
116 key={c.command}
117 className="flex items-center justify-between text-sm"
118 >
119 <span className="text-surface-600 font-mono text-[12px]">
120 "{c.command}"
121 </span>
122 <span className="text-surface-900 truncate ml-3">
123 {c.description}
124 </span>
125 </div>
126 ))}
127 </div>
128 </Section>
129 </div>
130 </div>
131 </div>
132 );
133}
134
135function Section({
136 title,
137 className,
138 children,
139}: {
140 title: string;
141 className?: string;
142 children: React.ReactNode;
143}) {
144 return (
145 <section className={className}>
146 <div className="text-[10px] font-medium uppercase tracking-wide-caps text-surface-600 mb-2">
147 {title}
148 </div>
149 <div className="space-y-1.5">{children}</div>
150 </section>
151 );
152}
153
154function Row({ label, keys }: { label: string; keys: string }) {
155 return (
156 <div className="flex items-center justify-between gap-4 py-1">
157 <span className="text-sm text-surface-900">{label}</span>
158 <KeyHint>{humanizeBinding(keys)}</KeyHint>
159 </div>
160 );
161}
162
163function KeyHint({ children }: { children: React.ReactNode }) {
164 return (
165 <kbd className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-surface-300/70 bg-surface-100 text-[11px] text-surface-900 font-mono shadow-inset-hairline">
166 {children}
167 </kbd>
168 );
169}
170
171function humanizeBinding(binding: string): string {
172 if (!binding) return "—";
173 const isMac =
174 typeof navigator !== "undefined" &&
175 /Mac|iPhone|iPad/.test(navigator.platform);
176 return binding
177 .split("+")
178 .map((part) => {
179 const p = part.trim();
180 if (p === "CommandOrControl") return isMac ? "⌘" : "Ctrl";
181 if (p === "Command") return "⌘";
182 if (p === "Control") return "Ctrl";
183 if (p === "Shift") return isMac ? "⇧" : "Shift";
184 if (p === "Alt" || p === "Option") return isMac ? "⌥" : "Alt";
185 if (p === "Escape") return "Esc";
186 if (p === "Space") return "Space";
187 return p;
188 })
189 .join(" + ");
190}
Modifiedsrc/components/layout/Sidebar.tsx+9−1View fileUnifiedSplit
@@ -4,7 +4,9 @@ import {
44 SpellCheck,
55 History,
66 ShieldCheck,
7 Brain,
78} from "lucide-react";
9import { APP_VERSION } from "@/lib/constants";
810import { cn } from "@/lib/utils";
911
1012interface SidebarProps {
@@ -31,6 +33,12 @@ const navItems = [
3133 icon: History,
3234 description: "Past sessions",
3335 },
36 {
37 id: "flywheel",
38 label: "Flywheel",
39 icon: Brain,
40 description: "Local learning",
41 },
3442];
3543
3644const bottomItems = [
@@ -147,7 +155,7 @@ export function Sidebar({ activeView, onViewChange }: SidebarProps) {
147155 </span>
148156 </div>
149157 <span className="text-[10px] text-surface-600 font-mono block mt-0.5">
150 v1.0.0
158 v{APP_VERSION}
151159 </span>
152160 </div>
153161 </div>
Modifiedsrc/components/settings/AdminPanel.tsx+1−1View fileUnifiedSplit
@@ -339,7 +339,7 @@ export function AdminPanel() {
339339 })}
340340 </div>
341341 <p className="text-[10.5px] text-surface-600 leading-snug mt-2 italic font-display">
342 Governed by the laws of New Zealand. Questions: legal.com.
342 Governed by the laws of New Zealand. Questions: legal@voxlen.ai.
343343 </p>
344344 </div>
345345
Modifiedsrc/components/settings/SettingsPanel.tsx+117−2View fileUnifiedSplit
@@ -59,7 +59,10 @@ function useSettingsPersistence() {
5959 preserveTone: settings.preserveTone,
6060 autoPunctuate: settings.autoPunctuate,
6161 smartFormat: settings.smartFormat,
62 speakerDiarization: settings.speakerDiarization,
6263 voiceCommandsEnabled: settings.voiceCommandsEnabled,
64 translationEnabled: settings.translationEnabled,
65 translationTargetLanguage: settings.translationTargetLanguage,
6366 injectionMode: settings.injectionMode,
6467 shortcutToggle: settings.shortcutToggle,
6568 shortcutPushToTalk: settings.shortcutPushToTalk,
@@ -78,7 +81,7 @@ function useSettingsPersistence() {
7881 } catch {
7982 try {
8083 localStorage.setItem(
81 "voxlen_settings",
84 "marcoreid_settings",
8285 JSON.stringify({
8386 preferredDeviceId: settings.preferredDeviceId,
8487 sttEngine: settings.sttEngine,
@@ -500,6 +503,118 @@ function SttSettings() {
500503 onChange={(v) => settings.updateSetting("smartFormat", v)}
501504 />
502505 </SettingRow>
506
507 <SettingRow>
508 <Switch
509 label="Speaker Diarization"
510 description="Label each speaker separately in multi-person recordings (Deepgram only)."
511 checked={settings.speakerDiarization}
512 onChange={(v) => settings.updateSetting("speakerDiarization", v)}
513 />
514 </SettingRow>
515
516 <SettingRow>
517 <Switch
518 label="Real-Time Translation"
519 description="Translate transcribed text into the selected language using your grammar API key."
520 checked={settings.translationEnabled}
521 onChange={(v) => settings.updateSetting("translationEnabled", v)}
522 />
523 </SettingRow>
524
525 {settings.translationEnabled && (
526 <SettingRow>
527 <Select
528 label="Translate Into"
529 value={settings.translationTargetLanguage}
530 onChange={(v) => settings.updateSetting("translationTargetLanguage", v)}
531 options={SUPPORTED_LANGUAGES.map((l) => ({
532 value: l.code,
533 label: `${l.flag} ${l.name}`,
534 }))}
535 />
536 </SettingRow>
537 )}
538
539 <SettingRow>
540 <CustomVocabularyEditor />
541 </SettingRow>
542 </div>
543 );
544}
545
546function CustomVocabularyEditor() {
547 const vocabulary = useSettingsStore((s) => s.customVocabulary);
548 const updateSetting = useSettingsStore((s) => s.updateSetting);
549 const [draft, setDraft] = useState("");
550
551 const add = () => {
552 const term = draft.trim();
553 if (!term) return;
554 if (vocabulary.includes(term)) {
555 setDraft("");
556 return;
557 }
558 updateSetting("customVocabulary", [...vocabulary, term]);
559 setDraft("");
560 };
561
562 const remove = (term: string) => {
563 updateSetting(
564 "customVocabulary",
565 vocabulary.filter((t) => t !== term)
566 );
567 };
568
569 return (
570 <div>
571 <p className="text-sm font-medium text-surface-900 tracking-tight">
572 Custom Vocabulary
573 </p>
574 <p className="text-[11px] text-surface-600 mt-0.5 leading-snug mb-3">
575 Boost recognition of names, legal terms, medication names, and
576 industry-specific jargon. Sent with every Deepgram request; no
577 document content ever leaves your device.
578 </p>
579
580 <div className="flex gap-2">
581 <input
582 type="text"
583 value={draft}
584 onChange={(e) => setDraft(e.target.value)}
585 onKeyDown={(e) => {
586 if (e.key === "Enter") {
587 e.preventDefault();
588 add();
589 }
590 }}
591 placeholder='e.g. "Alecrae", "Estoppel", "NetSuite"'
592 className="flex-1 rounded-md border border-surface-300/70 bg-surface-50 px-3 py-1.5 text-sm text-surface-900 shadow-inset-hairline focus:border-brass-400 focus:outline-none focus:ring-1 focus:ring-brass-400/40"
593 />
594 <Button size="sm" variant="secondary" onClick={add} disabled={!draft.trim()}>
595 Add
596 </Button>
597 </div>
598
599 {vocabulary.length > 0 && (
600 <div className="mt-3 flex flex-wrap gap-1.5">
601 {vocabulary.map((term) => (
602 <span
603 key={term}
604 className="group inline-flex items-center gap-1.5 rounded-full bg-surface-100/80 border border-surface-300/70 px-2.5 py-0.5 text-[11px] font-mono text-surface-800 shadow-inset-hairline"
605 >
606 {term}
607 <button
608 onClick={() => remove(term)}
609 className="text-surface-500 hover:text-red-500 transition-colors"
610 aria-label={`Remove ${term}`}
611 >
612 ×
613 </button>
614 </span>
615 ))}
616 </div>
617 )}
503618 </div>
504619 );
505620}
@@ -686,7 +801,7 @@ function AppearanceSettings() {
686801
687802 return (
688803 <div className="space-y-6 max-w-lg">
689 <SectionHeader title="Appearance" description="Customize how Voxlen looks." />
804 <SectionHeader title="Appearance" description="Customize how Marco Reid Voice looks." />
690805
691806 <SettingRow>
692807 <Select
Modifiedsrc/hooks/usePersistedSettings.ts+3−3View fileUnifiedSplit
@@ -17,7 +17,7 @@ export function usePersistedSettings() {
1717 } catch {
1818 // Fallback to localStorage
1919 try {
20 const saved = localStorage.getItem("marcoreid_settings");
20 const saved = localStorage.getItem("voxlen_settings");
2121 if (saved) {
2222 const parsed = JSON.parse(saved) as Partial<AppSettings>;
2323 updateSettings(parsed);
@@ -41,8 +41,8 @@ export async function saveSettings(settings: Partial<AppSettings>) {
4141 await store.save();
4242 } catch {
4343 try {
44 const existing = JSON.parse(localStorage.getItem("marcoreid_settings") || "{}");
45 localStorage.setItem("marcoreid_settings", JSON.stringify({ ...existing, ...settings }));
44 const existing = JSON.parse(localStorage.getItem("voxlen_settings") || "{}");
45 localStorage.setItem("voxlen_settings", JSON.stringify({ ...existing, ...settings }));
4646 } catch {
4747 // Ignore
4848 }
Modifiedsrc/hooks/useTauriEvents.ts+37−3View fileUnifiedSplit
@@ -3,6 +3,7 @@ import { useAudioStore } from "@/stores/audio";
33import { useDictationStore, buildSessionRecord } from "@/stores/dictation";
44import { useSettingsStore } from "@/stores/settings";
55import { processVoiceCommands, executeVoiceCommand, applyTextCommand } from "@/lib/voiceCommands";
6import { applySmartFormat } from "@/lib/smartFormat";
67
78interface TranscriptionEvent {
89 text: string;
@@ -121,12 +122,15 @@ export function useTauriEvents(): void {
121122 }
122123 }
123124
124 // Regular transcription: honor capsLock.
125 // Regular transcription: apply smart formatting (if enabled)
126 // and honour capsLock.
127 const shaped = settings.smartFormat ? applySmartFormat(text) : text;
125128 const capsLock = useDictationStore.getState().capsLock;
126 const finalText = capsLock ? text.toUpperCase() : text;
129 const finalText = capsLock ? shaped.toUpperCase() : shaped;
127130
131 const segmentId = crypto.randomUUID();
128132 dictation.addSegment({
129 id: crypto.randomUUID(),
133 id: segmentId,
130134 text: finalText,
131135 timestamp: new Date(),
132136 confidence: result.confidence,
@@ -135,6 +139,36 @@ export function useTauriEvents(): void {
135139 grammarApplied: false,
136140 });
137141 dictation.setCurrentTranscript("");
142
143 // Optional real-time translation. We drop this into `correctedText`
144 // so the original transcription is preserved and the UI can still
145 // show both if needed.
146 if (
147 settings.translationEnabled &&
148 settings.translationTargetLanguage &&
149 settings.translationTargetLanguage !== (result.language ?? "")
150 ) {
151 (async () => {
152 try {
153 const { invoke } = await import("@tauri-apps/api/core");
154 const translation = await invoke<{ translated: string }>(
155 "translate_text",
156 {
157 text: finalText,
158 targetLanguage: settings.translationTargetLanguage,
159 }
160 );
161 if (translation?.translated) {
162 useDictationStore.getState().updateSegment(segmentId, {
163 translatedText: translation.translated,
164 translatedToLanguage: settings.translationTargetLanguage,
165 });
166 }
167 } catch {
168 // Non-Tauri or translation failure — leave segment as-is.
169 }
170 })();
171 }
138172 }
139173 );
140174
Addedsrc/lib/export.test.ts+89−0View fileUnifiedSplit
@@ -0,0 +1,89 @@
1import { describe, it, expect } from "vitest";
2import { exportTranscript } from "./export";
3import type { TranscriptionSegment } from "@/stores/dictation";
4
5function seg(
6 text: string,
7 correctedText?: string,
8 ts = new Date("2026-04-20T10:00:00Z"),
9 grammarApplied = false,
10 translatedText?: string,
11 translatedToLanguage?: string
12): TranscriptionSegment {
13 return {
14 id: "id-" + text,
15 text,
16 correctedText,
17 translatedText,
18 translatedToLanguage,
19 timestamp: ts,
20 confidence: 0.92,
21 isFinal: true,
22 grammarApplied,
23 };
24}
25
26describe("exportTranscript", () => {
27 it("txt: joins segments, preferring corrected text when present", () => {
28 const out = exportTranscript(
29 [seg("hello", "hello."), seg("world")],
30 "txt"
31 );
32 expect(out.content).toBe("hello. world");
33 expect(out.filename).toMatch(/^voxlen-transcript-.*\.txt$/);
34 expect(out.mimeType).toBe("text/plain");
35 });
36
37 it("md: emits heading, metadata, per-segment lines with grammar tag", () => {
38 const out = exportTranscript(
39 [seg("first"), seg("second", "second clean", undefined, true)],
40 "md"
41 );
42 expect(out.content).toContain("# Voxlen Transcript");
43 expect(out.content).toContain("**Segments:** 2");
44 expect(out.content).toContain("*(AI polished)*");
45 expect(out.mimeType).toBe("text/markdown");
46 });
47
48 it("json: is valid JSON with the expected shape", () => {
49 const out = exportTranscript([seg("hi", "hi!")], "json");
50 const parsed = JSON.parse(out.content);
51 expect(parsed.app).toBe("Voxlen");
52 expect(parsed.segments).toHaveLength(1);
53 expect(parsed.segments[0].text).toBe("hi");
54 expect(parsed.segments[0].correctedText).toBe("hi!");
55 expect(out.mimeType).toBe("application/json");
56 });
57
58 it("srt: numbers segments and formats timestamps", () => {
59 const out = exportTranscript([seg("one"), seg("two")], "srt");
60 expect(out.content).toMatch(/^1\n\d\d:\d\d:\d\d,000 --> \d\d:\d\d:\d\d,000\none/);
61 expect(out.content).toContain("2\n");
62 expect(out.mimeType).toBe("text/srt");
63 });
64
65 it("unknown format falls back to txt", () => {
66 // @ts-expect-error - intentionally wrong
67 const out = exportTranscript([seg("ok")], "docx");
68 expect(out.filename).toMatch(/\.txt$/);
69 });
70
71 it("md: renders translations as blockquote under the segment", () => {
72 const out = exportTranscript(
73 [seg("hello", undefined, undefined, false, "hola", "es")],
74 "md"
75 );
76 expect(out.content).toContain("hello");
77 expect(out.content).toContain("> hola (es)");
78 });
79
80 it("json: exposes translatedText + translatedToLanguage", () => {
81 const out = exportTranscript(
82 [seg("hi", undefined, undefined, false, "bonjour", "fr")],
83 "json"
84 );
85 const parsed = JSON.parse(out.content);
86 expect(parsed.segments[0].translatedText).toBe("bonjour");
87 expect(parsed.segments[0].translatedToLanguage).toBe("fr");
88 });
89});
Modifiedsrc/lib/export.ts+8−2View fileUnifiedSplit
@@ -48,7 +48,7 @@ function formatAsText(segments: TranscriptionSegment[]): string {
4848
4949function formatAsMarkdown(segments: TranscriptionSegment[]): string {
5050 const lines = [
51 "# Voxlen Transcript",
51 "# Marco Reid Voice Transcript",
5252 "",
5353 `**Date:** ${new Date().toLocaleDateString()}`,
5454 `**Words:** ${segments.reduce((c, s) => c + (s.correctedText || s.text).split(/\s+/).filter(Boolean).length, 0)}`,
@@ -63,6 +63,10 @@ function formatAsMarkdown(segments: TranscriptionSegment[]): string {
6363 const text = s.correctedText || s.text;
6464 const grammarTag = s.grammarApplied ? " *(AI polished)*" : "";
6565 lines.push(`**[${time}]** ${text}${grammarTag}`);
66 if (s.translatedText) {
67 const lang = s.translatedToLanguage ? ` (${s.translatedToLanguage})` : "";
68 lines.push(`> ${s.translatedText}${lang}`);
69 }
6670 lines.push("");
6771 });
6872
@@ -73,12 +77,14 @@ function formatAsJson(segments: TranscriptionSegment[]): string {
7377 return JSON.stringify(
7478 {
7579 version: "1.0",
76 app: "Voxlen",
80 app: "Marco Reid Voice",
7781 exported: new Date().toISOString(),
7882 segments: segments.map((s) => ({
7983 id: s.id,
8084 text: s.text,
8185 correctedText: s.correctedText || null,
86 translatedText: s.translatedText || null,
87 translatedToLanguage: s.translatedToLanguage || null,
8288 timestamp: s.timestamp.toISOString(),
8389 confidence: s.confidence,
8490 language: s.language || null,
Modifiedsrc/lib/settings.ts+5−2View fileUnifiedSplit
@@ -17,6 +17,7 @@ export interface BackendAppSettings {
1717 stt_language: string;
1818 auto_detect_language: boolean;
1919 custom_vocabulary: string[];
20 speaker_diarization: boolean;
2021
2122 // Grammar
2223 grammar_enabled: boolean;
@@ -67,6 +68,7 @@ export function toBackendSettings(s: AppSettings): BackendAppSettings {
6768 stt_language: s.sttLanguage,
6869 auto_detect_language: s.autoDetectLanguage,
6970 custom_vocabulary: s.customVocabulary,
71 speaker_diarization: s.speakerDiarization,
7072
7173 grammar_enabled: s.grammarEnabled,
7274 grammar_api_key: s.grammarApiKey,
@@ -116,6 +118,7 @@ export function fromBackendSettings(
116118 if (s.stt_language !== undefined) out.sttLanguage = s.stt_language;
117119 if (s.auto_detect_language !== undefined) out.autoDetectLanguage = s.auto_detect_language;
118120 if (s.custom_vocabulary !== undefined) out.customVocabulary = s.custom_vocabulary;
121 if (s.speaker_diarization !== undefined) out.speakerDiarization = s.speaker_diarization;
119122
120123 if (s.grammar_enabled !== undefined) out.grammarEnabled = s.grammar_enabled;
121124 if (s.grammar_api_key !== undefined) out.grammarApiKey = s.grammar_api_key;
@@ -158,7 +161,7 @@ export async function persistSettings(settings: AppSettings): Promise<void> {
158161 } catch {
159162 // Non-Tauri environment — best-effort localStorage fallback.
160163 try {
161 localStorage.setItem("marcoreid_settings", JSON.stringify(settings));
164 localStorage.setItem("voxlen_settings", JSON.stringify(settings));
162165 } catch {
163166 // Ignore
164167 }
@@ -176,7 +179,7 @@ export async function loadSettings(): Promise<Partial<AppSettings> | undefined>
176179 return fromBackendSettings(backend);
177180 } catch {
178181 try {
179 const raw = localStorage.getItem("marcoreid_settings");
182 const raw = localStorage.getItem("voxlen_settings");
180183 if (raw) return JSON.parse(raw) as Partial<AppSettings>;
181184 } catch {
182185 // Ignore
Addedsrc/lib/smartFormat.test.ts+133−0View fileUnifiedSplit
@@ -0,0 +1,133 @@
1import { describe, it, expect } from "vitest";
2import { applySmartFormat } from "./smartFormat";
3
4describe("applySmartFormat", () => {
5 describe("emails", () => {
6 it("converts 'name at host dot tld' to an email", () => {
7 expect(applySmartFormat("send to alice at example dot com today")).toBe(
8 "send to alice@example.com today"
9 );
10 });
11
12 it("handles multi-segment domains", () => {
13 expect(
14 applySmartFormat("hello bob at mail dot example dot co dot uk")
15 ).toBe("hello bob@mail.example.co.uk");
16 });
17
18 it("leaves standalone 'at' alone when not email-shaped", () => {
19 expect(applySmartFormat("meet at the office")).toBe(
20 "meet at the office"
21 );
22 });
23 });
24
25 describe("urls", () => {
26 it("collapses 'w w w' to 'www' and joins 'dot' separators", () => {
27 expect(applySmartFormat("visit w w w dot voxlen dot ai")).toBe(
28 "visit www.voxlen.ai"
29 );
30 });
31
32 it("expands spoken scheme", () => {
33 expect(
34 applySmartFormat("go to https colon slash slash voxlen dot ai")
35 ).toBe("go to https://voxlen.ai");
36 });
37 });
38
39 describe("social", () => {
40 it("expands 'hashtag foo' to '#foo'", () => {
41 expect(applySmartFormat("tag as hashtag productivity please")).toBe(
42 "tag as #productivity please"
43 );
44 });
45
46 it("expands 'at username alice' to '@alice'", () => {
47 expect(applySmartFormat("message at username alice now")).toBe(
48 "message @alice now"
49 );
50 });
51 });
52
53 describe("digit runs", () => {
54 it("collapses a run of spoken digits into a numeric block", () => {
55 expect(applySmartFormat("reference two one three four five")).toBe(
56 "reference 21345"
57 );
58 });
59
60 it("leaves short runs alone (< 3 digits)", () => {
61 expect(applySmartFormat("see page two")).toBe("see page two");
62 });
63
64 it("treats 'oh' as zero inside digit runs", () => {
65 expect(applySmartFormat("dial oh two one five nine")).toBe(
66 "dial 02159"
67 );
68 });
69 });
70
71 describe("markdown leaders", () => {
72 it("turns 'heading one' into '# '", () => {
73 expect(applySmartFormat("heading one introduction")).toBe(
74 "# introduction"
75 );
76 });
77
78 it("turns 'heading two' into '## '", () => {
79 expect(applySmartFormat("heading two overview")).toBe("## overview");
80 });
81
82 it("turns 'bullet point' into '- '", () => {
83 expect(applySmartFormat("bullet point first item")).toBe(
84 "- first item"
85 );
86 });
87
88 it("turns 'block quote' into '> '", () => {
89 expect(applySmartFormat("block quote this is important")).toBe(
90 "> this is important"
91 );
92 });
93 });
94
95 describe("inline markdown wrappers", () => {
96 it("wraps 'bold ... end bold' in **", () => {
97 expect(applySmartFormat("this is bold critical end bold point")).toBe(
98 "this is **critical** point"
99 );
100 });
101
102 it("wraps 'italic ... end italic' in *", () => {
103 expect(applySmartFormat("italic emphasis end italic here")).toBe(
104 "*emphasis* here"
105 );
106 });
107
108 it("wraps 'code ... end code' in backticks", () => {
109 expect(applySmartFormat("run code npm test end code now")).toBe(
110 "run `npm test` now"
111 );
112 });
113 });
114
115 describe("options", () => {
116 it("can disable individual transforms", () => {
117 expect(
118 applySmartFormat("alice at example dot com", { emails: false })
119 ).toContain("at example");
120 });
121 });
122
123 describe("edge cases", () => {
124 it("returns empty string unchanged", () => {
125 expect(applySmartFormat("")).toBe("");
126 });
127
128 it("is idempotent on already-formatted text", () => {
129 const input = "email me at alice@example.com";
130 expect(applySmartFormat(input)).toBe(input);
131 });
132 });
133});
Addedsrc/lib/smartFormat.ts+170−0View fileUnifiedSplit
@@ -0,0 +1,170 @@
1/**
2 * Smart formatting pass for transcribed text.
3 *
4 * Runs AFTER voice-command extraction and BEFORE grammar correction.
5 * Converts common spoken patterns into their written form:
6 * "alice at example dot com" → "alice@example.com"
7 * "w w w dot example dot com" → "www.example.com"
8 * "https colon slash slash foo" → "https://foo"
9 * "hashtag productivity" → "#productivity"
10 * "at username alice" → "@alice"
11 * "two one three four five six" → "213456" (run of spoken digits)
12 * "heading one introduction" → "# introduction"
13 *
14 * All rules are pure string transforms. No external data sources —
15 * privacy-safe for lawyer/accountant content. Callers can disable
16 * the whole pass via the `smartFormat` setting.
17 */
18
19const DIGIT_WORDS: Record<string, string> = {
20 zero: "0",
21 one: "1",
22 two: "2",
23 three: "3",
24 four: "4",
25 five: "5",
26 six: "6",
27 seven: "7",
28 eight: "8",
29 nine: "9",
30 oh: "0",
31};
32
33/** Collapse runs of three or more spoken digits into a numeric string. */
34function collapseDigitRuns(input: string): string {
35 const words = input.split(/\s+/);
36 const out: string[] = [];
37 let run: string[] = [];
38
39 const flush = () => {
40 if (run.length >= 3) {
41 out.push(run.map((t) => DIGIT_WORDS[t.toLowerCase()]).join(""));
42 } else {
43 out.push(...run);
44 }
45 run = [];
46 };
47
48 for (const word of words) {
49 const key = word.replace(/[.,!?;:]+$/g, "").toLowerCase();
50 if (key && DIGIT_WORDS[key] !== undefined) {
51 run.push(word);
52 } else {
53 if (run.length) flush();
54 out.push(word);
55 }
56 }
57 if (run.length) flush();
58 return out.join(" ");
59}
60
61/** Email addresses spoken as "X at Y dot Z". */
62function formatEmails(input: string): string {
63 // word "at" between two word-ish tokens → '@'; propagate "dot" → '.' on both sides.
64 const re =
65 /([A-Za-z0-9._%+-]+)\s+at\s+([A-Za-z0-9-]+(?:\s+dot\s+[A-Za-z0-9-]+)+)/gi;
66 return input.replace(re, (_m, local: string, domain: string) => {
67 const dom = domain.replace(/\s+dot\s+/gi, ".");
68 return local + "@" + dom;
69 });
70}
71
72/** URLs spoken as "w w w dot foo dot com" or "https colon slash slash foo dot com". */
73function formatUrls(input: string): string {
74 // "w w w" / "www" → "www"
75 let out = input.replace(/\bw\s+w\s+w\b/gi, "www");
76
77 // "https colon slash slash" → "https://" (swallow trailing whitespace so
78 // the next host token sits flush against the scheme).
79 out = out.replace(/\b(https?)\s+colon\s+slash\s+slash\s*/gi, (_m, scheme) =>
80 scheme.toLowerCase() + "://"
81 );
82
83 // "hostname dot tld" runs — hostname-ish tokens separated by "dot"
84 out = out.replace(
85 /\b([A-Za-z0-9-]+(?:\s+dot\s+[A-Za-z0-9-]+){1,})\b/gi,
86 (m) => {
87 // Avoid double-joining when we already emitted an email (contains '@').
88 if (m.includes("@")) return m;
89 return m.replace(/\s+dot\s+/gi, ".");
90 }
91 );
92 return out;
93}
94
95/** Hashtags and mentions. */
96function formatSocial(input: string): string {
97 let out = input.replace(/\bhashtag\s+([A-Za-z0-9_]+)/gi, "#$1");
98 out = out.replace(/\bat\s+username\s+([A-Za-z0-9_]+)/gi, "@$1");
99 return out;
100}
101
102/** Markdown-ish dictation commands. Triggered only at the start of a segment. */
103function formatMarkdown(input: string): string {
104 const trimmed = input.trimStart();
105 const leading = input.slice(0, input.length - trimmed.length);
106
107 const pairs: Array<[RegExp, string]> = [
108 [/^heading\s+one[:\-]?\s+/i, "# "],
109 [/^heading\s+two[:\-]?\s+/i, "## "],
110 [/^heading\s+three[:\-]?\s+/i, "### "],
111 [/^heading\s+four[:\-]?\s+/i, "#### "],
112 [/^bullet\s+point\s+/i, "- "],
113 [/^bullet\s+/i, "- "],
114 [/^numbered\s+item\s+/i, "1. "],
115 [/^block\s*quote\s+/i, "> "],
116 ];
117
118 for (const [re, rep] of pairs) {
119 if (re.test(trimmed)) {
120 return leading + trimmed.replace(re, rep);
121 }
122 }
123
124 // Inline wrappers anywhere in the string.
125 let out = input;
126 out = out.replace(
127 /\bbold\s+(.+?)\s+end\s+bold\b/gi,
128 (_m, inner: string) => "**" + inner.trim() + "**"
129 );
130 out = out.replace(
131 /\bitalic\s+(.+?)\s+end\s+italic\b/gi,
132 (_m, inner: string) => "*" + inner.trim() + "*"
133 );
134 out = out.replace(
135 /\bcode\s+(.+?)\s+end\s+code\b/gi,
136 (_m, inner: string) => "`" + inner.trim() + "`"
137 );
138 return out;
139}
140
141export interface SmartFormatOptions {
142 emails?: boolean;
143 urls?: boolean;
144 social?: boolean;
145 markdown?: boolean;
146 digitRuns?: boolean;
147}
148
149const defaultOptions: Required<SmartFormatOptions> = {
150 emails: true,
151 urls: true,
152 social: true,
153 markdown: true,
154 digitRuns: true,
155};
156
157export function applySmartFormat(
158 input: string,
159 opts: SmartFormatOptions = {}
160): string {
161 const o = { ...defaultOptions, ...opts };
162 if (!input) return input;
163 let out = input;
164 if (o.markdown) out = formatMarkdown(out);
165 if (o.digitRuns) out = collapseDigitRuns(out);
166 if (o.emails) out = formatEmails(out);
167 if (o.urls) out = formatUrls(out);
168 if (o.social) out = formatSocial(out);
169 return out;
170}
Addedsrc/lib/voiceCommands.test.ts+107−0View fileUnifiedSplit
@@ -0,0 +1,107 @@
1import { describe, it, expect, beforeEach, vi } from "vitest";
2
3// Zustand store imports side-effect; stub the dictation store so tests stay
4// pure and don't pull in Tauri/plugin-store code paths.
5vi.mock("@/stores/dictation", () => {
6 const state = {
7 segments: [] as Array<{ id: string; text: string }>,
8 status: "idle",
9 setStatus: vi.fn((s: string) => {
10 state.status = s;
11 }),
12 };
13 return {
14 useDictationStore: {
15 getState: () => state,
16 setState: (patch: Partial<typeof state>) => Object.assign(state, patch),
17 },
18 };
19});
20
21import { processVoiceCommands, executeVoiceCommand, applyTextCommand } from "./voiceCommands";
22import { useDictationStore } from "@/stores/dictation";
23
24describe("processVoiceCommands", () => {
25 it("matches exact command", () => {
26 const r = processVoiceCommands("new line");
27 expect(r.matched).toBe(true);
28 expect(r.action).toBe("insert_newline");
29 expect(r.remainingText).toBe("");
30 });
31
32 it("matches trailing command and extracts remainder", () => {
33 const r = processVoiceCommands("this is the text period");
34 expect(r.matched).toBe(true);
35 expect(r.action).toBe("insert_period");
36 expect(r.remainingText).toBe("this is the text");
37 });
38
39 it("matches leading command", () => {
40 const r = processVoiceCommands("new paragraph here we go");
41 expect(r.matched).toBe(true);
42 expect(r.action).toBe("insert_paragraph");
43 expect(r.remainingText).toBe("here we go");
44 });
45
46 it("treats unknown text as no match", () => {
47 const r = processVoiceCommands("a random sentence");
48 expect(r.matched).toBe(false);
49 expect(r.remainingText).toBe("a random sentence");
50 });
51
52 it("is case insensitive", () => {
53 const r = processVoiceCommands("DELETE THAT");
54 expect(r.matched).toBe(true);
55 expect(r.action).toBe("delete_last");
56 });
57
58 it("supports alternates for the same action", () => {
59 expect(processVoiceCommands("full stop").action).toBe("insert_period");
60 expect(processVoiceCommands("dot").action).toBe("insert_period");
61 expect(processVoiceCommands("scratch that").action).toBe("delete_last");
62 expect(processVoiceCommands("stop dictation").action).toBe("stop");
63 });
64});
65
66describe("executeVoiceCommand", () => {
67 it("returns punctuation output for insert_period", () => {
68 expect(executeVoiceCommand("insert_period")).toBe(".");
69 });
70
71 it("returns newline for insert_newline", () => {
72 expect(executeVoiceCommand("insert_newline")).toBe("\n");
73 });
74
75 it("returns tab and space", () => {
76 expect(executeVoiceCommand("insert_tab")).toBe("\t");
77 expect(executeVoiceCommand("insert_space")).toBe(" ");
78 });
79
80 it("returns null for state-only actions", () => {
81 expect(executeVoiceCommand("caps_on")).toBe(null);
82 expect(executeVoiceCommand("caps_off")).toBe(null);
83 expect(executeVoiceCommand("select_all")).toBe(null);
84 });
85
86 it("stop action sets idle status", () => {
87 executeVoiceCommand("stop");
88 expect(useDictationStore.getState().status).toBe("idle");
89 });
90});
91
92describe("applyTextCommand", () => {
93 beforeEach(() => {});
94
95 it("appends newline directly", () => {
96 expect(applyTextCommand("hello", "\n")).toBe("hello\n");
97 });
98
99 it("places punctuation flush against last word, then a space", () => {
100 expect(applyTextCommand("hello ", ".")).toBe("hello. ");
101 expect(applyTextCommand("hello", ",")).toBe("hello, ");
102 });
103
104 it("is a no-op when command output is null", () => {
105 expect(applyTextCommand("hello", null)).toBe("hello");
106 });
107});
Modifiedsrc/stores/dictation.ts+5−0View fileUnifiedSplit
@@ -11,6 +11,11 @@ export interface TranscriptionSegment {
1111 id: string;
1212 text: string;
1313 correctedText?: string;
14 /** Translation of this segment into `translatedToLanguage`.
15 * Independent from `correctedText` so grammar polish and translation
16 * can coexist on the same segment. */
17 translatedText?: string;
18 translatedToLanguage?: string;
1419 timestamp: Date;
1520 confidence: number;
1621 language?: string;
Modifiedsrc/stores/flywheel.ts+2−2View fileUnifiedSplit
@@ -207,7 +207,7 @@ export async function loadFlywheel(): Promise<void> {
207207 });
208208 } catch {
209209 try {
210 const saved = localStorage.getItem("voxlen_flywheel");
210 const saved = localStorage.getItem("marcoreid_flywheel");
211211 if (saved) {
212212 const parsed = JSON.parse(saved);
213213 useFlywheelStore.setState({
@@ -238,7 +238,7 @@ async function persistFlywheel(): Promise<void> {
238238 await store.save();
239239 } catch {
240240 try {
241 localStorage.setItem("voxlen_flywheel", JSON.stringify(data));
241 localStorage.setItem("marcoreid_flywheel", JSON.stringify(data));
242242 } catch {
243243 // Ignore
244244 }
Modifiedsrc/stores/history.ts+2−2View fileUnifiedSplit
@@ -51,7 +51,7 @@ export async function loadHistory(): Promise<void> {
5151 }
5252 } catch {
5353 try {
54 const saved = localStorage.getItem("voxlen_history");
54 const saved = localStorage.getItem("marcoreid_history");
5555 if (saved) {
5656 const parsed = JSON.parse(saved) as HistoryEntry[];
5757 useHistoryStore.setState({ entries: parsed });
@@ -70,7 +70,7 @@ async function persistHistory(entries: HistoryEntry[]): Promise<void> {
7070 await store.save();
7171 } catch {
7272 try {
73 localStorage.setItem("voxlen_history", JSON.stringify(entries));
73 localStorage.setItem("marcoreid_history", JSON.stringify(entries));
7474 } catch {
7575 // Ignore
7676 }
Modifiedsrc/stores/settings.ts+13−1View fileUnifiedSplit
@@ -13,6 +13,7 @@ export interface AppSettings {
1313 sttLanguage: string;
1414 autoDetectLanguage: boolean;
1515 customVocabulary: string[];
16 speakerDiarization: boolean;
1617
1718 // Grammar
1819 grammarEnabled: boolean;
@@ -27,6 +28,10 @@ export interface AppSettings {
2728 smartFormat: boolean;
2829 voiceCommandsEnabled: boolean;
2930
31 // Translation
32 translationEnabled: boolean;
33 translationTargetLanguage: string;
34
3035 // Text injection
3136 injectionMode: "keyboard" | "clipboard" | "buffer";
3237
@@ -75,6 +80,7 @@ const defaultSettings: AppSettings = {
7580 sttLanguage: "en",
7681 autoDetectLanguage: true,
7782 customVocabulary: [],
83 speakerDiarization: false,
7884
7985 grammarEnabled: true,
8086 grammarApiKey: "",
@@ -87,6 +93,9 @@ const defaultSettings: AppSettings = {
8793 smartFormat: true,
8894 voiceCommandsEnabled: true,
8995
96 translationEnabled: false,
97 translationTargetLanguage: "en",
98
9099 injectionMode: "keyboard",
91100
92101 shortcutToggle: "CommandOrControl+Shift+D",
@@ -123,6 +132,7 @@ function schedulePersist() {
123132 sttLanguage: state.sttLanguage,
124133 autoDetectLanguage: state.autoDetectLanguage,
125134 customVocabulary: state.customVocabulary,
135 speakerDiarization: state.speakerDiarization,
126136 grammarEnabled: state.grammarEnabled,
127137 grammarProvider: state.grammarProvider,
128138 writingStyle: state.writingStyle,
@@ -131,6 +141,8 @@ function schedulePersist() {
131141 autoPunctuate: state.autoPunctuate,
132142 smartFormat: state.smartFormat,
133143 voiceCommandsEnabled: state.voiceCommandsEnabled,
144 translationEnabled: state.translationEnabled,
145 translationTargetLanguage: state.translationTargetLanguage,
134146 injectionMode: state.injectionMode,
135147 shortcutToggle: state.shortcutToggle,
136148 shortcutPushToTalk: state.shortcutPushToTalk,
@@ -152,7 +164,7 @@ function schedulePersist() {
152164 await store.save();
153165 } catch {
154166 try {
155 localStorage.setItem("voxlen_settings", JSON.stringify(toSave));
167 localStorage.setItem("marcoreid_settings", JSON.stringify(toSave));
156168 } catch {
157169 // Ignore
158170 }
Modifiedsrc/styles/globals.css+1−1View fileUnifiedSplit
@@ -56,7 +56,7 @@
5656
5757@layer base {
5858 :root {
59 --voxlen-font-size: 14px;
59 --marcoreid-font-size: 14px;
6060 }
6161
6262 * {
Addedvitest.config.ts+16−0View fileUnifiedSplit
@@ -0,0 +1,16 @@
1import { defineConfig } from "vitest/config";
2import path from "node:path";
3
4export default defineConfig({
5 resolve: {
6 alias: {
7 "@": path.resolve(__dirname, "./src"),
8 },
9 },
10 test: {
11 environment: "node",
12 include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
13 globals: false,
14 reporters: "default",
15 },
16});
017
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts